From b5670c4fc5347ec5c2c621f3f53a1a737bd21d2b Mon Sep 17 00:00:00 2001 From: Richard Brice <37087370+RickBrice@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:34:21 -0700 Subject: [PATCH] Reverts from automatically adding stationing to alignments because of missing initial geometry. Adds support for stationing with decreasing values. --- .../bonsai/bim/module/alignment/operator.py | 2 +- .../ifcopenshell/api/alignment/__init__.py | 10 +- .../api/alignment/_referent_distance_along.py | 37 ++++ .../api/alignment/add_stationing_referent.py | 25 ++- .../ifcopenshell/api/alignment/create.py | 15 +- .../api/alignment/create_as_polyline.py | 19 ++- .../api/alignment/create_by_pi_method.py | 15 +- .../api/alignment/create_from_csv.py | 13 +- .../alignment/distance_along_from_station.py | 70 ++++---- .../test_add_positioning_referent.py | 8 +- .../alignment/test_add_segment_to_layout.py | 7 +- .../alignment/test_add_stationing_referent.py | 42 ++++- .../test_add_stationing_to_alignment.py | 9 +- .../alignment/test_add_vertical_alignment.py | 5 +- .../test/api/alignment/test_create.py | 42 +---- .../api/alignment/test_create_by_pi_method.py | 6 +- .../alignment/test_create_representation.py | 16 +- .../test_distance_along_from_station.py | 160 +++++++++++++++++- .../test_horizontal_layout_by_pi_method.py | 8 +- .../test_update_key_point_referents.py | 10 +- .../test_vertical_layout_by_pi_method.py | 6 +- 21 files changed, 394 insertions(+), 131 deletions(-) create mode 100644 src/ifcopenshell-python/ifcopenshell/api/alignment/_referent_distance_along.py diff --git a/src/bonsai/bonsai/bim/module/alignment/operator.py b/src/bonsai/bonsai/bim/module/alignment/operator.py index 42ad1d25de..3f0c7c2119 100644 --- a/src/bonsai/bonsai/bim/module/alignment/operator.py +++ b/src/bonsai/bonsai/bim/module/alignment/operator.py @@ -51,7 +51,7 @@ class ImportAlignmentCSV(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): def _execute(self, context): self.file = tool.Ifc.get() start = time.time() - alignment = ifcopenshell.api.alignment.create_from_csv(self.file, self.filepath) + alignment = ifcopenshell.api.alignment.create_from_csv(self.file, self.filepath, start_station=0.0) # IFC 4.1.5.1 alignments cannot be contained in spatial structures, but can be referenced into them sites = self.file.by_type("IfcSite") diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/__init__.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/__init__.py index c0b24d9113..5d4a370f8e 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/alignment/__init__.py +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/__init__.py @@ -23,9 +23,11 @@ This API is defined in terms of the semantic definition of an alignment. The cor is created and maintained automatically. The manditory zero length segment for the semantic and geometric definitions are automatically created and maintained. -Alignments are created with stationing referents. Each layout segment is assigned a position referent that informs about -the start point of the segment. An example is the point of curvature of a horizontal circular curve. The referent is -nested to the segment representing the circular arc and is named with the alignment name and an indicator of the position and the station, e.g. "MyAlignment 145+98.32 (P.C.)" +Stationing is defined explicitly by calling add_stationing_referent() after the layout segments (and therefore the +basis curve geometry) exist - create() does not add one. update_key_point_referents() assigns each layout segment a +position referent that informs about the start point of the segment. An example is the point of curvature of a +horizontal circular curve. The referent is nested to the segment representing the circular arc and is named with the +alignment name and an indicator of the position and the station, e.g. "MyAlignment 145+98.32 (P.C.)" This API does not determine alignment parameters based on rules, such as minimum curve radius as a function of design speed or sight distance. @@ -35,7 +37,7 @@ Presently, this API supports: 1. Creating alignments, both horizontal and vertical, using the PI method. 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. Automatic definition of stationing + 4. Explicit definition of stationing, including station equations and reverse (decreasing) stationing 5. Automatic definition of alignment transition point referents 6. Utility functions for printing business logical and geometric representations, as well as minimal geometry evaluations diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/_referent_distance_along.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/_referent_distance_along.py new file mode 100644 index 0000000000..fe46b5e24c --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/_referent_distance_along.py @@ -0,0 +1,37 @@ +# 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 . + +from ifcopenshell import entity_instance + + +def _referent_distance_along(referent: entity_instance) -> float: + """The distance along the basis curve at which a referent is placed. + + Read from IfcLinearPlacement.RelativePlacement.Location.DistanceAlong. An + IfcLocalPlacement fallback (semantic-only alignment, or a placement that could not + yet be expressed relative to a basis curve) carries no DistanceAlong; it is only ever + used for the starting referent, at distance 0.0 (the global origin). + + Referents nested under an alignment are ordered by increasing DistanceAlong (IFC CT + 4.1.4.4.3), which - for a reverse-stationed alignment - means by decreasing Station. + Sort on this, never on Pset_Stationing.Station. + """ + placement = referent.ObjectPlacement + if placement and placement.is_a("IfcLinearPlacement"): + return placement.RelativePlacement.Location.DistanceAlong.wrappedValue + return 0.0 diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/add_stationing_referent.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/add_stationing_referent.py index ad2ed4db03..37a2c0f603 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/alignment/add_stationing_referent.py +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/add_stationing_referent.py @@ -22,8 +22,8 @@ import ifcopenshell import ifcopenshell.api.alignment import ifcopenshell.api.pset import ifcopenshell.guid -import ifcopenshell.util.element from ifcopenshell import entity_instance +from ifcopenshell.api.alignment._referent_distance_along import _referent_distance_along from ifcopenshell.api.alignment._sort_nest import _sort_nest from ifcopenshell.api.alignment.update_fallback_position import update_fallback_position @@ -35,16 +35,28 @@ def add_stationing_referent( distance_along: float, station: float, incoming_station: Optional[float] = None, + has_increasing_station: Optional[bool] = None, on_basis_curve: Optional[bool] = None, ) -> entity_instance: """ Adds an IfcReferent to the alignment that defines the stationing system. + Call this once with ``distance_along=0.0`` to define the starting station, and again + for each station equation. If the alignment has no geometry yet, the referent is + placed with an IfcLocalPlacement at the global origin; once the basis curve has real + segments it is placed with an IfcLinearPlacement at ``distance_along`` on that curve. + create_representation() restates an origin-placed starting referent onto the curve + when geometry is added later. + :param name: name to assign to IfcReferent.Name, typically a stringized version of the station value :param alignment: the alignment to receive the referent :param distance_along: distance along the alignment basis curve :param station: station value :param incoming_station: station value of the incoming segment, only set to specify a station equation + :param has_increasing_station: sets Pset_Stationing.HasIncreasingStation, which records the direction of + stationing for the referents nested after this one. Leave None (the default) or pass True for the + common case where station values increase with distance along; pass False on the starting referent + of a reverse-stationed alignment, where station values decrease as distance along increases. :param on_basis_curve: whether the referent is positioned on the basis curve or the alignment curve, if None the function will default to the basis curve :return: referent @@ -82,10 +94,13 @@ def add_stationing_referent( update_fallback_position(file, object_placement) else: + # No resolvable basis curve yet: place the referent at the global origin. Once + # geometry exists, create_representation() restates the starting referent onto the + # curve at DistanceAlong 0.0. object_placement = file.createIfcLocalPlacement( PlacementRelTo=None, RelativePlacement=file.createIfcAxis2Placement2D( - Location=file.createIfcCartesianPoint(alignment.ObjectPlacement.RelativePlacement.Location.Coordinates) + Location=file.createIfcCartesianPoint(Coordinates=(0.0, 0.0)) ), ) @@ -111,6 +126,8 @@ def add_stationing_referent( properties = {"Station": station} if incoming_station is not None: properties["IncomingStation"] = incoming_station + if has_increasing_station is not None: + properties["HasIncreasingStation"] = has_increasing_station pset_stationing = ifcopenshell.api.pset.add_pset(file, product=referent, name="Pset_Stationing") ifcopenshell.api.pset.edit_pset(file, pset=pset_stationing, properties=properties) @@ -123,6 +140,8 @@ def add_stationing_referent( else: nest.RelatedObjects += (referent,) - _sort_nest(nest, key=lambda x: ifcopenshell.util.element.get_pset(x, name="Pset_Stationing", prop="Station")) + # Referents are ordered by increasing DistanceAlong (IFC CT 4.1.4.4.3), which for a + # reverse-stationed alignment is decreasing Station - so sort on DistanceAlong, not Station. + _sort_nest(nest, key=_referent_distance_along) return referent diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/create.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/create.py index 9cb23165e9..090ba726fb 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/alignment/create.py +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/create.py @@ -20,7 +20,6 @@ import ifcopenshell import ifcopenshell.api.aggregate import ifcopenshell.api.alignment import ifcopenshell.api.nest -import ifcopenshell.util.alignment from ifcopenshell import entity_instance from ifcopenshell.api.alignment._add_zero_length_segment import _add_zero_length_segment from ifcopenshell.api.alignment._create_geometric_representation import ( @@ -34,7 +33,6 @@ def create( include_vertical: bool = False, include_cant: bool = False, include_geometry: bool = True, - start_station: float = 0.0, ) -> entity_instance: """ Creates a new alignment with a horizontal layout. Optionally, vertical and cant layouts can be created as well. @@ -49,19 +47,15 @@ def create( The horizontal geometry in the Viennese Bend transition curves depends on the Viennese Bend cant parameters. create_layout_segment() automatically creates the geometric representation from the semantic definition. The horizontal segment geometric representation will fail if the cant segment is not defined. - If geometric representations are created, the alignment stationing referent is also created using the start_station value. IfcReferent.ObjectPlacement - is required for linear positiion elements and IfcLinearPlacement is defined relative to alignment curve geometry. - This referent's Name follows the same " " convention update_key_point_referents() uses - for its own key-point referents (e.g. "MyAlignment 49+00.00"), so that every referent nested under an alignment - is identifiable by name alone, without needing to inspect its Pset_Stationing or placement to know which - alignment it belongs to. + This function does not define the alignment's stationing. Call add_stationing_referent() once the layout + segments (and therefore the basis curve geometry) exist to place the starting-station IfcReferent, and again + for any station equations. Until stationing is defined, get_alignment_start_station() reports 0.0. :param file: :param name: name assigned to IfcAlignment.Name :param include_vertical: If True, IfcAlignmentVertical is created. IfcGradientCurve is created if include_geometry is True :param include_cant: If True, IfcAlignmentCant is created. IfcSegmentedReferenceCurve is created if include_geometry is True :param include_geometry: If True, the geometric representations are added - :param start_station: station value at the start of the alignment. :return: Returns an IfcAlignment """ alignment = file.createIfcAlignment( @@ -90,9 +84,6 @@ def create( if include_geometry: _create_geometric_representation(file, alignment) - referent_name = f"{name} {ifcopenshell.util.alignment.station_as_string(file, start_station)}" - referent = ifcopenshell.api.alignment.add_stationing_referent(file, referent_name, alignment, 0.0, start_station) - for layout in alignment_layouts: _add_zero_length_segment(file, layout) diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/create_as_polyline.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/create_as_polyline.py index a86d1d16bc..41823ab271 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/alignment/create_as_polyline.py +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/create_as_polyline.py @@ -18,6 +18,7 @@ import math from collections.abc import Sequence +from typing import Optional import ifcopenshell import ifcopenshell.api.aggregate @@ -123,22 +124,23 @@ def create_as_polyline( file: ifcopenshell.file, name: str, points: Sequence[entity_instance], - start_station: float = 0.0, + start_station: Optional[float] = None, ) -> entity_instance: """ Creates a new IfcAlignment with an IfcPolyline representation. The IfcAlignment is aggreated to IfcProject - The stationing referent created from start_station has Name " " - (e.g. "MyAlignment 49+00.00"), the same convention update_key_point_referents() and - create() use for their own referents, so every referent nested under an alignment is - identifiable by name alone. + If start_station is given, a STATION IfcReferent named " " + (e.g. "MyAlignment 49+00.00") is added at distance along 0.0 - the same naming + convention update_key_point_referents() uses for its own referents, so every referent + nested under an alignment is identifiable by name alone. If None (the default), no + stationing referent is created. :param file: :param name: name assigned to IfcAlignment.Name :param points: sequence of points defining the polyline - :param start_station: station value at the start of the alignment + :param start_station: station value at the start of the alignment, or None for no stationing referent :return: Returns an IfcAlignment """ alignment = file.createIfcAlignment( @@ -149,8 +151,9 @@ def create_as_polyline( _create_polyline_representation(file, alignment, points) # define stationing - referent_name = f"{alignment.Name} {ifcopenshell.util.alignment.station_as_string(file, start_station)}" - referent = ifcopenshell.api.alignment.add_stationing_referent(file, referent_name, alignment, 0.0, start_station) + if start_station is not None: + referent_name = f"{alignment.Name} {ifcopenshell.util.alignment.station_as_string(file, start_station)}" + ifcopenshell.api.alignment.add_stationing_referent(file, referent_name, alignment, 0.0, start_station) # IFC 4.1.4.1.1 Alignment Aggregation To Project project = file.by_type("IfcProject")[0] 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 e6ceba0572..008cf34f60 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,9 +17,11 @@ # along with IfcOpenShell. If not, see . from collections.abc import Sequence +from typing import Optional import ifcopenshell import ifcopenshell.api.alignment +import ifcopenshell.util.alignment from ifcopenshell import entity_instance @@ -30,7 +32,7 @@ def create_by_pi_method( radii: Sequence[float], vpoints: Sequence[Sequence[float]] = None, lengths: Sequence[float] = None, - start_station: float = 0.0, + start_station: Optional[float] = None, ) -> entity_instance: """ Create an alignment using the PI layout method for both horizontal and vertical alignments. @@ -41,16 +43,21 @@ def create_by_pi_method( :param radii: radii values to use for transition :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 + " " is added at distance along 0.0 once the geometry exists. If None + (the default), no stationing referent is created and get_alignment_start_station() reports 0.0. :return: Returns an IfcAlignment """ include_vertical = True if vpoints and lengths else False - alignment = ifcopenshell.api.alignment.create( - file, name, include_vertical=include_vertical, start_station=start_station - ) + alignment = ifcopenshell.api.alignment.create(file, name, include_vertical=include_vertical) horizontal_layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment) ifcopenshell.api.alignment.layout_horizontal_alignment_by_pi_method(file, horizontal_layout, hpoints, radii) if include_vertical: vertical_layout = ifcopenshell.api.alignment.get_vertical_layout(alignment) ifcopenshell.api.alignment.layout_vertical_alignment_by_pi_method(file, vertical_layout, vpoints, lengths) + if start_station is not None: + referent_name = f"{name} {ifcopenshell.util.alignment.station_as_string(file, start_station)}" + ifcopenshell.api.alignment.add_stationing_referent(file, referent_name, alignment, 0.0, start_station) + return alignment 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 6ab61909db..000d36704c 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/alignment/create_from_csv.py +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/create_from_csv.py @@ -17,13 +17,17 @@ # along with IfcOpenShell. If not, see . import csv +from typing import Optional import ifcopenshell import ifcopenshell.api.alignment +import ifcopenshell.util.alignment from ifcopenshell import entity_instance -def create_from_csv(file: ifcopenshell.file, filepath: str) -> entity_instance: +def create_from_csv( + file: ifcopenshell.file, filepath: str, start_station: Optional[float] = None +) -> entity_instance: """ Creates an alignment from PI data stored in a CSV file. @@ -55,6 +59,8 @@ def create_from_csv(file: ifcopenshell.file, filepath: str) -> entity_instance: The CSV file contains one horizontal alignment, zero, one, or more vertical alignments :param filepath: path the to CSV file + :param start_station: if given, the starting station value; a STATION IfcReferent is added at + distance along 0.0 once the geometry exists. If None (the default), no stationing referent is created. :return: IfcAlignment """ alignment = None @@ -97,4 +103,9 @@ def create_from_csv(file: ifcopenshell.file, filepath: str) -> entity_instance: ) assert alignment is not None + + if start_station is not None: + referent_name = f"{alignment.Name} {ifcopenshell.util.alignment.station_as_string(file, start_station)}" + ifcopenshell.api.alignment.add_stationing_referent(file, referent_name, alignment, 0.0, start_station) + return alignment diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/distance_along_from_station.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/distance_along_from_station.py index f719a12b0d..7af8413172 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/alignment/distance_along_from_station.py +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/distance_along_from_station.py @@ -22,16 +22,7 @@ import ifcopenshell import ifcopenshell.api.alignment import ifcopenshell.util.element from ifcopenshell import entity_instance - - -def _distance_along_of_referent(referent: entity_instance) -> float: - placement = referent.ObjectPlacement - if placement.is_a("IfcLinearPlacement"): - return placement.RelativePlacement.Location.DistanceAlong.wrappedValue - # IfcLocalPlacement fallback (e.g. semantic-only alignment, or the placement could not yet - # be expressed relative to a basis curve) carries no DistanceAlong; it is only ever used for - # the starting referent, at distance 0.0. - return 0.0 +from ifcopenshell.api.alignment._referent_distance_along import _referent_distance_along def distance_along_from_station(file: ifcopenshell.file, alignment: entity_instance, station: float) -> Optional[float]: @@ -41,18 +32,24 @@ def distance_along_from_station(file: ifcopenshell.file, alignment: entity_insta If the alignment does not have stationing defined with an IfcReferent, the start of the alignment is assumed to be at station 0.0. That is, the station is the distance along. - Station equations (where Pset_Stationing.IncomingStation is set on a referent) are taken into account. + Station equations (where Pset_Stationing.IncomingStation is set on a referent) and reverse + (decreasing) stationing (where Pset_Stationing.HasIncreasingStation is False) are taken into account. + For each STATION referent nested to the alignment, DistanceAlong (D) and the outgoing station (S, i.e. - Pset_Stationing.Station) are read off, sorted by DistanceAlong. The requested station is located within - the segment defined by the last referent whose outgoing station is less than or equal to it, and the - distance along is computed as D + (station - S) for that referent. + Pset_Stationing.Station) are read off and sorted by DistanceAlong. A direction sign is tracked while + walking the sorted referents: it starts at +1 and is set to +1 or -1 at any referent that carries an + explicit Pset_Stationing.HasIncreasingStation, so each referent's region has a sign sigma of +1 + (increasing) or -1 (decreasing). HasIncreasingStation may flip any number of times along the alignment - + unrealistic, but valid IFC, and handled. The governing referent is the last one, by DistanceAlong, for + which sigma * (station - S) >= 0, and the distance along is D + sigma * (station - S). - If the station falls within a gap introduced by a forward (gap) station equation - that is, it was skipped - over by the equation - there is no distance along that corresponds to it, and None is returned. + If the station falls within a gap introduced by a station equation - that is, it was skipped over by the + equation - there is no distance along that corresponds to it, and None is returned. - Note that an overlap (backward) station equation causes a range of stations to correspond to two distinct - distances along the alignment, one on either side of the equation. This implementation returns the distance - along in the segment following the equation (i.e. the outgoing side). + Note that an overlap station equation - or a HasIncreasingStation direction reversal, which creates an + equivalent overlap zone - causes a range of stations to correspond to two (or more) distinct distances + along the alignment. This implementation returns the most downstream one (largest DistanceAlong), i.e. + the match in the region following the last equation / reversal. :param alignment: the alignment :param station: station value @@ -72,31 +69,42 @@ def distance_along_from_station(file: ifcopenshell.file, alignment: entity_insta start_station = ifcopenshell.api.alignment.get_alignment_start_station(file, alignment) return station - start_station - stations = [ + referents = [ ( - _distance_along_of_referent(referent), + _referent_distance_along(referent), ifcopenshell.util.element.get_pset(referent, name="Pset_Stationing", prop="Station"), + ifcopenshell.util.element.get_pset(referent, name="Pset_Stationing", prop="HasIncreasingStation"), ) for referent in stationing_nest.RelatedObjects ] - stations.sort(key=lambda entry: entry[0]) + referents.sort(key=lambda entry: entry[0]) + + # Assign each referent's region a direction sign: +1 increasing, -1 decreasing. The sign + # starts increasing and flips at any referent carrying an explicit HasIncreasingStation. + sigma = 1.0 + stations = [] + for distance_along, outgoing_station, has_increasing_station in referents: + if has_increasing_station is not None: + sigma = 1.0 if has_increasing_station else -1.0 + stations.append((distance_along, outgoing_station, sigma)) index = None - for i, (distance_along, outgoing_station) in enumerate(stations): - if outgoing_station <= station: + for i, (distance_along, outgoing_station, region_sign) in enumerate(stations): + if region_sign * (station - outgoing_station) >= 0.0: index = i if index is None: # station precedes the alignment's starting station; extrapolate from the first referent - distance_along, outgoing_station = stations[0] - return distance_along + (station - outgoing_station) + distance_along, outgoing_station, region_sign = stations[0] + return distance_along + region_sign * (station - outgoing_station) - distance_along, outgoing_station = stations[index] + distance_along, outgoing_station, region_sign = stations[index] + advance = region_sign * (station - outgoing_station) if index + 1 < len(stations): - next_distance_along, _ = stations[index + 1] - if station - outgoing_station > next_distance_along - distance_along: - # the station was skipped over by a forward (gap) station equation + next_distance_along, _, _ = stations[index + 1] + if advance > next_distance_along - distance_along: + # the station was skipped over by a gap station equation return None - return distance_along + (station - outgoing_station) + return distance_along + advance diff --git a/src/ifcopenshell-python/test/api/alignment/test_add_positioning_referent.py b/src/ifcopenshell-python/test/api/alignment/test_add_positioning_referent.py index ca4c5b0836..76d9ee77ba 100644 --- a/src/ifcopenshell-python/test/api/alignment/test_add_positioning_referent.py +++ b/src/ifcopenshell-python/test/api/alignment/test_add_positioning_referent.py @@ -24,7 +24,7 @@ import ifcopenshell.util.element def test_add_positioning_referent(): - file = ifcopenshell.file(schema="IFC4X3") + 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]) @@ -37,7 +37,7 @@ def test_add_positioning_referent(): parent=geometric_representation_context, ) - alignment = ifcopenshell.api.alignment.create(file, "TestAlignment", start_station=2000.0) + alignment = ifcopenshell.api.alignment.create(file, "TestAlignment") horizontal_layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment) segment = ifcopenshell.api.alignment.get_layout_segments(horizontal_layout)[0] @@ -61,7 +61,7 @@ def test_add_positioning_referent(): def test_add_positioning_referent_creates_separate_referent_per_call(): - file = ifcopenshell.file(schema="IFC4X3") + 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]) @@ -74,7 +74,7 @@ def test_add_positioning_referent_creates_separate_referent_per_call(): parent=geometric_representation_context, ) - alignment = ifcopenshell.api.alignment.create(file, "TestAlignment", start_station=2000.0) + alignment = ifcopenshell.api.alignment.create(file, "TestAlignment") horizontal_layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment) segment = ifcopenshell.api.alignment.get_layout_segments(horizontal_layout)[0] diff --git a/src/ifcopenshell-python/test/api/alignment/test_add_segment_to_layout.py b/src/ifcopenshell-python/test/api/alignment/test_add_segment_to_layout.py index e3cb304eac..1547a82f98 100644 --- a/src/ifcopenshell-python/test/api/alignment/test_add_segment_to_layout.py +++ b/src/ifcopenshell-python/test/api/alignment/test_add_segment_to_layout.py @@ -25,7 +25,7 @@ import ifcopenshell.api.unit from ifcopenshell.api.alignment._add_segment_to_layout import _add_segment_to_layout try: - ifcopenshell.file(schema="IFC4X3") + ifcopenshell.file(schema="IFC4X3_ADD2") IFC4X3_AVAILABLE = True except RuntimeError: IFC4X3_AVAILABLE = False @@ -33,7 +33,7 @@ except RuntimeError: @pytest.mark.skipif(not IFC4X3_AVAILABLE, reason="IFC4X3 not available") def test_add_segment_to_layout(): - file = ifcopenshell.file(schema="IFC4X3") + 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]) @@ -47,11 +47,12 @@ def test_add_segment_to_layout(): ) alignment = ifcopenshell.api.alignment.create(file, "") + ifcopenshell.api.alignment.add_stationing_referent(file, "0+00.00", alignment, distance_along=0.0, station=0.0) stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment) assert ( len(stationing_nest.RelatedObjects) == 1 - ) # the alignment creates the stationing nest and it has one referent to defined the stationing for the alignment + ) # the stationing nest has one referent that defines the stationing for the alignment horizontal_alignment = ifcopenshell.api.alignment.get_horizontal_layout(alignment) diff --git a/src/ifcopenshell-python/test/api/alignment/test_add_stationing_referent.py b/src/ifcopenshell-python/test/api/alignment/test_add_stationing_referent.py index f2082102a9..9adebabb0f 100644 --- a/src/ifcopenshell-python/test/api/alignment/test_add_stationing_referent.py +++ b/src/ifcopenshell-python/test/api/alignment/test_add_stationing_referent.py @@ -24,7 +24,7 @@ import ifcopenshell.util.element def _create_test_file(): - file = ifcopenshell.file(schema="IFC4X3") + 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]) @@ -43,7 +43,7 @@ def _create_test_alignment_with_vertical(file): # include_vertical=True so that get_curve() (IfcGradientCurve, on the "Axis" representation) # and get_basis_curve() (IfcCompositeCurve, on the "FootPrint" representation) are different # entities, letting the on_basis_curve option be observed. - alignment = ifcopenshell.api.alignment.create(file, "TestAlignment", include_vertical=True, start_station=0.0) + alignment = ifcopenshell.api.alignment.create(file, "TestAlignment", include_vertical=True) assert ifcopenshell.api.alignment.get_basis_curve(alignment).is_a("IfcCompositeCurve") assert ifcopenshell.api.alignment.get_curve(alignment).is_a("IfcGradientCurve") assert ifcopenshell.api.alignment.get_basis_curve(alignment) != ifcopenshell.api.alignment.get_curve(alignment) @@ -110,6 +110,44 @@ def test_add_stationing_referent_on_basis_curve_false(): assert basis_curve != ifcopenshell.api.alignment.get_basis_curve(alignment) +def test_add_stationing_referent_without_geometry_placed_at_global_origin(): + # An alignment with no resolvable basis curve gets an IfcLocalPlacement at (0, 0), + # not an IfcLinearPlacement, and does not require the alignment to have an ObjectPlacement. + file = _create_test_file() + alignment = ifcopenshell.api.alignment.create(file, "TestAlignment", include_geometry=False) + + referent = ifcopenshell.api.alignment.add_stationing_referent( + file, "1+00.000", alignment, distance_along=0.0, station=100.0 + ) + + _assert_common_referent_asserts(referent, "1+00.000", 100.0) + assert referent.ObjectPlacement.is_a("IfcLocalPlacement") + assert referent.ObjectPlacement.RelativePlacement.Location.Coordinates == (0.0, 0.0) + + +def test_add_stationing_referent_has_increasing_station(): + file = _create_test_file() + alignment = ifcopenshell.api.alignment.create(file, "TestAlignment", include_geometry=False) + + # default: HasIncreasingStation is not written + default_referent = ifcopenshell.api.alignment.add_stationing_referent( + file, "1+00.000", alignment, distance_along=0.0, station=100.0 + ) + assert ( + ifcopenshell.util.element.get_pset(default_referent, name="Pset_Stationing", prop="HasIncreasingStation") is None + ) + + # explicit False (reverse stationing) is written + reverse_referent = ifcopenshell.api.alignment.add_stationing_referent( + file, "R", alignment, distance_along=0.0, station=100.0, has_increasing_station=False + ) + assert ( + ifcopenshell.util.element.get_pset(reverse_referent, name="Pset_Stationing", prop="HasIncreasingStation") is False + ) + + test_add_stationing_referent_on_basis_curve_none_defaults_to_basis_curve() test_add_stationing_referent_on_basis_curve_true() test_add_stationing_referent_on_basis_curve_false() +test_add_stationing_referent_without_geometry_placed_at_global_origin() +test_add_stationing_referent_has_increasing_station() diff --git a/src/ifcopenshell-python/test/api/alignment/test_add_stationing_to_alignment.py b/src/ifcopenshell-python/test/api/alignment/test_add_stationing_to_alignment.py index afa6bca9dc..57ba9495dc 100644 --- a/src/ifcopenshell-python/test/api/alignment/test_add_stationing_to_alignment.py +++ b/src/ifcopenshell-python/test/api/alignment/test_add_stationing_to_alignment.py @@ -25,7 +25,7 @@ import ifcopenshell.api.unit import ifcopenshell.util.element try: - ifcopenshell.file(schema="IFC4X3") + ifcopenshell.file(schema="IFC4X3_ADD2") IFC4X3_AVAILABLE = True except RuntimeError: IFC4X3_AVAILABLE = False @@ -33,7 +33,7 @@ except RuntimeError: @pytest.mark.skipif(not IFC4X3_AVAILABLE, reason="IFC4X3 not available") def test_add_stationing_to_alignment(): - file = ifcopenshell.file(schema="IFC4X3") + 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]) @@ -46,7 +46,10 @@ def test_add_stationing_to_alignment(): parent=geometric_representation_context, ) - alignment = ifcopenshell.api.alignment.create(file, "TestAlignment", start_station=2000.0) + alignment = ifcopenshell.api.alignment.create(file, "TestAlignment") + ifcopenshell.api.alignment.add_stationing_referent( + file, "TestAlignment 2+000.000", alignment, distance_along=0.0, station=2000.0 + ) stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment) referent = stationing_nest.RelatedObjects[0] diff --git a/src/ifcopenshell-python/test/api/alignment/test_add_vertical_alignment.py b/src/ifcopenshell-python/test/api/alignment/test_add_vertical_alignment.py index ed9947afcd..32dd98b811 100644 --- a/src/ifcopenshell-python/test/api/alignment/test_add_vertical_alignment.py +++ b/src/ifcopenshell-python/test/api/alignment/test_add_vertical_alignment.py @@ -24,7 +24,7 @@ import ifcopenshell.api.context import ifcopenshell.api.unit try: - ifcopenshell.file(schema="IFC4X3") + ifcopenshell.file(schema="IFC4X3_ADD2") IFC4X3_AVAILABLE = True except RuntimeError: IFC4X3_AVAILABLE = False @@ -32,13 +32,14 @@ except RuntimeError: @pytest.mark.skipif(not IFC4X3_AVAILABLE, reason="IFC4X3 not available") def test_add_vertical_alignment(): - file = ifcopenshell.file(schema="IFC4X3") + 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") alignment = ifcopenshell.api.alignment.create(file, "A1", include_vertical=False) + ifcopenshell.api.alignment.add_stationing_referent(file, "0+00.00", alignment, distance_along=0.0, station=0.0) assert len(alignment.IsDecomposedBy) == 0 # no child alignments assert len(alignment.IsNestedBy) == 2 # nests for layout and referents diff --git a/src/ifcopenshell-python/test/api/alignment/test_create.py b/src/ifcopenshell-python/test/api/alignment/test_create.py index b28a9d9e53..946c8dbbc5 100644 --- a/src/ifcopenshell-python/test/api/alignment/test_create.py +++ b/src/ifcopenshell-python/test/api/alignment/test_create.py @@ -22,40 +22,6 @@ import pytest import ifcopenshell.api.alignment import ifcopenshell.api.context import ifcopenshell.api.unit -import ifcopenshell.util.element - - -def test_create_stationing_referent_name_includes_alignment_name(): - """create() creates an initial stationing IfcReferent from start_station - (see add_stationing_referent()). Its Name must include the alignment's - own name, the same " " convention - update_key_point_referents() uses for its own referents -- otherwise - this referent is indistinguishable by name alone from the same-named - referent of any OTHER alignment in the same file, unlike every other - referent in the model.""" - file = ifcopenshell.file(schema="IFC4X3_ADD2") - project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test") - length = ifcopenshell.api.unit.add_conversion_based_unit(file, name="foot") - ifcopenshell.api.unit.assign_unit(file, units=[length]) - geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model") - ifcopenshell.api.context.add_context( - file, - context_type="Model", - context_identifier="Axis", - target_view="MODEL_VIEW", - parent=geometric_representation_context, - ) - - alignment = ifcopenshell.api.alignment.create(file, "TestAlignment", start_station=4900.0) - - referents = [ - r - for r in ifcopenshell.util.element.get_components(alignment) - if r.is_a("IfcReferent") - and ifcopenshell.util.element.get_pset(r, name="Pset_Stationing", prop="Station") == 4900.0 - ] - assert len(referents) == 1 - assert referents[0].Name == "TestAlignment 49+00.00" try: @@ -67,7 +33,7 @@ except RuntimeError: @pytest.mark.skipif(not IFC4X3_AVAILABLE, reason="IFC4X3 not available") def test_create(): - file = ifcopenshell.file(schema="IFC4X3") + 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]) @@ -88,6 +54,12 @@ def test_create(): ali = ifcopenshell.api.alignment.create(file, "A1", include_vertical[i], include_cant[i]) assert ali != None + # create() does not define stationing - the alignment has no referent nest at all + assert ifcopenshell.api.alignment.get_stationing_nest(file, ali) is None + assert not any( + related.is_a("IfcReferent") for nest in ali.IsNestedBy for related in nest.RelatedObjects + ) + # verify the geometric representation was created curve = ifcopenshell.api.alignment.get_curve(ali) assert curve.is_a() == expected_curve_type[i] diff --git a/src/ifcopenshell-python/test/api/alignment/test_create_by_pi_method.py b/src/ifcopenshell-python/test/api/alignment/test_create_by_pi_method.py index c08b99b15c..266802fe3a 100644 --- a/src/ifcopenshell-python/test/api/alignment/test_create_by_pi_method.py +++ b/src/ifcopenshell-python/test/api/alignment/test_create_by_pi_method.py @@ -24,7 +24,7 @@ import ifcopenshell.api.context import ifcopenshell.api.unit try: - ifcopenshell.file(schema="IFC4X3") + ifcopenshell.file(schema="IFC4X3_ADD2") IFC4X3_AVAILABLE = True except RuntimeError: IFC4X3_AVAILABLE = False @@ -32,7 +32,7 @@ except RuntimeError: @pytest.mark.skipif(not IFC4X3_AVAILABLE, reason="IFC4X3 not available") def test_create_by_pi_method(): - file = ifcopenshell.file(schema="IFC4X3") + file = ifcopenshell.file(schema="IFC4X3_ADD2") project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test") length = ifcopenshell.api.unit.add_conversion_based_unit(file, name="foot") ifcopenshell.api.unit.assign_unit(file, units=[length]) @@ -51,7 +51,7 @@ def test_create_by_pi_method(): lengths = [(1600.0), (1200.0), (2000.0), (800.0)] alignment = ifcopenshell.api.alignment.create_by_pi_method( - file, "TestAlignment", coordinates, radii, vpoints, lengths + file, "TestAlignment", coordinates, radii, vpoints, lengths, start_station=10000.0 ) assert len(alignment.IsDecomposedBy) == 0 # no child alignments diff --git a/src/ifcopenshell-python/test/api/alignment/test_create_representation.py b/src/ifcopenshell-python/test/api/alignment/test_create_representation.py index b3ee95c4c2..4744d77d80 100644 --- a/src/ifcopenshell-python/test/api/alignment/test_create_representation.py +++ b/src/ifcopenshell-python/test/api/alignment/test_create_representation.py @@ -57,7 +57,7 @@ def test_create_representation(): (12799.99998062693, 89.99999997234107, 0.9999875002340269, -0.004999937569813611), ] - file = ifcopenshell.file(schema="IFC4X3") + file = ifcopenshell.file(schema="IFC4X3_ADD2") file.header.file_description.description = ["ViewDefinition [Alignment-basedView]"] project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="FHWA Alignment") @@ -77,9 +77,15 @@ def test_create_representation(): site = file.createIfcSite(GlobalId=ifcopenshell.guid.new(), Name="Site") ifcopenshell.api.aggregate.assign_object(file, relating_object=project, products=[site]) - alignment = ifcopenshell.api.alignment.create( - file, "E-Line", include_vertical=True, start_station=10000.0, include_geometry=False + alignment = ifcopenshell.api.alignment.create(file, "E-Line", include_vertical=True, include_geometry=False) + + # stationing is defined before the geometry exists, so the referent is placed at the global + # origin; create_representation() must restate it onto the basis curve at DistanceAlong 0.0 + ifcopenshell.api.alignment.add_stationing_referent( + file, "E-Line 100+00.00", alignment, distance_along=0.0, station=10000.0 ) + start_referent = ifcopenshell.api.alignment.get_stationing_nest(file, alignment).RelatedObjects[0] + assert start_referent.ObjectPlacement.is_a("IfcLocalPlacement") # alignment is referenced into spatial structure of site per CT 4.1.5.1 ifcopenshell.api.spatial.reference_structure(file, products=[alignment], relating_structure=site) @@ -420,6 +426,10 @@ def test_create_representation(): ifcopenshell.api.alignment.create_representation(file, alignment) + # the starting referent is now placed relative to the basis curve at DistanceAlong 0.0 + assert start_referent.ObjectPlacement.is_a("IfcLinearPlacement") + assert start_referent.ObjectPlacement.RelativePlacement.Location.DistanceAlong.wrappedValue == 0.0 + curve = ifcopenshell.api.alignment.get_basis_curve(alignment) assert curve.is_a("IfcCompositeCurve") for s in curve.Segments: diff --git a/src/ifcopenshell-python/test/api/alignment/test_distance_along_from_station.py b/src/ifcopenshell-python/test/api/alignment/test_distance_along_from_station.py index dea2c0d5c9..c968f384dc 100644 --- a/src/ifcopenshell-python/test/api/alignment/test_distance_along_from_station.py +++ b/src/ifcopenshell-python/test/api/alignment/test_distance_along_from_station.py @@ -23,7 +23,7 @@ import ifcopenshell.api.context import ifcopenshell.api.unit try: - ifcopenshell.file(schema="IFC4X3") + ifcopenshell.file(schema="IFC4X3_ADD2") IFC4X3_AVAILABLE = True except RuntimeError: IFC4X3_AVAILABLE = False @@ -31,7 +31,7 @@ except RuntimeError: @pytest.mark.skipif(not IFC4X3_AVAILABLE, reason="IFC4X3 not available") def test_distance_along_from_station(): - file = ifcopenshell.file(schema="IFC4X3") + file = ifcopenshell.file(schema="IFC4X3_ADD2") project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test") length = ifcopenshell.api.unit.add_conversion_based_unit(file, name="foot") ifcopenshell.api.unit.assign_unit(file, units=[length]) @@ -64,7 +64,7 @@ def test_distance_along_from_station_with_station_equations(): # Reproduces the worked example from the IFC Alignment Geometry Implementation Guide, chapter 9.2.6: # a gap equation (P3: incoming 14+00.00, outgoing 17+00.00) and an overlap equation # (P4: incoming 19+00.00, outgoing 18+50.00). - file = ifcopenshell.file(schema="IFC4X3") + file = ifcopenshell.file(schema="IFC4X3_ADD2") project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test") length = ifcopenshell.api.unit.add_conversion_based_unit(file, name="foot") ifcopenshell.api.unit.assign_unit(file, units=[length]) @@ -111,5 +111,159 @@ def test_distance_along_from_station_with_station_equations(): assert distance_along_from_station(file, alignment, 1875.0) == pytest.approx(625.0) +def test_distance_along_from_station_reverse_stationing_with_gap_equation(): + # A reverse-stationed alignment: HasIncreasingStation=False on the starting referent, station + # labels decreasing as distance along increases, with one gap equation. + # R1: D 0.0, Station 20+00.00, HasIncreasingStation=False + # R2: D 400.0, Station 15+50.00, IncomingStation 16+00.00 (gap of 50) + # R3: D 800.0, Station 11+50.00 + file = ifcopenshell.file(schema="IFC4X3_ADD2") + project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test") + length = ifcopenshell.api.unit.add_conversion_based_unit(file, name="foot") + ifcopenshell.api.unit.assign_unit(file, units=[length]) + geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model") + ifcopenshell.api.context.add_context( + file, + context_type="Model", + context_identifier="Axis", + target_view="MODEL_VIEW", + parent=geometric_representation_context, + ) + + coordinates = [(500.0, 2500.0), (3340.0, 660.0), (4340.0, 5000.0), (7600.0, 4560.0), (8480.0, 2010.0)] + radii = [(1000.0), (1250.0), (950.0)] + alignment = ifcopenshell.api.alignment.create_by_pi_method(file, "TestAlignment", coordinates, radii) + + ifcopenshell.api.alignment.add_stationing_referent( + file, "R1", alignment, distance_along=0.0, station=2000.0, has_increasing_station=False + ) + ifcopenshell.api.alignment.add_stationing_referent( + file, "R2", alignment, distance_along=400.0, station=1550.0, incoming_station=1600.0 + ) + ifcopenshell.api.alignment.add_stationing_referent(file, "R3", alignment, distance_along=800.0, station=1150.0) + + distance_along_from_station = ifcopenshell.api.alignment.distance_along_from_station + + # Sta. 18+00.00 -> governed by R1 + assert distance_along_from_station(file, alignment, 1800.0) == pytest.approx(200.0) + + # Sta. 13+00.00 -> governed by R2; naive subtraction from the start would overstate by the 50 ft gap + assert distance_along_from_station(file, alignment, 1300.0) == pytest.approx(650.0) + + # Sta. 15+75.00 falls inside the range the gap equation at R2 skipped -> no distance along + assert distance_along_from_station(file, alignment, 1575.0) is None + + +def _long_alignment(file): + project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test") + length = ifcopenshell.api.unit.add_conversion_based_unit(file, name="foot") + ifcopenshell.api.unit.assign_unit(file, units=[length]) + geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model") + ifcopenshell.api.context.add_context( + file, + context_type="Model", + context_identifier="Axis", + target_view="MODEL_VIEW", + parent=geometric_representation_context, + ) + coordinates = [(500.0, 2500.0), (3340.0, 660.0), (4340.0, 5000.0), (7600.0, 4560.0), (8480.0, 2010.0)] + radii = [(1000.0), (1250.0), (950.0)] + return ifcopenshell.api.alignment.create_by_pi_method(file, "TestAlignment", coordinates, radii) + + +def test_distance_along_from_station_direction_switch_increasing_then_decreasing(): + # A peak: station labels rise to R2 then fall. HasIncreasingStation=False on R2 governs the + # region after it. Data is self-consistent (no equations): each region is 500 units long with + # a 500 station-label swing. + # R1 D 0.0 S 1000.0 + # R2 D 500.0 S 1500.0 HasIncreasingStation=False + # R3 D 1000.0 S 1000.0 + file = ifcopenshell.file(schema="IFC4X3_ADD2") + alignment = _long_alignment(file) + ifcopenshell.api.alignment.add_stationing_referent(file, "R1", alignment, distance_along=0.0, station=1000.0) + ifcopenshell.api.alignment.add_stationing_referent( + file, "R2", alignment, distance_along=500.0, station=1500.0, has_increasing_station=False + ) + ifcopenshell.api.alignment.add_stationing_referent(file, "R3", alignment, distance_along=1000.0, station=1000.0) + + dafs = ifcopenshell.api.alignment.distance_along_from_station + + # Sta 12+00 appears twice (rising at D 200, falling at D 800); the downstream match is returned + assert dafs(file, alignment, 1200.0) == pytest.approx(800.0) + # the peak label sits at the single point D 500 + assert dafs(file, alignment, 1500.0) == pytest.approx(500.0) + # the end label + assert dafs(file, alignment, 1000.0) == pytest.approx(1000.0) + # Sta 16+00 is above the peak - it exists nowhere on the alignment + assert dafs(file, alignment, 1600.0) is None + + +def test_distance_along_from_station_direction_switch_decreasing_then_increasing(): + # A valley: labels fall to R2 then rise. R1 starts the alignment decreasing + # (HasIncreasingStation=False); R2 switches it back to increasing. + # R1 D 0.0 S 2000.0 HasIncreasingStation=False + # R2 D 500.0 S 1500.0 HasIncreasingStation=True + # R3 D 1000.0 S 2000.0 + file = ifcopenshell.file(schema="IFC4X3_ADD2") + alignment = _long_alignment(file) + ifcopenshell.api.alignment.add_stationing_referent( + file, "R1", alignment, distance_along=0.0, station=2000.0, has_increasing_station=False + ) + ifcopenshell.api.alignment.add_stationing_referent( + file, "R2", alignment, distance_along=500.0, station=1500.0, has_increasing_station=True + ) + ifcopenshell.api.alignment.add_stationing_referent(file, "R3", alignment, distance_along=1000.0, station=2000.0) + + dafs = ifcopenshell.api.alignment.distance_along_from_station + + # Sta 18+00 appears twice (falling at D 200, rising at D 800); the downstream match is returned + assert dafs(file, alignment, 1800.0) == pytest.approx(800.0) + # the valley label sits at the single point D 500 + assert dafs(file, alignment, 1500.0) == pytest.approx(500.0) + # Sta 14+00 is below the valley - it exists nowhere on the alignment + assert dafs(file, alignment, 1400.0) is None + + +def test_distance_along_from_station_multiple_direction_switches(): + # Not realistic, but valid IFC: stationing direction flips at every referent. + # R1 D 0.0 S 1000.0 increasing [0, 300] 1000 -> 1300 + # R2 D 300.0 S 1300.0 HasIncreasingStation=False decreasing [300, 600] 1300 -> 1000 + # R3 D 600.0 S 1000.0 HasIncreasingStation=True increasing [600, 900] 1000 -> 1300 + # R4 D 900.0 S 1300.0 HasIncreasingStation=False decreasing [900, 1200] 1300 -> 1000 + # R5 D 1200.0 S 1000.0 + file = ifcopenshell.file(schema="IFC4X3_ADD2") + alignment = _long_alignment(file) + ifcopenshell.api.alignment.add_stationing_referent(file, "R1", alignment, distance_along=0.0, station=1000.0) + ifcopenshell.api.alignment.add_stationing_referent( + file, "R2", alignment, distance_along=300.0, station=1300.0, has_increasing_station=False + ) + ifcopenshell.api.alignment.add_stationing_referent( + file, "R3", alignment, distance_along=600.0, station=1000.0, has_increasing_station=True + ) + ifcopenshell.api.alignment.add_stationing_referent( + file, "R4", alignment, distance_along=900.0, station=1300.0, has_increasing_station=False + ) + ifcopenshell.api.alignment.add_stationing_referent(file, "R5", alignment, distance_along=1200.0, station=1000.0) + + dafs = ifcopenshell.api.alignment.distance_along_from_station + + # referents nest in DistanceAlong order regardless of the direction flips + nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment) + assert [r.Name for r in nest.RelatedObjects] == ["R1", "R2", "R3", "R4", "R5"] + + # Sta 11+00 appears in every one of the four regions; the last (most downstream) match wins + assert dafs(file, alignment, 1100.0) == pytest.approx(1100.0) + # the shared min label resolves to the very end of the alignment + assert dafs(file, alignment, 1000.0) == pytest.approx(1200.0) + # Sta 13+50 is above every peak - nowhere on the alignment + assert dafs(file, alignment, 1350.0) is None + # a label exactly at a peak + assert dafs(file, alignment, 1300.0) == pytest.approx(900.0) + + test_distance_along_from_station() test_distance_along_from_station_with_station_equations() +test_distance_along_from_station_reverse_stationing_with_gap_equation() +test_distance_along_from_station_direction_switch_increasing_then_decreasing() +test_distance_along_from_station_direction_switch_decreasing_then_increasing() +test_distance_along_from_station_multiple_direction_switches() diff --git a/src/ifcopenshell-python/test/api/alignment/test_horizontal_layout_by_pi_method.py b/src/ifcopenshell-python/test/api/alignment/test_horizontal_layout_by_pi_method.py index 28ff4f410f..4b459de4f3 100644 --- a/src/ifcopenshell-python/test/api/alignment/test_horizontal_layout_by_pi_method.py +++ b/src/ifcopenshell-python/test/api/alignment/test_horizontal_layout_by_pi_method.py @@ -24,7 +24,7 @@ import ifcopenshell.api.context import ifcopenshell.api.unit try: - ifcopenshell.file(schema="IFC4X3") + ifcopenshell.file(schema="IFC4X3_ADD2") IFC4X3_AVAILABLE = True except RuntimeError: IFC4X3_AVAILABLE = False @@ -35,7 +35,7 @@ except RuntimeError: # compound curve (no tangent between curves) @pytest.mark.skipif(not IFC4X3_AVAILABLE, reason="IFC4X3 not available") def test_horizontal_layout_by_pi_method(): - file = ifcopenshell.file(schema="IFC4X3") + file = ifcopenshell.file(schema="IFC4X3_ADD2") project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test") length = ifcopenshell.api.unit.add_conversion_based_unit(file, name="foot") ifcopenshell.api.unit.assign_unit(file, units=[length]) @@ -51,7 +51,9 @@ def test_horizontal_layout_by_pi_method(): coordinates = [(838.760, 224.745), (965.926, 258.819), (1226.296, 258.819), (1350.817, 291.415)] radii = [(1000.0), (1000.0)] - alignment = ifcopenshell.api.alignment.create_by_pi_method(file, "TestAlignment", coordinates, radii) + alignment = ifcopenshell.api.alignment.create_by_pi_method( + file, "TestAlignment", coordinates, radii, start_station=10000.0 + ) assert len(alignment.IsDecomposedBy) == 0 # no child alignments assert len(alignment.IsNestedBy) == 2 diff --git a/src/ifcopenshell-python/test/api/alignment/test_update_key_point_referents.py b/src/ifcopenshell-python/test/api/alignment/test_update_key_point_referents.py index 9a6bf436a3..bd0fdcf898 100644 --- a/src/ifcopenshell-python/test/api/alignment/test_update_key_point_referents.py +++ b/src/ifcopenshell-python/test/api/alignment/test_update_key_point_referents.py @@ -33,7 +33,7 @@ LENGTHS = [1600.0, 1200.0, 2000.0, 800.0] def _new_file(): - file = ifcopenshell.file(schema="IFC4X3") + file = ifcopenshell.file(schema="IFC4X3_ADD2") 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]) @@ -49,7 +49,7 @@ def _new_file(): def _new_file_no_context(): - file = ifcopenshell.file(schema="IFC4X3") + file = ifcopenshell.file(schema="IFC4X3_ADD2") 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]) @@ -341,7 +341,8 @@ def test_single_real_segment_produces_only_boundary_labels(): def test_start_station_composes_for_child_alignment(): file = _new_file() - alignment = ifcopenshell.api.alignment.create(file, "A1", include_vertical=False, start_station=100.0) + alignment = ifcopenshell.api.alignment.create(file, "A1", include_vertical=False) + ifcopenshell.api.alignment.add_stationing_referent(file, "A1 1+00.00", alignment, distance_along=0.0, station=100.0) ifcopenshell.api.alignment.add_vertical_layout(file, alignment) ifcopenshell.api.alignment.add_vertical_layout(file, alignment) # forces the child-alignment split @@ -379,7 +380,8 @@ def test_rel_nests_from_ancestor_used_for_naming_and_nesting(): to an ancestor alignment's own rel_nests -- e.g. the same one already holding that ancestor's horizontal key points -- rather than the child's generic "Child of X" name.""" file = _new_file() - alignment = ifcopenshell.api.alignment.create(file, "A1", include_vertical=False, start_station=100.0) + alignment = ifcopenshell.api.alignment.create(file, "A1", include_vertical=False) + ifcopenshell.api.alignment.add_stationing_referent(file, "A1 1+00.00", alignment, distance_along=0.0, station=100.0) horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment) horizontal_nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal) horizontal_count = len(horizontal_nest.RelatedObjects) diff --git a/src/ifcopenshell-python/test/api/alignment/test_vertical_layout_by_pi_method.py b/src/ifcopenshell-python/test/api/alignment/test_vertical_layout_by_pi_method.py index 484fa79b1c..83cfea1660 100644 --- a/src/ifcopenshell-python/test/api/alignment/test_vertical_layout_by_pi_method.py +++ b/src/ifcopenshell-python/test/api/alignment/test_vertical_layout_by_pi_method.py @@ -24,7 +24,7 @@ import ifcopenshell.api.context import ifcopenshell.api.unit try: - ifcopenshell.file(schema="IFC4X3") + ifcopenshell.file(schema="IFC4X3_ADD2") IFC4X3_AVAILABLE = True except RuntimeError: IFC4X3_AVAILABLE = False @@ -35,7 +35,7 @@ except RuntimeError: # compound vertical curve (no gradient between curves) @pytest.mark.skipif(not IFC4X3_AVAILABLE, reason="IFC4X3 not available") def test_vertical_layout_by_pi_method(): - file = ifcopenshell.file(schema="IFC4X3") + file = ifcopenshell.file(schema="IFC4X3_ADD2") project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test") length = ifcopenshell.api.unit.add_conversion_based_unit(file, name="foot") ifcopenshell.api.unit.assign_unit(file, units=[length]) @@ -61,6 +61,8 @@ def test_vertical_layout_by_pi_method(): ifcopenshell.api.alignment.create_layout_segment(file, hlayout, segment1) + ifcopenshell.api.alignment.add_stationing_referent(file, "0+00.00", alignment, distance_along=0.0, station=0.0) + vpoints = [(0.0, 110.0), (400.0, 100.0), (800.0, 115.0), (1300.0, 125.0), (1800.0, 105.0)] lengths = [(800.0), (0.0), (1000.0)] vlayout = ifcopenshell.api.alignment.get_vertical_layout(alignment)