Merge branch 'datamodel-v1.0' into ifcviewer-wgpu

This commit is contained in:
Thomas Krijnen
2026-07-03 10:30:16 +02:00
committed by GitHub
252 changed files with 35575 additions and 3562 deletions
+2 -2
View File
@@ -5,8 +5,8 @@ VERSION_DATE:=$(shell date '+%y%m%d')
PYVERSION:=py311
PLATFORM:=linux64
PYTHON:=python3.11
PIP:=pip3.11
PYTHON:=python3
PIP:=pip3
SED:=sed -i
VENV_ACTIVATE:=bin/activate
+3 -3
View File
@@ -57,13 +57,13 @@ Dry-run to validate without modifying the file::
Apply an API function to each element in a JSON array from stdin (``{field}``
placeholders are substituted from each item; model is opened and saved once)::
$ ifcquery model.ifc select 'IfcWindow' | ifcedit foreach model.ifc root.remove_product --product {id}
$ ifcquery model.ifc select 'IfcWindow' | ifcedit foreach model.ifc root.remove_product --product '{id}'
$ ifcquery model.ifc select 'IfcDoor' | ifcedit foreach model.ifc attribute.edit_attributes \
--product {id} --attributes '{"Name": "Door"}'
--product '{id}' --attributes '{"Name": "Door"}'
Write to a separate output file instead of overwriting::
$ ifcquery model.ifc select 'IfcWall' | ifcedit foreach model.ifc root.remove_product -o output.ifc --product {id}
$ ifcquery model.ifc select 'IfcWall' | ifcedit foreach model.ifc root.remove_product -o output.ifc --product '{id}'
Quantity take-off (writes ``IfcElementQuantity`` psets back to the file; requires C++ geometry bindings)::
+1 -1
View File
@@ -86,7 +86,7 @@ pass query results directly into ``ifcedit run`` parameters, or pipe JSON into
--products "$(ifcquery model.ifc --format ids select 'IfcWall')"
# Fan-out — one operation per element, model opened and saved once
$ ifcquery model.ifc select 'IfcWindow' | ifcedit foreach model.ifc root.remove_product --product {id}
$ ifcquery model.ifc select 'IfcWindow' | ifcedit foreach model.ifc root.remove_product --product '{id}'
# Render an element highlighted against everything related to it
$ ifcquery model.ifc render -o relations.png \
@@ -70,8 +70,10 @@ from .get_basis_curve import get_basis_curve
from .get_cant_layout import get_cant_layout
from .get_child_alignments import get_child_alignments
from .get_curve import get_curve
from .get_curve_segment import get_curve_segment
from .get_curve_segment_transition_code import get_curve_segment_transition_code
from .get_horizontal_layout import get_horizontal_layout
from .get_layout import get_layout
from .get_layout_curve import get_layout_curve
from .get_layout_segments import get_layout_segments
from .get_mapped_segments import get_mapped_segments
@@ -86,6 +88,7 @@ from .layout_vertical_alignment_by_pi_method import (
layout_vertical_alignment_by_pi_method,
)
from .name_segments import name_segments
from .update_end_point import update_end_point
from .update_fallback_position import update_fallback_position
from .util import *
@@ -112,8 +115,10 @@ __all__ = [
"get_cant_layout",
"get_child_alignments",
"get_curve",
"get_curve_segment",
"get_curve_segment_transition_code",
"get_horizontal_layout",
"get_layout",
"get_layout_curve",
"get_layout_segments",
"get_parent_alignment",
@@ -124,6 +129,7 @@ __all__ = [
"layout_vertical_alignment_by_pi_method",
"name_segments",
"register_referent_name_callback",
"update_end_point",
"update_fallback_position",
"get_mapped_segments",
]
@@ -16,6 +16,7 @@
# 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 typing import Union
import numpy as np
import ifcopenshell
@@ -24,6 +25,9 @@ import ifcopenshell.geom
import ifcopenshell.ifcopenshell_wrapper as ifcopenshell_wrapper
import ifcopenshell.util.unit
from ifcopenshell import entity_instance
from ifcopenshell.api.alignment._get_segment_endpoint import _get_segment_endpoint
from ifcopenshell.api.alignment._update_zero_length_segment_placement import _update_zero_length_segment_placement
from ifcopenshell.api.alignment._map_alignment_cant_segment import (
_map_alignment_cant_segment,
)
@@ -39,11 +43,26 @@ from ifcopenshell.api.alignment._update_curve_segment_transition_code import (
def _add_curve_segment_to_composite_curve(
file: ifcopenshell.file, curve_segment: entity_instance, composite_curve: entity_instance
):
file: ifcopenshell.file,
layout_segment: entity_instance,
curve_segment: entity_instance,
composite_curve: entity_instance,
) -> Union[np.array, None]:
"""
Adds a curve segment to a composite curve and returns the end point of the added segment.
:param file: The IFC file
:param layout_segment: The layout segment
:param curve_segment: The curve segment to be added
:param composite_curve: The composite curve to which the segment will be added
:return: The end point of the added segment or None if an error occurs
"""
if 0 < len(curve_segment.UsingCurves):
raise TypeError("IfcCurveSegment cannot belong to other curves")
prev_segment = None
zero_length_segment = None
settings = ifcopenshell.geom.settings()
if composite_curve.Segments == None or 0 == len(composite_curve.Segments):
# this is the first segment so just add it
@@ -56,22 +75,29 @@ def _add_curve_segment_to_composite_curve(
composite_curve.Segments += (curve_segment,)
assert len(curve_segment.UsingCurves) == 1
else:
# not the first segment, so get the zero_length segment (if it exists)
zero_length_segment = (
composite_curve.Segments[-1]
if ifcopenshell.api.alignment.has_zero_length_segment(composite_curve)
else None
)
prev_segment = None
# get the previous segment, which is either the on preceeding the zero length segment (if it exists) or
# the last curve segment if there is no zero length segment.
# This segment's transition code will need to be updated to match the new curve segment.
if zero_length_segment and 1 < len(composite_curve.Segments):
prev_segment = composite_curve.Segments[-2]
elif zero_length_segment == None:
prev_segment = composite_curve.Segments[-1]
curve_segment.Transition = "CONTINUOUS"
# IfcCompositeCurve is supposed to be comprised of continuous segments
curve_segment.Transition = "DISCONTINUOUS"
# get a list of all but the last segment (skips the zero length segment, if it exists)
segments = composite_curve.Segments[0:-1]
if zero_length_segment:
# if there is a zero length segment, need to append new curve_segment and the zero length segment to the array
# them update the composite curve segments with the new array
segments += (
curve_segment,
zero_length_segment,
@@ -79,31 +105,23 @@ def _add_curve_segment_to_composite_curve(
composite_curve.Segments = []
composite_curve.Segments += segments
else:
# if there is no zero length segment, we can just append the new curve segment to the existing array of segments
composite_curve.Segments += (curve_segment,)
if prev_segment:
_update_curve_segment_transition_code(prev_segment, curve_segment)
if prev_segment:
_update_curve_segment_transition_code(prev_segment, curve_segment)
if zero_length_segment:
settings = ifcopenshell.geom.settings()
segment_fn = ifcopenshell_wrapper.map_shape(settings, curve_segment)
segment_evaluator = ifcopenshell_wrapper.function_item_evaluator(settings, segment_fn)
e = segment_evaluator.evaluate(segment_fn.end())
end = np.array(e)
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file)
x = float(end[0, 3]) / unit_scale
y = float(end[1, 3]) / unit_scale
dx = float(end[0, 0])
dy = float(end[1, 0])
end_point = _get_segment_endpoint(file, layout_segment)
if zero_length_segment:
_update_zero_length_segment_placement(file, zero_length_segment, end_point)
_update_curve_segment_transition_code(curve_segment, zero_length_segment)
# assume IfcAxis2Placement2D
zero_length_segment.Placement.Location.Coordinates = (x, y)
zero_length_segment.Placement.RefDirection.DirectionRatios = (dx, dy)
_update_curve_segment_transition_code(curve_segment, zero_length_segment)
return end_point
def _add_segment_to_curve(file: ifcopenshell.file, segment: entity_instance, curve: entity_instance) -> None:
def _add_segment_to_curve(
file: ifcopenshell.file, layout_segment: entity_instance, curve: entity_instance
) -> Union[np.array, None]:
"""
Creates an IfcCurveSegment from the IfcAlignmentSegment and adds it to the representation curve. The IfcCurveSegment is added
at the end of the curve, but before the manditory zero length segment. The IfcCurveSegment.Transition for the segment
@@ -114,16 +132,18 @@ def _add_segment_to_curve(file: ifcopenshell.file, segment: entity_instance, cur
:return: None
"""
expected_types = ["IfcAlignmentSegment"]
if not segment.is_a() in expected_types:
if not layout_segment.is_a() in expected_types:
raise TypeError(
f"Expected entity type to be one of {[_ for _ in expected_types]}, instead received '{segment.is_a()}"
f"Expected entity type to be one of {[_ for _ in expected_types]}, instead received '{layout_segment.is_a()}"
)
if segment.DesignParameters.is_a("IfcAlignmentHorizontalSegment") and not curve.is_a("IfcCompositeCurve"):
if layout_segment.DesignParameters.is_a("IfcAlignmentHorizontalSegment") and not curve.is_a("IfcCompositeCurve"):
raise TypeError(f"Expected to see IfcCompositeCurve, instead received '{curve.is_a()}'.")
elif segment.DesignParameters.is_a("IfcAlignmentVerticalSegment") and not curve.is_a("IfcGradientCurve"):
elif layout_segment.DesignParameters.is_a("IfcAlignmentVerticalSegment") and not curve.is_a("IfcGradientCurve"):
raise TypeError(f"Expected to see IfcGradientCurve, instead received '{curve.is_a()}'.")
elif segment.DesignParameters.is_a("IfcAlignmentCantSegment") and not curve.is_a("IfcSegmentedReferenceCurve"):
elif layout_segment.DesignParameters.is_a("IfcAlignmentCantSegment") and not curve.is_a(
"IfcSegmentedReferenceCurve"
):
raise TypeError(f"Expected to see IfcSegmentedReferenceCurve, instead received '{curve.is_a()}'.")
expected_type = "IfcCompositeCurve"
@@ -131,16 +151,18 @@ def _add_segment_to_curve(file: ifcopenshell.file, segment: entity_instance, cur
raise TypeError(f"Expected to see {expected_type}, instead received {curve.is_a()}.")
# map the IfcAlignmentSegment to an IfcCurveSegment (or two in the case of helmert curves)
if segment.DesignParameters.is_a("IfcAlignmentHorizontalSegment"):
mapped_segments = _map_alignment_horizontal_segment(file, segment)
elif segment.DesignParameters.is_a("IfcAlignmentVerticalSegment"):
mapped_segments = _map_alignment_vertical_segment(file, segment)
elif segment.DesignParameters.is_a("IfcAlignmentCantSegment"):
cant_layout = segment.Nests[0].RelatingObject
mapped_segments = _map_alignment_cant_segment(file, segment, cant_layout.RailHeadDistance)
if layout_segment.DesignParameters.is_a("IfcAlignmentHorizontalSegment"):
mapped_segments = _map_alignment_horizontal_segment(file, layout_segment)
elif layout_segment.DesignParameters.is_a("IfcAlignmentVerticalSegment"):
mapped_segments = _map_alignment_vertical_segment(file, layout_segment)
elif layout_segment.DesignParameters.is_a("IfcAlignmentCantSegment"):
cant_layout = layout_segment.Nests[0].RelatingObject
mapped_segments = _map_alignment_cant_segment(file, layout_segment, cant_layout.RailHeadDistance)
else:
assert False
for mapped_segment in mapped_segments:
if mapped_segment:
_add_curve_segment_to_composite_curve(file, mapped_segment, curve)
end_point = _add_curve_segment_to_composite_curve(file, layout_segment, mapped_segment, curve)
return end_point
@@ -16,12 +16,14 @@
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import math
from typing import Union
import numpy as np
import ifcopenshell
import ifcopenshell.api.alignment
from ifcopenshell.api.alignment import _map_alignment_cant_segment
from ifcopenshell.api.alignment._update_zero_length_segment_placement import _update_zero_length_segment_placement
import ifcopenshell.api.nest
import ifcopenshell.api.pset
import ifcopenshell.geom
@@ -29,15 +31,29 @@ import ifcopenshell.util.alignment
import ifcopenshell.util.unit
from ifcopenshell import entity_instance, ifcopenshell_wrapper
from ifcopenshell.api.alignment._add_segment_to_curve import _add_segment_to_curve
from ifcopenshell.api.alignment._get_segment_endpoint import _get_segment_endpoint
from ifcopenshell.api.alignment._get_segment_start_point_label import (
_get_segment_start_point_label,
)
from ifcopenshell.api.alignment._map_alignment_cant_segment import (
_map_alignment_cant_segment,
)
from ifcopenshell.api.alignment._map_alignment_horizontal_segment import (
_map_alignment_horizontal_segment,
)
from ifcopenshell.api.alignment._map_alignment_vertical_segment import (
_map_alignment_vertical_segment,
)
def _add_segment_to_layout(file: ifcopenshell.file, layout: entity_instance, segment: entity_instance) -> None:
def _add_segment_to_layout(
file: ifcopenshell.file, layout: entity_instance, layout_segment: entity_instance
) -> Union[np.array, None]:
"""
Adds an IfcAlignmentSegment to a layout alignment (IfcAlignmentHorizontal/Vertical/Cant). This segment is added at the end
of the layout, before the manditory zero length segment. An IfcCurveSegment is created for the corresponding geometric representation.
of the layout, before the manditory zero length segment (if it exists).
If the layout has a corresponding geometric representation, an IfcCurveSegment is created for it and appended at the end
of the representation curve, before the zero length segment (if it exists).
:param layout: The layout alignment
:param segment: The segment to be appended
@@ -50,160 +66,31 @@ def _add_segment_to_layout(file: ifcopenshell.file, layout: entity_instance, seg
f"Expected entity type to be one of {[_ for _ in expected_types]}, instead received {layout.is_a()}"
)
if not (segment.is_a("IfcAlignmentSegment")):
raise TypeError(f"Expected to see IfcAlignmentSegment, instead received {segment.is_a()}.")
curve = ifcopenshell.api.alignment.get_layout_curve(layout)
if not (layout_segment.is_a("IfcAlignmentSegment")):
raise TypeError(f"Expected to see IfcAlignmentSegment, instead received {layout_segment.is_a()}.")
# add the new segment to the layout
ifcopenshell.api.nest.assign_object(file, related_objects=[segment], relating_object=layout)
ifcopenshell.api.nest.assign_object(file, related_objects=[layout_segment], relating_object=layout)
# segment is attached at the end, but this is after the zero length segment
# swap the last two segments
ifcopenshell.api.nest.reorder_nesting(file, segment, -1, -1)
ifcopenshell.api.nest.reorder_nesting(file, layout_segment, -1, -1)
# For cant segments, the end point depends on the next segment. The next segment is the
# zero-length segment and it hasn't been updated to match the end point.
# For this reason, we can't compute the end point from the IfcCurveSegment, but instead we
# compute it from the layout segment design parameters.
end_point = _get_segment_endpoint(file, layout_segment)
# update the position of the zero length layout segment to be at the end point of the newly added segment
segment_nest = ifcopenshell.api.alignment.get_alignment_segment_nest(layout)
zero_length_layout_segment = segment_nest.RelatedObjects[-1]
_update_zero_length_segment_placement(file, zero_length_layout_segment, end_point)
# if there is a curve defined, add a new IfcCurveSegment to it.
# _add_segment_to_curve maps the layout segment to the appropriate IfcCurveSegment type and adds it to the curve.
curve = ifcopenshell.api.alignment.get_layout_curve(layout)
if curve:
# add the new segment to the geometric representation curve
_add_segment_to_curve(file, segment, curve)
_add_segment_to_curve(file, layout_segment, curve)
# gather information to:
# (1) add a referent at the start of this segment
# (2) update the name of the zero length segment's referent
# get the distance along the alignment to the start of the new segment
dist_along = 0.0
if layout.is_a("IfcAlignmentHorizontal"):
for nest in layout.IsNestedBy:
for seg in nest.RelatedObjects:
if seg.is_a("IfcAlignmentSegment"):
dist_along += seg.DesignParameters.SegmentLength
# the length of the current segment is in dist_along, so subtract it out
dist_along -= segment.DesignParameters.SegmentLength
else:
dist_along = segment.DesignParameters.StartDistAlong
# get the station of the start of the segment
alignment = ifcopenshell.api.alignment.get_alignment(layout)
start_station = ifcopenshell.api.alignment.get_alignment_start_station(file, alignment)
station = start_station + dist_along
# update the zero length layout segment
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file)
segment_nest = ifcopenshell.api.alignment.get_alignment_segment_nest(layout)
zero_length_segment = segment_nest.RelatedObjects[-1]
mapped_segments = ifcopenshell.api.alignment.get_mapped_segments(segment)
mapped_segment = mapped_segments[0] if mapped_segments[1] == None else mapped_segments[1]
# compute the end point matrix
settings = ifcopenshell.geom.settings()
segment_fn = ifcopenshell_wrapper.map_shape(settings, mapped_segment)
segment_evaluator = ifcopenshell_wrapper.function_item_evaluator(settings, segment_fn)
e = segment_evaluator.evaluate(segment_fn.end())
end = np.array(e)
# update the zero length segment semantic representation parameters
if zero_length_segment.DesignParameters.is_a("IfcAlignmentHorizontalSegment"):
x = float(end[0, 3]) / unit_scale
y = float(end[1, 3]) / unit_scale
dx = float(end[0, 0])
dy = float(end[1, 0])
zero_length_segment.DesignParameters.StartPoint.Coordinates = (x, y)
zero_length_segment.DesignParameters.StartDirection = dy / dx
elif zero_length_segment.DesignParameters.is_a("IfcAlignmentVerticalSegment"):
y = float(end[1, 3]) / unit_scale
zero_length_segment.DesignParameters.StartHeight = y
dx = float(end[0, 0])
dy = float(end[1, 0])
zero_length_segment.DesignParameters.StartGradient = dy / dx
zero_length_segment.DesignParameters.EndGradient = zero_length_segment.DesignParameters.StartGradient
else:
z = float(end[2, 3]) / unit_scale
dx = float(end[0, 1])
dy = float(end[1, 1])
dz = float(end[2, 1])
ds = math.sqrt(dx * dx + dy * dy)
slope = dz / ds
railhead = layout.RailHeadDistance
zero_length_segment.DesignParameters.StartCantLeft = z + slope * railhead / 2.0
zero_length_segment.DesignParameters.StartCantRight = z - slope * railhead / 2.0
# updated the referent's name because the referent is now at a new station
start_dist_along = 0.0
if segment.DesignParameters.is_a("IfcAlignmentHorizontalSegment"):
start_dist_along = dist_along + segment.DesignParameters.SegmentLength
else:
start_dist_along = segment.DesignParameters.StartDistAlong + segment.DesignParameters.HorizontalLength
zero_length_segment.DesignParameters.StartDistAlong = start_dist_along
end_referent = zero_length_segment.PositionedRelativeTo[0].RelatingPositioningElement
end_referent.Name = f"{_get_segment_start_point_label(zero_length_segment,None)} ({ifcopenshell.util.alignment.station_as_string(file,start_station+start_dist_along)})"
# update the referent's geometric representation's location
end_referent.ObjectPlacement.RelativePlacement.Location.DistanceAlong.wrappedValue = start_dist_along
settings = ifcopenshell.geom.settings()
basis_curve = ifcopenshell.api.alignment.get_basis_curve(alignment)
curve_fn = ifcopenshell_wrapper.map_shape(settings, basis_curve)
curve_evaluator = ifcopenshell_wrapper.function_item_evaluator(settings, curve_fn)
p = curve_evaluator.evaluate(start_dist_along * unit_scale)
p = np.array(p)
x = float(p[0, 3]) / unit_scale
y = float(p[1, 3]) / unit_scale
z = float(p[2, 3]) / unit_scale
rx = float(p[0, 0])
ry = float(p[1, 0])
rz = float(p[2, 0])
ax = float(p[0, 2])
ay = float(p[1, 2])
az = float(p[2, 2])
end_referent.ObjectPlacement.CartesianPosition.Location.Coordinates = (x, y, z)
end_referent.ObjectPlacement.CartesianPosition.Axis.DirectionRatios = (ax, ay, az)
end_referent.ObjectPlacement.CartesianPosition.RefDirection.DirectionRatios = (rx, ry, rz)
start_station = ifcopenshell.api.alignment.get_alignment_start_station(file, alignment)
end_referent_station = start_station + start_dist_along
pset_stationing = ifcopenshell.api.pset.add_pset(file, product=end_referent, name="Pset_Stationing")
ifcopenshell.api.pset.edit_pset(file, pset=pset_stationing, properties={"Station": end_referent_station})
# create the start of segment referent
# get the previous segment. Working from the end of the basis curve, -1 is zero length segment
# -2 is the newly added segment, so -3 is the segment occuring just before the newly added segment
prev_segment = segment_nest.RelatedObjects[-3] if 2 < len(segment_nest.RelatedObjects) else None
name = f"{_get_segment_start_point_label(prev_segment,segment)} ({ifcopenshell.util.alignment.station_as_string(file,station)})"
referent = ifcopenshell.api.alignment.add_stationing_referent(
file, alignment, distance_along=dist_along, station=station, name=name, positioned_product=segment
)
if len(curve.Segments) == 2 and layout.is_a("IfcAlignmentHorizontal"):
# this is the first real segment in the horizontal alignment
# update the location of the alignment's stationing referent
alignment = ifcopenshell.api.alignment.get_alignment(layout)
ref_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
stationing_referent = ref_nest.RelatedObjects[0]
p = curve_evaluator.evaluate(
stationing_referent.ObjectPlacement.RelativePlacement.Location.DistanceAlong.wrappedValue
)
p = np.array(p)
x = float(p[0, 3]) / unit_scale
y = float(p[1, 3]) / unit_scale
z = float(p[2, 3]) / unit_scale
rx = float(p[0, 0])
ry = float(p[1, 0])
rz = float(p[2, 0])
ax = float(p[0, 2])
ay = float(p[1, 2])
az = float(p[2, 2])
stationing_referent.ObjectPlacement.CartesianPosition.Location.Coordinates = (x, y, z)
stationing_referent.ObjectPlacement.CartesianPosition.Axis.DirectionRatios = (ax, ay, az)
stationing_referent.ObjectPlacement.CartesianPosition.RefDirection.DirectionRatios = (rx, ry, rz)
return end_point
@@ -42,17 +42,8 @@ def _add_zero_length_segment(file: ifcopenshell.file, layout: entity_instance) -
f"Expected layout type to be one of {[_ for _ in expected_types]}, instead received {layout.is_a()}"
)
if not ifcopenshell.api.alignment.add_zero_length_segment(file, layout, include_referent=False):
return # zero length segment not added, probably because it already exists
ifcopenshell.api.alignment.add_zero_length_segment(file, layout)
curve = ifcopenshell.api.alignment.get_layout_curve(layout)
if curve:
ifcopenshell.api.alignment.add_zero_length_segment(file, curve)
segment_nest = ifcopenshell.api.alignment.get_alignment_segment_nest(layout)
segment = segment_nest.RelatedObjects[-1]
alignment = ifcopenshell.api.alignment.get_alignment(layout)
station = ifcopenshell.api.alignment.get_alignment_start_station(file, alignment)
name = f"{_get_segment_start_point_label(segment,None)} ({ifcopenshell.util.alignment.station_as_string(file,station)})"
referent = ifcopenshell.api.alignment.add_stationing_referent(file, alignment, 0.0, station, name, segment)
@@ -35,6 +35,8 @@ def _create_geometric_representation(file: ifcopenshell.file, alignment: entity_
4) Vertical only (this occurs when horizontal is reused from a parent alignment) -> IfcGradientCurve
5) Vertical + Cant (this occurs when horizontal is reused from a parent alignment) -> IfcSegmentedReferenceCurve
This method creates the geometric representation entity and assigns it to the alignment, but does not populate the geometry of the representation.
:param alignment: The alignment for which the representation is being created
:return: None
"""
@@ -43,13 +45,6 @@ def _create_geometric_representation(file: ifcopenshell.file, alignment: entity_
if not alignment.is_a(expected_type):
raise TypeError(f"Expected {expected_type} but got {alignment.is_a()}")
placement = file.createIfcLocalPlacement(
PlacementRelTo=None,
RelativePlacement=file.createIfcAxis2Placement2D(Location=file.createIfcCartesianPoint(Coordinates=(0.0, 0.0))),
)
alignment.ObjectPlacement = placement
axis_geom_subcontext = ifcopenshell.api.alignment.get_axis_subcontext(file)
layouts = ifcopenshell.api.alignment.get_alignment_layouts(alignment)
@@ -126,7 +121,7 @@ def _create_geometric_representation(file: ifcopenshell.file, alignment: entity_
ifcopenshell.api.geometry.assign_representation(file, alignment, representation)
for child_alignment in children:
child_alignment.ObjectPlacement = placement
child_alignment.ObjectPlacement = alignment.ObjectPlacement
child_layouts = ifcopenshell.api.alignment.get_alignment_layouts(child_alignment)
if len(child_layouts) == 1:
assert child_layouts[0].is_a("IfcAlignmentVertical")
@@ -0,0 +1,89 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import ifcopenshell.api.alignment
import ifcopenshell.geom
from ifcopenshell import entity_instance, ifcopenshell_wrapper
from ifcopenshell.api.alignment._map_alignment_segment import _map_alignment_segment
from typing import Union
import math
import numpy as np
def _get_segment_endpoint(file: ifcopenshell.file, segment: entity_instance) -> Union[np.array, None]:
"""
Computes the 4x4 matrix for a segment end point. The segment can be an IfcAlignmentSegment
or IfcCurveSegment
"""
expected_types = ["IfcAlignmentSegment", "IfcCurveSegment"]
if not segment.is_a() in expected_types:
raise TypeError(
f"Expected entity type to be one of {[_ for _ in expected_types]}, instead received {segment.is_a()}"
)
file.begin_transaction() # use a transaction so we can discard any temporary IFC entities created
curve_segment = segment
if segment.is_a("IfcAlignmentSegment"):
layout = ifcopenshell.api.alignment.get_layout(segment)
mapped_segments = _map_alignment_segment(file, layout, segment)
curve_segment = mapped_segments[0] if mapped_segments[1] == None else mapped_segments[1]
# Inside of the IfcOpenShell C++ implementation where the IfcCurveSegment calculations occur,
# the composite curve owning the segment is evaluated to determine if a horizontal, vertical, or cant segment is being evaluated.
# This is necessary to determine how the end point of the curve segment is calculated.
# A temporary curve segment has been created and it needs to be associated with the correct composite curve for the end point to be calculated correctly.
# Inside the C++ implementation, if a composite curve isn't associated with the segment the segment is assumed to be horizontal. For this reason
# a temporary IfcCompositeCurve for horizontal segments doesn't need to be created.
if layout.is_a("IfcAlignmentVertical"):
gc = file.createIfcGradientCurve(Segments=[curve_segment])
elif layout.is_a("IfcAlignmentCant"):
# The evaluation of cant segments depend on the start conditions of the next segment. In the absense of a next segment the
# optional EndPoint is used. Since a tempoaryar IfcSegmentReferenceCurve is being used, there is not a next segment.
# For this reason the EndPoint must be created from the design parameters of the sementic segment definiton.
Dsl = segment.DesignParameters.StartCantLeft
Dsr = segment.DesignParameters.StartCantRight
Del = segment.DesignParameters.EndCantLeft if segment.DesignParameters.EndCantLeft != None else Dsl
Der = segment.DesignParameters.EndCantRight if segment.DesignParameters.EndCantRight != None else Dsr
cant = Der - Del
rh = layout.RailHeadDistance
Ay = cant / rh
Az = math.sqrt(rh**2 - cant**2) / rh
src = file.createIfcSegmentedReferenceCurve(
Segments=[curve_segment],
EndPoint=file.createIfcAxis2Placement3D(
Location=file.createIfcCartesianPoint((segment.DesignParameters.StartDistAlong, 0.5 * cant, 0.0)),
RefDirection=file.createIfcDirection((1.0, 0.0, 0.0)),
Axis=file.createIfcDirection((0.0, Ay, Az)),
),
)
settings = ifcopenshell.geom.settings()
segment_fn = ifcopenshell_wrapper.map_shape(settings, curve_segment)
segment_evaluator = ifcopenshell_wrapper.function_item_evaluator(settings, segment_fn)
x = segment_fn.end()
e = segment_evaluator.evaluate(x)
end = np.array(e)
file.discard_transaction()
return end
@@ -24,10 +24,12 @@ from ifcopenshell import entity_instance
def _get_axis(file: ifcopenshell.file, Ds: float, rail_head_distance: float) -> entity_instance:
Dy = rail_head_distance
Dz = 2 * Ds
D = math.sqrt(Dy * Dy + Dz * Dz)
return file.createIfcDirection((0.0, Dz / D, Dy / D))
# solves the ratio right triangle legs to hypotenous
# Dh^2 = Dy^2 + Dz^2
Dh = rail_head_distance # hypotenous
Dy = 2 * Ds # horizontal leg
Dz = math.sqrt(Dh * Dh - Dy * Dy) # vertical leg
return file.createIfcDirection((0.0, Dy / Dh, Dz / Dh))
def _map_constant_cant(
@@ -54,7 +56,7 @@ def _map_constant_cant(
Transition=transition,
Placement=file.createIfcAxis2Placement3D(
Location=start_point,
Axis=_get_axis(file, Ds, rail_head_distance),
Axis=_get_axis(file, 0.5 * (Dsr - Dsl), rail_head_distance),
RefDirection=file.createIfcDirection((math.cos(start_direction), math.sin(start_direction), 0.0)),
),
SegmentStart=file.createIfcLengthMeasure(0.0),
@@ -0,0 +1,49 @@
# 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 collections.abc import Sequence
import ifcopenshell
from ifcopenshell import entity_instance
from ifcopenshell.api.alignment._map_alignment_cant_segment import (
_map_alignment_cant_segment,
)
from ifcopenshell.api.alignment._map_alignment_horizontal_segment import (
_map_alignment_horizontal_segment,
)
from ifcopenshell.api.alignment._map_alignment_vertical_segment import (
_map_alignment_vertical_segment,
)
def _map_alignment_segment(
file: ifcopenshell.file, layout: entity_instance, segment: entity_instance
) -> Sequence[entity_instance]:
"""
Maps an IfcAlignmentSegment to its corresponding IfcCurveSegment(s) in the geometric representation.
The mapping is done based on the layout type and segment type.
"""
if layout.is_a("IfcAlignmentHorizontal"):
mapped_segments = _map_alignment_horizontal_segment(file, segment)
elif layout.is_a("IfcAlignmentVertical"):
mapped_segments = _map_alignment_vertical_segment(file, segment)
else:
mapped_segments = _map_alignment_cant_segment(file, segment, layout.RailHeadDistance)
return mapped_segments
@@ -0,0 +1,71 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import numpy as np
import ifcopenshell
import math
import ifcopenshell.api.alignment
import ifcopenshell.util.unit
from ifcopenshell import entity_instance
def _update_zero_length_segment_placement(
file: ifcopenshell.file, zero_length_segment: entity_instance, placement: np.array
) -> None:
"""
Updates the placement of a zero length segment (i.e. a segment with identical start and end point) based on a 4x4 placement matrix.
The zero_length_segment can be an IfcAlignmentSegment or IfcCurveSegment.
"""
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file)
x = float(placement[0, 3]) / unit_scale
y = float(placement[1, 3]) / unit_scale
z = float(placement[2, 3]) / unit_scale
Rdx = float(placement[0, 0])
Rdy = float(placement[1, 0])
Rdz = float(placement[2, 0])
Adx = float(placement[0, 2])
Ady = float(placement[1, 2])
Adz = float(placement[2, 2])
if zero_length_segment.is_a("IfcCurveSegment"):
if zero_length_segment.Placement.is_a("IfcAxis2Placement2D"):
zero_length_segment.Placement.Location.Coordinates = (x, y)
zero_length_segment.Placement.RefDirection.DirectionRatios = (Rdx, Rdy)
else:
zero_length_segment.Placement.Location.Coordinates = (x, y, z)
zero_length_segment.Placement.RefDirection.DirectionRatios = (Rdx, Rdy, Rdz)
zero_length_segment.Placement.Axis.DirectionRatios = (Adx, Ady, Adz)
elif zero_length_segment.DesignParameters.is_a("IfcAlignmentHorizontalSegment"):
zero_length_segment.DesignParameters.StartPoint.Coordinates = (x, y)
zero_length_segment.DesignParameters.StartDirection = math.atan(Rdy / Rdx)
elif zero_length_segment.DesignParameters.is_a("IfcAlignmentVerticalSegment"):
zero_length_segment.DesignParameters.StartDistAlong = x
zero_length_segment.DesignParameters.StartHeight = y
zero_length_segment.DesignParameters.StartGradient = Rdy / Rdx
zero_length_segment.DesignParameters.EndGradient = zero_length_segment.DesignParameters.StartGradient
else:
slope = Ady / math.sqrt(Ady**2 + Adz**2)
layout = ifcopenshell.api.alignment.get_layout(zero_length_segment)
railhead = layout.RailHeadDistance
zero_length_segment.DesignParameters.StartDistAlong = x
zero_length_segment.DesignParameters.StartCantLeft = y - slope * railhead / 2.0
zero_length_segment.DesignParameters.StartCantRight = y + slope * railhead / 2.0
zero_length_segment.DesignParameters.EndCantLeft = zero_length_segment.DesignParameters.StartCantLeft
zero_length_segment.DesignParameters.EndCantRight = zero_length_segment.DesignParameters.StartCantRight
@@ -20,6 +20,7 @@ import numpy as np
import ifcopenshell
import ifcopenshell.api.alignment
from ifcopenshell.api.alignment.update_fallback_position import update_fallback_position
import ifcopenshell.api.pset
import ifcopenshell.geom
import ifcopenshell.guid
@@ -58,7 +59,7 @@ def add_stationing_referent(
object_placement = None
representation = None
if basis_curve:
if basis_curve and basis_curve.is_a("IfcCompositeCurve") and 0 < len(basis_curve.Segments):
object_placement = file.createIfcLinearPlacement(
RelativePlacement=file.createIfcAxis2PlacementLinear(
Location=file.createIfcPointByDistanceExpression(
@@ -71,54 +72,13 @@ def add_stationing_referent(
),
)
is_valid_curve = True
if basis_curve.is_a("IfcCompositeCurve") and len(basis_curve.Segments) == 0:
is_valid_curve = False
if basis_curve.is_a("IfcPolyline") and len(basis_curve.Points) < 2:
is_valid_curve = False
elif basis_curve.is_a("IfcIndexedPolyCurve") and len(basis_curve.Points.CoordList) < 2:
is_valid_curve = False
if is_valid_curve:
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file)
settings = ifcopenshell.geom.settings()
fn = ifcopenshell_wrapper.map_shape(settings, basis_curve)
if basis_curve.is_a("IfcPolyline") or basis_curve.is_a("IfcIndexedPolyCurve"):
fn = ifcopenshell_wrapper.convert_loop_to_function_item(fn)
evaluator = ifcopenshell_wrapper.function_item_evaluator(settings, fn)
p = evaluator.evaluate(distance_along * unit_scale)
p = np.array(p)
x = float(p[0, 3]) / unit_scale
y = float(p[1, 3]) / unit_scale
z = float(p[2, 3]) / unit_scale
rx = float(p[0, 0])
ry = float(p[1, 0])
rz = float(p[2, 0])
ax = float(p[0, 2])
ay = float(p[1, 2])
az = float(p[2, 2])
else:
x = 0.0
y = 0.0
z = 0.0
rx = 1.0
ry = 0.0
rz = 0.0
ax = 0.0
ay = 0.0
az = 1.0
object_placement.CartesianPosition = file.createIfcAxis2Placement3D(
Location=file.createIfcCartesianPoint((x, y, z)),
Axis=file.createIfcDirection((ax, ay, az)),
RefDirection=file.createIfcDirection((rx, ry, rz)),
update_fallback_position(file, object_placement)
else:
object_placement = file.createIfcLocalPlacement(
PlacementRelTo=None,
RelativePlacement=file.createIfcAxis2Placement2D(
Location=file.createIfcCartesianPoint(alignment.ObjectPlacement.RelativePlacement.Location.Coordinates)
),
)
# this commented out code is what you would do to add a geometric representation of the referent
@@ -144,7 +104,12 @@ def add_stationing_referent(
ifcopenshell.api.pset.edit_pset(file, pset=pset_stationing, properties={"Station": station})
nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
nest.RelatedObjects += (referent,)
if nest is None:
nest = file.createIfcRelNests(
GlobalId=ifcopenshell.guid.new(), RelatingObject=alignment, RelatedObjects=(referent,)
)
else:
nest.RelatedObjects += (referent,)
nest.RelatedObjects = sorted(
nest.RelatedObjects, key=lambda x: ifcopenshell.util.element.get_pset(x, name="Pset_Stationing", prop="Station")
@@ -18,14 +18,12 @@
import math
import numpy as np
import ifcopenshell
import ifcopenshell.api.alignment
from ifcopenshell.api.alignment._get_segment_endpoint import _get_segment_endpoint
from ifcopenshell.api.alignment._update_zero_length_segment_placement import _update_zero_length_segment_placement
import ifcopenshell.api.nest
import ifcopenshell.geom
import ifcopenshell.ifcopenshell_wrapper as wrapper
import ifcopenshell.util.alignment
import ifcopenshell.util.unit
from ifcopenshell import entity_instance
from ifcopenshell.api.alignment._get_segment_start_point_label import (
@@ -42,14 +40,13 @@ from ifcopenshell.api.alignment._update_curve_segment_transition_code import (
)
def add_zero_length_segment(file: ifcopenshell.file, layout: entity_instance, include_referent: bool = True) -> bool:
def add_zero_length_segment(file: ifcopenshell.file, layout: entity_instance) -> bool:
"""
Adds a zero length segment to the end of a layout.
If the layout already has a zero length segment, nothing is changed.
:param layout: An IfcAlignmentHorizontal, IfcAlignmentVertical, IfcAlignmentCant, IfcCompositeCurve, IfcGradientCurve, IfcSegmentedReferenceCurve
:param include_referent: If True, an IfcReferent representing the ending point of the layout is included for IfcLinearElement layouts (i.e. business logic)
:return: True if segment is added
"""
@@ -74,28 +71,6 @@ def add_zero_length_segment(file: ifcopenshell.file, layout: entity_instance, in
return False
if layout.is_a("IfcCompositeCurve") or layout.is_a("IfcGradientCurve") or layout.is_a("IfcSegmentedReferenceCurve"):
x = 0.0
y = 0.0
dx = 1.0
dy = 0.0
segment_start = 0.0
last_segment = None
if layout.Segments and 0 < len(layout.Segments):
# If there are segments, get the last segment and compute the end point and tangent direction
# because this becomes of placement of the zero length segment
last_segment = layout.Segments[-1]
settings = ifcopenshell.geom.settings()
fn = wrapper.map_shape(settings, last_segment)
eval = wrapper.function_item_evaluator(settings, fn)
e = np.array(eval.evaluate(fn.end()))
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file)
e[:3, 3] /= unit_scale
x = float(e[0, 3])
y = float(e[1, 3])
dx = float(e[0, 0])
dy = float(e[1, 0])
parent_curve = file.createIfcLine(
Pnt=file.createIfcCartesianPoint(Coordinates=((0.0, 0.0))),
Dir=file.createIfcVector(
@@ -103,22 +78,36 @@ def add_zero_length_segment(file: ifcopenshell.file, layout: entity_instance, in
Magnitude=1.0,
),
)
if layout.is_a("IfcSegmentedReferenceCurve"):
placement = file.createIfcAxis2Placement3D(
Location=file.createIfcCartesianPoint((0.0, 0.0, 0.0)),
RefDirection=file.createIfcDirection((1.0, 0.0, 0.0)),
Axis=file.createIfcDirection((0.0, 0.0, 1.0)),
)
else:
placement = file.createIfcAxis2Placement2D(
Location=file.createIfcCartesianPoint((0.0, 0.0)),
RefDirection=file.createIfcDirection((1.0, 0.0)),
)
zero_length_curve_segment = file.createIfcCurveSegment(
Transition="DISCONTINUOUS",
Placement=file.createIfcAxis2Placement2D(
Location=file.createIfcCartesianPoint((x, y)),
RefDirection=file.createIfcDirection((dx, dy)),
),
Placement=placement,
SegmentStart=file.createIfcLengthMeasure(0.0),
SegmentLength=file.createIfcLengthMeasure(0.0),
ParentCurve=parent_curve,
)
layout.Segments += (zero_length_curve_segment,)
if last_segment:
if layout.Segments and 0 < len(layout.Segments):
# If there are segments, get the last segment and compute the end point and tangent direction
# because this becomes of placement of the zero length segment
last_segment = layout.Segments[-1]
end_point = _get_segment_endpoint(file, last_segment)
_update_zero_length_segment_placement(file, zero_length_curve_segment, end_point)
_update_curve_segment_transition_code(last_segment, zero_length_curve_segment)
layout.Segments += (zero_length_curve_segment,)
# add zero length segments to base curves
if layout.is_a("IfcSegmentedReferenceCurve"):
ifcopenshell.api.alignment.add_zero_length_segment(file, layout.BaseCurve)
@@ -139,22 +128,14 @@ def add_zero_length_segment(file: ifcopenshell.file, layout: entity_instance, in
break
if last_segment:
file.begin_transaction() # use a transaction so we can discard any temporary IFC entities created
e = _get_segment_endpoint(file, last_segment)
settings = ifcopenshell.geom.settings()
mapped_segments = _map_alignment_horizontal_segment(file, last_segment)
geometry_segment = mapped_segments[0] if mapped_segments[1] == None else mapped_segments[1]
fn = wrapper.map_shape(settings, geometry_segment)
eval = wrapper.function_item_evaluator(settings, fn)
e = np.array(eval.evaluate(fn.end()))
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file)
x = float(e[0, 3]) / unit_scale
y = float(e[1, 3]) / unit_scale
dx = float(e[0, 0])
dy = float(e[1, 0])
file.discard_transaction()
angle_unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file, "PLANEANGLEUNIT")
design_parameters = file.createIfcAlignmentHorizontalSegment(
StartPoint=file.createIfcCartesianPoint((x, y)),
@@ -178,22 +159,14 @@ def add_zero_length_segment(file: ifcopenshell.file, layout: entity_instance, in
break
if last_segment:
file.begin_transaction()
last_segment_dist_along = (
last_segment.DesignParameters.StartDistAlong + last_segment.DesignParameters.HorizontalLength
)
last_segment_end_gradient = last_segment.DesignParameters.EndGradient
settings = ifcopenshell.geom.settings()
mapped_segments = _map_alignment_vertical_segment(file, last_segment)
geometry_segment = mapped_segments[0] if mapped_segments[1] == None else mapped_segments[1]
fn = wrapper.map_shape(settings, geometry_segment)
eval = wrapper.function_item_evaluator(settings, fn)
e = np.array(eval.evaluate(fn.end()))
e = _get_segment_endpoint(file, last_segment)
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file)
last_segment_height = float(e[1, 3]) / unit_scale
file.discard_transaction()
design_parameters = file.createIfcAlignmentVerticalSegment(
StartDistAlong=last_segment_dist_along,
HorizontalLength=0.0,
@@ -240,13 +213,4 @@ def add_zero_length_segment(file: ifcopenshell.file, layout: entity_instance, in
ifcopenshell.api.nest.assign_object(file, related_objects=[zero_length_curve_segment], relating_object=layout)
if include_referent:
alignment = ifcopenshell.api.alignment.get_alignment(layout)
station = ifcopenshell.api.alignment.get_alignment_start_station(file, alignment)
name = f"{_get_segment_start_point_label(zero_length_curve_segment,None)} ({ifcopenshell.util.alignment.station_as_string(file,station)})"
referent = ifcopenshell.api.alignment.add_stationing_referent(
file, alignment, 0.0, station, name, zero_length_curve_segment
)
referent.Description = f"Positions zero length segment {zero_length_curve_segment.id()}"
return True
@@ -63,6 +63,12 @@ def create(
alignment = file.createIfcAlignment(
GlobalId=ifcopenshell.guid.new(),
Name=name,
ObjectPlacement=file.createIfcLocalPlacement(
PlacementRelTo=None,
RelativePlacement=file.createIfcAxis2Placement2D(
Location=file.createIfcCartesianPoint(Coordinates=(0.0, 0.0))
),
),
)
alignment_layouts = []
@@ -80,10 +86,10 @@ def create(
if include_geometry:
_create_geometric_representation(file, alignment)
name = ifcopenshell.util.alignment.station_as_string(file, start_station)
referent = ifcopenshell.api.alignment.add_stationing_referent(
file, alignment, 0.0, start_station, name, alignment
)
referent_name = ifcopenshell.util.alignment.station_as_string(file, start_station)
referent = ifcopenshell.api.alignment.add_stationing_referent(
file, alignment, 0.0, start_station, referent_name, alignment
)
for layout in alignment_layouts:
_add_zero_length_segment(file, layout)
@@ -53,35 +53,8 @@ def create_layout_segment(
# create the segment and add it to the layout.
segment = file.createIfcAlignmentSegment(GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters)
_add_segment_to_layout(file, layout, segment) # adds to layout and geometric representation
end = _add_segment_to_layout(
file, layout, segment
) # adds to layout and geometric representation (if present, also updates zero length segment position)
# compute the 4x4 matrix at the end of the segment so this information can be
# returned and used when defining the next segment
alignment = ifcopenshell.api.alignment.get_alignment(layout)
curve = ifcopenshell.api.alignment.get_curve(alignment)
if curve:
if layout.is_a("IfcAlignmentHorizontal"):
if curve.is_a("IfcGradientCurve"):
curve = curve.BaseCurve
elif curve.is_a("IfcSegmentedReferenceCurve"):
curve = (
curve.BaseCurve.BaseCurve
) # layout is horizontal and curve is segmented ref ... we want the curve's base curve
elif layout.is_a("IfcAlignmentVertical"):
if curve.is_a("IfcSegmentedReferenceCurve"):
curve = curve.BaseCurve
# the new segment is two from the end... the end segment is zero length
curve_segment = curve.Segments[-2]
settings = ifcopenshell.geom.settings()
segment_fn = ifcopenshell_wrapper.map_shape(settings, curve_segment)
segment_evaluator = ifcopenshell_wrapper.function_item_evaluator(settings, segment_fn)
e = segment_evaluator.evaluate(segment_fn.end())
end = np.array(e)
return end
else:
return None
return end
@@ -23,6 +23,7 @@ from ifcopenshell.api.alignment._add_segment_to_curve import _add_segment_to_cur
from ifcopenshell.api.alignment._create_geometric_representation import (
_create_geometric_representation,
)
from ifcopenshell.api.alignment.update_fallback_position import update_fallback_position
def create_representation(
@@ -34,8 +35,13 @@ def create_representation(
This function is intended to be used when a model has only the semantic definition of an alignment
and you want to add the geometric representation.
If the alignments are complete, it is recommended that add_zero_length_segment is called after this method to ensure
the proper structure of the semantic and geometric definitions of the alignment
If the alignments are complete, it is recommended that add_zero_length_segment is called before this method to ensure
the proper structure of the semantic and geometric definitions of the alignment.
It is presumed that the alignment does not have any geometric representation. However, if the alignment has stationing defined,
the referent defining the stationing is not related to the alignment geometry (it can't be because the geometry doesn't exist yet).
When the geometric representation is created, the referent is updated to have an IfcLinearPlacement that references the basis curve geometry.
This function assumes the referent defines the stationing at the start of the alignment, and therefore sets the IfcLinearPlacement.RelativePlacement.Location.DistanceAlong to 0.0.
:param alignment: The alignment to create the representation.
"""
@@ -51,6 +57,40 @@ def create_representation(
layouts = ifcopenshell.api.alignment.get_alignment_layouts(alignment)
for layout in layouts:
curve = ifcopenshell.api.alignment.get_layout_curve(layout)
layout_nest = ifcopenshell.api.alignment.get_alignment_segment_nest(layout)
for segment in layout_nest.RelatedObjects:
_add_segment_to_curve(file, segment, curve)
# 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)
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")
):
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)
lp = file.createIfcLinearPlacement(
RelativePlacement=file.createIfcAxis2PlacementLinear(
Location=file.createIfcPointByDistanceExpression(
DistanceAlong=file.createIfcLengthMeasure(0.0),
OffsetLateral=None,
OffsetVertical=None,
OffsetLongitudinal=None,
BasisCurve=basis_curve,
)
)
)
update_fallback_position(file, lp)
referent_nest.RelatedObjects[0].ObjectPlacement = lp
@@ -0,0 +1,51 @@
# 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 collections.abc import Sequence
from ifcopenshell import entity_instance
import ifcopenshell.api.alignment
from ifcopenshell.api.alignment.get_mapped_segments import _get_curve_segment_count
def get_curve_segment(layout: entity_instance, segment: entity_instance) -> entity_instance:
"""
Returns the IfcCurveSegment associated with the given alignment segment. If the curve segment does not exist, None is returned.
Example:
.. code:: python
horizontal = model.by_type("IfcAlignmentHorizontal")[0]
curve_segment = ifcopenshell.api.alignment.get_curve_segment(horizontal, alignment_segment)
"""
index = 0
segment_nest = ifcopenshell.api.alignment.get_alignment_segment_nest(layout)
for related_object in segment_nest.RelatedObjects:
if related_object == segment:
break
n = _get_curve_segment_count(related_object)
index += n
curve = ifcopenshell.api.alignment.get_layout_curve(layout)
if curve and index < len(curve.Segments):
return curve.Segments[index]
else:
return None
@@ -0,0 +1,34 @@
# 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 get_layout(segment: entity_instance) -> entity_instance:
"""
Retrieves the layout to which an alignment segment belongs.
"""
if not segment.is_a("IfcAlignmentSegment"):
raise TypeError(f"Expected entity type to be IfcAlignmentSegment, instead received {segment.is_a()}")
layout = None
nests = segment.Nests
if nests:
layout = nests[0].RelatingObject
return layout
@@ -22,11 +22,11 @@ from ifcopenshell import entity_instance
def get_referent_nest(file: ifcopenshell.file, alignment: entity_instance) -> entity_instance:
"""
Searches for the IfcRelNest that contains IfcReferent. If one is not found, a empty IfcRelNests is created.
Searches for the IfcRelNest that contains IfcReferent.
:param file:
:param alignment: The IfcAlignment which hosts IfcReferent
:return: Returns the IfcRelNests.
:return: Returns the IfcRelNests or None
"""
if not alignment.is_a("IfcAlignment"):
raise TypeError(f"Expected IfcAlignment, instead received {alignment.is_a()}")
@@ -36,5 +36,4 @@ def get_referent_nest(file: ifcopenshell.file, alignment: entity_instance) -> en
if related_object.is_a("IfcReferent"):
return nest
nest = file.createIfcRelNests(GlobalId=ifcopenshell.guid.new(), RelatingObject=alignment, RelatedObjects=[])
return nest
return None
@@ -0,0 +1,90 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import numpy as np
import ifcopenshell
import ifcopenshell.util.placement
from ifcopenshell import entity_instance
def update_end_point(file: ifcopenshell.file, curve: entity_instance):
"""
Updates the IfcGradientCurve.EndPoint and IfcSegmentedReferenceCurve.EndPoint.
If the curve does not have a zero length segment, one is added. The EndPoint is then updated to match the placement of the zero length segment.
:param curve: The gradient curve or segmented reference curve
:return: None
"""
expected_types = ["IfcGradientCurve", "IfcSegmentedReferenceCurve"]
if not curve.is_a() in expected_types:
raise TypeError(
f"Expected entity type to be one of {[_ for _ in expected_types]}, instead received '{curve.is_a()}"
)
if not ifcopenshell.api.alignment.has_zero_length_segment(curve):
ifcopenshell.api.alignment.add_zero_length_segment(file, curve)
zero_length_segment = curve.Segments[-1]
if not curve.EndPoint:
if curve.is_a("IfcGradientCurve"):
curve.EndPoint = file.createIfcAxis2Placement2D(
Location=file.createIfcCartesianPoint((0.0, 0.0)),
RefDirection=file.createIfcDirection((1.0, 0.0)),
)
else:
curve.EndPoint = file.createIfcAxis2Placement3D(
Location=file.createIfcCartesianPoint((0.0, 0.0, 0.0)),
RefDirection=file.createIfcDirection((1.0, 0.0, 0.0)),
Axis=file.createIfcDirection((0.0, 0.0, 1.0)),
)
p = np.array(ifcopenshell.util.placement.get_axis2placement(zero_length_segment.Placement))
x = float(p[0, 3])
y = float(p[1, 3])
z = float(p[2, 3])
rx = float(p[0, 0])
ry = float(p[1, 0])
rz = float(p[2, 0])
ax = float(p[0, 2])
ay = float(p[1, 2])
az = float(p[2, 2])
if curve.is_a("IfcGradientCurve"):
curve.EndPoint.Location.Coordinates = (x, y)
if not curve.EndPoint.RefDirection:
curve.EndPoint.RefDirection = file.createIfcDirection((1.0, 0.0))
curve.EndPoint.RefDirection.DirectionRatios = (rx, ry)
else:
curve.EndPoint.Location.Coordinates = (x, y, z)
if not curve.EndPoint.RefDirection:
curve.EndPoint.RefDirection = file.createIfcDirection((1.0, 0.0, 0.0))
if not curve.EndPoint.Axis:
curve.EndPoint.Axis = file.createIfcDirection((0.0, 0.0, 1.0))
curve.EndPoint.RefDirection.DirectionRatios = (rx, ry, rz)
curve.EndPoint.Axis.DirectionRatios = (ax, ay, az)
@@ -34,7 +34,7 @@ def update_fallback_position(file: ifcopenshell.file, lp: entity_instance):
"""
if not lp.CartesianPosition:
lp.CartesianPosition = file.createIfcAxis2Placement3D(Location=file.createIfcCartesianPoint((0.0, 0.0)))
lp.CartesianPosition = file.createIfcAxis2Placement3D(Location=file.createIfcCartesianPoint((0.0, 0.0, 0.0)))
p = np.array(ifcopenshell.util.placement.get_axis2placement(lp.RelativePlacement))
@@ -60,7 +60,7 @@ def evaluate_segment(segment: entity_instance, dist_along: float) -> np.ndarray:
segment_type = segment.is_a().upper()
if not segment_type in supported_segment_types:
raise NotImplementedError(f"Expected entity type 'IFCCURVESEGMENT', got '{segment_type}")
if dist_along > segment.SegmentLength:
if dist_along > abs(segment.SegmentLength.wrappedValue):
raise ValueError(f"Provided value {dist_along=} is beyond the end of the segment ({segment.SegmentLength}).")
s = ifcopenshell.geom.settings()
@@ -51,7 +51,7 @@ def remove_cost_item(file: ifcopenshell.file, cost_item: ifcopenshell.entity_ins
if history:
ifcopenshell.util.element.remove_deep2(file, history)
elif inverse.is_a("IfcRelAssignsToControl"):
if len(inverse.RelatedObjects) >= 2 or inverse.RelatingControl == cost_item:
if len(inverse.RelatedObjects) >= 2:
continue
history = inverse.OwnerHistory
file.remove(inverse)
@@ -33,7 +33,20 @@ from .add_door_representation import add_door_representation
from .add_footprint_representation import add_footprint_representation
from .add_mesh_representation import add_mesh_representation
from .add_profile_representation import add_profile_representation
from .add_railing_representation import add_railing_representation
# add_railing_representation is the pilot for a "pure-compute + IFC-wrap" split:
# compute_wall_mounted_handrail_geometry returns a dataclass with the raw geometry,
# add_railing_representation wraps it into an IfcShapeRepresentation. The split lets
# downstream consumers (Blender gizmo previews, etc.) drive the same math without
# round-tripping through an IFC file. Future add_X_representation work is encouraged
# to follow the same shape — sibling compute_X_geometry function + thin IFC wrapper.
from .add_railing_representation import (
RailingSupport,
TERMINAL_TYPE,
WallMountedHandrailGeometry,
add_railing_representation,
compute_wall_mounted_handrail_geometry,
)
try:
from .add_representation import add_representation
@@ -72,8 +85,12 @@ __all__ = [
"add_door_representation",
"add_footprint_representation",
"add_mesh_representation",
"RailingSupport",
"TERMINAL_TYPE",
"WallMountedHandrailGeometry",
"add_profile_representation",
"add_railing_representation",
"compute_wall_mounted_handrail_geometry",
"add_representation",
"add_shape_aspect",
"add_slab_representation",
@@ -28,6 +28,7 @@ import ifcopenshell.api.geometry
import ifcopenshell.util.unit
from ifcopenshell.api.geometry.add_window_representation import create_ifc_window
from ifcopenshell.util.shape_builder import ShapeBuilder, V
from ifcopenshell.util.unit import mm_to_m as mm
DOOR_TYPE = Literal[
"SINGLE_SWING_LEFT",
@@ -43,11 +44,6 @@ DOOR_TYPE = Literal[
SUPPORTED_DOOR_TYPES = get_args(DOOR_TYPE)
def mm(x: float) -> float:
"""mm to meters shortcut for readability"""
return x / 1000
def create_ifc_door_lining(
builder: ShapeBuilder, size: np.ndarray, thickness: Union[list[float], float], position: Optional[np.ndarray] = None
) -> ifcopenshell.entity_instance:
@@ -16,18 +16,21 @@
# 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 dataclasses import dataclass, field
from math import cos, pi, radians, sin, tan
from typing import Any, Literal, Optional
from typing import Callable, Literal, Optional
import numpy as np
from typing_extensions import assert_never
import ifcopenshell.util.unit
from ifcopenshell.util.shape_builder import (
NP_XY,
NP_YX,
NP_Z,
PRECISION,
SequenceOfVectors,
ShapeBuilder,
V,
is_x,
np_angle,
np_angle_signed,
np_intersect_line_line,
@@ -36,12 +39,7 @@ from ifcopenshell.util.shape_builder import (
np_normalized,
np_to_3d,
)
def mm(x: float) -> float:
"""mm to meters shortcut for readability"""
return x / 1000
from ifcopenshell.util.unit import mm_to_m as mm
TERMINAL_TYPE = Literal[
"180",
@@ -49,15 +47,524 @@ TERMINAL_TYPE = Literal[
"TO_WALL",
"TO_FLOOR",
"TO_END_POST_AND_FLOOR",
"NONE",
]
# Geometric design constants for the WALL_MOUNTED_HANDRAIL railing type (millimetres).
TERMINAL_RADIUS_MM = 150
HANDRAIL_FILLET_RADIUS_MM = 100
SUPPORT_ARC_RADIUS_MM = 10
SUPPORT_DISK_DEPTH_MM = 20
# Default parameter values for ``add_railing_representation`` (millimetres).
DEFAULT_SUPPORT_SPACING_MM = 1000
DEFAULT_RAILING_DIAMETER_MM = 50
DEFAULT_CLEAR_WIDTH_MM = 40
DEFAULT_HEIGHT_MM = 1000
@dataclass(slots=True)
class RailingSupport:
"""Pure-geometry description of a single wall-mount support.
A support consists of:
- A 3-point polyline (base at the handrail, mid-arc, floor end)
swept into a cylinder of radius ``arc_radius``.
- A short disk extrusion (wall-attachment plate) at the floor end.
All values are in IFC project units.
"""
arc_polyline: np.ndarray # shape (3, 3)
arc_radius: float
disk_position: np.ndarray # shape (3,) — equal to arc_polyline[-1]
disk_radius: float
disk_depth: float
disk_z_rotation: float # rotation around Z applied to the disk's "Y" extrude axis
@dataclass(slots=True)
class WallMountedHandrailGeometry:
"""Pure-geometry description of a wall-mounted handrail.
Decoupled from any IFC entity creation. The shared data structure is
consumed by the IFC-representation wrapper and by viewport-only previews
in authoring add-ons that need to update mesh state without mutating the
IFC file.
All values are in IFC project units.
"""
handrail_polyline: np.ndarray # shape (N, 3)
handrail_arc_point_indices: list[int]
handrail_radius: float
supports: list[RailingSupport] = field(default_factory=list)
_Z_DOWN = V(0, 0, -1)
_ARC_MIDDLE_POINT_COS = sin(radians(45))
@dataclass(frozen=True)
class _RailingDims:
"""Derived dimensions for a wall-mounted-handrail compute pass.
All values are in IFC project units.
"""
railing_radius: float
height_below_handrail: float
terminal_radius: float
fillet_radius: float
support_spacing: float
support_length: float
support_arc_radius: float
support_disk_radius: float
support_disk_depth: float
clear_width: float
cap_type: TERMINAL_TYPE
def _collinear(d0: np.ndarray, d1: np.ndarray) -> bool:
# Cross-product magnitude is linear near zero, so the test stays
# numerically stable for near-parallel unit vectors. The natural
# arccos(dot) formulation is not stable here: sub-ulp overshoot of
# dot past 1.0 returns NaN, which would silently break the fillet
# on straight subdivided edges. Anti-parallel vectors also collapse
# |d0 × d1| to 0 — and that "no usable turn" outcome is what the
# fillet caller wants, so we treat it as collinear too.
return bool(np.linalg.norm(np.cross(d0, d1)) < PRECISION)
def _get_fillet_points(v0: np.ndarray, v1: np.ndarray, v2: np.ndarray, radius: float) -> list[np.ndarray]:
"""Fillet arc points between edges v0v1 and v1v2.
Raises ``ZeroDivisionError`` / ``FloatingPointError`` (and may return
NaN/inf points) on numerically degenerate input — callers that may
receive degenerate input must guard.
"""
dir1 = np_normalized(v0 - v1)
dir2 = np_normalized(v2 - v1)
edge_angle = np_angle(dir1, dir2)
slide_distance = radius / tan(edge_angle / 2)
fillet_v1co = v1 + (dir1 * slide_distance)
fillet_v2co = v1 + (dir2 * slide_distance)
normal = np_normal([v0, v1, v2])
center = np_intersect_line_line(
fillet_v1co,
fillet_v1co + np.cross(normal, dir1),
fillet_v2co,
fillet_v2co + np.cross(normal, dir2),
)[0]
dir_ = np_normalized(np_lerp(fillet_v1co, fillet_v2co, 0.5) - center)
midpointco = center + dir_ * radius
return [fillet_v1co, midpointco, fillet_v2co]
def _make_support(point: np.ndarray, railing_direction: np.ndarray, dims: _RailingDims) -> RailingSupport:
"""Build a pure-geometry support description from a point + railing direction."""
ortho_dir = railing_direction[NP_YX] * (1, -1)
ortho_dir = np_normalized(np_to_3d(ortho_dir))
arc_center = point + ortho_dir * dims.support_length
support_points = V(
[
point,
arc_center - ortho_dir * dims.support_length * cos(pi / 4) + _Z_DOWN * dims.support_length * sin(pi / 4),
arc_center + _Z_DOWN * dims.support_length,
]
)
angle = np_angle_signed((0, 1), ortho_dir[NP_XY])
return RailingSupport(
arc_polyline=support_points,
arc_radius=dims.support_arc_radius,
disk_position=support_points[-1],
disk_radius=dims.support_disk_radius,
disk_depth=dims.support_disk_depth,
disk_z_rotation=angle,
)
def _add_arcs_on_turning_points(
base_points: np.ndarray, dims: _RailingDims, looped_path: bool
) -> tuple[np.ndarray, list[np.ndarray]]:
"""Add 3-point fillet arcs on turning points of the railing path.
Returns ``(polyline_with_arcs, arc_midpoints)``.
"""
arc_points: list[np.ndarray] = []
if len(base_points) < 3:
return base_points, arc_points
# looking for turning points by checking non-collinear edges
output_points: list[np.ndarray] = list(base_points[:1])
prev_dir = np_normalized(base_points[1] - base_points[0])
i = 1
while i < len(base_points) - 1:
cur_dir = np_normalized(base_points[i + 1] - base_points[i])
# Treat NaN cur_dir (zero-length edge → np_normalized of zero) as
# collinear: a coincident path vertex carries no turn information,
# so the safest fallback is "stay on the previous direction".
cur_dir_is_nan = bool(np.any(np.isnan(cur_dir)))
if cur_dir_is_nan or _collinear(cur_dir, prev_dir):
output_points.append(base_points[i])
else:
# User-supplied railing paths can produce numerically degenerate
# turns (anti-parallel directions, nearly-collinear triangle,
# zero-length edges from coincident vertices). Falling back to a
# sharp turn at the original vertex keeps the rest of the
# polyline real-valued instead of poisoning it with NaN.
fillet_points: Optional[list[np.ndarray]]
try:
fillet_points = _get_fillet_points(
base_points[i - 1], base_points[i], base_points[i + 1], dims.fillet_radius
)
except (ZeroDivisionError, FloatingPointError):
fillet_points = None
else:
if any(np.any(np.isnan(fp)) or np.any(np.isinf(fp)) for fp in fillet_points):
fillet_points = None
if fillet_points is None:
output_points.append(base_points[i])
else:
output_points.extend(fillet_points)
arc_points.append(fillet_points[1])
# Only advance prev_dir when cur_dir is well-defined — keeping a
# NaN prev_dir would cascade through every subsequent collinearity
# check.
if not cur_dir_is_nan:
prev_dir = cur_dir
i = i + 1
if looped_path:
output_points[0] = output_points[-1]
else:
output_points.append(base_points[-1])
return V(output_points), arc_points
def _collect_supports(coords: np.ndarray, manual_supports: bool, dims: _RailingDims) -> list[RailingSupport]:
"""Build the list of supports for the railing path."""
supports: list[RailingSupport] = []
# simplified_coords is a list of points that form non-collinear edges
simplified_coords: list[np.ndarray] = [coords[0]]
prev_dir = np_normalized(coords[1] - coords[0])
# iterating over each edge of the railing path
for i in range(1, len(coords) - 1):
cur_dir = np_normalized(coords[i + 1] - coords[i])
if not _collinear(cur_dir, prev_dir):
simplified_coords.append(coords[i])
prev_dir = cur_dir
# for manual supports each vertex on the railing path edge
# will be a point for a support
elif manual_supports:
supports.append(_make_support(coords[i], cur_dir, dims))
simplified_coords.append(coords[-1])
if manual_supports:
return supports
# create automatic supports based on the support spacing
for i in range(len(simplified_coords) - 1):
v0, v1 = simplified_coords[i : i + 2]
edge = v1 - v0
length: float = np.linalg.norm(edge)
edge_dir = np_normalized(edge)
n_supports, support_offset = divmod(length, dims.support_spacing)
n_supports = int(n_supports) + 1
support_offset /= 2
start_position = v0 + support_offset * edge_dir
for support_i in range(n_supports):
support_position = start_position + support_i * dims.support_spacing * edge_dir
supports.append(_make_support(support_position, edge, dims))
return supports
# Per-cap-type builders. Each takes the cap-frame inputs (precomputed by the
# dispatcher) and returns ``(cap_coords, new_arc_points)``. The shared
# orientation flip and final ``np.vstack`` live in the dispatcher so the
# builders stay focused on the geometric shape of their cap.
_CapBuilder = Callable[
[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, "_RailingDims"],
tuple[list[np.ndarray], list[np.ndarray]],
]
def _cap_180(
railing_coords_for_cap: np.ndarray,
start_point: np.ndarray,
cap_dir: np.ndarray,
ortho_dir: np.ndarray,
local_z_down: np.ndarray,
dims: "_RailingDims",
) -> tuple[list[np.ndarray], list[np.ndarray]]:
arc_point = start_point + cap_dir * dims.terminal_radius + dims.terminal_radius * local_z_down
cap_coords = [arc_point, start_point + dims.terminal_radius * 2 * local_z_down]
return cap_coords, [arc_point]
def _cap_to_end_post(
railing_coords_for_cap: np.ndarray,
start_point: np.ndarray,
cap_dir: np.ndarray,
ortho_dir: np.ndarray,
local_z_down: np.ndarray,
dims: "_RailingDims",
) -> tuple[list[np.ndarray], list[np.ndarray]]:
arc_point = start_point + cap_dir * dims.terminal_radius + dims.terminal_radius * local_z_down
end_point = railing_coords_for_cap[-2].copy()
end_point[NP_Z] -= dims.terminal_radius * 2
cap_coords = [arc_point, start_point + dims.terminal_radius * 2 * local_z_down, end_point]
return cap_coords, [arc_point]
def _cap_to_wall(
railing_coords_for_cap: np.ndarray,
start_point: np.ndarray,
cap_dir: np.ndarray,
ortho_dir: np.ndarray,
local_z_down: np.ndarray,
dims: "_RailingDims",
) -> tuple[list[np.ndarray], list[np.ndarray]]:
arc_point = (
start_point
+ cap_dir * dims.clear_width * _ARC_MIDDLE_POINT_COS
+ ortho_dir * dims.clear_width * (1 - _ARC_MIDDLE_POINT_COS)
)
cap_coords = [arc_point, start_point + ortho_dir * dims.clear_width + cap_dir * dims.clear_width]
return cap_coords, [arc_point]
def _cap_to_floor(
railing_coords_for_cap: np.ndarray,
start_point: np.ndarray,
cap_dir: np.ndarray,
ortho_dir: np.ndarray,
local_z_down: np.ndarray,
dims: "_RailingDims",
) -> tuple[list[np.ndarray], list[np.ndarray]]:
arc_point = (
start_point
+ cap_dir * dims.terminal_radius * _ARC_MIDDLE_POINT_COS
+ _Z_DOWN * dims.terminal_radius * (1 - _ARC_MIDDLE_POINT_COS)
)
arc_end = start_point + cap_dir * dims.terminal_radius + dims.terminal_radius * _Z_DOWN
cap_coords = [
arc_point,
arc_end,
arc_end + _Z_DOWN * (dims.height_below_handrail - dims.terminal_radius),
]
return cap_coords, [arc_point]
def _cap_to_end_post_and_floor(
railing_coords_for_cap: np.ndarray,
start_point: np.ndarray,
cap_dir: np.ndarray,
ortho_dir: np.ndarray,
local_z_down: np.ndarray,
dims: "_RailingDims",
) -> tuple[list[np.ndarray], list[np.ndarray]]:
first_arc_end = start_point + cap_dir * dims.terminal_radius + dims.terminal_radius * local_z_down
first_arc_coords = _get_fillet_points(
start_point, start_point + cap_dir * dims.terminal_radius, first_arc_end, dims.terminal_radius
)
end_point = railing_coords_for_cap[-2].copy()
end_point[NP_Z] -= dims.height_below_handrail
second_arc_coords = _get_fillet_points(
first_arc_end, first_arc_end + local_z_down * dims.terminal_radius, end_point, dims.terminal_radius
)
cap_coords = [start_point] + first_arc_coords + second_arc_coords + [end_point]
return cap_coords, [first_arc_coords[1], second_arc_coords[1]]
# Dispatch table for handrail terminal caps. "NONE" stays out of this table:
# every other cap type appends real geometry to the polyline, so a "NONE" slot
# would need an awkward empty-vstack contract — the dispatcher early-returns
# unchanged instead.
_CAP_BUILDERS: dict[TERMINAL_TYPE, _CapBuilder] = {
"180": _cap_180,
"TO_END_POST": _cap_to_end_post,
"TO_WALL": _cap_to_wall,
"TO_FLOOR": _cap_to_floor,
"TO_END_POST_AND_FLOOR": _cap_to_end_post_and_floor,
}
def _add_cap(
railing_coords: np.ndarray,
arc_points_list: list[np.ndarray],
start: bool,
dims: _RailingDims,
) -> tuple[np.ndarray, list[np.ndarray]]:
"""Add a handrail terminal cap at one end of the railing.
Returns the inputs unchanged when ``dims.cap_type == "NONE"``.
"""
if dims.cap_type == "NONE":
return railing_coords, arc_points_list
railing_coords_for_cap = railing_coords[::-1] if start else railing_coords
arc_points_list = arc_points_list[::-1] if start else arc_points_list
start_point: np.ndarray = railing_coords_for_cap[-1]
cap_dir = np_normalized(railing_coords_for_cap[-1] - railing_coords_for_cap[-2])
ortho_dir = np_normalized(np_to_3d(cap_dir[NP_YX] * (1, -1)))
local_z_down = np.cross(cap_dir, ortho_dir)
if start:
ortho_dir = -ortho_dir
cap_coords, new_arc_points = _CAP_BUILDERS[dims.cap_type](
railing_coords_for_cap, start_point, cap_dir, ortho_dir, local_z_down, dims
)
arc_points_list.extend(new_arc_points)
railing_coords = np.vstack((railing_coords_for_cap, cap_coords))
if start:
railing_coords = railing_coords[::-1]
arc_points_list = arc_points_list[::-1]
return railing_coords, arc_points_list
def _get_arc_indices(points: np.ndarray, arc_pts: list[np.ndarray]) -> list[int]:
points_ = points.copy()
arc_indices = []
i_base = 0
for arc_point in arc_pts:
for i, point in enumerate(points_):
if np.allclose(arc_point, point):
current_index = i + i_base
arc_indices.append(current_index)
i_base = current_index + 1
break
else:
raise Exception(
f"Arc point '{arc_point}' is not present in points:\n{points_}\nFull points data:\n{points}"
)
points_ = points_[i + 1 :]
return arc_indices
def compute_wall_mounted_handrail_geometry(
*,
railing_path: SequenceOfVectors,
support_spacing: float,
railing_diameter: float,
clear_width: float,
height: float,
use_manual_supports: bool = False,
terminal_type: TERMINAL_TYPE = "180",
looped_path: bool = False,
unit_scale: float = 1.0,
) -> WallMountedHandrailGeometry:
"""Compute pure geometric data for a wall-mounted handrail.
The result can be wrapped into an ``IfcShapeRepresentation`` by the
railing-representation API, or converted directly to a Blender bmesh
(or any other viewport mesh) for a live preview that does not mutate
the IFC file.
Geometric inputs (``railing_path``, ``support_spacing``,
``railing_diameter``, ``clear_width``, ``height``) are expected in IFC
project units. ``unit_scale`` is used only to convert hard-coded
millimetre constants (fillet radius, support rod radius, etc.) into
project units.
Constraints:
- ``railing_path`` must contain at least 2 points.
- ``railing_diameter`` must be > 0.
- ``height`` must be ≥ ``railing_diameter / 2`` (otherwise the
``TO_FLOOR`` / ``TO_END_POST_AND_FLOOR`` caps extrude upward
instead of down).
- ``clear_width`` must be > 0 (otherwise the support wraps backward
into the wall).
:param railing_path: Sequence of 3D points along the top of the
handrail (not the centre).
:param support_spacing: Distance between automatic supports.
:param railing_diameter: Handrail tube diameter.
:param clear_width: Clear gap between the wall and the handrail tube.
:param height: Total railing height (top of handrail to floor).
:param use_manual_supports: If true, one support is placed on every
non-collinear vertex of ``railing_path``; if false, supports are
distributed automatically by ``support_spacing``.
:param terminal_type: Style of the terminal end cap, or ``"NONE"`` for
no cap. Ignored when ``looped_path=True`` (no open ends to cap).
:param looped_path: If true, the railing closes on its first point.
:param unit_scale: Output of
:func:`ifcopenshell.util.unit.calculate_unit_scale`. Defaults to
1.0 (i.e. inputs are already in metres).
"""
railing_radius = railing_diameter / 2
# for calculations purposes we use height without railing radius
height_below_handrail = height - railing_radius
railing_coords: np.ndarray = np.subtract(railing_path, _Z_DOWN * railing_radius)
dims = _RailingDims(
railing_radius=railing_radius,
height_below_handrail=height_below_handrail,
terminal_radius=mm(TERMINAL_RADIUS_MM) / unit_scale,
fillet_radius=mm(HANDRAIL_FILLET_RADIUS_MM) / unit_scale,
support_spacing=support_spacing,
support_length=clear_width + railing_radius,
support_arc_radius=mm(SUPPORT_ARC_RADIUS_MM) / unit_scale,
support_disk_radius=railing_radius,
support_disk_depth=mm(SUPPORT_DISK_DEPTH_MM) / unit_scale,
clear_width=clear_width,
cap_type=terminal_type,
)
# need to add first two points to the path
# to create the turning arcs and supports on the last segment of the loop
if looped_path:
railing_coords = np.vstack((railing_coords, railing_coords[:2]))
supports = _collect_supports(railing_coords, use_manual_supports, dims)
railing_coords, arc_points = _add_arcs_on_turning_points(railing_coords, dims, looped_path)
if not looped_path:
railing_coords, arc_points = _add_cap(railing_coords, arc_points, start=True, dims=dims)
railing_coords, arc_points = _add_cap(railing_coords, arc_points, start=False, dims=dims)
return WallMountedHandrailGeometry(
handrail_polyline=railing_coords,
handrail_arc_point_indices=_get_arc_indices(railing_coords, arc_points),
handrail_radius=railing_radius,
supports=supports,
)
def _resolve_default_mm(value: Optional[float], default_mm: float, unit_scale: float) -> float:
"""Resolve an optional millimetre-defaulted parameter into project units.
Callers pass ``value`` as the user-supplied override (or ``None``) and
``default_mm`` as the integer millimetre default; the result is in project
units (``mm/1000 / unit_scale``).
"""
if value is not None:
return value
return mm(default_mm) / unit_scale
def add_railing_representation(
file: ifcopenshell.file,
*, # keywords only as this API implementation is probably not final
# IfcGeometricRepresentationContext
context: ifcopenshell.entity_instance,
railing_type: Literal["WALL_MOUNTED_HANDRAIL"] = "WALL_MOUNTED_HANDRAIL",
railing_path: SequenceOfVectors,
use_manual_supports: bool = False,
support_spacing: Optional[float] = None,
@@ -72,7 +579,6 @@ def add_railing_representation(
Units are expected to be in IFC project units.
:param context: IfcGeometricRepresentationContext for the representation.
:param railing_type: Type of the railing. Defaults to "WALL_MOUNTED_HANDRAIL".
:param railing_path: A list of points coordinates for the railing path,
coordinates are expected to be at the top of the railing, not at the center.
If not provided, default path [(0, 0, 1), (1, 0, 1), (2, 0, 1)] (in meters) will be used
@@ -81,7 +587,7 @@ def add_railing_representation(
:param support_spacing: Distance between supports if automatic supports are used. Defaults to 1m.
:param railing_diameter: Railing diameter. Defaults to 50mm.
:param clear_width: Clear width between the railing and the wall. Defaults to 40mm.
:param terminal_type: type of the cap. Defaults to "180".
:param terminal_type: type of the cap, or "NONE" for no cap. Defaults to "180".
:param height: defaults to 1m
:param looped_path: Whether to end the railing on the first point of `railing_path`. Defaults to False.
:param unit_scale: The unit scale as calculated by
@@ -89,317 +595,51 @@ def add_railing_representation(
will be automatically calculated for you.
:return: IfcShapeRepresentation for a railing.
"""
usecase = Usecase()
usecase.file = file
# define unit_scale first as it's going to be used setting default arguments
settings: dict[str, Any] = {
"unit_scale": ifcopenshell.util.unit.calculate_unit_scale(file) if unit_scale is None else unit_scale,
}
settings.update(
{
"context": context,
"railing_type": railing_path,
"railing_path": (
railing_path
if railing_path is not None
else usecase.path_si_to_units(V([(0, 0, 1), (1, 0, 1), (2, 0, 1)]))
),
"use_manual_supports": use_manual_supports,
"support_spacing": support_spacing if support_spacing is not None else usecase.convert_si_to_unit(mm(1000)),
"railing_diameter": (
railing_diameter if railing_diameter is not None else usecase.convert_si_to_unit(mm(50))
),
"clear_width": clear_width if clear_width is not None else usecase.convert_si_to_unit(mm(40)),
"terminal_type": terminal_type,
"height": height if height is not None else usecase.convert_si_to_unit(mm(1000)),
"looped_path": looped_path,
}
if unit_scale is None:
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file)
if railing_path is None:
railing_path = V([(0, 0, 1), (1, 0, 1), (2, 0, 1)]) / unit_scale
support_spacing = _resolve_default_mm(support_spacing, DEFAULT_SUPPORT_SPACING_MM, unit_scale)
railing_diameter = _resolve_default_mm(railing_diameter, DEFAULT_RAILING_DIAMETER_MM, unit_scale)
clear_width = _resolve_default_mm(clear_width, DEFAULT_CLEAR_WIDTH_MM, unit_scale)
height = _resolve_default_mm(height, DEFAULT_HEIGHT_MM, unit_scale)
geometry = compute_wall_mounted_handrail_geometry(
railing_path=railing_path,
use_manual_supports=use_manual_supports,
support_spacing=support_spacing,
railing_diameter=railing_diameter,
clear_width=clear_width,
terminal_type=terminal_type,
height=height,
looped_path=looped_path,
unit_scale=unit_scale,
)
usecase.settings = settings
if railing_type != "WALL_MOUNTED_HANDRAIL":
raise Exception('Only "WALL_MOUNTED_HANDRAIL" railing_type is supported at the moment.')
return usecase.execute()
builder = ShapeBuilder(file)
items_3d: list[ifcopenshell.entity_instance] = []
for support in geometry.supports:
support_polyline = builder.polyline(support.arc_polyline, closed=False, arc_points=(1,))
items_3d.append(builder.create_swept_disk_solid(support_polyline, support.arc_radius))
class Usecase:
file: ifcopenshell.file
settings: dict[str, Any]
def execute(self):
arc_points: list[np.ndarray] = []
items_3d: list[ifcopenshell.entity_instance] = []
builder = ShapeBuilder(self.file)
z_down = V(0, 0, -1)
# measurements
# from settings
use_manual_supports: bool = self.settings["use_manual_supports"]
railing_radius: float = self.settings["railing_diameter"] / 2
support_spacing: float = self.settings["support_spacing"]
clear_width: float = self.settings["clear_width"]
# for calculations purposes we use height without railing radius
height: float = self.settings["height"] - railing_radius
cap_type: TERMINAL_TYPE = self.settings["terminal_type"]
ifc_context: ifcopenshell.entity_instance = self.settings["context"]
railing_coords: SequenceOfVectors = self.settings["railing_path"]
looped_path: bool = self.settings["looped_path"]
railing_coords: np.ndarray
railing_coords = np.subtract(railing_coords, z_down * railing_radius)
# constant
terminal_radius = self.convert_si_to_unit(mm(150))
railing_fillet_radius = self.convert_si_to_unit(mm(100))
support_length = clear_width + railing_radius
support_radius = self.convert_si_to_unit(mm(10))
support_disk_radius = railing_radius
support_disk_depth = self.convert_si_to_unit(mm(20))
# util functions
def collinear(d0: np.ndarray, d1: np.ndarray) -> bool:
return is_x(np_angle(d0, d1), 0)
np_Z = 2
np_XY = slice(2)
np_YX = [1, 0]
def add_support_on_point(
point: np.ndarray, railing_direction: np.ndarray
) -> tuple[ifcopenshell.entity_instance, ...]:
"""create a support arc and a disk based on the position and direction of the railing"""
ortho_dir = railing_direction[np_YX] * (1, -1)
ortho_dir = np_normalized(np_to_3d(ortho_dir))
arc_center = point + ortho_dir * support_length
support_points: list[np.ndarray] = [
point,
arc_center - ortho_dir * support_length * cos(pi / 4) + z_down * support_length * sin(pi / 4),
arc_center + z_down * support_length,
]
polyline = builder.polyline(support_points, closed=False, arc_points=(1,))
solid = builder.create_swept_disk_solid(polyline, support_radius)
support_disk_circle = builder.circle(radius=support_disk_radius)
angle = np_angle_signed((0, 1), ortho_dir[np_XY])
y_extrusion_kwargs = builder.rotate_extrusion_kwargs_by_z(builder.extrude_kwargs("Y"), angle)
support_disk = builder.extrude(
support_disk_circle, support_disk_depth, position=support_points[-1], **y_extrusion_kwargs
disk_circle = builder.circle(radius=support.disk_radius)
y_extrusion_kwargs = builder.rotate_extrusion_kwargs_by_z(builder.extrude_kwargs("Y"), support.disk_z_rotation)
items_3d.append(
builder.extrude(
disk_circle,
support.disk_depth,
position=support.disk_position,
**y_extrusion_kwargs,
)
return (solid, support_disk)
def get_fillet_points(v0: np.ndarray, v1: np.ndarray, v2: np.ndarray, radius: float) -> list[np.ndarray]:
"""get fillet points between edges v0v1 and v1v2"""
dir1 = np_normalized(v0 - v1)
dir2 = np_normalized(v2 - v1)
edge_angle = np_angle(dir1, dir2)
slide_distance = radius / tan(edge_angle / 2)
fillet_v1co = v1 + (dir1 * slide_distance)
fillet_v2co = v1 + (dir2 * slide_distance)
normal = np_normal([v0, v1, v2])
center = np_intersect_line_line(
fillet_v1co,
fillet_v1co + np.cross(normal, dir1),
fillet_v2co,
fillet_v2co + np.cross(normal, dir2),
)[0]
dir_ = np_normalized(np_lerp(fillet_v1co, fillet_v2co, 0.5) - center)
midpointco = center + dir_ * radius
return [fillet_v1co, midpointco, fillet_v2co]
def add_arcs_on_turnings_points(base_points: np.ndarray) -> np.ndarray:
"""add 3 point fillet arcs on turning points of the railing path"""
if len(base_points) < 3:
return base_points
# looking for turning points by checking non-collinear edges
output_points: list[np.ndarray] = list(base_points[:1])
prev_dir = np_normalized(base_points[1] - base_points[0])
i = 1
while i < len(base_points) - 1:
cur_dir = np_normalized(base_points[i + 1] - base_points[i])
if collinear(cur_dir, prev_dir):
output_points.append(base_points[i])
else:
fillet_points = get_fillet_points(
base_points[i - 1], base_points[i], base_points[i + 1], railing_fillet_radius
)
output_points.extend(fillet_points)
arc_points.append(fillet_points[1])
prev_dir = cur_dir
i = i + 1
if looped_path:
output_points[0] = output_points[-1]
else:
output_points.append(base_points[-1])
return V(output_points)
def create_supports_items(
railing_coords: np.ndarray, manual_supports: bool = False
) -> list[ifcopenshell.entity_instance]:
"""create supports items based on the railing coordinates"""
supports_items: list[ifcopenshell.entity_instance] = []
# simplified_coords is a list of points that form non-collinear edges
simplified_coords: list[np.ndarray] = [railing_coords[0]]
prev_dir = np_normalized(railing_coords[1] - railing_coords[0])
# iterating over each edge of the railing path
for i in range(1, len(railing_coords) - 1):
cur_dir = np_normalized(railing_coords[i + 1] - railing_coords[i])
if not collinear(cur_dir, prev_dir):
simplified_coords.append(railing_coords[i])
prev_dir = cur_dir
# for manual supports each vertex on the railing path edge
# will be a point for a support
elif manual_supports:
supports_items.extend(add_support_on_point(point=railing_coords[i], railing_direction=cur_dir))
simplified_coords.append(railing_coords[-1])
if manual_supports:
return supports_items
# create automatic supports based on the support spacing
for i in range(0, len(simplified_coords) - 1):
v0, v1 = simplified_coords[i : i + 2]
edge = v1 - v0
length: float = np.linalg.norm(edge)
edge_dir = np_normalized(edge)
n_supports, support_offset = divmod(length, support_spacing)
n_supports = int(n_supports) + 1
support_offset /= 2
start_position = v0 + support_offset * edge_dir
for support_i in range(n_supports):
support_position = start_position + support_i * support_spacing * edge_dir
supports_items.extend(add_support_on_point(point=support_position, railing_direction=edge))
return supports_items
def add_cap(railing_coords: np.ndarray, arc_points: list[np.ndarray], start: bool = False):
"""add handrail terminal cap"""
railing_coords_for_cap = railing_coords[::-1] if start else railing_coords
arc_points = arc_points[::-1] if start else arc_points
start_point: np.ndarray = railing_coords_for_cap[-1]
cap_dir = railing_coords_for_cap[-1] - railing_coords_for_cap[-2]
cap_dir = np_normalized(cap_dir)
ortho_dir = np_to_3d(cap_dir[np_YX] * (1, -1))
ortho_dir = np_normalized(ortho_dir)
local_z_down = np.cross(cap_dir, ortho_dir)
if start:
ortho_dir = -ortho_dir
arc_middle_point_cos = sin(radians(45))
if cap_type in ("180", "TO_END_POST"):
arc_point = start_point + cap_dir * terminal_radius + terminal_radius * local_z_down
arc_points.append(arc_point)
cap_coords = [arc_point, start_point + terminal_radius * 2 * local_z_down]
if cap_type == "TO_END_POST":
end_point = railing_coords_for_cap[-2].copy()
end_point[np_Z] -= terminal_radius * 2
cap_coords.append(end_point)
elif cap_type == "TO_WALL":
arc_point = (
start_point
+ cap_dir * clear_width * arc_middle_point_cos
+ ortho_dir * clear_width * (1 - arc_middle_point_cos)
)
arc_points.append(arc_point)
cap_coords = [arc_point, start_point + ortho_dir * clear_width + cap_dir * clear_width]
elif cap_type == "TO_FLOOR":
arc_point = (
start_point
+ cap_dir * terminal_radius * arc_middle_point_cos
+ z_down * terminal_radius * (1 - arc_middle_point_cos)
)
arc_points.append(arc_point)
arc_end = start_point + cap_dir * terminal_radius + terminal_radius * z_down
cap_coords = [
arc_point,
arc_end,
arc_end + z_down * (height - terminal_radius),
]
elif cap_type == "TO_END_POST_AND_FLOOR":
first_arc_end = start_point + cap_dir * terminal_radius + terminal_radius * local_z_down
first_arc_coords = get_fillet_points(
start_point, start_point + cap_dir * terminal_radius, first_arc_end, terminal_radius
)
arc_points.append(first_arc_coords[1])
end_point = railing_coords_for_cap[-2].copy()
end_point[np_Z] -= height
second_arc_coords = get_fillet_points(
first_arc_end, first_arc_end + local_z_down * terminal_radius, end_point, terminal_radius
)
arc_points.append(second_arc_coords[1])
cap_coords = [start_point] + first_arc_coords + second_arc_coords + [end_point]
else:
assert_never(cap_type)
railing_coords = np.vstack((railing_coords_for_cap, cap_coords))
if start:
railing_coords = railing_coords[::-1]
arc_points = arc_points[::-1]
return railing_coords, arc_points
# need to add first two points to the path
# to create the turning arcs and supports on the last segment of the loop
if looped_path:
railing_coords = np.vstack((railing_coords, railing_coords[:2]))
items_3d.extend(create_supports_items(railing_coords, manual_supports=use_manual_supports))
railing_coords = add_arcs_on_turnings_points(railing_coords)
if not looped_path and cap_type != "NONE":
railing_coords, arc_points = add_cap(railing_coords, arc_points, start=True)
railing_coords, arc_points = add_cap(railing_coords, arc_points, start=False)
def get_arc_indices(points: np.ndarray, arc_points: list[np.ndarray]) -> list[int]:
points_ = points.copy()
arc_indices = []
i_base = 0
for arc_point in arc_points:
for i, point in enumerate(points_):
if np.allclose(arc_point, point):
current_index = i + i_base
arc_indices.append(current_index)
i_base = current_index + 1
break
else:
raise Exception(
f"Arc point '{arc_point}' is not present in points:\n{points_}\nFull points data:\n{points}"
)
points_ = points_[i + 1 :]
return arc_indices
railing_path = builder.polyline(
railing_coords,
closed=False,
arc_points=get_arc_indices(railing_coords, arc_points),
)
railing_solid = builder.create_swept_disk_solid(railing_path, railing_radius)
items_3d.append(railing_solid)
representation = builder.get_representation(ifc_context, items=items_3d)
return representation
def convert_si_to_unit(self, value: float) -> float:
return value / self.settings["unit_scale"]
railing_path_entity = builder.polyline(
geometry.handrail_polyline,
closed=False,
arc_points=geometry.handrail_arc_point_indices,
)
items_3d.append(builder.create_swept_disk_solid(railing_path_entity, geometry.handrail_radius))
def path_si_to_units(self, path: np.ndarray) -> np.ndarray:
"""converts list of vectors from SI to ifc project units"""
return path / self.settings["unit_scale"]
return builder.get_representation(context, items=items_3d)
@@ -121,6 +121,12 @@ class Usecase:
blender_object: bpy.types.Object
def execute(self) -> Union[ifcopenshell.entity_instance, None]:
# IfcTriangulatedFaceSet/IfcPolygonalFaceSet were introduced in IFC4 and
# do not exist in IFC2X3. Without this guard create_mesh_representation()
# silently falls back to a faceted brep, ignoring the requested class.
if self.settings["ifc_representation_class"] == "IfcTessellatedFaceSet" and self.file.schema == "IFC2X3":
raise ValueError("Tessellated face sets (IfcTessellatedFaceSet) are not supported in IFC2X3.")
self.is_manifold = None
self.coordinate_offset = self.settings["coordinate_offset"]
self.geometry = self.settings["geometry"]
@@ -27,6 +27,7 @@ import numpy as np
import ifcopenshell.api.geometry
import ifcopenshell.util.unit
from ifcopenshell.util.shape_builder import ShapeBuilder, V
from ifcopenshell.util.unit import mm_to_m as mm
# SCHEMAS describe panels setup
# where:
@@ -59,11 +60,6 @@ DEFAULT_PANEL_SCHEMAS = {
}
def mm(x: float) -> float:
"""mm to meters shortcut for readability"""
return x / 1000
def create_ifc_window_frame_simple(
builder: ShapeBuilder, size: np.ndarray, thickness: Union[list[float], float], position: Optional[np.ndarray] = None
) -> list[ifcopenshell.entity_instance]:
@@ -81,6 +81,13 @@ def validate_type(
if not preferred_item and remaining_items:
preferred_item = remaining_items[0]
# preferred_item must not appear in remaining_items — if it was selected from
# that list, leaving it in causes add_boolean to union it with itself, and the
# subsequent Items filter then removes ALL items (including preferred_item),
# leaving Items=[] which guess_type maps to "MappedRepresentation".
if preferred_item in remaining_items:
remaining_items = [i for i in remaining_items if i != preferred_item]
if remaining_items:
ifcopenshell.api.geometry.add_boolean(file, preferred_item, remaining_items, "UNION")
representation.Items = [i for i in representation.Items if i not in remaining_items]
+6 -2
View File
@@ -42,7 +42,8 @@ WHITE = numpy.array((1.0, 1.0, 1.0))
DO_NOTHING = lambda *args: None
ARRANGE_POLYGON_SETTINGS = W.arrange_polygon_settings() if hasattr(W, 'arrange_polygon_settings') else None
ARRANGE_POLYGON_SETTINGS = W.arrange_polygon_settings() if hasattr(W, "arrange_polygon_settings") else None
@dataclass
class draw_settings:
@@ -527,7 +528,10 @@ def main(
*(tup for i, tup in enumerate(zip(path_objects, section_polies, polies)) if has_relevant_zone(i))
)
arranged = W.arrange_polygons(*filter(None, (ARRANGE_POLYGON_SETTINGS,)), polies)
arranged = W.arrange_polygons(
*filter(None, (ARRANGE_POLYGON_SETTINGS,)),
polies, # ty: ignore[too-many-positional-arguments]
)
svg_data_3 = W.polygons_to_svg(arranged, False)
dom3 = parseString(svg_data_3)
svg3 = dom3.childNodes[0]
@@ -401,7 +401,15 @@ class SchemaClass(codegen.Base):
if isinstance(type, nodes.AggregationType):
aggr_type = type.aggregate_type
make_bound = lambda b: -1 if b == "?" else int(b)
def make_bound(b):
# `?` and non-literal bounds (attribute references, arithmetic expressions) collapse to -1.
#
try:
return int(b)
except (TypeError, ValueError):
return -1
bound1, bound2 = map(make_bound, (type.bounds.lower, type.bounds.upper))
decl_type = get_declared_type(type.type, emitted_names)
return x.aggregation_type(aggr_type, bound1, bound2, decl_type)
@@ -528,7 +536,16 @@ class SchemaClass(codegen.Base):
inv_attrs = []
for attr in type.inverse:
if attr.bounds:
make_bound = lambda b: -1 if b == "?" else int(b)
def make_bound(b):
# `?` and non-literal bounds (attribute references, arithmetic
# expressions) collapse to -1 (unbounded) — the C++ runtime has
# no third state for "dynamic cardinality".
try:
return int(b)
except (TypeError, ValueError):
return -1
bound1, bound2 = map(make_bound, (attr.bounds.lower, attr.bounds.upper))
else:
bound1, bound2 = -1, -1
@@ -1687,7 +1687,7 @@ class type_declaration(declaration):
class uninitialized_tag: ...
def arrange_polygons(polygons): ...
def arrange_polygons(settings, polygons): ...
def clear_plugin_search_paths() -> None: ...
def clear_schemas(): ...
def construct_iterator(geometry_library, settings, file, num_threads): ...
@@ -196,9 +196,12 @@ def get_cost_items_for_product(product: ifcopenshell.entity_instance) -> list[if
:return: A list of IfcCostItem objects representing the cost items related to the product.
"""
cost_items = []
for assignment in product.HasAssignments:
if assignment.is_a("IfcRelAssignsToControl") and assignment.RelatingControl.is_a("IfcCostItem"):
cost_items.append(assignment.RelatingControl)
for assignment in product.HasAssignments or []:
if assignment.is_a("IfcRelAssignsToControl"):
control = assignment.RelatingControl
if control and control.is_a("IfcCostItem"):
cost_items.append(control)
return cost_items
@@ -914,7 +914,7 @@ class FacetTransformer(lark.Transformer):
if self.elements:
self.results.append(self.elements)
self.elements = set()
self.has_additive_facet_in_current_list = False
self.has_additive_facet_in_current_list = False
def instance(self, args):
self.has_additive_facet_in_current_list = True
@@ -35,6 +35,15 @@ import ifcopenshell.util.unit
PRECISION = 1.0e-5
# Numpy axis-index helpers for 3D coordinates. Use these instead of redefining
# local copies in every geometry-builder module — they index ``np.ndarray``
# vectors of shape ``(3,)`` or ``(N, 3)``.
NP_X, NP_Y, NP_Z = 0, 1, 2
NP_XY = slice(2)
NP_XZ = [0, 2]
NP_YZ = [1, 2]
NP_YX = [1, 0]
if TYPE_CHECKING:
# NOTE: mathutils is never used at runtime in ifcopenshell,
@@ -1826,7 +1835,7 @@ class ShapeBuilder:
end_half_dim: np.ndarray,
angle: float,
profile_offset: VectorType = (0.0, 0.0),
verbose: bool = True,
verbose: bool = False,
) -> Optional[float]:
"""Get the transition length for two profile half-dimensions, an angle, and an XY offset.
@@ -1838,7 +1847,9 @@ class ShapeBuilder:
:param end_half_dim: Half-dimensions of the end profile in the same format.
:param angle: Maximum allowed transition angle, in degrees.
:param profile_offset: 2D XY offset between the centrelines of the start and end profiles.
:param verbose: If True, print diagnostic values during calculation.
:param verbose: If True, print diagnostic values during calculation. Default is False
the prints are debug-only output; enabling them spams the console on every transition
geometry computation (which fires per-fitting on IFC load).
:return: Transition length in project length units, or ``None`` if no valid length exists
for the given angle and offset.
"""
@@ -1899,7 +1910,7 @@ class ShapeBuilder:
end_profile: bool = False,
length: Optional[float] = None,
angle: Optional[float] = None,
verbose: bool = True,
verbose: bool = False,
) -> Union[float, None]:
"""Calculate MEP transition length from angle, or transition angle from length.
@@ -644,6 +644,11 @@ def convert_unit(value: float, from_unit: ifcopenshell.entity_instance, to_unit:
)
def mm_to_m(value: float) -> float:
"""Convert a millimetre value to metres."""
return value / 1000
def convert(value: float, from_prefix: Optional[str], from_unit: str, to_prefix: Optional[str], to_unit: str) -> float:
"""Converts between length, area, and volume units
+1
View File
@@ -21,6 +21,7 @@ dependencies = [
"isodate",
"python-dateutil",
"lark",
"pyparsing",
"typing-extensions",
]
@@ -48,6 +48,12 @@ def test_add_segment_to_layout():
)
alignment = ifcopenshell.api.alignment.create(file, "")
referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
assert (
len(referent_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)
design_parameters = file.create_entity(
@@ -80,4 +86,4 @@ def test_add_segment_to_layout():
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) == 3
assert len(referent_nest.RelatedObjects) == 1 # test this a second time to make sure that it is still true
@@ -47,7 +47,9 @@ def test_add_vertical_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)
assert len(referent_nest.RelatedObjects) == 2
assert (
len(referent_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")
curve = ifcopenshell.api.alignment.get_curve(alignment)
@@ -72,7 +74,7 @@ def test_add_vertical_alignment():
for child_alignment in alignment.IsDecomposedBy[0].RelatedObjects:
assert child_alignment.is_a("IfcAlignment")
assert len(child_alignment.IsNestedBy) == 2
assert len(child_alignment.IsNestedBy) == 1
child_layout_nest = ifcopenshell.api.alignment.get_alignment_layout_nest(child_alignment)
assert len(child_layout_nest.RelatedObjects) == 1 # The IfcAlignmentVertical
assert child_layout_nest.RelatedObjects[0].is_a("IfcAlignmentVertical")
@@ -62,7 +62,7 @@ def test_create_by_pi_method():
assert len(layout_nest.RelatedObjects) == 2
referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
assert len(referent_nest.RelatedObjects) == 19
assert len(referent_nest.RelatedObjects) == 1
horizontal_layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
horizontal_segment_nest = ifcopenshell.api.alignment.get_alignment_segment_nest(horizontal_layout)
@@ -82,9 +82,16 @@ def _test_horizontal() -> ifcopenshell.file:
assert y == 0.0
assert z == 0.0
# check the start point of the zero length segment
assert horizontal_alignment.IsNestedBy[0].RelatedObjects[1].DesignParameters.SegmentLength == 0.0
assert horizontal_alignment.IsNestedBy[0].RelatedObjects[1].DesignParameters.StartPoint.Coordinates[0] == x
assert horizontal_alignment.IsNestedBy[0].RelatedObjects[1].DesignParameters.StartPoint.Coordinates[1] == y
curve = ifcopenshell.api.alignment.get_curve(ali)
assert curve.is_a("IfcCompositeCurve")
assert len(curve.Segments) == 2
assert curve.Segments[0].Transition == "CONTSAMEGRADIENTSAMECURVATURE"
assert curve.Segments[1].Transition == "DISCONTINUOUS"
design_parameters = file.create_entity(
type="IfcAlignmentHorizontalSegment",
@@ -110,9 +117,16 @@ def _test_horizontal() -> ifcopenshell.file:
assert y == 50.0 * math.sin(math.pi / 6)
assert z == 0.0
# check the start point of the zero length segment
assert horizontal_alignment.IsNestedBy[0].RelatedObjects[2].DesignParameters.SegmentLength == 0.0
assert horizontal_alignment.IsNestedBy[0].RelatedObjects[2].DesignParameters.StartPoint.Coordinates[0] == x
assert horizontal_alignment.IsNestedBy[0].RelatedObjects[2].DesignParameters.StartPoint.Coordinates[1] == y
curve = ifcopenshell.api.alignment.get_curve(ali)
assert curve.is_a("IfcCompositeCurve")
assert len(curve.Segments) == 3
assert curve.Segments[1].Transition == "CONTSAMEGRADIENTSAMECURVATURE"
assert curve.Segments[2].Transition == "DISCONTINUOUS"
return file
@@ -60,7 +60,14 @@ def test_create_no_geometry():
PredefinedType="LINE",
)
end = ifcopenshell.api.alignment.create_layout_segment(file, horizontal_alignment, design_parameters)
assert end == None
x = end[0, 3]
y = end[1, 3]
z = end[2, 3]
assert x == 100.0
assert y == 0.0
assert z == 0.0
design_parameters = file.createIfcAlignmentVerticalSegment(
StartDistAlong=0.0,
@@ -71,4 +78,11 @@ def test_create_no_geometry():
PredefinedType="CONSTANTGRADIENT",
)
end = ifcopenshell.api.alignment.create_layout_segment(file, vertical_alignment, design_parameters)
assert end == None
x = end[0, 3]
y = end[1, 3]
z = end[2, 3]
assert x == 50.0
assert y == 20.0 + 50.0 * 1.0 / 100.0
assert z == 0.0
@@ -0,0 +1,443 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import math
import pytest
import ifcopenshell
import ifcopenshell.api.alignment
import ifcopenshell.api.unit
import numpy as np
def test_create_representation():
# expected values for horizontal segment ends points (X,Y,dx,dy)
h_expected = [
(500.0, 2500.0, math.cos(math.radians(327.0613)), math.sin(math.radians(327.0613))),
(2142.2378194934668, 1436.0145490066361, 0.8392527899703555, -0.5437414408769801),
(3660.446048592728, 2050.735651565721, 0.22453168741127044, 0.9744667882222808),
(4084.1161141648777, 3889.4623490042068, 0.22453168741127047, 0.9744667882222809),
(5469.395455576321, 4847.565492667097, 0.9910142023415828, -0.13375668490687387),
(7019.971720182908, 4638.284999653966, 0.9910142023415827, -0.13375668490687387),
(7790.932377201981, 4006.729563689594, 0.32621900658961334, -0.9452942186111613),
(8479.999918938518, 2009.9986857258034, 0.32621900658961345, -0.9452942186111613),
]
# expected values for vertical segment ends points (X,Y,dx,dy)
v_expected = [
(0.0, 100.0, 0.999846910161925, 0.01749732092783369),
(1200.0, 121.0, 0.999846910161925, 0.01749732092783369),
(2799.99999384661, 127.00000006153391, 0.9999500037507449, -0.009999499931751348),
(4399.99999384661, 111.00000023075212, 0.999950003750745, -0.009999499931751352),
(5599.9999883553455, 117.00000018438367, 0.999800059982751, 0.019996001062400855),
(6399.999988355345, 133.0000000745584, 0.999800059982751, 0.019996001062400855),
(8399.99998428796, 133.00000001862446, 0.999800059981633, -0.019996001118301257),
(9399.99998428796, 113.00000009997211, 0.999800059981633, -0.019996001118301257),
(10199.99998062693, 103.00000015081635, 0.9999875002340269, -0.004999937569813611),
(12799.99998062693, 89.99999997234107, 0.9999875002340269, -0.004999937569813611),
]
file = ifcopenshell.file(schema="IFC4X3_ADD2")
file.header.file_description.description = ["ViewDefinition [Alignment-basedView]"]
project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="FHWA Alignment")
# ifcopenshell.api.unit.assign_unit(file)
# length = ifcopenshell.api.unit.add_si_unit(file,unit_type="LENGTHUNIT")
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")
axis_model_representation_subcontext = ifcopenshell.api.context.add_context(
file,
context_type="Model",
context_identifier="Axis",
target_view="MODEL_VIEW",
parent=geometric_representation_context,
)
site = file.createIfcSite(GlobalId=ifcopenshell.guid.new(), Name="Site")
ifcopenshell.api.aggregate.assign_object(file, relating_object=project, products=[site])
alignment = ifcopenshell.api.alignment.create(
file, "E-Line", include_vertical=True, start_station=10000.0, include_geometry=False
)
# 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)
layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
segment1 = file.createIfcAlignmentHorizontalSegment(
StartPoint=file.createIfcCartesianPoint(Coordinates=((500.0, 2500.0))),
StartDirection=math.radians(327.0613),
StartRadiusOfCurvature=0.0,
EndRadiusOfCurvature=0.0,
SegmentLength=1956.785654,
PredefinedType="LINE",
)
end = ifcopenshell.api.alignment.create_layout_segment(file, layout, segment1)
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file)
x = float(end[0, 3]) / unit_scale
y = float(end[1, 3]) / unit_scale
dx = float(end[0, 0])
dy = float(end[1, 0])
dir = math.atan2(dy, dx)
assert (
pytest.approx(h_expected[1][0]) == x
and pytest.approx(h_expected[1][1]) == y
and pytest.approx(h_expected[1][2]) == dx
and pytest.approx(h_expected[1][3]) == dy
)
segment2 = file.createIfcAlignmentHorizontalSegment(
StartPoint=file.createIfcCartesianPoint((x, y)),
StartDirection=dir,
StartRadiusOfCurvature=1000.0,
EndRadiusOfCurvature=1000.0,
SegmentLength=1919.222667,
PredefinedType="CIRCULARARC",
)
end = ifcopenshell.api.alignment.create_layout_segment(file, layout, segment2)
x = float(end[0, 3]) / unit_scale
y = float(end[1, 3]) / unit_scale
dx = float(end[0, 0])
dy = float(end[1, 0])
dir = math.atan2(dy, dx)
assert (
pytest.approx(h_expected[2][0]) == x
and pytest.approx(h_expected[2][1]) == y
and pytest.approx(h_expected[2][2]) == dx
and pytest.approx(h_expected[2][3]) == dy
)
segment3 = file.createIfcAlignmentHorizontalSegment(
StartPoint=file.createIfcCartesianPoint((x, y)),
StartDirection=dir,
StartRadiusOfCurvature=0.0,
EndRadiusOfCurvature=0.0,
SegmentLength=1886.905454,
PredefinedType="LINE",
)
end = ifcopenshell.api.alignment.create_layout_segment(file, layout, segment3)
x = float(end[0, 3]) / unit_scale
y = float(end[1, 3]) / unit_scale
dx = float(end[0, 0])
dy = float(end[1, 0])
dir = math.atan2(dy, dx)
assert (
pytest.approx(h_expected[3][0]) == x
and pytest.approx(h_expected[3][1]) == y
and pytest.approx(h_expected[3][2]) == dx
and pytest.approx(h_expected[3][3]) == dy
)
segment4 = file.createIfcAlignmentHorizontalSegment(
StartPoint=file.createIfcCartesianPoint((x, y)),
StartDirection=dir,
StartRadiusOfCurvature=-1250.0,
EndRadiusOfCurvature=-1250.0,
SegmentLength=1848.115835,
PredefinedType="CIRCULARARC",
)
end = ifcopenshell.api.alignment.create_layout_segment(file, layout, segment4)
x = float(end[0, 3]) / unit_scale
y = float(end[1, 3]) / unit_scale
dx = float(end[0, 0])
dy = float(end[1, 0])
dir = math.atan2(dy, dx)
assert (
pytest.approx(h_expected[4][0]) == x
and pytest.approx(h_expected[4][1]) == y
and pytest.approx(h_expected[4][2]) == dx
and pytest.approx(h_expected[4][3]) == dy
)
segment5 = file.createIfcAlignmentHorizontalSegment(
StartPoint=file.createIfcCartesianPoint((x, y)),
StartDirection=dir,
StartRadiusOfCurvature=0.0,
EndRadiusOfCurvature=0.0,
SegmentLength=1564.635765,
PredefinedType="LINE",
)
end = ifcopenshell.api.alignment.create_layout_segment(file, layout, segment5)
x = float(end[0, 3]) / unit_scale
y = float(end[1, 3]) / unit_scale
dx = float(end[0, 0])
dy = float(end[1, 0])
dir = math.atan2(dy, dx)
assert (
pytest.approx(h_expected[5][0]) == x
and pytest.approx(h_expected[5][1]) == y
and pytest.approx(h_expected[5][2]) == dx
and pytest.approx(h_expected[5][3]) == dy
)
segment6 = file.createIfcAlignmentHorizontalSegment(
StartPoint=file.createIfcCartesianPoint((x, y)),
StartDirection=dir,
StartRadiusOfCurvature=-950.0,
EndRadiusOfCurvature=-950.0,
SegmentLength=1049.119737,
PredefinedType="CIRCULARARC",
)
end = ifcopenshell.api.alignment.create_layout_segment(file, layout, segment6)
x = float(end[0, 3]) / unit_scale
y = float(end[1, 3]) / unit_scale
dx = float(end[0, 0])
dy = float(end[1, 0])
dir = math.atan2(dy, dx)
assert (
pytest.approx(h_expected[6][0]) == x
and pytest.approx(h_expected[6][1]) == y
and pytest.approx(h_expected[6][2]) == dx
and pytest.approx(h_expected[6][3]) == dy
)
segment7 = file.createIfcAlignmentHorizontalSegment(
StartPoint=file.createIfcCartesianPoint((x, y)),
StartDirection=dir,
StartRadiusOfCurvature=0.0,
EndRadiusOfCurvature=0.0,
SegmentLength=2112.285084,
PredefinedType="LINE",
)
end = ifcopenshell.api.alignment.create_layout_segment(file, layout, segment7)
x = float(end[0, 3]) / unit_scale
y = float(end[1, 3]) / unit_scale
dx = float(end[0, 0])
dy = float(end[1, 0])
assert (
pytest.approx(h_expected[7][0]) == x
and pytest.approx(h_expected[7][1]) == y
and pytest.approx(h_expected[7][2]) == dx
and pytest.approx(h_expected[7][3]) == dy
)
vlayout = ifcopenshell.api.alignment.get_vertical_layout(alignment)
segment1 = file.createIfcAlignmentVerticalSegment(
StartDistAlong=0.0,
HorizontalLength=1200.0,
StartHeight=100.0,
StartGradient=1.75 / 100.0,
EndGradient=1.75 / 100.0,
PredefinedType="CONSTANTGRADIENT",
)
end = ifcopenshell.api.alignment.create_layout_segment(file, vlayout, segment1)
x = float(end[0, 3]) / unit_scale
y = float(end[1, 3]) / unit_scale
dx = float(end[0, 0])
dy = float(end[1, 0])
assert (
pytest.approx(v_expected[1][0]) == x
and pytest.approx(v_expected[1][1]) == y
and pytest.approx(v_expected[1][2]) == dx
and pytest.approx(v_expected[1][3]) == dy
)
segment2 = file.createIfcAlignmentVerticalSegment(
StartDistAlong=x,
HorizontalLength=1600.0,
StartHeight=y,
StartGradient=dy / dx,
EndGradient=-1.0 / 100.0,
PredefinedType="PARABOLICARC",
)
end = ifcopenshell.api.alignment.create_layout_segment(file, vlayout, segment2)
x = float(end[0, 3]) / unit_scale
y = float(end[1, 3]) / unit_scale
dx = float(end[0, 0])
dy = float(end[1, 0])
assert (
pytest.approx(v_expected[2][0]) == x
and pytest.approx(v_expected[2][1]) == y
and pytest.approx(v_expected[2][2]) == dx
and pytest.approx(v_expected[2][3]) == dy
)
segment3 = file.createIfcAlignmentVerticalSegment(
StartDistAlong=x,
HorizontalLength=1600.0,
StartHeight=y,
StartGradient=dy / dx,
EndGradient=-1.0 / 100.0,
PredefinedType="CONSTANTGRADIENT",
)
end = ifcopenshell.api.alignment.create_layout_segment(file, vlayout, segment3)
x = float(end[0, 3]) / unit_scale
y = float(end[1, 3]) / unit_scale
dx = float(end[0, 0])
dy = float(end[1, 0])
assert (
pytest.approx(v_expected[3][0]) == x
and pytest.approx(v_expected[3][1]) == y
and pytest.approx(v_expected[3][2]) == dx
and pytest.approx(v_expected[3][3]) == dy
)
segment4 = file.createIfcAlignmentVerticalSegment(
StartDistAlong=x,
HorizontalLength=1200.0,
StartHeight=y,
StartGradient=dy / dx,
EndGradient=2.0 / 100.0,
PredefinedType="PARABOLICARC",
)
end = ifcopenshell.api.alignment.create_layout_segment(file, vlayout, segment4)
x = float(end[0, 3]) / unit_scale
y = float(end[1, 3]) / unit_scale
dx = float(end[0, 0])
dy = float(end[1, 0])
assert (
pytest.approx(v_expected[4][0]) == x
and pytest.approx(v_expected[4][1]) == y
and pytest.approx(v_expected[4][2]) == dx
and pytest.approx(v_expected[4][3]) == dy
)
segment5 = file.createIfcAlignmentVerticalSegment(
StartDistAlong=x,
HorizontalLength=800.0,
StartHeight=y,
StartGradient=dy / dx,
EndGradient=2.0 / 100.0,
PredefinedType="CONSTANTGRADIENT",
)
end = ifcopenshell.api.alignment.create_layout_segment(file, vlayout, segment5)
x = float(end[0, 3]) / unit_scale
y = float(end[1, 3]) / unit_scale
dx = float(end[0, 0])
dy = float(end[1, 0])
assert (
pytest.approx(v_expected[5][0]) == x
and pytest.approx(v_expected[5][1]) == y
and pytest.approx(v_expected[5][2]) == dx
and pytest.approx(v_expected[5][3]) == dy
)
segment6 = file.createIfcAlignmentVerticalSegment(
StartDistAlong=x,
HorizontalLength=2000.0,
StartHeight=y,
StartGradient=dy / dx,
EndGradient=-2.0 / 100.0,
PredefinedType="PARABOLICARC",
)
end = ifcopenshell.api.alignment.create_layout_segment(file, vlayout, segment6)
x = float(end[0, 3]) / unit_scale
y = float(end[1, 3]) / unit_scale
dx = float(end[0, 0])
dy = float(end[1, 0])
assert (
pytest.approx(v_expected[6][0]) == x
and pytest.approx(v_expected[6][1]) == y
and pytest.approx(v_expected[6][2]) == dx
and pytest.approx(v_expected[6][3]) == dy
)
segment7 = file.createIfcAlignmentVerticalSegment(
StartDistAlong=x,
HorizontalLength=1000.0,
StartHeight=y,
StartGradient=dy / dx,
EndGradient=-2.0 / 100.0,
PredefinedType="CONSTANTGRADIENT",
)
end = ifcopenshell.api.alignment.create_layout_segment(file, vlayout, segment7)
x = float(end[0, 3]) / unit_scale
y = float(end[1, 3]) / unit_scale
dx = float(end[0, 0])
dy = float(end[1, 0])
assert (
pytest.approx(v_expected[7][0]) == x
and pytest.approx(v_expected[7][1]) == y
and pytest.approx(v_expected[7][2]) == dx
and pytest.approx(v_expected[7][3]) == dy
)
segment8 = file.createIfcAlignmentVerticalSegment(
StartDistAlong=x,
HorizontalLength=800.0,
StartHeight=y,
StartGradient=dy / dx,
EndGradient=-0.5 / 100.0,
PredefinedType="PARABOLICARC",
)
end = ifcopenshell.api.alignment.create_layout_segment(file, vlayout, segment8)
x = float(end[0, 3]) / unit_scale
y = float(end[1, 3]) / unit_scale
dx = float(end[0, 0])
dy = float(end[1, 0])
assert (
pytest.approx(v_expected[8][0]) == x
and pytest.approx(v_expected[8][1]) == y
and pytest.approx(v_expected[8][2]) == dx
and pytest.approx(v_expected[8][3]) == dy
)
segment9 = file.createIfcAlignmentVerticalSegment(
StartDistAlong=x,
HorizontalLength=2600.0,
StartHeight=y,
StartGradient=dy / dx,
EndGradient=-0.5 / 100.0,
PredefinedType="CONSTANTGRADIENT",
)
end = ifcopenshell.api.alignment.create_layout_segment(file, vlayout, segment9)
x = float(end[0, 3]) / unit_scale
y = float(end[1, 3]) / unit_scale
dx = float(end[0, 0])
dy = float(end[1, 0])
assert (
pytest.approx(v_expected[9][0]) == x
and pytest.approx(v_expected[9][1]) == y
and pytest.approx(v_expected[9][2]) == dx
and pytest.approx(v_expected[9][3]) == dy
)
ifcopenshell.api.alignment.create_representation(file, alignment)
curve = ifcopenshell.api.alignment.get_basis_curve(alignment)
assert curve.is_a("IfcCompositeCurve")
for s in curve.Segments:
assert len(s.UsingCurves) == 1
curve = ifcopenshell.api.alignment.get_layout_curve(layout)
assert curve.is_a("IfcCompositeCurve")
for index, s in enumerate(curve.Segments):
assert len(s.UsingCurves) == 1
assert s.Placement.Location.Coordinates[0] == pytest.approx(h_expected[index][0])
assert s.Placement.Location.Coordinates[1] == pytest.approx(h_expected[index][1])
assert s.Placement.RefDirection.DirectionRatios[0] == pytest.approx(h_expected[index][2])
assert s.Placement.RefDirection.DirectionRatios[1] == pytest.approx(h_expected[index][3])
curve = ifcopenshell.api.alignment.get_layout_curve(vlayout)
assert curve.is_a("IfcGradientCurve")
for index, s in enumerate(curve.Segments):
assert len(s.UsingCurves) == 1
assert s.Placement.Location.Coordinates[0] == pytest.approx(v_expected[index][0])
assert s.Placement.Location.Coordinates[1] == pytest.approx(v_expected[index][1])
assert s.Placement.RefDirection.DirectionRatios[0] == pytest.approx(v_expected[index][2])
assert s.Placement.RefDirection.DirectionRatios[1] == pytest.approx(v_expected[index][3])
test_create_representation()
@@ -75,7 +75,7 @@ def test_vertical_layout_by_pi_method():
assert len(layout_nest.RelatedObjects) == 2
referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
assert len(referent_nest.RelatedObjects) == 6
assert len(referent_nest.RelatedObjects) == 1
segment_nest = ifcopenshell.api.alignment.get_alignment_segment_nest(vlayout)
assert len(segment_nest.RelatedObjects) == 3
@@ -0,0 +1,332 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2026
#
# 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/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Tests for ``ifcopenshell.api.geometry.add_railing_representation``.
The module under test was refactored to separate **pure-geometry compute**
(``compute_wall_mounted_handrail_geometry``) from **IFC entity creation**
(``add_railing_representation`` itself). The split lets Bonsai drive a
viewport-only preview without mutating the IFC file (issue #7439).
The bulk of the tests here exercise the pure compute function it accepts
plain Python/NumPy inputs, returns a dataclass, and has no IFC dependency.
A smaller smoke test then runs the full ``add_railing_representation`` end
to end on a real ifcopenshell.file to confirm the IFC wrapping still
produces a valid ``IfcShapeRepresentation`` containing the expected items.
"""
import numpy as np
import pytest
import ifcopenshell.api.context
import ifcopenshell.api.geometry
import ifcopenshell.api.root
import ifcopenshell.api.unit
import test.bootstrap
from ifcopenshell.api.geometry import (
RailingSupport,
WallMountedHandrailGeometry,
compute_wall_mounted_handrail_geometry,
)
# ---------------------------------------------------------------------------
# Pure-geometry compute tests (no IFC file needed)
# ---------------------------------------------------------------------------
def _straight_path(length: float = 2.0) -> list[tuple[float, float, float]]:
"""Two-point horizontal path along +X at handrail height (1m)."""
return [(0.0, 0.0, 1.0), (length, 0.0, 1.0)]
def _l_path() -> list[tuple[float, float, float]]:
"""L-shaped path that turns 90° — exercises the fillet-arc branch."""
return [(0.0, 0.0, 1.0), (2.0, 0.0, 1.0), (2.0, 2.0, 1.0)]
def _common_kwargs(**overrides):
"""Default kwargs roughly matching ``add_railing_representation``'s defaults at unit_scale=1."""
kwargs = dict(
support_spacing=1.0,
railing_diameter=0.050,
clear_width=0.040,
height=1.0,
use_manual_supports=False,
terminal_type="180",
looped_path=False,
unit_scale=1.0,
)
kwargs.update(overrides)
return kwargs
def test_returns_geometry_dataclass():
"""Compute returns the documented dataclass shape."""
result = compute_wall_mounted_handrail_geometry(railing_path=_straight_path(), **_common_kwargs())
assert isinstance(result, WallMountedHandrailGeometry)
assert isinstance(result.handrail_polyline, np.ndarray)
assert result.handrail_polyline.ndim == 2
assert result.handrail_polyline.shape[1] == 3
assert isinstance(result.handrail_arc_point_indices, list)
assert isinstance(result.supports, list)
assert result.handrail_radius == pytest.approx(0.025) # diameter / 2
def test_no_ifc_dependency():
"""The compute function takes no ``ifcopenshell.file`` and creates no entities.
Asserts the signature has no required ``file`` parameter i.e. it can be
called from contexts that do not have an IFC file at all (e.g. Bonsai
viewport preview).
"""
import inspect
sig = inspect.signature(compute_wall_mounted_handrail_geometry)
assert "file" not in sig.parameters
assert "context" not in sig.parameters
def test_handrail_radius_is_half_diameter():
"""The returned handrail_radius equals diameter / 2."""
result = compute_wall_mounted_handrail_geometry(
railing_path=_straight_path(), **_common_kwargs(railing_diameter=0.080)
)
assert result.handrail_radius == pytest.approx(0.040)
def test_auto_supports_count_along_straight_path():
"""A 2m straight path at 1m support spacing yields 3 automatic supports.
``compute_wall_mounted_handrail_geometry`` adds one support every
``support_spacing`` along each edge, starting offset half-spacing in.
For a 2m edge: ``divmod(2.0, 1.0) == (2, 0)``, ``n_supports = 2 + 1 = 3``.
"""
result = compute_wall_mounted_handrail_geometry(
railing_path=_straight_path(length=2.0), **_common_kwargs(support_spacing=1.0)
)
assert len(result.supports) == 3
def test_manual_supports_skipped_on_straight_path():
"""Manual supports only land on non-collinear vertices.
A 2-point straight path has no internal vertices, so manual-supports mode
produces zero supports.
"""
result = compute_wall_mounted_handrail_geometry(
railing_path=_straight_path(), **_common_kwargs(use_manual_supports=True)
)
assert result.supports == []
def test_manual_supports_on_corner():
"""An L-shaped path under manual-supports mode places one support at the corner."""
result = compute_wall_mounted_handrail_geometry(railing_path=_l_path(), **_common_kwargs(use_manual_supports=True))
# The corner vertex is non-collinear so it does NOT receive a manual support
# (manual supports are placed on *collinear* internal vertices, i.e. spaced
# vertices along otherwise straight runs — see ``collect_supports``).
# The L-path has only the corner as an internal vertex, which is non-collinear,
# so no manual supports are produced. This pins the documented behaviour.
assert result.supports == []
def test_support_shape():
"""Each support is described by an arc polyline + a disk extrusion."""
result = compute_wall_mounted_handrail_geometry(railing_path=_straight_path(), **_common_kwargs())
assert len(result.supports) >= 1
support = result.supports[0]
assert isinstance(support, RailingSupport)
# 3-point arc polyline
assert support.arc_polyline.shape == (3, 3)
# disk position coincides with the arc endpoint
np.testing.assert_allclose(support.disk_position, support.arc_polyline[-1])
assert support.arc_radius > 0
assert support.disk_radius > 0
assert support.disk_depth > 0
@pytest.mark.parametrize(
"terminal_type",
["180", "TO_END_POST", "TO_WALL", "TO_FLOOR", "TO_END_POST_AND_FLOOR", "NONE"],
)
def test_all_terminal_types_produce_valid_geometry(terminal_type):
"""All terminal types execute without error and produce a valid handrail polyline."""
result = compute_wall_mounted_handrail_geometry(
railing_path=_straight_path(), **_common_kwargs(terminal_type=terminal_type)
)
assert result.handrail_polyline.shape[0] >= 2
assert all(0 <= idx < len(result.handrail_polyline) for idx in result.handrail_arc_point_indices)
def test_terminal_type_none_skips_cap_generation():
"""``terminal_type="NONE"`` skips terminal-cap generation entirely.
The "NONE" sentinel is consumed at the cap step the polyline is left
exactly as it came out of the fillet pass, with no extra cap vertices
or cap arc-point indices appended at either end. Every other terminal
type adds at least one cap vertex per end.
"""
result_none = compute_wall_mounted_handrail_geometry(
railing_path=_straight_path(), **_common_kwargs(terminal_type="NONE")
)
result_180 = compute_wall_mounted_handrail_geometry(
railing_path=_straight_path(), **_common_kwargs(terminal_type="180")
)
# NONE leaves the polyline at the raw 2-point path; 180 adds caps at both ends.
assert result_none.handrail_polyline.shape[0] == 2
assert result_none.handrail_polyline.shape[0] < result_180.handrail_polyline.shape[0]
# NONE registers no cap arc points; 180 registers one per cap (2 total).
assert result_none.handrail_arc_point_indices == []
assert len(result_180.handrail_arc_point_indices) >= 2
def test_l_path_adds_fillet_arc():
"""An L-path with a 90° turn introduces fillet arc points in the handrail polyline."""
result = compute_wall_mounted_handrail_geometry(railing_path=_l_path(), **_common_kwargs())
# The fillet replaces the corner vertex with three points (start, mid-arc, end),
# and registers the mid-arc index in handrail_arc_point_indices.
assert len(result.handrail_arc_point_indices) >= 1
def test_looped_path_runs_without_caps():
"""A looped path skips terminal caps (no open ends to cap).
Pins the documented behaviour: ``if not looped_path and cap_type != "NONE"``
caps only when not looped. The caller passes an *unclosed* sequence of
vertices; the function appends the first two points internally to compute
fillet arcs across the wrap-around. Passing an already-closed loop
(last vertex == first) produces a zero-length edge that breaks
``np_normalized`` the API contract is the unclosed form.
"""
# Square footprint, NOT closed (the function closes internally).
looped = [
(0.0, 0.0, 1.0),
(2.0, 0.0, 1.0),
(2.0, 2.0, 1.0),
(0.0, 2.0, 1.0),
]
result = compute_wall_mounted_handrail_geometry(railing_path=looped, **_common_kwargs(looped_path=True))
# Polyline must have no NaN values — checks that the closure was clean and
# no zero-length edge sneaked into the normalisation path.
assert not np.any(np.isnan(result.handrail_polyline))
# Looped path has 4 corners → 4 fillet arcs.
assert len(result.handrail_arc_point_indices) == 4
def test_unit_scale_converts_mm_constants():
"""``unit_scale`` divides the mm-based constants so they land in project units.
The fillet radius is hard-coded as ``mm(100) = 0.1m`` and gets divided by
``unit_scale`` before being applied. With ``unit_scale=1000`` (i.e. project
units are millimetres) the effective fillet radius should be 0.0001 too
small to affect the polyline noticeably but the function must run and
produce a valid result without raising.
"""
result = compute_wall_mounted_handrail_geometry(
railing_path=[(0, 0, 1000), (2000, 0, 1000), (2000, 2000, 1000)],
support_spacing=1000.0,
railing_diameter=50.0,
clear_width=40.0,
height=1000.0,
unit_scale=1000.0,
)
assert isinstance(result, WallMountedHandrailGeometry)
assert result.handrail_radius == pytest.approx(25.0)
# ---------------------------------------------------------------------------
# Collinearity precision regression guards
# ---------------------------------------------------------------------------
def test_collinear_subdivided_path_does_not_add_fillets():
"""Points produced by subdividing a non-axis-aligned straight edge
must be treated as collinear, even when float arithmetic pushes the
normalised dot product *above* 1.0.
Before fix: ``collinear(d0, d1)`` was ``is_x(np_angle(d0, d1), 0)``,
where ``np_angle`` is ``arccos(dot)``. When the two direction
vectors come from a subdivided non-axis-aligned segment, the dot of
the resulting unit vectors can land at ``1.0 + 1 ulp`` due to float
arithmetic. ``arccos`` of any value > 1.0 returns NaN, ``is_x(NaN,
0)`` is False, and the function then tries to compute a fillet at
what should be a straight run which immediately explodes via
``tan(near-zero)``.
Fix: ``collinear`` now uses ``|d0 × d1|`` instead of
``arccos(dot)``. The cross-product magnitude is computed without
going through ``arccos``, so it stays valid (and near zero) for
truly-collinear inputs regardless of which side of 1.0 the dot
product falls on. It also collapses to 0 for anti-parallel
directions, so back-and-forth paths get the same "no usable turn"
treatment.
"""
# Non-axis-aligned because axis-aligned cases happen to give an
# exact dot of 1.0 — the arccos-clamp bug only surfaces when float
# arithmetic produces a sub-ulp overshoot, which needs a direction
# whose components don't divide cleanly.
a = np.array([0.123, 0.456, 1.0])
direction = np.array([0.6, 0.8, 0.0]) # length 1, non-axis-aligned
p0 = a
p1 = a + direction * 1.5
p2 = a + direction * 3.0
path = [tuple(p0), tuple(p1), tuple(p2)]
result = compute_wall_mounted_handrail_geometry(railing_path=path, **_common_kwargs())
assert not np.any(np.isnan(result.handrail_polyline))
assert not np.any(np.isinf(result.handrail_polyline))
# Only the two terminal-cap fillets — the interior vertex was
# collinear and must not have introduced a third arc.
assert len(result.handrail_arc_point_indices) == 2
# ---------------------------------------------------------------------------
# End-to-end IFC smoke tests — confirms the IFC wrapping still produces a
# valid IfcShapeRepresentation around the computed geometry.
# ---------------------------------------------------------------------------
class TestAddRailingRepresentation(test.bootstrap.IFC4):
def setup_context(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
unit = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT", prefix=None)
ifcopenshell.api.unit.assign_unit(self.file, [unit])
model_context = ifcopenshell.api.context.add_context(self.file, context_type="Model")
self.body = ifcopenshell.api.context.add_context(
self.file,
context_type="Model",
context_identifier="Body",
target_view="MODEL_VIEW",
parent=model_context,
)
def test_default_railing_returns_shape_representation(self):
"""End-to-end smoke: a default-args call returns a valid IfcShapeRepresentation
with one item per support plus the main handrail solid."""
self.setup_context()
representation = ifcopenshell.api.geometry.add_railing_representation(
self.file,
context=self.body,
railing_path=[(0.0, 0.0, 1.0), (2.0, 0.0, 1.0)],
)
assert representation.is_a("IfcShapeRepresentation")
# Items: 2 per support (arc swept-disk + floor disk extrusion) + 1 handrail swept disk
assert len(representation.Items) >= 3
# Final item must be the handrail itself (a swept-disk solid)
assert representation.Items[-1].is_a("IfcSweptDiskSolid")
@@ -0,0 +1,74 @@
import os
import sys
import tempfile
import unittest
import ifcopenshell.express
sys.path.insert(0, os.path.dirname(ifcopenshell.express.__file__))
def _parse(schema_text):
with tempfile.NamedTemporaryFile(mode="w", suffix=".exp", delete=False) as f:
f.write(schema_text)
path = f.name
try:
return ifcopenshell.express.parse(path)
finally:
os.unlink(path)
cache = path + ".cache.dat"
if os.path.exists(cache):
os.unlink(cache)
class TestAggregateBounds(unittest.TestCase):
def test_literal_bounds_preserved(self):
"""After loading [1;3] -> (1, 3)?"""
s = _parse("SCHEMA t; ENTITY E; v : ARRAY [1:3] OF REAL; END_ENTITY; END_SCHEMA;")
agg = (
next(d for d in s.schema.declarations() if d.name() == "E")
.attributes()[0]
.type_of_attribute()
.as_aggregation_type()
)
self.assertEqual((agg.bound1(), agg.bound2()), (1, 3))
s.disown()
def test_unbounded_marker(self):
"""[0:?] -> (0, -1)?"""
s = _parse("SCHEMA t; ENTITY E; v : LIST [0:?] OF REAL; END_ENTITY; END_SCHEMA;")
agg = (
next(d for d in s.schema.declarations() if d.name() == "E")
.attributes()[0]
.type_of_attribute()
.as_aggregation_type()
)
# import pdb; pdb.set_trace()
self.assertEqual((agg.bound1(), agg.bound2()), (0, -1))
s.disown()
def test_voxel_grid_with_dynamic_bound_loads(self):
"""
Array that is an expression : [1:dim_x*dim_y*dim_z]
Parsing must not crash, Bbund must be (1, -1)
"""
s = _parse("""
SCHEMA t;
TYPE IfcBoolean = BOOLEAN; END_TYPE;
ENTITY IfcVoxelHolder;
NumberOfVoxelsX : INTEGER;
NumberOfVoxelsY : INTEGER;
NumberOfVoxelsZ : INTEGER;
Voxels : ARRAY [1:NumberOfVoxelsX*NumberOfVoxelsY*NumberOfVoxelsZ] OF IfcBoolean;
END_ENTITY;
END_SCHEMA;
""")
holder = next(d for d in s.schema.declarations() if d.name() == "IfcVoxelHolder")
voxels = holder.attributes()[-1].type_of_attribute().as_aggregation_type()
self.assertEqual((voxels.bound1(), voxels.bound2()), (1, -1))
s.disown()
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,51 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2021 Dion Moult <dion@thinkmoult.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import ifcopenshell.api.control
import ifcopenshell.api.cost
import test.bootstrap
import ifcopenshell.api.root
import ifcopenshell.util.cost as subject
class TestGetCostItemForProduct(test.bootstrap.IFC4):
def test_run(self):
model = self.file
element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
cost_schedule = ifcopenshell.api.cost.add_cost_schedule(model)
item1 = ifcopenshell.api.cost.add_cost_item(model, cost_schedule=cost_schedule)
ifcopenshell.api.control.assign_control(model, related_objects=[element], relating_control=item1)
assert list(subject.get_cost_items_for_product(element)) == [item1]
def test_remove_cost_item(self):
model = self.file
element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
cost_schedule = ifcopenshell.api.cost.add_cost_schedule(model)
item1 = ifcopenshell.api.cost.add_cost_item(model, cost_schedule=cost_schedule)
ifcopenshell.api.control.assign_control(model, related_objects=[element], relating_control=item1)
ifcopenshell.api.cost.remove_cost_item(model, cost_item=item1)
assert list(subject.get_cost_items_for_product(element)) == []
def test_no_assigned_cost_items(self):
model = self.file
element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
cost_schedule = ifcopenshell.api.cost.add_cost_schedule(model)
item1 = ifcopenshell.api.cost.add_cost_item(model, cost_schedule=cost_schedule)
assert list(subject.get_cost_items_for_product(element)) == []
@@ -32,6 +32,17 @@ import test.bootstrap
from ifcopenshell.util.shape_builder import ShapeBuilder
class TestMmToM:
def test_converts_a_positive_value(self):
assert subject.mm_to_m(150) == 0.15
def test_returns_zero_for_zero(self):
assert subject.mm_to_m(0) == 0.0
def test_passes_through_negative_values(self):
assert subject.mm_to_m(-25) == -0.025
class TestCacheUnits(test.bootstrap.IFC4):
def test_run(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")