diff --git a/src/bonsai/bonsai/bim/module/alignment/__init__.py b/src/bonsai/bonsai/bim/module/alignment/__init__.py index 0ab8b98841..f75790f57a 100644 --- a/src/bonsai/bonsai/bim/module/alignment/__init__.py +++ b/src/bonsai/bonsai/bim/module/alignment/__init__.py @@ -54,7 +54,6 @@ def on_undo_redo(scene): classes = ( # Property groups (must be registered before classes that use them) prop.AlignmentPI, - prop.AlignmentSegmentItem, prop.AlignmentDisplayRow, prop.SaikeiAlignmentProperties, # UILists diff --git a/src/bonsai/bonsai/bim/module/alignment/operator.py b/src/bonsai/bonsai/bim/module/alignment/operator.py index 46b7bb1a14..732de8c5d0 100644 --- a/src/bonsai/bonsai/bim/module/alignment/operator.py +++ b/src/bonsai/bonsai/bim/module/alignment/operator.py @@ -20,7 +20,6 @@ import bpy -import math import time import bonsai.core.alignment as core import bonsai.tool as tool @@ -28,8 +27,7 @@ import ifcopenshell.api.alignment import ifcopenshell.api.spatial from bpy_extras.io_utils import ImportHelper from bpy.types import Operator -from bpy.props import StringProperty, FloatProperty, IntProperty -from mathutils import Vector +from bpy.props import StringProperty, FloatProperty from . import decorator as alignment_decorator from bonsai.bim.module.model.polyline import PolylineOperator from bonsai.bim.module.model.decorator import PolylineDecorator @@ -192,7 +190,7 @@ def sync_pis_from_ifc(props): # Extract PIs from segment data # This reconstructs approximate PIs from the IFC segment geometry - extracted_pis = _extract_pis_from_segments(segments) + extracted_pis = tool.Alignment.extract_pis_from_segments(segments) if not extracted_pis: # Couldn't extract - keep current props.pis @@ -203,8 +201,8 @@ def sync_pis_from_ifc(props): props.pis.clear() for pi_data in extracted_pis: pi = props.pis.add() - pi.e = pi_data["e"] - pi.n = pi_data["n"] + pi.e = str(pi_data["e"]) + pi.n = str(pi_data["n"]) pi.pi_type = pi_data["pi_type"] pi.radius = pi_data.get("radius", 0.0) @@ -215,359 +213,6 @@ def sync_pis_from_ifc(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 = float(curr_pi.e) - float(prev_pi.e) - dy1 = float(curr_pi.n) - float(prev_pi.n) - angle1 = math.atan2(dy1, dx1) - - # Outgoing tangent direction - dx2 = float(next_pi.e) - float(curr_pi.e) - dy2 = float(next_pi.n) - float(curr_pi.n) - 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 = float(end_pi.e) - float(start_pi.e) - dy = float(end_pi.n) - float(start_pi.n) - 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. @@ -626,6 +271,9 @@ def rebuild_display_rows(props): segment_num = 0 i = 0 + # Pre-compute coordinate tuples for tool method calls + pi_coords = [(float(pi.e), float(pi.n)) for pi in pis] + while i < len(pis): pi = pis[i] is_interior = i > 0 and i < len(pis) - 1 @@ -633,17 +281,18 @@ def rebuild_display_rows(props): 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.e = pi.e # Show PI coordinates on curve row + curve_row.e = pi.e curve_row.n = pi.n curve_row.radius = pi.radius - curve_row.arc_length = compute_arc_length_for_pi(props, i) + curve_row.arc_length = tool.Alignment.arc_length_at_pi( + pi_coords[i - 1], pi_coords[i], pi_coords[i + 1], pi.radius + ) else: # Regular point row (End or Mid without curve) point_row = props.display_rows.add() @@ -660,10 +309,6 @@ def rebuild_display_rows(props): # 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" @@ -671,8 +316,24 @@ def rebuild_display_rows(props): 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) + # Compute tangent lengths at each end to subtract from full distance + start_t = 0.0 + end_t = 0.0 + if has_curve: + start_t = tool.Alignment.tangent_length_at_pi( + pi_coords[i - 1], pi_coords[i], pi_coords[i + 1], pi.radius + ) + next_pi = pis[i + 1] + next_is_interior = (i + 1 > 0) and (i + 1 < len(pis) - 1) + next_has_curve = next_is_interior and next_pi.radius > 0 + if next_has_curve: + end_t = tool.Alignment.tangent_length_at_pi( + pi_coords[i], pi_coords[i + 1], pi_coords[i + 2], next_pi.radius + ) + + seg_row.length = tool.Alignment.tangent_segment_length( + pi_coords[i], pi_coords[i + 1], start_t, end_t + ) i += 1 @@ -716,8 +377,8 @@ class SAIKEI_OT_add_pi(Operator): # Additional PIs - extrapolate from last two prev = props.pis[-2] prev_prev = props.pis[-3] if len(props.pis) > 2 else prev - de = float(prev.e) - prev_prev.e if len(props.pis) > 2 else 100.0 - dn = float(prev.n) - prev_prev.n if len(props.pis) > 2 else 0.0 + de = float(prev.e) - float(prev_prev.e) if len(props.pis) > 2 else 100.0 + dn = float(prev.n) - float(prev_prev.n) if len(props.pis) > 2 else 0.0 pi.e = str(float(prev.e) + de) pi.n = str(float(prev.n) + dn) pi.pi_type = "TANGENT" @@ -955,7 +616,7 @@ class SAIKEI_OT_pick_pi_from_viewport(bpy.types.Operator, PolylineOperator, tool rebuild_display_rows(props) -class SAIKEI_OT_recalculate_pis(Operator): +class SAIKEI_OT_recalculate_pis(Operator, tool.Ifc.Operator): """Recalculate PI geometry and update IFC/visualization""" bl_idname = "saikei.recalculate_pis" @@ -973,7 +634,7 @@ class SAIKEI_OT_recalculate_pis(Operator): return False return True - def execute(self, context): + def _execute(self, context): import ifcopenshell.api.alignment as align_api ifc = tool.Ifc.get() @@ -1027,7 +688,7 @@ class SAIKEI_OT_recalculate_pis(Operator): return {"FINISHED"} -class SAIKEI_OT_clear_pis(Operator): +class SAIKEI_OT_clear_pis(Operator, tool.Ifc.Operator): """Clear all PI points and optionally remove visualization/IFC data""" bl_idname = "saikei.clear_pis" @@ -1048,7 +709,7 @@ class SAIKEI_OT_clear_pis(Operator): def invoke(self, context, event): return context.window_manager.invoke_confirm(self, event) - def execute(self, context): + def _execute(self, context): ifc = tool.Ifc.get() props = context.scene.SaikeiAlignmentProperties @@ -1089,7 +750,7 @@ class SAIKEI_OT_clear_pis(Operator): # ============================================================================= -class SAIKEI_OT_create_alignment(Operator): +class SAIKEI_OT_create_alignment(Operator, tool.Ifc.Operator): """Create a new IFC alignment""" bl_idname = "saikei.create_alignment" @@ -1101,7 +762,7 @@ class SAIKEI_OT_create_alignment(Operator): def poll(cls, context): return poll_ifc4x3(cls, context) - def execute(self, context): + def _execute(self, context): ifc = tool.Ifc.get() props = context.scene.SaikeiAlignmentProperties @@ -1124,7 +785,7 @@ class SAIKEI_OT_create_alignment(Operator): return {"FINISHED"} -class SAIKEI_OT_create_alignment_by_pi(Operator): +class SAIKEI_OT_create_alignment_by_pi(Operator, tool.Ifc.Operator): """Create alignment using the PI (Point of Intersection) method""" bl_idname = "saikei.create_alignment_by_pi" @@ -1142,7 +803,7 @@ class SAIKEI_OT_create_alignment_by_pi(Operator): return False return True - def execute(self, context): + def _execute(self, context): props = context.scene.SaikeiAlignmentProperties if not props.active_alignment_id: return {"FINISHED"} @@ -1155,7 +816,7 @@ class SAIKEI_OT_create_alignment_by_pi(Operator): 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 = bool([s for s in segments if not tool.Alignment.is_zero_length_segment(seg)]) + has_real_segments = bool([s for s in segments if not tool.Alignment.is_zero_length_segment(s)]) if not has_real_segments: # Use existing alignment - add segments to it @@ -1178,7 +839,7 @@ class SAIKEI_OT_create_alignment_by_pi(Operator): return {"FINISHED"} -class SAIKEI_OT_import_alignment_csv(Operator, ImportHelper): +class SAIKEI_OT_import_alignment_csv(Operator, tool.Ifc.Operator, ImportHelper): """Import alignment from CSV file""" bl_idname = "saikei.import_alignment_csv" @@ -1193,7 +854,7 @@ class SAIKEI_OT_import_alignment_csv(Operator, ImportHelper): def poll(cls, context): return poll_ifc4x3(cls, context) - def execute(self, context): + def _execute(self, context): ifc = tool.Ifc.get() props = context.scene.SaikeiAlignmentProperties @@ -1214,7 +875,7 @@ class SAIKEI_OT_import_alignment_csv(Operator, ImportHelper): # ============================================================================= -class SAIKEI_OT_add_stationing_referent(Operator): +class SAIKEI_OT_add_stationing_referent(Operator, tool.Ifc.Operator): """Add a stationing referent to the alignment""" bl_idname = "saikei.add_stationing_referent" @@ -1258,7 +919,7 @@ class SAIKEI_OT_add_stationing_referent(Operator): station_str = format_station(self.station) layout.label(text=f"Station notation: {station_str}") - def execute(self, context): + def _execute(self, context): ifc = tool.Ifc.get() props = context.scene.SaikeiAlignmentProperties @@ -1304,7 +965,7 @@ def format_station(station_value): return f"{main}+{offset:05.2f}" -class SAIKEI_OT_name_segments(Operator): +class SAIKEI_OT_name_segments(Operator, tool.Ifc.Operator): """Auto-name segments based on station values""" bl_idname = "saikei.name_segments" @@ -1322,7 +983,7 @@ class SAIKEI_OT_name_segments(Operator): return False return True - def execute(self, context): + def _execute(self, context): ifc = tool.Ifc.get() props = context.scene.SaikeiAlignmentProperties diff --git a/src/bonsai/bonsai/bim/module/alignment/prop.py b/src/bonsai/bonsai/bim/module/alignment/prop.py index 2363aa2f28..5898719fcd 100644 --- a/src/bonsai/bonsai/bim/module/alignment/prop.py +++ b/src/bonsai/bonsai/bim/module/alignment/prop.py @@ -31,17 +31,6 @@ from bpy.props import ( ) -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. @@ -113,22 +102,6 @@ class AlignmentPI(PropertyGroup): 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. @@ -207,21 +180,10 @@ class SaikeiAlignmentProperties(PropertyGroup): 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, - ) - # PI Edit Mode state (for moving PIs with G key) is_pi_edit_mode: BoolProperty( name="PI Edit Mode Active", @@ -236,12 +198,6 @@ class SaikeiAlignmentProperties(PropertyGroup): ) # 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", diff --git a/src/bonsai/bonsai/core/alignment.py b/src/bonsai/bonsai/core/alignment.py index a17f1c47f1..be343a4544 100644 --- a/src/bonsai/bonsai/core/alignment.py +++ b/src/bonsai/bonsai/core/alignment.py @@ -20,115 +20,25 @@ """Core alignment business logic - Orchestration only, NO bpy imports. This module contains alignment-related business logic and workflow -orchestration. All calculations and algorithms are in the tool layer. -Functions receive tool classes as parameters following Bonsai's -dependency injection pattern. +orchestration. All calculations, algorithms, and IFC operations are +in the tool layer. Functions receive tool classes as parameters +following Bonsai's dependency injection pattern. -NOTE: Math, calculations, and algorithms belong in tool/alignment.py. -This module only handles: +NOTE: Math, calculations, algorithms, and IFC API calls belong in +tool/alignment.py. This module only handles: - Business rules and validation - Workflow orchestration (calling tool methods in sequence) - Decision-making about what should happen """ from __future__ import annotations -from typing import TYPE_CHECKING, Optional -from dataclasses import dataclass +from typing import TYPE_CHECKING 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 - - -# ============================================================================= -# Alignment Visualization Logic (Business Logic Orchestration) -# ============================================================================= - - -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. - - Delegates to the tool layer which creates both: - - A curve from the IFC representation (for visualization) - - Empty objects for each segment (for selection/editing) - - Args: - alignment_tool: The Alignment tool class - layout: The IFC layout entity - layout_obj: The parent Blender object - - Returns: - List of created Blender objects (curve + segment empties) - """ - return alignment_tool.create_objects_for_layout_segments(layout, layout_obj) - - # ============================================================================= # PI Edit Mode Functions # ============================================================================= @@ -159,8 +69,6 @@ def enter_pi_edit_mode( ValueError: If alignment doesn't exist, has no horizontal layout, or has no real segments """ - import ifcopenshell.api.alignment as align_api - # Validate alignment exists ifc_file = ifc_tool.get() if ifc_file is None: @@ -174,8 +82,8 @@ def enter_pi_edit_mode( if not alignment.is_a("IfcAlignment"): raise ValueError(f"Entity {alignment_id} is not an IfcAlignment") - # Validate alignment has horizontal layout - h_layout = align_api.get_horizontal_layout(alignment) + # Validate alignment has horizontal layout (delegated to tool) + h_layout = alignment_tool.get_horizontal_layout(alignment) if h_layout is None: raise ValueError(f"Alignment '{alignment.Name}' has no horizontal layout") @@ -228,9 +136,6 @@ def exit_pi_edit_mode( Raises: ValueError: If alignment doesn't exist or update fails """ - import ifcopenshell - import ifcopenshell.api.alignment as align_api - ifc_file = ifc_tool.get() if ifc_file is None: # No file loaded, just clean up empties @@ -252,8 +157,8 @@ def exit_pi_edit_mode( if len(hpoints) < 2: raise ValueError("At least 2 PIs are required") - # Get horizontal layout - required for in-place editing - h_layout = align_api.get_horizontal_layout(alignment) + # Get horizontal layout (delegated to tool) + h_layout = alignment_tool.get_horizontal_layout(alignment) if h_layout is None: raise ValueError("Alignment has no horizontal layout") @@ -263,13 +168,9 @@ def exit_pi_edit_mode( # Remove Blender visualization for segments (not the whole hierarchy) alignment_tool.remove_layout_segment_objects(h_layout) - # Clear existing IFC segments (preserves layout and zero-length terminator) - align_api.clear_layout_segments(ifc_file, h_layout) - - # Add new segments with updated PI positions - align_api.layout_horizontal_alignment_by_pi_method( - ifc_file, h_layout, hpoints, radii - ) + # Clear existing IFC segments and add new ones (delegated to tool) + alignment_tool.clear_layout_segments(h_layout) + alignment_tool.layout_by_pi_method(h_layout, hpoints, radii) # Refresh Blender visualization for new segments layout_obj = ifc_tool.get_object(h_layout) diff --git a/src/bonsai/bonsai/tool/alignment.py b/src/bonsai/bonsai/tool/alignment.py index b3a22d48a2..69b62915a4 100644 --- a/src/bonsai/bonsai/tool/alignment.py +++ b/src/bonsai/bonsai/tool/alignment.py @@ -114,25 +114,6 @@ class Alignment: stations=stations, lengths=lengths, directions=directions, total_length=total_length ) - @classmethod - def calculate_deflection_angle(cls, incoming_direction: float, outgoing_direction: float) -> float: - """Calculate the deflection angle between two tangent directions. - - Args: - incoming_direction: Direction angle of incoming tangent (radians) - outgoing_direction: Direction angle of outgoing tangent (radians) - - Returns: - Deflection angle in radians (always positive) - """ - delta = outgoing_direction - incoming_direction - # Normalize to -pi to pi - while delta > math.pi: - delta -= 2 * math.pi - while delta < -math.pi: - delta += 2 * math.pi - return abs(delta) - @classmethod def calculate_tangent_length(cls, radius: float, deflection_angle: float) -> float: """Calculate tangent length for a circular curve. @@ -165,39 +146,334 @@ class Alignment: """ return radius * deflection_angle - @classmethod - def calculate_bc_ec_points( - cls, - pi_x: float, - pi_y: float, - incoming_direction: float, - outgoing_direction: float, - tangent_length: float, - ) -> Tuple[Tuple[float, float], Tuple[float, float]]: - """Calculate Begin Curve (BC) and End Curve (EC) points. - BC = PI - incoming_tangent_vector * T - EC = PI + outgoing_tangent_vector * T + @classmethod + def deflection_angle_from_points( + cls, p1: Tuple[float, float], p2: Tuple[float, float], p3: Tuple[float, float] + ) -> float: + """Calculate deflection angle at p2 from three (e, n) coordinate tuples. 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 + p1: Previous PI coordinates (e, n) + p2: Current PI coordinates (e, n) + p3: Next PI coordinates (e, n) Returns: - Tuple of (BC point, EC point) as (x, y) tuples + Deflection angle in radians (signed: positive=left, negative=right) """ - # 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) + dx1 = p2[0] - p1[0] + dy1 = p2[1] - p1[1] + incoming = math.atan2(dy1, dx1) - # 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) + dx2 = p3[0] - p2[0] + dy2 = p3[1] - p2[1] + outgoing = math.atan2(dy2, dx2) - return ((bc_x, bc_y), (ec_x, ec_y)) + delta = outgoing - incoming + while delta > math.pi: + delta -= 2 * math.pi + while delta < -math.pi: + delta += 2 * math.pi + return delta + + @classmethod + def arc_length_at_pi( + cls, + p1: Tuple[float, float], + p2: Tuple[float, float], + p3: Tuple[float, float], + radius: float, + ) -> float: + """Calculate arc length L = R * |delta| at a PI with curve. + + Args: + p1, p2, p3: (e, n) coordinate tuples for prev, current, next PI + radius: Curve radius (must be > 0) + + Returns: + Arc length + """ + if radius <= 0: + return 0.0 + deflection = cls.deflection_angle_from_points(p1, p2, p3) + return cls.calculate_arc_length(radius, abs(deflection)) + + @classmethod + def tangent_length_at_pi( + cls, + p1: Tuple[float, float], + p2: Tuple[float, float], + p3: Tuple[float, float], + radius: float, + ) -> float: + """Calculate tangent length T = R * tan(|delta|/2) at a PI. + + Args: + p1, p2, p3: (e, n) coordinate tuples for prev, current, next PI + radius: Curve radius (must be > 0) + + Returns: + Tangent length + """ + if radius <= 0: + return 0.0 + deflection = cls.deflection_angle_from_points(p1, p2, p3) + return cls.calculate_tangent_length(radius, abs(deflection)) + + @classmethod + def tangent_segment_length( + cls, + p_start: Tuple[float, float], + p_end: Tuple[float, float], + start_tangent: float = 0.0, + end_tangent: float = 0.0, + ) -> float: + """Calculate tangent segment length between two PIs, minus curve tangent lengths. + + Args: + p_start: (e, n) coordinate tuple for start PI + p_end: (e, n) coordinate tuple for end PI + start_tangent: Tangent length to subtract at start + end_tangent: Tangent length to subtract at end + + Returns: + Net segment length (clamped to 0) + """ + dx = p_end[0] - p_start[0] + dy = p_end[1] - p_start[1] + full_length = math.sqrt(dx * dx + dy * dy) + return max(0.0, full_length - start_tangent - end_tangent) + + # ========================================================================= + # PI Extraction from IFC Segments + # ========================================================================= + + @classmethod + def extract_pis_from_segments(cls, segments): + """Extract PI data from IFC alignment segments. + + Reconstructs PI coordinates and types from horizontal segment + design parameters. Handles LINE and CIRCULARARC segments. + + Args: + segments: List of IfcAlignmentSegment entities + + Returns: + List of dicts with keys: e, n, pi_type, radius + """ + 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 [] + + # First PI: start of first segment + first_dp = real_segments[0].DesignParameters + start_coords = first_dp.StartPoint.Coordinates + pis.append( + { + "e": float(start_coords[0]), + "n": 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": + pi_data = cls._calculate_pi_from_curve(real_segments, i) + if pi_data: + pis.append(pi_data) + i += 1 + elif dp.PredefinedType == "LINE": + if i < len(real_segments) - 1: + next_dp = real_segments[i + 1].DesignParameters + if next_dp.PredefinedType == "LINE": + end_coords = cls._calculate_segment_endpoint(dp) + pis.append( + { + "e": float(end_coords[0]), + "n": float(end_coords[1]), + "pi_type": "TANGENT", + "radius": 0.0, + } + ) + i += 1 + else: + i += 1 + + # Last PI: end of last segment + last_dp = real_segments[-1].DesignParameters + end_coords = cls._calculate_segment_endpoint(last_dp) + if pis: + last_pi = pis[-1] + dist = math.sqrt((end_coords[0] - last_pi["e"]) ** 2 + (end_coords[1] - last_pi["n"]) ** 2) + if dist > 0.001: + pis.append( + { + "e": float(end_coords[0]), + "n": float(end_coords[1]), + "pi_type": "ENDPOINT", + "radius": 0.0, + } + ) + + return pis + + @classmethod + def _calculate_segment_endpoint(cls, design_params): + """Calculate the endpoint of a horizontal segment. + + Args: + design_params: IfcAlignmentHorizontalSegment + + Returns: + Tuple (e, n) of endpoint coordinates + """ + start = design_params.StartPoint.Coordinates + start_x = float(start[0]) + start_y = float(start[1]) + + direction = float(design_params.StartDirection) + length = float(design_params.SegmentLength) + + if design_params.PredefinedType == "LINE": + 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": + radius = abs(float(design_params.StartRadiusOfCurvature or design_params.EndRadiusOfCurvature or 0)) + if radius == 0: + end_x = start_x + length * math.cos(direction) + end_y = start_y + length * math.sin(direction) + return (end_x, end_y) + + start_radius = design_params.StartRadiusOfCurvature + is_clockwise = start_radius is not None and start_radius < 0 + theta = length / radius + + if is_clockwise: + center_dir = direction - math.pi / 2 + end_dir = direction - theta + else: + center_dir = direction + math.pi / 2 + end_dir = direction + theta + + center_x = start_x + radius * math.cos(center_dir) + center_y = start_y + radius * math.sin(center_dir) + + 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: + end_x = start_x + length * math.cos(direction) + end_y = start_y + length * math.sin(direction) + return (end_x, end_y) + + @classmethod + def _calculate_pi_from_curve(cls, segments, curve_index): + """Calculate the PI point from a curve segment. + + The PI is at the intersection of the incoming and outgoing tangents. + + 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 + + pc_coords = curve_dp.StartPoint.Coordinates + pc_x = float(pc_coords[0]) + pc_y = float(pc_coords[1]) + + start_dir = float(curve_dp.StartDirection) + arc_length = float(curve_dp.SegmentLength) + + radius = abs(float(curve_dp.StartRadiusOfCurvature or curve_dp.EndRadiusOfCurvature or 0)) + if radius == 0: + return None + + delta = arc_length / radius + tangent_length = radius * math.tan(delta / 2) + + pi_x = pc_x + tangent_length * math.cos(start_dir) + pi_y = pc_y + tangent_length * math.sin(start_dir) + + return { + "e": pi_x, + "n": pi_y, + "pi_type": "CURVE", + "radius": radius, + } + + # ========================================================================= + # IFC API Wrappers (for core layer delegation) + # ========================================================================= + + @classmethod + def get_horizontal_layout(cls, alignment: "ifcopenshell.entity_instance"): + """Get the IfcAlignmentHorizontal layout from an alignment. + + Args: + alignment: The IfcAlignment entity + + Returns: + The IfcAlignmentHorizontal entity, or None + """ + import ifcopenshell.api.alignment as align_api + + return align_api.get_horizontal_layout(alignment) + + @classmethod + def clear_layout_segments(cls, layout: "ifcopenshell.entity_instance"): + """Clear all segments from a layout, preserving the layout entity. + + Args: + layout: The IFC layout entity (IfcAlignmentHorizontal, etc.) + """ + import ifcopenshell.api.alignment as align_api + + ifc_file = tool.Ifc.get() + align_api.clear_layout_segments(ifc_file, layout) + + @classmethod + def layout_by_pi_method(cls, layout: "ifcopenshell.entity_instance", hpoints: list, radii: list): + """Add segments to a horizontal layout using the PI method. + + Args: + layout: The IfcAlignmentHorizontal layout + hpoints: List of (E, N) coordinate pairs for PIs + radii: List of curve radii for interior PIs + """ + import ifcopenshell.api.alignment as align_api + + ifc_file = tool.Ifc.get() + align_api.layout_horizontal_alignment_by_pi_method(ifc_file, layout, hpoints, radii) # ========================================================================= # Zero-Length Segment Utilities @@ -250,89 +526,6 @@ class Alignment: return True return False - # ========================================================================= - # Segment Geometry Utilities - # ========================================================================= - - @classmethod - def get_segment_vertices( - cls, segment: "ifcopenshell.entity_instance", distance_interval: float = 1.0 - ) -> Optional[List[Tuple[float, float, float]]]: - """Get vertices for a single alignment segment using IfcOpenShell's geometry engine. - - Uses the proven IfcOpenShell C++ geometry engine (create_shape) to generate - vertices, supporting all segment types (LINE, CIRCULARARC, CLOTHOID, - spirals, etc.) including negative-length curve segments. - - Args: - segment: The IfcAlignmentSegment entity - distance_interval: Distance between sample points (default 1.0 units) - - Returns: - List of (x, y, z) tuples representing vertices along the segment, - or None if geometry cannot be generated - """ - import ifcopenshell.api.alignment as align_api - import ifcopenshell.geom - import ifcopenshell.util.unit - import numpy as np - - # Skip zero-length segments - if cls.is_zero_length_segment(segment): - return None - - # Get the mapped curve segment(s) for this alignment segment - try: - mapped_segments = align_api.get_mapped_segments(segment) - except Exception as e: - print(f"[Alignment] get_mapped_segments failed: {e}") - return None - - if not mapped_segments: - return None - - # Get IFC file for unit scale - ifc_file = tool.Ifc.get() - if not ifc_file: - return None - - unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file) - - all_vertices = [] - - # Process each curve segment (usually 1, but HELMERTCURVE has 2) - for curve_segment in mapped_segments: - if curve_segment is None: - continue - - # Use create_shape to generate vertices - the same proven approach as generate_vertices() - # This handles negative-length curve segments correctly at the C++ level - try: - s = ifcopenshell.geom.settings() - - shape = ifcopenshell.geom.create_shape(s, curve_segment) - verts = shape.verts - - if len(verts) == 0: - continue - - # Reshape to (N, 3) array and apply unit scale - vertices_array = np.array(verts).reshape((-1, 3)) - for v in vertices_array: - # create_shape returns values already in file units, apply scale - x = float(v[0]) / unit_scale - y = float(v[1]) / unit_scale - z = float(v[2]) / unit_scale - all_vertices.append((x, y, z)) - - except Exception as e: - print(f"[Alignment] create_shape failed for curve segment: {e}") - continue - - if len(all_vertices) < 2: - return None - - return all_vertices # ========================================================================= # Blender Object Creation @@ -413,119 +606,6 @@ class Alignment: return obj - @classmethod - def create_curve_from_representation( - cls, - layout: "ifcopenshell.entity_instance", - parent_obj: Optional[bpy.types.Object] = None, - ) -> Optional[bpy.types.Object]: - """Create a Blender curve from an alignment layout's IFC representation. - - Uses IfcOpenShell's geometry engine to generate vertices, supporting - all segment types (LINE, CIRCULARARC, CLOTHOID, spirals, etc.). - - The vertices from IFC are in global/map coordinates. If a Blender offset - is configured (for handling large geospatial coordinates), the vertices - are transformed to Blender local coordinates. - - Empty alignments (only zero-length terminator segment) are silently skipped. - - Args: - layout: The IFC layout entity (IfcAlignmentHorizontal, etc.) - parent_obj: The parent Blender object (alignment object) - - Returns: - The created Blender curve object, or None if no representation or empty - """ - import ifcopenshell.api.alignment as align_api - from ifcopenshell.api.alignment import util as align_util - import ifcopenshell.util.geolocation - import ifcopenshell.util.unit - - # Skip empty layouts (only zero-length terminator) - no error message needed - if not cls.layout_has_real_segments(layout): - return None - - # Get the layout's curve representation - try: - rep_curve = align_api.get_layout_curve(layout) - except Exception as e: - print(f"[Alignment] get_layout_curve failed: {e}") - rep_curve = None - - if rep_curve is None: - return None - - # Generate vertices using IfcOpenShell's geometry engine - vertices = align_util.generate_vertices(rep_curve, distance_interval=1.0) - - if len(vertices) < 2: - print(f"[Alignment] Not enough vertices ({len(vertices)}), need at least 2") - return None - - # Check if we need to apply Blender offset transformation - # IFC vertices are in global/map coordinates, we need to convert to Blender local - gprops = tool.Georeference.get_georeference_props() - ifc_file = tool.Ifc.get() - unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file) if ifc_file else 1.0 - - if gprops.has_blender_offset: - offset_x = float(gprops.blender_offset_x) * unit_scale - offset_y = float(gprops.blender_offset_y) * unit_scale - offset_z = float(gprops.blender_offset_z) * unit_scale - x_axis_abscissa = float(gprops.blender_x_axis_abscissa) - x_axis_ordinate = float(gprops.blender_x_axis_ordinate) - - # Transform each vertex from IFC global to Blender local - transformed_vertices = [] - for vert in vertices: - # Create a 4x4 identity matrix with translation set to vertex position - import numpy as np - matrix = np.eye(4) - matrix[0, 3] = vert[0] - matrix[1, 3] = vert[1] - matrix[2, 3] = vert[2] - - # Apply global2local transformation - local_matrix = ifcopenshell.util.geolocation.global2local( - matrix, offset_x, offset_y, offset_z, x_axis_abscissa, x_axis_ordinate - ) - - # Extract transformed position - transformed_vertices.append((local_matrix[0, 3], local_matrix[1, 3], local_matrix[2, 3])) - - vertices = transformed_vertices - - # Create Blender curve from vertices - layout_type = layout.is_a().replace("IfcAlignment", "") # "Horizontal", "Vertical", etc. - name = f"{layout_type}Curve" - curve_data = bpy.data.curves.new(name, type="CURVE") - curve_data.dimensions = "3D" - - spline = curve_data.splines.new("POLY") - spline.points.add(len(vertices) - 1) - - for i, vert in enumerate(vertices): - spline.points[i].co = (vert[0], vert[1], vert[2], 1.0) - - obj = bpy.data.objects.new(name, curve_data) - obj.show_in_front = True - curve_data.bevel_depth = 0.0 - - # Set a visible color for the curve (black, like construction lines) - obj.color = (0.0, 0.0, 0.0, 1.0) # Black color - - # Set parent relationship - if parent_obj: - obj.parent = parent_obj - - # Assign to same collection as parent - if parent_obj and parent_obj.users_collection: - parent_obj.users_collection[0].objects.link(obj) - else: - tool.Collector.assign(obj) - - return obj @classmethod def _create_segment_curve( @@ -756,32 +836,6 @@ class Alignment: 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 - """ - # 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) - # ========================================================================= # Validation and Safe Wrappers # ========================================================================= @@ -815,22 +869,6 @@ class Alignment: 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( @@ -871,35 +909,6 @@ class Alignment: 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 # ========================================================================= # PI Edit Mode Methods @@ -940,10 +949,10 @@ class Alignment: Returns: List of dicts, each containing: - - "x": float - X coordinate in IFC space - - "y": float - Y coordinate in IFC space + - "e": float - Easting coordinate in IFC space + - "n": float - Northing coordinate in IFC space - "radius": float - Curve radius (0 for endpoints/tangent PIs) - - "type": str - "ENDPOINT", "CURVE", or "TANGENT" + - "pi_type": str - "ENDPOINT", "CURVE", or "TANGENT" Raises: ValueError: If alignment has no horizontal layout or segments @@ -981,10 +990,10 @@ class Alignment: first_x = float(first_dp.StartPoint.Coordinates[0]) first_y = float(first_dp.StartPoint.Coordinates[1]) pis.append({ - "x": first_x, - "y": first_y, + "e": first_x, + "n": first_y, "radius": 0.0, - "type": "ENDPOINT" + "pi_type": "ENDPOINT" }) # Track current position and direction for LINE segments @@ -1015,10 +1024,10 @@ class Alignment: pi_y = bc_y + tangent_length * math.sin(angle_in) pis.append({ - "x": pi_x, - "y": pi_y, + "e": pi_x, + "n": pi_y, "radius": radius, - "type": "CURVE" + "pi_type": "CURVE" }) elif seg_type == "LINE": @@ -1031,10 +1040,10 @@ class Alignment: start_x = float(dp.StartPoint.Coordinates[0]) start_y = float(dp.StartPoint.Coordinates[1]) pis.append({ - "x": start_x, - "y": start_y, + "e": start_x, + "n": start_y, "radius": 0.0, - "type": "TANGENT" + "pi_type": "TANGENT" }) prev_seg_type = seg_type @@ -1081,10 +1090,10 @@ class Alignment: end_y = last_start_y pis.append({ - "x": end_x, - "y": end_y, + "e": end_x, + "n": end_y, "radius": 0.0, - "type": "ENDPOINT" + "pi_type": "ENDPOINT" }) return pis @@ -1142,7 +1151,7 @@ class Alignment: empty["saikei_pi_index"] = i empty["saikei_pi_radius"] = pi["radius"] empty["saikei_alignment_id"] = alignment_id - empty["saikei_pi_type"] = pi["type"] + empty["saikei_pi_type"] = pi["pi_type"] # Parent to alignment object empty.parent = alignment_obj