diff --git a/src/bonsai/bonsai/bim/__init__.py b/src/bonsai/bonsai/bim/__init__.py index 4556a379d0..e854dcfe11 100644 --- a/src/bonsai/bonsai/bim/__init__.py +++ b/src/bonsai/bonsai/bim/__init__.py @@ -184,6 +184,9 @@ classes = [ ui.BIM_PT_tab_materials, ui.BIM_PT_tab_styles, ui.BIM_PT_tab_profiles, + # Civil infrastructure + ui.BIM_PT_tab_horizontal_alignment, + ui.BIM_PT_tab_alignments, # Drawings and documents ui.BIM_PT_tab_sheets, ui.BIM_PT_tab_drawings, diff --git a/src/bonsai/bonsai/bim/ifc.py b/src/bonsai/bonsai/bim/ifc.py index 97dc78fe7b..89368ef020 100644 --- a/src/bonsai/bonsai/bim/ifc.py +++ b/src/bonsai/bonsai/bim/ifc.py @@ -110,6 +110,8 @@ class IfcStore: """Should be set only using ``tool.Ifc.set``.""" schema: Optional[ifcopenshell.ifcopenshell_wrapper.schema_definition] = None + cache: Optional[ifcopenshell.geom.serializers.hdf5] = None + cache_path: str = "" id_map: dict[int, IFC_CONNECTED_TYPE] = {} guid_map: dict[str, IFC_CONNECTED_TYPE] = {} edited_objs: set[bpy.types.Object] = set() @@ -133,6 +135,8 @@ class IfcStore: IfcStore.path = "" IfcStore.file = None IfcStore.schema = None + IfcStore.cache = None + IfcStore.cache_path = "" IfcStore.id_map = {} IfcStore.guid_map = {} IfcStore.edited_objs = set() diff --git a/src/bonsai/bonsai/bim/module/alignment/REQUIREMENTS.md b/src/bonsai/bonsai/bim/module/alignment/REQUIREMENTS.md new file mode 100644 index 0000000000..7632e71791 --- /dev/null +++ b/src/bonsai/bonsai/bim/module/alignment/REQUIREMENTS.md @@ -0,0 +1,77 @@ +# Alignment Authoring — Requirements + +Working requirements doc for the Bonsai alignment authoring UI (Civil Infrastructure tab and the +Alignment BIM tab). Captures planned work, not yet implemented unless noted. Update in place as +scope is refined or decisions are made; keep open questions marked as such rather than silently +resolving them. + +## 1. Table-based editing + +The Alignment tab's UI lists the horizontal, vertical, and cant layouts. These listings need to +become editable: + +- Edit existing segments +- Add new segments +- Delete segments + +When editing finishes, the `IfcAlignment` model and its representations must be updated, and the +updated representations must automatically refresh in: + +- the 3D viewport +- the vertical/cant profile view + +## 2. Interactive creation of a horizontal alignment + +Mimic the existing draw/edit tangent-line workflow from the Civil Infrastructure tab, with these +improvements: + +1. Alongside Angle, also show the line's **Bearing** (e.g. `N 30 15 24 E`) — how civil engineers + think about direction. +2. Allow manual input of Distance and one of Bearing, Angle, or Deflection Angle — likely via a + pop-up input box. +3. Interactively define the smoothing curves *before* the command ends, rather than as a separate + pass afterward. + +### Proposed interaction sequence + +1. Press the eyedropper (or similar) to begin the command. +2. Automatically rotate the 3D viewport to the XY plane (Z-up). +3. Draw tangent lines with the mouse, or use the manual text input from item 2 above. +4. Repeat step 3 until all tangents are drawn. +5. Right-click (or whatever is the standard convention) to move on to the second phase of the + command. +6. Click each PI (or only the PIs of interest) and input the smoothing type and its parameters. + Smoothing types include: Circular, Spiral-Circular, Circular-Spiral, Spiral-Circular-Spiral. +7. In a pop-up (or other appropriate UI element), input the parameters: + - **Circular curve**: radius only. + - **Spiral curve**: spiral type (Clothoid, Bloss, Cosine, Helmert, etc.) and spiral length. + This assumes all spirals have infinite start/end radius and share the circular arc's radius. + + **Open question**: other cases exist that this doesn't cover, e.g. a spiral between two + circular arcs of different radius (Spiral-Circular-Spiral-Circular-Spiral). No UI is proposed + for this yet — it may require selecting 2 PIs and defining all parameters together. +8. Right-click (or whatever is standard) to end the command. Generate the alignment automatically. + +## 3. Interrogating an alignment + +Replace the PI-grid display with basic information about the alignment layout — PI points +themselves are no longer needed in that grid. + +With each alignment segment represented in the Scene Collection: + +- Selecting a segment highlights it. +- Display segment information in the 3D viewport: Start Point, End Point, Length, Radius, PI, + Center of Circle, Spiral Type (as applicable to the segment type). +- Draw tangent and radial lines for the segment. + +## 4. Interactively editing an alignment + +Two editing scenarios: + +1. **Moving a point.** Select the alignment's Start Point, End Point, or a PI point and drag it + (or key in a new position) to relocate it. +2. **Changing smoothing curve parameters.** Select a smoothing curve to get the same UI element + used to define it during creation (see §2, steps 6-7), and edit its parameters there. + +For now, edits trigger a full wipe-out-and-regenerate of the alignment. A future iteration should +regenerate only the affected subset instead of the whole alignment. diff --git a/src/bonsai/bonsai/bim/module/alignment/__init__.py b/src/bonsai/bonsai/bim/module/alignment/__init__.py index 3c49e0b4fe..82fc390380 100644 --- a/src/bonsai/bonsai/bim/module/alignment/__init__.py +++ b/src/bonsai/bonsai/bim/module/alignment/__init__.py @@ -1,5 +1,5 @@ # Bonsai - OpenBIM Blender Add-on -# Copyright (C) 2020, 2021 Dion Moult +# Copyright (C) 2020, 2021 Dion Moult , 2026 Michael Yoder # # This file is part of Bonsai. # @@ -17,11 +17,145 @@ # along with Bonsai. If not, see . import bpy +from . import ui, prop, operator, decorator, workspace -# from . import ui, prop, operator -from . import operator +_last_active_ptr: int = 0 +_last_profile_alignment_id: int = 0 # tracks which alignment the profile was last built for -classes = (operator.ImportAlignmentCSV,) + +@bpy.app.handlers.persistent +def _on_active_object_changed(scene, depsgraph): + """Sync the alignment dropdown, vertical profile, and Properties panel on selection change. + + Scene-context panels don't auto-redraw on selection changes. We watch the + active-object pointer and, when it changes, sync the dropdown and — if the + alignment itself changed — recompute the vertical profile. + + The profile recompute uses its own tracker (_last_profile_alignment_id) rather + than comparing the dropdown value. This is necessary because the dropdown is + updated by _on_active_alignment_update *before* the depsgraph fires, so the + two values would always match and the recompute would never run. + """ + global _last_active_ptr, _last_profile_alignment_id + try: + ctx = bpy.context + vl = ctx.view_layer + active = vl.objects.active if vl else None + ptr = active.as_pointer() if active else 0 + if ptr == _last_active_ptr: + return + _last_active_ptr = ptr + + import bonsai.tool as tool + from bonsai.bim.module.alignment.prop import _alignment_enum_items + + props = scene.CivilAlignmentProperties + alignment = tool.Alignment.get_active_alignment() + new_val = str(alignment.id()) if alignment else "0" + + # Sync dropdown (only needed when selection came from 3D view / outliner) + if props.active_alignment_id_str != new_val: + for idx, (ident, _, _) in enumerate(_alignment_enum_items(props, None)): + if ident == new_val: + props["active_alignment_id_str"] = idx + break + + # Recompute vertical profile when the alignment changes — use an independent + # tracker so this fires even when the dropdown already shows the new alignment + # (i.e. the change came from the dropdown, not from a viewport/outliner click). + new_aid = alignment.id() if alignment else 0 + if new_aid != _last_profile_alignment_id: + _last_profile_alignment_id = new_aid + if alignment: + from bonsai.bim.module.alignment.decorator import VerticalProfileDecorator + dec = VerticalProfileDecorator + if dec.is_installed: + dec._compute_profile(alignment) + props.vertical_items.clear() + for v_id, v_label in dec.available_verticals: + item = props.vertical_items.add() + item.entity_id = v_id + item.label = v_label + item.is_visible = True + props.cant_items.clear() + for c_id, c_label in dec.available_cants: + item = props.cant_items.add() + item.entity_id = c_id + item.label = c_label + item.is_visible = True + # Refit the camera to the new alignment's extent + ve = props.vertical_exaggeration + for window in ctx.window_manager.windows: + for a in window.screen.areas: + if a.as_pointer() == dec.profile_area_ptr: + space = next( + (s for s in a.spaces if s.type == "VIEW_3D"), None + ) + if space: + dec.fit_view(space, ve, area_width=a.width, area_height=a.height) + dec.tag_redraw() + + for window in ctx.window_manager.windows: + for area in window.screen.areas: + if area.type == "PROPERTIES": + area.tag_redraw() + except Exception: + pass + + +classes = ( + # Property groups (must be registered before classes that use them) + prop.AlignmentPI, + prop.AlignmentDisplayRow, + prop.VerticalAlignmentItem, + prop.CantAlignmentItem, + prop.CivilAlignmentProperties, + prop.PICurveMarkerProperties, + # UILists and section-toggle operators + ui.ALIGN_UL_alignment_pis, + ui.ALIGN_OT_toggle_h_segments, + ui.ALIGN_OT_toggle_v_segments, + ui.ALIGN_OT_toggle_cant_segments, + operator.ImportAlignmentCSV, + # Operators - PI Management + operator.ALIGN_OT_add_pi, + operator.ALIGN_OT_remove_pi, + operator.ALIGN_OT_pick_pi_from_viewport, + operator.ALIGN_OT_recalculate_pis, + operator.ALIGN_OT_clear_pis, + # Operators - Creation + operator.ALIGN_OT_create_alignment_by_pis, + operator.ALIGN_OT_create_alignment_by_pi, + # Operators - Stationing + operator.ALIGN_OT_add_stationing_referent, + operator.ALIGN_OT_name_segments, + # Operators - Vertical Profile Window + operator.ALIGN_OT_show_vertical_profile, + # Operators - Segment Selection + operator.ALIGN_OT_select_h_segment, + operator.ALIGN_OT_select_v_segment, + operator.ALIGN_OT_select_cant_segment, + # Operators - PI Edit Mode + operator.ALIGN_OT_enter_pi_edit_mode, + # Operators - Alignments tab authoring workflow (Add Element + interactive draw) + operator.ALIGN_OT_add_alignment, + operator.ALIGN_OT_remove_alignment, + operator.ALIGN_OT_set_start_station, + operator.ALIGN_OT_add_station_equation, + operator.ALIGN_OT_edit_station_equation, + operator.ALIGN_OT_remove_station_equation, + operator.ALIGN_OT_apply_pi_curve, + operator.ALIGN_OT_clear_pi_markers, + operator.ALIGN_OT_draw_horizontal_alignment, + # UI Panels (appear in Properties sidebar under CIVIL tab) + ui.ALIGN_PT_alignment_creation, + ui.ALIGN_PT_pi_editor, + ui.ALIGN_PT_alignment_stationing, + # UI Panels (appear in Properties sidebar under ALIGNMENTS tab) + ui.ALIGN_PT_alignment_authoring, + ui.ALIGN_PT_alignment_stationing_authoring, + ui.ALIGN_PT_alignment_segments, +) def menu_func_import(self, context): @@ -29,8 +163,40 @@ def menu_func_import(self, context): def register(): + if not bpy.app.background: + bpy.utils.register_tool( + workspace.AlignmentTool, + separator=True, + group=False, + ) + bpy.types.Scene.CivilAlignmentProperties = bpy.props.PointerProperty(type=prop.CivilAlignmentProperties) + bpy.types.Object.bonsai_pi_curve_marker = bpy.props.PointerProperty(type=prop.PICurveMarkerProperties) bpy.types.TOPBAR_MT_file_import.append(menu_func_import) + if _on_active_object_changed not in bpy.app.handlers.depsgraph_update_post: + bpy.app.handlers.depsgraph_update_post.append(_on_active_object_changed) + # Reset decorator state so a module hot-reload never leaves is_installed=True + # with stale handlers that prevent the first button press from opening the profile. + from .decorator import VerticalProfileDecorator + VerticalProfileDecorator.is_installed = False + VerticalProfileDecorator.handlers = [] + VerticalProfileDecorator.profile_area = None + VerticalProfileDecorator.profile_area_ptr = 0 def unregister(): + if _on_active_object_changed in bpy.app.handlers.depsgraph_update_post: + bpy.app.handlers.depsgraph_update_post.remove(_on_active_object_changed) + # Clean up any open profile window so re-registration starts from a clean state. + from .decorator import VerticalProfileDecorator + if VerticalProfileDecorator.is_installed: + try: + VerticalProfileDecorator.uninstall() + except Exception: + pass + VerticalProfileDecorator.is_installed = False + VerticalProfileDecorator.handlers = [] + if not bpy.app.background: + bpy.utils.unregister_tool(workspace.AlignmentTool) bpy.types.TOPBAR_MT_file_import.remove(menu_func_import) + del bpy.types.Scene.CivilAlignmentProperties + del bpy.types.Object.bonsai_pi_curve_marker diff --git a/src/bonsai/bonsai/bim/module/alignment/data.py b/src/bonsai/bonsai/bim/module/alignment/data.py new file mode 100644 index 0000000000..b9237b2a75 --- /dev/null +++ b/src/bonsai/bonsai/bim/module/alignment/data.py @@ -0,0 +1,66 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2025, 2026 Michael Yoder +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . + + +"""Data caching layer for the alignment module + +This module provides cached access to alignment data for UI display, +following Bonsai's data loading pattern. +""" + +import bonsai.tool as tool + + +class AlignmentData: + """Cached alignment data for UI display""" + + data = {} + is_loaded = False + + @classmethod + def load(cls): + """Load alignment data from IFC file""" + cls.data = { + "alignments": [], + "active_alignment": None, + "segments": [], + } + + ifc = tool.Ifc.get() + if ifc is None: + cls.is_loaded = True + return + + # Load all alignments + alignments = ifc.by_type("IfcAlignment") + cls.data["alignments"] = [ + { + "id": a.id(), + "name": a.Name or f"Alignment {a.id()}", + "global_id": a.GlobalId, + } + for a in alignments + ] + + cls.is_loaded = True + + @classmethod + def refresh(cls): + """Force refresh of alignment data""" + cls.is_loaded = False + cls.load() diff --git a/src/bonsai/bonsai/bim/module/alignment/decorator.py b/src/bonsai/bonsai/bim/module/alignment/decorator.py new file mode 100644 index 0000000000..d8ecda84de --- /dev/null +++ b/src/bonsai/bonsai/bim/module/alignment/decorator.py @@ -0,0 +1,2151 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2025, 2026 Michael Yoder +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . + +"""Alignment module decorators for GPU visualization. + +This module contains decorators for rendering visual feedback during +alignment-related operations, such as PI editing. +""" + +import bpy +import blf +import gpu +import math +import mathutils +import numpy as np +import ifcopenshell.api.alignment +import ifcopenshell.util.geolocation +import ifcopenshell.util.shape +import ifcopenshell.util.unit +import bonsai.tool as tool +from bpy.types import SpaceView3D +from bpy_extras.view3d_utils import location_3d_to_region_2d, region_2d_to_location_3d +from gpu_extras.batch import batch_for_shader + + +class PIEditDecorator: + """Decorator for visualizing PI edit mode. + + This decorator provides visual feedback while the user is editing + PI (Point of Intersection) positions with standard Blender transform tools: + - Yellow lines connecting PI empties (tangent preview) + - HUD text showing instructions + + The decorator reads positions directly from the PI empty objects, + which are updated by Blender's transform operators (G key). + """ + + # Class-level state (cleared on uninstall) + is_installed = False + handlers = [] + + # References to PI empty objects + pi_empties = [] + + # Colors + COLOR_TANGENT_LINE = (1.0, 0.9, 0.2, 1.0) # Yellow for tangent lines + COLOR_HUD_TEXT = (1.0, 1.0, 1.0, 1.0) # White for HUD text + COLOR_EDIT_MODE_BG = (0.2, 0.4, 0.8, 0.8) # Blue tint for edit mode indicator + + # Drawing parameters + LINE_WIDTH = 2.5 + + @classmethod + def install(cls, context, pi_empties): + """Install decorator handlers for PI edit mode visualization. + + Args: + context: Blender context + pi_empties: List of PI EMPTY objects to visualize + """ + if cls.is_installed: + cls.uninstall() + + cls.pi_empties = pi_empties + + handler = cls() + # POST_VIEW for 3D world-space drawing (tangent lines in 3D) + cls.handlers.append( + SpaceView3D.draw_handler_add(handler.draw_tangent_lines_3d, (context,), "WINDOW", "POST_VIEW") + ) + # POST_PIXEL for 2D screen-space drawing (HUD) + cls.handlers.append( + SpaceView3D.draw_handler_add(handler.draw_hud, (context,), "WINDOW", "POST_PIXEL") + ) + cls.is_installed = True + + @classmethod + def uninstall(cls): + """Remove all handlers and clear state.""" + for handler in cls.handlers: + try: + SpaceView3D.draw_handler_remove(handler, "WINDOW") + except ValueError: + pass + cls.handlers = [] + cls.is_installed = False + cls.pi_empties = [] + + @classmethod + def update_positions(cls, pi_empties): + """Update the list of PI empties (called when positions change). + + Args: + pi_empties: Updated list of PI EMPTY objects + """ + cls.pi_empties = pi_empties + + def draw_batch_3d(self, shader_type, content_pos, color, indices=None): + """Draw a batch of 3D primitives using GPU shader. + + Args: + shader_type: Type of primitive ("LINES", "POINTS", etc.) + content_pos: List of 3D vertex positions + color: RGBA color tuple + indices: Optional list of index pairs for lines + """ + if not tool.Blender.validate_shader_batch_data(content_pos, indices): + return + shader = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR") + shader.bind() + + # Get viewport size from active region + region = bpy.context.region + shader.uniform_float("viewportSize", (region.width, region.height)) + shader.uniform_float("lineWidth", self.LINE_WIDTH) + + batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices) + shader.uniform_float("color", color) + batch.draw(shader) + + def draw_tangent_lines_3d(self, context): + """Draw yellow tangent lines connecting PI empties in 3D space.""" + if not self.pi_empties or len(self.pi_empties) < 2: + return + + # Collect 3D positions from empties + positions = [] + for empty in self.pi_empties: + if empty and empty.name in bpy.data.objects: + positions.append(tuple(empty.location)) + + if len(positions) < 2: + return + + # Setup blending for line drawing + gpu.state.blend_set("ALPHA") + gpu.state.depth_test_set("LESS_EQUAL") + gpu.state.depth_mask_set(False) + + # Build edges list + edges = [[i, i + 1] for i in range(len(positions) - 1)] + + # Draw lines + self.draw_batch_3d("LINES", positions, self.COLOR_TANGENT_LINE, edges) + + # Restore state + gpu.state.blend_set("NONE") + gpu.state.depth_test_set("NONE") + gpu.state.depth_mask_set(True) + + def draw_hud(self, context): + """Draw HUD text with edit mode instructions.""" + region = context.region + if not region: + return + + font_id = 0 + font_size = tool.Blender.scale_font_size(14) + blf.size(font_id, font_size) + blf.enable(font_id, blf.SHADOW) + blf.shadow(font_id, 6, 0, 0, 0, 1) # Black shadow for readability + blf.color(font_id, *self.COLOR_HUD_TEXT) + + # Position in top-left of viewport + margin = 20 + line_height = 22 + y_pos = region.height - margin + + # Count valid empties + valid_count = sum(1 for e in self.pi_empties if e and e.name in bpy.data.objects) + + # Instructions + instructions = [ + "PI Edit Mode", + f"PIs: {valid_count}", + "", + "G: Move selected PI", + "ENTER: Apply changes", + "ESC: Cancel", + ] + + for i, line in enumerate(instructions): + blf.position(font_id, margin, y_pos - (i * line_height), 0) + blf.draw(font_id, line) + + blf.disable(font_id, blf.SHADOW) + + +class AlignmentSegmentDecorator: + """Decorator that highlights a selected horizontal alignment segment in the 3D viewport. + + Draws: + - A thick orange polyline over the segment + - Thin gray tangent extension lines from PC and PT to their PI intersection + - A yellow crosshair at the PI in screen space + - Labels at PC (start), PI, and PT (end) with station and E/N coordinates + """ + + is_installed = False + handlers = [] + + segment_id: int | None = None + segment_verts: list[tuple[float, float, float]] = [] + segment_label: str = "" + label_world_pos: tuple[float, float, float] | None = None + tangent_data: dict | None = None + + COLOR_HIGHLIGHT = (1.0, 0.55, 0.0, 1.0) # Orange - segment polyline + COLOR_TANGENT = (0.75, 0.75, 0.75, 0.85) # Light gray - tangent extension lines + COLOR_PI = (1.0, 0.85, 0.25, 1.0) # Yellow - PI crosshair + COLOR_LABEL = (1.0, 1.0, 1.0, 1.0) # White - fallback label + COLOR_LABEL_PC = (1.0, 0.78, 0.40, 1.0) # Warm orange - PC label + COLOR_LABEL_PT = (0.60, 0.88, 1.0, 1.0) # Light blue - PT label + COLOR_LABEL_PI = (1.0, 0.90, 0.30, 1.0) # Yellow - PI label + LINE_WIDTH = 4.0 + LINE_TANGENT = 1.5 + + @classmethod + def install(cls, context, segment_id: int) -> None: + if cls.is_installed: + cls.uninstall() + + cls.segment_id = segment_id + cls._compute_segment_geometry(segment_id) + if not cls.segment_verts: + return + + handler = cls() + cls.handlers.append( + SpaceView3D.draw_handler_add(handler.draw_segment, (context,), "WINDOW", "POST_VIEW") + ) + cls.handlers.append( + SpaceView3D.draw_handler_add(handler.draw_label, (context,), "WINDOW", "POST_PIXEL") + ) + cls.is_installed = True + + @classmethod + def refresh(cls) -> None: + """Recompute the highlighted segment's tangent/station data in place. + + Stationing (start station, station equations) can change while a + segment is already selected and highlighted; without this its PC/PI/PT + station labels would stay stale until the segment was deselected and + reselected. Called by the stationing operators after they succeed. + """ + if not cls.is_installed or cls.segment_id is None: + return + cls._compute_segment_geometry(cls.segment_id) + tool.Blender.update_viewport() + + @classmethod + def uninstall(cls) -> None: + for handler in cls.handlers: + try: + SpaceView3D.draw_handler_remove(handler, "WINDOW") + except ValueError: + pass + cls.handlers = [] + cls.is_installed = False + cls.segment_id = None + cls.segment_verts = [] + cls.segment_label = "" + cls.label_world_pos = None + cls.tangent_data = None + + @classmethod + def _compute_segment_geometry(cls, segment_id: int) -> None: + """Extract world-space polyline vertices for the given IfcAlignmentSegment.""" + import logging + import bonsai.bim.import_ifc + + ifc_file = tool.Ifc.get() + if not ifc_file: + return + + try: + segment = ifc_file.by_id(segment_id) + except Exception: + return + + if not segment or not segment.is_a("IfcAlignmentSegment"): + return + + dp = segment.DesignParameters + if dp: + cls.segment_label = getattr(dp, "PredefinedType", "Segment") or "Segment" + + layout = segment.Nests[0].RelatingObject if segment.Nests else None + if not layout: + return + + layout_curve = tool.Alignment._find_layout_curve(layout) + if not layout_curve: + return + + mapped_segments = tool.Alignment._map_alignment_segment_to_curve_segments( + segment, layout, layout_curve + ) + + logger = logging.getLogger("ImportIFC") + ifc_import_settings = bonsai.bim.import_ifc.IfcImportSettings.factory(bpy.context, None, logger) + ifc_importer = bonsai.bim.import_ifc.IfcImporter(ifc_import_settings) + ifc_importer.file = ifc_file + tool.Loader.load_settings() + + all_verts: list[tuple[float, float, float]] = [] + for curve_segment in mapped_segments: + if curve_segment is None: + continue + geometry = tool.Loader.create_generic_shape(curve_segment) + if not geometry: + continue + + mesh = ifc_importer.create_mesh(curve_segment, geometry) + tmp_obj = bpy.data.objects.new("__seg_highlight_tmp__", mesh) + + if hasattr(geometry, "transformation_buffer"): + mat = ifcopenshell.util.shape.get_shape_matrix(geometry) + else: + mat = np.eye(4) + + tmp_obj.matrix_world = tool.Loader.apply_blender_offset_to_matrix_world(tmp_obj, mat) + tool.Geometry.record_object_position(tmp_obj) + + world_mat = tmp_obj.matrix_world + for v in mesh.vertices: + all_verts.append(tuple(world_mat @ v.co)) + + bpy.data.objects.remove(tmp_obj) + bpy.data.meshes.remove(mesh) + + cls.segment_verts = all_verts + if all_verts: + cls.label_world_pos = all_verts[len(all_verts) // 2] + + cls._compute_tangent_data(segment, ifc_file) + + @classmethod + def _compute_tangent_data(cls, segment, ifc_file) -> None: + """Compute PI tangent intersection, stations, and E/N labels for the segment. + + Always populates basic PC/PT data. For non-linear segments where a finite PI + exists, also populates PI position and the tangent-direction reference points + used to draw perpendicular ticks at PC and PT. + """ + cls.tangent_data = None + if not cls.segment_verts: + return + + dp = segment.DesignParameters + if not dp or not getattr(dp, "StartPoint", None): + return + + # Require a horizontal layout so we know dp is IfcAlignmentHorizontalSegment + layout = segment.Nests[0].RelatingObject if segment.Nests else None + if not layout or not layout.is_a("IfcAlignmentHorizontal"): + return + + # Collect all horizontal segments in order + segments = [] + for seg_rel in (getattr(layout, "IsNestedBy", []) or []): + for seg in (seg_rel.RelatedObjects or []): + if seg.is_a("IfcAlignmentSegment") and seg.DesignParameters: + segments.append(seg) + + if segment not in segments: + return + seg_idx = segments.index(segment) + + # IFC local start coordinate and start tangent direction + sx, sy = dp.StartPoint.Coordinates[0], dp.StartPoint.Coordinates[1] + d1x, d1y = math.cos(dp.StartDirection), math.sin(dp.StartDirection) + seg_len = getattr(dp, "SegmentLength", 0.0) or 0.0 + + # Next segment data — provides the IFC end-point and end tangent + has_next = seg_idx + 1 < len(segments) + next_dp = segments[seg_idx + 1].DesignParameters if has_next else None + if next_dp and getattr(next_dp, "StartPoint", None): + ex = next_dp.StartPoint.Coordinates[0] + ey = next_dp.StartPoint.Coordinates[1] + d2x, d2y = math.cos(next_dp.StartDirection), math.sin(next_dp.StartDirection) + else: + # Last segment or next has no StartPoint — approximate end along start tangent + ex = sx + seg_len * d1x + ey = sy + seg_len * d1y + d2x, d2y = d1x, d1y # same direction → parallel, no PI + + # Derive IFC → Blender world affine transform + # (sx,sy) ↔ segment_verts[0] and (ex,ey) ↔ segment_verts[-1] + s_w = cls.segment_verts[0] + e_w = cls.segment_verts[-1] + ifc_dx, ifc_dy = ex - sx, ey - sy + ifc_len_sq = ifc_dx ** 2 + ifc_dy ** 2 + if ifc_len_sq < 1e-12: + return + + w_dx, w_dy = e_w[0] - s_w[0], e_w[1] - s_w[1] + a_c = (ifc_dx * w_dx + ifc_dy * w_dy) / ifc_len_sq + b_c = (ifc_dx * w_dy - ifc_dy * w_dx) / ifc_len_sq + + def ifc_to_world_xy(x, y): + rx, ry = x - sx, y - sy + return s_w[0] + a_c * rx - b_c * ry, s_w[1] + b_c * rx + a_c * ry + + def world_to_ifc_xy(wx, wy): + rx_w, ry_w = wx - s_w[0], wy - s_w[1] + det = a_c * a_c + b_c * b_c + if det < 1e-12: + return sx, sy + rx = (a_c * rx_w + b_c * ry_w) / det + ry = (-b_c * rx_w + a_c * ry_w) / det + return sx + rx, sy + ry + + # For the last segment, refine the IFC end position from the world end vertex + if not (next_dp and getattr(next_dp, "StartPoint", None)): + ex, ey = world_to_ifc_xy(e_w[0], e_w[1]) + + # E/N for start and end + try: + s_enh = ifcopenshell.util.geolocation.auto_xyz2enh(ifc_file, sx, sy, 0.0) + e_enh = ifcopenshell.util.geolocation.auto_xyz2enh(ifc_file, ex, ey, 0.0) + except Exception: + return + + # Cumulative distance along, converted to station through + # station_from_distance_along() so gap/overlap station equations and + # reversed stationing are accounted for — a plain + # start_station + distance_along (what this used to do) is only + # correct when the alignment has no equations. + pc_distance_along = sum( + getattr(segments[i].DesignParameters, "SegmentLength", 0.0) or 0.0 + for i in range(seg_idx) + ) + pt_distance_along = pc_distance_along + seg_len + alignment = cls._get_alignment(layout) + if alignment is not None: + pc_station = ifcopenshell.api.alignment.station_from_distance_along( + ifc_file, alignment, pc_distance_along + ) + pt_station = ifcopenshell.api.alignment.station_from_distance_along( + ifc_file, alignment, pt_distance_along + ) + else: + pc_station = pc_distance_along + pt_station = pt_distance_along + + unit_symbol, station_separator = _get_length_unit_info(ifc_file) + + # Base record — always present regardless of segment type + cls.tangent_data = { + "start_world": (s_w[0], s_w[1], s_w[2]), + "end_world": (e_w[0], e_w[1], e_w[2]), + "pc_station": pc_station, + "pt_station": pt_station, + "start_en": (s_enh[0], s_enh[1]), + "end_en": (e_enh[0], e_enh[1]), + "unit_symbol": unit_symbol, + "station_separator": station_separator, + "has_pi": False, + } + + # PI and perpendicular-tick data — only for non-linear segments with a finite PI + seg_type = getattr(dp, "PredefinedType", "") or "" + denom_ifc = d1x * d2y - d1y * d2x + if seg_type == "LINESEGMENT" or abs(denom_ifc) < 1e-10: + return # Linear or parallel tangents — labels only, no PI geometry + + dx_ifc, dy_ifc = ex - sx, ey - sy + t1_ifc = (dx_ifc * d2y - dy_ifc * d2x) / denom_ifc + pi_ifc_x = sx + t1_ifc * d1x + pi_ifc_y = sy + t1_ifc * d1y + pi_wx, pi_wy = ifc_to_world_xy(pi_ifc_x, pi_ifc_y) + pi_wz = (s_w[2] + e_w[2]) * 0.5 + + try: + pi_enh = ifcopenshell.util.geolocation.auto_xyz2enh(ifc_file, pi_ifc_x, pi_ifc_y, 0.0) + except Exception: + return + + # Turn direction: positive denom → left (CCW), negative → right (CW) + sign_turn = 1 if denom_ifc > 0 else -1 + + # World-space normalized perpendicular directions at PC and PT + # pointing toward the inside of the curve (toward the center of curvature) + def _world_perp(dx_ifc, dy_ifc): + # IFC perp direction (unit, since d is already unit): sign_turn * (-dy, dx) + ipx = -dy_ifc * sign_turn + ipy = dx_ifc * sign_turn + wpx = a_c * ipx - b_c * ipy + wpy = b_c * ipx + a_c * ipy + length = math.hypot(wpx, wpy) + return (wpx / length, wpy / length) if length > 0 else (0.0, 0.0) + + pc_perp = _world_perp(d1x, d1y) + pt_perp = _world_perp(d2x, d2y) + + # Center of curvature (circular arcs only — constant radius gives a single center) + center_world = None + center_en = None + if seg_type == "CIRCULARARC": + R = getattr(dp, "StartRadiusOfCurvature", None) or 0.0 + if abs(R) > 1e-6: + # center = PC + R * (-d1y, d1x) in IFC local (R is signed: + left, − right) + cix = sx + R * (-d1y) + ciy = sy + R * d1x + cwx, cwy = ifc_to_world_xy(cix, ciy) + center_world = (cwx, cwy, s_w[2]) + try: + c_enh = ifcopenshell.util.geolocation.auto_xyz2enh(ifc_file, cix, ciy, 0.0) + center_en = (c_enh[0], c_enh[1]) + except Exception: + pass + + cls.tangent_data.update({ + "has_pi": True, + "pi_world": (pi_wx, pi_wy, pi_wz), + "pi_en": (pi_enh[0], pi_enh[1]), + "pc_perp": pc_perp, # (x, y) normalized world perp at PC + "pt_perp": pt_perp, # (x, y) normalized world perp at PT + "center_world": center_world, # 3-tuple or None + "center_en": center_en, # (e, n) or None + }) + + @classmethod + def _get_alignment(cls, horizontal_layout): + """The IfcAlignment that nests ``horizontal_layout``, or None.""" + for rel in getattr(horizontal_layout, "Nests", []) or []: + if rel.RelatingObject.is_a("IfcAlignment"): + return rel.RelatingObject + return None + + def draw_segment(self, context): + """Draw the orange highlight polyline and gray tangent extension lines to PI.""" + # Never draw segment overlays inside the vertical profile area + pa_ptr = VerticalProfileDecorator.profile_area_ptr + if pa_ptr != 0: + try: + if bpy.context.area is not None and bpy.context.area.as_pointer() == pa_ptr: + return + except Exception: + pass + + verts = self.__class__.segment_verts + if not verts or len(verts) < 2: + return + + gpu.state.blend_set("ALPHA") + gpu.state.depth_test_set("LESS_EQUAL") + gpu.state.depth_mask_set(False) + + shader = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR") + shader.bind() + region = bpy.context.region + shader.uniform_float("viewportSize", (region.width, region.height)) + + # Supplementary geometry for non-linear segments + td = self.__class__.tangent_data + if td and td.get("has_pi"): + sw = td["start_world"] + ew = td["end_world"] + pi = td["pi_world"] + + # Gray tangent extension lines: PC→PI and PT→PI + shader.uniform_float("lineWidth", self.LINE_TANGENT) + shader.uniform_float("color", self.COLOR_TANGENT) + for a_pt, b_pt in [(sw, pi), (ew, pi)]: + batch = batch_for_shader(shader, "LINES", {"pos": [a_pt, b_pt]}, indices=[[0, 1]]) + batch.draw(shader) + + # Radius / perpendicular lines + center = td.get("center_world") + if center: + # Circular arc: draw full radius lines from PC and PT to center + for endpoint in [sw, ew]: + batch = batch_for_shader( + shader, "LINES", {"pos": [endpoint, center]}, indices=[[0, 1]] + ) + batch.draw(shader) + + # Center point marker (yellow crosshair scaled to view) + rv3d = bpy.context.space_data.region_3d if bpy.context.space_data else None + mk = rv3d.view_distance * 0.012 if rv3d else 1.0 + cx, cy, cz = center + shader.uniform_float("lineWidth", 2.0) + shader.uniform_float("color", self.COLOR_PI) + mk_verts = [ + (cx - mk, cy, cz), (cx + mk, cy, cz), + (cx, cy - mk, cz), (cx, cy + mk, cz), + ] + batch = batch_for_shader(shader, "LINES", {"pos": mk_verts}, indices=[[0, 1], [2, 3]]) + batch.draw(shader) + else: + # Non-circular: short perpendicular ticks at PC and PT + rv3d = bpy.context.space_data.region_3d if bpy.context.space_data else None + tick = rv3d.view_distance * 0.018 if rv3d else 1.0 + for endpoint, perp in [(sw, td["pc_perp"]), (ew, td["pt_perp"])]: + px, py = perp + ex_w, ey_w, ez_w = endpoint + tick_verts = [ + (ex_w - px * tick, ey_w - py * tick, ez_w), + (ex_w + px * tick, ey_w + py * tick, ez_w), + ] + batch = batch_for_shader( + shader, "LINES", {"pos": tick_verts}, indices=[[0, 1]] + ) + batch.draw(shader) + + # Orange highlight polyline on top + shader.uniform_float("lineWidth", self.LINE_WIDTH) + shader.uniform_float("color", self.COLOR_HIGHLIGHT) + edges = [[i, i + 1] for i in range(len(verts) - 1)] + batch = batch_for_shader(shader, "LINES", {"pos": verts}, indices=edges) + batch.draw(shader) + + gpu.state.blend_set("NONE") + gpu.state.depth_test_set("NONE") + gpu.state.depth_mask_set(True) + + def _draw_screen_crosshair(self, sx: float, sy: float, color: tuple, region) -> None: + """Draw a small + crosshair at screen pixel position (sx, sy).""" + r = 9 + shader = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR") + shader.bind() + shader.uniform_float("viewportSize", (region.width, region.height)) + shader.uniform_float("lineWidth", 2.0) + shader.uniform_float("color", color) + verts = [(sx - r, sy, 0), (sx + r, sy, 0), (sx, sy - r, 0), (sx, sy + r, 0)] + gpu.state.blend_set("ALPHA") + batch = batch_for_shader(shader, "LINES", {"pos": verts}, indices=[[0, 1], [2, 3]]) + batch.draw(shader) + gpu.state.blend_set("NONE") + + def draw_label(self, context): + """Draw point labels with station and E/N coordinates in screen space. + + Curve segments (has_pi=True): PC label, PI crosshair + label, PT label. + Linear segments (has_pi=False): start and end station + coords, no tag prefix. + Circular arcs: also label the center of curvature. + """ + try: + if not bpy.context.scene.CivilAlignmentProperties.show_h_segment_labels: + return + except Exception: + pass + + # Never draw segment labels inside the vertical profile area + pa_ptr = VerticalProfileDecorator.profile_area_ptr + if pa_ptr != 0: + try: + if bpy.context.area is not None and bpy.context.area.as_pointer() == pa_ptr: + return + except Exception: + pass + + region = context.region + rv3d = context.region_data + if not region or not rv3d: + return + + font_id = 0 + blf.enable(font_id, blf.SHADOW) + blf.shadow(font_id, 6, 0, 0, 0, 1) + font_size = tool.Blender.scale_font_size(12) + line_h = font_size + 3 + + td = self.__class__.tangent_data + if td: + sep = td["station_separator"] + has_pi = td.get("has_pi", False) + + def fmt_sta(dist: float) -> str: + return _fmt_station(dist, 1.0, sep) + + def fmt_en(e: float, n: float) -> str: + return f"E {e:.2f} N {n:.2f}" + + def draw_point_label(world_pos, name, station, coords, color): + screen = location_3d_to_region_2d(region, rv3d, world_pos) + if not screen: + return + sx, sy = screen.x, screen.y + if name == "PI": + self._draw_screen_crosshair(sx, sy, self.COLOR_PI, region) + blf.size(font_id, font_size) + blf.color(font_id, *color) + lines = [name] if name else [] + if station: + lines.append(f"Sta {station}") + lines.append(coords) + for i, line in enumerate(reversed(lines)): + blf.position(font_id, sx + 12, sy + 4 + i * line_h, 0) + blf.draw(font_id, line) + + if has_pi: + # Curve segment: PC / PI / PT with name tags + draw_point_label( + td["start_world"], "PC", fmt_sta(td["pc_station"]), + fmt_en(*td["start_en"]), self.COLOR_LABEL_PC, + ) + draw_point_label( + td["pi_world"], "PI", None, + fmt_en(*td["pi_en"]), self.COLOR_LABEL_PI, + ) + draw_point_label( + td["end_world"], "PT", fmt_sta(td["pt_station"]), + fmt_en(*td["end_en"]), self.COLOR_LABEL_PT, + ) + # Center of curvature label (circular arcs only) + if td.get("center_world") and td.get("center_en"): + draw_point_label( + td["center_world"], "Center", + None, fmt_en(*td["center_en"]), self.COLOR_PI, + ) + else: + # Linear segment: start and end without PC/PT tags + draw_point_label( + td["start_world"], "", fmt_sta(td["pc_station"]), + fmt_en(*td["start_en"]), self.COLOR_LABEL_PC, + ) + draw_point_label( + td["end_world"], "", fmt_sta(td["pt_station"]), + fmt_en(*td["end_en"]), self.COLOR_LABEL_PT, + ) + else: + # Fallback: type label at midpoint when tangent data is unavailable + label_pos = self.__class__.label_world_pos + label_text = self.__class__.segment_label + if label_pos and label_text: + screen = location_3d_to_region_2d(region, rv3d, label_pos) + if screen: + blf.size(font_id, tool.Blender.scale_font_size(13)) + blf.color(font_id, *self.COLOR_LABEL) + blf.position(font_id, screen.x + 10, screen.y + 6, 0) + blf.draw(font_id, label_text) + + blf.disable(font_id, blf.SHADOW) + + +# --------------------------------------------------------------------------- +# Vertical profile grid helpers +# --------------------------------------------------------------------------- + + +def _nice_interval(span: float, target_count: int = 8) -> float: + """Return a round-number grid interval that yields ~target_count lines.""" + if not math.isfinite(span) or span <= 0: + return 1.0 + rough = span / target_count + if rough <= 0: + return 1.0 + magnitude = 10.0 ** math.floor(math.log10(rough)) + normalized = rough / magnitude + factor = 1.0 if normalized < 1.5 else 2.0 if normalized < 3.5 else 5.0 if normalized < 7.5 else 10.0 + return factor * magnitude + + +def _frange(start: float, stop: float, step: float): + """Yield evenly-spaced floats aligned to step boundaries, from start to stop.""" + if step <= 0 or not math.isfinite(start) or not math.isfinite(stop): + return + val = math.ceil(start / step - 1e-9) * step + while val <= stop + step * 1e-6: + yield val + val += step + + +def _fmt_station(dist: float, interval: float, separator: int = 1000) -> str: + """Format a distance as a civil station string. + + separator=1000 (metric): 12345.0 → '12+345' + separator=100 (feet): 12345.0 → '123+45' + """ + major = int(dist) // separator + minor = dist % separator + n = len(str(separator - 1)) # digit count for minor part (3 for 1000, 2 for 100) + if interval >= separator / 10: + return f"{major}+{int(round(minor)):0{n}d}" + elif interval >= 1.0: + return f"{major}+{minor:0{n + 2}.1f}" + else: + return f"{major}+{minor:0{n + 3}.2f}" + + +def _get_length_unit_info(ifc_file) -> tuple[str, int]: + """Return (symbol, station_separator) for the project's length unit. + + symbol: display string such as "m", "ft", "mm" + station_separator: the value at which the station '+' splits + 1000 for metric (1+000 = 1000 m) + 100 for feet (1+00 = 100 ft, matching US practice) + """ + if not ifc_file: + return "m", 1000 + try: + unit = ifcopenshell.util.unit.get_project_unit(ifc_file, "LENGTHUNIT") + if unit is not None: + symbol = ifcopenshell.util.unit.get_unit_symbol(unit) + if symbol and symbol != "?": + return symbol, (100 if symbol in ("ft", "'") else 1000) + # Fallback: derive from the SI scale factor + scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file, "LENGTHUNIT") + if abs(scale - 0.3048) < 0.01: + return "ft", 100 + if abs(scale - 0.001) < 1e-5: + return "mm", 1000 + if abs(scale - 0.01) < 1e-4: + return "cm", 1000 + except Exception: + pass + return "m", 1000 + + +def _fmt_elev(elev: float, interval: float) -> str: + """Format an elevation with decimal places matched to the grid interval.""" + if interval >= 10.0: + return f"{elev:.0f}" + elif interval >= 1.0: + return f"{elev:.1f}" + elif interval >= 0.1: + return f"{elev:.2f}" + else: + return f"{elev:.3f}" + + +class VerticalProfileDecorator: + """GPU-drawn 2D vertical profile window. + + Opens a dedicated SpaceView3D window in front orthographic mode and draws the + IfcGradientCurve as a distance-along vs. elevation plot with a configurable + vertical exaggeration factor. Middle-mouse pan/zoom are handled by Blender's + native orthographic navigation. + + Coordinate mapping inside the 3D viewport: + world X = distance along alignment + world Z = elevation × vertical_exaggeration + world Y = 0 (orthographic front view collapses the depth axis) + """ + + is_installed: bool = False + handlers: list = [] + profile_area = None # bpy.types.Area reference (may drift after redraws) + profile_area_ptr: int = 0 # C-level area pointer — stable across Python wrapper churn + + # Profile data computed once on install + segments_polylines: list = [] # list of [(dist, elev), ...] per segment + segments_info: list = [] # list of metadata dicts per segment + available_verticals: list = [] # list of (entity_id, label) for all verticals found + alignment_name: str = "" + + # Colors for multiple vertical alignments shown simultaneously + VERTICAL_COLORS = [ + (0.25, 0.85, 0.45, 1.0), # Green + (0.45, 0.65, 1.0, 1.0), # Blue + (1.0, 0.60, 0.25, 1.0), # Orange + (0.85, 0.45, 0.85, 1.0), # Purple + (0.95, 0.85, 0.20, 1.0), # Yellow + ] + dist_min: float = 0.0 + dist_max: float = 1.0 + elev_min: float = 0.0 + elev_max: float = 1.0 + unit_symbol: str = "m" # project length unit display string + station_separator: int = 1000 # value at which station '+' splits + _alignment = None # IfcAlignment entity for station conversion at draw time + + # Normalized world-Z zone boundaries (set by fit_view from area dimensions). + # These replace the old `elev_min * ve` / `elev_max * ve` approach so that + # both zones always fill the viewport proportionally, regardless of elevation scale. + elev_zone_bot: float = 0.0 + elev_zone_top: float = 1.0 + cant_zone_bot: float = -0.4 + cant_zone_top: float = -0.04 + + # Horizontal-zoom self-correction (draw_3d refines fit_view's estimate and + # re-fits when the pane is resized). + _xfit_frames: int = 0 + _last_region_wh: tuple = (0, 0) + + # Elevation and cant data ranges visible within their respective zones. + # Set by fit_view; used by _ez/_cz2 for data→world-Z mapping. + _e_display_min: float = 0.0 + _e_display_max: float = 1.0 + _c_display_min: float = 0.0 + _c_display_max: float = 0.3 + + # Cant profile data (populated alongside elevation data) + cant_polylines: list = [] # list of [(dist, cant_val), ...] per cant segment + cant_info: list = [] # list of metadata dicts per cant segment + available_cants: list = [] # list of (entity_id, label) for all cants found + has_cant: bool = False + cant_min: float = 0.0 + cant_max: float = 0.005 # default small span to avoid zero-division + + # Cant zone sizing as fractions of the elevation world-space span. + # The cant panel sits below the elevation panel separated by a gap. + # With cant present, elevation occupies 3/4 of total view height and cant + # occupies 1/4. cant_h = elev_span/3 gives exactly that 3:1 ratio. + # The gap (4 % of elev span) visually separates the two graph boxes. + CANT_HEIGHT_FRACTION: float = 1.0 / 3.0 + CANT_GAP_FRACTION: float = 0.04 + + # Cant-specific colors + CANT_COLORS = [ + (0.45, 0.78, 0.95, 1.0), # Sky blue + (0.95, 0.65, 0.35, 1.0), # Peach + (0.80, 0.95, 0.45, 1.0), # Yellow-green + (0.85, 0.45, 0.85, 1.0), # Purple + ] + # Left / right rail get their own curve and colour. Cant is the deviating + # elevation of each rail (sign preserved from the IFC), so the two are + # plotted independently rather than collapsed to a single difference. + CANT_COLOR_LEFT = (0.45, 0.78, 0.95, 1.0) # Sky blue — left rail + CANT_COLOR_RIGHT = (0.98, 0.72, 0.38, 1.0) # Amber — right rail + CANT_COLOR_CENTER = (0.62, 0.62, 0.66, 1.0) # Grey — centreline + COLOR_CANT_GRID = (0.22, 0.22, 0.26, 1.0) + COLOR_CANT_ZERO = (0.50, 0.50, 0.52, 1.0) + COLOR_CANT_SEP = (0.48, 0.48, 0.48, 1.0) + COLOR_CANT_HDR = (0.75, 0.75, 0.82, 1.0) + + COLOR_GRID = (0.27, 0.27, 0.27, 1.0) # Subtle dark-gray grid + COLOR_PROFILE = (0.25, 0.85, 0.45, 1.0) # Green profile curve + COLOR_BOUNDARY = (0.90, 0.85, 0.25, 1.0) # Yellow segment ticks + COLOR_LABEL = (0.80, 0.80, 0.80, 1.0) # Light-gray axis labels + COLOR_AXIS_TITLE = (0.65, 0.65, 0.65, 1.0) + COLOR_HEADER = (0.95, 0.95, 0.95, 1.0) + COLOR_TANGENT_VERT = (0.70, 0.70, 0.70, 0.75) # Gray tangent extension lines + COLOR_BVC = (1.0, 0.78, 0.40, 1.0) # Warm orange — BVC + COLOR_EVC = (0.60, 0.88, 1.0, 1.0) # Light blue — EVC + COLOR_PVI_VERT = (1.0, 0.90, 0.30, 1.0) # Yellow — PVI + COLOR_GRAD = (0.75, 0.95, 0.75, 1.0) # Light green — gradient endpoints + LINE_GRID = 1.0 + LINE_PROFILE = 2.5 + LINE_BOUNDARY = 1.2 + LINE_TANGENT_VERT = 1.0 + + # ------------------------------------------------------------------ public + + @classmethod + def install(cls, context, profile_area) -> None: + """Attach draw handlers to an already-configured profile area.""" + if cls.is_installed: + cls.uninstall() + + cls.profile_area = profile_area + cls.profile_area_ptr = profile_area.as_pointer() + + handler = cls() + cls.handlers.append( + SpaceView3D.draw_handler_add(handler.draw_3d, (context,), "WINDOW", "POST_VIEW") + ) + cls.handlers.append( + SpaceView3D.draw_handler_add(handler.draw_labels, (context,), "WINDOW", "POST_PIXEL") + ) + cls.is_installed = True + + @classmethod + def uninstall(cls) -> None: + for handler in cls.handlers: + try: + SpaceView3D.draw_handler_remove(handler, "WINDOW") + except ValueError: + pass + cls.handlers = [] + cls.is_installed = False + + area_ptr = cls.profile_area_ptr + cls.profile_area = None + cls.profile_area_ptr = 0 + cls.segments_polylines = [] + cls.segments_info = [] + cls.available_verticals = [] + cls._alignment = None + cls.cant_polylines = [] + cls.cant_info = [] + cls.available_cants = [] + cls.has_cant = False + cls.elev_zone_bot = 0.0 + cls.elev_zone_top = 1.0 + cls.cant_zone_bot = -0.4 + cls.cant_zone_top = -0.04 + cls._e_display_min = 0.0 + cls._e_display_max = 1.0 + cls._c_display_min = 0.0 + cls._c_display_max = 0.3 + + # Find and close the profile area by its stable C pointer. + # We search screen.areas fresh rather than reusing the stored Python + # wrapper, which may have drifted after redraws. + if area_ptr != 0: + try: + target = next( + (a for a in bpy.context.screen.areas if a.as_pointer() == area_ptr), + None, + ) + if target is not None: + with bpy.context.temp_override( + area=target, + window=bpy.context.window, + screen=bpy.context.screen, + ): + bpy.ops.screen.area_close() + except Exception: + pass + + @classmethod + def tag_redraw(cls) -> None: + """Force the profile area to redraw (e.g. when VE slider changes).""" + if not cls.is_installed or cls.profile_area_ptr == 0: + return + try: + cls.profile_area.tag_redraw() + except Exception: + pass + + @classmethod + def fit_view(cls, space, ve: float, area_width: int = 1920, area_height: int = 400) -> None: + """Reposition the profile camera so both zones always fill the viewport proportionally. + + Zone heights are derived from the area aspect ratio so they remain visible + regardless of the elevation data scale (including flat/near-zero alignments). + """ + h_span = max(cls.dist_max - cls.dist_min, 1.0) + # An ortho VIEW_3D shows ~1.08x its view_distance in world height, so the + # distance that frames h_span across ~86% of the pane width is + # vd = h_span * (H/W) / (0.86 * 1.08). This is only the initial guess — + # draw_3d refines it against the real projection (and re-fits on resize). + ar = area_height / max(area_width, 1) + vd = h_span * ar / (0.86 * 1.08) + vis_z = 2 * vd + + # Elevation display range: at least 1 m visible so flat profiles show a usable axis. + e_span = max(cls.elev_max - cls.elev_min, 0.0) + e_pad = max(e_span * 0.10, 1.0) + cls._e_display_min = cls.elev_min - e_pad * 0.05 + cls._e_display_max = cls._e_display_min + max(e_span, 0.0) + e_pad + + # Zone boundaries: cant at bottom, elevation above, small gap between. + if cls.has_cant: + total_content_h = vis_z * 0.92 + cant_h = total_content_h * 0.25 + gap_h = vis_z * 0.02 + elev_h = total_content_h - cant_h - gap_h + total = elev_h + gap_h + cant_h + cls.cant_zone_bot = -total / 2 + cls.cant_zone_top = cls.cant_zone_bot + cant_h + cls.elev_zone_bot = cls.cant_zone_top + gap_h + cls.elev_zone_top = cls.elev_zone_bot + elev_h + + # Cant display range with 5 % padding on each side + c_span = max(cls.cant_max - cls.cant_min, 0.0) + c_pad = max(c_span * 0.10, 0.001) + cls._c_display_min = cls.cant_min - c_pad * 0.05 + cls._c_display_max = cls.cant_max + c_pad * 0.95 + else: + elev_h = vis_z * 0.90 + cls.elev_zone_bot = -elev_h / 2 + cls.elev_zone_top = elev_h / 2 + + mid_d = (cls.dist_min + cls.dist_max) * 0.5 + space.region_3d.view_location = mathutils.Vector((mid_d, 0.0, 0.0)) + space.region_3d.view_distance = max(vd, 1.0) + cls._xfit_frames = 6 + cls._last_region_wh = (0, 0) + + @classmethod + def _recompute_zones(cls, center_z: float, span_z: float) -> None: + """Derive the elevation / cant zone bands from the LIVE visible Z span. + + Called every frame from the draw handlers with the Z range actually + measured from the viewport's screen corners. fit_view can only estimate + this from the area size, which Blender has not finalised at split time + (and which changes whenever the user drags the pane border) — its + estimate is routinely 2-4x off, which pushes the bottom (cant) band + clean off the bottom edge of the viewport. Recomputing here from the + real visible span keeps both panels framed correctly no matter what. + """ + if not math.isfinite(span_z) or span_z <= 0: + return + if cls.has_cant: + content_h = span_z * 0.92 + cant_h = content_h * 0.25 + gap_h = span_z * 0.02 + elev_h = content_h - cant_h - gap_h + cls.cant_zone_bot = center_z - content_h / 2.0 + cls.cant_zone_top = cls.cant_zone_bot + cant_h + cls.elev_zone_bot = cls.cant_zone_top + gap_h + cls.elev_zone_top = cls.elev_zone_bot + elev_h + else: + elev_h = span_z * 0.90 + cls.elev_zone_bot = center_z - elev_h / 2.0 + cls.elev_zone_top = center_z + elev_h / 2.0 + + # --------------------------------------------------------------- geometry + + @classmethod + def _compute_profile(cls, alignment) -> None: + cls.segments_polylines = [] + cls.segments_info = [] + cls.available_verticals = [] + cls.alignment_name = alignment.Name or "(unnamed)" + cls.unit_symbol, cls.station_separator = _get_length_unit_info(tool.Ifc.get()) + cls._alignment = alignment + + all_dists: list[float] = [] + all_elevs: list[float] = [] + + # Collect all IfcAlignmentVertical layouts: those directly nested under + # the alignment AND those under child alignments (IFC CT 4.1.4.4.1.2). + vertical_layouts = tool.Alignment.get_all_vertical_layouts(alignment) + + for layout_entity in vertical_layouts: + v_id = layout_entity.id() + v_label = layout_entity.Name or f"V{len(cls.available_verticals) + 1}" + color_idx = len(cls.available_verticals) + cls.available_verticals.append((v_id, v_label)) + + for seg_rel in getattr(layout_entity, "IsNestedBy", []) or []: + for seg in seg_rel.RelatedObjects or []: + if not seg.is_a("IfcAlignmentSegment"): + continue + dp = seg.DesignParameters + if not dp: + continue + + dist = getattr(dp, "StartDistAlong", 0.0) or 0.0 + height = getattr(dp, "StartHeight", 0.0) or 0.0 + h_len = getattr(dp, "HorizontalLength", 0.0) or 0.0 + g_start = getattr(dp, "StartGradient", 0.0) or 0.0 + g_end = getattr(dp, "EndGradient", g_start) or g_start + seg_type = dp.PredefinedType or "UNKNOWN" + + if h_len <= 0: + continue + + pts = cls._sample_segment(dist, height, h_len, g_start, g_end, seg_type) + cls.segments_polylines.append(pts) + cls.segments_info.append( + { + "dist": dist, + "height": height, + "h_len": h_len, + "g_start": g_start, + "g_end": g_end, + "type": seg_type, + "vertical_id": v_id, + "vertical_label": v_label, + "color_idx": color_idx, + "segment_id": seg.id(), + } + ) + all_dists.extend(d for d, _ in pts) + all_elevs.extend(e for _, e in pts) + + if all_dists: + cls.dist_min = min(all_dists) + cls.dist_max = max(all_dists) + cls.elev_min = min(all_elevs) + cls.elev_max = max(all_elevs) + + # Collect cant data (plotted below the elevation profile) + cls._collect_cant_data(alignment) + + # Compute BVC / EVC / PVI geometry for each segment. + # BVC = start, EVC = end (from sampled polyline for accuracy). + # PVI = tangent intersection at dist + h_len/2 (parabolic arcs always have PVI at midpoint). + for i, info in enumerate(cls.segments_info): + pts = cls.segments_polylines[i] if i < len(cls.segments_polylines) else [] + bvc_d = info["dist"] + bvc_e = info["height"] + if pts: + evc_d, evc_e = pts[-1] + else: + evc_d = bvc_d + info["h_len"] + evc_e = bvc_e + info["g_start"] * info["h_len"] + is_curve = info["type"] not in ("CONSTANTGRADIENT",) + pvi = None + if is_curve and abs(info["g_start"] - info["g_end"]) > 1e-10: + pvi_d = bvc_d + info["h_len"] / 2.0 + pvi_e = bvc_e + info["g_start"] * info["h_len"] / 2.0 + pvi = (pvi_d, pvi_e) + info["bvc"] = (bvc_d, bvc_e) + info["evc"] = (evc_d, evc_e) + info["pvi"] = pvi + info["is_curve"] = is_curve + + @classmethod + def _collect_cant_data(cls, alignment) -> None: + """Collect IfcAlignmentCant segments and build the cant profile arrays.""" + cls.cant_polylines = [] + cls.cant_info = [] + cls.available_cants = [] + cls.has_cant = False + cls.cant_min = 0.0 + cls.cant_max = 0.005 + + # Cant layouts directly nested under the alignment + cant_layouts = [] + for rel in getattr(alignment, "IsNestedBy", []) or []: + for obj in rel.RelatedObjects or []: + if obj.is_a("IfcAlignmentCant"): + cant_layouts.append(obj) + + # Cant layouts on child alignments (IFC CT 4.1.4.4.1.2 pattern) + for rel in getattr(alignment, "IsDecomposedBy", []) or []: + for child in rel.RelatedObjects or []: + if not child.is_a("IfcAlignment"): + continue + for crel in getattr(child, "IsNestedBy", []) or []: + for obj in crel.RelatedObjects or []: + if obj.is_a("IfcAlignmentCant"): + cant_layouts.append(obj) + + if not cant_layouts: + return + + all_vals: list[float] = [] + + for cant_layout in cant_layouts: + c_id = cant_layout.id() + # Prefer the owning alignment name as the label + c_label = cant_layout.Name or f"Cant #{c_id}" + for rel in getattr(cant_layout, "Nests", []) or []: + if rel.RelatingObject.is_a("IfcAlignment"): + c_label = rel.RelatingObject.Name or c_label + break + color_idx = len(cls.available_cants) + cls.available_cants.append((c_id, c_label)) + + for seg_rel in getattr(cant_layout, "IsNestedBy", []) or []: + for seg in seg_rel.RelatedObjects or []: + if not seg.is_a("IfcAlignmentSegment"): + continue + dp = seg.DesignParameters + if not dp: + continue + + dist = getattr(dp, "StartDistAlong", 0.0) or 0.0 + h_len = getattr(dp, "HorizontalLength", None) + if h_len is None: + h_len = getattr(dp, "Length", 0.0) or 0.0 + seg_type = getattr(dp, "PredefinedType", "?") or "?" + + if h_len <= 0: + continue + + start_l = getattr(dp, "StartCantLeft", 0.0) or 0.0 + start_r = getattr(dp, "StartCantRight", 0.0) or 0.0 + end_l = getattr(dp, "EndCantLeft", None) + end_r = getattr(dp, "EndCantRight", None) + end_l = start_l if end_l is None else end_l + end_r = start_r if end_r is None else end_r + + # Three curves: centreline deviating elevation and each + # railhead. Railhead deviating elevation == the matching + # Start/EndCant{Left,Right}; centreline == their mean. Every + # curve follows the segment's named transition shape. + rails = ( + ("C", 0.5 * (start_l + start_r), 0.5 * (end_l + end_r)), + ("L", start_l, end_l), + ("R", start_r, end_r), + ) + for rail, s_val, e_val in rails: + pts = cls._sample_cant_segment(dist, h_len, s_val, e_val, seg_type) + cls.cant_polylines.append(pts) + cls.cant_info.append({ + "dist": dist, + "h_len": h_len, + "rail": rail, + "start_cant": s_val, + "end_cant": e_val, + "type": seg_type, + "cant_id": c_id, + "cant_label": c_label, + "color_idx": color_idx, + "segment_id": seg.id(), + }) + all_vals.extend(v for _, v in pts) + + if not all_vals: + return + + cls.has_cant = True + cls.cant_min = min(all_vals) + cls.cant_max = max(all_vals) + # Enforce a minimum visible span of 5 mm so the profile never collapses to a line + if cls.cant_max - cls.cant_min < 0.005: + mid = (cls.cant_max + cls.cant_min) / 2 + cls.cant_min = mid - 0.0025 + cls.cant_max = mid + 0.0025 + + @staticmethod + def _cant_transition_factor(u: float, seg_type: str) -> float: + """Fraction (0..1) of the cant change completed at normalised position u. + + Closed-form of each IfcAlignmentCantSegment transition shape — matches + the parent-curve the IFC geometry mapping builds + (ifcopenshell.api.alignment._map_alignment_cant_segment). + """ + if u <= 0.0: + return 0.0 + if u >= 1.0: + return 1.0 + if seg_type in ("LINEARTRANSITION", "CONSTANTCANT"): + return u + if seg_type == "BLOSSCURVE": + return 3.0 * u * u - 2.0 * u * u * u + if seg_type == "COSINECURVE": + return (1.0 - math.cos(math.pi * u)) * 0.5 + if seg_type == "SINECURVE": + return u - math.sin(2.0 * math.pi * u) / (2.0 * math.pi) + if seg_type == "HELMERTCURVE": + return 2.0 * u * u if u <= 0.5 else 1.0 - 2.0 * (1.0 - u) ** 2 + # VIENNESEBEND (couples with the horizontal) and anything unknown — + # a cubic S-curve is the closest single-segment approximation. + return 3.0 * u * u - 2.0 * u * u * u + + @classmethod + def _sample_cant_segment( + cls, + dist: float, + h_len: float, + cant_start: float, + cant_end: float, + seg_type: str, + n: int | None = None, + ) -> list[tuple[float, float]]: + """Return a polyline of one rail's deviating elevation over a cant segment.""" + d = cant_end - cant_start + if seg_type == "CONSTANTCANT" or abs(d) < 1e-12: + return [(dist, cant_start), (dist + h_len, cant_start)] + if n is None: + n = 2 if seg_type == "LINEARTRANSITION" else 24 + return [ + (dist + (i / n) * h_len, + cant_start + d * cls._cant_transition_factor(i / n, seg_type)) + for i in range(n + 1) + ] + + @staticmethod + def _sample_segment( + dist: float, + height: float, + h_len: float, + g_start: float, + g_end: float, + seg_type: str, + n: int = 48, + ) -> list[tuple[float, float]]: + """Return a polyline approximation of one vertical segment.""" + if seg_type == "CONSTANTGRADIENT": + return [(dist, height), (dist + h_len, height + h_len * g_start)] + + # Parabolic blending covers PARABOLICARC, CIRCULARARC, CLOTHOID, and + # other transition types to a good visual approximation. + # h(t) = h0 + g1·t + (g2−g1)/(2L)·t² + pts = [] + dg_over_2L = (g_end - g_start) / (2.0 * h_len) + for i in range(n + 1): + t = h_len * i / n + pts.append((dist + t, height + g_start * t + dg_over_2L * t * t)) + return pts + + @classmethod + def _dist_to_station_str(cls, dist_along: float, interval: float = 1.0) -> str: + """Convert a distance-along value to a formatted station string. + + Delegates to ifcopenshell.api.alignment.station_from_distance_along so that + beginning station and any station equations are accounted for. + """ + try: + import ifcopenshell.api.alignment as _ali + if cls._alignment is not None: + station = _ali.station_from_distance_along(tool.Ifc.get(), cls._alignment, dist_along) + return _fmt_station(station, interval, cls.station_separator) + except Exception: + pass + return _fmt_station(dist_along, interval, cls.station_separator) + + @classmethod + def _ez(cls, e: float) -> float: + """Map an elevation data value to world-Z within the elevation zone.""" + e_span = max(cls._e_display_max - cls._e_display_min, 1e-10) + t = (e - cls._e_display_min) / e_span + return cls.elev_zone_bot + t * (cls.elev_zone_top - cls.elev_zone_bot) + + @classmethod + def _cz2(cls, v: float) -> float: + """Map a cant data value to world-Z within the cant zone.""" + c_span = max(cls._c_display_max - cls._c_display_min, 1e-10) + t = (v - cls._c_display_min) / c_span + return cls.cant_zone_bot + t * (cls.cant_zone_top - cls.cant_zone_bot) + + # ---------------------------------------------------------------- drawing + + def draw_3d(self, context) -> None: + """Draw grid, profile polylines, and boundary ticks in 3D world space (POST_VIEW).""" + try: + if bpy.context.area is None or bpy.context.area.as_pointer() != self.__class__.profile_area_ptr: + return + except Exception: + return + + cls = self.__class__ + region = bpy.context.region + rv3d = bpy.context.region_data + if not region or not rv3d: + return + + try: + props = bpy.context.scene.CivilAlignmentProperties + ve = props.vertical_exaggeration + except Exception: + return + + # Visible vertical IDs (None = show all) + visible_ids: set | None = None + try: + if props.vertical_items: + visible_ids = {item.entity_id for item in props.vertical_items if item.is_visible} + except Exception: + pass + + # --- Visible world extents from screen corners ------------------------- + ref = (cls.dist_min, 0.0, (cls.elev_zone_bot + cls.elev_zone_top) * 0.5) + bl = region_2d_to_location_3d(region, rv3d, (0, 0), ref) + tr = region_2d_to_location_3d(region, rv3d, (region.width, region.height), ref) + if bl is None or tr is None: + return + + vis_d_min, vis_d_max = bl.x, tr.x + vis_z_min, vis_z_max = bl.z, tr.z + + # --- Horizontal-zoom self-correction -------------------------------- + # fit_view can only estimate the ortho projection; nail the X framing + # against the real one here so the whole alignment (segment 1 to the + # end) is on screen, and re-fit whenever the pane is resized. + wh = (region.width, region.height) + if wh != cls._last_region_wh: + cls._last_region_wh = wh + cls._xfit_frames = 6 + if cls._xfit_frames > 0: + cls._xfit_frames -= 1 + vis_span = vis_d_max - vis_d_min + data_span = max(cls.dist_max - cls.dist_min, 1e-6) + if vis_span > 1e-6: + ratio = (data_span / 0.88) / vis_span # alignment fills ~88% of width + if abs(ratio - 1.0) > 0.02: + # Adjust and repaint next frame; this frame still draws + # (one slightly-off frame reads better than a blank flash). + try: + rv3d.view_distance = max(rv3d.view_distance * ratio, 1.0) + rv3d.view_location = mathutils.Vector( + ((cls.dist_min + cls.dist_max) * 0.5, 0.0, 0.0) + ) + bpy.context.area.tag_redraw() + except Exception: + pass + else: + cls._xfit_frames = 0 + + # Frame the zone bands to the Z span actually visible right now. + cls._recompute_zones((vis_z_min + vis_z_max) * 0.5, vis_z_max - vis_z_min) + + # Small padding so grid lines fully cover the viewport edges + d_pad = (vis_d_max - vis_d_min) * 0.02 + z_pad = (vis_z_max - vis_z_min) * 0.02 + + d_interval = _nice_interval(vis_d_max - vis_d_min, 8) + # Zone boundaries in world-Z (shortcuts used throughout draw_3d) + z_elev_bot = cls.elev_zone_bot + z_elev_top = cls.elev_zone_top + # Elevation display range for grid intervals + vis_e_min_clamp = cls._e_display_min + vis_e_max_clamp = cls._e_display_max + e_interval = _nice_interval(max(vis_e_max_clamp - vis_e_min_clamp, 1e-6), 6) + + # --- Solid background — covers the 3D scene objects that the split + # VIEW_3D would otherwise show (horizontal alignment geometry, etc.). + # Drawn with depth_test NONE so it always writes over scene content. + gpu.state.blend_set("NONE") + gpu.state.depth_test_set("NONE") + bg_margin = max(abs(vis_d_max - vis_d_min), abs(vis_z_max - vis_z_min)) * 0.5 + 1e4 + bg_shader = gpu.shader.from_builtin("UNIFORM_COLOR") + bg_shader.bind() + bg_shader.uniform_float("color", (0.11, 0.11, 0.11, 1.0)) + bg_verts = [ + (vis_d_min - bg_margin, 0.0, vis_z_min - bg_margin), + (vis_d_max + bg_margin, 0.0, vis_z_min - bg_margin), + (vis_d_max + bg_margin, 0.0, vis_z_max + bg_margin), + (vis_d_min - bg_margin, 0.0, vis_z_max + bg_margin), + ] + batch_for_shader( + bg_shader, "TRIS", {"pos": bg_verts}, indices=[(0, 1, 2), (0, 2, 3)] + ).draw(bg_shader) + + gpu.state.blend_set("ALPHA") + gpu.state.depth_test_set("NONE") + + shader = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR") + shader.bind() + shader.uniform_float("viewportSize", (region.width, region.height)) + + # --- Grid lines (drawn first so profile renders on top) --------------- + shader.uniform_float("lineWidth", cls.LINE_GRID) + shader.uniform_float("color", cls.COLOR_GRID) + + # Vertical station grid lines + for d in _frange(vis_d_min, vis_d_max, d_interval): + batch = batch_for_shader( + shader, "LINES", + {"pos": [(d, 0.0, vis_z_min - z_pad), (d, 0.0, vis_z_max + z_pad)]}, + indices=[[0, 1]], + ) + batch.draw(shader) + + # Horizontal elevation grid lines — clipped to the elevation zone only + for e in _frange(vis_e_min_clamp, vis_e_max_clamp, e_interval): + z = cls._ez(e) + if z < z_elev_bot - 1e-6 or z > z_elev_top + 1e-6: + continue + batch = batch_for_shader( + shader, "LINES", + {"pos": [(vis_d_min - d_pad, 0.0, z), (vis_d_max + d_pad, 0.0, z)]}, + indices=[[0, 1]], + ) + batch.draw(shader) + + # --- Profile segments ------------------------------------------------- + shader.uniform_float("lineWidth", cls.LINE_PROFILE) + for pts, info in zip(cls.segments_polylines, cls.segments_info): + if visible_ids is not None and info.get("vertical_id", -1) not in visible_ids: + continue + verts = [(d, 0.0, cls._ez(e)) for d, e in pts] + if len(verts) < 2: + continue + color = cls.VERTICAL_COLORS[info.get("color_idx", 0) % len(cls.VERTICAL_COLORS)] + shader.uniform_float("color", color) + edges = [[i, i + 1] for i in range(len(verts) - 1)] + batch = batch_for_shader(shader, "LINES", {"pos": verts}, indices=edges) + batch.draw(shader) + + # --- Highlighted selected vertical segment (drawn on top) ------------ + selected_v_id = 0 + try: + selected_v_id = props.selected_v_segment_id + except Exception: + pass + + if selected_v_id: + shader.uniform_float("lineWidth", cls.LINE_PROFILE + 2.0) + shader.uniform_float("color", (1.0, 0.55, 0.0, 1.0)) # Orange + for pts, info in zip(cls.segments_polylines, cls.segments_info): + if info.get("segment_id") != selected_v_id: + continue + if visible_ids is not None and info.get("vertical_id", -1) not in visible_ids: + continue + verts = [(d, 0.0, cls._ez(e)) for d, e in pts] + if len(verts) < 2: + continue + edges = [[i, i + 1] for i in range(len(verts) - 1)] + batch = batch_for_shader(shader, "LINES", {"pos": verts}, indices=edges) + batch.draw(shader) + + # --- Segment boundary ticks ------------------------------------------ + tick = max((cls.elev_zone_top - cls.elev_zone_bot) * 0.04, 0.5) + shader.uniform_float("lineWidth", cls.LINE_BOUNDARY) + shader.uniform_float("color", cls.COLOR_BOUNDARY) + for info in cls.segments_info: + if visible_ids is not None and info.get("vertical_id", -1) not in visible_ids: + continue + d = info["dist"] + z = cls._ez(info["height"]) + batch = batch_for_shader( + shader, "LINES", + {"pos": [(d, 0.0, z - tick), (d, 0.0, z + tick)]}, + indices=[[0, 1]], + ) + batch.draw(shader) + + # --- Vertical curve tangent lines (BVC→PVI and EVC→PVI) -------------- + shader.uniform_float("lineWidth", cls.LINE_TANGENT_VERT) + shader.uniform_float("color", cls.COLOR_TANGENT_VERT) + for info in cls.segments_info: + if visible_ids is not None and info.get("vertical_id", -1) not in visible_ids: + continue + if not info.get("is_curve") or info.get("pvi") is None: + continue + bvc_d, bvc_e = info["bvc"] + evc_d, evc_e = info["evc"] + pvi_d, pvi_e = info["pvi"] + bvc_w = (bvc_d, 0.0, cls._ez(bvc_e)) + evc_w = (evc_d, 0.0, cls._ez(evc_e)) + pvi_w = (pvi_d, 0.0, cls._ez(pvi_e)) + for a_pt, b_pt in [(bvc_w, pvi_w), (evc_w, pvi_w)]: + batch = batch_for_shader( + shader, "LINES", {"pos": [a_pt, b_pt]}, indices=[[0, 1]] + ) + batch.draw(shader) + + # --- Elevation subplot — full 4-sided border box ---------------------- + AXIS_COLOR = (0.55, 0.55, 0.55, 1.0) + shader.uniform_float("lineWidth", 1.5) + shader.uniform_float("color", AXIS_COLOR) + # Left Y-axis + batch_for_shader(shader, "LINES", + {"pos": [(cls.dist_min, 0.0, z_elev_bot), + (cls.dist_min, 0.0, z_elev_top)]}, + indices=[[0, 1]]).draw(shader) + # Right border + batch_for_shader(shader, "LINES", + {"pos": [(cls.dist_max, 0.0, z_elev_bot), + (cls.dist_max, 0.0, z_elev_top)]}, + indices=[[0, 1]]).draw(shader) + # Bottom border + batch_for_shader(shader, "LINES", + {"pos": [(cls.dist_min, 0.0, z_elev_bot), + (cls.dist_max, 0.0, z_elev_bot)]}, + indices=[[0, 1]]).draw(shader) + # Top border + batch_for_shader(shader, "LINES", + {"pos": [(cls.dist_min, 0.0, z_elev_top), + (cls.dist_max, 0.0, z_elev_top)]}, + indices=[[0, 1]]).draw(shader) + + # ================================================================ + # --- Cant zone (plotted below elevation, separate Y axis) ----- + # ================================================================ + if cls.has_cant and cls.cant_polylines: + visible_cant_ids: set | None = None + try: + if props.cant_items: + visible_cant_ids = {it.entity_id for it in props.cant_items if it.is_visible} + except Exception: + pass + + cant_z_top = cls.cant_zone_top + cant_z_bot = cls.cant_zone_bot + cant_h = cant_z_top - cant_z_bot + _cz = cls._cz2 + + # Background — slightly distinct shade so the cant zone reads as + # a separate panel from the elevation zone above it. + gpu.state.blend_set("NONE") + gpu.state.depth_test_set("NONE") + bg2 = gpu.shader.from_builtin("UNIFORM_COLOR") + bg2.bind() + bg2.uniform_float("color", (0.12, 0.12, 0.14, 1.0)) + bg2_verts = [ + (vis_d_min - d_pad, 0.0, cant_z_bot), + (vis_d_max + d_pad, 0.0, cant_z_bot), + (vis_d_max + d_pad, 0.0, cant_z_top), + (vis_d_min - d_pad, 0.0, cant_z_top), + ] + batch_for_shader(bg2, "TRIS", {"pos": bg2_verts}, + indices=[(0, 1, 2), (0, 2, 3)]).draw(bg2) + + gpu.state.blend_set("ALPHA") + gpu.state.depth_test_set("NONE") + + # Re-bind the polyline shader — drawing the background quad above + # left the UNIFORM_COLOR shader bound, so every subsequent + # shader.uniform_float() call would target the wrong program and the + # entire cant zone (zero line, grid, polylines, ticks, border) would + # silently fail to render. + shader.bind() + shader.uniform_float("viewportSize", (region.width, region.height)) + + # Zero-cant reference line + zero_z = _cz(0.0) + if cant_z_bot <= zero_z <= cant_z_top: + shader.uniform_float("lineWidth", 1.2) + shader.uniform_float("color", cls.COLOR_CANT_ZERO) + batch_for_shader(shader, "LINES", + {"pos": [(vis_d_min - d_pad, 0.0, zero_z), + (vis_d_max + d_pad, 0.0, zero_z)]}, + indices=[[0, 1]]).draw(shader) + + # Cant grid lines (4–5 horizontal lines covering the cant range) + c_disp_span = max(cls._c_display_max - cls._c_display_min, 1e-10) + cant_interval = _nice_interval(c_disp_span, 4) + shader.uniform_float("lineWidth", cls.LINE_GRID) + shader.uniform_float("color", cls.COLOR_CANT_GRID) + for cv in _frange(cls._c_display_min, cls._c_display_max, cant_interval): + gz = _cz(cv) + if cant_z_bot - 1e-6 <= gz <= cant_z_top + 1e-6: + batch_for_shader(shader, "LINES", + {"pos": [(vis_d_min - d_pad, 0.0, gz), + (vis_d_max + d_pad, 0.0, gz)]}, + indices=[[0, 1]]).draw(shader) + + # Cant polylines — centreline + one per rail, coloured by role + _rail_color = { + "C": cls.CANT_COLOR_CENTER, + "L": cls.CANT_COLOR_LEFT, + "R": cls.CANT_COLOR_RIGHT, + } + for pts, info in zip(cls.cant_polylines, cls.cant_info): + if visible_cant_ids is not None and info.get("cant_id", -1) not in visible_cant_ids: + continue + verts = [(d, 0.0, _cz(v)) for d, v in pts] + if len(verts) < 2: + continue + rail = info.get("rail", "L") + shader.uniform_float("lineWidth", 1.4 if rail == "C" else cls.LINE_PROFILE) + shader.uniform_float("color", _rail_color.get(rail, cls.CANT_COLOR_LEFT)) + edges = [[i, i + 1] for i in range(len(verts) - 1)] + batch_for_shader(shader, "LINES", {"pos": verts}, indices=edges).draw(shader) + + # Selected cant segment highlight (orange, thicker) + selected_cant_id = 0 + try: + selected_cant_id = props.selected_cant_segment_id + except Exception: + pass + if selected_cant_id: + shader.uniform_float("lineWidth", cls.LINE_PROFILE + 2.0) + shader.uniform_float("color", (1.0, 0.55, 0.0, 1.0)) + for pts, info in zip(cls.cant_polylines, cls.cant_info): + if info.get("segment_id") != selected_cant_id: + continue + if visible_cant_ids is not None and info.get("cant_id", -1) not in visible_cant_ids: + continue + verts = [(d, 0.0, _cz(v)) for d, v in pts] + if len(verts) < 2: + continue + edges = [[i, i + 1] for i in range(len(verts) - 1)] + batch_for_shader(shader, "LINES", {"pos": verts}, indices=edges).draw(shader) + + # Cant segment boundary ticks + tick_c = cant_h * 0.04 + shader.uniform_float("lineWidth", cls.LINE_BOUNDARY) + shader.uniform_float("color", cls.COLOR_BOUNDARY) + for info in cls.cant_info: + if info.get("rail") == "C": + continue + if visible_cant_ids is not None and info.get("cant_id", -1) not in visible_cant_ids: + continue + d = info["dist"] + gz = _cz(info["start_cant"]) + batch_for_shader(shader, "LINES", + {"pos": [(d, 0.0, gz - tick_c), (d, 0.0, gz + tick_c)]}, + indices=[[0, 1]]).draw(shader) + + # Cant subplot — full 4-sided border box (right side = cant Y-axis) + shader.uniform_float("lineWidth", 1.5) + shader.uniform_float("color", (0.55, 0.55, 0.55, 1.0)) + # Left border + batch_for_shader(shader, "LINES", + {"pos": [(cls.dist_min, 0.0, cant_z_bot), + (cls.dist_min, 0.0, cant_z_top)]}, + indices=[[0, 1]]).draw(shader) + # Right border (cant Y-axis) + batch_for_shader(shader, "LINES", + {"pos": [(cls.dist_max, 0.0, cant_z_bot), + (cls.dist_max, 0.0, cant_z_top)]}, + indices=[[0, 1]]).draw(shader) + # Bottom border + batch_for_shader(shader, "LINES", + {"pos": [(cls.dist_min, 0.0, cant_z_bot), + (cls.dist_max, 0.0, cant_z_bot)]}, + indices=[[0, 1]]).draw(shader) + # Top border + batch_for_shader(shader, "LINES", + {"pos": [(cls.dist_min, 0.0, cant_z_top), + (cls.dist_max, 0.0, cant_z_top)]}, + indices=[[0, 1]]).draw(shader) + + gpu.state.blend_set("NONE") + gpu.state.depth_test_set("NONE") + + def draw_labels(self, context) -> None: + """Draw axis labels, grid tick labels, header, and segment callouts (POST_PIXEL).""" + try: + if bpy.context.area is None or bpy.context.area.as_pointer() != self.__class__.profile_area_ptr: + return + except Exception: + return + + cls = self.__class__ + region = bpy.context.region + rv3d = bpy.context.region_data + if not region or not rv3d: + return + + try: + props = bpy.context.scene.CivilAlignmentProperties + ve = props.vertical_exaggeration + except Exception: + return + + # Visible vertical IDs (None = show all) + visible_ids: set | None = None + try: + if props.vertical_items: + visible_ids = {item.entity_id for item in props.vertical_items if item.is_visible} + except Exception: + pass + + # Show the vertical name as a label prefix when >1 vertical is visible + n_visible = len(visible_ids) if visible_ids is not None else len(cls.available_verticals) + show_vertical_prefix = n_visible > 1 + + # Recompute visible world extents (same calculation as draw_3d). + ref = (cls.dist_min, 0.0, (cls.elev_zone_bot + cls.elev_zone_top) * 0.5) + bl = region_2d_to_location_3d(region, rv3d, (0, 0), ref) + tr = region_2d_to_location_3d(region, rv3d, (region.width, region.height), ref) + if bl is None or tr is None: + return + + vis_d_min, vis_d_max = bl.x, tr.x + vis_z_min, vis_z_max = bl.z, tr.z + + # Keep the label geometry in lock-step with draw_3d's zone framing. + cls._recompute_zones((vis_z_min + vis_z_max) * 0.5, vis_z_max - vis_z_min) + + d_interval = _nice_interval(vis_d_max - vis_d_min, 8) + vis_e_min_clamp = cls._e_display_min + vis_e_max_clamp = cls._e_display_max + e_interval = _nice_interval(max(vis_e_max_clamp - vis_e_min_clamp, 1e-6), 6) + + font_id = 0 + blf.enable(font_id, blf.SHADOW) + blf.shadow(font_id, 5, 0, 0, 0, 1) + + # --- Window header ---------------------------------------------------- + blf.size(font_id, tool.Blender.scale_font_size(13)) + blf.color(font_id, *cls.COLOR_HEADER) + blf.position(font_id, 16, region.height - 28, 0) + blf.draw(font_id, f"Vertical Profile — {cls.alignment_name} VE = {ve:.0f}×") + + # --- Station axis labels (horizontal, along the bottom) -------------- + BOTTOM_MARGIN = 28 # px from bottom for label baseline + ELEV_MARGIN = 72 # px from left where elevation labels end; station zone starts here + + blf.size(font_id, tool.Blender.scale_font_size(10)) + blf.color(font_id, *cls.COLOR_LABEL) + + prev_sx = -9999 + for d in _frange(vis_d_min, vis_d_max, d_interval): + screen = location_3d_to_region_2d(region, rv3d, (d, 0.0, vis_z_min)) + if screen is None: + continue + sx = screen.x + label = cls._dist_to_station_str(d, d_interval) + w, _ = blf.dimensions(font_id, label) + # Skip labels too close to the viewport edge or the previous label + if sx - w * 0.5 < ELEV_MARGIN or sx + w * 0.5 > region.width - 8: + continue + if sx - prev_sx < w + 10: + continue + blf.position(font_id, sx - w * 0.5, BOTTOM_MARGIN, 0) + blf.draw(font_id, label) + prev_sx = sx + + # Axis title "Station (unit)" centred at the bottom + blf.size(font_id, tool.Blender.scale_font_size(10)) + blf.color(font_id, *cls.COLOR_AXIS_TITLE) + title = f"Station ({cls.unit_symbol})" + tw, _ = blf.dimensions(font_id, title) + blf.position(font_id, (region.width - tw) * 0.5, 8, 0) + blf.draw(font_id, title) + + # --- Elevation axis labels (vertical axis, left side) ---------------- + blf.size(font_id, tool.Blender.scale_font_size(10)) + blf.color(font_id, *cls.COLOR_LABEL) + + # When cant is present, stop elevation labels at the top of the cant zone. + elev_label_sy_min = BOTTOM_MARGIN + 14 + if cls.has_cant: + sc_sep = location_3d_to_region_2d(region, rv3d, (vis_d_min, 0.0, cls.cant_zone_top)) + if sc_sep: + elev_label_sy_min = max(BOTTOM_MARGIN + 14, sc_sep.y + 6) + + prev_sy = -9999 + for e in _frange(vis_e_min_clamp, vis_e_max_clamp, e_interval): + screen = location_3d_to_region_2d(region, rv3d, (vis_d_min, 0.0, cls._ez(e))) + if screen is None: + continue + sy = screen.y + if sy < elev_label_sy_min or sy > region.height - 40: + continue + if sy - prev_sy < 14: + continue + label = _fmt_elev(e, e_interval) + w, h = blf.dimensions(font_id, label) + blf.position(font_id, ELEV_MARGIN - w - 4, sy - h * 0.5, 0) + blf.draw(font_id, label) + prev_sy = sy + + # Axis title "Elev (unit)" at the top of the elevation column + blf.size(font_id, tool.Blender.scale_font_size(10)) + blf.color(font_id, *cls.COLOR_AXIS_TITLE) + blf.position(font_id, 4, region.height - 44, 0) + blf.draw(font_id, f"Elev ({cls.unit_symbol})") + + # --- BVC / PVI / EVC callouts and gradient endpoint labels ----------- + # Build per-vertical label-enabled lookup from the vertical_items collection. + # Defaults to True when a vertical isn't in the list (profile not yet open). + v_label_enabled: dict = {} + try: + for item in props.vertical_items: + v_label_enabled[item.entity_id] = item.show_labels + except Exception: + pass + + pt_font_size = tool.Blender.scale_font_size(10) + blf.size(font_id, pt_font_size) + pt_line_h = pt_font_size + 3 + n_segs = len(cls.segments_info) + labeled_stations: set = set() + + def _draw_vp_label(world_pos, stacked_lines, color, draw_cross=False): + screen = location_3d_to_region_2d(region, rv3d, world_pos) + if not screen: + return + sx, sy = screen.x, screen.y + if sx < -80 or sx > region.width + 80 or sy < -20 or sy > region.height + 20: + return + if draw_cross: + r = 7 + shader_x = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR") + shader_x.bind() + shader_x.uniform_float("viewportSize", (region.width, region.height)) + shader_x.uniform_float("lineWidth", 1.8) + shader_x.uniform_float("color", color) + xv = [(sx - r, sy, 0), (sx + r, sy, 0), (sx, sy - r, 0), (sx, sy + r, 0)] + gpu.state.blend_set("ALPHA") + batch_for_shader(shader_x, "LINES", {"pos": xv}, indices=[[0, 1], [2, 3]]).draw(shader_x) + gpu.state.blend_set("NONE") + blf.size(font_id, pt_font_size) + blf.color(font_id, *color) + for j, line in enumerate(reversed(stacked_lines)): + blf.position(font_id, sx + 12, sy + 4 + j * pt_line_h, 0) + blf.draw(font_id, line) + + # Track labeled stations per vertical so duplicate-suppression stays + # within one vertical (different verticals can share the same station). + labeled_by_vertical: dict = {} # vertical_id → set of rounded station keys + + n_segs = len(cls.segments_info) + for i, info in enumerate(cls.segments_info): + if visible_ids is not None and info.get("vertical_id", -1) not in visible_ids: + continue + + v_id = info.get("vertical_id", -1) + if not v_label_enabled.get(v_id, True): + continue + + v_label = info.get("vertical_label", "") + if v_id not in labeled_by_vertical: + labeled_by_vertical[v_id] = set() + labeled = labeled_by_vertical[v_id] + + is_curve = info.get("is_curve", False) + bvc_d, bvc_e = info.get("bvc", (info["dist"], info["height"])) + evc_d, evc_e = info.get("evc", ( + info["dist"] + info["h_len"], + info["height"] + info["g_start"] * info["h_len"], + )) + pvi_data = info.get("pvi") + + bvc_w = (bvc_d, 0.0, cls._ez(bvc_e)) + evc_w = (evc_d, 0.0, cls._ez(evc_e)) + bvc_key = round(bvc_d, 3) + evc_key = round(evc_d, 3) + + sta_bvc = cls._dist_to_station_str(bvc_d) + sta_evc = cls._dist_to_station_str(evc_d) + + # When multiple verticals are visible, prefix point names with the vertical label + pfx = f" [{v_label}]" if show_vertical_prefix and v_label else "" + + if is_curve: + if bvc_key not in labeled: + _draw_vp_label( + bvc_w, + [f"BVC{pfx}", f"Sta {sta_bvc}", f"Elev {_fmt_elev(bvc_e, e_interval)}"], + cls.COLOR_BVC, + ) + labeled.add(bvc_key) + if pvi_data: + pvi_d, pvi_e = pvi_data + pvi_w = (pvi_d, 0.0, cls._ez(pvi_e)) + sta_pvi = cls._dist_to_station_str(pvi_d) + _draw_vp_label( + pvi_w, + [f"PVI{pfx}", f"Sta {sta_pvi}", f"Elev {_fmt_elev(pvi_e, e_interval)}"], + cls.COLOR_PVI_VERT, + draw_cross=True, + ) + if evc_key not in labeled: + _draw_vp_label( + evc_w, + [f"EVC{pfx}", f"Sta {sta_evc}", f"Elev {_fmt_elev(evc_e, e_interval)}"], + cls.COLOR_EVC, + ) + labeled.add(evc_key) + else: + g_pct = info["g_start"] * 100.0 + if bvc_key not in labeled: + lines = [f"Sta {sta_bvc}", f"Elev {_fmt_elev(bvc_e, e_interval)}", f"{g_pct:+.2f}%"] + if show_vertical_prefix and v_label: + lines.append(f"[{v_label}]") + _draw_vp_label(bvc_w, lines, cls.COLOR_GRAD) + labeled.add(bvc_key) + # Always label the end of the last segment for each vertical + is_last_for_vertical = ( + i == n_segs - 1 + or cls.segments_info[i + 1].get("vertical_id", -1) != v_id + ) + if is_last_for_vertical and evc_key not in labeled: + _draw_vp_label( + evc_w, + [f"Sta {sta_evc}", f"Elev {_fmt_elev(evc_e, e_interval)}"], + cls.COLOR_GRAD, + ) + labeled.add(evc_key) + + # ================================================================ + # --- Cant axis labels and callouts --------------------------- + # ================================================================ + if cls.has_cant and cls.cant_polylines: + visible_cant_ids_l: set | None = None + try: + if props.cant_items: + visible_cant_ids_l = {it.entity_id for it in props.cant_items if it.is_visible} + except Exception: + pass + + cant_z_top = cls.cant_zone_top + cant_z_bot = cls.cant_zone_bot + _cz_l = cls._cz2 + + def _fmt_cant(v: float) -> str: + if cls.unit_symbol in ("ft", "'"): + return f'{v * 12:.3f}"' + return f"{v * 1000:.1f} mm" + + # "Cant" header + axis unit — just above the cant graph top border, right-aligned + top_screen = location_3d_to_region_2d(region, rv3d, (vis_d_min, 0.0, cant_z_top)) + if top_screen: + blf.size(font_id, tool.Blender.scale_font_size(11)) + blf.color(font_id, *cls.COLOR_CANT_HDR) + hw, _ = blf.dimensions(font_id, "Cant") + blf.position(font_id, region.width - hw - 8, top_screen.y + 4, 0) + blf.draw(font_id, "Cant") + blf.size(font_id, tool.Blender.scale_font_size(10)) + blf.color(font_id, *cls.COLOR_AXIS_TITLE) + cant_unit = "mm" if cls.unit_symbol not in ("ft", "'") else "in" + uw, _ = blf.dimensions(font_id, cant_unit) + blf.position(font_id, region.width - uw - 8, top_screen.y - 10, 0) + blf.draw(font_id, cant_unit) + + # Cant Y-axis tick values — right side (cant has its own right Y-axis) + c_disp_span_l = max(cls._c_display_max - cls._c_display_min, 1e-10) + cant_interval = _nice_interval(c_disp_span_l, 4) + blf.size(font_id, tool.Blender.scale_font_size(10)) + blf.color(font_id, *cls.COLOR_LABEL) + prev_csy = -9999 + for cv in _frange(cls._c_display_min, cls._c_display_max, cant_interval): + gz = _cz_l(cv) + sc = location_3d_to_region_2d(region, rv3d, (vis_d_min, 0.0, gz)) + if sc is None: + continue + sy = sc.y + if sy < BOTTOM_MARGIN + 8 or sy > region.height - 40: + continue + if sy - prev_csy < 12: + continue + lbl = _fmt_cant(cv) + lw, lh = blf.dimensions(font_id, lbl) + blf.position(font_id, region.width - lw - 4, sy - lh * 0.5, 0) + blf.draw(font_id, lbl) + prev_csy = sy + + # Zero-cant reference label (right side near the zero line) + zero_z = _cz_l(0.0) + zero_s = location_3d_to_region_2d(region, rv3d, (vis_d_min, 0.0, zero_z)) + if zero_s and cant_z_bot < zero_z < cant_z_top: + blf.size(font_id, tool.Blender.scale_font_size(9)) + blf.color(font_id, *cls.COLOR_CANT_ZERO) + blf.position(font_id, region.width - 20, zero_s.y + 2, 0) + blf.draw(font_id, "0") + + # Centreline / left / right legend, under the "Cant" header + if top_screen: + blf.size(font_id, tool.Blender.scale_font_size(9)) + for lbl, col, dy in ( + ("L rail", cls.CANT_COLOR_LEFT, -22), + ("R rail", cls.CANT_COLOR_RIGHT, -32), + ("CL", cls.CANT_COLOR_CENTER, -42), + ): + blf.color(font_id, *col) + lw, _ = blf.dimensions(font_id, lbl) + blf.position(font_id, region.width - lw - 8, top_screen.y + dy, 0) + blf.draw(font_id, lbl) + + # Cant segment start/end callouts — per rail + if getattr(props, "show_cant_segment_labels", True): + blf.size(font_id, tool.Blender.scale_font_size(10)) + last_dist_by_cant: dict = {} + for info in cls.cant_info: + cid = info.get("cant_id", -1) + last_dist_by_cant[cid] = max(last_dist_by_cant.get(cid, info["dist"]), info["dist"]) + labeled_cant: set = set() + for info in cls.cant_info: + cid = info.get("cant_id", -1) + if visible_cant_ids_l is not None and cid not in visible_cant_ids_l: + continue + rail = info.get("rail", "L") + if rail == "C": + continue # numeric callouts only on the railheads + d = info["dist"] + seg_color = cls.CANT_COLOR_RIGHT if rail == "R" else cls.CANT_COLOR_LEFT + + s_key = (round(d, 3), rail) + if s_key not in labeled_cant: + sp = location_3d_to_region_2d(region, rv3d, (d, 0.0, _cz_l(info["start_cant"]))) + if sp: + blf.color(font_id, *seg_color) + blf.position(font_id, sp.x + 6, sp.y + 4, 0) + blf.draw(font_id, _fmt_cant(info["start_cant"])) + labeled_cant.add(s_key) + + if abs(d - last_dist_by_cant.get(cid, d)) < 1e-6: + e_d = d + info["h_len"] + e_key = (round(e_d, 3), rail) + if e_key not in labeled_cant: + ep = location_3d_to_region_2d(region, rv3d, (e_d, 0.0, _cz_l(info["end_cant"]))) + if ep: + blf.color(font_id, *seg_color) + blf.position(font_id, ep.x + 6, ep.y + 4, 0) + blf.draw(font_id, _fmt_cant(info["end_cant"])) + labeled_cant.add(e_key) + + blf.disable(font_id, blf.SHADOW) diff --git a/src/bonsai/bonsai/bim/module/alignment/operator.py b/src/bonsai/bonsai/bim/module/alignment/operator.py index 3f0c7c2119..34fa04d161 100644 --- a/src/bonsai/bonsai/bim/module/alignment/operator.py +++ b/src/bonsai/bonsai/bim/module/alignment/operator.py @@ -1,5 +1,5 @@ # Bonsai - OpenBIM Blender Add-on -# Copyright (C) 2020, 2021 Dion Moult , 2022 Yassine Oualid +# Copyright (C) 2020, 2021 Dion Moult , 2022 Yassine Oualid , 2026 Michael Yoder # # This file is part of Bonsai. # @@ -18,82 +18,2148 @@ # pyright: reportUnnecessaryTypeIgnoreComment=error -import time import bpy -import ifcopenshell.api.alignment -import ifcopenshell.api.spatial -import ifcopenshell.geom -from bpy_extras.io_utils import ImportHelper - +import blf +import time +import bonsai.core.alignment as core import bonsai.tool as tool +import ifcopenshell.api.alignment +import ifcopenshell.util.geolocation +import ifcopenshell.util.unit +from bpy_extras.io_utils import ImportHelper +from bpy.types import Operator, SpaceView3D +from bpy.props import StringProperty, FloatProperty, EnumProperty, IntProperty, BoolProperty +from . import decorator as alignment_decorator +from bonsai.bim.module.model.polyline import PolylineOperator +from bonsai.bim.module.model.decorator import PolylineDecorator +from bonsai.bim.ifc import IfcStore class ImportAlignmentCSV(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): bl_idname = "bim.import_alignment_csv" bl_label = "Import Alignment CSV" - bl_description = " Import alignment from the provided .csv file." + bl_description = ( + "Import alignment(s) from a .csv file — one horizontal row (X,Y,R " + "triples) plus any number of vertical rows (D,Z,L triples)" + ) bl_options = {"REGISTER", "UNDO"} filename_ext = ".csv" filter_glob: bpy.props.StringProperty(default="*.csv", options={"HIDDEN"}) @classmethod def poll(cls, context): - ifc_file = tool.Ifc.get() - if ifc_file is None: - cls.poll_message_set("No IFC file is loaded.") + return poll_ifc4x3(cls, context) + + def _execute(self, context): + start = time.time() + props = context.scene.CivilAlignmentProperties + + alignment = core.import_alignment_csv(tool.Ifc, tool.Alignment, filepath=self.filepath) + + props.active_alignment_name = alignment.Name or "Imported Alignment" + props.active_alignment_id = alignment.id() + + self.report({"INFO"}, "Imported in %s seconds" % (time.time() - start)) + + +def poll_ifc4x3(cls, context): + """Standard poll method for IFC4X3 requirement""" + ifc = tool.Ifc.get() + if ifc is None: + cls.poll_message_set("No IFC file loaded. Open an IFC file via Bonsai.") + return False + if ifc.schema != "IFC4X3": + cls.poll_message_set(f"Schema is {ifc.schema}. Alignments require IFC4X3.") + return False + return True + + +def _resolve_active_alignment(context): + """Return the IfcAlignment for ``props.active_alignment_id``, or None. + + Operators that act on an existing alignment store it as + ``active_alignment_id`` (set on create/visualize) and their ``_execute`` + uses that id — so their ``poll`` must resolve the alignment the same way, + NOT via the active viewport object (which is typically a segment curve + after PI/curve editing). + """ + props = context.scene.CivilAlignmentProperties + if props.active_alignment_id == 0: + return None + ifc_file = tool.Ifc.get() + if ifc_file is None: + return None + try: + alignment = ifc_file.by_id(props.active_alignment_id) + except RuntimeError: + return None + return alignment if alignment.is_a("IfcAlignment") else None + + +def sync_pis_from_ifc(props): + """Sync PI Editor data from IFC alignment. + + This is called on undo/redo to ensure the PI Editor reflects the current + IFC state. It extracts PI data from the alignment's horizontal segments. + + If no active alignment exists or it's invalid, clears the PI Editor. + + Returns: + bool: True if sync was successful, False if alignment was cleared. + """ + ifc = tool.Ifc.get() + if ifc is None: + # No IFC file - clear everything + props.pis.clear() + props.active_pi_index = 0 + rebuild_display_rows(props) + return False + + alignment = tool.Alignment.get_active_alignment() + if not alignment: + # Alignment no longer exists - clear everything + props.pis.clear() + props.active_pi_index = 0 + rebuild_display_rows(props) + return False + + # Alignment exists - extract PI data from IFC segments + h_layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment) + if not h_layout: + # No horizontal layout - rebuild display with current props + rebuild_display_rows(props) + return True + + segments = ifcopenshell.api.alignment.get_layout_segments(h_layout) + if not segments: + # No segments - rebuild display with current props + rebuild_display_rows(props) + return True + + # Extract PIs from segment data + # This reconstructs approximate PIs from the IFC segment geometry + extracted_pis = tool.Alignment.extract_pis_from_segments(segments) + + if not extracted_pis: + # Couldn't extract - keep current props.pis + rebuild_display_rows(props) + return True + + # Update props.pis with extracted data + props.pis.clear() + # pi.radius is a Blender LENGTH property (metres); the extracted radius is in + # project units, so scale it so the table displays the correct value. + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + for pi_data in extracted_pis: + pi = props.pis.add() + pi.e = str(pi_data["e"]) + pi.n = str(pi_data["n"]) + pi.pi_type = pi_data["pi_type"] + pi.radius = pi_data.get("radius", 0.0) * unit_scale + + props.active_pi_index = 0 + + # Recalculate geometry and rebuild display + recalculate_pi_geometry(props) + return True + + +def on_radius_changed(pi, context): + """Callback when PI radius is changed. Triggers geometry recalculation. + + This is called from the AlignmentPI.radius property's update callback. + When a radius is entered on a Mid point, this triggers: + 1. Recalculation of PI geometry (lengths, stations) + 2. Rebuild of display_rows (Mid point becomes Curve segment) + 3. If an active alignment exists, regeneration of IFC entities + """ + props = context.scene.CivilAlignmentProperties + recalculate_pi_geometry(props) + + # If there's an active alignment, trigger IFC regeneration + # This is handled by recalculate_pi_geometry when active_alignment_id is set + + +def recalculate_pi_geometry(props): + """Recalculate lengths and stations for all PIs using tool layer.""" + pis = props.pis + if len(pis) < 2: + rebuild_display_rows(props) + return + + # Extract PI coordinates for calculation + pi_coords = [(float(pi.e), float(pi.n)) for pi in pis] + + # Use tool layer for calculation (math belongs in tool, not core) + result = tool.Alignment.calculate_pi_geometry(pi_coords, props.start_station) + + # Update Blender properties with results + tool.Alignment.update_pi_properties(props, result) + + # Rebuild the display rows for the interleaved table view + rebuild_display_rows(props) + + +def rebuild_display_rows(props): + """Rebuild the display_rows collection from the pis collection. + + Creates an interleaved view of points and segments in Civil 3D style: + End point (POB) + Tangent segment 1 + Mid point (or Curve segment if radius > 0) + Tangent segment 2 + End point (POE) + + When a Mid point has a curve (radius > 0), it becomes a Curve segment row + instead of a point row, showing PI coordinates + arc length + radius. + """ + props.display_rows.clear() + + pis = props.pis + if len(pis) == 0: + return + + segment_num = 0 + i = 0 + + # Pre-compute coordinate tuples for tool method calls + pi_coords = [(float(pi.e), float(pi.n)) for pi in pis] + + while i < len(pis): + pi = pis[i] + is_interior = i > 0 and i < len(pis) - 1 + has_curve = is_interior and pi.radius > 0 + + if has_curve: + # Interior PI with curve: becomes a CURVE SEGMENT row + segment_num += 1 + curve_row = props.display_rows.add() + curve_row.row_type = "SEGMENT" + curve_row.segment_number = segment_num + curve_row.pi_index = i + curve_row.display_type = "Curve" + curve_row.e = pi.e + curve_row.n = pi.n + curve_row.radius = pi.radius + curve_row.arc_length = tool.Alignment.arc_length_at_pi( + pi_coords[i - 1], pi_coords[i], pi_coords[i + 1], pi.radius + ) + else: + # Regular point row (End or Mid without curve) + point_row = props.display_rows.add() + point_row.row_type = "POINT" + point_row.pi_index = i + + if pi.pi_type == "ENDPOINT": + point_row.display_type = "End" + else: + point_row.display_type = "Mid" + + point_row.e = pi.e + point_row.n = pi.n + + # Add tangent segment row after this point/curve (except after last PI) + if i < len(pis) - 1: + segment_num += 1 + seg_row = props.display_rows.add() + seg_row.row_type = "SEGMENT" + seg_row.segment_number = segment_num + seg_row.pi_index = i + seg_row.display_type = "Tan" + + # Compute tangent lengths at each end to subtract from full distance + start_t = 0.0 + end_t = 0.0 + if has_curve: + start_t = tool.Alignment.tangent_length_at_pi( + pi_coords[i - 1], pi_coords[i], pi_coords[i + 1], pi.radius + ) + next_pi = pis[i + 1] + next_is_interior = (i + 1 > 0) and (i + 1 < len(pis) - 1) + next_has_curve = next_is_interior and next_pi.radius > 0 + if next_has_curve: + end_t = tool.Alignment.tangent_length_at_pi( + pi_coords[i], pi_coords[i + 1], pi_coords[i + 2], next_pi.radius + ) + + seg_row.length = tool.Alignment.tangent_segment_length( + pi_coords[i], pi_coords[i + 1], start_t, end_t + ) + + i += 1 + + +# ============================================================================= +# PI Management Operators +# ============================================================================= + + +class ALIGN_OT_add_pi(Operator): + """Add a new PI point to the list""" + + bl_idname = "align.add_pi" + bl_label = "Add PI" + bl_description = "Add a new PI (Point of Intersection) to the alignment" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + return poll_ifc4x3(cls, context) + + def execute(self, context): + props = context.scene.CivilAlignmentProperties + + # Add new PI + pi = props.pis.add() + + # Set default position based on existing PIs + if len(props.pis) == 1: + # First PI - start at origin + pi.e = str(0.0) + pi.n = str(0.0) + pi.pi_type = "ENDPOINT" + elif len(props.pis) == 2: + # Second PI - offset from first + prev = props.pis[0] + pi.e = str(float(prev.e) + 100.0) + pi.n = prev.n + pi.pi_type = "ENDPOINT" + else: + # Additional PIs - extrapolate from last two + prev = props.pis[-2] + prev_prev = props.pis[-3] if len(props.pis) > 2 else prev + de = float(prev.e) - float(prev_prev.e) if len(props.pis) > 2 else 100.0 + dn = float(prev.n) - float(prev_prev.n) if len(props.pis) > 2 else 0.0 + pi.e = str(float(prev.e) + de) + pi.n = str(float(prev.n) + dn) + pi.pi_type = "TANGENT" + + # Previous endpoint becomes tangent or curve + props.pis[-2].pi_type = "TANGENT" + + # Make new PI active + props.active_pi_index = len(props.pis) - 1 + + # Recalculate geometry + recalculate_pi_geometry(props) + + return {"FINISHED"} + + +class ALIGN_OT_remove_pi(Operator): + """Remove the selected PI point""" + + bl_idname = "align.remove_pi" + bl_label = "Remove PI" + bl_description = "Remove the selected PI from the alignment" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + if not poll_ifc4x3(cls, context): return False - elif ifc_file.schema != "IFC4X3": - cls.poll_message_set("Schema must be IFC4x3.") + props = context.scene.CivilAlignmentProperties + if len(props.pis) == 0: + cls.poll_message_set("No PIs to remove") + return False + # Check if a POINT row is selected (can't remove from SEGMENT row selection) + if props.display_rows: + idx = props.active_display_row_index + if 0 <= idx < len(props.display_rows): + if props.display_rows[idx].row_type != "POINT": + cls.poll_message_set("Select a point row to remove") + return False + return True + + def execute(self, context): + props = context.scene.CivilAlignmentProperties + + # Get the PI index from the selected display row + pi_index = -1 + if props.display_rows: + idx = props.active_display_row_index + if 0 <= idx < len(props.display_rows): + row = props.display_rows[idx] + if row.row_type == "POINT": + pi_index = row.pi_index + + # Fallback to active_pi_index if display_rows isn't being used + if pi_index < 0: + pi_index = props.active_pi_index + + if 0 <= pi_index < len(props.pis): + props.pis.remove(pi_index) + props.active_pi_index = min(pi_index, len(props.pis) - 1) + + # Recalculate geometry (also rebuilds display_rows) + recalculate_pi_geometry(props) + + # Reset display row index to first row if needed + if len(props.display_rows) > 0: + props.active_display_row_index = min(props.active_display_row_index, len(props.display_rows) - 1) + else: + props.active_display_row_index = 0 + + return {"FINISHED"} + + +def _insert_polyline_point_no_close(op, context, event): + """Insert polyline points without close-polyline (C key) behavior. + + Alignments are open curves, so the C key (close polyline) is suppressed. + All other insertion behavior is preserved: LEFTMOUSE, BACKSPACE, and + RET/ENTER with numeric input active. + + Shared by every PolylineOperator-based alignment picker/drawer — ``op`` is + the calling operator instance (must provide the PolylineOperator mixin's + ``tool_state``/``input_ui``/``snapping_points``/``recalculate_inputs``). + """ + # LEFTMOUSE: insert point at current snap/cursor position + if not op.tool_state.is_input_on and event.value == "RELEASE" and event.type == "LEFTMOUSE": + result = tool.Polyline.insert_polyline_point(op.input_ui, op.tool_state) + if result: + op.report({"WARNING"}, result) + tool.Blender.update_viewport() + + # RET/ENTER with numeric input: validate and insert + if ( + op.tool_state.is_input_on + and event.value == "RELEASE" + and event.type in {"RET", "NUMPAD_ENTER", "RIGHTMOUSE"} + ): + is_valid = op.recalculate_inputs(context) + if is_valid: + result = tool.Polyline.insert_polyline_point(op.input_ui, op.tool_state) + if result: + op.report({"WARNING"}, result) + + op.tool_state.mode = "Mouse" + op.tool_state.is_input_on = False + op.input_type = None + op.tool_state.input_type = None + op.number_input = [] + op.number_output = "" + PolylineDecorator.update(event, op.tool_state, op.input_ui, op.snapping_points[0]) + tool.Blender.update_viewport() + + # BACKSPACE: remove last point (when not typing numeric input) + if not op.tool_state.is_input_on: + if event.value == "RELEASE" and event.type == "BACK_SPACE": + tool.Polyline.remove_last_polyline_point() + tool.Blender.update_viewport() + + +def _bearing_string(azimuth_from_east_ccw_deg: float) -> str: + """Convert a math-convention azimuth to a civil-engineering quadrant bearing. + + ``azimuth_from_east_ccw_deg`` is the signed angle in degrees, measured + counter-clockwise from world +X (East) — exactly what + ``Polyline.PolylineUI``'s ``WORLD_ANGLE`` value holds while drawing. + + Returns a quadrant bearing string such as ``"N 30°15'24.00\" E"``, or + ``"Due "`` at the cardinal directions. + """ + # Azimuth measured from North, clockwise, normalized to [0, 360). + azimuth = (90.0 - azimuth_from_east_ccw_deg) % 360.0 + + if azimuth <= 90.0: + ns, angle, ew = "N", azimuth, "E" + elif azimuth <= 180.0: + ns, angle, ew = "S", 180.0 - azimuth, "E" + elif azimuth <= 270.0: + ns, angle, ew = "S", azimuth - 180.0, "W" + else: + ns, angle, ew = "N", 360.0 - azimuth, "W" + + if angle < 1e-6: + return f"Due {ns}" + if abs(angle - 90.0) < 1e-6: + return f"Due {ew}" + + d = int(angle) + m_full = (angle - d) * 60.0 + m = int(m_full) + s = (m_full - m) * 60.0 + if round(s, 2) >= 60.0: + s = 0.0 + m += 1 + if m >= 60: + m = 0 + d += 1 + + return f"{ns} {d}°{m:02d}'{s:05.2f}\" {ew}" + + +class ALIGN_OT_pick_pi_from_viewport(bpy.types.Operator, PolylineOperator, tool.Ifc.Operator): + """Add PI points by clicking in the 3D viewport using polyline tools""" + + bl_idname = "align.pick_pi_from_viewport" + bl_label = "Pick PI from Viewport" + bl_description = "Click in the viewport to add PI points with snapping and numeric input. RMB/Enter to finish, ESC to cancel." + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + return poll_ifc4x3(cls, context) + + def __init__(self, *args, **kwargs): + bpy.types.Operator.__init__(self, *args, **kwargs) + PolylineOperator.__init__(self) + # Remove instructions that don't apply to alignments + self.instructions.pop("Close Polyline", None) + self.instructions.pop("Offset", None) + + def invoke(self, context, event): + return IfcStore.execute_ifc_operator(self, context, event, method="INVOKE") + + def _invoke(self, context, event): + # Find the 3D viewport — the operator is invoked from the Properties + # panel, so we need to override context for PolylineOperator.invoke() + # which requires bpy.context.space_data to be SpaceView3D. + area_3d = None + region_3d = None + for area in context.screen.areas: + if area.type == "VIEW_3D": + area_3d = area + for region in area.regions: + if region.type == "WINDOW": + region_3d = region + break + break + + if not area_3d or not region_3d: + self.report({"ERROR"}, "No 3D Viewport found") + return {"CANCELLED"} + + with context.temp_override(area=area_3d, region=region_3d): + super().invoke(context, event) + + self.tool_state.use_default_container = False + self.tool_state.plane_method = "XY" + return {"RUNNING_MODAL"} + + def modal(self, context, event): + return IfcStore.execute_ifc_operator(self, context, event, method="MODAL") + + def _modal(self, context, event): + PolylineDecorator.update(event, self.tool_state, self.input_ui, self.snapping_points[0]) + tool.Blender.update_viewport() + + self.handle_lock_axis(context, event) + + if event.type in {"MIDDLEMOUSE", "WHEELUPMOUSE", "WHEELDOWNMOUSE"}: + self.handle_mouse_move(context, event) + return {"PASS_THROUGH"} + + self.handle_instructions(context) + self.handle_mouse_move(context, event, should_round=True) + self.choose_axis(event) + self.handle_snap_selection(context, event) + self.handle_keyboard_input(context, event) + _insert_polyline_point_no_close(self, context, event) + + # Finish: transfer polyline points to PI table + if ( + not self.tool_state.is_input_on + and event.value == "RELEASE" + and event.type in {"RET", "NUMPAD_ENTER", "RIGHTMOUSE"} + ): + self._transfer_polyline_to_pis(context) + context.workspace.status_text_set(text=None) + PolylineDecorator.uninstall() + tool.Polyline.clear_polyline() + # Auto-visualize: build the IFC segments as soon as picking finishes, + # so the user no longer needs a separate "Visualize" click. + ok, message = _build_alignment_from_active_pis(context) + if not ok: + self.report({"WARNING"}, message) + tool.Blender.update_viewport() + return {"FINISHED"} + + cancel = self.handle_cancelation(context, event) + if cancel is not None: + return cancel + + return {"RUNNING_MODAL"} + + def _transfer_polyline_to_pis(self, context): + """Transfer collected polyline points to the PI Editor table. + + Polyline points are in Blender coordinate space. This method converts + each point to IFC coordinate space before storing in props.pis. + """ + props = context.scene.CivilAlignmentProperties + polyline_props = tool.Model.get_polyline_props() + polyline_data = polyline_props.insertion_polyline + if not polyline_data: + return + + polyline_points = polyline_data[0].polyline_points + if not polyline_points: + return + + # Blender world space is metres (1 BU = 1 m); the georeference helpers + # work in IFC project length units. Convert before storing so the + # alignment is recreated at the correct scale (e.g. feet projects). + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + + num_points = len(polyline_points) + for i, point in enumerate(polyline_points): + # Blender metres -> IFC project units -> global easting/northing (stored on props.pis). + local = (point.x / unit_scale, point.y / unit_scale, 0.0) + ifc_coord = tool.Georeference.xyz2enh(local) + + pi = props.pis.add() + pi.e = str(ifc_coord[0]) + pi.n = str(ifc_coord[1]) + + # Determine PI type based on position + if i == 0 or i == num_points - 1: + pi.pi_type = "ENDPOINT" + else: + pi.pi_type = "TANGENT" + + props.active_pi_index = len(props.pis) - 1 + recalculate_pi_geometry(props) + rebuild_display_rows(props) + + +def _build_alignment_from_active_pis(context): + """Build/refresh the IFC horizontal segments from props.pis on the active + alignment and visualize them. + + Shared by the Recalculate/Visualize operator and the PI picker (so picking + auto-visualizes on completion). Returns (ok: bool, message: str). + """ + import ifcopenshell.api.alignment as align_api + + ifc = tool.Ifc.get() + props = context.scene.CivilAlignmentProperties + recalculate_pi_geometry(props) + + alignment = tool.Alignment.get_active_alignment() + if not alignment: + total_length = sum(pi.length_to_next for pi in props.pis) + return ( + False, + f"Select an IfcAlignment in the outliner first. " + f"(Recalculated {len(props.pis)} PIs, total length: {total_length:.2f})", + ) + if len(props.pis) < 2: + return False, "Need at least 2 PIs to build the alignment" + + props.active_alignment_id = alignment.id() + + # Bootstrap horizontal layout if the alignment is bare (e.g. from Add Element) + h_layout = align_api.get_horizontal_layout(alignment) + if h_layout is None: + h_layout = tool.Alignment.add_horizontal_layout_to_alignment(alignment) + + # Ensure Blender objects exist for the alignment hierarchy + alignment_obj = tool.Ifc.get_object(alignment) + if not alignment_obj: + alignment_obj = tool.Alignment.create_hierarchy_for_alignment(alignment) + + # Stored PI E/N (IFC project units) -> local IFC coords for the API. + hpoints = [ + [float(o) for o in ifcopenshell.util.geolocation.auto_enh2xyz(ifc, float(pi.e), float(pi.n), 0.0)[:2]] + for pi in props.pis + ] + # pi.radius is a Blender LENGTH property (stored in metres); the API expects + # project units, so convert back via unit_scale — same as the coordinates. + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc) + radii = [pi.radius / unit_scale for pi in props.pis[1:-1]] + + tool.Alignment.remove_layout_segment_objects(h_layout) + tool.Alignment.clear_layout_segments(h_layout) + align_api.layout_horizontal_alignment_by_pi_method(ifc, h_layout, hpoints, radii) + + layout_obj = tool.Ifc.get_object(h_layout) + if not layout_obj: + layout_obj = tool.Alignment.create_object_for_layout(h_layout, alignment_obj) + if layout_obj: + tool.Alignment.create_objects_for_layout_segments(h_layout, layout_obj) + + # Make the alignment the active/selected object so it's immediately + # visible in the outliner/properties pane without a manual click. + if alignment_obj: + for obj in context.selected_objects: + obj.select_set(False) + alignment_obj.select_set(True) + context.view_layer.objects.active = alignment_obj + + tool.Blender.update_viewport() + return True, f"Updated alignment '{alignment.Name}' with {len(hpoints)} PIs" + + +class ALIGN_OT_recalculate_pis(Operator, tool.Ifc.Operator): + """Recalculate PI geometry and update IFC/visualization""" + + bl_idname = "align.recalculate_pis" + bl_label = "Recalculate PIs" + bl_description = "Recalculate geometry, update IFC segments, and refresh visualization" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + if not poll_ifc4x3(cls, context): + return False + props = context.scene.CivilAlignmentProperties + if len(props.pis) < 2: + cls.poll_message_set("Need at least 2 PIs to recalculate") return False return True def _execute(self, context): - self.file = tool.Ifc.get() - start = time.time() - alignment = ifcopenshell.api.alignment.create_from_csv(self.file, self.filepath, start_station=0.0) + ok, message = _build_alignment_from_active_pis(context) + self.report({"INFO"} if ok else {"WARNING"}, message) - # IFC 4.1.5.1 alignments cannot be contained in spatial structures, but can be referenced into them - sites = self.file.by_type("IfcSite") - for site in sites: - ifcopenshell.api.spatial.reference_structure(self.file, products=[alignment], relating_structure=site) - # process the generated IfcReferent for the alignment - for rel in alignment.IsNestedBy: - for referent in rel.RelatedObjects: - if referent.is_a("IfcReferent"): - referent_obj = bpy.data.objects.new(tool.Loader.get_name(referent), None) - tool.Geometry.link(referent, referent_obj) - tool.Collector.assign(referent_obj, should_clean_users_collection=False) +class ALIGN_OT_clear_pis(Operator, tool.Ifc.Operator): + """Delete the active alignment and clear the PI table""" - # an alignment can be an aggregation of multiple child alignments (ie. multiple verticals for a single horizontal) - # get all the alignment curves - curves = [] - for rel in alignment.IsDecomposedBy: - for agg in rel.RelatedObjects: - if agg.is_a("IfcAlignment"): - curves.append(ifcopenshell.api.alignment.get_curve(agg)) # 3D curve + bl_idname = "align.clear_pis" + bl_label = "Clear All PIs" + bl_description = ( + "Delete the entire active alignment — its IFC entity, all nested " + "layouts and segments, and its viewport objects — and clear the PI table" + ) + bl_options = {"REGISTER", "UNDO"} - # if there aren't any curves from aggregation, then there is only a single vertical or no vertical - if len(curves) == 0: - curves.append(ifcopenshell.api.alignment.get_curve(alignment)) + @classmethod + def poll(cls, context): + if not poll_ifc4x3(cls, context): + return False + props = context.scene.CivilAlignmentProperties + if len(props.pis) == 0: + cls.poll_message_set("No PIs to clear") + return False + return True - settings = ifcopenshell.geom.settings() - for curve in curves: - shape = ifcopenshell.geom.create_shape(settings, curve) + def invoke(self, context, event): + return context.window_manager.invoke_confirm(self, event) - # create a new Blender mesh - mesh_name = tool.Loader.get_mesh_name_from_shape(shape) - mesh = bpy.data.meshes.new(mesh_name) - m = tool.Loader.convert_geometry_to_mesh(shape, mesh) + def _execute(self, context): + ifc = tool.Ifc.get() + props = context.scene.CivilAlignmentProperties - # create a new Blender object - alignment_obj = bpy.data.objects.new(tool.Loader.get_name(alignment), m) + removed_objects = 0 - # link the blender object to with the alignment element - tool.Geometry.link(alignment, alignment_obj) + # Delete the active alignment entirely (Blender + IFC) so the file is + # never left with an orphaned, PI-less alignment. Resolved through + # props.active_alignment_id — the same reference every other panel + # operator uses — not the viewport's active object. + if alignment := _resolve_active_alignment(context): + removed_objects = tool.Alignment.remove_alignment_hierarchy(alignment) + ifcopenshell.api.run("root.remove_product", ifc, product=alignment) + props.active_alignment_id = 0 + props.active_alignment_name = "" - # assign the object to the blender collections - tool.Collector.assign(alignment_obj, should_clean_users_collection=False) + # Clear the PI list in the UI + props.pis.clear() + props.active_pi_index = 0 - self.report({"INFO"}, "Imported in %s seconds" % (time.time() - start)) + # Clear the display rows + props.display_rows.clear() + props.active_display_row_index = 0 + + if removed_objects > 0: + self.report({"INFO"}, f"Deleted alignment and removed {removed_objects} objects") + else: + self.report({"INFO"}, "Cleared all PIs") + + +# ============================================================================= +# Creation Operators +# ============================================================================= + + +class ALIGN_OT_create_alignment_by_pis(Operator, tool.Ifc.Operator): + """Create a new alignment and immediately start picking PI points""" + + bl_idname = "align.create_alignment_by_pis" + bl_label = "New Alignment (PI Method)" + bl_description = "Create a new alignment and pick PI points from the viewport" + bl_options = {"REGISTER", "UNDO"} + + alignment_name: StringProperty(name="Name", default="Alignment") + + @classmethod + def poll(cls, context): + return poll_ifc4x3(cls, context) + + def invoke(self, context, event): + return context.window_manager.invoke_props_dialog(self) + + def _execute(self, context): + props = context.scene.CivilAlignmentProperties + + # Create full alignment via core → tool → API + try: + alignment = core.create_alignment( + tool.Ifc, tool.Alignment, self.alignment_name + ) + except ValueError as e: + self.report({"ERROR"}, str(e)) + return {"CANCELLED"} + + props.active_alignment_id = alignment.id() + props.active_alignment_name = alignment.Name or self.alignment_name + + # Clear any existing PIs from previous work + props.pis.clear() + props.active_pi_index = 0 + props.display_rows.clear() + props.active_display_row_index = 0 + + self.report({"INFO"}, f"Created alignment '{alignment.Name}' — pick PI points now") + + # Chain into PI picker (runs as separate modal with its own undo) + bpy.ops.align.pick_pi_from_viewport("INVOKE_DEFAULT") + + return {"FINISHED"} + + +class ALIGN_OT_create_alignment_by_pi(Operator, tool.Ifc.Operator): + """Create alignment using the PI (Point of Intersection) method""" + + bl_idname = "align.create_alignment_by_pi" + bl_label = "Create by PI Method" + bl_description = "Create alignment using PI points and curve radii. If an active alignment exists with no segments, adds to it instead of creating new." + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + if not poll_ifc4x3(cls, context): + return False + props = context.scene.CivilAlignmentProperties + if len(props.pis) < 2: + cls.poll_message_set("Need at least 2 PI points") + return False + if not tool.Alignment.get_active_alignment(): + cls.poll_message_set("Select an alignment to edit") + return False + return True + + def _execute(self, context): + props = context.scene.CivilAlignmentProperties + + # Convert global E/N coords (stored in props.pis) -> local IFC coords for the IfcOpenShell API + hpoints = [ + [ + float(o) + for o in ifcopenshell.util.geolocation.auto_enh2xyz(tool.Ifc.get(), float(pi.e), float(pi.n), 0.0)[:2] + ] + for pi in props.pis + ] + radii = [pi.radius for pi in props.pis[1:-1]] + + existing_alignment = tool.Alignment.get_active_alignment() + if not (h_layout := ifcopenshell.api.alignment.get_horizontal_layout(existing_alignment)): + return + # Check if horizontal layout is empty (only has zero-length terminal or no segments) + segments = ifcopenshell.api.alignment.get_layout_segments(h_layout) + has_real_segments = bool([s for s in segments if not tool.Alignment.is_zero_length_segment(s)]) + + ifcopenshell.api.alignment.create_representation(tool.Ifc.get(), existing_alignment) + + if not has_real_segments: + # Use existing alignment - add segments to it + # Use safe wrapper to validate layout has parent alignment + tool.Alignment.safe_layout_horizontal_by_pi_method(tool.Ifc.get(), h_layout, hpoints, radii) + + # Create/update Blender objects for the segments + alignment_obj = tool.Ifc.get_object(existing_alignment) + h_layout_obj = tool.Ifc.get_object(h_layout) + + if not h_layout_obj and alignment_obj: + h_layout_obj = tool.Alignment.create_object_for_layout(h_layout, alignment_obj) + + if h_layout_obj: + tool.Alignment.create_objects_for_layout_segments(h_layout, h_layout_obj) + + self.report( + {"INFO"}, f"Added {len(hpoints)} PIs to existing alignment '{existing_alignment.Name}'" + ) + + +# CSV import lives on the single upstream operator id `bim.import_alignment_csv` +# (class ImportAlignmentCSV above) — it now routes through +# core.import_alignment_csv, which builds the Saikei viewport hierarchy for the +# parent and any aggregated child alignments. + + +# ============================================================================= +# Alignments tab — new authoring workflow (Add Element + interactive drawing). +# +# This is a from-scratch replacement for the CIVIL tab's PI-table workflow +# above: no persistent PI table, no CivilAlignmentProperties dependency. An +# alignment is added as a bare IfcAlignment, then its horizontal geometry is +# drawn directly in the viewport and committed to IFC on completion. +# ============================================================================= + + +class ALIGN_OT_add_alignment(Operator, tool.Ifc.Operator): + """Add a new, empty IfcAlignment to the project""" + + bl_idname = "align.add_alignment" + bl_label = "Add Alignment" + bl_description = "Add a new alignment to the project. Draw its horizontal geometry next." + bl_options = {"REGISTER", "UNDO"} + + alignment_name: StringProperty(name="Name", default="Alignment") + start_station: FloatProperty( + name="Start Station", + description="Station value at the start of the alignment (distance along 0)", + default=0.0, + ) + + @classmethod + def poll(cls, context): + return poll_ifc4x3(cls, context) + + def invoke(self, context, event): + return context.window_manager.invoke_props_dialog(self) + + def _execute(self, context): + try: + alignment = core.create_alignment(tool.Ifc, tool.Alignment, self.alignment_name, self.start_station) + except ValueError as e: + self.report({"ERROR"}, str(e)) + return {"CANCELLED"} + + # A viewport object for the start-station referent create_alignment() + # added — matches what loading a file gives you; interactive creation + # used to leave the referent with no object at all. + start_referent = tool.Alignment.find_stationing_referent_at(alignment, 0.0) + if start_referent: + tool.Alignment.create_object_for_referent(start_referent) + + # Make the new alignment the active/selected object so it is + # immediately picked up by tool.Alignment.get_active_alignment() — + # no separate outliner click needed before drawing its geometry. + alignment_obj = tool.Ifc.get_object(alignment) + if alignment_obj: + for obj in context.selected_objects: + obj.select_set(False) + alignment_obj.select_set(True) + context.view_layer.objects.active = alignment_obj + + self.report({"INFO"}, f"Added alignment '{alignment.Name}' — draw its horizontal alignment next") + return {"FINISHED"} + + +class ALIGN_OT_remove_alignment(Operator, tool.Ifc.Operator): + """Delete the active alignment — its IFC entity, nested layouts/segments, and viewport objects""" + + bl_idname = "align.remove_alignment" + bl_label = "Delete Alignment" + bl_description = ( + "Delete the active alignment: its IFC entity, all nested layouts and " + "segments, and its viewport objects. Cannot be undone via Blender's undo." + ) + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + if not poll_ifc4x3(cls, context): + return False + if not tool.Alignment.get_active_alignment(): + cls.poll_message_set("Select an alignment first") + return False + return True + + def invoke(self, context, event): + return context.window_manager.invoke_confirm(self, event) + + def _execute(self, context): + ifc = tool.Ifc.get() + alignment = tool.Alignment.get_active_alignment() + if not alignment: + return {"CANCELLED"} + + name = alignment.Name or "Alignment" + for marker in _find_pi_markers(alignment.id()): + bpy.data.objects.remove(marker, do_unlink=True) + removed_objects = tool.Alignment.remove_alignment_hierarchy(alignment) + ifcopenshell.api.run("root.remove_product", ifc, product=alignment) + + self.report({"INFO"}, f"Deleted alignment '{name}' and removed {removed_objects} objects") + return {"FINISHED"} + + +class ALIGN_OT_set_start_station(Operator, tool.Ifc.Operator): + """Change the active alignment's start station (distance along 0)""" + + bl_idname = "align.set_start_station" + bl_label = "Set Start Station" + bl_description = "Change the alignment's start station" + bl_options = {"REGISTER", "UNDO"} + + station: FloatProperty(name="Start Station", default=0.0) + + @classmethod + def poll(cls, context): + if not poll_ifc4x3(cls, context): + return False + if not tool.Alignment.get_active_alignment(): + cls.poll_message_set("Select an alignment first") + return False + return True + + def invoke(self, context, event): + alignment = tool.Alignment.get_active_alignment() + self.station = ifcopenshell.api.alignment.get_alignment_start_station(tool.Ifc.get(), alignment) or 0.0 + return context.window_manager.invoke_props_dialog(self) + + def _execute(self, context): + alignment = tool.Alignment.get_active_alignment() + start_referent = tool.Alignment.find_stationing_referent_at(alignment, 0.0) + if start_referent is None: + # No stationing at all yet (e.g. an alignment from before this + # feature existed) -- add the start referent rather than error. + start_referent = ifcopenshell.api.alignment.add_stationing_referent( + tool.Ifc.get(), tool.Alignment.format_station(self.station), alignment, 0.0, self.station + ) + else: + tool.Alignment.set_stationing_referent_station(start_referent, self.station) + tool.Alignment.create_object_for_referent(start_referent) + alignment_decorator.AlignmentSegmentDecorator.refresh() + self.report({"INFO"}, f"Start station set to {tool.Alignment.format_station(self.station)}") + return {"FINISHED"} + + +def _on_station_equation_station_update(self, context): + """Keep Incoming Station following Outgoing Station until the user + diverges it manually — that's what makes it "optional": leave it alone + and there's no gap/overlap, type a different value and there is. No + separate checkbox needed. + """ + if self.incoming_station == self.station_snapshot: + self.incoming_station = self.station + self.station_snapshot = self.station + + +class _StationEquationFields: + """Shared distance-along/station/incoming-station/direction fields for + ALIGN_OT_add_station_equation and ALIGN_OT_edit_station_equation. + """ + + distance_along: FloatProperty( + name="Distance Along", description="Distance along the alignment where the equation applies", default=0.0 + ) + station: FloatProperty( + name="Outgoing Station", + description="The station value immediately after this point", + default=0.0, + update=_on_station_equation_station_update, + ) + incoming_station: FloatProperty( + name="Incoming Station", + description="The station value immediately before this point. Leave equal to Outgoing " + "Station (the default) for no gap/overlap", + default=0.0, + ) + reverse_direction: BoolProperty( + name="Reverse Stationing Direction", + description="Stations decrease with distance along from this point on, instead of increasing", + default=False, + ) + # Bookkeeping only, for _on_station_equation_station_update — not shown, not saved. + station_snapshot: FloatProperty(options={"HIDDEN", "SKIP_SAVE"}, default=0.0) + + def draw(self, context): + layout = self.layout + layout.prop(self, "distance_along") + layout.prop(self, "incoming_station") + layout.prop(self, "station") + layout.prop(self, "reverse_direction") + + @property + def _has_gap_or_overlap(self) -> bool: + return self.incoming_station != self.station + + +class ALIGN_OT_add_station_equation(Operator, tool.Ifc.Operator, _StationEquationFields): + """Add a station equation (an additional stationing referent) to the active alignment""" + + bl_idname = "align.add_station_equation" + bl_label = "Add Station Equation" + bl_description = ( + "Add a station equation at a distance along the alignment — a gap or " + "overlap in the station numbering, or a switch to decreasing stations" + ) + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + if not poll_ifc4x3(cls, context): + return False + if not tool.Alignment.get_active_alignment(): + cls.poll_message_set("Select an alignment first") + return False + return True + + def invoke(self, context, event): + self.incoming_station = self.station + self.station_snapshot = self.station + return context.window_manager.invoke_props_dialog(self) + + def _execute(self, context): + alignment = tool.Alignment.get_active_alignment() + name = tool.Alignment.format_station(self.station) + referent = ifcopenshell.api.alignment.add_stationing_referent( + tool.Ifc.get(), + name, + alignment, + self.distance_along, + self.station, + incoming_station=self.incoming_station if self._has_gap_or_overlap else None, + has_increasing_station=False if self.reverse_direction else None, + ) + tool.Alignment.create_object_for_referent(referent) + alignment_decorator.AlignmentSegmentDecorator.refresh() + self.report({"INFO"}, f"Added station equation '{name}' at distance {self.distance_along}") + return {"FINISHED"} + + +class ALIGN_OT_edit_station_equation(Operator, tool.Ifc.Operator, _StationEquationFields): + """Edit an existing station equation's distance along, station, and gap/overlap. + + A referent's distance along is baked into its placement, which + add_stationing_referent alone can't move — so this removes and re-adds + the referent with the new values rather than mutating it in place. The + IFC id changes; the panel resolves the row it's editing by re-reading + the alignment's stationing nest on redraw, not by holding onto the id. + """ + + bl_idname = "align.edit_station_equation" + bl_label = "Edit Station Equation" + bl_options = {"REGISTER", "UNDO"} + + referent_id: IntProperty(options={"HIDDEN"}) + + @classmethod + def poll(cls, context): + return poll_ifc4x3(cls, context) + + def invoke(self, context, event): + import ifcopenshell.util.element + from ifcopenshell.api.alignment._referent_distance_along import _referent_distance_along + + try: + referent = tool.Ifc.get().by_id(self.referent_id) + except RuntimeError: + self.report({"ERROR"}, "Referent no longer exists") + return {"CANCELLED"} + + self.distance_along = _referent_distance_along(referent) + self.station = ifcopenshell.util.element.get_pset(referent, name="Pset_Stationing", prop="Station") or 0.0 + incoming = ifcopenshell.util.element.get_pset(referent, name="Pset_Stationing", prop="IncomingStation") + self.incoming_station = incoming if incoming is not None else self.station + self.reverse_direction = ( + ifcopenshell.util.element.get_pset(referent, name="Pset_Stationing", prop="HasIncreasingStation") is False + ) + self.station_snapshot = self.station + return context.window_manager.invoke_props_dialog(self) + + def _execute(self, context): + ifc = tool.Ifc.get() + try: + referent = ifc.by_id(self.referent_id) + except RuntimeError: + self.report({"ERROR"}, "Referent no longer exists") + return {"CANCELLED"} + + alignment = None + for rel in getattr(referent, "Nests", []) or []: + if rel.RelatingObject.is_a("IfcAlignment"): + alignment = rel.RelatingObject + break + if alignment is None: + self.report({"ERROR"}, "Could not find the referent's alignment") + return {"CANCELLED"} + + if obj := tool.Ifc.get_object(referent): + bpy.data.objects.remove(obj, do_unlink=True) + ifcopenshell.api.run("root.remove_product", ifc, product=referent) + + name = tool.Alignment.format_station(self.station) + new_referent = ifcopenshell.api.alignment.add_stationing_referent( + ifc, + name, + alignment, + self.distance_along, + self.station, + incoming_station=self.incoming_station if self._has_gap_or_overlap else None, + has_increasing_station=False if self.reverse_direction else None, + ) + tool.Alignment.create_object_for_referent(new_referent) + alignment_decorator.AlignmentSegmentDecorator.refresh() + self.report({"INFO"}, f"Updated station equation to '{name}'") + return {"FINISHED"} + + +class ALIGN_OT_remove_station_equation(Operator, tool.Ifc.Operator): + """Remove one stationing referent (station equation) from the active alignment""" + + bl_idname = "align.remove_station_equation" + bl_label = "Remove Station Equation" + bl_description = "Remove this stationing referent" + bl_options = {"REGISTER", "UNDO"} + + referent_id: IntProperty(options={"HIDDEN"}) + + @classmethod + def poll(cls, context): + return poll_ifc4x3(cls, context) + + def invoke(self, context, event): + return context.window_manager.invoke_confirm(self, event) + + def _execute(self, context): + ifc = tool.Ifc.get() + try: + referent = ifc.by_id(self.referent_id) + except RuntimeError: + return {"CANCELLED"} + if not referent.is_a("IfcReferent"): + return {"CANCELLED"} + + name = referent.Name or "referent" + if obj := tool.Ifc.get_object(referent): + bpy.data.objects.remove(obj, do_unlink=True) + ifcopenshell.api.run("root.remove_product", ifc, product=referent) + + alignment_decorator.AlignmentSegmentDecorator.refresh() + self.report({"INFO"}, f"Removed station equation '{name}'") + return {"FINISHED"} + + +def _world_point_to_local_ifc(ifc, unit_scale, point_xyz): + """Convert one Blender-world (metres) point to local IFC coordinates. + + Blender world -> IFC project units -> global E/N -> local IFC coords, the + same round trip _transfer_polyline_to_pis() uses, so georeferenced + projects (true-north rotation, false origin) place the alignment + correctly. + """ + local = (point_xyz[0] / unit_scale, point_xyz[1] / unit_scale, 0.0) + e, n = tool.Georeference.xyz2enh(local)[:2] + return [float(o) for o in ifcopenshell.util.geolocation.auto_enh2xyz(ifc, e, n, 0.0)[:2]] + + +def _hpoints_from_polyline(context): + """Read the drawn polyline and convert it to local IFC coords. + + Returns (True, (raw_points, hpoints)) on success, (False, message) + otherwise. ``raw_points`` are the original (x, y, z) points in Blender + world space (metres) — kept around so a caller can place viewport marker + objects at the exact drawn positions without inverting the coordinate + conversion below. ``hpoints`` are [x, y] pairs in local IFC coordinates, + including the start and end points — exactly what + layout_horizontal_alignment_by_pi_method expects. + """ + ifc = tool.Ifc.get() + polyline_props = tool.Model.get_polyline_props() + polyline_data = polyline_props.insertion_polyline + if not polyline_data or not polyline_data[0].polyline_points: + return False, "No points were drawn" + + polyline_points = polyline_data[0].polyline_points + if len(polyline_points) < 2: + return False, "Need at least 2 points to draw an alignment" + + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc) + raw_points = [(p.x, p.y, p.z) for p in polyline_points] + hpoints = [_world_point_to_local_ifc(ifc, unit_scale, p) for p in raw_points] + return True, (raw_points, hpoints) + + +def _generate_alignment_segments(context, alignment, hpoints, radii): + """Build horizontal alignment segments from PI points and per-PI radii. + + Mirrors _build_alignment_from_active_pis()'s IFC-side logic. ``hpoints`` + are local IFC coords (see _hpoints_from_polyline); ``radii`` has exactly + len(hpoints) - 2 entries, one per interior PI (0.0 = sharp, no curve). + + ``alignment`` is passed explicitly rather than resolved from the active + object — a PI marker empty (see _create_pi_markers) is typically the + active object when this runs from ALIGN_OT_apply_pi_curve, and markers + aren't IFC-linked, so tool.Alignment.get_active_alignment() can't find + the alignment from them. + + Uses layout_horizontal_alignment_by_pi_method for now (circular curves + only). Once spiral segments are needed this has to become genuinely + one-segment-at-a-time authoring via create_layout_segment(), since that + API only ever emits LINE/CIRCULARARC — see REQUIREMENTS.md §2 step 7. + """ + ifc = tool.Ifc.get() + + h_layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment) + if h_layout is None: + h_layout = tool.Alignment.add_horizontal_layout_to_alignment(alignment) + + if not tool.Ifc.get_object(alignment): + tool.Alignment.create_object_for_alignment(alignment) + + # Drop any layout/segment objects a previous draw on this alignment left + # behind (e.g. from before this single-mesh approach existed) so the + # scene collection converges on exactly one object for the alignment — + # what loading it from a file would give you — rather than accumulating + # the CIVIL tab's per-layout/per-segment objects alongside it. + tool.Alignment.remove_layout_and_child_layout_objects(alignment) + + tool.Alignment.clear_layout_segments(h_layout) + tool.Alignment.safe_layout_horizontal_by_pi_method(ifc, h_layout, hpoints, radii) + ifcopenshell.api.alignment.create_representation(ifc, alignment) + + tool.Alignment.refresh_alignment_representation_object(alignment) + + n_curved = sum(1 for r in radii if r) + return True, f"Drew alignment '{alignment.Name}' with {len(hpoints)} PIs ({n_curved} curved)" + + +def _create_pi_markers(context, alignment_id, raw_points): + """Place a marker empty at every interior PI (Blender-world position, metres). + + Start and end never get a curve, so — unlike an earlier version of this + feature — they get no marker: nothing to select, nothing to click. + Their positions aren't needed from a marker either; + tool.Alignment.get_alignment_start_end_points() reads them straight off + the alignment's own current segments instead. + + ``pi_index`` keeps counting from the full point list (1-based among + interior PIs, i.e. never 0 or the last index) purely for the "PI n" + label; ALIGN_OT_apply_pi_curve interleaves markers with the derived + start/end using their sort order, not that number. + + Tagged via Object.bonsai_pi_curve_marker so ALIGN_OT_apply_pi_curve / + ALIGN_OT_clear_pi_markers can find them without any operator-instance + state (the drawing operator that created them has already finished by + the time a curve is applied). + """ + n = len(raw_points) + markers = [] + for i, (x, y, z) in enumerate(raw_points): + if i == 0 or i == n - 1: + continue + empty = bpy.data.objects.new(f"PI {i} (tangent)", None) + empty.empty_display_type = "SPHERE" + empty.empty_display_size = 2.0 + empty.location = (x, y, z) + marker = empty.bonsai_pi_curve_marker + marker.is_pi_marker = True + marker.alignment_id = alignment_id + marker.pi_index = i + marker.curve_type = "TANGENT" + context.collection.objects.link(empty) + markers.append(empty) + return markers + + +def _find_pi_markers(alignment_id): + """All PI marker empties for one alignment, sorted by PI index.""" + markers = [ + obj + for obj in bpy.data.objects + if obj.bonsai_pi_curve_marker.is_pi_marker and obj.bonsai_pi_curve_marker.alignment_id == alignment_id + ] + markers.sort(key=lambda o: o.bonsai_pi_curve_marker.pi_index) + return markers + + +def _active_pi_marker(context): + obj = context.active_object + if obj is not None and obj.bonsai_pi_curve_marker.is_pi_marker: + return obj + return None + + +def _is_interior_pi_marker(obj) -> bool: + """Whether ``obj`` is one of our PI markers — _create_pi_markers() never + makes one for Start/End (they can't have a curve), so is_pi_marker being + set is enough now; kept as its own function since callers read it as an + "is this an editable PI" check. + """ + return obj.bonsai_pi_curve_marker.is_pi_marker + + +class ALIGN_OT_apply_pi_curve(Operator, tool.Ifc.Operator): + """Regenerate the alignment using the active PI marker's curve settings. + + A plain button click — a top-level operator invocation, not one nested + inside another operator's still-running modal() — which is what the + curve-editing popup used to be. That nesting is what silently broke the + alignment regeneration: Blender operators must not invoke another + operator's dialog from inside a live modal() callback (the rest of this + module's modal tools — and Bonsai's own gizmo/product-placement modals — + only ever do that from invoke()/exit(), never mid-modal). + """ + + bl_idname = "align.apply_pi_curve" + bl_label = "Apply Curve" + bl_description = "Regenerate the alignment using this PI's curve type/radius" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + if not poll_ifc4x3(cls, context): + return False + marker = _active_pi_marker(context) + if not marker or not _is_interior_pi_marker(marker): + cls.poll_message_set("Select an interior PI marker first") + return False + return True + + def _execute(self, context): + marker_obj = _active_pi_marker(context) + alignment_id = marker_obj.bonsai_pi_curve_marker.alignment_id + alignment = tool.Ifc.get().by_id(alignment_id) + + try: + start, end = tool.Alignment.get_alignment_start_end_points(alignment) + except ValueError as e: + self.report({"ERROR"}, str(e)) + return {"CANCELLED"} + + ifc = tool.Ifc.get() + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc) + interior_markers = _find_pi_markers(alignment_id) + hpoints = ( + [start] + + [_world_point_to_local_ifc(ifc, unit_scale, m.location) for m in interior_markers] + + [end] + ) + radii = [ + (m.bonsai_pi_curve_marker.radius if m.bonsai_pi_curve_marker.curve_type == "CIRCULAR" else 0.0) + for m in interior_markers + ] + + ok, message = _generate_alignment_segments(context, alignment, hpoints, radii) + + marker = marker_obj.bonsai_pi_curve_marker + marker_obj.name = ( + f"PI {marker.pi_index} (R={marker.radius:.2f})" + if marker.curve_type == "CIRCULAR" + else f"PI {marker.pi_index} (tangent)" + ) + # _generate_alignment_segments() replaces every IfcAlignmentSegment + # with a new one, so a previously-highlighted segment's id is gone — + # refreshing it would silently keep showing the old, now-stale + # highlight at its old position. Uninstalling is the correct call + # here, same idea as the stationing operators' refresh() below. + alignment_decorator.AlignmentSegmentDecorator.uninstall() + tool.Blender.update_viewport() + self.report({"INFO"} if ok else {"WARNING"}, message) + return {"FINISHED"} + + +class ALIGN_OT_clear_pi_markers(Operator, tool.Ifc.Operator): + """Remove the PI marker empties left over from drawing/curve-editing""" + + bl_idname = "align.clear_pi_markers" + bl_label = "Clear PI Markers" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + if not poll_ifc4x3(cls, context): + return False + if not _resolve_alignment_id_for_markers(context): + cls.poll_message_set("Select an alignment or one of its PI markers") + return False + return True + + def _execute(self, context): + alignment_id = _resolve_alignment_id_for_markers(context) + markers = _find_pi_markers(alignment_id) + for m in markers: + bpy.data.objects.remove(m, do_unlink=True) + tool.Blender.update_viewport() + self.report({"INFO"}, f"Removed {len(markers)} PI markers") + + +def _resolve_alignment_id_for_markers(context): + """The alignment id whose PI markers apply_pi_curve/clear_pi_markers act on. + + Works whether the active object is the alignment itself or one of its + own (non-IFC-linked) PI markers. + """ + marker = _active_pi_marker(context) + if marker: + return marker.bonsai_pi_curve_marker.alignment_id + alignment = tool.Alignment.get_active_alignment() + return alignment.id() if alignment else 0 + + +class ALIGN_OT_draw_horizontal_alignment(bpy.types.Operator, PolylineOperator, tool.Ifc.Operator): + """Draw the horizontal alignment of the active IfcAlignment directly in the viewport. + + Click to place each PI (tangent-to-tangent). RMB/Enter finishes and + immediately generates the alignment with every PI a sharp corner. If + there are interior PIs, a marker empty is left at each one — select a + marker and use "Apply Curve" (see the Alignments tab panel) to give it a + circular curve and regenerate. ESC cancels without creating anything. + + Numeric Distance/Angle input is available via the D/A keys, same as the + rest of Bonsai's polyline tools. + """ + + bl_idname = "align.draw_horizontal_alignment" + bl_label = "Draw Horizontal Alignment" + bl_description = ( + "Draw the horizontal alignment in the viewport. Click to place PIs, " + "RMB/Enter to generate it. ESC cancels." + ) + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + if not poll_ifc4x3(cls, context): + return False + if not tool.Alignment.get_active_alignment(): + cls.poll_message_set("Add or select an alignment first") + return False + return True + + def __init__(self, *args, **kwargs): + bpy.types.Operator.__init__(self, *args, **kwargs) + PolylineOperator.__init__(self) + # Remove instructions that don't apply to alignments + self.instructions.pop("Close Polyline", None) + self.instructions.pop("Offset", None) + self._bearing_handle = None + self._last_mouse_pos = (0, 0) + + def invoke(self, context, event): + return IfcStore.execute_ifc_operator(self, context, event, method="INVOKE") + + def _invoke(self, context, event): + # Find the 3D viewport — the operator is invoked from the Properties + # panel, so we need to override context for PolylineOperator.invoke(), + # and for snapping the view to plan (top-down). + area_3d = None + region_3d = None + for area in context.screen.areas: + if area.type == "VIEW_3D": + area_3d = area + for region in area.regions: + if region.type == "WINDOW": + region_3d = region + break + break + + if not area_3d or not region_3d: + self.report({"ERROR"}, "No 3D Viewport found") + return {"CANCELLED"} + + with context.temp_override(area=area_3d, region=region_3d): + # Step: automatically rotate the viewport to the XY plane (Z-up, + # looking straight down) so the alignment is drawn in plan. + bpy.ops.view3d.view_axis(type="TOP") + super().invoke(context, event) + + self.tool_state.use_default_container = False + self.tool_state.plane_method = "XY" + + args = (context,) + self._bearing_handle = SpaceView3D.draw_handler_add(self._draw_bearing_hud, args, "WINDOW", "POST_PIXEL") + + return {"RUNNING_MODAL"} + + def modal(self, context, event): + return IfcStore.execute_ifc_operator(self, context, event, method="MODAL") + + def _modal(self, context, event): + self._last_mouse_pos = (event.mouse_region_x, event.mouse_region_y) + PolylineDecorator.update(event, self.tool_state, self.input_ui, self.snapping_points[0]) + tool.Blender.update_viewport() + + self.handle_lock_axis(context, event) + + if event.type in {"MIDDLEMOUSE", "WHEELUPMOUSE", "WHEELDOWNMOUSE"}: + self.handle_mouse_move(context, event) + return {"PASS_THROUGH"} + + self.handle_instructions(context) + self.handle_mouse_move(context, event, should_round=True) + self.choose_axis(event) + self.handle_snap_selection(context, event) + self.handle_keyboard_input(context, event) + _insert_polyline_point_no_close(self, context, event) + + # Finish: generate the alignment (sharp corners) and, if there are + # interior PIs, leave a marker at each for later curve editing. + if ( + not self.tool_state.is_input_on + and event.value == "RELEASE" + and event.type in {"RET", "NUMPAD_ENTER", "RIGHTMOUSE"} + ): + self._uninstall_bearing_hud() + context.workspace.status_text_set(text=None) + PolylineDecorator.uninstall() + self._finish(context) + tool.Polyline.clear_polyline() + tool.Blender.update_viewport() + return {"FINISHED"} + + cancel = self.handle_cancelation(context, event) + if cancel is not None: + self._uninstall_bearing_hud() + return cancel + + return {"RUNNING_MODAL"} + + def _finish(self, context): + ok, result = _hpoints_from_polyline(context) + if not ok: + self.report({"WARNING"}, result) + return + + raw_points, hpoints = result + alignment = tool.Alignment.get_active_alignment() + if not alignment: + self.report({"WARNING"}, "Add or select an alignment first") + return + + # Redrawing replaces the alignment's geometry outright, so any markers + # left over from a previous draw on this same alignment are stale — + # drop them first rather than accumulating duplicates. + for old_marker in _find_pi_markers(alignment.id()): + bpy.data.objects.remove(old_marker, do_unlink=True) + + radii = [0.0] * (len(hpoints) - 2) + ok, message = _generate_alignment_segments(context, alignment, hpoints, radii) + if ok and radii: + _create_pi_markers(context, alignment.id(), raw_points) + message += " — select a PI marker and click Apply Curve to add a curve" + + # Make the alignment the active/selected object so it's immediately + # visible in the outliner/properties pane without a manual click — + # _generate_alignment_segments()/_create_pi_markers() don't leave + # any particular object selected. + if ok: + alignment_obj = tool.Ifc.get_object(alignment) + if alignment_obj: + for obj in context.selected_objects: + obj.select_set(False) + alignment_obj.select_set(True) + context.view_layer.objects.active = alignment_obj + + self.report({"INFO"} if ok else {"WARNING"}, message) + + def _uninstall_bearing_hud(self): + if self._bearing_handle is not None: + SpaceView3D.draw_handler_remove(self._bearing_handle, "WINDOW") + self._bearing_handle = None + + def _draw_bearing_hud(self, context): + """Draw the current tangent's bearing alongside the Distance/Angle HUD. + + Positioned below the existing D/A/X/Y readout (which floats near the + mouse cursor) so both are visible together while drawing. + """ + azimuth = self.input_ui.get_number_value("WORLD_ANGLE") if self.input_ui else None + if not azimuth and azimuth != 0.0: + return + mouse_pos = self._last_mouse_pos + + addon_prefs = tool.Blender.get_addon_preferences() + font_id = 0 + font_size = tool.Blender.scale_font_size() + offset = tool.Blender.scale_font_size() * 1.5 + line_height = tool.Blender.scale_font_size() * 1.25 + # One line below the D/A/X/Y stack (up to 4 lines tall). + below_stack = line_height * (len(self.input_ui.input_options) + 1) + + blf.size(font_id, font_size) + blf.enable(font_id, blf.SHADOW) + blf.shadow(font_id, 6, 0, 0, 0, 1) + blf.color(font_id, *addon_prefs.decorations_colour) + blf.position(font_id, mouse_pos[0] + offset, mouse_pos[1] - below_stack, 0) + blf.draw(font_id, "Bearing: " + _bearing_string(azimuth)) + blf.disable(font_id, blf.SHADOW) + + +# ============================================================================= +# Stationing Operators +# ============================================================================= + + +class ALIGN_OT_add_stationing_referent(Operator, tool.Ifc.Operator): + """Add a stationing referent to the alignment""" + + bl_idname = "align.add_stationing_referent" + bl_label = "Add Stationing Referent" + bl_description = "Add an IfcReferent for stationing" + bl_options = {"REGISTER", "UNDO"} + + station: FloatProperty( + name="Station", + description="Station value for the referent (e.g., 10000 for 100+00)", + default=10000.0, + ) + + name: StringProperty( + name="Name", + description="Name for the referent (leave blank to auto-generate)", + default="", + ) + + @classmethod + def poll(cls, context): + if not poll_ifc4x3(cls, context): + return False + props = context.scene.CivilAlignmentProperties + if props.active_alignment_id == 0: + cls.poll_message_set("Select an alignment first") + return False + return True + + def invoke(self, context, event): + # Default station to start_station from props + props = context.scene.CivilAlignmentProperties + self.station = props.start_station + return context.window_manager.invoke_props_dialog(self) + + def draw(self, context): + layout = self.layout + layout.prop(self, "station") + layout.prop(self, "name") + # Show station notation preview + station_str = tool.Alignment.format_station(self.station) + layout.label(text=f"Station notation: {station_str}") + + def _execute(self, context): + ifc = tool.Ifc.get() + props = context.scene.CivilAlignmentProperties + + alignment = _resolve_active_alignment(context) + if alignment is None: + self.report({"ERROR"}, "Alignment no longer exists. Reference cleared.") + return {"CANCELLED"} + + # Compute distance_along from station and start_station + # distance_along = station - start_station + distance_along = self.station - props.start_station + + # Auto-generate name if not provided + name = self.name if self.name else tool.Alignment.format_station(self.station) + + # Use the alignment itself as the positioned product + # (The referent marks a point on the alignment) + positioned_product = alignment + + ifcopenshell.api.alignment.add_stationing_referent( + ifc, + alignment=alignment, + distance_along=distance_along, + station=self.station, + name=name, + positioned_product=positioned_product, + ) + + self.report({"INFO"}, f"Added referent '{name}' at station {self.station}") + + +class ALIGN_OT_name_segments(Operator, tool.Ifc.Operator): + """Auto-name segments based on station values""" + + bl_idname = "align.name_segments" + bl_label = "Name Segments" + bl_description = "Automatically name segments with station-based labels" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + if not poll_ifc4x3(cls, context): + return False + props = context.scene.CivilAlignmentProperties + if props.active_alignment_id == 0: + cls.poll_message_set("Select an alignment first") + return False + return True + + def _execute(self, context): + ifc = tool.Ifc.get() + props = context.scene.CivilAlignmentProperties + + alignment = _resolve_active_alignment(context) + if alignment is None: + self.report({"ERROR"}, "Alignment no longer exists. Reference cleared.") + return {"CANCELLED"} + + ifcopenshell.api.alignment.name_segments(ifc, alignment) + + self.report({"INFO"}, "Named alignment segments") + + +# ============================================================================= +# Vertical Profile Window Operator +# ============================================================================= + + +class ALIGN_OT_show_vertical_profile(Operator): + """Toggle the docked vertical profile view below the active 3D viewport""" + + bl_idname = "align.show_vertical_profile" + bl_label = "Toggle Vertical Profile" + bl_description = ( + "Dock a 2D vertical profile view below this viewport (click again to close). " + "Elevation is exaggerated by the VE factor. Use middle-mouse to pan/zoom." + ) + bl_options = {"REGISTER"} + + def execute(self, context): + import mathutils + + dec = alignment_decorator.VerticalProfileDecorator + + # --- Toggle off --- + if dec.is_installed: + context.scene.CivilAlignmentProperties.vertical_items.clear() + dec.uninstall() + return {"FINISHED"} + + # --- Toggle on --- + alignment = tool.Alignment.get_active_alignment() + if not alignment: + self.report({"WARNING"}, "No alignment selected") + return {"CANCELLED"} + + dec._compute_profile(alignment) + if not dec.segments_polylines: + self.report({"WARNING"}, "No vertical alignment data found for this alignment") + return {"CANCELLED"} + + ve = context.scene.CivilAlignmentProperties.vertical_exaggeration + + # Find the 3D view area and its WINDOW region for the split call + area = context.area + if area.type != "VIEW_3D": + # Button pressed from a non-3D area — find the first 3D view + area = next((a for a in context.screen.areas if a.type == "VIEW_3D"), None) + if area is None: + self.report({"WARNING"}, "No 3D Viewport found") + return {"CANCELLED"} + win_region = next((r for r in area.regions if r.type == "WINDOW"), None) + if win_region is None: + return {"CANCELLED"} + + # Split with a horizontal dividing line so the profile appears below the + # main 3D view. factor=0.7 keeps 70% for the existing area (top) and + # gives 30% to the new profile area (bottom). + # direction="HORIZONTAL" = horizontal split line = top/bottom areas. + # Use as_pointer() (stable C address) rather than id() (Python wrapper ID, + # which can change after Blender reshuffles wrappers following area_close). + ptrs_before = {a.as_pointer() for a in context.screen.areas} + with context.temp_override(area=area, region=win_region): + bpy.ops.screen.area_split(direction="HORIZONTAL", factor=0.7) + + new_areas = [a for a in context.screen.areas if a.as_pointer() not in ptrs_before] + if not new_areas: + self.report({"WARNING"}, "Could not split the viewport") + return {"CANCELLED"} + + # Blender's area_split places the NEW area above the original area. + # The original area stays at the bottom — use it as the profile view. + profile_area = area + + space = next((s for s in profile_area.spaces if s.type == "VIEW_3D"), None) + if space is None: + return {"CANCELLED"} + + # Front orthographic: 90° rotation around X so Z is elevation, X is distance + space.region_3d.view_perspective = "ORTHO" + space.region_3d.view_rotation = mathutils.Quaternion((0.7071068, 0.7071068, 0.0, 0.0)) + + dec.fit_view(space, ve, area_width=profile_area.width, area_height=profile_area.height) + + space.overlay.show_floor = False + space.overlay.show_axis_x = False + space.overlay.show_axis_y = False + space.overlay.show_axis_z = False + space.show_gizmo = False + + # Hide tool shelf and N-panel so they don't obscure the profile extents. + # Set directly on the space (absolute, not a toggle) so this works reliably + # regardless of the panel's current visibility state. + space.show_region_toolbar = False + space.show_region_ui = False + + # Populate per-vertical and per-cant visibility filters (all visible by default) + props = context.scene.CivilAlignmentProperties + props.vertical_items.clear() + for v_id, v_label in dec.available_verticals: + item = props.vertical_items.add() + item.entity_id = v_id + item.label = v_label + item.is_visible = True + + props.cant_items.clear() + for c_id, c_label in dec.available_cants: + item = props.cant_items.add() + item.entity_id = c_id + item.label = c_label + item.is_visible = True + + dec.install(context, profile_area) + profile_area.tag_redraw() + + return {"FINISHED"} + + +# ============================================================================= +# Segment Selection Operator +# ============================================================================= + + +class ALIGN_OT_select_h_segment(Operator): + """Toggle highlight of a horizontal alignment segment in the 3D viewport""" + + bl_idname = "align.select_h_segment" + bl_label = "Select Horizontal Segment" + bl_description = "Highlight this segment in the 3D viewport" + bl_options = {"REGISTER"} + + segment_id: bpy.props.IntProperty(options={"HIDDEN"}) + + def execute(self, context): + props = context.scene.CivilAlignmentProperties + + if props.selected_h_segment_id == self.segment_id: + props.selected_h_segment_id = 0 + alignment_decorator.AlignmentSegmentDecorator.uninstall() + else: + props.selected_h_segment_id = self.segment_id + alignment_decorator.AlignmentSegmentDecorator.install(context, self.segment_id) + + for area in context.screen.areas: + if area.type == "VIEW_3D": + area.tag_redraw() + return {"FINISHED"} + + +class ALIGN_OT_select_v_segment(Operator): + """Toggle highlight of a vertical alignment segment in the profile view""" + + bl_idname = "align.select_v_segment" + bl_label = "Select Vertical Segment" + bl_description = "Highlight this segment in the profile view" + bl_options = {"REGISTER"} + + segment_id: bpy.props.IntProperty(options={"HIDDEN"}) + + def execute(self, context): + props = context.scene.CivilAlignmentProperties + + if props.selected_v_segment_id == self.segment_id: + props.selected_v_segment_id = 0 + else: + props.selected_v_segment_id = self.segment_id + + alignment_decorator.VerticalProfileDecorator.tag_redraw() + return {"FINISHED"} + + +class ALIGN_OT_select_cant_segment(Operator): + """Toggle highlight of a cant segment in the profile view""" + + bl_idname = "align.select_cant_segment" + bl_label = "Select Cant Segment" + bl_description = "Highlight this cant segment in the profile view" + bl_options = {"REGISTER"} + + segment_id: bpy.props.IntProperty(options={"HIDDEN"}) + + def execute(self, context): + props = context.scene.CivilAlignmentProperties + + if props.selected_cant_segment_id == self.segment_id: + props.selected_cant_segment_id = 0 + else: + props.selected_cant_segment_id = self.segment_id + + alignment_decorator.VerticalProfileDecorator.tag_redraw() + return {"FINISHED"} + + +# ============================================================================= +# PI Edit Mode Operator +# ============================================================================= + + +class ALIGN_OT_enter_pi_edit_mode(Operator, tool.Ifc.Operator): + """Enter PI editing mode - move PIs with G key, press Enter to apply or Escape to cancel""" + + bl_idname = "align.enter_pi_edit_mode" + bl_label = "Edit PIs" + bl_description = "Enter PI edit mode. Move PI points with G key. Press Enter to apply changes, Escape to cancel." + bl_options = {"REGISTER", "UNDO"} + + # Instance state for modal operation + _pi_empties: list = [] + _last_positions: list = [] + _area = None + _alignment_id: int = 0 + + @classmethod + def poll(cls, context): + if not poll_ifc4x3(cls, context): + return False + props = context.scene.CivilAlignmentProperties + if props.is_pi_edit_mode: + cls.poll_message_set("Already in PI edit mode") + return False + if props.active_alignment_id == 0: + cls.poll_message_set("No alignment selected") + return False + # Verify alignment still exists + alignment = _resolve_active_alignment(context) + if alignment is None: + cls.poll_message_set("Selected alignment no longer exists") + return False + return True + + def invoke(self, context, event): + return IfcStore.execute_ifc_operator(self, context, event, method="INVOKE") + + def _invoke(self, context, event): + props = context.scene.CivilAlignmentProperties + self._alignment_id = props.active_alignment_id + + # Enter edit mode via core layer (validates and creates empties) + try: + empties = core.enter_pi_edit_mode( + tool.Ifc, tool.Alignment, self._alignment_id + ) + except ValueError as e: + self.report({"ERROR"}, str(e)) + return {"CANCELLED"} + + if not empties: + self.report({"ERROR"}, "Failed to create PI empties") + return {"CANCELLED"} + + # Cache references to empties and their positions + self._pi_empties = empties + self._last_positions = [e.location.copy() for e in empties] + + # Find viewport for redraws + self._area = None + for area in context.screen.areas: + if area.type == "VIEW_3D": + self._area = area + break + + # Install visual feedback decorator + alignment_decorator.PIEditDecorator.install(context, empties) + + # Make the segment curves non-selectable so viewport clicks land on the + # PI empties, and deselect everything so the user starts clean. + alignment = tool.Ifc.get().by_id(self._alignment_id) + h_layout = tool.Alignment.get_horizontal_layout(alignment) + tool.Alignment.set_layout_segments_selectable(h_layout, False) + for obj in list(context.selected_objects): + obj.select_set(False) + + # Update UI state + props.is_pi_edit_mode = True + props.pi_edit_alignment_id = self._alignment_id + + # Start modal loop + context.window_manager.modal_handler_add(self) + self.report({"INFO"}, "PI Edit Mode: Move PIs with G. Press Enter to apply, Escape to cancel.") + return {"RUNNING_MODAL"} + + def modal(self, context, event): + return IfcStore.execute_ifc_operator(self, context, event, method="MODAL") + + def _modal(self, context, event): + props = context.scene.CivilAlignmentProperties + + # Safety: check if empties still exist (handles undo edge case) + if not self._empties_still_exist(): + self.report({"WARNING"}, "PI Edit Mode cancelled - empties were removed") + return self._cleanup_and_finish(context, apply=False) + + # Detect position changes and update decorator + positions_changed = False + for i, empty in enumerate(self._pi_empties): + if empty.location != self._last_positions[i]: + positions_changed = True + self._last_positions[i] = empty.location.copy() + + if positions_changed: + # Update decorator to show new tangent lines + alignment_decorator.PIEditDecorator.update_positions(self._pi_empties) + if self._area: + self._area.tag_redraw() + + # Handle keyboard input + if event.type in {"RET", "NUMPAD_ENTER"} and event.value == "PRESS": + return self._cleanup_and_finish(context, apply=True) + + if event.type == "ESC" and event.value == "PRESS": + return self._cleanup_and_finish(context, apply=False) + + # Let all other events pass through (G key, mouse, viewport navigation, etc.) + return {"PASS_THROUGH"} + + def _empties_still_exist(self) -> bool: + """Check if all PI empties still exist in the scene.""" + for empty in self._pi_empties: + if empty is None: + return False + if empty.name not in bpy.data.objects: + return False + return True + + def _cleanup_and_finish(self, context, apply: bool): + """Exit edit mode, optionally applying changes.""" + props = context.scene.CivilAlignmentProperties + + try: + if apply: + # Regenerate alignment from new PI positions + core.exit_pi_edit_mode( + tool.Ifc, tool.Alignment, self._alignment_id, apply=True + ) + self.report({"INFO"}, "PI changes applied - alignment updated") + else: + # Just cleanup without regenerating + core.exit_pi_edit_mode( + tool.Ifc, tool.Alignment, self._alignment_id, apply=False + ) + self.report({"INFO"}, "PI Edit Mode cancelled") + except ValueError as e: + self.report({"ERROR"}, str(e)) + + # Cleanup decorator + alignment_decorator.PIEditDecorator.uninstall() + + # Restore segment selectability (on apply the segments are rebuilt and + # already selectable; on cancel this re-enables the originals). + try: + alignment = tool.Ifc.get().by_id(self._alignment_id) + h_layout = tool.Alignment.get_horizontal_layout(alignment) + tool.Alignment.set_layout_segments_selectable(h_layout, True) + except (RuntimeError, AttributeError): + pass + + # Reset UI state + props.is_pi_edit_mode = False + props.pi_edit_alignment_id = 0 + + # Clear instance state + self._pi_empties = [] + self._last_positions = [] + + if self._area: + self._area.tag_redraw() + + if apply: + return {"FINISHED"} + return {"CANCELLED"} diff --git a/src/bonsai/bonsai/bim/module/alignment/ops.authoring.alignment.dat b/src/bonsai/bonsai/bim/module/alignment/ops.authoring.alignment.dat new file mode 100644 index 0000000000..88edb8568c Binary files /dev/null and b/src/bonsai/bonsai/bim/module/alignment/ops.authoring.alignment.dat differ diff --git a/src/bonsai/bonsai/bim/module/alignment/prop.py b/src/bonsai/bonsai/bim/module/alignment/prop.py new file mode 100644 index 0000000000..990c667991 --- /dev/null +++ b/src/bonsai/bonsai/bim/module/alignment/prop.py @@ -0,0 +1,422 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2025, 2026 Michael Yoder +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . + + +"""Property groups for the alignment module""" + +from bpy.types import PropertyGroup +from bpy.props import ( + StringProperty, + FloatProperty, + IntProperty, + BoolProperty, + CollectionProperty, + EnumProperty, +) + + +import bpy + + +def _on_vertical_visibility_update(self, context): + from .decorator import VerticalProfileDecorator + + VerticalProfileDecorator.tag_redraw() + + +def _alignment_enum_items(self, context): + """Dynamic items: all top-level IfcAlignment entities in the current file.""" + import bonsai.tool as tool + + items = [("0", "— select alignment —", "")] + ifc_file = tool.Ifc.get() + if not ifc_file: + return items + try: + for a in ifc_file.by_type("IfcAlignment"): + # Skip child alignments (used in multi-vertical template) + if any( + rel.RelatingObject.is_a("IfcAlignment") + for rel in (getattr(a, "Decomposes", []) or []) + ): + continue + label = a.Name or f"Alignment #{a.id()}" + items.append((str(a.id()), label, "")) + except Exception: + pass + return items + + +def _on_active_alignment_update(self, context): + """Select the alignment's Blender object when the dropdown changes.""" + import bonsai.tool as tool + + try: + aid = int(self.active_alignment_id_str) + except (ValueError, TypeError): + return + if aid == 0: + return + ifc_file = tool.Ifc.get() + if not ifc_file: + return + try: + alignment = ifc_file.by_id(aid) + obj = tool.Ifc.get_object(alignment) + if obj and context.view_layer.objects.get(obj.name): + # Use direct RNA — bpy.ops.object.select_all can fail from non-3D contexts + for o in context.view_layer.objects: + o.select_set(False) + obj.select_set(True) + context.view_layer.objects.active = obj + except Exception: + pass + + +class VerticalAlignmentItem(PropertyGroup): + """Tracks one IfcAlignmentVertical available in the profile view.""" + + entity_id: IntProperty(name="Entity ID", default=0) + label: StringProperty(name="Label", default="Vertical") + is_visible: BoolProperty( + name="Show in profile", + description="Show this vertical alignment in the profile view", + default=True, + update=_on_vertical_visibility_update, + ) + show_segments: BoolProperty( + name="Show Segments", + description="Expand the segment table for this vertical alignment", + default=True, + ) + show_labels: BoolProperty( + name="Show Labels", + description="Show BVC/PVI/EVC callout labels for this vertical alignment in the profile view", + default=True, + update=_on_vertical_visibility_update, + ) + + +def _on_cant_visibility_update(self, context): + from .decorator import VerticalProfileDecorator + + VerticalProfileDecorator.tag_redraw() + + +def _on_ve_update(self, context): + from .decorator import VerticalProfileDecorator + + VerticalProfileDecorator.tag_redraw() + + +def _on_radius_update(self, context): + """Callback when radius property changes. + + This dynamically imports the operator module to call on_radius_changed, + avoiding circular imports since prop.py is imported before operator.py. + """ + from . import operator as ops + + ops.on_radius_changed(self, context) + + +class CantAlignmentItem(PropertyGroup): + """Tracks one IfcAlignmentCant available in the profile view.""" + + entity_id: IntProperty(name="Entity ID", default=0) + label: StringProperty(name="Label", default="Cant") + is_visible: BoolProperty( + name="Show in profile", + description="Show this cant in the profile view", + default=True, + update=_on_cant_visibility_update, + ) + show_segments: BoolProperty( + name="Show Segments", + description="Expand the segment table for this cant", + default=True, + ) + + +class AlignmentPI(PropertyGroup): + """Property group for a single PI (Point of Intersection) + + In the PI method, alignments are defined by: + - Endpoint PIs: Start (POB) and End (POE) points + - Interior PIs: Points where tangents intersect, optionally with curves + """ + + # Coordinates stored as global easting/northing (map coordinates). + # Coordinate flow: Blender coords -> xyz2enh() -> global E/N (stored here) + # global E/N -> ifcopenshell.util.geolocation.auto_enh2xyz() -> local IFC coords (for IfcOpenShell API) + e: StringProperty(name="E", description="Easting (global map coordinates)", default="0.0") + n: StringProperty(name="N", description="Northing (global map coordinates)", default="0.0") + + # PI Type + pi_type: EnumProperty( + name="Type", + description="Type of PI point", + items=[ + ("ENDPOINT", "Endpoint", "Start or end point (no curve)"), + ("TANGENT", "Tangent", "Pass-through point (no curve)"), + ("CURVE", "Curve", "Point of intersection with curve"), + ], + default="TANGENT", + ) + + # Curve parameters (only used when pi_type == "CURVE") + radius: FloatProperty( + name="Radius", + description="Curve radius (0 = no curve, sharp angle)", + default=0.0, + min=0.0, + precision=3, + unit="LENGTH", + update=_on_radius_update, + ) + + # Computed/display values (updated by recalculate operator) + length_to_next: FloatProperty( + name="Length", + description="Length of tangent to next PI", + default=0.0, + precision=3, + unit="LENGTH", + ) + + direction_to_next: FloatProperty( + name="Direction", + description="Bearing/direction to next PI (degrees)", + default=0.0, + precision=4, + subtype="ANGLE", + ) + + # Station at this PI (computed) + station: FloatProperty( + name="Station", + description="Station value at this PI", + default=0.0, + precision=2, + ) + + +class AlignmentDisplayRow(PropertyGroup): + """Property group for interleaved point/segment display in the table. + + This creates the Civil 3D-style view where points and segments + are shown on separate rows: + Point 1 (End) + Segment 1 (Tan) + Point 2 (Tan) + Segment 2 (Tan) + ... + """ + + # Row type discriminator + row_type: EnumProperty( + name="Row Type", + items=[ + ("POINT", "Point", "A PI point row"), + ("SEGMENT", "Segment", "A segment row between points"), + ], + default="POINT", + ) + + # Segment number (1, 2, 3...) - only for SEGMENT rows + segment_number: IntProperty(name="Segment #", default=0) + + # Point index in the pis collection - for both types + # For POINT rows: the PI index + # For SEGMENT rows: the starting PI index of this segment + pi_index: IntProperty(name="PI Index", default=0) + + # Display type string (End, Tan, Curve for points; Tan, Curve for segments) + display_type: StringProperty(name="Type", default="") + + # Point coordinates (only for POINT rows) + e: StringProperty(name="E", default="0.0") + n: StringProperty(name="N", default="0.0") + + # Segment properties (only for SEGMENT rows) + length: FloatProperty(name="Length", default=0.0, precision=2, unit="LENGTH") + radius: FloatProperty(name="Radius", default=0.0, precision=2, unit="LENGTH") + arc_length: FloatProperty(name="Arc Length", default=0.0, precision=2, unit="LENGTH") + + +class CivilAlignmentProperties(PropertyGroup): + """Properties for the alignment module""" + + # Active alignment selection + active_alignment_id: IntProperty( + name="Active Alignment ID", + description="IFC entity ID of the active alignment", + default=0, + ) + + active_alignment_name: StringProperty( + name="Active Alignment", + description="Name of the currently active alignment", + default="", + ) + + # Alignment selector dropdown (top-level alignments only) + active_alignment_id_str: EnumProperty( + name="Alignment", + description="Active alignment shown in this panel", + items=_alignment_enum_items, + update=_on_active_alignment_update, + default=0, + ) + + # Panel collapse state + show_horizontal_segments: BoolProperty( + name="Show Horizontal Segments", + description="Expand the horizontal segment table", + default=True, + ) + + # New alignment creation properties + new_alignment_name: StringProperty( + name="Name", + description="Name for new alignment", + default="Alignment 1", + ) + + start_station: FloatProperty( + name="Start Station", + description="Starting station value (e.g., 10000 for 100+00)", + default=10000.0, + min=0.0, + ) + + # PI collection for PI method creation + pis: CollectionProperty(type=AlignmentPI) + active_pi_index: IntProperty(name="Active PI", default=0) + + # Combined point/segment display rows (for Civil 3D-style table) + display_rows: CollectionProperty(type=AlignmentDisplayRow) + active_display_row_index: IntProperty(name="Active Display Row", default=0) + + # Vertical profile window settings + vertical_exaggeration: FloatProperty( + name="Vertical Exaggeration", + description="Multiply elevation differences by this factor for the profile view", + default=10.0, + min=1.0, + max=1000.0, + precision=1, + update=_on_ve_update, + ) + + # Selected horizontal segment (for viewport highlight) + selected_h_segment_id: IntProperty( + name="Selected Horizontal Segment", + description="IFC entity ID of the highlighted horizontal segment", + default=0, + ) + + # Selected vertical segment (for profile view highlight) + selected_v_segment_id: IntProperty( + name="Selected Vertical Segment", + description="IFC entity ID of the highlighted vertical segment in the profile view", + default=0, + ) + + # Label visibility toggles + show_h_segment_labels: BoolProperty( + name="Show Horizontal Labels", + description="Show PC/PT/PI labels for the selected horizontal segment in the 3D viewport", + default=True, + ) + + show_v_segment_labels: BoolProperty( + name="Show Vertical Labels", + description="Show BVC/PVI/EVC callout labels in the profile view", + default=True, + ) + + # Per-vertical visibility filter for the profile window + vertical_items: CollectionProperty(type=VerticalAlignmentItem) + + # Per-cant visibility filter for the profile window + cant_items: CollectionProperty(type=CantAlignmentItem) + + # Selected cant segment (for profile view highlight) + selected_cant_segment_id: IntProperty( + name="Selected Cant Segment", + description="IFC entity ID of the highlighted cant segment in the profile view", + default=0, + ) + + # Label visibility toggle for cant callouts + show_cant_segment_labels: BoolProperty( + name="Show Cant Labels", + description="Show cant start/end value labels in the profile view", + default=True, + ) + + # PI Edit Mode state (for moving PIs with G key) + is_pi_edit_mode: BoolProperty( + name="PI Edit Mode Active", + description="Whether PI edit mode is currently active", + default=False, + ) + + pi_edit_alignment_id: IntProperty( + name="Editing Alignment ID", + description="IFC ID of alignment being edited in PI edit mode", + default=0, + ) + + +class PICurveMarkerProperties(PropertyGroup): + """Tags a transient Empty object placed at an interior PI while its + smoothing curve is being defined (ALIGN_OT_draw_horizontal_alignment / + align.set_pi_curve). Registered as Object.bonsai_pi_curve_marker. + + Deliberately edited via plain panel widgets bound directly to this + PropertyGroup (see ALIGN_PT_alignment_authoring), not a popup dialog — + Blender operators must not invoke another operator's dialog from inside + a still-running modal's modal() callback (this is why the alignment + wouldn't regenerate after the very first version of this feature: the + curve popup was invoked from inside the drawing operator's own modal + loop). A plain "Apply" button clicked from the panel is a top-level + operator invocation, not a nested one, so it's safe. + """ + + is_pi_marker: BoolProperty(default=False) + alignment_id: IntProperty( + name="Alignment ID", description="IFC ID of the IfcAlignment this PI belongs to", default=0 + ) + pi_index: IntProperty( + name="PI Index", description="0-based index among the alignment's interior PIs", default=0 + ) + curve_type: EnumProperty( + name="Curve Type", + items=[ + ("TANGENT", "None (sharp PI)", "No curve — the two tangents meet directly"), + ("CIRCULAR", "Circular", "A simple circular arc"), + # Spiral-Circular / Circular-Spiral / Spiral-Circular-Spiral are not + # implemented yet. See REQUIREMENTS.md §2 step 7 — they need each + # segment authored individually (create_layout_segment), which + # layout_horizontal_alignment_by_pi_method does not support. + ], + default="TANGENT", + ) + radius: FloatProperty(name="Radius", default=100.0, min=0.0001, unit="LENGTH") diff --git a/src/bonsai/bonsai/bim/module/alignment/ui.py b/src/bonsai/bonsai/bim/module/alignment/ui.py new file mode 100644 index 0000000000..0d116c3b58 --- /dev/null +++ b/src/bonsai/bonsai/bim/module/alignment/ui.py @@ -0,0 +1,857 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2025, 2026 Michael Yoder +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . + + +"""UI panels for the alignment module + +All panels appear in the Properties sidebar under the CIVIL tab, +nested under BIM_PT_tab_horizontal_alignment. +""" + +import bpy +import math +import ifcopenshell.api.alignment +import ifcopenshell.util.geolocation +import bonsai.tool as tool +from bpy.types import Panel, UIList, Operator +from bpy.props import IntProperty, BoolProperty +from .prop import _alignment_enum_items +from .operator import _find_pi_markers, _resolve_alignment_id_for_markers, _is_interior_pi_marker + + +def _pi_markers_present(context) -> bool: + """Whether the relevant alignment (from the active object or an active + PI marker of its own) currently has any leftover PI marker empties.""" + alignment_id = _resolve_alignment_id_for_markers(context) + return bool(alignment_id and _find_pi_markers(alignment_id)) + + +def is_ifc4x3(): + """Check if the current IFC file is IFC4X3 schema""" + return tool.Ifc.get_schema() == "IFC4X3" + + +# Module-level dicts store expand/collapse state for sections. +# Keys are IFC entity IDs; True = expanded (default). +# Using plain dicts avoids any RNA property modification during draw callbacks. +_H_EXPANDED: dict[int, bool] = {} # alignment_id → bool +_V_EXPANDED: dict[int, bool] = {} # vertical layout entity_id → bool +_C_EXPANDED: dict[int, bool] = {} # cant layout entity_id → bool + + +# ============================================================================= +# Section toggle operators +# ============================================================================= + + +class ALIGN_OT_toggle_h_segments(Operator): + """Toggle horizontal segment table""" + + bl_idname = "align.toggle_h_segments" + bl_label = "Toggle Horizontal Segments" + bl_options = {"INTERNAL"} + + alignment_id: IntProperty() + + def execute(self, context): + _H_EXPANDED[self.alignment_id] = not _H_EXPANDED.get(self.alignment_id, True) + context.area.tag_redraw() + return {"FINISHED"} + + +class ALIGN_OT_toggle_v_segments(Operator): + """Toggle vertical segment table""" + + bl_idname = "align.toggle_v_segments" + bl_label = "Toggle Vertical Segments" + bl_options = {"INTERNAL"} + + entity_id: IntProperty() + + def execute(self, context): + _V_EXPANDED[self.entity_id] = not _V_EXPANDED.get(self.entity_id, True) + context.area.tag_redraw() + return {"FINISHED"} + + +class ALIGN_OT_toggle_cant_segments(Operator): + """Toggle cant segment table""" + + bl_idname = "align.toggle_cant_segments" + bl_label = "Toggle Cant Segments" + bl_options = {"INTERNAL"} + + entity_id: IntProperty() + + def execute(self, context): + _C_EXPANDED[self.entity_id] = not _C_EXPANDED.get(self.entity_id, True) + context.area.tag_redraw() + return {"FINISHED"} + + +# ============================================================================= +# UILists +# ============================================================================= + + +class ALIGN_UL_alignment_pis(UIList): + """UIList for displaying interleaved points and segments (Civil 3D style) + + Row types: + - POINT rows: End (endpoint), Mid (interior PI without curve) + - SEGMENT rows: Tan (tangent line), Curve (circular arc) + + When a Mid point has radius > 0, it becomes a Curve segment row. + """ + + def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index): + if self.layout_type in {"DEFAULT", "COMPACT"}: + row = layout.row(align=True) + + if item.row_type == "POINT": + # Point row: No., Type, X, Y, Length, Radius + row.label(text="") # No segment number for points + + # Type with point/dot icon + # "End" = endpoint (POB/POE), "Mid" = interior PI point + row.label(text=item.display_type, icon="DOT") + + # X, Y coordinates - get actual PI for editing + pi = data.pis[item.pi_index] if item.pi_index < len(data.pis) else None + if pi: + sub = row.row(align=True) + sub.prop(pi, "e", text="") + sub.prop(pi, "n", text="") + else: + row.label(text=f"{float(item.e):.2f}") + row.label(text=f"{float(item.n):.2f}") + + # Length column - empty for point rows + row.label(text="") + + # Radius column - editable for Mid points (where curves can be added) + if item.display_type == "Mid" and pi: + row.prop(pi, "radius", text="") + else: + row.label(text="") + + elif item.row_type == "SEGMENT": + if item.display_type == "Curve": + # Curve segment row: No., Type (arc icon), X, Y, Arc Length, Radius + row.label(text=f"{item.segment_number}") + row.label(text="Curve", icon="SPHERECURVE") + + # Show PI coordinates on curve row + row.label(text=f"{float(item.e):.2f}") + row.label(text=f"{float(item.n):.2f}") + + # Arc length + row.label(text=f"{item.arc_length:.2f}") + + # Radius - editable so user can modify or delete curve (set to 0) + pi = data.pis[item.pi_index] if item.pi_index < len(data.pis) else None + if pi: + row.prop(pi, "radius", text="") + else: + row.label(text=f"{item.radius:.2f}") + else: + # Tangent segment row: No., Type (line icon), -, -, Length, - + row.label(text=f"{item.segment_number}") + row.label(text="Tan", icon="IPO_LINEAR") + + # No X, Y for tangent segments + row.label(text="") + row.label(text="") + + # Length + row.label(text=f"{item.length:.2f}") + + # No radius for tangent segments + row.label(text="-") + + elif self.layout_type == "GRID": + layout.alignment = "CENTER" + layout.label(text="", icon="DECORATE") + + +# ============================================================================= +# Creation Sub-Panel +# ============================================================================= + + +class ALIGN_PT_alignment_creation(Panel): + """Sub-panel for alignment creation tools""" + + bl_label = "Creation" + bl_idname = "ALIGN_PT_alignment_creation" + bl_space_type = "PROPERTIES" + bl_region_type = "WINDOW" + bl_context = "scene" + bl_parent_id = "BIM_PT_tab_horizontal_alignment" + bl_options = {"DEFAULT_CLOSED"} + + @classmethod + def poll(cls, context): + return tool.Blender.should_show_panel(context, "CIVIL", cls.bl_idname) and is_ifc4x3() + + def draw(self, context): + layout = self.layout + props = context.scene.CivilAlignmentProperties + + # New alignment properties + box = layout.box() + box.label(text="New Alignment:", icon="ADD") + box.prop(props, "new_alignment_name") + box.prop(props, "start_station") + + # Creation operators + col = layout.column(align=True) + col.operator("align.create_alignment_by_pi", icon="CURVE_DATA") + + +# ============================================================================= +# PI Editor Sub-Panel +# ============================================================================= + + +class ALIGN_PT_pi_editor(Panel): + """Sub-panel for PI point table editor (Civil 3D style grid view)""" + + bl_label = "PI Editor" + bl_idname = "ALIGN_PT_pi_editor" + bl_space_type = "PROPERTIES" + bl_region_type = "WINDOW" + bl_context = "scene" + bl_parent_id = "BIM_PT_tab_horizontal_alignment" + bl_options = set() # Open by default + + @classmethod + def poll(cls, context): + return tool.Blender.should_show_panel(context, "CIVIL", cls.bl_idname) and is_ifc4x3() + + def draw(self, context): + layout = self.layout + props = context.scene.CivilAlignmentProperties + + # PI Edit Mode indicator + if props.is_pi_edit_mode: + box = layout.box() + box.alert = True + box.label(text="PI Edit Mode Active", icon="EDITMODE_HLT") + col = box.column(align=True) + col.label(text="Move PIs with G key") + col.label(text="Press Enter to apply") + col.label(text="Press Escape to cancel") + layout.separator() + return # Don't show normal UI while in edit mode + + # Edit existing alignment button + if props.active_alignment_id != 0: + box = layout.box() + box.label(text="Edit Alignment:", icon="EDITMODE_HLT") + box.operator("align.enter_pi_edit_mode", icon="PIVOT_CURSOR", text="Edit PIs (G key)") + layout.separator() + + # Header row with column labels + header = layout.row(align=True) + header.label(text="No.") + header.label(text="Type") + header.label(text="E") + header.label(text="N") + header.label(text="Length") + header.label(text="Radius") + + # Combined point/segment list (interleaved view) + row = layout.row() + row.template_list( + "ALIGN_UL_alignment_pis", + "", + props, + "display_rows", + props, + "active_display_row_index", + rows=8, + ) + + # Side buttons for list management + col = row.column(align=True) + col.operator("align.add_pi", icon="ADD", text="") + col.operator("align.remove_pi", icon="REMOVE", text="") + col.separator() + col.operator("align.pick_pi_from_viewport", icon="EYEDROPPER", text="") + + # Bottom actions + layout.separator() + row = layout.row(align=True) + row.operator("align.recalculate_pis", icon="FILE_REFRESH", text="Recalculate") + row.operator("align.clear_pis", icon="TRASH", text="Clear All") + + +# ============================================================================= +# Stationing Sub-Panel +# ============================================================================= + + +class ALIGN_PT_alignment_stationing(Panel): + """Sub-panel for stationing and referents""" + + bl_label = "Stationing" + bl_idname = "ALIGN_PT_alignment_stationing" + bl_space_type = "PROPERTIES" + bl_region_type = "WINDOW" + bl_context = "scene" + bl_parent_id = "BIM_PT_tab_horizontal_alignment" + bl_options = {"DEFAULT_CLOSED"} + + @classmethod + def poll(cls, context): + return tool.Blender.should_show_panel(context, "CIVIL", cls.bl_idname) and is_ifc4x3() + + def draw(self, context): + layout = self.layout + + # Stationing operators + col = layout.column(align=True) + col.operator("align.add_stationing_referent", icon="EMPTY_AXIS") + col.operator("align.name_segments", icon="FONT_DATA") + + +# ============================================================================= +# Alignments Tab – Segment Breakdown Panel +# ============================================================================= + + +def _rad_to_bearing(rad: float) -> str: + """Convert IFC start direction (radians, CCW from east) to compass bearing. + + IFC: 0 = east, increasing CCW. Bearing: 0 = north, increasing CW. + Result: N dd°mm'ss" E / S dd°mm'ss" E / S dd°mm'ss" W / N dd°mm'ss" W + """ + bearing_deg = (90.0 - math.degrees(rad)) % 360.0 + + def dms(angle_deg: float) -> str: + d = int(angle_deg) + m = int((angle_deg - d) * 60) + s = (angle_deg - d - m / 60) * 3600 + return f"{d}°{m:02d}'{s:04.1f}\"" + + if bearing_deg < 90.0: + return f"N {dms(bearing_deg)} E" + elif bearing_deg < 180.0: + return f"S {dms(180.0 - bearing_deg)} E" + elif bearing_deg < 270.0: + return f"S {dms(bearing_deg - 180.0)} W" + else: + return f"N {dms(360.0 - bearing_deg)} W" + + +def _start_en(ifc_file, dp) -> tuple[float | None, float | None]: + """Return (Easting, Northing) for an IfcAlignmentHorizontalSegment. + + StartPoint is in IFC project coordinates; auto_xyz2enh applies any + IfcMapConversion to get global map coordinates. Returns (None, None) + if StartPoint is absent or the conversion fails. + """ + pt = getattr(dp, "StartPoint", None) + if pt is None: + return None, None + try: + coords = pt.Coordinates + e, n, _ = ifcopenshell.util.geolocation.auto_xyz2enh( + ifc_file, coords[0], coords[1], 0.0 + ) + return e, n + except Exception: + return None, None + + +class ALIGN_PT_alignment_authoring(Panel): + """Add an alignment and draw its horizontal geometry — Alignments tab. + + This is a from-scratch authoring workflow, independent of the CIVIL tab's + PI-table tools: add a bare alignment, then draw its horizontal geometry + directly in the viewport. + """ + + bl_label = "Add Alignment" + bl_idname = "ALIGN_PT_alignment_authoring" + bl_space_type = "PROPERTIES" + bl_region_type = "WINDOW" + bl_context = "scene" + bl_parent_id = "BIM_PT_tab_alignments" + bl_options = {"HIDE_HEADER"} + + @classmethod + def poll(cls, context): + if not tool.Blender.should_show_panel(context, "ALIGNMENTS", cls.bl_idname): + return False + return is_ifc4x3() + + def draw(self, context): + layout = self.layout + col = layout.column(align=True) + col.operator("align.add_alignment", icon="ADD") + + alignment = tool.Alignment.get_active_alignment() + row = col.row(align=True) + row.enabled = bool(alignment) + row.operator("align.draw_horizontal_alignment", icon="EYEDROPPER") + row.operator("align.remove_alignment", text="", icon="TRASH") + if not alignment: + col.label(text="Add or select an alignment first", icon="INFO") + + marker = context.active_object + is_marker = bool(marker) and _is_interior_pi_marker(marker) + markers_present = _pi_markers_present(context) + + if is_marker or markers_present: + box = layout.box() + if is_marker: + pi_data = marker.bonsai_pi_curve_marker + box.label(text=f"PI {pi_data.pi_index}", icon="EMPTY_AXIS") + box.prop(pi_data, "curve_type") + if pi_data.curve_type == "CIRCULAR": + box.prop(pi_data, "radius") + box.operator("align.apply_pi_curve", icon="CHECKMARK") + else: + box.label(text="Select a PI marker to define its curve", icon="INFO") + + if markers_present: + box.operator("align.clear_pi_markers", icon="TRASH") + + +class ALIGN_PT_alignment_stationing_authoring(Panel): + """Start station and station equations — Alignments tab. + + A from-scratch equivalent of the CIVIL tab's stationing panel: edit the + start station, and add/remove additional stationing referents (station + equations) for gaps, overlaps, or reversed stationing direction. + """ + + bl_label = "Stationing" + bl_idname = "ALIGN_PT_alignment_stationing_authoring" + bl_space_type = "PROPERTIES" + bl_region_type = "WINDOW" + bl_context = "scene" + bl_parent_id = "BIM_PT_tab_alignments" + bl_options = {"DEFAULT_CLOSED"} + + @classmethod + def poll(cls, context): + if not tool.Blender.should_show_panel(context, "ALIGNMENTS", cls.bl_idname): + return False + if not is_ifc4x3(): + return False + return bool(tool.Alignment.get_active_alignment()) + + def draw(self, context): + layout = self.layout + alignment = tool.Alignment.get_active_alignment() + if not alignment: + layout.label(text="Select an alignment", icon="INFO") + return + + ifc_file = tool.Ifc.get() + start_station = ifcopenshell.api.alignment.get_alignment_start_station(ifc_file, alignment) or 0.0 + row = layout.row(align=True) + row.label(text=f"Start: {tool.Alignment.format_station(start_station)}", icon="EMPTY_AXIS") + row.operator("align.set_start_station", text="", icon="GREASEPENCIL") + + equations = tool.Alignment.get_stationing_referents(alignment)[1:] # skip the start referent (D 0) + if equations: + layout.separator() + layout.label(text="Station Equations:") + for referent, distance_along, station, incoming_station, has_increasing in equations: + box = layout.box() + row = box.row(align=True) + label = f"D {distance_along:.2f}: {tool.Alignment.format_station(station or 0.0)}" + if incoming_station is not None: + label += f" (from {tool.Alignment.format_station(incoming_station)})" + if has_increasing is False: + label += " ↓" + row.label(text=label) + op = row.operator("align.edit_station_equation", text="", icon="GREASEPENCIL") + op.referent_id = referent.id() + op = row.operator("align.remove_station_equation", text="", icon="X") + op.referent_id = referent.id() + + layout.operator("align.add_station_equation", icon="ADD") + + +class ALIGN_PT_alignment_segments(Panel): + """Read-only segment breakdown for the selected IfcAlignment. + + Lists horizontal, vertical, and cant segments from IFC data. + Appears in the Alignments tab whenever an alignment object is active. + """ + + bl_label = "Alignment Segments" + bl_idname = "ALIGN_PT_alignment_segments" + bl_space_type = "PROPERTIES" + bl_region_type = "WINDOW" + bl_context = "scene" + bl_parent_id = "BIM_PT_tab_alignments" + bl_options = {"HIDE_HEADER"} + + @classmethod + def poll(cls, context): + if not tool.Blender.should_show_panel(context, "ALIGNMENTS", cls.bl_idname): + return False + if not is_ifc4x3(): + return False + ifc_file = tool.Ifc.get() + return bool(ifc_file and next(iter(ifc_file.by_type("IfcAlignment")), None)) + + def draw(self, context): + layout = self.layout + props = context.scene.CivilAlignmentProperties + ifc_file = tool.Ifc.get() + if not ifc_file: + return + + # --- Alignment selector dropdown (always at top, above segment boxes) --- + layout.prop(props, "active_alignment_id_str", text="", icon="CURVE_DATA") + + # Sync dropdown from viewport/outliner selection. + # - Attribute access (props.active_alignment_id_str) returns the string identifier. + # - Dict access (props["key"] = int) sets by index and bypasses the update callback, + # preventing the callback from calling bpy.ops from within a draw function. + viewport_alignment = tool.Alignment.get_active_alignment() + if viewport_alignment: + new_val = str(viewport_alignment.id()) + if props.active_alignment_id_str != new_val: + for idx, (ident, _, _) in enumerate(_alignment_enum_items(props, context)): + if ident == new_val: + props["active_alignment_id_str"] = idx + context.area.tag_redraw() # refresh the dropdown widget + break + + # Resolve which alignment to display + try: + aid = int(props.active_alignment_id_str) + except (ValueError, TypeError): + aid = 0 + + if aid == 0: + layout.label(text="Select an alignment above", icon="INFO") + return + + try: + alignment = ifc_file.by_id(aid) + except Exception: + return + if not alignment or not alignment.is_a("IfcAlignment"): + return + + # --- Horizontal layout (collect cants but draw them after vertical) --- + all_cants = [] + for rel in getattr(alignment, "IsNestedBy", []) or []: + for layout_entity in rel.RelatedObjects or []: + if layout_entity.is_a("IfcAlignmentHorizontal"): + self._draw_horizontal(layout, context, layout_entity, alignment.id()) + elif layout_entity.is_a("IfcAlignmentCant"): + all_cants.append(layout_entity) + + # --- Vertical layouts (direct + child alignments) --- + all_verticals = tool.Alignment.get_all_vertical_layouts(alignment) + + if all_verticals: + from .decorator import VerticalProfileDecorator + dec = VerticalProfileDecorator + row = layout.row(align=True) + row.label(text="Vertical Profile:", icon="FCURVE") + row.prop(props, "vertical_exaggeration", text="VE") + row.operator( + "align.show_vertical_profile", text="", + icon="GRAPH", depress=dec.is_installed, + ) + + for layout_entity in all_verticals: + self._draw_vertical(layout, context, layout_entity) + + # --- Cant layouts (after vertical) --- + for layout_entity in all_cants: + self._draw_cant(layout, context, layout_entity) + + def _segments(self, layout_entity): + for rel in getattr(layout_entity, "IsNestedBy", []) or []: + for seg in rel.RelatedObjects or []: + if seg.is_a("IfcAlignmentSegment"): + yield seg + + def _draw_horizontal(self, layout, context, layout_entity, alignment_id=0): + ifc_file = tool.Ifc.get() + props = context.scene.CivilAlignmentProperties + selected_id = props.selected_h_segment_id + expanded = _H_EXPANDED.get(alignment_id, True) + box = layout.box() + + # Collapsible header with label toggle + row = box.row(align=True) + op = row.operator( + "align.toggle_h_segments", + text="", icon="TRIA_DOWN" if expanded else "TRIA_RIGHT", emboss=False, + ) + op.alignment_id = alignment_id + row.label(text="Horizontal", icon="DRIVER_ROTATIONAL_DIFFERENCE") + row.prop(props, "show_h_segment_labels", text="", icon="FONT_DATA") + + if not expanded: + return + + # Column headers (no separate select column — index cell is the select button) + header = box.split(factor=0.08) + header.label(text="#") + h2 = header.split(factor=0.30) + h2.label(text="Type") + h3 = h2.split(factor=0.27) + h3.label(text="Length") + h4 = h3.split(factor=0.37) + h4.label(text="Radius") + h4.label(text="Bearing") + + idx = 1 + for seg in self._segments(layout_entity): + dp = seg.DesignParameters + if not dp: + continue + length = getattr(dp, "SegmentLength", 0.0) or 0.0 + if length == 0.0: + continue # zero-length terminators are invisible to users + + seg_id = seg.id() + is_selected = selected_id == seg_id + seg_type = dp.PredefinedType or "?" + r_start = getattr(dp, "StartRadiusOfCurvature", None) or 0.0 + bearing = _rad_to_bearing(dp.StartDirection) if hasattr(dp, "StartDirection") else "" + e, n = _start_en(ifc_file, dp) + + col = box.column(align=True) + col.alert = is_selected + + # Row 1: index (clickable to select), type, length, radius, bearing + row = col.split(factor=0.08) + op = row.operator( + "align.select_h_segment", + text=str(idx), + depress=is_selected, + ) + op.segment_id = seg_id + + r2 = row.split(factor=0.30) + r2.label(text=seg_type) + r3 = r2.split(factor=0.27) + r3.label(text=f"{abs(length):.2f}") + r4 = r3.split(factor=0.37) + r4.label(text=f"{abs(r_start):.1f}" if r_start else "-") + r4.label(text=bearing) + + # Row 2: start Easting / Northing + if e is not None: + sub = col.split(factor=0.08) + sub.label(text="") # align under # column + sub2 = sub.split(factor=0.50) + sub2.label(text=f"E: {e:.3f}") + sub2.label(text=f"N: {n:.3f}") + + idx += 1 + + def _draw_vertical(self, layout, context, layout_entity): + from .decorator import VerticalProfileDecorator + + dec = VerticalProfileDecorator + props = context.scene.CivilAlignmentProperties + v_id = layout_entity.id() + # Prefer the name of the alignment that owns this vertical layout. + # For CT 4.1.4.4.1.2 this is the child alignment (e.g. "Design Grade"); + # for a simple alignment it is the top-level alignment name. + label = None + for rel in getattr(layout_entity, "Nests", []) or []: + if rel.RelatingObject.is_a("IfcAlignment"): + label = rel.RelatingObject.Name + break + label = label or layout_entity.Name or f"Vertical #{v_id}" + expanded = _V_EXPANDED.get(v_id, True) + selected_v_id = props.selected_v_segment_id + + # Eye-icon uses vertical_items (populated when the profile window is open) + v_item = next((it for it in props.vertical_items if it.entity_id == v_id), None) + + box = layout.box() + row = box.row(align=True) + + # Collapsible toggle via operator (safe to call from draw) + op = row.operator( + "align.toggle_v_segments", + text="", icon="TRIA_DOWN" if expanded else "TRIA_RIGHT", emboss=False, + ) + op.entity_id = v_id + + row.label(text=label, icon="FCURVE") + + # Per-vertical eye-icon — only shown when the profile window is open + if dec.is_installed and v_item is not None: + vis_icon = "HIDE_OFF" if v_item.is_visible else "HIDE_ON" + row.prop(v_item, "is_visible", text="", icon=vis_icon, emboss=False) + + # Per-vertical label toggle — only shown when the profile window is open + if dec.is_installed and v_item is not None: + row.prop(v_item, "show_labels", text="", icon="FONT_DATA") + + if not expanded: + return + + # Column headers (index cell is the select button) + header = box.split(factor=0.08) + header.label(text="#") + h2 = header.split(factor=0.32) + h2.label(text="Type") + h3 = h2.split(factor=0.28) + h3.label(text="H-Length") + h4 = h3.split(factor=0.45) + h4.label(text="G In") + h4.label(text="G Out") + + idx = 1 + for seg in self._segments(layout_entity): + dp = seg.DesignParameters + if not dp: + continue + seg_type = dp.PredefinedType or "?" + h_len = getattr(dp, "HorizontalLength", 0.0) or 0.0 + g_start = getattr(dp, "StartGradient", 0.0) or 0.0 + g_end = getattr(dp, "EndGradient", 0.0) or 0.0 + dist_along = getattr(dp, "StartDistAlong", None) + start_height = getattr(dp, "StartHeight", None) + seg_id = seg.id() + is_v_selected = selected_v_id == seg_id + + col = box.column(align=True) + col.alert = is_v_selected + + # Row 1: index (clickable to select), type, length, grades + row = col.split(factor=0.08) + op = row.operator( + "align.select_v_segment", + text=str(idx), + depress=is_v_selected, + ) + op.segment_id = seg_id + + r2 = row.split(factor=0.32) + r2.label(text=seg_type[:14]) + r3 = r2.split(factor=0.28) + r3.label(text=f"{h_len:.2f}") + r4 = r3.split(factor=0.45) + r4.label(text=f"{g_start * 100:.3f}%") + r4.label(text=f"{g_end * 100:.3f}%") + + # Row 2: start distance along + elevation + if dist_along is not None or start_height is not None: + sub = col.split(factor=0.08) + sub.label(text="") + sub2 = sub.split(factor=0.50) + sub2.label(text=f"Dist: {dist_along:.2f}" if dist_along is not None else "") + sub2.label(text=f"Elev: {start_height:.3f}" if start_height is not None else "") + + idx += 1 + + def _draw_cant(self, layout, context, layout_entity): + from .decorator import VerticalProfileDecorator + + dec = VerticalProfileDecorator + props = context.scene.CivilAlignmentProperties + c_id = layout_entity.id() + label = layout_entity.Name or f"Cant #{c_id}" + expanded = _C_EXPANDED.get(c_id, True) + selected_c_id = props.selected_cant_segment_id + + c_item = next((it for it in props.cant_items if it.entity_id == c_id), None) + + box = layout.box() + row = box.row(align=True) + + op = row.operator( + "align.toggle_cant_segments", + text="", icon="TRIA_DOWN" if expanded else "TRIA_RIGHT", emboss=False, + ) + op.entity_id = c_id + row.label(text=label, icon="MOD_CURVE") + + if dec.is_installed and c_item is not None: + vis_icon = "HIDE_OFF" if c_item.is_visible else "HIDE_ON" + row.prop(c_item, "is_visible", text="", icon=vis_icon, emboss=False) + + row.prop(props, "show_cant_segment_labels", text="", icon="FONT_DATA") + + if not expanded: + return + + # Column headers (# is the select button) + header = box.split(factor=0.08) + header.label(text="#") + h2 = header.split(factor=0.30) + h2.label(text="Type") + h3 = h2.split(factor=0.27) + h3.label(text="Length") + h4 = h3.split(factor=0.37) + h4.label(text="Start L / R") + h4.label(text="End L / R") + + def _cant_pair(left, right): + # cant is each rail's deviating elevation; show both, sign preserved + return f"{left * 1000:.0f} / {right * 1000:.0f}" + + idx = 1 + for seg in self._segments(layout_entity): + dp = seg.DesignParameters + if not dp: + continue + seg_type = dp.PredefinedType or "?" + h_len = getattr(dp, "HorizontalLength", None) + if h_len is None: + h_len = getattr(dp, "Length", 0.0) or 0.0 + start_l = getattr(dp, "StartCantLeft", None) or 0.0 + start_r = getattr(dp, "StartCantRight", None) or 0.0 + end_l = getattr(dp, "EndCantLeft", None) + end_r = getattr(dp, "EndCantRight", None) + end_l = start_l if end_l is None else end_l + end_r = start_r if end_r is None else end_r + seg_id = seg.id() + is_c_selected = selected_c_id == seg_id + + col = box.column(align=True) + col.alert = is_c_selected + + row = col.split(factor=0.08) + op = row.operator( + "align.select_cant_segment", + text=str(idx), + depress=is_c_selected, + ) + op.segment_id = seg_id + + r2 = row.split(factor=0.30) + r2.label(text=seg_type[:14]) + r3 = r2.split(factor=0.27) + r3.label(text=f"{h_len:.2f}" if h_len else "-") + r4 = r3.split(factor=0.37) + r4.label(text=_cant_pair(start_l, start_r)) + r4.label(text=_cant_pair(end_l, end_r)) + + idx += 1 diff --git a/src/bonsai/bonsai/bim/module/alignment/workspace.py b/src/bonsai/bonsai/bim/module/alignment/workspace.py new file mode 100644 index 0000000000..ec7848bd55 --- /dev/null +++ b/src/bonsai/bonsai/bim/module/alignment/workspace.py @@ -0,0 +1,72 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2020, 2021 Dion Moult , 2026 Michael Yoder +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . + +import os + +import bpy +from bpy.types import WorkSpaceTool + +import bonsai.tool as tool + + +class AlignmentTool(WorkSpaceTool): + bl_space_type = "VIEW_3D" + bl_context_mode = "OBJECT" + bl_idname = "bim.alignment_tool" + bl_label = "Alignment" + bl_description = "Civil alignment tools — create and edit horizontal alignments using PI method" + bl_icon = os.path.join(os.path.dirname(__file__), "ops.authoring.alignment") + bl_widget = None + bl_keymap = tool.Blender.get_default_selection_keypmap() + + def draw_settings( + context: bpy.types.Context, + layout: bpy.types.UILayout, + workspace_tool: bpy.types.WorkSpaceTool, + ) -> None: + if context.region.type == "TOOL_HEADER": + _draw_header(layout) + else: + _draw_sidebar(layout) + + +def _draw_header(layout): + """Compact icon-only layout for the tool header bar.""" + row = layout.row(align=True) + row.operator("bim.import_alignment_csv", text="", icon="IMPORT") + row.operator("align.pick_pi_from_viewport", text="", icon="EYEDROPPER") + row.separator() + row.operator("align.recalculate_pis", text="", icon="FILE_REFRESH") + + +def _draw_sidebar(layout): + """Expanded layout for the sidebar / N-panel.""" + # -- Horizontal Alignment -- + col = layout.column(align=True) + col.label(text="Horizontal Alignment", icon="CURVE_DATA") + col.operator("align.create_alignment_by_pis", icon="ADD") + col.operator("bim.import_alignment_csv", icon="IMPORT") + col.separator() + col.operator("align.pick_pi_from_viewport", icon="EYEDROPPER") + col.operator("align.enter_pi_edit_mode", text="Edit PIs", icon="EDITMODE_HLT") + row = col.row(align=True) + row.operator("align.recalculate_pis", text="Visualize", icon="FILE_REFRESH") + row.operator("align.clear_pis", text="Clear", icon="TRASH") + col.separator() + col.operator("align.add_stationing_referent", icon="EMPTY_AXIS") + col.operator("align.name_segments", icon="FONT_DATA") diff --git a/src/bonsai/bonsai/bim/module/root/data.py b/src/bonsai/bonsai/bim/module/root/data.py index c1988c8b82..eaa2636217 100644 --- a/src/bonsai/bonsai/bim/module/root/data.py +++ b/src/bonsai/bonsai/bim/module/root/data.py @@ -135,6 +135,11 @@ class IfcClassData: ("EMPTY", "No Geometry", "Start with an empty object"), ] + if ifc_class == "IfcAlignment": + # Alignment representations come from ifcopenshell.api.alignment + # (composite curves), not from a mesh template. + return templates + if ifc_class in ("IfcWindowType", "IfcWindowStyle", "IfcWindow"): templates.extend([None, ("WINDOW", "Window", "Parametric window")]) elif ifc_class in ("IfcDoorType", "IfcDoorStyle", "IfcDoor"): diff --git a/src/bonsai/bonsai/bim/module/root/operator.py b/src/bonsai/bonsai/bim/module/root/operator.py index fd79c71875..58157a9edf 100644 --- a/src/bonsai/bonsai/bim/module/root/operator.py +++ b/src/bonsai/bonsai/bim/module/root/operator.py @@ -575,6 +575,29 @@ class AddElement(bpy.types.Operator, tool.Ifc.Operator): ) element.Description = props.description or None + if props.ifc_class == "IfcAlignment": + # Saikei: creating an alignment automatically creates its + # horizontal layout (spec 1.1). Alignments own an origin local + # placement (IFC 4.1.4.1.1 aggregates them to the project, never + # to a spatial container) and their representations come from + # ifcopenshell.api.alignment, so the representation templates and + # 3D-cursor placement do not apply. + obj.location = (0.0, 0.0, 0.0) + h_layout = tool.Alignment.add_horizontal_layout_to_alignment(element) + tool.Alignment.create_object_for_layout(h_layout, obj) + civil_props = context.scene.CivilAlignmentProperties + civil_props.active_alignment_id = element.id() + civil_props.active_alignment_name = element.Name or "" + bpy.context.view_layer.update() + bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj) + tool.Blender.set_active_object(obj) + self.report( + {"INFO"}, + f"Alignment '{element.Name}' created with an empty horizontal layout — " + "add PI points from the CIVIL tab or viewport picking.", + ) + return + if representation_template == "EMTPY" or not ifc_context: pass elif representation_template == "OBJ" and props.representation_obj: diff --git a/src/bonsai/bonsai/bim/prop.py b/src/bonsai/bonsai/bim/prop.py index 4c8f56fdba..0078124c23 100644 --- a/src/bonsai/bonsai/bim/prop.py +++ b/src/bonsai/bonsai/bim/prop.py @@ -530,6 +530,8 @@ def get_tab( ("PROJECT", "Project Overview", "", bonsai.bim.icons[icon_key].icon_id, 0), ("OBJECT", "Object Information", "", "FILE_3D", 1), ("GEOMETRY", "Geometry and Materials", "", "MATERIAL", 2), + ("CIVIL", "Civil Infrastructure", "", "CURVE_DATA", 11), + ("ALIGNMENTS", "Alignments", "", "ANIM_DATA", 12), ("DRAWINGS", "Drawings and Documents", "", "DOCUMENTS", 3), ("SERVICES", "Services and Systems", "", "NETWORK_DRIVE", 4), ("STRUCTURE", "Structural Analysis", "", "EDITMODE_HLT", 5), diff --git a/src/bonsai/bonsai/bim/ui.py b/src/bonsai/bonsai/bim/ui.py index 30a6e43acd..a457507953 100644 --- a/src/bonsai/bonsai/bim/ui.py +++ b/src/bonsai/bonsai/bim/ui.py @@ -1640,6 +1640,43 @@ class BIM_PT_tab_profiles(Panel): pass +# Civil Infrastructure tab panels +class BIM_PT_tab_horizontal_alignment(Panel): + bl_idname = "BIM_PT_tab_horizontal_alignment" + bl_label = "Horizontal Alignment" + bl_space_type = "PROPERTIES" + bl_region_type = "WINDOW" + bl_context = "scene" + bl_order = 1 + bim_tab_name = "CIVIL" + + @classmethod + def poll(cls, context): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get(): + return True + + def draw(self, context): + pass + + +class BIM_PT_tab_alignments(Panel): + bl_idname = "BIM_PT_tab_alignments" + bl_label = "Alignments" + bl_space_type = "PROPERTIES" + bl_region_type = "WINDOW" + bl_context = "scene" + bl_order = 1 + bim_tab_name = "ALIGNMENTS" + + @classmethod + def poll(cls, context): + if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get(): + return True + + def draw(self, context): + pass + + class BIM_PT_tab_sheets(Panel): bl_idname = "BIM_PT_tab_sheets" bl_label = "Sheets" @@ -1826,6 +1863,8 @@ class UIData: ("PROJECT", bonsai.bim.icons[f"{color_mode}_ifc"].icon_id, True), ("OBJECT", "FILE_3D", is_ifc_project), ("GEOMETRY", "MATERIAL", is_ifc_project), + ("CIVIL", "CURVE_DATA", is_ifc_project), + ("ALIGNMENTS", "ANIM_DATA", is_ifc_project), ("DRAWINGS", "DOCUMENTS", is_ifc_project), ("SERVICES", "NETWORK_DRIVE", is_ifc_project), ("STRUCTURE", "EDITMODE_HLT", is_ifc_project), diff --git a/src/bonsai/bonsai/core/alignment.py b/src/bonsai/bonsai/core/alignment.py new file mode 100644 index 0000000000..680a649a80 --- /dev/null +++ b/src/bonsai/bonsai/core/alignment.py @@ -0,0 +1,266 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2025, 2026 Michael Yoder +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . + + +"""Core alignment business logic - Orchestration only, NO bpy imports. + +This module contains alignment-related business logic and workflow +orchestration. All calculations, algorithms, and IFC operations are +in the tool layer. Functions receive tool classes as parameters +following Bonsai's dependency injection pattern. + +NOTE: Math, calculations, algorithms, and IFC API calls belong in +tool/alignment.py. This module only handles: +- Business rules and validation +- Workflow orchestration (calling tool methods in sequence) +- Decision-making about what should happen +""" + +from __future__ import annotations +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import ifcopenshell + from .. import tool + + +# ============================================================================= +# Alignment Creation +# ============================================================================= + + +def create_alignment( + ifc_tool: "type[tool.Ifc]", + alignment_tool: "type[tool.Alignment]", + name: str, + start_station: float = 0.0, +) -> "ifcopenshell.entity_instance": + """Create a new alignment with full IFC structure. + + Business rules: + 1. An IFC file must be loaded + 2. Name must not be empty + 3. Delegates to tool layer for IFC creation and Blender hierarchy + + Args: + ifc_tool: The IFC tool class + alignment_tool: The Alignment tool class + name: The alignment name + start_station: Starting station value + + Returns: + The created IfcAlignment entity + + Raises: + ValueError: If no IFC file is loaded or name is empty + """ + if ifc_tool.get() is None: + raise ValueError("No IFC file loaded") + + if not name or not name.strip(): + raise ValueError("Alignment name cannot be empty") + + return alignment_tool.create_alignment(name.strip(), start_station) + + +# ============================================================================= +# PI Edit Mode Functions +# ============================================================================= + + +def enter_pi_edit_mode( + ifc_tool: "type[tool.Ifc]", + alignment_tool: "type[tool.Alignment]", + alignment_id: int, +) -> list: + """Enter PI edit mode for an alignment. + + Business logic for entering PI edit mode: + 1. Validates that the alignment exists + 2. Validates that the alignment has a horizontal layout with real segments + 3. Back-calculates PI positions from segments + 4. Creates temporary EMPTY objects at each PI location + + Args: + ifc_tool: The IFC tool class + alignment_tool: The Alignment tool class + alignment_id: The IFC ID of the alignment to edit + + Returns: + List of created PI EMPTY objects + + Raises: + ValueError: If alignment doesn't exist, has no horizontal layout, + or has no real segments + """ + # Validate alignment exists + ifc_file = ifc_tool.get() + if ifc_file is None: + raise ValueError("No IFC file loaded") + + try: + alignment = ifc_file.by_id(alignment_id) + except RuntimeError: + raise ValueError(f"Alignment with ID {alignment_id} not found") + + if not alignment.is_a("IfcAlignment"): + raise ValueError(f"Entity {alignment_id} is not an IfcAlignment") + + # Validate alignment has horizontal layout (delegated to tool) + h_layout = alignment_tool.get_horizontal_layout(alignment) + if h_layout is None: + raise ValueError(f"Alignment '{alignment.Name}' has no horizontal layout") + + # Validate layout has real segments (not just zero-length terminator) + if not alignment_tool.layout_has_real_segments(h_layout): + raise ValueError(f"Alignment '{alignment.Name}' has no editable segments") + + # Back-calculate PI positions from segments + pis = alignment_tool.back_calculate_pis_from_alignment(alignment) + + if len(pis) < 2: + raise ValueError(f"Alignment '{alignment.Name}' must have at least 2 PIs") + + # Create temporary EMPTY objects at each PI location + empties = alignment_tool.create_pi_edit_empties(alignment, pis) + + return empties + + +def import_alignment_csv( + ifc_tool: "type[tool.Ifc]", + alignment_tool: "type[tool.Alignment]", + filepath: str, +): + """Import alignment(s) from a CSV file and build their viewport objects. + + Business rules: + 1. An IFC file must be loaded + 2. The CSV may carry one horizontal row plus any number of vertical rows; + extra verticals arrive as aggregated child alignments and each child + gets its own viewport hierarchy + 3. Referents generated by the import are materialized as empties + + Args: + ifc_tool: The IFC tool class + alignment_tool: The Alignment tool class + filepath: Path to the CSV file + + Returns: + The imported (parent) IfcAlignment entity + + Raises: + ValueError: If no IFC file is loaded + """ + ifc_file = ifc_tool.get() + if ifc_file is None: + raise ValueError("No IFC file loaded") + + alignment = alignment_tool.create_alignment_from_csv(filepath) + + alignment_tool.create_hierarchy_for_alignment(alignment) + parent_obj = ifc_tool.get_object(alignment) + for child in alignment_tool.get_child_alignments(alignment): + # Child alignments (IFC CT 4.1.4.4.1.2) are vertical-only wrappers. + # We skip creating a Blender empty for them so they don't clutter the + # scene collection. Their vertical layouts go under the parent object. + alignment_tool.create_child_vertical_hierarchy(child, parent_obj) + alignment_tool.create_objects_for_referents(alignment) + + return alignment + + +def exit_pi_edit_mode( + ifc_tool: "type[tool.Ifc]", + alignment_tool: "type[tool.Alignment]", + alignment_id: int, + apply: bool, +) -> bool: + """Exit PI edit mode for an alignment. + + Business logic for exiting PI edit mode: + 1. If apply=True: + - Collect new PI positions from empties + - Validate the new configuration + - Update alignment segments in-place (preserves alignment ID) + 2. Always: + - Remove temporary EMPTY objects + - Return success status + + This function modifies the alignment segments in-place rather than + deleting and recreating the alignment. This preserves the alignment's + IFC entity ID, preventing stale reference issues. + + Args: + ifc_tool: The IFC tool class + alignment_tool: The Alignment tool class + alignment_id: The IFC ID of the alignment being edited + apply: If True, update alignment with new PI positions + + Returns: + True if successful + + Raises: + ValueError: If alignment doesn't exist or update fails + """ + ifc_file = ifc_tool.get() + if ifc_file is None: + # No file loaded, just clean up empties + alignment_tool.remove_pi_edit_empties(alignment_id) + return True + + # Get alignment + try: + alignment = ifc_file.by_id(alignment_id) + except RuntimeError: + # Alignment was deleted, just clean up empties + alignment_tool.remove_pi_edit_empties(alignment_id) + return True + + if apply: + # Collect PI positions from empties + hpoints, radii = alignment_tool.collect_pis_from_empties(alignment_id) + + if len(hpoints) < 2: + raise ValueError("At least 2 PIs are required") + + # Get horizontal layout (delegated to tool) + h_layout = alignment_tool.get_horizontal_layout(alignment) + if h_layout is None: + raise ValueError("Alignment has no horizontal layout") + + # Remove empties before modifying segments + alignment_tool.remove_pi_edit_empties(alignment_id) + + # Remove Blender visualization for segments (not the whole hierarchy) + alignment_tool.remove_layout_segment_objects(h_layout) + + # Clear existing IFC segments and add new ones (delegated to tool) + alignment_tool.clear_layout_segments(h_layout) + alignment_tool.layout_by_pi_method(h_layout, hpoints, radii) + + # Refresh Blender visualization for new segments + layout_obj = ifc_tool.get_object(h_layout) + if layout_obj: + alignment_tool.create_objects_for_layout_segments(h_layout, layout_obj) + + return True + else: + # Cancel - just remove empties without regenerating + alignment_tool.remove_pi_edit_empties(alignment_id) + return True diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py index 7ba5e715db..5e9617adf1 100644 --- a/src/bonsai/bonsai/core/tool.py +++ b/src/bonsai/bonsai/core/tool.py @@ -1279,3 +1279,32 @@ class Wall: @interface class Web: pass + + +# ############################################################################ # + +# Saikei Civil - horizontal infrastructure modules. + + +@interface +class Alignment: + # Alignment creation + def create_alignment(cls, name, start_station=0.0): pass + # Horizontal PI edit mode + def back_calculate_pis_from_alignment(cls, alignment): pass + def clear_layout_segments(cls, h_layout): pass + def collect_pis_from_empties(cls, alignment_id): pass + def create_objects_for_layout_segments(cls, h_layout, layout_obj): pass + def create_pi_edit_empties(cls, alignment, pis): pass + def get_horizontal_layout(cls, alignment): pass + def layout_by_pi_method(cls, h_layout, hpoints, radii): pass + def layout_has_real_segments(cls, h_layout): pass + def remove_layout_segment_objects(cls, h_layout): pass + def remove_pi_edit_empties(cls, alignment_id): pass + # Stationing + def format_station(cls, station): pass + # CSV import + def create_alignment_from_csv(cls, filepath): pass + def create_hierarchy_for_alignment(cls, alignment): pass + def get_child_alignments(cls, alignment): pass + def create_objects_for_referents(cls, alignment): pass diff --git a/src/bonsai/bonsai/tool/__init__.py b/src/bonsai/bonsai/tool/__init__.py index ff08f82309..ac21df7d53 100644 --- a/src/bonsai/bonsai/tool/__init__.py +++ b/src/bonsai/bonsai/tool/__init__.py @@ -20,6 +20,7 @@ # ruff: file-ignore[unused-import] from bonsai.tool.aggregate import Aggregate +from bonsai.tool.alignment import Alignment from bonsai.tool.array import Array from bonsai.tool.attribute import Attribute from bonsai.tool.bcf import Bcf diff --git a/src/bonsai/bonsai/tool/alignment.py b/src/bonsai/bonsai/tool/alignment.py new file mode 100644 index 0000000000..87e861c588 --- /dev/null +++ b/src/bonsai/bonsai/tool/alignment.py @@ -0,0 +1,1697 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2025, 2026 Michael Yoder +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . + + +"""Alignment Tool - Blender implementations for alignment visualization. + +This module contains Blender-specific code for creating and managing +alignment objects in the 3D view. It bridges the core business logic +to the Blender environment. + +All methods are classmethods following Bonsai's tool pattern. +""" + +from __future__ import annotations +import bpy +import math +import logging +import numpy as np +import bonsai.tool as tool +import bonsai.bim.import_ifc +import ifcopenshell.api.alignment +import ifcopenshell.util.shape +from typing import TYPE_CHECKING, Optional, List, Tuple +from dataclasses import dataclass + +if TYPE_CHECKING: + import ifcopenshell + + +# ============================================================================= +# Data Classes for PI Geometry Results +# ============================================================================= + + +@dataclass +class PIGeometryResult: + """Result of PI geometry calculation.""" + + stations: List[float] + lengths: List[float] + directions: List[float] + total_length: float + + +class Alignment: + """Tool class for alignment-related Blender operations. + + Following Bonsai's tool pattern, all methods are classmethods + that can be called without instantiation. + """ + + # ========================================================================= + # Geometry Calculation Methods + # ========================================================================= + + @classmethod + def calculate_pi_geometry(cls, pis: List[Tuple[float, float]], start_station: float = 0.0) -> PIGeometryResult: + """Calculate lengths, stations, and directions for a list of PI points. + + Args: + pis: List of (x, y) coordinate tuples for each PI + start_station: Starting station value + + Returns: + PIGeometryResult containing calculated values + """ + if len(pis) < 2: + return PIGeometryResult( + stations=[start_station] if pis else [], + lengths=[0.0] if pis else [], + directions=[0.0] if pis else [], + total_length=0.0, + ) + + stations = [] + lengths = [] + directions = [] + cumulative_length = start_station + + for i, pi in enumerate(pis): + stations.append(cumulative_length) + + if i < len(pis) - 1: + next_pi = pis[i + 1] + dx = next_pi[0] - pi[0] + dy = next_pi[1] - pi[1] + length = math.sqrt(dx * dx + dy * dy) + direction = math.atan2(dy, dx) + lengths.append(length) + directions.append(direction) + cumulative_length += length + else: + lengths.append(0.0) + directions.append(0.0) + + total_length = cumulative_length - start_station + + return PIGeometryResult(stations=stations, lengths=lengths, directions=directions, total_length=total_length) + + @classmethod + def calculate_tangent_length(cls, radius: float, deflection_angle: float) -> float: + """Calculate tangent length for a circular curve. + + T = R * tan(Δ/2) + + Args: + radius: Curve radius + deflection_angle: Deflection angle in radians + + Returns: + Tangent length + """ + if deflection_angle == 0 or radius == 0: + return 0.0 + return radius * math.tan(deflection_angle / 2) + + @classmethod + def calculate_arc_length(cls, radius: float, deflection_angle: float) -> float: + """Calculate arc length for a circular curve. + + L = R * Δ + + Args: + radius: Curve radius + deflection_angle: Deflection angle in radians + + Returns: + Arc length + """ + return radius * deflection_angle + + @classmethod + def deflection_angle_from_points( + cls, p1: Tuple[float, float], p2: Tuple[float, float], p3: Tuple[float, float] + ) -> float: + """Calculate deflection angle at p2 from three (e, n) coordinate tuples. + + Args: + p1: Previous PI coordinates (e, n) + p2: Current PI coordinates (e, n) + p3: Next PI coordinates (e, n) + + Returns: + Deflection angle in radians (signed: positive=left, negative=right) + """ + dx1 = p2[0] - p1[0] + dy1 = p2[1] - p1[1] + incoming = math.atan2(dy1, dx1) + + dx2 = p3[0] - p2[0] + dy2 = p3[1] - p2[1] + outgoing = math.atan2(dy2, dx2) + + delta = outgoing - incoming + while delta > math.pi: + delta -= 2 * math.pi + while delta < -math.pi: + delta += 2 * math.pi + return delta + + @classmethod + def arc_length_at_pi( + cls, + p1: Tuple[float, float], + p2: Tuple[float, float], + p3: Tuple[float, float], + radius: float, + ) -> float: + """Calculate arc length L = R * |delta| at a PI with curve. + + Args: + p1, p2, p3: (e, n) coordinate tuples for prev, current, next PI + radius: Curve radius (must be > 0) + + Returns: + Arc length + """ + if radius <= 0: + return 0.0 + deflection = cls.deflection_angle_from_points(p1, p2, p3) + return cls.calculate_arc_length(radius, abs(deflection)) + + @classmethod + def tangent_length_at_pi( + cls, + p1: Tuple[float, float], + p2: Tuple[float, float], + p3: Tuple[float, float], + radius: float, + ) -> float: + """Calculate tangent length T = R * tan(|delta|/2) at a PI. + + Args: + p1, p2, p3: (e, n) coordinate tuples for prev, current, next PI + radius: Curve radius (must be > 0) + + Returns: + Tangent length + """ + if radius <= 0: + return 0.0 + deflection = cls.deflection_angle_from_points(p1, p2, p3) + return cls.calculate_tangent_length(radius, abs(deflection)) + + @classmethod + def tangent_segment_length( + cls, + p_start: Tuple[float, float], + p_end: Tuple[float, float], + start_tangent: float = 0.0, + end_tangent: float = 0.0, + ) -> float: + """Calculate tangent segment length between two PIs, minus curve tangent lengths. + + Args: + p_start: (e, n) coordinate tuple for start PI + p_end: (e, n) coordinate tuple for end PI + start_tangent: Tangent length to subtract at start + end_tangent: Tangent length to subtract at end + + Returns: + Net segment length (clamped to 0) + """ + dx = p_end[0] - p_start[0] + dy = p_end[1] - p_start[1] + full_length = math.sqrt(dx * dx + dy * dy) + return max(0.0, full_length - start_tangent - end_tangent) + + # ========================================================================= + # PI Extraction from IFC Segments + # ========================================================================= + + @classmethod + def _get_segment_vertices_in_model_units( + cls, ifc_file: "ifcopenshell.file", segment: "ifcopenshell.entity_instance" + ): + """Get segment control points (Start, End, TI, NI) in model units. + + Wraps ifcopenshell.api.alignment.segment_vertices() with: + - Backward-compatible fallback for segments without Axis/Segment + representation (falls back to IfcCurveSegment via get_mapped_segments) + - Unit conversion (geometry engine returns SI; we need model units) + + Args: + ifc_file: The IFC file + segment: An IfcAlignmentSegment entity + + Returns: + Tuple of (start, end, ti, ni) where each is (x, y) in model units, + or None for ti/ni when lines are parallel. + Returns None if segment cannot be evaluated. + """ + import ifcopenshell.api.alignment as align_api + import ifcopenshell.util.unit + + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file) + + def convert(point): + if point is None: + return None + return (point[0] / unit_scale, point[1] / unit_scale) + + result = align_api.segment_vertices(ifc_file, segment) + if result is None: + return None + start, end, ti, ni = result + + return (convert(start), convert(end), convert(ti), convert(ni)) + + @classmethod + def extract_pis_from_segments(cls, segments): + """Extract PI data from IFC alignment segments. + + Uses ifcopenshell.api.alignment.segment_vertices() to extract + PI (tangent intersection) points from segment geometry. + + Args: + segments: List of IfcAlignmentSegment entities + + Returns: + List of dicts with keys: e, n, pi_type, radius + """ + ifc_file = tool.Ifc.get() + + # Filter out zero-length terminal segments + real_segments = [seg for seg in segments if not cls.is_zero_length_segment(seg)] + if not real_segments: + return [] + + # Get vertices for all segments + seg_vertices = [cls._get_segment_vertices_in_model_units(ifc_file, seg) for seg in real_segments] + + pis = [] + + # First PI: start of first segment + if seg_vertices[0] is not None: + start_pt = seg_vertices[0][0] + pis.append({"e": start_pt[0], "n": start_pt[1], "pi_type": "ENDPOINT", "radius": 0.0}) + + # Process interior PIs + prev_is_line = True + for i, (seg, verts) in enumerate(zip(real_segments, seg_vertices)): + if verts is None: + prev_is_line = False + continue + + start, end, ti, ni = verts + dp = seg.DesignParameters + + if ti is not None: + # Curve segment: TI is the PI + radius = abs(float(dp.StartRadiusOfCurvature or dp.EndRadiusOfCurvature or 0)) + pis.append({"e": ti[0], "n": ti[1], "pi_type": "CURVE", "radius": radius}) + prev_is_line = False + else: + # Line segment: if previous was also a line, connection = tangent PI + if i > 0 and prev_is_line: + pis.append({"e": start[0], "n": start[1], "pi_type": "TANGENT", "radius": 0.0}) + prev_is_line = True + + # Last PI: end of last segment + if seg_vertices[-1] is not None: + end_pt = seg_vertices[-1][1] + if pis: + last = pis[-1] + dist = ((end_pt[0] - last["e"]) ** 2 + (end_pt[1] - last["n"]) ** 2) ** 0.5 + if dist > 0.001: + pis.append({"e": end_pt[0], "n": end_pt[1], "pi_type": "ENDPOINT", "radius": 0.0}) + else: + pis.append({"e": end_pt[0], "n": end_pt[1], "pi_type": "ENDPOINT", "radius": 0.0}) + + return pis + + # ========================================================================= + # IFC API Wrappers (for core layer delegation) + # ========================================================================= + + @classmethod + def get_horizontal_layout(cls, alignment: "ifcopenshell.entity_instance"): + """Get the IfcAlignmentHorizontal layout from an alignment. + + Args: + alignment: The IfcAlignment entity + + Returns: + The IfcAlignmentHorizontal entity, or None + """ + import ifcopenshell.api.alignment as align_api + + return align_api.get_horizontal_layout(alignment) + + @classmethod + def create_alignment(cls, name: str, start_station: float = 0.0) -> "ifcopenshell.entity_instance": + """Create a full IfcAlignment with horizontal layout via the alignment API. + + Creates the complete IFC structure: IfcAlignment, IfcAlignmentHorizontal, + stationing referent, geometric representation, and zero-length terminal. + Also creates the Blender object for the alignment itself — but not for + its (still segment-less) horizontal layout: create_hierarchy_for_alignment() + would create that eagerly, leaving a stray "Layout" object with no + segments in the scene before anything has actually been drawn, unlike + a loaded file which never has one until it's meaningful. Whichever + drawing flow adds real segments next (CIVIL's PI picker or the + Alignments tab's draw tool) creates the layout object lazily, once + there's something to show. + + Args: + name: The alignment name + start_station: Starting station value (default 0.0) + + Returns: + The created IfcAlignment entity + """ + import ifcopenshell.api.alignment as align_api + import ifcopenshell.util.alignment + + ifc_file = tool.Ifc.get() + # align_api.create() no longer takes start_station (it doesn't define + # stationing at all — see its docstring) — add the starting referent + # ourselves, same as add_horizontal_layout_to_alignment() does for the + # bare/Add-Element bootstrap path below. + alignment = align_api.create(ifc_file, name=name) + station_string = ifcopenshell.util.alignment.station_as_string(ifc_file, start_station) + referent_name = f"{alignment.Name or 'Alignment'} {station_string}" + align_api.add_stationing_referent(ifc_file, referent_name, alignment, 0.0, start_station) + cls.create_object_for_alignment(alignment) + return alignment + + @classmethod + def add_horizontal_layout_to_alignment( + cls, alignment: "ifcopenshell.entity_instance" + ) -> "ifcopenshell.entity_instance": + """Add an IfcAlignmentHorizontal layout to a bare IfcAlignment. + + Creates the nested horizontal layout, zero-length terminal segment, + and geometric representation using the alignment API. Use this to + bootstrap an alignment created via Add Element (which has no layouts). + + Args: + alignment: A bare IfcAlignment entity with no horizontal layout. + + Returns: + The newly created IfcAlignmentHorizontal entity. + """ + import ifcopenshell.api.aggregate + import ifcopenshell.api.alignment as align_api + import ifcopenshell.api.nest + import ifcopenshell.util.alignment + from ifcopenshell.api.alignment._add_zero_length_segment import ( + _add_zero_length_segment, + ) + from ifcopenshell.api.alignment._create_geometric_representation import ( + _create_geometric_representation, + ) + + ifc_file = tool.Ifc.get() + + # Mirrors align_api.create()'s sequence for an alignment entity that + # already exists (Add Element creates the bare IfcAlignment first). + + # create() gives the alignment an origin local placement; a bare + # Add Element entity may not have one yet. + if alignment.ObjectPlacement is None: + alignment.ObjectPlacement = ifc_file.createIfcLocalPlacement( + PlacementRelTo=None, + RelativePlacement=ifc_file.createIfcAxis2Placement2D( + Location=ifc_file.createIfcCartesianPoint(Coordinates=(0.0, 0.0)) + ), + ) + + # Create and nest the horizontal layout + h_layout = ifc_file.createIfcAlignmentHorizontal(GlobalId=ifcopenshell.guid.new()) + ifcopenshell.api.nest.assign_object(ifc_file, related_objects=[h_layout], relating_object=alignment) + + # Create geometric representation (curves) for the alignment + _create_geometric_representation(ifc_file, alignment) + + # Stationing referent (required by the segment-creation API), using + # the upstream " " naming convention. + start_station = 0.0 + station_string = ifcopenshell.util.alignment.station_as_string(ifc_file, start_station) + referent_name = f"{alignment.Name or 'Alignment'} {station_string}" + align_api.add_stationing_referent(ifc_file, referent_name, alignment, 0.0, start_station) + + # Zero-length terminal segment (semantic + geometric) + _add_zero_length_segment(ifc_file, h_layout) + + # IFC 4.1.4.1.1 Alignment Aggregation To Project + project = next(iter(ifc_file.by_type("IfcProject")), None) + if project is not None: + ifcopenshell.api.aggregate.assign_object(ifc_file, products=[alignment], relating_object=project) + + return h_layout + + @classmethod + def clear_layout_segments(cls, layout: "ifcopenshell.entity_instance"): + """Clear the real (non-terminator) segments from a layout. + + The alignment API's PI/PVI layout functions *append* segments and + expose no clear/remove helper, so editing a layout (PI/PVI recalc, edit + mode) requires removing the previous segments first. This removes both + halves of each real segment — the geometric IfcCurveSegment in the + layout's representation curve and the semantic IfcAlignmentSegment — + while preserving the layout entity and its mandatory zero-length + terminator (which the layout functions then update in place). + + Args: + layout: The IFC layout entity (IfcAlignmentHorizontal/Vertical/Cant) + """ + import ifcopenshell.api.alignment as align_api + import ifcopenshell.api.root + import ifcopenshell.util.element + + ifc_file = tool.Ifc.get() + + # 1) Remove the geometric curve segments (keep the zero-length terminator). + curve = align_api.get_layout_curve(layout) + if curve is not None and getattr(curve, "Segments", None): + kept_curve_segments = [] + dropped_curve_segments = [] + for curve_segment in curve.Segments: + segment_length = curve_segment.SegmentLength + value = float(getattr(segment_length, "wrappedValue", segment_length)) + (kept_curve_segments if abs(value) < 1e-6 else dropped_curve_segments).append(curve_segment) + curve.Segments = kept_curve_segments + for curve_segment in dropped_curve_segments: + ifcopenshell.util.element.remove_deep2(ifc_file, curve_segment) + + # 2) Remove the semantic IfcAlignmentSegments (keep the terminator). + dropped_segments = [] + for rel in getattr(layout, "IsNestedBy", []) or []: + kept_related = [] + for segment in rel.RelatedObjects or []: + if segment.is_a("IfcAlignmentSegment") and not cls.is_zero_length_segment(segment): + dropped_segments.append(segment) + else: + kept_related.append(segment) + rel.RelatedObjects = kept_related + for segment in dropped_segments: + ifcopenshell.api.root.remove_product(ifc_file, product=segment) + + @classmethod + def layout_by_pi_method(cls, layout: "ifcopenshell.entity_instance", hpoints: list, radii: list): + """Add segments to a horizontal layout using the PI method. + + Args: + layout: The IfcAlignmentHorizontal layout + hpoints: List of (E, N) coordinate pairs for PIs + radii: List of curve radii for interior PIs + """ + import ifcopenshell.api.alignment as align_api + + ifc_file = tool.Ifc.get() + align_api.layout_horizontal_alignment_by_pi_method(ifc_file, layout, hpoints, radii) + + # ========================================================================= + # Zero-Length Segment Utilities + # ========================================================================= + + @classmethod + def is_zero_length_segment(cls, segment: "ifcopenshell.entity_instance") -> bool: + """Check if a segment is a zero-length terminator segment. + + Zero-length segments are required by IFC to mark the end of an alignment + but should be invisible to users in the UI. + + Args: + segment: The IfcAlignmentSegment entity + + Returns: + True if this is a zero-length segment + """ + if not hasattr(segment, "DesignParameters") or not segment.DesignParameters: + return False + + dp = segment.DesignParameters + + # Check based on segment type + if dp.is_a("IfcAlignmentHorizontalSegment"): + return abs(dp.SegmentLength) < 1e-6 + elif dp.is_a("IfcAlignmentVerticalSegment"): + return abs(dp.HorizontalLength) < 1e-6 + elif dp.is_a("IfcAlignmentCantSegment"): + return abs(dp.HorizontalLength) < 1e-6 + + return False + + @classmethod + def layout_has_real_segments(cls, layout: "ifcopenshell.entity_instance") -> bool: + """Check if a layout has any real (non-zero-length) segments. + + An empty layout only has the mandatory zero-length terminator segment. + + Args: + layout: The IFC layout entity (IfcAlignmentHorizontal, etc.) + + Returns: + True if the layout has at least one real segment + """ + for rel in getattr(layout, "IsNestedBy", []) or []: + for segment in rel.RelatedObjects or []: + if segment.is_a("IfcAlignmentSegment"): + if not cls.is_zero_length_segment(segment): + return True + return False + + # ========================================================================= + # Blender Object Creation + # ========================================================================= + + @classmethod + def create_object_for_alignment(cls, alignment: ifcopenshell.entity_instance) -> Optional[bpy.types.Object]: + """Create a Blender object for an IFC alignment and link it properly. + + This follows Bonsai's pattern for creating Blender representations: + 1. Create a Blender Empty object + 2. Link it to the IFC element via tool.Ifc.link() + 3. Assign it to the appropriate collection via tool.Collector.assign() + + Args: + alignment: The IFC alignment entity + + Returns: + The created Blender object, or existing one if already linked + """ + # Check if a Blender object already exists for this IFC element + existing_obj = tool.Ifc.get_object(alignment) + if existing_obj: + return existing_obj + + # Create Blender Empty object with naming pattern "IfcClass/Name" + name = f"IfcAlignment/{alignment.Name or 'Unnamed'}" + obj = bpy.data.objects.new(name, None) # None = Empty object + obj.empty_display_type = "ARROWS" + obj.empty_display_size = 1.0 + + # Link the Blender object to the IFC element (creates bidirectional mapping) + tool.Ifc.link(alignment, obj) + + # Assign to appropriate collection (Bonsai handles collection hierarchy) + tool.Collector.assign(obj) + + return obj + + @classmethod + def refresh_alignment_representation_object( + cls, alignment: ifcopenshell.entity_instance + ) -> Optional[bpy.types.Object]: + """Create or refresh the single mesh object for `alignment`'s own geometry. + + `alignment` (IfcAlignment) carries its own Axis representation (the + whole composite curve — see create_representation()), so it gets + exactly one Blender mesh object, tessellated the same way any other + IFC product's representation is: this is what loading an alignment + from a file produces. It deliberately does NOT create separate + objects for the nested IfcAlignmentHorizontal/Vertical/Cant layouts + or their IfcAlignmentSegments — interactive creation used to (via + create_object_for_layout/create_objects_for_layout_segments, ported + from the CIVIL tab's segment-selection UI), which left the scene + collection looking different from a loaded file for no IFC-side + reason. Callers that still want per-segment objects for + selection/highlighting (CIVIL's tools) should keep using those + functions directly — this one is for the Alignments tab's authoring + workflow, which shows segments via ALIGN_PT_alignment_segments + instead of individual viewport objects. + + Args: + alignment: The IFC alignment entity, with a representation already + created (see ifcopenshell.api.alignment.create_representation). + + Returns: + The alignment's Blender object (existing, reloaded, or newly + created), or None if it has no representation to build a mesh + from yet. + """ + obj = tool.Ifc.get_object(alignment) + if obj is not None and obj.type == "MESH": + tool.Geometry.reload_representation(obj) + return obj + + # No object yet, or it's a bare Empty from before any geometry + # existed (Object.type can't be changed in place) — replace it. + if obj is not None: + cls._remove_blender_object(obj) + + logger = logging.getLogger("ImportIFC") + ifc_import_settings = bonsai.bim.import_ifc.IfcImportSettings.factory(bpy.context, None, logger) + ifc_importer = bonsai.bim.import_ifc.IfcImporter(ifc_import_settings) + ifc_importer.file = tool.Ifc.get() + + tool.Loader.load_settings() + geometry = tool.Loader.create_generic_shape(alignment) + if geometry is None: + return None + + mesh = ifc_importer.create_mesh(alignment, geometry) + if mesh is not None: + # Without this, the mesh has no record of which IfcRepresentation + # it came from, so a later reload_representation() (the branch + # above, once this object already exists) silently finds nothing + # to update — the IFC segments are correct (the segment listing + # panel reads them directly) but the viewport mesh never changes. + tool.Loader.link_mesh(geometry, mesh) + name = f"IfcAlignment/{alignment.Name or 'Unnamed'}" + new_obj = bpy.data.objects.new(name, mesh) + + if hasattr(geometry, "transformation_buffer"): + mat = ifcopenshell.util.shape.get_shape_matrix(geometry) + else: + mat = np.eye(4) + new_obj.matrix_world = tool.Loader.apply_blender_offset_to_matrix_world(new_obj, mat) + tool.Geometry.record_object_position(new_obj) + + tool.Ifc.link(alignment, new_obj) + tool.Collector.assign(new_obj) + + return new_obj + + @classmethod + def get_alignment_start_end_points( + cls, alignment: ifcopenshell.entity_instance + ) -> Tuple[List[float], List[float]]: + """The horizontal alignment's start and end points, in local IFC coords. + + Read directly from the current segments rather than tracked + separately: the first real segment's StartPoint is the start, and the + mandatory zero-length terminator's StartPoint sits exactly at the + end. Unlike an interior PI, neither point moves when a curve is + applied at some other PI, so there's no need to track them via a + marker object the way ALIGN_OT_apply_pi_curve's interior PI markers + do. + """ + h_layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment) + segments = list(ifcopenshell.api.alignment.get_layout_segments(h_layout)) if h_layout else [] + if not segments: + raise ValueError(f"Alignment #{alignment.id()} has no horizontal segments yet") + start = list(segments[0].DesignParameters.StartPoint.Coordinates) + end = list(segments[-1].DesignParameters.StartPoint.Coordinates) + return start, end + + @classmethod + def remove_layout_and_child_layout_objects(cls, alignment: ifcopenshell.entity_instance) -> int: + """Remove any layout/segment objects left from the old per-segment + object pipeline (create_object_for_layout / create_objects_for_layout_segments) + so redrawing via the Alignments tab converges on the single-mesh + representation refresh_alignment_representation_object() produces — + matching a loaded file — even for alignments first drawn before this + cleanup existed. + """ + removed = 0 + for rel in getattr(alignment, "IsNestedBy", []) or []: + for layout in rel.RelatedObjects or []: + if layout.is_a() not in ("IfcAlignmentHorizontal", "IfcAlignmentVertical", "IfcAlignmentCant"): + continue + removed += cls.remove_layout_segment_objects(layout) + layout_obj = tool.Ifc.get_object(layout) + if layout_obj and cls._remove_blender_object(layout_obj): + removed += 1 + return removed + + @classmethod + def create_object_for_layout( + cls, layout_entity: ifcopenshell.entity_instance, parent_obj: Optional[bpy.types.Object] = None + ) -> Optional[bpy.types.Object]: + """Create a Blender object for an IFC alignment layout. + + Args: + layout_entity: The IFC layout entity (IfcAlignmentHorizontal, etc.) + parent_obj: The parent Blender object (IfcAlignment object) + + Returns: + The created Blender object, or existing one if already linked + """ + # Check if a Blender object already exists for this IFC element + existing_obj = tool.Ifc.get_object(layout_entity) + if existing_obj: + return existing_obj + + # Determine the layout type from the IFC class + ifc_class = layout_entity.is_a() + name = f"{ifc_class}" + + obj = bpy.data.objects.new(name, None) + obj.empty_display_type = "PLAIN_AXES" + obj.empty_display_size = 0.5 + + # Link to IFC element + tool.Ifc.link(layout_entity, obj) + + # Set parent relationship in Blender (mirrors IFC nesting) + if parent_obj: + obj.parent = parent_obj + + # Assign to same collection as parent (avoid "Unsorted") + if parent_obj and parent_obj.users_collection: + parent_obj.users_collection[0].objects.link(obj) + else: + tool.Collector.assign(obj) + + return obj + + @classmethod + def _find_layout_curve(cls, layout: "ifcopenshell.entity_instance") -> Optional["ifcopenshell.entity_instance"]: + """Return the composite/gradient/reference curve for a layout. + + Extends get_layout_curve with a fallback for horizontal-only alignments + (e.g. "reusing horizontal" parents per IFC CT 4.1.4.4.1.2) that carry + only a FootPrint representation rather than an Axis one. + """ + curve = ifcopenshell.api.alignment.get_layout_curve(layout) + if curve is not None: + return curve + + if not layout.is_a("IfcAlignmentHorizontal"): + return None + + alignment = ifcopenshell.api.alignment.get_alignment(layout) + if alignment is None: + return None + + # Try FootPrint representation on the parent alignment + if alignment.Representation: + for rep in alignment.Representation.Representations: + if rep.RepresentationIdentifier == "FootPrint" and rep.Items: + item = rep.Items[0] + if item.is_a("IfcCompositeCurve"): + return item + + # Try BaseCurve of any child alignment's IfcGradientCurve + for rel in alignment.IsDecomposedBy or []: + for child in rel.RelatedObjects or []: + child_curve = ifcopenshell.api.alignment.get_curve(child) + if child_curve and child_curve.is_a("IfcGradientCurve"): + return child_curve.BaseCurve + + return None + + @classmethod + def _map_alignment_segment_to_curve_segments( + cls, + segment: "ifcopenshell.entity_instance", + layout: "ifcopenshell.entity_instance", + curve: "ifcopenshell.entity_instance", + ) -> tuple: + """Map an IfcAlignmentSegment to its IfcCurveSegment(s) in the given curve. + + Mirrors the logic of ifcopenshell.api.alignment.get_mapped_segments but + uses the caller-supplied curve so that FootPrint-derived curves work too. + """ + def _count(seg: "ifcopenshell.entity_instance") -> int: + dp = seg.DesignParameters + if dp.is_a("IfcAlignmentHorizontalSegment") or dp.is_a("IfcAlignmentCantSegment"): + return 2 if getattr(dp, "PredefinedType", None) == "HELMERTCURVE" else 1 + return 1 + + index = 0 + for seg in layout.IsNestedBy[0].RelatedObjects: + index += _count(seg) + if seg == segment: + break + + n = _count(segment) + if n == 1: + return (curve.Segments[index - 1], None) + return (curve.Segments[index - 2], curve.Segments[index - 1]) + + @classmethod + def _create_segment_curve( + cls, segment: "ifcopenshell.entity_instance", index: int, parent_obj: Optional[bpy.types.Object] = None + ) -> Optional[bpy.types.Object]: + """Create a Blender curve object for an IFC alignment segment. + + Creates actual curve geometry using IfcOpenShell's geometry engine, + so the segment can be selected and highlighted in the viewport. + + Zero-length segments (required terminators) are skipped as they should + be invisible to users. + + Args: + segment: The IfcAlignmentSegment entity + index: The segment index (for naming) + parent_obj: The parent Blender object (layout object) + + Returns: + The created Blender curve object, or existing one if already linked, + or None for zero-length segments or geometry failures + """ + # Skip zero-length segments - they are required terminators but should be invisible + if cls.is_zero_length_segment(segment): + return None + + # Check if a Blender object already exists for this IFC element + existing_obj = tool.Ifc.get_object(segment) + if existing_obj: + return existing_obj + + # Get segment parameters for naming + if not hasattr(segment, "DesignParameters") or not segment.DesignParameters: + return None + + dp = segment.DesignParameters + seg_type = getattr(dp, "PredefinedType", "UNKNOWN") or "UNKNOWN" + name = f"Segment {index + 1} ({seg_type})" + + # Get vertices for this segment using IfcOpenShell's geometry engine + logger = logging.getLogger("ImportIFC") + ifc_import_settings = bonsai.bim.import_ifc.IfcImportSettings.factory(bpy.context, None, logger) + ifc_importer = bonsai.bim.import_ifc.IfcImporter(ifc_import_settings) + ifc_importer.file = tool.Ifc.get() + + # Resolve the IfcCurveSegment(s) for this layout segment. + # Use _find_layout_curve instead of get_layout_curve because horizontal-only + # alignments (e.g. IFC CT 4.1.4.4.1.2 "reusing horizontal" parents) may only + # have a FootPrint representation rather than an Axis one, causing the standard + # get_layout_curve / get_mapped_segments to return None and crash. + layout = segment.Nests[0].RelatingObject if segment.Nests else None + if not layout: + return None + + layout_curve = cls._find_layout_curve(layout) + if not layout_curve: + return None + + mapped_segments = cls._map_alignment_segment_to_curve_segments(segment, layout, layout_curve) + tool.Loader.load_settings() + obj = None + for curve_segment in mapped_segments: + if curve_segment is not None: + geometry = tool.Loader.create_generic_shape(curve_segment) + # Currently, there may be potentially two IfcCurveSegments, for Helmert + mesh = ifc_importer.create_mesh(curve_segment, geometry) + obj = bpy.data.objects.new(f"IfcAlignmentSegment/{name}", mesh) + + if geometry: + # create_generic_shape on a non-product entity (IfcCurveSegment) returns + # a raw triangulation without transformation_buffer; only ShapeElements + # (IfcProducts) carry the placement matrix in transformation_buffer. + if hasattr(geometry, "transformation_buffer"): + mat = ifcopenshell.util.shape.get_shape_matrix(geometry) + else: + # Vertices are already in plan-space coordinates; use identity so + # apply_blender_offset_to_matrix_world can apply any cartesian_point_offset + # that create_mesh stored (for far-away coordinate handling). + mat = np.eye(4) + obj.matrix_world = tool.Loader.apply_blender_offset_to_matrix_world(obj, mat) + tool.Geometry.record_object_position(obj) + + # Parent to layout object and assign to same collection + if parent_obj: + obj.parent = parent_obj + if parent_obj.users_collection: + parent_obj.users_collection[0].objects.link(obj) + else: + tool.Collector.assign(obj) + else: + tool.Collector.assign(obj) + + # Link the Blender object to the IfcAlignmentSegment (the IfcProduct), + # not the IfcCurveSegment (geometry). This follows Bonsai's convention + # of one Blender object per IfcProduct and ensures correct cleanup. + if obj: + tool.Ifc.link(segment, obj) + + return obj + + @classmethod + def create_alignment_from_csv(cls, filepath: str) -> "ifcopenshell.entity_instance": + """Create alignment(s) from a CSV file via the alignment API. + + The CSV format (see ifcopenshell.api.alignment.create_from_csv) is one + horizontal row (X,Y,R triples) followed by any number of vertical rows + (D,Z,L triples) — extra verticals become aggregated child alignments. + Per IFC 4.1.5.1 alignments cannot be contained in spatial structures, + so the imported alignment is referenced into every IfcSite instead. + """ + import ifcopenshell.api.alignment as align_api + import ifcopenshell.api.spatial + + ifc_file = tool.Ifc.get() + # start_station=0.0 is required since upstream b5670c4fc (2026-08-31): + # create_from_csv no longer adds stationing when start_station is None, + # and the import path below materializes the referents it creates. + alignment = align_api.create_from_csv(ifc_file, filepath, start_station=0.0) + for site in ifc_file.by_type("IfcSite"): + ifcopenshell.api.spatial.reference_structure( + ifc_file, products=[alignment], relating_structure=site + ) + return alignment + + @classmethod + def get_child_alignments(cls, alignment: "ifcopenshell.entity_instance") -> list: + """Return child IfcAlignments aggregated under ``alignment``. + + Per IFC CT 4.1.4.4.1.2, an alignment reusing one horizontal for + several verticals aggregates a child IfcAlignment per extra vertical. + Returns [] for the common single-vertical case. + """ + children = [] + for rel in alignment.IsDecomposedBy or []: + for related in rel.RelatedObjects: + if related.is_a("IfcAlignment"): + children.append(related) + return children + + @classmethod + def create_child_vertical_hierarchy( + cls, + child_alignment: "ifcopenshell.entity_instance", + parent_obj: bpy.types.Object, + ) -> None: + """Create vertical layout objects for a child alignment under the parent object. + + In the IFC CT 4.1.4.4.1.2 multiple-vertical template, each child + IfcAlignment is only a structural wrapper around one IfcAlignmentVertical. + We skip creating a Blender empty for the child alignment itself so that + the scene collection stays uncluttered — only the top-level IfcAlignment + appears there. The vertical layout object (and its segment curves) are + parented directly to the top-level alignment object. + """ + for rel in getattr(child_alignment, "IsNestedBy", []) or []: + for layout in rel.RelatedObjects or []: + if layout.is_a("IfcAlignmentVertical"): + layout_obj = cls.create_object_for_layout(layout, parent_obj) + if layout_obj: + cls.create_objects_for_layout_segments(layout, layout_obj) + + @classmethod + def _get_top_level_alignment( + cls, alignment: "ifcopenshell.entity_instance" + ) -> "ifcopenshell.entity_instance": + """Walk up IfcRelAggregates to the top-level parent IfcAlignment.""" + for rel in getattr(alignment, "Decomposes", []) or []: + parent = rel.RelatingObject + if parent.is_a("IfcAlignment"): + return cls._get_top_level_alignment(parent) + return alignment + + @classmethod + def get_all_vertical_layouts(cls, alignment: "ifcopenshell.entity_instance") -> list: + """Return all IfcAlignmentVertical layouts for ``alignment``. + + Collects verticals nested directly under the alignment AND verticals + nested under child alignments (IFC CT 4.1.4.4.1.2 multiple-vertical + template). + """ + verticals = [] + for rel in getattr(alignment, "IsNestedBy", []) or []: + for obj in rel.RelatedObjects or []: + if obj.is_a("IfcAlignmentVertical"): + verticals.append(obj) + for child in cls.get_child_alignments(alignment): + for rel in getattr(child, "IsNestedBy", []) or []: + for obj in rel.RelatedObjects or []: + if obj.is_a("IfcAlignmentVertical"): + verticals.append(obj) + return verticals + + @classmethod + def create_object_for_referent(cls, referent: "ifcopenshell.entity_instance") -> Optional[bpy.types.Object]: + """Create a Blender empty for one IfcReferent (get-or-create). + + Matches what loading a file does for referents (e.g. stationing + referents from add_stationing_referent) — without this, an alignment + built interactively has no viewport object for its referents at all, + unlike one loaded from a file. + """ + existing_obj = tool.Ifc.get_object(referent) + if existing_obj: + return existing_obj + referent_obj = bpy.data.objects.new(tool.Loader.get_name(referent), None) + tool.Geometry.link(referent, referent_obj) + tool.Collector.assign(referent_obj, should_clean_users_collection=False) + return referent_obj + + @classmethod + def create_objects_for_referents(cls, alignment: "ifcopenshell.entity_instance") -> int: + """Create empty objects for every IfcReferent nested on ``alignment`` + that doesn't already have one. + + Returns the number of referent objects created. + """ + count = 0 + for rel in alignment.IsNestedBy or []: + for referent in rel.RelatedObjects: + if referent.is_a("IfcReferent") and not tool.Ifc.get_object(referent): + cls.create_object_for_referent(referent) + count += 1 + return count + + # ========================================================================= + # Stationing + # ========================================================================= + + @classmethod + def find_stationing_referent_at( + cls, alignment: "ifcopenshell.entity_instance", distance_along: float, tol: float = 1e-6 + ) -> Optional["ifcopenshell.entity_instance"]: + """The stationing referent at a given distance along ``alignment``, or None.""" + import ifcopenshell.api.alignment as align_api + from ifcopenshell.api.alignment._referent_distance_along import _referent_distance_along + + nest = align_api.get_stationing_nest(tool.Ifc.get(), alignment) + if nest is None: + return None + for referent in nest.RelatedObjects: + if abs(_referent_distance_along(referent) - distance_along) < tol: + return referent + return None + + @classmethod + def get_stationing_referents( + cls, alignment: "ifcopenshell.entity_instance" + ) -> List[Tuple["ifcopenshell.entity_instance", float, float, Optional[float], Optional[bool]]]: + """All stationing referents on ``alignment``, sorted by distance along. + + Returns (referent, distance_along, station, incoming_station, + has_increasing_station) tuples. + """ + import ifcopenshell.api.alignment as align_api + import ifcopenshell.util.element + from ifcopenshell.api.alignment._referent_distance_along import _referent_distance_along + + nest = align_api.get_stationing_nest(tool.Ifc.get(), alignment) + if nest is None: + return [] + rows = [] + for referent in nest.RelatedObjects: + rows.append( + ( + referent, + _referent_distance_along(referent), + ifcopenshell.util.element.get_pset(referent, name="Pset_Stationing", prop="Station"), + ifcopenshell.util.element.get_pset(referent, name="Pset_Stationing", prop="IncomingStation"), + ifcopenshell.util.element.get_pset(referent, name="Pset_Stationing", prop="HasIncreasingStation"), + ) + ) + rows.sort(key=lambda r: r[1]) + return rows + + @classmethod + def set_stationing_referent_station(cls, referent: "ifcopenshell.entity_instance", station: float) -> None: + """Change a stationing referent's outgoing Station value in place. + + Used to edit the alignment's start station (the referent at distance + along 0) without removing/re-adding it. Renames the referent and its + Blender object to match, same naming convention add_stationing_referent + uses. + """ + import ifcopenshell.api.pset + import ifcopenshell.util.element + + ifc_file = tool.Ifc.get() + pset_data = ifcopenshell.util.element.get_pset( + referent, name="Pset_Stationing", should_inherit=False, verbose=True + ) + if not pset_data or "id" not in pset_data: + raise ValueError(f"Referent #{referent.id()} has no Pset_Stationing to edit") + pset = ifc_file.by_id(pset_data["id"]) + ifcopenshell.api.pset.edit_pset(ifc_file, pset=pset, properties={"Station": station}) + + # " ", matching add_stationing_referent()'s + # own convention (e.g. create_alignment()'s start referent). + alignment_name = None + for rel in getattr(referent, "Nests", []) or []: + if rel.RelatingObject.is_a("IfcAlignment"): + alignment_name = rel.RelatingObject.Name + break + prefix = f"{alignment_name} " if alignment_name else "" + name = f"{prefix}{cls.format_station(station)}" + referent.Name = name + if obj := tool.Ifc.get_object(referent): + obj.name = tool.Loader.get_name(referent) + + @classmethod + def create_hierarchy_for_alignment(cls, alignment: "ifcopenshell.entity_instance") -> Optional[bpy.types.Object]: + """Create the full Blender object hierarchy for an alignment. + + Creates: + - IfcAlignment object (root) + - IfcAlignmentHorizontal object (child) + - IfcAlignmentVertical object (child, if present) + - IfcAlignmentCant object (child, if present) + - Segment objects under each layout + + Args: + alignment: The IFC alignment entity + + Returns: + The root alignment Blender object + """ + # Create the alignment object + alignment_obj = cls.create_object_for_alignment(alignment) + if not alignment_obj: + return None + + # Get nested layouts via IfcRelNests + layouts = [] + for rel in getattr(alignment, "IsNestedBy", []) or []: + for obj in rel.RelatedObjects or []: + if obj.is_a() in ("IfcAlignmentHorizontal", "IfcAlignmentVertical", "IfcAlignmentCant"): + layouts.append(obj) + + # Create Blender objects for each layout and its segments + for layout in layouts: + layout_obj = cls.create_object_for_layout(layout, alignment_obj) + if layout_obj: + cls.create_objects_for_layout_segments(layout, layout_obj) + + return alignment_obj + + @classmethod + def create_objects_for_layout_segments( + cls, layout: "ifcopenshell.entity_instance", layout_obj: bpy.types.Object + ) -> List[bpy.types.Object]: + """Create Blender curve objects for all segments in a layout. + + Each segment becomes its own selectable curve object, using IfcOpenShell's + geometry engine to generate accurate geometry for all segment types + (LINE, CIRCULARARC, CLOTHOID, spirals, etc.). + + Args: + layout: The IFC layout entity (IfcAlignmentHorizontal, etc.) + layout_obj: The parent Blender object for the layout + + Returns: + List of created Blender curve objects for each segment + """ + result_objs = [] + + # Create individual curve objects for each segment + # Each segment is its own selectable object with actual geometry + visible_index = 0 + for rel in getattr(layout, "IsNestedBy", []) or []: + for segment in rel.RelatedObjects or []: + if segment.is_a() == "IfcAlignmentSegment": + seg_obj = cls._create_segment_curve(segment, visible_index, layout_obj) + if seg_obj: + result_objs.append(seg_obj) + visible_index += 1 # Always increment for consistent numbering + + return result_objs + + @classmethod + def format_station(cls, station: float) -> str: + """Format a station (project units) in project stationing notation. + + Delegates to ifcopenshell.util.alignment.station_as_string, which + derives the notation from the project LENGTHUNIT: imperial projects + read ``100+50.00``, metric projects ``10+050.000``. Falls back to a + plain number when no IFC file is open (e.g. dialog previews before a + project exists). + """ + import ifcopenshell.util.alignment + + ifc_file = tool.Ifc.get() + if ifc_file is None: + return f"{float(station):.2f}" + return ifcopenshell.util.alignment.station_as_string(ifc_file, float(station)) + + @classmethod + def update_pi_properties(cls, props, geometry_result) -> None: + """Update Blender PropertyGroup with calculated geometry. + + This bridges the pure Python calculation results back to + the Blender UI properties. + + Args: + props: The CivilAlignmentProperties PropertyGroup + geometry_result: PIGeometryResult from core.alignment + """ + pis = props.pis + for i, pi in enumerate(pis): + if i < len(geometry_result.stations): + pi.station = geometry_result.stations[i] + if i < len(geometry_result.lengths): + pi.length_to_next = geometry_result.lengths[i] + if i < len(geometry_result.directions): + pi.direction_to_next = geometry_result.directions[i] + + @classmethod + def _remove_blender_object(cls, obj: bpy.types.Object) -> bool: + """Safely remove a Blender object and its data. + + Args: + obj: The Blender object to remove + + Returns: + True if removed successfully + """ + # Unlink from IFC if linked + try: + tool.Ifc.unlink(obj=obj) + except Exception: + pass # Object might not be linked + + # Store data reference before removing object + data = obj.data + + # Remove the object + bpy.data.objects.remove(obj, do_unlink=True) + + # Clean up orphan curve/mesh data + if data and data.users == 0: + if isinstance(data, bpy.types.Curve): + bpy.data.curves.remove(data) + elif isinstance(data, bpy.types.Mesh): + bpy.data.meshes.remove(data) + + return True + + @classmethod + def remove_layout_segment_objects(cls, layout: ifcopenshell.entity_instance) -> int: + """Remove all Blender objects for segments in a layout. + + Args: + layout: The IFC layout entity (IfcAlignmentHorizontal, etc.) + + Returns: + Number of objects removed + """ + removed_count = 0 + + for rel in getattr(layout, "IsNestedBy", []) or []: + for segment in rel.RelatedObjects or []: + if segment.is_a() == "IfcAlignmentSegment": + obj = tool.Ifc.get_object(segment) + if obj and cls._remove_blender_object(obj): + removed_count += 1 + + return removed_count + + @classmethod + def remove_alignment_hierarchy(cls, alignment: ifcopenshell.entity_instance) -> int: + """Remove all Blender objects for an alignment and its children. + + Args: + alignment: The IFC alignment entity + + Returns: + Number of objects removed + """ + removed_count = 0 + + # Get nested layouts (and referents) via IfcRelNests + for rel in getattr(alignment, "IsNestedBy", []) or []: + for related in rel.RelatedObjects or []: + if related.is_a() in ("IfcAlignmentHorizontal", "IfcAlignmentVertical", "IfcAlignmentCant"): + # Remove segment objects first + removed_count += cls.remove_layout_segment_objects(related) + + # Remove layout object + layout_obj = tool.Ifc.get_object(related) + if layout_obj and cls._remove_blender_object(layout_obj): + removed_count += 1 + elif related.is_a("IfcReferent"): + referent_obj = tool.Ifc.get_object(related) + if referent_obj and cls._remove_blender_object(referent_obj): + removed_count += 1 + + # Remove alignment object + alignment_obj = tool.Ifc.get_object(alignment) + if alignment_obj and cls._remove_blender_object(alignment_obj): + removed_count += 1 + + return removed_count + + # ========================================================================= + # Validation and Safe Wrappers + # ========================================================================= + # These methods provide pre-validation before calling IfcOpenShell alignment + # API functions. This prevents issues like orphan layouts (from undo/redo) + # causing invalid IFC entities (e.g., IfcRelPositions with empty RelatedProducts). + # + # The key principle: validate BEFORE operations to prevent invalid data, + # rather than cleaning up after the fact. + + @classmethod + def validate_layout_has_parent_alignment( + cls, layout: "ifcopenshell.entity_instance" + ) -> Optional["ifcopenshell.entity_instance"]: + """Check if a layout entity has a valid parent IfcAlignment. + + Orphan layouts (e.g., from undo/redo operations) can cause issues + when the alignment API tries to create referents, as the code + expects a parent alignment to exist. + + Args: + layout: The IFC layout entity (IfcAlignmentHorizontal, etc.) + + Returns: + The parent IfcAlignment if found, None otherwise + """ + import ifcopenshell.api.alignment as align_api + + return align_api.get_alignment(layout) + + @classmethod + def safe_layout_horizontal_by_pi_method( + cls, ifc_file: "ifcopenshell.file", layout: "ifcopenshell.entity_instance", hpoints: list, radii: list + ) -> bool: + """Safely add segments to a horizontal layout using PI method. + + This wrapper validates that the layout has a valid parent alignment + before calling the IfcOpenShell API. This prevents the creation of + invalid IfcRelPositions entities. + + Args: + ifc_file: The IFC file + layout: The IfcAlignmentHorizontal layout + hpoints: List of (X, Y) coordinate pairs for PIs + radii: List of curve radii + + Returns: + True if successful + + Raises: + ValueError: If layout has no parent alignment + """ + import ifcopenshell.api.alignment as align_api + + # Validate layout has a parent alignment - this is the key check + # that prevents orphan stationing from being created + alignment = cls.validate_layout_has_parent_alignment(layout) + if alignment is None: + raise ValueError( + f"Layout #{layout.id()} ({layout.is_a()}) has no parent IfcAlignment. " + "This may be an orphan layout from undo/redo. " + "Cannot add segments without a valid parent alignment." + ) + + # Now safe to call the API - stationing will be associated with alignment + align_api.layout_horizontal_alignment_by_pi_method(ifc_file, layout, hpoints, radii) + + return True + + # ========================================================================= + # PI Edit Mode Methods + # ========================================================================= + # These methods support the PI Edit Mode feature, which allows users to + # move alignment PIs (Points of Intersection) using Blender's standard + # transform tools (G key). The workflow is: + # 1. Back-calculate PI positions from existing IFC segments + # 2. Create temporary EMPTY objects at each PI location + # 3. User moves empties with standard Blender tools + # 4. Collect new positions and regenerate alignment segments + + @classmethod + def back_calculate_pis_from_alignment(cls, alignment: "ifcopenshell.entity_instance") -> List[dict]: + """Reverse-engineer PI positions from IFC alignment segments. + + Uses ifcopenshell.api.alignment.segment_vertices() to extract + the tangent intersection (TI) point for each segment — the TI + IS the PI for curve segments. + + Args: + alignment: The IfcAlignment entity + + Returns: + List of dicts, each containing: + - "e": float - Easting coordinate in IFC space + - "n": float - Northing coordinate in IFC space + - "radius": float - Curve radius (0 for endpoints/tangent PIs) + - "pi_type": str - "ENDPOINT", "CURVE", or "TANGENT" + + Raises: + ValueError: If alignment has no horizontal layout or segments + """ + import ifcopenshell.api.alignment as align_api + + ifc_file = tool.Ifc.get() + + # Get horizontal layout + h_layout = align_api.get_horizontal_layout(alignment) + if h_layout is None: + raise ValueError(f"Alignment #{alignment.id()} has no horizontal layout") + + # Get all segments + segments = align_api.get_layout_segments(h_layout) + if not segments: + raise ValueError(f"Alignment #{alignment.id()} has no segments") + + # Filter out zero-length terminator segments + real_segments = [seg for seg in segments if not cls.is_zero_length_segment(seg)] + if not real_segments: + raise ValueError(f"Alignment #{alignment.id()} has no real segments (only terminator)") + + # Get vertices for all segments + seg_vertices = [cls._get_segment_vertices_in_model_units(ifc_file, seg) for seg in real_segments] + + pis = [] + + # First PI: start of first segment + if seg_vertices[0] is not None: + start_pt = seg_vertices[0][0] + pis.append({"e": start_pt[0], "n": start_pt[1], "radius": 0.0, "pi_type": "ENDPOINT"}) + + # Process each segment for interior PIs + prev_is_line = True + for i, (seg, verts) in enumerate(zip(real_segments, seg_vertices)): + if verts is None: + prev_is_line = False + continue + + start, end, ti, ni = verts + dp = seg.DesignParameters + + if ti is not None: + # Curve segment: TI is the PI + radius = abs(float(dp.StartRadiusOfCurvature or dp.EndRadiusOfCurvature or 0)) + pis.append({"e": ti[0], "n": ti[1], "radius": radius, "pi_type": "CURVE"}) + prev_is_line = False + else: + # Line segment: if previous was also a line, connection = tangent PI + if i > 0 and prev_is_line: + pis.append({"e": start[0], "n": start[1], "radius": 0.0, "pi_type": "TANGENT"}) + prev_is_line = True + + # Last PI: end of last segment + if seg_vertices[-1] is not None: + end_pt = seg_vertices[-1][1] + if pis: + last = pis[-1] + dist = ((end_pt[0] - last["e"]) ** 2 + (end_pt[1] - last["n"]) ** 2) ** 0.5 + if dist > 0.001: + pis.append({"e": end_pt[0], "n": end_pt[1], "radius": 0.0, "pi_type": "ENDPOINT"}) + else: + pis.append({"e": end_pt[0], "n": end_pt[1], "radius": 0.0, "pi_type": "ENDPOINT"}) + + return pis + + @classmethod + def create_pi_edit_empties( + cls, + alignment: "ifcopenshell.entity_instance", + pis: List[dict], + ) -> List[bpy.types.Object]: + """Create EMPTY objects at PI locations for editing. + + Creates temporary Blender EMPTY objects at each PI position, + allowing users to move them with standard Blender tools (G key). + + The empties are: + - Parented to the alignment object + - Tagged with custom properties for identification + - Named sequentially (PI.001, PI.002, etc.) + + Args: + alignment: The IfcAlignment entity + pis: List of PI dicts from back_calculate_pis_from_alignment() + + Returns: + List of created Blender EMPTY objects, sorted by index + """ + alignment_obj = tool.Ifc.get_object(alignment) + if alignment_obj is None: + return [] + + # Get the collection to add objects to + collection = None + if alignment_obj.users_collection: + collection = alignment_obj.users_collection[0] + else: + collection = bpy.context.scene.collection + + import ifcopenshell.util.unit + + alignment_id = alignment.id() + empties = [] + # Georeference returns IFC project units; Blender world space is metres + # (1 BU = 1 m), so scale up to place empties at the correct location. + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + + for i, pi in enumerate(pis): + # IFC project units -> Blender world metres + local = tool.Georeference.enh2xyz((float(pi["e"]), float(pi["n"]), 0.0)) + blender_pos = (local[0] * unit_scale, local[1] * unit_scale, local[2] * unit_scale) + + # Create EMPTY object + name = f"PI.{i + 1:03d}" + empty = bpy.data.objects.new(name, None) + empty.empty_display_type = "SPHERE" + empty.empty_display_size = 2.0 + empty.location = blender_pos + + # Tag with custom properties for identification + empty["civil_is_pi_empty"] = True + empty["civil_pi_index"] = i + empty["civil_pi_radius"] = pi["radius"] + empty["civil_alignment_id"] = alignment_id + empty["civil_pi_type"] = pi["pi_type"] + + # Parent to alignment object + empty.parent = alignment_obj + + # Link to collection + collection.objects.link(empty) + + empties.append(empty) + + return empties + + @classmethod + def get_pi_edit_empties(cls, alignment_id: int) -> List[bpy.types.Object]: + """Find all PI EMPTY objects for a given alignment. + + Searches all objects in the scene for empties tagged with + the PI edit mode custom properties. + + Args: + alignment_id: The IFC ID of the alignment being edited + + Returns: + List of PI EMPTY objects, sorted by pi_index + """ + empties = [] + + for obj in bpy.data.objects: + if obj.get("civil_is_pi_empty") and obj.get("civil_alignment_id") == alignment_id: + empties.append(obj) + + # Sort by PI index + empties.sort(key=lambda e: e.get("civil_pi_index", 0)) + + return empties + + @classmethod + def remove_pi_edit_empties(cls, alignment_id: int) -> int: + """Remove all PI EMPTY objects for a given alignment. + + Args: + alignment_id: The IFC ID of the alignment being edited + + Returns: + Number of objects removed + """ + empties = cls.get_pi_edit_empties(alignment_id) + removed_count = 0 + + for empty in empties: + bpy.data.objects.remove(empty, do_unlink=True) + removed_count += 1 + + return removed_count + + @classmethod + def collect_pis_from_empties(cls, alignment_id: int) -> Tuple[List[Tuple[float, float]], List[float]]: + """Gather current PI positions from EMPTY objects. + + Reads the current positions of PI empties and converts them + back to IFC coordinates for regenerating the alignment. + + Args: + alignment_id: The IFC ID of the alignment being edited + + Returns: + Tuple of: + - hpoints: List of (x, y) tuples in IFC coordinates + - radii: List of radii for interior PIs only (not first/last) + """ + empties = cls.get_pi_edit_empties(alignment_id) + + if len(empties) < 2: + return ([], []) + + import ifcopenshell.util.unit + + hpoints = [] + radii = [] + # Blender world metres -> IFC project units before georeferencing. + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + + for i, empty in enumerate(empties): + # Blender metres -> IFC project units -> global E/N + translation = empty.matrix_world.translation + local = (translation[0] / unit_scale, translation[1] / unit_scale, translation[2] / unit_scale) + ifc_pos = tool.Georeference.xyz2enh(local) + hpoints.append((ifc_pos[0], ifc_pos[1])) + + # Collect radii for interior PIs only (not first or last) + if 0 < i < len(empties) - 1: + radius = empty.get("civil_pi_radius", 0.0) + radii.append(radius) + + return (hpoints, radii) + + @classmethod + def set_layout_segments_selectable(cls, layout: "ifcopenshell.entity_instance", selectable: bool) -> None: + """Toggle viewport selectability of a layout's segment objects. + + During PI edit mode the segment curves are made non-selectable so + viewport clicks land on the PI edit empties rather than on the curves + drawn along the alignment (which otherwise intercept the clicks). + """ + if layout is None: + return + for rel in getattr(layout, "IsNestedBy", []) or []: + for segment in rel.RelatedObjects or []: + if segment.is_a() == "IfcAlignmentSegment": + obj = tool.Ifc.get_object(segment) + if obj: + obj.hide_select = not selectable + + @classmethod + def get_active_alignment(cls) -> ifcopenshell.entity_instance | None: + if obj := tool.Blender.get_active_object(): + element = tool.Ifc.get_entity(obj) + if not element: + return None + if element.is_a("IfcAlignment"): + return cls._get_top_level_alignment(element) + # Walk up: segment → layout → alignment → top-level alignment + if element.is_a("IfcAlignmentSegment"): + for rel in getattr(element, "Nests", []) or []: + layout = rel.RelatingObject + for rel2 in getattr(layout, "Nests", []) or []: + if rel2.RelatingObject.is_a("IfcAlignment"): + return cls._get_top_level_alignment(rel2.RelatingObject) + if element.is_a("IfcAlignmentHorizontal") or element.is_a("IfcAlignmentVertical") or element.is_a("IfcAlignmentCant"): + for rel in getattr(element, "Nests", []) or []: + if rel.RelatingObject.is_a("IfcAlignment"): + return cls._get_top_level_alignment(rel.RelatingObject) + return None diff --git a/src/bonsai/bonsai/tool/root.py b/src/bonsai/bonsai/tool/root.py index ef2464d964..b926dd9b6e 100644 --- a/src/bonsai/bonsai/tool/root.py +++ b/src/bonsai/bonsai/tool/root.py @@ -488,4 +488,8 @@ class Root(bonsai.core.tool.Root): "IfcAnnotation", "IfcRelSpaceBoundary", ) + if version != "IFC4": + # IFC4X3+: alignments are created like any other element + # (Saikei); the create flow bootstraps the horizontal layout. + products += ("IfcAlignment",) return products diff --git a/src/bonsai/pytest.ini b/src/bonsai/pytest.ini index 24cdb39657..06369d9d23 100644 --- a/src/bonsai/pytest.ini +++ b/src/bonsai/pytest.ini @@ -1,12 +1,14 @@ [pytest] markers = aggregate + alignment array attribute boolean boundary brick bsdd + civil clash classification clip_box diff --git a/src/bonsai/test/bim/module/alignment/__init__.py b/src/bonsai/test/bim/module/alignment/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/bonsai/test/bim/module/alignment/test_alignment_operators.py b/src/bonsai/test/bim/module/alignment/test_alignment_operators.py new file mode 100644 index 0000000000..df43c291be --- /dev/null +++ b/src/bonsai/test/bim/module/alignment/test_alignment_operators.py @@ -0,0 +1,670 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 Michael Yoder +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . + +"""Headless operator tests for the Saikei alignment module. + +Tests non-modal alignment operators end-to-end in Blender headless mode. +Follows Bonsai's existing test patterns (NewIfc4X3 base class from bootstrap). + +Operators tested: + Horizontal: add_pi, remove_pi, recalculate_pis, clear_pis, create_alignment_by_pi + Utility: name_segments + CSV import: import_alignment_csv (EXEC_DEFAULT with explicit filepath) + +Operators skipped (modal / viewport): + pick_pi_from_viewport, enter_pi_edit_mode +""" + +import pytest + +import bpy +import ifcopenshell +import ifcopenshell.api.alignment as align_api + +import bonsai.tool as tool +from bonsai.bim.ifc import IfcStore +from test.bim.bootstrap import NewIfc4X3 + + +def _geometry_mapping_available() -> bool: + """True when the modular geometry-mapping plugins are present. + + v0.9.0 evaluates segment endpoints through the geometry engine, which + loads per-schema ifcopenshell_geometry_mapping_* plugins at runtime. The + win64 v0.9.0alpha0 builds ship without them (IfcOpenShell#9301), so + geometry-dependent tests skip locally and run in CI where builds are + complete. + """ + import pathlib + + package_root = pathlib.Path(ifcopenshell.__file__).parent + return any(f.name.startswith("ifcopenshell_geometry_mapping_") for f in package_root.iterdir()) + + +requires_geometry_engine = pytest.mark.skipif( + not _geometry_mapping_available(), + reason="geometry mapping plugins unavailable (IfcOpenShell#9301); covered in CI", +) + +pytestmark = pytest.mark.alignment + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def get_alignment_props(): + """Shortcut to CivilAlignmentProperties on the scene.""" + return bpy.context.scene.CivilAlignmentProperties + + +def create_empty_alignment(name="Test Alignment"): + """Create an IfcAlignment with an empty IfcAlignmentHorizontal layout. + + Uses align_api.create() which creates IfcAlignment + IfcAlignmentHorizontal + + zero-length terminator + geometric representations + aggregation to project. + + This is the minimal setup required for operators that need an active + alignment (e.g. create_alignment_by_pi, recalculate_pis). + + Returns: + tuple: (alignment_entity, alignment_blender_obj) + """ + ifc_file = tool.Ifc.get() + + # create() handles: IfcAlignment, IfcAlignmentHorizontal, IfcRelNests, + # zero-length terminator, geometric representation, project aggregation + alignment = align_api.create(ifc_file, name=name) + + # Create Blender objects via the tool layer + alignment_obj = tool.Alignment.create_hierarchy_for_alignment(alignment) + + # Set as active so operators can find it via get_active_alignment() + if alignment_obj: + bpy.context.view_layer.objects.active = alignment_obj + alignment_obj.select_set(True) + + # Set active alignment ID in properties + props = get_alignment_props() + props.active_alignment_id = alignment.id() + props.active_alignment_name = name + + return alignment, alignment_obj + + +def add_pis_to_props(pi_data): + """Add PIs to the props collection with specified coordinates. + + Args: + pi_data: list of (e, n, radius) tuples. + First and last are auto-typed as ENDPOINT. + """ + props = get_alignment_props() + for i, (e, n, radius) in enumerate(pi_data): + bpy.ops.civil.add_pi() + pi = props.pis[len(props.pis) - 1] + pi.e = str(e) + pi.n = str(n) + if radius > 0: + pi.radius = radius + + +# =========================================================================== +# Horizontal PI Operators +# =========================================================================== + + +class TestAddPi(NewIfc4X3): + """Tests for CIVIL_OT_add_pi (civil.add_pi).""" + + def test_add_first_pi_sets_endpoint_at_origin(self): + props = get_alignment_props() + result = bpy.ops.civil.add_pi() + assert result == {"FINISHED"} + assert len(props.pis) == 1 + assert float(props.pis[0].e) == 0.0 + assert float(props.pis[0].n) == 0.0 + assert props.pis[0].pi_type == "ENDPOINT" + + def test_add_second_pi_offsets_from_first(self): + props = get_alignment_props() + bpy.ops.civil.add_pi() + bpy.ops.civil.add_pi() + assert len(props.pis) == 2 + assert props.pis[1].pi_type == "ENDPOINT" + # Second PI should be offset 100 units east + assert float(props.pis[1].e) == pytest.approx(100.0) + assert float(props.pis[1].n) == pytest.approx(0.0) + + def test_add_third_pi_extrapolates_and_changes_second_type(self): + props = get_alignment_props() + bpy.ops.civil.add_pi() + bpy.ops.civil.add_pi() + bpy.ops.civil.add_pi() + assert len(props.pis) == 3 + # Second PI (index 1) should have been changed from ENDPOINT to TANGENT + assert props.pis[1].pi_type == "TANGENT" + # Third PI extrapolates direction + assert float(props.pis[2].e) == pytest.approx(200.0) + + def test_active_pi_index_tracks_last_added(self): + props = get_alignment_props() + bpy.ops.civil.add_pi() + assert props.active_pi_index == 0 + bpy.ops.civil.add_pi() + assert props.active_pi_index == 1 + bpy.ops.civil.add_pi() + assert props.active_pi_index == 2 + + def test_add_pi_triggers_geometry_recalculation(self): + """After adding 2+ PIs, display_rows should be populated.""" + props = get_alignment_props() + bpy.ops.civil.add_pi() + bpy.ops.civil.add_pi() + # With 2 PIs, we should have at least 2 point rows and 1 segment row + assert len(props.display_rows) >= 2 + + +class TestRemovePi(NewIfc4X3): + """Tests for CIVIL_OT_remove_pi (civil.remove_pi).""" + + def test_remove_pi_decrements_collection(self): + props = get_alignment_props() + bpy.ops.civil.add_pi() + bpy.ops.civil.add_pi() + bpy.ops.civil.add_pi() + assert len(props.pis) == 3 + + # Select first point row in display_rows + if props.display_rows: + props.active_display_row_index = 0 + result = bpy.ops.civil.remove_pi() + assert result == {"FINISHED"} + assert len(props.pis) == 2 + + def test_remove_pi_from_single_item_list(self): + props = get_alignment_props() + bpy.ops.civil.add_pi() + assert len(props.pis) == 1 + + # Use active_pi_index fallback (no display_rows for 1 PI) + props.active_pi_index = 0 + # display_rows may be empty with 1 PI, so remove_pi uses active_pi_index + props.display_rows.clear() + result = bpy.ops.civil.remove_pi() + assert result == {"FINISHED"} + assert len(props.pis) == 0 + + def test_remove_pi_updates_active_index(self): + props = get_alignment_props() + bpy.ops.civil.add_pi() + bpy.ops.civil.add_pi() + bpy.ops.civil.add_pi() + + # Select last point row + props.active_pi_index = 2 + props.display_rows.clear() + bpy.ops.civil.remove_pi() + # active_pi_index should clamp to valid range + assert props.active_pi_index <= len(props.pis) - 1 + + +class TestClearPis(NewIfc4X3): + """Tests for CIVIL_OT_clear_pis (civil.clear_pis). + + Note: clear_pis defines invoke() with invoke_confirm, but calling via + bpy.ops in Python uses EXEC_DEFAULT by default, skipping invoke. + """ + + def test_clear_pis_removes_all(self): + props = get_alignment_props() + for _ in range(5): + bpy.ops.civil.add_pi() + assert len(props.pis) == 5 + + result = bpy.ops.civil.clear_pis() + assert result == {"FINISHED"} + assert len(props.pis) == 0 + assert len(props.display_rows) == 0 + + def test_clear_pis_resets_indices(self): + props = get_alignment_props() + bpy.ops.civil.add_pi() + bpy.ops.civil.add_pi() + bpy.ops.civil.clear_pis() + assert props.active_pi_index == 0 + assert props.active_display_row_index == 0 + + def test_clear_pis_with_active_alignment_removes_ifc(self): + """When an active alignment exists, clear_pis should remove it from IFC.""" + alignment, alignment_obj = create_empty_alignment() + ifc_file = tool.Ifc.get() + + props = get_alignment_props() + bpy.ops.civil.add_pi() + bpy.ops.civil.add_pi() + + alignment_count_before = len(ifc_file.by_type("IfcAlignment")) + bpy.ops.civil.clear_pis() + + alignment_count_after = len(ifc_file.by_type("IfcAlignment")) + assert alignment_count_after < alignment_count_before + assert len(props.pis) == 0 + + def test_clear_pis_resolves_alignment_from_props_not_viewport(self): + """The alignment is resolved via props.active_alignment_id, so it is + deleted even when the viewport's active object is something else + (typically a segment curve after PI editing).""" + alignment, alignment_obj = create_empty_alignment() + ifc_file = tool.Ifc.get() + bpy.context.view_layer.objects.active = None + + props = get_alignment_props() + bpy.ops.civil.add_pi() + + alignment_count_before = len(ifc_file.by_type("IfcAlignment")) + bpy.ops.civil.clear_pis() + + assert len(ifc_file.by_type("IfcAlignment")) < alignment_count_before + assert props.active_alignment_id == 0 + assert props.active_alignment_name == "" + + +class TestRecalculatePis(NewIfc4X3): + """Tests for CIVIL_OT_recalculate_pis (civil.recalculate_pis).""" + + def test_recalculate_populates_display_rows(self): + props = get_alignment_props() + add_pis_to_props([(0, 0, 0), (500, 0, 0), (1000, 200, 0)]) + + result = bpy.ops.civil.recalculate_pis() + assert result == {"FINISHED"} + assert len(props.display_rows) > 0 + + def test_recalculate_computes_geometry_values(self): + """PI geometry values (station, length_to_next) should be reasonable.""" + props = get_alignment_props() + add_pis_to_props([(0, 0, 0), (500, 0, 0), (1000, 0, 0)]) + + bpy.ops.civil.recalculate_pis() + + # Straight line: each segment should be 500 units + assert props.pis[0].length_to_next == pytest.approx(500.0, abs=1.0) + assert props.pis[1].length_to_next == pytest.approx(500.0, abs=1.0) + + @requires_geometry_engine + def test_recalculate_with_active_alignment_updates_ifc(self): + """When an active alignment exists, recalculate should update IFC segments.""" + alignment, alignment_obj = create_empty_alignment() + ifc_file = tool.Ifc.get() + + props = get_alignment_props() + add_pis_to_props([(0, 0, 0), (500, 0, 0), (1000, 200, 0)]) + + # First, create alignment segments + bpy.ops.civil.create_alignment_by_pi() + + # Re-select alignment object (create_alignment_by_pi may change selection) + bpy.context.view_layer.objects.active = alignment_obj + alignment_obj.select_set(True) + + # Modify a PI + props.pis[1].e = str(600.0) + + # Recalculate should update IFC in-place + result = bpy.ops.civil.recalculate_pis() + assert result == {"FINISHED"} + + # IFC should still have segments + segments = ifc_file.by_type("IfcAlignmentSegment") + assert len(segments) >= 2 + + +@requires_geometry_engine +class TestCreateAlignmentByPi(NewIfc4X3): + """Tests for CIVIL_OT_create_alignment_by_pi (civil.create_alignment_by_pi). + + This operator requires an existing empty alignment set as active. + """ + + def test_create_alignment_creates_ifc_segments(self): + """Basic 3-PI alignment: creates tangent + tangent segments in IFC.""" + alignment, alignment_obj = create_empty_alignment() + ifc_file = tool.Ifc.get() + + props = get_alignment_props() + add_pis_to_props([(0, 0, 0), (500, 0, 0), (1000, 200, 0)]) + + result = bpy.ops.civil.create_alignment_by_pi() + assert result == {"FINISHED"} + + # IFC should contain alignment segments + segments = ifc_file.by_type("IfcAlignmentSegment") + assert len(segments) >= 2 + + # Alignment should still exist + alignments = ifc_file.by_type("IfcAlignment") + assert len(alignments) == 1 + + # Horizontal layout should exist + horizontals = ifc_file.by_type("IfcAlignmentHorizontal") + assert len(horizontals) == 1 + + def test_create_alignment_with_curve_creates_arc_segment(self): + """3-PI alignment with radius on middle PI creates LINE + ARC + LINE.""" + alignment, alignment_obj = create_empty_alignment() + ifc_file = tool.Ifc.get() + + props = get_alignment_props() + add_pis_to_props([(0, 0, 0), (500, 0, 300), (1000, 200, 0)]) + + result = bpy.ops.civil.create_alignment_by_pi() + assert result == {"FINISHED"} + + # Check segment design parameter types + segments = ifc_file.by_type("IfcAlignmentSegment") + segment_types = [] + for seg in segments: + dp = seg.DesignParameters + if dp and hasattr(dp, "PredefinedType"): + segment_types.append(dp.PredefinedType) + + # Should have at least LINE and CIRCULARARC + assert "LINE" in segment_types + assert "CIRCULARARC" in segment_types + + def test_create_alignment_produces_blender_objects(self): + """After creation, Blender scene should contain alignment objects.""" + alignment, alignment_obj = create_empty_alignment() + + props = get_alignment_props() + add_pis_to_props([(0, 0, 0), (500, 0, 0), (1000, 200, 0)]) + + bpy.ops.civil.create_alignment_by_pi() + + # Should have at least the alignment object in the scene + alignment_objects = [ + obj for obj in bpy.data.objects + if tool.Ifc.get_entity(obj) and tool.Ifc.get_entity(obj).is_a("IfcAlignment") + ] + assert len(alignment_objects) >= 1 + + def test_create_straight_alignment_two_pis(self): + """Minimal alignment: 2 PIs producing a single tangent.""" + alignment, alignment_obj = create_empty_alignment() + ifc_file = tool.Ifc.get() + + props = get_alignment_props() + add_pis_to_props([(0, 0, 0), (1000, 0, 0)]) + + result = bpy.ops.civil.create_alignment_by_pi() + assert result == {"FINISHED"} + + segments = ifc_file.by_type("IfcAlignmentSegment") + assert len(segments) >= 1 + + +@requires_geometry_engine +class TestEndToEndAlignmentCreation(NewIfc4X3): + """Full workflow: create alignment, add PIs, create IFC, validate.""" + + def test_basic_three_pi_alignment_workflow(self): + """Scenario 1: Create a basic 3-PI alignment end-to-end.""" + alignment, alignment_obj = create_empty_alignment("E2E Test Alignment") + ifc_file = tool.Ifc.get() + props = get_alignment_props() + + # Add 3 PIs: straight segment then angled + add_pis_to_props([(0, 0, 0), (500, 0, 0), (1000, 200, 0)]) + assert len(props.pis) == 3 + + # Create alignment + result = bpy.ops.civil.create_alignment_by_pi() + assert result == {"FINISHED"} + + # Validate IFC entities + alignments = ifc_file.by_type("IfcAlignment") + assert len(alignments) == 1 + assert alignments[0].Name == "E2E Test Alignment" + + horizontals = ifc_file.by_type("IfcAlignmentHorizontal") + assert len(horizontals) == 1 + + segments = ifc_file.by_type("IfcAlignmentSegment") + # At minimum: tangent + tangent (+ zero-length terminator possibly) + assert len(segments) >= 2 + + # All segment design params should be IfcAlignmentHorizontalSegment + for seg in segments: + dp = seg.DesignParameters + if dp: + assert dp.is_a("IfcAlignmentHorizontalSegment") + + def test_three_pi_with_curve_workflow(self): + """Scenario 2: 3-PI alignment with curve produces correct IFC segments.""" + alignment, alignment_obj = create_empty_alignment("Curved Alignment") + ifc_file = tool.Ifc.get() + props = get_alignment_props() + + # PI with 300m radius on middle point + add_pis_to_props([(0, 0, 0), (500, 0, 300), (1000, 500, 0)]) + + bpy.ops.civil.create_alignment_by_pi() + + segments = ifc_file.by_type("IfcAlignmentSegment") + predefined_types = set() + for seg in segments: + dp = seg.DesignParameters + if dp and hasattr(dp, "PredefinedType"): + predefined_types.add(dp.PredefinedType) + + assert "LINE" in predefined_types + assert "CIRCULARARC" in predefined_types + + def test_five_pi_complex_alignment(self): + """Scenario 3: 5-PI alignment with multiple curves.""" + alignment, alignment_obj = create_empty_alignment("Complex Alignment") + ifc_file = tool.Ifc.get() + props = get_alignment_props() + + add_pis_to_props([ + (0, 0, 0), + (300, 0, 200), + (600, 300, 150), + (900, 300, 250), + (1200, 0, 0), + ]) + + result = bpy.ops.civil.create_alignment_by_pi() + assert result == {"FINISHED"} + + segments = ifc_file.by_type("IfcAlignmentSegment") + # 5 PIs with 3 interior curves → many segments + assert len(segments) >= 4 + + def test_add_remove_pi_cycle(self): + """Scenario 4: Add/remove PIs cycle - props stay consistent.""" + props = get_alignment_props() + + bpy.ops.civil.add_pi() + bpy.ops.civil.add_pi() + bpy.ops.civil.add_pi() + assert len(props.pis) == 3 + + # Remove middle PI + props.display_rows.clear() + props.active_pi_index = 1 + bpy.ops.civil.remove_pi() + assert len(props.pis) == 2 + + # Add two more + bpy.ops.civil.add_pi() + bpy.ops.civil.add_pi() + assert len(props.pis) == 4 + + # Clear all + bpy.ops.civil.clear_pis() + assert len(props.pis) == 0 + assert len(props.display_rows) == 0 + +@requires_geometry_engine +class TestEndToEndIfcRoundtrip(NewIfc4X3): + """IFC save/reload roundtrip validation.""" + + def test_alignment_survives_ifc_roundtrip(self): + """Create alignment, save to temp file, reload, verify entities.""" + import tempfile + import os + + alignment, alignment_obj = create_empty_alignment("Roundtrip Test") + ifc_file = tool.Ifc.get() + props = get_alignment_props() + + add_pis_to_props([(0, 0, 0), (500, 0, 300), (1000, 200, 0)]) + bpy.ops.civil.create_alignment_by_pi() + + # Count entities before save + alignment_count = len(ifc_file.by_type("IfcAlignment")) + horizontal_count = len(ifc_file.by_type("IfcAlignmentHorizontal")) + segment_count = len(ifc_file.by_type("IfcAlignmentSegment")) + + assert alignment_count == 1 + assert horizontal_count == 1 + assert segment_count >= 2 + + # Save to temp file + temp_path = os.path.join(tempfile.gettempdir(), "alignment_roundtrip_test.ifc") + ifc_file.write(temp_path) + + # Reload + reloaded = ifcopenshell.open(temp_path) + + # Verify entity counts match + assert len(reloaded.by_type("IfcAlignment")) == alignment_count + assert len(reloaded.by_type("IfcAlignmentHorizontal")) == horizontal_count + assert len(reloaded.by_type("IfcAlignmentSegment")) == segment_count + + # Verify alignment name survived + assert reloaded.by_type("IfcAlignment")[0].Name == "Roundtrip Test" + + # Cleanup + os.unlink(temp_path) + + +# Station formatting is tool-layer now (tool.Alignment.format_station wrapping +# ifcopenshell.util.alignment.station_as_string) — see TestFormatStation in +# test/tool/test_alignment.py. + + +@requires_geometry_engine +class TestImportAlignmentCsv(NewIfc4X3): + """bim.import_alignment_csv — the single, merged CSV import path. + + CSV rows use full X,Y,R (or D,Z,L) triples: the first and last R/L values + are placeholders per the API's create_from_csv contract. + """ + + def _write_csv(self, tmp_path, rows): + path = tmp_path / "alignment.csv" + path.write_text("\n".join(rows) + "\n", encoding="utf-8") + return str(path) + + def test_import_sets_active_alignment_and_builds_hierarchy(self, tmp_path): + filepath = self._write_csv(tmp_path, ["0,0,0,1000,0,300,2000,800,0"]) + result = bpy.ops.bim.import_alignment_csv("EXEC_DEFAULT", filepath=filepath) + assert result == {"FINISHED"} + + props = get_alignment_props() + assert props.active_alignment_id != 0 + alignment = tool.Ifc.get().by_id(props.active_alignment_id) + assert alignment.is_a("IfcAlignment") + assert tool.Ifc.get_object(alignment) is not None + + def test_import_with_vertical_row_creates_vertical_layout(self, tmp_path): + filepath = self._write_csv( + tmp_path, + [ + "0,0,0,1000,0,300,2000,800,0", + "0,100,0,500,110,200,1000,105,0", + ], + ) + result = bpy.ops.bim.import_alignment_csv("EXEC_DEFAULT", filepath=filepath) + assert result == {"FINISHED"} + + props = get_alignment_props() + alignment = tool.Ifc.get().by_id(props.active_alignment_id) + assert align_api.get_vertical_layout(alignment) is not None + + +class TestAddElementAlignment(NewIfc4X3): + """Shift-A Add Element route for IfcAlignment (spec intro / 1.1). + + Reinstated from the 0.8 saikei branch in minimal scope: IfcAlignment in + the Definition dropdown; creating one bootstraps the horizontal layout, + stationing referent, zero-length terminator, and project aggregation — + landing in the same state as panel creation. + """ + + def _add_alignment(self, name=""): + import bonsai.bim.module.root.data + + bonsai.bim.module.root.data.IfcClassData.load() + root_props = tool.Root.get_root_props() + root_props.ifc_product = "IfcAlignment" + root_props.ifc_class = "IfcAlignment" + if name: + root_props.name = name + return bpy.ops.bim.add_element() + + def test_ifc_alignment_offered_in_products(self): + products = tool.Root.get_ifc_products() + assert "IfcAlignment" in products + + def test_add_element_bootstraps_horizontal_layout(self): + result = self._add_alignment(name="Route 66") + assert result == {"FINISHED"} + + ifc_file = tool.Ifc.get() + alignments = ifc_file.by_type("IfcAlignment") + assert len(alignments) == 1 + alignment = alignments[0] + + h_layout = align_api.get_horizontal_layout(alignment) + assert h_layout is not None + assert align_api.has_zero_length_segment(h_layout) + + def test_add_element_alignment_is_aggregated_not_contained(self): + self._add_alignment() + alignment = tool.Ifc.get().by_type("IfcAlignment")[0] + assert alignment.Decomposes + assert alignment.Decomposes[0].RelatingObject.is_a("IfcProject") + assert not alignment.ContainedInStructure + + def test_add_element_creates_stationing_referent_and_sets_active(self): + self._add_alignment(name="Route 66") + alignment = tool.Ifc.get().by_type("IfcAlignment")[0] + + nest = align_api.get_stationing_nest(tool.Ifc.get(), alignment) + assert nest is not None + referent = nest.RelatedObjects[0] + assert referent.Name.startswith("Route 66") + + props = get_alignment_props() + assert props.active_alignment_id == alignment.id() + assert props.active_alignment_name == "Route 66" diff --git a/src/bonsai/test/core/bootstrap.py b/src/bonsai/test/core/bootstrap.py index 6715fe8a94..83549eaa16 100644 --- a/src/bonsai/test/core/bootstrap.py +++ b/src/bonsai/test/core/bootstrap.py @@ -270,6 +270,16 @@ def voider(): prophet.verify() +# Saikei Civil modules. + + +@pytest.fixture +def alignment(): + prophet = Prophecy(bonsai.core.tool.Alignment) + yield prophet + prophet.verify() + + def flatten(iterable): for item in iterable: if isinstance(item, (list, tuple)): diff --git a/src/bonsai/test/core/test_alignment.py b/src/bonsai/test/core/test_alignment.py new file mode 100644 index 0000000000..2aea82f959 --- /dev/null +++ b/src/bonsai/test/core/test_alignment.py @@ -0,0 +1,229 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 Michael Yoder +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . + +import pytest + +import bonsai.core.alignment as subject +from test.core.bootstrap import alignment, ifc + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +class FakeIfcEntity(dict): + """A JSON-serializable stand-in for an IFC entity. + + Inherits from dict so json.dumps can serialize it when it appears as an + argument to Prophecy-tracked tool methods. The ifc_class key drives + is_a(), and the name key drives the Name property. + """ + + def is_a(self, ifc_class: str) -> bool: + return self.get("ifc_class") == ifc_class + + @property + def Name(self) -> str: + return self.get("name", "Test Entity") + + +class FakeIfcFile: + """Minimal stand-in for an open IFC file.""" + + def __init__(self, entity=None, not_found: bool = False): + self._entity = entity + self._not_found = not_found + + def by_id(self, entity_id: int): + if self._not_found: + raise RuntimeError(f"Could not find #{entity_id}") + return self._entity + + +def make_alignment_entity(name: str = "Test Alignment") -> FakeIfcEntity: + return FakeIfcEntity({"ifc_class": "IfcAlignment", "name": name}) + + +def make_non_alignment_entity(name: str = "Wall") -> FakeIfcEntity: + return FakeIfcEntity({"ifc_class": "IfcWall", "name": name}) + + +# --------------------------------------------------------------------------- +# enter_pi_edit_mode +# --------------------------------------------------------------------------- + + +class TestEnterPiEditMode: + def test_raises_when_no_ifc_file_loaded(self, ifc, alignment): + ifc.get().should_be_called().will_return(None) + with pytest.raises(ValueError, match="No IFC file loaded"): + subject.enter_pi_edit_mode(ifc, alignment, alignment_id=1) + + def test_raises_when_alignment_not_found(self, ifc, alignment): + ifc.get().should_be_called().will_return(FakeIfcFile(not_found=True)) + with pytest.raises(ValueError, match="not found"): + subject.enter_pi_edit_mode(ifc, alignment, alignment_id=1) + + def test_raises_when_entity_is_not_an_alignment(self, ifc, alignment): + entity = make_non_alignment_entity() + ifc.get().should_be_called().will_return(FakeIfcFile(entity=entity)) + with pytest.raises(ValueError, match="not an IfcAlignment"): + subject.enter_pi_edit_mode(ifc, alignment, alignment_id=1) + + def test_raises_when_alignment_has_no_horizontal_layout(self, ifc, alignment): + entity = make_alignment_entity() + ifc.get().should_be_called().will_return(FakeIfcFile(entity=entity)) + alignment.get_horizontal_layout(entity).should_be_called().will_return(None) + with pytest.raises(ValueError, match="no horizontal layout"): + subject.enter_pi_edit_mode(ifc, alignment, alignment_id=1) + + def test_raises_when_alignment_has_no_real_segments(self, ifc, alignment): + entity = make_alignment_entity() + ifc.get().should_be_called().will_return(FakeIfcFile(entity=entity)) + alignment.get_horizontal_layout(entity).should_be_called().will_return("h_layout") + alignment.layout_has_real_segments("h_layout").should_be_called().will_return(False) + with pytest.raises(ValueError, match="no editable segments"): + subject.enter_pi_edit_mode(ifc, alignment, alignment_id=1) + + def test_raises_when_back_calculated_pis_fewer_than_two(self, ifc, alignment): + entity = make_alignment_entity() + ifc.get().should_be_called().will_return(FakeIfcFile(entity=entity)) + alignment.get_horizontal_layout(entity).should_be_called().will_return("h_layout") + alignment.layout_has_real_segments("h_layout").should_be_called().will_return(True) + alignment.back_calculate_pis_from_alignment(entity).should_be_called().will_return([(0.0, 0.0)]) + with pytest.raises(ValueError, match="at least 2 PIs"): + subject.enter_pi_edit_mode(ifc, alignment, alignment_id=1) + + def test_returns_empties_for_valid_alignment(self, ifc, alignment): + entity = make_alignment_entity() + pis = [(0.0, 0.0), (100.0, 0.0), (200.0, 50.0)] + empties = ["empty_0", "empty_1", "empty_2"] + ifc.get().should_be_called().will_return(FakeIfcFile(entity=entity)) + alignment.get_horizontal_layout(entity).should_be_called().will_return("h_layout") + alignment.layout_has_real_segments("h_layout").should_be_called().will_return(True) + alignment.back_calculate_pis_from_alignment(entity).should_be_called().will_return(pis) + alignment.create_pi_edit_empties(entity, pis).should_be_called().will_return(empties) + result = subject.enter_pi_edit_mode(ifc, alignment, alignment_id=1) + assert result == empties + + +# --------------------------------------------------------------------------- +# exit_pi_edit_mode +# --------------------------------------------------------------------------- + + +class TestExitPiEditMode: + def test_cleans_up_and_returns_true_when_no_ifc_file(self, ifc, alignment): + ifc.get().should_be_called().will_return(None) + alignment.remove_pi_edit_empties(1).should_be_called() + result = subject.exit_pi_edit_mode(ifc, alignment, alignment_id=1, apply=True) + assert result is True + + def test_cleans_up_and_returns_true_when_alignment_deleted(self, ifc, alignment): + ifc.get().should_be_called().will_return(FakeIfcFile(not_found=True)) + alignment.remove_pi_edit_empties(1).should_be_called() + result = subject.exit_pi_edit_mode(ifc, alignment, alignment_id=1, apply=True) + assert result is True + + def test_removes_empties_and_returns_true_when_apply_is_false(self, ifc, alignment): + entity = make_alignment_entity() + ifc.get().should_be_called().will_return(FakeIfcFile(entity=entity)) + alignment.remove_pi_edit_empties(1).should_be_called() + result = subject.exit_pi_edit_mode(ifc, alignment, alignment_id=1, apply=False) + assert result is True + + def test_raises_when_fewer_than_two_pis_collected_on_apply(self, ifc, alignment): + entity = make_alignment_entity() + ifc.get().should_be_called().will_return(FakeIfcFile(entity=entity)) + alignment.collect_pis_from_empties(1).should_be_called().will_return(([(0.0, 0.0)], [0.0])) + with pytest.raises(ValueError, match="At least 2 PIs"): + subject.exit_pi_edit_mode(ifc, alignment, alignment_id=1, apply=True) + + def test_raises_when_no_horizontal_layout_on_apply(self, ifc, alignment): + entity = make_alignment_entity() + hpoints = [(0.0, 0.0), (100.0, 0.0)] + radii = [0.0, 0.0] + ifc.get().should_be_called().will_return(FakeIfcFile(entity=entity)) + alignment.collect_pis_from_empties(1).should_be_called().will_return((hpoints, radii)) + alignment.get_horizontal_layout(entity).should_be_called().will_return(None) + with pytest.raises(ValueError, match="no horizontal layout"): + subject.exit_pi_edit_mode(ifc, alignment, alignment_id=1, apply=True) + + def test_applies_new_pis_without_layout_obj_and_returns_true(self, ifc, alignment): + entity = make_alignment_entity() + hpoints = [(0.0, 0.0), (100.0, 0.0)] + radii = [0.0, 0.0] + ifc.get().should_be_called().will_return(FakeIfcFile(entity=entity)) + alignment.collect_pis_from_empties(1).should_be_called().will_return((hpoints, radii)) + alignment.get_horizontal_layout(entity).should_be_called().will_return("h_layout") + alignment.remove_pi_edit_empties(1).should_be_called() + alignment.remove_layout_segment_objects("h_layout").should_be_called() + alignment.clear_layout_segments("h_layout").should_be_called() + alignment.layout_by_pi_method("h_layout", hpoints, radii).should_be_called() + ifc.get_object("h_layout").should_be_called().will_return(None) + result = subject.exit_pi_edit_mode(ifc, alignment, alignment_id=1, apply=True) + assert result is True + + def test_creates_segment_objects_when_layout_obj_exists(self, ifc, alignment): + entity = make_alignment_entity() + hpoints = [(0.0, 0.0), (100.0, 0.0)] + radii = [0.0, 0.0] + ifc.get().should_be_called().will_return(FakeIfcFile(entity=entity)) + alignment.collect_pis_from_empties(1).should_be_called().will_return((hpoints, radii)) + alignment.get_horizontal_layout(entity).should_be_called().will_return("h_layout") + alignment.remove_pi_edit_empties(1).should_be_called() + alignment.remove_layout_segment_objects("h_layout").should_be_called() + alignment.clear_layout_segments("h_layout").should_be_called() + alignment.layout_by_pi_method("h_layout", hpoints, radii).should_be_called() + ifc.get_object("h_layout").should_be_called().will_return("layout_obj") + alignment.create_objects_for_layout_segments("h_layout", "layout_obj").should_be_called() + result = subject.exit_pi_edit_mode(ifc, alignment, alignment_id=1, apply=True) + assert result is True + + +# --------------------------------------------------------------------------- +# import_alignment_csv +# --------------------------------------------------------------------------- + + +class TestImportAlignmentCsv: + def test_raises_when_no_ifc_file_loaded(self, ifc, alignment): + ifc.get().should_be_called().will_return(None) + with pytest.raises(ValueError, match="No IFC file loaded"): + subject.import_alignment_csv(ifc, alignment, filepath="pis.csv") + + def test_imports_and_builds_hierarchy_for_parent_only(self, ifc, alignment): + ifc.get().should_be_called().will_return("ifc_file") + alignment.create_alignment_from_csv("pis.csv").should_be_called().will_return("parent") + alignment.create_hierarchy_for_alignment("parent").should_be_called() + alignment.get_child_alignments("parent").should_be_called().will_return([]) + alignment.create_objects_for_referents("parent").should_be_called() + result = subject.import_alignment_csv(ifc, alignment, filepath="pis.csv") + assert result == "parent" + + def test_builds_hierarchy_for_each_aggregated_child(self, ifc, alignment): + ifc.get().should_be_called().will_return("ifc_file") + alignment.create_alignment_from_csv("pis.csv").should_be_called().will_return("parent") + alignment.create_hierarchy_for_alignment("parent").should_be_called() + alignment.get_child_alignments("parent").should_be_called().will_return(["child_a", "child_b"]) + alignment.create_hierarchy_for_alignment("child_a").should_be_called() + alignment.create_hierarchy_for_alignment("child_b").should_be_called() + alignment.create_objects_for_referents("parent").should_be_called() + result = subject.import_alignment_csv(ifc, alignment, filepath="pis.csv") + assert result == "parent" diff --git a/src/bonsai/test/tool/test_alignment.py b/src/bonsai/test/tool/test_alignment.py new file mode 100644 index 0000000000..0318b9aafd --- /dev/null +++ b/src/bonsai/test/tool/test_alignment.py @@ -0,0 +1,1040 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 Michael Yoder +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . + +import math +import pytest +import bpy +import ifcopenshell +import ifcopenshell.api.alignment as align_api +import bonsai.tool as tool +from bonsai.tool.alignment import Alignment as subject +from test.bim.bootstrap import NewFile, NewIfc4X3 + + +def _geometry_mapping_available() -> bool: + """True when the modular geometry-mapping plugins are present. + + v0.9.0 evaluates segment endpoints through the geometry engine, which + loads per-schema ifcopenshell_geometry_mapping_* plugins at runtime. The + win64 v0.9.0alpha0 builds ship without them (IfcOpenShell#9301), so + geometry-dependent tests skip locally and run in CI where builds are + complete. + """ + import pathlib + + package_root = pathlib.Path(ifcopenshell.__file__).parent + return any(f.name.startswith("ifcopenshell_geometry_mapping_") for f in package_root.iterdir()) + + +requires_geometry_engine = pytest.mark.skipif( + not _geometry_mapping_available(), + reason="geometry mapping plugins unavailable (IfcOpenShell#9301); covered in CI", +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +TOLERANCE = 1e-9 + + +def assert_close(actual: float, expected: float, tol: float = TOLERANCE) -> None: + assert abs(actual - expected) < tol, f"Expected {expected}, got {actual} (diff={abs(actual-expected):.2e})" + + +class _FakeDesignParams: + """Minimal stand-in for an IfcAlignmentHorizontalSegment or similar.""" + + def __init__(self, ifc_class: str, segment_length: float = 0.0, horizontal_length: float = 0.0): + self._ifc_class = ifc_class + self.SegmentLength = segment_length + self.HorizontalLength = horizontal_length + + def is_a(self, ifc_class: str) -> bool: + return self._ifc_class == ifc_class + + +class _FakeSegment: + """Minimal stand-in for an IfcAlignmentSegment.""" + + def __init__(self, design_params=None): + self.DesignParameters = design_params + + +# --------------------------------------------------------------------------- +# calculate_pi_geometry +# --------------------------------------------------------------------------- + + +class TestCalculatePiGeometry(NewFile): + def test_returns_empty_result_for_empty_pi_list(self): + result = subject.calculate_pi_geometry([]) + assert result.stations == [] + assert result.total_length == 0.0 + + def test_returns_single_point_result_for_one_pi(self): + result = subject.calculate_pi_geometry([(50.0, 100.0)]) + assert len(result.stations) == 1 + assert result.total_length == 0.0 + + def test_calculates_length_between_two_points(self): + result = subject.calculate_pi_geometry([(0.0, 0.0), (100.0, 0.0)]) + assert_close(result.total_length, 100.0) + assert_close(result.lengths[0], 100.0) + + def test_calculates_due_east_direction(self): + result = subject.calculate_pi_geometry([(0.0, 0.0), (100.0, 0.0)]) + assert_close(result.directions[0], 0.0) + + def test_calculates_due_north_direction(self): + result = subject.calculate_pi_geometry([(0.0, 0.0), (0.0, 100.0)]) + assert_close(result.directions[0], math.pi / 2) + + def test_calculates_diagonal_length(self): + result = subject.calculate_pi_geometry([(0.0, 0.0), (3.0, 4.0)]) + assert_close(result.total_length, 5.0) + + def test_calculates_stations_for_three_pis(self): + pis = [(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)] + result = subject.calculate_pi_geometry(pis) + assert_close(result.stations[0], 0.0) + assert_close(result.stations[1], 100.0) + assert_close(result.stations[2], 200.0) + assert_close(result.total_length, 200.0) + + def test_applies_start_station_offset(self): + pis = [(0.0, 0.0), (100.0, 0.0)] + result = subject.calculate_pi_geometry(pis, start_station=1000.0) + assert_close(result.stations[0], 1000.0) + assert_close(result.stations[1], 1100.0) + assert_close(result.total_length, 100.0) + + def test_last_pi_has_zero_length_and_direction(self): + result = subject.calculate_pi_geometry([(0.0, 0.0), (100.0, 0.0)]) + assert_close(result.lengths[-1], 0.0) + assert_close(result.directions[-1], 0.0) + + +# --------------------------------------------------------------------------- +# calculate_tangent_length T = R * tan(Δ/2) +# --------------------------------------------------------------------------- + + +class TestCalculateTangentLength(NewFile): + def test_returns_zero_for_zero_radius(self): + assert_close(subject.calculate_tangent_length(0.0, math.pi / 2), 0.0) + + def test_returns_zero_for_zero_deflection(self): + assert_close(subject.calculate_tangent_length(300.0, 0.0), 0.0) + + def test_calculates_tangent_for_30_degree_deflection(self): + deflection = math.radians(30) + expected = 300.0 * math.tan(deflection / 2) + assert_close(subject.calculate_tangent_length(300.0, deflection), expected) + + def test_calculates_tangent_for_90_degree_deflection(self): + deflection = math.pi / 2 + expected = 100.0 * math.tan(math.pi / 4) # R * tan(45°) = R + assert_close(subject.calculate_tangent_length(100.0, deflection), expected) + + +# --------------------------------------------------------------------------- +# calculate_arc_length L = R * Δ +# --------------------------------------------------------------------------- + + +class TestCalculateArcLength(NewFile): + def test_calculates_arc_for_90_degree_curve(self): + expected = 100.0 * math.pi / 2 + assert_close(subject.calculate_arc_length(100.0, math.pi / 2), expected) + + def test_calculates_arc_for_full_circle(self): + expected = 50.0 * 2 * math.pi + assert_close(subject.calculate_arc_length(50.0, 2 * math.pi), expected) + + def test_zero_radius_yields_zero_length(self): + assert_close(subject.calculate_arc_length(0.0, math.pi / 2), 0.0) + + def test_zero_deflection_yields_zero_length(self): + assert_close(subject.calculate_arc_length(100.0, 0.0), 0.0) + + +# --------------------------------------------------------------------------- +# deflection_angle_from_points +# --------------------------------------------------------------------------- + + +class TestDeflectionAngleFromPoints(NewFile): + def test_returns_zero_for_straight_alignment(self): + angle = subject.deflection_angle_from_points((0.0, 0.0), (100.0, 0.0), (200.0, 0.0)) + assert_close(angle, 0.0) + + def test_positive_for_90_degree_left_turn(self): + """Turning left (CCW) is a positive deflection.""" + angle = subject.deflection_angle_from_points((0.0, 0.0), (100.0, 0.0), (100.0, 100.0)) + assert_close(angle, math.pi / 2) + + def test_negative_for_90_degree_right_turn(self): + """Turning right (CW) is a negative deflection.""" + angle = subject.deflection_angle_from_points((0.0, 0.0), (100.0, 0.0), (100.0, -100.0)) + assert_close(angle, -math.pi / 2) + + def test_returns_pi_for_u_turn(self): + """180-degree turn.""" + angle = subject.deflection_angle_from_points((0.0, 0.0), (100.0, 0.0), (0.0, 0.0)) + assert_close(abs(angle), math.pi) + + def test_normalises_angle_into_minus_pi_to_pi_range(self): + """Result must always be in (-π, π].""" + angle = subject.deflection_angle_from_points((0.0, 0.0), (100.0, 0.0), (50.0, -50.0)) + assert -math.pi < angle <= math.pi + + +# --------------------------------------------------------------------------- +# arc_length_at_pi +# --------------------------------------------------------------------------- + + +class TestArcLengthAtPi(NewFile): + def test_returns_zero_for_zero_radius(self): + arc = subject.arc_length_at_pi((0.0, 0.0), (100.0, 0.0), (100.0, 100.0), radius=0.0) + assert_close(arc, 0.0) + + def test_returns_zero_for_negative_radius(self): + arc = subject.arc_length_at_pi((0.0, 0.0), (100.0, 0.0), (100.0, 100.0), radius=-100.0) + assert_close(arc, 0.0) + + def test_calculates_arc_for_90_degree_left_turn(self): + expected = 100.0 * math.pi / 2 + arc = subject.arc_length_at_pi((0.0, 0.0), (100.0, 0.0), (100.0, 100.0), radius=100.0) + assert_close(arc, expected) + + def test_calculates_arc_for_90_degree_right_turn(self): + """Sign of deflection should not affect arc length.""" + expected = 100.0 * math.pi / 2 + arc = subject.arc_length_at_pi((0.0, 0.0), (100.0, 0.0), (100.0, -100.0), radius=100.0) + assert_close(arc, expected) + + +# --------------------------------------------------------------------------- +# tangent_length_at_pi +# --------------------------------------------------------------------------- + + +class TestTangentLengthAtPi(NewFile): + def test_returns_zero_for_zero_radius(self): + t = subject.tangent_length_at_pi((0.0, 0.0), (100.0, 0.0), (100.0, 100.0), radius=0.0) + assert_close(t, 0.0) + + def test_returns_zero_for_negative_radius(self): + t = subject.tangent_length_at_pi((0.0, 0.0), (100.0, 0.0), (100.0, 100.0), radius=-100.0) + assert_close(t, 0.0) + + def test_calculates_tangent_for_90_degree_left_turn(self): + expected = 100.0 * math.tan(math.pi / 4) # R * tan(45°) + t = subject.tangent_length_at_pi((0.0, 0.0), (100.0, 0.0), (100.0, 100.0), radius=100.0) + assert_close(t, expected) + + def test_matches_calculate_tangent_length_for_same_geometry(self): + """tangent_length_at_pi must agree with calculate_tangent_length.""" + deflection = abs(subject.deflection_angle_from_points((0.0, 0.0), (100.0, 0.0), (100.0, 100.0))) + expected = subject.calculate_tangent_length(300.0, deflection) + actual = subject.tangent_length_at_pi((0.0, 0.0), (100.0, 0.0), (100.0, 100.0), radius=300.0) + assert_close(actual, expected) + + +# --------------------------------------------------------------------------- +# tangent_segment_length +# --------------------------------------------------------------------------- + + +class TestTangentSegmentLength(NewFile): + def test_returns_full_distance_with_no_tangents(self): + length = subject.tangent_segment_length((0.0, 0.0), (100.0, 0.0)) + assert_close(length, 100.0) + + def test_subtracts_start_tangent(self): + length = subject.tangent_segment_length((0.0, 0.0), (100.0, 0.0), start_tangent=20.0) + assert_close(length, 80.0) + + def test_subtracts_end_tangent(self): + length = subject.tangent_segment_length((0.0, 0.0), (100.0, 0.0), end_tangent=30.0) + assert_close(length, 70.0) + + def test_subtracts_both_tangents(self): + length = subject.tangent_segment_length((0.0, 0.0), (100.0, 0.0), start_tangent=20.0, end_tangent=30.0) + assert_close(length, 50.0) + + def test_clamps_to_zero_when_tangents_exceed_full_distance(self): + length = subject.tangent_segment_length((0.0, 0.0), (100.0, 0.0), start_tangent=70.0, end_tangent=70.0) + assert_close(length, 0.0) + + def test_works_on_diagonal_leg(self): + """3-4-5 triangle: full_length=5, minus tangents=2 → 3.""" + length = subject.tangent_segment_length((0.0, 0.0), (3.0, 4.0), start_tangent=1.0, end_tangent=1.0) + assert_close(length, 3.0) + + +# --------------------------------------------------------------------------- +# is_zero_length_segment +# --------------------------------------------------------------------------- + + +class TestIsZeroLengthSegment(NewFile): + def test_returns_false_when_segment_has_no_design_parameters(self): + seg = _FakeSegment(design_params=None) + assert subject.is_zero_length_segment(seg) is False + + def test_returns_true_for_horizontal_segment_with_zero_length(self): + dp = _FakeDesignParams("IfcAlignmentHorizontalSegment", segment_length=0.0) + seg = _FakeSegment(dp) + assert subject.is_zero_length_segment(seg) is True + + def test_returns_false_for_horizontal_segment_with_nonzero_length(self): + dp = _FakeDesignParams("IfcAlignmentHorizontalSegment", segment_length=100.0) + seg = _FakeSegment(dp) + assert subject.is_zero_length_segment(seg) is False + + def test_returns_true_for_vertical_segment_with_zero_horizontal_length(self): + dp = _FakeDesignParams("IfcAlignmentVerticalSegment", horizontal_length=0.0) + seg = _FakeSegment(dp) + assert subject.is_zero_length_segment(seg) is True + + def test_returns_false_for_vertical_segment_with_nonzero_horizontal_length(self): + dp = _FakeDesignParams("IfcAlignmentVerticalSegment", horizontal_length=50.0) + seg = _FakeSegment(dp) + assert subject.is_zero_length_segment(seg) is False + + def test_returns_true_for_cant_segment_with_zero_horizontal_length(self): + dp = _FakeDesignParams("IfcAlignmentCantSegment", horizontal_length=0.0) + seg = _FakeSegment(dp) + assert subject.is_zero_length_segment(seg) is True + + def test_returns_false_for_unknown_segment_type(self): + dp = _FakeDesignParams("IfcUnknownSegmentType", segment_length=0.0) + seg = _FakeSegment(dp) + assert subject.is_zero_length_segment(seg) is False + + def test_uses_near_zero_tolerance(self): + """Lengths below 1e-6 should be considered zero.""" + dp = _FakeDesignParams("IfcAlignmentHorizontalSegment", segment_length=1e-7) + seg = _FakeSegment(dp) + assert subject.is_zero_length_segment(seg) is True + + +# --------------------------------------------------------------------------- +# layout_has_real_segments +# --------------------------------------------------------------------------- + + +class _FakeAlignmentSegment: + """Stand-in for IfcAlignmentSegment: is_a() returns True for IfcAlignmentSegment.""" + + def __init__(self, design_params=None): + self.DesignParameters = design_params + + def is_a(self, ifc_class: str) -> bool: + return ifc_class == "IfcAlignmentSegment" + + +class _FakeRelNests: + def __init__(self, related_objects): + self.RelatedObjects = related_objects + + +class _FakeLayout: + def __init__(self, rels=None): + self.IsNestedBy = rels or [] + + +class TestLayoutHasRealSegments(NewFile): + def test_returns_false_for_layout_with_no_nested_relationships(self): + layout = _FakeLayout(rels=[]) + assert subject.layout_has_real_segments(layout) is False + + def test_returns_false_for_layout_with_empty_related_objects(self): + layout = _FakeLayout(rels=[_FakeRelNests(related_objects=[])]) + assert subject.layout_has_real_segments(layout) is False + + def test_returns_false_when_only_segment_is_zero_length_terminator(self): + dp = _FakeDesignParams("IfcAlignmentHorizontalSegment", segment_length=0.0) + terminator = _FakeAlignmentSegment(dp) + layout = _FakeLayout(rels=[_FakeRelNests([terminator])]) + assert subject.layout_has_real_segments(layout) is False + + def test_returns_true_when_one_real_segment_exists(self): + dp = _FakeDesignParams("IfcAlignmentHorizontalSegment", segment_length=100.0) + real_seg = _FakeAlignmentSegment(dp) + layout = _FakeLayout(rels=[_FakeRelNests([real_seg])]) + assert subject.layout_has_real_segments(layout) is True + + def test_returns_true_when_real_segment_follows_terminator(self): + dp_zero = _FakeDesignParams("IfcAlignmentHorizontalSegment", segment_length=0.0) + dp_real = _FakeDesignParams("IfcAlignmentHorizontalSegment", segment_length=50.0) + layout = _FakeLayout(rels=[_FakeRelNests([_FakeAlignmentSegment(dp_zero), _FakeAlignmentSegment(dp_real)])]) + assert subject.layout_has_real_segments(layout) is True + + def test_ignores_non_alignment_segment_objects(self): + """Non-IfcAlignmentSegment objects in RelatedObjects should be ignored.""" + + class _FakeOtherObject: + def is_a(self, ifc_class): + return False + + layout = _FakeLayout(rels=[_FakeRelNests([_FakeOtherObject()])]) + assert subject.layout_has_real_segments(layout) is False + + +# --------------------------------------------------------------------------- +# safe_layout_horizontal_by_pi_method +# --------------------------------------------------------------------------- + + +@requires_geometry_engine +class TestSafeLayoutHorizontalByPiMethod(NewFile): + def test_raises_when_layout_has_no_parent_alignment(self): + """An orphan layout (no parent IfcAlignment) must raise ValueError.""" + ifc = ifcopenshell.file(schema="IFC4X3_ADD2") + tool.Ifc.set(ifc) + orphan_layout = ifc.createIfcAlignmentHorizontal() + with pytest.raises(ValueError, match="no parent IfcAlignment"): + subject.safe_layout_horizontal_by_pi_method( + ifc, orphan_layout, hpoints=[(0.0, 0.0), (100.0, 0.0)], radii=[] + ) + + def test_succeeds_when_layout_has_parent_alignment(self): + """A layout properly nested under an IfcAlignment should not raise.""" + import ifcopenshell.api.root + import ifcopenshell.api.alignment + + ifc = ifcopenshell.file(schema="IFC4X3_ADD2") + tool.Ifc.set(ifc) + # Create a minimal alignment hierarchy + project = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject") + alignment = ifc.createIfcAlignment() + layout = ifc.createIfcAlignmentHorizontal() + ifc.createIfcRelNests(RelatingObject=alignment, RelatedObjects=[layout]) + result = subject.safe_layout_horizontal_by_pi_method( + ifc, layout, hpoints=[(0.0, 0.0), (100.0, 0.0)], radii=[] + ) + assert result is True + + +# =========================================================================== +# Blender-Dependent Tool Tests (require full Bonsai IFC4X3 project) +# =========================================================================== +# These tests exercise methods that create or query Blender objects. +# They use NewIfc4X3 which sets up a clean Blender scene with a Bonsai-managed +# IFC4X3 project (IfcProject + geometric contexts + IfcStore integration). + + +def _create_alignment_with_pis(name="Test Alignment", hpoints=None, radii=None): + """Helper: create an IfcAlignment, add PI segments, return (alignment, h_layout). + + Uses align_api.create() to build the full alignment hierarchy (IfcAlignment + + IfcAlignmentHorizontal + zero-length terminator + geometry + project + aggregation), then lays out horizontal segments via PI method. + """ + if hpoints is None: + hpoints = [(0.0, 0.0), (500.0, 0.0), (1000.0, 200.0)] + if radii is None: + radii = [300.0] + + ifc_file = tool.Ifc.get() + alignment = align_api.create(ifc_file, name=name) + h_layout = align_api.get_horizontal_layout(alignment) + + # Add real segments via PI method + align_api.layout_horizontal_alignment_by_pi_method(ifc_file, h_layout, hpoints, radii) + + return alignment, h_layout + + +# --------------------------------------------------------------------------- +# get_horizontal_layout (IFC queries, no bpy needed) +# --------------------------------------------------------------------------- + + +class TestGetHorizontalLayout(NewIfc4X3): + """Tests for Alignment.get_horizontal_layout().""" + + def test_returns_horizontal_layout_from_alignment(self): + ifc_file = tool.Ifc.get() + alignment = align_api.create(ifc_file, name="HL Test") + h_layout = subject.get_horizontal_layout(alignment) + assert h_layout is not None + assert h_layout.is_a("IfcAlignmentHorizontal") + + def test_returns_none_for_alignment_without_horizontal(self): + ifc_file = tool.Ifc.get() + # Create a bare alignment without the helper (no nesting) + alignment = ifc_file.createIfcAlignment( + GlobalId=ifcopenshell.guid.new(), Name="Bare" + ) + h_layout = subject.get_horizontal_layout(alignment) + assert h_layout is None + + +# --------------------------------------------------------------------------- +# layout_by_pi_method (IFC + tool.Ifc integration) +# --------------------------------------------------------------------------- + + +@requires_geometry_engine +class TestLayoutByPiMethod(NewIfc4X3): + """Tests for Alignment.layout_by_pi_method() — IFC segment creation.""" + + def test_creates_ifc_segments_for_straight_alignment(self): + ifc_file = tool.Ifc.get() + alignment = align_api.create(ifc_file, name="Straight") + h_layout = subject.get_horizontal_layout(alignment) + + subject.layout_by_pi_method(h_layout, [(0.0, 0.0), (1000.0, 0.0)], []) + + segments = align_api.get_layout_segments(h_layout) + real_segments = [s for s in segments if not subject.is_zero_length_segment(s)] + assert len(real_segments) >= 1 # At least one tangent + + def test_creates_arc_segment_for_curve(self): + ifc_file = tool.Ifc.get() + alignment = align_api.create(ifc_file, name="Curve") + h_layout = subject.get_horizontal_layout(alignment) + + subject.layout_by_pi_method( + h_layout, [(0.0, 0.0), (500.0, 0.0), (1000.0, 200.0)], [300.0] + ) + + segments = align_api.get_layout_segments(h_layout) + real_segments = [s for s in segments if not subject.is_zero_length_segment(s)] + segment_types = [s.DesignParameters.PredefinedType for s in real_segments if s.DesignParameters] + assert "LINE" in segment_types + assert "CIRCULARARC" in segment_types + + +# --------------------------------------------------------------------------- +# back_calculate_pis_from_alignment (IFC + unit conversion) +# --------------------------------------------------------------------------- + + +@requires_geometry_engine +class TestBackCalculatePisFromAlignment(NewIfc4X3): + """Tests for Alignment.back_calculate_pis_from_alignment() — PI recovery.""" + + def test_recovers_endpoints_from_straight_alignment(self): + alignment, _ = _create_alignment_with_pis( + hpoints=[(0.0, 0.0), (1000.0, 0.0)], radii=[] + ) + pis = subject.back_calculate_pis_from_alignment(alignment) + assert len(pis) >= 2 + assert pis[0]["pi_type"] == "ENDPOINT" + assert pis[-1]["pi_type"] == "ENDPOINT" + assert_close(pis[0]["e"], 0.0, tol=0.01) + assert_close(pis[0]["n"], 0.0, tol=0.01) + assert_close(pis[-1]["e"], 1000.0, tol=0.01) + assert_close(pis[-1]["n"], 0.0, tol=0.01) + + def test_recovers_curve_pi_with_radius(self): + alignment, _ = _create_alignment_with_pis( + hpoints=[(0.0, 0.0), (500.0, 0.0), (1000.0, 200.0)], radii=[300.0] + ) + pis = subject.back_calculate_pis_from_alignment(alignment) + # Should have 3 PIs: start endpoint, curve PI, end endpoint + assert len(pis) == 3 + curve_pis = [p for p in pis if p["pi_type"] == "CURVE"] + assert len(curve_pis) == 1 + assert_close(curve_pis[0]["e"], 500.0, tol=1.0) + assert_close(curve_pis[0]["n"], 0.0, tol=1.0) + assert curve_pis[0]["radius"] > 0 + + def test_raises_for_alignment_without_horizontal_layout(self): + ifc_file = tool.Ifc.get() + alignment = ifc_file.createIfcAlignment( + GlobalId=ifcopenshell.guid.new(), Name="Bare" + ) + with pytest.raises(ValueError, match="no horizontal layout"): + subject.back_calculate_pis_from_alignment(alignment) + + def test_raises_for_alignment_with_only_terminator(self): + ifc_file = tool.Ifc.get() + alignment = align_api.create(ifc_file, name="EmptyLayout") + # align_api.create() produces a horizontal layout with only a zero-length terminator + with pytest.raises(ValueError, match="no real segments"): + subject.back_calculate_pis_from_alignment(alignment) + + def test_roundtrip_preserves_pi_positions(self): + """Create alignment from PIs, back-calculate, verify positions match.""" + original_hpoints = [(0.0, 0.0), (500.0, 0.0), (1000.0, 200.0)] + original_radii = [300.0] + alignment, _ = _create_alignment_with_pis( + hpoints=original_hpoints, radii=original_radii + ) + + recovered_pis = subject.back_calculate_pis_from_alignment(alignment) + assert len(recovered_pis) == len(original_hpoints) + + for original, recovered in zip(original_hpoints, recovered_pis): + assert_close(recovered["e"], original[0], tol=1.0) + assert_close(recovered["n"], original[1], tol=1.0) + + +# --------------------------------------------------------------------------- +# Blender Object Creation Methods +# --------------------------------------------------------------------------- + + +class TestCreateObjectForAlignment(NewIfc4X3): + """Tests for Alignment.create_object_for_alignment().""" + + def test_creates_empty_object_for_alignment(self): + ifc_file = tool.Ifc.get() + alignment = align_api.create(ifc_file, name="ObjTest") + obj = subject.create_object_for_alignment(alignment) + assert obj is not None + assert obj.type == "EMPTY" + assert "IfcAlignment" in obj.name + + def test_links_blender_object_to_ifc_entity(self): + ifc_file = tool.Ifc.get() + alignment = align_api.create(ifc_file, name="LinkTest") + obj = subject.create_object_for_alignment(alignment) + # Verify bidirectional IFC link + assert tool.Ifc.get_object(alignment) == obj + assert tool.Ifc.get_entity(obj) == alignment + + def test_returns_existing_object_if_already_linked(self): + ifc_file = tool.Ifc.get() + alignment = align_api.create(ifc_file, name="DupTest") + obj1 = subject.create_object_for_alignment(alignment) + obj2 = subject.create_object_for_alignment(alignment) + assert obj1 == obj2 # Same object returned, not a duplicate + + +class TestCreateObjectForLayout(NewIfc4X3): + """Tests for Alignment.create_object_for_layout().""" + + def test_creates_empty_object_for_horizontal_layout(self): + ifc_file = tool.Ifc.get() + alignment = align_api.create(ifc_file, name="LayoutObj") + h_layout = align_api.get_horizontal_layout(alignment) + alignment_obj = subject.create_object_for_alignment(alignment) + layout_obj = subject.create_object_for_layout(h_layout, alignment_obj) + assert layout_obj is not None + assert layout_obj.type == "EMPTY" + assert "IfcAlignmentHorizontal" in layout_obj.name + + def test_layout_object_is_parented_to_alignment_object(self): + ifc_file = tool.Ifc.get() + alignment = align_api.create(ifc_file, name="ParentTest") + h_layout = align_api.get_horizontal_layout(alignment) + alignment_obj = subject.create_object_for_alignment(alignment) + layout_obj = subject.create_object_for_layout(h_layout, alignment_obj) + assert layout_obj.parent == alignment_obj + + +class TestCreateHierarchyForAlignment(NewIfc4X3): + """Tests for Alignment.create_hierarchy_for_alignment() — full hierarchy creation.""" + + def test_creates_alignment_and_layout_objects(self): + ifc_file = tool.Ifc.get() + alignment = align_api.create(ifc_file, name="Hierarchy") + root_obj = subject.create_hierarchy_for_alignment(alignment) + assert root_obj is not None + assert tool.Ifc.get_entity(root_obj) == alignment + # Should have at least one child (the horizontal layout object) + child_objects = [o for o in bpy.data.objects if o.parent == root_obj] + assert len(child_objects) >= 1 + # One of the children should be linked to the horizontal layout + h_layout = align_api.get_horizontal_layout(alignment) + layout_obj = tool.Ifc.get_object(h_layout) + assert layout_obj is not None + assert layout_obj.parent == root_obj + + def test_creates_vertical_layout_object_when_present(self): + ifc_file = tool.Ifc.get() + alignment = align_api.create(ifc_file, name="WithVert", include_vertical=True) + root_obj = subject.create_hierarchy_for_alignment(alignment) + v_layout = align_api.get_vertical_layout(alignment) + assert v_layout is not None + v_layout_obj = tool.Ifc.get_object(v_layout) + assert v_layout_obj is not None + assert v_layout_obj.parent == root_obj + + +# --------------------------------------------------------------------------- +# get_active_alignment +# --------------------------------------------------------------------------- + + +class TestGetActiveAlignment(NewIfc4X3): + """Tests for Alignment.get_active_alignment() — scene context queries.""" + + def test_returns_none_when_no_object_is_active(self): + bpy.context.view_layer.objects.active = None + result = subject.get_active_alignment() + assert result is None + + def test_returns_none_when_active_object_is_not_alignment(self): + # Active object is some random cube, not an IFC alignment + bpy.ops.mesh.primitive_cube_add() + assert subject.get_active_alignment() is None + + def test_returns_alignment_when_active_object_is_linked(self): + ifc_file = tool.Ifc.get() + alignment = align_api.create(ifc_file, name="Active") + obj = subject.create_object_for_alignment(alignment) + bpy.context.view_layer.objects.active = obj + result = subject.get_active_alignment() + assert result is not None + assert result.id() == alignment.id() + + +# --------------------------------------------------------------------------- +# PI Edit Empties +# --------------------------------------------------------------------------- + + +@requires_geometry_engine +class TestCreatePiEditEmpties(NewIfc4X3): + """Tests for Alignment.create_pi_edit_empties().""" + + def test_creates_empties_at_pi_positions(self): + alignment, _ = _create_alignment_with_pis( + hpoints=[(0.0, 0.0), (500.0, 0.0), (1000.0, 200.0)], radii=[300.0] + ) + alignment_obj = subject.create_hierarchy_for_alignment(alignment) + bpy.context.view_layer.objects.active = alignment_obj + + pis = subject.back_calculate_pis_from_alignment(alignment) + empties = subject.create_pi_edit_empties(alignment, pis) + + assert len(empties) == len(pis) + for empty in empties: + assert empty.type == "EMPTY" + assert empty.get("civil_is_pi_empty") is True + assert empty.get("civil_alignment_id") == alignment.id() + + def test_empties_are_parented_to_alignment_object(self): + alignment, _ = _create_alignment_with_pis( + hpoints=[(0.0, 0.0), (500.0, 0.0)], radii=[] + ) + alignment_obj = subject.create_hierarchy_for_alignment(alignment) + pis = subject.back_calculate_pis_from_alignment(alignment) + empties = subject.create_pi_edit_empties(alignment, pis) + for empty in empties: + assert empty.parent == alignment_obj + + def test_empties_have_sequential_pi_indices(self): + alignment, _ = _create_alignment_with_pis( + hpoints=[(0.0, 0.0), (500.0, 0.0), (1000.0, 200.0)], radii=[300.0] + ) + alignment_obj = subject.create_hierarchy_for_alignment(alignment) + pis = subject.back_calculate_pis_from_alignment(alignment) + empties = subject.create_pi_edit_empties(alignment, pis) + indices = [e.get("civil_pi_index") for e in empties] + assert indices == list(range(len(pis))) + + +@requires_geometry_engine +class TestGetPiEditEmpties(NewIfc4X3): + """Tests for Alignment.get_pi_edit_empties().""" + + def test_finds_empties_for_given_alignment_id(self): + alignment, _ = _create_alignment_with_pis( + hpoints=[(0.0, 0.0), (500.0, 0.0)], radii=[] + ) + alignment_obj = subject.create_hierarchy_for_alignment(alignment) + pis = subject.back_calculate_pis_from_alignment(alignment) + subject.create_pi_edit_empties(alignment, pis) + + found = subject.get_pi_edit_empties(alignment.id()) + assert len(found) == len(pis) + + def test_returns_empty_list_when_no_empties_exist(self): + found = subject.get_pi_edit_empties(99999) + assert found == [] + + def test_returns_sorted_by_pi_index(self): + alignment, _ = _create_alignment_with_pis( + hpoints=[(0.0, 0.0), (500.0, 0.0), (1000.0, 200.0)], radii=[300.0] + ) + alignment_obj = subject.create_hierarchy_for_alignment(alignment) + pis = subject.back_calculate_pis_from_alignment(alignment) + subject.create_pi_edit_empties(alignment, pis) + + found = subject.get_pi_edit_empties(alignment.id()) + indices = [e.get("civil_pi_index") for e in found] + assert indices == sorted(indices) + + +@requires_geometry_engine +class TestRemovePiEditEmpties(NewIfc4X3): + """Tests for Alignment.remove_pi_edit_empties().""" + + def test_removes_all_empties_for_alignment(self): + alignment, _ = _create_alignment_with_pis( + hpoints=[(0.0, 0.0), (500.0, 0.0), (1000.0, 200.0)], radii=[300.0] + ) + alignment_obj = subject.create_hierarchy_for_alignment(alignment) + pis = subject.back_calculate_pis_from_alignment(alignment) + subject.create_pi_edit_empties(alignment, pis) + + removed = subject.remove_pi_edit_empties(alignment.id()) + assert removed == len(pis) + assert subject.get_pi_edit_empties(alignment.id()) == [] + + def test_returns_zero_when_no_empties_exist(self): + removed = subject.remove_pi_edit_empties(99999) + assert removed == 0 + + +@requires_geometry_engine +class TestCollectPisFromEmpties(NewIfc4X3): + """Tests for Alignment.collect_pis_from_empties() — reading positions back.""" + + def test_roundtrip_positions_through_empties(self): + """Create empties from PIs, collect back, verify positions match.""" + alignment, _ = _create_alignment_with_pis( + hpoints=[(0.0, 0.0), (500.0, 0.0), (1000.0, 200.0)], radii=[300.0] + ) + alignment_obj = subject.create_hierarchy_for_alignment(alignment) + + pis = subject.back_calculate_pis_from_alignment(alignment) + subject.create_pi_edit_empties(alignment, pis) + + # Force Blender to update transforms (empties are parented) + bpy.context.view_layer.update() + + hpoints_back, radii_back = subject.collect_pis_from_empties(alignment.id()) + assert len(hpoints_back) == len(pis) + + # Positions should round-trip: empties created from pis, collected back + for pi, (back_e, back_n) in zip(pis, hpoints_back): + assert_close(back_e, pi["e"], tol=2.0) # Generous tolerance for georef + assert_close(back_n, pi["n"], tol=2.0) + + def test_collects_radii_for_interior_pis_only(self): + alignment, _ = _create_alignment_with_pis( + hpoints=[(0.0, 0.0), (500.0, 0.0), (1000.0, 200.0)], radii=[300.0] + ) + alignment_obj = subject.create_hierarchy_for_alignment(alignment) + pis = subject.back_calculate_pis_from_alignment(alignment) + subject.create_pi_edit_empties(alignment, pis) + + _, radii_back = subject.collect_pis_from_empties(alignment.id()) + # Radii should have one entry (for the interior PI) + assert len(radii_back) == 1 + assert radii_back[0] > 0 + + def test_returns_empty_when_fewer_than_two_empties(self): + hpoints, radii = subject.collect_pis_from_empties(99999) + assert hpoints == [] + assert radii == [] + + +# --------------------------------------------------------------------------- +# Remove alignment hierarchy +# --------------------------------------------------------------------------- + + +class TestRemoveAlignmentHierarchy(NewIfc4X3): + """Tests for Alignment.remove_alignment_hierarchy() — cleanup.""" + + def test_removes_all_blender_objects_for_alignment(self): + ifc_file = tool.Ifc.get() + alignment = align_api.create(ifc_file, name="RemoveMe") + root_obj = subject.create_hierarchy_for_alignment(alignment) + assert root_obj is not None + + # Count objects before removal (excluding default camera/light) + alignment_objects_before = [ + o for o in bpy.data.objects if tool.Ifc.get_entity(o) + ] + assert len(alignment_objects_before) > 0 + + removed = subject.remove_alignment_hierarchy(alignment) + assert removed > 0 + + # The alignment object should be gone + assert tool.Ifc.get_object(alignment) is None + + +# --------------------------------------------------------------------------- +# IFC Roundtrip (save + reload) +# --------------------------------------------------------------------------- + + +@requires_geometry_engine +class TestIfcSaveReloadRoundtrip(NewIfc4X3): + """Tests verifying alignment data survives IFC file save/reload.""" + + def test_alignment_entities_survive_roundtrip(self): + import tempfile + import os + + alignment, _ = _create_alignment_with_pis( + hpoints=[(0.0, 0.0), (500.0, 0.0), (1000.0, 200.0)], radii=[300.0] + ) + + ifc_file = tool.Ifc.get() + alignment_count_before = len(ifc_file.by_type("IfcAlignment")) + segment_count_before = len(ifc_file.by_type("IfcAlignmentSegment")) + + tmp = tempfile.NamedTemporaryFile(suffix=".ifc", delete=False) + tmp.close() + try: + ifc_file.write(tmp.name) + reloaded = ifcopenshell.open(tmp.name) + + assert len(reloaded.by_type("IfcAlignment")) == alignment_count_before + assert len(reloaded.by_type("IfcAlignmentSegment")) == segment_count_before + assert len(reloaded.by_type("IfcAlignmentHorizontal")) >= 1 + + # Verify segment types survived + segments = reloaded.by_type("IfcAlignmentSegment") + predefined_types = set() + for seg in segments: + dp = seg.DesignParameters + if dp and hasattr(dp, "PredefinedType") and dp.PredefinedType: + predefined_types.add(dp.PredefinedType) + assert "LINE" in predefined_types + assert "CIRCULARARC" in predefined_types + finally: + os.unlink(tmp.name) + + +# --------------------------------------------------------------------------- +# clear_layout_segments (re-implemented after upstream removed the API helper) +# --------------------------------------------------------------------------- + + +@requires_geometry_engine +class TestClearLayoutSegments(NewFile): + """The alignment API exposes no segment-clearing helper and its layout + functions only append, so editing relies on tool.Alignment.clear_layout_segments. + These verify it removes real segments (both halves) without orphans and + keeps the zero-length terminator, for horizontal and vertical layouts.""" + + @staticmethod + def _new_ifc(): + import ifcopenshell.api.root + import ifcopenshell.api.unit + + ifc = ifcopenshell.file(schema="IFC4X3_ADD2") + tool.Ifc.set(ifc) + ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject") + ifcopenshell.api.unit.assign_unit(ifc) + return ifc + + def test_clear_horizontal_keeps_only_terminator(self): + ifc = self._new_ifc() + alignment = align_api.create(ifc, name="Clr", include_vertical=False) + h = align_api.get_horizontal_layout(alignment) + align_api.layout_horizontal_alignment_by_pi_method( + ifc, h, hpoints=[(0.0, 0.0), (500.0, 0.0), (1000.0, 200.0)], radii=[300.0] + ) + assert subject.layout_has_real_segments(h) is True + subject.clear_layout_segments(h) + assert subject.layout_has_real_segments(h) is False + assert len(align_api.get_layout_segments(h)) == 1 # terminator only + + def test_relayout_after_clear_has_no_doubling_or_orphans(self): + ifc = self._new_ifc() + alignment = align_api.create(ifc, name="Clr2", include_vertical=False) + h = align_api.get_horizontal_layout(alignment) + align_api.layout_horizontal_alignment_by_pi_method( + ifc, h, hpoints=[(0.0, 0.0), (500.0, 0.0), (1000.0, 200.0)], radii=[300.0] + ) + subject.clear_layout_segments(h) + align_api.layout_horizontal_alignment_by_pi_method( + ifc, h, hpoints=[(0.0, 0.0), (1000.0, 0.0)], radii=[] + ) + nested = align_api.get_layout_segments(h) + real = [s for s in nested if not subject.is_zero_length_segment(s)] + assert len(real) == 1 # exactly one LINE — no leftover from the first layout + # No orphaned semantic segments left in the file. + assert len(ifc.by_type("IfcAlignmentSegment")) == len(nested) + + def test_clear_vertical_keeps_only_terminator(self): + ifc = self._new_ifc() + alignment = align_api.create(ifc, name="ClrV", include_vertical=False) + h = align_api.get_horizontal_layout(alignment) + align_api.layout_horizontal_alignment_by_pi_method(ifc, h, hpoints=[(0.0, 0.0), (1000.0, 0.0)], radii=[]) + v = align_api.add_vertical_layout(ifc, alignment) + align_api.layout_vertical_alignment_by_pi_method( + ifc, v, [(0.0, 100.0), (500.0, 110.0), (1000.0, 100.0)], [100.0] + ) + assert subject.layout_has_real_segments(v) is True + subject.clear_layout_segments(v) + assert subject.layout_has_real_segments(v) is False + + +@requires_geometry_engine +class TestSetLayoutSegmentsSelectable(NewIfc4X3): + """PI edit mode disables segment-curve selection so clicks hit the PI + empties; set_layout_segments_selectable toggles hide_select accordingly.""" + + def test_toggles_segment_hide_select(self): + ifc_file = tool.Ifc.get() + alignment = align_api.create(ifc_file, name="Sel", include_vertical=False) + h = align_api.get_horizontal_layout(alignment) + align_api.layout_horizontal_alignment_by_pi_method( + ifc_file, h, hpoints=[(0.0, 0.0), (500.0, 0.0), (1000.0, 200.0)], radii=[300.0] + ) + subject.create_hierarchy_for_alignment(alignment) + segment_objects = [o for o in bpy.data.objects if "IfcAlignmentSegment" in o.name] + assert len(segment_objects) >= 1 + + subject.set_layout_segments_selectable(h, False) + assert all(o.hide_select for o in segment_objects) + + subject.set_layout_segments_selectable(h, True) + assert all(not o.hide_select for o in segment_objects) + + +class TestFormatStation(NewFile): + """tool.Alignment.format_station — project-unit-driven stationing notation.""" + + def _make_file(self, length): + import ifcopenshell.api.root + import ifcopenshell.api.unit + + ifc = ifcopenshell.file(schema="IFC4X3_ADD2") + tool.Ifc.set(ifc) + ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject") + ifcopenshell.api.unit.assign_unit(ifc, length=length) + return ifc + + def test_metric_metre_project_uses_three_digit_groups(self): + self._make_file(length={"is_metric": True, "raw": "METERS"}) + assert subject.format_station(10050.0) == "10+050.000" + + def test_imperial_foot_project_uses_two_digit_groups(self): + self._make_file(length={"is_metric": False, "raw": "FEET"}) + assert subject.format_station(10050.0) == "100+50.00" + + def test_zero_station_metric(self): + self._make_file(length={"is_metric": True, "raw": "METERS"}) + assert subject.format_station(0.0) == "0+000.000" + + def test_negative_station_keeps_sign(self): + self._make_file(length={"is_metric": True, "raw": "METERS"}) + assert subject.format_station(-50.0) == "-0+050.000" + + def test_without_project_falls_back_to_plain_number(self): + assert subject.format_station(1234.5) == "1234.50" diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/__init__.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/__init__.py index 43fd669143..98eeaa104b 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/alignment/__init__.py +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/__init__.py @@ -56,6 +56,7 @@ from .add_vertical_layout import add_vertical_layout from .add_zero_length_segment import add_zero_length_segment from .create import create from .create_as_offset_curve import create_as_offset_curve +from .clear_layout_segments import clear_layout_segments from .create_as_polyline import create_as_polyline from .create_by_pi_method import create_by_pi_method from .create_from_csv import create_from_csv @@ -92,6 +93,7 @@ from .layout_vertical_alignment_by_pi_method import ( layout_vertical_alignment_by_pi_method, ) from .name_segments import name_segments +from .segment_vertices import segment_vertices from .update_alignment_parameter_segment_tags import update_alignment_parameter_segment_tags from .update_end_point import update_end_point from .update_fallback_position import update_fallback_position @@ -103,6 +105,7 @@ __all__ = [ "add_stationing_referent", "add_vertical_layout", "add_zero_length_segment", + "clear_layout_segments", "create", "create_as_offset_curve", "create_as_polyline", @@ -137,6 +140,7 @@ __all__ = [ "layout_horizontal_alignment_by_pi_method", "layout_vertical_alignment_by_pi_method", "name_segments", + "segment_vertices", "register_referent_name_callback", "update_alignment_parameter_segment_tags", "update_end_point", diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/_create_geometric_representation.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/_create_geometric_representation.py index 53113aacd3..cbe0a64f0a 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/alignment/_create_geometric_representation.py +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/_create_geometric_representation.py @@ -142,7 +142,7 @@ def _create_geometric_representation(file: ifcopenshell.file, alignment: entity_ segmented_reference_curve = file.createIfcSegmentedReferenceCurve( Segments=[], BaseCurve=gradient_curve, SelfIntersect=False ) - representation = file.creatIfcShapeRepresentation( + representation = file.createIfcShapeRepresentation( ContextOfItems=axis_geom_subcontext, RepresentationIdentifier="Axis", RepresentationType="Curve3D", diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/_get_segment_start_point_label.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/_get_segment_start_point_label.py index 32dc5aa75d..97745969ac 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/alignment/_get_segment_start_point_label.py +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/_get_segment_start_point_label.py @@ -180,7 +180,7 @@ def _vertical_label(prev_segment: entity_instance, segment: entity_instance) -> "CONSTANTGRADIENT": { "CIRCULARARC": "xx", "CLOTHOID": "xx", - "CONSTANTGRADIENT": "P.V.I", + "CONSTANTGRADIENT": "P.V.I.", "PARABOLICARC": "P.V.C.", }, "PARABOLICARC": { diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/clear_layout_segments.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/clear_layout_segments.py new file mode 100644 index 0000000000..79560f46d9 --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/clear_layout_segments.py @@ -0,0 +1,220 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 Thomas Krijnen +# +# 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 . + +import ifcopenshell +import ifcopenshell.api.alignment +import ifcopenshell.api.nest +import ifcopenshell.util.element +from ifcopenshell import entity_instance + + +def _is_zero_length_segment(segment: entity_instance) -> bool: + """Check if segment is a zero-length terminator.""" + dp = segment.DesignParameters + if dp.is_a("IfcAlignmentHorizontalSegment"): + return dp.SegmentLength == 0.0 + elif dp.is_a("IfcAlignmentVerticalSegment"): + return dp.HorizontalLength == 0.0 + elif dp.is_a("IfcAlignmentCantSegment"): + return dp.HorizontalLength == 0.0 + return False + + +def clear_layout_segments(file: ifcopenshell.file, layout: entity_instance) -> None: + """ + Clear all segments from a layout while preserving the layout entity + and zero-length terminator. + + This function removes: + - All real (non-zero-length) IfcAlignmentSegment entities from the layout + - Their associated IfcCurveSegment entities from the geometric representation + - Referents positioned on the removed segments + + It preserves: + - The layout entity (IfcAlignmentHorizontal, IfcAlignmentVertical, or IfcAlignmentCant) + - The zero-length terminator segment (required by IFC spec) + - The alignment's main stationing referent + + :param file: The IFC file + :param layout: An IfcAlignmentHorizontal, IfcAlignmentVertical, or IfcAlignmentCant + + Example: + + .. code:: python + + alignment = model.by_type("IfcAlignment")[0] + h_layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment) + + # Clear existing segments + ifcopenshell.api.alignment.clear_layout_segments(model, h_layout) + + # Add new segments with updated PI positions + ifcopenshell.api.alignment.layout_horizontal_alignment_by_pi_method( + model, h_layout, new_hpoints, new_radii + ) + """ + expected_types = ["IfcAlignmentHorizontal", "IfcAlignmentVertical", "IfcAlignmentCant"] + if layout.is_a() not in expected_types: + raise TypeError(f"Expected entity type to be one of {expected_types}, instead received {layout.is_a()}") + + # Get the geometric curve for this layout + curve = ifcopenshell.api.alignment.get_layout_curve(layout) + + # Get all segments from the layout + segments = ifcopenshell.api.alignment.get_layout_segments(layout) + + if not segments: + return # Nothing to clear + + # Identify segments to remove (all except zero-length terminator) + zero_length_segment = None + segments_to_remove = [] + + for segment in segments: + if _is_zero_length_segment(segment): + zero_length_segment = segment + else: + segments_to_remove.append(segment) + + if not segments_to_remove: + return # Only zero-length terminator exists, nothing to clear + + # Collect curve segments to remove before removing alignment segments + # (we need the nesting relationship to find mapped segments) + curve_segments_to_remove = [] + for segment in segments_to_remove: + try: + mapped = ifcopenshell.api.alignment.get_mapped_segments(segment) + for cs in mapped: + if cs is not None: + curve_segments_to_remove.append(cs) + except (IndexError, AttributeError): + # Segment might not have curve representation yet + pass + + # Remove referents positioned on segments being removed + for segment in segments_to_remove: + # Check for referents positioned relative to this segment + if hasattr(segment, "PositionedRelativeTo") and segment.PositionedRelativeTo: + for rel_pos in segment.PositionedRelativeTo: + referent = rel_pos.RelatingPositioningElement + if referent and referent.is_a("IfcReferent"): + # Remove the referent + ifcopenshell.api.run("root.remove_product", file, product=referent) + + # Remove segments from nesting relationship + ifcopenshell.api.nest.unassign_object(file, related_objects=segments_to_remove) + + # Remove segment entities + for segment in segments_to_remove: + # Remove design parameters + dp = segment.DesignParameters + if dp: + # Remove StartPoint if it exists + if hasattr(dp, "StartPoint") and dp.StartPoint: + file.remove(dp.StartPoint) + file.remove(dp) + + # Remove the segment entity itself + file.remove(segment) + + # Clear curve segments from the geometric representation + if curve and curve.Segments: + # Keep only the zero-length curve segment (last one) + if ifcopenshell.api.alignment.has_zero_length_segment(curve): + zero_length_curve_seg = curve.Segments[-1] + # Update curve to only contain zero-length segment + curve.Segments = (zero_length_curve_seg,) + else: + # No zero-length segment in curve, clear all + curve.Segments = () + + # Clean up removed curve segment entities + for cs in curve_segments_to_remove: + try: + # Remove the curve segment's parent curve and placement + if hasattr(cs, "ParentCurve") and cs.ParentCurve: + parent_curve = cs.ParentCurve + # Check if parent curve is used elsewhere + if file.get_total_inverses(parent_curve) <= 1: + # Remove placement if exists + if hasattr(parent_curve, "Position") and parent_curve.Position: + pos = parent_curve.Position + if hasattr(pos, "Location") and pos.Location: + if file.get_total_inverses(pos.Location) <= 1: + file.remove(pos.Location) + if hasattr(pos, "RefDirection") and pos.RefDirection: + if file.get_total_inverses(pos.RefDirection) <= 1: + file.remove(pos.RefDirection) + if file.get_total_inverses(pos) <= 1: + file.remove(pos) + file.remove(parent_curve) + + # Remove placement on curve segment + if hasattr(cs, "Placement") and cs.Placement: + placement = cs.Placement + if hasattr(placement, "Location") and placement.Location: + if file.get_total_inverses(placement.Location) <= 1: + file.remove(placement.Location) + if hasattr(placement, "RefDirection") and placement.RefDirection: + if file.get_total_inverses(placement.RefDirection) <= 1: + file.remove(placement.RefDirection) + if file.get_total_inverses(placement) <= 1: + file.remove(placement) + + # Remove the curve segment itself + file.remove(cs) + except Exception: + # Entity may have already been removed + pass + + # Reset zero-length terminator to origin position + if zero_length_segment: + dp = zero_length_segment.DesignParameters + if dp.is_a("IfcAlignmentHorizontalSegment"): + # Reset StartPoint to origin + if dp.StartPoint: + dp.StartPoint.Coordinates = (0.0, 0.0) + dp.StartDirection = 0.0 + elif dp.is_a("IfcAlignmentVerticalSegment"): + dp.StartDistAlong = 0.0 + dp.StartHeight = 0.0 + dp.StartGradient = 0.0 + dp.EndGradient = 0.0 + elif dp.is_a("IfcAlignmentCantSegment"): + dp.StartDistAlong = 0.0 + dp.StartCantLeft = 0.0 + dp.StartCantRight = 0.0 + + # Update the zero-length segment's referent + if hasattr(zero_length_segment, "PositionedRelativeTo") and zero_length_segment.PositionedRelativeTo: + for rel_pos in zero_length_segment.PositionedRelativeTo: + referent = rel_pos.RelatingPositioningElement + if referent and referent.is_a("IfcReferent"): + # Update referent position to origin + if hasattr(referent, "ObjectPlacement") and referent.ObjectPlacement: + placement = referent.ObjectPlacement + if hasattr(placement, "RelativePlacement") and placement.RelativePlacement: + rel_place = placement.RelativePlacement + if hasattr(rel_place, "Location") and rel_place.Location: + if hasattr(rel_place.Location, "DistanceAlong"): + rel_place.Location.DistanceAlong.wrappedValue = 0.0 + if hasattr(placement, "CartesianPosition") and placement.CartesianPosition: + cart_pos = placement.CartesianPosition + if hasattr(cart_pos, "Location") and cart_pos.Location: + cart_pos.Location.Coordinates = (0.0, 0.0, 0.0) diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/create_as_offset_curve.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/create_as_offset_curve.py index 6ac56af362..dcd52db55b 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/alignment/create_as_offset_curve.py +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/create_as_offset_curve.py @@ -20,6 +20,8 @@ from collections.abc import Sequence import ifcopenshell import ifcopenshell.api.aggregate +import ifcopenshell.api.alignment +import ifcopenshell.util.alignment from ifcopenshell import entity_instance from ifcopenshell.api.alignment._create_offset_curve_representation import ( _create_offset_curve_representation, @@ -50,6 +52,10 @@ def create_as_offset_curve( _create_offset_curve_representation(file, alignment, offsets) + # establish the alignment's stationing scheme, same as create() does for start_station + referent_name = ifcopenshell.util.alignment.station_as_string(file, start_station) + ifcopenshell.api.alignment.add_stationing_referent(file, referent_name, alignment, 0.0, start_station) + # IFC 4.1.4.1.1 Alignment Aggregation To Project project = file.by_type("IfcProject")[0] if project: diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/get_mapped_segments.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/get_mapped_segments.py index 0b8a3b65b3..004fc9c5ba 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/alignment/get_mapped_segments.py +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/get_mapped_segments.py @@ -57,4 +57,4 @@ def get_mapped_segments(layout_segment: entity_instance) -> Sequence[entity_inst if segment_count == 1: return (curve.Segments[index - segment_count], None) else: - return (curve.Segments[index - segment_count], curve.Segments[index]) + return (curve.Segments[index - segment_count], curve.Segments[index - 1]) diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/get_stationing_nest.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/get_stationing_nest.py index e92dda7a81..7d613eb4be 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/alignment/get_stationing_nest.py +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/get_stationing_nest.py @@ -39,7 +39,7 @@ def get_stationing_nest(file: ifcopenshell.file, alignment: entity_instance) -> for nest in alignment.IsNestedBy: for related_object in nest.RelatedObjects: - if related_object.is_a("IfcReferent"): + if related_object.is_a("IfcReferent") and related_object.PredefinedType == "STATION": return nest return None diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/segment_vertices.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/segment_vertices.py new file mode 100644 index 0000000000..a7f2888e3c --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/segment_vertices.py @@ -0,0 +1,109 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 Thomas Krijnen +# +# 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 . + + +import numpy as np + +import ifcopenshell +import ifcopenshell.util.unit +from ifcopenshell import entity_instance, ifcopenshell_wrapper + + +def _intersect_lines(p1, d1, p2, d2): + x1, y1 = p1 + dx1, dy1 = d1 + x2, y2 = p2 + dx2, dy2 = d2 + + det = dx1 * dy2 - dy1 * dx2 + if abs(det) < 1e-12: + return None # lines are parallel + + t = ((x2 - x1) * dy2 - (y2 - y1) * dx2) / det + + x = x1 + t * dx1 + y = y1 + t * dy1 + + return (x, y) + + +def segment_vertices(file: ifcopenshell.file, segment: entity_instance): + """ + Generates segment vertices. Segment vertices are at the start and end as well as the points where the tangents + at the start and end of the segment intersect (the TI point) and where lines + normal (perpendicular) to the start and end of the segment intersect (NI). + + TI and NI are None if intersection points do not exist, such as in the case of a line. + + :param curve_segment: A curve segment of type IfcAlignmentSegment or IfcCurveSegment + :return: tuples for Start, End, TI, NI + """ + supported_segment_types = ["IFCALIGNMENTSEGMENT", "IFCCURVESEGMENT"] + segment_type = segment.is_a().upper() + if not segment_type in supported_segment_types: + raise NotImplementedError( + f"Expected entity type to be one of {[_ for _ in supported_segment_types]}, got '{segment_type}" + ) + + # in the general case an IfcAlignmentSegment for a Helmert transition curve + # maps into two IfcCurveSegment geometric representations. + # For that reason, we have a start_segment_curve and and end_segment_curve. + # In the more common case, there is only one IfcCurveSegment geometric representation + # and start_segment_curve and end_segment_curve are equal + if segment_type == "IFCALIGNMENTSEGMENT": + segments = ifcopenshell.api.alignment.get_mapped_segments(segment) + start_segment_curve = segments[0] + end_segment_curve = start_segment_curve if segments[1] is None else segments[1] + else: + start_segment_curve = segment + end_segment_curve = segment + + settings = ifcopenshell.geom.settings() + + # get parameters at start of start_segment_curve + segment_fn = ifcopenshell_wrapper.map_shape(settings, start_segment_curve) + segment_evaluator = ifcopenshell_wrapper.function_item_evaluator(settings, segment_fn) + + s = segment_evaluator.evaluate(segment_fn.start()) + start = np.array(s) + sx = float(start[0, 3]) + sy = float(start[1, 3]) + sdx = float(start[0, 0]) + sdy = float(start[1, 0]) + + # get parameters at end of end_segment_curve + segment_fn = ifcopenshell_wrapper.map_shape(settings, end_segment_curve) + segment_evaluator = ifcopenshell_wrapper.function_item_evaluator(settings, segment_fn) + + e = segment_evaluator.evaluate(segment_fn.end()) + end = np.array(e) + ex = float(end[0, 3]) + ey = float(end[1, 3]) + edx = float(end[0, 0]) + edy = float(end[1, 0]) + + ti = _intersect_lines((sx, sy), (sdx, sdy), (ex, ey), (edx, edy)) # tangent intersection + + sdx = float(start[0, 1]) + sdy = float(start[1, 1]) + edx = float(end[0, 1]) + edy = float(end[1, 1]) + + ni = _intersect_lines((sx, sy), (sdx, sdy), (ex, ey), (edx, edy)) # normal intersection + + return (sx, sy), (ex, ey), ti, ni diff --git a/src/ifcopenshell-python/test/api/alignment/test_create_as_offset_curve.py b/src/ifcopenshell-python/test/api/alignment/test_create_as_offset_curve.py index f93321ac73..37e2a6396b 100644 --- a/src/ifcopenshell-python/test/api/alignment/test_create_as_offset_curve.py +++ b/src/ifcopenshell-python/test/api/alignment/test_create_as_offset_curve.py @@ -23,6 +23,7 @@ import pytest import ifcopenshell.api.alignment import ifcopenshell.api.unit import ifcopenshell.util +import ifcopenshell.util.element import ifcopenshell.util.unit try: @@ -139,8 +140,16 @@ def test_create_as_offset_curve(): ), ] - offset_alignment = ifcopenshell.api.alignment.create_as_offset_curve(file, "A2", offsets) + offset_alignment = ifcopenshell.api.alignment.create_as_offset_curve(file, "A2", offsets, start_station=1000.0) assert offset_alignment.is_a("IfcAlignment") curve = ifcopenshell.api.alignment.get_curve(offset_alignment) assert curve.is_a("IfcOffsetCurveByDistances") assert curve.BasisCurve == basis_curve + + # start_station must be honored with a stationing referent, same as every other create path + referent_nest = ifcopenshell.api.alignment.get_stationing_nest(file, offset_alignment) + assert referent_nest is not None + referent = referent_nest.RelatedObjects[0] + assert referent.is_a("IfcReferent") + assert referent.PredefinedType == "STATION" + assert ifcopenshell.util.element.get_pset(referent, name="Pset_Stationing", prop="Station") == 1000.0 diff --git a/src/ifcopenshell-python/test/api/alignment/test_create_as_offset_curve_stationing.py b/src/ifcopenshell-python/test/api/alignment/test_create_as_offset_curve_stationing.py new file mode 100644 index 0000000000..5e64b55186 --- /dev/null +++ b/src/ifcopenshell-python/test/api/alignment/test_create_as_offset_curve_stationing.py @@ -0,0 +1,90 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 Thomas Krijnen +# +# 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 . + +import ifcopenshell +import ifcopenshell.api.alignment +import ifcopenshell.api.unit +import ifcopenshell.util.element + + +def test_create_as_offset_curve_honors_start_station_with_a_stationing_referent(): + # create_as_offset_curve() accepted start_station but silently dropped it -- unlike every + # other create path (create(), create_by_pi_method()), it never established the alignment's + # stationing referent. + file = ifcopenshell.file(schema="IFC4X3") + project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test") + length = ifcopenshell.api.unit.add_si_unit(file, unit_type="LENGTHUNIT") + ifcopenshell.api.unit.assign_unit(file, units=[length]) + + # the basis alignment only needs a real (if empty) Axis representation for + # IfcPointByDistanceExpression.BasisCurve to reference -- no real segments needed. + basis_alignment = ifcopenshell.api.alignment.create(file, "Basis", include_geometry=True) + basis_curve = ifcopenshell.api.alignment.get_curve(basis_alignment) + assert basis_curve.is_a("IfcCompositeCurve") + + offsets = [ + file.createIfcPointByDistanceExpression( + DistanceAlong=file.createIfcLengthMeasure(0.0), OffsetLateral=10.0, BasisCurve=basis_curve + ), + ] + + offset_alignment = ifcopenshell.api.alignment.create_as_offset_curve(file, "Offset", offsets, start_station=1000.0) + + curve = ifcopenshell.api.alignment.get_curve(offset_alignment) + assert curve.is_a("IfcOffsetCurveByDistances") + + # the referent must be created even though IfcOffsetCurveByDistances isn't a curve type that + # add_stationing_referent() can build a linear placement on top of -- it falls back to a plain + # IfcLocalPlacement, same as when there's no representation at all. + referent_nest = ifcopenshell.api.alignment.get_stationing_nest(file, offset_alignment) + assert referent_nest is not None + assert len(referent_nest.RelatedObjects) == 1 + + referent = referent_nest.RelatedObjects[0] + assert referent.is_a("IfcReferent") + assert referent.PredefinedType == "STATION" + assert referent.ObjectPlacement is not None + assert referent.ObjectPlacement.is_a("IfcLocalPlacement") + assert ifcopenshell.util.element.get_pset(referent, name="Pset_Stationing", prop="Station") == 1000.0 + + +def test_create_as_offset_curve_default_start_station_is_zero(): + file = ifcopenshell.file(schema="IFC4X3") + project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test") + length = ifcopenshell.api.unit.add_si_unit(file, unit_type="LENGTHUNIT") + ifcopenshell.api.unit.assign_unit(file, units=[length]) + + basis_alignment = ifcopenshell.api.alignment.create(file, "Basis", include_geometry=True) + basis_curve = ifcopenshell.api.alignment.get_curve(basis_alignment) + + offsets = [ + file.createIfcPointByDistanceExpression( + DistanceAlong=file.createIfcLengthMeasure(0.0), OffsetLateral=10.0, BasisCurve=basis_curve + ), + ] + + offset_alignment = ifcopenshell.api.alignment.create_as_offset_curve(file, "Offset", offsets) + + referent_nest = ifcopenshell.api.alignment.get_stationing_nest(file, offset_alignment) + assert referent_nest is not None + referent = referent_nest.RelatedObjects[0] + assert ifcopenshell.util.element.get_pset(referent, name="Pset_Stationing", prop="Station") == 0.0 + + +test_create_as_offset_curve_honors_start_station_with_a_stationing_referent() +test_create_as_offset_curve_default_start_station_is_zero() diff --git a/src/ifcopenshell-python/test/api/alignment/test_create_geometric_representation.py b/src/ifcopenshell-python/test/api/alignment/test_create_geometric_representation.py new file mode 100644 index 0000000000..e620749f50 --- /dev/null +++ b/src/ifcopenshell-python/test/api/alignment/test_create_geometric_representation.py @@ -0,0 +1,68 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 Thomas Krijnen +# +# 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 . + +import ifcopenshell +import ifcopenshell.api.aggregate +import ifcopenshell.api.alignment +import ifcopenshell.api.nest +import ifcopenshell.api.unit +from ifcopenshell.api.alignment._create_geometric_representation import ( + _create_geometric_representation, +) + + +def test_child_alignment_with_vertical_and_cant_gets_a_segmented_reference_curve(): + # IFC CT 4.1.4.4.1.2 "Reusing Horizontal Layout": a child IfcAlignment nests its own + # IfcAlignmentVertical and IfcAlignmentCant while reusing the parent's horizontal layout. There + # is no public API to build this today (add_vertical_layout() only ever creates children that + # nest a single IfcAlignmentVertical), so the len(child_layouts) == 2 branch inside + # _create_geometric_representation() -- which builds the child's IfcSegmentedReferenceCurve -- + # was unreachable and its `file.creatIfcShapeRepresentation` typo (missing "e") went unnoticed. + # This constructs that scenario directly against the private helper to exercise the fix. + file = ifcopenshell.file(schema="IFC4X3") + project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test") + length = ifcopenshell.api.unit.add_si_unit(file, unit_type="LENGTHUNIT") + ifcopenshell.api.unit.assign_unit(file, units=[length]) + + # parent: horizontal + vertical, geometry deferred so _create_geometric_representation is + # invoked exactly once, explicitly, below. + parent_alignment = ifcopenshell.api.alignment.create(file, "Parent", include_vertical=True, include_geometry=False) + + # child: vertical + cant, both nested directly to a new child alignment, reusing the parent's + # horizontal -- the CT 4.1.4.4.1.2 shape that has no builder function yet. + child_alignment = file.createIfcAlignment(GlobalId=ifcopenshell.guid.new(), Name="Child of Parent") + child_vertical_layout = file.createIfcAlignmentVertical(GlobalId=ifcopenshell.guid.new()) + child_cant_layout = file.createIfcAlignmentCant(GlobalId=ifcopenshell.guid.new(), RailHeadDistance=1.0) + ifcopenshell.api.nest.assign_object( + file, related_objects=[child_vertical_layout, child_cant_layout], relating_object=child_alignment + ) + ifcopenshell.api.aggregate.assign_object(file, products=[child_alignment], relating_object=parent_alignment) + + # before the fix this raised AttributeError: 'file' object has no attribute + # 'creatIfcShapeRepresentation' + _create_geometric_representation(file, parent_alignment) + + curve = ifcopenshell.api.alignment.get_curve(child_alignment) + assert curve is not None + assert curve.is_a("IfcSegmentedReferenceCurve") + assert curve.BaseCurve.is_a("IfcGradientCurve") + + assert child_alignment.ObjectPlacement == parent_alignment.ObjectPlacement + + +test_child_alignment_with_vertical_and_cant_gets_a_segmented_reference_curve() diff --git a/src/ifcopenshell-python/test/api/alignment/test_get_mapped_segments.py b/src/ifcopenshell-python/test/api/alignment/test_get_mapped_segments.py new file mode 100644 index 0000000000..76c8bf4fd1 --- /dev/null +++ b/src/ifcopenshell-python/test/api/alignment/test_get_mapped_segments.py @@ -0,0 +1,195 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 Thomas Krijnen +# +# 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 . + +import ifcopenshell +import ifcopenshell.api.alignment +import ifcopenshell.api.context +import ifcopenshell.api.nest +import ifcopenshell.api.unit +import ifcopenshell.guid + + +def _new_file_with_axis_context(): + file = ifcopenshell.file(schema="IFC4X3") + file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test") + length = ifcopenshell.api.unit.add_si_unit(file, unit_type="LENGTHUNIT") + angle = ifcopenshell.api.unit.add_si_unit(file, unit_type="PLANEANGLEUNIT") + ifcopenshell.api.unit.assign_unit(file, units=[length, angle]) + geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model") + ifcopenshell.api.context.add_context( + file, + context_type="Model", + context_identifier="Axis", + target_view="MODEL_VIEW", + parent=geometric_representation_context, + ) + return file + + +def _fake_curve_segment(file, segment_length): + # A structurally-valid but geometrically-meaningless IfcCurveSegment. get_mapped_segments() + # only ever returns these by reference -- it never evaluates them -- so their actual shape + # doesn't matter for testing the index math. + placement = file.createIfcAxis2Placement2D( + Location=file.createIfcCartesianPoint((0.0, 0.0)), RefDirection=file.createIfcDirection((1.0, 0.0)) + ) + parent_curve = file.createIfcLine( + Pnt=file.createIfcCartesianPoint((0.0, 0.0)), + Dir=file.createIfcVector(Orientation=file.createIfcDirection((1.0, 0.0)), Magnitude=1.0), + ) + return file.createIfcCurveSegment( + Transition="DISCONTINUOUS", + Placement=placement, + SegmentStart=file.createIfcLengthMeasure(0.0), + SegmentLength=file.createIfcLengthMeasure(segment_length), + ParentCurve=parent_curve, + ) + + +def test_get_mapped_segments_returns_consecutive_helmert_curve_segments(): + # HELMERTCURVE is the one horizontal segment type that maps to two IfcCurveSegment geometric + # representations instead of one. get_mapped_segments() previously returned + # (curve.Segments[index - segment_count], curve.Segments[index]) for the second half, which is + # one position too far -- curve.Segments[index] belongs to whatever segment comes *after* the + # Helmert curve (or is out of range for the last real segment). The fix returns + # curve.Segments[index - 1], the Helmert curve's own second half. + # + # This builds the IFC graph directly instead of going through create_layout_segment(), which + # requires a registered geometry mapping for the schema to compute segment end points -- + # get_mapped_segments() itself is pure graph traversal and needs no geometry evaluation. + file = _new_file_with_axis_context() + + alignment = ifcopenshell.api.alignment.create(file, "TestAlignment") + layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment) + curve = ifcopenshell.api.alignment.get_layout_curve(layout) + + # curve already has the mandatory zero-length terminal IfcCurveSegment; insert the LINE and + # HELMERTCURVE curve segments in front of it. + line_curve_segment = _fake_curve_segment(file, 100.0) + helmert_curve_segment_a = _fake_curve_segment(file, 50.0) + helmert_curve_segment_b = _fake_curve_segment(file, 50.0) + curve.Segments = (line_curve_segment, helmert_curve_segment_a, helmert_curve_segment_b) + curve.Segments + assert len(curve.Segments) == 4 # LINE, Helmert x2, zero-length terminal + + line_design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=0.0, + EndRadiusOfCurvature=0.0, + SegmentLength=100.0, + PredefinedType="LINE", + ) + helmert_design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((100.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=300.0, + EndRadiusOfCurvature=1000.0, + SegmentLength=100.0, + PredefinedType="HELMERTCURVE", + ) + line_layout_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=line_design_parameters + ) + helmert_layout_segment = file.createIfcAlignmentSegment( + GlobalId=ifcopenshell.guid.new(), DesignParameters=helmert_design_parameters + ) + # append then swap into place ahead of the mandatory zero-length terminal segment, in order -- + # the same two steps _add_segment_to_layout() performs per segment, minus the geometric + # end-point calculation. + ifcopenshell.api.nest.assign_object(file, related_objects=[line_layout_segment], relating_object=layout) + ifcopenshell.api.nest.reorder_nesting(file, line_layout_segment, -1, -1) + ifcopenshell.api.nest.assign_object(file, related_objects=[helmert_layout_segment], relating_object=layout) + ifcopenshell.api.nest.reorder_nesting(file, helmert_layout_segment, -1, -1) + + # order is [LINE, HELMERTCURVE, zero-length terminal segment]; the terminal segment is also + # PredefinedType="LINE" (with SegmentLength=0.0) + layout_segments = ifcopenshell.api.alignment.get_layout_segments(layout) + assert [s.DesignParameters.PredefinedType for s in layout_segments] == ["LINE", "HELMERTCURVE", "LINE"] + assert layout_segments[1] == helmert_layout_segment + + mapped_segments = ifcopenshell.api.alignment.get_mapped_segments(helmert_layout_segment) + assert len(mapped_segments) == 2 + first_half, second_half = mapped_segments + assert first_half is not None + assert second_half is not None + + # the two halves must be the two consecutive IfcCurveSegment entities belonging to the Helmert + # curve, identity-checked against curve.Segments -- not, e.g., the LINE segment's curve + # segment and the Helmert's first half (the pre-fix off-by-one). + assert first_half == helmert_curve_segment_a + assert second_half == helmert_curve_segment_b + assert first_half == curve.Segments[1] + assert second_half == curve.Segments[2] + + +def test_get_mapped_segments_and_segment_vertices_for_helmert_curve(): + # End-to-end regression test built the realistic way, via create_layout_segment() -- matching + # every other test in this suite. This requires a registered geometry mapping for the schema + # (ifcopenshell_wrapper.map_shape) to compute each segment's end point while chaining the + # layout together, and again inside segment_vertices() itself. + file = _new_file_with_axis_context() + + alignment = ifcopenshell.api.alignment.create(file, "TestAlignment") + layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment) + + line_design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=0.0, + EndRadiusOfCurvature=0.0, + SegmentLength=100.0, + PredefinedType="LINE", + ) + ifcopenshell.api.alignment.create_layout_segment(file, layout, line_design_parameters) + + helmert_design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((100.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=300.0, + EndRadiusOfCurvature=1000.0, + SegmentLength=100.0, + PredefinedType="HELMERTCURVE", + ) + ifcopenshell.api.alignment.create_layout_segment(file, layout, helmert_design_parameters) + + # layout order is [LINE, HELMERTCURVE, zero-length terminal segment] + layout_segments = ifcopenshell.api.alignment.get_layout_segments(layout) + helmert_layout_segment = layout_segments[-2] + assert helmert_layout_segment.DesignParameters.PredefinedType == "HELMERTCURVE" + + curve = ifcopenshell.api.alignment.get_layout_curve(layout) + # curve.Segments is [LINE cs(0), Helmert cs(1), Helmert cs(2), zero-length cs(3)] + assert len(curve.Segments) == 4 + + mapped_segments = ifcopenshell.api.alignment.get_mapped_segments(helmert_layout_segment) + assert len(mapped_segments) == 2 + first_half, second_half = mapped_segments + assert first_half is not None + assert second_half is not None + assert first_half == curve.Segments[1] + assert second_half == curve.Segments[2] + + # segment_vertices() must not raise for a HELMERTCURVE alignment segment (previously: NameError + # from the `segment[1]` typo) + start, end, ti, ni = ifcopenshell.api.alignment.segment_vertices(file, helmert_layout_segment) + assert start is not None + assert end is not None + + +test_get_mapped_segments_returns_consecutive_helmert_curve_segments() +test_get_mapped_segments_and_segment_vertices_for_helmert_curve() diff --git a/src/ifcopenshell-python/test/api/alignment/test_get_segment_start_point_label.py b/src/ifcopenshell-python/test/api/alignment/test_get_segment_start_point_label.py new file mode 100644 index 0000000000..e6f0ae78c8 --- /dev/null +++ b/src/ifcopenshell-python/test/api/alignment/test_get_segment_start_point_label.py @@ -0,0 +1,53 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 Thomas Krijnen +# +# 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 . + +import ifcopenshell +import ifcopenshell.guid +from ifcopenshell.api.alignment._get_segment_start_point_label import ( + _get_segment_start_point_label, +) + + +def test_vertical_constant_gradient_to_constant_gradient_label_has_trailing_period(): + # Every other label in the lookup tables ends with a period (e.g. "P.C.", "P.V.C.", + # "P.V.T."); CONSTANTGRADIENT -> CONSTANTGRADIENT ("P.V.I") was missing its trailing period. + file = ifcopenshell.file(schema="IFC4X3") + + dp1 = file.createIfcAlignmentVerticalSegment( + StartDistAlong=0.0, + HorizontalLength=100.0, + StartHeight=0.0, + StartGradient=0.01, + EndGradient=0.01, + PredefinedType="CONSTANTGRADIENT", + ) + dp2 = file.createIfcAlignmentVerticalSegment( + StartDistAlong=100.0, + HorizontalLength=100.0, + StartHeight=1.0, + StartGradient=0.02, + EndGradient=0.02, + PredefinedType="CONSTANTGRADIENT", + ) + prev_segment = file.createIfcAlignmentSegment(GlobalId=ifcopenshell.guid.new(), DesignParameters=dp1) + segment = file.createIfcAlignmentSegment(GlobalId=ifcopenshell.guid.new(), DesignParameters=dp2) + + assert _get_segment_start_point_label(prev_segment, segment) == "P.V.I." + + +test_vertical_constant_gradient_to_constant_gradient_label_has_trailing_period() diff --git a/src/ifcopenshell-python/test/api/alignment/test_get_stationing_nest.py b/src/ifcopenshell-python/test/api/alignment/test_get_stationing_nest.py new file mode 100644 index 0000000000..bae48e1f50 --- /dev/null +++ b/src/ifcopenshell-python/test/api/alignment/test_get_stationing_nest.py @@ -0,0 +1,119 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 Thomas Krijnen +# +# 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 . + +import ifcopenshell +import ifcopenshell.api.alignment +import ifcopenshell.api.nest +import ifcopenshell.api.unit +import ifcopenshell.util.element + + +def _new_file(): + file = ifcopenshell.file(schema="IFC4X3") + file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test") + length = ifcopenshell.api.unit.add_si_unit(file, unit_type="LENGTHUNIT") + ifcopenshell.api.unit.assign_unit(file, units=[length]) + return file + + +def _add_real_segment_without_geometry(file, horizontal, design_parameters): + # Equivalent to ifcopenshell.api.alignment.create_layout_segment(), minus the geometric + # end-point calculation performed by _add_segment_to_layout()/_get_segment_endpoint() (which + # requires a registered geometry mapping for the schema). include_geometry=False alignments + # have no representation to keep in sync anyway, so this reproduces exactly what the real + # code path does to the layout's segment nest: append the segment, then swap it in front of + # the mandatory zero-length terminal segment. + segment = file.createIfcAlignmentSegment(GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters) + ifcopenshell.api.nest.assign_object(file, related_objects=[segment], relating_object=horizontal) + ifcopenshell.api.nest.reorder_nesting(file, segment, -1, -1) + return segment + + +def test_get_stationing_nest_returns_the_station_nest_even_after_key_point_nest_created(): + # add_stationing_referent() establishes the stationing IfcRelNests (one IfcReferent, + # PredefinedType="STATION"). update_key_point_referents() then creates a second, separate + # IfcRelNests of PredefinedType="POSITION" referents. get_stationing_nest() must keep finding + # the STATION nest regardless of which nest happens to come first in alignment.IsNestedBy. + file = _new_file() + alignment = ifcopenshell.api.alignment.create(file, "A1", include_geometry=False) + horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment) + + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=0.0, + EndRadiusOfCurvature=0.0, + SegmentLength=100.0, + PredefinedType="LINE", + ) + _add_real_segment_without_geometry(file, horizontal, design_parameters) + + ifcopenshell.api.alignment.add_stationing_referent( + file, "1+00.00", alignment, distance_along=0.0, station=100.0 + ) + + station_nest_before = ifcopenshell.api.alignment.get_stationing_nest(file, alignment) + assert station_nest_before is not None + assert all(r.PredefinedType == "STATION" for r in station_nest_before.RelatedObjects) + + key_point_nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal) + assert all(r.PredefinedType == "POSITION" for r in key_point_nest.RelatedObjects) + assert key_point_nest.id() != station_nest_before.id() + + station_nest_after = ifcopenshell.api.alignment.get_stationing_nest(file, alignment) + assert station_nest_after is not None + assert station_nest_after.id() == station_nest_before.id() + assert all(r.PredefinedType == "STATION" for r in station_nest_after.RelatedObjects) + assert ( + ifcopenshell.util.element.get_pset(station_nest_after.RelatedObjects[0], name="Pset_Stationing", prop="Station") + == 100.0 + ) + + +def test_get_stationing_nest_returns_none_when_only_key_point_nest_exists(): + # A mixed or key-point-only nest must never be mistaken for the stationing nest. + file = _new_file() + alignment = ifcopenshell.api.alignment.create(file, "A1", include_geometry=False) + horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment) + + design_parameters = file.createIfcAlignmentHorizontalSegment( + StartPoint=file.createIfcCartesianPoint((0.0, 0.0)), + StartDirection=0.0, + StartRadiusOfCurvature=0.0, + EndRadiusOfCurvature=0.0, + SegmentLength=100.0, + PredefinedType="LINE", + ) + _add_real_segment_without_geometry(file, horizontal, design_parameters) + + ifcopenshell.api.alignment.add_stationing_referent( + file, "1+00.00", alignment, distance_along=0.0, station=100.0 + ) + + # remove the stationing nest, leaving only key-point referents behind + stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment) + file.remove(stationing_nest.RelatedObjects[0]) + file.remove(stationing_nest) + + ifcopenshell.api.alignment.update_key_point_referents(file, horizontal) + + assert ifcopenshell.api.alignment.get_stationing_nest(file, alignment) is None + + +test_get_stationing_nest_returns_the_station_nest_even_after_key_point_nest_created() +test_get_stationing_nest_returns_none_when_only_key_point_nest_exists() diff --git a/src/ifcopenshell-python/test/api/alignment/test_segment_vertices.py b/src/ifcopenshell-python/test/api/alignment/test_segment_vertices.py new file mode 100644 index 0000000000..0075bf7f57 --- /dev/null +++ b/src/ifcopenshell-python/test/api/alignment/test_segment_vertices.py @@ -0,0 +1,172 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2025 Thomas Krijnen +# +# 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 . + +import pytest +import ifcopenshell.api.alignment +import ifcopenshell.api.context +import ifcopenshell.api.unit + + +def unit_convert(unit_scale, p): + if p == None: + return p + + x, y = p + return (x / unit_scale, y / unit_scale) + + +def test_segment_vertices(): + file = ifcopenshell.file(schema="IFC4X3") + project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test") + length = ifcopenshell.api.unit.add_conversion_based_unit(file, name="foot") + angle = ifcopenshell.api.unit.add_si_unit(file, unit_type="PLANEANGLEUNIT") + ifcopenshell.api.unit.assign_unit(file, units=[length, angle]) + + geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model") + axis_model_representation_subcontext = ifcopenshell.api.context.add_context( + file, + context_type="Model", + context_identifier="Axis", + target_view="MODEL_VIEW", + parent=geometric_representation_context, + ) + + coordinates = [(500.0, 2500.0), (3340.0, 660.0), (4340.0, 5000.0), (7600.0, 4560.0), (8480.0, 2010.0)] + radii = [(1000.0), (1250.0), (950.0)] + vpoints = [(0.0, 100.0), (2000.0, 135.0), (5000.0, 105.0), (7400.0, 153.0), (9800.0, 105.0), (12800.0, 90.0)] + lengths = [(1600.0), (1200.0), (2000.0), (800.0)] + + alignment = ifcopenshell.api.alignment.create_by_pi_method( + file, "TestAlignment", coordinates, radii, vpoints, lengths + ) + + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file) + + # test the horizontal alignment geometry segments + expect = [ + [(500.0, 2500.0), (2142.2379952109395, 1436.01482000418), None, None], + [ + (2142.2379952109395, 1436.01482000418), + (3660.446122847804, 2050.7361731594674), + (3340.0, 659.9999999999998), + (2685.9792975637306, 2275.267699722618), + ], + [(3660.4461228478035, 2050.7361731594674), (4084.115884236641, 3889.4629375870213), None, None], + [ + (4084.115884236641, 3889.4629375870218), + (5469.395067206271, 4847.5663099476205), + (4340.0, 5000.000000000001), + (5302.199415841732, 3608.7985293830834), + ], + [(5469.395067206271, 4847.56630994762), (7019.971366858418, 4638.286073184753), None, None], + [ + (7019.971366858417, 4638.286073184753), + (7790.932128312586, 4006.7307645487535), + (7600.0, 4560.0), + (6892.902671821368, 3696.8225599557054), + ], + [(7790.932128312587, 4006.7307645487535), (8480.0, 2010.0000000000002), None, None], + [(8480.0, 2010.0000000000002), (8480.0, 2010.0000000000002), None, None], + ] + + layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment) + segments = ifcopenshell.api.alignment.get_layout_segments(layout) + for segment, expected in zip(segments, expect): + s, e, ti, ni = ifcopenshell.api.alignment.segment_vertices(file, segment) + s = unit_convert(unit_scale, s) + e = unit_convert(unit_scale, e) + ti = unit_convert(unit_scale, ti) + ni = unit_convert(unit_scale, ni) + assert s == pytest.approx(expected[0]) + assert e == pytest.approx(expected[1]) + assert ti == pytest.approx(expected[2]) + assert ni == pytest.approx(expected[3]) + + curve = ifcopenshell.api.alignment.get_basis_curve(alignment) + for segment, expected in zip(curve.Segments, expect): + s, e, ti, ni = ifcopenshell.api.alignment.segment_vertices(file, segment) + s = unit_convert(unit_scale, s) + e = unit_convert(unit_scale, e) + ti = unit_convert(unit_scale, ti) + ni = unit_convert(unit_scale, ni) + assert s == pytest.approx(expected[0]) + assert e == pytest.approx(expected[1]) + assert ti == pytest.approx(expected[2]) + assert ni == pytest.approx(expected[3]) + + # test vertical curve segments + expect = [ + [(0.0, 100.0), (1200.0, 121.0), None, None], + [ + (1200.0, 121.0), + (2799.99999384661, 127.00000006153391), + (1999.9999969233054, 134.99999994615786), + (2218.1436363635016, -58058.63636362867), + ], + [(2800.0, 127.0), (4400.0, 111.0), None, None], + [ + (4400.0, 111.0), + (5599.999994508736, 116.9999998901747), + (4999.999997254367, 105.00000002745632), + (4800.039999999177, 40114.99999991764), + ], + [(5600.0, 117.0), (6400.0, 133.0), None, None], + [ + (6400.0, 133.0), + (8399.999995932576, 133.0000000813485), + (7399.999997966288, 152.99999995932575), + (7399.999999999187, -49866.99999995936), + ], + [(8400.0, 133.0), (9400.0, 113.0), None, None], + [ + (9400.0, 113.0), + (10199.99999633883, 103.00000001830585), + (9799.999998169415, 105.00000003661171), + (10466.733333334432, 53449.66666672164), + ], + [(10200.0, 103.0), (12800.0, 90.0), None, None], + [(12800.0, 90.0), (12800.0, 90.0), None, None], + ] + + layout = ifcopenshell.api.alignment.get_vertical_layout(alignment) + segments = ifcopenshell.api.alignment.get_layout_segments(layout) + for segment, expected in zip(segments, expect): + s, e, ti, ni = ifcopenshell.api.alignment.segment_vertices(file, segment) + s = unit_convert(unit_scale, s) + e = unit_convert(unit_scale, e) + ti = unit_convert(unit_scale, ti) + ni = unit_convert(unit_scale, ni) + assert s == pytest.approx(expected[0]) + assert e == pytest.approx(expected[1]) + assert ti == pytest.approx(expected[2]) + assert ni == pytest.approx(expected[3]) + + curve = ifcopenshell.api.alignment.get_curve(alignment) + for segment, expected in zip(curve.Segments, expect): + s, e, ti, ni = ifcopenshell.api.alignment.segment_vertices(file, segment) + s = unit_convert(unit_scale, s) + e = unit_convert(unit_scale, e) + ti = unit_convert(unit_scale, ti) + ni = unit_convert(unit_scale, ni) + assert s == pytest.approx(expected[0]) + assert e == pytest.approx(expected[1]) + assert ti == pytest.approx(expected[2]) + assert ni == pytest.approx(expected[3]) + + +test_segment_vertices() diff --git a/win/build-deps.cmd b/win/build-deps.cmd index 754dd7a89f..3309beef4d 100644 --- a/win/build-deps.cmd +++ b/win/build-deps.cmd @@ -189,7 +189,7 @@ IF DEFINED QT6_VERSION ( IF DEFINED PYTHON_VERSION ( echo Using overridden PYTHON_VERSION: '%PYTHON_VERSION%' ) else ( - set PYTHON_VERSION=3.11.7 + set PYTHON_VERSION=3.13.13 ) :: VERSION DERIVATIONS