From 307836049f045106f0a6565f766fff1272882adf Mon Sep 17 00:00:00 2001 From: Richard Brice <37087370+RickBrice@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:08:53 -0700 Subject: [PATCH] Strengthens implementation of station_to_string. Adds alignment name to stationing referent. --- .../ifcopenshell/api/alignment/create.py | 6 +- .../api/alignment/create_as_polyline.py | 9 ++- .../ifcopenshell/util/alignment.py | 12 ++-- .../test_add_stationing_to_alignment.py | 2 +- .../test/api/alignment/test_create.py | 34 ++++++++++ .../api/alignment/test_create_as_polyline.py | 12 +++- .../test/util/test_alignment.py | 62 +++++++++++++++++++ 7 files changed, 126 insertions(+), 11 deletions(-) diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/create.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/create.py index a1b7734af6..9cb23165e9 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/alignment/create.py +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/create.py @@ -51,6 +51,10 @@ def create( 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. :param file: :param name: name assigned to IfcAlignment.Name @@ -86,7 +90,7 @@ def create( if include_geometry: _create_geometric_representation(file, alignment) - referent_name = ifcopenshell.util.alignment.station_as_string(file, start_station) + 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: 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 1348402d03..05e2262d4a 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/alignment/create_as_polyline.py +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/create_as_polyline.py @@ -128,6 +128,11 @@ def create_as_polyline( 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. + :param file: :param name: name assigned to IfcAlignment.Name :param points: sequence of points defining the polyline @@ -142,8 +147,8 @@ def create_as_polyline( _create_polyline_representation(file, alignment, points) # define stationing - name = ifcopenshell.util.alignment.station_as_string(file, start_station) - referent = ifcopenshell.api.alignment.add_stationing_referent(file, name, alignment, 0.0, start_station) + 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) # IFC 4.1.4.1.1 Alignment Aggregation To Project project = file.by_type("IfcProject")[0] diff --git a/src/ifcopenshell-python/ifcopenshell/util/alignment.py b/src/ifcopenshell-python/ifcopenshell/util/alignment.py index db2ba8e54f..2421504333 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/alignment.py +++ b/src/ifcopenshell-python/ifcopenshell/util/alignment.py @@ -69,21 +69,21 @@ def station_as_string(file: ifcopenshell.file, sta: float): Returns a stringized version of a station. Example 100.0 is 1+00.00 as a stationing string. If the project units are SI-based, the string is in the format xxx+yyy.zzz If the project units are Emperial-based, the string is in the format xx+yy.zz + :param station: the station to be stringized :return: stringized station """ unit_type = ifcopenshell.util.unit.get_project_unit(file, "LENGTHUNIT") + project_unit_to_metres = ifcopenshell.util.unit.calculate_unit_scale(file) if unit_type.is_a("IfcConversionBasedUnit"): - station = ifcopenshell.util.unit.convert( - sta, from_unit=unit_type.Name, from_prefix=None, to_unit="foot", to_prefix=None - ) + # xx+yy.zz display is inherently foot-based, regardless of which foot variant + # (international vs. US survey, etc.) the project's own unit actually is. + station = sta * project_unit_to_metres / 0.3048 plus_seperator = 2 precision = 2 else: - station = ifcopenshell.util.unit.convert( - sta, from_unit=unit_type.Name, from_prefix=unit_type.Prefix, to_unit="meter", to_prefix=None - ) + station = sta * project_unit_to_metres plus_seperator = 3 precision = 3 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 3422c3d8cc..6a3376c85a 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 @@ -43,7 +43,7 @@ def test_add_stationing_to_alignment(): referent = stationing_nest.RelatedObjects[0] assert referent.PredefinedType == "STATION" - assert referent.Name == "2+000.000" + assert referent.Name == "TestAlignment 2+000.000" 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 diff --git a/src/ifcopenshell-python/test/api/alignment/test_create.py b/src/ifcopenshell-python/test/api/alignment/test_create.py index f227bfd758..b849de2d63 100644 --- a/src/ifcopenshell-python/test/api/alignment/test_create.py +++ b/src/ifcopenshell-python/test/api/alignment/test_create.py @@ -20,6 +20,39 @@ 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" def test_create(): @@ -84,3 +117,4 @@ def test_create(): test_create() +test_create_stationing_referent_name_includes_alignment_name() diff --git a/src/ifcopenshell-python/test/api/alignment/test_create_as_polyline.py b/src/ifcopenshell-python/test/api/alignment/test_create_as_polyline.py index 6e4eee9527..65728bf19c 100644 --- a/src/ifcopenshell-python/test/api/alignment/test_create_as_polyline.py +++ b/src/ifcopenshell-python/test/api/alignment/test_create_as_polyline.py @@ -19,6 +19,7 @@ import ifcopenshell.api.alignment import ifcopenshell.api.unit +import ifcopenshell.util.element def test_create_as_polyline(): @@ -40,10 +41,19 @@ def test_create_as_polyline(): file.createIfcCartesianPoint((-585.0, 3275.2, 56.2)), ] - alignment = ifcopenshell.api.alignment.create_as_polyline(file, "A1", points) + alignment = ifcopenshell.api.alignment.create_as_polyline(file, "A1", points, start_station=100.0) curve = ifcopenshell.api.alignment.get_curve(alignment) assert curve.is_a("IfcPolyline") assert len(curve.Points) == 10 + # stationing referent's Name must include the alignment's own name, the + # same " " convention create() and + # update_key_point_referents() use -- previously this reassigned the + # local `name` variable (shadowing the "A1" parameter) to just the bare + # station string, losing the alignment name entirely. + referents = [r for r in ifcopenshell.util.element.get_components(alignment) if r.is_a("IfcReferent")] + assert len(referents) == 1 + assert referents[0].Name == "A1 0+100.000" + test_create_as_polyline() diff --git a/src/ifcopenshell-python/test/util/test_alignment.py b/src/ifcopenshell-python/test/util/test_alignment.py index 91c6ba707f..1ac4f54d8f 100644 --- a/src/ifcopenshell-python/test/util/test_alignment.py +++ b/src/ifcopenshell-python/test/util/test_alignment.py @@ -77,10 +77,72 @@ def _test_us_stations(): assert s == "-1234+56.79" +def _test_custom_named_conversion_based_unit_stations(): + """Regression test: station_as_string() must work for an + IfcConversionBasedUnit whose Name isn't one of the fixed set + ifcopenshell.util.unit.si_conversions recognises (e.g. a project that, + reasonably, names its foot-based unit something other than the bare + "foot" IfcOpenShell's own add_conversion_based_unit() produces -- for + instance to distinguish the US survey foot, 1200/3937 m exactly, from + the international foot, 0.3048 m exactly, which differ by ~2 ppm and + are NOT interchangeable once a project is tied to a US state plane CRS, + virtually all of which are defined in US survey feet). + + Previously, station_as_string() converted via + ifcopenshell.util.unit.convert(), which looks up the conversion factor + BY NAME in si_conversions -- silently substituting a factor of 1.0 + (i.e. treating the value as if it were already in the display unit) for + any unrecognised name, rather than raising an error. For a project unit + like "US survey foot" this inflated every station string by the + project-unit<->metre ratio (~3.28x), even though the underlying + Pset_Stationing.Station numeric value written by + ifcopenshell.api.alignment.create()/update_key_point_referents was + correct throughout -- only the display text was wrong. + """ + file = ifcopenshell.file(schema="IFC4X3_ADD2") + project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test") + + # Hand-built rather than via add_conversion_based_unit(), since that + # API also resolves its conversion factor by name (si_conversions) and + # can't produce a custom name paired with a specific factor. + si_unit = file.createIfcSIUnit(UnitType="LENGTHUNIT", Name="METRE") + value_component = file.create_entity("IfcReal", wrappedValue=1200.0 / 3937.0) # US survey foot, exact + conversion_factor = file.createIfcMeasureWithUnit(value_component, si_unit) + exponents = file.createIfcDimensionalExponents(1, 0, 0, 0, 0, 0, 0) + length = file.createIfcConversionBasedUnit(exponents, "LENGTHUNIT", "US survey foot", conversion_factor) + ifcopenshell.api.unit.assign_unit(file, units=[length]) + + # US survey foot and international foot differ by ~2 ppm. At small + # station values that's invisible at 2-decimal-place precision, so + # these match _test_us_stations()'s "foot" case exactly. + s = sta.station_as_string(file, 0.0) + assert s == "0+00.00" + + s = sta.station_as_string(file, 100.00) + assert s == "1+00.00" + + s = sta.station_as_string(file, -100.00) + assert s == "-1+00.00" + + # At a large enough station, ~2 ppm DOES become visible at 2 decimal + # places (123456.789 * 2e-6 =~ 0.25) -- this is the real, correct US + # survey foot vs. international foot difference, not a bug. Before the + # fix, the name-based lookup's silent 1.0 fallback inflated this same + # input by ~3.28x to "1234+57.036" -> "4050+82.90"-ish territory, wildly + # different from either correct answer -- so this still exercises the + # regression, it's just not identical to the "foot" case's value. + s = sta.station_as_string(file, 123456.789) + assert s == "1234+57.04" + + s = sta.station_as_string(file, -123456.789) + assert s == "-1234+57.04" + + def test_station_as_string(): _test_si_stations() _test_si_stations_millimeter() _test_us_stations() + _test_custom_named_conversion_based_unit_stations() test_station_as_string()