Forgot to commit these files

This commit is contained in:
Dion Moult
2026-01-21 18:59:31 +11:00
parent 00c971fa2d
commit 10ce30b2ac
3 changed files with 699 additions and 0 deletions
@@ -0,0 +1,75 @@
# ==============================================================================
# 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()
@@ -0,0 +1,258 @@
# ==============================================================================
# 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",
)
@@ -0,0 +1,366 @@
# ==============================================================================
# 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")