Purge unnecessary Saikei boilerplate

This commit is contained in:
Dion Moult
2026-01-21 12:46:58 +11:00
parent b86bf45167
commit 00c971fa2d
20 changed files with 1691 additions and 3974 deletions
@@ -17,11 +17,68 @@
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import bpy
from bpy.app.handlers import persistent
from . import ui, prop, operator
# from . import ui, prop, operator
from . import operator
classes = (operator.ImportAlignmentCSV,)
@persistent
def on_undo_redo(scene):
"""Handler called after undo/redo to sync PI Editor with IFC.
When Blender undoes, both Blender properties and IFC state may change.
This handler syncs the PI Editor to reflect the current IFC state:
- If the active alignment still exists, extracts PI data from IFC segments
- If the alignment was deleted/invalidated, clears the PI Editor
- Rebuilds display_rows to match the synced state
"""
if not hasattr(scene, "SaikeiAlignmentProperties"):
return
props = scene.SaikeiAlignmentProperties
# Sync PI Editor from IFC to ensure consistency after undo/redo
operator.sync_pis_from_ifc(props)
classes = (
# Property groups (must be registered before classes that use them)
prop.AlignmentPI,
prop.AlignmentSegmentItem,
prop.AlignmentDisplayRow,
prop.SaikeiAlignmentProperties,
# UILists
ui.SAIKEI_UL_alignment_pis,
operator.ImportAlignmentCSV, # Richard Brice
# Operators - PI Management
operator.SAIKEI_OT_add_pi,
operator.SAIKEI_OT_remove_pi,
operator.SAIKEI_OT_pick_pi_from_viewport,
operator.SAIKEI_OT_recalculate_pis,
operator.SAIKEI_OT_clear_pis,
# Operators - Creation
operator.SAIKEI_OT_create_alignment,
operator.SAIKEI_OT_create_alignment_by_pi,
operator.SAIKEI_OT_import_alignment_csv,
operator.SAIKEI_OT_create_alignment_polyline,
operator.SAIKEI_OT_create_alignment_offset,
# Operators - Layout
operator.SAIKEI_OT_add_vertical_layout,
operator.SAIKEI_OT_add_layout_segment,
operator.SAIKEI_OT_layout_horizontal_by_pi,
operator.SAIKEI_OT_layout_vertical_by_pi,
# Operators - Stationing
operator.SAIKEI_OT_add_stationing_referent,
operator.SAIKEI_OT_name_segments,
# Operators - Utilities
operator.SAIKEI_OT_create_representation,
operator.SAIKEI_OT_create_segment_representations,
operator.SAIKEI_OT_update_fallback_position,
operator.SAIKEI_OT_validate_segments,
operator.SAIKEI_OT_refresh_alignment_data,
# UI Panels
ui.SAIKEI_PT_horizontal_alignment,
ui.SAIKEI_PT_alignment_creation,
ui.SAIKEI_PT_pi_editor,
ui.SAIKEI_PT_alignment_stationing,
)
def menu_func_import(self, context):
@@ -29,8 +86,17 @@ def menu_func_import(self, context):
def register():
bpy.types.Scene.SaikeiAlignmentProperties = bpy.props.PointerProperty(type=prop.SaikeiAlignmentProperties)
bpy.types.TOPBAR_MT_file_import.append(menu_func_import)
bpy.app.handlers.undo_post.append(on_undo_redo)
bpy.app.handlers.redo_post.append(on_undo_redo)
def unregister():
bpy.types.TOPBAR_MT_file_import.remove(menu_func_import)
# Unregister handlers
if on_undo_redo in bpy.app.handlers.undo_post:
bpy.app.handlers.undo_post.remove(on_undo_redo)
if on_undo_redo in bpy.app.handlers.redo_post:
bpy.app.handlers.redo_post.remove(on_undo_redo)
del bpy.types.Scene.SaikeiAlignmentProperties
File diff suppressed because it is too large Load Diff
+1
View File
@@ -17,6 +17,7 @@
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
from bonsai.tool.aggregate import Aggregate
from bonsai.tool.alignment import Alignment
from bonsai.tool.attribute import Attribute
from bonsai.tool.bcf import Bcf
from bonsai.tool.blender import Blender
-54
View File
@@ -1,54 +0,0 @@
# ==============================================================================
# Saikei Civil - Civil Engineering Tools for Blender
# Copyright (c) 2025 Michael Yoder / Desert Springs Civil Engineering PLLC
#
# This program 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.
#
# This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
#
# Primary Author: Michael Yoder
# Company: Desert Springs Civil Engineering PLLC
# ==============================================================================
"""
Saikei Civil - Civil engineering design tools for Blender
This addon extends Bonsai (BlenderBIM) with civil engineering capabilities,
focusing on IFC4x3 alignment modeling for roads, railways, and infrastructure.
Requires:
- Bonsai addon installed and enabled
- IFC4X3 schema for alignment features
"""
bl_info = {
"name": "Saikei Civil",
"author": "IfcOpenShell Contributors",
"version": (0, 1, 0),
"blender": (4, 2, 0),
"location": "View3D > Sidebar > Saikei Civil",
"description": "Civil engineering design tools for IFC4x3 alignments",
"doc_url": "https://docs.ifcopenshell.org/",
"category": "Import-Export",
}
import sys
IN_BLENDER = sys.modules.get("bpy", None) is not None
if IN_BLENDER:
from . import civil
def register():
civil.register()
def unregister():
civil.unregister()
-22
View File
@@ -1,22 +0,0 @@
schema_version = "1.0.0"
id = "saikei_civil"
version = "0.1.0"
name = "Saikei Civil"
tagline = "Civil engineering design tools for IFC4x3 alignments"
maintainer = "IfcOpenShell Contributors"
type = "add-on"
# Blender version requirements
blender_version_min = "4.2.0"
# License
license = ["SPDX:GPL-3.0-or-later"]
# Website
website = "https://github.com/IfcOpenShell/IfcOpenShell"
# Categories
[permissions]
files = "Import/export alignment data from CSV files"
network = "Access online resources for civil engineering standards"
-79
View File
@@ -1,79 +0,0 @@
# ==============================================================================
# Saikei Civil - Civil Engineering Tools for Blender
# Copyright (c) 2025 Michael Yoder / Desert Springs Civil Engineering PLLC
#
# This program 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.
#
# This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
#
# Primary Author: Michael Yoder
# Company: Desert Springs Civil Engineering PLLC
# ==============================================================================
"""
Civil module - Core civil engineering functionality
This module follows Bonsai's architecture pattern with dynamic module loading.
"""
import bpy
import importlib
from . import handler, ui, prop, operator
# Feature modules - add new modules here
modules = {
"alignment": None,
# Future modules:
# "vertical": None,
# "corridor": None,
# "cross_section": None,
}
# Dynamically import all modules using relative import path
# Use __name__ to get the correct package path regardless of how Blender loads us
for name in modules.keys():
modules[name] = importlib.import_module(f".module.{name}", package=__name__)
# Collect all classes from global and module files
classes = [
prop.SaikeiCivilProperties,
]
# Add classes from each feature module
for mod in modules.values():
classes.extend(mod.classes)
def register():
"""Register all classes and properties"""
for cls in classes:
bpy.utils.register_class(cls)
# Register global properties
bpy.types.Scene.SaikeiCivilProperties = bpy.props.PointerProperty(type=prop.SaikeiCivilProperties)
# Register each module
for mod in modules.values():
mod.register()
def unregister():
"""Unregister all classes and properties in reverse order"""
# Unregister modules first
for mod in reversed(list(modules.values())):
mod.unregister()
# Remove global properties
del bpy.types.Scene.SaikeiCivilProperties
# Unregister classes
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
-29
View File
@@ -1,29 +0,0 @@
# ==============================================================================
# Saikei Civil - Civil Engineering Tools for Blender
# Copyright (c) 2025 Michael Yoder / Desert Springs Civil Engineering PLLC
#
# This program 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.
#
# This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
#
# Primary Author: Michael Yoder
# Company: Desert Springs Civil Engineering PLLC
# ==============================================================================
"""Blender event handlers for Saikei Civil
This module will contain handlers for:
- Undo/redo synchronization
- Real-time updates during PI editing
- File load/save hooks
"""
# Placeholder for future handler implementations
@@ -1,21 +0,0 @@
# ==============================================================================
# Saikei Civil - Civil Engineering Tools for Blender
# Copyright (c) 2025 Michael Yoder / Desert Springs Civil Engineering PLLC
#
# This program 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.
#
# This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
#
# Primary Author: Michael Yoder
# Company: Desert Springs Civil Engineering PLLC
# ==============================================================================
"""Feature modules for Saikei Civil"""
@@ -1,106 +0,0 @@
# ==============================================================================
# Saikei Civil - Civil Engineering Tools for Blender
# Copyright (c) 2025 Michael Yoder / Desert Springs Civil Engineering PLLC
#
# This program 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.
#
# This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
#
# Primary Author: Michael Yoder
# Company: Desert Springs Civil Engineering PLLC
# ==============================================================================
"""Alignment module for Saikei Civil
This module provides horizontal alignment tools using ifcopenshell.api.alignment.
"""
import bpy
from bpy.app.handlers import persistent
from . import ui, prop, operator
@persistent
def on_undo_redo(scene):
"""Handler called after undo/redo to sync PI Editor with IFC.
When Blender undoes, both Blender properties and IFC state may change.
This handler syncs the PI Editor to reflect the current IFC state:
- If the active alignment still exists, extracts PI data from IFC segments
- If the alignment was deleted/invalidated, clears the PI Editor
- Rebuilds display_rows to match the synced state
"""
if not hasattr(scene, "SaikeiAlignmentProperties"):
return
props = scene.SaikeiAlignmentProperties
# Sync PI Editor from IFC to ensure consistency after undo/redo
operator.sync_pis_from_ifc(props)
# All classes that need to be registered with Blender
classes = (
# Property groups (must be registered before classes that use them)
prop.AlignmentPI,
prop.AlignmentSegmentItem,
prop.AlignmentDisplayRow,
prop.SaikeiAlignmentProperties,
# UILists
ui.SAIKEI_UL_alignment_pis,
# Operators - PI Management
operator.SAIKEI_OT_add_pi,
operator.SAIKEI_OT_remove_pi,
operator.SAIKEI_OT_pick_pi_from_viewport,
operator.SAIKEI_OT_recalculate_pis,
operator.SAIKEI_OT_clear_pis,
# Operators - Creation
operator.SAIKEI_OT_create_alignment,
operator.SAIKEI_OT_create_alignment_by_pi,
operator.SAIKEI_OT_import_alignment_csv,
operator.SAIKEI_OT_create_alignment_polyline,
operator.SAIKEI_OT_create_alignment_offset,
# Operators - Layout
operator.SAIKEI_OT_add_vertical_layout,
operator.SAIKEI_OT_add_layout_segment,
operator.SAIKEI_OT_layout_horizontal_by_pi,
operator.SAIKEI_OT_layout_vertical_by_pi,
# Operators - Stationing
operator.SAIKEI_OT_add_stationing_referent,
operator.SAIKEI_OT_name_segments,
# Operators - Utilities
operator.SAIKEI_OT_create_representation,
operator.SAIKEI_OT_create_segment_representations,
operator.SAIKEI_OT_update_fallback_position,
operator.SAIKEI_OT_validate_segments,
operator.SAIKEI_OT_refresh_alignment_data,
# UI Panels
ui.SAIKEI_PT_horizontal_alignment,
ui.SAIKEI_PT_alignment_creation,
ui.SAIKEI_PT_pi_editor,
ui.SAIKEI_PT_alignment_stationing,
)
def register():
"""Register alignment module properties and handlers"""
bpy.types.Scene.SaikeiAlignmentProperties = bpy.props.PointerProperty(type=prop.SaikeiAlignmentProperties)
# Register undo/redo handlers to keep display_rows in sync
bpy.app.handlers.undo_post.append(on_undo_redo)
bpy.app.handlers.redo_post.append(on_undo_redo)
def unregister():
"""Unregister alignment module properties and handlers"""
# Unregister handlers
if on_undo_redo in bpy.app.handlers.undo_post:
bpy.app.handlers.undo_post.remove(on_undo_redo)
if on_undo_redo in bpy.app.handlers.redo_post:
bpy.app.handlers.redo_post.remove(on_undo_redo)
del bpy.types.Scene.SaikeiAlignmentProperties
@@ -1,75 +0,0 @@
# ==============================================================================
# Saikei Civil - Civil Engineering Tools for Blender
# Copyright (c) 2025 Michael Yoder / Desert Springs Civil Engineering PLLC
#
# This program 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.
#
# This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
#
# Primary Author: Michael Yoder
# Company: Desert Springs Civil Engineering PLLC
# ==============================================================================
"""Data caching layer for the alignment module
This module provides cached access to alignment data for UI display,
following Bonsai's data loading pattern.
"""
def get_ifc_file():
"""Get the current IFC file from Bonsai"""
try:
import bonsai.tool as tool
return tool.Ifc.get()
except (ImportError, AttributeError):
return None
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 = get_ifc_file()
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
@@ -1,258 +0,0 @@
# ==============================================================================
# Saikei Civil - Civil Engineering Tools for Blender
# Copyright (c) 2025 Michael Yoder / Desert Springs Civil Engineering PLLC
#
# This program 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.
#
# This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
#
# Primary Author: Michael Yoder
# Company: Desert Springs Civil Engineering PLLC
# ==============================================================================
"""Property groups for the alignment module"""
import bpy
from bpy.types import PropertyGroup
from bpy.props import (
StringProperty,
FloatProperty,
IntProperty,
BoolProperty,
CollectionProperty,
EnumProperty,
)
def get_pi_type_items(self, context):
"""Get available PI types based on position in list"""
# First and last PIs are always endpoints (no curve)
# Interior PIs can have curves
return [
("ENDPOINT", "Endpoint", "Start or end point (no curve)"),
("TANGENT", "Tangent", "Pass-through point (no curve)"),
("CURVE", "Curve", "Point of intersection with curve"),
]
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 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
x: FloatProperty(
name="X",
description="X coordinate (Easting)",
default=0.0,
precision=3,
unit="LENGTH",
)
y: FloatProperty(
name="Y",
description="Y coordinate (Northing)",
default=0.0,
precision=3,
unit="LENGTH",
)
# 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,
)
# Selection state
is_selected: BoolProperty(
name="Selected",
description="Whether this PI is selected for editing",
default=False,
)
class AlignmentSegmentItem(PropertyGroup):
"""Property group for displaying alignment segments in a UIList"""
name: StringProperty(name="Name", default="")
segment_type: StringProperty(name="Type", default="LINE")
length: FloatProperty(name="Length", default=0.0, unit="LENGTH")
ifc_id: IntProperty(name="IFC ID", default=0)
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)
x: FloatProperty(name="X", default=0.0, precision=3, unit="LENGTH")
y: FloatProperty(name="Y", default=0.0, precision=3, unit="LENGTH")
# 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 SaikeiAlignmentProperties(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="",
)
# 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)
# Segment display
segments: CollectionProperty(type=AlignmentSegmentItem)
active_segment_index: IntProperty(name="Active Segment", 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)
# Editing state
is_editing: BoolProperty(
name="Is Editing",
description="Whether alignment is being edited",
default=False,
)
# Display options
show_pi_markers: BoolProperty(
name="Show PI Markers",
description="Show PI markers in viewport",
default=True,
)
show_station_labels: BoolProperty(
name="Show Station Labels",
description="Show station labels along alignment",
default=True,
)
station_interval: FloatProperty(
name="Station Interval",
description="Interval between station markers",
default=100.0,
min=1.0,
unit="LENGTH",
)
@@ -1,366 +0,0 @@
# ==============================================================================
# Saikei Civil - Civil Engineering Tools for Blender
# Copyright (c) 2025 Michael Yoder / Desert Springs Civil Engineering PLLC
#
# This program 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.
#
# This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
#
# Primary Author: Michael Yoder
# Company: Desert Springs Civil Engineering PLLC
# ==============================================================================
"""UI panels for the alignment module
All panels appear in the VIEW_3D N-panel under the "Saikei Civil" tab.
"""
import bpy
from bpy.types import Panel, UIList
def get_ifc_file():
"""Get the current IFC file from Bonsai"""
try:
import bonsai.tool as tool
return tool.Ifc.get()
except (ImportError, AttributeError):
return None
def is_ifc4x3():
"""Check if the current IFC file is IFC4X3 schema"""
ifc = get_ifc_file()
return ifc is not None and ifc.schema == "IFC4X3"
# =============================================================================
# UILists
# =============================================================================
class SAIKEI_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, "x", text="")
sub.prop(pi, "y", text="")
else:
row.label(text=f"{item.x:.2f}")
row.label(text=f"{item.y:.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"{item.x:.2f}")
row.label(text=f"{item.y:.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")
# =============================================================================
# Main Panel
# =============================================================================
class SAIKEI_PT_horizontal_alignment(Panel):
"""Main Horizontal Alignment panel in the N-panel"""
bl_label = "Horizontal Alignment"
bl_idname = "SAIKEI_PT_horizontal_alignment"
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_category = "Saikei Civil"
def draw(self, context):
layout = self.layout
props = context.scene.SaikeiAlignmentProperties
# Status box
box = layout.box()
ifc = get_ifc_file()
if ifc is None:
box.label(text="No IFC file loaded", icon="ERROR")
box.label(text="Open an IFC4X3 file via Bonsai")
return
if ifc.schema != "IFC4X3":
box.label(text=f"Schema: {ifc.schema}", icon="ERROR")
box.label(text="Alignments require IFC4X3")
return
# IFC file is loaded and correct schema
row = box.row()
row.label(text="IFC4X3", icon="CHECKMARK")
# Count alignments
alignments = ifc.by_type("IfcAlignment")
row.label(text=f"Alignments: {len(alignments)}")
# Active alignment selector
if alignments:
box = layout.box()
box.label(text="Active Alignment:", icon="CURVE_PATH")
row = box.row()
row.prop(props, "active_alignment_name", text="")
# =============================================================================
# Creation Sub-Panel
# =============================================================================
class SAIKEI_PT_alignment_creation(Panel):
"""Sub-panel for alignment creation tools"""
bl_label = "Creation"
bl_idname = "SAIKEI_PT_alignment_creation"
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_category = "Saikei Civil"
bl_parent_id = "SAIKEI_PT_horizontal_alignment"
bl_options = {"DEFAULT_CLOSED"}
@classmethod
def poll(cls, context):
return is_ifc4x3()
def draw(self, context):
layout = self.layout
props = context.scene.SaikeiAlignmentProperties
# 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("saikei.create_alignment", icon="ADD")
col.operator("saikei.create_alignment_by_pi", icon="CURVE_PATH")
col.operator("saikei.import_alignment_csv", icon="IMPORT")
# =============================================================================
# PI Editor Sub-Panel
# =============================================================================
class SAIKEI_PT_pi_editor(Panel):
"""Sub-panel for PI point table editor (Civil 3D style grid view)"""
bl_label = "PI Editor"
bl_idname = "SAIKEI_PT_pi_editor"
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_category = "Saikei Civil"
bl_parent_id = "SAIKEI_PT_horizontal_alignment"
bl_options = set() # Open by default
@classmethod
def poll(cls, context):
return is_ifc4x3()
def draw(self, context):
layout = self.layout
props = context.scene.SaikeiAlignmentProperties
# Header row with column labels
header = layout.row(align=True)
header.label(text="No.")
header.label(text="Type")
header.label(text="X")
header.label(text="Y")
header.label(text="Length")
header.label(text="Radius")
# Combined point/segment list (interleaved view)
row = layout.row()
row.template_list(
"SAIKEI_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("saikei.add_pi", icon="ADD", text="")
col.operator("saikei.remove_pi", icon="REMOVE", text="")
col.separator()
col.operator("saikei.pick_pi_from_viewport", icon="EYEDROPPER", text="")
# Active item details - show details based on selected row
if props.display_rows and 0 <= props.active_display_row_index < len(props.display_rows):
active_row = props.display_rows[props.active_display_row_index]
if active_row.row_type == "POINT" and active_row.pi_index < len(props.pis):
pi = props.pis[active_row.pi_index]
box = layout.box()
box.label(text=f"PI {active_row.pi_index + 1} Details:", icon="PROPERTIES")
row = box.row()
row.prop(pi, "pi_type", text="Type")
row = box.row(align=True)
row.prop(pi, "x", text="X")
row.prop(pi, "y", text="Y")
# Show radius for interior points (can add curve)
if pi.pi_type != "ENDPOINT":
row = box.row()
row.prop(pi, "radius", text="Radius")
# Display computed values
row = box.row()
row.label(text=f"Station: {pi.station:.2f}")
row.label(text=f"Length: {pi.length_to_next:.2f}")
elif active_row.row_type == "SEGMENT":
if active_row.display_type == "Curve":
# Curve segment - show curve details with editable radius
pi = props.pis[active_row.pi_index] if active_row.pi_index < len(props.pis) else None
box = layout.box()
box.label(text=f"Curve {active_row.segment_number} Details:", icon="SPHERECURVE")
row = box.row()
row.label(text=f"PI Location: ({active_row.x:.2f}, {active_row.y:.2f})")
row = box.row()
row.label(text=f"Arc Length: {active_row.arc_length:.2f}")
# Editable radius
if pi:
row = box.row()
row.prop(pi, "radius", text="Radius")
else:
row = box.row()
row.label(text=f"Radius: {active_row.radius:.2f}")
else:
# Tangent segment
box = layout.box()
box.label(text=f"Tangent {active_row.segment_number} Details:", icon="IPO_LINEAR")
row = box.row()
row.label(text=f"Length: {active_row.length:.2f}")
# Bottom actions
layout.separator()
row = layout.row(align=True)
row.operator("saikei.recalculate_pis", icon="FILE_REFRESH", text="Recalculate")
row.operator("saikei.clear_pis", icon="TRASH", text="Clear All")
# =============================================================================
# Stationing Sub-Panel
# =============================================================================
class SAIKEI_PT_alignment_stationing(Panel):
"""Sub-panel for stationing and referents"""
bl_label = "Stationing"
bl_idname = "SAIKEI_PT_alignment_stationing"
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_category = "Saikei Civil"
bl_parent_id = "SAIKEI_PT_horizontal_alignment"
bl_options = {"DEFAULT_CLOSED"}
@classmethod
def poll(cls, context):
return is_ifc4x3()
def draw(self, context):
layout = self.layout
props = context.scene.SaikeiAlignmentProperties
# Station display options
box = layout.box()
box.label(text="Display:", icon="HIDE_OFF")
box.prop(props, "show_station_labels")
box.prop(props, "station_interval")
layout.separator()
# Stationing operators
col = layout.column(align=True)
col.operator("saikei.add_stationing_referent", icon="EMPTY_AXIS")
col.operator("saikei.name_segments", icon="FONT_DATA")
-27
View File
@@ -1,27 +0,0 @@
# ==============================================================================
# Saikei Civil - Civil Engineering Tools for Blender
# Copyright (c) 2025 Michael Yoder / Desert Springs Civil Engineering PLLC
#
# This program 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.
#
# This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
#
# Primary Author: Michael Yoder
# Company: Desert Springs Civil Engineering PLLC
# ==============================================================================
"""Global operators for Saikei Civil
This module contains operators that aren't specific to individual
feature modules.
"""
# Placeholder for global operators
-41
View File
@@ -1,41 +0,0 @@
# ==============================================================================
# Saikei Civil - Civil Engineering Tools for Blender
# Copyright (c) 2025 Michael Yoder / Desert Springs Civil Engineering PLLC
#
# This program 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.
#
# This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
#
# Primary Author: Michael Yoder
# Company: Desert Springs Civil Engineering PLLC
# ==============================================================================
"""Global properties for Saikei Civil addon"""
import bpy
from bpy.types import PropertyGroup
from bpy.props import BoolProperty, StringProperty
class SaikeiCivilProperties(PropertyGroup):
"""Global properties for the Saikei Civil addon"""
is_editing: BoolProperty(
name="Is Editing",
description="Whether an alignment is currently being edited",
default=False,
)
status_message: StringProperty(
name="Status Message",
description="Current status message to display in UI",
default="",
)
-27
View File
@@ -1,27 +0,0 @@
# ==============================================================================
# Saikei Civil - Civil Engineering Tools for Blender
# Copyright (c) 2025 Michael Yoder / Desert Springs Civil Engineering PLLC
#
# This program 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.
#
# This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
#
# Primary Author: Michael Yoder
# Company: Desert Springs Civil Engineering PLLC
# ==============================================================================
"""Global UI elements for Saikei Civil
This module contains any global UI elements that aren't specific to
individual feature modules.
"""
# Placeholder for global UI elements
-30
View File
@@ -1,30 +0,0 @@
# ==============================================================================
# Saikei Civil - Civil Engineering Tools for Blender
# Copyright (c) 2025 Michael Yoder / Desert Springs Civil Engineering PLLC
#
# This program 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.
#
# This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
#
# Primary Author: Michael Yoder
# Company: Desert Springs Civil Engineering PLLC
# ==============================================================================
"""Saikei Civil Core Module
This module contains pure Python business logic with NO Blender (bpy) dependencies.
All functions here must be testable outside of Blender.
Following Bonsai's architecture pattern:
- core/ = Pure Python logic, receives tool classes as parameters
- tool/ = Blender implementations with bpy
- civil/ = UI layer (operators, panels, properties)
"""
-270
View File
@@ -1,270 +0,0 @@
# ==============================================================================
# Saikei Civil - Civil Engineering Tools for Blender
# Copyright (c) 2025 Michael Yoder / Desert Springs Civil Engineering PLLC
#
# This program 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.
#
# This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
#
# Primary Author: Michael Yoder
# Company: Desert Springs Civil Engineering PLLC
# ==============================================================================
"""Core alignment business logic - Pure Python, NO bpy imports.
This module contains all alignment-related calculations and logic that
can be tested outside of Blender. Functions receive tool classes as
parameters following Bonsai's dependency injection pattern.
"""
from __future__ import annotations
import math
from typing import TYPE_CHECKING, List, Tuple, Optional
from dataclasses import dataclass
if TYPE_CHECKING:
import ifcopenshell
from .. import tool
# =============================================================================
# Data Classes for Pure Python PI Handling
# =============================================================================
@dataclass
class PIPoint:
"""Pure Python representation of a PI (Point of Intersection).
This mirrors the Blender PropertyGroup but without bpy dependencies,
allowing for testing and core logic operations.
"""
x: float
y: float
pi_type: str = "TANGENT" # ENDPOINT, TANGENT, or CURVE
radius: float = 0.0
length_to_next: float = 0.0
direction_to_next: float = 0.0
station: float = 0.0
@dataclass
class PIGeometryResult:
"""Result of PI geometry calculation."""
stations: List[float]
lengths: List[float]
directions: List[float]
total_length: float
# =============================================================================
# Pure Python Calculation Functions
# =============================================================================
def calculate_pi_geometry(pis: List[Tuple[float, float]], start_station: float = 0.0) -> PIGeometryResult:
"""Calculate lengths, stations, and directions for a list of PI points.
This is a pure Python function with no Blender dependencies.
Args:
pis: List of (x, y) coordinate tuples for each PI
start_station: Starting station value
Returns:
PIGeometryResult containing calculated values
"""
if len(pis) < 2:
return PIGeometryResult(
stations=[start_station] if pis else [],
lengths=[0.0] if pis else [],
directions=[0.0] if pis else [],
total_length=0.0,
)
stations = []
lengths = []
directions = []
cumulative_length = start_station
for i, pi in enumerate(pis):
stations.append(cumulative_length)
if i < len(pis) - 1:
next_pi = pis[i + 1]
dx = next_pi[0] - pi[0]
dy = next_pi[1] - pi[1]
length = math.sqrt(dx * dx + dy * dy)
direction = math.atan2(dy, dx)
lengths.append(length)
directions.append(direction)
cumulative_length += length
else:
lengths.append(0.0)
directions.append(0.0)
total_length = cumulative_length - start_station
return PIGeometryResult(stations=stations, lengths=lengths, directions=directions, total_length=total_length)
def calculate_deflection_angle(incoming_direction: float, outgoing_direction: float) -> float:
"""Calculate the deflection angle between two tangent directions.
Args:
incoming_direction: Direction angle of incoming tangent (radians)
outgoing_direction: Direction angle of outgoing tangent (radians)
Returns:
Deflection angle in radians (always positive)
"""
delta = outgoing_direction - incoming_direction
# Normalize to -pi to pi
while delta > math.pi:
delta -= 2 * math.pi
while delta < -math.pi:
delta += 2 * math.pi
return abs(delta)
def calculate_tangent_length(radius: float, deflection_angle: float) -> float:
"""Calculate tangent length for a circular curve.
T = R * tan(Δ/2)
Args:
radius: Curve radius
deflection_angle: Deflection angle in radians
Returns:
Tangent length
"""
if deflection_angle == 0 or radius == 0:
return 0.0
return radius * math.tan(deflection_angle / 2)
def calculate_arc_length(radius: float, deflection_angle: float) -> float:
"""Calculate arc length for a circular curve.
L = R * Δ
Args:
radius: Curve radius
deflection_angle: Deflection angle in radians
Returns:
Arc length
"""
return radius * deflection_angle
def calculate_bc_ec_points(
pi_x: float, pi_y: float, incoming_direction: float, outgoing_direction: float, tangent_length: float
) -> Tuple[Tuple[float, float], Tuple[float, float]]:
"""Calculate Begin Curve (BC) and End Curve (EC) points.
BC = PI - incoming_tangent_vector * T
EC = PI + outgoing_tangent_vector * T
Args:
pi_x: PI X coordinate
pi_y: PI Y coordinate
incoming_direction: Direction of incoming tangent (radians)
outgoing_direction: Direction of outgoing tangent (radians)
tangent_length: Calculated tangent length
Returns:
Tuple of (BC point, EC point) as (x, y) tuples
"""
# BC is along the incoming tangent, before the PI
bc_x = pi_x - tangent_length * math.cos(incoming_direction)
bc_y = pi_y - tangent_length * math.sin(incoming_direction)
# EC is along the outgoing tangent, after the PI
ec_x = pi_x + tangent_length * math.cos(outgoing_direction)
ec_y = pi_y + tangent_length * math.sin(outgoing_direction)
return ((bc_x, bc_y), (ec_x, ec_y))
# =============================================================================
# Alignment Visualization Logic (Pure Python)
# =============================================================================
def create_alignment_hierarchy(
ifc_tool: type[tool.Ifc],
alignment_tool: type[tool.Alignment],
alignment: ifcopenshell.entity_instance,
) -> object:
"""Create the Blender object hierarchy for an IFC alignment.
This is a core function that orchestrates the creation process
by calling tool methods. It contains the business logic but
delegates actual Blender operations to the tool layer.
Args:
ifc_tool: The IFC tool class for IFC operations
alignment_tool: The Alignment tool class for Blender operations
alignment: The IFC alignment entity
Returns:
The root Blender object for the alignment
"""
# Create the alignment object
alignment_obj = alignment_tool.create_object_for_alignment(alignment)
if not alignment_obj:
return None
# Get nested layouts via IfcRelNests
layouts = []
for rel in getattr(alignment, "IsNestedBy", []) or []:
for obj in rel.RelatedObjects or []:
if obj.is_a() in ("IfcAlignmentHorizontal", "IfcAlignmentVertical", "IfcAlignmentCant"):
layouts.append(obj)
# Create Blender objects for each layout and its segments
for layout in layouts:
layout_obj = alignment_tool.create_object_for_layout(layout, alignment_obj)
if layout_obj:
create_layout_segment_objects(alignment_tool, layout, layout_obj)
return alignment_obj
def create_layout_segment_objects(
alignment_tool: type[tool.Alignment],
layout: ifcopenshell.entity_instance,
layout_obj: object,
) -> list:
"""Create Blender objects for all segments in a layout.
Args:
alignment_tool: The Alignment tool class
layout: The IFC layout entity
layout_obj: The parent Blender object
Returns:
List of created segment Blender objects
"""
segment_objs = []
for rel in getattr(layout, "IsNestedBy", []) or []:
for i, segment in enumerate(rel.RelatedObjects or []):
if segment.is_a() == "IfcAlignmentSegment":
seg_obj = alignment_tool.create_object_for_segment(segment, i, layout_obj)
if seg_obj:
segment_objs.append(seg_obj)
return segment_objs
-126
View File
@@ -1,126 +0,0 @@
# ==============================================================================
# Saikei Civil - Civil Engineering Tools for Blender
# Copyright (c) 2025 Michael Yoder / Desert Springs Civil Engineering PLLC
#
# This program 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.
#
# This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
#
# Primary Author: Michael Yoder
# Company: Desert Springs Civil Engineering PLLC
# ==============================================================================
"""Saikei Civil Tool Module
This module contains Blender-specific implementations that bridge the
core business logic to the Blender environment.
Following Bonsai's architecture pattern:
- core/ = Pure Python logic
- tool/ = Blender implementations with bpy (this module)
- civil/ = UI layer (operators, panels, properties)
Usage:
from .... import tool # relative import from within saikei package
tool.Alignment.create_object_for_alignment(alignment)
tool.Ifc.get()
"""
from .alignment import Alignment
# Lazy import wrappers for Bonsai's tools
# We use lazy imports to avoid circular import issues with Bonsai's tool module
_bonsai_ifc = None
_bonsai_collector = None
class _LazyIfc:
"""Lazy wrapper for bonsai.tool.Ifc to avoid circular imports."""
@staticmethod
def _get_real():
global _bonsai_ifc
if _bonsai_ifc is None:
try:
from bonsai.tool import Ifc as _Ifc
_bonsai_ifc = _Ifc
except ImportError:
_bonsai_ifc = None
return _bonsai_ifc
def __getattr__(self, name):
real = self._get_real()
if real is None:
raise ImportError("Bonsai is not available")
return getattr(real, name)
@classmethod
def get(cls):
real = cls._get_real()
if real is None:
return None
return real.get()
@classmethod
def get_object(cls, element):
real = cls._get_real()
if real is None:
return None
return real.get_object(element)
@classmethod
def link(cls, element, obj):
real = cls._get_real()
if real is None:
return None
return real.link(element, obj)
@classmethod
def unlink(cls, obj=None, element=None):
real = cls._get_real()
if real is None:
return None
return real.unlink(obj=obj, element=element)
class _LazyCollector:
"""Lazy wrapper for bonsai.tool.Collector to avoid circular imports."""
@staticmethod
def _get_real():
global _bonsai_collector
if _bonsai_collector is None:
try:
from bonsai.tool import Collector as _Collector
_bonsai_collector = _Collector
except ImportError:
_bonsai_collector = None
return _bonsai_collector
def __getattr__(self, name):
real = self._get_real()
if real is None:
raise ImportError("Bonsai is not available")
return getattr(real, name)
@classmethod
def assign(cls, obj):
real = cls._get_real()
if real is None:
return None
return real.assign(obj)
# Export lazy wrappers as if they were the real tools
Ifc = _LazyIfc()
Collector = _LazyCollector()
-780
View File
@@ -1,780 +0,0 @@
# ==============================================================================
# Saikei Civil - Civil Engineering Tools for Blender
# Copyright (c) 2025 Michael Yoder / Desert Springs Civil Engineering PLLC
#
# This program 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.
#
# This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
#
# Primary Author: Michael Yoder
# Company: Desert Springs Civil Engineering PLLC
# ==============================================================================
"""Alignment Tool - Blender implementations for alignment visualization.
This module contains Blender-specific code for creating and managing
alignment objects in the 3D view. It bridges the core business logic
to the Blender environment.
All methods are classmethods following Bonsai's tool pattern.
"""
from __future__ import annotations
import bpy
from typing import TYPE_CHECKING, Optional, List
if TYPE_CHECKING:
import ifcopenshell
class Alignment:
"""Tool class for alignment-related Blender operations.
Following Bonsai's tool pattern, all methods are classmethods
that can be called without instantiation.
"""
@classmethod
def get_ifc_file(cls) -> Optional[ifcopenshell.file]:
"""Get the current IFC file from Bonsai.
Returns:
The IFC file object, or None if not available
"""
try:
import bonsai.tool as tool
return tool.Ifc.get()
except (ImportError, AttributeError):
return None
@classmethod
def create_object_for_alignment(cls, alignment: ifcopenshell.entity_instance) -> Optional[bpy.types.Object]:
"""Create a Blender object for an IFC alignment and link it properly.
This follows Bonsai's pattern for creating Blender representations:
1. Create a Blender Empty object
2. Link it to the IFC element via tool.Ifc.link()
3. Assign it to the appropriate collection via tool.Collector.assign()
Args:
alignment: The IFC alignment entity
Returns:
The created Blender object, or existing one if already linked
"""
try:
import bonsai.tool as tool
# Check if a Blender object already exists for this IFC element
existing_obj = tool.Ifc.get_object(alignment)
if existing_obj:
return existing_obj
# Create Blender Empty object with naming pattern "IfcClass/Name"
name = f"IfcAlignment/{alignment.Name or 'Unnamed'}"
obj = bpy.data.objects.new(name, None) # None = Empty object
obj.empty_display_type = "ARROWS"
obj.empty_display_size = 1.0
# Link the Blender object to the IFC element (creates bidirectional mapping)
tool.Ifc.link(alignment, obj)
# Also set ifc_definition_id manually as fallback for lookups
obj["ifc_definition_id"] = alignment.id()
# Assign to appropriate collection (Bonsai handles collection hierarchy)
tool.Collector.assign(obj)
return obj
except (ImportError, AttributeError) as e:
print(f"Warning: Could not create Blender object for alignment: {e}")
return None
@classmethod
def create_object_for_layout(
cls, layout_entity: ifcopenshell.entity_instance, parent_obj: Optional[bpy.types.Object] = None
) -> Optional[bpy.types.Object]:
"""Create a Blender object for an IFC alignment layout.
Args:
layout_entity: The IFC layout entity (IfcAlignmentHorizontal, etc.)
parent_obj: The parent Blender object (IfcAlignment object)
Returns:
The created Blender object, or existing one if already linked
"""
try:
import bonsai.tool as tool
# Check if a Blender object already exists for this IFC element
existing_obj = tool.Ifc.get_object(layout_entity)
if existing_obj:
return existing_obj
# Determine the layout type from the IFC class
ifc_class = layout_entity.is_a()
name = f"{ifc_class}"
obj = bpy.data.objects.new(name, None)
obj.empty_display_type = "PLAIN_AXES"
obj.empty_display_size = 0.5
# Link to IFC element
tool.Ifc.link(layout_entity, obj)
# Also set ifc_definition_id manually as fallback for lookups
obj["ifc_definition_id"] = layout_entity.id()
# Set parent relationship in Blender (mirrors IFC nesting)
if parent_obj:
obj.parent = parent_obj
# Assign to same collection as parent (avoid "Unsorted")
if parent_obj and parent_obj.users_collection:
parent_obj.users_collection[0].objects.link(obj)
else:
tool.Collector.assign(obj)
return obj
except (ImportError, AttributeError) as e:
print(f"Warning: Could not create Blender object for layout: {e}")
return None
@classmethod
def create_object_for_segment(
cls, segment: ifcopenshell.entity_instance, index: int, parent_obj: Optional[bpy.types.Object] = None
) -> Optional[bpy.types.Object]:
"""Create a Blender curve object for an IFC alignment segment.
Creates actual curve geometry (not just an empty) to visualize
the segment. LINE segments become straight curves, CIRCULARARC
segments become arcs.
Args:
segment: The IfcAlignmentSegment entity
index: The segment index (for naming)
parent_obj: The parent Blender object (layout object)
Returns:
The created Blender object, or existing one if already linked
"""
import math
try:
import bonsai.tool as tool
# Check if a Blender object already exists for this IFC element
existing_obj = tool.Ifc.get_object(segment)
if existing_obj:
return existing_obj
# Get segment parameters
if not hasattr(segment, "DesignParameters") or not segment.DesignParameters:
return None
dp = segment.DesignParameters
seg_type = getattr(dp, "PredefinedType", "UNKNOWN") or "UNKNOWN"
seg_length = getattr(dp, "SegmentLength", 0.0) or 0.0
# Skip zero-length terminal segments
if seg_length < 0.0001:
return None
# Get start point
start_point = None
if hasattr(dp, "StartPoint") and dp.StartPoint:
coords = dp.StartPoint.Coordinates
if len(coords) >= 2:
start_point = (coords[0], coords[1], 0.0)
if not start_point:
return None
# Get start direction - IFC stores this in degrees, convert to radians
start_direction_deg = getattr(dp, "StartDirection", 0.0) or 0.0
start_direction = math.radians(start_direction_deg)
name = f"Segment {index + 1} ({seg_type})"
# Create curve geometry based on segment type
if seg_type == "LINE":
obj = cls._create_line_segment(name, start_point, start_direction, seg_length)
elif seg_type == "CIRCULARARC":
# Get radius for arc (positive = left, negative = right in IFC)
radius = getattr(dp, "StartRadiusOfCurvature", None)
if radius is None or radius == 0:
# Fallback to line if no radius
obj = cls._create_line_segment(name, start_point, start_direction, seg_length)
else:
obj = cls._create_arc_segment(name, start_point, start_direction, seg_length, radius)
else:
# For unsupported types, create a simple line approximation
obj = cls._create_line_segment(name, start_point, start_direction, seg_length)
if not obj:
return None
# Link to IFC element
tool.Ifc.link(segment, obj)
# Also set ifc_definition_id manually as fallback for lookups
obj["ifc_definition_id"] = segment.id()
# Set parent relationship
if parent_obj:
obj.parent = parent_obj
# Assign to same collection as parent (avoid "Unsorted")
if parent_obj and parent_obj.users_collection:
parent_obj.users_collection[0].objects.link(obj)
else:
tool.Collector.assign(obj)
return obj
except (ImportError, AttributeError) as e:
print(f"Warning: Could not create Blender object for segment: {e}")
return None
@classmethod
def _create_line_segment(
cls, name: str, start_point: tuple, direction: float, length: float
) -> Optional[bpy.types.Object]:
"""Create a Blender curve for a LINE segment.
Args:
name: Object name
start_point: (x, y, z) start coordinates
direction: Direction angle in radians (IFC uses bearing from North/Y-axis)
length: Segment length
Returns:
Blender curve object
"""
import math
# IFC uses standard math convention: angle counter-clockwise from +X axis
end_x = start_point[0] + length * math.cos(direction)
end_y = start_point[1] + length * math.sin(direction)
end_point = (end_x, end_y, start_point[2])
# Create curve data
curve_data = bpy.data.curves.new(name, type="CURVE")
curve_data.dimensions = "3D"
# Create a polyline spline
spline = curve_data.splines.new("POLY")
spline.points.add(1) # Start with 1 point, add 1 more = 2 total
# Set point coordinates (Blender uses 4D coords: x, y, z, w)
spline.points[0].co = (start_point[0], start_point[1], start_point[2], 1.0)
spline.points[1].co = (end_point[0], end_point[1], end_point[2], 1.0)
# Create object
obj = bpy.data.objects.new(name, curve_data)
# Set curve display properties
curve_data.bevel_depth = 0.0 # No thickness for now
obj.show_in_front = True # Always visible
return obj
@classmethod
def _create_arc_segment(
cls, name: str, start_point: tuple, direction: float, length: float, radius: float
) -> Optional[bpy.types.Object]:
"""Create a Blender curve for a CIRCULARARC segment.
Args:
name: Object name
start_point: (x, y, z) start coordinates
direction: Start direction angle in radians (IFC uses bearing from North/Y-axis)
length: Arc length
radius: Radius of curvature (positive = curves left, negative = curves right)
Returns:
Blender curve object
"""
import math
# Calculate arc parameters
# Arc length L = R * theta, so theta = L / R
abs_radius = abs(radius)
if abs_radius < 0.0001:
# Degenerate case - just make a line
return cls._create_line_segment(name, start_point, direction, length)
theta = length / abs_radius # Total angle swept
# Determine if curving left (positive radius) or right (negative radius)
curve_left = radius > 0
# Generate points along the arc
num_points = max(int(theta * 10) + 2, 8) # At least 8 points, more for larger arcs
# Create curve data
curve_data = bpy.data.curves.new(name, type="CURVE")
curve_data.dimensions = "3D"
# Create a polyline spline
spline = curve_data.splines.new("POLY")
spline.points.add(num_points - 1) # Add points (starts with 1)
# Calculate center of the arc
# Center is perpendicular to start direction at distance R
# For standard math convention (angle from +X, CCW):
# Perpendicular left = direction + 90°, perpendicular right = direction - 90°
if curve_left:
center_angle = direction + math.pi / 2
else:
center_angle = direction - math.pi / 2
center_x = start_point[0] + abs_radius * math.cos(center_angle)
center_y = start_point[1] + abs_radius * math.sin(center_angle)
# Start angle from center to start point
start_angle = math.atan2(start_point[1] - center_y, start_point[0] - center_x)
# Generate points
for i in range(num_points):
t = i / (num_points - 1) # Parameter from 0 to 1
if curve_left:
angle = start_angle + t * theta
else:
angle = start_angle - t * theta
px = center_x + abs_radius * math.cos(angle)
py = center_y + abs_radius * math.sin(angle)
pz = start_point[2]
spline.points[i].co = (px, py, pz, 1.0)
# Create object
obj = bpy.data.objects.new(name, curve_data)
# Set curve display properties
curve_data.bevel_depth = 0.0
obj.show_in_front = True
return obj
@classmethod
def create_hierarchy_for_alignment(cls, alignment: ifcopenshell.entity_instance) -> Optional[bpy.types.Object]:
"""Create the full Blender object hierarchy for an alignment.
Creates:
- IfcAlignment object (root)
- IfcAlignmentHorizontal object (child)
- IfcAlignmentVertical object (child, if present)
- IfcAlignmentCant object (child, if present)
- Segment objects under each layout
Args:
alignment: The IFC alignment entity
Returns:
The root alignment Blender object
"""
# Create the alignment object
alignment_obj = cls.create_object_for_alignment(alignment)
if not alignment_obj:
return None
# Get nested layouts via IfcRelNests
layouts = []
for rel in getattr(alignment, "IsNestedBy", []) or []:
for obj in rel.RelatedObjects or []:
if obj.is_a() in ("IfcAlignmentHorizontal", "IfcAlignmentVertical", "IfcAlignmentCant"):
layouts.append(obj)
# Create Blender objects for each layout and its segments
for layout in layouts:
layout_obj = cls.create_object_for_layout(layout, alignment_obj)
if layout_obj:
cls.create_objects_for_layout_segments(layout, layout_obj)
return alignment_obj
@classmethod
def create_objects_for_layout_segments(
cls, layout: ifcopenshell.entity_instance, layout_obj: bpy.types.Object
) -> List[bpy.types.Object]:
"""Create Blender objects for all segments in a layout.
Args:
layout: The IFC layout entity (IfcAlignmentHorizontal, etc.)
layout_obj: The parent Blender object for the layout
Returns:
List of created segment Blender objects
"""
segment_objs = []
# Get segments via IfcRelNests
for rel in getattr(layout, "IsNestedBy", []) or []:
for i, segment in enumerate(rel.RelatedObjects or []):
if segment.is_a() == "IfcAlignmentSegment":
seg_obj = cls.create_object_for_segment(segment, i, layout_obj)
if seg_obj:
segment_objs.append(seg_obj)
return segment_objs
@classmethod
def update_pi_properties(cls, props, geometry_result) -> None:
"""Update Blender PropertyGroup with calculated geometry.
This bridges the pure Python calculation results back to
the Blender UI properties.
Args:
props: The SaikeiAlignmentProperties PropertyGroup
geometry_result: PIGeometryResult from core.alignment
"""
pis = props.pis
for i, pi in enumerate(pis):
if i < len(geometry_result.stations):
pi.station = geometry_result.stations[i]
if i < len(geometry_result.lengths):
pi.length_to_next = geometry_result.lengths[i]
if i < len(geometry_result.directions):
pi.direction_to_next = geometry_result.directions[i]
@classmethod
def _remove_blender_object(cls, obj: bpy.types.Object) -> bool:
"""Safely remove a Blender object and its data.
Args:
obj: The Blender object to remove
Returns:
True if removed successfully
"""
try:
import bonsai.tool as tool
# Unlink from IFC if linked
try:
tool.Ifc.unlink(obj)
except Exception:
pass # Object might not be linked
# Store data reference before removing object
data = obj.data
# Remove the object
bpy.data.objects.remove(obj, do_unlink=True)
# Clean up orphan curve/mesh data
if data and data.users == 0:
if isinstance(data, bpy.types.Curve):
bpy.data.curves.remove(data)
elif isinstance(data, bpy.types.Mesh):
bpy.data.meshes.remove(data)
return True
except Exception as e:
print(f"Warning: Could not remove object: {e}")
return False
@classmethod
def _find_object_by_ifc_id(cls, ifc_id: int) -> Optional[bpy.types.Object]:
"""Find a Blender object by its IFC definition ID.
Fallback method when tool.Ifc.get_object() doesn't work.
Args:
ifc_id: The IFC entity ID
Returns:
The Blender object, or None if not found
"""
for obj in bpy.data.objects:
if obj.get("ifc_definition_id") == ifc_id:
return obj
return None
@classmethod
def _find_object_by_name_pattern(cls, name_pattern: str) -> Optional[bpy.types.Object]:
"""Find a Blender object by name pattern.
Last resort fallback that matches object name.
Args:
name_pattern: Name or partial name to match
Returns:
The Blender object, or None if not found
"""
# Try exact match first
if name_pattern in bpy.data.objects:
return bpy.data.objects[name_pattern]
# Try partial match (for names like "IfcAlignment/SH-21")
for obj in bpy.data.objects:
if name_pattern in obj.name:
return obj
return None
@classmethod
def remove_layout_segment_objects(cls, layout: ifcopenshell.entity_instance) -> int:
"""Remove all Blender objects for segments in a layout.
Args:
layout: The IFC layout entity (IfcAlignmentHorizontal, etc.)
Returns:
Number of objects removed
"""
try:
import bonsai.tool as tool
except ImportError:
tool = None
removed_count = 0
# Get segments via IfcRelNests
for rel in getattr(layout, "IsNestedBy", []) or []:
for segment in rel.RelatedObjects or []:
if segment.is_a() == "IfcAlignmentSegment":
obj = None
# Try to get object via Bonsai's tool
if tool:
try:
obj = tool.Ifc.get_object(segment)
except Exception:
pass
# Fallback: search by IFC ID
if not obj:
obj = cls._find_object_by_ifc_id(segment.id())
if obj and cls._remove_blender_object(obj):
removed_count += 1
return removed_count
@classmethod
def remove_alignment_hierarchy(cls, alignment: ifcopenshell.entity_instance) -> int:
"""Remove all Blender objects for an alignment and its children.
Args:
alignment: The IFC alignment entity
Returns:
Number of objects removed
"""
try:
import bonsai.tool as tool
except ImportError:
tool = None
removed_count = 0
alignment_name = alignment.Name or "Unnamed"
# Get nested layouts via IfcRelNests
for rel in getattr(alignment, "IsNestedBy", []) or []:
for layout in rel.RelatedObjects or []:
if layout.is_a() in ("IfcAlignmentHorizontal", "IfcAlignmentVertical", "IfcAlignmentCant"):
# Remove segment objects first
removed_count += cls.remove_layout_segment_objects(layout)
# Try to get layout object via Bonsai's tool
layout_obj = None
if tool:
try:
layout_obj = tool.Ifc.get_object(layout)
except Exception:
pass
# Fallback: search by IFC ID
if not layout_obj:
layout_obj = cls._find_object_by_ifc_id(layout.id())
# Last resort: search by name pattern
if not layout_obj:
layout_obj = cls._find_object_by_name_pattern(layout.is_a())
if layout_obj and cls._remove_blender_object(layout_obj):
removed_count += 1
# Try to get alignment object via Bonsai's tool
alignment_obj = None
if tool:
try:
alignment_obj = tool.Ifc.get_object(alignment)
except Exception:
pass
# Fallback: search by IFC ID
if not alignment_obj:
alignment_obj = cls._find_object_by_ifc_id(alignment.id())
# Last resort: search by name pattern (IfcAlignment/Name)
if not alignment_obj:
alignment_obj = cls._find_object_by_name_pattern(f"IfcAlignment/{alignment_name}")
if alignment_obj and cls._remove_blender_object(alignment_obj):
removed_count += 1
return removed_count
@classmethod
def refresh_layout_visualization(
cls, layout: ifcopenshell.entity_instance, layout_obj: Optional[bpy.types.Object] = None
) -> List[bpy.types.Object]:
"""Refresh the visualization for a layout by removing and recreating segment objects.
Args:
layout: The IFC layout entity
layout_obj: Optional parent Blender object (will be looked up if not provided)
Returns:
List of newly created segment objects
"""
try:
import bonsai.tool as tool
# Get or find the layout object
if layout_obj is None:
layout_obj = tool.Ifc.get_object(layout)
if layout_obj is None:
return []
# Remove existing segment objects
cls.remove_layout_segment_objects(layout)
# Create new segment objects
return cls.create_objects_for_layout_segments(layout, layout_obj)
except (ImportError, AttributeError) as e:
print(f"Warning: Could not refresh layout visualization: {e}")
return []
# =========================================================================
# Validation and Safe Wrappers
# =========================================================================
# These methods provide pre-validation before calling IfcOpenShell alignment
# API functions. This prevents issues like orphan layouts (from undo/redo)
# causing invalid IFC entities (e.g., IfcRelPositions with empty RelatedProducts).
#
# The key principle: validate BEFORE operations to prevent invalid data,
# rather than cleaning up after the fact.
@classmethod
def validate_layout_has_parent_alignment(
cls, layout: "ifcopenshell.entity_instance"
) -> Optional["ifcopenshell.entity_instance"]:
"""Check if a layout entity has a valid parent IfcAlignment.
Orphan layouts (e.g., from undo/redo operations) can cause issues
when the alignment API tries to create referents, as the code
expects a parent alignment to exist.
Args:
layout: The IFC layout entity (IfcAlignmentHorizontal, etc.)
Returns:
The parent IfcAlignment if found, None otherwise
"""
try:
import ifcopenshell.api.alignment as align_api
return align_api.get_alignment(layout)
except Exception:
return None
@classmethod
def get_alignment_for_layout(
cls, layout: "ifcopenshell.entity_instance"
) -> Optional["ifcopenshell.entity_instance"]:
"""Get the parent IfcAlignment for a layout entity.
This is an alias for validate_layout_has_parent_alignment that
makes the intent clearer when you need the alignment itself.
Args:
layout: The IFC layout entity (IfcAlignmentHorizontal, etc.)
Returns:
The parent IfcAlignment if found, None otherwise
"""
return cls.validate_layout_has_parent_alignment(layout)
@classmethod
def safe_layout_horizontal_by_pi_method(
cls, ifc_file: "ifcopenshell.file", layout: "ifcopenshell.entity_instance", hpoints: list, radii: list
) -> bool:
"""Safely add segments to a horizontal layout using PI method.
This wrapper validates that the layout has a valid parent alignment
before calling the IfcOpenShell API. This prevents the creation of
invalid IfcRelPositions entities.
Args:
ifc_file: The IFC file
layout: The IfcAlignmentHorizontal layout
hpoints: List of (X, Y) coordinate pairs for PIs
radii: List of curve radii
Returns:
True if successful
Raises:
ValueError: If layout has no parent alignment
"""
import ifcopenshell.api.alignment as align_api
# Validate layout has a parent alignment - this is the key check
# that prevents orphan stationing from being created
alignment = cls.validate_layout_has_parent_alignment(layout)
if alignment is None:
raise ValueError(
f"Layout #{layout.id()} ({layout.is_a()}) has no parent IfcAlignment. "
"This may be an orphan layout from undo/redo. "
"Cannot add segments without a valid parent alignment."
)
# Now safe to call the API - stationing will be associated with alignment
align_api.layout_horizontal_alignment_by_pi_method(ifc_file, layout, hpoints, radii)
return True
@classmethod
def safe_create_alignment_by_pi_method(
cls, ifc_file: "ifcopenshell.file", name: str, hpoints: list, radii: list, start_station: float = 0.0
) -> "ifcopenshell.entity_instance":
"""Safely create a new alignment using PI method.
When creating a new alignment, we don't need validation since
we're creating the alignment itself - stationing will be
properly associated with it.
Args:
ifc_file: The IFC file
name: Alignment name
hpoints: List of (X, Y) coordinate pairs for PIs
radii: List of curve radii
start_station: Starting station value
Returns:
The created IfcAlignment entity
"""
import ifcopenshell.api.alignment as align_api
# Create the alignment - this creates a new alignment so stationing
# will be properly associated with it
alignment = align_api.create_by_pi_method(
ifc_file, name=name, hpoints=hpoints, radii=radii, start_station=start_station
)
return alignment