From d9864be64a82a7cbada753c04484a34daf53144e Mon Sep 17 00:00:00 2001 From: Richard Brice <37087370+RickBrice@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:14:29 -0700 Subject: [PATCH] Adds station_from_distance_along to alignment api --- .../ifcopenshell/api/alignment/__init__.py | 2 + .../alignment/station_from_distance_along.py | 102 ++++ .../test_station_from_distance_along.py | 577 ++++++++++++++++++ 3 files changed, 681 insertions(+) create mode 100644 src/ifcopenshell-python/ifcopenshell/api/alignment/station_from_distance_along.py create mode 100644 src/ifcopenshell-python/test/api/alignment/test_station_from_distance_along.py diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/__init__.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/__init__.py index 5d4a370f8e..43fd669143 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/alignment/__init__.py +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/__init__.py @@ -63,6 +63,7 @@ from .create_layout_segment import create_layout_segment from .create_representation import create_representation from .create_segment_representations import create_segment_representations from .distance_along_from_station import distance_along_from_station +from .station_from_distance_along import station_from_distance_along from .get_alignment import get_alignment from .get_alignment_layout_nest import get_alignment_layout_nest from .get_alignment_layouts import get_alignment_layouts @@ -111,6 +112,7 @@ __all__ = [ "create_representation", "create_segment_representations", "distance_along_from_station", + "station_from_distance_along", "get_alignment", "get_alignment_layout_nest", "get_alignment_layouts", diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/station_from_distance_along.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/station_from_distance_along.py new file mode 100644 index 0000000000..89a93eb6a4 --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/station_from_distance_along.py @@ -0,0 +1,102 @@ +# 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 +import ifcopenshell.util.element +from ifcopenshell import entity_instance +from ifcopenshell.api.alignment._referent_distance_along import _referent_distance_along + + +def station_from_distance_along(file: ifcopenshell.file, alignment: entity_instance, dist_along: float) -> float: + """ + Given a distance along a horizontal alignment, returns the corresponding station value. + + This is the inverse of distance_along_from_station(). Every distance along the alignment maps + to exactly one station, so this function always returns a float (never None). + + If the alignment does not have stationing defined with an IfcReferent, the start of the + alignment is taken from get_alignment_start_station() (or 0.0 if none is defined), and the + station is simply start_station + dist_along. + + 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 using the same referent walk as distance_along_from_station(): + + For each STATION referent nested to the alignment, DistanceAlong (D) and the outgoing station + (S, i.e. 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. The governing referent + is the last one whose DistanceAlong does not exceed dist_along, and the station is: + + station = S + sigma * (dist_along - D) + + If dist_along precedes the first referent's DistanceAlong, the station is extrapolated + backward from the first referent using the same formula. + + :param file: the IFC file + :param alignment: the alignment entity + :param dist_along: distance along the horizontal alignment + :return: the station value at the given distance along + + Example: + + .. code:: python + + alignment = model.by_type("IfcAlignment")[0] # alignment with start station 1+00.00 + station = ifcopenshell.api.alignment.station_from_distance_along(model, alignment, 200.0) + print(station) # 300.0 (100 + 200) + """ + stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment) + if stationing_nest is None: + start_station = ifcopenshell.api.alignment.get_alignment_start_station(file, alignment) or 0.0 + return float(start_station) + dist_along + + referents = [ + ( + _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 + ] + 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_ref, outgoing_station, has_increasing_station in referents: + if has_increasing_station is not None: + sigma = 1.0 if has_increasing_station else -1.0 + if outgoing_station is not None: + stations.append((distance_along_ref, float(outgoing_station), sigma)) + + if not stations: + start_station = ifcopenshell.api.alignment.get_alignment_start_station(file, alignment) or 0.0 + return float(start_station) + dist_along + + # Find governing referent: the last one whose DistanceAlong does not exceed dist_along. + # If dist_along precedes the first referent, extrapolate backward from it. + governing = stations[0] + for entry in stations: + if entry[0] <= dist_along + 1e-9: + governing = entry + + ref_dist, ref_station, ref_sigma = governing + return ref_station + ref_sigma * (dist_along - ref_dist) diff --git a/src/ifcopenshell-python/test/api/alignment/test_station_from_distance_along.py b/src/ifcopenshell-python/test/api/alignment/test_station_from_distance_along.py new file mode 100644 index 0000000000..a1ad3abbdd --- /dev/null +++ b/src/ifcopenshell-python/test/api/alignment/test_station_from_distance_along.py @@ -0,0 +1,577 @@ +# 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 . + +"""Tests for station_from_distance_along — the inverse of distance_along_from_station. + +Each test exercises a distinct stationing configuration. The expected values are derived from the +same referent tables used in the distance_along_from_station tests so the two functions are +verifiably inverse (within floating-point tolerance) where the mapping is one-to-one. +""" + +import pytest + +import ifcopenshell.api.alignment +import ifcopenshell.api.context +import ifcopenshell.api.unit +from ifcopenshell.api.alignment._referent_distance_along import _referent_distance_along + +try: + ifcopenshell.file(schema="IFC4X3_ADD2") + IFC4X3_AVAILABLE = True +except RuntimeError: + IFC4X3_AVAILABLE = False + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_file(): + file = ifcopenshell.file(schema="IFC4X3_ADD2") + 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]) + ctx = 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=ctx, + ) + return file + + +def _short_alignment(file, start_station=None): + """A compact horizontal-only alignment long enough for all tests (~1 300 ft).""" + coordinates = [(500.0, 2500.0), (3340.0, 660.0), (4340.0, 5000.0)] + radii = [(1000.0)] + return ifcopenshell.api.alignment.create_by_pi_method( + file, "TestAlignment", coordinates, radii, start_station=start_station + ) + + +def _long_alignment(file, start_station=None): + """A longer alignment (~8 000 ft), matching the fixture in test_distance_along_from_station.""" + 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, start_station=start_station + ) + + +sfda = ifcopenshell.api.alignment.station_from_distance_along +dafs = ifcopenshell.api.alignment.distance_along_from_station + + +# --------------------------------------------------------------------------- +# Test 1 — no stationing nest (get_stationing_nest returns None) +# --------------------------------------------------------------------------- + + +def test_no_stationing_nest_returns_dist_along(): + # create_by_pi_method without start_station creates no referent; the + # start station defaults to 0.0 and station == dist_along. + file = _make_file() + alignment = _short_alignment(file) # no start_station arg + + assert sfda(file, alignment, 0.0) == pytest.approx(0.0) + assert sfda(file, alignment, 300.0) == pytest.approx(300.0) + assert sfda(file, alignment, 750.5) == pytest.approx(750.5) + + +# --------------------------------------------------------------------------- +# Test 2 — single referent at D 0 (start station, no equations) +# --------------------------------------------------------------------------- + + +@pytest.mark.skipif(not IFC4X3_AVAILABLE, reason="IFC4X3 not available") +def test_single_start_station_referent(): + # Alignment with start station 10 000; one referent at D 0, S 10 000. + # station = 10 000 + dist_along. + file = _make_file() + alignment = _long_alignment(file, start_station=10000.0) + + assert sfda(file, alignment, 0.0) == pytest.approx(10000.0) + assert sfda(file, alignment, 3883.96) == pytest.approx(13883.96) + assert sfda(file, alignment, 7525.36) == pytest.approx(17525.36) + + +@pytest.mark.skipif(not IFC4X3_AVAILABLE, reason="IFC4X3 not available") +def test_single_start_station_referent_roundtrip(): + # station_from_distance_along and distance_along_from_station are inverses. + file = _make_file() + alignment = _long_alignment(file, start_station=10000.0) + + for d in (0.0, 500.0, 3883.96, 7525.36): + sta = sfda(file, alignment, d) + assert dafs(file, alignment, sta) == pytest.approx(d) + + +# --------------------------------------------------------------------------- +# Test 3 — gap station equation +# --------------------------------------------------------------------------- + + +def test_gap_station_equation(): + # Reproduces the IFC Alignment Implementation Guide example (§ 9.2.6): + # P0: D 0, S 1 000 (from start_station=1000) + # P3: D 400, S 1 700 (incoming 1 400 → gap of 300 stations) + # P4: D 600, S 1 850 (incoming 1 900 → overlap of 50 stations) + # + # station_from_distance_along is total — every dist_along has a station. + # The gap/overlap affects the *station → dist* direction only. + file = _make_file() + alignment = _long_alignment(file, 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 + ) + + # region governed by P0 (D 0–400, sigma +1) + assert sfda(file, alignment, 0.0) == pytest.approx(1000.0) + assert sfda(file, alignment, 300.0) == pytest.approx(1300.0) + + # at the gap equation referent itself + assert sfda(file, alignment, 400.0) == pytest.approx(1700.0) + + # region governed by P3 (D 400–600, sigma +1): stations 1 700–1 900 + # (stations 1 400–1 700 are in the gap — skipped over in the D→S direction) + assert sfda(file, alignment, 450.0) == pytest.approx(1750.0) + + # at the overlap equation referent: outgoing station 1 850 + assert sfda(file, alignment, 600.0) == pytest.approx(1850.0) + + # region governed by P4 (D >= 600, sigma +1) + assert sfda(file, alignment, 675.0) == pytest.approx(1925.0) + + +def test_gap_station_equation_roundtrip(): + # For stations outside the gap, distance_along_from_station is the inverse. + file = _make_file() + alignment = _long_alignment(file, 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 + ) + + for d in (300.0, 450.0, 675.0): + sta = sfda(file, alignment, d) + assert dafs(file, alignment, sta) == pytest.approx(d) + + +# --------------------------------------------------------------------------- +# Test 4 — overlap station equation +# --------------------------------------------------------------------------- + + +def test_overlap_station_equation(): + # Two dist_alongs that bracket the overlap equation at P4 both return + # stations within the overlap band — no ambiguity going D→S. + # P4: D 600, incoming 1 900, outgoing 1 850 → overlap [1 850, 1 900] + file = _make_file() + alignment = _long_alignment(file, 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 + ) + + # D 550 is in P3's region: station = 1700 + 1*(550-400) = 1850 + assert sfda(file, alignment, 550.0) == pytest.approx(1850.0) + + # D 600 is P4's outgoing point: station = 1850 + assert sfda(file, alignment, 600.0) == pytest.approx(1850.0) + + # D 625 is past P4: station = 1850 + 1*(625-600) = 1875 + assert sfda(file, alignment, 625.0) == pytest.approx(1875.0) + + +# --------------------------------------------------------------------------- +# Test 5 — decreasing stationing with a gap equation +# --------------------------------------------------------------------------- + + +def test_decreasing_stationing_with_gap(): + # Mirrors test_distance_along_from_station_reverse_stationing_with_gap_equation: + # R1: D 0, S 2 000, HasIncreasingStation=False → sigma -1 + # R2: D 400, S 1 550, incoming 1 600 → sigma -1 (inherited) + # R3: D 800, S 1 150 → sigma -1 (inherited) + file = _make_file() + 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=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) + + # R1's region (D 0–400, sigma -1): station = 2000 - dist_along + assert sfda(file, alignment, 0.0) == pytest.approx(2000.0) + assert sfda(file, alignment, 200.0) == pytest.approx(1800.0) + + # at R2 (D 400): station = 1550 + assert sfda(file, alignment, 400.0) == pytest.approx(1550.0) + + # R2's region (D 400–800, sigma -1): station = 1550 - (dist-400) + assert sfda(file, alignment, 650.0) == pytest.approx(1300.0) + + # at R3 (D 800): station = 1150 + assert sfda(file, alignment, 800.0) == pytest.approx(1150.0) + + # past R3 (sigma -1 continues) + assert sfda(file, alignment, 950.0) == pytest.approx(1000.0) + + +def test_decreasing_stationing_roundtrip(): + # distance_along_from_station is the inverse except within the gap zone. + file = _make_file() + 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=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) + + for d in (200.0, 650.0, 950.0): + sta = sfda(file, alignment, d) + assert dafs(file, alignment, sta) == pytest.approx(d) + + +# --------------------------------------------------------------------------- +# Test 6 — direction switch: increasing then decreasing (peak) +# --------------------------------------------------------------------------- + + +def test_direction_switch_increasing_then_decreasing(): + # Mirrors test_distance_along_from_station_direction_switch_increasing_then_decreasing: + # R1: D 0, S 1 000 sigma +1 [0, 500] + # R2: D 500, S 1 500, HasIncreasingStation=False sigma -1 [500, 1000] + # R3: D 1000, S 1 000 sigma -1 + file = _make_file() + 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) + + # rising region [0, 500]: station = 1000 + dist_along + assert sfda(file, alignment, 0.0) == pytest.approx(1000.0) + assert sfda(file, alignment, 200.0) == pytest.approx(1200.0) + + # at the peak (D 500): station = 1500 + assert sfda(file, alignment, 500.0) == pytest.approx(1500.0) + + # falling region [500, 1000]: station = 1500 - (dist - 500) + assert sfda(file, alignment, 800.0) == pytest.approx(1200.0) + + # at the end (D 1000): station = 1000 + assert sfda(file, alignment, 1000.0) == pytest.approx(1000.0) + + +def test_direction_switch_increasing_then_decreasing_symmetric_stations(): + # D 200 and D 800 both map to station 1200 — two dist_alongs, one station. + # station_from_distance_along handles this without ambiguity (D→S is always 1:1). + file = _make_file() + 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) + + assert sfda(file, alignment, 200.0) == pytest.approx(1200.0) + assert sfda(file, alignment, 800.0) == pytest.approx(1200.0) + + +# --------------------------------------------------------------------------- +# Test 7 — direction switch: decreasing then increasing (valley) +# --------------------------------------------------------------------------- + + +def test_direction_switch_decreasing_then_increasing(): + # Mirrors test_distance_along_from_station_direction_switch_decreasing_then_increasing: + # R1: D 0, S 2 000, HasIncreasingStation=False sigma -1 [0, 500] + # R2: D 500, S 1 500, HasIncreasingStation=True sigma +1 [500, 1000] + # R3: D 1000, S 2 000 sigma +1 + file = _make_file() + 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) + + # falling region [0, 500]: station = 2000 - dist_along + assert sfda(file, alignment, 0.0) == pytest.approx(2000.0) + assert sfda(file, alignment, 200.0) == pytest.approx(1800.0) + + # at the valley (D 500): station = 1500 + assert sfda(file, alignment, 500.0) == pytest.approx(1500.0) + + # rising region [500, 1000]: station = 1500 + (dist - 500) + assert sfda(file, alignment, 800.0) == pytest.approx(1800.0) + + # at the end (D 1000): station = 2000 + assert sfda(file, alignment, 1000.0) == pytest.approx(2000.0) + + +# --------------------------------------------------------------------------- +# Test 8 — multiple direction switches +# --------------------------------------------------------------------------- + + +def test_multiple_direction_switches(): + # Mirrors test_distance_along_from_station_multiple_direction_switches. + # Stationing direction flips at every referent (valid but not realistic IFC): + # R1 D 0 S 1000 sigma +1 [0, 300] 1000→1300 + # R2 D 300 S 1300 HIS=False sigma -1 [300, 600] 1300→1000 + # R3 D 600 S 1000 HIS=True sigma +1 [600, 900] 1000→1300 + # R4 D 900 S 1300 HIS=False sigma -1 [900, 1200] 1300→1000 + # R5 D 1200 S 1000 + file = _make_file() + 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) + + # each region boundary + assert sfda(file, alignment, 0.0) == pytest.approx(1000.0) + assert sfda(file, alignment, 300.0) == pytest.approx(1300.0) + assert sfda(file, alignment, 600.0) == pytest.approx(1000.0) + assert sfda(file, alignment, 900.0) == pytest.approx(1300.0) + assert sfda(file, alignment, 1200.0) == pytest.approx(1000.0) + + # mid-region values + assert sfda(file, alignment, 150.0) == pytest.approx(1150.0) # [0,300] rising + assert sfda(file, alignment, 450.0) == pytest.approx(1150.0) # [300,600] falling + assert sfda(file, alignment, 750.0) == pytest.approx(1150.0) # [600,900] rising + assert sfda(file, alignment, 1050.0) == pytest.approx(1150.0) # [900,1200] falling + + # same station (1100) appears in all four regions + assert sfda(file, alignment, 100.0) == pytest.approx(1100.0) + assert sfda(file, alignment, 500.0) == pytest.approx(1100.0) + assert sfda(file, alignment, 700.0) == pytest.approx(1100.0) + assert sfda(file, alignment, 1100.0) == pytest.approx(1100.0) + + +# --------------------------------------------------------------------------- +# Test 9 — extrapolation before the first referent +# --------------------------------------------------------------------------- + + +def test_extrapolation_before_first_referent(): + # When dist_along precedes the first referent's DistanceAlong, the station + # is extrapolated backward using the first referent's sigma. + file = _make_file() + alignment = _long_alignment(file) + + # Single referent at D 100, S 1100, sigma +1. + # dist_along 0 → station = 1100 + 1*(0-100) = 1000. + ifcopenshell.api.alignment.add_stationing_referent( + file, "R1", alignment, distance_along=100.0, station=1100.0 + ) + + assert sfda(file, alignment, 0.0) == pytest.approx(1000.0) + assert sfda(file, alignment, 50.0) == pytest.approx(1050.0) + assert sfda(file, alignment, 100.0) == pytest.approx(1100.0) + assert sfda(file, alignment, 300.0) == pytest.approx(1300.0) + + +def test_extrapolation_before_first_referent_decreasing(): + # Decreasing stationing: first referent at D 100, S 1100, sigma -1. + # dist_along 0 → station = 1100 + (-1)*(0-100) = 1200. + file = _make_file() + alignment = _long_alignment(file) + + ifcopenshell.api.alignment.add_stationing_referent( + file, "R1", alignment, distance_along=100.0, station=1100.0, has_increasing_station=False + ) + + assert sfda(file, alignment, 0.0) == pytest.approx(1200.0) + assert sfda(file, alignment, 50.0) == pytest.approx(1150.0) + assert sfda(file, alignment, 100.0) == pytest.approx(1100.0) + assert sfda(file, alignment, 300.0) == pytest.approx(900.0) + + +# --------------------------------------------------------------------------- +# Test 10 — referent nest ordering drives the governing-referent walk +# --------------------------------------------------------------------------- + + +def test_referents_nest_in_distance_along_order(): + # add_stationing_referent is called out of DistanceAlong order; the nest must + # still be sorted by DistanceAlong, and station_from_distance_along must pick + # the last referent whose DistanceAlong does not exceed dist_along. + file = _make_file() + alignment = _long_alignment(file, start_station=1000.0) + + ifcopenshell.api.alignment.add_stationing_referent(file, "R3", alignment, distance_along=800.0, station=1800.0) + ifcopenshell.api.alignment.add_stationing_referent(file, "R2", alignment, distance_along=400.0, station=1400.0) + + # nest is sorted by DistanceAlong: the D 0 start referent, then R2 (D 400), then R3 (D 800), + # regardless of the order add_stationing_referent was called in. + nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment) + assert [r.Name for r in nest.RelatedObjects][1:] == ["R2", "R3"] + assert [_referent_distance_along(r) for r in nest.RelatedObjects] == [0.0, 400.0, 800.0] + + # governed by the D 0 start referent + assert sfda(file, alignment, 200.0) == pytest.approx(1200.0) + # governed by R2 (D 400): station = 1400 + (600 - 400) + assert sfda(file, alignment, 600.0) == pytest.approx(1600.0) + # governed by R3 (D 800): station = 1800 + (900 - 800) + assert sfda(file, alignment, 900.0) == pytest.approx(1900.0) + + +# --------------------------------------------------------------------------- +# Test 11 — additional roundtrips through distance_along_from_station +# --------------------------------------------------------------------------- + + +def test_overlap_station_equation_roundtrip(): + # In the region *after* an overlap equation the mapping is one-to-one, so + # distance_along_from_station recovers the original dist_along. + file = _make_file() + alignment = _long_alignment(file, 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 + ) + + for d in (625.0, 675.0, 900.0): + sta = sfda(file, alignment, d) + assert dafs(file, alignment, sta) == pytest.approx(d) + + +def test_direction_switch_decreasing_then_increasing_roundtrip(): + # The valley: dist_alongs in the final (rising) region roundtrip cleanly; + # distance_along_from_station returns the most downstream match. + file = _make_file() + 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) + + for d in (600.0, 800.0, 1000.0): + sta = sfda(file, alignment, d) + assert dafs(file, alignment, sta) == pytest.approx(d) + + +def test_multiple_direction_switches_roundtrip(): + # Every region boundary and the shared mid-region station roundtrip to the + # most downstream distance along. + file = _make_file() + 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) + + # station 1100 appears in every region; both functions agree on the last one + assert sfda(file, alignment, 1100.0) == pytest.approx(1100.0) + assert dafs(file, alignment, 1100.0) == pytest.approx(1100.0) + + +# --------------------------------------------------------------------------- +# Test 12 — negative and fractional distances along (no stationing nest) +# --------------------------------------------------------------------------- + + +def test_negative_and_fractional_dist_along_no_nest(): + # With no stationing nest the result is start_station (0.0) + dist_along for + # any real dist_along, including negative and fractional values. + file = _make_file() + alignment = _long_alignment(file) # no start_station -> no referent, no nest + + assert ifcopenshell.api.alignment.get_stationing_nest(file, alignment) is None + assert sfda(file, alignment, -25.0) == pytest.approx(-25.0) + assert sfda(file, alignment, 0.0) == pytest.approx(0.0) + assert sfda(file, alignment, 123.456) == pytest.approx(123.456) + + +# --------------------------------------------------------------------------- +# Allow running the module directly for quick verification +# --------------------------------------------------------------------------- + + +if __name__ == "__main__": + test_no_stationing_nest_returns_dist_along() + test_single_start_station_referent() + test_single_start_station_referent_roundtrip() + test_gap_station_equation() + test_gap_station_equation_roundtrip() + test_overlap_station_equation() + test_decreasing_stationing_with_gap() + test_decreasing_stationing_roundtrip() + test_direction_switch_increasing_then_decreasing() + test_direction_switch_increasing_then_decreasing_symmetric_stations() + test_direction_switch_decreasing_then_increasing() + test_multiple_direction_switches() + test_extrapolation_before_first_referent() + test_extrapolation_before_first_referent_decreasing() + test_referents_nest_in_distance_along_order() + test_overlap_station_equation_roundtrip() + test_direction_switch_decreasing_then_increasing_roundtrip() + test_multiple_direction_switches_roundtrip() + test_negative_and_fractional_dist_along_no_nest() + print("All tests passed.")