Removed CIVIL prototyping

This commit is contained in:
Richard Brice
2026-09-13 11:25:54 -07:00
parent 45caff3558
commit b8d8dcf6ba
20 changed files with 47 additions and 3848 deletions
+1 -2
View File
@@ -184,8 +184,7 @@ classes = [
ui.BIM_PT_tab_materials, ui.BIM_PT_tab_materials,
ui.BIM_PT_tab_styles, ui.BIM_PT_tab_styles,
ui.BIM_PT_tab_profiles, ui.BIM_PT_tab_profiles,
# Civil infrastructure # Alignments
ui.BIM_PT_tab_horizontal_alignment,
ui.BIM_PT_tab_alignments, ui.BIM_PT_tab_alignments,
# Drawings and documents # Drawings and documents
ui.BIM_PT_tab_sheets, ui.BIM_PT_tab_sheets,
@@ -17,7 +17,7 @@
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>. # along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import bpy import bpy
from . import ui, prop, operator, decorator, workspace from . import ui, prop, operator, decorator
_last_active_ptr: int = 0 _last_active_ptr: int = 0
_last_profile_alignment_id: int = 0 # tracks which alignment the profile was last built for _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 = ( classes = (
# Property groups (must be registered before classes that use them) # Property groups (must be registered before classes that use them)
prop.AlignmentPI,
prop.AlignmentDisplayRow,
prop.VerticalAlignmentItem, prop.VerticalAlignmentItem,
prop.CantAlignmentItem, prop.CantAlignmentItem,
prop.CivilAlignmentProperties, prop.CivilAlignmentProperties,
prop.PICurveMarkerProperties, prop.PICurveMarkerProperties,
# UILists and section-toggle operators # UILists and section-toggle operators
ui.ALIGN_UL_alignment_pis,
ui.ALIGN_OT_toggle_h_segments, ui.ALIGN_OT_toggle_h_segments,
ui.ALIGN_OT_toggle_v_segments, ui.ALIGN_OT_toggle_v_segments,
ui.ALIGN_OT_toggle_cant_segments, ui.ALIGN_OT_toggle_cant_segments,
operator.ImportAlignmentCSV, 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 # Operators - Vertical Profile Window
operator.ALIGN_OT_show_vertical_profile, operator.ALIGN_OT_show_vertical_profile,
# Operators - Segment Selection # Operators - Segment Selection
operator.ALIGN_OT_select_h_segment, operator.ALIGN_OT_select_h_segment,
operator.ALIGN_OT_select_v_segment, operator.ALIGN_OT_select_v_segment,
operator.ALIGN_OT_select_cant_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) # Operators - Alignments tab authoring workflow (Add Element + interactive draw)
operator.ALIGN_OT_add_alignment, operator.ALIGN_OT_add_alignment,
operator.ALIGN_OT_remove_alignment, operator.ALIGN_OT_remove_alignment,
@@ -147,10 +130,6 @@ classes = (
operator.ALIGN_OT_apply_pi_curve, operator.ALIGN_OT_apply_pi_curve,
operator.ALIGN_OT_clear_pi_markers, operator.ALIGN_OT_clear_pi_markers,
operator.ALIGN_OT_draw_horizontal_alignment, 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 Panels (appear in Properties sidebar under ALIGNMENTS tab)
ui.ALIGN_PT_alignment_authoring, ui.ALIGN_PT_alignment_authoring,
ui.ALIGN_PT_alignment_stationing_authoring, ui.ALIGN_PT_alignment_stationing_authoring,
@@ -163,12 +142,6 @@ def menu_func_import(self, context):
def register(): 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.Scene.CivilAlignmentProperties = bpy.props.PointerProperty(type=prop.CivilAlignmentProperties)
bpy.types.Object.bonsai_pi_curve_marker = bpy.props.PointerProperty(type=prop.PICurveMarkerProperties) bpy.types.Object.bonsai_pi_curve_marker = bpy.props.PointerProperty(type=prop.PICurveMarkerProperties)
bpy.types.TOPBAR_MT_file_import.append(menu_func_import) bpy.types.TOPBAR_MT_file_import.append(menu_func_import)
@@ -195,8 +168,6 @@ def unregister():
pass pass
VerticalProfileDecorator.is_installed = False VerticalProfileDecorator.is_installed = False
VerticalProfileDecorator.handlers = [] VerticalProfileDecorator.handlers = []
if not bpy.app.background:
bpy.utils.unregister_tool(workspace.AlignmentTool)
bpy.types.TOPBAR_MT_file_import.remove(menu_func_import) bpy.types.TOPBAR_MT_file_import.remove(menu_func_import)
del bpy.types.Scene.CivilAlignmentProperties del bpy.types.Scene.CivilAlignmentProperties
del bpy.types.Object.bonsai_pi_curve_marker del bpy.types.Object.bonsai_pi_curve_marker
@@ -19,7 +19,8 @@
"""Alignment module decorators for GPU visualization. """Alignment module decorators for GPU visualization.
This module contains decorators for rendering visual feedback during 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 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 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: class AlignmentSegmentDecorator:
"""Decorator that highlights a selected horizontal alignment segment in the 3D viewport. """Decorator that highlights a selected horizontal alignment segment in the 3D viewport.
File diff suppressed because it is too large Load Diff
@@ -124,17 +124,6 @@ def _on_ve_update(self, context):
VerticalProfileDecorator.tag_redraw() VerticalProfileDecorator.tag_redraw()
def _on_radius_update(self, context):
"""Callback when radius property changes.
This dynamically imports the operator module to call on_radius_changed,
avoiding circular imports since prop.py is imported before operator.py.
"""
from . import operator as ops
ops.on_radius_changed(self, context)
class CantAlignmentItem(PropertyGroup): class CantAlignmentItem(PropertyGroup):
"""Tracks one IfcAlignmentCant available in the profile view.""" """Tracks one IfcAlignmentCant available in the profile view."""
@@ -153,128 +142,9 @@ class CantAlignmentItem(PropertyGroup):
) )
class AlignmentPI(PropertyGroup):
"""Property group for a single PI (Point of Intersection)
In the PI method, alignments are defined by:
- Endpoint PIs: Start (POB) and End (POE) points
- Interior PIs: Points where tangents intersect, optionally with curves
"""
# Coordinates stored as global easting/northing (map coordinates).
# Coordinate flow: Blender coords -> 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): class CivilAlignmentProperties(PropertyGroup):
"""Properties for the alignment module""" """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) # Alignment selector dropdown (top-level alignments only)
active_alignment_id_str: EnumProperty( active_alignment_id_str: EnumProperty(
name="Alignment", name="Alignment",
@@ -284,35 +154,6 @@ class CivilAlignmentProperties(PropertyGroup):
default=0, 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 profile window settings
vertical_exaggeration: FloatProperty( vertical_exaggeration: FloatProperty(
name="Vertical Exaggeration", name="Vertical Exaggeration",
@@ -371,19 +212,6 @@ class CivilAlignmentProperties(PropertyGroup):
default=True, 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): class PICurveMarkerProperties(PropertyGroup):
"""Tags a transient Empty object placed at an interior PI while its """Tags a transient Empty object placed at an interior PI while its
+7 -236
View File
@@ -19,8 +19,8 @@
"""UI panels for the alignment module """UI panels for the alignment module
All panels appear in the Properties sidebar under the CIVIL tab, All panels appear in the Properties sidebar under the Alignments tab,
nested under BIM_PT_tab_horizontal_alignment. nested under BIM_PT_tab_alignments.
""" """
import bpy import bpy
@@ -28,7 +28,7 @@ import math
import ifcopenshell.api.alignment import ifcopenshell.api.alignment
import ifcopenshell.util.geolocation import ifcopenshell.util.geolocation
import bonsai.tool as tool 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 bpy.props import IntProperty, BoolProperty
from .prop import _alignment_enum_items from .prop import _alignment_enum_items
from .operator import _find_pi_markers, _resolve_alignment_id_for_markers, _is_interior_pi_marker 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"} 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 # 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): class ALIGN_PT_alignment_authoring(Panel):
"""Add an alignment and draw its horizontal geometry — Alignments tab. """Add an alignment and draw its horizontal geometry — Alignments tab.
This is a from-scratch authoring workflow, independent of the CIVIL tab's Add a bare alignment, then draw its horizontal geometry directly in the
PI-table tools: add a bare alignment, then draw its horizontal geometry viewport.
directly in the viewport.
""" """
bl_label = "Add Alignment" bl_label = "Add Alignment"
@@ -438,9 +210,8 @@ class ALIGN_PT_alignment_authoring(Panel):
class ALIGN_PT_alignment_stationing_authoring(Panel): class ALIGN_PT_alignment_stationing_authoring(Panel):
"""Start station and station equations — Alignments tab. """Start station and station equations — Alignments tab.
A from-scratch equivalent of the CIVIL tab's stationing panel: edit the Edit the start station, and add/remove additional stationing referents
start station, and add/remove additional stationing referents (station (station equations) for gaps, overlaps, or reversed stationing direction.
equations) for gaps, overlaps, or reversed stationing direction.
""" """
bl_label = "Stationing" bl_label = "Stationing"
@@ -1,72 +0,0 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2020, 2021 Dion Moult <dion@thinkmoult.com>, 2026 Michael Yoder <myoder@desertspringscivil.com>
#
# 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 <http://www.gnu.org/licenses/>.
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")
@@ -135,11 +135,6 @@ class IfcClassData:
("EMPTY", "No Geometry", "Start with an empty object"), ("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"): if ifc_class in ("IfcWindowType", "IfcWindowStyle", "IfcWindow"):
templates.extend([None, ("WINDOW", "Window", "Parametric window")]) templates.extend([None, ("WINDOW", "Window", "Parametric window")])
elif ifc_class in ("IfcDoorType", "IfcDoorStyle", "IfcDoor"): elif ifc_class in ("IfcDoorType", "IfcDoorStyle", "IfcDoor"):
@@ -575,29 +575,6 @@ class AddElement(bpy.types.Operator, tool.Ifc.Operator):
) )
element.Description = props.description or None 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: if representation_template == "EMTPY" or not ifc_context:
pass pass
elif representation_template == "OBJ" and props.representation_obj: elif representation_template == "OBJ" and props.representation_obj:
-1
View File
@@ -530,7 +530,6 @@ def get_tab(
("PROJECT", "Project Overview", "", bonsai.bim.icons[icon_key].icon_id, 0), ("PROJECT", "Project Overview", "", bonsai.bim.icons[icon_key].icon_id, 0),
("OBJECT", "Object Information", "", "FILE_3D", 1), ("OBJECT", "Object Information", "", "FILE_3D", 1),
("GEOMETRY", "Geometry and Materials", "", "MATERIAL", 2), ("GEOMETRY", "Geometry and Materials", "", "MATERIAL", 2),
("CIVIL", "Civil Infrastructure", "", "CURVE_DATA", 11),
("ALIGNMENTS", "Alignments", "", "ANIM_DATA", 12), ("ALIGNMENTS", "Alignments", "", "ANIM_DATA", 12),
("DRAWINGS", "Drawings and Documents", "", "DOCUMENTS", 3), ("DRAWINGS", "Drawings and Documents", "", "DOCUMENTS", 3),
("SERVICES", "Services and Systems", "", "NETWORK_DRIVE", 4), ("SERVICES", "Services and Systems", "", "NETWORK_DRIVE", 4),
-20
View File
@@ -1640,25 +1640,6 @@ class BIM_PT_tab_profiles(Panel):
pass 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): class BIM_PT_tab_alignments(Panel):
bl_idname = "BIM_PT_tab_alignments" bl_idname = "BIM_PT_tab_alignments"
bl_label = "Alignments" bl_label = "Alignments"
@@ -1863,7 +1844,6 @@ class UIData:
("PROJECT", bonsai.bim.icons[f"{color_mode}_ifc"].icon_id, True), ("PROJECT", bonsai.bim.icons[f"{color_mode}_ifc"].icon_id, True),
("OBJECT", "FILE_3D", is_ifc_project), ("OBJECT", "FILE_3D", is_ifc_project),
("GEOMETRY", "MATERIAL", is_ifc_project), ("GEOMETRY", "MATERIAL", is_ifc_project),
("CIVIL", "CURVE_DATA", is_ifc_project),
("ALIGNMENTS", "ANIM_DATA", is_ifc_project), ("ALIGNMENTS", "ANIM_DATA", is_ifc_project),
("DRAWINGS", "DOCUMENTS", is_ifc_project), ("DRAWINGS", "DOCUMENTS", is_ifc_project),
("SERVICES", "NETWORK_DRIVE", is_ifc_project), ("SERVICES", "NETWORK_DRIVE", is_ifc_project),
-145
View File
@@ -78,70 +78,6 @@ def create_alignment(
return alignment_tool.create_alignment(name.strip(), start_station) 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( def import_alignment_csv(
ifc_tool: "type[tool.Ifc]", ifc_tool: "type[tool.Ifc]",
alignment_tool: "type[tool.Alignment]", alignment_tool: "type[tool.Alignment]",
@@ -183,84 +119,3 @@ def import_alignment_csv(
alignment_tool.create_objects_for_referents(alignment) alignment_tool.create_objects_for_referents(alignment)
return 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
-7
View File
@@ -1290,17 +1290,10 @@ class Web:
class Alignment: class Alignment:
# Alignment creation # Alignment creation
def create_alignment(cls, name, start_station=0.0): pass 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 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_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 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_layout_segment_objects(cls, h_layout): pass
def remove_pi_edit_empties(cls, alignment_id): pass
# Stationing # Stationing
def format_station(cls, station): pass def format_station(cls, station): pass
# CSV import # CSV import
+9 -630
View File
@@ -36,27 +36,11 @@ import bonsai.bim.import_ifc
import ifcopenshell.api.alignment import ifcopenshell.api.alignment
import ifcopenshell.util.shape import ifcopenshell.util.shape
from typing import TYPE_CHECKING, Optional, List, Tuple from typing import TYPE_CHECKING, Optional, List, Tuple
from dataclasses import dataclass
if TYPE_CHECKING: if TYPE_CHECKING:
import ifcopenshell 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: class Alignment:
"""Tool class for alignment-related Blender operations. """Tool class for alignment-related Blender operations.
@@ -64,288 +48,6 @@ class Alignment:
that can be called without instantiation. 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) # IFC API Wrappers (for core layer delegation)
# ========================================================================= # =========================================================================
@@ -374,9 +76,8 @@ class Alignment:
its (still segment-less) horizontal layout: create_hierarchy_for_alignment() its (still segment-less) horizontal layout: create_hierarchy_for_alignment()
would create that eagerly, leaving a stray "Layout" object with no would create that eagerly, leaving a stray "Layout" object with no
segments in the scene before anything has actually been drawn, unlike segments in the scene before anything has actually been drawn, unlike
a loaded file which never has one until it's meaningful. Whichever a loaded file which never has one until it's meaningful. The
drawing flow adds real segments next (CIVIL's PI picker or the Alignments tab's draw tool creates the layout object lazily, once
Alignments tab's draw tool) creates the layout object lazily, once
there's something to show. there's something to show.
Args: Args:
@@ -514,20 +215,6 @@ class Alignment:
for segment in dropped_segments: for segment in dropped_segments:
ifcopenshell.api.root.remove_product(ifc_file, product=segment) 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 # Zero-Length Segment Utilities
# ========================================================================= # =========================================================================
@@ -560,25 +247,6 @@ class Alignment:
return False 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 # Blender Object Creation
# ========================================================================= # =========================================================================
@@ -629,15 +297,13 @@ class Alignment:
IFC product's representation is: this is what loading an alignment IFC product's representation is: this is what loading an alignment
from a file produces. It deliberately does NOT create separate from a file produces. It deliberately does NOT create separate
objects for the nested IfcAlignmentHorizontal/Vertical/Cant layouts objects for the nested IfcAlignmentHorizontal/Vertical/Cant layouts
or their IfcAlignmentSegments interactive creation used to (via or their IfcAlignmentSegments, unlike create_object_for_layout /
create_object_for_layout/create_objects_for_layout_segments, ported create_objects_for_layout_segments (still used by CSV import's
from the CIVIL tab's segment-selection UI), which left the scene hierarchy build), which would leave the scene collection looking
collection looking different from a loaded file for no IFC-side different from a loaded file for no IFC-side reason. This one is
reason. Callers that still want per-segment objects for for the Alignments tab's authoring workflow, which shows segments
selection/highlighting (CIVIL's tools) should keep using those via ALIGN_PT_alignment_segments instead of individual viewport
functions directly this one is for the Alignments tab's authoring objects.
workflow, which shows segments via ALIGN_PT_alignment_segments
instead of individual viewport objects.
Args: Args:
alignment: The IFC alignment entity, with a representation already alignment: The IFC alignment entity, with a representation already
@@ -1232,26 +898,6 @@ class Alignment:
return f"{float(station):.2f}" return f"{float(station):.2f}"
return ifcopenshell.util.alignment.station_as_string(ifc_file, float(station)) 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 @classmethod
def _remove_blender_object(cls, obj: bpy.types.Object) -> bool: def _remove_blender_object(cls, obj: bpy.types.Object) -> bool:
"""Safely remove a Blender object and its data. """Safely remove a Blender object and its data.
@@ -1408,273 +1054,6 @@ class Alignment:
return True 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 @classmethod
def get_active_alignment(cls) -> ifcopenshell.entity_instance | None: def get_active_alignment(cls) -> ifcopenshell.entity_instance | None:
if obj := tool.Blender.get_active_object(): if obj := tool.Blender.get_active_object():
-4
View File
@@ -488,8 +488,4 @@ class Root(bonsai.core.tool.Root):
"IfcAnnotation", "IfcAnnotation",
"IfcRelSpaceBoundary", "IfcRelSpaceBoundary",
) )
if version != "IFC4":
# IFC4X3+: alignments are created like any other element
# (Saikei); the create flow bootstraps the horizontal layout.
products += ("IfcAlignment",)
return products return products
-1
View File
@@ -8,7 +8,6 @@ markers =
boundary boundary
brick brick
bsdd bsdd
civil
clash clash
classification classification
clip_box clip_box
@@ -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). Follows Bonsai's existing test patterns (NewIfc4X3 base class from bootstrap).
Operators tested: 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) CSV import: import_alignment_csv (EXEC_DEFAULT with explicit filepath)
Operators skipped (modal / viewport):
pick_pi_from_viewport, enter_pi_edit_mode
""" """
import pytest import pytest
@@ -37,7 +32,6 @@ import ifcopenshell
import ifcopenshell.api.alignment as align_api import ifcopenshell.api.alignment as align_api
import bonsai.tool as tool import bonsai.tool as tool
from bonsai.bim.ifc import IfcStore
from test.bim.bootstrap import NewIfc4X3 from test.bim.bootstrap import NewIfc4X3
@@ -64,514 +58,6 @@ requires_geometry_engine = pytest.mark.skipif(
pytestmark = pytest.mark.alignment 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 @requires_geometry_engine
class TestImportAlignmentCsv(NewIfc4X3): class TestImportAlignmentCsv(NewIfc4X3):
"""bim.import_alignment_csv — the single, merged CSV import path. """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) result = bpy.ops.bim.import_alignment_csv("EXEC_DEFAULT", filepath=filepath)
assert result == {"FINISHED"} assert result == {"FINISHED"}
props = get_alignment_props() alignment = tool.Alignment.get_active_alignment()
assert props.active_alignment_id != 0 assert alignment is not None
alignment = tool.Ifc.get().by_id(props.active_alignment_id)
assert alignment.is_a("IfcAlignment") assert alignment.is_a("IfcAlignment")
assert tool.Ifc.get_object(alignment) is not None 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) result = bpy.ops.bim.import_alignment_csv("EXEC_DEFAULT", filepath=filepath)
assert result == {"FINISHED"} assert result == {"FINISHED"}
props = get_alignment_props() alignment = tool.Alignment.get_active_alignment()
alignment = tool.Ifc.get().by_id(props.active_alignment_id)
assert align_api.get_vertical_layout(alignment) is not None 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"
-175
View File
@@ -22,181 +22,6 @@ import bonsai.core.alignment as subject
from test.core.bootstrap import alignment, ifc 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 # import_alignment_csv
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+9 -556
View File
@@ -16,7 +16,6 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>. # along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import math
import pytest import pytest
import bpy import bpy
import ifcopenshell import ifcopenshell
@@ -51,12 +50,6 @@ requires_geometry_engine = pytest.mark.skipif(
# Helpers # 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: class _FakeDesignParams:
"""Minimal stand-in for an IfcAlignmentHorizontalSegment or similar.""" """Minimal stand-in for an IfcAlignmentHorizontalSegment or similar."""
@@ -77,220 +70,6 @@ class _FakeSegment:
self.DesignParameters = design_params 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 # is_zero_length_segment
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -338,69 +117,6 @@ class TestIsZeroLengthSegment(NewFile):
assert subject.is_zero_length_segment(seg) is True 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 # safe_layout_horizontal_by_pi_method
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -491,108 +207,6 @@ class TestGetHorizontalLayout(NewIfc4X3):
assert h_layout is None 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 # Blender Object Creation Methods
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -704,149 +318,6 @@ class TestGetActiveAlignment(NewIfc4X3):
assert result.id() == alignment.id() 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 # Remove alignment hierarchy
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -924,6 +395,11 @@ class TestIfcSaveReloadRoundtrip(NewIfc4X3):
@requires_geometry_engine @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): class TestClearLayoutSegments(NewFile):
"""The alignment API exposes no segment-clearing helper and its layout """The alignment API exposes no segment-clearing helper and its layout
functions only append, so editing relies on tool.Alignment.clear_layout_segments. 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( 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] 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) 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 assert len(align_api.get_layout_segments(h)) == 1 # terminator only
def test_relayout_after_clear_has_no_doubling_or_orphans(self): 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( align_api.layout_vertical_alignment_by_pi_method(
ifc, v, [(0.0, 100.0), (500.0, 110.0), (1000.0, 100.0)], [100.0] 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) subject.clear_layout_segments(v)
assert subject.layout_has_real_segments(v) is False assert _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)
class TestFormatStation(NewFile): class TestFormatStation(NewFile):