alignment api: read spiral transition curves and cant from PI method CSV

Thin CSV front end for the spiral and cant support in the PI method
core. The import can now start with an optional header row naming the
values that define each horizontal PI. The number of header columns
selects the format: X,Y,R (the existing format, also used when there is
no header), X,Y,R,Lin,Lout for spiral transition curves, and
X,Y,R,Lin,Lout,E for spiral transition curves with cant. The parser only
groups columns and forwards them to create() and
layout_horizontal_alignment_by_pi_method(); all geometry lives in the
core, so this surface stays small and remains removable without
affecting API users, per the maintainer guidance that the CSV import is
a stop-gap.

Files in the existing format import unchanged; the output is
byte-identical apart from GUIDs. Pure column-count auto-detection would
be ambiguous (15 values parse as 5 legacy PIs or 3 spiral PIs), so an
explicit header is required for the new groups, and no existing file can
collide with one because row 1 of a headerless file must parse as
numbers. Since cant requires horizontal, vertical, and cant layouts, at
least one vertical row is required when the cant format is used.
create_from_csv() accepts an optional rail_head_distance that is
assigned to IfcAlignmentCant.RailHeadDistance.

Fixes #6890

Generated with the assistance of an AI coding tool.
This commit is contained in:
Petru Conduraru
2026-07-21 17:49:51 +03:00
parent 1a81292eb4
commit c36fe0a446
2 changed files with 359 additions and 28 deletions
@@ -17,23 +17,80 @@
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>. # along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import csv import csv
from typing import Optional
import ifcopenshell import ifcopenshell
import ifcopenshell.api.alignment import ifcopenshell.api.alignment
from ifcopenshell import entity_instance from ifcopenshell import entity_instance
# number of values per horizontal PI for each supported format
_horizontal_group_sizes = (3, 5, 6)
def create_from_csv(file: ifcopenshell.file, filepath: str) -> entity_instance:
def _is_number(value: str) -> bool:
try:
float(value)
return True
except ValueError:
return False
def _parse_horizontal_row(data: list[float], group_size: int):
"""
Parses the horizontal row into PI coordinates, radii (optionally with spiral transition
lengths), and cants. The curve values of the first and last PI are placeholders and are
discarded.
"""
if len(data) % group_size != 0 or len(data) // group_size < 2:
raise ValueError(
f"expected the horizontal row to have {group_size} values per PI for at least two PIs, "
f"instead received {len(data)} values"
)
groups = [data[k : k + group_size] for k in range(0, len(data), group_size)]
coordinates = [(g[0], g[1]) for g in groups]
cants: Optional[list[float]] = None
if group_size == 3:
radii = [g[2] for g in groups[1:-1]]
elif group_size == 5:
radii = [(g[2], g[3], g[4]) for g in groups[1:-1]]
else:
radii = [(g[2], g[3], g[4]) for g in groups[1:-1]]
cants = [g[5] for g in groups[1:-1]]
return coordinates, radii, cants
def _parse_vertical_row(data: list[float]):
"""
Parses a vertical row into VPI coordinates and vertical curve lengths. The length values of the
first and last VPI are placeholders and are discarded.
"""
if len(data) % 3 != 0 or len(data) // 3 < 2:
raise ValueError(
f"expected a vertical row to have 3 values per VPI for at least two VPIs, "
f"instead received {len(data)} values"
)
groups = [data[k : k + 3] for k in range(0, len(data), 3)]
coordinates = [(g[0], g[1]) for g in groups]
lengths = [g[2] for g in groups[1:-1]]
return coordinates, lengths
def create_from_csv(file: ifcopenshell.file, filepath: str, rail_head_distance: float = 1.0) -> entity_instance:
""" """
Creates an alignment from PI data stored in a CSV file. Creates an alignment from PI data stored in a CSV file.
The format of the file is: The format of the file is:
X1,Y1,R1,X2,Y2,R2 ... Xn-1,Yn-1,Rn-1,Xn,Yn X1,Y1,R1,X2,Y2,R2 ... Xn,Yn,Rn
D1,Z1,L1,D2,Z2,L2 ... Dn-1,Zn-1,Ln-1,Dn,Zn D1,Z1,L1,D2,Z2,L2 ... Dn,Zn,Ln
D1,Z1,L1,D2,Z2,L2 ... Dn-1,Zn-1,Ln-1,Dn,Zn D1,Z1,L1,D2,Z2,L2 ... Dn,Zn,Ln
... ...
@@ -48,56 +105,96 @@ def create_from_csv(file: ifcopenshell.file, filepath: str) -> entity_instance:
R1 and Rn, as well as L1 and Ln are placeholders and not used. They are recommended to have values of 0.0. R1 and Rn, as well as L1 and Ln are placeholders and not used. They are recommended to have values of 0.0.
R2 and Rn-2 are the radii of the first and last horizontal curves.
L2 and Ln-2 are the length of the first and last vertical curves.
The CSV file contains one horizontal alignment, zero, one, or more vertical alignments The CSV file contains one horizontal alignment, zero, one, or more vertical alignments
Optionally, the file can begin with a header row naming the values that define each horizontal
PI. The number of header columns sets the format of the horizontal row:
X,Y,R - PI coordinates and circular curve radius (the default format described above)
X,Y,R,Lin,Lout - adds clothoid spiral transition curves of length Lin ahead of the circular
curve and Lout following the circular curve. Use 0.0 for a spiral-less connection.
X,Y,R,Lin,Lout,E - adds a cant profile. E is the cant of the curve, in the project length
unit, applied to the rail on the outside of the curve. The cant varies linearly over the
spiral transition curves, is constant over the circular curve, and is zero on tangent runs.
Curves with a non-zero cant require non-zero Lin and Lout so the cant profile is continuous.
As with R, the Lin, Lout, and E values of the first and last PI are placeholders and are
recommended to have values of 0.0. Vertical rows always have 3 values per VPI. A cant layout is
created only for the X,Y,R,Lin,Lout,E format, and at least one vertical alignment row is
required in that case.
:param filepath: path the to CSV file :param filepath: path the to CSV file
:param rail_head_distance: value assigned to IfcAlignmentCant.RailHeadDistance when a cant layout is created
:return: IfcAlignment :return: IfcAlignment
""" """
alignment = None alignment = None
group_size = 3
include_cant = False
vertical_count = 0
with open(filepath, newline="") as csvfile: with open(filepath, newline="") as csvfile:
reader = csv.reader(csvfile) reader = csv.reader(csvfile)
row_count = 0 row_count = 0
for row in reader: for row in reader:
if row_count == 0 and len(row) and not _is_number(row[0]):
# the first row is a header row naming the horizontal PI values
header = [column for column in row if column.strip()]
if len(header) not in _horizontal_group_sizes:
raise ValueError(
f"expected the header row to have {_horizontal_group_sizes} columns, "
f"instead received {len(header)} columns"
)
group_size = len(header)
continue
data = list(map(float, row)) # Convert all values to float data = list(map(float, row)) # Convert all values to float
coordinates: list[list[float]] = (
[]
) # horizontal coordinates for first row, vertical coordinates for subsequent rows
radii: list[float] = [] # horizontal curve radii for first row, vertical curve length for subsequent rows
row_count += 1 row_count += 1
i = 0
while i < len(data):
if i + 1 < len(data):
x, y = float(data[i]), float(data[i + 1])
coordinates.append((x, y)) # Store (X, Y) pair
i += 2
if i < len(data) and (i + 1) % 3 == 0: # Every third element after an (X,Y) pair is R
radii.append(data[i])
i += 1
radii = radii[1:-1] # The first radius value is a placeholder, remove it
if row_count == 1: if row_count == 1:
alignment = ifcopenshell.api.alignment.create(file, "Alignment_from_CSV") coordinates, radii, cants = _parse_horizontal_row(data, group_size)
include_cant = cants is not None
if include_cant:
# cant requires horizontal, vertical, and cant layouts. the vertical layout is
# populated by the first vertical row.
alignment = ifcopenshell.api.alignment.create(
file,
"Alignment_from_CSV",
include_vertical=True,
include_cant=True,
rail_head_distance=rail_head_distance,
)
cant_layout = ifcopenshell.api.alignment.get_cant_layout(alignment)
else:
alignment = ifcopenshell.api.alignment.create(file, "Alignment_from_CSV")
cant_layout = None
horizontal_layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment) horizontal_layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
ifcopenshell.api.alignment.layout_horizontal_alignment_by_pi_method( ifcopenshell.api.alignment.layout_horizontal_alignment_by_pi_method(
file, horizontal_layout, coordinates, radii file, horizontal_layout, coordinates, radii, cant_layout=cant_layout, cants=cants
) )
else: else:
# add all subsequent vertical alignments # add all subsequent vertical alignments
assert alignment is not None assert alignment is not None
vertical_layout = ifcopenshell.api.alignment.add_vertical_layout(file, alignment) coordinates, lengths = _parse_vertical_row(data)
vertical_count += 1
if include_cant and vertical_count == 1:
# the vertical layout was created along with the cant layout
vertical_layout = ifcopenshell.api.alignment.get_vertical_layout(alignment)
else:
vertical_layout = ifcopenshell.api.alignment.add_vertical_layout(file, alignment)
ifcopenshell.api.alignment.layout_vertical_alignment_by_pi_method( ifcopenshell.api.alignment.layout_vertical_alignment_by_pi_method(
file, vertical_layout, coordinates, radii file, vertical_layout, coordinates, lengths
) )
if row_count == 0: if row_count == 0:
raise ValueError(f"CSV file '{filepath}' is empty; expected at least one row for the horizontal alignment.") raise ValueError(f"CSV file '{filepath}' is empty; expected at least one row for the horizontal alignment.")
if include_cant and vertical_count == 0:
raise ValueError(
f"CSV file '{filepath}' has a cant profile but no vertical alignment; "
"at least one vertical alignment row is required with cant."
)
assert alignment is not None assert alignment is not None
return alignment return alignment
@@ -0,0 +1,234 @@
# 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/>.
# This file was generated with the assistance of an AI coding tool.
import os
import tempfile
import pytest
import ifcopenshell.api.alignment
import ifcopenshell.api.context
import ifcopenshell.api.unit
def _create_file():
file = ifcopenshell.file(schema="IFC4X3")
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 _write_csv(content: str) -> str:
handle, filepath = tempfile.mkstemp(suffix=".csv", text=True)
with os.fdopen(handle, "w") as f:
f.write(content)
return filepath
def _get_horizontal_segments(alignment):
horizontal_layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
segment_nest = ifcopenshell.api.alignment.get_alignment_segment_nest(horizontal_layout)
return segment_nest.RelatedObjects
def test_create_from_csv():
file = _create_file()
filepath = _write_csv(
"500,2500,0,3340,660,1000,4340,5000,1250,7600,4560,950,8480,2010,0\n"
"0,100,0,2000,135,1600,5000,105,1200,9800,105,0\n"
)
alignment = ifcopenshell.api.alignment.create_from_csv(file, filepath)
os.remove(filepath)
assert alignment.Name == "Alignment_from_CSV"
segments = _get_horizontal_segments(alignment)
assert len(segments) == 8 # 4 tangent runs + 3 circular curves + zero length segment
expected_types = ["LINE", "CIRCULARARC", "LINE", "CIRCULARARC", "LINE", "CIRCULARARC", "LINE", "LINE"]
assert [s.DesignParameters.PredefinedType for s in segments] == expected_types
# the CSV result matches the result of create_by_pi_method with the same PI data
file2 = _create_file()
alignment2 = ifcopenshell.api.alignment.create_by_pi_method(
file2,
"TestAlignment",
[(500.0, 2500.0), (3340.0, 660.0), (4340.0, 5000.0), (7600.0, 4560.0), (8480.0, 2010.0)],
[(1000.0), (1250.0), (950.0)],
[(0.0, 100.0), (2000.0, 135.0), (5000.0, 105.0), (9800.0, 105.0)],
[(1600.0), (1200.0)],
)
segments2 = _get_horizontal_segments(alignment2)
assert len(segments) == len(segments2)
for s1, s2 in zip(segments, segments2):
d1 = s1.DesignParameters
d2 = s2.DesignParameters
assert d1.PredefinedType == d2.PredefinedType
assert d1.StartPoint.Coordinates == pytest.approx(d2.StartPoint.Coordinates)
assert d1.StartDirection == pytest.approx(d2.StartDirection)
assert d1.StartRadiusOfCurvature == pytest.approx(d2.StartRadiusOfCurvature)
assert d1.EndRadiusOfCurvature == pytest.approx(d2.EndRadiusOfCurvature)
assert d1.SegmentLength == pytest.approx(d2.SegmentLength)
def test_create_from_csv_spiral_transitions():
file = _create_file()
filepath = _write_csv(
"X,Y,R,Lin,Lout\n"
"500,2500,0,0,0,3340,660,1000,200,150,4340,5000,1250,180,180,7600,4560,950,0,120,8480,2010,0,0,0\n"
"0,100,0,2000,135,1600,5000,105,1200,9800,105,0\n"
)
alignment = ifcopenshell.api.alignment.create_from_csv(file, filepath)
os.remove(filepath)
segments = _get_horizontal_segments(alignment)
expected_types = [
"LINE",
"CLOTHOID",
"CIRCULARARC",
"CLOTHOID",
"LINE",
"CLOTHOID",
"CIRCULARARC",
"CLOTHOID",
"LINE",
"CIRCULARARC",
"CLOTHOID",
"LINE",
"LINE", # zero length segment
]
assert [s.DesignParameters.PredefinedType for s in segments] == expected_types
# spirals run from zero curvature to the curve radius and vice versa
entry_spiral = segments[1].DesignParameters
assert entry_spiral.StartRadiusOfCurvature == 0.0
assert entry_spiral.EndRadiusOfCurvature == pytest.approx(1000.0) # positive, curve to the left
assert entry_spiral.SegmentLength == pytest.approx(200.0)
exit_spiral = segments[3].DesignParameters
assert exit_spiral.StartRadiusOfCurvature == pytest.approx(1000.0)
assert exit_spiral.EndRadiusOfCurvature == 0.0
assert exit_spiral.SegmentLength == pytest.approx(150.0)
assert segments[5].DesignParameters.EndRadiusOfCurvature == pytest.approx(-1250.0) # curve to the right
# the CSV result matches the pure PI method solution for the same PI data
# (geometric continuity of the solution is covered by test_solve_horizontal_alignment_by_pi_method)
solved = ifcopenshell.api.alignment.solve_horizontal_alignment_by_pi_method(
[(500.0, 2500.0), (3340.0, 660.0), (4340.0, 5000.0), (7600.0, 4560.0), (8480.0, 2010.0)],
[(1000.0, 200.0, 150.0), (1250.0, 180.0, 180.0), (950.0, 0.0, 120.0)],
)
assert len(solved) == len(segments) - 1 # the file has an additional zero length segment
for definition, segment in zip(solved, segments):
d = segment.DesignParameters
assert d.PredefinedType == definition.predefined_type
assert d.StartPoint.Coordinates == pytest.approx(definition.start_point)
assert d.StartDirection == pytest.approx(definition.start_direction)
assert d.StartRadiusOfCurvature == pytest.approx(definition.start_radius_of_curvature)
assert d.EndRadiusOfCurvature == pytest.approx(definition.end_radius_of_curvature)
assert d.SegmentLength == pytest.approx(definition.segment_length)
def test_create_from_csv_cant():
file = _create_file()
filepath = _write_csv(
"X,Y,R,Lin,Lout,E\n"
"500,2500,0,0,0,0,3340,660,1000,200,150,0.15,4340,5000,1250,180,180,0.12,7600,4560,950,140,120,0.1,8480,2010,0,0,0,0\n"
"0,100,0,2000,135,1600,5000,105,1200,9800,105,0\n"
)
alignment = ifcopenshell.api.alignment.create_from_csv(file, filepath, rail_head_distance=1.5)
os.remove(filepath)
cant_layout = ifcopenshell.api.alignment.get_cant_layout(alignment)
assert cant_layout is not None
assert cant_layout.RailHeadDistance == pytest.approx(1.5)
vertical_layout = ifcopenshell.api.alignment.get_vertical_layout(alignment)
assert vertical_layout is not None
horizontal_segments = _get_horizontal_segments(alignment)
cant_segment_nest = ifcopenshell.api.alignment.get_alignment_segment_nest(cant_layout)
cant_segments = cant_segment_nest.RelatedObjects
# cant segments correspond one-for-one with the horizontal segments
assert len(cant_segments) == len(horizontal_segments)
dist_along = 0.0
for horizontal_segment, cant_segment in zip(horizontal_segments[:-1], cant_segments[:-1]):
h = horizontal_segment.DesignParameters
c = cant_segment.DesignParameters
assert c.StartDistAlong == pytest.approx(dist_along)
assert c.HorizontalLength == pytest.approx(h.SegmentLength)
if h.PredefinedType == "CLOTHOID":
assert c.PredefinedType == "LINEARTRANSITION"
else:
assert c.PredefinedType == "CONSTANTCANT"
dist_along += h.SegmentLength
# the first curve is to the left, so the cant is applied to the right rail
entry_cant = cant_segments[1].DesignParameters
assert entry_cant.StartCantLeft == pytest.approx(0.0)
assert entry_cant.EndCantLeft == pytest.approx(0.0)
assert entry_cant.StartCantRight == pytest.approx(0.0)
assert entry_cant.EndCantRight == pytest.approx(0.15)
curve_cant = cant_segments[2].DesignParameters
assert curve_cant.StartCantRight == pytest.approx(0.15)
# the second curve is to the right, so the cant is applied to the left rail
assert cant_segments[6].DesignParameters.StartCantLeft == pytest.approx(0.12)
assert cant_segments[6].DesignParameters.StartCantRight == pytest.approx(0.0)
def test_create_from_csv_cant_requires_transitions():
file = _create_file()
filepath = _write_csv(
"X,Y,R,Lin,Lout,E\n"
"500,2500,0,0,0,0,3340,660,1000,0,0,0.15,4340,5000,0,0,0,0\n"
"0,100,0,2000,135,1600,5000,105,0\n"
)
with pytest.raises(ValueError):
ifcopenshell.api.alignment.create_from_csv(file, filepath)
os.remove(filepath)
def test_create_from_csv_cant_requires_vertical():
file = _create_file()
filepath = _write_csv("X,Y,R,Lin,Lout,E\n" "500,2500,0,0,0,0,3340,660,1000,200,150,0.15,4340,5000,0,0,0,0\n")
with pytest.raises(ValueError):
ifcopenshell.api.alignment.create_from_csv(file, filepath)
os.remove(filepath)
test_create_from_csv()
test_create_from_csv_spiral_transitions()
test_create_from_csv_cant()
test_create_from_csv_cant_requires_transitions()
test_create_from_csv_cant_requires_vertical()