From 80cc603932b65490e80196504c5cda499e7619be Mon Sep 17 00:00:00 2001 From: Richard Brice <37087370+RickBrice@users.noreply.github.com> Date: Sat, 1 Aug 2026 12:32:07 -0700 Subject: [PATCH 1/7] alignment: rename get_referent_nest to get_stationing_nest --- .../ifcopenshell/api/alignment/__init__.py | 4 +-- .../api/alignment/add_stationing_referent.py | 2 +- .../api/alignment/create_representation.py | 26 +++++++++---------- .../alignment/distance_along_from_station.py | 6 ++--- ...eferent_nest.py => get_stationing_nest.py} | 12 ++++++--- .../alignment/test_add_segment_to_layout.py | 8 +++--- .../test_add_stationing_to_alignment.py | 10 +++---- .../alignment/test_add_vertical_alignment.py | 6 ++--- .../api/alignment/test_create_by_pi_method.py | 4 +-- .../test_horizontal_layout_by_pi_method.py | 4 +-- .../test_vertical_layout_by_pi_method.py | 4 +-- 11 files changed, 46 insertions(+), 40 deletions(-) rename src/ifcopenshell-python/ifcopenshell/api/alignment/{get_referent_nest.py => get_stationing_nest.py} (64%) diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/__init__.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/__init__.py index d364c8fc91..7914f982ba 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/alignment/__init__.py +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/__init__.py @@ -79,7 +79,7 @@ from .get_layout_curve import get_layout_curve from .get_layout_segments import get_layout_segments from .get_mapped_segments import get_mapped_segments from .get_parent_alignment import get_parent_alignment -from .get_referent_nest import get_referent_nest +from .get_stationing_nest import get_stationing_nest from .get_vertical_layout import get_vertical_layout from .has_zero_length_segment import has_zero_length_segment from .layout_horizontal_alignment_by_pi_method import ( @@ -124,7 +124,7 @@ __all__ = [ "get_layout_curve", "get_layout_segments", "get_parent_alignment", - "get_referent_nest", + "get_stationing_nest", "get_vertical_layout", "has_zero_length_segment", "layout_horizontal_alignment_by_pi_method", 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 6c81234894..b3bc758dd3 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/alignment/add_stationing_referent.py +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/add_stationing_referent.py @@ -114,7 +114,7 @@ def add_stationing_referent( pset_stationing = ifcopenshell.api.pset.add_pset(file, product=referent, name="Pset_Stationing") ifcopenshell.api.pset.edit_pset(file, pset=pset_stationing, properties=properties) - nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment) + nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment) if nest is None: nest = file.createIfcRelNests( GlobalId=ifcopenshell.guid.new(), RelatingObject=alignment, RelatedObjects=(referent,) diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/create_representation.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/create_representation.py index 2ce48fd965..7771ed1faf 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/alignment/create_representation.py +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/create_representation.py @@ -64,22 +64,22 @@ def create_representation( # if the alignment is created without geometry it's stationing referent isn't related to the alignment geometry. # the stationing referent needs to be updated to have an IfcLinearPlacement that references the basis curve geometry - referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment) + stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment) if ( - referent_nest - and 0 < len(referent_nest.RelatedObjects) - and referent_nest.RelatedObjects[0].ObjectPlacement - and not referent_nest.RelatedObjects[0].ObjectPlacement.is_a("IfcLinearPlacement") + stationing_nest + and 0 < len(stationing_nest.RelatedObjects) + and stationing_nest.RelatedObjects[0].ObjectPlacement + and not stationing_nest.RelatedObjects[0].ObjectPlacement.is_a("IfcLinearPlacement") ): basis_curve = ifcopenshell.api.alignment.get_basis_curve(alignment) - if referent_nest.RelatedObjects[0].ObjectPlacement: - if referent_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement.Location: - file.remove(referent_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement.Location) - if referent_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement.RefDirection: - file.remove(referent_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement.RefDirection) - file.remove(referent_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement) - file.remove(referent_nest.RelatedObjects[0].ObjectPlacement) + if stationing_nest.RelatedObjects[0].ObjectPlacement: + if stationing_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement.Location: + file.remove(stationing_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement.Location) + if stationing_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement.RefDirection: + file.remove(stationing_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement.RefDirection) + file.remove(stationing_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement) + file.remove(stationing_nest.RelatedObjects[0].ObjectPlacement) lp = file.createIfcLinearPlacement( RelativePlacement=file.createIfcAxis2PlacementLinear( @@ -93,4 +93,4 @@ def create_representation( ) ) update_fallback_position(file, lp) - referent_nest.RelatedObjects[0].ObjectPlacement = lp + stationing_nest.RelatedObjects[0].ObjectPlacement = lp diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/distance_along_from_station.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/distance_along_from_station.py index f4db3e4b4e..f719a12b0d 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/alignment/distance_along_from_station.py +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/distance_along_from_station.py @@ -67,8 +67,8 @@ def distance_along_from_station(file: ifcopenshell.file, alignment: entity_insta print(dist_along) # 100.00 """ - referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment) - if referent_nest is None: + stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment) + if stationing_nest is None: start_station = ifcopenshell.api.alignment.get_alignment_start_station(file, alignment) return station - start_station @@ -77,7 +77,7 @@ def distance_along_from_station(file: ifcopenshell.file, alignment: entity_insta _distance_along_of_referent(referent), ifcopenshell.util.element.get_pset(referent, name="Pset_Stationing", prop="Station"), ) - for referent in referent_nest.RelatedObjects + for referent in stationing_nest.RelatedObjects ] stations.sort(key=lambda entry: entry[0]) diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/get_referent_nest.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/get_stationing_nest.py similarity index 64% rename from src/ifcopenshell-python/ifcopenshell/api/alignment/get_referent_nest.py rename to src/ifcopenshell-python/ifcopenshell/api/alignment/get_stationing_nest.py index b67b9589de..e92dda7a81 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/alignment/get_referent_nest.py +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/get_stationing_nest.py @@ -20,12 +20,18 @@ import ifcopenshell from ifcopenshell import entity_instance -def get_referent_nest(file: ifcopenshell.file, alignment: entity_instance) -> entity_instance: +def get_stationing_nest(file: ifcopenshell.file, alignment: entity_instance) -> entity_instance: """ - Searches for the IfcRelNest that contains IfcReferent. + Searches for the IfcRelNests that defines the alignment's stationing scheme. + + The returned nest is nested to the IfcAlignment and its RelatedObjects contains only the + IfcReferent(s) (PredefinedType="STATION") that establish the alignment's starting station and + any station equations along it, as created by add_stationing_referent. It does not contain any + other kind of referent (e.g. key-point referents from update_key_point_referents live in their + own, separate IfcRelNests). :param file: - :param alignment: The IfcAlignment which hosts IfcReferent + :param alignment: The IfcAlignment which hosts the stationing IfcReferent(s) :return: Returns the IfcRelNests or None """ if not alignment.is_a("IfcAlignment"): diff --git a/src/ifcopenshell-python/test/api/alignment/test_add_segment_to_layout.py b/src/ifcopenshell-python/test/api/alignment/test_add_segment_to_layout.py index cb8b158420..d20eecd38c 100644 --- a/src/ifcopenshell-python/test/api/alignment/test_add_segment_to_layout.py +++ b/src/ifcopenshell-python/test/api/alignment/test_add_segment_to_layout.py @@ -39,9 +39,9 @@ def test_add_segment_to_layout(): alignment = ifcopenshell.api.alignment.create(file, "") - referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment) + stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment) assert ( - len(referent_nest.RelatedObjects) == 1 + len(stationing_nest.RelatedObjects) == 1 ) # the alignment creates the stationing nest and it has one referent to defined the stationing for the alignment horizontal_alignment = ifcopenshell.api.alignment.get_horizontal_layout(alignment) @@ -75,8 +75,8 @@ def test_add_segment_to_layout(): assert len(horizontal_alignment.IsNestedBy) == 1 segment_nest = ifcopenshell.api.alignment.get_alignment_segment_nest(horizontal_alignment) assert len(segment_nest.RelatedObjects) == 2 - referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment) - assert len(referent_nest.RelatedObjects) == 1 # test this a second time to make sure that it is still true + stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment) + assert len(stationing_nest.RelatedObjects) == 1 # test this a second time to make sure that it is still true test_add_segment_to_layout() 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 6375ddfe90..3422c3d8cc 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 @@ -39,8 +39,8 @@ def test_add_stationing_to_alignment(): alignment = ifcopenshell.api.alignment.create(file, "TestAlignment", start_station=2000.0) - referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment) - referent = referent_nest.RelatedObjects[0] + stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment) + referent = stationing_nest.RelatedObjects[0] assert referent.PredefinedType == "STATION" assert referent.Name == "2+000.000" @@ -54,10 +54,10 @@ def test_add_stationing_to_alignment(): file, "4+000.000", alignment, distance_along=1000.0, station=4000.0, incoming_station=3000.0 ) - referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment) - assert len(referent_nest.RelatedObjects) == 2 + stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment) + assert len(stationing_nest.RelatedObjects) == 2 - assert second_referent == referent_nest.RelatedObjects[1] + assert second_referent == stationing_nest.RelatedObjects[1] assert second_referent.PredefinedType == "STATION" assert second_referent.Name == "4+000.000" diff --git a/src/ifcopenshell-python/test/api/alignment/test_add_vertical_alignment.py b/src/ifcopenshell-python/test/api/alignment/test_add_vertical_alignment.py index 0b9b76fa45..acc48b8268 100644 --- a/src/ifcopenshell-python/test/api/alignment/test_add_vertical_alignment.py +++ b/src/ifcopenshell-python/test/api/alignment/test_add_vertical_alignment.py @@ -36,11 +36,11 @@ def test_add_vertical_alignment(): layout_nest = ifcopenshell.api.alignment.get_alignment_layout_nest(alignment) assert len(layout_nest.RelatedObjects) == 1 assert layout_nest.RelatedObjects[0].is_a("IfcAlignmentHorizontal") - referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment) + stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment) assert ( - len(referent_nest.RelatedObjects) == 1 + len(stationing_nest.RelatedObjects) == 1 ) # the alignment creates the stationing nest and it has one referent to defined the stationing for the alignment - assert referent_nest.RelatedObjects[0].is_a("IfcReferent") + assert stationing_nest.RelatedObjects[0].is_a("IfcReferent") curve = ifcopenshell.api.alignment.get_curve(alignment) assert curve.is_a("IfcCompositeCurve") diff --git a/src/ifcopenshell-python/test/api/alignment/test_create_by_pi_method.py b/src/ifcopenshell-python/test/api/alignment/test_create_by_pi_method.py index afa309c66d..06794585a3 100644 --- a/src/ifcopenshell-python/test/api/alignment/test_create_by_pi_method.py +++ b/src/ifcopenshell-python/test/api/alignment/test_create_by_pi_method.py @@ -51,8 +51,8 @@ def test_create_by_pi_method(): layout_nest = ifcopenshell.api.alignment.get_alignment_layout_nest(alignment) assert len(layout_nest.RelatedObjects) == 2 - referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment) - assert len(referent_nest.RelatedObjects) == 1 + stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment) + assert len(stationing_nest.RelatedObjects) == 1 horizontal_layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment) horizontal_segment_nest = ifcopenshell.api.alignment.get_alignment_segment_nest(horizontal_layout) diff --git a/src/ifcopenshell-python/test/api/alignment/test_horizontal_layout_by_pi_method.py b/src/ifcopenshell-python/test/api/alignment/test_horizontal_layout_by_pi_method.py index 0864d5a67f..fc18a27c35 100644 --- a/src/ifcopenshell-python/test/api/alignment/test_horizontal_layout_by_pi_method.py +++ b/src/ifcopenshell-python/test/api/alignment/test_horizontal_layout_by_pi_method.py @@ -46,9 +46,9 @@ def test_horizontal_layout_by_pi_method(): assert len(alignment.IsDecomposedBy) == 0 # no child alignments assert len(alignment.IsNestedBy) == 2 - referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment) + stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment) layout_nest = ifcopenshell.api.alignment.get_alignment_layout_nest(alignment) - assert referent_nest.RelatedObjects[0].is_a("IfcReferent") + assert stationing_nest.RelatedObjects[0].is_a("IfcReferent") assert layout_nest.RelatedObjects[0].is_a("IfcAlignmentHorizontal") segment_nest = ifcopenshell.api.alignment.get_alignment_segment_nest(layout_nest.RelatedObjects[0]) assert len(segment_nest.RelatedObjects) == 3 # segments in horizontal layout diff --git a/src/ifcopenshell-python/test/api/alignment/test_vertical_layout_by_pi_method.py b/src/ifcopenshell-python/test/api/alignment/test_vertical_layout_by_pi_method.py index a217008b67..d87a1d1f42 100644 --- a/src/ifcopenshell-python/test/api/alignment/test_vertical_layout_by_pi_method.py +++ b/src/ifcopenshell-python/test/api/alignment/test_vertical_layout_by_pi_method.py @@ -64,8 +64,8 @@ def test_vertical_layout_by_pi_method(): layout_nest = ifcopenshell.api.alignment.get_alignment_layout_nest(alignment) assert len(layout_nest.RelatedObjects) == 2 - referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment) - assert len(referent_nest.RelatedObjects) == 1 + stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment) + assert len(stationing_nest.RelatedObjects) == 1 segment_nest = ifcopenshell.api.alignment.get_alignment_segment_nest(vlayout) assert len(segment_nest.RelatedObjects) == 3 From e077390e3dffd198233a0b21d5df158a849675db Mon Sep 17 00:00:00 2001 From: Richard Brice <37087370+RickBrice@users.noreply.github.com> Date: Sat, 1 Aug 2026 15:24:03 -0700 Subject: [PATCH 2/7] add update_key_point_referents to label key alignment points --- .../ifcopenshell/api/alignment/__init__.py | 2 + .../_get_segment_start_point_label.py | 11 +- .../ifcopenshell/api/alignment/_sort_nest.py | 27 ++ .../api/alignment/add_stationing_referent.py | 5 +- .../alignment/update_key_point_referents.py | 222 +++++++++++ .../test/api/alignment/test_referent_names.py | 32 +- .../test_update_key_point_referents.py | 372 ++++++++++++++++++ 7 files changed, 655 insertions(+), 16 deletions(-) create mode 100644 src/ifcopenshell-python/ifcopenshell/api/alignment/_sort_nest.py create mode 100644 src/ifcopenshell-python/ifcopenshell/api/alignment/update_key_point_referents.py create mode 100644 src/ifcopenshell-python/test/api/alignment/test_update_key_point_referents.py 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() From 6f3acc84eed9d2bd10750cd9b208bd9525e3b41c Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Sun, 2 Aug 2026 14:45:20 +0100 Subject: [PATCH 3/7] ifcmcp: source tool descriptions from ifcquery/ifcedit instead of duplicating them Alternative to #8955, for #8951 (23 of 25 ifcmcp tools reach MCP clients with an empty description because FastMCP reads each wrapper's own __doc__, and the server.py wrappers had none). #8955 fixes this by hand-writing a new docstring directly onto each server.py wrapper. Most of those wrappers are thin passthroughs to IfcSession methods in core.py, which already had short docstrings, which themselves mostly delegate to already-documented ifcquery/ifcedit functions -- so that fix tripled up content across three layers that can drift out of sync. This instead enriches the true source (the ifcquery/ifcedit library functions, useful independently of MCP) and has core.py's IfcSession methods copy __doc__ from their delegate via a small _use_doc() decorator, and server.py's tool registration pull description= from the matching IfcSession method. Methods that aren't pure passthroughs (session lifecycle, generic API/shape dispatch) keep their own hand-written docs. Keeps #8955's regression test. Generated with the assistance of an AI coding tool. --- src/ifcedit/ifcedit/discover.py | 10 +- src/ifcedit/ifcedit/quantify.py | 17 ++- src/ifcmcp/ifcmcp/core.py | 178 +++++++++++++++++++++-------- src/ifcmcp/ifcmcp/server.py | 52 +++++---- src/ifcmcp/tests/test_server.py | 6 + src/ifcquery/ifcquery/clash.py | 19 ++- src/ifcquery/ifcquery/cost.py | 15 ++- src/ifcquery/ifcquery/info.py | 11 +- src/ifcquery/ifcquery/materials.py | 8 +- src/ifcquery/ifcquery/relations.py | 17 ++- src/ifcquery/ifcquery/schedule.py | 16 ++- src/ifcquery/ifcquery/schema.py | 10 +- src/ifcquery/ifcquery/summary.py | 10 +- src/ifcquery/ifcquery/tree.py | 12 +- src/ifcquery/ifcquery/validate.py | 11 +- 15 files changed, 303 insertions(+), 89 deletions(-) diff --git a/src/ifcedit/ifcedit/discover.py b/src/ifcedit/ifcedit/discover.py index b26a93f6c6..76ddc05710 100644 --- a/src/ifcedit/ifcedit/discover.py +++ b/src/ifcedit/ifcedit/discover.py @@ -94,9 +94,15 @@ def list_functions(module: str) -> list[dict]: def function_docs(module: str, function: str) -> dict: - """Full documentation for a single API function. + """Show the full documentation for one ifcopenshell.api function. - Returns a dict with: module, function, description, params (with types/defaults/descriptions), return_type + Returns the summary and long description, every parameter with its type, + default and description, and the return type. Read this before calling + ``run_api()`` so that parameter names and value types are correct. + + :param module: API module name, for example ``'root'``. + :param function: Function name within the module, for example + ``'create_entity'``. """ fn = _get_underlying_function(module, function) if fn is None: diff --git a/src/ifcedit/ifcedit/quantify.py b/src/ifcedit/ifcedit/quantify.py index adc024ca2f..303963d37e 100644 --- a/src/ifcedit/ifcedit/quantify.py +++ b/src/ifcedit/ifcedit/quantify.py @@ -14,10 +14,21 @@ def list_rules() -> list[dict[str, str]]: def run_quantify(model: ifcopenshell.file, rule: str, selector: str | None = None) -> dict[str, Any]: - """Run quantity take-off on the model using the named rule. + """Compute base quantities for elements and write them into the model. - Modifies the model in-place by adding/updating IfcElementQuantity psets. - Returns a summary dict with ok, rule, and elements_quantified. + This is a write operation: it derives lengths, areas and volumes from + element geometry and adds or updates their ``IfcElementQuantity`` sets. + It does not report a schedule — see ``ifcquery.schedule()`` for the + construction programme and ``ifcquery.cost()`` for cost schedules. An + unrecognised ``rule`` is reported as an error listing the rules that are + available. + + :param model: The in-memory IFC model. Modified in-place. + :param rule: Quantity take-off rule set, for example + ``'IFC4QtoBaseQuantities'`` or ``'IFC4X3QtoBaseQuantities'``. + :param selector: ifcopenshell selector restricting which elements are + measured, e.g. ``'IfcWall'``. Omit to measure every ``IfcElement`` and + ``IfcSpace``. """ from ifc5d.qto import edit_qtos, quantify from ifc5d.qto import rules as rule_sets diff --git a/src/ifcmcp/ifcmcp/core.py b/src/ifcmcp/ifcmcp/core.py index 3f249a57bb..a5a44bb26e 100644 --- a/src/ifcmcp/ifcmcp/core.py +++ b/src/ifcmcp/ifcmcp/core.py @@ -35,6 +35,27 @@ from ifcquery import ( from ifcquery import validate as validate_mod +def _use_doc(source: Callable, extra: str = "") -> Callable: + """Decorator: copy `source`'s docstring onto the decorated method. + + Keeps the query/edit logic in ``ifcquery``/``ifcedit`` as the single + source of truth for what a delegating ``IfcSession`` method does, rather + than maintaining a second prose description here. Only ``__doc__`` is + copied — unlike `functools.wraps`, this leaves the method's own signature + (and MCP tool schema derived from it) untouched. + + :param extra: Optional session-specific note appended after `source`'s + docstring, for the handful of methods that translate an argument + (e.g. a JSON/MCP-friendly default) before delegating. + """ + + def decorator(fn: Callable) -> Callable: + fn.__doc__ = (source.__doc__ or "").rstrip() + extra + return fn + + return decorator + + def _jsonify(x: Any) -> Any: """Convert IfcOpenShell objects / iterables into JSON-safe primitives.""" if x is None or isinstance(x, (str, int, float, bool)): @@ -231,20 +252,47 @@ class IfcSession: return self.model def ifc_new(self, schema: str = "IFC4") -> dict[str, Any]: - """Create a new empty IFC model in memory.""" + """Create a new empty IFC model in memory. + + Replaces the model currently held by the session, discarding any unsaved + edits. The new model has no file path of its own, so ``ifc_save`` must be + given an explicit path. + + :param schema: IFC schema version — ``IFC2X3``, ``IFC4``, ``IFC4X1``, + ``IFC4X2`` or ``IFC4X3`` — passed straight to ``ifcopenshell.file()`` + (default ``IFC4``). ``IFC4X3_ADD2`` is also accepted and, like + ``IFC4X3``, produces a model whose ``schema`` reports ``IFC4X3``. + """ self.model = ifcopenshell.file(schema=schema) self.model_path = None return {"ok": True, "schema": self.model.schema, "entities": sum(1 for _ in self.model)} def ifc_load(self, path: str) -> str: - """Open an IFC file into memory. Returns confirmation string.""" + """Open an IFC file from disk into the session. + + Replaces the model currently held by the session, discarding any unsaved + edits, and remembers the path so a later ``ifc_save`` can overwrite it. + Call this before any query or edit method. Returns a confirmation string + naming the schema version and entity count. + + :param path: Filesystem path of the IFC file to open. + """ self.model = ifcopenshell.open(path) self.model_path = path count = sum(1 for _ in self.model) return f"Loaded {path}: schema {self.model.schema}, {count} entities" def ifc_save(self, path: str = "") -> str: - """Write the in-memory model to disk. Empty path overwrites the original file.""" + """Write the in-memory model to disk. + + Overwrites the target file without further confirmation. Edits made by + ``ifc_edit``, ``ifc_shape`` and ``ifc_quantify`` exist only in memory + until this is called. + + :param path: Destination path. Omit to overwrite the file the model was + loaded from; this fails for a model created by ``ifc_new``, which has + no original path. + """ model = self._require_model() target = path if path else self.model_path if not target: @@ -253,7 +301,11 @@ class IfcSession: return f"Saved to {target}" def ifc_reset(self) -> dict[str, Any]: - """Drop the in-memory model.""" + """Discard the in-memory model. + + Drops the model and its file path, throwing away any edits not already + written with ``ifc_save``. Succeeds even when no model is loaded. + """ self.model = None self.model_path = None return {"ok": True} @@ -261,39 +313,42 @@ class IfcSession: # ------------- # Query tools # ------------- + @_use_doc(summary.summary) def ifc_summary(self) -> dict[str, Any]: - """Model overview: schema, entity counts, project info.""" return summary.summary(self._require_model()) + @_use_doc(tree.tree) def ifc_tree(self) -> dict[str, Any] | list[dict[str, Any]]: - """Full spatial hierarchy tree (Project -> Site -> Building -> Storeys -> Elements).""" return tree.tree(self._require_model()) + @_use_doc(info.info) def ifc_info(self, element_id: int) -> dict[str, Any]: - """Deep inspection of an entity by step ID (attributes, psets, placement, type, material).""" model = self._require_model() element = model.by_id(element_id) if element is None: raise IfcSessionError(f"Element #{element_id} not found.") return info.info(model, element) + @_use_doc(select.select) def ifc_select(self, query: str) -> list[dict[str, Any]]: - """Filter elements using ifcopenshell selector syntax. - - Examples: ``IfcWall``, ``IfcWall, IfcColumn``, ``! IfcWall``, - ``IfcWall, Name = "My Wall"``, ``type = "Concrete Wall"``, - ``material = "Concrete"``. - """ return select.select(self._require_model(), query) + @_use_doc(relations.relations) def ifc_relations(self, element_id: int, traverse: str = "") -> dict[str, Any] | list[dict[str, Any]]: - """Show relationships for an element. Set traverse='up' to walk hierarchy to IfcProject.""" model = self._require_model() element = model.by_id(element_id) if element is None: raise IfcSessionError(f"Element #{element_id} not found.") return relations.relations(model, element, traverse=traverse if traverse else None) + @_use_doc( + clash_mod.clash, + extra=( + "\n\nNote: this method takes a plain ``clearance: float`` rather than\n" + '``clearance: float | None`` — ``0.0`` (the default) means "skip the\n' + 'clearance check", matching ``None`` in ``ifcquery.clash.clash()``.' + ), + ) def ifc_clash( self, element_id: int, @@ -301,7 +356,6 @@ class IfcSession: tolerance: float = 0.002, scope: str = "storey", ) -> dict[str, Any]: - """Check element for geometric clashes. clearance=0.0 means no clearance check.""" model = self._require_model() element = model.by_id(element_id) if element is None: @@ -314,33 +368,53 @@ class IfcSession: scope=scope, ) + @_use_doc(contexts_mod.contexts) def ifc_contexts(self) -> list[dict[str, Any]]: - """List all geometric representation contexts and subcontexts with their step IDs.""" return contexts_mod.contexts(self._require_model()) + @_use_doc(materials_mod.materials) def ifc_materials(self) -> list[dict[str, Any]]: - """List all materials and material sets (layers, constituents, profiles).""" return materials_mod.materials(self._require_model()) # ------------------------ # Edit discovery + execute # ------------------------ def ifc_list(self, module: str = "") -> list[dict]: - """List all API modules, or functions within a module. Empty module = all modules.""" + """Discover the ifcopenshell.api functions available for editing. + + With no argument returns every API module with its description, + function names and function count. With a module name returns that + module's functions, each with a one-line description and its + parameters. This is the starting point for ``ifc_docs`` and + ``ifc_edit``; it inspects the installed ifcopenshell package and works + without a model loaded. + + :param module: API module name, for example ``'root'``, ``'geometry'`` + or ``'pset'``. Omit to list all modules. + """ return list_functions(module) if module else list_modules() + @_use_doc(function_docs) def ifc_docs(self, function_path: str) -> dict: - """Show full documentation for an API function. Input format: 'module.function'.""" module, function = function_path.split(".", 1) return function_docs(module, function) def ifc_edit(self, function_path: str, params: Any = "{}") -> dict: - """Execute an ifcopenshell.api mutation. + """Run an ifcopenshell.api function to modify the model. - params may be: - - JSON string - - dict (from tool calling / JS) - - JsProxy (handled upstream in embedded.py) + This is the general-purpose edit method; use ``ifc_list`` and + ``ifc_docs`` first to find the function and its parameters. Changes + are made to the in-memory model only, so ``ifc_save`` is needed to + persist them. Returns ``{"ok": True, "result": ...}``, or + ``{"ok": False, "error": ...}`` when the function is unknown, a + parameter cannot be converted, or the call raises. + + :param function_path: ``'module.function'``, for example + ``'root.create_entity'``. + :param params: Keyword arguments as a JSON string, a dict (tool + calling) or a JsProxy (handled upstream in embedded.py). Pass + entity references as integer step IDs, and arguments typed as an + IFC file as a file path string. """ model = self._require_model() module, function = function_path.split(".", 1) @@ -359,28 +433,20 @@ class IfcSession: # ------------------------ # Extended query + edit tools # ------------------------ + @_use_doc(validate_mod.validate) def ifc_validate(self, express_rules: bool = False) -> dict[str, Any]: - """Validate the loaded model. Returns {'valid': bool, 'issues': [...]}.""" return validate_mod.validate(self._require_model(), express_rules=express_rules) + @_use_doc(schedule.schedule) def ifc_schedule(self, max_depth: int | None = None) -> list[dict[str, Any]]: - """List work schedules and nested tasks from the model. - - max_depth limits subtask expansion (None = unlimited). At the cutoff, - subtasks is replaced with {"truncated": True, "count": N}. - """ return schedule.schedule(self._require_model(), max_depth=max_depth) + @_use_doc(cost_mod.cost) def ifc_cost(self, max_depth: int | None = None) -> list[dict[str, Any]]: - """List cost schedules and nested cost items from the model. - - max_depth limits cost item expansion (None = unlimited). At the cutoff, - subitems is replaced with {"truncated": True, "count": N}. - """ return cost_mod.cost(self._require_model(), max_depth=max_depth) + @_use_doc(schema.schema) def ifc_schema(self, entity_type: str) -> dict[str, Any]: - """Return IFC class documentation for entity_type using the model's schema version.""" return schema.schema(self._require_model(), entity_type) def ifc_plot( @@ -456,18 +522,43 @@ class IfcSession: # Shape builder tools # ------------------------ def ifc_shape_list(self) -> list[dict]: - """List all ShapeBuilder geometry methods with one-line descriptions and parameter names.""" + """List the ShapeBuilder methods available for constructing geometry. + + Returns every public ``ifcopenshell.util.shape_builder.ShapeBuilder`` + method with a one-line description and its parameter names, read + directly from that class's own docstrings. Use it to find a method, + then ``ifc_shape_docs`` for the details and ``ifc_shape`` to call it. + Works without a model loaded. + """ return _list_shape_methods() def ifc_shape_docs(self, method: str) -> dict: - """Full documentation for a ShapeBuilder method: params, types, return value.""" + """Show the full documentation for one ShapeBuilder method. + + Returns the summary and long description, every parameter with its + type and default, and the return type — read directly from + ``ShapeBuilder``'s own docstring. Read this before ``ifc_shape`` so + that argument names and value shapes are correct. Works without a + model loaded. + + :param method: ShapeBuilder method name, for example ``'polyline'``, + ``'rectangle'`` or ``'extrude'``. + """ return _shape_method_docs(method) def ifc_shape(self, method: str, params: Any = "{}") -> dict: - """Call a ShapeBuilder method by name. Returns the created entity's step ID. + """Call a ShapeBuilder method to build geometry in the model. - params is a JSON string of keyword arguments. Pass entity references as integer - step IDs; vectors as JSON arrays (e.g. [1.0, 0.0, 0.0]). + The created entities are added to the in-memory model, so + ``ifc_save`` is needed to persist them. On success the result + identifies the created entity by step ID and type; an unknown method + or a failed call is reported as an error instead. + + :param method: ShapeBuilder method name, as listed by + ``ifc_shape_list``. + :param params: JSON string of keyword arguments. Pass entity + references as integer step IDs and vectors as JSON arrays, e.g. + ``[1.0, 0.0, 0.0]``. """ model = self._require_model() @@ -493,11 +584,8 @@ class IfcSession: except Exception as e: return {"ok": False, "error": f"{type(e).__name__}: {e}"} + @_use_doc(run_quantify, extra="\n\nCall ``ifc_save`` afterwards to persist the result.") def ifc_quantify(self, rule: str, selector: str = "") -> dict[str, Any]: - """Run quantity take-off on the model using the named rule. - - Modifies the model in-place; call ifc_save() after. - """ model = self._require_model() return run_quantify(model, rule, selector=selector if selector else None) diff --git a/src/ifcmcp/ifcmcp/server.py b/src/ifcmcp/ifcmcp/server.py index 202333b2ac..91a207486f 100644 --- a/src/ifcmcp/ifcmcp/server.py +++ b/src/ifcmcp/ifcmcp/server.py @@ -2,6 +2,7 @@ from __future__ import annotations import base64 +import inspect from typing import Any from ifcmcp.core import IfcSession @@ -23,6 +24,11 @@ def build_server() -> Any: session = IfcSession() + def _tool(fn): + """Register a tool, taking its MCP description from the identically-named + IfcSession method rather than duplicating it here.""" + return server.tool(description=inspect.getdoc(getattr(IfcSession, fn.__name__)))(fn) + server = FastMCP( name="ifc-mcp", instructions=( @@ -33,44 +39,44 @@ def build_server() -> Any: ) # ---- Lifecycle ---- - @server.tool() + @_tool def ifc_new(schema: str = "IFC4") -> dict[str, Any]: return session.ifc_new(schema=schema) - @server.tool() + @_tool def ifc_load(path: str) -> str: return session.ifc_load(path) - @server.tool() + @_tool def ifc_save(path: str = "") -> str: return session.ifc_save(path) - @server.tool() + @_tool def ifc_reset() -> dict[str, Any]: return session.ifc_reset() # ---- Query ---- - @server.tool() + @_tool def ifc_summary() -> dict[str, Any]: return session.ifc_summary() - @server.tool() + @_tool def ifc_tree() -> dict[str, Any] | list[dict[str, Any]]: return session.ifc_tree() - @server.tool() + @_tool def ifc_info(element_id: int) -> dict[str, Any]: return session.ifc_info(element_id) - @server.tool() + @_tool def ifc_select(query: str) -> list[dict[str, Any]]: return session.ifc_select(query) - @server.tool() + @_tool def ifc_relations(element_id: int, traverse: str = "") -> dict[str, Any] | list[dict[str, Any]]: return session.ifc_relations(element_id, traverse=traverse) - @server.tool() + @_tool def ifc_clash( element_id: int, clearance: float = 0.0, @@ -84,58 +90,58 @@ def build_server() -> Any: scope=scope, ) - @server.tool() + @_tool def ifc_contexts() -> list[dict[str, Any]]: return session.ifc_contexts() - @server.tool() + @_tool def ifc_materials() -> list[dict[str, Any]]: return session.ifc_materials() # ---- Edit ---- - @server.tool() + @_tool def ifc_list(module: str = "") -> list[dict]: return session.ifc_list(module=module) - @server.tool() + @_tool def ifc_docs(function_path: str) -> dict: return session.ifc_docs(function_path=function_path) - @server.tool() + @_tool def ifc_edit(function_path: str, params: str = "{}") -> dict: return session.ifc_edit(function_path=function_path, params=params) # ---- Extended query + edit ---- - @server.tool() + @_tool def ifc_validate(express_rules: bool = False) -> dict[str, Any]: return session.ifc_validate(express_rules=express_rules) - @server.tool() + @_tool def ifc_schedule(max_depth: int | None = None) -> list[dict[str, Any]]: return session.ifc_schedule(max_depth=max_depth) - @server.tool() + @_tool def ifc_cost(max_depth: int | None = None) -> list[dict[str, Any]]: return session.ifc_cost(max_depth=max_depth) - @server.tool() + @_tool def ifc_schema(entity_type: str) -> dict[str, Any]: return session.ifc_schema(entity_type=entity_type) - @server.tool() + @_tool def ifc_quantify(rule: str, selector: str = "") -> dict[str, Any]: return session.ifc_quantify(rule=rule, selector=selector) # ---- Shape builder ---- - @server.tool() + @_tool def ifc_shape_list() -> list[dict]: return session.ifc_shape_list() - @server.tool() + @_tool def ifc_shape_docs(method: str) -> dict: return session.ifc_shape_docs(method=method) - @server.tool() + @_tool def ifc_shape(method: str, params: str = "{}") -> dict: return session.ifc_shape(method=method, params=params) diff --git a/src/ifcmcp/tests/test_server.py b/src/ifcmcp/tests/test_server.py index ed41434df7..0bd1f7b11d 100644 --- a/src/ifcmcp/tests/test_server.py +++ b/src/ifcmcp/tests/test_server.py @@ -30,6 +30,12 @@ class TestServerRegistration: for name in expected: assert name in tools, f"Tool {name} not registered" + def test_all_tools_have_descriptions(self): + server = build_server() + tools = server._tool_manager.list_tools() + missing = [t.name for t in tools if not (t.description or "").strip()] + assert not missing, f"Tools with no description: {missing}" + @pytest.fixture def tool_fns(): diff --git a/src/ifcquery/ifcquery/clash.py b/src/ifcquery/ifcquery/clash.py index 52c8d53e3b..2366f0257c 100644 --- a/src/ifcquery/ifcquery/clash.py +++ b/src/ifcquery/ifcquery/clash.py @@ -102,13 +102,26 @@ def clash( tolerance: float = 0.002, scope: str = "storey", ) -> dict[str, Any]: - """Check element for geometric clashes against other elements. + """Check one element for geometric clashes against other elements. + + Reports hard intersections and, optionally, violations of a required + clearance. Returns the overall ``pass``, the ``scope`` actually used, a + ``checks`` block in which each clash names the other ``element``, the + clash ``type``, the ``distance`` and the two closest points ``p1``/``p2``, + and a de-duplicated flat ``elements`` list of everything involved. + Geometry is computed for every element in scope, so this is slow on large + models; ``pass`` is ``None`` with an ``error`` when the element has no + usable geometry. :param model: The IFC model. :param element: The element to check. - :param clearance: Minimum clearance distance; if provided, runs clearance check. + :param clearance: Minimum required clearance distance; when given, also + runs the clearance check alongside the intersection check. :param tolerance: Intersection tolerance in meters (default 0.002). - :param scope: Which elements to check against: "storey" or "all". + :param scope: ``"storey"`` (default) checks only elements sharing the + same spatial container; ``"all"`` checks every ``IfcElement``. + ``"storey"`` falls back to ``"all"`` when the element has no spatial + container. :return: Dict with clash results suitable for JSON serialization. """ result: dict[str, Any] = {"element": _ref(element)} diff --git a/src/ifcquery/ifcquery/cost.py b/src/ifcquery/ifcquery/cost.py index ef9559d597..eaeeaa2db6 100644 --- a/src/ifcquery/ifcquery/cost.py +++ b/src/ifcquery/ifcquery/cost.py @@ -26,10 +26,19 @@ def _cost_item_to_dict(item: ifcopenshell.entity_instance, max_depth: int | None def cost(model: ifcopenshell.file, max_depth: int | None = None) -> list[dict[str, Any]]: - """Return a list of IfcCostSchedule entries with nested cost item trees. + """List the cost schedules: bills of quantities and their cost items. - max_depth limits how many levels of subitems are expanded (None = unlimited). - At the cutoff level, subitems is replaced with {"truncated": True, "count": N}. + Covers ``IfcCostSchedule`` only — this is the money dimension of the + model; see ``schedule()`` in this module for the construction programme. + Each cost item reports its cost ``values`` as ``formula`` label and + ``category`` pairs, together with its nested ``subitems``. Returns an + empty list when the model has no cost schedules. + + :param model: The in-memory IFC model. + :param max_depth: Levels of cost item nesting to expand, counting root + items as level 1. Past the cutoff ``subitems`` is replaced by a + ``{"truncated": True, "count": N}`` marker giving the number of items + not expanded. ``None`` (default) expands to unlimited depth. """ result = [] for cost_schedule in model.by_type("IfcCostSchedule"): diff --git a/src/ifcquery/ifcquery/info.py b/src/ifcquery/ifcquery/info.py index ad9a9daa4f..9674b90062 100644 --- a/src/ifcquery/ifcquery/info.py +++ b/src/ifcquery/ifcquery/info.py @@ -188,7 +188,16 @@ def _material_to_dict(material: ifcopenshell.entity_instance | None) -> dict[str def info(model: ifcopenshell.file, element: ifcopenshell.entity_instance) -> dict[str, Any]: - """Return deep inspection data for an element.""" + """Inspect a single entity in depth. + + Returns the entity's direct ``attributes`` plus, where present, + ``property_sets``, ``element_type``, ``material``, ``container``, + ``placement`` and ``geometry_summary``. Keys are omitted when the + information is unavailable. + + :param model: The in-memory IFC model. + :param element: The entity to inspect. + """ result: dict[str, Any] = { "id": element.id(), "type": element.is_a(), diff --git a/src/ifcquery/ifcquery/materials.py b/src/ifcquery/ifcquery/materials.py index c7402d51e5..6f39ed3fb4 100644 --- a/src/ifcquery/ifcquery/materials.py +++ b/src/ifcquery/ifcquery/materials.py @@ -22,7 +22,13 @@ import ifcopenshell def materials(model: ifcopenshell.file) -> list[dict]: - """Return all materials and material sets from the model. + """List the materials and material sets defined in the model. + + Returns a single list covering ``IfcMaterial`` (with its category), + ``IfcMaterialLayerSet`` (each layer's name, thickness, material and + ventilation flag), ``IfcMaterialConstituentSet`` (constituent names, + materials and fractions) and ``IfcMaterialProfileSet`` (profile names and + materials). Every entry carries the step ID of the material entity. :param model: The in-memory IFC model. :return: List of dicts covering IfcMaterial, IfcMaterialLayerSet, diff --git a/src/ifcquery/ifcquery/relations.py b/src/ifcquery/ifcquery/relations.py index 556ee0324d..f6f868c579 100644 --- a/src/ifcquery/ifcquery/relations.py +++ b/src/ifcquery/ifcquery/relations.py @@ -182,7 +182,22 @@ def _collect_elements(data: Any, seen: set[int], result: list[dict[str, Any]]) - def relations( model: ifcopenshell.file, element: ifcopenshell.entity_instance, traverse: str | None = None ) -> dict[str, Any] | list[dict[str, Any]]: - """Return relationships for an element, or hierarchy chain if traverse='up'.""" + """Show how an element relates to the rest of the model. + + By default returns a dict whose optional blocks are ``hierarchy`` (parent, + container, aggregate, nest, filled void, voided element), ``children`` + (contained, parts, components, openings), ``type_relationship``, + ``groups``, ``systems``, ``zones``, ``material``, ``referenced_structures`` + and ``connections`` (connected to/from, ports), plus a de-duplicated flat + ``elements`` list of everything referenced. Blocks with nothing to report + are omitted. + + :param model: The IFC model. + :param element: The element to examine. + :param traverse: Set to ``'up'`` to instead return the chain of ancestors + from the element to ``IfcProject`` as a flat list. Any other value + gives the default behaviour. + """ if traverse == "up": return _traverse_up(element) result = _all_relations(model, element) diff --git a/src/ifcquery/ifcquery/schedule.py b/src/ifcquery/ifcquery/schedule.py index b3e3ae45d6..ddeb97e26d 100644 --- a/src/ifcquery/ifcquery/schedule.py +++ b/src/ifcquery/ifcquery/schedule.py @@ -37,10 +37,20 @@ def _task_to_dict(task: ifcopenshell.entity_instance, max_depth: int | None, dep def schedule(model: ifcopenshell.file, max_depth: int | None = None) -> list[dict[str, Any]]: - """Return a list of IfcWorkSchedule entries with nested task trees. + """List the construction programme: work schedules and their task trees. - max_depth limits how many levels of subtasks are expanded (None = unlimited). - At the cutoff level, subtasks is replaced with {"truncated": True, "count": N}. + Covers ``IfcWorkSchedule`` only — this is the time dimension of the + model; see ``cost()`` in this module for the money dimension. Each + schedule lists its tasks recursively, and each task carries its scheduled + ``start`` and ``finish``, an ``is_milestone`` flag, the products it + ``outputs`` and its ``subtasks``. Returns an empty list when the model has + no work schedules. + + :param model: The in-memory IFC model. + :param max_depth: Levels of subtask nesting to expand, counting root tasks + as level 1. Past the cutoff ``subtasks`` is replaced by a + ``{"truncated": True, "count": N}`` marker giving the number of tasks + not expanded. ``None`` (default) expands to unlimited depth. """ result = [] for work_schedule in model.by_type("IfcWorkSchedule"): diff --git a/src/ifcquery/ifcquery/schema.py b/src/ifcquery/ifcquery/schema.py index ad47486db2..d2815173d7 100644 --- a/src/ifcquery/ifcquery/schema.py +++ b/src/ifcquery/ifcquery/schema.py @@ -8,7 +8,15 @@ import ifcopenshell.util.doc def schema(model: ifcopenshell.file, entity_type: str) -> dict[str, Any]: - """Return IFC class documentation for entity_type from model's schema version.""" + """Look up the IFC documentation for an entity class. + + Returns the class ``description``, its ``predefined_types``, per-attribute + documentation and a ``spec_url``, resolved against the model's schema + version. Returns an ``error`` key for an unknown class. + + :param model: The in-memory IFC model, used only for its schema version. + :param entity_type: IFC class name, for example ``'IfcWall'``. + """ schema_name = model.schema try: doc = ifcopenshell.util.doc.get_entity_doc(schema_name, entity_type) diff --git a/src/ifcquery/ifcquery/summary.py b/src/ifcquery/ifcquery/summary.py index d13f070472..d4c8ec4cd7 100644 --- a/src/ifcquery/ifcquery/summary.py +++ b/src/ifcquery/ifcquery/summary.py @@ -26,7 +26,15 @@ import ifcopenshell def summary(model: ifcopenshell.file) -> dict[str, Any]: - """Return a model overview with schema, element counts, and project info.""" + """Summarise the model: schema, entity counts and project info. + + Returns the ``schema`` version, ``total_entities``, and a ``project`` + block with the id, name and description of the first ``IfcProject`` + (omitted if the model has none). The count covers every entity in the + file, not just physical elements. + + :param model: The in-memory IFC model. + """ # Count elements by IFC type, sorted by count descending type_counter: Counter[str] = Counter() total = 0 diff --git a/src/ifcquery/ifcquery/tree.py b/src/ifcquery/ifcquery/tree.py index 1cbbaeb986..3c97ba336d 100644 --- a/src/ifcquery/ifcquery/tree.py +++ b/src/ifcquery/ifcquery/tree.py @@ -59,7 +59,17 @@ def _build_spatial_node(element: ifcopenshell.entity_instance) -> dict[str, Any] def tree(model: ifcopenshell.file) -> dict[str, Any] | list[dict[str, Any]]: - """Return the spatial hierarchy tree starting from IfcProject.""" + """Return the spatial hierarchy of the model as a nested tree. + + Starts at ``IfcProject`` and descends through decomposition (site, + building, storeys) and containment (the elements placed in each storey). + Every node carries ``id``, ``type`` and ``name``; ``children`` holds + decomposed sub-spaces and ``elements`` holds contained elements, and + either key is omitted when empty. Returns a list when the file contains + several projects, or an ``error`` key when it contains none. + + :param model: The in-memory IFC model. + """ projects = model.by_type("IfcProject") if not projects: return {"error": "No IfcProject found in model"} diff --git a/src/ifcquery/ifcquery/validate.py b/src/ifcquery/ifcquery/validate.py index d35d9150af..d409c024c5 100644 --- a/src/ifcquery/ifcquery/validate.py +++ b/src/ifcquery/ifcquery/validate.py @@ -8,7 +8,16 @@ import ifcopenshell.validate def validate(model: ifcopenshell.file, express_rules: bool = False) -> dict[str, Any]: - """Validate the model and return a dict with 'valid' bool and 'issues' list.""" + """Validate the model against the IFC schema. + + Returns ``valid`` together with a list of ``issues``, each carrying a + ``level`` and a ``message``. Worth running after a batch of edits and + before writing the model back to disk. + + :param model: The in-memory IFC model. + :param express_rules: Also evaluate the schema's EXPRESS rules. Catches + more problems but is considerably slower (default ``False``). + """ logger = ifcopenshell.validate.json_logger() ifcopenshell.validate.validate(model, logger, express_rules=express_rules) issues = [{"level": s["level"], "message": s["message"]} for s in logger.statements] From 048242783eafe097eb9733eee9c99a4ded235682 Mon Sep 17 00:00:00 2001 From: Richard Brice <37087370+RickBrice@users.noreply.github.com> Date: Wed, 5 Aug 2026 07:26:27 -0700 Subject: [PATCH 4/7] Updates update_key_point_referents to confirm to CT 4.1.4.4.3 --- .../alignment/update_key_point_referents.py | 42 ++++++++----------- .../test_update_key_point_referents.py | 38 ++++++++++++++--- 2 files changed, 50 insertions(+), 30 deletions(-) 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 index b339942bda..6d5bd37ac0 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/alignment/update_key_point_referents.py +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/update_key_point_referents.py @@ -32,21 +32,6 @@ 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.""" @@ -129,9 +114,11 @@ def update_key_point_referents( 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 rel_nests: an existing IfcRelNests to (re)populate; its RelatingObject must be the + IfcAlignment that nests `layout` (TypeError is raised otherwise). If omitted, a new + IfcRelNests is always created and related to that IfcAlignment -- there is no implicit + search for or reuse of a previously created nest. Callers who want to regenerate into an + existing nest must pass it back in explicitly via `rel_nests`. :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. @@ -167,12 +154,20 @@ def update_key_point_referents( 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=() + 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.") + + if rel_nests is not None: + if not rel_nests.RelatingObject.is_a("IfcAlignment"): + raise TypeError( + f"Expected rel_nests.RelatingObject to be IfcAlignment, instead received " + f"{rel_nests.RelatingObject.is_a()}" ) + else: + rel_nests = file.createIfcRelNests( + GlobalId=ifcopenshell.guid.new(), RelatingObject=alignment, RelatedObjects=() + ) if clear: for referent in list(rel_nests.RelatedObjects): @@ -189,7 +184,6 @@ def update_key_point_referents( ) 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") 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 index f58dc67443..d914763ddd 100644 --- 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 @@ -82,13 +82,13 @@ def test_default_rel_nests_created_when_none_provided(): nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal) assert nest.is_a("IfcRelNests") - assert nest.RelatingObject == horizontal + assert nest.RelatingObject == alignment 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(): +def test_second_call_without_rel_nests_creates_separate_nest(): file = _new_file() alignment = _build_alignment(file) horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment) @@ -97,18 +97,31 @@ def test_second_call_without_rel_nests_reuses_existing_nest(): 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 + assert nest1.id() != nest2.id() + assert len(nest1.RelatedObjects) == 8 + assert len(nest2.RelatedObjects) == 8 segment_count_after = len(ifcopenshell.api.alignment.get_alignment_segment_nest(horizontal).RelatedObjects) assert segment_count_after == segment_count_before +def test_passing_previous_nest_back_in_accumulates(): + file = _new_file() + alignment = _build_alignment(file) + horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment) + + nest1 = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal) + nest2 = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal, rel_nests=nest1) + + assert nest1.id() == nest2.id() + assert len(nest2.RelatedObjects) == 16 + + 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.RelatingObject must be the IfcAlignment that nests `layout` 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) @@ -118,6 +131,17 @@ def test_provided_rel_nests_is_used_as_is(): assert len(result.RelatedObjects) == 8 +def test_provided_rel_nests_with_wrong_relating_object_raises_type_error(): + file = _new_file() + alignment = _build_alignment(file) + horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment) + + rel_nests = file.createIfcRelNests(GlobalId=ifcopenshell.guid.new(), RelatingObject=horizontal, RelatedObjects=()) + + with pytest.raises(TypeError): + ifcopenshell.api.alignment.update_key_point_referents(file, horizontal, rel_nests=rel_nests) + + def test_clear_true_removes_old_referents_and_psets(): file = _new_file() alignment = _build_alignment(file) @@ -356,8 +380,10 @@ def test_returns_ifc_rel_nests(): 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_second_call_without_rel_nests_creates_separate_nest() +test_passing_previous_nest_back_in_accumulates() test_provided_rel_nests_is_used_as_is() +test_provided_rel_nests_with_wrong_relating_object_raises_type_error() test_clear_true_removes_old_referents_and_psets() test_clear_false_appends_without_dedup() test_default_horizontal_labels_and_order() From c5ba22451f48f6e611cec7d9ec7568f07bd77305 Mon Sep 17 00:00:00 2001 From: Richard Brice <37087370+RickBrice@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:33:04 -0700 Subject: [PATCH 5/7] Adds update_alignment_parameter_segment_tags function --- .../ifcopenshell/api/alignment/__init__.py | 4 +- .../api/alignment/_get_key_point_tag.py | 30 ++ ...update_alignment_parameter_segment_tags.py | 100 +++++++ .../alignment/update_key_point_referents.py | 9 +- .../test/api/alignment/test_referent_names.py | 12 +- ...update_alignment_parameter_segment_tags.py | 279 ++++++++++++++++++ .../test_update_key_point_referents.py | 14 +- 7 files changed, 434 insertions(+), 14 deletions(-) create mode 100644 src/ifcopenshell-python/ifcopenshell/api/alignment/_get_key_point_tag.py create mode 100644 src/ifcopenshell-python/ifcopenshell/api/alignment/update_alignment_parameter_segment_tags.py create mode 100644 src/ifcopenshell-python/test/api/alignment/test_update_alignment_parameter_segment_tags.py diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/__init__.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/__init__.py index 56a158842c..e0d5684164 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/alignment/__init__.py +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/__init__.py @@ -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", diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/_get_key_point_tag.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/_get_key_point_tag.py new file mode 100644 index 0000000000..ca888f2e9c --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/_get_key_point_tag.py @@ -0,0 +1,30 @@ +# 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 . + +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): " (