diff --git a/src/bonsai/bonsai/bim/module/alignment/__init__.py b/src/bonsai/bonsai/bim/module/alignment/__init__.py index 8ee2258ebf..0ab8b98841 100644 --- a/src/bonsai/bonsai/bim/module/alignment/__init__.py +++ b/src/bonsai/bonsai/bim/module/alignment/__init__.py @@ -30,10 +30,23 @@ def on_undo_redo(scene): - If the active alignment still exists, extracts PI data from IFC segments - If the alignment was deleted/invalidated, clears the PI Editor - Rebuilds display_rows to match the synced state + - If in PI edit mode and empties are gone, reset edit mode state """ if not hasattr(scene, "SaikeiAlignmentProperties"): return props = scene.SaikeiAlignmentProperties + + # Handle orphaned PI edit mode state (empties removed by undo) + if props.is_pi_edit_mode: + empties = [obj for obj in bpy.data.objects if obj.get("saikei_is_pi_empty")] + if not empties: + # Empties were undone - reset edit mode state + # The modal operator will detect this and clean up + props.is_pi_edit_mode = False + props.pi_edit_alignment_id = 0 + # Uninstall decorator if still active + decorator.PIEditDecorator.uninstall() + # Sync PI Editor from IFC to ensure consistency after undo/redo operator.sync_pis_from_ifc(props) @@ -60,6 +73,8 @@ classes = ( # Operators - Stationing operator.SAIKEI_OT_add_stationing_referent, operator.SAIKEI_OT_name_segments, + # Operators - PI Edit Mode + operator.SAIKEI_OT_enter_pi_edit_mode, # UI Panels (appear in Properties sidebar under CIVIL tab) ui.SAIKEI_PT_alignment_status, ui.SAIKEI_PT_alignment_creation, diff --git a/src/bonsai/bonsai/bim/module/alignment/decorator.py b/src/bonsai/bonsai/bim/module/alignment/decorator.py index 4bd63b8081..4db56c7be7 100644 --- a/src/bonsai/bonsai/bim/module/alignment/decorator.py +++ b/src/bonsai/bonsai/bim/module/alignment/decorator.py @@ -238,3 +238,166 @@ class PIPickerDecorator: blf.draw(font_id, line) blf.disable(font_id, blf.SHADOW) + + +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 (matching PIPickerDecorator) + 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) diff --git a/src/bonsai/bonsai/bim/module/alignment/operator.py b/src/bonsai/bonsai/bim/module/alignment/operator.py index 2183b92449..66cb09815d 100644 --- a/src/bonsai/bonsai/bim/module/alignment/operator.py +++ b/src/bonsai/bonsai/bim/module/alignment/operator.py @@ -1378,3 +1378,158 @@ class SAIKEI_OT_name_segments(Operator): return {"FINISHED"} +# ============================================================================= +# PI Edit Mode Operator +# ============================================================================= + + +class SAIKEI_OT_enter_pi_edit_mode(Operator): + """Enter PI editing mode - move PIs with G key, press Enter to apply or Escape to cancel""" + + bl_idname = "saikei.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.SaikeiAlignmentProperties + 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 + ifc = tool.Ifc.get() + alignment = get_alignment_by_id(ifc, props.active_alignment_id) + if alignment is None: + cls.poll_message_set("Selected alignment no longer exists") + return False + return True + + def invoke(self, context, event): + props = context.scene.SaikeiAlignmentProperties + 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) + + # 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): + props = context.scene.SaikeiAlignmentProperties + + # 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 == "RET" 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.SaikeiAlignmentProperties + + 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 regenerated") + 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() + + # 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() + + return {"FINISHED"} + + diff --git a/src/bonsai/bonsai/bim/module/alignment/prop.py b/src/bonsai/bonsai/bim/module/alignment/prop.py index 4bdb9565ee..df30da6434 100644 --- a/src/bonsai/bonsai/bim/module/alignment/prop.py +++ b/src/bonsai/bonsai/bim/module/alignment/prop.py @@ -235,6 +235,19 @@ class SaikeiAlignmentProperties(PropertyGroup): default=False, ) + # 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, + ) + # Display options show_pi_markers: BoolProperty( name="Show PI Markers", diff --git a/src/bonsai/bonsai/bim/module/alignment/ui.py b/src/bonsai/bonsai/bim/module/alignment/ui.py index da901bba10..a8d3dade30 100644 --- a/src/bonsai/bonsai/bim/module/alignment/ui.py +++ b/src/bonsai/bonsai/bim/module/alignment/ui.py @@ -231,6 +231,25 @@ class SAIKEI_PT_pi_editor(Panel): layout = self.layout props = context.scene.SaikeiAlignmentProperties + # 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("saikei.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.") diff --git a/src/bonsai/bonsai/core/alignment.py b/src/bonsai/bonsai/core/alignment.py index b3c3ad12fd..b41d2e3226 100644 --- a/src/bonsai/bonsai/core/alignment.py +++ b/src/bonsai/bonsai/core/alignment.py @@ -127,3 +127,169 @@ def create_layout_segment_objects( List of created Blender objects (curve + segment empties) """ return alignment_tool.create_objects_for_layout_segments(layout, layout_obj) + + +# ============================================================================= +# 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 + """ + import ifcopenshell.api.alignment as align_api + + # 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 + h_layout = align_api.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 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 + - Regenerate alignment segments + 2. Always: + - Remove temporary EMPTY objects + - Return success status + + 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, regenerate alignment with new PI positions + + Returns: + True if successful + + Raises: + ValueError: If alignment doesn't exist or regeneration fails + """ + import ifcopenshell + import ifcopenshell.api.alignment as align_api + + 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 alignment metadata for recreation + alignment_name = alignment.Name or "Alignment" + + # Get start station from existing alignment (or default) + start_station = 0.0 + try: + h_layout = align_api.get_horizontal_layout(alignment) + if h_layout: + # Try to get existing start station from referent + for rel in getattr(alignment, "IsNestedBy", []) or []: + for child in rel.RelatedObjects or []: + if child.is_a("IfcReferent"): + pos_el = child.ObjectPlacement + if pos_el and hasattr(pos_el, "PlacementRelTo"): + # Extract station value if available + pass + except Exception: + pass # Use default start_station + + # Remove empties BEFORE deleting alignment (they're parented to it) + alignment_tool.remove_pi_edit_empties(alignment_id) + + # Remove old alignment hierarchy from Blender + alignment_tool.remove_alignment_hierarchy(alignment) + + # Delete old IFC alignment + ifcopenshell.api.run("root.remove_product", ifc_file, product=alignment) + + # Create new alignment with updated PI positions + new_alignment = alignment_tool.safe_create_alignment_by_pi_method( + ifc_file, + name=alignment_name, + hpoints=hpoints, + radii=radii, + start_station=start_station, + ) + + # Create new Blender hierarchy + alignment_tool.create_hierarchy_for_alignment(new_alignment) + + 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/tool/alignment.py b/src/bonsai/bonsai/tool/alignment.py index 21a7fc2da3..b9c5d200b7 100644 --- a/src/bonsai/bonsai/tool/alignment.py +++ b/src/bonsai/bonsai/tool/alignment.py @@ -1043,3 +1043,339 @@ class Alignment: ) return (local_matrix[0, 3], local_matrix[1, 3], local_matrix[2, 3]) + + # ========================================================================= + # 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. + + This function analyzes the alignment's horizontal segments and + reconstructs the original PI (Point of Intersection) positions + that were used to create the alignment. + + Algorithm: + 1. Get horizontal layout and segments + 2. First PI = start point of first segment + 3. For each CIRCULARARC segment: + - Extract BC (begin curve) from StartPoint + - Calculate deflection: Δ = arc_length / radius + - Calculate tangent length: T = R × tan(Δ/2) + - PI position = BC + T × direction_vector + - Store radius + 4. For LINE-only transitions (radius=0): + - PI = endpoint of LINE segment (becomes a tangent PI) + 5. Last PI = end point of last real segment + + Args: + alignment: The IfcAlignment entity + + Returns: + List of dicts, each containing: + - "x": float - X coordinate in IFC space + - "y": float - Y coordinate in IFC space + - "radius": float - Curve radius (0 for endpoints/tangent PIs) + - "type": str - "ENDPOINT", "CURVE", or "TANGENT" + + Raises: + ValueError: If alignment has no horizontal layout or segments + """ + import ifcopenshell.api.alignment as align_api + + # 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 = [] + for rel in getattr(h_layout, "IsNestedBy", []) or []: + for segment in rel.RelatedObjects or []: + if segment.is_a("IfcAlignmentSegment"): + segments.append(segment) + + if not segments: + raise ValueError(f"Alignment #{alignment.id()} has no segments") + + # Filter out zero-length terminator segments + real_segments = [] + for seg in segments: + if not cls.is_zero_length_segment(seg): + real_segments.append(seg) + + if not real_segments: + raise ValueError(f"Alignment #{alignment.id()} has no real segments (only terminator)") + + pis = [] + + # First PI: start point of first segment + first_dp = real_segments[0].DesignParameters + first_x = float(first_dp.StartPoint.Coordinates[0]) + first_y = float(first_dp.StartPoint.Coordinates[1]) + pis.append({ + "x": first_x, + "y": first_y, + "radius": 0.0, + "type": "ENDPOINT" + }) + + # Track current position and direction for LINE segments + # This helps us identify tangent PIs (where LINE meets LINE) + prev_seg_type = first_dp.PredefinedType + + # Process each segment + for i, seg in enumerate(real_segments): + dp = seg.DesignParameters + seg_type = dp.PredefinedType + + if seg_type == "CIRCULARARC": + # Reconstruct PI from arc segment + bc_x = float(dp.StartPoint.Coordinates[0]) + bc_y = float(dp.StartPoint.Coordinates[1]) + angle_in = float(dp.StartDirection) + radius = abs(float(dp.StartRadiusOfCurvature)) + arc_length = float(dp.SegmentLength) + + # Deflection angle: Δ = L / R + deflection = arc_length / radius + + # Tangent length: T = R × tan(Δ/2) + tangent_length = radius * math.tan(deflection / 2) + + # PI position: BC + T × direction_vector + pi_x = bc_x + tangent_length * math.cos(angle_in) + pi_y = bc_y + tangent_length * math.sin(angle_in) + + pis.append({ + "x": pi_x, + "y": pi_y, + "radius": radius, + "type": "CURVE" + }) + + elif seg_type == "LINE": + # For LINE segments, check if this is a transition point + # If the previous segment was also LINE and this isn't the first, + # we may have a tangent PI at the connection point + if i > 0 and prev_seg_type == "LINE": + # There's a tangent PI at the start of this LINE + # (end of previous LINE) + start_x = float(dp.StartPoint.Coordinates[0]) + start_y = float(dp.StartPoint.Coordinates[1]) + pis.append({ + "x": start_x, + "y": start_y, + "radius": 0.0, + "type": "TANGENT" + }) + + prev_seg_type = seg_type + + # Last PI: end point of last segment + last_dp = real_segments[-1].DesignParameters + last_seg_type = last_dp.PredefinedType + last_length = float(last_dp.SegmentLength) + last_direction = float(last_dp.StartDirection) + last_start_x = float(last_dp.StartPoint.Coordinates[0]) + last_start_y = float(last_dp.StartPoint.Coordinates[1]) + + if last_seg_type == "LINE": + # End of LINE: simple projection + end_x = last_start_x + last_length * math.cos(last_direction) + end_y = last_start_y + last_length * math.sin(last_direction) + elif last_seg_type == "CIRCULARARC": + # End of ARC: use geometry engine or calculate + last_radius = abs(float(last_dp.StartRadiusOfCurvature)) + deflection = last_length / last_radius + + # Determine curve direction (positive radius = counterclockwise) + is_ccw = float(last_dp.StartRadiusOfCurvature) > 0 + if is_ccw: + end_direction = last_direction + deflection + else: + end_direction = last_direction - deflection + + # Calculate EC (end curve) position + # For an arc, EC is at BC + arc travel + # We need to use the center calculation + center_offset_angle = last_direction + (math.pi / 2 if is_ccw else -math.pi / 2) + center_x = last_start_x + last_radius * math.cos(center_offset_angle) + center_y = last_start_y + last_radius * math.sin(center_offset_angle) + + # EC is at the end of the arc + ec_angle = center_offset_angle + math.pi + (deflection if is_ccw else -deflection) + end_x = center_x + last_radius * math.cos(ec_angle) + end_y = center_y + last_radius * math.sin(ec_angle) + else: + # For other segment types (CLOTHOID, etc.), use start point as fallback + # TODO: Support spiral transitions + end_x = last_start_x + end_y = last_start_y + + pis.append({ + "x": end_x, + "y": end_y, + "radius": 0.0, + "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 + + alignment_id = alignment.id() + empties = [] + + for i, pi in enumerate(pis): + # Convert IFC coordinates to Blender coordinates + blender_pos = cls.ifc_to_blender_coordinates(pi["x"], pi["y"], 0.0) + + # 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["saikei_is_pi_empty"] = True + empty["saikei_pi_index"] = i + empty["saikei_pi_radius"] = pi["radius"] + empty["saikei_alignment_id"] = alignment_id + empty["saikei_pi_type"] = 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("saikei_is_pi_empty") and obj.get("saikei_alignment_id") == alignment_id: + empties.append(obj) + + # Sort by PI index + empties.sort(key=lambda e: e.get("saikei_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 ([], []) + + hpoints = [] + radii = [] + + for i, empty in enumerate(empties): + # Convert Blender position to IFC coordinates + blender_pos = empty.location + ifc_pos = cls.blender_to_ifc_coordinates( + blender_pos.x, blender_pos.y, blender_pos.z + ) + 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("saikei_pi_radius", 0.0) + radii.append(radius) + + return (hpoints, radii)