mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-21 12:13:43 +00:00
WIP infra editing
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
Binary file not shown.
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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)):
|
||||
|
||||
@@ -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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+1
-1
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user