mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-09 17:31:45 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user