diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/__init__.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/__init__.py index 7914f982ba..56a158842c 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/alignment/__init__.py +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/__init__.py @@ -91,6 +91,7 @@ from .layout_vertical_alignment_by_pi_method import ( from .name_segments import name_segments 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 from .util import * __all__ = [ @@ -133,5 +134,6 @@ __all__ = [ "register_referent_name_callback", "update_end_point", "update_fallback_position", + "update_key_point_referents", "get_mapped_segments", ] diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/_get_segment_start_point_label.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/_get_segment_start_point_label.py index db162d7d33..32dc5aa75d 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/alignment/_get_segment_start_point_label.py +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/_get_segment_start_point_label.py @@ -26,9 +26,10 @@ _cant_callback = None def register_referent_name_callback(horizontal=None, vertical=None, cant=None): """ - Referents are automatically created at the start of each horizontal, vertical, and cant segment. - The referents represent key points in the alignment layout such as Point of Curvature, Point of Tangent, and others. - Different juristicions use different naming systems for these key points. + Referents are created at the start of each horizontal, vertical, and cant segment by + ifcopenshell.api.alignment.update_key_point_referents. The referents represent key points in the + alignment layout such as Point of Curvature, Point of Tangent, and others. Different + juristicions use different naming systems for these key points. The referent name callback functions provide a customizable method for naming these referents. If a callback is registered, it is called when creating the referent name, otherwise the default naming is used. @@ -39,8 +40,8 @@ def register_referent_name_callback(horizontal=None, vertical=None, cant=None): The callback function returns a string that is used in the referent name for the referent at the start of `segment`. The callback must accomodate the following cases: - * prev_segment = None and segment != None - this indicates the last segment so the "End of Alignment" name is returned - * prev_segment != None and segment == None - this indicates the first segment so the "Beginning of Alignment" name is returned + * prev_segment = None and segment != None - this indicates the first segment so the "Beginning of Alignment" name is returned + * prev_segment != None and segment == None - this indicates the last segment so the "End of Alignment" name is returned * prev_segment != None and segment != None - this indicates an intermediate segment so a name representitive of the transition is returned Setting any or all of the callbacks to None causes the default naming to be used. diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/_sort_nest.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/_sort_nest.py new file mode 100644 index 0000000000..eff06eebb2 --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/_sort_nest.py @@ -0,0 +1,27 @@ +# 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 . + +from typing import Callable + +from ifcopenshell import entity_instance + + +def _sort_nest(nest: entity_instance, key: Callable) -> entity_instance: + """Sorts the RelatedObjects of an IfcRelNests in place, by an arbitrary key function.""" + nest.RelatedObjects = sorted(nest.RelatedObjects, key=key) + return nest diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/add_stationing_referent.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/add_stationing_referent.py index b3bc758dd3..19f13ca904 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/alignment/add_stationing_referent.py +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/add_stationing_referent.py @@ -20,6 +20,7 @@ from typing import Optional import ifcopenshell import ifcopenshell.api.alignment +from ifcopenshell.api.alignment._sort_nest import _sort_nest from ifcopenshell.api.alignment.update_fallback_position import update_fallback_position import ifcopenshell.api.pset import ifcopenshell.guid @@ -122,8 +123,6 @@ def add_stationing_referent( else: nest.RelatedObjects += (referent,) - nest.RelatedObjects = sorted( - nest.RelatedObjects, key=lambda x: ifcopenshell.util.element.get_pset(x, name="Pset_Stationing", prop="Station") - ) + _sort_nest(nest, key=lambda x: ifcopenshell.util.element.get_pset(x, name="Pset_Stationing", prop="Station")) return referent diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/update_key_point_referents.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/update_key_point_referents.py new file mode 100644 index 0000000000..b339942bda --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/update_key_point_referents.py @@ -0,0 +1,222 @@ +# 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 . + +from typing import Optional + +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_segment_start_point_label import ( + _get_segment_start_point_label, +) +from ifcopenshell.api.alignment._sort_nest import _sort_nest +from ifcopenshell.api.alignment.update_fallback_position import update_fallback_position + + +def _get_key_point_referent_nest(layout: entity_instance) -> Optional[entity_instance]: + """ + Searches layout.IsNestedBy for the IfcRelNests whose RelatedObjects are IfcReferent. + + This is distinct from both get_stationing_nest (scoped to the parent IfcAlignment, and + specifically the STATION/station-equation nest) and get_alignment_segment_nest (the *segment* + nest that also lives on layout.IsNestedBy, holding IfcAlignmentSegment, never IfcReferent). + """ + for nest in layout.IsNestedBy: + for related_object in nest.RelatedObjects: + if related_object.is_a("IfcReferent"): + return nest + return None + + +def _remove_referent(file: ifcopenshell.file, referent: entity_instance) -> None: + """Cleanly deletes a key-point IfcReferent: its Pset_Stationing, its ObjectPlacement (if + exclusively owned by it), and finally the referent itself.""" + for inverse in list(file.get_inverse(referent)): + if inverse.is_a("IfcRelDefinesByProperties"): + ifcopenshell.api.pset.remove_pset(file, product=referent, pset=inverse.RelatingPropertyDefinition) + + object_placement = referent.ObjectPlacement + if object_placement and file.get_total_inverses(object_placement) == 1: + referent.ObjectPlacement = None + ifcopenshell.util.element.remove_deep2(file, object_placement) + + file.remove(referent) # also strips referent out of any IfcRelNests.RelatedObjects referencing it + + +def _create_key_point_referent( + file: ifcopenshell.file, + alignment: entity_instance, + curve: Optional[entity_instance], + label: str, + distance_along: float, + station: float, +) -> entity_instance: + if curve and curve.is_a("IfcCompositeCurve") and 0 < len(curve.Segments): + object_placement = file.createIfcLinearPlacement( + RelativePlacement=file.createIfcAxis2PlacementLinear( + Location=file.createIfcPointByDistanceExpression( + DistanceAlong=file.createIfcLengthMeasure(distance_along), + OffsetLateral=None, + OffsetVertical=None, + OffsetLongitudinal=None, + BasisCurve=curve, + ) + ), + ) + update_fallback_position(file, object_placement) + else: + object_placement = file.createIfcLocalPlacement( + PlacementRelTo=None, + RelativePlacement=file.createIfcAxis2Placement2D( + Location=file.createIfcCartesianPoint(alignment.ObjectPlacement.RelativePlacement.Location.Coordinates) + ), + ) + + name = f"{label} ({ifcopenshell.util.alignment.station_as_string(file, station)})" + + referent = file.createIfcReferent( + GlobalId=ifcopenshell.guid.new(), + OwnerHistory=None, + Name=name, + Description=None, + ObjectType=None, + ObjectPlacement=object_placement, + Representation=None, + PredefinedType="POSITION", + ) + + pset_stationing = ifcopenshell.api.pset.add_pset(file, product=referent, name="Pset_Stationing") + ifcopenshell.api.pset.edit_pset(file, pset=pset_stationing, properties={"Station": station}) + + return referent + + +def update_key_point_referents( + file: ifcopenshell.file, + layout: entity_instance, + rel_nests: Optional[entity_instance] = None, + clear: bool = False, +) -> entity_instance: + """ + 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 + 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 + nested to `rel_nests`, an IfcRelNests distinct from the layout's segment nest (found via + get_alignment_segment_nest) and from the alignment's stationing nest (found via + get_stationing_nest) -- key-point referents never belong in either of those. + + :param layout: IfcAlignmentHorizontal, IfcAlignmentVertical, or IfcAlignmentCant + :param rel_nests: an existing IfcRelNests to (re)populate. May live anywhere (e.g. the parent + IfcAlignment, the layout, or elsewhere) -- the caller decides. If omitted, an existing + referent-nest already on `layout` is reused, or a new one is created and related to `layout`. + :param clear: if True, deletes all IfcReferent currently in rel_nests.RelatedObjects (and their + Pset_Stationing) before regenerating. If False (default), new referents are appended to + whatever already exists -- no deduplication. + :return: the IfcRelNests, with RelatedObjects sorted ascending by Pset_Stationing.Station + + Example: + + .. code:: python + + horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment) + nest = ifcopenshell.api.alignment.update_key_point_referents(model, horizontal) + + Example, with custom labels for a jurisdiction that doesn't use the built-in abbreviations: + + .. code:: python + + def my_horizontal_labels(prev_segment, segment): + if prev_segment is None: + return "Start" + if segment is None: + return "End" + return "Curve Point" # a name representative of the prev_segment -> segment transition + + 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. (" + """ + + 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()}" + ) + + if rel_nests is None: + rel_nests = _get_key_point_referent_nest(layout) + if rel_nests is None: + rel_nests = file.createIfcRelNests( + GlobalId=ifcopenshell.guid.new(), RelatingObject=layout, RelatedObjects=() + ) + + if clear: + for referent in list(rel_nests.RelatedObjects): + _remove_referent(file, referent) + rel_nests.RelatedObjects = () + + 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: + _sort_nest( + rel_nests, key=lambda x: ifcopenshell.util.element.get_pset(x, name="Pset_Stationing", prop="Station") + ) + return rel_nests + + alignment = ifcopenshell.api.alignment.get_alignment(layout) + start_station = ifcopenshell.api.alignment.get_alignment_start_station(file, alignment) + curve = ifcopenshell.api.alignment.get_layout_curve(layout) + is_horizontal = layout.is_a("IfcAlignmentHorizontal") + + new_referents = [] + 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 + new_referents.append(_create_key_point_referent(file, alignment, curve, label, seg_distance_along, station)) + + 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 + new_referents.append(_create_key_point_referent(file, alignment, curve, label, distance_along, station)) + + rel_nests.RelatedObjects = tuple(rel_nests.RelatedObjects) + tuple(new_referents) + _sort_nest(rel_nests, key=lambda x: ifcopenshell.util.element.get_pset(x, name="Pset_Stationing", prop="Station")) + + return rel_nests diff --git a/src/ifcopenshell-python/test/api/alignment/test_referent_names.py b/src/ifcopenshell-python/test/api/alignment/test_referent_names.py index d64ea9a3ec..856e1ad197 100644 --- a/src/ifcopenshell-python/test/api/alignment/test_referent_names.py +++ b/src/ifcopenshell-python/test/api/alignment/test_referent_names.py @@ -97,16 +97,32 @@ def callback_alignment(): def test_with_default_names(default_names_alignment): - referent_nest = ifcopenshell.api.alignment.get_referent_nest(None, default_names_alignment) + file = default_names_alignment.file + horizontal = ifcopenshell.api.alignment.get_horizontal_layout(default_names_alignment) + vertical = ifcopenshell.api.alignment.get_vertical_layout(default_names_alignment) - expected = ["P.O.B", "P.C.", "P.T.", "P.O.E.", "V.P.O.B.", "P.V.C.", "P.V.T.", "V.P.O.E"] - for r in referent_nest.RelatedObjects: - assert [x in r.Name for x in expected] + h_nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal) + v_nest = ifcopenshell.api.alignment.update_key_point_referents(file, vertical) + + 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 def test_with_callbacks(callback_alignment): - referent_nest = ifcopenshell.api.alignment.get_referent_nest(None, callback_alignment) + file = callback_alignment.file + horizontal = ifcopenshell.api.alignment.get_horizontal_layout(callback_alignment) + vertical = ifcopenshell.api.alignment.get_vertical_layout(callback_alignment) - expected = ["A", "Q", "Z", "a", "q", "z"] - for r in referent_nest.RelatedObjects: - assert [x in r.Name for x in expected] + h_nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal) + v_nest = ifcopenshell.api.alignment.update_key_point_referents(file, vertical) + + 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 + + ifcopenshell.api.alignment.register_referent_name_callback(None, None, None) # reset global state diff --git a/src/ifcopenshell-python/test/api/alignment/test_update_key_point_referents.py b/src/ifcopenshell-python/test/api/alignment/test_update_key_point_referents.py new file mode 100644 index 0000000000..f58dc67443 --- /dev/null +++ b/src/ifcopenshell-python/test/api/alignment/test_update_key_point_referents.py @@ -0,0 +1,372 @@ +# 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 . + +from collections import Counter + +import pytest + +import ifcopenshell.api.alignment +import ifcopenshell.api.context +import ifcopenshell.api.unit +import ifcopenshell.util.alignment +import ifcopenshell.util.element + +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 _pset_station(referent): + return ifcopenshell.util.element.get_pset(referent, name="Pset_Stationing", prop="Station") + + +def test_wrong_layout_type_raises_type_error(): + file = _new_file() + alignment = _build_alignment(file) + with pytest.raises(TypeError): + ifcopenshell.api.alignment.update_key_point_referents(file, alignment) + + +def test_default_rel_nests_created_when_none_provided(): + file = _new_file() + alignment = _build_alignment(file) + horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment) + segment_nest = ifcopenshell.api.alignment.get_alignment_segment_nest(horizontal) + + nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal) + + assert nest.is_a("IfcRelNests") + assert nest.RelatingObject == horizontal + assert nest.id() != segment_nest.id() + assert len(nest.RelatedObjects) == 8 + assert all(r.is_a("IfcReferent") for r in nest.RelatedObjects) + + +def test_second_call_without_rel_nests_reuses_existing_nest(): + file = _new_file() + alignment = _build_alignment(file) + horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment) + segment_count_before = len(ifcopenshell.api.alignment.get_alignment_segment_nest(horizontal).RelatedObjects) + + nest1 = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal) + nest2 = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal) + + assert nest1.id() == nest2.id() + assert len(nest2.RelatedObjects) == 16 + segment_count_after = len(ifcopenshell.api.alignment.get_alignment_segment_nest(horizontal).RelatedObjects) + assert segment_count_after == segment_count_before + + +def test_provided_rel_nests_is_used_as_is(): + file = _new_file() + alignment = _build_alignment(file) + horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment) + + # the nest may live anywhere the caller chooses, e.g. hung off the parent IfcAlignment + rel_nests = file.createIfcRelNests(GlobalId=ifcopenshell.guid.new(), RelatingObject=alignment, RelatedObjects=()) + + result = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal, rel_nests=rel_nests) + + assert result.id() == rel_nests.id() + assert result.RelatingObject == alignment + assert len(result.RelatedObjects) == 8 + + +def test_clear_true_removes_old_referents_and_psets(): + file = _new_file() + alignment = _build_alignment(file) + horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment) + + nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal) + old_referent_ids = [r.id() for r in nest.RelatedObjects] + old_pset_ids = [r.IsDefinedBy[0].RelatingPropertyDefinition.id() for r in nest.RelatedObjects] + + nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal, rel_nests=nest, clear=True) + + assert len(nest.RelatedObjects) == 8 + for old_id in old_referent_ids + old_pset_ids: + with pytest.raises(RuntimeError): + file.by_id(old_id) + + +def test_clear_false_appends_without_dedup(): + file = _new_file() + alignment = _build_alignment(file) + horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment) + + nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal) + ifcopenshell.api.alignment.update_key_point_referents(file, horizontal, rel_nests=nest, clear=False) + + assert len(nest.RelatedObjects) == 16 + counts = Counter(r.Name for r in nest.RelatedObjects) + assert len(counts) == 8 + assert all(count == 2 for count in counts.values()) + + +def test_default_horizontal_labels_and_order(): + file = _new_file() + alignment = _build_alignment(file) + horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment) + + 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 + + stations = [_pset_station(r) for r in nest.RelatedObjects] + assert stations == sorted(stations) + assert stations[0] == 0.0 + + +def test_default_vertical_labels_and_order(): + file = _new_file() + alignment = _build_alignment(file) + vertical = ifcopenshell.api.alignment.get_vertical_layout(alignment) + + nest = ifcopenshell.api.alignment.update_key_point_referents(file, vertical) + + expected = [ + "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 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 + # spot check the interior referents' stations against the segments' StartDistAlong directly + for referent, segment in zip(nest.RelatedObjects[1:-1], real_segments[1:]): + assert _pset_station(referent) == pytest.approx(segment.DesignParameters.StartDistAlong) + + +def test_name_format(): + file = _new_file() + alignment = _build_alignment(file) + horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment) + + 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)})" + + +def test_geometric_placement_when_layout_has_representation(): + file = _new_file() + alignment = _build_alignment(file) + horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment) + curve = ifcopenshell.api.alignment.get_layout_curve(horizontal) + + nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal) + + for referent in nest.RelatedObjects: + assert referent.ObjectPlacement.is_a("IfcLinearPlacement") + location = referent.ObjectPlacement.RelativePlacement.Location + assert location.is_a("IfcPointByDistanceExpression") + assert location.BasisCurve == curve + assert referent.ObjectPlacement.CartesianPosition is not None + + first, last = nest.RelatedObjects[0], nest.RelatedObjects[-1] + assert first.ObjectPlacement.RelativePlacement.Location.DistanceAlong.wrappedValue == pytest.approx(0.0) + assert last.ObjectPlacement.RelativePlacement.Location.DistanceAlong.wrappedValue == pytest.approx( + _pset_station(last) + ) + + +def test_fallback_placement_when_layout_has_no_geometry(): + file = _new_file_no_context() + alignment = ifcopenshell.api.alignment.create(file, "A1", include_geometry=False) + horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment) + ifcopenshell.api.alignment.layout_horizontal_alignment_by_pi_method(file, horizontal, COORDINATES, RADII) + + nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal) + + expected_coordinates = alignment.ObjectPlacement.RelativePlacement.Location.Coordinates + for referent in nest.RelatedObjects: + assert referent.ObjectPlacement.is_a("IfcLocalPlacement") + assert referent.ObjectPlacement.RelativePlacement.Location.Coordinates == expected_coordinates + + +def test_cant_layout_boundary_labels(): + 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) + + nest = ifcopenshell.api.alignment.update_key_point_referents(file, cant) + + labels = [r.Name.split(" (")[0] 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 + # table (_get_segment_start_point_label.py) -- out of scope to fill in here. + assert labels[1] == "xx" + + stations = [_pset_station(r) for r in nest.RelatedObjects] + assert stations == [0.0, 100.0, 150.0] + + +def test_no_real_segments_produces_no_referents(): + file = _new_file_no_context() + alignment = ifcopenshell.api.alignment.create(file, "A1", include_geometry=False) + horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment) + + nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal) + assert nest.RelatedObjects == () + + +def test_single_real_segment_produces_only_boundary_labels(): + 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) + + nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal) + labels = [r.Name.split(" (")[0] for r in nest.RelatedObjects] + assert labels == ["P.O.B.", "P.O.E."] + + +def test_start_station_composes_for_child_alignment(): + file = _new_file() + alignment = ifcopenshell.api.alignment.create(file, "A1", include_vertical=False, start_station=100.0) + ifcopenshell.api.alignment.add_vertical_layout(file, alignment) + ifcopenshell.api.alignment.add_vertical_layout(file, alignment) # forces the child-alignment split + + child_alignment = alignment.IsDecomposedBy[0].RelatedObjects[-1] + child_vertical = ifcopenshell.api.alignment.get_vertical_layout(child_alignment) + + dp1 = file.createIfcAlignmentVerticalSegment( + StartDistAlong=0.0, + HorizontalLength=500.0, + StartHeight=10.0, + StartGradient=0.01, + EndGradient=0.01, + PredefinedType="CONSTANTGRADIENT", + ) + ifcopenshell.api.alignment.create_layout_segment(file, child_vertical, dp1) + + dp2 = file.createIfcAlignmentVerticalSegment( + StartDistAlong=500.0, + HorizontalLength=300.0, + StartHeight=15.0, + StartGradient=0.01, + EndGradient=0.01, + PredefinedType="CONSTANTGRADIENT", + ) + ifcopenshell.api.alignment.create_layout_segment(file, child_vertical, dp2) + + nest = ifcopenshell.api.alignment.update_key_point_referents(file, child_vertical) + stations = [_pset_station(r) for r in nest.RelatedObjects] + assert stations == pytest.approx([100.0, 600.0, 900.0]) + + +def test_returns_ifc_rel_nests(): + file = _new_file() + alignment = _build_alignment(file) + horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment) + + result = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal) + assert result.is_a("IfcRelNests") + + +test_wrong_layout_type_raises_type_error() +test_default_rel_nests_created_when_none_provided() +test_second_call_without_rel_nests_reuses_existing_nest() +test_provided_rel_nests_is_used_as_is() +test_clear_true_removes_old_referents_and_psets() +test_clear_false_appends_without_dedup() +test_default_horizontal_labels_and_order() +test_default_vertical_labels_and_order() +test_name_format() +test_geometric_placement_when_layout_has_representation() +test_fallback_placement_when_layout_has_no_geometry() +test_cant_layout_boundary_labels() +test_no_real_segments_produces_no_referents() +test_single_real_segment_produces_only_boundary_labels() +test_start_station_composes_for_child_alignment() +test_returns_ifc_rel_nests()