mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-06 07:51:47 +00:00
Compare commits
86 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4043037484 | |||
| eb1e79ea5c | |||
| d2ee4f2f97 | |||
| e09ce1bcd8 | |||
| 0b3967609c | |||
| 9a7cd5b373 | |||
| 701635b81c | |||
| 88f6c7cf65 | |||
| cbdf1a193e | |||
| 95048ea2dd | |||
| 0e1a2510ff | |||
| fff653b882 | |||
| 160348df1f | |||
| 278a35e729 | |||
| e1bf717af5 | |||
| be30f9cbea | |||
| 4971546de9 | |||
| d9a3c2d5c2 | |||
| 5066fc5a29 | |||
| 9041857277 | |||
| 7e93a73c6a | |||
| 99640912c1 | |||
| 798ed0d502 | |||
| c3f325861c | |||
| 8433999575 | |||
| bba1ff3786 | |||
| a39e4c552a | |||
| 21c6384b7e | |||
| 418d410b5c | |||
| a5461c0748 | |||
| 291e815770 | |||
| 07ef382c7e | |||
| ed500d58ba | |||
| 8023a992da | |||
| fcc80ad14a | |||
| c6b14d1474 | |||
| 37fe0ad993 | |||
| e20e286168 | |||
| 8c0bed0c61 | |||
| 8cfb162851 | |||
| 379c74b31f | |||
| 1d1f158fe2 | |||
| 65d5df7801 | |||
| b246998f68 | |||
| a88c5938dc | |||
| 7978f1fb08 | |||
| 7b4889d2ec | |||
| 65af40e7d5 | |||
| cfe30a505e | |||
| 04677c1b80 | |||
| f06873d24e | |||
| cd6e531120 | |||
| f9f0f1915f | |||
| 740fcf7768 | |||
| 067e04b564 | |||
| cd95f46db5 | |||
| 348e48b49c | |||
| 6bb8abdc5a | |||
| b7a8c9b330 | |||
| e6780973da | |||
| c6072e416c | |||
| f5686fff26 | |||
| e0a1577ae0 | |||
| a46cdbb907 | |||
| a4d5d4e19a | |||
| 33a4639de5 | |||
| a3038528bb | |||
| eea060b72d | |||
| 862697baae | |||
| 9a3ae628aa | |||
| 46da629c8b | |||
| fd85b29777 | |||
| 95952d03f8 | |||
| 5ad40612fb | |||
| 6294c35a11 | |||
| de2a7c9527 | |||
| a37de44a24 | |||
| c0b7256832 | |||
| 42db172827 | |||
| f52e95a05a | |||
| 04ad0b92bd | |||
| 8416ca46a7 | |||
| 10ce30b2ac | |||
| 00c971fa2d | |||
| b86bf45167 | |||
| 22fd318d21 |
@@ -82,6 +82,8 @@ src/bonsai/bonsai/translations.py
|
||||
|
||||
# bonsai test temp files
|
||||
src/bonsai/test/files/temp
|
||||
src/bonsai/test/files/basic.ifc.cache.blend
|
||||
src/bonsai/test/files/basic.ifc.cache.sqlite
|
||||
|
||||
# bonsai data
|
||||
src/bonsai/bonsai/bim/data/build/
|
||||
@@ -113,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
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
<!-- This file was generated with the assistance of an AI coding tool. -->
|
||||
|
||||
# AGENTS.md
|
||||
|
||||
Guidelines for AI coding agents contributing to IfcOpenShell. This file is
|
||||
intended to be read by all AI agents regardless of platform (Claude Code,
|
||||
Copilot, Cursor, etc.) in addition to any tool-specific configuration files.
|
||||
|
||||
Human contributors using AI tools should also read this document carefully,
|
||||
as they are responsible for ensuring their contributions comply with these
|
||||
guidelines.
|
||||
|
||||
## Project Overview
|
||||
|
||||
IfcOpenShell is an open source library for working with Industry Foundation
|
||||
Classes (IFC). It provides C++ and Python APIs, geometry processing, and an
|
||||
ecosystem of tools including IfcConvert and the Bonsai Blender add-on.
|
||||
|
||||
## Licensing
|
||||
|
||||
All contributions must be compatible with the project's licensing:
|
||||
|
||||
- **Library code** (everything except Bonsai): **LGPL-3.0-or-later**
|
||||
- **Bonsai** (`src/bonsai/`): **GPL-3.0-or-later**
|
||||
|
||||
There is no Contributor License Agreement (CLA). By submitting a pull request,
|
||||
you agree that your contribution is licensed under the applicable license above.
|
||||
|
||||
## Indicating AI-Generated Code
|
||||
|
||||
Contributors must clearly indicate when code has been generated or
|
||||
substantially written by an AI tool.
|
||||
|
||||
### Commits
|
||||
|
||||
Commits that modify existing code must include a note in the **body** of the
|
||||
commit message (not the subject line) indicating that the change was
|
||||
AI-generated. For example:
|
||||
|
||||
```
|
||||
Fix off-by-one error in element iteration
|
||||
|
||||
The loop termination condition was incorrect when processing
|
||||
IfcRelAggregates relationships.
|
||||
|
||||
Generated with the assistance of an AI coding tool.
|
||||
```
|
||||
|
||||
### New Files
|
||||
|
||||
New files that are AI-generated must include a comment near the top of the
|
||||
file indicating this. Use the appropriate comment syntax for the language:
|
||||
|
||||
```python
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
```
|
||||
|
||||
```cpp
|
||||
// This file was generated with the assistance of an AI coding tool.
|
||||
```
|
||||
|
||||
### Pull Requests
|
||||
|
||||
Pull requests containing AI-generated code must indicate in the PR description
|
||||
which parts of the contribution are AI-generated. If the entire PR is
|
||||
AI-generated, state that clearly. If only specific commits or files are
|
||||
AI-generated, identify them.
|
||||
|
||||
## Pull Request Guidelines
|
||||
|
||||
### Scope and Size
|
||||
|
||||
- Each pull request should address a **single issue or feature**.
|
||||
- Do not mix unrelated changes (e.g., bug fixes with refactoring or style
|
||||
changes) in the same PR.
|
||||
- Large pull requests should be broken down into **multiple small, standalone
|
||||
commits** that are each easy to review independently. Rewrite commit history
|
||||
for this purpose if necessary.
|
||||
- PRs that are minimal, focused solutions to a specific problem are much more
|
||||
likely to be accepted.
|
||||
|
||||
### What to Avoid
|
||||
|
||||
- **Over-engineering**: Do not add features, abstractions, or configurability
|
||||
beyond what is needed to solve the immediate problem.
|
||||
- **Scope creep**: Do not make changes to files or code that are not directly
|
||||
related to the task at hand.
|
||||
- **Unnecessary additions**: Do not add docstrings, comments, type annotations,
|
||||
or error handling to code you did not otherwise need to change.
|
||||
- **Cosmetic changes**: Do not reformat, rename, or reorganize code that is
|
||||
unrelated to your change.
|
||||
|
||||
## Commit Messages
|
||||
|
||||
- The **subject line** must be **50 characters or less**.
|
||||
- Use the **imperative mood** (e.g., "Fix crash in geometry kernel", not
|
||||
"Fixed crash" or "Fixes crash").
|
||||
- A commit message can be a single line if the purpose is obvious from the
|
||||
subject alone.
|
||||
- Otherwise, add a blank line after the subject followed by a short explanation
|
||||
of a few lines in the body.
|
||||
|
||||
## Code Style
|
||||
|
||||
### Python
|
||||
|
||||
- **Line length**: 120 characters
|
||||
- **Formatter**: black
|
||||
- **Linter**: ruff
|
||||
- Configuration is in `pyproject.toml`
|
||||
|
||||
### C++
|
||||
|
||||
- **Standard**: C++17 minimum
|
||||
- **Formatter**: clang-format (configuration in `.clang-format`)
|
||||
- **Linter**: clang-tidy (configuration in `.clang-tidy`)
|
||||
|
||||
Run linters and formatters **before submitting** your pull request. Do not rely
|
||||
on CI to catch formatting issues.
|
||||
|
||||
## Testing
|
||||
|
||||
- Pull requests with test coverage are **much more likely to be merged**.
|
||||
- If tests are appropriate and feasible for your change, they should be
|
||||
included.
|
||||
- Tests are not required for every change (e.g., documentation-only changes),
|
||||
but the expectation is that testable code changes come with tests.
|
||||
- Python tests use **pytest** and are located in `test/` or `tests/` directories
|
||||
within each package under `src/`.
|
||||
- Run the existing test suite for the package you modified before submitting.
|
||||
|
||||
## Architecture Quick Reference
|
||||
|
||||
### Directory Structure
|
||||
|
||||
- `src/ifcparse/` — C++ IFC file parsing
|
||||
- `src/ifcgeom/` — C++ geometry processing (OpenCASCADE and CGAL kernels)
|
||||
- `src/serializers/` — Output format serializers (glTF, Collada, SVG, etc.)
|
||||
- `src/ifcwrap/` — SWIG Python bindings
|
||||
- `src/ifcconvert/` — CLI conversion tool
|
||||
- `src/ifcopenshell-python/` — Python API (`ifcopenshell` package)
|
||||
- `src/bonsai/` — Blender add-on (GPL-3.0-or-later)
|
||||
- `src/ifctester/` — IDS model auditing
|
||||
- `src/ifcpatch/` — IFC file manipulation scripts
|
||||
- `src/ifcdiff/` — IFC model comparison
|
||||
- `src/ifcclash/` — Clash detection
|
||||
- `src/ifccsv/` — Schedule import/export
|
||||
|
||||
### IFC Schema Versions
|
||||
|
||||
The library supports IFC2x3 TC1, IFC4 Add2 TC1, IFC4x1, IFC4x2, and
|
||||
IFC4x3 Add2. Schema-specific code is compiled conditionally. Be aware of
|
||||
which schema versions your change affects.
|
||||
+1
-1
@@ -3,7 +3,7 @@ name = "IfcOpenShell"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"black==26.1.0",
|
||||
"ruff==0.15.0",
|
||||
"ruff==0.15.1",
|
||||
"poethepoet",
|
||||
"gersemi==0.25.4",
|
||||
]
|
||||
|
||||
+1
-1
@@ -371,7 +371,7 @@ test-tool:
|
||||
ifndef MODULE
|
||||
pytest test/tool
|
||||
else
|
||||
pytest test/tool/test_$(MODULE).py
|
||||
pytest test/tool/test_$(MODULE).py --maxfail=1
|
||||
endif
|
||||
|
||||
# Reregistering test is not added to the standard test suite because during unregister
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -52,7 +52,6 @@ class IfcExporter:
|
||||
self.set_header()
|
||||
IfcStore.update_cache()
|
||||
self.sync_all_objects()
|
||||
tool.Project.save_linked_models_to_ifc()
|
||||
extension = self.ifc_export_settings.output_file.split(".")[-1].lower()
|
||||
if extension == "ifczip":
|
||||
with tempfile.TemporaryDirectory() as unzipped_path:
|
||||
|
||||
@@ -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")
|
||||
@@ -163,7 +163,7 @@ class BIMBSDDProperties(PropertyGroup):
|
||||
default=False,
|
||||
)
|
||||
classification_psets: CollectionProperty(name="Classification Psets", type=BSDDPset)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
active_dictionary: str
|
||||
active_dictionary: str
|
||||
@@ -182,7 +182,7 @@ class BIMBSDDProperties(PropertyGroup):
|
||||
should_filter_ifc_class: bool
|
||||
use_only_ifc_properties: bool
|
||||
classification_psets: bpy.types.bpy_prop_collection_idprop[BSDDPset]
|
||||
|
||||
|
||||
@property
|
||||
def active_class(self) -> Union[BSDDClassification, None]:
|
||||
return tool.Blender.get_active_uilist_element(self.classes, self.active_class_index)
|
||||
|
||||
@@ -24,6 +24,7 @@ import bpy
|
||||
from bpy.types import Panel, UIList
|
||||
|
||||
import bonsai.tool as tool
|
||||
import bsdd
|
||||
from bonsai.bim.module.bsdd.data import BSDDData
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -83,7 +84,6 @@ class BIM_PT_bsdd(Panel):
|
||||
row = self.layout.row()
|
||||
row.operator("bim.load_bsdd_dictionaries")
|
||||
|
||||
|
||||
class BIM_UL_bsdd_dictionaries(UIList):
|
||||
def draw_item(
|
||||
self,
|
||||
|
||||
@@ -928,7 +928,7 @@ class CreateDrawing(bpy.types.Operator):
|
||||
|
||||
props = tool.Project.get_project_props()
|
||||
for link in props.get_loaded_links_for_drawings():
|
||||
files[link.name] = self.get_linked_file(link)
|
||||
files[link.filepath] = self.get_linked_file(link)
|
||||
|
||||
target_view = ifcopenshell.util.element.get_psets(self.camera_element)["EPset_Drawing"]["TargetView"]
|
||||
self.setup_serialiser(target_view)
|
||||
@@ -1374,7 +1374,7 @@ class CreateDrawing(bpy.types.Operator):
|
||||
return True
|
||||
|
||||
def get_linked_file(self, link: "Link") -> ifcopenshell.file:
|
||||
link_path = link.name
|
||||
link_path = link.filepath
|
||||
ifc_file = IfcStore.session_files.get(link_path, None)
|
||||
if ifc_file is not None:
|
||||
return ifc_file
|
||||
@@ -2896,12 +2896,16 @@ class AddSchedule(bpy.types.Operator, tool.Ifc.Operator, ImportHelper):
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
bl_description = "Add an .ods, .xls or .xlsx file as a schedule"
|
||||
|
||||
files: bpy.props.CollectionProperty(name="Files", type=bpy.types.OperatorFileListElement)
|
||||
directory: bpy.props.StringProperty(subtype="DIR_PATH")
|
||||
filter_glob: bpy.props.StringProperty(default="*.ods;*.xls;*.xlsx", options={"HIDDEN"})
|
||||
use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=True)
|
||||
|
||||
def _execute(self, context):
|
||||
filepath = tool.Ifc.get_uri(self.filepath, use_relative_path=self.use_relative_path)
|
||||
core.add_document(tool.Ifc, tool.Drawing, "SCHEDULE", uri=filepath)
|
||||
for filepath in tool.Blender.get_selected_files(
|
||||
self.directory, self.files, use_relative_path=self.use_relative_path
|
||||
):
|
||||
core.add_document(tool.Ifc, tool.Drawing, "SCHEDULE", uri=filepath)
|
||||
|
||||
|
||||
class RemoveSchedule(bpy.types.Operator, tool.Ifc.Operator):
|
||||
@@ -3090,23 +3094,16 @@ class AddReference(bpy.types.Operator, tool.Ifc.Operator, ImportHelper):
|
||||
bl_description = "Import a .svg file to the project as a reference"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
files: bpy.props.CollectionProperty(name="Files", type=bpy.types.OperatorFileListElement)
|
||||
directory: bpy.props.StringProperty(subtype="DIR_PATH")
|
||||
filter_glob: bpy.props.StringProperty(default="*.svg", options={"HIDDEN"})
|
||||
use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=True)
|
||||
filename_ext = ".svg"
|
||||
|
||||
files: bpy.props.CollectionProperty(type=bpy.types.OperatorFileListElement)
|
||||
directory: bpy.props.StringProperty(subtype="DIR_PATH")
|
||||
|
||||
def _execute(self, context):
|
||||
# Handle both single and multiple file selection
|
||||
if self.files:
|
||||
for file_elem in self.files:
|
||||
filepath = os.path.join(self.directory, file_elem.name)
|
||||
uri = tool.Ifc.get_uri(filepath, use_relative_path=self.use_relative_path)
|
||||
core.add_document(tool.Ifc, tool.Drawing, "REFERENCE", uri=uri)
|
||||
else:
|
||||
# Fallback for single file (backward compatibility)
|
||||
filepath = tool.Ifc.get_uri(self.filepath, use_relative_path=self.use_relative_path)
|
||||
for filepath in tool.Blender.get_selected_files(
|
||||
self.directory, self.files, use_relative_path=self.use_relative_path
|
||||
):
|
||||
core.add_document(tool.Ifc, tool.Drawing, "REFERENCE", uri=filepath)
|
||||
|
||||
|
||||
@@ -3165,6 +3162,7 @@ class EditTextPopup(bpy.types.Operator):
|
||||
bpy.ops.bim.disable_editing_text()
|
||||
|
||||
def execute(self, context):
|
||||
# TODO: check for possible subtle undo bug here
|
||||
# can't use invoke() because this operator
|
||||
# will be run indirectly by hotkey
|
||||
# so we use execute() and track whether it's the first run of the operator
|
||||
@@ -3817,91 +3815,14 @@ class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator, ImportHelper):
|
||||
description="Existing object name to add a style with reference image to. If not provided will create a new object.",
|
||||
options={"SKIP_SAVE"},
|
||||
)
|
||||
|
||||
x_length: bpy.props.FloatProperty(
|
||||
name="X Length",
|
||||
description="Width of the reference image in project units",
|
||||
default=1.0,
|
||||
min=0.001,
|
||||
soft_min=0.01,
|
||||
precision=3,
|
||||
)
|
||||
y_length: bpy.props.FloatProperty(
|
||||
name="Y Length",
|
||||
description="Height of the reference image in project units",
|
||||
default=1.0,
|
||||
min=0.001,
|
||||
soft_min=0.01,
|
||||
precision=3,
|
||||
)
|
||||
|
||||
show_dimensions_dialog: bpy.props.BoolProperty(default=False, options={"HIDDEN", "SKIP_SAVE"})
|
||||
size: bpy.props.FloatProperty(name="Size", description="Size of the reference image", default=1.0, unit="LENGTH")
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
|
||||
if getattr(self, "show_dimensions_dialog", False):
|
||||
if tool.Ifc.get():
|
||||
length_unit = ifcopenshell.util.unit.get_project_unit(tool.Ifc.get(), "LENGTHUNIT")
|
||||
if length_unit:
|
||||
unit_name = ifcopenshell.util.unit.get_full_unit_name(length_unit).lower()
|
||||
else:
|
||||
unit_name = "project units"
|
||||
layout.label(text=f"Set Reference Image Dimensions (in {unit_name}):")
|
||||
else:
|
||||
layout.label(text="Set Reference Image Dimensions (in project units):")
|
||||
layout.separator()
|
||||
layout.prop(self, "x_length")
|
||||
layout.prop(self, "y_length")
|
||||
else:
|
||||
if Path(tool.Ifc.get_path()).is_file():
|
||||
layout.prop(self, "use_relative_path")
|
||||
else:
|
||||
self.use_relative_path = False
|
||||
layout.label(text="Save the .ifc file first ")
|
||||
layout.label(text="to use relative paths.")
|
||||
layout.prop(self, "override_existing_image")
|
||||
layout.prop(self, "use_existing_object_by_name")
|
||||
|
||||
def invoke(self, context, event):
|
||||
if not getattr(self, "show_dimensions_dialog", False):
|
||||
context.window_manager.fileselect_add(self)
|
||||
return {"RUNNING_MODAL"}
|
||||
else:
|
||||
return context.window_manager.invoke_props_dialog(self)
|
||||
|
||||
def execute(self, context):
|
||||
if not getattr(self, "show_dimensions_dialog", False):
|
||||
abs_path = Path(self.filepath).absolute().resolve()
|
||||
if self.override_existing_image:
|
||||
params = {"check_existing": True, "force_reload": True}
|
||||
else:
|
||||
params = {"check_existing": False}
|
||||
|
||||
try:
|
||||
image = load_image(abs_path.name, str(abs_path.parent), **params)
|
||||
|
||||
image_width_px = image.size[0]
|
||||
image_height_px = image.size[1]
|
||||
aspect_ratio = image_width_px / image_height_px
|
||||
|
||||
if aspect_ratio >= 1.0:
|
||||
self.x_length = 1.0
|
||||
self.y_length = 1.0 / aspect_ratio
|
||||
else:
|
||||
self.x_length = aspect_ratio
|
||||
self.y_length = 1.0
|
||||
|
||||
bpy.data.images.remove(image)
|
||||
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, f"Failed to load image: {str(e)}")
|
||||
return {"CANCELLED"}
|
||||
|
||||
self.show_dimensions_dialog = True
|
||||
return context.window_manager.invoke_props_dialog(self)
|
||||
|
||||
return self._execute(context)
|
||||
if Path(tool.Ifc.get_path()).is_file():
|
||||
self.layout.prop(self, "use_relative_path")
|
||||
self.layout.prop(self, "override_existing_image")
|
||||
self.layout.prop(self, "use_existing_object_by_name")
|
||||
self.layout.prop(self, "size")
|
||||
|
||||
def _execute(self, context):
|
||||
space = tool.Blender.get_view3d_space()
|
||||
@@ -3922,11 +3843,19 @@ class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator, ImportHelper):
|
||||
params = {"check_existing": False}
|
||||
image = load_image(abs_path.name, str(abs_path.parent), **params)
|
||||
|
||||
aspect_ratio = image.size[0] / image.size[1]
|
||||
if aspect_ratio >= 1.0: # Landscape
|
||||
x_length = self.size
|
||||
y_length = self.size / aspect_ratio
|
||||
else:
|
||||
x_length = self.size / aspect_ratio
|
||||
y_length = self.size
|
||||
|
||||
def bm_add_image_plane(mesh):
|
||||
bm = tool.Blender.get_bmesh_for_mesh(mesh, clean=True)
|
||||
|
||||
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file)
|
||||
plane_scale = Vector((self.x_length * unit_scale / 2.0, self.y_length * unit_scale / 2.0, 1.0))
|
||||
plane_scale = Vector((x_length / 2.0, y_length / 2.0, 1.0))
|
||||
matrix = Matrix.LocRotScale(None, None, plane_scale)
|
||||
bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=1, matrix=matrix, calc_uvs=False)
|
||||
|
||||
@@ -4030,8 +3959,6 @@ class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator, ImportHelper):
|
||||
tool.Style.reload_material_from_ifc(material)
|
||||
tool.Geometry.record_object_materials(obj)
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class ConvertSVGToDXF(bpy.types.Operator):
|
||||
bl_idname = "bim.convert_svg_to_dxf"
|
||||
|
||||
@@ -89,7 +89,7 @@ class BIM_PT_camera(Panel):
|
||||
for link in links:
|
||||
row = panel.row(align=True)
|
||||
split = row.split(factor=0.9)
|
||||
split.label(text=link.name, icon="FILE")
|
||||
split.label(text=link.filepath, icon="FILE")
|
||||
split.prop(link, "include_in_drawings", text="")
|
||||
else:
|
||||
panel.label(text="No IFC projects linked and loaded.")
|
||||
|
||||
@@ -902,6 +902,7 @@ class OverrideDelete(bpy.types.Operator):
|
||||
|
||||
if not is_valid_data_block:
|
||||
continue
|
||||
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if element:
|
||||
if tool.Geometry.is_locked(element):
|
||||
@@ -912,6 +913,9 @@ class OverrideDelete(bpy.types.Operator):
|
||||
if ifcopenshell.util.element.get_pset(element, "BBIM_Array"):
|
||||
self.report({"INFO"}, "Elements that are part of an array cannot be deleted.")
|
||||
continue
|
||||
if element.is_a("IfcDocumentReference"):
|
||||
self.report({"INFO"}, "Linked models cannot be deleted.")
|
||||
continue
|
||||
if element.is_a("IfcGridAxis"):
|
||||
# Deleting the last W axis is OK
|
||||
if ((grid := element.PartOfU) and len(grid[0].UAxes) == 1) or (
|
||||
@@ -1208,6 +1212,11 @@ class OverrideDuplicateMove(bpy.types.Operator):
|
||||
operator.report({"ERROR"}, f"Drawing '{obj.name}' not duplicated.")
|
||||
continue
|
||||
|
||||
if element.is_a("IfcDocumentReference"):
|
||||
objects_to_remove.add(obj)
|
||||
operator.report({"ERROR"}, f"Linked model '{obj.name}' not duplicated.")
|
||||
continue
|
||||
|
||||
if tool.Geometry.is_locked(element):
|
||||
objects_to_remove.add(obj)
|
||||
operator.report({"ERROR"}, lock_error_message(obj.name))
|
||||
|
||||
@@ -51,7 +51,7 @@ class RemoveGeoreferencing(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_description = "Remove the georeferencing"
|
||||
|
||||
def _execute(self, context):
|
||||
core.remove_georeferencing(tool.Ifc)
|
||||
core.remove_georeferencing(tool.Ifc, tool.Georeference)
|
||||
|
||||
|
||||
class EditGeoreferencing(bpy.types.Operator, tool.Ifc.Operator):
|
||||
|
||||
@@ -224,16 +224,12 @@ class BIMGeoreferenceProperties(PropertyGroup):
|
||||
x_axis_ordinate: StringProperty(name="X Axis Ordinate", update=update_grid_north_vector)
|
||||
x_axis_is_null: BoolProperty(name="X Axis Is Null")
|
||||
|
||||
# These are only for reference to capture data about a host model from a linked model
|
||||
# If you relink a model from a new host origin, we can autodetect it in theory with this
|
||||
host_model_origin: StringProperty(name="Host Model Origin")
|
||||
host_model_origin_si: StringProperty(name="Host Model Origin SI")
|
||||
host_model_project_north: StringProperty(name="Host Model Angle to Grid North")
|
||||
|
||||
# This is the ENH in project units and SI units of the Blender session's 0,0,0.
|
||||
# These are only for reference, using tool.Georeference.set_model_origin on
|
||||
# project load, project create, and when linking for the first time from an
|
||||
# empty Blender session.
|
||||
model_is_georeferenced: BoolProperty(name="Model Is Georeferenced")
|
||||
model_crs: StringProperty(name="Model CRS")
|
||||
model_origin: StringProperty(name="Model Origin")
|
||||
model_origin_si: StringProperty(name="Model Origin SI")
|
||||
model_project_north: StringProperty(name="Model Angle to Grid North")
|
||||
@@ -275,10 +271,6 @@ class BIMGeoreferenceProperties(PropertyGroup):
|
||||
x_axis_ordinate: str
|
||||
x_axis_is_null: bool
|
||||
|
||||
host_model_origin: str
|
||||
host_model_origin_si: str
|
||||
host_model_project_north: str
|
||||
|
||||
model_origin: str
|
||||
model_origin_si: str
|
||||
model_project_north: str
|
||||
|
||||
@@ -211,22 +211,21 @@ class PolylineOperator:
|
||||
context.workspace.status_text_set(draw_instructions)
|
||||
|
||||
def handle_lock_axis(self, context: bpy.types.Context, event: bpy.types.Event) -> None:
|
||||
angle_snap = tool.Snap.get_angle_snap_value(context)
|
||||
if event.value == "PRESS" and event.type == "A":
|
||||
self.tool_state.lock_axis = False if self.tool_state.lock_axis else True
|
||||
if self.tool_state.lock_axis:
|
||||
self.tool_state.snap_angle = self.input_ui.get_number_value("WORLD_ANGLE")
|
||||
# Round to the closest 5
|
||||
self.tool_state.snap_angle = round(self.tool_state.snap_angle / 5) * 5
|
||||
self.tool_state.snap_angle = round(self.tool_state.snap_angle / angle_snap) * angle_snap
|
||||
|
||||
if event.shift and event.type in {"WHEELUPMOUSE", "WHEELDOWNMOUSE"}:
|
||||
self.tool_state.lock_axis = True
|
||||
self.tool_state.snap_angle = self.input_ui.get_number_value("WORLD_ANGLE")
|
||||
# Round to the closest 5
|
||||
self.tool_state.snap_angle = round(self.tool_state.snap_angle / 5) * 5
|
||||
self.tool_state.snap_angle = round(self.tool_state.snap_angle / angle_snap) * angle_snap
|
||||
if event.type in {"WHEELUPMOUSE"}:
|
||||
self.tool_state.snap_angle += 5
|
||||
self.tool_state.snap_angle += angle_snap
|
||||
else:
|
||||
self.tool_state.snap_angle -= 5
|
||||
self.tool_state.snap_angle -= angle_snap
|
||||
self.handle_mouse_move(context, event)
|
||||
detected_snaps = tool.Snap.detect_snapping_points(context, event, self.objs_2d_bbox, self.tool_state)
|
||||
self.snapping_points = tool.Snap.select_snapping_points(context, event, self.tool_state, detected_snaps)
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import copy
|
||||
from math import atan2, degrees, pi
|
||||
from math import atan2, degrees, pi, radians
|
||||
from typing import Any, Literal, Optional, Union
|
||||
|
||||
import bmesh
|
||||
@@ -195,8 +195,8 @@ class DumbProfileGenerator:
|
||||
if should_round:
|
||||
# Round to nearest 50mm (yes, metric for now)
|
||||
self.length = 0.05 * round(length / 0.05)
|
||||
# Round to nearest 5 degrees
|
||||
nearest_degree = (pi / 180) * 5
|
||||
angle_snap = tool.Snap.get_angle_snap_value(bpy.context)
|
||||
nearest_degree = radians(angle_snap)
|
||||
self.rotation = nearest_degree * round(self.rotation / nearest_degree)
|
||||
self.location = coords[0]
|
||||
data["obj"] = self.create_profile()
|
||||
|
||||
@@ -916,8 +916,8 @@ class DumbWallGenerator:
|
||||
if should_round:
|
||||
# Round to nearest 50mm (yes, metric for now)
|
||||
self.length = 0.05 * round(length / 0.05)
|
||||
# Round to nearest 5 degrees
|
||||
nearest_degree = (math.pi / 180) * 5
|
||||
angle_snap = tool.Snap.get_angle_snap_value(bpy.context)
|
||||
nearest_degree = math.radians(angle_snap)
|
||||
self.rotation = nearest_degree * round(self.rotation / nearest_degree)
|
||||
self.location = coords[0]
|
||||
data["obj"] = self.create_wall()
|
||||
|
||||
@@ -31,27 +31,31 @@ classes = (
|
||||
operator.BIM_OT_load_clipping_planes,
|
||||
operator.BIM_OT_save_clipping_planes,
|
||||
operator.ChangeLibraryElement,
|
||||
operator.ClearMeasurement,
|
||||
operator.ClearRecentIFCProjects,
|
||||
operator.CreateClippingPlane,
|
||||
operator.CreateProject,
|
||||
operator.DisableCulling,
|
||||
operator.DisableEditingHeader,
|
||||
operator.DisableEditingLink,
|
||||
operator.EditHeader,
|
||||
operator.EditLink,
|
||||
operator.EditProjectLibrary,
|
||||
operator.EnableCulling,
|
||||
operator.EnableEditingHeader,
|
||||
operator.EnableEditingLink,
|
||||
operator.ExportIFC,
|
||||
operator.FlipClippingPlane,
|
||||
operator.IFCFileHandlerOperator,
|
||||
operator.ImageScalingTool,
|
||||
operator.LinkIfc,
|
||||
operator.LoadBlendMetadataAndIFC,
|
||||
operator.LoadLink,
|
||||
operator.LoadLinkedProject,
|
||||
operator.LoadProject,
|
||||
operator.LoadProjectElements,
|
||||
operator.MeasureTool,
|
||||
operator.MeasureFaceAreaTool,
|
||||
operator.ClearMeasurement,
|
||||
operator.MeasureTool,
|
||||
operator.NewProject,
|
||||
operator.QueryLinkedElement,
|
||||
operator.RefreshClippingPlanes,
|
||||
@@ -69,7 +73,6 @@ classes = (
|
||||
operator.UnassignLibraryDeclaration,
|
||||
operator.UnlinkIfc,
|
||||
operator.UnloadLink,
|
||||
operator.LoadBlendMetadataAndIFC,
|
||||
workspace.ExploreHotkey,
|
||||
prop.LibraryBreadcrumb,
|
||||
prop.LibraryElement,
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import math
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
@@ -59,6 +60,15 @@ import bonsai.core.project as core
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim import export_ifc, import_ifc
|
||||
from bonsai.bim.ifc import IfcStore
|
||||
from bonsai.bim.ui import IFCFileSelector
|
||||
from bonsai.bim import import_ifc
|
||||
from bonsai.bim import export_ifc
|
||||
from math import radians, degrees
|
||||
from pathlib import Path
|
||||
from collections import defaultdict
|
||||
from mathutils import Vector, Matrix
|
||||
from bpy.app.handlers import persistent
|
||||
from ifcopenshell.geom import ShapeElementType
|
||||
from bonsai.bim.module.model.decorator import FaceAreaDecorator, PolylineDecorator
|
||||
from bonsai.bim.module.model.polyline import PolylineOperator
|
||||
from bonsai.bim.module.project.data import LinksData, ProjectLibraryData
|
||||
@@ -1321,7 +1331,7 @@ class ToggleFilterCategories(bpy.types.Operator):
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class LinkIfc(bpy.types.Operator, ImportHelper):
|
||||
class LinkIfc(bpy.types.Operator, ImportHelper, tool.Ifc.Operator):
|
||||
bl_idname = "bim.link_ifc"
|
||||
bl_label = "Link IFC"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
@@ -1360,134 +1370,125 @@ class LinkIfc(bpy.types.Operator, ImportHelper):
|
||||
row = self.layout.row()
|
||||
row.prop(pprops, "project_north")
|
||||
|
||||
def execute(self, context):
|
||||
def _execute(self, context):
|
||||
start = time.time()
|
||||
files = [f.name for f in self.files] if self.files else [self.filepath]
|
||||
|
||||
if not files or all(not f or not f.strip() for f in files):
|
||||
self.report({"ERROR"}, "No file selected")
|
||||
return {"CANCELLED"}
|
||||
|
||||
existing_links = tool.Project.get_linked_models_documents() if tool.Ifc.get() else {}
|
||||
for filename in files:
|
||||
if not filename or not filename.strip():
|
||||
continue
|
||||
filepath = Path(self.directory) / filename
|
||||
if bpy.data.filepath and filepath.samefile(bpy.data.filepath):
|
||||
self.report({"INFO"}, "Can't link the current .blend file")
|
||||
continue
|
||||
props = tool.Project.get_project_props()
|
||||
new = props.links.add()
|
||||
filepath = tool.Ifc.get_uri(filepath, use_relative_path=self.use_relative_path)
|
||||
|
||||
new = props.links.add()
|
||||
if tool.Ifc.get():
|
||||
if not (document := existing_links.get(filepath)):
|
||||
document = ifcopenshell.api.document.add_information(tool.Ifc.get())
|
||||
document.Name = Path(filepath).name
|
||||
document.Scope = "LINKED_MODEL"
|
||||
reference = ifcopenshell.api.document.add_reference(tool.Ifc.get(), information=document)
|
||||
reference[1] = ",".join([str(o) for o in np.eye(4).flatten().tolist()])
|
||||
reference.Location = filepath.replace("\\", "/")
|
||||
new.ifc_definition_id = reference.id()
|
||||
new.name = filepath
|
||||
status = bpy.ops.bim.load_link(filepath=filepath, use_cache=self.use_cache)
|
||||
if status == {"CANCELLED"}:
|
||||
error_msg = (
|
||||
f'Error processing IFC file "{filepath}" '
|
||||
"was critical and blend file either wasn't saved or wasn't updated. "
|
||||
"See logs above in system console for details."
|
||||
)
|
||||
print(error_msg)
|
||||
self.report({"ERROR"}, error_msg)
|
||||
return {"FINISHED"}
|
||||
print(f"Finished linking {len(files)} IFCs", time.time() - start)
|
||||
return {"FINISHED"}
|
||||
new.filepath = filepath
|
||||
bpy.ops.bim.load_link(link_index=-1, use_cache=self.use_cache)
|
||||
|
||||
|
||||
class UnlinkIfc(bpy.types.Operator):
|
||||
class UnlinkIfc(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.unlink_ifc"
|
||||
bl_label = "Unlink IFC"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
bl_description = "Remove the selected file from the link list"
|
||||
filepath: bpy.props.StringProperty()
|
||||
link_index: bpy.props.IntProperty(name="Link Index")
|
||||
|
||||
def execute(self, context):
|
||||
filepath = Path(self.filepath).as_posix()
|
||||
bpy.ops.bim.unload_link(filepath=filepath)
|
||||
def _execute(self, context):
|
||||
props = tool.Project.get_project_props()
|
||||
index = props.links.find(filepath)
|
||||
if index != -1:
|
||||
props.links.remove(index)
|
||||
return {"FINISHED"}
|
||||
link = props.links[self.link_index]
|
||||
bpy.ops.bim.unload_link(link_index=self.link_index)
|
||||
if tool.Ifc.get():
|
||||
reference = tool.Ifc.get().by_id(link.ifc_definition_id)
|
||||
document = tool.Document.get_reference_document(reference)
|
||||
ifcopenshell.api.document.remove_reference(tool.Ifc.get(), reference)
|
||||
if document and not tool.Document.get_document_references(document):
|
||||
ifcopenshell.api.document.remove_information(tool.Ifc.get(), document)
|
||||
props.links.remove(self.link_index)
|
||||
|
||||
|
||||
class UnloadLink(bpy.types.Operator):
|
||||
class UnloadLink(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.unload_link"
|
||||
bl_label = "Unload Link"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
bl_description = "Unload the selected linked file"
|
||||
filepath: bpy.props.StringProperty()
|
||||
link_index: bpy.props.IntProperty(name="Link Index")
|
||||
|
||||
def execute(self, context):
|
||||
filepath = tool.Blender.ensure_blender_path_is_abs(Path(self.filepath))
|
||||
if filepath.suffix.lower() == ".ifc":
|
||||
filepath = filepath.with_suffix(".ifc.cache.blend")
|
||||
|
||||
for library in list(bpy.data.libraries):
|
||||
if tool.Blender.ensure_blender_path_is_abs(Path(library.filepath)) == filepath:
|
||||
def _execute(self, context):
|
||||
link = tool.Project.get_project_props().links[self.link_index]
|
||||
if obj := tool.Project.get_link_empty_handle(link):
|
||||
collection = obj.instance_collection
|
||||
library = collection.library
|
||||
tool.Ifc.unlink(obj=obj)
|
||||
bpy.data.objects.remove(obj)
|
||||
if collection.users == 0:
|
||||
bpy.data.collections.remove(collection)
|
||||
if not len([c for c in bpy.data.collections if c.library == library]):
|
||||
bpy.data.libraries.remove(library)
|
||||
|
||||
props = tool.Project.get_project_props()
|
||||
links = props.links
|
||||
link = links[self.filepath]
|
||||
# Let's assume that user might delete it.
|
||||
if empty_handle := link.empty_handle:
|
||||
bpy.data.objects.remove(empty_handle)
|
||||
|
||||
# following lines removes the library also when use_relative_path=True, otherwise it doesn't
|
||||
libraries = bpy.data.libraries
|
||||
for library in libraries:
|
||||
if library.name == self.filepath + ".cache.blend":
|
||||
bpy.data.libraries.remove(library)
|
||||
|
||||
link.is_loaded = False
|
||||
|
||||
if not any([l.is_loaded for l in links]):
|
||||
ProjectDecorator.uninstall()
|
||||
# we make sure we don't draw queried object from the file that was just unlinked
|
||||
elif queried_obj := props.queried_obj:
|
||||
queried_filepath = Path(queried_obj["ifc_filepath"])
|
||||
if queried_filepath == filepath:
|
||||
ProjectDecorator.uninstall()
|
||||
|
||||
return {"FINISHED"}
|
||||
ProjectDecorator.uninstall()
|
||||
|
||||
|
||||
class LoadLink(bpy.types.Operator):
|
||||
class LoadLink(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.load_link"
|
||||
bl_label = "Load Link"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
bl_description = "Load the selected file"
|
||||
filepath: bpy.props.StringProperty(name="Link Filepath")
|
||||
link_index: bpy.props.IntProperty(name="Link Index")
|
||||
use_cache: bpy.props.BoolProperty(name="Use Cache", default=True)
|
||||
|
||||
filepath_: Path
|
||||
|
||||
def execute(self, context):
|
||||
filepath = Path(tool.Ifc.resolve_uri(self.filepath))
|
||||
def _execute(self, context):
|
||||
self.link = tool.Project.get_project_props().links[self.link_index]
|
||||
filepath = Path(tool.Ifc.resolve_uri(self.link.filepath))
|
||||
if not filepath.exists():
|
||||
self.report({"ERROR"}, f"File does not exist: '{filepath}'")
|
||||
return {"CANCELLED"}
|
||||
self.filepath_ = filepath
|
||||
if filepath.suffix.lower().endswith(".blend"):
|
||||
self.link_blend(filepath)
|
||||
elif filepath.suffix.lower().endswith(".ifc"):
|
||||
status = self.link_ifc()
|
||||
if status:
|
||||
return status
|
||||
return {"FINISHED"}
|
||||
if filepath.suffix.lower().endswith(".ifc"):
|
||||
return self.link_ifc()
|
||||
|
||||
def link_blend(self, filepath: Path) -> None:
|
||||
with bpy.data.libraries.load(str(filepath), link=True) as (data_from, data_to):
|
||||
data_to.scenes = data_from.scenes
|
||||
link = tool.Project.get_project_props().links[self.filepath]
|
||||
for scene in bpy.data.scenes:
|
||||
if not scene.library or Path(scene.library.filepath) != filepath:
|
||||
data_to.collections = [c for c in data_from.collections if "IfcProject" in c]
|
||||
|
||||
# Find the linked collection
|
||||
for collection in bpy.data.collections:
|
||||
if not collection.library or Path(collection.library.filepath) != filepath:
|
||||
continue
|
||||
for child in scene.collection.children:
|
||||
if "IfcProject" not in child.name:
|
||||
continue
|
||||
empty = bpy.data.objects.new(child.name, None)
|
||||
empty.instance_type = "COLLECTION"
|
||||
empty.instance_collection = child
|
||||
link.empty_handle = empty
|
||||
bpy.context.scene.collection.objects.link(empty)
|
||||
break
|
||||
# Create unique empty instance for this link
|
||||
empty_name = collection.name
|
||||
empty = bpy.data.objects.new(empty_name, None)
|
||||
empty.instance_type = "COLLECTION"
|
||||
empty.instance_collection = collection
|
||||
empty.matrix_world = Matrix(tool.Project.calculate_link_matrix(self.link))
|
||||
|
||||
tool.Project.set_link_empty_handle(self.link, empty)
|
||||
bpy.context.scene.collection.objects.link(empty)
|
||||
self.link.is_loaded = True
|
||||
if tool.Ifc.get(): # For non-IFC projects, locking has no meaning
|
||||
tool.Geometry.lock_object(empty)
|
||||
tool.Blender.select_and_activate_single_object(bpy.context, empty)
|
||||
break
|
||||
link.is_loaded = True
|
||||
tool.Blender.select_and_activate_single_object(bpy.context, empty)
|
||||
else:
|
||||
print(f"WARNING: No IfcProject collection found in {filepath}")
|
||||
self.link.is_loaded = False
|
||||
|
||||
def link_ifc(self) -> Union[set[str], None]:
|
||||
blend_filepath = self.filepath_.with_suffix(".ifc.cache.blend")
|
||||
@@ -1502,14 +1503,12 @@ class LoadLink(bpy.types.Operator):
|
||||
|
||||
code = f"""
|
||||
import bpy
|
||||
import sys
|
||||
|
||||
def run():
|
||||
import bonsai.tool as tool
|
||||
gprops = tool.Georeference.get_georeference_props()
|
||||
# Our model origin becomes their host model origin
|
||||
gprops.host_model_origin = "{gprops.model_origin}"
|
||||
gprops.host_model_origin_si = "{gprops.model_origin_si}"
|
||||
gprops.host_model_project_north = "{gprops.model_project_north}"
|
||||
gprops.has_blender_offset = {gprops.has_blender_offset}
|
||||
gprops.blender_offset_x = "{gprops.blender_offset_x}"
|
||||
gprops.blender_offset_y = "{gprops.blender_offset_y}"
|
||||
@@ -1522,7 +1521,12 @@ def run():
|
||||
pprops.false_origin = "{pprops.false_origin}"
|
||||
pprops.project_north = "{pprops.project_north}"
|
||||
# Use absolute path to be safe from cwd changes.
|
||||
bpy.ops.bim.load_linked_project(filepath=r"{str(self.filepath_)}")
|
||||
try:
|
||||
bpy.ops.bim.load_linked_project(filepath=r"{str(self.filepath_)}")
|
||||
except RuntimeError as e:
|
||||
# Operator failed (returned CANCELLED with error report)
|
||||
print(f"Failed to load linked project: {{e}}")
|
||||
sys.exit(1)
|
||||
# Use str instead of as_posix to avoid issues with Windows shared paths.
|
||||
bpy.ops.wm.save_as_mainfile(filepath=r"{str(blend_filepath)}")
|
||||
|
||||
@@ -1556,14 +1560,17 @@ except Exception as e:
|
||||
if not blend_filepath.exists() or blend_filepath.stat().st_mtime < t:
|
||||
return {"CANCELLED"}
|
||||
|
||||
self.set_model_origin_from_link()
|
||||
|
||||
self.set_model_origin_from_link()
|
||||
self.set_georeferencing_indicator()
|
||||
self.link_blend(blend_filepath)
|
||||
|
||||
def set_model_origin_from_link(self) -> None:
|
||||
if tool.Ifc.get():
|
||||
return # The current model's coordinates always take priority.
|
||||
|
||||
if len(tool.Project.get_project_props().links) > 1:
|
||||
return # Only the first link sets the origin
|
||||
|
||||
json_filepath = self.filepath_.with_suffix(".ifc.cache.json")
|
||||
if not json_filepath.exists():
|
||||
return
|
||||
@@ -1576,21 +1583,36 @@ except Exception as e:
|
||||
if (value := data.get(prop, None)) is not None:
|
||||
setattr(gprops, prop, value)
|
||||
|
||||
def set_georeferencing_indicator(self) -> None:
|
||||
if not tool.Ifc.get():
|
||||
self.link.georeferenced = "NONE"
|
||||
return
|
||||
if not (crs_name := (ifcopenshell.util.geolocation.get_crs(tool.Ifc.get()) or {}).get("Name", "")):
|
||||
self.link.georeferenced = "NONE"
|
||||
return
|
||||
reference = tool.Ifc.get().by_id(self.link.ifc_definition_id)
|
||||
json_filepath = Path(reference.Location).with_suffix(".ifc.cache.json")
|
||||
if not json_filepath.exists():
|
||||
self.link.georeferenced = "NONE"
|
||||
return
|
||||
with open(json_filepath, "r") as f:
|
||||
data = json.load(f)
|
||||
if not data["model_is_georeferenced"]:
|
||||
self.link.georeferenced = "NONE"
|
||||
else:
|
||||
self.link.georeferenced = "FULL_COMPATIBLE" if crs_name == data["model_crs"] else "NOT_COMPATIBLE"
|
||||
|
||||
|
||||
class ReloadLink(bpy.types.Operator):
|
||||
bl_idname = "bim.reload_link"
|
||||
bl_label = "Reload Link"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
bl_description = "Reload the selected file"
|
||||
filepath: bpy.props.StringProperty()
|
||||
link_index: bpy.props.IntProperty(name="Link Index")
|
||||
|
||||
def execute(self, context):
|
||||
is_abs = os.path.isabs(Path(self.filepath))
|
||||
use_relative_path = not is_abs
|
||||
bpy.ops.bim.unlink_ifc(filepath=self.filepath)
|
||||
filepath = tool.Ifc.resolve_uri(self.filepath)
|
||||
status = bpy.ops.bim.link_ifc(filepath=filepath, use_cache=False, use_relative_path=use_relative_path)
|
||||
return {"FINISHED"}
|
||||
bpy.ops.bim.unload_link(link_index=self.link_index)
|
||||
return bpy.ops.bim.load_link(link_index=self.link_index, use_cache=False) or {"FINISHED"}
|
||||
|
||||
|
||||
class ToggleLinkSelectability(bpy.types.Operator):
|
||||
@@ -1598,16 +1620,18 @@ class ToggleLinkSelectability(bpy.types.Operator):
|
||||
bl_label = "Toggle Link Selectability"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
bl_description = "Toggle selectability"
|
||||
link: bpy.props.StringProperty(name="Linked IFC Filepath")
|
||||
link_index: bpy.props.IntProperty(name="Link Index")
|
||||
|
||||
def execute(self, context):
|
||||
props = tool.Project.get_project_props()
|
||||
link = props.links[self.link]
|
||||
self.library_filepath = tool.Blender.ensure_blender_path_is_abs(Path(self.link).with_suffix(".ifc.cache.blend"))
|
||||
link = props.links[self.link_index]
|
||||
self.library_filepath = tool.Blender.ensure_blender_path_is_abs(
|
||||
Path(link.filepath).with_suffix(".ifc.cache.blend")
|
||||
)
|
||||
link.is_selectable = (is_selectable := not link.is_selectable)
|
||||
for collection in self.get_linked_collections():
|
||||
collection.hide_select = not is_selectable
|
||||
if handle := link.empty_handle:
|
||||
if handle := tool.Project.get_link_empty_handle(link):
|
||||
handle.hide_select = not is_selectable
|
||||
return {"FINISHED"}
|
||||
|
||||
@@ -1624,13 +1648,15 @@ class ToggleLinkVisibility(bpy.types.Operator):
|
||||
bl_label = "Toggle Link Visibility"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
bl_description = "Toggle visibility between SOLID and WIREFRAME"
|
||||
link: bpy.props.StringProperty(name="Linked IFC Filepath")
|
||||
link_index: bpy.props.IntProperty(name="Link Index")
|
||||
mode: bpy.props.EnumProperty(name="Visibility Mode", items=((i, i, "") for i in ("WIREFRAME", "VISIBLE")))
|
||||
|
||||
def execute(self, context):
|
||||
props = tool.Project.get_project_props()
|
||||
link = props.links[self.link]
|
||||
self.library_filepath = tool.Blender.ensure_blender_path_is_abs(Path(self.link).with_suffix(".ifc.cache.blend"))
|
||||
link = props.links[self.link_index]
|
||||
self.library_filepath = tool.Blender.ensure_blender_path_is_abs(
|
||||
Path(link.filepath).with_suffix(".ifc.cache.blend")
|
||||
)
|
||||
if self.mode == "WIREFRAME":
|
||||
self.toggle_wireframe(link)
|
||||
elif self.mode == "VISIBLE":
|
||||
@@ -1652,7 +1678,7 @@ class ToggleLinkVisibility(bpy.types.Operator):
|
||||
layer_collections = tool.Blender.get_layer_collections_mapping(linked_collections)
|
||||
for layer_collection in layer_collections.values():
|
||||
layer_collection.exclude = is_hidden
|
||||
if handle := link.empty_handle:
|
||||
if handle := tool.Project.get_link_empty_handle(link):
|
||||
handle.hide_set(is_hidden)
|
||||
|
||||
def get_linked_collections(self) -> list[bpy.types.Collection]:
|
||||
@@ -1663,17 +1689,95 @@ class ToggleLinkVisibility(bpy.types.Operator):
|
||||
]
|
||||
|
||||
|
||||
class EnableEditingLink(bpy.types.Operator):
|
||||
bl_idname = "bim.enable_editing_link"
|
||||
bl_label = "Enable Editing Link"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
bl_description = "Enable editing link location"
|
||||
|
||||
def execute(self, context):
|
||||
link = tool.Project.get_project_props().active_link
|
||||
link.is_editing = True
|
||||
tool.Geometry.unlock_object(tool.Project.get_link_empty_handle(link))
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class DisableEditingLink(bpy.types.Operator):
|
||||
bl_idname = "bim.disable_editing_link"
|
||||
bl_label = "Disable Editing Link"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
bl_description = "Disable editing link and restore to previously saved location"
|
||||
|
||||
def execute(self, context):
|
||||
link = tool.Project.get_project_props().active_link
|
||||
link.is_editing = False
|
||||
obj = tool.Project.get_link_empty_handle(link)
|
||||
obj.matrix_world = Matrix(tool.Project.calculate_link_matrix(link))
|
||||
tool.Geometry.lock_object(obj)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class EditLink(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.edit_link"
|
||||
bl_label = "Edit Link"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
bl_description = "Disable editing link and restore to previously saved location"
|
||||
|
||||
def _execute(self, context):
|
||||
link = tool.Project.get_project_props().active_link
|
||||
link.is_editing = False
|
||||
obj = tool.Project.get_link_empty_handle(link)
|
||||
new_obj_matrix = obj.matrix_world
|
||||
|
||||
filepath = Path(tool.Ifc.resolve_uri(link.filepath))
|
||||
with open(filepath.with_suffix(".ifc.cache.json"), "r") as f:
|
||||
metadata = json.load(f)
|
||||
|
||||
rot = ifcopenshell.util.shape_builder.np_rotation_matrix(
|
||||
radians(-float(metadata["model_project_north"])), 4, "Z"
|
||||
)
|
||||
global_matrix = rot @ np.eye(4)
|
||||
global_matrix[:, 3][:3] = [float(o) for o in metadata["model_origin_si"].split(",")]
|
||||
|
||||
gprops = tool.Georeference.get_georeference_props()
|
||||
rot = ifcopenshell.util.shape_builder.np_rotation_matrix(radians(-float(gprops.model_project_north)), 4, "Z")
|
||||
local_matrix = rot @ np.eye(4)
|
||||
local_matrix[:, 3][:3] = [float(o) for o in gprops.model_origin_si.split(",")]
|
||||
|
||||
# obj_matrix is typically calculated as:
|
||||
# obj_matrix = np.linalg.inv(local_matrix) @ transformation @ global_matrix
|
||||
# So let's calculate the transformation
|
||||
|
||||
transformed_global_matrix = local_matrix @ np.array(new_obj_matrix)
|
||||
transformation = transformed_global_matrix @ np.linalg.inv(global_matrix)
|
||||
if np.allclose(transformation, np.eye(4)):
|
||||
link.has_transformation = True
|
||||
transformation = ",".join(map(str, np.eye(4).reshape(-1)))
|
||||
else:
|
||||
link.has_transformation = False
|
||||
transformation = ",".join(map(str, transformation.reshape(-1)))
|
||||
|
||||
if tool.Ifc.get():
|
||||
reference = tool.Ifc.get().by_id(link.ifc_definition_id)
|
||||
reference[1] = transformation
|
||||
else:
|
||||
link.transformation = transformation
|
||||
|
||||
obj.matrix_world = Matrix(tool.Project.calculate_link_matrix(link))
|
||||
tool.Geometry.lock_object(obj)
|
||||
|
||||
|
||||
class SelectLinkHandle(bpy.types.Operator):
|
||||
bl_idname = "bim.select_link_handle"
|
||||
bl_label = "Select Link Handle"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
bl_description = "Select link empty object handle"
|
||||
index: bpy.props.IntProperty(name="Link Index")
|
||||
link_index: bpy.props.IntProperty(name="Link Index")
|
||||
|
||||
def execute(self, context):
|
||||
props = tool.Project.get_project_props()
|
||||
link = props.links[self.index]
|
||||
handle = link.empty_handle
|
||||
link = props.links[self.link_index]
|
||||
handle = tool.Project.get_link_empty_handle(link)
|
||||
if not handle:
|
||||
self.report({"ERROR"}, "Link has no empty handle (probably it was deleted).")
|
||||
return {"CANCELLED"}
|
||||
@@ -1866,7 +1970,14 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper):
|
||||
print("Processing", self.filepath)
|
||||
|
||||
self.collection = bpy.data.collections.new("IfcProject/" + os.path.basename(self.filepath))
|
||||
self.file = ifcopenshell.open(self.filepath)
|
||||
|
||||
try:
|
||||
self.file = ifcopenshell.open(self.filepath)
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, f"Failed to open IFC file: {str(e)}")
|
||||
bpy.data.collections.remove(self.collection)
|
||||
return {"CANCELLED"}
|
||||
|
||||
tool.Ifc.set(self.file)
|
||||
print("Finished opening")
|
||||
|
||||
@@ -1897,20 +2008,13 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper):
|
||||
if tool.Loader.settings.false_origin_mode == "MANUAL" and tool.Loader.settings.false_origin:
|
||||
tool.Loader.set_manual_blender_offset(self.file)
|
||||
elif tool.Loader.settings.false_origin_mode == "AUTOMATIC":
|
||||
if host_model_origin_si := gprops.host_model_origin_si:
|
||||
host_model_origin_si = [float(o) / self.unit_scale for o in host_model_origin_si.split(",")]
|
||||
tool.Loader.settings.false_origin = host_model_origin_si
|
||||
tool.Loader.settings.project_north = float(gprops.host_model_project_north)
|
||||
tool.Loader.set_manual_blender_offset(self.file)
|
||||
else:
|
||||
tool.Loader.guess_false_origin(self.file)
|
||||
tool.Loader.guess_false_origin(self.file)
|
||||
|
||||
tool.Georeference.set_model_origin()
|
||||
self.json_filepath = self.filepath + ".cache.json"
|
||||
data = {
|
||||
"host_model_origin": gprops.host_model_origin,
|
||||
"host_model_origin_si": gprops.host_model_origin_si,
|
||||
"host_model_project_north": gprops.host_model_project_north,
|
||||
"model_is_georeferenced": gprops.model_is_georeferenced,
|
||||
"model_crs": gprops.model_crs,
|
||||
"model_origin": gprops.model_origin,
|
||||
"model_origin_si": gprops.model_origin_si,
|
||||
"model_project_north": gprops.model_project_north,
|
||||
|
||||
@@ -16,8 +16,9 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import os
|
||||
import math
|
||||
from collections.abc import Generator
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Literal, Union, assert_never, get_args
|
||||
|
||||
import bpy
|
||||
@@ -219,29 +220,62 @@ class FilterCategory(PropertyGroup):
|
||||
|
||||
|
||||
class Link(PropertyGroup):
|
||||
name: StringProperty(
|
||||
name="Name",
|
||||
name: StringProperty(name="Name")
|
||||
filepath: StringProperty(
|
||||
name="Filepath",
|
||||
description="Filepath to linked .ifc file, stored in posix format (could be relative to .ifc file, not to .blend)",
|
||||
)
|
||||
transformation: StringProperty(
|
||||
name="Transformation",
|
||||
description="4x4 matrix transformation as a flattened comma separated list for the linked model",
|
||||
default="",
|
||||
)
|
||||
georeferenced: EnumProperty(
|
||||
name="Georeferenced",
|
||||
description="Georeferencing status: compatibility between host and linked model",
|
||||
items=[
|
||||
("NONE", "No Georef", "Linked model has no georeferencing"),
|
||||
("NOT_COMPATIBLE", "Not Compatible", "Has geo data but CRS differ from host"),
|
||||
("FULL_COMPATIBLE", "Full Compatible", "Both CRS name and vertical datum match host"),
|
||||
],
|
||||
default="NONE",
|
||||
)
|
||||
has_transformation: BoolProperty(
|
||||
name="Has Transformation",
|
||||
description="Whether there is a transformation from its global coordinates",
|
||||
default=False,
|
||||
)
|
||||
is_loaded: BoolProperty(name="Is Loaded", default=False)
|
||||
is_editing: BoolProperty(name="Is Editing", description="Whether the link is being transformed", default=False)
|
||||
is_selectable: BoolProperty(name="Is Selectable", default=True)
|
||||
is_wireframe: BoolProperty(name="Is Wireframe", default=False)
|
||||
is_hidden: BoolProperty(name="Is Hidden", default=False)
|
||||
include_in_drawings: BoolProperty(name="Include in Drawings", default=True, options=set())
|
||||
empty_handle: PointerProperty(
|
||||
name="Empty Object Handle",
|
||||
description="We use empty object handle to allow simple manipulations with a linked model (moving, scaling, rotating)",
|
||||
description="Storage for empty handle. Used in non-IFC scenarios or temporarily during link creation",
|
||||
type=bpy.types.Object,
|
||||
)
|
||||
ifc_definition_id: IntProperty(
|
||||
name="IFC Definition ID",
|
||||
description="STEP ID of the IfcDocumentReference when linked to a parent IFC project. Zero when no parent IFC exists",
|
||||
default=0,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
name: str
|
||||
filepath: str
|
||||
transformation: str
|
||||
georeferenced: Literal["NONE", "NOT_COMPATIBLE", "FULL_COMPATIBLE"]
|
||||
has_transformation: bool
|
||||
is_loaded: bool
|
||||
is_editing: bool
|
||||
is_selectable: bool
|
||||
is_wireframe: bool
|
||||
is_hidden: bool
|
||||
include_in_drawings: bool
|
||||
empty_handle: Union[bpy.types.Object, None]
|
||||
ifc_definition_id: int
|
||||
|
||||
|
||||
class EditedObj(PropertyGroup):
|
||||
@@ -424,6 +458,10 @@ class BIMProjectProperties(PropertyGroup):
|
||||
clipping_planes_active_index: bpy.props.IntProperty(min=0, default=0, max=5)
|
||||
edited_objs: bpy.props.CollectionProperty(type=EditedObj)
|
||||
|
||||
@property
|
||||
def active_link(self) -> Union[Link, None]:
|
||||
return tool.Blender.get_active_uilist_element(self.links, self.active_link_index)
|
||||
|
||||
@property
|
||||
def active_clipping_plane(self) -> ObjProperty | None:
|
||||
return tool.Blender.get_active_uilist_element(self.clipping_planes, self.clipping_planes_active_index)
|
||||
|
||||
@@ -22,6 +22,7 @@ import os
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import bpy
|
||||
import math
|
||||
import ifcopenshell
|
||||
from bpy.types import Menu, Panel, UIList
|
||||
|
||||
@@ -30,6 +31,7 @@ import bonsai.tool as tool
|
||||
from bonsai.bim.helper import draw_attributes, prop_with_search
|
||||
from bonsai.bim.ifc import IfcStore
|
||||
from bonsai.bim.module.project.data import LinksData, ProjectData
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bonsai.bim.module.project.prop import (
|
||||
@@ -477,17 +479,27 @@ class BIM_PT_links(Panel):
|
||||
|
||||
def draw(self, context):
|
||||
self.props = tool.Project.get_project_props()
|
||||
|
||||
row = self.layout.row(align=True)
|
||||
row.operator("bim.link_ifc")
|
||||
if self.props.links:
|
||||
self.layout.template_list(
|
||||
"BIM_UL_links",
|
||||
"",
|
||||
self.props,
|
||||
"links",
|
||||
self.props,
|
||||
"active_link_index",
|
||||
)
|
||||
if self.props.active_link:
|
||||
row = self.layout.row(align=True)
|
||||
row.alignment = "RIGHT"
|
||||
index = self.props.active_link_index
|
||||
if self.props.active_link.is_editing:
|
||||
row.operator("bim.edit_link", text="", icon="CHECKMARK")
|
||||
row.operator("bim.disable_editing_link", text="", icon="CANCEL")
|
||||
else:
|
||||
row.operator("bim.enable_editing_link", text="", icon="GREASEPENCIL")
|
||||
if self.props.active_link.is_loaded:
|
||||
row.operator("bim.select_link_handle", text="", icon="OBJECT_DATA").link_index = index
|
||||
row.operator("bim.unload_link", text="", icon="UNLINKED").link_index = index
|
||||
row.operator("bim.reload_link", text="", icon="FILE_REFRESH").link_index = index
|
||||
else:
|
||||
row.operator("bim.load_link", text="", icon="LINKED").link_index = index
|
||||
row.operator("bim.unlink_ifc", text="", icon="X").link_index = index
|
||||
self.layout.template_list("BIM_UL_links", "", self.props, "links", self.props, "active_link_index")
|
||||
|
||||
if LinksData.enable_culling:
|
||||
row = self.layout.row(align=True)
|
||||
@@ -607,47 +619,30 @@ class BIM_UL_links(UIList):
|
||||
active_propname,
|
||||
index,
|
||||
):
|
||||
if item:
|
||||
row = layout.row(align=True)
|
||||
if item.is_loaded:
|
||||
row.label(text=item.name)
|
||||
op = row.operator(
|
||||
"bim.toggle_link_selectability",
|
||||
text="",
|
||||
icon="RESTRICT_SELECT_OFF" if item.is_selectable else "RESTRICT_SELECT_ON",
|
||||
emboss=False,
|
||||
)
|
||||
op.link = item.name
|
||||
op = row.operator(
|
||||
"bim.toggle_link_visibility",
|
||||
text="",
|
||||
icon="CUBE" if item.is_wireframe else "MESH_CUBE",
|
||||
emboss=False,
|
||||
)
|
||||
op.link = item.name
|
||||
op.mode = "WIREFRAME"
|
||||
op = row.operator(
|
||||
"bim.toggle_link_visibility",
|
||||
text="",
|
||||
icon="HIDE_ON" if item.is_hidden else "HIDE_OFF",
|
||||
emboss=False,
|
||||
)
|
||||
op.link = item.name
|
||||
op.mode = "VISIBLE"
|
||||
op = row.operator("bim.select_link_handle", text="", icon="OBJECT_DATA")
|
||||
op.index = index
|
||||
op = row.operator("bim.unload_link", text="", icon="UNLINKED")
|
||||
op.filepath = item.name
|
||||
op = row.operator("bim.reload_link", text="", icon="FILE_REFRESH")
|
||||
op.filepath = item.name
|
||||
else:
|
||||
row.prop(item, "name", text="")
|
||||
op = row.operator("bim.select_uri_attribute", text="", icon="FILE_FOLDER")
|
||||
op.attribute_data_path = tool.Blender.get_full_data_path(item, "name")
|
||||
op = row.operator("bim.load_link", text="", icon="LINKED")
|
||||
op.filepath = item.name
|
||||
op = row.operator("bim.unlink_ifc", text="", icon="X")
|
||||
op.filepath = item.name
|
||||
row = layout.row(align=True)
|
||||
if item.is_loaded:
|
||||
if item.georeferenced == "NONE":
|
||||
row.label(text="", icon="QUESTION")
|
||||
elif item.georeferenced == "NOT_COMPATIBLE":
|
||||
row.label(text="", icon="ERROR")
|
||||
elif item.georeferenced == "FULL_COMPATIBLE":
|
||||
row.label(text="", icon="WORLD")
|
||||
if item.has_transformation:
|
||||
row.label(text="", icon="OBJECT_ORIGIN")
|
||||
|
||||
row.label(text=item.filepath)
|
||||
icon = "RESTRICT_SELECT_OFF" if item.is_selectable else "RESTRICT_SELECT_ON"
|
||||
row.operator("bim.toggle_link_selectability", text="", icon=icon, emboss=False).link_index = index
|
||||
icon = "CUBE" if item.is_wireframe else "MESH_CUBE"
|
||||
op = row.operator("bim.toggle_link_visibility", text="", icon=icon, emboss=False)
|
||||
op.link_index = index
|
||||
op.mode = "WIREFRAME"
|
||||
icon = "HIDE_ON" if item.is_hidden else "HIDE_OFF"
|
||||
op = row.operator("bim.toggle_link_visibility", text="", icon=icon, emboss=False)
|
||||
op.link_index = index
|
||||
op.mode = "VISIBLE"
|
||||
else:
|
||||
row.label(text=item.filepath)
|
||||
|
||||
|
||||
class BIM_PT_purge(Panel):
|
||||
|
||||
@@ -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,42 @@ class IfcClassData:
|
||||
("EMPTY", "No Geometry", "Start with an empty object"),
|
||||
]
|
||||
|
||||
if ifc_class in ("IfcWindowType", "IfcWindowStyle", "IfcWindow"):
|
||||
if ifc_class in ("IfcAlignmentHorizontal", "IfcAlignmentVertical", "IfcAlignmentCant"):
|
||||
return templates # layout containers have no direct representation per IFC 4.3 spec
|
||||
|
||||
if ifc_class == "IfcAlignment":
|
||||
templates.extend(
|
||||
[
|
||||
None,
|
||||
(
|
||||
"ALIGNMENT_HORIZONTAL",
|
||||
"Horizontal Alignment (2D)",
|
||||
"2D horizontal-only alignment (IfcCompositeCurve)",
|
||||
),
|
||||
(
|
||||
"ALIGNMENT_GRADIENT",
|
||||
"Gradient Curve (3D)",
|
||||
"3D alignment with horizontal and vertical layouts (IfcGradientCurve)",
|
||||
),
|
||||
(
|
||||
"ALIGNMENT_CANT",
|
||||
"Segmented Reference Curve",
|
||||
"3D alignment with horizontal, vertical, and cant layouts (IfcSegmentedReferenceCurve)",
|
||||
),
|
||||
(
|
||||
"ALIGNMENT_POLYLINE_3D",
|
||||
"3D Survey Polyline",
|
||||
"3D alignment from survey data (IfcPolyline with 3D points)",
|
||||
),
|
||||
(
|
||||
"ALIGNMENT_POLYLINE_2D",
|
||||
"2D Planning Polyline",
|
||||
"2D alignment for early planning phases (IfcPolyline with 2D points)",
|
||||
),
|
||||
]
|
||||
)
|
||||
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,39 +531,96 @@ 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
|
||||
if ifc_context:
|
||||
ifc_context = tool.Ifc.get().by_id(ifc_context)
|
||||
|
||||
if representation_template in (
|
||||
"EMPTY",
|
||||
"LAYERSET_AXIS2",
|
||||
"LAYERSET_AXIS3",
|
||||
"PROFILESET",
|
||||
) or representation_template.startswith("FLOW_SEGMENT_"):
|
||||
mesh = None
|
||||
elif representation_template == "OBJ" and not props.representation_obj:
|
||||
mesh = None
|
||||
alignment_templates = {
|
||||
"ALIGNMENT_HORIZONTAL",
|
||||
"ALIGNMENT_GRADIENT",
|
||||
"ALIGNMENT_CANT",
|
||||
"ALIGNMENT_POLYLINE_3D",
|
||||
"ALIGNMENT_POLYLINE_2D",
|
||||
}
|
||||
|
||||
if representation_template in alignment_templates:
|
||||
import ifcopenshell.api.alignment as align_api
|
||||
from ifcopenshell.api.alignment._create_polyline_representation import (
|
||||
_create_polyline_representation,
|
||||
)
|
||||
|
||||
ifc_file = tool.Ifc.get()
|
||||
alignment_name = props.name or "Unnamed"
|
||||
|
||||
if representation_template == "ALIGNMENT_HORIZONTAL":
|
||||
element = align_api.create(ifc_file, alignment_name, include_vertical=False, include_cant=False)
|
||||
elif representation_template == "ALIGNMENT_GRADIENT":
|
||||
element = align_api.create(ifc_file, alignment_name, include_vertical=True, include_cant=False)
|
||||
elif representation_template == "ALIGNMENT_CANT":
|
||||
element = align_api.create(ifc_file, alignment_name, include_vertical=True, include_cant=True)
|
||||
elif representation_template == "ALIGNMENT_POLYLINE_3D":
|
||||
element = ifc_file.createIfcAlignment(GlobalId=ifcopenshell.guid.new(), Name=alignment_name)
|
||||
pts = [
|
||||
ifc_file.createIfcCartesianPoint(Coordinates=(0.0, 0.0, 0.0)),
|
||||
ifc_file.createIfcCartesianPoint(Coordinates=(1.0, 0.0, 0.0)),
|
||||
]
|
||||
_create_polyline_representation(ifc_file, element, pts)
|
||||
project = ifc_file.by_type("IfcProject")
|
||||
if project:
|
||||
ifcopenshell.api.aggregate.assign_object(ifc_file, products=[element], relating_object=project[0])
|
||||
elif representation_template == "ALIGNMENT_POLYLINE_2D":
|
||||
element = ifc_file.createIfcAlignment(GlobalId=ifcopenshell.guid.new(), Name=alignment_name)
|
||||
pts = [
|
||||
ifc_file.createIfcCartesianPoint(Coordinates=(0.0, 0.0)),
|
||||
ifc_file.createIfcCartesianPoint(Coordinates=(1.0, 0.0)),
|
||||
]
|
||||
_create_polyline_representation(ifc_file, element, pts)
|
||||
project = ifc_file.by_type("IfcProject")
|
||||
if project:
|
||||
ifcopenshell.api.aggregate.assign_object(ifc_file, products=[element], relating_object=project[0])
|
||||
|
||||
element.Description = props.description or None
|
||||
obj = bpy.data.objects.new(props.ifc_class[3:], None)
|
||||
obj.name = alignment_name
|
||||
obj.location = bpy.context.scene.cursor.location
|
||||
tool.Root.set_object_name(obj, element)
|
||||
tool.Ifc.link(element, obj)
|
||||
tool.Collector.assign(obj)
|
||||
else:
|
||||
mesh = bpy.data.meshes.new("Mesh")
|
||||
if representation_template in (
|
||||
"EMPTY",
|
||||
"LAYERSET_AXIS2",
|
||||
"LAYERSET_AXIS3",
|
||||
"PROFILESET",
|
||||
) or representation_template.startswith("FLOW_SEGMENT_"):
|
||||
mesh = None
|
||||
elif representation_template == "OBJ" and not props.representation_obj:
|
||||
mesh = None
|
||||
else:
|
||||
mesh = bpy.data.meshes.new("Mesh")
|
||||
|
||||
obj = bpy.data.objects.new(props.ifc_class[3:], mesh)
|
||||
obj.name = props.name or "Unnamed"
|
||||
obj.location = bpy.context.scene.cursor.location
|
||||
element = core.assign_class(
|
||||
tool.Ifc,
|
||||
tool.Collector,
|
||||
tool.Root,
|
||||
obj=obj,
|
||||
ifc_class=props.ifc_class,
|
||||
predefined_type=predefined_type,
|
||||
should_add_representation=False,
|
||||
)
|
||||
element.Description = props.description or None
|
||||
obj = bpy.data.objects.new(props.ifc_class[3:], mesh)
|
||||
obj.name = props.name or "Unnamed"
|
||||
obj.location = bpy.context.scene.cursor.location
|
||||
element = core.assign_class(
|
||||
tool.Ifc,
|
||||
tool.Collector,
|
||||
tool.Root,
|
||||
obj=obj,
|
||||
ifc_class=props.ifc_class,
|
||||
predefined_type=predefined_type,
|
||||
should_add_representation=False,
|
||||
)
|
||||
element.Description = props.description or None
|
||||
|
||||
if representation_template == "EMTPY" or not ifc_context:
|
||||
if representation_template == "EMPTY" or representation_template in alignment_templates:
|
||||
pass
|
||||
elif not ifc_context:
|
||||
pass
|
||||
elif representation_template == "OBJ" and props.representation_obj:
|
||||
obj.matrix_world = props.representation_obj.matrix_world.copy()
|
||||
@@ -821,9 +878,29 @@ 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)
|
||||
|
||||
# After alignment creation, auto-invoke PI picker for layout-based templates
|
||||
if (
|
||||
props.ifc_class == "IfcAlignment"
|
||||
and representation_template.startswith("ALIGNMENT_")
|
||||
and representation_template not in ("ALIGNMENT_POLYLINE_2D", "ALIGNMENT_POLYLINE_3D")
|
||||
):
|
||||
civil_props = context.scene.CivilAlignmentProperties
|
||||
civil_props.active_alignment_id = element.id()
|
||||
civil_props.active_alignment_name = element.Name or "Unnamed"
|
||||
bpy.ops.civil.pick_pi_from_viewport("INVOKE_DEFAULT")
|
||||
|
||||
def draw(self, context):
|
||||
props = tool.Root.get_root_props()
|
||||
self.layout.use_property_split = True
|
||||
@@ -842,7 +919,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)
|
||||
@@ -852,5 +929,12 @@ class AddElement(bpy.types.Operator, tool.Ifc.Operator):
|
||||
elif props.representation_template == "PROFILESET":
|
||||
row = self.layout.row()
|
||||
prop_with_search(self.layout, props, "profile", text="Profile", should_click_ok=True)
|
||||
if props.representation_template != "EMPTY":
|
||||
alignment_templates = {
|
||||
"ALIGNMENT_HORIZONTAL",
|
||||
"ALIGNMENT_GRADIENT",
|
||||
"ALIGNMENT_CANT",
|
||||
"ALIGNMENT_POLYLINE_3D",
|
||||
"ALIGNMENT_POLYLINE_2D",
|
||||
}
|
||||
if props.representation_template != "EMPTY" and props.representation_template not in alignment_templates:
|
||||
prop_with_search(self.layout, props, "contexts", should_click_ok=True)
|
||||
|
||||
@@ -268,63 +268,45 @@ class SaveBlendMetadataFile(bpy.types.Operator):
|
||||
import bpy
|
||||
|
||||
# Ensure all styles are loaded before attempting to remove them
|
||||
try:
|
||||
bpy.ops.bim.load_styles()
|
||||
except Exception:
|
||||
pass
|
||||
bpy.ops.bim.load_styles()
|
||||
|
||||
# 1. Collect all IfcStyle material names
|
||||
ifcstyle_material_names = []
|
||||
try:
|
||||
styles_props = getattr(bpy.context.scene, "BIMStylesProperties", None)
|
||||
if styles_props is None and bpy.data.scenes:
|
||||
styles_props = getattr(bpy.data.scenes[0], "BIMStylesProperties", None)
|
||||
if styles_props:
|
||||
for style in list(styles_props.styles):
|
||||
material = getattr(style, "blender_material", None)
|
||||
if material and material.name:
|
||||
ifcstyle_material_names.append(material.name)
|
||||
except Exception:
|
||||
pass
|
||||
styles_props = getattr(bpy.context.scene, "BIMStylesProperties", None)
|
||||
if styles_props is None and bpy.data.scenes:
|
||||
styles_props = getattr(bpy.data.scenes[0], "BIMStylesProperties", None)
|
||||
if styles_props:
|
||||
for style in list(styles_props.styles):
|
||||
material = getattr(style, "blender_material", None)
|
||||
if material and material.name:
|
||||
ifcstyle_material_names.append(material.name)
|
||||
|
||||
# 2. Purge IfcStore
|
||||
try:
|
||||
from bonsai.bim.ifc import IfcStore
|
||||
except ImportError:
|
||||
IfcStore = None
|
||||
|
||||
if IfcStore:
|
||||
try:
|
||||
IfcStore.purge()
|
||||
except Exception:
|
||||
pass
|
||||
from bonsai.bim.ifc import IfcStore
|
||||
IfcStore.purge()
|
||||
|
||||
# 3. Remove all collections named IfcProject*
|
||||
for collection in list(bpy.data.collections):
|
||||
if collection.name.startswith('IfcProject'):
|
||||
try:
|
||||
bpy.data.collections.remove(collection, do_unlink=True)
|
||||
except Exception:
|
||||
pass
|
||||
bpy.data.collections.remove(collection, do_unlink=True)
|
||||
|
||||
# 4. Purge orphaned data blocks after removing IfcProject collections
|
||||
try:
|
||||
bpy.ops.outliner.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True)
|
||||
except Exception:
|
||||
pass
|
||||
# 4.1 Remove all collections from linked libraries (they will be recreated by bonsai)
|
||||
for collection in list(bpy.data.collections):
|
||||
if collection.library:
|
||||
bpy.data.collections.remove(collection, do_unlink=True)
|
||||
|
||||
# 5. Remove all materials corresponding to the IfcStyles we collected
|
||||
materials_removed = 0
|
||||
try:
|
||||
for mat_name in ifcstyle_material_names:
|
||||
if mat_name in bpy.data.materials:
|
||||
try:
|
||||
bpy.data.materials.remove(bpy.data.materials[mat_name], do_unlink=True)
|
||||
materials_removed += 1
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
# 4.2. Remove all empty objects that are collection instances for linked models
|
||||
for obj in list(bpy.data.objects):
|
||||
if obj.type == 'EMPTY' and obj.instance_type == 'COLLECTION' and obj.name.startswith('IfcProject/'):
|
||||
bpy.data.objects.remove(obj, do_unlink=True)
|
||||
|
||||
# 5. Purge orphaned data blocks after removing IfcProject collections
|
||||
bpy.ops.outliner.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True)
|
||||
|
||||
# 6. Remove all materials corresponding to the IfcStyles we collected
|
||||
for mat_name in ifcstyle_material_names:
|
||||
if mat_name in bpy.data.materials:
|
||||
bpy.data.materials.remove(bpy.data.materials[mat_name], do_unlink=True)
|
||||
|
||||
bpy.ops.wm.save_as_mainfile(filepath=r'{blendmetadata_path}')
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -467,12 +467,12 @@ class DocPreferences(bpy.types.PropertyGroup):
|
||||
classes_to_wireframe: StringProperty(
|
||||
default="IfcVirtualElement",
|
||||
name="Classes to Wireframe",
|
||||
description="Upon import, these classes will display as wireframe.\nEx: IfcVirtualelement, IfcSpace",
|
||||
description="Upon import, these classes will display as wireframe.\nEx: IfcVirtualElement, IfcSpace",
|
||||
)
|
||||
classes_no_cut: StringProperty(
|
||||
default="IfcVirtualElement, IfcSpace",
|
||||
name="Classes that are not cut",
|
||||
description="The cut decoractor will be turned off for these classes\nEx: IfcVirtualelement, IfcSpace",
|
||||
description="The cut decorator will be turned off for these classes\nEx: IfcVirtualElement, IfcSpace",
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -660,6 +660,10 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
|
||||
bsdd_load_test_dictionaries: BoolProperty(
|
||||
name="Load Test Dictionaries", description="Load dictionaries that are for testing only", default=False
|
||||
)
|
||||
bsdd_baseurl: StringProperty(
|
||||
name="bSDD API Base URL", description="Base URL for data dictionary API requests, e.g. https://api.bsdd.buildingsmart.org/api/",
|
||||
default="https://api.bsdd.buildingsmart.org/api/",
|
||||
)
|
||||
should_disable_undo_on_save: BoolProperty(
|
||||
name="Disable Undo When Saving (Faster saves, no undo for you!)", default=False
|
||||
)
|
||||
@@ -972,6 +976,7 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
|
||||
layout.prop(self, "bsdd_load_preview_dictionaries")
|
||||
layout.prop(self, "bsdd_load_inactive_dictionaries")
|
||||
layout.prop(self, "bsdd_load_test_dictionaries")
|
||||
layout.prop(self, "bsdd_baseurl")
|
||||
|
||||
def draw_extras_settings(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
|
||||
layout.prop(self, "container_hide_show_isolate")
|
||||
@@ -1678,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"
|
||||
@@ -1864,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
|
||||
@@ -29,6 +29,7 @@ if TYPE_CHECKING:
|
||||
|
||||
def add_georeferencing(georeference: type[tool.Georeference]) -> None:
|
||||
georeference.add_georeferencing()
|
||||
georeference.set_model_origin()
|
||||
|
||||
|
||||
def enable_editing_georeferencing(georeference: type[tool.Georeference]) -> None:
|
||||
@@ -37,8 +38,9 @@ def enable_editing_georeferencing(georeference: type[tool.Georeference]) -> None
|
||||
georeference.enable_editing()
|
||||
|
||||
|
||||
def remove_georeferencing(ifc: type[tool.Ifc]) -> None:
|
||||
def remove_georeferencing(ifc: type[tool.Ifc], georeference: type[tool.Georeference]) -> None:
|
||||
ifc.run("georeference.remove_georeferencing")
|
||||
georeference.set_model_origin()
|
||||
|
||||
|
||||
def disable_editing_georeferencing(georeference: type[tool.Georeference]) -> None:
|
||||
|
||||
@@ -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
@@ -2165,3 +2165,13 @@ class Blender(bonsai.core.tool.Blender):
|
||||
if cls.BLENDER_5:
|
||||
return np.array(mathutils_type)
|
||||
return np.array(mathutils_type, dtype=np.float32)
|
||||
|
||||
@classmethod
|
||||
def get_selected_files(
|
||||
cls, directory: str, files: bpy.types.OperatorFileListElement, use_relative_path=False
|
||||
) -> list[Path]:
|
||||
return [
|
||||
tool.Ifc.get_uri(Path(directory) / f.name, use_relative_path=use_relative_path)
|
||||
for f in files
|
||||
if (Path(directory) / f.name).is_file()
|
||||
]
|
||||
|
||||
@@ -123,6 +123,10 @@ class Bsdd(bonsai.core.tool.Bsdd):
|
||||
@classmethod
|
||||
def get_dictionaries(cls) -> list[bsdd.DictionaryContractV1]:
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
baseurl = getattr(prefs, "bsdd_baseurl", "https://api.bsdd.buildingsmart.org/api/")
|
||||
cls.client = bsdd.Client()
|
||||
if hasattr(cls.client, "baseurl"):
|
||||
cls.client.baseurl = baseurl
|
||||
response = cls.client.get_dictionary(include_test_dictionaries=prefs.bsdd_load_test_dictionaries)
|
||||
dicts = response.get("dictionaries") or []
|
||||
statuses = ["Active"]
|
||||
|
||||
@@ -251,11 +251,19 @@ class Document(bonsai.core.tool.Document):
|
||||
def get_document_references(
|
||||
cls, document: ifcopenshell.entity_instance
|
||||
) -> tuple[ifcopenshell.entity_instance, ...]:
|
||||
# TODO: migrate to util.document and replace all instances
|
||||
"""Get IfcDocumentReference.ReferencedDocuments, compatible with IFC2X3."""
|
||||
if document.file.schema == "IFC2X3":
|
||||
return document.DocumentReferences or ()
|
||||
return document.HasDocumentReferences
|
||||
|
||||
@classmethod
|
||||
def get_reference_document(cls, reference: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance | None:
|
||||
# TODO: migrate to util.document and replace all instances
|
||||
if reference.file.schema == "IFC2X3":
|
||||
return (reference.ReferenceToDocument or (None))[0]
|
||||
return reference.ReferencedDocument
|
||||
|
||||
@classmethod
|
||||
def clear_active_document(cls) -> None:
|
||||
props = cls.get_document_props()
|
||||
|
||||
@@ -857,9 +857,11 @@ class Drawing(bonsai.core.tool.Drawing):
|
||||
def edit_text_literals(cls, obj: bpy.types.Object, literal_attributes: dict) -> None:
|
||||
assert (element := tool.Ifc.get_entity(obj))
|
||||
assert (rep := cls.get_annotation_representation(element))
|
||||
for literal in cls.get_text_literal(obj, return_list=True):
|
||||
to_remove = [i for i in rep.Items if i.is_a("IfcTextLiteral")]
|
||||
new_literals = [cls.add_literal(**a) for a in literal_attributes]
|
||||
rep.Items = [i for i in rep.Items if not i.is_a("IfcTextLiteral")] + new_literals
|
||||
for literal in to_remove:
|
||||
ifcopenshell.util.element.remove_deep2(tool.Ifc.get(), literal)
|
||||
rep.Items = [cls.add_literal(**a) for a in literal_attributes]
|
||||
|
||||
@classmethod
|
||||
def add_literal(cls, **attributes: str) -> ifcopenshell.entity_instance:
|
||||
|
||||
@@ -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],
|
||||
@@ -315,6 +315,21 @@ class Georeference(bonsai.core.tool.Georeference):
|
||||
)
|
||||
return coordinates
|
||||
|
||||
@classmethod
|
||||
def global2local(cls, matrix, is_specified_in_map_units: bool) -> tuple[float, float, float]:
|
||||
matrix = ifcopenshell.util.geolocation.auto_global2local(tool.Ifc.get(), matrix, is_specified_in_map_units=is_specified_in_map_units)
|
||||
props = cls.get_georeference_props()
|
||||
if props.has_blender_offset:
|
||||
matrix = ifcopenshell.util.geolocation.global2local(
|
||||
matrix,
|
||||
float(props.blender_offset_x),
|
||||
float(props.blender_offset_y),
|
||||
float(props.blender_offset_z),
|
||||
float(props.blender_x_axis_abscissa),
|
||||
float(props.blender_x_axis_ordinate),
|
||||
)
|
||||
return matrix
|
||||
|
||||
@classmethod
|
||||
def import_plot(cls, filepath: str) -> None:
|
||||
import bmesh
|
||||
@@ -385,6 +400,9 @@ class Georeference(bonsai.core.tool.Georeference):
|
||||
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
||||
gprops = tool.Georeference.get_georeference_props()
|
||||
e, n, h = cls.xyz2enh((0, 0, 0), should_return_in_map_units=False)
|
||||
crs = ifcopenshell.util.geolocation.get_crs(tool.Ifc.get()) or {}
|
||||
gprops.model_is_georeferenced = bool(crs)
|
||||
gprops.model_crs = crs.get("Name", "") or ""
|
||||
gprops.model_origin = f"{e},{n},{h}"
|
||||
gprops.model_origin_si = f"{e * unit_scale},{n * unit_scale},{h * unit_scale}"
|
||||
angle = ifcopenshell.util.geolocation.get_grid_north(tool.Ifc.get())
|
||||
|
||||
@@ -191,7 +191,8 @@ class Polyline(bonsai.core.tool.Polyline):
|
||||
orientation_angle = 0
|
||||
if input_ui:
|
||||
if should_round:
|
||||
angle = 5 * round(angle / 5) if distance < angle_round_threshold else angle
|
||||
angle_snap = tool.Snap.get_angle_snap_value(context)
|
||||
angle = angle_snap * round(angle / angle_snap) if distance < angle_round_threshold else angle
|
||||
factor = tool.Snap.get_increment_snap_value(context)
|
||||
distance = factor * round(distance / factor)
|
||||
input_ui.set_value("X", mouse_vector.x)
|
||||
|
||||
@@ -19,10 +19,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import json
|
||||
import math
|
||||
import shutil
|
||||
import numpy as np
|
||||
from collections import defaultdict
|
||||
from math import radians
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, NamedTuple, Optional, Union
|
||||
from typing import TYPE_CHECKING, NamedTuple, Optional
|
||||
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
@@ -58,6 +62,47 @@ class Project(bonsai.core.tool.Project):
|
||||
assert (scene := bpy.context.scene)
|
||||
return scene.MeasureToolSettings # pyright: ignore[reportAttributeAccessIssue]
|
||||
|
||||
@classmethod
|
||||
def get_link_empty_handle(cls, link) -> bpy.types.Object | None:
|
||||
if tool.Ifc.get():
|
||||
return tool.Ifc.get_object(tool.Ifc.get().by_id(link.ifc_definition_id))
|
||||
return link.empty_handle
|
||||
|
||||
@classmethod
|
||||
def set_link_empty_handle(cls, link, empty: bpy.types.Object) -> None:
|
||||
if tool.Ifc.get():
|
||||
tool.Ifc.link(tool.Ifc.get().by_id(link.ifc_definition_id), empty)
|
||||
else:
|
||||
link.empty_handle = empty
|
||||
|
||||
@classmethod
|
||||
def calculate_link_matrix(cls, link) -> None:
|
||||
filepath = Path(tool.Ifc.resolve_uri(link.filepath))
|
||||
with open(filepath.with_suffix(".ifc.cache.json"), "r") as f:
|
||||
metadata = json.load(f)
|
||||
|
||||
rot = ifcopenshell.util.shape_builder.np_rotation_matrix(
|
||||
radians(-float(metadata["model_project_north"])), 4, "Z"
|
||||
)
|
||||
global_matrix = rot @ np.eye(4)
|
||||
global_matrix[:, 3][:3] = [float(o) for o in metadata["model_origin_si"].split(",")]
|
||||
|
||||
if tool.Ifc.get():
|
||||
transformation = tool.Ifc.get().by_id(link.ifc_definition_id)[1] # Identification
|
||||
else:
|
||||
transformation = link.transformation
|
||||
|
||||
if transformation:
|
||||
transformation = np.fromstring(transformation, sep=",", dtype=np.float64).reshape(4, 4)
|
||||
if not np.allclose(transformation, np.eye(4)):
|
||||
global_matrix = transformation @ global_matrix
|
||||
|
||||
gprops = tool.Georeference.get_georeference_props()
|
||||
rot = ifcopenshell.util.shape_builder.np_rotation_matrix(radians(-float(gprops.model_project_north)), 4, "Z")
|
||||
local_matrix = rot @ np.eye(4)
|
||||
local_matrix[:, 3][:3] = [float(o) for o in gprops.model_origin_si.split(",")]
|
||||
return np.linalg.inv(local_matrix) @ global_matrix
|
||||
|
||||
@classmethod
|
||||
def append_all_types_from_template(cls, template: str) -> None:
|
||||
# TODO refactor
|
||||
@@ -249,68 +294,32 @@ class Project(bonsai.core.tool.Project):
|
||||
tool.Root.reload_grid_decorator()
|
||||
|
||||
@classmethod
|
||||
def get_linked_models_document(cls) -> Union[ifcopenshell.entity_instance, None]:
|
||||
for document in tool.Ifc.get().by_type("IfcDocumentInformation"):
|
||||
if document.Name == "BBIM_Linked_Models":
|
||||
return document
|
||||
def get_linked_models_documents(cls) -> dict[str, ifcopenshell.entity_instance]:
|
||||
linked_docs = {}
|
||||
for doc in tool.Ifc.get().by_type("IfcDocumentInformation"):
|
||||
if doc.Scope == "LINKED_MODEL":
|
||||
for reference in tool.Drawing.get_document_references(doc):
|
||||
linked_docs[Path(reference.Location).as_posix()] = doc
|
||||
break
|
||||
return linked_docs
|
||||
|
||||
@classmethod
|
||||
def load_linked_models_from_ifc(cls) -> None:
|
||||
links = tool.Project.get_project_props().links
|
||||
links.clear()
|
||||
links_document = cls.get_linked_models_document()
|
||||
if not links_document:
|
||||
return
|
||||
|
||||
references = tool.Document.get_document_references(links_document)
|
||||
if not references:
|
||||
return
|
||||
|
||||
for reference in references:
|
||||
link = links.add()
|
||||
link.name = reference.Location
|
||||
|
||||
@classmethod
|
||||
def save_linked_models_to_ifc(cls) -> None:
|
||||
ifc_file = tool.Ifc.get()
|
||||
links = tool.Project.get_project_props().links
|
||||
filepaths: set[Path] = set()
|
||||
for link in links:
|
||||
filepaths.add(Path(link.name))
|
||||
|
||||
links_document = cls.get_linked_models_document()
|
||||
|
||||
if not filepaths and links_document is None:
|
||||
return
|
||||
|
||||
paths_to_add = filepaths.copy()
|
||||
references_to_remove: list[ifcopenshell.entity_instance] = []
|
||||
if links_document:
|
||||
references = tool.Document.get_document_references(links_document)
|
||||
for reference in references:
|
||||
# I guess got corrupted by the user.
|
||||
if not (location := reference.Location):
|
||||
references_to_remove.remove(reference)
|
||||
continue
|
||||
path = Path(location)
|
||||
if path in paths_to_add:
|
||||
paths_to_add.remove(path)
|
||||
else:
|
||||
references_to_remove.append(reference)
|
||||
|
||||
if paths_to_add:
|
||||
if links_document is None:
|
||||
links_document = ifcopenshell.api.document.add_information(ifc_file)
|
||||
links_document.Name = "BBIM_Linked_Models"
|
||||
links_document.Description = "Bonsai internal document containing references to currently linked models"
|
||||
|
||||
for path in paths_to_add:
|
||||
reference = ifcopenshell.api.document.add_reference(ifc_file, links_document)
|
||||
reference.Location = path.as_posix()
|
||||
|
||||
if references_to_remove:
|
||||
for reference in references_to_remove:
|
||||
ifcopenshell.api.document.remove_reference(ifc_file, reference)
|
||||
for doc in tool.Ifc.get().by_type("IfcDocumentInformation"):
|
||||
if doc.Scope != "LINKED_MODEL":
|
||||
continue
|
||||
for reference in tool.Drawing.get_document_references(doc):
|
||||
filepath = reference.Location
|
||||
link = links.add()
|
||||
link.name = filepath
|
||||
link.filepath = filepath
|
||||
link.ifc_definition_id = reference.id()
|
||||
link.has_transformation = False
|
||||
if reference[1]:
|
||||
m = np.fromstring(reference[1], sep=",", dtype=np.float64).reshape(4, 4)
|
||||
link.has_transformation = not np.allclose(m, np.eye(4))
|
||||
|
||||
@classmethod
|
||||
def get_project_library_elements(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -114,6 +114,15 @@ class Snap(bonsai.core.tool.Snap):
|
||||
|
||||
return increment
|
||||
|
||||
@classmethod
|
||||
def get_angle_snap_value(cls, context: bpy.types.Context) -> float:
|
||||
"""Get the angle snap increment from Blender's tool settings.
|
||||
|
||||
:param context: Blender context
|
||||
:return: Angle snap increment in degrees
|
||||
"""
|
||||
return math.degrees(context.scene.tool_settings.snap_angle_increment_3d)
|
||||
|
||||
@classmethod
|
||||
def get_snap_points_on_raycasted_face(cls, context, event, obj, face_index):
|
||||
matrix = obj.matrix_world.copy()
|
||||
|
||||
@@ -34,6 +34,7 @@ from bonsai.bim.ifc import IfcStore
|
||||
|
||||
# Monkey-patch webbrowser opening since we want to test headlessly
|
||||
webbrowser.open = lambda x: True
|
||||
tool.Drawing.open_with_user_command = lambda x, y: True
|
||||
|
||||
|
||||
variables = {"cwd": os.getcwd(), "ifc": "IfcStore.get_file()"}
|
||||
|
||||
@@ -3,12 +3,6 @@ Feature: Drawing
|
||||
|
||||
Scenario: Duplicate drawing
|
||||
Given an empty IFC project
|
||||
And I add a cube
|
||||
And the object "Cube" is selected
|
||||
And I look at the "Class" panel
|
||||
And I set the "Products" property to "IfcElement"
|
||||
And I set the "Class" property to "IfcWall"
|
||||
And I click "Assign IFC Class"
|
||||
And I save IFC project
|
||||
And I look at the "Drawings" panel
|
||||
And I click "IMPORT"
|
||||
@@ -315,3 +309,10 @@ Scenario: Create sheet - with a drawing added to it
|
||||
And I click "IMAGE_PLANE"
|
||||
When I click "OUTPUT"
|
||||
Then the file "{ifc_dir}/sheets/A01 - UNTITLED.svg" should contain "IfcWall"
|
||||
|
||||
Scenario: Add reference image
|
||||
Given an empty IFC project
|
||||
And I save IFC project
|
||||
When I press "bim.add_reference_image(filepath='{cwd}/test/files/image.jpg')"
|
||||
Then the object "IfcAnnotation/image" exists
|
||||
And the object "IfcAnnotation/image" dimensions are "1.0,0.565,0."
|
||||
|
||||
@@ -677,31 +677,43 @@ Scenario: Load project elements - all georeferencing coordinate situations with
|
||||
And the object "IfcActuator/J" has a vertex at "10.366,3.813,-1"
|
||||
And the object "IfcActuator/J" has a vertex at "12.298,4.331,-1"
|
||||
|
||||
Scenario: Link IFC
|
||||
Scenario: Link IFC - from an empty IFC project
|
||||
Given an empty IFC project
|
||||
When I link IFC project from "{cwd}/test/files/basic.ifc"
|
||||
When I press "bim.link_ifc(filepath='{cwd}/test/files/basic.ifc')"
|
||||
Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_loaded" is "True"
|
||||
And the collection "IfcProject/basic.ifc" exists
|
||||
And the object "Chunk" exists
|
||||
And the object "Chunk" is placed in the collection "IfcProject/basic.ifc"
|
||||
|
||||
Scenario: Link IFC - disabled false origin mode
|
||||
Given an empty IFC project
|
||||
# Not currently possible via UI
|
||||
And I set "scene.BIMProjectProperties.distance_limit" to "5"
|
||||
And I set "scene.BIMProjectProperties.false_origin_mode" to "DISABLED"
|
||||
When I link IFC project from "{cwd}/test/files/geolocation.ifc"
|
||||
Then the object "Chunk" exists
|
||||
And the object "Chunk" has a vertex at "2,2,-1"
|
||||
And the object "Chunk" has a vertex at "9,-1,-1"
|
||||
And the object "Chunk" has a vertex at "17,4,-1"
|
||||
|
||||
Scenario: Link IFC - from an empty IFC project - automatic false origin mode (0,0,0 will be the false origin)
|
||||
Given an empty IFC project
|
||||
# Not currently possible via UI
|
||||
And I set "scene.BIMProjectProperties.distance_limit" to "5"
|
||||
And I set "scene.BIMProjectProperties.false_origin_mode" to "AUTOMATIC"
|
||||
When I link IFC project from "{cwd}/test/files/geolocation.ifc"
|
||||
When I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation.ifc', use_cache=False)"
|
||||
Then the object "Chunk" exists
|
||||
And the object "Chunk" has a vertex at "2,2,-1"
|
||||
And the object "Chunk" has a vertex at "9,-1,-1"
|
||||
And the object "Chunk" has a vertex at "17,4,-1"
|
||||
|
||||
Scenario: Link IFC - from an empty IFC project - manual false origin mode
|
||||
Given an empty IFC project
|
||||
# Not currently possible via UI
|
||||
And I set "scene.BIMProjectProperties.distance_limit" to "5"
|
||||
And I set "scene.BIMProjectProperties.false_origin_mode" to "MANUAL"
|
||||
And I set "scene.BIMProjectProperties.false_origin" to "10000,0,0"
|
||||
When I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation.ifc', use_cache=False)"
|
||||
Then the object "Chunk" exists
|
||||
And the object "Chunk" has a vertex at "2,2,-1"
|
||||
And the object "Chunk" has a vertex at "9,-1,-1"
|
||||
And the object "Chunk" has a vertex at "17,4,-1"
|
||||
|
||||
Scenario: Link IFC - from an empty IFC project - disabled false origin mode
|
||||
Given an empty IFC project
|
||||
# Not currently possible via UI
|
||||
And I set "scene.BIMProjectProperties.distance_limit" to "5"
|
||||
And I set "scene.BIMProjectProperties.false_origin_mode" to "DISABLED"
|
||||
When I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation.ifc', use_cache=False)"
|
||||
Then the object "Chunk" exists
|
||||
And the object "Chunk" has a vertex at "2,2,-1"
|
||||
And the object "Chunk" has a vertex at "9,-1,-1"
|
||||
@@ -712,31 +724,42 @@ Scenario: Link IFC - from an empty Blender session - automatic false origin mode
|
||||
# Not currently possible via UI
|
||||
And I set "scene.BIMProjectProperties.distance_limit" to "5"
|
||||
And I set "scene.BIMProjectProperties.false_origin_mode" to "AUTOMATIC"
|
||||
When I link IFC project from "{cwd}/test/files/geolocation.ifc"
|
||||
When I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation.ifc', use_cache=False)"
|
||||
Then the object "Chunk" exists
|
||||
And the object "Chunk" has a vertex at "-11,-2,0"
|
||||
And the object "Chunk" has a vertex at "-4,-5,0"
|
||||
And the object "Chunk" has a vertex at "4,0,0"
|
||||
|
||||
Scenario: Link IFC - manual false origin mode
|
||||
Scenario: Link IFC - from an empty Blender session - manual false origin mode
|
||||
Given an empty Blender session
|
||||
# Not currently possible via UI
|
||||
And I set "scene.BIMProjectProperties.distance_limit" to "5"
|
||||
And I set "scene.BIMProjectProperties.false_origin_mode" to "MANUAL"
|
||||
And I set "scene.BIMProjectProperties.false_origin" to "10000,0,0"
|
||||
When I link IFC project from "{cwd}/test/files/geolocation.ifc"
|
||||
When I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation.ifc', use_cache=False)"
|
||||
Then the object "Chunk" exists
|
||||
And the object "Chunk" has a vertex at "-8,2,-1"
|
||||
And the object "Chunk" has a vertex at "-1,-1,-1"
|
||||
And the object "Chunk" has a vertex at "7,4,-1"
|
||||
|
||||
Scenario: Link IFC - automatic false origin mode - two different false origins and project norths - grid north is up because we start with geolocation.ifc
|
||||
Scenario: Link IFC - from an empty Blender session - disabled false origin mode
|
||||
Given an empty Blender session
|
||||
# Not currently possible via UI
|
||||
And I set "scene.BIMProjectProperties.distance_limit" to "5"
|
||||
And I set "scene.BIMProjectProperties.false_origin_mode" to "DISABLED"
|
||||
When I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation.ifc', use_cache=False)"
|
||||
Then the object "Chunk" exists
|
||||
And the object "Chunk" has a vertex at "2,2,-1"
|
||||
And the object "Chunk" has a vertex at "9,-1,-1"
|
||||
And the object "Chunk" has a vertex at "17,4,-1"
|
||||
|
||||
Scenario: Link IFC - from an empty Blender session - automatic false origin mode - two different false origins and project norths - grid north is up because we start with geolocation.ifc
|
||||
Given an empty Blender session
|
||||
# Not currently possible via UI
|
||||
And I set "scene.BIMProjectProperties.distance_limit" to "5"
|
||||
And I set "scene.BIMProjectProperties.false_origin_mode" to "AUTOMATIC"
|
||||
When I link IFC project from "{cwd}/test/files/geolocation.ifc"
|
||||
And I link IFC project from "{cwd}/test/files/geolocation-mapconversion-angle.ifc"
|
||||
When I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation.ifc', use_cache=False)"
|
||||
And I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation-mapconversion-angle.ifc', use_cache=False)"
|
||||
Then the object "Col:IfcProject/geolocation.ifc:Chunk" exists
|
||||
And the object "Col:IfcProject/geolocation-mapconversion-angle.ifc:Chunk" exists
|
||||
And the object "Col:IfcProject/geolocation.ifc:Chunk" has a vertex at "-11,-2,0"
|
||||
@@ -746,13 +769,13 @@ Scenario: Link IFC - automatic false origin mode - two different false origins a
|
||||
And the object "Col:IfcProject/geolocation-mapconversion-angle.ifc:Chunk" has a vertex at "9.294,-9.366,0"
|
||||
And the object "Col:IfcProject/geolocation-mapconversion-angle.ifc:Chunk" has a vertex at "18.722,-9.036,0"
|
||||
|
||||
Scenario: Link IFC - automatic false origin mode - two different false origins and project norths - project north is up because we start with geolocation-mapconversion-angle.ifc
|
||||
Scenario: Link IFC - from an empty Blender session - automatic false origin mode - two different false origins and project norths - project north is up because we start with geolocation-mapconversion-angle.ifc
|
||||
Given an empty Blender session
|
||||
# Not currently possible via UI
|
||||
And I set "scene.BIMProjectProperties.distance_limit" to "5"
|
||||
And I set "scene.BIMProjectProperties.false_origin_mode" to "AUTOMATIC"
|
||||
When I link IFC project from "{cwd}/test/files/geolocation-mapconversion-angle.ifc"
|
||||
And I link IFC project from "{cwd}/test/files/geolocation.ifc"
|
||||
When I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation-mapconversion-angle.ifc', use_cache=False)"
|
||||
And I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation.ifc', use_cache=False)"
|
||||
Then the object "Col:IfcProject/geolocation.ifc:Chunk" exists
|
||||
And the object "Col:IfcProject/geolocation-mapconversion-angle.ifc:Chunk" exists
|
||||
And the object "Col:IfcProject/geolocation-mapconversion-angle.ifc:Chunk" has a vertex at "-11,-2,0"
|
||||
@@ -762,14 +785,14 @@ Scenario: Link IFC - automatic false origin mode - two different false origins a
|
||||
And the object "Col:IfcProject/geolocation.ifc:Chunk" has a vertex at "-17.696,-7.866,0"
|
||||
And the object "Col:IfcProject/geolocation.ifc:Chunk" has a vertex at "-13.268,0.464,0"
|
||||
|
||||
Scenario: Link IFC - automatic false origin mode - three identical false origins but different project and map units
|
||||
Scenario: Link IFC - from an empty Blender session - automatic false origin mode - three identical false origins but different project and map units
|
||||
Given an empty Blender session
|
||||
# Not currently possible via UI
|
||||
And I set "scene.BIMProjectProperties.distance_limit" to "5"
|
||||
And I set "scene.BIMProjectProperties.false_origin_mode" to "AUTOMATIC"
|
||||
When I link IFC project from "{cwd}/test/files/geolocation-unit1.ifc"
|
||||
And I link IFC project from "{cwd}/test/files/geolocation-unit2.ifc"
|
||||
And I link IFC project from "{cwd}/test/files/geolocation-unit3.ifc"
|
||||
When I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation-unit1.ifc', use_cache=False)"
|
||||
And I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation-unit2.ifc', use_cache=False)"
|
||||
And I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation-unit3.ifc', use_cache=False)"
|
||||
Then the object "Col:IfcProject/geolocation-unit1.ifc:Chunk" exists
|
||||
And the object "Col:IfcProject/geolocation-unit2.ifc:Chunk" exists
|
||||
And the object "Col:IfcProject/geolocation-unit3.ifc:Chunk" exists
|
||||
@@ -779,54 +802,54 @@ Scenario: Link IFC - automatic false origin mode - three identical false origins
|
||||
|
||||
Scenario: Toggle link visibility - wireframe mode
|
||||
Given an empty IFC project
|
||||
And I link IFC project from "{cwd}/test/files/basic.ifc"
|
||||
When I press "bim.toggle_link_visibility(link='{cwd}/test/files/basic.ifc', mode='WIREFRAME')"
|
||||
And I press "bim.link_ifc(filepath='{cwd}/test/files/basic.ifc')"
|
||||
When I press "bim.toggle_link_visibility(link_index=0, mode='WIREFRAME')"
|
||||
Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_wireframe" is "True"
|
||||
And the object "Chunk" should display as "WIRE"
|
||||
When I press "bim.toggle_link_visibility(link='{cwd}/test/files/basic.ifc', mode='WIREFRAME')"
|
||||
When I press "bim.toggle_link_visibility(link_index=0, mode='WIREFRAME')"
|
||||
Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_wireframe" is "False"
|
||||
And the object "Chunk" should display as "TEXTURED"
|
||||
|
||||
Scenario: Toggle link selectability
|
||||
Given an empty IFC project
|
||||
And I link IFC project from "{cwd}/test/files/basic.ifc"
|
||||
When I press "bim.toggle_link_selectability(link='{cwd}/test/files/basic.ifc')"
|
||||
And I press "bim.link_ifc(filepath='{cwd}/test/files/basic.ifc')"
|
||||
When I press "bim.toggle_link_selectability(link_index=0)"
|
||||
Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_selectable" is "False"
|
||||
And the collection "IfcProject/basic.ifc" is unselectable
|
||||
When I press "bim.toggle_link_selectability(link='{cwd}/test/files/basic.ifc')"
|
||||
When I press "bim.toggle_link_selectability(link_index=0)"
|
||||
Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_selectable" is "True"
|
||||
And the collection "IfcProject/basic.ifc" is selectable
|
||||
|
||||
Scenario: Toggle link visibility - visible mode
|
||||
Given an empty IFC project
|
||||
And I link IFC project from "{cwd}/test/files/basic.ifc"
|
||||
When I press "bim.toggle_link_visibility(link='{cwd}/test/files/basic.ifc', mode='VISIBLE')"
|
||||
And I press "bim.link_ifc(filepath='{cwd}/test/files/basic.ifc')"
|
||||
When I press "bim.toggle_link_visibility(link_index=0, mode='VISIBLE')"
|
||||
Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_hidden" is "True"
|
||||
And the object "IfcProject/basic.ifc" is not visible
|
||||
When I press "bim.toggle_link_visibility(link='{cwd}/test/files/basic.ifc', mode='VISIBLE')"
|
||||
When I press "bim.toggle_link_visibility(link_index=0, mode='VISIBLE')"
|
||||
Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_hidden" is "False"
|
||||
And the object "IfcProject/basic.ifc" is visible
|
||||
|
||||
Scenario: Unload link
|
||||
Given an empty Blender session
|
||||
And I link IFC project from "{cwd}/test/files/basic.ifc"
|
||||
When I press "bim.unload_link(filepath='{cwd}/test/files/basic.ifc')"
|
||||
And I press "bim.link_ifc(filepath='{cwd}/test/files/basic.ifc')"
|
||||
When I press "bim.unload_link(link_index=0)"
|
||||
Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_loaded" is "False"
|
||||
And the collection "IfcProject/basic.ifc" does not exist
|
||||
|
||||
Scenario: Load link
|
||||
Given an empty Blender session
|
||||
And I link IFC project from "{cwd}/test/files/basic.ifc"
|
||||
And I press "bim.unload_link(filepath='{cwd}/test/files/basic.ifc')"
|
||||
When I press "bim.load_link(filepath='{cwd}/test/files/basic.ifc')"
|
||||
And I press "bim.link_ifc(filepath='{cwd}/test/files/basic.ifc')"
|
||||
And I press "bim.unload_link(link_index=0)"
|
||||
When I press "bim.load_link(link_index=0)"
|
||||
Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_loaded" is "True"
|
||||
And the object "IfcProject/basic.ifc" exists
|
||||
|
||||
Scenario: Unlink IFC
|
||||
Given an empty Blender session
|
||||
And I link IFC project from "{cwd}/test/files/basic.ifc"
|
||||
And I press "bim.unload_link(filepath='{cwd}/test/files/basic.ifc')"
|
||||
When I press "bim.unlink_ifc(filepath='{cwd}/test/files/basic.ifc')"
|
||||
And I press "bim.link_ifc(filepath='{cwd}/test/files/basic.ifc')"
|
||||
And I press "bim.unload_link(link_index=0)"
|
||||
When I press "bim.unlink_ifc(link_index=0)"
|
||||
Then "scene.BIMProjectProperties.links.get('{cwd}/test/files/basic.ifc')" is "None"
|
||||
And "scene.collection.children.get('IfcProject/basic.ifc')" is "None"
|
||||
And the object "Chunk" does not exist
|
||||
|
||||
@@ -70,10 +70,7 @@ Can be useful for debugging, but has caveats - can't use ``wm.read_homefile``
|
||||
as resets the ``bpy.context`` and some it's members become `None`.
|
||||
"""
|
||||
|
||||
TMP = Path.cwd() / "test/files/temp"
|
||||
TEST_FILES_DIR = Path.cwd() / "test/files"
|
||||
|
||||
CLEAN_LINKED_FILES_CACHE = False
|
||||
TMP = Path(f"{variables['cwd']}/test/files/temp")
|
||||
|
||||
EPSET_DRAWING = Path.cwd() / "bonsai/bim/data/pset/EPset_Drawing.ifc"
|
||||
EPSET_DRAWING_BYTES = EPSET_DRAWING.read_bytes()
|
||||
@@ -283,6 +280,8 @@ def create_ui_name_cache():
|
||||
try:
|
||||
panel_type = getattr(bpy.types, bl_idname)
|
||||
if panel_type.bl_rna.base.name == "Panel":
|
||||
if "_tab_" in panel_type.bl_idname:
|
||||
continue # Tab panels are just groups and not relevant in testing
|
||||
ui_name_cache[panel_type.bl_label] = panel_type.bl_idname
|
||||
elif panel_type.bl_rna.base.name == "Operator":
|
||||
ui_name_cache[panel_type.bl_label] = bl_idname
|
||||
@@ -311,25 +310,6 @@ def vectors_are_equal(v1, v2):
|
||||
return all(is_x(v1[i], v2[i]) for i in range(len(v1)))
|
||||
|
||||
|
||||
@pytest.fixture(scope="function", autouse=True)
|
||||
def run_for_each_test() -> Generator[None]:
|
||||
# Code before this runs before each test
|
||||
yield
|
||||
# Code after this runs after each test
|
||||
|
||||
global CLEAN_LINKED_FILES_CACHE
|
||||
if CLEAN_LINKED_FILES_CACHE:
|
||||
for filepath in TEST_FILES_DIR.glob("*.ifc.cache.*"):
|
||||
filepath.unlink()
|
||||
CLEAN_LINKED_FILES_CACHE = False
|
||||
|
||||
# pset_template tests are editing EPset_Drawing.ifc, so we need to restore it.
|
||||
global RELOAD_EPSET_DRAWING
|
||||
if RELOAD_EPSET_DRAWING:
|
||||
EPSET_DRAWING.write_bytes(EPSET_DRAWING_BYTES)
|
||||
RELOAD_EPSET_DRAWING = False
|
||||
|
||||
|
||||
@given("an untestable scenario")
|
||||
def an_untestable_scenario():
|
||||
pass
|
||||
@@ -384,16 +364,6 @@ def saving_ifc_project() -> None:
|
||||
tool.Project.save_test_project()
|
||||
|
||||
|
||||
@given(parsers.parse('I link IFC project from "{filepath}"'))
|
||||
@when(parsers.parse('I link IFC project from "{filepath}"'))
|
||||
@then(parsers.parse('I link IFC project from "{filepath}"'))
|
||||
def i_link_ifc_project_from_filepath(filepath: str) -> None:
|
||||
global CLEAN_LINKED_FILES_CACHE
|
||||
filepath = replace_variables(filepath)
|
||||
CLEAN_LINKED_FILES_CACHE = True
|
||||
bpy.ops.bim.link_ifc(filepath=filepath, use_cache=False)
|
||||
|
||||
|
||||
@given("the Brickschema is stubbed")
|
||||
def the_brickschema_is_stubbed():
|
||||
# This makes things run faster since we don't need to load the entire brick schema
|
||||
@@ -1587,10 +1557,19 @@ def the_object_name_has_a_vertex_at_location(name, location):
|
||||
is_pass = False
|
||||
target = Vector([float(co) for co in location.split(",")])
|
||||
verts = []
|
||||
for v in obj.data.vertices:
|
||||
verts.append(obj.matrix_world @ v.co)
|
||||
if (verts[-1] - target).length < 0.001:
|
||||
is_pass = True
|
||||
depsgraph = bpy.context.evaluated_depsgraph_get()
|
||||
obj_eval = obj.evaluated_get(depsgraph)
|
||||
mesh = obj_eval.to_mesh(preserve_all_data_layers=False, depsgraph=depsgraph)
|
||||
try:
|
||||
for inst in depsgraph.object_instances:
|
||||
if inst.object.original is obj:
|
||||
mw = inst.matrix_world
|
||||
for i, v in enumerate(mesh.vertices):
|
||||
verts.append(mw @ v.co)
|
||||
if (verts[-1] - target).length < 0.001:
|
||||
is_pass = True
|
||||
finally:
|
||||
obj_eval.to_mesh_clear()
|
||||
assert is_pass, f"No verts found at {location}: {verts}"
|
||||
|
||||
|
||||
|
||||
@@ -24,8 +24,10 @@ import pytest
|
||||
|
||||
import bonsai.core.tool
|
||||
import bonsai.tool as tool
|
||||
import tempfile
|
||||
from bonsai.tool.blender import Blender as subject
|
||||
from test.bim.bootstrap import NewFile
|
||||
from pathlib import Path
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import bpy.stub_internal.rna_enums as rna_enums
|
||||
@@ -110,3 +112,35 @@ class TestBlenderErrorMessageExtraction(NewFile):
|
||||
assert error_reports == []
|
||||
|
||||
bpy.utils.unregister_class(OBJECT_OT_test_fail_operator)
|
||||
|
||||
|
||||
class TestGetSelectedFiles(NewFile):
|
||||
def test_get_a_single_file(self) -> None:
|
||||
with tempfile.NamedTemporaryFile() as f:
|
||||
file = type("", (object,), {"name": f.name})()
|
||||
assert subject.get_selected_files(Path(f.name).parent, [file]) == [f.name]
|
||||
|
||||
def test_get_multiple_files(self) -> None:
|
||||
with tempfile.NamedTemporaryFile() as f:
|
||||
with tempfile.NamedTemporaryFile() as g:
|
||||
file = type("", (object,), {"name": f.name})()
|
||||
file2 = type("", (object,), {"name": g.name})()
|
||||
assert subject.get_selected_files(Path(f.name).parent, [file, file2]) == [f.name, g.name]
|
||||
|
||||
def test_exclude_directories(self) -> None:
|
||||
with tempfile.NamedTemporaryFile() as f:
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
file = type("", (object,), {"name": f.name})()
|
||||
directory = type("", (object,), {"name": d})()
|
||||
assert subject.get_selected_files(Path(f.name).parent, [file, directory]) == [f.name]
|
||||
|
||||
def test_get_relative_paths(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
base_path = Path(tmp_dir)
|
||||
with tempfile.NamedTemporaryFile(dir=tmp_dir, suffix=".ifc") as f:
|
||||
tool.Ifc.set_path(str(f.name))
|
||||
with tempfile.NamedTemporaryFile(dir=tmp_dir) as g:
|
||||
file = type("", (object,), {"name": g.name})
|
||||
assert subject.get_selected_files(Path(g.name).parent, [file], use_relative_path=True) == [
|
||||
Path(g.name).name
|
||||
]
|
||||
|
||||
@@ -21,6 +21,7 @@ import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.drawing
|
||||
import ifcopenshell.api.group
|
||||
@@ -31,6 +32,7 @@ import ifcopenshell.util.element
|
||||
import mathutils
|
||||
import numpy as np
|
||||
from mathutils import Vector
|
||||
from ifcopenshell.util.shape_builder import ShapeBuilder
|
||||
|
||||
import bonsai.core.tool
|
||||
import bonsai.tool as tool
|
||||
@@ -150,6 +152,34 @@ class TestDisableEditingSheets(NewFile):
|
||||
assert props.is_editing_sheets == False
|
||||
|
||||
|
||||
class TestEditTextLiterals(NewFile):
|
||||
def test_run(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
obj = bpy.data.objects.new("Object", None)
|
||||
element = ifc.createIfcAnnotation()
|
||||
element.Representation = ifc.createIfcProductDefinitionShape()
|
||||
context = ifc.createIfcGeometricRepresentationSubContext(ContextType="Plan", ContextIdentifier="Annotation")
|
||||
item = ifc.createIfcTextLiteralWithExtent(Literal="Literal", Path="RIGHT", BoxAlignment="bottom-left")
|
||||
builder = ShapeBuilder(tool.Ifc.get())
|
||||
polyline = builder.polyline([(0.,0.,0.), (1.,0.,0.)])
|
||||
representation = ifc.createIfcShapeRepresentation(ContextOfItems=context, Items=[item, polyline])
|
||||
element.Representation.Representations = [representation]
|
||||
tool.Ifc.link(element, obj)
|
||||
literal_attributes = [
|
||||
{
|
||||
"Literal": "Foo",
|
||||
"Path": "RIGHT",
|
||||
"BoxAlignment": "bottom-left",
|
||||
}
|
||||
]
|
||||
subject.edit_text_literals(obj, literal_attributes)
|
||||
assert len(ifc.by_type("IfcTextLiteralWithExtent")) == 1
|
||||
literal = ifc.by_type("IfcTextLiteralWithExtent")[0]
|
||||
assert literal in representation.Items
|
||||
assert literal.Literal == "Foo"
|
||||
|
||||
|
||||
class TestDisableEditingText(NewFile):
|
||||
def test_run(self):
|
||||
obj = bpy.data.objects.new("Object", None)
|
||||
@@ -908,7 +938,7 @@ class TestAddReferenceImage(NewFile):
|
||||
|
||||
obj = bpy.data.objects["IfcAnnotation/image"]
|
||||
assert obj is not None
|
||||
assert tool.Cad.are_vectors_equal(obj.dimensions, Vector((3.53982, 2.0, 0.0)))
|
||||
assert tool.Cad.are_vectors_equal(obj.dimensions, Vector((1.0, 0.565, 0.0)))
|
||||
|
||||
material = obj.active_material
|
||||
assert material
|
||||
@@ -928,71 +958,3 @@ class TestAddReferenceImage(NewFile):
|
||||
|
||||
uv_node = material_nodes["Texture Coordinate"]
|
||||
assert len(uv_node.outputs["Generated"].links[:]) == 1
|
||||
|
||||
|
||||
class TestAddReference(NewFile):
|
||||
def test_add_single_reference(self):
|
||||
"""Test adding a single reference file (backward compatibility)"""
|
||||
bpy.ops.bim.create_project()
|
||||
ifc_path = Path("test/files/temp/test.ifc").absolute()
|
||||
bpy.ops.bim.save_project(filepath=str(ifc_path), should_save_as=True)
|
||||
|
||||
# Create a temporary SVG file
|
||||
svg_path = Path("test/files/temp/reference.svg").absolute()
|
||||
svg_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(svg_path, "w") as f:
|
||||
f.write('<svg xmlns="http://www.w3.org/2000/svg"></svg>')
|
||||
|
||||
try:
|
||||
# Add single reference
|
||||
bpy.ops.bim.add_reference(filepath=str(svg_path))
|
||||
|
||||
# Verify reference was added
|
||||
ifc = tool.Ifc.get()
|
||||
references = [doc for doc in ifc.by_type("IfcDocumentInformation") if doc.Scope == "REFERENCE"]
|
||||
assert len(references) == 1
|
||||
assert references[0].Name == "reference"
|
||||
finally:
|
||||
# Cleanup
|
||||
if svg_path.exists():
|
||||
svg_path.unlink()
|
||||
|
||||
def test_add_multiple_references(self):
|
||||
"""Test adding multiple reference files at once"""
|
||||
bpy.ops.bim.create_project()
|
||||
ifc_path = Path("test/files/temp/test.ifc").absolute()
|
||||
bpy.ops.bim.save_project(filepath=str(ifc_path), should_save_as=True)
|
||||
|
||||
# Create temporary SVG files
|
||||
temp_dir = Path("test/files/temp").absolute()
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
svg_files = []
|
||||
for i in range(3):
|
||||
svg_path = temp_dir / f"reference_{i}.svg"
|
||||
with open(svg_path, "w") as f:
|
||||
f.write('<svg xmlns="http://www.w3.org/2000/svg"></svg>')
|
||||
svg_files.append(svg_path)
|
||||
|
||||
try:
|
||||
# Test by directly calling core.add_document multiple times
|
||||
# (simulating what the operator does with multiple files)
|
||||
ifc = tool.Ifc.get()
|
||||
for svg_file in svg_files:
|
||||
uri = tool.Ifc.get_uri(str(svg_file), use_relative_path=True)
|
||||
from bonsai.bim import core
|
||||
|
||||
core.drawing.add_document(tool.Ifc, tool.Drawing, "REFERENCE", uri=uri)
|
||||
|
||||
# Verify all references were added
|
||||
references = [doc for doc in ifc.by_type("IfcDocumentInformation") if doc.Scope == "REFERENCE"]
|
||||
assert len(references) == 3
|
||||
|
||||
reference_names = {ref.Name for ref in references}
|
||||
expected_names = {f"reference_{i}" for i in range(3)}
|
||||
assert reference_names == expected_names
|
||||
finally:
|
||||
# Cleanup
|
||||
for svg_file in svg_files:
|
||||
if svg_file.exists():
|
||||
svg_file.unlink()
|
||||
|
||||
@@ -16,9 +16,12 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import json
|
||||
import contextlib
|
||||
import tempfile
|
||||
import numpy as np
|
||||
from pathlib import Path
|
||||
from tempfile import NamedTemporaryFile
|
||||
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
@@ -263,7 +266,7 @@ class TestLoadLinkedModels(NewFile):
|
||||
props = tool.Project.get_project_props()
|
||||
ifcopenshell.api.root.create_entity(ifc, "IfcProject")
|
||||
document = ifcopenshell.api.document.add_information(ifc)
|
||||
document.Name = "BBIM_Linked_Models"
|
||||
document.Name = "X"
|
||||
tool.Ifc.set(ifc)
|
||||
subject.load_linked_models_from_ifc()
|
||||
assert len(props.links) == 0
|
||||
@@ -273,86 +276,81 @@ class TestLoadLinkedModels(NewFile):
|
||||
props = tool.Project.get_project_props()
|
||||
ifcopenshell.api.root.create_entity(ifc, "IfcProject")
|
||||
document = ifcopenshell.api.document.add_information(ifc)
|
||||
document.Name = "BBIM_Linked_Models"
|
||||
document.Scope = "LINKED_MODEL"
|
||||
reference = ifcopenshell.api.document.add_reference(ifc, document)
|
||||
linked_model_path = "test.ifc"
|
||||
reference.Location = linked_model_path
|
||||
reference.Location = "test.ifc"
|
||||
reference.Identification = ""
|
||||
reference2 = ifcopenshell.api.document.add_reference(ifc, document)
|
||||
reference2.Location = "test2.ifc"
|
||||
reference2.Identification = "1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16"
|
||||
tool.Ifc.set(ifc)
|
||||
subject.load_linked_models_from_ifc()
|
||||
assert len(props.links) == 1
|
||||
assert props.links[0].name == linked_model_path
|
||||
assert len(props.links) == 2
|
||||
assert props.links[0].name == "test.ifc"
|
||||
assert props.links[0].ifc_definition_id == reference.id()
|
||||
assert props.links[0].has_transformation is False
|
||||
assert props.links[1].name == "test2.ifc"
|
||||
assert props.links[1].ifc_definition_id == reference2.id()
|
||||
assert props.links[1].has_transformation is True
|
||||
|
||||
|
||||
class TestSaveLinkedModelsToIfc(NewFile):
|
||||
def test_save_linked_models_to_ifc_no_links(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
subject.save_linked_models_to_ifc()
|
||||
assert len(ifc.by_type("IfcDocumentInformation")) == 0
|
||||
assert len(ifc.by_type("IfcDocumentReference")) == 0
|
||||
|
||||
def test_save_linked_models_to_ifc_paths_to_add(self):
|
||||
ifc = ifcopenshell.file()
|
||||
ifcopenshell.api.root.create_entity(ifc, "IfcProject")
|
||||
class TestCalculateLinkMatrix(NewFile):
|
||||
def test_linking_a_model_without_an_offset_to_our_session_with_no_offset(self):
|
||||
props = tool.Project.get_project_props()
|
||||
link = props.links.add()
|
||||
linked_model_path = "test.ifc"
|
||||
link.name = linked_model_path
|
||||
tool.Ifc.set(ifc)
|
||||
subject.save_linked_models_to_ifc()
|
||||
assert len(documents := ifc.by_type("IfcDocumentInformation")) == 1
|
||||
assert documents[0].Name == "BBIM_Linked_Models"
|
||||
assert len(references := ifc.by_type("IfcDocumentReference")) == 1
|
||||
assert references[0].Location == linked_model_path
|
||||
gprops = tool.Georeference.get_georeference_props()
|
||||
with NamedTemporaryFile(suffix=".ifc.cache.json", mode="w", delete=True) as tmp:
|
||||
link = props.links.add()
|
||||
link.filepath = tmp.name.replace(".ifc.cache.json", ".ifc")
|
||||
json.dump({"model_project_north": "0", "model_origin_si": "0,0,0"}, tmp)
|
||||
tmp.flush()
|
||||
gprops.model_project_north = "0"
|
||||
gprops.model_origin_si = "0,0,0"
|
||||
assert np.allclose(subject.calculate_link_matrix(link), np.eye(4))
|
||||
|
||||
def test_save_linked_models_to_ifc_already_created_references(self):
|
||||
ifc = ifcopenshell.file()
|
||||
links = tool.Project.get_project_props().links
|
||||
ifcopenshell.api.root.create_entity(ifc, "IfcProject")
|
||||
def test_linking_an_offset_model_to_our_session_with_no_offset(self):
|
||||
props = tool.Project.get_project_props()
|
||||
gprops = tool.Georeference.get_georeference_props()
|
||||
with NamedTemporaryFile(suffix=".ifc.cache.json", mode="w", delete=True) as tmp:
|
||||
link = props.links.add()
|
||||
link.filepath = tmp.name.replace(".ifc.cache.json", ".ifc")
|
||||
json.dump({"model_project_north": "0", "model_origin_si": "5,0,0"}, tmp)
|
||||
tmp.flush()
|
||||
gprops.model_project_north = "0"
|
||||
gprops.model_origin_si = "0,0,0"
|
||||
m = np.eye(4)
|
||||
m[0][3] = 5
|
||||
assert np.allclose(subject.calculate_link_matrix(link), m)
|
||||
|
||||
document = ifcopenshell.api.document.add_information(ifc)
|
||||
document.Name = "BBIM_Linked_Models"
|
||||
document_id = document.id()
|
||||
reference = ifcopenshell.api.document.add_reference(ifc, document)
|
||||
linked_model_path = "test.ifc"
|
||||
reference.Location = linked_model_path
|
||||
reference_id = reference.id()
|
||||
def test_linking_an_offset_model_to_our_session_with_offset(self):
|
||||
props = tool.Project.get_project_props()
|
||||
gprops = tool.Georeference.get_georeference_props()
|
||||
with NamedTemporaryFile(suffix=".ifc.cache.json", mode="w", delete=True) as tmp:
|
||||
link = props.links.add()
|
||||
link.filepath = tmp.name.replace(".ifc.cache.json", ".ifc")
|
||||
json.dump({"model_project_north": "0", "model_origin_si": "5,0,0"}, tmp)
|
||||
tmp.flush()
|
||||
gprops.model_project_north = "0"
|
||||
gprops.model_origin_si = "2,0,0"
|
||||
m = np.eye(4)
|
||||
m[0][3] = 3
|
||||
assert np.allclose(subject.calculate_link_matrix(link), m)
|
||||
|
||||
link = links.add()
|
||||
linked_model_path = "test.ifc"
|
||||
link.name = linked_model_path
|
||||
tool.Ifc.set(ifc)
|
||||
subject.save_linked_models_to_ifc()
|
||||
|
||||
# Information and references to stay intact.
|
||||
assert len(documents := ifc.by_type("IfcDocumentInformation")) == 1
|
||||
assert documents[0].id() == document_id
|
||||
assert documents[0].Name == "BBIM_Linked_Models"
|
||||
assert len(references := ifc.by_type("IfcDocumentReference")) == 1
|
||||
assert references[0].id() == reference_id
|
||||
assert references[0].Location == linked_model_path
|
||||
|
||||
def test_save_linked_models_to_ifc_references_to_remove(self):
|
||||
ifc = ifcopenshell.file()
|
||||
links = tool.Project.get_project_props().links
|
||||
ifcopenshell.api.root.create_entity(ifc, "IfcProject")
|
||||
|
||||
document = ifcopenshell.api.document.add_information(ifc)
|
||||
document.Name = "BBIM_Linked_Models"
|
||||
document_id = document.id()
|
||||
reference = ifcopenshell.api.document.add_reference(ifc, document)
|
||||
linked_model_path = "test.ifc"
|
||||
reference.Location = linked_model_path
|
||||
|
||||
tool.Ifc.set(ifc)
|
||||
subject.save_linked_models_to_ifc()
|
||||
links.clear()
|
||||
|
||||
# Remove reference for removed link.
|
||||
assert len(documents := ifc.by_type("IfcDocumentInformation")) == 1
|
||||
assert documents[0].id() == document_id
|
||||
assert documents[0].Name == "BBIM_Linked_Models"
|
||||
assert len(ifc.by_type("IfcDocumentReference")) == 0
|
||||
def test_linking_an_offset_model_to_our_session_with_offset_and_transformation(self):
|
||||
props = tool.Project.get_project_props()
|
||||
gprops = tool.Georeference.get_georeference_props()
|
||||
with NamedTemporaryFile(suffix=".ifc.cache.json", mode="w", delete=True) as tmp:
|
||||
link = props.links.add()
|
||||
link.filepath = tmp.name.replace(".ifc.cache.json", ".ifc")
|
||||
transformation = np.eye(4)
|
||||
transformation[0][3] = 4
|
||||
link.transformation = ",".join(map(str, transformation.reshape(-1)))
|
||||
json.dump({"model_project_north": "0", "model_origin_si": "5,0,0"}, tmp)
|
||||
tmp.flush()
|
||||
gprops.model_project_north = "0"
|
||||
gprops.model_origin_si = "2,0,0"
|
||||
m = np.eye(4)
|
||||
m[0][3] = 7
|
||||
assert np.allclose(subject.calculate_link_matrix(link), m)
|
||||
|
||||
|
||||
class TestLoadingIfcSqlite(NewFile):
|
||||
|
||||
@@ -12,11 +12,8 @@ ifcopenshell::geometry::Converter::Converter(std::unique_ptr<ifcopenshell::geome
|
||||
settings_ = mapping_->settings();
|
||||
}
|
||||
|
||||
ifcopenshell::geometry::Converter::~Converter()
|
||||
{
|
||||
if (mapping_ != nullptr) {
|
||||
delete mapping_;
|
||||
}
|
||||
ifcopenshell::geometry::Converter::~Converter() {
|
||||
delete mapping_;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
@@ -82,49 +82,79 @@ bool OpenCascadeKernel::convert(const taxonomy::loft::ptr loft, TopoDS_Shape& re
|
||||
}
|
||||
|
||||
if (non_polygonal) {
|
||||
if (loft->children.size() == 2) {
|
||||
BRep_Builder BB;
|
||||
TopoDS_Shell comp;
|
||||
BB.MakeShell(comp);
|
||||
if (loft->children.size() < 2) {
|
||||
Logger::Error("Not enough sections to loft");
|
||||
return false;
|
||||
}
|
||||
|
||||
std::vector<std::vector<TopoDS_Wire>> sections;
|
||||
sections.reserve(loft->children.size());
|
||||
|
||||
TopoDS_Shape f0, f1;
|
||||
if (!convert(std::static_pointer_cast<taxonomy::face>(loft->children.front()), f0) ||
|
||||
!convert(std::static_pointer_cast<taxonomy::face>(loft->children.back()), f1))
|
||||
{
|
||||
TopoDS_Shape f0, f1;
|
||||
|
||||
// Convert all children to vectors of wires
|
||||
for (const auto& child : loft->children) {
|
||||
TopoDS_Shape shape;
|
||||
if (!convert(std::static_pointer_cast<taxonomy::face>(child), shape)) {
|
||||
return false;
|
||||
}
|
||||
if (shape.ShapeType() != TopAbs_FACE) {
|
||||
return false;
|
||||
}
|
||||
// At least make sure to have outer wire consistent, but in reality
|
||||
// this is probably not a concern given how to build up these faces
|
||||
auto f = TopoDS::Face(shape);
|
||||
|
||||
if (child == loft->children.front()) {
|
||||
f0 = f;
|
||||
} else if (child == loft->children.back()) {
|
||||
f1 = f;
|
||||
}
|
||||
|
||||
auto outer = BRepTools::OuterWire(f);
|
||||
sections.emplace_back();
|
||||
sections.back().push_back(outer);
|
||||
for (TopoDS_Iterator it(f); it.More(); it.Next()) {
|
||||
if (outer != it.Value()) {
|
||||
sections.back().push_back(TopoDS::Wire(it.Value()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
auto first_wire_count = sections.front().size();
|
||||
for (auto& section : sections) {
|
||||
if (section.size() != first_wire_count) {
|
||||
Logger::Error("Inconsistent number of wires in sections");
|
||||
return false;
|
||||
}
|
||||
if (f0.ShapeType() != TopAbs_FACE || f1.ShapeType() != TopAbs_FACE) {
|
||||
}
|
||||
|
||||
BRep_Builder BB;
|
||||
TopoDS_Shell comp;
|
||||
BB.MakeShell(comp);
|
||||
|
||||
for (size_t i = 0; i < first_wire_count; ++i) {
|
||||
// Rule=True uses linear interpolation.
|
||||
// This is critical for preventing twists in roads/railings.
|
||||
BRepOffsetAPI_ThruSections builder(false, true);
|
||||
for (auto& ws : sections) {
|
||||
builder.AddWire(ws[i]);
|
||||
}
|
||||
builder.Build();
|
||||
if (!builder.IsDone()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
TopExp_Explorer exp1(f0, TopAbs_WIRE);
|
||||
TopExp_Explorer exp2(f1, TopAbs_WIRE);
|
||||
for (; exp1.More() && exp2.More(); exp1.Next(), exp2.Next()) {
|
||||
const auto& w1 = TopoDS::Wire(exp1.Current());
|
||||
const auto& w2 = TopoDS::Wire(exp2.Current());
|
||||
BRepOffsetAPI_ThruSections builder;
|
||||
builder.AddWire(w1);
|
||||
builder.AddWire(w2);
|
||||
builder.Build();
|
||||
if (!builder.IsDone()) {
|
||||
return false;
|
||||
}
|
||||
for (TopExp_Explorer exp(builder.Shape(), TopAbs_FACE); exp.More(); exp.Next()) {
|
||||
BB.Add(comp, exp.Current());
|
||||
}
|
||||
for (TopExp_Explorer exp(builder.Shape(), TopAbs_FACE); exp.More(); exp.Next()) {
|
||||
BB.Add(comp, exp.Current());
|
||||
}
|
||||
|
||||
BB.Add(comp, f0.Reversed());
|
||||
BB.Add(comp, f1);
|
||||
|
||||
result = BRepBuilderAPI_MakeSolid(comp).Solid();
|
||||
|
||||
return true;
|
||||
} else {
|
||||
Logger::Error("Lofting more than two sections is not supported");
|
||||
return false;
|
||||
}
|
||||
|
||||
BB.Add(comp, f0.Reversed());
|
||||
BB.Add(comp, f1);
|
||||
|
||||
result = BRepBuilderAPI_MakeSolid(comp).Solid();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
TopTools_ListOfShape faces;
|
||||
|
||||
@@ -21,7 +21,44 @@
|
||||
#define mapping POSTFIX_SCHEMA(mapping)
|
||||
using namespace ifcopenshell::geometry;
|
||||
|
||||
#include <deque>
|
||||
|
||||
taxonomy::ptr mapping::map_impl(const IfcSchema::IfcObjectPlacement* inst) {
|
||||
if (placement_rel_to_type_ || placement_rel_to_instance_) {
|
||||
using QueueItem = std::pair<const IfcUtil::IfcBaseEntity*, int>;
|
||||
std::deque<QueueItem> q = {{inst, 0}};
|
||||
while (!q.empty()) {
|
||||
auto [placement_entity, depth] = q.front();
|
||||
q.pop_front();
|
||||
|
||||
auto placement = placement_entity->as<typename IfcSchema::IfcObjectPlacement>();
|
||||
if (!placement) {
|
||||
continue;
|
||||
}
|
||||
|
||||
auto self_places = placement->PlacesObject();
|
||||
for (auto iter = self_places->begin(); iter != self_places->end(); ++iter) {
|
||||
if ((placement_rel_to_type_ && (*iter)->declaration().is(*placement_rel_to_type_)) ||
|
||||
(placement_rel_to_instance_ && (*iter)->as<IfcUtil::IfcBaseEntity>() == placement_rel_to_instance_)) {
|
||||
return taxonomy::make<taxonomy::matrix4>();
|
||||
}
|
||||
}
|
||||
|
||||
// Look for two levels deep, we want to know if we're at or *above* the
|
||||
// element we're ignoring, but we don't want to traverse the entire model.
|
||||
#ifdef SCHEMA_IfcObjectPlacement_HAS_ReferencedByPlacements
|
||||
if (depth < 2) {
|
||||
auto refs = placement->ReferencedByPlacements();
|
||||
for (auto& ref : *refs) {
|
||||
q.emplace_back(ref, depth + 1);
|
||||
}
|
||||
}
|
||||
#else
|
||||
Logger::Warning("Using --site-local-placement or --building-local-placement on IFC4.2 might have issues");
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
const IfcSchema::IfcObjectPlacement* relative_to = nullptr;
|
||||
const IfcUtil::IfcBaseInterface* transform;
|
||||
|
||||
|
||||
@@ -61,33 +61,9 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcSectionedSolidHorizontal* in
|
||||
|
||||
longitudes.push_back(*pbde->DistanceAlong()->as<IfcSchema::IfcLengthMeasure>(true) * length_unit_);
|
||||
|
||||
// Corresponds to the profile X, Y directions (hopefully).
|
||||
Eigen::Vector3d po(
|
||||
pbde->OffsetLateral().get_value_or(0.),
|
||||
// @todo I don't understand whether vertical is an offset relative to the tangent plane or to the global XY plane
|
||||
pbde->OffsetVertical().get_value_or(0.),
|
||||
0.
|
||||
);
|
||||
|
||||
profile_offsets.push_back(po);
|
||||
|
||||
boost::optional<Eigen::Matrix3d> rot;
|
||||
if (csp->Axis() && csp->RefDirection()) {
|
||||
rot = taxonomy::matrix4(
|
||||
Eigen::Vector3d(0, 0, 0),
|
||||
taxonomy::cast<taxonomy::direction3>(map(csp->Axis()))->ccomponents(),
|
||||
taxonomy::cast<taxonomy::direction3>(map(csp->RefDirection()))->ccomponents()).ccomponents().block<3,3>(0,0);
|
||||
} else if (csp->Axis()) {
|
||||
rot = taxonomy::matrix4(
|
||||
Eigen::Vector3d(0, 0, 0),
|
||||
taxonomy::cast<taxonomy::direction3>(map(csp->Axis()))->ccomponents()).ccomponents().block<3, 3>(0, 0);
|
||||
} else if (csp->RefDirection()) {
|
||||
rot = taxonomy::matrix4(
|
||||
Eigen::Vector3d(0, 0, 0),
|
||||
Eigen::Vector3d(0, 0, 1),
|
||||
taxonomy::cast<taxonomy::direction3>(map(csp->RefDirection()))->ccomponents()
|
||||
).ccomponents().block<3, 3>(0, 0);
|
||||
}
|
||||
auto linear_placement = taxonomy::cast<taxonomy::matrix4>(map(csp));
|
||||
profile_offsets.push_back(linear_placement->ccomponents().block<3, 1>(0, 3));
|
||||
boost::optional<Eigen::Matrix3d> rot(linear_placement->ccomponents().block<3,3>(0,0));
|
||||
profile_rotations.push_back(rot);
|
||||
}
|
||||
if (faces.size() != profile_offsets.size()) {
|
||||
|
||||
@@ -63,35 +63,10 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcSectionedSurface* inst) {
|
||||
|
||||
longitudes.push_back(*pbde->DistanceAlong()->as<IfcSchema::IfcLengthMeasure>(true) * length_unit_);
|
||||
|
||||
// Corresponds to the profile X, Y directions (hopefully).
|
||||
Eigen::Vector3d po(
|
||||
pbde->OffsetLateral().get_value_or(0.),
|
||||
// @todo I don't understand whether vertical is an offset relative to the tangent plane or to the global XY plane
|
||||
pbde->OffsetVertical().get_value_or(0.),
|
||||
0.
|
||||
);
|
||||
|
||||
profile_offsets.push_back(po);
|
||||
|
||||
boost::optional<Eigen::Matrix3d> rot;
|
||||
if (csp->Axis() && csp->RefDirection()) {
|
||||
rot = taxonomy::matrix4(
|
||||
Eigen::Vector3d(0, 0, 0),
|
||||
taxonomy::cast<taxonomy::direction3>(map(csp->Axis()))->ccomponents(),
|
||||
taxonomy::cast<taxonomy::direction3>(map(csp->RefDirection()))->ccomponents()).ccomponents().block<3, 3>(0, 0);
|
||||
} else if (csp->Axis()) {
|
||||
rot = taxonomy::matrix4(
|
||||
Eigen::Vector3d(0, 0, 0),
|
||||
taxonomy::cast<taxonomy::direction3>(map(csp->Axis()))->ccomponents()).ccomponents().block<3, 3>(0, 0);
|
||||
} else if (csp->RefDirection()) {
|
||||
rot = taxonomy::matrix4(
|
||||
Eigen::Vector3d(0, 0, 0),
|
||||
Eigen::Vector3d(0, 0, 1),
|
||||
taxonomy::cast<taxonomy::direction3>(map(csp->RefDirection()))->ccomponents())
|
||||
.ccomponents()
|
||||
.block<3, 3>(0, 0);
|
||||
}
|
||||
profile_rotations.push_back(rot);
|
||||
auto linear_placement = taxonomy::cast<taxonomy::matrix4>(map(csp));
|
||||
profile_offsets.push_back(linear_placement->ccomponents().block<3, 1>(0, 3));
|
||||
boost::optional<Eigen::Matrix3d> rot(linear_placement->ccomponents().block<3, 3>(0, 0));
|
||||
profile_rotations.push_back(rot);
|
||||
}
|
||||
#else
|
||||
return nullptr;
|
||||
|
||||
@@ -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
|
||||
@@ -126,10 +128,6 @@ def _add_segment_to_curve(file: ifcopenshell.file, segment: entity_instance, cur
|
||||
elif segment.DesignParameters.is_a("IfcAlignmentCantSegment") and not curve.is_a("IfcSegmentedReferenceCurve"):
|
||||
raise TypeError(f"Expected to see IfcSegmentedReferenceCurve, instead received '{curve.is_a()}'.")
|
||||
|
||||
expected_type = "IfcCompositeCurve"
|
||||
if not curve.is_a(expected_type):
|
||||
raise TypeError(f"Expected to see {expected_type}, instead received {curve.is_a()}.")
|
||||
|
||||
# map the IfcAlignmentSegment to an IfcCurveSegment (or two in the case of helmert curves)
|
||||
if segment.DesignParameters.is_a("IfcAlignmentHorizontalSegment"):
|
||||
mapped_segments = _map_alignment_horizontal_segment(file, segment)
|
||||
@@ -141,6 +139,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
|
||||
|
||||
@@ -91,7 +91,9 @@ class IfcHeaderExtractor:
|
||||
data = HeaderMetadata()
|
||||
max_lines_to_parse = 50
|
||||
for _ in range(max_lines_to_parse):
|
||||
line = next(ifc_file)
|
||||
line = next(ifc_file, None)
|
||||
if line is None:
|
||||
break
|
||||
if isinstance(line, bytes):
|
||||
line = line.decode("utf-8")
|
||||
if line.startswith("FILE_DESCRIPTION"):
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
import math
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
from typing import NamedTuple, Optional, Union
|
||||
from typing import NamedTuple, Optional, Union, Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
@@ -268,6 +268,15 @@ def get_helmert_transformation_parameters(ifc_file: ifcopenshell.file) -> Option
|
||||
return HelmertTransformation(e, n, h, xaa, xao, scale, factor_x, factor_y, factor_z)
|
||||
|
||||
|
||||
def get_crs(ifc_file: ifcopenshell.file) -> dict[str, Any]:
|
||||
"""Get CRS information from an IFC file."""
|
||||
if ifc_file.schema == "IFC2X3":
|
||||
return ifcopenshell.util.element.get_pset(ifc_file.by_type("IfcProject")[0], "ePSet_ProjectedCRS")
|
||||
for context in ifc_file.by_type("IfcGeometricRepresentationContext", include_subtypes=False):
|
||||
if operation := context.HasCoordinateOperation:
|
||||
return operation[0].TargetCRS.get_info()
|
||||
|
||||
|
||||
def auto_z2e(ifc_file: ifcopenshell.file, z: float, should_return_in_map_units: bool = True) -> float:
|
||||
"""Convert a Z coordinate to an elevation using model georeferencing data
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -746,7 +746,7 @@ namespace {
|
||||
static std::string format_double(const double& d) {
|
||||
std::ostringstream oss;
|
||||
oss.imbue(std::locale::classic());
|
||||
oss << std::setprecision(std::numeric_limits<double>::digits10) << d;
|
||||
oss << std::setprecision(std::numeric_limits<double>::max_digits10) << d;
|
||||
const std::string str = oss.str();
|
||||
oss.str("");
|
||||
std::string::size_type e = str.find('e');
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,8 +2,10 @@
|
||||
#define GRAPH_2D_H
|
||||
|
||||
#ifdef SVGFILL_DEBUG
|
||||
#if 0
|
||||
#include <nlohmann/json.hpp>
|
||||
#endif
|
||||
#endif
|
||||
|
||||
template <typename Kernel>
|
||||
class Graph2D {
|
||||
@@ -334,6 +336,16 @@ public:
|
||||
return Graph2D(input_adjacency_list);
|
||||
}
|
||||
|
||||
template <typename T>
|
||||
void to_arrangement(T& arr) {
|
||||
for (auto it = edges_begin(); it != edges_end(); ++it) {
|
||||
if (it->first == it->second) {
|
||||
continue;
|
||||
}
|
||||
CGAL::insert(arr, CGAL::Segment_2<Kernel>(it->first, it->second));
|
||||
}
|
||||
}
|
||||
|
||||
void assert_symmetric() {
|
||||
#ifdef SVGFILL_DEBUG
|
||||
#if 0
|
||||
|
||||
Reference in New Issue
Block a user