mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-09 01:11:40 +00:00
Compare commits
105 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e02ab5b30 | |||
| 2d7a556dd5 | |||
| dcc25038f9 | |||
| 0d382119dd | |||
| 41afaaec0d | |||
| f69ea82789 | |||
| 2f5c71588e | |||
| 7d8c7a2c3d | |||
| 514cbb49cc | |||
| 9ff4a7f0e0 | |||
| 8afe05601e | |||
| 1751c36c67 | |||
| d4388ec76d | |||
| 7141f2cf90 | |||
| 418d410b5c | |||
| a5461c0748 | |||
| 291e815770 | |||
| ed500d58ba | |||
| 8023a992da | |||
| fcc80ad14a | |||
| c6b14d1474 | |||
| 37fe0ad993 | |||
| e20e286168 | |||
| 8c0bed0c61 | |||
| 8cfb162851 | |||
| 379c74b31f | |||
| 1d1f158fe2 | |||
| 65d5df7801 | |||
| b246998f68 | |||
| a88c5938dc | |||
| 7978f1fb08 | |||
| 7b4889d2ec | |||
| 740fcf7768 | |||
| 067e04b564 | |||
| cd95f46db5 | |||
| 348e48b49c | |||
| 6bb8abdc5a | |||
| b7a8c9b330 | |||
| e6780973da | |||
| c6072e416c | |||
| f5686fff26 | |||
| a46cdbb907 | |||
| a4d5d4e19a | |||
| 17b88fff22 | |||
| 3fe9980aa4 | |||
| dffa3515c0 | |||
| 1d5108f934 | |||
| 02fab6eee2 | |||
| 5e9f97a0c7 | |||
| 2692341d3a | |||
| a54b21b838 | |||
| 79ef6eb55c | |||
| 7e1843b962 | |||
| 7411da2ae8 | |||
| 2f586d6b9e | |||
| f10d4602fc | |||
| 50d00eba20 | |||
| 95480a231c | |||
| a62b177b19 | |||
| d6b636909f | |||
| abf0d6f81a | |||
| 015431fa1f | |||
| 95cc52a6f3 | |||
| 9dff1441d7 | |||
| fdf23ece85 | |||
| 1445f89f19 | |||
| fe395f9024 | |||
| 950e147bde | |||
| c385b93701 | |||
| 2d2e8e51aa | |||
| 13f03b7487 | |||
| c6257a4b3a | |||
| 9b8b05a570 | |||
| 611a898ada | |||
| 6409f41cdf | |||
| 35dc029898 | |||
| 2f72346845 | |||
| 40e4894f8e | |||
| 91ee2b9222 | |||
| ac4ffd91e7 | |||
| d74588246a | |||
| e8a4590d0a | |||
| 38f9f309e7 | |||
| 47ac909a81 | |||
| 6a63c651d3 | |||
| 2727bec929 | |||
| 22f91e11b3 | |||
| 398cd7571e | |||
| 83d97d7e95 | |||
| e47694458f | |||
| 98a3b2b132 | |||
| d06f5a6cb9 | |||
| 966751a928 | |||
| c1aa1dedba | |||
| cc995db98b | |||
| 0e8c98a7bb | |||
| 5ce6d927f3 | |||
| 34303c2cfe | |||
| 6cfb6d74fe | |||
| e76c2a70e6 | |||
| 53e5f84730 | |||
| bcff5c7449 | |||
| 6f78d60c9f | |||
| 8a32d7bea0 | |||
| fed98451d4 |
@@ -139,7 +139,7 @@ jobs:
|
||||
cd ..
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@v5
|
||||
uses: aws-actions/configure-aws-credentials@v6
|
||||
with:
|
||||
aws-access-key-id: ${{ secrets.AWS_UPLOAD_ACCESS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.AWS_UPLOAD_SECRET_ACCESS_KEY }}
|
||||
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
git push || echo "Push failed"
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@v5
|
||||
uses: aws-actions/configure-aws-credentials@v6
|
||||
with:
|
||||
aws-access-key-id: ${{ secrets.AWS_UPLOAD_ACCESS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.AWS_UPLOAD_SECRET_ACCESS_KEY }}
|
||||
|
||||
@@ -118,7 +118,7 @@ jobs:
|
||||
cd ..
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@v5
|
||||
uses: aws-actions/configure-aws-credentials@v6
|
||||
with:
|
||||
aws-access-key-id: ${{ secrets.AWS_UPLOAD_ACCESS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.AWS_UPLOAD_SECRET_ACCESS_KEY }}
|
||||
|
||||
@@ -118,7 +118,7 @@ jobs:
|
||||
cd ..
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@v5
|
||||
uses: aws-actions/configure-aws-credentials@v6
|
||||
with:
|
||||
aws-access-key-id: ${{ secrets.AWS_UPLOAD_ACCESS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.AWS_UPLOAD_SECRET_ACCESS_KEY }}
|
||||
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
git push || echo "Push failed"
|
||||
|
||||
- name: Configure AWS Credentials
|
||||
uses: aws-actions/configure-aws-credentials@v5
|
||||
uses: aws-actions/configure-aws-credentials@v6
|
||||
with:
|
||||
aws-access-key-id: ${{ secrets.AWS_UPLOAD_ACCESS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.AWS_UPLOAD_SECRET_ACCESS_KEY }}
|
||||
|
||||
@@ -53,6 +53,11 @@ jobs:
|
||||
name: "MacOS ARM Build",
|
||||
short_name: macosm1,
|
||||
}
|
||||
exclude:
|
||||
# Python 3.13 is needed for Blender 5.1+ and Blender dropped Intel Mac support in 5.0.
|
||||
- pyver: py313
|
||||
config:
|
||||
short_name: macos
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
|
||||
|
||||
@@ -53,7 +53,6 @@ jobs:
|
||||
python -m pip install --upgrade pip
|
||||
pip install xmlschema xsdata numpy lxml pytest isodate lark networkx tabulate python-dateutil shapely
|
||||
pip install src/bcf --no-deps
|
||||
pip install git+https://github.com/zdhoward/aud
|
||||
pip install pytest-xdist==3.8.0
|
||||
|
||||
- name: Install C++ dependencies
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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.
|
||||
+2
-2
@@ -3,9 +3,9 @@ name = "IfcOpenShell"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"black==26.1.0",
|
||||
"ruff==0.14.14",
|
||||
"ruff==0.15.1",
|
||||
"poethepoet",
|
||||
"gersemi==0.25.1",
|
||||
"gersemi==0.25.4",
|
||||
]
|
||||
|
||||
[tool.black]
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Build Bonsai .zip.
|
||||
# Usage:
|
||||
# # While in `bonsai` folder.
|
||||
# docker build -t bonsai-dist .
|
||||
# # Output zip file will be in `dist` folder.
|
||||
# # See possible values for variables in `Makefile`'s `dist` target description.
|
||||
# docker run --rm -v "$PWD/../../:/work" -w /work -e PLATFORM=win -e PYVERSION=py313 bonsai-dist
|
||||
|
||||
FROM ubuntu:24.04
|
||||
RUN apt-get update
|
||||
RUN apt-get install -y make git wget unzip zip
|
||||
|
||||
# Python
|
||||
RUN apt-get install -y software-properties-common
|
||||
RUN add-apt-repository ppa:deadsnakes/ppa
|
||||
RUN apt-get update
|
||||
RUN apt-get install -y python3.11 python3.11-venv
|
||||
|
||||
RUN apt-get install -y npm
|
||||
|
||||
WORKDIR /work
|
||||
CMD cd src/bonsai && make dist PLATFORM=$PLATFORM PYVERSION=$PYVERSION
|
||||
+37
-21
@@ -48,18 +48,33 @@ VERSION_PATCH:=$(shell cat '../../VERSION' | cut -d '.' -f 3)
|
||||
VERSION_DATE:=$(shell date '+%y%m%d')
|
||||
LAST_COMMIT_HASH:=$(shell git rev-parse HEAD)
|
||||
LAST_COMMIT_DATE:=$(shell git show -s --format=%cI)
|
||||
PYVERSION:=py310
|
||||
PYPI_IMP:=cp
|
||||
|
||||
ifeq ($(PYVERSION), py311)
|
||||
PYLIBDIR:=python3.11
|
||||
PYNUMBER:=311
|
||||
PYPI_VERSION:=3.11
|
||||
ifdef PYVERSION
|
||||
SUPPORTED_PYVERSIONS := py311 py312 py313
|
||||
|
||||
ifeq ($(filter $(PYVERSION),$(SUPPORTED_PYVERSIONS)),)
|
||||
$(error Unsupported PYVERSION=$(PYVERSION). Must be one of $(SUPPORTED_PYVERSIONS))
|
||||
endif
|
||||
|
||||
PYMINOR:=$(subst py3,,$(PYVERSION))
|
||||
PYLIBDIR:=python3.$(PYMINOR)
|
||||
PYNUMBER:=3$(PYMINOR)
|
||||
PYPI_VERSION:=3.$(PYMINOR)
|
||||
endif # def PYVERSION
|
||||
|
||||
|
||||
ifdef PLATFORM
|
||||
SUPPORTED_PLATFORMS := linux macos macosm1 win
|
||||
|
||||
ifeq ($(filter $(PLATFORM),$(SUPPORTED_PLATFORMS)),)
|
||||
$(error Unsupported PLATFORM=$(PLATFORM). Must be one of $(SUPPORTED_PLATFORMS))
|
||||
endif
|
||||
|
||||
ifeq ($(PLATFORM),macos)
|
||||
ifeq ($(PYVERSION),py313)
|
||||
$(error Blender 5.1 with Python 3.13 doesn't support intel macOS.)
|
||||
endif
|
||||
ifeq ($(PYVERSION), py312)
|
||||
PYLIBDIR:=python3.12
|
||||
PYNUMBER:=312
|
||||
PYPI_VERSION:=3.12
|
||||
endif
|
||||
|
||||
ifeq ($(PLATFORM), linux)
|
||||
@@ -86,6 +101,8 @@ PYPI_PLATFORM:=--platform win_amd64
|
||||
BLENDER_PLATFORM:=windows-x64
|
||||
endif
|
||||
|
||||
endif # def PLATFORM
|
||||
|
||||
# Current build commit hash.
|
||||
OLD:=e8eb5e4
|
||||
.PHONY: bump
|
||||
@@ -99,7 +116,10 @@ endif
|
||||
.PHONY: dist
|
||||
dist:
|
||||
ifndef PLATFORM
|
||||
$(error PLATFORM is not set)
|
||||
$(error PLATFORM is not set. Example values: $(SUPPORTED_PLATFORMS).)
|
||||
endif
|
||||
ifndef PYVERSION
|
||||
$(error PYVERSION is not set. Example values: $(SUPPORTED_PYVERSIONS). )
|
||||
endif
|
||||
rm -rf build
|
||||
mkdir -p build
|
||||
@@ -125,9 +145,6 @@ endif
|
||||
cd ../ifc5d && make dist && mv dist/*.whl ../bonsai/build/wheels/
|
||||
cd ../ifccityjson && make dist && mv dist/*.whl ../bonsai/build/wheels/
|
||||
cd build && . env/$(VENV_ACTIVATE) && $(PIP) download GitPython --dest=./wheels
|
||||
# Provides audio playback for costing
|
||||
# This is a REALLY IMPORTANT feature
|
||||
cd build && . env/$(VENV_ACTIVATE) && $(PIP) wheel git+https://github.com/zdhoward/aud --wheel-dir=./wheels
|
||||
# IfcOpenShell dependency - support for new typing features
|
||||
cd build && . env/$(VENV_ACTIVATE) && $(PIP) download typing_extensions --dest=./wheels
|
||||
# Required by IfcCSV
|
||||
@@ -189,7 +206,7 @@ endif
|
||||
cd build && . env/$(VENV_ACTIVATE) && $(PIP) download tzfpy $(PYPI_PLATFORM) --python-version $(PYPI_VERSION) --implementation $(PYPI_IMP) --only-binary=:all: --dest=./wheels
|
||||
# pyradiance is using different platform versions than defaults in our makefile.
|
||||
ifeq ($(PLATFORM), linux)
|
||||
cd build && . env/$(VENV_ACTIVATE) && $(PIP) download pyradiance --platform manylinux_2_35_x86_64 --python-version $(PYPI_VERSION) --implementation $(PYPI_IMP) --only-binary=:all: --dest=./wheels
|
||||
cd build && . env/$(VENV_ACTIVATE) && $(PIP) download pyradiance --platform manylinux_2_28_x86_64 --python-version $(PYPI_VERSION) --implementation $(PYPI_IMP) --only-binary=:all: --dest=./wheels
|
||||
else ifeq ($(PLATFORM), macos)
|
||||
cd build && . env/$(VENV_ACTIVATE) && $(PIP) download pyradiance --platform macosx_10_13_x86_64 --python-version $(PYPI_VERSION) --implementation $(PYPI_IMP) --only-binary=:all: --dest=./wheels
|
||||
else
|
||||
@@ -267,6 +284,11 @@ else
|
||||
$(SED) 's/version = "0.0.0"/version = "$(VERSION)-alpha$(VERSION_DATE)"/' build/pyproject.toml
|
||||
endif
|
||||
|
||||
# Blender 5.1+ requires Python 3.13.
|
||||
ifeq ($(PYVERSION), py313)
|
||||
$(SED) 's/blender_version_min = "4\.2\.0"/blender_version_min = "5.1.0"/' build/bonsai/blender_manifest.toml
|
||||
endif
|
||||
|
||||
$(SED) "s/os-arch/$(BLENDER_PLATFORM)/" build/bonsai/blender_manifest.toml
|
||||
|
||||
# Provides bonsai Add-on functionality
|
||||
@@ -288,12 +310,6 @@ endif
|
||||
fi
|
||||
mv build/wheels/*.whl build/bonsai/wheels/
|
||||
|
||||
# Temporary workaround for Blender not handling non-3.11 wheels #5743.
|
||||
# Use '-n' as on macos x86-64 doesn't have a binary wheel and it autoincludes 'cp311'.
|
||||
prev_whl_name=$$(find build/bonsai/wheels/tzfpy-*.whl); \
|
||||
whl_name=$$(echo $$prev_whl_name | sed "s/-cp39-/-cp$(PYNUMBER)-/"); \
|
||||
mv --update=none "$$prev_whl_name" "$$whl_name";
|
||||
|
||||
ifneq ($(PLATFORM), linux)
|
||||
# Safeguard: in case one of `pip download` will break,
|
||||
# it will produce a linux wheel for non-linux build (our github action machine is using linux).
|
||||
@@ -355,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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -85,7 +85,7 @@ class MaterialCreator:
|
||||
if element.is_a("IfcTypeProduct"):
|
||||
self.parse_element_type_material_styles(element)
|
||||
self.parsed_meshes.add(self.mesh.name)
|
||||
if not self.ifc_import_settings.load_indexed_maps:
|
||||
if self.ifc_import_settings.load_indexed_maps:
|
||||
self.load_texture_maps(shape_has_openings)
|
||||
self.assign_material_slots_to_faces()
|
||||
tool.Geometry.record_object_materials(obj)
|
||||
@@ -117,7 +117,6 @@ class MaterialCreator:
|
||||
for texture in texture_style.Textures or []:
|
||||
if coords := getattr(texture, "IsMappedBy", None):
|
||||
coords = coords[0]
|
||||
# IfcTextureCoordinateGenerator handled in the style shader graph
|
||||
if coords.is_a("IfcIndexedTextureMap"):
|
||||
return coords
|
||||
# TODO: support IfcTextureMap
|
||||
@@ -135,6 +134,10 @@ class MaterialCreator:
|
||||
if shape_has_openings and coords.is_a("IfcIndexedTextureMap"):
|
||||
continue
|
||||
tool.Loader.load_indexed_map(coords, self.mesh)
|
||||
elif tool.Style.get_texture_style(material):
|
||||
# No explicit coordinate mapping (e.g. IFC2X3 has no IsMappedBy,
|
||||
# and IFC4 COORD uses generated UVs). Bake XY→UV as fallback.
|
||||
tool.Loader.load_generated_uv_map(self.mesh)
|
||||
|
||||
def assign_material_slots_to_faces(self) -> None:
|
||||
if not self.mesh["ios_materials"]:
|
||||
@@ -892,48 +895,6 @@ class IfcImporter:
|
||||
obj, tool.Loader.apply_blender_offset_to_matrix_world(obj, self.get_element_matrix(element))
|
||||
)
|
||||
|
||||
if element.is_a("IfcAnnotation") and getattr(element, "ObjectType", None) == "IMAGE":
|
||||
image = None
|
||||
if obj.data and obj.data.materials and obj.data.materials[0]:
|
||||
material = obj.data.materials[0]
|
||||
if material.use_nodes and material.node_tree:
|
||||
for node in material.node_tree.nodes:
|
||||
if node.type == "TEX_IMAGE" and node.image:
|
||||
image = node.image
|
||||
break
|
||||
if image:
|
||||
import bmesh
|
||||
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(obj.data)
|
||||
if not bm.loops.layers.uv:
|
||||
uv_layer = bm.loops.layers.uv.new()
|
||||
else:
|
||||
uv_layer = bm.loops.layers.uv.active
|
||||
|
||||
if bm.verts:
|
||||
min_x = min(v.co.x for v in bm.verts)
|
||||
max_x = max(v.co.x for v in bm.verts)
|
||||
min_y = min(v.co.y for v in bm.verts)
|
||||
max_y = max(v.co.y for v in bm.verts)
|
||||
|
||||
width = max_x - min_x
|
||||
height = max_y - min_y
|
||||
|
||||
for face in bm.faces:
|
||||
for loop in face.loops:
|
||||
vert = loop.vert
|
||||
u = (vert.co.x - min_x) / width if width > 0 else 0.5
|
||||
v = (vert.co.y - min_y) / height if height > 0 else 0.5
|
||||
|
||||
u = max(0.0, min(1.0, u))
|
||||
v = max(0.0, min(1.0, v))
|
||||
|
||||
loop[uv_layer].uv = (u, v)
|
||||
|
||||
bm.to_mesh(obj.data)
|
||||
bm.free()
|
||||
obj.data.update()
|
||||
return obj
|
||||
|
||||
def load_existing_meshes(self) -> None:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -45,6 +45,7 @@ classes = (
|
||||
operator.CleanWireframes,
|
||||
operator.ContractSheet,
|
||||
operator.ConvertSVGToDXF,
|
||||
operator.CopyTextToSelection,
|
||||
operator.CreateDrawing,
|
||||
operator.CreateSheets,
|
||||
operator.DisableAddAnnotationType,
|
||||
@@ -115,7 +116,6 @@ classes = (
|
||||
prop.BIMCameraProperties,
|
||||
prop.ElementValueRow,
|
||||
prop.LiteralProps,
|
||||
prop.LiteralApplySettings,
|
||||
prop.BIMTextProperties,
|
||||
prop.BIMAssignedProductProperties,
|
||||
prop.BIMAnnotationProperties,
|
||||
|
||||
@@ -355,8 +355,6 @@ class DecoratorData:
|
||||
font_size = FONT_SIZES[font_size_type]
|
||||
symbol = tool.Drawing.get_annotation_symbol(element)
|
||||
newline_at = pset_data.get("Newline_At", 0)
|
||||
reverse_list = pset_data.get("Reverse_List", False)
|
||||
list_separator = pset_data.get("List_Separator") or ", "
|
||||
|
||||
# other attributes
|
||||
literals = tool.Drawing.get_text_literal(obj, return_list=True)
|
||||
@@ -367,15 +365,10 @@ class DecoratorData:
|
||||
|
||||
for literal in literals:
|
||||
literal_value = literal.Literal
|
||||
try:
|
||||
eval_value = cls.evaluate_formatting_expressions(literal_value, product)
|
||||
current_value = tool.Drawing.replace_text_literal_variables(eval_value, product)
|
||||
except Exception:
|
||||
current_value = literal_value
|
||||
literal_data = {
|
||||
"Literal": literal_value,
|
||||
"BoxAlignment": literal.BoxAlignment,
|
||||
"CurrentValue": current_value,
|
||||
"CurrentValue": tool.Drawing.replace_text_literal_variables(literal_value, product),
|
||||
}
|
||||
literals_data.append(literal_data)
|
||||
|
||||
@@ -384,8 +377,6 @@ class DecoratorData:
|
||||
"FontSize": font_size,
|
||||
"Symbol": symbol,
|
||||
"Newline_At": newline_at,
|
||||
"Reverse_List": reverse_list,
|
||||
"List_Separator": list_separator,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -403,21 +394,6 @@ class DecoratorData:
|
||||
|
||||
return element
|
||||
|
||||
@classmethod
|
||||
def evaluate_formatting_expressions(cls, text: str, element=None) -> str:
|
||||
"""Evaluate formatting expressions wrapped in backticks using ifcopenshell.util.selector.format, always passing element context"""
|
||||
import re
|
||||
|
||||
def evaluate_expression(match):
|
||||
try:
|
||||
expression = match.group(1)
|
||||
result = ifcopenshell.util.selector.format(expression, element)
|
||||
return str(result)
|
||||
except Exception as e:
|
||||
return match.group(0)
|
||||
|
||||
return re.sub(r"``([^`]+)``", evaluate_expression, text)
|
||||
|
||||
@classmethod
|
||||
def get_element_value_by_key(cls, element: ifcopenshell.entity_instance, key: str):
|
||||
"""Get element value by its key using IfcOpenShell selector syntax"""
|
||||
|
||||
@@ -997,7 +997,7 @@ class FallDecorator(BaseDecorator):
|
||||
# generate label text
|
||||
# same function as in svgwriter.py
|
||||
def get_label_text():
|
||||
B, A = [v.co.xyz for v in spline_points[:2]]
|
||||
B, A = [obj.matrix_world @ v.co.xyz for v in spline_points[:2]]
|
||||
rise = abs(A.z - B.z)
|
||||
O = A.copy()
|
||||
O.z = B.z
|
||||
@@ -1009,13 +1009,14 @@ class FallDecorator(BaseDecorator):
|
||||
angle = 90
|
||||
|
||||
# uses SLOPE_ANGLE as default
|
||||
object_type = DecoratorData.data["fall"].get(obj, {}).get("object_type", None)
|
||||
object_type = DecoratorData.data["fall"].get(obj.name, {}).get("object_type", None)
|
||||
if object_type in ("FALL", "SLOPE_ANGLE"):
|
||||
return f"{angle}°"
|
||||
elif object_type == "SLOPE_FRACTION":
|
||||
if angle == 90:
|
||||
return "-"
|
||||
return f"{self.format_value(context, rise)} / {self.format_value(context, run)}"
|
||||
segment_length = (B - A).length
|
||||
return f"{self.format_value(context, rise)} / {self.format_value(context, segment_length)}"
|
||||
elif object_type == "SLOPE_PERCENT":
|
||||
if angle == 90:
|
||||
return "-"
|
||||
@@ -1762,40 +1763,34 @@ class CutDecorator:
|
||||
shader.uniform_float("color", color)
|
||||
batch.draw(shader)
|
||||
|
||||
def cache_camera_matrix(self):
|
||||
def cache_camera_matrix(self) -> None:
|
||||
assert bpy.context.scene and bpy.context.scene.camera
|
||||
obj = bpy.context.scene.camera
|
||||
# Explicit `dtype` for Blender <5.0 compatibility.
|
||||
DecoratorData.camera_location_checksum = repr(
|
||||
np.array(obj.matrix_world.translation, dtype=np.float32).tobytes()
|
||||
tool.Blender.np_array_legacy(obj.matrix_world.translation).tobytes()
|
||||
)
|
||||
DecoratorData.camera_rotation_checksum = repr(np.array(obj.matrix_world.to_3x3(), dtype=np.float32).tobytes())
|
||||
DecoratorData.camera_rotation_checksum = repr(tool.Blender.np_array_legacy(obj.matrix_world.to_3x3()).tobytes())
|
||||
|
||||
def is_camera_moved(self):
|
||||
def is_camera_moved(self) -> bool:
|
||||
if not DecoratorData.camera_location_checksum:
|
||||
self.cache_camera_matrix()
|
||||
return True # Let's be conservative
|
||||
|
||||
assert bpy.context.scene and bpy.context.scene.camera
|
||||
obj = bpy.context.scene.camera
|
||||
|
||||
# Handle both old float64 and new float32 checksums for version compatibility
|
||||
loc_checksum_bytes = eval(DecoratorData.camera_location_checksum)
|
||||
if len(loc_checksum_bytes) == 24: # Old format: 3 * 8 bytes (float64)
|
||||
loc_check = np.frombuffer(loc_checksum_bytes, dtype=np.float64).astype(np.float32)
|
||||
else: # New format: 3 * 4 bytes (float32)
|
||||
loc_check = np.frombuffer(loc_checksum_bytes, dtype=np.float32)
|
||||
|
||||
loc_real = np.array(obj.matrix_world.translation, dtype=np.float32).flatten()
|
||||
loc_checksum_bytes: bytes = eval(DecoratorData.camera_location_checksum)
|
||||
loc_check = tool.Blender.np_frombuffer_legacy(loc_checksum_bytes, 3)
|
||||
loc_real = tool.Blender.np_array_legacy(obj.matrix_world.translation)
|
||||
if not np.allclose(loc_check, loc_real, atol=1e-4): # 0.1 mm
|
||||
self.cache_camera_matrix()
|
||||
return True
|
||||
|
||||
# Handle both old float64 and new float32 checksums for version compatibility
|
||||
rot_checksum_bytes = eval(DecoratorData.camera_rotation_checksum)
|
||||
if len(rot_checksum_bytes) == 72: # Old format: 9 * 8 bytes (float64)
|
||||
rot_check = np.frombuffer(rot_checksum_bytes, dtype=np.float64).astype(np.float32).reshape(3, 3)
|
||||
else: # New format: 9 * 4 bytes (float32)
|
||||
rot_check = np.frombuffer(rot_checksum_bytes, dtype=np.float32).reshape(3, 3)
|
||||
|
||||
rot_real = np.array(obj.matrix_world.to_3x3(), dtype=np.float32)
|
||||
rot_checksum_bytes: bytes = eval(DecoratorData.camera_rotation_checksum)
|
||||
rot_check = tool.Blender.np_frombuffer_legacy(rot_checksum_bytes, 9)
|
||||
rot_real = tool.Blender.np_array_legacy(obj.matrix_world.to_3x3())
|
||||
rot_dot = np.dot(rot_check, rot_real.T)
|
||||
angle_rad = np.arccos(np.clip((np.trace(rot_dot) - 1) / 2, -1, 1))
|
||||
if angle_rad > 0.0017453292519943296: # 0.1 degrees
|
||||
|
||||
@@ -40,6 +40,7 @@ from typing import (
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
import logging
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api
|
||||
import ifcopenshell.api.document
|
||||
@@ -50,6 +51,7 @@ import ifcopenshell.ifcopenshell_wrapper
|
||||
import ifcopenshell.util.element
|
||||
import ifcopenshell.util.representation
|
||||
import ifcopenshell.util.selector
|
||||
import ifcopenshell.util.shape_builder
|
||||
import ifcopenshell.util.unit
|
||||
import numpy as np
|
||||
import shapely
|
||||
@@ -59,6 +61,7 @@ from bpy_extras.io_utils import ImportHelper
|
||||
from lxml import etree
|
||||
from mathutils import Color, Matrix, Vector
|
||||
|
||||
import bonsai.bim.import_ifc
|
||||
import bonsai.bim.export_ifc
|
||||
import bonsai.bim.handler
|
||||
import bonsai.bim.helper
|
||||
@@ -138,7 +141,7 @@ class AddAnnotationType(bpy.types.Operator, tool.Ifc.Operator):
|
||||
element.ApplicableOccurrence = f"IfcAnnotation/{object_type}"
|
||||
|
||||
if props.create_representation_for_type and object_type == "IMAGE":
|
||||
bpy.ops.bim.add_reference_image("INVOKE_DEFAULT", use_existing_object_by_name=obj.name)
|
||||
bpy.ops.bim.add_reference_image("INVOKE_DEFAULT", existing_object_by_name=obj.name)
|
||||
|
||||
|
||||
class EnableAddAnnotationType(bpy.types.Operator):
|
||||
@@ -928,7 +931,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 +1377,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
|
||||
@@ -1759,7 +1762,7 @@ class AddAnnotation(bpy.types.Operator, tool.Ifc.Operator):
|
||||
enable_editing=True,
|
||||
)
|
||||
if props.object_type == "IMAGE":
|
||||
bpy.ops.bim.add_reference_image("INVOKE_DEFAULT", use_existing_object_by_name=obj.name)
|
||||
bpy.ops.bim.add_reference_image("INVOKE_DEFAULT", existing_object_by_name=obj.name)
|
||||
|
||||
|
||||
class AddSheet(bpy.types.Operator, tool.Ifc.Operator):
|
||||
@@ -2896,12 +2899,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 +3097,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 +3165,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
|
||||
@@ -3181,169 +3182,34 @@ class EditText(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.edit_text"
|
||||
bl_label = "Edit Text"
|
||||
bl_description = "Save changes to the text annotation and\ndisable the text editing options"
|
||||
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def _execute(self, context):
|
||||
obj = context.active_object
|
||||
props = tool.Drawing.get_text_props(obj)
|
||||
|
||||
captured_apply_settings = {
|
||||
"apply_font_size_to_all": props.apply_font_size_to_all,
|
||||
"apply_newline_to_all": props.apply_newline_to_all,
|
||||
"font_size": props.font_size,
|
||||
"newline_at": props.newline_at,
|
||||
"literals": [],
|
||||
}
|
||||
|
||||
for i, literal in enumerate(props.literals):
|
||||
literal_data = {
|
||||
"attributes": [
|
||||
(attr.string_value, attr.enum_value if attr.data_type == "enum" else attr.string_value)
|
||||
for attr in literal.attributes
|
||||
],
|
||||
"box_alignment": literal.box_alignment[:] if hasattr(literal, "box_alignment") else None,
|
||||
"element_value_rows": [
|
||||
{
|
||||
"category": row.category,
|
||||
"element_key": row.element_key,
|
||||
"formatted_value": row.formatted_value,
|
||||
"separator": row.separator,
|
||||
}
|
||||
for row in literal.element_value_rows
|
||||
],
|
||||
"product_used": literal.product_used.name if literal.product_used else None,
|
||||
}
|
||||
|
||||
if i < len(props.literal_apply_settings):
|
||||
apply_settings = props.literal_apply_settings[i]
|
||||
literal_data["apply_text_to_all"] = apply_settings.apply_text_to_all
|
||||
literal_data["apply_path_to_all"] = apply_settings.apply_path_to_all
|
||||
literal_data["apply_box_alignment_to_all"] = apply_settings.apply_box_alignment_to_all
|
||||
else:
|
||||
literal_data["apply_text_to_all"] = False
|
||||
literal_data["apply_path_to_all"] = False
|
||||
literal_data["apply_box_alignment_to_all"] = False
|
||||
|
||||
captured_apply_settings["literals"].append(literal_data)
|
||||
|
||||
obj["_bonsai_element_value_rows_backup"] = json.dumps(captured_apply_settings["literals"])
|
||||
|
||||
core.edit_text(tool.Drawing, obj=obj)
|
||||
|
||||
self.apply_to_selected_objects_with_captured_data(context, obj, captured_apply_settings)
|
||||
|
||||
core.edit_text(tool.Drawing, obj=tool.Blender.get_active_object())
|
||||
tool.Blender.update_viewport()
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
def apply_to_selected_objects(self, context, active_obj, active_props):
|
||||
"""Apply changes to other selected text objects based on toggle settings"""
|
||||
selected_objects = [obj for obj in context.selected_objects if obj != active_obj]
|
||||
class CopyTextToSelection(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.copy_text_to_selection"
|
||||
bl_label = "Copy Text To Selection"
|
||||
bl_description = "Copy text formatting or literals to selected objects"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
attribute: bpy.props.StringProperty()
|
||||
|
||||
for obj in selected_objects:
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element or not tool.Drawing.is_annotation_object_type(element, ["TEXT", "TEXT_LEADER"]):
|
||||
continue
|
||||
|
||||
obj_props = tool.Drawing.get_text_props(obj)
|
||||
needs_update = False
|
||||
|
||||
if active_props.apply_font_size_to_all:
|
||||
obj_props.font_size = active_props.font_size
|
||||
needs_update = True
|
||||
|
||||
if active_props.apply_newline_to_all:
|
||||
obj_props.newline_at = active_props.newline_at
|
||||
needs_update = True
|
||||
|
||||
for i, active_literal in enumerate(active_props.literals):
|
||||
if i >= len(obj_props.literals):
|
||||
continue
|
||||
|
||||
obj_props.ensure_literal_apply_settings(len(obj_props.literals))
|
||||
obj_literal = obj_props.literals[i]
|
||||
|
||||
if i < len(active_props.literal_apply_settings):
|
||||
active_settings = active_props.literal_apply_settings[i]
|
||||
|
||||
if active_settings.apply_text_to_all:
|
||||
if len(active_literal.attributes) > 0 and len(obj_literal.attributes) > 0:
|
||||
obj_literal.attributes[0].string_value = active_literal.attributes[0].string_value
|
||||
needs_update = True
|
||||
|
||||
if active_settings.apply_path_to_all:
|
||||
if len(active_literal.attributes) > 1 and len(obj_literal.attributes) > 1:
|
||||
if (
|
||||
active_literal.attributes[1].data_type == "enum"
|
||||
and obj_literal.attributes[1].data_type == "enum"
|
||||
):
|
||||
obj_literal.attributes[1].enum_value = active_literal.attributes[1].enum_value
|
||||
else:
|
||||
obj_literal.attributes[1].string_value = active_literal.attributes[1].string_value
|
||||
needs_update = True
|
||||
|
||||
if active_settings.apply_box_alignment_to_all:
|
||||
obj_literal.box_alignment = active_literal.box_alignment[:]
|
||||
needs_update = True
|
||||
|
||||
if needs_update:
|
||||
core.edit_text(tool.Drawing, obj=obj)
|
||||
|
||||
def apply_to_selected_objects_with_captured_data(self, context, active_obj, captured_data):
|
||||
"""Apply changes to other selected text objects using captured apply settings"""
|
||||
selected_objects = [obj for obj in context.selected_objects if obj != active_obj]
|
||||
|
||||
for obj in selected_objects:
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element:
|
||||
continue
|
||||
if not tool.Drawing.is_annotation_object_type(element, ["TEXT", "TEXT_LEADER"]):
|
||||
continue
|
||||
|
||||
obj_props = tool.Drawing.get_text_props(obj)
|
||||
|
||||
if len(obj_props.literals) == 0:
|
||||
core.enable_editing_text(tool.Drawing, obj=obj)
|
||||
obj_props.ensure_literal_apply_settings(len(obj_props.literals))
|
||||
|
||||
needs_update = False
|
||||
|
||||
if captured_data["apply_font_size_to_all"]:
|
||||
obj_props.font_size = captured_data["font_size"]
|
||||
needs_update = True
|
||||
|
||||
if captured_data["apply_newline_to_all"]:
|
||||
obj_props.newline_at = captured_data["newline_at"]
|
||||
needs_update = True
|
||||
|
||||
for i, captured_literal in enumerate(captured_data["literals"]):
|
||||
if i >= len(obj_props.literals):
|
||||
continue
|
||||
|
||||
obj_literal = obj_props.literals[i]
|
||||
|
||||
if captured_literal["apply_text_to_all"]:
|
||||
if len(captured_literal["attributes"]) > 0 and len(obj_literal.attributes) > 0:
|
||||
new_value = captured_literal["attributes"][0][0] # [0] = string_value
|
||||
obj_literal.attributes[0].string_value = new_value
|
||||
needs_update = True
|
||||
|
||||
if captured_literal["apply_path_to_all"]:
|
||||
if len(captured_literal["attributes"]) > 1 and len(obj_literal.attributes) > 1:
|
||||
new_value = captured_literal["attributes"][1][1] # [1] = enum_value or string_value
|
||||
if obj_literal.attributes[1].data_type == "enum":
|
||||
obj_literal.attributes[1].enum_value = new_value
|
||||
else:
|
||||
obj_literal.attributes[1].string_value = new_value
|
||||
needs_update = True
|
||||
|
||||
if captured_literal["apply_box_alignment_to_all"] and captured_literal["box_alignment"]:
|
||||
obj_literal.box_alignment = captured_literal["box_alignment"]
|
||||
needs_update = True
|
||||
|
||||
if needs_update:
|
||||
core.edit_text(tool.Drawing, obj=obj)
|
||||
def _execute(self, context):
|
||||
apply_objs = [
|
||||
obj
|
||||
for obj in tool.Blender.get_selected_objects()
|
||||
if (element := tool.Ifc.get_entity(obj))
|
||||
and tool.Drawing.is_annotation_object_type(element, ["TEXT", "TEXT_LEADER"])
|
||||
]
|
||||
core.copy_text_to_selection(
|
||||
tool.Drawing,
|
||||
attribute=self.attribute,
|
||||
attribute_obj=tool.Blender.get_active_object(),
|
||||
apply_objs=apply_objs,
|
||||
)
|
||||
tool.Blender.update_viewport()
|
||||
|
||||
|
||||
class EnableEditingText(bpy.types.Operator, tool.Ifc.Operator):
|
||||
@@ -3358,8 +3224,6 @@ class EnableEditingText(bpy.types.Operator, tool.Ifc.Operator):
|
||||
props = tool.Drawing.get_text_props(obj)
|
||||
core.enable_editing_text(tool.Drawing, obj=obj)
|
||||
|
||||
props.ensure_literal_apply_settings(len(props.literals))
|
||||
|
||||
text_element = tool.Ifc.get_entity(obj)
|
||||
assigned_product_entity = tool.Drawing.get_assigned_product(text_element) if text_element else None
|
||||
assigned_product_obj = tool.Ifc.get_object(assigned_product_entity) if assigned_product_entity else None
|
||||
@@ -3448,9 +3312,6 @@ class AddTextLiteral(bpy.types.Operator):
|
||||
box_alignment_mask = [False] * 9
|
||||
box_alignment_mask[6] = True # bottom_left box_alignment
|
||||
literal_props.box_alignment = box_alignment_mask
|
||||
|
||||
props.ensure_literal_apply_settings(len(props.literals))
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
@@ -3469,9 +3330,6 @@ class RemoveTextLiteral(bpy.types.Operator):
|
||||
props = tool.Drawing.get_text_props(obj)
|
||||
props.literals.remove(self.literal_prop_id)
|
||||
tool.Blender.update_viewport()
|
||||
|
||||
props.ensure_literal_apply_settings(len(props.literals))
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
@@ -3947,83 +3805,51 @@ class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator, ImportHelper):
|
||||
use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=True)
|
||||
filter_image: bpy.props.BoolProperty(default=True, options={"HIDDEN", "SKIP_SAVE"})
|
||||
filter_folder: bpy.props.BoolProperty(default=True, options={"HIDDEN", "SKIP_SAVE"})
|
||||
|
||||
override_existing_image: bpy.props.BoolProperty(
|
||||
name="Override Existing Image",
|
||||
default=True,
|
||||
description=(
|
||||
"Override image if it was previously loaded to Blender. If disabled, will always create a new image"
|
||||
),
|
||||
)
|
||||
use_existing_object_by_name: bpy.props.StringProperty(
|
||||
name="Use Existing Object By Name",
|
||||
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",
|
||||
description="Width of the reference image",
|
||||
default=1.0,
|
||||
min=0.001,
|
||||
soft_min=0.01,
|
||||
precision=3,
|
||||
unit="LENGTH",
|
||||
)
|
||||
y_length: bpy.props.FloatProperty(
|
||||
name="Y Length",
|
||||
description="Height of the reference image in project units",
|
||||
description="Height of the reference image",
|
||||
default=1.0,
|
||||
min=0.001,
|
||||
soft_min=0.01,
|
||||
precision=3,
|
||||
unit="LENGTH",
|
||||
)
|
||||
show_texture_solid_mode: bpy.props.BoolProperty(
|
||||
name="Show Texture in Solid mode (slow)",
|
||||
description="Show Texture in Solid mode (slow)",
|
||||
default=False,
|
||||
)
|
||||
|
||||
show_dimensions_dialog: bpy.props.BoolProperty(default=False, options={"HIDDEN", "SKIP_SAVE"})
|
||||
|
||||
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")
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
if not tool.Ifc.get():
|
||||
cls.poll_message_set("No IFC project is loaded.")
|
||||
return False
|
||||
return True
|
||||
|
||||
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)
|
||||
self._last_filepath = ""
|
||||
return super().invoke(context, event)
|
||||
|
||||
def check(self, context):
|
||||
if not hasattr(self, "_last_filepath"):
|
||||
self._last_filepath = ""
|
||||
|
||||
if self.filepath and self.filepath != self._last_filepath:
|
||||
self._last_filepath = self.filepath
|
||||
|
||||
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)
|
||||
|
||||
if abs_path.exists() and abs_path.is_file():
|
||||
image = load_image(abs_path.name, str(abs_path.parent), check_existing=False)
|
||||
image_width_px = image.size[0]
|
||||
image_height_px = image.size[1]
|
||||
aspect_ratio = image_width_px / image_height_px
|
||||
@@ -4036,17 +3862,24 @@ class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator, ImportHelper):
|
||||
self.y_length = 1.0
|
||||
|
||||
bpy.data.images.remove(image)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, f"Failed to load image: {str(e)}")
|
||||
return {"CANCELLED"}
|
||||
return False
|
||||
|
||||
self.show_dimensions_dialog = True
|
||||
return context.window_manager.invoke_props_dialog(self)
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
if Path(tool.Ifc.get_path()).is_file():
|
||||
layout.prop(self, "use_relative_path")
|
||||
else:
|
||||
self.use_relative_path = False
|
||||
layout.prop(self, "show_texture_solid_mode")
|
||||
layout.prop(self, "x_length")
|
||||
layout.prop(self, "y_length")
|
||||
|
||||
return self._execute(context)
|
||||
|
||||
def _execute(self, context):
|
||||
project_props = tool.Project.get_project_props()
|
||||
project_props.load_indexed_maps = self.show_texture_solid_mode
|
||||
space = tool.Blender.get_view3d_space()
|
||||
if space.shading.color_type != "TEXTURE":
|
||||
space.shading.color_type = "TEXTURE"
|
||||
@@ -4059,121 +3892,66 @@ class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator, ImportHelper):
|
||||
image_filepath = Path(tool.Ifc.get_uri(self.filepath, use_relative_path=self.use_relative_path))
|
||||
ifc_file = tool.Ifc.get()
|
||||
|
||||
if self.override_existing_image:
|
||||
params = {"check_existing": True, "force_reload": True}
|
||||
else:
|
||||
params = {"check_existing": False}
|
||||
params = {"check_existing": False}
|
||||
image = load_image(abs_path.name, str(abs_path.parent), **params)
|
||||
|
||||
def bm_add_image_plane(mesh):
|
||||
bm = tool.Blender.get_bmesh_for_mesh(mesh, clean=True)
|
||||
mesh = bpy.data.meshes.new(image_filepath.stem)
|
||||
obj = bpy.data.objects.new(image_filepath.stem, mesh)
|
||||
element = tool.Drawing.run_root_assign_class(
|
||||
obj=obj, ifc_class="IfcAnnotation", predefined_type="IMAGE", should_add_representation=False
|
||||
)
|
||||
|
||||
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))
|
||||
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)
|
||||
builder = ifcopenshell.util.shape_builder.ShapeBuilder(ifc_file)
|
||||
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file)
|
||||
hx = self.x_length * 0.5 / unit_scale
|
||||
hy = self.y_length * 0.5 / unit_scale
|
||||
verts = [(-hx, -hy, 0.0), ( hx, -hy, 0.0), ( hx, hy, 0.0), (-hx, hy, 0.0)]
|
||||
item = builder.mesh(verts, [[0, 1, 2, 3]])
|
||||
|
||||
if not bm.loops.layers.uv:
|
||||
uv_layer = bm.loops.layers.uv.new()
|
||||
else:
|
||||
uv_layer = bm.loops.layers.uv.active
|
||||
ifc_context = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW")
|
||||
representation = builder.get_representation(ifc_context, [item])
|
||||
ifcopenshell.api.geometry.assign_representation(ifc_file, element, representation)
|
||||
|
||||
min_x = min(v.co.x for v in bm.verts)
|
||||
max_x = max(v.co.x for v in bm.verts)
|
||||
min_y = min(v.co.y for v in bm.verts)
|
||||
max_y = max(v.co.y for v in bm.verts)
|
||||
|
||||
width = max_x - min_x
|
||||
height = max_y - min_y
|
||||
|
||||
for face in bm.faces:
|
||||
for loop in face.loops:
|
||||
vert = loop.vert
|
||||
u = (vert.co.x - min_x) / width if width > 0 else 0.5
|
||||
v = (vert.co.y - min_y) / height if height > 0 else 0.5
|
||||
|
||||
u = max(0.0, min(1.0, u))
|
||||
v = max(0.0, min(1.0, v))
|
||||
loop[uv_layer].uv = (u, v)
|
||||
|
||||
tool.Blender.apply_bmesh(mesh, bm)
|
||||
|
||||
if self.use_existing_object_by_name:
|
||||
obj = bpy.data.objects[self.use_existing_object_by_name]
|
||||
bm_add_image_plane(obj.data)
|
||||
bpy.ops.bim.update_representation(obj=obj.name, ifc_representation_class="")
|
||||
else:
|
||||
temp_mesh = bpy.data.meshes.new("temp_mesh")
|
||||
bm_add_image_plane(temp_mesh)
|
||||
obj = bpy.data.objects.new(image_filepath.stem, temp_mesh)
|
||||
tool.Drawing.run_root_assign_class(
|
||||
obj=obj,
|
||||
ifc_class="IfcAnnotation",
|
||||
predefined_type="IMAGE",
|
||||
should_add_representation=True,
|
||||
context=ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW"),
|
||||
ifc_representation_class=None,
|
||||
)
|
||||
tool.Blender.remove_data_block(temp_mesh)
|
||||
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if element and isinstance(obj.data, bpy.types.Mesh):
|
||||
representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
|
||||
if representation and representation.Items:
|
||||
item_id = representation.Items[0].id()
|
||||
num_faces = len(obj.data.polygons)
|
||||
obj.data["ios_item_ids"] = [item_id] * num_faces
|
||||
tool.Blender.Attribute.fill_attribute(obj.data, "ios_item_ids", "FACE", "INT", [item_id] * num_faces)
|
||||
|
||||
for item in representation.Items:
|
||||
if item.is_a("IfcPolygonalFaceSet") and item.Coordinates:
|
||||
new_coords = []
|
||||
for vertex in obj.data.vertices:
|
||||
co = obj.matrix_world @ vertex.co
|
||||
new_coords.append([co.x, co.y, co.z])
|
||||
item.Coordinates.CoordList = new_coords
|
||||
|
||||
tool.Blender.set_active_object(obj)
|
||||
|
||||
material = bpy.data.materials.new(name=image_filepath.stem)
|
||||
obj.data.materials.append(None) # new slot
|
||||
obj.material_slots[0].material = material
|
||||
bpy.ops.bim.add_style()
|
||||
|
||||
style = tool.Ifc.get_entity(material)
|
||||
assert style
|
||||
tool.Style.assign_style_to_object(style, obj)
|
||||
style = ifcopenshell.api.style.add_style(tool.Ifc.get(), name=image_filepath.stem)
|
||||
ifcopenshell.api.style.assign_representation_styles(
|
||||
ifc_file, shape_representation=representation, styles=[style]
|
||||
)
|
||||
|
||||
# TODO: IfcSurfaceStyleRendering is unnecessary here, added it only because
|
||||
# we don't support IfcSurfaceStyleWithTextures without Rendering yet
|
||||
shading_attributes = {
|
||||
"SurfaceColour": {
|
||||
"Red": 1.0,
|
||||
"Green": 1.0,
|
||||
"Blue": 1.0,
|
||||
},
|
||||
"SurfaceColour": {"Red": 1.0, "Green": 1.0, "Blue": 1.0},
|
||||
"Transparency": 0.0,
|
||||
"ReflectanceMethod": "NOTDEFINED",
|
||||
}
|
||||
ifcopenshell.api.style.add_surface_style(
|
||||
tool.Ifc.get(),
|
||||
style=style,
|
||||
ifc_class="IfcSurfaceStyleRendering",
|
||||
attributes=shading_attributes,
|
||||
tool.Ifc.get(), style=style, ifc_class="IfcSurfaceStyleRendering", attributes=shading_attributes
|
||||
)
|
||||
texture = ifc_file.create_entity("IfcImageTexture", Mode="DIFFUSE", URLReference=image_filepath.as_posix())
|
||||
textures = [texture]
|
||||
ifc_file.create_entity("IfcTextureCoordinateGenerator", Maps=textures, Mode="COORD") # UV map
|
||||
ifcopenshell.api.style.add_surface_style(
|
||||
ifc_file,
|
||||
style=style,
|
||||
ifc_class="IfcSurfaceStyleWithTextures",
|
||||
attributes={"Textures": textures},
|
||||
)
|
||||
tool.Style.reload_material_from_ifc(material)
|
||||
tool.Geometry.record_object_materials(obj)
|
||||
|
||||
return {"FINISHED"}
|
||||
if tool.Ifc.get_schema() == "IFC2X3":
|
||||
texture = ifc_file.create_entity(
|
||||
"IfcImageTexture",
|
||||
RepeatS=True,
|
||||
RepeatT=True,
|
||||
TextureType="TEXTURE",
|
||||
UrlReference=image_filepath.as_posix(),
|
||||
)
|
||||
else:
|
||||
texture = ifc_file.create_entity("IfcImageTexture", Mode="DIFFUSE", URLReference=image_filepath.as_posix())
|
||||
ifc_file.create_entity("IfcTextureCoordinateGenerator", Maps=[texture], Mode="COORD")
|
||||
|
||||
textures = [texture]
|
||||
ifcopenshell.api.style.add_surface_style(
|
||||
ifc_file, style=style, ifc_class="IfcSurfaceStyleWithTextures", attributes={"Textures": textures}
|
||||
)
|
||||
|
||||
logger = logging.getLogger("ImportIFC")
|
||||
ifc_import_settings = bonsai.bim.import_ifc.IfcImportSettings.factory(bpy.context, None, logger)
|
||||
ifc_importer = bonsai.bim.import_ifc.IfcImporter(ifc_import_settings)
|
||||
ifc_importer.file = tool.Ifc.get()
|
||||
ifc_importer.create_style(style)
|
||||
|
||||
bonsai.core.geometry.switch_representation(tool.Ifc, tool.Geometry, obj=obj, representation=representation)
|
||||
|
||||
|
||||
class ConvertSVGToDXF(bpy.types.Operator):
|
||||
@@ -5056,7 +4834,7 @@ class ShowCategoryHelp(bpy.types.Operator):
|
||||
|
||||
class AddElementValueRow(bpy.types.Operator):
|
||||
bl_idname = "bim.add_element_value_row"
|
||||
bl_label = "Add Element"
|
||||
bl_label = "Add Element Value Row"
|
||||
bl_description = "Add a new element value row"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
|
||||
@@ -872,22 +872,20 @@ class LiteralProps(PropertyGroup):
|
||||
category_for_adding: str
|
||||
|
||||
|
||||
class LiteralApplySettings(PropertyGroup):
|
||||
literal_index: IntProperty(name="Literal Index")
|
||||
apply_text_to_all: BoolProperty(name="Apply Text to All", default=False)
|
||||
apply_path_to_all: BoolProperty(name="Apply Path to All", default=False)
|
||||
apply_box_alignment_to_all: BoolProperty(name="Apply Box Alignment to All", default=False)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
literal_index: int
|
||||
apply_text_to_all: bool
|
||||
apply_path_to_all: bool
|
||||
apply_box_alignment_to_all: bool
|
||||
|
||||
|
||||
class BIMTextProperties(PropertyGroup):
|
||||
is_editing: BoolProperty(name="Is Editing", default=False)
|
||||
literals: CollectionProperty(name="Literals", type=LiteralProps)
|
||||
newline_at: IntProperty(name="Newline At")
|
||||
symbol: EnumProperty( # pyright: ignore[reportRedeclaration]
|
||||
name="Symbol",
|
||||
description="Symbol from symbols.svg to use for this text.",
|
||||
items=[(s, s, "") for s in ["NO SYMBOL", "CUSTOM SYMBOL"] + tool.Drawing.DEFAULT_SYMBOLS],
|
||||
default="NO SYMBOL",
|
||||
)
|
||||
custom_symbol: StringProperty( # pyright: ignore[reportRedeclaration]
|
||||
name="Custom Symbol",
|
||||
description="Non-default symbol to use for this text.",
|
||||
)
|
||||
font_size: EnumProperty(
|
||||
items=[
|
||||
("1.8", "1.8 - Small", ""),
|
||||
@@ -899,52 +897,34 @@ class BIMTextProperties(PropertyGroup):
|
||||
default="2.5",
|
||||
name="Font Size",
|
||||
)
|
||||
newline_at: IntProperty(name="Newline At")
|
||||
reverse_list: BoolProperty(name="Reverse List", description="Reverses the order of any list.", default=False)
|
||||
list_separator: StringProperty( # pyright: ignore[reportRedeclaration]
|
||||
name="List Separator",
|
||||
description="Text used to separate lists. Uses a comma (, ) if empty.",
|
||||
align_horizontal: EnumProperty(
|
||||
items=[
|
||||
("left", "Left", "", "ALIGN_LEFT", 0),
|
||||
("middle", "Middle", "", "ALIGN_CENTER", 1),
|
||||
("right", "Right", "", "ALIGN_RIGHT", 2),
|
||||
],
|
||||
default="left",
|
||||
name="Horizontal Alignment",
|
||||
)
|
||||
symbol: EnumProperty( # pyright: ignore[reportRedeclaration]
|
||||
name="Symbol",
|
||||
description="Symbol from symbols.svg to use for this text.",
|
||||
items=[(s, s, "") for s in ["NO SYMBOL", "CUSTOM SYMBOL"] + tool.Drawing.DEFAULT_SYMBOLS],
|
||||
default="NO SYMBOL",
|
||||
align_vertical: EnumProperty(
|
||||
items=[
|
||||
("top", "Top", "", "ALIGN_TOP", 0),
|
||||
("middle", "Middle", "", "ALIGN_MIDDLE", 1),
|
||||
("bottom", "Bottom", "", "ALIGN_BOTTOM", 2),
|
||||
],
|
||||
default="middle",
|
||||
name="Vertical Alignment",
|
||||
)
|
||||
custom_symbol: StringProperty( # pyright: ignore[reportRedeclaration]
|
||||
name="Custom Symbol",
|
||||
description="Non-default symbol to use for this text.",
|
||||
)
|
||||
|
||||
apply_font_size_to_all: BoolProperty(
|
||||
name="Apply Font Size to All", description="Apply font size changes to all selected text objects", default=False
|
||||
)
|
||||
apply_newline_to_all: BoolProperty(
|
||||
name="Apply Newline to All", description="Apply newline changes to all selected text objects", default=False
|
||||
)
|
||||
|
||||
literal_apply_settings: CollectionProperty(name="Literal Apply Settings", type=LiteralApplySettings)
|
||||
|
||||
def ensure_literal_apply_settings(self, literal_count: int):
|
||||
"""Ensure we have apply settings for all literals"""
|
||||
while len(self.literal_apply_settings) > literal_count:
|
||||
self.literal_apply_settings.remove(len(self.literal_apply_settings) - 1)
|
||||
|
||||
while len(self.literal_apply_settings) < literal_count:
|
||||
setting = self.literal_apply_settings.add()
|
||||
setting.literal_index = len(self.literal_apply_settings) - 1
|
||||
|
||||
if TYPE_CHECKING:
|
||||
is_editing: bool
|
||||
literals: bpy.types.bpy_prop_collection_idprop[LiteralProps]
|
||||
font_size: str
|
||||
newline_at: int
|
||||
reverse_list: bool
|
||||
list_separator: str
|
||||
symbol: Union[str, Literal["NO SYMBOL", "CUSTOM SYMBOL"]]
|
||||
custom_symbol: str
|
||||
apply_font_size_to_all: bool
|
||||
apply_newline_to_all: bool
|
||||
font_size: str
|
||||
align_horizontal: str
|
||||
align_vertical: str
|
||||
|
||||
def get_symbol(self) -> Union[str, None]:
|
||||
if self.symbol == "NO SYMBOL":
|
||||
@@ -979,8 +959,6 @@ class BIMTextProperties(PropertyGroup):
|
||||
"FontSize": float(self.font_size),
|
||||
"Newline_At": int(self.newline_at),
|
||||
"Symbol": self.get_symbol(),
|
||||
"Reverse_List": self.reverse_list,
|
||||
"List_Separator": self.list_separator or ", ",
|
||||
}
|
||||
return text_data
|
||||
|
||||
|
||||
@@ -989,11 +989,6 @@ class SvgWriter:
|
||||
symbol = tool.Drawing.get_annotation_symbol(element)
|
||||
newline_at = tool.Drawing.get_newline_at(element)
|
||||
|
||||
# Get reverse_list and list_separator from EPset_Annotation
|
||||
pset_data = ifcopenshell.util.element.get_pset(element, "EPset_Annotation") or {}
|
||||
reverse_list = pset_data.get("Reverse_List", False)
|
||||
list_separator = pset_data.get("List_Separator") or ", "
|
||||
|
||||
template_text_fields = []
|
||||
if symbol:
|
||||
symbol_transform = self.get_symbol_transform(text_position_svg_str, angle, text_obj)
|
||||
@@ -1010,7 +1005,7 @@ class SvgWriter:
|
||||
# NOTE: zip makes sure that we iterate over the shortest list
|
||||
for field, text_literal in zip(template_text_fields, text_literals):
|
||||
field.text = tool.Drawing.replace_text_literal_variables(
|
||||
text_literal.Literal, product or element, reverse_list, list_separator
|
||||
text_literal.Literal, product or element
|
||||
)
|
||||
field.attrib["class"] = classes_str
|
||||
|
||||
@@ -1030,9 +1025,7 @@ class SvgWriter:
|
||||
line_number = 0
|
||||
|
||||
for text_literal in text_literals:
|
||||
text = tool.Drawing.replace_text_literal_variables(
|
||||
text_literal.Literal, product or element, reverse_list, list_separator
|
||||
)
|
||||
text = tool.Drawing.replace_text_literal_variables(text_literal.Literal, product or element)
|
||||
|
||||
text_segments = parse_markdown_it(text)
|
||||
|
||||
@@ -1453,7 +1446,7 @@ class SvgWriter:
|
||||
# generate label text
|
||||
# same function as in decoration.py
|
||||
def get_label_text():
|
||||
B, A = [v.co.xyz for v in points[:2]]
|
||||
B, A = [matrix_world @ v.co.xyz for v in points[:2]]
|
||||
rise = abs(A.z - B.z)
|
||||
O = A.copy()
|
||||
O.z = B.z
|
||||
|
||||
@@ -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.")
|
||||
@@ -644,58 +644,56 @@ class BIM_PT_text(Panel):
|
||||
row.operator("bim.add_text_literal", icon="ADD", text="Add Literal")
|
||||
else:
|
||||
row.operator("bim.edit_text", icon="CHECKMARK")
|
||||
row.operator("bim.add_text_literal", icon="ADD", text="")
|
||||
row.operator("bim.disable_editing_text", icon="CANCEL", text="")
|
||||
|
||||
row = self.layout.row(align=True)
|
||||
row.prop(props, "font_size")
|
||||
row.prop(props, "apply_font_size_to_all", text="", icon="COPYDOWN")
|
||||
row.operator("bim.copy_text_to_selection", text="", icon="COPYDOWN").attribute = "FONT_SIZE"
|
||||
row = self.layout.row()
|
||||
row.label(text="Alignment")
|
||||
row.prop(props, "align_horizontal", text="", expand=True)
|
||||
row.prop(props, "align_vertical", text="", expand=True)
|
||||
row.operator("bim.copy_text_to_selection", text="", icon="COPYDOWN").attribute = "ALIGNMENT"
|
||||
|
||||
row = self.layout.row(align=True)
|
||||
row.prop(props, "newline_at")
|
||||
row = self.layout.row(align=True)
|
||||
row.prop(props, "reverse_list")
|
||||
row = self.layout.row(align=True)
|
||||
row.prop(props, "list_separator")
|
||||
row.operator("bim.copy_text_to_selection", text="", icon="COPYDOWN").attribute = "WRAP_LENGTH"
|
||||
|
||||
row = self.layout.row(align=True)
|
||||
row.prop(props, "symbol")
|
||||
if props.symbol == "CUSTOM SYMBOL":
|
||||
row = self.layout.row(align=True)
|
||||
row.prop(props, "custom_symbol", text="")
|
||||
row.prop(props, "apply_newline_to_all", text="", icon="COPYDOWN")
|
||||
select_op = row.operator("bim.select_similar_text_literal_value", text="", icon="RESTRICT_SELECT_OFF")
|
||||
select_op.literal_value = str(props.newline_at)
|
||||
select_op.attribute_type = "newline"
|
||||
row.operator("bim.copy_text_to_selection", text="", icon="COPYDOWN").attribute = "SYMBOL"
|
||||
|
||||
row = self.layout.row(align=True)
|
||||
row.label(text="Literals:")
|
||||
row.operator("bim.copy_text_to_selection", text="", icon="COPYDOWN").attribute = "LITERALS"
|
||||
row.operator("bim.add_text_literal", icon="ADD", text="")
|
||||
|
||||
for i, literal_props in enumerate(props.literals):
|
||||
box = self.layout.box()
|
||||
|
||||
row = box.row(align=True)
|
||||
row.label(text=f"Literal[{i}]:")
|
||||
if i > 0:
|
||||
row.operator("bim.order_text_literal_up", icon="TRIA_UP", text="").literal_prop_id = i
|
||||
if i < len(props.literals) - 1:
|
||||
row.operator("bim.order_text_literal_down", icon="TRIA_DOWN", text="").literal_prop_id = i
|
||||
row.operator("bim.remove_text_literal", icon="X", text="").literal_prop_id = i
|
||||
|
||||
if len(literal_props.attributes) > 0 and i < len(props.literal_apply_settings):
|
||||
if len(literal_props.attributes):
|
||||
row = box.row(align=True)
|
||||
bonsai.bim.helper.draw_attribute(literal_props.attributes[0], row, enable_search=True)
|
||||
if i > 0:
|
||||
row.operator("bim.order_text_literal_up", icon="TRIA_UP", text="").literal_prop_id = i
|
||||
if i < len(props.literals) - 1:
|
||||
row.operator("bim.order_text_literal_down", icon="TRIA_DOWN", text="").literal_prop_id = i
|
||||
row.operator("bim.remove_text_literal", icon="X", text="").literal_prop_id = i
|
||||
|
||||
expand_icon = "DOWNARROW_HLT" if getattr(literal_props, "show_element_values", False) else "RIGHTARROW"
|
||||
op = row.operator("bim.toggle_element_values_panel", icon=expand_icon, text="")
|
||||
op.literal_prop_id = i
|
||||
|
||||
row.prop(props.literal_apply_settings[i], "apply_text_to_all", text="", icon="COPYDOWN")
|
||||
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
assigned_element = tool.Drawing.get_assigned_product(element) or element
|
||||
resolved_value = tool.Drawing.replace_text_literal_variables(
|
||||
literal_props.attributes[0].string_value,
|
||||
assigned_element,
|
||||
props.reverse_list,
|
||||
props.list_separator,
|
||||
)
|
||||
row = box.row(align=True)
|
||||
row.label(text="CurrentValue:")
|
||||
@@ -779,8 +777,6 @@ class BIM_PT_text(Panel):
|
||||
else:
|
||||
row.prop(attr, "string_value", text="Path")
|
||||
select_value = attr.string_value
|
||||
if i < len(props.literal_apply_settings):
|
||||
row.prop(props.literal_apply_settings[i], "apply_path_to_all", text="", icon="COPYDOWN")
|
||||
|
||||
other_attributes = [a for a in literal_props.attributes[2:] if a.name != "BoxAlignment"]
|
||||
if other_attributes:
|
||||
@@ -800,10 +796,6 @@ class BIM_PT_text(Panel):
|
||||
col = row.column(align=True)
|
||||
alignment_label_row = col.row(align=True)
|
||||
alignment_label_row.label(text=" Text box alignment:")
|
||||
if i < len(props.literal_apply_settings):
|
||||
alignment_label_row.prop(
|
||||
props.literal_apply_settings[i], "apply_box_alignment_to_all", text="", icon="COPYDOWN"
|
||||
)
|
||||
|
||||
box_alignment_value = (
|
||||
literal_props.attributes[
|
||||
@@ -824,61 +816,52 @@ class BIM_PT_text(Panel):
|
||||
props = tool.Drawing.get_text_props(obj)
|
||||
|
||||
if props.is_editing:
|
||||
self.draw_text_editing_ui(context)
|
||||
else:
|
||||
text_data = DecoratorData.get_text_data(obj)
|
||||
return self.draw_text_editing_ui(context)
|
||||
text_data = DecoratorData.get_text_data(obj)
|
||||
|
||||
row = self.layout.row()
|
||||
row.operator("bim.enable_editing_text", icon="GREASEPENCIL")
|
||||
row = self.layout.row()
|
||||
row.operator("bim.enable_editing_text", icon="GREASEPENCIL")
|
||||
|
||||
row = self.layout.row(align=True)
|
||||
row.label(text="FontSize")
|
||||
click_op = row.operator(
|
||||
"bim.select_similar_text_literal_value", text=str(text_data["FontSize"]), emboss=False
|
||||
)
|
||||
click_op.literal_value = str(text_data["FontSize"])
|
||||
click_op.attribute_type = "font_size"
|
||||
click_op.display_text = str(text_data["FontSize"])
|
||||
row = self.layout.row(align=True)
|
||||
row.label(text="FontSize")
|
||||
click_op = row.operator("bim.select_similar_text_literal_value", text=str(text_data["FontSize"]), emboss=False)
|
||||
click_op.literal_value = str(text_data["FontSize"])
|
||||
click_op.attribute_type = "font_size"
|
||||
click_op.display_text = str(text_data["FontSize"])
|
||||
|
||||
row = self.layout.row(align=True)
|
||||
row.label(text="Newline_At")
|
||||
click_op = row.operator(
|
||||
"bim.select_similar_text_literal_value", text=str(text_data["Newline_At"]), emboss=False
|
||||
)
|
||||
click_op.literal_value = str(text_data["Newline_At"])
|
||||
click_op.attribute_type = "newline"
|
||||
click_op.display_text = str(text_data["Newline_At"])
|
||||
row = self.layout.row(align=True)
|
||||
row.label(text="Reverse_List")
|
||||
row.label(text=str(text_data["Reverse_List"]))
|
||||
row = self.layout.row(align=True)
|
||||
row.label(text="List_Separator")
|
||||
row.label(text=str(text_data["List_Separator"]))
|
||||
row = self.layout.row(align=True)
|
||||
row.label(text="Newline_At")
|
||||
click_op = row.operator(
|
||||
"bim.select_similar_text_literal_value", text=str(text_data["Newline_At"]), emboss=False
|
||||
)
|
||||
click_op.literal_value = str(text_data["Newline_At"])
|
||||
click_op.attribute_type = "newline"
|
||||
click_op.display_text = str(text_data["Newline_At"])
|
||||
|
||||
for i, literal_data in enumerate(text_data["Literals"]):
|
||||
box = self.layout.box()
|
||||
box.label(text=f"Literal[{i}]:")
|
||||
for i, literal_data in enumerate(text_data["Literals"]):
|
||||
box = self.layout.box()
|
||||
box.label(text=f"Literal[{i}]:")
|
||||
|
||||
# Combine both approaches: clickable attributes from PR #7292 and display from PR #7106
|
||||
for attribute in literal_data:
|
||||
row = box.row(align=True)
|
||||
row.label(text=attribute)
|
||||
click_op = row.operator(
|
||||
"bim.select_similar_text_literal_value",
|
||||
text=str(literal_data[attribute]),
|
||||
emboss=False,
|
||||
)
|
||||
click_op.literal_value = str(literal_data[attribute])
|
||||
click_op.literal_index = i
|
||||
if attribute == "Literal":
|
||||
click_op.attribute_type = "literal"
|
||||
elif attribute == "Path":
|
||||
click_op.attribute_type = "path"
|
||||
elif attribute == "BoxAlignment":
|
||||
click_op.attribute_type = "box_alignment"
|
||||
else:
|
||||
click_op.attribute_type = "text"
|
||||
click_op.display_text = str(literal_data[attribute])
|
||||
# Combine both approaches: clickable attributes from PR #7292 and display from PR #7106
|
||||
for attribute in literal_data:
|
||||
row = box.row(align=True)
|
||||
row.label(text=attribute)
|
||||
click_op = row.operator(
|
||||
"bim.select_similar_text_literal_value",
|
||||
text=str(literal_data[attribute]),
|
||||
emboss=False,
|
||||
)
|
||||
click_op.literal_value = str(literal_data[attribute])
|
||||
click_op.literal_index = i
|
||||
if attribute == "Literal":
|
||||
click_op.attribute_type = "literal"
|
||||
elif attribute == "Path":
|
||||
click_op.attribute_type = "path"
|
||||
elif attribute == "BoxAlignment":
|
||||
click_op.attribute_type = "box_alignment"
|
||||
else:
|
||||
click_op.attribute_type = "text"
|
||||
click_op.display_text = str(literal_data[attribute])
|
||||
|
||||
|
||||
class BIM_UL_drawinglist(bpy.types.UIList):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -50,9 +50,10 @@ classes = (
|
||||
array.EditArray,
|
||||
array.EnableEditingArray,
|
||||
array.ApplyArray,
|
||||
array.RegenerateArray,
|
||||
array.RemoveArray,
|
||||
array.SelectArrayParent,
|
||||
array.SelectAllArrayObjects,
|
||||
array.SelectArrayParent,
|
||||
array.Input3DCursorXArray,
|
||||
array.Input3DCursorYArray,
|
||||
array.Input3DCursorZArray,
|
||||
|
||||
@@ -61,7 +61,6 @@ class AddArray(bpy.types.Operator, tool.Ifc.Operator):
|
||||
"y": 0.0,
|
||||
"z": 0.0,
|
||||
"use_local_space": True,
|
||||
"sync_children": False,
|
||||
"method": "OFFSET",
|
||||
}
|
||||
|
||||
@@ -80,28 +79,27 @@ class AddArray(bpy.types.Operator, tool.Ifc.Operator):
|
||||
pset=pset,
|
||||
properties={"Parent": element.GlobalId, "Data": ifc_file.create_entity("IfcText", json.dumps(data))},
|
||||
)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class DisableEditingArray(bpy.types.Operator, tool.Ifc.Operator):
|
||||
class DisableEditingArray(bpy.types.Operator):
|
||||
bl_idname = "bim.disable_editing_array"
|
||||
bl_label = "Disable Editing Array"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def _execute(self, context):
|
||||
def execute(self, context):
|
||||
obj = context.active_object
|
||||
assert obj
|
||||
tool.Model.get_array_props(obj).is_editing = -1
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class EnableEditingArray(bpy.types.Operator, tool.Ifc.Operator):
|
||||
class EnableEditingArray(bpy.types.Operator):
|
||||
bl_idname = "bim.enable_editing_array"
|
||||
bl_label = "Enable Editing Array"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
item: bpy.props.IntProperty()
|
||||
|
||||
def _execute(self, context):
|
||||
def execute(self, context):
|
||||
obj = context.active_object
|
||||
assert obj
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
@@ -122,11 +120,9 @@ class EnableEditingArray(bpy.types.Operator, tool.Ifc.Operator):
|
||||
props.y = data["y"] * si_conversion
|
||||
props.z = data["z"] * si_conversion
|
||||
props.use_local_space = data.get("use_local_space", False)
|
||||
props.sync_children = data.get("sync_children", False)
|
||||
props.method = data.get("method", "OFFSET")
|
||||
|
||||
props.is_editing = self.item
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
@@ -151,31 +147,25 @@ class EditArray(bpy.types.Operator, tool.Ifc.Operator):
|
||||
"y": props.y / si_conversion,
|
||||
"z": props.z / si_conversion,
|
||||
"use_local_space": props.use_local_space,
|
||||
"sync_children": props.sync_children,
|
||||
"method": props.method,
|
||||
}
|
||||
|
||||
props.is_editing = -1
|
||||
|
||||
try:
|
||||
parent = tool.Ifc.get_object(tool.Ifc.get().by_guid(pset["Parent"]))
|
||||
parent_element = tool.Ifc.get().by_guid(pset["Parent"])
|
||||
parent = tool.Ifc.get_object(parent_element)
|
||||
except:
|
||||
return {"FINISHED"}
|
||||
|
||||
tool.Blender.Modifier.Array.remove_constraints(parent_element)
|
||||
tool.Model.regenerate_array(parent, data)
|
||||
|
||||
pset = tool.Ifc.get().by_id(pset["id"])
|
||||
data = tool.Ifc.get().createIfcText(json.dumps(data))
|
||||
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Data": data})
|
||||
|
||||
tool.Blender.Modifier.Array.set_children_lock_state(element, self.item, True)
|
||||
tool.Blender.Modifier.Array.constrain_children_to_parent(element)
|
||||
|
||||
# clears the relating_array_object so it doesn't show again next time
|
||||
props.relating_array_object = None
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class ApplyArray(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.apply_array"
|
||||
@@ -192,6 +182,33 @@ class ApplyArray(bpy.types.Operator, tool.Ifc.Operator):
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class RegenerateArray(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.regenerate_array"
|
||||
bl_label = "Regenerate Array"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def _execute(self, context):
|
||||
obj = context.active_object
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
|
||||
try:
|
||||
parent_element = tool.Ifc.get().by_guid(pset["Parent"])
|
||||
parent = tool.Ifc.get_object(parent_element)
|
||||
except:
|
||||
return {"FINISHED"}
|
||||
pset = ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array")
|
||||
arrays = json.loads(pset["Data"])
|
||||
pset = tool.Ifc.get().by_id(pset["id"])
|
||||
for array in arrays:
|
||||
for child in set(array["children"]):
|
||||
if child_obj := tool.Ifc.get_object(tool.Ifc.get().by_guid(child)):
|
||||
tool.Geometry.delete_ifc_object(child_obj)
|
||||
array["children"].clear()
|
||||
print("cleared array", arrays)
|
||||
tool.Model.regenerate_array(obj, arrays)
|
||||
tool.Blender.Modifier.Array.constrain_children_to_parent(element)
|
||||
|
||||
|
||||
class RemoveArray(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.remove_array"
|
||||
bl_label = "Remove Array"
|
||||
@@ -216,7 +233,8 @@ class RemoveArray(bpy.types.Operator, tool.Ifc.Operator):
|
||||
props.is_editing = -1
|
||||
|
||||
try:
|
||||
parent = tool.Ifc.get_object(tool.Ifc.get().by_guid(pset["Parent"]))
|
||||
parent_element = tool.Ifc.get().by_guid(pset["Parent"])
|
||||
parent = tool.Ifc.get_object(parent_element)
|
||||
except:
|
||||
return {"FINISHED"}
|
||||
|
||||
@@ -226,9 +244,10 @@ class RemoveArray(bpy.types.Operator, tool.Ifc.Operator):
|
||||
|
||||
if not self.keep_objs:
|
||||
data[self.item]["count"] = 1
|
||||
tool.Blender.Modifier.Array.remove_constraints(parent_element)
|
||||
tool.Model.regenerate_array(parent, data, array_layers_to_apply=[self.item] if self.keep_objs else [])
|
||||
|
||||
pset = tool.Ifc.get().by_id(pset["id"])
|
||||
pset = tool.Pset.get_element_pset(element, "BBIM_Array")
|
||||
if len(data) == 1:
|
||||
ifcopenshell.api.pset.remove_pset(tool.Ifc.get(), product=element, pset=pset)
|
||||
else:
|
||||
@@ -237,8 +256,6 @@ class RemoveArray(bpy.types.Operator, tool.Ifc.Operator):
|
||||
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Data": data})
|
||||
tool.Blender.Modifier.Array.constrain_children_to_parent(element)
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class SelectArrayParent(bpy.types.Operator):
|
||||
bl_idname = "bim.select_array_parent"
|
||||
|
||||
@@ -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()
|
||||
@@ -1131,8 +1131,13 @@ class DrawPolylineProfile(bpy.types.Operator, PolylineOperator, tool.Ifc.Operato
|
||||
|
||||
MEPGenerator().setup_ports(profile1["obj"])
|
||||
else:
|
||||
connect_IfcFlowSegments = tool.Ifc.get_entity(profiles[0]["obj"]).is_a("IfcFlowSegment")
|
||||
for profile1, profile2 in zip(profiles[:-1], profiles[1:]):
|
||||
DumbProfileJoiner().join_V(profile2["obj"], profile1["obj"])
|
||||
if connect_IfcFlowSegments:
|
||||
bpy.ops.bim.mep_connect_elements(
|
||||
obj1_name=profile1["obj"].name, obj2_name=profile2["obj"].name
|
||||
)
|
||||
|
||||
def modal(self, context, event):
|
||||
return IfcStore.execute_ifc_operator(self, context, event, method="MODAL")
|
||||
|
||||
@@ -387,11 +387,6 @@ class BIMArrayProperties(PropertyGroup):
|
||||
name="Method",
|
||||
default="OFFSET",
|
||||
)
|
||||
sync_children: bpy.props.BoolProperty(
|
||||
name="Sync Children",
|
||||
description="Regenerate all children based on the parent object",
|
||||
default=False,
|
||||
)
|
||||
relating_array_object: bpy.props.PointerProperty(
|
||||
type=bpy.types.Object,
|
||||
name="Copy Array Properties",
|
||||
|
||||
@@ -231,17 +231,16 @@ class DumbSlabPlaner:
|
||||
for inverse in tool.Ifc.get().get_inverse(layer_set):
|
||||
if not inverse.is_a("IfcMaterialLayerSetUsage") or inverse.LayerSetDirection != "AXIS3":
|
||||
continue
|
||||
|
||||
if tool.Ifc.get().schema == "IFC2X3":
|
||||
for rel in tool.Ifc.get().get_inverse(inverse):
|
||||
if not rel.is_a("IfcRelAssociatesMaterial"):
|
||||
continue
|
||||
for element in rel.RelatedObjects:
|
||||
self.change_thickness(element, total_thickness, preserve_offset=True)
|
||||
self.change_thickness(element, total_thickness)
|
||||
else:
|
||||
for rel in inverse.AssociatedTo:
|
||||
for element in rel.RelatedObjects:
|
||||
self.change_thickness(element, total_thickness, preserve_offset=True)
|
||||
self.change_thickness(element, total_thickness)
|
||||
|
||||
def regenerate_from_occurence(self, element, material_set_usage):
|
||||
self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
||||
@@ -251,12 +250,10 @@ class DumbSlabPlaner:
|
||||
return
|
||||
self.change_thickness(element, total_thickness)
|
||||
|
||||
def change_thickness(
|
||||
self, element: ifcopenshell.entity_instance, thickness: float, preserve_offset: bool = False
|
||||
) -> None:
|
||||
def change_thickness(self, element: ifcopenshell.entity_instance, thickness: float) -> None:
|
||||
self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
||||
if tool.Model.get_usage_type(element) != "LAYER3":
|
||||
return
|
||||
|
||||
layer_params = tool.Model.get_material_layer_parameters(element)
|
||||
ifc_file = tool.Ifc.get()
|
||||
body_context = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Model", "Body", "MODEL_VIEW")
|
||||
@@ -274,48 +271,72 @@ class DumbSlabPlaner:
|
||||
if representation:
|
||||
extrusion = tool.Model.get_extrusion(representation)
|
||||
if extrusion:
|
||||
# TODO Right now we don't have a reliable way to calculate the existing x_angle only based solely on the extrusion direction.
|
||||
# For instances, a 30 degrees angled extrusion with positive direction has the same extrusion direction as a
|
||||
# -150 degrees angled extrusion with negative direction. The difference lies in the object's rotation.
|
||||
# This means that things can get messy if the user changes the object x angle somehow. We have to figure out an alternative approach.
|
||||
existing_x_angle = obj.rotation_euler.x
|
||||
existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 0, tolerance=0.001) else existing_x_angle
|
||||
existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, pi, tolerance=0.001) else existing_x_angle
|
||||
existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 2 * pi, tolerance=0.001) else existing_x_angle
|
||||
direction_ratios = Vector(extrusion.ExtrudedDirection.DirectionRatios)
|
||||
offset_direction = direction_ratios.copy()
|
||||
perpendicular_depth = thickness * abs(1 / cos(existing_x_angle))
|
||||
perpendicular_offset = layer_offset * abs(1 / cos(existing_x_angle)) / self.unit_scale
|
||||
|
||||
# Calculate the actual extrusion angle from vertical
|
||||
extrusion_angle = 0
|
||||
if direction_ratios.length > 0:
|
||||
cos_angle = direction_ratios.normalized().dot(Vector((0, 0, 1)))
|
||||
extrusion_angle = acos(min(max(cos_angle, -1), 1))
|
||||
|
||||
# Only apply 1/cos factor when there's actual extrusion slope
|
||||
if extrusion_angle > 1e-6:
|
||||
perpendicular_depth = thickness * abs(1 / cos(extrusion_angle))
|
||||
perpendicular_offset = layer_offset * abs(1 / cos(extrusion_angle))
|
||||
else:
|
||||
perpendicular_depth = thickness
|
||||
perpendicular_offset = layer_offset
|
||||
# Check angle and z direction to determine whether the extrusion direction is positive or negative
|
||||
if (abs(existing_x_angle) < (pi / 2) and direction_ratios.z > 0) or (
|
||||
abs(existing_x_angle) > (pi / 2) and direction_ratios.z < 0
|
||||
):
|
||||
# The extrusion direction is positive. If the layer_parameter is set to negative,
|
||||
# then the we change the extrusion direction.
|
||||
if layer_params["direction_sense"] == "NEGATIVE":
|
||||
direction_ratios *= -1
|
||||
elif (abs(existing_x_angle) > (pi / 2) and direction_ratios.z > 0) or (
|
||||
abs(existing_x_angle) < (pi / 2) and direction_ratios.z < 0
|
||||
):
|
||||
# The extrusion direction is negative. If the layer_parameter is set to positive,
|
||||
# then the we change the extrusion direction. And the offset direction should remain positive
|
||||
# for either direction sense, so we change it.
|
||||
offset_direction *= -1
|
||||
if layer_params["direction_sense"] == "POSITIVE":
|
||||
direction_ratios *= -1
|
||||
|
||||
extrusion.ExtrudedDirection.DirectionRatios = tuple(direction_ratios)
|
||||
extrusion.Depth = perpendicular_depth
|
||||
|
||||
# Update position
|
||||
ifc_position = extrusion.Position
|
||||
position = offset_direction * perpendicular_offset
|
||||
material = ifcopenshell.util.element.get_material(element)
|
||||
if material:
|
||||
if material.is_a("IfcMaterialLayerSetUsage"):
|
||||
material.OffsetFromReferenceLine = position.z
|
||||
if ifc_position:
|
||||
ifc_position.Location.Coordinates = position
|
||||
else:
|
||||
tool.Model.add_extrusion_position(extrusion, position)
|
||||
|
||||
if direction_ratios.length > 0:
|
||||
offset_vector = direction_ratios.normalized() * perpendicular_offset
|
||||
position = offset_vector
|
||||
|
||||
material = ifcopenshell.util.element.get_material(element)
|
||||
if material and material.is_a("IfcMaterialLayerSetUsage"):
|
||||
# Only set offset if not preserving it (preserves independent offsets per instance)
|
||||
if not preserve_offset:
|
||||
material.OffsetFromReferenceLine = position.z
|
||||
|
||||
if ifc_position:
|
||||
ifc_position.Location.Coordinates = position
|
||||
else:
|
||||
tool.Model.add_extrusion_position(extrusion, position)
|
||||
|
||||
bonsai.core.geometry.switch_representation(
|
||||
tool.Ifc,
|
||||
tool.Geometry,
|
||||
obj=obj,
|
||||
representation=representation,
|
||||
)
|
||||
else:
|
||||
props = tool.Model.get_model_props()
|
||||
x_angle = 0 if tool.Cad.is_x(props.x_angle, 0, tolerance=0.001) else props.x_angle
|
||||
new_rep = ifcopenshell.api.geometry.add_slab_representation(
|
||||
tool.Ifc.get(),
|
||||
context=body_context,
|
||||
depth=thickness * self.unit_scale,
|
||||
x_angle=x_angle,
|
||||
)
|
||||
for inverse in tool.Ifc.get().get_inverse(representation):
|
||||
ifcopenshell.util.element.replace_attribute(inverse, representation, new_rep)
|
||||
bonsai.core.geometry.switch_representation(
|
||||
tool.Ifc,
|
||||
tool.Geometry,
|
||||
obj=obj,
|
||||
representation=new_rep,
|
||||
)
|
||||
bonsai.core.geometry.remove_representation(
|
||||
tool.Ifc, tool.Geometry, obj=obj, representation=representation
|
||||
)
|
||||
return
|
||||
else:
|
||||
props = tool.Model.get_model_props()
|
||||
x_angle = 0 if tool.Cad.is_x(props.x_angle, 0, tolerance=0.001) else props.x_angle
|
||||
@@ -328,118 +349,13 @@ class DumbSlabPlaner:
|
||||
ifcopenshell.api.geometry.assign_representation(
|
||||
tool.Ifc.get(), product=element, representation=representation
|
||||
)
|
||||
bonsai.core.geometry.switch_representation(
|
||||
tool.Ifc,
|
||||
tool.Geometry,
|
||||
obj=obj,
|
||||
representation=representation,
|
||||
)
|
||||
|
||||
def update_extrusion_direction(
|
||||
element: ifcopenshell.entity_instance, new_direction_ratios: tuple, obj: bpy.types.Object = None
|
||||
) -> None:
|
||||
"""
|
||||
Update extrusion direction while preserving overall object orientation.
|
||||
|
||||
Args:
|
||||
element: The IFC element
|
||||
new_direction_ratios: New extrusion direction ratios (x,y,z)
|
||||
obj: Optional Blender object (will be fetched if not provided)
|
||||
"""
|
||||
if not obj:
|
||||
obj = tool.Ifc.get_object(element)
|
||||
if not obj:
|
||||
return
|
||||
|
||||
representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
|
||||
if not representation:
|
||||
return
|
||||
|
||||
extrusion = tool.Model.get_extrusion(representation)
|
||||
if not extrusion:
|
||||
return
|
||||
|
||||
# Get current extrusion direction
|
||||
old_direction = Vector(extrusion.ExtrudedDirection.DirectionRatios)
|
||||
if old_direction.length == 0:
|
||||
old_direction = Vector((0, 0, 1)) # Default
|
||||
|
||||
new_direction = Vector(new_direction_ratios)
|
||||
if new_direction.length == 0:
|
||||
new_direction = Vector((0, 0, 1)) # Default
|
||||
|
||||
# Normalize both directions
|
||||
old_direction_normalized = old_direction.normalized()
|
||||
new_direction_normalized = new_direction.normalized()
|
||||
|
||||
# Store current object matrix
|
||||
old_matrix = obj.matrix_world.copy()
|
||||
|
||||
# Calculate the rotation needed to keep same orientation
|
||||
# When extrusion direction changes from A to B relative to local coordinates,
|
||||
# we need to rotate the object by the inverse of that change
|
||||
|
||||
# Calculate rotation from old to new direction
|
||||
rotation_axis = old_direction_normalized.cross(new_direction_normalized)
|
||||
if rotation_axis.length > 1e-6:
|
||||
rotation_axis.normalized()
|
||||
dot_product = old_direction_normalized.dot(new_direction_normalized)
|
||||
angle = acos(min(max(dot_product, -1), 1))
|
||||
|
||||
# Apply INVERSE rotation to object to compensate
|
||||
rotation_matrix = Matrix.Rotation(-angle, 4, rotation_axis)
|
||||
|
||||
# Update object rotation
|
||||
obj.matrix_world = old_matrix @ rotation_matrix
|
||||
bpy.context.view_layer.update()
|
||||
|
||||
# Update extrusion direction (keeping magnitude)
|
||||
if old_direction.length > 0:
|
||||
# Preserve the magnitude of the original direction vector
|
||||
magnitude = old_direction.length
|
||||
new_direction = new_direction_normalized * magnitude
|
||||
|
||||
extrusion.ExtrudedDirection.DirectionRatios = tuple(new_direction)
|
||||
|
||||
# Update depth based on new extrusion angle
|
||||
extrusion_angle = 0
|
||||
if new_direction.length > 0:
|
||||
cos_angle = new_direction_normalized.dot(Vector((0, 0, 1)))
|
||||
extrusion_angle = acos(min(max(cos_angle, -1), 1))
|
||||
|
||||
# Get current depth (perpendicular depth)
|
||||
current_perpendicular_depth = extrusion.Depth
|
||||
|
||||
# If we have material layer info, calculate actual thickness
|
||||
material = ifcopenshell.util.element.get_material(element)
|
||||
actual_thickness = current_perpendicular_depth
|
||||
if material and material.is_a("IfcMaterialLayerSetUsage"):
|
||||
layer_set = material.ForLayerSet
|
||||
actual_thickness = sum([l.LayerThickness for l in layer_set.MaterialLayers])
|
||||
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
||||
actual_thickness *= unit_scale
|
||||
|
||||
# Convert to perpendicular depth if needed
|
||||
if extrusion_angle > 1e-6:
|
||||
new_perpendicular_depth = actual_thickness * abs(1 / cos(extrusion_angle))
|
||||
else:
|
||||
new_perpendicular_depth = actual_thickness
|
||||
|
||||
extrusion.Depth = new_perpendicular_depth
|
||||
|
||||
# Update position offset if needed
|
||||
if extrusion.Position:
|
||||
# Recalculate offset based on new direction
|
||||
material = ifcopenshell.util.element.get_material(element)
|
||||
if material and material.is_a("IfcMaterialLayerSetUsage"):
|
||||
offset = material.OffsetFromReferenceLine
|
||||
if extrusion_angle > 1e-6:
|
||||
perpendicular_offset = offset * abs(1 / cos(extrusion_angle))
|
||||
else:
|
||||
perpendicular_offset = offset
|
||||
|
||||
offset_vector = new_direction_normalized * perpendicular_offset
|
||||
extrusion.Position.Location.Coordinates = tuple(offset_vector)
|
||||
bonsai.core.geometry.switch_representation(
|
||||
tool.Ifc,
|
||||
tool.Geometry,
|
||||
obj=obj,
|
||||
representation=representation,
|
||||
)
|
||||
|
||||
|
||||
class EnableEditingSketchExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator):
|
||||
@@ -716,8 +632,6 @@ class EnableEditingExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator):
|
||||
existing_x_angle = tool.Model.get_existing_x_angle(extrusion)
|
||||
layer_params = tool.Model.get_material_layer_parameters(element)
|
||||
|
||||
usage_type = tool.Model.get_usage_type(element)
|
||||
|
||||
# TODO: review #7537 properly, this is a quick fix but something doesn't seem right.
|
||||
original_rotation_x = 0
|
||||
if extrusion.Position:
|
||||
@@ -732,49 +646,22 @@ class EnableEditingExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator):
|
||||
tranlation_matrix = Matrix.Translation(rot_offset)
|
||||
position = position @ tranlation_matrix
|
||||
|
||||
# For AXIS3 with dual rotation: Reset rotation to zero so profile is horizontal
|
||||
if usage_type == "LAYER3":
|
||||
# Store original rotation for later restoration
|
||||
original_rotation_x = obj.rotation_euler.x
|
||||
obj["pre_edit_rotation_x"] = original_rotation_x
|
||||
|
||||
# Reset rotation to zero - profile will be horizontal
|
||||
current_z_rot = obj.rotation_euler.z
|
||||
obj.rotation_euler.x = 0.0
|
||||
obj.rotation_euler.z = current_z_rot
|
||||
else:
|
||||
# Original behavior: Restore Object rotation to zero
|
||||
local_rot_mat = obj.rotation_euler.to_matrix()
|
||||
rot_mat = Matrix.Rotation(-existing_x_angle, 4, "X")
|
||||
new_rot_mat = local_rot_mat.to_4x4() @ rot_mat
|
||||
new_rot_euler = new_rot_mat.to_euler()
|
||||
obj.rotation_euler = new_rot_euler
|
||||
# Restore Object rotation to zero
|
||||
local_rot_mat = obj.rotation_euler.to_matrix()
|
||||
rot_mat = Matrix.Rotation(-existing_x_angle, 4, "X")
|
||||
new_rot_mat = local_rot_mat.to_4x4() @ rot_mat
|
||||
new_rot_euler = new_rot_mat.to_euler()
|
||||
obj.rotation_euler = new_rot_euler
|
||||
|
||||
else:
|
||||
position = Matrix()
|
||||
|
||||
# Import profile with correct x_angle
|
||||
if usage_type == "LAYER3":
|
||||
# For LAYER3: Use x_angle=0 and scale by cos(rotation) to get horizontal projection
|
||||
obj_x_rotation = original_rotation_x # Use stored original rotation
|
||||
scale_factor = abs(cos(obj_x_rotation)) if abs(obj_x_rotation) > 1e-6 else 1.0
|
||||
|
||||
# Import with x_angle=0
|
||||
tool.Model.import_profile(extrusion.SweptArea, obj=obj, position=position, x_angle=0)
|
||||
|
||||
# Scale the Y coordinates by cos(rotation) to get horizontal projection
|
||||
bpy.ops.object.mode_set(mode="OBJECT")
|
||||
for vert in obj.data.vertices:
|
||||
vert.co.y *= scale_factor
|
||||
else:
|
||||
# For other types: Use existing_x_angle
|
||||
tool.Model.import_profile(extrusion.SweptArea, obj=obj, position=position, x_angle=existing_x_angle)
|
||||
tool.Model.import_profile(extrusion.SweptArea, obj=obj, position=position, x_angle=existing_x_angle)
|
||||
|
||||
bpy.ops.object.mode_set(mode="EDIT")
|
||||
ProfileDecorator.install(context, exit_edit_mode_callback=lambda: disable_editing_extrusion_profile(context))
|
||||
if not bpy.app.background:
|
||||
tool.Blender.set_viewport_tool("bim.cad_tool")
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
@@ -796,7 +683,6 @@ class EditExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator):
|
||||
extrusion = tool.Model.get_extrusion(body)
|
||||
existing_x_angle = tool.Model.get_existing_x_angle(extrusion)
|
||||
layer_params = tool.Model.get_material_layer_parameters(element)
|
||||
usage_type = tool.Model.get_usage_type(element)
|
||||
|
||||
if extrusion.Position:
|
||||
position = Matrix(ifcopenshell.util.placement.get_axis2placement(extrusion.Position).tolist())
|
||||
@@ -810,38 +696,17 @@ class EditExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator):
|
||||
tranlation_matrix = Matrix.Translation(rot_offset)
|
||||
position = position @ tranlation_matrix
|
||||
|
||||
# Restore rotation
|
||||
if usage_type == "LAYER3":
|
||||
# Restore original rotation from before editing
|
||||
if "pre_edit_rotation_x" in obj:
|
||||
current_z_rot = obj.rotation_euler.z
|
||||
obj.rotation_euler.x = obj["pre_edit_rotation_x"]
|
||||
obj.rotation_euler.z = current_z_rot
|
||||
del obj["pre_edit_rotation_x"]
|
||||
else:
|
||||
# Original behavior
|
||||
local_rot_mat = obj.rotation_euler.to_matrix()
|
||||
rot_mat = Matrix.Rotation(existing_x_angle, 4, "X")
|
||||
new_rot_mat = local_rot_mat.to_4x4() @ rot_mat
|
||||
new_rot_euler = new_rot_mat.to_euler()
|
||||
obj.rotation_euler = new_rot_euler
|
||||
# Restore Object rotation to x_angle
|
||||
local_rot_mat = obj.rotation_euler.to_matrix()
|
||||
rot_mat = Matrix.Rotation(existing_x_angle, 4, "X")
|
||||
new_rot_mat = local_rot_mat.to_4x4() @ rot_mat
|
||||
new_rot_euler = new_rot_mat.to_euler()
|
||||
obj.rotation_euler = new_rot_euler
|
||||
|
||||
else:
|
||||
position = Matrix()
|
||||
|
||||
# Export profile with correct x_angle
|
||||
if usage_type == "LAYER3":
|
||||
# Scale Y coordinates back up before exporting
|
||||
obj_x_rotation = obj.rotation_euler.x
|
||||
scale_factor = abs(cos(obj_x_rotation)) if abs(obj_x_rotation) > 1e-6 else 1.0
|
||||
|
||||
# Un-scale the profile before exporting
|
||||
for vert in obj.data.vertices:
|
||||
vert.co.y /= scale_factor # Inverse of import scaling
|
||||
|
||||
profile = tool.Model.export_profile(obj, position=position, x_angle=0)
|
||||
else:
|
||||
profile = tool.Model.export_profile(obj, position=position, x_angle=existing_x_angle)
|
||||
profile = tool.Model.export_profile(obj, position=position, x_angle=existing_x_angle)
|
||||
|
||||
if not profile:
|
||||
|
||||
@@ -893,28 +758,6 @@ class EditExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator):
|
||||
tool.Ifc.get(), product=element, representation=new_footprint
|
||||
)
|
||||
|
||||
footprint_context = ifcopenshell.util.representation.get_context(
|
||||
tool.Ifc.get(), "Plan", "FootPrint", "SKETCH_VIEW"
|
||||
)
|
||||
if not footprint_context:
|
||||
return
|
||||
|
||||
curves = [profile.OuterCurve]
|
||||
if profile.is_a("IfcArbitraryProfileDefWithVoids"):
|
||||
curves.extend(profile.InnerCurves)
|
||||
new_footprint = ifcopenshell.api.geometry.add_footprint_representation(
|
||||
tool.Ifc.get(), context=footprint_context, curves=curves
|
||||
)
|
||||
old_footprint = ifcopenshell.util.representation.get_representation(element, "Plan", "FootPrint", "SKETCH_VIEW")
|
||||
if old_footprint:
|
||||
for inverse in tool.Ifc.get().get_inverse(old_footprint):
|
||||
ifcopenshell.util.element.replace_attribute(inverse, old_footprint, new_footprint)
|
||||
bonsai.core.geometry.remove_representation(tool.Ifc, tool.Geometry, obj=obj, representation=old_footprint)
|
||||
else:
|
||||
ifcopenshell.api.geometry.assign_representation(
|
||||
tool.Ifc.get(), product=element, representation=new_footprint
|
||||
)
|
||||
|
||||
|
||||
class ResetVertex(bpy.types.Operator):
|
||||
bl_idname = "bim.reset_vertex"
|
||||
|
||||
@@ -229,6 +229,7 @@ class BIM_PT_array(bpy.types.Panel):
|
||||
if ArrayData.data["parameters"]:
|
||||
row = self.layout.row(align=True)
|
||||
row.label(text=ArrayData.data["parameters"]["parent_name"], icon="CON_CHILDOF")
|
||||
row.operator("bim.regenerate_array", icon="FILE_REFRESH", text="")
|
||||
row.operator("bim.select_array_parent", icon="OBJECT_DATA", text="")
|
||||
row.operator("bim.select_all_array_objects", icon="RESTRICT_SELECT_OFF", text="")
|
||||
|
||||
@@ -246,7 +247,6 @@ class BIM_PT_array(bpy.types.Panel):
|
||||
row.prop(props, "method")
|
||||
row = box.row(align=True)
|
||||
row.prop(props, "use_local_space")
|
||||
row.prop(props, "sync_children")
|
||||
col = box.column()
|
||||
row = col.row(align=True)
|
||||
row.prop(props, "x")
|
||||
|
||||
@@ -403,42 +403,24 @@ class ChangeExtrusionDepth(bpy.types.Operator, tool.Ifc.Operator):
|
||||
representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
|
||||
if not representation:
|
||||
continue
|
||||
|
||||
extrusion = tool.Model.get_extrusion(representation)
|
||||
if not extrusion:
|
||||
continue
|
||||
|
||||
# Get extrusion direction
|
||||
x, y, z = extrusion.ExtrudedDirection.DirectionRatios
|
||||
|
||||
# Calculate angle from vertical
|
||||
x_angle = Vector((0, 1)).angle_signed(Vector((y, z)))
|
||||
|
||||
# For sloped walls, compensate so VERTICAL height = target depth
|
||||
cos_angle = cos(x_angle)
|
||||
compensation_factor = abs(1 / cos_angle) if abs(cos_angle) > 1e-6 else 1.0
|
||||
new_depth_ifc = (self.depth / si_conversion) * compensation_factor
|
||||
|
||||
extrusion.Depth = new_depth_ifc
|
||||
|
||||
# IMPORTANT: Refresh the geometry to reflect the IFC changes
|
||||
bonsai.core.geometry.switch_representation(
|
||||
tool.Ifc,
|
||||
tool.Geometry,
|
||||
obj=obj,
|
||||
representation=representation,
|
||||
)
|
||||
|
||||
extrusion.Depth = self.depth / si_conversion * (1 / cos(x_angle))
|
||||
if tool.Model.get_usage_type(element) == "LAYER2":
|
||||
for rel in element.ConnectedFrom:
|
||||
if rel.is_a() == "IfcRelConnectsElements":
|
||||
related_element = rel.RelatedElement
|
||||
if related_element.is_a() == "IfcWall":
|
||||
layer2_objs.append(tool.Ifc.get_object(related_element))
|
||||
ifcopenshell.api.geometry.disconnect_element(
|
||||
ifc_file,
|
||||
relating_element=rel.RelatingElement,
|
||||
related_element=element,
|
||||
)
|
||||
layer2_objs.append(obj)
|
||||
|
||||
if layer2_objs:
|
||||
tool.Model.recalculate_walls(layer2_objs)
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
@@ -458,143 +440,81 @@ class ChangeExtrusionXAngle(bpy.types.Operator, tool.Ifc.Operator):
|
||||
|
||||
def _execute(self, context):
|
||||
layer2_objs: list[bpy.types.Object] = []
|
||||
builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get())
|
||||
x_angle = 0 if tool.Cad.is_x(self.x_angle, 0, tolerance=0.001) else self.x_angle
|
||||
x_angle = 0 if tool.Cad.is_x(self.x_angle, pi, tolerance=0.001) else self.x_angle
|
||||
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
||||
x_angle = self.x_angle
|
||||
selected_objs = tool.Model.get_selected_mesh_ifc_objects()
|
||||
builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get())
|
||||
|
||||
for obj in context.selected_objects:
|
||||
for obj in selected_objs:
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element:
|
||||
continue
|
||||
|
||||
assert element
|
||||
representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
|
||||
if not representation:
|
||||
continue
|
||||
extrusion = tool.Model.get_extrusion(representation)
|
||||
if not extrusion:
|
||||
continue
|
||||
|
||||
# Get current object rotation matrix
|
||||
obj_rotation = obj.matrix_world.to_3x3()
|
||||
|
||||
# Get current extrusion direction in LOCAL coordinates
|
||||
current_local_direction = Vector(extrusion.ExtrudedDirection.DirectionRatios)
|
||||
if current_local_direction.length == 0:
|
||||
current_local_direction = Vector((0, 0, 1))
|
||||
current_local_direction_normalized = current_local_direction.normalized()
|
||||
|
||||
# Calculate what the current extrusion direction is in WORLD coordinates
|
||||
current_world_direction = obj_rotation @ current_local_direction_normalized
|
||||
|
||||
existing_x_angle = tool.Model.get_existing_x_angle(extrusion)
|
||||
existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 0, tolerance=0.001) else existing_x_angle
|
||||
existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, pi, tolerance=0.001) else existing_x_angle
|
||||
|
||||
# Calculate the NEW local extrusion direction based on x_angle
|
||||
new_local_direction = Vector((0.0, sin(x_angle), cos(x_angle)))
|
||||
|
||||
# Check if extrusion direction is actually changing
|
||||
current_local_norm = current_local_direction_normalized
|
||||
new_local_norm = new_local_direction.normalized()
|
||||
|
||||
# Compare the LOCAL directions
|
||||
local_direction_changed = (new_local_norm - current_local_norm).length > 1e-6
|
||||
|
||||
if tool.Model.get_usage_type(element) == "LAYER2":
|
||||
x, y, z = extrusion.ExtrudedDirection.DirectionRatios
|
||||
depth = extrusion.Depth / abs(1 / cos(existing_x_angle))
|
||||
perpendicular_depth = depth * abs(1 / cos(x_angle))
|
||||
|
||||
# Update extrusion direction
|
||||
if local_direction_changed:
|
||||
extrusion.ExtrudedDirection.DirectionRatios = tuple(new_local_direction)
|
||||
|
||||
# Always update depth
|
||||
extrusion.Depth = perpendicular_depth
|
||||
extrusion.ExtrudedDirection.DirectionRatios = (0.0, sin(x_angle), cos(x_angle))
|
||||
layer2_objs.append(obj)
|
||||
|
||||
extrusion.Depth = perpendicular_depth
|
||||
else:
|
||||
if tool.Model.get_usage_type(element) == "LAYER3":
|
||||
# For slabs, handle polyline scaling
|
||||
existing_obj_x_angle = obj.rotation_euler.x
|
||||
existing_obj_x_angle = (
|
||||
0 if tool.Cad.is_x(existing_obj_x_angle, 0, tolerance=0.001) else existing_obj_x_angle
|
||||
)
|
||||
existing_obj_x_angle = (
|
||||
0 if tool.Cad.is_x(existing_obj_x_angle, pi, tolerance=0.001) else existing_obj_x_angle
|
||||
)
|
||||
existing_x_angle = obj.rotation_euler.x
|
||||
existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 0, tolerance=0.001) else existing_x_angle
|
||||
existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, pi, tolerance=0.001) else existing_x_angle
|
||||
|
||||
# Scale the polyline coordinates
|
||||
coord_list = builder.get_polyline_coords(extrusion.SweptArea.OuterCurve)
|
||||
coord_list = [
|
||||
(p[0], p[1] * abs(cos(existing_x_angle))) for p in coord_list
|
||||
] # Reset the transformation
|
||||
] # Reset the transformation and returns to the original points with 0 degrees
|
||||
coord_list = [
|
||||
(p[0], p[1] * abs(1 / cos(x_angle))) for p in coord_list
|
||||
] # Apply the transformation for the new x_angle
|
||||
builder.set_polyline_coords(extrusion.SweptArea.OuterCurve, coord_list)
|
||||
|
||||
# Calculate new extrusion direction with direction sense
|
||||
base_local_direction = Vector((0.0, sin(x_angle), cos(x_angle)))
|
||||
# The extrusion direction calculated previously default to the positive direction
|
||||
# Here we set the extrusion direction to negative if that's the case
|
||||
direction_ratios = Vector((0.0, sin(x_angle), cos(x_angle)))
|
||||
# direction_ratios = Vector(extrusion.ExtrudedDirection.DirectionRatios)
|
||||
layer_params = tool.Model.get_material_layer_parameters(element)
|
||||
perpendicular_depth = layer_params["thickness"] * abs(1 / cos(x_angle)) / unit_scale
|
||||
perpendicular_offset = layer_params["offset"] * abs(1 / cos(x_angle)) / unit_scale
|
||||
offset_direction = base_local_direction.copy()
|
||||
offset_direction = direction_ratios.copy()
|
||||
|
||||
# Apply direction sense
|
||||
final_local_direction = base_local_direction.copy()
|
||||
if (abs(x_angle) < (pi / 2) and base_local_direction.z > 0) or (
|
||||
abs(x_angle) > (pi / 2) and base_local_direction.z < 0
|
||||
# Check angle and z direction to determine whether the extrusion direction is positive or negative
|
||||
if (abs(x_angle) < (pi / 2) and direction_ratios.z > 0) or (
|
||||
abs(x_angle) > (pi / 2) and direction_ratios.z < 0
|
||||
):
|
||||
# The extrusion direction is positive. If the layer_parameter is set to negative,
|
||||
# then the we change the extrusion direction.
|
||||
if layer_params["direction_sense"] == "NEGATIVE":
|
||||
final_local_direction *= -1
|
||||
elif (x_angle > (pi / 2) and base_local_direction.z > 0) or (
|
||||
x_angle < (pi / 2) and base_local_direction.z < 0
|
||||
direction_ratios *= -1
|
||||
elif ((x_angle) > (pi / 2) and direction_ratios.z > 0) or (
|
||||
(x_angle) < (pi / 2) and direction_ratios.z < 0
|
||||
):
|
||||
# The extrusion direction is negative. If the layer_parameter is set to positive,
|
||||
# then the we change the extrusion direction.
|
||||
# then the we change the extrusion direction. And the offset direction should remain positive
|
||||
# for either direction sense, so we change it.
|
||||
offset_direction *= -1
|
||||
if layer_params["direction_sense"] == "POSITIVE":
|
||||
final_local_direction *= -1
|
||||
direction_ratios *= -1
|
||||
|
||||
# Check if extrusion direction actually changed
|
||||
final_local_norm = final_local_direction.normalized()
|
||||
local_direction_changed = (final_local_norm - current_local_norm).length > 1e-6
|
||||
|
||||
# Update extrusion properties
|
||||
extrusion.ExtrudedDirection.DirectionRatios = tuple(final_local_direction)
|
||||
extrusion.ExtrudedDirection.DirectionRatios = tuple(direction_ratios)
|
||||
extrusion.Depth = perpendicular_depth
|
||||
|
||||
if extrusion.Position or perpendicular_offset != 0:
|
||||
position = offset_direction * perpendicular_offset
|
||||
tool.Model.add_extrusion_position(extrusion, position)
|
||||
|
||||
# Adjust object rotation if extrusion direction changed
|
||||
if local_direction_changed:
|
||||
# Calculate what the NEW world direction would be with current object rotation
|
||||
expected_new_world_direction = obj_rotation @ final_local_norm
|
||||
|
||||
# The rotation needed is from expected_new_world_direction to current_world_direction
|
||||
rotation_axis = expected_new_world_direction.cross(current_world_direction)
|
||||
if rotation_axis.length > 1e-6:
|
||||
rotation_axis.normalize()
|
||||
dot_product = expected_new_world_direction.dot(current_world_direction)
|
||||
angle = acos(min(max(dot_product, -1), 1))
|
||||
|
||||
# Rotate around object's own origin
|
||||
# Decompose the matrix to get translation, rotation, scale
|
||||
translation, rotation, scale = obj.matrix_world.decompose()
|
||||
|
||||
# Create rotation matrix and convert to quaternion
|
||||
rotation_matrix = Matrix.Rotation(angle, 4, rotation_axis)
|
||||
rotation_quat = rotation_matrix.to_quaternion()
|
||||
|
||||
# Apply rotation to existing rotation (quaternion multiplication)
|
||||
new_rotation = rotation_quat @ rotation
|
||||
|
||||
# Reconstruct matrix_world with same translation, new rotation, same scale
|
||||
obj.matrix_world = (
|
||||
Matrix.Translation(translation) @ new_rotation.to_matrix().to_4x4() @ Matrix.Scale(1, 4)
|
||||
)
|
||||
bpy.context.view_layer.update()
|
||||
|
||||
bonsai.core.geometry.switch_representation(
|
||||
tool.Ifc,
|
||||
tool.Geometry,
|
||||
@@ -602,6 +522,12 @@ class ChangeExtrusionXAngle(bpy.types.Operator, tool.Ifc.Operator):
|
||||
representation=representation,
|
||||
)
|
||||
|
||||
# Object rotation
|
||||
current_z_rot = obj.rotation_euler.z
|
||||
rot_mat = mathutils.Matrix.Rotation(x_angle, 4, "X")
|
||||
obj.rotation_euler = rot_mat.to_euler()
|
||||
obj.rotation_euler.z = current_z_rot
|
||||
|
||||
if layer2_objs:
|
||||
tool.Model.recalculate_walls(layer2_objs)
|
||||
return {"FINISHED"}
|
||||
@@ -990,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()
|
||||
@@ -1096,7 +1022,6 @@ class DumbWallGenerator:
|
||||
obj=obj,
|
||||
representation=representation,
|
||||
)
|
||||
|
||||
pset = ifcopenshell.api.pset.add_pset(self.file, product=element, name="EPset_Parametric")
|
||||
ifcopenshell.api.pset.edit_pset(self.file, pset=pset, properties={"Engine": "Bonsai.DumbLayer2"})
|
||||
material = ifcopenshell.util.element.get_material(element)
|
||||
|
||||
@@ -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":
|
||||
@@ -1638,12 +1664,16 @@ class ToggleLinkVisibility(bpy.types.Operator):
|
||||
return {"FINISHED"}
|
||||
|
||||
def toggle_wireframe(self, link: "Link") -> None:
|
||||
linked_collections = self.get_linked_collections()
|
||||
|
||||
link.is_wireframe = not link.is_wireframe
|
||||
display_type = "WIRE" if link.is_wireframe else "TEXTURED"
|
||||
for collection in self.get_linked_collections():
|
||||
for collection in linked_collections:
|
||||
objs = filter(lambda obj: "IfcOpeningElement" not in obj.name, collection.all_objects)
|
||||
for obj in objs:
|
||||
obj.display_type = display_type
|
||||
if handle := tool.Project.get_link_empty_handle(link):
|
||||
handle.display_type = display_type
|
||||
|
||||
def toggle_visibility(self, link: "Link") -> None:
|
||||
linked_collections = self.get_linked_collections()
|
||||
@@ -1652,7 +1682,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 +1693,94 @@ 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
|
||||
identity_blender_matrix = np.linalg.inv(local_matrix) @ global_matrix
|
||||
if np.allclose(np.array(new_obj_matrix), identity_blender_matrix, atol=1e-5):
|
||||
link.has_transformation = False
|
||||
transformation = ",".join(map(str, np.eye(4).reshape(-1)))
|
||||
else:
|
||||
transformed_global_matrix = local_matrix @ np.array(new_obj_matrix)
|
||||
transformation = transformed_global_matrix @ np.linalg.inv(global_matrix)
|
||||
link.has_transformation = True
|
||||
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 +1973,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 +2011,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,
|
||||
@@ -2735,17 +2842,27 @@ class IFCFileHandlerOperator(bpy.types.Operator):
|
||||
# Keeping code in .invoke() as we'll probably add some
|
||||
# popup windows later.
|
||||
|
||||
def clean_up_path(path: str) -> str:
|
||||
# In Blender 4.5.6 there was a bug producing unncesseary double slash prefix
|
||||
# breaking the paths. Issue is not present in 5.0+ and presumably will be solved in 4.5.7 too.
|
||||
# https://projects.blender.org/blender/blender/issues/153822
|
||||
if bpy.app.version == (4, 5, 6):
|
||||
blender_prefix = "//"
|
||||
if path.startswith(blender_prefix):
|
||||
return path.removeprefix(blender_prefix)
|
||||
return path
|
||||
|
||||
# `files` contain only .ifc files.
|
||||
filepath = Path(self.directory)
|
||||
# If user is just drag'n'dropping a single file -> load it as a new project,
|
||||
# if they're holding ALT -> link the file/files to the current project.
|
||||
if event.alt:
|
||||
# Passing self.files directly results in TypeError.
|
||||
serialized_files = [{"name": f.name} for f in self.files]
|
||||
serialized_files = [{"name": clean_up_path(f.name)} for f in self.files]
|
||||
return bpy.ops.bim.link_ifc(directory=self.directory, files=serialized_files)
|
||||
else:
|
||||
if len(self.files) == 1:
|
||||
return bpy.ops.bim.load_project(filepath=(filepath / self.files[0].name).as_posix())
|
||||
return bpy.ops.bim.load_project(filepath=(filepath / clean_up_path(self.files[0].name)).as_posix())
|
||||
else:
|
||||
self.report(
|
||||
{"INFO"},
|
||||
@@ -3131,29 +3248,9 @@ class ImageScalingTool(bpy.types.Operator, PolylineOperator):
|
||||
|
||||
bmesh.ops.scale(bm, vec=(scale_factor, scale_factor, 1.0), verts=bm.verts)
|
||||
|
||||
if bm.loops.layers.uv:
|
||||
uv_layer = bm.loops.layers.uv.active
|
||||
|
||||
min_x = min(v.co.x for v in bm.verts)
|
||||
max_x = max(v.co.x for v in bm.verts)
|
||||
min_y = min(v.co.y for v in bm.verts)
|
||||
max_y = max(v.co.y for v in bm.verts)
|
||||
|
||||
width = max_x - min_x
|
||||
height = max_y - min_y
|
||||
|
||||
for face in bm.faces:
|
||||
for loop in face.loops:
|
||||
vert = loop.vert
|
||||
u = (vert.co.x - min_x) / width if width > 0 else 0.5
|
||||
v = (vert.co.y - min_y) / height if height > 0 else 0.5
|
||||
|
||||
u = max(0.0, min(1.0, u))
|
||||
v = max(0.0, min(1.0, v))
|
||||
loop[uv_layer].uv = (u, v)
|
||||
|
||||
bm.to_mesh(mesh)
|
||||
bm.free()
|
||||
tool.Loader.load_generated_uv_map(mesh)
|
||||
mesh.update()
|
||||
|
||||
element = tool.Ifc.get_entity(self.target_object)
|
||||
|
||||
@@ -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):
|
||||
@@ -370,7 +404,7 @@ class BIMProjectProperties(PropertyGroup):
|
||||
load_indexed_maps: BoolProperty(
|
||||
name="Load Indexed Maps",
|
||||
description="Load indexed maps (UV and color maps)",
|
||||
default=True,
|
||||
default=False, # Very slow and hackishly implemented
|
||||
)
|
||||
links: CollectionProperty(name="Links", type=Link)
|
||||
active_link_index: IntProperty(name="Active Link Index")
|
||||
@@ -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_loaded:
|
||||
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")
|
||||
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):
|
||||
|
||||
@@ -134,23 +134,55 @@ class IfcClassData:
|
||||
return [("FACE", "Face", "A planar face surface")]
|
||||
templates = [
|
||||
("EMPTY", "No Geometry", "Start with an empty object"),
|
||||
None,
|
||||
(
|
||||
"OBJ",
|
||||
"Tessellation From Object",
|
||||
"Use an object as a template to create a new tessellation",
|
||||
),
|
||||
(
|
||||
"MESH",
|
||||
"Custom Tessellation",
|
||||
"Create a basic tessellated or faceted cube",
|
||||
),
|
||||
(
|
||||
"EXTRUSION",
|
||||
"Custom Extruded Solid",
|
||||
"An extrusion from an arbitrary profile",
|
||||
),
|
||||
]
|
||||
|
||||
if 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")])
|
||||
elif ifc_class in ("IfcStairType", "IfcStairFlightType", "IfcStair", "IfcStairFlight"):
|
||||
templates.extend([None, ("STAIR", "Stair", "Parametric stair")])
|
||||
elif ifc_class in ("IfcRailingType", "IfcRailing"):
|
||||
templates.extend([None, ("RAILING", "Railing", "Parametric railing")])
|
||||
elif ifc_class in ("IfcRoofType", "IfcRoof", "IfcSlabType", "IfcSlab", "IfcCovering", "IfcCoveringType"):
|
||||
templates.extend([None, ("ROOF", "Roof", "Parametric roof with a constant pitch")])
|
||||
elif ifc_class and "Segment" in ifc_class:
|
||||
templates.extend(
|
||||
(
|
||||
None,
|
||||
(
|
||||
"FLOW_SEGMENT_RECTANGULAR",
|
||||
"Rectangular Distribution Segment",
|
||||
"Works similarly to Profile, has distribution ports",
|
||||
),
|
||||
(
|
||||
"FLOW_SEGMENT_RECTANGULAR_HOLLOW",
|
||||
"Rectangular Hollow Distribution Segment",
|
||||
"Works similarly to Profile, has distribution ports",
|
||||
),
|
||||
(
|
||||
"FLOW_SEGMENT_CIRCULAR",
|
||||
"Circular Distribution Segment",
|
||||
"Works similarly to Profile, has distribution ports",
|
||||
),
|
||||
(
|
||||
"FLOW_SEGMENT_CIRCULAR_HOLLOW",
|
||||
"Circular Hollow Distribution Segment",
|
||||
"Works similarly to Profile, has distribution ports",
|
||||
),
|
||||
)
|
||||
)
|
||||
if ifc_class and "IfcCableCarrierSegment" in ifc_class:
|
||||
templates.extend(
|
||||
(
|
||||
(
|
||||
"FLOW_SEGMENT_U_SHAPE",
|
||||
"U-Shape Distribution Segment",
|
||||
"Uses IfcUShapeProfileDef, has distribution ports",
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
if ifc_class.endswith("Type") or ifc_class.endswith("Style"):
|
||||
templates.extend(
|
||||
[
|
||||
@@ -172,38 +204,26 @@ class IfcClassData:
|
||||
),
|
||||
]
|
||||
)
|
||||
if 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")])
|
||||
elif ifc_class in ("IfcStairType", "IfcStairFlightType", "IfcStair", "IfcStairFlight"):
|
||||
templates.extend([None, ("STAIR", "Stair", "Parametric stair")])
|
||||
elif ifc_class in ("IfcRailingType", "IfcRailing"):
|
||||
templates.extend([None, ("RAILING", "Railing", "Parametric railing")])
|
||||
elif ifc_class in ("IfcRoofType", "IfcRoof", "IfcSlabType", "IfcSlab", "IfcCovering", "IfcCoveringType"):
|
||||
templates.extend([None, ("ROOF", "Roof", "Parametric roof with a constant pitch")])
|
||||
elif ifc_class and "Segment" in ifc_class:
|
||||
templates.extend(
|
||||
templates.extend(
|
||||
[
|
||||
None,
|
||||
(
|
||||
None,
|
||||
(
|
||||
"FLOW_SEGMENT_RECTANGULAR",
|
||||
"Rectangular Distribution Segment",
|
||||
"Works similarly to Profile, has distribution ports",
|
||||
),
|
||||
(
|
||||
"FLOW_SEGMENT_CIRCULAR",
|
||||
"Circular Distribution Segment",
|
||||
"Works similarly to Profile, has distribution ports",
|
||||
),
|
||||
(
|
||||
"FLOW_SEGMENT_CIRCULAR_HOLLOW",
|
||||
"Circular Hollow Distribution Segment",
|
||||
"Works similarly to Profile, has distribution ports",
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
"OBJ",
|
||||
"Tessellation From Object",
|
||||
"Use an object as a template to create a new tessellation",
|
||||
),
|
||||
(
|
||||
"MESH",
|
||||
"Custom Tessellation",
|
||||
"Create a basic tessellated or faceted cube",
|
||||
),
|
||||
(
|
||||
"EXTRUSION",
|
||||
"Custom Extruded Solid",
|
||||
"An extrusion from an arbitrary profile",
|
||||
),
|
||||
]
|
||||
)
|
||||
return templates
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -697,6 +697,26 @@ class AddElement(bpy.types.Operator, tool.Ifc.Operator):
|
||||
XDim=default_x_dim / unit_scale,
|
||||
YDim=default_y_dim / unit_scale,
|
||||
)
|
||||
elif representation_template == "FLOW_SEGMENT_RECTANGULAR_HOLLOW":
|
||||
default_x_dim = 0.4
|
||||
default_y_dim = 0.2
|
||||
default_thickness = 0.005
|
||||
default_inner_fillet_radius = 0.005
|
||||
default_outer_fillet_radius = 0.005
|
||||
profile_name = (
|
||||
f"{props.ifc_class}-{default_x_dim*1000}x{default_y_dim*1000}x{default_thickness*1000}"
|
||||
)
|
||||
profile = tool.Ifc.get().create_entity(
|
||||
"IfcRectangleHollowProfileDef",
|
||||
ProfileName=profile_name,
|
||||
ProfileType="AREA",
|
||||
XDim=default_x_dim / unit_scale,
|
||||
YDim=default_y_dim / unit_scale,
|
||||
WallThickness=default_thickness / unit_scale,
|
||||
InnerFilletRadius=default_inner_fillet_radius / unit_scale,
|
||||
OuterFilletRadius=default_outer_fillet_radius / unit_scale,
|
||||
)
|
||||
|
||||
elif representation_template == "FLOW_SEGMENT_CIRCULAR":
|
||||
default_diameter = 0.1
|
||||
profile_name = f"{props.ifc_class}-{default_diameter*1000}"
|
||||
@@ -717,6 +737,21 @@ class AddElement(bpy.types.Operator, tool.Ifc.Operator):
|
||||
Radius=(default_diameter / 2) / unit_scale,
|
||||
WallThickness=default_thickness,
|
||||
)
|
||||
elif representation_template == "FLOW_SEGMENT_U_SHAPE":
|
||||
default_depth = 0.4
|
||||
default_flange_width = 0.2
|
||||
default_web_thickness = 0.005
|
||||
default_flange_thickness = 0.005
|
||||
profile_name = f"{props.ifc_class}-{default_depth*1000}x{default_flange_width*1000}x{default_web_thickness*1000}x{default_flange_thickness*1000}"
|
||||
profile = tool.Ifc.get().create_entity(
|
||||
"IfcUShapeProfileDef",
|
||||
ProfileName=profile_name,
|
||||
ProfileType="AREA",
|
||||
Depth=default_depth / unit_scale,
|
||||
FlangeWidth=default_flange_width / unit_scale,
|
||||
WebThickness=default_web_thickness / unit_scale,
|
||||
FlangeThickness=default_flange_thickness / unit_scale,
|
||||
)
|
||||
|
||||
rel = ifcopenshell.api.material.assign_material(
|
||||
tool.Ifc.get(), products=[element], type="IfcMaterialProfileSet"
|
||||
|
||||
@@ -45,8 +45,12 @@ import bonsai.tool as tool
|
||||
from bonsai.bim.ifc import IfcStore
|
||||
from bonsai.bim.prop import StrProperty
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bonsai.bim.prop import BIMFacet
|
||||
|
||||
def draw_text_editor_header(self, context):
|
||||
|
||||
def draw_text_editor_header(self: bpy.types.TEXT_HT_header, context: bpy.types.Context) -> None:
|
||||
assert isinstance(context.space_data, bpy.types.SpaceTextEditor)
|
||||
if context.space_data.text and context.space_data.text.name.startswith("FilterQuery_"):
|
||||
layout = self.layout
|
||||
layout.separator()
|
||||
@@ -160,6 +164,7 @@ class FilterValueSuggestions(Operator):
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
assert layout
|
||||
|
||||
filter_groups = tool.Search.get_filter_groups(self.module)
|
||||
ifc_filter = filter_groups[self.group_index].filters[self.filter_index]
|
||||
@@ -202,8 +207,8 @@ class FilterValueSuggestions(Operator):
|
||||
results_are_suggestions=True,
|
||||
)
|
||||
|
||||
def get_suggestions(self, ifc_file, ifc_filter):
|
||||
suggestions = set()
|
||||
def get_suggestions(self, ifc_file: ifcopenshell.file, ifc_filter: "BIMFacet") -> set[str]:
|
||||
suggestions: set[str] = set()
|
||||
|
||||
if ifc_filter.type == "entity":
|
||||
suggestions = self.get_entity_suggestions(ifc_file)
|
||||
@@ -236,8 +241,8 @@ class FilterValueSuggestions(Operator):
|
||||
|
||||
return suggestions
|
||||
|
||||
def build_hierarchy_path(self, element):
|
||||
path = []
|
||||
def build_hierarchy_path(self, element: ifcopenshell.entity_instance) -> list[str]:
|
||||
path: list[str] = []
|
||||
current = element
|
||||
|
||||
while current:
|
||||
@@ -262,8 +267,8 @@ class FilterValueSuggestions(Operator):
|
||||
|
||||
return path
|
||||
|
||||
def get_entity_suggestions(self, ifc_file):
|
||||
all_classes = set()
|
||||
def get_entity_suggestions(self, ifc_file: ifcopenshell.file) -> set[str]:
|
||||
all_classes: set[str] = set()
|
||||
schema = tool.Ifc.schema()
|
||||
|
||||
for element_id in IfcStore.id_map.keys():
|
||||
@@ -273,11 +278,13 @@ class FilterValueSuggestions(Operator):
|
||||
|
||||
try:
|
||||
entity = schema.declaration_by_name(class_name).as_entity()
|
||||
assert entity
|
||||
current = entity
|
||||
|
||||
chain_names = [class_name]
|
||||
while current.supertype():
|
||||
supertype = current.supertype()
|
||||
assert supertype
|
||||
chain_names.insert(0, supertype.name())
|
||||
current = supertype
|
||||
|
||||
@@ -295,8 +302,8 @@ class FilterValueSuggestions(Operator):
|
||||
|
||||
return all_classes
|
||||
|
||||
def get_type_suggestions(self, ifc_file):
|
||||
suggestions = set()
|
||||
def get_type_suggestions(self, ifc_file: ifcopenshell.file) -> set[str]:
|
||||
suggestions: set[str] = set()
|
||||
for element_type in ifc_file.by_type("IfcTypeObject"):
|
||||
if element_type.Name:
|
||||
hierarchy_path = self.build_hierarchy_path(element_type)
|
||||
@@ -306,15 +313,15 @@ class FilterValueSuggestions(Operator):
|
||||
suggestions.add(element_type.Name)
|
||||
return suggestions
|
||||
|
||||
def get_material_suggestions(self, ifc_file):
|
||||
def get_material_suggestions(self, ifc_file: ifcopenshell.file) -> set[str]:
|
||||
suggestions = set()
|
||||
for material in ifc_file.by_type("IfcMaterial"):
|
||||
if material.Name:
|
||||
suggestions.add(material.Name)
|
||||
return suggestions
|
||||
|
||||
def get_location_suggestions(self, ifc_file):
|
||||
suggestions = set()
|
||||
def get_location_suggestions(self, ifc_file: ifcopenshell.file) -> set[str]:
|
||||
suggestions: set[str] = set()
|
||||
for spatial in ifc_file.by_type("IfcSpatialStructureElement"):
|
||||
if spatial.Name:
|
||||
hierarchy_path = self.build_hierarchy_path(spatial)
|
||||
@@ -324,8 +331,8 @@ class FilterValueSuggestions(Operator):
|
||||
suggestions.add(spatial.Name)
|
||||
return suggestions
|
||||
|
||||
def get_group_suggestions(self, ifc_file):
|
||||
suggestions = set()
|
||||
def get_group_suggestions(self, ifc_file: ifcopenshell.file) -> set[str]:
|
||||
suggestions: set[str] = set()
|
||||
for group in ifc_file.by_type("IfcGroup"):
|
||||
if group.Name:
|
||||
hierarchy_path = self.build_hierarchy_path(group)
|
||||
@@ -335,15 +342,15 @@ class FilterValueSuggestions(Operator):
|
||||
suggestions.add(group.Name)
|
||||
return suggestions
|
||||
|
||||
def get_classification_suggestions(self, ifc_file):
|
||||
suggestions = set()
|
||||
def get_classification_suggestions(self, ifc_file: ifcopenshell.file) -> set[str]:
|
||||
suggestions: set[str] = set()
|
||||
for ref in ifc_file.by_type("IfcClassificationReference"):
|
||||
if ref.Identification:
|
||||
suggestions.add(ref.Identification)
|
||||
return suggestions
|
||||
|
||||
def get_parent_suggestions(self, ifc_file):
|
||||
suggestions = set()
|
||||
def get_parent_suggestions(self, ifc_file: ifcopenshell.file) -> set[str]:
|
||||
suggestions: set[str] = set()
|
||||
for element_id in IfcStore.id_map.keys():
|
||||
try:
|
||||
element = ifc_file.by_id(element_id)
|
||||
@@ -371,9 +378,9 @@ class FilterValueSuggestions(Operator):
|
||||
|
||||
return suggestions
|
||||
|
||||
def get_instance_suggestions(self, ifc_file):
|
||||
suggestions = set()
|
||||
element_data = []
|
||||
def get_instance_suggestions(self, ifc_file: ifcopenshell.file) -> set[str]:
|
||||
suggestions: set[str] = set()
|
||||
element_data: list[tuple[str, str]] = []
|
||||
|
||||
for element_id in IfcStore.id_map.keys():
|
||||
try:
|
||||
@@ -392,7 +399,7 @@ class FilterValueSuggestions(Operator):
|
||||
except:
|
||||
continue
|
||||
|
||||
display_counts = {}
|
||||
display_counts: dict[str, int] = {}
|
||||
for display_str, global_id in element_data:
|
||||
display_counts[display_str] = display_counts.get(display_str, 0) + 1
|
||||
|
||||
@@ -404,8 +411,8 @@ class FilterValueSuggestions(Operator):
|
||||
|
||||
return suggestions
|
||||
|
||||
def get_property_sets(self, ifc_file):
|
||||
psets = set()
|
||||
def get_property_sets(self, ifc_file: ifcopenshell.file) -> set[str]:
|
||||
psets: set[str] = set()
|
||||
for element_id in IfcStore.id_map.keys():
|
||||
try:
|
||||
element = ifc_file.by_id(element_id)
|
||||
@@ -418,8 +425,8 @@ class FilterValueSuggestions(Operator):
|
||||
continue
|
||||
return psets
|
||||
|
||||
def get_property_names(self, ifc_file, pset_name):
|
||||
property_names = set()
|
||||
def get_property_names(self, ifc_file: ifcopenshell.file, pset_name: str) -> set[str]:
|
||||
property_names: set[str] = set()
|
||||
for element_id in IfcStore.id_map.keys():
|
||||
try:
|
||||
element = ifc_file.by_id(element_id)
|
||||
@@ -435,8 +442,8 @@ class FilterValueSuggestions(Operator):
|
||||
continue
|
||||
return property_names
|
||||
|
||||
def get_property_values(self, ifc_file, pset_name, property_name):
|
||||
property_values = set()
|
||||
def get_property_values(self, ifc_file: ifcopenshell.file, pset_name: str, property_name: str) -> set[str]:
|
||||
property_values: set[str] = set()
|
||||
for element_id in IfcStore.id_map.keys():
|
||||
try:
|
||||
element = ifc_file.by_id(element_id)
|
||||
@@ -462,8 +469,8 @@ class FilterValueSuggestions(Operator):
|
||||
continue
|
||||
return property_values
|
||||
|
||||
def get_attribute_names(self, ifc_file):
|
||||
attribute_names = set()
|
||||
def get_attribute_names(self, ifc_file: ifcopenshell.file) -> set[str]:
|
||||
attribute_names: set[str] = set()
|
||||
schema = tool.Ifc.schema()
|
||||
|
||||
ifc_classes = set()
|
||||
@@ -477,6 +484,7 @@ class FilterValueSuggestions(Operator):
|
||||
for ifc_class in ifc_classes:
|
||||
try:
|
||||
entity = schema.declaration_by_name(ifc_class).as_entity()
|
||||
assert entity
|
||||
attributes = entity.all_attributes()
|
||||
for attr in attributes:
|
||||
attribute_names.add(attr.name())
|
||||
@@ -485,8 +493,8 @@ class FilterValueSuggestions(Operator):
|
||||
|
||||
return attribute_names
|
||||
|
||||
def get_attribute_values(self, ifc_file, attribute_name):
|
||||
attribute_values = set()
|
||||
def get_attribute_values(self, ifc_file: ifcopenshell.file, attribute_name: str) -> set[str]:
|
||||
attribute_values: set[str] = set()
|
||||
|
||||
for element_id in IfcStore.id_map.keys():
|
||||
try:
|
||||
@@ -780,11 +788,10 @@ class Search(Operator):
|
||||
)
|
||||
|
||||
objs = [obj for e in results if isinstance(obj := tool.Ifc.get_object(e), bpy.types.Object)]
|
||||
for obj in objs:
|
||||
tool.Blender.set_object_selection(obj)
|
||||
if objs:
|
||||
tool.Blender.set_active_object(objs[0])
|
||||
self.report({"INFO"}, f"{len(results)} Results.")
|
||||
active_object = next(iter(objs), None)
|
||||
selection = tool.Blender.validate_object_selection(context, active_object, objs)
|
||||
tool.Blender.set_objects_selection(*selection, clear_previous_selection=False)
|
||||
self.report({"INFO"}, f"{len(results)} Results, {len(selection.selected_objects)} Objects Selected")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
|
||||
@@ -99,8 +99,7 @@ def update_name(self: "BIMContainer", context: bpy.types.Context) -> None:
|
||||
tool.Spatial.edit_container_name(element, self.name)
|
||||
if obj := tool.Ifc.get_object(element):
|
||||
tool.Root.set_object_name(obj, element)
|
||||
if collection := tool.Blender.get_object_bim_props(obj).collection:
|
||||
collection.name = f"{element.is_a()}/{element.Name or 'Unnamed'}"
|
||||
tool.Collector.assign(obj)
|
||||
bonsai.bim.handler.refresh_ui_data()
|
||||
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@ class RemoveStyle(bpy.types.Operator, tool.Ifc.Operator):
|
||||
core.remove_style(tool.Ifc, tool.Style, style=tool.Ifc.get().by_id(self.style), reload_styles_ui=True)
|
||||
|
||||
|
||||
# TODO: remove completely
|
||||
class AddStyle(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.add_style"
|
||||
bl_label = "Add Style"
|
||||
|
||||
@@ -37,6 +37,7 @@ classes = (
|
||||
operator.EditZone,
|
||||
operator.EnableEditingSystem,
|
||||
operator.EnableEditingZone,
|
||||
operator.EstablishPathDirection,
|
||||
operator.HidePorts,
|
||||
operator.LoadSystems,
|
||||
operator.LoadZones,
|
||||
|
||||
@@ -317,17 +317,19 @@ class MEPConnectElements(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_label = "Connect MEP Elements"
|
||||
bl_description = "Connects two selected elements by their closest located ports and adjusts them"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
if not len(context.selected_objects) == 2:
|
||||
cls.poll_message_set("Need to select 2 objects.")
|
||||
return False
|
||||
return True
|
||||
obj1_name: bpy.props.StringProperty(name="Object 1")
|
||||
obj2_name: bpy.props.StringProperty(name="Object 2")
|
||||
|
||||
def _execute(self, context):
|
||||
obj1 = context.active_object
|
||||
obj2 = next(o for o in context.selected_objects if o != obj1)
|
||||
if self.obj1_name and self.obj2_name:
|
||||
obj1 = bpy.data.objects.get(self.obj1_name)
|
||||
obj2 = bpy.data.objects.get(self.obj2_name)
|
||||
else:
|
||||
if not context.selected_objects or len(context.selected_objects) != 2:
|
||||
self.report({"ERROR"}, "Need to select 2 objects.")
|
||||
return {"CANCELLED"}
|
||||
obj1 = context.active_object
|
||||
obj2 = next(o for o in context.selected_objects if o != obj1)
|
||||
|
||||
tool.Model.sync_object_ifc_position(obj1)
|
||||
tool.Model.sync_object_ifc_position(obj2)
|
||||
@@ -477,6 +479,55 @@ class CycleFlowDirection(bpy.types.Operator, tool.Ifc.Operator):
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class EstablishPathDirection(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.establish_path_direction"
|
||||
bl_label = "Establish Path Direction"
|
||||
bl_description = "Propagates flow direction through connected flow segments with two ports"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
port_id: bpy.props.IntProperty()
|
||||
|
||||
def _execute(self, context):
|
||||
connected_port = tool.Ifc.get().by_id(self.port_id)
|
||||
|
||||
if not connected_port or not connected_port.is_a("IfcDistributionPort"):
|
||||
self.report({"ERROR"}, "Invalid port specified.")
|
||||
return {"CANCELLED"}
|
||||
|
||||
direction_map = {
|
||||
"SOURCE": "SINK",
|
||||
"SINK": "SOURCE",
|
||||
"SOURCEANDSINK": "SOURCEANDSINK",
|
||||
"NOTDEFINED": "NOTDEFINED",
|
||||
}
|
||||
next_element = tool.System.get_port_relating_element(connected_port)
|
||||
ports = tool.System.get_ports(next_element)
|
||||
segments_processed = 0
|
||||
while len(ports) == 2:
|
||||
if ports[0].id() == connected_port.id():
|
||||
other_port = ports[1]
|
||||
else:
|
||||
other_port = ports[0]
|
||||
|
||||
new_direction = direction_map.get(connected_port.FlowDirection, "NOTDEFINED")
|
||||
other_port.FlowDirection = new_direction
|
||||
segments_processed += 1
|
||||
|
||||
connected_port = tool.System.get_connected_port(other_port)
|
||||
if not connected_port:
|
||||
break
|
||||
connected_port.FlowDirection = direction_map.get(other_port.FlowDirection, "NOTDEFINED")
|
||||
next_element = tool.System.get_port_relating_element(connected_port)
|
||||
|
||||
if not next_element.is_a("IfcFlowSegment"):
|
||||
print(f"DEBUG: next_element is not IfcFlowSegment, stopping")
|
||||
break
|
||||
|
||||
ports = tool.System.get_ports(next_element)
|
||||
|
||||
self.report({"INFO"}, f"Established path direction through {segments_processed} flow segment(s).")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class LoadZones(bpy.types.Operator):
|
||||
bl_idname = "bim.load_zones"
|
||||
bl_label = "Load Zones"
|
||||
|
||||
@@ -296,7 +296,7 @@ class BIM_PT_port(Panel):
|
||||
element = tool.Ifc.get_entity(context.active_object)
|
||||
|
||||
row = layout.row(align=True)
|
||||
cols = [row.column(align=True) for i in range(9)]
|
||||
cols = [row.column(align=True) for i in range(10)]
|
||||
cols[3].scale_x = 1.0
|
||||
cols[6].scale_x = 1.0
|
||||
cols[8].scale_x = 1.33
|
||||
@@ -333,12 +333,16 @@ class BIM_PT_port(Panel):
|
||||
else:
|
||||
cols[7].label(text="", icon="BLANK1")
|
||||
cols[8].label(text="")
|
||||
cols[9].operator("bim.establish_path_direction", text="", icon="CON_FOLLOWPATH").port_id = (
|
||||
connected_port.id()
|
||||
)
|
||||
else:
|
||||
cols[4].label(text="", icon="BLANK1")
|
||||
cols[5].label(text="", icon="BLANK1")
|
||||
cols[6].label(text="Port is disconnected")
|
||||
cols[7].label(text="", icon="BLANK1")
|
||||
cols[8].label(text="")
|
||||
cols[9].label(text="", icon="BLANK1")
|
||||
|
||||
|
||||
class BIM_PT_flow_controls(Panel):
|
||||
|
||||
@@ -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}')
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -721,13 +725,20 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
|
||||
|
||||
chain_filter_with_set_operations: BoolProperty(
|
||||
name="NEW Filter mode: Enable chained filters with set operations",
|
||||
description="Enable chaining search filters with set operations: ADD (union: combine sets), SUBTRACT (difference: remove from set), FILTER (intersection: only elements in both sets), with autocomplete suggestions for filter values",
|
||||
description=(
|
||||
"Enable chaining search filters with set operations: "
|
||||
"ADD (union: combine sets), SUBTRACT (difference: remove from set), "
|
||||
"FILTER (intersection: only elements in both sets), with autocomplete suggestions for filter values"
|
||||
),
|
||||
default=False,
|
||||
)
|
||||
|
||||
save_metadata_blend_file: BoolProperty(
|
||||
name="Save non ifc data to metadata blend File",
|
||||
description="Save session data (window layout, settings) to a metadata blend file alongside the IFC file. This file is automatically loaded when opening the project.",
|
||||
description=(
|
||||
"Save session data (window layout, settings) to a metadata blend file alongside the IFC file. "
|
||||
"This file is automatically loaded when opening the project."
|
||||
),
|
||||
default=False,
|
||||
)
|
||||
metadata_blend_file_suffix: StringProperty(
|
||||
@@ -965,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")
|
||||
|
||||
@@ -39,12 +39,44 @@ def disable_editing_text(drawing: type[tool.Drawing], obj: bpy.types.Object) ->
|
||||
|
||||
|
||||
def edit_text(drawing: type[tool.Drawing], obj: bpy.types.Object) -> None:
|
||||
drawing.synchronise_ifc_and_text_attributes(obj)
|
||||
drawing.update_text_size_pset(obj)
|
||||
drawing.update_text_annotation_properties(obj)
|
||||
literal_attributes = drawing.export_text_literal_attributes(obj)
|
||||
drawing.edit_text_font_size(obj, drawing.export_font_size(obj))
|
||||
drawing.edit_text_wrap_length(obj, drawing.export_wrap_length(obj))
|
||||
drawing.edit_text_symbol(obj, drawing.export_symbol(obj))
|
||||
drawing.edit_text_literals(obj, literal_attributes)
|
||||
drawing.disable_editing_text(obj)
|
||||
|
||||
|
||||
def copy_text_to_selection(
|
||||
drawing: type[tool.Drawing],
|
||||
attribute: Literal["FONT_SIZE", "ALIGNMENT", "WRAP_LENGTH", "SYMBOL", "LITERALS"],
|
||||
attribute_obj: bpy.types.Object,
|
||||
apply_objs: list[bpy.types.Object],
|
||||
) -> None:
|
||||
if attribute == "FONT_SIZE":
|
||||
data = drawing.export_font_size(attribute_obj)
|
||||
elif attribute == "ALIGNMENT":
|
||||
data = drawing.export_alignment(attribute_obj)
|
||||
elif attribute == "WRAP_LENGTH":
|
||||
data = drawing.export_wrap_length(attribute_obj)
|
||||
elif attribute == "SYMBOL":
|
||||
data = drawing.export_symbol(attribute_obj)
|
||||
elif attribute == "LITERALS":
|
||||
data = drawing.export_text_literal_attributes(attribute_obj)
|
||||
for obj in apply_objs:
|
||||
if attribute == "FONT_SIZE":
|
||||
drawing.edit_text_font_size(obj, data)
|
||||
elif attribute == "ALIGNMENT":
|
||||
drawing.edit_text_alignment(obj, data)
|
||||
elif attribute == "WRAP_LENGTH":
|
||||
drawing.edit_text_wrap_length(obj, data)
|
||||
elif attribute == "SYMBOL":
|
||||
drawing.edit_text_symbol(obj, data)
|
||||
elif attribute == "LITERALS":
|
||||
drawing.edit_text_literals(obj, data)
|
||||
drawing.disable_editing_text(obj)
|
||||
|
||||
|
||||
def enable_editing_assigned_product(drawing: type[tool.Drawing], obj: bpy.types.Object) -> None:
|
||||
drawing.enable_editing_assigned_product(obj)
|
||||
drawing.import_assigned_product(obj)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -334,6 +334,11 @@ class Drawing:
|
||||
def disable_editing_sheets(cls): pass
|
||||
def disable_editing_text(cls, obj): pass
|
||||
def does_file_exist(cls, uri): pass
|
||||
def edit_text_alignment(cls, obj, alignment): pass
|
||||
def edit_text_font_size(cls, obj, size): pass
|
||||
def edit_text_literals(cls, obj, literals): pass
|
||||
def edit_text_symbol(cls, obj, symbol): pass
|
||||
def edit_text_wrap_length(cls, obj, wrap_length): pass
|
||||
def enable_editing(cls, obj): pass
|
||||
def enable_editing_assigned_product(cls, obj): pass
|
||||
def enable_editing_drawings(cls): pass
|
||||
@@ -402,10 +407,7 @@ class Drawing:
|
||||
def setup_shading_styles_path(cls, resource_path): pass
|
||||
def show_decorations(cls): pass
|
||||
def sync_object_placement(cls, obj): pass
|
||||
def synchronise_ifc_and_text_attributes(cls, obj): pass
|
||||
def update_embedded_svg_location(cls, uri, old_location, new_location): pass
|
||||
def update_text_annotation_properties(cls, obj): pass
|
||||
def update_text_size_pset(cls, obj): pass
|
||||
|
||||
|
||||
@interface
|
||||
@@ -951,11 +953,11 @@ class Spatial:
|
||||
def get_active_container(cls): pass
|
||||
def get_container(cls, element): pass
|
||||
def get_decomposed_elements(cls, container, recursive): pass
|
||||
def get_decomposition(cls, element): pass
|
||||
def get_object_matrix(cls, obj): pass
|
||||
def get_relative_object_matrix(cls, target_obj, relative_to_obj): pass
|
||||
def get_selected_product_types(cls): pass
|
||||
def get_root_element(cls, element): pass
|
||||
def get_decomposition(cls, element): pass
|
||||
def get_selected_product_types(cls): pass
|
||||
def get_selected_products(cls): pass
|
||||
def import_spatial_decomposition(cls): pass
|
||||
def import_spatial_element(cls, element, level_index): pass
|
||||
@@ -1125,15 +1127,15 @@ class Unit:
|
||||
def disable_editing_units(cls): pass
|
||||
def enable_editing_units(cls): pass
|
||||
def export_unit_attributes(cls): pass
|
||||
def get_currency_name(cls): pass
|
||||
def get_project_currency_unit(cls): pass
|
||||
def get_scene_unit_name(cls, unit_type): pass
|
||||
def get_scene_unit_si_prefix(cls, name): pass
|
||||
def import_unit_attributes(cls, unit): pass
|
||||
def import_units(cls): pass
|
||||
def is_scene_unit_metric(cls): pass
|
||||
def is_si_unit(cls, name): pass
|
||||
def is_unit_class(cls, unit, ifc_class): pass
|
||||
def set_active_unit(cls, unit): pass
|
||||
def get_project_currency_unit(cls): pass
|
||||
def get_currency_name(cls): pass
|
||||
|
||||
@interface
|
||||
class Voider:
|
||||
|
||||
@@ -36,6 +36,7 @@ from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Literal,
|
||||
NamedTuple,
|
||||
Optional,
|
||||
TypeVar,
|
||||
Union,
|
||||
@@ -49,7 +50,7 @@ import ifcopenshell.util.element
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
from ifcopenshell import entity_instance
|
||||
from mathutils import Vector
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
import bonsai.bim
|
||||
import bonsai.core.tool
|
||||
@@ -718,13 +719,18 @@ class Blender(bonsai.core.tool.Blender):
|
||||
if active_object:
|
||||
active_object.select_set(True)
|
||||
|
||||
class ObjectsSelectionArgs(NamedTuple):
|
||||
context: bpy.types.Context
|
||||
active_object: bpy.types.Object | None
|
||||
selected_objects: list[bpy.types.Object]
|
||||
|
||||
@classmethod
|
||||
def validate_object_selection(
|
||||
cls,
|
||||
context: bpy.types.Context,
|
||||
active_object: Union[bpy.types.Object, None] = None,
|
||||
selected_objects: Sequence[bpy.types.Object] = (),
|
||||
) -> tuple[bpy.types.Context, Union[bpy.types.Object, None], list[bpy.types.Object]]:
|
||||
) -> ObjectsSelectionArgs:
|
||||
"""Validate object selection and return only valid objects.
|
||||
|
||||
Can be used before ``set_objects_selection`` to avoid errors
|
||||
@@ -734,12 +740,15 @@ class Blender(bonsai.core.tool.Blender):
|
||||
assert context.view_layer
|
||||
view_layer_objects = set(context.view_layer.objects)
|
||||
|
||||
new_selected_objects = [o for o in selected_objects if cls.is_valid_data_block(o) and o in view_layer_objects]
|
||||
def is_selectable(obj: bpy.types.Object) -> bool:
|
||||
return cls.is_valid_data_block(obj) and obj in view_layer_objects
|
||||
|
||||
if active_object and not cls.is_valid_data_block(active_object):
|
||||
new_selected_objects = [o for o in selected_objects if is_selectable(o)]
|
||||
|
||||
if active_object and not is_selectable(active_object):
|
||||
active_object = None
|
||||
|
||||
return context, active_object, new_selected_objects
|
||||
return cls.ObjectsSelectionArgs(context, active_object, new_selected_objects)
|
||||
|
||||
@classmethod
|
||||
def clear_objects_selection(cls) -> None:
|
||||
@@ -2127,3 +2136,42 @@ class Blender(bonsai.core.tool.Blender):
|
||||
if cls.BLENDER_5:
|
||||
return "BLENDER_EEVEE"
|
||||
return "BLENDER_EEVEE_NEXT"
|
||||
|
||||
@classmethod
|
||||
def np_frombuffer_legacy(cls, bytedata: bytes, n: int) -> npt.NDArray[np.float32]:
|
||||
"""
|
||||
Read ``n`` float values from ``bytedata``, regardless if they are stored as ``float32`` or ``float64``.
|
||||
Needed to support .blend files saved in Blender <5.0.0.
|
||||
Also allows to work with .blend files from 5.0.0+ in older Blender versions.
|
||||
|
||||
In ``bpy.app.version >= 5.0.0`` ``mathutils`` transitioned to use ``float32`` buffer type,
|
||||
while in previous version they were using ``float64``.
|
||||
In some cases we are storing raw bytes (e.g. object transforms cheksums), so old .blend files
|
||||
might still have ``float64`` data stored.
|
||||
|
||||
See https://projects.blender.org/blender/blender/issues/149283
|
||||
"""
|
||||
if len(bytedata) == (n * 2):
|
||||
return np.frombuffer(bytedata, dtype=np.float64).astype(np.float32)
|
||||
return np.frombuffer(bytedata, dtype=np.float32)
|
||||
|
||||
@classmethod
|
||||
def np_array_legacy(cls, mathutils_type: Union[Vector, Matrix]) -> npt.NDArray[np.float32]:
|
||||
"""
|
||||
Converts ``mathutils`` types to ``np.float32`` arrays, regardless of Blender version.
|
||||
|
||||
See ``np_frombuffer_legacy`` for more details.
|
||||
"""
|
||||
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"]
|
||||
|
||||
@@ -46,6 +46,8 @@ class Collector(bonsai.core.tool.Collector):
|
||||
# Note that tool.Geometry.is_locked is only checked within the if
|
||||
# statements for efficiency as it is a slow check.
|
||||
tool.Geometry.lock_scale(obj)
|
||||
if element.is_a("IfcSlab"):
|
||||
tool.Geometry.lock_rotation(obj, x=True)
|
||||
|
||||
if element.is_a("IfcGridAxis"):
|
||||
if tool.Geometry.is_locked(element):
|
||||
|
||||
@@ -23,6 +23,7 @@ from collections.abc import Generator
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Literal, Optional, Union, assert_never
|
||||
|
||||
import aud
|
||||
import bpy
|
||||
import ifcopenshell.api
|
||||
import ifcopenshell.api.cost
|
||||
@@ -143,25 +144,18 @@ class Cost(bonsai.core.tool.Cost):
|
||||
|
||||
@classmethod
|
||||
def play_sound(cls) -> None:
|
||||
if tool.Blender.get_addon_preferences().should_play_chaching_sound:
|
||||
# Save ears for those running the tests in background mode.
|
||||
if tool.Blender.get_addon_preferences().should_play_chaching_sound and not bpy.app.background:
|
||||
cls.play_chaching_sound() # lol
|
||||
|
||||
@classmethod
|
||||
def play_chaching_sound(cls) -> None:
|
||||
# TODO: make pitch higher as costs rise
|
||||
try:
|
||||
import aud
|
||||
|
||||
device = aud.Device()
|
||||
# chaching.mp3 is by Lucish_ CC-BY-3.0 https://freesound.org/people/Lucish_/sounds/554841/
|
||||
sound = aud.Sound(tool.Blender.get_data_dir_path(filename="chaching.mp3").__str__())
|
||||
handle = device.play(sound)
|
||||
sound_buffered = aud.Sound.buffer(sound)
|
||||
handle_buffered = device.play(sound_buffered)
|
||||
handle.stop()
|
||||
handle_buffered.stop()
|
||||
except:
|
||||
pass # ah well
|
||||
device = aud.Device()
|
||||
# chaching.mp3 is by Lucish_ CC-BY-3.0 https://freesound.org/people/Lucish_/sounds/554841/
|
||||
filepath = tool.Blender.get_data_dir_path("chaching.mp3").__str__()
|
||||
sound = aud.Sound(filepath)
|
||||
device.play(sound)
|
||||
|
||||
@classmethod
|
||||
def load_cost_schedule_tree(cls) -> None:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -608,6 +608,25 @@ class Drawing(bonsai.core.tool.Drawing):
|
||||
literals.append(literal_data)
|
||||
return literals
|
||||
|
||||
@classmethod
|
||||
def export_font_size(cls, obj: bpy.types.Object) -> str:
|
||||
return float(cls.get_text_props(obj).font_size)
|
||||
|
||||
@classmethod
|
||||
def export_alignment(cls, obj: bpy.types.Object) -> str:
|
||||
props = cls.get_text_props(obj)
|
||||
if (alignment := props.align_vertical + "-" + props.align_horizontal) == "middle-middle":
|
||||
return "center"
|
||||
return alignment
|
||||
|
||||
@classmethod
|
||||
def export_wrap_length(cls, obj: bpy.types.Object) -> str:
|
||||
return cls.get_text_props(obj).newline_at
|
||||
|
||||
@classmethod
|
||||
def export_symbol(cls, obj: bpy.types.Object) -> str:
|
||||
return cls.get_text_props(obj).get_symbol()
|
||||
|
||||
@classmethod
|
||||
def create_annotation_context(
|
||||
cls, target_view: str, object_type: Optional[str] = None
|
||||
@@ -835,43 +854,14 @@ class Drawing(bonsai.core.tool.Drawing):
|
||||
return props.is_editing_sheets
|
||||
|
||||
@classmethod
|
||||
def synchronise_ifc_and_text_attributes(cls, obj: bpy.types.Object) -> None:
|
||||
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))
|
||||
|
||||
old_literals = cls.get_text_literal(obj, return_list=True)
|
||||
assert isinstance(old_literals, list)
|
||||
literals_attributes = cls.export_text_literal_attributes(obj)
|
||||
props = cls.get_text_props(obj)
|
||||
defined_ifc_ids = [l.ifc_definition_id for l in props.literals]
|
||||
ifc_file = tool.Ifc.get()
|
||||
|
||||
added_literals: list[ifcopenshell.entity_instance] = []
|
||||
new_literals: list[ifcopenshell.entity_instance] = []
|
||||
for ifc_definition_id, attributes in zip(defined_ifc_ids, literals_attributes):
|
||||
# making sure all literals from text edit exist in ifc
|
||||
if ifc_definition_id == 0:
|
||||
literal = cls.add_literal(**attributes)
|
||||
added_literals.append(literal)
|
||||
else:
|
||||
literal = ifc_file.by_id(ifc_definition_id)
|
||||
ifcopenshell.api.drawing.edit_text_literal(
|
||||
ifc_file,
|
||||
text_literal=literal,
|
||||
attributes=attributes,
|
||||
)
|
||||
new_literals.append(literal)
|
||||
|
||||
removed_literals = set(old_literals) - set(new_literals)
|
||||
|
||||
# Add new literals and keep the order as defined in text props.
|
||||
items = [i for i in rep.Items if i not in removed_literals] + added_literals
|
||||
items.sort(key=lambda x: new_literals.index(x) if x in new_literals else -1)
|
||||
rep.Items = items
|
||||
|
||||
# Remove from ifc the literals that were removed during the edit.
|
||||
for literal in removed_literals:
|
||||
ifcopenshell.util.element.remove_deep2(ifc_file, literal)
|
||||
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)
|
||||
|
||||
@classmethod
|
||||
def add_literal(cls, **attributes: str) -> ifcopenshell.entity_instance:
|
||||
@@ -1212,8 +1202,6 @@ class Drawing(bonsai.core.tool.Drawing):
|
||||
props.font_size = str(text_data["FontSize"])
|
||||
props.newline_at = text_data["Newline_At"]
|
||||
props.set_symbol(text_data["Symbol"])
|
||||
props.reverse_list = text_data["Reverse_List"]
|
||||
props.list_separator = text_data["List_Separator"]
|
||||
|
||||
@classmethod
|
||||
def import_assigned_product(cls, obj: bpy.types.Object) -> None:
|
||||
@@ -1310,7 +1298,7 @@ class Drawing(bonsai.core.tool.Drawing):
|
||||
props.should_draw_decorations = True
|
||||
|
||||
@classmethod
|
||||
def update_text_size_pset(cls, obj: bpy.types.Object) -> None:
|
||||
def edit_text_font_size(cls, obj: bpy.types.Object, font_size: float) -> None:
|
||||
"""updates pset `EPset_Annotation.Classes` value
|
||||
based on current font size from `obj.BIMTextProperties.font_size`
|
||||
"""
|
||||
@@ -1320,7 +1308,6 @@ class Drawing(bonsai.core.tool.Drawing):
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
assert element
|
||||
# updating text font size in EPset_Annotation.Classes
|
||||
font_size = float(props.font_size)
|
||||
font_size_str = next((key for key in FONT_SIZES if FONT_SIZES[key] == font_size), None)
|
||||
classes = ifcopenshell.util.element.get_pset(element, "EPset_Annotation", "Classes")
|
||||
assert isinstance(classes, Union[str, None])
|
||||
@@ -1341,34 +1328,32 @@ class Drawing(bonsai.core.tool.Drawing):
|
||||
pset = tool.Pset.get_element_pset(element, "EPset_Annotation")
|
||||
if not pset:
|
||||
pset = ifcopenshell.api.pset.add_pset(ifc_file, product=element, name="EPset_Annotation")
|
||||
ifcopenshell.api.pset.edit_pset(
|
||||
ifc_file,
|
||||
pset=pset,
|
||||
properties={"Classes": classes},
|
||||
)
|
||||
ifcopenshell.api.pset.edit_pset(ifc_file, pset=pset, properties={"Classes": classes})
|
||||
|
||||
@classmethod
|
||||
def update_text_annotation_properties(cls, obj: bpy.types.Object) -> None:
|
||||
"""Update all EPset_Annotation properties from the text props"""
|
||||
props = cls.get_text_props(obj)
|
||||
def edit_text_wrap_length(cls, obj: bpy.types.Object, wrap_length: int) -> None:
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
assert element
|
||||
|
||||
ifc_file = tool.Ifc.get()
|
||||
pset = tool.Pset.get_element_pset(element, "EPset_Annotation")
|
||||
if not pset:
|
||||
pset = ifcopenshell.api.pset.add_pset(ifc_file, product=element, name="EPset_Annotation")
|
||||
ifcopenshell.api.pset.edit_pset(ifc_file, pset=pset, properties={"Newline_At": wrap_length})
|
||||
|
||||
ifcopenshell.api.pset.edit_pset(
|
||||
ifc_file,
|
||||
pset=pset,
|
||||
properties={
|
||||
"Newline_At": int(props.newline_at),
|
||||
"Symbol": props.get_symbol(),
|
||||
"Reverse_List": props.reverse_list,
|
||||
"List_Separator": props.list_separator or "",
|
||||
},
|
||||
)
|
||||
@classmethod
|
||||
def edit_text_symbol(cls, obj: bpy.types.Object, symbol: str) -> None:
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
ifc_file = tool.Ifc.get()
|
||||
pset = tool.Pset.get_element_pset(element, "EPset_Annotation")
|
||||
if not pset:
|
||||
pset = ifcopenshell.api.pset.add_pset(ifc_file, product=element, name="EPset_Annotation")
|
||||
ifcopenshell.api.pset.edit_pset(ifc_file, pset=pset, properties={"Symbol": symbol})
|
||||
|
||||
@classmethod
|
||||
def edit_text_alignment(cls, obj: bpy.types.Object, alignment: str) -> None:
|
||||
ifc_literals = cls.get_text_literal(obj, return_list=True)
|
||||
for ifc_literal in ifc_literals or []:
|
||||
if ifc_literal.is_a("IfcTextLiteralWithExtent"):
|
||||
ifc_literal.BoxAlignment = alignment
|
||||
|
||||
# TODO below this point is highly experimental prototype code with no tests
|
||||
|
||||
@@ -2112,31 +2097,21 @@ class Drawing(bonsai.core.tool.Drawing):
|
||||
cls,
|
||||
text: str,
|
||||
product: Optional[ifcopenshell.entity_instance] = None,
|
||||
reverse_list: bool = False,
|
||||
list_separator: str = ", ",
|
||||
) -> str:
|
||||
if not product:
|
||||
return text
|
||||
if list_separator:
|
||||
list_separator = list_separator.encode().decode("unicode_escape")
|
||||
|
||||
for command in re.findall("``.*?``", text):
|
||||
for command in re.findall("``.+?``", text):
|
||||
original_command = command
|
||||
command_content = command[2:-2]
|
||||
if command_content is None or str(command_content).strip().lower() == "none":
|
||||
try:
|
||||
text = text.replace(original_command, ifcopenshell.util.selector.format(command_content, product))
|
||||
except Exception:
|
||||
text = text.replace(original_command, "")
|
||||
else:
|
||||
try:
|
||||
text = text.replace(original_command, ifcopenshell.util.selector.format(command_content, product))
|
||||
except Exception:
|
||||
text = text.replace(original_command, "")
|
||||
for variable in re.findall("{{.*?}}", text):
|
||||
value = ifcopenshell.util.selector.get_element_value(product, variable[2:-2])
|
||||
if isinstance(value, (list, tuple)):
|
||||
if reverse_list:
|
||||
value = list_separator.join(str(v) for v in reversed(value))
|
||||
else:
|
||||
value = list_separator.join(str(v) for v in value)
|
||||
value = ", ".join(str(v) for v in value)
|
||||
text = text.replace(variable, str(value))
|
||||
return text
|
||||
|
||||
|
||||
@@ -1152,9 +1152,8 @@ class Geometry(bonsai.core.tool.Geometry):
|
||||
def record_object_position(cls, obj: bpy.types.Object) -> None:
|
||||
# These are recorded separately because they have different numerical tolerances
|
||||
props = tool.Blender.get_object_bim_props(obj)
|
||||
# Explicit dtype for Blender <5.0 compatibility.
|
||||
props.location_checksum = repr(np.array(obj.matrix_world.translation, dtype=np.float32).tobytes())
|
||||
props.rotation_checksum = repr(np.array(obj.matrix_world.to_3x3(), dtype=np.float32).tobytes())
|
||||
props.location_checksum = repr(tool.Blender.np_array_legacy(obj.matrix_world.translation).tobytes())
|
||||
props.rotation_checksum = repr(tool.Blender.np_array_legacy(obj.matrix_world.to_3x3()).tobytes())
|
||||
|
||||
@classmethod
|
||||
def remove_connection(cls, connection: ifcopenshell.entity_instance) -> None:
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -115,24 +115,17 @@ class Ifc(bonsai.core.tool.Ifc):
|
||||
return True # Let's be conservative
|
||||
|
||||
# Handle both old float64 and new float32 checksums for version compatibility
|
||||
loc_checksum_bytes = eval(oprops.location_checksum)
|
||||
if len(loc_checksum_bytes) == 24: # Old format: 3 * 8 bytes (float64)
|
||||
loc_check = np.frombuffer(loc_checksum_bytes, dtype=np.float64).astype(np.float32)
|
||||
else: # New format: 3 * 4 bytes (float32)
|
||||
loc_check = np.frombuffer(loc_checksum_bytes, dtype=np.float32)
|
||||
|
||||
loc_real = np.array(obj.matrix_world.translation, dtype=np.float32).flatten()
|
||||
loc_checksum_bytes: bytes = eval(oprops.location_checksum)
|
||||
loc_check = tool.Blender.np_frombuffer_legacy(loc_checksum_bytes, 3)
|
||||
loc_real = tool.Blender.np_array_legacy(obj.matrix_world.translation)
|
||||
if not np.allclose(loc_check, loc_real, atol=1e-4): # 0.1 mm
|
||||
return True
|
||||
|
||||
# Handle both old float64 and new float32 checksums for version compatibility
|
||||
rot_checksum_bytes = eval(oprops.rotation_checksum)
|
||||
if len(rot_checksum_bytes) == 72: # Old format: 9 * 8 bytes (float64)
|
||||
rot_check = np.frombuffer(rot_checksum_bytes, dtype=np.float64).astype(np.float32).reshape(3, 3)
|
||||
else: # New format: 9 * 4 bytes (float32)
|
||||
rot_check = np.frombuffer(rot_checksum_bytes, dtype=np.float32).reshape(3, 3)
|
||||
|
||||
rot_real = np.array(obj.matrix_world.to_3x3(), dtype=np.float32)
|
||||
rot_checksum_bytes: bytes = eval(oprops.rotation_checksum)
|
||||
rot_check = tool.Blender.np_frombuffer_legacy(rot_checksum_bytes, 9)
|
||||
rot_check = rot_check.reshape(3, 3)
|
||||
rot_real = tool.Blender.np_array_legacy(obj.matrix_world.to_3x3())
|
||||
rot_dot = np.dot(rot_check, rot_real.T)
|
||||
angle_rad = np.arccos(np.clip((np.trace(rot_dot) - 1) / 2, -1, 1))
|
||||
if angle_rad > 0.0017453292519943296: # 0.1 degrees
|
||||
|
||||
@@ -64,10 +64,13 @@ class IfcGit:
|
||||
|
||||
@classmethod
|
||||
def clone_repo(cls, remote_url: str, local_folder: str) -> git.Repo:
|
||||
IfcGitRepo.repo = git.Repo.clone_from(
|
||||
repo = git.Repo.clone_from(
|
||||
url=remote_url,
|
||||
to_path=local_folder,
|
||||
)
|
||||
# Close the repo to release stale subprocess
|
||||
repo.close()
|
||||
IfcGitRepo.repo = git.Repo(local_folder)
|
||||
cls.config_info_attributes(IfcGitRepo.repo)
|
||||
return IfcGitRepo.repo
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ import numpy as np
|
||||
import numpy.typing as npt
|
||||
from ifcopenshell.util.shape_builder import np_to_4d
|
||||
from mathutils import Matrix, Vector
|
||||
from mathutils.kdtree import KDTree
|
||||
|
||||
import bonsai.bim.import_ifc
|
||||
import bonsai.core.tool
|
||||
@@ -178,7 +179,8 @@ class Loader(bonsai.core.tool.Loader):
|
||||
def surface_texture_to_dict(cls, surface_texture):
|
||||
if isinstance(surface_texture, dict):
|
||||
return surface_texture
|
||||
mappings = surface_texture.IsMappedBy or []
|
||||
# IsMappedBy is an IFC4+ inverse attribute, not available in IFC2X3.
|
||||
mappings = getattr(surface_texture, "IsMappedBy", None) or []
|
||||
surface_texture = surface_texture.get_info()
|
||||
uv_mode = None
|
||||
if mappings:
|
||||
@@ -187,7 +189,7 @@ class Loader(bonsai.core.tool.Loader):
|
||||
uv_mode = "Generated"
|
||||
elif coordinates.is_a("IfcTextureCoordinateGenerator") and coordinates.Mode == "COORD-EYE":
|
||||
uv_mode = "Camera"
|
||||
surface_texture["uv_mode"] = uv_mode or "UV"
|
||||
surface_texture["uv_mode"] = uv_mode or "Generated"
|
||||
return surface_texture
|
||||
|
||||
@classmethod
|
||||
@@ -285,6 +287,9 @@ class Loader(bonsai.core.tool.Loader):
|
||||
|
||||
for texture in textures:
|
||||
mode = texture.get("Mode", None)
|
||||
# IFC2X3 IfcImageTexture has no Mode attribute; default to DIFFUSE.
|
||||
if mode is None and texture["type"] == "IfcImageTexture":
|
||||
mode = "DIFFUSE"
|
||||
node = None
|
||||
|
||||
image_url = None
|
||||
@@ -292,7 +297,8 @@ class Loader(bonsai.core.tool.Loader):
|
||||
def get_image() -> Union[bpy.types.Image, None]:
|
||||
# TODO: orphaned textures after shader recreated?
|
||||
if texture["type"] == "IfcImageTexture":
|
||||
original_image_url = texture["URLReference"]
|
||||
# IFC2X3 uses UrlReference, IFC4+ uses URLReference.
|
||||
original_image_url = texture.get("URLReference") or texture.get("UrlReference", "")
|
||||
is_relative = not os.path.isabs(original_image_url)
|
||||
nonlocal image_url
|
||||
image_url = Path(original_image_url)
|
||||
@@ -538,6 +544,33 @@ class Loader(bonsai.core.tool.Loader):
|
||||
for colour in colours:
|
||||
cls.load_indexed_map(colour, mesh)
|
||||
|
||||
@classmethod
|
||||
def load_generated_uv_map(cls, mesh: bpy.types.Mesh) -> None:
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(mesh)
|
||||
uv_layer = bm.loops.layers.uv.active or bm.loops.layers.uv.new("UVMap")
|
||||
|
||||
all_verts = [v.co for v in bm.verts]
|
||||
if not all_verts:
|
||||
bm.free()
|
||||
return
|
||||
|
||||
min_x = min(v.x for v in all_verts)
|
||||
max_x = max(v.x for v in all_verts)
|
||||
min_y = min(v.y for v in all_verts)
|
||||
max_y = max(v.y for v in all_verts)
|
||||
width = max_x - min_x
|
||||
height = max_y - min_y
|
||||
|
||||
for face in bm.faces:
|
||||
for loop in face.loops:
|
||||
u = (loop.vert.co.x - min_x) / width if width > 0 else 0.5
|
||||
v = (loop.vert.co.y - min_y) / height if height > 0 else 0.5
|
||||
loop[uv_layer].uv = (max(0.0, min(1.0, u)), max(0.0, min(1.0, v)))
|
||||
|
||||
bm.to_mesh(mesh)
|
||||
bm.free()
|
||||
|
||||
@classmethod
|
||||
def load_indexed_map(cls, index_map: ifcopenshell.entity_instance, mesh: bpy.types.Mesh) -> None:
|
||||
"""Add data from index map as blender mesh attribute.
|
||||
@@ -562,7 +595,18 @@ class Loader(bonsai.core.tool.Loader):
|
||||
|
||||
bm_verts = np.array([v.co for v in bm.verts])
|
||||
coords_scaled = np.array(faceset.Coordinates.CoordList) * si_conversion
|
||||
coordinates_remap = [np.argmin(np.sum((bm_verts - co) ** 2, axis=1)) for co in coords_scaled]
|
||||
# See #2824. IfcIndexedColourMap is not natively handled by IfcOpenShell
|
||||
# As a result, we map IFC coords to Blender coords (highly wasteful but...)
|
||||
# coordinates_remap = [np.argmin(np.sum((bm_verts - co) ** 2, axis=1)) for co in coords_scaled]
|
||||
# Because this is O(N*M), here is a faster KDTree implementation.
|
||||
kd = KDTree(len(bm_verts))
|
||||
for i, v in enumerate(bm_verts):
|
||||
kd.insert((float(v[0]), float(v[1]), float(v[2])), i)
|
||||
kd.balance()
|
||||
coordinates_remap = np.empty(len(coords_scaled), dtype=np.int32)
|
||||
for j, co in enumerate(coords_scaled):
|
||||
_co, index, _dist = kd.find((float(co[0]), float(co[1]), float(co[2])))
|
||||
coordinates_remap[j] = index
|
||||
|
||||
# ifc indices start with 1
|
||||
remap_verts_to_blender = lambda ifc_verts: [coordinates_remap[i - 1] for i in ifc_verts]
|
||||
@@ -1024,7 +1068,7 @@ class Loader(bonsai.core.tool.Loader):
|
||||
elif material.is_a("IfcMaterialLayerSetUsage"):
|
||||
usage = material
|
||||
layer_set = material.ForLayerSet
|
||||
offset = usage.OffsetFromReferenceLine
|
||||
offset = usage.OffsetFromReferenceLine * cls.unit_scale
|
||||
sense_factor = 1 if usage.DirectionSense == "POSITIVE" else -1
|
||||
elif material.is_a("IfcMaterialLayerSet"):
|
||||
usage = None
|
||||
@@ -1033,168 +1077,61 @@ class Loader(bonsai.core.tool.Loader):
|
||||
sense_factor = 1
|
||||
else:
|
||||
return mesh
|
||||
|
||||
if len(layer_set.MaterialLayers) == 1:
|
||||
return mesh
|
||||
|
||||
# Get mesh bounds
|
||||
if len(mesh.vertices) > 0:
|
||||
z_coords = [v.co.z for v in mesh.vertices]
|
||||
mesh_z_min = min(z_coords)
|
||||
mesh_z_max = max(z_coords)
|
||||
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(mesh)
|
||||
|
||||
prev_co = None
|
||||
advance_direction = None
|
||||
|
||||
if not usage:
|
||||
sense_factor = 1
|
||||
sense_factor = 1 # Assume the extrusion vector points in the direction sense
|
||||
no = cls.get_extrusion_vector(element).normalized()
|
||||
co = Vector((0.0, 0.0, offset))
|
||||
advance_direction = no
|
||||
elif usage.LayerSetDirection == "AXIS2":
|
||||
# Get local extrusion direction
|
||||
local_extrusion = Vector([0.0, 0.0, 1.0])
|
||||
if body := ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW"):
|
||||
for item in ifcopenshell.util.representation.resolve_representation(body).Items:
|
||||
while item.is_a("IfcBooleanResult"):
|
||||
item = item.FirstOperand
|
||||
if item.is_a("IfcExtrudedAreaSolid"):
|
||||
local_extrusion = Vector(item.ExtrudedDirection.DirectionRatios).normalized()
|
||||
break
|
||||
|
||||
# Thickness direction: perpendicular to extrusion and length
|
||||
thickness_dir = local_extrusion.cross(Vector([1.0, 0.0, 0.0])).normalized()
|
||||
if thickness_dir.y < 0:
|
||||
thickness_dir = -thickness_dir
|
||||
|
||||
no = thickness_dir
|
||||
|
||||
# Find start point by projecting vertices onto thickness direction
|
||||
if len(mesh.vertices) > 0:
|
||||
projections = [Vector(v.co).dot(no) for v in mesh.vertices]
|
||||
min_proj = min(projections)
|
||||
max_proj = max(projections)
|
||||
|
||||
centroid = sum((Vector(v.co) for v in mesh.vertices), Vector()) / len(mesh.vertices)
|
||||
centroid_proj = centroid.dot(no)
|
||||
|
||||
if sense_factor == 1:
|
||||
start_proj = min_proj
|
||||
else:
|
||||
start_proj = max_proj
|
||||
|
||||
offset_dist = start_proj - centroid_proj
|
||||
co = centroid + no * offset_dist
|
||||
|
||||
actual_mesh_height = max_proj - min_proj
|
||||
else:
|
||||
co = Vector((0.0, 0.0, 0.0))
|
||||
|
||||
advance_direction = thickness_dir
|
||||
co = Vector((0.0, offset, 0.0))
|
||||
no = cls.get_extrusion_vector(element).normalized()
|
||||
no = no.cross(Vector([1.0, 0.0, 0.0]))
|
||||
elif usage.LayerSetDirection == "AXIS3":
|
||||
# AXIS3 layers go through slab thickness (local Z)
|
||||
co = Vector((0.0, 0.0, offset))
|
||||
no = cls.get_extrusion_vector(element).normalized()
|
||||
no = Vector([0.0, 0.0, 1.0])
|
||||
|
||||
# Find start point by projecting vertices onto Z direction
|
||||
if len(mesh.vertices) > 0:
|
||||
projections = [Vector(v.co).dot(no) for v in mesh.vertices]
|
||||
min_proj = min(projections)
|
||||
max_proj = max(projections)
|
||||
|
||||
centroid = sum((Vector(v.co) for v in mesh.vertices), Vector()) / len(mesh.vertices)
|
||||
centroid_proj = centroid.dot(no)
|
||||
|
||||
if sense_factor == 1:
|
||||
start_proj = min_proj
|
||||
else:
|
||||
start_proj = max_proj
|
||||
|
||||
offset = start_proj - centroid_proj
|
||||
co = centroid + no * offset
|
||||
|
||||
actual_mesh_height = max_proj - min_proj
|
||||
else:
|
||||
co = Vector((0.0, 0.0, 0.0))
|
||||
|
||||
advance_direction = no
|
||||
elif usage.LayerSetDirection == "AXIS1":
|
||||
co = Vector((0.0, 0.0, offset))
|
||||
no = cls.get_extrusion_vector(element).normalized()
|
||||
no = Vector([1.0, 0.0, 0.0])
|
||||
advance_direction = no
|
||||
|
||||
# Apply DirectionSense
|
||||
if usage and usage.LayerSetDirection == "AXIS2":
|
||||
if sense_factor == -1:
|
||||
advance_direction = -advance_direction
|
||||
test_normal = -no
|
||||
else:
|
||||
test_normal = no
|
||||
elif usage and usage.LayerSetDirection == "AXIS1":
|
||||
no = no * sense_factor
|
||||
advance_direction = advance_direction * sense_factor
|
||||
test_normal = no
|
||||
elif usage and usage.LayerSetDirection == "AXIS3":
|
||||
if sense_factor == -1:
|
||||
advance_direction = -advance_direction
|
||||
test_normal = -no
|
||||
else:
|
||||
test_normal = no
|
||||
else:
|
||||
test_normal = no
|
||||
|
||||
# Cache material styles
|
||||
no *= sense_factor
|
||||
# Cache this
|
||||
body = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Model", "Body", "MODEL_VIEW")
|
||||
styles = {}
|
||||
has_layer_styles = False
|
||||
for i, material in enumerate(mesh.materials):
|
||||
if style := tool.Ifc.get_entity(material):
|
||||
styles[style] = i
|
||||
|
||||
layer_list = list(enumerate(layer_set.MaterialLayers))
|
||||
|
||||
# Calculate scale factor
|
||||
total_layer_thickness = sum(layer.LayerThickness for _, layer in layer_list)
|
||||
|
||||
if "actual_mesh_height" not in locals():
|
||||
actual_mesh_height = mesh_z_max - mesh_z_min if len(mesh.vertices) > 0 else total_layer_thickness
|
||||
|
||||
thickness_scale = actual_mesh_height / total_layer_thickness if total_layer_thickness > 0 else 1.0
|
||||
|
||||
last_i = len(layer_set.MaterialLayers) - 1
|
||||
|
||||
for idx, (original_i, layer) in enumerate(layer_list):
|
||||
if idx != last_i:
|
||||
for i, layer in enumerate(layer_set.MaterialLayers):
|
||||
if i != last_i:
|
||||
prev_co = co.copy()
|
||||
advance_vector = advance_direction * layer.LayerThickness * thickness_scale
|
||||
co += advance_vector
|
||||
|
||||
co += no * layer.LayerThickness * cls.unit_scale
|
||||
bisect_geom = bmesh.ops.bisect_plane(
|
||||
bm, geom=bm.verts[:] + bm.edges[:] + bm.faces[:], dist=0.0001, plane_co=co, plane_no=no
|
||||
)
|
||||
bmesh.ops.duplicate(bm, geom=bisect_geom["geom_cut"])
|
||||
|
||||
if not (style := ifcopenshell.util.representation.get_material_style(layer.Material, body)):
|
||||
continue
|
||||
if (material_index := styles.get(style, None)) is None:
|
||||
material_index = len(mesh.materials)
|
||||
mesh.materials.append(tool.Ifc.get_object(style))
|
||||
|
||||
if idx == last_i:
|
||||
if i == last_i:
|
||||
for face in bisect_geom["geom"]:
|
||||
if isinstance(face, bmesh.types.BMFace):
|
||||
center = face.calc_center_median()
|
||||
if (center - co).dot(test_normal) >= 0:
|
||||
if (center - co).dot(no) >= 0:
|
||||
face.material_index = material_index
|
||||
has_layer_styles = True
|
||||
else:
|
||||
for face in bisect_geom["geom"]:
|
||||
if isinstance(face, bmesh.types.BMFace):
|
||||
center = face.calc_center_median()
|
||||
if (center - co).dot(test_normal) < 0 and (center - prev_co).dot(test_normal) >= 0:
|
||||
if (center - co).dot(no) < 0 and (center - prev_co).dot(no) >= 0:
|
||||
face.material_index = material_index
|
||||
has_layer_styles = True
|
||||
|
||||
@@ -1207,35 +1144,13 @@ class Loader(bonsai.core.tool.Loader):
|
||||
return mesh
|
||||
|
||||
@classmethod
|
||||
def get_extrusion_vector(cls, element):
|
||||
"""Get the extrusion direction in WORLD coordinates (accounting for object rotation)"""
|
||||
if body := ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW"):
|
||||
def get_extrusion_vector(cls, wall):
|
||||
if body := ifcopenshell.util.representation.get_representation(wall, "Model", "Body", "MODEL_VIEW"):
|
||||
for item in ifcopenshell.util.representation.resolve_representation(body).Items:
|
||||
while item.is_a("IfcBooleanResult"):
|
||||
item = item.FirstOperand
|
||||
if item.is_a("IfcExtrudedAreaSolid"):
|
||||
local_direction = Vector(item.ExtrudedDirection.DirectionRatios)
|
||||
|
||||
# Transform to world coordinates using object rotation
|
||||
obj = tool.Ifc.get_object(element)
|
||||
if obj:
|
||||
# Apply object rotation to get actual world direction
|
||||
world_direction = obj.matrix_world.to_3x3() @ local_direction
|
||||
return world_direction
|
||||
|
||||
return local_direction
|
||||
return Vector([0.0, 0.0, 1.0])
|
||||
|
||||
@classmethod
|
||||
def get_local_extrusion_vector(cls, element):
|
||||
"""Get the extrusion direction in LOCAL coordinates (from IFC, no object rotation)"""
|
||||
if body := ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW"):
|
||||
for item in ifcopenshell.util.representation.resolve_representation(body).Items:
|
||||
while item.is_a("IfcBooleanResult"):
|
||||
item = item.FirstOperand
|
||||
if item.is_a("IfcExtrudedAreaSolid"):
|
||||
local_direction = Vector(item.ExtrudedDirection.DirectionRatios)
|
||||
return local_direction
|
||||
return Vector(item.ExtrudedDirection.DirectionRatios)
|
||||
return Vector([0.0, 0.0, 1.0])
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -1051,6 +1051,7 @@ class Model(bonsai.core.tool.Model):
|
||||
|
||||
tool.Model.regenerate_array(obj, array_data)
|
||||
|
||||
array_pset = tool.Pset.get_element_pset(element, "BBIM_Array")
|
||||
json_data = tool.Ifc.get().createIfcText(json.dumps(array_data))
|
||||
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=array_pset, properties={"Data": json_data})
|
||||
|
||||
@@ -1063,22 +1064,17 @@ class Model(bonsai.core.tool.Model):
|
||||
cls, parent_obj: bpy.types.Object, data: list[dict[str, Any]], array_layers_to_apply: Iterable[int] = tuple()
|
||||
) -> None:
|
||||
"""`array_layers_to_apply` - list of array layer indices to apply"""
|
||||
tool.Blender.Modifier.Array.remove_constraints(tool.Ifc.get_entity(parent_obj))
|
||||
parent_element = tool.Ifc.get_entity(parent_obj)
|
||||
|
||||
if pset := ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array"):
|
||||
ifcopenshell.api.pset.remove_pset(
|
||||
tool.Ifc.get(), product=parent_element, pset=tool.Ifc.get().by_id(pset["id"])
|
||||
)
|
||||
|
||||
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
||||
obj_stack = [parent_obj]
|
||||
|
||||
for array_i, array in enumerate(data):
|
||||
# for `sync_children` we remove all previously generated children to regenerate them again
|
||||
# to assure they are in complete sync (psets, etc) with the array parent
|
||||
if array["sync_children"]:
|
||||
removed_children = set(array["children"])
|
||||
for removed_child in removed_children:
|
||||
element = tool.Ifc.get().by_guid(removed_child)
|
||||
if obj := tool.Ifc.get_object(element):
|
||||
tool.Geometry.delete_ifc_object(obj)
|
||||
array["children"].clear()
|
||||
|
||||
child_i = 0
|
||||
existing_children = set(array["children"])
|
||||
total_existing_children = len(array["children"])
|
||||
@@ -1104,22 +1100,21 @@ class Model(bonsai.core.tool.Model):
|
||||
child_obj = tool.Ifc.get_object(child_element)
|
||||
assert child_obj
|
||||
except:
|
||||
old_to_new, _ = tool.Geometry.duplicate_ifc_objects([obj])
|
||||
# TODO Is this correct to assume one child? I really
|
||||
# don't understand the linked aggregates and array
|
||||
# behaviour.
|
||||
old_to_new, _ = tool.Geometry.duplicate_ifc_objects([parent_obj])
|
||||
child_element = next(iter(old_to_new.values()))[0]
|
||||
child_obj = tool.Ifc.get_object(child_element)
|
||||
|
||||
# add child pset
|
||||
child_pset = tool.Pset.get_element_pset(child_element, "BBIM_Array")
|
||||
if child_pset:
|
||||
ifcopenshell.api.pset.edit_pset(
|
||||
tool.Ifc.get(),
|
||||
pset=child_pset,
|
||||
properties={"Data": None},
|
||||
should_purge=False,
|
||||
if not (child_pset := tool.Pset.get_element_pset(child_element, "BBIM_Array")):
|
||||
child_pset = ifcopenshell.api.pset.add_pset(
|
||||
tool.Ifc.get(), product=child_element, name="BBIM_Array"
|
||||
)
|
||||
ifcopenshell.api.pset.edit_pset(
|
||||
tool.Ifc.get(),
|
||||
pset=child_pset,
|
||||
properties={"Data": None, "Parent": parent_element.GlobalId},
|
||||
should_purge=False,
|
||||
)
|
||||
|
||||
# set child object position
|
||||
new_matrix = obj.matrix_world.copy()
|
||||
@@ -1155,6 +1150,12 @@ class Model(bonsai.core.tool.Model):
|
||||
|
||||
bpy.context.view_layer.update()
|
||||
|
||||
pset = ifcopenshell.api.pset.add_pset(tool.Ifc.get(), product=parent_element, name="BBIM_Array")
|
||||
json_data = tool.Ifc.get().createIfcText(json.dumps(data))
|
||||
ifcopenshell.api.pset.edit_pset(
|
||||
tool.Ifc.get(), pset=pset, properties={"Data": json_data, "Parent": parent_element.GlobalId}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def replace_object_ifc_representation(
|
||||
cls,
|
||||
@@ -1243,18 +1244,7 @@ class Model(bonsai.core.tool.Model):
|
||||
|
||||
height = 100
|
||||
|
||||
is_horizontal = False
|
||||
if element.is_a("IfcSlabType"):
|
||||
is_horizontal = True
|
||||
|
||||
parametric = ifcopenshell.util.element.get_psets(element).get("EPset_Parametric")
|
||||
if parametric:
|
||||
layer_set_direction = parametric.get("LayerSetDirection", None)
|
||||
if layer_set_direction == "AXIS2":
|
||||
is_horizontal = False
|
||||
elif layer_set_direction == "AXIS3":
|
||||
is_horizontal = True
|
||||
|
||||
is_horizontal = cls.get_usage_type(element) == "LAYER3"
|
||||
if is_horizontal:
|
||||
width, height = height, width
|
||||
|
||||
@@ -1265,7 +1255,7 @@ class Model(bonsai.core.tool.Model):
|
||||
del thicknesses[-1]
|
||||
for thickness in thicknesses:
|
||||
current_thickness += thickness
|
||||
if element.is_a("IfcSlabType"):
|
||||
if is_horizontal:
|
||||
y = (current_thickness / total_thickness) * height
|
||||
line = [x_offset, y_offset + y, x_offset + width, y_offset + y]
|
||||
else:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -419,11 +419,6 @@ class Unit(bonsai.core.tool.Unit):
|
||||
new.is_assigned = unit in assigned_units
|
||||
new.ifc_class = unit.is_a()
|
||||
|
||||
@classmethod
|
||||
def is_scene_unit_metric(cls) -> bool:
|
||||
assert bpy.context.scene
|
||||
return bpy.context.scene.unit_settings.system in ["METRIC", "NONE"]
|
||||
|
||||
@classmethod
|
||||
def is_unit_class(cls, unit: ifcopenshell.entity_instance, ifc_class: str) -> bool:
|
||||
return unit.is_a(ifc_class)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
[pytest]
|
||||
markers =
|
||||
aggregate
|
||||
array
|
||||
attribute
|
||||
boolean
|
||||
boundary
|
||||
|
||||
@@ -78,7 +78,8 @@ BONSAI_PATH = find_bonsai_path()
|
||||
# ---------------------------
|
||||
|
||||
# Never changed by user.
|
||||
PACKAGE_PATH = BLENDER_PATH / r"extensions/.local/lib/python3.11/site-packages"
|
||||
PYTHON_VERSION = "3.13" if BLENDER_VERSION == "5.1" else "3.11"
|
||||
PACKAGE_PATH = BLENDER_PATH / rf"extensions/.local/lib/python{PYTHON_VERSION}/site-packages"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
@@ -193,7 +194,7 @@ def main() -> None:
|
||||
print(f"Downloading {url} -> {filepath}")
|
||||
urllib.request.urlretrieve(url, filepath)
|
||||
|
||||
input("Dev environment is all set. 🎉🎉\nPress Enter to continue..." "")
|
||||
input("Dev environment is all set!! \nPress Enter to continue...")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -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()"}
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
@array
|
||||
Feature: Array
|
||||
|
||||
Scenario: Add array
|
||||
Given an empty IFC project
|
||||
And I load the demo construction library
|
||||
And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType"
|
||||
And I add the construction type
|
||||
When the object "IfcColumn/Column" is selected
|
||||
And I look at the "Array" panel
|
||||
And I see "No Array Found"
|
||||
And I click "ADD"
|
||||
Then the object "IfcColumn/Column" exists
|
||||
And I see "Column"
|
||||
And I see "1 Items"
|
||||
And I don't see "No Array Found"
|
||||
And the object "IfcColumn/Column" is at "0,0,0"
|
||||
|
||||
Scenario: Enable editing array
|
||||
Given an empty IFC project
|
||||
And I load the demo construction library
|
||||
And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType"
|
||||
And I add the construction type
|
||||
And the object "IfcColumn/Column" is selected
|
||||
And I look at the "Array" panel
|
||||
And I click "ADD"
|
||||
When I click "GREASEPENCIL"
|
||||
Then I see "Count"
|
||||
And I see "Method"
|
||||
And I don't see "1 Items"
|
||||
|
||||
Scenario: Disable editing array
|
||||
Given an empty IFC project
|
||||
And I load the demo construction library
|
||||
And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType"
|
||||
And I add the construction type
|
||||
And the object "IfcColumn/Column" is selected
|
||||
And I look at the "Array" panel
|
||||
And I click "ADD"
|
||||
And I click "GREASEPENCIL"
|
||||
When I click "CANCEL"
|
||||
Then I see "1 Items"
|
||||
And I don't see "Count"
|
||||
|
||||
Scenario: Edit array
|
||||
Given an empty IFC project
|
||||
And I load the demo construction library
|
||||
And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType"
|
||||
And I add the construction type
|
||||
And the object "IfcColumn/Column" is selected
|
||||
And I look at the "Array" panel
|
||||
And I click "ADD"
|
||||
And I click "GREASEPENCIL"
|
||||
When I set the "Count" property to "2"
|
||||
And I click "CHECKMARK"
|
||||
Then the object "IfcColumn/Column.001" is in the collection "IfcBuildingStorey/My Storey"
|
||||
And the object "IfcColumn/Column" is at "0,0,0"
|
||||
And the object "IfcColumn/Column.001" is at "0,0,0"
|
||||
And the object "IfcColumn/Column" dimensions are "0.5,0.6,3"
|
||||
|
||||
Scenario: Edit array - offset method
|
||||
Given an empty IFC project
|
||||
And I load the demo construction library
|
||||
And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType"
|
||||
And I add the construction type
|
||||
And the object "IfcColumn/Column" is selected
|
||||
And I look at the "Array" panel
|
||||
And I click "ADD"
|
||||
And I click "GREASEPENCIL"
|
||||
When I set the "Count" property to "3"
|
||||
And I set the "Method" property to "Offset"
|
||||
And I set the "X" property to "1"
|
||||
And I click "CHECKMARK"
|
||||
Then the object "IfcColumn/Column.001" is in the collection "IfcBuildingStorey/My Storey"
|
||||
And the object "IfcColumn/Column.002" is in the collection "IfcBuildingStorey/My Storey"
|
||||
And the object "IfcColumn/Column" is at "0,0,0"
|
||||
And the object "IfcColumn/Column.001" is at "1,0,0"
|
||||
And the object "IfcColumn/Column.002" is at "2,0,0"
|
||||
And the object "IfcColumn/Column" dimensions are "0.5,0.6,3"
|
||||
And the object "IfcColumn/Column.001" dimensions are "0.5,0.6,3"
|
||||
And the object "IfcColumn/Column.002" dimensions are "0.5,0.6,3"
|
||||
|
||||
Scenario: Edit array - distribute method
|
||||
Given an empty IFC project
|
||||
And I load the demo construction library
|
||||
And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType"
|
||||
And I add the construction type
|
||||
And the object "IfcColumn/Column" is selected
|
||||
And I look at the "Array" panel
|
||||
And I click "ADD"
|
||||
And I click "GREASEPENCIL"
|
||||
When I set the "Count" property to "3"
|
||||
And I set the "Method" property to "Distribute"
|
||||
And I set the "X" property to "2"
|
||||
And I click "CHECKMARK"
|
||||
Then the object "IfcColumn/Column.001" is in the collection "IfcBuildingStorey/My Storey"
|
||||
And the object "IfcColumn/Column.002" is in the collection "IfcBuildingStorey/My Storey"
|
||||
And the object "IfcColumn/Column" is at "0,0,0"
|
||||
And the object "IfcColumn/Column.001" is at "1,0,0"
|
||||
And the object "IfcColumn/Column.002" is at "2,0,0"
|
||||
And the object "IfcColumn/Column" dimensions are "0.5,0.6,3"
|
||||
And the object "IfcColumn/Column.001" dimensions are "0.5,0.6,3"
|
||||
And the object "IfcColumn/Column.002" dimensions are "0.5,0.6,3"
|
||||
|
||||
Scenario: Edit array - local space
|
||||
Given an empty IFC project
|
||||
And I load the demo construction library
|
||||
And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType"
|
||||
And I add the construction type
|
||||
And the object "IfcColumn/Column" is rotated by "0,0,90" deg
|
||||
And the object "IfcColumn/Column" is selected
|
||||
And I look at the "Array" panel
|
||||
And I click "ADD"
|
||||
And I click "GREASEPENCIL"
|
||||
When I set the "Count" property to "2"
|
||||
And I set the "Method" property to "Offset"
|
||||
And I set the "Use Local Space" property to "TRUE"
|
||||
And I set the "X" property to "1"
|
||||
And I click "CHECKMARK"
|
||||
Then the object "IfcColumn/Column" is at "0,0,0"
|
||||
And the object "IfcColumn/Column.001" is at "0,1,0"
|
||||
|
||||
Scenario: Edit array - world space
|
||||
Given an empty IFC project
|
||||
And I load the demo construction library
|
||||
And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType"
|
||||
And I add the construction type
|
||||
And the object "IfcColumn/Column" is rotated by "0,0,90" deg
|
||||
And the object "IfcColumn/Column" is selected
|
||||
And I look at the "Array" panel
|
||||
And I click "ADD"
|
||||
And I click "GREASEPENCIL"
|
||||
When I set the "Count" property to "2"
|
||||
And I set the "Method" property to "Offset"
|
||||
And I set the "Use Local Space" property to "FALSE"
|
||||
And I set the "X" property to "1"
|
||||
And I click "CHECKMARK"
|
||||
Then the object "IfcColumn/Column" is at "0,0,0"
|
||||
And the object "IfcColumn/Column.001" is at "1,0,0"
|
||||
|
||||
Scenario: Edit array - decrease count
|
||||
Given an empty IFC project
|
||||
And I load the demo construction library
|
||||
And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType"
|
||||
And I add the construction type
|
||||
And the object "IfcColumn/Column" is selected
|
||||
And I look at the "Array" panel
|
||||
And I click "ADD"
|
||||
And I click "GREASEPENCIL"
|
||||
When I set the "Count" property to "3"
|
||||
And I click "CHECKMARK"
|
||||
And I click "GREASEPENCIL"
|
||||
When I set the "Count" property to "2"
|
||||
And I click "CHECKMARK"
|
||||
Then the object "IfcColumn/Column.001" exists
|
||||
And the object "IfcColumn/Column.002" does not exist
|
||||
|
||||
Scenario: Edit array - multiple arrays
|
||||
Given an empty IFC project
|
||||
And I load the demo construction library
|
||||
And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType"
|
||||
And I add the construction type
|
||||
And the object "IfcColumn/Column" is selected
|
||||
And I look at the "Array" panel
|
||||
And I click "ADD"
|
||||
And I click "GREASEPENCIL"
|
||||
When I set the "Count" property to "3"
|
||||
And I set the "Method" property to "Offset"
|
||||
And I set the "X" property to "1"
|
||||
And I click "CHECKMARK"
|
||||
And I see "3 Items"
|
||||
And I click "ADD"
|
||||
And I click the "GREASEPENCIL" after the text "1 Items (Offset)"
|
||||
And I set the "Count" property to "2"
|
||||
And I set the "Method" property to "Offset"
|
||||
And I set the "Y" property to "1"
|
||||
And I click the "CHECKMARK" after the text "Count"
|
||||
Then I see "3 Items"
|
||||
And I see "2 Items"
|
||||
Then the object "IfcColumn/Column.001" is in the collection "IfcBuildingStorey/My Storey"
|
||||
And the object "IfcColumn/Column.002" is in the collection "IfcBuildingStorey/My Storey"
|
||||
And the object "IfcColumn/Column.003" is in the collection "IfcBuildingStorey/My Storey"
|
||||
And the object "IfcColumn/Column.004" is in the collection "IfcBuildingStorey/My Storey"
|
||||
And the object "IfcColumn/Column.005" is in the collection "IfcBuildingStorey/My Storey"
|
||||
And the object "IfcColumn/Column" is at "0,0,0"
|
||||
And the object "IfcColumn/Column.001" is at "1,0,0"
|
||||
And the object "IfcColumn/Column.002" is at "2,0,0"
|
||||
And the object "IfcColumn/Column.003" is at "0,1,0"
|
||||
And the object "IfcColumn/Column.004" is at "1,1,0"
|
||||
And the object "IfcColumn/Column.005" is at "2,1,0"
|
||||
And the object "IfcColumn/Column" dimensions are "0.5,0.6,3"
|
||||
And the object "IfcColumn/Column.001" dimensions are "0.5,0.6,3"
|
||||
And the object "IfcColumn/Column.002" dimensions are "0.5,0.6,3"
|
||||
And the object "IfcColumn/Column.003" dimensions are "0.5,0.6,3"
|
||||
And the object "IfcColumn/Column.004" dimensions are "0.5,0.6,3"
|
||||
And the object "IfcColumn/Column.005" dimensions are "0.5,0.6,3"
|
||||
|
||||
Scenario: Remove array
|
||||
Given an empty IFC project
|
||||
And I load the demo construction library
|
||||
And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType"
|
||||
And I add the construction type
|
||||
And the object "IfcColumn/Column" is selected
|
||||
And I look at the "Array" panel
|
||||
And I click "ADD"
|
||||
And I click "GREASEPENCIL"
|
||||
And I set the "Count" property to "2"
|
||||
And I set the "Method" property to "Offset"
|
||||
And I set the "X" property to "1"
|
||||
And I click "CHECKMARK"
|
||||
When I click "X"
|
||||
Then the object "IfcColumn/Column" exists
|
||||
And the object "IfcColumn/Column.001" does not exist
|
||||
|
||||
Scenario: Regenerate array
|
||||
Given an empty IFC project
|
||||
And I load the demo construction library
|
||||
And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType"
|
||||
And I add the construction type
|
||||
And the object "IfcColumn/Column" is selected
|
||||
And I look at the "Array" panel
|
||||
And I click "ADD"
|
||||
And I click "GREASEPENCIL"
|
||||
And I set the "Count" property to "2"
|
||||
And I click "CHECKMARK"
|
||||
When I click "FILE_REFRESH"
|
||||
Then the object "IfcColumn/Column" exists
|
||||
And the object "IfcColumn/Column.001" exists
|
||||
|
||||
Scenario: Apply array
|
||||
Given an empty IFC project
|
||||
And I load the demo construction library
|
||||
And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType"
|
||||
And I add the construction type
|
||||
And the object "IfcColumn/Column" is selected
|
||||
And I look at the "Array" panel
|
||||
And I click "ADD"
|
||||
And I click "GREASEPENCIL"
|
||||
And I set the "Count" property to "2"
|
||||
And I click "CHECKMARK"
|
||||
When I click "CHECKMARK"
|
||||
Then the object "IfcColumn/Column" exists
|
||||
And the object "IfcColumn/Column.001" exists
|
||||
And I see "No Array Found"
|
||||
|
||||
Scenario: Select array parent
|
||||
Given an empty IFC project
|
||||
And I load the demo construction library
|
||||
And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType"
|
||||
And I add the construction type
|
||||
And the object "IfcColumn/Column" is selected
|
||||
And I look at the "Array" panel
|
||||
And I click "ADD"
|
||||
And I click "GREASEPENCIL"
|
||||
And I set the "Count" property to "2"
|
||||
And I click "CHECKMARK"
|
||||
When the object "IfcColumn/Column.001" is selected
|
||||
And I click "OBJECT_DATA"
|
||||
Then the object "IfcColumn/Column" is selected
|
||||
And the object "IfcColumn/Column.001" is not selected
|
||||
|
||||
Scenario: Select all array objects
|
||||
Given an empty IFC project
|
||||
And I load the demo construction library
|
||||
And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType"
|
||||
And I add the construction type
|
||||
And the object "IfcColumn/Column" is selected
|
||||
And I look at the "Array" panel
|
||||
And I click "ADD"
|
||||
And I click "GREASEPENCIL"
|
||||
And I set the "Count" property to "2"
|
||||
And I click "CHECKMARK"
|
||||
When the object "IfcColumn/Column.001" is selected
|
||||
And I click "RESTRICT_SELECT_OFF"
|
||||
Then the object "IfcColumn/Column" is selected
|
||||
And the object "IfcColumn/Column.001" is selected
|
||||
@@ -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
|
||||
|
||||
@@ -74,6 +74,50 @@ Scenario: Assign container
|
||||
When I click "CHECKMARK"
|
||||
Then the object "IfcWall/Cube" is in the collection "IfcSite/My Site"
|
||||
|
||||
Scenario: Assign container - assign an aggregate which also affects children
|
||||
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 the object "IfcWall/Cube" is selected
|
||||
When I press "bim.add_aggregate"
|
||||
Then the object "IfcElementAssembly/Default_Name" exists
|
||||
And the object "IfcElementAssembly/Default_Name" is contained in object "IfcBuildingStorey/My Storey"
|
||||
When I look at the "Spatial Decomposition" panel
|
||||
And I select the "My Site" item in the "BIM_UL_containers_manager" list
|
||||
And I click "Set Default"
|
||||
And the object "IfcWall/Cube" is selected
|
||||
And I look at the "Spatial Container" panel
|
||||
And I click "GREASEPENCIL"
|
||||
And I click "CHECKMARK"
|
||||
Then the object "IfcWall/Cube" is in the collection "IfcSite/My Site"
|
||||
And the object "IfcElementAssembly/Default_Name" is in the collection "IfcSite/My Site"
|
||||
|
||||
Scenario: Assign container - assign a child which also affects parents
|
||||
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 the object "IfcWall/Cube" is selected
|
||||
When I press "bim.add_aggregate"
|
||||
Then the object "IfcElementAssembly/Default_Name" exists
|
||||
And the object "IfcElementAssembly/Default_Name" is contained in object "IfcBuildingStorey/My Storey"
|
||||
When I look at the "Spatial Decomposition" panel
|
||||
And I select the "My Site" item in the "BIM_UL_containers_manager" list
|
||||
And I click "Set Default"
|
||||
And the object "IfcElementAssembly/Default_Name" is selected
|
||||
And I look at the "Spatial Container" panel
|
||||
And I click "GREASEPENCIL"
|
||||
And I click "CHECKMARK"
|
||||
Then the object "IfcWall/Cube" is in the collection "IfcSite/My Site"
|
||||
And the object "IfcElementAssembly/Default_Name" is in the collection "IfcSite/My Site"
|
||||
|
||||
Scenario: Copy to container
|
||||
Given an empty IFC project
|
||||
And I add a cube
|
||||
|
||||
@@ -110,7 +110,7 @@ Scenario: Assign type - assign to a type with a material layer set, which automa
|
||||
And the object "IfcWall/Unnamed" has a "100" thick layered material containing the material "Default"
|
||||
And the object "IfcWall/Unnamed" dimensions are ".5,.1,.5"
|
||||
|
||||
Scenario: Assign type - assign to a different type with a material layer set
|
||||
Scenario: Assign type - assign to a different type with a LAYER2 material layer set
|
||||
Given an empty IFC project
|
||||
And I add a cube
|
||||
And the object "Cube" is selected
|
||||
@@ -149,6 +149,23 @@ Scenario: Assign type - assign to a different type with a material layer set
|
||||
Then the object "IfcWall/Cube" has a "200" thick layered material containing the material "Default"
|
||||
And the object "IfcWall/Cube" dimensions are "1,.2,1"
|
||||
|
||||
Scenario: Assign type - assign to a different type with a LAYER3 material layer set
|
||||
Given an empty IFC project
|
||||
And I load the demo construction library
|
||||
And I set "scene.BIMModelProperties.ifc_class" to "IfcSlabType"
|
||||
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcSlabType') if e.Name == 'FLR200'][0].id()"
|
||||
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
|
||||
When I press "bim.add_occurrence"
|
||||
Then the object "IfcSlab/Slab" is an "IfcSlab"
|
||||
And the object "IfcSlab/Slab" dimensions are "1,1,0.2"
|
||||
And the object "IfcSlab/Slab" bottom left corner is at "0,0,0"
|
||||
And the object "IfcSlab/Slab" top right corner is at "1,1,0.2"
|
||||
When I look at the "Type" panel
|
||||
And I click "GREASEPENCIL"
|
||||
And I set the "relating_type" property to "FLR300"
|
||||
And I click "CHECKMARK"
|
||||
Then the object "IfcSlab/Slab" dimensions are "1,1,0.3"
|
||||
|
||||
Scenario: Assign type - assign to a type with a material profile set
|
||||
Given an empty IFC project
|
||||
And I add a cube
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import bonsai.core.type as subject
|
||||
from test.core.bootstrap import ifc, model, type, geometry
|
||||
from test.core.bootstrap import geometry, ifc, model, type
|
||||
|
||||
|
||||
class TestAssignType:
|
||||
|
||||
@@ -22,119 +22,91 @@ from test.core.bootstrap import ifc, unit
|
||||
|
||||
class TestAssignSceneUnits:
|
||||
def test_creating_and_assigning_metric_units(self, ifc, unit):
|
||||
unit.is_scene_unit_metric().should_be_called().will_return(True)
|
||||
unit.get_scene_unit_si_prefix("LENGTHUNIT").should_be_called().will_return("prefix")
|
||||
unit.get_scene_unit_si_prefix("AREAUNIT").should_be_called().will_return("prefix")
|
||||
unit.get_scene_unit_si_prefix("VOLUMEUNIT").should_be_called().will_return("prefix")
|
||||
unit.add_mass_and_time_units().should_be_called().will_return(True)
|
||||
unit.get_scene_unit_si_prefix("MASSUNIT").should_be_called().will_return("KILO")
|
||||
unit.get_scene_unit_si_prefix("TIMEUNIT").should_be_called().will_return(None)
|
||||
|
||||
ifc.run("unit.add_si_unit", unit_type="LENGTHUNIT", prefix="prefix").should_be_called().will_return(
|
||||
unit.get_scene_unit_name("LENGTHUNIT").should_be_called().will_return("length_name")
|
||||
unit.get_scene_unit_name("AREAUNIT").should_be_called().will_return("area_name")
|
||||
unit.get_scene_unit_name("VOLUMEUNIT").should_be_called().will_return("volume_name")
|
||||
unit.get_scene_unit_name("MASSUNIT").should_be_called().will_return("mass_name")
|
||||
unit.get_scene_unit_name("TIMEUNIT").should_be_called().will_return("time_name")
|
||||
unit.is_si_unit("length_name").should_be_called().will_return(True)
|
||||
unit.is_si_unit("area_name").should_be_called().will_return(True)
|
||||
unit.is_si_unit("volume_name").should_be_called().will_return(True)
|
||||
unit.is_si_unit("mass_name").should_be_called().will_return(True)
|
||||
unit.is_si_unit("time_name").should_be_called().will_return(True)
|
||||
unit.get_scene_unit_si_prefix("length_name").should_be_called().will_return("length_prefix")
|
||||
unit.get_scene_unit_si_prefix("area_name").should_be_called().will_return("area_prefix")
|
||||
unit.get_scene_unit_si_prefix("volume_name").should_be_called().will_return("volume_prefix")
|
||||
unit.get_scene_unit_si_prefix("mass_name").should_be_called().will_return("mass_prefix")
|
||||
unit.get_scene_unit_si_prefix("time_name").should_be_called().will_return("time_prefix")
|
||||
ifc.run("unit.add_si_unit", unit_type="LENGTHUNIT", prefix="length_prefix").should_be_called().will_return(
|
||||
"lengthunit"
|
||||
)
|
||||
ifc.run("unit.add_si_unit", unit_type="AREAUNIT", prefix="prefix").should_be_called().will_return("areaunit")
|
||||
ifc.run("unit.add_si_unit", unit_type="VOLUMEUNIT", prefix="prefix").should_be_called().will_return(
|
||||
ifc.run("unit.add_si_unit", unit_type="AREAUNIT", prefix="area_prefix").should_be_called().will_return(
|
||||
"areaunit"
|
||||
)
|
||||
ifc.run("unit.add_si_unit", unit_type="VOLUMEUNIT", prefix="volume_prefix").should_be_called().will_return(
|
||||
"volumeunit"
|
||||
)
|
||||
ifc.run("unit.add_si_unit", unit_type="MASSUNIT", prefix="KILO").should_be_called().will_return("massunit")
|
||||
ifc.run("unit.add_si_unit", unit_type="TIMEUNIT", prefix=None).should_be_called().will_return("timeunit")
|
||||
ifc.run("unit.add_conversion_based_unit", name="degree").should_be_called().will_return("planeangleunit")
|
||||
|
||||
ifc.run("unit.add_si_unit", unit_type="MASSUNIT", prefix="mass_prefix").should_be_called().will_return(
|
||||
"massunit"
|
||||
)
|
||||
ifc.run("unit.add_si_unit", unit_type="TIMEUNIT", prefix="time_prefix").should_be_called().will_return(
|
||||
"timeunit"
|
||||
)
|
||||
ifc.run(
|
||||
"unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "planeangleunit", "massunit", "timeunit"]
|
||||
"unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "massunit", "timeunit"]
|
||||
).should_be_called()
|
||||
subject.assign_scene_units(ifc, unit)
|
||||
|
||||
def test_creating_and_assigning_metric_units_without_mass_and_time(self, ifc, unit):
|
||||
unit.is_scene_unit_metric().should_be_called().will_return(True)
|
||||
unit.get_scene_unit_si_prefix("LENGTHUNIT").should_be_called().will_return("CENTI")
|
||||
unit.get_scene_unit_si_prefix("AREAUNIT").should_be_called().will_return("CENTI")
|
||||
unit.get_scene_unit_si_prefix("VOLUMEUNIT").should_be_called().will_return("CENTI")
|
||||
unit.add_mass_and_time_units().should_be_called().will_return(False)
|
||||
ifc.run("unit.add_si_unit", unit_type="LENGTHUNIT", prefix="CENTI").should_be_called().will_return("lengthunit")
|
||||
ifc.run("unit.add_si_unit", unit_type="AREAUNIT", prefix="CENTI").should_be_called().will_return("areaunit")
|
||||
ifc.run("unit.add_si_unit", unit_type="VOLUMEUNIT", prefix="CENTI").should_be_called().will_return("volumeunit")
|
||||
ifc.run("unit.add_conversion_based_unit", name="degree").should_be_called().will_return("planeangleunit")
|
||||
ifc.run("unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "planeangleunit"]).should_be_called()
|
||||
def test_creating_and_assigning_only_specified_units(self, ifc, unit):
|
||||
unit.get_scene_unit_name("LENGTHUNIT").should_be_called().will_return("length_name")
|
||||
unit.get_scene_unit_name("AREAUNIT").should_be_called().will_return(None)
|
||||
unit.get_scene_unit_name("VOLUMEUNIT").should_be_called().will_return(None)
|
||||
unit.get_scene_unit_name("MASSUNIT").should_be_called().will_return(None)
|
||||
unit.get_scene_unit_name("TIMEUNIT").should_be_called().will_return(None)
|
||||
unit.is_si_unit("length_name").should_be_called().will_return(True)
|
||||
unit.get_scene_unit_si_prefix("length_name").should_be_called().will_return("length_prefix")
|
||||
ifc.run("unit.add_si_unit", unit_type="LENGTHUNIT", prefix="length_prefix").should_be_called().will_return(
|
||||
"lengthunit"
|
||||
)
|
||||
ifc.run("unit.assign_unit", units=["lengthunit"]).should_be_called()
|
||||
subject.assign_scene_units(ifc, unit)
|
||||
|
||||
def test_creating_and_assigning_imperial_units(self, ifc, unit):
|
||||
unit.is_scene_unit_metric().should_be_called().will_return(False)
|
||||
unit.get_scene_unit_name("LENGTHUNIT").should_be_called().will_return("foot")
|
||||
unit.get_scene_unit_name("AREAUNIT").should_be_called().will_return("square foot")
|
||||
unit.get_scene_unit_name("VOLUMEUNIT").should_be_called().will_return("cubic foot")
|
||||
unit.add_mass_and_time_units().should_be_called().will_return(True)
|
||||
unit.get_scene_unit_name("MASSUNIT").should_be_called().will_return("pound")
|
||||
unit.get_scene_unit_name("TIMEUNIT").should_be_called().will_return("SECOND")
|
||||
|
||||
ifc.run("unit.add_conversion_based_unit", name="foot").should_be_called().will_return("lengthunit")
|
||||
ifc.run("unit.add_conversion_based_unit", name="square foot").should_be_called().will_return("areaunit")
|
||||
ifc.run("unit.add_conversion_based_unit", name="cubic foot").should_be_called().will_return("volumeunit")
|
||||
ifc.run("unit.add_conversion_based_unit", name="pound").should_be_called().will_return("massunit")
|
||||
ifc.run("unit.add_si_unit", unit_type="TIMEUNIT", prefix=None).should_be_called().will_return("timeunit")
|
||||
ifc.run("unit.add_conversion_based_unit", name="degree").should_be_called().will_return("planeangleunit")
|
||||
|
||||
unit.get_scene_unit_name("LENGTHUNIT").should_be_called().will_return("length_name")
|
||||
unit.get_scene_unit_name("AREAUNIT").should_be_called().will_return("area_name")
|
||||
unit.get_scene_unit_name("VOLUMEUNIT").should_be_called().will_return("volume_name")
|
||||
unit.get_scene_unit_name("MASSUNIT").should_be_called().will_return("mass_name")
|
||||
unit.get_scene_unit_name("TIMEUNIT").should_be_called().will_return("time_name")
|
||||
unit.is_si_unit("length_name").should_be_called().will_return(False)
|
||||
unit.is_si_unit("area_name").should_be_called().will_return(False)
|
||||
unit.is_si_unit("volume_name").should_be_called().will_return(False)
|
||||
unit.is_si_unit("mass_name").should_be_called().will_return(False)
|
||||
unit.is_si_unit("time_name").should_be_called().will_return(False)
|
||||
ifc.run("unit.add_conversion_based_unit", name="length_name").should_be_called().will_return("lengthunit")
|
||||
ifc.run("unit.add_conversion_based_unit", name="area_name").should_be_called().will_return("areaunit")
|
||||
ifc.run("unit.add_conversion_based_unit", name="volume_name").should_be_called().will_return("volumeunit")
|
||||
ifc.run("unit.add_conversion_based_unit", name="mass_name").should_be_called().will_return("massunit")
|
||||
ifc.run("unit.add_conversion_based_unit", name="time_name").should_be_called().will_return("timeunit")
|
||||
ifc.run(
|
||||
"unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "planeangleunit", "massunit", "timeunit"]
|
||||
"unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "massunit", "timeunit"]
|
||||
).should_be_called()
|
||||
subject.assign_scene_units(ifc, unit)
|
||||
|
||||
def test_creating_and_assigning_imperial_units_without_mass_and_time(self, ifc, unit):
|
||||
unit.is_scene_unit_metric().should_be_called().will_return(False)
|
||||
unit.get_scene_unit_name("LENGTHUNIT").should_be_called().will_return("yard")
|
||||
unit.get_scene_unit_name("AREAUNIT").should_be_called().will_return("square yard")
|
||||
unit.get_scene_unit_name("VOLUMEUNIT").should_be_called().will_return("cubic yard")
|
||||
unit.add_mass_and_time_units().should_be_called().will_return(False)
|
||||
ifc.run("unit.add_conversion_based_unit", name="yard").should_be_called().will_return("lengthunit")
|
||||
ifc.run("unit.add_conversion_based_unit", name="square yard").should_be_called().will_return("areaunit")
|
||||
ifc.run("unit.add_conversion_based_unit", name="cubic yard").should_be_called().will_return("volumeunit")
|
||||
ifc.run("unit.add_conversion_based_unit", name="degree").should_be_called().will_return("planeangleunit")
|
||||
ifc.run("unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "planeangleunit"]).should_be_called()
|
||||
subject.assign_scene_units(ifc, unit)
|
||||
|
||||
def test_creating_metric_units_with_conversion_based_mass_and_time(self, ifc, unit):
|
||||
unit.is_scene_unit_metric().should_be_called().will_return(True)
|
||||
unit.get_scene_unit_si_prefix("LENGTHUNIT").should_be_called().will_return("MILLI")
|
||||
unit.get_scene_unit_si_prefix("AREAUNIT").should_be_called().will_return(None)
|
||||
unit.get_scene_unit_si_prefix("VOLUMEUNIT").should_be_called().will_return(None)
|
||||
unit.add_mass_and_time_units().should_be_called().will_return(True)
|
||||
unit.get_scene_unit_si_prefix("MASSUNIT").should_be_called().will_return("CONVERSION")
|
||||
unit.get_scene_unit_name("MASSUNIT").should_be_called().will_return("tonne")
|
||||
unit.get_scene_unit_si_prefix("TIMEUNIT").should_be_called().will_return("CONVERSION")
|
||||
unit.get_scene_unit_name("TIMEUNIT").should_be_called().will_return("minute")
|
||||
|
||||
ifc.run("unit.add_si_unit", unit_type="LENGTHUNIT", prefix="MILLI").should_be_called().will_return("lengthunit")
|
||||
ifc.run("unit.add_si_unit", unit_type="AREAUNIT", prefix=None).should_be_called().will_return("areaunit")
|
||||
ifc.run("unit.add_si_unit", unit_type="VOLUMEUNIT", prefix=None).should_be_called().will_return("volumeunit")
|
||||
ifc.run("unit.add_conversion_based_unit", name="tonne").should_be_called().will_return("massunit")
|
||||
ifc.run("unit.add_conversion_based_unit", name="minute").should_be_called().will_return("timeunit")
|
||||
ifc.run("unit.add_conversion_based_unit", name="degree").should_be_called().will_return("planeangleunit")
|
||||
|
||||
ifc.run(
|
||||
"unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "planeangleunit", "massunit", "timeunit"]
|
||||
).should_be_called()
|
||||
subject.assign_scene_units(ifc, unit)
|
||||
|
||||
def test_creating_imperial_units_with_conversion_based_units(self, ifc, unit):
|
||||
unit.is_scene_unit_metric().should_be_called().will_return(False)
|
||||
unit.get_scene_unit_name("LENGTHUNIT").should_be_called().will_return("inch")
|
||||
unit.get_scene_unit_name("AREAUNIT").should_be_called().will_return("square inch")
|
||||
unit.get_scene_unit_name("VOLUMEUNIT").should_be_called().will_return("cubic inch")
|
||||
unit.add_mass_and_time_units().should_be_called().will_return(True)
|
||||
unit.get_scene_unit_name("MASSUNIT").should_be_called().will_return("ounce")
|
||||
unit.get_scene_unit_name("TIMEUNIT").should_be_called().will_return("hour")
|
||||
|
||||
ifc.run("unit.add_conversion_based_unit", name="inch").should_be_called().will_return("lengthunit")
|
||||
ifc.run("unit.add_conversion_based_unit", name="square inch").should_be_called().will_return("areaunit")
|
||||
ifc.run("unit.add_conversion_based_unit", name="cubic inch").should_be_called().will_return("volumeunit")
|
||||
ifc.run("unit.add_conversion_based_unit", name="ounce").should_be_called().will_return("massunit")
|
||||
ifc.run("unit.add_conversion_based_unit", name="hour").should_be_called().will_return("timeunit")
|
||||
ifc.run("unit.add_conversion_based_unit", name="degree").should_be_called().will_return("planeangleunit")
|
||||
|
||||
ifc.run(
|
||||
"unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "planeangleunit", "massunit", "timeunit"]
|
||||
).should_be_called()
|
||||
def test_creating_both_metric_and_imperial_units(self, ifc, unit):
|
||||
# I know British doctors measure with stones so...
|
||||
unit.get_scene_unit_name("LENGTHUNIT").should_be_called().will_return("length_name")
|
||||
unit.get_scene_unit_name("AREAUNIT").should_be_called().will_return("area_name")
|
||||
unit.get_scene_unit_name("VOLUMEUNIT").should_be_called().will_return(None)
|
||||
unit.get_scene_unit_name("MASSUNIT").should_be_called().will_return(None)
|
||||
unit.get_scene_unit_name("TIMEUNIT").should_be_called().will_return(None)
|
||||
unit.is_si_unit("length_name").should_be_called().will_return(True)
|
||||
unit.is_si_unit("area_name").should_be_called().will_return(False)
|
||||
unit.get_scene_unit_si_prefix("length_name").should_be_called().will_return("length_prefix")
|
||||
ifc.run("unit.add_si_unit", unit_type="LENGTHUNIT", prefix="length_prefix").should_be_called().will_return(
|
||||
"lengthunit"
|
||||
)
|
||||
ifc.run("unit.add_conversion_based_unit", name="area_name").should_be_called().will_return("areaunit")
|
||||
ifc.run("unit.assign_unit", units=["lengthunit", "areaunit"]).should_be_called()
|
||||
subject.assign_scene_units(ifc, unit)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -904,7 +934,7 @@ class TestAddReferenceImage(NewFile):
|
||||
bpy.ops.bim.save_project(filepath=str(ifc_path), should_save_as=True)
|
||||
|
||||
filepath = Path("test/files/image.jpg").absolute()
|
||||
bpy.ops.bim.add_reference_image(filepath=str(filepath))
|
||||
bpy.ops.bim.add_reference_image(filepath=str(filepath), x_length=3.53982, y_length=2.0)
|
||||
|
||||
obj = bpy.data.objects["IfcAnnotation/image"]
|
||||
assert obj is not None
|
||||
@@ -927,72 +957,4 @@ class TestAddReferenceImage(NewFile):
|
||||
assert texture_filepath == filepath
|
||||
|
||||
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()
|
||||
assert len(uv_node.outputs["UV"].links[:]) == 1
|
||||
|
||||
@@ -323,8 +323,8 @@ class TestRecordObjectPosition(NewFile):
|
||||
obj = bpy.data.objects.new("Object", None)
|
||||
props = tool.Blender.get_object_bim_props(obj)
|
||||
subject.record_object_position(obj)
|
||||
assert props.location_checksum == repr(np.array(obj.matrix_world.translation, dtype=np.float32).tobytes())
|
||||
assert props.rotation_checksum == repr(np.array(obj.matrix_world.to_3x3(), dtype=np.float32).tobytes())
|
||||
assert props.location_checksum == repr(tool.Blender.np_array_legacy(obj.matrix_world.translation).tobytes())
|
||||
assert props.rotation_checksum == repr(tool.Blender.np_array_legacy(obj.matrix_world.to_3x3()).tobytes())
|
||||
|
||||
|
||||
class TestRemoveConnection(NewFile):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -434,18 +434,6 @@ class TestImportUnits(NewFile):
|
||||
assert second_prop.ifc_class == "IfcSIUnit"
|
||||
|
||||
|
||||
class TestIsSceneUnitMetric(NewFile):
|
||||
def test_run(self):
|
||||
assert bpy.context.scene
|
||||
props = bpy.context.scene.unit_settings
|
||||
props.system = "METRIC"
|
||||
assert subject.is_scene_unit_metric() is True
|
||||
props.system = "IMPERIAL"
|
||||
assert subject.is_scene_unit_metric() is False
|
||||
props.system = "NONE"
|
||||
assert subject.is_scene_unit_metric() is True
|
||||
|
||||
|
||||
class TestIsUnitClass:
|
||||
def test_run(self):
|
||||
ifc = ifcopenshell.file()
|
||||
|
||||
@@ -31,6 +31,9 @@
|
||||
#include "ifcparse/Ifc2x3.h"
|
||||
#include "ifcparse/IfcHierarchyHelper.h"
|
||||
|
||||
#include <boost/math/constants/constants.hpp>
|
||||
const static double PI = boost::math::constants::pi<double>();
|
||||
|
||||
typedef std::string S;
|
||||
typedef IfcParse::IfcGlobalId guid;
|
||||
boost::none_t const null = boost::none;
|
||||
@@ -41,7 +44,7 @@ void create_curve_rebar(IfcHierarchyHelper<IfcSchema>& file)
|
||||
int R = 3 * dia;
|
||||
int length = 12 * dia;
|
||||
|
||||
double crossSectionarea = M_PI * (dia / 2) * 2;
|
||||
double crossSectionarea = PI * dia * dia / 4;
|
||||
IfcSchema::IfcReinforcingBar* rebar = new IfcSchema::IfcReinforcingBar(
|
||||
guid(), 0, S("test"), null,
|
||||
null, 0, 0,
|
||||
|
||||
@@ -15,6 +15,7 @@ target_link_libraries(
|
||||
IfcGeom
|
||||
IfcParse
|
||||
Serializers
|
||||
${kernel_libraries}
|
||||
${OpenCASCADE_LIBRARIES}
|
||||
${Boost_LIBRARIES}
|
||||
${HDF5_LIBRARIES}
|
||||
|
||||
@@ -452,6 +452,12 @@ namespace ifcopenshell {
|
||||
static constexpr bool defaultvalue = false;
|
||||
};
|
||||
|
||||
struct MakeVolume : public SettingBase<MakeVolume, bool> {
|
||||
static constexpr const char* const name = "make-volume";
|
||||
static constexpr const char* const description = "Try to isolate and fix a valid volume from non-manifold elements prior to opening subtraction";
|
||||
static constexpr bool defaultvalue = false;
|
||||
};
|
||||
|
||||
struct DeferProcessingFirstElement : public SettingBase<DeferProcessingFirstElement, bool, true> {
|
||||
static constexpr const char* const name = "defer-processing-first-element";
|
||||
static constexpr const char* const description = "Don't process first element in Iterator::initialize call()";
|
||||
@@ -647,7 +653,7 @@ namespace ifcopenshell {
|
||||
};
|
||||
|
||||
class Settings : public SettingsContainer<
|
||||
std::tuple<MesherLinearDeflection, MesherAngularDeflection, ReorientShells, LengthUnit, PlaneUnit, Precision, OutputDimensionality, LayersetFirst, DisableBooleanResult, NoWireIntersectionCheck, NoWireIntersectionTolerance, PrecisionFactor, DebugBooleanOperations, BooleanAttempt2d, SurfaceColour, WeldVertices, UseWorldCoords, UnifyShapes, UseMaterialNames, ConvertBackUnits, ContextIds, ContextTypes, ContextIdentifiers, IteratorOutput, DisableOpeningSubtractions, ApplyDefaultMaterials, DontEmitNormals, GenerateUvs, ApplyLayerSets, UseElementHierarchy, ValidateQuantities, EdgeArrows, BuildingLocalPlacement, SiteLocalPlacement, ForceSpaceTransparency, CircleSegments, CgalSmoothAngleDegrees, KeepBoundingBoxes, ComputeCurvature, FunctionStepType, FunctionStepParam, NoParallelMapping, PermissiveShapeReuse, ModelOffset, ModelRotation, TriangulationType, CgalEmitOriginalEdges, OcctNoCleanTriangulation, CacheShapes, DeferProcessingFirstElement, MaxOffset, MaxOffsetDeviation, ApplyOffset>
|
||||
std::tuple<MesherLinearDeflection, MesherAngularDeflection, ReorientShells, LengthUnit, PlaneUnit, Precision, OutputDimensionality, LayersetFirst, DisableBooleanResult, NoWireIntersectionCheck, NoWireIntersectionTolerance, PrecisionFactor, DebugBooleanOperations, BooleanAttempt2d, SurfaceColour, WeldVertices, UseWorldCoords, UnifyShapes, UseMaterialNames, ConvertBackUnits, ContextIds, ContextTypes, ContextIdentifiers, IteratorOutput, DisableOpeningSubtractions, ApplyDefaultMaterials, DontEmitNormals, GenerateUvs, ApplyLayerSets, UseElementHierarchy, ValidateQuantities, EdgeArrows, BuildingLocalPlacement, SiteLocalPlacement, ForceSpaceTransparency, CircleSegments, CgalSmoothAngleDegrees, KeepBoundingBoxes, ComputeCurvature, FunctionStepType, FunctionStepParam, NoParallelMapping, PermissiveShapeReuse, ModelOffset, ModelRotation, TriangulationType, CgalEmitOriginalEdges, OcctNoCleanTriangulation, CacheShapes, DeferProcessingFirstElement, MaxOffset, MaxOffsetDeviation, ApplyOffset, MakeVolume>
|
||||
>
|
||||
{};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
#include "base_utils.h"
|
||||
|
||||
#include <BRepPrimAPI_MakeRevol.hxx>
|
||||
#include <BOPAlgo_MakerVolume.hxx>
|
||||
|
||||
namespace {
|
||||
struct opening_sorter {
|
||||
@@ -132,11 +133,24 @@ bool IfcGeom::OpenCascadeKernel::convert_openings(const IfcUtil::IfcBaseEntity*
|
||||
parts.push_back(it3_shape);
|
||||
}
|
||||
|
||||
for (auto& entity_part : parts) {
|
||||
for (auto entity_part : parts) {
|
||||
bool is_manifold = util::is_manifold(entity_part);
|
||||
|
||||
if (!is_manifold) {
|
||||
Logger::Warning("Non-manifold first operand");
|
||||
if (settings_.get<settings::MakeVolume>().get()) {
|
||||
BOPAlgo_MakerVolume mv;
|
||||
mv.AddArgument(entity_part);
|
||||
mv.SetAvoidInternalShapes(true);
|
||||
try {
|
||||
mv.Perform();
|
||||
entity_part = mv.Shape();
|
||||
Logger::Warning("Sucessfully detected exterior volume to non-manifold first operand");
|
||||
} catch (const Standard_Failure& e) {
|
||||
Logger::Warning("MakeVolume failed: " + std::string(e.GetMessageString()), entity);
|
||||
}
|
||||
} else {
|
||||
Logger::Warning("Non-manifold first operand, use --make-volume to try and make manifold");
|
||||
}
|
||||
}
|
||||
|
||||
TopoDS_Shape entity_part_result;
|
||||
|
||||
@@ -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;
|
||||
@@ -135,13 +165,6 @@ bool OpenCascadeKernel::convert(const taxonomy::loft::ptr loft, TopoDS_Shape& re
|
||||
std::vector<TopoDS_Shape> shps(loft->children.size());
|
||||
std::vector<std::vector<std::set<std::string>>> all_tags;
|
||||
|
||||
|
||||
std::ostringstream oss;
|
||||
loft->children[0]->print(oss);
|
||||
loft->children[1]->print(oss);
|
||||
auto s = oss.str();
|
||||
std::wcout << s.c_str() << std::endl;
|
||||
|
||||
// First convert all taxonomy items to TopoDS_Wire/Face
|
||||
for (auto it = loft->children.begin(); it < loft->children.end(); ++it) {
|
||||
auto i = std::distance(loft->children.begin(), it);
|
||||
@@ -237,6 +260,18 @@ bool OpenCascadeKernel::convert(const taxonomy::loft::ptr loft, TopoDS_Shape& re
|
||||
return true;
|
||||
*/
|
||||
|
||||
if (shps.size() < 2) {
|
||||
Logger::Error("Not enough sections to loft");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (shps[0].ShapeType() == TopAbs_FACE) {
|
||||
// When processing a sectioned *surface* there are no
|
||||
// begin and end caps that need to be added.
|
||||
BB.Add(comp, shps.front().Reversed());
|
||||
BB.Add(comp, shps.back());
|
||||
}
|
||||
|
||||
// @todo this approach is
|
||||
// potentially incorrect as there is no guarantee that the wires for
|
||||
// subsequently placed profiles are traversed from an equivalent start vertex.
|
||||
@@ -262,18 +297,6 @@ bool OpenCascadeKernel::convert(const taxonomy::loft::ptr loft, TopoDS_Shape& re
|
||||
ws[0][i] = TopoDS::Wire(*fa[i]);
|
||||
}
|
||||
}
|
||||
if (it->ShapeType() == TopAbs_FACE) {
|
||||
// When processing a sectioned *surface* there are no
|
||||
// begin and end caps that need to be added.
|
||||
if (it == shps.begin()) {
|
||||
// faces.Append(shps[0]);
|
||||
BB.Add(comp, shps[0]);
|
||||
}
|
||||
if (jt == shps.end() - 1) {
|
||||
// faces.Append(shps[1]);
|
||||
BB.Add(comp, shps[1]);
|
||||
}
|
||||
}
|
||||
|
||||
if (!all_tags.empty()) {
|
||||
// only open profiles have tags for now, so there is only one wire, no inner wires
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -252,6 +252,10 @@ nest formulas, for example ``concat(title("foo"), lower("Bar"))`` will produce
|
||||
"``number({{value}}[, {{decimal_separator}}[, {{thousands_separator}}]])``", "``number(1234.56, "","", ""."")``", "``1.234,56``", "Formats {{value}} with an optional custom {{decimal_separator}} and {{thousands_separator}}. The default separators are ``.`` and ``,``."
|
||||
"``metric_length({{value}}, {{precision}}, {{decimals}})``", "``metric_length(3.123, 0.1, 2)``", "``3.10``", "Rounds ``{{value}}`` to the nearest ``{{precision}}`` then displays using a certain amount of decimal places."
|
||||
"``imperial_length({{value}}, {{precision}}, {{input_unit}}, {{output_unit}}, {{suppress_zero_inches}})``", "``imperial_length(3.0, 4, ""foot"", ""foot"", true)`` OR ``imperial_length(3.0, 4, ""foot"", ""foot"", false)``", "``3'`` OR ``3' - 0""``", "The ``{{value}}`` may be specified either as ``foot`` or ``inch`` depending on ``{{input_unit}}``. The ``{{value}}`` is then rounded to the nearest ``1/{{precision}}`` inch, then formatted using fractional feet and inches if ``{{output_unit}}`` is set to ``foot``, or just inches if ``{{output_unit}}`` is set to ``inch``. When ``{{suppress_zero_inches}}`` is ``true`` (default), measurements with zero inches will omit the inch portion (e.g., ``3'`` instead of ``3' - 0""``)."
|
||||
"``sort({{values}})``", "``sort({{mats.Name}})``", "``Name1, Name2``", "Sorts a list of items."
|
||||
"``reverse({{values}})``", "``reverse({{mats.Name}})``", "``Name2, Name1``", "Reverses a list of items."
|
||||
"``join({{separator}}, {{values}})``", "``join(""-"", {{mats.Name}})``", "``Name1-Name2``", "Joins a list of items with a custom separator. By default, all lists a rendered as comma separated."
|
||||
"``{{value1}}[+-*/]{{value2}}``", "``{{z}}+3``", "``5``", "Does arithmetic. Typical operators such as +, -, \*, and / are allowed and can be mixed with other variables and formatting functions."
|
||||
|
||||
When using queries in an IfcAnnotation tag surround with backticks.
|
||||
Examples:
|
||||
|
||||
+6
-1
@@ -28,7 +28,7 @@ def _create_offset_curve_representation(
|
||||
file: ifcopenshell.file, alignment: entity_instance, offsets: Sequence[entity_instance]
|
||||
) -> None:
|
||||
"""
|
||||
Create geometric representation for the alignment based on an IfcPolyline
|
||||
Create geometric representation for the alignment based on an IfcOffsetByDistances curve
|
||||
|
||||
:param alignment: The alignment for which the representation is being created
|
||||
:return: None
|
||||
@@ -36,6 +36,11 @@ 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):
|
||||
raise TypeError(f"Expected {expected_type} but got {offset.is_a()}")
|
||||
|
||||
axis_geom_subcontext = ifcopenshell.api.alignment.get_axis_subcontext(file)
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ def create_as_offset_curve(
|
||||
|
||||
:param file:
|
||||
:param name: name assigned to IfcAlignment.Name
|
||||
:param offsets: offsets from the basis curve that defines the offset curve, expected to be IfcOffsetCurveByDistances.
|
||||
:param offsets: offsets from the basis curve that defines the offset curve, expected to be IfcPointByDistanceExpression.
|
||||
:param start_station: station value at the start of the alignment
|
||||
:return: Returns an IfcAlignment
|
||||
"""
|
||||
|
||||
@@ -100,15 +100,10 @@ class Usecase:
|
||||
size = self.convert_si_to_unit(1)
|
||||
points = ((0.0, 0.0), (size, 0.0), (size, size), (0.0, size), (0.0, 0.0))
|
||||
if self.polyline:
|
||||
# Only scale polyline if we have actual slope
|
||||
if self.x_angle and abs(self.x_angle) > 1e-6:
|
||||
points = [
|
||||
(self.convert_si_to_unit(p[0]), self.convert_si_to_unit(p[1] * abs(1 / cos(self.x_angle))))
|
||||
for p in self.polyline
|
||||
]
|
||||
else:
|
||||
points = [(self.convert_si_to_unit(p[0]), self.convert_si_to_unit(p[1])) for p in self.polyline]
|
||||
|
||||
points = [
|
||||
(self.convert_si_to_unit(p[0]), self.convert_si_to_unit(p[1] * abs(1 / cos(self.x_angle))))
|
||||
for p in self.polyline
|
||||
]
|
||||
if self.file.schema == "IFC2X3":
|
||||
curve = self.file.createIfcPolyline([self.file.createIfcCartesianPoint(p) for p in points])
|
||||
else:
|
||||
@@ -119,23 +114,21 @@ class Usecase:
|
||||
else:
|
||||
direction_ratios = (0.0, 0.0, 1.0)
|
||||
|
||||
offset_direction = direction_ratios # offset direction doesn't change if direction_sense is negative
|
||||
extrusion_direction = self.file.createIfcDirection(direction_ratios)
|
||||
if self.direction_sense == "NEGATIVE":
|
||||
direction_ratios = tuple(-n for n in direction_ratios)
|
||||
extrusion_direction = self.file.createIfcDirection(direction_ratios)
|
||||
|
||||
# Calculate depth based on extrusion angle
|
||||
extrusion_angle = abs(self.x_angle) if self.x_angle else 0
|
||||
if extrusion_angle > 1e-6:
|
||||
perpendicular_depth = self.convert_si_to_unit(self.depth) * abs(1 / cos(extrusion_angle))
|
||||
perpendicular_offset = self.convert_si_to_unit(self.offset) * abs(1 / cos(extrusion_angle))
|
||||
else:
|
||||
perpendicular_depth = self.convert_si_to_unit(self.depth)
|
||||
perpendicular_offset = self.convert_si_to_unit(self.offset)
|
||||
|
||||
perpendicular_offset = self.convert_si_to_unit(self.offset) * abs(1 / cos(self.x_angle))
|
||||
perpendicular_depth = self.convert_si_to_unit(self.depth) * abs(1 / cos(self.x_angle))
|
||||
position = None
|
||||
# default position for IFC2X3 where .Position is not optional
|
||||
if self.file.schema == "IFC2X3" or self.offset != 0:
|
||||
position_vector = (
|
||||
direction_ratios[0] * perpendicular_offset,
|
||||
direction_ratios[1] * perpendicular_offset,
|
||||
direction_ratios[2] * perpendicular_offset,
|
||||
offset_direction[0] * perpendicular_offset,
|
||||
offset_direction[1] * perpendicular_offset,
|
||||
offset_direction[2] * perpendicular_offset,
|
||||
)
|
||||
position = self.file.createIfcAxis2Placement3D(
|
||||
self.file.createIfcCartesianPoint(position_vector),
|
||||
|
||||
@@ -85,6 +85,7 @@ class Usecase:
|
||||
def create_item(self) -> ifcopenshell.entity_instance:
|
||||
length = self.convert_si_to_unit(self.settings["length"])
|
||||
thickness = self.convert_si_to_unit(self.settings["thickness"])
|
||||
thickness *= 1 / cos(self.settings["x_angle"])
|
||||
if self.settings["direction_sense"] == "NEGATIVE":
|
||||
thickness *= -1
|
||||
points = (
|
||||
@@ -112,7 +113,7 @@ class Usecase:
|
||||
self.file.createIfcDirection((1.0, 0.0, 0.0)),
|
||||
),
|
||||
extrusion_direction,
|
||||
self.convert_si_to_unit(self.settings["height"]),
|
||||
self.convert_si_to_unit(self.settings["height"]) * abs(1 / cos(self.settings["x_angle"])),
|
||||
)
|
||||
if self.settings["booleans"]:
|
||||
extrusion = self.apply_booleans(extrusion)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@ format_grammar = lark.Lark(
|
||||
| mul_div "*" function -> multiply
|
||||
| mul_div "/" function -> divide
|
||||
|
||||
function: round | number | int | format_length | lower | upper | title | concat | substr | variable | ESCAPED_STRING | NUMBER | "(" expression ")"
|
||||
function: round | number | int | format_length | lower | upper | title | concat | substr | sort | reverse | join | variable | ESCAPED_STRING | SIGNED_NUMBER | "(" expression ")"
|
||||
|
||||
variable: "{{" query_path "}}"
|
||||
query_path: /[^}]+/
|
||||
@@ -160,6 +160,9 @@ format_grammar = lark.Lark(
|
||||
title: "title(" expression ")"
|
||||
concat: "concat(" expression ("," expression)* ")"
|
||||
substr: "substr(" expression "," SIGNED_INT ["," SIGNED_INT] ")"
|
||||
sort: "sort(" expression ")"
|
||||
reverse: "reverse(" expression ")"
|
||||
join: "join(" ESCAPED_STRING "," expression ")"
|
||||
boolean: TRUE | FALSE
|
||||
|
||||
TRUE: "true" | "True" | "TRUE"
|
||||
@@ -201,6 +204,8 @@ class FormatTransformer(lark.Transformer):
|
||||
self.element = element
|
||||
|
||||
def start(self, args):
|
||||
if isinstance(args[0], (list, tuple)):
|
||||
return ", ".join(args[0])
|
||||
return args[0]
|
||||
|
||||
def expression(self, args):
|
||||
@@ -208,18 +213,11 @@ class FormatTransformer(lark.Transformer):
|
||||
|
||||
def variable(self, args):
|
||||
"""Handle variable substitution like {{z}} or {{Pset_Wall.FireRating}}"""
|
||||
if self.element is None:
|
||||
return "0" # Default value if no element context
|
||||
|
||||
query_path = args[0]
|
||||
try:
|
||||
value = get_element_value(self.element, query_path)
|
||||
if value is None:
|
||||
return "0"
|
||||
# Convert to string for further processing
|
||||
return str(value)
|
||||
except:
|
||||
return "0" # Return default on error
|
||||
if self.element:
|
||||
try:
|
||||
return get_element_value(self.element, args[0])
|
||||
except:
|
||||
pass
|
||||
|
||||
def query_path(self, args):
|
||||
"""Extract the query path from variable"""
|
||||
@@ -301,6 +299,15 @@ class FormatTransformer(lark.Transformer):
|
||||
elif len(args) == 2:
|
||||
return str(args[0])[int(args[1]) :]
|
||||
|
||||
def sort(self, args):
|
||||
return sorted(args[0])
|
||||
|
||||
def reverse(self, args):
|
||||
return list(reversed(args[0]))
|
||||
|
||||
def join(self, args):
|
||||
return args[0].join(args[1])
|
||||
|
||||
def boolean(self, args):
|
||||
if not args:
|
||||
return True
|
||||
|
||||
@@ -35,7 +35,7 @@ import ifcopenshell.util.selector as subject
|
||||
import test.bootstrap
|
||||
|
||||
|
||||
class TestFormat:
|
||||
class TestFormat(test.bootstrap.IFC4):
|
||||
def test_no_formatting(self):
|
||||
assert subject.format("123") == "123"
|
||||
assert subject.format('"123"') == "123"
|
||||
@@ -54,6 +54,7 @@ class TestFormat:
|
||||
def test_number_formatting(self):
|
||||
assert subject.format("round(123, 5)") == "125"
|
||||
assert subject.format('round("123", 5)') == "125"
|
||||
assert subject.format('round(-123, 5)') == "-125"
|
||||
assert subject.format("int(123.123)") == "123"
|
||||
assert subject.format("int(123)") == "123"
|
||||
assert subject.format("number(123)") == "123"
|
||||
@@ -77,6 +78,38 @@ class TestFormat:
|
||||
assert subject.format('imperial_length(3.0, 4, "foot", "foot", false)') == "3' - 0\""
|
||||
assert subject.format('imperial_length(3.0, 4, "foot", "foot", False)') == "3' - 0\""
|
||||
|
||||
def test_variable_formatting(self):
|
||||
assert subject.format('{{undefined}}') is None
|
||||
assert subject.format('upper({{undefined}})') == "NONE"
|
||||
assert subject.format('int({{undefined}})') == "0"
|
||||
element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
|
||||
assert subject.format('{{undefined}}', element) is None
|
||||
assert subject.format('{{class}}', element) == "IfcWall"
|
||||
assert subject.format('{{ class }}', element) == "IfcWall"
|
||||
assert subject.format('upper({{ class }})', element) == "IFCWALL"
|
||||
|
||||
def test_list_formatting(self):
|
||||
element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
|
||||
material = ifcopenshell.api.material.add_material(self.file, name="CON01")
|
||||
material2 = ifcopenshell.api.material.add_material(self.file, name="CON03")
|
||||
material3 = ifcopenshell.api.material.add_material(self.file, name="CON02")
|
||||
material_set = ifcopenshell.api.material.add_material_set(self.file, set_type="IfcMaterialLayerSet")
|
||||
layer = ifcopenshell.api.material.add_layer(self.file, layer_set=material_set, material=material)
|
||||
layer = ifcopenshell.api.material.add_layer(self.file, layer_set=material_set, material=material2)
|
||||
layer = ifcopenshell.api.material.add_layer(self.file, layer_set=material_set, material=material3)
|
||||
ifcopenshell.api.material.assign_material(self.file, products=[element], material=material_set)
|
||||
assert subject.format('{{materials.Name}}', element) == "CON01, CON03, CON02"
|
||||
assert subject.format('sort({{materials.Name}})', element) == "CON01, CON02, CON03"
|
||||
assert subject.format('reverse({{materials.Name}})', element) == "CON02, CON03, CON01"
|
||||
assert subject.format('join("-", {{materials.Name}})', element) == "CON01-CON03-CON02"
|
||||
|
||||
def test_expressions(self):
|
||||
assert subject.format('2+3') == "5"
|
||||
assert subject.format('-2+3') == "1"
|
||||
assert subject.format('2-3') == "-1"
|
||||
assert subject.format('3*2') == "6"
|
||||
assert subject.format('3/2') == "1.5"
|
||||
|
||||
|
||||
class TestGetElementValue(test.bootstrap.IFC4):
|
||||
def test_selecting_an_elements_class_or_id_using_a_query(self):
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user