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