diff --git a/src/bonsai/bonsai/bim/module/alignment/data.py b/src/bonsai/bonsai/bim/module/alignment/data.py
new file mode 100644
index 0000000000..a4dc5f18ed
--- /dev/null
+++ b/src/bonsai/bonsai/bim/module/alignment/data.py
@@ -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 .
+#
+# 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()
diff --git a/src/bonsai/bonsai/bim/module/alignment/prop.py b/src/bonsai/bonsai/bim/module/alignment/prop.py
new file mode 100644
index 0000000000..e4f67b4169
--- /dev/null
+++ b/src/bonsai/bonsai/bim/module/alignment/prop.py
@@ -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 .
+#
+# 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",
+ )
diff --git a/src/bonsai/bonsai/bim/module/alignment/ui.py b/src/bonsai/bonsai/bim/module/alignment/ui.py
new file mode 100644
index 0000000000..6e9224830a
--- /dev/null
+++ b/src/bonsai/bonsai/bim/module/alignment/ui.py
@@ -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 .
+#
+# 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")