mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-08 00:41:36 +00:00
Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ac18195361 | |||
| e6258ab4a8 | |||
| 5db65f4041 | |||
| 89ce32fdfd | |||
| 763a31a31d | |||
| 4b8c612647 | |||
| 16723d11ca | |||
| 67238c4ac1 | |||
| 20229aa88c | |||
| 7788ae86c9 | |||
| 002b7c5d6e | |||
| e7db239647 | |||
| 158756e921 | |||
| a3efa7e9ee | |||
| 4896946e78 | |||
| 588f365366 | |||
| fa8770c14d | |||
| 0cf831133e | |||
| 98338e0831 | |||
| 5a3160eb62 | |||
| 80a9df8f52 | |||
| 8eb0060d4a | |||
| 51a338e4c8 | |||
| 7169dcd053 | |||
| b9d4ea38b0 | |||
| c8f46cfb69 | |||
| 6242251d3c | |||
| 1689960257 | |||
| c509f1d3ee | |||
| 217bfed847 | |||
| b4558f7f75 | |||
| ebd5fe854f | |||
| 06cfd0931c | |||
| 90bd7d26ac | |||
| 3fbf01f446 | |||
| 26a1955cba | |||
| 27d9cae8ff | |||
| a751c1cce3 | |||
| 9d4307d343 | |||
| 394eb54f80 | |||
| f91f44cd4a | |||
| d835577750 | |||
| fffb444b20 | |||
| a3f212c021 | |||
| c627d59bda | |||
| fd13cd6c13 | |||
| afd6f422ee | |||
| bb661ce998 | |||
| f40377b93d | |||
| 30210b0bf6 | |||
| f7d438f759 | |||
| 8534abe0e0 | |||
| 323d6db4d1 | |||
| 99e20cae64 | |||
| 5e10752bd7 | |||
| 39839cff66 | |||
| a570d81fd7 | |||
| f6e23c0462 | |||
| dcd87260e7 | |||
| 7d148456c0 | |||
| 55568e122d | |||
| 776112bf1d |
@@ -24,7 +24,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
pyver: [py311, py312]
|
||||
pyver: [py311, py312, py313]
|
||||
config:
|
||||
- {
|
||||
name: "Windows Build",
|
||||
@@ -42,6 +42,11 @@ jobs:
|
||||
name: "MacOS ARM Build",
|
||||
short_name: macosm1,
|
||||
}
|
||||
exclude:
|
||||
# Python 3.13 is needed for Blender 5.1+ and Blender dropped Intel Mac support in 5.0.
|
||||
- pyver: py313
|
||||
config:
|
||||
short_name: macos
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: ci-black-formatting
|
||||
name: ci-lint
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -30,6 +30,7 @@ jobs:
|
||||
uv tool install ruff
|
||||
uv tool install black
|
||||
uv tool install poethepoet
|
||||
uv tool install ty
|
||||
|
||||
# black doesn't catch all syntax errors, so we check them explicitly.
|
||||
- name: Check syntax errors
|
||||
@@ -57,6 +58,13 @@ jobs:
|
||||
black --diff --check . | black-codeclimate | python .github/workflows/black_to_github_annotations.py
|
||||
continue-on-error: true
|
||||
|
||||
- name: ty check
|
||||
id: ty
|
||||
run: |
|
||||
poe ty-venv
|
||||
poe ty
|
||||
continue-on-error: true
|
||||
|
||||
- name: Ruff check
|
||||
id: ruff
|
||||
run: |
|
||||
@@ -105,4 +113,7 @@ jobs:
|
||||
if [ "${{ steps.ruff.outcome }}" != "success" ]; then
|
||||
echo "::error::Ruff check failed, see Summary or 'ruff' step for the details." && ERROR=1
|
||||
fi
|
||||
if [ "${{ steps.ty.outcome }}" != "success" ]; then
|
||||
echo "::error::ty check failed, see 'ty check' step for the details." && ERROR=1
|
||||
fi
|
||||
exit $ERROR
|
||||
@@ -0,0 +1,46 @@
|
||||
name: Release Pyodide WASM Wheel
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout IfcOpenShell
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
|
||||
- name: Build wheel
|
||||
working-directory: pyodide
|
||||
run: uv run pack_wheel.py --build
|
||||
|
||||
- name: Find wheel
|
||||
id: wheel
|
||||
run: |
|
||||
WHEEL=$(ls pyodide/dist/ifcopenshell-*.whl)
|
||||
echo "path=$WHEEL" >> $GITHUB_OUTPUT
|
||||
echo "name=$(basename $WHEEL)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Checkout wasm-wheels
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: IfcOpenShell/wasm-wheels
|
||||
path: wasm-wheels
|
||||
token: ${{ secrets.BUILD_REPO_TOKEN }}
|
||||
|
||||
- name: Commit and push wheel to wasm-wheels
|
||||
run: |
|
||||
WHEEL_NAME="${{ steps.wheel.outputs.name }}"
|
||||
cp "${{ steps.wheel.outputs.path }}" "wasm-wheels/$WHEEL_NAME"
|
||||
cd wasm-wheels
|
||||
git config user.name "IfcOpenBot"
|
||||
git config user.email "ifcopenbot@ifcopenshell.org"
|
||||
git add "$WHEEL_NAME"
|
||||
git commit -m "Add $WHEEL_NAME"
|
||||
VERSION=$(cat ../VERSION)
|
||||
git tag "v${VERSION}"
|
||||
git push origin main
|
||||
git push origin "v${VERSION}"
|
||||
@@ -1,36 +0,0 @@
|
||||
name: Build and Deploy Stable Documentation
|
||||
|
||||
on:
|
||||
workflow_dispatch: # Manual trigger
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd src/bonsai/docs
|
||||
pip install -r requirements.txt # Run pip install from the docs directory
|
||||
|
||||
- name: Build documentation
|
||||
run: |
|
||||
cd src/bonsai/docs
|
||||
make html
|
||||
|
||||
- name: Deploy to GitHub Pages (Stable)
|
||||
uses: peaceiris/actions-gh-pages@v4
|
||||
with:
|
||||
deploy_key: ${{ secrets.ACTIONS_DEPLOY_KEY }}
|
||||
external_repository: IfcOpenShell/bonsaibim_org_docs
|
||||
publish_branch: main
|
||||
cname: docs.bonsaibim.org
|
||||
publish_dir: src/bonsai/docs/_build/html
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
submodules: recursive
|
||||
fetch-depth: 0
|
||||
- name: Checkout intermediate Pages repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: IfcOpenShell/aichat_ifcopenshell_org_static_html
|
||||
ref: gh-pages
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
run: |
|
||||
rsync -av --delete --exclude='.git/' src/ifcchat/ output/
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.x"
|
||||
- name: Download wheels
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
submodules: recursive
|
||||
fetch-depth: 0
|
||||
- name: Checkout intermediate Pages repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: IfcOpenShell/wasm_ifcopenshell_org_static_html
|
||||
ref: gh-pages
|
||||
|
||||
@@ -53,11 +53,11 @@ Contents
|
||||
| [ifcedit](https://docs.ifcopenshell.org/ifcedit.html) | CLI wrapper for ifcopenshell.api IFC model mutation functions | LGPL-3.0-or-later | [](https://pypi.org/project/ifcedit/) |
|
||||
| [ifcfm](https://docs.ifcopenshell.org/ifcfm.html) | Extract IFC data for FM handover requirements | LGPL-3.0-or-later | [](https://pypi.org/project/ifcfm/) |
|
||||
| [ifcmax](https://docs.ifcopenshell.org/ifcmax.html) | Historic extension for IFC support in 3DS Max | LGPL-3.0-or-later\* | [](https://docs.ifcopenshell.org/ifcmax.html)
|
||||
| [ifcmcp](https://docs.ifcopenshell.org/ifcmcp.html) | MCP server for querying and editing IFC building models | LGPL-3.0-or-later | [](https://pypi.org/project/ifcmcp/) |
|
||||
| [ifcopenshell-python](https://docs.ifcopenshell.org/ifcopenshell-python.html) | Python library for IFC manipulation | LGPL-3.0-or-later\* | [](https://docs.ifcopenshell.org/ifcopenshell-python/installation.html) [](https://github.com/IfcOpenShell/IfcOpenShell/releases?q=ifcopenshell-python&expanded=true) [](https://pypi.org/project/ifcopenshell/) [](https://anaconda.org/conda-forge/ifcopenshell) [](https://anaconda.org/ifcopenshell/ifcopenshell) [](https://hub.docker.com/r/aecgeeks/ifcopenshell) [](https://aur.archlinux.org/packages/ifcopenshell) [](https://aur.archlinux.org/packages/ifcopenshell-git) [Pyodide WASM Wheels](https://github.com/IfcOpenShell/wasm-wheels#pyodide-test-wheels) |
|
||||
| [ifcmcp](https://docs.ifcopenshell.org/ifcmcp.html) | MCP server for querying and editing IFC building models | LGPL-3.0-or-later | [](https://pypi.org/project/ifcopenshell-mcp/) |
|
||||
| [ifcopenshell-python](https://docs.ifcopenshell.org/ifcopenshell-python.html) | Python library for IFC manipulation | LGPL-3.0-or-later\* | [](https://docs.ifcopenshell.org/ifcopenshell-python/installation.html) [](https://github.com/IfcOpenShell/IfcOpenShell/releases?q=ifcopenshell-python&expanded=true) [](https://pypi.org/project/ifcopenshell/) [](https://anaconda.org/conda-forge/ifcopenshell) [](https://anaconda.org/ifcopenshell/ifcopenshell) [](https://hub.docker.com/r/aecgeeks/ifcopenshell) [](https://aur.archlinux.org/packages/ifcopenshell) [](https://aur.archlinux.org/packages/ifcopenshell-git) [](https://github.com/IfcOpenShell/wasm-wheels) |
|
||||
| [ifcpatch](https://docs.ifcopenshell.org/ifcpatch.html) | Utility to run pre-packaged scripts to manipulate IFCs | LGPL-3.0-or-later | [](https://pypi.org/project/ifcpatch/) |
|
||||
| [ifcquery](https://docs.ifcopenshell.org/ifcquery.html) | CLI tool for querying and inspecting IFC building models | LGPL-3.0-or-later | [](https://pypi.org/project/ifcquery/) |
|
||||
| [ifcsverchok](https://docs.ifcopenshell.org/ifcsverchok.html) | Blender Add-on for visual node programming with IFC | GPL-3.0-or-later | [](https://github.com/IfcOpenShell/IfcOpenShell/releases?q=ifcsverchok&expanded=true)
|
||||
| [ifcsverchok](https://docs.ifcopenshell.org/ifcsverchok.html) | Blender Add-on for visual node programming with IFC | GPL-3.0-or-later | [](https://github.com/IfcOpenShell/IfcOpenShell/releases?q=ifcsverchok&expanded=true)
|
||||
| [ifctester](https://docs.ifcopenshell.org/ifctester.html) | Library, CLI and webapp for IDS model auditing | LGPL-3.0-or-later | [](https://pypi.org/project/ifctester/) |
|
||||
|
||||
The IfcOpenShell C++ codebase is split into multiple interal libraries:
|
||||
|
||||
+12
-3
@@ -1094,10 +1094,19 @@ if "python" in targets and not USE_CURRENT_PYTHON_VERSION and "wasm" not in flag
|
||||
f"http://www.python.org/ftp/python/{PYTHON_VERSION}/",
|
||||
f"Python-{PYTHON_VERSION}.tgz",
|
||||
)
|
||||
python_bin = INSTALL_DIR / f"python-{PYTHON_VERSION}" / "bin" / "python3"
|
||||
python_install = INSTALL_DIR / f"python-{PYTHON_VERSION}"
|
||||
python_bin = python_install / "bin" / "python3"
|
||||
# `_ssl` module is present -> we will be able to install `numpy` later
|
||||
# to verify IfcOpenShell installation
|
||||
run([str(python_bin), "-c", "import _ssl"])
|
||||
try:
|
||||
run([str(python_bin), "-c", "import _ssl"])
|
||||
except RuntimeError:
|
||||
print(
|
||||
"ERROR: Python was built without SSL support (_ssl module is missing). "
|
||||
f"To fix this: remove the installed Python at {python_install}; "
|
||||
"install OpenSSL development libraries and re-run."
|
||||
)
|
||||
raise
|
||||
|
||||
if MAC_CROSS_COMPILE_INTEL:
|
||||
assert original_path
|
||||
@@ -1515,7 +1524,7 @@ if "IfcOpenShell-Python" in targets:
|
||||
)
|
||||
# Copy setup.py where pyodide build system expects it.
|
||||
shutil.copy(REPO_PATH / "pyodide" / "setup.py", REPO_PATH)
|
||||
# Empty pyproject so it's contents won't affect the resulting wheelthe the
|
||||
# Empty pyproject so it's contents won't affect the resulting wheel
|
||||
# otherwise the wheel will use version and dependencies from toml, not setup.py.
|
||||
(REPO_PATH / "pyproject.toml").write_text("")
|
||||
|
||||
|
||||
@@ -14,18 +14,11 @@ source .venv/bin/activate
|
||||
uv pip install pyodide-build
|
||||
# `uv run` is required, so xbuildenv would skip using `pip`.
|
||||
uv run pyodide xbuildenv install
|
||||
uv run pyodide xbuildenv install-emscripten
|
||||
|
||||
# Emscripten doesn't come with xbuildenv.
|
||||
if [ ! -d emsdk ]; then
|
||||
git clone https://github.com/emscripten-core/emsdk
|
||||
fi
|
||||
pushd emsdk
|
||||
PYODIDE_EMSCRIPTEN_VERSION=$(pyodide config get emscripten_version)
|
||||
./emsdk install ${PYODIDE_EMSCRIPTEN_VERSION}
|
||||
./emsdk activate ${PYODIDE_EMSCRIPTEN_VERSION}
|
||||
source emsdk_env.sh
|
||||
EMSDK_ROOT=$(pyodide config get emscripten_dir)
|
||||
source ${EMSDK_ROOT}/emsdk_env.sh
|
||||
which emcc
|
||||
popd
|
||||
|
||||
mkdir -p packages/ifcopenshell
|
||||
VERSION=`cat IfcOpenShell/VERSION`
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
#
|
||||
# /// script
|
||||
# # Latest Pyodide build env versions are listed here:
|
||||
# # https://pyodide.github.io/pyodide/api/pyodide-cross-build-environments.json
|
||||
# # https://github.com/pyodide/pyodide-build/blob/main/pyodide_build/xbuildenv_releases.py
|
||||
# requires-python = "==3.13.2"
|
||||
# dependencies = [
|
||||
# "requests",
|
||||
# "setuptools",
|
||||
# ]
|
||||
# ///
|
||||
"""
|
||||
Pack an IfcOpenShell WASM wheel using Pyodide build system.
|
||||
|
||||
Usage:
|
||||
uv run make_wheel.py # Show this help
|
||||
uv run make_wheel.py --build # Build wheel
|
||||
uv run make_wheel.py --clean # Clean build artifacts and exit
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
import requests
|
||||
|
||||
# Get repo root (parent of this script's parent directory)
|
||||
REPO_ROOT = Path(__file__).parent.parent
|
||||
PYODIDE_DIR = REPO_ROOT / "pyodide"
|
||||
BUILD_DIR = PYODIDE_DIR / "build"
|
||||
|
||||
# Hardcoded path (Windows packing workaround with --dev flag)
|
||||
PYODIDE_BUILD = Path(r"L:\Projects\Github\pyodide-build")
|
||||
|
||||
# Wheel platform tag (from PYODIDE_EMSCRIPTEN_VERSION in pyodide-build/Makefile.envs)
|
||||
WHEEL_PLATFORM_TAG = "emscripten_4_0_9_wasm32"
|
||||
|
||||
# Location where ifcopenshell will be extracted
|
||||
IFCOPENSHELL_DIR = PYODIDE_DIR / "ifcopenshell"
|
||||
|
||||
|
||||
class WheelBuilder:
|
||||
@staticmethod
|
||||
def extract_ifcopenshell_from_git(dst: Path) -> None:
|
||||
"""Extract ifcopenshell directory from git repo into destination."""
|
||||
Tools.rmrf(dst)
|
||||
|
||||
print(f"Extracting ifcopenshell from git to {dst}...")
|
||||
# Use git ls-files piped to git checkout-index to avoid copying
|
||||
# untracked or ignored files from the actual repo.
|
||||
ls_proc = subprocess.Popen(
|
||||
["git", "ls-files", "-z", "src/ifcopenshell-python/ifcopenshell"],
|
||||
cwd=REPO_ROOT,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
checkout_proc = subprocess.Popen(
|
||||
["git", "checkout-index", "-z", "--prefix", "pyodide/", "--stdin"],
|
||||
cwd=REPO_ROOT,
|
||||
stdin=ls_proc.stdout,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
assert ls_proc.stdout is not None
|
||||
ls_proc.stdout.close()
|
||||
checkout_proc.communicate()
|
||||
|
||||
if checkout_proc.returncode != 0:
|
||||
assert checkout_proc.stderr is not None
|
||||
raise RuntimeError(f"Failed to extract: {checkout_proc.stderr.decode()}")
|
||||
|
||||
# Move src/ifcopenshell-python/ifcopenshell to ifcopenshell.
|
||||
temp_src = PYODIDE_DIR / "src" / "ifcopenshell-python" / "ifcopenshell"
|
||||
shutil.move(temp_src, dst)
|
||||
|
||||
# Clean up temporary src directory.
|
||||
Tools.rmrf(PYODIDE_DIR / "src")
|
||||
|
||||
print("✓ Extracted ifcopenshell from git")
|
||||
|
||||
@staticmethod
|
||||
def get_wheel_url(makefile_path: Path) -> str:
|
||||
"""Get S3 wheel URL based on BINARY_VERSION and BUILD_COMMIT from Makefile."""
|
||||
|
||||
def parse_makefile_vars() -> dict[str, str]:
|
||||
content = makefile_path.read_text()
|
||||
vars: dict[str, str] = {}
|
||||
for match in re.finditer(r"^(BINARY_VERSION|BUILD_COMMIT):=(.+)$", content, re.MULTILINE):
|
||||
vars[match.group(1)] = match.group(2).strip()
|
||||
return vars
|
||||
|
||||
vars: dict[str, str] = parse_makefile_vars()
|
||||
binary_version = vars["BINARY_VERSION"]
|
||||
build_commit = vars["BUILD_COMMIT"]
|
||||
filename = f"ifcopenshell-{binary_version}+{build_commit}-cp313-cp313-pyodide_2025_0_wasm32.whl"
|
||||
encoded_filename = quote(filename, safe="")
|
||||
return f"https://s3.amazonaws.com/ifcopenshell-builds/{encoded_filename}"
|
||||
|
||||
@staticmethod
|
||||
def download_and_extract_so(url: str, build_dir: Path) -> tuple[Path, Path]:
|
||||
"""Download wheel from URL and extract .so and .py files."""
|
||||
py_wrapper_filename = "ifcopenshell_wrapper.py"
|
||||
build_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
wheel_path = build_dir / url.rsplit("/", 1)[-1]
|
||||
|
||||
if wheel_path.exists():
|
||||
print(f"Using cached wheel: {wheel_path}")
|
||||
else:
|
||||
print(f"Downloading {url}...")
|
||||
response = requests.get(url)
|
||||
response.raise_for_status()
|
||||
wheel_path.write_bytes(response.content)
|
||||
|
||||
print("Extracting _ifcopenshell_wrapper files...")
|
||||
with zipfile.ZipFile(wheel_path) as zf:
|
||||
so_files = [f for f in zf.namelist() if f.endswith(".so")]
|
||||
py_files = [f for f in zf.namelist() if f.endswith(py_wrapper_filename)]
|
||||
|
||||
assert so_files, "No .so file found in wheel"
|
||||
assert py_files, f"No {py_wrapper_filename} file found in wheel"
|
||||
|
||||
so_file = so_files[0]
|
||||
so_dst = build_dir / Path(so_file).name
|
||||
so_dst.write_bytes(zf.read(so_file))
|
||||
|
||||
py_file = py_files[0]
|
||||
py_dst = build_dir / Path(py_file).name
|
||||
py_dst.write_bytes(zf.read(py_file))
|
||||
|
||||
return so_dst, py_dst
|
||||
|
||||
|
||||
class Tools:
|
||||
@staticmethod
|
||||
def run(
|
||||
cmd: list[str],
|
||||
cwd: Path | None = None,
|
||||
) -> None:
|
||||
print(f"$ {' '.join(cmd)}")
|
||||
subprocess.check_call(cmd, cwd=cwd)
|
||||
|
||||
@staticmethod
|
||||
def create_symlink(dst: Path, src: Path) -> None:
|
||||
Tools.rmrf(dst)
|
||||
dst.symlink_to(src)
|
||||
|
||||
@staticmethod
|
||||
def rmrf(path: Path) -> None:
|
||||
if path.exists() or path.is_symlink():
|
||||
if path.is_dir() and not path.is_symlink():
|
||||
shutil.rmtree(path)
|
||||
else:
|
||||
path.unlink()
|
||||
|
||||
|
||||
def clean() -> None:
|
||||
"""Remove build artifacts."""
|
||||
paths_to_remove = (
|
||||
BUILD_DIR,
|
||||
PYODIDE_DIR / ".pyodide_build",
|
||||
PYODIDE_DIR / "dist",
|
||||
PYODIDE_DIR / "ifcopenshell.egg-info",
|
||||
PYODIDE_DIR / "src",
|
||||
IFCOPENSHELL_DIR,
|
||||
)
|
||||
for path in paths_to_remove:
|
||||
if path.exists() or path.is_symlink():
|
||||
print(f"Removing {path}...")
|
||||
Tools.rmrf(path)
|
||||
print("✓ Clean complete")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__, add_help=False)
|
||||
parser.add_argument("--build", action="store_true", help="Build the wheel")
|
||||
parser.add_argument("--clean", action="store_true", help="Clean build folder")
|
||||
parser.add_argument(
|
||||
"--dev",
|
||||
action="store_true",
|
||||
help="Use editable pyodide-build from hardcoded path (Windows packing workaround)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.build and not args.clean:
|
||||
print(__doc__)
|
||||
return
|
||||
|
||||
if args.clean:
|
||||
clean()
|
||||
return
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
WheelBuilder.extract_ifcopenshell_from_git(IFCOPENSHELL_DIR)
|
||||
|
||||
print("Downloading and extracting _ifcopenshell_wrapper files...")
|
||||
makefile = REPO_ROOT / "src" / "ifcopenshell-python" / "Makefile"
|
||||
wheel_url = WheelBuilder.get_wheel_url(makefile)
|
||||
so_file, py_file = WheelBuilder.download_and_extract_so(wheel_url, BUILD_DIR)
|
||||
|
||||
Tools.create_symlink(IFCOPENSHELL_DIR / Path(so_file).name, so_file)
|
||||
Tools.create_symlink(IFCOPENSHELL_DIR / Path(py_file).name, py_file)
|
||||
|
||||
print("Installing pyodide-build...")
|
||||
if args.dev:
|
||||
Tools.run(["uv", "pip", "install", "-e", str(PYODIDE_BUILD)])
|
||||
else:
|
||||
Tools.run(["uv", "pip", "install", "pyodide-build"])
|
||||
|
||||
print("Building with pyodide...")
|
||||
# Use --no-isolation due to pyodide-build Windows support issues:
|
||||
# symlink_unisolated_packages fails with missing `_sysconfigdata_$(CPYTHON_ABI_FLAGS)_emscripten_wasm32-emscripten.py`.
|
||||
# Hardcode platform name since pyodide doesn't yet support overriding wheel tags on Windows.
|
||||
#
|
||||
# Use `LEGACY_PLATFORM` since pyodide 0.34.1 introduced new tag for wheels `pyemscripten`,
|
||||
# which doesn't work with pyodide itself yet - https://github.com/pyodide/pyodide/issues/6177.
|
||||
os.environ["USE_LEGACY_PLATFORM"] = "1"
|
||||
Tools.run(["pyodide", "build", f"-C--build-option=--plat-name={WHEEL_PLATFORM_TAG}"])
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
print(f"\n✓ Done! ({elapsed:.1f}s)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+39
-1
@@ -2,12 +2,16 @@
|
||||
# because `tool.setuptools.ext-modules` is still experimental in pyproject.toml
|
||||
# and we need it to get the wheel suffix right.
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import tomllib
|
||||
from setuptools import Extension, find_packages, setup
|
||||
from setuptools.command.build_ext import build_ext
|
||||
|
||||
REPO_FOLDER = Path(__file__).parent
|
||||
# Detect repo folder: if setup.py is in pyodide folder, go to parent
|
||||
SETUP_DIR = Path(__file__).parent
|
||||
REPO_FOLDER = SETUP_DIR.parent if SETUP_DIR.name == "pyodide" else SETUP_DIR
|
||||
|
||||
|
||||
def get_version() -> str:
|
||||
@@ -25,6 +29,39 @@ def get_dependencies() -> list[str]:
|
||||
return dependencies
|
||||
|
||||
|
||||
class UnixBuildExt(build_ext):
|
||||
"""Customize ``build_ext`` to support packing on Windows."""
|
||||
|
||||
def finalize_options(self):
|
||||
from distutils import sysconfig
|
||||
|
||||
super().finalize_options()
|
||||
if sys.platform == "win32":
|
||||
self.compiler = "unix"
|
||||
|
||||
# Configure sysconfig for Windows builds
|
||||
# CCSHARED is the only variable that's not customizable with env vars.
|
||||
# Basically avoiding this:
|
||||
# File ".venv\Lib\site-packages\setuptools\_distutils\sysconfig.py", line 366, in customize_compiler
|
||||
# compiler_so=cc_cmd + ' ' + ccshared,
|
||||
# ~~~~~~~~~~~~~^~~~~~~~~~
|
||||
# TypeError: can only concatenate str (not "NoneType") to str
|
||||
sysconfig.get_config_vars() # Initialize config cache
|
||||
if sysconfig._config_vars.get("CCSHARED") is None:
|
||||
sysconfig._config_vars["CCSHARED"] = "-fPIC"
|
||||
# Override compiler type before it's instantiated
|
||||
|
||||
# Set Emscripten compiler environment variables
|
||||
os.environ["CC"] = "emcc"
|
||||
os.environ["CXX"] = "em++"
|
||||
os.environ["CFLAGS"] = ""
|
||||
os.environ["CXXFLAGS"] = ""
|
||||
os.environ["LDSHARED"] = "emcc -shared"
|
||||
os.environ["AR"] = "emar"
|
||||
os.environ["ARFLAGS"] = "rcs"
|
||||
os.environ["SETUPTOOLS_EXT_SUFFIX"] = ".cpython-313-wasm32-emscripten.so"
|
||||
|
||||
|
||||
setup(
|
||||
name="ifcopenshell",
|
||||
version=get_version(),
|
||||
@@ -44,4 +81,5 @@ setup(
|
||||
},
|
||||
# Has to provide extension to get the correct wheel suffix.
|
||||
ext_modules=[Extension("ifcopenshell._ifcopenshell_wrapper", sources=[])],
|
||||
cmdclass={"build_ext": UnixBuildExt},
|
||||
)
|
||||
|
||||
+6
-4
@@ -3,8 +3,9 @@ name = "IfcOpenShell"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"black==26.3.1",
|
||||
"ruff==0.15.8",
|
||||
"ruff==0.15.9",
|
||||
"poethepoet",
|
||||
"ty==0.0.29",
|
||||
"gersemi==0.26.1",
|
||||
]
|
||||
|
||||
@@ -28,6 +29,9 @@ extend-exclude = '''
|
||||
reportInvalidTypeForm = false
|
||||
disableBytesTypePromotions = true
|
||||
reportUnnecessaryTypeIgnoreComment = true
|
||||
reportRedeclaration = false
|
||||
# Ignore warnings from bpy stubs missing actual source files.
|
||||
reportMissingModuleSource = false
|
||||
# Pylance doesn't respect gitignore, so we have to exclude files manually here
|
||||
# to avoid VS Code slowing down.
|
||||
# https://github.com/microsoft/pylance-release/issues/5169
|
||||
@@ -84,7 +88,6 @@ 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"
|
||||
@@ -96,7 +99,6 @@ 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"
|
||||
@@ -224,7 +226,7 @@ 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.sequence = ["bonsai-deps", "ty-venv-bonsai", "ty-venv-ios"]
|
||||
|
||||
ty-venv-bonsai.sequence = [
|
||||
{cmd = "uv venv src/bonsai/.venv --python=3.11 --allow-existing"},
|
||||
|
||||
+1
-1
@@ -64,7 +64,7 @@ PYNUMBER:=3$(PYMINOR)
|
||||
PYPI_VERSION:=3.$(PYMINOR)
|
||||
endif # def PYVERSION
|
||||
|
||||
IFCMERGE_VERSION:=2026-04-02
|
||||
IFCMERGE_VERSION:=2026-04-07
|
||||
|
||||
ifdef PLATFORM
|
||||
SUPPORTED_PLATFORMS := linux macos macosm1 win
|
||||
|
||||
@@ -101,7 +101,7 @@ class AggregateDecorator:
|
||||
cls.is_installed = False
|
||||
|
||||
def dotted_line_shader(self):
|
||||
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") # ty:ignore[too-many-positional-arguments]
|
||||
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface")
|
||||
vert_out.smooth("FLOAT", "v_ArcLength")
|
||||
|
||||
shader_info = gpu.types.GPUShaderCreateInfo()
|
||||
|
||||
@@ -295,13 +295,13 @@ class ExplorerShowUIPopup(bpy.types.Operator):
|
||||
bl_description = "Show Explorer UI to select element as attribute value or edit it."
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
ifc_class: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
|
||||
ifc_class: bpy.props.StringProperty()
|
||||
"""Element IFC class."""
|
||||
attribute_name: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
|
||||
attribute_name: bpy.props.StringProperty()
|
||||
"""IFC class attribute name."""
|
||||
data_path: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
|
||||
data_path: bpy.props.StringProperty()
|
||||
"""Full data path"""
|
||||
preselect_ifc_id: bpy.props.IntProperty(options={"SKIP_SAVE"}) # pyright: ignore[reportRedeclaration]
|
||||
preselect_ifc_id: bpy.props.IntProperty(options={"SKIP_SAVE"})
|
||||
"""IFC id to preselect in the popup."""
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
@@ -41,7 +41,7 @@ class BIMAttributeProperties(PropertyGroup):
|
||||
|
||||
|
||||
class ExplorerEntity(PropertyGroup):
|
||||
ifc_definition_id: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
|
||||
ifc_definition_id: bpy.props.IntProperty()
|
||||
|
||||
if TYPE_CHECKING:
|
||||
ifc_definition_id: int
|
||||
@@ -60,7 +60,7 @@ class BIMExplorerProperties(PropertyGroup):
|
||||
self.property_unset("editing_entity_id")
|
||||
self.entity_attributes.clear()
|
||||
|
||||
is_loaded: BoolProperty( # pyright: ignore[reportRedeclaration]
|
||||
is_loaded: BoolProperty(
|
||||
name="Toggle Explorer UI",
|
||||
update=update_is_loaded,
|
||||
)
|
||||
@@ -76,15 +76,15 @@ class BIMExplorerProperties(PropertyGroup):
|
||||
def update_ifc_class(self, context: object) -> None:
|
||||
tool.Attribute.refresh_uilist_entities()
|
||||
|
||||
ifc_class: EnumProperty( # pyright: ignore[reportRedeclaration]
|
||||
ifc_class: EnumProperty(
|
||||
name="IFC Class To Search",
|
||||
items=get_ifc_class,
|
||||
update=update_ifc_class,
|
||||
)
|
||||
entities: CollectionProperty(type=ExplorerEntity) # pyright: ignore[reportRedeclaration]
|
||||
active_entity_index: IntProperty() # pyright: ignore[reportRedeclaration]
|
||||
editing_entity_id: IntProperty() # pyright: ignore[reportRedeclaration]
|
||||
entity_attributes: CollectionProperty(type=Attribute) # pyright: ignore[reportRedeclaration]
|
||||
entities: CollectionProperty(type=ExplorerEntity)
|
||||
active_entity_index: IntProperty()
|
||||
editing_entity_id: IntProperty()
|
||||
entity_attributes: CollectionProperty(type=Attribute)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
is_loaded: bool
|
||||
|
||||
@@ -201,16 +201,10 @@ class ExecuteIfcClash(bpy.types.Operator, ExportHelper):
|
||||
"ALT+click to run a quick clash without selecting a file to save."
|
||||
)
|
||||
|
||||
filter_glob: bpy.props.StringProperty( # pyright: ignore[reportRedeclaration]
|
||||
default="*.bcf;*.json", options={"HIDDEN"}
|
||||
)
|
||||
format: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
|
||||
name="Format", items=[(i, i, "") for i in ("bcf", "json")]
|
||||
)
|
||||
filepath: bpy.props.StringProperty( # pyright: ignore[reportRedeclaration]
|
||||
subtype="FILE_PATH", options={"SKIP_SAVE"}
|
||||
)
|
||||
quick_clash: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
|
||||
filter_glob: bpy.props.StringProperty(default="*.bcf;*.json", options={"HIDDEN"})
|
||||
format: bpy.props.EnumProperty(name="Format", items=[(i, i, "") for i in ("bcf", "json")])
|
||||
filepath: bpy.props.StringProperty(subtype="FILE_PATH", options={"SKIP_SAVE"})
|
||||
quick_clash: bpy.props.BoolProperty(
|
||||
options={"SKIP_SAVE"},
|
||||
)
|
||||
|
||||
|
||||
@@ -37,12 +37,12 @@ from bonsai.bim.prop import BIMFilterGroup, StrProperty
|
||||
|
||||
|
||||
class ClashSource(PropertyGroup):
|
||||
name: StringProperty( # pyright: ignore[reportRedeclaration]
|
||||
name: StringProperty(
|
||||
name="File",
|
||||
description="Absolute filepath to existing .ifc file to use as a clash source.",
|
||||
)
|
||||
filter_groups: CollectionProperty(type=BIMFilterGroup, name="Filter Groups") # pyright: ignore[reportRedeclaration]
|
||||
mode: EnumProperty( # pyright: ignore[reportRedeclaration]
|
||||
filter_groups: CollectionProperty(type=BIMFilterGroup, name="Filter Groups")
|
||||
mode: EnumProperty(
|
||||
items=[
|
||||
("a", "All Elements", "All elements will be used for clashing"),
|
||||
("i", "Include", "Only the selected elements are included for clashing"),
|
||||
@@ -62,7 +62,7 @@ class Clash(PropertyGroup):
|
||||
b_global_id: StringProperty(name="B")
|
||||
a_name: StringProperty(name="A Name")
|
||||
b_name: StringProperty(name="B Name")
|
||||
clash_type: EnumProperty( # pyright: ignore[reportRedeclaration]
|
||||
clash_type: EnumProperty(
|
||||
name="Clash Type",
|
||||
items=tuple((i, i, "") for i in CLASH_TYPE_ITEMS),
|
||||
)
|
||||
|
||||
@@ -87,7 +87,7 @@ class CopyCostSchedule(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_label = "Copy Cost Schedule"
|
||||
bl_description = "Create a duplicate of the provided cost schedule."
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
cost_schedule: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
|
||||
cost_schedule: bpy.props.IntProperty()
|
||||
|
||||
if TYPE_CHECKING:
|
||||
cost_schedule: int
|
||||
|
||||
@@ -260,14 +260,14 @@ class CreateAllShapes(bpy.types.Operator):
|
||||
)
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
geometry_library: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
|
||||
geometry_library: bpy.props.EnumProperty(
|
||||
name="Geometry Library",
|
||||
description="Geometry library to use for testing shape creation.",
|
||||
items=[(i, i, "") for i in get_args(ifcopenshell.geom.GEOMETRY_LIBRARY)],
|
||||
# By default use the same library as used for importing ifc project.
|
||||
default="hybrid-cgal-simple-opencascade",
|
||||
)
|
||||
custom_geometry_library: bpy.props.StringProperty( # pyright: ignore[reportRedeclaration]
|
||||
custom_geometry_library: bpy.props.StringProperty(
|
||||
name="Custom Geometry Library",
|
||||
description="Provide a custom geometry library name, will override the 'geometry library' property.",
|
||||
)
|
||||
@@ -781,7 +781,7 @@ class PurgeUnusedObjects(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_label = "Purge Unused Objects"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
object_type: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
|
||||
object_type: bpy.props.EnumProperty(
|
||||
name="Object Type",
|
||||
items=((s, s.capitalize(), "") for s in get_args(tool.Debug.PurgeMergeObjectType)),
|
||||
)
|
||||
@@ -827,7 +827,7 @@ class MergeIdenticalObjects(bpy.types.Operator, tool.Ifc.Operator):
|
||||
)
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
object_type: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
|
||||
object_type: bpy.props.EnumProperty(
|
||||
name="Object Type",
|
||||
items=((s, s.capitalize(), "") for s in get_args(tool.Debug.PurgeMergeObjectType)),
|
||||
)
|
||||
@@ -1073,7 +1073,7 @@ class ChangeLogLevel(bpy.types.Operator):
|
||||
bl_options = {"REGISTER"}
|
||||
bl_description = "Change general log level across all Python code in Blender"
|
||||
|
||||
log_level: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
|
||||
log_level: bpy.props.EnumProperty(
|
||||
name="Log Level",
|
||||
items=[(i, i, "") for i in get_args(LogLevelType)],
|
||||
default="WARNING",
|
||||
|
||||
@@ -246,17 +246,17 @@ class CreateDrawing(bpy.types.Operator):
|
||||
+ "Add the CTRL modifier to optionally open drawings to view them as\n"
|
||||
+ "they are created"
|
||||
)
|
||||
print_all: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
|
||||
print_all: bpy.props.BoolProperty(
|
||||
name="Print All",
|
||||
default=False,
|
||||
options={"SKIP_SAVE"},
|
||||
)
|
||||
open_viewer: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
|
||||
open_viewer: bpy.props.BoolProperty(
|
||||
name="Open in Viewer",
|
||||
default=False,
|
||||
options={"SKIP_SAVE"},
|
||||
)
|
||||
sync: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
|
||||
sync: bpy.props.BoolProperty(
|
||||
name="Sync Before Creating Drawing",
|
||||
description="Could save some time if you're sure IFC and current Blender session are already in sync",
|
||||
default=True,
|
||||
@@ -2322,14 +2322,14 @@ class ActivateDrawingBase(tool.Ifc.Operator):
|
||||
+ "SHIFT+CLICK to load a quick preview of the drawing view"
|
||||
)
|
||||
|
||||
drawing: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
|
||||
should_view_from_camera: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
|
||||
drawing: bpy.props.IntProperty()
|
||||
should_view_from_camera: bpy.props.BoolProperty(
|
||||
name="Should View From Camera",
|
||||
description="Move view to the activated drawing's camera position.",
|
||||
default=True,
|
||||
options={"SKIP_SAVE"},
|
||||
)
|
||||
use_quick_preview: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
|
||||
use_quick_preview: bpy.props.BoolProperty(
|
||||
name="Use Quick Preview",
|
||||
description="Just move the camera to the drawing view, without loading anything else.",
|
||||
default=False,
|
||||
@@ -3635,14 +3635,12 @@ class ToggleTargetView(bpy.types.Operator):
|
||||
bl_label = "Toggle Target View"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
target_view: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
|
||||
toggle_all: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
|
||||
target_view: bpy.props.StringProperty()
|
||||
toggle_all: bpy.props.BoolProperty(
|
||||
default=False,
|
||||
options={"SKIP_SAVE"},
|
||||
)
|
||||
option: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
|
||||
items=[(i, i, "") for i in get_args(ToggleOption)]
|
||||
)
|
||||
option: bpy.props.EnumProperty(items=[(i, i, "") for i in get_args(ToggleOption)])
|
||||
|
||||
if TYPE_CHECKING:
|
||||
target_view: str
|
||||
|
||||
@@ -860,13 +860,13 @@ class BIMTextProperties(PropertyGroup):
|
||||
is_editing: BoolProperty(name="Is Editing", default=False)
|
||||
literals: CollectionProperty(name="Literals", type=LiteralProps)
|
||||
newline_at: IntProperty(name="Newline At")
|
||||
symbol: EnumProperty( # pyright: ignore[reportRedeclaration]
|
||||
symbol: EnumProperty(
|
||||
name="Symbol",
|
||||
description="Symbol from symbols.svg to use for this text.",
|
||||
items=[(s, s, "") for s in ["NO SYMBOL", "CUSTOM SYMBOL"] + tool.Drawing.DEFAULT_SYMBOLS],
|
||||
default="NO SYMBOL",
|
||||
)
|
||||
custom_symbol: StringProperty( # pyright: ignore[reportRedeclaration]
|
||||
custom_symbol: StringProperty(
|
||||
name="Custom Symbol",
|
||||
description="Non-default symbol to use for this text.",
|
||||
)
|
||||
|
||||
@@ -85,7 +85,7 @@ class EditObjectPlacement(bpy.types.Operator, tool.Ifc.Operator):
|
||||
class OverrideMeshSeparate(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.override_mesh_separate"
|
||||
bl_label = "IFC Mesh Separate"
|
||||
blender_op = bpy.ops.mesh.separate.get_rna_type()
|
||||
blender_op = bpy.ops.mesh.separate.get_rna_type() # ty: ignore[missing-argument]
|
||||
bl_description = blender_op.description + ".\nAlso makes sure changes are in sync with IFC."
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
blender_type_prop = blender_op.properties["type"]
|
||||
@@ -246,7 +246,7 @@ class OverrideMeshSeparate(bpy.types.Operator, tool.Ifc.Operator):
|
||||
|
||||
class OverrideOriginSet(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.override_origin_set"
|
||||
blender_op = bpy.ops.object.origin_set.get_rna_type()
|
||||
blender_op = bpy.ops.object.origin_set.get_rna_type() # ty: ignore[missing-argument]
|
||||
bl_label = "IFC Origin Set"
|
||||
bl_description = (
|
||||
blender_op.description + ".\nAlso makes sure changes are in sync with IFC (operator works only on IFC objects)"
|
||||
@@ -801,7 +801,7 @@ def calc_delete_is_batch(ifc_file: ifcopenshell.file, context: bpy.types.Context
|
||||
class OverrideDelete(bpy.types.Operator):
|
||||
bl_idname = "bim.override_object_delete"
|
||||
bl_label = "IFC Delete"
|
||||
blender_op = bpy.ops.object.delete.get_rna_type()
|
||||
blender_op = bpy.ops.object.delete.get_rna_type() # ty: ignore[missing-argument]
|
||||
bl_description = (
|
||||
blender_op.description
|
||||
+ ".\nAlso makes sure changes in sync with IFC."
|
||||
@@ -821,7 +821,7 @@ class OverrideDelete(bpy.types.Operator):
|
||||
def poll(cls, context):
|
||||
# Match `object.delete` poll for consistency.
|
||||
# `object.delete` poll just checks for OBJECT mode.
|
||||
poll = bpy.ops.object.delete.poll()
|
||||
poll = bpy.ops.object.delete.poll() # ty: ignore[missing-argument]
|
||||
if poll:
|
||||
return True
|
||||
cls.poll_message_set("Only available in OBJECT mode")
|
||||
@@ -1045,7 +1045,7 @@ class SelectedIdsData(NamedTuple):
|
||||
class OverrideOutlinerDelete(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.override_outliner_delete"
|
||||
bl_label = "IFC Delete"
|
||||
blender_op = bpy.ops.outliner.delete.get_rna_type()
|
||||
blender_op = bpy.ops.outliner.delete.get_rna_type() # ty: ignore[missing-argument]
|
||||
bl_description = (
|
||||
blender_op.description
|
||||
+ ".\nAlso makes sure changes in sync with IFC."
|
||||
@@ -1060,7 +1060,7 @@ class OverrideOutlinerDelete(bpy.types.Operator, tool.Ifc.Operator):
|
||||
def poll(cls, context) -> bool:
|
||||
# Match `outliner.delete` poll for consistency.
|
||||
# `outliner.delete` just checks `area.type` == `OUTLINER`.
|
||||
poll = bpy.ops.outliner.delete.poll()
|
||||
poll = bpy.ops.outliner.delete.poll() # ty: ignore[missing-argument]
|
||||
if poll:
|
||||
return True
|
||||
cls.poll_message_set("Only available from Outliner.")
|
||||
@@ -1164,7 +1164,7 @@ class OverrideDuplicateMove(bpy.types.Operator):
|
||||
def poll(cls, context) -> bool:
|
||||
# Match `object.duplicate_move` poll for consistency.
|
||||
# `object.duplicate_move` poll checks for OBJECT mode.
|
||||
poll = bpy.ops.object.duplicate_move.poll()
|
||||
poll = bpy.ops.object.duplicate_move.poll() # ty: ignore[missing-argument]
|
||||
if poll:
|
||||
return True
|
||||
cls.poll_message_set("Only available in OBJECT mode")
|
||||
@@ -1908,7 +1908,7 @@ class RefreshLinkedAggregate(bpy.types.Operator, tool.Ifc.Operator):
|
||||
class OverrideJoin(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.override_object_join"
|
||||
bl_label = "IFC Join"
|
||||
blender_op = bpy.ops.mesh.separate.get_rna_type()
|
||||
blender_op = bpy.ops.mesh.separate.get_rna_type() # ty: ignore[missing-argument]
|
||||
bl_description = (
|
||||
blender_op.description
|
||||
+ ".\nAlso makes sure changes are in sync with IFC."
|
||||
@@ -1926,7 +1926,7 @@ class OverrideJoin(bpy.types.Operator, tool.Ifc.Operator):
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
if not bpy.ops.object.join.poll():
|
||||
if not bpy.ops.object.join.poll(): # ty: ignore[missing-argument]
|
||||
cls.poll_message_set("Active object is not EDITable.")
|
||||
return False
|
||||
if not context.selected_editable_objects:
|
||||
|
||||
@@ -43,11 +43,11 @@ class ToggleGroup(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_label = "Toggle Group"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
ifc_definition_id: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
|
||||
group_type: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
|
||||
ifc_definition_id: bpy.props.IntProperty()
|
||||
group_type: bpy.props.EnumProperty(
|
||||
items=[(i, i, "") for i in get_args(tool.Group.GroupType)],
|
||||
)
|
||||
option: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
|
||||
option: bpy.props.EnumProperty(
|
||||
items=[(i, i, "") for i in get_args(tool.Group.ToggleOption)],
|
||||
)
|
||||
|
||||
|
||||
@@ -314,7 +314,7 @@ class SelectConflictEntity(bpy.types.Operator):
|
||||
bl_idname = "ifcgit.select_conflict_entity"
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
step_id: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
|
||||
step_id: bpy.props.IntProperty()
|
||||
|
||||
if TYPE_CHECKING:
|
||||
step_id: int
|
||||
@@ -515,7 +515,7 @@ class RunGitDiff(bpy.types.Operator):
|
||||
)
|
||||
bl_options = set()
|
||||
|
||||
save_to_temp: bpy.props.BoolProperty(options={"SKIP_SAVE"}) # pyright: ignore[reportRedeclaration]
|
||||
save_to_temp: bpy.props.BoolProperty(options={"SKIP_SAVE"})
|
||||
|
||||
if TYPE_CHECKING:
|
||||
save_to_temp: bool
|
||||
@@ -547,7 +547,7 @@ class RenameBranch(bpy.types.Operator):
|
||||
bl_idname = "ifcgit.rename_branch"
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
new_name: bpy.props.StringProperty(name="New name") # pyright: ignore[reportRedeclaration]
|
||||
new_name: bpy.props.StringProperty(name="New name")
|
||||
|
||||
if TYPE_CHECKING:
|
||||
new_name: str
|
||||
|
||||
@@ -23,7 +23,7 @@ from pathlib import Path
|
||||
import bpy
|
||||
import pyradiance
|
||||
|
||||
from . import list, operator, prop, ui
|
||||
from . import export, ies, list, material, prepare, prop, render, solar, ui
|
||||
|
||||
|
||||
def get_pyradiance_path():
|
||||
@@ -31,31 +31,43 @@ def get_pyradiance_path():
|
||||
|
||||
|
||||
classes = (
|
||||
operator.ExportOBJ,
|
||||
operator.ImportLatLong,
|
||||
operator.ImportTrueNorth,
|
||||
operator.MoveSunPathTo3DCursor,
|
||||
operator.RadianceRender,
|
||||
operator.ViewFromSun,
|
||||
operator.LightPickCoordinates,
|
||||
operator.LightSetTimeToNow,
|
||||
operator.RefreshIFCMaterials,
|
||||
operator.UnmapMaterial,
|
||||
operator.RADIANCE_OT_select_camera,
|
||||
operator.RADIANCE_OT_export_material_mappings,
|
||||
operator.RADIANCE_OT_import_material_mappings,
|
||||
operator.RADIANCE_OT_open_spectraldb,
|
||||
export.ExportOBJ,
|
||||
solar.ImportLatLong,
|
||||
solar.ImportTrueNorth,
|
||||
solar.MoveSunPathTo3DCursor,
|
||||
render.RadianceRender,
|
||||
render.FalseColorRadiance,
|
||||
render.RADIANCE_OT_select_camera,
|
||||
solar.ViewFromSun,
|
||||
solar.LightPickCoordinates,
|
||||
solar.LightSetTimeToNow,
|
||||
material.RefreshIFCMaterials,
|
||||
material.UnmapMaterial,
|
||||
material.RADIANCE_OT_export_material_mappings,
|
||||
material.RADIANCE_OT_import_material_mappings,
|
||||
material.RADIANCE_OT_open_spectraldb,
|
||||
prepare.PrepareRadianceScene,
|
||||
ies.AddIESLight,
|
||||
ies.RemoveIESLight,
|
||||
export.CleanupRadianceFiles,
|
||||
prop.RadianceMaterial,
|
||||
prop.IESLight,
|
||||
prop.BIMSolarProperties,
|
||||
prop.RadianceExporterProperties,
|
||||
ui.BIM_PT_radiance_exporter,
|
||||
ui.BIM_PT_radiance_scene_setup,
|
||||
ui.BIM_PT_radiance_materials,
|
||||
ui.BIM_PT_radiance_lighting,
|
||||
ui.BIM_PT_radiance_render_settings,
|
||||
ui.BIM_PT_radiance_pipeline,
|
||||
ui.BIM_PT_solar,
|
||||
list.MATERIAL_UL_radiance_materials,
|
||||
list.MATERIAL_UL_ies_lights,
|
||||
)
|
||||
|
||||
|
||||
def register():
|
||||
bpy.types.Scene.BIMRadianceExporeterProperies = bpy.props.PointerProperty(type=prop.RadianceExporterProperties)
|
||||
bpy.types.Scene.BIMRadianceExporterProperties = bpy.props.PointerProperty(type=prop.RadianceExporterProperties)
|
||||
bpy.types.Scene.BIMSolarProperties = bpy.props.PointerProperty(type=prop.BIMSolarProperties)
|
||||
|
||||
if pyradiance:
|
||||
@@ -68,5 +80,5 @@ def register():
|
||||
|
||||
|
||||
def unregister():
|
||||
del bpy.types.Scene.BIMRadianceExporeterProperies
|
||||
del bpy.types.Scene.BIMRadianceExporterProperties
|
||||
del bpy.types.Scene.BIMSolarProperties
|
||||
|
||||
@@ -0,0 +1,488 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2020, 2021 Dion Moult <dion@thinkmoult.com>
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import multiprocessing
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
import ifcopenshell.geom
|
||||
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.module.light.shared import ifc_materials, linked_model_exports
|
||||
|
||||
|
||||
class ExportOBJ(bpy.types.Operator):
|
||||
"""Exports the IFC File to OBJ"""
|
||||
|
||||
bl_idname = "export_scene.radiance"
|
||||
bl_label = "Export"
|
||||
bl_description = "Export the IFC to OBJ"
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
if not tool.Ifc.get():
|
||||
cls.poll_message_set("No IFC file loaded in Bonsai.")
|
||||
return False
|
||||
props = tool.Blender.get_radiance_exporter_props()
|
||||
if not props.output_dir:
|
||||
cls.poll_message_set("Output directory is not set.")
|
||||
return False
|
||||
return True
|
||||
|
||||
def _get_geom_settings(self):
|
||||
"""Create standard geometry and serializer settings for OBJ export."""
|
||||
settings = ifcopenshell.geom.settings()
|
||||
serializer_settings = ifcopenshell.geom.serializer_settings()
|
||||
settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.SURFACES_AND_SOLIDS)
|
||||
settings.set("apply-default-materials", True)
|
||||
serializer_settings.set("use-element-guids", True)
|
||||
settings.set("use-world-coords", True)
|
||||
return settings, serializer_settings
|
||||
|
||||
def _get_exportable_elements(self, ifc_file, filter_visibility=True):
|
||||
"""Get the list of elements to export from an IFC file."""
|
||||
if ifc_file.schema in ("IFC2X3", "IFC4"):
|
||||
elements = ifc_file.by_type("IfcElement") + ifc_file.by_type("IfcProxy")
|
||||
else:
|
||||
elements = ifc_file.by_type("IfcElement")
|
||||
|
||||
elements += ifc_file.by_type("IfcSite")
|
||||
elements = [e for e in elements if not e.is_a("IfcFeatureElement") or e.is_a("IfcSurfaceFeature")]
|
||||
|
||||
if not filter_visibility:
|
||||
return elements
|
||||
|
||||
# Filter by visibility in Blender.
|
||||
# We use hide_get() (user-toggled eye icon) instead of visible_get()
|
||||
# because visible_get() also considers collection/view-layer visibility
|
||||
# which incorrectly excludes objects in linked aggregate sub-collections.
|
||||
visible_elements = []
|
||||
for element in elements:
|
||||
blender_obj = tool.Ifc.get_object(element)
|
||||
if blender_obj is None:
|
||||
# No Blender object (linked aggregate copies, or not yet represented)
|
||||
# Include by default — the geometry exists in the IFC file
|
||||
visible_elements.append(element)
|
||||
continue
|
||||
if not blender_obj.hide_get():
|
||||
visible_elements.append(element)
|
||||
else:
|
||||
print(f"Skipping hidden element: {element.GlobalId if hasattr(element, 'GlobalId') else element.id()}")
|
||||
return visible_elements
|
||||
|
||||
def _export_ifc_to_obj(self, ifc_file, obj_path, mtl_path, settings, serializer_settings, elements):
|
||||
"""Export elements from an IFC file to OBJ format. Returns collected material names."""
|
||||
materials_collected = []
|
||||
serialiser = ifcopenshell.geom.serializers.obj(obj_path, mtl_path, settings, serializer_settings)
|
||||
serialiser.setFile(ifc_file)
|
||||
serialiser.setUnitNameAndMagnitude("METER", 1.0)
|
||||
serialiser.writeHeader()
|
||||
|
||||
print(f"Exporting {len(elements)} elements to {obj_path}")
|
||||
iterator = ifcopenshell.geom.iterator(settings, ifc_file, multiprocessing.cpu_count(), include=elements)
|
||||
if iterator.initialize():
|
||||
while True:
|
||||
shape = iterator.get()
|
||||
for material in shape.geometry.materials:
|
||||
materials_collected.append(material.name)
|
||||
serialiser.write(shape)
|
||||
if not iterator.next():
|
||||
break
|
||||
|
||||
serialiser.finalize()
|
||||
return materials_collected
|
||||
|
||||
def _sync_moved_object_placements(self):
|
||||
"""Sync Blender object positions to IFC ObjectPlacements for all moved objects.
|
||||
|
||||
This is critical for linked aggregate copies: their IFC ObjectPlacements
|
||||
are initially copies of the original's placement. After the user moves them
|
||||
in Blender, the IFC placements are stale until explicitly synced. Without
|
||||
this, the iterator with use-world-coords=True exports all copies at the
|
||||
original position.
|
||||
"""
|
||||
import bonsai.core.geometry as core_geometry
|
||||
|
||||
synced = 0
|
||||
for obj in bpy.data.objects:
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if element is None:
|
||||
continue
|
||||
if not element.is_a("IfcProduct"):
|
||||
continue
|
||||
try:
|
||||
if tool.Ifc.is_moved(obj):
|
||||
core_geometry.edit_object_placement(
|
||||
ifc=tool.Ifc,
|
||||
geometry=tool.Geometry,
|
||||
surveyor=tool.Surveyor,
|
||||
obj=obj,
|
||||
apply_scale=False,
|
||||
)
|
||||
synced += 1
|
||||
except Exception as e:
|
||||
print(f"Could not sync placement for {obj.name}: {e}")
|
||||
if synced:
|
||||
print(f"Synced {synced} moved object placement(s) to IFC before export")
|
||||
|
||||
def _export_collection_instances_obj(self, context, output_dir):
|
||||
"""Export Blender collection instances as per-parent OBJ files.
|
||||
|
||||
Collection instances (empties with instance_type='COLLECTION') are purely
|
||||
Blender constructs — they don't exist in the IFC file. The ifcopenshell
|
||||
iterator ignores them entirely, so we must export their geometry via
|
||||
Blender's depsgraph which evaluates all instances with correct world transforms.
|
||||
|
||||
Each collection instance parent gets its own OBJ file because obj2mesh
|
||||
cannot handle all instances in a single file ("too many patch triangles").
|
||||
"""
|
||||
depsgraph = context.evaluated_depsgraph_get()
|
||||
|
||||
# Find which objects are collection instance parents (visible, not hidden)
|
||||
# Skip collections that belong to linked IFC models — those are already
|
||||
# exported by _export_linked_models() via the IFC serializer.
|
||||
instance_parents = set()
|
||||
for obj in bpy.data.objects:
|
||||
if obj.instance_type == "COLLECTION" and obj.instance_collection is not None:
|
||||
if not obj.visible_get():
|
||||
continue
|
||||
# Check if this collection contains linked IFC objects
|
||||
coll = obj.instance_collection
|
||||
is_linked_ifc = any("guids" in child for child in coll.all_objects if child.type == "MESH")
|
||||
if is_linked_ifc:
|
||||
print(f"Skipping collection instance '{obj.name}' (linked IFC model, exported via IFC serializer)")
|
||||
continue
|
||||
instance_parents.add(obj.name)
|
||||
|
||||
if not instance_parents:
|
||||
return
|
||||
|
||||
print(f"Found {len(instance_parents)} collection instance(s) to export")
|
||||
|
||||
# Export one OBJ per collection instance parent
|
||||
identity = [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]
|
||||
total_meshes = 0
|
||||
|
||||
for parent_name in sorted(instance_parents):
|
||||
obj_path = os.path.join(output_dir, f"instance_{parent_name}.obj")
|
||||
vert_offset = 0
|
||||
mesh_count = 0
|
||||
|
||||
with open(obj_path, "w") as f:
|
||||
f.write(f"# Collection instance geometry for {parent_name}\n")
|
||||
f.write("usemtl white\n\n")
|
||||
|
||||
for dep_inst in depsgraph.object_instances:
|
||||
if not dep_inst.is_instance:
|
||||
continue
|
||||
if dep_inst.parent is None:
|
||||
continue
|
||||
if dep_inst.parent.original.name != parent_name:
|
||||
continue
|
||||
|
||||
eval_obj = dep_inst.object
|
||||
if eval_obj.type != "MESH":
|
||||
continue
|
||||
|
||||
try:
|
||||
mesh = eval_obj.to_mesh()
|
||||
except RuntimeError:
|
||||
continue
|
||||
if mesh is None:
|
||||
continue
|
||||
|
||||
matrix = dep_inst.matrix_world
|
||||
f.write(f"g obj_{mesh_count}\n")
|
||||
|
||||
for v in mesh.vertices:
|
||||
co = matrix @ v.co
|
||||
f.write(f"v {co.x} {co.y} {co.z}\n")
|
||||
|
||||
mesh.calc_loop_triangles()
|
||||
for tri in mesh.loop_triangles:
|
||||
i0 = vert_offset + tri.vertices[0] + 1
|
||||
i1 = vert_offset + tri.vertices[1] + 1
|
||||
i2 = vert_offset + tri.vertices[2] + 1
|
||||
f.write(f"f {i0} {i1} {i2}\n")
|
||||
|
||||
vert_offset += len(mesh.vertices)
|
||||
mesh_count += 1
|
||||
eval_obj.to_mesh_clear()
|
||||
|
||||
if mesh_count == 0:
|
||||
try:
|
||||
os.remove(obj_path)
|
||||
except OSError:
|
||||
pass
|
||||
continue
|
||||
|
||||
# Identity matrix — geometry is already at world coordinates
|
||||
linked_model_exports.append((obj_path, "", identity))
|
||||
total_meshes += mesh_count
|
||||
print(f" {parent_name}: {mesh_count} meshes, {vert_offset} vertices")
|
||||
|
||||
if total_meshes > 0:
|
||||
print(f"Exported {total_meshes} instanced meshes across {len(linked_model_exports)} file(s)")
|
||||
|
||||
def _export_non_ifc_meshes(self, context, output_dir):
|
||||
"""Export visible Blender mesh objects that have no IFC entity.
|
||||
|
||||
These are plain Blender geometry (e.g. manually added planes, cubes)
|
||||
that don't exist in any IFC file. They are skipped by the IFC iterator
|
||||
and by the collection instance exporter, so we handle them separately.
|
||||
"""
|
||||
non_ifc_meshes = []
|
||||
for obj in bpy.data.objects:
|
||||
if obj.type != "MESH":
|
||||
continue
|
||||
if not obj.visible_get():
|
||||
continue
|
||||
# Skip objects that have an IFC entity (handled by main/linked IFC export)
|
||||
if tool.Ifc.get_entity(obj) is not None:
|
||||
continue
|
||||
# Skip objects inside instanced collections (handled by collection instance export)
|
||||
if any(col.library for col in obj.users_collection):
|
||||
continue
|
||||
# Skip linked IFC element objects (have "guids" custom prop)
|
||||
if "guids" in obj:
|
||||
continue
|
||||
non_ifc_meshes.append(obj)
|
||||
|
||||
if not non_ifc_meshes:
|
||||
return
|
||||
|
||||
obj_path = os.path.join(output_dir, "blender_meshes.obj")
|
||||
vert_offset = 0
|
||||
mesh_count = 0
|
||||
identity = [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]
|
||||
|
||||
depsgraph = context.evaluated_depsgraph_get()
|
||||
|
||||
with open(obj_path, "w") as f:
|
||||
f.write("# Non-IFC Blender mesh geometry\n")
|
||||
f.write("usemtl white\n\n")
|
||||
|
||||
for obj in non_ifc_meshes:
|
||||
eval_obj = obj.evaluated_get(depsgraph)
|
||||
try:
|
||||
mesh = eval_obj.to_mesh()
|
||||
except RuntimeError:
|
||||
continue
|
||||
if mesh is None:
|
||||
continue
|
||||
|
||||
matrix = obj.matrix_world
|
||||
f.write(f"g {obj.name}\n")
|
||||
|
||||
for v in mesh.vertices:
|
||||
co = matrix @ v.co
|
||||
f.write(f"v {co.x} {co.y} {co.z}\n")
|
||||
|
||||
mesh.calc_loop_triangles()
|
||||
for tri in mesh.loop_triangles:
|
||||
i0 = vert_offset + tri.vertices[0] + 1
|
||||
i1 = vert_offset + tri.vertices[1] + 1
|
||||
i2 = vert_offset + tri.vertices[2] + 1
|
||||
f.write(f"f {i0} {i1} {i2}\n")
|
||||
|
||||
vert_offset += len(mesh.vertices)
|
||||
mesh_count += 1
|
||||
eval_obj.to_mesh_clear()
|
||||
|
||||
if mesh_count == 0:
|
||||
try:
|
||||
os.remove(obj_path)
|
||||
except OSError:
|
||||
pass
|
||||
return
|
||||
|
||||
linked_model_exports.append((obj_path, "", identity))
|
||||
print(f"Exported {mesh_count} non-IFC Blender mesh(es) ({vert_offset} vertices)")
|
||||
|
||||
def execute(self, context):
|
||||
ifc_materials.clear()
|
||||
linked_model_exports.clear()
|
||||
|
||||
props = tool.Blender.get_radiance_exporter_props()
|
||||
output_dir = props.output_dir
|
||||
props.is_exporting = True
|
||||
|
||||
# Sync all moved Blender object positions to IFC before export.
|
||||
self._sync_moved_object_placements()
|
||||
|
||||
settings, serializer_settings = self._get_geom_settings()
|
||||
|
||||
ifc_file = tool.Ifc.get()
|
||||
|
||||
# --- Export main model ---
|
||||
obj_file_path = os.path.join(output_dir, "model.obj")
|
||||
mtl_file_path = os.path.join(output_dir, "model.mtl")
|
||||
|
||||
visible_elements = self._get_exportable_elements(ifc_file, filter_visibility=True)
|
||||
mats = self._export_ifc_to_obj(
|
||||
ifc_file, obj_file_path, mtl_file_path, settings, serializer_settings, visible_elements
|
||||
)
|
||||
ifc_materials.extend(mats)
|
||||
|
||||
self.report({"INFO"}, f"Exported main model OBJ to: {obj_file_path}")
|
||||
|
||||
# --- Export linked models (external IFC files) ---
|
||||
self._export_linked_models(ifc_file, output_dir, settings, serializer_settings)
|
||||
|
||||
# --- Export collection instances (Blender-level linked copies) ---
|
||||
self._export_collection_instances_obj(context, output_dir)
|
||||
|
||||
# --- Export non-IFC Blender meshes (plain geometry with no IFC entity) ---
|
||||
self._export_non_ifc_meshes(context, output_dir)
|
||||
|
||||
props.is_exporting = False
|
||||
total_linked = len(linked_model_exports)
|
||||
if total_linked:
|
||||
self.report({"INFO"}, f"Also exported {total_linked} linked/instanced model(s)")
|
||||
return {"FINISHED"}
|
||||
|
||||
def _export_linked_models(self, main_ifc_file, output_dir, settings, serializer_settings):
|
||||
"""Detect and export all linked IFC models."""
|
||||
try:
|
||||
project_props = tool.Project.get_project_props()
|
||||
except Exception:
|
||||
print("Could not access project properties for linked models")
|
||||
return
|
||||
|
||||
for idx, link in enumerate(project_props.links):
|
||||
if not link.is_loaded:
|
||||
print(f"Skipping linked model '{link.name}' (not loaded)")
|
||||
continue
|
||||
|
||||
# Check if the link's empty handle is hidden in Blender
|
||||
try:
|
||||
link_empty = tool.Project.get_link_empty_handle(link)
|
||||
if link_empty is not None and not link_empty.visible_get():
|
||||
print(f"Skipping linked model '{link.name}' (hidden in viewport)")
|
||||
continue
|
||||
except Exception:
|
||||
pass # If we can't check visibility, export anyway
|
||||
|
||||
try:
|
||||
filepath = Path(tool.Ifc.resolve_uri(link.filepath))
|
||||
except Exception as e:
|
||||
print(f"Could not resolve path for linked model '{link.name}': {e}")
|
||||
continue
|
||||
|
||||
if not filepath.exists():
|
||||
print(f"Linked IFC file not found: {filepath}")
|
||||
continue
|
||||
|
||||
print(f"Exporting linked model {idx}: {filepath.name}")
|
||||
try:
|
||||
linked_ifc = ifcopenshell.open(str(filepath))
|
||||
except Exception as e:
|
||||
print(f" Failed to open linked IFC: {e}")
|
||||
continue
|
||||
|
||||
link_obj_path = os.path.join(output_dir, f"linked_{idx}.obj")
|
||||
link_mtl_path = os.path.join(output_dir, f"linked_{idx}.mtl")
|
||||
|
||||
# For linked models we don't filter by Blender visibility
|
||||
# (their objects are collection instances, not individually tracked)
|
||||
elements = self._get_exportable_elements(linked_ifc, filter_visibility=False)
|
||||
if not elements:
|
||||
print(f" No exportable elements found in linked model")
|
||||
continue
|
||||
|
||||
mats = self._export_ifc_to_obj(
|
||||
linked_ifc, link_obj_path, link_mtl_path, settings, serializer_settings, elements
|
||||
)
|
||||
ifc_materials.extend(mats)
|
||||
|
||||
# Get the link transformation matrix
|
||||
try:
|
||||
link_matrix = tool.Project.calculate_link_matrix(link)
|
||||
matrix_list = [list(row) for row in link_matrix]
|
||||
except Exception as e:
|
||||
print(f" Could not calculate link matrix: {e}, using identity")
|
||||
matrix_list = [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]
|
||||
|
||||
linked_model_exports.append((link_obj_path, link_mtl_path, matrix_list))
|
||||
print(f" Exported {len(elements)} elements from linked model '{link.name}'")
|
||||
|
||||
|
||||
class CleanupRadianceFiles(bpy.types.Operator):
|
||||
"""Delete all generated Radiance files from the output directory"""
|
||||
|
||||
bl_idname = "radiance.cleanup_files"
|
||||
bl_label = "Cleanup Radiance Files"
|
||||
bl_description = "Remove all generated files (OBJ, MTL, RTM, RAD, HDR, TIFF, DAT) from the output directory"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
props = tool.Blender.get_radiance_exporter_props()
|
||||
if not props.output_dir:
|
||||
cls.poll_message_set("Output directory is not set.")
|
||||
return False
|
||||
return True
|
||||
|
||||
def execute(self, context):
|
||||
import bonsai.bim.module.light.shared as shared
|
||||
|
||||
props = tool.Blender.get_radiance_exporter_props()
|
||||
output_dir = props.output_dir
|
||||
|
||||
if not os.path.isdir(output_dir):
|
||||
self.report({"WARNING"}, f"Output directory does not exist: {output_dir}")
|
||||
return {"CANCELLED"}
|
||||
|
||||
cleanup_patterns = (
|
||||
"model.obj",
|
||||
"model.mtl",
|
||||
"model.rtm",
|
||||
"sky.rad",
|
||||
"materials.rad",
|
||||
"scene.rad",
|
||||
"ascene.oct",
|
||||
"mascene.oct",
|
||||
"ascene.amb",
|
||||
)
|
||||
cleanup_extensions = (".hdr", ".tiff", ".rad", ".dat")
|
||||
cleanup_prefixes = ("instance_", "linked_", "blender_meshes")
|
||||
|
||||
removed = 0
|
||||
for filename in os.listdir(output_dir):
|
||||
filepath = os.path.join(output_dir, filename)
|
||||
if not os.path.isfile(filepath):
|
||||
continue
|
||||
is_generated = (
|
||||
filename in cleanup_patterns
|
||||
or os.path.splitext(filename)[1].lower() in cleanup_extensions
|
||||
or any(filename.startswith(p) for p in cleanup_prefixes)
|
||||
)
|
||||
if is_generated:
|
||||
try:
|
||||
os.remove(filepath)
|
||||
removed += 1
|
||||
except OSError as e:
|
||||
print(f"Failed to remove {filepath}: {e}")
|
||||
|
||||
# Reset the global scene reference
|
||||
shared.scene = None
|
||||
|
||||
self.report({"INFO"}, f"Cleaned up {removed} generated files from {output_dir}")
|
||||
return {"FINISHED"}
|
||||
@@ -0,0 +1,80 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2020, 2021 Dion Moult <dion@thinkmoult.com>
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import bpy
|
||||
from bpy_extras.io_utils import ImportHelper
|
||||
|
||||
import bonsai.tool as tool
|
||||
|
||||
|
||||
class AddIESLight(bpy.types.Operator, ImportHelper):
|
||||
"""Upload and add an IES light fixture to the scene"""
|
||||
|
||||
bl_idname = "radiance.add_ies_light"
|
||||
bl_label = "Add IES Light"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
filename_ext = ".ies"
|
||||
filter_glob: bpy.props.StringProperty(default="*.ies;*.IES", options={"HIDDEN"})
|
||||
|
||||
def execute(self, context):
|
||||
props = tool.Blender.get_radiance_exporter_props()
|
||||
|
||||
# Create new IES light entry
|
||||
ies_light = props.ies_lights.add()
|
||||
# Store as relative path if the blend file is saved
|
||||
if bpy.data.filepath:
|
||||
ies_light.ies_file_path = bpy.path.relpath(self.filepath)
|
||||
else:
|
||||
ies_light.ies_file_path = self.filepath
|
||||
ies_light.rotation_z = 0.0
|
||||
ies_light.is_enabled = True
|
||||
|
||||
# Set as active
|
||||
props.active_ies_light_index = len(props.ies_lights) - 1
|
||||
|
||||
self.report({"INFO"}, f"Added IES light: {Path(self.filepath).name}")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class RemoveIESLight(bpy.types.Operator):
|
||||
"""Remove an IES light fixture mapping"""
|
||||
|
||||
bl_idname = "radiance.remove_ies_light"
|
||||
bl_label = "Remove IES Light"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
index: bpy.props.IntProperty()
|
||||
|
||||
def execute(self, context):
|
||||
props = tool.Blender.get_radiance_exporter_props()
|
||||
|
||||
if 0 <= self.index < len(props.ies_lights):
|
||||
props.ies_lights.remove(self.index)
|
||||
|
||||
# Adjust active index if needed
|
||||
if props.active_ies_light_index >= len(props.ies_lights):
|
||||
props.active_ies_light_index = len(props.ies_lights) - 1
|
||||
|
||||
self.report({"INFO"}, "IES light removed")
|
||||
return {"FINISHED"}
|
||||
|
||||
self.report({"WARNING"}, "Invalid IES light index")
|
||||
return {"CANCELLED"}
|
||||
@@ -18,21 +18,18 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import bpy
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bonsai.bim.module.light.prop import (
|
||||
IESLight,
|
||||
RadianceExporterProperties,
|
||||
RadianceMaterial,
|
||||
)
|
||||
|
||||
with open(os.path.join(os.path.dirname(__file__), "spectraldb.json"), "r") as f:
|
||||
spectraldb = json.load(f)
|
||||
|
||||
|
||||
class MATERIAL_UL_radiance_materials(bpy.types.UIList):
|
||||
def draw_item(
|
||||
@@ -64,3 +61,41 @@ class MATERIAL_UL_radiance_materials(bpy.types.UIList):
|
||||
op.material_index = index
|
||||
else:
|
||||
row.label(text="Not Mapped (White)")
|
||||
|
||||
|
||||
class MATERIAL_UL_ies_lights(bpy.types.UIList):
|
||||
"""UIList for displaying IES light fixtures."""
|
||||
|
||||
def draw_item(
|
||||
self,
|
||||
context,
|
||||
layout: bpy.types.UILayout,
|
||||
data: RadianceExporterProperties,
|
||||
item: IESLight,
|
||||
icon,
|
||||
active_data,
|
||||
active_propname,
|
||||
index,
|
||||
) -> None:
|
||||
if self.layout_type in {"DEFAULT", "COMPACT"}:
|
||||
row = layout.row(align=True)
|
||||
|
||||
# Enable/disable checkbox (visible toggle)
|
||||
row.prop(item, "is_enabled", text="", emboss=True)
|
||||
|
||||
# IES file name
|
||||
if item.ies_file_path:
|
||||
filename = Path(item.ies_file_path).name
|
||||
row.label(text=filename, icon="FILE")
|
||||
else:
|
||||
row.label(text="(No file selected)", icon="ERROR")
|
||||
|
||||
# Target: collection or single object
|
||||
if item.use_collection:
|
||||
row.prop(item, "target_collection", text="", icon="OUTLINER_COLLECTION", emboss=False)
|
||||
else:
|
||||
row.prop(item, "target_object", text="", emboss=False)
|
||||
|
||||
# Remove button (X icon - negative action)
|
||||
op = row.operator("radiance.remove_ies_light", text="", icon="X")
|
||||
op.index = index
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2020, 2021 Dion Moult <dion@thinkmoult.com>
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import json
|
||||
import webbrowser
|
||||
|
||||
import bpy
|
||||
from bpy_extras.io_utils import ExportHelper, ImportHelper
|
||||
|
||||
import bonsai.tool as tool
|
||||
|
||||
|
||||
class RefreshIFCMaterials(bpy.types.Operator):
|
||||
bl_idname = "bim.refresh_ifc_materials"
|
||||
bl_label = "Refresh IFC Materials"
|
||||
bl_description = "Refresh the list of IFC materials for mapping"
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
if not tool.Ifc.get():
|
||||
cls.poll_message_set("No IFC file loaded in Bonsai.")
|
||||
return False
|
||||
return True
|
||||
|
||||
def execute(self, context):
|
||||
props = tool.Blender.get_radiance_exporter_props()
|
||||
ifc_file = tool.Ifc.get()
|
||||
|
||||
props.materials.clear()
|
||||
|
||||
for style in ifc_file.by_type("IfcSurfaceStyle"):
|
||||
for render_item in style.Styles:
|
||||
if render_item.is_a("IfcSurfaceStyleRendering"):
|
||||
style_id = f"IfcSurfaceStyleRendering-{render_item.id()}"
|
||||
style_name = style.Name or f"Unnamed Style {render_item.id()}"
|
||||
|
||||
# Extract color
|
||||
color = (1.0, 1.0, 1.0) # Default white
|
||||
if render_item.SurfaceColour:
|
||||
color = (
|
||||
render_item.SurfaceColour.Red,
|
||||
render_item.SurfaceColour.Green,
|
||||
render_item.SurfaceColour.Blue,
|
||||
)
|
||||
|
||||
material = props.add_material_mapping(style_id, style_name)
|
||||
material.color = color
|
||||
|
||||
material.category = ""
|
||||
material.subcategory = ""
|
||||
material.is_mapped = False
|
||||
|
||||
props.active_material_index = 0 if props.materials else -1
|
||||
|
||||
self.report({"INFO"}, f"Refreshed {len(props.materials)} IFC materials")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class UnmapMaterial(bpy.types.Operator):
|
||||
bl_idname = "bim.unmap_material"
|
||||
bl_label = "Unmap Material"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
material_index: bpy.props.IntProperty()
|
||||
|
||||
def execute(self, context):
|
||||
props = tool.Blender.get_radiance_exporter_props()
|
||||
material = props.materials[self.material_index]
|
||||
props.unmap_material(material.name)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class RADIANCE_OT_export_material_mappings(bpy.types.Operator, ExportHelper):
|
||||
bl_idname = "radiance.export_material_mappings"
|
||||
bl_label = "Export Material Mappings"
|
||||
bl_description = "Export material mappings to a JSON file"
|
||||
|
||||
filename_ext = ".json"
|
||||
filter_glob: bpy.props.StringProperty(default="*.json", options={"HIDDEN"})
|
||||
|
||||
def execute(self, context):
|
||||
props = tool.Blender.get_radiance_exporter_props()
|
||||
mappings = {}
|
||||
|
||||
for material in props.materials:
|
||||
if material.is_mapped:
|
||||
mappings[material.style_id] = {
|
||||
"name": material.name,
|
||||
"category": material.category,
|
||||
"subcategory": material.subcategory,
|
||||
}
|
||||
|
||||
with open(self.filepath, "w") as f:
|
||||
json.dump(mappings, f, indent=4)
|
||||
|
||||
self.report({"INFO"}, f"Material mappings exported to {self.filepath}")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class RADIANCE_OT_import_material_mappings(bpy.types.Operator, ImportHelper):
|
||||
bl_idname = "radiance.import_material_mappings"
|
||||
bl_label = "Import Material Mappings"
|
||||
bl_description = "Import material mappings from a JSON file"
|
||||
|
||||
filename_ext = ".json"
|
||||
|
||||
def execute(self, context):
|
||||
props = tool.Blender.get_radiance_exporter_props()
|
||||
props.import_mappings(self.filepath)
|
||||
self.report({"INFO"}, f"Material mappings imported from {self.filepath}")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class RADIANCE_OT_open_spectraldb(bpy.types.Operator):
|
||||
bl_idname = "radiance.open_spectraldb"
|
||||
bl_label = "Open SpectralDB"
|
||||
bl_description = "Open the SpectralDB website for reference"
|
||||
|
||||
def execute(self, context):
|
||||
webbrowser.open("https://spectraldb.com")
|
||||
return {"FINISHED"}
|
||||
@@ -16,716 +16,44 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import json
|
||||
import math
|
||||
import multiprocessing
|
||||
import os
|
||||
import time
|
||||
import webbrowser
|
||||
from datetime import datetime
|
||||
from math import radians
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Union
|
||||
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
import ifcopenshell.geom
|
||||
import ifcopenshell.ifcopenshell_wrapper as W
|
||||
import ifcopenshell.util.geolocation
|
||||
import pyradiance as pr
|
||||
import requests
|
||||
from bpy_extras.io_utils import ExportHelper, ImportHelper
|
||||
from mathutils import Vector
|
||||
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.module.light.data import SolarData
|
||||
|
||||
ifc_materials = []
|
||||
|
||||
with open(os.path.join(os.path.dirname(__file__), "spectraldb.json"), "r") as f:
|
||||
spectraldb = json.load(f)
|
||||
|
||||
|
||||
class ExportOBJ(bpy.types.Operator):
|
||||
"""Exports the IFC File to OBJ"""
|
||||
|
||||
bl_idname = "export_scene.radiance"
|
||||
bl_label = "Export"
|
||||
bl_description = "Export the IFC to OBJ"
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
props = tool.Blender.get_radiance_exporter_props()
|
||||
if not props.should_load_from_memory and not props.ifc_file:
|
||||
cls.poll_message_set("Select an IFC file or use 'load from memory' if it's loaded in Bonsai.")
|
||||
return False
|
||||
return True
|
||||
|
||||
def execute(self, context):
|
||||
# Get the output directory
|
||||
props = tool.Blender.get_radiance_exporter_props()
|
||||
should_load_from_memory = props.should_load_from_memory
|
||||
output_dir = props.output_dir
|
||||
|
||||
props.is_exporting = True
|
||||
|
||||
# Conversion from IFC to OBJ
|
||||
# Settings for obj
|
||||
settings = ifcopenshell.geom.settings()
|
||||
serializer_settings = ifcopenshell.geom.serializer_settings()
|
||||
|
||||
settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.SURFACES_AND_SOLIDS)
|
||||
settings.set("apply-default-materials", True)
|
||||
serializer_settings.set("use-element-guids", True)
|
||||
settings.set("use-world-coords", True)
|
||||
|
||||
ifc_file: ifcopenshell.file
|
||||
if should_load_from_memory:
|
||||
ifc_file = tool.Ifc.get()
|
||||
|
||||
else:
|
||||
ifc_file_path = props.ifc_file
|
||||
ifc_file = ifcopenshell.open(ifc_file_path)
|
||||
|
||||
obj_file_path = os.path.join(output_dir, "model.obj")
|
||||
mtl_file_path = os.path.join(output_dir, "model.mtl")
|
||||
|
||||
serialiser = ifcopenshell.geom.serializers.obj(obj_file_path, mtl_file_path, settings, serializer_settings)
|
||||
serialiser.setFile(ifc_file)
|
||||
serialiser.setUnitNameAndMagnitude("METER", 1.0)
|
||||
serialiser.writeHeader()
|
||||
|
||||
if ifc_file.schema in ("IFC2X3", "IFC4"):
|
||||
elements = ifc_file.by_type("IfcElement") + ifc_file.by_type("IfcProxy")
|
||||
else:
|
||||
elements = ifc_file.by_type("IfcElement")
|
||||
|
||||
elements += ifc_file.by_type("IfcSite")
|
||||
elements = [e for e in elements if not e.is_a("IfcFeatureElement") or e.is_a("IfcSurfaceFeature")]
|
||||
|
||||
iterator = ifcopenshell.geom.iterator(settings, ifc_file, multiprocessing.cpu_count(), include=elements)
|
||||
if iterator.initialize():
|
||||
while True:
|
||||
shape = iterator.get()
|
||||
assert isinstance(shape, W.TriangulationElement)
|
||||
materials = shape.geometry.materials
|
||||
|
||||
for material in materials:
|
||||
ifc_materials.append(material.name)
|
||||
|
||||
serialiser.write(shape)
|
||||
if not iterator.next():
|
||||
break
|
||||
|
||||
serialiser.finalize()
|
||||
props.is_exporting = False
|
||||
|
||||
self.report({"INFO"}, "Exported OBJ file to: {}".format(obj_file_path))
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class RadianceRender(bpy.types.Operator):
|
||||
"""Radiance Rendering"""
|
||||
|
||||
bl_idname = "render_scene.radiance"
|
||||
bl_label = "Render"
|
||||
bl_description = "Renders the scene using Radiance"
|
||||
|
||||
def execute(self, context):
|
||||
print("Starting Radiance rendering process...")
|
||||
props = tool.Blender.get_radiance_exporter_props()
|
||||
resolution_x, resolution_y = props.radiance_resolution_x, props.radiance_resolution_y
|
||||
|
||||
assert context.scene
|
||||
context.scene.render.resolution_x = resolution_x
|
||||
context.scene.render.resolution_y = resolution_y
|
||||
|
||||
aspect_ratio = resolution_x / resolution_y
|
||||
|
||||
quality = props.radiance_quality.upper()
|
||||
detail = props.radiance_detail.upper()
|
||||
variability = props.radiance_variability.upper()
|
||||
output_dir = props.output_dir
|
||||
output_file_name = props.output_file_name
|
||||
output_file_format = props.output_file_format
|
||||
use_hdr = props.use_hdr
|
||||
choose_hdr_image = props.choose_hdr_image
|
||||
|
||||
print(f"Resolution: {resolution_x}x{resolution_y}")
|
||||
print(f"Quality: {quality}, Detail: {detail}, Variability: {variability}")
|
||||
print(f"Output directory: {output_dir}")
|
||||
|
||||
if use_hdr:
|
||||
hdr_image = "noon_grass_2k.hdr"
|
||||
hdr_mask = "noon_grass_2k_mask.hdr"
|
||||
sky_map_cal = "skymap.cal"
|
||||
hdr_image_path = os.path.join(os.path.dirname(__file__), "HDRs", hdr_image)
|
||||
hdr_mask_path = os.path.join(os.path.dirname(__file__), "HDRs", hdr_mask)
|
||||
sky_map_cal_path = os.path.join(os.path.dirname(__file__), "HDRs", sky_map_cal)
|
||||
|
||||
obj_file_path = os.path.join(output_dir, "model.obj")
|
||||
|
||||
sun_props = tool.Blender.get_solar_props()
|
||||
sun_pos_props = tool.Blender.get_sun_props()
|
||||
assert sun_pos_props
|
||||
sky_file_path = os.path.join(output_dir, "sky.rad")
|
||||
# latitude = sun_props.latitude
|
||||
# longitude = sun_props.longitude
|
||||
# month = sun_props.month
|
||||
# day = sun_props.day
|
||||
# hour = sun_props.hour
|
||||
# minute = sun_props.minute
|
||||
|
||||
# print("Sun Properties:")
|
||||
# print("Latitude: ", latitude)
|
||||
# print("Longitude: ", longitude)
|
||||
# print("Timezone: ", timezone)
|
||||
# print("Month: ", month)
|
||||
# print("Day: ", day)
|
||||
# print("Hour: ", hour)
|
||||
# print("Minute: ", minute)
|
||||
|
||||
print("Setting up camera...")
|
||||
if props.use_active_camera:
|
||||
camera = context.scene.camera
|
||||
else:
|
||||
camera = props.selected_camera
|
||||
|
||||
if camera is None:
|
||||
self.report({"ERROR"}, "No active camera found in the scene. Please add a camera and set it as active.")
|
||||
return {"CANCELLED"}
|
||||
|
||||
# Get camera position and direction
|
||||
camera_position, camera_direction = self.get_camera_data(camera)
|
||||
|
||||
print(f"Camera position: {camera_position}")
|
||||
print(f"Camera direction: {camera_direction}")
|
||||
|
||||
# sun_position = tool.Blender.get_addon("sun_position")
|
||||
# azimuth, elevation = sun_position.sun_calc.get_sun_coordinates(
|
||||
# sun_pos_props.time,
|
||||
# sun_pos_props.latitude,
|
||||
# sun_pos_props.longitude,
|
||||
# -sun_pos_props.UTC_zone,
|
||||
# sun_pos_props.month,
|
||||
# sun_pos_props.day,
|
||||
# sun_pos_props.year,
|
||||
# )
|
||||
|
||||
dt = datetime(sun_pos_props.year, sun_props.month, sun_props.day, sun_props.hour, sun_props.minute)
|
||||
|
||||
sky_description = pr.gensky(
|
||||
dt=dt,
|
||||
# azimuth=64.1,
|
||||
# altitude=-26.6,
|
||||
latitude=sun_props.latitude,
|
||||
longitude=sun_props.longitude,
|
||||
year=sun_pos_props.year,
|
||||
timezone=-int(sun_props.UTC_zone),
|
||||
# sunny_with_sun=False,
|
||||
# sunny_without_sun=False,
|
||||
# cloudy=False,
|
||||
# ground_reflectance=0.2,
|
||||
# turbidity=3.0,
|
||||
)
|
||||
|
||||
sky_description_str = sky_description.decode("utf-8")
|
||||
|
||||
# Write all this to file
|
||||
# skyfunc glow sky_glow
|
||||
# 0
|
||||
# 0
|
||||
# 4 .9 .9 1.15 0
|
||||
|
||||
# sky_glow source sky
|
||||
# 0
|
||||
# 0
|
||||
# 4 0 0 1 180
|
||||
|
||||
# skyfunc glow ground_glow
|
||||
# 0
|
||||
# 0
|
||||
# 4 1.4 .9 .6 0
|
||||
|
||||
# ground_glow source ground
|
||||
# 0
|
||||
# 0
|
||||
# 4 0 0 -1 180
|
||||
|
||||
if use_hdr and choose_hdr_image == "Noon":
|
||||
|
||||
with open(sky_file_path, "w") as f:
|
||||
f.write(sky_description_str)
|
||||
f.write("\n")
|
||||
# f.write("skyfunc glow sky_glow\n0\n0\n4 .9 .9 1.15 0\n")
|
||||
# f.write("sky_glow source sky\n0\n0\n4 0 0 1 180\n")
|
||||
# f.write("skyfunc glow ground_glow\n0\n0\n4 1.4 .9 .6 0\n")
|
||||
# f.write("ground_glow source ground\n0\n0\n4 0 0 -1 180\n")
|
||||
|
||||
f.write(
|
||||
'''void colorpict env_map
|
||||
7 red green blue "'''
|
||||
+ hdr_image_path
|
||||
+ '''" "'''
|
||||
+ sky_map_cal_path
|
||||
+ '''" map_u map_v
|
||||
0
|
||||
1 0.5
|
||||
|
||||
# This is a multiplier to colour balance the env map
|
||||
# In this case, it provides a rough ground luminance from 3k-5k
|
||||
env_map colorfunc env_colour
|
||||
4 100 100 100 .
|
||||
0
|
||||
0
|
||||
|
||||
# .37 .57 1.5 is measured from a HDRI image
|
||||
# It is multiplied by a factor such that grey(r,g,b) = 1
|
||||
skyfunc colorfunc sky_colour
|
||||
4 .64 .99 2.6 .
|
||||
0
|
||||
0
|
||||
|
||||
void mixpict composite
|
||||
7 env_colour sky_colour grey "'''
|
||||
+ hdr_mask_path
|
||||
+ '''" "'''
|
||||
+ sky_map_cal_path
|
||||
+ """" map_u map_v
|
||||
0
|
||||
2 0.5 1
|
||||
|
||||
composite glow env_map_glow
|
||||
0
|
||||
0
|
||||
4 1 1 1 0
|
||||
|
||||
env_map_glow source sky
|
||||
0
|
||||
0
|
||||
4 0 0 1 180
|
||||
|
||||
env_colour glow ground_glow
|
||||
0
|
||||
0
|
||||
4 1 1 1 0
|
||||
|
||||
ground_glow source ground
|
||||
0
|
||||
0
|
||||
4 0 0 -1 180"""
|
||||
)
|
||||
|
||||
elif not use_hdr:
|
||||
with open(sky_file_path, "w") as f:
|
||||
f.write(sky_description_str)
|
||||
f.write("\n")
|
||||
# f.write("skyfunc glow sky_glow\n0\n0\n4 .9 .9 1.15 0\n")
|
||||
# f.write("sky_glow source sky\n0\n0\n4 0 0 1 180\n")
|
||||
# f.write("skyfunc glow ground_glow\n0\n0\n4 1.4 .9 .6 0\n")
|
||||
# f.write("ground_glow source ground\n0\n0\n4 0 0 -1 180\n")
|
||||
|
||||
props = tool.Blender.get_radiance_exporter_props()
|
||||
|
||||
data = props.get_mappings_dict()
|
||||
|
||||
materials_file = os.path.join(output_dir, "materials.rad")
|
||||
written_materials = set()
|
||||
|
||||
all_materials = set(ifc_materials)
|
||||
|
||||
with open(materials_file, "w") as file:
|
||||
# Write default materials
|
||||
default_materials = [
|
||||
"void plastic white\n0\n0\n5 0.8 0.8 0.8 0 0\n",
|
||||
# "void plastic blue_plastic\n0\n0\n5 0.1 0.2 0.8 0.05 0.1\n",
|
||||
# "void plastic red_plastic\n0\n0\n5 0.8 0.1 0.2 0.05 0.1\n",
|
||||
# "void metal silver_metal\n0\n0\n5 0.8 0.8 0.8 0.9 0.1\n",
|
||||
# "void glass clear_glass\n0\n0\n3 0.96 0.96 0.96\n",
|
||||
# "void light white_light\n0\n0\n3 1.0 1.0 1.0\n",
|
||||
# "void trans olive_trans\n0\n0\n7 0.6 0.7 0.4 0.05 0.05 0.7 0.2\n",
|
||||
]
|
||||
for material in default_materials:
|
||||
file.write(material)
|
||||
written_materials.add(material.split()[2]) # Add material name to written set
|
||||
|
||||
for style_id in all_materials:
|
||||
material = next((m for m in props.materials if m.style_id == style_id), None)
|
||||
if material and material.is_mapped:
|
||||
category, subcategory = material.category, material.subcategory
|
||||
if category in spectraldb and subcategory in spectraldb[category]:
|
||||
material_def = spectraldb[category][subcategory]
|
||||
material_name = material_def.split()[2]
|
||||
if material_name not in written_materials:
|
||||
file.write(material_def + "\n")
|
||||
written_materials.add(material_name)
|
||||
file.write(f"inherit alias {style_id} {material_name}\n")
|
||||
else:
|
||||
file.write(f"inherit alias {style_id} white\n")
|
||||
else:
|
||||
# If the material is not mapped, alias it to white
|
||||
file.write(f"inherit alias {style_id} white\n")
|
||||
|
||||
self.report({"INFO"}, f"Exported Materials Rad file to: {materials_file}")
|
||||
|
||||
# Run obj2mesh
|
||||
rtm_file_path = os.path.join(output_dir, "model.rtm")
|
||||
mesh_file_path = save_obj2mesh_output(obj_file_path, rtm_file_path, matfiles=[materials_file])
|
||||
# subprocess.run(["obj2mesh", "-a", materials_file, obj_file_path, rtm_file_path])
|
||||
self.report({"INFO"}, "obj2mesh output: {}".format(mesh_file_path))
|
||||
scene_file = os.path.join(output_dir, "scene.rad")
|
||||
with open(scene_file, "w") as file:
|
||||
file.write('void mesh model\n1 "' + rtm_file_path + '"\n0\n0\n')
|
||||
|
||||
self.report({"INFO"}, "Exported Scene file to: {}".format(scene_file))
|
||||
|
||||
print("Setting up Radiance scene...")
|
||||
scene = pr.Scene("ascene")
|
||||
|
||||
material_path = os.path.join(output_dir, "materials.rad")
|
||||
scene_path = os.path.join(output_dir, "scene.rad")
|
||||
|
||||
scene.add_material(material_path)
|
||||
scene.add_surface(scene_path)
|
||||
scene.add_source(sky_file_path)
|
||||
print("Setting up view...")
|
||||
assert isinstance(camera.data, bpy.types.Camera)
|
||||
if camera.data.type == "PERSP":
|
||||
# Perspective camera
|
||||
camera_fov = camera.data.angle
|
||||
# Calculate vertical FOV based on the desired aspect ratio
|
||||
vertical_fov = 2 * math.atan(math.tan(camera_fov / 2) / aspect_ratio)
|
||||
|
||||
aview = pr.View(
|
||||
vtype="v", # Perspective view
|
||||
position=camera_position,
|
||||
direction=camera_direction,
|
||||
vup=(0, 0, 1), # Assuming Z is up
|
||||
horiz=math.degrees(camera_fov),
|
||||
vert=math.degrees(vertical_fov),
|
||||
)
|
||||
else: # 'ORTHO'
|
||||
# Orthographic camera
|
||||
# Calculate the view size based on the camera's orthographic scale
|
||||
ortho_scale = camera.data.ortho_scale
|
||||
view_width = ortho_scale
|
||||
view_height = ortho_scale / aspect_ratio
|
||||
|
||||
aview = pr.View(
|
||||
vtype="l", # Parallel projection (orthographic)
|
||||
position=camera_position,
|
||||
direction=camera_direction,
|
||||
vup=(0, 0, 1), # Assuming Z is up
|
||||
horiz=view_width,
|
||||
vert=view_height,
|
||||
)
|
||||
scene.add_view(aview)
|
||||
print("Starting render...")
|
||||
start_time = time.time()
|
||||
image = pr.render(
|
||||
scene,
|
||||
ambbounce=1,
|
||||
resolution=(resolution_x, resolution_y),
|
||||
quality=quality,
|
||||
detail=detail,
|
||||
variability=variability,
|
||||
nproc=multiprocessing.cpu_count(),
|
||||
)
|
||||
end_time = time.time()
|
||||
print(f"Render completed in {end_time - start_time:.2f} seconds")
|
||||
|
||||
output_hdr_path = os.path.join(output_dir, f"{output_file_name}.{output_file_format.lower()}")
|
||||
print(f"Saving HDR output to: {output_hdr_path}")
|
||||
if output_file_format == "HDR":
|
||||
with open(output_hdr_path, "wb") as wtr:
|
||||
wtr.write(image)
|
||||
else:
|
||||
pass
|
||||
print("Applying tone mapping...")
|
||||
pcond_image = pr.pcond(hdr=output_hdr_path, human=True)
|
||||
|
||||
tiff_path = os.path.join(output_dir, f"{output_file_name}.tiff")
|
||||
print(f"Saving TIFF output to: {tiff_path}")
|
||||
pr.ra_tiff(inp=pcond_image, out=tiff_path, lzw=True)
|
||||
print("Radiance rendering process completed successfully.")
|
||||
self.report({"INFO"}, "Radiance rendering completed. Output: {}".format(tiff_path))
|
||||
return {"FINISHED"}
|
||||
|
||||
def get_active_camera(self, context):
|
||||
props = tool.Blender.get_radiance_exporter_props()
|
||||
if props.use_active_camera:
|
||||
return context.scene.camera
|
||||
else:
|
||||
return props.selected_camera
|
||||
|
||||
def get_camera_data(self, camera):
|
||||
# Get camera position
|
||||
position = camera.matrix_world.to_translation()
|
||||
|
||||
# Get camera direction
|
||||
direction = camera.matrix_world.to_quaternion() @ Vector((0, 0, -1))
|
||||
direction.normalize()
|
||||
|
||||
return (position.x, position.y, position.z), (direction.x, direction.y, direction.z)
|
||||
|
||||
def getResolution(self, context):
|
||||
props = tool.Blender.get_radiance_exporter_props()
|
||||
resolution_x = props.radiance_resolution_x
|
||||
resolution_y = props.radiance_resolution_y
|
||||
return resolution_x, resolution_y
|
||||
|
||||
|
||||
def save_obj2mesh_output(inp: Union[bytes, str, Path], output_file: str, **kwargs):
|
||||
output_bytes = pr.obj2mesh(inp, **kwargs)
|
||||
|
||||
with open(output_file, "wb") as f:
|
||||
f.write(output_bytes)
|
||||
return output_file
|
||||
|
||||
|
||||
class ImportTrueNorth(bpy.types.Operator):
|
||||
bl_idname = "bim.import_true_north"
|
||||
bl_label = "Import True North"
|
||||
bl_description = "Imports the True North from your IFC geometric context"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
if not tool.Ifc.get():
|
||||
return False
|
||||
if not SolarData.is_loaded:
|
||||
SolarData.load()
|
||||
return SolarData.data["true_north"] is not None
|
||||
|
||||
def execute(self, context):
|
||||
props = tool.Blender.get_solar_props()
|
||||
for context in tool.Ifc.get().by_type("IfcGeometricRepresentationContext", include_subtypes=False):
|
||||
if not context.TrueNorth:
|
||||
continue
|
||||
value = context.TrueNorth.DirectionRatios
|
||||
props.true_north = radians(ifcopenshell.util.geolocation.yaxis2angle(*value[:2]))
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class ImportLatLong(bpy.types.Operator):
|
||||
bl_idname = "bim.import_lat_long"
|
||||
bl_label = "Import Latitude / Longitude"
|
||||
bl_description = "Imports the latitude / longitude from an IfcSite"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context):
|
||||
props = tool.Blender.get_solar_props()
|
||||
site = tool.Ifc.get().by_id(int(props.sites))
|
||||
if site.RefLatitude and site.RefLongitude:
|
||||
props.latitude = ifcopenshell.util.geolocation.dms2dd(*site.RefLatitude)
|
||||
props.longitude = ifcopenshell.util.geolocation.dms2dd(*site.RefLongitude)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class MoveSunPathTo3DCursor(bpy.types.Operator):
|
||||
bl_idname = "bim.move_sun_path_to_3d_cursor"
|
||||
bl_label = "Move Sun Path To 3D Cursor"
|
||||
bl_description = "Shifts the visualisation of the Sun Path to the 3D cursor"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context):
|
||||
props = tool.Blender.get_solar_props()
|
||||
assert context.scene
|
||||
props.sun_path_origin = context.scene.cursor.location
|
||||
tool.Blender.update_viewport()
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class ViewFromSun(bpy.types.Operator):
|
||||
bl_idname = "bim.view_from_sun"
|
||||
bl_label = "View From Sun"
|
||||
bl_description = "Views your model as if you were looking from the perspective of the sun"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context):
|
||||
if not (camera := bpy.data.objects.get("SunPathCamera")):
|
||||
camera = bpy.data.objects.new("SunPathCamera", bpy.data.cameras.new("SunPathCamera"))
|
||||
assert isinstance(camera.data, bpy.types.Camera)
|
||||
assert context.scene
|
||||
camera.data.type = "ORTHO"
|
||||
camera.data.ortho_scale = 100 # The default of 6m is too small
|
||||
context.scene.collection.objects.link(camera)
|
||||
tool.Blender.activate_camera(camera)
|
||||
props = tool.Blender.get_solar_props()
|
||||
props.hour = props.hour # Just to refresh camera position
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class LightPickCoordinates(bpy.types.Operator):
|
||||
bl_idname = "bim.light_pick_coordinates"
|
||||
bl_label = "Pick Coordinates"
|
||||
bl_description = (
|
||||
"Open web browser with Google Maps to pick coordinates (Right Mouse Click in maps to copy selected location).\n\n"
|
||||
"ALT+Click to insert current location based on the current IP-address (using ip-api.com)."
|
||||
)
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
use_current_location: bpy.props.BoolProperty(options={"SKIP_SAVE"}) # pyright: ignore[reportRedeclaration]
|
||||
|
||||
if TYPE_CHECKING:
|
||||
use_current_location: bool
|
||||
|
||||
def invoke(self, context, event):
|
||||
if event.alt:
|
||||
self.use_current_location = True
|
||||
return self.execute(context)
|
||||
|
||||
def execute(self, context):
|
||||
props = tool.Blender.get_solar_props()
|
||||
if not self.use_current_location:
|
||||
zoom = 13.5
|
||||
url = f"https://www.google.com/maps/@{props.latitude},{props.longitude},{zoom}z"
|
||||
webbrowser.open(url)
|
||||
return {"FINISHED"}
|
||||
|
||||
response = requests.get("http://ip-api.com/json/")
|
||||
data = response.json()
|
||||
props.latitude = data["lat"]
|
||||
props.longitude = data["lon"]
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class LightSetTimeToNow(bpy.types.Operator):
|
||||
bl_idname = "bim.light_set_time_to_now"
|
||||
bl_label = "Now"
|
||||
bl_description = "Set time to current local time."
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context):
|
||||
props = tool.Blender.get_solar_props()
|
||||
props.set_from_datetime(datetime.now())
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class RefreshIFCMaterials(bpy.types.Operator):
|
||||
bl_idname = "bim.refresh_ifc_materials"
|
||||
bl_label = "Refresh IFC Materials"
|
||||
bl_description = "Refresh the list of IFC materials for mapping"
|
||||
|
||||
def execute(self, context):
|
||||
props = tool.Blender.get_radiance_exporter_props()
|
||||
ifc_file: ifcopenshell.file
|
||||
ifc_file = tool.Ifc.get() if props.should_load_from_memory else ifcopenshell.open(props.ifc_file)
|
||||
|
||||
props.materials.clear()
|
||||
|
||||
for style in ifc_file.by_type("IfcSurfaceStyle"):
|
||||
for render_item in style.Styles:
|
||||
if render_item.is_a("IfcSurfaceStyleRendering"):
|
||||
style_id = f"IfcSurfaceStyleRendering-{render_item.id()}"
|
||||
style_name = style.Name or f"Unnamed Style {render_item.id()}"
|
||||
|
||||
# Extract color and transparency
|
||||
color = (1.0, 1.0, 1.0) # Default white
|
||||
transparency = 0.0 # Default opaque
|
||||
if render_item.SurfaceColour:
|
||||
color = (
|
||||
render_item.SurfaceColour.Red,
|
||||
render_item.SurfaceColour.Green,
|
||||
render_item.SurfaceColour.Blue,
|
||||
)
|
||||
if hasattr(render_item, "Transparency") and render_item.Transparency is not None:
|
||||
transparency = render_item.Transparency
|
||||
|
||||
# Add material with color
|
||||
material = props.add_material_mapping(style_id, style_name)
|
||||
material.color = color
|
||||
|
||||
# If transparency is high, consider it as glass
|
||||
if transparency > 0.5:
|
||||
material.category = "Glass"
|
||||
material.subcategory = "Clear Glass"
|
||||
material.is_mapped = True
|
||||
|
||||
props.active_material_index = 0 if props.materials else -1
|
||||
|
||||
self.report({"INFO"}, f"Refreshed {len(props.materials)} IFC materials")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class UnmapMaterial(bpy.types.Operator):
|
||||
bl_idname = "bim.unmap_material"
|
||||
bl_label = "Unmap Material"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
material_index: bpy.props.IntProperty()
|
||||
|
||||
def execute(self, context):
|
||||
props = tool.Blender.get_radiance_exporter_props()
|
||||
material = props.materials[self.material_index]
|
||||
props.unmap_material(material.name)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class RADIANCE_OT_select_camera(bpy.types.Operator):
|
||||
bl_idname = "radiance.select_camera"
|
||||
bl_label = "Select Camera"
|
||||
bl_description = "Select a camera from the viewport"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return context.object is not None and context.object.type == "CAMERA"
|
||||
|
||||
def execute(self, context):
|
||||
props = tool.Blender.get_radiance_exporter_props()
|
||||
props.selected_camera = context.object
|
||||
props.use_active_camera = False
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class RADIANCE_OT_export_material_mappings(bpy.types.Operator, ExportHelper):
|
||||
bl_idname = "radiance.export_material_mappings"
|
||||
bl_label = "Export Material Mappings"
|
||||
bl_description = "Export material mappings to a JSON file"
|
||||
|
||||
filename_ext = ".json"
|
||||
filter_glob: bpy.props.StringProperty(default="*.json", options={"HIDDEN"})
|
||||
|
||||
def execute(self, context):
|
||||
props = tool.Blender.get_radiance_exporter_props()
|
||||
mappings = {}
|
||||
|
||||
for material in props.materials:
|
||||
if material.is_mapped:
|
||||
mappings[material.style_id] = {
|
||||
"name": material.name,
|
||||
"category": material.category,
|
||||
"subcategory": material.subcategory,
|
||||
}
|
||||
|
||||
with open(self.filepath, "w") as f:
|
||||
json.dump(mappings, f, indent=4)
|
||||
|
||||
self.report({"INFO"}, f"Material mappings exported to {self.filepath}")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class RADIANCE_OT_import_material_mappings(bpy.types.Operator, ImportHelper):
|
||||
bl_idname = "radiance.import_material_mappings"
|
||||
bl_label = "Import Material Mappings"
|
||||
bl_description = "Import material mappings from a JSON file"
|
||||
|
||||
filename_ext = ".json"
|
||||
|
||||
def execute(self, context):
|
||||
props = tool.Blender.get_radiance_exporter_props()
|
||||
props.import_mappings(self.filepath)
|
||||
self.report({"INFO"}, f"Material mappings imported from {self.filepath}")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class RADIANCE_OT_open_spectraldb(bpy.types.Operator):
|
||||
bl_idname = "radiance.open_spectraldb"
|
||||
bl_label = "Open SpectralDB"
|
||||
bl_description = "Open the SpectralDB website for reference"
|
||||
|
||||
def execute(self, context):
|
||||
webbrowser.open("https://spectraldb.com")
|
||||
return {"FINISHED"}
|
||||
"""Thin re-export module for backward compatibility.
|
||||
|
||||
All operator classes are defined in their respective submodules:
|
||||
- export.py: ExportOBJ, CleanupRadianceFiles
|
||||
- prepare.py: PrepareRadianceScene
|
||||
- render.py: RadianceRender, FalseColorRadiance, RADIANCE_OT_select_camera
|
||||
- solar.py: ImportTrueNorth, ImportLatLong, MoveSunPathTo3DCursor,
|
||||
ViewFromSun, LightPickCoordinates, LightSetTimeToNow
|
||||
- material.py: RefreshIFCMaterials, UnmapMaterial,
|
||||
RADIANCE_OT_export_material_mappings,
|
||||
RADIANCE_OT_import_material_mappings,
|
||||
RADIANCE_OT_open_spectraldb
|
||||
- ies.py: AddIESLight, RemoveIESLight
|
||||
|
||||
EnumPropertySearch and SetEnumProperty are provided by the global
|
||||
bonsai.bim.operator module (bl_idname "bim.enum_property_search").
|
||||
"""
|
||||
|
||||
from bonsai.bim.module.light.export import CleanupRadianceFiles, ExportOBJ # noqa: F401
|
||||
from bonsai.bim.module.light.ies import AddIESLight, RemoveIESLight # noqa: F401
|
||||
from bonsai.bim.module.light.material import ( # noqa: F401
|
||||
RADIANCE_OT_export_material_mappings,
|
||||
RADIANCE_OT_import_material_mappings,
|
||||
RADIANCE_OT_open_spectraldb,
|
||||
RefreshIFCMaterials,
|
||||
UnmapMaterial,
|
||||
)
|
||||
from bonsai.bim.module.light.prepare import PrepareRadianceScene # noqa: F401
|
||||
from bonsai.bim.module.light.render import ( # noqa: F401
|
||||
FalseColorRadiance,
|
||||
RADIANCE_OT_select_camera,
|
||||
RadianceRender,
|
||||
)
|
||||
from bonsai.bim.module.light.solar import ( # noqa: F401
|
||||
ImportLatLong,
|
||||
ImportTrueNorth,
|
||||
LightPickCoordinates,
|
||||
LightSetTimeToNow,
|
||||
MoveSunPathTo3DCursor,
|
||||
ViewFromSun,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,741 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2020, 2021 Dion Moult <dion@thinkmoult.com>
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import math
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Union
|
||||
|
||||
import bpy
|
||||
import pyradiance as pr
|
||||
from mathutils import Vector
|
||||
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.module.light.prop import spectraldb
|
||||
from bonsai.bim.module.light.shared import ifc_materials, linked_model_exports
|
||||
|
||||
|
||||
def _matrix_to_xform_args(matrix: list[list[float]]) -> str:
|
||||
"""Decompose a 4x4 matrix into Radiance xform arguments.
|
||||
|
||||
Decomposes into Z/Y/X Euler rotations + translation.
|
||||
The matrix is expected in row-major format (Blender convention).
|
||||
xform applies transforms right-to-left, so we write: -rx X -ry Y -rz Z -t tx ty tz
|
||||
"""
|
||||
from mathutils import Matrix as MMatrix
|
||||
|
||||
m = MMatrix(matrix)
|
||||
translation = m.to_translation()
|
||||
euler = m.to_euler("XYZ")
|
||||
|
||||
parts = []
|
||||
rx = math.degrees(euler.x)
|
||||
ry = math.degrees(euler.y)
|
||||
rz = math.degrees(euler.z)
|
||||
|
||||
if abs(rx) > 1e-6:
|
||||
parts.append(f"-rx {rx:.6f}")
|
||||
if abs(ry) > 1e-6:
|
||||
parts.append(f"-ry {ry:.6f}")
|
||||
if abs(rz) > 1e-6:
|
||||
parts.append(f"-rz {rz:.6f}")
|
||||
|
||||
parts.append(f"-t {translation.x:.6f} {translation.y:.6f} {translation.z:.6f}")
|
||||
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def save_obj2mesh_output(inp: Union[bytes, str, Path], output_file: str, **kwargs):
|
||||
try:
|
||||
output_bytes = pr.obj2mesh(inp, **kwargs)
|
||||
with open(output_file, "wb") as f:
|
||||
f.write(output_bytes)
|
||||
return output_file
|
||||
except Exception as e:
|
||||
print(f"ERROR in obj2mesh conversion:")
|
||||
print(f" Input file: {inp}")
|
||||
print(f" Output file: {output_file}")
|
||||
print(f" Additional args: {kwargs}")
|
||||
print(f" Error: {str(e)}")
|
||||
raise
|
||||
|
||||
|
||||
def convert_ies_to_radiance(
|
||||
ies_file_path: str,
|
||||
output_dir: str,
|
||||
lamp_type: str = "",
|
||||
lamp_color: tuple[float, float, float] = (1.0, 1.0, 1.0),
|
||||
multiply_factor: float = 1.0,
|
||||
radius: float = 0.0,
|
||||
) -> tuple[str, str]:
|
||||
"""Convert IES file to Radiance format using pyradiance.
|
||||
|
||||
Args:
|
||||
ies_file_path: Path to the .ies file
|
||||
output_dir: Directory where .rad and .dat files will be saved
|
||||
lamp_type: Type of lamp (e.g., 'LED', 'metal halide')
|
||||
lamp_color: RGB color tuple (0.0-1.0 each)
|
||||
multiply_factor: Brightness multiplier (0.1-10.0)
|
||||
radius: Illum sphere radius (0 = use IES geometry)
|
||||
|
||||
Returns:
|
||||
Tuple of (rad_file_path, dat_file_path)
|
||||
"""
|
||||
try:
|
||||
ies_path = Path(bpy.path.abspath(ies_file_path))
|
||||
base_name = ies_path.stem
|
||||
|
||||
output_path = os.path.join(output_dir, base_name)
|
||||
|
||||
kwargs = {"outname": output_path}
|
||||
|
||||
if lamp_type:
|
||||
kwargs["lamp_type"] = lamp_type
|
||||
|
||||
if lamp_color != (1.0, 1.0, 1.0):
|
||||
kwargs["lamp_color"] = lamp_color
|
||||
|
||||
if multiply_factor != 1.0:
|
||||
kwargs["multiply_factor"] = multiply_factor
|
||||
|
||||
if radius > 0.0:
|
||||
kwargs["radius"] = radius
|
||||
|
||||
pr.ies2rad(ies_path, **kwargs)
|
||||
|
||||
rad_file = os.path.join(output_dir, f"{base_name}.rad")
|
||||
dat_file = os.path.join(output_dir, f"{base_name}.dat")
|
||||
|
||||
return rad_file, dat_file
|
||||
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to convert IES file '{ies_file_path}': {str(e)}")
|
||||
|
||||
|
||||
class PrepareRadianceScene(bpy.types.Operator):
|
||||
"""Prepares the Radiance scene (runs heavy work in background thread)"""
|
||||
|
||||
bl_idname = "scene.prepare_radiance"
|
||||
bl_label = "Prepare Radiance Scene"
|
||||
bl_description = "Prepares the Radiance scene by creating necessary files and setting up the view"
|
||||
|
||||
_timer = None
|
||||
_thread: Union[threading.Thread, None] = None
|
||||
_error: Union[str, None] = None
|
||||
_start_time: float = 0.0
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
props = tool.Blender.get_radiance_exporter_props()
|
||||
if not props.output_dir:
|
||||
cls.poll_message_set("Output directory is not set.")
|
||||
return False
|
||||
if props.is_preparing:
|
||||
cls.poll_message_set("Scene preparation is already in progress.")
|
||||
return False
|
||||
return True
|
||||
|
||||
def get_camera_data(self, camera):
|
||||
position = camera.matrix_world.to_translation()
|
||||
|
||||
direction = camera.matrix_world.to_quaternion() @ Vector((0, 0, -1))
|
||||
direction.normalize()
|
||||
|
||||
up = camera.matrix_world.to_quaternion() @ Vector((0, 1, 0))
|
||||
up.normalize()
|
||||
|
||||
if abs(direction.dot(up)) > 0.99:
|
||||
if abs(direction.dot(Vector((0, 1, 0)))) > 0.99:
|
||||
reference = Vector((1, 0, 0))
|
||||
else:
|
||||
reference = Vector((0, 1, 0))
|
||||
right = direction.cross(reference)
|
||||
if right.length < 0.01:
|
||||
reference = Vector((0, 0, 1))
|
||||
right = direction.cross(reference)
|
||||
right.normalize()
|
||||
up = right.cross(direction)
|
||||
up.normalize()
|
||||
|
||||
if up.length < 0.01:
|
||||
up = Vector((0, 0, 1))
|
||||
else:
|
||||
up.normalize()
|
||||
|
||||
return (
|
||||
(position.x, position.y, position.z),
|
||||
(direction.x, direction.y, direction.z),
|
||||
(up.x, up.y, up.z),
|
||||
)
|
||||
|
||||
def execute(self, context):
|
||||
print("Starting Radiance scene preparation...")
|
||||
props = tool.Blender.get_radiance_exporter_props()
|
||||
output_dir = props.output_dir
|
||||
|
||||
obj_file_path = os.path.join(output_dir, "model.obj")
|
||||
if not os.path.exists(obj_file_path):
|
||||
error_msg = "OBJ file not found. Please run 'Export Geometry for Simulation' first."
|
||||
self.report({"ERROR"}, error_msg)
|
||||
print(f"ERROR: {error_msg}")
|
||||
print(f" Expected file: {obj_file_path}")
|
||||
return {"CANCELLED"}
|
||||
|
||||
resolution_x, resolution_y = props.radiance_resolution_x, props.radiance_resolution_y
|
||||
|
||||
assert context.scene
|
||||
context.scene.render.resolution_x = resolution_x
|
||||
context.scene.render.resolution_y = resolution_y
|
||||
|
||||
aspect_ratio = resolution_x / resolution_y
|
||||
use_hdr = props.use_hdr
|
||||
choose_hdr_image = props.choose_hdr_image
|
||||
|
||||
print(f"Resolution: {resolution_x}x{resolution_y}")
|
||||
print(f"Output directory: {output_dir}")
|
||||
print(f"Found OBJ file: {obj_file_path} ({os.path.getsize(obj_file_path)} bytes)")
|
||||
|
||||
hdr_image_path = ""
|
||||
hdr_mask_path = ""
|
||||
sky_map_cal_path = ""
|
||||
if use_hdr:
|
||||
hdr_image_path = os.path.join(os.path.dirname(__file__), "HDRs", "noon_grass_2k.hdr")
|
||||
hdr_mask_path = os.path.join(os.path.dirname(__file__), "HDRs", "noon_grass_2k_mask.hdr")
|
||||
sky_map_cal_path = os.path.join(os.path.dirname(__file__), "HDRs", "skymap.cal")
|
||||
|
||||
sky_file_path = os.path.join(output_dir, "sky.rad")
|
||||
|
||||
print("Setting up camera...")
|
||||
if props.use_active_camera:
|
||||
camera = context.scene.camera
|
||||
else:
|
||||
camera = props.selected_camera
|
||||
|
||||
if camera is None:
|
||||
self.report({"ERROR"}, "No active camera found in the scene. Please add a camera and set it as active.")
|
||||
return {"CANCELLED"}
|
||||
|
||||
camera_position, camera_direction, camera_up = self.get_camera_data(camera)
|
||||
camera_type = camera.data.type
|
||||
camera_fov = camera.data.angle if camera_type == "PERSP" else 0.0
|
||||
camera_ortho_scale = camera.data.ortho_scale if camera_type == "ORTHO" else 0.0
|
||||
|
||||
print(f"Camera position: {camera_position}")
|
||||
print(f"Camera direction: {camera_direction}")
|
||||
print(f"Camera up: {camera_up}")
|
||||
|
||||
# Collect sky generation data (must be on main thread)
|
||||
sky_data = None
|
||||
if props.use_sun:
|
||||
sun_props = tool.Blender.get_solar_props()
|
||||
sun_pos_props = tool.Blender.get_sun_props()
|
||||
if not sun_pos_props:
|
||||
self.report(
|
||||
{"ERROR"}, "Sun position addon not available. Enable 'Sun Position' addon or disable 'Use Sun'."
|
||||
)
|
||||
return {"CANCELLED"}
|
||||
|
||||
sky_data = {
|
||||
"year": sun_props.year,
|
||||
"month": sun_props.month,
|
||||
"day": sun_props.day,
|
||||
"hour": sun_props.hour,
|
||||
"minute": sun_props.minute,
|
||||
"latitude": sun_props.latitude,
|
||||
"longitude": sun_props.longitude,
|
||||
"UTC_zone": sun_props.UTC_zone,
|
||||
"sun_year": sun_pos_props.year,
|
||||
"sky_condition": props.sky_condition,
|
||||
"ground_reflectance": props.ground_reflectance,
|
||||
"turbidity": props.turbidity,
|
||||
}
|
||||
|
||||
# Collect IES light data (must be on main thread)
|
||||
ies_light_data = []
|
||||
for idx, ies_light in enumerate(props.ies_lights):
|
||||
if not ies_light.is_enabled or not ies_light.ies_file_path:
|
||||
ies_light_data.append(None)
|
||||
continue
|
||||
|
||||
target_empties = ies_light.get_target_empties()
|
||||
if not target_empties:
|
||||
ies_light_data.append(None)
|
||||
continue
|
||||
|
||||
positions = []
|
||||
for obj in target_empties:
|
||||
try:
|
||||
positions.append((obj.location.x, obj.location.y, obj.location.z))
|
||||
except ReferenceError:
|
||||
continue
|
||||
|
||||
if not positions:
|
||||
ies_light_data.append(None)
|
||||
continue
|
||||
|
||||
ies_light_data.append(
|
||||
{
|
||||
"ies_file_path": ies_light.ies_file_path,
|
||||
"lamp_type": ies_light.lamp_type,
|
||||
"lamp_color": ies_light.lamp_color,
|
||||
"multiply_factor": ies_light.multiply_factor,
|
||||
"radius": ies_light.radius,
|
||||
"rotation_z": ies_light.rotation_z,
|
||||
"positions": positions,
|
||||
"is_enabled": ies_light.is_enabled,
|
||||
}
|
||||
)
|
||||
|
||||
# Collect material mapping data (must be on main thread)
|
||||
material_mappings = []
|
||||
for m in props.materials:
|
||||
material_mappings.append(
|
||||
{
|
||||
"style_id": m.style_id,
|
||||
"is_mapped": m.is_mapped,
|
||||
"category": m.category,
|
||||
"subcategory": m.subcategory,
|
||||
}
|
||||
)
|
||||
|
||||
linked_exports_snapshot = list(linked_model_exports)
|
||||
|
||||
# All Blender data collected — now launch background thread
|
||||
props.is_preparing = True
|
||||
self._start_time = time.time()
|
||||
self._error = None
|
||||
|
||||
import bonsai.bim.module.light.shared as shared
|
||||
|
||||
shared.scene = None
|
||||
|
||||
self._thread = threading.Thread(
|
||||
target=self._prepare_worker,
|
||||
args=(
|
||||
output_dir,
|
||||
obj_file_path,
|
||||
sky_file_path,
|
||||
use_hdr,
|
||||
choose_hdr_image,
|
||||
hdr_image_path,
|
||||
hdr_mask_path,
|
||||
sky_map_cal_path,
|
||||
sky_data,
|
||||
ies_light_data,
|
||||
material_mappings,
|
||||
linked_exports_snapshot,
|
||||
camera_position,
|
||||
camera_direction,
|
||||
camera_up,
|
||||
camera_type,
|
||||
camera_fov,
|
||||
camera_ortho_scale,
|
||||
aspect_ratio,
|
||||
),
|
||||
daemon=True,
|
||||
)
|
||||
self._thread.start()
|
||||
|
||||
wm = context.window_manager
|
||||
self._timer = wm.event_timer_add(0.5, window=context.window)
|
||||
wm.modal_handler_add(self)
|
||||
|
||||
self.report({"INFO"}, "Scene preparation started in background...")
|
||||
context.window.cursor_set("WAIT")
|
||||
return {"RUNNING_MODAL"}
|
||||
|
||||
def _prepare_worker(
|
||||
self,
|
||||
output_dir,
|
||||
obj_file_path,
|
||||
sky_file_path,
|
||||
use_hdr,
|
||||
choose_hdr_image,
|
||||
hdr_image_path,
|
||||
hdr_mask_path,
|
||||
sky_map_cal_path,
|
||||
sky_data,
|
||||
ies_light_data,
|
||||
material_mappings,
|
||||
linked_exports_snapshot,
|
||||
camera_position,
|
||||
camera_direction,
|
||||
camera_up,
|
||||
camera_type,
|
||||
camera_fov,
|
||||
camera_ortho_scale,
|
||||
aspect_ratio,
|
||||
):
|
||||
"""Runs in a background thread — no Blender API calls allowed here."""
|
||||
try:
|
||||
self._do_prepare(
|
||||
output_dir,
|
||||
obj_file_path,
|
||||
sky_file_path,
|
||||
use_hdr,
|
||||
choose_hdr_image,
|
||||
hdr_image_path,
|
||||
hdr_mask_path,
|
||||
sky_map_cal_path,
|
||||
sky_data,
|
||||
ies_light_data,
|
||||
material_mappings,
|
||||
linked_exports_snapshot,
|
||||
camera_position,
|
||||
camera_direction,
|
||||
camera_up,
|
||||
camera_type,
|
||||
camera_fov,
|
||||
camera_ortho_scale,
|
||||
aspect_ratio,
|
||||
)
|
||||
except Exception as e:
|
||||
self._error = str(e)
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
|
||||
def _do_prepare(
|
||||
self,
|
||||
output_dir,
|
||||
obj_file_path,
|
||||
sky_file_path,
|
||||
use_hdr,
|
||||
choose_hdr_image,
|
||||
hdr_image_path,
|
||||
hdr_mask_path,
|
||||
sky_map_cal_path,
|
||||
sky_data,
|
||||
ies_light_data,
|
||||
material_mappings,
|
||||
linked_exports_snapshot,
|
||||
camera_position,
|
||||
camera_direction,
|
||||
camera_up,
|
||||
camera_type,
|
||||
camera_fov,
|
||||
camera_ortho_scale,
|
||||
aspect_ratio,
|
||||
):
|
||||
"""The actual preparation logic (no Blender API)."""
|
||||
import bonsai.bim.module.light.shared as shared
|
||||
|
||||
# --- Generate sky file ---
|
||||
if sky_data is not None:
|
||||
dt = datetime(sky_data["year"], sky_data["month"], sky_data["day"], sky_data["hour"], sky_data["minute"])
|
||||
|
||||
print(f"Sun position data for Radiance gensky:")
|
||||
print(f" DateTime: {dt}")
|
||||
print(f" Latitude: {sky_data['latitude']}°")
|
||||
print(f" Longitude: {sky_data['longitude']}°")
|
||||
|
||||
sky_condition = sky_data["sky_condition"]
|
||||
longitude_for_gensky = -sky_data["longitude"]
|
||||
timezone_for_gensky = -int(sky_data["UTC_zone"]) * 15
|
||||
|
||||
sky_description = pr.gensky(
|
||||
dt=dt,
|
||||
latitude=sky_data["latitude"],
|
||||
longitude=longitude_for_gensky,
|
||||
year=sky_data["sun_year"],
|
||||
timezone=timezone_for_gensky,
|
||||
sunny_with_sun=sky_condition == "SUNNY_WITH_SUN",
|
||||
sunny_without_sun=sky_condition == "SUNNY_WITHOUT_SUN",
|
||||
cloudy=sky_condition == "CLOUDY",
|
||||
ground_reflectance=sky_data["ground_reflectance"],
|
||||
turbidity=sky_data["turbidity"],
|
||||
)
|
||||
|
||||
sky_description_str = sky_description.decode("utf-8")
|
||||
|
||||
if use_hdr and choose_hdr_image == "Noon":
|
||||
with open(sky_file_path, "w") as f:
|
||||
f.write(sky_description_str)
|
||||
f.write("\n")
|
||||
f.write(
|
||||
'''void colorpict env_map
|
||||
7 red green blue "'''
|
||||
+ hdr_image_path
|
||||
+ '''" "'''
|
||||
+ sky_map_cal_path
|
||||
+ '''" map_u map_v
|
||||
0
|
||||
1 0.5
|
||||
|
||||
# This is a multiplier to colour balance the env map
|
||||
# In this case, it provides a rough ground luminance from 3k-5k
|
||||
env_map colorfunc env_colour
|
||||
4 100 100 100 .
|
||||
0
|
||||
0
|
||||
|
||||
# .37 .57 1.5 is measured from a HDRI image
|
||||
# It is multiplied by a factor such that grey(r,g,b) = 1
|
||||
skyfunc colorfunc sky_colour
|
||||
4 .64 .99 2.6 .
|
||||
0
|
||||
0
|
||||
|
||||
void mixpict composite
|
||||
7 env_colour sky_colour grey "'''
|
||||
+ hdr_mask_path
|
||||
+ '''" "'''
|
||||
+ sky_map_cal_path
|
||||
+ """" map_u map_v
|
||||
0
|
||||
2 0.5 1
|
||||
|
||||
composite glow env_map_glow
|
||||
0
|
||||
0
|
||||
4 1 1 1 0
|
||||
|
||||
env_map_glow source sky
|
||||
0
|
||||
0
|
||||
4 0 0 1 180
|
||||
|
||||
env_colour glow ground_glow
|
||||
0
|
||||
0
|
||||
4 1 1 1 0
|
||||
|
||||
ground_glow source ground
|
||||
0
|
||||
0
|
||||
4 0 0 -1 180"""
|
||||
)
|
||||
elif not use_hdr:
|
||||
with open(sky_file_path, "w") as f:
|
||||
f.write(sky_description_str)
|
||||
f.write("\n")
|
||||
f.write("skyfunc glow sky_glow\n0\n0\n4 .9 .9 1.15 0\n")
|
||||
f.write("sky_glow source sky\n0\n0\n4 0 0 1 180\n")
|
||||
f.write("skyfunc glow ground_glow\n0\n0\n4 1.4 .9 .6 0\n")
|
||||
f.write("ground_glow source ground\n0\n0\n4 0 0 -1 180\n")
|
||||
else:
|
||||
print("Skipping sky generation (use_sun is False)...")
|
||||
|
||||
# --- Write materials.rad ---
|
||||
materials_file = os.path.join(output_dir, "materials.rad")
|
||||
written_materials = set()
|
||||
|
||||
all_materials = set(ifc_materials)
|
||||
|
||||
with open(materials_file, "w") as file:
|
||||
default_materials = [
|
||||
"void plastic white\n0\n0\n5 0.8 0.8 0.8 0 0\n",
|
||||
]
|
||||
for material in default_materials:
|
||||
file.write(material)
|
||||
written_materials.add(material.split()[2])
|
||||
|
||||
for style_id in all_materials:
|
||||
mat_data = next((m for m in material_mappings if m["style_id"] == style_id), None)
|
||||
if mat_data and mat_data["is_mapped"]:
|
||||
category, subcategory = mat_data["category"], mat_data["subcategory"]
|
||||
if category in spectraldb and subcategory in spectraldb[category]:
|
||||
material_def = spectraldb[category][subcategory]
|
||||
material_name = material_def.split()[2]
|
||||
if material_name not in written_materials:
|
||||
file.write(material_def + "\n")
|
||||
written_materials.add(material_name)
|
||||
file.write(f"inherit alias {style_id} {material_name}\n")
|
||||
else:
|
||||
file.write(f"inherit alias {style_id} white\n")
|
||||
else:
|
||||
file.write(f"inherit alias {style_id} white\n")
|
||||
|
||||
print(f"Exported Materials Rad file to: {materials_file}")
|
||||
|
||||
# --- Convert IES light files ---
|
||||
print("Processing IES light files...")
|
||||
converted_ies_lights = {}
|
||||
for idx, ies_data in enumerate(ies_light_data):
|
||||
if ies_data is None:
|
||||
continue
|
||||
try:
|
||||
rad_file, dat_file = convert_ies_to_radiance(
|
||||
ies_data["ies_file_path"],
|
||||
output_dir,
|
||||
lamp_type=ies_data["lamp_type"],
|
||||
lamp_color=ies_data["lamp_color"],
|
||||
multiply_factor=ies_data["multiply_factor"],
|
||||
radius=ies_data["radius"],
|
||||
)
|
||||
converted_ies_lights[idx] = (rad_file, dat_file)
|
||||
print(f" Converted IES light {idx}: {Path(ies_data['ies_file_path']).name}")
|
||||
except Exception as e:
|
||||
print(f" ERROR converting IES light {idx}: {str(e)}")
|
||||
|
||||
# --- Convert OBJ to RTM ---
|
||||
print(f"OBJ file size: {os.path.getsize(obj_file_path)} bytes")
|
||||
print(f"Materials file size: {os.path.getsize(materials_file)} bytes")
|
||||
print(f"Converting OBJ to RTM format...")
|
||||
|
||||
rtm_file_path = os.path.join(output_dir, "model.rtm")
|
||||
mesh_file_path = save_obj2mesh_output(obj_file_path, rtm_file_path, matfiles=[materials_file])
|
||||
print(f"obj2mesh output: {mesh_file_path}")
|
||||
|
||||
# Convert linked model OBJs to RTM
|
||||
linked_rtm_files = []
|
||||
for link_idx, (link_obj_path, link_mtl_path, link_matrix) in enumerate(linked_exports_snapshot):
|
||||
if not os.path.exists(link_obj_path):
|
||||
print(f"Linked model OBJ not found: {link_obj_path}")
|
||||
continue
|
||||
link_rtm_path = os.path.join(output_dir, f"linked_{link_idx}.rtm")
|
||||
try:
|
||||
save_obj2mesh_output(link_obj_path, link_rtm_path, matfiles=[materials_file])
|
||||
linked_rtm_files.append((link_rtm_path, link_matrix))
|
||||
print(f"Converted linked model {link_idx} to RTM")
|
||||
except Exception as e:
|
||||
print(f"Failed to convert linked model {link_idx} to RTM: {e}")
|
||||
|
||||
# --- Write scene.rad ---
|
||||
scene_file = os.path.join(output_dir, "scene.rad")
|
||||
with open(scene_file, "w") as file:
|
||||
file.write('void mesh model\n1 "' + rtm_file_path + '"\n0\n0\n')
|
||||
file.write("\n")
|
||||
|
||||
if linked_rtm_files:
|
||||
file.write("\n# Linked Models\n")
|
||||
for link_idx, (link_rtm_path, link_matrix) in enumerate(linked_rtm_files):
|
||||
is_identity = all(
|
||||
abs(link_matrix[i][j] - (1.0 if i == j else 0.0)) < 1e-6 for i in range(4) for j in range(4)
|
||||
)
|
||||
|
||||
if is_identity:
|
||||
file.write(f'void mesh linked_{link_idx}\n1 "{link_rtm_path}"\n0\n0\n\n')
|
||||
print(f"Added linked model {link_idx} to scene (identity, inline mesh)")
|
||||
else:
|
||||
link_rad_path = os.path.join(output_dir, f"linked_{link_idx}.rad")
|
||||
with open(link_rad_path, "w") as link_file:
|
||||
link_file.write(f'void mesh linked_{link_idx}\n1 "{link_rtm_path}"\n0\n0\n')
|
||||
|
||||
xform_args = _matrix_to_xform_args(link_matrix)
|
||||
file.write(f'!xform {xform_args} "{link_rad_path}"\n')
|
||||
print(f"Added linked model {link_idx} to scene with xform: {xform_args}")
|
||||
|
||||
# IES light fixtures
|
||||
if ies_light_data:
|
||||
file.write("\n# IES Light Fixtures\n")
|
||||
for idx, ies_data in enumerate(ies_light_data):
|
||||
if ies_data is None:
|
||||
continue
|
||||
z_rot = math.degrees(ies_data["rotation_z"])
|
||||
|
||||
for pos in ies_data["positions"]:
|
||||
if idx in converted_ies_lights:
|
||||
rad_path = converted_ies_lights[idx][0]
|
||||
rad_filename = Path(rad_path).name
|
||||
file.write(f'!xform -rz {z_rot} -t {pos[0]} {pos[1]} {pos[2]} "{rad_filename}"\n')
|
||||
else:
|
||||
rad_base = Path(ies_data["ies_file_path"]).stem
|
||||
rad_filename = f"{rad_base}.rad"
|
||||
file.write(f'# !xform -rz {z_rot} -t {pos[0]} {pos[1]} {pos[2]} "{rad_filename}"\n')
|
||||
|
||||
print(f"Exported Scene file to: {scene_file}")
|
||||
|
||||
# --- Validate light sources ---
|
||||
has_sky = sky_data is not None
|
||||
has_ies_lights = len(converted_ies_lights) > 0
|
||||
|
||||
if not has_sky and not has_ies_lights:
|
||||
raise RuntimeError("No light sources available. Please enable 'Use Sun' or add and map IES light fixtures.")
|
||||
|
||||
# --- Build pr.Scene ---
|
||||
print("Setting up Radiance scene...")
|
||||
new_scene = pr.Scene("ascene")
|
||||
|
||||
material_path = os.path.join(output_dir, "materials.rad")
|
||||
scene_path = os.path.join(output_dir, "scene.rad")
|
||||
|
||||
new_scene.add_material(material_path)
|
||||
new_scene.add_surface(scene_path)
|
||||
|
||||
if has_sky:
|
||||
new_scene.add_source(sky_file_path)
|
||||
print(f"Added sky light source")
|
||||
|
||||
if has_ies_lights:
|
||||
print(f"Added {len(converted_ies_lights)} IES light source(s)")
|
||||
|
||||
print("Setting up view...")
|
||||
if camera_type == "PERSP":
|
||||
vertical_fov = 2 * math.atan(math.tan(camera_fov / 2) / aspect_ratio)
|
||||
aview = pr.create_default_view()
|
||||
aview.type = "v"
|
||||
aview.vp = camera_position
|
||||
aview.vdir = camera_direction
|
||||
aview.vu = camera_up
|
||||
aview.horiz = math.degrees(camera_fov)
|
||||
aview.vert = math.degrees(vertical_fov)
|
||||
else:
|
||||
view_width = camera_ortho_scale
|
||||
view_height = camera_ortho_scale / aspect_ratio
|
||||
aview = pr.create_default_view()
|
||||
aview.type = "l"
|
||||
aview.vp = camera_position
|
||||
aview.vdir = camera_direction
|
||||
aview.vu = camera_up
|
||||
aview.horiz = view_width
|
||||
aview.vert = view_height
|
||||
|
||||
new_scene.add_view(aview)
|
||||
|
||||
# Set the global scene reference (thread-safe assignment)
|
||||
shared.scene = new_scene
|
||||
print("Scene preparation complete.")
|
||||
|
||||
def modal(self, context, event):
|
||||
if event.type == "TIMER":
|
||||
if self._thread is not None and self._thread.is_alive():
|
||||
return {"RUNNING_MODAL"}
|
||||
|
||||
# Thread finished — clean up
|
||||
self._cleanup_timer(context)
|
||||
props = tool.Blender.get_radiance_exporter_props()
|
||||
props.is_preparing = False
|
||||
context.window.cursor_set("DEFAULT")
|
||||
|
||||
if self._error:
|
||||
self.report({"ERROR"}, f"Scene preparation failed: {self._error}")
|
||||
return {"CANCELLED"}
|
||||
|
||||
elapsed = time.time() - self._start_time
|
||||
self.report({"INFO"}, f"Radiance scene prepared successfully in {elapsed:.1f}s")
|
||||
print(f"Scene preparation completed in {elapsed:.2f} seconds")
|
||||
return {"FINISHED"}
|
||||
|
||||
elif event.type == "ESC":
|
||||
self._cleanup_timer(context)
|
||||
props = tool.Blender.get_radiance_exporter_props()
|
||||
props.is_preparing = False
|
||||
context.window.cursor_set("DEFAULT")
|
||||
self.report({"WARNING"}, "Scene preparation cannot be cancelled mid-operation")
|
||||
return {"RUNNING_MODAL"}
|
||||
|
||||
return {"PASS_THROUGH"}
|
||||
|
||||
def _cleanup_timer(self, context):
|
||||
if self._timer is not None:
|
||||
context.window_manager.event_timer_remove(self._timer)
|
||||
self._timer = None
|
||||
@@ -16,6 +16,7 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import calendar
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
@@ -43,7 +44,6 @@ from bonsai.bim.module.light.data import SolarData
|
||||
from bonsai.bim.module.light.decorator import SolarDecorator
|
||||
|
||||
sun_position = tool.Blender.get_addon("sun_position")
|
||||
now = datetime.datetime.now()
|
||||
|
||||
with open(os.path.join(os.path.dirname(__file__), "spectraldb.json"), "r") as f:
|
||||
spectraldb: dict[str, dict[str, str]] = json.load(f)
|
||||
@@ -72,8 +72,8 @@ def update_coordinates(self: "BIMSolarProperties", context: bpy.types.Context) -
|
||||
def update_latlong(self: "BIMSolarProperties", context: bpy.types.Context) -> None:
|
||||
sun_props = tool.Blender.get_sun_props()
|
||||
assert sun_props
|
||||
sun_props.longitude = sun_props.longitude
|
||||
sun_props.latitude = sun_props.latitude
|
||||
sun_props.longitude = self.longitude
|
||||
sun_props.latitude = self.latitude
|
||||
self["coordinates"] = sun_props.coordinates
|
||||
update_sun_path(self)
|
||||
|
||||
@@ -135,6 +135,14 @@ def update_resolution(self: "RadianceExporterProperties", context: bpy.types.Con
|
||||
context.scene.render.resolution_y = self.radiance_resolution_y
|
||||
|
||||
|
||||
def update_day(self: "BIMSolarProperties", context: bpy.types.Context) -> None:
|
||||
"""Clamp the day to the valid range for the current month/year."""
|
||||
max_day = calendar.monthrange(self.year, self.month)[1]
|
||||
if self.day > max_day:
|
||||
self["day"] = max_day
|
||||
update_sun_path(self)
|
||||
|
||||
|
||||
def update_sun_path(self: "BIMSolarProperties", context: Union[bpy.types.Context, None] = None) -> None:
|
||||
if not SolarData.is_loaded:
|
||||
SolarData.load()
|
||||
@@ -152,9 +160,13 @@ def update_sun_path(self: "BIMSolarProperties", context: Union[bpy.types.Context
|
||||
sun_props.sun_distance = self.sun_path_size
|
||||
sun_props.latitude = self.latitude
|
||||
sun_props.longitude = self.longitude
|
||||
# Clamp day to valid range for the current month/year
|
||||
max_day = calendar.monthrange(self.year, self.month)[1]
|
||||
day = min(self.day, max_day)
|
||||
|
||||
sun_props.year = self.year
|
||||
sun_props.month = self.month
|
||||
sun_props.day = self.day
|
||||
sun_props.day = day
|
||||
sun_props.time = self.hour + (self.minute / 60)
|
||||
# Preserve IFC sign convention
|
||||
sun_props.north_offset = self.true_north * -1
|
||||
@@ -181,8 +193,11 @@ def update_sun_path(self: "BIMSolarProperties", context: Union[bpy.types.Context
|
||||
)
|
||||
sun_vector = sun_position.sun_calc.get_sun_vector(azimuth, elevation) * sun_props.sun_distance
|
||||
props.sun_position = sun_vector
|
||||
# sun_vector.z = max(0, sun_vector.z)
|
||||
# Light direction is a bit weird?
|
||||
|
||||
# Update Blender viewport light direction for shadow visualization
|
||||
# This coordinate transformation converts from sun_position addon's coordinate system
|
||||
# to Blender's display.light_direction coordinate system
|
||||
# Note: This only affects viewport shading, not the Radiance rendering
|
||||
mat = Matrix(((-1.0, 0.0, 0.0, 0.0), (0.0, 0, 1.0, 0.0), (-0.0, -1.0, 0, 0.0), (0.0, 0.0, 0.0, 1.0))).inverted()
|
||||
rotation_euler = Euler((elevation - pi / 2, 0, -azimuth))
|
||||
rotation_quaternion = rotation_euler.to_quaternion()
|
||||
@@ -193,6 +208,9 @@ def update_sun_path(self: "BIMSolarProperties", context: Union[bpy.types.Context
|
||||
|
||||
assert bpy.context.scene
|
||||
assert bpy.context.scene.display
|
||||
# Set viewport light direction based on sun position
|
||||
# If sun is below horizon (z < 0), use default upward direction
|
||||
# Otherwise, use calculated direction from sun position
|
||||
if sun_vector.z < 0:
|
||||
bpy.context.scene.display.light_direction = mat @ Vector((0, 0, 1))
|
||||
else:
|
||||
@@ -223,6 +241,106 @@ class RadianceMaterial(PropertyGroup):
|
||||
color: tuple[float, float, float]
|
||||
|
||||
|
||||
class IESLight(PropertyGroup):
|
||||
"""Represents a mapping between an IES light file and scene Empty objects.
|
||||
|
||||
Supports two targeting modes:
|
||||
- Object mode: target a single Empty object
|
||||
- Collection mode: target all Empty objects in a collection
|
||||
"""
|
||||
|
||||
ies_file_path: StringProperty(
|
||||
name="IES File Path",
|
||||
description="Path to the IES luminaire data file",
|
||||
subtype="FILE_PATH",
|
||||
default="",
|
||||
)
|
||||
use_collection: BoolProperty(
|
||||
name="Use Collection",
|
||||
description="Apply this light to all Empty objects in a collection instead of a single object",
|
||||
default=False,
|
||||
)
|
||||
target_object: PointerProperty(
|
||||
type=bpy.types.Object,
|
||||
name="Target Object",
|
||||
description="Empty object where the light fixture will be placed",
|
||||
poll=lambda self, obj: obj.type == "EMPTY",
|
||||
)
|
||||
target_collection: PointerProperty(
|
||||
type=bpy.types.Collection,
|
||||
name="Target Collection",
|
||||
description="Collection of Empty objects where the light fixture will be placed",
|
||||
)
|
||||
rotation_z: FloatProperty(
|
||||
name="Rotation Z",
|
||||
description="Rotation around Z-axis in degrees (-180 to 180)",
|
||||
min=-180.0,
|
||||
max=180.0,
|
||||
default=0.0,
|
||||
subtype="ANGLE",
|
||||
)
|
||||
is_enabled: BoolProperty(
|
||||
name="Enabled",
|
||||
description="Include this light in the Radiance export",
|
||||
default=True,
|
||||
)
|
||||
|
||||
# Additional lamp properties for customization
|
||||
lamp_type: StringProperty(
|
||||
name="Lamp Type",
|
||||
description="Type of lamp (e.g., 'LED', 'metal halide', 'fluorescent')",
|
||||
default="",
|
||||
)
|
||||
lamp_color: FloatVectorProperty(
|
||||
name="Lamp Color",
|
||||
description="Lamp color (RGB) for custom color adjustments",
|
||||
subtype="COLOR",
|
||||
default=(1.0, 1.0, 1.0),
|
||||
min=0.0,
|
||||
max=1.0,
|
||||
size=3,
|
||||
)
|
||||
multiply_factor: FloatProperty(
|
||||
name="Brightness Factor",
|
||||
description="Multiply all output quantities by this factor (0.1 to 10.0)",
|
||||
min=0.1,
|
||||
max=10.0,
|
||||
default=1.0,
|
||||
)
|
||||
radius: FloatProperty(
|
||||
name="Illum Sphere Radius",
|
||||
description="Radius of illum sphere (ignores geometry from IES file). 0 = use IES geometry",
|
||||
min=0.0,
|
||||
max=10.0,
|
||||
default=0.0,
|
||||
)
|
||||
|
||||
def get_target_empties(self) -> list[bpy.types.Object]:
|
||||
"""Return all Empty objects this light targets."""
|
||||
if self.use_collection and self.target_collection is not None:
|
||||
return [obj for obj in self.target_collection.all_objects if obj.type == "EMPTY" and not obj.hide_get()]
|
||||
elif not self.use_collection and self.target_object is not None:
|
||||
try:
|
||||
_ = self.target_object.name
|
||||
if not self.target_object.hide_get():
|
||||
return [self.target_object]
|
||||
except ReferenceError:
|
||||
pass
|
||||
return []
|
||||
|
||||
if TYPE_CHECKING:
|
||||
ies_file_path: str
|
||||
use_collection: bool
|
||||
target_object: Union[bpy.types.Object, None]
|
||||
target_collection: Union[bpy.types.Collection, None]
|
||||
rotation_z: float
|
||||
is_enabled: bool
|
||||
lamp_type: str
|
||||
lamp_color: tuple[float, float, float]
|
||||
multiply_factor: float
|
||||
radius: float
|
||||
|
||||
|
||||
class RadianceExporterProperties(PropertyGroup):
|
||||
|
||||
def update_output_dir(self, context) -> None:
|
||||
@@ -233,6 +351,9 @@ class RadianceExporterProperties(PropertyGroup):
|
||||
if self.ifc_file:
|
||||
self.ifc_file = bpy.path.abspath(self.ifc_file)
|
||||
|
||||
def get_categories(self, context):
|
||||
return sorted([(k, k, "") for k in spectraldb.keys()])
|
||||
|
||||
def add_material_mapping(self, style_id: str, style_name: str) -> RadianceMaterial:
|
||||
item = self.materials.add()
|
||||
item.name = style_name
|
||||
@@ -247,7 +368,10 @@ class RadianceExporterProperties(PropertyGroup):
|
||||
mappings = json.load(f)
|
||||
|
||||
for style_id, mapping in mappings.items():
|
||||
material = self.get_material_mapping(mapping["name"])
|
||||
# Try matching by style_id first, fall back to name
|
||||
material = self.get_material_mapping_by_id(style_id)
|
||||
if material is None:
|
||||
material = self.get_material_mapping(mapping["name"])
|
||||
if material:
|
||||
material.style_id = style_id
|
||||
material.category = mapping["category"]
|
||||
@@ -259,11 +383,14 @@ class RadianceExporterProperties(PropertyGroup):
|
||||
new_material.subcategory = mapping["subcategory"]
|
||||
new_material.is_mapped = True
|
||||
|
||||
def get_material_mapping_by_id(self, style_id: str) -> Union[RadianceMaterial, None]:
|
||||
return next((item for item in self.materials if item.style_id == style_id), None)
|
||||
|
||||
def get_material_mapping(self, style_name: str) -> Union[RadianceMaterial, None]:
|
||||
return next((item for item in self.materials if item.name == style_name), None)
|
||||
|
||||
def set_material_mapping(self, style_id: str, style_name: str, category: str, subcategory: str) -> None:
|
||||
item = self.get_material_mapping(style_name)
|
||||
item = self.get_material_mapping_by_id(style_id) or self.get_material_mapping(style_name)
|
||||
if item:
|
||||
item.category = category
|
||||
item.subcategory = subcategory
|
||||
@@ -279,8 +406,12 @@ class RadianceExporterProperties(PropertyGroup):
|
||||
if item.category and item.subcategory
|
||||
}
|
||||
|
||||
def unmap_material(self, style_name: str) -> None:
|
||||
item = self.get_material_mapping(style_name)
|
||||
def unmap_material(self, style_name: str, style_id: str = "") -> None:
|
||||
item = None
|
||||
if style_id:
|
||||
item = self.get_material_mapping_by_id(style_id)
|
||||
if item is None:
|
||||
item = self.get_material_mapping(style_name)
|
||||
if item:
|
||||
item.category = ""
|
||||
item.subcategory = ""
|
||||
@@ -290,6 +421,14 @@ class RadianceExporterProperties(PropertyGroup):
|
||||
name="Is Exporting", description="Whether the OBJ export is in progress", default=False
|
||||
)
|
||||
|
||||
is_preparing: bpy.props.BoolProperty(
|
||||
name="Is Preparing", description="Whether scene preparation is in progress", default=False
|
||||
)
|
||||
|
||||
is_rendering: bpy.props.BoolProperty(
|
||||
name="Is Rendering", description="Whether a Radiance render is in progress", default=False
|
||||
)
|
||||
|
||||
categories = [
|
||||
("Wall", "Wall", ""),
|
||||
("Floor", "Floor", ""),
|
||||
@@ -316,16 +455,13 @@ class RadianceExporterProperties(PropertyGroup):
|
||||
print(f"Material '{active_material.name}' mapped to {self.category} - {self.subcategory}")
|
||||
|
||||
category: bpy.props.EnumProperty(
|
||||
items=categories, name="Category", description="Material category", update=update_material_mapping
|
||||
items=get_categories, name="Category", description="Material category", update=update_material_mapping
|
||||
)
|
||||
|
||||
def get_subcategories(self, context: bpy.types.Context) -> tool.Blender.BLENDER_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:
|
||||
SUBCATEGORIES_ENUM_ITEMS = []
|
||||
return SUBCATEGORIES_ENUM_ITEMS
|
||||
return sorted([(k, k, "") for k in spectraldb[self.category].keys()])
|
||||
return []
|
||||
|
||||
subcategory: bpy.props.EnumProperty(
|
||||
items=get_subcategories, name="Subcategory", description="Material subcategory", update=update_material_mapping
|
||||
@@ -334,12 +470,9 @@ class RadianceExporterProperties(PropertyGroup):
|
||||
materials: CollectionProperty(type=RadianceMaterial)
|
||||
active_material_index: IntProperty()
|
||||
|
||||
# material_mappings: bpy.props.CollectionProperty(type=bpy.types.PropertyGroup, name="Material Mappings")
|
||||
# material_mappings: CollectionProperty(type=MaterialMapping)
|
||||
|
||||
should_load_from_memory: BoolProperty(
|
||||
name="Load from Memory",
|
||||
default=False,
|
||||
default=True,
|
||||
)
|
||||
|
||||
radiance_resolution_x: IntProperty(
|
||||
@@ -348,6 +481,13 @@ class RadianceExporterProperties(PropertyGroup):
|
||||
radiance_resolution_y: IntProperty(
|
||||
name="Y", description="Vertical resolution of the output image", default=1080, min=1, update=update_resolution
|
||||
)
|
||||
radiance_bin_dir: StringProperty(
|
||||
name="Radiance Bin",
|
||||
description="Path to the Radiance bin folder (containing falsecolor, pcomb, etc.)",
|
||||
default="",
|
||||
subtype="DIR_PATH",
|
||||
)
|
||||
|
||||
output_dir: StringProperty(
|
||||
name="Output Directory",
|
||||
description="Directory to output Radiance files",
|
||||
@@ -363,6 +503,15 @@ class RadianceExporterProperties(PropertyGroup):
|
||||
update=lambda self, context: self.update_ifc_file(context),
|
||||
)
|
||||
|
||||
ambient_bounces: IntProperty(
|
||||
name="Ambient Bounces",
|
||||
description="Number of indirect light bounces. Higher = more light fills dark areas but slower. "
|
||||
"1 is minimal, 2-3 recommended for IES-only scenes, 4+ for complex interiors",
|
||||
min=0,
|
||||
max=8,
|
||||
default=2,
|
||||
)
|
||||
|
||||
radiance_quality: EnumProperty(
|
||||
name="Quality",
|
||||
description="Radiance rendering quality",
|
||||
@@ -393,14 +542,15 @@ class RadianceExporterProperties(PropertyGroup):
|
||||
name="Output File Format",
|
||||
description="Format of the output image file",
|
||||
items=[
|
||||
("HDR", "HDR + Tiff", "High Dynamic Range"),
|
||||
("HDR", "HDR", "High Dynamic Range (HDR) file only"),
|
||||
("HDR_TIFF", "HDR + Tiff", "High Dynamic Range (HDR) and Tiff files"),
|
||||
],
|
||||
default="HDR",
|
||||
default="HDR_TIFF",
|
||||
)
|
||||
|
||||
use_hdr: BoolProperty(
|
||||
name="Use HDR",
|
||||
description="Use HDR image format",
|
||||
name="HDR Environment Map",
|
||||
description="Use an HDR image as the sky dome for realistic environment lighting and reflections",
|
||||
default=True,
|
||||
)
|
||||
|
||||
@@ -413,6 +563,40 @@ class RadianceExporterProperties(PropertyGroup):
|
||||
default="Noon",
|
||||
)
|
||||
|
||||
use_sun: BoolProperty(
|
||||
name="Use Sun",
|
||||
description="Use sun position data to generate sky. If disabled, generates a default sky without sun",
|
||||
default=True,
|
||||
)
|
||||
|
||||
# Sky generation parameters
|
||||
sky_condition: EnumProperty(
|
||||
name="Sky Condition",
|
||||
description="Type of sky condition to generate",
|
||||
items=[
|
||||
("SUNNY_WITH_SUN", "Sunny with Sun", "Clear sky with direct sun"),
|
||||
("SUNNY_WITHOUT_SUN", "Sunny without Sun", "Clear sky without direct sun"),
|
||||
("CLOUDY", "Cloudy", "Overcast sky condition"),
|
||||
],
|
||||
default="SUNNY_WITH_SUN",
|
||||
)
|
||||
|
||||
ground_reflectance: FloatProperty(
|
||||
name="Ground Reflectance",
|
||||
description="Ground reflectance value (0.0 to 1.0)",
|
||||
min=0.0,
|
||||
max=1.0,
|
||||
default=0.2,
|
||||
)
|
||||
|
||||
turbidity: FloatProperty(
|
||||
name="Turbidity",
|
||||
description="Atmospheric turbidity (1.0 to 10.0). Lower values = clearer sky",
|
||||
min=1.0,
|
||||
max=10.0,
|
||||
default=3.0,
|
||||
)
|
||||
|
||||
use_active_camera: BoolProperty(
|
||||
name="Use Active Camera", description="Use the active camera in the scene", default=True
|
||||
)
|
||||
@@ -424,8 +608,90 @@ class RadianceExporterProperties(PropertyGroup):
|
||||
poll=lambda self, object: object.type == "CAMERA",
|
||||
)
|
||||
|
||||
ies_lights: CollectionProperty(
|
||||
type=IESLight,
|
||||
name="IES Lights",
|
||||
description="Collection of IES light fixtures mapped to scene objects",
|
||||
)
|
||||
active_ies_light_index: IntProperty(
|
||||
name="Active IES Light Index",
|
||||
description="Index of the active IES light in the collection",
|
||||
default=-1,
|
||||
)
|
||||
|
||||
# False color analysis properties
|
||||
use_false_color: BoolProperty(
|
||||
name="Generate False Color Image",
|
||||
description="Generate a false color HDR image for illuminance analysis",
|
||||
default=False,
|
||||
)
|
||||
|
||||
false_color_label: EnumProperty(
|
||||
name="Legend Label Unit",
|
||||
description="Unit for the false color legend",
|
||||
items=[
|
||||
("fc", "Foot-Candles", "US lighting standard unit"),
|
||||
("lux", "Lux", "International lighting standard unit"),
|
||||
("cd/m2", "Candela per m²", "Luminance unit"),
|
||||
],
|
||||
default="fc",
|
||||
)
|
||||
|
||||
false_color_scale: FloatProperty(
|
||||
name="Scale Factor",
|
||||
description="Maximum scale value for the false color legend",
|
||||
min=0.1,
|
||||
max=100000.0,
|
||||
default=3.0,
|
||||
)
|
||||
|
||||
false_color_steps: IntProperty(
|
||||
name="Legend Steps",
|
||||
description="Number of divisions on the legend. Contour increment = Scale / Steps. "
|
||||
"E.g. Scale=20, Steps=10 → contours at 2, 4, 6, 8...",
|
||||
min=2,
|
||||
max=50,
|
||||
default=10,
|
||||
)
|
||||
|
||||
false_color_contour_lines: BoolProperty(
|
||||
name="Enable Contour Lines",
|
||||
description="Add contour lines to the false color image",
|
||||
default=True,
|
||||
)
|
||||
|
||||
false_color_contour_mode: EnumProperty(
|
||||
name="Contour Mode",
|
||||
description="Contour line display mode",
|
||||
items=[
|
||||
(
|
||||
"WITH_BG",
|
||||
"Contour Lines with Background",
|
||||
"Show contour lines overlaid on the colored false color background",
|
||||
),
|
||||
("WITHOUT_BG", "Contour Lines Only", "Show only contour lines without the false color background"),
|
||||
],
|
||||
default="WITH_BG",
|
||||
)
|
||||
|
||||
false_color_multiplier: FloatProperty(
|
||||
name="Multiplier",
|
||||
description="Conversion multiplier (179.0 for lux, 16.6295 for foot-candles)",
|
||||
min=0.1,
|
||||
max=1000.0,
|
||||
default=16.629505759940542,
|
||||
)
|
||||
|
||||
false_color_output_name: StringProperty(
|
||||
name="False Color Output Name",
|
||||
description="Name of the false color output file (without extension)",
|
||||
default="false_color",
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
is_exporting: bool
|
||||
is_preparing: bool
|
||||
is_rendering: bool
|
||||
category: str
|
||||
subcategory: str
|
||||
materials: bpy.types.bpy_prop_collection_idprop[RadianceMaterial]
|
||||
@@ -433,8 +699,10 @@ class RadianceExporterProperties(PropertyGroup):
|
||||
should_load_from_memory: bool
|
||||
radiance_resolution_x: int
|
||||
radiance_resolution_y: int
|
||||
radiance_bin_dir: str
|
||||
output_dir: str
|
||||
ifc_file: str
|
||||
ambient_bounces: int
|
||||
radiance_quality: Literal["LOW", "MEDIUM", "HIGH"]
|
||||
radiance_detail: Literal["LOW", "MEDIUM", "HIGH"]
|
||||
radiance_variability: Literal["LOW", "MEDIUM", "HIGH"]
|
||||
@@ -442,8 +710,22 @@ class RadianceExporterProperties(PropertyGroup):
|
||||
output_file_format: Literal["HDR"]
|
||||
use_hdr: bool
|
||||
choose_hdr_image: Literal["Noon"]
|
||||
use_sun: bool
|
||||
sky_condition: Literal["SUNNY_WITH_SUN", "SUNNY_WITHOUT_SUN", "CLOUDY"]
|
||||
ground_reflectance: float
|
||||
turbidity: float
|
||||
use_active_camera: bool
|
||||
selected_camera: Union[bpy.types.Object, None]
|
||||
ies_lights: bpy.types.bpy_prop_collection_idprop[IESLight]
|
||||
active_ies_light_index: int
|
||||
use_false_color: bool
|
||||
false_color_label: Literal["fc", "lux", "cd/m2"]
|
||||
false_color_scale: float
|
||||
false_color_steps: int
|
||||
false_color_contour_lines: bool
|
||||
false_color_contour_mode: Literal["WITH_BG", "WITHOUT_BG"]
|
||||
false_color_multiplier: float
|
||||
false_color_output_name: str
|
||||
|
||||
|
||||
class BIMSolarProperties(PropertyGroup):
|
||||
@@ -470,11 +752,12 @@ class BIMSolarProperties(PropertyGroup):
|
||||
)
|
||||
timezone: StringProperty(name="Timezone", default="Etc/GMT")
|
||||
true_north: FloatProperty(name="True North", min=-pi, max=pi, subtype="ANGLE", update=update_sun_path)
|
||||
year: IntProperty(name="Year", min=1, max=9999, default=now.year, update=update_sun_path)
|
||||
month: IntProperty(name="Month", min=1, max=12, default=now.month, update=update_sun_path)
|
||||
day: IntProperty(name="Date", min=1, max=31, default=now.day, update=update_sun_path)
|
||||
hour: IntProperty(name="Hour", min=0, max=23, default=now.hour, update=update_sun_path)
|
||||
minute: IntProperty(name="Minute", min=0, max=59, default=now.minute, update=update_sun_path)
|
||||
# Defaults are static; use the "Now" button (LightSetTimeToNow) to set current time.
|
||||
year: IntProperty(name="Year", min=1, max=9999, default=2025, update=update_day)
|
||||
month: IntProperty(name="Month", min=1, max=12, default=1, update=update_day)
|
||||
day: IntProperty(name="Date", min=1, max=31, default=1, update=update_day)
|
||||
hour: IntProperty(name="Hour", min=0, max=23, default=12, update=update_sun_path)
|
||||
minute: IntProperty(name="Minute", min=0, max=59, default=0, update=update_sun_path)
|
||||
sun_position: FloatVectorProperty(name="Sun Position", subtype="XYZ", default=(0, 0, 0))
|
||||
sun_path_origin: FloatVectorProperty(name="Sun Path Origin", subtype="XYZ", default=(0, 0, 0))
|
||||
sun_path_size: FloatProperty(name="Sun Path Size", min=0.1, default=50, update=update_sun_path)
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2020, 2021 Dion Moult <dion@thinkmoult.com>
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import multiprocessing
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Union
|
||||
|
||||
import bpy
|
||||
import pyradiance as pr
|
||||
|
||||
import bonsai.bim.module.light.shared as shared
|
||||
import bonsai.tool as tool
|
||||
|
||||
# pyradiance's bundled Radiance binaries
|
||||
_PYRAD_BIN = Path(pr.__file__).parent / "bin"
|
||||
|
||||
|
||||
class RadianceRender(bpy.types.Operator):
|
||||
"""Radiance Rendering (runs in background thread)"""
|
||||
|
||||
bl_idname = "render_scene.radiance"
|
||||
bl_label = "Render"
|
||||
bl_description = "Renders the scene using Radiance"
|
||||
|
||||
_timer = None
|
||||
_thread: Union[threading.Thread, None] = None
|
||||
_result_image: Union[bytes, None] = None
|
||||
_error: Union[str, None] = None
|
||||
_start_time: float = 0.0
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
props = tool.Blender.get_radiance_exporter_props()
|
||||
if not props.output_dir:
|
||||
cls.poll_message_set("Output directory is not set.")
|
||||
return False
|
||||
if props.is_rendering:
|
||||
cls.poll_message_set("A render is already in progress.")
|
||||
return False
|
||||
if shared.scene is None:
|
||||
cls.poll_message_set("Radiance scene not prepared. Please run 'Prepare Scene' (Step 2) first.")
|
||||
return False
|
||||
return True
|
||||
|
||||
def execute(self, context):
|
||||
props = tool.Blender.get_radiance_exporter_props()
|
||||
resolution_x, resolution_y = props.radiance_resolution_x, props.radiance_resolution_y
|
||||
|
||||
context.scene.render.resolution_x = resolution_x
|
||||
context.scene.render.resolution_y = resolution_y
|
||||
|
||||
quality = props.radiance_quality.upper()
|
||||
detail = props.radiance_detail.upper()
|
||||
variability = props.radiance_variability.upper()
|
||||
ambient_bounces = props.ambient_bounces
|
||||
output_dir = props.output_dir
|
||||
|
||||
props.is_rendering = True
|
||||
self._start_time = time.time()
|
||||
self._result_image = None
|
||||
self._error = None
|
||||
|
||||
self._thread = threading.Thread(
|
||||
target=self._render_worker,
|
||||
args=(shared.scene, output_dir, resolution_x, resolution_y, quality, detail, variability, ambient_bounces),
|
||||
daemon=True,
|
||||
)
|
||||
self._thread.start()
|
||||
|
||||
wm = context.window_manager
|
||||
self._timer = wm.event_timer_add(0.5, window=context.window)
|
||||
wm.modal_handler_add(self)
|
||||
|
||||
self.report({"INFO"}, "Radiance render started in background...")
|
||||
context.window.cursor_set("WAIT")
|
||||
return {"RUNNING_MODAL"}
|
||||
|
||||
def _render_worker(self, render_scene, output_dir, res_x, res_y, quality, detail, variability, ambient_bounces):
|
||||
"""Runs in a background thread — no Blender API calls allowed here."""
|
||||
cwd_saved = os.getcwd()
|
||||
try:
|
||||
os.chdir(output_dir)
|
||||
self._result_image = pr.render(
|
||||
render_scene,
|
||||
ambbounce=ambient_bounces,
|
||||
resolution=(res_x, res_y),
|
||||
quality=quality,
|
||||
detail=detail,
|
||||
variability=variability,
|
||||
nproc=multiprocessing.cpu_count(),
|
||||
)
|
||||
except Exception as e:
|
||||
self._error = str(e)
|
||||
finally:
|
||||
os.chdir(cwd_saved)
|
||||
|
||||
def modal(self, context, event):
|
||||
if event.type == "TIMER":
|
||||
if self._thread is not None and self._thread.is_alive():
|
||||
return {"RUNNING_MODAL"}
|
||||
|
||||
self._cleanup_timer(context)
|
||||
props = tool.Blender.get_radiance_exporter_props()
|
||||
props.is_rendering = False
|
||||
context.window.cursor_set("DEFAULT")
|
||||
|
||||
if self._error:
|
||||
self.report({"ERROR"}, f"Radiance render failed: {self._error}")
|
||||
return {"CANCELLED"}
|
||||
|
||||
elapsed = time.time() - self._start_time
|
||||
print(f"Render completed in {elapsed:.2f} seconds")
|
||||
|
||||
output_dir = props.output_dir
|
||||
output_file_name = props.output_file_name
|
||||
output_file_format = props.output_file_format
|
||||
|
||||
output_hdr_path = os.path.join(output_dir, f"{output_file_name}.hdr")
|
||||
print(f"Saving HDR output to: {output_hdr_path}")
|
||||
with open(output_hdr_path, "wb") as wtr:
|
||||
wtr.write(self._result_image)
|
||||
|
||||
if output_file_format == "HDR_TIFF":
|
||||
print("Applying tone mapping...")
|
||||
pcond_image = pr.pcond(hdr=output_hdr_path, human=True)
|
||||
tiff_path = os.path.join(output_dir, f"{output_file_name}.tiff")
|
||||
print(f"Saving TIFF output to: {tiff_path}")
|
||||
pr.ra_tiff(inp=pcond_image, out=tiff_path, lzw=True)
|
||||
|
||||
print("Radiance rendering process completed successfully.")
|
||||
self.report({"INFO"}, f"Radiance rendering completed. HDR Output: {output_hdr_path}")
|
||||
if output_file_format == "HDR_TIFF":
|
||||
self.report({"INFO"}, f"TIFF Output: {tiff_path}")
|
||||
|
||||
for area in context.screen.areas:
|
||||
area.tag_redraw()
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
elif event.type == "ESC":
|
||||
self._cleanup_timer(context)
|
||||
props = tool.Blender.get_radiance_exporter_props()
|
||||
props.is_rendering = False
|
||||
context.window.cursor_set("DEFAULT")
|
||||
self.report({"WARNING"}, "Render cancelled by user. Background process may still be running.")
|
||||
return {"CANCELLED"}
|
||||
|
||||
return {"PASS_THROUGH"}
|
||||
|
||||
def _cleanup_timer(self, context):
|
||||
if self._timer is not None:
|
||||
context.window_manager.event_timer_remove(self._timer)
|
||||
self._timer = None
|
||||
|
||||
|
||||
class FalseColorRadiance(bpy.types.Operator):
|
||||
"""Generate false color HDR image for illuminance analysis"""
|
||||
|
||||
bl_idname = "render_scene.false_color_radiance"
|
||||
bl_label = "Generate False Color Image"
|
||||
bl_description = "Generate a false color HDR image for illuminance analysis"
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
props = tool.Blender.get_radiance_exporter_props()
|
||||
if not props.output_dir:
|
||||
cls.poll_message_set("Output directory is not set.")
|
||||
return False
|
||||
return True
|
||||
|
||||
def execute(self, context):
|
||||
props = tool.Blender.get_radiance_exporter_props()
|
||||
output_dir = props.output_dir
|
||||
output_file_name = props.output_file_name
|
||||
|
||||
hdr_path = os.path.join(output_dir, f"{output_file_name}.hdr")
|
||||
if not os.path.exists(hdr_path):
|
||||
self.report(
|
||||
{"ERROR"},
|
||||
f"HDR file not found at: {hdr_path}. Please run 'Radiance Render' first.",
|
||||
)
|
||||
return {"CANCELLED"}
|
||||
|
||||
fc_scale = (
|
||||
str(int(props.false_color_scale))
|
||||
if props.false_color_scale == int(props.false_color_scale)
|
||||
else str(props.false_color_scale)
|
||||
)
|
||||
|
||||
# Multiplier converts Radiance raw values to display units
|
||||
# fc (foot-candles) = 16.629..., lux & cd/m2 = 179.0
|
||||
multiplier = 179.0 if props.false_color_label in ("lux", "cd/m2") else 16.629505759940542
|
||||
|
||||
print(
|
||||
f"False color parameters: label={props.false_color_label}, scale={fc_scale}, "
|
||||
f"steps={props.false_color_steps}, multiplier={multiplier}, "
|
||||
f"contour={props.false_color_contour_lines}"
|
||||
)
|
||||
|
||||
try:
|
||||
fc_output_name = props.false_color_output_name
|
||||
fc_hdr_path = os.path.join(output_dir, f"{fc_output_name}.hdr")
|
||||
|
||||
# Find falsecolor binary from user-specified Radiance bin directory
|
||||
radiance_bin_dir = os.path.normpath(props.radiance_bin_dir) if props.radiance_bin_dir else ""
|
||||
if radiance_bin_dir:
|
||||
falsecolor_bin = os.path.join(radiance_bin_dir, "falsecolor.exe")
|
||||
if not os.path.exists(falsecolor_bin):
|
||||
falsecolor_bin = os.path.join(radiance_bin_dir, "falsecolor")
|
||||
if not os.path.exists(falsecolor_bin):
|
||||
self.report({"ERROR"}, f"falsecolor not found in: {radiance_bin_dir}")
|
||||
return {"CANCELLED"}
|
||||
else:
|
||||
import shutil
|
||||
|
||||
falsecolor_bin = shutil.which("falsecolor") or shutil.which("falsecolor.exe")
|
||||
if not falsecolor_bin:
|
||||
self.report(
|
||||
{"ERROR"}, "Set the Radiance Bin path in False Color settings, or add Radiance to system PATH."
|
||||
)
|
||||
return {"CANCELLED"}
|
||||
radiance_bin_dir = os.path.dirname(falsecolor_bin)
|
||||
|
||||
radiance_lib = os.path.join(os.path.dirname(radiance_bin_dir), "lib")
|
||||
print(f"Using falsecolor: {falsecolor_bin}")
|
||||
print(f"Radiance bin: {radiance_bin_dir}, lib: {radiance_lib}")
|
||||
|
||||
cmd = [falsecolor_bin]
|
||||
cmd.extend(["-m", str(multiplier)])
|
||||
cmd.extend(["-s", fc_scale])
|
||||
cmd.extend(["-n", str(props.false_color_steps)])
|
||||
cmd.extend(["-l", props.false_color_label])
|
||||
|
||||
if props.false_color_contour_lines:
|
||||
cmd.append("-cl")
|
||||
if props.false_color_contour_mode == "WITH_BG":
|
||||
cmd.extend(["-ip", hdr_path])
|
||||
else:
|
||||
cmd.extend(["-i", hdr_path])
|
||||
else:
|
||||
cmd.extend(["-ip", hdr_path])
|
||||
|
||||
# Setup environment so falsecolor can find pcomb, psign, pcompos etc.
|
||||
env = os.environ.copy()
|
||||
if os.path.exists(radiance_bin_dir):
|
||||
env["PATH"] = radiance_bin_dir + os.pathsep + env.get("PATH", "")
|
||||
if os.path.exists(radiance_lib):
|
||||
env["RAYPATH"] = "." + os.pathsep + radiance_lib
|
||||
|
||||
print(f"Running: {' '.join(cmd)}")
|
||||
# Write output to file via redirection to avoid Windows stdout binary corruption
|
||||
cmd_str = subprocess.list2cmdline(cmd) + f' > "{fc_hdr_path}"'
|
||||
result = subprocess.run(cmd_str, shell=True, stderr=subprocess.PIPE, env=env, cwd=output_dir)
|
||||
if result.returncode != 0:
|
||||
error_msg = result.stderr.decode() if result.stderr else "Unknown error"
|
||||
self.report({"ERROR"}, f"falsecolor failed: {error_msg}")
|
||||
return {"CANCELLED"}
|
||||
|
||||
fc_size = os.path.getsize(fc_hdr_path) if os.path.exists(fc_hdr_path) else 0
|
||||
print(f"False color HDR generated: {fc_hdr_path} ({fc_size} bytes)")
|
||||
self.report({"INFO"}, f"False color image generated: {fc_hdr_path}")
|
||||
|
||||
# Generate TIFF version
|
||||
try:
|
||||
pcond_fc_image = pr.pcond(hdr=fc_hdr_path, human=True)
|
||||
fc_tiff_path = os.path.join(output_dir, f"{fc_output_name}.tiff")
|
||||
pr.ra_tiff(inp=pcond_fc_image, out=fc_tiff_path, lzw=True)
|
||||
print(f"False color TIFF generated: {fc_tiff_path}")
|
||||
self.report({"INFO"}, f"False color TIFF also generated: {fc_tiff_path}")
|
||||
except Exception as e:
|
||||
self.report({"WARNING"}, f"TIFF generation failed: {str(e)}")
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
# Clean up incomplete HDR file on failure
|
||||
if os.path.exists(fc_hdr_path):
|
||||
os.remove(fc_hdr_path)
|
||||
error_msg = f"falsecolor failed: {e.stderr.decode() if e.stderr else str(e)}"
|
||||
self.report({"ERROR"}, error_msg)
|
||||
return {"CANCELLED"}
|
||||
except FileNotFoundError:
|
||||
self.report({"ERROR"}, "falsecolor not found. Please install Radiance and add it to PATH.")
|
||||
return {"CANCELLED"}
|
||||
except Exception as e:
|
||||
self.report({"ERROR"}, f"Failed to generate false color image: {str(e)}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
return {"CANCELLED"}
|
||||
|
||||
|
||||
class RADIANCE_OT_select_camera(bpy.types.Operator):
|
||||
bl_idname = "radiance.select_camera"
|
||||
bl_label = "Select Camera"
|
||||
bl_description = "Select a camera from the viewport"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return context.object is not None and context.object.type == "CAMERA"
|
||||
|
||||
def execute(self, context):
|
||||
props = tool.Blender.get_radiance_exporter_props()
|
||||
props.selected_camera = context.object
|
||||
props.use_active_camera = False
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2020, 2021 Dion Moult <dion@thinkmoult.com>
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""Shared module-level state for the light/radiance pipeline.
|
||||
|
||||
These globals are populated by ExportOBJ, consumed by PrepareRadianceScene,
|
||||
and read by RadianceRender. They must live in a shared module so that
|
||||
all operator files can access the same state.
|
||||
"""
|
||||
|
||||
# Collected IFC material names from the most recent export
|
||||
ifc_materials: list[str] = []
|
||||
|
||||
# The prepared pyradiance Scene object (set by PrepareRadianceScene, read by RadianceRender)
|
||||
scene = None
|
||||
|
||||
# Info about exported linked models: list of (obj_path, mtl_path, link_matrix_4x4)
|
||||
linked_model_exports: list[tuple[str, str, list[list[float]]]] = []
|
||||
@@ -0,0 +1,148 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2020, 2021 Dion Moult <dion@thinkmoult.com>
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import webbrowser
|
||||
from datetime import datetime
|
||||
from math import radians
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import bpy
|
||||
import ifcopenshell.util.geolocation
|
||||
import requests
|
||||
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.module.light.data import SolarData
|
||||
|
||||
|
||||
class ImportTrueNorth(bpy.types.Operator):
|
||||
bl_idname = "bim.import_true_north"
|
||||
bl_label = "Import True North"
|
||||
bl_description = "Imports the True North from your IFC geometric context"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
if not tool.Ifc.get():
|
||||
return False
|
||||
if not SolarData.is_loaded:
|
||||
SolarData.load()
|
||||
return SolarData.data["true_north"] is not None
|
||||
|
||||
def execute(self, context):
|
||||
props = tool.Blender.get_solar_props()
|
||||
for context in tool.Ifc.get().by_type("IfcGeometricRepresentationContext", include_subtypes=False):
|
||||
if not context.TrueNorth:
|
||||
continue
|
||||
value = context.TrueNorth.DirectionRatios
|
||||
props.true_north = radians(ifcopenshell.util.geolocation.yaxis2angle(*value[:2]))
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class ImportLatLong(bpy.types.Operator):
|
||||
bl_idname = "bim.import_lat_long"
|
||||
bl_label = "Import Latitude / Longitude"
|
||||
bl_description = "Imports the latitude / longitude from an IfcSite"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context):
|
||||
props = tool.Blender.get_solar_props()
|
||||
site = tool.Ifc.get().by_id(int(props.sites))
|
||||
if site.RefLatitude and site.RefLongitude:
|
||||
props.latitude = ifcopenshell.util.geolocation.dms2dd(*site.RefLatitude)
|
||||
props.longitude = ifcopenshell.util.geolocation.dms2dd(*site.RefLongitude)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class MoveSunPathTo3DCursor(bpy.types.Operator):
|
||||
bl_idname = "bim.move_sun_path_to_3d_cursor"
|
||||
bl_label = "Move Sun Path To 3D Cursor"
|
||||
bl_description = "Shifts the visualisation of the Sun Path to the 3D cursor"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context):
|
||||
props = tool.Blender.get_solar_props()
|
||||
assert context.scene
|
||||
props.sun_path_origin = context.scene.cursor.location
|
||||
tool.Blender.update_viewport()
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class ViewFromSun(bpy.types.Operator):
|
||||
bl_idname = "bim.view_from_sun"
|
||||
bl_label = "View From Sun"
|
||||
bl_description = "Views your model as if you were looking from the perspective of the sun"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context):
|
||||
if not (camera := bpy.data.objects.get("SunPathCamera")):
|
||||
camera = bpy.data.objects.new("SunPathCamera", bpy.data.cameras.new("SunPathCamera"))
|
||||
assert isinstance(camera.data, bpy.types.Camera)
|
||||
assert context.scene
|
||||
camera.data.type = "ORTHO"
|
||||
camera.data.ortho_scale = 100 # The default of 6m is too small
|
||||
context.scene.collection.objects.link(camera)
|
||||
tool.Blender.activate_camera(camera)
|
||||
props = tool.Blender.get_solar_props()
|
||||
props.hour = props.hour # Just to refresh camera position
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class LightPickCoordinates(bpy.types.Operator):
|
||||
bl_idname = "bim.light_pick_coordinates"
|
||||
bl_label = "Pick Coordinates"
|
||||
bl_description = (
|
||||
"Open web browser with Google Maps to pick coordinates (Right Mouse Click in maps to copy selected location).\n\n"
|
||||
"ALT+Click to insert current location based on the current IP-address (using ip-api.com)."
|
||||
)
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
use_current_location: bpy.props.BoolProperty(options={"SKIP_SAVE"}) # pyright: ignore[reportRedeclaration]
|
||||
|
||||
if TYPE_CHECKING:
|
||||
use_current_location: bool
|
||||
|
||||
def invoke(self, context, event):
|
||||
if event.alt:
|
||||
self.use_current_location = True
|
||||
return self.execute(context)
|
||||
|
||||
def execute(self, context):
|
||||
props = tool.Blender.get_solar_props()
|
||||
if not self.use_current_location:
|
||||
zoom = 13.5
|
||||
url = f"https://www.google.com/maps/@{props.latitude},{props.longitude},{zoom}z"
|
||||
webbrowser.open(url)
|
||||
return {"FINISHED"}
|
||||
|
||||
response = requests.get("http://ip-api.com/json/")
|
||||
data = response.json()
|
||||
props.latitude = data["lat"]
|
||||
props.longitude = data["lon"]
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class LightSetTimeToNow(bpy.types.Operator):
|
||||
bl_idname = "bim.light_set_time_to_now"
|
||||
bl_label = "Now"
|
||||
bl_description = "Set time to current local time."
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def execute(self, context):
|
||||
props = tool.Blender.get_solar_props()
|
||||
props.set_from_datetime(datetime.now())
|
||||
return {"FINISHED"}
|
||||
@@ -22,52 +22,97 @@ from typing import TYPE_CHECKING
|
||||
import bpy
|
||||
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.helper import prop_with_search
|
||||
from bonsai.bim.module.light.data import SolarData
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Root panel (replaces the old "Radiance Exporter" nested panel)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class BIM_PT_radiance_exporter(bpy.types.Panel):
|
||||
"""Creates a Panel in the render properties window"""
|
||||
|
||||
bl_label = "Radiance Exporter"
|
||||
bl_idname = "BIM_PT_radiance_exporter"
|
||||
bl_options = {"DEFAULT_CLOSED"}
|
||||
bl_space_type = "PROPERTIES"
|
||||
bl_region_type = "WINDOW"
|
||||
bl_context = "scene"
|
||||
bl_parent_id = "BIM_PT_tab_lighting"
|
||||
bl_options = {"HIDE_HEADER"}
|
||||
|
||||
def draw(self, context):
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Scene Setup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class BIM_PT_radiance_scene_setup(bpy.types.Panel):
|
||||
bl_label = "Scene Setup"
|
||||
bl_idname = "BIM_PT_radiance_scene_setup"
|
||||
bl_space_type = "PROPERTIES"
|
||||
bl_region_type = "WINDOW"
|
||||
bl_context = "scene"
|
||||
bl_parent_id = "BIM_PT_radiance_exporter"
|
||||
|
||||
def draw(self, context):
|
||||
assert self.layout
|
||||
layout = self.layout
|
||||
props = tool.Blender.get_radiance_exporter_props()
|
||||
|
||||
if tool.Ifc.get():
|
||||
row = self.layout.row()
|
||||
row.prop(props, "should_load_from_memory")
|
||||
|
||||
if not tool.Ifc.get() or not props.should_load_from_memory:
|
||||
row = self.layout.row(align=True)
|
||||
row.prop(props, "ifc_file")
|
||||
|
||||
row = layout.row()
|
||||
row.prop(props, "output_dir")
|
||||
|
||||
layout.separator()
|
||||
|
||||
row = layout.row()
|
||||
layout.label(text="Info: Unmapped materials default to white")
|
||||
row.prop(props, "use_active_camera")
|
||||
if not props.use_active_camera:
|
||||
row = layout.row()
|
||||
row.prop(props, "selected_camera")
|
||||
row.operator("radiance.select_camera", text="", icon="EYEDROPPER")
|
||||
|
||||
row = layout.row(align=True)
|
||||
row.label(text="Resolution")
|
||||
row.prop(props, "radiance_resolution_x", text="X")
|
||||
row.prop(props, "radiance_resolution_y", text="Y")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Materials
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class BIM_PT_radiance_materials(bpy.types.Panel):
|
||||
bl_label = "Materials"
|
||||
bl_idname = "BIM_PT_radiance_materials"
|
||||
bl_space_type = "PROPERTIES"
|
||||
bl_region_type = "WINDOW"
|
||||
bl_context = "scene"
|
||||
bl_parent_id = "BIM_PT_radiance_exporter"
|
||||
bl_options = {"DEFAULT_CLOSED"}
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
props = tool.Blender.get_radiance_exporter_props()
|
||||
|
||||
layout.label(text="Unmapped materials default to white", icon="INFO")
|
||||
|
||||
row = layout.row()
|
||||
row.template_list("MATERIAL_UL_radiance_materials", "", props, "materials", props, "active_material_index")
|
||||
row.operator("radiance.open_spectraldb", text="", icon="WORLD") # Globe icon
|
||||
row.operator("radiance.open_spectraldb", text="", icon="WORLD")
|
||||
|
||||
if len(props.materials) > 0:
|
||||
col = layout.column(align=True)
|
||||
col.prop(props, "category")
|
||||
prop_with_search(col, props, "category")
|
||||
if props.category:
|
||||
col.prop(props, "subcategory")
|
||||
prop_with_search(col, props, "subcategory")
|
||||
|
||||
if props.active_material_index >= 0 and props.active_material_index < len(props.materials):
|
||||
if 0 <= props.active_material_index < len(props.materials):
|
||||
active_material = props.materials[props.active_material_index]
|
||||
if active_material.category and active_material.subcategory:
|
||||
layout.label(
|
||||
text=f"Mapped: {active_material.name} to {active_material.category} - {active_material.subcategory}"
|
||||
text=f"Mapped: {active_material.name} -> {active_material.category} - {active_material.subcategory}"
|
||||
)
|
||||
else:
|
||||
layout.label(text=f"Select category and subcategory for: {active_material.name}")
|
||||
@@ -78,28 +123,95 @@ class BIM_PT_radiance_exporter(bpy.types.Panel):
|
||||
row = layout.row()
|
||||
row.operator("bim.refresh_ifc_materials", text="Refresh IFC Materials")
|
||||
|
||||
layout.separator()
|
||||
|
||||
row = layout.row()
|
||||
layout.label(text="Step 1: Export geometry for simulation")
|
||||
row = layout.row()
|
||||
row.operator("export_scene.radiance", text="Export Geometry for Simulation")
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Lighting (Environment + IES)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
layout.separator()
|
||||
|
||||
class BIM_PT_radiance_lighting(bpy.types.Panel):
|
||||
bl_label = "Lighting"
|
||||
bl_idname = "BIM_PT_radiance_lighting"
|
||||
bl_space_type = "PROPERTIES"
|
||||
bl_region_type = "WINDOW"
|
||||
bl_context = "scene"
|
||||
bl_parent_id = "BIM_PT_radiance_exporter"
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
props = tool.Blender.get_radiance_exporter_props()
|
||||
|
||||
# Environment
|
||||
box = layout.box()
|
||||
box.label(text="Camera Settings")
|
||||
box.label(text="Environment", icon="WORLD")
|
||||
row = box.row()
|
||||
row.prop(props, "use_active_camera")
|
||||
if not props.use_active_camera:
|
||||
row.prop(props, "use_hdr")
|
||||
row = box.row()
|
||||
row.prop(props, "use_sun")
|
||||
if props.use_sun:
|
||||
box.prop(props, "sky_condition")
|
||||
row = box.row()
|
||||
row.prop(props, "selected_camera")
|
||||
row.operator("radiance.select_camera", text="", icon="EYEDROPPER")
|
||||
row.prop(props, "ground_reflectance")
|
||||
row = box.row()
|
||||
row.prop(props, "turbidity")
|
||||
|
||||
row = box.row(align=True)
|
||||
row.label(text="Resolution")
|
||||
row.prop(props, "radiance_resolution_x", text="X")
|
||||
row.prop(props, "radiance_resolution_y", text="Y")
|
||||
layout.separator()
|
||||
|
||||
# IES Light Fixtures
|
||||
box = layout.box()
|
||||
box.label(text="IES Light Fixtures", icon="LIGHT_POINT")
|
||||
row = box.row()
|
||||
row.template_list("MATERIAL_UL_ies_lights", "", props, "ies_lights", props, "active_ies_light_index")
|
||||
col = row.column(align=True)
|
||||
col.operator("radiance.add_ies_light", text="", icon="ADD")
|
||||
|
||||
if len(props.ies_lights) > 0 and 0 <= props.active_ies_light_index < len(props.ies_lights):
|
||||
active_light = props.ies_lights[props.active_ies_light_index]
|
||||
|
||||
col = box.column(align=True)
|
||||
|
||||
row = col.row()
|
||||
row.prop(active_light, "use_collection", text="Target Collection", icon="OUTLINER_COLLECTION")
|
||||
|
||||
row = col.row()
|
||||
if active_light.use_collection:
|
||||
row.prop(active_light, "target_collection", text="Collection")
|
||||
if active_light.target_collection:
|
||||
empties = [o for o in active_light.target_collection.all_objects if o.type == "EMPTY"]
|
||||
row = col.row()
|
||||
row.label(text=f"{len(empties)} empty object(s) in collection", icon="INFO")
|
||||
else:
|
||||
row.prop(active_light, "target_object", text="Object")
|
||||
|
||||
row = col.row()
|
||||
row.label(text="Rotation Z")
|
||||
row.prop(active_light, "rotation_z", text="")
|
||||
|
||||
row = col.row()
|
||||
row.prop(active_light, "lamp_color")
|
||||
|
||||
split = col.split(factor=0.5)
|
||||
split.prop(active_light, "multiply_factor")
|
||||
split.prop(active_light, "radius")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Render Settings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class BIM_PT_radiance_render_settings(bpy.types.Panel):
|
||||
bl_label = "Render Settings"
|
||||
bl_idname = "BIM_PT_radiance_render_settings"
|
||||
bl_space_type = "PROPERTIES"
|
||||
bl_region_type = "WINDOW"
|
||||
bl_context = "scene"
|
||||
bl_parent_id = "BIM_PT_radiance_exporter"
|
||||
bl_options = {"DEFAULT_CLOSED"}
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
props = tool.Blender.get_radiance_exporter_props()
|
||||
|
||||
row = layout.row()
|
||||
row.prop(props, "radiance_quality")
|
||||
@@ -110,6 +222,9 @@ class BIM_PT_radiance_exporter(bpy.types.Panel):
|
||||
row = layout.row()
|
||||
row.prop(props, "radiance_variability")
|
||||
|
||||
row = layout.row()
|
||||
row.prop(props, "ambient_bounces")
|
||||
|
||||
layout.separator()
|
||||
|
||||
row = layout.row()
|
||||
@@ -117,20 +232,94 @@ class BIM_PT_radiance_exporter(bpy.types.Panel):
|
||||
|
||||
row = layout.row()
|
||||
row.prop(props, "output_file_format")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Pipeline (Steps 1-4 + Cleanup)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class BIM_PT_radiance_pipeline(bpy.types.Panel):
|
||||
bl_label = "Pipeline"
|
||||
bl_idname = "BIM_PT_radiance_pipeline"
|
||||
bl_space_type = "PROPERTIES"
|
||||
bl_region_type = "WINDOW"
|
||||
bl_context = "scene"
|
||||
bl_parent_id = "BIM_PT_radiance_exporter"
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
props = tool.Blender.get_radiance_exporter_props()
|
||||
|
||||
# Step 1
|
||||
box = layout.box()
|
||||
row = box.row()
|
||||
row.label(text="Step 1: Export Geometry")
|
||||
row = box.row()
|
||||
if props.is_exporting:
|
||||
row.label(text="Exporting...", icon="SORTTIME")
|
||||
else:
|
||||
row.operator("export_scene.radiance", text="Export Geometry")
|
||||
|
||||
# Step 2
|
||||
box = layout.box()
|
||||
row = box.row()
|
||||
row.label(text="Step 2: Prepare Scene")
|
||||
row = box.row()
|
||||
if props.is_preparing:
|
||||
row.label(text="Preparing scene...", icon="SORTTIME")
|
||||
else:
|
||||
row.operator("scene.prepare_radiance", text="Prepare Scene")
|
||||
|
||||
# Step 3
|
||||
box = layout.box()
|
||||
row = box.row()
|
||||
row.label(text="Step 3: Render")
|
||||
row = box.row()
|
||||
if props.is_rendering:
|
||||
row.label(text="Rendering...", icon="RENDER_STILL")
|
||||
else:
|
||||
row.operator("render_scene.radiance", text="Radiance Render")
|
||||
|
||||
# Step 4: False Color
|
||||
box = layout.box()
|
||||
row = box.row()
|
||||
row.label(text="Step 4: False Color Analysis")
|
||||
|
||||
row = box.row()
|
||||
row.prop(props, "radiance_bin_dir")
|
||||
|
||||
row = box.row()
|
||||
row.prop(props, "false_color_label")
|
||||
|
||||
split = box.split(factor=0.5)
|
||||
split.prop(props, "false_color_scale")
|
||||
split.prop(props, "false_color_steps")
|
||||
|
||||
row = box.row()
|
||||
row.label(text="Output Name")
|
||||
row.prop(props, "false_color_output_name", text="")
|
||||
|
||||
row = box.row()
|
||||
row.prop(props, "false_color_contour_lines")
|
||||
|
||||
if props.false_color_contour_lines:
|
||||
row = box.row()
|
||||
row.prop(props, "false_color_contour_mode")
|
||||
|
||||
row = box.row()
|
||||
row.operator("render_scene.false_color_radiance", text="Generate False Color Image")
|
||||
|
||||
layout.separator()
|
||||
|
||||
# Cleanup
|
||||
row = layout.row()
|
||||
row.prop(props, "use_hdr")
|
||||
row.operator("radiance.cleanup_files", text="Cleanup Generated Files", icon="TRASH")
|
||||
|
||||
if props.use_hdr:
|
||||
row = layout.row()
|
||||
row.prop(props, "choose_hdr_image")
|
||||
|
||||
row = layout.row()
|
||||
layout.label(text="Step 2: Run the simulation")
|
||||
row = layout.row()
|
||||
row.operator("render_scene.radiance", text="Radiance Render")
|
||||
row.enabled = not props.is_exporting
|
||||
# ---------------------------------------------------------------------------
|
||||
# Solar Panel (unchanged)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class BIM_PT_solar(bpy.types.Panel):
|
||||
@@ -248,7 +437,10 @@ class BIM_PT_solar(bpy.types.Panel):
|
||||
row = self.layout.row()
|
||||
sun_props = tool.Blender.get_sun_props()
|
||||
assert sun_props
|
||||
row.prop(sun_props.sun_object.data, "energy", text="Sun Intensity")
|
||||
if sun_props.sun_object is not None and sun_props.sun_object.data is not None:
|
||||
row.prop(sun_props.sun_object.data, "energy", text="Sun Intensity")
|
||||
else:
|
||||
row.label(text="Sun object not found. Toggle shadow mode to recreate.", icon="ERROR")
|
||||
|
||||
row = self.layout.row(align=True)
|
||||
row.operator("bim.view_from_sun", icon="LIGHT_HEMI")
|
||||
|
||||
@@ -136,7 +136,7 @@ class SplitAlongEdge(bpy.types.Operator, tool.Ifc.Operator):
|
||||
"Will unassign element from a type if type has a representation."
|
||||
)
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
mode: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
|
||||
mode: bpy.props.EnumProperty(
|
||||
default="BOOLEAN",
|
||||
items=tuple((i, i, "") for i in get_args(SplitAlongEdgeMode)),
|
||||
)
|
||||
@@ -359,7 +359,7 @@ class ConfirmQuickFavoriteOperator(bpy.types.Operator):
|
||||
bl_idname = "bim.confirm_quick_favorite_operator"
|
||||
bl_label = "Confirm Operator"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
index: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
|
||||
index: bpy.props.IntProperty()
|
||||
|
||||
if TYPE_CHECKING:
|
||||
index: int
|
||||
@@ -452,10 +452,8 @@ class MoveQuickFavoritesItem(bpy.types.Operator):
|
||||
bl_idname = "bim.move_quick_favorites_item"
|
||||
bl_label = "Move Quick Favorites Item"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
index: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
|
||||
direction: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
|
||||
items=[("UP", "Up", ""), ("DOWN", "Down", "")]
|
||||
)
|
||||
index: bpy.props.IntProperty()
|
||||
direction: bpy.props.EnumProperty(items=[("UP", "Up", ""), ("DOWN", "Down", "")])
|
||||
|
||||
if TYPE_CHECKING:
|
||||
index: int
|
||||
@@ -474,7 +472,7 @@ class RemoveQuickFavoritesItem(bpy.types.Operator):
|
||||
bl_idname = "bim.remove_quick_favorites_item"
|
||||
bl_label = "Remove Quick Favorites Item"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
index: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
|
||||
index: bpy.props.IntProperty()
|
||||
|
||||
if TYPE_CHECKING:
|
||||
index: int
|
||||
|
||||
@@ -36,9 +36,9 @@ QuickFavoriteValueType = Literal["float_value", "bool_value", "int_value", "stri
|
||||
|
||||
|
||||
class QuickFavoriteEnumItem(PropertyGroup):
|
||||
name: StringProperty(name="Name", default="") # pyright: ignore[reportRedeclaration]
|
||||
display_name: StringProperty(name="Display Name", default="") # pyright: ignore[reportRedeclaration]
|
||||
description: StringProperty(name="Description", default="") # pyright: ignore[reportRedeclaration]
|
||||
name: StringProperty(name="Name", default="")
|
||||
display_name: StringProperty(name="Display Name", default="")
|
||||
description: StringProperty(name="Description", default="")
|
||||
|
||||
if TYPE_CHECKING:
|
||||
name: str
|
||||
@@ -51,19 +51,19 @@ def get_enum_items(self: "QuickFavoriteProperty", context: bpy.types.Context | N
|
||||
|
||||
|
||||
class QuickFavoriteProperty(PropertyGroup):
|
||||
name: StringProperty(name="Name", default="") # pyright: ignore[reportRedeclaration]
|
||||
display_name: StringProperty(name="Display Name", default="") # pyright: ignore[reportRedeclaration]
|
||||
value_prop: EnumProperty( # pyright: ignore[reportRedeclaration]
|
||||
name: StringProperty(name="Name", default="")
|
||||
display_name: StringProperty(name="Display Name", default="")
|
||||
value_prop: EnumProperty(
|
||||
name="Value Prop",
|
||||
items=tuple((v, v, "") for v in get_args(QuickFavoriteValueType)),
|
||||
)
|
||||
string_value: StringProperty(name="String Value", default="") # pyright: ignore[reportRedeclaration]
|
||||
float_value: FloatProperty(name="Float Value", default=0.0) # pyright: ignore[reportRedeclaration]
|
||||
int_value: IntProperty(name="Int Value", default=0) # pyright: ignore[reportRedeclaration]
|
||||
bool_value: BoolProperty(name="Bool Value", default=False) # pyright: ignore[reportRedeclaration]
|
||||
enum_value: EnumProperty(name="Enum Value", items=get_enum_items) # pyright: ignore[reportRedeclaration]
|
||||
enum_items: CollectionProperty(type=QuickFavoriteEnumItem) # pyright: ignore[reportRedeclaration]
|
||||
is_active: BoolProperty( # pyright: ignore[reportRedeclaration]
|
||||
string_value: StringProperty(name="String Value", default="")
|
||||
float_value: FloatProperty(name="Float Value", default=0.0)
|
||||
int_value: IntProperty(name="Int Value", default=0)
|
||||
bool_value: BoolProperty(name="Bool Value", default=False)
|
||||
enum_value: EnumProperty(name="Enum Value", items=get_enum_items)
|
||||
enum_items: CollectionProperty(type=QuickFavoriteEnumItem)
|
||||
is_active: BoolProperty(
|
||||
name="Is Active",
|
||||
description="Only active properties will be added to the operator when invoked from Quick Favorites",
|
||||
default=False,
|
||||
@@ -100,20 +100,20 @@ def get_operator_suggestions(self: "QuickFavoritesItem", context: bpy.types.Cont
|
||||
|
||||
|
||||
class QuickFavoritesItem(PropertyGroup):
|
||||
is_expanded: BoolProperty(name="Is Expanded", default=False) # pyright: ignore[reportRedeclaration]
|
||||
search: StringProperty( # pyright: ignore[reportRedeclaration]
|
||||
is_expanded: BoolProperty(name="Is Expanded", default=False)
|
||||
search: StringProperty(
|
||||
name="Search",
|
||||
default="",
|
||||
search=get_operator_suggestions,
|
||||
# Resetting `search_options`, allowing users only to use suggestions.
|
||||
search_options=set(),
|
||||
)
|
||||
properties: CollectionProperty(type=QuickFavoriteProperty) # pyright: ignore[reportRedeclaration]
|
||||
operator_id: StringProperty( # pyright: ignore[reportRedeclaration]
|
||||
properties: CollectionProperty(type=QuickFavoriteProperty)
|
||||
operator_id: StringProperty(
|
||||
name="Operator ID",
|
||||
default="",
|
||||
)
|
||||
label: StringProperty( # pyright: ignore[reportRedeclaration]
|
||||
label: StringProperty(
|
||||
name="Label",
|
||||
description="Label that will be used in Quick Favorites for this operator",
|
||||
default="",
|
||||
@@ -139,15 +139,15 @@ class QuickFavoritesItem(PropertyGroup):
|
||||
|
||||
|
||||
class BIMMiscProperties(PropertyGroup):
|
||||
total_storeys: IntProperty( # pyright: ignore[reportRedeclaration]
|
||||
total_storeys: IntProperty(
|
||||
name="Total Storeys",
|
||||
description="Number of storeys above object's storey to take into account for resizing",
|
||||
default=1,
|
||||
)
|
||||
override_colour: FloatVectorProperty( # pyright: ignore[reportRedeclaration]
|
||||
override_colour: FloatVectorProperty(
|
||||
name="Override Colour", subtype="COLOR", default=(1, 0, 0, 1), min=0.0, max=1.0, size=4
|
||||
)
|
||||
quick_favorites: CollectionProperty(type=QuickFavoritesItem) # pyright: ignore[reportRedeclaration]
|
||||
quick_favorites: CollectionProperty(type=QuickFavoritesItem)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
total_storeys: int
|
||||
|
||||
@@ -545,7 +545,7 @@ class ChangeTypePage(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.change_type_page"
|
||||
bl_label = "Change Type Page"
|
||||
bl_options = {"REGISTER"}
|
||||
page: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
|
||||
page: bpy.props.IntProperty()
|
||||
|
||||
if TYPE_CHECKING:
|
||||
page: int
|
||||
|
||||
@@ -271,7 +271,7 @@ 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.EnumProperty( # pyright: ignore[reportRedeclaration]
|
||||
join_type: bpy.props.EnumProperty(
|
||||
items=[("-", "Unjoin", ""), ("L", "L", ""), ("V", "V", ""), ("T", "T", "")],
|
||||
default="-",
|
||||
)
|
||||
|
||||
@@ -1729,20 +1729,20 @@ def poll_sverchok_nodes(self: "BIMExternalParametricGeometryProperties", node_tr
|
||||
|
||||
|
||||
class BIMExternalParametricGeometryProperties(bpy.types.PropertyGroup):
|
||||
is_editing: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
|
||||
is_editing: bpy.props.BoolProperty(
|
||||
name="Is Editing Paramteric Geometry",
|
||||
description="Toggle editing parametric geometry.",
|
||||
default=False,
|
||||
update=update_is_editing,
|
||||
)
|
||||
geometry_source: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
|
||||
geometry_source: bpy.props.EnumProperty(
|
||||
name="Geometry Source",
|
||||
items=[
|
||||
("GEONODES", "Geometry Nodes", ""),
|
||||
("IFCSVERCHOK", "IFC Sverchok", ""),
|
||||
],
|
||||
)
|
||||
geo_nodes: bpy.props.PointerProperty( # pyright: ignore[reportRedeclaration]
|
||||
geo_nodes: bpy.props.PointerProperty(
|
||||
name="Geometry Nodes",
|
||||
description="Geometry nodes tree to use as a source for representation.",
|
||||
type=bpy.types.GeometryNodeTree,
|
||||
@@ -1750,7 +1750,7 @@ class BIMExternalParametricGeometryProperties(bpy.types.PropertyGroup):
|
||||
poll=lambda self, node_tree: not node_tree.name.startswith("BBIM_EPG"),
|
||||
)
|
||||
|
||||
sverchok_nodes: bpy.props.PointerProperty( # pyright: ignore[reportRedeclaration]
|
||||
sverchok_nodes: bpy.props.PointerProperty(
|
||||
name="Sverchok Nodes",
|
||||
description="Sverchok node tree to use as a source for representation.",
|
||||
type=bpy.types.NodeTree,
|
||||
|
||||
@@ -101,7 +101,7 @@ class NestDecorator:
|
||||
cls.is_installed = False
|
||||
|
||||
def dotted_line_shader(self):
|
||||
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") # ty:ignore[too-many-positional-arguments]
|
||||
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface")
|
||||
vert_out.smooth("FLOAT", "v_ArcLength")
|
||||
|
||||
shader_info = gpu.types.GPUShaderCreateInfo()
|
||||
|
||||
@@ -33,7 +33,7 @@ class EnableEditingPerson(bpy.types.Operator):
|
||||
bl_idname = "bim.enable_editing_person"
|
||||
bl_label = "Enable Editing Person"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
person: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
|
||||
person: bpy.props.IntProperty()
|
||||
|
||||
if TYPE_CHECKING:
|
||||
person: int
|
||||
@@ -75,7 +75,7 @@ class RemovePerson(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.remove_person"
|
||||
bl_label = "Remove Person"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
person: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
|
||||
person: bpy.props.IntProperty()
|
||||
|
||||
if TYPE_CHECKING:
|
||||
person: int
|
||||
@@ -88,7 +88,7 @@ class AddPersonAttribute(bpy.types.Operator):
|
||||
bl_idname = "bim.add_person_attribute"
|
||||
bl_label = "Add Person Attribute"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
name: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
|
||||
name: bpy.props.EnumProperty(
|
||||
items=tuple((i, i, "") for i in get_args(tool.Owner.PersonAttributeType)),
|
||||
)
|
||||
|
||||
@@ -104,10 +104,10 @@ class RemovePersonAttribute(bpy.types.Operator):
|
||||
bl_idname = "bim.remove_person_attribute"
|
||||
bl_label = "Remove Person Attribute"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
name: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
|
||||
name: bpy.props.EnumProperty(
|
||||
items=tuple((i, i, "") for i in get_args(tool.Owner.PersonAttributeType)),
|
||||
)
|
||||
id: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
|
||||
id: bpy.props.IntProperty()
|
||||
|
||||
if TYPE_CHECKING:
|
||||
name: tool.Owner.PersonAttributeType # pyright: ignore[reportIncompatibleVariableOverride]
|
||||
@@ -122,7 +122,7 @@ class EnableEditingRole(bpy.types.Operator):
|
||||
bl_idname = "bim.enable_editing_role"
|
||||
bl_label = "Enable Editing Role"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
role: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
|
||||
role: bpy.props.IntProperty()
|
||||
|
||||
if TYPE_CHECKING:
|
||||
role: int
|
||||
@@ -146,7 +146,7 @@ class AddRole(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.add_role"
|
||||
bl_label = "Add Role"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
parent: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
|
||||
parent: bpy.props.IntProperty()
|
||||
|
||||
if TYPE_CHECKING:
|
||||
parent: int
|
||||
@@ -168,7 +168,7 @@ class RemoveRole(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.remove_role"
|
||||
bl_label = "Remove Role"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
role: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
|
||||
role: bpy.props.IntProperty()
|
||||
|
||||
if TYPE_CHECKING:
|
||||
role: int
|
||||
@@ -181,8 +181,8 @@ class AddAddress(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.add_address"
|
||||
bl_label = "Add Address"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
parent: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
|
||||
ifc_class: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
|
||||
parent: bpy.props.IntProperty()
|
||||
ifc_class: bpy.props.EnumProperty(
|
||||
items=tuple((i, i, "") for i in get_args(ADDRESS_TYPE)),
|
||||
)
|
||||
|
||||
@@ -198,7 +198,7 @@ class AddAddressAttribute(bpy.types.Operator):
|
||||
bl_idname = "bim.add_address_attribute"
|
||||
bl_label = "Add Address Attribute"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
name: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
|
||||
name: bpy.props.EnumProperty(
|
||||
items=tuple((i, i, "") for i in get_args(tool.Owner.AddressAttributeType)),
|
||||
)
|
||||
|
||||
@@ -214,10 +214,10 @@ class RemoveAddressAttribute(bpy.types.Operator):
|
||||
bl_idname = "bim.remove_address_attribute"
|
||||
bl_label = "Remove Address Attribute"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
name: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
|
||||
name: bpy.props.EnumProperty(
|
||||
items=tuple((i, i, "") for i in get_args(tool.Owner.AddressAttributeType)),
|
||||
)
|
||||
id: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
|
||||
id: bpy.props.IntProperty()
|
||||
|
||||
if TYPE_CHECKING:
|
||||
name: tool.Owner.AddressAttributeType # pyright: ignore[reportIncompatibleVariableOverride]
|
||||
@@ -232,7 +232,7 @@ class EnableEditingAddress(bpy.types.Operator):
|
||||
bl_idname = "bim.enable_editing_address"
|
||||
bl_label = "Enable Editing Address"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
address: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
|
||||
address: bpy.props.IntProperty()
|
||||
|
||||
if TYPE_CHECKING:
|
||||
address: int
|
||||
@@ -265,7 +265,7 @@ class RemoveAddress(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.remove_address"
|
||||
bl_label = "Remove Address"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
address: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
|
||||
address: bpy.props.IntProperty()
|
||||
|
||||
if TYPE_CHECKING:
|
||||
address: int
|
||||
@@ -278,7 +278,7 @@ class EnableEditingOrganisation(bpy.types.Operator):
|
||||
bl_idname = "bim.enable_editing_organisation"
|
||||
bl_label = "Enable Editing Organisation"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
organisation: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
|
||||
organisation: bpy.props.IntProperty()
|
||||
|
||||
if TYPE_CHECKING:
|
||||
organisation: int
|
||||
@@ -320,7 +320,7 @@ class RemoveOrganisation(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.remove_organisation"
|
||||
bl_label = "Remove Organisation"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
organisation: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
|
||||
organisation: bpy.props.IntProperty()
|
||||
|
||||
if TYPE_CHECKING:
|
||||
organisation: int
|
||||
@@ -333,8 +333,8 @@ class AddPersonAndOrganisation(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.add_person_and_organisation"
|
||||
bl_label = "Add Person And Organisation"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
person: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
|
||||
organisation: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
|
||||
person: bpy.props.IntProperty()
|
||||
organisation: bpy.props.IntProperty()
|
||||
|
||||
if TYPE_CHECKING:
|
||||
person: int
|
||||
@@ -350,7 +350,7 @@ class RemovePersonAndOrganisation(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.remove_person_and_organisation"
|
||||
bl_label = "Remove Person And Organisation"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
person_and_organisation: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
|
||||
person_and_organisation: bpy.props.IntProperty()
|
||||
|
||||
if TYPE_CHECKING:
|
||||
person_and_organisation: int
|
||||
@@ -365,7 +365,7 @@ class SetUser(bpy.types.Operator):
|
||||
bl_idname = "bim.set_user"
|
||||
bl_label = "Set User"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
user: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
|
||||
user: bpy.props.IntProperty()
|
||||
|
||||
if TYPE_CHECKING:
|
||||
user: int
|
||||
@@ -401,7 +401,7 @@ class EnableEditingActor(bpy.types.Operator):
|
||||
bl_idname = "bim.enable_editing_actor"
|
||||
bl_label = "Enable Editing Actor"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
actor: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
|
||||
actor: bpy.props.IntProperty()
|
||||
|
||||
if TYPE_CHECKING:
|
||||
actor: int
|
||||
@@ -434,7 +434,7 @@ class RemoveActor(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.remove_actor"
|
||||
bl_label = "Remove Actor"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
actor: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
|
||||
actor: bpy.props.IntProperty()
|
||||
|
||||
if TYPE_CHECKING:
|
||||
actor: int
|
||||
@@ -447,7 +447,7 @@ class AssignActor(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.assign_actor"
|
||||
bl_label = "Assign Actor"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
actor: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
|
||||
actor: bpy.props.IntProperty()
|
||||
|
||||
if TYPE_CHECKING:
|
||||
actor: int
|
||||
@@ -462,7 +462,7 @@ class UnassignActor(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.unassign_actor"
|
||||
bl_label = "Unassign Actor"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
actor: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
|
||||
actor: bpy.props.IntProperty()
|
||||
|
||||
if TYPE_CHECKING:
|
||||
actor: int
|
||||
@@ -481,7 +481,7 @@ class RemoveApplication(bpy.types.Operator, tool.Ifc.Operator):
|
||||
"Remove provided IfcApplication."
|
||||
"\n\nFor safety will only work on applications without inverses (they are typically marked as '(unused)'."
|
||||
)
|
||||
application_id: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
|
||||
application_id: bpy.props.IntProperty()
|
||||
|
||||
if TYPE_CHECKING:
|
||||
application_id: int
|
||||
@@ -525,7 +525,7 @@ class EnableEditingApplication(bpy.types.Operator):
|
||||
bl_idname = "bim.enable_editing_application"
|
||||
bl_label = "Enable Editing Application"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
application_id: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
|
||||
application_id: bpy.props.IntProperty()
|
||||
|
||||
if TYPE_CHECKING:
|
||||
application_id: int
|
||||
|
||||
@@ -86,9 +86,7 @@ class NewProject(bpy.types.Operator):
|
||||
bl_label = "New Project"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
bl_description = "Start a new IFC project in a fresh session"
|
||||
preset: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
|
||||
items=[(i, i, "") for i in get_args(PresetType)]
|
||||
)
|
||||
preset: bpy.props.EnumProperty(items=[(i, i, "") for i in get_args(PresetType)])
|
||||
|
||||
if TYPE_CHECKING:
|
||||
preset: PresetType
|
||||
@@ -178,13 +176,9 @@ class SelectLibraryFile(bpy.types.Operator, IFCFileSelector, ImportHelper):
|
||||
bl_description = (
|
||||
"Select an IFC file that can be used as a library.\n\nALT+click to reload the current loaded library file."
|
||||
)
|
||||
filter_glob: bpy.props.StringProperty(
|
||||
default="*.ifc;*.ifczip;*.ifcxml", options={"HIDDEN"}
|
||||
) # pyright: ignore[reportRedeclaration]
|
||||
append_all: bpy.props.BoolProperty(default=False) # pyright: ignore[reportRedeclaration]
|
||||
use_relative_path: bpy.props.BoolProperty(
|
||||
name="Use Relative Path", default=False
|
||||
) # pyright: ignore[reportRedeclaration]
|
||||
filter_glob: bpy.props.StringProperty(default="*.ifc;*.ifczip;*.ifcxml", options={"HIDDEN"})
|
||||
append_all: bpy.props.BoolProperty(default=False)
|
||||
use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=False)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
filter_glob: str
|
||||
@@ -568,7 +562,7 @@ class AppendLibraryElementByQuery(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.append_library_element_by_query"
|
||||
bl_label = "Append Library Element By Query"
|
||||
|
||||
query: bpy.props.StringProperty(name="Query") # pyright: ignore[reportRedeclaration]
|
||||
query: bpy.props.StringProperty(name="Query")
|
||||
|
||||
if TYPE_CHECKING:
|
||||
query: str
|
||||
@@ -600,11 +594,9 @@ class AppendLibraryElement(bpy.types.Operator, tool.Ifc.Operator):
|
||||
"Append element to the current project.\n\n"
|
||||
"ALT+CLICK to skip reusing materials, profiles, styles based on their name (may result in duplicates)"
|
||||
)
|
||||
definition: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
|
||||
prop_index: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
|
||||
assume_unique_by_name: bpy.props.BoolProperty(
|
||||
name="Assume Unique By Name", default=True, options={"SKIP_SAVE"}
|
||||
) # pyright: ignore[reportRedeclaration]
|
||||
definition: bpy.props.IntProperty()
|
||||
prop_index: bpy.props.IntProperty()
|
||||
assume_unique_by_name: bpy.props.BoolProperty(name="Assume Unique By Name", default=True, options={"SKIP_SAVE"})
|
||||
|
||||
if TYPE_CHECKING:
|
||||
definition: int
|
||||
@@ -959,28 +951,24 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
|
||||
bl_label = "Load Project"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
bl_description = "Load an existing IFC project"
|
||||
filepath: bpy.props.StringProperty(
|
||||
subtype="FILE_PATH", options={"SKIP_SAVE"}
|
||||
) # pyright: ignore[reportRedeclaration]
|
||||
filter_glob: bpy.props.StringProperty(
|
||||
default="*.ifc;*.ifczip;*.ifcxml;*.ifcsqlite", options={"HIDDEN"}
|
||||
) # pyright: ignore[reportRedeclaration]
|
||||
is_advanced: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
|
||||
filepath: bpy.props.StringProperty(subtype="FILE_PATH", options={"SKIP_SAVE"})
|
||||
filter_glob: bpy.props.StringProperty(default="*.ifc;*.ifczip;*.ifcxml;*.ifcsqlite", options={"HIDDEN"})
|
||||
is_advanced: bpy.props.BoolProperty(
|
||||
name="Enable Advanced Mode",
|
||||
description="Load IFC file with advanced settings. Checking this option will skip loading IFC file and will open advanced load settings",
|
||||
default=False,
|
||||
)
|
||||
use_relative_path: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
|
||||
use_relative_path: bpy.props.BoolProperty(
|
||||
name="Use Relative Path",
|
||||
description="Store the IFC project path relative to the .blend file. Requires .blend file to be saved",
|
||||
default=False,
|
||||
)
|
||||
should_start_fresh_session: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
|
||||
should_start_fresh_session: bpy.props.BoolProperty(
|
||||
name="Should Start Fresh Session",
|
||||
description="Clear current Blender session before loading IFC. Not supported with 'Use Relative Path' option",
|
||||
default=True,
|
||||
)
|
||||
import_without_ifc_data: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
|
||||
import_without_ifc_data: bpy.props.BoolProperty(
|
||||
name="Import Without IFC Data",
|
||||
description=(
|
||||
"Import IFC objects as Blender objects without any IFC metadata and authoring capabilities."
|
||||
@@ -988,9 +976,7 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
|
||||
),
|
||||
default=False,
|
||||
)
|
||||
use_detailed_tooltip: bpy.props.BoolProperty(
|
||||
default=False, options={"HIDDEN"}
|
||||
) # pyright: ignore[reportRedeclaration]
|
||||
use_detailed_tooltip: bpy.props.BoolProperty(default=False, options={"HIDDEN"})
|
||||
filename_ext = ".ifc"
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -1300,7 +1286,7 @@ class ToggleFilterCategories(bpy.types.Operator):
|
||||
bl_idname = "bim.toggle_filter_categories"
|
||||
bl_label = "Toggle Filter Categories"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
should_select: bpy.props.BoolProperty(name="Should Select", default=True) # pyright: ignore[reportRedeclaration]
|
||||
should_select: bpy.props.BoolProperty(name="Should Select", default=True)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
should_select: bool
|
||||
@@ -1327,7 +1313,7 @@ class LinkIfc(bpy.types.Operator, ImportHelper, tool.Ifc.Operator):
|
||||
default=False,
|
||||
)
|
||||
use_cache: bpy.props.BoolProperty(name="Use Cache", default=True)
|
||||
query: bpy.props.StringProperty( # pyright: ignore[reportRedeclaration]
|
||||
query: bpy.props.StringProperty(
|
||||
name="Query",
|
||||
description=(
|
||||
"Custom selector query to use to load element from a linked model. E.g. 'IfcElement'.\n\n"
|
||||
@@ -1404,7 +1390,7 @@ class UnlinkIfc(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
bl_description = "Remove the selected file from the link list"
|
||||
|
||||
link_index: bpy.props.IntProperty(name="Link Index") # pyright: ignore[reportRedeclaration]
|
||||
link_index: bpy.props.IntProperty(name="Link Index")
|
||||
|
||||
if TYPE_CHECKING:
|
||||
link_index: int
|
||||
@@ -1428,7 +1414,7 @@ class UnloadLink(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
bl_description = "Unload the selected linked file"
|
||||
|
||||
link_index: bpy.props.IntProperty(name="Link Index") # pyright: ignore[reportRedeclaration]
|
||||
link_index: bpy.props.IntProperty(name="Link Index")
|
||||
|
||||
if TYPE_CHECKING:
|
||||
link_index: int
|
||||
@@ -1454,9 +1440,9 @@ class LoadLink(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
bl_description = "Load the selected file"
|
||||
|
||||
link_index: bpy.props.IntProperty(name="Link Index") # pyright: ignore[reportRedeclaration]
|
||||
use_cache: bpy.props.BoolProperty(name="Use Cache", default=True) # pyright: ignore[reportRedeclaration]
|
||||
query: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
|
||||
link_index: bpy.props.IntProperty(name="Link Index")
|
||||
use_cache: bpy.props.BoolProperty(name="Use Cache", default=True)
|
||||
query: bpy.props.StringProperty()
|
||||
|
||||
if TYPE_CHECKING:
|
||||
link_index: int
|
||||
@@ -1631,7 +1617,7 @@ class ReloadLink(bpy.types.Operator):
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
bl_description = "Reload the selected file"
|
||||
|
||||
link_index: bpy.props.IntProperty(name="Link Index") # pyright: ignore[reportRedeclaration]
|
||||
link_index: bpy.props.IntProperty(name="Link Index")
|
||||
|
||||
if TYPE_CHECKING:
|
||||
link_index: int
|
||||
@@ -1647,7 +1633,7 @@ class ToggleLinkSelectability(bpy.types.Operator):
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
bl_description = "Toggle selectability"
|
||||
|
||||
link_index: bpy.props.IntProperty(name="Link Index") # pyright: ignore[reportRedeclaration]
|
||||
link_index: bpy.props.IntProperty(name="Link Index")
|
||||
|
||||
if TYPE_CHECKING:
|
||||
link_index: int
|
||||
@@ -1679,8 +1665,8 @@ class ToggleLinkVisibility(bpy.types.Operator):
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
bl_description = "Toggle visibility between SOLID and WIREFRAME"
|
||||
|
||||
link_index: bpy.props.IntProperty(name="Link Index") # pyright: ignore[reportRedeclaration]
|
||||
mode: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
|
||||
link_index: bpy.props.IntProperty(name="Link Index")
|
||||
mode: bpy.props.EnumProperty(
|
||||
name="Visibility Mode",
|
||||
items=((i, i, "") for i in ("WIREFRAME", "VISIBLE")),
|
||||
)
|
||||
@@ -1821,7 +1807,7 @@ class SelectLinkHandle(bpy.types.Operator):
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
bl_description = "Select link empty object handle"
|
||||
|
||||
link_index: bpy.props.IntProperty(name="Link Index") # pyright: ignore[reportRedeclaration]
|
||||
link_index: bpy.props.IntProperty(name="Link Index")
|
||||
|
||||
if TYPE_CHECKING:
|
||||
link_index: int
|
||||
@@ -1843,7 +1829,7 @@ class SelectLinkedModelElement(bpy.types.Operator):
|
||||
bl_options = {"REGISTER"}
|
||||
bl_description = "Select an element in the currently selected linked model by providing GlobalId."
|
||||
|
||||
guid: bpy.props.StringProperty(name="GlobalId") # pyright: ignore[reportRedeclaration]
|
||||
guid: bpy.props.StringProperty(name="GlobalId")
|
||||
|
||||
if TYPE_CHECKING:
|
||||
guid: str
|
||||
@@ -1882,21 +1868,11 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
filename_ext = ".ifc"
|
||||
supported_filexts = (".ifc", ".ifczip", ".ifcjson")
|
||||
filter_glob: bpy.props.StringProperty(
|
||||
default=";".join(f"*{ext}" for ext in supported_filexts), options={"HIDDEN"}
|
||||
) # pyright: ignore[reportRedeclaration]
|
||||
json_version: bpy.props.EnumProperty(
|
||||
items=[("4", "4", ""), ("5a", "5a", "")], name="IFC JSON Version"
|
||||
) # pyright: ignore[reportRedeclaration]
|
||||
json_compact: bpy.props.BoolProperty(
|
||||
name="Export Compact IFCJSON", default=False
|
||||
) # pyright: ignore[reportRedeclaration]
|
||||
should_save_as: bpy.props.BoolProperty(
|
||||
name="Should Save As", default=False, options={"HIDDEN"}
|
||||
) # pyright: ignore[reportRedeclaration]
|
||||
use_relative_path: bpy.props.BoolProperty(
|
||||
name="Use Relative Path", default=False
|
||||
) # pyright: ignore[reportRedeclaration]
|
||||
filter_glob: bpy.props.StringProperty(default=";".join(f"*{ext}" for ext in supported_filexts), options={"HIDDEN"})
|
||||
json_version: bpy.props.EnumProperty(items=[("4", "4", ""), ("5a", "5a", "")], name="IFC JSON Version")
|
||||
json_compact: bpy.props.BoolProperty(name="Export Compact IFCJSON", default=False)
|
||||
should_save_as: bpy.props.BoolProperty(name="Should Save As", default=False, options={"HIDDEN"})
|
||||
use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=False)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
filter_glob: str
|
||||
@@ -2053,7 +2029,7 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper):
|
||||
bl_description = "Operator is used to load a project .cache.blend to then link it to the IFC file."
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
query: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
|
||||
query: bpy.props.StringProperty()
|
||||
"""See ``bim.link_ifc``."""
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -2443,8 +2419,8 @@ class HideQueriedLinkedElement(bpy.types.Operator):
|
||||
)
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
unhide_all: bpy.props.BoolProperty(options={"SKIP_SAVE"}) # pyright: ignore[reportRedeclaration]
|
||||
hide_all_except: bpy.props.BoolProperty(options={"SKIP_SAVE"}) # pyright: ignore[reportRedeclaration]
|
||||
unhide_all: bpy.props.BoolProperty(options={"SKIP_SAVE"})
|
||||
hide_all_except: bpy.props.BoolProperty(options={"SKIP_SAVE"})
|
||||
|
||||
if TYPE_CHECKING:
|
||||
unhide_all: bool
|
||||
@@ -2918,12 +2894,8 @@ class IFCFileHandlerOperator(bpy.types.Operator):
|
||||
bl_label = "Import .ifc file"
|
||||
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
|
||||
|
||||
directory: bpy.props.StringProperty(
|
||||
subtype="FILE_PATH", options={"SKIP_SAVE", "HIDDEN"}
|
||||
) # pyright: ignore[reportRedeclaration]
|
||||
files: bpy.props.CollectionProperty(
|
||||
type=bpy.types.OperatorFileListElement, options={"SKIP_SAVE", "HIDDEN"}
|
||||
) # pyright: ignore[reportRedeclaration]
|
||||
directory: bpy.props.StringProperty(subtype="FILE_PATH", options={"SKIP_SAVE", "HIDDEN"})
|
||||
files: bpy.props.CollectionProperty(type=bpy.types.OperatorFileListElement, options={"SKIP_SAVE", "HIDDEN"})
|
||||
|
||||
if TYPE_CHECKING:
|
||||
directory: str
|
||||
@@ -2978,7 +2950,7 @@ class MeasureTool(bpy.types.Operator, PolylineOperator):
|
||||
bl_label = "Measure Tool"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
measure_type: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
|
||||
measure_type: bpy.props.StringProperty()
|
||||
|
||||
if TYPE_CHECKING:
|
||||
measure_type: str
|
||||
@@ -3077,7 +3049,7 @@ class MeasureFaceAreaTool(bpy.types.Operator, PolylineOperator):
|
||||
bl_label = "Measure Face Area Tool"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
measure_type: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
|
||||
measure_type: bpy.props.StringProperty()
|
||||
|
||||
if TYPE_CHECKING:
|
||||
measure_type: str
|
||||
@@ -3379,7 +3351,7 @@ class LoadBlendMetadataAndIFC(bpy.types.Operator):
|
||||
bl_idname = "bim.load_blend_metadata_and_ifc"
|
||||
bl_label = "Load Blend Metadata and IFC"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
filepath: bpy.props.StringProperty(name="IFC File Path", default="") # pyright: ignore[reportRedeclaration]
|
||||
filepath: bpy.props.StringProperty(name="IFC File Path", default="")
|
||||
|
||||
if TYPE_CHECKING:
|
||||
filepath: str
|
||||
|
||||
@@ -345,7 +345,7 @@ class BIMProjectProperties(PropertyGroup):
|
||||
),
|
||||
default=False,
|
||||
)
|
||||
should_cache: BoolProperty( # pyright: ignore[reportRedeclaration]
|
||||
should_cache: BoolProperty(
|
||||
name="Cache",
|
||||
description=(
|
||||
"Cache loaded geometry to .h5 file in your cache directory (see in preferences) "
|
||||
|
||||
@@ -240,7 +240,7 @@ class CopyPropertyToSelection(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_label = "Copy Property To Selection"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
name: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
|
||||
name: bpy.props.StringProperty()
|
||||
|
||||
if TYPE_CHECKING:
|
||||
name: str
|
||||
@@ -280,10 +280,10 @@ class BIM_OT_add_property_to_edit(bpy.types.Operator):
|
||||
bl_label = "Add Property to Edit"
|
||||
bl_idname = "bim.add_property_to_edit"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
option: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
|
||||
option: bpy.props.EnumProperty(
|
||||
items=[(t, t, "") for t in tool.Pset.BULK_OPERATION_TYPES],
|
||||
)
|
||||
index: bpy.props.IntProperty(default=-1) # pyright: ignore[reportRedeclaration]
|
||||
index: bpy.props.IntProperty(default=-1)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
option: tool.Pset.BulkOperationType
|
||||
@@ -307,9 +307,9 @@ class BIM_OT_remove_property_to_edit(bpy.types.Operator):
|
||||
bl_label = "Remove Property from Editing"
|
||||
bl_idname = "bim.remove_property_to_edit"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
index: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
|
||||
index2: bpy.props.IntProperty(default=-1) # pyright: ignore[reportRedeclaration]
|
||||
option: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
|
||||
index: bpy.props.IntProperty()
|
||||
index2: bpy.props.IntProperty(default=-1)
|
||||
option: bpy.props.EnumProperty(
|
||||
items=[(t, t, "") for t in tool.Pset.BULK_OPERATION_TYPES],
|
||||
)
|
||||
|
||||
@@ -336,7 +336,7 @@ class BIM_OT_bulk_edit_clear_list(bpy.types.Operator):
|
||||
bl_label = "Clear List of Properties"
|
||||
bl_idname = "bim.pset_bulk_edit_clear_list"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
option: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
|
||||
option: bpy.props.EnumProperty(
|
||||
items=[(t, t, "") for t in tool.Pset.BULK_OPERATION_TYPES],
|
||||
)
|
||||
|
||||
|
||||
@@ -368,9 +368,9 @@ class GlobalPsetProperties(PropertyGroup):
|
||||
qto_filter: StringProperty(name="Qto Filter", options={"TEXTEDIT_UPDATE"})
|
||||
|
||||
# Bulk operations.
|
||||
psets_to_delete: CollectionProperty(type=DeletePsetEntry) # pyright: ignore[reportRedeclaration]
|
||||
psets_to_rename: CollectionProperty(type=RenamePropertyEntry) # pyright: ignore[reportRedeclaration]
|
||||
psets_to_add_edit: CollectionProperty(type=AddEditPropertyEntry) # pyright: ignore[reportRedeclaration]
|
||||
psets_to_delete: CollectionProperty(type=DeletePsetEntry)
|
||||
psets_to_rename: CollectionProperty(type=RenamePropertyEntry)
|
||||
psets_to_add_edit: CollectionProperty(type=AddEditPropertyEntry)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pset_filter: str
|
||||
|
||||
@@ -799,7 +799,7 @@ class SelectQueryElements(Operator):
|
||||
bl_description = "Select elements matching an provided selector query"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
query: StringProperty(name="Query") # pyright: ignore[reportRedeclaration]
|
||||
query: StringProperty(name="Query")
|
||||
|
||||
if TYPE_CHECKING:
|
||||
query: str
|
||||
@@ -829,12 +829,12 @@ class SaveSearch(Operator, tool.Ifc.Operator):
|
||||
# Extra item so it will be easy to select current text.
|
||||
return [text] + SaveSearch.name_search_items
|
||||
|
||||
name: StringProperty( # pyright: ignore[reportRedeclaration]
|
||||
name: StringProperty(
|
||||
name="Name",
|
||||
search=get_name_search_items,
|
||||
search_options={"SORT"},
|
||||
)
|
||||
module: StringProperty() # pyright: ignore[reportRedeclaration]
|
||||
module: StringProperty()
|
||||
|
||||
def update_use_all_ifcgroups(self, context: object = None) -> None:
|
||||
ifc_file = tool.Ifc.get()
|
||||
@@ -845,7 +845,7 @@ class SaveSearch(Operator, tool.Ifc.Operator):
|
||||
}
|
||||
self.name_search_items[:] = natsorted(groups)
|
||||
|
||||
use_all_ifcgroups: BoolProperty( # pyright: ignore[reportRedeclaration]
|
||||
use_all_ifcgroups: BoolProperty(
|
||||
name="Use Any IfcGroup",
|
||||
description=(
|
||||
"By default we're targeting only IfcGroups with SEARCH ObjectType "
|
||||
|
||||
@@ -106,7 +106,7 @@ class ActivateStatusFilters(bpy.types.Operator):
|
||||
bl_description = "Filter and display objects based on currently selected IFC statuses"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
only_if_enabled: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
|
||||
only_if_enabled: bpy.props.BoolProperty(
|
||||
name="Only If Filters are Enabled",
|
||||
description="Activate status filters only in case if they were enabled from the UI before.",
|
||||
default=False,
|
||||
@@ -137,7 +137,7 @@ class SelectStatusFilter(bpy.types.Operator):
|
||||
bl_description = "Select elements with currently selected status"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
status: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
|
||||
status: bpy.props.StringProperty()
|
||||
|
||||
if TYPE_CHECKING:
|
||||
status: tool.Sequence.ElementStatusUI
|
||||
@@ -156,7 +156,7 @@ class AssignStatus(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_description = "Assign status to the selected elements.\n\nAlt+CLICK to unassign the status."
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
should_override_previous_status: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
|
||||
should_override_previous_status: bpy.props.BoolProperty(
|
||||
name="Override Previous Status",
|
||||
description=(
|
||||
"Whether assigning new status should override previous one.\n\n"
|
||||
@@ -165,8 +165,8 @@ class AssignStatus(bpy.types.Operator, tool.Ifc.Operator):
|
||||
),
|
||||
default=True,
|
||||
)
|
||||
status: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
|
||||
should_unassign_status: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
|
||||
status: bpy.props.StringProperty()
|
||||
should_unassign_status: bpy.props.BoolProperty(
|
||||
options={"SKIP_SAVE"},
|
||||
)
|
||||
|
||||
@@ -415,7 +415,7 @@ class CopyWorkSchedule(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_label = "Copy Work Schedule"
|
||||
bl_description = "Create a duplicate of the provided work schedule."
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
work_schedule: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
|
||||
work_schedule: bpy.props.IntProperty()
|
||||
|
||||
if TYPE_CHECKING:
|
||||
work_schedule: int
|
||||
|
||||
@@ -412,7 +412,7 @@ WorkPlanEditingType = Literal["-", "ATTRIBUTES", "SCHEDULES", "WORK_SCHEDULE", "
|
||||
|
||||
class BIMWorkPlanProperties(PropertyGroup):
|
||||
work_plan_attributes: CollectionProperty(name="Work Plan Attributes", type=Attribute)
|
||||
editing_type: EnumProperty( # pyright: ignore[reportRedeclaration]
|
||||
editing_type: EnumProperty(
|
||||
items=[(i, i, "") for i in get_args(WorkPlanEditingType)],
|
||||
)
|
||||
work_plans: CollectionProperty(name="Work Plans", type=WorkPlan)
|
||||
@@ -430,8 +430,8 @@ class BIMWorkPlanProperties(PropertyGroup):
|
||||
|
||||
|
||||
class IFCStatus(PropertyGroup):
|
||||
name: StringProperty() # pyright: ignore[reportRedeclaration]
|
||||
is_visible: BoolProperty( # pyright: ignore[reportRedeclaration]
|
||||
name: StringProperty()
|
||||
is_visible: BoolProperty(
|
||||
name="Is Visible", default=True, update=lambda x, y: (None, bpy.ops.bim.activate_status_filters())[0]
|
||||
)
|
||||
|
||||
|
||||
@@ -220,7 +220,7 @@ class CopyToContainer(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_label = "Copy to Container"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
container: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
|
||||
container: bpy.props.IntProperty()
|
||||
|
||||
if TYPE_CHECKING:
|
||||
container: int
|
||||
|
||||
@@ -167,7 +167,7 @@ class EnableEditingStructuralBoundaryCondition(bpy.types.Operator):
|
||||
bl_idname = "bim.enable_editing_structural_boundary_condition"
|
||||
bl_label = "Enable Editing Structural Boundary Condition"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
boundary_condition: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
|
||||
boundary_condition: bpy.props.IntProperty()
|
||||
|
||||
if TYPE_CHECKING:
|
||||
boundary_condition: int
|
||||
@@ -186,7 +186,7 @@ class EditStructuralBoundaryCondition(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.edit_structural_boundary_condition"
|
||||
bl_label = "Edit Structural Boundary Condition"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
connection: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
|
||||
connection: bpy.props.IntProperty()
|
||||
|
||||
if TYPE_CHECKING:
|
||||
connection: int
|
||||
@@ -917,7 +917,7 @@ class EnableEditingBoundaryCondition(bpy.types.Operator):
|
||||
bl_idname = "bim.enable_editing_boundary_condition"
|
||||
bl_label = "Enable Editing Boundary Condition"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
boundary_condition: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
|
||||
boundary_condition: bpy.props.IntProperty()
|
||||
|
||||
if TYPE_CHECKING:
|
||||
boundary_condition: int
|
||||
|
||||
@@ -83,7 +83,7 @@ class DecorationShader:
|
||||
PARALLEL DISTRIBUTED FORCE,
|
||||
DISTRIBUTED MOMENT,
|
||||
"""
|
||||
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") # ty:ignore[too-many-positional-arguments]
|
||||
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface")
|
||||
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") # ty: ignore[too-many-positional-arguments]
|
||||
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface")
|
||||
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") # ty: ignore[too-many-positional-arguments]
|
||||
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface")
|
||||
vert_out.smooth("VEC3", "co")
|
||||
|
||||
shader_info = gpu.types.GPUShaderCreateInfo()
|
||||
|
||||
@@ -54,7 +54,7 @@ class AddSystem(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_label = "Add System"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
parent_system_id: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
|
||||
parent_system_id: bpy.props.IntProperty()
|
||||
|
||||
if TYPE_CHECKING:
|
||||
parent_system_id: int
|
||||
|
||||
@@ -26,16 +26,16 @@ from bpy.types import PropertyGroup
|
||||
|
||||
|
||||
class WebProperties(PropertyGroup):
|
||||
webserver_port: IntProperty( # pyright: ignore[reportRedeclaration]
|
||||
webserver_port: IntProperty(
|
||||
name="Webserver Port",
|
||||
min=0,
|
||||
max=65535,
|
||||
)
|
||||
is_running: BoolProperty( # pyright: ignore[reportRedeclaration]
|
||||
is_running: BoolProperty(
|
||||
name="Webserver Running Status",
|
||||
default=False,
|
||||
)
|
||||
is_connected: BoolProperty( # pyright: ignore[reportRedeclaration]
|
||||
is_connected: BoolProperty(
|
||||
name="Connection Status",
|
||||
default=False,
|
||||
)
|
||||
|
||||
@@ -159,9 +159,9 @@ class SelectURIAttribute(bpy.types.Operator, ImportHelper):
|
||||
bl_label = "Select URI Attribute"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
bl_description = "Select a local file"
|
||||
attribute_data_path: bpy.props.StringProperty(name="Data Path") # pyright: ignore[reportRedeclaration]
|
||||
attribute_data_path: bpy.props.StringProperty(name="Data Path")
|
||||
"""Full data path to `Attribute`/string property."""
|
||||
use_relative_path: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
|
||||
use_relative_path: bpy.props.BoolProperty(
|
||||
name="Use Relative Path",
|
||||
default=False,
|
||||
)
|
||||
@@ -601,7 +601,7 @@ class CreateMacBonsaiApp(bpy.types.Operator):
|
||||
"ALT+click to uninstall Bonsai app if it was installed previously."
|
||||
)
|
||||
|
||||
uninstall: bpy.props.BoolProperty(options={"SKIP_SAVE"}) # pyright: ignore[reportRedeclaration]
|
||||
uninstall: bpy.props.BoolProperty(options={"SKIP_SAVE"})
|
||||
|
||||
if TYPE_CHECKING:
|
||||
uninstall: bool
|
||||
@@ -1667,7 +1667,7 @@ class BIM_OT_attribute_add_subitem(bpy.types.Operator):
|
||||
bl_description = "Add subitem to the current attribute"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
data_path: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
|
||||
data_path: bpy.props.StringProperty()
|
||||
"""Full data path."""
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -1691,9 +1691,9 @@ class BIM_OT_attribute_remove_subitem(bpy.types.Operator):
|
||||
bl_description = "Add subitem to the current attribute"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
data_path: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
|
||||
data_path: bpy.props.StringProperty()
|
||||
"""Full data path."""
|
||||
index: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
|
||||
index: bpy.props.IntProperty()
|
||||
|
||||
if TYPE_CHECKING:
|
||||
data_path: str
|
||||
|
||||
@@ -333,7 +333,7 @@ class Attribute(PropertyGroup):
|
||||
filter_glob: StringProperty()
|
||||
is_null: BoolProperty(name="Is Null", update=update_is_null)
|
||||
is_selected: BoolProperty(name="Is Selected", default=False)
|
||||
subitems_values: CollectionProperty(type=StrProperty) # pyright: ignore[reportRedeclaration]
|
||||
subitems_values: CollectionProperty(type=StrProperty)
|
||||
|
||||
# Attribute parameters.
|
||||
is_optional: BoolProperty(name="Is Optional")
|
||||
@@ -342,7 +342,7 @@ class Attribute(PropertyGroup):
|
||||
value_max: FloatProperty(description="This is used to validate int_value and float_value")
|
||||
value_max_constraint: BoolProperty(default=False, description="True if the numerical value has an upper bound")
|
||||
special_type: StringProperty(name="Special Value Type", default="")
|
||||
use_explorer_ui: BoolProperty() # pyright: ignore[reportRedeclaration]
|
||||
use_explorer_ui: BoolProperty()
|
||||
metadata: StringProperty(name="Metadata", description="For storing some additional information about the attribute")
|
||||
update: StringProperty(name="Update", description="Custom update function to be executed")
|
||||
|
||||
|
||||
@@ -665,7 +665,7 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
|
||||
name="Disable Undo When Saving (Faster saves, no undo for you!)", default=False
|
||||
)
|
||||
should_stream: BoolProperty(name="Stream Data From IFC-SPF (Only for advanced users)", default=False)
|
||||
should_always_cache: BoolProperty( # pyright: ignore[reportRedeclaration]
|
||||
should_always_cache: BoolProperty(
|
||||
name="Always Cache Geometry",
|
||||
description="Whether to always cache geometry regardless of 'Cache' setting during Advanced Project Load.",
|
||||
)
|
||||
|
||||
@@ -151,8 +151,11 @@ def merge_branch(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc], operator: bpy.t
|
||||
conflicts = ifcgit.git_mergetool(mergetool, path_ifc)
|
||||
if conflicts is not None:
|
||||
ifcgit.git_merge_abort()
|
||||
ifcgit.store_merge_conflicts(conflicts)
|
||||
operator.report({"WARNING"}, "Merge failed — see the conflict report in the panel below")
|
||||
if conflicts:
|
||||
ifcgit.store_merge_conflicts(conflicts)
|
||||
operator.report({"WARNING"}, "Merge failed — see the conflict report in the panel below")
|
||||
else:
|
||||
operator.report({"ERROR"}, "Merge tool failed — check that ifcmerge is installed correctly")
|
||||
return False
|
||||
ifcgit.commit_merge(path_ifc)
|
||||
|
||||
|
||||
@@ -1888,7 +1888,7 @@ class Blender(bonsai.core.tool.Blender):
|
||||
@classmethod
|
||||
def get_radiance_exporter_props(cls) -> RadianceExporterProperties:
|
||||
assert (scene := bpy.context.scene)
|
||||
return scene.BIMRadianceExporeterProperies # pyright: ignore[reportAttributeAccessIssue]
|
||||
return scene.BIMRadianceExporterProperties # pyright: ignore[reportAttributeAccessIssue]
|
||||
|
||||
@classmethod
|
||||
def get_fm_props(cls) -> BIMFMProperties:
|
||||
|
||||
@@ -217,7 +217,7 @@ class IfcGit:
|
||||
rev=[props.display_branch],
|
||||
)
|
||||
)
|
||||
commits_relevant = list(
|
||||
commits_relevant = set(
|
||||
git.objects.commit.Commit.iter_items(
|
||||
repo=repo,
|
||||
rev=[props.display_branch],
|
||||
@@ -225,11 +225,17 @@ class IfcGit:
|
||||
)
|
||||
)
|
||||
|
||||
def is_relevant(commit):
|
||||
if commit in commits_relevant:
|
||||
return True
|
||||
# Merge commits are relevant too
|
||||
return len(commit.parents) > 1 and any(p in commits_relevant for p in commit.parents)
|
||||
|
||||
for commit in commits:
|
||||
|
||||
if props.ifcgit_filter == "tagged" and commit.hexsha not in lookup:
|
||||
continue
|
||||
elif props.ifcgit_filter == "relevant" and commit not in commits_relevant:
|
||||
elif props.ifcgit_filter == "relevant" and not is_relevant(commit):
|
||||
continue
|
||||
|
||||
props.ifcgit_commits.add()
|
||||
@@ -239,7 +245,7 @@ class IfcGit:
|
||||
list_item.author_name = commit.author.name
|
||||
list_item.author_email = commit.author.email
|
||||
list_item.committed_date = int(commit.committed_date)
|
||||
if commit in commits_relevant:
|
||||
if is_relevant(commit):
|
||||
list_item.relevant = True
|
||||
if commit.hexsha in lookup:
|
||||
for tag in lookup[commit.hexsha]:
|
||||
@@ -280,11 +286,11 @@ class IfcGit:
|
||||
if re.match("^Ifc", obj.name):
|
||||
bpy.data.objects.remove(obj, do_unlink=True)
|
||||
|
||||
bpy.data.orphans_purge(do_recursive=True) # ty:ignore[unknown-argument]
|
||||
bpy.data.orphans_purge(do_recursive=True)
|
||||
|
||||
from bonsai.bim.module.root.data import IfcClassData
|
||||
from bonsai.bim.module.model.data import AuthoringData
|
||||
import bonsai.bim.handler
|
||||
from bonsai.bim.module.model.data import AuthoringData
|
||||
from bonsai.bim.module.root.data import IfcClassData
|
||||
|
||||
AuthoringData.type_thumbnails = {}
|
||||
|
||||
@@ -589,7 +595,7 @@ class IfcGit:
|
||||
"""Attempt a git merge. Returns None on clean merge, 'conflict' on expected
|
||||
GitCommandError, or 'error' on an unknown GitError."""
|
||||
repo = IfcGitRepo.repo
|
||||
branch = repo.branches[branch_name]
|
||||
branch = repo.refs[branch_name]
|
||||
try:
|
||||
repo.git.merge(branch)
|
||||
return None
|
||||
@@ -603,7 +609,7 @@ class IfcGit:
|
||||
"""Attempt a git merge without committing (always leaves a merge state to abort).
|
||||
Returns None on clean merge, 'conflict' on conflict, or 'error' on unknown failure."""
|
||||
repo = IfcGitRepo.repo
|
||||
branch = repo.branches[branch_name]
|
||||
branch = repo.refs[branch_name]
|
||||
try:
|
||||
repo.git.merge(branch, no_commit=True, no_ff=True)
|
||||
return None
|
||||
@@ -619,8 +625,8 @@ class IfcGit:
|
||||
report_path = path_ifc + ".ifcmerge"
|
||||
try:
|
||||
repo.git.mergetool(tool=mergetool)
|
||||
except git.exc.GitCommandError:
|
||||
pass
|
||||
except git.exc.GitCommandError as e:
|
||||
print(f"ifcgit: mergetool failed: {e}")
|
||||
|
||||
conflicts = None
|
||||
if os.path.exists(report_path):
|
||||
@@ -636,6 +642,10 @@ class IfcGit:
|
||||
os.remove(report_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if conflicts is None and repo.index.unmerged_blobs():
|
||||
conflicts = []
|
||||
|
||||
return conflicts
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -25,13 +25,12 @@ import bmesh
|
||||
import bpy
|
||||
import mathutils
|
||||
import numpy as np
|
||||
from mathutils import Vector
|
||||
|
||||
from bpy_extras import view3d_utils
|
||||
from mathutils import Vector
|
||||
|
||||
import bonsai.core.tool
|
||||
import bonsai.tool as tool
|
||||
from bpy_extras import view3d_utils
|
||||
|
||||
|
||||
class Raycast(bonsai.core.tool.Raycast):
|
||||
offset = 10
|
||||
|
||||
@@ -7,7 +7,7 @@ Python code formatters
|
||||
For Python code formatting, we use `Black code formatter <https://pypi.org/project/black/>`__,
|
||||
black settings are stored in the repository's pyproject.toml.
|
||||
|
||||
We have GitHub workflow `ci-black-formatting` to maintain black formatting across the repository.
|
||||
We have GitHub workflow `ci-lint` to maintain black formatting across the repository.
|
||||
|
||||
``black`` can be installed using ``pip install black`` and files can be formatted with the following example command:
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ When adding or removing a supported Python version, update the following:
|
||||
|
||||
* - File
|
||||
- What to update
|
||||
* - ``.github/workflows/ci-black-formatting.yaml``
|
||||
* - ``.github/workflows/ci-lint.yaml``
|
||||
- ``MIN_IOS_PY_VERSION``
|
||||
* - ``.github/workflows/ci-ifcopenshell-python-pypi.yml``
|
||||
- ``pyver`` matrix
|
||||
@@ -44,6 +44,8 @@ When a new Blender version is released and supported:
|
||||
|
||||
* - File
|
||||
- What to update
|
||||
* - ``.github/workflows/ci-bonsai.yml``
|
||||
- ``pyver`` matrix
|
||||
* - ``.github/workflows/ci-bonsai-daily.yml``
|
||||
- Blender download URL
|
||||
|
||||
@@ -57,9 +59,47 @@ When Blender ships with a new Python version:
|
||||
|
||||
* - File
|
||||
- What to update
|
||||
* - ``.github/workflows/ci-black-formatting.yaml``
|
||||
* - ``.github/workflows/ci-lint.yaml``
|
||||
- ``MIN_BLENDER_PY_VERSION``
|
||||
* - ``src/bonsai/Makefile``
|
||||
- ``SUPPORTED_PYVERSIONS``
|
||||
* - ``src/bonsai/scripts/dev_environment.py``
|
||||
- ``PYTHON_VERSION`` mapping (Blender version, bundled Python version)
|
||||
|
||||
Release
|
||||
-------
|
||||
|
||||
Notes:
|
||||
|
||||
- Typically all packages are released at once using the same version schema
|
||||
- The ``README.md`` badges can serve as a visual reference for what versions have been released
|
||||
|
||||
Things to update:
|
||||
|
||||
- ``.github/workflows/ci-bcf-pypi.yml`` - release `bcf-client <https://pypi.org/project/bcf-client/>`_ to PyPI
|
||||
- ``.github/workflows/ci-bonsai.yml`` - release bonsai in GitHub releases
|
||||
- ``.github/workflows/ci-bsdd-pypi.yaml`` - release `bsdd <https://pypi.org/project/bsdd/>`_ to PyPI
|
||||
- ``.github/workflows/ci-ifc4d-pypi.yaml`` - release `ifc4d <https://pypi.org/project/ifc4d/>`_ to PyPI
|
||||
- ``.github/workflows/ci-ifc5d-pypi.yaml`` - release `ifc5d <https://pypi.org/project/ifc5d/>`_ to PyPI
|
||||
- ``.github/workflows/ci-ifcclash-pypi.yaml`` - release `ifcclash <https://pypi.org/project/ifcclash/>`_ to PyPI
|
||||
- ``.github/workflows/ci-ifcconvert.yml`` - release ifcconvert binaries in GitHub releases
|
||||
- ``.github/workflows/ci-ifccsv-pypi.yaml`` - release `ifccsv <https://pypi.org/project/ifccsv/>`_ to PyPI
|
||||
- ``.github/workflows/ci-ifcdiff-pypi.yaml`` - release `ifcdiff <https://pypi.org/project/ifcdiff/>`_ to PyPI
|
||||
- ``.github/workflows/ci-ifcedit-pypi.yaml`` - release `ifcedit <https://pypi.org/project/ifcedit/>`_ to PyPI
|
||||
- ``.github/workflows/ci-ifcfm-pypi.yaml`` - release `ifcfm <https://pypi.org/project/ifcfm/>`_ to PyPI
|
||||
- ``.github/workflows/ci-ifccityjson-pypi.yaml`` - release `ifccityjson <https://pypi.org/project/ifccityjson/>`_ to PyPI
|
||||
- ``.github/workflows/ci-ifcmcp-pypi.yaml`` - release `ifcopenshell-mcp <https://pypi.org/project/ifcopenshell-mcp/>`_ to PyPI
|
||||
- ``.github/workflows/ci-ifcopenshell-python.yml`` - release ifcopenshell-python binaries in GitHub releases
|
||||
- ``.github/workflows/ci-ifcopenshell-python-pypi.yml`` - release `ifcopenshell <https://pypi.org/project/ifcopenshell/>`_ wheels to PyPI
|
||||
- ``.github/workflows/ci-ifcpatch-pypi.yaml`` - release `ifcpatch <https://pypi.org/project/ifcpatch/>`_ to PyPI
|
||||
- ``.github/workflows/ci-ifcquery-pypi.yaml`` - release `ifcquery <https://pypi.org/project/ifcquery/>`_ to PyPI
|
||||
- ``.github/workflows/ci-ifcsverchok.yml`` - release ifcsverchok Blender add-on in GitHub releases
|
||||
- ``.github/workflows/ci-ifctester-pypi.yml`` - release `ifctester <https://pypi.org/project/ifctester/>`_ to PyPI
|
||||
- ``.github/workflows/ci-pyodide-wasm-release.yml`` - release pyodide wasm wheel to `wasm-wheels <https://github.com/IfcOpenShell/wasm-wheels>`_
|
||||
- Release Bonsai Blender extension - zip files from ci-bonsai.yml releases should be uploaded manually to `Blender extensions platform <https://extensions.blender.org/add-ons/bonsai/>`_
|
||||
- Publishing documentation and websites (see `website <https://github.com/IfcOpenShell/website>`_ repository):
|
||||
|
||||
- `ifcopenshell-docs.yml` - builds and publishes IfcOpenShell documentation to `docs.ifcopenshell.org <https://docs.ifcopenshell.org>`_ (`ifcopenshell_org_docs <https://github.com/IfcOpenShell/ifcopenshell_org_docs>`_ repo)
|
||||
- `bonsai-docs.yml` - builds and publishes Bonsai documentation to `docs.bonsaibim.org <https://docs.bonsaibim.org>`_ (`bonsaibim_org_docs <https://github.com/IfcOpenShell/bonsaibim_org_docs>`_ repo)
|
||||
- `main.yml` - publishes `bonsaibim.org <https://bonsaibim.org>`_ (`bonsaibim_org_static_html <https://github.com/IfcOpenShell/bonsaibim_org_static_html>`_ repo) and `ifcopenshell.org <https://ifcopenshell.org>`_ (`ifcopenshell_org_static_html <https://github.com/IfcOpenShell/ifcopenshell_org_static_html>`_ repo)
|
||||
- ``VERSION`` to the release version - **UPDATE THIS LAST** as all workflows above typically depend on it to set the version correctly
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
import pytest
|
||||
|
||||
import bonsai.core.ifcgit as subject
|
||||
from test.core.bootstrap import ifcgit, ifc
|
||||
from test.core.bootstrap import ifc, ifcgit
|
||||
|
||||
|
||||
class MockOperator:
|
||||
|
||||
+123
-4
@@ -139,6 +139,7 @@ const baseUrlRowEl = $("baseUrlRow");
|
||||
const baseUrlLabelEl = $("baseUrlLabel");
|
||||
const baseUrlEl = $("baseUrl");
|
||||
const thinkingIndicatorEl = $("thinkingIndicator");
|
||||
const compactingIndicatorEl = $("compactingIndicator");
|
||||
const modelEl = $("model");
|
||||
const providerEls = document.querySelectorAll('input[name="provider"]');
|
||||
const ifcFileEl = $("ifcFile");
|
||||
@@ -395,6 +396,7 @@ function addMessage(role, text) {
|
||||
function setStatus(text) {
|
||||
statusEl.textContent = text;
|
||||
thinkingIndicatorEl.hidden = text !== "Thinking…";
|
||||
compactingIndicatorEl.hidden = text !== "Compacting…";
|
||||
msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||
}
|
||||
|
||||
@@ -491,6 +493,7 @@ Rules:
|
||||
- If the user asks about model contents (counts, lists, properties, hierarchy), use tools like ifc_summary/ifc_select/ifc_info/ifc_tree.
|
||||
- If the user asks to change the model, prefer: (1) ifc_list to find candidate API modules, (2) ifc_docs for the exact function signature, then (3) ifc_edit.
|
||||
- If there is no model and the user wants to create one, call ifc_new.
|
||||
- In case of type errors on api functions, retry providing values as strings (for example in the case of the matrix in geometry.edit_object_placement).
|
||||
- After edits, explain what changed and suggest downloading the IFC.
|
||||
Be concise. Avoid dumping huge trees unless asked.
|
||||
`;
|
||||
@@ -499,6 +502,11 @@ let messages = []; // running conversation state (Chat Completions style)
|
||||
|
||||
const MAX_TOOL_RESULT_CHARS = 0;
|
||||
const MAX_HISTORY_MESSAGES = 40;
|
||||
const ESTIMATED_CHARS_PER_TOKEN = 4;
|
||||
const MAX_ESTIMATED_TOKENS_PER_MINUTE = 24000;
|
||||
const COMPACT_WHEN_ESTIMATED_TOKENS = 18000;
|
||||
const KEEP_RAW_TURN_GROUPS = 1;
|
||||
const minuteTokenMap = new Map();
|
||||
|
||||
function truncateToolResult(text) {
|
||||
if (MAX_TOOL_RESULT_CHARS == 0 || text.length <= MAX_TOOL_RESULT_CHARS) return text;
|
||||
@@ -518,6 +526,103 @@ function trimHistory() {
|
||||
}
|
||||
}
|
||||
|
||||
function getEstimatedTokenMinuteLog(firstIterationMinuteBucket) {
|
||||
return Array.from(minuteTokenMap.entries())
|
||||
.filter(([minuteBucket]) => minuteBucket >= firstIterationMinuteBucket)
|
||||
.sort(([leftMinuteBucket], [rightMinuteBucket]) => leftMinuteBucket - rightMinuteBucket)
|
||||
.map(([minuteBucket, estimatedTokens]) => ({
|
||||
timestamp: new Date(minuteBucket * 60000).toISOString(),
|
||||
estimated_tokens: estimatedTokens,
|
||||
}));
|
||||
}
|
||||
|
||||
async function chatWithMinuteDelay({ chat, apiKey, baseURL, model, messages, tools }) {
|
||||
const estimatedTokens = Math.max(
|
||||
1,
|
||||
Math.ceil(JSON.stringify({ model, messages, ...(tools ? { tools } : {}) }).length / ESTIMATED_CHARS_PER_TOKEN)
|
||||
);
|
||||
let currentMinuteBucket = Math.floor(Date.now() / 60000);
|
||||
const estimateTokenUsage = (minuteTokenMap.get(currentMinuteBucket) ?? 0) + estimatedTokens;
|
||||
|
||||
if (estimateTokenUsage > MAX_ESTIMATED_TOKENS_PER_MINUTE) {
|
||||
currentMinuteBucket += 1;
|
||||
await new Promise((resolve) => setTimeout(() => resolve(), 60000));
|
||||
}
|
||||
|
||||
minuteTokenMap.set(currentMinuteBucket, (minuteTokenMap.get(currentMinuteBucket) ?? 0) + estimatedTokens);
|
||||
|
||||
return {
|
||||
minuteBucket: currentMinuteBucket,
|
||||
response: await chat({ apiKey, baseURL, model, messages, tools }),
|
||||
};
|
||||
}
|
||||
|
||||
async function compactHistoryWithLLM(chat, apiKey, baseURL, model) {
|
||||
const estimatedTokens = Math.max(
|
||||
1,
|
||||
Math.ceil(JSON.stringify([{ role: "system", content: SYSTEM_INSTRUCTIONS }, ...messages]).length / ESTIMATED_CHARS_PER_TOKEN)
|
||||
);
|
||||
if (messages.length <= MAX_HISTORY_MESSAGES && estimatedTokens <= COMPACT_WHEN_ESTIMATED_TOKENS) return null;
|
||||
|
||||
const { prefix, groups } = messages.reduce((acc, message) => {
|
||||
if (message.role === "user") {
|
||||
acc.groups.push([message]);
|
||||
} else if (acc.groups.length) {
|
||||
acc.groups[acc.groups.length - 1].push(message);
|
||||
} else {
|
||||
acc.prefix.push(message);
|
||||
}
|
||||
return acc;
|
||||
}, { prefix: [], groups: [] });
|
||||
|
||||
if (groups.length <= KEEP_RAW_TURN_GROUPS) return null;
|
||||
|
||||
const compacted = [...prefix, ...groups.slice(0, -KEEP_RAW_TURN_GROUPS).flat()];
|
||||
if (!compacted.length) return null;
|
||||
|
||||
setStatus("Compacting…");
|
||||
try {
|
||||
const before = {
|
||||
message_count: messages.length,
|
||||
turn_group_count: groups.length,
|
||||
estimated_tokens: estimatedTokens,
|
||||
};
|
||||
const { minuteBucket, response } = await chatWithMinuteDelay({
|
||||
chat,
|
||||
apiKey,
|
||||
baseURL,
|
||||
model,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: "Summarize older IFC chat context for continuation. Preserve user goals, model state and schema, edits already applied, important ids, names, selectors, and unresolved questions. Be concise, factual, and use short markdown bullets. Do not mention that this is a summary."
|
||||
},
|
||||
{ role: "user", content: JSON.stringify(compacted) },
|
||||
],
|
||||
});
|
||||
const summary = response.choices?.[0]?.message?.content?.trim();
|
||||
|
||||
if (!summary) return minuteBucket;
|
||||
|
||||
messages = [
|
||||
{ role: "assistant", content: `[Context summary]\n${summary}` },
|
||||
...groups.slice(-KEEP_RAW_TURN_GROUPS).flat(),
|
||||
];
|
||||
console.log("History compaction before", before);
|
||||
console.log("History compaction after", {
|
||||
message_count: messages.length,
|
||||
turn_group_count: messages.filter((message) => message.role === "user").length,
|
||||
estimated_tokens: Math.max(
|
||||
1,
|
||||
Math.ceil(JSON.stringify([{ role: "system", content: SYSTEM_INSTRUCTIONS }, ...messages]).length / ESTIMATED_CHARS_PER_TOKEN)
|
||||
),
|
||||
});
|
||||
return minuteBucket;
|
||||
} finally {
|
||||
setStatus("Thinking…");
|
||||
}
|
||||
}
|
||||
|
||||
async function runAgentTurn(userText) {
|
||||
const apiKey = apiKeyEl.value.trim();
|
||||
if (!apiKey) throw new Error("Missing API key");
|
||||
@@ -525,18 +630,29 @@ async function runAgentTurn(userText) {
|
||||
const provider = PROVIDERS[getProviderValue()];
|
||||
const { chat } = provider.api;
|
||||
const baseURL = provider.baseUrlDefault ? baseUrlEl.value.trim() : undefined;
|
||||
let firstIterationMinuteBucket = null;
|
||||
|
||||
messages.push({ role: "user", content: userText });
|
||||
trimHistory();
|
||||
|
||||
for (let i = 0; i < 64; i++) {
|
||||
const response = await chat({
|
||||
const compactedMinuteBucket = await compactHistoryWithLLM(chat, apiKey, baseURL, modelEl.value);
|
||||
if (firstIterationMinuteBucket === null && compactedMinuteBucket !== null) {
|
||||
firstIterationMinuteBucket = compactedMinuteBucket;
|
||||
}
|
||||
if (messages.length > MAX_HISTORY_MESSAGES * 2) trimHistory();
|
||||
|
||||
const messages_with_system = [{ role: "system", content: SYSTEM_INSTRUCTIONS }, ...messages];
|
||||
const { minuteBucket, response } = await chatWithMinuteDelay({
|
||||
chat,
|
||||
apiKey,
|
||||
baseURL,
|
||||
model: modelEl.value,
|
||||
messages: [{ role: "system", content: SYSTEM_INSTRUCTIONS }, ...messages],
|
||||
messages: messages_with_system,
|
||||
tools,
|
||||
});
|
||||
if (firstIterationMinuteBucket === null) {
|
||||
firstIterationMinuteBucket = minuteBucket;
|
||||
}
|
||||
|
||||
const message = response.choices?.[0]?.message;
|
||||
if (!message) throw new Error("No message in response");
|
||||
@@ -546,7 +662,10 @@ async function runAgentTurn(userText) {
|
||||
if (message.content) addMessage("assistant", message.content);
|
||||
|
||||
const calls = message.tool_calls ?? [];
|
||||
if (calls.length === 0) return;
|
||||
if (calls.length === 0) {
|
||||
console.log("Estimated token usage by minute", getEstimatedTokenMinuteLog(firstIterationMinuteBucket));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const call of calls) {
|
||||
let args = {};
|
||||
|
||||
@@ -111,6 +111,10 @@
|
||||
<span class="spinner"></span>
|
||||
<span>thinking...</span>
|
||||
</div>
|
||||
<div class="thinking-indicator" id="compactingIndicator" hidden>
|
||||
<span class="spinner"></span>
|
||||
<span>compacting...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="composer">
|
||||
<div class="inner">
|
||||
|
||||
@@ -52,15 +52,6 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcPointByDistanceExpression* i
|
||||
if (inst->OffsetVertical().has_value()) {
|
||||
auto offset_vertical = inst->OffsetVertical().get() * length_unit_;
|
||||
o += offset_vertical * z;
|
||||
|
||||
auto tmp1 = (z * offset_vertical).eval();
|
||||
auto tmp2 = (Eigen::Vector3d(0, 0, 1) * offset_vertical).eval();
|
||||
auto tmp3 = (tmp1 - tmp2).eval();
|
||||
|
||||
std::ostringstream oss;
|
||||
oss << "local z: " << z.x() << "," << z.y() << "," << z.z() << "; delta: " << tmp3.x() << "," << tmp3.y() << "," << tmp3.z();
|
||||
auto osss = oss.str();
|
||||
std::wcout << osss.c_str() << std::endl;
|
||||
}
|
||||
|
||||
if (inst->OffsetLongitudinal().has_value()) {
|
||||
|
||||
@@ -8,10 +8,10 @@ sessions.
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install ifcmcp
|
||||
pip install ifcopenshell-mcp
|
||||
```
|
||||
|
||||
Requires `ifcopenshell`, `ifcquery`, and `ifcedit`. The `mcp` package is an optional dependency needed to run the server; install it with `pip install ifcmcp[mcp]` or add `mcp` separately.
|
||||
Requires `ifcopenshell`, `ifcquery`, and `ifcedit`. The `mcp` package is an optional dependency needed to run the server; install it with `pip install ifcopenshell-mcp[mcp]` or add `mcp` separately.
|
||||
|
||||
## Running the server
|
||||
|
||||
|
||||
@@ -278,7 +278,12 @@ class IfcSession:
|
||||
return info.info(model, element)
|
||||
|
||||
def ifc_select(self, query: str) -> list[dict[str, Any]]:
|
||||
"""Filter elements using ifcopenshell selector syntax (e.g. 'IfcWall', 'IfcWindow')."""
|
||||
"""Filter elements using ifcopenshell selector syntax.
|
||||
|
||||
Examples: ``IfcWall``, ``IfcWall, IfcColumn``, ``! IfcWall``,
|
||||
``IfcWall, Name = "My Wall"``, ``type = "Concrete Wall"``,
|
||||
``material = "Concrete"``.
|
||||
"""
|
||||
return select.select(self._require_model(), query)
|
||||
|
||||
def ifc_relations(self, element_id: int, traverse: str = "") -> dict[str, Any] | list[dict[str, Any]]:
|
||||
@@ -536,7 +541,12 @@ class IfcSession:
|
||||
{
|
||||
"type": "function",
|
||||
"name": "ifc_select",
|
||||
"description": "Select elements using ifcopenshell selector syntax (e.g. 'IfcWall').",
|
||||
"description": (
|
||||
"Select elements using ifcopenshell selector syntax. "
|
||||
"Examples: 'IfcWall', 'IfcWall, IfcColumn', '! IfcWall', "
|
||||
"'IfcWall, Name = \"My Wall\"', 'type = \"Concrete Wall\"', "
|
||||
"'material = \"Concrete\"'."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"query": {"type": "string"}},
|
||||
|
||||
@@ -469,7 +469,7 @@ def get_properties(
|
||||
del data["HasProperties"]
|
||||
results[prop_name] = data
|
||||
if verbose:
|
||||
results[prop_name] = {"id": data["id"], "class": data["class"], "value": results[prop_name]}
|
||||
results[prop_name] = {"id": data["id"], "class": data["type"], "value": results[prop_name]}
|
||||
return results
|
||||
|
||||
|
||||
|
||||
@@ -307,6 +307,35 @@ class TestGetPropertiesIFC4(test.bootstrap.IFC4):
|
||||
}
|
||||
}
|
||||
|
||||
def test_getting_complex_properties_verbose(self):
|
||||
element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
|
||||
pset = ifcopenshell.api.pset.add_pset(self.file, product=element, name="pset")
|
||||
complex_property = self.file.create_entity("IfcComplexProperty", Name="prop", UsageName="usage_name")
|
||||
ifcopenshell.api.pset.edit_pset(self.file, pset=complex_property, properties={"a": "b"})
|
||||
pset.HasProperties = [complex_property]
|
||||
properties = subject.get_properties(pset.HasProperties, verbose=True)
|
||||
prop_value = properties["prop"]["value"]
|
||||
nested_prop = prop_value["properties"]["a"]
|
||||
assert properties == {
|
||||
"prop": {
|
||||
"id": complex_property.id(),
|
||||
"class": "IfcComplexProperty",
|
||||
"value": {
|
||||
"UsageName": "usage_name",
|
||||
"id": complex_property.id(),
|
||||
"type": "IfcComplexProperty",
|
||||
"properties": {
|
||||
"a": {
|
||||
"id": nested_prop["id"],
|
||||
"class": "IfcPropertySingleValue",
|
||||
"value": "b",
|
||||
"value_type": "IfcLabel",
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class TestGetElementsUsingPset(test.bootstrap.IFC4):
|
||||
def test_run(self):
|
||||
|
||||
@@ -26,7 +26,16 @@ import ifcopenshell.util.selector
|
||||
|
||||
|
||||
def select(model: ifcopenshell.file, query: str) -> list[dict[str, Any]]:
|
||||
"""Filter elements using selector syntax and return matching element summaries."""
|
||||
"""Filter elements using ifcopenshell selector syntax and return matching element summaries.
|
||||
|
||||
Examples:
|
||||
- ``IfcWall`` — all walls
|
||||
- ``IfcWall, IfcColumn`` — walls and columns
|
||||
- ``! IfcWall`` — everything except walls
|
||||
- ``IfcWall, Name = "My Wall"`` — walls with a specific name attribute
|
||||
- ``type = "Concrete Wall"`` — elements assigned that type product
|
||||
- ``material = "Concrete"`` — elements with that material
|
||||
"""
|
||||
elements = ifcopenshell.util.selector.filter_elements(model, query)
|
||||
results = []
|
||||
for element in sorted(elements, key=lambda e: e.id()):
|
||||
|
||||
@@ -32,7 +32,7 @@ class SvIfcSbExtrude(bpy.types.Node, SverchCustomTreeNode, ifcsverchok.helper.Sv
|
||||
bl_idname = "SvIfcSbExtrude"
|
||||
bl_label = "IFC Extrude"
|
||||
|
||||
extrude_axis: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
|
||||
extrude_axis: bpy.props.EnumProperty(
|
||||
default="Z",
|
||||
items=[
|
||||
("X", "X", "Interpret curve as in XY plane and extrude along X+."),
|
||||
|
||||
Generated
+4
-4
@@ -33,7 +33,7 @@
|
||||
"tailwindcss": "^4.0.0",
|
||||
"tw-animate-css": "^1.3.2",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.4.1"
|
||||
"vite": "^6.4.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@ampproject/remapping": {
|
||||
@@ -3264,9 +3264,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||
"version": "6.4.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
|
||||
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.8.3",
|
||||
"tw-animate-css": "^1.3.2",
|
||||
"vite": "^6.4.1"
|
||||
"vite": "^6.4.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"eventemitter3": "^5.0.1",
|
||||
|
||||
@@ -615,10 +615,7 @@ void eliminate_overlaps(DebugWriter& debug_writer, double OVERLAP_RESOLUTION_DIS
|
||||
std::swap(poly1, poly2);
|
||||
}
|
||||
|
||||
std::cerr << "processing: " << edge.first << " " << edge.second << std::endl;
|
||||
std::cerr << "area before: " << poly1->area() << " " << poly2->area() << std::endl;
|
||||
|
||||
bool is_ = edge == std::make_pair(25, 27);
|
||||
bool is_ = edge == std::make_pair<size_t, size_t>(25, 27);
|
||||
|
||||
bool success = false;
|
||||
if ((mp1 = maybe_take_first_if_single_item(create_and_convert_offset_polygon(OVERLAP_RESOLUTION_DISTANCE, *poly2)))) {
|
||||
@@ -657,8 +654,6 @@ void eliminate_overlaps(DebugWriter& debug_writer, double OVERLAP_RESOLUTION_DIS
|
||||
}
|
||||
}
|
||||
|
||||
std::cerr << "area after: " << poly1->area() << " " << poly2->area() << std::endl;
|
||||
|
||||
if (!success) {
|
||||
eliminated_polies.insert(swap ? edge.first : edge.second);
|
||||
continue;
|
||||
@@ -911,6 +906,681 @@ build_line_graph(const std::vector<Polygon_2>& input_polygons, SegmentLookup& se
|
||||
return {line_graph, midpoint_to_segment, segment_to_input_facet, midpoint_to_edge_length};
|
||||
}
|
||||
|
||||
using DPoint = CGAL::Simple_cartesian<double>::Point_2;
|
||||
using DDir = CGAL::Simple_cartesian<double>::Vector_2;
|
||||
using DBox = std::array<DPoint, 2>;
|
||||
|
||||
struct CenterLineGraphData {
|
||||
std::vector<Point_2> points;
|
||||
std::vector<DPoint> points_double;
|
||||
std::vector<double> widths;
|
||||
std::vector<std::pair<size_t, size_t>> edges;
|
||||
std::vector<std::vector<size_t>> incident_edges;
|
||||
};
|
||||
|
||||
struct LineRun {
|
||||
Point_2 start_exact;
|
||||
Point_2 end_exact;
|
||||
DPoint start;
|
||||
DPoint end;
|
||||
DDir direction;
|
||||
double avg_width;
|
||||
double length;
|
||||
size_t vertex_count;
|
||||
};
|
||||
|
||||
struct RunBoxRecord {
|
||||
size_t run_index;
|
||||
DPoint start;
|
||||
DPoint end;
|
||||
DDir direction;
|
||||
double width;
|
||||
double length;
|
||||
std::array<DPoint, 4> corners;
|
||||
DBox bbox;
|
||||
};
|
||||
|
||||
struct MergedBoxRecord {
|
||||
DPoint start;
|
||||
DPoint end;
|
||||
DDir direction;
|
||||
DDir normal;
|
||||
double avg_width;
|
||||
double length;
|
||||
size_t member_count;
|
||||
std::vector<size_t> members;
|
||||
std::array<DPoint, 4> corners;
|
||||
DBox bbox;
|
||||
Point_2 exact_start;
|
||||
Point_2 exact_end;
|
||||
};
|
||||
|
||||
struct BoxCluster {
|
||||
std::vector<size_t> members;
|
||||
MergedBoxRecord box;
|
||||
};
|
||||
|
||||
struct SnapCandidate {
|
||||
size_t box_index;
|
||||
double box_distance;
|
||||
double line_distance;
|
||||
Point_2 projection;
|
||||
};
|
||||
|
||||
DDir unit(const DDir& a) {
|
||||
auto n = std::sqrt(a.squared_length());
|
||||
if (n < 1.e-9) {
|
||||
return {0., 0.};
|
||||
}
|
||||
return a / n;
|
||||
}
|
||||
|
||||
DDir perpendicular(const DDir& a) {
|
||||
return DDir(-a.y(), a.x());
|
||||
}
|
||||
|
||||
DDir canonicalize_like(const DDir& a, const DDir& ref) {
|
||||
return (a * ref) < 0. ? -a : a;
|
||||
}
|
||||
|
||||
DPoint to_double_point(const Point_2& p) {
|
||||
return {CGAL::to_double(p.x()), CGAL::to_double(p.y())};
|
||||
}
|
||||
|
||||
Point_2 to_exact_point(const DPoint& p) {
|
||||
return Point_2(p.x(), p.y());
|
||||
}
|
||||
|
||||
double point_line_distance(const DPoint& p, const DPoint& line_point, const DDir& line_dir) {
|
||||
auto u = unit(line_dir);
|
||||
auto delta = (p - line_point);
|
||||
if (u.squared_length() < 1.e-18) {
|
||||
return std::sqrt(delta.squared_length());
|
||||
}
|
||||
return std::abs(CGAL::determinant(u.x(), u.y(), delta.x(), delta.y()));
|
||||
}
|
||||
|
||||
double angle_between_dirs_deg(const DDir& a, const DDir& b) {
|
||||
auto u = unit(a);
|
||||
auto v = unit(b);
|
||||
auto c = std::abs(u * v);
|
||||
if (c > 1.) {
|
||||
c = 1.;
|
||||
}
|
||||
return std::acos(c) * 180. / 3.14159265358979323846;
|
||||
}
|
||||
|
||||
std::array<DPoint, 4> rectangle_corners(const DPoint& start, const DPoint& end, double width) {
|
||||
auto u = unit(end - start);
|
||||
if (u.squared_length() < 1.e-18) {
|
||||
u = {1., 0.};
|
||||
}
|
||||
auto n = perpendicular(u);
|
||||
auto ext = width;
|
||||
auto p0 = start - u * ext;
|
||||
auto p1 = end + u * ext;
|
||||
auto w = n * (width / 2.);
|
||||
return {p0 + w, p1 + w, p1 - w, p0 - w};
|
||||
}
|
||||
|
||||
DBox aabb_from_points(const std::array<DPoint, 4>& corners) {
|
||||
DBox bbox{corners[0], corners[0]};
|
||||
for (auto& p : corners) {
|
||||
bbox[0] = {std::min(bbox[0].x(), p.x()), std::min(bbox[0].y(), p.y())};
|
||||
bbox[1] = {std::max(bbox[1].x(), p.x()), std::max(bbox[1].y(), p.y())};
|
||||
}
|
||||
return bbox;
|
||||
}
|
||||
|
||||
bool aabb_overlap(const DBox& a, const DBox& b, double eps = 1.e-9) {
|
||||
return a[0].x() <= b[1].x() + eps &&
|
||||
a[1].x() + eps >= b[0].x() &&
|
||||
a[0].y() <= b[1].y() + eps &&
|
||||
a[1].y() + eps >= b[0].y();
|
||||
}
|
||||
|
||||
CenterLineGraphData make_center_line_graph_data(
|
||||
const std::map<Point_2, std::vector<Point_2>>& line_graph,
|
||||
const std::map<Point_2, double>& midpoint_to_edge_length)
|
||||
{
|
||||
CenterLineGraphData graph;
|
||||
std::map<Point_2, size_t> point_to_index;
|
||||
|
||||
auto ensure_point = [&](const Point_2& p) {
|
||||
auto it = point_to_index.find(p);
|
||||
if (it != point_to_index.end()) {
|
||||
return it->second;
|
||||
}
|
||||
auto i = graph.points.size();
|
||||
point_to_index[p] = i;
|
||||
graph.points.push_back(p);
|
||||
graph.points_double.push_back(to_double_point(p));
|
||||
auto wt = midpoint_to_edge_length.find(p);
|
||||
graph.widths.push_back(wt == midpoint_to_edge_length.end() ? 0. : wt->second);
|
||||
graph.incident_edges.emplace_back();
|
||||
return i;
|
||||
};
|
||||
|
||||
for (auto& p : line_graph) {
|
||||
ensure_point(p.first);
|
||||
for (auto& q : p.second) {
|
||||
ensure_point(q);
|
||||
}
|
||||
}
|
||||
|
||||
std::set<std::pair<size_t, size_t>> seen_edges;
|
||||
for (auto& p : line_graph) {
|
||||
auto i = ensure_point(p.first);
|
||||
for (auto& q : p.second) {
|
||||
auto j = ensure_point(q);
|
||||
if (i == j) {
|
||||
continue;
|
||||
}
|
||||
auto e = i < j ? std::make_pair(i, j) : std::make_pair(j, i);
|
||||
if (seen_edges.insert(e).second) {
|
||||
auto k = graph.edges.size();
|
||||
graph.edges.push_back(e);
|
||||
graph.incident_edges[e.first].push_back(k);
|
||||
graph.incident_edges[e.second].push_back(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return graph;
|
||||
}
|
||||
|
||||
double segment_width(const CenterLineGraphData& graph, const std::pair<size_t, size_t>& edge) {
|
||||
return 0.5 * (graph.widths[edge.first] + graph.widths[edge.second]);
|
||||
}
|
||||
|
||||
bool edge_supports_same_line(
|
||||
const DPoint& seed_a,
|
||||
const DPoint& seed_b,
|
||||
const DPoint& test_a,
|
||||
const DPoint& test_b,
|
||||
double angle_tol_deg = 3.,
|
||||
double line_dist_tol = 0.15)
|
||||
{
|
||||
auto d_seed = seed_b - seed_a;
|
||||
auto d_test = test_b - test_a;
|
||||
if (d_seed.squared_length() < 1.e-18 || d_test.squared_length() < 1.e-18) {
|
||||
return false;
|
||||
}
|
||||
if (angle_between_dirs_deg(d_seed, d_test) > angle_tol_deg) {
|
||||
return false;
|
||||
}
|
||||
return
|
||||
point_line_distance(test_a, seed_a, d_seed) <= line_dist_tol &&
|
||||
point_line_distance(test_b, seed_a, d_seed) <= line_dist_tol;
|
||||
}
|
||||
|
||||
std::vector<LineRun> runs_from_graph(const CenterLineGraphData& graph, double angle_tol_deg = 3., double line_dist_tol = 0.15) {
|
||||
std::vector<bool> visited(graph.edges.size(), false);
|
||||
std::vector<LineRun> runs;
|
||||
|
||||
for (size_t seed_ei = 0; seed_ei < graph.edges.size(); ++seed_ei) {
|
||||
if (visited[seed_ei]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const auto& seed_edge = graph.edges[seed_ei];
|
||||
auto seed_a = graph.points_double[seed_edge.first];
|
||||
auto seed_b = graph.points_double[seed_edge.second];
|
||||
auto seed_dir = seed_b - seed_a;
|
||||
if (seed_dir.squared_length() < 1.e-18) {
|
||||
visited[seed_ei] = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
std::vector<size_t> queue = {seed_ei};
|
||||
std::set<size_t> component_edges;
|
||||
|
||||
while (!queue.empty()) {
|
||||
auto ei = queue.back();
|
||||
queue.pop_back();
|
||||
if (!component_edges.insert(ei).second) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const auto& edge = graph.edges[ei];
|
||||
std::array<size_t, 2> vertices = {edge.first, edge.second};
|
||||
for (auto v : vertices) {
|
||||
for (auto ej : graph.incident_edges[v]) {
|
||||
if (ej == ei || visited[ej] || component_edges.count(ej)) {
|
||||
continue;
|
||||
}
|
||||
const auto& candidate = graph.edges[ej];
|
||||
auto test_a = graph.points_double[candidate.first];
|
||||
auto test_b = graph.points_double[candidate.second];
|
||||
if (edge_supports_same_line(seed_a, seed_b, test_a, test_b, angle_tol_deg, line_dist_tol)) {
|
||||
queue.push_back(ej);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (auto ei : component_edges) {
|
||||
visited[ei] = true;
|
||||
}
|
||||
|
||||
std::set<size_t> component_vertices;
|
||||
auto ref = unit(seed_dir);
|
||||
DDir direction_sum{0., 0.};
|
||||
double total_length = 0.;
|
||||
double weighted_width_sum = 0.;
|
||||
|
||||
for (auto ei : component_edges) {
|
||||
const auto& edge = graph.edges[ei];
|
||||
component_vertices.insert(edge.first);
|
||||
component_vertices.insert(edge.second);
|
||||
|
||||
auto d = graph.points_double[edge.second] - graph.points_double[edge.first];
|
||||
auto u = canonicalize_like(unit(d), ref);
|
||||
direction_sum = direction_sum + u;
|
||||
|
||||
auto len = std::sqrt(d.squared_length());
|
||||
total_length += len;
|
||||
weighted_width_sum += len * segment_width(graph, edge);
|
||||
}
|
||||
|
||||
auto run_direction = direction_sum.squared_length() < 1.e-18 ? ref : unit(direction_sum);
|
||||
|
||||
double min_t = std::numeric_limits<double>::infinity();
|
||||
double max_t = -std::numeric_limits<double>::infinity();
|
||||
size_t start_index = *component_vertices.begin();
|
||||
size_t end_index = start_index;
|
||||
for (auto vi : component_vertices) {
|
||||
auto t = (graph.points_double[vi] - CGAL::ORIGIN) * run_direction;
|
||||
if (t < min_t) {
|
||||
min_t = t;
|
||||
start_index = vi;
|
||||
}
|
||||
if (t > max_t) {
|
||||
max_t = t;
|
||||
end_index = vi;
|
||||
}
|
||||
}
|
||||
|
||||
auto avg_width = total_length < 1.e-9 ? segment_width(graph, seed_edge) : weighted_width_sum / total_length;
|
||||
|
||||
runs.push_back({
|
||||
graph.points[start_index],
|
||||
graph.points[end_index],
|
||||
graph.points_double[start_index],
|
||||
graph.points_double[end_index],
|
||||
run_direction,
|
||||
avg_width,
|
||||
std::sqrt((graph.points_double[end_index] - graph.points_double[start_index]).squared_length()),
|
||||
component_vertices.size()
|
||||
});
|
||||
}
|
||||
|
||||
return runs;
|
||||
}
|
||||
|
||||
std::vector<RunBoxRecord> build_run_box_records(const std::vector<LineRun>& runs) {
|
||||
std::vector<RunBoxRecord> records;
|
||||
records.reserve(runs.size());
|
||||
for (size_t i = 0; i < runs.size(); ++i) {
|
||||
auto corners = rectangle_corners(runs[i].start, runs[i].end, runs[i].avg_width);
|
||||
records.push_back({
|
||||
i,
|
||||
runs[i].start,
|
||||
runs[i].end,
|
||||
unit(runs[i].end - runs[i].start),
|
||||
runs[i].avg_width,
|
||||
runs[i].length,
|
||||
corners,
|
||||
aabb_from_points(corners)
|
||||
});
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
template <typename T>
|
||||
std::pair<double, double> projected_interval_on_axis(const T& box, const DDir& axis_u) {
|
||||
auto u = unit(axis_u);
|
||||
auto ta = (box.start - CGAL::ORIGIN) * u;
|
||||
auto tb = (box.end - CGAL::ORIGIN) * u;
|
||||
return {std::min(ta, tb), std::max(ta, tb)};
|
||||
}
|
||||
|
||||
double interval_overlap_length(const std::pair<double, double>& a, const std::pair<double, double>& b) {
|
||||
return std::max(0., std::min(a.second, b.second) - std::max(a.first, b.first));
|
||||
}
|
||||
|
||||
template <typename T>
|
||||
double boxes_overlap_along_merge_axis(const T& a, const T& b) {
|
||||
auto d1 = unit(a.end - a.start);
|
||||
auto d2 = unit(b.end - b.start);
|
||||
if (d1 * d2 < 0.) {
|
||||
d2 = {-d2.x(), -d2.y()};
|
||||
}
|
||||
auto merge_axis = unit(d1 + d2);
|
||||
if (merge_axis.squared_length() < 1.e-18) {
|
||||
merge_axis = d1;
|
||||
}
|
||||
|
||||
auto i1 = projected_interval_on_axis(a, merge_axis);
|
||||
auto i2 = projected_interval_on_axis(b, merge_axis);
|
||||
auto overlap = interval_overlap_length(i1, i2);
|
||||
auto small_length = std::min(i1.second - i1.first, i2.second - i2.first);
|
||||
if (small_length < 1.e-9) {
|
||||
return false;
|
||||
}
|
||||
return overlap / small_length;
|
||||
}
|
||||
|
||||
MergedBoxRecord merge_cluster_to_box(const std::vector<size_t>& member_indices, const std::vector<RunBoxRecord>& records) {
|
||||
auto ref = records[member_indices.front()].direction;
|
||||
DDir direction_sum{0., 0.};
|
||||
for (auto i : member_indices) {
|
||||
auto u = canonicalize_like(records[i].direction, ref);
|
||||
direction_sum = direction_sum + u * std::max(records[i].length, 1.e-9);
|
||||
}
|
||||
|
||||
auto u = direction_sum.squared_length() < 1.e-18 ? ref : unit(direction_sum);
|
||||
auto n = perpendicular(u);
|
||||
|
||||
double tmin = std::numeric_limits<double>::infinity();
|
||||
double tmax = -std::numeric_limits<double>::infinity();
|
||||
double smin = std::numeric_limits<double>::infinity();
|
||||
double smax = -std::numeric_limits<double>::infinity();
|
||||
|
||||
for (auto i : member_indices) {
|
||||
for (auto& corner : records[i].corners) {
|
||||
auto t = (corner - CGAL::ORIGIN) * u;
|
||||
auto s = (corner - CGAL::ORIGIN) * n;
|
||||
tmin = std::min(tmin, t);
|
||||
tmax = std::max(tmax, t);
|
||||
smin = std::min(smin, s);
|
||||
smax = std::max(smax, s);
|
||||
}
|
||||
}
|
||||
|
||||
auto width = smax - smin;
|
||||
auto sc = (smin + smax) / 2.;
|
||||
auto start = u * tmin + n * sc;
|
||||
auto end = u * tmax + n * sc;
|
||||
auto corners = rectangle_corners(CGAL::ORIGIN + start, CGAL::ORIGIN + end, width);
|
||||
|
||||
MergedBoxRecord box{
|
||||
CGAL::ORIGIN + start,
|
||||
CGAL::ORIGIN + end,
|
||||
u,
|
||||
n,
|
||||
width,
|
||||
std::sqrt((end - start).squared_length()),
|
||||
member_indices.size(),
|
||||
member_indices,
|
||||
corners,
|
||||
aabb_from_points(corners),
|
||||
to_exact_point(CGAL::ORIGIN + start),
|
||||
to_exact_point(CGAL::ORIGIN + end)
|
||||
};
|
||||
return box;
|
||||
}
|
||||
|
||||
std::pair<double, double> merge_score(const MergedBoxRecord& a, const MergedBoxRecord& b) {
|
||||
auto ang = angle_between_dirs_deg(a.direction, b.direction);
|
||||
auto center_a = ((a.start - CGAL::ORIGIN) + (a.end - CGAL::ORIGIN)) / 2.;
|
||||
auto center_b = ((b.start - CGAL::ORIGIN) + (b.end - CGAL::ORIGIN)) / 2.;
|
||||
return {ang, std::sqrt((center_b - center_a).squared_length())};
|
||||
}
|
||||
|
||||
bool clusters_can_merge(const BoxCluster& a, const BoxCluster& b, double angle_tol_deg = 5., double axis_overlap_ratio_limit = 0.5) {
|
||||
if (!aabb_overlap(a.box.bbox, b.box.bbox)) {
|
||||
return false;
|
||||
}
|
||||
if (angle_between_dirs_deg(a.box.direction, b.box.direction) > angle_tol_deg) {
|
||||
return false;
|
||||
}
|
||||
if (boxes_overlap_along_merge_axis(a.box, b.box) > axis_overlap_ratio_limit) {
|
||||
auto a_center = CGAL::ORIGIN + ((a.box.start - CGAL::ORIGIN) + (a.box.end - CGAL::ORIGIN)) / 2.;
|
||||
auto b_center = CGAL::ORIGIN + ((b.box.start - CGAL::ORIGIN) + (b.box.end - CGAL::ORIGIN)) / 2.;
|
||||
auto a_dir = a.box.direction;
|
||||
auto b_dir = b.box.direction;
|
||||
auto dist = a.box.length < b.box.length ? point_line_distance(a_center, b_center, b_dir) : point_line_distance(b_center, a_center, a_dir);
|
||||
auto ref = a.box.length < b.box.length ? a.box.avg_width : b.box.avg_width;
|
||||
return dist < (ref / 4.);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
std::vector<MergedBoxRecord> merge_intersecting_parallel_boxes_iterative(const std::vector<LineRun>& runs) {
|
||||
auto records = build_run_box_records(runs);
|
||||
std::vector<BoxCluster> clusters;
|
||||
clusters.reserve(records.size());
|
||||
for (size_t i = 0; i < records.size(); ++i) {
|
||||
clusters.push_back({{i}, merge_cluster_to_box({i}, records)});
|
||||
}
|
||||
|
||||
while (true) {
|
||||
std::optional<std::pair<size_t, size_t>> best_pair;
|
||||
std::pair<double, double> best_score;
|
||||
|
||||
for (size_t i = 0; i < clusters.size(); ++i) {
|
||||
for (size_t j = i + 1; j < clusters.size(); ++j) {
|
||||
if (!clusters_can_merge(clusters[i], clusters[j])) {
|
||||
continue;
|
||||
}
|
||||
auto score = merge_score(clusters[i].box, clusters[j].box);
|
||||
if (!best_pair || score < best_score) {
|
||||
best_pair = std::make_pair(i, j);
|
||||
best_score = score;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!best_pair) {
|
||||
break;
|
||||
}
|
||||
|
||||
auto i = best_pair->first;
|
||||
auto j = best_pair->second;
|
||||
std::vector<size_t> members = clusters[i].members;
|
||||
members.insert(members.end(), clusters[j].members.begin(), clusters[j].members.end());
|
||||
auto merged = BoxCluster{members, merge_cluster_to_box(members, records)};
|
||||
|
||||
std::vector<BoxCluster> next_clusters;
|
||||
next_clusters.reserve(clusters.size() - 1);
|
||||
for (size_t k = 0; k < clusters.size(); ++k) {
|
||||
if (k != i && k != j) {
|
||||
next_clusters.push_back(std::move(clusters[k]));
|
||||
}
|
||||
}
|
||||
next_clusters.push_back(std::move(merged));
|
||||
clusters = std::move(next_clusters);
|
||||
}
|
||||
|
||||
std::vector<MergedBoxRecord> merged_boxes;
|
||||
merged_boxes.reserve(clusters.size());
|
||||
for (auto& cluster : clusters) {
|
||||
merged_boxes.push_back(cluster.box);
|
||||
}
|
||||
return merged_boxes;
|
||||
}
|
||||
|
||||
Point_2 project_point_to_line_exact(const Point_2& p, const MergedBoxRecord& box) {
|
||||
auto d = box.exact_end - box.exact_start;
|
||||
if (d.squared_length() == 0) {
|
||||
return box.exact_start;
|
||||
}
|
||||
auto t = ((p - box.exact_start) * d) / d.squared_length();
|
||||
return box.exact_start + d * t;
|
||||
}
|
||||
|
||||
boost::optional<Point_2> intersect_infinite_lines_exact(const MergedBoxRecord& a, const MergedBoxRecord& b) {
|
||||
if (a.exact_start == a.exact_end || b.exact_start == b.exact_end) {
|
||||
return boost::none;
|
||||
}
|
||||
auto x = CGAL::intersection(CGAL::Line_2<K>(a.exact_start, a.exact_end), CGAL::Line_2<K>(b.exact_start, b.exact_end));
|
||||
if (!x) {
|
||||
return boost::none;
|
||||
}
|
||||
if (auto* xp = variant_get<Point_2>(&*x)) {
|
||||
return *xp;
|
||||
}
|
||||
return boost::none;
|
||||
}
|
||||
|
||||
double point_to_oriented_box_distance(const DPoint& p, const MergedBoxRecord& box) {
|
||||
auto d = box.end - box.start;
|
||||
auto L = std::sqrt(d.squared_length());
|
||||
if (L < 1.e-9) {
|
||||
return std::sqrt((p - box.start).squared_length());
|
||||
}
|
||||
|
||||
auto u = d / L;
|
||||
auto n = perpendicular(u);
|
||||
auto rel = p - box.start;
|
||||
auto t = rel * u;
|
||||
auto s = rel * n;
|
||||
|
||||
auto tmin = -box.avg_width / 2.;
|
||||
auto tmax = L + box.avg_width / 2.;
|
||||
auto smin = -box.avg_width / 2.;
|
||||
auto smax = box.avg_width / 2.;
|
||||
|
||||
double dt = 0.;
|
||||
if (t < tmin) {
|
||||
dt = tmin - t;
|
||||
} else if (t > tmax) {
|
||||
dt = t - tmax;
|
||||
}
|
||||
|
||||
double ds = 0.;
|
||||
if (s < smin) {
|
||||
ds = smin - s;
|
||||
} else if (s > smax) {
|
||||
ds = s - smax;
|
||||
}
|
||||
|
||||
return std::hypot(dt, ds);
|
||||
}
|
||||
|
||||
std::map<Point_2, std::vector<Point_2>> snap_points_to_box_axes(
|
||||
const CenterLineGraphData& graph,
|
||||
const std::vector<MergedBoxRecord>& boxes)
|
||||
{
|
||||
std::vector<Point_2> snapped_points(graph.points.size());
|
||||
|
||||
for (size_t i = 0; i < graph.points.size(); ++i) {
|
||||
if (boxes.empty()) {
|
||||
snapped_points[i] = graph.points[i];
|
||||
continue;
|
||||
}
|
||||
|
||||
std::vector<SnapCandidate> candidates;
|
||||
candidates.reserve(boxes.size());
|
||||
for (size_t j = 0; j < boxes.size(); ++j) {
|
||||
candidates.push_back({
|
||||
j,
|
||||
point_to_oriented_box_distance(graph.points_double[i], boxes[j]),
|
||||
point_line_distance(graph.points_double[i], boxes[j].start, boxes[j].direction),
|
||||
project_point_to_line_exact(graph.points[i], boxes[j])
|
||||
});
|
||||
}
|
||||
|
||||
std::vector<SnapCandidate> containing;
|
||||
for (auto& candidate : candidates) {
|
||||
if (candidate.box_distance <= 1.e-9) {
|
||||
containing.push_back(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
auto less = [](const SnapCandidate& a, const SnapCandidate& b) {
|
||||
if (a.line_distance != b.line_distance) {
|
||||
return a.line_distance < b.line_distance;
|
||||
}
|
||||
return a.box_distance < b.box_distance;
|
||||
};
|
||||
|
||||
if (containing.size() >= 2) {
|
||||
std::sort(containing.begin(), containing.end(), less);
|
||||
auto& c1 = containing[0];
|
||||
auto& c2 = containing[1];
|
||||
if (angle_between_dirs_deg(boxes[c1.box_index].direction, boxes[c2.box_index].direction) > 8.) {
|
||||
if (auto x = intersect_infinite_lines_exact(boxes[c1.box_index], boxes[c2.box_index])) {
|
||||
snapped_points[i] = *x;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
snapped_points[i] = c1.projection;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (containing.size() == 1) {
|
||||
snapped_points[i] = containing[0].projection;
|
||||
continue;
|
||||
}
|
||||
|
||||
auto best = *std::min_element(candidates.begin(), candidates.end(), [](const SnapCandidate& a, const SnapCandidate& b) {
|
||||
if (a.box_distance != b.box_distance) {
|
||||
return a.box_distance < b.box_distance;
|
||||
}
|
||||
return a.line_distance < b.line_distance;
|
||||
});
|
||||
snapped_points[i] = best.projection;
|
||||
}
|
||||
|
||||
std::map<Point_2, std::set<Point_2>> adjacency;
|
||||
for (auto& edge : graph.edges) {
|
||||
auto a = snapped_points[edge.first];
|
||||
auto b = snapped_points[edge.second];
|
||||
if (a == b) {
|
||||
continue;
|
||||
}
|
||||
adjacency[a].insert(b);
|
||||
adjacency[b].insert(a);
|
||||
}
|
||||
|
||||
std::map<Point_2, std::vector<Point_2>> snapped_graph;
|
||||
for (auto& p : adjacency) {
|
||||
snapped_graph[p.first] = {p.second.begin(), p.second.end()};
|
||||
}
|
||||
return snapped_graph;
|
||||
}
|
||||
|
||||
Graph2D<K> join_segment_runs(
|
||||
DebugWriter& debug,
|
||||
const std::map<Point_2, std::vector<Point_2>>& line_graph,
|
||||
const std::map<Point_2, double>& midpoint_to_edge_length)
|
||||
{
|
||||
auto graph = make_center_line_graph_data(line_graph, midpoint_to_edge_length);
|
||||
auto runs = runs_from_graph(graph);
|
||||
runs.erase(std::remove_if(runs.begin(), runs.end(), [](const LineRun& run) {
|
||||
return run.vertex_count <= 5;
|
||||
}), runs.end());
|
||||
|
||||
std::vector<Polygon_2> run_polygons;
|
||||
for (auto& r : runs) {
|
||||
auto ps = rectangle_corners(r.start, r.end, r.avg_width);
|
||||
std::array<Point_2, 4> exact_corners;
|
||||
std::transform(ps.begin(), ps.end(), exact_corners.begin(), [](const DPoint& p) {
|
||||
return to_exact_point(p);
|
||||
});
|
||||
run_polygons.emplace_back(exact_corners.begin(), exact_corners.end());
|
||||
}
|
||||
debug.write_polygons(run_polygons, "initial_runs");
|
||||
run_polygons.clear();
|
||||
|
||||
auto boxes = merge_intersecting_parallel_boxes_iterative(runs);
|
||||
|
||||
for (auto& r : boxes) {
|
||||
auto ps = rectangle_corners(r.start, r.end, r.avg_width);
|
||||
std::array<Point_2, 4> exact_corners;
|
||||
std::transform(ps.begin(), ps.end(), exact_corners.begin(), [](const DPoint& p) {
|
||||
return to_exact_point(p);
|
||||
});
|
||||
run_polygons.emplace_back(exact_corners.begin(), exact_corners.end());
|
||||
}
|
||||
debug.write_polygons(run_polygons, "merged_boxes");
|
||||
|
||||
auto snapped_graph = snap_points_to_box_axes(graph, boxes);
|
||||
return Graph2D<K>(snapped_graph);
|
||||
}
|
||||
|
||||
std::set<Triangle<K>> find_triangles(const std::map<Point_2, std::vector<Point_2>>& line_graph) {
|
||||
// Find triangles in this network often occuring at junctions in the corridor mesh
|
||||
std::set<Triangle<K>> triangles;
|
||||
@@ -1249,8 +1919,6 @@ std::list<std::pair<Point_2, Point_2>> extend_end_vertices_based_on_input(
|
||||
// create ray incoming -> M
|
||||
CGAL::Ray_2<K> ray(incoming, M - incoming);
|
||||
|
||||
std::cerr << "Extending end vertex " << M << " along ray " << ray << " to boundary of input polygon" << std::endl;
|
||||
|
||||
// intersect ray with boundary
|
||||
boost::optional<CGAL::Segment_2<K>> closest_segment;
|
||||
boost::optional<CGAL::Point_2<K>> closest_intersection_point;
|
||||
@@ -1261,7 +1929,6 @@ std::list<std::pair<Point_2, Point_2>> extend_end_vertices_based_on_input(
|
||||
if (x) {
|
||||
if (auto* xp = variant_get<CGAL::Point_2<K>>(&*x)) {
|
||||
auto dist = ((*xp) - M).squared_length();
|
||||
std::cerr << " - found " << *xp << " on segment " << seg << " with distance " << std::sqrt(CGAL::to_double(dist)) << std::endl;
|
||||
if (dist < sq_distance_along_ray) {
|
||||
if (dist < (max_projection_distance * max_projection_distance)) {
|
||||
closest_segment = seg;
|
||||
@@ -1329,9 +1996,11 @@ std::list<std::pair<Point_2, Point_2>> extend_end_vertices_based_on_input(
|
||||
auto Pp = seg.supporting_line().projection(M);
|
||||
if (seg.has_on(Pp)) {
|
||||
auto d = CGAL::squared_distance(Pp, M);
|
||||
if (d < closest_distance) {
|
||||
closest_distance = d;
|
||||
closest_point = Pp;
|
||||
if (d < (max_projection_distance * max_projection_distance)) {
|
||||
if (d < closest_distance) {
|
||||
closest_distance = d;
|
||||
closest_point = Pp;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1550,7 +2219,7 @@ std::vector<K::FT> arrangement_cell_iou(Arrangement_2& left, Arrangement_2& righ
|
||||
}
|
||||
|
||||
auto res = walk_pl.locate(best_point);
|
||||
if (auto* v = boost::get<Arrangement_2::Face_const_handle>(&res)) {
|
||||
if (auto* v = variant_get<Arrangement_2::Face_const_handle>(&res)) {
|
||||
if (visited_faces_on_right.count(*v) > 0) {
|
||||
return_values.push_back(0);
|
||||
} else {
|
||||
@@ -1651,13 +2320,10 @@ void clean_noisy_paths(DebugWriter& debug_output, Arrangement_2& arr, SegmentLoo
|
||||
return (best + 0.01) / own_length;
|
||||
};
|
||||
|
||||
std::cerr << "badnesses:";
|
||||
std::map<Segment_2, double, Segment_2_less> badnesses;
|
||||
for (auto& e : edges) {
|
||||
badnesses[e] = edge_badness(e);
|
||||
std::cerr << " (" << e.source().x() << "," << e.source().y() << ") - (" << e.target().x() << "," << e.target().y() << "): " << badnesses[e] << ";";
|
||||
}
|
||||
std::cerr << std::endl;
|
||||
|
||||
{
|
||||
std::vector<double> tmp;
|
||||
@@ -1670,8 +2336,6 @@ void clean_noisy_paths(DebugWriter& debug_output, Arrangement_2& arr, SegmentLoo
|
||||
threshold = 4.0 * med;
|
||||
}
|
||||
|
||||
std::cerr << "badness threshold: " << threshold << std::endl;
|
||||
|
||||
std::set<Segment_2, Segment_2_less> bad_edges;
|
||||
for (auto& p : badnesses) {
|
||||
if (p.second > threshold) {
|
||||
@@ -1835,14 +2499,6 @@ void clean_noisy_paths(DebugWriter& debug_output, Arrangement_2& arr, SegmentLoo
|
||||
decltype(to_remove) to_remove_this_path;
|
||||
decltype(to_insert) to_insert_this_path;
|
||||
|
||||
std::cerr << "Processing bad path:";
|
||||
for (size_t i = 0; i < path.size() - 1; ++i) {
|
||||
auto& a = path[i];
|
||||
auto& b = path[i + 1];
|
||||
std::cerr << " (" << a.x() << "," << a.y() << ") - (" << b.x() << "," << b.y() << ");";
|
||||
}
|
||||
std::cerr << std::endl;
|
||||
|
||||
for (size_t i = 0; i < path.size() - 1; ++i) {
|
||||
auto& a = path[i];
|
||||
auto& b = path[i + 1];
|
||||
@@ -1852,7 +2508,7 @@ void clean_noisy_paths(DebugWriter& debug_output, Arrangement_2& arr, SegmentLoo
|
||||
|
||||
auto x = collapse_path(path);
|
||||
if (!x) {
|
||||
std::cerr << "Unable to collapse path, skipping" << std::endl;
|
||||
// std::cerr << "Unable to collapse path, skipping" << std::endl;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1866,16 +2522,14 @@ void clean_noisy_paths(DebugWriter& debug_output, Arrangement_2& arr, SegmentLoo
|
||||
double new_length = std::sqrt(CGAL::to_double((path.front() - *x).squared_length())) + std::sqrt(CGAL::to_double((path.back() - *x).squared_length()));
|
||||
|
||||
if (new_length > orig_length * 2 || orig_length > new_length * 2) {
|
||||
std::cerr << "Collapsing path would increase length too much, skipping" << std::endl;
|
||||
// std::cerr << "Collapsing path would increase length too much, skipping" << std::endl;
|
||||
continue;
|
||||
}
|
||||
|
||||
std::cerr << "new_length: " << new_length << " orig_length: " << orig_length << std::endl;
|
||||
|
||||
for (size_t i = 0; i < path.size(); ++i) {
|
||||
auto& v = path[i];
|
||||
if (CGAL::squared_distance(v, *x) < 1.e-5) {
|
||||
std::cerr << "Collapsing path would create near-duplicate vert to previous path, skipping" << std::endl;
|
||||
// std::cerr << "Collapsing path would create near-duplicate vert to previous path, skipping" << std::endl;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -2059,7 +2713,6 @@ void clean_noisy_bounds(DebugWriter& debug_output, Arrangement_2& arr, SegmentLo
|
||||
|
||||
size_t facet_index = 0;
|
||||
for (auto it = arr.faces_begin(); it != arr.faces_end(); ++it, ++facet_index) {
|
||||
std::cout << "facet_index " << facet_index << std::endl;
|
||||
if (!it->is_unbounded()) {
|
||||
std::set<std::pair<Point_2, Point_2>> to_remove;
|
||||
std::vector<std::pair<Point_2, Point_2>> to_insert;
|
||||
@@ -2078,17 +2731,14 @@ void clean_noisy_bounds(DebugWriter& debug_output, Arrangement_2& arr, SegmentLo
|
||||
++circ;
|
||||
} while (circ != it->outer_ccb());
|
||||
|
||||
std::cerr << "badnesses:";
|
||||
std::vector<double> badnesses;
|
||||
for (auto& e : segs) {
|
||||
badnesses.push_back(edge_badness(e));
|
||||
std::cerr << " (" << e.source().x() << "," << e.source().y() << ") - (" << e.target().x() << "," << e.target().y() << "): " << badnesses.back() << ";";
|
||||
}
|
||||
std::cerr << std::endl;
|
||||
|
||||
auto bit = std::min_element(badnesses.begin(), badnesses.end());
|
||||
if (*bit > threshold) {
|
||||
std::cerr << "All edges are good, skipping" << std::endl;
|
||||
// std::cerr << "All edges are good, skipping" << std::endl;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -2096,19 +2746,15 @@ void clean_noisy_bounds(DebugWriter& debug_output, Arrangement_2& arr, SegmentLo
|
||||
auto N = circular_distance(it_pair.first, it_pair.second, badnesses);
|
||||
|
||||
if (N == 0) {
|
||||
std::cerr << "Unable to find run of bad edges, skipping" << std::endl;
|
||||
// std::cerr << "Unable to find run of bad edges, skipping" << std::endl;
|
||||
continue;
|
||||
}
|
||||
|
||||
std::vector<std::vector<Point_2>> incoming_paths;
|
||||
|
||||
std::cout << "range " << std::distance(badnesses.cbegin(), it_pair.first) << " to " << std::distance(badnesses.cbegin(), it_pair.second) << " length " << N << std::endl;
|
||||
|
||||
auto jt = it_pair.first;
|
||||
for (std::size_t k = 0; k < N; ++k, next_circular(jt, badnesses)) {
|
||||
|
||||
std::cout << " at " << std::distance(badnesses.cbegin(), jt) << " badness: " << *jt << std::endl;
|
||||
|
||||
auto he = halfedges[std::distance(badnesses.cbegin(), jt)];
|
||||
to_remove.insert({he->source()->point(), he->target()->point()});
|
||||
debug_output.write_segment(he->source()->point(), he->target()->point(), "arr_bad_bound facet_" + std::to_string(facet_index));
|
||||
@@ -2175,12 +2821,9 @@ void clean_noisy_bounds(DebugWriter& debug_output, Arrangement_2& arr, SegmentLo
|
||||
CGAL::Ray_2<K> r1((*a)->point(), (*b)->point());
|
||||
CGAL::Ray_2<K> r2((*d)->point(), (*c)->point());
|
||||
|
||||
std::cout << "a: (" << (*a)->point().x() << "," << (*a)->point().y() << ") b: (" << (*b)->point().x() << "," << (*b)->point().y() << ") c: (" << (*c)->point().x() << "," << (*c)->point().y() << ") d: (" << (*d)->point().x() << "," << (*d)->point().y() << ")" << std::endl;
|
||||
|
||||
auto x = CGAL::intersection(r1, r2);
|
||||
if (x) {
|
||||
if (auto* xp = variant_get<CGAL::Point_2<K>>(&*x)) {
|
||||
std::cout << "ray xp: (" << xp->x() << "," << xp->y() << ")" << std::endl;
|
||||
to_insert.emplace_back((*b)->point(), *xp);
|
||||
to_insert.emplace_back((*c)->point(), *xp);
|
||||
|
||||
@@ -2194,7 +2837,6 @@ void clean_noisy_bounds(DebugWriter& debug_output, Arrangement_2& arr, SegmentLo
|
||||
auto x = CGAL::intersection(r1, r2);
|
||||
if (x) {
|
||||
if (auto* xp = variant_get<CGAL::Point_2<K>>(&*x)) {
|
||||
std::cout << "line xp: (" << xp->x() << "," << xp->y() << ")" << std::endl;
|
||||
to_insert.emplace_back((*b)->point(), *xp);
|
||||
to_insert.emplace_back((*c)->point(), *xp);
|
||||
|
||||
@@ -2495,64 +3137,44 @@ void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std
|
||||
}
|
||||
}
|
||||
|
||||
// Write a JSON structure with center line topology with the midpoint_to_edge_length as per-point data
|
||||
{
|
||||
std::ofstream ofs("center_line_topology.json");
|
||||
ofs << "{\n";
|
||||
ofs << " \"vertices\": [\n";
|
||||
bool first_vertex = true;
|
||||
for (auto& p : line_graph) {
|
||||
if (!first_vertex) {
|
||||
ofs << ",\n";
|
||||
}
|
||||
first_vertex = false;
|
||||
ofs << " {\n";
|
||||
ofs << " \"point\": [" << p.first.x() << ", " << p.first.y() << "],\n";
|
||||
ofs << " \"width\":" << midpoint_to_edge_length.find(p.first)->second << ",\n";
|
||||
ofs << " \"connected_to\": [\n";
|
||||
bool first_connected = true;
|
||||
for (auto& q : p.second) {
|
||||
if (!first_connected) {
|
||||
ofs << ",\n";
|
||||
}
|
||||
first_connected = false;
|
||||
ofs << " [" << q.x() << ", " << q.y() << "]";
|
||||
}
|
||||
ofs << "\n ]\n";
|
||||
ofs << " }";
|
||||
}
|
||||
ofs << "\n ]\n";
|
||||
ofs << "}\n";
|
||||
}
|
||||
|
||||
t0.stop();
|
||||
|
||||
t0 = timer.start("center line cleaning");
|
||||
|
||||
auto triangles = find_triangles(line_graph);
|
||||
|
||||
// For every triangle found in the network we eliminate one edge to break the cycle
|
||||
// The edge we eliminate is the edge with the greatest angle with any of it's neighbours
|
||||
auto eliminated_segments = eliminate_triangles(line_graph);
|
||||
Graph2D<K> G;
|
||||
if (settings.line_cleaning_algo == 0) {
|
||||
G = join_segment_runs(debug_output, line_graph, midpoint_to_edge_length);
|
||||
Arrangement_2 arr;
|
||||
G.to_arrangement(arr);
|
||||
Graph2D<K> G2;
|
||||
G2.from_arrangement(arr);
|
||||
eliminate_colinear_vertices(G2);
|
||||
G = G2;
|
||||
for (auto it = G.edges_begin(); it != G.edges_end(); ++it) {
|
||||
debug_output.write_segment(it->first, it->second, "network_2");
|
||||
}
|
||||
} else {
|
||||
auto eliminated_segments = eliminate_triangles(line_graph);
|
||||
|
||||
Graph2D<K> G2(line_graph);
|
||||
for (auto& e : eliminated_segments) {
|
||||
debug_output.write_segment(e.first, e.second, "eliminated");
|
||||
G2.remove_edge(e.first, e.second);
|
||||
}
|
||||
Graph2D<K> G2(line_graph);
|
||||
for (auto& e : eliminated_segments) {
|
||||
debug_output.write_segment(e.first, e.second, "eliminated");
|
||||
G2.remove_edge(e.first, e.second);
|
||||
}
|
||||
|
||||
auto G = G2.weld_vertices();
|
||||
G = G2.weld_vertices();
|
||||
|
||||
for (auto it = G.edges_begin(); it != G.edges_end(); ++it) {
|
||||
debug_output.write_segment(it->first, it->second, "network_2");
|
||||
}
|
||||
for (auto it = G.edges_begin(); it != G.edges_end(); ++it) {
|
||||
debug_output.write_segment(it->first, it->second, "network_2");
|
||||
}
|
||||
|
||||
eliminate_colinear_vertices(G);
|
||||
eliminate_colinear_vertices(G);
|
||||
|
||||
edge_slide(G);
|
||||
edge_slide(G);
|
||||
|
||||
for (auto it = G.edges_begin(); it != G.edges_end(); ++it) {
|
||||
debug_output.write_segment(it->first, it->second, "network_3");
|
||||
for (auto it = G.edges_begin(); it != G.edges_end(); ++it) {
|
||||
debug_output.write_segment(it->first, it->second, "network_3");
|
||||
}
|
||||
}
|
||||
|
||||
t0.stop();
|
||||
@@ -2629,7 +3251,7 @@ void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std
|
||||
fuse_corridor_halves_with_input(arr, G, segment_lookup, input_polygons, debug_output);
|
||||
}
|
||||
|
||||
if (settings.perform_cleanup) {
|
||||
if (settings.perform_cleanup && settings.line_cleaning_algo != 0) {
|
||||
remove_colinear_vertices(arr);
|
||||
double threshold;
|
||||
clean_noisy_paths(debug_output, arr, segment_lookup, threshold);
|
||||
|
||||
@@ -346,6 +346,13 @@ public:
|
||||
}
|
||||
}
|
||||
|
||||
template <typename T>
|
||||
void from_arrangement(T& arr) {
|
||||
for (auto it = arr.edges_begin(); it != arr.edges_end(); ++it) {
|
||||
insert(it->source()->point(), it->target()->point());
|
||||
}
|
||||
}
|
||||
|
||||
void assert_symmetric() {
|
||||
#ifdef SVGFILL_DEBUG
|
||||
#if 0
|
||||
|
||||
@@ -126,6 +126,9 @@ namespace svgfill {
|
||||
// 0: outer perimiter and corridor center lines
|
||||
// 1: input polygons, corridor center lines and segments connecting corridor center lines to input polygons
|
||||
int topology_reconstruction_algo = 0;
|
||||
// 0: join segment runs
|
||||
// 1: local badness reduction
|
||||
int line_cleaning_algo = 0;
|
||||
bool perform_cleanup = true;
|
||||
double subdivision_factor = 16.;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user