mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-06 07:51:47 +00:00
Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b3967609c | |||
| 9a7cd5b373 | |||
| 701635b81c | |||
| 88f6c7cf65 | |||
| cbdf1a193e | |||
| 95048ea2dd | |||
| 0e1a2510ff | |||
| fff653b882 | |||
| 160348df1f | |||
| 278a35e729 | |||
| e1bf717af5 | |||
| be30f9cbea | |||
| 4971546de9 | |||
| d9a3c2d5c2 | |||
| 5066fc5a29 | |||
| 9041857277 | |||
| 7e93a73c6a | |||
| 99640912c1 | |||
| 798ed0d502 | |||
| c3f325861c | |||
| 8433999575 | |||
| bba1ff3786 | |||
| a39e4c552a | |||
| 21c6384b7e | |||
| 07ef382c7e | |||
| 65af40e7d5 | |||
| cfe30a505e | |||
| 04677c1b80 | |||
| f06873d24e | |||
| cd6e531120 | |||
| f9f0f1915f | |||
| e0a1577ae0 | |||
| 33a4639de5 | |||
| a3038528bb | |||
| eea060b72d | |||
| 862697baae | |||
| 9a3ae628aa | |||
| 46da629c8b | |||
| fd85b29777 | |||
| 95952d03f8 | |||
| 5ad40612fb | |||
| 6294c35a11 | |||
| de2a7c9527 | |||
| a37de44a24 | |||
| c0b7256832 | |||
| 42db172827 | |||
| f52e95a05a | |||
| 04ad0b92bd | |||
| 8416ca46a7 | |||
| 10ce30b2ac | |||
| 00c971fa2d | |||
| b86bf45167 | |||
| 22fd318d21 |
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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")])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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],
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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()
|
||||
Reference in New Issue
Block a user