Replace manual PI trigonometry with segment_vertices() API

Uses Rick Brice's new ifcopenshell.api.alignment.segment_vertices()
to extract PI positions from alignment segments via the C++ geometry
engine, replacing ~300 lines of hand-coded trig that only handled
LINE and CIRCULARARC. Now supports all segment types (CLOTHOID,
Helmert curves, etc.). Includes backward-compatible fallback for
IFC files without Axis/Segment representations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
DesertSpringsCivil
2026-02-24 12:42:31 -07:00
parent d9a3c2d5c2
commit 4971546de9
+127 -306
View File
@@ -67,9 +67,7 @@ class Alignment:
# ========================================================================= # =========================================================================
@classmethod @classmethod
def calculate_pi_geometry( def calculate_pi_geometry(cls, pis: List[Tuple[float, float]], start_station: float = 0.0) -> PIGeometryResult:
cls, pis: List[Tuple[float, float]], start_station: float = 0.0
) -> PIGeometryResult:
"""Calculate lengths, stations, and directions for a list of PI points. """Calculate lengths, stations, and directions for a list of PI points.
Args: Args:
@@ -110,9 +108,7 @@ class Alignment:
total_length = cumulative_length - start_station total_length = cumulative_length - start_station
return PIGeometryResult( return PIGeometryResult(stations=stations, lengths=lengths, directions=directions, total_length=total_length)
stations=stations, lengths=lengths, directions=directions, total_length=total_length
)
@classmethod @classmethod
def calculate_tangent_length(cls, radius: float, deflection_angle: float) -> float: def calculate_tangent_length(cls, radius: float, deflection_angle: float) -> float:
@@ -146,7 +142,6 @@ class Alignment:
""" """
return radius * deflection_angle return radius * deflection_angle
@classmethod @classmethod
def deflection_angle_from_points( def deflection_angle_from_points(
cls, p1: Tuple[float, float], p2: Tuple[float, float], p3: Tuple[float, float] cls, p1: Tuple[float, float], p2: Tuple[float, float], p3: Tuple[float, float]
@@ -248,12 +243,56 @@ class Alignment:
# PI Extraction from IFC Segments # PI Extraction from IFC Segments
# ========================================================================= # =========================================================================
@classmethod
def _get_segment_vertices_in_model_units(
cls, ifc_file: "ifcopenshell.file", segment: "ifcopenshell.entity_instance"
):
"""Get segment control points (Start, End, TI, NI) in model units.
Wraps ifcopenshell.api.alignment.segment_vertices() with:
- Backward-compatible fallback for segments without Axis/Segment
representation (falls back to IfcCurveSegment via get_mapped_segments)
- Unit conversion (geometry engine returns SI; we need model units)
Args:
ifc_file: The IFC file
segment: An IfcAlignmentSegment entity
Returns:
Tuple of (start, end, ti, ni) where each is (x, y) in model units,
or None for ti/ni when lines are parallel.
Returns None if segment cannot be evaluated.
"""
import ifcopenshell.api.alignment as align_api
import ifcopenshell.util.unit
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file)
def convert(point):
if point is None:
return None
return (point[0] / unit_scale, point[1] / unit_scale)
try:
start, end, ti, ni = align_api.segment_vertices(ifc_file, segment)
except Exception:
try:
mapped = align_api.get_mapped_segments(segment)
curve_segment = mapped[0]
if curve_segment is None:
return None
start, end, ti, ni = align_api.segment_vertices(ifc_file, curve_segment)
except Exception:
return None
return (convert(start), convert(end), convert(ti), convert(ni))
@classmethod @classmethod
def extract_pis_from_segments(cls, segments): def extract_pis_from_segments(cls, segments):
"""Extract PI data from IFC alignment segments. """Extract PI data from IFC alignment segments.
Reconstructs PI coordinates and types from horizontal segment Uses ifcopenshell.api.alignment.segment_vertices() to extract
design parameters. Handles LINE and CIRCULARARC segments. PI (tangent intersection) points from segment geometry.
Args: Args:
segments: List of IfcAlignmentSegment entities segments: List of IfcAlignmentSegment entities
@@ -261,176 +300,57 @@ class Alignment:
Returns: Returns:
List of dicts with keys: e, n, pi_type, radius List of dicts with keys: e, n, pi_type, radius
""" """
pis = [] ifc_file = tool.Ifc.get()
# Filter out zero-length terminal segments # Filter out zero-length terminal segments
real_segments = [] real_segments = [seg for seg in segments if not cls.is_zero_length_segment(seg)]
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: if not real_segments:
return [] return []
# Get vertices for all segments
seg_vertices = [cls._get_segment_vertices_in_model_units(ifc_file, seg) for seg in real_segments]
pis = []
# First PI: start of first segment # First PI: start of first segment
first_dp = real_segments[0].DesignParameters if seg_vertices[0] is not None:
start_coords = first_dp.StartPoint.Coordinates start_pt = seg_vertices[0][0]
pis.append( pis.append({"e": start_pt[0], "n": start_pt[1], "pi_type": "ENDPOINT", "radius": 0.0})
{
"e": float(start_coords[0]),
"n": float(start_coords[1]),
"pi_type": "ENDPOINT",
"radius": 0.0,
}
)
# Process interior points # Process interior PIs
i = 0 prev_is_line = True
while i < len(real_segments): for i, (seg, verts) in enumerate(zip(real_segments, seg_vertices)):
dp = real_segments[i].DesignParameters if verts is None:
prev_is_line = False
continue
if dp.PredefinedType == "CIRCULARARC": start, end, ti, ni = verts
pi_data = cls._calculate_pi_from_curve(real_segments, i) dp = seg.DesignParameters
if pi_data:
pis.append(pi_data) if ti is not None:
i += 1 # Curve segment: TI is the PI
elif dp.PredefinedType == "LINE": radius = abs(float(dp.StartRadiusOfCurvature or dp.EndRadiusOfCurvature or 0))
if i < len(real_segments) - 1: pis.append({"e": ti[0], "n": ti[1], "pi_type": "CURVE", "radius": radius})
next_dp = real_segments[i + 1].DesignParameters prev_is_line = False
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: else:
i += 1 # Line segment: if previous was also a line, connection = tangent PI
if i > 0 and prev_is_line:
pis.append({"e": start[0], "n": start[1], "pi_type": "TANGENT", "radius": 0.0})
prev_is_line = True
# Last PI: end of last segment # Last PI: end of last segment
last_dp = real_segments[-1].DesignParameters if seg_vertices[-1] is not None:
end_coords = cls._calculate_segment_endpoint(last_dp) end_pt = seg_vertices[-1][1]
if pis: if pis:
last_pi = pis[-1] last = pis[-1]
dist = math.sqrt((end_coords[0] - last_pi["e"]) ** 2 + (end_coords[1] - last_pi["n"]) ** 2) dist = ((end_pt[0] - last["e"]) ** 2 + (end_pt[1] - last["n"]) ** 2) ** 0.5
if dist > 0.001: if dist > 0.001:
pis.append( pis.append({"e": end_pt[0], "n": end_pt[1], "pi_type": "ENDPOINT", "radius": 0.0})
{ else:
"e": float(end_coords[0]), pis.append({"e": end_pt[0], "n": end_pt[1], "pi_type": "ENDPOINT", "radius": 0.0})
"n": float(end_coords[1]),
"pi_type": "ENDPOINT",
"radius": 0.0,
}
)
return pis 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) # IFC API Wrappers (for core layer delegation)
# ========================================================================= # =========================================================================
@@ -526,7 +446,6 @@ class Alignment:
return True return True
return False return False
# ========================================================================= # =========================================================================
# Blender Object Creation # Blender Object Creation
# ========================================================================= # =========================================================================
@@ -606,7 +525,6 @@ class Alignment:
return obj return obj
@classmethod @classmethod
def _create_segment_curve( def _create_segment_curve(
cls, segment: "ifcopenshell.entity_instance", index: int, parent_obj: Optional[bpy.types.Object] = None cls, segment: "ifcopenshell.entity_instance", index: int, parent_obj: Optional[bpy.types.Object] = None
@@ -869,7 +787,6 @@ class Alignment:
except Exception: except Exception:
return None return None
@classmethod @classmethod
def safe_layout_horizontal_by_pi_method( def safe_layout_horizontal_by_pi_method(
cls, ifc_file: "ifcopenshell.file", layout: "ifcopenshell.entity_instance", hpoints: list, radii: list cls, ifc_file: "ifcopenshell.file", layout: "ifcopenshell.entity_instance", hpoints: list, radii: list
@@ -909,7 +826,6 @@ class Alignment:
return True return True
# ========================================================================= # =========================================================================
# PI Edit Mode Methods # PI Edit Mode Methods
# ========================================================================= # =========================================================================
@@ -922,27 +838,12 @@ class Alignment:
# 4. Collect new positions and regenerate alignment segments # 4. Collect new positions and regenerate alignment segments
@classmethod @classmethod
def back_calculate_pis_from_alignment( def back_calculate_pis_from_alignment(cls, alignment: "ifcopenshell.entity_instance") -> List[dict]:
cls, alignment: "ifcopenshell.entity_instance"
) -> List[dict]:
"""Reverse-engineer PI positions from IFC alignment segments. """Reverse-engineer PI positions from IFC alignment segments.
This function analyzes the alignment's horizontal segments and Uses ifcopenshell.api.alignment.segment_vertices() to extract
reconstructs the original PI (Point of Intersection) positions the tangent intersection (TI) point for each segment — the TI
that were used to create the alignment. IS the PI for curve segments.
Algorithm:
1. Get horizontal layout and segments
2. First PI = start point of first segment
3. For each CIRCULARARC segment:
- Extract BC (begin curve) from StartPoint
- Calculate deflection: Δ = arc_length / radius
- Calculate tangent length: T = R × tan(Δ/2)
- PI position = BC + T × direction_vector
- Store radius
4. For LINE-only transitions (radius=0):
- PI = endpoint of LINE segment (becomes a tangent PI)
5. Last PI = end point of last real segment
Args: Args:
alignment: The IfcAlignment entity alignment: The IfcAlignment entity
@@ -959,142 +860,64 @@ class Alignment:
""" """
import ifcopenshell.api.alignment as align_api import ifcopenshell.api.alignment as align_api
ifc_file = tool.Ifc.get()
# Get horizontal layout # Get horizontal layout
h_layout = align_api.get_horizontal_layout(alignment) h_layout = align_api.get_horizontal_layout(alignment)
if h_layout is None: if h_layout is None:
raise ValueError(f"Alignment #{alignment.id()} has no horizontal layout") raise ValueError(f"Alignment #{alignment.id()} has no horizontal layout")
# Get all segments # Get all segments
segments = [] segments = align_api.get_layout_segments(h_layout)
for rel in getattr(h_layout, "IsNestedBy", []) or []:
for segment in rel.RelatedObjects or []:
if segment.is_a("IfcAlignmentSegment"):
segments.append(segment)
if not segments: if not segments:
raise ValueError(f"Alignment #{alignment.id()} has no segments") raise ValueError(f"Alignment #{alignment.id()} has no segments")
# Filter out zero-length terminator segments # Filter out zero-length terminator segments
real_segments = [] real_segments = [seg for seg in segments if not cls.is_zero_length_segment(seg)]
for seg in segments:
if not cls.is_zero_length_segment(seg):
real_segments.append(seg)
if not real_segments: if not real_segments:
raise ValueError(f"Alignment #{alignment.id()} has no real segments (only terminator)") raise ValueError(f"Alignment #{alignment.id()} has no real segments (only terminator)")
# Get vertices for all segments
seg_vertices = [cls._get_segment_vertices_in_model_units(ifc_file, seg) for seg in real_segments]
pis = [] pis = []
# First PI: start point of first segment # First PI: start of first segment
first_dp = real_segments[0].DesignParameters if seg_vertices[0] is not None:
first_x = float(first_dp.StartPoint.Coordinates[0]) start_pt = seg_vertices[0][0]
first_y = float(first_dp.StartPoint.Coordinates[1]) pis.append({"e": start_pt[0], "n": start_pt[1], "radius": 0.0, "pi_type": "ENDPOINT"})
pis.append({
"e": first_x,
"n": first_y,
"radius": 0.0,
"pi_type": "ENDPOINT"
})
# Track current position and direction for LINE segments # Process each segment for interior PIs
# This helps us identify tangent PIs (where LINE meets LINE) prev_is_line = True
prev_seg_type = first_dp.PredefinedType for i, (seg, verts) in enumerate(zip(real_segments, seg_vertices)):
if verts is None:
prev_is_line = False
continue
# Process each segment start, end, ti, ni = verts
for i, seg in enumerate(real_segments):
dp = seg.DesignParameters dp = seg.DesignParameters
seg_type = dp.PredefinedType
if seg_type == "CIRCULARARC": if ti is not None:
# Reconstruct PI from arc segment # Curve segment: TI is the PI
bc_x = float(dp.StartPoint.Coordinates[0]) radius = abs(float(dp.StartRadiusOfCurvature or dp.EndRadiusOfCurvature or 0))
bc_y = float(dp.StartPoint.Coordinates[1]) pis.append({"e": ti[0], "n": ti[1], "radius": radius, "pi_type": "CURVE"})
angle_in = float(dp.StartDirection) prev_is_line = False
radius = abs(float(dp.StartRadiusOfCurvature))
arc_length = float(dp.SegmentLength)
# Deflection angle: Δ = L / R
deflection = arc_length / radius
# Tangent length: T = R × tan(Δ/2)
tangent_length = radius * math.tan(deflection / 2)
# PI position: BC + T × direction_vector
pi_x = bc_x + tangent_length * math.cos(angle_in)
pi_y = bc_y + tangent_length * math.sin(angle_in)
pis.append({
"e": pi_x,
"n": pi_y,
"radius": radius,
"pi_type": "CURVE"
})
elif seg_type == "LINE":
# For LINE segments, check if this is a transition point
# If the previous segment was also LINE and this isn't the first,
# we may have a tangent PI at the connection point
if i > 0 and prev_seg_type == "LINE":
# There's a tangent PI at the start of this LINE
# (end of previous LINE)
start_x = float(dp.StartPoint.Coordinates[0])
start_y = float(dp.StartPoint.Coordinates[1])
pis.append({
"e": start_x,
"n": start_y,
"radius": 0.0,
"pi_type": "TANGENT"
})
prev_seg_type = seg_type
# Last PI: end point of last segment
last_dp = real_segments[-1].DesignParameters
last_seg_type = last_dp.PredefinedType
last_length = float(last_dp.SegmentLength)
last_direction = float(last_dp.StartDirection)
last_start_x = float(last_dp.StartPoint.Coordinates[0])
last_start_y = float(last_dp.StartPoint.Coordinates[1])
if last_seg_type == "LINE":
# End of LINE: simple projection
end_x = last_start_x + last_length * math.cos(last_direction)
end_y = last_start_y + last_length * math.sin(last_direction)
elif last_seg_type == "CIRCULARARC":
# End of ARC: use geometry engine or calculate
last_radius = abs(float(last_dp.StartRadiusOfCurvature))
deflection = last_length / last_radius
# Determine curve direction (positive radius = counterclockwise)
is_ccw = float(last_dp.StartRadiusOfCurvature) > 0
if is_ccw:
end_direction = last_direction + deflection
else: else:
end_direction = last_direction - deflection # Line segment: if previous was also a line, connection = tangent PI
if i > 0 and prev_is_line:
pis.append({"e": start[0], "n": start[1], "radius": 0.0, "pi_type": "TANGENT"})
prev_is_line = True
# Calculate EC (end curve) position # Last PI: end of last segment
# For an arc, EC is at BC + arc travel if seg_vertices[-1] is not None:
# We need to use the center calculation end_pt = seg_vertices[-1][1]
center_offset_angle = last_direction + (math.pi / 2 if is_ccw else -math.pi / 2) if pis:
center_x = last_start_x + last_radius * math.cos(center_offset_angle) last = pis[-1]
center_y = last_start_y + last_radius * math.sin(center_offset_angle) dist = ((end_pt[0] - last["e"]) ** 2 + (end_pt[1] - last["n"]) ** 2) ** 0.5
if dist > 0.001:
# EC is at the end of the arc pis.append({"e": end_pt[0], "n": end_pt[1], "radius": 0.0, "pi_type": "ENDPOINT"})
ec_angle = center_offset_angle + math.pi + (deflection if is_ccw else -deflection) else:
end_x = center_x + last_radius * math.cos(ec_angle) pis.append({"e": end_pt[0], "n": end_pt[1], "radius": 0.0, "pi_type": "ENDPOINT"})
end_y = center_y + last_radius * math.sin(ec_angle)
else:
# For other segment types (CLOTHOID, etc.), use start point as fallback
# TODO: Support spiral transitions
end_x = last_start_x
end_y = last_start_y
pis.append({
"e": end_x,
"n": end_y,
"radius": 0.0,
"pi_type": "ENDPOINT"
})
return pis return pis
@@ -1207,9 +1030,7 @@ class Alignment:
return removed_count return removed_count
@classmethod @classmethod
def collect_pis_from_empties( def collect_pis_from_empties(cls, alignment_id: int) -> Tuple[List[Tuple[float, float]], List[float]]:
cls, alignment_id: int
) -> Tuple[List[Tuple[float, float]], List[float]]:
"""Gather current PI positions from EMPTY objects. """Gather current PI positions from EMPTY objects.
Reads the current positions of PI empties and converts them Reads the current positions of PI empties and converts them