diff --git a/src/bonsai/bonsai/bim/__init__.py b/src/bonsai/bonsai/bim/__init__.py index e854dcfe11..1ec96c9532 100644 --- a/src/bonsai/bonsai/bim/__init__.py +++ b/src/bonsai/bonsai/bim/__init__.py @@ -184,8 +184,7 @@ classes = [ ui.BIM_PT_tab_materials, ui.BIM_PT_tab_styles, ui.BIM_PT_tab_profiles, - # Civil infrastructure - ui.BIM_PT_tab_horizontal_alignment, + # Alignments ui.BIM_PT_tab_alignments, # Drawings and documents ui.BIM_PT_tab_sheets, diff --git a/src/bonsai/bonsai/bim/module/alignment/__init__.py b/src/bonsai/bonsai/bim/module/alignment/__init__.py index 82fc390380..bf40b000be 100644 --- a/src/bonsai/bonsai/bim/module/alignment/__init__.py +++ b/src/bonsai/bonsai/bim/module/alignment/__init__.py @@ -17,7 +17,7 @@ # along with Bonsai. If not, see . import bpy -from . import ui, prop, operator, decorator, workspace +from . import ui, prop, operator, decorator _last_active_ptr: int = 0 _last_profile_alignment_id: int = 0 # tracks which alignment the profile was last built for @@ -105,38 +105,21 @@ def _on_active_object_changed(scene, depsgraph): 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, @@ -147,10 +130,6 @@ classes = ( 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, @@ -163,12 +142,6 @@ 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) @@ -195,8 +168,6 @@ def unregister(): 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/decorator.py b/src/bonsai/bonsai/bim/module/alignment/decorator.py index d8ecda84de..511c253a9d 100644 --- a/src/bonsai/bonsai/bim/module/alignment/decorator.py +++ b/src/bonsai/bonsai/bim/module/alignment/decorator.py @@ -19,7 +19,8 @@ """Alignment module decorators for GPU visualization. This module contains decorators for rendering visual feedback during -alignment-related operations, such as PI editing. +alignment-related operations, such as segment highlighting and the +vertical profile view. """ import bpy @@ -38,169 +39,6 @@ from bpy_extras.view3d_utils import location_3d_to_region_2d, region_2d_to_locat 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. diff --git a/src/bonsai/bonsai/bim/module/alignment/operator.py b/src/bonsai/bonsai/bim/module/alignment/operator.py index 34fa04d161..9b915b4ca7 100644 --- a/src/bonsai/bonsai/bim/module/alignment/operator.py +++ b/src/bonsai/bonsai/bim/module/alignment/operator.py @@ -53,12 +53,18 @@ class ImportAlignmentCSV(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): 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() + # Make the imported alignment the active/selected object so it's + # immediately picked up by tool.Alignment.get_active_alignment() — + # same convention as ALIGN_OT_add_alignment/ALIGN_OT_draw_horizontal_alignment. + 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"}, "Imported in %s seconds" % (time.time() - start)) @@ -75,335 +81,6 @@ def poll_ifc4x3(cls, context): 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 - 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. @@ -491,412 +168,13 @@ def _bearing_string(azimuth_from_east_ccw_deg: float) -> str: 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): - ok, message = _build_alignment_from_active_pis(context) - self.report({"INFO"} if ok else {"WARNING"}, message) - - -class ALIGN_OT_clear_pis(Operator, tool.Ifc.Operator): - """Delete the active alignment and clear the PI table""" - - 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"} - - @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 - - def invoke(self, context, event): - return context.window_manager.invoke_confirm(self, event) - - def _execute(self, context): - ifc = tool.Ifc.get() - props = context.scene.CivilAlignmentProperties - - removed_objects = 0 - - # 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 = "" - - # Clear the PI list in the UI - props.pis.clear() - props.active_pi_index = 0 - - # 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). +# Alignments tab 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. +# No persistent PI table, no dependency on CivilAlignmentProperties beyond +# the alignment selector: an alignment is added as a bare IfcAlignment, then +# its horizontal geometry is drawn directly in the viewport and committed to +# IFC on completion. # ============================================================================= @@ -1308,7 +586,7 @@ def _generate_alignment_segments(context, alignment, hpoints, radii): # 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. + # per-layout/per-segment objects alongside it. tool.Alignment.remove_layout_and_child_layout_objects(alignment) tool.Alignment.clear_layout_segments(h_layout) @@ -1683,119 +961,6 @@ class ALIGN_OT_draw_horizontal_alignment(bpy.types.Operator, PolylineOperator, t 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 # ============================================================================= @@ -1985,181 +1150,3 @@ class ALIGN_OT_select_cant_segment(Operator): 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 deleted file mode 100644 index 88edb8568c..0000000000 Binary files a/src/bonsai/bonsai/bim/module/alignment/ops.authoring.alignment.dat and /dev/null differ diff --git a/src/bonsai/bonsai/bim/module/alignment/prop.py b/src/bonsai/bonsai/bim/module/alignment/prop.py index 990c667991..7e6d3d37a1 100644 --- a/src/bonsai/bonsai/bim/module/alignment/prop.py +++ b/src/bonsai/bonsai/bim/module/alignment/prop.py @@ -124,17 +124,6 @@ def _on_ve_update(self, context): 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.""" @@ -153,128 +142,9 @@ class CantAlignmentItem(PropertyGroup): ) -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", @@ -284,35 +154,6 @@ class CivilAlignmentProperties(PropertyGroup): 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", @@ -371,19 +212,6 @@ class CivilAlignmentProperties(PropertyGroup): 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 diff --git a/src/bonsai/bonsai/bim/module/alignment/ui.py b/src/bonsai/bonsai/bim/module/alignment/ui.py index 0d116c3b58..c7090c30c1 100644 --- a/src/bonsai/bonsai/bim/module/alignment/ui.py +++ b/src/bonsai/bonsai/bim/module/alignment/ui.py @@ -19,8 +19,8 @@ """UI panels for the alignment module -All panels appear in the Properties sidebar under the CIVIL tab, -nested under BIM_PT_tab_horizontal_alignment. +All panels appear in the Properties sidebar under the Alignments tab, +nested under BIM_PT_tab_alignments. """ import bpy @@ -28,7 +28,7 @@ import math import ifcopenshell.api.alignment import ifcopenshell.util.geolocation import bonsai.tool as tool -from bpy.types import Panel, UIList, Operator +from bpy.types import Panel, 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 @@ -104,233 +104,6 @@ class ALIGN_OT_toggle_cant_segments(Operator): 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 # ============================================================================= @@ -383,9 +156,8 @@ def _start_en(ifc_file, dp) -> tuple[float | None, float | 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. + Add a bare alignment, then draw its horizontal geometry directly in the + viewport. """ bl_label = "Add Alignment" @@ -438,9 +210,8 @@ class ALIGN_PT_alignment_authoring(Panel): 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. + Edit the start station, and add/remove additional stationing referents + (station equations) for gaps, overlaps, or reversed stationing direction. """ bl_label = "Stationing" diff --git a/src/bonsai/bonsai/bim/module/alignment/workspace.py b/src/bonsai/bonsai/bim/module/alignment/workspace.py deleted file mode 100644 index ec7848bd55..0000000000 --- a/src/bonsai/bonsai/bim/module/alignment/workspace.py +++ /dev/null @@ -1,72 +0,0 @@ -# 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 eaa2636217..c1988c8b82 100644 --- a/src/bonsai/bonsai/bim/module/root/data.py +++ b/src/bonsai/bonsai/bim/module/root/data.py @@ -135,11 +135,6 @@ 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 58157a9edf..fd79c71875 100644 --- a/src/bonsai/bonsai/bim/module/root/operator.py +++ b/src/bonsai/bonsai/bim/module/root/operator.py @@ -575,29 +575,6 @@ 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 0078124c23..921d986336 100644 --- a/src/bonsai/bonsai/bim/prop.py +++ b/src/bonsai/bonsai/bim/prop.py @@ -530,7 +530,6 @@ 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), diff --git a/src/bonsai/bonsai/bim/ui.py b/src/bonsai/bonsai/bim/ui.py index a457507953..fc22665555 100644 --- a/src/bonsai/bonsai/bim/ui.py +++ b/src/bonsai/bonsai/bim/ui.py @@ -1640,25 +1640,6 @@ 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" @@ -1863,7 +1844,6 @@ 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), diff --git a/src/bonsai/bonsai/core/alignment.py b/src/bonsai/bonsai/core/alignment.py index 680a649a80..f3f2fe83ff 100644 --- a/src/bonsai/bonsai/core/alignment.py +++ b/src/bonsai/bonsai/core/alignment.py @@ -78,70 +78,6 @@ def create_alignment( 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]", @@ -183,84 +119,3 @@ def import_alignment_csv( 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 5e9617adf1..4a0e5b0c0d 100644 --- a/src/bonsai/bonsai/core/tool.py +++ b/src/bonsai/bonsai/core/tool.py @@ -1290,17 +1290,10 @@ class Web: 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 diff --git a/src/bonsai/bonsai/tool/alignment.py b/src/bonsai/bonsai/tool/alignment.py index 87e861c588..6e8febbc5f 100644 --- a/src/bonsai/bonsai/tool/alignment.py +++ b/src/bonsai/bonsai/tool/alignment.py @@ -36,27 +36,11 @@ 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. @@ -64,288 +48,6 @@ class Alignment: 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) # ========================================================================= @@ -374,9 +76,8 @@ class Alignment: 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 + a loaded file which never has one until it's meaningful. The + Alignments tab's draw tool creates the layout object lazily, once there's something to show. Args: @@ -514,20 +215,6 @@ class Alignment: 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 # ========================================================================= @@ -560,25 +247,6 @@ class Alignment: 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 # ========================================================================= @@ -629,15 +297,13 @@ class Alignment: 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. + or their IfcAlignmentSegments, unlike create_object_for_layout / + create_objects_for_layout_segments (still used by CSV import's + hierarchy build), which would leave the scene collection looking + different from a loaded file for no IFC-side reason. 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 @@ -1232,26 +898,6 @@ class Alignment: 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. @@ -1408,273 +1054,6 @@ class Alignment: 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(): diff --git a/src/bonsai/bonsai/tool/root.py b/src/bonsai/bonsai/tool/root.py index b926dd9b6e..ef2464d964 100644 --- a/src/bonsai/bonsai/tool/root.py +++ b/src/bonsai/bonsai/tool/root.py @@ -488,8 +488,4 @@ 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 06369d9d23..cb77ee9060 100644 --- a/src/bonsai/pytest.ini +++ b/src/bonsai/pytest.ini @@ -8,7 +8,6 @@ markers = boundary brick bsdd - civil clash classification clip_box diff --git a/src/bonsai/test/bim/module/alignment/test_alignment_operators.py b/src/bonsai/test/bim/module/alignment/test_alignment_operators.py index df43c291be..5872f42329 100644 --- a/src/bonsai/test/bim/module/alignment/test_alignment_operators.py +++ b/src/bonsai/test/bim/module/alignment/test_alignment_operators.py @@ -22,12 +22,7 @@ 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 @@ -37,7 +32,6 @@ 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 @@ -64,514 +58,6 @@ requires_geometry_engine = pytest.mark.skipif( 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. @@ -590,9 +76,8 @@ class TestImportAlignmentCsv(NewIfc4X3): 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) + alignment = tool.Alignment.get_active_alignment() + assert alignment is not None assert alignment.is_a("IfcAlignment") assert tool.Ifc.get_object(alignment) is not None @@ -607,64 +92,5 @@ class TestImportAlignmentCsv(NewIfc4X3): 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) + alignment = tool.Alignment.get_active_alignment() 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/test_alignment.py b/src/bonsai/test/core/test_alignment.py index 2aea82f959..441f5afe5c 100644 --- a/src/bonsai/test/core/test_alignment.py +++ b/src/bonsai/test/core/test_alignment.py @@ -22,181 +22,6 @@ 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 # --------------------------------------------------------------------------- diff --git a/src/bonsai/test/tool/test_alignment.py b/src/bonsai/test/tool/test_alignment.py index 0318b9aafd..8edd4a4515 100644 --- a/src/bonsai/test/tool/test_alignment.py +++ b/src/bonsai/test/tool/test_alignment.py @@ -16,7 +16,6 @@ # 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 @@ -51,12 +50,6 @@ requires_geometry_engine = pytest.mark.skipif( # 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.""" @@ -77,220 +70,6 @@ class _FakeSegment: 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 # --------------------------------------------------------------------------- @@ -338,69 +117,6 @@ class TestIsZeroLengthSegment(NewFile): 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 # --------------------------------------------------------------------------- @@ -491,108 +207,6 @@ class TestGetHorizontalLayout(NewIfc4X3): 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 # --------------------------------------------------------------------------- @@ -704,149 +318,6 @@ class TestGetActiveAlignment(NewIfc4X3): 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 # --------------------------------------------------------------------------- @@ -924,6 +395,11 @@ class TestIfcSaveReloadRoundtrip(NewIfc4X3): @requires_geometry_engine +def _has_real_segments(layout) -> bool: + """Whether ``layout`` has any segment beyond the zero-length terminator.""" + return any(not subject.is_zero_length_segment(s) for s in align_api.get_layout_segments(layout)) + + 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. @@ -948,9 +424,9 @@ class TestClearLayoutSegments(NewFile): 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 + assert _has_real_segments(h) is True subject.clear_layout_segments(h) - assert subject.layout_has_real_segments(h) is False + assert _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): @@ -979,32 +455,9 @@ class TestClearLayoutSegments(NewFile): 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 + assert _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) + assert _has_real_segments(v) is False class TestFormatStation(NewFile):