Split railing representation into pure-compute + IFC wrapper

add_railing_representation now factors into two parts:

* compute_wall_mounted_handrail_geometry returns a pure-geometry
  WallMountedHandrailGeometry dataclass (handrail polyline + support
  list + terminal caps), no IFC mutation.
* add_railing_representation wraps that dataclass into an
  IfcShapeRepresentation as before.

Downstream consumers that want the same math without round-tripping
through an IFC file (Blender gizmo previews, viewport drafts) now
drive compute_X directly. Future add_X_representation work in the
geometry API is encouraged to follow the same shape — a sibling
compute_X function + thin IFC wrapper.

The railing_type parameter is dropped from the signature — only
WALL_MOUNTED_HANDRAIL was ever supported, so the kwarg was dead.
The Bonsai railing-modifier caller is updated in the same commit
to stop passing it; without that update Bonsai's
finish_editing_railing_path raises TypeError on the first edit.

RailingSupport and WallMountedHandrailGeometry use @dataclass(slots=True)
— they're constructed N-per-cap during arc sampling, so the per-instance
overhead matters.

Public symbols (RailingSupport, TERMINAL_TYPE,
WallMountedHandrailGeometry, compute_wall_mounted_handrail_geometry,
add_railing_representation) re-exported from ifcopenshell.api.geometry.
New test/api/geometry/test_add_railing_representation.py covers the
compute/wrap contract.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Gorgious56
2026-05-26 23:09:14 +02:00
parent 3d81660dad
commit 4d4c5b4d51
4 changed files with 908 additions and 320 deletions
@@ -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",
@@ -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)
@@ -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")