From 95952d03f823a9595b8ccf8a06e4f33cb73a67b3 Mon Sep 17 00:00:00 2001 From: DesertSpringsCivil Date: Mon, 2 Feb 2026 10:19:02 -0700 Subject: [PATCH] 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 --- .../bonsai/bim/module/alignment/operator.py | 57 ++++++- src/bonsai/bonsai/tool/alignment.py | 153 +++++++++++++++++- .../ifcopenshell/api/alignment/util.py | 7 +- 3 files changed, 204 insertions(+), 13 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/alignment/operator.py b/src/bonsai/bonsai/bim/module/alignment/operator.py index 1b13dd8541..0a86aa581a 100644 --- a/src/bonsai/bonsai/bim/module/alignment/operator.py +++ b/src/bonsai/bonsai/bim/module/alignment/operator.py @@ -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: diff --git a/src/bonsai/bonsai/tool/alignment.py b/src/bonsai/bonsai/tool/alignment.py index c7fa073ed9..37f649ec7d 100644 --- a/src/bonsai/bonsai/tool/alignment.py +++ b/src/bonsai/bonsai/tool/alignment.py @@ -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]) diff --git a/src/ifcopenshell-python/ifcopenshell/api/alignment/util.py b/src/ifcopenshell-python/ifcopenshell/api/alignment/util.py index bb9578f199..a9e6e3f2e6 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/alignment/util.py +++ b/src/ifcopenshell-python/ifcopenshell/api/alignment/util.py @@ -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: