Fix PI picker viewport coordinate calculation and add georeference support

- Fix modal operator to use absolute mouse coordinates converted to 3D
  viewport region space, instead of event.mouse_region_x/y which are
  relative to whichever region received the event
- Store 3D viewport area, region, and region_data references in invoke()
  for consistent raycasting throughout modal operation
- Add coordinate transformation methods (blender_to_ifc_coordinates and
  ifc_to_blender_coordinates) for projects with geospatial Blender offsets
- Transform alignment curve vertices from IFC global to Blender local
  coordinates when has_blender_offset is enabled
- Add try/except for piecewise-step-size geometry setting in util.py

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
DesertSpringsCivil
2026-02-02 10:19:02 -07:00
parent 5ad40612fb
commit 95952d03f8
3 changed files with 204 additions and 13 deletions
@@ -793,11 +793,34 @@ class SAIKEI_OT_pick_pi_from_viewport(Operator):
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. Right-click or Escape to finish."
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 invoke(self, context, event): def invoke(self, context, event):
# Find the 3D viewport area, region, and region_data
for area in context.screen.areas:
if area.type == "VIEW_3D":
self._area = 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
break
break
if not self._region or not self._rv3d:
self.report({"ERROR"}, "No 3D Viewport found")
return {"CANCELLED"}
context.window.cursor_set("CROSSHAIR") context.window.cursor_set("CROSSHAIR")
context.window_manager.modal_handler_add(self) context.window_manager.modal_handler_add(self)
self.report({"INFO"}, "Click to add PIs. Right-click or Escape to finish.") self.report({"INFO"}, "Click to add PIs. Right-click or Escape to finish.")
@@ -809,7 +832,8 @@ class SAIKEI_OT_pick_pi_from_viewport(Operator):
coord = self.get_ground_intersection(context, event) coord = self.get_ground_intersection(context, event)
if coord: if coord:
self.add_pi_at_location(context, coord) self.add_pi_at_location(context, coord)
context.area.tag_redraw() if self._area:
self._area.tag_redraw()
return {"RUNNING_MODAL"} return {"RUNNING_MODAL"}
elif event.type in {"RIGHTMOUSE", "ESC"}: elif event.type in {"RIGHTMOUSE", "ESC"}:
@@ -827,9 +851,19 @@ class SAIKEI_OT_pick_pi_from_viewport(Operator):
"""Raycast from mouse to Z=0 ground plane""" """Raycast from mouse to Z=0 ground plane"""
from bpy_extras.view3d_utils import region_2d_to_origin_3d, region_2d_to_vector_3d from bpy_extras.view3d_utils import region_2d_to_origin_3d, region_2d_to_vector_3d
region = context.region region = self._region
rv3d = context.region_data rv3d = self._rv3d
coord = (event.mouse_region_x, event.mouse_region_y)
# Guard against None context
if region is None or rv3d is None:
return None
# 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)
origin = region_2d_to_origin_3d(region, rv3d, coord) origin = region_2d_to_origin_3d(region, rv3d, coord)
direction = region_2d_to_vector_3d(region, rv3d, coord) direction = region_2d_to_vector_3d(region, rv3d, coord)
@@ -843,12 +877,21 @@ class SAIKEI_OT_pick_pi_from_viewport(Operator):
return None return None
def add_pi_at_location(self, context, coord): def add_pi_at_location(self, context, coord):
"""Add a new PI at the given (x, y) coordinate""" """Add a new PI at the given (x, y) coordinate.
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 props = context.scene.SaikeiAlignmentProperties
# Convert Blender coordinates to IFC coordinates
# PIs are stored in IFC coordinate space (global/map coordinates)
ifc_coord = tool.Alignment.blender_to_ifc_coordinates(coord[0], coord[1], 0.0)
pi = props.pis.add() pi = props.pis.add()
pi.x = coord[0] pi.x = ifc_coord[0]
pi.y = coord[1] pi.y = ifc_coord[1]
# Determine PI type based on position in list # Determine PI type based on position in list
if len(props.pis) == 1: if len(props.pis) == 1:
+149 -4
View File
@@ -286,6 +286,10 @@ class Alignment:
Uses IfcOpenShell's geometry engine to generate vertices, supporting Uses IfcOpenShell's geometry engine to generate vertices, supporting
all segment types (LINE, CIRCULARARC, CLOTHOID, spirals, etc.). all segment types (LINE, CIRCULARARC, CLOTHOID, spirals, etc.).
The vertices from IFC are in global/map coordinates. If a Blender offset
is configured (for handling large geospatial coordinates), the vertices
are transformed to Blender local coordinates.
Args: Args:
layout: The IFC layout entity (IfcAlignmentHorizontal, etc.) layout: The IFC layout entity (IfcAlignmentHorizontal, etc.)
parent_obj: The parent Blender object (alignment object) parent_obj: The parent Blender object (alignment object)
@@ -295,11 +299,14 @@ class Alignment:
""" """
import ifcopenshell.api.alignment as align_api import ifcopenshell.api.alignment as align_api
from ifcopenshell.api.alignment import util as align_util from ifcopenshell.api.alignment import util as align_util
import ifcopenshell.util.geolocation
import ifcopenshell.util.unit
# Get the layout's curve representation # Get the layout's curve representation
try: try:
rep_curve = align_api.get_layout_curve(layout) rep_curve = align_api.get_layout_curve(layout)
except Exception: except Exception as e:
print(f"[Alignment] get_layout_curve failed: {e}")
rep_curve = None rep_curve = None
if rep_curve is None: if rep_curve is None:
@@ -308,13 +315,48 @@ class Alignment:
# Generate vertices using IfcOpenShell's geometry engine # Generate vertices using IfcOpenShell's geometry engine
try: try:
vertices = align_util.generate_vertices(rep_curve, distance_interval=1.0) vertices = align_util.generate_vertices(rep_curve, distance_interval=1.0)
except (ValueError, NotImplementedError, RuntimeError): except (ValueError, NotImplementedError, RuntimeError) as e:
# RuntimeError can occur if IfcOpenShell version doesn't support certain settings # RuntimeError can occur if IfcOpenShell version doesn't support certain settings
print(f"[Alignment] generate_vertices failed: {e}")
return None return None
if len(vertices) < 2: if len(vertices) < 2:
print(f"[Alignment] Not enough vertices ({len(vertices)}), need at least 2")
return None return None
# Check if we need to apply Blender offset transformation
# IFC vertices are in global/map coordinates, we need to convert to Blender local
gprops = tool.Georeference.get_georeference_props()
ifc_file = tool.Ifc.get()
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file) if ifc_file else 1.0
if gprops.has_blender_offset:
offset_x = float(gprops.blender_offset_x) * unit_scale
offset_y = float(gprops.blender_offset_y) * unit_scale
offset_z = float(gprops.blender_offset_z) * unit_scale
x_axis_abscissa = float(gprops.blender_x_axis_abscissa)
x_axis_ordinate = float(gprops.blender_x_axis_ordinate)
# Transform each vertex from IFC global to Blender local
transformed_vertices = []
for vert in vertices:
# Create a 4x4 identity matrix with translation set to vertex position
import numpy as np
matrix = np.eye(4)
matrix[0, 3] = vert[0]
matrix[1, 3] = vert[1]
matrix[2, 3] = vert[2]
# Apply global2local transformation
local_matrix = ifcopenshell.util.geolocation.global2local(
matrix, offset_x, offset_y, offset_z, x_axis_abscissa, x_axis_ordinate
)
# Extract transformed position
transformed_vertices.append((local_matrix[0, 3], local_matrix[1, 3], local_matrix[2, 3]))
vertices = transformed_vertices
# Create Blender curve from vertices # Create Blender curve from vertices
layout_type = layout.is_a().replace("IfcAlignment", "") # "Horizontal", "Vertical", etc. layout_type = layout.is_a().replace("IfcAlignment", "") # "Horizontal", "Vertical", etc.
name = f"{layout_type}Curve" name = f"{layout_type}Curve"
@@ -331,6 +373,9 @@ class Alignment:
obj.show_in_front = True obj.show_in_front = True
curve_data.bevel_depth = 0.0 curve_data.bevel_depth = 0.0
# Set a visible color for the curve (black, like construction lines)
obj.color = (0.0, 0.0, 0.0, 1.0) # Black color
# Set parent relationship # Set parent relationship
if parent_obj: if parent_obj:
obj.parent = parent_obj obj.parent = parent_obj
@@ -378,11 +423,13 @@ class Alignment:
obj.empty_display_type = "PLAIN_AXES" obj.empty_display_type = "PLAIN_AXES"
obj.empty_display_size = 0.5 obj.empty_display_size = 0.5
# Position at segment start point # Position at segment start point (convert from IFC to Blender coordinates)
if hasattr(dp, "StartPoint") and dp.StartPoint: if hasattr(dp, "StartPoint") and dp.StartPoint:
coords = dp.StartPoint.Coordinates coords = dp.StartPoint.Coordinates
if len(coords) >= 2: if len(coords) >= 2:
obj.location = (coords[0], coords[1], 0.0) # Transform from IFC global to Blender local coordinates
blender_coords = cls.ifc_to_blender_coordinates(coords[0], coords[1], 0.0)
obj.location = blender_coords
# Link to IFC element # Link to IFC element
tool.Ifc.link(segment, obj) tool.Ifc.link(segment, obj)
@@ -718,3 +765,101 @@ class Alignment:
) )
return alignment return alignment
# =========================================================================
# Coordinate Transformation Methods
# =========================================================================
@classmethod
def blender_to_ifc_coordinates(cls, x: float, y: float, z: float = 0.0) -> Tuple[float, float, float]:
"""Convert Blender local coordinates to IFC global/map coordinates.
When a Blender offset is configured (for handling large geospatial coordinates),
this transforms from Blender's local coordinate system (near origin) to
the IFC global coordinate system (large geospatial values).
Args:
x: X coordinate in Blender space
y: Y coordinate in Blender space
z: Z coordinate in Blender space (default 0.0)
Returns:
Tuple of (x, y, z) in IFC global coordinates
"""
import ifcopenshell.util.geolocation
import ifcopenshell.util.unit
gprops = tool.Georeference.get_georeference_props()
ifc_file = tool.Ifc.get()
if not gprops.has_blender_offset or ifc_file is None:
# No transformation needed
return (x, y, z)
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file)
offset_x = float(gprops.blender_offset_x) * unit_scale
offset_y = float(gprops.blender_offset_y) * unit_scale
offset_z = float(gprops.blender_offset_z) * unit_scale
x_axis_abscissa = float(gprops.blender_x_axis_abscissa)
x_axis_ordinate = float(gprops.blender_x_axis_ordinate)
# Create a 4x4 identity matrix with translation set to position
import numpy as np
matrix = np.eye(4)
matrix[0, 3] = x
matrix[1, 3] = y
matrix[2, 3] = z
# Apply local2global transformation (inverse of global2local)
global_matrix = ifcopenshell.util.geolocation.local2global(
matrix, offset_x, offset_y, offset_z, x_axis_abscissa, x_axis_ordinate
)
return (global_matrix[0, 3], global_matrix[1, 3], global_matrix[2, 3])
@classmethod
def ifc_to_blender_coordinates(cls, x: float, y: float, z: float = 0.0) -> Tuple[float, float, float]:
"""Convert IFC global/map coordinates to Blender local coordinates.
When a Blender offset is configured (for handling large geospatial coordinates),
this transforms from the IFC global coordinate system (large geospatial values)
to Blender's local coordinate system (near origin).
Args:
x: X coordinate in IFC global space
y: Y coordinate in IFC global space
z: Z coordinate in IFC global space (default 0.0)
Returns:
Tuple of (x, y, z) in Blender local coordinates
"""
import ifcopenshell.util.geolocation
import ifcopenshell.util.unit
gprops = tool.Georeference.get_georeference_props()
ifc_file = tool.Ifc.get()
if not gprops.has_blender_offset or ifc_file is None:
# No transformation needed
return (x, y, z)
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file)
offset_x = float(gprops.blender_offset_x) * unit_scale
offset_y = float(gprops.blender_offset_y) * unit_scale
offset_z = float(gprops.blender_offset_z) * unit_scale
x_axis_abscissa = float(gprops.blender_x_axis_abscissa)
x_axis_ordinate = float(gprops.blender_x_axis_ordinate)
# Create a 4x4 identity matrix with translation set to position
import numpy as np
matrix = np.eye(4)
matrix[0, 3] = x
matrix[1, 3] = y
matrix[2, 3] = z
# Apply global2local transformation
local_matrix = ifcopenshell.util.geolocation.global2local(
matrix, offset_x, offset_y, offset_z, x_axis_abscissa, x_axis_ordinate
)
return (local_matrix[0, 3], local_matrix[1, 3], local_matrix[2, 3])
@@ -93,8 +93,11 @@ def generate_vertices(rep_curve: entity_instance, distance_interval: float = 5.0
try: try:
s.set("piecewise-step-type", 0) # 0 = step-size is maximum step size, 1 = step-size is mininimum number of steps s.set("piecewise-step-type", 0) # 0 = step-size is maximum step size, 1 = step-size is mininimum number of steps
except RuntimeError: except RuntimeError:
pass # Setting not available in older IfcOpenShell versions print("[util.py] piecewise-step-type setting not available, skipping")
s.set("piecewise-step-size", distance_interval) try:
s.set("piecewise-step-size", distance_interval)
except RuntimeError:
print("[util.py] piecewise-step-size setting not available, skipping")
shape = ifcopenshell.geom.create_shape(s, rep_curve) shape = ifcopenshell.geom.create_shape(s, rep_curve)
vertices = shape.verts vertices = shape.verts
if len(vertices) == 0: if len(vertices) == 0: