Reverts from automatically adding stationing to alignments because of missing initial geometry. Adds support for stationing with decreasing values.

This commit is contained in:
Richard Brice
2026-08-31 15:34:21 -07:00
parent 971cccc186
commit b5670c4fc5
21 changed files with 394 additions and 131 deletions
@@ -51,7 +51,7 @@ class ImportAlignmentCSV(bpy.types.Operator, tool.Ifc.Operator, ImportHelper):
def _execute(self, context): def _execute(self, context):
self.file = tool.Ifc.get() self.file = tool.Ifc.get()
start = time.time() start = time.time()
alignment = ifcopenshell.api.alignment.create_from_csv(self.file, self.filepath) alignment = ifcopenshell.api.alignment.create_from_csv(self.file, self.filepath, start_station=0.0)
# IFC 4.1.5.1 alignments cannot be contained in spatial structures, but can be referenced into them # IFC 4.1.5.1 alignments cannot be contained in spatial structures, but can be referenced into them
sites = self.file.by_type("IfcSite") sites = self.file.by_type("IfcSite")
@@ -23,9 +23,11 @@ This API is defined in terms of the semantic definition of an alignment. The cor
is created and maintained automatically. The manditory zero length segment for the semantic and geometric definitions is created and maintained automatically. The manditory zero length segment for the semantic and geometric definitions
are automatically created and maintained. are automatically created and maintained.
Alignments are created with stationing referents. Each layout segment is assigned a position referent that informs about Stationing is defined explicitly by calling add_stationing_referent() after the layout segments (and therefore the
the start point of the segment. An example is the point of curvature of a horizontal circular curve. The referent is basis curve geometry) exist - create() does not add one. update_key_point_referents() assigns each layout segment a
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.)" 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 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. This API does not determine alignment parameters based on rules, such as minimum curve radius as a function of design speed or sight distance.
@@ -35,7 +37,7 @@ Presently, this API supports:
1. Creating alignments, both horizontal and vertical, using the PI method. Alignment definition can be read from a CSV file. 1. Creating alignments, both horizontal and vertical, using the PI method. Alignment definition can be read from a CSV file.
2. Creating alignments segment by segment. 2. Creating alignments segment by segment.
3. Automatic creation of geometric definitions (IfcCompositeCurve, IfcGradientCurve, IfcSegmentedReferenceCurve) 3. Automatic creation of geometric definitions (IfcCompositeCurve, IfcGradientCurve, IfcSegmentedReferenceCurve)
4. Automatic definition of stationing 4. Explicit definition of stationing, including station equations and reverse (decreasing) stationing
5. Automatic definition of alignment transition point referents 5. Automatic definition of alignment transition point referents
6. Utility functions for printing business logical and geometric representations, as well as minimal geometry evaluations 6. Utility functions for printing business logical and geometric representations, as well as minimal geometry evaluations
@@ -0,0 +1,37 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
from ifcopenshell import entity_instance
def _referent_distance_along(referent: entity_instance) -> float:
"""The distance along the basis curve at which a referent is placed.
Read from IfcLinearPlacement.RelativePlacement.Location.DistanceAlong. An
IfcLocalPlacement fallback (semantic-only alignment, or a placement that could not
yet be expressed relative to a basis curve) carries no DistanceAlong; it is only ever
used for the starting referent, at distance 0.0 (the global origin).
Referents nested under an alignment are ordered by increasing DistanceAlong (IFC CT
4.1.4.4.3), which - for a reverse-stationed alignment - means by decreasing Station.
Sort on this, never on Pset_Stationing.Station.
"""
placement = referent.ObjectPlacement
if placement and placement.is_a("IfcLinearPlacement"):
return placement.RelativePlacement.Location.DistanceAlong.wrappedValue
return 0.0
@@ -22,8 +22,8 @@ import ifcopenshell
import ifcopenshell.api.alignment import ifcopenshell.api.alignment
import ifcopenshell.api.pset import ifcopenshell.api.pset
import ifcopenshell.guid import ifcopenshell.guid
import ifcopenshell.util.element
from ifcopenshell import entity_instance from ifcopenshell import entity_instance
from ifcopenshell.api.alignment._referent_distance_along import _referent_distance_along
from ifcopenshell.api.alignment._sort_nest import _sort_nest from ifcopenshell.api.alignment._sort_nest import _sort_nest
from ifcopenshell.api.alignment.update_fallback_position import update_fallback_position from ifcopenshell.api.alignment.update_fallback_position import update_fallback_position
@@ -35,16 +35,28 @@ def add_stationing_referent(
distance_along: float, distance_along: float,
station: float, station: float,
incoming_station: Optional[float] = None, incoming_station: Optional[float] = None,
has_increasing_station: Optional[bool] = None,
on_basis_curve: Optional[bool] = None, on_basis_curve: Optional[bool] = None,
) -> entity_instance: ) -> entity_instance:
""" """
Adds an IfcReferent to the alignment that defines the stationing system. Adds an IfcReferent to the alignment that defines the stationing system.
Call this once with ``distance_along=0.0`` to define the starting station, and again
for each station equation. If the alignment has no geometry yet, the referent is
placed with an IfcLocalPlacement at the global origin; once the basis curve has real
segments it is placed with an IfcLinearPlacement at ``distance_along`` on that curve.
create_representation() restates an origin-placed starting referent onto the curve
when geometry is added later.
:param name: name to assign to IfcReferent.Name, typically a stringized version of the station value :param name: name to assign to IfcReferent.Name, typically a stringized version of the station value
:param alignment: the alignment to receive the referent :param alignment: the alignment to receive the referent
:param distance_along: distance along the alignment basis curve :param distance_along: distance along the alignment basis curve
:param station: station value :param station: station value
:param incoming_station: station value of the incoming segment, only set to specify a station equation :param incoming_station: station value of the incoming segment, only set to specify a station equation
:param has_increasing_station: sets Pset_Stationing.HasIncreasingStation, which records the direction of
stationing for the referents nested after this one. Leave None (the default) or pass True for the
common case where station values increase with distance along; pass False on the starting referent
of a reverse-stationed alignment, where station values decrease as distance along increases.
:param on_basis_curve: whether the referent is positioned on the basis curve or the alignment curve, if None the function will default to the basis curve :param on_basis_curve: whether the referent is positioned on the basis curve or the alignment curve, if None the function will default to the basis curve
:return: referent :return: referent
@@ -82,10 +94,13 @@ def add_stationing_referent(
update_fallback_position(file, object_placement) update_fallback_position(file, object_placement)
else: else:
# No resolvable basis curve yet: place the referent at the global origin. Once
# geometry exists, create_representation() restates the starting referent onto the
# curve at DistanceAlong 0.0.
object_placement = file.createIfcLocalPlacement( object_placement = file.createIfcLocalPlacement(
PlacementRelTo=None, PlacementRelTo=None,
RelativePlacement=file.createIfcAxis2Placement2D( RelativePlacement=file.createIfcAxis2Placement2D(
Location=file.createIfcCartesianPoint(alignment.ObjectPlacement.RelativePlacement.Location.Coordinates) Location=file.createIfcCartesianPoint(Coordinates=(0.0, 0.0))
), ),
) )
@@ -111,6 +126,8 @@ def add_stationing_referent(
properties = {"Station": station} properties = {"Station": station}
if incoming_station is not None: if incoming_station is not None:
properties["IncomingStation"] = incoming_station properties["IncomingStation"] = incoming_station
if has_increasing_station is not None:
properties["HasIncreasingStation"] = has_increasing_station
pset_stationing = ifcopenshell.api.pset.add_pset(file, product=referent, name="Pset_Stationing") pset_stationing = ifcopenshell.api.pset.add_pset(file, product=referent, name="Pset_Stationing")
ifcopenshell.api.pset.edit_pset(file, pset=pset_stationing, properties=properties) ifcopenshell.api.pset.edit_pset(file, pset=pset_stationing, properties=properties)
@@ -123,6 +140,8 @@ def add_stationing_referent(
else: else:
nest.RelatedObjects += (referent,) nest.RelatedObjects += (referent,)
_sort_nest(nest, key=lambda x: ifcopenshell.util.element.get_pset(x, name="Pset_Stationing", prop="Station")) # Referents are ordered by increasing DistanceAlong (IFC CT 4.1.4.4.3), which for a
# reverse-stationed alignment is decreasing Station - so sort on DistanceAlong, not Station.
_sort_nest(nest, key=_referent_distance_along)
return referent return referent
@@ -20,7 +20,6 @@ import ifcopenshell
import ifcopenshell.api.aggregate import ifcopenshell.api.aggregate
import ifcopenshell.api.alignment import ifcopenshell.api.alignment
import ifcopenshell.api.nest import ifcopenshell.api.nest
import ifcopenshell.util.alignment
from ifcopenshell import entity_instance from ifcopenshell import entity_instance
from ifcopenshell.api.alignment._add_zero_length_segment import _add_zero_length_segment from ifcopenshell.api.alignment._add_zero_length_segment import _add_zero_length_segment
from ifcopenshell.api.alignment._create_geometric_representation import ( from ifcopenshell.api.alignment._create_geometric_representation import (
@@ -34,7 +33,6 @@ def create(
include_vertical: bool = False, include_vertical: bool = False,
include_cant: bool = False, include_cant: bool = False,
include_geometry: bool = True, include_geometry: bool = True,
start_station: float = 0.0,
) -> entity_instance: ) -> entity_instance:
""" """
Creates a new alignment with a horizontal layout. Optionally, vertical and cant layouts can be created as well. Creates a new alignment with a horizontal layout. Optionally, vertical and cant layouts can be created as well.
@@ -49,19 +47,15 @@ def create(
The horizontal geometry in the Viennese Bend transition curves depends on the Viennese Bend cant parameters. create_layout_segment() automatically creates The horizontal geometry in the Viennese Bend transition curves depends on the Viennese Bend cant parameters. create_layout_segment() automatically creates
the geometric representation from the semantic definition. The horizontal segment geometric representation will fail if the cant segment is not defined. the geometric representation from the semantic definition. The horizontal segment geometric representation will fail if the cant segment is not defined.
If geometric representations are created, the alignment stationing referent is also created using the start_station value. IfcReferent.ObjectPlacement This function does not define the alignment's stationing. Call add_stationing_referent() once the layout
is required for linear positiion elements and IfcLinearPlacement is defined relative to alignment curve geometry. segments (and therefore the basis curve geometry) exist to place the starting-station IfcReferent, and again
This referent's Name follows the same "<alignment name> <station>" convention update_key_point_referents() uses for any station equations. Until stationing is defined, get_alignment_start_station() reports 0.0.
for its own key-point referents (e.g. "MyAlignment 49+00.00"), so that every referent nested under an alignment
is identifiable by name alone, without needing to inspect its Pset_Stationing or placement to know which
alignment it belongs to.
:param file: :param file:
:param name: name assigned to IfcAlignment.Name :param name: name assigned to IfcAlignment.Name
:param include_vertical: If True, IfcAlignmentVertical is created. IfcGradientCurve is created if include_geometry is True :param include_vertical: If True, IfcAlignmentVertical is created. IfcGradientCurve is created if include_geometry is True
:param include_cant: If True, IfcAlignmentCant is created. IfcSegmentedReferenceCurve is created if include_geometry is True :param include_cant: If True, IfcAlignmentCant is created. IfcSegmentedReferenceCurve is created if include_geometry is True
:param include_geometry: If True, the geometric representations are added :param include_geometry: If True, the geometric representations are added
:param start_station: station value at the start of the alignment.
:return: Returns an IfcAlignment :return: Returns an IfcAlignment
""" """
alignment = file.createIfcAlignment( alignment = file.createIfcAlignment(
@@ -90,9 +84,6 @@ def create(
if include_geometry: if include_geometry:
_create_geometric_representation(file, alignment) _create_geometric_representation(file, alignment)
referent_name = f"{name} {ifcopenshell.util.alignment.station_as_string(file, start_station)}"
referent = ifcopenshell.api.alignment.add_stationing_referent(file, referent_name, alignment, 0.0, start_station)
for layout in alignment_layouts: for layout in alignment_layouts:
_add_zero_length_segment(file, layout) _add_zero_length_segment(file, layout)
@@ -18,6 +18,7 @@
import math import math
from collections.abc import Sequence from collections.abc import Sequence
from typing import Optional
import ifcopenshell import ifcopenshell
import ifcopenshell.api.aggregate import ifcopenshell.api.aggregate
@@ -123,22 +124,23 @@ def create_as_polyline(
file: ifcopenshell.file, file: ifcopenshell.file,
name: str, name: str,
points: Sequence[entity_instance], points: Sequence[entity_instance],
start_station: float = 0.0, start_station: Optional[float] = None,
) -> entity_instance: ) -> entity_instance:
""" """
Creates a new IfcAlignment with an IfcPolyline representation. Creates a new IfcAlignment with an IfcPolyline representation.
The IfcAlignment is aggreated to IfcProject The IfcAlignment is aggreated to IfcProject
The stationing referent created from start_station has Name "<alignment name> <station>" If start_station is given, a STATION IfcReferent named "<alignment name> <station>"
(e.g. "MyAlignment 49+00.00"), the same convention update_key_point_referents() and (e.g. "MyAlignment 49+00.00") is added at distance along 0.0 - the same naming
create() use for their own referents, so every referent nested under an alignment is convention update_key_point_referents() uses for its own referents, so every referent
identifiable by name alone. nested under an alignment is identifiable by name alone. If None (the default), no
stationing referent is created.
:param file: :param file:
:param name: name assigned to IfcAlignment.Name :param name: name assigned to IfcAlignment.Name
:param points: sequence of points defining the polyline :param points: sequence of points defining the polyline
:param start_station: station value at the start of the alignment :param start_station: station value at the start of the alignment, or None for no stationing referent
:return: Returns an IfcAlignment :return: Returns an IfcAlignment
""" """
alignment = file.createIfcAlignment( alignment = file.createIfcAlignment(
@@ -149,8 +151,9 @@ def create_as_polyline(
_create_polyline_representation(file, alignment, points) _create_polyline_representation(file, alignment, points)
# define stationing # define stationing
referent_name = f"{alignment.Name} {ifcopenshell.util.alignment.station_as_string(file, start_station)}" if start_station is not None:
referent = ifcopenshell.api.alignment.add_stationing_referent(file, referent_name, alignment, 0.0, start_station) referent_name = f"{alignment.Name} {ifcopenshell.util.alignment.station_as_string(file, start_station)}"
ifcopenshell.api.alignment.add_stationing_referent(file, referent_name, alignment, 0.0, start_station)
# IFC 4.1.4.1.1 Alignment Aggregation To Project # IFC 4.1.4.1.1 Alignment Aggregation To Project
project = file.by_type("IfcProject")[0] project = file.by_type("IfcProject")[0]
@@ -17,9 +17,11 @@
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>. # along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
from collections.abc import Sequence from collections.abc import Sequence
from typing import Optional
import ifcopenshell import ifcopenshell
import ifcopenshell.api.alignment import ifcopenshell.api.alignment
import ifcopenshell.util.alignment
from ifcopenshell import entity_instance from ifcopenshell import entity_instance
@@ -30,7 +32,7 @@ def create_by_pi_method(
radii: Sequence[float], radii: Sequence[float],
vpoints: Sequence[Sequence[float]] = None, vpoints: Sequence[Sequence[float]] = None,
lengths: Sequence[float] = None, lengths: Sequence[float] = None,
start_station: float = 0.0, start_station: Optional[float] = None,
) -> entity_instance: ) -> entity_instance:
""" """
Create an alignment using the PI layout method for both horizontal and vertical alignments. Create an alignment using the PI layout method for both horizontal and vertical alignments.
@@ -41,16 +43,21 @@ def create_by_pi_method(
:param radii: radii values to use for transition :param radii: radii values to use for transition
:param vpoints: (distance_along, Z_height) pairs denoting the location of the vertical PIs, including start and end. :param vpoints: (distance_along, Z_height) pairs denoting the location of the vertical PIs, including start and end.
:param lengths: parabolic vertical curve horizontal length values to use for transition :param lengths: parabolic vertical curve horizontal length values to use for transition
:param start_station: if given, the starting station value. A STATION IfcReferent named
"<name> <station string>" is added at distance along 0.0 once the geometry exists. If None
(the default), no stationing referent is created and get_alignment_start_station() reports 0.0.
:return: Returns an IfcAlignment :return: Returns an IfcAlignment
""" """
include_vertical = True if vpoints and lengths else False include_vertical = True if vpoints and lengths else False
alignment = ifcopenshell.api.alignment.create( alignment = ifcopenshell.api.alignment.create(file, name, include_vertical=include_vertical)
file, name, include_vertical=include_vertical, start_station=start_station
)
horizontal_layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment) horizontal_layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
ifcopenshell.api.alignment.layout_horizontal_alignment_by_pi_method(file, horizontal_layout, hpoints, radii) ifcopenshell.api.alignment.layout_horizontal_alignment_by_pi_method(file, horizontal_layout, hpoints, radii)
if include_vertical: if include_vertical:
vertical_layout = ifcopenshell.api.alignment.get_vertical_layout(alignment) vertical_layout = ifcopenshell.api.alignment.get_vertical_layout(alignment)
ifcopenshell.api.alignment.layout_vertical_alignment_by_pi_method(file, vertical_layout, vpoints, lengths) ifcopenshell.api.alignment.layout_vertical_alignment_by_pi_method(file, vertical_layout, vpoints, lengths)
if start_station is not None:
referent_name = f"{name} {ifcopenshell.util.alignment.station_as_string(file, start_station)}"
ifcopenshell.api.alignment.add_stationing_referent(file, referent_name, alignment, 0.0, start_station)
return alignment return alignment
@@ -17,13 +17,17 @@
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>. # along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import csv import csv
from typing import Optional
import ifcopenshell import ifcopenshell
import ifcopenshell.api.alignment import ifcopenshell.api.alignment
import ifcopenshell.util.alignment
from ifcopenshell import entity_instance from ifcopenshell import entity_instance
def create_from_csv(file: ifcopenshell.file, filepath: str) -> entity_instance: def create_from_csv(
file: ifcopenshell.file, filepath: str, start_station: Optional[float] = None
) -> entity_instance:
""" """
Creates an alignment from PI data stored in a CSV file. Creates an alignment from PI data stored in a CSV file.
@@ -55,6 +59,8 @@ def create_from_csv(file: ifcopenshell.file, filepath: str) -> entity_instance:
The CSV file contains one horizontal alignment, zero, one, or more vertical alignments The CSV file contains one horizontal alignment, zero, one, or more vertical alignments
:param filepath: path the to CSV file :param filepath: path the to CSV file
:param start_station: if given, the starting station value; a STATION IfcReferent is added at
distance along 0.0 once the geometry exists. If None (the default), no stationing referent is created.
:return: IfcAlignment :return: IfcAlignment
""" """
alignment = None alignment = None
@@ -97,4 +103,9 @@ def create_from_csv(file: ifcopenshell.file, filepath: str) -> entity_instance:
) )
assert alignment is not None assert alignment is not None
if start_station is not None:
referent_name = f"{alignment.Name} {ifcopenshell.util.alignment.station_as_string(file, start_station)}"
ifcopenshell.api.alignment.add_stationing_referent(file, referent_name, alignment, 0.0, start_station)
return alignment return alignment
@@ -22,16 +22,7 @@ import ifcopenshell
import ifcopenshell.api.alignment import ifcopenshell.api.alignment
import ifcopenshell.util.element import ifcopenshell.util.element
from ifcopenshell import entity_instance from ifcopenshell import entity_instance
from ifcopenshell.api.alignment._referent_distance_along import _referent_distance_along
def _distance_along_of_referent(referent: entity_instance) -> float:
placement = referent.ObjectPlacement
if placement.is_a("IfcLinearPlacement"):
return placement.RelativePlacement.Location.DistanceAlong.wrappedValue
# IfcLocalPlacement fallback (e.g. semantic-only alignment, or the placement could not yet
# be expressed relative to a basis curve) carries no DistanceAlong; it is only ever used for
# the starting referent, at distance 0.0.
return 0.0
def distance_along_from_station(file: ifcopenshell.file, alignment: entity_instance, station: float) -> Optional[float]: def distance_along_from_station(file: ifcopenshell.file, alignment: entity_instance, station: float) -> Optional[float]:
@@ -41,18 +32,24 @@ def distance_along_from_station(file: ifcopenshell.file, alignment: entity_insta
If the alignment does not have stationing defined with an IfcReferent, the start of the alignment is assumed If the alignment does not have stationing defined with an IfcReferent, the start of the alignment is assumed
to be at station 0.0. That is, the station is the distance along. to be at station 0.0. That is, the station is the distance along.
Station equations (where Pset_Stationing.IncomingStation is set on a referent) are taken into account. Station equations (where Pset_Stationing.IncomingStation is set on a referent) and reverse
(decreasing) stationing (where Pset_Stationing.HasIncreasingStation is False) are taken into account.
For each STATION referent nested to the alignment, DistanceAlong (D) and the outgoing station (S, i.e. For each STATION referent nested to the alignment, DistanceAlong (D) and the outgoing station (S, i.e.
Pset_Stationing.Station) are read off, sorted by DistanceAlong. The requested station is located within Pset_Stationing.Station) are read off and sorted by DistanceAlong. A direction sign is tracked while
the segment defined by the last referent whose outgoing station is less than or equal to it, and the walking the sorted referents: it starts at +1 and is set to +1 or -1 at any referent that carries an
distance along is computed as D + (station - S) for that referent. explicit Pset_Stationing.HasIncreasingStation, so each referent's region has a sign sigma of +1
(increasing) or -1 (decreasing). HasIncreasingStation may flip any number of times along the alignment -
unrealistic, but valid IFC, and handled. The governing referent is the last one, by DistanceAlong, for
which sigma * (station - S) >= 0, and the distance along is D + sigma * (station - S).
If the station falls within a gap introduced by a forward (gap) station equation - that is, it was skipped If the station falls within a gap introduced by a station equation - that is, it was skipped over by the
over by the equation - there is no distance along that corresponds to it, and None is returned. equation - there is no distance along that corresponds to it, and None is returned.
Note that an overlap (backward) station equation causes a range of stations to correspond to two distinct Note that an overlap station equation - or a HasIncreasingStation direction reversal, which creates an
distances along the alignment, one on either side of the equation. This implementation returns the distance equivalent overlap zone - causes a range of stations to correspond to two (or more) distinct distances
along in the segment following the equation (i.e. the outgoing side). along the alignment. This implementation returns the most downstream one (largest DistanceAlong), i.e.
the match in the region following the last equation / reversal.
:param alignment: the alignment :param alignment: the alignment
:param station: station value :param station: station value
@@ -72,31 +69,42 @@ def distance_along_from_station(file: ifcopenshell.file, alignment: entity_insta
start_station = ifcopenshell.api.alignment.get_alignment_start_station(file, alignment) start_station = ifcopenshell.api.alignment.get_alignment_start_station(file, alignment)
return station - start_station return station - start_station
stations = [ referents = [
( (
_distance_along_of_referent(referent), _referent_distance_along(referent),
ifcopenshell.util.element.get_pset(referent, name="Pset_Stationing", prop="Station"), ifcopenshell.util.element.get_pset(referent, name="Pset_Stationing", prop="Station"),
ifcopenshell.util.element.get_pset(referent, name="Pset_Stationing", prop="HasIncreasingStation"),
) )
for referent in stationing_nest.RelatedObjects for referent in stationing_nest.RelatedObjects
] ]
stations.sort(key=lambda entry: entry[0]) referents.sort(key=lambda entry: entry[0])
# Assign each referent's region a direction sign: +1 increasing, -1 decreasing. The sign
# starts increasing and flips at any referent carrying an explicit HasIncreasingStation.
sigma = 1.0
stations = []
for distance_along, outgoing_station, has_increasing_station in referents:
if has_increasing_station is not None:
sigma = 1.0 if has_increasing_station else -1.0
stations.append((distance_along, outgoing_station, sigma))
index = None index = None
for i, (distance_along, outgoing_station) in enumerate(stations): for i, (distance_along, outgoing_station, region_sign) in enumerate(stations):
if outgoing_station <= station: if region_sign * (station - outgoing_station) >= 0.0:
index = i index = i
if index is None: if index is None:
# station precedes the alignment's starting station; extrapolate from the first referent # station precedes the alignment's starting station; extrapolate from the first referent
distance_along, outgoing_station = stations[0] distance_along, outgoing_station, region_sign = stations[0]
return distance_along + (station - outgoing_station) return distance_along + region_sign * (station - outgoing_station)
distance_along, outgoing_station = stations[index] distance_along, outgoing_station, region_sign = stations[index]
advance = region_sign * (station - outgoing_station)
if index + 1 < len(stations): if index + 1 < len(stations):
next_distance_along, _ = stations[index + 1] next_distance_along, _, _ = stations[index + 1]
if station - outgoing_station > next_distance_along - distance_along: if advance > next_distance_along - distance_along:
# the station was skipped over by a forward (gap) station equation # the station was skipped over by a gap station equation
return None return None
return distance_along + (station - outgoing_station) return distance_along + advance
@@ -24,7 +24,7 @@ import ifcopenshell.util.element
def test_add_positioning_referent(): def test_add_positioning_referent():
file = ifcopenshell.file(schema="IFC4X3") file = ifcopenshell.file(schema="IFC4X3_ADD2")
project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test") project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
length = ifcopenshell.api.unit.add_si_unit(file, unit_type="LENGTHUNIT") length = ifcopenshell.api.unit.add_si_unit(file, unit_type="LENGTHUNIT")
ifcopenshell.api.unit.assign_unit(file, units=[length]) ifcopenshell.api.unit.assign_unit(file, units=[length])
@@ -37,7 +37,7 @@ def test_add_positioning_referent():
parent=geometric_representation_context, parent=geometric_representation_context,
) )
alignment = ifcopenshell.api.alignment.create(file, "TestAlignment", start_station=2000.0) alignment = ifcopenshell.api.alignment.create(file, "TestAlignment")
horizontal_layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment) horizontal_layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
segment = ifcopenshell.api.alignment.get_layout_segments(horizontal_layout)[0] segment = ifcopenshell.api.alignment.get_layout_segments(horizontal_layout)[0]
@@ -61,7 +61,7 @@ def test_add_positioning_referent():
def test_add_positioning_referent_creates_separate_referent_per_call(): def test_add_positioning_referent_creates_separate_referent_per_call():
file = ifcopenshell.file(schema="IFC4X3") file = ifcopenshell.file(schema="IFC4X3_ADD2")
project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test") project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
length = ifcopenshell.api.unit.add_si_unit(file, unit_type="LENGTHUNIT") length = ifcopenshell.api.unit.add_si_unit(file, unit_type="LENGTHUNIT")
ifcopenshell.api.unit.assign_unit(file, units=[length]) ifcopenshell.api.unit.assign_unit(file, units=[length])
@@ -74,7 +74,7 @@ def test_add_positioning_referent_creates_separate_referent_per_call():
parent=geometric_representation_context, parent=geometric_representation_context,
) )
alignment = ifcopenshell.api.alignment.create(file, "TestAlignment", start_station=2000.0) alignment = ifcopenshell.api.alignment.create(file, "TestAlignment")
horizontal_layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment) horizontal_layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
segment = ifcopenshell.api.alignment.get_layout_segments(horizontal_layout)[0] segment = ifcopenshell.api.alignment.get_layout_segments(horizontal_layout)[0]
@@ -25,7 +25,7 @@ import ifcopenshell.api.unit
from ifcopenshell.api.alignment._add_segment_to_layout import _add_segment_to_layout from ifcopenshell.api.alignment._add_segment_to_layout import _add_segment_to_layout
try: try:
ifcopenshell.file(schema="IFC4X3") ifcopenshell.file(schema="IFC4X3_ADD2")
IFC4X3_AVAILABLE = True IFC4X3_AVAILABLE = True
except RuntimeError: except RuntimeError:
IFC4X3_AVAILABLE = False IFC4X3_AVAILABLE = False
@@ -33,7 +33,7 @@ except RuntimeError:
@pytest.mark.skipif(not IFC4X3_AVAILABLE, reason="IFC4X3 not available") @pytest.mark.skipif(not IFC4X3_AVAILABLE, reason="IFC4X3 not available")
def test_add_segment_to_layout(): def test_add_segment_to_layout():
file = ifcopenshell.file(schema="IFC4X3") file = ifcopenshell.file(schema="IFC4X3_ADD2")
project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test") project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
length = ifcopenshell.api.unit.add_si_unit(file, unit_type="LENGTHUNIT") length = ifcopenshell.api.unit.add_si_unit(file, unit_type="LENGTHUNIT")
ifcopenshell.api.unit.assign_unit(file, units=[length]) ifcopenshell.api.unit.assign_unit(file, units=[length])
@@ -47,11 +47,12 @@ def test_add_segment_to_layout():
) )
alignment = ifcopenshell.api.alignment.create(file, "") alignment = ifcopenshell.api.alignment.create(file, "")
ifcopenshell.api.alignment.add_stationing_referent(file, "0+00.00", alignment, distance_along=0.0, station=0.0)
stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment) stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment)
assert ( assert (
len(stationing_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 ) # the stationing nest has one referent that defines the stationing for the alignment
horizontal_alignment = ifcopenshell.api.alignment.get_horizontal_layout(alignment) horizontal_alignment = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
@@ -24,7 +24,7 @@ import ifcopenshell.util.element
def _create_test_file(): def _create_test_file():
file = ifcopenshell.file(schema="IFC4X3") file = ifcopenshell.file(schema="IFC4X3_ADD2")
project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test") project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
length = ifcopenshell.api.unit.add_si_unit(file, unit_type="LENGTHUNIT") length = ifcopenshell.api.unit.add_si_unit(file, unit_type="LENGTHUNIT")
ifcopenshell.api.unit.assign_unit(file, units=[length]) ifcopenshell.api.unit.assign_unit(file, units=[length])
@@ -43,7 +43,7 @@ def _create_test_alignment_with_vertical(file):
# include_vertical=True so that get_curve() (IfcGradientCurve, on the "Axis" representation) # include_vertical=True so that get_curve() (IfcGradientCurve, on the "Axis" representation)
# and get_basis_curve() (IfcCompositeCurve, on the "FootPrint" representation) are different # and get_basis_curve() (IfcCompositeCurve, on the "FootPrint" representation) are different
# entities, letting the on_basis_curve option be observed. # entities, letting the on_basis_curve option be observed.
alignment = ifcopenshell.api.alignment.create(file, "TestAlignment", include_vertical=True, start_station=0.0) alignment = ifcopenshell.api.alignment.create(file, "TestAlignment", include_vertical=True)
assert ifcopenshell.api.alignment.get_basis_curve(alignment).is_a("IfcCompositeCurve") assert ifcopenshell.api.alignment.get_basis_curve(alignment).is_a("IfcCompositeCurve")
assert ifcopenshell.api.alignment.get_curve(alignment).is_a("IfcGradientCurve") assert ifcopenshell.api.alignment.get_curve(alignment).is_a("IfcGradientCurve")
assert ifcopenshell.api.alignment.get_basis_curve(alignment) != ifcopenshell.api.alignment.get_curve(alignment) assert ifcopenshell.api.alignment.get_basis_curve(alignment) != ifcopenshell.api.alignment.get_curve(alignment)
@@ -110,6 +110,44 @@ def test_add_stationing_referent_on_basis_curve_false():
assert basis_curve != ifcopenshell.api.alignment.get_basis_curve(alignment) assert basis_curve != ifcopenshell.api.alignment.get_basis_curve(alignment)
def test_add_stationing_referent_without_geometry_placed_at_global_origin():
# An alignment with no resolvable basis curve gets an IfcLocalPlacement at (0, 0),
# not an IfcLinearPlacement, and does not require the alignment to have an ObjectPlacement.
file = _create_test_file()
alignment = ifcopenshell.api.alignment.create(file, "TestAlignment", include_geometry=False)
referent = ifcopenshell.api.alignment.add_stationing_referent(
file, "1+00.000", alignment, distance_along=0.0, station=100.0
)
_assert_common_referent_asserts(referent, "1+00.000", 100.0)
assert referent.ObjectPlacement.is_a("IfcLocalPlacement")
assert referent.ObjectPlacement.RelativePlacement.Location.Coordinates == (0.0, 0.0)
def test_add_stationing_referent_has_increasing_station():
file = _create_test_file()
alignment = ifcopenshell.api.alignment.create(file, "TestAlignment", include_geometry=False)
# default: HasIncreasingStation is not written
default_referent = ifcopenshell.api.alignment.add_stationing_referent(
file, "1+00.000", alignment, distance_along=0.0, station=100.0
)
assert (
ifcopenshell.util.element.get_pset(default_referent, name="Pset_Stationing", prop="HasIncreasingStation") is None
)
# explicit False (reverse stationing) is written
reverse_referent = ifcopenshell.api.alignment.add_stationing_referent(
file, "R", alignment, distance_along=0.0, station=100.0, has_increasing_station=False
)
assert (
ifcopenshell.util.element.get_pset(reverse_referent, name="Pset_Stationing", prop="HasIncreasingStation") is False
)
test_add_stationing_referent_on_basis_curve_none_defaults_to_basis_curve() test_add_stationing_referent_on_basis_curve_none_defaults_to_basis_curve()
test_add_stationing_referent_on_basis_curve_true() test_add_stationing_referent_on_basis_curve_true()
test_add_stationing_referent_on_basis_curve_false() test_add_stationing_referent_on_basis_curve_false()
test_add_stationing_referent_without_geometry_placed_at_global_origin()
test_add_stationing_referent_has_increasing_station()
@@ -25,7 +25,7 @@ import ifcopenshell.api.unit
import ifcopenshell.util.element import ifcopenshell.util.element
try: try:
ifcopenshell.file(schema="IFC4X3") ifcopenshell.file(schema="IFC4X3_ADD2")
IFC4X3_AVAILABLE = True IFC4X3_AVAILABLE = True
except RuntimeError: except RuntimeError:
IFC4X3_AVAILABLE = False IFC4X3_AVAILABLE = False
@@ -33,7 +33,7 @@ except RuntimeError:
@pytest.mark.skipif(not IFC4X3_AVAILABLE, reason="IFC4X3 not available") @pytest.mark.skipif(not IFC4X3_AVAILABLE, reason="IFC4X3 not available")
def test_add_stationing_to_alignment(): def test_add_stationing_to_alignment():
file = ifcopenshell.file(schema="IFC4X3") file = ifcopenshell.file(schema="IFC4X3_ADD2")
project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test") project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
length = ifcopenshell.api.unit.add_si_unit(file, unit_type="LENGTHUNIT") length = ifcopenshell.api.unit.add_si_unit(file, unit_type="LENGTHUNIT")
ifcopenshell.api.unit.assign_unit(file, units=[length]) ifcopenshell.api.unit.assign_unit(file, units=[length])
@@ -46,7 +46,10 @@ def test_add_stationing_to_alignment():
parent=geometric_representation_context, parent=geometric_representation_context,
) )
alignment = ifcopenshell.api.alignment.create(file, "TestAlignment", start_station=2000.0) alignment = ifcopenshell.api.alignment.create(file, "TestAlignment")
ifcopenshell.api.alignment.add_stationing_referent(
file, "TestAlignment 2+000.000", alignment, distance_along=0.0, station=2000.0
)
stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment) stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment)
referent = stationing_nest.RelatedObjects[0] referent = stationing_nest.RelatedObjects[0]
@@ -24,7 +24,7 @@ import ifcopenshell.api.context
import ifcopenshell.api.unit import ifcopenshell.api.unit
try: try:
ifcopenshell.file(schema="IFC4X3") ifcopenshell.file(schema="IFC4X3_ADD2")
IFC4X3_AVAILABLE = True IFC4X3_AVAILABLE = True
except RuntimeError: except RuntimeError:
IFC4X3_AVAILABLE = False IFC4X3_AVAILABLE = False
@@ -32,13 +32,14 @@ except RuntimeError:
@pytest.mark.skipif(not IFC4X3_AVAILABLE, reason="IFC4X3 not available") @pytest.mark.skipif(not IFC4X3_AVAILABLE, reason="IFC4X3 not available")
def test_add_vertical_alignment(): def test_add_vertical_alignment():
file = ifcopenshell.file(schema="IFC4X3") file = ifcopenshell.file(schema="IFC4X3_ADD2")
project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test") project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
length = ifcopenshell.api.unit.add_si_unit(file, unit_type="LENGTHUNIT") length = ifcopenshell.api.unit.add_si_unit(file, unit_type="LENGTHUNIT")
ifcopenshell.api.unit.assign_unit(file, units=[length]) ifcopenshell.api.unit.assign_unit(file, units=[length])
geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model") geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model")
alignment = ifcopenshell.api.alignment.create(file, "A1", include_vertical=False) alignment = ifcopenshell.api.alignment.create(file, "A1", include_vertical=False)
ifcopenshell.api.alignment.add_stationing_referent(file, "0+00.00", alignment, distance_along=0.0, station=0.0)
assert len(alignment.IsDecomposedBy) == 0 # no child alignments assert len(alignment.IsDecomposedBy) == 0 # no child alignments
assert len(alignment.IsNestedBy) == 2 # nests for layout and referents assert len(alignment.IsNestedBy) == 2 # nests for layout and referents
@@ -22,40 +22,6 @@ import pytest
import ifcopenshell.api.alignment import ifcopenshell.api.alignment
import ifcopenshell.api.context import ifcopenshell.api.context
import ifcopenshell.api.unit import ifcopenshell.api.unit
import ifcopenshell.util.element
def test_create_stationing_referent_name_includes_alignment_name():
"""create() creates an initial stationing IfcReferent from start_station
(see add_stationing_referent()). Its Name must include the alignment's
own name, the same "<alignment name> <station>" convention
update_key_point_referents() uses for its own referents -- otherwise
this referent is indistinguishable by name alone from the same-named
referent of any OTHER alignment in the same file, unlike every other
referent in the model."""
file = ifcopenshell.file(schema="IFC4X3_ADD2")
project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
length = ifcopenshell.api.unit.add_conversion_based_unit(file, name="foot")
ifcopenshell.api.unit.assign_unit(file, units=[length])
geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model")
ifcopenshell.api.context.add_context(
file,
context_type="Model",
context_identifier="Axis",
target_view="MODEL_VIEW",
parent=geometric_representation_context,
)
alignment = ifcopenshell.api.alignment.create(file, "TestAlignment", start_station=4900.0)
referents = [
r
for r in ifcopenshell.util.element.get_components(alignment)
if r.is_a("IfcReferent")
and ifcopenshell.util.element.get_pset(r, name="Pset_Stationing", prop="Station") == 4900.0
]
assert len(referents) == 1
assert referents[0].Name == "TestAlignment 49+00.00"
try: try:
@@ -67,7 +33,7 @@ except RuntimeError:
@pytest.mark.skipif(not IFC4X3_AVAILABLE, reason="IFC4X3 not available") @pytest.mark.skipif(not IFC4X3_AVAILABLE, reason="IFC4X3 not available")
def test_create(): def test_create():
file = ifcopenshell.file(schema="IFC4X3") file = ifcopenshell.file(schema="IFC4X3_ADD2")
project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test") project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
length = ifcopenshell.api.unit.add_si_unit(file, unit_type="LENGTHUNIT") length = ifcopenshell.api.unit.add_si_unit(file, unit_type="LENGTHUNIT")
ifcopenshell.api.unit.assign_unit(file, units=[length]) ifcopenshell.api.unit.assign_unit(file, units=[length])
@@ -88,6 +54,12 @@ def test_create():
ali = ifcopenshell.api.alignment.create(file, "A1", include_vertical[i], include_cant[i]) ali = ifcopenshell.api.alignment.create(file, "A1", include_vertical[i], include_cant[i])
assert ali != None assert ali != None
# create() does not define stationing - the alignment has no referent nest at all
assert ifcopenshell.api.alignment.get_stationing_nest(file, ali) is None
assert not any(
related.is_a("IfcReferent") for nest in ali.IsNestedBy for related in nest.RelatedObjects
)
# verify the geometric representation was created # verify the geometric representation was created
curve = ifcopenshell.api.alignment.get_curve(ali) curve = ifcopenshell.api.alignment.get_curve(ali)
assert curve.is_a() == expected_curve_type[i] assert curve.is_a() == expected_curve_type[i]
@@ -24,7 +24,7 @@ import ifcopenshell.api.context
import ifcopenshell.api.unit import ifcopenshell.api.unit
try: try:
ifcopenshell.file(schema="IFC4X3") ifcopenshell.file(schema="IFC4X3_ADD2")
IFC4X3_AVAILABLE = True IFC4X3_AVAILABLE = True
except RuntimeError: except RuntimeError:
IFC4X3_AVAILABLE = False IFC4X3_AVAILABLE = False
@@ -32,7 +32,7 @@ except RuntimeError:
@pytest.mark.skipif(not IFC4X3_AVAILABLE, reason="IFC4X3 not available") @pytest.mark.skipif(not IFC4X3_AVAILABLE, reason="IFC4X3 not available")
def test_create_by_pi_method(): def test_create_by_pi_method():
file = ifcopenshell.file(schema="IFC4X3") file = ifcopenshell.file(schema="IFC4X3_ADD2")
project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test") project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
length = ifcopenshell.api.unit.add_conversion_based_unit(file, name="foot") length = ifcopenshell.api.unit.add_conversion_based_unit(file, name="foot")
ifcopenshell.api.unit.assign_unit(file, units=[length]) ifcopenshell.api.unit.assign_unit(file, units=[length])
@@ -51,7 +51,7 @@ def test_create_by_pi_method():
lengths = [(1600.0), (1200.0), (2000.0), (800.0)] lengths = [(1600.0), (1200.0), (2000.0), (800.0)]
alignment = ifcopenshell.api.alignment.create_by_pi_method( alignment = ifcopenshell.api.alignment.create_by_pi_method(
file, "TestAlignment", coordinates, radii, vpoints, lengths file, "TestAlignment", coordinates, radii, vpoints, lengths, start_station=10000.0
) )
assert len(alignment.IsDecomposedBy) == 0 # no child alignments assert len(alignment.IsDecomposedBy) == 0 # no child alignments
@@ -57,7 +57,7 @@ def test_create_representation():
(12799.99998062693, 89.99999997234107, 0.9999875002340269, -0.004999937569813611), (12799.99998062693, 89.99999997234107, 0.9999875002340269, -0.004999937569813611),
] ]
file = ifcopenshell.file(schema="IFC4X3") file = ifcopenshell.file(schema="IFC4X3_ADD2")
file.header.file_description.description = ["ViewDefinition [Alignment-basedView]"] file.header.file_description.description = ["ViewDefinition [Alignment-basedView]"]
project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="FHWA Alignment") project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="FHWA Alignment")
@@ -77,9 +77,15 @@ def test_create_representation():
site = file.createIfcSite(GlobalId=ifcopenshell.guid.new(), Name="Site") site = file.createIfcSite(GlobalId=ifcopenshell.guid.new(), Name="Site")
ifcopenshell.api.aggregate.assign_object(file, relating_object=project, products=[site]) ifcopenshell.api.aggregate.assign_object(file, relating_object=project, products=[site])
alignment = ifcopenshell.api.alignment.create( alignment = ifcopenshell.api.alignment.create(file, "E-Line", include_vertical=True, include_geometry=False)
file, "E-Line", include_vertical=True, start_station=10000.0, include_geometry=False
# stationing is defined before the geometry exists, so the referent is placed at the global
# origin; create_representation() must restate it onto the basis curve at DistanceAlong 0.0
ifcopenshell.api.alignment.add_stationing_referent(
file, "E-Line 100+00.00", alignment, distance_along=0.0, station=10000.0
) )
start_referent = ifcopenshell.api.alignment.get_stationing_nest(file, alignment).RelatedObjects[0]
assert start_referent.ObjectPlacement.is_a("IfcLocalPlacement")
# alignment is referenced into spatial structure of site per CT 4.1.5.1 # alignment is referenced into spatial structure of site per CT 4.1.5.1
ifcopenshell.api.spatial.reference_structure(file, products=[alignment], relating_structure=site) ifcopenshell.api.spatial.reference_structure(file, products=[alignment], relating_structure=site)
@@ -420,6 +426,10 @@ def test_create_representation():
ifcopenshell.api.alignment.create_representation(file, alignment) ifcopenshell.api.alignment.create_representation(file, alignment)
# the starting referent is now placed relative to the basis curve at DistanceAlong 0.0
assert start_referent.ObjectPlacement.is_a("IfcLinearPlacement")
assert start_referent.ObjectPlacement.RelativePlacement.Location.DistanceAlong.wrappedValue == 0.0
curve = ifcopenshell.api.alignment.get_basis_curve(alignment) curve = ifcopenshell.api.alignment.get_basis_curve(alignment)
assert curve.is_a("IfcCompositeCurve") assert curve.is_a("IfcCompositeCurve")
for s in curve.Segments: for s in curve.Segments:
@@ -23,7 +23,7 @@ import ifcopenshell.api.context
import ifcopenshell.api.unit import ifcopenshell.api.unit
try: try:
ifcopenshell.file(schema="IFC4X3") ifcopenshell.file(schema="IFC4X3_ADD2")
IFC4X3_AVAILABLE = True IFC4X3_AVAILABLE = True
except RuntimeError: except RuntimeError:
IFC4X3_AVAILABLE = False IFC4X3_AVAILABLE = False
@@ -31,7 +31,7 @@ except RuntimeError:
@pytest.mark.skipif(not IFC4X3_AVAILABLE, reason="IFC4X3 not available") @pytest.mark.skipif(not IFC4X3_AVAILABLE, reason="IFC4X3 not available")
def test_distance_along_from_station(): def test_distance_along_from_station():
file = ifcopenshell.file(schema="IFC4X3") file = ifcopenshell.file(schema="IFC4X3_ADD2")
project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test") project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
length = ifcopenshell.api.unit.add_conversion_based_unit(file, name="foot") length = ifcopenshell.api.unit.add_conversion_based_unit(file, name="foot")
ifcopenshell.api.unit.assign_unit(file, units=[length]) ifcopenshell.api.unit.assign_unit(file, units=[length])
@@ -64,7 +64,7 @@ def test_distance_along_from_station_with_station_equations():
# Reproduces the worked example from the IFC Alignment Geometry Implementation Guide, chapter 9.2.6: # Reproduces the worked example from the IFC Alignment Geometry Implementation Guide, chapter 9.2.6:
# a gap equation (P3: incoming 14+00.00, outgoing 17+00.00) and an overlap equation # a gap equation (P3: incoming 14+00.00, outgoing 17+00.00) and an overlap equation
# (P4: incoming 19+00.00, outgoing 18+50.00). # (P4: incoming 19+00.00, outgoing 18+50.00).
file = ifcopenshell.file(schema="IFC4X3") file = ifcopenshell.file(schema="IFC4X3_ADD2")
project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test") project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
length = ifcopenshell.api.unit.add_conversion_based_unit(file, name="foot") length = ifcopenshell.api.unit.add_conversion_based_unit(file, name="foot")
ifcopenshell.api.unit.assign_unit(file, units=[length]) ifcopenshell.api.unit.assign_unit(file, units=[length])
@@ -111,5 +111,159 @@ def test_distance_along_from_station_with_station_equations():
assert distance_along_from_station(file, alignment, 1875.0) == pytest.approx(625.0) assert distance_along_from_station(file, alignment, 1875.0) == pytest.approx(625.0)
def test_distance_along_from_station_reverse_stationing_with_gap_equation():
# A reverse-stationed alignment: HasIncreasingStation=False on the starting referent, station
# labels decreasing as distance along increases, with one gap equation.
# R1: D 0.0, Station 20+00.00, HasIncreasingStation=False
# R2: D 400.0, Station 15+50.00, IncomingStation 16+00.00 (gap of 50)
# R3: D 800.0, Station 11+50.00
file = ifcopenshell.file(schema="IFC4X3_ADD2")
project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
length = ifcopenshell.api.unit.add_conversion_based_unit(file, name="foot")
ifcopenshell.api.unit.assign_unit(file, units=[length])
geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model")
ifcopenshell.api.context.add_context(
file,
context_type="Model",
context_identifier="Axis",
target_view="MODEL_VIEW",
parent=geometric_representation_context,
)
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)]
alignment = ifcopenshell.api.alignment.create_by_pi_method(file, "TestAlignment", coordinates, radii)
ifcopenshell.api.alignment.add_stationing_referent(
file, "R1", alignment, distance_along=0.0, station=2000.0, has_increasing_station=False
)
ifcopenshell.api.alignment.add_stationing_referent(
file, "R2", alignment, distance_along=400.0, station=1550.0, incoming_station=1600.0
)
ifcopenshell.api.alignment.add_stationing_referent(file, "R3", alignment, distance_along=800.0, station=1150.0)
distance_along_from_station = ifcopenshell.api.alignment.distance_along_from_station
# Sta. 18+00.00 -> governed by R1
assert distance_along_from_station(file, alignment, 1800.0) == pytest.approx(200.0)
# Sta. 13+00.00 -> governed by R2; naive subtraction from the start would overstate by the 50 ft gap
assert distance_along_from_station(file, alignment, 1300.0) == pytest.approx(650.0)
# Sta. 15+75.00 falls inside the range the gap equation at R2 skipped -> no distance along
assert distance_along_from_station(file, alignment, 1575.0) is None
def _long_alignment(file):
project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
length = ifcopenshell.api.unit.add_conversion_based_unit(file, name="foot")
ifcopenshell.api.unit.assign_unit(file, units=[length])
geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model")
ifcopenshell.api.context.add_context(
file,
context_type="Model",
context_identifier="Axis",
target_view="MODEL_VIEW",
parent=geometric_representation_context,
)
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)]
return ifcopenshell.api.alignment.create_by_pi_method(file, "TestAlignment", coordinates, radii)
def test_distance_along_from_station_direction_switch_increasing_then_decreasing():
# A peak: station labels rise to R2 then fall. HasIncreasingStation=False on R2 governs the
# region after it. Data is self-consistent (no equations): each region is 500 units long with
# a 500 station-label swing.
# R1 D 0.0 S 1000.0
# R2 D 500.0 S 1500.0 HasIncreasingStation=False
# R3 D 1000.0 S 1000.0
file = ifcopenshell.file(schema="IFC4X3_ADD2")
alignment = _long_alignment(file)
ifcopenshell.api.alignment.add_stationing_referent(file, "R1", alignment, distance_along=0.0, station=1000.0)
ifcopenshell.api.alignment.add_stationing_referent(
file, "R2", alignment, distance_along=500.0, station=1500.0, has_increasing_station=False
)
ifcopenshell.api.alignment.add_stationing_referent(file, "R3", alignment, distance_along=1000.0, station=1000.0)
dafs = ifcopenshell.api.alignment.distance_along_from_station
# Sta 12+00 appears twice (rising at D 200, falling at D 800); the downstream match is returned
assert dafs(file, alignment, 1200.0) == pytest.approx(800.0)
# the peak label sits at the single point D 500
assert dafs(file, alignment, 1500.0) == pytest.approx(500.0)
# the end label
assert dafs(file, alignment, 1000.0) == pytest.approx(1000.0)
# Sta 16+00 is above the peak - it exists nowhere on the alignment
assert dafs(file, alignment, 1600.0) is None
def test_distance_along_from_station_direction_switch_decreasing_then_increasing():
# A valley: labels fall to R2 then rise. R1 starts the alignment decreasing
# (HasIncreasingStation=False); R2 switches it back to increasing.
# R1 D 0.0 S 2000.0 HasIncreasingStation=False
# R2 D 500.0 S 1500.0 HasIncreasingStation=True
# R3 D 1000.0 S 2000.0
file = ifcopenshell.file(schema="IFC4X3_ADD2")
alignment = _long_alignment(file)
ifcopenshell.api.alignment.add_stationing_referent(
file, "R1", alignment, distance_along=0.0, station=2000.0, has_increasing_station=False
)
ifcopenshell.api.alignment.add_stationing_referent(
file, "R2", alignment, distance_along=500.0, station=1500.0, has_increasing_station=True
)
ifcopenshell.api.alignment.add_stationing_referent(file, "R3", alignment, distance_along=1000.0, station=2000.0)
dafs = ifcopenshell.api.alignment.distance_along_from_station
# Sta 18+00 appears twice (falling at D 200, rising at D 800); the downstream match is returned
assert dafs(file, alignment, 1800.0) == pytest.approx(800.0)
# the valley label sits at the single point D 500
assert dafs(file, alignment, 1500.0) == pytest.approx(500.0)
# Sta 14+00 is below the valley - it exists nowhere on the alignment
assert dafs(file, alignment, 1400.0) is None
def test_distance_along_from_station_multiple_direction_switches():
# Not realistic, but valid IFC: stationing direction flips at every referent.
# R1 D 0.0 S 1000.0 increasing [0, 300] 1000 -> 1300
# R2 D 300.0 S 1300.0 HasIncreasingStation=False decreasing [300, 600] 1300 -> 1000
# R3 D 600.0 S 1000.0 HasIncreasingStation=True increasing [600, 900] 1000 -> 1300
# R4 D 900.0 S 1300.0 HasIncreasingStation=False decreasing [900, 1200] 1300 -> 1000
# R5 D 1200.0 S 1000.0
file = ifcopenshell.file(schema="IFC4X3_ADD2")
alignment = _long_alignment(file)
ifcopenshell.api.alignment.add_stationing_referent(file, "R1", alignment, distance_along=0.0, station=1000.0)
ifcopenshell.api.alignment.add_stationing_referent(
file, "R2", alignment, distance_along=300.0, station=1300.0, has_increasing_station=False
)
ifcopenshell.api.alignment.add_stationing_referent(
file, "R3", alignment, distance_along=600.0, station=1000.0, has_increasing_station=True
)
ifcopenshell.api.alignment.add_stationing_referent(
file, "R4", alignment, distance_along=900.0, station=1300.0, has_increasing_station=False
)
ifcopenshell.api.alignment.add_stationing_referent(file, "R5", alignment, distance_along=1200.0, station=1000.0)
dafs = ifcopenshell.api.alignment.distance_along_from_station
# referents nest in DistanceAlong order regardless of the direction flips
nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment)
assert [r.Name for r in nest.RelatedObjects] == ["R1", "R2", "R3", "R4", "R5"]
# Sta 11+00 appears in every one of the four regions; the last (most downstream) match wins
assert dafs(file, alignment, 1100.0) == pytest.approx(1100.0)
# the shared min label resolves to the very end of the alignment
assert dafs(file, alignment, 1000.0) == pytest.approx(1200.0)
# Sta 13+50 is above every peak - nowhere on the alignment
assert dafs(file, alignment, 1350.0) is None
# a label exactly at a peak
assert dafs(file, alignment, 1300.0) == pytest.approx(900.0)
test_distance_along_from_station() test_distance_along_from_station()
test_distance_along_from_station_with_station_equations() test_distance_along_from_station_with_station_equations()
test_distance_along_from_station_reverse_stationing_with_gap_equation()
test_distance_along_from_station_direction_switch_increasing_then_decreasing()
test_distance_along_from_station_direction_switch_decreasing_then_increasing()
test_distance_along_from_station_multiple_direction_switches()
@@ -24,7 +24,7 @@ import ifcopenshell.api.context
import ifcopenshell.api.unit import ifcopenshell.api.unit
try: try:
ifcopenshell.file(schema="IFC4X3") ifcopenshell.file(schema="IFC4X3_ADD2")
IFC4X3_AVAILABLE = True IFC4X3_AVAILABLE = True
except RuntimeError: except RuntimeError:
IFC4X3_AVAILABLE = False IFC4X3_AVAILABLE = False
@@ -35,7 +35,7 @@ except RuntimeError:
# compound curve (no tangent between curves) # compound curve (no tangent between curves)
@pytest.mark.skipif(not IFC4X3_AVAILABLE, reason="IFC4X3 not available") @pytest.mark.skipif(not IFC4X3_AVAILABLE, reason="IFC4X3 not available")
def test_horizontal_layout_by_pi_method(): def test_horizontal_layout_by_pi_method():
file = ifcopenshell.file(schema="IFC4X3") file = ifcopenshell.file(schema="IFC4X3_ADD2")
project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test") project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
length = ifcopenshell.api.unit.add_conversion_based_unit(file, name="foot") length = ifcopenshell.api.unit.add_conversion_based_unit(file, name="foot")
ifcopenshell.api.unit.assign_unit(file, units=[length]) ifcopenshell.api.unit.assign_unit(file, units=[length])
@@ -51,7 +51,9 @@ def test_horizontal_layout_by_pi_method():
coordinates = [(838.760, 224.745), (965.926, 258.819), (1226.296, 258.819), (1350.817, 291.415)] coordinates = [(838.760, 224.745), (965.926, 258.819), (1226.296, 258.819), (1350.817, 291.415)]
radii = [(1000.0), (1000.0)] radii = [(1000.0), (1000.0)]
alignment = ifcopenshell.api.alignment.create_by_pi_method(file, "TestAlignment", coordinates, radii) alignment = ifcopenshell.api.alignment.create_by_pi_method(
file, "TestAlignment", coordinates, radii, start_station=10000.0
)
assert len(alignment.IsDecomposedBy) == 0 # no child alignments assert len(alignment.IsDecomposedBy) == 0 # no child alignments
assert len(alignment.IsNestedBy) == 2 assert len(alignment.IsNestedBy) == 2
@@ -33,7 +33,7 @@ LENGTHS = [1600.0, 1200.0, 2000.0, 800.0]
def _new_file(): def _new_file():
file = ifcopenshell.file(schema="IFC4X3") file = ifcopenshell.file(schema="IFC4X3_ADD2")
file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test") file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
length = ifcopenshell.api.unit.add_si_unit(file, unit_type="LENGTHUNIT") length = ifcopenshell.api.unit.add_si_unit(file, unit_type="LENGTHUNIT")
ifcopenshell.api.unit.assign_unit(file, units=[length]) ifcopenshell.api.unit.assign_unit(file, units=[length])
@@ -49,7 +49,7 @@ def _new_file():
def _new_file_no_context(): def _new_file_no_context():
file = ifcopenshell.file(schema="IFC4X3") file = ifcopenshell.file(schema="IFC4X3_ADD2")
file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test") file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
length = ifcopenshell.api.unit.add_si_unit(file, unit_type="LENGTHUNIT") length = ifcopenshell.api.unit.add_si_unit(file, unit_type="LENGTHUNIT")
ifcopenshell.api.unit.assign_unit(file, units=[length]) ifcopenshell.api.unit.assign_unit(file, units=[length])
@@ -341,7 +341,8 @@ def test_single_real_segment_produces_only_boundary_labels():
def test_start_station_composes_for_child_alignment(): def test_start_station_composes_for_child_alignment():
file = _new_file() file = _new_file()
alignment = ifcopenshell.api.alignment.create(file, "A1", include_vertical=False, start_station=100.0) alignment = ifcopenshell.api.alignment.create(file, "A1", include_vertical=False)
ifcopenshell.api.alignment.add_stationing_referent(file, "A1 1+00.00", alignment, distance_along=0.0, station=100.0)
ifcopenshell.api.alignment.add_vertical_layout(file, alignment) ifcopenshell.api.alignment.add_vertical_layout(file, alignment)
ifcopenshell.api.alignment.add_vertical_layout(file, alignment) # forces the child-alignment split ifcopenshell.api.alignment.add_vertical_layout(file, alignment) # forces the child-alignment split
@@ -379,7 +380,8 @@ def test_rel_nests_from_ancestor_used_for_naming_and_nesting():
to an ancestor alignment's own rel_nests -- e.g. the same one already holding that to an ancestor alignment's own rel_nests -- e.g. the same one already holding that
ancestor's horizontal key points -- rather than the child's generic "Child of X" name.""" ancestor's horizontal key points -- rather than the child's generic "Child of X" name."""
file = _new_file() file = _new_file()
alignment = ifcopenshell.api.alignment.create(file, "A1", include_vertical=False, start_station=100.0) alignment = ifcopenshell.api.alignment.create(file, "A1", include_vertical=False)
ifcopenshell.api.alignment.add_stationing_referent(file, "A1 1+00.00", alignment, distance_along=0.0, station=100.0)
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment) horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
horizontal_nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal) horizontal_nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
horizontal_count = len(horizontal_nest.RelatedObjects) horizontal_count = len(horizontal_nest.RelatedObjects)
@@ -24,7 +24,7 @@ import ifcopenshell.api.context
import ifcopenshell.api.unit import ifcopenshell.api.unit
try: try:
ifcopenshell.file(schema="IFC4X3") ifcopenshell.file(schema="IFC4X3_ADD2")
IFC4X3_AVAILABLE = True IFC4X3_AVAILABLE = True
except RuntimeError: except RuntimeError:
IFC4X3_AVAILABLE = False IFC4X3_AVAILABLE = False
@@ -35,7 +35,7 @@ except RuntimeError:
# compound vertical curve (no gradient between curves) # compound vertical curve (no gradient between curves)
@pytest.mark.skipif(not IFC4X3_AVAILABLE, reason="IFC4X3 not available") @pytest.mark.skipif(not IFC4X3_AVAILABLE, reason="IFC4X3 not available")
def test_vertical_layout_by_pi_method(): def test_vertical_layout_by_pi_method():
file = ifcopenshell.file(schema="IFC4X3") file = ifcopenshell.file(schema="IFC4X3_ADD2")
project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test") project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
length = ifcopenshell.api.unit.add_conversion_based_unit(file, name="foot") length = ifcopenshell.api.unit.add_conversion_based_unit(file, name="foot")
ifcopenshell.api.unit.assign_unit(file, units=[length]) ifcopenshell.api.unit.assign_unit(file, units=[length])
@@ -61,6 +61,8 @@ def test_vertical_layout_by_pi_method():
ifcopenshell.api.alignment.create_layout_segment(file, hlayout, segment1) ifcopenshell.api.alignment.create_layout_segment(file, hlayout, segment1)
ifcopenshell.api.alignment.add_stationing_referent(file, "0+00.00", alignment, distance_along=0.0, station=0.0)
vpoints = [(0.0, 110.0), (400.0, 100.0), (800.0, 115.0), (1300.0, 125.0), (1800.0, 105.0)] vpoints = [(0.0, 110.0), (400.0, 100.0), (800.0, 115.0), (1300.0, 125.0), (1800.0, 105.0)]
lengths = [(800.0), (0.0), (1000.0)] lengths = [(800.0), (0.0), (1000.0)]
vlayout = ifcopenshell.api.alignment.get_vertical_layout(alignment) vlayout = ifcopenshell.api.alignment.get_vertical_layout(alignment)