From a2914eeb914eb0d60462abe1f3a69f6e3ede327c Mon Sep 17 00:00:00 2001
From: Richard Brice <37087370+RickBrice@users.noreply.github.com>
Date: Sun, 13 Sep 2026 17:12:00 -0700
Subject: [PATCH] Adding spirals to alignments
---
.../bim/module/alignment/REQUIREMENTS.md | 29 +-
.../bonsai/bim/module/alignment/operator.py | 62 ++-
.../bonsai/bim/module/alignment/prop.py | 37 +-
src/bonsai/bonsai/bim/module/alignment/ui.py | 6 +-
.../ifcopenshell/api/alignment/__init__.py | 19 +-
.../api/alignment/create_by_pi_method.py | 10 +-
...ayout_horizontal_alignment_by_pi_method.py | 170 +++----
...solve_horizontal_alignment_by_pi_method.py | 429 ++++++++++++++++++
...solve_horizontal_alignment_by_pi_method.py | 204 +++++++++
.../test_solve_spiral_worked_example.py | 196 ++++++++
10 files changed, 1035 insertions(+), 127 deletions(-)
create mode 100644 src/ifcopenshell-python/ifcopenshell/api/alignment/solve_horizontal_alignment_by_pi_method.py
create mode 100644 src/ifcopenshell-python/test/api/alignment/test_solve_horizontal_alignment_by_pi_method.py
create mode 100644 src/ifcopenshell-python/test/api/alignment/test_solve_spiral_worked_example.py
diff --git a/src/bonsai/bonsai/bim/module/alignment/REQUIREMENTS.md b/src/bonsai/bonsai/bim/module/alignment/REQUIREMENTS.md
index 7632e71791..d35623c16f 100644
--- a/src/bonsai/bonsai/bim/module/alignment/REQUIREMENTS.md
+++ b/src/bonsai/bonsai/bim/module/alignment/REQUIREMENTS.md
@@ -42,14 +42,33 @@ improvements:
command.
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.
+ **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:
- **Circular curve**: radius only.
- - **Spiral curve**: spiral type (Clothoid, Bloss, Cosine, Helmert, etc.) and spiral length.
- This assumes all spirals have infinite start/end radius and share the circular arc's radius.
+ - **Spiral curve**: spiral length(s) (entry, exit, or both). **Implemented for the clothoid
+ 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
- circular arcs of different radius (Spiral-Circular-Spiral-Circular-Spiral). No UI is proposed
- for this yet — it may require selecting 2 PIs and defining all parameters together.
+ **Confirmed future requirement**: compound curves (PCC, point of compound curvature — two
+ arcs curving the same direction) and reverse curves (PRC, point of reverse curvature — two
+ 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.
## 3. Interrogating an alignment
diff --git a/src/bonsai/bonsai/bim/module/alignment/operator.py b/src/bonsai/bonsai/bim/module/alignment/operator.py
index fabda53fd5..6847a98a13 100644
--- a/src/bonsai/bonsai/bim/module/alignment/operator.py
+++ b/src/bonsai/bonsai/bim/module/alignment/operator.py
@@ -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
the alignment from them.
- Uses layout_horizontal_alignment_by_pi_method for now (circular curves
- only). Once spiral segments are needed this has to become genuinely
- one-segment-at-a-time authoring via create_layout_segment(), since that
- API only ever emits LINE/CIRCULARARC — see REQUIREMENTS.md §2 step 7.
+ Uses layout_horizontal_alignment_by_pi_method, which now also accepts
+ (radius, entry_length, exit_length) tuples for clothoid spiral-circular,
+ circular-spiral, and spiral-circular-spiral PIs alongside plain-radius
+ circular curves — see solve_horizontal_alignment_by_pi_method.
"""
ifc = tool.Ifc.get()
@@ -638,7 +638,7 @@ def _generate_alignment_segments(context, alignment, hpoints, radii):
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)"
@@ -707,6 +707,43 @@ def _is_interior_pi_marker(obj) -> bool:
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):
"""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]
+ [end]
)
- radii = [
- (m.bonsai_pi_curve_marker.radius if m.bonsai_pi_curve_marker.curve_type == "CIRCULAR" else 0.0)
- for m in interior_markers
- ]
+ radii = [_pi_curve_radii_entry(m.bonsai_pi_curve_marker) for m in interior_markers]
ok, message = _generate_alignment_segments(context, alignment, hpoints, radii)
marker = marker_obj.bonsai_pi_curve_marker
- marker_obj.name = (
- f"PI {marker.pi_index} (R={marker.radius:.2f})"
- if marker.curve_type == "CIRCULAR"
- else f"PI {marker.pi_index} (tangent)"
- )
+ marker_obj.name = f"PI {marker.pi_index} ({_pi_curve_marker_label(marker)})"
# _generate_alignment_segments() replaces every IfcAlignmentSegment
# with a new one, so a previously-highlighted segment's id is gone —
# 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
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
- 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
rest of Bonsai's polyline tools.
diff --git a/src/bonsai/bonsai/bim/module/alignment/prop.py b/src/bonsai/bonsai/bim/module/alignment/prop.py
index 6c7772b045..6085bb972d 100644
--- a/src/bonsai/bonsai/bim/module/alignment/prop.py
+++ b/src/bonsai/bonsai/bim/module/alignment/prop.py
@@ -286,11 +286,40 @@ class PICurveMarkerProperties(PropertyGroup):
items=[
("TANGENT", "None (sharp PI)", "No curve — the two tangents meet directly"),
("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
- # segment authored individually (create_layout_segment), which
- # layout_horizontal_alignment_by_pi_method does not support.
+ (
+ "SPIRAL_CIRCULAR",
+ "Spiral-Circular",
+ "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",
)
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",
+ )
diff --git a/src/bonsai/bonsai/bim/module/alignment/ui.py b/src/bonsai/bonsai/bim/module/alignment/ui.py
index 44fbd70431..67558c2a70 100644
--- a/src/bonsai/bonsai/bim/module/alignment/ui.py
+++ b/src/bonsai/bonsai/bim/module/alignment/ui.py
@@ -219,8 +219,12 @@ class ALIGN_PT_alignment_authoring(Panel):
pi_data = marker.bonsai_pi_curve_marker
box.label(text=f"PI {pi_data.pi_index}", icon="EMPTY_AXIS")
box.prop(pi_data, "curve_type")
- if pi_data.curve_type == "CIRCULAR":
+ if pi_data.curve_type != "TANGENT":
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")
else:
box.label(text="Select a PI marker to define its curve", icon="INFO")
diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/__init__.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/__init__.py
index 98eeaa104b..53f1b61338 100644
--- a/src/ifcopenshell-python/ifcopenshell/api/alignment/__init__.py
+++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/__init__.py
@@ -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.
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.
3. Automatic creation of geometric definitions (IfcCompositeCurve, IfcGradientCurve, IfcSegmentedReferenceCurve)
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
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
3. Updating vertical curve definitions by revising horizontal length of curves
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 .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_end_point import update_end_point
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 *
__all__ = [
+ "HorizontalSegmentDefinition",
+ "compute_clothoid_end",
+ "compute_horizontal_segment_end",
+ "solve_horizontal_alignment_by_pi_method",
"add_positioning_referent",
"add_stationing_referent",
"add_vertical_layout",
diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/create_by_pi_method.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/create_by_pi_method.py
index 008cf34f60..11d122d29d 100644
--- a/src/ifcopenshell-python/ifcopenshell/api/alignment/create_by_pi_method.py
+++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/create_by_pi_method.py
@@ -17,7 +17,7 @@
# along with IfcOpenShell. If not, see .
from collections.abc import Sequence
-from typing import Optional
+from typing import Optional, Union
import ifcopenshell
import ifcopenshell.api.alignment
@@ -29,7 +29,7 @@ def create_by_pi_method(
file: ifcopenshell.file,
name: str,
hpoints: Sequence[Sequence[float]],
- radii: Sequence[float],
+ radii: Sequence[Union[float, Sequence[float]]],
vpoints: Sequence[Sequence[float]] = None,
lengths: Sequence[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.
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 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 lengths: parabolic vertical curve horizontal length values to use for transition
:param start_station: if given, the starting station value. A STATION IfcReferent named
diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/layout_horizontal_alignment_by_pi_method.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/layout_horizontal_alignment_by_pi_method.py
index 676e03e429..52791599a1 100644
--- a/src/ifcopenshell-python/ifcopenshell/api/alignment/layout_horizontal_alignment_by_pi_method.py
+++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/layout_horizontal_alignment_by_pi_method.py
@@ -16,128 +16,104 @@
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see .
-import math
from collections.abc import Sequence
+from typing import Optional, Union
import ifcopenshell
import ifcopenshell.api.alignment
import ifcopenshell.util.unit
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(
- 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:
"""
Appends IfcAlignmentHorizontalSegment to a previously defined IfcAlignmentHorizontal using the PI layout method.
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 layout: An IfcAlignmentHorizontal layout
: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
"""
- if not (len(hpoints) - 2 == len(radii)):
- raise ValueError("radii should have two fewer elements that hpoints")
+ if (cant_layout is None) != (cants is None):
+ raise ValueError("cant_layout and cants must be provided together")
angle_unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file, "PLANEANGLEUNIT")
- xBT, yBT = hpoints[0]
- xPI, yPI = hpoints[1]
-
- i = 1
-
- 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))
-
+ for segment in solve_horizontal_alignment_by_pi_method(hpoints, radii, cants):
+ if cant_layout is not None:
+ _create_cant_segment(file, cant_layout, segment)
+ start_point = file.createIfcCartesianPoint(
+ Coordinates=segment.start_point,
+ )
design_parameters = file.createIfcAlignmentHorizontalSegment(
StartTag=None,
EndTag=None,
- StartPoint=pt,
- StartDirection=angleBT / angle_unit_scale,
- StartRadiusOfCurvature=0.0,
- EndRadiusOfCurvature=0.0,
- SegmentLength=tangent_run,
+ StartPoint=start_point,
+ StartDirection=segment.start_direction / angle_unit_scale,
+ StartRadiusOfCurvature=segment.start_radius_of_curvature,
+ EndRadiusOfCurvature=segment.end_radius_of_curvature,
+ SegmentLength=segment.segment_length,
GravityCenterLineHeight=None,
- PredefinedType="LINE",
+ PredefinedType=segment.predefined_type,
)
ifcopenshell.api.alignment.create_layout_segment(file, layout, design_parameters)
diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/solve_horizontal_alignment_by_pi_method.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/solve_horizontal_alignment_by_pi_method.py
new file mode 100644
index 0000000000..7ce10714b2
--- /dev/null
+++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/solve_horizontal_alignment_by_pi_method.py
@@ -0,0 +1,429 @@
+# IfcOpenShell - IFC toolkit and geometry engine
+# Copyright (C) 2025 Thomas Krijnen
+#
+# 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 .
+
+# 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
diff --git a/src/ifcopenshell-python/test/api/alignment/test_solve_horizontal_alignment_by_pi_method.py b/src/ifcopenshell-python/test/api/alignment/test_solve_horizontal_alignment_by_pi_method.py
new file mode 100644
index 0000000000..d57cecf973
--- /dev/null
+++ b/src/ifcopenshell-python/test/api/alignment/test_solve_horizontal_alignment_by_pi_method.py
@@ -0,0 +1,204 @@
+# IfcOpenShell - IFC toolkit and geometry engine
+# Copyright (C) 2025 Thomas Krijnen
+#
+# 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 .
+
+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] == []
diff --git a/src/ifcopenshell-python/test/api/alignment/test_solve_spiral_worked_example.py b/src/ifcopenshell-python/test/api/alignment/test_solve_spiral_worked_example.py
new file mode 100644
index 0000000000..934c5d14be
--- /dev/null
+++ b/src/ifcopenshell-python/test/api/alignment/test_solve_spiral_worked_example.py
@@ -0,0 +1,196 @@
+# IfcOpenShell - IFC toolkit and geometry engine
+# Copyright (C) 2025 Thomas Krijnen
+#
+# 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 .
+
+"""
+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