2023-04-24 10:57:56 +05:00
|
|
|
|
# IfcOpenShell - IFC toolkit and geometry engine
|
|
|
|
|
|
# Copyright (C) 2023 @Andrej730
|
|
|
|
|
|
#
|
|
|
|
|
|
# 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/>.
|
|
|
|
|
|
|
2026-05-26 23:09:14 +02:00
|
|
|
|
from dataclasses import dataclass, field
|
2025-12-19 18:53:04 +05:00
|
|
|
|
from math import cos, pi, radians, sin, tan
|
2026-05-26 23:09:14 +02:00
|
|
|
|
from typing import Callable, Literal, Optional
|
2025-12-19 18:53:04 +05:00
|
|
|
|
|
2024-12-17 18:05:14 +05:00
|
|
|
|
import numpy as np
|
2025-12-19 18:53:04 +05:00
|
|
|
|
|
2023-04-24 10:57:56 +05:00
|
|
|
|
import ifcopenshell.util.unit
|
2024-12-17 18:05:14 +05:00
|
|
|
|
from ifcopenshell.util.shape_builder import (
|
2026-05-26 23:09:14 +02:00
|
|
|
|
NP_XY,
|
|
|
|
|
|
NP_YX,
|
|
|
|
|
|
NP_Z,
|
|
|
|
|
|
PRECISION,
|
2025-12-19 18:53:04 +05:00
|
|
|
|
SequenceOfVectors,
|
2024-12-17 18:05:14 +05:00
|
|
|
|
ShapeBuilder,
|
|
|
|
|
|
V,
|
|
|
|
|
|
np_angle,
|
|
|
|
|
|
np_angle_signed,
|
|
|
|
|
|
np_intersect_line_line,
|
|
|
|
|
|
np_lerp,
|
2025-12-19 18:53:04 +05:00
|
|
|
|
np_normal,
|
|
|
|
|
|
np_normalized,
|
|
|
|
|
|
np_to_3d,
|
2024-12-17 18:05:14 +05:00
|
|
|
|
)
|
2026-05-26 23:09:14 +02:00
|
|
|
|
from ifcopenshell.util.unit import mm_to_m as mm
|
2023-04-24 10:57:56 +05:00
|
|
|
|
|
2024-12-17 18:05:14 +05:00
|
|
|
|
TERMINAL_TYPE = Literal[
|
|
|
|
|
|
"180",
|
|
|
|
|
|
"TO_END_POST",
|
|
|
|
|
|
"TO_WALL",
|
|
|
|
|
|
"TO_FLOOR",
|
|
|
|
|
|
"TO_END_POST_AND_FLOOR",
|
2026-05-26 23:09:14 +02:00
|
|
|
|
"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]],
|
2024-12-17 18:05:14 +05:00
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-26 23:09:14 +02:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2024-05-15 10:55:29 +05:00
|
|
|
|
def add_railing_representation(
|
|
|
|
|
|
file: ifcopenshell.file,
|
|
|
|
|
|
*, # keywords only as this API implementation is probably not final
|
|
|
|
|
|
# IfcGeometricRepresentationContext
|
|
|
|
|
|
context: ifcopenshell.entity_instance,
|
2024-12-17 18:05:14 +05:00
|
|
|
|
railing_path: SequenceOfVectors,
|
2024-05-15 10:55:29 +05:00
|
|
|
|
use_manual_supports: bool = False,
|
|
|
|
|
|
support_spacing: Optional[float] = None,
|
|
|
|
|
|
railing_diameter: Optional[float] = None,
|
|
|
|
|
|
clear_width: Optional[float] = None,
|
2024-12-17 18:05:14 +05:00
|
|
|
|
terminal_type: TERMINAL_TYPE = "180",
|
2024-05-15 10:55:29 +05:00
|
|
|
|
height: Optional[float] = None,
|
|
|
|
|
|
looped_path: bool = False,
|
|
|
|
|
|
unit_scale: Optional[float] = None,
|
|
|
|
|
|
) -> ifcopenshell.entity_instance:
|
2024-05-06 14:35:39 +10:00
|
|
|
|
"""
|
2024-05-15 10:55:29 +05:00
|
|
|
|
Units are expected to be in IFC project units.
|
2024-05-06 14:35:39 +10:00
|
|
|
|
|
2024-05-15 10:55:29 +05:00
|
|
|
|
:param context: IfcGeometricRepresentationContext for the representation.
|
|
|
|
|
|
: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
|
|
|
|
|
|
:param use_manual_supports: If enabled, supports are added on every vertex on the edges of the railing path.
|
|
|
|
|
|
If disabled, supports are added automatically based on the support spacing. Default to False.
|
|
|
|
|
|
: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.
|
2026-05-26 23:09:14 +02:00
|
|
|
|
:param terminal_type: type of the cap, or "NONE" for no cap. Defaults to "180".
|
2024-05-15 10:55:29 +05:00
|
|
|
|
: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
|
|
|
|
|
|
ifcopenshell.util.unit.calculate_unit_scale. If not provided, it
|
|
|
|
|
|
will be automatically calculated for you.
|
|
|
|
|
|
:return: IfcShapeRepresentation for a railing.
|
2024-05-06 14:35:39 +10:00
|
|
|
|
"""
|
2026-05-26 23:09:14 +02:00
|
|
|
|
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,
|
2024-05-06 14:35:39 +10:00
|
|
|
|
)
|
2023-04-28 17:23:04 +05:00
|
|
|
|
|
2026-05-26 23:09:14 +02:00
|
|
|
|
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))
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
|
)
|
2023-04-24 10:57:56 +05:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-26 23:09:14 +02:00
|
|
|
|
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))
|
2023-04-24 10:57:56 +05:00
|
|
|
|
|
2026-05-26 23:09:14 +02:00
|
|
|
|
return builder.get_representation(context, items=items_3d)
|