diff --git a/src/bonsai/bonsai/bim/module/model/railing.py b/src/bonsai/bonsai/bim/module/model/railing.py
index 641674b060..0048a57fa4 100644
--- a/src/bonsai/bonsai/bim/module/model/railing.py
+++ b/src/bonsai/bonsai/bim/module/model/railing.py
@@ -93,7 +93,6 @@ def update_railing_modifier_ifc_data(context: bpy.types.Context) -> None:
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
representation_data = {
- "railing_type": props.railing_type,
"context": body,
"railing_path": railing_path,
"use_manual_supports": props.use_manual_supports,
diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/__init__.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/__init__.py
index d845f4dc83..93a5b8da78 100644
--- a/src/ifcopenshell-python/ifcopenshell/api/geometry/__init__.py
+++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/__init__.py
@@ -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",
diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_railing_representation.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_railing_representation.py
index a3af58dfbf..dea9dba023 100644
--- a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_railing_representation.py
+++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_railing_representation.py
@@ -16,18 +16,21 @@
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see .
+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)
diff --git a/src/ifcopenshell-python/test/api/geometry/test_add_railing_representation.py b/src/ifcopenshell-python/test/api/geometry/test_add_railing_representation.py
new file mode 100644
index 0000000000..a1e1bf0812
--- /dev/null
+++ b/src/ifcopenshell-python/test/api/geometry/test_add_railing_representation.py
@@ -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 .
+#
+# 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")