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()