diff --git a/.claude/settings.local.json b/.claude/settings.local.json
new file mode 100644
index 0000000000..7968720238
--- /dev/null
+++ b/.claude/settings.local.json
@@ -0,0 +1,10 @@
+{
+ "permissions": {
+ "allow": [
+ "Bash(cat:*)",
+ "Bash(find:*)",
+ "Bash(grep:*)",
+ "Bash(xargs:*)"
+ ]
+ }
+}
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000000..01aaf0b19b
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,637 @@
+# CLAUDE.md - Saikei Civil Context for IfcOpenShell Contributions
+
+> This document provides context for Claude Code when working on Saikei Civil contributions to IfcOpenShell, specifically horizontal alignment visualization.
+
+---
+
+## Project Overview
+
+**Saikei Civil** (formerly BlenderCivil) is an open-source Blender extension for native IFC 4.3 infrastructure design. The project aims to democratize professional civil engineering tools by providing free alternatives to expensive commercial software like Civil 3D ($2,500/year) and OpenRoads ($4,000/year).
+
+### Mission
+- **Bonsai BIM** = Buildings (vertical construction)
+- **Saikei Civil** = Infrastructure (horizontal construction: roads, earthwork, drainage)
+
+> "While Bonsai crafts the buildings, Saikei shapes the world around them."
+
+### Key Differentiator
+**Native IFC Philosophy**: IFC files serve as the primary database rather than export targets. We're not converting TO IFC - we ARE IFC from the start.
+
+---
+
+## Architecture Principles
+
+### Three-Layer Architecture (Matches Bonsai)
+
+Saikei follows Bonsai's proven architecture with `core/` and `tool/` at the **package root level**:
+
+```
+saikei/
+├── core/ # Layer 1: Pure Python - NO bpy imports
+│ └── alignment.py # Business logic, math, validation
+├── tool/ # Layer 2: Blender implementations - HAS bpy
+│ └── alignment.py # Blender object creation, linking
+└── civil/ # Layer 3: UI (like Bonsai's bim/)
+ └── module/
+ └── alignment/
+ ├── __init__.py # Registration
+ ├── operator.py # Blender operators
+ ├── ui.py # UI panels
+ ├── prop.py # PropertyGroups
+ └── data.py # UI data caching
+```
+
+**Layer Responsibilities:**
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ Layer 3: civil/module/{name}/ (UI Layer) │
+│ - operator.py: User actions (bpy.types.Operator) │
+│ - ui.py: Interface panels (bpy.types.Panel) │
+│ - prop.py: UI state (bpy.types.PropertyGroup) │
+│ - Calls core functions, passing tool implementations │
+└─────────────────────────────────────────────────────────────┘
+ │ calls
+ ▼
+┌─────────────────────────────────────────────────────────────┐
+│ Layer 1: core/ (Pure Python - NO bpy imports) │
+│ - All business logic, algorithms, mathematics │
+│ - Receives tool classes as parameters (dependency inject) │
+│ - MUST be testable outside Blender │
+└─────────────────────────────────────────────────────────────┘
+ │ receives as parameters
+ ▼
+┌─────────────────────────────────────────────────────────────┐
+│ Layer 2: tool/ (Blender implementations) │
+│ - Concrete implementations with bpy │
+│ - Blender object creation, scene manipulation │
+│ - Wraps Bonsai's tool.Ifc, tool.Collector, etc. │
+└─────────────────────────────────────────────────────────────┘
+```
+
+### Bonsai's Dependency Injection Pattern
+
+Core functions receive tool classes as parameters, enabling testability:
+
+```python
+# In core/alignment.py (NO bpy imports)
+from __future__ import annotations
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ import ifcopenshell
+ import saikei.tool as tool
+
+def create_alignment_visualization(
+ ifc: type[tool.Ifc],
+ alignment_tool: type[tool.Alignment],
+ alignment: ifcopenshell.entity_instance,
+) -> list:
+ """Pure business logic - tools passed as parameters."""
+ segments = get_layout_segments(alignment)
+ objects = []
+ for segment in segments:
+ obj = alignment_tool.create_segment_object(segment)
+ ifc.link(obj, segment)
+ objects.append(obj)
+ return objects
+```
+
+```python
+# In civil/module/alignment/operator.py (HAS bpy)
+import bpy
+import saikei.tool as tool
+import saikei.core.alignment as core
+
+class SAIKEI_OT_visualize_alignment(bpy.types.Operator):
+ def execute(self, context):
+ alignment = self.get_active_alignment()
+ # Call core logic, passing tool implementations
+ core.create_alignment_visualization(
+ tool.Ifc, tool.Alignment, alignment
+ )
+ return {'FINISHED'}
+```
+
+### Golden Pattern for Native IFC
+
+```python
+# 1. GET IFC FILE
+ifc = NativeIfcManager.get_file()
+
+# 2. CREATE/MODIFY IFC ENTITY FIRST
+entity = ifc.create_entity("IfcAlignment", ...)
+
+# 3. CREATE BLENDER VISUALIZATION SECOND
+obj = create_blender_object(...)
+
+# 4. LINK THEM (minimal storage in Blender)
+NativeIfcManager.link_object(obj, entity)
+# obj["ifc_definition_id"] = entity.id()
+# obj["ifc_class"] = entity.is_a()
+# obj["GlobalId"] = entity.GlobalId
+
+# 5. SAVE = WRITE IFC FILE
+NativeIfcManager.save_file("project.ifc")
+```
+
+### Key Principles
+1. **IFC-First Design**: ALL civil engineering data lives in the IFC file
+2. **Minimal Blender Storage**: Only 3 properties stored in Blender objects
+3. **Separation of Concerns**: Core logic has NO bpy imports
+4. **IfcStore Pattern**: Following Bonsai's proven pattern for IFC file management
+
+---
+
+## IFC 4.3 Alignment Structure
+
+### Entity Hierarchy
+
+```
+IfcProject
+└── IfcSite
+ └── IfcRoad (via IfcRelAggregates)
+ └── IfcAlignment (via IfcRelContainedInSpatialStructure)
+ ├── IfcAlignmentHorizontal (via IfcRelNests)
+ │ └── [IfcAlignmentSegment → IfcAlignmentHorizontalSegment]*
+ ├── IfcAlignmentVertical (via IfcRelNests)
+ │ └── [IfcAlignmentSegment → IfcAlignmentVerticalSegment]*
+ └── IfcAlignmentCant (via IfcRelNests) [Future]
+ └── [IfcAlignmentSegment → IfcAlignmentCantSegment]*
+```
+
+### Critical IFC Rules
+
+1. **Zero-Length Terminal Segments**: The last `IfcAlignmentSegment.DesignParameters` in each layout MUST be zero length to provide the end point
+2. **Ordered Collections**: `IfcRelNests.RelatedObjects` maintains segment order (start to end)
+3. **Nesting Order**: Horizontal precedes Vertical precedes Cant in `IfcRelNests`
+
+### Semantic vs Geometric Representation
+
+The IFC 4.3 alignment model has TWO parallel structures:
+
+**Semantic (Business Logic)**:
+- `IfcAlignmentHorizontalSegment` with `PredefinedType` (LINE, CIRCULARARC, CLOTHOID, etc.)
+- Contains design parameters (radius, length, direction)
+
+**Geometric (Shape)**:
+- `IfcCurveSegment` → `ParentCurve` (IfcLine, IfcCircle, IfcClothoid)
+- Combined into `IfcCompositeCurve` (horizontal) or `IfcGradientCurve` (vertical)
+
+**Mapping Table:**
+| Business Logic (Semantic) | Geometric ParentCurve |
+|---------------------------|----------------------|
+| LINE | IfcLine |
+| CIRCULARARC | IfcCircle |
+| CLOTHOID | IfcClothoid |
+| CUBIC | IfcPolynomialCurve |
+| PARABOLICARC | IfcPolynomialCurve |
+| CONSTANTGRADIENT | IfcLine |
+
+### The 2.5D Layered Model
+
+IFC alignments use a "2.5D" approach - combining multiple 2D curves:
+
+1. **Layer 1 - Horizontal**: `IfcCompositeCurve` in X-Y (Easting-Northing) plane
+2. **Layer 2 - Vertical**: `IfcGradientCurve` in "Distance Along, Elevation" coordinate system
+ - `BaseCurve` attribute points to horizontal `IfcCompositeCurve`
+3. **Layer 3 - Cant**: `IfcSegmentedReferenceCurve` for superelevation
+ - `BaseCurve` typically points to `IfcGradientCurve`
+
+---
+
+## Horizontal Alignment Implementation
+
+### PI-Driven Design Approach
+
+Saikei Civil uses the **Point of Intersection (PI)** method, matching professional workflows in Civil 3D and OpenRoads:
+
+```
+ PI● is just an intersection point
+ ╲
+ ╲ Tangent
+ BC (Begin Curve)
+ ╲ ╱
+ ● Curve (R=150m)
+ ╱ ╲
+ EC (End Curve)
+ ╱ Tangent
+ ╱
+```
+
+**Key Distinction**:
+- PIs are just intersection points (NO radius property)
+- Curves are separate entities inserted between tangents (Curves HAVE radius)
+
+### Core Classes
+
+```python
+class HorizontalAlignmentManager:
+ """Core engine for horizontal alignment"""
+ def add_pi(self, x: float, y: float) -> PI
+ def insert_curve(self, pi_index: int, radius: float) -> Curve
+ def generate_segments(self) -> List[Segment]
+
+class PI:
+ """Point of Intersection - just a position"""
+ position: Tuple[float, float]
+ # NO radius property
+
+class Curve:
+ """Curve inserted at a PI"""
+ radius: float
+ bc: Tuple[float, float] # Begin Curve point
+ ec: Tuple[float, float] # End Curve point
+ at_pi: int # Which PI this curve is at
+```
+
+### Civil Engineering Mathematics
+
+**Deflection Angle:**
+```
+Δ = arccos(t₁ · t₂)
+```
+Where t₁ and t₂ are normalized incoming/outgoing tangent vectors.
+
+**Tangent Length:**
+```
+T = R × tan(Δ/2)
+```
+
+**Arc Length:**
+```
+L = R × Δ (radians)
+```
+
+**BC/EC Points:**
+```
+BC = PI - t₁ × T
+EC = PI + t₂ × T
+```
+
+### Segment Generation
+
+```python
+def regenerate_segments(self):
+ """Auto-generate tangent and curve segments from PIs"""
+ segments = []
+
+ for i, pi in enumerate(self.pis[:-1]):
+ next_pi = self.pis[i + 1]
+
+ # Check if curve at this PI
+ curve = self.get_curve_at_pi(i)
+
+ if curve:
+ # Add incoming tangent (trimmed to BC)
+ segments.append(create_line_segment(prev_end, curve.bc))
+ # Add curve
+ segments.append(create_arc_segment(curve))
+ prev_end = curve.ec
+ else:
+ # Add full tangent
+ segments.append(create_line_segment(prev_end, next_pi.position))
+ prev_end = next_pi.position
+
+ return segments
+```
+
+---
+
+## Visualization Layer
+
+### AlignmentVisualizer Class
+
+```python
+class AlignmentVisualizer:
+ """Create Blender visualization of IFC alignment"""
+
+ def __init__(self, native_alignment):
+ self.alignment = native_alignment
+ self.collection = None
+ self.pi_objects = []
+ self.segment_objects = []
+
+ def visualize_all(self):
+ """Generate complete visualization"""
+ self.setup_collection()
+ self.create_pi_markers()
+ self.create_segment_curves()
+```
+
+### Color Coding Convention
+
+**PI Markers (Empties):**
+- 🟢 Green = Tangent points (no curve)
+- 🟠 Orange = Curve PIs (curve inserted)
+
+**Segment Objects (Curves):**
+- 🔵 Blue = Tangent segments (LINE)
+- 🔴 Red = Circular arcs (CIRCULARARC)
+
+### Object Linking Pattern
+
+```python
+# Every Blender object stores only 3 properties
+obj["ifc_definition_id"] = entity.id()
+obj["ifc_class"] = entity.is_a()
+obj["GlobalId"] = entity.GlobalId
+
+# All other data comes from IFC
+entity = NativeIfcManager.get_entity(obj)
+params = entity.DesignParameters # Real data from IFC!
+```
+
+---
+
+## Rick Brice's IfcOpenShell Alignment API
+
+Rick Brice's alignment API was merged into IfcOpenShell v0.8.0 (PR #6234, March 14, 2025). This API provides comprehensive Python functions for IFC alignment creation.
+
+### Key Functions
+
+**Creation:**
+```python
+import ifcopenshell.api.alignment as align_api
+
+# Create alignment with PI method
+alignment = align_api.create_by_pi_method(
+ ifc_file,
+ name='Main Alignment',
+ hpoints=[(0,0), (100,50), (200,100)], # Horizontal PI coordinates
+ radii=[0, 150, 200], # Curve radii at PIs
+ start_station=0.0
+)
+
+# Create alignment structure
+alignment = align_api.create(
+ ifc_file,
+ name="Highway 101",
+ horizontal_layout=horiz,
+ vertical_layout=vert # Optional
+)
+```
+
+**Layout Functions:**
+```python
+# Add to existing horizontal
+align_api.layout_horizontal_alignment_by_pi_method(
+ ifc_file, horiz_alignment, hpoints, radii
+)
+
+# Add vertical layout
+align_api.add_vertical_layout(alignment, vertical_layout)
+
+# Add zero-length terminator (required!)
+align_api.add_zero_length_segment(layout)
+```
+
+**Getters:**
+```python
+horiz = align_api.get_horizontal_layout(alignment)
+vert = align_api.get_vertical_layout(alignment)
+segments = align_api.get_layout_segments(layout)
+curve = align_api.get_layout_curve(layout) # IfcCompositeCurve/IfcGradientCurve
+```
+
+### Integration Strategy
+
+**Recommended architecture for Saikei contributions:**
+
+| Layer | Rick's API Responsibility | Saikei's Responsibility |
+|-------|--------------------------|------------------------|
+| IFC Backend | All IFC entity creation, relationships, geometric representations | None - use Rick's API |
+| Business Logic | Alignment math, segment generation, stationing | Design validation, AASHTO rules |
+| UI Layer | None (Python API only) | Full Blender panels, operators |
+| Visualization | None | Real-time 3D preview, PI markers |
+
+### Example Integration Pattern
+
+```python
+# Saikei Civil operator using Rick's API backend
+import ifcopenshell.api.alignment as align_api
+
+class SAIKEI_OT_create_alignment(bpy.types.Operator):
+ def execute(self, context):
+ # Get PI data from Blender UI
+ hpoints = [(pi.x, pi.y) for pi in context.scene.saikei_pis]
+ radii = [pi.radius for pi in context.scene.saikei_pis]
+
+ # Use Rick's API for IFC creation
+ alignment = align_api.create_by_pi_method(
+ self.ifc_file,
+ name='Main Alignment',
+ hpoints=hpoints,
+ radii=radii,
+ start_station=context.scene.saikei_start_station
+ )
+
+ # Saikei handles visualization
+ self.visualizer.update_from_ifc(alignment)
+ return {'FINISHED'}
+```
+
+---
+
+## Validation and Compliance
+
+### buildingSMART Validation
+
+Saikei Civil has undergone extensive validation testing with buildingSMART International. Key compliance requirements:
+
+1. **Zero-Length Terminal Segments**: Every layout must end with a zero-length segment
+2. **Spatial Hierarchy**: Proper `IfcRelAggregates` and `IfcRelContainedInSpatialStructure`
+3. **Segment Continuity**: End of segment N must match start of segment N+1 (< 0.001m tolerance)
+4. **Ordered Nesting**: Segments in correct order via `IfcRelNests.RelatedObjects`
+
+### Validation Code Pattern
+
+```python
+def validate_alignment(alignment):
+ """Validate IFC alignment structure"""
+ errors = []
+ warnings = []
+
+ # Check basic structure
+ if not alignment:
+ errors.append("No IfcAlignment entity")
+ return errors, warnings
+
+ # Get horizontal layout
+ horiz = get_horizontal_layout(alignment)
+ if not horiz:
+ errors.append("No IfcAlignmentHorizontal")
+ return errors, warnings
+
+ # Check segments
+ segments = get_layout_segments(horiz)
+ if len(segments) < 2:
+ warnings.append("Need at least 2 segments")
+
+ # Check zero-length terminator
+ last_seg = segments[-1].DesignParameters
+ if last_seg.SegmentLength > 0.0001:
+ errors.append("Missing zero-length terminal segment")
+
+ # Check continuity
+ for i in range(len(segments) - 1):
+ gap = calculate_gap(segments[i], segments[i+1])
+ if gap > 0.001:
+ errors.append(f"Gap of {gap}m between segments {i} and {i+1}")
+
+ return errors, warnings
+```
+
+---
+
+## PR Focus: Horizontal Alignment Visualization
+
+For your first PR focused on horizontal alignment visualization, focus on:
+
+### Core Requirements
+
+1. **Read IFC alignment data** using Rick's API getters
+2. **Generate Blender visualization objects** (curves/empties)
+3. **Link objects to IFC entities** with minimal storage
+4. **Color-code by segment type** (LINE=blue, CIRCULARARC=red)
+5. **Support real-time updates** via depsgraph handlers
+
+### Key Files to Create/Modify
+
+Following Bonsai's architecture with `core/` and `tool/` at package root:
+
+```
+saikei/
+├── core/ # NEW - Pure Python (NO bpy)
+│ ├── __init__.py
+│ └── alignment.py # Business logic, math, validation
+├── tool/ # NEW - Blender implementations
+│ ├── __init__.py
+│ └── alignment.py # Blender object creation, linking
+└── civil/ # EXISTING - UI layer
+ └── module/
+ └── alignment/
+ ├── __init__.py # Registration
+ ├── operator.py # Operators call core.*, passing tool.*
+ ├── ui.py # UI panels
+ ├── prop.py # PropertyGroups
+ └── data.py # UI data caching
+```
+
+### Minimal Visualization Implementation
+
+```python
+def visualize_horizontal_alignment(alignment, collection):
+ """Create Blender visualization from IFC alignment"""
+
+ # Get segments from IFC
+ horiz = align_api.get_horizontal_layout(alignment)
+ segments = align_api.get_layout_segments(horiz)
+
+ objects = []
+ for i, segment in enumerate(segments):
+ params = segment.DesignParameters
+
+ if params.PredefinedType == "LINE":
+ obj = create_line_curve(params, name=f"Tangent_{i}")
+ set_material_color(obj, BLUE)
+ elif params.PredefinedType == "CIRCULARARC":
+ obj = create_arc_curve(params, name=f"Curve_{i}")
+ set_material_color(obj, RED)
+
+ # Link to IFC
+ obj["ifc_definition_id"] = segment.id()
+ obj["ifc_class"] = segment.is_a()
+ obj["GlobalId"] = segment.GlobalId
+
+ collection.objects.link(obj)
+ objects.append(obj)
+
+ return objects
+```
+
+---
+
+## Development Guidelines
+
+### Code Style
+- Follow PEP 8
+- Type annotations for all public functions
+- Docstrings with examples
+- No `bpy` imports in `core/` modules
+
+### Testing
+- Unit tests for all core logic (outside Blender)
+- Integration tests with sample IFC files
+- Validation against buildingSMART checker
+
+### Commit Messages
+```
+feat(alignment): Add horizontal alignment visualization
+
+- Create AlignmentVisualizer class for Blender curve generation
+- Support LINE and CIRCULARARC segment types
+- Implement IFC entity linking pattern
+- Add color coding by segment type
+
+Refs: #123
+```
+
+---
+
+## Resources
+
+### Documentation
+- IFC 4.3 Specification: https://ifc43-docs.standards.buildingsmart.org/
+- IfcOpenShell Docs: https://docs.ifcopenshell.org/
+- Bonsai Wiki: https://wiki.osarch.org/
+
+### Community
+- OSArch Forum: https://community.osarch.org/
+- IfcOpenShell GitHub: https://github.com/IfcOpenShell/IfcOpenShell
+- buildingSMART Forums: https://forums.buildingsmart.org/
+
+### Key Contacts
+- **Rick Brice** (WSDOT) - IfcOpenShell alignment API author
+- **Dion Moult** - Bonsai BIM founder
+- **Will Sharp** (HDR) - buildingSMART committee co-chair
+
+---
+
+## Quick Reference
+
+### Entity Creation Pattern
+```python
+# Always create IFC first, then Blender, then link
+entity = ifc.create_entity("IfcAlignmentSegment", ...)
+obj = bpy.data.objects.new("Segment", curve_data)
+obj["ifc_definition_id"] = entity.id()
+```
+
+### Segment Types
+- `LINE` → IfcLine (blue visualization)
+- `CIRCULARARC` → IfcCircle (red visualization)
+- `CLOTHOID` → IfcClothoid (future)
+- `CONSTANTGRADIENT` → IfcLine (vertical)
+- `PARABOLICARC` → IfcPolynomialCurve (vertical)
+
+### Required Terminal Segment
+```python
+# Last segment MUST be zero-length
+align_api.add_zero_length_segment(layout)
+```
+
+### Minimal Object Storage
+```python
+# ONLY these 3 properties in Blender
+obj["ifc_definition_id"] = entity.id()
+obj["ifc_class"] = entity.is_a()
+obj["GlobalId"] = entity.GlobalId
+```
+
+---
+
+*Document Version: 1.1*
+*Updated: January 2026*
+*Changes: Updated architecture to match Bonsai's actual pattern (core/ and tool/ at package root)*
+*For: Saikei Civil IfcOpenShell PR - Horizontal Alignment Visualization*
diff --git a/src/saikei/saikei/__init__.py b/src/saikei/saikei/__init__.py
new file mode 100644
index 0000000000..b54698af92
--- /dev/null
+++ b/src/saikei/saikei/__init__.py
@@ -0,0 +1,54 @@
+# ==============================================================================
+# 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
+# ==============================================================================
+
+
+"""
+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()
diff --git a/src/saikei/saikei/blender_manifest.toml b/src/saikei/saikei/blender_manifest.toml
new file mode 100644
index 0000000000..5738363d33
--- /dev/null
+++ b/src/saikei/saikei/blender_manifest.toml
@@ -0,0 +1,22 @@
+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"
diff --git a/src/saikei/saikei/civil/__init__.py b/src/saikei/saikei/civil/__init__.py
new file mode 100644
index 0000000000..850536d050
--- /dev/null
+++ b/src/saikei/saikei/civil/__init__.py
@@ -0,0 +1,79 @@
+# ==============================================================================
+# 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
+# ==============================================================================
+
+
+"""
+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)
diff --git a/src/saikei/saikei/civil/handler.py b/src/saikei/saikei/civil/handler.py
new file mode 100644
index 0000000000..cd4a6a1bf0
--- /dev/null
+++ b/src/saikei/saikei/civil/handler.py
@@ -0,0 +1,29 @@
+# ==============================================================================
+# 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
+# ==============================================================================
+
+
+"""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
diff --git a/src/saikei/saikei/civil/module/__init__.py b/src/saikei/saikei/civil/module/__init__.py
new file mode 100644
index 0000000000..e143960a88
--- /dev/null
+++ b/src/saikei/saikei/civil/module/__init__.py
@@ -0,0 +1,21 @@
+# ==============================================================================
+# 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
+# ==============================================================================
+
+
+"""Feature modules for Saikei Civil"""
diff --git a/src/saikei/saikei/civil/module/alignment/__init__.py b/src/saikei/saikei/civil/module/alignment/__init__.py
new file mode 100644
index 0000000000..97b9532e47
--- /dev/null
+++ b/src/saikei/saikei/civil/module/alignment/__init__.py
@@ -0,0 +1,106 @@
+# ==============================================================================
+# 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
+# ==============================================================================
+
+
+"""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
diff --git a/src/saikei/saikei/civil/module/alignment/data.py b/src/saikei/saikei/civil/module/alignment/data.py
new file mode 100644
index 0000000000..1a4dc6d3d7
--- /dev/null
+++ b/src/saikei/saikei/civil/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/saikei/saikei/civil/module/alignment/operator.py b/src/saikei/saikei/civil/module/alignment/operator.py
new file mode 100644
index 0000000000..6950d8a5fa
--- /dev/null
+++ b/src/saikei/saikei/civil/module/alignment/operator.py
@@ -0,0 +1,1659 @@
+# ==============================================================================
+# 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
+# ==============================================================================
+
+
+"""Operators for the alignment module
+
+These operators wrap ifcopenshell.api.alignment functions and integrate
+with Bonsai for IFC file management.
+
+Architecture (following Bonsai pattern):
+- Operators call core functions, passing tool implementations
+- core/ contains pure Python logic (no bpy)
+- tool/ contains Blender implementations
+"""
+
+import math
+
+import bpy
+from bpy.types import Operator
+from bpy.props import StringProperty, FloatProperty, IntProperty
+from bpy_extras.io_utils import ImportHelper
+
+import ifcopenshell.api.alignment
+
+# Import from Saikei's layered architecture (relative imports)
+# From civil/module/alignment/operator.py -> go up 4 levels to package root
+from .... import tool
+from ....core import alignment as core
+
+
+def poll_ifc4x3(cls, context):
+ """Standard poll method for IFC4X3 requirement"""
+ ifc = tool.Alignment.get_ifc_file()
+ if ifc is None:
+ cls.poll_message_set("No IFC file loaded. Open an IFC file via Bonsai.")
+ return False
+ if ifc.schema != "IFC4X3":
+ cls.poll_message_set(f"Schema is {ifc.schema}. Alignments require IFC4X3.")
+ return False
+ return True
+
+
+def get_alignment_by_id(ifc, alignment_id):
+ """Safely get an alignment by ID, returning None if not found.
+
+ This handles the case where the IFC entity no longer exists
+ (e.g., after undo or external modification).
+ """
+ if alignment_id == 0:
+ return None
+ try:
+ entity = ifc.by_id(alignment_id)
+ # Verify it's actually an alignment
+ if entity and entity.is_a("IfcAlignment"):
+ return entity
+ return None
+ except RuntimeError:
+ # Entity not found in IFC file
+ return None
+
+
+def clear_invalid_alignment_reference(props):
+ """Clear active alignment reference if it's invalid."""
+ props.active_alignment_id = 0
+ props.active_alignment_name = ""
+
+
+def sync_pis_from_ifc(props):
+ """Sync PI Editor data from IFC alignment.
+
+ This is called on undo/redo to ensure the PI Editor reflects the current
+ IFC state. It extracts PI data from the alignment's horizontal segments.
+
+ If no active alignment exists or it's invalid, clears the PI Editor.
+
+ Returns:
+ bool: True if sync was successful, False if alignment was cleared.
+ """
+ ifc = tool.Alignment.get_ifc_file()
+ if ifc is None:
+ # No IFC file - clear everything
+ props.pis.clear()
+ props.active_pi_index = 0
+ clear_invalid_alignment_reference(props)
+ rebuild_display_rows(props)
+ return False
+
+ if props.active_alignment_id == 0:
+ # No active alignment - just rebuild display
+ rebuild_display_rows(props)
+ return True
+
+ alignment = get_alignment_by_id(ifc, props.active_alignment_id)
+ if alignment is None:
+ # Alignment no longer exists - clear everything
+ props.pis.clear()
+ props.active_pi_index = 0
+ clear_invalid_alignment_reference(props)
+ rebuild_display_rows(props)
+ return False
+
+ # Alignment exists - extract PI data from IFC segments
+ h_layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
+ if not h_layout:
+ # No horizontal layout - rebuild display with current props
+ rebuild_display_rows(props)
+ return True
+
+ segments = ifcopenshell.api.alignment.get_layout_segments(h_layout)
+ if not segments:
+ # No segments - rebuild display with current props
+ rebuild_display_rows(props)
+ return True
+
+ # Extract PIs from segment data
+ # This reconstructs approximate PIs from the IFC segment geometry
+ extracted_pis = _extract_pis_from_segments(segments)
+
+ if not extracted_pis:
+ # Couldn't extract - keep current props.pis
+ rebuild_display_rows(props)
+ return True
+
+ # Update props.pis with extracted data
+ props.pis.clear()
+ for pi_data in extracted_pis:
+ pi = props.pis.add()
+ pi.x = pi_data["x"]
+ pi.y = pi_data["y"]
+ pi.pi_type = pi_data["pi_type"]
+ pi.radius = pi_data.get("radius", 0.0)
+
+ props.active_pi_index = 0
+
+ # Recalculate geometry and rebuild display
+ recalculate_pi_geometry(props)
+ return True
+
+
+def _extract_pis_from_segments(segments):
+ """Extract PI data from IFC alignment segments.
+
+ This reconstructs PI coordinates and types from the horizontal segment
+ design parameters. It handles:
+ - LINE segments (tangent lines)
+ - CIRCULARARC segments (horizontal curves)
+
+ Args:
+ segments: List of IfcAlignmentSegment entities
+
+ Returns:
+ List of dicts with keys: x, y, pi_type, radius (optional)
+ """
+ pis = []
+
+ # Filter out zero-length terminal segments
+ real_segments = []
+ for seg in segments:
+ if hasattr(seg, "DesignParameters") and seg.DesignParameters:
+ dp = seg.DesignParameters
+ if dp.SegmentLength > 0.0001:
+ real_segments.append(seg)
+
+ if not real_segments:
+ return []
+
+ # Track which segments are curves and their indices
+ curve_indices = set()
+ for i, seg in enumerate(real_segments):
+ dp = seg.DesignParameters
+ if dp.PredefinedType == "CIRCULARARC":
+ curve_indices.add(i)
+
+ # First PI: start of first segment
+ first_dp = real_segments[0].DesignParameters
+ start_coords = first_dp.StartPoint.Coordinates
+ pis.append(
+ {
+ "x": float(start_coords[0]),
+ "y": float(start_coords[1]),
+ "pi_type": "ENDPOINT",
+ "radius": 0.0,
+ }
+ )
+
+ # Process interior points
+ i = 0
+ while i < len(real_segments):
+ dp = real_segments[i].DesignParameters
+
+ if dp.PredefinedType == "CIRCULARARC":
+ # This is a curve - calculate PI from curve geometry
+ # PI is at the intersection of incoming and outgoing tangents
+ pi_data = _calculate_pi_from_curve(real_segments, i)
+ if pi_data:
+ pis.append(pi_data)
+ i += 1
+ elif dp.PredefinedType == "LINE":
+ # Check if next segment is also a LINE (sharp angle, no curve)
+ if i < len(real_segments) - 1:
+ next_dp = real_segments[i + 1].DesignParameters
+ if next_dp.PredefinedType == "LINE":
+ # End of this LINE is a PI with no curve
+ end_coords = _calculate_segment_endpoint(dp)
+ pis.append(
+ {
+ "x": float(end_coords[0]),
+ "y": float(end_coords[1]),
+ "pi_type": "TANGENT",
+ "radius": 0.0,
+ }
+ )
+ i += 1
+ else:
+ # Other segment type - skip for now
+ i += 1
+
+ # Last PI: end of last segment
+ last_dp = real_segments[-1].DesignParameters
+ end_coords = _calculate_segment_endpoint(last_dp)
+ # Only add if it's different from the last PI we added
+ if pis:
+ last_pi = pis[-1]
+ dist = math.sqrt((end_coords[0] - last_pi["x"]) ** 2 + (end_coords[1] - last_pi["y"]) ** 2)
+ if dist > 0.001: # More than 1mm apart
+ pis.append(
+ {
+ "x": float(end_coords[0]),
+ "y": float(end_coords[1]),
+ "pi_type": "ENDPOINT",
+ "radius": 0.0,
+ }
+ )
+
+ return pis
+
+
+def _calculate_segment_endpoint(design_params):
+ """Calculate the endpoint of a horizontal segment.
+
+ Args:
+ design_params: IfcAlignmentHorizontalSegment
+
+ Returns:
+ Tuple (x, y) of endpoint coordinates
+ """
+ start = design_params.StartPoint.Coordinates
+ start_x = float(start[0])
+ start_y = float(start[1])
+
+ # StartDirection is in radians (counter-clockwise from east)
+ direction = float(design_params.StartDirection)
+ length = float(design_params.SegmentLength)
+
+ if design_params.PredefinedType == "LINE":
+ # Simple line endpoint
+ end_x = start_x + length * math.cos(direction)
+ end_y = start_y + length * math.sin(direction)
+ return (end_x, end_y)
+
+ elif design_params.PredefinedType == "CIRCULARARC":
+ # Arc endpoint calculation
+ radius = abs(float(design_params.StartRadiusOfCurvature or design_params.EndRadiusOfCurvature or 0))
+ if radius == 0:
+ # Fallback to line calculation
+ end_x = start_x + length * math.cos(direction)
+ end_y = start_y + length * math.sin(direction)
+ return (end_x, end_y)
+
+ # Determine curve direction (clockwise or counter-clockwise)
+ start_radius = design_params.StartRadiusOfCurvature
+ is_clockwise = start_radius is not None and start_radius < 0
+
+ # Arc length to angle: theta = L / R
+ theta = length / radius
+
+ if is_clockwise:
+ # Center is to the right of start direction
+ center_dir = direction - math.pi / 2
+ end_dir = direction - theta
+ else:
+ # Center is to the left of start direction
+ center_dir = direction + math.pi / 2
+ end_dir = direction + theta
+
+ # Calculate center
+ center_x = start_x + radius * math.cos(center_dir)
+ center_y = start_y + radius * math.sin(center_dir)
+
+ # Calculate endpoint
+ if is_clockwise:
+ end_x = center_x + radius * math.cos(end_dir + math.pi / 2)
+ end_y = center_y + radius * math.sin(end_dir + math.pi / 2)
+ else:
+ end_x = center_x + radius * math.cos(end_dir - math.pi / 2)
+ end_y = center_y + radius * math.sin(end_dir - math.pi / 2)
+
+ return (end_x, end_y)
+
+ else:
+ # Unknown type - linear approximation
+ end_x = start_x + length * math.cos(direction)
+ end_y = start_y + length * math.sin(direction)
+ return (end_x, end_y)
+
+
+def _calculate_pi_from_curve(segments, curve_index):
+ """Calculate the PI point from a curve segment.
+
+ The PI is at the intersection of the incoming and outgoing tangents.
+ For a circular arc: PI = PC + T * incoming_tangent = PT + T * (-outgoing_tangent)
+ where T = R * tan(delta/2).
+
+ Args:
+ segments: List of all segments
+ curve_index: Index of the curve segment
+
+ Returns:
+ Dict with PI data, or None if can't calculate
+ """
+ curve_seg = segments[curve_index]
+ curve_dp = curve_seg.DesignParameters
+
+ if curve_dp.PredefinedType != "CIRCULARARC":
+ return None
+
+ # Get curve parameters
+ pc_coords = curve_dp.StartPoint.Coordinates
+ pc_x = float(pc_coords[0])
+ pc_y = float(pc_coords[1])
+
+ start_dir = float(curve_dp.StartDirection) # Incoming tangent direction
+ arc_length = float(curve_dp.SegmentLength)
+
+ radius = abs(float(curve_dp.StartRadiusOfCurvature or curve_dp.EndRadiusOfCurvature or 0))
+ if radius == 0:
+ return None
+
+ # Determine if clockwise
+ start_radius = curve_dp.StartRadiusOfCurvature
+ is_clockwise = start_radius is not None and start_radius < 0
+
+ # Calculate deflection angle from arc length: delta = L / R
+ delta = arc_length / radius
+
+ # Calculate tangent length: T = R * tan(delta/2)
+ tangent_length = radius * math.tan(delta / 2)
+
+ # PI = PC + T * incoming_tangent_unit_vector
+ pi_x = pc_x + tangent_length * math.cos(start_dir)
+ pi_y = pc_y + tangent_length * math.sin(start_dir)
+
+ return {
+ "x": pi_x,
+ "y": pi_y,
+ "pi_type": "CURVE",
+ "radius": radius,
+ }
+
+
+# =============================================================================
+# Curve Geometry Helper Functions
+# =============================================================================
+
+
+def compute_deflection_angle(prev_pi, curr_pi, next_pi):
+ """Compute the deflection angle at a PI point.
+
+ Args:
+ prev_pi: Previous PI (with x, y attributes)
+ curr_pi: Current PI (with x, y attributes)
+ next_pi: Next PI (with x, y attributes)
+
+ Returns:
+ Deflection angle in radians (signed: positive=left, negative=right)
+ """
+ # Incoming tangent direction
+ dx1 = curr_pi.x - prev_pi.x
+ dy1 = curr_pi.y - prev_pi.y
+ angle1 = math.atan2(dy1, dx1)
+
+ # Outgoing tangent direction
+ dx2 = next_pi.x - curr_pi.x
+ dy2 = next_pi.y - curr_pi.y
+ angle2 = math.atan2(dy2, dx2)
+
+ # Deflection angle
+ deflection = angle2 - angle1
+
+ # Normalize to [-pi, pi]
+ while deflection > math.pi:
+ deflection -= 2 * math.pi
+ while deflection < -math.pi:
+ deflection += 2 * math.pi
+
+ return deflection
+
+
+def compute_arc_length_for_pi(props, pi_index):
+ """Compute arc length for a curve at the given PI.
+
+ Arc length L = R * |delta| where delta is the deflection angle.
+
+ Args:
+ props: SaikeiAlignmentProperties
+ pi_index: Index of the PI with the curve
+
+ Returns:
+ Arc length in same units as radius (meters)
+ """
+ pis = props.pis
+ if pi_index <= 0 or pi_index >= len(pis) - 1:
+ return 0.0
+
+ prev_pi = pis[pi_index - 1]
+ curr_pi = pis[pi_index]
+ next_pi = pis[pi_index + 1]
+
+ if curr_pi.radius <= 0:
+ return 0.0
+
+ deflection = compute_deflection_angle(prev_pi, curr_pi, next_pi)
+ return curr_pi.radius * abs(deflection)
+
+
+def compute_tangent_length_at_pi(props, pi_index):
+ """Compute the tangent length T at a PI with a curve.
+
+ Tangent length T = R * tan(|delta|/2)
+
+ Args:
+ props: SaikeiAlignmentProperties
+ pi_index: Index of the PI with the curve
+
+ Returns:
+ Tangent length (distance from PI to PC or PT)
+ """
+ pis = props.pis
+ if pi_index <= 0 or pi_index >= len(pis) - 1:
+ return 0.0
+
+ prev_pi = pis[pi_index - 1]
+ curr_pi = pis[pi_index]
+ next_pi = pis[pi_index + 1]
+
+ if curr_pi.radius <= 0:
+ return 0.0
+
+ deflection = compute_deflection_angle(prev_pi, curr_pi, next_pi)
+ return curr_pi.radius * math.tan(abs(deflection) / 2)
+
+
+def compute_segment_length(props, start_pi_index, account_for_curves=True):
+ """Compute the length of a tangent segment between two PIs.
+
+ If curves exist at the start or end PI, the segment is shortened
+ to PC (Point of Curvature) or PT (Point of Tangency).
+
+ Args:
+ props: SaikeiAlignmentProperties
+ start_pi_index: Index of the starting PI
+ account_for_curves: If True, subtract tangent lengths for adjacent curves
+
+ Returns:
+ Segment length in meters
+ """
+ pis = props.pis
+ if start_pi_index < 0 or start_pi_index >= len(pis) - 1:
+ return 0.0
+
+ start_pi = pis[start_pi_index]
+ end_pi = pis[start_pi_index + 1]
+
+ # Full length between PIs
+ dx = end_pi.x - start_pi.x
+ dy = end_pi.y - start_pi.y
+ full_length = math.sqrt(dx * dx + dy * dy)
+
+ if not account_for_curves:
+ return full_length
+
+ # Subtract tangent length if start PI has a curve (segment starts at PT)
+ if start_pi_index > 0 and start_pi.radius > 0:
+ full_length -= compute_tangent_length_at_pi(props, start_pi_index)
+
+ # Subtract tangent length if end PI has a curve (segment ends at PC)
+ if start_pi_index + 1 < len(pis) - 1 and end_pi.radius > 0:
+ full_length -= compute_tangent_length_at_pi(props, start_pi_index + 1)
+
+ return max(0.0, full_length)
+
+
+def on_radius_changed(pi, context):
+ """Callback when PI radius is changed. Triggers geometry recalculation.
+
+ This is called from the AlignmentPI.radius property's update callback.
+ When a radius is entered on a Mid point, this triggers:
+ 1. Recalculation of PI geometry (lengths, stations)
+ 2. Rebuild of display_rows (Mid point becomes Curve segment)
+ 3. If an active alignment exists, regeneration of IFC entities
+ """
+ props = context.scene.SaikeiAlignmentProperties
+ recalculate_pi_geometry(props)
+
+ # If there's an active alignment, trigger IFC regeneration
+ # This is handled by recalculate_pi_geometry when active_alignment_id is set
+
+
+def recalculate_pi_geometry(props):
+ """Recalculate lengths and stations for all PIs using core logic."""
+ pis = props.pis
+ if len(pis) < 2:
+ rebuild_display_rows(props)
+ return
+
+ # Extract PI coordinates for pure Python calculation
+ pi_coords = [(pi.x, pi.y) for pi in pis]
+
+ # Use core function for calculation
+ result = core.calculate_pi_geometry(pi_coords, props.start_station)
+
+ # Update Blender properties with results
+ tool.Alignment.update_pi_properties(props, result)
+
+ # Rebuild the display rows for the interleaved table view
+ rebuild_display_rows(props)
+
+
+def rebuild_display_rows(props):
+ """Rebuild the display_rows collection from the pis collection.
+
+ Creates an interleaved view of points and segments in Civil 3D style:
+ End point (POB)
+ Tangent segment 1
+ Mid point (or Curve segment if radius > 0)
+ Tangent segment 2
+ End point (POE)
+
+ When a Mid point has a curve (radius > 0), it becomes a Curve segment row
+ instead of a point row, showing PI coordinates + arc length + radius.
+ """
+ props.display_rows.clear()
+
+ pis = props.pis
+ if len(pis) == 0:
+ return
+
+ segment_num = 0
+ i = 0
+
+ while i < len(pis):
+ pi = pis[i]
+ is_interior = i > 0 and i < len(pis) - 1
+ has_curve = is_interior and pi.radius > 0
+
+ if has_curve:
+ # Interior PI with curve: becomes a CURVE SEGMENT row
+ # This replaces what would have been a Mid point row
+ segment_num += 1
+ curve_row = props.display_rows.add()
+ curve_row.row_type = "SEGMENT"
+ curve_row.segment_number = segment_num
+ curve_row.pi_index = i
+ curve_row.display_type = "Curve"
+ curve_row.x = pi.x # Show PI coordinates on curve row
+ curve_row.y = pi.y
+ curve_row.radius = pi.radius
+ curve_row.arc_length = compute_arc_length_for_pi(props, i)
+ else:
+ # Regular point row (End or Mid without curve)
+ point_row = props.display_rows.add()
+ point_row.row_type = "POINT"
+ point_row.pi_index = i
+
+ if pi.pi_type == "ENDPOINT":
+ point_row.display_type = "End"
+ else:
+ point_row.display_type = "Mid"
+
+ point_row.x = pi.x
+ point_row.y = pi.y
+
+ # Add tangent segment row after this point/curve (except after last PI)
+ if i < len(pis) - 1:
+ # Check if next PI also has a curve (affects segment length calculation)
+ next_pi = pis[i + 1]
+ next_has_curve = (i + 1 < len(pis) - 1) and next_pi.radius > 0
+
+ segment_num += 1
+ seg_row = props.display_rows.add()
+ seg_row.row_type = "SEGMENT"
+ seg_row.segment_number = segment_num
+ seg_row.pi_index = i
+ seg_row.display_type = "Tan"
+
+ # Compute segment length accounting for curves at either end
+ seg_row.length = compute_segment_length(props, i, account_for_curves=True)
+
+ i += 1
+
+
+# =============================================================================
+# PI Management Operators
+# =============================================================================
+
+
+class SAIKEI_OT_add_pi(Operator):
+ """Add a new PI point to the list"""
+
+ bl_idname = "saikei.add_pi"
+ bl_label = "Add PI"
+ bl_description = "Add a new PI (Point of Intersection) to the alignment"
+ bl_options = {"REGISTER", "UNDO"}
+
+ @classmethod
+ def poll(cls, context):
+ return poll_ifc4x3(cls, context)
+
+ def execute(self, context):
+ props = context.scene.SaikeiAlignmentProperties
+
+ # Add new PI
+ pi = props.pis.add()
+
+ # Set default position based on existing PIs
+ if len(props.pis) == 1:
+ # First PI - start at origin
+ pi.x = 0.0
+ pi.y = 0.0
+ pi.pi_type = "ENDPOINT"
+ elif len(props.pis) == 2:
+ # Second PI - offset from first
+ prev = props.pis[0]
+ pi.x = prev.x + 100.0
+ pi.y = prev.y
+ pi.pi_type = "ENDPOINT"
+ else:
+ # Additional PIs - extrapolate from last two
+ prev = props.pis[-2]
+ prev_prev = props.pis[-3] if len(props.pis) > 2 else prev
+ dx = prev.x - prev_prev.x if len(props.pis) > 2 else 100.0
+ dy = prev.y - prev_prev.y if len(props.pis) > 2 else 0.0
+ pi.x = prev.x + dx
+ pi.y = prev.y + dy
+ pi.pi_type = "TANGENT"
+
+ # Previous endpoint becomes tangent or curve
+ props.pis[-2].pi_type = "TANGENT"
+
+ # Make new PI active
+ props.active_pi_index = len(props.pis) - 1
+
+ # Recalculate geometry
+ recalculate_pi_geometry(props)
+
+ return {"FINISHED"}
+
+
+class SAIKEI_OT_remove_pi(Operator):
+ """Remove the selected PI point"""
+
+ bl_idname = "saikei.remove_pi"
+ bl_label = "Remove PI"
+ bl_description = "Remove the selected PI from the alignment"
+ bl_options = {"REGISTER", "UNDO"}
+
+ @classmethod
+ def poll(cls, context):
+ if not poll_ifc4x3(cls, context):
+ return False
+ props = context.scene.SaikeiAlignmentProperties
+ if len(props.pis) == 0:
+ cls.poll_message_set("No PIs to remove")
+ return False
+ # Check if a POINT row is selected (can't remove from SEGMENT row selection)
+ if props.display_rows:
+ idx = props.active_display_row_index
+ if 0 <= idx < len(props.display_rows):
+ if props.display_rows[idx].row_type != "POINT":
+ cls.poll_message_set("Select a point row to remove")
+ return False
+ return True
+
+ def execute(self, context):
+ props = context.scene.SaikeiAlignmentProperties
+
+ # Get the PI index from the selected display row
+ pi_index = -1
+ if props.display_rows:
+ idx = props.active_display_row_index
+ if 0 <= idx < len(props.display_rows):
+ row = props.display_rows[idx]
+ if row.row_type == "POINT":
+ pi_index = row.pi_index
+
+ # Fallback to active_pi_index if display_rows isn't being used
+ if pi_index < 0:
+ pi_index = props.active_pi_index
+
+ if 0 <= pi_index < len(props.pis):
+ props.pis.remove(pi_index)
+ props.active_pi_index = min(pi_index, len(props.pis) - 1)
+
+ # Recalculate geometry (also rebuilds display_rows)
+ recalculate_pi_geometry(props)
+
+ # Reset display row index to first row if needed
+ if len(props.display_rows) > 0:
+ props.active_display_row_index = min(props.active_display_row_index, len(props.display_rows) - 1)
+ else:
+ props.active_display_row_index = 0
+
+ return {"FINISHED"}
+
+
+class SAIKEI_OT_pick_pi_from_viewport(Operator):
+ """Add PI points by clicking in the 3D viewport"""
+
+ bl_idname = "saikei.pick_pi_from_viewport"
+ bl_label = "Pick PI from Viewport"
+ bl_description = "Click in the viewport to add PI points. Right-click or Escape to finish."
+ bl_options = {"REGISTER", "UNDO"}
+
+ @classmethod
+ def poll(cls, context):
+ return poll_ifc4x3(cls, context)
+
+ def invoke(self, context, event):
+ context.window.cursor_set("CROSSHAIR")
+ context.window_manager.modal_handler_add(self)
+ self.report({"INFO"}, "Click to add PIs. Right-click or Escape to finish.")
+ return {"RUNNING_MODAL"}
+
+ def modal(self, context, event):
+ if event.type == "LEFTMOUSE" and event.value == "PRESS":
+ # Raycast to ground plane (Z=0)
+ coord = self.get_ground_intersection(context, event)
+ if coord:
+ self.add_pi_at_location(context, coord)
+ context.area.tag_redraw()
+ return {"RUNNING_MODAL"}
+
+ elif event.type in {"RIGHTMOUSE", "ESC"}:
+ context.window.cursor_set("DEFAULT")
+ self.report({"INFO"}, "Finished adding PIs")
+ return {"FINISHED"}
+
+ # Allow viewport navigation
+ elif event.type in {"MIDDLEMOUSE", "WHEELUPMOUSE", "WHEELDOWNMOUSE"}:
+ return {"PASS_THROUGH"}
+
+ return {"RUNNING_MODAL"}
+
+ def get_ground_intersection(self, context, event):
+ """Raycast from mouse to Z=0 ground plane"""
+ from bpy_extras.view3d_utils import region_2d_to_origin_3d, region_2d_to_vector_3d
+
+ region = context.region
+ rv3d = context.region_data
+ coord = (event.mouse_region_x, event.mouse_region_y)
+
+ origin = region_2d_to_origin_3d(region, rv3d, coord)
+ direction = region_2d_to_vector_3d(region, rv3d, coord)
+
+ # Intersect with Z=0 plane
+ if direction.z != 0:
+ t = -origin.z / direction.z
+ if t > 0: # In front of camera
+ hit = origin + direction * t
+ return (hit.x, hit.y)
+ return None
+
+ def add_pi_at_location(self, context, coord):
+ """Add a new PI at the given (x, y) coordinate"""
+ props = context.scene.SaikeiAlignmentProperties
+
+ pi = props.pis.add()
+ pi.x = coord[0]
+ pi.y = coord[1]
+
+ # Determine PI type based on position in list
+ if len(props.pis) == 1:
+ pi.pi_type = "ENDPOINT"
+ elif len(props.pis) == 2:
+ pi.pi_type = "ENDPOINT"
+ else:
+ pi.pi_type = "TANGENT"
+ # Previous endpoint becomes tangent
+ if len(props.pis) >= 2:
+ props.pis[-2].pi_type = "TANGENT"
+
+ props.active_pi_index = len(props.pis) - 1
+ recalculate_pi_geometry(props)
+
+
+class SAIKEI_OT_recalculate_pis(Operator):
+ """Recalculate PI geometry and update IFC/visualization"""
+
+ bl_idname = "saikei.recalculate_pis"
+ bl_label = "Recalculate PIs"
+ bl_description = "Recalculate geometry, update IFC segments, and refresh visualization"
+ bl_options = {"REGISTER", "UNDO"}
+
+ @classmethod
+ def poll(cls, context):
+ if not poll_ifc4x3(cls, context):
+ return False
+ props = context.scene.SaikeiAlignmentProperties
+ if len(props.pis) < 2:
+ cls.poll_message_set("Need at least 2 PIs to recalculate")
+ return False
+ return True
+
+ def execute(self, context):
+ ifc = tool.Alignment.get_ifc_file()
+ props = context.scene.SaikeiAlignmentProperties
+
+ # Recalculate geometry in UI properties
+ recalculate_pi_geometry(props)
+
+ # If there's an active alignment, recreate it with updated data
+ # We recreate the entire alignment because modifying segments in place
+ # can leave the IFC layout in an inconsistent state
+ if props.active_alignment_id != 0:
+ alignment = get_alignment_by_id(ifc, props.active_alignment_id)
+ if alignment is None:
+ # Alignment no longer exists (e.g., after undo) - clear reference
+ clear_invalid_alignment_reference(props)
+ self.report({"WARNING"}, "Active alignment no longer exists. Reference cleared.")
+ return {"FINISHED"}
+ if alignment:
+ # Save the alignment name
+ alignment_name = alignment.Name or props.new_alignment_name
+
+ # Remove the entire alignment hierarchy (Blender objects)
+ tool.Alignment.remove_alignment_hierarchy(alignment)
+
+ # Remove the IFC alignment entity entirely
+ ifcopenshell.api.run("root.remove_product", ifc, product=alignment)
+
+ # Collect updated PI data
+ hpoints = [(pi.x, pi.y) for pi in props.pis]
+ radii = [pi.radius for pi in props.pis[1:-1]]
+
+ # Create a fresh alignment with the same name
+ # Use safe wrapper to validate/cleanup before creating
+ new_alignment = tool.Alignment.safe_create_alignment_by_pi_method(
+ ifc,
+ name=alignment_name,
+ hpoints=hpoints,
+ radii=radii,
+ start_station=props.start_station,
+ )
+
+ # Create Blender hierarchy for the new alignment
+ tool.Alignment.create_hierarchy_for_alignment(new_alignment)
+
+ # Update the active alignment ID to reference the new entity
+ props.active_alignment_id = new_alignment.id()
+ props.active_alignment_name = alignment_name
+
+ self.report({"INFO"}, f"Updated alignment '{alignment_name}' with {len(hpoints)} PIs")
+ return {"FINISHED"}
+
+ # No active alignment - just report geometry recalculation
+ total_length = sum(pi.length_to_next for pi in props.pis)
+ self.report({"INFO"}, f"Recalculated {len(props.pis)} PIs, total length: {total_length:.2f}")
+ return {"FINISHED"}
+
+
+class SAIKEI_OT_clear_pis(Operator):
+ """Clear all PI points and optionally remove visualization/IFC data"""
+
+ bl_idname = "saikei.clear_pis"
+ bl_label = "Clear All PIs"
+ bl_description = "Remove all PI points and clear segment visualization"
+ bl_options = {"REGISTER", "UNDO"}
+
+ @classmethod
+ def poll(cls, context):
+ if not poll_ifc4x3(cls, context):
+ return False
+ props = context.scene.SaikeiAlignmentProperties
+ if len(props.pis) == 0:
+ cls.poll_message_set("No PIs to clear")
+ return False
+ return True
+
+ def invoke(self, context, event):
+ return context.window_manager.invoke_confirm(self, event)
+
+ def execute(self, context):
+ ifc = tool.Alignment.get_ifc_file()
+ props = context.scene.SaikeiAlignmentProperties
+
+ removed_objects = 0
+
+ # If there's an active alignment, remove it entirely (Blender + IFC)
+ # This ensures we don't leave the IFC in an inconsistent state
+ if props.active_alignment_id != 0:
+ alignment = get_alignment_by_id(ifc, props.active_alignment_id)
+ if alignment:
+ # Remove all Blender objects for this alignment
+ removed_objects = tool.Alignment.remove_alignment_hierarchy(alignment)
+
+ # Remove the IFC alignment entity entirely
+ ifcopenshell.api.run("root.remove_product", ifc, product=alignment)
+
+ # Clear the active alignment reference
+ props.active_alignment_id = 0
+ props.active_alignment_name = ""
+
+ # Clear the PI list in the UI
+ props.pis.clear()
+ props.active_pi_index = 0
+
+ # Clear the display rows
+ props.display_rows.clear()
+ props.active_display_row_index = 0
+
+ if removed_objects > 0:
+ self.report({"INFO"}, f"Cleared all PIs and removed {removed_objects} objects")
+ else:
+ self.report({"INFO"}, "Cleared all PIs")
+ return {"FINISHED"}
+
+
+# =============================================================================
+# Creation Operators
+# =============================================================================
+
+
+class SAIKEI_OT_create_alignment(Operator):
+ """Create a new IFC alignment"""
+
+ bl_idname = "saikei.create_alignment"
+ bl_label = "Create Alignment"
+ bl_description = "Create a new empty IFC alignment"
+ bl_options = {"REGISTER", "UNDO"}
+
+ @classmethod
+ def poll(cls, context):
+ return poll_ifc4x3(cls, context)
+
+ def execute(self, context):
+ ifc = tool.Alignment.get_ifc_file()
+ props = context.scene.SaikeiAlignmentProperties
+
+ alignment = ifcopenshell.api.alignment.create(
+ ifc,
+ name=props.new_alignment_name,
+ )
+
+ # Create full Blender hierarchy (alignment + layouts + segments)
+ obj = tool.Alignment.create_hierarchy_for_alignment(alignment)
+
+ # Update UI
+ props.active_alignment_name = props.new_alignment_name
+ props.active_alignment_id = alignment.id()
+
+ if obj:
+ self.report({"INFO"}, f"Created alignment: {props.new_alignment_name}")
+ else:
+ self.report({"WARNING"}, f"Created IFC alignment but could not create Blender object")
+ return {"FINISHED"}
+
+
+class SAIKEI_OT_create_alignment_by_pi(Operator):
+ """Create alignment using the PI (Point of Intersection) method"""
+
+ bl_idname = "saikei.create_alignment_by_pi"
+ bl_label = "Create by PI Method"
+ bl_description = "Create alignment using PI points and curve radii. If an active alignment exists with no segments, adds to it instead of creating new."
+ bl_options = {"REGISTER", "UNDO"}
+
+ @classmethod
+ def poll(cls, context):
+ if not poll_ifc4x3(cls, context):
+ return False
+ props = context.scene.SaikeiAlignmentProperties
+ if len(props.pis) < 2:
+ cls.poll_message_set("Need at least 2 PI points")
+ return False
+ return True
+
+ def execute(self, context):
+ ifc = tool.Alignment.get_ifc_file()
+ props = context.scene.SaikeiAlignmentProperties
+
+ # Collect PI data
+ hpoints = [(pi.x, pi.y) for pi in props.pis]
+ radii = [pi.radius for pi in props.pis[1:-1]]
+
+ # Check if there's an active alignment we should add to instead of creating new
+ if props.active_alignment_id != 0:
+ existing_alignment = get_alignment_by_id(ifc, props.active_alignment_id)
+ if existing_alignment:
+ h_layout = ifcopenshell.api.alignment.get_horizontal_layout(existing_alignment)
+ if h_layout:
+ # Check if horizontal layout is empty (only has zero-length terminal or no segments)
+ segments = ifcopenshell.api.alignment.get_layout_segments(h_layout)
+ has_real_segments = False
+ for seg in segments:
+ if hasattr(seg, "DesignParameters") and seg.DesignParameters:
+ if seg.DesignParameters.SegmentLength > 0.0001:
+ has_real_segments = True
+ break
+
+ if not has_real_segments:
+ # Use existing alignment - add segments to it
+ # Use safe wrapper to validate layout has parent alignment
+ tool.Alignment.safe_layout_horizontal_by_pi_method(ifc, h_layout, hpoints, radii)
+
+ # Create/update Blender objects for the segments
+ alignment_obj = tool.Ifc.get_object(existing_alignment)
+ h_layout_obj = tool.Ifc.get_object(h_layout)
+
+ if not h_layout_obj and alignment_obj:
+ h_layout_obj = tool.Alignment.create_object_for_layout(h_layout, alignment_obj)
+
+ if h_layout_obj:
+ tool.Alignment.create_objects_for_layout_segments(h_layout, h_layout_obj)
+
+ self.report(
+ {"INFO"}, f"Added {len(hpoints)} PIs to existing alignment '{existing_alignment.Name}'"
+ )
+ return {"FINISHED"}
+
+ # No suitable existing alignment - create a new one
+ # Use safe wrapper to validate/cleanup before creating
+ alignment = tool.Alignment.safe_create_alignment_by_pi_method(
+ ifc,
+ name=props.new_alignment_name,
+ hpoints=hpoints,
+ radii=radii,
+ start_station=props.start_station,
+ )
+
+ # Create full Blender hierarchy (alignment + layouts + segments)
+ obj = tool.Alignment.create_hierarchy_for_alignment(alignment)
+
+ props.active_alignment_name = props.new_alignment_name
+ props.active_alignment_id = alignment.id()
+
+ if obj:
+ self.report({"INFO"}, f"Created new alignment '{props.new_alignment_name}' with {len(hpoints)} PIs")
+ else:
+ self.report({"WARNING"}, f"Created IFC alignment but could not create Blender object")
+ return {"FINISHED"}
+
+
+class SAIKEI_OT_import_alignment_csv(Operator, ImportHelper):
+ """Import alignment from CSV file"""
+
+ bl_idname = "saikei.import_alignment_csv"
+ bl_label = "Import Alignment CSV"
+ bl_description = "Import alignment definition from a CSV file"
+ bl_options = {"REGISTER", "UNDO"}
+
+ filename_ext = ".csv"
+ filter_glob: StringProperty(default="*.csv", options={"HIDDEN"})
+
+ @classmethod
+ def poll(cls, context):
+ return poll_ifc4x3(cls, context)
+
+ def execute(self, context):
+ ifc = tool.Alignment.get_ifc_file()
+ props = context.scene.SaikeiAlignmentProperties
+
+ alignment = ifcopenshell.api.alignment.create_from_csv(ifc, self.filepath)
+
+ # Create full Blender hierarchy (alignment + layouts + segments)
+ obj = tool.Alignment.create_hierarchy_for_alignment(alignment)
+
+ props.active_alignment_name = alignment.Name or "Imported Alignment"
+ props.active_alignment_id = alignment.id()
+
+ self.report({"INFO"}, f"Imported alignment from {self.filepath}")
+ return {"FINISHED"}
+
+
+class SAIKEI_OT_create_alignment_polyline(Operator):
+ """Create alignment as a polyline"""
+
+ bl_idname = "saikei.create_alignment_polyline"
+ bl_label = "Create as Polyline"
+ bl_description = "Create alignment from a polyline (no curves)"
+ bl_options = {"REGISTER", "UNDO"}
+
+ @classmethod
+ def poll(cls, context):
+ return poll_ifc4x3(cls, context)
+
+ def execute(self, context):
+ ifc = tool.Alignment.get_ifc_file()
+ props = context.scene.SaikeiAlignmentProperties
+
+ # Collect points from PIs (no radii)
+ points = [(pi.x, pi.y) for pi in props.pis]
+
+ if len(points) < 2:
+ self.report({"ERROR"}, "Need at least 2 points for polyline")
+ return {"CANCELLED"}
+
+ alignment = ifcopenshell.api.alignment.create_as_polyline(
+ ifc,
+ name=props.new_alignment_name,
+ points=points,
+ )
+
+ # Create full Blender hierarchy (alignment + layouts + segments)
+ obj = tool.Alignment.create_hierarchy_for_alignment(alignment)
+
+ props.active_alignment_name = props.new_alignment_name
+ props.active_alignment_id = alignment.id()
+
+ self.report({"INFO"}, f"Created polyline alignment with {len(points)} points")
+ return {"FINISHED"}
+
+
+class SAIKEI_OT_create_alignment_offset(Operator):
+ """Create alignment as an offset from existing alignment"""
+
+ bl_idname = "saikei.create_alignment_offset"
+ bl_label = "Create as Offset Curve"
+ bl_description = "Create a new alignment offset from an existing alignment"
+ bl_options = {"REGISTER", "UNDO"}
+
+ offset_distance: FloatProperty(
+ name="Offset Distance",
+ description="Distance to offset (positive = right, negative = left)",
+ default=10.0,
+ unit="LENGTH",
+ )
+
+ @classmethod
+ def poll(cls, context):
+ if not poll_ifc4x3(cls, context):
+ return False
+ props = context.scene.SaikeiAlignmentProperties
+ if props.active_alignment_id == 0:
+ cls.poll_message_set("Select an alignment first")
+ return False
+ return True
+
+ def invoke(self, context, event):
+ return context.window_manager.invoke_props_dialog(self)
+
+ def execute(self, context):
+ ifc = tool.Alignment.get_ifc_file()
+ props = context.scene.SaikeiAlignmentProperties
+
+ base_alignment = get_alignment_by_id(ifc, props.active_alignment_id)
+ if base_alignment is None:
+ clear_invalid_alignment_reference(props)
+ self.report({"ERROR"}, "Base alignment no longer exists. Reference cleared.")
+ return {"CANCELLED"}
+
+ alignment = ifcopenshell.api.alignment.create_as_offset_curve(
+ ifc,
+ name=f"{props.new_alignment_name} (Offset)",
+ base_alignment=base_alignment,
+ offset=self.offset_distance,
+ )
+
+ # Create full Blender hierarchy (alignment + layouts + segments)
+ obj = tool.Alignment.create_hierarchy_for_alignment(alignment)
+
+ self.report({"INFO"}, f"Created offset alignment at {self.offset_distance}m")
+ return {"FINISHED"}
+
+
+# =============================================================================
+# Layout Operators
+# =============================================================================
+
+
+class SAIKEI_OT_add_vertical_layout(Operator):
+ """Add vertical layout to an alignment"""
+
+ bl_idname = "saikei.add_vertical_layout"
+ bl_label = "Add Vertical Layout"
+ bl_description = "Add an IfcAlignmentVertical to the active alignment"
+ bl_options = {"REGISTER", "UNDO"}
+
+ @classmethod
+ def poll(cls, context):
+ if not poll_ifc4x3(cls, context):
+ return False
+ props = context.scene.SaikeiAlignmentProperties
+ if props.active_alignment_id == 0:
+ cls.poll_message_set("Select an alignment first")
+ return False
+ return True
+
+ def execute(self, context):
+ ifc = tool.Alignment.get_ifc_file()
+ props = context.scene.SaikeiAlignmentProperties
+
+ alignment = get_alignment_by_id(ifc, props.active_alignment_id)
+ if alignment is None:
+ clear_invalid_alignment_reference(props)
+ self.report({"ERROR"}, "Alignment no longer exists. Reference cleared.")
+ return {"CANCELLED"}
+
+ vertical = ifcopenshell.api.alignment.add_vertical_layout(ifc, alignment)
+
+ self.report({"INFO"}, "Added vertical layout")
+ return {"FINISHED"}
+
+
+class SAIKEI_OT_add_layout_segment(Operator):
+ """Add a segment to an alignment layout"""
+
+ bl_idname = "saikei.add_layout_segment"
+ bl_label = "Add Layout Segment"
+ bl_description = "Add a new segment to the alignment layout"
+ bl_options = {"REGISTER", "UNDO"}
+
+ @classmethod
+ def poll(cls, context):
+ if not poll_ifc4x3(cls, context):
+ return False
+ props = context.scene.SaikeiAlignmentProperties
+ if props.active_alignment_id == 0:
+ cls.poll_message_set("Select an alignment first")
+ return False
+ return True
+
+ def execute(self, context):
+ # This operator would open a dialog for segment parameters
+ self.report({"INFO"}, "Add segment - dialog coming soon")
+ return {"FINISHED"}
+
+
+class SAIKEI_OT_layout_horizontal_by_pi(Operator):
+ """Layout horizontal alignment using PI method"""
+
+ bl_idname = "saikei.layout_horizontal_by_pi"
+ bl_label = "Layout Horizontal by PI"
+ bl_description = "Layout the horizontal alignment using PI points"
+ bl_options = {"REGISTER", "UNDO"}
+
+ @classmethod
+ def poll(cls, context):
+ if not poll_ifc4x3(cls, context):
+ return False
+ props = context.scene.SaikeiAlignmentProperties
+ if props.active_alignment_id == 0:
+ cls.poll_message_set("Select an alignment first")
+ return False
+ if len(props.pis) < 2:
+ cls.poll_message_set("Need at least 2 PI points")
+ return False
+ return True
+
+ def execute(self, context):
+ ifc = tool.Alignment.get_ifc_file()
+ props = context.scene.SaikeiAlignmentProperties
+
+ alignment = get_alignment_by_id(ifc, props.active_alignment_id)
+ if alignment is None:
+ clear_invalid_alignment_reference(props)
+ self.report({"ERROR"}, "Alignment no longer exists. Reference cleared.")
+ return {"CANCELLED"}
+
+ h_layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
+
+ if not h_layout:
+ self.report({"ERROR"}, "Alignment has no horizontal layout")
+ return {"CANCELLED"}
+
+ pis = [(pi.x, pi.y) for pi in props.pis]
+ radii = [pi.radius for pi in props.pis[1:-1]]
+
+ # Create the IFC segments
+ # Use safe wrapper to validate layout has parent alignment
+ tool.Alignment.safe_layout_horizontal_by_pi_method(ifc, h_layout, pis, radii)
+
+ # Find or create Blender object for the horizontal layout
+ # Use Bonsai's Ifc tool (re-exported via saikei.tool)
+ alignment_obj = tool.Ifc.get_object(alignment)
+ h_layout_obj = tool.Ifc.get_object(h_layout)
+
+ if not h_layout_obj and alignment_obj:
+ # Create the horizontal layout object if it doesn't exist
+ h_layout_obj = tool.Alignment.create_object_for_layout(h_layout, alignment_obj)
+
+ # Create Blender objects for the newly created segments
+ if h_layout_obj:
+ tool.Alignment.create_objects_for_layout_segments(h_layout, h_layout_obj)
+
+ self.report({"INFO"}, f"Laid out horizontal alignment with {len(pis)} PIs")
+ return {"FINISHED"}
+
+
+class SAIKEI_OT_layout_vertical_by_pi(Operator):
+ """Layout vertical alignment using PI method"""
+
+ bl_idname = "saikei.layout_vertical_by_pi"
+ bl_label = "Layout Vertical by PI"
+ bl_description = "Layout the vertical alignment using PVI points"
+ bl_options = {"REGISTER", "UNDO"}
+
+ @classmethod
+ def poll(cls, context):
+ if not poll_ifc4x3(cls, context):
+ return False
+ props = context.scene.SaikeiAlignmentProperties
+ if props.active_alignment_id == 0:
+ cls.poll_message_set("Select an alignment first")
+ return False
+ return True
+
+ def execute(self, context):
+ # This would collect vertical PIs and create vertical layout
+ self.report({"INFO"}, "Layout vertical - implementation coming soon")
+ return {"FINISHED"}
+
+
+# =============================================================================
+# Stationing Operators
+# =============================================================================
+
+
+class SAIKEI_OT_add_stationing_referent(Operator):
+ """Add a stationing referent to the alignment"""
+
+ bl_idname = "saikei.add_stationing_referent"
+ bl_label = "Add Stationing Referent"
+ bl_description = "Add an IfcReferent for stationing"
+ bl_options = {"REGISTER", "UNDO"}
+
+ station: FloatProperty(
+ name="Station",
+ description="Station value for the referent (e.g., 10000 for 100+00)",
+ default=10000.0,
+ )
+
+ name: StringProperty(
+ name="Name",
+ description="Name for the referent (leave blank to auto-generate)",
+ default="",
+ )
+
+ @classmethod
+ def poll(cls, context):
+ if not poll_ifc4x3(cls, context):
+ return False
+ props = context.scene.SaikeiAlignmentProperties
+ if props.active_alignment_id == 0:
+ cls.poll_message_set("Select an alignment first")
+ return False
+ return True
+
+ def invoke(self, context, event):
+ # Default station to start_station from props
+ props = context.scene.SaikeiAlignmentProperties
+ self.station = props.start_station
+ return context.window_manager.invoke_props_dialog(self)
+
+ def draw(self, context):
+ layout = self.layout
+ layout.prop(self, "station")
+ layout.prop(self, "name")
+ # Show station notation preview
+ station_str = format_station(self.station)
+ layout.label(text=f"Station notation: {station_str}")
+
+ def execute(self, context):
+ ifc = tool.Alignment.get_ifc_file()
+ props = context.scene.SaikeiAlignmentProperties
+
+ alignment = get_alignment_by_id(ifc, props.active_alignment_id)
+ if alignment is None:
+ clear_invalid_alignment_reference(props)
+ self.report({"ERROR"}, "Alignment no longer exists. Reference cleared.")
+ return {"CANCELLED"}
+
+ # Compute distance_along from station and start_station
+ # distance_along = station - start_station
+ distance_along = self.station - props.start_station
+
+ # Auto-generate name if not provided
+ name = self.name if self.name else format_station(self.station)
+
+ # Use the alignment itself as the positioned product
+ # (The referent marks a point on the alignment)
+ positioned_product = alignment
+
+ ifcopenshell.api.alignment.add_stationing_referent(
+ ifc,
+ alignment=alignment,
+ distance_along=distance_along,
+ station=self.station,
+ name=name,
+ positioned_product=positioned_product,
+ )
+
+ self.report({"INFO"}, f"Added referent '{name}' at station {self.station}")
+ return {"FINISHED"}
+
+
+def format_station(station_value):
+ """Format a station value in standard notation (e.g., 10000 -> '100+00')"""
+ # Station notation: divide by 100 for the main part, remainder for the offset
+ # e.g., 10000 -> 100+00, 10050 -> 100+50, 10123.45 -> 101+23.45
+ main = int(station_value // 100)
+ offset = station_value % 100
+ if offset == int(offset):
+ return f"{main}+{int(offset):02d}"
+ else:
+ return f"{main}+{offset:05.2f}"
+
+
+class SAIKEI_OT_name_segments(Operator):
+ """Auto-name segments based on station values"""
+
+ bl_idname = "saikei.name_segments"
+ bl_label = "Name Segments"
+ bl_description = "Automatically name segments with station-based labels"
+ bl_options = {"REGISTER", "UNDO"}
+
+ @classmethod
+ def poll(cls, context):
+ if not poll_ifc4x3(cls, context):
+ return False
+ props = context.scene.SaikeiAlignmentProperties
+ if props.active_alignment_id == 0:
+ cls.poll_message_set("Select an alignment first")
+ return False
+ return True
+
+ def execute(self, context):
+ ifc = tool.Alignment.get_ifc_file()
+ props = context.scene.SaikeiAlignmentProperties
+
+ alignment = get_alignment_by_id(ifc, props.active_alignment_id)
+ if alignment is None:
+ clear_invalid_alignment_reference(props)
+ self.report({"ERROR"}, "Alignment no longer exists. Reference cleared.")
+ return {"CANCELLED"}
+
+ ifcopenshell.api.alignment.name_segments(ifc, alignment)
+
+ self.report({"INFO"}, "Named alignment segments")
+ return {"FINISHED"}
+
+
+# =============================================================================
+# Utility Operators
+# =============================================================================
+
+
+class SAIKEI_OT_create_representation(Operator):
+ """Create geometric representation for alignment"""
+
+ bl_idname = "saikei.create_representation"
+ bl_label = "Create Representation"
+ bl_description = "Create or update the geometric representation"
+ bl_options = {"REGISTER", "UNDO"}
+
+ @classmethod
+ def poll(cls, context):
+ if not poll_ifc4x3(cls, context):
+ return False
+ props = context.scene.SaikeiAlignmentProperties
+ if props.active_alignment_id == 0:
+ cls.poll_message_set("Select an alignment first")
+ return False
+ return True
+
+ def execute(self, context):
+ ifc = tool.Alignment.get_ifc_file()
+ props = context.scene.SaikeiAlignmentProperties
+
+ alignment = get_alignment_by_id(ifc, props.active_alignment_id)
+ if alignment is None:
+ clear_invalid_alignment_reference(props)
+ self.report({"ERROR"}, "Alignment no longer exists. Reference cleared.")
+ return {"CANCELLED"}
+
+ ifcopenshell.api.alignment.create_representation(ifc, alignment)
+
+ self.report({"INFO"}, "Created geometric representation")
+ return {"FINISHED"}
+
+
+class SAIKEI_OT_create_segment_representations(Operator):
+ """Create representations for individual segments"""
+
+ bl_idname = "saikei.create_segment_representations"
+ bl_label = "Create Segment Representations"
+ bl_description = "Create geometric representations for each segment"
+ bl_options = {"REGISTER", "UNDO"}
+
+ @classmethod
+ def poll(cls, context):
+ if not poll_ifc4x3(cls, context):
+ return False
+ props = context.scene.SaikeiAlignmentProperties
+ if props.active_alignment_id == 0:
+ cls.poll_message_set("Select an alignment first")
+ return False
+ return True
+
+ def execute(self, context):
+ ifc = tool.Alignment.get_ifc_file()
+ props = context.scene.SaikeiAlignmentProperties
+
+ alignment = get_alignment_by_id(ifc, props.active_alignment_id)
+ if alignment is None:
+ clear_invalid_alignment_reference(props)
+ self.report({"ERROR"}, "Alignment no longer exists. Reference cleared.")
+ return {"CANCELLED"}
+
+ ifcopenshell.api.alignment.create_segment_representations(ifc, alignment)
+
+ self.report({"INFO"}, "Created segment representations")
+ return {"FINISHED"}
+
+
+class SAIKEI_OT_update_fallback_position(Operator):
+ """Update the fallback position for the alignment"""
+
+ bl_idname = "saikei.update_fallback_position"
+ bl_label = "Update Fallback Position"
+ bl_description = "Update the fallback position point"
+ bl_options = {"REGISTER", "UNDO"}
+
+ @classmethod
+ def poll(cls, context):
+ if not poll_ifc4x3(cls, context):
+ return False
+ props = context.scene.SaikeiAlignmentProperties
+ if props.active_alignment_id == 0:
+ cls.poll_message_set("Select an alignment first")
+ return False
+ return True
+
+ def execute(self, context):
+ ifc = tool.Alignment.get_ifc_file()
+ props = context.scene.SaikeiAlignmentProperties
+
+ alignment = get_alignment_by_id(ifc, props.active_alignment_id)
+ if alignment is None:
+ clear_invalid_alignment_reference(props)
+ self.report({"ERROR"}, "Alignment no longer exists. Reference cleared.")
+ return {"CANCELLED"}
+
+ ifcopenshell.api.alignment.update_fallback_position(ifc, alignment)
+
+ self.report({"INFO"}, "Updated fallback position")
+ return {"FINISHED"}
+
+
+class SAIKEI_OT_validate_segments(Operator):
+ """Validate alignment segments"""
+
+ bl_idname = "saikei.validate_segments"
+ bl_label = "Validate Segments"
+ bl_description = "Check for issues like zero-length segments"
+ bl_options = {"REGISTER"}
+
+ @classmethod
+ def poll(cls, context):
+ if not poll_ifc4x3(cls, context):
+ return False
+ props = context.scene.SaikeiAlignmentProperties
+ if props.active_alignment_id == 0:
+ cls.poll_message_set("Select an alignment first")
+ return False
+ return True
+
+ def execute(self, context):
+ ifc = tool.Alignment.get_ifc_file()
+ props = context.scene.SaikeiAlignmentProperties
+
+ alignment = get_alignment_by_id(ifc, props.active_alignment_id)
+ if alignment is None:
+ clear_invalid_alignment_reference(props)
+ self.report({"ERROR"}, "Alignment no longer exists. Reference cleared.")
+ return {"CANCELLED"}
+
+ h_layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
+
+ if h_layout:
+ has_zero = ifcopenshell.api.alignment.has_zero_length_segment(h_layout)
+ if has_zero:
+ self.report({"WARNING"}, "Alignment has zero-length segments")
+ else:
+ self.report({"INFO"}, "All segments valid")
+ else:
+ self.report({"WARNING"}, "No horizontal layout found")
+
+ return {"FINISHED"}
+
+
+class SAIKEI_OT_refresh_alignment_data(Operator):
+ """Refresh alignment data display"""
+
+ bl_idname = "saikei.refresh_alignment_data"
+ bl_label = "Refresh Data"
+ bl_description = "Refresh the alignment segment list"
+ bl_options = {"REGISTER"}
+
+ @classmethod
+ def poll(cls, context):
+ return poll_ifc4x3(cls, context)
+
+ def execute(self, context):
+ ifc = tool.Alignment.get_ifc_file()
+ props = context.scene.SaikeiAlignmentProperties
+
+ # Clear existing segments
+ props.segments.clear()
+
+ if props.active_alignment_id == 0:
+ return {"FINISHED"}
+
+ alignment = get_alignment_by_id(ifc, props.active_alignment_id)
+ if alignment is None:
+ # Alignment no longer exists - clear reference and return
+ clear_invalid_alignment_reference(props)
+ self.report({"WARNING"}, "Alignment no longer exists. Reference cleared.")
+ return {"FINISHED"}
+
+ h_layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
+
+ if h_layout:
+ segments = ifcopenshell.api.alignment.get_layout_segments(h_layout)
+ for i, seg in enumerate(segments):
+ item = props.segments.add()
+ item.name = f"Segment {i + 1}"
+ if hasattr(seg, "DesignParameters") and seg.DesignParameters:
+ dp = seg.DesignParameters
+ item.segment_type = dp.PredefinedType or "UNKNOWN"
+ item.length = dp.SegmentLength or 0.0
+ item.ifc_id = seg.id()
+
+ self.report({"INFO"}, f"Loaded {len(props.segments)} segments")
+ return {"FINISHED"}
diff --git a/src/saikei/saikei/civil/module/alignment/prop.py b/src/saikei/saikei/civil/module/alignment/prop.py
new file mode 100644
index 0000000000..7f8966c3d6
--- /dev/null
+++ b/src/saikei/saikei/civil/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/saikei/saikei/civil/module/alignment/ui.py b/src/saikei/saikei/civil/module/alignment/ui.py
new file mode 100644
index 0000000000..0047e98a7e
--- /dev/null
+++ b/src/saikei/saikei/civil/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")
diff --git a/src/saikei/saikei/civil/operator.py b/src/saikei/saikei/civil/operator.py
new file mode 100644
index 0000000000..2c209a8cc6
--- /dev/null
+++ b/src/saikei/saikei/civil/operator.py
@@ -0,0 +1,27 @@
+# ==============================================================================
+# 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
+# ==============================================================================
+
+
+"""Global operators for Saikei Civil
+
+This module contains operators that aren't specific to individual
+feature modules.
+"""
+
+# Placeholder for global operators
diff --git a/src/saikei/saikei/civil/prop.py b/src/saikei/saikei/civil/prop.py
new file mode 100644
index 0000000000..a3a5d1e47e
--- /dev/null
+++ b/src/saikei/saikei/civil/prop.py
@@ -0,0 +1,41 @@
+# ==============================================================================
+# 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
+# ==============================================================================
+
+
+"""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="",
+ )
diff --git a/src/saikei/saikei/civil/ui.py b/src/saikei/saikei/civil/ui.py
new file mode 100644
index 0000000000..0bf8c249f9
--- /dev/null
+++ b/src/saikei/saikei/civil/ui.py
@@ -0,0 +1,27 @@
+# ==============================================================================
+# 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
+# ==============================================================================
+
+
+"""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
diff --git a/src/saikei/saikei/core/__init__.py b/src/saikei/saikei/core/__init__.py
new file mode 100644
index 0000000000..265fdf2dc8
--- /dev/null
+++ b/src/saikei/saikei/core/__init__.py
@@ -0,0 +1,30 @@
+# ==============================================================================
+# 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
+# ==============================================================================
+
+
+"""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)
+"""
diff --git a/src/saikei/saikei/core/alignment.py b/src/saikei/saikei/core/alignment.py
new file mode 100644
index 0000000000..a7ab6b769f
--- /dev/null
+++ b/src/saikei/saikei/core/alignment.py
@@ -0,0 +1,270 @@
+# ==============================================================================
+# 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
+# ==============================================================================
+
+
+"""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
diff --git a/src/saikei/saikei/tool/__init__.py b/src/saikei/saikei/tool/__init__.py
new file mode 100644
index 0000000000..85b3e9080d
--- /dev/null
+++ b/src/saikei/saikei/tool/__init__.py
@@ -0,0 +1,126 @@
+# ==============================================================================
+# 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
+# ==============================================================================
+
+
+"""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()
diff --git a/src/saikei/saikei/tool/alignment.py b/src/saikei/saikei/tool/alignment.py
new file mode 100644
index 0000000000..22c5340982
--- /dev/null
+++ b/src/saikei/saikei/tool/alignment.py
@@ -0,0 +1,780 @@
+# ==============================================================================
+# 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
+# ==============================================================================
+
+
+"""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