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")