mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-08 08:51:35 +00:00
Compare commits
104 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f229ad5fd0 | |||
| 0ed96d32dd | |||
| f96526195d | |||
| fd29481d65 | |||
| 24b48497f0 | |||
| 2b9822f141 | |||
| 3d4db13fc1 | |||
| 31b571322b | |||
| cef5d41b54 | |||
| d7b2358d58 | |||
| cafe5aa7f7 | |||
| de34e73451 | |||
| 5721a8b602 | |||
| f820214500 | |||
| dae913e06a | |||
| 1a849395c2 | |||
| 611273a20a | |||
| db68195310 | |||
| 29079e8cba | |||
| 6bf4259298 | |||
| 7cd40bf8cb | |||
| 8b8f78095d | |||
| 23ba9e4db0 | |||
| 1aec991f08 | |||
| bddf9b85f8 | |||
| f679c63a18 | |||
| e6cc0e7813 | |||
| cf2acfc649 | |||
| 60ebb99fda | |||
| ab96add772 | |||
| d8de623086 | |||
| a3f2e061fb | |||
| f51a4673db | |||
| 75b8d4f218 | |||
| b8136d4762 | |||
| ecde429d36 | |||
| 1771b34449 | |||
| 41469acbc8 | |||
| 547b22199f | |||
| 94c15213f6 | |||
| fca258fb07 | |||
| bcfad8d96d | |||
| c026dd3b6e | |||
| d0f20371bd | |||
| 7b6e82a9cc | |||
| 286c69429d | |||
| 9c22dc6013 | |||
| 0acbd5ffad | |||
| 77f0c43314 | |||
| c1a9708504 | |||
| bcf6f6197d | |||
| 1778656bd5 | |||
| 54f450129c | |||
| 9fad1569c7 | |||
| 722c374fa6 | |||
| 91b6c3e256 | |||
| ea3f71b4e0 | |||
| 1a8b17e235 | |||
| 2bad861122 | |||
| 6c1fb3b01a | |||
| f5be64af6c | |||
| b043dd4d04 | |||
| ffd2466321 | |||
| e9241fd812 | |||
| 48d45451ac | |||
| 3b7cf6e865 | |||
| 6038373ee5 | |||
| 3d7de87b46 | |||
| cb113ae8da | |||
| 26280d24fe | |||
| 30551cb288 | |||
| 3bf0edeca2 | |||
| 3590b08e68 | |||
| fd902d88fb | |||
| aa5f5120e0 | |||
| 81986bcbfb | |||
| ec6c268cdb | |||
| 64003fd5ef | |||
| 58d07bace4 | |||
| 3b718bc58d | |||
| 18c035ea77 | |||
| f2e2e324b1 | |||
| 069dbbd8c2 | |||
| 3385872e8b | |||
| f0b27a0910 | |||
| 888158570a | |||
| c03156b5cd | |||
| 05bf2b82d2 | |||
| 7bdc1b6a75 | |||
| 03de69814a | |||
| 4dc6a0f2bc | |||
| c33509364c | |||
| 47f058341b | |||
| a51b2c587c | |||
| 845a13ba83 | |||
| 530841967e | |||
| c36e7badae | |||
| 515fe8d2ef | |||
| b8d3d1d105 | |||
| e95da857d6 | |||
| d587d1ac11 | |||
| 6117417b89 | |||
| 0398584c69 | |||
| 0c69f85d5e |
@@ -53,7 +53,7 @@ jobs:
|
||||
python ../nix/cache_dependencies.py unpack
|
||||
|
||||
- name: ccache
|
||||
uses: hendrikmuhs/ccache-action@v1.2.20
|
||||
uses: hendrikmuhs/ccache-action@v1.2.22
|
||||
with:
|
||||
key: mac-${{ matrix.arch }}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
python ../IfcOpenShell/nix/cache_dependencies.py unpack
|
||||
|
||||
- name: ccache
|
||||
uses: hendrikmuhs/ccache-action@v1.2.20
|
||||
uses: hendrikmuhs/ccache-action@v1.2.22
|
||||
with:
|
||||
key: ubuntu-22.04-${{ runner.arch }}
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
python3 ../nix/cache_dependencies.py unpack
|
||||
|
||||
- name: ccache
|
||||
uses: hendrikmuhs/ccache-action@v1.2.20
|
||||
uses: hendrikmuhs/ccache-action@v1.2.22
|
||||
with:
|
||||
key: ubuntu-22.04-${{ runner.arch }}-rockylinux9
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
python3 ../nix/cache_dependencies.py unpack
|
||||
|
||||
- name: ccache
|
||||
uses: hendrikmuhs/ccache-action@v1.2.20
|
||||
uses: hendrikmuhs/ccache-action@v1.2.22
|
||||
with:
|
||||
key: ubuntu-22.04-${{ runner.arch }}-rockylinux9
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
}
|
||||
|
||||
- name: ccache
|
||||
uses: hendrikmuhs/ccache-action@v1.2.20
|
||||
uses: hendrikmuhs/ccache-action@v1.2.22
|
||||
with:
|
||||
key: win-${{ matrix.arch }}
|
||||
# Windows ccache needs ~1GB
|
||||
|
||||
@@ -7,6 +7,9 @@ on:
|
||||
jobs:
|
||||
lint-formatting:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
MIN_IOS_PY_VERSION: "3.10"
|
||||
MIN_BLENDER_PY_VERSION: "3.11"
|
||||
steps:
|
||||
- name: Action - checkout repository
|
||||
uses: actions/checkout@v6
|
||||
@@ -14,12 +17,12 @@ jobs:
|
||||
- name: Action - install python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.10"
|
||||
python-version: ${{ env.MIN_IOS_PY_VERSION }}
|
||||
|
||||
- name: Action - install python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
python-version: ${{ env.MIN_BLENDER_PY_VERSION }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
@@ -35,8 +38,8 @@ jobs:
|
||||
ERROR=0
|
||||
# Using 2 Python versions - one minimum required for IfcOpenShell
|
||||
# and other that's used by Blender currently.
|
||||
python3.10 -W error -m compileall -q src/ifcopenshell-python || ERROR=1
|
||||
python3.11 -W error -m compileall -q src/bonsai || ERROR=1
|
||||
python${{ env.MIN_IOS_PY_VERSION }} -W error -m compileall -q src/ifcopenshell-python || ERROR=1
|
||||
python${{ env.MIN_BLENDER_PY_VERSION }} -W error -m compileall -q src/bonsai || ERROR=1
|
||||
exit $ERROR
|
||||
continue-on-error: true
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
if: |
|
||||
github.repository == 'IfcOpenShell/IfcOpenShell'
|
||||
steps:
|
||||
- uses: mamba-org/setup-micromamba@v2 # https://github.com/mamba-org/setup-micromamba
|
||||
- uses: mamba-org/setup-micromamba@v3 # https://github.com/mamba-org/setup-micromamba
|
||||
with:
|
||||
environment-name: test-env
|
||||
create-args: >-
|
||||
|
||||
@@ -84,7 +84,7 @@ jobs:
|
||||
run: |
|
||||
curl -L https://github.com/phracker/MacOSX-SDKs/releases/download/11.3/MacOSX10.13.sdk.tar.xz | tar -xvJf - -C /Users/runner/work/
|
||||
|
||||
- uses: mamba-org/setup-micromamba@v2 # https://github.com/mamba-org/setup-micromamba
|
||||
- uses: mamba-org/setup-micromamba@v3 # https://github.com/mamba-org/setup-micromamba
|
||||
with:
|
||||
environment-name: test-env
|
||||
create-args: >-
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
|
||||
-
|
||||
name: ccache
|
||||
uses: hendrikmuhs/ccache-action@v1.2.20
|
||||
uses: hendrikmuhs/ccache-action@v1.2.22
|
||||
|
||||
-
|
||||
name: Build ifcopenshell
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
pyver: [py39, py310, py311, py312, py313, py314]
|
||||
pyver: [py310, py311, py312, py313, py314]
|
||||
config:
|
||||
- {
|
||||
name: "Windows 64bit",
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
pyver: [py39, py310, py311, py312, py313, py314]
|
||||
pyver: [py310, py311, py312, py313, py314]
|
||||
config:
|
||||
- {
|
||||
name: "Windows 64bit",
|
||||
|
||||
@@ -79,7 +79,7 @@ jobs:
|
||||
libhdf5-dev libcgal-dev libeigen3-dev
|
||||
|
||||
- name: ccache
|
||||
uses: hendrikmuhs/ccache-action@v1.2.20
|
||||
uses: hendrikmuhs/ccache-action@v1.2.22
|
||||
with:
|
||||
key: ubuntu-22.04-${{ runner.arch }}
|
||||
|
||||
@@ -254,6 +254,7 @@ jobs:
|
||||
cd ../ifcpatch && make test || ERROR=1
|
||||
pip install -e ../ifctester --no-deps
|
||||
cd ../ifctester && make test || ERROR=1
|
||||
make build-ids-docs || ERROR=1
|
||||
# Run mathutils related tests at the end to ensure no other code is relying on mathutils.
|
||||
cd ../ifcopenshell-python
|
||||
pip install mathutils
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
submodules: recursive
|
||||
fetch-depth: 0
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v5
|
||||
uses: actions/configure-pages@v6
|
||||
- name: Upload static files as artifact
|
||||
id: deployment
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
@@ -47,4 +47,4 @@ jobs:
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
uses: actions/deploy-pages@v5
|
||||
|
||||
+10
-3
@@ -5,6 +5,8 @@
|
||||
/_installed-vs*-x*/
|
||||
/build/
|
||||
/src/examples/build/
|
||||
# ifctester docs output
|
||||
/src/ifctester/test/build/
|
||||
|
||||
# output directories
|
||||
/cmake/out/
|
||||
@@ -12,6 +14,7 @@
|
||||
/src/ifcmax/out/
|
||||
/src/ifcwrap/out/
|
||||
/src/qtviewer/out/
|
||||
/src/ifctester/webapp/public/pyodide/
|
||||
|
||||
/win/BuildDepsCache*.txt
|
||||
|
||||
@@ -80,10 +83,14 @@ src/ifcopenshell-python/test/build
|
||||
# bonsai i18n
|
||||
src/bonsai/bonsai/translations.py
|
||||
|
||||
# bonsai test temp files
|
||||
# bonsai external dependencies (cloned for just ty checks)
|
||||
src/bonsai/external_dependencies/
|
||||
|
||||
# bonsai test temp/cache files
|
||||
src/bonsai/test/files/temp
|
||||
src/bonsai/test/files/basic.ifc.cache.blend
|
||||
src/bonsai/test/files/basic.ifc.cache.sqlite
|
||||
src/bonsai/test/files/*.cache.blend
|
||||
src/bonsai/test/files/*.cache.json
|
||||
src/bonsai/test/files/*.cache.sqlite
|
||||
|
||||
# bonsai data
|
||||
src/bonsai/bonsai/bim/data/build/
|
||||
|
||||
@@ -13,6 +13,7 @@ import hashlib
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import subprocess
|
||||
from typing import NoReturn
|
||||
from urllib import request
|
||||
|
||||
@@ -20,7 +21,7 @@ from github import Github
|
||||
|
||||
|
||||
def get_repo_tag_names() -> list[str]:
|
||||
git_return = os.popen("git tag -l").read()
|
||||
git_return = subprocess.check_output("git tag -l", text=True)
|
||||
tag_names = [tag_name for tag_name in git_return.split("\n") if tag_name]
|
||||
print(f"{len(tag_names)} tag_names found in repo")
|
||||
return tag_names
|
||||
@@ -78,6 +79,10 @@ def get_release_zip(tag: str) -> tuple[str, str]:
|
||||
raise Exception(f"Couldn't find the release matching '{python_version}' and '{TARGET_OS}' in tag '{tag}'.")
|
||||
|
||||
|
||||
def run(command: str) -> None:
|
||||
subprocess.check_output(command)
|
||||
|
||||
|
||||
start = datetime.datetime.now()
|
||||
|
||||
URL_CHOCO_PACKAGE = "https://community.chocolatey.org/packages/blender"
|
||||
@@ -97,7 +102,7 @@ should_release = False
|
||||
target_release_tag = ""
|
||||
TARGET_OS = "windows-x64"
|
||||
|
||||
git_status = os.popen("git status").read()
|
||||
git_status = subprocess.check_output("git status", text=True)
|
||||
print(git_status)
|
||||
|
||||
for tag_name in get_repo_tag_names():
|
||||
@@ -147,7 +152,7 @@ blenderbim_build_version = target_release_tag.replace("blenderbim-", "")
|
||||
|
||||
# url_blenderbim_py3x_win_zip
|
||||
release_zip_file_name, url_blenderbim_py3x_win_zip = get_release_zip(target_release_tag)
|
||||
os.popen(f"wget {url_blenderbim_py3x_win_zip} --no-verbose").read()
|
||||
subprocess.check_call(f"wget {url_blenderbim_py3x_win_zip} --no-verbose")
|
||||
|
||||
# sha256sum_blenderbim_py310_win_zip
|
||||
sha256sum_blenderbim_py3x_win_zip = get_file_sha256_hash(release_zip_file_name)
|
||||
@@ -201,13 +206,13 @@ print("[INFO] inserting dynamic chocolatey package parameters successful")
|
||||
print("\n_____ build choco.exe with mono")
|
||||
|
||||
choco_version = "1.1.0"
|
||||
os.popen(f"wget https://github.com/chocolatey/choco/archive/refs/tags/{choco_version}.tar.gz --quiet").read()
|
||||
os.popen(f"tar -xzf {choco_version}.tar.gz").read()
|
||||
run(f"wget https://github.com/chocolatey/choco/archive/refs/tags/{choco_version}.tar.gz --quiet")
|
||||
run(f"tar -xzf {choco_version}.tar.gz")
|
||||
print("choco tar unpack successful")
|
||||
os.chdir("choco-1.1.0")
|
||||
os.popen("./build.sh").read()
|
||||
run("./build.sh")
|
||||
|
||||
os.popen("cp -r build_output/chocolatey /opt/chocolatey").read()
|
||||
run("cp -r build_output/chocolatey /opt/chocolatey")
|
||||
os.chdir(BLENDERBIM_DIR)
|
||||
|
||||
if pathlib.Path("/opt/chocolatey/choco.exe").exists():
|
||||
@@ -215,11 +220,15 @@ if pathlib.Path("/opt/chocolatey/choco.exe").exists():
|
||||
|
||||
print("\n_____ build choco pack")
|
||||
|
||||
os.popen("mono /opt/chocolatey/choco.exe pack --allow-unofficial").read()
|
||||
os.popen('mono /opt/chocolatey/choco.exe setapikey --key="{choco_token}" --source="https://push.chocolatey.org/" --allow-unofficial').read()
|
||||
run("mono /opt/chocolatey/choco.exe pack --allow-unofficial")
|
||||
run(
|
||||
'mono /opt/chocolatey/choco.exe setapikey --key="{choco_token}" --source="https://push.chocolatey.org/" --allow-unofficial'
|
||||
)
|
||||
|
||||
print("\n_____ build choco push")
|
||||
os.popen('mono /opt/chocolatey/choco.exe push --source="https://push.chocolatey.org/" --key="$CHOCO_TOKEN" --allow-unofficial --verbose').read()
|
||||
run(
|
||||
'mono /opt/chocolatey/choco.exe push --source="https://push.chocolatey.org/" --key="$CHOCO_TOKEN" --allow-unofficial --verbose'
|
||||
)
|
||||
|
||||
print(f"choco push of version: {target_release_tag} successful!")
|
||||
print(f"it took: {datetime.datetime.now() - start}")
|
||||
|
||||
@@ -41,6 +41,9 @@ def pack_dependencies(install_dir: Path) -> None:
|
||||
if not dependency_path.is_dir():
|
||||
continue
|
||||
dependency_name = dependency_path.name
|
||||
# Skip ifcopenshell - it's a build output, not a dependency to reuse across builds.
|
||||
if dependency_name == "ifcopenshell":
|
||||
continue
|
||||
tar_path = install_dir / f"{CACHE_PREFIX}{dependency_name}.tar.gz"
|
||||
if tar_path.exists():
|
||||
print(f"Skipping existing cache: '{tar_path}'")
|
||||
|
||||
+175
-1
@@ -3,7 +3,7 @@ name = "IfcOpenShell"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"black==26.3.1",
|
||||
"ruff==0.15.6",
|
||||
"ruff==0.15.8",
|
||||
"poethepoet",
|
||||
"gersemi==0.26.1",
|
||||
]
|
||||
@@ -78,6 +78,139 @@ ignore = [
|
||||
"UP032", # Replace .format with f-string
|
||||
]
|
||||
|
||||
[tool.ty.rules]
|
||||
all = "ignore"
|
||||
|
||||
# Structural rules (no deep type inference needed, easier to adapt).
|
||||
abstract-method-in-final-class = "error"
|
||||
ambiguous-protocol-member = "error"
|
||||
byte-string-type-annotation = "error"
|
||||
conflicting-declarations = "error"
|
||||
conflicting-metaclass = "error"
|
||||
cyclic-class-definition = "error"
|
||||
cyclic-type-alias-definition = "error"
|
||||
dataclass-field-order = "error"
|
||||
duplicate-base = "error"
|
||||
duplicate-kw-only = "error"
|
||||
empty-body = "error"
|
||||
escape-character-in-forward-annotation = "error"
|
||||
final-on-non-method = "error"
|
||||
final-without-value = "error"
|
||||
fstring-type-annotation = "error"
|
||||
ignore-comment-unknown-rule = "error"
|
||||
implicit-concatenated-string-type-annotation = "error"
|
||||
inconsistent-mro = "error"
|
||||
ineffective-final = "error"
|
||||
instance-layout-conflict = "error"
|
||||
invalid-dataclass = "error"
|
||||
invalid-dataclass-override = "error"
|
||||
invalid-enum-member-annotation = "error"
|
||||
invalid-explicit-override = "error"
|
||||
invalid-frozen-dataclass-subclass = "error"
|
||||
invalid-generic-class = "error"
|
||||
invalid-generic-enum = "error"
|
||||
invalid-ignore-comment = "error"
|
||||
invalid-legacy-positional-parameter = "error"
|
||||
invalid-legacy-type-variable = "error"
|
||||
invalid-named-tuple = "error"
|
||||
invalid-newtype = "error"
|
||||
invalid-overload = "error"
|
||||
invalid-paramspec = "error"
|
||||
invalid-protocol = "error"
|
||||
invalid-syntax-in-forward-annotation = "error"
|
||||
invalid-total-ordering = "error"
|
||||
invalid-type-alias-type = "error"
|
||||
invalid-type-checking-constant = "error"
|
||||
invalid-type-guard-definition = "error"
|
||||
invalid-type-variable-bound = "error"
|
||||
invalid-type-variable-constraints = "error"
|
||||
invalid-typed-dict-header = "error"
|
||||
invalid-typed-dict-statement = "error"
|
||||
override-of-final-method = "error"
|
||||
override-of-final-variable = "error"
|
||||
possibly-missing-import = "error"
|
||||
possibly-missing-submodule = "error"
|
||||
# Has false positives due to ty walrus operator bug.
|
||||
# possibly-unresolved-reference = "error"
|
||||
raw-string-type-annotation = "error"
|
||||
redundant-final-classvar = "error"
|
||||
shadowed-type-variable = "error"
|
||||
subclass-of-final-class = "error"
|
||||
super-call-in-named-tuple-method = "error"
|
||||
unavailable-implicit-super-arguments = "error"
|
||||
unbound-type-variable = "error"
|
||||
undefined-reveal = "error"
|
||||
unresolved-global = "error"
|
||||
unresolved-import = "error"
|
||||
unresolved-reference = "error"
|
||||
unused-ignore-comment = "error"
|
||||
unused-type-ignore-comment = "error"
|
||||
useless-overload-body = "error"
|
||||
|
||||
# Non-structural rules:
|
||||
deprecated = "error"
|
||||
zero-stepsize-in-slice = "error"
|
||||
possibly-missing-implicit-call = "error"
|
||||
unused-awaitable = "error"
|
||||
|
||||
# Function argument rules:
|
||||
# Conflicts with `ifcopenshell.api.geometry.add_representation` type of callables we have, confusing them with a module.
|
||||
# call-non-callable = "error"
|
||||
conflicting-argument-forms = "error"
|
||||
# Too many false positives.
|
||||
# invalid-argument-type = "error"
|
||||
missing-argument = "error"
|
||||
parameter-already-assigned = "error"
|
||||
positional-only-parameter-as-kwarg = "error"
|
||||
too-many-positional-arguments = "error"
|
||||
unknown-argument = "error"
|
||||
# Has a lot of warnings due to current ty walrus operator issues.
|
||||
# index-out-of-bounds = "error"
|
||||
# unresolved-attribute = "error"
|
||||
|
||||
[tool.ty.environment]
|
||||
extra-paths = [
|
||||
"src/bonsai/external_dependencies",
|
||||
"src/bcf",
|
||||
"src/bsdd",
|
||||
"src/bonsai",
|
||||
"src/ifc4d",
|
||||
"src/ifc5d",
|
||||
"src/ifccityjson",
|
||||
"src/ifcclash",
|
||||
"src/ifccsv",
|
||||
"src/ifcdiff",
|
||||
"src/ifcfm",
|
||||
"src/ifcopenshell-python",
|
||||
"src/ifcpatch",
|
||||
"src/ifctester",
|
||||
]
|
||||
|
||||
[tool.ty.src]
|
||||
exclude = [
|
||||
# External dependencies cloned for type checking only.
|
||||
"src/bonsai/external_dependencies",
|
||||
# Submodules.
|
||||
"src/ifcopenshell-python/ifcopenshell/express",
|
||||
"src/ifcopenshell-python/ifcopenshell/mvd",
|
||||
"src/ifcopenshell-python/ifcopenshell/simple_spf",
|
||||
"src/svgfill/3rdparty",
|
||||
# Has special dependencies.
|
||||
"src/ifcopenshell-python/ifcopenshell/geom/app.py",
|
||||
"src/ifcopenshell-python/ifcopenshell/geom/code_editor_pane.py",
|
||||
"src/ifcopenshell-python/ifcopenshell/util/doc.py",
|
||||
"src/ifcopenshell-python/ifcopenshell/util/generate_pset_templates.py",
|
||||
"src/ifcopenshell-python/ifcopenshell/util/ifc4x3dev_scrape_data_for_docs.py",
|
||||
# Too esoteric.
|
||||
"src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.py",
|
||||
"src/ifc2ca/templates",
|
||||
# Too dev.
|
||||
"src/bcf/setup.py",
|
||||
"src/bsdd/yml_to_classes.py",
|
||||
# Deprecated.
|
||||
"src/ifc2ca/_deprecated",
|
||||
]
|
||||
|
||||
[tool.poe.tasks]
|
||||
|
||||
ruff-main = "ruff check --extend-exclude nix/build-all.py"
|
||||
@@ -87,6 +220,47 @@ ruff.sequence = ["ruff-main", "ruff-old"]
|
||||
|
||||
black = "black ."
|
||||
|
||||
ty.sequence = ["ty-bonsai", "ty-ios"]
|
||||
ty.help = "Run ty type checker. Requires ty-venv to be set up first."
|
||||
ty-bonsai = "ty check src/bonsai --python=src/bonsai/.venv"
|
||||
|
||||
ty-venv.sequence = ["ty-venv-bonsai", "ty-venv-ios"]
|
||||
|
||||
ty-venv-bonsai.sequence = [
|
||||
{cmd = "uv venv src/bonsai/.venv --python=3.11 --allow-existing"},
|
||||
{cmd = "uv pip install -r src/bonsai/type-check-requirements.txt --python=src/bonsai/.venv"},
|
||||
]
|
||||
|
||||
ty-venv-ios.sequence = [
|
||||
{cmd = "uv venv src/ifcopenshell-python/.venv --python=3.10 --allow-existing"},
|
||||
{cmd = "uv pip install -r src/ifcopenshell-python/type-check-requirements.txt --python=src/ifcopenshell-python/.venv"},
|
||||
]
|
||||
|
||||
format.sequence = ["black", "ruff-main", "ruff-old"]
|
||||
|
||||
cmake-format = "gersemi . --in-place"
|
||||
|
||||
[tool.poe.tasks.ty-ios]
|
||||
# --ignore unresolved-reference: walrus operator false positives in ty.
|
||||
cmd = """
|
||||
ty check
|
||||
src/bcf
|
||||
src/bsdd
|
||||
src/ifc2ca
|
||||
src/ifc4d
|
||||
src/ifc5d
|
||||
src/ifccityjson
|
||||
src/ifcclash
|
||||
src/ifccsv
|
||||
src/ifcdiff
|
||||
src/ifcfm
|
||||
src/ifcopenshell-python
|
||||
src/ifcpatch
|
||||
src/ifctester
|
||||
--python=src/ifcopenshell-python/.venv
|
||||
--ignore unresolved-reference
|
||||
"""
|
||||
|
||||
[tool.poe.tasks.bonsai-deps]
|
||||
help = "Clone or update Bonsai external dependencies."
|
||||
cmd = "python src/bonsai/scripts/bonsai_deps.py"
|
||||
|
||||
@@ -34,8 +34,8 @@ client_id, client_secret = "", ""
|
||||
class OAuthReceiver(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self) -> None:
|
||||
query = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
|
||||
self.server.auth_code = query.get("code", [""])[0] # type: ignore
|
||||
self.server.auth_state = query.get("state", [""])[0] # type: ignore
|
||||
self.server.auth_code = query.get("code", [""])[0]
|
||||
self.server.auth_state = query.get("state", [""])[0]
|
||||
self.send_response(200)
|
||||
self.send_header("Content-type", "text/plain")
|
||||
self.end_headers()
|
||||
@@ -255,7 +255,7 @@ class BcfClient:
|
||||
project_id: str = "",
|
||||
topics: str = "",
|
||||
query_string: Optional[str] = None,
|
||||
) -> list[Any]:
|
||||
) -> None:
|
||||
# return self.get(
|
||||
# f"/projects/{project_id}/topics",
|
||||
# {
|
||||
|
||||
@@ -173,16 +173,17 @@ def assert_viewpoints(viewpoints):
|
||||
assert viewpoint.snapshot is not None
|
||||
|
||||
|
||||
# TODO: dead code - ported from v2 but buildingSMART/BCF-XML has no v3 MaximumInformation.bcf equivalent
|
||||
def assert_second_viewpoint(viewpoint, expected_selection, expected_exception, expected_coloring) -> None:
|
||||
expected_vp = mdl.VisualizationInfo(
|
||||
components=mdl.Components(
|
||||
view_setup_hints=mdl.ViewSetupHints(
|
||||
spaces_visible=False,
|
||||
space_boundaries_visible=False,
|
||||
openings_visible=False,
|
||||
),
|
||||
selection=expected_selection,
|
||||
visibility=mdl.ComponentVisibility(
|
||||
view_setup_hints=mdl.ViewSetupHints(
|
||||
spaces_visible=False,
|
||||
space_boundaries_visible=False,
|
||||
openings_visible=False,
|
||||
),
|
||||
exceptions=expected_exception,
|
||||
default_visibility=False,
|
||||
),
|
||||
@@ -193,6 +194,7 @@ def assert_second_viewpoint(viewpoint, expected_selection, expected_exception, e
|
||||
camera_direction=mdl.Direction(x=0.6745243072509766, y=-0.6599355936050415, z=-0.33091068267822266),
|
||||
camera_up_vector=mdl.Direction(x=0.2271970510482788, y=-0.24091780185699463, z=0.9435783624649048),
|
||||
field_of_view=60,
|
||||
aspect_ratio=1.0,
|
||||
),
|
||||
guid="21dd4807-e9af-439e-a980-04d913a6b1ce",
|
||||
)
|
||||
@@ -200,16 +202,17 @@ def assert_second_viewpoint(viewpoint, expected_selection, expected_exception, e
|
||||
assert viewpoint.snapshot is not None
|
||||
|
||||
|
||||
# TODO: dead code - ported from v2 but buildingSMART/BCF-XML has no v3 MaximumInformation.bcf equivalent
|
||||
def assert_third_viewpoint(viewpoint, expected_selection, expected_exception, expected_coloring) -> None:
|
||||
expected_vp = mdl.VisualizationInfo(
|
||||
components=mdl.Components(
|
||||
view_setup_hints=mdl.ViewSetupHints(
|
||||
spaces_visible=False,
|
||||
space_boundaries_visible=False,
|
||||
openings_visible=True,
|
||||
),
|
||||
selection=expected_selection,
|
||||
visibility=mdl.ComponentVisibility(
|
||||
view_setup_hints=mdl.ViewSetupHints(
|
||||
spaces_visible=False,
|
||||
space_boundaries_visible=False,
|
||||
openings_visible=True,
|
||||
),
|
||||
exceptions=expected_exception,
|
||||
default_visibility=True,
|
||||
),
|
||||
@@ -220,6 +223,7 @@ def assert_third_viewpoint(viewpoint, expected_selection, expected_exception, ex
|
||||
camera_direction=mdl.Direction(x=0.7232745289802551, y=0.5967116951942444, z=-0.3475759029388428),
|
||||
camera_up_vector=mdl.Direction(x=0.27662187814712524, y=0.21082592010498047, z=0.937567412853241),
|
||||
field_of_view=60,
|
||||
aspect_ratio=1.0,
|
||||
),
|
||||
guid="81daa431-bf01-4a49-80a2-1ab07c177717",
|
||||
)
|
||||
|
||||
@@ -45,16 +45,19 @@ from bonsai.bim.module.nest.decorator import NestDecorator
|
||||
|
||||
cwd = os.path.dirname(os.path.realpath(__file__))
|
||||
global_subscription_owner = object()
|
||||
# Separate owner for per-object msgbus subscriptions (name, active_material_index).
|
||||
# Using a dedicated owner allows clearing all per-object subscriptions at once
|
||||
# during undo/redo without affecting other global subscriptions.
|
||||
object_subscription_owner = object()
|
||||
|
||||
|
||||
def name_callback(obj: Union[bpy.types.Object, bpy.types.Material], data: str) -> None:
|
||||
try:
|
||||
obj.name
|
||||
except:
|
||||
# The object is invalid but somehow still has a callback. Clear all
|
||||
# msgbus subscriptions to prevent useless further triggers.
|
||||
bpy.msgbus.clear_by_owner(obj)
|
||||
return # In case the object RNA is gone during an undo / redo operation
|
||||
# The object is invalid but somehow still has a callback.
|
||||
# This can occur during undo/redo when the Python wrapper is stale.
|
||||
return
|
||||
# Blender names are up to 63 UTF-8 bytes
|
||||
if len(bytes(obj.name, "utf-8")) >= 63:
|
||||
return
|
||||
@@ -189,7 +192,7 @@ def subscribe_to(obj: bpy.types.ID, data_path: str, callback: Callable[[bpy.type
|
||||
return
|
||||
bpy.msgbus.subscribe_rna(
|
||||
key=subscribe_to,
|
||||
owner=obj,
|
||||
owner=object_subscription_owner,
|
||||
args=(
|
||||
obj,
|
||||
data_path,
|
||||
|
||||
@@ -316,11 +316,8 @@ class IfcStore:
|
||||
del IfcStore.id_map[data["id"]]
|
||||
if "guid" in data:
|
||||
del IfcStore.guid_map[data["guid"]]
|
||||
obj = IfcStore.get_object_by_name(data["obj"])
|
||||
if obj is None:
|
||||
# obj was just created during this step and didn't existed before.
|
||||
return
|
||||
bpy.msgbus.clear_by_owner(obj)
|
||||
# Note: msgbus subscriptions are cleared globally during
|
||||
# rebuild_element_maps which runs after every undo/redo.
|
||||
|
||||
@staticmethod
|
||||
def commit_link_element(data: OperationData) -> None:
|
||||
@@ -367,10 +364,8 @@ class IfcStore:
|
||||
del IfcStore.id_map[data["id"]]
|
||||
if "guid" in data:
|
||||
del IfcStore.guid_map[data["guid"]]
|
||||
obj = IfcStore.get_object_by_name(data["obj"])
|
||||
# obj might be removed after unlink.
|
||||
if not obj:
|
||||
bpy.msgbus.clear_by_owner(obj)
|
||||
# Note: msgbus subscriptions are cleared globally during
|
||||
# rebuild_element_maps which runs after every undo/redo.
|
||||
|
||||
@staticmethod
|
||||
def unlink_element(
|
||||
|
||||
@@ -64,8 +64,8 @@ class MaterialCreator:
|
||||
mesh: Union[OBJECT_DATA_TYPE, None],
|
||||
shape_has_openings: bool,
|
||||
) -> None:
|
||||
if ((rep := getattr(element, "Representation", ...) is not ...) and not rep) or (
|
||||
(rep := getattr(element, "RepresentationMaps", ...) is not ...) and not rep
|
||||
if (((rep := getattr(element, "Representation", ...)) is not ... and not rep) or
|
||||
((rep := getattr(element, "RepresentationMaps", ...)) is not ... and not rep)
|
||||
):
|
||||
return
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@ class AggregateDecorator:
|
||||
cls.is_installed = False
|
||||
|
||||
def dotted_line_shader(self):
|
||||
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface")
|
||||
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") # ty:ignore[too-many-positional-arguments]
|
||||
vert_out.smooth("FLOAT", "v_ArcLength")
|
||||
|
||||
shader_info = gpu.types.GPUShaderCreateInfo()
|
||||
|
||||
@@ -230,7 +230,7 @@ class BcfTopic(PropertyGroup):
|
||||
|
||||
|
||||
def get_related_topics(self: "BCFProperties", context: bpy.types.Context) -> list[tuple[str, str, str]]:
|
||||
global RELATED_TOPICS_ENUM_ITEMS
|
||||
global RELATED_TOPICS_ENUM_ITEMS # ty: ignore[unresolved-global]
|
||||
props = self
|
||||
active_topic = props.active_topic
|
||||
active_related_topics = active_topic.related_topics.keys()
|
||||
|
||||
@@ -46,26 +46,26 @@ def get_libraries(self, context):
|
||||
|
||||
|
||||
def get_namespaces(self, context):
|
||||
global NAMESPACES_ENUM_ITEMS
|
||||
global NAMESPACES_ENUM_ITEMS # ty: ignore[unresolved-global]
|
||||
NAMESPACES_ENUM_ITEMS = [(uri, f"{alias}: {uri}", "") for alias, uri in BrickStore.namespaces]
|
||||
return NAMESPACES_ENUM_ITEMS
|
||||
|
||||
|
||||
def get_brick_entity_classes(self, context):
|
||||
global ENTITY_CLASSES_ENUM_ITEMS
|
||||
global ENTITY_CLASSES_ENUM_ITEMS # ty: ignore[unresolved-global]
|
||||
entity = self.brick_entity_create_type
|
||||
ENTITY_CLASSES_ENUM_ITEMS = [(uri, uri.split("#")[-1], "") for uri in BrickStore.entity_classes[entity]]
|
||||
return ENTITY_CLASSES_ENUM_ITEMS
|
||||
|
||||
|
||||
def get_brick_roots(self, context):
|
||||
global BRICK_ROOTS_ENUM_ITEMS
|
||||
global BRICK_ROOTS_ENUM_ITEMS # ty: ignore[unresolved-global]
|
||||
BRICK_ROOTS_ENUM_ITEMS = [(root, root, "") for root in BrickStore.root_classes]
|
||||
return BRICK_ROOTS_ENUM_ITEMS
|
||||
|
||||
|
||||
def get_brick_relations(self, context):
|
||||
global BRICK_RELATIONS_ENUM_ITEMS
|
||||
global BRICK_RELATIONS_ENUM_ITEMS # ty: ignore[unresolved-global]
|
||||
BRICK_RELATIONS_ENUM_ITEMS = [(uri, uri.split("#")[-1], "") for uri in BrickStore.relationships]
|
||||
for relation in BrickschemaData.data["active_relations"]:
|
||||
if relation["predicate_name"] == "label":
|
||||
|
||||
@@ -1285,7 +1285,7 @@ class SnapManager:
|
||||
continue
|
||||
|
||||
coords = np.empty(vertex_count * 3, dtype=np.float32)
|
||||
mesh.vertices.foreach_get("co", coords) # type: ignore[arg-type]
|
||||
mesh.vertices.foreach_get("co", coords)
|
||||
coords = coords.reshape(-1, 3)
|
||||
|
||||
matrix = np.array(obj_eval.matrix_world, dtype=np.float32)
|
||||
|
||||
@@ -456,7 +456,8 @@ def format_distance(
|
||||
tx_dist = fmt % d_cm
|
||||
|
||||
else:
|
||||
tx_dist = fmt % value
|
||||
assert f"Unexpected unit_system - '{unit_system}'."
|
||||
# tx_dist = fmt % value
|
||||
|
||||
return tx_dist
|
||||
|
||||
|
||||
@@ -1426,6 +1426,7 @@ class CreateDrawing(bpy.types.Operator):
|
||||
"/Pset_.*Common/.Status",
|
||||
"EPset_Status.Status",
|
||||
"EPset_Status.UserDefinedStatus",
|
||||
"Material.Name",
|
||||
]
|
||||
|
||||
group = root.find("{http://www.w3.org/2000/svg}g")
|
||||
|
||||
@@ -366,9 +366,6 @@ class BaseLinesShader(BaseShader):
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self, gap_size=16):
|
||||
super().__init__(gap_size=gap_size)
|
||||
|
||||
def glenable(self):
|
||||
super().glenable()
|
||||
|
||||
|
||||
@@ -1066,7 +1066,7 @@ class OverrideOutlinerDelete(bpy.types.Operator, tool.Ifc.Operator):
|
||||
cls.poll_message_set("Only available from Outliner.")
|
||||
return False
|
||||
|
||||
def execute(self, context):
|
||||
def execute(self, context): # ty:ignore[override-of-final-method]
|
||||
if len(getattr(context, "selected_ids", [])) == 0:
|
||||
return {"FINISHED"}
|
||||
|
||||
@@ -2289,7 +2289,7 @@ class OverrideModeSetEdit(bpy.types.Operator, tool.Ifc.Operator):
|
||||
elif obj in pprops.clipping_planes_objs:
|
||||
self.report({"ERROR"}, "Clipping planes cannot be edited")
|
||||
elif element:
|
||||
if not obj.data:
|
||||
if not obj.data or obj.type not in ("MESH", "CURVE"):
|
||||
self.report({"INFO"}, "No geometry to edit")
|
||||
elif tool.Geometry.is_locked(element):
|
||||
self.report({"ERROR"}, lock_error_message(obj.name))
|
||||
|
||||
@@ -139,7 +139,9 @@ def update_local_coordinates(self: "BIMGeoreferenceProperties", context: bpy.typ
|
||||
tool.Georeference.set_coordinates(
|
||||
"blender",
|
||||
ifcopenshell.util.geolocation.enh2xyz(
|
||||
*local_coordinates,
|
||||
local_coordinates[0],
|
||||
local_coordinates[1],
|
||||
local_coordinates[2],
|
||||
float(props.blender_offset_x),
|
||||
float(props.blender_offset_y),
|
||||
float(props.blender_offset_z),
|
||||
@@ -162,7 +164,9 @@ def update_map_coordinates(self: "BIMGeoreferenceProperties", context: bpy.types
|
||||
tool.Georeference.set_coordinates(
|
||||
"blender",
|
||||
ifcopenshell.util.geolocation.enh2xyz(
|
||||
*local_coordinates,
|
||||
local_coordinates[0],
|
||||
local_coordinates[1],
|
||||
local_coordinates[2],
|
||||
float(props.blender_offset_x),
|
||||
float(props.blender_offset_y),
|
||||
float(props.blender_offset_z),
|
||||
|
||||
@@ -27,7 +27,7 @@ from bonsai.bim.prop import StrProperty
|
||||
|
||||
class BIMCityJsonProperties(PropertyGroup):
|
||||
def get_lods(self, context):
|
||||
global LODS_ENUM_ITEMS
|
||||
global LODS_ENUM_ITEMS # ty: ignore[unresolved-global]
|
||||
LODS_ENUM_ITEMS = [(item.name, "LOD" + item.name, "Level of Detail " + item.name) for item in self.lods]
|
||||
return LODS_ENUM_ITEMS
|
||||
|
||||
|
||||
@@ -320,7 +320,7 @@ class RadianceExporterProperties(PropertyGroup):
|
||||
)
|
||||
|
||||
def get_subcategories(self, context: bpy.types.Context) -> tool.Blender.BLENDER_ENUM_ITEMS:
|
||||
global SUBCATEGORIES_ENUM_ITEMS
|
||||
global SUBCATEGORIES_ENUM_ITEMS # ty: ignore[unresolved-global]
|
||||
if self.category in spectraldb:
|
||||
SUBCATEGORIES_ENUM_ITEMS = [(k, k, "") for k in spectraldb[self.category].keys()]
|
||||
else:
|
||||
|
||||
@@ -717,7 +717,11 @@ class EnableEditingMaterialSetItem(bpy.types.Operator):
|
||||
self.props.material_set_item_material = str(material_set_item.Material.id())
|
||||
|
||||
self.props.material_set_item_attributes.clear()
|
||||
bonsai.bim.helper.import_attributes(material_set_item, self.props.material_set_item_attributes)
|
||||
bonsai.bim.helper.import_attributes(
|
||||
material_set_item,
|
||||
self.props.material_set_item_attributes,
|
||||
callback=self.import_attributes_callback,
|
||||
)
|
||||
|
||||
if material_set_item.is_a("IfcMaterialProfile"):
|
||||
if material_set_item.Profile and material_set_item.Profile.ProfileName:
|
||||
@@ -725,6 +729,29 @@ class EnableEditingMaterialSetItem(bpy.types.Operator):
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
def import_attributes_callback(
|
||||
self, name: str, prop: Union["Attribute", None], data: dict[str, Any]
|
||||
) -> None | Literal[True]:
|
||||
if data["type"] != "IfcMaterialLayer" or name != "IsVentilated" or not prop:
|
||||
return None
|
||||
|
||||
# Keep null semantics unchanged on export, but avoid an empty UI selection.
|
||||
prop.data_type = "enum"
|
||||
prop.special_type = "LOGICAL"
|
||||
prop.enum_items = json.dumps(("TRUE", "FALSE", "UNKNOWN"))
|
||||
|
||||
value = data[name]
|
||||
if value == "UNKNOWN":
|
||||
prop.enum_value = "UNKNOWN"
|
||||
elif value is None:
|
||||
# Keep visible default as FALSE, but preserve null semantics on save.
|
||||
prop.enum_value = "FALSE"
|
||||
prop.is_null = True
|
||||
else:
|
||||
prop.enum_value = "TRUE" if value else "FALSE"
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class DisableEditingMaterialSetItem(bpy.types.Operator):
|
||||
bl_idname = "bim.disable_editing_material_set_item"
|
||||
|
||||
@@ -104,6 +104,7 @@ classes = (
|
||||
profile.ExtendProfile,
|
||||
profile.RecalculateProfile,
|
||||
profile.Rotate90,
|
||||
profile.SplitProfile,
|
||||
profile.PatchNonParametricMepSegment,
|
||||
roof.GenerateHippedRoof,
|
||||
slab.DisableEditingExtrusionProfile,
|
||||
|
||||
@@ -227,7 +227,7 @@ class FitFlowSegments(bpy.types.Operator, tool.Ifc.Operator):
|
||||
is_parallel21 = tool.Cad.is_x(angle21, (0, 180), tolerance=0.001)
|
||||
is_parallel23 = tool.Cad.is_x(angle23, (0, 180), tolerance=0.001)
|
||||
|
||||
if not all(is_parallel12, is_parallel13, is_parallel21, is_parallel23):
|
||||
if not all([is_parallel12, is_parallel13, is_parallel21, is_parallel23]):
|
||||
fitting_type = "WYE"
|
||||
|
||||
if not fitting_type:
|
||||
@@ -903,7 +903,7 @@ class MEPAddBend(bpy.types.Operator, tool.Ifc.Operator):
|
||||
start_segment_id: bpy.props.IntProperty(name="Start Segment Element ID", default=0)
|
||||
end_segment_id: bpy.props.IntProperty(name="End Segment Element ID", default=0)
|
||||
radius: bpy.props.FloatProperty(
|
||||
"Bend Inner Radius", description="Bend inner radius in SI units", default=0.2, subtype="DISTANCE", min=0
|
||||
name="Bend Inner Radius", description="Bend inner radius in SI units", default=0.2, subtype="DISTANCE", min=0
|
||||
)
|
||||
|
||||
def _execute(self, context):
|
||||
|
||||
@@ -540,6 +540,16 @@ class AddBoolean(Operator, tool.Ifc.Operator):
|
||||
booleans = ifcopenshell.api.geometry.add_boolean(tool.Ifc.get(), first_item, second_items, props.operator)
|
||||
|
||||
rep_obj = tool.Geometry.get_geometry_props().representation_obj
|
||||
if booleans:
|
||||
# Users typically select two top-level items and expect the
|
||||
# operand to be absorbed into the boolean, not remain as a
|
||||
# standalone item alongside it.
|
||||
representation = tool.Geometry.get_active_representation(rep_obj)
|
||||
representation = ifcopenshell.util.representation.resolve_representation(representation)
|
||||
second_items_set = set(second_items)
|
||||
new_items = [i for i in representation.Items if i not in second_items_set]
|
||||
if new_items:
|
||||
representation.Items = new_items
|
||||
rep_element = tool.Ifc.get_entity(rep_obj)
|
||||
tool.Model.mark_manual_booleans(rep_element, booleans)
|
||||
tool.Geometry.reload_representation(rep_obj)
|
||||
|
||||
@@ -421,7 +421,7 @@ class PolylineOperator:
|
||||
tool.Polyline.calculate_x_y_and_z(context, self.input_ui, self.tool_state)
|
||||
|
||||
tool.Blender.update_viewport()
|
||||
return {"RUNNING_MODAL"}
|
||||
return {"RUNNING_MODAL"}
|
||||
|
||||
def set_offset(self, context: bpy.types.Context, relating_type: ifcopenshell.entity_instance) -> None:
|
||||
props = tool.Model.get_model_props()
|
||||
@@ -461,6 +461,7 @@ class PolylineOperator:
|
||||
self.tool_state.axis_method = None
|
||||
self.tool_state.plane_method = None
|
||||
self.tool_state.mode = "Mouse"
|
||||
tool.Raycast.clear_snap_objs()
|
||||
self.visible_objs = tool.Raycast.get_visible_objects(context)
|
||||
for obj in self.visible_objs:
|
||||
if bbox_2d := tool.Raycast.get_on_screen_2d_bounding_boxes(context, obj):
|
||||
|
||||
@@ -694,10 +694,14 @@ def generate_box(usecase_path: str, ifc_file: ifcopenshell.file, settings: dict[
|
||||
|
||||
new_settings = settings.copy()
|
||||
new_settings["context"] = box_context
|
||||
new_box = ifcopenshell.api.geometry.add_representation(ifc_file, should_run_listeners=False, **new_settings)
|
||||
new_box = ifcopenshell.api.geometry.add_representation(
|
||||
ifc_file,
|
||||
should_run_listeners=False, # ty:ignore[unknown-argument]
|
||||
**new_settings,
|
||||
)
|
||||
ifcopenshell.api.geometry.assign_representation(
|
||||
ifc_file,
|
||||
should_run_listeners=False,
|
||||
should_run_listeners=False, # ty:ignore[unknown-argument]
|
||||
product=product,
|
||||
representation=new_box,
|
||||
)
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
import copy
|
||||
from math import atan2, degrees, pi, radians
|
||||
from typing import Any, Literal, Optional, Union
|
||||
from typing import TYPE_CHECKING, Any, Literal, Optional, Union
|
||||
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
@@ -49,7 +49,7 @@ ProfileFrom2PointsReturn = Union[dict[str, Any], None]
|
||||
|
||||
|
||||
class DumbProfileGenerator:
|
||||
def __init__(self, relating_type):
|
||||
def __init__(self, relating_type: ifcopenshell.entity_instance):
|
||||
self.relating_type = relating_type
|
||||
self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
||||
|
||||
@@ -201,7 +201,7 @@ class DumbProfileGenerator:
|
||||
|
||||
|
||||
class DumbProfileRegenerator:
|
||||
def regenerate_from_profile_def(self, profile):
|
||||
def regenerate_from_profile_def(self, profile: ifcopenshell.entity_instance) -> None:
|
||||
self.file = tool.Ifc.get()
|
||||
objs = []
|
||||
if not profile:
|
||||
@@ -221,7 +221,7 @@ class DumbProfileRegenerator:
|
||||
for element in self.get_element_types_using_profile(profile):
|
||||
tool.Model.mark_thumbnail_for_update(element)
|
||||
|
||||
def regenerate_from_profile(self, usecase_path, ifc_file, settings):
|
||||
def regenerate_from_profile(self, usecase_path: str, ifc_file: ifcopenshell.file, settings: dict[str, Any]) -> None:
|
||||
self.file = ifc_file
|
||||
objs = []
|
||||
profile = settings["profile"].Profile
|
||||
@@ -233,7 +233,7 @@ class DumbProfileRegenerator:
|
||||
objs.append(obj)
|
||||
DumbProfileRecalculator().recalculate(objs)
|
||||
|
||||
def get_elements_using_profile(self, profile):
|
||||
def get_elements_using_profile(self, profile: ifcopenshell.entity_instance) -> list[ifcopenshell.entity_instance]:
|
||||
results = []
|
||||
profile_sets = [
|
||||
mp.ToMaterialProfileSet[0] for mp in self.file.get_inverse(profile) if mp.is_a("IfcMaterialProfile")
|
||||
@@ -252,7 +252,9 @@ class DumbProfileRegenerator:
|
||||
results.extend(rel.RelatedObjects)
|
||||
return results
|
||||
|
||||
def get_element_types_using_profile(self, profile):
|
||||
def get_element_types_using_profile(
|
||||
self, profile: ifcopenshell.entity_instance
|
||||
) -> list[ifcopenshell.entity_instance]:
|
||||
results = []
|
||||
profile_sets = [
|
||||
mp.ToMaterialProfileSet[0] for mp in self.file.get_inverse(profile) if mp.is_a("IfcMaterialProfile")
|
||||
@@ -269,12 +271,18 @@ class ExtendProfile(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.extend_profile"
|
||||
bl_label = "Extend Profile"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
join_type: bpy.props.StringProperty()
|
||||
join_type: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
|
||||
items=[("-", "Unjoin", ""), ("L", "L", ""), ("V", "V", ""), ("T", "T", "")],
|
||||
default="-",
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
join_type: Literal["-", "L", "V", "T"]
|
||||
|
||||
def _execute(self, context):
|
||||
selected_objs = context.selected_objects
|
||||
joiner = DumbProfileJoiner()
|
||||
if not self.join_type:
|
||||
if self.join_type == "-":
|
||||
for obj in selected_objs:
|
||||
joiner.unjoin(obj)
|
||||
return {"FINISHED"}
|
||||
@@ -626,11 +634,15 @@ class DumbProfileJoiner:
|
||||
if connection1 == "ATEND":
|
||||
if tool.Cad.is_x(abs(xy_angle), (0, 90, 180), tolerance=0.001) and is_orthogonal:
|
||||
plane = self.get_profile_plane(profile2, furthest_plane)
|
||||
intersect = mathutils.geometry.intersect_line_plane(*axis1, plane.translation, plane.col[2].to_3d())
|
||||
intersect = mathutils.geometry.intersect_line_plane(
|
||||
axis1[0], axis1[1], plane.translation, plane.col[2].to_3d()
|
||||
)
|
||||
self.body[1] = intersect
|
||||
else:
|
||||
plane = self.get_profile_plane(profile2, furthest_plane, z_inwards=False)
|
||||
intersect = mathutils.geometry.intersect_line_plane(*axis1, plane.translation, plane.col[2].to_3d())
|
||||
intersect = mathutils.geometry.intersect_line_plane(
|
||||
axis1[0], axis1[1], plane.translation, plane.col[2].to_3d()
|
||||
)
|
||||
max_dim = self.get_max_bound_box_dimension(profile1)
|
||||
self.body[1] = intersect + profile1.matrix_world.to_quaternion() @ Vector((0, 0, max_dim))
|
||||
|
||||
@@ -673,11 +685,15 @@ class DumbProfileJoiner:
|
||||
elif connection1 == "ATSTART":
|
||||
if tool.Cad.is_x(abs(xy_angle), (0, 90, 180), tolerance=0.001) and is_orthogonal:
|
||||
plane = self.get_profile_plane(profile2, furthest_plane)
|
||||
intersect = mathutils.geometry.intersect_line_plane(*axis1, plane.translation, plane.col[2].to_3d())
|
||||
intersect = mathutils.geometry.intersect_line_plane(
|
||||
axis1[0], axis1[1], plane.translation, plane.col[2].to_3d()
|
||||
)
|
||||
self.body[0] = intersect
|
||||
else:
|
||||
plane = self.get_profile_plane(profile2, furthest_plane, z_inwards=False)
|
||||
intersect = mathutils.geometry.intersect_line_plane(*axis1, plane.translation, plane.col[2].to_3d())
|
||||
intersect = mathutils.geometry.intersect_line_plane(
|
||||
axis1[0], axis1[1], plane.translation, plane.col[2].to_3d()
|
||||
)
|
||||
max_dim = self.get_max_bound_box_dimension(profile1)
|
||||
self.body[0] = intersect - profile1.matrix_world.to_quaternion() @ Vector((0, 0, max_dim))
|
||||
|
||||
@@ -721,7 +737,9 @@ class DumbProfileJoiner:
|
||||
if connection1 == "ATEND":
|
||||
if tool.Cad.is_x(abs(xy_angle), (0, 90, 180), tolerance=0.001) and is_orthogonal:
|
||||
plane = self.get_profile_plane(profile2, furthest_plane if is_relating else closest_plane)
|
||||
intersect = mathutils.geometry.intersect_line_plane(*axis1, plane.translation, plane.col[2].to_3d())
|
||||
intersect = mathutils.geometry.intersect_line_plane(
|
||||
axis1[0], axis1[1], plane.translation, plane.col[2].to_3d()
|
||||
)
|
||||
self.body[1] = intersect
|
||||
else:
|
||||
plane = self.get_profile_plane(
|
||||
@@ -729,7 +747,9 @@ class DumbProfileJoiner:
|
||||
furthest_plane if is_relating else closest_plane,
|
||||
z_inwards=False if is_relating else True,
|
||||
)
|
||||
intersect = mathutils.geometry.intersect_line_plane(*axis1, plane.translation, plane.col[2].to_3d())
|
||||
intersect = mathutils.geometry.intersect_line_plane(
|
||||
axis1[0], axis1[1], plane.translation, plane.col[2].to_3d()
|
||||
)
|
||||
max_dim = self.get_max_bound_box_dimension(profile1)
|
||||
self.body[1] = intersect + profile1.matrix_world.to_quaternion() @ Vector((0, 0, max_dim))
|
||||
self.clippings.append(
|
||||
@@ -742,7 +762,9 @@ class DumbProfileJoiner:
|
||||
elif connection1 == "ATSTART":
|
||||
if tool.Cad.is_x(abs(xy_angle), (0, 90, 180), tolerance=0.001) and is_orthogonal:
|
||||
plane = self.get_profile_plane(profile2, furthest_plane if is_relating else closest_plane)
|
||||
intersect = mathutils.geometry.intersect_line_plane(*axis1, plane.translation, plane.col[2].to_3d())
|
||||
intersect = mathutils.geometry.intersect_line_plane(
|
||||
axis1[0], axis1[1], plane.translation, plane.col[2].to_3d()
|
||||
)
|
||||
self.body[0] = intersect
|
||||
else:
|
||||
plane = self.get_profile_plane(
|
||||
@@ -750,7 +772,9 @@ class DumbProfileJoiner:
|
||||
furthest_plane if is_relating else closest_plane,
|
||||
z_inwards=False if is_relating else True,
|
||||
)
|
||||
intersect = mathutils.geometry.intersect_line_plane(*axis1, plane.translation, plane.col[2].to_3d())
|
||||
intersect = mathutils.geometry.intersect_line_plane(
|
||||
axis1[0], axis1[1], plane.translation, plane.col[2].to_3d()
|
||||
)
|
||||
max_dim = self.get_max_bound_box_dimension(profile1)
|
||||
self.body[0] = intersect - profile1.matrix_world.to_quaternion() @ Vector((0, 0, max_dim))
|
||||
self.clippings.append(
|
||||
@@ -825,6 +849,57 @@ class DumbProfileJoiner:
|
||||
def create_matrix(self, p: Vector, x: Vector, y: Vector, z: Vector) -> Matrix:
|
||||
return Matrix([x, y, z, p]).to_4x4().transposed()
|
||||
|
||||
def split(self, profile1: bpy.types.Object, target: Vector) -> None:
|
||||
element1 = tool.Ifc.get_entity(profile1)
|
||||
if not element1:
|
||||
return
|
||||
|
||||
if tool.Ifc.is_moved(profile1):
|
||||
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=profile1)
|
||||
|
||||
axis1 = self.get_profile_axis(profile1)
|
||||
intersect, cut_percentage = mathutils.geometry.intersect_point_line(target, *axis1)
|
||||
if cut_percentage < 0 or cut_percentage > 1 or tool.Cad.is_x(cut_percentage, (0, 1)):
|
||||
return
|
||||
|
||||
# Duplicate the profile element
|
||||
profile2 = profile1.copy()
|
||||
profile2.data = profile2.data.copy()
|
||||
for collection in profile1.users_collection:
|
||||
collection.objects.link(profile2)
|
||||
bonsai.core.root.copy_class(tool.Ifc, tool.Collector, tool.Geometry, tool.Root, obj=profile2)
|
||||
element2 = tool.Ifc.get_entity(profile2)
|
||||
|
||||
# Transfer ATEND connection from element1 to element2
|
||||
relating_element = None
|
||||
relating_connection = None
|
||||
description = None
|
||||
for conn in list(element1.ConnectedTo):
|
||||
if conn.is_a("IfcRelConnectsPathElements") and conn.RelatingConnectionType == "ATEND":
|
||||
relating_element = conn.RelatedElement
|
||||
relating_connection = conn.RelatedConnectionType
|
||||
description = conn.Description
|
||||
bonsai.core.geometry.remove_connection(tool.Geometry, connection=conn)
|
||||
for conn in list(element1.ConnectedFrom):
|
||||
if conn.is_a("IfcRelConnectsPathElements") and conn.RelatedConnectionType == "ATEND":
|
||||
relating_element = conn.RelatingElement
|
||||
relating_connection = conn.RelatingConnectionType
|
||||
description = conn.Description
|
||||
bonsai.core.geometry.remove_connection(tool.Geometry, connection=conn)
|
||||
if relating_element:
|
||||
ifcopenshell.api.geometry.connect_path(
|
||||
tool.Ifc.get(),
|
||||
relating_element=relating_element,
|
||||
related_element=element2,
|
||||
relating_connection=relating_connection,
|
||||
related_connection="ATEND",
|
||||
description=description,
|
||||
)
|
||||
|
||||
# Recreate both profiles with split axes
|
||||
self.recreate_profile(element1, profile1, [axis1[0], intersect], [axis1[0], intersect])
|
||||
self.recreate_profile(element2, profile2, [intersect, axis1[1]], [intersect, axis1[1]])
|
||||
|
||||
def get_profile_axis(self, obj: bpy.types.Object) -> list[Vector]:
|
||||
z_values = [v[2] for v in obj.bound_box]
|
||||
return [
|
||||
@@ -833,6 +908,29 @@ class DumbProfileJoiner:
|
||||
]
|
||||
|
||||
|
||||
class SplitProfile(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.split_profile"
|
||||
bl_label = "Split Profile"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
bl_description = (
|
||||
"Split selected profile element into two elements at the Blender cursor location. "
|
||||
"The cursor must be positioned on the element's axis."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
if not tool.Model.has_selected_ifc_objects():
|
||||
cls.poll_message_set("No IFC objects selected.")
|
||||
return False
|
||||
return True
|
||||
|
||||
def _execute(self, context):
|
||||
selected_objs = tool.Model.get_selected_mesh_objects()
|
||||
for obj in selected_objs:
|
||||
DumbProfileJoiner().split(obj, context.scene.cursor.location)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class RecalculateProfile(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.recalculate_profile"
|
||||
bl_label = "Recalculate Profile"
|
||||
|
||||
@@ -31,11 +31,11 @@ def calculate_quantities(usecase_path, ifc_file: ifcopenshell.file, settings):
|
||||
return
|
||||
task = next(e for e in ifc_file.get_inverse(element) if e.is_a("IfcTask"))
|
||||
qto = ifcopenshell.api.pset.add_qto(
|
||||
ifc_file, should_run_listeners=False, product=task, name="Qto_TaskBaseQuantities"
|
||||
ifc_file, should_run_listeners=False, product=task, name="Qto_TaskBaseQuantities" # ty:ignore[unknown-argument]
|
||||
)
|
||||
ifcopenshell.api.pset.edit_qto(
|
||||
ifc_file,
|
||||
should_run_listeners=False,
|
||||
should_run_listeners=False, # ty:ignore[unknown-argument]
|
||||
qto=qto,
|
||||
properties={
|
||||
"StandardWork": ifcopenshell.util.date.ifc2datetime(element.ScheduleDuration).days,
|
||||
|
||||
@@ -1268,27 +1268,6 @@ class DumbWallJoiner:
|
||||
bonsai.core.root.copy_class(tool.Ifc, tool.Collector, tool.Geometry, tool.Root, obj=wall2)
|
||||
return wall2
|
||||
|
||||
def join_Z(self, wall1, slab2):
|
||||
element1 = tool.Ifc.get_entity(wall1)
|
||||
element2 = tool.Ifc.get_entity(slab2)
|
||||
|
||||
for rel in element1.ConnectedFrom:
|
||||
if rel.is_a() == "IfcRelConnectsElements" and rel.Description == "TOP":
|
||||
ifcopenshell.api.geometry.disconnect_element(
|
||||
tool.Ifc.get(),
|
||||
relating_element=rel.RelatingElement,
|
||||
related_element=element1,
|
||||
)
|
||||
|
||||
ifcopenshell.api.geometry.connect_element(
|
||||
tool.Ifc.get(),
|
||||
relating_element=element2,
|
||||
related_element=element1,
|
||||
description="TOP",
|
||||
)
|
||||
|
||||
tool.Model.recreate_wall(element1, wall1)
|
||||
|
||||
def set_axis(self, wall, p1, p2):
|
||||
axis = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Plan", "Axis", "GRAPH_VIEW")
|
||||
builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get())
|
||||
@@ -1333,29 +1312,6 @@ class DumbWallJoiner:
|
||||
self.set_axis(element1, p1, p2)
|
||||
tool.Model.recreate_wall(element1, wall1)
|
||||
|
||||
def join_T(self, wall1: bpy.types.Object, wall2: bpy.types.Object) -> None:
|
||||
element1 = tool.Ifc.get_entity(wall1)
|
||||
element2 = tool.Ifc.get_entity(wall2)
|
||||
axis1 = tool.Model.get_wall_axis(wall1)
|
||||
axis2 = tool.Model.get_wall_axis(wall2)
|
||||
intersect = tool.Cad.intersect_edges(axis1["reference"], axis2["reference"])
|
||||
if intersect:
|
||||
intersect, _ = intersect
|
||||
else:
|
||||
return
|
||||
connection = "ATEND" if tool.Cad.edge_percent(intersect, axis1["reference"]) > 0.5 else "ATSTART"
|
||||
|
||||
ifcopenshell.api.geometry.connect_path(
|
||||
tool.Ifc.get(),
|
||||
related_element=element1,
|
||||
relating_element=element2,
|
||||
relating_connection="ATPATH",
|
||||
related_connection=connection,
|
||||
description="BUTT",
|
||||
)
|
||||
|
||||
tool.Model.recreate_wall(element1, wall1, axis1["reference"], axis1["reference"])
|
||||
|
||||
def connect(self, obj1: bpy.types.Object, obj2: bpy.types.Object) -> None:
|
||||
wall1 = tool.Ifc.get_entity(obj1)
|
||||
wall2 = tool.Ifc.get_entity(obj2)
|
||||
|
||||
@@ -937,13 +937,21 @@ class EditObjectUI:
|
||||
row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row
|
||||
add_layout_hotkey_operator(row, "Mitre", "S_Y", "", ui_context)
|
||||
row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row
|
||||
add_layout_hotkey_operator(
|
||||
row,
|
||||
"Split",
|
||||
"S_K",
|
||||
"Split selected Element into two Elements at the cursor location\n\nHotkey: ⇧ K",
|
||||
ui_context,
|
||||
)
|
||||
row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row
|
||||
add_layout_hotkey_operator(row, "Rotate 90", "S_R", bpy.ops.bim.rotate_90.__doc__, ui_context)
|
||||
|
||||
else:
|
||||
if "LAYER2" in AuthoringData.data["selected_material_usages"]:
|
||||
row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row
|
||||
add_layout_hotkey_operator(
|
||||
cls.layout, "Extend To Underside", "S_E", bpy.ops.bim.extend_to_underside.__doc__, ui_context
|
||||
cls.layout, "Extend To Underside", "S_E", bpy.ops.bim.extend_walls_to_underside.__doc__, ui_context
|
||||
)
|
||||
|
||||
if AuthoringData.data["is_flippable_element"]:
|
||||
@@ -1361,6 +1369,8 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator):
|
||||
return
|
||||
if self.active_material_usage == "LAYER2":
|
||||
bpy.ops.bim.split_wall()
|
||||
elif self.active_material_usage == "PROFILE":
|
||||
bpy.ops.bim.split_profile()
|
||||
|
||||
def hotkey_S_T(self):
|
||||
if not bpy.context.selected_objects:
|
||||
|
||||
@@ -101,7 +101,7 @@ class NestDecorator:
|
||||
cls.is_installed = False
|
||||
|
||||
def dotted_line_shader(self):
|
||||
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface")
|
||||
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") # ty:ignore[too-many-positional-arguments]
|
||||
vert_out.smooth("FLOAT", "v_ArcLength")
|
||||
|
||||
shader_info = gpu.types.GPUShaderCreateInfo()
|
||||
|
||||
@@ -321,7 +321,7 @@ def get_gross_perimeter(o: bpy.types.Object) -> float:
|
||||
return gross_perimeter
|
||||
|
||||
|
||||
def get_space_net_perimeter(obj: bpy.types.Object) -> float:
|
||||
def get_space_net_perimeter(obj: bpy.types.Object) -> None:
|
||||
pass
|
||||
|
||||
|
||||
|
||||
@@ -619,7 +619,7 @@ class SelectFilterElements(bpy.types.Operator):
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class ApplyFilterFromText(Operator, tool.Ifc.Operator):
|
||||
class ApplyFilterFromText(Operator):
|
||||
bl_idname = "bim.apply_filter_from_text"
|
||||
bl_label = "Apply Filter Configuration"
|
||||
bl_description = "Apply the JSON filter configuration from the current text block"
|
||||
@@ -1440,7 +1440,7 @@ class ShowAllElements(Operator):
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class SelectSimilar(Operator, tool.Ifc.Operator):
|
||||
class SelectSimilar(Operator):
|
||||
bl_idname = "bim.select_similar"
|
||||
bl_label = "Select Similar"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@@ -28,6 +28,7 @@ import ifcopenshell.util.representation
|
||||
import ifcopenshell.util.unit
|
||||
import ifcopenshell.util.unit as ifcunit
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
from mathutils import Vector
|
||||
|
||||
import bonsai.tool as tool
|
||||
@@ -478,7 +479,7 @@ class ShaderInfo:
|
||||
"""get the args to the point shader"""
|
||||
location = np.array(location)
|
||||
indices = []
|
||||
direction_dict = {
|
||||
direction_dict: dict[str, tuple[npt.NDArray, ...]] = {
|
||||
"fx": (np.array((1, 0, 0)), np.array((0, 1, 0)), np.array((0, 0, 1))),
|
||||
"fy": (np.array((0, 1, 0)), np.array((1, 0, 0)), np.array((0, 0, 1))),
|
||||
"fz": (np.array((0, 0, 1)), np.array((0, 1, 0)), np.array((1, 0, 0))),
|
||||
|
||||
@@ -83,7 +83,7 @@ class DecorationShader:
|
||||
PARALLEL DISTRIBUTED FORCE,
|
||||
DISTRIBUTED MOMENT,
|
||||
"""
|
||||
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface")
|
||||
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") # ty:ignore[too-many-positional-arguments]
|
||||
vert_out.smooth("VEC3", "forces")
|
||||
vert_out.smooth("VEC3", "co")
|
||||
|
||||
@@ -203,7 +203,7 @@ class DecorationShader:
|
||||
"""param: pattern: type of pattern
|
||||
SINGLE FORCE,
|
||||
SINGLE MOMENT"""
|
||||
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface")
|
||||
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") # ty: ignore[too-many-positional-arguments]
|
||||
vert_out.smooth("VEC3", "co")
|
||||
|
||||
shader_info = gpu.types.GPUShaderCreateInfo()
|
||||
@@ -253,7 +253,7 @@ class DecorationShader:
|
||||
|
||||
def get_planar_shader(self) -> gpu.types.GPUShader:
|
||||
"""shader for planar loads"""
|
||||
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface")
|
||||
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") # ty: ignore[too-many-positional-arguments]
|
||||
vert_out.smooth("VEC3", "co")
|
||||
|
||||
shader_info = gpu.types.GPUShaderCreateInfo()
|
||||
|
||||
@@ -787,7 +787,6 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
|
||||
doc: DocPreferences
|
||||
default_parameters: DefaultParameters
|
||||
container_hide_show_isolate: bool
|
||||
mass_time_units_in_wizard: bool
|
||||
chain_filter_with_set_operations: bool
|
||||
save_metadata_blend_file: bool
|
||||
metadata_blend_file_suffix: str
|
||||
@@ -986,7 +985,6 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
|
||||
|
||||
def draw_extras_settings(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
|
||||
layout.prop(self, "container_hide_show_isolate")
|
||||
layout.prop(self, "mass_time_units_in_wizard")
|
||||
row = layout.row(align=True)
|
||||
row.prop(self, "chain_filter_with_set_operations")
|
||||
row.operator("bim.open_uri", text="", icon="HELP").uri = "https://community.osarch.org/discussion/3270"
|
||||
|
||||
@@ -51,21 +51,11 @@ def add_instance_flooring_covering_from_cursor(
|
||||
if isinstance(space_polygon, str):
|
||||
return
|
||||
|
||||
bm = spatial.get_bmesh_from_polygon(space_polygon, h=0, polygon_is_si=True)
|
||||
name = "Covering"
|
||||
mesh = spatial.get_named_mesh_from_bmesh(name=name, bmesh=bm)
|
||||
|
||||
obj = spatial.get_named_obj_from_mesh(name, mesh)
|
||||
|
||||
obj = spatial.create_object("Covering")
|
||||
spatial.set_obj_origin_to_cursor_position_and_zero_elevation(obj)
|
||||
spatial.translate_obj_to_z_location(obj, z)
|
||||
points = spatial.get_2d_vertices_from_obj(obj)
|
||||
points = spatial.get_scaled_2d_vertices(points)
|
||||
spatial.assign_type_to_obj(obj)
|
||||
|
||||
spatial.assign_swept_area_outer_curve_from_2d_vertices(obj, vertices=points)
|
||||
body = spatial.get_body_representation(obj)
|
||||
spatial.regen_obj_representation(obj, body)
|
||||
spatial.set_covering_representation_from_polygon(obj, space_polygon, polygon_is_si=True)
|
||||
|
||||
|
||||
def add_instance_ceiling_covering_from_cursor(
|
||||
@@ -95,21 +85,11 @@ def add_instance_ceiling_covering_from_cursor(
|
||||
if isinstance(space_polygon, str):
|
||||
return
|
||||
|
||||
bm = spatial.get_bmesh_from_polygon(space_polygon, h=0, polygon_is_si=True)
|
||||
name = "Covering"
|
||||
mesh = spatial.get_named_mesh_from_bmesh(name=name, bmesh=bm)
|
||||
|
||||
obj = spatial.get_named_obj_from_mesh(name, mesh)
|
||||
|
||||
obj = spatial.create_object("Covering")
|
||||
spatial.set_obj_origin_to_cursor_position_and_zero_elevation(obj)
|
||||
spatial.translate_obj_to_z_location(obj, z + ceiling_height)
|
||||
points = spatial.get_2d_vertices_from_obj(obj)
|
||||
points = spatial.get_scaled_2d_vertices(points)
|
||||
spatial.assign_type_to_obj(obj)
|
||||
|
||||
spatial.assign_swept_area_outer_curve_from_2d_vertices(obj, vertices=points)
|
||||
body = spatial.get_body_representation(obj)
|
||||
spatial.regen_obj_representation(obj, body)
|
||||
spatial.set_covering_representation_from_polygon(obj, space_polygon, polygon_is_si=True)
|
||||
|
||||
|
||||
def regen_selected_covering_object(root: type[tool.Root], spatial: type[tool.Spatial]) -> None:
|
||||
@@ -127,19 +107,7 @@ def regen_selected_covering_object(root: type[tool.Root], spatial: type[tool.Spa
|
||||
if isinstance(space_polygon, str):
|
||||
return
|
||||
|
||||
bm = spatial.get_bmesh_from_polygon(space_polygon, h=0, polygon_is_si=True)
|
||||
|
||||
name = "Aux"
|
||||
mesh = spatial.get_named_mesh_from_bmesh(name=name, bmesh=bm)
|
||||
mesh = spatial.get_transformed_mesh_from_local_to_global(mesh)
|
||||
obj = spatial.get_named_obj_from_mesh(name, mesh)
|
||||
|
||||
points = spatial.get_2d_vertices_from_obj(obj)
|
||||
points = spatial.get_scaled_2d_vertices(points)
|
||||
|
||||
spatial.assign_swept_area_outer_curve_from_2d_vertices(active_obj, vertices=points)
|
||||
body = spatial.get_body_representation(active_obj)
|
||||
spatial.regen_obj_representation(active_obj, body)
|
||||
spatial.set_covering_representation_from_polygon(active_obj, space_polygon, polygon_is_si=True)
|
||||
|
||||
|
||||
# TODO CHECK IF IT IS POSSIBLE TO CREATE ONLY ONE CORE FUNCTION FOR _FROM_WALLS
|
||||
@@ -151,22 +119,13 @@ def add_instance_flooring_coverings_from_walls(root: type[tool.Root], spatial: t
|
||||
union = spatial.get_union_shape_from_selected_objects()
|
||||
for i, linear_ring in enumerate(union.interiors):
|
||||
poly = spatial.get_buffered_poly_from_linear_ring(linear_ring)
|
||||
bm = spatial.get_bmesh_from_polygon(poly, h=0, polygon_is_si=False)
|
||||
|
||||
name = "Covering" + str(i)
|
||||
obj = spatial.get_named_obj_from_bmesh(name, bmesh=bm)
|
||||
|
||||
spatial.set_obj_origin_to_bboxcenter(obj)
|
||||
obj = spatial.create_object(name)
|
||||
spatial.set_obj_origin_to_polygon_center(obj, poly, polygon_is_si=False)
|
||||
spatial.translate_obj_to_z_location(obj, z)
|
||||
|
||||
points = spatial.get_2d_vertices_from_obj(obj)
|
||||
points = spatial.get_scaled_2d_vertices(points)
|
||||
|
||||
spatial.assign_type_to_obj(obj)
|
||||
|
||||
spatial.assign_swept_area_outer_curve_from_2d_vertices(obj, vertices=points)
|
||||
body = spatial.get_body_representation(obj)
|
||||
spatial.regen_obj_representation(obj, body)
|
||||
spatial.set_covering_representation_from_polygon(obj, poly, polygon_is_si=False)
|
||||
|
||||
|
||||
def add_instance_ceiling_coverings_from_walls(
|
||||
@@ -179,22 +138,13 @@ def add_instance_ceiling_coverings_from_walls(
|
||||
union = spatial.get_union_shape_from_selected_objects()
|
||||
for i, linear_ring in enumerate(union.interiors):
|
||||
poly = spatial.get_buffered_poly_from_linear_ring(linear_ring)
|
||||
bm = spatial.get_bmesh_from_polygon(poly, h=0, polygon_is_si=False)
|
||||
|
||||
name = "Covering" + str(i)
|
||||
obj = spatial.get_named_obj_from_bmesh(name, bmesh=bm)
|
||||
|
||||
spatial.set_obj_origin_to_bboxcenter(obj)
|
||||
obj = spatial.create_object(name)
|
||||
spatial.set_obj_origin_to_polygon_center(obj, poly, polygon_is_si=False)
|
||||
spatial.translate_obj_to_z_location(obj, z)
|
||||
|
||||
points = spatial.get_2d_vertices_from_obj(obj)
|
||||
points = spatial.get_scaled_2d_vertices(points)
|
||||
|
||||
spatial.assign_type_to_obj(obj)
|
||||
|
||||
spatial.assign_swept_area_outer_curve_from_2d_vertices(obj, vertices=points)
|
||||
body = spatial.get_body_representation(obj)
|
||||
spatial.regen_obj_representation(obj, body)
|
||||
spatial.set_covering_representation_from_polygon(obj, poly, polygon_is_si=False)
|
||||
|
||||
|
||||
class NoDefaultContainer(Exception):
|
||||
|
||||
@@ -432,10 +432,7 @@ def update_drawing_name(
|
||||
if drawing_tool.get_name(drawing) != name:
|
||||
ifc.run("attribute.edit_attributes", product=drawing, attributes={"Name": name})
|
||||
|
||||
# Update the camera object name
|
||||
camera = ifc.get_object(drawing)
|
||||
if camera and camera.name != name:
|
||||
camera.name = name
|
||||
drawing_tool.set_camera_name(drawing, name)
|
||||
|
||||
group = drawing_tool.get_drawing_group(drawing)
|
||||
if drawing_tool.get_name(group) != name:
|
||||
|
||||
@@ -109,7 +109,7 @@ def align_walls(
|
||||
align_type: AlignType,
|
||||
):
|
||||
reference_obj = blender.get_active_object(is_selected=True)
|
||||
if not (e := ifc.get_entity(reference_obj) or not model.get_usage_type(e) == "LAYER2"):
|
||||
if not reference_obj or not (e := ifc.get_entity(reference_obj)) or not model.get_usage_type(e) == "LAYER2":
|
||||
reference_obj = None
|
||||
objs = [
|
||||
o
|
||||
@@ -159,48 +159,6 @@ def extend_wall_to_slab(
|
||||
model.reload_body_representation(wall_objs)
|
||||
|
||||
|
||||
def join_walls_TZ(
|
||||
ifc: type[tool.Ifc],
|
||||
blender: type[tool.Blender],
|
||||
geometry: type[tool.Geometry],
|
||||
joiner: DumbWallJoiner,
|
||||
model: type[tool.Model],
|
||||
) -> None:
|
||||
selected_objs = [
|
||||
o
|
||||
for o in blender.get_selected_objects()
|
||||
if (e := ifc.get_entity(o)) and model.get_usage_type(e) in ("LAYER2", "LAYER3")
|
||||
]
|
||||
if len(selected_objs) < 2:
|
||||
raise RequireAtLeastTwoLayeredElements(
|
||||
"Two or more vertically or horizontally layered elements must be selected to connect their paths together"
|
||||
)
|
||||
|
||||
for obj in selected_objs:
|
||||
geometry.clear_scale(obj)
|
||||
|
||||
elements = [ifc.get_entity(o) for o in blender.get_selected_objects()]
|
||||
layer2_elements = []
|
||||
layer3_elements = []
|
||||
for element in elements:
|
||||
usage = model.get_usage_type(element)
|
||||
if usage == "LAYER2":
|
||||
layer2_elements.append(element)
|
||||
elif usage == "LAYER3":
|
||||
layer3_elements.append(element)
|
||||
if layer3_elements:
|
||||
target = ifc.get_object(layer3_elements[0])
|
||||
for element in layer2_elements:
|
||||
joiner.join_Z(ifc.get_object(element), target)
|
||||
else:
|
||||
if not (active_obj := blender.get_active_object()):
|
||||
active_obj = selected_objs[0]
|
||||
for obj in selected_objs:
|
||||
if obj == active_obj:
|
||||
continue
|
||||
joiner.join_T(obj, active_obj)
|
||||
|
||||
|
||||
class RequireTwoWallsError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@@ -219,13 +219,8 @@ def generate_space(
|
||||
else:
|
||||
assert space_polygon
|
||||
|
||||
bm = spatial.get_bmesh_from_polygon(space_polygon, h=h, polygon_is_si=True)
|
||||
|
||||
mesh = spatial.get_named_mesh_from_bmesh(name="Space", bmesh=bm)
|
||||
|
||||
if element and element.is_a("IfcSpace"):
|
||||
mesh = spatial.get_transformed_mesh_from_local_to_global(mesh)
|
||||
spatial.edit_active_space_obj_from_mesh(mesh)
|
||||
spatial.set_space_representation_from_polygon(active_obj, element, space_polygon, h, polygon_is_si=True)
|
||||
spatial.translate_obj_to_z_location(active_obj, z)
|
||||
else:
|
||||
if relating_type:
|
||||
@@ -233,12 +228,13 @@ def generate_space(
|
||||
else:
|
||||
name = "Space"
|
||||
|
||||
obj = spatial.get_named_obj_from_mesh(name, mesh)
|
||||
obj = spatial.create_object(name)
|
||||
spatial.set_obj_origin_to_cursor_position_and_zero_elevation(obj)
|
||||
spatial.translate_obj_to_z_location(obj, z)
|
||||
spatial.assign_ifcspace_class_to_obj(obj)
|
||||
|
||||
element = ifc.get_entity(obj)
|
||||
spatial.set_space_representation_from_polygon(obj, element, space_polygon, h, polygon_is_si=True)
|
||||
|
||||
if relating_type:
|
||||
spatial.assign_relating_type_to_element(ifc, type, element, relating_type)
|
||||
@@ -257,16 +253,16 @@ def generate_spaces_from_walls(
|
||||
for i, linear_ring in enumerate(union.interiors):
|
||||
poly = spatial.get_buffered_poly_from_linear_ring(linear_ring)
|
||||
|
||||
bm = spatial.get_bmesh_from_polygon(poly, h, polygon_is_si=False)
|
||||
|
||||
name = "Space" + str(i)
|
||||
|
||||
obj = spatial.get_named_obj_from_bmesh(name, bmesh=bm)
|
||||
|
||||
spatial.set_obj_origin_to_bboxcenter_and_zero_elevation(obj)
|
||||
obj = spatial.create_object(name)
|
||||
spatial.set_obj_origin_to_polygon_center(obj, poly, polygon_is_si=False)
|
||||
spatial.translate_obj_to_z_location(obj, z)
|
||||
spatial.assign_ifcspace_class_to_obj(obj)
|
||||
|
||||
element = ifc.get_entity(obj)
|
||||
spatial.set_space_representation_from_polygon(obj, element, poly, h, polygon_is_si=False)
|
||||
|
||||
|
||||
def toggle_space_visibility(ifc: type[tool.Ifc], spatial: type[tool.Spatial]) -> None:
|
||||
model = ifc.get()
|
||||
|
||||
@@ -349,7 +349,10 @@ class Drawing:
|
||||
def enable_editing_text(cls, obj): pass
|
||||
def ensure_unique_drawing_name(cls, name): pass
|
||||
def ensure_unique_identification(cls, identification): pass
|
||||
def export_font_size(cls, obj): pass
|
||||
def export_symbol(cls, obj): pass
|
||||
def export_text_literal_attributes(cls, obj): pass
|
||||
def export_wrap_length(cls, obj): pass
|
||||
def generate_drawing_matrix(cls, target_view, location_hint): pass
|
||||
def generate_drawing_name(cls, target_view, location_hint): pass
|
||||
def generate_reference_attributes(cls, reference, **attributes): pass
|
||||
@@ -402,6 +405,7 @@ class Drawing:
|
||||
def run_root_assign_class(cls, obj=None, ifc_class=None, predefined_type=None, should_add_representation=True, context=None, ifc_representation_class=None): pass
|
||||
def run_type_assign_type(cls, element=None, relating_type=None): pass
|
||||
def select_assigned_product(cls, drawing): pass
|
||||
def set_camera_name(cls, drawing, name): pass
|
||||
def set_drawing_collection_name(cls, drawing, collection): pass
|
||||
def set_name(cls, element, name): pass
|
||||
def setup_annotation_object(cls, obj, object_type): pass
|
||||
@@ -620,7 +624,10 @@ class Model:
|
||||
def import_rectangle(cls, obj, position, profile): pass
|
||||
def load_openings(cls, openings): pass
|
||||
def purge_scene_openings(cls): pass
|
||||
def recalculate_walls(cls, objs): pass
|
||||
def regenerate_array(cls, parent, data): pass
|
||||
def regenerate_profile(cls, obj): pass
|
||||
def regenerate_slab(cls, obj): pass
|
||||
def reload_body_representation(cls, obj_or_objects): pass
|
||||
def replace_object_ifc_representation(cls, ifc_file, ifc_context, obj, new_representation): pass
|
||||
|
||||
@@ -992,14 +999,12 @@ class Spatial:
|
||||
def get_purged_inner_holes_poly(cls, union_geom, min_area): pass
|
||||
def get_poly_valid_interior_list(cls, poly, min_area, interiors_list): pass
|
||||
def get_buffered_poly_from_linear_ring(cls, linear_ring): pass
|
||||
def get_bmesh_from_polygon(cls, poly, h, polygon_is_si=False): pass
|
||||
def get_named_obj_from_bmesh(cls, name, bmesh): pass
|
||||
def get_named_obj_from_mesh(cls, name, mesh): pass
|
||||
def get_named_mesh_from_bmesh(cls, name, bmesh): pass
|
||||
def get_transformed_mesh_from_local_to_global(cls, mesh): pass
|
||||
def edit_active_space_obj_from_mesh(cls, mesh): pass
|
||||
def set_obj_origin_to_bboxcenter(cls, obj): pass
|
||||
def set_obj_origin_to_bboxcenter_and_zero_elevation(cls, obj): pass
|
||||
def get_2d_vertices_from_polygon(cls, poly, obj, polygon_is_si=True): pass
|
||||
def set_extrusion_representation_from_polygon(cls, obj, element, poly, depth_ifc, polygon_is_si=True): pass
|
||||
def set_space_representation_from_polygon(cls, obj, element, poly, h, polygon_is_si=True): pass
|
||||
def set_covering_representation_from_polygon(cls, obj, poly, polygon_is_si=True): pass
|
||||
def create_object(cls, name): pass
|
||||
def set_obj_origin_to_polygon_center(cls, obj, poly, polygon_is_si=True): pass
|
||||
def set_obj_origin_to_cursor_position_and_zero_elevation(cls, obj): pass
|
||||
def get_selected_objects(cls): pass
|
||||
def get_active_obj(cls): pass
|
||||
@@ -1007,14 +1012,9 @@ class Spatial:
|
||||
def get_active_obj_height(cls): pass
|
||||
def get_relating_type_id(cls): pass
|
||||
def translate_obj_to_z_location(cls, obj, z): pass
|
||||
def get_2d_vertices_from_obj(cls, obj): pass
|
||||
def get_scaled_2d_vertices(cls, points): pass
|
||||
def assign_swept_area_outer_curve_from_2d_vertices(cls, obj, vertices): pass
|
||||
def get_body_representation(cls, obj): pass
|
||||
def assign_ifcspace_class_to_obj(cls, obj): pass
|
||||
def assign_type_to_obj(cls, obj): pass
|
||||
def assign_relating_type_to_element(cls, ifc, type, element, relating_type): pass
|
||||
def regen_obj_representation(cls, obj, body): pass
|
||||
def toggle_spaces_visibility_wired_and_textured(cls, spaces): pass
|
||||
def toggle_hide_spaces(cls, spaces): pass
|
||||
def set_default_container(cls, container): pass
|
||||
@@ -1119,6 +1119,8 @@ class Type:
|
||||
def get_representation_context(cls, representation): pass
|
||||
def get_type_occurrences(cls, element_type): pass
|
||||
def has_material_usage(cls, element): pass
|
||||
def record_material_usage_attributes(cls, element): pass
|
||||
def restore_material_usage_attributes(cls, element, usage_attributes): pass
|
||||
def run_geometry_add_representation(cls, obj=None, context=None, ifc_representation_class=None, profile_set_usage=None): pass
|
||||
def run_geometry_switch_representation(cls, obj=None, representation=None): pass
|
||||
|
||||
|
||||
@@ -49,21 +49,40 @@ class Aggregate(bonsai.core.tool.Aggregate):
|
||||
related_object = tool.Ifc.get_entity(related_obj)
|
||||
if not relating_object or not related_object:
|
||||
return False
|
||||
if relating_object == related_object:
|
||||
return False
|
||||
|
||||
is_compatible_class = False
|
||||
if (relating_object.is_a("IfcElement") or relating_object.is_a("IfcElementType")) and related_object.is_a(
|
||||
"IfcElement"
|
||||
):
|
||||
return True
|
||||
if tool.Ifc.get_schema() == "IFC2X3":
|
||||
is_compatible_class = True
|
||||
elif tool.Ifc.get_schema() == "IFC2X3":
|
||||
if relating_object.is_a("IfcSpatialStructureElement") and related_object.is_a("IfcSpatialStructureElement"):
|
||||
return True
|
||||
if relating_object.is_a("IfcProject") and related_object.is_a("IfcSpatialStructureElement"):
|
||||
return True
|
||||
is_compatible_class = True
|
||||
elif relating_object.is_a("IfcProject") and related_object.is_a("IfcSpatialStructureElement"):
|
||||
is_compatible_class = True
|
||||
else:
|
||||
if relating_object.is_a("IfcSpatialElement") and related_object.is_a("IfcSpatialElement"):
|
||||
return True
|
||||
if relating_object.is_a("IfcProject") and related_object.is_a("IfcSpatialElement"):
|
||||
return True
|
||||
return False
|
||||
is_compatible_class = True
|
||||
elif relating_object.is_a("IfcProject") and related_object.is_a("IfcSpatialElement"):
|
||||
is_compatible_class = True
|
||||
|
||||
if not is_compatible_class:
|
||||
return False
|
||||
|
||||
# Prevent cyclic references: walk up the full hierarchy from the
|
||||
# proposed parent and reject if we encounter the proposed child.
|
||||
ancestor = ifcopenshell.util.element.get_parent(relating_object)
|
||||
seen = {relating_object}
|
||||
while ancestor:
|
||||
if ancestor == related_object:
|
||||
return False
|
||||
if ancestor in seen:
|
||||
break
|
||||
seen.add(ancestor)
|
||||
ancestor = ifcopenshell.util.element.get_parent(ancestor)
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def has_physical_body_representation(cls, element: ifcopenshell.entity_instance) -> bool:
|
||||
|
||||
@@ -32,6 +32,8 @@ import bonsai.core.tool
|
||||
import bonsai.tool as tool
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bsdd.bsdd import ClassContractV1, ClassPropertyContractV1, PropertyContractV5
|
||||
|
||||
from bonsai.bim.module.bsdd.prop import BIMBSDDProperties, BSDDDictionary
|
||||
|
||||
|
||||
@@ -39,8 +41,8 @@ class Bsdd(bonsai.core.tool.Bsdd):
|
||||
default_identifier_url = "https://identifier.buildingsmart.org"
|
||||
default_api_url = "https://api.bsdd.buildingsmart.org/api/"
|
||||
client = bsdd.Client()
|
||||
bsdd_classes: dict[str, dict] = {}
|
||||
bsdd_properties: dict[str, dict] = {}
|
||||
bsdd_classes: dict[str, ClassContractV1] = {}
|
||||
bsdd_properties: dict[str, ClassPropertyContractV1 | PropertyContractV5] = {}
|
||||
|
||||
@classmethod
|
||||
def identifier_url(cls) -> str:
|
||||
@@ -267,7 +269,8 @@ class Bsdd(bonsai.core.tool.Bsdd):
|
||||
@classmethod
|
||||
def get_bsdd_property(cls, uri: str) -> dict:
|
||||
if not (bsdd_property := cls.bsdd_properties.get(uri, {})):
|
||||
bsdd_property = cls.client.get_property(uri, include_classes=True)
|
||||
# Cache miss occurs for keyword search mode, for classes cache is prepopulated.
|
||||
bsdd_property = cls.client.get_property(uri)
|
||||
cls.bsdd_properties[uri] = bsdd_property
|
||||
return bsdd_property
|
||||
|
||||
|
||||
@@ -154,7 +154,7 @@ class Cost(bonsai.core.tool.Cost):
|
||||
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)
|
||||
sound = aud.Sound(filepath) # ty:ignore[too-many-positional-arguments]
|
||||
device.play(sound)
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -857,6 +857,8 @@ class Drawing(bonsai.core.tool.Drawing):
|
||||
|
||||
@classmethod
|
||||
def edit_text_literals(cls, obj: bpy.types.Object, literal_attributes: dict) -> None:
|
||||
if not literal_attributes:
|
||||
return
|
||||
assert (element := tool.Ifc.get_entity(obj))
|
||||
assert (rep := cls.get_annotation_representation(element))
|
||||
to_remove = [i for i in rep.Items if i.is_a("IfcTextLiteral")]
|
||||
@@ -1288,6 +1290,12 @@ class Drawing(bonsai.core.tool.Drawing):
|
||||
def get_representation(cls, element, context):
|
||||
return ifcopenshell.util.representation.get_representation(element, context)
|
||||
|
||||
@classmethod
|
||||
def set_camera_name(cls, drawing: ifcopenshell.entity_instance, name: str) -> None:
|
||||
camera = tool.Ifc.get_object(drawing)
|
||||
if camera and camera.name != name:
|
||||
camera.name = name
|
||||
|
||||
@classmethod
|
||||
def set_drawing_collection_name(
|
||||
cls, drawing: ifcopenshell.entity_instance, collection: bpy.types.Collection
|
||||
|
||||
@@ -1407,8 +1407,6 @@ class Geometry(bonsai.core.tool.Geometry):
|
||||
:param representation_item: item to remove.
|
||||
:param element: item's element. Is used to unmark manual booleans.
|
||||
"""
|
||||
# NOTE: we assume it's not the last representation item
|
||||
# otherwise we probably would need to remove representation too
|
||||
# NOTE: a lot of shared code with `geometry.remove_representation`
|
||||
ifc_file = tool.Ifc.get()
|
||||
shape_aspects: list[ifcopenshell.entity_instance] = []
|
||||
@@ -1467,7 +1465,10 @@ class Geometry(bonsai.core.tool.Geometry):
|
||||
cls.remove_representation_items_from_shape_aspect([representation_item], shape_aspect)
|
||||
|
||||
if representation:
|
||||
representation.Items = tuple(set(representation.Items) - {representation_item})
|
||||
new_items = tuple(set(representation.Items) - {representation_item})
|
||||
if not new_items:
|
||||
return
|
||||
representation.Items = new_items
|
||||
also_consider = list(consider_inverses)
|
||||
ifcopenshell.util.element.remove_deep2(ifc_file, representation_item, also_consider=also_consider)
|
||||
|
||||
|
||||
@@ -197,12 +197,16 @@ class Ifc(bonsai.core.tool.Ifc):
|
||||
if not cls.get():
|
||||
return
|
||||
|
||||
# Clear all per-object msgbus subscriptions at once using the dedicated
|
||||
# owner. After undo/redo, per-object Python wrappers have new
|
||||
# identities so clearing by individual obj would miss stale
|
||||
# subscriptions registered with the old wrappers.
|
||||
bpy.msgbus.clear_by_owner(bonsai.bim.handler.object_subscription_owner)
|
||||
|
||||
for obj in bpy.data.objects:
|
||||
if obj.library:
|
||||
continue
|
||||
|
||||
bpy.msgbus.clear_by_owner(obj)
|
||||
|
||||
element = cls.get_entity(obj)
|
||||
if not element:
|
||||
continue
|
||||
@@ -217,8 +221,6 @@ class Ifc(bonsai.core.tool.Ifc):
|
||||
if obj.library:
|
||||
continue
|
||||
|
||||
bpy.msgbus.clear_by_owner(obj)
|
||||
|
||||
style = cls.get_entity(obj)
|
||||
if not style:
|
||||
continue
|
||||
|
||||
@@ -284,7 +284,7 @@ class IfcGit:
|
||||
if re.match("^Ifc", obj.name):
|
||||
bpy.data.objects.remove(obj, do_unlink=True)
|
||||
|
||||
bpy.data.orphans_purge(do_recursive=True)
|
||||
bpy.data.orphans_purge(do_recursive=True) # ty:ignore[unknown-argument]
|
||||
|
||||
settings = import_ifc.IfcImportSettings.factory(bpy.context, path_ifc, logging.getLogger("ImportIFC"))
|
||||
settings.should_setup_viewport_camera = False
|
||||
|
||||
@@ -45,9 +45,23 @@ class Nest(bonsai.core.tool.Nest):
|
||||
related_object = tool.Ifc.get_entity(related_obj)
|
||||
if not relating_object or not related_object:
|
||||
return False
|
||||
if relating_object.is_a("IfcElement") and related_object.is_a("IfcElement"):
|
||||
return True
|
||||
return False
|
||||
if relating_object == related_object:
|
||||
return False
|
||||
is_compatible_class = relating_object.is_a("IfcElement") and related_object.is_a("IfcElement")
|
||||
if not is_compatible_class:
|
||||
return False
|
||||
# Prevent cyclic references: walk up the full hierarchy from the
|
||||
# proposed parent and reject if we encounter the proposed child.
|
||||
ancestor = ifcopenshell.util.element.get_parent(relating_object)
|
||||
seen = {relating_object}
|
||||
while ancestor:
|
||||
if ancestor == related_object:
|
||||
return False
|
||||
if ancestor in seen:
|
||||
break
|
||||
seen.add(ancestor)
|
||||
ancestor = ifcopenshell.util.element.get_parent(ancestor)
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def disable_editing(cls, obj: bpy.types.Object) -> None:
|
||||
|
||||
@@ -16,6 +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/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Union
|
||||
|
||||
import bmesh
|
||||
@@ -41,6 +44,7 @@ class Raycast(bonsai.core.tool.Raycast):
|
||||
(0, -offset),
|
||||
(offset, -offset),
|
||||
)
|
||||
snap_objs = []
|
||||
|
||||
@classmethod
|
||||
def get_visible_objects(cls, context: bpy.types.Context):
|
||||
@@ -69,8 +73,14 @@ class Raycast(bonsai.core.tool.Raycast):
|
||||
rv3d = context.region_data
|
||||
assert rv3d
|
||||
view_location = rv3d.view_matrix.inverted().translation
|
||||
view_normal = rv3d.view_rotation @ mathutils.Vector((0.0, 0.0, -1.0))
|
||||
obj_matrix = obj.matrix_world.copy()
|
||||
bbox = [obj_matrix @ Vector(v) for v in obj.bound_box]
|
||||
bbox_edges = [
|
||||
(0,1),(1,2),(2,3),(3,0),
|
||||
(4,5),(5,6),(6,7),(7,4),
|
||||
(0,4),(1,5),(2,6),(3,7)
|
||||
]
|
||||
|
||||
transposed_bbox: list[Vector] = []
|
||||
bbox_2d: list[float] = []
|
||||
@@ -94,8 +104,23 @@ class Raycast(bonsai.core.tool.Raycast):
|
||||
|
||||
for v in bbox:
|
||||
coord_2d = tool.Cad.location_3d_to_region_2d_np(context.region, context.space_data.region_3d, v)
|
||||
if coord_2d is not None:
|
||||
transposed_bbox.append(coord_2d)
|
||||
transposed_bbox.append(coord_2d)
|
||||
|
||||
if not any(transposed_bbox):
|
||||
transposed_bbox = []
|
||||
# If there are None values in transposed_bbox it means that there are vertices behind the camera
|
||||
# so we get the intersection of the edge with the region border
|
||||
# new_bbox = []
|
||||
if any(transposed_bbox) and not all(transposed_bbox):
|
||||
new_bbox = transposed_bbox.copy()
|
||||
new_bbox = [x for x in new_bbox if x is not None]
|
||||
for edge in bbox_edges:
|
||||
if (transposed_bbox[edge[0]] is None) ^ (transposed_bbox[edge[1]] is None):
|
||||
point, _ = cls.intersect_edge_region_border(context.region, context.space_data, rv3d, bbox[edge[0]], bbox[edge[1]])
|
||||
if point:
|
||||
new_bbox.append(point)
|
||||
if new_bbox:
|
||||
transposed_bbox = new_bbox
|
||||
|
||||
region = context.region
|
||||
borders = (0, region.width, 0, region.height)
|
||||
@@ -117,6 +142,99 @@ class Raycast(bonsai.core.tool.Raycast):
|
||||
return (obj, bbox_2d)
|
||||
return None
|
||||
|
||||
def intersect_edge_region_border(region, space, rv3d, v1, v2):
|
||||
def segment_intersect_near_plane(view_matrix, clip_start, p_world_a, p_world_b):
|
||||
a_view = view_matrix @ p_world_a
|
||||
b_view = view_matrix @ p_world_b
|
||||
z_near = -clip_start
|
||||
za = a_view.z
|
||||
zb = b_view.z
|
||||
denom = (zb - za)
|
||||
if denom == 0.0:
|
||||
return None, None
|
||||
t = (z_near - za) / denom
|
||||
if t < 0.0 or t > 1.0:
|
||||
return None, None
|
||||
p_view = a_view.lerp(b_view, t)
|
||||
cam_world = view_matrix.inverted()
|
||||
p_world = cam_world @ p_view
|
||||
return p_world, t
|
||||
|
||||
def is_inside_region(pt2d, region):
|
||||
return 0.0 <= pt2d.x <= region.width and 0.0 <= pt2d.y <= region.height
|
||||
|
||||
def clamp_to_region_border(point2d, region):
|
||||
x, y = point2d
|
||||
x_clamped = max(0.0, min(region.width, x))
|
||||
y_clamped = max(0.0, min(region.height, y))
|
||||
return Vector((x_clamped, y_clamped))
|
||||
|
||||
def find_nearby_onscreen_point(region, rv3d, p1, p2, initial_t_on_segment, max_iters=40, step=0.05):
|
||||
"""
|
||||
Use iterative approach: move t toward 0. Returns the first point that is inside region border
|
||||
"""
|
||||
t = initial_t_on_segment
|
||||
for i in range(max_iters):
|
||||
test_3d = p1.lerp(p2, t)
|
||||
test_2d = view3d_utils.location_3d_to_region_2d(region, rv3d, test_3d)
|
||||
if test_2d is not None and is_inside_region(test_2d, region):
|
||||
return test_3d, test_2d, t
|
||||
# move t toward 0 by reducing it by a fraction of its current value
|
||||
t -= step
|
||||
# if t is already very small, break
|
||||
if t <= 1e-6:
|
||||
break
|
||||
|
||||
return None, None, None
|
||||
|
||||
# Ensures that all the calculation uses the same direction based on which point is on the screen
|
||||
if view3d_utils.location_3d_to_region_2d(region, rv3d, v1):
|
||||
onscreen_vert = v1
|
||||
offscreen_vert = v2
|
||||
else:
|
||||
onscreen_vert = v2
|
||||
offscreen_vert = v1
|
||||
# v2, v1 = v1, v2
|
||||
|
||||
clip_start = space.clip_start
|
||||
view_mat = rv3d.view_matrix
|
||||
inter_world, t_on_ab = segment_intersect_near_plane(view_mat, clip_start, onscreen_vert, offscreen_vert)
|
||||
|
||||
if inter_world is None:
|
||||
print("No intersection with viewport near plane found for the segment.")
|
||||
return
|
||||
|
||||
init_2d = view3d_utils.location_3d_to_region_2d(region, rv3d, inter_world)
|
||||
|
||||
if init_2d is not None and is_inside_region(init_2d, region):
|
||||
final_world = inter_world
|
||||
final_2d = init_2d
|
||||
final_t = initial_t
|
||||
else:
|
||||
found_world, found_2d, found_t = find_nearby_onscreen_point(
|
||||
region, rv3d,
|
||||
onscreen_vert, offscreen_vert,
|
||||
t_on_ab,
|
||||
max_iters=600, step=0.01
|
||||
)
|
||||
if found_world is None:
|
||||
if init_2d is None:
|
||||
print("Initial projection invalid and iterative search failed.")
|
||||
return
|
||||
# fallback: clamp projected point to border via manual mapping
|
||||
final_2d = clamp_to_region_border(init_2d, region)
|
||||
final_world = None
|
||||
final_t = None
|
||||
# print("Iterative search failed; using clamped 2D:", final_2d)
|
||||
else:
|
||||
final_world = found_world
|
||||
final_2d = found_2d
|
||||
final_t = found_t
|
||||
# print(f"Found onscreen point at t={final_t:.4f}")
|
||||
|
||||
# print("Final 2D:", final_2d)
|
||||
return final_2d, v2
|
||||
|
||||
@classmethod
|
||||
def intersect_mouse_2d_bounding_box(cls, mouse_pos: tuple[int, int], bbox: list[float, float, float, float]):
|
||||
x, y = mouse_pos
|
||||
@@ -232,6 +350,161 @@ class Raycast(bonsai.core.tool.Raycast):
|
||||
else:
|
||||
return None, None, None
|
||||
|
||||
@classmethod
|
||||
def ray_cast_by_proximity_2d(
|
||||
cls,
|
||||
context: bpy.types.Context,
|
||||
event: bpy.types.Event,
|
||||
snap_obj: SnapObj,
|
||||
):
|
||||
|
||||
def divide_vector(start, end, n):
|
||||
points = []
|
||||
delta = (end - start) / n
|
||||
for i in range(1, n):
|
||||
point = start + i * delta
|
||||
points.append(point)
|
||||
return points
|
||||
|
||||
region = context.region
|
||||
rv3d = context.region_data
|
||||
mouse_pos = event.mouse_region_x, event.mouse_region_y
|
||||
ray_origin, ray_target, ray_direction = cls.get_viewport_ray_data(context, event)
|
||||
points = []
|
||||
|
||||
try:
|
||||
loc = tool.Cad.region_2d_to_location_3d_np(region, rv3d, mouse_pos, ray_direction)
|
||||
except:
|
||||
loc = Vector((0, 0, 0))
|
||||
|
||||
verts_2d = [
|
||||
view3d_utils.location_3d_to_region_2d(region, rv3d, v) for v in snap_obj.verts_3d
|
||||
] # Numpy version is worst in performance
|
||||
|
||||
intersected = snap_obj.raycast_boxes(
|
||||
context, event, snap_obj.root, intersected=[], rays=(ray_origin, ray_direction)
|
||||
)
|
||||
edges = []
|
||||
for it in intersected:
|
||||
edges.extend(it.edges)
|
||||
edges = set(edges)
|
||||
|
||||
edge_verts = {}
|
||||
for e in edges:
|
||||
verts_idx = tuple(snap_obj.obj.data.edges[e].vertices)
|
||||
verts = snap_obj.obj.data.vertices
|
||||
v1 = snap_obj.obj.matrix_world @ verts[verts_idx[0]].co
|
||||
v1_2d = verts_2d[verts_idx[0]]
|
||||
v2 = snap_obj.obj.matrix_world @ verts[verts_idx[1]].co
|
||||
v2_2d = verts_2d[verts_idx[1]]
|
||||
if (v1_2d is None) ^ (v2_2d is None):
|
||||
point, _ = cls.intersect_edge_region_border(region, context.space_data, rv3d, v1, v2)
|
||||
if v1_2d is None:
|
||||
edge_verts[e] = (point, v2_2d)
|
||||
else:
|
||||
edge_verts[e] = (v1_2d, point)
|
||||
else:
|
||||
edge_verts[e] = (v1_2d, v2_2d)
|
||||
|
||||
snap_threshold = 10.0
|
||||
|
||||
for i, point in enumerate(verts_2d):
|
||||
if not point:
|
||||
continue
|
||||
distance = (Vector(mouse_pos) - point).length
|
||||
if distance <= snap_threshold:
|
||||
snap_point = {
|
||||
"object": snap_obj.obj,
|
||||
"type": "Vertex",
|
||||
"point": snap_obj.verts_3d[i],
|
||||
"distance": distance / 10,
|
||||
}
|
||||
points.append(snap_point)
|
||||
|
||||
count = 0
|
||||
selected_edges = {}
|
||||
for e in edges:
|
||||
p0, p1 = edge_verts[e]
|
||||
p0x, p0y = p0
|
||||
p1x, p1y = p1
|
||||
px, py = mouse_pos
|
||||
|
||||
# segment vector = p1 - p0
|
||||
sx = p1x - p0x
|
||||
sy = p1y - p0y
|
||||
|
||||
# seg length squared
|
||||
seg_len_sq = sx * sx + sy * sy
|
||||
|
||||
if seg_len_sq == 0.0:
|
||||
# degenerate segment: return distance to p0
|
||||
dx = px - p0x
|
||||
dy = py - p0y
|
||||
dist = math.hypot(dx, dy)
|
||||
return dist, (p0x, p0y), 0.0
|
||||
|
||||
# project (p - p0) onto seg: t = dot(p-p0, seg) / |seg|^2
|
||||
apx = px - p0x
|
||||
apy = py - p0y
|
||||
t = (apx * sx + apy * sy) / seg_len_sq
|
||||
|
||||
# clamp to segment
|
||||
if t <= 0.0:
|
||||
t_clamped = 0.0
|
||||
cx, cy = p0x, p0y
|
||||
elif t >= 1.0:
|
||||
t_clamped = 1.0
|
||||
cx, cy = p1x, p1y
|
||||
else:
|
||||
t_clamped = t
|
||||
cx = p0x + sx * t_clamped
|
||||
cy = p0y + sy * t_clamped
|
||||
|
||||
dx = px - cx
|
||||
dy = py - cy
|
||||
dist = math.hypot(dx, dy)
|
||||
if dist <= snap_threshold:
|
||||
selected_edges[dist] = e
|
||||
|
||||
if selected_edges:
|
||||
min_dist = float("inf")
|
||||
for key in selected_edges:
|
||||
if key < min_dist:
|
||||
min_dist = key
|
||||
|
||||
idx = snap_obj.obj.data.edges[selected_edges[min_dist]].vertices
|
||||
edge_verts = (snap_obj.verts_3d[idx[0]], snap_obj.verts_3d[idx[1]])
|
||||
division_points = divide_vector(
|
||||
edge_verts[0], edge_verts[1], 2
|
||||
) # TODO Make it work for different divisions
|
||||
for division_point in division_points:
|
||||
intersection = tool.Cad.point_on_edge(division_point, (ray_target, loc))
|
||||
distance = (division_point - intersection).length
|
||||
if distance < snap_threshold:
|
||||
snap_point = {
|
||||
"object": snap_obj.obj,
|
||||
"type": "Edge Center",
|
||||
"point": division_point.copy(),
|
||||
"distance": distance,
|
||||
}
|
||||
points.append(snap_point)
|
||||
|
||||
intersection = tool.Cad.intersect_edges_v2((ray_target, loc), edge_verts)
|
||||
if intersection[0]:
|
||||
if tool.Cad.is_point_on_edge(intersection[1], edge_verts):
|
||||
distance = (intersection[1] - intersection[0]).length
|
||||
if distance < snap_threshold:
|
||||
snap_point = {
|
||||
"object": snap_obj.obj,
|
||||
"type": "Edge",
|
||||
"point": intersection[1].copy(),
|
||||
"edge_verts": edge_verts,
|
||||
"distance": distance,
|
||||
}
|
||||
points.append(snap_point)
|
||||
|
||||
return points
|
||||
|
||||
@classmethod
|
||||
def ray_cast_by_proximity(
|
||||
cls,
|
||||
@@ -457,7 +730,8 @@ class Raycast(bonsai.core.tool.Raycast):
|
||||
if bbox_2d:
|
||||
if tool.Raycast.intersect_mouse_2d_bounding_box(mouse_pos, bbox_2d):
|
||||
if tool.Raycast.object_is_visible_in_clipping_plane(obj):
|
||||
objs_to_raycast.append(obj)
|
||||
snap_obj = cls.create_snap_obj(obj)
|
||||
objs_to_raycast.append(snap_obj)
|
||||
|
||||
return objs_to_raycast
|
||||
|
||||
@@ -474,12 +748,6 @@ class Raycast(bonsai.core.tool.Raycast):
|
||||
face_index = None
|
||||
# Wireframes
|
||||
if obj.type in {"EMPTY", "CURVE"} or (hasattr(obj.data, "polygons") and len(obj.data.polygons) == 0):
|
||||
snap_points = tool.Raycast.ray_cast_by_proximity(context, event, obj)
|
||||
if snap_points:
|
||||
hit = sorted(snap_points, key=lambda x: x["distance"])[0]["point"]
|
||||
if hit:
|
||||
hit_world = obj.original.matrix_world @ hit
|
||||
return obj, hit_world, face_index
|
||||
return None, None, None
|
||||
# Meshes
|
||||
else:
|
||||
@@ -514,19 +782,20 @@ class Raycast(bonsai.core.tool.Raycast):
|
||||
|
||||
ray_origin, ray_target, ray_direction = cls.get_viewport_ray_data(context, event)
|
||||
|
||||
for obj in objs_to_raycast:
|
||||
for snap_obj in objs_to_raycast:
|
||||
if not include_wireframes and (
|
||||
obj.type in {"EMPTY", "CURVE"} or (hasattr(obj.data, "polygons") and len(obj.data.polygons) == 0)
|
||||
snap_obj.obj.type in {"EMPTY", "CURVE"}
|
||||
or (hasattr(snap_obj.obj.data, "polygons") and len(snap_obj.obj.data.polygons) == 0)
|
||||
):
|
||||
continue
|
||||
|
||||
snap_obj, hit, face_index = cls.cast_rays_to_single_object(context, event, obj)
|
||||
hit_obj, hit, face_index = cls.cast_rays_to_single_object(context, event, snap_obj.obj)
|
||||
|
||||
if hit is not None:
|
||||
length_squared = (hit - ray_origin).length_squared
|
||||
if best_obj is None or length_squared < best_length_squared:
|
||||
best_length_squared = length_squared
|
||||
best_obj = snap_obj
|
||||
best_obj = hit_obj
|
||||
best_hit = hit
|
||||
best_face_index = face_index
|
||||
|
||||
@@ -536,6 +805,81 @@ class Raycast(bonsai.core.tool.Raycast):
|
||||
else:
|
||||
return None, None, None
|
||||
|
||||
@classmethod
|
||||
def ray_cast_and_get_closest_to_camera_snaps(
|
||||
cls,
|
||||
context: bpy.types.Context,
|
||||
event: bpy.types.Event,
|
||||
objs_to_raycast: list[bpy.types.Object],
|
||||
) -> Union[tuple[bpy.types.Object, Vector, int], tuple[None, None, None]]:
|
||||
closest_length_squared = 1.0
|
||||
closest_obj = None
|
||||
closest_hit = None
|
||||
closest_face_index = None
|
||||
|
||||
ray_origin, ray_target, ray_direction = cls.get_viewport_ray_data(context, event)
|
||||
|
||||
closest_snaps = []
|
||||
hit = None
|
||||
|
||||
for snap_obj in objs_to_raycast:
|
||||
if (snap_obj.obj.type in {"EMPTY", "CURVE"}
|
||||
or (hasattr(snap_obj.obj.data, "polygons") and len(snap_obj.obj.data.polygons) == 0)
|
||||
):
|
||||
# For wireframe objects we have to test all the snaps to see which is closer
|
||||
snap_points = tool.Raycast.ray_cast_by_proximity_2d(context, event, snap_obj)
|
||||
closest_wf_hit = None
|
||||
closest_wf_length_squared = 1.0
|
||||
closest_wf_point = None
|
||||
if snap_points:
|
||||
for point in snap_points:
|
||||
point["group"] = "Wireframe"
|
||||
closest_snaps.append(point)
|
||||
length = (point["point"] - ray_origin).length_squared
|
||||
if closest_wf_hit is None or length < closest_wf_length_squared:
|
||||
closest_wf_length_squared = length
|
||||
closest_wf_hit = point["point"]
|
||||
closest_wf_point = point
|
||||
|
||||
if closest_wf_point:
|
||||
hit_obj = closest_wf_point["object"]
|
||||
hit = closest_wf_point["point"]
|
||||
face_index = None
|
||||
|
||||
|
||||
else:
|
||||
# Solid objects
|
||||
hit_obj, hit, face_index = cls.cast_rays_to_single_object(context, event, snap_obj.obj)
|
||||
|
||||
if hit:
|
||||
snap_point = {
|
||||
"point": hit,
|
||||
"type": "Face",
|
||||
"group": "Object",
|
||||
"object": hit_obj,
|
||||
"face_index": face_index,
|
||||
"distance": 9, # High value so it has low priority
|
||||
}
|
||||
closest_snaps.append(snap_point)
|
||||
|
||||
|
||||
# Here we test which is closer, including wireframe and solid objects
|
||||
if hit is not None:
|
||||
length_squared = (hit - ray_origin).length_squared
|
||||
if closest_obj is None or length_squared < closest_length_squared:
|
||||
closest_length_squared = length_squared
|
||||
closest_obj = hit_obj
|
||||
closest_hit = hit
|
||||
closest_face_index = face_index
|
||||
|
||||
# Label snaps from the closest object
|
||||
if closest_obj is not None:
|
||||
for snap in closest_snaps:
|
||||
if snap["object"] == closest_obj:
|
||||
snap["is_closest_to_camera"] = True
|
||||
|
||||
return closest_snaps
|
||||
|
||||
@classmethod
|
||||
def calculate_snap_threshold(cls, view_distance):
|
||||
snap_threshold = view_distance / 100
|
||||
@@ -547,3 +891,260 @@ class Raycast(bonsai.core.tool.Raycast):
|
||||
if lens < 50:
|
||||
snap_threshold *= value
|
||||
return snap_threshold
|
||||
|
||||
@classmethod
|
||||
def create_snap_obj(cls, obj):
|
||||
for snap_obj in cls.snap_objs:
|
||||
if obj.name == snap_obj.obj.name:
|
||||
return snap_obj
|
||||
snap_obj = SnapObj(obj)
|
||||
cls.snap_objs.append(snap_obj)
|
||||
return snap_obj
|
||||
|
||||
@classmethod
|
||||
def clear_snap_objs(cls):
|
||||
TreeNode.__clear_all__()
|
||||
SnapObj.__clear_all__()
|
||||
cls.snap_objs.clear()
|
||||
|
||||
|
||||
class TreeNode:
|
||||
all = []
|
||||
|
||||
def __init__(self, box: tuple):
|
||||
self.__class__.all.append(self)
|
||||
self.box = box
|
||||
self.child_a = None
|
||||
self.child_b = None
|
||||
self.edges = []
|
||||
|
||||
def __clear_all__():
|
||||
for instance in TreeNode.all:
|
||||
del instance
|
||||
TreeNode.all.clear()
|
||||
|
||||
|
||||
class SnapObj:
|
||||
max_depth = 9
|
||||
all = []
|
||||
|
||||
def __init__(self, obj: bpy.types.Object):
|
||||
self.__class__.all.append(self)
|
||||
self.obj = obj
|
||||
self.root = self._create_root_node()
|
||||
self.root.edges = [e.index for e in obj.data.edges]
|
||||
self.split_box(self.root, 0)
|
||||
self.verts_3d = [obj.matrix_world @ v.co for v in obj.data.vertices]
|
||||
self.snap_points = []
|
||||
|
||||
def __clear_all__():
|
||||
for instance in SnapObj.all:
|
||||
del instance
|
||||
SnapObj.all.clear()
|
||||
|
||||
def _create_root_node(self) -> TreeNode:
|
||||
bbox = tool.Blender.get_object_bounding_box(self.obj)
|
||||
min_point = self.obj.matrix_world @ bbox["min_point"]
|
||||
max_point = self.obj.matrix_world @ bbox["max_point"]
|
||||
new_bbox = self.expand_bounding_box((min_point, max_point))
|
||||
return TreeNode(new_bbox)
|
||||
|
||||
def divide_bounding_box_along_longest_axis(
|
||||
self, min_pt: Vector, max_pt: Vector
|
||||
) -> Union[tuple[Vector, Vector], tuple[Vector, Vector]]:
|
||||
"""
|
||||
Divide a bounding box into two equal parts along the axis with the longest dimension.
|
||||
|
||||
Args:
|
||||
min_pt: The minimum point of the bounding box.
|
||||
max_pt: The maximum point of the bounding box.
|
||||
|
||||
Returns:
|
||||
list: A list of two tuples, each containing the minimum and maximum points of the divided boxes.
|
||||
"""
|
||||
|
||||
# Calculate the dimensions of the box
|
||||
dx = max_pt.x - min_pt.x
|
||||
dy = max_pt.y - min_pt.y
|
||||
dz = max_pt.z - min_pt.z
|
||||
|
||||
# Determine the axis with the longest dimension
|
||||
if dx >= dy and dx >= dz:
|
||||
# Divide along the x-axis
|
||||
mid_x = min_pt.x + dx / 2
|
||||
box1 = (min_pt, Vector((mid_x, max_pt.y, max_pt.z)))
|
||||
box2 = (Vector((mid_x, min_pt.y, min_pt.z)), max_pt)
|
||||
elif dy >= dx and dy >= dz:
|
||||
# Divide along the y-axis
|
||||
mid_y = min_pt.y + dy / 2
|
||||
box1 = (min_pt, Vector((max_pt.x, mid_y, max_pt.z)))
|
||||
box2 = (Vector((min_pt.x, mid_y, min_pt.z)), max_pt)
|
||||
else:
|
||||
# Divide along the z-axis
|
||||
mid_z = min_pt.z + dz / 2
|
||||
box1 = (min_pt, Vector((max_pt.x, max_pt.y, mid_z)))
|
||||
box2 = (Vector((min_pt.x, min_pt.y, mid_z)), max_pt)
|
||||
|
||||
return [box1, box2]
|
||||
|
||||
def expand_bounding_box(self, box: tuple[Vector, Vector], offset: float = 0.1) -> tuple[Vector, Vector]:
|
||||
"""
|
||||
Expand a 3D bounding box by a given offset.
|
||||
|
||||
Args:
|
||||
min_pt: The minimum point of the bounding box.
|
||||
max_pt: The maximum point of the bounding box.
|
||||
offset: The offset to expand the bounding box by.
|
||||
|
||||
Returns:
|
||||
tuple: A tuple containing the new minimum and maximum points of the expanded bounding box.
|
||||
"""
|
||||
|
||||
min_pt, max_pt = box
|
||||
# Calculate the new minimum and maximum points
|
||||
new_min_pt = Vector((min_pt.x - offset, min_pt.y - offset, min_pt.z - offset))
|
||||
new_max_pt = Vector((max_pt.x + offset, max_pt.y + offset, max_pt.z + offset))
|
||||
|
||||
return new_min_pt, new_max_pt
|
||||
|
||||
def split_box(self, parent: TreeNode, depth: int):
|
||||
"""
|
||||
Splits the bounding box creating two child nodes to compose a BVH Tree recursively.
|
||||
|
||||
Args:
|
||||
parent: the TreeNode instance that represents the parent node of a BVH Tree.
|
||||
depth: the depth of the BVH Tree no be used in recursion.
|
||||
"""
|
||||
if depth > self.max_depth:
|
||||
return
|
||||
box_a, box_b = self.divide_bounding_box_along_longest_axis(parent.box[0], parent.box[1])
|
||||
parent.child_a = TreeNode(box_a)
|
||||
parent.child_b = TreeNode(box_b)
|
||||
edges_a = []
|
||||
edges_b = []
|
||||
for e in parent.edges:
|
||||
verts_idx = [v for v in self.obj.data.edges[e].vertices]
|
||||
verts_coords = []
|
||||
for idx in verts_idx:
|
||||
if idx < len(self.obj.data.vertices):
|
||||
verts_coords.append(self.obj.matrix_world @ self.obj.data.vertices[idx].co)
|
||||
if self.line_intersects_box(verts_coords[0], verts_coords[1], parent.child_a.box):
|
||||
edges_a.append(e)
|
||||
if self.line_intersects_box(verts_coords[0], verts_coords[1], parent.child_b.box):
|
||||
edges_b.append(e)
|
||||
parent.child_a.edges = edges_a
|
||||
parent.child_b.edges = edges_b
|
||||
self.split_box(parent.child_a, depth + 1)
|
||||
self.split_box(parent.child_b, depth + 1)
|
||||
|
||||
def raycast_box(
|
||||
self, context: bpy.types.Context, event: bpy.types.Event, node: TreeNode, rays: tuple[Vector, Vector]
|
||||
) -> bool:
|
||||
"""
|
||||
Raycast bounding box.
|
||||
|
||||
Args:
|
||||
context: Blender context.
|
||||
event: Blender event.
|
||||
node: a TreeNode instance.
|
||||
rays: tuple containing ray origin and ray direction
|
||||
|
||||
Returns:
|
||||
True if hits the box or False otherwise.
|
||||
"""
|
||||
box = node.box
|
||||
min_v = box[0]
|
||||
max_v = box[1]
|
||||
t_min = 0.0
|
||||
t_max = float("inf")
|
||||
ray_origin, ray_dir = rays
|
||||
inv_dir = Vector((1.0 / r if r != 0.0 else 1e32) for r in (ray_dir.x, ray_dir.y, ray_dir.z))
|
||||
# X
|
||||
tx1 = (min_v.x - ray_origin.x) * inv_dir[0]
|
||||
tx2 = (max_v.x - ray_origin.x) * inv_dir[0]
|
||||
tmin = min(tx1, tx2)
|
||||
tmax = max(tx1, tx2)
|
||||
# Y
|
||||
ty1 = (min_v.y - ray_origin.y) * inv_dir[1]
|
||||
ty2 = (max_v.y - ray_origin.y) * inv_dir[1]
|
||||
tmin = max(tmin, min(ty1, ty2))
|
||||
tmax = min(tmax, max(ty1, ty2))
|
||||
# Z
|
||||
tz1 = (min_v.z - ray_origin.z) * inv_dir[2]
|
||||
tz2 = (max_v.z - ray_origin.z) * inv_dir[2]
|
||||
tmin = max(tmin, min(tz1, tz2))
|
||||
tmax = min(tmax, max(tz1, tz2))
|
||||
return (tmax >= max(tmin, t_min)) and (tmin <= t_max)
|
||||
|
||||
def line_intersects_box(self, v1: mathutils.Vector, v2: mathutils.Vector, box: tuple) -> bool:
|
||||
"""
|
||||
Check if a line segment intersects an axis-aligned bounding box (AABB).
|
||||
|
||||
Args:
|
||||
v1: The first endpoint of the line segment as a mathutils.Vector.
|
||||
v2: The second endpoint of the line segment as a mathutils.Vector.
|
||||
box: A tuple containing the minimum and maximum points of the AABB, where each point is a mathutils.Vector.
|
||||
|
||||
Returns:
|
||||
bool: True if the segment [v1, v2] intersects the AABB; otherwise, False.
|
||||
"""
|
||||
bmin, bmax = box
|
||||
dir = v2 - v1
|
||||
tmin = 0.0
|
||||
tmax = 1.0
|
||||
|
||||
for i in range(3):
|
||||
if abs(dir[i]) < 1e-12:
|
||||
# Line is parallel to slab. If origin not within slab -> no hit.
|
||||
if v1[i] < bmin[i] or v1[i] > bmax[i]:
|
||||
return False
|
||||
else:
|
||||
ood = 1.0 / dir[i]
|
||||
t1 = (bmin[i] - v1[i]) * ood
|
||||
t2 = (bmax[i] - v1[i]) * ood
|
||||
if t1 > t2:
|
||||
t1, t2 = t2, t1
|
||||
if t1 > tmin:
|
||||
tmin = t1
|
||||
if t2 < tmax:
|
||||
tmax = t2
|
||||
if tmin > tmax:
|
||||
return False
|
||||
|
||||
# If any overlap in [0,1] exists, there's intersection
|
||||
return (tmax >= 0.0) and (tmin <= 1.0)
|
||||
|
||||
def raycast_boxes(
|
||||
self,
|
||||
context: bpy.types.Context,
|
||||
event: bpy.Types.Event,
|
||||
node: TreeNode,
|
||||
intersected: Union[TreeNode] = [],
|
||||
rays: tuple[Vector, Vector] = (),
|
||||
) -> Union[TreeNode]:
|
||||
"""
|
||||
Raycast bounding box subdivisions recursively.
|
||||
|
||||
Args:
|
||||
context: Blender context.
|
||||
event: Blender event.
|
||||
node: a TreeNode instance.
|
||||
intersected: list of intersected boxes to use in recursion.
|
||||
rays: tuple containing ray origin and ray direction
|
||||
|
||||
Returns:
|
||||
tuple: a list of TreeNode instances that represent the subdivided boxes hit by the ray cast.
|
||||
"""
|
||||
if not node.child_a:
|
||||
intersected.append(node)
|
||||
return intersected
|
||||
|
||||
intersects_a = self.raycast_box(context, event, node.child_a, rays)
|
||||
intersects_b = self.raycast_box(context, event, node.child_b, rays)
|
||||
if intersects_a:
|
||||
intersected = self.raycast_boxes(context, event, node.child_a, intersected, rays)
|
||||
|
||||
if intersects_b:
|
||||
intersected = self.raycast_boxes(context, event, node.child_b, intersected, rays)
|
||||
|
||||
return intersected
|
||||
|
||||
@@ -360,6 +360,7 @@ class Snap(bonsai.core.tool.Snap):
|
||||
plane_normal = tool.Polyline.use_transform_orientations(plane_normal)
|
||||
return plane_origin, plane_normal
|
||||
|
||||
|
||||
# Polyline
|
||||
polyline_props = tool.Model.get_polyline_props()
|
||||
try:
|
||||
@@ -388,58 +389,31 @@ class Snap(bonsai.core.tool.Snap):
|
||||
|
||||
# Objects
|
||||
objs_to_raycast = tool.Raycast.filter_objects_to_raycast(context, event, objs_2d_bbox)
|
||||
# Wireframes
|
||||
# For wireframe we have to get all the objects so we can further calculate edge intersection
|
||||
closest_snaps = tool.Raycast.ray_cast_and_get_closest_to_camera_snaps(context, event, objs_to_raycast)
|
||||
detected_snaps.extend(closest_snaps)
|
||||
|
||||
xray_mode = (space.shading.type == "SOLID" and space.shading.show_xray) or (space.shading.type == "WIREFRAME" and space.shading.show_xray_wireframe)
|
||||
|
||||
for snap_obj in objs_to_raycast:
|
||||
if snap_obj.type in {"EMPTY", "CURVE"} or (snap_obj.type == "MESH" and len(snap_obj.data.polygons) == 0):
|
||||
snap_points = tool.Raycast.ray_cast_by_proximity(context, event, snap_obj)
|
||||
if snap_points:
|
||||
for point in snap_points:
|
||||
point["group"] = "Wireframe"
|
||||
detected_snaps.append(point)
|
||||
|
||||
if (space.shading.type == "SOLID" and space.shading.show_xray) or (
|
||||
space.shading.type == "WIREFRAME" and space.shading.show_xray_wireframe
|
||||
):
|
||||
results = []
|
||||
for obj in objs_to_raycast:
|
||||
results.append(tool.Raycast.cast_rays_to_single_object(context, event, obj))
|
||||
else:
|
||||
results = []
|
||||
results.append(tool.Raycast.cast_rays_and_get_best_object(context, event, objs_to_raycast))
|
||||
|
||||
for result in results:
|
||||
snap_obj = result[0]
|
||||
hit = result[1]
|
||||
face_index = result[2]
|
||||
if hit is not None:
|
||||
# Wireframes
|
||||
if snap_obj.type in {"EMPTY", "CURVE"} or (
|
||||
snap_obj.type == "MESH" and len(snap_obj.data.polygons) == 0
|
||||
):
|
||||
continue
|
||||
# Meshes
|
||||
else:
|
||||
# Add face snap
|
||||
snap_point = {
|
||||
"point": hit,
|
||||
"type": "Face",
|
||||
"group": "Object",
|
||||
"object": snap_obj,
|
||||
"face_index": face_index,
|
||||
"distance": 9, # High value so it has low priority
|
||||
}
|
||||
detected_snaps.append(snap_point)
|
||||
|
||||
# Add vertex and edge snap
|
||||
snap_points = tool.Raycast.ray_cast_by_proximity(
|
||||
context, event, snap_obj, snap_obj.data.polygons[face_index]
|
||||
)
|
||||
if snap_points:
|
||||
for point in snap_points:
|
||||
point["group"] = "Object"
|
||||
detected_snaps.append(point)
|
||||
|
||||
for snap in closest_snaps:
|
||||
if snap_obj.obj == snap["object"]:
|
||||
if xray_mode:
|
||||
if "face_index" in snap and snap["face_index"] is not None:
|
||||
snap_points = tool.Raycast.ray_cast_by_proximity_2d(context, event, snap_obj)
|
||||
for point in snap_points:
|
||||
point["group"] = "Object"
|
||||
detected_snaps.append(point)
|
||||
else:
|
||||
# If it is a solid object that is closest to camera it ignores all the rest
|
||||
if "is_closest_to_camera" in snap and snap["is_closest_to_camera"] and snap["group"] == "Object":
|
||||
closest_snap = [snap] # discards objects that aren't the closest
|
||||
if "face_index" in snap and snap["face_index"] is not None:
|
||||
snap_points = tool.Raycast.ray_cast_by_proximity_2d(context, event, snap_obj)
|
||||
for point in snap_points:
|
||||
point["group"] = "Object"
|
||||
closest_snap.append(point)
|
||||
detected_snaps = closest_snap
|
||||
|
||||
# snap to cut geometry (e.g. in plan view)
|
||||
if CutDecorator.installed:
|
||||
cut_snaps = []
|
||||
|
||||
+124
-151
@@ -21,13 +21,13 @@ from __future__ import annotations
|
||||
import json
|
||||
from collections import defaultdict
|
||||
from collections.abc import Generator, Iterable
|
||||
from math import pi
|
||||
from typing import TYPE_CHECKING, Any, Literal, Optional, Union
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.attribute
|
||||
import ifcopenshell.api.geometry
|
||||
import ifcopenshell.api.type
|
||||
import ifcopenshell.geom
|
||||
import ifcopenshell.util.classification
|
||||
@@ -991,114 +991,107 @@ class Spatial(bonsai.core.tool.Spatial):
|
||||
return poly
|
||||
|
||||
@classmethod
|
||||
def get_bmesh_from_polygon(cls, poly: Polygon, h: float, polygon_is_si: bool = False) -> bmesh.types.BMesh:
|
||||
"""
|
||||
:param h: Height, in meters.
|
||||
:param polygon_is_si: Should be True if `poly` is defined in meters.
|
||||
"""
|
||||
mat = Matrix()
|
||||
bm = bmesh.new()
|
||||
bm.verts.index_update()
|
||||
bm.edges.index_update()
|
||||
|
||||
mat_invert = mat.inverted()
|
||||
si_conversion = 1.0 if polygon_is_si else ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
||||
new_verts = [
|
||||
# bm.verts.new(mat_invert @ (Vector([v[0], v[1], 0]) * si_conversion)) for v in poly.exterior.coords[0:-1]
|
||||
bm.verts.new(mat_invert @ (Vector([v[0], v[1], 0]) * si_conversion))
|
||||
for v in shapely.get_exterior_ring(poly).coords[0:-1]
|
||||
]
|
||||
[bm.edges.new((new_verts[i], new_verts[i + 1])) for i in range(len(new_verts) - 1)]
|
||||
bm.edges.new((new_verts[len(new_verts) - 1], new_verts[0]))
|
||||
|
||||
bm.verts.index_update()
|
||||
bm.edges.index_update()
|
||||
|
||||
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-5)
|
||||
bmesh.ops.triangle_fill(bm, edges=bm.edges)
|
||||
bmesh.ops.dissolve_limit(bm, angle_limit=pi / 180 * 5, verts=bm.verts, edges=bm.edges)
|
||||
|
||||
if h != 0:
|
||||
extrusion = bmesh.ops.extrude_face_region(bm, geom=bm.faces)
|
||||
extruded_verts = [g for g in extrusion["geom"] if isinstance(g, bmesh.types.BMVert)]
|
||||
bmesh.ops.translate(bm, vec=[0.0, 0.0, h], verts=extruded_verts)
|
||||
|
||||
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
|
||||
|
||||
return bm
|
||||
|
||||
@classmethod
|
||||
def get_named_obj_from_bmesh(cls, name: str, bmesh: bmesh.types.BMesh) -> bpy.types.Object:
|
||||
mesh = cls.get_named_mesh_from_bmesh(name, bmesh)
|
||||
obj = cls.get_named_obj_from_mesh(name, mesh)
|
||||
return obj
|
||||
|
||||
@classmethod
|
||||
def get_named_obj_from_mesh(cls, name: str, mesh: bpy.types.Mesh) -> bpy.types.Object:
|
||||
def create_object(cls, name: str) -> bpy.types.Object:
|
||||
mesh = bpy.data.meshes.new(name=name)
|
||||
obj = bpy.data.objects.new(name, mesh)
|
||||
return obj
|
||||
|
||||
@classmethod
|
||||
def get_named_mesh_from_bmesh(cls, name: str, bmesh: bmesh.types.BMesh) -> bpy.types.Mesh:
|
||||
mesh = bpy.data.meshes.new(name=name)
|
||||
bmesh.to_mesh(mesh)
|
||||
bmesh.free()
|
||||
return mesh
|
||||
def set_obj_origin_to_polygon_center(
|
||||
cls, obj: bpy.types.Object, poly: Polygon, polygon_is_si: bool = True
|
||||
) -> None:
|
||||
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
||||
centroid = poly.centroid
|
||||
if polygon_is_si:
|
||||
obj.location = Vector((centroid.x, centroid.y, 0))
|
||||
else:
|
||||
obj.location = Vector((centroid.x * unit_scale, centroid.y * unit_scale, 0))
|
||||
|
||||
|
||||
@classmethod
|
||||
def get_transformed_mesh_from_local_to_global(cls, mesh: bpy.types.Mesh) -> bpy.types.Mesh:
|
||||
active_obj = cls.get_active_obj()
|
||||
mat = active_obj.matrix_world
|
||||
mesh.transform(mat.inverted())
|
||||
mesh.update()
|
||||
return mesh
|
||||
def get_2d_vertices_from_polygon(
|
||||
cls,
|
||||
poly: Polygon,
|
||||
obj: bpy.types.Object,
|
||||
polygon_is_si: bool = True,
|
||||
) -> list[list[float]]:
|
||||
"""Convert a world-space shapely polygon to 2D vertices in obj's local space, in IFC file units.
|
||||
|
||||
:param poly: The polygon in world space.
|
||||
:param obj: The Blender object whose local space is used.
|
||||
:param polygon_is_si: True if polygon coords are in SI, False if in IFC file units.
|
||||
:return: List of [x, y] coordinates (not closed).
|
||||
"""
|
||||
ifc_file = tool.Ifc.get()
|
||||
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file)
|
||||
bpy.context.view_layer.update()
|
||||
mat_inv = obj.matrix_world.inverted()
|
||||
coords_2d = []
|
||||
for v in shapely.get_exterior_ring(poly).coords[:-1]:
|
||||
world_si = Vector((v[0], v[1], 0))
|
||||
if not polygon_is_si:
|
||||
world_si = world_si * unit_scale
|
||||
local_si = mat_inv @ world_si
|
||||
coords_2d.append([local_si.x / unit_scale, local_si.y / unit_scale])
|
||||
return coords_2d
|
||||
|
||||
@classmethod
|
||||
def edit_active_space_obj_from_mesh(cls, mesh: bpy.types.Mesh) -> None:
|
||||
active_obj = bpy.context.active_object
|
||||
old_mesh = active_obj.data
|
||||
old_mesh_name = old_mesh.name
|
||||
assert active_obj and isinstance(old_mesh, bpy.types.Mesh)
|
||||
tool.Geometry.get_mesh_props(mesh).ifc_definition_id = tool.Geometry.get_mesh_props(old_mesh).ifc_definition_id
|
||||
tool.Geometry.change_object_data(active_obj, mesh, is_global=True)
|
||||
tool.Ifc.edit(active_obj)
|
||||
tool.Blender.remove_data_block(old_mesh)
|
||||
# Rename after old mesh is removed to avoid .001 suffix.
|
||||
mesh.name = old_mesh_name
|
||||
def set_extrusion_representation_from_polygon(
|
||||
cls,
|
||||
obj: bpy.types.Object,
|
||||
element: ifcopenshell.entity_instance,
|
||||
poly: Polygon,
|
||||
depth_ifc: float,
|
||||
polygon_is_si: bool = True,
|
||||
) -> None:
|
||||
"""Create or replace the IFC body representation from a polygon extrusion.
|
||||
|
||||
:param obj: The Blender object.
|
||||
:param element: The IFC product entity.
|
||||
:param poly: The polygon in world space.
|
||||
:param depth_ifc: The extrusion depth in IFC file units.
|
||||
:param polygon_is_si: True if polygon coords are in SI, False if in IFC file units.
|
||||
"""
|
||||
ifc_file = tool.Ifc.get()
|
||||
builder = ifcopenshell.util.shape_builder.ShapeBuilder(ifc_file)
|
||||
|
||||
coords_2d = cls.get_2d_vertices_from_polygon(poly, obj, polygon_is_si)
|
||||
|
||||
curve = builder.polyline(coords_2d, closed=True)
|
||||
item = builder.extrude(curve, magnitude=depth_ifc)
|
||||
|
||||
old_body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
|
||||
if old_body:
|
||||
context = old_body.ContextOfItems
|
||||
ifcopenshell.api.geometry.unassign_representation(ifc_file, product=element, representation=old_body)
|
||||
ifcopenshell.api.geometry.remove_representation(ifc_file, representation=old_body)
|
||||
else:
|
||||
context = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW")
|
||||
|
||||
new_body = builder.get_representation(context, item)
|
||||
ifcopenshell.api.geometry.assign_representation(ifc_file, product=element, representation=new_body)
|
||||
bonsai.core.geometry.switch_representation(
|
||||
tool.Ifc,
|
||||
tool.Geometry,
|
||||
obj=obj,
|
||||
representation=new_body,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def set_obj_origin_to_bboxcenter(cls, obj: bpy.types.Object) -> None:
|
||||
mat = obj.matrix_world
|
||||
inverted = mat.inverted()
|
||||
local_bbox_center = 0.125 * sum((Vector(b) for b in obj.bound_box), Vector())
|
||||
global_bbox_center = mat @ local_bbox_center
|
||||
def set_space_representation_from_polygon(
|
||||
cls,
|
||||
obj: bpy.types.Object,
|
||||
element: ifcopenshell.entity_instance,
|
||||
poly: Polygon,
|
||||
h: float,
|
||||
polygon_is_si: bool = True,
|
||||
) -> None:
|
||||
"""Create or replace the IFC body representation of a space from a polygon.
|
||||
|
||||
oldLoc = obj.location
|
||||
newLoc = global_bbox_center
|
||||
diff = newLoc - oldLoc
|
||||
for vert in obj.data.vertices:
|
||||
aux_vector = mat @ vert.co
|
||||
aux_vector = aux_vector - diff
|
||||
vert.co = inverted @ aux_vector
|
||||
obj.location = newLoc
|
||||
|
||||
@classmethod
|
||||
def set_obj_origin_to_bboxcenter_and_zero_elevation(cls, obj: bpy.types.Object) -> None:
|
||||
mat = obj.matrix_world
|
||||
inverted = mat.inverted()
|
||||
local_bbox_center = 0.125 * sum((Vector(b) for b in obj.bound_box), Vector())
|
||||
global_bbox_center = mat @ local_bbox_center
|
||||
global_obj_origin = global_bbox_center
|
||||
global_obj_origin.z = 0
|
||||
|
||||
oldLoc = obj.location
|
||||
newLoc = global_obj_origin
|
||||
diff = newLoc - oldLoc
|
||||
for vert in obj.data.vertices:
|
||||
aux_vector = mat @ vert.co
|
||||
aux_vector = aux_vector - diff
|
||||
vert.co = inverted @ aux_vector
|
||||
obj.location = newLoc
|
||||
:param h: The height in SI (meters).
|
||||
"""
|
||||
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
||||
cls.set_extrusion_representation_from_polygon(obj, element, poly, h / unit_scale, polygon_is_si)
|
||||
|
||||
@classmethod
|
||||
def set_obj_origin_to_cursor_position_and_zero_elevation(cls, obj: bpy.types.Object) -> None:
|
||||
@@ -1145,65 +1138,53 @@ class Spatial(bonsai.core.tool.Spatial):
|
||||
if z != 0:
|
||||
obj.location = obj.location + Vector((0, 0, z))
|
||||
|
||||
@classmethod
|
||||
def get_2d_vertices_from_obj(cls, obj: bpy.types.Object) -> list[tuple]:
|
||||
points = []
|
||||
vectors = [v.co for v in obj.data.vertices.values()]
|
||||
for vector in vectors:
|
||||
points.append(vector.xy)
|
||||
|
||||
points.append(vectors[0].xy)
|
||||
return points
|
||||
|
||||
@classmethod
|
||||
def get_scaled_2d_vertices(cls, points: list[Vector]) -> list[tuple[float, float]]:
|
||||
model = tool.Ifc.get()
|
||||
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(model)
|
||||
_points = []
|
||||
for p in points:
|
||||
_p = list(p)
|
||||
_p[0] /= unit_scale
|
||||
_p[1] /= unit_scale
|
||||
_points.append(_p)
|
||||
return _points
|
||||
|
||||
@classmethod
|
||||
def assign_swept_area_outer_curve_from_2d_vertices(cls, obj: bpy.types.Object, vertices: list[Vector]) -> None:
|
||||
body = cls.get_body_representation(obj)
|
||||
model = tool.Ifc.get()
|
||||
extrusion = tool.Model.get_extrusion(body)
|
||||
area = extrusion.SweptArea
|
||||
old_area = area.OuterCurve
|
||||
|
||||
builder = ifcopenshell.util.shape_builder.ShapeBuilder(model)
|
||||
outer_curve = builder.polyline(vertices, closed=True)
|
||||
|
||||
area.OuterCurve = outer_curve
|
||||
ifcopenshell.util.element.remove_deep2(tool.Ifc.get(), old_area)
|
||||
|
||||
@classmethod
|
||||
def get_body_representation(cls, obj: bpy.types.Object) -> Union[ifcopenshell.entity_instance, None]:
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
return ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
|
||||
|
||||
@classmethod
|
||||
def assign_ifcspace_class_to_obj(cls, obj: bpy.types.Object) -> None:
|
||||
bpy.ops.bim.assign_class(obj=obj.name, ifc_class="IfcSpace")
|
||||
bonsai.core.root.assign_class(
|
||||
tool.Ifc,
|
||||
tool.Collector,
|
||||
tool.Root,
|
||||
obj=obj,
|
||||
ifc_class="IfcSpace",
|
||||
should_add_representation=False,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def assign_type_to_obj(cls, obj: bpy.types.Object) -> None:
|
||||
# TODO this code looks in the wrong spot and suspicious
|
||||
props = tool.Model.get_model_props()
|
||||
ifc_file = tool.Ifc.get()
|
||||
relating_type_id = props.relating_type_id
|
||||
relating_type = tool.Ifc.get().by_id(int(relating_type_id))
|
||||
relating_type = ifc_file.by_id(int(relating_type_id))
|
||||
ifc_class = relating_type.is_a()
|
||||
instance_class = ifcopenshell.util.type.get_applicable_entities(ifc_class, ifc_file.schema)[0]
|
||||
bpy.ops.bim.assign_class(obj=obj.name, ifc_class=instance_class)
|
||||
bonsai.core.root.assign_class(
|
||||
tool.Ifc,
|
||||
tool.Collector,
|
||||
tool.Root,
|
||||
obj=obj,
|
||||
ifc_class=instance_class,
|
||||
should_add_representation=False,
|
||||
)
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
assert element
|
||||
ifcopenshell.api.type.assign_type(ifc_file, related_objects=[element], relating_type=relating_type)
|
||||
|
||||
@classmethod
|
||||
def set_covering_representation_from_polygon(
|
||||
cls,
|
||||
obj: bpy.types.Object,
|
||||
poly: Polygon,
|
||||
polygon_is_si: bool = True,
|
||||
) -> None:
|
||||
"""Create the covering body representation from a polygon, extruded by the type's material layer thickness."""
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
relating_type = ifcopenshell.util.element.get_type(element)
|
||||
material = ifcopenshell.util.element.get_material(relating_type, should_skip_usage=True)
|
||||
depth = 0.0
|
||||
if material and material.is_a("IfcMaterialLayerSet"):
|
||||
depth = sum(layer.LayerThickness for layer in material.MaterialLayers)
|
||||
cls.set_extrusion_representation_from_polygon(obj, element, poly, depth, polygon_is_si)
|
||||
|
||||
@classmethod
|
||||
def assign_relating_type_to_element(
|
||||
cls,
|
||||
@@ -1214,14 +1195,6 @@ class Spatial(bonsai.core.tool.Spatial):
|
||||
) -> None:
|
||||
bonsai.core.type.assign_type(ifc, tool.Model, type, element=element, type=relating_type)
|
||||
|
||||
@classmethod
|
||||
def regen_obj_representation(cls, obj: bpy.types.Object, body: ifcopenshell.entity_instance) -> None:
|
||||
bonsai.core.geometry.switch_representation(
|
||||
tool.Ifc,
|
||||
tool.Geometry,
|
||||
obj=obj,
|
||||
representation=body,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def set_space_visibility(cls, is_visible: bool) -> None:
|
||||
|
||||
@@ -19,4 +19,5 @@ This chapter covers how you can help contribute to Bonsai.
|
||||
undo_system
|
||||
writing_docs
|
||||
debugging
|
||||
maintenance
|
||||
ide/index
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
Maintenance
|
||||
===========
|
||||
|
||||
This page documents what needs to be updated in various maintenance scenarios.
|
||||
|
||||
Python Version Added or Removed
|
||||
--------------------------------
|
||||
|
||||
When adding or removing a supported Python version, update the following:
|
||||
|
||||
.. list-table::
|
||||
:header-rows: 1
|
||||
|
||||
* - File
|
||||
- What to update
|
||||
* - ``.github/workflows/ci-black-formatting.yaml``
|
||||
- ``MIN_IOS_PY_VERSION``
|
||||
* - ``.github/workflows/ci-ifcopenshell-python-pypi.yml``
|
||||
- ``pyver`` matrix
|
||||
* - ``.github/workflows/ci-ifcopenshell-python.yml``
|
||||
- ``pyver`` matrix
|
||||
* - ``nix/build-all.py``
|
||||
- ``PYTHON_VERSIONS`` list
|
||||
* - ``src/bsdd/pyproject.toml``
|
||||
- ``requires-python``
|
||||
* - ``src/ifcopenshell-python/docs/ifcopenshell-python/installation.rst``
|
||||
- add or remove the row in the ZIP packages table
|
||||
* - ``src/ifcopenshell-python/Makefile``
|
||||
- ``SUPPORTED_PYVERSIONS``
|
||||
* - ``src/ifcopenshell-python/pyproject.toml``
|
||||
- ``requires-python``
|
||||
* - ``src/ifcopenshell-python/test/test_package.py``
|
||||
- ``SUPPORTED_PY_VERSIONS`` tuple
|
||||
* - ``win/build-all-win.py``
|
||||
- ``PYTHON_VERSIONS`` list
|
||||
|
||||
Blender Version Updated
|
||||
-----------------------
|
||||
|
||||
When a new Blender version is released and supported:
|
||||
|
||||
.. list-table::
|
||||
:header-rows: 1
|
||||
|
||||
* - File
|
||||
- What to update
|
||||
* - ``.github/workflows/ci-bonsai-daily.yml``
|
||||
- Blender download URL
|
||||
|
||||
Blender's Bundled Python Version Updated
|
||||
-----------------------------------------
|
||||
|
||||
When Blender ships with a new Python version:
|
||||
|
||||
.. list-table::
|
||||
:header-rows: 1
|
||||
|
||||
* - File
|
||||
- What to update
|
||||
* - ``.github/workflows/ci-black-formatting.yaml``
|
||||
- ``MIN_BLENDER_PY_VERSION``
|
||||
* - ``src/bonsai/Makefile``
|
||||
- ``SUPPORTED_PYVERSIONS``
|
||||
* - ``src/bonsai/scripts/dev_environment.py``
|
||||
- ``PYTHON_VERSION`` mapping (Blender version, bundled Python version)
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Clone or update Bonsai external dependencies.
|
||||
|
||||
Must be run from the repository root.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
DEPS = [
|
||||
("https://projects.blender.org/pioverfour/sun_position.git", "sun_position"),
|
||||
("https://github.com/kevancress/MeasureIt_ARCH", "MeasureIt_ARCH"),
|
||||
("https://github.com/nortikin/sverchok.git", "sverchok"),
|
||||
]
|
||||
|
||||
base = Path("src/bonsai/external_dependencies")
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for url, name in DEPS:
|
||||
path = base / name
|
||||
if not path.exists():
|
||||
subprocess.check_call(["git", "clone", url, str(path)])
|
||||
else:
|
||||
subprocess.check_call(["git", "-C", str(path), "pull", "--rebase"])
|
||||
@@ -273,10 +273,10 @@ if BPY_IS_LOADED:
|
||||
f"Couldn't find locale path in the source directory, creating dummy directory: {source_locale_path}.",
|
||||
)
|
||||
|
||||
from ui_translate.settings import ( # pyright: ignore[reportMissingImports]
|
||||
from ui_translate.settings import ( # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import]
|
||||
settings as ui_translate_settings,
|
||||
)
|
||||
from ui_translate.update_ui import ( # pyright: ignore[reportMissingImports]
|
||||
from ui_translate.update_ui import ( # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import]
|
||||
UI_OT_i18n_updatetranslation_init_settings,
|
||||
)
|
||||
|
||||
|
||||
@@ -23,7 +23,9 @@ import bpy
|
||||
|
||||
# sys.path.append('C:\Program Files\Python37\Lib\site-packages')
|
||||
import lxml.etree
|
||||
from bspy import Gbxml # pyright: ignore[reportMissingImports]
|
||||
from bspy import ( # ty: ignore[unresolved-import]
|
||||
Gbxml, # pyright: ignore[reportMissingImports]
|
||||
)
|
||||
|
||||
|
||||
class GbxmlExporter:
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
from math import pi
|
||||
from pathlib import Path
|
||||
|
||||
import boltspy as bolts # pyright: ignore[reportMissingImports]
|
||||
import boltspy as bolts # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import]
|
||||
import ifcopenshell.api
|
||||
import ifcopenshell.api.material
|
||||
import ifcopenshell.api.project
|
||||
|
||||
@@ -31,7 +31,7 @@ import ifcopenshell.api.spatial
|
||||
import ifcopenshell.api.unit
|
||||
import ifcopenshell.guid
|
||||
import numpy as np
|
||||
import pymeshlab # pyright: ignore[reportMissingImports]
|
||||
import pymeshlab # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import]
|
||||
|
||||
|
||||
class Obj2Ifc:
|
||||
|
||||
@@ -31,7 +31,7 @@ import ifcopenshell.api.spatial
|
||||
import ifcopenshell.api.unit
|
||||
import ifcopenshell.guid
|
||||
import numpy as np
|
||||
import pywavefront # pyright: ignore[reportMissingImports]
|
||||
import pywavefront # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import]
|
||||
|
||||
|
||||
class Obj2Ifc:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Feature: Covering
|
||||
Covers covering tool.
|
||||
|
||||
Scenario: Execute generate flooring coverings from walls
|
||||
Scenario: Add flooring from walls
|
||||
Given an empty IFC project
|
||||
And I load the demo construction library
|
||||
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
|
||||
@@ -24,9 +24,9 @@ Scenario: Execute generate flooring coverings from walls
|
||||
And the cursor is at "0,2.0,0"
|
||||
And I set "scene.BIMModelProperties.length" to "1.9"
|
||||
And I press "bim.add_occurrence"
|
||||
# add_instance_flooring_coverings_from_walls is expecting FLOORING predefined type.
|
||||
# Set COV30 predefined type to FLOORING.
|
||||
And the object "IfcCoveringType/COV30" is selected
|
||||
And I look at the "Attributes" panel
|
||||
And I look at the "Object Attributes" panel
|
||||
And I click "Edit"
|
||||
And I set the "PredefinedType" property to "FLOORING"
|
||||
And I click "Save Attributes"
|
||||
@@ -42,3 +42,108 @@ Scenario: Execute generate flooring coverings from walls
|
||||
Then the object "IfcCovering/Covering0" exists
|
||||
And the object "IfcCovering/Covering0" is at "1.8,1.05,0.0"
|
||||
And the object "IfcCovering/Covering0" dimensions are "3.4,1.9,0.03"
|
||||
|
||||
Scenario: Add ceiling from walls
|
||||
Given an empty IFC project
|
||||
And I load the demo construction library
|
||||
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
|
||||
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
|
||||
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
|
||||
And I press "bim.add_occurrence"
|
||||
And the object "IfcWall/Wall" is selected
|
||||
And I press "bim.change_layer_length(length=3.6)"
|
||||
And the cursor is at "3.6,0.1,3"
|
||||
And I set "scene.BIMModelProperties.length" to "2.0"
|
||||
And I press "bim.add_occurrence"
|
||||
And the cursor is at "3.5,2.1,3"
|
||||
And I set "scene.BIMModelProperties.length" to "3.5"
|
||||
And I press "bim.add_occurrence"
|
||||
And the cursor is at "0,2.0,0"
|
||||
And I set "scene.BIMModelProperties.length" to "1.9"
|
||||
And I press "bim.add_occurrence"
|
||||
# Set COV30 predefined type to CEILING.
|
||||
And the object "IfcCoveringType/COV30" is selected
|
||||
And I look at the "Object Attributes" panel
|
||||
And I click "Edit"
|
||||
And I set the "PredefinedType" property to "CEILING"
|
||||
And I click "Save Attributes"
|
||||
# Run the operator with ceiling height = 2.7 (default).
|
||||
When the object "IfcWall/Wall" is selected
|
||||
And additionally the object "IfcWall/Wall.001" is selected
|
||||
And additionally the object "IfcWall/Wall.002" is selected
|
||||
And additionally the object "IfcWall/Wall.003" is selected
|
||||
And I set "scene.BIMModelProperties.ifc_class" to "IfcCoveringType"
|
||||
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcCoveringType') if e.Name == 'COV30'][0].id()"
|
||||
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
|
||||
And I press "bim.add_instance_ceiling_coverings_from_walls"
|
||||
Then the object "IfcCovering/Covering0" exists
|
||||
And the object "IfcCovering/Covering0" is at "1.8,1.05,2.7"
|
||||
And the object "IfcCovering/Covering0" dimensions are "3.4,1.9,0.03"
|
||||
|
||||
Scenario: Add flooring from cursor
|
||||
Given an empty IFC project
|
||||
And I load the demo construction library
|
||||
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
|
||||
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
|
||||
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
|
||||
And I press "bim.add_occurrence"
|
||||
And the cursor is at "1.1,0,0"
|
||||
And I press "bim.add_occurrence"
|
||||
And the object "IfcWall/Wall.001" is selected
|
||||
And I press "bim.hotkey(hotkey='S_R')"
|
||||
And the cursor is at "0,.9,0"
|
||||
And I press "bim.add_occurrence"
|
||||
And the cursor is at "-1,0,0"
|
||||
And I press "bim.add_occurrence"
|
||||
And the object "IfcWall/Wall.003" is selected
|
||||
And I press "bim.hotkey(hotkey='S_R')"
|
||||
And the object "IfcWall/Wall.003" is moved to "0,0,0"
|
||||
# Set COV30 predefined type to FLOORING.
|
||||
And the object "IfcCoveringType/COV30" is selected
|
||||
And I look at the "Object Attributes" panel
|
||||
And I click "Edit"
|
||||
And I set the "PredefinedType" property to "FLOORING"
|
||||
And I click "Save Attributes"
|
||||
# Generate covering from cursor inside the room.
|
||||
When the cursor is at "0.5,0.5,0"
|
||||
And I deselect all objects
|
||||
And I set "scene.BIMModelProperties.ifc_class" to "IfcCoveringType"
|
||||
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcCoveringType') if e.Name == 'COV30'][0].id()"
|
||||
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
|
||||
And I press "bim.add_instance_flooring_covering_from_cursor"
|
||||
Then the object "IfcCovering/Covering" exists
|
||||
And the object "IfcCovering/Covering" dimensions are "1,0.8,0.03"
|
||||
|
||||
Scenario: Add ceiling from cursor
|
||||
Given an empty IFC project
|
||||
And I load the demo construction library
|
||||
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
|
||||
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
|
||||
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
|
||||
And I press "bim.add_occurrence"
|
||||
And the cursor is at "1.1,0,0"
|
||||
And I press "bim.add_occurrence"
|
||||
And the object "IfcWall/Wall.001" is selected
|
||||
And I press "bim.hotkey(hotkey='S_R')"
|
||||
And the cursor is at "0,.9,0"
|
||||
And I press "bim.add_occurrence"
|
||||
And the cursor is at "-1,0,0"
|
||||
And I press "bim.add_occurrence"
|
||||
And the object "IfcWall/Wall.003" is selected
|
||||
And I press "bim.hotkey(hotkey='S_R')"
|
||||
And the object "IfcWall/Wall.003" is moved to "0,0,0"
|
||||
# Set COV30 predefined type to CEILING.
|
||||
And the object "IfcCoveringType/COV30" is selected
|
||||
And I look at the "Object Attributes" panel
|
||||
And I click "Edit"
|
||||
And I set the "PredefinedType" property to "CEILING"
|
||||
And I click "Save Attributes"
|
||||
# Generate covering from cursor inside the room.
|
||||
When the cursor is at "0.5,0.5,0"
|
||||
And I deselect all objects
|
||||
And I set "scene.BIMModelProperties.ifc_class" to "IfcCoveringType"
|
||||
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcCoveringType') if e.Name == 'COV30'][0].id()"
|
||||
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
|
||||
And I press "bim.add_instance_ceiling_covering_from_cursor"
|
||||
Then the object "IfcCovering/Covering" exists
|
||||
And the object "IfcCovering/Covering" dimensions are "1,0.8,0.03"
|
||||
|
||||
@@ -422,6 +422,24 @@ Scenario: Enable editing material set item
|
||||
When I press "bim.enable_editing_material_set_item(material_set_item={material_profile})"
|
||||
Then nothing happens
|
||||
|
||||
Scenario: Edit layer item defaults null IsVentilated to FALSE in UI
|
||||
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 press "bim.add_material()"
|
||||
And I set "active_object.BIMObjectMaterialProperties.material_type" to "IfcMaterialLayerSet"
|
||||
And I press "bim.assign_material"
|
||||
And I press "bim.enable_editing_assigned_material"
|
||||
And the variable "layer" is "{ifc}.by_type('IfcMaterialLayer')[0].id()"
|
||||
And I press "bim.enable_editing_material_set_item(material_set_item={layer})"
|
||||
When I evaluate expression "attrs = bpy.context.active_object.BIMObjectMaterialProperties.material_set_item_attributes; is_vent = next(a for a in attrs if a.name == 'IsVentilated'); assert is_vent.enum_value == 'FALSE'; assert is_vent.is_null is True"
|
||||
And I press "bim.edit_material_set_item(material_set_item={layer})"
|
||||
Then I evaluate expression "assert {ifc}.by_id({layer}).IsVentilated is None"
|
||||
|
||||
Scenario: Add material set layer
|
||||
Given an empty IFC project
|
||||
And I add a cube
|
||||
|
||||
@@ -396,6 +396,44 @@ Scenario: Add a slab
|
||||
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"
|
||||
|
||||
Scenario: Extend walls to underside
|
||||
Given an empty IFC project
|
||||
And I load the demo construction library
|
||||
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
|
||||
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
|
||||
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
|
||||
And I press "bim.add_occurrence"
|
||||
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}"
|
||||
And I press "bim.add_occurrence"
|
||||
And the object "IfcSlab/Slab" is moved to "0,0,2.5"
|
||||
When the object "IfcWall/Wall" is selected
|
||||
And additionally the object "IfcSlab/Slab" is selected
|
||||
And I look at the tool header
|
||||
And I click "Extend To Underside"
|
||||
Then the object "IfcWall/Wall" dimensions are "1,0.1,2.5"
|
||||
|
||||
Scenario: Extend walls to underside - extending to a tessellated gable roof
|
||||
Given an empty IFC project
|
||||
And I load the demo construction library
|
||||
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
|
||||
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
|
||||
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
|
||||
And I press "bim.add_occurrence"
|
||||
# Create gable roof: a cube turned into a prism with a ridge.
|
||||
And I add a cube of size "1" at "0.5,0.05,3"
|
||||
And the object "Cube" is selected
|
||||
And I evaluate expression "obj = bpy.context.active_object; [setattr(v.co, 'y', 0) for v in obj.data.vertices if v.co.z > 0]"
|
||||
And I set "scene.BIMRootProperties.ifc_product" to "IfcElement"
|
||||
And I set "scene.BIMRootProperties.ifc_class" to "IfcRoof"
|
||||
And I press "bim.assign_class"
|
||||
When the object "IfcWall/Wall" is selected
|
||||
And additionally the object "IfcRoof/Cube" is selected
|
||||
And I look at the tool header
|
||||
And I click "Extend To Underside"
|
||||
Then the object "IfcWall/Wall" dimensions are "1,0.1,2.5"
|
||||
|
||||
Scenario: Enable editing a slab profile
|
||||
Given an empty IFC project
|
||||
And I load the demo construction library
|
||||
|
||||
@@ -133,7 +133,11 @@ class PanelSpy:
|
||||
self.spied_labels.append(kwargs["text"])
|
||||
return self
|
||||
elif self.spied_attr == "prop":
|
||||
props, name = args
|
||||
if args:
|
||||
props, name = args
|
||||
else:
|
||||
props = kwargs.get("data")
|
||||
name = kwargs.get("property")
|
||||
props: bpy.types.bpy_struct
|
||||
text = kwargs.get("text", props.bl_rna.properties[name].name)
|
||||
icon = kwargs.get("icon", None)
|
||||
@@ -242,7 +246,7 @@ class TemplateListItemSpy(PanelSpy):
|
||||
self.spied_props: list[dict[str, Any]] = []
|
||||
self.spied_operators: list[dict[str, Any]] = []
|
||||
if len(signature(blender_panel.draw_item).parameters) == 8:
|
||||
blender_panel.draw_item(
|
||||
blender_panel.draw_item( # ty:ignore[missing-argument]
|
||||
self,
|
||||
bpy.context,
|
||||
self,
|
||||
@@ -390,6 +394,32 @@ def i_look_at_the_panel_panel(panel: str) -> None:
|
||||
panel_spy.refresh_spy()
|
||||
|
||||
|
||||
@given(parsers.parse("I look at the tool header"))
|
||||
@when(parsers.parse("I look at the tool header"))
|
||||
@then(parsers.parse("I look at the tool header"))
|
||||
def i_look_at_the_tool_header() -> None:
|
||||
from bonsai.bim.module.model.workspace import EditObjectUI
|
||||
|
||||
class MockRegion:
|
||||
type = "UI"
|
||||
|
||||
class MockContext:
|
||||
def __getattr__(self, name):
|
||||
if name == "region":
|
||||
return MockRegion()
|
||||
return getattr(bpy.context, name)
|
||||
|
||||
global panel_spy
|
||||
panel_spy = PanelSpy(EditObjectUI)
|
||||
panel_spy.is_spy_dirty = False
|
||||
panel_spy.spied_attr = None
|
||||
panel_spy.spied_labels = []
|
||||
panel_spy.spied_props = []
|
||||
panel_spy.spied_operators = []
|
||||
panel_spy.spied_lists = []
|
||||
EditObjectUI.draw(MockContext(), panel_spy)
|
||||
|
||||
|
||||
@given(parsers.parse('I open the "{name}" menu'))
|
||||
@when(parsers.parse('I open the "{name}" menu'))
|
||||
@then(parsers.parse('I open the "{name}" menu'))
|
||||
|
||||
@@ -35,9 +35,14 @@ class TestDisableEditingText:
|
||||
|
||||
class TestEditText:
|
||||
def test_run(self, drawing):
|
||||
drawing.synchronise_ifc_and_text_attributes("obj").should_be_called()
|
||||
drawing.update_text_size_pset("obj").should_be_called()
|
||||
drawing.update_text_annotation_properties("obj").should_be_called()
|
||||
drawing.export_text_literal_attributes("obj").should_be_called().will_return("literal_attributes")
|
||||
drawing.export_font_size("obj").should_be_called().will_return("font_size")
|
||||
drawing.edit_text_font_size("obj", "font_size").should_be_called()
|
||||
drawing.export_wrap_length("obj").should_be_called().will_return("wrap_length")
|
||||
drawing.edit_text_wrap_length("obj", "wrap_length").should_be_called()
|
||||
drawing.export_symbol("obj").should_be_called().will_return("symbol")
|
||||
drawing.edit_text_symbol("obj", "symbol").should_be_called()
|
||||
drawing.edit_text_literals("obj", "literal_attributes").should_be_called()
|
||||
drawing.disable_editing_text("obj").should_be_called()
|
||||
subject.edit_text(drawing, obj="obj")
|
||||
|
||||
@@ -466,6 +471,7 @@ class TestRemoveDrawing:
|
||||
class TestUpdateDrawingName:
|
||||
def test_do_not_update_if_name_unchanged(self, ifc, drawing):
|
||||
drawing.get_name("drawing").should_be_called().will_return("name")
|
||||
drawing.set_camera_name("drawing", "name").should_be_called()
|
||||
drawing.get_drawing_group("drawing").should_be_called().will_return("group")
|
||||
drawing.get_name("group").should_be_called().will_return("name")
|
||||
drawing.get_drawing_collection("drawing").should_be_called().will_return("collection")
|
||||
@@ -482,6 +488,7 @@ class TestUpdateDrawingName:
|
||||
def test_run(self, ifc, drawing):
|
||||
drawing.get_name("drawing").should_be_called().will_return("oldname")
|
||||
ifc.run("attribute.edit_attributes", product="drawing", attributes={"Name": "name"}).should_be_called()
|
||||
drawing.set_camera_name("drawing", "name").should_be_called()
|
||||
drawing.get_drawing_group("drawing").should_be_called().will_return("group")
|
||||
drawing.get_name("group").should_be_called().will_return("oldname")
|
||||
ifc.run("attribute.edit_attributes", product="group", attributes={"Name": "name"}).should_be_called()
|
||||
|
||||
@@ -23,6 +23,7 @@ from test.core.bootstrap import georeference, ifc
|
||||
class TestAddGeoreferencing:
|
||||
def test_run(self, georeference):
|
||||
georeference.add_georeferencing().should_be_called()
|
||||
georeference.set_model_origin().should_be_called()
|
||||
subject.add_georeferencing(georeference)
|
||||
|
||||
|
||||
@@ -35,9 +36,10 @@ class TestEnableEditingGeoreferencing:
|
||||
|
||||
|
||||
class TestRemoveGeoreferencing:
|
||||
def test_run(self, ifc):
|
||||
def test_run(self, ifc, georeference):
|
||||
ifc.run("georeference.remove_georeferencing").should_be_called()
|
||||
subject.remove_georeferencing(ifc)
|
||||
georeference.set_model_origin().should_be_called()
|
||||
subject.remove_georeferencing(ifc, georeference)
|
||||
|
||||
|
||||
class TestDisableEditingGeoreferencing:
|
||||
|
||||
@@ -22,8 +22,9 @@ from test.core.bootstrap import geometry, ifc, model, type
|
||||
|
||||
class TestAssignType:
|
||||
def test_assigning_and_switching_to_an_existing_type_data(self, ifc, model, type):
|
||||
type.record_material_usage_attributes("element").should_be_called().will_return(None)
|
||||
ifc.run("type.assign_type", related_objects=["element"], relating_type="type").should_be_called()
|
||||
type.has_material_usage("element").should_be_called().will_return(False)
|
||||
model.get_usage_type("type").should_be_called(2).will_return(None)
|
||||
ifc.get_object("type").should_be_called().will_return("type_obj")
|
||||
type.get_object_data("type_obj").should_be_called().will_return("type_obj_data")
|
||||
type.change_object_data("obj", "type_obj_data", is_global=False).should_be_called()
|
||||
@@ -31,9 +32,10 @@ class TestAssignType:
|
||||
type.disable_editing("obj").should_be_called()
|
||||
subject.assign_type(ifc, model, type, element="element", type="type")
|
||||
|
||||
def test_assigning_and_not_changing_data_if_the_type_has_no_data(self, ifc, type):
|
||||
def test_assigning_and_not_changing_data_if_the_type_has_no_data(self, ifc, model, type):
|
||||
type.record_material_usage_attributes("element").should_be_called().will_return(None)
|
||||
ifc.run("type.assign_type", related_objects=["element"], relating_type="type").should_be_called()
|
||||
type.has_material_usage("element").should_be_called().will_return(False)
|
||||
model.get_usage_type("type").should_be_called(2).will_return(None)
|
||||
ifc.get_object("type").should_be_called().will_return("type_obj")
|
||||
type.get_object_data("type_obj").should_be_called().will_return(None)
|
||||
ifc.get_object("element").should_be_called().will_return("obj")
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.aggregate
|
||||
import ifcopenshell.api.context
|
||||
import ifcopenshell.api.geometry
|
||||
import ifcopenshell.api.root
|
||||
@@ -99,6 +100,42 @@ class TestCanAggregate(NewFile):
|
||||
subelement_obj = bpy.data.objects.new("Object", None)
|
||||
assert subject.can_aggregate(element_obj, subelement_obj) is False
|
||||
|
||||
def test_element_cannot_aggregate_to_itself(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
element = ifc.createIfcElementAssembly()
|
||||
element_obj = bpy.data.objects.new("Object", None)
|
||||
tool.Ifc.link(element, element_obj)
|
||||
assert subject.can_aggregate(element_obj, element_obj) is False
|
||||
|
||||
def test_cyclic_aggregation_is_prevented(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
assembly_a = ifc.createIfcElementAssembly()
|
||||
assembly_a_obj = bpy.data.objects.new("AssemblyA", None)
|
||||
tool.Ifc.link(assembly_a, assembly_a_obj)
|
||||
beam = ifc.createIfcBeam()
|
||||
beam_obj = bpy.data.objects.new("Beam", None)
|
||||
tool.Ifc.link(beam, beam_obj)
|
||||
ifcopenshell.api.aggregate.assign_object(ifc, products=[beam], relating_object=assembly_a)
|
||||
assert subject.can_aggregate(beam_obj, assembly_a_obj) is False
|
||||
|
||||
def test_deep_cyclic_aggregation_is_prevented(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
assembly_a = ifc.createIfcElementAssembly()
|
||||
assembly_a_obj = bpy.data.objects.new("AssemblyA", None)
|
||||
tool.Ifc.link(assembly_a, assembly_a_obj)
|
||||
assembly_b = ifc.createIfcElementAssembly()
|
||||
assembly_b_obj = bpy.data.objects.new("AssemblyB", None)
|
||||
tool.Ifc.link(assembly_b, assembly_b_obj)
|
||||
beam = ifc.createIfcBeam()
|
||||
beam_obj = bpy.data.objects.new("Beam", None)
|
||||
tool.Ifc.link(beam, beam_obj)
|
||||
ifcopenshell.api.aggregate.assign_object(ifc, products=[assembly_b], relating_object=assembly_a)
|
||||
ifcopenshell.api.aggregate.assign_object(ifc, products=[beam], relating_object=assembly_b)
|
||||
assert subject.can_aggregate(beam_obj, assembly_a_obj) is False
|
||||
|
||||
|
||||
class TestHasPhysicalBodyRepresentation(NewFile):
|
||||
def test_run(self):
|
||||
|
||||
@@ -176,6 +176,7 @@ class TestStairCalculatedParams(NewFile):
|
||||
pset_data = pset_data_base.copy()
|
||||
calculated_data = calculated_data_base.copy()
|
||||
pset_data["custom_first_last_tread_run"] = (0.1, 0.4)
|
||||
pset_data["custom_tread_lock"] = False
|
||||
calculated_data["Length"] += -0.2 + 0.1
|
||||
self.compare_data(pset_data, calculated_data)
|
||||
|
||||
@@ -183,6 +184,7 @@ class TestStairCalculatedParams(NewFile):
|
||||
pset_data = pset_data_base.copy()
|
||||
calculated_data = calculated_data_base.copy()
|
||||
pset_data["custom_first_last_tread_run"] = (0.0, None)
|
||||
pset_data["custom_tread_lock"] = False
|
||||
calculated_data["Length"] = 0.9 # Only 3 treads at 0.3 each
|
||||
self.compare_data(pset_data, calculated_data)
|
||||
|
||||
@@ -190,6 +192,7 @@ class TestStairCalculatedParams(NewFile):
|
||||
pset_data = pset_data_base.copy()
|
||||
calculated_data = calculated_data_base.copy()
|
||||
pset_data["custom_first_last_tread_run"] = (None, 0.0)
|
||||
pset_data["custom_tread_lock"] = False
|
||||
calculated_data["Length"] = 0.9 # Only 3 treads at 0.3 each
|
||||
self.compare_data(pset_data, calculated_data)
|
||||
|
||||
@@ -197,6 +200,7 @@ class TestStairCalculatedParams(NewFile):
|
||||
pset_data = pset_data_base.copy()
|
||||
calculated_data = calculated_data_base.copy()
|
||||
pset_data["custom_first_last_tread_run"] = (0.0, 0.0)
|
||||
pset_data["custom_tread_lock"] = False
|
||||
calculated_data["Length"] = 0.6 # Only 2 middle treads at 0.3 each
|
||||
self.compare_data(pset_data, calculated_data)
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api
|
||||
import ifcopenshell.api.nest
|
||||
import ifcopenshell.api.spatial
|
||||
|
||||
import bonsai.core.tool
|
||||
@@ -51,6 +52,42 @@ class TestCanNest(NewFile):
|
||||
subelement_obj = bpy.data.objects.new("Object", None)
|
||||
assert subject.can_nest(element_obj, subelement_obj) is False
|
||||
|
||||
def test_element_cannot_nest_to_itself(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
element = ifc.createIfcWall()
|
||||
element_obj = bpy.data.objects.new("Object", None)
|
||||
tool.Ifc.link(element, element_obj)
|
||||
assert subject.can_nest(element_obj, element_obj) is False
|
||||
|
||||
def test_cyclic_nesting_is_prevented(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
wall_a = ifc.createIfcWall()
|
||||
wall_a_obj = bpy.data.objects.new("WallA", None)
|
||||
tool.Ifc.link(wall_a, wall_a_obj)
|
||||
wall_b = ifc.createIfcWall()
|
||||
wall_b_obj = bpy.data.objects.new("WallB", None)
|
||||
tool.Ifc.link(wall_b, wall_b_obj)
|
||||
ifcopenshell.api.nest.assign_object(ifc, related_objects=[wall_b], relating_object=wall_a)
|
||||
assert subject.can_nest(wall_b_obj, wall_a_obj) is False
|
||||
|
||||
def test_deep_cyclic_nesting_is_prevented(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
wall_a = ifc.createIfcWall()
|
||||
wall_a_obj = bpy.data.objects.new("WallA", None)
|
||||
tool.Ifc.link(wall_a, wall_a_obj)
|
||||
wall_b = ifc.createIfcWall()
|
||||
wall_b_obj = bpy.data.objects.new("WallB", None)
|
||||
tool.Ifc.link(wall_b, wall_b_obj)
|
||||
wall_c = ifc.createIfcWall()
|
||||
wall_c_obj = bpy.data.objects.new("WallC", None)
|
||||
tool.Ifc.link(wall_c, wall_c_obj)
|
||||
ifcopenshell.api.nest.assign_object(ifc, related_objects=[wall_b], relating_object=wall_a)
|
||||
ifcopenshell.api.nest.assign_object(ifc, related_objects=[wall_c], relating_object=wall_b)
|
||||
assert subject.can_nest(wall_c_obj, wall_a_obj) is False
|
||||
|
||||
|
||||
class TestDisableEditing(NewFile):
|
||||
def test_run(self):
|
||||
|
||||
@@ -365,7 +365,7 @@ class TestLoadingIfcSqlite(NewFile):
|
||||
sql_type="SQLite",
|
||||
)
|
||||
patcher.patch()
|
||||
tmp_file = Path(tempfile.mktemp(suffix=".ifcsqlite"))
|
||||
tmp_file = Path(tempfile.mkstemp(suffix=".ifcsqlite")[1])
|
||||
ifcpatch.write(patcher.get_output(), tmp_file)
|
||||
|
||||
elements_with_meshes = [
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
aiohttp
|
||||
beautifulsoup4
|
||||
boto3
|
||||
botocore
|
||||
brickschema
|
||||
cjio >=0.8, <0.10
|
||||
debugpy
|
||||
ezdxf
|
||||
fake-bpy-module-latest
|
||||
git+https://github.com/prochitecture/bpypolyskel
|
||||
git+https://github.com/Andrej730/IFC2JSON_python.git@pyproject_toml
|
||||
gitpython
|
||||
isodate
|
||||
lark
|
||||
lxml
|
||||
lxml-stubs
|
||||
markdown-it-py
|
||||
natsort
|
||||
numpy
|
||||
odfpy
|
||||
openpyxl
|
||||
pandas
|
||||
pillow
|
||||
platformdirs
|
||||
pygments
|
||||
pyradiance
|
||||
pystache
|
||||
pytest
|
||||
pytest_bdd
|
||||
pytest_blender
|
||||
python-dateutil
|
||||
python-socketio
|
||||
pytz
|
||||
rdflib
|
||||
requests
|
||||
shapely
|
||||
svgwrite
|
||||
typing-extensions
|
||||
typst
|
||||
tzfpy
|
||||
xsdata
|
||||
@@ -7,7 +7,7 @@ from typing import Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, model_validator
|
||||
|
||||
from .type_hints import *
|
||||
from type_hints import *
|
||||
|
||||
|
||||
def _lower_first(s: str) -> str:
|
||||
|
||||
@@ -28,6 +28,7 @@ dist:
|
||||
mkdir -p dist
|
||||
cp -r $(PACKAGE_NAME) build/
|
||||
cp pyproject.toml build/
|
||||
if [ -f README.md ]; then cp README.md build/; fi
|
||||
ifeq ($(IS_STABLE), TRUE)
|
||||
$(SED) 's/version = "0.0.0"/version = "$(VERSION)"/' build/pyproject.toml
|
||||
ifdef IS_MODULE
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
<!-- This file was generated with the assistance of an AI coding tool. -->
|
||||
# ifcedit
|
||||
|
||||
A CLI wrapper that exposes all 350+ `ifcopenshell.api` mutation functions as
|
||||
shell commands. Functions are auto-discovered at runtime via introspection --
|
||||
no hardcoded list to maintain.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install ifcedit
|
||||
```
|
||||
|
||||
Requires `ifcopenshell`.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
ifcedit <command> [options] [--format json|text]
|
||||
```
|
||||
|
||||
Three subcommands: `list` to discover functions, `docs` to read their
|
||||
documentation, and `run` to execute them.
|
||||
|
||||
## Subcommands
|
||||
|
||||
### list
|
||||
|
||||
Discover available API modules and their functions.
|
||||
|
||||
**List all modules:**
|
||||
|
||||
```bash
|
||||
ifcedit list
|
||||
```
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"module": "root",
|
||||
"description": "Functions for creating project-level entities",
|
||||
"functions": ["create_entity", "remove_product", "copy_class"],
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"module": "spatial",
|
||||
"description": "Functions for managing spatial relationships",
|
||||
"functions": ["assign_container", "unassign_container"],
|
||||
"count": 2
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**List functions in a module:**
|
||||
|
||||
```bash
|
||||
ifcedit list root
|
||||
```
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "create_entity",
|
||||
"description": "Create an IFC entity with optional initial attributes",
|
||||
"params": [
|
||||
{"name": "ifc_class", "type": "str", "required": true},
|
||||
{"name": "name", "type": "Optional[str]"}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### docs
|
||||
|
||||
Show full documentation for a specific function, including parameter
|
||||
descriptions from docstrings and return type.
|
||||
|
||||
```bash
|
||||
ifcedit docs root.create_entity
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"module": "root",
|
||||
"function": "create_entity",
|
||||
"description": "Create an IFC entity with optional initial attributes",
|
||||
"long_description": "This function creates a new entity instance...",
|
||||
"params": [
|
||||
{
|
||||
"name": "ifc_class",
|
||||
"type": "str",
|
||||
"required": true,
|
||||
"description": "The IFC class name (e.g. 'IfcWall', 'IfcProject')"
|
||||
},
|
||||
{
|
||||
"name": "name",
|
||||
"type": "Optional[str]",
|
||||
"description": "Optional name attribute"
|
||||
}
|
||||
],
|
||||
"return_type": "ifcopenshell.entity_instance",
|
||||
"return_description": "The newly created entity instance"
|
||||
}
|
||||
```
|
||||
|
||||
### run
|
||||
|
||||
Execute an API function against an IFC file. Parameters are passed as
|
||||
`--key value` pairs after the function name.
|
||||
|
||||
```bash
|
||||
ifcedit run model.ifc root.create_entity --ifc_class IfcWall --name "My Wall"
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"result": {"id": 42, "type": "IfcWall", "name": "My Wall"}
|
||||
}
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
- `-o, --output <path>` -- write to a different file instead of overwriting the input
|
||||
- `--dry-run` -- validate parameters without executing or saving
|
||||
|
||||
```bash
|
||||
# Save to a new file
|
||||
ifcedit run model.ifc root.create_entity -o out.ifc --ifc_class IfcWall
|
||||
|
||||
# Validate without executing
|
||||
ifcedit run model.ifc root.create_entity --dry-run --ifc_class IfcWall
|
||||
```
|
||||
|
||||
Dry-run output shows the resolved parameters:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"dry_run": true,
|
||||
"module": "root",
|
||||
"function": "create_entity",
|
||||
"args": {"ifc_class": "IfcWall", "name": "My Wall"}
|
||||
}
|
||||
```
|
||||
|
||||
## Parameter type coercion
|
||||
|
||||
CLI strings are automatically converted to the types expected by each API
|
||||
function, using the function's type annotations:
|
||||
|
||||
| Type | CLI input | Python value |
|
||||
|------|-----------|--------------|
|
||||
| `str` | `"hello"` | `"hello"` |
|
||||
| `int` | `"42"` or `"#42"` | `42` |
|
||||
| `float` | `"3.14"` | `3.14` |
|
||||
| `bool` | `"true"`, `"1"`, `"yes"` | `True` |
|
||||
| `Optional[X]` | `"none"` | `None` |
|
||||
| `entity_instance` | `"42"` or `"#42"` | resolved from model by step ID |
|
||||
| `list[entity_instance]` | `"5,6,7"` or `"[5, 6, 7]"` | list of resolved entities |
|
||||
| `dict` | `'{"key": "val"}'` | parsed JSON object |
|
||||
| `Literal["A", "B"]` | `"A"` | validated against allowed values |
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
# Create a project
|
||||
ifcedit run model.ifc root.create_entity --ifc_class IfcProject --name "My Project"
|
||||
|
||||
# Assign an element to a storey
|
||||
ifcedit run model.ifc spatial.assign_container --products 10 --relating_structure 4
|
||||
|
||||
# Assign multiple elements at once
|
||||
ifcedit run model.ifc aggregate.assign_object --products "5,6,7" --relating_object 1
|
||||
|
||||
# Add a property set
|
||||
ifcedit run model.ifc pset.add_pset --product 10 --name "Pset_WallCommon"
|
||||
|
||||
# Edit properties
|
||||
ifcedit run model.ifc pset.edit_pset --pset 15 \
|
||||
--properties '{"IsExternal": true, "FireRating": "2HR"}'
|
||||
```
|
||||
|
||||
### quantify
|
||||
|
||||
Run quantity take-off (QTO) on an IFC file, computing physical measurements
|
||||
(volume, area, length, count, weight) and writing them back as
|
||||
`IfcElementQuantity` property sets. Uses `ifc5d` rules.
|
||||
|
||||
**List available rules:**
|
||||
|
||||
```bash
|
||||
ifcedit quantify list
|
||||
```
|
||||
|
||||
```json
|
||||
[
|
||||
{"name": "IFC4QtoBaseQuantities"},
|
||||
{"name": "IFC4X3QtoBaseQuantities"}
|
||||
]
|
||||
```
|
||||
|
||||
**Run QTO on a file:**
|
||||
|
||||
```bash
|
||||
ifcedit quantify run model.ifc IFC4QtoBaseQuantities
|
||||
ifcedit quantify run model.ifc IFC4QtoBaseQuantities --selector IfcWall
|
||||
ifcedit quantify run model.ifc IFC4QtoBaseQuantities -o model_qto.ifc
|
||||
```
|
||||
|
||||
```json
|
||||
{"ok": true, "rule": "IFC4QtoBaseQuantities", "elements_quantified": 42}
|
||||
```
|
||||
|
||||
Options:
|
||||
|
||||
- `--selector <query>` -- ifcopenshell selector to restrict elements (default: all `IfcElement`)
|
||||
- `-o, --output <path>` -- write to a different file instead of overwriting the input
|
||||
|
||||
Note: `quantify run` writes geometry-based measurements and requires the
|
||||
IfcOpenShell C++ geometry bindings for elements with computed quantities.
|
||||
|
||||
## Error handling
|
||||
|
||||
Errors are reported in the JSON response:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"error": "Entity #999 not found in model"
|
||||
}
|
||||
```
|
||||
|
||||
Exit code is 0 on success, 1 on error.
|
||||
|
||||
## Relationship to ifcquery
|
||||
|
||||
`ifcedit` and `ifcquery` are complementary tools:
|
||||
|
||||
- **ifcquery** reads and inspects IFC models (summary, tree, info, select, relations, clash, validate, schedule, cost, schema, contexts, materials, plot, render)
|
||||
- **ifcedit** modifies IFC models by wrapping `ifcopenshell.api` functions, and runs QTO via `quantify`
|
||||
|
||||
A typical workflow: inspect with `ifcquery`, look up the right API function
|
||||
with `ifcedit docs`, then apply changes with `ifcedit run`.
|
||||
|
||||
## License
|
||||
|
||||
LGPLv3+ -- see the IfcOpenShell project license.
|
||||
@@ -0,0 +1,20 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions
|
||||
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
|
||||
#
|
||||
# This file is part of IfcEdit.
|
||||
#
|
||||
# IfcEdit is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# IfcEdit is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcEdit. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
__version__ = version = "0.0.0"
|
||||
@@ -0,0 +1,217 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions
|
||||
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
|
||||
#
|
||||
# This file is part of IfcEdit.
|
||||
#
|
||||
# IfcEdit is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# IfcEdit is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcEdit. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
import ifcopenshell
|
||||
|
||||
from ifcedit.discover import function_docs, list_functions, list_modules
|
||||
from ifcedit.quantify import list_rules, run_quantify
|
||||
from ifcedit.run import run_api
|
||||
|
||||
|
||||
def format_output(data, fmt: str) -> str:
|
||||
if fmt == "json":
|
||||
return json.dumps(data, indent=2, ensure_ascii=False)
|
||||
elif fmt == "text":
|
||||
return _format_text(data)
|
||||
return json.dumps(data, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
def _format_text(data, indent: int = 0) -> str:
|
||||
prefix = " " * indent
|
||||
lines = []
|
||||
if isinstance(data, dict):
|
||||
for key, value in data.items():
|
||||
if isinstance(value, (dict, list)):
|
||||
lines.append(f"{prefix}{key}:")
|
||||
lines.append(_format_text(value, indent + 1))
|
||||
else:
|
||||
lines.append(f"{prefix}{key}: {value}")
|
||||
elif isinstance(data, list):
|
||||
for item in data:
|
||||
if isinstance(item, dict):
|
||||
lines.append(_format_text(item, indent))
|
||||
lines.append("")
|
||||
else:
|
||||
lines.append(f"{prefix}- {item}")
|
||||
else:
|
||||
lines.append(f"{prefix}{data}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def cmd_list(args):
|
||||
if args.module:
|
||||
try:
|
||||
functions = list_functions(args.module)
|
||||
except Exception as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(format_output(functions, args.output_format))
|
||||
else:
|
||||
modules = list_modules()
|
||||
print(format_output(modules, args.output_format))
|
||||
|
||||
|
||||
def cmd_docs(args):
|
||||
parts = args.function_path.split(".")
|
||||
if len(parts) != 2:
|
||||
print("Error: function path must be 'module.function' (e.g. root.create_entity)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
module, function = parts
|
||||
try:
|
||||
docs = function_docs(module, function)
|
||||
except Exception as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(format_output(docs, args.output_format))
|
||||
|
||||
|
||||
def cmd_run(args, extra_args):
|
||||
try:
|
||||
model = ifcopenshell.open(args.ifc_file)
|
||||
except Exception as e:
|
||||
print(f"Error: Could not open IFC file: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
parts = args.function_path.split(".")
|
||||
if len(parts) != 2:
|
||||
print("Error: function path must be 'module.function' (e.g. root.create_entity)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
module, function = parts
|
||||
|
||||
# Parse extra --key value arguments into a dict
|
||||
raw_kwargs = _parse_extra_args(extra_args)
|
||||
|
||||
if args.dry_run:
|
||||
result = {"ok": True, "dry_run": True, "module": module, "function": function, "args": raw_kwargs}
|
||||
else:
|
||||
result = run_api(model, module, function, raw_kwargs)
|
||||
|
||||
if result["ok"]:
|
||||
output_path = args.output or args.ifc_file
|
||||
model.write(output_path)
|
||||
|
||||
print(format_output(result, args.output_format))
|
||||
if not result["ok"]:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _parse_extra_args(extra: list[str]) -> dict[str, str]:
|
||||
"""Parse a list of ['--key', 'value', ...] into a dict."""
|
||||
kwargs = {}
|
||||
i = 0
|
||||
while i < len(extra):
|
||||
arg = extra[i]
|
||||
if arg.startswith("--"):
|
||||
key = arg[2:]
|
||||
if i + 1 < len(extra) and not extra[i + 1].startswith("--"):
|
||||
kwargs[key] = extra[i + 1]
|
||||
i += 2
|
||||
else:
|
||||
# Flag without value — treat as "true"
|
||||
kwargs[key] = "true"
|
||||
i += 1
|
||||
else:
|
||||
print(f"Error: Unexpected argument: {arg}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return kwargs
|
||||
|
||||
|
||||
def cmd_quantify(args, extra_args):
|
||||
if args.quantify_command == "list":
|
||||
result = list_rules()
|
||||
print(format_output(result, args.output_format))
|
||||
elif args.quantify_command == "run":
|
||||
try:
|
||||
model = ifcopenshell.open(args.ifc_file)
|
||||
except Exception as e:
|
||||
print(f"Error: Could not open IFC file: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
selector = args.selector or None
|
||||
result = run_quantify(model, args.rule_name, selector=selector)
|
||||
if result["ok"]:
|
||||
output_path = args.output or args.ifc_file
|
||||
model.write(output_path)
|
||||
print(format_output(result, args.output_format))
|
||||
if not result["ok"]:
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("Error: quantify requires a subcommand: list or run", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="ifcedit",
|
||||
description="CLI wrapper for ifcopenshell.api IFC model mutation functions",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--format",
|
||||
choices=["json", "text"],
|
||||
default="json",
|
||||
dest="output_format",
|
||||
help="Output format (default: json)",
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
# list
|
||||
list_parser = subparsers.add_parser("list", help="List API modules or functions in a module")
|
||||
list_parser.add_argument("module", nargs="?", help="Module name (omit to list all modules)")
|
||||
|
||||
# docs
|
||||
docs_parser = subparsers.add_parser("docs", help="Show full documentation for an API function")
|
||||
docs_parser.add_argument("function_path", help="module.function (e.g. root.create_entity)")
|
||||
|
||||
# run
|
||||
run_parser = subparsers.add_parser("run", help="Execute an API function on an IFC file")
|
||||
run_parser.add_argument("ifc_file", help="Path to the IFC file")
|
||||
run_parser.add_argument("function_path", help="module.function (e.g. root.create_entity)")
|
||||
run_parser.add_argument("-o", "--output", help="Output file path (default: overwrite input)")
|
||||
run_parser.add_argument("--dry-run", action="store_true", help="Validate without executing or saving")
|
||||
|
||||
# quantify
|
||||
quantify_parser = subparsers.add_parser("quantify", help="Quantity take-off (QTO) using ifc5d rules")
|
||||
quantify_sub = quantify_parser.add_subparsers(dest="quantify_command")
|
||||
quantify_sub.add_parser("list", help="List available QTO rule names")
|
||||
qrun_parser = quantify_sub.add_parser("run", help="Run QTO on an IFC file")
|
||||
qrun_parser.add_argument("ifc_file", help="Path to the IFC file")
|
||||
qrun_parser.add_argument("rule_name", help="QTO rule name (e.g. IFC4QtoBaseQuantities)")
|
||||
qrun_parser.add_argument("--selector", help="ifcopenshell selector to restrict elements (default: all IfcElement)")
|
||||
qrun_parser.add_argument("-o", "--output", help="Output file path (default: overwrite input)")
|
||||
|
||||
args, extra = parser.parse_known_args()
|
||||
|
||||
if args.command == "list":
|
||||
cmd_list(args)
|
||||
elif args.command == "docs":
|
||||
cmd_docs(args)
|
||||
elif args.command == "run":
|
||||
cmd_run(args, extra)
|
||||
elif args.command == "quantify":
|
||||
cmd_quantify(args, extra)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,180 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions
|
||||
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
|
||||
#
|
||||
# This file is part of IfcEdit.
|
||||
#
|
||||
# IfcEdit is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# IfcEdit is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcEdit. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import typing
|
||||
|
||||
import ifcopenshell
|
||||
|
||||
|
||||
def coerce_value(
|
||||
value_str: str,
|
||||
type_hint,
|
||||
model: ifcopenshell.file | None = None,
|
||||
lookup_file: ifcopenshell.file | None = None,
|
||||
):
|
||||
"""Convert a CLI string argument to the proper Python type based on a type hint.
|
||||
|
||||
Args:
|
||||
value_str: The raw string from the CLI.
|
||||
type_hint: The type annotation from the function signature.
|
||||
model: The main open IFC model, needed to resolve entity instance references by ID.
|
||||
lookup_file: Override file for entity resolution (e.g. a library file for
|
||||
project.append_asset). When provided, entity IDs are looked up here instead
|
||||
of in model.
|
||||
|
||||
Returns:
|
||||
The converted Python value.
|
||||
|
||||
Raises:
|
||||
ValueError: If the value cannot be converted.
|
||||
TypeError: If the type hint is not supported.
|
||||
"""
|
||||
# When a library file has been opened, entity IDs are resolved from it, not the main model.
|
||||
effective_lookup = lookup_file if lookup_file is not None else model
|
||||
|
||||
if type_hint is None:
|
||||
return value_str
|
||||
|
||||
origin = typing.get_origin(type_hint)
|
||||
args = typing.get_args(type_hint)
|
||||
|
||||
# Union / Optional
|
||||
if origin is typing.Union:
|
||||
non_none_types = [a for a in args if a is not type(None)]
|
||||
if value_str.lower() == "none":
|
||||
if type(None) in args:
|
||||
return None
|
||||
# Try each non-None type in order
|
||||
for t in non_none_types:
|
||||
try:
|
||||
return coerce_value(value_str, t, model, lookup_file)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
raise ValueError(f"Cannot convert '{value_str}' to any of {non_none_types}")
|
||||
|
||||
# Literal
|
||||
if origin is typing.Literal:
|
||||
allowed = args
|
||||
if value_str in [str(a) for a in allowed]:
|
||||
# return the actual literal value with proper type
|
||||
for a in allowed:
|
||||
if str(a) == value_str:
|
||||
return a
|
||||
raise ValueError(f"'{value_str}' is not one of: {', '.join(repr(a) for a in allowed)}")
|
||||
|
||||
# list types
|
||||
if origin is list:
|
||||
if args and _is_entity_type(args[0]):
|
||||
return _coerce_entity_list(value_str, effective_lookup)
|
||||
if args:
|
||||
items = _split_list(value_str)
|
||||
return [coerce_value(item.strip(), args[0], model, lookup_file) for item in items]
|
||||
return _split_list(value_str)
|
||||
|
||||
# dict types
|
||||
if origin is dict:
|
||||
return _floatify_numeric_lists(json.loads(value_str))
|
||||
|
||||
# Simple types
|
||||
if type_hint is str:
|
||||
return value_str
|
||||
if type_hint is int:
|
||||
return int(value_str.lstrip("#"))
|
||||
if type_hint is float:
|
||||
return float(value_str)
|
||||
if type_hint is bool:
|
||||
return value_str.lower() in ("true", "1", "yes")
|
||||
|
||||
# ifcopenshell.file — open from path string
|
||||
if type_hint is ifcopenshell.file:
|
||||
return ifcopenshell.open(value_str)
|
||||
|
||||
# entity_instance
|
||||
if _is_entity_type(type_hint):
|
||||
return _coerce_entity(value_str, effective_lookup)
|
||||
|
||||
# Fallback: try json.loads for complex types, then plain string
|
||||
try:
|
||||
return json.loads(value_str)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return value_str
|
||||
|
||||
|
||||
def _is_entity_type(hint) -> bool:
|
||||
"""Check if a type hint refers to ifcopenshell.entity_instance."""
|
||||
if hint is ifcopenshell.entity_instance:
|
||||
return True
|
||||
if isinstance(hint, type) and issubclass(hint, ifcopenshell.entity_instance):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _coerce_entity(value_str: str | int, lookup_file: ifcopenshell.file | None) -> ifcopenshell.entity_instance:
|
||||
"""Resolve a step ID string like '123' or '#123' to an entity instance."""
|
||||
if lookup_file is None:
|
||||
raise ValueError("Cannot resolve entity reference without an IFC model")
|
||||
if isinstance(value_str, int):
|
||||
entity_id = value_str
|
||||
else:
|
||||
entity_id = int(value_str.strip().lstrip("#"))
|
||||
try:
|
||||
return lookup_file.by_id(entity_id)
|
||||
except RuntimeError:
|
||||
raise ValueError(f"Entity #{entity_id} not found in model")
|
||||
|
||||
|
||||
def _coerce_entity_list(value_str: str, lookup_file: ifcopenshell.file | None) -> list[ifcopenshell.entity_instance]:
|
||||
"""Resolve a comma-separated list of step IDs to entity instances."""
|
||||
items = _split_list(value_str)
|
||||
return [_coerce_entity(item.strip(), lookup_file) for item in items]
|
||||
|
||||
|
||||
def _floatify_numeric_lists(obj):
|
||||
"""Recursively convert lists of numbers to lists of floats.
|
||||
|
||||
IFC C++ bindings require Python floats (not ints) for AGGREGATE OF DOUBLE
|
||||
attributes (e.g. DirectionRatios, Coordinates). JSON parsing produces ints
|
||||
for whole numbers like 0, which causes a TypeError at the binding level.
|
||||
"""
|
||||
if isinstance(obj, dict):
|
||||
return {k: _floatify_numeric_lists(v) for k, v in obj.items()}
|
||||
if (
|
||||
isinstance(obj, list)
|
||||
and obj
|
||||
and all(isinstance(v, (int, float)) for v in obj)
|
||||
and any(isinstance(v, float) for v in obj)
|
||||
):
|
||||
return [float(v) for v in obj]
|
||||
return obj
|
||||
|
||||
|
||||
def _split_list(value_str: str) -> list[str]:
|
||||
"""Split a comma-separated string, handling JSON arrays too."""
|
||||
value_str = value_str.strip()
|
||||
if value_str.startswith("["):
|
||||
try:
|
||||
parsed = json.loads(value_str)
|
||||
if isinstance(parsed, list):
|
||||
return [json.dumps(item) if isinstance(item, (dict, list)) else str(item) for item in parsed]
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return [item.strip() for item in value_str.split(",") if item.strip()]
|
||||
@@ -0,0 +1,282 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions
|
||||
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
|
||||
#
|
||||
# This file is part of IfcEdit.
|
||||
#
|
||||
# IfcEdit is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# IfcEdit is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcEdit. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import inspect
|
||||
import re
|
||||
import typing
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _api_package_path() -> Path:
|
||||
"""Return the filesystem path to the ifcopenshell.api package."""
|
||||
import ifcopenshell.api
|
||||
|
||||
return Path(ifcopenshell.api.__file__).parent
|
||||
|
||||
|
||||
def list_modules() -> list[dict]:
|
||||
"""List all API modules with their function counts and descriptions.
|
||||
|
||||
Returns a list of dicts: [{"module": "root", "description": "...", "functions": [...], "count": 4}, ...]
|
||||
"""
|
||||
api_path = _api_package_path()
|
||||
modules = []
|
||||
for child in sorted(api_path.iterdir()):
|
||||
if not child.is_dir() or child.name.startswith("_"):
|
||||
continue
|
||||
init_file = child / "__init__.py"
|
||||
if not init_file.exists():
|
||||
continue
|
||||
try:
|
||||
mod = importlib.import_module(f"ifcopenshell.api.{child.name}")
|
||||
except Exception:
|
||||
continue
|
||||
all_names = getattr(mod, "__all__", [])
|
||||
if not all_names:
|
||||
continue
|
||||
description = ""
|
||||
if mod.__doc__:
|
||||
description = mod.__doc__.strip().split("\n")[0]
|
||||
modules.append(
|
||||
{
|
||||
"module": child.name,
|
||||
"description": description,
|
||||
"functions": list(all_names),
|
||||
"count": len(all_names),
|
||||
}
|
||||
)
|
||||
return modules
|
||||
|
||||
|
||||
def list_functions(module: str) -> list[dict]:
|
||||
"""List functions in an API module with one-line descriptions and parameter info.
|
||||
|
||||
Returns a list of dicts: [{"name": "create_entity", "description": "...", "params": [...]}]
|
||||
"""
|
||||
mod = importlib.import_module(f"ifcopenshell.api.{module}")
|
||||
all_names = getattr(mod, "__all__", [])
|
||||
functions = []
|
||||
for name in all_names:
|
||||
fn = _get_underlying_function(module, name)
|
||||
if fn is None:
|
||||
continue
|
||||
description = ""
|
||||
if fn.__doc__:
|
||||
description = fn.__doc__.strip().split("\n")[0]
|
||||
params = _extract_params(fn)
|
||||
functions.append(
|
||||
{
|
||||
"name": name,
|
||||
"description": description,
|
||||
"params": params,
|
||||
}
|
||||
)
|
||||
return functions
|
||||
|
||||
|
||||
def function_docs(module: str, function: str) -> dict:
|
||||
"""Full documentation for a single API function.
|
||||
|
||||
Returns a dict with: module, function, description, params (with types/defaults/descriptions), return_type
|
||||
"""
|
||||
fn = _get_underlying_function(module, function)
|
||||
if fn is None:
|
||||
raise ValueError(f"Function '{module}.{function}' not found")
|
||||
|
||||
description = ""
|
||||
long_description = ""
|
||||
if fn.__doc__:
|
||||
description, long_description = _parse_docstring_body(fn.__doc__)
|
||||
|
||||
params = _extract_params(fn)
|
||||
param_descriptions = _parse_param_docs(fn.__doc__ or "")
|
||||
for param in params:
|
||||
if param["name"] in param_descriptions:
|
||||
param["description"] = param_descriptions[param["name"]]
|
||||
|
||||
return_type = _format_type_hint(typing.get_type_hints(fn).get("return"))
|
||||
return_description = _parse_return_doc(fn.__doc__ or "")
|
||||
|
||||
result = {
|
||||
"module": module,
|
||||
"function": function,
|
||||
"description": description,
|
||||
"long_description": long_description,
|
||||
"params": params,
|
||||
}
|
||||
if return_type:
|
||||
result["return_type"] = return_type
|
||||
if return_description:
|
||||
result["return_description"] = return_description
|
||||
return result
|
||||
|
||||
|
||||
def _get_underlying_function(module: str, function: str):
|
||||
"""Get the actual function object (unwrapping the listener wrapper if needed)."""
|
||||
try:
|
||||
fn_module = importlib.import_module(f"ifcopenshell.api.{module}.{function}")
|
||||
fn = getattr(fn_module, function, None)
|
||||
return fn
|
||||
except (ImportError, AttributeError):
|
||||
return None
|
||||
|
||||
|
||||
def _extract_params(fn) -> list[dict]:
|
||||
"""Extract parameter info from a function's signature and type hints."""
|
||||
sig = inspect.signature(fn)
|
||||
try:
|
||||
hints = typing.get_type_hints(fn)
|
||||
except Exception:
|
||||
hints = {}
|
||||
|
||||
params = []
|
||||
for name, param in sig.parameters.items():
|
||||
if name == "file" or name == "self":
|
||||
continue
|
||||
info = {"name": name}
|
||||
if name in hints:
|
||||
info["type"] = _format_type_hint(hints[name])
|
||||
if param.default is not inspect.Parameter.empty:
|
||||
info["default"] = _serialize_default(param.default)
|
||||
else:
|
||||
info["required"] = True
|
||||
params.append(info)
|
||||
return params
|
||||
|
||||
|
||||
def _format_type_hint(hint) -> str | None:
|
||||
"""Format a type hint to a readable string."""
|
||||
import ifcopenshell
|
||||
|
||||
if hint is None:
|
||||
return None
|
||||
if hint is type(None):
|
||||
return "None"
|
||||
# ifcopenshell.file params are passed as a file path string
|
||||
if hint is ifcopenshell.file:
|
||||
return "file_path"
|
||||
origin = typing.get_origin(hint)
|
||||
args = typing.get_args(hint)
|
||||
|
||||
# Union (including Optional)
|
||||
if origin is typing.Union:
|
||||
formatted = [_format_type_hint(a) for a in args]
|
||||
# Optional[X] is Union[X, None] — render as "Optional[X]"
|
||||
if len(formatted) == 2 and "None" in formatted:
|
||||
inner = next(f for f in formatted if f != "None")
|
||||
return f"Optional[{inner}]"
|
||||
return " | ".join(formatted)
|
||||
|
||||
# Literal
|
||||
if origin is typing.Literal:
|
||||
values = ", ".join(repr(a) for a in args)
|
||||
return f"Literal[{values}]"
|
||||
|
||||
# Generic types (list, dict, etc.)
|
||||
if origin is not None:
|
||||
origin_name = getattr(origin, "__name__", str(origin))
|
||||
if args:
|
||||
inner = ", ".join(_format_type_hint(a) for a in args)
|
||||
return f"{origin_name}[{inner}]"
|
||||
return origin_name
|
||||
|
||||
# Simple types
|
||||
return getattr(hint, "__name__", str(hint))
|
||||
|
||||
|
||||
def _serialize_default(value):
|
||||
"""Serialize a default value to something JSON-friendly."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (str, int, float, bool)):
|
||||
return value
|
||||
return repr(value)
|
||||
|
||||
|
||||
def _parse_docstring_body(docstring: str) -> tuple[str, str]:
|
||||
"""Parse the summary and long description from a docstring."""
|
||||
lines = docstring.strip().split("\n")
|
||||
summary = lines[0].strip() if lines else ""
|
||||
body_lines = []
|
||||
in_body = False
|
||||
for line in lines[1:]:
|
||||
stripped = line.strip()
|
||||
if stripped.startswith(":param") or stripped.startswith(":return"):
|
||||
break
|
||||
if stripped.startswith("Example"):
|
||||
break
|
||||
if not in_body and not stripped:
|
||||
in_body = True
|
||||
continue
|
||||
if in_body:
|
||||
body_lines.append(stripped)
|
||||
|
||||
long_description = " ".join(body_lines).strip()
|
||||
# collapse multiple spaces
|
||||
long_description = re.sub(r"\s+", " ", long_description)
|
||||
return summary, long_description
|
||||
|
||||
|
||||
_FIELD_MARKER = re.compile(r":(?:param|returns?|rtype|type|raises?)\b")
|
||||
|
||||
|
||||
def _parse_param_docs(docstring: str) -> dict[str, str]:
|
||||
"""Extract :param name: description lines from a docstring."""
|
||||
params = {}
|
||||
current_param = None
|
||||
current_lines = []
|
||||
for line in docstring.split("\n"):
|
||||
stripped = line.strip()
|
||||
match = re.match(r":param\s+(\w+):\s*(.*)", stripped)
|
||||
if match:
|
||||
if current_param:
|
||||
params[current_param] = " ".join(current_lines).strip()
|
||||
current_param = match.group(1)
|
||||
current_lines = [match.group(2)]
|
||||
elif current_param and stripped and not _FIELD_MARKER.match(stripped):
|
||||
current_lines.append(stripped)
|
||||
elif _FIELD_MARKER.match(stripped) or (stripped == "" and current_param):
|
||||
if current_param:
|
||||
params[current_param] = " ".join(current_lines).strip()
|
||||
current_param = None
|
||||
current_lines = []
|
||||
if current_param:
|
||||
params[current_param] = " ".join(current_lines).strip()
|
||||
# collapse whitespace
|
||||
return {k: re.sub(r"\s+", " ", v) for k, v in params.items()}
|
||||
|
||||
|
||||
def _parse_return_doc(docstring: str) -> str:
|
||||
"""Extract :return: description from a docstring."""
|
||||
lines = []
|
||||
in_return = False
|
||||
for line in docstring.split("\n"):
|
||||
stripped = line.strip()
|
||||
match = re.match(r":return:\s*(.*)", stripped)
|
||||
if match:
|
||||
in_return = True
|
||||
lines = [match.group(1)]
|
||||
elif in_return:
|
||||
if _FIELD_MARKER.match(stripped) or stripped == "":
|
||||
break
|
||||
lines.append(stripped)
|
||||
return re.sub(r"\s+", " ", " ".join(lines).strip())
|
||||
@@ -0,0 +1,37 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import ifcopenshell
|
||||
|
||||
AVAILABLE_RULES = ["IFC4QtoBaseQuantities", "IFC4X3QtoBaseQuantities"]
|
||||
|
||||
|
||||
def list_rules() -> list[dict[str, str]]:
|
||||
"""Return a list of available quantification rule names."""
|
||||
return [{"name": name} for name in AVAILABLE_RULES]
|
||||
|
||||
|
||||
def run_quantify(model: ifcopenshell.file, rule: str, selector: str | None = None) -> dict[str, Any]:
|
||||
"""Run quantity take-off on the model using the named rule.
|
||||
|
||||
Modifies the model in-place by adding/updating IfcElementQuantity psets.
|
||||
Returns a summary dict with ok, rule, and elements_quantified.
|
||||
"""
|
||||
from ifc5d.qto import edit_qtos, quantify
|
||||
from ifc5d.qto import rules as rule_sets
|
||||
|
||||
if rule not in rule_sets:
|
||||
return {"ok": False, "error": f"Unknown rule: {rule}. Available: {list(rule_sets.keys())}"}
|
||||
|
||||
import ifcopenshell.util.selector
|
||||
|
||||
if selector:
|
||||
elements = set(ifcopenshell.util.selector.filter_elements(model, selector))
|
||||
else:
|
||||
elements = set(model.by_type("IfcElement"))
|
||||
|
||||
results = quantify(model, elements, rule_sets[rule])
|
||||
edit_qtos(model, results)
|
||||
return {"ok": True, "rule": rule, "elements_quantified": len(results)}
|
||||
@@ -0,0 +1,148 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions
|
||||
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
|
||||
#
|
||||
# This file is part of IfcEdit.
|
||||
#
|
||||
# IfcEdit is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# IfcEdit is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcEdit. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import inspect
|
||||
import typing
|
||||
|
||||
import ifcopenshell
|
||||
|
||||
from ifcedit.coerce import coerce_value
|
||||
|
||||
|
||||
def _is_file_type(hint) -> bool:
|
||||
"""Check if a type hint refers to ifcopenshell.file (or Optional[ifcopenshell.file])."""
|
||||
if hint is ifcopenshell.file:
|
||||
return True
|
||||
origin = typing.get_origin(hint)
|
||||
args = typing.get_args(hint)
|
||||
if origin is typing.Union and ifcopenshell.file in args:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def run_api(
|
||||
model: ifcopenshell.file,
|
||||
module: str,
|
||||
function: str,
|
||||
raw_kwargs: dict[str, str],
|
||||
) -> dict:
|
||||
"""Execute an ifcopenshell.api function with CLI-provided string arguments.
|
||||
|
||||
Args:
|
||||
model: The open IFC model.
|
||||
module: API module name (e.g. "root").
|
||||
function: Function name (e.g. "create_entity").
|
||||
raw_kwargs: String keyword arguments from the CLI.
|
||||
|
||||
Returns:
|
||||
A dict with {"ok": True, "result": ...} on success,
|
||||
or {"ok": False, "error": "..."} on failure.
|
||||
"""
|
||||
try:
|
||||
fn = _import_function(module, function)
|
||||
except (ImportError, AttributeError) as e:
|
||||
return {"ok": False, "error": f"Cannot find function '{module}.{function}': {e}"}
|
||||
|
||||
try:
|
||||
hints = typing.get_type_hints(fn)
|
||||
except Exception:
|
||||
hints = {}
|
||||
|
||||
sig = inspect.signature(fn)
|
||||
coerced_kwargs = {}
|
||||
|
||||
# Pass 1: coerce ifcopenshell.file-typed params first (e.g. library= in append_asset).
|
||||
# The opened file is then used as the lookup file for entity resolution in pass 2.
|
||||
opened_files: list[ifcopenshell.file] = []
|
||||
for name, value_str in raw_kwargs.items():
|
||||
if name not in sig.parameters:
|
||||
return {"ok": False, "error": f"Unknown parameter '{name}' for {module}.{function}"}
|
||||
hint = hints.get(name)
|
||||
if not _is_file_type(hint):
|
||||
continue
|
||||
try:
|
||||
coerced = coerce_value(value_str, hint, model)
|
||||
coerced_kwargs[name] = coerced
|
||||
if isinstance(coerced, ifcopenshell.file):
|
||||
opened_files.append(coerced)
|
||||
except (ValueError, TypeError) as e:
|
||||
return {"ok": False, "error": f"Cannot convert parameter '{name}': {e}"}
|
||||
|
||||
# Pass 2: coerce remaining params. Entity instance IDs are resolved from the opened
|
||||
# library file (if any), since you are always appending from another file, never
|
||||
# from the current model.
|
||||
lookup_file = opened_files[0] if opened_files else None
|
||||
for name, value_str in raw_kwargs.items():
|
||||
if name in coerced_kwargs:
|
||||
continue
|
||||
if name not in sig.parameters:
|
||||
return {"ok": False, "error": f"Unknown parameter '{name}' for {module}.{function}"}
|
||||
hint = hints.get(name)
|
||||
try:
|
||||
coerced_kwargs[name] = coerce_value(value_str, hint, model, lookup_file=lookup_file)
|
||||
except (ValueError, TypeError) as e:
|
||||
return {"ok": False, "error": f"Cannot convert parameter '{name}': {e}"}
|
||||
|
||||
# Determine if the function takes 'file' as its first parameter
|
||||
first_param = next(iter(sig.parameters), None)
|
||||
try:
|
||||
if first_param == "file":
|
||||
result = fn(model, **coerced_kwargs)
|
||||
else:
|
||||
result = fn(**coerced_kwargs)
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
|
||||
|
||||
return {"ok": True, "result": serialize_result(result)}
|
||||
|
||||
|
||||
def _import_function(module: str, function: str):
|
||||
"""Import and return the underlying function from ifcopenshell.api."""
|
||||
fn_module = importlib.import_module(f"ifcopenshell.api.{module}.{function}")
|
||||
fn = getattr(fn_module, function)
|
||||
return fn
|
||||
|
||||
|
||||
def serialize_result(value) -> object:
|
||||
"""Serialize an API result to a JSON-friendly structure."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, ifcopenshell.entity_instance):
|
||||
return _serialize_entity(value)
|
||||
if isinstance(value, (list, tuple, set, frozenset)):
|
||||
return [serialize_result(item) for item in value]
|
||||
if isinstance(value, dict):
|
||||
return {str(k): serialize_result(v) for k, v in value.items()}
|
||||
if isinstance(value, (str, int, float, bool)):
|
||||
return value
|
||||
return str(value)
|
||||
|
||||
|
||||
def _serialize_entity(entity: ifcopenshell.entity_instance) -> dict:
|
||||
"""Serialize an entity instance to a summary dict."""
|
||||
result = {
|
||||
"id": entity.id(),
|
||||
"type": entity.is_a(),
|
||||
}
|
||||
if hasattr(entity, "Name") and entity.Name:
|
||||
result["name"] = entity.Name
|
||||
return result
|
||||
@@ -0,0 +1,33 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "ifcedit"
|
||||
version = "0.0.0"
|
||||
authors = [
|
||||
{ name="Bruno Postle", email="bruno@postle.net" },
|
||||
]
|
||||
description = "CLI wrapper for ifcopenshell.api IFC model mutation functions"
|
||||
readme = "README.md"
|
||||
keywords = ["IFC", "BIM", "API"]
|
||||
classifiers = [
|
||||
"Programming Language :: Python :: 3",
|
||||
"License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)",
|
||||
]
|
||||
dependencies = ["ifcopenshell", "ifc5d"]
|
||||
|
||||
[project.scripts]
|
||||
ifcedit = "ifcedit.__main__:main"
|
||||
|
||||
[project.urls]
|
||||
Homepage = "http://ifcopenshell.org"
|
||||
Documentation = "https://docs.ifcopenshell.org"
|
||||
Issues = "https://github.com/IfcOpenShell/IfcOpenShell/issues"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["ifcedit*"]
|
||||
exclude = ["test*"]
|
||||
|
||||
[tool.ruff]
|
||||
extend = "../../pyproject.toml"
|
||||
@@ -0,0 +1 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
@@ -0,0 +1,63 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.aggregate
|
||||
import ifcopenshell.api.material
|
||||
import ifcopenshell.api.owner.settings
|
||||
import ifcopenshell.api.project
|
||||
import ifcopenshell.api.pset
|
||||
import ifcopenshell.api.root
|
||||
import ifcopenshell.api.spatial
|
||||
import ifcopenshell.api.unit
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def model():
|
||||
"""Create an IFC4 model with a spatial hierarchy and a wall."""
|
||||
f = ifcopenshell.api.project.create_file()
|
||||
ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0]
|
||||
ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0]
|
||||
|
||||
project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="TestProject")
|
||||
ifcopenshell.api.unit.assign_unit(f)
|
||||
|
||||
site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="TestSite")
|
||||
building = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuilding", name="TestBuilding")
|
||||
storey = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="Ground Floor")
|
||||
|
||||
ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project)
|
||||
ifcopenshell.api.aggregate.assign_object(f, products=[building], relating_object=site)
|
||||
ifcopenshell.api.aggregate.assign_object(f, products=[storey], relating_object=building)
|
||||
|
||||
wall = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall001")
|
||||
ifcopenshell.api.spatial.assign_container(f, products=[wall], relating_structure=storey)
|
||||
|
||||
return f
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def model_file(model, tmp_path):
|
||||
"""Write the model fixture to a temp file and return the path."""
|
||||
path = tmp_path / "test.ifc"
|
||||
model.write(str(path))
|
||||
return str(path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def library():
|
||||
"""Create an IFC4 library with a single IfcWallType asset."""
|
||||
lib = ifcopenshell.api.project.create_file()
|
||||
ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0]
|
||||
ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0]
|
||||
ifcopenshell.api.root.create_entity(lib, ifc_class="IfcProject", name="TestLibrary")
|
||||
ifcopenshell.api.unit.assign_unit(lib)
|
||||
ifcopenshell.api.root.create_entity(lib, ifc_class="IfcWallType", name="WAL01")
|
||||
return lib
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def library_file(library, tmp_path):
|
||||
"""Write the library fixture to a temp file and return the path."""
|
||||
path = tmp_path / "library.ifc"
|
||||
library.write(str(path))
|
||||
return str(path)
|
||||
@@ -0,0 +1,158 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
import json
|
||||
from typing import Literal, Optional, Union
|
||||
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.project
|
||||
import pytest
|
||||
|
||||
from ifcedit.coerce import coerce_value
|
||||
|
||||
|
||||
class TestStringCoercion:
|
||||
def test_plain_string(self):
|
||||
assert coerce_value("hello", str) == "hello"
|
||||
|
||||
def test_empty_string(self):
|
||||
assert coerce_value("", str) == ""
|
||||
|
||||
|
||||
class TestIntCoercion:
|
||||
def test_plain_int(self):
|
||||
assert coerce_value("42", int) == 42
|
||||
|
||||
def test_hash_prefix(self):
|
||||
assert coerce_value("#42", int) == 42
|
||||
|
||||
def test_negative(self):
|
||||
assert coerce_value("-5", int) == -5
|
||||
|
||||
|
||||
class TestFloatCoercion:
|
||||
def test_plain_float(self):
|
||||
assert coerce_value("3.14", float) == pytest.approx(3.14)
|
||||
|
||||
def test_integer_as_float(self):
|
||||
assert coerce_value("5", float) == 5.0
|
||||
|
||||
|
||||
class TestBoolCoercion:
|
||||
def test_true_values(self):
|
||||
for val in ("true", "True", "TRUE", "1", "yes"):
|
||||
assert coerce_value(val, bool) is True
|
||||
|
||||
def test_false_values(self):
|
||||
for val in ("false", "False", "0", "no"):
|
||||
assert coerce_value(val, bool) is False
|
||||
|
||||
|
||||
class TestOptionalCoercion:
|
||||
def test_optional_string(self):
|
||||
assert coerce_value("hello", Optional[str]) == "hello"
|
||||
|
||||
def test_optional_none(self):
|
||||
assert coerce_value("none", Optional[str]) is None
|
||||
assert coerce_value("None", Optional[str]) is None
|
||||
|
||||
def test_optional_int(self):
|
||||
assert coerce_value("42", Optional[int]) == 42
|
||||
|
||||
|
||||
class TestUnionCoercion:
|
||||
def test_union_str_int(self):
|
||||
# Tries str first (or int first depending on order), both work
|
||||
result = coerce_value("hello", Union[str, int])
|
||||
assert result == "hello"
|
||||
|
||||
def test_union_int_none(self):
|
||||
result = coerce_value("42", Union[int, None])
|
||||
assert result == 42
|
||||
|
||||
|
||||
class TestLiteralCoercion:
|
||||
def test_valid_literal(self):
|
||||
assert coerce_value("IFC4", Literal["IFC2X3", "IFC4", "IFC4X3"]) == "IFC4"
|
||||
|
||||
def test_invalid_literal(self):
|
||||
with pytest.raises(ValueError, match="not one of"):
|
||||
coerce_value("IFC5", Literal["IFC2X3", "IFC4", "IFC4X3"])
|
||||
|
||||
|
||||
class TestDictCoercion:
|
||||
def test_json_dict(self):
|
||||
result = coerce_value('{"IsExternal": true, "FireRating": "2HR"}', dict[str, object])
|
||||
assert result == {"IsExternal": True, "FireRating": "2HR"}
|
||||
|
||||
def test_mixed_float_int_list_coerced_to_float(self):
|
||||
# [0.419, 0, 0.908] — JSON integer 0 mixed with floats must become float
|
||||
# so ifcopenshell AGGREGATE OF DOUBLE attributes (e.g. DirectionRatios) don't reject the list
|
||||
result = coerce_value('{"DirectionRatios": [0.419, 0, 0.908]}', dict[str, object])
|
||||
assert result["DirectionRatios"] == pytest.approx([0.419, 0.0, 0.908])
|
||||
assert all(isinstance(v, float) for v in result["DirectionRatios"])
|
||||
|
||||
def test_pure_int_list_not_coerced(self):
|
||||
# All-integer lists (e.g. face indices) must stay as ints
|
||||
result = coerce_value('{"CoordIndex": [0, 1, 2]}', dict[str, object])
|
||||
assert result["CoordIndex"] == [0, 1, 2]
|
||||
assert all(isinstance(v, int) for v in result["CoordIndex"])
|
||||
|
||||
|
||||
class TestListCoercion:
|
||||
def test_comma_separated(self):
|
||||
result = coerce_value("a,b,c", list[str])
|
||||
assert result == ["a", "b", "c"]
|
||||
|
||||
def test_json_array(self):
|
||||
result = coerce_value("[1, 2, 3]", list[int])
|
||||
assert result == [1, 2, 3]
|
||||
|
||||
|
||||
class TestEntityCoercion:
|
||||
def test_entity_by_id(self, model):
|
||||
wall = model.by_type("IfcWall")[0]
|
||||
result = coerce_value(str(wall.id()), ifcopenshell.entity_instance, model)
|
||||
assert result == wall
|
||||
|
||||
def test_entity_with_hash(self, model):
|
||||
wall = model.by_type("IfcWall")[0]
|
||||
result = coerce_value(f"#{wall.id()}", ifcopenshell.entity_instance, model)
|
||||
assert result == wall
|
||||
|
||||
def test_entity_not_found(self, model):
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
coerce_value("999999", ifcopenshell.entity_instance, model)
|
||||
|
||||
def test_entity_list(self, model):
|
||||
wall = model.by_type("IfcWall")[0]
|
||||
result = coerce_value(str(wall.id()), list[ifcopenshell.entity_instance], model)
|
||||
assert len(result) == 1
|
||||
assert result[0] == wall
|
||||
|
||||
def test_entity_list_multiple(self, model):
|
||||
wall = model.by_type("IfcWall")[0]
|
||||
storey = model.by_type("IfcBuildingStorey")[0]
|
||||
result = coerce_value(f"{wall.id()},{storey.id()}", list[ifcopenshell.entity_instance], model)
|
||||
assert len(result) == 2
|
||||
|
||||
def test_entity_no_model(self):
|
||||
with pytest.raises(ValueError, match="without an IFC model"):
|
||||
coerce_value("42", ifcopenshell.entity_instance, None)
|
||||
|
||||
|
||||
class TestFileCoercion:
|
||||
def test_opens_file_from_path(self, model_file):
|
||||
result = coerce_value(model_file, ifcopenshell.file)
|
||||
assert isinstance(result, ifcopenshell.file)
|
||||
|
||||
def test_entity_from_lookup_file(self, model_file):
|
||||
lib = ifcopenshell.open(model_file)
|
||||
wall = lib.by_type("IfcWall")[0]
|
||||
empty_model = ifcopenshell.api.project.create_file()
|
||||
result = coerce_value(str(wall.id()), ifcopenshell.entity_instance, empty_model, lookup_file=lib)
|
||||
assert result.id() == wall.id()
|
||||
assert result.is_a("IfcWall")
|
||||
|
||||
|
||||
class TestFallback:
|
||||
def test_no_type_hint(self):
|
||||
assert coerce_value("hello", None) == "hello"
|
||||
@@ -0,0 +1,104 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
from ifcedit.discover import function_docs, list_functions, list_modules
|
||||
|
||||
|
||||
class TestListModules:
|
||||
def test_returns_list(self):
|
||||
result = list_modules()
|
||||
assert isinstance(result, list)
|
||||
assert len(result) > 0
|
||||
|
||||
def test_module_structure(self):
|
||||
result = list_modules()
|
||||
for entry in result:
|
||||
assert "module" in entry
|
||||
assert "description" in entry
|
||||
assert "functions" in entry
|
||||
assert "count" in entry
|
||||
assert isinstance(entry["functions"], list)
|
||||
assert entry["count"] == len(entry["functions"])
|
||||
|
||||
def test_known_modules_present(self):
|
||||
result = list_modules()
|
||||
module_names = [m["module"] for m in result]
|
||||
for expected in ("root", "spatial", "pset", "aggregate", "unit"):
|
||||
assert expected in module_names
|
||||
|
||||
def test_root_module_has_functions(self):
|
||||
result = list_modules()
|
||||
root = next(m for m in result if m["module"] == "root")
|
||||
assert "create_entity" in root["functions"]
|
||||
assert root["count"] >= 3
|
||||
|
||||
|
||||
class TestListFunctions:
|
||||
def test_root_functions(self):
|
||||
result = list_functions("root")
|
||||
assert isinstance(result, list)
|
||||
names = [f["name"] for f in result]
|
||||
assert "create_entity" in names
|
||||
|
||||
def test_function_structure(self):
|
||||
result = list_functions("root")
|
||||
for fn in result:
|
||||
assert "name" in fn
|
||||
assert "description" in fn
|
||||
assert "params" in fn
|
||||
|
||||
def test_create_entity_params(self):
|
||||
result = list_functions("root")
|
||||
create = next(f for f in result if f["name"] == "create_entity")
|
||||
param_names = [p["name"] for p in create["params"]]
|
||||
assert "ifc_class" in param_names
|
||||
assert "name" in param_names
|
||||
|
||||
def test_pset_functions(self):
|
||||
result = list_functions("pset")
|
||||
names = [f["name"] for f in result]
|
||||
assert "add_pset" in names
|
||||
assert "edit_pset" in names
|
||||
|
||||
|
||||
class TestFunctionDocs:
|
||||
def test_create_entity_docs(self):
|
||||
result = function_docs("root", "create_entity")
|
||||
assert result["module"] == "root"
|
||||
assert result["function"] == "create_entity"
|
||||
assert result["description"]
|
||||
assert isinstance(result["params"], list)
|
||||
assert len(result["params"]) > 0
|
||||
|
||||
def test_params_have_types(self):
|
||||
result = function_docs("root", "create_entity")
|
||||
for param in result["params"]:
|
||||
assert "name" in param
|
||||
assert "type" in param
|
||||
|
||||
def test_params_have_descriptions(self):
|
||||
result = function_docs("root", "create_entity")
|
||||
ifc_class = next(p for p in result["params"] if p["name"] == "ifc_class")
|
||||
assert "description" in ifc_class
|
||||
assert len(ifc_class["description"]) > 0
|
||||
|
||||
def test_return_type(self):
|
||||
result = function_docs("root", "create_entity")
|
||||
assert "return_type" in result
|
||||
|
||||
def test_assign_container_docs(self):
|
||||
result = function_docs("spatial", "assign_container")
|
||||
assert result["module"] == "spatial"
|
||||
param_names = [p["name"] for p in result["params"]]
|
||||
assert "products" in param_names
|
||||
assert "relating_structure" in param_names
|
||||
|
||||
def test_unknown_function_raises(self):
|
||||
import pytest
|
||||
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
function_docs("root", "nonexistent_function")
|
||||
|
||||
def test_edit_pset_docs(self):
|
||||
result = function_docs("pset", "edit_pset")
|
||||
param_names = [p["name"] for p in result["params"]]
|
||||
assert "pset" in param_names
|
||||
assert "properties" in param_names
|
||||
@@ -0,0 +1,100 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def run_ifcedit(*args):
|
||||
"""Run ifcedit as a subprocess and return (stdout, stderr, returncode)."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "ifcedit", *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.stdout, result.stderr, result.returncode
|
||||
|
||||
|
||||
class TestListCommand:
|
||||
def test_list_all_modules(self):
|
||||
stdout, stderr, rc = run_ifcedit("list")
|
||||
assert rc == 0
|
||||
data = json.loads(stdout)
|
||||
assert isinstance(data, list)
|
||||
module_names = [m["module"] for m in data]
|
||||
assert "root" in module_names
|
||||
assert "spatial" in module_names
|
||||
|
||||
def test_list_module_functions(self):
|
||||
stdout, stderr, rc = run_ifcedit("list", "root")
|
||||
assert rc == 0
|
||||
data = json.loads(stdout)
|
||||
assert isinstance(data, list)
|
||||
names = [f["name"] for f in data]
|
||||
assert "create_entity" in names
|
||||
|
||||
def test_list_text_format(self):
|
||||
stdout, stderr, rc = run_ifcedit("--format", "text", "list")
|
||||
assert rc == 0
|
||||
assert "root" in stdout
|
||||
|
||||
|
||||
class TestDocsCommand:
|
||||
def test_docs_create_entity(self):
|
||||
stdout, stderr, rc = run_ifcedit("docs", "root.create_entity")
|
||||
assert rc == 0
|
||||
data = json.loads(stdout)
|
||||
assert data["module"] == "root"
|
||||
assert data["function"] == "create_entity"
|
||||
assert "params" in data
|
||||
|
||||
def test_docs_invalid_path(self):
|
||||
stdout, stderr, rc = run_ifcedit("docs", "invalid_path")
|
||||
assert rc != 0
|
||||
assert "module.function" in stderr
|
||||
|
||||
def test_docs_unknown_function(self):
|
||||
stdout, stderr, rc = run_ifcedit("docs", "root.nonexistent")
|
||||
assert rc != 0
|
||||
|
||||
|
||||
class TestRunCommand:
|
||||
def test_create_entity(self, model_file):
|
||||
stdout, stderr, rc = run_ifcedit(
|
||||
"run", model_file, "root.create_entity", "--ifc_class", "IfcWall", "--name", "CLIWall"
|
||||
)
|
||||
assert rc == 0, f"stderr: {stderr}"
|
||||
data = json.loads(stdout)
|
||||
assert data["ok"] is True
|
||||
assert data["result"]["type"] == "IfcWall"
|
||||
assert data["result"]["name"] == "CLIWall"
|
||||
|
||||
def test_dry_run(self, model_file):
|
||||
stdout, stderr, rc = run_ifcedit("run", model_file, "root.create_entity", "--dry-run", "--ifc_class", "IfcWall")
|
||||
assert rc == 0
|
||||
data = json.loads(stdout)
|
||||
assert data["ok"] is True
|
||||
assert data["dry_run"] is True
|
||||
|
||||
def test_output_to_different_file(self, model_file, tmp_path):
|
||||
output = str(tmp_path / "output.ifc")
|
||||
stdout, stderr, rc = run_ifcedit(
|
||||
"run", model_file, "root.create_entity", "-o", output, "--ifc_class", "IfcSlab"
|
||||
)
|
||||
assert rc == 0, f"stderr: {stderr}"
|
||||
data = json.loads(stdout)
|
||||
assert data["ok"] is True
|
||||
|
||||
import os
|
||||
|
||||
assert os.path.exists(output)
|
||||
|
||||
def test_run_error_bad_function(self, model_file):
|
||||
stdout, stderr, rc = run_ifcedit("run", model_file, "root.nonexistent")
|
||||
assert rc != 0
|
||||
|
||||
def test_run_invalid_function_path(self, model_file):
|
||||
stdout, stderr, rc = run_ifcedit("run", model_file, "invalid_path")
|
||||
assert rc != 0
|
||||
assert "module.function" in stderr
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user