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