mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-10 01:41:57 +00:00
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:
@@ -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.")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user