From 206cd6bbe19290f87f0b21c0d55ceb5afc899759 Mon Sep 17 00:00:00 2001 From: Richard Brice <37087370+RickBrice@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:10:33 -0700 Subject: [PATCH] Alignment API update for station and positioning referents. Fixes bug with fallback position. --- .../ifcopenshell/api/alignment/__init__.py | 2 + .../api/alignment/add_positioning_referent.py | 113 ++++++++++++++++++ .../api/alignment/add_stationing_referent.py | 35 ++---- .../api/alignment/add_vertical_layout.py | 12 -- .../ifcopenshell/api/alignment/create.py | 2 +- .../api/alignment/create_as_polyline.py | 2 +- .../alignment/distance_along_from_station.py | 63 +++++++++- .../api/alignment/update_fallback_position.py | 9 +- .../test_add_positioning_referent.py | 100 ++++++++++++++++ .../test_add_stationing_to_alignment.py | 21 ++++ .../test_distance_along_from_station.py | 52 ++++++++ 11 files changed, 366 insertions(+), 45 deletions(-) create mode 100644 src/ifcopenshell-python/ifcopenshell/api/alignment/add_positioning_referent.py create mode 100644 src/ifcopenshell-python/test/api/alignment/test_add_positioning_referent.py diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/__init__.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/__init__.py index 13f4feefe5..d364c8fc91 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/alignment/__init__.py +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/__init__.py @@ -49,6 +49,7 @@ Future versions of this API may support: from ._get_segment_start_point_label import register_referent_name_callback from .add_stationing_referent import add_stationing_referent +from .add_positioning_referent import add_positioning_referent from .add_vertical_layout import add_vertical_layout from .add_zero_length_segment import add_zero_length_segment from .create import create @@ -94,6 +95,7 @@ from .util import * __all__ = [ "add_stationing_referent", + "add_positioning_referent", "add_vertical_layout", "add_zero_length_segment", "create", diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/add_positioning_referent.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/add_positioning_referent.py new file mode 100644 index 0000000000..fa72e24ff4 --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/add_positioning_referent.py @@ -0,0 +1,113 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 Thomas Krijnen +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + +import ifcopenshell +import ifcopenshell.api.alignment +from ifcopenshell.api.alignment.update_fallback_position import update_fallback_position +import ifcopenshell.api.pset +import ifcopenshell.guid +from ifcopenshell import entity_instance + + +def add_positioning_referent( + file: ifcopenshell.file, + name: str, + alignment: entity_instance, + distance_along: float, + station: float, + positioned_product: entity_instance, +) -> entity_instance: + """ + Semantically defines the position of a product along an alignment by adding an IfcReferent to the alignment that defines the stationing system. + + :param alignment: the alignment to receive the referent + :param distance_along: distance along the alignment basis curve + :param station: station value + :param name: name to assign to IfcReferent.Name, typically a stringized version of the station value + :param positioned_product: the product whose position is informed by the referent + :return: referent + + Example: + + .. code:: python + + alignment = model.by_type("IfcAlignment")[0] + pier = model.by_type("IfcBridgePart")[0] + ifcopenshell.api.alignment.add_positioning_referent(model,name="Pier 1 Sta 1+00",alignment=alignment,distance_along=0.0,station=100.0,positioned_product=pier) + """ + + basis_curve = ifcopenshell.api.alignment.get_basis_curve(alignment) + + object_placement = None + representation = None + if basis_curve and basis_curve.is_a("IfcCompositeCurve") and 0 < len(basis_curve.Segments): + object_placement = file.createIfcLinearPlacement( + RelativePlacement=file.createIfcAxis2PlacementLinear( + Location=file.createIfcPointByDistanceExpression( + DistanceAlong=file.createIfcLengthMeasure(distance_along), + OffsetLateral=None, + OffsetVertical=None, + OffsetLongitudinal=None, + BasisCurve=basis_curve, + ) + ), + ) + + update_fallback_position(file, object_placement) + else: + object_placement = file.createIfcLocalPlacement( + PlacementRelTo=None, + RelativePlacement=file.createIfcAxis2Placement2D( + Location=file.createIfcCartesianPoint(alignment.ObjectPlacement.RelativePlacement.Location.Coordinates) + ), + ) + + # this commented out code is what you would do to add a geometric representation of the referent + # the example is a circle. a better way would be to pass a representation into the function + # representation = file.create_entity( + # name="IfcCircle", + # position=file.createIfcAxis2Placement2D(Location=file.createIfcCartesianPoint(Coordinates=(0.0, 0.0)), + # radius=1.0) + # ) + + # create referent for the station + referent = file.createIfcReferent( + GlobalId=ifcopenshell.guid.new(), + OwnerHistory=None, + Name=name, + Description=None, + ObjectType=None, + ObjectPlacement=object_placement, + Representation=representation, + PredefinedType="POSITION", + ) + pset_stationing = ifcopenshell.api.pset.add_pset(file, product=referent, name="Pset_Stationing") + ifcopenshell.api.pset.edit_pset(file, pset=pset_stationing, properties={"Station": station}) + + if len(referent.Positions) == 0: + rel_positions = file.createIfcRelPositions( + GlobalId=ifcopenshell.guid.new(), + RelatingPositioningElement=referent, + RelatedProducts=[ + positioned_product, + ], + ) + else: + referent.Positions[0].RelatedProducts += (positioned_product,) + + return referent 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 32f88ef501..58dcaa3765 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/alignment/add_stationing_referent.py +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/add_stationing_referent.py @@ -16,35 +16,33 @@ # You should have received a copy of the GNU Lesser General Public License # along with IfcOpenShell. If not, see . -import numpy as np +from typing import Optional import ifcopenshell import ifcopenshell.api.alignment from ifcopenshell.api.alignment.update_fallback_position import update_fallback_position import ifcopenshell.api.pset -import ifcopenshell.geom import ifcopenshell.guid import ifcopenshell.util.element -import ifcopenshell.util.unit -from ifcopenshell import entity_instance, ifcopenshell_wrapper +from ifcopenshell import entity_instance def add_stationing_referent( file: ifcopenshell.file, + name: str, alignment: entity_instance, distance_along: float, station: float, - name: str, - positioned_product: entity_instance, + incoming_station: Optional[float] = None, ) -> entity_instance: """ - Adds an IfcReferent to the alignment with the Pset_Stationing property set. + Adds an IfcReferent to the alignment that defines the stationing system. + :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 name: name to assign to IfcReferent.Name, typically a stringized version of the station value - :param positioned_product: the product whose position is informed by the referent + :param incoming_station: station value of the incoming segment, only set to specify a station equation :return: referent Example: @@ -52,7 +50,7 @@ def add_stationing_referent( .. code:: python alignment = model.by_type("IfcAlignment")[0] - ifcopenshell.api.alignment.add_stationing_referent(model,alignment=alignment,distance_along=0.0,station=100.0) + ifcopenshell.api.alignment.add_stationing_referent(model,name="1+00.0",alignment=alignment,distance_along=0.0,station=100.0) """ basis_curve = ifcopenshell.api.alignment.get_basis_curve(alignment) @@ -100,8 +98,12 @@ def add_stationing_referent( Representation=representation, PredefinedType="STATION", ) + properties = {"Station": station} + if incoming_station is not None: + properties["IncomingStation"] = incoming_station + pset_stationing = ifcopenshell.api.pset.add_pset(file, product=referent, name="Pset_Stationing") - ifcopenshell.api.pset.edit_pset(file, pset=pset_stationing, properties={"Station": station}) + ifcopenshell.api.pset.edit_pset(file, pset=pset_stationing, properties=properties) nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment) if nest is None: @@ -115,15 +117,4 @@ def add_stationing_referent( nest.RelatedObjects, key=lambda x: ifcopenshell.util.element.get_pset(x, name="Pset_Stationing", prop="Station") ) - if len(referent.Positions) == 0: - rel_positions = file.createIfcRelPositions( - GlobalId=ifcopenshell.guid.new(), - RelatingPositioningElement=referent, - RelatedProducts=[ - positioned_product, - ], - ) - else: - referent.Positions[0].RelatedProducts += (positioned_product,) - return referent diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/add_vertical_layout.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/add_vertical_layout.py index 09feb78a40..ef0cac8e40 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/alignment/add_vertical_layout.py +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/add_vertical_layout.py @@ -51,18 +51,6 @@ def _move_vertical_layout_to_child_alignment( # aggregate the child alignment to the parent alignment ifcopenshell.api.aggregate.assign_object(file, products=[child_alignment], relating_object=parent_alignment) - # move all referents positioning segments of the vertical layout to the referent nest of the child alignment - child_referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, child_alignment) - parent_referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, parent_alignment) - for referent in parent_referent_nest.RelatedObjects: - for product in referent.Positions[0].RelatedProducts: - if product.is_a("IfcAlignmentSegment") and product.Nests[0].RelatingObject == vertical_layout: - # ifcopenshell.api.nest.change_nest(file,referent,child_alignment) - this doesn't work because referent is assigned to child_alignment.IsNestedBy[0].RelatedObjects - # and it needs to be assigned to child_alignment.IsNestedBy[1].RelatedObjects - # move the referent manually - unassign it and add it to the child alignment's referent nest - ifcopenshell.api.nest.unassign_object(file, [referent]) - child_referent_nest.RelatedObjects += (referent,) - # if the parent alignment has a representation, move the Axis/Curve3D represention to the child alignment base_curve = ifcopenshell.api.alignment.get_basis_curve(parent_alignment) if base_curve: diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/create.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/create.py index 0077f672e8..5d7d105ec8 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/alignment/create.py +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/create.py @@ -88,7 +88,7 @@ def create( referent_name = ifcopenshell.util.alignment.station_as_string(file, start_station) referent = ifcopenshell.api.alignment.add_stationing_referent( - file, alignment, 0.0, start_station, referent_name, alignment + file, referent_name, alignment, 0.0, start_station ) for layout in alignment_layouts: 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 4988a31920..b786b80396 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/alignment/create_as_polyline.py +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/create_as_polyline.py @@ -141,7 +141,7 @@ def create_as_polyline( # define stationing name = ifcopenshell.util.alignment.station_as_string(file, start_station) - referent = ifcopenshell.api.alignment.add_stationing_referent(file, alignment, 0.0, start_station, name, alignment) + referent = ifcopenshell.api.alignment.add_stationing_referent(file, 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/distance_along_from_station.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/distance_along_from_station.py index c52d9962ad..9c08f479be 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 @@ -16,23 +16,47 @@ # You should have received a copy of the GNU Lesser General Public License # along with IfcOpenShell. If not, see . +from typing import Optional + import ifcopenshell import ifcopenshell.api.alignment +import ifcopenshell.util.element from ifcopenshell import entity_instance -def distance_along_from_station(file: ifcopenshell.file, alignment: entity_instance, station: float) -> float: +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 + + +def distance_along_from_station(file: ifcopenshell.file, alignment: entity_instance, station: float) -> Optional[float]: """ Given a station, returns the distance along the horizontal alignment. 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. - .. note:: The current implementation does not account for station equations and assumes stationing is increasing along the alignment. + Station equations (where Pset_Stationing.IncomingStation is set on a referent) 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. + + 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. + + 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). :param alignment: the alignment :param station: station value - :return: distance along the horizontal alignment + :return: distance along the horizontal alignment, or None if the station falls inside a station equation gap Example: @@ -43,6 +67,33 @@ def distance_along_from_station(file: ifcopenshell.file, alignment: entity_insta print(dist_along) # 100.00 """ - start_station = ifcopenshell.api.alignment.get_alignment_start_station(file, alignment) - dist_along = station - start_station - return dist_along + referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment) + if referent_nest is None: + start_station = ifcopenshell.api.alignment.get_alignment_start_station(file, alignment) + return station - start_station + + stations = [ + (_distance_along_of_referent(referent), ifcopenshell.util.element.get_pset(referent, name="Pset_Stationing", prop="Station")) + for referent in referent_nest.RelatedObjects + ] + stations.sort(key=lambda entry: entry[0]) + + index = None + for i, (distance_along, outgoing_station) in enumerate(stations): + if outgoing_station <= station: + 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 = stations[index] + + 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 + return None + + return distance_along + (station - outgoing_station) diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/update_fallback_position.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/update_fallback_position.py index 431d19fc86..85318d781f 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/alignment/update_fallback_position.py +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/update_fallback_position.py @@ -36,9 +36,12 @@ def update_fallback_position(file: ifcopenshell.file, lp: entity_instance): p = ifcopenshell.util.placement.get_local_placement(lp) - x = float(p[0, 3]) - y = float(p[1, 3]) - z = float(p[2, 3]) + + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file) + + x = float(p[0, 3])*unit_scale + y = float(p[1, 3])*unit_scale + z = float(p[2, 3])*unit_scale rx = float(p[0, 0]) ry = float(p[1, 0]) 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 new file mode 100644 index 0000000000..ca4c5b0836 --- /dev/null +++ b/src/ifcopenshell-python/test/api/alignment/test_add_positioning_referent.py @@ -0,0 +1,100 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 Thomas Krijnen +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + + +import ifcopenshell.api.alignment +import ifcopenshell.api.context +import ifcopenshell.api.unit +import ifcopenshell.util.element + + +def test_add_positioning_referent(): + 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, + ) + + alignment = ifcopenshell.api.alignment.create(file, "TestAlignment", start_station=2000.0) + + horizontal_layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment) + segment = ifcopenshell.api.alignment.get_layout_segments(horizontal_layout)[0] + + referent = ifcopenshell.api.alignment.add_positioning_referent( + file, "P.C.", alignment, distance_along=0.0, station=2000.0, positioned_product=segment + ) + + assert referent.is_a("IfcReferent") + assert referent.PredefinedType == "POSITION" + assert referent.Name == "P.C." + assert ifcopenshell.util.element.get_pset(element=referent, name="Pset_Stationing") + assert ifcopenshell.util.element.get_pset(element=referent, name="Pset_Stationing", prop="Station") == 2000.0 + assert referent.ObjectPlacement != None + + assert len(referent.Positions) == 1 + rel_positions = referent.Positions[0] + assert rel_positions.is_a("IfcRelPositions") + assert rel_positions.RelatingPositioningElement == referent + assert rel_positions.RelatedProducts == (segment,) + + +def test_add_positioning_referent_creates_separate_referent_per_call(): + 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, + ) + + alignment = ifcopenshell.api.alignment.create(file, "TestAlignment", start_station=2000.0) + + horizontal_layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment) + segment = ifcopenshell.api.alignment.get_layout_segments(horizontal_layout)[0] + + first_referent = ifcopenshell.api.alignment.add_positioning_referent( + file, "P.C.", alignment, distance_along=0.0, station=2000.0, positioned_product=segment + ) + + other_product = file.createIfcBuildingElementProxy(GlobalId=ifcopenshell.guid.new(), Name="Sign") + second_referent = ifcopenshell.api.alignment.add_positioning_referent( + file, "P.C.", alignment, distance_along=0.0, station=2000.0, positioned_product=other_product + ) + + # each call creates its own IfcReferent, each with its own IfcRelPositions to the product passed in + assert first_referent != second_referent + assert len(first_referent.Positions) == 1 + assert first_referent.Positions[0].RelatedProducts == (segment,) + assert len(second_referent.Positions) == 1 + assert second_referent.Positions[0].RelatedProducts == (other_product,) + + +test_add_positioning_referent() +test_add_positioning_referent_creates_separate_referent_per_call() 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 4dd113ebbc..6375ddfe90 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 @@ -48,5 +48,26 @@ def test_add_stationing_to_alignment(): assert ifcopenshell.util.element.get_pset(element=referent, name="Pset_Stationing", prop="Station") == 2000.0 assert referent.ObjectPlacement != None + # add a station equation at 1000 distance along. this is station 3+000 in coming and 4+000 outgoing. + # this is a gap equation. + second_referent = ifcopenshell.api.alignment.add_stationing_referent( + file, "4+000.000", alignment, distance_along=1000.0, station=4000.0, incoming_station=3000.0 + ) + + referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment) + assert len(referent_nest.RelatedObjects) == 2 + + assert second_referent == referent_nest.RelatedObjects[1] + + assert second_referent.PredefinedType == "STATION" + assert second_referent.Name == "4+000.000" + assert ifcopenshell.util.element.get_pset(element=second_referent, name="Pset_Stationing") + assert ifcopenshell.util.element.get_pset(element=second_referent, name="Pset_Stationing", prop="Station") == 4000.0 + assert ( + ifcopenshell.util.element.get_pset(element=second_referent, name="Pset_Stationing", prop="IncomingStation") + == 3000.0 + ) + assert second_referent.ObjectPlacement != None + test_add_stationing_to_alignment() 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 a5af19ebe3..6c284eacf8 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 @@ -53,4 +53,56 @@ def test_distance_along_from_station(): assert ifcopenshell.api.alignment.distance_along_from_station(file, alignment, 17525.36) == pytest.approx(7525.36) +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") + 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") + axis_model_representation_subcontext = 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)] + vpoints = [(0.0, 100.0), (2000.0, 135.0), (5000.0, 105.0), (7400.0, 153.0), (9800.0, 105.0), (12800.0, 90.0)] + lengths = [(1600.0), (1200.0), (2000.0), (800.0)] + + alignment = ifcopenshell.api.alignment.create_by_pi_method( + file, "TestAlignment", coordinates, radii, vpoints, lengths, start_station=1000.0 + ) + + ifcopenshell.api.alignment.add_stationing_referent( + file, "P3", alignment, distance_along=400.0, station=1700.0, incoming_station=1400.0 + ) + ifcopenshell.api.alignment.add_stationing_referent( + file, "P4", alignment, distance_along=600.0, station=1850.0, incoming_station=1900.0 + ) + + distance_along_from_station = ifcopenshell.api.alignment.distance_along_from_station + + # between P2 and P3: Sta. 13+00.00 + assert distance_along_from_station(file, alignment, 1300.0) == pytest.approx(300.0) + + # between P3 and P4: Sta. 18+00.00 + assert distance_along_from_station(file, alignment, 1800.0) == pytest.approx(500.0) + + # between P4 and P5: Sta. 19+25.00 + assert distance_along_from_station(file, alignment, 1925.0) == pytest.approx(675.0) + + # Sta. 15+00.00 falls inside the gap opened by the equation at P3 and has no corresponding distance along + assert distance_along_from_station(file, alignment, 1500.0) is None + + # Sta. 18+75.00 falls inside the overlap zone at P4; the post-equation (outgoing) match is returned + assert distance_along_from_station(file, alignment, 1875.0) == pytest.approx(625.0) + + test_distance_along_from_station() +test_distance_along_from_station_with_station_equations()