mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-21 12:13:43 +00:00
Removed CIVIL prototyping
This commit is contained in:
@@ -184,8 +184,7 @@ classes = [
|
||||
ui.BIM_PT_tab_materials,
|
||||
ui.BIM_PT_tab_styles,
|
||||
ui.BIM_PT_tab_profiles,
|
||||
# Civil infrastructure
|
||||
ui.BIM_PT_tab_horizontal_alignment,
|
||||
# Alignments
|
||||
ui.BIM_PT_tab_alignments,
|
||||
# Drawings and documents
|
||||
ui.BIM_PT_tab_sheets,
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import bpy
|
||||
from . import ui, prop, operator, decorator, workspace
|
||||
from . import ui, prop, operator, decorator
|
||||
|
||||
_last_active_ptr: int = 0
|
||||
_last_profile_alignment_id: int = 0 # tracks which alignment the profile was last built for
|
||||
@@ -105,38 +105,21 @@ def _on_active_object_changed(scene, depsgraph):
|
||||
|
||||
classes = (
|
||||
# Property groups (must be registered before classes that use them)
|
||||
prop.AlignmentPI,
|
||||
prop.AlignmentDisplayRow,
|
||||
prop.VerticalAlignmentItem,
|
||||
prop.CantAlignmentItem,
|
||||
prop.CivilAlignmentProperties,
|
||||
prop.PICurveMarkerProperties,
|
||||
# UILists and section-toggle operators
|
||||
ui.ALIGN_UL_alignment_pis,
|
||||
ui.ALIGN_OT_toggle_h_segments,
|
||||
ui.ALIGN_OT_toggle_v_segments,
|
||||
ui.ALIGN_OT_toggle_cant_segments,
|
||||
operator.ImportAlignmentCSV,
|
||||
# Operators - PI Management
|
||||
operator.ALIGN_OT_add_pi,
|
||||
operator.ALIGN_OT_remove_pi,
|
||||
operator.ALIGN_OT_pick_pi_from_viewport,
|
||||
operator.ALIGN_OT_recalculate_pis,
|
||||
operator.ALIGN_OT_clear_pis,
|
||||
# Operators - Creation
|
||||
operator.ALIGN_OT_create_alignment_by_pis,
|
||||
operator.ALIGN_OT_create_alignment_by_pi,
|
||||
# Operators - Stationing
|
||||
operator.ALIGN_OT_add_stationing_referent,
|
||||
operator.ALIGN_OT_name_segments,
|
||||
# Operators - Vertical Profile Window
|
||||
operator.ALIGN_OT_show_vertical_profile,
|
||||
# Operators - Segment Selection
|
||||
operator.ALIGN_OT_select_h_segment,
|
||||
operator.ALIGN_OT_select_v_segment,
|
||||
operator.ALIGN_OT_select_cant_segment,
|
||||
# Operators - PI Edit Mode
|
||||
operator.ALIGN_OT_enter_pi_edit_mode,
|
||||
# Operators - Alignments tab authoring workflow (Add Element + interactive draw)
|
||||
operator.ALIGN_OT_add_alignment,
|
||||
operator.ALIGN_OT_remove_alignment,
|
||||
@@ -147,10 +130,6 @@ classes = (
|
||||
operator.ALIGN_OT_apply_pi_curve,
|
||||
operator.ALIGN_OT_clear_pi_markers,
|
||||
operator.ALIGN_OT_draw_horizontal_alignment,
|
||||
# UI Panels (appear in Properties sidebar under CIVIL tab)
|
||||
ui.ALIGN_PT_alignment_creation,
|
||||
ui.ALIGN_PT_pi_editor,
|
||||
ui.ALIGN_PT_alignment_stationing,
|
||||
# UI Panels (appear in Properties sidebar under ALIGNMENTS tab)
|
||||
ui.ALIGN_PT_alignment_authoring,
|
||||
ui.ALIGN_PT_alignment_stationing_authoring,
|
||||
@@ -163,12 +142,6 @@ def menu_func_import(self, context):
|
||||
|
||||
|
||||
def register():
|
||||
if not bpy.app.background:
|
||||
bpy.utils.register_tool(
|
||||
workspace.AlignmentTool,
|
||||
separator=True,
|
||||
group=False,
|
||||
)
|
||||
bpy.types.Scene.CivilAlignmentProperties = bpy.props.PointerProperty(type=prop.CivilAlignmentProperties)
|
||||
bpy.types.Object.bonsai_pi_curve_marker = bpy.props.PointerProperty(type=prop.PICurveMarkerProperties)
|
||||
bpy.types.TOPBAR_MT_file_import.append(menu_func_import)
|
||||
@@ -195,8 +168,6 @@ def unregister():
|
||||
pass
|
||||
VerticalProfileDecorator.is_installed = False
|
||||
VerticalProfileDecorator.handlers = []
|
||||
if not bpy.app.background:
|
||||
bpy.utils.unregister_tool(workspace.AlignmentTool)
|
||||
bpy.types.TOPBAR_MT_file_import.remove(menu_func_import)
|
||||
del bpy.types.Scene.CivilAlignmentProperties
|
||||
del bpy.types.Object.bonsai_pi_curve_marker
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
"""Alignment module decorators for GPU visualization.
|
||||
|
||||
This module contains decorators for rendering visual feedback during
|
||||
alignment-related operations, such as PI editing.
|
||||
alignment-related operations, such as segment highlighting and the
|
||||
vertical profile view.
|
||||
"""
|
||||
|
||||
import bpy
|
||||
@@ -38,169 +39,6 @@ from bpy_extras.view3d_utils import location_3d_to_region_2d, region_2d_to_locat
|
||||
from gpu_extras.batch import batch_for_shader
|
||||
|
||||
|
||||
class PIEditDecorator:
|
||||
"""Decorator for visualizing PI edit mode.
|
||||
|
||||
This decorator provides visual feedback while the user is editing
|
||||
PI (Point of Intersection) positions with standard Blender transform tools:
|
||||
- Yellow lines connecting PI empties (tangent preview)
|
||||
- HUD text showing instructions
|
||||
|
||||
The decorator reads positions directly from the PI empty objects,
|
||||
which are updated by Blender's transform operators (G key).
|
||||
"""
|
||||
|
||||
# Class-level state (cleared on uninstall)
|
||||
is_installed = False
|
||||
handlers = []
|
||||
|
||||
# References to PI empty objects
|
||||
pi_empties = []
|
||||
|
||||
# Colors
|
||||
COLOR_TANGENT_LINE = (1.0, 0.9, 0.2, 1.0) # Yellow for tangent lines
|
||||
COLOR_HUD_TEXT = (1.0, 1.0, 1.0, 1.0) # White for HUD text
|
||||
COLOR_EDIT_MODE_BG = (0.2, 0.4, 0.8, 0.8) # Blue tint for edit mode indicator
|
||||
|
||||
# Drawing parameters
|
||||
LINE_WIDTH = 2.5
|
||||
|
||||
@classmethod
|
||||
def install(cls, context, pi_empties):
|
||||
"""Install decorator handlers for PI edit mode visualization.
|
||||
|
||||
Args:
|
||||
context: Blender context
|
||||
pi_empties: List of PI EMPTY objects to visualize
|
||||
"""
|
||||
if cls.is_installed:
|
||||
cls.uninstall()
|
||||
|
||||
cls.pi_empties = pi_empties
|
||||
|
||||
handler = cls()
|
||||
# POST_VIEW for 3D world-space drawing (tangent lines in 3D)
|
||||
cls.handlers.append(
|
||||
SpaceView3D.draw_handler_add(handler.draw_tangent_lines_3d, (context,), "WINDOW", "POST_VIEW")
|
||||
)
|
||||
# POST_PIXEL for 2D screen-space drawing (HUD)
|
||||
cls.handlers.append(
|
||||
SpaceView3D.draw_handler_add(handler.draw_hud, (context,), "WINDOW", "POST_PIXEL")
|
||||
)
|
||||
cls.is_installed = True
|
||||
|
||||
@classmethod
|
||||
def uninstall(cls):
|
||||
"""Remove all handlers and clear state."""
|
||||
for handler in cls.handlers:
|
||||
try:
|
||||
SpaceView3D.draw_handler_remove(handler, "WINDOW")
|
||||
except ValueError:
|
||||
pass
|
||||
cls.handlers = []
|
||||
cls.is_installed = False
|
||||
cls.pi_empties = []
|
||||
|
||||
@classmethod
|
||||
def update_positions(cls, pi_empties):
|
||||
"""Update the list of PI empties (called when positions change).
|
||||
|
||||
Args:
|
||||
pi_empties: Updated list of PI EMPTY objects
|
||||
"""
|
||||
cls.pi_empties = pi_empties
|
||||
|
||||
def draw_batch_3d(self, shader_type, content_pos, color, indices=None):
|
||||
"""Draw a batch of 3D primitives using GPU shader.
|
||||
|
||||
Args:
|
||||
shader_type: Type of primitive ("LINES", "POINTS", etc.)
|
||||
content_pos: List of 3D vertex positions
|
||||
color: RGBA color tuple
|
||||
indices: Optional list of index pairs for lines
|
||||
"""
|
||||
if not tool.Blender.validate_shader_batch_data(content_pos, indices):
|
||||
return
|
||||
shader = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR")
|
||||
shader.bind()
|
||||
|
||||
# Get viewport size from active region
|
||||
region = bpy.context.region
|
||||
shader.uniform_float("viewportSize", (region.width, region.height))
|
||||
shader.uniform_float("lineWidth", self.LINE_WIDTH)
|
||||
|
||||
batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices)
|
||||
shader.uniform_float("color", color)
|
||||
batch.draw(shader)
|
||||
|
||||
def draw_tangent_lines_3d(self, context):
|
||||
"""Draw yellow tangent lines connecting PI empties in 3D space."""
|
||||
if not self.pi_empties or len(self.pi_empties) < 2:
|
||||
return
|
||||
|
||||
# Collect 3D positions from empties
|
||||
positions = []
|
||||
for empty in self.pi_empties:
|
||||
if empty and empty.name in bpy.data.objects:
|
||||
positions.append(tuple(empty.location))
|
||||
|
||||
if len(positions) < 2:
|
||||
return
|
||||
|
||||
# Setup blending for line drawing
|
||||
gpu.state.blend_set("ALPHA")
|
||||
gpu.state.depth_test_set("LESS_EQUAL")
|
||||
gpu.state.depth_mask_set(False)
|
||||
|
||||
# Build edges list
|
||||
edges = [[i, i + 1] for i in range(len(positions) - 1)]
|
||||
|
||||
# Draw lines
|
||||
self.draw_batch_3d("LINES", positions, self.COLOR_TANGENT_LINE, edges)
|
||||
|
||||
# Restore state
|
||||
gpu.state.blend_set("NONE")
|
||||
gpu.state.depth_test_set("NONE")
|
||||
gpu.state.depth_mask_set(True)
|
||||
|
||||
def draw_hud(self, context):
|
||||
"""Draw HUD text with edit mode instructions."""
|
||||
region = context.region
|
||||
if not region:
|
||||
return
|
||||
|
||||
font_id = 0
|
||||
font_size = tool.Blender.scale_font_size(14)
|
||||
blf.size(font_id, font_size)
|
||||
blf.enable(font_id, blf.SHADOW)
|
||||
blf.shadow(font_id, 6, 0, 0, 0, 1) # Black shadow for readability
|
||||
blf.color(font_id, *self.COLOR_HUD_TEXT)
|
||||
|
||||
# Position in top-left of viewport
|
||||
margin = 20
|
||||
line_height = 22
|
||||
y_pos = region.height - margin
|
||||
|
||||
# Count valid empties
|
||||
valid_count = sum(1 for e in self.pi_empties if e and e.name in bpy.data.objects)
|
||||
|
||||
# Instructions
|
||||
instructions = [
|
||||
"PI Edit Mode",
|
||||
f"PIs: {valid_count}",
|
||||
"",
|
||||
"G: Move selected PI",
|
||||
"ENTER: Apply changes",
|
||||
"ESC: Cancel",
|
||||
]
|
||||
|
||||
for i, line in enumerate(instructions):
|
||||
blf.position(font_id, margin, y_pos - (i * line_height), 0)
|
||||
blf.draw(font_id, line)
|
||||
|
||||
blf.disable(font_id, blf.SHADOW)
|
||||
|
||||
|
||||
class AlignmentSegmentDecorator:
|
||||
"""Decorator that highlights a selected horizontal alignment segment in the 3D viewport.
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -124,17 +124,6 @@ def _on_ve_update(self, context):
|
||||
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):
|
||||
"""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):
|
||||
"""Properties for the alignment module"""
|
||||
|
||||
# Active alignment selection
|
||||
active_alignment_id: IntProperty(
|
||||
name="Active Alignment ID",
|
||||
description="IFC entity ID of the active alignment",
|
||||
default=0,
|
||||
)
|
||||
|
||||
active_alignment_name: StringProperty(
|
||||
name="Active Alignment",
|
||||
description="Name of the currently active alignment",
|
||||
default="",
|
||||
)
|
||||
|
||||
# Alignment selector dropdown (top-level alignments only)
|
||||
active_alignment_id_str: EnumProperty(
|
||||
name="Alignment",
|
||||
@@ -284,35 +154,6 @@ class CivilAlignmentProperties(PropertyGroup):
|
||||
default=0,
|
||||
)
|
||||
|
||||
# Panel collapse state
|
||||
show_horizontal_segments: BoolProperty(
|
||||
name="Show Horizontal Segments",
|
||||
description="Expand the horizontal segment table",
|
||||
default=True,
|
||||
)
|
||||
|
||||
# New alignment creation properties
|
||||
new_alignment_name: StringProperty(
|
||||
name="Name",
|
||||
description="Name for new alignment",
|
||||
default="Alignment 1",
|
||||
)
|
||||
|
||||
start_station: FloatProperty(
|
||||
name="Start Station",
|
||||
description="Starting station value (e.g., 10000 for 100+00)",
|
||||
default=10000.0,
|
||||
min=0.0,
|
||||
)
|
||||
|
||||
# PI collection for PI method creation
|
||||
pis: CollectionProperty(type=AlignmentPI)
|
||||
active_pi_index: IntProperty(name="Active PI", default=0)
|
||||
|
||||
# Combined point/segment display rows (for Civil 3D-style table)
|
||||
display_rows: CollectionProperty(type=AlignmentDisplayRow)
|
||||
active_display_row_index: IntProperty(name="Active Display Row", default=0)
|
||||
|
||||
# Vertical profile window settings
|
||||
vertical_exaggeration: FloatProperty(
|
||||
name="Vertical Exaggeration",
|
||||
@@ -371,19 +212,6 @@ class CivilAlignmentProperties(PropertyGroup):
|
||||
default=True,
|
||||
)
|
||||
|
||||
# PI Edit Mode state (for moving PIs with G key)
|
||||
is_pi_edit_mode: BoolProperty(
|
||||
name="PI Edit Mode Active",
|
||||
description="Whether PI edit mode is currently active",
|
||||
default=False,
|
||||
)
|
||||
|
||||
pi_edit_alignment_id: IntProperty(
|
||||
name="Editing Alignment ID",
|
||||
description="IFC ID of alignment being edited in PI edit mode",
|
||||
default=0,
|
||||
)
|
||||
|
||||
|
||||
class PICurveMarkerProperties(PropertyGroup):
|
||||
"""Tags a transient Empty object placed at an interior PI while its
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
|
||||
"""UI panels for the alignment module
|
||||
|
||||
All panels appear in the Properties sidebar under the CIVIL tab,
|
||||
nested under BIM_PT_tab_horizontal_alignment.
|
||||
All panels appear in the Properties sidebar under the Alignments tab,
|
||||
nested under BIM_PT_tab_alignments.
|
||||
"""
|
||||
|
||||
import bpy
|
||||
@@ -28,7 +28,7 @@ import math
|
||||
import ifcopenshell.api.alignment
|
||||
import ifcopenshell.util.geolocation
|
||||
import bonsai.tool as tool
|
||||
from bpy.types import Panel, UIList, Operator
|
||||
from bpy.types import Panel, Operator
|
||||
from bpy.props import IntProperty, BoolProperty
|
||||
from .prop import _alignment_enum_items
|
||||
from .operator import _find_pi_markers, _resolve_alignment_id_for_markers, _is_interior_pi_marker
|
||||
@@ -104,233 +104,6 @@ class ALIGN_OT_toggle_cant_segments(Operator):
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# UILists
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ALIGN_UL_alignment_pis(UIList):
|
||||
"""UIList for displaying interleaved points and segments (Civil 3D style)
|
||||
|
||||
Row types:
|
||||
- POINT rows: End (endpoint), Mid (interior PI without curve)
|
||||
- SEGMENT rows: Tan (tangent line), Curve (circular arc)
|
||||
|
||||
When a Mid point has radius > 0, it becomes a Curve segment row.
|
||||
"""
|
||||
|
||||
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
|
||||
if self.layout_type in {"DEFAULT", "COMPACT"}:
|
||||
row = layout.row(align=True)
|
||||
|
||||
if item.row_type == "POINT":
|
||||
# Point row: No., Type, X, Y, Length, Radius
|
||||
row.label(text="") # No segment number for points
|
||||
|
||||
# Type with point/dot icon
|
||||
# "End" = endpoint (POB/POE), "Mid" = interior PI point
|
||||
row.label(text=item.display_type, icon="DOT")
|
||||
|
||||
# X, Y coordinates - get actual PI for editing
|
||||
pi = data.pis[item.pi_index] if item.pi_index < len(data.pis) else None
|
||||
if pi:
|
||||
sub = row.row(align=True)
|
||||
sub.prop(pi, "e", text="")
|
||||
sub.prop(pi, "n", text="")
|
||||
else:
|
||||
row.label(text=f"{float(item.e):.2f}")
|
||||
row.label(text=f"{float(item.n):.2f}")
|
||||
|
||||
# Length column - empty for point rows
|
||||
row.label(text="")
|
||||
|
||||
# Radius column - editable for Mid points (where curves can be added)
|
||||
if item.display_type == "Mid" and pi:
|
||||
row.prop(pi, "radius", text="")
|
||||
else:
|
||||
row.label(text="")
|
||||
|
||||
elif item.row_type == "SEGMENT":
|
||||
if item.display_type == "Curve":
|
||||
# Curve segment row: No., Type (arc icon), X, Y, Arc Length, Radius
|
||||
row.label(text=f"{item.segment_number}")
|
||||
row.label(text="Curve", icon="SPHERECURVE")
|
||||
|
||||
# Show PI coordinates on curve row
|
||||
row.label(text=f"{float(item.e):.2f}")
|
||||
row.label(text=f"{float(item.n):.2f}")
|
||||
|
||||
# Arc length
|
||||
row.label(text=f"{item.arc_length:.2f}")
|
||||
|
||||
# Radius - editable so user can modify or delete curve (set to 0)
|
||||
pi = data.pis[item.pi_index] if item.pi_index < len(data.pis) else None
|
||||
if pi:
|
||||
row.prop(pi, "radius", text="")
|
||||
else:
|
||||
row.label(text=f"{item.radius:.2f}")
|
||||
else:
|
||||
# Tangent segment row: No., Type (line icon), -, -, Length, -
|
||||
row.label(text=f"{item.segment_number}")
|
||||
row.label(text="Tan", icon="IPO_LINEAR")
|
||||
|
||||
# No X, Y for tangent segments
|
||||
row.label(text="")
|
||||
row.label(text="")
|
||||
|
||||
# Length
|
||||
row.label(text=f"{item.length:.2f}")
|
||||
|
||||
# No radius for tangent segments
|
||||
row.label(text="-")
|
||||
|
||||
elif self.layout_type == "GRID":
|
||||
layout.alignment = "CENTER"
|
||||
layout.label(text="", icon="DECORATE")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Creation Sub-Panel
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ALIGN_PT_alignment_creation(Panel):
|
||||
"""Sub-panel for alignment creation tools"""
|
||||
|
||||
bl_label = "Creation"
|
||||
bl_idname = "ALIGN_PT_alignment_creation"
|
||||
bl_space_type = "PROPERTIES"
|
||||
bl_region_type = "WINDOW"
|
||||
bl_context = "scene"
|
||||
bl_parent_id = "BIM_PT_tab_horizontal_alignment"
|
||||
bl_options = {"DEFAULT_CLOSED"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return tool.Blender.should_show_panel(context, "CIVIL", cls.bl_idname) and is_ifc4x3()
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
props = context.scene.CivilAlignmentProperties
|
||||
|
||||
# New alignment properties
|
||||
box = layout.box()
|
||||
box.label(text="New Alignment:", icon="ADD")
|
||||
box.prop(props, "new_alignment_name")
|
||||
box.prop(props, "start_station")
|
||||
|
||||
# Creation operators
|
||||
col = layout.column(align=True)
|
||||
col.operator("align.create_alignment_by_pi", icon="CURVE_DATA")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PI Editor Sub-Panel
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ALIGN_PT_pi_editor(Panel):
|
||||
"""Sub-panel for PI point table editor (Civil 3D style grid view)"""
|
||||
|
||||
bl_label = "PI Editor"
|
||||
bl_idname = "ALIGN_PT_pi_editor"
|
||||
bl_space_type = "PROPERTIES"
|
||||
bl_region_type = "WINDOW"
|
||||
bl_context = "scene"
|
||||
bl_parent_id = "BIM_PT_tab_horizontal_alignment"
|
||||
bl_options = set() # Open by default
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return tool.Blender.should_show_panel(context, "CIVIL", cls.bl_idname) and is_ifc4x3()
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
props = context.scene.CivilAlignmentProperties
|
||||
|
||||
# PI Edit Mode indicator
|
||||
if props.is_pi_edit_mode:
|
||||
box = layout.box()
|
||||
box.alert = True
|
||||
box.label(text="PI Edit Mode Active", icon="EDITMODE_HLT")
|
||||
col = box.column(align=True)
|
||||
col.label(text="Move PIs with G key")
|
||||
col.label(text="Press Enter to apply")
|
||||
col.label(text="Press Escape to cancel")
|
||||
layout.separator()
|
||||
return # Don't show normal UI while in edit mode
|
||||
|
||||
# Edit existing alignment button
|
||||
if props.active_alignment_id != 0:
|
||||
box = layout.box()
|
||||
box.label(text="Edit Alignment:", icon="EDITMODE_HLT")
|
||||
box.operator("align.enter_pi_edit_mode", icon="PIVOT_CURSOR", text="Edit PIs (G key)")
|
||||
layout.separator()
|
||||
|
||||
# Header row with column labels
|
||||
header = layout.row(align=True)
|
||||
header.label(text="No.")
|
||||
header.label(text="Type")
|
||||
header.label(text="E")
|
||||
header.label(text="N")
|
||||
header.label(text="Length")
|
||||
header.label(text="Radius")
|
||||
|
||||
# Combined point/segment list (interleaved view)
|
||||
row = layout.row()
|
||||
row.template_list(
|
||||
"ALIGN_UL_alignment_pis",
|
||||
"",
|
||||
props,
|
||||
"display_rows",
|
||||
props,
|
||||
"active_display_row_index",
|
||||
rows=8,
|
||||
)
|
||||
|
||||
# Side buttons for list management
|
||||
col = row.column(align=True)
|
||||
col.operator("align.add_pi", icon="ADD", text="")
|
||||
col.operator("align.remove_pi", icon="REMOVE", text="")
|
||||
col.separator()
|
||||
col.operator("align.pick_pi_from_viewport", icon="EYEDROPPER", text="")
|
||||
|
||||
# Bottom actions
|
||||
layout.separator()
|
||||
row = layout.row(align=True)
|
||||
row.operator("align.recalculate_pis", icon="FILE_REFRESH", text="Recalculate")
|
||||
row.operator("align.clear_pis", icon="TRASH", text="Clear All")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Stationing Sub-Panel
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ALIGN_PT_alignment_stationing(Panel):
|
||||
"""Sub-panel for stationing and referents"""
|
||||
|
||||
bl_label = "Stationing"
|
||||
bl_idname = "ALIGN_PT_alignment_stationing"
|
||||
bl_space_type = "PROPERTIES"
|
||||
bl_region_type = "WINDOW"
|
||||
bl_context = "scene"
|
||||
bl_parent_id = "BIM_PT_tab_horizontal_alignment"
|
||||
bl_options = {"DEFAULT_CLOSED"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return tool.Blender.should_show_panel(context, "CIVIL", cls.bl_idname) and is_ifc4x3()
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
|
||||
# Stationing operators
|
||||
col = layout.column(align=True)
|
||||
col.operator("align.add_stationing_referent", icon="EMPTY_AXIS")
|
||||
col.operator("align.name_segments", icon="FONT_DATA")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Alignments Tab – Segment Breakdown Panel
|
||||
# =============================================================================
|
||||
@@ -383,9 +156,8 @@ def _start_en(ifc_file, dp) -> tuple[float | None, float | None]:
|
||||
class ALIGN_PT_alignment_authoring(Panel):
|
||||
"""Add an alignment and draw its horizontal geometry — Alignments tab.
|
||||
|
||||
This is a from-scratch authoring workflow, independent of the CIVIL tab's
|
||||
PI-table tools: add a bare alignment, then draw its horizontal geometry
|
||||
directly in the viewport.
|
||||
Add a bare alignment, then draw its horizontal geometry directly in the
|
||||
viewport.
|
||||
"""
|
||||
|
||||
bl_label = "Add Alignment"
|
||||
@@ -438,9 +210,8 @@ class ALIGN_PT_alignment_authoring(Panel):
|
||||
class ALIGN_PT_alignment_stationing_authoring(Panel):
|
||||
"""Start station and station equations — Alignments tab.
|
||||
|
||||
A from-scratch equivalent of the CIVIL tab's stationing panel: edit the
|
||||
start station, and add/remove additional stationing referents (station
|
||||
equations) for gaps, overlaps, or reversed stationing direction.
|
||||
Edit the start station, and add/remove additional stationing referents
|
||||
(station equations) for gaps, overlaps, or reversed stationing direction.
|
||||
"""
|
||||
|
||||
bl_label = "Stationing"
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
if ifc_class == "IfcAlignment":
|
||||
# Alignment representations come from ifcopenshell.api.alignment
|
||||
# (composite curves), not from a mesh template.
|
||||
return templates
|
||||
|
||||
if ifc_class in ("IfcWindowType", "IfcWindowStyle", "IfcWindow"):
|
||||
templates.extend([None, ("WINDOW", "Window", "Parametric window")])
|
||||
elif ifc_class in ("IfcDoorType", "IfcDoorStyle", "IfcDoor"):
|
||||
|
||||
@@ -575,29 +575,6 @@ class AddElement(bpy.types.Operator, tool.Ifc.Operator):
|
||||
)
|
||||
element.Description = props.description or None
|
||||
|
||||
if props.ifc_class == "IfcAlignment":
|
||||
# Saikei: creating an alignment automatically creates its
|
||||
# horizontal layout (spec 1.1). Alignments own an origin local
|
||||
# placement (IFC 4.1.4.1.1 aggregates them to the project, never
|
||||
# to a spatial container) and their representations come from
|
||||
# ifcopenshell.api.alignment, so the representation templates and
|
||||
# 3D-cursor placement do not apply.
|
||||
obj.location = (0.0, 0.0, 0.0)
|
||||
h_layout = tool.Alignment.add_horizontal_layout_to_alignment(element)
|
||||
tool.Alignment.create_object_for_layout(h_layout, obj)
|
||||
civil_props = context.scene.CivilAlignmentProperties
|
||||
civil_props.active_alignment_id = element.id()
|
||||
civil_props.active_alignment_name = element.Name or ""
|
||||
bpy.context.view_layer.update()
|
||||
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj)
|
||||
tool.Blender.set_active_object(obj)
|
||||
self.report(
|
||||
{"INFO"},
|
||||
f"Alignment '{element.Name}' created with an empty horizontal layout — "
|
||||
"add PI points from the CIVIL tab or viewport picking.",
|
||||
)
|
||||
return
|
||||
|
||||
if representation_template == "EMTPY" or not ifc_context:
|
||||
pass
|
||||
elif representation_template == "OBJ" and props.representation_obj:
|
||||
|
||||
@@ -530,7 +530,6 @@ def get_tab(
|
||||
("PROJECT", "Project Overview", "", bonsai.bim.icons[icon_key].icon_id, 0),
|
||||
("OBJECT", "Object Information", "", "FILE_3D", 1),
|
||||
("GEOMETRY", "Geometry and Materials", "", "MATERIAL", 2),
|
||||
("CIVIL", "Civil Infrastructure", "", "CURVE_DATA", 11),
|
||||
("ALIGNMENTS", "Alignments", "", "ANIM_DATA", 12),
|
||||
("DRAWINGS", "Drawings and Documents", "", "DOCUMENTS", 3),
|
||||
("SERVICES", "Services and Systems", "", "NETWORK_DRIVE", 4),
|
||||
|
||||
@@ -1640,25 +1640,6 @@ class BIM_PT_tab_profiles(Panel):
|
||||
pass
|
||||
|
||||
|
||||
# Civil Infrastructure tab panels
|
||||
class BIM_PT_tab_horizontal_alignment(Panel):
|
||||
bl_idname = "BIM_PT_tab_horizontal_alignment"
|
||||
bl_label = "Horizontal Alignment"
|
||||
bl_space_type = "PROPERTIES"
|
||||
bl_region_type = "WINDOW"
|
||||
bl_context = "scene"
|
||||
bl_order = 1
|
||||
bim_tab_name = "CIVIL"
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get():
|
||||
return True
|
||||
|
||||
def draw(self, context):
|
||||
pass
|
||||
|
||||
|
||||
class BIM_PT_tab_alignments(Panel):
|
||||
bl_idname = "BIM_PT_tab_alignments"
|
||||
bl_label = "Alignments"
|
||||
@@ -1863,7 +1844,6 @@ class UIData:
|
||||
("PROJECT", bonsai.bim.icons[f"{color_mode}_ifc"].icon_id, True),
|
||||
("OBJECT", "FILE_3D", is_ifc_project),
|
||||
("GEOMETRY", "MATERIAL", is_ifc_project),
|
||||
("CIVIL", "CURVE_DATA", is_ifc_project),
|
||||
("ALIGNMENTS", "ANIM_DATA", is_ifc_project),
|
||||
("DRAWINGS", "DOCUMENTS", is_ifc_project),
|
||||
("SERVICES", "NETWORK_DRIVE", is_ifc_project),
|
||||
|
||||
@@ -78,70 +78,6 @@ def create_alignment(
|
||||
return alignment_tool.create_alignment(name.strip(), start_station)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PI Edit Mode Functions
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def enter_pi_edit_mode(
|
||||
ifc_tool: "type[tool.Ifc]",
|
||||
alignment_tool: "type[tool.Alignment]",
|
||||
alignment_id: int,
|
||||
) -> list:
|
||||
"""Enter PI edit mode for an alignment.
|
||||
|
||||
Business logic for entering PI edit mode:
|
||||
1. Validates that the alignment exists
|
||||
2. Validates that the alignment has a horizontal layout with real segments
|
||||
3. Back-calculates PI positions from segments
|
||||
4. Creates temporary EMPTY objects at each PI location
|
||||
|
||||
Args:
|
||||
ifc_tool: The IFC tool class
|
||||
alignment_tool: The Alignment tool class
|
||||
alignment_id: The IFC ID of the alignment to edit
|
||||
|
||||
Returns:
|
||||
List of created PI EMPTY objects
|
||||
|
||||
Raises:
|
||||
ValueError: If alignment doesn't exist, has no horizontal layout,
|
||||
or has no real segments
|
||||
"""
|
||||
# Validate alignment exists
|
||||
ifc_file = ifc_tool.get()
|
||||
if ifc_file is None:
|
||||
raise ValueError("No IFC file loaded")
|
||||
|
||||
try:
|
||||
alignment = ifc_file.by_id(alignment_id)
|
||||
except RuntimeError:
|
||||
raise ValueError(f"Alignment with ID {alignment_id} not found")
|
||||
|
||||
if not alignment.is_a("IfcAlignment"):
|
||||
raise ValueError(f"Entity {alignment_id} is not an IfcAlignment")
|
||||
|
||||
# Validate alignment has horizontal layout (delegated to tool)
|
||||
h_layout = alignment_tool.get_horizontal_layout(alignment)
|
||||
if h_layout is None:
|
||||
raise ValueError(f"Alignment '{alignment.Name}' has no horizontal layout")
|
||||
|
||||
# Validate layout has real segments (not just zero-length terminator)
|
||||
if not alignment_tool.layout_has_real_segments(h_layout):
|
||||
raise ValueError(f"Alignment '{alignment.Name}' has no editable segments")
|
||||
|
||||
# Back-calculate PI positions from segments
|
||||
pis = alignment_tool.back_calculate_pis_from_alignment(alignment)
|
||||
|
||||
if len(pis) < 2:
|
||||
raise ValueError(f"Alignment '{alignment.Name}' must have at least 2 PIs")
|
||||
|
||||
# Create temporary EMPTY objects at each PI location
|
||||
empties = alignment_tool.create_pi_edit_empties(alignment, pis)
|
||||
|
||||
return empties
|
||||
|
||||
|
||||
def import_alignment_csv(
|
||||
ifc_tool: "type[tool.Ifc]",
|
||||
alignment_tool: "type[tool.Alignment]",
|
||||
@@ -183,84 +119,3 @@ def import_alignment_csv(
|
||||
alignment_tool.create_objects_for_referents(alignment)
|
||||
|
||||
return alignment
|
||||
|
||||
|
||||
def exit_pi_edit_mode(
|
||||
ifc_tool: "type[tool.Ifc]",
|
||||
alignment_tool: "type[tool.Alignment]",
|
||||
alignment_id: int,
|
||||
apply: bool,
|
||||
) -> bool:
|
||||
"""Exit PI edit mode for an alignment.
|
||||
|
||||
Business logic for exiting PI edit mode:
|
||||
1. If apply=True:
|
||||
- Collect new PI positions from empties
|
||||
- Validate the new configuration
|
||||
- Update alignment segments in-place (preserves alignment ID)
|
||||
2. Always:
|
||||
- Remove temporary EMPTY objects
|
||||
- Return success status
|
||||
|
||||
This function modifies the alignment segments in-place rather than
|
||||
deleting and recreating the alignment. This preserves the alignment's
|
||||
IFC entity ID, preventing stale reference issues.
|
||||
|
||||
Args:
|
||||
ifc_tool: The IFC tool class
|
||||
alignment_tool: The Alignment tool class
|
||||
alignment_id: The IFC ID of the alignment being edited
|
||||
apply: If True, update alignment with new PI positions
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
|
||||
Raises:
|
||||
ValueError: If alignment doesn't exist or update fails
|
||||
"""
|
||||
ifc_file = ifc_tool.get()
|
||||
if ifc_file is None:
|
||||
# No file loaded, just clean up empties
|
||||
alignment_tool.remove_pi_edit_empties(alignment_id)
|
||||
return True
|
||||
|
||||
# Get alignment
|
||||
try:
|
||||
alignment = ifc_file.by_id(alignment_id)
|
||||
except RuntimeError:
|
||||
# Alignment was deleted, just clean up empties
|
||||
alignment_tool.remove_pi_edit_empties(alignment_id)
|
||||
return True
|
||||
|
||||
if apply:
|
||||
# Collect PI positions from empties
|
||||
hpoints, radii = alignment_tool.collect_pis_from_empties(alignment_id)
|
||||
|
||||
if len(hpoints) < 2:
|
||||
raise ValueError("At least 2 PIs are required")
|
||||
|
||||
# Get horizontal layout (delegated to tool)
|
||||
h_layout = alignment_tool.get_horizontal_layout(alignment)
|
||||
if h_layout is None:
|
||||
raise ValueError("Alignment has no horizontal layout")
|
||||
|
||||
# Remove empties before modifying segments
|
||||
alignment_tool.remove_pi_edit_empties(alignment_id)
|
||||
|
||||
# Remove Blender visualization for segments (not the whole hierarchy)
|
||||
alignment_tool.remove_layout_segment_objects(h_layout)
|
||||
|
||||
# Clear existing IFC segments and add new ones (delegated to tool)
|
||||
alignment_tool.clear_layout_segments(h_layout)
|
||||
alignment_tool.layout_by_pi_method(h_layout, hpoints, radii)
|
||||
|
||||
# Refresh Blender visualization for new segments
|
||||
layout_obj = ifc_tool.get_object(h_layout)
|
||||
if layout_obj:
|
||||
alignment_tool.create_objects_for_layout_segments(h_layout, layout_obj)
|
||||
|
||||
return True
|
||||
else:
|
||||
# Cancel - just remove empties without regenerating
|
||||
alignment_tool.remove_pi_edit_empties(alignment_id)
|
||||
return True
|
||||
|
||||
@@ -1290,17 +1290,10 @@ class Web:
|
||||
class Alignment:
|
||||
# Alignment creation
|
||||
def create_alignment(cls, name, start_station=0.0): pass
|
||||
# Horizontal PI edit mode
|
||||
def back_calculate_pis_from_alignment(cls, alignment): pass
|
||||
def clear_layout_segments(cls, h_layout): pass
|
||||
def collect_pis_from_empties(cls, alignment_id): pass
|
||||
def create_objects_for_layout_segments(cls, h_layout, layout_obj): pass
|
||||
def create_pi_edit_empties(cls, alignment, pis): pass
|
||||
def get_horizontal_layout(cls, alignment): pass
|
||||
def layout_by_pi_method(cls, h_layout, hpoints, radii): pass
|
||||
def layout_has_real_segments(cls, h_layout): pass
|
||||
def remove_layout_segment_objects(cls, h_layout): pass
|
||||
def remove_pi_edit_empties(cls, alignment_id): pass
|
||||
# Stationing
|
||||
def format_station(cls, station): pass
|
||||
# CSV import
|
||||
|
||||
@@ -36,27 +36,11 @@ import bonsai.bim.import_ifc
|
||||
import ifcopenshell.api.alignment
|
||||
import ifcopenshell.util.shape
|
||||
from typing import TYPE_CHECKING, Optional, List, Tuple
|
||||
from dataclasses import dataclass
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import ifcopenshell
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Data Classes for PI Geometry Results
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class PIGeometryResult:
|
||||
"""Result of PI geometry calculation."""
|
||||
|
||||
stations: List[float]
|
||||
lengths: List[float]
|
||||
directions: List[float]
|
||||
total_length: float
|
||||
|
||||
|
||||
class Alignment:
|
||||
"""Tool class for alignment-related Blender operations.
|
||||
|
||||
@@ -64,288 +48,6 @@ class Alignment:
|
||||
that can be called without instantiation.
|
||||
"""
|
||||
|
||||
# =========================================================================
|
||||
# Geometry Calculation Methods
|
||||
# =========================================================================
|
||||
|
||||
@classmethod
|
||||
def calculate_pi_geometry(cls, pis: List[Tuple[float, float]], start_station: float = 0.0) -> PIGeometryResult:
|
||||
"""Calculate lengths, stations, and directions for a list of PI points.
|
||||
|
||||
Args:
|
||||
pis: List of (x, y) coordinate tuples for each PI
|
||||
start_station: Starting station value
|
||||
|
||||
Returns:
|
||||
PIGeometryResult containing calculated values
|
||||
"""
|
||||
if len(pis) < 2:
|
||||
return PIGeometryResult(
|
||||
stations=[start_station] if pis else [],
|
||||
lengths=[0.0] if pis else [],
|
||||
directions=[0.0] if pis else [],
|
||||
total_length=0.0,
|
||||
)
|
||||
|
||||
stations = []
|
||||
lengths = []
|
||||
directions = []
|
||||
cumulative_length = start_station
|
||||
|
||||
for i, pi in enumerate(pis):
|
||||
stations.append(cumulative_length)
|
||||
|
||||
if i < len(pis) - 1:
|
||||
next_pi = pis[i + 1]
|
||||
dx = next_pi[0] - pi[0]
|
||||
dy = next_pi[1] - pi[1]
|
||||
length = math.sqrt(dx * dx + dy * dy)
|
||||
direction = math.atan2(dy, dx)
|
||||
lengths.append(length)
|
||||
directions.append(direction)
|
||||
cumulative_length += length
|
||||
else:
|
||||
lengths.append(0.0)
|
||||
directions.append(0.0)
|
||||
|
||||
total_length = cumulative_length - start_station
|
||||
|
||||
return PIGeometryResult(stations=stations, lengths=lengths, directions=directions, total_length=total_length)
|
||||
|
||||
@classmethod
|
||||
def calculate_tangent_length(cls, radius: float, deflection_angle: float) -> float:
|
||||
"""Calculate tangent length for a circular curve.
|
||||
|
||||
T = R * tan(Δ/2)
|
||||
|
||||
Args:
|
||||
radius: Curve radius
|
||||
deflection_angle: Deflection angle in radians
|
||||
|
||||
Returns:
|
||||
Tangent length
|
||||
"""
|
||||
if deflection_angle == 0 or radius == 0:
|
||||
return 0.0
|
||||
return radius * math.tan(deflection_angle / 2)
|
||||
|
||||
@classmethod
|
||||
def calculate_arc_length(cls, radius: float, deflection_angle: float) -> float:
|
||||
"""Calculate arc length for a circular curve.
|
||||
|
||||
L = R * Δ
|
||||
|
||||
Args:
|
||||
radius: Curve radius
|
||||
deflection_angle: Deflection angle in radians
|
||||
|
||||
Returns:
|
||||
Arc length
|
||||
"""
|
||||
return radius * deflection_angle
|
||||
|
||||
@classmethod
|
||||
def deflection_angle_from_points(
|
||||
cls, p1: Tuple[float, float], p2: Tuple[float, float], p3: Tuple[float, float]
|
||||
) -> float:
|
||||
"""Calculate deflection angle at p2 from three (e, n) coordinate tuples.
|
||||
|
||||
Args:
|
||||
p1: Previous PI coordinates (e, n)
|
||||
p2: Current PI coordinates (e, n)
|
||||
p3: Next PI coordinates (e, n)
|
||||
|
||||
Returns:
|
||||
Deflection angle in radians (signed: positive=left, negative=right)
|
||||
"""
|
||||
dx1 = p2[0] - p1[0]
|
||||
dy1 = p2[1] - p1[1]
|
||||
incoming = math.atan2(dy1, dx1)
|
||||
|
||||
dx2 = p3[0] - p2[0]
|
||||
dy2 = p3[1] - p2[1]
|
||||
outgoing = math.atan2(dy2, dx2)
|
||||
|
||||
delta = outgoing - incoming
|
||||
while delta > math.pi:
|
||||
delta -= 2 * math.pi
|
||||
while delta < -math.pi:
|
||||
delta += 2 * math.pi
|
||||
return delta
|
||||
|
||||
@classmethod
|
||||
def arc_length_at_pi(
|
||||
cls,
|
||||
p1: Tuple[float, float],
|
||||
p2: Tuple[float, float],
|
||||
p3: Tuple[float, float],
|
||||
radius: float,
|
||||
) -> float:
|
||||
"""Calculate arc length L = R * |delta| at a PI with curve.
|
||||
|
||||
Args:
|
||||
p1, p2, p3: (e, n) coordinate tuples for prev, current, next PI
|
||||
radius: Curve radius (must be > 0)
|
||||
|
||||
Returns:
|
||||
Arc length
|
||||
"""
|
||||
if radius <= 0:
|
||||
return 0.0
|
||||
deflection = cls.deflection_angle_from_points(p1, p2, p3)
|
||||
return cls.calculate_arc_length(radius, abs(deflection))
|
||||
|
||||
@classmethod
|
||||
def tangent_length_at_pi(
|
||||
cls,
|
||||
p1: Tuple[float, float],
|
||||
p2: Tuple[float, float],
|
||||
p3: Tuple[float, float],
|
||||
radius: float,
|
||||
) -> float:
|
||||
"""Calculate tangent length T = R * tan(|delta|/2) at a PI.
|
||||
|
||||
Args:
|
||||
p1, p2, p3: (e, n) coordinate tuples for prev, current, next PI
|
||||
radius: Curve radius (must be > 0)
|
||||
|
||||
Returns:
|
||||
Tangent length
|
||||
"""
|
||||
if radius <= 0:
|
||||
return 0.0
|
||||
deflection = cls.deflection_angle_from_points(p1, p2, p3)
|
||||
return cls.calculate_tangent_length(radius, abs(deflection))
|
||||
|
||||
@classmethod
|
||||
def tangent_segment_length(
|
||||
cls,
|
||||
p_start: Tuple[float, float],
|
||||
p_end: Tuple[float, float],
|
||||
start_tangent: float = 0.0,
|
||||
end_tangent: float = 0.0,
|
||||
) -> float:
|
||||
"""Calculate tangent segment length between two PIs, minus curve tangent lengths.
|
||||
|
||||
Args:
|
||||
p_start: (e, n) coordinate tuple for start PI
|
||||
p_end: (e, n) coordinate tuple for end PI
|
||||
start_tangent: Tangent length to subtract at start
|
||||
end_tangent: Tangent length to subtract at end
|
||||
|
||||
Returns:
|
||||
Net segment length (clamped to 0)
|
||||
"""
|
||||
dx = p_end[0] - p_start[0]
|
||||
dy = p_end[1] - p_start[1]
|
||||
full_length = math.sqrt(dx * dx + dy * dy)
|
||||
return max(0.0, full_length - start_tangent - end_tangent)
|
||||
|
||||
# =========================================================================
|
||||
# PI Extraction from IFC Segments
|
||||
# =========================================================================
|
||||
|
||||
@classmethod
|
||||
def _get_segment_vertices_in_model_units(
|
||||
cls, ifc_file: "ifcopenshell.file", segment: "ifcopenshell.entity_instance"
|
||||
):
|
||||
"""Get segment control points (Start, End, TI, NI) in model units.
|
||||
|
||||
Wraps ifcopenshell.api.alignment.segment_vertices() with:
|
||||
- Backward-compatible fallback for segments without Axis/Segment
|
||||
representation (falls back to IfcCurveSegment via get_mapped_segments)
|
||||
- Unit conversion (geometry engine returns SI; we need model units)
|
||||
|
||||
Args:
|
||||
ifc_file: The IFC file
|
||||
segment: An IfcAlignmentSegment entity
|
||||
|
||||
Returns:
|
||||
Tuple of (start, end, ti, ni) where each is (x, y) in model units,
|
||||
or None for ti/ni when lines are parallel.
|
||||
Returns None if segment cannot be evaluated.
|
||||
"""
|
||||
import ifcopenshell.api.alignment as align_api
|
||||
import ifcopenshell.util.unit
|
||||
|
||||
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file)
|
||||
|
||||
def convert(point):
|
||||
if point is None:
|
||||
return None
|
||||
return (point[0] / unit_scale, point[1] / unit_scale)
|
||||
|
||||
result = align_api.segment_vertices(ifc_file, segment)
|
||||
if result is None:
|
||||
return None
|
||||
start, end, ti, ni = result
|
||||
|
||||
return (convert(start), convert(end), convert(ti), convert(ni))
|
||||
|
||||
@classmethod
|
||||
def extract_pis_from_segments(cls, segments):
|
||||
"""Extract PI data from IFC alignment segments.
|
||||
|
||||
Uses ifcopenshell.api.alignment.segment_vertices() to extract
|
||||
PI (tangent intersection) points from segment geometry.
|
||||
|
||||
Args:
|
||||
segments: List of IfcAlignmentSegment entities
|
||||
|
||||
Returns:
|
||||
List of dicts with keys: e, n, pi_type, radius
|
||||
"""
|
||||
ifc_file = tool.Ifc.get()
|
||||
|
||||
# Filter out zero-length terminal segments
|
||||
real_segments = [seg for seg in segments if not cls.is_zero_length_segment(seg)]
|
||||
if not real_segments:
|
||||
return []
|
||||
|
||||
# Get vertices for all segments
|
||||
seg_vertices = [cls._get_segment_vertices_in_model_units(ifc_file, seg) for seg in real_segments]
|
||||
|
||||
pis = []
|
||||
|
||||
# First PI: start of first segment
|
||||
if seg_vertices[0] is not None:
|
||||
start_pt = seg_vertices[0][0]
|
||||
pis.append({"e": start_pt[0], "n": start_pt[1], "pi_type": "ENDPOINT", "radius": 0.0})
|
||||
|
||||
# Process interior PIs
|
||||
prev_is_line = True
|
||||
for i, (seg, verts) in enumerate(zip(real_segments, seg_vertices)):
|
||||
if verts is None:
|
||||
prev_is_line = False
|
||||
continue
|
||||
|
||||
start, end, ti, ni = verts
|
||||
dp = seg.DesignParameters
|
||||
|
||||
if ti is not None:
|
||||
# Curve segment: TI is the PI
|
||||
radius = abs(float(dp.StartRadiusOfCurvature or dp.EndRadiusOfCurvature or 0))
|
||||
pis.append({"e": ti[0], "n": ti[1], "pi_type": "CURVE", "radius": radius})
|
||||
prev_is_line = False
|
||||
else:
|
||||
# Line segment: if previous was also a line, connection = tangent PI
|
||||
if i > 0 and prev_is_line:
|
||||
pis.append({"e": start[0], "n": start[1], "pi_type": "TANGENT", "radius": 0.0})
|
||||
prev_is_line = True
|
||||
|
||||
# Last PI: end of last segment
|
||||
if seg_vertices[-1] is not None:
|
||||
end_pt = seg_vertices[-1][1]
|
||||
if pis:
|
||||
last = pis[-1]
|
||||
dist = ((end_pt[0] - last["e"]) ** 2 + (end_pt[1] - last["n"]) ** 2) ** 0.5
|
||||
if dist > 0.001:
|
||||
pis.append({"e": end_pt[0], "n": end_pt[1], "pi_type": "ENDPOINT", "radius": 0.0})
|
||||
else:
|
||||
pis.append({"e": end_pt[0], "n": end_pt[1], "pi_type": "ENDPOINT", "radius": 0.0})
|
||||
|
||||
return pis
|
||||
|
||||
# =========================================================================
|
||||
# IFC API Wrappers (for core layer delegation)
|
||||
# =========================================================================
|
||||
@@ -374,9 +76,8 @@ class Alignment:
|
||||
its (still segment-less) horizontal layout: create_hierarchy_for_alignment()
|
||||
would create that eagerly, leaving a stray "Layout" object with no
|
||||
segments in the scene before anything has actually been drawn, unlike
|
||||
a loaded file which never has one until it's meaningful. Whichever
|
||||
drawing flow adds real segments next (CIVIL's PI picker or the
|
||||
Alignments tab's draw tool) creates the layout object lazily, once
|
||||
a loaded file which never has one until it's meaningful. The
|
||||
Alignments tab's draw tool creates the layout object lazily, once
|
||||
there's something to show.
|
||||
|
||||
Args:
|
||||
@@ -514,20 +215,6 @@ class Alignment:
|
||||
for segment in dropped_segments:
|
||||
ifcopenshell.api.root.remove_product(ifc_file, product=segment)
|
||||
|
||||
@classmethod
|
||||
def layout_by_pi_method(cls, layout: "ifcopenshell.entity_instance", hpoints: list, radii: list):
|
||||
"""Add segments to a horizontal layout using the PI method.
|
||||
|
||||
Args:
|
||||
layout: The IfcAlignmentHorizontal layout
|
||||
hpoints: List of (E, N) coordinate pairs for PIs
|
||||
radii: List of curve radii for interior PIs
|
||||
"""
|
||||
import ifcopenshell.api.alignment as align_api
|
||||
|
||||
ifc_file = tool.Ifc.get()
|
||||
align_api.layout_horizontal_alignment_by_pi_method(ifc_file, layout, hpoints, radii)
|
||||
|
||||
# =========================================================================
|
||||
# Zero-Length Segment Utilities
|
||||
# =========================================================================
|
||||
@@ -560,25 +247,6 @@ class Alignment:
|
||||
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def layout_has_real_segments(cls, layout: "ifcopenshell.entity_instance") -> bool:
|
||||
"""Check if a layout has any real (non-zero-length) segments.
|
||||
|
||||
An empty layout only has the mandatory zero-length terminator segment.
|
||||
|
||||
Args:
|
||||
layout: The IFC layout entity (IfcAlignmentHorizontal, etc.)
|
||||
|
||||
Returns:
|
||||
True if the layout has at least one real segment
|
||||
"""
|
||||
for rel in getattr(layout, "IsNestedBy", []) or []:
|
||||
for segment in rel.RelatedObjects or []:
|
||||
if segment.is_a("IfcAlignmentSegment"):
|
||||
if not cls.is_zero_length_segment(segment):
|
||||
return True
|
||||
return False
|
||||
|
||||
# =========================================================================
|
||||
# Blender Object Creation
|
||||
# =========================================================================
|
||||
@@ -629,15 +297,13 @@ class Alignment:
|
||||
IFC product's representation is: this is what loading an alignment
|
||||
from a file produces. It deliberately does NOT create separate
|
||||
objects for the nested IfcAlignmentHorizontal/Vertical/Cant layouts
|
||||
or their IfcAlignmentSegments — interactive creation used to (via
|
||||
create_object_for_layout/create_objects_for_layout_segments, ported
|
||||
from the CIVIL tab's segment-selection UI), which left the scene
|
||||
collection looking different from a loaded file for no IFC-side
|
||||
reason. Callers that still want per-segment objects for
|
||||
selection/highlighting (CIVIL's tools) should keep using those
|
||||
functions directly — this one is for the Alignments tab's authoring
|
||||
workflow, which shows segments via ALIGN_PT_alignment_segments
|
||||
instead of individual viewport objects.
|
||||
or their IfcAlignmentSegments, unlike create_object_for_layout /
|
||||
create_objects_for_layout_segments (still used by CSV import's
|
||||
hierarchy build), which would leave the scene collection looking
|
||||
different from a loaded file for no IFC-side reason. This one is
|
||||
for the Alignments tab's authoring workflow, which shows segments
|
||||
via ALIGN_PT_alignment_segments instead of individual viewport
|
||||
objects.
|
||||
|
||||
Args:
|
||||
alignment: The IFC alignment entity, with a representation already
|
||||
@@ -1232,26 +898,6 @@ class Alignment:
|
||||
return f"{float(station):.2f}"
|
||||
return ifcopenshell.util.alignment.station_as_string(ifc_file, float(station))
|
||||
|
||||
@classmethod
|
||||
def update_pi_properties(cls, props, geometry_result) -> None:
|
||||
"""Update Blender PropertyGroup with calculated geometry.
|
||||
|
||||
This bridges the pure Python calculation results back to
|
||||
the Blender UI properties.
|
||||
|
||||
Args:
|
||||
props: The CivilAlignmentProperties PropertyGroup
|
||||
geometry_result: PIGeometryResult from core.alignment
|
||||
"""
|
||||
pis = props.pis
|
||||
for i, pi in enumerate(pis):
|
||||
if i < len(geometry_result.stations):
|
||||
pi.station = geometry_result.stations[i]
|
||||
if i < len(geometry_result.lengths):
|
||||
pi.length_to_next = geometry_result.lengths[i]
|
||||
if i < len(geometry_result.directions):
|
||||
pi.direction_to_next = geometry_result.directions[i]
|
||||
|
||||
@classmethod
|
||||
def _remove_blender_object(cls, obj: bpy.types.Object) -> bool:
|
||||
"""Safely remove a Blender object and its data.
|
||||
@@ -1408,273 +1054,6 @@ class Alignment:
|
||||
|
||||
return True
|
||||
|
||||
# =========================================================================
|
||||
# PI Edit Mode Methods
|
||||
# =========================================================================
|
||||
# These methods support the PI Edit Mode feature, which allows users to
|
||||
# move alignment PIs (Points of Intersection) using Blender's standard
|
||||
# transform tools (G key). The workflow is:
|
||||
# 1. Back-calculate PI positions from existing IFC segments
|
||||
# 2. Create temporary EMPTY objects at each PI location
|
||||
# 3. User moves empties with standard Blender tools
|
||||
# 4. Collect new positions and regenerate alignment segments
|
||||
|
||||
@classmethod
|
||||
def back_calculate_pis_from_alignment(cls, alignment: "ifcopenshell.entity_instance") -> List[dict]:
|
||||
"""Reverse-engineer PI positions from IFC alignment segments.
|
||||
|
||||
Uses ifcopenshell.api.alignment.segment_vertices() to extract
|
||||
the tangent intersection (TI) point for each segment — the TI
|
||||
IS the PI for curve segments.
|
||||
|
||||
Args:
|
||||
alignment: The IfcAlignment entity
|
||||
|
||||
Returns:
|
||||
List of dicts, each containing:
|
||||
- "e": float - Easting coordinate in IFC space
|
||||
- "n": float - Northing coordinate in IFC space
|
||||
- "radius": float - Curve radius (0 for endpoints/tangent PIs)
|
||||
- "pi_type": str - "ENDPOINT", "CURVE", or "TANGENT"
|
||||
|
||||
Raises:
|
||||
ValueError: If alignment has no horizontal layout or segments
|
||||
"""
|
||||
import ifcopenshell.api.alignment as align_api
|
||||
|
||||
ifc_file = tool.Ifc.get()
|
||||
|
||||
# Get horizontal layout
|
||||
h_layout = align_api.get_horizontal_layout(alignment)
|
||||
if h_layout is None:
|
||||
raise ValueError(f"Alignment #{alignment.id()} has no horizontal layout")
|
||||
|
||||
# Get all segments
|
||||
segments = align_api.get_layout_segments(h_layout)
|
||||
if not segments:
|
||||
raise ValueError(f"Alignment #{alignment.id()} has no segments")
|
||||
|
||||
# Filter out zero-length terminator segments
|
||||
real_segments = [seg for seg in segments if not cls.is_zero_length_segment(seg)]
|
||||
if not real_segments:
|
||||
raise ValueError(f"Alignment #{alignment.id()} has no real segments (only terminator)")
|
||||
|
||||
# Get vertices for all segments
|
||||
seg_vertices = [cls._get_segment_vertices_in_model_units(ifc_file, seg) for seg in real_segments]
|
||||
|
||||
pis = []
|
||||
|
||||
# First PI: start of first segment
|
||||
if seg_vertices[0] is not None:
|
||||
start_pt = seg_vertices[0][0]
|
||||
pis.append({"e": start_pt[0], "n": start_pt[1], "radius": 0.0, "pi_type": "ENDPOINT"})
|
||||
|
||||
# Process each segment for interior PIs
|
||||
prev_is_line = True
|
||||
for i, (seg, verts) in enumerate(zip(real_segments, seg_vertices)):
|
||||
if verts is None:
|
||||
prev_is_line = False
|
||||
continue
|
||||
|
||||
start, end, ti, ni = verts
|
||||
dp = seg.DesignParameters
|
||||
|
||||
if ti is not None:
|
||||
# Curve segment: TI is the PI
|
||||
radius = abs(float(dp.StartRadiusOfCurvature or dp.EndRadiusOfCurvature or 0))
|
||||
pis.append({"e": ti[0], "n": ti[1], "radius": radius, "pi_type": "CURVE"})
|
||||
prev_is_line = False
|
||||
else:
|
||||
# Line segment: if previous was also a line, connection = tangent PI
|
||||
if i > 0 and prev_is_line:
|
||||
pis.append({"e": start[0], "n": start[1], "radius": 0.0, "pi_type": "TANGENT"})
|
||||
prev_is_line = True
|
||||
|
||||
# Last PI: end of last segment
|
||||
if seg_vertices[-1] is not None:
|
||||
end_pt = seg_vertices[-1][1]
|
||||
if pis:
|
||||
last = pis[-1]
|
||||
dist = ((end_pt[0] - last["e"]) ** 2 + (end_pt[1] - last["n"]) ** 2) ** 0.5
|
||||
if dist > 0.001:
|
||||
pis.append({"e": end_pt[0], "n": end_pt[1], "radius": 0.0, "pi_type": "ENDPOINT"})
|
||||
else:
|
||||
pis.append({"e": end_pt[0], "n": end_pt[1], "radius": 0.0, "pi_type": "ENDPOINT"})
|
||||
|
||||
return pis
|
||||
|
||||
@classmethod
|
||||
def create_pi_edit_empties(
|
||||
cls,
|
||||
alignment: "ifcopenshell.entity_instance",
|
||||
pis: List[dict],
|
||||
) -> List[bpy.types.Object]:
|
||||
"""Create EMPTY objects at PI locations for editing.
|
||||
|
||||
Creates temporary Blender EMPTY objects at each PI position,
|
||||
allowing users to move them with standard Blender tools (G key).
|
||||
|
||||
The empties are:
|
||||
- Parented to the alignment object
|
||||
- Tagged with custom properties for identification
|
||||
- Named sequentially (PI.001, PI.002, etc.)
|
||||
|
||||
Args:
|
||||
alignment: The IfcAlignment entity
|
||||
pis: List of PI dicts from back_calculate_pis_from_alignment()
|
||||
|
||||
Returns:
|
||||
List of created Blender EMPTY objects, sorted by index
|
||||
"""
|
||||
alignment_obj = tool.Ifc.get_object(alignment)
|
||||
if alignment_obj is None:
|
||||
return []
|
||||
|
||||
# Get the collection to add objects to
|
||||
collection = None
|
||||
if alignment_obj.users_collection:
|
||||
collection = alignment_obj.users_collection[0]
|
||||
else:
|
||||
collection = bpy.context.scene.collection
|
||||
|
||||
import ifcopenshell.util.unit
|
||||
|
||||
alignment_id = alignment.id()
|
||||
empties = []
|
||||
# Georeference returns IFC project units; Blender world space is metres
|
||||
# (1 BU = 1 m), so scale up to place empties at the correct location.
|
||||
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
||||
|
||||
for i, pi in enumerate(pis):
|
||||
# IFC project units -> Blender world metres
|
||||
local = tool.Georeference.enh2xyz((float(pi["e"]), float(pi["n"]), 0.0))
|
||||
blender_pos = (local[0] * unit_scale, local[1] * unit_scale, local[2] * unit_scale)
|
||||
|
||||
# Create EMPTY object
|
||||
name = f"PI.{i + 1:03d}"
|
||||
empty = bpy.data.objects.new(name, None)
|
||||
empty.empty_display_type = "SPHERE"
|
||||
empty.empty_display_size = 2.0
|
||||
empty.location = blender_pos
|
||||
|
||||
# Tag with custom properties for identification
|
||||
empty["civil_is_pi_empty"] = True
|
||||
empty["civil_pi_index"] = i
|
||||
empty["civil_pi_radius"] = pi["radius"]
|
||||
empty["civil_alignment_id"] = alignment_id
|
||||
empty["civil_pi_type"] = pi["pi_type"]
|
||||
|
||||
# Parent to alignment object
|
||||
empty.parent = alignment_obj
|
||||
|
||||
# Link to collection
|
||||
collection.objects.link(empty)
|
||||
|
||||
empties.append(empty)
|
||||
|
||||
return empties
|
||||
|
||||
@classmethod
|
||||
def get_pi_edit_empties(cls, alignment_id: int) -> List[bpy.types.Object]:
|
||||
"""Find all PI EMPTY objects for a given alignment.
|
||||
|
||||
Searches all objects in the scene for empties tagged with
|
||||
the PI edit mode custom properties.
|
||||
|
||||
Args:
|
||||
alignment_id: The IFC ID of the alignment being edited
|
||||
|
||||
Returns:
|
||||
List of PI EMPTY objects, sorted by pi_index
|
||||
"""
|
||||
empties = []
|
||||
|
||||
for obj in bpy.data.objects:
|
||||
if obj.get("civil_is_pi_empty") and obj.get("civil_alignment_id") == alignment_id:
|
||||
empties.append(obj)
|
||||
|
||||
# Sort by PI index
|
||||
empties.sort(key=lambda e: e.get("civil_pi_index", 0))
|
||||
|
||||
return empties
|
||||
|
||||
@classmethod
|
||||
def remove_pi_edit_empties(cls, alignment_id: int) -> int:
|
||||
"""Remove all PI EMPTY objects for a given alignment.
|
||||
|
||||
Args:
|
||||
alignment_id: The IFC ID of the alignment being edited
|
||||
|
||||
Returns:
|
||||
Number of objects removed
|
||||
"""
|
||||
empties = cls.get_pi_edit_empties(alignment_id)
|
||||
removed_count = 0
|
||||
|
||||
for empty in empties:
|
||||
bpy.data.objects.remove(empty, do_unlink=True)
|
||||
removed_count += 1
|
||||
|
||||
return removed_count
|
||||
|
||||
@classmethod
|
||||
def collect_pis_from_empties(cls, alignment_id: int) -> Tuple[List[Tuple[float, float]], List[float]]:
|
||||
"""Gather current PI positions from EMPTY objects.
|
||||
|
||||
Reads the current positions of PI empties and converts them
|
||||
back to IFC coordinates for regenerating the alignment.
|
||||
|
||||
Args:
|
||||
alignment_id: The IFC ID of the alignment being edited
|
||||
|
||||
Returns:
|
||||
Tuple of:
|
||||
- hpoints: List of (x, y) tuples in IFC coordinates
|
||||
- radii: List of radii for interior PIs only (not first/last)
|
||||
"""
|
||||
empties = cls.get_pi_edit_empties(alignment_id)
|
||||
|
||||
if len(empties) < 2:
|
||||
return ([], [])
|
||||
|
||||
import ifcopenshell.util.unit
|
||||
|
||||
hpoints = []
|
||||
radii = []
|
||||
# Blender world metres -> IFC project units before georeferencing.
|
||||
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
||||
|
||||
for i, empty in enumerate(empties):
|
||||
# Blender metres -> IFC project units -> global E/N
|
||||
translation = empty.matrix_world.translation
|
||||
local = (translation[0] / unit_scale, translation[1] / unit_scale, translation[2] / unit_scale)
|
||||
ifc_pos = tool.Georeference.xyz2enh(local)
|
||||
hpoints.append((ifc_pos[0], ifc_pos[1]))
|
||||
|
||||
# Collect radii for interior PIs only (not first or last)
|
||||
if 0 < i < len(empties) - 1:
|
||||
radius = empty.get("civil_pi_radius", 0.0)
|
||||
radii.append(radius)
|
||||
|
||||
return (hpoints, radii)
|
||||
|
||||
@classmethod
|
||||
def set_layout_segments_selectable(cls, layout: "ifcopenshell.entity_instance", selectable: bool) -> None:
|
||||
"""Toggle viewport selectability of a layout's segment objects.
|
||||
|
||||
During PI edit mode the segment curves are made non-selectable so
|
||||
viewport clicks land on the PI edit empties rather than on the curves
|
||||
drawn along the alignment (which otherwise intercept the clicks).
|
||||
"""
|
||||
if layout is None:
|
||||
return
|
||||
for rel in getattr(layout, "IsNestedBy", []) or []:
|
||||
for segment in rel.RelatedObjects or []:
|
||||
if segment.is_a() == "IfcAlignmentSegment":
|
||||
obj = tool.Ifc.get_object(segment)
|
||||
if obj:
|
||||
obj.hide_select = not selectable
|
||||
|
||||
@classmethod
|
||||
def get_active_alignment(cls) -> ifcopenshell.entity_instance | None:
|
||||
if obj := tool.Blender.get_active_object():
|
||||
|
||||
@@ -488,8 +488,4 @@ class Root(bonsai.core.tool.Root):
|
||||
"IfcAnnotation",
|
||||
"IfcRelSpaceBoundary",
|
||||
)
|
||||
if version != "IFC4":
|
||||
# IFC4X3+: alignments are created like any other element
|
||||
# (Saikei); the create flow bootstraps the horizontal layout.
|
||||
products += ("IfcAlignment",)
|
||||
return products
|
||||
|
||||
@@ -8,7 +8,6 @@ markers =
|
||||
boundary
|
||||
brick
|
||||
bsdd
|
||||
civil
|
||||
clash
|
||||
classification
|
||||
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).
|
||||
|
||||
Operators tested:
|
||||
Horizontal: add_pi, remove_pi, recalculate_pis, clear_pis, create_alignment_by_pi
|
||||
Utility: name_segments
|
||||
CSV import: import_alignment_csv (EXEC_DEFAULT with explicit filepath)
|
||||
|
||||
Operators skipped (modal / viewport):
|
||||
pick_pi_from_viewport, enter_pi_edit_mode
|
||||
"""
|
||||
|
||||
import pytest
|
||||
@@ -37,7 +32,6 @@ import ifcopenshell
|
||||
import ifcopenshell.api.alignment as align_api
|
||||
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.ifc import IfcStore
|
||||
from test.bim.bootstrap import NewIfc4X3
|
||||
|
||||
|
||||
@@ -64,514 +58,6 @@ requires_geometry_engine = pytest.mark.skipif(
|
||||
pytestmark = pytest.mark.alignment
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def get_alignment_props():
|
||||
"""Shortcut to CivilAlignmentProperties on the scene."""
|
||||
return bpy.context.scene.CivilAlignmentProperties
|
||||
|
||||
|
||||
def create_empty_alignment(name="Test Alignment"):
|
||||
"""Create an IfcAlignment with an empty IfcAlignmentHorizontal layout.
|
||||
|
||||
Uses align_api.create() which creates IfcAlignment + IfcAlignmentHorizontal
|
||||
+ zero-length terminator + geometric representations + aggregation to project.
|
||||
|
||||
This is the minimal setup required for operators that need an active
|
||||
alignment (e.g. create_alignment_by_pi, recalculate_pis).
|
||||
|
||||
Returns:
|
||||
tuple: (alignment_entity, alignment_blender_obj)
|
||||
"""
|
||||
ifc_file = tool.Ifc.get()
|
||||
|
||||
# create() handles: IfcAlignment, IfcAlignmentHorizontal, IfcRelNests,
|
||||
# zero-length terminator, geometric representation, project aggregation
|
||||
alignment = align_api.create(ifc_file, name=name)
|
||||
|
||||
# Create Blender objects via the tool layer
|
||||
alignment_obj = tool.Alignment.create_hierarchy_for_alignment(alignment)
|
||||
|
||||
# Set as active so operators can find it via get_active_alignment()
|
||||
if alignment_obj:
|
||||
bpy.context.view_layer.objects.active = alignment_obj
|
||||
alignment_obj.select_set(True)
|
||||
|
||||
# Set active alignment ID in properties
|
||||
props = get_alignment_props()
|
||||
props.active_alignment_id = alignment.id()
|
||||
props.active_alignment_name = name
|
||||
|
||||
return alignment, alignment_obj
|
||||
|
||||
|
||||
def add_pis_to_props(pi_data):
|
||||
"""Add PIs to the props collection with specified coordinates.
|
||||
|
||||
Args:
|
||||
pi_data: list of (e, n, radius) tuples.
|
||||
First and last are auto-typed as ENDPOINT.
|
||||
"""
|
||||
props = get_alignment_props()
|
||||
for i, (e, n, radius) in enumerate(pi_data):
|
||||
bpy.ops.civil.add_pi()
|
||||
pi = props.pis[len(props.pis) - 1]
|
||||
pi.e = str(e)
|
||||
pi.n = str(n)
|
||||
if radius > 0:
|
||||
pi.radius = radius
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Horizontal PI Operators
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestAddPi(NewIfc4X3):
|
||||
"""Tests for CIVIL_OT_add_pi (civil.add_pi)."""
|
||||
|
||||
def test_add_first_pi_sets_endpoint_at_origin(self):
|
||||
props = get_alignment_props()
|
||||
result = bpy.ops.civil.add_pi()
|
||||
assert result == {"FINISHED"}
|
||||
assert len(props.pis) == 1
|
||||
assert float(props.pis[0].e) == 0.0
|
||||
assert float(props.pis[0].n) == 0.0
|
||||
assert props.pis[0].pi_type == "ENDPOINT"
|
||||
|
||||
def test_add_second_pi_offsets_from_first(self):
|
||||
props = get_alignment_props()
|
||||
bpy.ops.civil.add_pi()
|
||||
bpy.ops.civil.add_pi()
|
||||
assert len(props.pis) == 2
|
||||
assert props.pis[1].pi_type == "ENDPOINT"
|
||||
# Second PI should be offset 100 units east
|
||||
assert float(props.pis[1].e) == pytest.approx(100.0)
|
||||
assert float(props.pis[1].n) == pytest.approx(0.0)
|
||||
|
||||
def test_add_third_pi_extrapolates_and_changes_second_type(self):
|
||||
props = get_alignment_props()
|
||||
bpy.ops.civil.add_pi()
|
||||
bpy.ops.civil.add_pi()
|
||||
bpy.ops.civil.add_pi()
|
||||
assert len(props.pis) == 3
|
||||
# Second PI (index 1) should have been changed from ENDPOINT to TANGENT
|
||||
assert props.pis[1].pi_type == "TANGENT"
|
||||
# Third PI extrapolates direction
|
||||
assert float(props.pis[2].e) == pytest.approx(200.0)
|
||||
|
||||
def test_active_pi_index_tracks_last_added(self):
|
||||
props = get_alignment_props()
|
||||
bpy.ops.civil.add_pi()
|
||||
assert props.active_pi_index == 0
|
||||
bpy.ops.civil.add_pi()
|
||||
assert props.active_pi_index == 1
|
||||
bpy.ops.civil.add_pi()
|
||||
assert props.active_pi_index == 2
|
||||
|
||||
def test_add_pi_triggers_geometry_recalculation(self):
|
||||
"""After adding 2+ PIs, display_rows should be populated."""
|
||||
props = get_alignment_props()
|
||||
bpy.ops.civil.add_pi()
|
||||
bpy.ops.civil.add_pi()
|
||||
# With 2 PIs, we should have at least 2 point rows and 1 segment row
|
||||
assert len(props.display_rows) >= 2
|
||||
|
||||
|
||||
class TestRemovePi(NewIfc4X3):
|
||||
"""Tests for CIVIL_OT_remove_pi (civil.remove_pi)."""
|
||||
|
||||
def test_remove_pi_decrements_collection(self):
|
||||
props = get_alignment_props()
|
||||
bpy.ops.civil.add_pi()
|
||||
bpy.ops.civil.add_pi()
|
||||
bpy.ops.civil.add_pi()
|
||||
assert len(props.pis) == 3
|
||||
|
||||
# Select first point row in display_rows
|
||||
if props.display_rows:
|
||||
props.active_display_row_index = 0
|
||||
result = bpy.ops.civil.remove_pi()
|
||||
assert result == {"FINISHED"}
|
||||
assert len(props.pis) == 2
|
||||
|
||||
def test_remove_pi_from_single_item_list(self):
|
||||
props = get_alignment_props()
|
||||
bpy.ops.civil.add_pi()
|
||||
assert len(props.pis) == 1
|
||||
|
||||
# Use active_pi_index fallback (no display_rows for 1 PI)
|
||||
props.active_pi_index = 0
|
||||
# display_rows may be empty with 1 PI, so remove_pi uses active_pi_index
|
||||
props.display_rows.clear()
|
||||
result = bpy.ops.civil.remove_pi()
|
||||
assert result == {"FINISHED"}
|
||||
assert len(props.pis) == 0
|
||||
|
||||
def test_remove_pi_updates_active_index(self):
|
||||
props = get_alignment_props()
|
||||
bpy.ops.civil.add_pi()
|
||||
bpy.ops.civil.add_pi()
|
||||
bpy.ops.civil.add_pi()
|
||||
|
||||
# Select last point row
|
||||
props.active_pi_index = 2
|
||||
props.display_rows.clear()
|
||||
bpy.ops.civil.remove_pi()
|
||||
# active_pi_index should clamp to valid range
|
||||
assert props.active_pi_index <= len(props.pis) - 1
|
||||
|
||||
|
||||
class TestClearPis(NewIfc4X3):
|
||||
"""Tests for CIVIL_OT_clear_pis (civil.clear_pis).
|
||||
|
||||
Note: clear_pis defines invoke() with invoke_confirm, but calling via
|
||||
bpy.ops in Python uses EXEC_DEFAULT by default, skipping invoke.
|
||||
"""
|
||||
|
||||
def test_clear_pis_removes_all(self):
|
||||
props = get_alignment_props()
|
||||
for _ in range(5):
|
||||
bpy.ops.civil.add_pi()
|
||||
assert len(props.pis) == 5
|
||||
|
||||
result = bpy.ops.civil.clear_pis()
|
||||
assert result == {"FINISHED"}
|
||||
assert len(props.pis) == 0
|
||||
assert len(props.display_rows) == 0
|
||||
|
||||
def test_clear_pis_resets_indices(self):
|
||||
props = get_alignment_props()
|
||||
bpy.ops.civil.add_pi()
|
||||
bpy.ops.civil.add_pi()
|
||||
bpy.ops.civil.clear_pis()
|
||||
assert props.active_pi_index == 0
|
||||
assert props.active_display_row_index == 0
|
||||
|
||||
def test_clear_pis_with_active_alignment_removes_ifc(self):
|
||||
"""When an active alignment exists, clear_pis should remove it from IFC."""
|
||||
alignment, alignment_obj = create_empty_alignment()
|
||||
ifc_file = tool.Ifc.get()
|
||||
|
||||
props = get_alignment_props()
|
||||
bpy.ops.civil.add_pi()
|
||||
bpy.ops.civil.add_pi()
|
||||
|
||||
alignment_count_before = len(ifc_file.by_type("IfcAlignment"))
|
||||
bpy.ops.civil.clear_pis()
|
||||
|
||||
alignment_count_after = len(ifc_file.by_type("IfcAlignment"))
|
||||
assert alignment_count_after < alignment_count_before
|
||||
assert len(props.pis) == 0
|
||||
|
||||
def test_clear_pis_resolves_alignment_from_props_not_viewport(self):
|
||||
"""The alignment is resolved via props.active_alignment_id, so it is
|
||||
deleted even when the viewport's active object is something else
|
||||
(typically a segment curve after PI editing)."""
|
||||
alignment, alignment_obj = create_empty_alignment()
|
||||
ifc_file = tool.Ifc.get()
|
||||
bpy.context.view_layer.objects.active = None
|
||||
|
||||
props = get_alignment_props()
|
||||
bpy.ops.civil.add_pi()
|
||||
|
||||
alignment_count_before = len(ifc_file.by_type("IfcAlignment"))
|
||||
bpy.ops.civil.clear_pis()
|
||||
|
||||
assert len(ifc_file.by_type("IfcAlignment")) < alignment_count_before
|
||||
assert props.active_alignment_id == 0
|
||||
assert props.active_alignment_name == ""
|
||||
|
||||
|
||||
class TestRecalculatePis(NewIfc4X3):
|
||||
"""Tests for CIVIL_OT_recalculate_pis (civil.recalculate_pis)."""
|
||||
|
||||
def test_recalculate_populates_display_rows(self):
|
||||
props = get_alignment_props()
|
||||
add_pis_to_props([(0, 0, 0), (500, 0, 0), (1000, 200, 0)])
|
||||
|
||||
result = bpy.ops.civil.recalculate_pis()
|
||||
assert result == {"FINISHED"}
|
||||
assert len(props.display_rows) > 0
|
||||
|
||||
def test_recalculate_computes_geometry_values(self):
|
||||
"""PI geometry values (station, length_to_next) should be reasonable."""
|
||||
props = get_alignment_props()
|
||||
add_pis_to_props([(0, 0, 0), (500, 0, 0), (1000, 0, 0)])
|
||||
|
||||
bpy.ops.civil.recalculate_pis()
|
||||
|
||||
# Straight line: each segment should be 500 units
|
||||
assert props.pis[0].length_to_next == pytest.approx(500.0, abs=1.0)
|
||||
assert props.pis[1].length_to_next == pytest.approx(500.0, abs=1.0)
|
||||
|
||||
@requires_geometry_engine
|
||||
def test_recalculate_with_active_alignment_updates_ifc(self):
|
||||
"""When an active alignment exists, recalculate should update IFC segments."""
|
||||
alignment, alignment_obj = create_empty_alignment()
|
||||
ifc_file = tool.Ifc.get()
|
||||
|
||||
props = get_alignment_props()
|
||||
add_pis_to_props([(0, 0, 0), (500, 0, 0), (1000, 200, 0)])
|
||||
|
||||
# First, create alignment segments
|
||||
bpy.ops.civil.create_alignment_by_pi()
|
||||
|
||||
# Re-select alignment object (create_alignment_by_pi may change selection)
|
||||
bpy.context.view_layer.objects.active = alignment_obj
|
||||
alignment_obj.select_set(True)
|
||||
|
||||
# Modify a PI
|
||||
props.pis[1].e = str(600.0)
|
||||
|
||||
# Recalculate should update IFC in-place
|
||||
result = bpy.ops.civil.recalculate_pis()
|
||||
assert result == {"FINISHED"}
|
||||
|
||||
# IFC should still have segments
|
||||
segments = ifc_file.by_type("IfcAlignmentSegment")
|
||||
assert len(segments) >= 2
|
||||
|
||||
|
||||
@requires_geometry_engine
|
||||
class TestCreateAlignmentByPi(NewIfc4X3):
|
||||
"""Tests for CIVIL_OT_create_alignment_by_pi (civil.create_alignment_by_pi).
|
||||
|
||||
This operator requires an existing empty alignment set as active.
|
||||
"""
|
||||
|
||||
def test_create_alignment_creates_ifc_segments(self):
|
||||
"""Basic 3-PI alignment: creates tangent + tangent segments in IFC."""
|
||||
alignment, alignment_obj = create_empty_alignment()
|
||||
ifc_file = tool.Ifc.get()
|
||||
|
||||
props = get_alignment_props()
|
||||
add_pis_to_props([(0, 0, 0), (500, 0, 0), (1000, 200, 0)])
|
||||
|
||||
result = bpy.ops.civil.create_alignment_by_pi()
|
||||
assert result == {"FINISHED"}
|
||||
|
||||
# IFC should contain alignment segments
|
||||
segments = ifc_file.by_type("IfcAlignmentSegment")
|
||||
assert len(segments) >= 2
|
||||
|
||||
# Alignment should still exist
|
||||
alignments = ifc_file.by_type("IfcAlignment")
|
||||
assert len(alignments) == 1
|
||||
|
||||
# Horizontal layout should exist
|
||||
horizontals = ifc_file.by_type("IfcAlignmentHorizontal")
|
||||
assert len(horizontals) == 1
|
||||
|
||||
def test_create_alignment_with_curve_creates_arc_segment(self):
|
||||
"""3-PI alignment with radius on middle PI creates LINE + ARC + LINE."""
|
||||
alignment, alignment_obj = create_empty_alignment()
|
||||
ifc_file = tool.Ifc.get()
|
||||
|
||||
props = get_alignment_props()
|
||||
add_pis_to_props([(0, 0, 0), (500, 0, 300), (1000, 200, 0)])
|
||||
|
||||
result = bpy.ops.civil.create_alignment_by_pi()
|
||||
assert result == {"FINISHED"}
|
||||
|
||||
# Check segment design parameter types
|
||||
segments = ifc_file.by_type("IfcAlignmentSegment")
|
||||
segment_types = []
|
||||
for seg in segments:
|
||||
dp = seg.DesignParameters
|
||||
if dp and hasattr(dp, "PredefinedType"):
|
||||
segment_types.append(dp.PredefinedType)
|
||||
|
||||
# Should have at least LINE and CIRCULARARC
|
||||
assert "LINE" in segment_types
|
||||
assert "CIRCULARARC" in segment_types
|
||||
|
||||
def test_create_alignment_produces_blender_objects(self):
|
||||
"""After creation, Blender scene should contain alignment objects."""
|
||||
alignment, alignment_obj = create_empty_alignment()
|
||||
|
||||
props = get_alignment_props()
|
||||
add_pis_to_props([(0, 0, 0), (500, 0, 0), (1000, 200, 0)])
|
||||
|
||||
bpy.ops.civil.create_alignment_by_pi()
|
||||
|
||||
# Should have at least the alignment object in the scene
|
||||
alignment_objects = [
|
||||
obj for obj in bpy.data.objects
|
||||
if tool.Ifc.get_entity(obj) and tool.Ifc.get_entity(obj).is_a("IfcAlignment")
|
||||
]
|
||||
assert len(alignment_objects) >= 1
|
||||
|
||||
def test_create_straight_alignment_two_pis(self):
|
||||
"""Minimal alignment: 2 PIs producing a single tangent."""
|
||||
alignment, alignment_obj = create_empty_alignment()
|
||||
ifc_file = tool.Ifc.get()
|
||||
|
||||
props = get_alignment_props()
|
||||
add_pis_to_props([(0, 0, 0), (1000, 0, 0)])
|
||||
|
||||
result = bpy.ops.civil.create_alignment_by_pi()
|
||||
assert result == {"FINISHED"}
|
||||
|
||||
segments = ifc_file.by_type("IfcAlignmentSegment")
|
||||
assert len(segments) >= 1
|
||||
|
||||
|
||||
@requires_geometry_engine
|
||||
class TestEndToEndAlignmentCreation(NewIfc4X3):
|
||||
"""Full workflow: create alignment, add PIs, create IFC, validate."""
|
||||
|
||||
def test_basic_three_pi_alignment_workflow(self):
|
||||
"""Scenario 1: Create a basic 3-PI alignment end-to-end."""
|
||||
alignment, alignment_obj = create_empty_alignment("E2E Test Alignment")
|
||||
ifc_file = tool.Ifc.get()
|
||||
props = get_alignment_props()
|
||||
|
||||
# Add 3 PIs: straight segment then angled
|
||||
add_pis_to_props([(0, 0, 0), (500, 0, 0), (1000, 200, 0)])
|
||||
assert len(props.pis) == 3
|
||||
|
||||
# Create alignment
|
||||
result = bpy.ops.civil.create_alignment_by_pi()
|
||||
assert result == {"FINISHED"}
|
||||
|
||||
# Validate IFC entities
|
||||
alignments = ifc_file.by_type("IfcAlignment")
|
||||
assert len(alignments) == 1
|
||||
assert alignments[0].Name == "E2E Test Alignment"
|
||||
|
||||
horizontals = ifc_file.by_type("IfcAlignmentHorizontal")
|
||||
assert len(horizontals) == 1
|
||||
|
||||
segments = ifc_file.by_type("IfcAlignmentSegment")
|
||||
# At minimum: tangent + tangent (+ zero-length terminator possibly)
|
||||
assert len(segments) >= 2
|
||||
|
||||
# All segment design params should be IfcAlignmentHorizontalSegment
|
||||
for seg in segments:
|
||||
dp = seg.DesignParameters
|
||||
if dp:
|
||||
assert dp.is_a("IfcAlignmentHorizontalSegment")
|
||||
|
||||
def test_three_pi_with_curve_workflow(self):
|
||||
"""Scenario 2: 3-PI alignment with curve produces correct IFC segments."""
|
||||
alignment, alignment_obj = create_empty_alignment("Curved Alignment")
|
||||
ifc_file = tool.Ifc.get()
|
||||
props = get_alignment_props()
|
||||
|
||||
# PI with 300m radius on middle point
|
||||
add_pis_to_props([(0, 0, 0), (500, 0, 300), (1000, 500, 0)])
|
||||
|
||||
bpy.ops.civil.create_alignment_by_pi()
|
||||
|
||||
segments = ifc_file.by_type("IfcAlignmentSegment")
|
||||
predefined_types = set()
|
||||
for seg in segments:
|
||||
dp = seg.DesignParameters
|
||||
if dp and hasattr(dp, "PredefinedType"):
|
||||
predefined_types.add(dp.PredefinedType)
|
||||
|
||||
assert "LINE" in predefined_types
|
||||
assert "CIRCULARARC" in predefined_types
|
||||
|
||||
def test_five_pi_complex_alignment(self):
|
||||
"""Scenario 3: 5-PI alignment with multiple curves."""
|
||||
alignment, alignment_obj = create_empty_alignment("Complex Alignment")
|
||||
ifc_file = tool.Ifc.get()
|
||||
props = get_alignment_props()
|
||||
|
||||
add_pis_to_props([
|
||||
(0, 0, 0),
|
||||
(300, 0, 200),
|
||||
(600, 300, 150),
|
||||
(900, 300, 250),
|
||||
(1200, 0, 0),
|
||||
])
|
||||
|
||||
result = bpy.ops.civil.create_alignment_by_pi()
|
||||
assert result == {"FINISHED"}
|
||||
|
||||
segments = ifc_file.by_type("IfcAlignmentSegment")
|
||||
# 5 PIs with 3 interior curves → many segments
|
||||
assert len(segments) >= 4
|
||||
|
||||
def test_add_remove_pi_cycle(self):
|
||||
"""Scenario 4: Add/remove PIs cycle - props stay consistent."""
|
||||
props = get_alignment_props()
|
||||
|
||||
bpy.ops.civil.add_pi()
|
||||
bpy.ops.civil.add_pi()
|
||||
bpy.ops.civil.add_pi()
|
||||
assert len(props.pis) == 3
|
||||
|
||||
# Remove middle PI
|
||||
props.display_rows.clear()
|
||||
props.active_pi_index = 1
|
||||
bpy.ops.civil.remove_pi()
|
||||
assert len(props.pis) == 2
|
||||
|
||||
# Add two more
|
||||
bpy.ops.civil.add_pi()
|
||||
bpy.ops.civil.add_pi()
|
||||
assert len(props.pis) == 4
|
||||
|
||||
# Clear all
|
||||
bpy.ops.civil.clear_pis()
|
||||
assert len(props.pis) == 0
|
||||
assert len(props.display_rows) == 0
|
||||
|
||||
@requires_geometry_engine
|
||||
class TestEndToEndIfcRoundtrip(NewIfc4X3):
|
||||
"""IFC save/reload roundtrip validation."""
|
||||
|
||||
def test_alignment_survives_ifc_roundtrip(self):
|
||||
"""Create alignment, save to temp file, reload, verify entities."""
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
alignment, alignment_obj = create_empty_alignment("Roundtrip Test")
|
||||
ifc_file = tool.Ifc.get()
|
||||
props = get_alignment_props()
|
||||
|
||||
add_pis_to_props([(0, 0, 0), (500, 0, 300), (1000, 200, 0)])
|
||||
bpy.ops.civil.create_alignment_by_pi()
|
||||
|
||||
# Count entities before save
|
||||
alignment_count = len(ifc_file.by_type("IfcAlignment"))
|
||||
horizontal_count = len(ifc_file.by_type("IfcAlignmentHorizontal"))
|
||||
segment_count = len(ifc_file.by_type("IfcAlignmentSegment"))
|
||||
|
||||
assert alignment_count == 1
|
||||
assert horizontal_count == 1
|
||||
assert segment_count >= 2
|
||||
|
||||
# Save to temp file
|
||||
temp_path = os.path.join(tempfile.gettempdir(), "alignment_roundtrip_test.ifc")
|
||||
ifc_file.write(temp_path)
|
||||
|
||||
# Reload
|
||||
reloaded = ifcopenshell.open(temp_path)
|
||||
|
||||
# Verify entity counts match
|
||||
assert len(reloaded.by_type("IfcAlignment")) == alignment_count
|
||||
assert len(reloaded.by_type("IfcAlignmentHorizontal")) == horizontal_count
|
||||
assert len(reloaded.by_type("IfcAlignmentSegment")) == segment_count
|
||||
|
||||
# Verify alignment name survived
|
||||
assert reloaded.by_type("IfcAlignment")[0].Name == "Roundtrip Test"
|
||||
|
||||
# Cleanup
|
||||
os.unlink(temp_path)
|
||||
|
||||
|
||||
# Station formatting is tool-layer now (tool.Alignment.format_station wrapping
|
||||
# ifcopenshell.util.alignment.station_as_string) — see TestFormatStation in
|
||||
# test/tool/test_alignment.py.
|
||||
|
||||
|
||||
@requires_geometry_engine
|
||||
class TestImportAlignmentCsv(NewIfc4X3):
|
||||
"""bim.import_alignment_csv — the single, merged CSV import path.
|
||||
@@ -590,9 +76,8 @@ class TestImportAlignmentCsv(NewIfc4X3):
|
||||
result = bpy.ops.bim.import_alignment_csv("EXEC_DEFAULT", filepath=filepath)
|
||||
assert result == {"FINISHED"}
|
||||
|
||||
props = get_alignment_props()
|
||||
assert props.active_alignment_id != 0
|
||||
alignment = tool.Ifc.get().by_id(props.active_alignment_id)
|
||||
alignment = tool.Alignment.get_active_alignment()
|
||||
assert alignment is not None
|
||||
assert alignment.is_a("IfcAlignment")
|
||||
assert tool.Ifc.get_object(alignment) is not None
|
||||
|
||||
@@ -607,64 +92,5 @@ class TestImportAlignmentCsv(NewIfc4X3):
|
||||
result = bpy.ops.bim.import_alignment_csv("EXEC_DEFAULT", filepath=filepath)
|
||||
assert result == {"FINISHED"}
|
||||
|
||||
props = get_alignment_props()
|
||||
alignment = tool.Ifc.get().by_id(props.active_alignment_id)
|
||||
alignment = tool.Alignment.get_active_alignment()
|
||||
assert align_api.get_vertical_layout(alignment) is not None
|
||||
|
||||
|
||||
class TestAddElementAlignment(NewIfc4X3):
|
||||
"""Shift-A Add Element route for IfcAlignment (spec intro / 1.1).
|
||||
|
||||
Reinstated from the 0.8 saikei branch in minimal scope: IfcAlignment in
|
||||
the Definition dropdown; creating one bootstraps the horizontal layout,
|
||||
stationing referent, zero-length terminator, and project aggregation —
|
||||
landing in the same state as panel creation.
|
||||
"""
|
||||
|
||||
def _add_alignment(self, name=""):
|
||||
import bonsai.bim.module.root.data
|
||||
|
||||
bonsai.bim.module.root.data.IfcClassData.load()
|
||||
root_props = tool.Root.get_root_props()
|
||||
root_props.ifc_product = "IfcAlignment"
|
||||
root_props.ifc_class = "IfcAlignment"
|
||||
if name:
|
||||
root_props.name = name
|
||||
return bpy.ops.bim.add_element()
|
||||
|
||||
def test_ifc_alignment_offered_in_products(self):
|
||||
products = tool.Root.get_ifc_products()
|
||||
assert "IfcAlignment" in products
|
||||
|
||||
def test_add_element_bootstraps_horizontal_layout(self):
|
||||
result = self._add_alignment(name="Route 66")
|
||||
assert result == {"FINISHED"}
|
||||
|
||||
ifc_file = tool.Ifc.get()
|
||||
alignments = ifc_file.by_type("IfcAlignment")
|
||||
assert len(alignments) == 1
|
||||
alignment = alignments[0]
|
||||
|
||||
h_layout = align_api.get_horizontal_layout(alignment)
|
||||
assert h_layout is not None
|
||||
assert align_api.has_zero_length_segment(h_layout)
|
||||
|
||||
def test_add_element_alignment_is_aggregated_not_contained(self):
|
||||
self._add_alignment()
|
||||
alignment = tool.Ifc.get().by_type("IfcAlignment")[0]
|
||||
assert alignment.Decomposes
|
||||
assert alignment.Decomposes[0].RelatingObject.is_a("IfcProject")
|
||||
assert not alignment.ContainedInStructure
|
||||
|
||||
def test_add_element_creates_stationing_referent_and_sets_active(self):
|
||||
self._add_alignment(name="Route 66")
|
||||
alignment = tool.Ifc.get().by_type("IfcAlignment")[0]
|
||||
|
||||
nest = align_api.get_stationing_nest(tool.Ifc.get(), alignment)
|
||||
assert nest is not None
|
||||
referent = nest.RelatedObjects[0]
|
||||
assert referent.Name.startswith("Route 66")
|
||||
|
||||
props = get_alignment_props()
|
||||
assert props.active_alignment_id == alignment.id()
|
||||
assert props.active_alignment_name == "Route 66"
|
||||
|
||||
@@ -22,181 +22,6 @@ import bonsai.core.alignment as subject
|
||||
from test.core.bootstrap import alignment, ifc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class FakeIfcEntity(dict):
|
||||
"""A JSON-serializable stand-in for an IFC entity.
|
||||
|
||||
Inherits from dict so json.dumps can serialize it when it appears as an
|
||||
argument to Prophecy-tracked tool methods. The ifc_class key drives
|
||||
is_a(), and the name key drives the Name property.
|
||||
"""
|
||||
|
||||
def is_a(self, ifc_class: str) -> bool:
|
||||
return self.get("ifc_class") == ifc_class
|
||||
|
||||
@property
|
||||
def Name(self) -> str:
|
||||
return self.get("name", "Test Entity")
|
||||
|
||||
|
||||
class FakeIfcFile:
|
||||
"""Minimal stand-in for an open IFC file."""
|
||||
|
||||
def __init__(self, entity=None, not_found: bool = False):
|
||||
self._entity = entity
|
||||
self._not_found = not_found
|
||||
|
||||
def by_id(self, entity_id: int):
|
||||
if self._not_found:
|
||||
raise RuntimeError(f"Could not find #{entity_id}")
|
||||
return self._entity
|
||||
|
||||
|
||||
def make_alignment_entity(name: str = "Test Alignment") -> FakeIfcEntity:
|
||||
return FakeIfcEntity({"ifc_class": "IfcAlignment", "name": name})
|
||||
|
||||
|
||||
def make_non_alignment_entity(name: str = "Wall") -> FakeIfcEntity:
|
||||
return FakeIfcEntity({"ifc_class": "IfcWall", "name": name})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# enter_pi_edit_mode
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEnterPiEditMode:
|
||||
def test_raises_when_no_ifc_file_loaded(self, ifc, alignment):
|
||||
ifc.get().should_be_called().will_return(None)
|
||||
with pytest.raises(ValueError, match="No IFC file loaded"):
|
||||
subject.enter_pi_edit_mode(ifc, alignment, alignment_id=1)
|
||||
|
||||
def test_raises_when_alignment_not_found(self, ifc, alignment):
|
||||
ifc.get().should_be_called().will_return(FakeIfcFile(not_found=True))
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
subject.enter_pi_edit_mode(ifc, alignment, alignment_id=1)
|
||||
|
||||
def test_raises_when_entity_is_not_an_alignment(self, ifc, alignment):
|
||||
entity = make_non_alignment_entity()
|
||||
ifc.get().should_be_called().will_return(FakeIfcFile(entity=entity))
|
||||
with pytest.raises(ValueError, match="not an IfcAlignment"):
|
||||
subject.enter_pi_edit_mode(ifc, alignment, alignment_id=1)
|
||||
|
||||
def test_raises_when_alignment_has_no_horizontal_layout(self, ifc, alignment):
|
||||
entity = make_alignment_entity()
|
||||
ifc.get().should_be_called().will_return(FakeIfcFile(entity=entity))
|
||||
alignment.get_horizontal_layout(entity).should_be_called().will_return(None)
|
||||
with pytest.raises(ValueError, match="no horizontal layout"):
|
||||
subject.enter_pi_edit_mode(ifc, alignment, alignment_id=1)
|
||||
|
||||
def test_raises_when_alignment_has_no_real_segments(self, ifc, alignment):
|
||||
entity = make_alignment_entity()
|
||||
ifc.get().should_be_called().will_return(FakeIfcFile(entity=entity))
|
||||
alignment.get_horizontal_layout(entity).should_be_called().will_return("h_layout")
|
||||
alignment.layout_has_real_segments("h_layout").should_be_called().will_return(False)
|
||||
with pytest.raises(ValueError, match="no editable segments"):
|
||||
subject.enter_pi_edit_mode(ifc, alignment, alignment_id=1)
|
||||
|
||||
def test_raises_when_back_calculated_pis_fewer_than_two(self, ifc, alignment):
|
||||
entity = make_alignment_entity()
|
||||
ifc.get().should_be_called().will_return(FakeIfcFile(entity=entity))
|
||||
alignment.get_horizontal_layout(entity).should_be_called().will_return("h_layout")
|
||||
alignment.layout_has_real_segments("h_layout").should_be_called().will_return(True)
|
||||
alignment.back_calculate_pis_from_alignment(entity).should_be_called().will_return([(0.0, 0.0)])
|
||||
with pytest.raises(ValueError, match="at least 2 PIs"):
|
||||
subject.enter_pi_edit_mode(ifc, alignment, alignment_id=1)
|
||||
|
||||
def test_returns_empties_for_valid_alignment(self, ifc, alignment):
|
||||
entity = make_alignment_entity()
|
||||
pis = [(0.0, 0.0), (100.0, 0.0), (200.0, 50.0)]
|
||||
empties = ["empty_0", "empty_1", "empty_2"]
|
||||
ifc.get().should_be_called().will_return(FakeIfcFile(entity=entity))
|
||||
alignment.get_horizontal_layout(entity).should_be_called().will_return("h_layout")
|
||||
alignment.layout_has_real_segments("h_layout").should_be_called().will_return(True)
|
||||
alignment.back_calculate_pis_from_alignment(entity).should_be_called().will_return(pis)
|
||||
alignment.create_pi_edit_empties(entity, pis).should_be_called().will_return(empties)
|
||||
result = subject.enter_pi_edit_mode(ifc, alignment, alignment_id=1)
|
||||
assert result == empties
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# exit_pi_edit_mode
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestExitPiEditMode:
|
||||
def test_cleans_up_and_returns_true_when_no_ifc_file(self, ifc, alignment):
|
||||
ifc.get().should_be_called().will_return(None)
|
||||
alignment.remove_pi_edit_empties(1).should_be_called()
|
||||
result = subject.exit_pi_edit_mode(ifc, alignment, alignment_id=1, apply=True)
|
||||
assert result is True
|
||||
|
||||
def test_cleans_up_and_returns_true_when_alignment_deleted(self, ifc, alignment):
|
||||
ifc.get().should_be_called().will_return(FakeIfcFile(not_found=True))
|
||||
alignment.remove_pi_edit_empties(1).should_be_called()
|
||||
result = subject.exit_pi_edit_mode(ifc, alignment, alignment_id=1, apply=True)
|
||||
assert result is True
|
||||
|
||||
def test_removes_empties_and_returns_true_when_apply_is_false(self, ifc, alignment):
|
||||
entity = make_alignment_entity()
|
||||
ifc.get().should_be_called().will_return(FakeIfcFile(entity=entity))
|
||||
alignment.remove_pi_edit_empties(1).should_be_called()
|
||||
result = subject.exit_pi_edit_mode(ifc, alignment, alignment_id=1, apply=False)
|
||||
assert result is True
|
||||
|
||||
def test_raises_when_fewer_than_two_pis_collected_on_apply(self, ifc, alignment):
|
||||
entity = make_alignment_entity()
|
||||
ifc.get().should_be_called().will_return(FakeIfcFile(entity=entity))
|
||||
alignment.collect_pis_from_empties(1).should_be_called().will_return(([(0.0, 0.0)], [0.0]))
|
||||
with pytest.raises(ValueError, match="At least 2 PIs"):
|
||||
subject.exit_pi_edit_mode(ifc, alignment, alignment_id=1, apply=True)
|
||||
|
||||
def test_raises_when_no_horizontal_layout_on_apply(self, ifc, alignment):
|
||||
entity = make_alignment_entity()
|
||||
hpoints = [(0.0, 0.0), (100.0, 0.0)]
|
||||
radii = [0.0, 0.0]
|
||||
ifc.get().should_be_called().will_return(FakeIfcFile(entity=entity))
|
||||
alignment.collect_pis_from_empties(1).should_be_called().will_return((hpoints, radii))
|
||||
alignment.get_horizontal_layout(entity).should_be_called().will_return(None)
|
||||
with pytest.raises(ValueError, match="no horizontal layout"):
|
||||
subject.exit_pi_edit_mode(ifc, alignment, alignment_id=1, apply=True)
|
||||
|
||||
def test_applies_new_pis_without_layout_obj_and_returns_true(self, ifc, alignment):
|
||||
entity = make_alignment_entity()
|
||||
hpoints = [(0.0, 0.0), (100.0, 0.0)]
|
||||
radii = [0.0, 0.0]
|
||||
ifc.get().should_be_called().will_return(FakeIfcFile(entity=entity))
|
||||
alignment.collect_pis_from_empties(1).should_be_called().will_return((hpoints, radii))
|
||||
alignment.get_horizontal_layout(entity).should_be_called().will_return("h_layout")
|
||||
alignment.remove_pi_edit_empties(1).should_be_called()
|
||||
alignment.remove_layout_segment_objects("h_layout").should_be_called()
|
||||
alignment.clear_layout_segments("h_layout").should_be_called()
|
||||
alignment.layout_by_pi_method("h_layout", hpoints, radii).should_be_called()
|
||||
ifc.get_object("h_layout").should_be_called().will_return(None)
|
||||
result = subject.exit_pi_edit_mode(ifc, alignment, alignment_id=1, apply=True)
|
||||
assert result is True
|
||||
|
||||
def test_creates_segment_objects_when_layout_obj_exists(self, ifc, alignment):
|
||||
entity = make_alignment_entity()
|
||||
hpoints = [(0.0, 0.0), (100.0, 0.0)]
|
||||
radii = [0.0, 0.0]
|
||||
ifc.get().should_be_called().will_return(FakeIfcFile(entity=entity))
|
||||
alignment.collect_pis_from_empties(1).should_be_called().will_return((hpoints, radii))
|
||||
alignment.get_horizontal_layout(entity).should_be_called().will_return("h_layout")
|
||||
alignment.remove_pi_edit_empties(1).should_be_called()
|
||||
alignment.remove_layout_segment_objects("h_layout").should_be_called()
|
||||
alignment.clear_layout_segments("h_layout").should_be_called()
|
||||
alignment.layout_by_pi_method("h_layout", hpoints, radii).should_be_called()
|
||||
ifc.get_object("h_layout").should_be_called().will_return("layout_obj")
|
||||
alignment.create_objects_for_layout_segments("h_layout", "layout_obj").should_be_called()
|
||||
result = subject.exit_pi_edit_mode(ifc, alignment, alignment_id=1, apply=True)
|
||||
assert result is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# import_alignment_csv
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import math
|
||||
import pytest
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
@@ -51,12 +50,6 @@ requires_geometry_engine = pytest.mark.skipif(
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
TOLERANCE = 1e-9
|
||||
|
||||
|
||||
def assert_close(actual: float, expected: float, tol: float = TOLERANCE) -> None:
|
||||
assert abs(actual - expected) < tol, f"Expected {expected}, got {actual} (diff={abs(actual-expected):.2e})"
|
||||
|
||||
|
||||
class _FakeDesignParams:
|
||||
"""Minimal stand-in for an IfcAlignmentHorizontalSegment or similar."""
|
||||
@@ -77,220 +70,6 @@ class _FakeSegment:
|
||||
self.DesignParameters = design_params
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# calculate_pi_geometry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCalculatePiGeometry(NewFile):
|
||||
def test_returns_empty_result_for_empty_pi_list(self):
|
||||
result = subject.calculate_pi_geometry([])
|
||||
assert result.stations == []
|
||||
assert result.total_length == 0.0
|
||||
|
||||
def test_returns_single_point_result_for_one_pi(self):
|
||||
result = subject.calculate_pi_geometry([(50.0, 100.0)])
|
||||
assert len(result.stations) == 1
|
||||
assert result.total_length == 0.0
|
||||
|
||||
def test_calculates_length_between_two_points(self):
|
||||
result = subject.calculate_pi_geometry([(0.0, 0.0), (100.0, 0.0)])
|
||||
assert_close(result.total_length, 100.0)
|
||||
assert_close(result.lengths[0], 100.0)
|
||||
|
||||
def test_calculates_due_east_direction(self):
|
||||
result = subject.calculate_pi_geometry([(0.0, 0.0), (100.0, 0.0)])
|
||||
assert_close(result.directions[0], 0.0)
|
||||
|
||||
def test_calculates_due_north_direction(self):
|
||||
result = subject.calculate_pi_geometry([(0.0, 0.0), (0.0, 100.0)])
|
||||
assert_close(result.directions[0], math.pi / 2)
|
||||
|
||||
def test_calculates_diagonal_length(self):
|
||||
result = subject.calculate_pi_geometry([(0.0, 0.0), (3.0, 4.0)])
|
||||
assert_close(result.total_length, 5.0)
|
||||
|
||||
def test_calculates_stations_for_three_pis(self):
|
||||
pis = [(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)]
|
||||
result = subject.calculate_pi_geometry(pis)
|
||||
assert_close(result.stations[0], 0.0)
|
||||
assert_close(result.stations[1], 100.0)
|
||||
assert_close(result.stations[2], 200.0)
|
||||
assert_close(result.total_length, 200.0)
|
||||
|
||||
def test_applies_start_station_offset(self):
|
||||
pis = [(0.0, 0.0), (100.0, 0.0)]
|
||||
result = subject.calculate_pi_geometry(pis, start_station=1000.0)
|
||||
assert_close(result.stations[0], 1000.0)
|
||||
assert_close(result.stations[1], 1100.0)
|
||||
assert_close(result.total_length, 100.0)
|
||||
|
||||
def test_last_pi_has_zero_length_and_direction(self):
|
||||
result = subject.calculate_pi_geometry([(0.0, 0.0), (100.0, 0.0)])
|
||||
assert_close(result.lengths[-1], 0.0)
|
||||
assert_close(result.directions[-1], 0.0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# calculate_tangent_length T = R * tan(Δ/2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCalculateTangentLength(NewFile):
|
||||
def test_returns_zero_for_zero_radius(self):
|
||||
assert_close(subject.calculate_tangent_length(0.0, math.pi / 2), 0.0)
|
||||
|
||||
def test_returns_zero_for_zero_deflection(self):
|
||||
assert_close(subject.calculate_tangent_length(300.0, 0.0), 0.0)
|
||||
|
||||
def test_calculates_tangent_for_30_degree_deflection(self):
|
||||
deflection = math.radians(30)
|
||||
expected = 300.0 * math.tan(deflection / 2)
|
||||
assert_close(subject.calculate_tangent_length(300.0, deflection), expected)
|
||||
|
||||
def test_calculates_tangent_for_90_degree_deflection(self):
|
||||
deflection = math.pi / 2
|
||||
expected = 100.0 * math.tan(math.pi / 4) # R * tan(45°) = R
|
||||
assert_close(subject.calculate_tangent_length(100.0, deflection), expected)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# calculate_arc_length L = R * Δ
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCalculateArcLength(NewFile):
|
||||
def test_calculates_arc_for_90_degree_curve(self):
|
||||
expected = 100.0 * math.pi / 2
|
||||
assert_close(subject.calculate_arc_length(100.0, math.pi / 2), expected)
|
||||
|
||||
def test_calculates_arc_for_full_circle(self):
|
||||
expected = 50.0 * 2 * math.pi
|
||||
assert_close(subject.calculate_arc_length(50.0, 2 * math.pi), expected)
|
||||
|
||||
def test_zero_radius_yields_zero_length(self):
|
||||
assert_close(subject.calculate_arc_length(0.0, math.pi / 2), 0.0)
|
||||
|
||||
def test_zero_deflection_yields_zero_length(self):
|
||||
assert_close(subject.calculate_arc_length(100.0, 0.0), 0.0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# deflection_angle_from_points
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDeflectionAngleFromPoints(NewFile):
|
||||
def test_returns_zero_for_straight_alignment(self):
|
||||
angle = subject.deflection_angle_from_points((0.0, 0.0), (100.0, 0.0), (200.0, 0.0))
|
||||
assert_close(angle, 0.0)
|
||||
|
||||
def test_positive_for_90_degree_left_turn(self):
|
||||
"""Turning left (CCW) is a positive deflection."""
|
||||
angle = subject.deflection_angle_from_points((0.0, 0.0), (100.0, 0.0), (100.0, 100.0))
|
||||
assert_close(angle, math.pi / 2)
|
||||
|
||||
def test_negative_for_90_degree_right_turn(self):
|
||||
"""Turning right (CW) is a negative deflection."""
|
||||
angle = subject.deflection_angle_from_points((0.0, 0.0), (100.0, 0.0), (100.0, -100.0))
|
||||
assert_close(angle, -math.pi / 2)
|
||||
|
||||
def test_returns_pi_for_u_turn(self):
|
||||
"""180-degree turn."""
|
||||
angle = subject.deflection_angle_from_points((0.0, 0.0), (100.0, 0.0), (0.0, 0.0))
|
||||
assert_close(abs(angle), math.pi)
|
||||
|
||||
def test_normalises_angle_into_minus_pi_to_pi_range(self):
|
||||
"""Result must always be in (-π, π]."""
|
||||
angle = subject.deflection_angle_from_points((0.0, 0.0), (100.0, 0.0), (50.0, -50.0))
|
||||
assert -math.pi < angle <= math.pi
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# arc_length_at_pi
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestArcLengthAtPi(NewFile):
|
||||
def test_returns_zero_for_zero_radius(self):
|
||||
arc = subject.arc_length_at_pi((0.0, 0.0), (100.0, 0.0), (100.0, 100.0), radius=0.0)
|
||||
assert_close(arc, 0.0)
|
||||
|
||||
def test_returns_zero_for_negative_radius(self):
|
||||
arc = subject.arc_length_at_pi((0.0, 0.0), (100.0, 0.0), (100.0, 100.0), radius=-100.0)
|
||||
assert_close(arc, 0.0)
|
||||
|
||||
def test_calculates_arc_for_90_degree_left_turn(self):
|
||||
expected = 100.0 * math.pi / 2
|
||||
arc = subject.arc_length_at_pi((0.0, 0.0), (100.0, 0.0), (100.0, 100.0), radius=100.0)
|
||||
assert_close(arc, expected)
|
||||
|
||||
def test_calculates_arc_for_90_degree_right_turn(self):
|
||||
"""Sign of deflection should not affect arc length."""
|
||||
expected = 100.0 * math.pi / 2
|
||||
arc = subject.arc_length_at_pi((0.0, 0.0), (100.0, 0.0), (100.0, -100.0), radius=100.0)
|
||||
assert_close(arc, expected)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# tangent_length_at_pi
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTangentLengthAtPi(NewFile):
|
||||
def test_returns_zero_for_zero_radius(self):
|
||||
t = subject.tangent_length_at_pi((0.0, 0.0), (100.0, 0.0), (100.0, 100.0), radius=0.0)
|
||||
assert_close(t, 0.0)
|
||||
|
||||
def test_returns_zero_for_negative_radius(self):
|
||||
t = subject.tangent_length_at_pi((0.0, 0.0), (100.0, 0.0), (100.0, 100.0), radius=-100.0)
|
||||
assert_close(t, 0.0)
|
||||
|
||||
def test_calculates_tangent_for_90_degree_left_turn(self):
|
||||
expected = 100.0 * math.tan(math.pi / 4) # R * tan(45°)
|
||||
t = subject.tangent_length_at_pi((0.0, 0.0), (100.0, 0.0), (100.0, 100.0), radius=100.0)
|
||||
assert_close(t, expected)
|
||||
|
||||
def test_matches_calculate_tangent_length_for_same_geometry(self):
|
||||
"""tangent_length_at_pi must agree with calculate_tangent_length."""
|
||||
deflection = abs(subject.deflection_angle_from_points((0.0, 0.0), (100.0, 0.0), (100.0, 100.0)))
|
||||
expected = subject.calculate_tangent_length(300.0, deflection)
|
||||
actual = subject.tangent_length_at_pi((0.0, 0.0), (100.0, 0.0), (100.0, 100.0), radius=300.0)
|
||||
assert_close(actual, expected)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# tangent_segment_length
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTangentSegmentLength(NewFile):
|
||||
def test_returns_full_distance_with_no_tangents(self):
|
||||
length = subject.tangent_segment_length((0.0, 0.0), (100.0, 0.0))
|
||||
assert_close(length, 100.0)
|
||||
|
||||
def test_subtracts_start_tangent(self):
|
||||
length = subject.tangent_segment_length((0.0, 0.0), (100.0, 0.0), start_tangent=20.0)
|
||||
assert_close(length, 80.0)
|
||||
|
||||
def test_subtracts_end_tangent(self):
|
||||
length = subject.tangent_segment_length((0.0, 0.0), (100.0, 0.0), end_tangent=30.0)
|
||||
assert_close(length, 70.0)
|
||||
|
||||
def test_subtracts_both_tangents(self):
|
||||
length = subject.tangent_segment_length((0.0, 0.0), (100.0, 0.0), start_tangent=20.0, end_tangent=30.0)
|
||||
assert_close(length, 50.0)
|
||||
|
||||
def test_clamps_to_zero_when_tangents_exceed_full_distance(self):
|
||||
length = subject.tangent_segment_length((0.0, 0.0), (100.0, 0.0), start_tangent=70.0, end_tangent=70.0)
|
||||
assert_close(length, 0.0)
|
||||
|
||||
def test_works_on_diagonal_leg(self):
|
||||
"""3-4-5 triangle: full_length=5, minus tangents=2 → 3."""
|
||||
length = subject.tangent_segment_length((0.0, 0.0), (3.0, 4.0), start_tangent=1.0, end_tangent=1.0)
|
||||
assert_close(length, 3.0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# is_zero_length_segment
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -338,69 +117,6 @@ class TestIsZeroLengthSegment(NewFile):
|
||||
assert subject.is_zero_length_segment(seg) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# layout_has_real_segments
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeAlignmentSegment:
|
||||
"""Stand-in for IfcAlignmentSegment: is_a() returns True for IfcAlignmentSegment."""
|
||||
|
||||
def __init__(self, design_params=None):
|
||||
self.DesignParameters = design_params
|
||||
|
||||
def is_a(self, ifc_class: str) -> bool:
|
||||
return ifc_class == "IfcAlignmentSegment"
|
||||
|
||||
|
||||
class _FakeRelNests:
|
||||
def __init__(self, related_objects):
|
||||
self.RelatedObjects = related_objects
|
||||
|
||||
|
||||
class _FakeLayout:
|
||||
def __init__(self, rels=None):
|
||||
self.IsNestedBy = rels or []
|
||||
|
||||
|
||||
class TestLayoutHasRealSegments(NewFile):
|
||||
def test_returns_false_for_layout_with_no_nested_relationships(self):
|
||||
layout = _FakeLayout(rels=[])
|
||||
assert subject.layout_has_real_segments(layout) is False
|
||||
|
||||
def test_returns_false_for_layout_with_empty_related_objects(self):
|
||||
layout = _FakeLayout(rels=[_FakeRelNests(related_objects=[])])
|
||||
assert subject.layout_has_real_segments(layout) is False
|
||||
|
||||
def test_returns_false_when_only_segment_is_zero_length_terminator(self):
|
||||
dp = _FakeDesignParams("IfcAlignmentHorizontalSegment", segment_length=0.0)
|
||||
terminator = _FakeAlignmentSegment(dp)
|
||||
layout = _FakeLayout(rels=[_FakeRelNests([terminator])])
|
||||
assert subject.layout_has_real_segments(layout) is False
|
||||
|
||||
def test_returns_true_when_one_real_segment_exists(self):
|
||||
dp = _FakeDesignParams("IfcAlignmentHorizontalSegment", segment_length=100.0)
|
||||
real_seg = _FakeAlignmentSegment(dp)
|
||||
layout = _FakeLayout(rels=[_FakeRelNests([real_seg])])
|
||||
assert subject.layout_has_real_segments(layout) is True
|
||||
|
||||
def test_returns_true_when_real_segment_follows_terminator(self):
|
||||
dp_zero = _FakeDesignParams("IfcAlignmentHorizontalSegment", segment_length=0.0)
|
||||
dp_real = _FakeDesignParams("IfcAlignmentHorizontalSegment", segment_length=50.0)
|
||||
layout = _FakeLayout(rels=[_FakeRelNests([_FakeAlignmentSegment(dp_zero), _FakeAlignmentSegment(dp_real)])])
|
||||
assert subject.layout_has_real_segments(layout) is True
|
||||
|
||||
def test_ignores_non_alignment_segment_objects(self):
|
||||
"""Non-IfcAlignmentSegment objects in RelatedObjects should be ignored."""
|
||||
|
||||
class _FakeOtherObject:
|
||||
def is_a(self, ifc_class):
|
||||
return False
|
||||
|
||||
layout = _FakeLayout(rels=[_FakeRelNests([_FakeOtherObject()])])
|
||||
assert subject.layout_has_real_segments(layout) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# safe_layout_horizontal_by_pi_method
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -491,108 +207,6 @@ class TestGetHorizontalLayout(NewIfc4X3):
|
||||
assert h_layout is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# layout_by_pi_method (IFC + tool.Ifc integration)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@requires_geometry_engine
|
||||
class TestLayoutByPiMethod(NewIfc4X3):
|
||||
"""Tests for Alignment.layout_by_pi_method() — IFC segment creation."""
|
||||
|
||||
def test_creates_ifc_segments_for_straight_alignment(self):
|
||||
ifc_file = tool.Ifc.get()
|
||||
alignment = align_api.create(ifc_file, name="Straight")
|
||||
h_layout = subject.get_horizontal_layout(alignment)
|
||||
|
||||
subject.layout_by_pi_method(h_layout, [(0.0, 0.0), (1000.0, 0.0)], [])
|
||||
|
||||
segments = align_api.get_layout_segments(h_layout)
|
||||
real_segments = [s for s in segments if not subject.is_zero_length_segment(s)]
|
||||
assert len(real_segments) >= 1 # At least one tangent
|
||||
|
||||
def test_creates_arc_segment_for_curve(self):
|
||||
ifc_file = tool.Ifc.get()
|
||||
alignment = align_api.create(ifc_file, name="Curve")
|
||||
h_layout = subject.get_horizontal_layout(alignment)
|
||||
|
||||
subject.layout_by_pi_method(
|
||||
h_layout, [(0.0, 0.0), (500.0, 0.0), (1000.0, 200.0)], [300.0]
|
||||
)
|
||||
|
||||
segments = align_api.get_layout_segments(h_layout)
|
||||
real_segments = [s for s in segments if not subject.is_zero_length_segment(s)]
|
||||
segment_types = [s.DesignParameters.PredefinedType for s in real_segments if s.DesignParameters]
|
||||
assert "LINE" in segment_types
|
||||
assert "CIRCULARARC" in segment_types
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# back_calculate_pis_from_alignment (IFC + unit conversion)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@requires_geometry_engine
|
||||
class TestBackCalculatePisFromAlignment(NewIfc4X3):
|
||||
"""Tests for Alignment.back_calculate_pis_from_alignment() — PI recovery."""
|
||||
|
||||
def test_recovers_endpoints_from_straight_alignment(self):
|
||||
alignment, _ = _create_alignment_with_pis(
|
||||
hpoints=[(0.0, 0.0), (1000.0, 0.0)], radii=[]
|
||||
)
|
||||
pis = subject.back_calculate_pis_from_alignment(alignment)
|
||||
assert len(pis) >= 2
|
||||
assert pis[0]["pi_type"] == "ENDPOINT"
|
||||
assert pis[-1]["pi_type"] == "ENDPOINT"
|
||||
assert_close(pis[0]["e"], 0.0, tol=0.01)
|
||||
assert_close(pis[0]["n"], 0.0, tol=0.01)
|
||||
assert_close(pis[-1]["e"], 1000.0, tol=0.01)
|
||||
assert_close(pis[-1]["n"], 0.0, tol=0.01)
|
||||
|
||||
def test_recovers_curve_pi_with_radius(self):
|
||||
alignment, _ = _create_alignment_with_pis(
|
||||
hpoints=[(0.0, 0.0), (500.0, 0.0), (1000.0, 200.0)], radii=[300.0]
|
||||
)
|
||||
pis = subject.back_calculate_pis_from_alignment(alignment)
|
||||
# Should have 3 PIs: start endpoint, curve PI, end endpoint
|
||||
assert len(pis) == 3
|
||||
curve_pis = [p for p in pis if p["pi_type"] == "CURVE"]
|
||||
assert len(curve_pis) == 1
|
||||
assert_close(curve_pis[0]["e"], 500.0, tol=1.0)
|
||||
assert_close(curve_pis[0]["n"], 0.0, tol=1.0)
|
||||
assert curve_pis[0]["radius"] > 0
|
||||
|
||||
def test_raises_for_alignment_without_horizontal_layout(self):
|
||||
ifc_file = tool.Ifc.get()
|
||||
alignment = ifc_file.createIfcAlignment(
|
||||
GlobalId=ifcopenshell.guid.new(), Name="Bare"
|
||||
)
|
||||
with pytest.raises(ValueError, match="no horizontal layout"):
|
||||
subject.back_calculate_pis_from_alignment(alignment)
|
||||
|
||||
def test_raises_for_alignment_with_only_terminator(self):
|
||||
ifc_file = tool.Ifc.get()
|
||||
alignment = align_api.create(ifc_file, name="EmptyLayout")
|
||||
# align_api.create() produces a horizontal layout with only a zero-length terminator
|
||||
with pytest.raises(ValueError, match="no real segments"):
|
||||
subject.back_calculate_pis_from_alignment(alignment)
|
||||
|
||||
def test_roundtrip_preserves_pi_positions(self):
|
||||
"""Create alignment from PIs, back-calculate, verify positions match."""
|
||||
original_hpoints = [(0.0, 0.0), (500.0, 0.0), (1000.0, 200.0)]
|
||||
original_radii = [300.0]
|
||||
alignment, _ = _create_alignment_with_pis(
|
||||
hpoints=original_hpoints, radii=original_radii
|
||||
)
|
||||
|
||||
recovered_pis = subject.back_calculate_pis_from_alignment(alignment)
|
||||
assert len(recovered_pis) == len(original_hpoints)
|
||||
|
||||
for original, recovered in zip(original_hpoints, recovered_pis):
|
||||
assert_close(recovered["e"], original[0], tol=1.0)
|
||||
assert_close(recovered["n"], original[1], tol=1.0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Blender Object Creation Methods
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -704,149 +318,6 @@ class TestGetActiveAlignment(NewIfc4X3):
|
||||
assert result.id() == alignment.id()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PI Edit Empties
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@requires_geometry_engine
|
||||
class TestCreatePiEditEmpties(NewIfc4X3):
|
||||
"""Tests for Alignment.create_pi_edit_empties()."""
|
||||
|
||||
def test_creates_empties_at_pi_positions(self):
|
||||
alignment, _ = _create_alignment_with_pis(
|
||||
hpoints=[(0.0, 0.0), (500.0, 0.0), (1000.0, 200.0)], radii=[300.0]
|
||||
)
|
||||
alignment_obj = subject.create_hierarchy_for_alignment(alignment)
|
||||
bpy.context.view_layer.objects.active = alignment_obj
|
||||
|
||||
pis = subject.back_calculate_pis_from_alignment(alignment)
|
||||
empties = subject.create_pi_edit_empties(alignment, pis)
|
||||
|
||||
assert len(empties) == len(pis)
|
||||
for empty in empties:
|
||||
assert empty.type == "EMPTY"
|
||||
assert empty.get("civil_is_pi_empty") is True
|
||||
assert empty.get("civil_alignment_id") == alignment.id()
|
||||
|
||||
def test_empties_are_parented_to_alignment_object(self):
|
||||
alignment, _ = _create_alignment_with_pis(
|
||||
hpoints=[(0.0, 0.0), (500.0, 0.0)], radii=[]
|
||||
)
|
||||
alignment_obj = subject.create_hierarchy_for_alignment(alignment)
|
||||
pis = subject.back_calculate_pis_from_alignment(alignment)
|
||||
empties = subject.create_pi_edit_empties(alignment, pis)
|
||||
for empty in empties:
|
||||
assert empty.parent == alignment_obj
|
||||
|
||||
def test_empties_have_sequential_pi_indices(self):
|
||||
alignment, _ = _create_alignment_with_pis(
|
||||
hpoints=[(0.0, 0.0), (500.0, 0.0), (1000.0, 200.0)], radii=[300.0]
|
||||
)
|
||||
alignment_obj = subject.create_hierarchy_for_alignment(alignment)
|
||||
pis = subject.back_calculate_pis_from_alignment(alignment)
|
||||
empties = subject.create_pi_edit_empties(alignment, pis)
|
||||
indices = [e.get("civil_pi_index") for e in empties]
|
||||
assert indices == list(range(len(pis)))
|
||||
|
||||
|
||||
@requires_geometry_engine
|
||||
class TestGetPiEditEmpties(NewIfc4X3):
|
||||
"""Tests for Alignment.get_pi_edit_empties()."""
|
||||
|
||||
def test_finds_empties_for_given_alignment_id(self):
|
||||
alignment, _ = _create_alignment_with_pis(
|
||||
hpoints=[(0.0, 0.0), (500.0, 0.0)], radii=[]
|
||||
)
|
||||
alignment_obj = subject.create_hierarchy_for_alignment(alignment)
|
||||
pis = subject.back_calculate_pis_from_alignment(alignment)
|
||||
subject.create_pi_edit_empties(alignment, pis)
|
||||
|
||||
found = subject.get_pi_edit_empties(alignment.id())
|
||||
assert len(found) == len(pis)
|
||||
|
||||
def test_returns_empty_list_when_no_empties_exist(self):
|
||||
found = subject.get_pi_edit_empties(99999)
|
||||
assert found == []
|
||||
|
||||
def test_returns_sorted_by_pi_index(self):
|
||||
alignment, _ = _create_alignment_with_pis(
|
||||
hpoints=[(0.0, 0.0), (500.0, 0.0), (1000.0, 200.0)], radii=[300.0]
|
||||
)
|
||||
alignment_obj = subject.create_hierarchy_for_alignment(alignment)
|
||||
pis = subject.back_calculate_pis_from_alignment(alignment)
|
||||
subject.create_pi_edit_empties(alignment, pis)
|
||||
|
||||
found = subject.get_pi_edit_empties(alignment.id())
|
||||
indices = [e.get("civil_pi_index") for e in found]
|
||||
assert indices == sorted(indices)
|
||||
|
||||
|
||||
@requires_geometry_engine
|
||||
class TestRemovePiEditEmpties(NewIfc4X3):
|
||||
"""Tests for Alignment.remove_pi_edit_empties()."""
|
||||
|
||||
def test_removes_all_empties_for_alignment(self):
|
||||
alignment, _ = _create_alignment_with_pis(
|
||||
hpoints=[(0.0, 0.0), (500.0, 0.0), (1000.0, 200.0)], radii=[300.0]
|
||||
)
|
||||
alignment_obj = subject.create_hierarchy_for_alignment(alignment)
|
||||
pis = subject.back_calculate_pis_from_alignment(alignment)
|
||||
subject.create_pi_edit_empties(alignment, pis)
|
||||
|
||||
removed = subject.remove_pi_edit_empties(alignment.id())
|
||||
assert removed == len(pis)
|
||||
assert subject.get_pi_edit_empties(alignment.id()) == []
|
||||
|
||||
def test_returns_zero_when_no_empties_exist(self):
|
||||
removed = subject.remove_pi_edit_empties(99999)
|
||||
assert removed == 0
|
||||
|
||||
|
||||
@requires_geometry_engine
|
||||
class TestCollectPisFromEmpties(NewIfc4X3):
|
||||
"""Tests for Alignment.collect_pis_from_empties() — reading positions back."""
|
||||
|
||||
def test_roundtrip_positions_through_empties(self):
|
||||
"""Create empties from PIs, collect back, verify positions match."""
|
||||
alignment, _ = _create_alignment_with_pis(
|
||||
hpoints=[(0.0, 0.0), (500.0, 0.0), (1000.0, 200.0)], radii=[300.0]
|
||||
)
|
||||
alignment_obj = subject.create_hierarchy_for_alignment(alignment)
|
||||
|
||||
pis = subject.back_calculate_pis_from_alignment(alignment)
|
||||
subject.create_pi_edit_empties(alignment, pis)
|
||||
|
||||
# Force Blender to update transforms (empties are parented)
|
||||
bpy.context.view_layer.update()
|
||||
|
||||
hpoints_back, radii_back = subject.collect_pis_from_empties(alignment.id())
|
||||
assert len(hpoints_back) == len(pis)
|
||||
|
||||
# Positions should round-trip: empties created from pis, collected back
|
||||
for pi, (back_e, back_n) in zip(pis, hpoints_back):
|
||||
assert_close(back_e, pi["e"], tol=2.0) # Generous tolerance for georef
|
||||
assert_close(back_n, pi["n"], tol=2.0)
|
||||
|
||||
def test_collects_radii_for_interior_pis_only(self):
|
||||
alignment, _ = _create_alignment_with_pis(
|
||||
hpoints=[(0.0, 0.0), (500.0, 0.0), (1000.0, 200.0)], radii=[300.0]
|
||||
)
|
||||
alignment_obj = subject.create_hierarchy_for_alignment(alignment)
|
||||
pis = subject.back_calculate_pis_from_alignment(alignment)
|
||||
subject.create_pi_edit_empties(alignment, pis)
|
||||
|
||||
_, radii_back = subject.collect_pis_from_empties(alignment.id())
|
||||
# Radii should have one entry (for the interior PI)
|
||||
assert len(radii_back) == 1
|
||||
assert radii_back[0] > 0
|
||||
|
||||
def test_returns_empty_when_fewer_than_two_empties(self):
|
||||
hpoints, radii = subject.collect_pis_from_empties(99999)
|
||||
assert hpoints == []
|
||||
assert radii == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Remove alignment hierarchy
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -924,6 +395,11 @@ class TestIfcSaveReloadRoundtrip(NewIfc4X3):
|
||||
|
||||
|
||||
@requires_geometry_engine
|
||||
def _has_real_segments(layout) -> bool:
|
||||
"""Whether ``layout`` has any segment beyond the zero-length terminator."""
|
||||
return any(not subject.is_zero_length_segment(s) for s in align_api.get_layout_segments(layout))
|
||||
|
||||
|
||||
class TestClearLayoutSegments(NewFile):
|
||||
"""The alignment API exposes no segment-clearing helper and its layout
|
||||
functions only append, so editing relies on tool.Alignment.clear_layout_segments.
|
||||
@@ -948,9 +424,9 @@ class TestClearLayoutSegments(NewFile):
|
||||
align_api.layout_horizontal_alignment_by_pi_method(
|
||||
ifc, h, hpoints=[(0.0, 0.0), (500.0, 0.0), (1000.0, 200.0)], radii=[300.0]
|
||||
)
|
||||
assert subject.layout_has_real_segments(h) is True
|
||||
assert _has_real_segments(h) is True
|
||||
subject.clear_layout_segments(h)
|
||||
assert subject.layout_has_real_segments(h) is False
|
||||
assert _has_real_segments(h) is False
|
||||
assert len(align_api.get_layout_segments(h)) == 1 # terminator only
|
||||
|
||||
def test_relayout_after_clear_has_no_doubling_or_orphans(self):
|
||||
@@ -979,32 +455,9 @@ class TestClearLayoutSegments(NewFile):
|
||||
align_api.layout_vertical_alignment_by_pi_method(
|
||||
ifc, v, [(0.0, 100.0), (500.0, 110.0), (1000.0, 100.0)], [100.0]
|
||||
)
|
||||
assert subject.layout_has_real_segments(v) is True
|
||||
assert _has_real_segments(v) is True
|
||||
subject.clear_layout_segments(v)
|
||||
assert subject.layout_has_real_segments(v) is False
|
||||
|
||||
|
||||
@requires_geometry_engine
|
||||
class TestSetLayoutSegmentsSelectable(NewIfc4X3):
|
||||
"""PI edit mode disables segment-curve selection so clicks hit the PI
|
||||
empties; set_layout_segments_selectable toggles hide_select accordingly."""
|
||||
|
||||
def test_toggles_segment_hide_select(self):
|
||||
ifc_file = tool.Ifc.get()
|
||||
alignment = align_api.create(ifc_file, name="Sel", include_vertical=False)
|
||||
h = align_api.get_horizontal_layout(alignment)
|
||||
align_api.layout_horizontal_alignment_by_pi_method(
|
||||
ifc_file, h, hpoints=[(0.0, 0.0), (500.0, 0.0), (1000.0, 200.0)], radii=[300.0]
|
||||
)
|
||||
subject.create_hierarchy_for_alignment(alignment)
|
||||
segment_objects = [o for o in bpy.data.objects if "IfcAlignmentSegment" in o.name]
|
||||
assert len(segment_objects) >= 1
|
||||
|
||||
subject.set_layout_segments_selectable(h, False)
|
||||
assert all(o.hide_select for o in segment_objects)
|
||||
|
||||
subject.set_layout_segments_selectable(h, True)
|
||||
assert all(not o.hide_select for o in segment_objects)
|
||||
assert _has_real_segments(v) is False
|
||||
|
||||
|
||||
class TestFormatStation(NewFile):
|
||||
|
||||
Reference in New Issue
Block a user