mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-11 18:16:40 +00:00
Merge branch 'datamodel-v1.0' into ifcviewer-wgpu
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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)::
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
+3
-8
@@ -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
|
||||
+71
@@ -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]
|
||||
|
||||
@@ -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): ...
|
||||
|
||||
Submodule src/ifcopenshell-python/ifcopenshell/simple_spf updated: 2849a31788...9400d243d8
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user