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_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 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_manager.modal_handler_add(self)
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)
if coord:
self.add_pi_at_location(context, coord)
context.area.tag_redraw()
if self._area:
self._area.tag_redraw()
return {"RUNNING_MODAL"}
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"""
from bpy_extras.view3d_utils import region_2d_to_origin_3d, region_2d_to_vector_3d
region = context.region
rv3d = context.region_data
coord = (event.mouse_region_x, event.mouse_region_y)
region = self._region
rv3d = self._rv3d
# 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)
direction = region_2d_to_vector_3d(region, rv3d, coord)
@@ -843,12 +877,21 @@ class SAIKEI_OT_pick_pi_from_viewport(Operator):
return None
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
# 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.x = coord[0]
pi.y = coord[1]
pi.x = ifc_coord[0]
pi.y = ifc_coord[1]
# Determine PI type based on position in list
if len(props.pis) == 1:
+149 -4
View File
@@ -286,6 +286,10 @@ class Alignment:
Uses IfcOpenShell's geometry engine to generate vertices, supporting
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:
layout: The IFC layout entity (IfcAlignmentHorizontal, etc.)
parent_obj: The parent Blender object (alignment object)
@@ -295,11 +299,14 @@ class Alignment:
"""
import ifcopenshell.api.alignment as align_api
from ifcopenshell.api.alignment import util as align_util
import ifcopenshell.util.geolocation
import ifcopenshell.util.unit
# Get the layout's curve representation
try:
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
if rep_curve is None:
@@ -308,13 +315,48 @@ class Alignment:
# Generate vertices using IfcOpenShell's geometry engine
try:
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
print(f"[Alignment] generate_vertices failed: {e}")
return None
if len(vertices) < 2:
print(f"[Alignment] Not enough vertices ({len(vertices)}), need at least 2")
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
layout_type = layout.is_a().replace("IfcAlignment", "") # "Horizontal", "Vertical", etc.
name = f"{layout_type}Curve"
@@ -331,6 +373,9 @@ class Alignment:
obj.show_in_front = True
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
if parent_obj:
obj.parent = parent_obj
@@ -378,11 +423,13 @@ class Alignment:
obj.empty_display_type = "PLAIN_AXES"
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:
coords = dp.StartPoint.Coordinates
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
tool.Ifc.link(segment, obj)
@@ -718,3 +765,101 @@ class 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:
s.set("piecewise-step-type", 0) # 0 = step-size is maximum step size, 1 = step-size is mininimum number of steps
except RuntimeError:
pass # Setting not available in older IfcOpenShell versions
s.set("piecewise-step-size", distance_interval)
print("[util.py] piecewise-step-type setting not available, skipping")
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)
vertices = shape.verts
if len(vertices) == 0: