Compare commits

...

53 Commits

Author SHA1 Message Date
DesertSpringsCivil 0b3967609c fix: Address PR #7589 review items - prefix rename, cleanup, copyright
- Rename all saikei.* operator idnames to civil.* per Bonsai convention
- Rename SAIKEI_OT_*, SAIKEI_PT_*, SAIKEI_UL_* classes to CIVIL_* prefix
- Rename SaikeiAlignmentProperties -> CivilAlignmentProperties
- Rename saikei_* Blender object custom property keys to civil_*
- Remove IOS-version-compat try/except fallback in _get_segment_vertices_in_model_units()
  now that Rick's segment_vertices() API accepts IfcAlignmentSegment directly
- Remove try/except wrapper around get_alignment() - call directly
- Add Michael Yoder copyright to __init__.py and operator.py
- Fix misleading coordinate comment (IFC -> global easting/northing)
- Document props.pis coordinate system (global E/N) in AlignmentPI and operator comments

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 13:39:19 -07:00
DesertSpringsCivil 9a7cd5b373 chore: Add Claude Code local config files to .gitignore
Ignore CLAUDE.md, CLAUDE.local.md, and .mcp.json so personal
Claude Code configuration (managed via private dotfiles repo)
doesn't pollute the shared repository.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 10:16:37 -07:00
Richard Brice 701635b81c revises get_mapped_segments
get_mapped_segments now looks for representations attached to alignment segments before using the more complex method of computing the index of segments in the composite curve.
updates segment_vertices to use get_mapped_segments
2026-02-25 07:51:17 -08:00
Dion Moult 88f6c7cf65 Remove unnecessary status panel 2026-02-25 11:03:51 +11:00
Dion Moult cbdf1a193e Remove create alignment operator, refactor to use tool.Alignment.get_active_alignment 2026-02-25 11:03:06 +11:00
Dion Moult 95048ea2dd Typo 2026-02-25 11:02:23 +11:00
Dion Moult 0e1a2510ff Refactor get alignment layouts into util 2026-02-25 10:24:30 +11:00
Dion Moult fff653b882 Consolidate adding alignments into Add Element interface 2026-02-25 10:24:18 +11:00
Dion Moult 160348df1f Purge unnecessary undo code 2026-02-25 10:20:31 +11:00
Dion Moult 278a35e729 Revert "Fix segment object parenting and add Claude Code to .gitignore"
This reverts commit e1bf717af5.
2026-02-25 09:16:18 +11:00
DesertSpringsCivil e1bf717af5 Fix segment object parenting and add Claude Code to .gitignore
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 13:54:51 -07:00
DesertSpringsCivil be30f9cbea Add undo/redo support to PI edit mode operator
Wrap SAIKEI_OT_enter_pi_edit_mode with Bonsai's IfcStore transaction
system so IFC segment changes from applying PI edits are tracked and
undoable via Ctrl+Z.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 13:54:50 -07:00
DesertSpringsCivil 4971546de9 Replace manual PI trigonometry with segment_vertices() API
Uses Rick Brice's new ifcopenshell.api.alignment.segment_vertices()
to extract PI positions from alignment segments via the C++ geometry
engine, replacing ~300 lines of hand-coded trig that only handled
LINE and CIRCULARARC. Now supports all segment types (CLOTHOID,
Helmert curves, etc.). Includes backward-compatible fallback for
IFC files without Axis/Segment representations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 13:54:50 -07:00
Richard Brice d9a3c2d5c2 alignment api cleanup
updates some documentation
removes some dead code
2026-02-24 12:44:34 -08:00
Richard Brice 5066fc5a29 black 2026-02-24 08:58:00 -08:00
Richard Brice 9041857277 Updates segment_vertices to handle IfcAlignmentSegment 2026-02-24 08:57:51 -08:00
Richard Brice 7e93a73c6a Updates alignment API function for creating a layout segment to include the segment geometric representation 2026-02-24 08:55:33 -08:00
Richard Brice 99640912c1 Adds alignment API function to get the alignment layout from one of its segments 2026-02-23 16:21:05 -08:00
Richard Brice 798ed0d502 Updates segment_vertices function
Renamed PI and CC to TI and NI
Fixes handling of units (now works correctly with US and SI units)
Changed tests to use US feet units
Fixed documentation
2026-02-23 08:08:48 -08:00
Richard Brice c3f325861c Adds segment_vertices function to alignment api 2026-02-22 15:47:16 -08:00
DesertSpringsCivil 8433999575 Refactor alignment module: fix bugs, remove dead code, enforce architecture
- Fix 4 runtime bugs: seg/s variable mismatch, missing float() wrappers,
  PI dict key mismatches ("x"/"y" -> "e"/"n"), float-to-StringProperty
- Remove ~470 lines of dead code across prop.py, core/alignment.py,
  tool/alignment.py, and operator.py
- Consolidate duplicate math functions from operator.py into tool layer
  (arc_length_at_pi, tangent_length_at_pi, tangent_segment_length)
- Move PI extraction logic from operator.py to tool/alignment.py
- Add IfcStore undo pattern to 7 IFC-modifying operators
- Core layer no longer calls IFC API directly (delegates via tool wrappers)
- Remove unused imports (math, IntProperty, Vector)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 19:40:44 -07:00
Dion Moult bba1ff3786 Merge branch 'v0.8.0' into saikei 2026-02-19 10:22:31 +11:00
Dion Moult a39e4c552a Now horizontal alignments are created in IFC local coords, remove redundant PI calculation code 2026-02-18 11:14:35 +11:00
Dion Moult 21c6384b7e Use existing create_mesh when creating segments to handle Blender specific georeferencing offsets 2026-02-18 11:13:25 +11:00
DesertSpringsCivil 07ef382c7e Refactor PI picker to use Bonsai polyline system
Replace custom SAIKEI_OT_pick_pi_from_viewport modal with a
PolylineOperator subclass, reusing Bonsai's proven polyline
infrastructure (same base class as wall/slab/profile drawing).

Gains: snapping, numeric D/A/X/Y input, axis locking, angle
locking, measurement display, undo-last-point, status bar hints.

Remove PIPickerDecorator (replaced by PolylineDecorator).
Fix EN string formatting in UI list display rows.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 11:29:30 -07:00
Dion Moult 65af40e7d5 Change from XY to EN and use strings not floats in Blender due to precision 2026-02-13 11:09:45 +11:00
Dion Moult cfe30a505e Readd numpad enter keybinding 2026-02-13 09:59:35 +11:00
Dion Moult 04677c1b80 Revert "Add Space key as alternative to Enter for PI Edit Mode apply"
This reverts commit 33a4639de5.
2026-02-13 09:58:00 +11:00
Dion Moult f06873d24e Remove try except blocks, rename piecewise-step to function-step, remove settings where default suffices 2026-02-13 09:57:51 +11:00
Dion Moult cd6e531120 Revert "Fix dev_environment.py Windows compatibility for symlink handling"
This reverts commit a37de44a24.
2026-02-13 09:14:35 +11:00
Dion Moult f9f0f1915f Remove claude 2026-02-13 09:14:32 +11:00
DesertSpringsCivil e0a1577ae0 Fix in-place alignment editing and curve visualization
- Add clear_layout_segments API to remove segments while preserving alignment ID
- Modify exit_pi_edit_mode to edit segments in-place instead of delete+recreate
- Fix curve visualization by using create_shape for segment vertices
- Fix evaluate_segment validation to handle negative-length curve segments

This prevents "Active alignment no longer exists" errors when editing PIs
and properly renders circular arcs regardless of turn direction.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 00:05:54 -07:00
DesertSpringsCivil 33a4639de5 Add Space key as alternative to Enter for PI Edit Mode apply
When using G key to move PIs, Blender's transform modal consumes
the Enter key. Adding Space as an alternative lets users apply
changes more easily. Also adds Numpad Enter support.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 20:46:19 -07:00
DesertSpringsCivil a3038528bb Add PI Edit Mode for moving alignment points with G key
Implements the ability to edit alignment PI (Point of Intersection)
positions after creation using Blender's standard transform tools:

- Back-calculate PI positions from existing IFC alignment segments
- Create temporary EMPTY objects at PI locations for editing
- Visual feedback via PIEditDecorator (yellow tangent lines, HUD)
- Modal operator handles G key movement, Enter to apply, Escape to cancel
- Regenerates alignment with new PI positions on apply
- Handles edge cases: single-segment, tangent-only, undo during edit

Architecture follows Bonsai patterns:
- Core layer: Business logic orchestration (enter/exit_pi_edit_mode)
- Tool layer: Math, IFC, and Blender implementations
- UI layer: Modal operator with PASS_THROUGH for standard transforms

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 20:26:41 -07:00
DesertSpringsCivil eea060b72d Local: Document git workflow for local-only files in CLAUDE.md
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 19:09:58 -07:00
DesertSpringsCivil 862697baae Local: Add CLAUDE.md project context
This file contains project-specific context for Claude Code sessions.
It should NOT be pushed to origin (protected by pre-push hook).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 19:00:58 -07:00
DesertSpringsCivil 9a3ae628aa Add rubber band visualization to PI picker modal
Add visual feedback during PI placement with PIPickerDecorator:
- Yellow tangent lines connecting placed PIs
- Rubber band line from last PI to cursor position
- Green circle markers at each PI location
- HUD text showing instructions and PI count

Follows Bonsai's established decorator pattern with GPU draw handlers.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 18:19:58 -07:00
DesertSpringsCivil 46da629c8b Create selectable curve geometry for alignment segments
Replace segment empty objects with actual curve geometry so that selecting
a segment in the Outliner highlights the corresponding line/curve in the
viewport.

Changes:
- Add get_segment_vertices() using IfcOpenShell's evaluate_segment() to
  sample points along individual segments via the geometry engine
- Replace _create_segment_empty with _create_segment_curve that creates
  Blender CURVE objects with actual geometry
- Remove single HorizontalCurve in favor of per-segment curves
- Supports all segment types (LINE, CIRCULARARC, CLOTHOID, spirals, etc.)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 16:41:04 -07:00
DesertSpringsCivil fd85b29777 Hide zero-length terminator segments from UI and suppress empty alignment warnings
Zero-length segments are required by IFC to mark alignment ends but should
be invisible to users. This change:
- Adds helper methods to detect zero-length and empty layouts
- Silently skips geometry generation for empty alignments (no error messages)
- Excludes zero-length segments from Outliner display
- Uses separate visible segment counter for consistent naming

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 15:34:07 -07:00
DesertSpringsCivil 95952d03f8 Fix PI picker viewport coordinate calculation and add georeference support
- Fix modal operator to use absolute mouse coordinates converted to 3D
  viewport region space, instead of event.mouse_region_x/y which are
  relative to whichever region received the event
- Store 3D viewport area, region, and region_data references in invoke()
  for consistent raycasting throughout modal operation
- Add coordinate transformation methods (blender_to_ifc_coordinates and
  ifc_to_blender_coordinates) for projects with geospatial Blender offsets
- Transform alignment curve vertices from IFC global to Blender local
  coordinates when has_blender_offset is enabled
- Add try/except for piecewise-step-size geometry setting in util.py

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 10:19:02 -07:00
DesertSpringsCivil 5ad40612fb Remove Import CSV button and PI Details panel from alignment UI
Simplifies the Horizontal Alignment panel by removing the Import
Alignment CSV button and the PI Details submenu that displayed
when selecting rows in the PI Editor list.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 10:17:44 -07:00
DesertSpringsCivil 6294c35a11 Handle RuntimeError in alignment geometry generation
Add RuntimeError handling for IfcOpenShell versions that don't support
the piecewise-step-type setting in geometry generation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 18:40:49 -07:00
DesertSpringsCivil de2a7c9527 Add CIVIL tab to Bonsai Properties sidebar for alignment tools
- Add new CIVIL tab (4th position) with CURVE_DATA icon
- Create BIM_PT_tab_horizontal_alignment panel container
- Move alignment UI from N-panel to Properties sidebar
- Remove old "Saikei Civil" N-panel tab

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 18:39:38 -07:00
DesertSpringsCivil a37de44a24 Fix dev_environment.py Windows compatibility for symlink handling
Git checkout with glob patterns (*.ifc) doesn't work on Windows.
This change:
- Expands glob via git ls-files and checks out files individually
- Skips symlink recreation if they already exist and are valid
- Refreshes git index before checkout to recognize deleted files

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 14:43:13 -07:00
DesertSpringsCivil c0b7256832 Use IfcOpenShell geometry engine for alignment visualization
Replace manual per-segment geometry creation with IfcOpenShell's
built-in generate_vertices() utility. This provides automatic support
for all curve types (CLOTHOID, spirals, etc.) and removes ~75 lines
of manual geometry code.

Changes:
- Add create_curve_from_representation() using IfcOpenShell geometry engine
- Add _create_segment_empty() for segment selection without geometry
- Update create_objects_for_layout_segments() to use new methods
- Delete manual geometry methods: create_object_for_segment(),
  _create_line_segment(), _create_arc_segment()

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 13:35:20 -07:00
DesertSpringsCivil 42db172827 Clean up unused imports in alignment operator module
Removed leftover imports from sequence module template:
- isodate, dateutil, calendar, datetime
- bonsai.bim.module.sequence.helper
- ifcopenshell.util.sequence, ifcopenshell.geom, ifcopenshell.util.selector
- Duplicate imports (os, json, ImportHelper, ifcopenshell.api.alignment)

These unused imports were causing silent module registration failures.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 11:18:35 +11:00
DesertSpringsCivil f52e95a05a Remove 11 unused operators from Saikei alignment module
Operators removed (not called in any UI):
- SAIKEI_OT_create_alignment_polyline
- SAIKEI_OT_create_alignment_offset
- SAIKEI_OT_add_vertical_layout
- SAIKEI_OT_add_layout_segment
- SAIKEI_OT_layout_horizontal_by_pi
- SAIKEI_OT_layout_vertical_by_pi
- SAIKEI_OT_create_representation
- SAIKEI_OT_create_segment_representations
- SAIKEI_OT_update_fallback_position
- SAIKEI_OT_validate_segments
- SAIKEI_OT_refresh_alignment_data

Also fixed poll_ifc4x3() and replaced all tool.Alignment.get_ifc_file()
calls with tool.Ifc.get() after previous refactoring removed that method.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 11:18:35 +11:00
DesertSpringsCivil 04ad0b92bd Refactor Saikei alignment module to follow Bonsai architecture
- Move math/calculation functions from core to tool layer
  (calculate_pi_geometry, calculate_deflection_angle, etc.)
- Remove duplicate get_ifc_file() wrappers, use tool.Ifc.get() directly
- Remove redundant ifc_definition_id manual settings (tool.Ifc.link handles this)
- Remove fallback object lookup methods (_find_object_by_ifc_id, _find_object_by_name_pattern)
- Simplify remove methods to use tool.Ifc.get_object() directly
- Clean up defensive try/except ImportError blocks
- Update is_ifc4x3() to use tool.Ifc.get_schema()
- Update license headers to Bonsai standard

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 11:18:35 +11:00
Dion Moult 8416ca46a7 More forgotten files 2026-01-22 11:23:26 +11:00
Dion Moult 10ce30b2ac Forgot to commit these files 2026-01-21 18:59:31 +11:00
Dion Moult 00c971fa2d Purge unnecessary Saikei boilerplate 2026-01-21 12:46:58 +11:00
Dion Moult b86bf45167 Remove claude 2026-01-21 12:32:11 +11:00
Dion Moult 22fd318d21 Initial commit of sakei code prior to any refactoring 2026-01-21 12:17:36 +11:00
32 changed files with 3766 additions and 150 deletions
+5
View File
@@ -115,3 +115,8 @@ dev_environment.bat
src/ifcopenshell-python/ifcopenshell/express/*.exp
src/ifcopenshell-python/ifcopenshell/express/*.exp.cache.dat
# Claude Code local config (managed via dotfiles repo)
CLAUDE.md
CLAUDE.local.md
.mcp.json
+2
View File
@@ -184,6 +184,8 @@ classes = [
ui.BIM_PT_tab_materials,
ui.BIM_PT_tab_styles,
ui.BIM_PT_tab_profiles,
# Civil infrastructure
ui.BIM_PT_tab_horizontal_alignment,
# Drawings and documents
ui.BIM_PT_tab_sheets,
ui.BIM_PT_tab_drawings,
@@ -1,5 +1,5 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2020, 2021 Dion Moult <dion@thinkmoult.com>
# Copyright (C) 2020, 2021 Dion Moult <dion@thinkmoult.com>, 2026 Michael Yoder <myoder@desertspringscivil.com>
#
# This file is part of Bonsai.
#
@@ -17,11 +17,37 @@
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import bpy
from bpy.app.handlers import persistent
from . import ui, prop, operator, decorator
# from . import ui, prop, operator
from . import operator
classes = (operator.ImportAlignmentCSV,)
classes = (
# Property groups (must be registered before classes that use them)
prop.AlignmentPI,
prop.AlignmentDisplayRow,
prop.CivilAlignmentProperties,
# UILists
ui.CIVIL_UL_alignment_pis,
operator.ImportAlignmentCSV,
# Operators - PI Management
operator.CIVIL_OT_add_pi,
operator.CIVIL_OT_remove_pi,
operator.CIVIL_OT_pick_pi_from_viewport,
operator.CIVIL_OT_recalculate_pis,
operator.CIVIL_OT_clear_pis,
# Operators - Creation
operator.CIVIL_OT_create_alignment_by_pi,
operator.CIVIL_OT_import_alignment_csv,
# Operators - Stationing
operator.CIVIL_OT_add_stationing_referent,
operator.CIVIL_OT_name_segments,
# Operators - PI Edit Mode
operator.CIVIL_OT_enter_pi_edit_mode,
# UI Panels (appear in Properties sidebar under CIVIL tab)
ui.CIVIL_PT_alignment_creation,
ui.CIVIL_PT_pi_editor,
ui.CIVIL_PT_alignment_stationing,
)
def menu_func_import(self, context):
@@ -29,8 +55,10 @@ def menu_func_import(self, context):
def register():
bpy.types.Scene.CivilAlignmentProperties = bpy.props.PointerProperty(type=prop.CivilAlignmentProperties)
bpy.types.TOPBAR_MT_file_import.append(menu_func_import)
def unregister():
bpy.types.TOPBAR_MT_file_import.remove(menu_func_import)
del bpy.types.Scene.CivilAlignmentProperties
@@ -0,0 +1,66 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2025, 2026 Michael Yoder <myoder@desertspringscivil.com>
#
# This file is part of Bonsai.
#
# Bonsai 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.
#
# Bonsai 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 Bonsai. If not, see <http://www.gnu.org/licenses/>.
"""Data caching layer for the alignment module
This module provides cached access to alignment data for UI display,
following Bonsai's data loading pattern.
"""
import bonsai.tool as tool
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 = tool.Ifc.get()
if ifc is None:
cls.is_loaded = True
return
# Load all alignments
alignments = ifc.by_type("IfcAlignment")
cls.data["alignments"] = [
{
"id": a.id(),
"name": a.Name or f"Alignment {a.id()}",
"global_id": a.GlobalId,
}
for a in alignments
]
cls.is_loaded = True
@classmethod
def refresh(cls):
"""Force refresh of alignment data"""
cls.is_loaded = False
cls.load()
@@ -0,0 +1,193 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2025, 2026 Michael Yoder <myoder@desertspringscivil.com>
#
# This file is part of Bonsai.
#
# Bonsai 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.
#
# Bonsai 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 Bonsai. If not, see <http://www.gnu.org/licenses/>.
"""Alignment module decorators for GPU visualization.
This module contains decorators for rendering visual feedback during
alignment-related operations, such as PI editing.
"""
import bpy
import blf
import gpu
import bonsai.tool as tool
from bpy.types import SpaceView3D
from gpu_extras.batch import batch_for_shader
class PIEditDecorator:
"""Decorator for visualizing PI edit mode.
This decorator provides visual feedback while the user is editing
PI (Point of Intersection) positions with standard Blender transform tools:
- Yellow lines connecting PI empties (tangent preview)
- HUD text showing instructions
The decorator reads positions directly from the PI empty objects,
which are updated by Blender's transform operators (G key).
"""
# Class-level state (cleared on uninstall)
is_installed = False
handlers = []
# References to PI empty objects
pi_empties = []
# Colors
COLOR_TANGENT_LINE = (1.0, 0.9, 0.2, 1.0) # Yellow for tangent lines
COLOR_HUD_TEXT = (1.0, 1.0, 1.0, 1.0) # White for HUD text
COLOR_EDIT_MODE_BG = (0.2, 0.4, 0.8, 0.8) # Blue tint for edit mode indicator
# Drawing parameters
LINE_WIDTH = 2.5
@classmethod
def install(cls, context, pi_empties):
"""Install decorator handlers for PI edit mode visualization.
Args:
context: Blender context
pi_empties: List of PI EMPTY objects to visualize
"""
if cls.is_installed:
cls.uninstall()
cls.pi_empties = pi_empties
handler = cls()
# POST_VIEW for 3D world-space drawing (tangent lines in 3D)
cls.handlers.append(
SpaceView3D.draw_handler_add(handler.draw_tangent_lines_3d, (context,), "WINDOW", "POST_VIEW")
)
# POST_PIXEL for 2D screen-space drawing (HUD)
cls.handlers.append(
SpaceView3D.draw_handler_add(handler.draw_hud, (context,), "WINDOW", "POST_PIXEL")
)
cls.is_installed = True
@classmethod
def uninstall(cls):
"""Remove all handlers and clear state."""
for handler in cls.handlers:
try:
SpaceView3D.draw_handler_remove(handler, "WINDOW")
except ValueError:
pass
cls.handlers = []
cls.is_installed = False
cls.pi_empties = []
@classmethod
def update_positions(cls, pi_empties):
"""Update the list of PI empties (called when positions change).
Args:
pi_empties: Updated list of PI EMPTY objects
"""
cls.pi_empties = pi_empties
def draw_batch_3d(self, shader_type, content_pos, color, indices=None):
"""Draw a batch of 3D primitives using GPU shader.
Args:
shader_type: Type of primitive ("LINES", "POINTS", etc.)
content_pos: List of 3D vertex positions
color: RGBA color tuple
indices: Optional list of index pairs for lines
"""
if not tool.Blender.validate_shader_batch_data(content_pos, indices):
return
shader = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR")
shader.bind()
# Get viewport size from active region
region = bpy.context.region
shader.uniform_float("viewportSize", (region.width, region.height))
shader.uniform_float("lineWidth", self.LINE_WIDTH)
batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices)
shader.uniform_float("color", color)
batch.draw(shader)
def draw_tangent_lines_3d(self, context):
"""Draw yellow tangent lines connecting PI empties in 3D space."""
if not self.pi_empties or len(self.pi_empties) < 2:
return
# Collect 3D positions from empties
positions = []
for empty in self.pi_empties:
if empty and empty.name in bpy.data.objects:
positions.append(tuple(empty.location))
if len(positions) < 2:
return
# Setup blending for line drawing
gpu.state.blend_set("ALPHA")
gpu.state.depth_test_set("LESS_EQUAL")
gpu.state.depth_mask_set(False)
# Build edges list
edges = [[i, i + 1] for i in range(len(positions) - 1)]
# Draw lines
self.draw_batch_3d("LINES", positions, self.COLOR_TANGENT_LINE, edges)
# Restore state
gpu.state.blend_set("NONE")
gpu.state.depth_test_set("NONE")
gpu.state.depth_mask_set(True)
def draw_hud(self, context):
"""Draw HUD text with edit mode instructions."""
region = context.region
if not region:
return
font_id = 0
font_size = tool.Blender.scale_font_size(14)
blf.size(font_id, font_size)
blf.enable(font_id, blf.SHADOW)
blf.shadow(font_id, 6, 0, 0, 0, 1) # Black shadow for readability
blf.color(font_id, *self.COLOR_HUD_TEXT)
# Position in top-left of viewport
margin = 20
line_height = 22
y_pos = region.height - margin
# Count valid empties
valid_count = sum(1 for e in self.pi_empties if e and e.name in bpy.data.objects)
# Instructions
instructions = [
"PI Edit Mode",
f"PIs: {valid_count}",
"",
"G: Move selected PI",
"ENTER: Apply changes",
"ESC: Cancel",
]
for i, line in enumerate(instructions):
blf.position(font_id, margin, y_pos - (i * line_height), 0)
blf.draw(font_id, line)
blf.disable(font_id, blf.SHADOW)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,215 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2025, 2026 Michael Yoder <myoder@desertspringscivil.com>
#
# This file is part of Bonsai.
#
# Bonsai 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.
#
# Bonsai 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 Bonsai. If not, see <http://www.gnu.org/licenses/>.
"""Property groups for the alignment module"""
import bpy
from bpy.types import PropertyGroup
from bpy.props import (
StringProperty,
FloatProperty,
IntProperty,
BoolProperty,
CollectionProperty,
EnumProperty,
)
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 stored as global easting/northing (map coordinates).
# Coordinate flow: Blender coords -> xyz2enh() -> global E/N (stored here)
# global E/N -> enh2xyz(to_blender=False) -> local IFC coords (for IfcOpenShell API)
e: StringProperty(name="E", description="Easting (global map coordinates)", default="0.0")
n: StringProperty(name="N", description="Northing (global map coordinates)", default="0.0")
# 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,
)
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)
e: StringProperty(name="E", default="0.0")
n: StringProperty(name="N", default="0.0")
# 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 CivilAlignmentProperties(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)
# 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)
# PI Edit Mode state (for moving PIs with G key)
is_pi_edit_mode: BoolProperty(
name="PI Edit Mode Active",
description="Whether PI edit mode is currently active",
default=False,
)
pi_edit_alignment_id: IntProperty(
name="Editing Alignment ID",
description="IFC ID of alignment being edited in PI edit mode",
default=0,
)
# Display options
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,269 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2025, 2026 Michael Yoder <myoder@desertspringscivil.com>
#
# This file is part of Bonsai.
#
# Bonsai 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.
#
# Bonsai 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 Bonsai. If not, see <http://www.gnu.org/licenses/>.
"""UI panels for the alignment module
All panels appear in the Properties sidebar under the CIVIL tab,
nested under BIM_PT_tab_horizontal_alignment.
"""
import bpy
import bonsai.tool as tool
from bpy.types import Panel, UIList
def is_ifc4x3():
"""Check if the current IFC file is IFC4X3 schema"""
return tool.Ifc.get_schema() == "IFC4X3"
# =============================================================================
# UILists
# =============================================================================
class CIVIL_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, "e", text="")
sub.prop(pi, "n", text="")
else:
row.label(text=f"{float(item.e):.2f}")
row.label(text=f"{float(item.n):.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"{float(item.e):.2f}")
row.label(text=f"{float(item.n):.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")
# =============================================================================
# Creation Sub-Panel
# =============================================================================
class CIVIL_PT_alignment_creation(Panel):
"""Sub-panel for alignment creation tools"""
bl_label = "Creation"
bl_idname = "CIVIL_PT_alignment_creation"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "scene"
bl_parent_id = "BIM_PT_tab_horizontal_alignment"
bl_options = {"DEFAULT_CLOSED"}
@classmethod
def poll(cls, context):
return tool.Blender.should_show_panel(context, "CIVIL", cls.bl_idname) and is_ifc4x3()
def draw(self, context):
layout = self.layout
props = context.scene.CivilAlignmentProperties
# 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("civil.create_alignment_by_pi", icon="CURVE_DATA")
# =============================================================================
# PI Editor Sub-Panel
# =============================================================================
class CIVIL_PT_pi_editor(Panel):
"""Sub-panel for PI point table editor (Civil 3D style grid view)"""
bl_label = "PI Editor"
bl_idname = "CIVIL_PT_pi_editor"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "scene"
bl_parent_id = "BIM_PT_tab_horizontal_alignment"
bl_options = set() # Open by default
@classmethod
def poll(cls, context):
return tool.Blender.should_show_panel(context, "CIVIL", cls.bl_idname) and is_ifc4x3()
def draw(self, context):
layout = self.layout
props = context.scene.CivilAlignmentProperties
# PI Edit Mode indicator
if props.is_pi_edit_mode:
box = layout.box()
box.alert = True
box.label(text="PI Edit Mode Active", icon="EDITMODE_HLT")
col = box.column(align=True)
col.label(text="Move PIs with G key")
col.label(text="Press Enter to apply")
col.label(text="Press Escape to cancel")
layout.separator()
return # Don't show normal UI while in edit mode
# Edit existing alignment button
if props.active_alignment_id != 0:
box = layout.box()
box.label(text="Edit Alignment:", icon="EDITMODE_HLT")
box.operator("civil.enter_pi_edit_mode", icon="PIVOT_CURSOR", text="Edit PIs (G key)")
layout.separator()
# Header row with column labels
header = layout.row(align=True)
header.label(text="No.")
header.label(text="Type")
header.label(text="E")
header.label(text="N")
header.label(text="Length")
header.label(text="Radius")
# Combined point/segment list (interleaved view)
row = layout.row()
row.template_list(
"CIVIL_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("civil.add_pi", icon="ADD", text="")
col.operator("civil.remove_pi", icon="REMOVE", text="")
col.separator()
col.operator("civil.pick_pi_from_viewport", icon="EYEDROPPER", text="")
# Bottom actions
layout.separator()
row = layout.row(align=True)
row.operator("civil.recalculate_pis", icon="FILE_REFRESH", text="Recalculate")
row.operator("civil.clear_pis", icon="TRASH", text="Clear All")
# =============================================================================
# Stationing Sub-Panel
# =============================================================================
class CIVIL_PT_alignment_stationing(Panel):
"""Sub-panel for stationing and referents"""
bl_label = "Stationing"
bl_idname = "CIVIL_PT_alignment_stationing"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "scene"
bl_parent_id = "BIM_PT_tab_horizontal_alignment"
bl_options = {"DEFAULT_CLOSED"}
@classmethod
def poll(cls, context):
return tool.Blender.should_show_panel(context, "CIVIL", cls.bl_idname) and is_ifc4x3()
def draw(self, context):
layout = self.layout
props = context.scene.CivilAlignmentProperties
# 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("civil.add_stationing_referent", icon="EMPTY_AXIS")
col.operator("civil.name_segments", icon="FONT_DATA")
+5 -1
View File
@@ -82,6 +82,8 @@ class IfcClassData:
feature_elements = ifcopenshell.util.schema.get_subtypes(entity)
for feature_element in feature_elements:
names.remove(feature_element.name())
if ifc_product == "IfcAlignment":
names.extend(("IfcAlignmentHorizontal", "IfcAlignmentVertical", "IfcAlignmentCant"))
version = tool.Ifc.get_schema()
return [(c, c, (get_entity_doc(version, c) or {}).get("description", "")) for c in sorted(names)]
@@ -136,7 +138,9 @@ class IfcClassData:
("EMPTY", "No Geometry", "Start with an empty object"),
]
if ifc_class in ("IfcWindowType", "IfcWindowStyle", "IfcWindow"):
if ifc_class == "IfcAlignment":
return templates
elif ifc_class in ("IfcWindowType", "IfcWindowStyle", "IfcWindow"):
templates.extend([None, ("WINDOW", "Window", "Parametric window")])
elif ifc_class in ("IfcDoorType", "IfcDoorStyle", "IfcDoor"):
templates.extend([None, ("DOOR", "Door", "Parametric door")])
+11 -1
View File
@@ -531,6 +531,9 @@ class AddElement(bpy.types.Operator, tool.Ifc.Operator):
if props.ifc_product == "IfcFeatureElement" and not props.featured_obj:
return self.report({"WARNING"}, "A featured element must be nominated.")
if "Alignment" in props.ifc_product and props.ifc_product != "IfcAlignment" and not props.featured_obj:
return self.report({"WARNING"}, "A parent alignment element must be nominated.")
ifc_context = None
if get_enum_items(props, "contexts", context):
ifc_context = int(props.contexts or "0") or None
@@ -821,6 +824,13 @@ class AddElement(bpy.types.Operator, tool.Ifc.Operator):
tool.Model.purge_scene_openings()
tool.Collector.assign(obj)
if props.featured_obj:
alignment = tool.Ifc.get_entity(props.featured_obj)
if props.ifc_class == "IfcAlignment":
ifcopenshell.api.aggregate.assign_object(tool.Ifc.get(), products=[element], relating_object=alignment)
elif props.ifc_class in ("IfcAlignmentHorizontal", "IfcAlignmentVertical", "IfcAlignmentCant"):
ifcopenshell.api.nest.assign_object(tool.Ifc.get(), related_objects=[element], relating_object=alignment)
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj)
tool.Blender.set_active_object(obj)
@@ -842,7 +852,7 @@ class AddElement(bpy.types.Operator, tool.Ifc.Operator):
if props.ifc_predefined_type == "USERDEFINED":
row = self.layout.row()
row.prop(props, "ifc_userdefined_type")
if props.ifc_product == "IfcFeatureElement":
if props.ifc_product in ("IfcFeatureElement", "IfcAlignment"):
row = self.layout.row()
row.prop(props, "featured_obj", text="Featured Object")
prop_with_search(self.layout, props, "representation_template", text="Representation", should_click_ok=True)
+9 -8
View File
@@ -498,15 +498,16 @@ def get_tab(
("PROJECT", "Project Overview", "", bonsai.bim.icons[icon_key].icon_id, 0),
("OBJECT", "Object Information", "", "FILE_3D", 1),
("GEOMETRY", "Geometry and Materials", "", "MATERIAL", 2),
("DRAWINGS", "Drawings and Documents", "", "DOCUMENTS", 3),
("SERVICES", "Services and Systems", "", "NETWORK_DRIVE", 4),
("STRUCTURE", "Structural Analysis", "", "EDITMODE_HLT", 5),
("SCHEDULING", "Costing and Scheduling", "", "NLA", 6),
("FM", "Facility Management", "", "PACKAGE", 7),
("QUALITY", "Quality and Coordination", "", "COMMUNITY", 8),
("BOOKMARK", "Bookmark", "", "SOLO_ON", 9),
("CIVIL", "Civil Infrastructure", "", "CURVE_DATA", 3),
("DRAWINGS", "Drawings and Documents", "", "DOCUMENTS", 4),
("SERVICES", "Services and Systems", "", "NETWORK_DRIVE", 5),
("STRUCTURE", "Structural Analysis", "", "EDITMODE_HLT", 6),
("SCHEDULING", "Costing and Scheduling", "", "NLA", 7),
("FM", "Facility Management", "", "PACKAGE", 8),
("QUALITY", "Quality and Coordination", "", "COMMUNITY", 9),
("BOOKMARK", "Bookmark", "", "SOLO_ON", 10),
None,
("BLENDER", "Blender Properties", "", "BLENDER", 10),
("BLENDER", "Blender Properties", "", "BLENDER", 11),
]
return get_tab.enum_items
+20
View File
@@ -1683,6 +1683,25 @@ class BIM_PT_tab_profiles(Panel):
pass
# Civil Infrastructure tab panels
class BIM_PT_tab_horizontal_alignment(Panel):
bl_idname = "BIM_PT_tab_horizontal_alignment"
bl_label = "Horizontal Alignment"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "scene"
bl_order = 1
bim_tab_name = "CIVIL"
@classmethod
def poll(cls, context):
if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get():
return True
def draw(self, context):
pass
class BIM_PT_tab_sheets(Panel):
bl_idname = "BIM_PT_tab_sheets"
bl_label = "Sheets"
@@ -1869,6 +1888,7 @@ class UIData:
("PROJECT", bonsai.bim.icons[f"{color_mode}_ifc"].icon_id, True),
("OBJECT", "FILE_3D", is_ifc_project),
("GEOMETRY", "MATERIAL", is_ifc_project),
("CIVIL", "CURVE_DATA", is_ifc_project),
("DRAWINGS", "DOCUMENTS", is_ifc_project),
("SERVICES", "NETWORK_DRIVE", is_ifc_project),
("STRUCTURE", "EDITMODE_HLT", is_ifc_project),
+184
View File
@@ -0,0 +1,184 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2025, 2026 Michael Yoder <myoder@desertspringscivil.com>
#
# This file is part of Bonsai.
#
# Bonsai 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.
#
# Bonsai 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 Bonsai. If not, see <http://www.gnu.org/licenses/>.
"""Core alignment business logic - Orchestration only, NO bpy imports.
This module contains alignment-related business logic and workflow
orchestration. All calculations, algorithms, and IFC operations are
in the tool layer. Functions receive tool classes as parameters
following Bonsai's dependency injection pattern.
NOTE: Math, calculations, algorithms, and IFC API calls belong in
tool/alignment.py. This module only handles:
- Business rules and validation
- Workflow orchestration (calling tool methods in sequence)
- Decision-making about what should happen
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import ifcopenshell
from .. import tool
# =============================================================================
# PI Edit Mode Functions
# =============================================================================
def enter_pi_edit_mode(
ifc_tool: "type[tool.Ifc]",
alignment_tool: "type[tool.Alignment]",
alignment_id: int,
) -> list:
"""Enter PI edit mode for an alignment.
Business logic for entering PI edit mode:
1. Validates that the alignment exists
2. Validates that the alignment has a horizontal layout with real segments
3. Back-calculates PI positions from segments
4. Creates temporary EMPTY objects at each PI location
Args:
ifc_tool: The IFC tool class
alignment_tool: The Alignment tool class
alignment_id: The IFC ID of the alignment to edit
Returns:
List of created PI EMPTY objects
Raises:
ValueError: If alignment doesn't exist, has no horizontal layout,
or has no real segments
"""
# Validate alignment exists
ifc_file = ifc_tool.get()
if ifc_file is None:
raise ValueError("No IFC file loaded")
try:
alignment = ifc_file.by_id(alignment_id)
except RuntimeError:
raise ValueError(f"Alignment with ID {alignment_id} not found")
if not alignment.is_a("IfcAlignment"):
raise ValueError(f"Entity {alignment_id} is not an IfcAlignment")
# Validate alignment has horizontal layout (delegated to tool)
h_layout = alignment_tool.get_horizontal_layout(alignment)
if h_layout is None:
raise ValueError(f"Alignment '{alignment.Name}' has no horizontal layout")
# Validate layout has real segments (not just zero-length terminator)
if not alignment_tool.layout_has_real_segments(h_layout):
raise ValueError(f"Alignment '{alignment.Name}' has no editable segments")
# Back-calculate PI positions from segments
pis = alignment_tool.back_calculate_pis_from_alignment(alignment)
if len(pis) < 2:
raise ValueError(f"Alignment '{alignment.Name}' must have at least 2 PIs")
# Create temporary EMPTY objects at each PI location
empties = alignment_tool.create_pi_edit_empties(alignment, pis)
return empties
def exit_pi_edit_mode(
ifc_tool: "type[tool.Ifc]",
alignment_tool: "type[tool.Alignment]",
alignment_id: int,
apply: bool,
) -> bool:
"""Exit PI edit mode for an alignment.
Business logic for exiting PI edit mode:
1. If apply=True:
- Collect new PI positions from empties
- Validate the new configuration
- Update alignment segments in-place (preserves alignment ID)
2. Always:
- Remove temporary EMPTY objects
- Return success status
This function modifies the alignment segments in-place rather than
deleting and recreating the alignment. This preserves the alignment's
IFC entity ID, preventing stale reference issues.
Args:
ifc_tool: The IFC tool class
alignment_tool: The Alignment tool class
alignment_id: The IFC ID of the alignment being edited
apply: If True, update alignment with new PI positions
Returns:
True if successful
Raises:
ValueError: If alignment doesn't exist or update fails
"""
ifc_file = ifc_tool.get()
if ifc_file is None:
# No file loaded, just clean up empties
alignment_tool.remove_pi_edit_empties(alignment_id)
return True
# Get alignment
try:
alignment = ifc_file.by_id(alignment_id)
except RuntimeError:
# Alignment was deleted, just clean up empties
alignment_tool.remove_pi_edit_empties(alignment_id)
return True
if apply:
# Collect PI positions from empties
hpoints, radii = alignment_tool.collect_pis_from_empties(alignment_id)
if len(hpoints) < 2:
raise ValueError("At least 2 PIs are required")
# Get horizontal layout (delegated to tool)
h_layout = alignment_tool.get_horizontal_layout(alignment)
if h_layout is None:
raise ValueError("Alignment has no horizontal layout")
# Remove empties before modifying segments
alignment_tool.remove_pi_edit_empties(alignment_id)
# Remove Blender visualization for segments (not the whole hierarchy)
alignment_tool.remove_layout_segment_objects(h_layout)
# Clear existing IFC segments and add new ones (delegated to tool)
alignment_tool.clear_layout_segments(h_layout)
alignment_tool.layout_by_pi_method(h_layout, hpoints, radii)
# Refresh Blender visualization for new segments
layout_obj = ifc_tool.get_object(h_layout)
if layout_obj:
alignment_tool.create_objects_for_layout_segments(h_layout, layout_obj)
return True
else:
# Cancel - just remove empties without regenerating
alignment_tool.remove_pi_edit_empties(alignment_id)
return True
+1
View File
@@ -17,6 +17,7 @@
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
from bonsai.tool.aggregate import Aggregate
from bonsai.tool.alignment import Alignment
from bonsai.tool.attribute import Attribute
from bonsai.tool.bcf import Bcf
from bonsai.tool.blender import Blender
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -299,10 +299,10 @@ class Georeference(bonsai.core.tool.Georeference):
)
@classmethod
def enh2xyz(cls, coordinates: tuple[float, float, float]) -> tuple[float, float, float]:
def enh2xyz(cls, coordinates: tuple[float, float, float], to_blender: bool = True) -> tuple[float, float, float]:
coordinates = ifcopenshell.util.geolocation.auto_enh2xyz(tool.Ifc.get(), *coordinates)
props = cls.get_georeference_props()
if props.has_blender_offset:
if to_blender and props.has_blender_offset:
coordinates = ifcopenshell.util.geolocation.enh2xyz(
coordinates[0],
coordinates[1],
+15 -14
View File
@@ -474,10 +474,10 @@ class Root(bonsai.core.tool.Root):
obj.name = obj.name.split("/", 1)[1]
@classmethod
def get_ifc_products(cls) -> tuple[str, ...]:
def get_ifc_products(cls) -> list[str]:
version = tool.Ifc.get_schema()
if version == "IFC2X3":
products = (
return [
"IfcElementType",
"IfcElement",
"IfcFeatureElement",
@@ -485,16 +485,17 @@ class Root(bonsai.core.tool.Root):
"IfcStructuralItem",
"IfcAnnotation",
"IfcRelSpaceBoundary",
)
else:
products = (
"IfcElementType",
"IfcElement",
"IfcFeatureElement",
"IfcSpatialElement",
"IfcSpatialElementType",
"IfcStructuralItem",
"IfcAnnotation",
"IfcRelSpaceBoundary",
)
]
products = [
"IfcElementType",
"IfcElement",
"IfcFeatureElement",
"IfcSpatialElement",
"IfcSpatialElementType",
"IfcStructuralItem",
"IfcAnnotation",
"IfcRelSpaceBoundary",
]
if version != "IFC4":
products.append("IfcAlignment")
return products
@@ -51,6 +51,7 @@ from ._get_segment_start_point_label import register_referent_name_callback
from .add_stationing_referent import add_stationing_referent
from .add_vertical_layout import add_vertical_layout
from .add_zero_length_segment import add_zero_length_segment
from .clear_layout_segments import clear_layout_segments
from .create import create
from .create_as_offset_curve import create_as_offset_curve
from .create_as_polyline import create_as_polyline
@@ -62,6 +63,7 @@ from .create_segment_representations import create_segment_representations
from .distance_along_from_station import distance_along_from_station
from .get_alignment import get_alignment
from .get_alignment_layout_nest import get_alignment_layout_nest
from .get_alignment_layout import get_alignment_layout
from .get_alignment_layouts import get_alignment_layouts
from .get_alignment_segment_nest import get_alignment_segment_nest
from .get_alignment_start_station import get_alignment_start_station
@@ -86,13 +88,16 @@ from .layout_vertical_alignment_by_pi_method import (
layout_vertical_alignment_by_pi_method,
)
from .name_segments import name_segments
from .segment_vertices import segment_vertices
from .update_fallback_position import update_fallback_position
from ._create_geometric_representation import _create_geometric_representation
from .util import *
__all__ = [
"add_stationing_referent",
"add_vertical_layout",
"add_zero_length_segment",
"clear_layout_segments",
"create",
"create_as_offset_curve",
"create_as_polyline",
@@ -101,9 +106,11 @@ __all__ = [
"create_layout_segment",
"create_representation",
"create_segment_representations",
"_create_geometric_representation", # TODO I know I know
"distance_along_from_station",
"get_alignment",
"get_alignment_layout_nest",
"get_alignment_layout",
"get_alignment_layouts",
"get_alignment_segment_nest",
"get_alignment_start_station",
@@ -123,6 +130,7 @@ __all__ = [
"layout_horizontal_alignment_by_pi_method",
"layout_vertical_alignment_by_pi_method",
"name_segments",
"segment_vertices",
"register_referent_name_callback",
"update_fallback_position",
"get_mapped_segments",
@@ -109,6 +109,8 @@ def _add_segment_to_curve(file: ifcopenshell.file, segment: entity_instance, cur
at the end of the curve, but before the manditory zero length segment. The IfcCurveSegment.Transition for the segment
that preceeds the new segment is updated.
The geometric representation is also added to the IfcCurveSegment based on CT 4.1.7.1.1.4 Alignment Geometry - Segments
:param segment: The segment to be added to the curve
:param curve: The representation curve receiving the segment
:return: None
@@ -141,6 +143,23 @@ def _add_segment_to_curve(file: ifcopenshell.file, segment: entity_instance, cur
else:
assert False
items = []
for mapped_segment in mapped_segments:
if mapped_segment:
_add_curve_segment_to_composite_curve(file, mapped_segment, curve)
items.append(mapped_segment)
axis_geom_subcontext = ifcopenshell.api.alignment.get_axis_subcontext(file)
axis_representation = file.createIfcShapeRepresentation(
ContextOfItems=axis_geom_subcontext, RepresentationIdentifier="Axis", RepresentationType="Segment", Items=items
)
product = file.createIfcProductDefinitionShape(Representations=(axis_representation,))
layout = ifcopenshell.api.alignment.get_alignment_layout(segment)
alignment = ifcopenshell.api.alignment.get_alignment(layout)
if alignment != None:
segment.ObjectPlacement = alignment.ObjectPlacement
segment.Representation = product
@@ -36,7 +36,7 @@ def _create_offset_curve_representation(
expected_type = "IfcAlignment"
if not alignment.is_a(expected_type):
raise TypeError(f"Expected {expected_type} but got {alignment.is_a()}")
expected_type = "IfcPointByDistanceExpression"
for offset in offsets:
if not offset.is_a(expected_type):
@@ -0,0 +1,220 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell 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 Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import ifcopenshell
import ifcopenshell.api.alignment
import ifcopenshell.api.nest
import ifcopenshell.util.element
from ifcopenshell import entity_instance
def _is_zero_length_segment(segment: entity_instance) -> bool:
"""Check if segment is a zero-length terminator."""
dp = segment.DesignParameters
if dp.is_a("IfcAlignmentHorizontalSegment"):
return dp.SegmentLength == 0.0
elif dp.is_a("IfcAlignmentVerticalSegment"):
return dp.HorizontalLength == 0.0
elif dp.is_a("IfcAlignmentCantSegment"):
return dp.HorizontalLength == 0.0
return False
def clear_layout_segments(file: ifcopenshell.file, layout: entity_instance) -> None:
"""
Clear all segments from a layout while preserving the layout entity
and zero-length terminator.
This function removes:
- All real (non-zero-length) IfcAlignmentSegment entities from the layout
- Their associated IfcCurveSegment entities from the geometric representation
- Referents positioned on the removed segments
It preserves:
- The layout entity (IfcAlignmentHorizontal, IfcAlignmentVertical, or IfcAlignmentCant)
- The zero-length terminator segment (required by IFC spec)
- The alignment's main stationing referent
:param file: The IFC file
:param layout: An IfcAlignmentHorizontal, IfcAlignmentVertical, or IfcAlignmentCant
Example:
.. code:: python
alignment = model.by_type("IfcAlignment")[0]
h_layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
# Clear existing segments
ifcopenshell.api.alignment.clear_layout_segments(model, h_layout)
# Add new segments with updated PI positions
ifcopenshell.api.alignment.layout_horizontal_alignment_by_pi_method(
model, h_layout, new_hpoints, new_radii
)
"""
expected_types = ["IfcAlignmentHorizontal", "IfcAlignmentVertical", "IfcAlignmentCant"]
if layout.is_a() not in expected_types:
raise TypeError(f"Expected entity type to be one of {expected_types}, instead received {layout.is_a()}")
# Get the geometric curve for this layout
curve = ifcopenshell.api.alignment.get_layout_curve(layout)
# Get all segments from the layout
segments = ifcopenshell.api.alignment.get_layout_segments(layout)
if not segments:
return # Nothing to clear
# Identify segments to remove (all except zero-length terminator)
zero_length_segment = None
segments_to_remove = []
for segment in segments:
if _is_zero_length_segment(segment):
zero_length_segment = segment
else:
segments_to_remove.append(segment)
if not segments_to_remove:
return # Only zero-length terminator exists, nothing to clear
# Collect curve segments to remove before removing alignment segments
# (we need the nesting relationship to find mapped segments)
curve_segments_to_remove = []
for segment in segments_to_remove:
try:
mapped = ifcopenshell.api.alignment.get_mapped_segments(segment)
for cs in mapped:
if cs is not None:
curve_segments_to_remove.append(cs)
except (IndexError, AttributeError):
# Segment might not have curve representation yet
pass
# Remove referents positioned on segments being removed
for segment in segments_to_remove:
# Check for referents positioned relative to this segment
if hasattr(segment, "PositionedRelativeTo") and segment.PositionedRelativeTo:
for rel_pos in segment.PositionedRelativeTo:
referent = rel_pos.RelatingPositioningElement
if referent and referent.is_a("IfcReferent"):
# Remove the referent
ifcopenshell.api.run("root.remove_product", file, product=referent)
# Remove segments from nesting relationship
ifcopenshell.api.nest.unassign_object(file, related_objects=segments_to_remove)
# Remove segment entities
for segment in segments_to_remove:
# Remove design parameters
dp = segment.DesignParameters
if dp:
# Remove StartPoint if it exists
if hasattr(dp, "StartPoint") and dp.StartPoint:
file.remove(dp.StartPoint)
file.remove(dp)
# Remove the segment entity itself
file.remove(segment)
# Clear curve segments from the geometric representation
if curve and curve.Segments:
# Keep only the zero-length curve segment (last one)
if ifcopenshell.api.alignment.has_zero_length_segment(curve):
zero_length_curve_seg = curve.Segments[-1]
# Update curve to only contain zero-length segment
curve.Segments = (zero_length_curve_seg,)
else:
# No zero-length segment in curve, clear all
curve.Segments = ()
# Clean up removed curve segment entities
for cs in curve_segments_to_remove:
try:
# Remove the curve segment's parent curve and placement
if hasattr(cs, "ParentCurve") and cs.ParentCurve:
parent_curve = cs.ParentCurve
# Check if parent curve is used elsewhere
if file.get_total_inverses(parent_curve) <= 1:
# Remove placement if exists
if hasattr(parent_curve, "Position") and parent_curve.Position:
pos = parent_curve.Position
if hasattr(pos, "Location") and pos.Location:
if file.get_total_inverses(pos.Location) <= 1:
file.remove(pos.Location)
if hasattr(pos, "RefDirection") and pos.RefDirection:
if file.get_total_inverses(pos.RefDirection) <= 1:
file.remove(pos.RefDirection)
if file.get_total_inverses(pos) <= 1:
file.remove(pos)
file.remove(parent_curve)
# Remove placement on curve segment
if hasattr(cs, "Placement") and cs.Placement:
placement = cs.Placement
if hasattr(placement, "Location") and placement.Location:
if file.get_total_inverses(placement.Location) <= 1:
file.remove(placement.Location)
if hasattr(placement, "RefDirection") and placement.RefDirection:
if file.get_total_inverses(placement.RefDirection) <= 1:
file.remove(placement.RefDirection)
if file.get_total_inverses(placement) <= 1:
file.remove(placement)
# Remove the curve segment itself
file.remove(cs)
except Exception:
# Entity may have already been removed
pass
# Reset zero-length terminator to origin position
if zero_length_segment:
dp = zero_length_segment.DesignParameters
if dp.is_a("IfcAlignmentHorizontalSegment"):
# Reset StartPoint to origin
if dp.StartPoint:
dp.StartPoint.Coordinates = (0.0, 0.0)
dp.StartDirection = 0.0
elif dp.is_a("IfcAlignmentVerticalSegment"):
dp.StartDistAlong = 0.0
dp.StartHeight = 0.0
dp.StartGradient = 0.0
dp.EndGradient = 0.0
elif dp.is_a("IfcAlignmentCantSegment"):
dp.StartDistAlong = 0.0
dp.StartCantLeft = 0.0
dp.StartCantRight = 0.0
# Update the zero-length segment's referent
if hasattr(zero_length_segment, "PositionedRelativeTo") and zero_length_segment.PositionedRelativeTo:
for rel_pos in zero_length_segment.PositionedRelativeTo:
referent = rel_pos.RelatingPositioningElement
if referent and referent.is_a("IfcReferent"):
# Update referent position to origin
if hasattr(referent, "ObjectPlacement") and referent.ObjectPlacement:
placement = referent.ObjectPlacement
if hasattr(placement, "RelativePlacement") and placement.RelativePlacement:
rel_place = placement.RelativePlacement
if hasattr(rel_place, "Location") and rel_place.Location:
if hasattr(rel_place.Location, "DistanceAlong"):
rel_place.Location.DistanceAlong.wrappedValue = 0.0
if hasattr(placement, "CartesianPosition") and placement.CartesianPosition:
cart_pos = placement.CartesianPosition
if hasattr(cart_pos, "Location") and cart_pos.Location:
cart_pos.Location.Coordinates = (0.0, 0.0, 0.0)
@@ -80,14 +80,29 @@ def create(
if include_geometry:
_create_geometric_representation(file, alignment)
name = ifcopenshell.util.alignment.station_as_string(file, start_station)
referent = ifcopenshell.api.alignment.add_stationing_referent(
file, alignment, 0.0, start_station, name, alignment
)
name = ifcopenshell.util.alignment.station_as_string(file, start_station)
referent = ifcopenshell.api.alignment.add_stationing_referent(file, alignment, 0.0, start_station, name, alignment)
for layout in alignment_layouts:
_add_zero_length_segment(file, layout)
if include_geometry:
# add the representation to the zero length segment
curve = ifcopenshell.api.alignment.get_layout_curve(layout)
axis_geom_subcontext = ifcopenshell.api.alignment.get_axis_subcontext(file)
axis_representation = file.createIfcShapeRepresentation(
ContextOfItems=axis_geom_subcontext,
RepresentationIdentifier="Axis",
RepresentationType="Segment",
Items=(curve.Segments[-1],),
)
product = file.createIfcProductDefinitionShape(Representations=(axis_representation,))
layout.IsNestedBy[0].RelatedObjects[-1].ObjectPlacement = alignment.ObjectPlacement
layout.IsNestedBy[0].RelatedObjects[-1].Representation = product
# IFC 4.1.4.1.1 Alignment Aggregation To Project
project = file.by_type("IfcProject")[0]
if project:
@@ -30,91 +30,6 @@ from ifcopenshell.api.alignment._create_polyline_representation import (
)
def _create_layout(file: ifcopenshell.file, alignment: entity_instance, points: Sequence[entity_instance]):
"""
I don't believe it is required for polylines, but the validation serivce gives an error if the alignment doesn't have a layout
"""
include_vertical = False if points[0].Dim == 2 else True
alignment_layouts = []
alignment_layouts.append(file.createIfcAlignmentHorizontal(GlobalId=ifcopenshell.guid.new()))
if include_vertical:
alignment_layouts.append(file.createIfcAlignmentVertical(GlobalId=ifcopenshell.guid.new()))
ifcopenshell.api.nest.assign_object(file, related_objects=alignment_layouts, relating_object=alignment)
start_dist_along = 0.0
for p1, p2 in zip(points, points[1:]):
x1, y1, z1 = p1.Coordinates
x2, y2, z2 = p2.Coordinates
dir = math.atan2(y2 - y1, x2 - x1)
gradient = (z2 - z1) / (x2 - x1)
length = math.sqrt(math.pow((x2 - x1), 2.0) + math.pow((y2 - y1), 2.0))
hsegment = file.createIfcAlignmentSegment(
ifcopenshell.guid.new(),
DesignParameters=file.createIfcAlignmentHorizontalSegment(
StartPoint=p1,
StartDirection=dir,
StartRadiusOfCurvature=0.0,
EndRadiusOfCurvature=0.0,
SegmentLength=length,
PredefinedType="LINE",
),
)
ifcopenshell.api.nest.assign_object(file, related_objects=[hsegment], relating_object=alignment_layouts[0])
if include_vertical:
vsegment = file.createIfcAlignmentSegment(
ifcopenshell.guid.new(),
DesignParameters=file.createIfcAlignmentVerticalSegment(
StartDistAlong=start_dist_along,
HorizontalLength=length,
StartHeight=z1,
StartGradient=gradient,
EndGradient=gradient,
PredefinedType="CONSTANTGRADIENT",
),
)
ifcopenshell.api.nest.assign_object(file, related_objects=[vsegment], relating_object=alignment_layouts[1])
start_dist_along += length
# zero length segment
hsegment = file.createIfcAlignmentSegment(
ifcopenshell.guid.new(),
DesignParameters=file.createIfcAlignmentHorizontalSegment(
StartPoint=points[-1],
StartDirection=dir,
StartRadiusOfCurvature=0.0,
EndRadiusOfCurvature=0.0,
SegmentLength=0.0,
PredefinedType="LINE",
),
)
ifcopenshell.api.nest.assign_object(file, related_objects=[hsegment], relating_object=alignment_layouts[0])
if include_vertical:
vsegment = file.createIfcAlignmentSegment(
ifcopenshell.guid.new(),
DesignParameters=file.createIfcAlignmentVerticalSegment(
StartDistAlong=start_dist_along,
HorizontalLength=0.0,
StartHeight=points[-1].Coordinates[-1],
StartGradient=gradient,
EndGradient=gradient,
PredefinedType="CONSTANTGRADIENT",
),
)
ifcopenshell.api.nest.assign_object(file, related_objects=[vsegment], relating_object=alignment_layouts[1])
def create_as_polyline(
file: ifcopenshell.file,
name: str,
@@ -34,6 +34,8 @@ def create_layout_segment(
Creates a new IfcAlignmentSegment using the IfcAlignmentParameterSegment design parameters.
The new segment is appended to the layout alignment and the corresponding IfcCurveSegment is created in the geometric representation if it exists.
Additionally, if the geometric representation of the alignment exists, the segment's geometric representation is added to the IfcCurveSegment based on CT 4.1.7.1.1.4 Alignment Geometry - Segments
:param layout: The layout to receive the new layout segment. This parameter is expected to be IfcAlignmentHorizontal, IfcAlignmentVertical or IfcAlignmentCant
:param design_parameters: The parameters defining the segment. Expected to be the appropreate subclass of IfcAlignmentParameterSegment
:return: 4x4 matrix at end of segment as np.array intended to be used as the start point geometry for the next segment or None if there is the geometric representation is not defined.
@@ -0,0 +1,41 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell 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 Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
from collections.abc import Sequence
from ifcopenshell import entity_instance
def get_alignment_layout(segment: entity_instance) -> entity_instance:
"""
Returns the layout alignment that the segment is nested into
"""
expected_types = ["IfcAlignmentSegment"]
if not segment.is_a() in expected_types:
raise TypeError(
f"Expected entity type to be one of {[_ for _ in expected_types]}, instead received '{segment.is_a()}"
)
layout = None
layouts = ["IfcAlignmentHorizontal", "IfcAlignmentVertical", "IfcAlignmentCant"]
for nest in segment.Nests:
if nest.RelatingObject.is_a() in layouts:
layout = nest.RelatingObject
break
return layout
@@ -19,20 +19,12 @@
from collections.abc import Sequence
from ifcopenshell import entity_instance
import ifcopenshell.util.alignment
# TODO remove this function, use util directly
def get_alignment_layouts(alignment: entity_instance) -> Sequence[entity_instance]:
"""
Returns the layout alignments nested to this alignment
"""
layouts = []
for rel in alignment.IsNestedBy:
for layout in rel.RelatedObjects:
if (
layout.is_a("IfcAlignmentHorizontal")
or layout.is_a("IfcAlignmentVertical")
or layout.is_a("IfcAlignmentCant")
):
layouts.append(layout)
return layouts
return ifcopenshell.util.alignment.get_alignment_layouts(alignment)
@@ -44,6 +44,17 @@ def get_mapped_segments(layout_segment: entity_instance) -> Sequence[entity_inst
if not layout_segment.is_a(expected_type):
raise TypeError(f"Expected to see type '{expected_type}', instead received '{layout_segment.is_a()}'.")
# if the representation is attached directly to the layout segment, just get the representation curve
representations = ifcopenshell.util.representation.get_representations_iter(layout_segment)
for representation in representations:
if representation.RepresentationIdentifier == "Axis" and representation.RepresentationType == "Segment":
if len(representation.Items) == 1:
return (representation.Items[0],None)
else:
return representation.Items
# representation was not attached directly to the segment, so we have to find
# them from the composite curve
layout = layout_segment.Nests[0].RelatingObject
curve = ifcopenshell.api.alignment.get_layout_curve(layout)
@@ -0,0 +1,109 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell 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 Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import numpy as np
import ifcopenshell
import ifcopenshell.util.unit
from ifcopenshell import entity_instance, ifcopenshell_wrapper
def _intersect_lines(p1, d1, p2, d2):
x1, y1 = p1
dx1, dy1 = d1
x2, y2 = p2
dx2, dy2 = d2
det = dx1 * dy2 - dy1 * dx2
if abs(det) < 1e-12:
return None # lines are parallel
t = ((x2 - x1) * dy2 - (y2 - y1) * dx2) / det
x = x1 + t * dx1
y = y1 + t * dy1
return (x, y)
def segment_vertices(file: ifcopenshell.file, segment: entity_instance):
"""
Generates segment vertices. Segment vertices are at the start and end as well as the points where the tangents
at the start and end of the segment intersect (the TI point) and where lines
normal (perpendicular) to the start and end of the segment intersect (NI).
TI and NI are None if intersection points do not exist, such as in the case of a line.
:param curve_segment: A curve segment of type IfcAlignmentSegment or IfcCurveSegment
:return: tuples for Start, End, TI, NI
"""
supported_segment_types = ["IFCALIGNMENTSEGMENT", "IFCCURVESEGMENT"]
segment_type = segment.is_a().upper()
if not segment_type in supported_segment_types:
raise NotImplementedError(
f"Expected entity type to be one of {[_ for _ in supported_segment_types]}, got '{segment_type}"
)
# in the general case an IfcAlignmentSegment for a Helmert transition curve
# maps into two IfcCurveSegment geometric representations.
# For that reason, we have a start_segment_curve and and end_segment_curve.
# In the more common case, there is only one IfcCurveSegment geometric representation
# and start_segment_curve and end_segment_curve are equal
if segment_type == "IFCALIGNMENTSEGMENT":
segments = ifcopenshell.api.alignment.get_mapped_segments(segment)
start_segment_curve = segments[0]
end_segment_curve = start_segment_curve if segments[1] == None else segment[1]
else:
start_segment_curve = segment
end_segment_curve = segment
settings = ifcopenshell.geom.settings()
# get parameters at start of start_segment_curve
segment_fn = ifcopenshell_wrapper.map_shape(settings, start_segment_curve.wrapped_data)
segment_evaluator = ifcopenshell_wrapper.function_item_evaluator(settings, segment_fn)
s = segment_evaluator.evaluate(segment_fn.start())
start = np.array(s)
sx = float(start[0, 3])
sy = float(start[1, 3])
sdx = float(start[0, 0])
sdy = float(start[1, 0])
# get parameters at end of end_segment_curve
segment_fn = ifcopenshell_wrapper.map_shape(settings, end_segment_curve.wrapped_data)
segment_evaluator = ifcopenshell_wrapper.function_item_evaluator(settings, segment_fn)
e = segment_evaluator.evaluate(segment_fn.end())
end = np.array(e)
ex = float(end[0, 3])
ey = float(end[1, 3])
edx = float(end[0, 0])
edy = float(end[1, 0])
ti = _intersect_lines((sx, sy), (sdx, sdy), (ex, ey), (edx, edy)) # tangent intersection
sdx = float(start[0, 1])
sdy = float(start[1, 1])
edx = float(end[0, 1])
edy = float(end[1, 1])
ni = _intersect_lines((sx, sy), (sdx, sdy), (ex, ey), (edx, edy)) # normal intersection
return (sx, sy), (ex, ey), ti, ni
@@ -60,8 +60,24 @@ def evaluate_segment(segment: entity_instance, dist_along: float) -> np.ndarray:
segment_type = segment.is_a().upper()
if not segment_type in supported_segment_types:
raise NotImplementedError(f"Expected entity type 'IFCCURVESEGMENT', got '{segment_type}")
if dist_along > segment.SegmentLength:
raise ValueError(f"Provided value {dist_along=} is beyond the end of the segment ({segment.SegmentLength}).")
# Validate dist_along is within segment bounds
# SegmentLength can be negative (indicates curve direction), so we need to handle both cases
seg_len = (
segment.SegmentLength.wrappedValue if hasattr(segment.SegmentLength, "wrappedValue") else segment.SegmentLength
)
if seg_len >= 0:
# Positive length: valid range is 0 to seg_len
if dist_along < 0 or dist_along > seg_len:
raise ValueError(
f"Provided value {dist_along=} is beyond the end of the segment ({segment.SegmentLength})."
)
else:
# Negative length: valid range is seg_len to 0
if dist_along > 0 or dist_along < seg_len:
raise ValueError(
f"Provided value {dist_along=} is beyond the end of the segment ({segment.SegmentLength})."
)
s = ifcopenshell.geom.settings()
function_item = ifcopenshell_wrapper.map_shape(s, segment.wrapped_data)
@@ -90,8 +106,7 @@ def generate_vertices(rep_curve: entity_instance, distance_interval: float = 5.0
)
s = ifcopenshell.geom.settings()
s.set("piecewise-step-type", 0) # 0 = step-size is maximum step size, 1 = step-size is mininimum number of steps
s.set("piecewise-step-size", distance_interval)
s.set("function-step-param", distance_interval)
shape = ifcopenshell.geom.create_shape(s, rep_curve)
vertices = shape.verts
if len(vertices) == 0:
@@ -20,6 +20,7 @@ import math
import ifcopenshell
import ifcopenshell.util.unit
from typing import Sequence
def add_linear_placement_fallback_position(file: ifcopenshell.file) -> ifcopenshell.file:
@@ -109,3 +110,19 @@ def station_as_string(file: ifcopenshell.file, sta: float):
station_string = "-" + station_string
return station_string
def get_alignment_layouts(alignment: ifcopenshell.entity_instance) -> Sequence[ifcopenshell.entity_instance]:
"""
Returns the layout alignments nested to this alignment
"""
layouts = []
for rel in alignment.IsNestedBy:
for layout in rel.RelatedObjects:
if (
layout.is_a("IfcAlignmentHorizontal")
or layout.is_a("IfcAlignmentVertical")
or layout.is_a("IfcAlignmentCant")
):
layouts.append(layout)
return layouts
@@ -0,0 +1,49 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell 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 Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import ifcopenshell.api.alignment
import ifcopenshell.api.context
import ifcopenshell.api.unit
def test_get_alignment_layout():
file = ifcopenshell.file(schema="IFC4X3_ADD2")
project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
length = ifcopenshell.api.unit.add_si_unit(file, unit_type="LENGTHUNIT")
ifcopenshell.api.unit.assign_unit(file, units=[length])
geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model")
axis_model_representation_subcontext = ifcopenshell.api.context.add_context(
file,
context_type="Model",
context_identifier="Axis",
target_view="MODEL_VIEW",
parent=geometric_representation_context,
)
alignment = ifcopenshell.api.alignment.create(file, "Test", include_vertical=True, include_cant=True)
horiz = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
vert = ifcopenshell.api.alignment.get_vertical_layout(alignment)
cant = ifcopenshell.api.alignment.get_cant_layout(alignment)
assert horiz == ifcopenshell.api.alignment.get_alignment_layout(horiz.IsNestedBy[0].RelatedObjects[0])
assert vert == ifcopenshell.api.alignment.get_alignment_layout(vert.IsNestedBy[0].RelatedObjects[0])
assert cant == ifcopenshell.api.alignment.get_alignment_layout(cant.IsNestedBy[0].RelatedObjects[0])
test_get_alignment_layout()
@@ -0,0 +1,172 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell 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 Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import pytest
import ifcopenshell.api.alignment
import ifcopenshell.api.context
import ifcopenshell.api.unit
def unit_convert(unit_scale, p):
if p == None:
return p
x, y = p
return (x / unit_scale, y / unit_scale)
def test_segment_vertices():
file = ifcopenshell.file(schema="IFC4X3")
project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
length = ifcopenshell.api.unit.add_conversion_based_unit(file, name="foot")
angle = ifcopenshell.api.unit.add_si_unit(file, unit_type="PLANEANGLEUNIT")
ifcopenshell.api.unit.assign_unit(file, units=[length, angle])
geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model")
axis_model_representation_subcontext = ifcopenshell.api.context.add_context(
file,
context_type="Model",
context_identifier="Axis",
target_view="MODEL_VIEW",
parent=geometric_representation_context,
)
coordinates = [(500.0, 2500.0), (3340.0, 660.0), (4340.0, 5000.0), (7600.0, 4560.0), (8480.0, 2010.0)]
radii = [(1000.0), (1250.0), (950.0)]
vpoints = [(0.0, 100.0), (2000.0, 135.0), (5000.0, 105.0), (7400.0, 153.0), (9800.0, 105.0), (12800.0, 90.0)]
lengths = [(1600.0), (1200.0), (2000.0), (800.0)]
alignment = ifcopenshell.api.alignment.create_by_pi_method(
file, "TestAlignment", coordinates, radii, vpoints, lengths
)
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file)
# test the horizontal alignment geometry segments
expect = [
[(500.0, 2500.0), (2142.2379952109395, 1436.01482000418), None, None],
[
(2142.2379952109395, 1436.01482000418),
(3660.446122847804, 2050.7361731594674),
(3340.0, 659.9999999999998),
(2685.9792975637306, 2275.267699722618),
],
[(3660.4461228478035, 2050.7361731594674), (4084.115884236641, 3889.4629375870213), None, None],
[
(4084.115884236641, 3889.4629375870218),
(5469.395067206271, 4847.5663099476205),
(4340.0, 5000.000000000001),
(5302.199415841732, 3608.7985293830834),
],
[(5469.395067206271, 4847.56630994762), (7019.971366858418, 4638.286073184753), None, None],
[
(7019.971366858417, 4638.286073184753),
(7790.932128312586, 4006.7307645487535),
(7600.0, 4560.0),
(6892.902671821368, 3696.8225599557054),
],
[(7790.932128312587, 4006.7307645487535), (8480.0, 2010.0000000000002), None, None],
[(8480.0, 2010.0000000000002), (8480.0, 2010.0000000000002), None, None],
]
layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
segments = ifcopenshell.api.alignment.get_layout_segments(layout)
for segment, expected in zip(segments, expect):
s, e, ti, ni = ifcopenshell.api.alignment.segment_vertices(file, segment)
s = unit_convert(unit_scale, s)
e = unit_convert(unit_scale, e)
ti = unit_convert(unit_scale, ti)
ni = unit_convert(unit_scale, ni)
assert s == pytest.approx(expected[0])
assert e == pytest.approx(expected[1])
assert ti == pytest.approx(expected[2])
assert ni == pytest.approx(expected[3])
curve = ifcopenshell.api.alignment.get_basis_curve(alignment)
for segment, expected in zip(curve.Segments, expect):
s, e, ti, ni = ifcopenshell.api.alignment.segment_vertices(file, segment)
s = unit_convert(unit_scale, s)
e = unit_convert(unit_scale, e)
ti = unit_convert(unit_scale, ti)
ni = unit_convert(unit_scale, ni)
assert s == pytest.approx(expected[0])
assert e == pytest.approx(expected[1])
assert ti == pytest.approx(expected[2])
assert ni == pytest.approx(expected[3])
# test vertical curve segments
expect = [
[(0.0, 100.0), (1200.0, 121.0), None, None],
[
(1200.0, 121.0),
(2799.99999384661, 127.00000006153391),
(1999.9999969233054, 134.99999994615786),
(2218.1436363635016, -58058.63636362867),
],
[(2800.0, 127.0), (4400.0, 111.0), None, None],
[
(4400.0, 111.0),
(5599.999994508736, 116.9999998901747),
(4999.999997254367, 105.00000002745632),
(4800.039999999177, 40114.99999991764),
],
[(5600.0, 117.0), (6400.0, 133.0), None, None],
[
(6400.0, 133.0),
(8399.999995932576, 133.0000000813485),
(7399.999997966288, 152.99999995932575),
(7399.999999999187, -49866.99999995936),
],
[(8400.0, 133.0), (9400.0, 113.0), None, None],
[
(9400.0, 113.0),
(10199.99999633883, 103.00000001830585),
(9799.999998169415, 105.00000003661171),
(10466.733333334432, 53449.66666672164),
],
[(10200.0, 103.0), (12800.0, 90.0), None, None],
[(12800.0, 90.0), (12800.0, 90.0), None, None],
]
layout = ifcopenshell.api.alignment.get_vertical_layout(alignment)
segments = ifcopenshell.api.alignment.get_layout_segments(layout)
for segment, expected in zip(segments, expect):
s, e, ti, ni = ifcopenshell.api.alignment.segment_vertices(file, segment)
s = unit_convert(unit_scale, s)
e = unit_convert(unit_scale, e)
ti = unit_convert(unit_scale, ti)
ni = unit_convert(unit_scale, ni)
assert s == pytest.approx(expected[0])
assert e == pytest.approx(expected[1])
assert ti == pytest.approx(expected[2])
assert ni == pytest.approx(expected[3])
curve = ifcopenshell.api.alignment.get_curve(alignment)
for segment, expected in zip(curve.Segments, expect):
s, e, ti, ni = ifcopenshell.api.alignment.segment_vertices(file, segment)
s = unit_convert(unit_scale, s)
e = unit_convert(unit_scale, e)
ti = unit_convert(unit_scale, ti)
ni = unit_convert(unit_scale, ni)
assert s == pytest.approx(expected[0])
assert e == pytest.approx(expected[1])
assert ti == pytest.approx(expected[2])
assert ni == pytest.approx(expected[3])
test_segment_vertices()