Adding spirals to alignments

This commit is contained in:
Richard Brice
2026-09-13 17:12:00 -07:00
parent f9a8c2e14f
commit a2914eeb91
10 changed files with 1035 additions and 127 deletions
@@ -42,14 +42,33 @@ improvements:
command. command.
6. Click each PI (or only the PIs of interest) and input the smoothing type and its parameters. 6. Click each PI (or only the PIs of interest) and input the smoothing type and its parameters.
Smoothing types include: Circular, Spiral-Circular, Circular-Spiral, Spiral-Circular-Spiral. Smoothing types include: Circular, Spiral-Circular, Circular-Spiral, Spiral-Circular-Spiral.
**Implemented** (`ALIGN_OT_apply_pi_curve`, `PICurveMarkerProperties.curve_type` in the
Alignments tab panel) for the clothoid spiral family, via the PI method: each PI marker still
stands for one combined "curve" in the UI, but resolves to a run of independent
`IfcAlignmentSegment`s underneath (tangent run / entry spiral / arc / exit spiral / tangent
run), placed by `ifcopenshell.api.alignment.solve_horizontal_alignment_by_pi_method` and
written via `layout_horizontal_alignment_by_pi_method`.
7. In a pop-up (or other appropriate UI element), input the parameters: 7. In a pop-up (or other appropriate UI element), input the parameters:
- **Circular curve**: radius only. - **Circular curve**: radius only.
- **Spiral curve**: spiral type (Clothoid, Bloss, Cosine, Helmert, etc.) and spiral length. - **Spiral curve**: spiral length(s) (entry, exit, or both). **Implemented for the clothoid
This assumes all spirals have infinite start/end radius and share the circular arc's radius. family only** — this assumes all spirals have infinite start/end radius and share the
circular arc's radius. Other spiral families (Bloss, Cosine, Sine, Cubic, Helmert) are not
yet supported by the PI-method solver; each would need its own curvature-vs-length
integrand substituted into `solve_horizontal_alignment_by_pi_method`'s displacement
composition (the tangent-distance projection itself is spiral-family agnostic).
**Open question**: other cases exist that this doesn't cover, e.g. a spiral between two **Confirmed future requirement**: compound curves (PCC, point of compound curvature — two
circular arcs of different radius (Spiral-Circular-Spiral-Circular-Spiral). No UI is proposed arcs curving the same direction) and reverse curves (PRC, point of reverse curvature — two
for this yet — it may require selecting 2 PIs and defining all parameters together. arcs curving opposite directions), joined directly with no tangent run between them, optionally
with a spiral on the outer/non-joined side of either curve (e.g.
Spiral-Circular-Spiral-Circular-Spiral). A PCC/PRC-capable solver variant (`join_next` on a PI's
radii entry, closure-validated so the two curves' tangent lengths exactly span the PI-to-PI
distance) exists as prior art on another branch, ported from upstream PR #8833, but was not
brought in with the clothoid spiral-circular-spiral pass.
**Open question**: the UI for this — since a compound/reverse curve junction spans two PIs, it
may need selecting 2 PIs and defining both curves' parameters together, rather than the
single-PI marker interaction used for §2 steps 6-7 today.
8. Right-click (or whatever is standard) to end the command. Generate the alignment automatically. 8. Right-click (or whatever is standard) to end the command. Generate the alignment automatically.
## 3. Interrogating an alignment ## 3. Interrogating an alignment
@@ -611,10 +611,10 @@ def _generate_alignment_segments(context, alignment, hpoints, radii):
aren't IFC-linked, so tool.Alignment.get_active_alignment() can't find aren't IFC-linked, so tool.Alignment.get_active_alignment() can't find
the alignment from them. the alignment from them.
Uses layout_horizontal_alignment_by_pi_method for now (circular curves Uses layout_horizontal_alignment_by_pi_method, which now also accepts
only). Once spiral segments are needed this has to become genuinely (radius, entry_length, exit_length) tuples for clothoid spiral-circular,
one-segment-at-a-time authoring via create_layout_segment(), since that circular-spiral, and spiral-circular-spiral PIs alongside plain-radius
API only ever emits LINE/CIRCULARARC — see REQUIREMENTS.md §2 step 7. circular curves — see solve_horizontal_alignment_by_pi_method.
""" """
ifc = tool.Ifc.get() ifc = tool.Ifc.get()
@@ -638,7 +638,7 @@ def _generate_alignment_segments(context, alignment, hpoints, radii):
tool.Alignment.refresh_alignment_representation_object(alignment) tool.Alignment.refresh_alignment_representation_object(alignment)
n_curved = sum(1 for r in radii if r) n_curved = sum(1 for r in radii if (r[0] if isinstance(r, tuple) else r))
return True, f"Drew alignment '{alignment.Name}' with {len(hpoints)} PIs ({n_curved} curved)" return True, f"Drew alignment '{alignment.Name}' with {len(hpoints)} PIs ({n_curved} curved)"
@@ -707,6 +707,43 @@ def _is_interior_pi_marker(obj) -> bool:
return obj.bonsai_pi_curve_marker.is_pi_marker return obj.bonsai_pi_curve_marker.is_pi_marker
def _pi_curve_radii_entry(marker):
"""One radii[] element (see solve_horizontal_alignment_by_pi_method) for a PI marker.
TANGENT stays a plain 0.0 (no curve). CIRCULAR stays a plain radius float
for backward compatibility. The three spiral curve types become a
(radius, entry_length, exit_length) tuple with whichever length(s) don't
apply left at 0.0 — only the clothoid spiral family is supported, per
solve_horizontal_alignment_by_pi_method.
"""
curve_type = marker.curve_type
if curve_type == "TANGENT":
return 0.0
if curve_type == "CIRCULAR":
return marker.radius
if curve_type == "SPIRAL_CIRCULAR":
return (marker.radius, marker.spiral_in_length, 0.0)
if curve_type == "CIRCULAR_SPIRAL":
return (marker.radius, 0.0, marker.spiral_out_length)
# SPIRAL_CIRCULAR_SPIRAL
return (marker.radius, marker.spiral_in_length, marker.spiral_out_length)
def _pi_curve_marker_label(marker) -> str:
"""Short label for a PI marker's name, reflecting its curve settings."""
curve_type = marker.curve_type
if curve_type == "TANGENT":
return "tangent"
if curve_type == "CIRCULAR":
return f"R={marker.radius:.2f}"
if curve_type == "SPIRAL_CIRCULAR":
return f"R={marker.radius:.2f}, Lin={marker.spiral_in_length:.2f}"
if curve_type == "CIRCULAR_SPIRAL":
return f"R={marker.radius:.2f}, Lout={marker.spiral_out_length:.2f}"
# SPIRAL_CIRCULAR_SPIRAL
return f"R={marker.radius:.2f}, Lin={marker.spiral_in_length:.2f}, Lout={marker.spiral_out_length:.2f}"
class ALIGN_OT_apply_pi_curve(Operator, tool.Ifc.Operator): class ALIGN_OT_apply_pi_curve(Operator, tool.Ifc.Operator):
"""Regenerate the alignment using the active PI marker's curve settings. """Regenerate the alignment using the active PI marker's curve settings.
@@ -753,19 +790,12 @@ class ALIGN_OT_apply_pi_curve(Operator, tool.Ifc.Operator):
+ [_world_point_to_local_ifc(ifc, unit_scale, m.location) for m in interior_markers] + [_world_point_to_local_ifc(ifc, unit_scale, m.location) for m in interior_markers]
+ [end] + [end]
) )
radii = [ radii = [_pi_curve_radii_entry(m.bonsai_pi_curve_marker) for m in interior_markers]
(m.bonsai_pi_curve_marker.radius if m.bonsai_pi_curve_marker.curve_type == "CIRCULAR" else 0.0)
for m in interior_markers
]
ok, message = _generate_alignment_segments(context, alignment, hpoints, radii) ok, message = _generate_alignment_segments(context, alignment, hpoints, radii)
marker = marker_obj.bonsai_pi_curve_marker marker = marker_obj.bonsai_pi_curve_marker
marker_obj.name = ( marker_obj.name = f"PI {marker.pi_index} ({_pi_curve_marker_label(marker)})"
f"PI {marker.pi_index} (R={marker.radius:.2f})"
if marker.curve_type == "CIRCULAR"
else f"PI {marker.pi_index} (tangent)"
)
# _generate_alignment_segments() replaces every IfcAlignmentSegment # _generate_alignment_segments() replaces every IfcAlignmentSegment
# with a new one, so a previously-highlighted segment's id is gone — # with a new one, so a previously-highlighted segment's id is gone —
# refreshing it would silently keep showing the old, now-stale # refreshing it would silently keep showing the old, now-stale
@@ -822,7 +852,9 @@ class ALIGN_OT_draw_horizontal_alignment(bpy.types.Operator, PolylineOperator, t
immediately generates the alignment with every PI a sharp corner. If immediately generates the alignment with every PI a sharp corner. If
there are interior PIs, a marker empty is left at each one — select a there are interior PIs, a marker empty is left at each one — select a
marker and use "Apply Curve" (see the Alignments tab panel) to give it a marker and use "Apply Curve" (see the Alignments tab panel) to give it a
circular curve and regenerate. ESC cancels without creating anything. circular arc, a clothoid spiral-circular/circular-spiral transition, or a
symmetric spiral-circular-spiral, and regenerate. ESC cancels without
creating anything.
Numeric Distance/Angle input is available via the D/A keys, same as the Numeric Distance/Angle input is available via the D/A keys, same as the
rest of Bonsai's polyline tools. rest of Bonsai's polyline tools.
+33 -4
View File
@@ -286,11 +286,40 @@ class PICurveMarkerProperties(PropertyGroup):
items=[ items=[
("TANGENT", "None (sharp PI)", "No curve — the two tangents meet directly"), ("TANGENT", "None (sharp PI)", "No curve — the two tangents meet directly"),
("CIRCULAR", "Circular", "A simple circular arc"), ("CIRCULAR", "Circular", "A simple circular arc"),
# Spiral-Circular / Circular-Spiral / Spiral-Circular-Spiral are not (
# implemented yet. See REQUIREMENTS.md §2 step 7 — they need each "SPIRAL_CIRCULAR",
# segment authored individually (create_layout_segment), which "Spiral-Circular",
# layout_horizontal_alignment_by_pi_method does not support. "An entry clothoid spiral transitions into the circular arc, which runs to the forward tangent",
),
(
"CIRCULAR_SPIRAL",
"Circular-Spiral",
"The circular arc leaves the back tangent directly and transitions to the forward tangent via an exit clothoid spiral",
),
(
"SPIRAL_CIRCULAR_SPIRAL",
"Spiral-Circular-Spiral",
"An entry clothoid spiral, a circular arc, and an exit clothoid spiral, symmetric about the PI",
),
# Only the clothoid spiral family is supported for now — see
# solve_horizontal_alignment_by_pi_method. Other families (Bloss,
# cosine, sine, cubic, Helmert) would need their own
# curvature-vs-length integrand.
], ],
default="TANGENT", default="TANGENT",
) )
radius: FloatProperty(name="Radius", default=100.0, min=0.0001, unit="LENGTH") radius: FloatProperty(name="Radius", default=100.0, min=0.0001, unit="LENGTH")
spiral_in_length: FloatProperty(
name="Entry Spiral Length",
description="Length of the clothoid spiral ahead of the circular arc",
default=100.0,
min=0.0001,
unit="LENGTH",
)
spiral_out_length: FloatProperty(
name="Exit Spiral Length",
description="Length of the clothoid spiral following the circular arc",
default=100.0,
min=0.0001,
unit="LENGTH",
)
+5 -1
View File
@@ -219,8 +219,12 @@ class ALIGN_PT_alignment_authoring(Panel):
pi_data = marker.bonsai_pi_curve_marker pi_data = marker.bonsai_pi_curve_marker
box.label(text=f"PI {pi_data.pi_index}", icon="EMPTY_AXIS") box.label(text=f"PI {pi_data.pi_index}", icon="EMPTY_AXIS")
box.prop(pi_data, "curve_type") box.prop(pi_data, "curve_type")
if pi_data.curve_type == "CIRCULAR": if pi_data.curve_type != "TANGENT":
box.prop(pi_data, "radius") box.prop(pi_data, "radius")
if pi_data.curve_type in {"SPIRAL_CIRCULAR", "SPIRAL_CIRCULAR_SPIRAL"}:
box.prop(pi_data, "spiral_in_length")
if pi_data.curve_type in {"CIRCULAR_SPIRAL", "SPIRAL_CIRCULAR_SPIRAL"}:
box.prop(pi_data, "spiral_out_length")
box.operator("align.apply_pi_curve", icon="CHECKMARK") box.operator("align.apply_pi_curve", icon="CHECKMARK")
else: else:
box.label(text="Select a PI marker to define its curve", icon="INFO") box.label(text="Select a PI marker to define its curve", icon="INFO")
@@ -34,7 +34,11 @@ This API does not determine alignment parameters based on rules, such as minimum
This API is under development and subject to code breaking changes in the future. This API is under development and subject to code breaking changes in the future.
Presently, this API supports: Presently, this API supports:
1. Creating alignments, both horizontal and vertical, using the PI method. Alignment definition can be read from a CSV file. 1. Creating alignments, both horizontal and vertical, using the PI method, including clothoid
transition spirals in the horizontal layout. The horizontal PI solve is also available as a
pure geometric computation (solve_horizontal_alignment_by_pi_method) for callers that need
segment parameters without writing to a file, such as interactive editors. Alignment
definition can be read from a CSV file.
2. Creating alignments segment by segment. 2. Creating alignments segment by segment.
3. Automatic creation of geometric definitions (IfcCompositeCurve, IfcGradientCurve, IfcSegmentedReferenceCurve) 3. Automatic creation of geometric definitions (IfcCompositeCurve, IfcGradientCurve, IfcSegmentedReferenceCurve)
4. Explicit definition of stationing, including station equations and reverse (decreasing) stationing 4. Explicit definition of stationing, including station equations and reverse (decreasing) stationing
@@ -42,7 +46,8 @@ Presently, this API supports:
6. Utility functions for printing business logical and geometric representations, as well as minimal geometry evaluations 6. Utility functions for printing business logical and geometric representations, as well as minimal geometry evaluations
Future versions of this API may support: Future versions of this API may support:
1. Defining alignments using the PI method, including transition spirals 1. Transition spiral families other than the clothoid (Bloss, cosine, sine, cubic, Helmert) in
the PI method solver.
2. Updating horizontal curve definitions by revising transition spiral parameters and circular curve radii 2. Updating horizontal curve definitions by revising transition spiral parameters and circular curve radii
3. Updating vertical curve definitions by revising horizontal length of curves 3. Updating vertical curve definitions by revising horizontal length of curves
4. Removing a segment at any location along a curve 4. Removing a segment at any location along a curve
@@ -94,6 +99,12 @@ from .layout_vertical_alignment_by_pi_method import (
) )
from .name_segments import name_segments from .name_segments import name_segments
from .segment_vertices import segment_vertices from .segment_vertices import segment_vertices
from .solve_horizontal_alignment_by_pi_method import (
HorizontalSegmentDefinition,
compute_clothoid_end,
compute_horizontal_segment_end,
solve_horizontal_alignment_by_pi_method,
)
from .update_alignment_parameter_segment_tags import update_alignment_parameter_segment_tags from .update_alignment_parameter_segment_tags import update_alignment_parameter_segment_tags
from .update_end_point import update_end_point from .update_end_point import update_end_point
from .update_fallback_position import update_fallback_position from .update_fallback_position import update_fallback_position
@@ -101,6 +112,10 @@ from .update_key_point_referents import update_key_point_referents
from .util import * from .util import *
__all__ = [ __all__ = [
"HorizontalSegmentDefinition",
"compute_clothoid_end",
"compute_horizontal_segment_end",
"solve_horizontal_alignment_by_pi_method",
"add_positioning_referent", "add_positioning_referent",
"add_stationing_referent", "add_stationing_referent",
"add_vertical_layout", "add_vertical_layout",
@@ -17,7 +17,7 @@
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>. # along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
from collections.abc import Sequence from collections.abc import Sequence
from typing import Optional from typing import Optional, Union
import ifcopenshell import ifcopenshell
import ifcopenshell.api.alignment import ifcopenshell.api.alignment
@@ -29,7 +29,7 @@ def create_by_pi_method(
file: ifcopenshell.file, file: ifcopenshell.file,
name: str, name: str,
hpoints: Sequence[Sequence[float]], hpoints: Sequence[Sequence[float]],
radii: Sequence[float], radii: Sequence[Union[float, Sequence[float]]],
vpoints: Sequence[Sequence[float]] = None, vpoints: Sequence[Sequence[float]] = None,
lengths: Sequence[float] = None, lengths: Sequence[float] = None,
start_station: Optional[float] = None, start_station: Optional[float] = None,
@@ -38,9 +38,13 @@ def create_by_pi_method(
Create an alignment using the PI layout method for both horizontal and vertical alignments. Create an alignment using the PI layout method for both horizontal and vertical alignments.
If vpoints and lengths are omitted, only a horizontal alignment is created. If vpoints and lengths are omitted, only a horizontal alignment is created.
Each element of radii is either a circular curve radius R, or a (R, Lin, Lout) sequence with
clothoid spiral transition curve lengths ahead of and following the circular curve (see
layout_horizontal_alignment_by_pi_method / solve_horizontal_alignment_by_pi_method).
:param name: value for Name attribute :param name: value for Name attribute
:param points: (X,Y) pairs denoting the location of the horizontal PIs, including start and end :param points: (X,Y) pairs denoting the location of the horizontal PIs, including start and end
:param radii: radii values to use for transition :param radii: radii values to use for transition, optionally with spiral transition lengths
:param vpoints: (distance_along, Z_height) pairs denoting the location of the vertical PIs, including start and end. :param vpoints: (distance_along, Z_height) pairs denoting the location of the vertical PIs, including start and end.
:param lengths: parabolic vertical curve horizontal length values to use for transition :param lengths: parabolic vertical curve horizontal length values to use for transition
:param start_station: if given, the starting station value. A STATION IfcReferent named :param start_station: if given, the starting station value. A STATION IfcReferent named
@@ -16,128 +16,104 @@
# You should have received a copy of the GNU Lesser General Public License # You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>. # along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import math
from collections.abc import Sequence from collections.abc import Sequence
from typing import Optional, Union
import ifcopenshell import ifcopenshell
import ifcopenshell.api.alignment import ifcopenshell.api.alignment
import ifcopenshell.util.unit import ifcopenshell.util.unit
from ifcopenshell import entity_instance from ifcopenshell import entity_instance
from ifcopenshell.api.alignment.solve_horizontal_alignment_by_pi_method import (
HorizontalSegmentDefinition,
solve_horizontal_alignment_by_pi_method,
)
def _create_cant_segment(
file: ifcopenshell.file,
cant_layout: entity_instance,
segment: HorizontalSegmentDefinition,
) -> None:
"""
Appends the cant segment corresponding to one horizontal segment definition. Cant is applied
to a single rail (the rail on the outside of the curve). Constant cant is modeled with
CONSTANTCANT and varying cant (over a transition curve) with LINEARTRANSITION.
"""
is_transition = segment.start_cant != segment.end_cant
if segment.raise_left_rail:
start_left, start_right = segment.start_cant, 0.0
end_left, end_right = segment.end_cant, 0.0
else:
start_left, start_right = 0.0, segment.start_cant
end_left, end_right = 0.0, segment.end_cant
design_parameters = file.createIfcAlignmentCantSegment(
StartTag=None,
EndTag=None,
StartDistAlong=segment.start_dist_along,
HorizontalLength=segment.segment_length,
StartCantLeft=start_left,
EndCantLeft=end_left if is_transition else None,
StartCantRight=start_right,
EndCantRight=end_right if is_transition else None,
PredefinedType="LINEARTRANSITION" if is_transition else "CONSTANTCANT",
)
ifcopenshell.api.alignment.create_layout_segment(file, cant_layout, design_parameters)
def layout_horizontal_alignment_by_pi_method( def layout_horizontal_alignment_by_pi_method(
file: ifcopenshell.file, layout: entity_instance, hpoints: Sequence[Sequence[float]], radii: Sequence[float] file: ifcopenshell.file,
layout: entity_instance,
hpoints: Sequence[Sequence[float]],
radii: Sequence[Union[float, Sequence[float]]],
cant_layout: Optional[entity_instance] = None,
cants: Optional[Sequence[float]] = None,
) -> None: ) -> None:
""" """
Appends IfcAlignmentHorizontalSegment to a previously defined IfcAlignmentHorizontal using the PI layout method. Appends IfcAlignmentHorizontalSegment to a previously defined IfcAlignmentHorizontal using the PI layout method.
The zero length segment is updated. The zero length segment is updated.
The geometry is computed by solve_horizontal_alignment_by_pi_method; see that function for the
meaning of hpoints, radii, and cants. This function writes the resulting segment definitions to
the layout.
Optionally, a cant profile can be created alongside the horizontal layout. Cant segments are
created one-for-one with the horizontal segments: zero cant on tangent runs (CONSTANTCANT),
linearly varying cant over spiral transitions (LINEARTRANSITION), and constant cant over
circular curves (CONSTANTCANT). The cant is applied to the rail on the outside of the curve.
Cant values are expressed in the project length unit. IfcAlignmentCant.RailHeadDistance is
taken from the cant_layout. Curves with a non-zero cant require entry and exit spiral
transition curves so the cant profile is continuous.
:param file: file :param file: file
:param layout: An IfcAlignmentHorizontal layout :param layout: An IfcAlignmentHorizontal layout
:param hpoints: (X, Y) pairs denoting the location of the horizontal PIs, including start (POB) and end (POE). :param hpoints: (X, Y) pairs denoting the location of the horizontal PIs, including start (POB) and end (POE).
:param radii: radius values to use for transition :param radii: radius values to use for transition, optionally with clothoid spiral transition lengths
as (R, Lin, Lout)
:param cant_layout: An IfcAlignmentCant layout to receive the cant segments. Required when cants is provided.
:param cants: cant values, one per PI curve, applied to the outer rail. Required when cant_layout is provided.
:return: None :return: None
""" """
if not (len(hpoints) - 2 == len(radii)): if (cant_layout is None) != (cants is None):
raise ValueError("radii should have two fewer elements that hpoints") raise ValueError("cant_layout and cants must be provided together")
angle_unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file, "PLANEANGLEUNIT") angle_unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file, "PLANEANGLEUNIT")
xBT, yBT = hpoints[0] for segment in solve_horizontal_alignment_by_pi_method(hpoints, radii, cants):
xPI, yPI = hpoints[1] if cant_layout is not None:
_create_cant_segment(file, cant_layout, segment)
i = 1 start_point = file.createIfcCartesianPoint(
Coordinates=segment.start_point,
for radius in radii: )
# back tangent
dxBT = xPI - xBT
dyBT = yPI - yBT
angleBT = math.atan2(dyBT, dxBT)
lengthBT = math.sqrt(dxBT * dxBT + dyBT * dyBT)
# forward tangent
i += 1
xFT, yFT = hpoints[i]
dxFT = xFT - xPI
dyFT = yFT - yPI
angleFT = math.atan2(dyFT, dxFT)
delta = angleFT - angleBT
tangent = abs(radius * math.tan(delta / 2))
lc = abs(radius * delta)
radius *= delta / abs(delta)
xPC = xPI - tangent * math.cos(angleBT)
yPC = yPI - tangent * math.sin(angleBT)
xPT = xPI + tangent * math.cos(angleFT)
yPT = yPI + tangent * math.sin(angleFT)
tangent_run = lengthBT - tangent
# create back tangent run
if 1.0e-03 < tangent_run:
pt = file.createIfcCartesianPoint(
Coordinates=(xBT, yBT),
)
design_parameters = file.createIfcAlignmentHorizontalSegment(
StartTag=None,
EndTag=None,
StartPoint=pt,
StartDirection=angleBT / angle_unit_scale,
StartRadiusOfCurvature=0.0,
EndRadiusOfCurvature=0.0,
SegmentLength=tangent_run,
GravityCenterLineHeight=None,
PredefinedType="LINE",
)
ifcopenshell.api.alignment.create_layout_segment(file, layout, design_parameters)
# create circular curve
if radius != 0.0:
pc = file.createIfcCartesianPoint(
Coordinates=(xPC, yPC),
)
design_parameters = file.createIfcAlignmentHorizontalSegment(
StartTag=None,
EndTag=None,
StartPoint=pc,
StartDirection=angleBT / angle_unit_scale,
StartRadiusOfCurvature=float(radius),
EndRadiusOfCurvature=float(radius),
SegmentLength=lc,
GravityCenterLineHeight=None,
PredefinedType="CIRCULARARC",
)
ifcopenshell.api.alignment.create_layout_segment(file, layout, design_parameters)
xBT = xPT
yBT = yPT
xPI = xFT
yPI = yFT
# done processing radii
# create last tangent run
dx = xPI - xBT
dy = yPI - yBT
angleBT = math.atan2(dy, dx)
tangent_run = math.sqrt(dx * dx + dy * dy)
if 1.0e-03 < tangent_run:
pt = file.createIfcCartesianPoint(Coordinates=(xBT, yBT))
design_parameters = file.createIfcAlignmentHorizontalSegment( design_parameters = file.createIfcAlignmentHorizontalSegment(
StartTag=None, StartTag=None,
EndTag=None, EndTag=None,
StartPoint=pt, StartPoint=start_point,
StartDirection=angleBT / angle_unit_scale, StartDirection=segment.start_direction / angle_unit_scale,
StartRadiusOfCurvature=0.0, StartRadiusOfCurvature=segment.start_radius_of_curvature,
EndRadiusOfCurvature=0.0, EndRadiusOfCurvature=segment.end_radius_of_curvature,
SegmentLength=tangent_run, SegmentLength=segment.segment_length,
GravityCenterLineHeight=None, GravityCenterLineHeight=None,
PredefinedType="LINE", PredefinedType=segment.predefined_type,
) )
ifcopenshell.api.alignment.create_layout_segment(file, layout, design_parameters) ifcopenshell.api.alignment.create_layout_segment(file, layout, design_parameters)
@@ -0,0 +1,429 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
# Ported from IfcOpenShell/IfcOpenShell#8833 by Petru Conduraru (BIMvoice), onto this
# repository's PI-method API surface.
import math
from collections.abc import Sequence
from typing import NamedTuple, Optional, Union
import numpy as np
# Gauss-Legendre quadrature nodes and weights used to integrate the clothoid position functions
_gauss_legendre_points = np.polynomial.legendre.leggauss(32)
class HorizontalSegmentDefinition(NamedTuple):
"""
Parameters of one horizontal alignment segment produced by the PI method solver.
The fields mirror IfcAlignmentHorizontalSegment so a definition can be written to a file
without further computation, but the definition itself is independent of any file. Directions
are in radians, lengths and coordinates in the caller's length unit.
"""
start_point: tuple[float, float]
"""(X, Y) of the segment start"""
start_direction: float
"""direction of the tangent at the segment start, in radians"""
start_radius_of_curvature: float
"""radius at the segment start; 0.0 for straight, positive curving left, negative curving right"""
end_radius_of_curvature: float
"""radius at the segment end, with the same sign convention as start_radius_of_curvature"""
segment_length: float
"""length of the segment along the curve"""
predefined_type: str
"""IfcAlignmentHorizontalSegmentTypeEnum value: LINE, CLOTHOID, or CIRCULARARC"""
start_dist_along: float = 0.0
"""distance along the alignment at the segment start"""
start_cant: float = 0.0
"""cant at the segment start, applied to the rail on the outside of the curve"""
end_cant: float = 0.0
"""cant at the segment end, applied to the rail on the outside of the curve"""
raise_left_rail: bool = False
"""True when the outside of the curve is the left rail (a curve to the right)"""
def compute_clothoid_end(length: float, start_curvature: float, end_curvature: float) -> tuple[float, float, float]:
"""
Computes the end point of a clothoid transition whose curvature varies linearly from
start_curvature to end_curvature over length.
The result (dx, dy, dtheta) is relative to the start of the transition, with the x-axis in the
direction of the tangent at the start. Curvatures are signed: positive curving left, negative
curving right. The position is computed with 32 point Gauss-Legendre quadrature of the clothoid
integrals.
:param length: length of the transition, measured along the curve
:param start_curvature: curvature at the start (1/R, 0.0 for a straight)
:param end_curvature: curvature at the end (1/R, 0.0 for a straight)
:return: (dx, dy, dtheta) displacement and change in tangent direction over the transition
"""
u, w = _gauss_legendre_points
l = 0.5 * length * (u + 1.0) # map quadrature points from (-1,1) onto (0,length)
theta = start_curvature * l + (end_curvature - start_curvature) * l * l / (2.0 * length)
dx = 0.5 * length * float(np.sum(w * np.cos(theta)))
dy = 0.5 * length * float(np.sum(w * np.sin(theta)))
dtheta = 0.5 * (start_curvature + end_curvature) * length
return dx, dy, dtheta
def compute_horizontal_segment_end(segment: HorizontalSegmentDefinition) -> tuple[float, float, float]:
"""
Computes the end point and end direction of a horizontal segment definition.
Useful for checking position and direction continuity between consecutive segments: the result
for one segment should match the start_point and start_direction of the next.
:param segment: the segment definition
:return: (x, y, direction) at the end of the segment, direction in radians
"""
x, y = segment.start_point
direction = segment.start_direction
length = segment.segment_length
if segment.predefined_type == "LINE":
return (x + length * math.cos(direction), y + length * math.sin(direction), direction)
start_curvature = 1.0 / segment.start_radius_of_curvature if segment.start_radius_of_curvature != 0.0 else 0.0
end_curvature = 1.0 / segment.end_radius_of_curvature if segment.end_radius_of_curvature != 0.0 else 0.0
if segment.predefined_type == "CIRCULARARC":
dtheta = start_curvature * length
dx = math.sin(dtheta) / start_curvature
dy = (1.0 - math.cos(dtheta)) / start_curvature
elif segment.predefined_type == "CLOTHOID":
dx, dy, dtheta = compute_clothoid_end(length, start_curvature, end_curvature)
else:
raise NotImplementedError(f"unsupported predefined type '{segment.predefined_type}'")
return (
x + dx * math.cos(direction) - dy * math.sin(direction),
y + dx * math.sin(direction) + dy * math.cos(direction),
direction + dtheta,
)
def solve_horizontal_alignment_by_pi_method(
hpoints: Sequence[Sequence[float]],
radii: Sequence[Union[float, Sequence[float]]],
cants: Optional[Sequence[float]] = None,
) -> list[HorizontalSegmentDefinition]:
"""
Solves a horizontal alignment defined by the PI layout method into a continuous sequence of
segment definitions.
This is a pure geometric computation: no file is read or written. Use
layout_horizontal_alignment_by_pi_method to write the solution to an IfcAlignmentHorizontal
layout, or consume the returned definitions directly, for example to preview an alignment in
an interactive editor before committing it to a file.
Each element of radii defines the transition at the corresponding PI and is either:
R - radius of a circular curve (tangent runs connect directly to the circular curve), or
(R, Lin, Lout) - radius of a circular curve with clothoid spiral transition curves of length
Lin ahead of the curve and Lout following the curve. When spiral transitions are used the
circular curve shifts inward relative to the tangent runs so the tangent runs, spirals, and
circular curve are continuous in position and direction. Lin and Lout can be 0.0 for a
spiral-less connection on that end of the curve.
Only clothoid spirals are supported at present (other spiral families - Bloss, cosine, sine,
cubic, Helmert - would need their own curvature-vs-length integrand substituted into the
displacement composition below; the tangent-distance projection itself is spiral-family
agnostic).
If cants is provided, each definition also carries the cant at the segment start and end,
applied to the rail on the outside of the curve: zero cant on tangent runs, linearly varying
cant over spiral transitions, and constant cant over circular curves. Because every horizontal
segment carries its own cant values, a cant layout built from the definitions is one-for-one
with the horizontal layout. Curves with a non-zero cant require entry and exit spiral
transition curves so the cant profile is continuous.
:param hpoints: (X, Y) pairs denoting the location of the horizontal PIs, including start (POB) and end (POE).
:param radii: radius values to use for transition, optionally with spiral transition lengths
:param cants: cant values, one per PI curve, applied to the outer rail
:return: list of segment definitions, in order, continuous in position and direction
"""
if not (len(hpoints) - 2 == len(radii)):
raise ValueError("radii should have two fewer elements that hpoints")
if cants is not None and not (len(cants) == len(radii)):
raise ValueError("cants should have the same number of elements as radii")
segments: list[HorizontalSegmentDefinition] = []
xBT, yBT = hpoints[0]
xPI, yPI = hpoints[1]
i = 1
dist_along = 0.0 # distance along the horizontal alignment at the start of the next segment
for curve_index, curve in enumerate(radii):
if isinstance(curve, (int, float)):
radius = float(curve)
entry_length = 0.0
exit_length = 0.0
else:
if len(curve) != 3:
raise ValueError("each radii element should be a radius R or a (R, Lin, Lout) sequence")
radius, entry_length, exit_length = (float(v) for v in curve)
if radius == 0.0 and (entry_length != 0.0 or exit_length != 0.0):
raise ValueError("spiral transition lengths require a non-zero radius")
cant = float(cants[curve_index]) if cants is not None else 0.0
if cant != 0.0 and (entry_length == 0.0 or exit_length == 0.0):
raise ValueError(
"curves with a non-zero cant require entry and exit spiral transition curves; "
"otherwise the cant profile is discontinuous"
)
# back tangent
dxBT = xPI - xBT
dyBT = yPI - yBT
angleBT = math.atan2(dyBT, dxBT)
lengthBT = math.sqrt(dxBT * dxBT + dyBT * dyBT)
# forward tangent
i += 1
xFT, yFT = hpoints[i]
dxFT = xFT - xPI
dyFT = yFT - yPI
angleFT = math.atan2(dyFT, dxFT)
delta = angleFT - angleBT
if entry_length == 0.0 and exit_length == 0.0:
# tangent runs connect directly to the circular curve
tangent = abs(radius * math.tan(delta / 2))
lc = abs(radius * delta)
radius *= delta / abs(delta)
xPC = xPI - tangent * math.cos(angleBT)
yPC = yPI - tangent * math.sin(angleBT)
xPT = xPI + tangent * math.cos(angleFT)
yPT = yPI + tangent * math.sin(angleFT)
tangent_run = lengthBT - tangent
# back tangent run
if 1.0e-03 < tangent_run:
segments.append(
HorizontalSegmentDefinition(
start_point=(xBT, yBT),
start_direction=angleBT,
start_radius_of_curvature=0.0,
end_radius_of_curvature=0.0,
segment_length=tangent_run,
predefined_type="LINE",
start_dist_along=dist_along,
raise_left_rail=delta < 0.0,
)
)
dist_along += tangent_run
# circular curve
if radius != 0.0:
segments.append(
HorizontalSegmentDefinition(
start_point=(xPC, yPC),
start_direction=angleBT,
start_radius_of_curvature=float(radius),
end_radius_of_curvature=float(radius),
segment_length=lc,
predefined_type="CIRCULARARC",
start_dist_along=dist_along,
start_cant=cant,
end_cant=cant,
raise_left_rail=delta < 0.0,
)
)
dist_along += lc
else:
# tangent runs connect to the circular curve with clothoid spiral transition curves.
# normalize the deflection angle onto (-pi, pi)
delta = math.atan2(math.sin(delta), math.cos(delta))
if delta == 0.0:
raise ValueError("PI deflection angle is zero; spiral transitions cannot be created")
R = abs(radius)
s = 1.0 if 0.0 < delta else -1.0 # +1 curve to the left, -1 curve to the right
theta1 = entry_length / (2.0 * R) # deflection of the entry spiral
theta2 = exit_length / (2.0 * R) # deflection of the exit spiral
theta_c = abs(delta) - theta1 - theta2 # deflection of the circular curve
if theta_c < 0.0:
raise ValueError(
"spiral transition curves are too long; their combined deflection exceeds the PI deflection angle"
)
lc = R * theta_c
# compose the displacement from the start of the entry spiral (TS) to the end of the
# exit spiral (ST), in a frame with the x-axis along the back tangent.
# pieces are computed for a curve to the left and mirrored by s.
pieces = []
if 0.0 < entry_length:
pieces.append(compute_clothoid_end(entry_length, 0.0, 1.0 / R))
pieces.append((R * math.sin(theta_c), R * (1.0 - math.cos(theta_c)), theta_c))
if 0.0 < exit_length:
pieces.append(compute_clothoid_end(exit_length, 1.0 / R, 0.0))
x = 0.0
y = 0.0
direction = 0.0
for dx_, dy_, dtheta_ in pieces:
x += dx_ * math.cos(direction) - s * dy_ * math.sin(direction)
y += dx_ * math.sin(direction) + s * dy_ * math.cos(direction)
direction += s * dtheta_
# locate TS on the back tangent and ST on the forward tangent so that the curve ends on
# the forward tangent. this accounts for the inward shift of the circular curve.
ts_to_pi = x - y / math.tan(delta) # distance from TS to the PI, along the back tangent
pi_to_st = y / math.sin(delta) # distance from the PI to ST, along the forward tangent
tangent_run = lengthBT - ts_to_pi
# back tangent run
if 1.0e-03 < tangent_run:
segments.append(
HorizontalSegmentDefinition(
start_point=(xBT, yBT),
start_direction=angleBT,
start_radius_of_curvature=0.0,
end_radius_of_curvature=0.0,
segment_length=tangent_run,
predefined_type="LINE",
start_dist_along=dist_along,
raise_left_rail=delta < 0.0,
)
)
dist_along += tangent_run
signed_radius = s * R
cur_x = xPI - ts_to_pi * math.cos(angleBT)
cur_y = yPI - ts_to_pi * math.sin(angleBT)
cur_direction = angleBT
# entry spiral
if 0.0 < entry_length:
segments.append(
HorizontalSegmentDefinition(
start_point=(cur_x, cur_y),
start_direction=cur_direction,
start_radius_of_curvature=0.0,
end_radius_of_curvature=signed_radius,
segment_length=entry_length,
predefined_type="CLOTHOID",
start_dist_along=dist_along,
start_cant=0.0,
end_cant=cant,
raise_left_rail=delta < 0.0,
)
)
dist_along += entry_length
dx_, dy_, dtheta_ = compute_clothoid_end(entry_length, 0.0, 1.0 / R)
cur_x += dx_ * math.cos(cur_direction) - s * dy_ * math.sin(cur_direction)
cur_y += dx_ * math.sin(cur_direction) + s * dy_ * math.cos(cur_direction)
cur_direction += s * dtheta_
# circular curve
if 1.0e-03 < lc:
segments.append(
HorizontalSegmentDefinition(
start_point=(cur_x, cur_y),
start_direction=cur_direction,
start_radius_of_curvature=signed_radius,
end_radius_of_curvature=signed_radius,
segment_length=lc,
predefined_type="CIRCULARARC",
start_dist_along=dist_along,
start_cant=cant,
end_cant=cant,
raise_left_rail=delta < 0.0,
)
)
dist_along += lc
cur_x += R * math.sin(theta_c) * math.cos(cur_direction) - s * R * (1.0 - math.cos(theta_c)) * math.sin(
cur_direction
)
cur_y += R * math.sin(theta_c) * math.sin(cur_direction) + s * R * (1.0 - math.cos(theta_c)) * math.cos(
cur_direction
)
cur_direction += s * theta_c
# exit spiral
if 0.0 < exit_length:
segments.append(
HorizontalSegmentDefinition(
start_point=(cur_x, cur_y),
start_direction=cur_direction,
start_radius_of_curvature=signed_radius,
end_radius_of_curvature=0.0,
segment_length=exit_length,
predefined_type="CLOTHOID",
start_dist_along=dist_along,
start_cant=cant,
end_cant=0.0,
raise_left_rail=delta < 0.0,
)
)
dist_along += exit_length
xPT = xPI + pi_to_st * math.cos(angleFT)
yPT = yPI + pi_to_st * math.sin(angleFT)
xBT = xPT
yBT = yPT
xPI = xFT
yPI = yFT
# done processing radii
# last tangent run
dx = xPI - xBT
dy = yPI - yBT
angleBT = math.atan2(dy, dx)
tangent_run = math.sqrt(dx * dx + dy * dy)
if 1.0e-03 < tangent_run:
segments.append(
HorizontalSegmentDefinition(
start_point=(xBT, yBT),
start_direction=angleBT,
start_radius_of_curvature=0.0,
end_radius_of_curvature=0.0,
segment_length=tangent_run,
predefined_type="LINE",
start_dist_along=dist_along,
)
)
return segments
@@ -0,0 +1,204 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import logging
import math
import numpy as np
import pytest
import ifcopenshell.api.alignment
import ifcopenshell.api.context
import ifcopenshell.api.unit
import ifcopenshell.geom
import ifcopenshell.validate
from ifcopenshell import ifcopenshell_wrapper
try:
ifcopenshell.file(schema="IFC4X3_ADD2")
IFC4X3_AVAILABLE = True
except RuntimeError:
IFC4X3_AVAILABLE = False
def _create_file():
file = ifcopenshell.file(schema="IFC4X3_ADD2")
project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
length = ifcopenshell.api.unit.add_si_unit(file, unit_type="LENGTHUNIT")
ifcopenshell.api.unit.assign_unit(file, units=[length])
geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model")
axis_model_representation_subcontext = ifcopenshell.api.context.add_context(
file,
context_type="Model",
context_identifier="Axis",
target_view="MODEL_VIEW",
parent=geometric_representation_context,
)
return file
def _reference_clothoid_end(length, start_curvature, end_curvature, steps=20000):
"""Composite Simpson integration of the clothoid position functions, as an independent check."""
l = np.linspace(0.0, length, 2 * steps + 1)
theta = start_curvature * l + (end_curvature - start_curvature) * l * l / (2.0 * length)
h = length / (2.0 * steps)
weights = np.ones(2 * steps + 1)
weights[1:-1:2] = 4.0
weights[2:-1:2] = 2.0
dx = h / 3.0 * float(np.sum(weights * np.cos(theta)))
dy = h / 3.0 * float(np.sum(weights * np.sin(theta)))
return dx, dy
def test_compute_clothoid_end():
for length, k1, k2 in [(200.0, 0.0, 1.0 / 1000.0), (150.0, 1.0 / 1000.0, 0.0), (120.0, -1.0 / 800.0, 1.0 / 500.0)]:
dx, dy, dtheta = ifcopenshell.api.alignment.compute_clothoid_end(length, k1, k2)
ref_dx, ref_dy = _reference_clothoid_end(length, k1, k2)
assert dx == pytest.approx(ref_dx, abs=1.0e-12)
assert dy == pytest.approx(ref_dy, abs=1.0e-12)
assert dtheta == pytest.approx(0.5 * (k1 + k2) * length)
# signed curvatures mirror the unsigned result
dx, dy, dtheta = ifcopenshell.api.alignment.compute_clothoid_end(200.0, 0.0, 1.0 / 1000.0)
mx, my, mtheta = ifcopenshell.api.alignment.compute_clothoid_end(200.0, 0.0, -1.0 / 1000.0)
assert mx == pytest.approx(dx)
assert my == pytest.approx(-dy)
assert mtheta == pytest.approx(-dtheta)
def test_solve_produces_continuous_segments():
hpoints = [(500.0, 2500.0), (3340.0, 660.0), (4340.0, 5000.0), (7600.0, 4560.0), (8480.0, 2010.0)]
radii = [(1000.0, 200.0, 150.0), (1250.0, 180.0, 180.0), (950.0, 0.0, 120.0)]
segments = ifcopenshell.api.alignment.solve_horizontal_alignment_by_pi_method(hpoints, radii)
expected_types = [
"LINE",
"CLOTHOID",
"CIRCULARARC",
"CLOTHOID",
"LINE",
"CLOTHOID",
"CIRCULARARC",
"CLOTHOID",
"LINE",
"CIRCULARARC",
"CLOTHOID",
"LINE",
]
assert [s.predefined_type for s in segments] == expected_types
# the solution starts at the POB, in the direction of the first PI
assert segments[0].start_point == pytest.approx((500.0, 2500.0))
assert segments[0].start_direction == pytest.approx(math.atan2(660.0 - 2500.0, 3340.0 - 500.0))
# spirals run from zero curvature to the curve radius and vice versa
entry_spiral = segments[1]
assert entry_spiral.start_radius_of_curvature == 0.0
assert entry_spiral.end_radius_of_curvature == pytest.approx(1000.0) # positive, curve to the left
assert entry_spiral.segment_length == pytest.approx(200.0)
exit_spiral = segments[3]
assert exit_spiral.start_radius_of_curvature == pytest.approx(1000.0)
assert exit_spiral.end_radius_of_curvature == 0.0
assert exit_spiral.segment_length == pytest.approx(150.0)
assert segments[5].end_radius_of_curvature == pytest.approx(-1250.0) # curve to the right
# each segment ends exactly where the next one starts, in position and direction
dist_along = 0.0
for segment, next_segment in zip(segments[:-1], segments[1:]):
assert segment.start_dist_along == pytest.approx(dist_along)
end_x, end_y, end_direction = ifcopenshell.api.alignment.compute_horizontal_segment_end(segment)
assert end_x == pytest.approx(next_segment.start_point[0], abs=1.0e-9)
assert end_y == pytest.approx(next_segment.start_point[1], abs=1.0e-9)
direction_gap = end_direction - next_segment.start_direction
assert math.atan2(math.sin(direction_gap), math.cos(direction_gap)) == pytest.approx(0.0, abs=1.0e-12)
dist_along += segment.segment_length
def test_solve_plain_radius_matches_spiral_free_tuple():
hpoints = [(500.0, 2500.0), (3340.0, 660.0), (4340.0, 5000.0), (8480.0, 2010.0)]
segments1 = ifcopenshell.api.alignment.solve_horizontal_alignment_by_pi_method(hpoints, [1000.0, 1250.0])
segments2 = ifcopenshell.api.alignment.solve_horizontal_alignment_by_pi_method(
hpoints, [(1000.0, 0.0, 0.0), (1250.0, 0.0, 0.0)]
)
assert segments1 == segments2
def test_solve_errors():
hpoints = [(0.0, 0.0), (1000.0, 0.0), (2000.0, 1000.0)]
with pytest.raises(ValueError): # radii count mismatch
ifcopenshell.api.alignment.solve_horizontal_alignment_by_pi_method(hpoints, [500.0, 500.0])
with pytest.raises(ValueError): # malformed radii element
ifcopenshell.api.alignment.solve_horizontal_alignment_by_pi_method(hpoints, [(500.0, 50.0)])
with pytest.raises(ValueError): # spiral lengths without a radius
ifcopenshell.api.alignment.solve_horizontal_alignment_by_pi_method(hpoints, [(0.0, 50.0, 50.0)])
with pytest.raises(ValueError): # spirals deflect more than the PI deflection angle
ifcopenshell.api.alignment.solve_horizontal_alignment_by_pi_method(hpoints, [(500.0, 5000.0, 5000.0)])
with pytest.raises(ValueError): # zero deflection angle
ifcopenshell.api.alignment.solve_horizontal_alignment_by_pi_method(
[(0.0, 0.0), (1000.0, 0.0), (2000.0, 0.0)], [(500.0, 50.0, 50.0)]
)
@pytest.mark.skipif(not IFC4X3_AVAILABLE, reason="IFC4X3 not available")
def test_author_transition_curve_alignment():
"""
End-to-end example: author a tangent -> clothoid -> circular arc -> clothoid -> tangent
alignment, then check the written geometry for continuity with the geometry engine and
validate the file against the schema and express rules.
"""
file = _create_file()
alignment = ifcopenshell.api.alignment.create_by_pi_method(
file,
"TestAlignment",
[(500.0, 2500.0), (3340.0, 660.0), (4340.0, 5000.0)],
[(1000.0, 200.0, 150.0)],
[(0.0, 100.0), (2000.0, 135.0), (4000.0, 105.0)],
[1600.0],
)
horizontal_layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
segment_nest = ifcopenshell.api.alignment.get_alignment_segment_nest(horizontal_layout)
expected_types = ["LINE", "CLOTHOID", "CIRCULARARC", "CLOTHOID", "LINE", "LINE"] # last is the zero length segment
assert [s.DesignParameters.PredefinedType for s in segment_nest.RelatedObjects] == expected_types
# verify continuity of position and direction between consecutive segments of the
# geometric representation
curve = ifcopenshell.api.alignment.get_layout_curve(horizontal_layout)
settings = ifcopenshell.geom.settings()
for segment, next_segment in zip(curve.Segments[:-1], curve.Segments[1:]):
fn = ifcopenshell_wrapper.map_shape(settings, segment)
evaluator = ifcopenshell_wrapper.function_item_evaluator(settings, fn)
end = np.array(evaluator.evaluate(fn.end()))
end_position = end[0:2, 3]
end_direction = math.atan2(end[1, 0], end[0, 0])
start_position = next_segment.Placement.Location.Coordinates
d = next_segment.Placement.RefDirection.DirectionRatios
start_direction = math.atan2(d[1], d[0])
assert end_position[0] == pytest.approx(start_position[0], abs=1.0e-5)
assert end_position[1] == pytest.approx(start_position[1], abs=1.0e-5)
direction_gap = math.atan2(math.sin(end_direction - start_direction), math.cos(end_direction - start_direction))
assert direction_gap == pytest.approx(0.0, abs=1.0e-9)
# the file is schema and express rule valid
logger = ifcopenshell.validate.json_logger()
ifcopenshell.validate.validate(file, logger, express_rules=True)
assert [entry for entry in logger.statements if entry["level"] == logging.ERROR] == []
@@ -0,0 +1,196 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
"""
Golden worked example for solve_horizontal_alignment_by_pi_method with a symmetric
spiral-curve-spiral (clothoid / circular arc / clothoid) transition.
Every expected value below is derived directly from the classic route-surveying spiral
formulas (see e.g. AASHTO "A Policy on Geometric Design of Highways and Streets", spiral
curve tables), independently of solve_horizontal_alignment_by_pi_method's own
implementation (which integrates the clothoid position functions with 32 point
Gauss-Legendre quadrature). The two methods agreeing to 1e-6 or better is the point of the
test: it is a check on solve_horizontal_alignment_by_pi_method, not a restatement of it.
Problem setup (units are arbitrary but consistent, e.g. feet or meters):
Delta = 60 deg total PI deflection angle
R = 500 circular curve radius
Ls = 150 spiral length, both entry and exit (equal-spiral case)
Derivation, step by step:
theta_s = Ls / (2R) spiral angle (deflection of one spiral)
= 150 / 1000 = 0.15 rad
X = Ls * (1 - theta_s^2/10 + theta_s^4/216) local coordinates of the spiral's far end
Y = Ls * (theta_s/3 - theta_s^3/42 + theta_s^5/1320) (TS at the origin, tangent along +X)
p = Y - R*(1 - cos(theta_s)) shift: inward offset of the circular
curve from the tangent line
k = X - R*sin(theta_s) abscissa of the shifted PC, measured
from TS along the tangent
Ts = (R + p)*tan(Delta/2) + k total tangent distance, PI to TS (or,
by symmetry of an equal-spiral curve,
PI to ST)
Delta_c = Delta - 2*theta_s central angle left for the circular arc
Lc = R * Delta_c circular arc length
Plugging in Delta = pi/3, R = 500, Ls = 150 (computed with Python's math module, which is
just a calculator here -- no call into ifcopenshell.api.alignment is involved):
theta_s = 0.15
X = 149.6628515625
Y = 7.487955057832791
p = 1.8734940258539128
k = 74.9437853257004
Ts = 364.70058220066517
Delta_c = 0.7471975511965976 rad (=~ 42.809 deg)
Lc = 373.5987755982988
Geometry of the PI-method problem: POB at (0,0), PI1 at (D,0) with D = 1200 (comfortably
more than Ts so the back tangent run is a real segment), and POE at
(D + L*cos(-60deg), L*sin(-60deg)) with L = 1200, so PI1 deflects 60 degrees to the right.
Because the back tangent lies exactly on the world X axis (POB -> PI1 direction is 0 rad),
the offset of any point on the curve from the initial tangent line is simply that point's
world Y coordinate, and the spiral's local (X, Y) frame is the world frame translated to TS
(no rotation) -- which is what lets this example's numbers be checked directly against the
solver's segment start points without any extra transform.
TS = (D - Ts, 0) = (835.2994177993348, 0.0)
The circular arc begins where the entry spiral ends. In the spiral's local frame that is
(X, Y); since the curve deflects right (s = -1 in the solver's sign convention) the spiral's
local Y is mirrored into world coordinates:
circular arc start = TS + (X, -Y) = (984.9622693618348, -7.487955057832791)
By symmetry (equal spirals, same radius on both sides) the exit tangent run has the same
length as the entry tangent run, D - Ts = L - Ts = 835.2994177993348.
"""
import math
import pytest
import ifcopenshell.api.alignment
R = 500.0
Ls = 150.0
DELTA = math.pi / 3.0 # 60 degrees
D = 1200.0
L = 1200.0
# --- independently derived expected values (see module docstring for the derivation) ---
THETA_S = Ls / (2.0 * R)
assert THETA_S == pytest.approx(0.15)
EXPECTED_X = Ls * (1.0 - THETA_S**2 / 10.0 + THETA_S**4 / 216.0)
EXPECTED_Y = Ls * (THETA_S / 3.0 - THETA_S**3 / 42.0 + THETA_S**5 / 1320.0)
assert EXPECTED_X == pytest.approx(149.6628515625)
assert EXPECTED_Y == pytest.approx(7.487955057832791)
EXPECTED_P = EXPECTED_Y - R * (1.0 - math.cos(THETA_S))
EXPECTED_K = EXPECTED_X - R * math.sin(THETA_S)
assert EXPECTED_P == pytest.approx(1.8734940258539128)
assert EXPECTED_K == pytest.approx(74.9437853257004)
EXPECTED_TS = (R + EXPECTED_P) * math.tan(DELTA / 2.0) + EXPECTED_K
assert EXPECTED_TS == pytest.approx(364.70058220066517)
EXPECTED_DELTA_C = DELTA - 2.0 * THETA_S
EXPECTED_ARC_LENGTH = R * EXPECTED_DELTA_C
assert EXPECTED_DELTA_C == pytest.approx(0.7471975511965976)
assert EXPECTED_ARC_LENGTH == pytest.approx(373.5987755982988)
EXPECTED_TANGENT_RUN = D - EXPECTED_TS
assert EXPECTED_TANGENT_RUN == pytest.approx(835.2994177993348)
EXPECTED_TS_POINT = (D - EXPECTED_TS, 0.0)
EXPECTED_CIRCULARARC_START = (EXPECTED_TS_POINT[0] + EXPECTED_X, EXPECTED_TS_POINT[1] - EXPECTED_Y)
assert EXPECTED_CIRCULARARC_START == pytest.approx((984.9622693618348, -7.487955057832791))
def _pi_points() -> list[tuple[float, float]]:
pi0 = (0.0, 0.0)
pi1 = (D, 0.0)
pi2 = (D + L * math.cos(math.radians(-60.0)), L * math.sin(math.radians(-60.0)))
return [pi0, pi1, pi2]
def test_solve_spiral_worked_example_segment_types_and_lengths():
hpoints = _pi_points()
segments = ifcopenshell.api.alignment.solve_horizontal_alignment_by_pi_method(hpoints, [(R, Ls, Ls)])
expected_types = ["LINE", "CLOTHOID", "CIRCULARARC", "CLOTHOID", "LINE"]
assert [s.predefined_type for s in segments] == expected_types
back_tangent, entry_spiral, arc, exit_spiral, forward_tangent = segments
# first tangent run: POB to TS, length D - Ts
assert back_tangent.segment_length == pytest.approx(EXPECTED_TANGENT_RUN, rel=1.0e-6)
# spiral lengths are exactly what was requested
assert entry_spiral.segment_length == pytest.approx(Ls, rel=1.0e-6)
assert exit_spiral.segment_length == pytest.approx(Ls, rel=1.0e-6)
# circular arc length == R * Delta_c
assert arc.segment_length == pytest.approx(EXPECTED_ARC_LENGTH, rel=1.0e-6)
# by symmetry, the closing tangent run has the same length as the opening one
assert forward_tangent.segment_length == pytest.approx(EXPECTED_TANGENT_RUN, rel=1.0e-6)
def test_solve_spiral_worked_example_spiral_end_offset():
hpoints = _pi_points()
segments = ifcopenshell.api.alignment.solve_horizontal_alignment_by_pi_method(hpoints, [(R, Ls, Ls)])
_, entry_spiral, arc, _, _ = segments
# the back tangent (POB -> PI1) runs exactly along the world X axis, so TS is where the
# entry spiral starts. EXPECTED_TS_POINT comes from the truncated power series formulas
# for X and Y, while the solver integrates the same clothoid with Gauss-Legendre
# quadrature; the two methods agree to a few parts in 1e-10 relative, well inside the
# 1e-6 relative tolerance used throughout this test.
assert entry_spiral.start_point == pytest.approx(EXPECTED_TS_POINT, rel=1.0e-6, abs=1.0e-9)
# the circular arc starts where the entry spiral ends: TS + (X, -Y) in world coordinates
# (Y is mirrored because the curve deflects to the right). this is the spiral end offset
# from the initial tangent, expressed directly against the classic X, Y spiral formulas.
assert arc.start_point == pytest.approx(EXPECTED_CIRCULARARC_START, rel=1.0e-6, abs=1.0e-9)
offset_from_tangent = -arc.start_point[1] # tangent line is world Y == 0
assert offset_from_tangent == pytest.approx(EXPECTED_Y, rel=1.0e-6)
def test_solve_spiral_worked_example_is_continuous():
hpoints = _pi_points()
segments = ifcopenshell.api.alignment.solve_horizontal_alignment_by_pi_method(hpoints, [(R, Ls, Ls)])
max_position_gap = 0.0
max_direction_gap = 0.0
for segment, next_segment in zip(segments[:-1], segments[1:]):
end_x, end_y, end_direction = ifcopenshell.api.alignment.compute_horizontal_segment_end(segment)
position_gap = math.hypot(end_x - next_segment.start_point[0], end_y - next_segment.start_point[1])
raw_direction_gap = end_direction - next_segment.start_direction
direction_gap = abs(math.atan2(math.sin(raw_direction_gap), math.cos(raw_direction_gap)))
max_position_gap = max(max_position_gap, position_gap)
max_direction_gap = max(max_direction_gap, direction_gap)
assert max_position_gap < 1.0e-9
assert max_direction_gap < 1.0e-9