diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/create_from_csv.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/create_from_csv.py
index f092b273ba..073661b366 100644
--- a/src/ifcopenshell-python/ifcopenshell/api/alignment/create_from_csv.py
+++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/create_from_csv.py
@@ -17,23 +17,80 @@
# along with IfcOpenShell. If not, see .
import csv
+from typing import Optional
import ifcopenshell
import ifcopenshell.api.alignment
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.
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.
- 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
+ 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 rail_head_distance: value assigned to IfcAlignmentCant.RailHeadDistance when a cant layout is created
:return: IfcAlignment
"""
alignment = None
+ group_size = 3
+ include_cant = False
+ vertical_count = 0
with open(filepath, newline="") as csvfile:
reader = csv.reader(csvfile)
row_count = 0
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
- 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
- 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:
- 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)
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:
# add all subsequent vertical alignments
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(
- file, vertical_layout, coordinates, radii
+ file, vertical_layout, coordinates, lengths
)
if row_count == 0:
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
return alignment
diff --git a/src/ifcopenshell-python/test/api/alignment/test_create_from_csv.py b/src/ifcopenshell-python/test/api/alignment/test_create_from_csv.py
new file mode 100644
index 0000000000..1443a26f2a
--- /dev/null
+++ b/src/ifcopenshell-python/test/api/alignment/test_create_from_csv.py
@@ -0,0 +1,234 @@
+# 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 .
+
+# 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()