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