WIP infra editing

This commit is contained in:
Richard Brice
2026-09-11 04:49:01 -07:00
parent d9864be64a
commit 45caff3558
42 changed files with 11007 additions and 61 deletions
+3
View File
@@ -184,6 +184,9 @@ classes = [
ui.BIM_PT_tab_materials,
ui.BIM_PT_tab_styles,
ui.BIM_PT_tab_profiles,
# Civil infrastructure
ui.BIM_PT_tab_horizontal_alignment,
ui.BIM_PT_tab_alignments,
# Drawings and documents
ui.BIM_PT_tab_sheets,
ui.BIM_PT_tab_drawings,
+4
View File
@@ -110,6 +110,8 @@ class IfcStore:
"""Should be set only using ``tool.Ifc.set``."""
schema: Optional[ifcopenshell.ifcopenshell_wrapper.schema_definition] = None
cache: Optional[ifcopenshell.geom.serializers.hdf5] = None
cache_path: str = ""
id_map: dict[int, IFC_CONNECTED_TYPE] = {}
guid_map: dict[str, IFC_CONNECTED_TYPE] = {}
edited_objs: set[bpy.types.Object] = set()
@@ -133,6 +135,8 @@ class IfcStore:
IfcStore.path = ""
IfcStore.file = None
IfcStore.schema = None
IfcStore.cache = None
IfcStore.cache_path = ""
IfcStore.id_map = {}
IfcStore.guid_map = {}
IfcStore.edited_objs = set()
@@ -0,0 +1,77 @@
# Alignment Authoring — Requirements
Working requirements doc for the Bonsai alignment authoring UI (Civil Infrastructure tab and the
Alignment BIM tab). Captures planned work, not yet implemented unless noted. Update in place as
scope is refined or decisions are made; keep open questions marked as such rather than silently
resolving them.
## 1. Table-based editing
The Alignment tab's UI lists the horizontal, vertical, and cant layouts. These listings need to
become editable:
- Edit existing segments
- Add new segments
- Delete segments
When editing finishes, the `IfcAlignment` model and its representations must be updated, and the
updated representations must automatically refresh in:
- the 3D viewport
- the vertical/cant profile view
## 2. Interactive creation of a horizontal alignment
Mimic the existing draw/edit tangent-line workflow from the Civil Infrastructure tab, with these
improvements:
1. Alongside Angle, also show the line's **Bearing** (e.g. `N 30 15 24 E`) — how civil engineers
think about direction.
2. Allow manual input of Distance and one of Bearing, Angle, or Deflection Angle — likely via a
pop-up input box.
3. Interactively define the smoothing curves *before* the command ends, rather than as a separate
pass afterward.
### Proposed interaction sequence
1. Press the eyedropper (or similar) to begin the command.
2. Automatically rotate the 3D viewport to the XY plane (Z-up).
3. Draw tangent lines with the mouse, or use the manual text input from item 2 above.
4. Repeat step 3 until all tangents are drawn.
5. Right-click (or whatever is the standard convention) to move on to the second phase of the
command.
6. Click each PI (or only the PIs of interest) and input the smoothing type and its parameters.
Smoothing types include: Circular, Spiral-Circular, Circular-Spiral, Spiral-Circular-Spiral.
7. In a pop-up (or other appropriate UI element), input the parameters:
- **Circular curve**: radius only.
- **Spiral curve**: spiral type (Clothoid, Bloss, Cosine, Helmert, etc.) and spiral length.
This assumes all spirals have infinite start/end radius and share the circular arc's radius.
**Open question**: other cases exist that this doesn't cover, e.g. a spiral between two
circular arcs of different radius (Spiral-Circular-Spiral-Circular-Spiral). No UI is proposed
for this yet — it may require selecting 2 PIs and defining all parameters together.
8. Right-click (or whatever is standard) to end the command. Generate the alignment automatically.
## 3. Interrogating an alignment
Replace the PI-grid display with basic information about the alignment layout — PI points
themselves are no longer needed in that grid.
With each alignment segment represented in the Scene Collection:
- Selecting a segment highlights it.
- Display segment information in the 3D viewport: Start Point, End Point, Length, Radius, PI,
Center of Circle, Spiral Type (as applicable to the segment type).
- Draw tangent and radial lines for the segment.
## 4. Interactively editing an alignment
Two editing scenarios:
1. **Moving a point.** Select the alignment's Start Point, End Point, or a PI point and drag it
(or key in a new position) to relocate it.
2. **Changing smoothing curve parameters.** Select a smoothing curve to get the same UI element
used to define it during creation (see §2, steps 6-7), and edit its parameters there.
For now, edits trigger a full wipe-out-and-regenerate of the alignment. A future iteration should
regenerate only the affected subset instead of the whole alignment.
@@ -1,5 +1,5 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2020, 2021 Dion Moult <dion@thinkmoult.com>
# Copyright (C) 2020, 2021 Dion Moult <dion@thinkmoult.com>, 2026 Michael Yoder <myoder@desertspringscivil.com>
#
# This file is part of Bonsai.
#
@@ -17,11 +17,145 @@
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import bpy
from . import ui, prop, operator, decorator, workspace
# from . import ui, prop, operator
from . import operator
_last_active_ptr: int = 0
_last_profile_alignment_id: int = 0 # tracks which alignment the profile was last built for
classes = (operator.ImportAlignmentCSV,)
@bpy.app.handlers.persistent
def _on_active_object_changed(scene, depsgraph):
"""Sync the alignment dropdown, vertical profile, and Properties panel on selection change.
Scene-context panels don't auto-redraw on selection changes. We watch the
active-object pointer and, when it changes, sync the dropdown and if the
alignment itself changed recompute the vertical profile.
The profile recompute uses its own tracker (_last_profile_alignment_id) rather
than comparing the dropdown value. This is necessary because the dropdown is
updated by _on_active_alignment_update *before* the depsgraph fires, so the
two values would always match and the recompute would never run.
"""
global _last_active_ptr, _last_profile_alignment_id
try:
ctx = bpy.context
vl = ctx.view_layer
active = vl.objects.active if vl else None
ptr = active.as_pointer() if active else 0
if ptr == _last_active_ptr:
return
_last_active_ptr = ptr
import bonsai.tool as tool
from bonsai.bim.module.alignment.prop import _alignment_enum_items
props = scene.CivilAlignmentProperties
alignment = tool.Alignment.get_active_alignment()
new_val = str(alignment.id()) if alignment else "0"
# Sync dropdown (only needed when selection came from 3D view / outliner)
if props.active_alignment_id_str != new_val:
for idx, (ident, _, _) in enumerate(_alignment_enum_items(props, None)):
if ident == new_val:
props["active_alignment_id_str"] = idx
break
# Recompute vertical profile when the alignment changes — use an independent
# tracker so this fires even when the dropdown already shows the new alignment
# (i.e. the change came from the dropdown, not from a viewport/outliner click).
new_aid = alignment.id() if alignment else 0
if new_aid != _last_profile_alignment_id:
_last_profile_alignment_id = new_aid
if alignment:
from bonsai.bim.module.alignment.decorator import VerticalProfileDecorator
dec = VerticalProfileDecorator
if dec.is_installed:
dec._compute_profile(alignment)
props.vertical_items.clear()
for v_id, v_label in dec.available_verticals:
item = props.vertical_items.add()
item.entity_id = v_id
item.label = v_label
item.is_visible = True
props.cant_items.clear()
for c_id, c_label in dec.available_cants:
item = props.cant_items.add()
item.entity_id = c_id
item.label = c_label
item.is_visible = True
# Refit the camera to the new alignment's extent
ve = props.vertical_exaggeration
for window in ctx.window_manager.windows:
for a in window.screen.areas:
if a.as_pointer() == dec.profile_area_ptr:
space = next(
(s for s in a.spaces if s.type == "VIEW_3D"), None
)
if space:
dec.fit_view(space, ve, area_width=a.width, area_height=a.height)
dec.tag_redraw()
for window in ctx.window_manager.windows:
for area in window.screen.areas:
if area.type == "PROPERTIES":
area.tag_redraw()
except Exception:
pass
classes = (
# Property groups (must be registered before classes that use them)
prop.AlignmentPI,
prop.AlignmentDisplayRow,
prop.VerticalAlignmentItem,
prop.CantAlignmentItem,
prop.CivilAlignmentProperties,
prop.PICurveMarkerProperties,
# UILists and section-toggle operators
ui.ALIGN_UL_alignment_pis,
ui.ALIGN_OT_toggle_h_segments,
ui.ALIGN_OT_toggle_v_segments,
ui.ALIGN_OT_toggle_cant_segments,
operator.ImportAlignmentCSV,
# Operators - PI Management
operator.ALIGN_OT_add_pi,
operator.ALIGN_OT_remove_pi,
operator.ALIGN_OT_pick_pi_from_viewport,
operator.ALIGN_OT_recalculate_pis,
operator.ALIGN_OT_clear_pis,
# Operators - Creation
operator.ALIGN_OT_create_alignment_by_pis,
operator.ALIGN_OT_create_alignment_by_pi,
# Operators - Stationing
operator.ALIGN_OT_add_stationing_referent,
operator.ALIGN_OT_name_segments,
# Operators - Vertical Profile Window
operator.ALIGN_OT_show_vertical_profile,
# Operators - Segment Selection
operator.ALIGN_OT_select_h_segment,
operator.ALIGN_OT_select_v_segment,
operator.ALIGN_OT_select_cant_segment,
# Operators - PI Edit Mode
operator.ALIGN_OT_enter_pi_edit_mode,
# Operators - Alignments tab authoring workflow (Add Element + interactive draw)
operator.ALIGN_OT_add_alignment,
operator.ALIGN_OT_remove_alignment,
operator.ALIGN_OT_set_start_station,
operator.ALIGN_OT_add_station_equation,
operator.ALIGN_OT_edit_station_equation,
operator.ALIGN_OT_remove_station_equation,
operator.ALIGN_OT_apply_pi_curve,
operator.ALIGN_OT_clear_pi_markers,
operator.ALIGN_OT_draw_horizontal_alignment,
# UI Panels (appear in Properties sidebar under CIVIL tab)
ui.ALIGN_PT_alignment_creation,
ui.ALIGN_PT_pi_editor,
ui.ALIGN_PT_alignment_stationing,
# UI Panels (appear in Properties sidebar under ALIGNMENTS tab)
ui.ALIGN_PT_alignment_authoring,
ui.ALIGN_PT_alignment_stationing_authoring,
ui.ALIGN_PT_alignment_segments,
)
def menu_func_import(self, context):
@@ -29,8 +163,40 @@ def menu_func_import(self, context):
def register():
if not bpy.app.background:
bpy.utils.register_tool(
workspace.AlignmentTool,
separator=True,
group=False,
)
bpy.types.Scene.CivilAlignmentProperties = bpy.props.PointerProperty(type=prop.CivilAlignmentProperties)
bpy.types.Object.bonsai_pi_curve_marker = bpy.props.PointerProperty(type=prop.PICurveMarkerProperties)
bpy.types.TOPBAR_MT_file_import.append(menu_func_import)
if _on_active_object_changed not in bpy.app.handlers.depsgraph_update_post:
bpy.app.handlers.depsgraph_update_post.append(_on_active_object_changed)
# Reset decorator state so a module hot-reload never leaves is_installed=True
# with stale handlers that prevent the first button press from opening the profile.
from .decorator import VerticalProfileDecorator
VerticalProfileDecorator.is_installed = False
VerticalProfileDecorator.handlers = []
VerticalProfileDecorator.profile_area = None
VerticalProfileDecorator.profile_area_ptr = 0
def unregister():
if _on_active_object_changed in bpy.app.handlers.depsgraph_update_post:
bpy.app.handlers.depsgraph_update_post.remove(_on_active_object_changed)
# Clean up any open profile window so re-registration starts from a clean state.
from .decorator import VerticalProfileDecorator
if VerticalProfileDecorator.is_installed:
try:
VerticalProfileDecorator.uninstall()
except Exception:
pass
VerticalProfileDecorator.is_installed = False
VerticalProfileDecorator.handlers = []
if not bpy.app.background:
bpy.utils.unregister_tool(workspace.AlignmentTool)
bpy.types.TOPBAR_MT_file_import.remove(menu_func_import)
del bpy.types.Scene.CivilAlignmentProperties
del bpy.types.Object.bonsai_pi_curve_marker
@@ -0,0 +1,66 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2025, 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/>.
"""Data caching layer for the alignment module
This module provides cached access to alignment data for UI display,
following Bonsai's data loading pattern.
"""
import bonsai.tool as tool
class AlignmentData:
"""Cached alignment data for UI display"""
data = {}
is_loaded = False
@classmethod
def load(cls):
"""Load alignment data from IFC file"""
cls.data = {
"alignments": [],
"active_alignment": None,
"segments": [],
}
ifc = tool.Ifc.get()
if ifc is None:
cls.is_loaded = True
return
# Load all alignments
alignments = ifc.by_type("IfcAlignment")
cls.data["alignments"] = [
{
"id": a.id(),
"name": a.Name or f"Alignment {a.id()}",
"global_id": a.GlobalId,
}
for a in alignments
]
cls.is_loaded = True
@classmethod
def refresh(cls):
"""Force refresh of alignment data"""
cls.is_loaded = False
cls.load()
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,422 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2025, 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/>.
"""Property groups for the alignment module"""
from bpy.types import PropertyGroup
from bpy.props import (
StringProperty,
FloatProperty,
IntProperty,
BoolProperty,
CollectionProperty,
EnumProperty,
)
import bpy
def _on_vertical_visibility_update(self, context):
from .decorator import VerticalProfileDecorator
VerticalProfileDecorator.tag_redraw()
def _alignment_enum_items(self, context):
"""Dynamic items: all top-level IfcAlignment entities in the current file."""
import bonsai.tool as tool
items = [("0", "— select alignment —", "")]
ifc_file = tool.Ifc.get()
if not ifc_file:
return items
try:
for a in ifc_file.by_type("IfcAlignment"):
# Skip child alignments (used in multi-vertical template)
if any(
rel.RelatingObject.is_a("IfcAlignment")
for rel in (getattr(a, "Decomposes", []) or [])
):
continue
label = a.Name or f"Alignment #{a.id()}"
items.append((str(a.id()), label, ""))
except Exception:
pass
return items
def _on_active_alignment_update(self, context):
"""Select the alignment's Blender object when the dropdown changes."""
import bonsai.tool as tool
try:
aid = int(self.active_alignment_id_str)
except (ValueError, TypeError):
return
if aid == 0:
return
ifc_file = tool.Ifc.get()
if not ifc_file:
return
try:
alignment = ifc_file.by_id(aid)
obj = tool.Ifc.get_object(alignment)
if obj and context.view_layer.objects.get(obj.name):
# Use direct RNA — bpy.ops.object.select_all can fail from non-3D contexts
for o in context.view_layer.objects:
o.select_set(False)
obj.select_set(True)
context.view_layer.objects.active = obj
except Exception:
pass
class VerticalAlignmentItem(PropertyGroup):
"""Tracks one IfcAlignmentVertical available in the profile view."""
entity_id: IntProperty(name="Entity ID", default=0)
label: StringProperty(name="Label", default="Vertical")
is_visible: BoolProperty(
name="Show in profile",
description="Show this vertical alignment in the profile view",
default=True,
update=_on_vertical_visibility_update,
)
show_segments: BoolProperty(
name="Show Segments",
description="Expand the segment table for this vertical alignment",
default=True,
)
show_labels: BoolProperty(
name="Show Labels",
description="Show BVC/PVI/EVC callout labels for this vertical alignment in the profile view",
default=True,
update=_on_vertical_visibility_update,
)
def _on_cant_visibility_update(self, context):
from .decorator import VerticalProfileDecorator
VerticalProfileDecorator.tag_redraw()
def _on_ve_update(self, context):
from .decorator import VerticalProfileDecorator
VerticalProfileDecorator.tag_redraw()
def _on_radius_update(self, context):
"""Callback when radius property changes.
This dynamically imports the operator module to call on_radius_changed,
avoiding circular imports since prop.py is imported before operator.py.
"""
from . import operator as ops
ops.on_radius_changed(self, context)
class CantAlignmentItem(PropertyGroup):
"""Tracks one IfcAlignmentCant available in the profile view."""
entity_id: IntProperty(name="Entity ID", default=0)
label: StringProperty(name="Label", default="Cant")
is_visible: BoolProperty(
name="Show in profile",
description="Show this cant in the profile view",
default=True,
update=_on_cant_visibility_update,
)
show_segments: BoolProperty(
name="Show Segments",
description="Expand the segment table for this cant",
default=True,
)
class AlignmentPI(PropertyGroup):
"""Property group for a single PI (Point of Intersection)
In the PI method, alignments are defined by:
- Endpoint PIs: Start (POB) and End (POE) points
- Interior PIs: Points where tangents intersect, optionally with curves
"""
# Coordinates stored as global easting/northing (map coordinates).
# Coordinate flow: Blender coords -> xyz2enh() -> global E/N (stored here)
# global E/N -> ifcopenshell.util.geolocation.auto_enh2xyz() -> local IFC coords (for IfcOpenShell API)
e: StringProperty(name="E", description="Easting (global map coordinates)", default="0.0")
n: StringProperty(name="N", description="Northing (global map coordinates)", default="0.0")
# PI Type
pi_type: EnumProperty(
name="Type",
description="Type of PI point",
items=[
("ENDPOINT", "Endpoint", "Start or end point (no curve)"),
("TANGENT", "Tangent", "Pass-through point (no curve)"),
("CURVE", "Curve", "Point of intersection with curve"),
],
default="TANGENT",
)
# Curve parameters (only used when pi_type == "CURVE")
radius: FloatProperty(
name="Radius",
description="Curve radius (0 = no curve, sharp angle)",
default=0.0,
min=0.0,
precision=3,
unit="LENGTH",
update=_on_radius_update,
)
# Computed/display values (updated by recalculate operator)
length_to_next: FloatProperty(
name="Length",
description="Length of tangent to next PI",
default=0.0,
precision=3,
unit="LENGTH",
)
direction_to_next: FloatProperty(
name="Direction",
description="Bearing/direction to next PI (degrees)",
default=0.0,
precision=4,
subtype="ANGLE",
)
# Station at this PI (computed)
station: FloatProperty(
name="Station",
description="Station value at this PI",
default=0.0,
precision=2,
)
class AlignmentDisplayRow(PropertyGroup):
"""Property group for interleaved point/segment display in the table.
This creates the Civil 3D-style view where points and segments
are shown on separate rows:
Point 1 (End)
Segment 1 (Tan)
Point 2 (Tan)
Segment 2 (Tan)
...
"""
# Row type discriminator
row_type: EnumProperty(
name="Row Type",
items=[
("POINT", "Point", "A PI point row"),
("SEGMENT", "Segment", "A segment row between points"),
],
default="POINT",
)
# Segment number (1, 2, 3...) - only for SEGMENT rows
segment_number: IntProperty(name="Segment #", default=0)
# Point index in the pis collection - for both types
# For POINT rows: the PI index
# For SEGMENT rows: the starting PI index of this segment
pi_index: IntProperty(name="PI Index", default=0)
# Display type string (End, Tan, Curve for points; Tan, Curve for segments)
display_type: StringProperty(name="Type", default="")
# Point coordinates (only for POINT rows)
e: StringProperty(name="E", default="0.0")
n: StringProperty(name="N", default="0.0")
# Segment properties (only for SEGMENT rows)
length: FloatProperty(name="Length", default=0.0, precision=2, unit="LENGTH")
radius: FloatProperty(name="Radius", default=0.0, precision=2, unit="LENGTH")
arc_length: FloatProperty(name="Arc Length", default=0.0, precision=2, unit="LENGTH")
class CivilAlignmentProperties(PropertyGroup):
"""Properties for the alignment module"""
# Active alignment selection
active_alignment_id: IntProperty(
name="Active Alignment ID",
description="IFC entity ID of the active alignment",
default=0,
)
active_alignment_name: StringProperty(
name="Active Alignment",
description="Name of the currently active alignment",
default="",
)
# Alignment selector dropdown (top-level alignments only)
active_alignment_id_str: EnumProperty(
name="Alignment",
description="Active alignment shown in this panel",
items=_alignment_enum_items,
update=_on_active_alignment_update,
default=0,
)
# Panel collapse state
show_horizontal_segments: BoolProperty(
name="Show Horizontal Segments",
description="Expand the horizontal segment table",
default=True,
)
# New alignment creation properties
new_alignment_name: StringProperty(
name="Name",
description="Name for new alignment",
default="Alignment 1",
)
start_station: FloatProperty(
name="Start Station",
description="Starting station value (e.g., 10000 for 100+00)",
default=10000.0,
min=0.0,
)
# PI collection for PI method creation
pis: CollectionProperty(type=AlignmentPI)
active_pi_index: IntProperty(name="Active PI", default=0)
# Combined point/segment display rows (for Civil 3D-style table)
display_rows: CollectionProperty(type=AlignmentDisplayRow)
active_display_row_index: IntProperty(name="Active Display Row", default=0)
# Vertical profile window settings
vertical_exaggeration: FloatProperty(
name="Vertical Exaggeration",
description="Multiply elevation differences by this factor for the profile view",
default=10.0,
min=1.0,
max=1000.0,
precision=1,
update=_on_ve_update,
)
# Selected horizontal segment (for viewport highlight)
selected_h_segment_id: IntProperty(
name="Selected Horizontal Segment",
description="IFC entity ID of the highlighted horizontal segment",
default=0,
)
# Selected vertical segment (for profile view highlight)
selected_v_segment_id: IntProperty(
name="Selected Vertical Segment",
description="IFC entity ID of the highlighted vertical segment in the profile view",
default=0,
)
# Label visibility toggles
show_h_segment_labels: BoolProperty(
name="Show Horizontal Labels",
description="Show PC/PT/PI labels for the selected horizontal segment in the 3D viewport",
default=True,
)
show_v_segment_labels: BoolProperty(
name="Show Vertical Labels",
description="Show BVC/PVI/EVC callout labels in the profile view",
default=True,
)
# Per-vertical visibility filter for the profile window
vertical_items: CollectionProperty(type=VerticalAlignmentItem)
# Per-cant visibility filter for the profile window
cant_items: CollectionProperty(type=CantAlignmentItem)
# Selected cant segment (for profile view highlight)
selected_cant_segment_id: IntProperty(
name="Selected Cant Segment",
description="IFC entity ID of the highlighted cant segment in the profile view",
default=0,
)
# Label visibility toggle for cant callouts
show_cant_segment_labels: BoolProperty(
name="Show Cant Labels",
description="Show cant start/end value labels in the profile view",
default=True,
)
# PI Edit Mode state (for moving PIs with G key)
is_pi_edit_mode: BoolProperty(
name="PI Edit Mode Active",
description="Whether PI edit mode is currently active",
default=False,
)
pi_edit_alignment_id: IntProperty(
name="Editing Alignment ID",
description="IFC ID of alignment being edited in PI edit mode",
default=0,
)
class PICurveMarkerProperties(PropertyGroup):
"""Tags a transient Empty object placed at an interior PI while its
smoothing curve is being defined (ALIGN_OT_draw_horizontal_alignment /
align.set_pi_curve). Registered as Object.bonsai_pi_curve_marker.
Deliberately edited via plain panel widgets bound directly to this
PropertyGroup (see ALIGN_PT_alignment_authoring), not a popup dialog
Blender operators must not invoke another operator's dialog from inside
a still-running modal's modal() callback (this is why the alignment
wouldn't regenerate after the very first version of this feature: the
curve popup was invoked from inside the drawing operator's own modal
loop). A plain "Apply" button clicked from the panel is a top-level
operator invocation, not a nested one, so it's safe.
"""
is_pi_marker: BoolProperty(default=False)
alignment_id: IntProperty(
name="Alignment ID", description="IFC ID of the IfcAlignment this PI belongs to", default=0
)
pi_index: IntProperty(
name="PI Index", description="0-based index among the alignment's interior PIs", default=0
)
curve_type: EnumProperty(
name="Curve Type",
items=[
("TANGENT", "None (sharp PI)", "No curve — the two tangents meet directly"),
("CIRCULAR", "Circular", "A simple circular arc"),
# Spiral-Circular / Circular-Spiral / Spiral-Circular-Spiral are not
# implemented yet. See REQUIREMENTS.md §2 step 7 — they need each
# segment authored individually (create_layout_segment), which
# layout_horizontal_alignment_by_pi_method does not support.
],
default="TANGENT",
)
radius: FloatProperty(name="Radius", default=100.0, min=0.0001, unit="LENGTH")
@@ -0,0 +1,857 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2025, 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/>.
"""UI panels for the alignment module
All panels appear in the Properties sidebar under the CIVIL tab,
nested under BIM_PT_tab_horizontal_alignment.
"""
import bpy
import math
import ifcopenshell.api.alignment
import ifcopenshell.util.geolocation
import bonsai.tool as tool
from bpy.types import Panel, UIList, Operator
from bpy.props import IntProperty, BoolProperty
from .prop import _alignment_enum_items
from .operator import _find_pi_markers, _resolve_alignment_id_for_markers, _is_interior_pi_marker
def _pi_markers_present(context) -> bool:
"""Whether the relevant alignment (from the active object or an active
PI marker of its own) currently has any leftover PI marker empties."""
alignment_id = _resolve_alignment_id_for_markers(context)
return bool(alignment_id and _find_pi_markers(alignment_id))
def is_ifc4x3():
"""Check if the current IFC file is IFC4X3 schema"""
return tool.Ifc.get_schema() == "IFC4X3"
# Module-level dicts store expand/collapse state for sections.
# Keys are IFC entity IDs; True = expanded (default).
# Using plain dicts avoids any RNA property modification during draw callbacks.
_H_EXPANDED: dict[int, bool] = {} # alignment_id → bool
_V_EXPANDED: dict[int, bool] = {} # vertical layout entity_id → bool
_C_EXPANDED: dict[int, bool] = {} # cant layout entity_id → bool
# =============================================================================
# Section toggle operators
# =============================================================================
class ALIGN_OT_toggle_h_segments(Operator):
"""Toggle horizontal segment table"""
bl_idname = "align.toggle_h_segments"
bl_label = "Toggle Horizontal Segments"
bl_options = {"INTERNAL"}
alignment_id: IntProperty()
def execute(self, context):
_H_EXPANDED[self.alignment_id] = not _H_EXPANDED.get(self.alignment_id, True)
context.area.tag_redraw()
return {"FINISHED"}
class ALIGN_OT_toggle_v_segments(Operator):
"""Toggle vertical segment table"""
bl_idname = "align.toggle_v_segments"
bl_label = "Toggle Vertical Segments"
bl_options = {"INTERNAL"}
entity_id: IntProperty()
def execute(self, context):
_V_EXPANDED[self.entity_id] = not _V_EXPANDED.get(self.entity_id, True)
context.area.tag_redraw()
return {"FINISHED"}
class ALIGN_OT_toggle_cant_segments(Operator):
"""Toggle cant segment table"""
bl_idname = "align.toggle_cant_segments"
bl_label = "Toggle Cant Segments"
bl_options = {"INTERNAL"}
entity_id: IntProperty()
def execute(self, context):
_C_EXPANDED[self.entity_id] = not _C_EXPANDED.get(self.entity_id, True)
context.area.tag_redraw()
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
# =============================================================================
def _rad_to_bearing(rad: float) -> str:
"""Convert IFC start direction (radians, CCW from east) to compass bearing.
IFC: 0 = east, increasing CCW. Bearing: 0 = north, increasing CW.
Result: N dd°mm'ss" E / S dd°mm'ss" E / S dd°mm'ss" W / N dd°mm'ss" W
"""
bearing_deg = (90.0 - math.degrees(rad)) % 360.0
def dms(angle_deg: float) -> str:
d = int(angle_deg)
m = int((angle_deg - d) * 60)
s = (angle_deg - d - m / 60) * 3600
return f"{d}°{m:02d}'{s:04.1f}\""
if bearing_deg < 90.0:
return f"N {dms(bearing_deg)} E"
elif bearing_deg < 180.0:
return f"S {dms(180.0 - bearing_deg)} E"
elif bearing_deg < 270.0:
return f"S {dms(bearing_deg - 180.0)} W"
else:
return f"N {dms(360.0 - bearing_deg)} W"
def _start_en(ifc_file, dp) -> tuple[float | None, float | None]:
"""Return (Easting, Northing) for an IfcAlignmentHorizontalSegment.
StartPoint is in IFC project coordinates; auto_xyz2enh applies any
IfcMapConversion to get global map coordinates. Returns (None, None)
if StartPoint is absent or the conversion fails.
"""
pt = getattr(dp, "StartPoint", None)
if pt is None:
return None, None
try:
coords = pt.Coordinates
e, n, _ = ifcopenshell.util.geolocation.auto_xyz2enh(
ifc_file, coords[0], coords[1], 0.0
)
return e, n
except Exception:
return None, None
class ALIGN_PT_alignment_authoring(Panel):
"""Add an alignment and draw its horizontal geometry — Alignments tab.
This is a from-scratch authoring workflow, independent of the CIVIL tab's
PI-table tools: add a bare alignment, then draw its horizontal geometry
directly in the viewport.
"""
bl_label = "Add Alignment"
bl_idname = "ALIGN_PT_alignment_authoring"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "scene"
bl_parent_id = "BIM_PT_tab_alignments"
bl_options = {"HIDE_HEADER"}
@classmethod
def poll(cls, context):
if not tool.Blender.should_show_panel(context, "ALIGNMENTS", cls.bl_idname):
return False
return is_ifc4x3()
def draw(self, context):
layout = self.layout
col = layout.column(align=True)
col.operator("align.add_alignment", icon="ADD")
alignment = tool.Alignment.get_active_alignment()
row = col.row(align=True)
row.enabled = bool(alignment)
row.operator("align.draw_horizontal_alignment", icon="EYEDROPPER")
row.operator("align.remove_alignment", text="", icon="TRASH")
if not alignment:
col.label(text="Add or select an alignment first", icon="INFO")
marker = context.active_object
is_marker = bool(marker) and _is_interior_pi_marker(marker)
markers_present = _pi_markers_present(context)
if is_marker or markers_present:
box = layout.box()
if is_marker:
pi_data = marker.bonsai_pi_curve_marker
box.label(text=f"PI {pi_data.pi_index}", icon="EMPTY_AXIS")
box.prop(pi_data, "curve_type")
if pi_data.curve_type == "CIRCULAR":
box.prop(pi_data, "radius")
box.operator("align.apply_pi_curve", icon="CHECKMARK")
else:
box.label(text="Select a PI marker to define its curve", icon="INFO")
if markers_present:
box.operator("align.clear_pi_markers", icon="TRASH")
class ALIGN_PT_alignment_stationing_authoring(Panel):
"""Start station and station equations — Alignments tab.
A from-scratch equivalent of the CIVIL tab's stationing panel: edit the
start station, and add/remove additional stationing referents (station
equations) for gaps, overlaps, or reversed stationing direction.
"""
bl_label = "Stationing"
bl_idname = "ALIGN_PT_alignment_stationing_authoring"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "scene"
bl_parent_id = "BIM_PT_tab_alignments"
bl_options = {"DEFAULT_CLOSED"}
@classmethod
def poll(cls, context):
if not tool.Blender.should_show_panel(context, "ALIGNMENTS", cls.bl_idname):
return False
if not is_ifc4x3():
return False
return bool(tool.Alignment.get_active_alignment())
def draw(self, context):
layout = self.layout
alignment = tool.Alignment.get_active_alignment()
if not alignment:
layout.label(text="Select an alignment", icon="INFO")
return
ifc_file = tool.Ifc.get()
start_station = ifcopenshell.api.alignment.get_alignment_start_station(ifc_file, alignment) or 0.0
row = layout.row(align=True)
row.label(text=f"Start: {tool.Alignment.format_station(start_station)}", icon="EMPTY_AXIS")
row.operator("align.set_start_station", text="", icon="GREASEPENCIL")
equations = tool.Alignment.get_stationing_referents(alignment)[1:] # skip the start referent (D 0)
if equations:
layout.separator()
layout.label(text="Station Equations:")
for referent, distance_along, station, incoming_station, has_increasing in equations:
box = layout.box()
row = box.row(align=True)
label = f"D {distance_along:.2f}: {tool.Alignment.format_station(station or 0.0)}"
if incoming_station is not None:
label += f" (from {tool.Alignment.format_station(incoming_station)})"
if has_increasing is False:
label += ""
row.label(text=label)
op = row.operator("align.edit_station_equation", text="", icon="GREASEPENCIL")
op.referent_id = referent.id()
op = row.operator("align.remove_station_equation", text="", icon="X")
op.referent_id = referent.id()
layout.operator("align.add_station_equation", icon="ADD")
class ALIGN_PT_alignment_segments(Panel):
"""Read-only segment breakdown for the selected IfcAlignment.
Lists horizontal, vertical, and cant segments from IFC data.
Appears in the Alignments tab whenever an alignment object is active.
"""
bl_label = "Alignment Segments"
bl_idname = "ALIGN_PT_alignment_segments"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "scene"
bl_parent_id = "BIM_PT_tab_alignments"
bl_options = {"HIDE_HEADER"}
@classmethod
def poll(cls, context):
if not tool.Blender.should_show_panel(context, "ALIGNMENTS", cls.bl_idname):
return False
if not is_ifc4x3():
return False
ifc_file = tool.Ifc.get()
return bool(ifc_file and next(iter(ifc_file.by_type("IfcAlignment")), None))
def draw(self, context):
layout = self.layout
props = context.scene.CivilAlignmentProperties
ifc_file = tool.Ifc.get()
if not ifc_file:
return
# --- Alignment selector dropdown (always at top, above segment boxes) ---
layout.prop(props, "active_alignment_id_str", text="", icon="CURVE_DATA")
# Sync dropdown from viewport/outliner selection.
# - Attribute access (props.active_alignment_id_str) returns the string identifier.
# - Dict access (props["key"] = int) sets by index and bypasses the update callback,
# preventing the callback from calling bpy.ops from within a draw function.
viewport_alignment = tool.Alignment.get_active_alignment()
if viewport_alignment:
new_val = str(viewport_alignment.id())
if props.active_alignment_id_str != new_val:
for idx, (ident, _, _) in enumerate(_alignment_enum_items(props, context)):
if ident == new_val:
props["active_alignment_id_str"] = idx
context.area.tag_redraw() # refresh the dropdown widget
break
# Resolve which alignment to display
try:
aid = int(props.active_alignment_id_str)
except (ValueError, TypeError):
aid = 0
if aid == 0:
layout.label(text="Select an alignment above", icon="INFO")
return
try:
alignment = ifc_file.by_id(aid)
except Exception:
return
if not alignment or not alignment.is_a("IfcAlignment"):
return
# --- Horizontal layout (collect cants but draw them after vertical) ---
all_cants = []
for rel in getattr(alignment, "IsNestedBy", []) or []:
for layout_entity in rel.RelatedObjects or []:
if layout_entity.is_a("IfcAlignmentHorizontal"):
self._draw_horizontal(layout, context, layout_entity, alignment.id())
elif layout_entity.is_a("IfcAlignmentCant"):
all_cants.append(layout_entity)
# --- Vertical layouts (direct + child alignments) ---
all_verticals = tool.Alignment.get_all_vertical_layouts(alignment)
if all_verticals:
from .decorator import VerticalProfileDecorator
dec = VerticalProfileDecorator
row = layout.row(align=True)
row.label(text="Vertical Profile:", icon="FCURVE")
row.prop(props, "vertical_exaggeration", text="VE")
row.operator(
"align.show_vertical_profile", text="",
icon="GRAPH", depress=dec.is_installed,
)
for layout_entity in all_verticals:
self._draw_vertical(layout, context, layout_entity)
# --- Cant layouts (after vertical) ---
for layout_entity in all_cants:
self._draw_cant(layout, context, layout_entity)
def _segments(self, layout_entity):
for rel in getattr(layout_entity, "IsNestedBy", []) or []:
for seg in rel.RelatedObjects or []:
if seg.is_a("IfcAlignmentSegment"):
yield seg
def _draw_horizontal(self, layout, context, layout_entity, alignment_id=0):
ifc_file = tool.Ifc.get()
props = context.scene.CivilAlignmentProperties
selected_id = props.selected_h_segment_id
expanded = _H_EXPANDED.get(alignment_id, True)
box = layout.box()
# Collapsible header with label toggle
row = box.row(align=True)
op = row.operator(
"align.toggle_h_segments",
text="", icon="TRIA_DOWN" if expanded else "TRIA_RIGHT", emboss=False,
)
op.alignment_id = alignment_id
row.label(text="Horizontal", icon="DRIVER_ROTATIONAL_DIFFERENCE")
row.prop(props, "show_h_segment_labels", text="", icon="FONT_DATA")
if not expanded:
return
# Column headers (no separate select column — index cell is the select button)
header = box.split(factor=0.08)
header.label(text="#")
h2 = header.split(factor=0.30)
h2.label(text="Type")
h3 = h2.split(factor=0.27)
h3.label(text="Length")
h4 = h3.split(factor=0.37)
h4.label(text="Radius")
h4.label(text="Bearing")
idx = 1
for seg in self._segments(layout_entity):
dp = seg.DesignParameters
if not dp:
continue
length = getattr(dp, "SegmentLength", 0.0) or 0.0
if length == 0.0:
continue # zero-length terminators are invisible to users
seg_id = seg.id()
is_selected = selected_id == seg_id
seg_type = dp.PredefinedType or "?"
r_start = getattr(dp, "StartRadiusOfCurvature", None) or 0.0
bearing = _rad_to_bearing(dp.StartDirection) if hasattr(dp, "StartDirection") else ""
e, n = _start_en(ifc_file, dp)
col = box.column(align=True)
col.alert = is_selected
# Row 1: index (clickable to select), type, length, radius, bearing
row = col.split(factor=0.08)
op = row.operator(
"align.select_h_segment",
text=str(idx),
depress=is_selected,
)
op.segment_id = seg_id
r2 = row.split(factor=0.30)
r2.label(text=seg_type)
r3 = r2.split(factor=0.27)
r3.label(text=f"{abs(length):.2f}")
r4 = r3.split(factor=0.37)
r4.label(text=f"{abs(r_start):.1f}" if r_start else "-")
r4.label(text=bearing)
# Row 2: start Easting / Northing
if e is not None:
sub = col.split(factor=0.08)
sub.label(text="") # align under # column
sub2 = sub.split(factor=0.50)
sub2.label(text=f"E: {e:.3f}")
sub2.label(text=f"N: {n:.3f}")
idx += 1
def _draw_vertical(self, layout, context, layout_entity):
from .decorator import VerticalProfileDecorator
dec = VerticalProfileDecorator
props = context.scene.CivilAlignmentProperties
v_id = layout_entity.id()
# Prefer the name of the alignment that owns this vertical layout.
# For CT 4.1.4.4.1.2 this is the child alignment (e.g. "Design Grade");
# for a simple alignment it is the top-level alignment name.
label = None
for rel in getattr(layout_entity, "Nests", []) or []:
if rel.RelatingObject.is_a("IfcAlignment"):
label = rel.RelatingObject.Name
break
label = label or layout_entity.Name or f"Vertical #{v_id}"
expanded = _V_EXPANDED.get(v_id, True)
selected_v_id = props.selected_v_segment_id
# Eye-icon uses vertical_items (populated when the profile window is open)
v_item = next((it for it in props.vertical_items if it.entity_id == v_id), None)
box = layout.box()
row = box.row(align=True)
# Collapsible toggle via operator (safe to call from draw)
op = row.operator(
"align.toggle_v_segments",
text="", icon="TRIA_DOWN" if expanded else "TRIA_RIGHT", emboss=False,
)
op.entity_id = v_id
row.label(text=label, icon="FCURVE")
# Per-vertical eye-icon — only shown when the profile window is open
if dec.is_installed and v_item is not None:
vis_icon = "HIDE_OFF" if v_item.is_visible else "HIDE_ON"
row.prop(v_item, "is_visible", text="", icon=vis_icon, emboss=False)
# Per-vertical label toggle — only shown when the profile window is open
if dec.is_installed and v_item is not None:
row.prop(v_item, "show_labels", text="", icon="FONT_DATA")
if not expanded:
return
# Column headers (index cell is the select button)
header = box.split(factor=0.08)
header.label(text="#")
h2 = header.split(factor=0.32)
h2.label(text="Type")
h3 = h2.split(factor=0.28)
h3.label(text="H-Length")
h4 = h3.split(factor=0.45)
h4.label(text="G In")
h4.label(text="G Out")
idx = 1
for seg in self._segments(layout_entity):
dp = seg.DesignParameters
if not dp:
continue
seg_type = dp.PredefinedType or "?"
h_len = getattr(dp, "HorizontalLength", 0.0) or 0.0
g_start = getattr(dp, "StartGradient", 0.0) or 0.0
g_end = getattr(dp, "EndGradient", 0.0) or 0.0
dist_along = getattr(dp, "StartDistAlong", None)
start_height = getattr(dp, "StartHeight", None)
seg_id = seg.id()
is_v_selected = selected_v_id == seg_id
col = box.column(align=True)
col.alert = is_v_selected
# Row 1: index (clickable to select), type, length, grades
row = col.split(factor=0.08)
op = row.operator(
"align.select_v_segment",
text=str(idx),
depress=is_v_selected,
)
op.segment_id = seg_id
r2 = row.split(factor=0.32)
r2.label(text=seg_type[:14])
r3 = r2.split(factor=0.28)
r3.label(text=f"{h_len:.2f}")
r4 = r3.split(factor=0.45)
r4.label(text=f"{g_start * 100:.3f}%")
r4.label(text=f"{g_end * 100:.3f}%")
# Row 2: start distance along + elevation
if dist_along is not None or start_height is not None:
sub = col.split(factor=0.08)
sub.label(text="")
sub2 = sub.split(factor=0.50)
sub2.label(text=f"Dist: {dist_along:.2f}" if dist_along is not None else "")
sub2.label(text=f"Elev: {start_height:.3f}" if start_height is not None else "")
idx += 1
def _draw_cant(self, layout, context, layout_entity):
from .decorator import VerticalProfileDecorator
dec = VerticalProfileDecorator
props = context.scene.CivilAlignmentProperties
c_id = layout_entity.id()
label = layout_entity.Name or f"Cant #{c_id}"
expanded = _C_EXPANDED.get(c_id, True)
selected_c_id = props.selected_cant_segment_id
c_item = next((it for it in props.cant_items if it.entity_id == c_id), None)
box = layout.box()
row = box.row(align=True)
op = row.operator(
"align.toggle_cant_segments",
text="", icon="TRIA_DOWN" if expanded else "TRIA_RIGHT", emboss=False,
)
op.entity_id = c_id
row.label(text=label, icon="MOD_CURVE")
if dec.is_installed and c_item is not None:
vis_icon = "HIDE_OFF" if c_item.is_visible else "HIDE_ON"
row.prop(c_item, "is_visible", text="", icon=vis_icon, emboss=False)
row.prop(props, "show_cant_segment_labels", text="", icon="FONT_DATA")
if not expanded:
return
# Column headers (# is the select button)
header = box.split(factor=0.08)
header.label(text="#")
h2 = header.split(factor=0.30)
h2.label(text="Type")
h3 = h2.split(factor=0.27)
h3.label(text="Length")
h4 = h3.split(factor=0.37)
h4.label(text="Start L / R")
h4.label(text="End L / R")
def _cant_pair(left, right):
# cant is each rail's deviating elevation; show both, sign preserved
return f"{left * 1000:.0f} / {right * 1000:.0f}"
idx = 1
for seg in self._segments(layout_entity):
dp = seg.DesignParameters
if not dp:
continue
seg_type = dp.PredefinedType or "?"
h_len = getattr(dp, "HorizontalLength", None)
if h_len is None:
h_len = getattr(dp, "Length", 0.0) or 0.0
start_l = getattr(dp, "StartCantLeft", None) or 0.0
start_r = getattr(dp, "StartCantRight", None) or 0.0
end_l = getattr(dp, "EndCantLeft", None)
end_r = getattr(dp, "EndCantRight", None)
end_l = start_l if end_l is None else end_l
end_r = start_r if end_r is None else end_r
seg_id = seg.id()
is_c_selected = selected_c_id == seg_id
col = box.column(align=True)
col.alert = is_c_selected
row = col.split(factor=0.08)
op = row.operator(
"align.select_cant_segment",
text=str(idx),
depress=is_c_selected,
)
op.segment_id = seg_id
r2 = row.split(factor=0.30)
r2.label(text=seg_type[:14])
r3 = r2.split(factor=0.27)
r3.label(text=f"{h_len:.2f}" if h_len else "-")
r4 = r3.split(factor=0.37)
r4.label(text=_cant_pair(start_l, start_r))
r4.label(text=_cant_pair(end_l, end_r))
idx += 1
@@ -0,0 +1,72 @@
# 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,6 +135,11 @@ class IfcClassData:
("EMPTY", "No Geometry", "Start with an empty object"),
]
if ifc_class == "IfcAlignment":
# Alignment representations come from ifcopenshell.api.alignment
# (composite curves), not from a mesh template.
return templates
if ifc_class in ("IfcWindowType", "IfcWindowStyle", "IfcWindow"):
templates.extend([None, ("WINDOW", "Window", "Parametric window")])
elif ifc_class in ("IfcDoorType", "IfcDoorStyle", "IfcDoor"):
@@ -575,6 +575,29 @@ class AddElement(bpy.types.Operator, tool.Ifc.Operator):
)
element.Description = props.description or None
if props.ifc_class == "IfcAlignment":
# Saikei: creating an alignment automatically creates its
# horizontal layout (spec 1.1). Alignments own an origin local
# placement (IFC 4.1.4.1.1 aggregates them to the project, never
# to a spatial container) and their representations come from
# ifcopenshell.api.alignment, so the representation templates and
# 3D-cursor placement do not apply.
obj.location = (0.0, 0.0, 0.0)
h_layout = tool.Alignment.add_horizontal_layout_to_alignment(element)
tool.Alignment.create_object_for_layout(h_layout, obj)
civil_props = context.scene.CivilAlignmentProperties
civil_props.active_alignment_id = element.id()
civil_props.active_alignment_name = element.Name or ""
bpy.context.view_layer.update()
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj)
tool.Blender.set_active_object(obj)
self.report(
{"INFO"},
f"Alignment '{element.Name}' created with an empty horizontal layout — "
"add PI points from the CIVIL tab or viewport picking.",
)
return
if representation_template == "EMTPY" or not ifc_context:
pass
elif representation_template == "OBJ" and props.representation_obj:
+2
View File
@@ -530,6 +530,8 @@ def get_tab(
("PROJECT", "Project Overview", "", bonsai.bim.icons[icon_key].icon_id, 0),
("OBJECT", "Object Information", "", "FILE_3D", 1),
("GEOMETRY", "Geometry and Materials", "", "MATERIAL", 2),
("CIVIL", "Civil Infrastructure", "", "CURVE_DATA", 11),
("ALIGNMENTS", "Alignments", "", "ANIM_DATA", 12),
("DRAWINGS", "Drawings and Documents", "", "DOCUMENTS", 3),
("SERVICES", "Services and Systems", "", "NETWORK_DRIVE", 4),
("STRUCTURE", "Structural Analysis", "", "EDITMODE_HLT", 5),
+39
View File
@@ -1640,6 +1640,43 @@ class BIM_PT_tab_profiles(Panel):
pass
# Civil Infrastructure tab panels
class BIM_PT_tab_horizontal_alignment(Panel):
bl_idname = "BIM_PT_tab_horizontal_alignment"
bl_label = "Horizontal Alignment"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "scene"
bl_order = 1
bim_tab_name = "CIVIL"
@classmethod
def poll(cls, context):
if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get():
return True
def draw(self, context):
pass
class BIM_PT_tab_alignments(Panel):
bl_idname = "BIM_PT_tab_alignments"
bl_label = "Alignments"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "scene"
bl_order = 1
bim_tab_name = "ALIGNMENTS"
@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_sheets(Panel):
bl_idname = "BIM_PT_tab_sheets"
bl_label = "Sheets"
@@ -1826,6 +1863,8 @@ class UIData:
("PROJECT", bonsai.bim.icons[f"{color_mode}_ifc"].icon_id, True),
("OBJECT", "FILE_3D", is_ifc_project),
("GEOMETRY", "MATERIAL", is_ifc_project),
("CIVIL", "CURVE_DATA", is_ifc_project),
("ALIGNMENTS", "ANIM_DATA", is_ifc_project),
("DRAWINGS", "DOCUMENTS", is_ifc_project),
("SERVICES", "NETWORK_DRIVE", is_ifc_project),
("STRUCTURE", "EDITMODE_HLT", is_ifc_project),
+266
View File
@@ -0,0 +1,266 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2025, 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/>.
"""Core alignment business logic - Orchestration only, NO bpy imports.
This module contains alignment-related business logic and workflow
orchestration. All calculations, algorithms, and IFC operations are
in the tool layer. Functions receive tool classes as parameters
following Bonsai's dependency injection pattern.
NOTE: Math, calculations, algorithms, and IFC API calls belong in
tool/alignment.py. This module only handles:
- Business rules and validation
- Workflow orchestration (calling tool methods in sequence)
- Decision-making about what should happen
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import ifcopenshell
from .. import tool
# =============================================================================
# Alignment Creation
# =============================================================================
def create_alignment(
ifc_tool: "type[tool.Ifc]",
alignment_tool: "type[tool.Alignment]",
name: str,
start_station: float = 0.0,
) -> "ifcopenshell.entity_instance":
"""Create a new alignment with full IFC structure.
Business rules:
1. An IFC file must be loaded
2. Name must not be empty
3. Delegates to tool layer for IFC creation and Blender hierarchy
Args:
ifc_tool: The IFC tool class
alignment_tool: The Alignment tool class
name: The alignment name
start_station: Starting station value
Returns:
The created IfcAlignment entity
Raises:
ValueError: If no IFC file is loaded or name is empty
"""
if ifc_tool.get() is None:
raise ValueError("No IFC file loaded")
if not name or not name.strip():
raise ValueError("Alignment name cannot be empty")
return alignment_tool.create_alignment(name.strip(), start_station)
# =============================================================================
# PI Edit Mode Functions
# =============================================================================
def enter_pi_edit_mode(
ifc_tool: "type[tool.Ifc]",
alignment_tool: "type[tool.Alignment]",
alignment_id: int,
) -> list:
"""Enter PI edit mode for an alignment.
Business logic for entering PI edit mode:
1. Validates that the alignment exists
2. Validates that the alignment has a horizontal layout with real segments
3. Back-calculates PI positions from segments
4. Creates temporary EMPTY objects at each PI location
Args:
ifc_tool: The IFC tool class
alignment_tool: The Alignment tool class
alignment_id: The IFC ID of the alignment to edit
Returns:
List of created PI EMPTY objects
Raises:
ValueError: If alignment doesn't exist, has no horizontal layout,
or has no real segments
"""
# Validate alignment exists
ifc_file = ifc_tool.get()
if ifc_file is None:
raise ValueError("No IFC file loaded")
try:
alignment = ifc_file.by_id(alignment_id)
except RuntimeError:
raise ValueError(f"Alignment with ID {alignment_id} not found")
if not alignment.is_a("IfcAlignment"):
raise ValueError(f"Entity {alignment_id} is not an IfcAlignment")
# Validate alignment has horizontal layout (delegated to tool)
h_layout = alignment_tool.get_horizontal_layout(alignment)
if h_layout is None:
raise ValueError(f"Alignment '{alignment.Name}' has no horizontal layout")
# Validate layout has real segments (not just zero-length terminator)
if not alignment_tool.layout_has_real_segments(h_layout):
raise ValueError(f"Alignment '{alignment.Name}' has no editable segments")
# Back-calculate PI positions from segments
pis = alignment_tool.back_calculate_pis_from_alignment(alignment)
if len(pis) < 2:
raise ValueError(f"Alignment '{alignment.Name}' must have at least 2 PIs")
# Create temporary EMPTY objects at each PI location
empties = alignment_tool.create_pi_edit_empties(alignment, pis)
return empties
def import_alignment_csv(
ifc_tool: "type[tool.Ifc]",
alignment_tool: "type[tool.Alignment]",
filepath: str,
):
"""Import alignment(s) from a CSV file and build their viewport objects.
Business rules:
1. An IFC file must be loaded
2. The CSV may carry one horizontal row plus any number of vertical rows;
extra verticals arrive as aggregated child alignments and each child
gets its own viewport hierarchy
3. Referents generated by the import are materialized as empties
Args:
ifc_tool: The IFC tool class
alignment_tool: The Alignment tool class
filepath: Path to the CSV file
Returns:
The imported (parent) IfcAlignment entity
Raises:
ValueError: If no IFC file is loaded
"""
ifc_file = ifc_tool.get()
if ifc_file is None:
raise ValueError("No IFC file loaded")
alignment = alignment_tool.create_alignment_from_csv(filepath)
alignment_tool.create_hierarchy_for_alignment(alignment)
parent_obj = ifc_tool.get_object(alignment)
for child in alignment_tool.get_child_alignments(alignment):
# Child alignments (IFC CT 4.1.4.4.1.2) are vertical-only wrappers.
# We skip creating a Blender empty for them so they don't clutter the
# scene collection. Their vertical layouts go under the parent object.
alignment_tool.create_child_vertical_hierarchy(child, parent_obj)
alignment_tool.create_objects_for_referents(alignment)
return alignment
def exit_pi_edit_mode(
ifc_tool: "type[tool.Ifc]",
alignment_tool: "type[tool.Alignment]",
alignment_id: int,
apply: bool,
) -> bool:
"""Exit PI edit mode for an alignment.
Business logic for exiting PI edit mode:
1. If apply=True:
- Collect new PI positions from empties
- Validate the new configuration
- Update alignment segments in-place (preserves alignment ID)
2. Always:
- Remove temporary EMPTY objects
- Return success status
This function modifies the alignment segments in-place rather than
deleting and recreating the alignment. This preserves the alignment's
IFC entity ID, preventing stale reference issues.
Args:
ifc_tool: The IFC tool class
alignment_tool: The Alignment tool class
alignment_id: The IFC ID of the alignment being edited
apply: If True, update alignment with new PI positions
Returns:
True if successful
Raises:
ValueError: If alignment doesn't exist or update fails
"""
ifc_file = ifc_tool.get()
if ifc_file is None:
# No file loaded, just clean up empties
alignment_tool.remove_pi_edit_empties(alignment_id)
return True
# Get alignment
try:
alignment = ifc_file.by_id(alignment_id)
except RuntimeError:
# Alignment was deleted, just clean up empties
alignment_tool.remove_pi_edit_empties(alignment_id)
return True
if apply:
# Collect PI positions from empties
hpoints, radii = alignment_tool.collect_pis_from_empties(alignment_id)
if len(hpoints) < 2:
raise ValueError("At least 2 PIs are required")
# Get horizontal layout (delegated to tool)
h_layout = alignment_tool.get_horizontal_layout(alignment)
if h_layout is None:
raise ValueError("Alignment has no horizontal layout")
# Remove empties before modifying segments
alignment_tool.remove_pi_edit_empties(alignment_id)
# Remove Blender visualization for segments (not the whole hierarchy)
alignment_tool.remove_layout_segment_objects(h_layout)
# Clear existing IFC segments and add new ones (delegated to tool)
alignment_tool.clear_layout_segments(h_layout)
alignment_tool.layout_by_pi_method(h_layout, hpoints, radii)
# Refresh Blender visualization for new segments
layout_obj = ifc_tool.get_object(h_layout)
if layout_obj:
alignment_tool.create_objects_for_layout_segments(h_layout, layout_obj)
return True
else:
# Cancel - just remove empties without regenerating
alignment_tool.remove_pi_edit_empties(alignment_id)
return True
+29
View File
@@ -1279,3 +1279,32 @@ class Wall:
@interface
class Web:
pass
# ############################################################################ #
# Saikei Civil - horizontal infrastructure modules.
@interface
class Alignment:
# Alignment creation
def create_alignment(cls, name, start_station=0.0): pass
# Horizontal PI edit mode
def back_calculate_pis_from_alignment(cls, alignment): pass
def clear_layout_segments(cls, h_layout): pass
def collect_pis_from_empties(cls, alignment_id): pass
def create_objects_for_layout_segments(cls, h_layout, layout_obj): pass
def create_pi_edit_empties(cls, alignment, pis): pass
def get_horizontal_layout(cls, alignment): pass
def layout_by_pi_method(cls, h_layout, hpoints, radii): pass
def layout_has_real_segments(cls, h_layout): pass
def remove_layout_segment_objects(cls, h_layout): pass
def remove_pi_edit_empties(cls, alignment_id): pass
# Stationing
def format_station(cls, station): pass
# CSV import
def create_alignment_from_csv(cls, filepath): pass
def create_hierarchy_for_alignment(cls, alignment): pass
def get_child_alignments(cls, alignment): pass
def create_objects_for_referents(cls, alignment): pass
+1
View File
@@ -20,6 +20,7 @@
# ruff: file-ignore[unused-import]
from bonsai.tool.aggregate import Aggregate
from bonsai.tool.alignment import Alignment
from bonsai.tool.array import Array
from bonsai.tool.attribute import Attribute
from bonsai.tool.bcf import Bcf
File diff suppressed because it is too large Load Diff
+4
View File
@@ -488,4 +488,8 @@ class Root(bonsai.core.tool.Root):
"IfcAnnotation",
"IfcRelSpaceBoundary",
)
if version != "IFC4":
# IFC4X3+: alignments are created like any other element
# (Saikei); the create flow bootstraps the horizontal layout.
products += ("IfcAlignment",)
return products
+2
View File
@@ -1,12 +1,14 @@
[pytest]
markers =
aggregate
alignment
array
attribute
boolean
boundary
brick
bsdd
civil
clash
classification
clip_box
@@ -0,0 +1,670 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 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/>.
"""Headless operator tests for the Saikei alignment module.
Tests non-modal alignment operators end-to-end in Blender headless mode.
Follows Bonsai's existing test patterns (NewIfc4X3 base class from bootstrap).
Operators tested:
Horizontal: add_pi, remove_pi, recalculate_pis, clear_pis, create_alignment_by_pi
Utility: name_segments
CSV import: import_alignment_csv (EXEC_DEFAULT with explicit filepath)
Operators skipped (modal / viewport):
pick_pi_from_viewport, enter_pi_edit_mode
"""
import pytest
import bpy
import ifcopenshell
import ifcopenshell.api.alignment as align_api
import bonsai.tool as tool
from bonsai.bim.ifc import IfcStore
from test.bim.bootstrap import NewIfc4X3
def _geometry_mapping_available() -> bool:
"""True when the modular geometry-mapping plugins are present.
v0.9.0 evaluates segment endpoints through the geometry engine, which
loads per-schema ifcopenshell_geometry_mapping_* plugins at runtime. The
win64 v0.9.0alpha0 builds ship without them (IfcOpenShell#9301), so
geometry-dependent tests skip locally and run in CI where builds are
complete.
"""
import pathlib
package_root = pathlib.Path(ifcopenshell.__file__).parent
return any(f.name.startswith("ifcopenshell_geometry_mapping_") for f in package_root.iterdir())
requires_geometry_engine = pytest.mark.skipif(
not _geometry_mapping_available(),
reason="geometry mapping plugins unavailable (IfcOpenShell#9301); covered in CI",
)
pytestmark = pytest.mark.alignment
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def get_alignment_props():
"""Shortcut to CivilAlignmentProperties on the scene."""
return bpy.context.scene.CivilAlignmentProperties
def create_empty_alignment(name="Test Alignment"):
"""Create an IfcAlignment with an empty IfcAlignmentHorizontal layout.
Uses align_api.create() which creates IfcAlignment + IfcAlignmentHorizontal
+ zero-length terminator + geometric representations + aggregation to project.
This is the minimal setup required for operators that need an active
alignment (e.g. create_alignment_by_pi, recalculate_pis).
Returns:
tuple: (alignment_entity, alignment_blender_obj)
"""
ifc_file = tool.Ifc.get()
# create() handles: IfcAlignment, IfcAlignmentHorizontal, IfcRelNests,
# zero-length terminator, geometric representation, project aggregation
alignment = align_api.create(ifc_file, name=name)
# Create Blender objects via the tool layer
alignment_obj = tool.Alignment.create_hierarchy_for_alignment(alignment)
# Set as active so operators can find it via get_active_alignment()
if alignment_obj:
bpy.context.view_layer.objects.active = alignment_obj
alignment_obj.select_set(True)
# Set active alignment ID in properties
props = get_alignment_props()
props.active_alignment_id = alignment.id()
props.active_alignment_name = name
return alignment, alignment_obj
def add_pis_to_props(pi_data):
"""Add PIs to the props collection with specified coordinates.
Args:
pi_data: list of (e, n, radius) tuples.
First and last are auto-typed as ENDPOINT.
"""
props = get_alignment_props()
for i, (e, n, radius) in enumerate(pi_data):
bpy.ops.civil.add_pi()
pi = props.pis[len(props.pis) - 1]
pi.e = str(e)
pi.n = str(n)
if radius > 0:
pi.radius = radius
# ===========================================================================
# Horizontal PI Operators
# ===========================================================================
class TestAddPi(NewIfc4X3):
"""Tests for CIVIL_OT_add_pi (civil.add_pi)."""
def test_add_first_pi_sets_endpoint_at_origin(self):
props = get_alignment_props()
result = bpy.ops.civil.add_pi()
assert result == {"FINISHED"}
assert len(props.pis) == 1
assert float(props.pis[0].e) == 0.0
assert float(props.pis[0].n) == 0.0
assert props.pis[0].pi_type == "ENDPOINT"
def test_add_second_pi_offsets_from_first(self):
props = get_alignment_props()
bpy.ops.civil.add_pi()
bpy.ops.civil.add_pi()
assert len(props.pis) == 2
assert props.pis[1].pi_type == "ENDPOINT"
# Second PI should be offset 100 units east
assert float(props.pis[1].e) == pytest.approx(100.0)
assert float(props.pis[1].n) == pytest.approx(0.0)
def test_add_third_pi_extrapolates_and_changes_second_type(self):
props = get_alignment_props()
bpy.ops.civil.add_pi()
bpy.ops.civil.add_pi()
bpy.ops.civil.add_pi()
assert len(props.pis) == 3
# Second PI (index 1) should have been changed from ENDPOINT to TANGENT
assert props.pis[1].pi_type == "TANGENT"
# Third PI extrapolates direction
assert float(props.pis[2].e) == pytest.approx(200.0)
def test_active_pi_index_tracks_last_added(self):
props = get_alignment_props()
bpy.ops.civil.add_pi()
assert props.active_pi_index == 0
bpy.ops.civil.add_pi()
assert props.active_pi_index == 1
bpy.ops.civil.add_pi()
assert props.active_pi_index == 2
def test_add_pi_triggers_geometry_recalculation(self):
"""After adding 2+ PIs, display_rows should be populated."""
props = get_alignment_props()
bpy.ops.civil.add_pi()
bpy.ops.civil.add_pi()
# With 2 PIs, we should have at least 2 point rows and 1 segment row
assert len(props.display_rows) >= 2
class TestRemovePi(NewIfc4X3):
"""Tests for CIVIL_OT_remove_pi (civil.remove_pi)."""
def test_remove_pi_decrements_collection(self):
props = get_alignment_props()
bpy.ops.civil.add_pi()
bpy.ops.civil.add_pi()
bpy.ops.civil.add_pi()
assert len(props.pis) == 3
# Select first point row in display_rows
if props.display_rows:
props.active_display_row_index = 0
result = bpy.ops.civil.remove_pi()
assert result == {"FINISHED"}
assert len(props.pis) == 2
def test_remove_pi_from_single_item_list(self):
props = get_alignment_props()
bpy.ops.civil.add_pi()
assert len(props.pis) == 1
# Use active_pi_index fallback (no display_rows for 1 PI)
props.active_pi_index = 0
# display_rows may be empty with 1 PI, so remove_pi uses active_pi_index
props.display_rows.clear()
result = bpy.ops.civil.remove_pi()
assert result == {"FINISHED"}
assert len(props.pis) == 0
def test_remove_pi_updates_active_index(self):
props = get_alignment_props()
bpy.ops.civil.add_pi()
bpy.ops.civil.add_pi()
bpy.ops.civil.add_pi()
# Select last point row
props.active_pi_index = 2
props.display_rows.clear()
bpy.ops.civil.remove_pi()
# active_pi_index should clamp to valid range
assert props.active_pi_index <= len(props.pis) - 1
class TestClearPis(NewIfc4X3):
"""Tests for CIVIL_OT_clear_pis (civil.clear_pis).
Note: clear_pis defines invoke() with invoke_confirm, but calling via
bpy.ops in Python uses EXEC_DEFAULT by default, skipping invoke.
"""
def test_clear_pis_removes_all(self):
props = get_alignment_props()
for _ in range(5):
bpy.ops.civil.add_pi()
assert len(props.pis) == 5
result = bpy.ops.civil.clear_pis()
assert result == {"FINISHED"}
assert len(props.pis) == 0
assert len(props.display_rows) == 0
def test_clear_pis_resets_indices(self):
props = get_alignment_props()
bpy.ops.civil.add_pi()
bpy.ops.civil.add_pi()
bpy.ops.civil.clear_pis()
assert props.active_pi_index == 0
assert props.active_display_row_index == 0
def test_clear_pis_with_active_alignment_removes_ifc(self):
"""When an active alignment exists, clear_pis should remove it from IFC."""
alignment, alignment_obj = create_empty_alignment()
ifc_file = tool.Ifc.get()
props = get_alignment_props()
bpy.ops.civil.add_pi()
bpy.ops.civil.add_pi()
alignment_count_before = len(ifc_file.by_type("IfcAlignment"))
bpy.ops.civil.clear_pis()
alignment_count_after = len(ifc_file.by_type("IfcAlignment"))
assert alignment_count_after < alignment_count_before
assert len(props.pis) == 0
def test_clear_pis_resolves_alignment_from_props_not_viewport(self):
"""The alignment is resolved via props.active_alignment_id, so it is
deleted even when the viewport's active object is something else
(typically a segment curve after PI editing)."""
alignment, alignment_obj = create_empty_alignment()
ifc_file = tool.Ifc.get()
bpy.context.view_layer.objects.active = None
props = get_alignment_props()
bpy.ops.civil.add_pi()
alignment_count_before = len(ifc_file.by_type("IfcAlignment"))
bpy.ops.civil.clear_pis()
assert len(ifc_file.by_type("IfcAlignment")) < alignment_count_before
assert props.active_alignment_id == 0
assert props.active_alignment_name == ""
class TestRecalculatePis(NewIfc4X3):
"""Tests for CIVIL_OT_recalculate_pis (civil.recalculate_pis)."""
def test_recalculate_populates_display_rows(self):
props = get_alignment_props()
add_pis_to_props([(0, 0, 0), (500, 0, 0), (1000, 200, 0)])
result = bpy.ops.civil.recalculate_pis()
assert result == {"FINISHED"}
assert len(props.display_rows) > 0
def test_recalculate_computes_geometry_values(self):
"""PI geometry values (station, length_to_next) should be reasonable."""
props = get_alignment_props()
add_pis_to_props([(0, 0, 0), (500, 0, 0), (1000, 0, 0)])
bpy.ops.civil.recalculate_pis()
# Straight line: each segment should be 500 units
assert props.pis[0].length_to_next == pytest.approx(500.0, abs=1.0)
assert props.pis[1].length_to_next == pytest.approx(500.0, abs=1.0)
@requires_geometry_engine
def test_recalculate_with_active_alignment_updates_ifc(self):
"""When an active alignment exists, recalculate should update IFC segments."""
alignment, alignment_obj = create_empty_alignment()
ifc_file = tool.Ifc.get()
props = get_alignment_props()
add_pis_to_props([(0, 0, 0), (500, 0, 0), (1000, 200, 0)])
# First, create alignment segments
bpy.ops.civil.create_alignment_by_pi()
# Re-select alignment object (create_alignment_by_pi may change selection)
bpy.context.view_layer.objects.active = alignment_obj
alignment_obj.select_set(True)
# Modify a PI
props.pis[1].e = str(600.0)
# Recalculate should update IFC in-place
result = bpy.ops.civil.recalculate_pis()
assert result == {"FINISHED"}
# IFC should still have segments
segments = ifc_file.by_type("IfcAlignmentSegment")
assert len(segments) >= 2
@requires_geometry_engine
class TestCreateAlignmentByPi(NewIfc4X3):
"""Tests for CIVIL_OT_create_alignment_by_pi (civil.create_alignment_by_pi).
This operator requires an existing empty alignment set as active.
"""
def test_create_alignment_creates_ifc_segments(self):
"""Basic 3-PI alignment: creates tangent + tangent segments in IFC."""
alignment, alignment_obj = create_empty_alignment()
ifc_file = tool.Ifc.get()
props = get_alignment_props()
add_pis_to_props([(0, 0, 0), (500, 0, 0), (1000, 200, 0)])
result = bpy.ops.civil.create_alignment_by_pi()
assert result == {"FINISHED"}
# IFC should contain alignment segments
segments = ifc_file.by_type("IfcAlignmentSegment")
assert len(segments) >= 2
# Alignment should still exist
alignments = ifc_file.by_type("IfcAlignment")
assert len(alignments) == 1
# Horizontal layout should exist
horizontals = ifc_file.by_type("IfcAlignmentHorizontal")
assert len(horizontals) == 1
def test_create_alignment_with_curve_creates_arc_segment(self):
"""3-PI alignment with radius on middle PI creates LINE + ARC + LINE."""
alignment, alignment_obj = create_empty_alignment()
ifc_file = tool.Ifc.get()
props = get_alignment_props()
add_pis_to_props([(0, 0, 0), (500, 0, 300), (1000, 200, 0)])
result = bpy.ops.civil.create_alignment_by_pi()
assert result == {"FINISHED"}
# Check segment design parameter types
segments = ifc_file.by_type("IfcAlignmentSegment")
segment_types = []
for seg in segments:
dp = seg.DesignParameters
if dp and hasattr(dp, "PredefinedType"):
segment_types.append(dp.PredefinedType)
# Should have at least LINE and CIRCULARARC
assert "LINE" in segment_types
assert "CIRCULARARC" in segment_types
def test_create_alignment_produces_blender_objects(self):
"""After creation, Blender scene should contain alignment objects."""
alignment, alignment_obj = create_empty_alignment()
props = get_alignment_props()
add_pis_to_props([(0, 0, 0), (500, 0, 0), (1000, 200, 0)])
bpy.ops.civil.create_alignment_by_pi()
# Should have at least the alignment object in the scene
alignment_objects = [
obj for obj in bpy.data.objects
if tool.Ifc.get_entity(obj) and tool.Ifc.get_entity(obj).is_a("IfcAlignment")
]
assert len(alignment_objects) >= 1
def test_create_straight_alignment_two_pis(self):
"""Minimal alignment: 2 PIs producing a single tangent."""
alignment, alignment_obj = create_empty_alignment()
ifc_file = tool.Ifc.get()
props = get_alignment_props()
add_pis_to_props([(0, 0, 0), (1000, 0, 0)])
result = bpy.ops.civil.create_alignment_by_pi()
assert result == {"FINISHED"}
segments = ifc_file.by_type("IfcAlignmentSegment")
assert len(segments) >= 1
@requires_geometry_engine
class TestEndToEndAlignmentCreation(NewIfc4X3):
"""Full workflow: create alignment, add PIs, create IFC, validate."""
def test_basic_three_pi_alignment_workflow(self):
"""Scenario 1: Create a basic 3-PI alignment end-to-end."""
alignment, alignment_obj = create_empty_alignment("E2E Test Alignment")
ifc_file = tool.Ifc.get()
props = get_alignment_props()
# Add 3 PIs: straight segment then angled
add_pis_to_props([(0, 0, 0), (500, 0, 0), (1000, 200, 0)])
assert len(props.pis) == 3
# Create alignment
result = bpy.ops.civil.create_alignment_by_pi()
assert result == {"FINISHED"}
# Validate IFC entities
alignments = ifc_file.by_type("IfcAlignment")
assert len(alignments) == 1
assert alignments[0].Name == "E2E Test Alignment"
horizontals = ifc_file.by_type("IfcAlignmentHorizontal")
assert len(horizontals) == 1
segments = ifc_file.by_type("IfcAlignmentSegment")
# At minimum: tangent + tangent (+ zero-length terminator possibly)
assert len(segments) >= 2
# All segment design params should be IfcAlignmentHorizontalSegment
for seg in segments:
dp = seg.DesignParameters
if dp:
assert dp.is_a("IfcAlignmentHorizontalSegment")
def test_three_pi_with_curve_workflow(self):
"""Scenario 2: 3-PI alignment with curve produces correct IFC segments."""
alignment, alignment_obj = create_empty_alignment("Curved Alignment")
ifc_file = tool.Ifc.get()
props = get_alignment_props()
# PI with 300m radius on middle point
add_pis_to_props([(0, 0, 0), (500, 0, 300), (1000, 500, 0)])
bpy.ops.civil.create_alignment_by_pi()
segments = ifc_file.by_type("IfcAlignmentSegment")
predefined_types = set()
for seg in segments:
dp = seg.DesignParameters
if dp and hasattr(dp, "PredefinedType"):
predefined_types.add(dp.PredefinedType)
assert "LINE" in predefined_types
assert "CIRCULARARC" in predefined_types
def test_five_pi_complex_alignment(self):
"""Scenario 3: 5-PI alignment with multiple curves."""
alignment, alignment_obj = create_empty_alignment("Complex Alignment")
ifc_file = tool.Ifc.get()
props = get_alignment_props()
add_pis_to_props([
(0, 0, 0),
(300, 0, 200),
(600, 300, 150),
(900, 300, 250),
(1200, 0, 0),
])
result = bpy.ops.civil.create_alignment_by_pi()
assert result == {"FINISHED"}
segments = ifc_file.by_type("IfcAlignmentSegment")
# 5 PIs with 3 interior curves → many segments
assert len(segments) >= 4
def test_add_remove_pi_cycle(self):
"""Scenario 4: Add/remove PIs cycle - props stay consistent."""
props = get_alignment_props()
bpy.ops.civil.add_pi()
bpy.ops.civil.add_pi()
bpy.ops.civil.add_pi()
assert len(props.pis) == 3
# Remove middle PI
props.display_rows.clear()
props.active_pi_index = 1
bpy.ops.civil.remove_pi()
assert len(props.pis) == 2
# Add two more
bpy.ops.civil.add_pi()
bpy.ops.civil.add_pi()
assert len(props.pis) == 4
# Clear all
bpy.ops.civil.clear_pis()
assert len(props.pis) == 0
assert len(props.display_rows) == 0
@requires_geometry_engine
class TestEndToEndIfcRoundtrip(NewIfc4X3):
"""IFC save/reload roundtrip validation."""
def test_alignment_survives_ifc_roundtrip(self):
"""Create alignment, save to temp file, reload, verify entities."""
import tempfile
import os
alignment, alignment_obj = create_empty_alignment("Roundtrip Test")
ifc_file = tool.Ifc.get()
props = get_alignment_props()
add_pis_to_props([(0, 0, 0), (500, 0, 300), (1000, 200, 0)])
bpy.ops.civil.create_alignment_by_pi()
# Count entities before save
alignment_count = len(ifc_file.by_type("IfcAlignment"))
horizontal_count = len(ifc_file.by_type("IfcAlignmentHorizontal"))
segment_count = len(ifc_file.by_type("IfcAlignmentSegment"))
assert alignment_count == 1
assert horizontal_count == 1
assert segment_count >= 2
# Save to temp file
temp_path = os.path.join(tempfile.gettempdir(), "alignment_roundtrip_test.ifc")
ifc_file.write(temp_path)
# Reload
reloaded = ifcopenshell.open(temp_path)
# Verify entity counts match
assert len(reloaded.by_type("IfcAlignment")) == alignment_count
assert len(reloaded.by_type("IfcAlignmentHorizontal")) == horizontal_count
assert len(reloaded.by_type("IfcAlignmentSegment")) == segment_count
# Verify alignment name survived
assert reloaded.by_type("IfcAlignment")[0].Name == "Roundtrip Test"
# Cleanup
os.unlink(temp_path)
# Station formatting is tool-layer now (tool.Alignment.format_station wrapping
# ifcopenshell.util.alignment.station_as_string) — see TestFormatStation in
# test/tool/test_alignment.py.
@requires_geometry_engine
class TestImportAlignmentCsv(NewIfc4X3):
"""bim.import_alignment_csv — the single, merged CSV import path.
CSV rows use full X,Y,R (or D,Z,L) triples: the first and last R/L values
are placeholders per the API's create_from_csv contract.
"""
def _write_csv(self, tmp_path, rows):
path = tmp_path / "alignment.csv"
path.write_text("\n".join(rows) + "\n", encoding="utf-8")
return str(path)
def test_import_sets_active_alignment_and_builds_hierarchy(self, tmp_path):
filepath = self._write_csv(tmp_path, ["0,0,0,1000,0,300,2000,800,0"])
result = bpy.ops.bim.import_alignment_csv("EXEC_DEFAULT", filepath=filepath)
assert result == {"FINISHED"}
props = get_alignment_props()
assert props.active_alignment_id != 0
alignment = tool.Ifc.get().by_id(props.active_alignment_id)
assert alignment.is_a("IfcAlignment")
assert tool.Ifc.get_object(alignment) is not None
def test_import_with_vertical_row_creates_vertical_layout(self, tmp_path):
filepath = self._write_csv(
tmp_path,
[
"0,0,0,1000,0,300,2000,800,0",
"0,100,0,500,110,200,1000,105,0",
],
)
result = bpy.ops.bim.import_alignment_csv("EXEC_DEFAULT", filepath=filepath)
assert result == {"FINISHED"}
props = get_alignment_props()
alignment = tool.Ifc.get().by_id(props.active_alignment_id)
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"
+10
View File
@@ -270,6 +270,16 @@ def voider():
prophet.verify()
# Saikei Civil modules.
@pytest.fixture
def alignment():
prophet = Prophecy(bonsai.core.tool.Alignment)
yield prophet
prophet.verify()
def flatten(iterable):
for item in iterable:
if isinstance(item, (list, tuple)):
+229
View File
@@ -0,0 +1,229 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 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 pytest
import bonsai.core.alignment as subject
from test.core.bootstrap import alignment, ifc
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
class FakeIfcEntity(dict):
"""A JSON-serializable stand-in for an IFC entity.
Inherits from dict so json.dumps can serialize it when it appears as an
argument to Prophecy-tracked tool methods. The ifc_class key drives
is_a(), and the name key drives the Name property.
"""
def is_a(self, ifc_class: str) -> bool:
return self.get("ifc_class") == ifc_class
@property
def Name(self) -> str:
return self.get("name", "Test Entity")
class FakeIfcFile:
"""Minimal stand-in for an open IFC file."""
def __init__(self, entity=None, not_found: bool = False):
self._entity = entity
self._not_found = not_found
def by_id(self, entity_id: int):
if self._not_found:
raise RuntimeError(f"Could not find #{entity_id}")
return self._entity
def make_alignment_entity(name: str = "Test Alignment") -> FakeIfcEntity:
return FakeIfcEntity({"ifc_class": "IfcAlignment", "name": name})
def make_non_alignment_entity(name: str = "Wall") -> FakeIfcEntity:
return FakeIfcEntity({"ifc_class": "IfcWall", "name": name})
# ---------------------------------------------------------------------------
# enter_pi_edit_mode
# ---------------------------------------------------------------------------
class TestEnterPiEditMode:
def test_raises_when_no_ifc_file_loaded(self, ifc, alignment):
ifc.get().should_be_called().will_return(None)
with pytest.raises(ValueError, match="No IFC file loaded"):
subject.enter_pi_edit_mode(ifc, alignment, alignment_id=1)
def test_raises_when_alignment_not_found(self, ifc, alignment):
ifc.get().should_be_called().will_return(FakeIfcFile(not_found=True))
with pytest.raises(ValueError, match="not found"):
subject.enter_pi_edit_mode(ifc, alignment, alignment_id=1)
def test_raises_when_entity_is_not_an_alignment(self, ifc, alignment):
entity = make_non_alignment_entity()
ifc.get().should_be_called().will_return(FakeIfcFile(entity=entity))
with pytest.raises(ValueError, match="not an IfcAlignment"):
subject.enter_pi_edit_mode(ifc, alignment, alignment_id=1)
def test_raises_when_alignment_has_no_horizontal_layout(self, ifc, alignment):
entity = make_alignment_entity()
ifc.get().should_be_called().will_return(FakeIfcFile(entity=entity))
alignment.get_horizontal_layout(entity).should_be_called().will_return(None)
with pytest.raises(ValueError, match="no horizontal layout"):
subject.enter_pi_edit_mode(ifc, alignment, alignment_id=1)
def test_raises_when_alignment_has_no_real_segments(self, ifc, alignment):
entity = make_alignment_entity()
ifc.get().should_be_called().will_return(FakeIfcFile(entity=entity))
alignment.get_horizontal_layout(entity).should_be_called().will_return("h_layout")
alignment.layout_has_real_segments("h_layout").should_be_called().will_return(False)
with pytest.raises(ValueError, match="no editable segments"):
subject.enter_pi_edit_mode(ifc, alignment, alignment_id=1)
def test_raises_when_back_calculated_pis_fewer_than_two(self, ifc, alignment):
entity = make_alignment_entity()
ifc.get().should_be_called().will_return(FakeIfcFile(entity=entity))
alignment.get_horizontal_layout(entity).should_be_called().will_return("h_layout")
alignment.layout_has_real_segments("h_layout").should_be_called().will_return(True)
alignment.back_calculate_pis_from_alignment(entity).should_be_called().will_return([(0.0, 0.0)])
with pytest.raises(ValueError, match="at least 2 PIs"):
subject.enter_pi_edit_mode(ifc, alignment, alignment_id=1)
def test_returns_empties_for_valid_alignment(self, ifc, alignment):
entity = make_alignment_entity()
pis = [(0.0, 0.0), (100.0, 0.0), (200.0, 50.0)]
empties = ["empty_0", "empty_1", "empty_2"]
ifc.get().should_be_called().will_return(FakeIfcFile(entity=entity))
alignment.get_horizontal_layout(entity).should_be_called().will_return("h_layout")
alignment.layout_has_real_segments("h_layout").should_be_called().will_return(True)
alignment.back_calculate_pis_from_alignment(entity).should_be_called().will_return(pis)
alignment.create_pi_edit_empties(entity, pis).should_be_called().will_return(empties)
result = subject.enter_pi_edit_mode(ifc, alignment, alignment_id=1)
assert result == empties
# ---------------------------------------------------------------------------
# exit_pi_edit_mode
# ---------------------------------------------------------------------------
class TestExitPiEditMode:
def test_cleans_up_and_returns_true_when_no_ifc_file(self, ifc, alignment):
ifc.get().should_be_called().will_return(None)
alignment.remove_pi_edit_empties(1).should_be_called()
result = subject.exit_pi_edit_mode(ifc, alignment, alignment_id=1, apply=True)
assert result is True
def test_cleans_up_and_returns_true_when_alignment_deleted(self, ifc, alignment):
ifc.get().should_be_called().will_return(FakeIfcFile(not_found=True))
alignment.remove_pi_edit_empties(1).should_be_called()
result = subject.exit_pi_edit_mode(ifc, alignment, alignment_id=1, apply=True)
assert result is True
def test_removes_empties_and_returns_true_when_apply_is_false(self, ifc, alignment):
entity = make_alignment_entity()
ifc.get().should_be_called().will_return(FakeIfcFile(entity=entity))
alignment.remove_pi_edit_empties(1).should_be_called()
result = subject.exit_pi_edit_mode(ifc, alignment, alignment_id=1, apply=False)
assert result is True
def test_raises_when_fewer_than_two_pis_collected_on_apply(self, ifc, alignment):
entity = make_alignment_entity()
ifc.get().should_be_called().will_return(FakeIfcFile(entity=entity))
alignment.collect_pis_from_empties(1).should_be_called().will_return(([(0.0, 0.0)], [0.0]))
with pytest.raises(ValueError, match="At least 2 PIs"):
subject.exit_pi_edit_mode(ifc, alignment, alignment_id=1, apply=True)
def test_raises_when_no_horizontal_layout_on_apply(self, ifc, alignment):
entity = make_alignment_entity()
hpoints = [(0.0, 0.0), (100.0, 0.0)]
radii = [0.0, 0.0]
ifc.get().should_be_called().will_return(FakeIfcFile(entity=entity))
alignment.collect_pis_from_empties(1).should_be_called().will_return((hpoints, radii))
alignment.get_horizontal_layout(entity).should_be_called().will_return(None)
with pytest.raises(ValueError, match="no horizontal layout"):
subject.exit_pi_edit_mode(ifc, alignment, alignment_id=1, apply=True)
def test_applies_new_pis_without_layout_obj_and_returns_true(self, ifc, alignment):
entity = make_alignment_entity()
hpoints = [(0.0, 0.0), (100.0, 0.0)]
radii = [0.0, 0.0]
ifc.get().should_be_called().will_return(FakeIfcFile(entity=entity))
alignment.collect_pis_from_empties(1).should_be_called().will_return((hpoints, radii))
alignment.get_horizontal_layout(entity).should_be_called().will_return("h_layout")
alignment.remove_pi_edit_empties(1).should_be_called()
alignment.remove_layout_segment_objects("h_layout").should_be_called()
alignment.clear_layout_segments("h_layout").should_be_called()
alignment.layout_by_pi_method("h_layout", hpoints, radii).should_be_called()
ifc.get_object("h_layout").should_be_called().will_return(None)
result = subject.exit_pi_edit_mode(ifc, alignment, alignment_id=1, apply=True)
assert result is True
def test_creates_segment_objects_when_layout_obj_exists(self, ifc, alignment):
entity = make_alignment_entity()
hpoints = [(0.0, 0.0), (100.0, 0.0)]
radii = [0.0, 0.0]
ifc.get().should_be_called().will_return(FakeIfcFile(entity=entity))
alignment.collect_pis_from_empties(1).should_be_called().will_return((hpoints, radii))
alignment.get_horizontal_layout(entity).should_be_called().will_return("h_layout")
alignment.remove_pi_edit_empties(1).should_be_called()
alignment.remove_layout_segment_objects("h_layout").should_be_called()
alignment.clear_layout_segments("h_layout").should_be_called()
alignment.layout_by_pi_method("h_layout", hpoints, radii).should_be_called()
ifc.get_object("h_layout").should_be_called().will_return("layout_obj")
alignment.create_objects_for_layout_segments("h_layout", "layout_obj").should_be_called()
result = subject.exit_pi_edit_mode(ifc, alignment, alignment_id=1, apply=True)
assert result is True
# ---------------------------------------------------------------------------
# import_alignment_csv
# ---------------------------------------------------------------------------
class TestImportAlignmentCsv:
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.import_alignment_csv(ifc, alignment, filepath="pis.csv")
def test_imports_and_builds_hierarchy_for_parent_only(self, ifc, alignment):
ifc.get().should_be_called().will_return("ifc_file")
alignment.create_alignment_from_csv("pis.csv").should_be_called().will_return("parent")
alignment.create_hierarchy_for_alignment("parent").should_be_called()
alignment.get_child_alignments("parent").should_be_called().will_return([])
alignment.create_objects_for_referents("parent").should_be_called()
result = subject.import_alignment_csv(ifc, alignment, filepath="pis.csv")
assert result == "parent"
def test_builds_hierarchy_for_each_aggregated_child(self, ifc, alignment):
ifc.get().should_be_called().will_return("ifc_file")
alignment.create_alignment_from_csv("pis.csv").should_be_called().will_return("parent")
alignment.create_hierarchy_for_alignment("parent").should_be_called()
alignment.get_child_alignments("parent").should_be_called().will_return(["child_a", "child_b"])
alignment.create_hierarchy_for_alignment("child_a").should_be_called()
alignment.create_hierarchy_for_alignment("child_b").should_be_called()
alignment.create_objects_for_referents("parent").should_be_called()
result = subject.import_alignment_csv(ifc, alignment, filepath="pis.csv")
assert result == "parent"
File diff suppressed because it is too large Load Diff
@@ -56,6 +56,7 @@ from .add_vertical_layout import add_vertical_layout
from .add_zero_length_segment import add_zero_length_segment
from .create import create
from .create_as_offset_curve import create_as_offset_curve
from .clear_layout_segments import clear_layout_segments
from .create_as_polyline import create_as_polyline
from .create_by_pi_method import create_by_pi_method
from .create_from_csv import create_from_csv
@@ -92,6 +93,7 @@ from .layout_vertical_alignment_by_pi_method import (
layout_vertical_alignment_by_pi_method,
)
from .name_segments import name_segments
from .segment_vertices import segment_vertices
from .update_alignment_parameter_segment_tags import update_alignment_parameter_segment_tags
from .update_end_point import update_end_point
from .update_fallback_position import update_fallback_position
@@ -103,6 +105,7 @@ __all__ = [
"add_stationing_referent",
"add_vertical_layout",
"add_zero_length_segment",
"clear_layout_segments",
"create",
"create_as_offset_curve",
"create_as_polyline",
@@ -137,6 +140,7 @@ __all__ = [
"layout_horizontal_alignment_by_pi_method",
"layout_vertical_alignment_by_pi_method",
"name_segments",
"segment_vertices",
"register_referent_name_callback",
"update_alignment_parameter_segment_tags",
"update_end_point",
@@ -142,7 +142,7 @@ def _create_geometric_representation(file: ifcopenshell.file, alignment: entity_
segmented_reference_curve = file.createIfcSegmentedReferenceCurve(
Segments=[], BaseCurve=gradient_curve, SelfIntersect=False
)
representation = file.creatIfcShapeRepresentation(
representation = file.createIfcShapeRepresentation(
ContextOfItems=axis_geom_subcontext,
RepresentationIdentifier="Axis",
RepresentationType="Curve3D",
@@ -180,7 +180,7 @@ def _vertical_label(prev_segment: entity_instance, segment: entity_instance) ->
"CONSTANTGRADIENT": {
"CIRCULARARC": "xx",
"CLOTHOID": "xx",
"CONSTANTGRADIENT": "P.V.I",
"CONSTANTGRADIENT": "P.V.I.",
"PARABOLICARC": "P.V.C.",
},
"PARABOLICARC": {
@@ -0,0 +1,220 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell 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 Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import ifcopenshell
import ifcopenshell.api.alignment
import ifcopenshell.api.nest
import ifcopenshell.util.element
from ifcopenshell import entity_instance
def _is_zero_length_segment(segment: entity_instance) -> bool:
"""Check if segment is a zero-length terminator."""
dp = segment.DesignParameters
if dp.is_a("IfcAlignmentHorizontalSegment"):
return dp.SegmentLength == 0.0
elif dp.is_a("IfcAlignmentVerticalSegment"):
return dp.HorizontalLength == 0.0
elif dp.is_a("IfcAlignmentCantSegment"):
return dp.HorizontalLength == 0.0
return False
def clear_layout_segments(file: ifcopenshell.file, layout: entity_instance) -> None:
"""
Clear all segments from a layout while preserving the layout entity
and zero-length terminator.
This function removes:
- All real (non-zero-length) IfcAlignmentSegment entities from the layout
- Their associated IfcCurveSegment entities from the geometric representation
- Referents positioned on the removed segments
It preserves:
- The layout entity (IfcAlignmentHorizontal, IfcAlignmentVertical, or IfcAlignmentCant)
- The zero-length terminator segment (required by IFC spec)
- The alignment's main stationing referent
:param file: The IFC file
:param layout: An IfcAlignmentHorizontal, IfcAlignmentVertical, or IfcAlignmentCant
Example:
.. code:: python
alignment = model.by_type("IfcAlignment")[0]
h_layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
# Clear existing segments
ifcopenshell.api.alignment.clear_layout_segments(model, h_layout)
# Add new segments with updated PI positions
ifcopenshell.api.alignment.layout_horizontal_alignment_by_pi_method(
model, h_layout, new_hpoints, new_radii
)
"""
expected_types = ["IfcAlignmentHorizontal", "IfcAlignmentVertical", "IfcAlignmentCant"]
if layout.is_a() not in expected_types:
raise TypeError(f"Expected entity type to be one of {expected_types}, instead received {layout.is_a()}")
# Get the geometric curve for this layout
curve = ifcopenshell.api.alignment.get_layout_curve(layout)
# Get all segments from the layout
segments = ifcopenshell.api.alignment.get_layout_segments(layout)
if not segments:
return # Nothing to clear
# Identify segments to remove (all except zero-length terminator)
zero_length_segment = None
segments_to_remove = []
for segment in segments:
if _is_zero_length_segment(segment):
zero_length_segment = segment
else:
segments_to_remove.append(segment)
if not segments_to_remove:
return # Only zero-length terminator exists, nothing to clear
# Collect curve segments to remove before removing alignment segments
# (we need the nesting relationship to find mapped segments)
curve_segments_to_remove = []
for segment in segments_to_remove:
try:
mapped = ifcopenshell.api.alignment.get_mapped_segments(segment)
for cs in mapped:
if cs is not None:
curve_segments_to_remove.append(cs)
except (IndexError, AttributeError):
# Segment might not have curve representation yet
pass
# Remove referents positioned on segments being removed
for segment in segments_to_remove:
# Check for referents positioned relative to this segment
if hasattr(segment, "PositionedRelativeTo") and segment.PositionedRelativeTo:
for rel_pos in segment.PositionedRelativeTo:
referent = rel_pos.RelatingPositioningElement
if referent and referent.is_a("IfcReferent"):
# Remove the referent
ifcopenshell.api.run("root.remove_product", file, product=referent)
# Remove segments from nesting relationship
ifcopenshell.api.nest.unassign_object(file, related_objects=segments_to_remove)
# Remove segment entities
for segment in segments_to_remove:
# Remove design parameters
dp = segment.DesignParameters
if dp:
# Remove StartPoint if it exists
if hasattr(dp, "StartPoint") and dp.StartPoint:
file.remove(dp.StartPoint)
file.remove(dp)
# Remove the segment entity itself
file.remove(segment)
# Clear curve segments from the geometric representation
if curve and curve.Segments:
# Keep only the zero-length curve segment (last one)
if ifcopenshell.api.alignment.has_zero_length_segment(curve):
zero_length_curve_seg = curve.Segments[-1]
# Update curve to only contain zero-length segment
curve.Segments = (zero_length_curve_seg,)
else:
# No zero-length segment in curve, clear all
curve.Segments = ()
# Clean up removed curve segment entities
for cs in curve_segments_to_remove:
try:
# Remove the curve segment's parent curve and placement
if hasattr(cs, "ParentCurve") and cs.ParentCurve:
parent_curve = cs.ParentCurve
# Check if parent curve is used elsewhere
if file.get_total_inverses(parent_curve) <= 1:
# Remove placement if exists
if hasattr(parent_curve, "Position") and parent_curve.Position:
pos = parent_curve.Position
if hasattr(pos, "Location") and pos.Location:
if file.get_total_inverses(pos.Location) <= 1:
file.remove(pos.Location)
if hasattr(pos, "RefDirection") and pos.RefDirection:
if file.get_total_inverses(pos.RefDirection) <= 1:
file.remove(pos.RefDirection)
if file.get_total_inverses(pos) <= 1:
file.remove(pos)
file.remove(parent_curve)
# Remove placement on curve segment
if hasattr(cs, "Placement") and cs.Placement:
placement = cs.Placement
if hasattr(placement, "Location") and placement.Location:
if file.get_total_inverses(placement.Location) <= 1:
file.remove(placement.Location)
if hasattr(placement, "RefDirection") and placement.RefDirection:
if file.get_total_inverses(placement.RefDirection) <= 1:
file.remove(placement.RefDirection)
if file.get_total_inverses(placement) <= 1:
file.remove(placement)
# Remove the curve segment itself
file.remove(cs)
except Exception:
# Entity may have already been removed
pass
# Reset zero-length terminator to origin position
if zero_length_segment:
dp = zero_length_segment.DesignParameters
if dp.is_a("IfcAlignmentHorizontalSegment"):
# Reset StartPoint to origin
if dp.StartPoint:
dp.StartPoint.Coordinates = (0.0, 0.0)
dp.StartDirection = 0.0
elif dp.is_a("IfcAlignmentVerticalSegment"):
dp.StartDistAlong = 0.0
dp.StartHeight = 0.0
dp.StartGradient = 0.0
dp.EndGradient = 0.0
elif dp.is_a("IfcAlignmentCantSegment"):
dp.StartDistAlong = 0.0
dp.StartCantLeft = 0.0
dp.StartCantRight = 0.0
# Update the zero-length segment's referent
if hasattr(zero_length_segment, "PositionedRelativeTo") and zero_length_segment.PositionedRelativeTo:
for rel_pos in zero_length_segment.PositionedRelativeTo:
referent = rel_pos.RelatingPositioningElement
if referent and referent.is_a("IfcReferent"):
# Update referent position to origin
if hasattr(referent, "ObjectPlacement") and referent.ObjectPlacement:
placement = referent.ObjectPlacement
if hasattr(placement, "RelativePlacement") and placement.RelativePlacement:
rel_place = placement.RelativePlacement
if hasattr(rel_place, "Location") and rel_place.Location:
if hasattr(rel_place.Location, "DistanceAlong"):
rel_place.Location.DistanceAlong.wrappedValue = 0.0
if hasattr(placement, "CartesianPosition") and placement.CartesianPosition:
cart_pos = placement.CartesianPosition
if hasattr(cart_pos, "Location") and cart_pos.Location:
cart_pos.Location.Coordinates = (0.0, 0.0, 0.0)
@@ -20,6 +20,8 @@ from collections.abc import Sequence
import ifcopenshell
import ifcopenshell.api.aggregate
import ifcopenshell.api.alignment
import ifcopenshell.util.alignment
from ifcopenshell import entity_instance
from ifcopenshell.api.alignment._create_offset_curve_representation import (
_create_offset_curve_representation,
@@ -50,6 +52,10 @@ def create_as_offset_curve(
_create_offset_curve_representation(file, alignment, offsets)
# establish the alignment's stationing scheme, same as create() does for start_station
referent_name = ifcopenshell.util.alignment.station_as_string(file, start_station)
ifcopenshell.api.alignment.add_stationing_referent(file, referent_name, alignment, 0.0, start_station)
# IFC 4.1.4.1.1 Alignment Aggregation To Project
project = file.by_type("IfcProject")[0]
if project:
@@ -57,4 +57,4 @@ def get_mapped_segments(layout_segment: entity_instance) -> Sequence[entity_inst
if segment_count == 1:
return (curve.Segments[index - segment_count], None)
else:
return (curve.Segments[index - segment_count], curve.Segments[index])
return (curve.Segments[index - segment_count], curve.Segments[index - 1])
@@ -39,7 +39,7 @@ def get_stationing_nest(file: ifcopenshell.file, alignment: entity_instance) ->
for nest in alignment.IsNestedBy:
for related_object in nest.RelatedObjects:
if related_object.is_a("IfcReferent"):
if related_object.is_a("IfcReferent") and related_object.PredefinedType == "STATION":
return nest
return None
@@ -0,0 +1,109 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell 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 Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import numpy as np
import ifcopenshell
import ifcopenshell.util.unit
from ifcopenshell import entity_instance, ifcopenshell_wrapper
def _intersect_lines(p1, d1, p2, d2):
x1, y1 = p1
dx1, dy1 = d1
x2, y2 = p2
dx2, dy2 = d2
det = dx1 * dy2 - dy1 * dx2
if abs(det) < 1e-12:
return None # lines are parallel
t = ((x2 - x1) * dy2 - (y2 - y1) * dx2) / det
x = x1 + t * dx1
y = y1 + t * dy1
return (x, y)
def segment_vertices(file: ifcopenshell.file, segment: entity_instance):
"""
Generates segment vertices. Segment vertices are at the start and end as well as the points where the tangents
at the start and end of the segment intersect (the TI point) and where lines
normal (perpendicular) to the start and end of the segment intersect (NI).
TI and NI are None if intersection points do not exist, such as in the case of a line.
:param curve_segment: A curve segment of type IfcAlignmentSegment or IfcCurveSegment
:return: tuples for Start, End, TI, NI
"""
supported_segment_types = ["IFCALIGNMENTSEGMENT", "IFCCURVESEGMENT"]
segment_type = segment.is_a().upper()
if not segment_type in supported_segment_types:
raise NotImplementedError(
f"Expected entity type to be one of {[_ for _ in supported_segment_types]}, got '{segment_type}"
)
# in the general case an IfcAlignmentSegment for a Helmert transition curve
# maps into two IfcCurveSegment geometric representations.
# For that reason, we have a start_segment_curve and and end_segment_curve.
# In the more common case, there is only one IfcCurveSegment geometric representation
# and start_segment_curve and end_segment_curve are equal
if segment_type == "IFCALIGNMENTSEGMENT":
segments = ifcopenshell.api.alignment.get_mapped_segments(segment)
start_segment_curve = segments[0]
end_segment_curve = start_segment_curve if segments[1] is None else segments[1]
else:
start_segment_curve = segment
end_segment_curve = segment
settings = ifcopenshell.geom.settings()
# get parameters at start of start_segment_curve
segment_fn = ifcopenshell_wrapper.map_shape(settings, start_segment_curve)
segment_evaluator = ifcopenshell_wrapper.function_item_evaluator(settings, segment_fn)
s = segment_evaluator.evaluate(segment_fn.start())
start = np.array(s)
sx = float(start[0, 3])
sy = float(start[1, 3])
sdx = float(start[0, 0])
sdy = float(start[1, 0])
# get parameters at end of end_segment_curve
segment_fn = ifcopenshell_wrapper.map_shape(settings, end_segment_curve)
segment_evaluator = ifcopenshell_wrapper.function_item_evaluator(settings, segment_fn)
e = segment_evaluator.evaluate(segment_fn.end())
end = np.array(e)
ex = float(end[0, 3])
ey = float(end[1, 3])
edx = float(end[0, 0])
edy = float(end[1, 0])
ti = _intersect_lines((sx, sy), (sdx, sdy), (ex, ey), (edx, edy)) # tangent intersection
sdx = float(start[0, 1])
sdy = float(start[1, 1])
edx = float(end[0, 1])
edy = float(end[1, 1])
ni = _intersect_lines((sx, sy), (sdx, sdy), (ex, ey), (edx, edy)) # normal intersection
return (sx, sy), (ex, ey), ti, ni
@@ -23,6 +23,7 @@ import pytest
import ifcopenshell.api.alignment
import ifcopenshell.api.unit
import ifcopenshell.util
import ifcopenshell.util.element
import ifcopenshell.util.unit
try:
@@ -139,8 +140,16 @@ def test_create_as_offset_curve():
),
]
offset_alignment = ifcopenshell.api.alignment.create_as_offset_curve(file, "A2", offsets)
offset_alignment = ifcopenshell.api.alignment.create_as_offset_curve(file, "A2", offsets, start_station=1000.0)
assert offset_alignment.is_a("IfcAlignment")
curve = ifcopenshell.api.alignment.get_curve(offset_alignment)
assert curve.is_a("IfcOffsetCurveByDistances")
assert curve.BasisCurve == basis_curve
# start_station must be honored with a stationing referent, same as every other create path
referent_nest = ifcopenshell.api.alignment.get_stationing_nest(file, offset_alignment)
assert referent_nest is not None
referent = referent_nest.RelatedObjects[0]
assert referent.is_a("IfcReferent")
assert referent.PredefinedType == "STATION"
assert ifcopenshell.util.element.get_pset(referent, name="Pset_Stationing", prop="Station") == 1000.0
@@ -0,0 +1,90 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell 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 Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import ifcopenshell
import ifcopenshell.api.alignment
import ifcopenshell.api.unit
import ifcopenshell.util.element
def test_create_as_offset_curve_honors_start_station_with_a_stationing_referent():
# create_as_offset_curve() accepted start_station but silently dropped it -- unlike every
# other create path (create(), create_by_pi_method()), it never established the alignment's
# stationing referent.
file = ifcopenshell.file(schema="IFC4X3")
project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
length = ifcopenshell.api.unit.add_si_unit(file, unit_type="LENGTHUNIT")
ifcopenshell.api.unit.assign_unit(file, units=[length])
# the basis alignment only needs a real (if empty) Axis representation for
# IfcPointByDistanceExpression.BasisCurve to reference -- no real segments needed.
basis_alignment = ifcopenshell.api.alignment.create(file, "Basis", include_geometry=True)
basis_curve = ifcopenshell.api.alignment.get_curve(basis_alignment)
assert basis_curve.is_a("IfcCompositeCurve")
offsets = [
file.createIfcPointByDistanceExpression(
DistanceAlong=file.createIfcLengthMeasure(0.0), OffsetLateral=10.0, BasisCurve=basis_curve
),
]
offset_alignment = ifcopenshell.api.alignment.create_as_offset_curve(file, "Offset", offsets, start_station=1000.0)
curve = ifcopenshell.api.alignment.get_curve(offset_alignment)
assert curve.is_a("IfcOffsetCurveByDistances")
# the referent must be created even though IfcOffsetCurveByDistances isn't a curve type that
# add_stationing_referent() can build a linear placement on top of -- it falls back to a plain
# IfcLocalPlacement, same as when there's no representation at all.
referent_nest = ifcopenshell.api.alignment.get_stationing_nest(file, offset_alignment)
assert referent_nest is not None
assert len(referent_nest.RelatedObjects) == 1
referent = referent_nest.RelatedObjects[0]
assert referent.is_a("IfcReferent")
assert referent.PredefinedType == "STATION"
assert referent.ObjectPlacement is not None
assert referent.ObjectPlacement.is_a("IfcLocalPlacement")
assert ifcopenshell.util.element.get_pset(referent, name="Pset_Stationing", prop="Station") == 1000.0
def test_create_as_offset_curve_default_start_station_is_zero():
file = ifcopenshell.file(schema="IFC4X3")
project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
length = ifcopenshell.api.unit.add_si_unit(file, unit_type="LENGTHUNIT")
ifcopenshell.api.unit.assign_unit(file, units=[length])
basis_alignment = ifcopenshell.api.alignment.create(file, "Basis", include_geometry=True)
basis_curve = ifcopenshell.api.alignment.get_curve(basis_alignment)
offsets = [
file.createIfcPointByDistanceExpression(
DistanceAlong=file.createIfcLengthMeasure(0.0), OffsetLateral=10.0, BasisCurve=basis_curve
),
]
offset_alignment = ifcopenshell.api.alignment.create_as_offset_curve(file, "Offset", offsets)
referent_nest = ifcopenshell.api.alignment.get_stationing_nest(file, offset_alignment)
assert referent_nest is not None
referent = referent_nest.RelatedObjects[0]
assert ifcopenshell.util.element.get_pset(referent, name="Pset_Stationing", prop="Station") == 0.0
test_create_as_offset_curve_honors_start_station_with_a_stationing_referent()
test_create_as_offset_curve_default_start_station_is_zero()
@@ -0,0 +1,68 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell 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 Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import ifcopenshell
import ifcopenshell.api.aggregate
import ifcopenshell.api.alignment
import ifcopenshell.api.nest
import ifcopenshell.api.unit
from ifcopenshell.api.alignment._create_geometric_representation import (
_create_geometric_representation,
)
def test_child_alignment_with_vertical_and_cant_gets_a_segmented_reference_curve():
# IFC CT 4.1.4.4.1.2 "Reusing Horizontal Layout": a child IfcAlignment nests its own
# IfcAlignmentVertical and IfcAlignmentCant while reusing the parent's horizontal layout. There
# is no public API to build this today (add_vertical_layout() only ever creates children that
# nest a single IfcAlignmentVertical), so the len(child_layouts) == 2 branch inside
# _create_geometric_representation() -- which builds the child's IfcSegmentedReferenceCurve --
# was unreachable and its `file.creatIfcShapeRepresentation` typo (missing "e") went unnoticed.
# This constructs that scenario directly against the private helper to exercise the fix.
file = ifcopenshell.file(schema="IFC4X3")
project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
length = ifcopenshell.api.unit.add_si_unit(file, unit_type="LENGTHUNIT")
ifcopenshell.api.unit.assign_unit(file, units=[length])
# parent: horizontal + vertical, geometry deferred so _create_geometric_representation is
# invoked exactly once, explicitly, below.
parent_alignment = ifcopenshell.api.alignment.create(file, "Parent", include_vertical=True, include_geometry=False)
# child: vertical + cant, both nested directly to a new child alignment, reusing the parent's
# horizontal -- the CT 4.1.4.4.1.2 shape that has no builder function yet.
child_alignment = file.createIfcAlignment(GlobalId=ifcopenshell.guid.new(), Name="Child of Parent")
child_vertical_layout = file.createIfcAlignmentVertical(GlobalId=ifcopenshell.guid.new())
child_cant_layout = file.createIfcAlignmentCant(GlobalId=ifcopenshell.guid.new(), RailHeadDistance=1.0)
ifcopenshell.api.nest.assign_object(
file, related_objects=[child_vertical_layout, child_cant_layout], relating_object=child_alignment
)
ifcopenshell.api.aggregate.assign_object(file, products=[child_alignment], relating_object=parent_alignment)
# before the fix this raised AttributeError: 'file' object has no attribute
# 'creatIfcShapeRepresentation'
_create_geometric_representation(file, parent_alignment)
curve = ifcopenshell.api.alignment.get_curve(child_alignment)
assert curve is not None
assert curve.is_a("IfcSegmentedReferenceCurve")
assert curve.BaseCurve.is_a("IfcGradientCurve")
assert child_alignment.ObjectPlacement == parent_alignment.ObjectPlacement
test_child_alignment_with_vertical_and_cant_gets_a_segmented_reference_curve()
@@ -0,0 +1,195 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell 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 Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import ifcopenshell
import ifcopenshell.api.alignment
import ifcopenshell.api.context
import ifcopenshell.api.nest
import ifcopenshell.api.unit
import ifcopenshell.guid
def _new_file_with_axis_context():
file = ifcopenshell.file(schema="IFC4X3")
file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
length = ifcopenshell.api.unit.add_si_unit(file, unit_type="LENGTHUNIT")
angle = ifcopenshell.api.unit.add_si_unit(file, unit_type="PLANEANGLEUNIT")
ifcopenshell.api.unit.assign_unit(file, units=[length, angle])
geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model")
ifcopenshell.api.context.add_context(
file,
context_type="Model",
context_identifier="Axis",
target_view="MODEL_VIEW",
parent=geometric_representation_context,
)
return file
def _fake_curve_segment(file, segment_length):
# A structurally-valid but geometrically-meaningless IfcCurveSegment. get_mapped_segments()
# only ever returns these by reference -- it never evaluates them -- so their actual shape
# doesn't matter for testing the index math.
placement = file.createIfcAxis2Placement2D(
Location=file.createIfcCartesianPoint((0.0, 0.0)), RefDirection=file.createIfcDirection((1.0, 0.0))
)
parent_curve = file.createIfcLine(
Pnt=file.createIfcCartesianPoint((0.0, 0.0)),
Dir=file.createIfcVector(Orientation=file.createIfcDirection((1.0, 0.0)), Magnitude=1.0),
)
return file.createIfcCurveSegment(
Transition="DISCONTINUOUS",
Placement=placement,
SegmentStart=file.createIfcLengthMeasure(0.0),
SegmentLength=file.createIfcLengthMeasure(segment_length),
ParentCurve=parent_curve,
)
def test_get_mapped_segments_returns_consecutive_helmert_curve_segments():
# HELMERTCURVE is the one horizontal segment type that maps to two IfcCurveSegment geometric
# representations instead of one. get_mapped_segments() previously returned
# (curve.Segments[index - segment_count], curve.Segments[index]) for the second half, which is
# one position too far -- curve.Segments[index] belongs to whatever segment comes *after* the
# Helmert curve (or is out of range for the last real segment). The fix returns
# curve.Segments[index - 1], the Helmert curve's own second half.
#
# This builds the IFC graph directly instead of going through create_layout_segment(), which
# requires a registered geometry mapping for the schema to compute segment end points --
# get_mapped_segments() itself is pure graph traversal and needs no geometry evaluation.
file = _new_file_with_axis_context()
alignment = ifcopenshell.api.alignment.create(file, "TestAlignment")
layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
curve = ifcopenshell.api.alignment.get_layout_curve(layout)
# curve already has the mandatory zero-length terminal IfcCurveSegment; insert the LINE and
# HELMERTCURVE curve segments in front of it.
line_curve_segment = _fake_curve_segment(file, 100.0)
helmert_curve_segment_a = _fake_curve_segment(file, 50.0)
helmert_curve_segment_b = _fake_curve_segment(file, 50.0)
curve.Segments = (line_curve_segment, helmert_curve_segment_a, helmert_curve_segment_b) + curve.Segments
assert len(curve.Segments) == 4 # LINE, Helmert x2, zero-length terminal
line_design_parameters = file.createIfcAlignmentHorizontalSegment(
StartPoint=file.createIfcCartesianPoint((0.0, 0.0)),
StartDirection=0.0,
StartRadiusOfCurvature=0.0,
EndRadiusOfCurvature=0.0,
SegmentLength=100.0,
PredefinedType="LINE",
)
helmert_design_parameters = file.createIfcAlignmentHorizontalSegment(
StartPoint=file.createIfcCartesianPoint((100.0, 0.0)),
StartDirection=0.0,
StartRadiusOfCurvature=300.0,
EndRadiusOfCurvature=1000.0,
SegmentLength=100.0,
PredefinedType="HELMERTCURVE",
)
line_layout_segment = file.createIfcAlignmentSegment(
GlobalId=ifcopenshell.guid.new(), DesignParameters=line_design_parameters
)
helmert_layout_segment = file.createIfcAlignmentSegment(
GlobalId=ifcopenshell.guid.new(), DesignParameters=helmert_design_parameters
)
# append then swap into place ahead of the mandatory zero-length terminal segment, in order --
# the same two steps _add_segment_to_layout() performs per segment, minus the geometric
# end-point calculation.
ifcopenshell.api.nest.assign_object(file, related_objects=[line_layout_segment], relating_object=layout)
ifcopenshell.api.nest.reorder_nesting(file, line_layout_segment, -1, -1)
ifcopenshell.api.nest.assign_object(file, related_objects=[helmert_layout_segment], relating_object=layout)
ifcopenshell.api.nest.reorder_nesting(file, helmert_layout_segment, -1, -1)
# order is [LINE, HELMERTCURVE, zero-length terminal segment]; the terminal segment is also
# PredefinedType="LINE" (with SegmentLength=0.0)
layout_segments = ifcopenshell.api.alignment.get_layout_segments(layout)
assert [s.DesignParameters.PredefinedType for s in layout_segments] == ["LINE", "HELMERTCURVE", "LINE"]
assert layout_segments[1] == helmert_layout_segment
mapped_segments = ifcopenshell.api.alignment.get_mapped_segments(helmert_layout_segment)
assert len(mapped_segments) == 2
first_half, second_half = mapped_segments
assert first_half is not None
assert second_half is not None
# the two halves must be the two consecutive IfcCurveSegment entities belonging to the Helmert
# curve, identity-checked against curve.Segments -- not, e.g., the LINE segment's curve
# segment and the Helmert's first half (the pre-fix off-by-one).
assert first_half == helmert_curve_segment_a
assert second_half == helmert_curve_segment_b
assert first_half == curve.Segments[1]
assert second_half == curve.Segments[2]
def test_get_mapped_segments_and_segment_vertices_for_helmert_curve():
# End-to-end regression test built the realistic way, via create_layout_segment() -- matching
# every other test in this suite. This requires a registered geometry mapping for the schema
# (ifcopenshell_wrapper.map_shape) to compute each segment's end point while chaining the
# layout together, and again inside segment_vertices() itself.
file = _new_file_with_axis_context()
alignment = ifcopenshell.api.alignment.create(file, "TestAlignment")
layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
line_design_parameters = file.createIfcAlignmentHorizontalSegment(
StartPoint=file.createIfcCartesianPoint((0.0, 0.0)),
StartDirection=0.0,
StartRadiusOfCurvature=0.0,
EndRadiusOfCurvature=0.0,
SegmentLength=100.0,
PredefinedType="LINE",
)
ifcopenshell.api.alignment.create_layout_segment(file, layout, line_design_parameters)
helmert_design_parameters = file.createIfcAlignmentHorizontalSegment(
StartPoint=file.createIfcCartesianPoint((100.0, 0.0)),
StartDirection=0.0,
StartRadiusOfCurvature=300.0,
EndRadiusOfCurvature=1000.0,
SegmentLength=100.0,
PredefinedType="HELMERTCURVE",
)
ifcopenshell.api.alignment.create_layout_segment(file, layout, helmert_design_parameters)
# layout order is [LINE, HELMERTCURVE, zero-length terminal segment]
layout_segments = ifcopenshell.api.alignment.get_layout_segments(layout)
helmert_layout_segment = layout_segments[-2]
assert helmert_layout_segment.DesignParameters.PredefinedType == "HELMERTCURVE"
curve = ifcopenshell.api.alignment.get_layout_curve(layout)
# curve.Segments is [LINE cs(0), Helmert cs(1), Helmert cs(2), zero-length cs(3)]
assert len(curve.Segments) == 4
mapped_segments = ifcopenshell.api.alignment.get_mapped_segments(helmert_layout_segment)
assert len(mapped_segments) == 2
first_half, second_half = mapped_segments
assert first_half is not None
assert second_half is not None
assert first_half == curve.Segments[1]
assert second_half == curve.Segments[2]
# segment_vertices() must not raise for a HELMERTCURVE alignment segment (previously: NameError
# from the `segment[1]` typo)
start, end, ti, ni = ifcopenshell.api.alignment.segment_vertices(file, helmert_layout_segment)
assert start is not None
assert end is not None
test_get_mapped_segments_returns_consecutive_helmert_curve_segments()
test_get_mapped_segments_and_segment_vertices_for_helmert_curve()
@@ -0,0 +1,53 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell 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 Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import ifcopenshell
import ifcopenshell.guid
from ifcopenshell.api.alignment._get_segment_start_point_label import (
_get_segment_start_point_label,
)
def test_vertical_constant_gradient_to_constant_gradient_label_has_trailing_period():
# Every other label in the lookup tables ends with a period (e.g. "P.C.", "P.V.C.",
# "P.V.T."); CONSTANTGRADIENT -> CONSTANTGRADIENT ("P.V.I") was missing its trailing period.
file = ifcopenshell.file(schema="IFC4X3")
dp1 = file.createIfcAlignmentVerticalSegment(
StartDistAlong=0.0,
HorizontalLength=100.0,
StartHeight=0.0,
StartGradient=0.01,
EndGradient=0.01,
PredefinedType="CONSTANTGRADIENT",
)
dp2 = file.createIfcAlignmentVerticalSegment(
StartDistAlong=100.0,
HorizontalLength=100.0,
StartHeight=1.0,
StartGradient=0.02,
EndGradient=0.02,
PredefinedType="CONSTANTGRADIENT",
)
prev_segment = file.createIfcAlignmentSegment(GlobalId=ifcopenshell.guid.new(), DesignParameters=dp1)
segment = file.createIfcAlignmentSegment(GlobalId=ifcopenshell.guid.new(), DesignParameters=dp2)
assert _get_segment_start_point_label(prev_segment, segment) == "P.V.I."
test_vertical_constant_gradient_to_constant_gradient_label_has_trailing_period()
@@ -0,0 +1,119 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell 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 Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import ifcopenshell
import ifcopenshell.api.alignment
import ifcopenshell.api.nest
import ifcopenshell.api.unit
import ifcopenshell.util.element
def _new_file():
file = ifcopenshell.file(schema="IFC4X3")
file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
length = ifcopenshell.api.unit.add_si_unit(file, unit_type="LENGTHUNIT")
ifcopenshell.api.unit.assign_unit(file, units=[length])
return file
def _add_real_segment_without_geometry(file, horizontal, design_parameters):
# Equivalent to ifcopenshell.api.alignment.create_layout_segment(), minus the geometric
# end-point calculation performed by _add_segment_to_layout()/_get_segment_endpoint() (which
# requires a registered geometry mapping for the schema). include_geometry=False alignments
# have no representation to keep in sync anyway, so this reproduces exactly what the real
# code path does to the layout's segment nest: append the segment, then swap it in front of
# the mandatory zero-length terminal segment.
segment = file.createIfcAlignmentSegment(GlobalId=ifcopenshell.guid.new(), DesignParameters=design_parameters)
ifcopenshell.api.nest.assign_object(file, related_objects=[segment], relating_object=horizontal)
ifcopenshell.api.nest.reorder_nesting(file, segment, -1, -1)
return segment
def test_get_stationing_nest_returns_the_station_nest_even_after_key_point_nest_created():
# add_stationing_referent() establishes the stationing IfcRelNests (one IfcReferent,
# PredefinedType="STATION"). update_key_point_referents() then creates a second, separate
# IfcRelNests of PredefinedType="POSITION" referents. get_stationing_nest() must keep finding
# the STATION nest regardless of which nest happens to come first in alignment.IsNestedBy.
file = _new_file()
alignment = ifcopenshell.api.alignment.create(file, "A1", include_geometry=False)
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
design_parameters = file.createIfcAlignmentHorizontalSegment(
StartPoint=file.createIfcCartesianPoint((0.0, 0.0)),
StartDirection=0.0,
StartRadiusOfCurvature=0.0,
EndRadiusOfCurvature=0.0,
SegmentLength=100.0,
PredefinedType="LINE",
)
_add_real_segment_without_geometry(file, horizontal, design_parameters)
ifcopenshell.api.alignment.add_stationing_referent(
file, "1+00.00", alignment, distance_along=0.0, station=100.0
)
station_nest_before = ifcopenshell.api.alignment.get_stationing_nest(file, alignment)
assert station_nest_before is not None
assert all(r.PredefinedType == "STATION" for r in station_nest_before.RelatedObjects)
key_point_nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
assert all(r.PredefinedType == "POSITION" for r in key_point_nest.RelatedObjects)
assert key_point_nest.id() != station_nest_before.id()
station_nest_after = ifcopenshell.api.alignment.get_stationing_nest(file, alignment)
assert station_nest_after is not None
assert station_nest_after.id() == station_nest_before.id()
assert all(r.PredefinedType == "STATION" for r in station_nest_after.RelatedObjects)
assert (
ifcopenshell.util.element.get_pset(station_nest_after.RelatedObjects[0], name="Pset_Stationing", prop="Station")
== 100.0
)
def test_get_stationing_nest_returns_none_when_only_key_point_nest_exists():
# A mixed or key-point-only nest must never be mistaken for the stationing nest.
file = _new_file()
alignment = ifcopenshell.api.alignment.create(file, "A1", include_geometry=False)
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
design_parameters = file.createIfcAlignmentHorizontalSegment(
StartPoint=file.createIfcCartesianPoint((0.0, 0.0)),
StartDirection=0.0,
StartRadiusOfCurvature=0.0,
EndRadiusOfCurvature=0.0,
SegmentLength=100.0,
PredefinedType="LINE",
)
_add_real_segment_without_geometry(file, horizontal, design_parameters)
ifcopenshell.api.alignment.add_stationing_referent(
file, "1+00.00", alignment, distance_along=0.0, station=100.0
)
# remove the stationing nest, leaving only key-point referents behind
stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment)
file.remove(stationing_nest.RelatedObjects[0])
file.remove(stationing_nest)
ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
assert ifcopenshell.api.alignment.get_stationing_nest(file, alignment) is None
test_get_stationing_nest_returns_the_station_nest_even_after_key_point_nest_created()
test_get_stationing_nest_returns_none_when_only_key_point_nest_exists()
@@ -0,0 +1,172 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell 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 Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import pytest
import ifcopenshell.api.alignment
import ifcopenshell.api.context
import ifcopenshell.api.unit
def unit_convert(unit_scale, p):
if p == None:
return p
x, y = p
return (x / unit_scale, y / unit_scale)
def test_segment_vertices():
file = ifcopenshell.file(schema="IFC4X3")
project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
length = ifcopenshell.api.unit.add_conversion_based_unit(file, name="foot")
angle = ifcopenshell.api.unit.add_si_unit(file, unit_type="PLANEANGLEUNIT")
ifcopenshell.api.unit.assign_unit(file, units=[length, angle])
geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model")
axis_model_representation_subcontext = ifcopenshell.api.context.add_context(
file,
context_type="Model",
context_identifier="Axis",
target_view="MODEL_VIEW",
parent=geometric_representation_context,
)
coordinates = [(500.0, 2500.0), (3340.0, 660.0), (4340.0, 5000.0), (7600.0, 4560.0), (8480.0, 2010.0)]
radii = [(1000.0), (1250.0), (950.0)]
vpoints = [(0.0, 100.0), (2000.0, 135.0), (5000.0, 105.0), (7400.0, 153.0), (9800.0, 105.0), (12800.0, 90.0)]
lengths = [(1600.0), (1200.0), (2000.0), (800.0)]
alignment = ifcopenshell.api.alignment.create_by_pi_method(
file, "TestAlignment", coordinates, radii, vpoints, lengths
)
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file)
# test the horizontal alignment geometry segments
expect = [
[(500.0, 2500.0), (2142.2379952109395, 1436.01482000418), None, None],
[
(2142.2379952109395, 1436.01482000418),
(3660.446122847804, 2050.7361731594674),
(3340.0, 659.9999999999998),
(2685.9792975637306, 2275.267699722618),
],
[(3660.4461228478035, 2050.7361731594674), (4084.115884236641, 3889.4629375870213), None, None],
[
(4084.115884236641, 3889.4629375870218),
(5469.395067206271, 4847.5663099476205),
(4340.0, 5000.000000000001),
(5302.199415841732, 3608.7985293830834),
],
[(5469.395067206271, 4847.56630994762), (7019.971366858418, 4638.286073184753), None, None],
[
(7019.971366858417, 4638.286073184753),
(7790.932128312586, 4006.7307645487535),
(7600.0, 4560.0),
(6892.902671821368, 3696.8225599557054),
],
[(7790.932128312587, 4006.7307645487535), (8480.0, 2010.0000000000002), None, None],
[(8480.0, 2010.0000000000002), (8480.0, 2010.0000000000002), None, None],
]
layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
segments = ifcopenshell.api.alignment.get_layout_segments(layout)
for segment, expected in zip(segments, expect):
s, e, ti, ni = ifcopenshell.api.alignment.segment_vertices(file, segment)
s = unit_convert(unit_scale, s)
e = unit_convert(unit_scale, e)
ti = unit_convert(unit_scale, ti)
ni = unit_convert(unit_scale, ni)
assert s == pytest.approx(expected[0])
assert e == pytest.approx(expected[1])
assert ti == pytest.approx(expected[2])
assert ni == pytest.approx(expected[3])
curve = ifcopenshell.api.alignment.get_basis_curve(alignment)
for segment, expected in zip(curve.Segments, expect):
s, e, ti, ni = ifcopenshell.api.alignment.segment_vertices(file, segment)
s = unit_convert(unit_scale, s)
e = unit_convert(unit_scale, e)
ti = unit_convert(unit_scale, ti)
ni = unit_convert(unit_scale, ni)
assert s == pytest.approx(expected[0])
assert e == pytest.approx(expected[1])
assert ti == pytest.approx(expected[2])
assert ni == pytest.approx(expected[3])
# test vertical curve segments
expect = [
[(0.0, 100.0), (1200.0, 121.0), None, None],
[
(1200.0, 121.0),
(2799.99999384661, 127.00000006153391),
(1999.9999969233054, 134.99999994615786),
(2218.1436363635016, -58058.63636362867),
],
[(2800.0, 127.0), (4400.0, 111.0), None, None],
[
(4400.0, 111.0),
(5599.999994508736, 116.9999998901747),
(4999.999997254367, 105.00000002745632),
(4800.039999999177, 40114.99999991764),
],
[(5600.0, 117.0), (6400.0, 133.0), None, None],
[
(6400.0, 133.0),
(8399.999995932576, 133.0000000813485),
(7399.999997966288, 152.99999995932575),
(7399.999999999187, -49866.99999995936),
],
[(8400.0, 133.0), (9400.0, 113.0), None, None],
[
(9400.0, 113.0),
(10199.99999633883, 103.00000001830585),
(9799.999998169415, 105.00000003661171),
(10466.733333334432, 53449.66666672164),
],
[(10200.0, 103.0), (12800.0, 90.0), None, None],
[(12800.0, 90.0), (12800.0, 90.0), None, None],
]
layout = ifcopenshell.api.alignment.get_vertical_layout(alignment)
segments = ifcopenshell.api.alignment.get_layout_segments(layout)
for segment, expected in zip(segments, expect):
s, e, ti, ni = ifcopenshell.api.alignment.segment_vertices(file, segment)
s = unit_convert(unit_scale, s)
e = unit_convert(unit_scale, e)
ti = unit_convert(unit_scale, ti)
ni = unit_convert(unit_scale, ni)
assert s == pytest.approx(expected[0])
assert e == pytest.approx(expected[1])
assert ti == pytest.approx(expected[2])
assert ni == pytest.approx(expected[3])
curve = ifcopenshell.api.alignment.get_curve(alignment)
for segment, expected in zip(curve.Segments, expect):
s, e, ti, ni = ifcopenshell.api.alignment.segment_vertices(file, segment)
s = unit_convert(unit_scale, s)
e = unit_convert(unit_scale, e)
ti = unit_convert(unit_scale, ti)
ni = unit_convert(unit_scale, ni)
assert s == pytest.approx(expected[0])
assert e == pytest.approx(expected[1])
assert ti == pytest.approx(expected[2])
assert ni == pytest.approx(expected[3])
test_segment_vertices()
+1 -1
View File
@@ -189,7 +189,7 @@ IF DEFINED QT6_VERSION (
IF DEFINED PYTHON_VERSION (
echo Using overridden PYTHON_VERSION: '%PYTHON_VERSION%'
) else (
set PYTHON_VERSION=3.11.7
set PYTHON_VERSION=3.13.13
)
:: VERSION DERIVATIONS