Adds update_alignment_parameter_segment_tags function

This commit is contained in:
Richard Brice
2026-08-07 14:33:04 -07:00
parent 048242783e
commit c5ba22451f
7 changed files with 434 additions and 14 deletions
@@ -25,7 +25,7 @@ are automatically created and maintained.
Alignments are created with stationing referents. Each layout segment is assigned a position referent that informs about
the start point of the segment. An example is the point of curvature of a horizontal circular curve. The referent is
nested to the segment representing the circular arc and is named with a indicator of the position and the station, e.g. "P.C. (145+98.32)"
nested to the segment representing the circular arc and is named with the alignment name and an indicator of the position and the station, e.g. "MyAlignment 145+98.32 (P.C.)"
This API does not determine alignment parameters based on rules, such as minimum curve radius as a function of design speed or sight distance.
@@ -89,6 +89,7 @@ from .layout_vertical_alignment_by_pi_method import (
layout_vertical_alignment_by_pi_method,
)
from .name_segments import name_segments
from .update_alignment_parameter_segment_tags import update_alignment_parameter_segment_tags
from .update_end_point import update_end_point
from .update_fallback_position import update_fallback_position
from .update_key_point_referents import update_key_point_referents
@@ -132,6 +133,7 @@ __all__ = [
"layout_vertical_alignment_by_pi_method",
"name_segments",
"register_referent_name_callback",
"update_alignment_parameter_segment_tags",
"update_end_point",
"update_fallback_position",
"update_key_point_referents",
@@ -0,0 +1,30 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
#
# 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 <http://www.gnu.org/licenses/>.
import ifcopenshell
import ifcopenshell.util.alignment
def _get_key_point_tag(file: ifcopenshell.file, label: str, station: float) -> str:
"""
Builds the station-and-label text shared by update_alignment_parameter_segment_tags (used
directly as IfcAlignmentParameterSegment.StartTag/EndTag) and update_key_point_referents (used,
prefixed with the alignment name, as IfcReferent.Name): "<station> (<label>)", e.g.
"145+98.32 (P.O.B.)".
"""
return f"{ifcopenshell.util.alignment.station_as_string(file, station)} ({label})"
@@ -0,0 +1,100 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
#
# 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 <http://www.gnu.org/licenses/>.
import ifcopenshell
import ifcopenshell.api.alignment
from ifcopenshell import entity_instance
from ifcopenshell.api.alignment._get_key_point_tag import _get_key_point_tag
from ifcopenshell.api.alignment._get_segment_start_point_label import (
_get_segment_start_point_label,
)
def update_alignment_parameter_segment_tags(file: ifcopenshell.file, layout: entity_instance) -> None:
"""
Sets IfcAlignmentParameterSegment.StartTag/EndTag for every segment transition in an alignment
layout. Unlike update_key_point_referents, this does not create any IfcReferent or IfcRelNests --
it only mutates the StartTag/EndTag string attributes already present on each segment's
DesignParameters.
For each transition between two consecutive segments, the outgoing segment's EndTag and the
incoming segment's StartTag are both set to the same computed tag (they describe the same
physical point), using the same label-and-station format as update_key_point_referents' Name
minus the alignment name (via _get_key_point_tag), e.g. "145+98.32 (P.C.)". Every real segment
ends up with both StartTag and EndTag populated: the first segment's StartTag and the last
segment's EndTag come from the "Beginning of Alignment"/"End of Alignment" boundary labels.
Labels come from _get_segment_start_point_label -- if a callback has been registered via
register_referent_name_callback(), its output is used instead of the built-in labels, exactly as
in update_key_point_referents.
:param layout: IfcAlignmentHorizontal, IfcAlignmentVertical, or IfcAlignmentCant
:return: None -- this function mutates segment.DesignParameters.StartTag/EndTag in place
Example:
.. code:: python
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
ifcopenshell.api.alignment.update_alignment_parameter_segment_tags(model, horizontal)
"""
expected_types = ["IfcAlignmentHorizontal", "IfcAlignmentVertical", "IfcAlignmentCant"]
if not layout.is_a() in expected_types:
raise TypeError(
f"Expected entity type to be one of {[_ for _ in expected_types]}, instead received {layout.is_a()}"
)
alignment = ifcopenshell.api.alignment.get_alignment(layout)
if alignment is None:
raise ValueError(f"{layout.is_a()} #{layout.id()} is not nested under an IfcAlignment.")
segments = list(ifcopenshell.api.alignment.get_layout_segments(layout))
if segments and ifcopenshell.api.alignment.has_zero_length_segment(layout):
segments = segments[:-1]
if not segments:
return
start_station = ifcopenshell.api.alignment.get_alignment_start_station(file, alignment)
is_horizontal = layout.is_a("IfcAlignmentHorizontal")
distance_along = 0.0
prev_segment = None
for segment in segments:
dp = segment.DesignParameters
seg_distance_along = distance_along if is_horizontal else dp.StartDistAlong
label = _get_segment_start_point_label(prev_segment, segment)
station = start_station + seg_distance_along
tag = _get_key_point_tag(file, label, station)
dp.StartTag = tag
if prev_segment is not None:
prev_segment.DesignParameters.EndTag = tag
if is_horizontal:
distance_along += dp.SegmentLength
else:
distance_along = dp.StartDistAlong + dp.HorizontalLength
prev_segment = segment
label = _get_segment_start_point_label(prev_segment, None)
station = start_station + distance_along
prev_segment.DesignParameters.EndTag = _get_key_point_tag(file, label, station)
@@ -22,9 +22,9 @@ import ifcopenshell
import ifcopenshell.api.alignment
import ifcopenshell.api.pset
import ifcopenshell.guid
import ifcopenshell.util.alignment
import ifcopenshell.util.element
from ifcopenshell import entity_instance
from ifcopenshell.api.alignment._get_key_point_tag import _get_key_point_tag
from ifcopenshell.api.alignment._get_segment_start_point_label import (
_get_segment_start_point_label,
)
@@ -76,7 +76,7 @@ def _create_key_point_referent(
),
)
name = f"{label} ({ifcopenshell.util.alignment.station_as_string(file, station)})"
name = f"{alignment.Name} {_get_key_point_tag(file, label, station)}"
referent = file.createIfcReferent(
GlobalId=ifcopenshell.guid.new(),
@@ -105,7 +105,8 @@ def update_key_point_referents(
Creates IfcReferent key-point markers for every segment transition in an alignment layout.
Labels are derived from _get_segment_start_point_label (e.g. "P.C.", "P.T.", "P.O.B.",
"P.V.C.", ...), with the station appended, e.g. "P.C. (145+98.32)". Different jurisdictions use
"P.V.C.", ...), and combined with the alignment name and station to build the Name, e.g.
"MyAlignment 145+98.32 (P.C.)". Different jurisdictions use
different naming systems for these key points -- register_referent_name_callback() lets a
caller override the default horizontal/vertical/cant labeling before calling this function; if
a callback is registered, its output is used here instead of the built-in labels. Referents are
@@ -145,7 +146,7 @@ def update_key_point_referents(
ifcopenshell.api.alignment.register_referent_name_callback(horizontal=my_horizontal_labels)
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
nest = ifcopenshell.api.alignment.update_key_point_referents(model, horizontal)
# nest.RelatedObjects[0].Name starts with "Start (" instead of the default "P.O.B. ("
# nest.RelatedObjects[0].Name ends with "(Start)" instead of the default "(P.O.B.)"
"""
expected_types = ["IfcAlignmentHorizontal", "IfcAlignmentVertical", "IfcAlignmentCant"]
@@ -96,6 +96,10 @@ def callback_alignment():
yield alignment
def _label(name):
return name.rsplit("(", 1)[1].rstrip(")")
def test_with_default_names(default_names_alignment):
file = default_names_alignment.file
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(default_names_alignment)
@@ -107,8 +111,8 @@ def test_with_default_names(default_names_alignment):
expected_h = ["P.O.B.", "P.C.", "P.T.", "P.C.", "P.T.", "P.C.", "P.T.", "P.O.E."]
expected_v = ["V.P.O.B.", "P.V.C.", "P.V.T.", "P.V.C.", "P.V.T.", "P.V.C.", "P.V.T.", "P.V.C.", "P.V.T.", "V.P.O.E."]
assert [r.Name.split(" (")[0] for r in h_nest.RelatedObjects] == expected_h
assert [r.Name.split(" (")[0] for r in v_nest.RelatedObjects] == expected_v
assert [_label(r.Name) for r in h_nest.RelatedObjects] == expected_h
assert [_label(r.Name) for r in v_nest.RelatedObjects] == expected_v
def test_with_callbacks(callback_alignment):
@@ -122,7 +126,7 @@ def test_with_callbacks(callback_alignment):
expected_h = ["A", "Q", "Q", "Q", "Q", "Q", "Q", "Z"]
expected_v = ["a", "q", "q", "q", "q", "q", "q", "q", "q", "z"]
assert [r.Name.split(" (")[0] for r in h_nest.RelatedObjects] == expected_h
assert [r.Name.split(" (")[0] for r in v_nest.RelatedObjects] == expected_v
assert [_label(r.Name) for r in h_nest.RelatedObjects] == expected_h
assert [_label(r.Name) for r in v_nest.RelatedObjects] == expected_v
ifcopenshell.api.alignment.register_referent_name_callback(None, None, None) # reset global state
@@ -0,0 +1,279 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
#
# 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 <http://www.gnu.org/licenses/>.
import pytest
import ifcopenshell.api.alignment
import ifcopenshell.api.context
import ifcopenshell.api.unit
import ifcopenshell.util.alignment
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]
def _new_file():
file = ifcopenshell.file(schema="IFC4X3")
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")
ifcopenshell.api.context.add_context(
file,
context_type="Model",
context_identifier="Axis",
target_view="MODEL_VIEW",
parent=geometric_representation_context,
)
return file
def _new_file_no_context():
file = ifcopenshell.file(schema="IFC4X3")
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])
return file
def _build_alignment(file, start_station=0.0):
return ifcopenshell.api.alignment.create_by_pi_method(
file, "TestAlignment", COORDINATES, RADII, VPOINTS, LENGTHS, start_station
)
def _real_segments(layout):
segments = ifcopenshell.api.alignment.get_layout_segments(layout)
return segments[:-1] if ifcopenshell.api.alignment.has_zero_length_segment(layout) else segments
def _label(tag):
return tag.rsplit("(", 1)[1].rstrip(")")
def test_wrong_layout_type_raises_type_error():
file = _new_file()
alignment = _build_alignment(file)
with pytest.raises(TypeError):
ifcopenshell.api.alignment.update_alignment_parameter_segment_tags(file, alignment)
def test_not_nested_under_alignment_raises_value_error():
file = _new_file_no_context()
horizontal = file.createIfcAlignmentHorizontal(GlobalId=ifcopenshell.guid.new())
with pytest.raises(ValueError):
ifcopenshell.api.alignment.update_alignment_parameter_segment_tags(file, horizontal)
def test_returns_none():
file = _new_file()
alignment = _build_alignment(file)
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
result = ifcopenshell.api.alignment.update_alignment_parameter_segment_tags(file, horizontal)
assert result is None
def test_no_referents_or_rel_nests_created():
file = _new_file()
alignment = _build_alignment(file)
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
referents_before = len(file.by_type("IfcReferent"))
rel_nests_before = len(file.by_type("IfcRelNests"))
ifcopenshell.api.alignment.update_alignment_parameter_segment_tags(file, horizontal)
assert len(file.by_type("IfcReferent")) == referents_before
assert len(file.by_type("IfcRelNests")) == rel_nests_before
def test_no_real_segments_leaves_tags_none():
file = _new_file_no_context()
alignment = ifcopenshell.api.alignment.create(file, "A1", include_geometry=False)
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
result = ifcopenshell.api.alignment.update_alignment_parameter_segment_tags(file, horizontal)
assert result is None
segments = ifcopenshell.api.alignment.get_layout_segments(horizontal)
assert len(segments) == 1 # only the auto zero-length segment
assert segments[0].DesignParameters.StartTag is None
assert segments[0].DesignParameters.EndTag is None
def test_single_real_segment_produces_only_boundary_tags():
file = _new_file_no_context()
alignment = ifcopenshell.api.alignment.create(file, "A1", include_geometry=False)
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
design_parameters = file.createIfcAlignmentHorizontalSegment(
StartTag=None,
EndTag=None,
StartPoint=file.createIfcCartesianPoint((0.0, 0.0)),
StartDirection=0.0,
StartRadiusOfCurvature=0.0,
EndRadiusOfCurvature=0.0,
SegmentLength=100.0,
GravityCenterLineHeight=None,
PredefinedType="LINE",
)
ifcopenshell.api.alignment.create_layout_segment(file, horizontal, design_parameters)
ifcopenshell.api.alignment.update_alignment_parameter_segment_tags(file, horizontal)
segments = _real_segments(horizontal)
assert len(segments) == 1
dp = segments[0].DesignParameters
assert _label(dp.StartTag) == "P.O.B."
assert _label(dp.EndTag) == "P.O.E."
def test_horizontal_tag_labels_and_adjacency():
file = _new_file()
alignment = _build_alignment(file)
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
ifcopenshell.api.alignment.update_alignment_parameter_segment_tags(file, horizontal)
segments = _real_segments(horizontal)
assert len(segments) == 7
start_labels = [_label(s.DesignParameters.StartTag) for s in segments]
end_labels = [_label(s.DesignParameters.EndTag) for s in segments]
assert start_labels == ["P.O.B.", "P.C.", "P.T.", "P.C.", "P.T.", "P.C.", "P.T."]
assert end_labels == ["P.C.", "P.T.", "P.C.", "P.T.", "P.C.", "P.T.", "P.O.E."]
# every real segment has both tags set
assert all(s.DesignParameters.StartTag is not None for s in segments)
assert all(s.DesignParameters.EndTag is not None for s in segments)
# adjacent segments agree on the tag describing their shared transition point
for i in range(len(segments) - 1):
assert segments[i].DesignParameters.EndTag == segments[i + 1].DesignParameters.StartTag
def test_vertical_tag_labels_and_adjacency():
file = _new_file()
alignment = _build_alignment(file)
vertical = ifcopenshell.api.alignment.get_vertical_layout(alignment)
ifcopenshell.api.alignment.update_alignment_parameter_segment_tags(file, vertical)
segments = _real_segments(vertical)
assert len(segments) == 9
start_labels = [_label(s.DesignParameters.StartTag) for s in segments]
end_labels = [_label(s.DesignParameters.EndTag) for s in segments]
assert start_labels == [
"V.P.O.B.",
"P.V.C.",
"P.V.T.",
"P.V.C.",
"P.V.T.",
"P.V.C.",
"P.V.T.",
"P.V.C.",
"P.V.T.",
]
assert end_labels == [
"P.V.C.",
"P.V.T.",
"P.V.C.",
"P.V.T.",
"P.V.C.",
"P.V.T.",
"P.V.C.",
"P.V.T.",
"V.P.O.E.",
]
assert all(s.DesignParameters.StartTag is not None for s in segments)
assert all(s.DesignParameters.EndTag is not None for s in segments)
for i in range(len(segments) - 1):
assert segments[i].DesignParameters.EndTag == segments[i + 1].DesignParameters.StartTag
def test_cant_layout_boundary_tags():
file = _new_file_no_context()
alignment = ifcopenshell.api.alignment.create(file, "A1", include_cant=True, include_geometry=False)
cant = ifcopenshell.api.alignment.get_cant_layout(alignment)
dp1 = file.createIfcAlignmentCantSegment(
StartDistAlong=0.0,
HorizontalLength=100.0,
StartCantLeft=0.0,
EndCantLeft=0.0,
StartCantRight=0.0,
EndCantRight=0.0,
PredefinedType="CONSTANTCANT",
)
ifcopenshell.api.alignment.create_layout_segment(file, cant, dp1)
dp2 = file.createIfcAlignmentCantSegment(
StartDistAlong=100.0,
HorizontalLength=50.0,
StartCantLeft=0.0,
EndCantLeft=0.0,
StartCantRight=0.0,
EndCantRight=0.0,
PredefinedType="CONSTANTCANT",
)
ifcopenshell.api.alignment.create_layout_segment(file, cant, dp2)
ifcopenshell.api.alignment.update_alignment_parameter_segment_tags(file, cant)
segments = _real_segments(cant)
assert _label(segments[0].DesignParameters.StartTag) == "C.P.O.B."
assert _label(segments[-1].DesignParameters.EndTag) == "C.P.O.E."
# CONSTANTCANT -> CONSTANTCANT is currently an unfilled "xx" placeholder in the cant lookup
# table (_get_segment_start_point_label.py) -- out of scope to fill in here.
assert _label(segments[0].DesignParameters.EndTag) == "xx"
assert _label(segments[-1].DesignParameters.StartTag) == "xx"
def test_exact_tag_format():
file = _new_file()
alignment = _build_alignment(file)
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
ifcopenshell.api.alignment.update_alignment_parameter_segment_tags(file, horizontal)
start_station = ifcopenshell.api.alignment.get_alignment_start_station(file, alignment)
segments = _real_segments(horizontal)
assert segments[0].DesignParameters.StartTag == (
f"{ifcopenshell.util.alignment.station_as_string(file, start_station)} (P.O.B.)"
)
test_wrong_layout_type_raises_type_error()
test_not_nested_under_alignment_raises_value_error()
test_returns_none()
test_no_referents_or_rel_nests_created()
test_no_real_segments_leaves_tags_none()
test_single_real_segment_produces_only_boundary_tags()
test_horizontal_tag_labels_and_adjacency()
test_vertical_tag_labels_and_adjacency()
test_cant_layout_boundary_tags()
test_exact_tag_format()
@@ -66,6 +66,10 @@ def _pset_station(referent):
return ifcopenshell.util.element.get_pset(referent, name="Pset_Stationing", prop="Station")
def _label(name):
return name.rsplit("(", 1)[1].rstrip(")")
def test_wrong_layout_type_raises_type_error():
file = _new_file()
alignment = _build_alignment(file)
@@ -181,7 +185,7 @@ def test_default_horizontal_labels_and_order():
nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
expected = ["P.O.B.", "P.C.", "P.T.", "P.C.", "P.T.", "P.C.", "P.T.", "P.O.E."]
assert [r.Name.split(" (")[0] for r in nest.RelatedObjects] == expected
assert [_label(r.Name) for r in nest.RelatedObjects] == expected
stations = [_pset_station(r) for r in nest.RelatedObjects]
assert stations == sorted(stations)
@@ -207,7 +211,7 @@ def test_default_vertical_labels_and_order():
"P.V.T.",
"V.P.O.E.",
]
assert [r.Name.split(" (")[0] for r in nest.RelatedObjects] == expected
assert [_label(r.Name) for r in nest.RelatedObjects] == expected
segments = ifcopenshell.api.alignment.get_layout_segments(vertical)
real_segments = segments[:-1] if ifcopenshell.api.alignment.has_zero_length_segment(vertical) else segments
@@ -224,7 +228,7 @@ def test_name_format():
nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
referent = nest.RelatedObjects[0]
station = _pset_station(referent)
assert referent.Name == f"P.O.B. ({ifcopenshell.util.alignment.station_as_string(file, station)})"
assert referent.Name == f"{alignment.Name} {ifcopenshell.util.alignment.station_as_string(file, station)} (P.O.B.)"
def test_geometric_placement_when_layout_has_representation():
@@ -292,7 +296,7 @@ def test_cant_layout_boundary_labels():
nest = ifcopenshell.api.alignment.update_key_point_referents(file, cant)
labels = [r.Name.split(" (")[0] for r in nest.RelatedObjects]
labels = [_label(r.Name) for r in nest.RelatedObjects]
assert labels[0] == "C.P.O.B."
assert labels[-1] == "C.P.O.E."
# CONSTANTCANT -> CONSTANTCANT is currently an unfilled "xx" placeholder in the cant lookup
@@ -331,7 +335,7 @@ def test_single_real_segment_produces_only_boundary_labels():
ifcopenshell.api.alignment.create_layout_segment(file, horizontal, design_parameters)
nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
labels = [r.Name.split(" (")[0] for r in nest.RelatedObjects]
labels = [_label(r.Name) for r in nest.RelatedObjects]
assert labels == ["P.O.B.", "P.O.E."]