mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-05 20:06:25 +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 = (
|
classes = (
|
||||||
# Property groups (must be registered before classes that use them)
|
# Property groups (must be registered before classes that use them)
|
||||||
prop.AlignmentPI,
|
prop.AlignmentPI,
|
||||||
prop.AlignmentSegmentItem,
|
|
||||||
prop.AlignmentDisplayRow,
|
prop.AlignmentDisplayRow,
|
||||||
prop.SaikeiAlignmentProperties,
|
prop.SaikeiAlignmentProperties,
|
||||||
# UILists
|
# UILists
|
||||||
|
|||||||
@@ -20,7 +20,6 @@
|
|||||||
|
|
||||||
|
|
||||||
import bpy
|
import bpy
|
||||||
import math
|
|
||||||
import time
|
import time
|
||||||
import bonsai.core.alignment as core
|
import bonsai.core.alignment as core
|
||||||
import bonsai.tool as tool
|
import bonsai.tool as tool
|
||||||
@@ -28,8 +27,7 @@ import ifcopenshell.api.alignment
|
|||||||
import ifcopenshell.api.spatial
|
import ifcopenshell.api.spatial
|
||||||
from bpy_extras.io_utils import ImportHelper
|
from bpy_extras.io_utils import ImportHelper
|
||||||
from bpy.types import Operator
|
from bpy.types import Operator
|
||||||
from bpy.props import StringProperty, FloatProperty, IntProperty
|
from bpy.props import StringProperty, FloatProperty
|
||||||
from mathutils import Vector
|
|
||||||
from . import decorator as alignment_decorator
|
from . import decorator as alignment_decorator
|
||||||
from bonsai.bim.module.model.polyline import PolylineOperator
|
from bonsai.bim.module.model.polyline import PolylineOperator
|
||||||
from bonsai.bim.module.model.decorator import PolylineDecorator
|
from bonsai.bim.module.model.decorator import PolylineDecorator
|
||||||
@@ -192,7 +190,7 @@ def sync_pis_from_ifc(props):
|
|||||||
|
|
||||||
# Extract PIs from segment data
|
# Extract PIs from segment data
|
||||||
# This reconstructs approximate PIs from the IFC segment geometry
|
# 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:
|
if not extracted_pis:
|
||||||
# Couldn't extract - keep current props.pis
|
# Couldn't extract - keep current props.pis
|
||||||
@@ -203,8 +201,8 @@ def sync_pis_from_ifc(props):
|
|||||||
props.pis.clear()
|
props.pis.clear()
|
||||||
for pi_data in extracted_pis:
|
for pi_data in extracted_pis:
|
||||||
pi = props.pis.add()
|
pi = props.pis.add()
|
||||||
pi.e = pi_data["e"]
|
pi.e = str(pi_data["e"])
|
||||||
pi.n = pi_data["n"]
|
pi.n = str(pi_data["n"])
|
||||||
pi.pi_type = pi_data["pi_type"]
|
pi.pi_type = pi_data["pi_type"]
|
||||||
pi.radius = pi_data.get("radius", 0.0)
|
pi.radius = pi_data.get("radius", 0.0)
|
||||||
|
|
||||||
@@ -215,359 +213,6 @@ def sync_pis_from_ifc(props):
|
|||||||
return True
|
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):
|
def on_radius_changed(pi, context):
|
||||||
"""Callback when PI radius is changed. Triggers geometry recalculation.
|
"""Callback when PI radius is changed. Triggers geometry recalculation.
|
||||||
|
|
||||||
@@ -626,6 +271,9 @@ def rebuild_display_rows(props):
|
|||||||
segment_num = 0
|
segment_num = 0
|
||||||
i = 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):
|
while i < len(pis):
|
||||||
pi = pis[i]
|
pi = pis[i]
|
||||||
is_interior = i > 0 and i < len(pis) - 1
|
is_interior = i > 0 and i < len(pis) - 1
|
||||||
@@ -633,17 +281,18 @@ def rebuild_display_rows(props):
|
|||||||
|
|
||||||
if has_curve:
|
if has_curve:
|
||||||
# Interior PI with curve: becomes a CURVE SEGMENT row
|
# Interior PI with curve: becomes a CURVE SEGMENT row
|
||||||
# This replaces what would have been a Mid point row
|
|
||||||
segment_num += 1
|
segment_num += 1
|
||||||
curve_row = props.display_rows.add()
|
curve_row = props.display_rows.add()
|
||||||
curve_row.row_type = "SEGMENT"
|
curve_row.row_type = "SEGMENT"
|
||||||
curve_row.segment_number = segment_num
|
curve_row.segment_number = segment_num
|
||||||
curve_row.pi_index = i
|
curve_row.pi_index = i
|
||||||
curve_row.display_type = "Curve"
|
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.n = pi.n
|
||||||
curve_row.radius = pi.radius
|
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:
|
else:
|
||||||
# Regular point row (End or Mid without curve)
|
# Regular point row (End or Mid without curve)
|
||||||
point_row = props.display_rows.add()
|
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)
|
# Add tangent segment row after this point/curve (except after last PI)
|
||||||
if i < len(pis) - 1:
|
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
|
segment_num += 1
|
||||||
seg_row = props.display_rows.add()
|
seg_row = props.display_rows.add()
|
||||||
seg_row.row_type = "SEGMENT"
|
seg_row.row_type = "SEGMENT"
|
||||||
@@ -671,8 +316,24 @@ def rebuild_display_rows(props):
|
|||||||
seg_row.pi_index = i
|
seg_row.pi_index = i
|
||||||
seg_row.display_type = "Tan"
|
seg_row.display_type = "Tan"
|
||||||
|
|
||||||
# Compute segment length accounting for curves at either end
|
# Compute tangent lengths at each end to subtract from full distance
|
||||||
seg_row.length = compute_segment_length(props, i, account_for_curves=True)
|
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
|
i += 1
|
||||||
|
|
||||||
@@ -716,8 +377,8 @@ class SAIKEI_OT_add_pi(Operator):
|
|||||||
# Additional PIs - extrapolate from last two
|
# Additional PIs - extrapolate from last two
|
||||||
prev = props.pis[-2]
|
prev = props.pis[-2]
|
||||||
prev_prev = props.pis[-3] if len(props.pis) > 2 else prev
|
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
|
de = float(prev.e) - float(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
|
dn = float(prev.n) - float(prev_prev.n) if len(props.pis) > 2 else 0.0
|
||||||
pi.e = str(float(prev.e) + de)
|
pi.e = str(float(prev.e) + de)
|
||||||
pi.n = str(float(prev.n) + dn)
|
pi.n = str(float(prev.n) + dn)
|
||||||
pi.pi_type = "TANGENT"
|
pi.pi_type = "TANGENT"
|
||||||
@@ -955,7 +616,7 @@ class SAIKEI_OT_pick_pi_from_viewport(bpy.types.Operator, PolylineOperator, tool
|
|||||||
rebuild_display_rows(props)
|
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"""
|
"""Recalculate PI geometry and update IFC/visualization"""
|
||||||
|
|
||||||
bl_idname = "saikei.recalculate_pis"
|
bl_idname = "saikei.recalculate_pis"
|
||||||
@@ -973,7 +634,7 @@ class SAIKEI_OT_recalculate_pis(Operator):
|
|||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def execute(self, context):
|
def _execute(self, context):
|
||||||
import ifcopenshell.api.alignment as align_api
|
import ifcopenshell.api.alignment as align_api
|
||||||
|
|
||||||
ifc = tool.Ifc.get()
|
ifc = tool.Ifc.get()
|
||||||
@@ -1027,7 +688,7 @@ class SAIKEI_OT_recalculate_pis(Operator):
|
|||||||
return {"FINISHED"}
|
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"""
|
"""Clear all PI points and optionally remove visualization/IFC data"""
|
||||||
|
|
||||||
bl_idname = "saikei.clear_pis"
|
bl_idname = "saikei.clear_pis"
|
||||||
@@ -1048,7 +709,7 @@ class SAIKEI_OT_clear_pis(Operator):
|
|||||||
def invoke(self, context, event):
|
def invoke(self, context, event):
|
||||||
return context.window_manager.invoke_confirm(self, event)
|
return context.window_manager.invoke_confirm(self, event)
|
||||||
|
|
||||||
def execute(self, context):
|
def _execute(self, context):
|
||||||
ifc = tool.Ifc.get()
|
ifc = tool.Ifc.get()
|
||||||
props = context.scene.SaikeiAlignmentProperties
|
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"""
|
"""Create a new IFC alignment"""
|
||||||
|
|
||||||
bl_idname = "saikei.create_alignment"
|
bl_idname = "saikei.create_alignment"
|
||||||
@@ -1101,7 +762,7 @@ class SAIKEI_OT_create_alignment(Operator):
|
|||||||
def poll(cls, context):
|
def poll(cls, context):
|
||||||
return poll_ifc4x3(cls, context)
|
return poll_ifc4x3(cls, context)
|
||||||
|
|
||||||
def execute(self, context):
|
def _execute(self, context):
|
||||||
ifc = tool.Ifc.get()
|
ifc = tool.Ifc.get()
|
||||||
props = context.scene.SaikeiAlignmentProperties
|
props = context.scene.SaikeiAlignmentProperties
|
||||||
|
|
||||||
@@ -1124,7 +785,7 @@ class SAIKEI_OT_create_alignment(Operator):
|
|||||||
return {"FINISHED"}
|
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"""
|
"""Create alignment using the PI (Point of Intersection) method"""
|
||||||
|
|
||||||
bl_idname = "saikei.create_alignment_by_pi"
|
bl_idname = "saikei.create_alignment_by_pi"
|
||||||
@@ -1142,7 +803,7 @@ class SAIKEI_OT_create_alignment_by_pi(Operator):
|
|||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def execute(self, context):
|
def _execute(self, context):
|
||||||
props = context.scene.SaikeiAlignmentProperties
|
props = context.scene.SaikeiAlignmentProperties
|
||||||
if not props.active_alignment_id:
|
if not props.active_alignment_id:
|
||||||
return {"FINISHED"}
|
return {"FINISHED"}
|
||||||
@@ -1155,7 +816,7 @@ class SAIKEI_OT_create_alignment_by_pi(Operator):
|
|||||||
if h_layout:
|
if h_layout:
|
||||||
# Check if horizontal layout is empty (only has zero-length terminal or no segments)
|
# Check if horizontal layout is empty (only has zero-length terminal or no segments)
|
||||||
segments = ifcopenshell.api.alignment.get_layout_segments(h_layout)
|
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:
|
if not has_real_segments:
|
||||||
# Use existing alignment - add segments to it
|
# Use existing alignment - add segments to it
|
||||||
@@ -1178,7 +839,7 @@ class SAIKEI_OT_create_alignment_by_pi(Operator):
|
|||||||
return {"FINISHED"}
|
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"""
|
"""Import alignment from CSV file"""
|
||||||
|
|
||||||
bl_idname = "saikei.import_alignment_csv"
|
bl_idname = "saikei.import_alignment_csv"
|
||||||
@@ -1193,7 +854,7 @@ class SAIKEI_OT_import_alignment_csv(Operator, ImportHelper):
|
|||||||
def poll(cls, context):
|
def poll(cls, context):
|
||||||
return poll_ifc4x3(cls, context)
|
return poll_ifc4x3(cls, context)
|
||||||
|
|
||||||
def execute(self, context):
|
def _execute(self, context):
|
||||||
ifc = tool.Ifc.get()
|
ifc = tool.Ifc.get()
|
||||||
props = context.scene.SaikeiAlignmentProperties
|
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"""
|
"""Add a stationing referent to the alignment"""
|
||||||
|
|
||||||
bl_idname = "saikei.add_stationing_referent"
|
bl_idname = "saikei.add_stationing_referent"
|
||||||
@@ -1258,7 +919,7 @@ class SAIKEI_OT_add_stationing_referent(Operator):
|
|||||||
station_str = format_station(self.station)
|
station_str = format_station(self.station)
|
||||||
layout.label(text=f"Station notation: {station_str}")
|
layout.label(text=f"Station notation: {station_str}")
|
||||||
|
|
||||||
def execute(self, context):
|
def _execute(self, context):
|
||||||
ifc = tool.Ifc.get()
|
ifc = tool.Ifc.get()
|
||||||
props = context.scene.SaikeiAlignmentProperties
|
props = context.scene.SaikeiAlignmentProperties
|
||||||
|
|
||||||
@@ -1304,7 +965,7 @@ def format_station(station_value):
|
|||||||
return f"{main}+{offset:05.2f}"
|
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"""
|
"""Auto-name segments based on station values"""
|
||||||
|
|
||||||
bl_idname = "saikei.name_segments"
|
bl_idname = "saikei.name_segments"
|
||||||
@@ -1322,7 +983,7 @@ class SAIKEI_OT_name_segments(Operator):
|
|||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def execute(self, context):
|
def _execute(self, context):
|
||||||
ifc = tool.Ifc.get()
|
ifc = tool.Ifc.get()
|
||||||
props = context.scene.SaikeiAlignmentProperties
|
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):
|
def _on_radius_update(self, context):
|
||||||
"""Callback when radius property changes.
|
"""Callback when radius property changes.
|
||||||
|
|
||||||
@@ -113,22 +102,6 @@ class AlignmentPI(PropertyGroup):
|
|||||||
precision=2,
|
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):
|
class AlignmentDisplayRow(PropertyGroup):
|
||||||
"""Property group for interleaved point/segment display in the table.
|
"""Property group for interleaved point/segment display in the table.
|
||||||
@@ -207,21 +180,10 @@ class SaikeiAlignmentProperties(PropertyGroup):
|
|||||||
pis: CollectionProperty(type=AlignmentPI)
|
pis: CollectionProperty(type=AlignmentPI)
|
||||||
active_pi_index: IntProperty(name="Active PI", default=0)
|
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)
|
# Combined point/segment display rows (for Civil 3D-style table)
|
||||||
display_rows: CollectionProperty(type=AlignmentDisplayRow)
|
display_rows: CollectionProperty(type=AlignmentDisplayRow)
|
||||||
active_display_row_index: IntProperty(name="Active Display Row", default=0)
|
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)
|
# PI Edit Mode state (for moving PIs with G key)
|
||||||
is_pi_edit_mode: BoolProperty(
|
is_pi_edit_mode: BoolProperty(
|
||||||
name="PI Edit Mode Active",
|
name="PI Edit Mode Active",
|
||||||
@@ -236,12 +198,6 @@ class SaikeiAlignmentProperties(PropertyGroup):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Display options
|
# Display options
|
||||||
show_pi_markers: BoolProperty(
|
|
||||||
name="Show PI Markers",
|
|
||||||
description="Show PI markers in viewport",
|
|
||||||
default=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
show_station_labels: BoolProperty(
|
show_station_labels: BoolProperty(
|
||||||
name="Show Station Labels",
|
name="Show Station Labels",
|
||||||
description="Show station labels along alignment",
|
description="Show station labels along alignment",
|
||||||
|
|||||||
@@ -20,115 +20,25 @@
|
|||||||
"""Core alignment business logic - Orchestration only, NO bpy imports.
|
"""Core alignment business logic - Orchestration only, NO bpy imports.
|
||||||
|
|
||||||
This module contains alignment-related business logic and workflow
|
This module contains alignment-related business logic and workflow
|
||||||
orchestration. All calculations and algorithms are in the tool layer.
|
orchestration. All calculations, algorithms, and IFC operations are
|
||||||
Functions receive tool classes as parameters following Bonsai's
|
in the tool layer. Functions receive tool classes as parameters
|
||||||
dependency injection pattern.
|
following Bonsai's dependency injection pattern.
|
||||||
|
|
||||||
NOTE: Math, calculations, and algorithms belong in tool/alignment.py.
|
NOTE: Math, calculations, algorithms, and IFC API calls belong in
|
||||||
This module only handles:
|
tool/alignment.py. This module only handles:
|
||||||
- Business rules and validation
|
- Business rules and validation
|
||||||
- Workflow orchestration (calling tool methods in sequence)
|
- Workflow orchestration (calling tool methods in sequence)
|
||||||
- Decision-making about what should happen
|
- Decision-making about what should happen
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from typing import TYPE_CHECKING, Optional
|
from typing import TYPE_CHECKING
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
import ifcopenshell
|
import ifcopenshell
|
||||||
from .. import tool
|
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
|
# PI Edit Mode Functions
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -159,8 +69,6 @@ def enter_pi_edit_mode(
|
|||||||
ValueError: If alignment doesn't exist, has no horizontal layout,
|
ValueError: If alignment doesn't exist, has no horizontal layout,
|
||||||
or has no real segments
|
or has no real segments
|
||||||
"""
|
"""
|
||||||
import ifcopenshell.api.alignment as align_api
|
|
||||||
|
|
||||||
# Validate alignment exists
|
# Validate alignment exists
|
||||||
ifc_file = ifc_tool.get()
|
ifc_file = ifc_tool.get()
|
||||||
if ifc_file is None:
|
if ifc_file is None:
|
||||||
@@ -174,8 +82,8 @@ def enter_pi_edit_mode(
|
|||||||
if not alignment.is_a("IfcAlignment"):
|
if not alignment.is_a("IfcAlignment"):
|
||||||
raise ValueError(f"Entity {alignment_id} is not an IfcAlignment")
|
raise ValueError(f"Entity {alignment_id} is not an IfcAlignment")
|
||||||
|
|
||||||
# Validate alignment has horizontal layout
|
# Validate alignment has horizontal layout (delegated to tool)
|
||||||
h_layout = align_api.get_horizontal_layout(alignment)
|
h_layout = alignment_tool.get_horizontal_layout(alignment)
|
||||||
if h_layout is None:
|
if h_layout is None:
|
||||||
raise ValueError(f"Alignment '{alignment.Name}' has no horizontal layout")
|
raise ValueError(f"Alignment '{alignment.Name}' has no horizontal layout")
|
||||||
|
|
||||||
@@ -228,9 +136,6 @@ def exit_pi_edit_mode(
|
|||||||
Raises:
|
Raises:
|
||||||
ValueError: If alignment doesn't exist or update fails
|
ValueError: If alignment doesn't exist or update fails
|
||||||
"""
|
"""
|
||||||
import ifcopenshell
|
|
||||||
import ifcopenshell.api.alignment as align_api
|
|
||||||
|
|
||||||
ifc_file = ifc_tool.get()
|
ifc_file = ifc_tool.get()
|
||||||
if ifc_file is None:
|
if ifc_file is None:
|
||||||
# No file loaded, just clean up empties
|
# No file loaded, just clean up empties
|
||||||
@@ -252,8 +157,8 @@ def exit_pi_edit_mode(
|
|||||||
if len(hpoints) < 2:
|
if len(hpoints) < 2:
|
||||||
raise ValueError("At least 2 PIs are required")
|
raise ValueError("At least 2 PIs are required")
|
||||||
|
|
||||||
# Get horizontal layout - required for in-place editing
|
# Get horizontal layout (delegated to tool)
|
||||||
h_layout = align_api.get_horizontal_layout(alignment)
|
h_layout = alignment_tool.get_horizontal_layout(alignment)
|
||||||
if h_layout is None:
|
if h_layout is None:
|
||||||
raise ValueError("Alignment has no horizontal layout")
|
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)
|
# Remove Blender visualization for segments (not the whole hierarchy)
|
||||||
alignment_tool.remove_layout_segment_objects(h_layout)
|
alignment_tool.remove_layout_segment_objects(h_layout)
|
||||||
|
|
||||||
# Clear existing IFC segments (preserves layout and zero-length terminator)
|
# Clear existing IFC segments and add new ones (delegated to tool)
|
||||||
align_api.clear_layout_segments(ifc_file, h_layout)
|
alignment_tool.clear_layout_segments(h_layout)
|
||||||
|
alignment_tool.layout_by_pi_method(h_layout, hpoints, radii)
|
||||||
# Add new segments with updated PI positions
|
|
||||||
align_api.layout_horizontal_alignment_by_pi_method(
|
|
||||||
ifc_file, h_layout, hpoints, radii
|
|
||||||
)
|
|
||||||
|
|
||||||
# Refresh Blender visualization for new segments
|
# Refresh Blender visualization for new segments
|
||||||
layout_obj = ifc_tool.get_object(h_layout)
|
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
|
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
|
@classmethod
|
||||||
def calculate_tangent_length(cls, radius: float, deflection_angle: float) -> float:
|
def calculate_tangent_length(cls, radius: float, deflection_angle: float) -> float:
|
||||||
"""Calculate tangent length for a circular curve.
|
"""Calculate tangent length for a circular curve.
|
||||||
@@ -165,39 +146,334 @@ class Alignment:
|
|||||||
"""
|
"""
|
||||||
return radius * deflection_angle
|
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
|
@classmethod
|
||||||
EC = PI + outgoing_tangent_vector * T
|
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:
|
Args:
|
||||||
pi_x: PI X coordinate
|
p1: Previous PI coordinates (e, n)
|
||||||
pi_y: PI Y coordinate
|
p2: Current PI coordinates (e, n)
|
||||||
incoming_direction: Direction of incoming tangent (radians)
|
p3: Next PI coordinates (e, n)
|
||||||
outgoing_direction: Direction of outgoing tangent (radians)
|
|
||||||
tangent_length: Calculated tangent length
|
|
||||||
|
|
||||||
Returns:
|
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
|
dx1 = p2[0] - p1[0]
|
||||||
bc_x = pi_x - tangent_length * math.cos(incoming_direction)
|
dy1 = p2[1] - p1[1]
|
||||||
bc_y = pi_y - tangent_length * math.sin(incoming_direction)
|
incoming = math.atan2(dy1, dx1)
|
||||||
|
|
||||||
# EC is along the outgoing tangent, after the PI
|
dx2 = p3[0] - p2[0]
|
||||||
ec_x = pi_x + tangent_length * math.cos(outgoing_direction)
|
dy2 = p3[1] - p2[1]
|
||||||
ec_y = pi_y + tangent_length * math.sin(outgoing_direction)
|
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
|
# Zero-Length Segment Utilities
|
||||||
@@ -250,89 +526,6 @@ class Alignment:
|
|||||||
return True
|
return True
|
||||||
return False
|
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
|
# Blender Object Creation
|
||||||
@@ -413,119 +606,6 @@ class Alignment:
|
|||||||
|
|
||||||
return obj
|
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
|
@classmethod
|
||||||
def _create_segment_curve(
|
def _create_segment_curve(
|
||||||
@@ -756,32 +836,6 @@ class Alignment:
|
|||||||
|
|
||||||
return removed_count
|
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
|
# Validation and Safe Wrappers
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
@@ -815,22 +869,6 @@ class Alignment:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return None
|
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
|
@classmethod
|
||||||
def safe_layout_horizontal_by_pi_method(
|
def safe_layout_horizontal_by_pi_method(
|
||||||
@@ -871,35 +909,6 @@ class Alignment:
|
|||||||
|
|
||||||
return True
|
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
|
# PI Edit Mode Methods
|
||||||
@@ -940,10 +949,10 @@ class Alignment:
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of dicts, each containing:
|
List of dicts, each containing:
|
||||||
- "x": float - X coordinate in IFC space
|
- "e": float - Easting coordinate in IFC space
|
||||||
- "y": float - Y coordinate in IFC space
|
- "n": float - Northing coordinate in IFC space
|
||||||
- "radius": float - Curve radius (0 for endpoints/tangent PIs)
|
- "radius": float - Curve radius (0 for endpoints/tangent PIs)
|
||||||
- "type": str - "ENDPOINT", "CURVE", or "TANGENT"
|
- "pi_type": str - "ENDPOINT", "CURVE", or "TANGENT"
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: If alignment has no horizontal layout or segments
|
ValueError: If alignment has no horizontal layout or segments
|
||||||
@@ -981,10 +990,10 @@ class Alignment:
|
|||||||
first_x = float(first_dp.StartPoint.Coordinates[0])
|
first_x = float(first_dp.StartPoint.Coordinates[0])
|
||||||
first_y = float(first_dp.StartPoint.Coordinates[1])
|
first_y = float(first_dp.StartPoint.Coordinates[1])
|
||||||
pis.append({
|
pis.append({
|
||||||
"x": first_x,
|
"e": first_x,
|
||||||
"y": first_y,
|
"n": first_y,
|
||||||
"radius": 0.0,
|
"radius": 0.0,
|
||||||
"type": "ENDPOINT"
|
"pi_type": "ENDPOINT"
|
||||||
})
|
})
|
||||||
|
|
||||||
# Track current position and direction for LINE segments
|
# Track current position and direction for LINE segments
|
||||||
@@ -1015,10 +1024,10 @@ class Alignment:
|
|||||||
pi_y = bc_y + tangent_length * math.sin(angle_in)
|
pi_y = bc_y + tangent_length * math.sin(angle_in)
|
||||||
|
|
||||||
pis.append({
|
pis.append({
|
||||||
"x": pi_x,
|
"e": pi_x,
|
||||||
"y": pi_y,
|
"n": pi_y,
|
||||||
"radius": radius,
|
"radius": radius,
|
||||||
"type": "CURVE"
|
"pi_type": "CURVE"
|
||||||
})
|
})
|
||||||
|
|
||||||
elif seg_type == "LINE":
|
elif seg_type == "LINE":
|
||||||
@@ -1031,10 +1040,10 @@ class Alignment:
|
|||||||
start_x = float(dp.StartPoint.Coordinates[0])
|
start_x = float(dp.StartPoint.Coordinates[0])
|
||||||
start_y = float(dp.StartPoint.Coordinates[1])
|
start_y = float(dp.StartPoint.Coordinates[1])
|
||||||
pis.append({
|
pis.append({
|
||||||
"x": start_x,
|
"e": start_x,
|
||||||
"y": start_y,
|
"n": start_y,
|
||||||
"radius": 0.0,
|
"radius": 0.0,
|
||||||
"type": "TANGENT"
|
"pi_type": "TANGENT"
|
||||||
})
|
})
|
||||||
|
|
||||||
prev_seg_type = seg_type
|
prev_seg_type = seg_type
|
||||||
@@ -1081,10 +1090,10 @@ class Alignment:
|
|||||||
end_y = last_start_y
|
end_y = last_start_y
|
||||||
|
|
||||||
pis.append({
|
pis.append({
|
||||||
"x": end_x,
|
"e": end_x,
|
||||||
"y": end_y,
|
"n": end_y,
|
||||||
"radius": 0.0,
|
"radius": 0.0,
|
||||||
"type": "ENDPOINT"
|
"pi_type": "ENDPOINT"
|
||||||
})
|
})
|
||||||
|
|
||||||
return pis
|
return pis
|
||||||
@@ -1142,7 +1151,7 @@ class Alignment:
|
|||||||
empty["saikei_pi_index"] = i
|
empty["saikei_pi_index"] = i
|
||||||
empty["saikei_pi_radius"] = pi["radius"]
|
empty["saikei_pi_radius"] = pi["radius"]
|
||||||
empty["saikei_alignment_id"] = alignment_id
|
empty["saikei_alignment_id"] = alignment_id
|
||||||
empty["saikei_pi_type"] = pi["type"]
|
empty["saikei_pi_type"] = pi["pi_type"]
|
||||||
|
|
||||||
# Parent to alignment object
|
# Parent to alignment object
|
||||||
empty.parent = alignment_obj
|
empty.parent = alignment_obj
|
||||||
|
|||||||
Reference in New Issue
Block a user