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
committed by Dion Moult
parent 59b957daff
commit f65de78c46
7 changed files with 125 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 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. 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 file:
:param name: name assigned to IfcAlignment.Name :param name: name assigned to IfcAlignment.Name
@@ -86,7 +90,7 @@ def create(
if include_geometry: if include_geometry:
_create_geometric_representation(file, alignment) _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) referent = ifcopenshell.api.alignment.add_stationing_referent(file, referent_name, alignment, 0.0, start_station)
for layout in alignment_layouts: for layout in alignment_layouts:
@@ -128,6 +128,11 @@ def create_as_polyline(
The IfcAlignment is aggreated to IfcProject 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 file:
:param name: name assigned to IfcAlignment.Name :param name: name assigned to IfcAlignment.Name
:param points: sequence of points defining the polyline :param points: sequence of points defining the polyline
@@ -142,8 +147,8 @@ def create_as_polyline(
_create_polyline_representation(file, alignment, points) _create_polyline_representation(file, alignment, points)
# define stationing # define stationing
name = ifcopenshell.util.alignment.station_as_string(file, start_station) referent_name = f"{alignment.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 = ifcopenshell.api.alignment.add_stationing_referent(file, referent_name, alignment, 0.0, start_station)
# IFC 4.1.4.1.1 Alignment Aggregation To Project # IFC 4.1.4.1.1 Alignment Aggregation To Project
project = file.by_type("IfcProject")[0] 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. 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 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 If the project units are Emperial-based, the string is in the format xx+yy.zz
:param station: the station to be stringized :param station: the station to be stringized
:return: stringized station :return: stringized station
""" """
unit_type = ifcopenshell.util.unit.get_project_unit(file, "LENGTHUNIT") 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"): if unit_type.is_a("IfcConversionBasedUnit"):
station = ifcopenshell.util.unit.convert( # xx+yy.zz display is inherently foot-based, regardless of which foot variant
sta, from_unit=unit_type.Name, from_prefix=None, to_unit="foot", to_prefix=None # (international vs. US survey, etc.) the project's own unit actually is.
) station = sta * project_unit_to_metres / 0.3048
plus_seperator = 2 plus_seperator = 2
precision = 2 precision = 2
else: else:
station = ifcopenshell.util.unit.convert( station = sta * project_unit_to_metres
sta, from_unit=unit_type.Name, from_prefix=unit_type.Prefix, to_unit="meter", to_prefix=None
)
plus_seperator = 3 plus_seperator = 3
precision = 3 precision = 3
@@ -52,7 +52,7 @@ def test_add_stationing_to_alignment():
referent = stationing_nest.RelatedObjects[0] referent = stationing_nest.RelatedObjects[0]
assert referent.PredefinedType == "STATION" 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")
assert ifcopenshell.util.element.get_pset(element=referent, name="Pset_Stationing", prop="Station") == 2000.0 assert ifcopenshell.util.element.get_pset(element=referent, name="Pset_Stationing", prop="Station") == 2000.0
assert referent.ObjectPlacement != None assert referent.ObjectPlacement != None
@@ -22,6 +22,39 @@ import pytest
import ifcopenshell.api.alignment import ifcopenshell.api.alignment
import ifcopenshell.api.context import ifcopenshell.api.context
import ifcopenshell.api.unit 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"
try: try:
ifcopenshell.file(schema="IFC4") ifcopenshell.file(schema="IFC4")
@@ -21,6 +21,7 @@ import pytest
import ifcopenshell.api.alignment import ifcopenshell.api.alignment
import ifcopenshell.api.unit import ifcopenshell.api.unit
import ifcopenshell.util.element
try: try:
ifcopenshell.file(schema="IFC4X3") ifcopenshell.file(schema="IFC4X3")
@@ -49,7 +50,16 @@ def test_create_as_polyline():
file.createIfcCartesianPoint((-585.0, 3275.2, 56.2)), 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) curve = ifcopenshell.api.alignment.get_curve(alignment)
assert curve.is_a("IfcPolyline") assert curve.is_a("IfcPolyline")
assert len(curve.Points) == 10 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"
@@ -86,7 +86,69 @@ def _test_us_stations():
@pytest.mark.skipif(not IFC4X3_AVAILABLE, reason="IFC4X3 not available") @pytest.mark.skipif(not IFC4X3_AVAILABLE, reason="IFC4X3 not available")
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(): def test_station_as_string():
_test_si_stations() _test_si_stations()
_test_si_stations_millimeter() _test_si_stations_millimeter()
_test_us_stations() _test_us_stations()
_test_custom_named_conversion_based_unit_stations()