diff --git a/src/bonsai/bonsai/bim/module/alignment/decorator.py b/src/bonsai/bonsai/bim/module/alignment/decorator.py index 4db56c7be7..2ad358353d 100644 --- a/src/bonsai/bonsai/bim/module/alignment/decorator.py +++ b/src/bonsai/bonsai/bim/module/alignment/decorator.py @@ -19,7 +19,7 @@ """Alignment module decorators for GPU visualization. This module contains decorators for rendering visual feedback during -alignment-related operations, such as PI picking. +alignment-related operations, such as PI editing. """ import bpy @@ -27,217 +27,7 @@ import blf import gpu import bonsai.tool as tool from bpy.types import SpaceView3D -from mathutils import Vector from gpu_extras.batch import batch_for_shader -from gpu_extras.presets import draw_circle_2d -from bpy_extras.view3d_utils import location_3d_to_region_2d - - -class PIPickerDecorator: - """Decorator for visualizing PI placement during modal picking. - - This decorator draws visual feedback while the user is placing - PI (Point of Intersection) points in the viewport: - - Yellow lines connecting placed PIs (tangent preview) - - Rubber band line from last PI to cursor - - Green circles at PI marker positions - - HUD text showing instructions and PI count - - All drawings are ephemeral - they disappear when the modal ends. - """ - - # Class-level state (cleared on uninstall) - is_installed = False - handlers = [] - - # PI points in Blender coordinates (list of Vector) - pi_points = [] - - # Current mouse position in 3D (Vector or None) - mouse_3d = None - - # Reference to the active region/rv3d for coordinate conversion - region = None - rv3d = None - - # Colors (matching reference document) - COLOR_TANGENT_LINE = (1.0, 0.9, 0.2, 1.0) # Yellow for tangent lines - COLOR_RUBBER_BAND = (1.0, 0.9, 0.2, 0.5) # Yellow with alpha for rubber band - COLOR_PI_MARKER = (0.3, 1.0, 0.4, 1.0) # Green for PI markers - COLOR_HUD_TEXT = (1.0, 1.0, 1.0, 1.0) # White for HUD text - - # Drawing parameters - PI_MARKER_RADIUS = 8 # pixels - LINE_WIDTH = 2.5 - - @classmethod - def install(cls, context, region, rv3d): - """Install decorator handlers for PI visualization. - - Args: - context: Blender context - region: The 3D viewport region for coordinate conversion - rv3d: The region's 3D data (RegionView3D) - """ - if cls.is_installed: - cls.uninstall() - - # Store region references for 3D->2D conversion - cls.region = region - cls.rv3d = rv3d - - # Clear any stale state - cls.pi_points = [] - cls.mouse_3d = None - - handler = cls() - # POST_PIXEL for 2D screen-space drawing (more efficient) - cls.handlers.append( - SpaceView3D.draw_handler_add(handler.draw_tangent_lines, (context,), "WINDOW", "POST_PIXEL") - ) - cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_pi_markers, (context,), "WINDOW", "POST_PIXEL")) - 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_points = [] - cls.mouse_3d = None - cls.region = None - cls.rv3d = None - - @classmethod - def update(cls, pi_points_blender, mouse_3d): - """Update decorator state from modal operator. - - Args: - pi_points_blender: List of Vector - PI positions in Blender coords - mouse_3d: Vector or None - Current mouse position on ground plane - """ - cls.pi_points = pi_points_blender - cls.mouse_3d = mouse_3d - - def draw_batch(self, shader_type, content_pos, color, indices=None): - """Draw a batch of primitives using GPU shader. - - This follows the established Bonsai decorator pattern. - - Args: - shader_type: Type of primitive ("LINES", "POINTS", etc.) - content_pos: List of 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 = self.line_shader if shader_type == "LINES" else self.shader - batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices) - shader.uniform_float("color", color) - batch.draw(shader) - - def draw_tangent_lines(self, context): - """Draw yellow tangent lines connecting PIs and rubber band to cursor.""" - region = self.region or context.region - rv3d = self.rv3d or context.region_data - - if not region or not rv3d: - return - - # Convert 3D points to 2D screen coordinates - screen_points = [] - for pt in self.pi_points: - screen_pt = location_3d_to_region_2d(region, rv3d, pt) - if screen_pt: - screen_points.append(screen_pt) - - # Nothing to draw if no points - if not screen_points and not self.mouse_3d: - return - - # Setup shaders - gpu.state.blend_set("ALPHA") - self.line_shader = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR") - self.line_shader.bind() - self.line_shader.uniform_float("viewportSize", (region.width, region.height)) - self.line_shader.uniform_float("lineWidth", self.LINE_WIDTH) - self.shader = gpu.shader.from_builtin("UNIFORM_COLOR") - - # Draw lines between placed PIs (solid yellow) - if len(screen_points) >= 2: - verts = [(p.x, p.y) for p in screen_points] - edges = [[i, i + 1] for i in range(len(screen_points) - 1)] - self.draw_batch("LINES", verts, self.COLOR_TANGENT_LINE, edges) - - # Draw rubber band from last PI to cursor (semi-transparent) - if screen_points and self.mouse_3d: - mouse_2d = location_3d_to_region_2d(region, rv3d, self.mouse_3d) - if mouse_2d: - last_pt = screen_points[-1] - verts = [(last_pt.x, last_pt.y), (mouse_2d.x, mouse_2d.y)] - self.draw_batch("LINES", verts, self.COLOR_RUBBER_BAND, [[0, 1]]) - - gpu.state.blend_set("NONE") - - def draw_pi_markers(self, context): - """Draw green circles at each PI location.""" - if not self.pi_points: - return - - region = self.region or context.region - rv3d = self.rv3d or context.region_data - - if not region or not rv3d: - return - - gpu.state.blend_set("ALPHA") - - for pt in self.pi_points: - screen_pt = location_3d_to_region_2d(region, rv3d, pt) - if screen_pt: - draw_circle_2d(screen_pt, self.COLOR_PI_MARKER, self.PI_MARKER_RADIUS) - - gpu.state.blend_set("NONE") - - def draw_hud(self, context): - """Draw HUD text with instructions and PI count.""" - region = self.region or 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 - - # Instructions - instructions = [ - "PI Picker Mode", - f"PIs placed: {len(self.pi_points)}", - "", - "LMB: Place PI", - "RMB/ESC: Finish", - ] - - 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 PIEditDecorator: @@ -259,7 +49,7 @@ class PIEditDecorator: # References to PI empty objects pi_empties = [] - # Colors (matching PIPickerDecorator) + # 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 diff --git a/src/bonsai/bonsai/bim/module/alignment/operator.py b/src/bonsai/bonsai/bim/module/alignment/operator.py index 372561b5a4..ae79835e88 100644 --- a/src/bonsai/bonsai/bim/module/alignment/operator.py +++ b/src/bonsai/bonsai/bim/module/alignment/operator.py @@ -30,6 +30,9 @@ from bpy.types import Operator from bpy.props import StringProperty, FloatProperty, IntProperty from mathutils import Vector from . import decorator as alignment_decorator +from bonsai.bim.module.model.polyline import PolylineOperator +from bonsai.bim.module.model.decorator import PolylineDecorator +from bonsai.bim.ifc import IfcStore class ImportAlignmentCSV(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): @@ -787,180 +790,168 @@ class SAIKEI_OT_remove_pi(Operator): return {"FINISHED"} -class SAIKEI_OT_pick_pi_from_viewport(Operator): - """Add PI points by clicking in the 3D viewport""" +class SAIKEI_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 = "saikei.pick_pi_from_viewport" bl_label = "Pick PI from Viewport" - bl_description = "Click in the viewport to add PI points. Right-click or Escape to finish." + 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"} - # Store reference to 3D view for modal - _area = None - _region = None - _rv3d = None - @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): - # Find the 3D viewport area, region, and region_data + 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": - self._area = area + area_3d = area for region in area.regions: if region.type == "WINDOW": - self._region = region - break - for space in area.spaces: - if space.type == "VIEW_3D": - self._rv3d = space.region_3d + region_3d = region break break - if not self._region or not self._rv3d: + if not area_3d or not region_3d: self.report({"ERROR"}, "No 3D Viewport found") return {"CANCELLED"} - # Install the PI picker decorator for visual feedback - alignment_decorator.PIPickerDecorator.install(context, self._region, self._rv3d) + with context.temp_override(area=area_3d, region=region_3d): + super().invoke(context, event) - # Initialize decorator with any existing PIs - alignment_decorator.PIPickerDecorator.update( - pi_points_blender=self._get_pi_points_blender(context), - mouse_3d=None, - ) - - context.window.cursor_set("CROSSHAIR") - context.window_manager.modal_handler_add(self) - self.report({"INFO"}, "Click to add PIs. Right-click or Escape to finish.") + self.tool_state.use_default_container = False + self.tool_state.plane_method = "XY" return {"RUNNING_MODAL"} def modal(self, context, event): - # Update rubber band position on mouse move - if event.type == "MOUSEMOVE": - coord = self.get_ground_intersection(context, event) - if coord: - mouse_3d = Vector((coord[0], coord[1], 0.0)) - alignment_decorator.PIPickerDecorator.update( - pi_points_blender=self._get_pi_points_blender(context), - mouse_3d=mouse_3d, - ) - if self._area: - self._area.tag_redraw() - return {"RUNNING_MODAL"} + return IfcStore.execute_ifc_operator(self, context, event, method="MODAL") - if event.type == "LEFTMOUSE" and event.value == "PRESS": - # Raycast to ground plane (Z=0) - coord = self.get_ground_intersection(context, event) - if coord: - self.add_pi_at_location(context, coord) - # Update decorator with new PI - mouse_3d = Vector((coord[0], coord[1], 0.0)) - alignment_decorator.PIPickerDecorator.update( - pi_points_blender=self._get_pi_points_blender(context), - mouse_3d=mouse_3d, - ) - if self._area: - self._area.tag_redraw() - return {"RUNNING_MODAL"} + def _modal(self, context, event): + PolylineDecorator.update(event, self.tool_state, self.input_ui, self.snapping_points[0]) + tool.Blender.update_viewport() - elif event.type in {"RIGHTMOUSE", "ESC"}: - self._finish_modal(context) + 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) + self._handle_inserting_polyline_no_close(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() + tool.Blender.update_viewport() return {"FINISHED"} - # Allow viewport navigation - elif event.type in {"MIDDLEMOUSE", "WHEELUPMOUSE", "WHEELDOWNMOUSE"}: - return {"PASS_THROUGH"} + cancel = self.handle_cancelation(context, event) + if cancel is not None: + return cancel return {"RUNNING_MODAL"} - def get_ground_intersection(self, context, event): - """Raycast from mouse to Z=0 ground plane""" - from bpy_extras.view3d_utils import region_2d_to_origin_3d, region_2d_to_vector_3d + def _handle_inserting_polyline_no_close(self, context, event): + """Insert polyline points without close-polyline (C key) behavior. - region = self._region - rv3d = self._rv3d + Alignments are open curves, so the C key (close polyline) is suppressed. + All other insertion behavior is preserved: LEFTMOUSE, BACKSPACE, and + RET/ENTER with numeric input active. + """ + # LEFTMOUSE: insert point at current snap/cursor position + if not self.tool_state.is_input_on and event.value == "RELEASE" and event.type == "LEFTMOUSE": + result = tool.Polyline.insert_polyline_point(self.input_ui, self.tool_state) + if result: + self.report({"WARNING"}, result) + tool.Blender.update_viewport() - # Guard against None context - if region is None or rv3d is None: - return None + # RET/ENTER with numeric input: validate and insert + if ( + self.tool_state.is_input_on + and event.value == "RELEASE" + and event.type in {"RET", "NUMPAD_ENTER", "RIGHTMOUSE"} + ): + is_valid = self.recalculate_inputs(context) + if is_valid: + result = tool.Polyline.insert_polyline_point(self.input_ui, self.tool_state) + if result: + self.report({"WARNING"}, result) - # Use absolute mouse coordinates and convert to the 3D viewport region's local coords - # event.mouse_region_x/y are relative to whatever region received the event, - # which may not be the 3D viewport region we stored - region_x = event.mouse_x - region.x - region_y = event.mouse_y - region.y - coord = (region_x, region_y) + self.tool_state.mode = "Mouse" + self.tool_state.is_input_on = False + self.input_type = None + self.tool_state.input_type = None + self.number_input = [] + self.number_output = "" + PolylineDecorator.update(event, self.tool_state, self.input_ui, self.snapping_points[0]) + tool.Blender.update_viewport() - origin = region_2d_to_origin_3d(region, rv3d, coord) - direction = region_2d_to_vector_3d(region, rv3d, coord) + # BACKSPACE: remove last point (when not typing numeric input) + if not self.tool_state.is_input_on: + if event.value == "RELEASE" and event.type == "BACK_SPACE": + tool.Polyline.remove_last_polyline_point() + tool.Blender.update_viewport() - # Intersect with Z=0 plane - if direction.z != 0: - t = -origin.z / direction.z - if t > 0: # In front of camera - hit = origin + direction * t - return (hit.x, hit.y) - return None + def _transfer_polyline_to_pis(self, context): + """Transfer collected polyline points to the PI Editor table. - def _get_pi_points_blender(self, context): - """Get all PI points converted to Blender coordinates. - - PI coordinates are stored in IFC space; this converts them - to Blender local coordinates for visualization. - - Returns: - List of Vector - PI positions in Blender coordinates + Polyline points are in Blender coordinate space. This method converts + each point to IFC coordinate space before storing in props.pis. """ props = context.scene.SaikeiAlignmentProperties - points = [] - for pi in props.pis: - # Convert from IFC to Blender coordinates - blender_coord = tool.Georeference.enh2xyz((float(pi.e), float(pi.n), 0.0)) - print(repr(blender_coord)) - points.append(Vector(blender_coord)) - return points + polyline_props = tool.Model.get_polyline_props() + polyline_data = polyline_props.insertion_polyline + if not polyline_data: + return - def _finish_modal(self, context): - """Clean up modal state and uninstall decorator.""" - context.window.cursor_set("DEFAULT") - alignment_decorator.PIPickerDecorator.uninstall() - if self._area: - self._area.tag_redraw() - self.report({"INFO"}, "Finished adding PIs") + polyline_points = polyline_data[0].polyline_points + if not polyline_points: + return - def add_pi_at_location(self, context, coord): - """Add a new PI at the given (x, y) coordinate. + num_points = len(polyline_points) + for i, point in enumerate(polyline_points): + # Convert Blender space -> IFC easting/northing + ifc_coord = tool.Georeference.xyz2enh((point.x, point.y, 0.0)) - The coordinate is in Blender world space. If there's a Blender offset - configured (for geospatial coordinates), we convert to IFC global - coordinates before storing. - """ - props = context.scene.SaikeiAlignmentProperties + pi = props.pis.add() + pi.e = str(ifc_coord[0]) + pi.n = str(ifc_coord[1]) - # Convert Blender coordinates to IFC coordinates - # PIs are stored in IFC coordinate space (global/map coordinates) - ifc_coord = tool.Georeference.xyz2enh((coord[0], coord[1], 0.0)) - - pi = props.pis.add() - pi.e = str(ifc_coord[0]) - pi.n = str(ifc_coord[1]) - - # Determine PI type based on position in list - if len(props.pis) == 1: - pi.pi_type = "ENDPOINT" - elif len(props.pis) == 2: - pi.pi_type = "ENDPOINT" - else: - pi.pi_type = "TANGENT" - # Previous endpoint becomes tangent - if len(props.pis) >= 2: - props.pis[-2].pi_type = "TANGENT" + # 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) class SAIKEI_OT_recalculate_pis(Operator): diff --git a/src/bonsai/bonsai/bim/module/alignment/ui.py b/src/bonsai/bonsai/bim/module/alignment/ui.py index 3f40d4219f..84f0692827 100644 --- a/src/bonsai/bonsai/bim/module/alignment/ui.py +++ b/src/bonsai/bonsai/bim/module/alignment/ui.py @@ -67,8 +67,8 @@ class SAIKEI_UL_alignment_pis(UIList): sub.prop(pi, "e", text="") sub.prop(pi, "n", text="") else: - row.label(text=f"{item.e:.2f}") - row.label(text=f"{item.n:.2f}") + 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="") @@ -86,8 +86,8 @@ class SAIKEI_UL_alignment_pis(UIList): row.label(text="Curve", icon="SPHERECURVE") # Show PI coordinates on curve row - row.label(text=f"{item.e:.2f}") - row.label(text=f"{item.n:.2f}") + 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}")