From 9a3ae628aabbc74c502ab2be61236c195be11d1b Mon Sep 17 00:00:00 2001 From: DesertSpringsCivil Date: Mon, 2 Feb 2026 18:19:58 -0700 Subject: [PATCH] Add rubber band visualization to PI picker modal Add visual feedback during PI placement with PIPickerDecorator: - Yellow tangent lines connecting placed PIs - Rubber band line from last PI to cursor position - Green circle markers at each PI location - HUD text showing instructions and PI count Follows Bonsai's established decorator pattern with GPU draw handlers. Co-Authored-By: Claude Opus 4.5 --- .../bonsai/bim/module/alignment/__init__.py | 2 +- .../bonsai/bim/module/alignment/decorator.py | 240 ++++++++++++++++++ .../bonsai/bim/module/alignment/operator.py | 58 ++++- 3 files changed, 297 insertions(+), 3 deletions(-) create mode 100644 src/bonsai/bonsai/bim/module/alignment/decorator.py diff --git a/src/bonsai/bonsai/bim/module/alignment/__init__.py b/src/bonsai/bonsai/bim/module/alignment/__init__.py index a7f68fa0b2..8ee2258ebf 100644 --- a/src/bonsai/bonsai/bim/module/alignment/__init__.py +++ b/src/bonsai/bonsai/bim/module/alignment/__init__.py @@ -18,7 +18,7 @@ import bpy from bpy.app.handlers import persistent -from . import ui, prop, operator +from . import ui, prop, operator, decorator @persistent diff --git a/src/bonsai/bonsai/bim/module/alignment/decorator.py b/src/bonsai/bonsai/bim/module/alignment/decorator.py new file mode 100644 index 0000000000..4bd63b8081 --- /dev/null +++ b/src/bonsai/bonsai/bim/module/alignment/decorator.py @@ -0,0 +1,240 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2025, 2026 Michael Yoder +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . + +"""Alignment module decorators for GPU visualization. + +This module contains decorators for rendering visual feedback during +alignment-related operations, such as PI picking. +""" + +import bpy +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) diff --git a/src/bonsai/bonsai/bim/module/alignment/operator.py b/src/bonsai/bonsai/bim/module/alignment/operator.py index 0a86aa581a..2183b92449 100644 --- a/src/bonsai/bonsai/bim/module/alignment/operator.py +++ b/src/bonsai/bonsai/bim/module/alignment/operator.py @@ -28,6 +28,8 @@ import ifcopenshell.api.spatial from bpy_extras.io_utils import ImportHelper from bpy.types import Operator from bpy.props import StringProperty, FloatProperty, IntProperty +from mathutils import Vector +from . import decorator as alignment_decorator class ImportAlignmentCSV(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): @@ -821,24 +823,51 @@ class SAIKEI_OT_pick_pi_from_viewport(Operator): 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) + + # 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.") 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"} + 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"} elif event.type in {"RIGHTMOUSE", "ESC"}: - context.window.cursor_set("DEFAULT") - self.report({"INFO"}, "Finished adding PIs") + self._finish_modal(context) return {"FINISHED"} # Allow viewport navigation @@ -876,6 +905,31 @@ class SAIKEI_OT_pick_pi_from_viewport(Operator): return (hit.x, hit.y) return None + 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 + """ + props = context.scene.SaikeiAlignmentProperties + points = [] + for pi in props.pis: + # Convert from IFC to Blender coordinates + blender_coord = tool.Alignment.ifc_to_blender_coordinates(pi.x, pi.y, 0.0) + points.append(Vector(blender_coord)) + return points + + 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") + def add_pi_at_location(self, context, coord): """Add a new PI at the given (x, y) coordinate.