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)