mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-17 22:11:36 +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 active alignment still exists, extracts PI data from IFC segments
|
||||||
- If the alignment was deleted/invalidated, clears the PI Editor
|
- If the alignment was deleted/invalidated, clears the PI Editor
|
||||||
- Rebuilds display_rows to match the synced state
|
- 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"):
|
if not hasattr(scene, "SaikeiAlignmentProperties"):
|
||||||
return
|
return
|
||||||
props = scene.SaikeiAlignmentProperties
|
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
|
# Sync PI Editor from IFC to ensure consistency after undo/redo
|
||||||
operator.sync_pis_from_ifc(props)
|
operator.sync_pis_from_ifc(props)
|
||||||
|
|
||||||
@@ -60,6 +73,8 @@ classes = (
|
|||||||
# Operators - Stationing
|
# Operators - Stationing
|
||||||
operator.SAIKEI_OT_add_stationing_referent,
|
operator.SAIKEI_OT_add_stationing_referent,
|
||||||
operator.SAIKEI_OT_name_segments,
|
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 Panels (appear in Properties sidebar under CIVIL tab)
|
||||||
ui.SAIKEI_PT_alignment_status,
|
ui.SAIKEI_PT_alignment_status,
|
||||||
ui.SAIKEI_PT_alignment_creation,
|
ui.SAIKEI_PT_alignment_creation,
|
||||||
|
|||||||
@@ -238,3 +238,166 @@ class PIPickerDecorator:
|
|||||||
blf.draw(font_id, line)
|
blf.draw(font_id, line)
|
||||||
|
|
||||||
blf.disable(font_id, blf.SHADOW)
|
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"}
|
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,
|
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
|
# Display options
|
||||||
show_pi_markers: BoolProperty(
|
show_pi_markers: BoolProperty(
|
||||||
name="Show PI Markers",
|
name="Show PI Markers",
|
||||||
|
|||||||
@@ -231,6 +231,25 @@ class SAIKEI_PT_pi_editor(Panel):
|
|||||||
layout = self.layout
|
layout = self.layout
|
||||||
props = context.scene.SaikeiAlignmentProperties
|
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 row with column labels
|
||||||
header = layout.row(align=True)
|
header = layout.row(align=True)
|
||||||
header.label(text="No.")
|
header.label(text="No.")
|
||||||
|
|||||||
@@ -127,3 +127,169 @@ def create_layout_segment_objects(
|
|||||||
List of created Blender objects (curve + segment empties)
|
List of created Blender objects (curve + segment empties)
|
||||||
"""
|
"""
|
||||||
return alignment_tool.create_objects_for_layout_segments(layout, layout_obj)
|
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])
|
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