From b8d8dcf6baecf62924f9ccf9edb607ce95ad751a Mon Sep 17 00:00:00 2001
From: Richard Brice <37087370+RickBrice@users.noreply.github.com>
Date: Sun, 13 Sep 2026 11:25:54 -0700
Subject: [PATCH] Removed CIVIL prototyping
---
src/bonsai/bonsai/bim/__init__.py | 3 +-
.../bonsai/bim/module/alignment/__init__.py | 31 +-
.../bonsai/bim/module/alignment/decorator.py | 166 +--
.../bonsai/bim/module/alignment/operator.py | 1043 +----------------
.../alignment/ops.authoring.alignment.dat | Bin 836 -> 0 bytes
.../bonsai/bim/module/alignment/prop.py | 172 ---
src/bonsai/bonsai/bim/module/alignment/ui.py | 243 +---
.../bonsai/bim/module/alignment/workspace.py | 72 --
src/bonsai/bonsai/bim/module/root/data.py | 5 -
src/bonsai/bonsai/bim/module/root/operator.py | 23 -
src/bonsai/bonsai/bim/prop.py | 1 -
src/bonsai/bonsai/bim/ui.py | 20 -
src/bonsai/bonsai/core/alignment.py | 145 ---
src/bonsai/bonsai/core/tool.py | 7 -
src/bonsai/bonsai/tool/alignment.py | 639 +---------
src/bonsai/bonsai/tool/root.py | 4 -
src/bonsai/pytest.ini | 1 -
.../alignment/test_alignment_operators.py | 580 +--------
src/bonsai/test/core/test_alignment.py | 175 ---
src/bonsai/test/tool/test_alignment.py | 565 +--------
20 files changed, 47 insertions(+), 3848 deletions(-)
delete mode 100644 src/bonsai/bonsai/bim/module/alignment/ops.authoring.alignment.dat
delete mode 100644 src/bonsai/bonsai/bim/module/alignment/workspace.py
diff --git a/src/bonsai/bonsai/bim/__init__.py b/src/bonsai/bonsai/bim/__init__.py
index e854dcfe11..1ec96c9532 100644
--- a/src/bonsai/bonsai/bim/__init__.py
+++ b/src/bonsai/bonsai/bim/__init__.py
@@ -184,8 +184,7 @@ classes = [
ui.BIM_PT_tab_materials,
ui.BIM_PT_tab_styles,
ui.BIM_PT_tab_profiles,
- # Civil infrastructure
- ui.BIM_PT_tab_horizontal_alignment,
+ # Alignments
ui.BIM_PT_tab_alignments,
# Drawings and documents
ui.BIM_PT_tab_sheets,
diff --git a/src/bonsai/bonsai/bim/module/alignment/__init__.py b/src/bonsai/bonsai/bim/module/alignment/__init__.py
index 82fc390380..bf40b000be 100644
--- a/src/bonsai/bonsai/bim/module/alignment/__init__.py
+++ b/src/bonsai/bonsai/bim/module/alignment/__init__.py
@@ -17,7 +17,7 @@
# along with Bonsai. If not, see .
import bpy
-from . import ui, prop, operator, decorator, workspace
+from . import ui, prop, operator, decorator
_last_active_ptr: int = 0
_last_profile_alignment_id: int = 0 # tracks which alignment the profile was last built for
@@ -105,38 +105,21 @@ def _on_active_object_changed(scene, depsgraph):
classes = (
# Property groups (must be registered before classes that use them)
- prop.AlignmentPI,
- prop.AlignmentDisplayRow,
prop.VerticalAlignmentItem,
prop.CantAlignmentItem,
prop.CivilAlignmentProperties,
prop.PICurveMarkerProperties,
# UILists and section-toggle operators
- ui.ALIGN_UL_alignment_pis,
ui.ALIGN_OT_toggle_h_segments,
ui.ALIGN_OT_toggle_v_segments,
ui.ALIGN_OT_toggle_cant_segments,
operator.ImportAlignmentCSV,
- # Operators - PI Management
- operator.ALIGN_OT_add_pi,
- operator.ALIGN_OT_remove_pi,
- operator.ALIGN_OT_pick_pi_from_viewport,
- operator.ALIGN_OT_recalculate_pis,
- operator.ALIGN_OT_clear_pis,
- # Operators - Creation
- operator.ALIGN_OT_create_alignment_by_pis,
- operator.ALIGN_OT_create_alignment_by_pi,
- # Operators - Stationing
- operator.ALIGN_OT_add_stationing_referent,
- operator.ALIGN_OT_name_segments,
# Operators - Vertical Profile Window
operator.ALIGN_OT_show_vertical_profile,
# Operators - Segment Selection
operator.ALIGN_OT_select_h_segment,
operator.ALIGN_OT_select_v_segment,
operator.ALIGN_OT_select_cant_segment,
- # Operators - PI Edit Mode
- operator.ALIGN_OT_enter_pi_edit_mode,
# Operators - Alignments tab authoring workflow (Add Element + interactive draw)
operator.ALIGN_OT_add_alignment,
operator.ALIGN_OT_remove_alignment,
@@ -147,10 +130,6 @@ classes = (
operator.ALIGN_OT_apply_pi_curve,
operator.ALIGN_OT_clear_pi_markers,
operator.ALIGN_OT_draw_horizontal_alignment,
- # UI Panels (appear in Properties sidebar under CIVIL tab)
- ui.ALIGN_PT_alignment_creation,
- ui.ALIGN_PT_pi_editor,
- ui.ALIGN_PT_alignment_stationing,
# UI Panels (appear in Properties sidebar under ALIGNMENTS tab)
ui.ALIGN_PT_alignment_authoring,
ui.ALIGN_PT_alignment_stationing_authoring,
@@ -163,12 +142,6 @@ def menu_func_import(self, context):
def register():
- if not bpy.app.background:
- bpy.utils.register_tool(
- workspace.AlignmentTool,
- separator=True,
- group=False,
- )
bpy.types.Scene.CivilAlignmentProperties = bpy.props.PointerProperty(type=prop.CivilAlignmentProperties)
bpy.types.Object.bonsai_pi_curve_marker = bpy.props.PointerProperty(type=prop.PICurveMarkerProperties)
bpy.types.TOPBAR_MT_file_import.append(menu_func_import)
@@ -195,8 +168,6 @@ def unregister():
pass
VerticalProfileDecorator.is_installed = False
VerticalProfileDecorator.handlers = []
- if not bpy.app.background:
- bpy.utils.unregister_tool(workspace.AlignmentTool)
bpy.types.TOPBAR_MT_file_import.remove(menu_func_import)
del bpy.types.Scene.CivilAlignmentProperties
del bpy.types.Object.bonsai_pi_curve_marker
diff --git a/src/bonsai/bonsai/bim/module/alignment/decorator.py b/src/bonsai/bonsai/bim/module/alignment/decorator.py
index d8ecda84de..511c253a9d 100644
--- a/src/bonsai/bonsai/bim/module/alignment/decorator.py
+++ b/src/bonsai/bonsai/bim/module/alignment/decorator.py
@@ -19,7 +19,8 @@
"""Alignment module decorators for GPU visualization.
This module contains decorators for rendering visual feedback during
-alignment-related operations, such as PI editing.
+alignment-related operations, such as segment highlighting and the
+vertical profile view.
"""
import bpy
@@ -38,169 +39,6 @@ from bpy_extras.view3d_utils import location_3d_to_region_2d, region_2d_to_locat
from gpu_extras.batch import batch_for_shader
-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
- 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)
-
-
class AlignmentSegmentDecorator:
"""Decorator that highlights a selected horizontal alignment segment in the 3D viewport.
diff --git a/src/bonsai/bonsai/bim/module/alignment/operator.py b/src/bonsai/bonsai/bim/module/alignment/operator.py
index 34fa04d161..9b915b4ca7 100644
--- a/src/bonsai/bonsai/bim/module/alignment/operator.py
+++ b/src/bonsai/bonsai/bim/module/alignment/operator.py
@@ -53,12 +53,18 @@ class ImportAlignmentCSV(bpy.types.Operator, tool.Ifc.Operator, ImportHelper):
def _execute(self, context):
start = time.time()
- props = context.scene.CivilAlignmentProperties
alignment = core.import_alignment_csv(tool.Ifc, tool.Alignment, filepath=self.filepath)
- props.active_alignment_name = alignment.Name or "Imported Alignment"
- props.active_alignment_id = alignment.id()
+ # Make the imported alignment the active/selected object so it's
+ # immediately picked up by tool.Alignment.get_active_alignment() —
+ # same convention as ALIGN_OT_add_alignment/ALIGN_OT_draw_horizontal_alignment.
+ alignment_obj = tool.Ifc.get_object(alignment)
+ if alignment_obj:
+ for obj in context.selected_objects:
+ obj.select_set(False)
+ alignment_obj.select_set(True)
+ context.view_layer.objects.active = alignment_obj
self.report({"INFO"}, "Imported in %s seconds" % (time.time() - start))
@@ -75,335 +81,6 @@ def poll_ifc4x3(cls, context):
return True
-def _resolve_active_alignment(context):
- """Return the IfcAlignment for ``props.active_alignment_id``, or None.
-
- Operators that act on an existing alignment store it as
- ``active_alignment_id`` (set on create/visualize) and their ``_execute``
- uses that id — so their ``poll`` must resolve the alignment the same way,
- NOT via the active viewport object (which is typically a segment curve
- after PI/curve editing).
- """
- props = context.scene.CivilAlignmentProperties
- if props.active_alignment_id == 0:
- return None
- ifc_file = tool.Ifc.get()
- if ifc_file is None:
- return None
- try:
- alignment = ifc_file.by_id(props.active_alignment_id)
- except RuntimeError:
- return None
- return alignment if alignment.is_a("IfcAlignment") else None
-
-
-def sync_pis_from_ifc(props):
- """Sync PI Editor data from IFC alignment.
-
- This is called on undo/redo to ensure the PI Editor reflects the current
- IFC state. It extracts PI data from the alignment's horizontal segments.
-
- If no active alignment exists or it's invalid, clears the PI Editor.
-
- Returns:
- bool: True if sync was successful, False if alignment was cleared.
- """
- ifc = tool.Ifc.get()
- if ifc is None:
- # No IFC file - clear everything
- props.pis.clear()
- props.active_pi_index = 0
- rebuild_display_rows(props)
- return False
-
- alignment = tool.Alignment.get_active_alignment()
- if not alignment:
- # Alignment no longer exists - clear everything
- props.pis.clear()
- props.active_pi_index = 0
- rebuild_display_rows(props)
- return False
-
- # Alignment exists - extract PI data from IFC segments
- h_layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
- if not h_layout:
- # No horizontal layout - rebuild display with current props
- rebuild_display_rows(props)
- return True
-
- segments = ifcopenshell.api.alignment.get_layout_segments(h_layout)
- if not segments:
- # No segments - rebuild display with current props
- rebuild_display_rows(props)
- return True
-
- # Extract PIs from segment data
- # This reconstructs approximate PIs from the IFC segment geometry
- extracted_pis = tool.Alignment.extract_pis_from_segments(segments)
-
- if not extracted_pis:
- # Couldn't extract - keep current props.pis
- rebuild_display_rows(props)
- return True
-
- # Update props.pis with extracted data
- props.pis.clear()
- # pi.radius is a Blender LENGTH property (metres); the extracted radius is in
- # project units, so scale it so the table displays the correct value.
- unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
- for pi_data in extracted_pis:
- pi = props.pis.add()
- pi.e = str(pi_data["e"])
- pi.n = str(pi_data["n"])
- pi.pi_type = pi_data["pi_type"]
- pi.radius = pi_data.get("radius", 0.0) * unit_scale
-
- props.active_pi_index = 0
-
- # Recalculate geometry and rebuild display
- recalculate_pi_geometry(props)
- return True
-
-
-def on_radius_changed(pi, context):
- """Callback when PI radius is changed. Triggers geometry recalculation.
-
- This is called from the AlignmentPI.radius property's update callback.
- When a radius is entered on a Mid point, this triggers:
- 1. Recalculation of PI geometry (lengths, stations)
- 2. Rebuild of display_rows (Mid point becomes Curve segment)
- 3. If an active alignment exists, regeneration of IFC entities
- """
- props = context.scene.CivilAlignmentProperties
- recalculate_pi_geometry(props)
-
- # If there's an active alignment, trigger IFC regeneration
- # This is handled by recalculate_pi_geometry when active_alignment_id is set
-
-
-def recalculate_pi_geometry(props):
- """Recalculate lengths and stations for all PIs using tool layer."""
- pis = props.pis
- if len(pis) < 2:
- rebuild_display_rows(props)
- return
-
- # Extract PI coordinates for calculation
- pi_coords = [(float(pi.e), float(pi.n)) for pi in pis]
-
- # Use tool layer for calculation (math belongs in tool, not core)
- result = tool.Alignment.calculate_pi_geometry(pi_coords, props.start_station)
-
- # Update Blender properties with results
- tool.Alignment.update_pi_properties(props, result)
-
- # Rebuild the display rows for the interleaved table view
- rebuild_display_rows(props)
-
-
-def rebuild_display_rows(props):
- """Rebuild the display_rows collection from the pis collection.
-
- Creates an interleaved view of points and segments in Civil 3D style:
- End point (POB)
- Tangent segment 1
- Mid point (or Curve segment if radius > 0)
- Tangent segment 2
- End point (POE)
-
- When a Mid point has a curve (radius > 0), it becomes a Curve segment row
- instead of a point row, showing PI coordinates + arc length + radius.
- """
- props.display_rows.clear()
-
- pis = props.pis
- if len(pis) == 0:
- return
-
- segment_num = 0
- i = 0
-
- # Pre-compute coordinate tuples for tool method calls
- pi_coords = [(float(pi.e), float(pi.n)) for pi in pis]
-
- while i < len(pis):
- pi = pis[i]
- is_interior = i > 0 and i < len(pis) - 1
- has_curve = is_interior and pi.radius > 0
-
- if has_curve:
- # Interior PI with curve: becomes a CURVE SEGMENT row
- segment_num += 1
- curve_row = props.display_rows.add()
- curve_row.row_type = "SEGMENT"
- curve_row.segment_number = segment_num
- curve_row.pi_index = i
- curve_row.display_type = "Curve"
- curve_row.e = pi.e
- curve_row.n = pi.n
- curve_row.radius = pi.radius
- curve_row.arc_length = tool.Alignment.arc_length_at_pi(
- pi_coords[i - 1], pi_coords[i], pi_coords[i + 1], pi.radius
- )
- else:
- # Regular point row (End or Mid without curve)
- point_row = props.display_rows.add()
- point_row.row_type = "POINT"
- point_row.pi_index = i
-
- if pi.pi_type == "ENDPOINT":
- point_row.display_type = "End"
- else:
- point_row.display_type = "Mid"
-
- point_row.e = pi.e
- point_row.n = pi.n
-
- # Add tangent segment row after this point/curve (except after last PI)
- if i < len(pis) - 1:
- segment_num += 1
- seg_row = props.display_rows.add()
- seg_row.row_type = "SEGMENT"
- seg_row.segment_number = segment_num
- seg_row.pi_index = i
- seg_row.display_type = "Tan"
-
- # Compute tangent lengths at each end to subtract from full distance
- start_t = 0.0
- end_t = 0.0
- if has_curve:
- start_t = tool.Alignment.tangent_length_at_pi(
- pi_coords[i - 1], pi_coords[i], pi_coords[i + 1], pi.radius
- )
- next_pi = pis[i + 1]
- next_is_interior = (i + 1 > 0) and (i + 1 < len(pis) - 1)
- next_has_curve = next_is_interior and next_pi.radius > 0
- if next_has_curve:
- end_t = tool.Alignment.tangent_length_at_pi(
- pi_coords[i], pi_coords[i + 1], pi_coords[i + 2], next_pi.radius
- )
-
- seg_row.length = tool.Alignment.tangent_segment_length(
- pi_coords[i], pi_coords[i + 1], start_t, end_t
- )
-
- i += 1
-
-
-# =============================================================================
-# PI Management Operators
-# =============================================================================
-
-
-class ALIGN_OT_add_pi(Operator):
- """Add a new PI point to the list"""
-
- bl_idname = "align.add_pi"
- bl_label = "Add PI"
- bl_description = "Add a new PI (Point of Intersection) to the alignment"
- bl_options = {"REGISTER", "UNDO"}
-
- @classmethod
- def poll(cls, context):
- return poll_ifc4x3(cls, context)
-
- def execute(self, context):
- props = context.scene.CivilAlignmentProperties
-
- # Add new PI
- pi = props.pis.add()
-
- # Set default position based on existing PIs
- if len(props.pis) == 1:
- # First PI - start at origin
- pi.e = str(0.0)
- pi.n = str(0.0)
- pi.pi_type = "ENDPOINT"
- elif len(props.pis) == 2:
- # Second PI - offset from first
- prev = props.pis[0]
- pi.e = str(float(prev.e) + 100.0)
- pi.n = prev.n
- pi.pi_type = "ENDPOINT"
- else:
- # Additional PIs - extrapolate from last two
- prev = props.pis[-2]
- prev_prev = props.pis[-3] if len(props.pis) > 2 else prev
- de = float(prev.e) - float(prev_prev.e) if len(props.pis) > 2 else 100.0
- dn = float(prev.n) - float(prev_prev.n) if len(props.pis) > 2 else 0.0
- pi.e = str(float(prev.e) + de)
- pi.n = str(float(prev.n) + dn)
- pi.pi_type = "TANGENT"
-
- # Previous endpoint becomes tangent or curve
- props.pis[-2].pi_type = "TANGENT"
-
- # Make new PI active
- props.active_pi_index = len(props.pis) - 1
-
- # Recalculate geometry
- recalculate_pi_geometry(props)
-
- return {"FINISHED"}
-
-
-class ALIGN_OT_remove_pi(Operator):
- """Remove the selected PI point"""
-
- bl_idname = "align.remove_pi"
- bl_label = "Remove PI"
- bl_description = "Remove the selected PI from the alignment"
- bl_options = {"REGISTER", "UNDO"}
-
- @classmethod
- def poll(cls, context):
- if not poll_ifc4x3(cls, context):
- return False
- props = context.scene.CivilAlignmentProperties
- if len(props.pis) == 0:
- cls.poll_message_set("No PIs to remove")
- return False
- # Check if a POINT row is selected (can't remove from SEGMENT row selection)
- if props.display_rows:
- idx = props.active_display_row_index
- if 0 <= idx < len(props.display_rows):
- if props.display_rows[idx].row_type != "POINT":
- cls.poll_message_set("Select a point row to remove")
- return False
- return True
-
- def execute(self, context):
- props = context.scene.CivilAlignmentProperties
-
- # Get the PI index from the selected display row
- pi_index = -1
- if props.display_rows:
- idx = props.active_display_row_index
- if 0 <= idx < len(props.display_rows):
- row = props.display_rows[idx]
- if row.row_type == "POINT":
- pi_index = row.pi_index
-
- # Fallback to active_pi_index if display_rows isn't being used
- if pi_index < 0:
- pi_index = props.active_pi_index
-
- if 0 <= pi_index < len(props.pis):
- props.pis.remove(pi_index)
- props.active_pi_index = min(pi_index, len(props.pis) - 1)
-
- # Recalculate geometry (also rebuilds display_rows)
- recalculate_pi_geometry(props)
-
- # Reset display row index to first row if needed
- if len(props.display_rows) > 0:
- props.active_display_row_index = min(props.active_display_row_index, len(props.display_rows) - 1)
- else:
- props.active_display_row_index = 0
-
- return {"FINISHED"}
-
-
def _insert_polyline_point_no_close(op, context, event):
"""Insert polyline points without close-polyline (C key) behavior.
@@ -491,412 +168,13 @@ def _bearing_string(azimuth_from_east_ccw_deg: float) -> str:
return f"{ns} {d}°{m:02d}'{s:05.2f}\" {ew}"
-class ALIGN_OT_pick_pi_from_viewport(bpy.types.Operator, PolylineOperator, tool.Ifc.Operator):
- """Add PI points by clicking in the 3D viewport using polyline tools"""
-
- bl_idname = "align.pick_pi_from_viewport"
- bl_label = "Pick PI from Viewport"
- bl_description = "Click in the viewport to add PI points with snapping and numeric input. RMB/Enter to finish, ESC to cancel."
- bl_options = {"REGISTER", "UNDO"}
-
- @classmethod
- def poll(cls, context):
- return poll_ifc4x3(cls, context)
-
- def __init__(self, *args, **kwargs):
- bpy.types.Operator.__init__(self, *args, **kwargs)
- PolylineOperator.__init__(self)
- # Remove instructions that don't apply to alignments
- self.instructions.pop("Close Polyline", None)
- self.instructions.pop("Offset", None)
-
- def invoke(self, context, event):
- return IfcStore.execute_ifc_operator(self, context, event, method="INVOKE")
-
- def _invoke(self, context, event):
- # Find the 3D viewport — the operator is invoked from the Properties
- # panel, so we need to override context for PolylineOperator.invoke()
- # which requires bpy.context.space_data to be SpaceView3D.
- area_3d = None
- region_3d = None
- for area in context.screen.areas:
- if area.type == "VIEW_3D":
- area_3d = area
- for region in area.regions:
- if region.type == "WINDOW":
- region_3d = region
- break
- break
-
- if not area_3d or not region_3d:
- self.report({"ERROR"}, "No 3D Viewport found")
- return {"CANCELLED"}
-
- with context.temp_override(area=area_3d, region=region_3d):
- super().invoke(context, event)
-
- self.tool_state.use_default_container = False
- self.tool_state.plane_method = "XY"
- return {"RUNNING_MODAL"}
-
- def modal(self, context, event):
- return IfcStore.execute_ifc_operator(self, context, event, method="MODAL")
-
- def _modal(self, context, event):
- PolylineDecorator.update(event, self.tool_state, self.input_ui, self.snapping_points[0])
- tool.Blender.update_viewport()
-
- self.handle_lock_axis(context, event)
-
- if event.type in {"MIDDLEMOUSE", "WHEELUPMOUSE", "WHEELDOWNMOUSE"}:
- self.handle_mouse_move(context, event)
- return {"PASS_THROUGH"}
-
- self.handle_instructions(context)
- self.handle_mouse_move(context, event, should_round=True)
- self.choose_axis(event)
- self.handle_snap_selection(context, event)
- self.handle_keyboard_input(context, event)
- _insert_polyline_point_no_close(self, context, event)
-
- # Finish: transfer polyline points to PI table
- if (
- not self.tool_state.is_input_on
- and event.value == "RELEASE"
- and event.type in {"RET", "NUMPAD_ENTER", "RIGHTMOUSE"}
- ):
- self._transfer_polyline_to_pis(context)
- context.workspace.status_text_set(text=None)
- PolylineDecorator.uninstall()
- tool.Polyline.clear_polyline()
- # Auto-visualize: build the IFC segments as soon as picking finishes,
- # so the user no longer needs a separate "Visualize" click.
- ok, message = _build_alignment_from_active_pis(context)
- if not ok:
- self.report({"WARNING"}, message)
- tool.Blender.update_viewport()
- return {"FINISHED"}
-
- cancel = self.handle_cancelation(context, event)
- if cancel is not None:
- return cancel
-
- return {"RUNNING_MODAL"}
-
- def _transfer_polyline_to_pis(self, context):
- """Transfer collected polyline points to the PI Editor table.
-
- Polyline points are in Blender coordinate space. This method converts
- each point to IFC coordinate space before storing in props.pis.
- """
- props = context.scene.CivilAlignmentProperties
- polyline_props = tool.Model.get_polyline_props()
- polyline_data = polyline_props.insertion_polyline
- if not polyline_data:
- return
-
- polyline_points = polyline_data[0].polyline_points
- if not polyline_points:
- return
-
- # Blender world space is metres (1 BU = 1 m); the georeference helpers
- # work in IFC project length units. Convert before storing so the
- # alignment is recreated at the correct scale (e.g. feet projects).
- unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
-
- num_points = len(polyline_points)
- for i, point in enumerate(polyline_points):
- # Blender metres -> IFC project units -> global easting/northing (stored on props.pis).
- local = (point.x / unit_scale, point.y / unit_scale, 0.0)
- ifc_coord = tool.Georeference.xyz2enh(local)
-
- pi = props.pis.add()
- pi.e = str(ifc_coord[0])
- pi.n = str(ifc_coord[1])
-
- # Determine PI type based on position
- if i == 0 or i == num_points - 1:
- pi.pi_type = "ENDPOINT"
- else:
- pi.pi_type = "TANGENT"
-
- props.active_pi_index = len(props.pis) - 1
- recalculate_pi_geometry(props)
- rebuild_display_rows(props)
-
-
-def _build_alignment_from_active_pis(context):
- """Build/refresh the IFC horizontal segments from props.pis on the active
- alignment and visualize them.
-
- Shared by the Recalculate/Visualize operator and the PI picker (so picking
- auto-visualizes on completion). Returns (ok: bool, message: str).
- """
- import ifcopenshell.api.alignment as align_api
-
- ifc = tool.Ifc.get()
- props = context.scene.CivilAlignmentProperties
- recalculate_pi_geometry(props)
-
- alignment = tool.Alignment.get_active_alignment()
- if not alignment:
- total_length = sum(pi.length_to_next for pi in props.pis)
- return (
- False,
- f"Select an IfcAlignment in the outliner first. "
- f"(Recalculated {len(props.pis)} PIs, total length: {total_length:.2f})",
- )
- if len(props.pis) < 2:
- return False, "Need at least 2 PIs to build the alignment"
-
- props.active_alignment_id = alignment.id()
-
- # Bootstrap horizontal layout if the alignment is bare (e.g. from Add Element)
- h_layout = align_api.get_horizontal_layout(alignment)
- if h_layout is None:
- h_layout = tool.Alignment.add_horizontal_layout_to_alignment(alignment)
-
- # Ensure Blender objects exist for the alignment hierarchy
- alignment_obj = tool.Ifc.get_object(alignment)
- if not alignment_obj:
- alignment_obj = tool.Alignment.create_hierarchy_for_alignment(alignment)
-
- # Stored PI E/N (IFC project units) -> local IFC coords for the API.
- hpoints = [
- [float(o) for o in ifcopenshell.util.geolocation.auto_enh2xyz(ifc, float(pi.e), float(pi.n), 0.0)[:2]]
- for pi in props.pis
- ]
- # pi.radius is a Blender LENGTH property (stored in metres); the API expects
- # project units, so convert back via unit_scale — same as the coordinates.
- unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc)
- radii = [pi.radius / unit_scale for pi in props.pis[1:-1]]
-
- tool.Alignment.remove_layout_segment_objects(h_layout)
- tool.Alignment.clear_layout_segments(h_layout)
- align_api.layout_horizontal_alignment_by_pi_method(ifc, h_layout, hpoints, radii)
-
- layout_obj = tool.Ifc.get_object(h_layout)
- if not layout_obj:
- layout_obj = tool.Alignment.create_object_for_layout(h_layout, alignment_obj)
- if layout_obj:
- tool.Alignment.create_objects_for_layout_segments(h_layout, layout_obj)
-
- # Make the alignment the active/selected object so it's immediately
- # visible in the outliner/properties pane without a manual click.
- if alignment_obj:
- for obj in context.selected_objects:
- obj.select_set(False)
- alignment_obj.select_set(True)
- context.view_layer.objects.active = alignment_obj
-
- tool.Blender.update_viewport()
- return True, f"Updated alignment '{alignment.Name}' with {len(hpoints)} PIs"
-
-
-class ALIGN_OT_recalculate_pis(Operator, tool.Ifc.Operator):
- """Recalculate PI geometry and update IFC/visualization"""
-
- bl_idname = "align.recalculate_pis"
- bl_label = "Recalculate PIs"
- bl_description = "Recalculate geometry, update IFC segments, and refresh visualization"
- bl_options = {"REGISTER", "UNDO"}
-
- @classmethod
- def poll(cls, context):
- if not poll_ifc4x3(cls, context):
- return False
- props = context.scene.CivilAlignmentProperties
- if len(props.pis) < 2:
- cls.poll_message_set("Need at least 2 PIs to recalculate")
- return False
- return True
-
- def _execute(self, context):
- ok, message = _build_alignment_from_active_pis(context)
- self.report({"INFO"} if ok else {"WARNING"}, message)
-
-
-class ALIGN_OT_clear_pis(Operator, tool.Ifc.Operator):
- """Delete the active alignment and clear the PI table"""
-
- bl_idname = "align.clear_pis"
- bl_label = "Clear All PIs"
- bl_description = (
- "Delete the entire active alignment — its IFC entity, all nested "
- "layouts and segments, and its viewport objects — and clear the PI table"
- )
- bl_options = {"REGISTER", "UNDO"}
-
- @classmethod
- def poll(cls, context):
- if not poll_ifc4x3(cls, context):
- return False
- props = context.scene.CivilAlignmentProperties
- if len(props.pis) == 0:
- cls.poll_message_set("No PIs to clear")
- return False
- return True
-
- def invoke(self, context, event):
- return context.window_manager.invoke_confirm(self, event)
-
- def _execute(self, context):
- ifc = tool.Ifc.get()
- props = context.scene.CivilAlignmentProperties
-
- removed_objects = 0
-
- # Delete the active alignment entirely (Blender + IFC) so the file is
- # never left with an orphaned, PI-less alignment. Resolved through
- # props.active_alignment_id — the same reference every other panel
- # operator uses — not the viewport's active object.
- if alignment := _resolve_active_alignment(context):
- removed_objects = tool.Alignment.remove_alignment_hierarchy(alignment)
- ifcopenshell.api.run("root.remove_product", ifc, product=alignment)
- props.active_alignment_id = 0
- props.active_alignment_name = ""
-
- # Clear the PI list in the UI
- props.pis.clear()
- props.active_pi_index = 0
-
- # Clear the display rows
- props.display_rows.clear()
- props.active_display_row_index = 0
-
- if removed_objects > 0:
- self.report({"INFO"}, f"Deleted alignment and removed {removed_objects} objects")
- else:
- self.report({"INFO"}, "Cleared all PIs")
-
-
# =============================================================================
-# Creation Operators
-# =============================================================================
-
-
-class ALIGN_OT_create_alignment_by_pis(Operator, tool.Ifc.Operator):
- """Create a new alignment and immediately start picking PI points"""
-
- bl_idname = "align.create_alignment_by_pis"
- bl_label = "New Alignment (PI Method)"
- bl_description = "Create a new alignment and pick PI points from the viewport"
- bl_options = {"REGISTER", "UNDO"}
-
- alignment_name: StringProperty(name="Name", default="Alignment")
-
- @classmethod
- def poll(cls, context):
- return poll_ifc4x3(cls, context)
-
- def invoke(self, context, event):
- return context.window_manager.invoke_props_dialog(self)
-
- def _execute(self, context):
- props = context.scene.CivilAlignmentProperties
-
- # Create full alignment via core → tool → API
- try:
- alignment = core.create_alignment(
- tool.Ifc, tool.Alignment, self.alignment_name
- )
- except ValueError as e:
- self.report({"ERROR"}, str(e))
- return {"CANCELLED"}
-
- props.active_alignment_id = alignment.id()
- props.active_alignment_name = alignment.Name or self.alignment_name
-
- # Clear any existing PIs from previous work
- props.pis.clear()
- props.active_pi_index = 0
- props.display_rows.clear()
- props.active_display_row_index = 0
-
- self.report({"INFO"}, f"Created alignment '{alignment.Name}' — pick PI points now")
-
- # Chain into PI picker (runs as separate modal with its own undo)
- bpy.ops.align.pick_pi_from_viewport("INVOKE_DEFAULT")
-
- return {"FINISHED"}
-
-
-class ALIGN_OT_create_alignment_by_pi(Operator, tool.Ifc.Operator):
- """Create alignment using the PI (Point of Intersection) method"""
-
- bl_idname = "align.create_alignment_by_pi"
- bl_label = "Create by PI Method"
- bl_description = "Create alignment using PI points and curve radii. If an active alignment exists with no segments, adds to it instead of creating new."
- bl_options = {"REGISTER", "UNDO"}
-
- @classmethod
- def poll(cls, context):
- if not poll_ifc4x3(cls, context):
- return False
- props = context.scene.CivilAlignmentProperties
- if len(props.pis) < 2:
- cls.poll_message_set("Need at least 2 PI points")
- return False
- if not tool.Alignment.get_active_alignment():
- cls.poll_message_set("Select an alignment to edit")
- return False
- return True
-
- def _execute(self, context):
- props = context.scene.CivilAlignmentProperties
-
- # Convert global E/N coords (stored in props.pis) -> local IFC coords for the IfcOpenShell API
- hpoints = [
- [
- float(o)
- for o in ifcopenshell.util.geolocation.auto_enh2xyz(tool.Ifc.get(), float(pi.e), float(pi.n), 0.0)[:2]
- ]
- for pi in props.pis
- ]
- radii = [pi.radius for pi in props.pis[1:-1]]
-
- existing_alignment = tool.Alignment.get_active_alignment()
- if not (h_layout := ifcopenshell.api.alignment.get_horizontal_layout(existing_alignment)):
- return
- # Check if horizontal layout is empty (only has zero-length terminal or no segments)
- segments = ifcopenshell.api.alignment.get_layout_segments(h_layout)
- has_real_segments = bool([s for s in segments if not tool.Alignment.is_zero_length_segment(s)])
-
- ifcopenshell.api.alignment.create_representation(tool.Ifc.get(), existing_alignment)
-
- if not has_real_segments:
- # Use existing alignment - add segments to it
- # Use safe wrapper to validate layout has parent alignment
- tool.Alignment.safe_layout_horizontal_by_pi_method(tool.Ifc.get(), h_layout, hpoints, radii)
-
- # Create/update Blender objects for the segments
- alignment_obj = tool.Ifc.get_object(existing_alignment)
- h_layout_obj = tool.Ifc.get_object(h_layout)
-
- if not h_layout_obj and alignment_obj:
- h_layout_obj = tool.Alignment.create_object_for_layout(h_layout, alignment_obj)
-
- if h_layout_obj:
- tool.Alignment.create_objects_for_layout_segments(h_layout, h_layout_obj)
-
- self.report(
- {"INFO"}, f"Added {len(hpoints)} PIs to existing alignment '{existing_alignment.Name}'"
- )
-
-
-# CSV import lives on the single upstream operator id `bim.import_alignment_csv`
-# (class ImportAlignmentCSV above) — it now routes through
-# core.import_alignment_csv, which builds the Saikei viewport hierarchy for the
-# parent and any aggregated child alignments.
-
-
-# =============================================================================
-# Alignments tab — new authoring workflow (Add Element + interactive drawing).
+# Alignments tab authoring workflow (Add Element + interactive drawing).
#
-# This is a from-scratch replacement for the CIVIL tab's PI-table workflow
-# above: no persistent PI table, no CivilAlignmentProperties dependency. An
-# alignment is added as a bare IfcAlignment, then its horizontal geometry is
-# drawn directly in the viewport and committed to IFC on completion.
+# No persistent PI table, no dependency on CivilAlignmentProperties beyond
+# the alignment selector: an alignment is added as a bare IfcAlignment, then
+# its horizontal geometry is drawn directly in the viewport and committed to
+# IFC on completion.
# =============================================================================
@@ -1308,7 +586,7 @@ def _generate_alignment_segments(context, alignment, hpoints, radii):
# behind (e.g. from before this single-mesh approach existed) so the
# scene collection converges on exactly one object for the alignment —
# what loading it from a file would give you — rather than accumulating
- # the CIVIL tab's per-layout/per-segment objects alongside it.
+ # per-layout/per-segment objects alongside it.
tool.Alignment.remove_layout_and_child_layout_objects(alignment)
tool.Alignment.clear_layout_segments(h_layout)
@@ -1683,119 +961,6 @@ class ALIGN_OT_draw_horizontal_alignment(bpy.types.Operator, PolylineOperator, t
blf.disable(font_id, blf.SHADOW)
-# =============================================================================
-# Stationing Operators
-# =============================================================================
-
-
-class ALIGN_OT_add_stationing_referent(Operator, tool.Ifc.Operator):
- """Add a stationing referent to the alignment"""
-
- bl_idname = "align.add_stationing_referent"
- bl_label = "Add Stationing Referent"
- bl_description = "Add an IfcReferent for stationing"
- bl_options = {"REGISTER", "UNDO"}
-
- station: FloatProperty(
- name="Station",
- description="Station value for the referent (e.g., 10000 for 100+00)",
- default=10000.0,
- )
-
- name: StringProperty(
- name="Name",
- description="Name for the referent (leave blank to auto-generate)",
- default="",
- )
-
- @classmethod
- def poll(cls, context):
- if not poll_ifc4x3(cls, context):
- return False
- props = context.scene.CivilAlignmentProperties
- if props.active_alignment_id == 0:
- cls.poll_message_set("Select an alignment first")
- return False
- return True
-
- def invoke(self, context, event):
- # Default station to start_station from props
- props = context.scene.CivilAlignmentProperties
- self.station = props.start_station
- return context.window_manager.invoke_props_dialog(self)
-
- def draw(self, context):
- layout = self.layout
- layout.prop(self, "station")
- layout.prop(self, "name")
- # Show station notation preview
- station_str = tool.Alignment.format_station(self.station)
- layout.label(text=f"Station notation: {station_str}")
-
- def _execute(self, context):
- ifc = tool.Ifc.get()
- props = context.scene.CivilAlignmentProperties
-
- alignment = _resolve_active_alignment(context)
- if alignment is None:
- self.report({"ERROR"}, "Alignment no longer exists. Reference cleared.")
- return {"CANCELLED"}
-
- # Compute distance_along from station and start_station
- # distance_along = station - start_station
- distance_along = self.station - props.start_station
-
- # Auto-generate name if not provided
- name = self.name if self.name else tool.Alignment.format_station(self.station)
-
- # Use the alignment itself as the positioned product
- # (The referent marks a point on the alignment)
- positioned_product = alignment
-
- ifcopenshell.api.alignment.add_stationing_referent(
- ifc,
- alignment=alignment,
- distance_along=distance_along,
- station=self.station,
- name=name,
- positioned_product=positioned_product,
- )
-
- self.report({"INFO"}, f"Added referent '{name}' at station {self.station}")
-
-
-class ALIGN_OT_name_segments(Operator, tool.Ifc.Operator):
- """Auto-name segments based on station values"""
-
- bl_idname = "align.name_segments"
- bl_label = "Name Segments"
- bl_description = "Automatically name segments with station-based labels"
- bl_options = {"REGISTER", "UNDO"}
-
- @classmethod
- def poll(cls, context):
- if not poll_ifc4x3(cls, context):
- return False
- props = context.scene.CivilAlignmentProperties
- if props.active_alignment_id == 0:
- cls.poll_message_set("Select an alignment first")
- return False
- return True
-
- def _execute(self, context):
- ifc = tool.Ifc.get()
- props = context.scene.CivilAlignmentProperties
-
- alignment = _resolve_active_alignment(context)
- if alignment is None:
- self.report({"ERROR"}, "Alignment no longer exists. Reference cleared.")
- return {"CANCELLED"}
-
- ifcopenshell.api.alignment.name_segments(ifc, alignment)
-
- self.report({"INFO"}, "Named alignment segments")
-
-
# =============================================================================
# Vertical Profile Window Operator
# =============================================================================
@@ -1985,181 +1150,3 @@ class ALIGN_OT_select_cant_segment(Operator):
alignment_decorator.VerticalProfileDecorator.tag_redraw()
return {"FINISHED"}
-
-# =============================================================================
-# PI Edit Mode Operator
-# =============================================================================
-
-
-class ALIGN_OT_enter_pi_edit_mode(Operator, tool.Ifc.Operator):
- """Enter PI editing mode - move PIs with G key, press Enter to apply or Escape to cancel"""
-
- bl_idname = "align.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.CivilAlignmentProperties
- 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
- alignment = _resolve_active_alignment(context)
- if alignment is None:
- cls.poll_message_set("Selected alignment no longer exists")
- return False
- return True
-
- def invoke(self, context, event):
- return IfcStore.execute_ifc_operator(self, context, event, method="INVOKE")
-
- def _invoke(self, context, event):
- props = context.scene.CivilAlignmentProperties
- 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)
-
- # Make the segment curves non-selectable so viewport clicks land on the
- # PI empties, and deselect everything so the user starts clean.
- alignment = tool.Ifc.get().by_id(self._alignment_id)
- h_layout = tool.Alignment.get_horizontal_layout(alignment)
- tool.Alignment.set_layout_segments_selectable(h_layout, False)
- for obj in list(context.selected_objects):
- obj.select_set(False)
-
- # 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):
- return IfcStore.execute_ifc_operator(self, context, event, method="MODAL")
-
- def _modal(self, context, event):
- props = context.scene.CivilAlignmentProperties
-
- # 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 in {"RET", "NUMPAD_ENTER"} 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.CivilAlignmentProperties
-
- 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 updated")
- 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()
-
- # Restore segment selectability (on apply the segments are rebuilt and
- # already selectable; on cancel this re-enables the originals).
- try:
- alignment = tool.Ifc.get().by_id(self._alignment_id)
- h_layout = tool.Alignment.get_horizontal_layout(alignment)
- tool.Alignment.set_layout_segments_selectable(h_layout, True)
- except (RuntimeError, AttributeError):
- pass
-
- # 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()
-
- if apply:
- return {"FINISHED"}
- return {"CANCELLED"}
diff --git a/src/bonsai/bonsai/bim/module/alignment/ops.authoring.alignment.dat b/src/bonsai/bonsai/bim/module/alignment/ops.authoring.alignment.dat
deleted file mode 100644
index 88edb8568c4d4a906f45f158cfe1a7c0919bf4af..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 836
zcmeH@F{*<=5QaTRZs4|-HZM^j2r3wN4U!lnpj*6z5D^v$s1VuCHtpsDdI?MGJ%AVS
zO)l_Gkb!At=KFpaVtC53C`#2b#+fGcUL7VOhq32~p=wpndSA xyz2enh() -> global E/N (stored here)
- # global E/N -> ifcopenshell.util.geolocation.auto_enh2xyz() -> local IFC coords (for IfcOpenShell API)
- e: StringProperty(name="E", description="Easting (global map coordinates)", default="0.0")
- n: StringProperty(name="N", description="Northing (global map coordinates)", default="0.0")
-
- # PI Type
- pi_type: EnumProperty(
- name="Type",
- description="Type of PI point",
- items=[
- ("ENDPOINT", "Endpoint", "Start or end point (no curve)"),
- ("TANGENT", "Tangent", "Pass-through point (no curve)"),
- ("CURVE", "Curve", "Point of intersection with curve"),
- ],
- default="TANGENT",
- )
-
- # Curve parameters (only used when pi_type == "CURVE")
- radius: FloatProperty(
- name="Radius",
- description="Curve radius (0 = no curve, sharp angle)",
- default=0.0,
- min=0.0,
- precision=3,
- unit="LENGTH",
- update=_on_radius_update,
- )
-
- # Computed/display values (updated by recalculate operator)
- length_to_next: FloatProperty(
- name="Length",
- description="Length of tangent to next PI",
- default=0.0,
- precision=3,
- unit="LENGTH",
- )
-
- direction_to_next: FloatProperty(
- name="Direction",
- description="Bearing/direction to next PI (degrees)",
- default=0.0,
- precision=4,
- subtype="ANGLE",
- )
-
- # Station at this PI (computed)
- station: FloatProperty(
- name="Station",
- description="Station value at this PI",
- default=0.0,
- precision=2,
- )
-
-
-class AlignmentDisplayRow(PropertyGroup):
- """Property group for interleaved point/segment display in the table.
-
- This creates the Civil 3D-style view where points and segments
- are shown on separate rows:
- Point 1 (End)
- Segment 1 (Tan)
- Point 2 (Tan)
- Segment 2 (Tan)
- ...
- """
-
- # Row type discriminator
- row_type: EnumProperty(
- name="Row Type",
- items=[
- ("POINT", "Point", "A PI point row"),
- ("SEGMENT", "Segment", "A segment row between points"),
- ],
- default="POINT",
- )
-
- # Segment number (1, 2, 3...) - only for SEGMENT rows
- segment_number: IntProperty(name="Segment #", default=0)
-
- # Point index in the pis collection - for both types
- # For POINT rows: the PI index
- # For SEGMENT rows: the starting PI index of this segment
- pi_index: IntProperty(name="PI Index", default=0)
-
- # Display type string (End, Tan, Curve for points; Tan, Curve for segments)
- display_type: StringProperty(name="Type", default="")
-
- # Point coordinates (only for POINT rows)
- e: StringProperty(name="E", default="0.0")
- n: StringProperty(name="N", default="0.0")
-
- # Segment properties (only for SEGMENT rows)
- length: FloatProperty(name="Length", default=0.0, precision=2, unit="LENGTH")
- radius: FloatProperty(name="Radius", default=0.0, precision=2, unit="LENGTH")
- arc_length: FloatProperty(name="Arc Length", default=0.0, precision=2, unit="LENGTH")
-
-
class CivilAlignmentProperties(PropertyGroup):
"""Properties for the alignment module"""
- # Active alignment selection
- active_alignment_id: IntProperty(
- name="Active Alignment ID",
- description="IFC entity ID of the active alignment",
- default=0,
- )
-
- active_alignment_name: StringProperty(
- name="Active Alignment",
- description="Name of the currently active alignment",
- default="",
- )
-
# Alignment selector dropdown (top-level alignments only)
active_alignment_id_str: EnumProperty(
name="Alignment",
@@ -284,35 +154,6 @@ class CivilAlignmentProperties(PropertyGroup):
default=0,
)
- # Panel collapse state
- show_horizontal_segments: BoolProperty(
- name="Show Horizontal Segments",
- description="Expand the horizontal segment table",
- default=True,
- )
-
- # New alignment creation properties
- new_alignment_name: StringProperty(
- name="Name",
- description="Name for new alignment",
- default="Alignment 1",
- )
-
- start_station: FloatProperty(
- name="Start Station",
- description="Starting station value (e.g., 10000 for 100+00)",
- default=10000.0,
- min=0.0,
- )
-
- # PI collection for PI method creation
- pis: CollectionProperty(type=AlignmentPI)
- active_pi_index: IntProperty(name="Active PI", default=0)
-
- # Combined point/segment display rows (for Civil 3D-style table)
- display_rows: CollectionProperty(type=AlignmentDisplayRow)
- active_display_row_index: IntProperty(name="Active Display Row", default=0)
-
# Vertical profile window settings
vertical_exaggeration: FloatProperty(
name="Vertical Exaggeration",
@@ -371,19 +212,6 @@ class CivilAlignmentProperties(PropertyGroup):
default=True,
)
- # 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,
- )
-
class PICurveMarkerProperties(PropertyGroup):
"""Tags a transient Empty object placed at an interior PI while its
diff --git a/src/bonsai/bonsai/bim/module/alignment/ui.py b/src/bonsai/bonsai/bim/module/alignment/ui.py
index 0d116c3b58..c7090c30c1 100644
--- a/src/bonsai/bonsai/bim/module/alignment/ui.py
+++ b/src/bonsai/bonsai/bim/module/alignment/ui.py
@@ -19,8 +19,8 @@
"""UI panels for the alignment module
-All panels appear in the Properties sidebar under the CIVIL tab,
-nested under BIM_PT_tab_horizontal_alignment.
+All panels appear in the Properties sidebar under the Alignments tab,
+nested under BIM_PT_tab_alignments.
"""
import bpy
@@ -28,7 +28,7 @@ import math
import ifcopenshell.api.alignment
import ifcopenshell.util.geolocation
import bonsai.tool as tool
-from bpy.types import Panel, UIList, Operator
+from bpy.types import Panel, Operator
from bpy.props import IntProperty, BoolProperty
from .prop import _alignment_enum_items
from .operator import _find_pi_markers, _resolve_alignment_id_for_markers, _is_interior_pi_marker
@@ -104,233 +104,6 @@ class ALIGN_OT_toggle_cant_segments(Operator):
return {"FINISHED"}
-# =============================================================================
-# UILists
-# =============================================================================
-
-
-class ALIGN_UL_alignment_pis(UIList):
- """UIList for displaying interleaved points and segments (Civil 3D style)
-
- Row types:
- - POINT rows: End (endpoint), Mid (interior PI without curve)
- - SEGMENT rows: Tan (tangent line), Curve (circular arc)
-
- When a Mid point has radius > 0, it becomes a Curve segment row.
- """
-
- def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
- if self.layout_type in {"DEFAULT", "COMPACT"}:
- row = layout.row(align=True)
-
- if item.row_type == "POINT":
- # Point row: No., Type, X, Y, Length, Radius
- row.label(text="") # No segment number for points
-
- # Type with point/dot icon
- # "End" = endpoint (POB/POE), "Mid" = interior PI point
- row.label(text=item.display_type, icon="DOT")
-
- # X, Y coordinates - get actual PI for editing
- pi = data.pis[item.pi_index] if item.pi_index < len(data.pis) else None
- if pi:
- sub = row.row(align=True)
- sub.prop(pi, "e", text="")
- sub.prop(pi, "n", text="")
- else:
- row.label(text=f"{float(item.e):.2f}")
- row.label(text=f"{float(item.n):.2f}")
-
- # Length column - empty for point rows
- row.label(text="")
-
- # Radius column - editable for Mid points (where curves can be added)
- if item.display_type == "Mid" and pi:
- row.prop(pi, "radius", text="")
- else:
- row.label(text="")
-
- elif item.row_type == "SEGMENT":
- if item.display_type == "Curve":
- # Curve segment row: No., Type (arc icon), X, Y, Arc Length, Radius
- row.label(text=f"{item.segment_number}")
- row.label(text="Curve", icon="SPHERECURVE")
-
- # Show PI coordinates on curve row
- row.label(text=f"{float(item.e):.2f}")
- row.label(text=f"{float(item.n):.2f}")
-
- # Arc length
- row.label(text=f"{item.arc_length:.2f}")
-
- # Radius - editable so user can modify or delete curve (set to 0)
- pi = data.pis[item.pi_index] if item.pi_index < len(data.pis) else None
- if pi:
- row.prop(pi, "radius", text="")
- else:
- row.label(text=f"{item.radius:.2f}")
- else:
- # Tangent segment row: No., Type (line icon), -, -, Length, -
- row.label(text=f"{item.segment_number}")
- row.label(text="Tan", icon="IPO_LINEAR")
-
- # No X, Y for tangent segments
- row.label(text="")
- row.label(text="")
-
- # Length
- row.label(text=f"{item.length:.2f}")
-
- # No radius for tangent segments
- row.label(text="-")
-
- elif self.layout_type == "GRID":
- layout.alignment = "CENTER"
- layout.label(text="", icon="DECORATE")
-
-
-# =============================================================================
-# Creation Sub-Panel
-# =============================================================================
-
-
-class ALIGN_PT_alignment_creation(Panel):
- """Sub-panel for alignment creation tools"""
-
- bl_label = "Creation"
- bl_idname = "ALIGN_PT_alignment_creation"
- bl_space_type = "PROPERTIES"
- bl_region_type = "WINDOW"
- bl_context = "scene"
- bl_parent_id = "BIM_PT_tab_horizontal_alignment"
- bl_options = {"DEFAULT_CLOSED"}
-
- @classmethod
- def poll(cls, context):
- return tool.Blender.should_show_panel(context, "CIVIL", cls.bl_idname) and is_ifc4x3()
-
- def draw(self, context):
- layout = self.layout
- props = context.scene.CivilAlignmentProperties
-
- # New alignment properties
- box = layout.box()
- box.label(text="New Alignment:", icon="ADD")
- box.prop(props, "new_alignment_name")
- box.prop(props, "start_station")
-
- # Creation operators
- col = layout.column(align=True)
- col.operator("align.create_alignment_by_pi", icon="CURVE_DATA")
-
-
-# =============================================================================
-# PI Editor Sub-Panel
-# =============================================================================
-
-
-class ALIGN_PT_pi_editor(Panel):
- """Sub-panel for PI point table editor (Civil 3D style grid view)"""
-
- bl_label = "PI Editor"
- bl_idname = "ALIGN_PT_pi_editor"
- bl_space_type = "PROPERTIES"
- bl_region_type = "WINDOW"
- bl_context = "scene"
- bl_parent_id = "BIM_PT_tab_horizontal_alignment"
- bl_options = set() # Open by default
-
- @classmethod
- def poll(cls, context):
- return tool.Blender.should_show_panel(context, "CIVIL", cls.bl_idname) and is_ifc4x3()
-
- def draw(self, context):
- layout = self.layout
- props = context.scene.CivilAlignmentProperties
-
- # 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("align.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.")
- header.label(text="Type")
- header.label(text="E")
- header.label(text="N")
- header.label(text="Length")
- header.label(text="Radius")
-
- # Combined point/segment list (interleaved view)
- row = layout.row()
- row.template_list(
- "ALIGN_UL_alignment_pis",
- "",
- props,
- "display_rows",
- props,
- "active_display_row_index",
- rows=8,
- )
-
- # Side buttons for list management
- col = row.column(align=True)
- col.operator("align.add_pi", icon="ADD", text="")
- col.operator("align.remove_pi", icon="REMOVE", text="")
- col.separator()
- col.operator("align.pick_pi_from_viewport", icon="EYEDROPPER", text="")
-
- # Bottom actions
- layout.separator()
- row = layout.row(align=True)
- row.operator("align.recalculate_pis", icon="FILE_REFRESH", text="Recalculate")
- row.operator("align.clear_pis", icon="TRASH", text="Clear All")
-
-
-# =============================================================================
-# Stationing Sub-Panel
-# =============================================================================
-
-
-class ALIGN_PT_alignment_stationing(Panel):
- """Sub-panel for stationing and referents"""
-
- bl_label = "Stationing"
- bl_idname = "ALIGN_PT_alignment_stationing"
- bl_space_type = "PROPERTIES"
- bl_region_type = "WINDOW"
- bl_context = "scene"
- bl_parent_id = "BIM_PT_tab_horizontal_alignment"
- bl_options = {"DEFAULT_CLOSED"}
-
- @classmethod
- def poll(cls, context):
- return tool.Blender.should_show_panel(context, "CIVIL", cls.bl_idname) and is_ifc4x3()
-
- def draw(self, context):
- layout = self.layout
-
- # Stationing operators
- col = layout.column(align=True)
- col.operator("align.add_stationing_referent", icon="EMPTY_AXIS")
- col.operator("align.name_segments", icon="FONT_DATA")
-
-
# =============================================================================
# Alignments Tab – Segment Breakdown Panel
# =============================================================================
@@ -383,9 +156,8 @@ def _start_en(ifc_file, dp) -> tuple[float | None, float | None]:
class ALIGN_PT_alignment_authoring(Panel):
"""Add an alignment and draw its horizontal geometry — Alignments tab.
- This is a from-scratch authoring workflow, independent of the CIVIL tab's
- PI-table tools: add a bare alignment, then draw its horizontal geometry
- directly in the viewport.
+ Add a bare alignment, then draw its horizontal geometry directly in the
+ viewport.
"""
bl_label = "Add Alignment"
@@ -438,9 +210,8 @@ class ALIGN_PT_alignment_authoring(Panel):
class ALIGN_PT_alignment_stationing_authoring(Panel):
"""Start station and station equations — Alignments tab.
- A from-scratch equivalent of the CIVIL tab's stationing panel: edit the
- start station, and add/remove additional stationing referents (station
- equations) for gaps, overlaps, or reversed stationing direction.
+ Edit the start station, and add/remove additional stationing referents
+ (station equations) for gaps, overlaps, or reversed stationing direction.
"""
bl_label = "Stationing"
diff --git a/src/bonsai/bonsai/bim/module/alignment/workspace.py b/src/bonsai/bonsai/bim/module/alignment/workspace.py
deleted file mode 100644
index ec7848bd55..0000000000
--- a/src/bonsai/bonsai/bim/module/alignment/workspace.py
+++ /dev/null
@@ -1,72 +0,0 @@
-# Bonsai - OpenBIM Blender Add-on
-# Copyright (C) 2020, 2021 Dion Moult , 2026 Michael Yoder
-#
-# This file is part of Bonsai.
-#
-# Bonsai is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# Bonsai is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with Bonsai. If not, see .
-
-import os
-
-import bpy
-from bpy.types import WorkSpaceTool
-
-import bonsai.tool as tool
-
-
-class AlignmentTool(WorkSpaceTool):
- bl_space_type = "VIEW_3D"
- bl_context_mode = "OBJECT"
- bl_idname = "bim.alignment_tool"
- bl_label = "Alignment"
- bl_description = "Civil alignment tools — create and edit horizontal alignments using PI method"
- bl_icon = os.path.join(os.path.dirname(__file__), "ops.authoring.alignment")
- bl_widget = None
- bl_keymap = tool.Blender.get_default_selection_keypmap()
-
- def draw_settings(
- context: bpy.types.Context,
- layout: bpy.types.UILayout,
- workspace_tool: bpy.types.WorkSpaceTool,
- ) -> None:
- if context.region.type == "TOOL_HEADER":
- _draw_header(layout)
- else:
- _draw_sidebar(layout)
-
-
-def _draw_header(layout):
- """Compact icon-only layout for the tool header bar."""
- row = layout.row(align=True)
- row.operator("bim.import_alignment_csv", text="", icon="IMPORT")
- row.operator("align.pick_pi_from_viewport", text="", icon="EYEDROPPER")
- row.separator()
- row.operator("align.recalculate_pis", text="", icon="FILE_REFRESH")
-
-
-def _draw_sidebar(layout):
- """Expanded layout for the sidebar / N-panel."""
- # -- Horizontal Alignment --
- col = layout.column(align=True)
- col.label(text="Horizontal Alignment", icon="CURVE_DATA")
- col.operator("align.create_alignment_by_pis", icon="ADD")
- col.operator("bim.import_alignment_csv", icon="IMPORT")
- col.separator()
- col.operator("align.pick_pi_from_viewport", icon="EYEDROPPER")
- col.operator("align.enter_pi_edit_mode", text="Edit PIs", icon="EDITMODE_HLT")
- row = col.row(align=True)
- row.operator("align.recalculate_pis", text="Visualize", icon="FILE_REFRESH")
- row.operator("align.clear_pis", text="Clear", icon="TRASH")
- col.separator()
- col.operator("align.add_stationing_referent", icon="EMPTY_AXIS")
- col.operator("align.name_segments", icon="FONT_DATA")
diff --git a/src/bonsai/bonsai/bim/module/root/data.py b/src/bonsai/bonsai/bim/module/root/data.py
index eaa2636217..c1988c8b82 100644
--- a/src/bonsai/bonsai/bim/module/root/data.py
+++ b/src/bonsai/bonsai/bim/module/root/data.py
@@ -135,11 +135,6 @@ class IfcClassData:
("EMPTY", "No Geometry", "Start with an empty object"),
]
- if ifc_class == "IfcAlignment":
- # Alignment representations come from ifcopenshell.api.alignment
- # (composite curves), not from a mesh template.
- return templates
-
if ifc_class in ("IfcWindowType", "IfcWindowStyle", "IfcWindow"):
templates.extend([None, ("WINDOW", "Window", "Parametric window")])
elif ifc_class in ("IfcDoorType", "IfcDoorStyle", "IfcDoor"):
diff --git a/src/bonsai/bonsai/bim/module/root/operator.py b/src/bonsai/bonsai/bim/module/root/operator.py
index 58157a9edf..fd79c71875 100644
--- a/src/bonsai/bonsai/bim/module/root/operator.py
+++ b/src/bonsai/bonsai/bim/module/root/operator.py
@@ -575,29 +575,6 @@ class AddElement(bpy.types.Operator, tool.Ifc.Operator):
)
element.Description = props.description or None
- if props.ifc_class == "IfcAlignment":
- # Saikei: creating an alignment automatically creates its
- # horizontal layout (spec 1.1). Alignments own an origin local
- # placement (IFC 4.1.4.1.1 aggregates them to the project, never
- # to a spatial container) and their representations come from
- # ifcopenshell.api.alignment, so the representation templates and
- # 3D-cursor placement do not apply.
- obj.location = (0.0, 0.0, 0.0)
- h_layout = tool.Alignment.add_horizontal_layout_to_alignment(element)
- tool.Alignment.create_object_for_layout(h_layout, obj)
- civil_props = context.scene.CivilAlignmentProperties
- civil_props.active_alignment_id = element.id()
- civil_props.active_alignment_name = element.Name or ""
- bpy.context.view_layer.update()
- bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj)
- tool.Blender.set_active_object(obj)
- self.report(
- {"INFO"},
- f"Alignment '{element.Name}' created with an empty horizontal layout — "
- "add PI points from the CIVIL tab or viewport picking.",
- )
- return
-
if representation_template == "EMTPY" or not ifc_context:
pass
elif representation_template == "OBJ" and props.representation_obj:
diff --git a/src/bonsai/bonsai/bim/prop.py b/src/bonsai/bonsai/bim/prop.py
index 0078124c23..921d986336 100644
--- a/src/bonsai/bonsai/bim/prop.py
+++ b/src/bonsai/bonsai/bim/prop.py
@@ -530,7 +530,6 @@ def get_tab(
("PROJECT", "Project Overview", "", bonsai.bim.icons[icon_key].icon_id, 0),
("OBJECT", "Object Information", "", "FILE_3D", 1),
("GEOMETRY", "Geometry and Materials", "", "MATERIAL", 2),
- ("CIVIL", "Civil Infrastructure", "", "CURVE_DATA", 11),
("ALIGNMENTS", "Alignments", "", "ANIM_DATA", 12),
("DRAWINGS", "Drawings and Documents", "", "DOCUMENTS", 3),
("SERVICES", "Services and Systems", "", "NETWORK_DRIVE", 4),
diff --git a/src/bonsai/bonsai/bim/ui.py b/src/bonsai/bonsai/bim/ui.py
index a457507953..fc22665555 100644
--- a/src/bonsai/bonsai/bim/ui.py
+++ b/src/bonsai/bonsai/bim/ui.py
@@ -1640,25 +1640,6 @@ class BIM_PT_tab_profiles(Panel):
pass
-# Civil Infrastructure tab panels
-class BIM_PT_tab_horizontal_alignment(Panel):
- bl_idname = "BIM_PT_tab_horizontal_alignment"
- bl_label = "Horizontal Alignment"
- bl_space_type = "PROPERTIES"
- bl_region_type = "WINDOW"
- bl_context = "scene"
- bl_order = 1
- bim_tab_name = "CIVIL"
-
- @classmethod
- def poll(cls, context):
- if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get():
- return True
-
- def draw(self, context):
- pass
-
-
class BIM_PT_tab_alignments(Panel):
bl_idname = "BIM_PT_tab_alignments"
bl_label = "Alignments"
@@ -1863,7 +1844,6 @@ class UIData:
("PROJECT", bonsai.bim.icons[f"{color_mode}_ifc"].icon_id, True),
("OBJECT", "FILE_3D", is_ifc_project),
("GEOMETRY", "MATERIAL", is_ifc_project),
- ("CIVIL", "CURVE_DATA", is_ifc_project),
("ALIGNMENTS", "ANIM_DATA", is_ifc_project),
("DRAWINGS", "DOCUMENTS", is_ifc_project),
("SERVICES", "NETWORK_DRIVE", is_ifc_project),
diff --git a/src/bonsai/bonsai/core/alignment.py b/src/bonsai/bonsai/core/alignment.py
index 680a649a80..f3f2fe83ff 100644
--- a/src/bonsai/bonsai/core/alignment.py
+++ b/src/bonsai/bonsai/core/alignment.py
@@ -78,70 +78,6 @@ def create_alignment(
return alignment_tool.create_alignment(name.strip(), start_station)
-# =============================================================================
-# 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
- """
- # 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 (delegated to tool)
- h_layout = alignment_tool.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 import_alignment_csv(
ifc_tool: "type[tool.Ifc]",
alignment_tool: "type[tool.Alignment]",
@@ -183,84 +119,3 @@ def import_alignment_csv(
alignment_tool.create_objects_for_referents(alignment)
return alignment
-
-
-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
- - Update alignment segments in-place (preserves alignment ID)
- 2. Always:
- - Remove temporary EMPTY objects
- - Return success status
-
- This function modifies the alignment segments in-place rather than
- deleting and recreating the alignment. This preserves the alignment's
- IFC entity ID, preventing stale reference issues.
-
- 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, update alignment with new PI positions
-
- Returns:
- True if successful
-
- Raises:
- ValueError: If alignment doesn't exist or update fails
- """
- 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 horizontal layout (delegated to tool)
- h_layout = alignment_tool.get_horizontal_layout(alignment)
- if h_layout is None:
- raise ValueError("Alignment has no horizontal layout")
-
- # Remove empties before modifying segments
- alignment_tool.remove_pi_edit_empties(alignment_id)
-
- # Remove Blender visualization for segments (not the whole hierarchy)
- alignment_tool.remove_layout_segment_objects(h_layout)
-
- # Clear existing IFC segments and add new ones (delegated to tool)
- alignment_tool.clear_layout_segments(h_layout)
- alignment_tool.layout_by_pi_method(h_layout, hpoints, radii)
-
- # Refresh Blender visualization for new segments
- layout_obj = ifc_tool.get_object(h_layout)
- if layout_obj:
- alignment_tool.create_objects_for_layout_segments(h_layout, layout_obj)
-
- return True
- else:
- # Cancel - just remove empties without regenerating
- alignment_tool.remove_pi_edit_empties(alignment_id)
- return True
diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py
index 5e9617adf1..4a0e5b0c0d 100644
--- a/src/bonsai/bonsai/core/tool.py
+++ b/src/bonsai/bonsai/core/tool.py
@@ -1290,17 +1290,10 @@ class Web:
class Alignment:
# Alignment creation
def create_alignment(cls, name, start_station=0.0): pass
- # Horizontal PI edit mode
- def back_calculate_pis_from_alignment(cls, alignment): pass
def clear_layout_segments(cls, h_layout): pass
- def collect_pis_from_empties(cls, alignment_id): pass
def create_objects_for_layout_segments(cls, h_layout, layout_obj): pass
- def create_pi_edit_empties(cls, alignment, pis): pass
def get_horizontal_layout(cls, alignment): pass
- def layout_by_pi_method(cls, h_layout, hpoints, radii): pass
- def layout_has_real_segments(cls, h_layout): pass
def remove_layout_segment_objects(cls, h_layout): pass
- def remove_pi_edit_empties(cls, alignment_id): pass
# Stationing
def format_station(cls, station): pass
# CSV import
diff --git a/src/bonsai/bonsai/tool/alignment.py b/src/bonsai/bonsai/tool/alignment.py
index 87e861c588..6e8febbc5f 100644
--- a/src/bonsai/bonsai/tool/alignment.py
+++ b/src/bonsai/bonsai/tool/alignment.py
@@ -36,27 +36,11 @@ import bonsai.bim.import_ifc
import ifcopenshell.api.alignment
import ifcopenshell.util.shape
from typing import TYPE_CHECKING, Optional, List, Tuple
-from dataclasses import dataclass
if TYPE_CHECKING:
import ifcopenshell
-# =============================================================================
-# Data Classes for PI Geometry Results
-# =============================================================================
-
-
-@dataclass
-class PIGeometryResult:
- """Result of PI geometry calculation."""
-
- stations: List[float]
- lengths: List[float]
- directions: List[float]
- total_length: float
-
-
class Alignment:
"""Tool class for alignment-related Blender operations.
@@ -64,288 +48,6 @@ class Alignment:
that can be called without instantiation.
"""
- # =========================================================================
- # Geometry Calculation Methods
- # =========================================================================
-
- @classmethod
- def calculate_pi_geometry(cls, pis: List[Tuple[float, float]], start_station: float = 0.0) -> PIGeometryResult:
- """Calculate lengths, stations, and directions for a list of PI points.
-
- Args:
- pis: List of (x, y) coordinate tuples for each PI
- start_station: Starting station value
-
- Returns:
- PIGeometryResult containing calculated values
- """
- if len(pis) < 2:
- return PIGeometryResult(
- stations=[start_station] if pis else [],
- lengths=[0.0] if pis else [],
- directions=[0.0] if pis else [],
- total_length=0.0,
- )
-
- stations = []
- lengths = []
- directions = []
- cumulative_length = start_station
-
- for i, pi in enumerate(pis):
- stations.append(cumulative_length)
-
- if i < len(pis) - 1:
- next_pi = pis[i + 1]
- dx = next_pi[0] - pi[0]
- dy = next_pi[1] - pi[1]
- length = math.sqrt(dx * dx + dy * dy)
- direction = math.atan2(dy, dx)
- lengths.append(length)
- directions.append(direction)
- cumulative_length += length
- else:
- lengths.append(0.0)
- directions.append(0.0)
-
- total_length = cumulative_length - start_station
-
- return PIGeometryResult(stations=stations, lengths=lengths, directions=directions, total_length=total_length)
-
- @classmethod
- def calculate_tangent_length(cls, radius: float, deflection_angle: float) -> float:
- """Calculate tangent length for a circular curve.
-
- T = R * tan(Δ/2)
-
- Args:
- radius: Curve radius
- deflection_angle: Deflection angle in radians
-
- Returns:
- Tangent length
- """
- if deflection_angle == 0 or radius == 0:
- return 0.0
- return radius * math.tan(deflection_angle / 2)
-
- @classmethod
- def calculate_arc_length(cls, radius: float, deflection_angle: float) -> float:
- """Calculate arc length for a circular curve.
-
- L = R * Δ
-
- Args:
- radius: Curve radius
- deflection_angle: Deflection angle in radians
-
- Returns:
- Arc length
- """
- return radius * deflection_angle
-
- @classmethod
- def deflection_angle_from_points(
- cls, p1: Tuple[float, float], p2: Tuple[float, float], p3: Tuple[float, float]
- ) -> float:
- """Calculate deflection angle at p2 from three (e, n) coordinate tuples.
-
- Args:
- p1: Previous PI coordinates (e, n)
- p2: Current PI coordinates (e, n)
- p3: Next PI coordinates (e, n)
-
- Returns:
- Deflection angle in radians (signed: positive=left, negative=right)
- """
- dx1 = p2[0] - p1[0]
- dy1 = p2[1] - p1[1]
- incoming = math.atan2(dy1, dx1)
-
- dx2 = p3[0] - p2[0]
- dy2 = p3[1] - p2[1]
- outgoing = math.atan2(dy2, dx2)
-
- delta = outgoing - incoming
- while delta > math.pi:
- delta -= 2 * math.pi
- while delta < -math.pi:
- delta += 2 * math.pi
- return delta
-
- @classmethod
- def arc_length_at_pi(
- cls,
- p1: Tuple[float, float],
- p2: Tuple[float, float],
- p3: Tuple[float, float],
- radius: float,
- ) -> float:
- """Calculate arc length L = R * |delta| at a PI with curve.
-
- Args:
- p1, p2, p3: (e, n) coordinate tuples for prev, current, next PI
- radius: Curve radius (must be > 0)
-
- Returns:
- Arc length
- """
- if radius <= 0:
- return 0.0
- deflection = cls.deflection_angle_from_points(p1, p2, p3)
- return cls.calculate_arc_length(radius, abs(deflection))
-
- @classmethod
- def tangent_length_at_pi(
- cls,
- p1: Tuple[float, float],
- p2: Tuple[float, float],
- p3: Tuple[float, float],
- radius: float,
- ) -> float:
- """Calculate tangent length T = R * tan(|delta|/2) at a PI.
-
- Args:
- p1, p2, p3: (e, n) coordinate tuples for prev, current, next PI
- radius: Curve radius (must be > 0)
-
- Returns:
- Tangent length
- """
- if radius <= 0:
- return 0.0
- deflection = cls.deflection_angle_from_points(p1, p2, p3)
- return cls.calculate_tangent_length(radius, abs(deflection))
-
- @classmethod
- def tangent_segment_length(
- cls,
- p_start: Tuple[float, float],
- p_end: Tuple[float, float],
- start_tangent: float = 0.0,
- end_tangent: float = 0.0,
- ) -> float:
- """Calculate tangent segment length between two PIs, minus curve tangent lengths.
-
- Args:
- p_start: (e, n) coordinate tuple for start PI
- p_end: (e, n) coordinate tuple for end PI
- start_tangent: Tangent length to subtract at start
- end_tangent: Tangent length to subtract at end
-
- Returns:
- Net segment length (clamped to 0)
- """
- dx = p_end[0] - p_start[0]
- dy = p_end[1] - p_start[1]
- full_length = math.sqrt(dx * dx + dy * dy)
- return max(0.0, full_length - start_tangent - end_tangent)
-
- # =========================================================================
- # PI Extraction from IFC Segments
- # =========================================================================
-
- @classmethod
- def _get_segment_vertices_in_model_units(
- cls, ifc_file: "ifcopenshell.file", segment: "ifcopenshell.entity_instance"
- ):
- """Get segment control points (Start, End, TI, NI) in model units.
-
- Wraps ifcopenshell.api.alignment.segment_vertices() with:
- - Backward-compatible fallback for segments without Axis/Segment
- representation (falls back to IfcCurveSegment via get_mapped_segments)
- - Unit conversion (geometry engine returns SI; we need model units)
-
- Args:
- ifc_file: The IFC file
- segment: An IfcAlignmentSegment entity
-
- Returns:
- Tuple of (start, end, ti, ni) where each is (x, y) in model units,
- or None for ti/ni when lines are parallel.
- Returns None if segment cannot be evaluated.
- """
- import ifcopenshell.api.alignment as align_api
- import ifcopenshell.util.unit
-
- unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file)
-
- def convert(point):
- if point is None:
- return None
- return (point[0] / unit_scale, point[1] / unit_scale)
-
- result = align_api.segment_vertices(ifc_file, segment)
- if result is None:
- return None
- start, end, ti, ni = result
-
- return (convert(start), convert(end), convert(ti), convert(ni))
-
- @classmethod
- def extract_pis_from_segments(cls, segments):
- """Extract PI data from IFC alignment segments.
-
- Uses ifcopenshell.api.alignment.segment_vertices() to extract
- PI (tangent intersection) points from segment geometry.
-
- Args:
- segments: List of IfcAlignmentSegment entities
-
- Returns:
- List of dicts with keys: e, n, pi_type, radius
- """
- ifc_file = tool.Ifc.get()
-
- # Filter out zero-length terminal segments
- real_segments = [seg for seg in segments if not cls.is_zero_length_segment(seg)]
- if not real_segments:
- return []
-
- # Get vertices for all segments
- seg_vertices = [cls._get_segment_vertices_in_model_units(ifc_file, seg) for seg in real_segments]
-
- pis = []
-
- # First PI: start of first segment
- if seg_vertices[0] is not None:
- start_pt = seg_vertices[0][0]
- pis.append({"e": start_pt[0], "n": start_pt[1], "pi_type": "ENDPOINT", "radius": 0.0})
-
- # Process interior PIs
- prev_is_line = True
- for i, (seg, verts) in enumerate(zip(real_segments, seg_vertices)):
- if verts is None:
- prev_is_line = False
- continue
-
- start, end, ti, ni = verts
- dp = seg.DesignParameters
-
- if ti is not None:
- # Curve segment: TI is the PI
- radius = abs(float(dp.StartRadiusOfCurvature or dp.EndRadiusOfCurvature or 0))
- pis.append({"e": ti[0], "n": ti[1], "pi_type": "CURVE", "radius": radius})
- prev_is_line = False
- else:
- # Line segment: if previous was also a line, connection = tangent PI
- if i > 0 and prev_is_line:
- pis.append({"e": start[0], "n": start[1], "pi_type": "TANGENT", "radius": 0.0})
- prev_is_line = True
-
- # Last PI: end of last segment
- if seg_vertices[-1] is not None:
- end_pt = seg_vertices[-1][1]
- if pis:
- last = pis[-1]
- dist = ((end_pt[0] - last["e"]) ** 2 + (end_pt[1] - last["n"]) ** 2) ** 0.5
- if dist > 0.001:
- pis.append({"e": end_pt[0], "n": end_pt[1], "pi_type": "ENDPOINT", "radius": 0.0})
- else:
- pis.append({"e": end_pt[0], "n": end_pt[1], "pi_type": "ENDPOINT", "radius": 0.0})
-
- return pis
-
# =========================================================================
# IFC API Wrappers (for core layer delegation)
# =========================================================================
@@ -374,9 +76,8 @@ class Alignment:
its (still segment-less) horizontal layout: create_hierarchy_for_alignment()
would create that eagerly, leaving a stray "Layout" object with no
segments in the scene before anything has actually been drawn, unlike
- a loaded file which never has one until it's meaningful. Whichever
- drawing flow adds real segments next (CIVIL's PI picker or the
- Alignments tab's draw tool) creates the layout object lazily, once
+ a loaded file which never has one until it's meaningful. The
+ Alignments tab's draw tool creates the layout object lazily, once
there's something to show.
Args:
@@ -514,20 +215,6 @@ class Alignment:
for segment in dropped_segments:
ifcopenshell.api.root.remove_product(ifc_file, product=segment)
- @classmethod
- def layout_by_pi_method(cls, layout: "ifcopenshell.entity_instance", hpoints: list, radii: list):
- """Add segments to a horizontal layout using the PI method.
-
- Args:
- layout: The IfcAlignmentHorizontal layout
- hpoints: List of (E, N) coordinate pairs for PIs
- radii: List of curve radii for interior PIs
- """
- import ifcopenshell.api.alignment as align_api
-
- ifc_file = tool.Ifc.get()
- align_api.layout_horizontal_alignment_by_pi_method(ifc_file, layout, hpoints, radii)
-
# =========================================================================
# Zero-Length Segment Utilities
# =========================================================================
@@ -560,25 +247,6 @@ class Alignment:
return False
- @classmethod
- def layout_has_real_segments(cls, layout: "ifcopenshell.entity_instance") -> bool:
- """Check if a layout has any real (non-zero-length) segments.
-
- An empty layout only has the mandatory zero-length terminator segment.
-
- Args:
- layout: The IFC layout entity (IfcAlignmentHorizontal, etc.)
-
- Returns:
- True if the layout has at least one real segment
- """
- for rel in getattr(layout, "IsNestedBy", []) or []:
- for segment in rel.RelatedObjects or []:
- if segment.is_a("IfcAlignmentSegment"):
- if not cls.is_zero_length_segment(segment):
- return True
- return False
-
# =========================================================================
# Blender Object Creation
# =========================================================================
@@ -629,15 +297,13 @@ class Alignment:
IFC product's representation is: this is what loading an alignment
from a file produces. It deliberately does NOT create separate
objects for the nested IfcAlignmentHorizontal/Vertical/Cant layouts
- or their IfcAlignmentSegments — interactive creation used to (via
- create_object_for_layout/create_objects_for_layout_segments, ported
- from the CIVIL tab's segment-selection UI), which left the scene
- collection looking different from a loaded file for no IFC-side
- reason. Callers that still want per-segment objects for
- selection/highlighting (CIVIL's tools) should keep using those
- functions directly — this one is for the Alignments tab's authoring
- workflow, which shows segments via ALIGN_PT_alignment_segments
- instead of individual viewport objects.
+ or their IfcAlignmentSegments, unlike create_object_for_layout /
+ create_objects_for_layout_segments (still used by CSV import's
+ hierarchy build), which would leave the scene collection looking
+ different from a loaded file for no IFC-side reason. This one is
+ for the Alignments tab's authoring workflow, which shows segments
+ via ALIGN_PT_alignment_segments instead of individual viewport
+ objects.
Args:
alignment: The IFC alignment entity, with a representation already
@@ -1232,26 +898,6 @@ class Alignment:
return f"{float(station):.2f}"
return ifcopenshell.util.alignment.station_as_string(ifc_file, float(station))
- @classmethod
- def update_pi_properties(cls, props, geometry_result) -> None:
- """Update Blender PropertyGroup with calculated geometry.
-
- This bridges the pure Python calculation results back to
- the Blender UI properties.
-
- Args:
- props: The CivilAlignmentProperties PropertyGroup
- geometry_result: PIGeometryResult from core.alignment
- """
- pis = props.pis
- for i, pi in enumerate(pis):
- if i < len(geometry_result.stations):
- pi.station = geometry_result.stations[i]
- if i < len(geometry_result.lengths):
- pi.length_to_next = geometry_result.lengths[i]
- if i < len(geometry_result.directions):
- pi.direction_to_next = geometry_result.directions[i]
-
@classmethod
def _remove_blender_object(cls, obj: bpy.types.Object) -> bool:
"""Safely remove a Blender object and its data.
@@ -1408,273 +1054,6 @@ class Alignment:
return True
- # =========================================================================
- # 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.
-
- Uses ifcopenshell.api.alignment.segment_vertices() to extract
- the tangent intersection (TI) point for each segment — the TI
- IS the PI for curve segments.
-
- Args:
- alignment: The IfcAlignment entity
-
- Returns:
- List of dicts, each containing:
- - "e": float - Easting coordinate in IFC space
- - "n": float - Northing coordinate in IFC space
- - "radius": float - Curve radius (0 for endpoints/tangent PIs)
- - "pi_type": str - "ENDPOINT", "CURVE", or "TANGENT"
-
- Raises:
- ValueError: If alignment has no horizontal layout or segments
- """
- import ifcopenshell.api.alignment as align_api
-
- ifc_file = tool.Ifc.get()
-
- # 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 = align_api.get_layout_segments(h_layout)
- if not segments:
- raise ValueError(f"Alignment #{alignment.id()} has no segments")
-
- # Filter out zero-length terminator segments
- real_segments = [seg for seg in segments if not cls.is_zero_length_segment(seg)]
- if not real_segments:
- raise ValueError(f"Alignment #{alignment.id()} has no real segments (only terminator)")
-
- # Get vertices for all segments
- seg_vertices = [cls._get_segment_vertices_in_model_units(ifc_file, seg) for seg in real_segments]
-
- pis = []
-
- # First PI: start of first segment
- if seg_vertices[0] is not None:
- start_pt = seg_vertices[0][0]
- pis.append({"e": start_pt[0], "n": start_pt[1], "radius": 0.0, "pi_type": "ENDPOINT"})
-
- # Process each segment for interior PIs
- prev_is_line = True
- for i, (seg, verts) in enumerate(zip(real_segments, seg_vertices)):
- if verts is None:
- prev_is_line = False
- continue
-
- start, end, ti, ni = verts
- dp = seg.DesignParameters
-
- if ti is not None:
- # Curve segment: TI is the PI
- radius = abs(float(dp.StartRadiusOfCurvature or dp.EndRadiusOfCurvature or 0))
- pis.append({"e": ti[0], "n": ti[1], "radius": radius, "pi_type": "CURVE"})
- prev_is_line = False
- else:
- # Line segment: if previous was also a line, connection = tangent PI
- if i > 0 and prev_is_line:
- pis.append({"e": start[0], "n": start[1], "radius": 0.0, "pi_type": "TANGENT"})
- prev_is_line = True
-
- # Last PI: end of last segment
- if seg_vertices[-1] is not None:
- end_pt = seg_vertices[-1][1]
- if pis:
- last = pis[-1]
- dist = ((end_pt[0] - last["e"]) ** 2 + (end_pt[1] - last["n"]) ** 2) ** 0.5
- if dist > 0.001:
- pis.append({"e": end_pt[0], "n": end_pt[1], "radius": 0.0, "pi_type": "ENDPOINT"})
- else:
- pis.append({"e": end_pt[0], "n": end_pt[1], "radius": 0.0, "pi_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
-
- import ifcopenshell.util.unit
-
- alignment_id = alignment.id()
- empties = []
- # Georeference returns IFC project units; Blender world space is metres
- # (1 BU = 1 m), so scale up to place empties at the correct location.
- unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
-
- for i, pi in enumerate(pis):
- # IFC project units -> Blender world metres
- local = tool.Georeference.enh2xyz((float(pi["e"]), float(pi["n"]), 0.0))
- blender_pos = (local[0] * unit_scale, local[1] * unit_scale, local[2] * unit_scale)
-
- # 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["civil_is_pi_empty"] = True
- empty["civil_pi_index"] = i
- empty["civil_pi_radius"] = pi["radius"]
- empty["civil_alignment_id"] = alignment_id
- empty["civil_pi_type"] = pi["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("civil_is_pi_empty") and obj.get("civil_alignment_id") == alignment_id:
- empties.append(obj)
-
- # Sort by PI index
- empties.sort(key=lambda e: e.get("civil_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 ([], [])
-
- import ifcopenshell.util.unit
-
- hpoints = []
- radii = []
- # Blender world metres -> IFC project units before georeferencing.
- unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
-
- for i, empty in enumerate(empties):
- # Blender metres -> IFC project units -> global E/N
- translation = empty.matrix_world.translation
- local = (translation[0] / unit_scale, translation[1] / unit_scale, translation[2] / unit_scale)
- ifc_pos = tool.Georeference.xyz2enh(local)
- 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("civil_pi_radius", 0.0)
- radii.append(radius)
-
- return (hpoints, radii)
-
- @classmethod
- def set_layout_segments_selectable(cls, layout: "ifcopenshell.entity_instance", selectable: bool) -> None:
- """Toggle viewport selectability of a layout's segment objects.
-
- During PI edit mode the segment curves are made non-selectable so
- viewport clicks land on the PI edit empties rather than on the curves
- drawn along the alignment (which otherwise intercept the clicks).
- """
- if layout is None:
- return
- for rel in getattr(layout, "IsNestedBy", []) or []:
- for segment in rel.RelatedObjects or []:
- if segment.is_a() == "IfcAlignmentSegment":
- obj = tool.Ifc.get_object(segment)
- if obj:
- obj.hide_select = not selectable
-
@classmethod
def get_active_alignment(cls) -> ifcopenshell.entity_instance | None:
if obj := tool.Blender.get_active_object():
diff --git a/src/bonsai/bonsai/tool/root.py b/src/bonsai/bonsai/tool/root.py
index b926dd9b6e..ef2464d964 100644
--- a/src/bonsai/bonsai/tool/root.py
+++ b/src/bonsai/bonsai/tool/root.py
@@ -488,8 +488,4 @@ class Root(bonsai.core.tool.Root):
"IfcAnnotation",
"IfcRelSpaceBoundary",
)
- if version != "IFC4":
- # IFC4X3+: alignments are created like any other element
- # (Saikei); the create flow bootstraps the horizontal layout.
- products += ("IfcAlignment",)
return products
diff --git a/src/bonsai/pytest.ini b/src/bonsai/pytest.ini
index 06369d9d23..cb77ee9060 100644
--- a/src/bonsai/pytest.ini
+++ b/src/bonsai/pytest.ini
@@ -8,7 +8,6 @@ markers =
boundary
brick
bsdd
- civil
clash
classification
clip_box
diff --git a/src/bonsai/test/bim/module/alignment/test_alignment_operators.py b/src/bonsai/test/bim/module/alignment/test_alignment_operators.py
index df43c291be..5872f42329 100644
--- a/src/bonsai/test/bim/module/alignment/test_alignment_operators.py
+++ b/src/bonsai/test/bim/module/alignment/test_alignment_operators.py
@@ -22,12 +22,7 @@ Tests non-modal alignment operators end-to-end in Blender headless mode.
Follows Bonsai's existing test patterns (NewIfc4X3 base class from bootstrap).
Operators tested:
- Horizontal: add_pi, remove_pi, recalculate_pis, clear_pis, create_alignment_by_pi
- Utility: name_segments
CSV import: import_alignment_csv (EXEC_DEFAULT with explicit filepath)
-
-Operators skipped (modal / viewport):
- pick_pi_from_viewport, enter_pi_edit_mode
"""
import pytest
@@ -37,7 +32,6 @@ import ifcopenshell
import ifcopenshell.api.alignment as align_api
import bonsai.tool as tool
-from bonsai.bim.ifc import IfcStore
from test.bim.bootstrap import NewIfc4X3
@@ -64,514 +58,6 @@ requires_geometry_engine = pytest.mark.skipif(
pytestmark = pytest.mark.alignment
-# ---------------------------------------------------------------------------
-# Helpers
-# ---------------------------------------------------------------------------
-
-
-def get_alignment_props():
- """Shortcut to CivilAlignmentProperties on the scene."""
- return bpy.context.scene.CivilAlignmentProperties
-
-
-def create_empty_alignment(name="Test Alignment"):
- """Create an IfcAlignment with an empty IfcAlignmentHorizontal layout.
-
- Uses align_api.create() which creates IfcAlignment + IfcAlignmentHorizontal
- + zero-length terminator + geometric representations + aggregation to project.
-
- This is the minimal setup required for operators that need an active
- alignment (e.g. create_alignment_by_pi, recalculate_pis).
-
- Returns:
- tuple: (alignment_entity, alignment_blender_obj)
- """
- ifc_file = tool.Ifc.get()
-
- # create() handles: IfcAlignment, IfcAlignmentHorizontal, IfcRelNests,
- # zero-length terminator, geometric representation, project aggregation
- alignment = align_api.create(ifc_file, name=name)
-
- # Create Blender objects via the tool layer
- alignment_obj = tool.Alignment.create_hierarchy_for_alignment(alignment)
-
- # Set as active so operators can find it via get_active_alignment()
- if alignment_obj:
- bpy.context.view_layer.objects.active = alignment_obj
- alignment_obj.select_set(True)
-
- # Set active alignment ID in properties
- props = get_alignment_props()
- props.active_alignment_id = alignment.id()
- props.active_alignment_name = name
-
- return alignment, alignment_obj
-
-
-def add_pis_to_props(pi_data):
- """Add PIs to the props collection with specified coordinates.
-
- Args:
- pi_data: list of (e, n, radius) tuples.
- First and last are auto-typed as ENDPOINT.
- """
- props = get_alignment_props()
- for i, (e, n, radius) in enumerate(pi_data):
- bpy.ops.civil.add_pi()
- pi = props.pis[len(props.pis) - 1]
- pi.e = str(e)
- pi.n = str(n)
- if radius > 0:
- pi.radius = radius
-
-
-# ===========================================================================
-# Horizontal PI Operators
-# ===========================================================================
-
-
-class TestAddPi(NewIfc4X3):
- """Tests for CIVIL_OT_add_pi (civil.add_pi)."""
-
- def test_add_first_pi_sets_endpoint_at_origin(self):
- props = get_alignment_props()
- result = bpy.ops.civil.add_pi()
- assert result == {"FINISHED"}
- assert len(props.pis) == 1
- assert float(props.pis[0].e) == 0.0
- assert float(props.pis[0].n) == 0.0
- assert props.pis[0].pi_type == "ENDPOINT"
-
- def test_add_second_pi_offsets_from_first(self):
- props = get_alignment_props()
- bpy.ops.civil.add_pi()
- bpy.ops.civil.add_pi()
- assert len(props.pis) == 2
- assert props.pis[1].pi_type == "ENDPOINT"
- # Second PI should be offset 100 units east
- assert float(props.pis[1].e) == pytest.approx(100.0)
- assert float(props.pis[1].n) == pytest.approx(0.0)
-
- def test_add_third_pi_extrapolates_and_changes_second_type(self):
- props = get_alignment_props()
- bpy.ops.civil.add_pi()
- bpy.ops.civil.add_pi()
- bpy.ops.civil.add_pi()
- assert len(props.pis) == 3
- # Second PI (index 1) should have been changed from ENDPOINT to TANGENT
- assert props.pis[1].pi_type == "TANGENT"
- # Third PI extrapolates direction
- assert float(props.pis[2].e) == pytest.approx(200.0)
-
- def test_active_pi_index_tracks_last_added(self):
- props = get_alignment_props()
- bpy.ops.civil.add_pi()
- assert props.active_pi_index == 0
- bpy.ops.civil.add_pi()
- assert props.active_pi_index == 1
- bpy.ops.civil.add_pi()
- assert props.active_pi_index == 2
-
- def test_add_pi_triggers_geometry_recalculation(self):
- """After adding 2+ PIs, display_rows should be populated."""
- props = get_alignment_props()
- bpy.ops.civil.add_pi()
- bpy.ops.civil.add_pi()
- # With 2 PIs, we should have at least 2 point rows and 1 segment row
- assert len(props.display_rows) >= 2
-
-
-class TestRemovePi(NewIfc4X3):
- """Tests for CIVIL_OT_remove_pi (civil.remove_pi)."""
-
- def test_remove_pi_decrements_collection(self):
- props = get_alignment_props()
- bpy.ops.civil.add_pi()
- bpy.ops.civil.add_pi()
- bpy.ops.civil.add_pi()
- assert len(props.pis) == 3
-
- # Select first point row in display_rows
- if props.display_rows:
- props.active_display_row_index = 0
- result = bpy.ops.civil.remove_pi()
- assert result == {"FINISHED"}
- assert len(props.pis) == 2
-
- def test_remove_pi_from_single_item_list(self):
- props = get_alignment_props()
- bpy.ops.civil.add_pi()
- assert len(props.pis) == 1
-
- # Use active_pi_index fallback (no display_rows for 1 PI)
- props.active_pi_index = 0
- # display_rows may be empty with 1 PI, so remove_pi uses active_pi_index
- props.display_rows.clear()
- result = bpy.ops.civil.remove_pi()
- assert result == {"FINISHED"}
- assert len(props.pis) == 0
-
- def test_remove_pi_updates_active_index(self):
- props = get_alignment_props()
- bpy.ops.civil.add_pi()
- bpy.ops.civil.add_pi()
- bpy.ops.civil.add_pi()
-
- # Select last point row
- props.active_pi_index = 2
- props.display_rows.clear()
- bpy.ops.civil.remove_pi()
- # active_pi_index should clamp to valid range
- assert props.active_pi_index <= len(props.pis) - 1
-
-
-class TestClearPis(NewIfc4X3):
- """Tests for CIVIL_OT_clear_pis (civil.clear_pis).
-
- Note: clear_pis defines invoke() with invoke_confirm, but calling via
- bpy.ops in Python uses EXEC_DEFAULT by default, skipping invoke.
- """
-
- def test_clear_pis_removes_all(self):
- props = get_alignment_props()
- for _ in range(5):
- bpy.ops.civil.add_pi()
- assert len(props.pis) == 5
-
- result = bpy.ops.civil.clear_pis()
- assert result == {"FINISHED"}
- assert len(props.pis) == 0
- assert len(props.display_rows) == 0
-
- def test_clear_pis_resets_indices(self):
- props = get_alignment_props()
- bpy.ops.civil.add_pi()
- bpy.ops.civil.add_pi()
- bpy.ops.civil.clear_pis()
- assert props.active_pi_index == 0
- assert props.active_display_row_index == 0
-
- def test_clear_pis_with_active_alignment_removes_ifc(self):
- """When an active alignment exists, clear_pis should remove it from IFC."""
- alignment, alignment_obj = create_empty_alignment()
- ifc_file = tool.Ifc.get()
-
- props = get_alignment_props()
- bpy.ops.civil.add_pi()
- bpy.ops.civil.add_pi()
-
- alignment_count_before = len(ifc_file.by_type("IfcAlignment"))
- bpy.ops.civil.clear_pis()
-
- alignment_count_after = len(ifc_file.by_type("IfcAlignment"))
- assert alignment_count_after < alignment_count_before
- assert len(props.pis) == 0
-
- def test_clear_pis_resolves_alignment_from_props_not_viewport(self):
- """The alignment is resolved via props.active_alignment_id, so it is
- deleted even when the viewport's active object is something else
- (typically a segment curve after PI editing)."""
- alignment, alignment_obj = create_empty_alignment()
- ifc_file = tool.Ifc.get()
- bpy.context.view_layer.objects.active = None
-
- props = get_alignment_props()
- bpy.ops.civil.add_pi()
-
- alignment_count_before = len(ifc_file.by_type("IfcAlignment"))
- bpy.ops.civil.clear_pis()
-
- assert len(ifc_file.by_type("IfcAlignment")) < alignment_count_before
- assert props.active_alignment_id == 0
- assert props.active_alignment_name == ""
-
-
-class TestRecalculatePis(NewIfc4X3):
- """Tests for CIVIL_OT_recalculate_pis (civil.recalculate_pis)."""
-
- def test_recalculate_populates_display_rows(self):
- props = get_alignment_props()
- add_pis_to_props([(0, 0, 0), (500, 0, 0), (1000, 200, 0)])
-
- result = bpy.ops.civil.recalculate_pis()
- assert result == {"FINISHED"}
- assert len(props.display_rows) > 0
-
- def test_recalculate_computes_geometry_values(self):
- """PI geometry values (station, length_to_next) should be reasonable."""
- props = get_alignment_props()
- add_pis_to_props([(0, 0, 0), (500, 0, 0), (1000, 0, 0)])
-
- bpy.ops.civil.recalculate_pis()
-
- # Straight line: each segment should be 500 units
- assert props.pis[0].length_to_next == pytest.approx(500.0, abs=1.0)
- assert props.pis[1].length_to_next == pytest.approx(500.0, abs=1.0)
-
- @requires_geometry_engine
- def test_recalculate_with_active_alignment_updates_ifc(self):
- """When an active alignment exists, recalculate should update IFC segments."""
- alignment, alignment_obj = create_empty_alignment()
- ifc_file = tool.Ifc.get()
-
- props = get_alignment_props()
- add_pis_to_props([(0, 0, 0), (500, 0, 0), (1000, 200, 0)])
-
- # First, create alignment segments
- bpy.ops.civil.create_alignment_by_pi()
-
- # Re-select alignment object (create_alignment_by_pi may change selection)
- bpy.context.view_layer.objects.active = alignment_obj
- alignment_obj.select_set(True)
-
- # Modify a PI
- props.pis[1].e = str(600.0)
-
- # Recalculate should update IFC in-place
- result = bpy.ops.civil.recalculate_pis()
- assert result == {"FINISHED"}
-
- # IFC should still have segments
- segments = ifc_file.by_type("IfcAlignmentSegment")
- assert len(segments) >= 2
-
-
-@requires_geometry_engine
-class TestCreateAlignmentByPi(NewIfc4X3):
- """Tests for CIVIL_OT_create_alignment_by_pi (civil.create_alignment_by_pi).
-
- This operator requires an existing empty alignment set as active.
- """
-
- def test_create_alignment_creates_ifc_segments(self):
- """Basic 3-PI alignment: creates tangent + tangent segments in IFC."""
- alignment, alignment_obj = create_empty_alignment()
- ifc_file = tool.Ifc.get()
-
- props = get_alignment_props()
- add_pis_to_props([(0, 0, 0), (500, 0, 0), (1000, 200, 0)])
-
- result = bpy.ops.civil.create_alignment_by_pi()
- assert result == {"FINISHED"}
-
- # IFC should contain alignment segments
- segments = ifc_file.by_type("IfcAlignmentSegment")
- assert len(segments) >= 2
-
- # Alignment should still exist
- alignments = ifc_file.by_type("IfcAlignment")
- assert len(alignments) == 1
-
- # Horizontal layout should exist
- horizontals = ifc_file.by_type("IfcAlignmentHorizontal")
- assert len(horizontals) == 1
-
- def test_create_alignment_with_curve_creates_arc_segment(self):
- """3-PI alignment with radius on middle PI creates LINE + ARC + LINE."""
- alignment, alignment_obj = create_empty_alignment()
- ifc_file = tool.Ifc.get()
-
- props = get_alignment_props()
- add_pis_to_props([(0, 0, 0), (500, 0, 300), (1000, 200, 0)])
-
- result = bpy.ops.civil.create_alignment_by_pi()
- assert result == {"FINISHED"}
-
- # Check segment design parameter types
- segments = ifc_file.by_type("IfcAlignmentSegment")
- segment_types = []
- for seg in segments:
- dp = seg.DesignParameters
- if dp and hasattr(dp, "PredefinedType"):
- segment_types.append(dp.PredefinedType)
-
- # Should have at least LINE and CIRCULARARC
- assert "LINE" in segment_types
- assert "CIRCULARARC" in segment_types
-
- def test_create_alignment_produces_blender_objects(self):
- """After creation, Blender scene should contain alignment objects."""
- alignment, alignment_obj = create_empty_alignment()
-
- props = get_alignment_props()
- add_pis_to_props([(0, 0, 0), (500, 0, 0), (1000, 200, 0)])
-
- bpy.ops.civil.create_alignment_by_pi()
-
- # Should have at least the alignment object in the scene
- alignment_objects = [
- obj for obj in bpy.data.objects
- if tool.Ifc.get_entity(obj) and tool.Ifc.get_entity(obj).is_a("IfcAlignment")
- ]
- assert len(alignment_objects) >= 1
-
- def test_create_straight_alignment_two_pis(self):
- """Minimal alignment: 2 PIs producing a single tangent."""
- alignment, alignment_obj = create_empty_alignment()
- ifc_file = tool.Ifc.get()
-
- props = get_alignment_props()
- add_pis_to_props([(0, 0, 0), (1000, 0, 0)])
-
- result = bpy.ops.civil.create_alignment_by_pi()
- assert result == {"FINISHED"}
-
- segments = ifc_file.by_type("IfcAlignmentSegment")
- assert len(segments) >= 1
-
-
-@requires_geometry_engine
-class TestEndToEndAlignmentCreation(NewIfc4X3):
- """Full workflow: create alignment, add PIs, create IFC, validate."""
-
- def test_basic_three_pi_alignment_workflow(self):
- """Scenario 1: Create a basic 3-PI alignment end-to-end."""
- alignment, alignment_obj = create_empty_alignment("E2E Test Alignment")
- ifc_file = tool.Ifc.get()
- props = get_alignment_props()
-
- # Add 3 PIs: straight segment then angled
- add_pis_to_props([(0, 0, 0), (500, 0, 0), (1000, 200, 0)])
- assert len(props.pis) == 3
-
- # Create alignment
- result = bpy.ops.civil.create_alignment_by_pi()
- assert result == {"FINISHED"}
-
- # Validate IFC entities
- alignments = ifc_file.by_type("IfcAlignment")
- assert len(alignments) == 1
- assert alignments[0].Name == "E2E Test Alignment"
-
- horizontals = ifc_file.by_type("IfcAlignmentHorizontal")
- assert len(horizontals) == 1
-
- segments = ifc_file.by_type("IfcAlignmentSegment")
- # At minimum: tangent + tangent (+ zero-length terminator possibly)
- assert len(segments) >= 2
-
- # All segment design params should be IfcAlignmentHorizontalSegment
- for seg in segments:
- dp = seg.DesignParameters
- if dp:
- assert dp.is_a("IfcAlignmentHorizontalSegment")
-
- def test_three_pi_with_curve_workflow(self):
- """Scenario 2: 3-PI alignment with curve produces correct IFC segments."""
- alignment, alignment_obj = create_empty_alignment("Curved Alignment")
- ifc_file = tool.Ifc.get()
- props = get_alignment_props()
-
- # PI with 300m radius on middle point
- add_pis_to_props([(0, 0, 0), (500, 0, 300), (1000, 500, 0)])
-
- bpy.ops.civil.create_alignment_by_pi()
-
- segments = ifc_file.by_type("IfcAlignmentSegment")
- predefined_types = set()
- for seg in segments:
- dp = seg.DesignParameters
- if dp and hasattr(dp, "PredefinedType"):
- predefined_types.add(dp.PredefinedType)
-
- assert "LINE" in predefined_types
- assert "CIRCULARARC" in predefined_types
-
- def test_five_pi_complex_alignment(self):
- """Scenario 3: 5-PI alignment with multiple curves."""
- alignment, alignment_obj = create_empty_alignment("Complex Alignment")
- ifc_file = tool.Ifc.get()
- props = get_alignment_props()
-
- add_pis_to_props([
- (0, 0, 0),
- (300, 0, 200),
- (600, 300, 150),
- (900, 300, 250),
- (1200, 0, 0),
- ])
-
- result = bpy.ops.civil.create_alignment_by_pi()
- assert result == {"FINISHED"}
-
- segments = ifc_file.by_type("IfcAlignmentSegment")
- # 5 PIs with 3 interior curves → many segments
- assert len(segments) >= 4
-
- def test_add_remove_pi_cycle(self):
- """Scenario 4: Add/remove PIs cycle - props stay consistent."""
- props = get_alignment_props()
-
- bpy.ops.civil.add_pi()
- bpy.ops.civil.add_pi()
- bpy.ops.civil.add_pi()
- assert len(props.pis) == 3
-
- # Remove middle PI
- props.display_rows.clear()
- props.active_pi_index = 1
- bpy.ops.civil.remove_pi()
- assert len(props.pis) == 2
-
- # Add two more
- bpy.ops.civil.add_pi()
- bpy.ops.civil.add_pi()
- assert len(props.pis) == 4
-
- # Clear all
- bpy.ops.civil.clear_pis()
- assert len(props.pis) == 0
- assert len(props.display_rows) == 0
-
-@requires_geometry_engine
-class TestEndToEndIfcRoundtrip(NewIfc4X3):
- """IFC save/reload roundtrip validation."""
-
- def test_alignment_survives_ifc_roundtrip(self):
- """Create alignment, save to temp file, reload, verify entities."""
- import tempfile
- import os
-
- alignment, alignment_obj = create_empty_alignment("Roundtrip Test")
- ifc_file = tool.Ifc.get()
- props = get_alignment_props()
-
- add_pis_to_props([(0, 0, 0), (500, 0, 300), (1000, 200, 0)])
- bpy.ops.civil.create_alignment_by_pi()
-
- # Count entities before save
- alignment_count = len(ifc_file.by_type("IfcAlignment"))
- horizontal_count = len(ifc_file.by_type("IfcAlignmentHorizontal"))
- segment_count = len(ifc_file.by_type("IfcAlignmentSegment"))
-
- assert alignment_count == 1
- assert horizontal_count == 1
- assert segment_count >= 2
-
- # Save to temp file
- temp_path = os.path.join(tempfile.gettempdir(), "alignment_roundtrip_test.ifc")
- ifc_file.write(temp_path)
-
- # Reload
- reloaded = ifcopenshell.open(temp_path)
-
- # Verify entity counts match
- assert len(reloaded.by_type("IfcAlignment")) == alignment_count
- assert len(reloaded.by_type("IfcAlignmentHorizontal")) == horizontal_count
- assert len(reloaded.by_type("IfcAlignmentSegment")) == segment_count
-
- # Verify alignment name survived
- assert reloaded.by_type("IfcAlignment")[0].Name == "Roundtrip Test"
-
- # Cleanup
- os.unlink(temp_path)
-
-
-# Station formatting is tool-layer now (tool.Alignment.format_station wrapping
-# ifcopenshell.util.alignment.station_as_string) — see TestFormatStation in
-# test/tool/test_alignment.py.
-
-
@requires_geometry_engine
class TestImportAlignmentCsv(NewIfc4X3):
"""bim.import_alignment_csv — the single, merged CSV import path.
@@ -590,9 +76,8 @@ class TestImportAlignmentCsv(NewIfc4X3):
result = bpy.ops.bim.import_alignment_csv("EXEC_DEFAULT", filepath=filepath)
assert result == {"FINISHED"}
- props = get_alignment_props()
- assert props.active_alignment_id != 0
- alignment = tool.Ifc.get().by_id(props.active_alignment_id)
+ alignment = tool.Alignment.get_active_alignment()
+ assert alignment is not None
assert alignment.is_a("IfcAlignment")
assert tool.Ifc.get_object(alignment) is not None
@@ -607,64 +92,5 @@ class TestImportAlignmentCsv(NewIfc4X3):
result = bpy.ops.bim.import_alignment_csv("EXEC_DEFAULT", filepath=filepath)
assert result == {"FINISHED"}
- props = get_alignment_props()
- alignment = tool.Ifc.get().by_id(props.active_alignment_id)
+ alignment = tool.Alignment.get_active_alignment()
assert align_api.get_vertical_layout(alignment) is not None
-
-
-class TestAddElementAlignment(NewIfc4X3):
- """Shift-A Add Element route for IfcAlignment (spec intro / 1.1).
-
- Reinstated from the 0.8 saikei branch in minimal scope: IfcAlignment in
- the Definition dropdown; creating one bootstraps the horizontal layout,
- stationing referent, zero-length terminator, and project aggregation —
- landing in the same state as panel creation.
- """
-
- def _add_alignment(self, name=""):
- import bonsai.bim.module.root.data
-
- bonsai.bim.module.root.data.IfcClassData.load()
- root_props = tool.Root.get_root_props()
- root_props.ifc_product = "IfcAlignment"
- root_props.ifc_class = "IfcAlignment"
- if name:
- root_props.name = name
- return bpy.ops.bim.add_element()
-
- def test_ifc_alignment_offered_in_products(self):
- products = tool.Root.get_ifc_products()
- assert "IfcAlignment" in products
-
- def test_add_element_bootstraps_horizontal_layout(self):
- result = self._add_alignment(name="Route 66")
- assert result == {"FINISHED"}
-
- ifc_file = tool.Ifc.get()
- alignments = ifc_file.by_type("IfcAlignment")
- assert len(alignments) == 1
- alignment = alignments[0]
-
- h_layout = align_api.get_horizontal_layout(alignment)
- assert h_layout is not None
- assert align_api.has_zero_length_segment(h_layout)
-
- def test_add_element_alignment_is_aggregated_not_contained(self):
- self._add_alignment()
- alignment = tool.Ifc.get().by_type("IfcAlignment")[0]
- assert alignment.Decomposes
- assert alignment.Decomposes[0].RelatingObject.is_a("IfcProject")
- assert not alignment.ContainedInStructure
-
- def test_add_element_creates_stationing_referent_and_sets_active(self):
- self._add_alignment(name="Route 66")
- alignment = tool.Ifc.get().by_type("IfcAlignment")[0]
-
- nest = align_api.get_stationing_nest(tool.Ifc.get(), alignment)
- assert nest is not None
- referent = nest.RelatedObjects[0]
- assert referent.Name.startswith("Route 66")
-
- props = get_alignment_props()
- assert props.active_alignment_id == alignment.id()
- assert props.active_alignment_name == "Route 66"
diff --git a/src/bonsai/test/core/test_alignment.py b/src/bonsai/test/core/test_alignment.py
index 2aea82f959..441f5afe5c 100644
--- a/src/bonsai/test/core/test_alignment.py
+++ b/src/bonsai/test/core/test_alignment.py
@@ -22,181 +22,6 @@ import bonsai.core.alignment as subject
from test.core.bootstrap import alignment, ifc
-# ---------------------------------------------------------------------------
-# Helpers
-# ---------------------------------------------------------------------------
-
-
-class FakeIfcEntity(dict):
- """A JSON-serializable stand-in for an IFC entity.
-
- Inherits from dict so json.dumps can serialize it when it appears as an
- argument to Prophecy-tracked tool methods. The ifc_class key drives
- is_a(), and the name key drives the Name property.
- """
-
- def is_a(self, ifc_class: str) -> bool:
- return self.get("ifc_class") == ifc_class
-
- @property
- def Name(self) -> str:
- return self.get("name", "Test Entity")
-
-
-class FakeIfcFile:
- """Minimal stand-in for an open IFC file."""
-
- def __init__(self, entity=None, not_found: bool = False):
- self._entity = entity
- self._not_found = not_found
-
- def by_id(self, entity_id: int):
- if self._not_found:
- raise RuntimeError(f"Could not find #{entity_id}")
- return self._entity
-
-
-def make_alignment_entity(name: str = "Test Alignment") -> FakeIfcEntity:
- return FakeIfcEntity({"ifc_class": "IfcAlignment", "name": name})
-
-
-def make_non_alignment_entity(name: str = "Wall") -> FakeIfcEntity:
- return FakeIfcEntity({"ifc_class": "IfcWall", "name": name})
-
-
-# ---------------------------------------------------------------------------
-# enter_pi_edit_mode
-# ---------------------------------------------------------------------------
-
-
-class TestEnterPiEditMode:
- def test_raises_when_no_ifc_file_loaded(self, ifc, alignment):
- ifc.get().should_be_called().will_return(None)
- with pytest.raises(ValueError, match="No IFC file loaded"):
- subject.enter_pi_edit_mode(ifc, alignment, alignment_id=1)
-
- def test_raises_when_alignment_not_found(self, ifc, alignment):
- ifc.get().should_be_called().will_return(FakeIfcFile(not_found=True))
- with pytest.raises(ValueError, match="not found"):
- subject.enter_pi_edit_mode(ifc, alignment, alignment_id=1)
-
- def test_raises_when_entity_is_not_an_alignment(self, ifc, alignment):
- entity = make_non_alignment_entity()
- ifc.get().should_be_called().will_return(FakeIfcFile(entity=entity))
- with pytest.raises(ValueError, match="not an IfcAlignment"):
- subject.enter_pi_edit_mode(ifc, alignment, alignment_id=1)
-
- def test_raises_when_alignment_has_no_horizontal_layout(self, ifc, alignment):
- entity = make_alignment_entity()
- ifc.get().should_be_called().will_return(FakeIfcFile(entity=entity))
- alignment.get_horizontal_layout(entity).should_be_called().will_return(None)
- with pytest.raises(ValueError, match="no horizontal layout"):
- subject.enter_pi_edit_mode(ifc, alignment, alignment_id=1)
-
- def test_raises_when_alignment_has_no_real_segments(self, ifc, alignment):
- entity = make_alignment_entity()
- ifc.get().should_be_called().will_return(FakeIfcFile(entity=entity))
- alignment.get_horizontal_layout(entity).should_be_called().will_return("h_layout")
- alignment.layout_has_real_segments("h_layout").should_be_called().will_return(False)
- with pytest.raises(ValueError, match="no editable segments"):
- subject.enter_pi_edit_mode(ifc, alignment, alignment_id=1)
-
- def test_raises_when_back_calculated_pis_fewer_than_two(self, ifc, alignment):
- entity = make_alignment_entity()
- ifc.get().should_be_called().will_return(FakeIfcFile(entity=entity))
- alignment.get_horizontal_layout(entity).should_be_called().will_return("h_layout")
- alignment.layout_has_real_segments("h_layout").should_be_called().will_return(True)
- alignment.back_calculate_pis_from_alignment(entity).should_be_called().will_return([(0.0, 0.0)])
- with pytest.raises(ValueError, match="at least 2 PIs"):
- subject.enter_pi_edit_mode(ifc, alignment, alignment_id=1)
-
- def test_returns_empties_for_valid_alignment(self, ifc, alignment):
- entity = make_alignment_entity()
- pis = [(0.0, 0.0), (100.0, 0.0), (200.0, 50.0)]
- empties = ["empty_0", "empty_1", "empty_2"]
- ifc.get().should_be_called().will_return(FakeIfcFile(entity=entity))
- alignment.get_horizontal_layout(entity).should_be_called().will_return("h_layout")
- alignment.layout_has_real_segments("h_layout").should_be_called().will_return(True)
- alignment.back_calculate_pis_from_alignment(entity).should_be_called().will_return(pis)
- alignment.create_pi_edit_empties(entity, pis).should_be_called().will_return(empties)
- result = subject.enter_pi_edit_mode(ifc, alignment, alignment_id=1)
- assert result == empties
-
-
-# ---------------------------------------------------------------------------
-# exit_pi_edit_mode
-# ---------------------------------------------------------------------------
-
-
-class TestExitPiEditMode:
- def test_cleans_up_and_returns_true_when_no_ifc_file(self, ifc, alignment):
- ifc.get().should_be_called().will_return(None)
- alignment.remove_pi_edit_empties(1).should_be_called()
- result = subject.exit_pi_edit_mode(ifc, alignment, alignment_id=1, apply=True)
- assert result is True
-
- def test_cleans_up_and_returns_true_when_alignment_deleted(self, ifc, alignment):
- ifc.get().should_be_called().will_return(FakeIfcFile(not_found=True))
- alignment.remove_pi_edit_empties(1).should_be_called()
- result = subject.exit_pi_edit_mode(ifc, alignment, alignment_id=1, apply=True)
- assert result is True
-
- def test_removes_empties_and_returns_true_when_apply_is_false(self, ifc, alignment):
- entity = make_alignment_entity()
- ifc.get().should_be_called().will_return(FakeIfcFile(entity=entity))
- alignment.remove_pi_edit_empties(1).should_be_called()
- result = subject.exit_pi_edit_mode(ifc, alignment, alignment_id=1, apply=False)
- assert result is True
-
- def test_raises_when_fewer_than_two_pis_collected_on_apply(self, ifc, alignment):
- entity = make_alignment_entity()
- ifc.get().should_be_called().will_return(FakeIfcFile(entity=entity))
- alignment.collect_pis_from_empties(1).should_be_called().will_return(([(0.0, 0.0)], [0.0]))
- with pytest.raises(ValueError, match="At least 2 PIs"):
- subject.exit_pi_edit_mode(ifc, alignment, alignment_id=1, apply=True)
-
- def test_raises_when_no_horizontal_layout_on_apply(self, ifc, alignment):
- entity = make_alignment_entity()
- hpoints = [(0.0, 0.0), (100.0, 0.0)]
- radii = [0.0, 0.0]
- ifc.get().should_be_called().will_return(FakeIfcFile(entity=entity))
- alignment.collect_pis_from_empties(1).should_be_called().will_return((hpoints, radii))
- alignment.get_horizontal_layout(entity).should_be_called().will_return(None)
- with pytest.raises(ValueError, match="no horizontal layout"):
- subject.exit_pi_edit_mode(ifc, alignment, alignment_id=1, apply=True)
-
- def test_applies_new_pis_without_layout_obj_and_returns_true(self, ifc, alignment):
- entity = make_alignment_entity()
- hpoints = [(0.0, 0.0), (100.0, 0.0)]
- radii = [0.0, 0.0]
- ifc.get().should_be_called().will_return(FakeIfcFile(entity=entity))
- alignment.collect_pis_from_empties(1).should_be_called().will_return((hpoints, radii))
- alignment.get_horizontal_layout(entity).should_be_called().will_return("h_layout")
- alignment.remove_pi_edit_empties(1).should_be_called()
- alignment.remove_layout_segment_objects("h_layout").should_be_called()
- alignment.clear_layout_segments("h_layout").should_be_called()
- alignment.layout_by_pi_method("h_layout", hpoints, radii).should_be_called()
- ifc.get_object("h_layout").should_be_called().will_return(None)
- result = subject.exit_pi_edit_mode(ifc, alignment, alignment_id=1, apply=True)
- assert result is True
-
- def test_creates_segment_objects_when_layout_obj_exists(self, ifc, alignment):
- entity = make_alignment_entity()
- hpoints = [(0.0, 0.0), (100.0, 0.0)]
- radii = [0.0, 0.0]
- ifc.get().should_be_called().will_return(FakeIfcFile(entity=entity))
- alignment.collect_pis_from_empties(1).should_be_called().will_return((hpoints, radii))
- alignment.get_horizontal_layout(entity).should_be_called().will_return("h_layout")
- alignment.remove_pi_edit_empties(1).should_be_called()
- alignment.remove_layout_segment_objects("h_layout").should_be_called()
- alignment.clear_layout_segments("h_layout").should_be_called()
- alignment.layout_by_pi_method("h_layout", hpoints, radii).should_be_called()
- ifc.get_object("h_layout").should_be_called().will_return("layout_obj")
- alignment.create_objects_for_layout_segments("h_layout", "layout_obj").should_be_called()
- result = subject.exit_pi_edit_mode(ifc, alignment, alignment_id=1, apply=True)
- assert result is True
-
-
# ---------------------------------------------------------------------------
# import_alignment_csv
# ---------------------------------------------------------------------------
diff --git a/src/bonsai/test/tool/test_alignment.py b/src/bonsai/test/tool/test_alignment.py
index 0318b9aafd..8edd4a4515 100644
--- a/src/bonsai/test/tool/test_alignment.py
+++ b/src/bonsai/test/tool/test_alignment.py
@@ -16,7 +16,6 @@
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see .
-import math
import pytest
import bpy
import ifcopenshell
@@ -51,12 +50,6 @@ requires_geometry_engine = pytest.mark.skipif(
# Helpers
# ---------------------------------------------------------------------------
-TOLERANCE = 1e-9
-
-
-def assert_close(actual: float, expected: float, tol: float = TOLERANCE) -> None:
- assert abs(actual - expected) < tol, f"Expected {expected}, got {actual} (diff={abs(actual-expected):.2e})"
-
class _FakeDesignParams:
"""Minimal stand-in for an IfcAlignmentHorizontalSegment or similar."""
@@ -77,220 +70,6 @@ class _FakeSegment:
self.DesignParameters = design_params
-# ---------------------------------------------------------------------------
-# calculate_pi_geometry
-# ---------------------------------------------------------------------------
-
-
-class TestCalculatePiGeometry(NewFile):
- def test_returns_empty_result_for_empty_pi_list(self):
- result = subject.calculate_pi_geometry([])
- assert result.stations == []
- assert result.total_length == 0.0
-
- def test_returns_single_point_result_for_one_pi(self):
- result = subject.calculate_pi_geometry([(50.0, 100.0)])
- assert len(result.stations) == 1
- assert result.total_length == 0.0
-
- def test_calculates_length_between_two_points(self):
- result = subject.calculate_pi_geometry([(0.0, 0.0), (100.0, 0.0)])
- assert_close(result.total_length, 100.0)
- assert_close(result.lengths[0], 100.0)
-
- def test_calculates_due_east_direction(self):
- result = subject.calculate_pi_geometry([(0.0, 0.0), (100.0, 0.0)])
- assert_close(result.directions[0], 0.0)
-
- def test_calculates_due_north_direction(self):
- result = subject.calculate_pi_geometry([(0.0, 0.0), (0.0, 100.0)])
- assert_close(result.directions[0], math.pi / 2)
-
- def test_calculates_diagonal_length(self):
- result = subject.calculate_pi_geometry([(0.0, 0.0), (3.0, 4.0)])
- assert_close(result.total_length, 5.0)
-
- def test_calculates_stations_for_three_pis(self):
- pis = [(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)]
- result = subject.calculate_pi_geometry(pis)
- assert_close(result.stations[0], 0.0)
- assert_close(result.stations[1], 100.0)
- assert_close(result.stations[2], 200.0)
- assert_close(result.total_length, 200.0)
-
- def test_applies_start_station_offset(self):
- pis = [(0.0, 0.0), (100.0, 0.0)]
- result = subject.calculate_pi_geometry(pis, start_station=1000.0)
- assert_close(result.stations[0], 1000.0)
- assert_close(result.stations[1], 1100.0)
- assert_close(result.total_length, 100.0)
-
- def test_last_pi_has_zero_length_and_direction(self):
- result = subject.calculate_pi_geometry([(0.0, 0.0), (100.0, 0.0)])
- assert_close(result.lengths[-1], 0.0)
- assert_close(result.directions[-1], 0.0)
-
-
-# ---------------------------------------------------------------------------
-# calculate_tangent_length T = R * tan(Δ/2)
-# ---------------------------------------------------------------------------
-
-
-class TestCalculateTangentLength(NewFile):
- def test_returns_zero_for_zero_radius(self):
- assert_close(subject.calculate_tangent_length(0.0, math.pi / 2), 0.0)
-
- def test_returns_zero_for_zero_deflection(self):
- assert_close(subject.calculate_tangent_length(300.0, 0.0), 0.0)
-
- def test_calculates_tangent_for_30_degree_deflection(self):
- deflection = math.radians(30)
- expected = 300.0 * math.tan(deflection / 2)
- assert_close(subject.calculate_tangent_length(300.0, deflection), expected)
-
- def test_calculates_tangent_for_90_degree_deflection(self):
- deflection = math.pi / 2
- expected = 100.0 * math.tan(math.pi / 4) # R * tan(45°) = R
- assert_close(subject.calculate_tangent_length(100.0, deflection), expected)
-
-
-# ---------------------------------------------------------------------------
-# calculate_arc_length L = R * Δ
-# ---------------------------------------------------------------------------
-
-
-class TestCalculateArcLength(NewFile):
- def test_calculates_arc_for_90_degree_curve(self):
- expected = 100.0 * math.pi / 2
- assert_close(subject.calculate_arc_length(100.0, math.pi / 2), expected)
-
- def test_calculates_arc_for_full_circle(self):
- expected = 50.0 * 2 * math.pi
- assert_close(subject.calculate_arc_length(50.0, 2 * math.pi), expected)
-
- def test_zero_radius_yields_zero_length(self):
- assert_close(subject.calculate_arc_length(0.0, math.pi / 2), 0.0)
-
- def test_zero_deflection_yields_zero_length(self):
- assert_close(subject.calculate_arc_length(100.0, 0.0), 0.0)
-
-
-# ---------------------------------------------------------------------------
-# deflection_angle_from_points
-# ---------------------------------------------------------------------------
-
-
-class TestDeflectionAngleFromPoints(NewFile):
- def test_returns_zero_for_straight_alignment(self):
- angle = subject.deflection_angle_from_points((0.0, 0.0), (100.0, 0.0), (200.0, 0.0))
- assert_close(angle, 0.0)
-
- def test_positive_for_90_degree_left_turn(self):
- """Turning left (CCW) is a positive deflection."""
- angle = subject.deflection_angle_from_points((0.0, 0.0), (100.0, 0.0), (100.0, 100.0))
- assert_close(angle, math.pi / 2)
-
- def test_negative_for_90_degree_right_turn(self):
- """Turning right (CW) is a negative deflection."""
- angle = subject.deflection_angle_from_points((0.0, 0.0), (100.0, 0.0), (100.0, -100.0))
- assert_close(angle, -math.pi / 2)
-
- def test_returns_pi_for_u_turn(self):
- """180-degree turn."""
- angle = subject.deflection_angle_from_points((0.0, 0.0), (100.0, 0.0), (0.0, 0.0))
- assert_close(abs(angle), math.pi)
-
- def test_normalises_angle_into_minus_pi_to_pi_range(self):
- """Result must always be in (-π, π]."""
- angle = subject.deflection_angle_from_points((0.0, 0.0), (100.0, 0.0), (50.0, -50.0))
- assert -math.pi < angle <= math.pi
-
-
-# ---------------------------------------------------------------------------
-# arc_length_at_pi
-# ---------------------------------------------------------------------------
-
-
-class TestArcLengthAtPi(NewFile):
- def test_returns_zero_for_zero_radius(self):
- arc = subject.arc_length_at_pi((0.0, 0.0), (100.0, 0.0), (100.0, 100.0), radius=0.0)
- assert_close(arc, 0.0)
-
- def test_returns_zero_for_negative_radius(self):
- arc = subject.arc_length_at_pi((0.0, 0.0), (100.0, 0.0), (100.0, 100.0), radius=-100.0)
- assert_close(arc, 0.0)
-
- def test_calculates_arc_for_90_degree_left_turn(self):
- expected = 100.0 * math.pi / 2
- arc = subject.arc_length_at_pi((0.0, 0.0), (100.0, 0.0), (100.0, 100.0), radius=100.0)
- assert_close(arc, expected)
-
- def test_calculates_arc_for_90_degree_right_turn(self):
- """Sign of deflection should not affect arc length."""
- expected = 100.0 * math.pi / 2
- arc = subject.arc_length_at_pi((0.0, 0.0), (100.0, 0.0), (100.0, -100.0), radius=100.0)
- assert_close(arc, expected)
-
-
-# ---------------------------------------------------------------------------
-# tangent_length_at_pi
-# ---------------------------------------------------------------------------
-
-
-class TestTangentLengthAtPi(NewFile):
- def test_returns_zero_for_zero_radius(self):
- t = subject.tangent_length_at_pi((0.0, 0.0), (100.0, 0.0), (100.0, 100.0), radius=0.0)
- assert_close(t, 0.0)
-
- def test_returns_zero_for_negative_radius(self):
- t = subject.tangent_length_at_pi((0.0, 0.0), (100.0, 0.0), (100.0, 100.0), radius=-100.0)
- assert_close(t, 0.0)
-
- def test_calculates_tangent_for_90_degree_left_turn(self):
- expected = 100.0 * math.tan(math.pi / 4) # R * tan(45°)
- t = subject.tangent_length_at_pi((0.0, 0.0), (100.0, 0.0), (100.0, 100.0), radius=100.0)
- assert_close(t, expected)
-
- def test_matches_calculate_tangent_length_for_same_geometry(self):
- """tangent_length_at_pi must agree with calculate_tangent_length."""
- deflection = abs(subject.deflection_angle_from_points((0.0, 0.0), (100.0, 0.0), (100.0, 100.0)))
- expected = subject.calculate_tangent_length(300.0, deflection)
- actual = subject.tangent_length_at_pi((0.0, 0.0), (100.0, 0.0), (100.0, 100.0), radius=300.0)
- assert_close(actual, expected)
-
-
-# ---------------------------------------------------------------------------
-# tangent_segment_length
-# ---------------------------------------------------------------------------
-
-
-class TestTangentSegmentLength(NewFile):
- def test_returns_full_distance_with_no_tangents(self):
- length = subject.tangent_segment_length((0.0, 0.0), (100.0, 0.0))
- assert_close(length, 100.0)
-
- def test_subtracts_start_tangent(self):
- length = subject.tangent_segment_length((0.0, 0.0), (100.0, 0.0), start_tangent=20.0)
- assert_close(length, 80.0)
-
- def test_subtracts_end_tangent(self):
- length = subject.tangent_segment_length((0.0, 0.0), (100.0, 0.0), end_tangent=30.0)
- assert_close(length, 70.0)
-
- def test_subtracts_both_tangents(self):
- length = subject.tangent_segment_length((0.0, 0.0), (100.0, 0.0), start_tangent=20.0, end_tangent=30.0)
- assert_close(length, 50.0)
-
- def test_clamps_to_zero_when_tangents_exceed_full_distance(self):
- length = subject.tangent_segment_length((0.0, 0.0), (100.0, 0.0), start_tangent=70.0, end_tangent=70.0)
- assert_close(length, 0.0)
-
- def test_works_on_diagonal_leg(self):
- """3-4-5 triangle: full_length=5, minus tangents=2 → 3."""
- length = subject.tangent_segment_length((0.0, 0.0), (3.0, 4.0), start_tangent=1.0, end_tangent=1.0)
- assert_close(length, 3.0)
-
-
# ---------------------------------------------------------------------------
# is_zero_length_segment
# ---------------------------------------------------------------------------
@@ -338,69 +117,6 @@ class TestIsZeroLengthSegment(NewFile):
assert subject.is_zero_length_segment(seg) is True
-# ---------------------------------------------------------------------------
-# layout_has_real_segments
-# ---------------------------------------------------------------------------
-
-
-class _FakeAlignmentSegment:
- """Stand-in for IfcAlignmentSegment: is_a() returns True for IfcAlignmentSegment."""
-
- def __init__(self, design_params=None):
- self.DesignParameters = design_params
-
- def is_a(self, ifc_class: str) -> bool:
- return ifc_class == "IfcAlignmentSegment"
-
-
-class _FakeRelNests:
- def __init__(self, related_objects):
- self.RelatedObjects = related_objects
-
-
-class _FakeLayout:
- def __init__(self, rels=None):
- self.IsNestedBy = rels or []
-
-
-class TestLayoutHasRealSegments(NewFile):
- def test_returns_false_for_layout_with_no_nested_relationships(self):
- layout = _FakeLayout(rels=[])
- assert subject.layout_has_real_segments(layout) is False
-
- def test_returns_false_for_layout_with_empty_related_objects(self):
- layout = _FakeLayout(rels=[_FakeRelNests(related_objects=[])])
- assert subject.layout_has_real_segments(layout) is False
-
- def test_returns_false_when_only_segment_is_zero_length_terminator(self):
- dp = _FakeDesignParams("IfcAlignmentHorizontalSegment", segment_length=0.0)
- terminator = _FakeAlignmentSegment(dp)
- layout = _FakeLayout(rels=[_FakeRelNests([terminator])])
- assert subject.layout_has_real_segments(layout) is False
-
- def test_returns_true_when_one_real_segment_exists(self):
- dp = _FakeDesignParams("IfcAlignmentHorizontalSegment", segment_length=100.0)
- real_seg = _FakeAlignmentSegment(dp)
- layout = _FakeLayout(rels=[_FakeRelNests([real_seg])])
- assert subject.layout_has_real_segments(layout) is True
-
- def test_returns_true_when_real_segment_follows_terminator(self):
- dp_zero = _FakeDesignParams("IfcAlignmentHorizontalSegment", segment_length=0.0)
- dp_real = _FakeDesignParams("IfcAlignmentHorizontalSegment", segment_length=50.0)
- layout = _FakeLayout(rels=[_FakeRelNests([_FakeAlignmentSegment(dp_zero), _FakeAlignmentSegment(dp_real)])])
- assert subject.layout_has_real_segments(layout) is True
-
- def test_ignores_non_alignment_segment_objects(self):
- """Non-IfcAlignmentSegment objects in RelatedObjects should be ignored."""
-
- class _FakeOtherObject:
- def is_a(self, ifc_class):
- return False
-
- layout = _FakeLayout(rels=[_FakeRelNests([_FakeOtherObject()])])
- assert subject.layout_has_real_segments(layout) is False
-
-
# ---------------------------------------------------------------------------
# safe_layout_horizontal_by_pi_method
# ---------------------------------------------------------------------------
@@ -491,108 +207,6 @@ class TestGetHorizontalLayout(NewIfc4X3):
assert h_layout is None
-# ---------------------------------------------------------------------------
-# layout_by_pi_method (IFC + tool.Ifc integration)
-# ---------------------------------------------------------------------------
-
-
-@requires_geometry_engine
-class TestLayoutByPiMethod(NewIfc4X3):
- """Tests for Alignment.layout_by_pi_method() — IFC segment creation."""
-
- def test_creates_ifc_segments_for_straight_alignment(self):
- ifc_file = tool.Ifc.get()
- alignment = align_api.create(ifc_file, name="Straight")
- h_layout = subject.get_horizontal_layout(alignment)
-
- subject.layout_by_pi_method(h_layout, [(0.0, 0.0), (1000.0, 0.0)], [])
-
- segments = align_api.get_layout_segments(h_layout)
- real_segments = [s for s in segments if not subject.is_zero_length_segment(s)]
- assert len(real_segments) >= 1 # At least one tangent
-
- def test_creates_arc_segment_for_curve(self):
- ifc_file = tool.Ifc.get()
- alignment = align_api.create(ifc_file, name="Curve")
- h_layout = subject.get_horizontal_layout(alignment)
-
- subject.layout_by_pi_method(
- h_layout, [(0.0, 0.0), (500.0, 0.0), (1000.0, 200.0)], [300.0]
- )
-
- segments = align_api.get_layout_segments(h_layout)
- real_segments = [s for s in segments if not subject.is_zero_length_segment(s)]
- segment_types = [s.DesignParameters.PredefinedType for s in real_segments if s.DesignParameters]
- assert "LINE" in segment_types
- assert "CIRCULARARC" in segment_types
-
-
-# ---------------------------------------------------------------------------
-# back_calculate_pis_from_alignment (IFC + unit conversion)
-# ---------------------------------------------------------------------------
-
-
-@requires_geometry_engine
-class TestBackCalculatePisFromAlignment(NewIfc4X3):
- """Tests for Alignment.back_calculate_pis_from_alignment() — PI recovery."""
-
- def test_recovers_endpoints_from_straight_alignment(self):
- alignment, _ = _create_alignment_with_pis(
- hpoints=[(0.0, 0.0), (1000.0, 0.0)], radii=[]
- )
- pis = subject.back_calculate_pis_from_alignment(alignment)
- assert len(pis) >= 2
- assert pis[0]["pi_type"] == "ENDPOINT"
- assert pis[-1]["pi_type"] == "ENDPOINT"
- assert_close(pis[0]["e"], 0.0, tol=0.01)
- assert_close(pis[0]["n"], 0.0, tol=0.01)
- assert_close(pis[-1]["e"], 1000.0, tol=0.01)
- assert_close(pis[-1]["n"], 0.0, tol=0.01)
-
- def test_recovers_curve_pi_with_radius(self):
- alignment, _ = _create_alignment_with_pis(
- hpoints=[(0.0, 0.0), (500.0, 0.0), (1000.0, 200.0)], radii=[300.0]
- )
- pis = subject.back_calculate_pis_from_alignment(alignment)
- # Should have 3 PIs: start endpoint, curve PI, end endpoint
- assert len(pis) == 3
- curve_pis = [p for p in pis if p["pi_type"] == "CURVE"]
- assert len(curve_pis) == 1
- assert_close(curve_pis[0]["e"], 500.0, tol=1.0)
- assert_close(curve_pis[0]["n"], 0.0, tol=1.0)
- assert curve_pis[0]["radius"] > 0
-
- def test_raises_for_alignment_without_horizontal_layout(self):
- ifc_file = tool.Ifc.get()
- alignment = ifc_file.createIfcAlignment(
- GlobalId=ifcopenshell.guid.new(), Name="Bare"
- )
- with pytest.raises(ValueError, match="no horizontal layout"):
- subject.back_calculate_pis_from_alignment(alignment)
-
- def test_raises_for_alignment_with_only_terminator(self):
- ifc_file = tool.Ifc.get()
- alignment = align_api.create(ifc_file, name="EmptyLayout")
- # align_api.create() produces a horizontal layout with only a zero-length terminator
- with pytest.raises(ValueError, match="no real segments"):
- subject.back_calculate_pis_from_alignment(alignment)
-
- def test_roundtrip_preserves_pi_positions(self):
- """Create alignment from PIs, back-calculate, verify positions match."""
- original_hpoints = [(0.0, 0.0), (500.0, 0.0), (1000.0, 200.0)]
- original_radii = [300.0]
- alignment, _ = _create_alignment_with_pis(
- hpoints=original_hpoints, radii=original_radii
- )
-
- recovered_pis = subject.back_calculate_pis_from_alignment(alignment)
- assert len(recovered_pis) == len(original_hpoints)
-
- for original, recovered in zip(original_hpoints, recovered_pis):
- assert_close(recovered["e"], original[0], tol=1.0)
- assert_close(recovered["n"], original[1], tol=1.0)
-
-
# ---------------------------------------------------------------------------
# Blender Object Creation Methods
# ---------------------------------------------------------------------------
@@ -704,149 +318,6 @@ class TestGetActiveAlignment(NewIfc4X3):
assert result.id() == alignment.id()
-# ---------------------------------------------------------------------------
-# PI Edit Empties
-# ---------------------------------------------------------------------------
-
-
-@requires_geometry_engine
-class TestCreatePiEditEmpties(NewIfc4X3):
- """Tests for Alignment.create_pi_edit_empties()."""
-
- def test_creates_empties_at_pi_positions(self):
- alignment, _ = _create_alignment_with_pis(
- hpoints=[(0.0, 0.0), (500.0, 0.0), (1000.0, 200.0)], radii=[300.0]
- )
- alignment_obj = subject.create_hierarchy_for_alignment(alignment)
- bpy.context.view_layer.objects.active = alignment_obj
-
- pis = subject.back_calculate_pis_from_alignment(alignment)
- empties = subject.create_pi_edit_empties(alignment, pis)
-
- assert len(empties) == len(pis)
- for empty in empties:
- assert empty.type == "EMPTY"
- assert empty.get("civil_is_pi_empty") is True
- assert empty.get("civil_alignment_id") == alignment.id()
-
- def test_empties_are_parented_to_alignment_object(self):
- alignment, _ = _create_alignment_with_pis(
- hpoints=[(0.0, 0.0), (500.0, 0.0)], radii=[]
- )
- alignment_obj = subject.create_hierarchy_for_alignment(alignment)
- pis = subject.back_calculate_pis_from_alignment(alignment)
- empties = subject.create_pi_edit_empties(alignment, pis)
- for empty in empties:
- assert empty.parent == alignment_obj
-
- def test_empties_have_sequential_pi_indices(self):
- alignment, _ = _create_alignment_with_pis(
- hpoints=[(0.0, 0.0), (500.0, 0.0), (1000.0, 200.0)], radii=[300.0]
- )
- alignment_obj = subject.create_hierarchy_for_alignment(alignment)
- pis = subject.back_calculate_pis_from_alignment(alignment)
- empties = subject.create_pi_edit_empties(alignment, pis)
- indices = [e.get("civil_pi_index") for e in empties]
- assert indices == list(range(len(pis)))
-
-
-@requires_geometry_engine
-class TestGetPiEditEmpties(NewIfc4X3):
- """Tests for Alignment.get_pi_edit_empties()."""
-
- def test_finds_empties_for_given_alignment_id(self):
- alignment, _ = _create_alignment_with_pis(
- hpoints=[(0.0, 0.0), (500.0, 0.0)], radii=[]
- )
- alignment_obj = subject.create_hierarchy_for_alignment(alignment)
- pis = subject.back_calculate_pis_from_alignment(alignment)
- subject.create_pi_edit_empties(alignment, pis)
-
- found = subject.get_pi_edit_empties(alignment.id())
- assert len(found) == len(pis)
-
- def test_returns_empty_list_when_no_empties_exist(self):
- found = subject.get_pi_edit_empties(99999)
- assert found == []
-
- def test_returns_sorted_by_pi_index(self):
- alignment, _ = _create_alignment_with_pis(
- hpoints=[(0.0, 0.0), (500.0, 0.0), (1000.0, 200.0)], radii=[300.0]
- )
- alignment_obj = subject.create_hierarchy_for_alignment(alignment)
- pis = subject.back_calculate_pis_from_alignment(alignment)
- subject.create_pi_edit_empties(alignment, pis)
-
- found = subject.get_pi_edit_empties(alignment.id())
- indices = [e.get("civil_pi_index") for e in found]
- assert indices == sorted(indices)
-
-
-@requires_geometry_engine
-class TestRemovePiEditEmpties(NewIfc4X3):
- """Tests for Alignment.remove_pi_edit_empties()."""
-
- def test_removes_all_empties_for_alignment(self):
- alignment, _ = _create_alignment_with_pis(
- hpoints=[(0.0, 0.0), (500.0, 0.0), (1000.0, 200.0)], radii=[300.0]
- )
- alignment_obj = subject.create_hierarchy_for_alignment(alignment)
- pis = subject.back_calculate_pis_from_alignment(alignment)
- subject.create_pi_edit_empties(alignment, pis)
-
- removed = subject.remove_pi_edit_empties(alignment.id())
- assert removed == len(pis)
- assert subject.get_pi_edit_empties(alignment.id()) == []
-
- def test_returns_zero_when_no_empties_exist(self):
- removed = subject.remove_pi_edit_empties(99999)
- assert removed == 0
-
-
-@requires_geometry_engine
-class TestCollectPisFromEmpties(NewIfc4X3):
- """Tests for Alignment.collect_pis_from_empties() — reading positions back."""
-
- def test_roundtrip_positions_through_empties(self):
- """Create empties from PIs, collect back, verify positions match."""
- alignment, _ = _create_alignment_with_pis(
- hpoints=[(0.0, 0.0), (500.0, 0.0), (1000.0, 200.0)], radii=[300.0]
- )
- alignment_obj = subject.create_hierarchy_for_alignment(alignment)
-
- pis = subject.back_calculate_pis_from_alignment(alignment)
- subject.create_pi_edit_empties(alignment, pis)
-
- # Force Blender to update transforms (empties are parented)
- bpy.context.view_layer.update()
-
- hpoints_back, radii_back = subject.collect_pis_from_empties(alignment.id())
- assert len(hpoints_back) == len(pis)
-
- # Positions should round-trip: empties created from pis, collected back
- for pi, (back_e, back_n) in zip(pis, hpoints_back):
- assert_close(back_e, pi["e"], tol=2.0) # Generous tolerance for georef
- assert_close(back_n, pi["n"], tol=2.0)
-
- def test_collects_radii_for_interior_pis_only(self):
- alignment, _ = _create_alignment_with_pis(
- hpoints=[(0.0, 0.0), (500.0, 0.0), (1000.0, 200.0)], radii=[300.0]
- )
- alignment_obj = subject.create_hierarchy_for_alignment(alignment)
- pis = subject.back_calculate_pis_from_alignment(alignment)
- subject.create_pi_edit_empties(alignment, pis)
-
- _, radii_back = subject.collect_pis_from_empties(alignment.id())
- # Radii should have one entry (for the interior PI)
- assert len(radii_back) == 1
- assert radii_back[0] > 0
-
- def test_returns_empty_when_fewer_than_two_empties(self):
- hpoints, radii = subject.collect_pis_from_empties(99999)
- assert hpoints == []
- assert radii == []
-
-
# ---------------------------------------------------------------------------
# Remove alignment hierarchy
# ---------------------------------------------------------------------------
@@ -924,6 +395,11 @@ class TestIfcSaveReloadRoundtrip(NewIfc4X3):
@requires_geometry_engine
+def _has_real_segments(layout) -> bool:
+ """Whether ``layout`` has any segment beyond the zero-length terminator."""
+ return any(not subject.is_zero_length_segment(s) for s in align_api.get_layout_segments(layout))
+
+
class TestClearLayoutSegments(NewFile):
"""The alignment API exposes no segment-clearing helper and its layout
functions only append, so editing relies on tool.Alignment.clear_layout_segments.
@@ -948,9 +424,9 @@ class TestClearLayoutSegments(NewFile):
align_api.layout_horizontal_alignment_by_pi_method(
ifc, h, hpoints=[(0.0, 0.0), (500.0, 0.0), (1000.0, 200.0)], radii=[300.0]
)
- assert subject.layout_has_real_segments(h) is True
+ assert _has_real_segments(h) is True
subject.clear_layout_segments(h)
- assert subject.layout_has_real_segments(h) is False
+ assert _has_real_segments(h) is False
assert len(align_api.get_layout_segments(h)) == 1 # terminator only
def test_relayout_after_clear_has_no_doubling_or_orphans(self):
@@ -979,32 +455,9 @@ class TestClearLayoutSegments(NewFile):
align_api.layout_vertical_alignment_by_pi_method(
ifc, v, [(0.0, 100.0), (500.0, 110.0), (1000.0, 100.0)], [100.0]
)
- assert subject.layout_has_real_segments(v) is True
+ assert _has_real_segments(v) is True
subject.clear_layout_segments(v)
- assert subject.layout_has_real_segments(v) is False
-
-
-@requires_geometry_engine
-class TestSetLayoutSegmentsSelectable(NewIfc4X3):
- """PI edit mode disables segment-curve selection so clicks hit the PI
- empties; set_layout_segments_selectable toggles hide_select accordingly."""
-
- def test_toggles_segment_hide_select(self):
- ifc_file = tool.Ifc.get()
- alignment = align_api.create(ifc_file, name="Sel", include_vertical=False)
- h = align_api.get_horizontal_layout(alignment)
- align_api.layout_horizontal_alignment_by_pi_method(
- ifc_file, h, hpoints=[(0.0, 0.0), (500.0, 0.0), (1000.0, 200.0)], radii=[300.0]
- )
- subject.create_hierarchy_for_alignment(alignment)
- segment_objects = [o for o in bpy.data.objects if "IfcAlignmentSegment" in o.name]
- assert len(segment_objects) >= 1
-
- subject.set_layout_segments_selectable(h, False)
- assert all(o.hide_select for o in segment_objects)
-
- subject.set_layout_segments_selectable(h, True)
- assert all(not o.hide_select for o in segment_objects)
+ assert _has_real_segments(v) is False
class TestFormatStation(NewFile):