Strengthens implementation of station_to_string. Adds alignment name to stationing referent.

This commit is contained in:
Richard Brice
2026-08-12 13:08:53 -07:00
parent 223f358f28
commit 307836049f
7 changed files with 126 additions and 11 deletions
@@ -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 "<alignment name> <station>" 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:
@@ -128,6 +128,11 @@ def create_as_polyline(
The IfcAlignment is aggreated to IfcProject
The stationing referent created from start_station has Name "<alignment name> <station>"
(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]
@@ -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
@@ -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
@@ -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 "<alignment name> <station>" 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()
@@ -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 "<alignment name> <station>" 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()
@@ -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()