Refactor alignment module: fix bugs, remove dead code, enforce architecture

- Fix 4 runtime bugs: seg/s variable mismatch, missing float() wrappers,
  PI dict key mismatches ("x"/"y" -> "e"/"n"), float-to-StringProperty
- Remove ~470 lines of dead code across prop.py, core/alignment.py,
  tool/alignment.py, and operator.py
- Consolidate duplicate math functions from operator.py into tool layer
  (arc_length_at_pi, tangent_length_at_pi, tangent_segment_length)
- Move PI extraction logic from operator.py to tool/alignment.py
- Add IfcStore undo pattern to 7 IFC-modifying operators
- Core layer no longer calls IFC API directly (delegates via tool wrappers)
- Remove unused imports (math, IntProperty, Vector)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
DesertSpringsCivil
2026-02-18 19:40:44 -07:00
parent bba1ff3786
commit 8433999575
5 changed files with 395 additions and 869 deletions
@@ -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
@@ -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
@@ -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",
+13 -112
View File
@@ -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)
+336 -327
View File
@@ -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