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