Refactor PI picker to use Bonsai polyline system

Replace custom SAIKEI_OT_pick_pi_from_viewport modal with a
PolylineOperator subclass, reusing Bonsai's proven polyline
infrastructure (same base class as wall/slab/profile drawing).

Gains: snapping, numeric D/A/X/Y input, axis locking, angle
locking, measurement display, undo-last-point, status bar hints.

Remove PIPickerDecorator (replaced by PolylineDecorator).
Fix EN string formatting in UI list display rows.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
DesertSpringsCivil
2026-02-17 11:29:30 -07:00
parent 65af40e7d5
commit 07ef382c7e
3 changed files with 125 additions and 344 deletions
@@ -19,7 +19,7 @@
"""Alignment module decorators for GPU visualization. """Alignment module decorators for GPU visualization.
This module contains decorators for rendering visual feedback during 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 import bpy
@@ -27,217 +27,7 @@ import blf
import gpu import gpu
import bonsai.tool as tool import bonsai.tool as tool
from bpy.types import SpaceView3D from bpy.types import SpaceView3D
from mathutils import Vector
from gpu_extras.batch import batch_for_shader 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: class PIEditDecorator:
@@ -259,7 +49,7 @@ class PIEditDecorator:
# References to PI empty objects # References to PI empty objects
pi_empties = [] pi_empties = []
# Colors (matching PIPickerDecorator) # Colors
COLOR_TANGENT_LINE = (1.0, 0.9, 0.2, 1.0) # Yellow for tangent lines 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_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 COLOR_EDIT_MODE_BG = (0.2, 0.4, 0.8, 0.8) # Blue tint for edit mode indicator
+119 -128
View File
@@ -30,6 +30,9 @@ from bpy.types import Operator
from bpy.props import StringProperty, FloatProperty, IntProperty from bpy.props import StringProperty, FloatProperty, IntProperty
from mathutils import Vector from mathutils import Vector
from . import decorator as alignment_decorator 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): class ImportAlignmentCSV(bpy.types.Operator, tool.Ifc.Operator, ImportHelper):
@@ -787,180 +790,168 @@ class SAIKEI_OT_remove_pi(Operator):
return {"FINISHED"} return {"FINISHED"}
class SAIKEI_OT_pick_pi_from_viewport(Operator): class SAIKEI_OT_pick_pi_from_viewport(bpy.types.Operator, PolylineOperator, tool.Ifc.Operator):
"""Add PI points by clicking in the 3D viewport""" """Add PI points by clicking in the 3D viewport using polyline tools"""
bl_idname = "saikei.pick_pi_from_viewport" bl_idname = "saikei.pick_pi_from_viewport"
bl_label = "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"} bl_options = {"REGISTER", "UNDO"}
# Store reference to 3D view for modal
_area = None
_region = None
_rv3d = None
@classmethod @classmethod
def poll(cls, context): def poll(cls, context):
return poll_ifc4x3(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): 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: for area in context.screen.areas:
if area.type == "VIEW_3D": if area.type == "VIEW_3D":
self._area = area area_3d = area
for region in area.regions: for region in area.regions:
if region.type == "WINDOW": if region.type == "WINDOW":
self._region = region region_3d = region
break
for space in area.spaces:
if space.type == "VIEW_3D":
self._rv3d = space.region_3d
break break
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") self.report({"ERROR"}, "No 3D Viewport found")
return {"CANCELLED"} return {"CANCELLED"}
# Install the PI picker decorator for visual feedback with context.temp_override(area=area_3d, region=region_3d):
alignment_decorator.PIPickerDecorator.install(context, self._region, self._rv3d) super().invoke(context, event)
# Initialize decorator with any existing PIs self.tool_state.use_default_container = False
alignment_decorator.PIPickerDecorator.update( self.tool_state.plane_method = "XY"
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"} return {"RUNNING_MODAL"}
def modal(self, context, event): def modal(self, context, event):
# Update rubber band position on mouse move return IfcStore.execute_ifc_operator(self, context, event, method="MODAL")
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": def _modal(self, context, event):
# Raycast to ground plane (Z=0) PolylineDecorator.update(event, self.tool_state, self.input_ui, self.snapping_points[0])
coord = self.get_ground_intersection(context, event) tool.Blender.update_viewport()
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"}: self.handle_lock_axis(context, event)
self._finish_modal(context)
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"} return {"FINISHED"}
# Allow viewport navigation cancel = self.handle_cancelation(context, event)
elif event.type in {"MIDDLEMOUSE", "WHEELUPMOUSE", "WHEELDOWNMOUSE"}: if cancel is not None:
return {"PASS_THROUGH"} return cancel
return {"RUNNING_MODAL"} return {"RUNNING_MODAL"}
def get_ground_intersection(self, context, event): def _handle_inserting_polyline_no_close(self, context, event):
"""Raycast from mouse to Z=0 ground plane""" """Insert polyline points without close-polyline (C key) behavior.
from bpy_extras.view3d_utils import region_2d_to_origin_3d, region_2d_to_vector_3d
region = self._region Alignments are open curves, so the C key (close polyline) is suppressed.
rv3d = self._rv3d 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 # RET/ENTER with numeric input: validate and insert
if region is None or rv3d is None: if (
return None 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 self.tool_state.mode = "Mouse"
# event.mouse_region_x/y are relative to whatever region received the event, self.tool_state.is_input_on = False
# which may not be the 3D viewport region we stored self.input_type = None
region_x = event.mouse_x - region.x self.tool_state.input_type = None
region_y = event.mouse_y - region.y self.number_input = []
coord = (region_x, region_y) 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) # BACKSPACE: remove last point (when not typing numeric input)
direction = region_2d_to_vector_3d(region, rv3d, coord) 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 def _transfer_polyline_to_pis(self, context):
if direction.z != 0: """Transfer collected polyline points to the PI Editor table.
t = -origin.z / direction.z
if t > 0: # In front of camera
hit = origin + direction * t
return (hit.x, hit.y)
return None
def _get_pi_points_blender(self, context): Polyline points are in Blender coordinate space. This method converts
"""Get all PI points converted to Blender coordinates. each point to IFC coordinate space before storing in props.pis.
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 props = context.scene.SaikeiAlignmentProperties
points = [] polyline_props = tool.Model.get_polyline_props()
for pi in props.pis: polyline_data = polyline_props.insertion_polyline
# Convert from IFC to Blender coordinates if not polyline_data:
blender_coord = tool.Georeference.enh2xyz((float(pi.e), float(pi.n), 0.0)) return
print(repr(blender_coord))
points.append(Vector(blender_coord))
return points
def _finish_modal(self, context): polyline_points = polyline_data[0].polyline_points
"""Clean up modal state and uninstall decorator.""" if not polyline_points:
context.window.cursor_set("DEFAULT") return
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): num_points = len(polyline_points)
"""Add a new PI at the given (x, y) coordinate. 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 pi = props.pis.add()
configured (for geospatial coordinates), we convert to IFC global pi.e = str(ifc_coord[0])
coordinates before storing. pi.n = str(ifc_coord[1])
"""
props = context.scene.SaikeiAlignmentProperties
# Convert Blender coordinates to IFC coordinates # Determine PI type based on position
# PIs are stored in IFC coordinate space (global/map coordinates) if i == 0 or i == num_points - 1:
ifc_coord = tool.Georeference.xyz2enh((coord[0], coord[1], 0.0)) pi.pi_type = "ENDPOINT"
else:
pi = props.pis.add() pi.pi_type = "TANGENT"
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"
props.active_pi_index = len(props.pis) - 1 props.active_pi_index = len(props.pis) - 1
recalculate_pi_geometry(props) recalculate_pi_geometry(props)
rebuild_display_rows(props)
class SAIKEI_OT_recalculate_pis(Operator): class SAIKEI_OT_recalculate_pis(Operator):
+4 -4
View File
@@ -67,8 +67,8 @@ class SAIKEI_UL_alignment_pis(UIList):
sub.prop(pi, "e", text="") sub.prop(pi, "e", text="")
sub.prop(pi, "n", text="") sub.prop(pi, "n", text="")
else: else:
row.label(text=f"{item.e:.2f}") row.label(text=f"{float(item.e):.2f}")
row.label(text=f"{item.n:.2f}") row.label(text=f"{float(item.n):.2f}")
# Length column - empty for point rows # Length column - empty for point rows
row.label(text="") row.label(text="")
@@ -86,8 +86,8 @@ class SAIKEI_UL_alignment_pis(UIList):
row.label(text="Curve", icon="SPHERECURVE") row.label(text="Curve", icon="SPHERECURVE")
# Show PI coordinates on curve row # Show PI coordinates on curve row
row.label(text=f"{item.e:.2f}") row.label(text=f"{float(item.e):.2f}")
row.label(text=f"{item.n:.2f}") row.label(text=f"{float(item.n):.2f}")
# Arc length # Arc length
row.label(text=f"{item.arc_length:.2f}") row.label(text=f"{item.arc_length:.2f}")