diff --git a/src/bonsai/bonsai/bim/module/alignment/__init__.py b/src/bonsai/bonsai/bim/module/alignment/__init__.py index 3c49e0b4fe..10be0b44fa 100644 --- a/src/bonsai/bonsai/bim/module/alignment/__init__.py +++ b/src/bonsai/bonsai/bim/module/alignment/__init__.py @@ -17,11 +17,68 @@ # along with Bonsai. If not, see . import bpy +from bpy.app.handlers import persistent +from . import ui, prop, operator -# from . import ui, prop, operator -from . import operator -classes = (operator.ImportAlignmentCSV,) +@persistent +def on_undo_redo(scene): + """Handler called after undo/redo to sync PI Editor with IFC. + + When Blender undoes, both Blender properties and IFC state may change. + This handler syncs the PI Editor to reflect the current IFC state: + - If the active alignment still exists, extracts PI data from IFC segments + - If the alignment was deleted/invalidated, clears the PI Editor + - Rebuilds display_rows to match the synced state + """ + if not hasattr(scene, "SaikeiAlignmentProperties"): + return + props = scene.SaikeiAlignmentProperties + # Sync PI Editor from IFC to ensure consistency after undo/redo + operator.sync_pis_from_ifc(props) + + +classes = ( + # Property groups (must be registered before classes that use them) + prop.AlignmentPI, + prop.AlignmentSegmentItem, + prop.AlignmentDisplayRow, + prop.SaikeiAlignmentProperties, + # UILists + ui.SAIKEI_UL_alignment_pis, + operator.ImportAlignmentCSV, # Richard Brice + # Operators - PI Management + operator.SAIKEI_OT_add_pi, + operator.SAIKEI_OT_remove_pi, + operator.SAIKEI_OT_pick_pi_from_viewport, + operator.SAIKEI_OT_recalculate_pis, + operator.SAIKEI_OT_clear_pis, + # Operators - Creation + operator.SAIKEI_OT_create_alignment, + operator.SAIKEI_OT_create_alignment_by_pi, + operator.SAIKEI_OT_import_alignment_csv, + operator.SAIKEI_OT_create_alignment_polyline, + operator.SAIKEI_OT_create_alignment_offset, + # Operators - Layout + operator.SAIKEI_OT_add_vertical_layout, + operator.SAIKEI_OT_add_layout_segment, + operator.SAIKEI_OT_layout_horizontal_by_pi, + operator.SAIKEI_OT_layout_vertical_by_pi, + # Operators - Stationing + operator.SAIKEI_OT_add_stationing_referent, + operator.SAIKEI_OT_name_segments, + # Operators - Utilities + operator.SAIKEI_OT_create_representation, + operator.SAIKEI_OT_create_segment_representations, + operator.SAIKEI_OT_update_fallback_position, + operator.SAIKEI_OT_validate_segments, + operator.SAIKEI_OT_refresh_alignment_data, + # UI Panels + ui.SAIKEI_PT_horizontal_alignment, + ui.SAIKEI_PT_alignment_creation, + ui.SAIKEI_PT_pi_editor, + ui.SAIKEI_PT_alignment_stationing, +) def menu_func_import(self, context): @@ -29,8 +86,17 @@ def menu_func_import(self, context): def register(): + bpy.types.Scene.SaikeiAlignmentProperties = bpy.props.PointerProperty(type=prop.SaikeiAlignmentProperties) bpy.types.TOPBAR_MT_file_import.append(menu_func_import) + bpy.app.handlers.undo_post.append(on_undo_redo) + bpy.app.handlers.redo_post.append(on_undo_redo) def unregister(): bpy.types.TOPBAR_MT_file_import.remove(menu_func_import) + # Unregister handlers + if on_undo_redo in bpy.app.handlers.undo_post: + bpy.app.handlers.undo_post.remove(on_undo_redo) + if on_undo_redo in bpy.app.handlers.redo_post: + bpy.app.handlers.redo_post.remove(on_undo_redo) + del bpy.types.Scene.SaikeiAlignmentProperties diff --git a/src/bonsai/bonsai/bim/module/alignment/operator.py b/src/bonsai/bonsai/bim/module/alignment/operator.py index 58c139b639..1ad4610d05 100644 --- a/src/bonsai/bonsai/bim/module/alignment/operator.py +++ b/src/bonsai/bonsai/bim/module/alignment/operator.py @@ -23,11 +23,12 @@ import os import ifcopenshell.api.alignment import bpy +import math import json import time import calendar import isodate -import bonsai.core.sequence as core +import bonsai.core.alignment as core import bonsai.tool as tool import bonsai.bim.module.sequence.helper as helper import ifcopenshell.api.spatial @@ -37,6 +38,10 @@ import ifcopenshell.util.selector from datetime import datetime from dateutil import parser, relativedelta from bpy_extras.io_utils import ImportHelper +from bpy.types import Operator +from bpy.props import StringProperty, FloatProperty, IntProperty +from bpy_extras.io_utils import ImportHelper +import ifcopenshell.api.alignment class ImportAlignmentCSV(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): @@ -107,3 +112,1618 @@ class ImportAlignmentCSV(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): tool.Collector.assign(alignment_obj, should_clean_users_collection=False) self.report({"INFO"}, "Imported in %s seconds" % (time.time() - start)) + + +def poll_ifc4x3(cls, context): + """Standard poll method for IFC4X3 requirement""" + ifc = tool.Alignment.get_ifc_file() + if ifc is None: + cls.poll_message_set("No IFC file loaded. Open an IFC file via Bonsai.") + return False + if ifc.schema != "IFC4X3": + cls.poll_message_set(f"Schema is {ifc.schema}. Alignments require IFC4X3.") + return False + return True + + +def get_alignment_by_id(ifc, alignment_id): + """Safely get an alignment by ID, returning None if not found. + + This handles the case where the IFC entity no longer exists + (e.g., after undo or external modification). + """ + if alignment_id == 0: + return None + try: + entity = ifc.by_id(alignment_id) + # Verify it's actually an alignment + if entity and entity.is_a("IfcAlignment"): + return entity + return None + except RuntimeError: + # Entity not found in IFC file + return None + + +def clear_invalid_alignment_reference(props): + """Clear active alignment reference if it's invalid.""" + props.active_alignment_id = 0 + props.active_alignment_name = "" + + +def sync_pis_from_ifc(props): + """Sync PI Editor data from IFC alignment. + + This is called on undo/redo to ensure the PI Editor reflects the current + IFC state. It extracts PI data from the alignment's horizontal segments. + + If no active alignment exists or it's invalid, clears the PI Editor. + + Returns: + bool: True if sync was successful, False if alignment was cleared. + """ + ifc = tool.Alignment.get_ifc_file() + if ifc is None: + # No IFC file - clear everything + props.pis.clear() + props.active_pi_index = 0 + clear_invalid_alignment_reference(props) + rebuild_display_rows(props) + return False + + if props.active_alignment_id == 0: + # No active alignment - just rebuild display + rebuild_display_rows(props) + return True + + alignment = get_alignment_by_id(ifc, props.active_alignment_id) + if alignment is None: + # Alignment no longer exists - clear everything + props.pis.clear() + props.active_pi_index = 0 + clear_invalid_alignment_reference(props) + rebuild_display_rows(props) + return False + + # Alignment exists - extract PI data from IFC segments + h_layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment) + if not h_layout: + # No horizontal layout - rebuild display with current props + rebuild_display_rows(props) + return True + + segments = ifcopenshell.api.alignment.get_layout_segments(h_layout) + if not segments: + # No segments - rebuild display with current props + rebuild_display_rows(props) + return True + + # Extract PIs from segment data + # This reconstructs approximate PIs from the IFC segment geometry + extracted_pis = _extract_pis_from_segments(segments) + + if not extracted_pis: + # Couldn't extract - keep current props.pis + rebuild_display_rows(props) + return True + + # Update props.pis with extracted data + props.pis.clear() + for pi_data in extracted_pis: + pi = props.pis.add() + pi.x = pi_data["x"] + pi.y = pi_data["y"] + pi.pi_type = pi_data["pi_type"] + pi.radius = pi_data.get("radius", 0.0) + + props.active_pi_index = 0 + + # Recalculate geometry and rebuild display + recalculate_pi_geometry(props) + return True + + +def _extract_pis_from_segments(segments): + """Extract PI data from IFC alignment segments. + + This reconstructs PI coordinates and types from the horizontal segment + design parameters. It handles: + - LINE segments (tangent lines) + - CIRCULARARC segments (horizontal curves) + + Args: + segments: List of IfcAlignmentSegment entities + + Returns: + List of dicts with keys: x, y, pi_type, radius (optional) + """ + pis = [] + + # Filter out zero-length terminal segments + real_segments = [] + for seg in segments: + if hasattr(seg, "DesignParameters") and seg.DesignParameters: + dp = seg.DesignParameters + if dp.SegmentLength > 0.0001: + real_segments.append(seg) + + if not real_segments: + return [] + + # Track which segments are curves and their indices + curve_indices = set() + for i, seg in enumerate(real_segments): + dp = seg.DesignParameters + if dp.PredefinedType == "CIRCULARARC": + curve_indices.add(i) + + # First PI: start of first segment + first_dp = real_segments[0].DesignParameters + start_coords = first_dp.StartPoint.Coordinates + pis.append( + { + "x": float(start_coords[0]), + "y": float(start_coords[1]), + "pi_type": "ENDPOINT", + "radius": 0.0, + } + ) + + # Process interior points + i = 0 + while i < len(real_segments): + dp = real_segments[i].DesignParameters + + if dp.PredefinedType == "CIRCULARARC": + # This is a curve - calculate PI from curve geometry + # PI is at the intersection of incoming and outgoing tangents + pi_data = _calculate_pi_from_curve(real_segments, i) + if pi_data: + pis.append(pi_data) + i += 1 + elif dp.PredefinedType == "LINE": + # Check if next segment is also a LINE (sharp angle, no curve) + if i < len(real_segments) - 1: + next_dp = real_segments[i + 1].DesignParameters + if next_dp.PredefinedType == "LINE": + # End of this LINE is a PI with no curve + end_coords = _calculate_segment_endpoint(dp) + pis.append( + { + "x": float(end_coords[0]), + "y": float(end_coords[1]), + "pi_type": "TANGENT", + "radius": 0.0, + } + ) + i += 1 + else: + # Other segment type - skip for now + i += 1 + + # Last PI: end of last segment + last_dp = real_segments[-1].DesignParameters + end_coords = _calculate_segment_endpoint(last_dp) + # Only add if it's different from the last PI we added + if pis: + last_pi = pis[-1] + dist = math.sqrt((end_coords[0] - last_pi["x"]) ** 2 + (end_coords[1] - last_pi["y"]) ** 2) + if dist > 0.001: # More than 1mm apart + pis.append( + { + "x": float(end_coords[0]), + "y": float(end_coords[1]), + "pi_type": "ENDPOINT", + "radius": 0.0, + } + ) + + return pis + + +def _calculate_segment_endpoint(design_params): + """Calculate the endpoint of a horizontal segment. + + Args: + design_params: IfcAlignmentHorizontalSegment + + Returns: + Tuple (x, y) of endpoint coordinates + """ + start = design_params.StartPoint.Coordinates + start_x = float(start[0]) + start_y = float(start[1]) + + # StartDirection is in radians (counter-clockwise from east) + direction = float(design_params.StartDirection) + length = float(design_params.SegmentLength) + + if design_params.PredefinedType == "LINE": + # Simple line endpoint + end_x = start_x + length * math.cos(direction) + end_y = start_y + length * math.sin(direction) + return (end_x, end_y) + + elif design_params.PredefinedType == "CIRCULARARC": + # Arc endpoint calculation + radius = abs(float(design_params.StartRadiusOfCurvature or design_params.EndRadiusOfCurvature or 0)) + if radius == 0: + # Fallback to line calculation + end_x = start_x + length * math.cos(direction) + end_y = start_y + length * math.sin(direction) + return (end_x, end_y) + + # Determine curve direction (clockwise or counter-clockwise) + start_radius = design_params.StartRadiusOfCurvature + is_clockwise = start_radius is not None and start_radius < 0 + + # Arc length to angle: theta = L / R + theta = length / radius + + if is_clockwise: + # Center is to the right of start direction + center_dir = direction - math.pi / 2 + end_dir = direction - theta + else: + # Center is to the left of start direction + center_dir = direction + math.pi / 2 + end_dir = direction + theta + + # Calculate center + center_x = start_x + radius * math.cos(center_dir) + center_y = start_y + radius * math.sin(center_dir) + + # Calculate endpoint + if is_clockwise: + end_x = center_x + radius * math.cos(end_dir + math.pi / 2) + end_y = center_y + radius * math.sin(end_dir + math.pi / 2) + else: + end_x = center_x + radius * math.cos(end_dir - math.pi / 2) + end_y = center_y + radius * math.sin(end_dir - math.pi / 2) + + return (end_x, end_y) + + else: + # Unknown type - linear approximation + end_x = start_x + length * math.cos(direction) + end_y = start_y + length * math.sin(direction) + return (end_x, end_y) + + +def _calculate_pi_from_curve(segments, curve_index): + """Calculate the PI point from a curve segment. + + The PI is at the intersection of the incoming and outgoing tangents. + For a circular arc: PI = PC + T * incoming_tangent = PT + T * (-outgoing_tangent) + where T = R * tan(delta/2). + + Args: + segments: List of all segments + curve_index: Index of the curve segment + + Returns: + Dict with PI data, or None if can't calculate + """ + curve_seg = segments[curve_index] + curve_dp = curve_seg.DesignParameters + + if curve_dp.PredefinedType != "CIRCULARARC": + return None + + # Get curve parameters + pc_coords = curve_dp.StartPoint.Coordinates + pc_x = float(pc_coords[0]) + pc_y = float(pc_coords[1]) + + start_dir = float(curve_dp.StartDirection) # Incoming tangent direction + arc_length = float(curve_dp.SegmentLength) + + radius = abs(float(curve_dp.StartRadiusOfCurvature or curve_dp.EndRadiusOfCurvature or 0)) + if radius == 0: + return None + + # Determine if clockwise + start_radius = curve_dp.StartRadiusOfCurvature + is_clockwise = start_radius is not None and start_radius < 0 + + # Calculate deflection angle from arc length: delta = L / R + delta = arc_length / radius + + # Calculate tangent length: T = R * tan(delta/2) + tangent_length = radius * math.tan(delta / 2) + + # PI = PC + T * incoming_tangent_unit_vector + pi_x = pc_x + tangent_length * math.cos(start_dir) + pi_y = pc_y + tangent_length * math.sin(start_dir) + + return { + "x": pi_x, + "y": pi_y, + "pi_type": "CURVE", + "radius": radius, + } + + +# ============================================================================= +# Curve Geometry Helper Functions +# ============================================================================= + + +def compute_deflection_angle(prev_pi, curr_pi, next_pi): + """Compute the deflection angle at a PI point. + + Args: + prev_pi: Previous PI (with x, y attributes) + curr_pi: Current PI (with x, y attributes) + next_pi: Next PI (with x, y attributes) + + Returns: + Deflection angle in radians (signed: positive=left, negative=right) + """ + # Incoming tangent direction + dx1 = curr_pi.x - prev_pi.x + dy1 = curr_pi.y - prev_pi.y + angle1 = math.atan2(dy1, dx1) + + # Outgoing tangent direction + dx2 = next_pi.x - curr_pi.x + dy2 = next_pi.y - curr_pi.y + angle2 = math.atan2(dy2, dx2) + + # Deflection angle + deflection = angle2 - angle1 + + # Normalize to [-pi, pi] + while deflection > math.pi: + deflection -= 2 * math.pi + while deflection < -math.pi: + deflection += 2 * math.pi + + return deflection + + +def compute_arc_length_for_pi(props, pi_index): + """Compute arc length for a curve at the given PI. + + Arc length L = R * |delta| where delta is the deflection angle. + + Args: + props: SaikeiAlignmentProperties + pi_index: Index of the PI with the curve + + Returns: + Arc length in same units as radius (meters) + """ + pis = props.pis + if pi_index <= 0 or pi_index >= len(pis) - 1: + return 0.0 + + prev_pi = pis[pi_index - 1] + curr_pi = pis[pi_index] + next_pi = pis[pi_index + 1] + + if curr_pi.radius <= 0: + return 0.0 + + deflection = compute_deflection_angle(prev_pi, curr_pi, next_pi) + return curr_pi.radius * abs(deflection) + + +def compute_tangent_length_at_pi(props, pi_index): + """Compute the tangent length T at a PI with a curve. + + Tangent length T = R * tan(|delta|/2) + + Args: + props: SaikeiAlignmentProperties + pi_index: Index of the PI with the curve + + Returns: + Tangent length (distance from PI to PC or PT) + """ + pis = props.pis + if pi_index <= 0 or pi_index >= len(pis) - 1: + return 0.0 + + prev_pi = pis[pi_index - 1] + curr_pi = pis[pi_index] + next_pi = pis[pi_index + 1] + + if curr_pi.radius <= 0: + return 0.0 + + deflection = compute_deflection_angle(prev_pi, curr_pi, next_pi) + return curr_pi.radius * math.tan(abs(deflection) / 2) + + +def compute_segment_length(props, start_pi_index, account_for_curves=True): + """Compute the length of a tangent segment between two PIs. + + If curves exist at the start or end PI, the segment is shortened + to PC (Point of Curvature) or PT (Point of Tangency). + + Args: + props: SaikeiAlignmentProperties + start_pi_index: Index of the starting PI + account_for_curves: If True, subtract tangent lengths for adjacent curves + + Returns: + Segment length in meters + """ + pis = props.pis + if start_pi_index < 0 or start_pi_index >= len(pis) - 1: + return 0.0 + + start_pi = pis[start_pi_index] + end_pi = pis[start_pi_index + 1] + + # Full length between PIs + dx = end_pi.x - start_pi.x + dy = end_pi.y - start_pi.y + full_length = math.sqrt(dx * dx + dy * dy) + + if not account_for_curves: + return full_length + + # Subtract tangent length if start PI has a curve (segment starts at PT) + if start_pi_index > 0 and start_pi.radius > 0: + full_length -= compute_tangent_length_at_pi(props, start_pi_index) + + # Subtract tangent length if end PI has a curve (segment ends at PC) + if start_pi_index + 1 < len(pis) - 1 and end_pi.radius > 0: + full_length -= compute_tangent_length_at_pi(props, start_pi_index + 1) + + return max(0.0, full_length) + + +def on_radius_changed(pi, context): + """Callback when PI radius is changed. Triggers geometry recalculation. + + This is called from the AlignmentPI.radius property's update callback. + When a radius is entered on a Mid point, this triggers: + 1. Recalculation of PI geometry (lengths, stations) + 2. Rebuild of display_rows (Mid point becomes Curve segment) + 3. If an active alignment exists, regeneration of IFC entities + """ + props = context.scene.SaikeiAlignmentProperties + recalculate_pi_geometry(props) + + # If there's an active alignment, trigger IFC regeneration + # This is handled by recalculate_pi_geometry when active_alignment_id is set + + +def recalculate_pi_geometry(props): + """Recalculate lengths and stations for all PIs using core logic.""" + pis = props.pis + if len(pis) < 2: + rebuild_display_rows(props) + return + + # Extract PI coordinates for pure Python calculation + pi_coords = [(pi.x, pi.y) for pi in pis] + + # Use core function for calculation + result = core.calculate_pi_geometry(pi_coords, props.start_station) + + # Update Blender properties with results + tool.Alignment.update_pi_properties(props, result) + + # Rebuild the display rows for the interleaved table view + rebuild_display_rows(props) + + +def rebuild_display_rows(props): + """Rebuild the display_rows collection from the pis collection. + + Creates an interleaved view of points and segments in Civil 3D style: + End point (POB) + Tangent segment 1 + Mid point (or Curve segment if radius > 0) + Tangent segment 2 + End point (POE) + + When a Mid point has a curve (radius > 0), it becomes a Curve segment row + instead of a point row, showing PI coordinates + arc length + radius. + """ + props.display_rows.clear() + + pis = props.pis + if len(pis) == 0: + return + + segment_num = 0 + i = 0 + + while i < len(pis): + pi = pis[i] + is_interior = i > 0 and i < len(pis) - 1 + has_curve = is_interior and pi.radius > 0 + + if has_curve: + # Interior PI with curve: becomes a CURVE SEGMENT row + # This replaces what would have been a Mid point row + segment_num += 1 + curve_row = props.display_rows.add() + curve_row.row_type = "SEGMENT" + curve_row.segment_number = segment_num + curve_row.pi_index = i + curve_row.display_type = "Curve" + curve_row.x = pi.x # Show PI coordinates on curve row + curve_row.y = pi.y + curve_row.radius = pi.radius + curve_row.arc_length = compute_arc_length_for_pi(props, i) + else: + # Regular point row (End or Mid without curve) + point_row = props.display_rows.add() + point_row.row_type = "POINT" + point_row.pi_index = i + + if pi.pi_type == "ENDPOINT": + point_row.display_type = "End" + else: + point_row.display_type = "Mid" + + point_row.x = pi.x + point_row.y = pi.y + + # Add tangent segment row after this point/curve (except after last PI) + if i < len(pis) - 1: + # Check if next PI also has a curve (affects segment length calculation) + next_pi = pis[i + 1] + next_has_curve = (i + 1 < len(pis) - 1) and next_pi.radius > 0 + + segment_num += 1 + seg_row = props.display_rows.add() + seg_row.row_type = "SEGMENT" + seg_row.segment_number = segment_num + seg_row.pi_index = i + seg_row.display_type = "Tan" + + # Compute segment length accounting for curves at either end + seg_row.length = compute_segment_length(props, i, account_for_curves=True) + + i += 1 + + +# ============================================================================= +# PI Management Operators +# ============================================================================= + + +class SAIKEI_OT_add_pi(Operator): + """Add a new PI point to the list""" + + bl_idname = "saikei.add_pi" + bl_label = "Add PI" + bl_description = "Add a new PI (Point of Intersection) to the alignment" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + return poll_ifc4x3(cls, context) + + def execute(self, context): + props = context.scene.SaikeiAlignmentProperties + + # Add new PI + pi = props.pis.add() + + # Set default position based on existing PIs + if len(props.pis) == 1: + # First PI - start at origin + pi.x = 0.0 + pi.y = 0.0 + pi.pi_type = "ENDPOINT" + elif len(props.pis) == 2: + # Second PI - offset from first + prev = props.pis[0] + pi.x = prev.x + 100.0 + pi.y = prev.y + pi.pi_type = "ENDPOINT" + else: + # Additional PIs - extrapolate from last two + prev = props.pis[-2] + prev_prev = props.pis[-3] if len(props.pis) > 2 else prev + dx = prev.x - prev_prev.x if len(props.pis) > 2 else 100.0 + dy = prev.y - prev_prev.y if len(props.pis) > 2 else 0.0 + pi.x = prev.x + dx + pi.y = prev.y + dy + pi.pi_type = "TANGENT" + + # Previous endpoint becomes tangent or curve + props.pis[-2].pi_type = "TANGENT" + + # Make new PI active + props.active_pi_index = len(props.pis) - 1 + + # Recalculate geometry + recalculate_pi_geometry(props) + + return {"FINISHED"} + + +class SAIKEI_OT_remove_pi(Operator): + """Remove the selected PI point""" + + bl_idname = "saikei.remove_pi" + bl_label = "Remove PI" + bl_description = "Remove the selected PI from the alignment" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + if not poll_ifc4x3(cls, context): + return False + props = context.scene.SaikeiAlignmentProperties + if len(props.pis) == 0: + cls.poll_message_set("No PIs to remove") + return False + # Check if a POINT row is selected (can't remove from SEGMENT row selection) + if props.display_rows: + idx = props.active_display_row_index + if 0 <= idx < len(props.display_rows): + if props.display_rows[idx].row_type != "POINT": + cls.poll_message_set("Select a point row to remove") + return False + return True + + def execute(self, context): + props = context.scene.SaikeiAlignmentProperties + + # Get the PI index from the selected display row + pi_index = -1 + if props.display_rows: + idx = props.active_display_row_index + if 0 <= idx < len(props.display_rows): + row = props.display_rows[idx] + if row.row_type == "POINT": + pi_index = row.pi_index + + # Fallback to active_pi_index if display_rows isn't being used + if pi_index < 0: + pi_index = props.active_pi_index + + if 0 <= pi_index < len(props.pis): + props.pis.remove(pi_index) + props.active_pi_index = min(pi_index, len(props.pis) - 1) + + # Recalculate geometry (also rebuilds display_rows) + recalculate_pi_geometry(props) + + # Reset display row index to first row if needed + if len(props.display_rows) > 0: + props.active_display_row_index = min(props.active_display_row_index, len(props.display_rows) - 1) + else: + props.active_display_row_index = 0 + + return {"FINISHED"} + + +class SAIKEI_OT_pick_pi_from_viewport(Operator): + """Add PI points by clicking in the 3D viewport""" + + bl_idname = "saikei.pick_pi_from_viewport" + bl_label = "Pick PI from Viewport" + bl_description = "Click in the viewport to add PI points. Right-click or Escape to finish." + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + return poll_ifc4x3(cls, context) + + def invoke(self, context, event): + context.window.cursor_set("CROSSHAIR") + context.window_manager.modal_handler_add(self) + self.report({"INFO"}, "Click to add PIs. Right-click or Escape to finish.") + return {"RUNNING_MODAL"} + + def modal(self, context, event): + if event.type == "LEFTMOUSE" and event.value == "PRESS": + # Raycast to ground plane (Z=0) + coord = self.get_ground_intersection(context, event) + if coord: + self.add_pi_at_location(context, coord) + context.area.tag_redraw() + return {"RUNNING_MODAL"} + + elif event.type in {"RIGHTMOUSE", "ESC"}: + context.window.cursor_set("DEFAULT") + self.report({"INFO"}, "Finished adding PIs") + return {"FINISHED"} + + # Allow viewport navigation + elif event.type in {"MIDDLEMOUSE", "WHEELUPMOUSE", "WHEELDOWNMOUSE"}: + return {"PASS_THROUGH"} + + return {"RUNNING_MODAL"} + + def get_ground_intersection(self, context, event): + """Raycast from mouse to Z=0 ground plane""" + from bpy_extras.view3d_utils import region_2d_to_origin_3d, region_2d_to_vector_3d + + region = context.region + rv3d = context.region_data + coord = (event.mouse_region_x, event.mouse_region_y) + + origin = region_2d_to_origin_3d(region, rv3d, coord) + direction = region_2d_to_vector_3d(region, rv3d, coord) + + # Intersect with Z=0 plane + if direction.z != 0: + t = -origin.z / direction.z + if t > 0: # In front of camera + hit = origin + direction * t + return (hit.x, hit.y) + return None + + def add_pi_at_location(self, context, coord): + """Add a new PI at the given (x, y) coordinate""" + props = context.scene.SaikeiAlignmentProperties + + pi = props.pis.add() + pi.x = coord[0] + pi.y = coord[1] + + # Determine PI type based on position in list + if len(props.pis) == 1: + pi.pi_type = "ENDPOINT" + elif len(props.pis) == 2: + pi.pi_type = "ENDPOINT" + else: + pi.pi_type = "TANGENT" + # Previous endpoint becomes tangent + if len(props.pis) >= 2: + props.pis[-2].pi_type = "TANGENT" + + props.active_pi_index = len(props.pis) - 1 + recalculate_pi_geometry(props) + + +class SAIKEI_OT_recalculate_pis(Operator): + """Recalculate PI geometry and update IFC/visualization""" + + bl_idname = "saikei.recalculate_pis" + bl_label = "Recalculate PIs" + bl_description = "Recalculate geometry, update IFC segments, and refresh visualization" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + if not poll_ifc4x3(cls, context): + return False + props = context.scene.SaikeiAlignmentProperties + if len(props.pis) < 2: + cls.poll_message_set("Need at least 2 PIs to recalculate") + return False + return True + + def execute(self, context): + ifc = tool.Alignment.get_ifc_file() + props = context.scene.SaikeiAlignmentProperties + + # Recalculate geometry in UI properties + recalculate_pi_geometry(props) + + # If there's an active alignment, recreate it with updated data + # We recreate the entire alignment because modifying segments in place + # can leave the IFC layout in an inconsistent state + if props.active_alignment_id != 0: + alignment = get_alignment_by_id(ifc, props.active_alignment_id) + if alignment is None: + # Alignment no longer exists (e.g., after undo) - clear reference + clear_invalid_alignment_reference(props) + self.report({"WARNING"}, "Active alignment no longer exists. Reference cleared.") + return {"FINISHED"} + if alignment: + # Save the alignment name + alignment_name = alignment.Name or props.new_alignment_name + + # Remove the entire alignment hierarchy (Blender objects) + tool.Alignment.remove_alignment_hierarchy(alignment) + + # Remove the IFC alignment entity entirely + ifcopenshell.api.run("root.remove_product", ifc, product=alignment) + + # Collect updated PI data + hpoints = [(pi.x, pi.y) for pi in props.pis] + radii = [pi.radius for pi in props.pis[1:-1]] + + # Create a fresh alignment with the same name + # Use safe wrapper to validate/cleanup before creating + new_alignment = tool.Alignment.safe_create_alignment_by_pi_method( + ifc, + name=alignment_name, + hpoints=hpoints, + radii=radii, + start_station=props.start_station, + ) + + # Create Blender hierarchy for the new alignment + tool.Alignment.create_hierarchy_for_alignment(new_alignment) + + # Update the active alignment ID to reference the new entity + props.active_alignment_id = new_alignment.id() + props.active_alignment_name = alignment_name + + self.report({"INFO"}, f"Updated alignment '{alignment_name}' with {len(hpoints)} PIs") + return {"FINISHED"} + + # No active alignment - just report geometry recalculation + total_length = sum(pi.length_to_next for pi in props.pis) + self.report({"INFO"}, f"Recalculated {len(props.pis)} PIs, total length: {total_length:.2f}") + return {"FINISHED"} + + +class SAIKEI_OT_clear_pis(Operator): + """Clear all PI points and optionally remove visualization/IFC data""" + + bl_idname = "saikei.clear_pis" + bl_label = "Clear All PIs" + bl_description = "Remove all PI points and clear segment visualization" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + if not poll_ifc4x3(cls, context): + return False + props = context.scene.SaikeiAlignmentProperties + if len(props.pis) == 0: + cls.poll_message_set("No PIs to clear") + return False + return True + + def invoke(self, context, event): + return context.window_manager.invoke_confirm(self, event) + + def execute(self, context): + ifc = tool.Alignment.get_ifc_file() + props = context.scene.SaikeiAlignmentProperties + + removed_objects = 0 + + # If there's an active alignment, remove it entirely (Blender + IFC) + # This ensures we don't leave the IFC in an inconsistent state + if props.active_alignment_id != 0: + alignment = get_alignment_by_id(ifc, props.active_alignment_id) + if alignment: + # Remove all Blender objects for this alignment + removed_objects = tool.Alignment.remove_alignment_hierarchy(alignment) + + # Remove the IFC alignment entity entirely + ifcopenshell.api.run("root.remove_product", ifc, product=alignment) + + # Clear the active alignment reference + props.active_alignment_id = 0 + props.active_alignment_name = "" + + # Clear the PI list in the UI + props.pis.clear() + props.active_pi_index = 0 + + # Clear the display rows + props.display_rows.clear() + props.active_display_row_index = 0 + + if removed_objects > 0: + self.report({"INFO"}, f"Cleared all PIs and removed {removed_objects} objects") + else: + self.report({"INFO"}, "Cleared all PIs") + return {"FINISHED"} + + +# ============================================================================= +# Creation Operators +# ============================================================================= + + +class SAIKEI_OT_create_alignment(Operator): + """Create a new IFC alignment""" + + bl_idname = "saikei.create_alignment" + bl_label = "Create Alignment" + bl_description = "Create a new empty IFC alignment" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + return poll_ifc4x3(cls, context) + + def execute(self, context): + ifc = tool.Alignment.get_ifc_file() + props = context.scene.SaikeiAlignmentProperties + + alignment = ifcopenshell.api.alignment.create( + ifc, + name=props.new_alignment_name, + ) + + # Create full Blender hierarchy (alignment + layouts + segments) + obj = tool.Alignment.create_hierarchy_for_alignment(alignment) + + # Update UI + props.active_alignment_name = props.new_alignment_name + props.active_alignment_id = alignment.id() + + if obj: + self.report({"INFO"}, f"Created alignment: {props.new_alignment_name}") + else: + self.report({"WARNING"}, f"Created IFC alignment but could not create Blender object") + return {"FINISHED"} + + +class SAIKEI_OT_create_alignment_by_pi(Operator): + """Create alignment using the PI (Point of Intersection) method""" + + bl_idname = "saikei.create_alignment_by_pi" + bl_label = "Create by PI Method" + bl_description = "Create alignment using PI points and curve radii. If an active alignment exists with no segments, adds to it instead of creating new." + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + if not poll_ifc4x3(cls, context): + return False + props = context.scene.SaikeiAlignmentProperties + if len(props.pis) < 2: + cls.poll_message_set("Need at least 2 PI points") + return False + return True + + def execute(self, context): + ifc = tool.Alignment.get_ifc_file() + props = context.scene.SaikeiAlignmentProperties + + # Collect PI data + hpoints = [(pi.x, pi.y) for pi in props.pis] + radii = [pi.radius for pi in props.pis[1:-1]] + + # Check if there's an active alignment we should add to instead of creating new + if props.active_alignment_id != 0: + existing_alignment = get_alignment_by_id(ifc, props.active_alignment_id) + if existing_alignment: + h_layout = ifcopenshell.api.alignment.get_horizontal_layout(existing_alignment) + if h_layout: + # Check if horizontal layout is empty (only has zero-length terminal or no segments) + segments = ifcopenshell.api.alignment.get_layout_segments(h_layout) + has_real_segments = False + for seg in segments: + if hasattr(seg, "DesignParameters") and seg.DesignParameters: + if seg.DesignParameters.SegmentLength > 0.0001: + has_real_segments = True + break + + if not has_real_segments: + # Use existing alignment - add segments to it + # Use safe wrapper to validate layout has parent alignment + tool.Alignment.safe_layout_horizontal_by_pi_method(ifc, h_layout, hpoints, radii) + + # Create/update Blender objects for the segments + alignment_obj = tool.Ifc.get_object(existing_alignment) + h_layout_obj = tool.Ifc.get_object(h_layout) + + if not h_layout_obj and alignment_obj: + h_layout_obj = tool.Alignment.create_object_for_layout(h_layout, alignment_obj) + + if h_layout_obj: + tool.Alignment.create_objects_for_layout_segments(h_layout, h_layout_obj) + + self.report( + {"INFO"}, f"Added {len(hpoints)} PIs to existing alignment '{existing_alignment.Name}'" + ) + return {"FINISHED"} + + # No suitable existing alignment - create a new one + # Use safe wrapper to validate/cleanup before creating + alignment = tool.Alignment.safe_create_alignment_by_pi_method( + ifc, + name=props.new_alignment_name, + hpoints=hpoints, + radii=radii, + start_station=props.start_station, + ) + + # Create full Blender hierarchy (alignment + layouts + segments) + obj = tool.Alignment.create_hierarchy_for_alignment(alignment) + + props.active_alignment_name = props.new_alignment_name + props.active_alignment_id = alignment.id() + + if obj: + self.report({"INFO"}, f"Created new alignment '{props.new_alignment_name}' with {len(hpoints)} PIs") + else: + self.report({"WARNING"}, f"Created IFC alignment but could not create Blender object") + return {"FINISHED"} + + +class SAIKEI_OT_import_alignment_csv(Operator, ImportHelper): + """Import alignment from CSV file""" + + bl_idname = "saikei.import_alignment_csv" + bl_label = "Import Alignment CSV" + bl_description = "Import alignment definition from a CSV file" + bl_options = {"REGISTER", "UNDO"} + + filename_ext = ".csv" + filter_glob: StringProperty(default="*.csv", options={"HIDDEN"}) + + @classmethod + def poll(cls, context): + return poll_ifc4x3(cls, context) + + def execute(self, context): + ifc = tool.Alignment.get_ifc_file() + props = context.scene.SaikeiAlignmentProperties + + alignment = ifcopenshell.api.alignment.create_from_csv(ifc, self.filepath) + + # Create full Blender hierarchy (alignment + layouts + segments) + obj = tool.Alignment.create_hierarchy_for_alignment(alignment) + + props.active_alignment_name = alignment.Name or "Imported Alignment" + props.active_alignment_id = alignment.id() + + self.report({"INFO"}, f"Imported alignment from {self.filepath}") + return {"FINISHED"} + + +class SAIKEI_OT_create_alignment_polyline(Operator): + """Create alignment as a polyline""" + + bl_idname = "saikei.create_alignment_polyline" + bl_label = "Create as Polyline" + bl_description = "Create alignment from a polyline (no curves)" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + return poll_ifc4x3(cls, context) + + def execute(self, context): + ifc = tool.Alignment.get_ifc_file() + props = context.scene.SaikeiAlignmentProperties + + # Collect points from PIs (no radii) + points = [(pi.x, pi.y) for pi in props.pis] + + if len(points) < 2: + self.report({"ERROR"}, "Need at least 2 points for polyline") + return {"CANCELLED"} + + alignment = ifcopenshell.api.alignment.create_as_polyline( + ifc, + name=props.new_alignment_name, + points=points, + ) + + # Create full Blender hierarchy (alignment + layouts + segments) + obj = tool.Alignment.create_hierarchy_for_alignment(alignment) + + props.active_alignment_name = props.new_alignment_name + props.active_alignment_id = alignment.id() + + self.report({"INFO"}, f"Created polyline alignment with {len(points)} points") + return {"FINISHED"} + + +class SAIKEI_OT_create_alignment_offset(Operator): + """Create alignment as an offset from existing alignment""" + + bl_idname = "saikei.create_alignment_offset" + bl_label = "Create as Offset Curve" + bl_description = "Create a new alignment offset from an existing alignment" + bl_options = {"REGISTER", "UNDO"} + + offset_distance: FloatProperty( + name="Offset Distance", + description="Distance to offset (positive = right, negative = left)", + default=10.0, + unit="LENGTH", + ) + + @classmethod + def poll(cls, context): + if not poll_ifc4x3(cls, context): + return False + props = context.scene.SaikeiAlignmentProperties + if props.active_alignment_id == 0: + cls.poll_message_set("Select an alignment first") + return False + return True + + def invoke(self, context, event): + return context.window_manager.invoke_props_dialog(self) + + def execute(self, context): + ifc = tool.Alignment.get_ifc_file() + props = context.scene.SaikeiAlignmentProperties + + base_alignment = get_alignment_by_id(ifc, props.active_alignment_id) + if base_alignment is None: + clear_invalid_alignment_reference(props) + self.report({"ERROR"}, "Base alignment no longer exists. Reference cleared.") + return {"CANCELLED"} + + alignment = ifcopenshell.api.alignment.create_as_offset_curve( + ifc, + name=f"{props.new_alignment_name} (Offset)", + base_alignment=base_alignment, + offset=self.offset_distance, + ) + + # Create full Blender hierarchy (alignment + layouts + segments) + obj = tool.Alignment.create_hierarchy_for_alignment(alignment) + + self.report({"INFO"}, f"Created offset alignment at {self.offset_distance}m") + return {"FINISHED"} + + +# ============================================================================= +# Layout Operators +# ============================================================================= + + +class SAIKEI_OT_add_vertical_layout(Operator): + """Add vertical layout to an alignment""" + + bl_idname = "saikei.add_vertical_layout" + bl_label = "Add Vertical Layout" + bl_description = "Add an IfcAlignmentVertical to the active alignment" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + if not poll_ifc4x3(cls, context): + return False + props = context.scene.SaikeiAlignmentProperties + if props.active_alignment_id == 0: + cls.poll_message_set("Select an alignment first") + return False + return True + + def execute(self, context): + ifc = tool.Alignment.get_ifc_file() + props = context.scene.SaikeiAlignmentProperties + + alignment = get_alignment_by_id(ifc, props.active_alignment_id) + if alignment is None: + clear_invalid_alignment_reference(props) + self.report({"ERROR"}, "Alignment no longer exists. Reference cleared.") + return {"CANCELLED"} + + vertical = ifcopenshell.api.alignment.add_vertical_layout(ifc, alignment) + + self.report({"INFO"}, "Added vertical layout") + return {"FINISHED"} + + +class SAIKEI_OT_add_layout_segment(Operator): + """Add a segment to an alignment layout""" + + bl_idname = "saikei.add_layout_segment" + bl_label = "Add Layout Segment" + bl_description = "Add a new segment to the alignment layout" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + if not poll_ifc4x3(cls, context): + return False + props = context.scene.SaikeiAlignmentProperties + if props.active_alignment_id == 0: + cls.poll_message_set("Select an alignment first") + return False + return True + + def execute(self, context): + # This operator would open a dialog for segment parameters + self.report({"INFO"}, "Add segment - dialog coming soon") + return {"FINISHED"} + + +class SAIKEI_OT_layout_horizontal_by_pi(Operator): + """Layout horizontal alignment using PI method""" + + bl_idname = "saikei.layout_horizontal_by_pi" + bl_label = "Layout Horizontal by PI" + bl_description = "Layout the horizontal alignment using PI points" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + if not poll_ifc4x3(cls, context): + return False + props = context.scene.SaikeiAlignmentProperties + if props.active_alignment_id == 0: + cls.poll_message_set("Select an alignment first") + return False + if len(props.pis) < 2: + cls.poll_message_set("Need at least 2 PI points") + return False + return True + + def execute(self, context): + ifc = tool.Alignment.get_ifc_file() + props = context.scene.SaikeiAlignmentProperties + + alignment = get_alignment_by_id(ifc, props.active_alignment_id) + if alignment is None: + clear_invalid_alignment_reference(props) + self.report({"ERROR"}, "Alignment no longer exists. Reference cleared.") + return {"CANCELLED"} + + h_layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment) + + if not h_layout: + self.report({"ERROR"}, "Alignment has no horizontal layout") + return {"CANCELLED"} + + pis = [(pi.x, pi.y) for pi in props.pis] + radii = [pi.radius for pi in props.pis[1:-1]] + + # Create the IFC segments + # Use safe wrapper to validate layout has parent alignment + tool.Alignment.safe_layout_horizontal_by_pi_method(ifc, h_layout, pis, radii) + + # Find or create Blender object for the horizontal layout + # Use Bonsai's Ifc tool (re-exported via saikei.tool) + alignment_obj = tool.Ifc.get_object(alignment) + h_layout_obj = tool.Ifc.get_object(h_layout) + + if not h_layout_obj and alignment_obj: + # Create the horizontal layout object if it doesn't exist + h_layout_obj = tool.Alignment.create_object_for_layout(h_layout, alignment_obj) + + # Create Blender objects for the newly created segments + if h_layout_obj: + tool.Alignment.create_objects_for_layout_segments(h_layout, h_layout_obj) + + self.report({"INFO"}, f"Laid out horizontal alignment with {len(pis)} PIs") + return {"FINISHED"} + + +class SAIKEI_OT_layout_vertical_by_pi(Operator): + """Layout vertical alignment using PI method""" + + bl_idname = "saikei.layout_vertical_by_pi" + bl_label = "Layout Vertical by PI" + bl_description = "Layout the vertical alignment using PVI points" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + if not poll_ifc4x3(cls, context): + return False + props = context.scene.SaikeiAlignmentProperties + if props.active_alignment_id == 0: + cls.poll_message_set("Select an alignment first") + return False + return True + + def execute(self, context): + # This would collect vertical PIs and create vertical layout + self.report({"INFO"}, "Layout vertical - implementation coming soon") + return {"FINISHED"} + + +# ============================================================================= +# Stationing Operators +# ============================================================================= + + +class SAIKEI_OT_add_stationing_referent(Operator): + """Add a stationing referent to the alignment""" + + bl_idname = "saikei.add_stationing_referent" + bl_label = "Add Stationing Referent" + bl_description = "Add an IfcReferent for stationing" + bl_options = {"REGISTER", "UNDO"} + + station: FloatProperty( + name="Station", + description="Station value for the referent (e.g., 10000 for 100+00)", + default=10000.0, + ) + + name: StringProperty( + name="Name", + description="Name for the referent (leave blank to auto-generate)", + default="", + ) + + @classmethod + def poll(cls, context): + if not poll_ifc4x3(cls, context): + return False + props = context.scene.SaikeiAlignmentProperties + if props.active_alignment_id == 0: + cls.poll_message_set("Select an alignment first") + return False + return True + + def invoke(self, context, event): + # Default station to start_station from props + props = context.scene.SaikeiAlignmentProperties + self.station = props.start_station + return context.window_manager.invoke_props_dialog(self) + + def draw(self, context): + layout = self.layout + layout.prop(self, "station") + layout.prop(self, "name") + # Show station notation preview + station_str = format_station(self.station) + layout.label(text=f"Station notation: {station_str}") + + def execute(self, context): + ifc = tool.Alignment.get_ifc_file() + props = context.scene.SaikeiAlignmentProperties + + alignment = get_alignment_by_id(ifc, props.active_alignment_id) + if alignment is None: + clear_invalid_alignment_reference(props) + self.report({"ERROR"}, "Alignment no longer exists. Reference cleared.") + return {"CANCELLED"} + + # Compute distance_along from station and start_station + # distance_along = station - start_station + distance_along = self.station - props.start_station + + # Auto-generate name if not provided + name = self.name if self.name else format_station(self.station) + + # Use the alignment itself as the positioned product + # (The referent marks a point on the alignment) + positioned_product = alignment + + ifcopenshell.api.alignment.add_stationing_referent( + ifc, + alignment=alignment, + distance_along=distance_along, + station=self.station, + name=name, + positioned_product=positioned_product, + ) + + self.report({"INFO"}, f"Added referent '{name}' at station {self.station}") + return {"FINISHED"} + + +def format_station(station_value): + """Format a station value in standard notation (e.g., 10000 -> '100+00')""" + # Station notation: divide by 100 for the main part, remainder for the offset + # e.g., 10000 -> 100+00, 10050 -> 100+50, 10123.45 -> 101+23.45 + main = int(station_value // 100) + offset = station_value % 100 + if offset == int(offset): + return f"{main}+{int(offset):02d}" + else: + return f"{main}+{offset:05.2f}" + + +class SAIKEI_OT_name_segments(Operator): + """Auto-name segments based on station values""" + + bl_idname = "saikei.name_segments" + bl_label = "Name Segments" + bl_description = "Automatically name segments with station-based labels" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + if not poll_ifc4x3(cls, context): + return False + props = context.scene.SaikeiAlignmentProperties + if props.active_alignment_id == 0: + cls.poll_message_set("Select an alignment first") + return False + return True + + def execute(self, context): + ifc = tool.Alignment.get_ifc_file() + props = context.scene.SaikeiAlignmentProperties + + alignment = get_alignment_by_id(ifc, props.active_alignment_id) + if alignment is None: + clear_invalid_alignment_reference(props) + self.report({"ERROR"}, "Alignment no longer exists. Reference cleared.") + return {"CANCELLED"} + + ifcopenshell.api.alignment.name_segments(ifc, alignment) + + self.report({"INFO"}, "Named alignment segments") + return {"FINISHED"} + + +# ============================================================================= +# Utility Operators +# ============================================================================= + + +class SAIKEI_OT_create_representation(Operator): + """Create geometric representation for alignment""" + + bl_idname = "saikei.create_representation" + bl_label = "Create Representation" + bl_description = "Create or update the geometric representation" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + if not poll_ifc4x3(cls, context): + return False + props = context.scene.SaikeiAlignmentProperties + if props.active_alignment_id == 0: + cls.poll_message_set("Select an alignment first") + return False + return True + + def execute(self, context): + ifc = tool.Alignment.get_ifc_file() + props = context.scene.SaikeiAlignmentProperties + + alignment = get_alignment_by_id(ifc, props.active_alignment_id) + if alignment is None: + clear_invalid_alignment_reference(props) + self.report({"ERROR"}, "Alignment no longer exists. Reference cleared.") + return {"CANCELLED"} + + ifcopenshell.api.alignment.create_representation(ifc, alignment) + + self.report({"INFO"}, "Created geometric representation") + return {"FINISHED"} + + +class SAIKEI_OT_create_segment_representations(Operator): + """Create representations for individual segments""" + + bl_idname = "saikei.create_segment_representations" + bl_label = "Create Segment Representations" + bl_description = "Create geometric representations for each segment" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + if not poll_ifc4x3(cls, context): + return False + props = context.scene.SaikeiAlignmentProperties + if props.active_alignment_id == 0: + cls.poll_message_set("Select an alignment first") + return False + return True + + def execute(self, context): + ifc = tool.Alignment.get_ifc_file() + props = context.scene.SaikeiAlignmentProperties + + alignment = get_alignment_by_id(ifc, props.active_alignment_id) + if alignment is None: + clear_invalid_alignment_reference(props) + self.report({"ERROR"}, "Alignment no longer exists. Reference cleared.") + return {"CANCELLED"} + + ifcopenshell.api.alignment.create_segment_representations(ifc, alignment) + + self.report({"INFO"}, "Created segment representations") + return {"FINISHED"} + + +class SAIKEI_OT_update_fallback_position(Operator): + """Update the fallback position for the alignment""" + + bl_idname = "saikei.update_fallback_position" + bl_label = "Update Fallback Position" + bl_description = "Update the fallback position point" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + if not poll_ifc4x3(cls, context): + return False + props = context.scene.SaikeiAlignmentProperties + if props.active_alignment_id == 0: + cls.poll_message_set("Select an alignment first") + return False + return True + + def execute(self, context): + ifc = tool.Alignment.get_ifc_file() + props = context.scene.SaikeiAlignmentProperties + + alignment = get_alignment_by_id(ifc, props.active_alignment_id) + if alignment is None: + clear_invalid_alignment_reference(props) + self.report({"ERROR"}, "Alignment no longer exists. Reference cleared.") + return {"CANCELLED"} + + ifcopenshell.api.alignment.update_fallback_position(ifc, alignment) + + self.report({"INFO"}, "Updated fallback position") + return {"FINISHED"} + + +class SAIKEI_OT_validate_segments(Operator): + """Validate alignment segments""" + + bl_idname = "saikei.validate_segments" + bl_label = "Validate Segments" + bl_description = "Check for issues like zero-length segments" + bl_options = {"REGISTER"} + + @classmethod + def poll(cls, context): + if not poll_ifc4x3(cls, context): + return False + props = context.scene.SaikeiAlignmentProperties + if props.active_alignment_id == 0: + cls.poll_message_set("Select an alignment first") + return False + return True + + def execute(self, context): + ifc = tool.Alignment.get_ifc_file() + props = context.scene.SaikeiAlignmentProperties + + alignment = get_alignment_by_id(ifc, props.active_alignment_id) + if alignment is None: + clear_invalid_alignment_reference(props) + self.report({"ERROR"}, "Alignment no longer exists. Reference cleared.") + return {"CANCELLED"} + + h_layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment) + + if h_layout: + has_zero = ifcopenshell.api.alignment.has_zero_length_segment(h_layout) + if has_zero: + self.report({"WARNING"}, "Alignment has zero-length segments") + else: + self.report({"INFO"}, "All segments valid") + else: + self.report({"WARNING"}, "No horizontal layout found") + + return {"FINISHED"} + + +class SAIKEI_OT_refresh_alignment_data(Operator): + """Refresh alignment data display""" + + bl_idname = "saikei.refresh_alignment_data" + bl_label = "Refresh Data" + bl_description = "Refresh the alignment segment list" + bl_options = {"REGISTER"} + + @classmethod + def poll(cls, context): + return poll_ifc4x3(cls, context) + + def execute(self, context): + ifc = tool.Alignment.get_ifc_file() + props = context.scene.SaikeiAlignmentProperties + + # Clear existing segments + props.segments.clear() + + if props.active_alignment_id == 0: + return {"FINISHED"} + + alignment = get_alignment_by_id(ifc, props.active_alignment_id) + if alignment is None: + # Alignment no longer exists - clear reference and return + clear_invalid_alignment_reference(props) + self.report({"WARNING"}, "Alignment no longer exists. Reference cleared.") + return {"FINISHED"} + + h_layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment) + + if h_layout: + segments = ifcopenshell.api.alignment.get_layout_segments(h_layout) + for i, seg in enumerate(segments): + item = props.segments.add() + item.name = f"Segment {i + 1}" + if hasattr(seg, "DesignParameters") and seg.DesignParameters: + dp = seg.DesignParameters + item.segment_type = dp.PredefinedType or "UNKNOWN" + item.length = dp.SegmentLength or 0.0 + item.ifc_id = seg.id() + + self.report({"INFO"}, f"Loaded {len(props.segments)} segments") + return {"FINISHED"} diff --git a/src/bonsai/bonsai/tool/__init__.py b/src/bonsai/bonsai/tool/__init__.py index 8dae934981..2de1dbb9db 100644 --- a/src/bonsai/bonsai/tool/__init__.py +++ b/src/bonsai/bonsai/tool/__init__.py @@ -17,6 +17,7 @@ # along with Bonsai. If not, see . from bonsai.tool.aggregate import Aggregate +from bonsai.tool.alignment import Alignment from bonsai.tool.attribute import Attribute from bonsai.tool.bcf import Bcf from bonsai.tool.blender import Blender diff --git a/src/saikei/saikei/__init__.py b/src/saikei/saikei/__init__.py deleted file mode 100644 index b54698af92..0000000000 --- a/src/saikei/saikei/__init__.py +++ /dev/null @@ -1,54 +0,0 @@ -# ============================================================================== -# 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 -# ============================================================================== - - -""" -Saikei Civil - Civil engineering design tools for Blender - -This addon extends Bonsai (BlenderBIM) with civil engineering capabilities, -focusing on IFC4x3 alignment modeling for roads, railways, and infrastructure. - -Requires: - - Bonsai addon installed and enabled - - IFC4X3 schema for alignment features -""" - -bl_info = { - "name": "Saikei Civil", - "author": "IfcOpenShell Contributors", - "version": (0, 1, 0), - "blender": (4, 2, 0), - "location": "View3D > Sidebar > Saikei Civil", - "description": "Civil engineering design tools for IFC4x3 alignments", - "doc_url": "https://docs.ifcopenshell.org/", - "category": "Import-Export", -} - -import sys - -IN_BLENDER = sys.modules.get("bpy", None) is not None - -if IN_BLENDER: - from . import civil - - def register(): - civil.register() - - def unregister(): - civil.unregister() diff --git a/src/saikei/saikei/blender_manifest.toml b/src/saikei/saikei/blender_manifest.toml deleted file mode 100644 index 5738363d33..0000000000 --- a/src/saikei/saikei/blender_manifest.toml +++ /dev/null @@ -1,22 +0,0 @@ -schema_version = "1.0.0" - -id = "saikei_civil" -version = "0.1.0" -name = "Saikei Civil" -tagline = "Civil engineering design tools for IFC4x3 alignments" -maintainer = "IfcOpenShell Contributors" -type = "add-on" - -# Blender version requirements -blender_version_min = "4.2.0" - -# License -license = ["SPDX:GPL-3.0-or-later"] - -# Website -website = "https://github.com/IfcOpenShell/IfcOpenShell" - -# Categories -[permissions] -files = "Import/export alignment data from CSV files" -network = "Access online resources for civil engineering standards" diff --git a/src/saikei/saikei/civil/__init__.py b/src/saikei/saikei/civil/__init__.py deleted file mode 100644 index 850536d050..0000000000 --- a/src/saikei/saikei/civil/__init__.py +++ /dev/null @@ -1,79 +0,0 @@ -# ============================================================================== -# 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 -# ============================================================================== - - -""" -Civil module - Core civil engineering functionality - -This module follows Bonsai's architecture pattern with dynamic module loading. -""" - -import bpy -import importlib -from . import handler, ui, prop, operator - -# Feature modules - add new modules here -modules = { - "alignment": None, - # Future modules: - # "vertical": None, - # "corridor": None, - # "cross_section": None, -} - -# Dynamically import all modules using relative import path -# Use __name__ to get the correct package path regardless of how Blender loads us -for name in modules.keys(): - modules[name] = importlib.import_module(f".module.{name}", package=__name__) - -# Collect all classes from global and module files -classes = [ - prop.SaikeiCivilProperties, -] - -# Add classes from each feature module -for mod in modules.values(): - classes.extend(mod.classes) - - -def register(): - """Register all classes and properties""" - for cls in classes: - bpy.utils.register_class(cls) - - # Register global properties - bpy.types.Scene.SaikeiCivilProperties = bpy.props.PointerProperty(type=prop.SaikeiCivilProperties) - - # Register each module - for mod in modules.values(): - mod.register() - - -def unregister(): - """Unregister all classes and properties in reverse order""" - # Unregister modules first - for mod in reversed(list(modules.values())): - mod.unregister() - - # Remove global properties - del bpy.types.Scene.SaikeiCivilProperties - - # Unregister classes - for cls in reversed(classes): - bpy.utils.unregister_class(cls) diff --git a/src/saikei/saikei/civil/handler.py b/src/saikei/saikei/civil/handler.py deleted file mode 100644 index cd4a6a1bf0..0000000000 --- a/src/saikei/saikei/civil/handler.py +++ /dev/null @@ -1,29 +0,0 @@ -# ============================================================================== -# 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 -# ============================================================================== - - -"""Blender event handlers for Saikei Civil - -This module will contain handlers for: -- Undo/redo synchronization -- Real-time updates during PI editing -- File load/save hooks -""" - -# Placeholder for future handler implementations diff --git a/src/saikei/saikei/civil/module/__init__.py b/src/saikei/saikei/civil/module/__init__.py deleted file mode 100644 index e143960a88..0000000000 --- a/src/saikei/saikei/civil/module/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -# ============================================================================== -# 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 -# ============================================================================== - - -"""Feature modules for Saikei Civil""" diff --git a/src/saikei/saikei/civil/module/alignment/__init__.py b/src/saikei/saikei/civil/module/alignment/__init__.py deleted file mode 100644 index 97b9532e47..0000000000 --- a/src/saikei/saikei/civil/module/alignment/__init__.py +++ /dev/null @@ -1,106 +0,0 @@ -# ============================================================================== -# 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 module for Saikei Civil - -This module provides horizontal alignment tools using ifcopenshell.api.alignment. -""" - -import bpy -from bpy.app.handlers import persistent -from . import ui, prop, operator - - -@persistent -def on_undo_redo(scene): - """Handler called after undo/redo to sync PI Editor with IFC. - - When Blender undoes, both Blender properties and IFC state may change. - This handler syncs the PI Editor to reflect the current IFC state: - - If the active alignment still exists, extracts PI data from IFC segments - - If the alignment was deleted/invalidated, clears the PI Editor - - Rebuilds display_rows to match the synced state - """ - if not hasattr(scene, "SaikeiAlignmentProperties"): - return - props = scene.SaikeiAlignmentProperties - # Sync PI Editor from IFC to ensure consistency after undo/redo - operator.sync_pis_from_ifc(props) - - -# All classes that need to be registered with Blender -classes = ( - # Property groups (must be registered before classes that use them) - prop.AlignmentPI, - prop.AlignmentSegmentItem, - prop.AlignmentDisplayRow, - prop.SaikeiAlignmentProperties, - # UILists - ui.SAIKEI_UL_alignment_pis, - # Operators - PI Management - operator.SAIKEI_OT_add_pi, - operator.SAIKEI_OT_remove_pi, - operator.SAIKEI_OT_pick_pi_from_viewport, - operator.SAIKEI_OT_recalculate_pis, - operator.SAIKEI_OT_clear_pis, - # Operators - Creation - operator.SAIKEI_OT_create_alignment, - operator.SAIKEI_OT_create_alignment_by_pi, - operator.SAIKEI_OT_import_alignment_csv, - operator.SAIKEI_OT_create_alignment_polyline, - operator.SAIKEI_OT_create_alignment_offset, - # Operators - Layout - operator.SAIKEI_OT_add_vertical_layout, - operator.SAIKEI_OT_add_layout_segment, - operator.SAIKEI_OT_layout_horizontal_by_pi, - operator.SAIKEI_OT_layout_vertical_by_pi, - # Operators - Stationing - operator.SAIKEI_OT_add_stationing_referent, - operator.SAIKEI_OT_name_segments, - # Operators - Utilities - operator.SAIKEI_OT_create_representation, - operator.SAIKEI_OT_create_segment_representations, - operator.SAIKEI_OT_update_fallback_position, - operator.SAIKEI_OT_validate_segments, - operator.SAIKEI_OT_refresh_alignment_data, - # UI Panels - ui.SAIKEI_PT_horizontal_alignment, - ui.SAIKEI_PT_alignment_creation, - ui.SAIKEI_PT_pi_editor, - ui.SAIKEI_PT_alignment_stationing, -) - - -def register(): - """Register alignment module properties and handlers""" - bpy.types.Scene.SaikeiAlignmentProperties = bpy.props.PointerProperty(type=prop.SaikeiAlignmentProperties) - # Register undo/redo handlers to keep display_rows in sync - bpy.app.handlers.undo_post.append(on_undo_redo) - bpy.app.handlers.redo_post.append(on_undo_redo) - - -def unregister(): - """Unregister alignment module properties and handlers""" - # Unregister handlers - if on_undo_redo in bpy.app.handlers.undo_post: - bpy.app.handlers.undo_post.remove(on_undo_redo) - if on_undo_redo in bpy.app.handlers.redo_post: - bpy.app.handlers.redo_post.remove(on_undo_redo) - del bpy.types.Scene.SaikeiAlignmentProperties diff --git a/src/saikei/saikei/civil/module/alignment/data.py b/src/saikei/saikei/civil/module/alignment/data.py deleted file mode 100644 index 1a4dc6d3d7..0000000000 --- a/src/saikei/saikei/civil/module/alignment/data.py +++ /dev/null @@ -1,75 +0,0 @@ -# ============================================================================== -# 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 -# ============================================================================== - - -"""Data caching layer for the alignment module - -This module provides cached access to alignment data for UI display, -following Bonsai's data loading pattern. -""" - - -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: - """Cached alignment data for UI display""" - - data = {} - is_loaded = False - - @classmethod - def load(cls): - """Load alignment data from IFC file""" - cls.data = { - "alignments": [], - "active_alignment": None, - "segments": [], - } - - ifc = get_ifc_file() - if ifc is None: - cls.is_loaded = True - return - - # Load all alignments - alignments = ifc.by_type("IfcAlignment") - cls.data["alignments"] = [ - { - "id": a.id(), - "name": a.Name or f"Alignment {a.id()}", - "global_id": a.GlobalId, - } - for a in alignments - ] - - cls.is_loaded = True - - @classmethod - def refresh(cls): - """Force refresh of alignment data""" - cls.is_loaded = False - cls.load() diff --git a/src/saikei/saikei/civil/module/alignment/operator.py b/src/saikei/saikei/civil/module/alignment/operator.py deleted file mode 100644 index 6950d8a5fa..0000000000 --- a/src/saikei/saikei/civil/module/alignment/operator.py +++ /dev/null @@ -1,1659 +0,0 @@ -# ============================================================================== -# 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 -# ============================================================================== - - -"""Operators for the alignment module - -These operators wrap ifcopenshell.api.alignment functions and integrate -with Bonsai for IFC file management. - -Architecture (following Bonsai pattern): -- Operators call core functions, passing tool implementations -- core/ contains pure Python logic (no bpy) -- tool/ contains Blender implementations -""" - -import math - -import bpy -from bpy.types import Operator -from bpy.props import StringProperty, FloatProperty, IntProperty -from bpy_extras.io_utils import ImportHelper - -import ifcopenshell.api.alignment - -# Import from Saikei's layered architecture (relative imports) -# From civil/module/alignment/operator.py -> go up 4 levels to package root -from .... import tool -from ....core import alignment as core - - -def poll_ifc4x3(cls, context): - """Standard poll method for IFC4X3 requirement""" - ifc = tool.Alignment.get_ifc_file() - if ifc is None: - cls.poll_message_set("No IFC file loaded. Open an IFC file via Bonsai.") - return False - if ifc.schema != "IFC4X3": - cls.poll_message_set(f"Schema is {ifc.schema}. Alignments require IFC4X3.") - return False - return True - - -def get_alignment_by_id(ifc, alignment_id): - """Safely get an alignment by ID, returning None if not found. - - This handles the case where the IFC entity no longer exists - (e.g., after undo or external modification). - """ - if alignment_id == 0: - return None - try: - entity = ifc.by_id(alignment_id) - # Verify it's actually an alignment - if entity and entity.is_a("IfcAlignment"): - return entity - return None - except RuntimeError: - # Entity not found in IFC file - return None - - -def clear_invalid_alignment_reference(props): - """Clear active alignment reference if it's invalid.""" - props.active_alignment_id = 0 - props.active_alignment_name = "" - - -def sync_pis_from_ifc(props): - """Sync PI Editor data from IFC alignment. - - This is called on undo/redo to ensure the PI Editor reflects the current - IFC state. It extracts PI data from the alignment's horizontal segments. - - If no active alignment exists or it's invalid, clears the PI Editor. - - Returns: - bool: True if sync was successful, False if alignment was cleared. - """ - ifc = tool.Alignment.get_ifc_file() - if ifc is None: - # No IFC file - clear everything - props.pis.clear() - props.active_pi_index = 0 - clear_invalid_alignment_reference(props) - rebuild_display_rows(props) - return False - - if props.active_alignment_id == 0: - # No active alignment - just rebuild display - rebuild_display_rows(props) - return True - - alignment = get_alignment_by_id(ifc, props.active_alignment_id) - if alignment is None: - # Alignment no longer exists - clear everything - props.pis.clear() - props.active_pi_index = 0 - clear_invalid_alignment_reference(props) - rebuild_display_rows(props) - return False - - # Alignment exists - extract PI data from IFC segments - h_layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment) - if not h_layout: - # No horizontal layout - rebuild display with current props - rebuild_display_rows(props) - return True - - segments = ifcopenshell.api.alignment.get_layout_segments(h_layout) - if not segments: - # No segments - rebuild display with current props - rebuild_display_rows(props) - return True - - # Extract PIs from segment data - # This reconstructs approximate PIs from the IFC segment geometry - extracted_pis = _extract_pis_from_segments(segments) - - if not extracted_pis: - # Couldn't extract - keep current props.pis - rebuild_display_rows(props) - return True - - # Update props.pis with extracted data - props.pis.clear() - for pi_data in extracted_pis: - pi = props.pis.add() - pi.x = pi_data["x"] - pi.y = pi_data["y"] - pi.pi_type = pi_data["pi_type"] - pi.radius = pi_data.get("radius", 0.0) - - props.active_pi_index = 0 - - # Recalculate geometry and rebuild display - recalculate_pi_geometry(props) - return True - - -def _extract_pis_from_segments(segments): - """Extract PI data from IFC alignment segments. - - This reconstructs PI coordinates and types from the horizontal segment - design parameters. It handles: - - LINE segments (tangent lines) - - CIRCULARARC segments (horizontal curves) - - Args: - segments: List of IfcAlignmentSegment entities - - Returns: - List of dicts with keys: x, y, pi_type, radius (optional) - """ - pis = [] - - # Filter out zero-length terminal segments - real_segments = [] - for seg in segments: - if hasattr(seg, "DesignParameters") and seg.DesignParameters: - dp = seg.DesignParameters - if dp.SegmentLength > 0.0001: - real_segments.append(seg) - - if not real_segments: - return [] - - # Track which segments are curves and their indices - curve_indices = set() - for i, seg in enumerate(real_segments): - dp = seg.DesignParameters - if dp.PredefinedType == "CIRCULARARC": - curve_indices.add(i) - - # First PI: start of first segment - first_dp = real_segments[0].DesignParameters - start_coords = first_dp.StartPoint.Coordinates - pis.append( - { - "x": float(start_coords[0]), - "y": float(start_coords[1]), - "pi_type": "ENDPOINT", - "radius": 0.0, - } - ) - - # Process interior points - i = 0 - while i < len(real_segments): - dp = real_segments[i].DesignParameters - - if dp.PredefinedType == "CIRCULARARC": - # This is a curve - calculate PI from curve geometry - # PI is at the intersection of incoming and outgoing tangents - pi_data = _calculate_pi_from_curve(real_segments, i) - if pi_data: - pis.append(pi_data) - i += 1 - elif dp.PredefinedType == "LINE": - # Check if next segment is also a LINE (sharp angle, no curve) - if i < len(real_segments) - 1: - next_dp = real_segments[i + 1].DesignParameters - if next_dp.PredefinedType == "LINE": - # End of this LINE is a PI with no curve - end_coords = _calculate_segment_endpoint(dp) - pis.append( - { - "x": float(end_coords[0]), - "y": float(end_coords[1]), - "pi_type": "TANGENT", - "radius": 0.0, - } - ) - i += 1 - else: - # Other segment type - skip for now - i += 1 - - # Last PI: end of last segment - last_dp = real_segments[-1].DesignParameters - end_coords = _calculate_segment_endpoint(last_dp) - # Only add if it's different from the last PI we added - if pis: - last_pi = pis[-1] - dist = math.sqrt((end_coords[0] - last_pi["x"]) ** 2 + (end_coords[1] - last_pi["y"]) ** 2) - if dist > 0.001: # More than 1mm apart - pis.append( - { - "x": float(end_coords[0]), - "y": float(end_coords[1]), - "pi_type": "ENDPOINT", - "radius": 0.0, - } - ) - - return pis - - -def _calculate_segment_endpoint(design_params): - """Calculate the endpoint of a horizontal segment. - - Args: - design_params: IfcAlignmentHorizontalSegment - - Returns: - Tuple (x, y) of endpoint coordinates - """ - start = design_params.StartPoint.Coordinates - start_x = float(start[0]) - start_y = float(start[1]) - - # StartDirection is in radians (counter-clockwise from east) - direction = float(design_params.StartDirection) - length = float(design_params.SegmentLength) - - if design_params.PredefinedType == "LINE": - # Simple line endpoint - end_x = start_x + length * math.cos(direction) - end_y = start_y + length * math.sin(direction) - return (end_x, end_y) - - elif design_params.PredefinedType == "CIRCULARARC": - # Arc endpoint calculation - radius = abs(float(design_params.StartRadiusOfCurvature or design_params.EndRadiusOfCurvature or 0)) - if radius == 0: - # Fallback to line calculation - end_x = start_x + length * math.cos(direction) - end_y = start_y + length * math.sin(direction) - return (end_x, end_y) - - # Determine curve direction (clockwise or counter-clockwise) - start_radius = design_params.StartRadiusOfCurvature - is_clockwise = start_radius is not None and start_radius < 0 - - # Arc length to angle: theta = L / R - theta = length / radius - - if is_clockwise: - # Center is to the right of start direction - center_dir = direction - math.pi / 2 - end_dir = direction - theta - else: - # Center is to the left of start direction - center_dir = direction + math.pi / 2 - end_dir = direction + theta - - # Calculate center - center_x = start_x + radius * math.cos(center_dir) - center_y = start_y + radius * math.sin(center_dir) - - # Calculate endpoint - if is_clockwise: - end_x = center_x + radius * math.cos(end_dir + math.pi / 2) - end_y = center_y + radius * math.sin(end_dir + math.pi / 2) - else: - end_x = center_x + radius * math.cos(end_dir - math.pi / 2) - end_y = center_y + radius * math.sin(end_dir - math.pi / 2) - - return (end_x, end_y) - - else: - # Unknown type - linear approximation - end_x = start_x + length * math.cos(direction) - end_y = start_y + length * math.sin(direction) - return (end_x, end_y) - - -def _calculate_pi_from_curve(segments, curve_index): - """Calculate the PI point from a curve segment. - - The PI is at the intersection of the incoming and outgoing tangents. - For a circular arc: PI = PC + T * incoming_tangent = PT + T * (-outgoing_tangent) - where T = R * tan(delta/2). - - Args: - segments: List of all segments - curve_index: Index of the curve segment - - Returns: - Dict with PI data, or None if can't calculate - """ - curve_seg = segments[curve_index] - curve_dp = curve_seg.DesignParameters - - if curve_dp.PredefinedType != "CIRCULARARC": - return None - - # Get curve parameters - pc_coords = curve_dp.StartPoint.Coordinates - pc_x = float(pc_coords[0]) - pc_y = float(pc_coords[1]) - - start_dir = float(curve_dp.StartDirection) # Incoming tangent direction - arc_length = float(curve_dp.SegmentLength) - - radius = abs(float(curve_dp.StartRadiusOfCurvature or curve_dp.EndRadiusOfCurvature or 0)) - if radius == 0: - return None - - # Determine if clockwise - start_radius = curve_dp.StartRadiusOfCurvature - is_clockwise = start_radius is not None and start_radius < 0 - - # Calculate deflection angle from arc length: delta = L / R - delta = arc_length / radius - - # Calculate tangent length: T = R * tan(delta/2) - tangent_length = radius * math.tan(delta / 2) - - # PI = PC + T * incoming_tangent_unit_vector - pi_x = pc_x + tangent_length * math.cos(start_dir) - pi_y = pc_y + tangent_length * math.sin(start_dir) - - return { - "x": pi_x, - "y": pi_y, - "pi_type": "CURVE", - "radius": radius, - } - - -# ============================================================================= -# Curve Geometry Helper Functions -# ============================================================================= - - -def compute_deflection_angle(prev_pi, curr_pi, next_pi): - """Compute the deflection angle at a PI point. - - Args: - prev_pi: Previous PI (with x, y attributes) - curr_pi: Current PI (with x, y attributes) - next_pi: Next PI (with x, y attributes) - - Returns: - Deflection angle in radians (signed: positive=left, negative=right) - """ - # Incoming tangent direction - dx1 = curr_pi.x - prev_pi.x - dy1 = curr_pi.y - prev_pi.y - angle1 = math.atan2(dy1, dx1) - - # Outgoing tangent direction - dx2 = next_pi.x - curr_pi.x - dy2 = next_pi.y - curr_pi.y - angle2 = math.atan2(dy2, dx2) - - # Deflection angle - deflection = angle2 - angle1 - - # Normalize to [-pi, pi] - while deflection > math.pi: - deflection -= 2 * math.pi - while deflection < -math.pi: - deflection += 2 * math.pi - - return deflection - - -def compute_arc_length_for_pi(props, pi_index): - """Compute arc length for a curve at the given PI. - - Arc length L = R * |delta| where delta is the deflection angle. - - Args: - props: SaikeiAlignmentProperties - pi_index: Index of the PI with the curve - - Returns: - Arc length in same units as radius (meters) - """ - pis = props.pis - if pi_index <= 0 or pi_index >= len(pis) - 1: - return 0.0 - - prev_pi = pis[pi_index - 1] - curr_pi = pis[pi_index] - next_pi = pis[pi_index + 1] - - if curr_pi.radius <= 0: - return 0.0 - - deflection = compute_deflection_angle(prev_pi, curr_pi, next_pi) - return curr_pi.radius * abs(deflection) - - -def compute_tangent_length_at_pi(props, pi_index): - """Compute the tangent length T at a PI with a curve. - - Tangent length T = R * tan(|delta|/2) - - Args: - props: SaikeiAlignmentProperties - pi_index: Index of the PI with the curve - - Returns: - Tangent length (distance from PI to PC or PT) - """ - pis = props.pis - if pi_index <= 0 or pi_index >= len(pis) - 1: - return 0.0 - - prev_pi = pis[pi_index - 1] - curr_pi = pis[pi_index] - next_pi = pis[pi_index + 1] - - if curr_pi.radius <= 0: - return 0.0 - - deflection = compute_deflection_angle(prev_pi, curr_pi, next_pi) - return curr_pi.radius * math.tan(abs(deflection) / 2) - - -def compute_segment_length(props, start_pi_index, account_for_curves=True): - """Compute the length of a tangent segment between two PIs. - - If curves exist at the start or end PI, the segment is shortened - to PC (Point of Curvature) or PT (Point of Tangency). - - Args: - props: SaikeiAlignmentProperties - start_pi_index: Index of the starting PI - account_for_curves: If True, subtract tangent lengths for adjacent curves - - Returns: - Segment length in meters - """ - pis = props.pis - if start_pi_index < 0 or start_pi_index >= len(pis) - 1: - return 0.0 - - start_pi = pis[start_pi_index] - end_pi = pis[start_pi_index + 1] - - # Full length between PIs - dx = end_pi.x - start_pi.x - dy = end_pi.y - start_pi.y - full_length = math.sqrt(dx * dx + dy * dy) - - if not account_for_curves: - return full_length - - # Subtract tangent length if start PI has a curve (segment starts at PT) - if start_pi_index > 0 and start_pi.radius > 0: - full_length -= compute_tangent_length_at_pi(props, start_pi_index) - - # Subtract tangent length if end PI has a curve (segment ends at PC) - if start_pi_index + 1 < len(pis) - 1 and end_pi.radius > 0: - full_length -= compute_tangent_length_at_pi(props, start_pi_index + 1) - - return max(0.0, full_length) - - -def on_radius_changed(pi, context): - """Callback when PI radius is changed. Triggers geometry recalculation. - - This is called from the AlignmentPI.radius property's update callback. - When a radius is entered on a Mid point, this triggers: - 1. Recalculation of PI geometry (lengths, stations) - 2. Rebuild of display_rows (Mid point becomes Curve segment) - 3. If an active alignment exists, regeneration of IFC entities - """ - props = context.scene.SaikeiAlignmentProperties - recalculate_pi_geometry(props) - - # If there's an active alignment, trigger IFC regeneration - # This is handled by recalculate_pi_geometry when active_alignment_id is set - - -def recalculate_pi_geometry(props): - """Recalculate lengths and stations for all PIs using core logic.""" - pis = props.pis - if len(pis) < 2: - rebuild_display_rows(props) - return - - # Extract PI coordinates for pure Python calculation - pi_coords = [(pi.x, pi.y) for pi in pis] - - # Use core function for calculation - result = core.calculate_pi_geometry(pi_coords, props.start_station) - - # Update Blender properties with results - tool.Alignment.update_pi_properties(props, result) - - # Rebuild the display rows for the interleaved table view - rebuild_display_rows(props) - - -def rebuild_display_rows(props): - """Rebuild the display_rows collection from the pis collection. - - Creates an interleaved view of points and segments in Civil 3D style: - End point (POB) - Tangent segment 1 - Mid point (or Curve segment if radius > 0) - Tangent segment 2 - End point (POE) - - When a Mid point has a curve (radius > 0), it becomes a Curve segment row - instead of a point row, showing PI coordinates + arc length + radius. - """ - props.display_rows.clear() - - pis = props.pis - if len(pis) == 0: - return - - segment_num = 0 - i = 0 - - while i < len(pis): - pi = pis[i] - is_interior = i > 0 and i < len(pis) - 1 - has_curve = is_interior and pi.radius > 0 - - if has_curve: - # Interior PI with curve: becomes a CURVE SEGMENT row - # This replaces what would have been a Mid point row - segment_num += 1 - curve_row = props.display_rows.add() - curve_row.row_type = "SEGMENT" - curve_row.segment_number = segment_num - curve_row.pi_index = i - curve_row.display_type = "Curve" - curve_row.x = pi.x # Show PI coordinates on curve row - curve_row.y = pi.y - curve_row.radius = pi.radius - curve_row.arc_length = compute_arc_length_for_pi(props, i) - else: - # Regular point row (End or Mid without curve) - point_row = props.display_rows.add() - point_row.row_type = "POINT" - point_row.pi_index = i - - if pi.pi_type == "ENDPOINT": - point_row.display_type = "End" - else: - point_row.display_type = "Mid" - - point_row.x = pi.x - point_row.y = pi.y - - # Add tangent segment row after this point/curve (except after last PI) - if i < len(pis) - 1: - # Check if next PI also has a curve (affects segment length calculation) - next_pi = pis[i + 1] - next_has_curve = (i + 1 < len(pis) - 1) and next_pi.radius > 0 - - segment_num += 1 - seg_row = props.display_rows.add() - seg_row.row_type = "SEGMENT" - seg_row.segment_number = segment_num - seg_row.pi_index = i - seg_row.display_type = "Tan" - - # Compute segment length accounting for curves at either end - seg_row.length = compute_segment_length(props, i, account_for_curves=True) - - i += 1 - - -# ============================================================================= -# PI Management Operators -# ============================================================================= - - -class SAIKEI_OT_add_pi(Operator): - """Add a new PI point to the list""" - - bl_idname = "saikei.add_pi" - bl_label = "Add PI" - bl_description = "Add a new PI (Point of Intersection) to the alignment" - bl_options = {"REGISTER", "UNDO"} - - @classmethod - def poll(cls, context): - return poll_ifc4x3(cls, context) - - def execute(self, context): - props = context.scene.SaikeiAlignmentProperties - - # Add new PI - pi = props.pis.add() - - # Set default position based on existing PIs - if len(props.pis) == 1: - # First PI - start at origin - pi.x = 0.0 - pi.y = 0.0 - pi.pi_type = "ENDPOINT" - elif len(props.pis) == 2: - # Second PI - offset from first - prev = props.pis[0] - pi.x = prev.x + 100.0 - pi.y = prev.y - pi.pi_type = "ENDPOINT" - else: - # Additional PIs - extrapolate from last two - prev = props.pis[-2] - prev_prev = props.pis[-3] if len(props.pis) > 2 else prev - dx = prev.x - prev_prev.x if len(props.pis) > 2 else 100.0 - dy = prev.y - prev_prev.y if len(props.pis) > 2 else 0.0 - pi.x = prev.x + dx - pi.y = prev.y + dy - pi.pi_type = "TANGENT" - - # Previous endpoint becomes tangent or curve - props.pis[-2].pi_type = "TANGENT" - - # Make new PI active - props.active_pi_index = len(props.pis) - 1 - - # Recalculate geometry - recalculate_pi_geometry(props) - - return {"FINISHED"} - - -class SAIKEI_OT_remove_pi(Operator): - """Remove the selected PI point""" - - bl_idname = "saikei.remove_pi" - bl_label = "Remove PI" - bl_description = "Remove the selected PI from the alignment" - bl_options = {"REGISTER", "UNDO"} - - @classmethod - def poll(cls, context): - if not poll_ifc4x3(cls, context): - return False - props = context.scene.SaikeiAlignmentProperties - if len(props.pis) == 0: - cls.poll_message_set("No PIs to remove") - return False - # Check if a POINT row is selected (can't remove from SEGMENT row selection) - if props.display_rows: - idx = props.active_display_row_index - if 0 <= idx < len(props.display_rows): - if props.display_rows[idx].row_type != "POINT": - cls.poll_message_set("Select a point row to remove") - return False - return True - - def execute(self, context): - props = context.scene.SaikeiAlignmentProperties - - # Get the PI index from the selected display row - pi_index = -1 - if props.display_rows: - idx = props.active_display_row_index - if 0 <= idx < len(props.display_rows): - row = props.display_rows[idx] - if row.row_type == "POINT": - pi_index = row.pi_index - - # Fallback to active_pi_index if display_rows isn't being used - if pi_index < 0: - pi_index = props.active_pi_index - - if 0 <= pi_index < len(props.pis): - props.pis.remove(pi_index) - props.active_pi_index = min(pi_index, len(props.pis) - 1) - - # Recalculate geometry (also rebuilds display_rows) - recalculate_pi_geometry(props) - - # Reset display row index to first row if needed - if len(props.display_rows) > 0: - props.active_display_row_index = min(props.active_display_row_index, len(props.display_rows) - 1) - else: - props.active_display_row_index = 0 - - return {"FINISHED"} - - -class SAIKEI_OT_pick_pi_from_viewport(Operator): - """Add PI points by clicking in the 3D viewport""" - - bl_idname = "saikei.pick_pi_from_viewport" - bl_label = "Pick PI from Viewport" - bl_description = "Click in the viewport to add PI points. Right-click or Escape to finish." - bl_options = {"REGISTER", "UNDO"} - - @classmethod - def poll(cls, context): - return poll_ifc4x3(cls, context) - - def invoke(self, context, event): - context.window.cursor_set("CROSSHAIR") - context.window_manager.modal_handler_add(self) - self.report({"INFO"}, "Click to add PIs. Right-click or Escape to finish.") - return {"RUNNING_MODAL"} - - def modal(self, context, event): - if event.type == "LEFTMOUSE" and event.value == "PRESS": - # Raycast to ground plane (Z=0) - coord = self.get_ground_intersection(context, event) - if coord: - self.add_pi_at_location(context, coord) - context.area.tag_redraw() - return {"RUNNING_MODAL"} - - elif event.type in {"RIGHTMOUSE", "ESC"}: - context.window.cursor_set("DEFAULT") - self.report({"INFO"}, "Finished adding PIs") - return {"FINISHED"} - - # Allow viewport navigation - elif event.type in {"MIDDLEMOUSE", "WHEELUPMOUSE", "WHEELDOWNMOUSE"}: - return {"PASS_THROUGH"} - - return {"RUNNING_MODAL"} - - def get_ground_intersection(self, context, event): - """Raycast from mouse to Z=0 ground plane""" - from bpy_extras.view3d_utils import region_2d_to_origin_3d, region_2d_to_vector_3d - - region = context.region - rv3d = context.region_data - coord = (event.mouse_region_x, event.mouse_region_y) - - origin = region_2d_to_origin_3d(region, rv3d, coord) - direction = region_2d_to_vector_3d(region, rv3d, coord) - - # Intersect with Z=0 plane - if direction.z != 0: - t = -origin.z / direction.z - if t > 0: # In front of camera - hit = origin + direction * t - return (hit.x, hit.y) - return None - - def add_pi_at_location(self, context, coord): - """Add a new PI at the given (x, y) coordinate""" - props = context.scene.SaikeiAlignmentProperties - - pi = props.pis.add() - pi.x = coord[0] - pi.y = coord[1] - - # Determine PI type based on position in list - if len(props.pis) == 1: - pi.pi_type = "ENDPOINT" - elif len(props.pis) == 2: - pi.pi_type = "ENDPOINT" - else: - pi.pi_type = "TANGENT" - # Previous endpoint becomes tangent - if len(props.pis) >= 2: - props.pis[-2].pi_type = "TANGENT" - - props.active_pi_index = len(props.pis) - 1 - recalculate_pi_geometry(props) - - -class SAIKEI_OT_recalculate_pis(Operator): - """Recalculate PI geometry and update IFC/visualization""" - - bl_idname = "saikei.recalculate_pis" - bl_label = "Recalculate PIs" - bl_description = "Recalculate geometry, update IFC segments, and refresh visualization" - bl_options = {"REGISTER", "UNDO"} - - @classmethod - def poll(cls, context): - if not poll_ifc4x3(cls, context): - return False - props = context.scene.SaikeiAlignmentProperties - if len(props.pis) < 2: - cls.poll_message_set("Need at least 2 PIs to recalculate") - return False - return True - - def execute(self, context): - ifc = tool.Alignment.get_ifc_file() - props = context.scene.SaikeiAlignmentProperties - - # Recalculate geometry in UI properties - recalculate_pi_geometry(props) - - # If there's an active alignment, recreate it with updated data - # We recreate the entire alignment because modifying segments in place - # can leave the IFC layout in an inconsistent state - if props.active_alignment_id != 0: - alignment = get_alignment_by_id(ifc, props.active_alignment_id) - if alignment is None: - # Alignment no longer exists (e.g., after undo) - clear reference - clear_invalid_alignment_reference(props) - self.report({"WARNING"}, "Active alignment no longer exists. Reference cleared.") - return {"FINISHED"} - if alignment: - # Save the alignment name - alignment_name = alignment.Name or props.new_alignment_name - - # Remove the entire alignment hierarchy (Blender objects) - tool.Alignment.remove_alignment_hierarchy(alignment) - - # Remove the IFC alignment entity entirely - ifcopenshell.api.run("root.remove_product", ifc, product=alignment) - - # Collect updated PI data - hpoints = [(pi.x, pi.y) for pi in props.pis] - radii = [pi.radius for pi in props.pis[1:-1]] - - # Create a fresh alignment with the same name - # Use safe wrapper to validate/cleanup before creating - new_alignment = tool.Alignment.safe_create_alignment_by_pi_method( - ifc, - name=alignment_name, - hpoints=hpoints, - radii=radii, - start_station=props.start_station, - ) - - # Create Blender hierarchy for the new alignment - tool.Alignment.create_hierarchy_for_alignment(new_alignment) - - # Update the active alignment ID to reference the new entity - props.active_alignment_id = new_alignment.id() - props.active_alignment_name = alignment_name - - self.report({"INFO"}, f"Updated alignment '{alignment_name}' with {len(hpoints)} PIs") - return {"FINISHED"} - - # No active alignment - just report geometry recalculation - total_length = sum(pi.length_to_next for pi in props.pis) - self.report({"INFO"}, f"Recalculated {len(props.pis)} PIs, total length: {total_length:.2f}") - return {"FINISHED"} - - -class SAIKEI_OT_clear_pis(Operator): - """Clear all PI points and optionally remove visualization/IFC data""" - - bl_idname = "saikei.clear_pis" - bl_label = "Clear All PIs" - bl_description = "Remove all PI points and clear segment visualization" - bl_options = {"REGISTER", "UNDO"} - - @classmethod - def poll(cls, context): - if not poll_ifc4x3(cls, context): - return False - props = context.scene.SaikeiAlignmentProperties - if len(props.pis) == 0: - cls.poll_message_set("No PIs to clear") - return False - return True - - def invoke(self, context, event): - return context.window_manager.invoke_confirm(self, event) - - def execute(self, context): - ifc = tool.Alignment.get_ifc_file() - props = context.scene.SaikeiAlignmentProperties - - removed_objects = 0 - - # If there's an active alignment, remove it entirely (Blender + IFC) - # This ensures we don't leave the IFC in an inconsistent state - if props.active_alignment_id != 0: - alignment = get_alignment_by_id(ifc, props.active_alignment_id) - if alignment: - # Remove all Blender objects for this alignment - removed_objects = tool.Alignment.remove_alignment_hierarchy(alignment) - - # Remove the IFC alignment entity entirely - ifcopenshell.api.run("root.remove_product", ifc, product=alignment) - - # Clear the active alignment reference - props.active_alignment_id = 0 - props.active_alignment_name = "" - - # Clear the PI list in the UI - props.pis.clear() - props.active_pi_index = 0 - - # Clear the display rows - props.display_rows.clear() - props.active_display_row_index = 0 - - if removed_objects > 0: - self.report({"INFO"}, f"Cleared all PIs and removed {removed_objects} objects") - else: - self.report({"INFO"}, "Cleared all PIs") - return {"FINISHED"} - - -# ============================================================================= -# Creation Operators -# ============================================================================= - - -class SAIKEI_OT_create_alignment(Operator): - """Create a new IFC alignment""" - - bl_idname = "saikei.create_alignment" - bl_label = "Create Alignment" - bl_description = "Create a new empty IFC alignment" - bl_options = {"REGISTER", "UNDO"} - - @classmethod - def poll(cls, context): - return poll_ifc4x3(cls, context) - - def execute(self, context): - ifc = tool.Alignment.get_ifc_file() - props = context.scene.SaikeiAlignmentProperties - - alignment = ifcopenshell.api.alignment.create( - ifc, - name=props.new_alignment_name, - ) - - # Create full Blender hierarchy (alignment + layouts + segments) - obj = tool.Alignment.create_hierarchy_for_alignment(alignment) - - # Update UI - props.active_alignment_name = props.new_alignment_name - props.active_alignment_id = alignment.id() - - if obj: - self.report({"INFO"}, f"Created alignment: {props.new_alignment_name}") - else: - self.report({"WARNING"}, f"Created IFC alignment but could not create Blender object") - return {"FINISHED"} - - -class SAIKEI_OT_create_alignment_by_pi(Operator): - """Create alignment using the PI (Point of Intersection) method""" - - bl_idname = "saikei.create_alignment_by_pi" - bl_label = "Create by PI Method" - bl_description = "Create alignment using PI points and curve radii. If an active alignment exists with no segments, adds to it instead of creating new." - bl_options = {"REGISTER", "UNDO"} - - @classmethod - def poll(cls, context): - if not poll_ifc4x3(cls, context): - return False - props = context.scene.SaikeiAlignmentProperties - if len(props.pis) < 2: - cls.poll_message_set("Need at least 2 PI points") - return False - return True - - def execute(self, context): - ifc = tool.Alignment.get_ifc_file() - props = context.scene.SaikeiAlignmentProperties - - # Collect PI data - hpoints = [(pi.x, pi.y) for pi in props.pis] - radii = [pi.radius for pi in props.pis[1:-1]] - - # Check if there's an active alignment we should add to instead of creating new - if props.active_alignment_id != 0: - existing_alignment = get_alignment_by_id(ifc, props.active_alignment_id) - if existing_alignment: - h_layout = ifcopenshell.api.alignment.get_horizontal_layout(existing_alignment) - if h_layout: - # Check if horizontal layout is empty (only has zero-length terminal or no segments) - segments = ifcopenshell.api.alignment.get_layout_segments(h_layout) - has_real_segments = False - for seg in segments: - if hasattr(seg, "DesignParameters") and seg.DesignParameters: - if seg.DesignParameters.SegmentLength > 0.0001: - has_real_segments = True - break - - if not has_real_segments: - # Use existing alignment - add segments to it - # Use safe wrapper to validate layout has parent alignment - tool.Alignment.safe_layout_horizontal_by_pi_method(ifc, h_layout, hpoints, radii) - - # Create/update Blender objects for the segments - alignment_obj = tool.Ifc.get_object(existing_alignment) - h_layout_obj = tool.Ifc.get_object(h_layout) - - if not h_layout_obj and alignment_obj: - h_layout_obj = tool.Alignment.create_object_for_layout(h_layout, alignment_obj) - - if h_layout_obj: - tool.Alignment.create_objects_for_layout_segments(h_layout, h_layout_obj) - - self.report( - {"INFO"}, f"Added {len(hpoints)} PIs to existing alignment '{existing_alignment.Name}'" - ) - return {"FINISHED"} - - # No suitable existing alignment - create a new one - # Use safe wrapper to validate/cleanup before creating - alignment = tool.Alignment.safe_create_alignment_by_pi_method( - ifc, - name=props.new_alignment_name, - hpoints=hpoints, - radii=radii, - start_station=props.start_station, - ) - - # Create full Blender hierarchy (alignment + layouts + segments) - obj = tool.Alignment.create_hierarchy_for_alignment(alignment) - - props.active_alignment_name = props.new_alignment_name - props.active_alignment_id = alignment.id() - - if obj: - self.report({"INFO"}, f"Created new alignment '{props.new_alignment_name}' with {len(hpoints)} PIs") - else: - self.report({"WARNING"}, f"Created IFC alignment but could not create Blender object") - return {"FINISHED"} - - -class SAIKEI_OT_import_alignment_csv(Operator, ImportHelper): - """Import alignment from CSV file""" - - bl_idname = "saikei.import_alignment_csv" - bl_label = "Import Alignment CSV" - bl_description = "Import alignment definition from a CSV file" - bl_options = {"REGISTER", "UNDO"} - - filename_ext = ".csv" - filter_glob: StringProperty(default="*.csv", options={"HIDDEN"}) - - @classmethod - def poll(cls, context): - return poll_ifc4x3(cls, context) - - def execute(self, context): - ifc = tool.Alignment.get_ifc_file() - props = context.scene.SaikeiAlignmentProperties - - alignment = ifcopenshell.api.alignment.create_from_csv(ifc, self.filepath) - - # Create full Blender hierarchy (alignment + layouts + segments) - obj = tool.Alignment.create_hierarchy_for_alignment(alignment) - - props.active_alignment_name = alignment.Name or "Imported Alignment" - props.active_alignment_id = alignment.id() - - self.report({"INFO"}, f"Imported alignment from {self.filepath}") - return {"FINISHED"} - - -class SAIKEI_OT_create_alignment_polyline(Operator): - """Create alignment as a polyline""" - - bl_idname = "saikei.create_alignment_polyline" - bl_label = "Create as Polyline" - bl_description = "Create alignment from a polyline (no curves)" - bl_options = {"REGISTER", "UNDO"} - - @classmethod - def poll(cls, context): - return poll_ifc4x3(cls, context) - - def execute(self, context): - ifc = tool.Alignment.get_ifc_file() - props = context.scene.SaikeiAlignmentProperties - - # Collect points from PIs (no radii) - points = [(pi.x, pi.y) for pi in props.pis] - - if len(points) < 2: - self.report({"ERROR"}, "Need at least 2 points for polyline") - return {"CANCELLED"} - - alignment = ifcopenshell.api.alignment.create_as_polyline( - ifc, - name=props.new_alignment_name, - points=points, - ) - - # Create full Blender hierarchy (alignment + layouts + segments) - obj = tool.Alignment.create_hierarchy_for_alignment(alignment) - - props.active_alignment_name = props.new_alignment_name - props.active_alignment_id = alignment.id() - - self.report({"INFO"}, f"Created polyline alignment with {len(points)} points") - return {"FINISHED"} - - -class SAIKEI_OT_create_alignment_offset(Operator): - """Create alignment as an offset from existing alignment""" - - bl_idname = "saikei.create_alignment_offset" - bl_label = "Create as Offset Curve" - bl_description = "Create a new alignment offset from an existing alignment" - bl_options = {"REGISTER", "UNDO"} - - offset_distance: FloatProperty( - name="Offset Distance", - description="Distance to offset (positive = right, negative = left)", - default=10.0, - unit="LENGTH", - ) - - @classmethod - def poll(cls, context): - if not poll_ifc4x3(cls, context): - return False - props = context.scene.SaikeiAlignmentProperties - if props.active_alignment_id == 0: - cls.poll_message_set("Select an alignment first") - return False - return True - - def invoke(self, context, event): - return context.window_manager.invoke_props_dialog(self) - - def execute(self, context): - ifc = tool.Alignment.get_ifc_file() - props = context.scene.SaikeiAlignmentProperties - - base_alignment = get_alignment_by_id(ifc, props.active_alignment_id) - if base_alignment is None: - clear_invalid_alignment_reference(props) - self.report({"ERROR"}, "Base alignment no longer exists. Reference cleared.") - return {"CANCELLED"} - - alignment = ifcopenshell.api.alignment.create_as_offset_curve( - ifc, - name=f"{props.new_alignment_name} (Offset)", - base_alignment=base_alignment, - offset=self.offset_distance, - ) - - # Create full Blender hierarchy (alignment + layouts + segments) - obj = tool.Alignment.create_hierarchy_for_alignment(alignment) - - self.report({"INFO"}, f"Created offset alignment at {self.offset_distance}m") - return {"FINISHED"} - - -# ============================================================================= -# Layout Operators -# ============================================================================= - - -class SAIKEI_OT_add_vertical_layout(Operator): - """Add vertical layout to an alignment""" - - bl_idname = "saikei.add_vertical_layout" - bl_label = "Add Vertical Layout" - bl_description = "Add an IfcAlignmentVertical to the active alignment" - bl_options = {"REGISTER", "UNDO"} - - @classmethod - def poll(cls, context): - if not poll_ifc4x3(cls, context): - return False - props = context.scene.SaikeiAlignmentProperties - if props.active_alignment_id == 0: - cls.poll_message_set("Select an alignment first") - return False - return True - - def execute(self, context): - ifc = tool.Alignment.get_ifc_file() - props = context.scene.SaikeiAlignmentProperties - - alignment = get_alignment_by_id(ifc, props.active_alignment_id) - if alignment is None: - clear_invalid_alignment_reference(props) - self.report({"ERROR"}, "Alignment no longer exists. Reference cleared.") - return {"CANCELLED"} - - vertical = ifcopenshell.api.alignment.add_vertical_layout(ifc, alignment) - - self.report({"INFO"}, "Added vertical layout") - return {"FINISHED"} - - -class SAIKEI_OT_add_layout_segment(Operator): - """Add a segment to an alignment layout""" - - bl_idname = "saikei.add_layout_segment" - bl_label = "Add Layout Segment" - bl_description = "Add a new segment to the alignment layout" - bl_options = {"REGISTER", "UNDO"} - - @classmethod - def poll(cls, context): - if not poll_ifc4x3(cls, context): - return False - props = context.scene.SaikeiAlignmentProperties - if props.active_alignment_id == 0: - cls.poll_message_set("Select an alignment first") - return False - return True - - def execute(self, context): - # This operator would open a dialog for segment parameters - self.report({"INFO"}, "Add segment - dialog coming soon") - return {"FINISHED"} - - -class SAIKEI_OT_layout_horizontal_by_pi(Operator): - """Layout horizontal alignment using PI method""" - - bl_idname = "saikei.layout_horizontal_by_pi" - bl_label = "Layout Horizontal by PI" - bl_description = "Layout the horizontal alignment using PI points" - bl_options = {"REGISTER", "UNDO"} - - @classmethod - def poll(cls, context): - if not poll_ifc4x3(cls, context): - return False - props = context.scene.SaikeiAlignmentProperties - if props.active_alignment_id == 0: - cls.poll_message_set("Select an alignment first") - return False - if len(props.pis) < 2: - cls.poll_message_set("Need at least 2 PI points") - return False - return True - - def execute(self, context): - ifc = tool.Alignment.get_ifc_file() - props = context.scene.SaikeiAlignmentProperties - - alignment = get_alignment_by_id(ifc, props.active_alignment_id) - if alignment is None: - clear_invalid_alignment_reference(props) - self.report({"ERROR"}, "Alignment no longer exists. Reference cleared.") - return {"CANCELLED"} - - h_layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment) - - if not h_layout: - self.report({"ERROR"}, "Alignment has no horizontal layout") - return {"CANCELLED"} - - pis = [(pi.x, pi.y) for pi in props.pis] - radii = [pi.radius for pi in props.pis[1:-1]] - - # Create the IFC segments - # Use safe wrapper to validate layout has parent alignment - tool.Alignment.safe_layout_horizontal_by_pi_method(ifc, h_layout, pis, radii) - - # Find or create Blender object for the horizontal layout - # Use Bonsai's Ifc tool (re-exported via saikei.tool) - alignment_obj = tool.Ifc.get_object(alignment) - h_layout_obj = tool.Ifc.get_object(h_layout) - - if not h_layout_obj and alignment_obj: - # Create the horizontal layout object if it doesn't exist - h_layout_obj = tool.Alignment.create_object_for_layout(h_layout, alignment_obj) - - # Create Blender objects for the newly created segments - if h_layout_obj: - tool.Alignment.create_objects_for_layout_segments(h_layout, h_layout_obj) - - self.report({"INFO"}, f"Laid out horizontal alignment with {len(pis)} PIs") - return {"FINISHED"} - - -class SAIKEI_OT_layout_vertical_by_pi(Operator): - """Layout vertical alignment using PI method""" - - bl_idname = "saikei.layout_vertical_by_pi" - bl_label = "Layout Vertical by PI" - bl_description = "Layout the vertical alignment using PVI points" - bl_options = {"REGISTER", "UNDO"} - - @classmethod - def poll(cls, context): - if not poll_ifc4x3(cls, context): - return False - props = context.scene.SaikeiAlignmentProperties - if props.active_alignment_id == 0: - cls.poll_message_set("Select an alignment first") - return False - return True - - def execute(self, context): - # This would collect vertical PIs and create vertical layout - self.report({"INFO"}, "Layout vertical - implementation coming soon") - return {"FINISHED"} - - -# ============================================================================= -# Stationing Operators -# ============================================================================= - - -class SAIKEI_OT_add_stationing_referent(Operator): - """Add a stationing referent to the alignment""" - - bl_idname = "saikei.add_stationing_referent" - bl_label = "Add Stationing Referent" - bl_description = "Add an IfcReferent for stationing" - bl_options = {"REGISTER", "UNDO"} - - station: FloatProperty( - name="Station", - description="Station value for the referent (e.g., 10000 for 100+00)", - default=10000.0, - ) - - name: StringProperty( - name="Name", - description="Name for the referent (leave blank to auto-generate)", - default="", - ) - - @classmethod - def poll(cls, context): - if not poll_ifc4x3(cls, context): - return False - props = context.scene.SaikeiAlignmentProperties - if props.active_alignment_id == 0: - cls.poll_message_set("Select an alignment first") - return False - return True - - def invoke(self, context, event): - # Default station to start_station from props - props = context.scene.SaikeiAlignmentProperties - self.station = props.start_station - return context.window_manager.invoke_props_dialog(self) - - def draw(self, context): - layout = self.layout - layout.prop(self, "station") - layout.prop(self, "name") - # Show station notation preview - station_str = format_station(self.station) - layout.label(text=f"Station notation: {station_str}") - - def execute(self, context): - ifc = tool.Alignment.get_ifc_file() - props = context.scene.SaikeiAlignmentProperties - - alignment = get_alignment_by_id(ifc, props.active_alignment_id) - if alignment is None: - clear_invalid_alignment_reference(props) - self.report({"ERROR"}, "Alignment no longer exists. Reference cleared.") - return {"CANCELLED"} - - # Compute distance_along from station and start_station - # distance_along = station - start_station - distance_along = self.station - props.start_station - - # Auto-generate name if not provided - name = self.name if self.name else format_station(self.station) - - # Use the alignment itself as the positioned product - # (The referent marks a point on the alignment) - positioned_product = alignment - - ifcopenshell.api.alignment.add_stationing_referent( - ifc, - alignment=alignment, - distance_along=distance_along, - station=self.station, - name=name, - positioned_product=positioned_product, - ) - - self.report({"INFO"}, f"Added referent '{name}' at station {self.station}") - return {"FINISHED"} - - -def format_station(station_value): - """Format a station value in standard notation (e.g., 10000 -> '100+00')""" - # Station notation: divide by 100 for the main part, remainder for the offset - # e.g., 10000 -> 100+00, 10050 -> 100+50, 10123.45 -> 101+23.45 - main = int(station_value // 100) - offset = station_value % 100 - if offset == int(offset): - return f"{main}+{int(offset):02d}" - else: - return f"{main}+{offset:05.2f}" - - -class SAIKEI_OT_name_segments(Operator): - """Auto-name segments based on station values""" - - bl_idname = "saikei.name_segments" - bl_label = "Name Segments" - bl_description = "Automatically name segments with station-based labels" - bl_options = {"REGISTER", "UNDO"} - - @classmethod - def poll(cls, context): - if not poll_ifc4x3(cls, context): - return False - props = context.scene.SaikeiAlignmentProperties - if props.active_alignment_id == 0: - cls.poll_message_set("Select an alignment first") - return False - return True - - def execute(self, context): - ifc = tool.Alignment.get_ifc_file() - props = context.scene.SaikeiAlignmentProperties - - alignment = get_alignment_by_id(ifc, props.active_alignment_id) - if alignment is None: - clear_invalid_alignment_reference(props) - self.report({"ERROR"}, "Alignment no longer exists. Reference cleared.") - return {"CANCELLED"} - - ifcopenshell.api.alignment.name_segments(ifc, alignment) - - self.report({"INFO"}, "Named alignment segments") - return {"FINISHED"} - - -# ============================================================================= -# Utility Operators -# ============================================================================= - - -class SAIKEI_OT_create_representation(Operator): - """Create geometric representation for alignment""" - - bl_idname = "saikei.create_representation" - bl_label = "Create Representation" - bl_description = "Create or update the geometric representation" - bl_options = {"REGISTER", "UNDO"} - - @classmethod - def poll(cls, context): - if not poll_ifc4x3(cls, context): - return False - props = context.scene.SaikeiAlignmentProperties - if props.active_alignment_id == 0: - cls.poll_message_set("Select an alignment first") - return False - return True - - def execute(self, context): - ifc = tool.Alignment.get_ifc_file() - props = context.scene.SaikeiAlignmentProperties - - alignment = get_alignment_by_id(ifc, props.active_alignment_id) - if alignment is None: - clear_invalid_alignment_reference(props) - self.report({"ERROR"}, "Alignment no longer exists. Reference cleared.") - return {"CANCELLED"} - - ifcopenshell.api.alignment.create_representation(ifc, alignment) - - self.report({"INFO"}, "Created geometric representation") - return {"FINISHED"} - - -class SAIKEI_OT_create_segment_representations(Operator): - """Create representations for individual segments""" - - bl_idname = "saikei.create_segment_representations" - bl_label = "Create Segment Representations" - bl_description = "Create geometric representations for each segment" - bl_options = {"REGISTER", "UNDO"} - - @classmethod - def poll(cls, context): - if not poll_ifc4x3(cls, context): - return False - props = context.scene.SaikeiAlignmentProperties - if props.active_alignment_id == 0: - cls.poll_message_set("Select an alignment first") - return False - return True - - def execute(self, context): - ifc = tool.Alignment.get_ifc_file() - props = context.scene.SaikeiAlignmentProperties - - alignment = get_alignment_by_id(ifc, props.active_alignment_id) - if alignment is None: - clear_invalid_alignment_reference(props) - self.report({"ERROR"}, "Alignment no longer exists. Reference cleared.") - return {"CANCELLED"} - - ifcopenshell.api.alignment.create_segment_representations(ifc, alignment) - - self.report({"INFO"}, "Created segment representations") - return {"FINISHED"} - - -class SAIKEI_OT_update_fallback_position(Operator): - """Update the fallback position for the alignment""" - - bl_idname = "saikei.update_fallback_position" - bl_label = "Update Fallback Position" - bl_description = "Update the fallback position point" - bl_options = {"REGISTER", "UNDO"} - - @classmethod - def poll(cls, context): - if not poll_ifc4x3(cls, context): - return False - props = context.scene.SaikeiAlignmentProperties - if props.active_alignment_id == 0: - cls.poll_message_set("Select an alignment first") - return False - return True - - def execute(self, context): - ifc = tool.Alignment.get_ifc_file() - props = context.scene.SaikeiAlignmentProperties - - alignment = get_alignment_by_id(ifc, props.active_alignment_id) - if alignment is None: - clear_invalid_alignment_reference(props) - self.report({"ERROR"}, "Alignment no longer exists. Reference cleared.") - return {"CANCELLED"} - - ifcopenshell.api.alignment.update_fallback_position(ifc, alignment) - - self.report({"INFO"}, "Updated fallback position") - return {"FINISHED"} - - -class SAIKEI_OT_validate_segments(Operator): - """Validate alignment segments""" - - bl_idname = "saikei.validate_segments" - bl_label = "Validate Segments" - bl_description = "Check for issues like zero-length segments" - bl_options = {"REGISTER"} - - @classmethod - def poll(cls, context): - if not poll_ifc4x3(cls, context): - return False - props = context.scene.SaikeiAlignmentProperties - if props.active_alignment_id == 0: - cls.poll_message_set("Select an alignment first") - return False - return True - - def execute(self, context): - ifc = tool.Alignment.get_ifc_file() - props = context.scene.SaikeiAlignmentProperties - - alignment = get_alignment_by_id(ifc, props.active_alignment_id) - if alignment is None: - clear_invalid_alignment_reference(props) - self.report({"ERROR"}, "Alignment no longer exists. Reference cleared.") - return {"CANCELLED"} - - h_layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment) - - if h_layout: - has_zero = ifcopenshell.api.alignment.has_zero_length_segment(h_layout) - if has_zero: - self.report({"WARNING"}, "Alignment has zero-length segments") - else: - self.report({"INFO"}, "All segments valid") - else: - self.report({"WARNING"}, "No horizontal layout found") - - return {"FINISHED"} - - -class SAIKEI_OT_refresh_alignment_data(Operator): - """Refresh alignment data display""" - - bl_idname = "saikei.refresh_alignment_data" - bl_label = "Refresh Data" - bl_description = "Refresh the alignment segment list" - bl_options = {"REGISTER"} - - @classmethod - def poll(cls, context): - return poll_ifc4x3(cls, context) - - def execute(self, context): - ifc = tool.Alignment.get_ifc_file() - props = context.scene.SaikeiAlignmentProperties - - # Clear existing segments - props.segments.clear() - - if props.active_alignment_id == 0: - return {"FINISHED"} - - alignment = get_alignment_by_id(ifc, props.active_alignment_id) - if alignment is None: - # Alignment no longer exists - clear reference and return - clear_invalid_alignment_reference(props) - self.report({"WARNING"}, "Alignment no longer exists. Reference cleared.") - return {"FINISHED"} - - h_layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment) - - if h_layout: - segments = ifcopenshell.api.alignment.get_layout_segments(h_layout) - for i, seg in enumerate(segments): - item = props.segments.add() - item.name = f"Segment {i + 1}" - if hasattr(seg, "DesignParameters") and seg.DesignParameters: - dp = seg.DesignParameters - item.segment_type = dp.PredefinedType or "UNKNOWN" - item.length = dp.SegmentLength or 0.0 - item.ifc_id = seg.id() - - self.report({"INFO"}, f"Loaded {len(props.segments)} segments") - return {"FINISHED"} diff --git a/src/saikei/saikei/civil/module/alignment/prop.py b/src/saikei/saikei/civil/module/alignment/prop.py deleted file mode 100644 index 7f8966c3d6..0000000000 --- a/src/saikei/saikei/civil/module/alignment/prop.py +++ /dev/null @@ -1,258 +0,0 @@ -# ============================================================================== -# 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 -# ============================================================================== - - -"""Property groups for the alignment module""" - -import bpy -from bpy.types import PropertyGroup -from bpy.props import ( - StringProperty, - FloatProperty, - IntProperty, - BoolProperty, - CollectionProperty, - EnumProperty, -) - - -def get_pi_type_items(self, context): - """Get available PI types based on position in list""" - # First and last PIs are always endpoints (no curve) - # Interior PIs can have curves - return [ - ("ENDPOINT", "Endpoint", "Start or end point (no curve)"), - ("TANGENT", "Tangent", "Pass-through point (no curve)"), - ("CURVE", "Curve", "Point of intersection with curve"), - ] - - -def _on_radius_update(self, context): - """Callback when radius property changes. - - This dynamically imports the operator module to call on_radius_changed, - avoiding circular imports since prop.py is imported before operator.py. - """ - from . import operator as ops - - ops.on_radius_changed(self, context) - - -class AlignmentPI(PropertyGroup): - """Property group for a single PI (Point of Intersection) - - In the PI method, alignments are defined by: - - Endpoint PIs: Start (POB) and End (POE) points - - Interior PIs: Points where tangents intersect, optionally with curves - """ - - # Coordinates - x: FloatProperty( - name="X", - description="X coordinate (Easting)", - default=0.0, - precision=3, - unit="LENGTH", - ) - - y: FloatProperty( - name="Y", - description="Y coordinate (Northing)", - default=0.0, - precision=3, - unit="LENGTH", - ) - - # PI Type - pi_type: EnumProperty( - name="Type", - description="Type of PI point", - items=[ - ("ENDPOINT", "Endpoint", "Start or end point (no curve)"), - ("TANGENT", "Tangent", "Pass-through point (no curve)"), - ("CURVE", "Curve", "Point of intersection with curve"), - ], - default="TANGENT", - ) - - # Curve parameters (only used when pi_type == "CURVE") - radius: FloatProperty( - name="Radius", - description="Curve radius (0 = no curve, sharp angle)", - default=0.0, - min=0.0, - precision=3, - unit="LENGTH", - update=_on_radius_update, - ) - - # Computed/display values (updated by recalculate operator) - length_to_next: FloatProperty( - name="Length", - description="Length of tangent to next PI", - default=0.0, - precision=3, - unit="LENGTH", - ) - - direction_to_next: FloatProperty( - name="Direction", - description="Bearing/direction to next PI (degrees)", - default=0.0, - precision=4, - subtype="ANGLE", - ) - - # Station at this PI (computed) - station: FloatProperty( - name="Station", - description="Station value at this PI", - default=0.0, - precision=2, - ) - - # Selection state - is_selected: BoolProperty( - name="Selected", - description="Whether this PI is selected for editing", - default=False, - ) - - -class AlignmentSegmentItem(PropertyGroup): - """Property group for displaying alignment segments in a UIList""" - - name: StringProperty(name="Name", default="") - segment_type: StringProperty(name="Type", default="LINE") - length: FloatProperty(name="Length", default=0.0, unit="LENGTH") - ifc_id: IntProperty(name="IFC ID", default=0) - - -class AlignmentDisplayRow(PropertyGroup): - """Property group for interleaved point/segment display in the table. - - This creates the Civil 3D-style view where points and segments - are shown on separate rows: - Point 1 (End) - Segment 1 (Tan) - Point 2 (Tan) - Segment 2 (Tan) - ... - """ - - # Row type discriminator - row_type: EnumProperty( - name="Row Type", - items=[ - ("POINT", "Point", "A PI point row"), - ("SEGMENT", "Segment", "A segment row between points"), - ], - default="POINT", - ) - - # Segment number (1, 2, 3...) - only for SEGMENT rows - segment_number: IntProperty(name="Segment #", default=0) - - # Point index in the pis collection - for both types - # For POINT rows: the PI index - # For SEGMENT rows: the starting PI index of this segment - pi_index: IntProperty(name="PI Index", default=0) - - # Display type string (End, Tan, Curve for points; Tan, Curve for segments) - display_type: StringProperty(name="Type", default="") - - # Point coordinates (only for POINT rows) - x: FloatProperty(name="X", default=0.0, precision=3, unit="LENGTH") - y: FloatProperty(name="Y", default=0.0, precision=3, unit="LENGTH") - - # Segment properties (only for SEGMENT rows) - length: FloatProperty(name="Length", default=0.0, precision=2, unit="LENGTH") - radius: FloatProperty(name="Radius", default=0.0, precision=2, unit="LENGTH") - arc_length: FloatProperty(name="Arc Length", default=0.0, precision=2, unit="LENGTH") - - -class SaikeiAlignmentProperties(PropertyGroup): - """Properties for the alignment module""" - - # Active alignment selection - active_alignment_id: IntProperty( - name="Active Alignment ID", - description="IFC entity ID of the active alignment", - default=0, - ) - - active_alignment_name: StringProperty( - name="Active Alignment", - description="Name of the currently active alignment", - default="", - ) - - # New alignment creation properties - new_alignment_name: StringProperty( - name="Name", - description="Name for new alignment", - default="Alignment 1", - ) - - start_station: FloatProperty( - name="Start Station", - description="Starting station value (e.g., 10000 for 100+00)", - default=10000.0, - min=0.0, - ) - - # PI collection for PI method creation - pis: CollectionProperty(type=AlignmentPI) - active_pi_index: IntProperty(name="Active PI", default=0) - - # Segment display - segments: CollectionProperty(type=AlignmentSegmentItem) - active_segment_index: IntProperty(name="Active Segment", default=0) - - # Combined point/segment display rows (for Civil 3D-style table) - display_rows: CollectionProperty(type=AlignmentDisplayRow) - active_display_row_index: IntProperty(name="Active Display Row", default=0) - - # Editing state - is_editing: BoolProperty( - name="Is Editing", - description="Whether alignment is being edited", - default=False, - ) - - # Display options - show_pi_markers: BoolProperty( - name="Show PI Markers", - description="Show PI markers in viewport", - default=True, - ) - - show_station_labels: BoolProperty( - name="Show Station Labels", - description="Show station labels along alignment", - default=True, - ) - - station_interval: FloatProperty( - name="Station Interval", - description="Interval between station markers", - default=100.0, - min=1.0, - unit="LENGTH", - ) diff --git a/src/saikei/saikei/civil/module/alignment/ui.py b/src/saikei/saikei/civil/module/alignment/ui.py deleted file mode 100644 index 0047e98a7e..0000000000 --- a/src/saikei/saikei/civil/module/alignment/ui.py +++ /dev/null @@ -1,366 +0,0 @@ -# ============================================================================== -# 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 -# ============================================================================== - - -"""UI panels for the alignment module - -All panels appear in the VIEW_3D N-panel under the "Saikei Civil" tab. -""" - -import bpy -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(): - """Check if the current IFC file is IFC4X3 schema""" - ifc = get_ifc_file() - return ifc is not None and ifc.schema == "IFC4X3" - - -# ============================================================================= -# UILists -# ============================================================================= - - -class SAIKEI_UL_alignment_pis(UIList): - """UIList for displaying interleaved points and segments (Civil 3D style) - - Row types: - - POINT rows: End (endpoint), Mid (interior PI without curve) - - SEGMENT rows: Tan (tangent line), Curve (circular arc) - - When a Mid point has radius > 0, it becomes a Curve segment row. - """ - - def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index): - if self.layout_type in {"DEFAULT", "COMPACT"}: - row = layout.row(align=True) - - if item.row_type == "POINT": - # Point row: No., Type, X, Y, Length, Radius - row.label(text="") # No segment number for points - - # Type with point/dot icon - # "End" = endpoint (POB/POE), "Mid" = interior PI point - row.label(text=item.display_type, icon="DOT") - - # X, Y coordinates - get actual PI for editing - pi = data.pis[item.pi_index] if item.pi_index < len(data.pis) else None - if pi: - sub = row.row(align=True) - sub.prop(pi, "x", text="") - sub.prop(pi, "y", text="") - else: - row.label(text=f"{item.x:.2f}") - row.label(text=f"{item.y:.2f}") - - # Length column - empty for point rows - row.label(text="") - - # Radius column - editable for Mid points (where curves can be added) - if item.display_type == "Mid" and pi: - row.prop(pi, "radius", text="") - else: - row.label(text="") - - elif item.row_type == "SEGMENT": - if item.display_type == "Curve": - # Curve segment row: No., Type (arc icon), X, Y, Arc Length, Radius - row.label(text=f"{item.segment_number}") - row.label(text="Curve", icon="SPHERECURVE") - - # Show PI coordinates on curve row - row.label(text=f"{item.x:.2f}") - row.label(text=f"{item.y:.2f}") - - # Arc length - row.label(text=f"{item.arc_length:.2f}") - - # Radius - editable so user can modify or delete curve (set to 0) - pi = data.pis[item.pi_index] if item.pi_index < len(data.pis) else None - if pi: - row.prop(pi, "radius", text="") - else: - row.label(text=f"{item.radius:.2f}") - else: - # Tangent segment row: No., Type (line icon), -, -, Length, - - row.label(text=f"{item.segment_number}") - row.label(text="Tan", icon="IPO_LINEAR") - - # No X, Y for tangent segments - row.label(text="") - row.label(text="") - - # Length - row.label(text=f"{item.length:.2f}") - - # No radius for tangent segments - row.label(text="-") - - elif self.layout_type == "GRID": - layout.alignment = "CENTER" - layout.label(text="", icon="DECORATE") - - -# ============================================================================= -# Main Panel -# ============================================================================= - - -class SAIKEI_PT_horizontal_alignment(Panel): - """Main Horizontal Alignment panel in the N-panel""" - - bl_label = "Horizontal Alignment" - bl_idname = "SAIKEI_PT_horizontal_alignment" - bl_space_type = "VIEW_3D" - bl_region_type = "UI" - bl_category = "Saikei Civil" - - def draw(self, context): - layout = self.layout - props = context.scene.SaikeiAlignmentProperties - - # Status box - box = layout.box() - ifc = get_ifc_file() - - if ifc is None: - box.label(text="No IFC file loaded", icon="ERROR") - box.label(text="Open an IFC4X3 file via Bonsai") - return - - if ifc.schema != "IFC4X3": - box.label(text=f"Schema: {ifc.schema}", icon="ERROR") - box.label(text="Alignments require IFC4X3") - return - - # IFC file is loaded and correct schema - row = box.row() - row.label(text="IFC4X3", icon="CHECKMARK") - - # Count alignments - alignments = ifc.by_type("IfcAlignment") - row.label(text=f"Alignments: {len(alignments)}") - - # Active alignment selector - if alignments: - box = layout.box() - box.label(text="Active Alignment:", icon="CURVE_PATH") - row = box.row() - row.prop(props, "active_alignment_name", text="") - - -# ============================================================================= -# Creation Sub-Panel -# ============================================================================= - - -class SAIKEI_PT_alignment_creation(Panel): - """Sub-panel for alignment creation tools""" - - bl_label = "Creation" - bl_idname = "SAIKEI_PT_alignment_creation" - bl_space_type = "VIEW_3D" - bl_region_type = "UI" - bl_category = "Saikei Civil" - bl_parent_id = "SAIKEI_PT_horizontal_alignment" - bl_options = {"DEFAULT_CLOSED"} - - @classmethod - def poll(cls, context): - return is_ifc4x3() - - def draw(self, context): - layout = self.layout - props = context.scene.SaikeiAlignmentProperties - - # New alignment properties - box = layout.box() - box.label(text="New Alignment:", icon="ADD") - box.prop(props, "new_alignment_name") - box.prop(props, "start_station") - - # Creation operators - col = layout.column(align=True) - col.operator("saikei.create_alignment", icon="ADD") - col.operator("saikei.create_alignment_by_pi", icon="CURVE_PATH") - col.operator("saikei.import_alignment_csv", icon="IMPORT") - - -# ============================================================================= -# PI Editor Sub-Panel -# ============================================================================= - - -class SAIKEI_PT_pi_editor(Panel): - """Sub-panel for PI point table editor (Civil 3D style grid view)""" - - bl_label = "PI Editor" - bl_idname = "SAIKEI_PT_pi_editor" - bl_space_type = "VIEW_3D" - bl_region_type = "UI" - bl_category = "Saikei Civil" - bl_parent_id = "SAIKEI_PT_horizontal_alignment" - bl_options = set() # Open by default - - @classmethod - def poll(cls, context): - return is_ifc4x3() - - def draw(self, context): - layout = self.layout - props = context.scene.SaikeiAlignmentProperties - - # Header row with column labels - header = layout.row(align=True) - header.label(text="No.") - header.label(text="Type") - header.label(text="X") - header.label(text="Y") - header.label(text="Length") - header.label(text="Radius") - - # Combined point/segment list (interleaved view) - row = layout.row() - row.template_list( - "SAIKEI_UL_alignment_pis", - "", - props, - "display_rows", - props, - "active_display_row_index", - rows=8, - ) - - # Side buttons for list management - col = row.column(align=True) - col.operator("saikei.add_pi", icon="ADD", text="") - col.operator("saikei.remove_pi", icon="REMOVE", text="") - col.separator() - col.operator("saikei.pick_pi_from_viewport", icon="EYEDROPPER", text="") - - # Active item details - show details based on selected row - if props.display_rows and 0 <= props.active_display_row_index < len(props.display_rows): - active_row = props.display_rows[props.active_display_row_index] - - if active_row.row_type == "POINT" and active_row.pi_index < len(props.pis): - pi = props.pis[active_row.pi_index] - - box = layout.box() - box.label(text=f"PI {active_row.pi_index + 1} Details:", icon="PROPERTIES") - - row = box.row() - row.prop(pi, "pi_type", text="Type") - - row = box.row(align=True) - row.prop(pi, "x", text="X") - row.prop(pi, "y", text="Y") - - # Show radius for interior points (can add curve) - if pi.pi_type != "ENDPOINT": - row = box.row() - row.prop(pi, "radius", text="Radius") - - # Display computed values - row = box.row() - row.label(text=f"Station: {pi.station:.2f}") - row.label(text=f"Length: {pi.length_to_next:.2f}") - - elif active_row.row_type == "SEGMENT": - if active_row.display_type == "Curve": - # Curve segment - show curve details with editable radius - pi = props.pis[active_row.pi_index] if active_row.pi_index < len(props.pis) else None - - box = layout.box() - box.label(text=f"Curve {active_row.segment_number} Details:", icon="SPHERECURVE") - - row = box.row() - row.label(text=f"PI Location: ({active_row.x:.2f}, {active_row.y:.2f})") - - row = box.row() - row.label(text=f"Arc Length: {active_row.arc_length:.2f}") - - # Editable radius - if pi: - row = box.row() - row.prop(pi, "radius", text="Radius") - else: - row = box.row() - row.label(text=f"Radius: {active_row.radius:.2f}") - else: - # Tangent segment - box = layout.box() - box.label(text=f"Tangent {active_row.segment_number} Details:", icon="IPO_LINEAR") - - row = box.row() - row.label(text=f"Length: {active_row.length:.2f}") - - # Bottom actions - layout.separator() - row = layout.row(align=True) - row.operator("saikei.recalculate_pis", icon="FILE_REFRESH", text="Recalculate") - row.operator("saikei.clear_pis", icon="TRASH", text="Clear All") - - -# ============================================================================= -# Stationing Sub-Panel -# ============================================================================= - - -class SAIKEI_PT_alignment_stationing(Panel): - """Sub-panel for stationing and referents""" - - bl_label = "Stationing" - bl_idname = "SAIKEI_PT_alignment_stationing" - bl_space_type = "VIEW_3D" - bl_region_type = "UI" - bl_category = "Saikei Civil" - bl_parent_id = "SAIKEI_PT_horizontal_alignment" - bl_options = {"DEFAULT_CLOSED"} - - @classmethod - def poll(cls, context): - return is_ifc4x3() - - def draw(self, context): - layout = self.layout - props = context.scene.SaikeiAlignmentProperties - - # Station display options - box = layout.box() - box.label(text="Display:", icon="HIDE_OFF") - box.prop(props, "show_station_labels") - box.prop(props, "station_interval") - - layout.separator() - - # Stationing operators - col = layout.column(align=True) - col.operator("saikei.add_stationing_referent", icon="EMPTY_AXIS") - col.operator("saikei.name_segments", icon="FONT_DATA") diff --git a/src/saikei/saikei/civil/operator.py b/src/saikei/saikei/civil/operator.py deleted file mode 100644 index 2c209a8cc6..0000000000 --- a/src/saikei/saikei/civil/operator.py +++ /dev/null @@ -1,27 +0,0 @@ -# ============================================================================== -# 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 -# ============================================================================== - - -"""Global operators for Saikei Civil - -This module contains operators that aren't specific to individual -feature modules. -""" - -# Placeholder for global operators diff --git a/src/saikei/saikei/civil/prop.py b/src/saikei/saikei/civil/prop.py deleted file mode 100644 index a3a5d1e47e..0000000000 --- a/src/saikei/saikei/civil/prop.py +++ /dev/null @@ -1,41 +0,0 @@ -# ============================================================================== -# 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 -# ============================================================================== - - -"""Global properties for Saikei Civil addon""" - -import bpy -from bpy.types import PropertyGroup -from bpy.props import BoolProperty, StringProperty - - -class SaikeiCivilProperties(PropertyGroup): - """Global properties for the Saikei Civil addon""" - - is_editing: BoolProperty( - name="Is Editing", - description="Whether an alignment is currently being edited", - default=False, - ) - - status_message: StringProperty( - name="Status Message", - description="Current status message to display in UI", - default="", - ) diff --git a/src/saikei/saikei/civil/ui.py b/src/saikei/saikei/civil/ui.py deleted file mode 100644 index 0bf8c249f9..0000000000 --- a/src/saikei/saikei/civil/ui.py +++ /dev/null @@ -1,27 +0,0 @@ -# ============================================================================== -# 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 -# ============================================================================== - - -"""Global UI elements for Saikei Civil - -This module contains any global UI elements that aren't specific to -individual feature modules. -""" - -# Placeholder for global UI elements diff --git a/src/saikei/saikei/core/__init__.py b/src/saikei/saikei/core/__init__.py deleted file mode 100644 index 265fdf2dc8..0000000000 --- a/src/saikei/saikei/core/__init__.py +++ /dev/null @@ -1,30 +0,0 @@ -# ============================================================================== -# 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 -# ============================================================================== - - -"""Saikei Civil Core Module - -This module contains pure Python business logic with NO Blender (bpy) dependencies. -All functions here must be testable outside of Blender. - -Following Bonsai's architecture pattern: -- core/ = Pure Python logic, receives tool classes as parameters -- tool/ = Blender implementations with bpy -- civil/ = UI layer (operators, panels, properties) -""" diff --git a/src/saikei/saikei/core/alignment.py b/src/saikei/saikei/core/alignment.py deleted file mode 100644 index a7ab6b769f..0000000000 --- a/src/saikei/saikei/core/alignment.py +++ /dev/null @@ -1,270 +0,0 @@ -# ============================================================================== -# 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/saikei/saikei/tool/__init__.py b/src/saikei/saikei/tool/__init__.py deleted file mode 100644 index 85b3e9080d..0000000000 --- a/src/saikei/saikei/tool/__init__.py +++ /dev/null @@ -1,126 +0,0 @@ -# ============================================================================== -# 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 -# ============================================================================== - - -"""Saikei Civil Tool Module - -This module contains Blender-specific implementations that bridge the -core business logic to the Blender environment. - -Following Bonsai's architecture pattern: -- core/ = Pure Python logic -- tool/ = Blender implementations with bpy (this module) -- civil/ = UI layer (operators, panels, properties) - -Usage: - from .... import tool # relative import from within saikei package - tool.Alignment.create_object_for_alignment(alignment) - tool.Ifc.get() -""" - -from .alignment import Alignment - -# Lazy import wrappers for Bonsai's tools -# We use lazy imports to avoid circular import issues with Bonsai's tool module -_bonsai_ifc = None -_bonsai_collector = None - - -class _LazyIfc: - """Lazy wrapper for bonsai.tool.Ifc to avoid circular imports.""" - - @staticmethod - def _get_real(): - global _bonsai_ifc - if _bonsai_ifc is None: - try: - from bonsai.tool import Ifc as _Ifc - - _bonsai_ifc = _Ifc - except ImportError: - _bonsai_ifc = None - return _bonsai_ifc - - def __getattr__(self, name): - real = self._get_real() - if real is None: - raise ImportError("Bonsai is not available") - return getattr(real, name) - - @classmethod - def get(cls): - real = cls._get_real() - if real is None: - return None - return real.get() - - @classmethod - def get_object(cls, element): - real = cls._get_real() - if real is None: - return None - return real.get_object(element) - - @classmethod - def link(cls, element, obj): - real = cls._get_real() - if real is None: - return None - return real.link(element, obj) - - @classmethod - def unlink(cls, obj=None, element=None): - real = cls._get_real() - if real is None: - return None - return real.unlink(obj=obj, element=element) - - -class _LazyCollector: - """Lazy wrapper for bonsai.tool.Collector to avoid circular imports.""" - - @staticmethod - def _get_real(): - global _bonsai_collector - if _bonsai_collector is None: - try: - from bonsai.tool import Collector as _Collector - - _bonsai_collector = _Collector - except ImportError: - _bonsai_collector = None - return _bonsai_collector - - def __getattr__(self, name): - real = self._get_real() - if real is None: - raise ImportError("Bonsai is not available") - return getattr(real, name) - - @classmethod - def assign(cls, obj): - real = cls._get_real() - if real is None: - return None - return real.assign(obj) - - -# Export lazy wrappers as if they were the real tools -Ifc = _LazyIfc() -Collector = _LazyCollector() diff --git a/src/saikei/saikei/tool/alignment.py b/src/saikei/saikei/tool/alignment.py deleted file mode 100644 index 22c5340982..0000000000 --- a/src/saikei/saikei/tool/alignment.py +++ /dev/null @@ -1,780 +0,0 @@ -# ============================================================================== -# 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