Add PI Edit Mode for moving alignment points with G key

Implements the ability to edit alignment PI (Point of Intersection)
positions after creation using Blender's standard transform tools:

- Back-calculate PI positions from existing IFC alignment segments
- Create temporary EMPTY objects at PI locations for editing
- Visual feedback via PIEditDecorator (yellow tangent lines, HUD)
- Modal operator handles G key movement, Enter to apply, Escape to cancel
- Regenerates alignment with new PI positions on apply
- Handles edge cases: single-segment, tangent-only, undo during edit

Architecture follows Bonsai patterns:
- Core layer: Business logic orchestration (enter/exit_pi_edit_mode)
- Tool layer: Math, IFC, and Blender implementations
- UI layer: Modal operator with PASS_THROUGH for standard transforms

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
DesertSpringsCivil
2026-02-02 20:26:41 -07:00
parent eea060b72d
commit a3038528bb
7 changed files with 867 additions and 0 deletions
@@ -30,10 +30,23 @@ def on_undo_redo(scene):
- If the active alignment still exists, extracts PI data from IFC segments
- If the alignment was deleted/invalidated, clears the PI Editor
- Rebuilds display_rows to match the synced state
- If in PI edit mode and empties are gone, reset edit mode state
"""
if not hasattr(scene, "SaikeiAlignmentProperties"):
return
props = scene.SaikeiAlignmentProperties
# Handle orphaned PI edit mode state (empties removed by undo)
if props.is_pi_edit_mode:
empties = [obj for obj in bpy.data.objects if obj.get("saikei_is_pi_empty")]
if not empties:
# Empties were undone - reset edit mode state
# The modal operator will detect this and clean up
props.is_pi_edit_mode = False
props.pi_edit_alignment_id = 0
# Uninstall decorator if still active
decorator.PIEditDecorator.uninstall()
# Sync PI Editor from IFC to ensure consistency after undo/redo
operator.sync_pis_from_ifc(props)
@@ -60,6 +73,8 @@ classes = (
# Operators - Stationing
operator.SAIKEI_OT_add_stationing_referent,
operator.SAIKEI_OT_name_segments,
# Operators - PI Edit Mode
operator.SAIKEI_OT_enter_pi_edit_mode,
# UI Panels (appear in Properties sidebar under CIVIL tab)
ui.SAIKEI_PT_alignment_status,
ui.SAIKEI_PT_alignment_creation,
@@ -238,3 +238,166 @@ class PIPickerDecorator:
blf.draw(font_id, line)
blf.disable(font_id, blf.SHADOW)
class PIEditDecorator:
"""Decorator for visualizing PI edit mode.
This decorator provides visual feedback while the user is editing
PI (Point of Intersection) positions with standard Blender transform tools:
- Yellow lines connecting PI empties (tangent preview)
- HUD text showing instructions
The decorator reads positions directly from the PI empty objects,
which are updated by Blender's transform operators (G key).
"""
# Class-level state (cleared on uninstall)
is_installed = False
handlers = []
# References to PI empty objects
pi_empties = []
# Colors (matching PIPickerDecorator)
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_EDIT_MODE_BG = (0.2, 0.4, 0.8, 0.8) # Blue tint for edit mode indicator
# Drawing parameters
LINE_WIDTH = 2.5
@classmethod
def install(cls, context, pi_empties):
"""Install decorator handlers for PI edit mode visualization.
Args:
context: Blender context
pi_empties: List of PI EMPTY objects to visualize
"""
if cls.is_installed:
cls.uninstall()
cls.pi_empties = pi_empties
handler = cls()
# POST_VIEW for 3D world-space drawing (tangent lines in 3D)
cls.handlers.append(
SpaceView3D.draw_handler_add(handler.draw_tangent_lines_3d, (context,), "WINDOW", "POST_VIEW")
)
# POST_PIXEL for 2D screen-space drawing (HUD)
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_empties = []
@classmethod
def update_positions(cls, pi_empties):
"""Update the list of PI empties (called when positions change).
Args:
pi_empties: Updated list of PI EMPTY objects
"""
cls.pi_empties = pi_empties
def draw_batch_3d(self, shader_type, content_pos, color, indices=None):
"""Draw a batch of 3D primitives using GPU shader.
Args:
shader_type: Type of primitive ("LINES", "POINTS", etc.)
content_pos: List of 3D 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 = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR")
shader.bind()
# Get viewport size from active region
region = bpy.context.region
shader.uniform_float("viewportSize", (region.width, region.height))
shader.uniform_float("lineWidth", self.LINE_WIDTH)
batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices)
shader.uniform_float("color", color)
batch.draw(shader)
def draw_tangent_lines_3d(self, context):
"""Draw yellow tangent lines connecting PI empties in 3D space."""
if not self.pi_empties or len(self.pi_empties) < 2:
return
# Collect 3D positions from empties
positions = []
for empty in self.pi_empties:
if empty and empty.name in bpy.data.objects:
positions.append(tuple(empty.location))
if len(positions) < 2:
return
# Setup blending for line drawing
gpu.state.blend_set("ALPHA")
gpu.state.depth_test_set("LESS_EQUAL")
gpu.state.depth_mask_set(False)
# Build edges list
edges = [[i, i + 1] for i in range(len(positions) - 1)]
# Draw lines
self.draw_batch_3d("LINES", positions, self.COLOR_TANGENT_LINE, edges)
# Restore state
gpu.state.blend_set("NONE")
gpu.state.depth_test_set("NONE")
gpu.state.depth_mask_set(True)
def draw_hud(self, context):
"""Draw HUD text with edit mode instructions."""
region = 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
# Count valid empties
valid_count = sum(1 for e in self.pi_empties if e and e.name in bpy.data.objects)
# Instructions
instructions = [
"PI Edit Mode",
f"PIs: {valid_count}",
"",
"G: Move selected PI",
"ENTER: Apply changes",
"ESC: Cancel",
]
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)
@@ -1378,3 +1378,158 @@ class SAIKEI_OT_name_segments(Operator):
return {"FINISHED"}
# =============================================================================
# PI Edit Mode Operator
# =============================================================================
class SAIKEI_OT_enter_pi_edit_mode(Operator):
"""Enter PI editing mode - move PIs with G key, press Enter to apply or Escape to cancel"""
bl_idname = "saikei.enter_pi_edit_mode"
bl_label = "Edit PIs"
bl_description = "Enter PI edit mode. Move PI points with G key. Press Enter to apply changes, Escape to cancel."
bl_options = {"REGISTER", "UNDO"}
# Instance state for modal operation
_pi_empties: list = []
_last_positions: list = []
_area = None
_alignment_id: int = 0
@classmethod
def poll(cls, context):
if not poll_ifc4x3(cls, context):
return False
props = context.scene.SaikeiAlignmentProperties
if props.is_pi_edit_mode:
cls.poll_message_set("Already in PI edit mode")
return False
if props.active_alignment_id == 0:
cls.poll_message_set("No alignment selected")
return False
# Verify alignment still exists
ifc = tool.Ifc.get()
alignment = get_alignment_by_id(ifc, props.active_alignment_id)
if alignment is None:
cls.poll_message_set("Selected alignment no longer exists")
return False
return True
def invoke(self, context, event):
props = context.scene.SaikeiAlignmentProperties
self._alignment_id = props.active_alignment_id
# Enter edit mode via core layer (validates and creates empties)
try:
empties = core.enter_pi_edit_mode(
tool.Ifc, tool.Alignment, self._alignment_id
)
except ValueError as e:
self.report({"ERROR"}, str(e))
return {"CANCELLED"}
if not empties:
self.report({"ERROR"}, "Failed to create PI empties")
return {"CANCELLED"}
# Cache references to empties and their positions
self._pi_empties = empties
self._last_positions = [e.location.copy() for e in empties]
# Find viewport for redraws
self._area = None
for area in context.screen.areas:
if area.type == "VIEW_3D":
self._area = area
break
# Install visual feedback decorator
alignment_decorator.PIEditDecorator.install(context, empties)
# Update UI state
props.is_pi_edit_mode = True
props.pi_edit_alignment_id = self._alignment_id
# Start modal loop
context.window_manager.modal_handler_add(self)
self.report({"INFO"}, "PI Edit Mode: Move PIs with G. Press Enter to apply, Escape to cancel.")
return {"RUNNING_MODAL"}
def modal(self, context, event):
props = context.scene.SaikeiAlignmentProperties
# Safety: check if empties still exist (handles undo edge case)
if not self._empties_still_exist():
self.report({"WARNING"}, "PI Edit Mode cancelled - empties were removed")
return self._cleanup_and_finish(context, apply=False)
# Detect position changes and update decorator
positions_changed = False
for i, empty in enumerate(self._pi_empties):
if empty.location != self._last_positions[i]:
positions_changed = True
self._last_positions[i] = empty.location.copy()
if positions_changed:
# Update decorator to show new tangent lines
alignment_decorator.PIEditDecorator.update_positions(self._pi_empties)
if self._area:
self._area.tag_redraw()
# Handle keyboard input
if event.type == "RET" and event.value == "PRESS":
return self._cleanup_and_finish(context, apply=True)
if event.type == "ESC" and event.value == "PRESS":
return self._cleanup_and_finish(context, apply=False)
# Let all other events pass through (G key, mouse, viewport navigation, etc.)
return {"PASS_THROUGH"}
def _empties_still_exist(self) -> bool:
"""Check if all PI empties still exist in the scene."""
for empty in self._pi_empties:
if empty is None:
return False
if empty.name not in bpy.data.objects:
return False
return True
def _cleanup_and_finish(self, context, apply: bool):
"""Exit edit mode, optionally applying changes."""
props = context.scene.SaikeiAlignmentProperties
try:
if apply:
# Regenerate alignment from new PI positions
core.exit_pi_edit_mode(
tool.Ifc, tool.Alignment, self._alignment_id, apply=True
)
self.report({"INFO"}, "PI changes applied - alignment regenerated")
else:
# Just cleanup without regenerating
core.exit_pi_edit_mode(
tool.Ifc, tool.Alignment, self._alignment_id, apply=False
)
self.report({"INFO"}, "PI Edit Mode cancelled")
except ValueError as e:
self.report({"ERROR"}, str(e))
# Cleanup decorator
alignment_decorator.PIEditDecorator.uninstall()
# Reset UI state
props.is_pi_edit_mode = False
props.pi_edit_alignment_id = 0
# Clear instance state
self._pi_empties = []
self._last_positions = []
if self._area:
self._area.tag_redraw()
return {"FINISHED"}
@@ -235,6 +235,19 @@ class SaikeiAlignmentProperties(PropertyGroup):
default=False,
)
# PI Edit Mode state (for moving PIs with G key)
is_pi_edit_mode: BoolProperty(
name="PI Edit Mode Active",
description="Whether PI edit mode is currently active",
default=False,
)
pi_edit_alignment_id: IntProperty(
name="Editing Alignment ID",
description="IFC ID of alignment being edited in PI edit mode",
default=0,
)
# Display options
show_pi_markers: BoolProperty(
name="Show PI Markers",
@@ -231,6 +231,25 @@ class SAIKEI_PT_pi_editor(Panel):
layout = self.layout
props = context.scene.SaikeiAlignmentProperties
# PI Edit Mode indicator
if props.is_pi_edit_mode:
box = layout.box()
box.alert = True
box.label(text="PI Edit Mode Active", icon="EDITMODE_HLT")
col = box.column(align=True)
col.label(text="Move PIs with G key")
col.label(text="Press Enter to apply")
col.label(text="Press Escape to cancel")
layout.separator()
return # Don't show normal UI while in edit mode
# Edit existing alignment button
if props.active_alignment_id != 0:
box = layout.box()
box.label(text="Edit Alignment:", icon="EDITMODE_HLT")
box.operator("saikei.enter_pi_edit_mode", icon="PIVOT_CURSOR", text="Edit PIs (G key)")
layout.separator()
# Header row with column labels
header = layout.row(align=True)
header.label(text="No.")
+166
View File
@@ -127,3 +127,169 @@ def create_layout_segment_objects(
List of created Blender objects (curve + segment empties)
"""
return alignment_tool.create_objects_for_layout_segments(layout, layout_obj)
# =============================================================================
# PI Edit Mode Functions
# =============================================================================
def enter_pi_edit_mode(
ifc_tool: "type[tool.Ifc]",
alignment_tool: "type[tool.Alignment]",
alignment_id: int,
) -> list:
"""Enter PI edit mode for an alignment.
Business logic for entering PI edit mode:
1. Validates that the alignment exists
2. Validates that the alignment has a horizontal layout with real segments
3. Back-calculates PI positions from segments
4. Creates temporary EMPTY objects at each PI location
Args:
ifc_tool: The IFC tool class
alignment_tool: The Alignment tool class
alignment_id: The IFC ID of the alignment to edit
Returns:
List of created PI EMPTY objects
Raises:
ValueError: If alignment doesn't exist, has no horizontal layout,
or has no real segments
"""
import ifcopenshell.api.alignment as align_api
# Validate alignment exists
ifc_file = ifc_tool.get()
if ifc_file is None:
raise ValueError("No IFC file loaded")
try:
alignment = ifc_file.by_id(alignment_id)
except RuntimeError:
raise ValueError(f"Alignment with ID {alignment_id} not found")
if not alignment.is_a("IfcAlignment"):
raise ValueError(f"Entity {alignment_id} is not an IfcAlignment")
# Validate alignment has horizontal layout
h_layout = align_api.get_horizontal_layout(alignment)
if h_layout is None:
raise ValueError(f"Alignment '{alignment.Name}' has no horizontal layout")
# Validate layout has real segments (not just zero-length terminator)
if not alignment_tool.layout_has_real_segments(h_layout):
raise ValueError(f"Alignment '{alignment.Name}' has no editable segments")
# Back-calculate PI positions from segments
pis = alignment_tool.back_calculate_pis_from_alignment(alignment)
if len(pis) < 2:
raise ValueError(f"Alignment '{alignment.Name}' must have at least 2 PIs")
# Create temporary EMPTY objects at each PI location
empties = alignment_tool.create_pi_edit_empties(alignment, pis)
return empties
def exit_pi_edit_mode(
ifc_tool: "type[tool.Ifc]",
alignment_tool: "type[tool.Alignment]",
alignment_id: int,
apply: bool,
) -> bool:
"""Exit PI edit mode for an alignment.
Business logic for exiting PI edit mode:
1. If apply=True:
- Collect new PI positions from empties
- Validate the new configuration
- Regenerate alignment segments
2. Always:
- Remove temporary EMPTY objects
- Return success status
Args:
ifc_tool: The IFC tool class
alignment_tool: The Alignment tool class
alignment_id: The IFC ID of the alignment being edited
apply: If True, regenerate alignment with new PI positions
Returns:
True if successful
Raises:
ValueError: If alignment doesn't exist or regeneration fails
"""
import ifcopenshell
import ifcopenshell.api.alignment as align_api
ifc_file = ifc_tool.get()
if ifc_file is None:
# No file loaded, just clean up empties
alignment_tool.remove_pi_edit_empties(alignment_id)
return True
# Get alignment
try:
alignment = ifc_file.by_id(alignment_id)
except RuntimeError:
# Alignment was deleted, just clean up empties
alignment_tool.remove_pi_edit_empties(alignment_id)
return True
if apply:
# Collect PI positions from empties
hpoints, radii = alignment_tool.collect_pis_from_empties(alignment_id)
if len(hpoints) < 2:
raise ValueError("At least 2 PIs are required")
# Get alignment metadata for recreation
alignment_name = alignment.Name or "Alignment"
# Get start station from existing alignment (or default)
start_station = 0.0
try:
h_layout = align_api.get_horizontal_layout(alignment)
if h_layout:
# Try to get existing start station from referent
for rel in getattr(alignment, "IsNestedBy", []) or []:
for child in rel.RelatedObjects or []:
if child.is_a("IfcReferent"):
pos_el = child.ObjectPlacement
if pos_el and hasattr(pos_el, "PlacementRelTo"):
# Extract station value if available
pass
except Exception:
pass # Use default start_station
# Remove empties BEFORE deleting alignment (they're parented to it)
alignment_tool.remove_pi_edit_empties(alignment_id)
# Remove old alignment hierarchy from Blender
alignment_tool.remove_alignment_hierarchy(alignment)
# Delete old IFC alignment
ifcopenshell.api.run("root.remove_product", ifc_file, product=alignment)
# Create new alignment with updated PI positions
new_alignment = alignment_tool.safe_create_alignment_by_pi_method(
ifc_file,
name=alignment_name,
hpoints=hpoints,
radii=radii,
start_station=start_station,
)
# Create new Blender hierarchy
alignment_tool.create_hierarchy_for_alignment(new_alignment)
return True
else:
# Cancel - just remove empties without regenerating
alignment_tool.remove_pi_edit_empties(alignment_id)
return True
+336
View File
@@ -1043,3 +1043,339 @@ class Alignment:
)
return (local_matrix[0, 3], local_matrix[1, 3], local_matrix[2, 3])
# =========================================================================
# PI Edit Mode Methods
# =========================================================================
# These methods support the PI Edit Mode feature, which allows users to
# move alignment PIs (Points of Intersection) using Blender's standard
# transform tools (G key). The workflow is:
# 1. Back-calculate PI positions from existing IFC segments
# 2. Create temporary EMPTY objects at each PI location
# 3. User moves empties with standard Blender tools
# 4. Collect new positions and regenerate alignment segments
@classmethod
def back_calculate_pis_from_alignment(
cls, alignment: "ifcopenshell.entity_instance"
) -> List[dict]:
"""Reverse-engineer PI positions from IFC alignment segments.
This function analyzes the alignment's horizontal segments and
reconstructs the original PI (Point of Intersection) positions
that were used to create the alignment.
Algorithm:
1. Get horizontal layout and segments
2. First PI = start point of first segment
3. For each CIRCULARARC segment:
- Extract BC (begin curve) from StartPoint
- Calculate deflection: Δ = arc_length / radius
- Calculate tangent length: T = R × tan(Δ/2)
- PI position = BC + T × direction_vector
- Store radius
4. For LINE-only transitions (radius=0):
- PI = endpoint of LINE segment (becomes a tangent PI)
5. Last PI = end point of last real segment
Args:
alignment: The IfcAlignment entity
Returns:
List of dicts, each containing:
- "x": float - X coordinate in IFC space
- "y": float - Y coordinate in IFC space
- "radius": float - Curve radius (0 for endpoints/tangent PIs)
- "type": str - "ENDPOINT", "CURVE", or "TANGENT"
Raises:
ValueError: If alignment has no horizontal layout or segments
"""
import ifcopenshell.api.alignment as align_api
# Get horizontal layout
h_layout = align_api.get_horizontal_layout(alignment)
if h_layout is None:
raise ValueError(f"Alignment #{alignment.id()} has no horizontal layout")
# Get all segments
segments = []
for rel in getattr(h_layout, "IsNestedBy", []) or []:
for segment in rel.RelatedObjects or []:
if segment.is_a("IfcAlignmentSegment"):
segments.append(segment)
if not segments:
raise ValueError(f"Alignment #{alignment.id()} has no segments")
# Filter out zero-length terminator segments
real_segments = []
for seg in segments:
if not cls.is_zero_length_segment(seg):
real_segments.append(seg)
if not real_segments:
raise ValueError(f"Alignment #{alignment.id()} has no real segments (only terminator)")
pis = []
# First PI: start point of first segment
first_dp = real_segments[0].DesignParameters
first_x = float(first_dp.StartPoint.Coordinates[0])
first_y = float(first_dp.StartPoint.Coordinates[1])
pis.append({
"x": first_x,
"y": first_y,
"radius": 0.0,
"type": "ENDPOINT"
})
# Track current position and direction for LINE segments
# This helps us identify tangent PIs (where LINE meets LINE)
prev_seg_type = first_dp.PredefinedType
# Process each segment
for i, seg in enumerate(real_segments):
dp = seg.DesignParameters
seg_type = dp.PredefinedType
if seg_type == "CIRCULARARC":
# Reconstruct PI from arc segment
bc_x = float(dp.StartPoint.Coordinates[0])
bc_y = float(dp.StartPoint.Coordinates[1])
angle_in = float(dp.StartDirection)
radius = abs(float(dp.StartRadiusOfCurvature))
arc_length = float(dp.SegmentLength)
# Deflection angle: Δ = L / R
deflection = arc_length / radius
# Tangent length: T = R × tan(Δ/2)
tangent_length = radius * math.tan(deflection / 2)
# PI position: BC + T × direction_vector
pi_x = bc_x + tangent_length * math.cos(angle_in)
pi_y = bc_y + tangent_length * math.sin(angle_in)
pis.append({
"x": pi_x,
"y": pi_y,
"radius": radius,
"type": "CURVE"
})
elif seg_type == "LINE":
# For LINE segments, check if this is a transition point
# If the previous segment was also LINE and this isn't the first,
# we may have a tangent PI at the connection point
if i > 0 and prev_seg_type == "LINE":
# There's a tangent PI at the start of this LINE
# (end of previous LINE)
start_x = float(dp.StartPoint.Coordinates[0])
start_y = float(dp.StartPoint.Coordinates[1])
pis.append({
"x": start_x,
"y": start_y,
"radius": 0.0,
"type": "TANGENT"
})
prev_seg_type = seg_type
# Last PI: end point of last segment
last_dp = real_segments[-1].DesignParameters
last_seg_type = last_dp.PredefinedType
last_length = float(last_dp.SegmentLength)
last_direction = float(last_dp.StartDirection)
last_start_x = float(last_dp.StartPoint.Coordinates[0])
last_start_y = float(last_dp.StartPoint.Coordinates[1])
if last_seg_type == "LINE":
# End of LINE: simple projection
end_x = last_start_x + last_length * math.cos(last_direction)
end_y = last_start_y + last_length * math.sin(last_direction)
elif last_seg_type == "CIRCULARARC":
# End of ARC: use geometry engine or calculate
last_radius = abs(float(last_dp.StartRadiusOfCurvature))
deflection = last_length / last_radius
# Determine curve direction (positive radius = counterclockwise)
is_ccw = float(last_dp.StartRadiusOfCurvature) > 0
if is_ccw:
end_direction = last_direction + deflection
else:
end_direction = last_direction - deflection
# Calculate EC (end curve) position
# For an arc, EC is at BC + arc travel
# We need to use the center calculation
center_offset_angle = last_direction + (math.pi / 2 if is_ccw else -math.pi / 2)
center_x = last_start_x + last_radius * math.cos(center_offset_angle)
center_y = last_start_y + last_radius * math.sin(center_offset_angle)
# EC is at the end of the arc
ec_angle = center_offset_angle + math.pi + (deflection if is_ccw else -deflection)
end_x = center_x + last_radius * math.cos(ec_angle)
end_y = center_y + last_radius * math.sin(ec_angle)
else:
# For other segment types (CLOTHOID, etc.), use start point as fallback
# TODO: Support spiral transitions
end_x = last_start_x
end_y = last_start_y
pis.append({
"x": end_x,
"y": end_y,
"radius": 0.0,
"type": "ENDPOINT"
})
return pis
@classmethod
def create_pi_edit_empties(
cls,
alignment: "ifcopenshell.entity_instance",
pis: List[dict],
) -> List[bpy.types.Object]:
"""Create EMPTY objects at PI locations for editing.
Creates temporary Blender EMPTY objects at each PI position,
allowing users to move them with standard Blender tools (G key).
The empties are:
- Parented to the alignment object
- Tagged with custom properties for identification
- Named sequentially (PI.001, PI.002, etc.)
Args:
alignment: The IfcAlignment entity
pis: List of PI dicts from back_calculate_pis_from_alignment()
Returns:
List of created Blender EMPTY objects, sorted by index
"""
alignment_obj = tool.Ifc.get_object(alignment)
if alignment_obj is None:
return []
# Get the collection to add objects to
collection = None
if alignment_obj.users_collection:
collection = alignment_obj.users_collection[0]
else:
collection = bpy.context.scene.collection
alignment_id = alignment.id()
empties = []
for i, pi in enumerate(pis):
# Convert IFC coordinates to Blender coordinates
blender_pos = cls.ifc_to_blender_coordinates(pi["x"], pi["y"], 0.0)
# Create EMPTY object
name = f"PI.{i + 1:03d}"
empty = bpy.data.objects.new(name, None)
empty.empty_display_type = "SPHERE"
empty.empty_display_size = 2.0
empty.location = blender_pos
# Tag with custom properties for identification
empty["saikei_is_pi_empty"] = True
empty["saikei_pi_index"] = i
empty["saikei_pi_radius"] = pi["radius"]
empty["saikei_alignment_id"] = alignment_id
empty["saikei_pi_type"] = pi["type"]
# Parent to alignment object
empty.parent = alignment_obj
# Link to collection
collection.objects.link(empty)
empties.append(empty)
return empties
@classmethod
def get_pi_edit_empties(cls, alignment_id: int) -> List[bpy.types.Object]:
"""Find all PI EMPTY objects for a given alignment.
Searches all objects in the scene for empties tagged with
the PI edit mode custom properties.
Args:
alignment_id: The IFC ID of the alignment being edited
Returns:
List of PI EMPTY objects, sorted by pi_index
"""
empties = []
for obj in bpy.data.objects:
if obj.get("saikei_is_pi_empty") and obj.get("saikei_alignment_id") == alignment_id:
empties.append(obj)
# Sort by PI index
empties.sort(key=lambda e: e.get("saikei_pi_index", 0))
return empties
@classmethod
def remove_pi_edit_empties(cls, alignment_id: int) -> int:
"""Remove all PI EMPTY objects for a given alignment.
Args:
alignment_id: The IFC ID of the alignment being edited
Returns:
Number of objects removed
"""
empties = cls.get_pi_edit_empties(alignment_id)
removed_count = 0
for empty in empties:
bpy.data.objects.remove(empty, do_unlink=True)
removed_count += 1
return removed_count
@classmethod
def collect_pis_from_empties(
cls, alignment_id: int
) -> Tuple[List[Tuple[float, float]], List[float]]:
"""Gather current PI positions from EMPTY objects.
Reads the current positions of PI empties and converts them
back to IFC coordinates for regenerating the alignment.
Args:
alignment_id: The IFC ID of the alignment being edited
Returns:
Tuple of:
- hpoints: List of (x, y) tuples in IFC coordinates
- radii: List of radii for interior PIs only (not first/last)
"""
empties = cls.get_pi_edit_empties(alignment_id)
if len(empties) < 2:
return ([], [])
hpoints = []
radii = []
for i, empty in enumerate(empties):
# Convert Blender position to IFC coordinates
blender_pos = empty.location
ifc_pos = cls.blender_to_ifc_coordinates(
blender_pos.x, blender_pos.y, blender_pos.z
)
hpoints.append((ifc_pos[0], ifc_pos[1]))
# Collect radii for interior PIs only (not first or last)
if 0 < i < len(empties) - 1:
radius = empty.get("saikei_pi_radius", 0.0)
radii.append(radius)
return (hpoints, radii)