mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-06 07:51:47 +00:00
Compare commits
84 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 09ceb64fff | |||
| a11ebdf8c4 | |||
| b997726564 | |||
| 25713a486a | |||
| d3b6b82151 | |||
| 9e6797e172 | |||
| 1f9a0a53bb | |||
| b82c4c53fe | |||
| 8deefe497c | |||
| 9621388953 | |||
| 89523999b3 | |||
| 51ab38de27 | |||
| fbe36532a0 | |||
| 2f1b2f9638 | |||
| 9001cca078 | |||
| ec558dc57e | |||
| 2db5658386 | |||
| 88c8bd032f | |||
| 3d8654acfd | |||
| 4a20b67038 | |||
| b14df627d7 | |||
| 0828c6ba92 | |||
| a586c7f695 | |||
| 0bad5a9389 | |||
| 0ce400cace | |||
| 91ed59311b | |||
| 1906481a01 | |||
| 73bf238232 | |||
| a85d5cc990 | |||
| c16aec2cb0 | |||
| 76be31561e | |||
| a111c68d44 | |||
| 62f627ecc6 | |||
| 279e16f2ab | |||
| 3d68d0f0e5 | |||
| e759135608 | |||
| 1df738d968 | |||
| 98c28a1f30 | |||
| 21122c0d28 | |||
| d63a99b70c | |||
| f2d8f17f88 | |||
| e9b619e3fb | |||
| d1f9e5243e | |||
| 537317b26f | |||
| 4a095f9810 | |||
| c92a825a94 | |||
| 4c5bd88877 | |||
| 92e3e400f8 | |||
| 13c4ba257d | |||
| c77a28c862 | |||
| 82f73c29ea | |||
| 209c44db83 | |||
| 382f5e0c21 | |||
| 81a42cec1a | |||
| 45fa04a94b | |||
| e27624c77f | |||
| 836d57e7ff | |||
| 3d7d1ff4f3 | |||
| 6911418c67 | |||
| 5c8eab981c | |||
| a155a1ca80 | |||
| 8c434b7167 | |||
| 8fb8966094 | |||
| d506f5df1e | |||
| e23142d01a | |||
| 6085894433 | |||
| c61ebe3876 | |||
| 305f8c6003 | |||
| 98a25eca96 | |||
| 99c370b755 | |||
| f3cb7aea61 | |||
| 84b2cf6db0 | |||
| 113643c916 | |||
| f0e6cfecc1 | |||
| 7338736898 | |||
| e9e2f89649 | |||
| 4ceadd8f10 | |||
| 0cfc77030d | |||
| efac8a0ec0 | |||
| 7ab0628c54 | |||
| 16b1b4e7b1 | |||
| 8c667b8ae0 | |||
| e52e5e2e58 | |||
| 2d59ea1988 |
@@ -21,12 +21,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Checkout Build Repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: IfcOpenShell/build-outputs
|
||||
path: ./build
|
||||
|
||||
@@ -9,13 +9,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
submodules: recursive
|
||||
path: IfcOpenShell
|
||||
|
||||
- name: Checkout Build Repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: IfcOpenShell/build-outputs
|
||||
path: ifcopenshell_build
|
||||
|
||||
@@ -35,12 +35,12 @@ jobs:
|
||||
aws --version
|
||||
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Checkout Build Repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: IfcOpenShell/build-outputs
|
||||
path: ./build
|
||||
|
||||
@@ -35,12 +35,12 @@ jobs:
|
||||
aws --version
|
||||
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Checkout Build Repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: IfcOpenShell/build-outputs
|
||||
path: ./build
|
||||
|
||||
@@ -27,12 +27,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Checkout Build Repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: IfcOpenShell/build-outputs
|
||||
path: ${{ matrix.deps_dir }}
|
||||
|
||||
@@ -19,8 +19,8 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
steps:
|
||||
- uses: actions/checkout@v6 # https://github.com/actions/checkout
|
||||
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
|
||||
- uses: actions/checkout@v7 # https://github.com/actions/checkout
|
||||
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
|
||||
with:
|
||||
python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax
|
||||
architecture: 'x64' # optional x64 or x86. Defaults to x64 if not specified
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-tags: true
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -65,8 +65,8 @@ jobs:
|
||||
config:
|
||||
short_name: macos
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
|
||||
with:
|
||||
architecture: 'x64' # optional x64 or x86. Defaults to x64 if not specified
|
||||
python-version: '3.11'
|
||||
@@ -98,7 +98,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout bonsai_unstable_repo repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: IfcOpenShell/bonsai_unstable_repo
|
||||
token: ${{ secrets.IFCOPENBOT_TOKEN }}
|
||||
|
||||
@@ -48,8 +48,8 @@ jobs:
|
||||
config:
|
||||
short_name: macos
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
|
||||
with:
|
||||
architecture: 'x64' # optional x64 or x86. Defaults to x64 if not specified
|
||||
python-version: '3.11'
|
||||
|
||||
@@ -18,8 +18,8 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
|
||||
with:
|
||||
python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax
|
||||
- name: Compile
|
||||
|
||||
@@ -18,8 +18,8 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
|
||||
with:
|
||||
python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax
|
||||
- name: Compile
|
||||
|
||||
@@ -18,8 +18,8 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
|
||||
with:
|
||||
python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax
|
||||
- name: Compile
|
||||
|
||||
@@ -18,8 +18,8 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
|
||||
with:
|
||||
python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax
|
||||
- name: Compile
|
||||
|
||||
@@ -18,8 +18,8 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
|
||||
with:
|
||||
python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax
|
||||
- name: Compile
|
||||
|
||||
@@ -37,8 +37,8 @@ jobs:
|
||||
short_name: macosm164
|
||||
}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
|
||||
with:
|
||||
python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax
|
||||
architecture: 'x64' # optional x64 or x86. Defaults to x64 if not specified
|
||||
|
||||
@@ -18,8 +18,8 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
|
||||
with:
|
||||
python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax
|
||||
- name: Compile
|
||||
|
||||
@@ -18,8 +18,8 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
|
||||
with:
|
||||
python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax
|
||||
- name: Compile
|
||||
|
||||
@@ -18,8 +18,8 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Compile
|
||||
|
||||
@@ -18,8 +18,8 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
|
||||
with:
|
||||
python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax
|
||||
- name: Compile
|
||||
|
||||
@@ -18,8 +18,8 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Compile
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
date: ${{ steps.date.outputs.date }}
|
||||
verdate: ${{ steps.verdate.outputs.verdate }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Set env
|
||||
run: echo ok go
|
||||
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
echo "ARTIFACTS_DIR=/home/runner/work/artifacts" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
needs: activate
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
@@ -86,7 +86,7 @@ jobs:
|
||||
name: Docker Build, Tag, Push
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
lfs: true
|
||||
|
||||
@@ -104,7 +104,7 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v4
|
||||
-
|
||||
name: Login to Dockerhub
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v4.5.2
|
||||
with:
|
||||
username: aecgeeks
|
||||
password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
||||
|
||||
@@ -47,10 +47,10 @@ jobs:
|
||||
short_name: macosm164
|
||||
}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
submodules: recursive
|
||||
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
|
||||
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
|
||||
with:
|
||||
python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax
|
||||
architecture: 'x64' # optional x64 or x86. Defaults to x64 if not specified
|
||||
|
||||
@@ -38,10 +38,10 @@ jobs:
|
||||
short_name: macosm164
|
||||
}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
submodules: recursive
|
||||
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
|
||||
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
|
||||
with:
|
||||
python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax
|
||||
architecture: 'x64' # optional x64 or x86. Defaults to x64 if not specified
|
||||
|
||||
@@ -18,8 +18,8 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
|
||||
with:
|
||||
python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax
|
||||
- name: Compile
|
||||
|
||||
@@ -18,8 +18,8 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Compile
|
||||
|
||||
@@ -25,8 +25,8 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
|
||||
with:
|
||||
architecture: 'x64' # optional x64 or x86. Defaults to x64 if not specified
|
||||
python-version: '3.11'
|
||||
|
||||
@@ -19,8 +19,8 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
|
||||
with:
|
||||
architecture: 'x64' # optional x64 or x86. Defaults to x64 if not specified
|
||||
python-version: '3.11'
|
||||
|
||||
@@ -10,9 +10,9 @@ jobs:
|
||||
publish_website:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Checkout ifctester_org_static_html
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: IfcOpenShell/ifctester_org_static_html
|
||||
token: ${{ secrets.IFCOPENBOT_TOKEN }}
|
||||
|
||||
@@ -18,8 +18,8 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v7 # https://github.com/actions/setup-python
|
||||
with:
|
||||
python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax
|
||||
- name: Compile
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
cmake --build build-ifcopenshell --target install -j "$(nproc)"
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: 3.11
|
||||
|
||||
@@ -120,7 +120,7 @@ jobs:
|
||||
PY
|
||||
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: 3.12
|
||||
|
||||
|
||||
@@ -12,15 +12,15 @@ jobs:
|
||||
MIN_BLENDER_PY_VERSION: "3.11"
|
||||
steps:
|
||||
- name: Action - checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Action - install python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: ${{ env.MIN_IOS_PY_VERSION }}
|
||||
|
||||
- name: Action - install python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: ${{ env.MIN_BLENDER_PY_VERSION }}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout IfcOpenShell
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
echo "name=$(basename $WHEEL)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Checkout wasm-wheels
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: IfcOpenShell/wasm-wheels
|
||||
path: wasm-wheels
|
||||
|
||||
@@ -43,12 +43,12 @@ jobs:
|
||||
CLICOLOR_FORCE: "1"
|
||||
CMAKE_COLOR_DIAGNOSTICS: "ON"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: 3.11
|
||||
|
||||
@@ -257,13 +257,15 @@ jobs:
|
||||
cd ../ifcdiff && make test || ERROR=1
|
||||
cd ../ifcpatch && make test || ERROR=1
|
||||
pip install -e ../ifc5d --no-deps
|
||||
pip install odfpy xlsxwriter
|
||||
pip install odfpy openpyxl
|
||||
cd ../ifc5d && make test || ERROR=1
|
||||
pip install -e ../ifcquery --no-deps
|
||||
cd ../ifcquery && make test || ERROR=1
|
||||
pip install -e ../ifcedit --no-deps
|
||||
cd ../ifcedit && make test || ERROR=1
|
||||
pip install mcp
|
||||
# Pinned <2: mcp 2.0.0 renamed mcp.server.fastmcp.FastMCP to
|
||||
# mcp.server.mcpserver.MCPServer, which ifcmcp doesn't support yet.
|
||||
pip install "mcp>=1.0,<2"
|
||||
pip install -e ../ifcmcp --no-deps
|
||||
cd ../ifcmcp && make test || ERROR=1
|
||||
pip install -e ../ifctester --no-deps
|
||||
|
||||
@@ -11,10 +11,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
|
||||
@@ -27,12 +27,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout (recursive)
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
submodules: recursive
|
||||
fetch-depth: 0
|
||||
- name: Checkout intermediate Pages repo
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
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@v6
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.x"
|
||||
- name: Download wheels
|
||||
|
||||
@@ -7,7 +7,7 @@ jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: astral-sh/setup-uv@v7
|
||||
|
||||
|
||||
@@ -27,12 +27,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout (recursive)
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
submodules: recursive
|
||||
fetch-depth: 0
|
||||
- name: Checkout intermediate Pages repo
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: IfcOpenShell/wasm_ifcopenshell_org_static_html
|
||||
ref: gh-pages
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
submodules: recursive
|
||||
- name: Install C++ dependencies
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<package xmlns="http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd">
|
||||
<metadata>
|
||||
<id>blenderbim-nightly</id>
|
||||
<version>blenderbim_build_version-alpha</version>
|
||||
<version>blenderbim_build_version</version>
|
||||
<packageSourceUrl>https://github.com/IfcOpenShell/IfcOpenShell</packageSourceUrl>
|
||||
<owners>fbpyr</owners>
|
||||
<!-- == SOFTWARE SPECIFIC SECTION == -->
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
apt update && apt install git wget curl ptpython mono-devel micro
|
||||
mkdir -p /home/runner/work/IfcOpenShell && cd /home/runner/work/IfcOpenShell
|
||||
git clone https://github.com/IfcOpenShell/IfcOpenShell
|
||||
cd /home/runner/work/IfcOpenShell/IfcOpenShell/choco/blenderbim/
|
||||
cd /home/runner/work/IfcOpenShell/IfcOpenShell/choco/bonsai/
|
||||
micro choco_release.py # paste this script, comment out push command
|
||||
export CHOCO_TOKEN="secret_choco_release_token"
|
||||
python3 choco_release.py
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import hashlib
|
||||
import os
|
||||
@@ -28,7 +29,7 @@ def get_repo_tag_names() -> list[str]:
|
||||
|
||||
|
||||
def request_repo_info(url: str):
|
||||
req = request.Request(url)
|
||||
req = request.Request(url)
|
||||
resp = request.urlopen(req)
|
||||
if not resp.status == 200:
|
||||
print(f"[ERROR] could not contact server: {url}")
|
||||
@@ -85,13 +86,15 @@ def run(command: str) -> None:
|
||||
|
||||
start = datetime.datetime.now()
|
||||
|
||||
URL_CHOCO_PACKAGE = "https://community.chocolatey.org/packages/blender"
|
||||
URL_BLENDER_CMAKE = "https://raw.githubusercontent.com/blender/blender/{}/build_files/cmake/Modules/FindPythonLibsUnix.cmake"
|
||||
RE_BLENDER_VERSION_MIN_MAJ = r"Latest Version.+<span>Blender (\d+\.\d+)\..+</span>"
|
||||
RE_BLENDER_VERSION_MIN_MAJ_PAT = r"Latest Version.+<span>Blender (\d+\.\d+\.\d+)</span>"
|
||||
URL_CHOCO_PACKAGE = "https://community.chocolatey.org/packages/blender"
|
||||
URL_BLENDER_CMAKE = (
|
||||
"https://raw.githubusercontent.com/blender/blender/{}/build_files/cmake/Modules/FindPythonLibsUnix.cmake"
|
||||
)
|
||||
RE_BLENDER_VERSION_MIN_MAJ = r"Latest Version.+<span>Blender (\d+\.\d+)\..+</span>"
|
||||
RE_BLENDER_VERSION_MIN_MAJ_PAT = r"Latest Version.+<span>Blender (\d+\.\d+\.\d+)</span>"
|
||||
RE_BLENDER_PYTHON_VERSION_MAJ_MIN = r"\(_PYTHON_VERSION_SUPPORTED (\d+\.\d+)\)"
|
||||
|
||||
BLENDERBIM_DIR = pathlib.Path("/home/runner/work/IfcOpenShell/IfcOpenShell/choco/blenderbim/")
|
||||
BLENDERBIM_DIR = pathlib.Path("/home/runner/work/IfcOpenShell/IfcOpenShell/choco/bonsai/")
|
||||
|
||||
print("_____ check choco release needed?")
|
||||
|
||||
@@ -148,7 +151,7 @@ print(f"{blender_python_version_maj_min=}")
|
||||
python_version = f"py{found[0].replace('.', '')}"
|
||||
print(f"{python_version=}")
|
||||
|
||||
blenderbim_build_version = target_release_tag.replace("blenderbim-", "")
|
||||
blenderbim_build_version = target_release_tag.replace("bonsai-", "")
|
||||
|
||||
# url_blenderbim_py3x_win_zip
|
||||
release_zip_file_name, url_blenderbim_py3x_win_zip = get_release_zip(target_release_tag)
|
||||
@@ -166,15 +169,15 @@ topics = {
|
||||
"path": HERE_DIR / "blenderbim.nuspec",
|
||||
"key_values": {
|
||||
"latest_blender_version_maj_min_pat": latest_blender_release_maj_min_pat,
|
||||
"blenderbim_build_version" : blenderbim_build_version,
|
||||
"blenderbim_build_version": blenderbim_build_version,
|
||||
},
|
||||
},
|
||||
"install": {
|
||||
"path": HERE_DIR / "tools" / "chocolateyinstall.ps1",
|
||||
"key_values": {
|
||||
"url_blenderbim_py3x_win_zip" : url_blenderbim_py3x_win_zip,
|
||||
"url_blenderbim_py3x_win_zip": url_blenderbim_py3x_win_zip,
|
||||
"sha256sum_blenderbim_py3x_win_zip": sha256sum_blenderbim_py3x_win_zip,
|
||||
"latest_blender_version_maj_min" : blender_version_min_maj,
|
||||
"latest_blender_version_maj_min": blender_version_min_maj,
|
||||
},
|
||||
},
|
||||
"uninstall": {
|
||||
|
||||
+5
-2
@@ -121,7 +121,7 @@ import tarfile
|
||||
import threading
|
||||
from datetime import datetime
|
||||
|
||||
ssl._create_default_https_context = ssl._create_unverified_context
|
||||
ssl._create_default_https_context = ssl._create_unverified_context # ty:ignore[invalid-assignment]
|
||||
|
||||
import time
|
||||
from collections.abc import Generator, Sequence
|
||||
@@ -697,7 +697,10 @@ def build_dependency(
|
||||
compr = "xz"
|
||||
else:
|
||||
raise RuntimeError("fix source for new download type")
|
||||
download_tarfile = tarfile.open(name=download_tarfile_path, mode=f"r:{compr}")
|
||||
# ty: false positive bug upstream.
|
||||
download_tarfile = tarfile.open(
|
||||
name=download_tarfile_path, mode=f"r:{compr}"
|
||||
) # ty:ignore[no-matching-overload]
|
||||
# tarfile seriously doesn't have a function to retrieve the root directory more easily
|
||||
extract_dir_name = os.path.commonprefix([x for x in download_tarfile.getnames() if x != "."])
|
||||
# run([tar, "--exclude=\"*/*\"", "-tf", download_name], cwd=build_dir).strip() no longer works
|
||||
|
||||
+2
-1
@@ -156,7 +156,8 @@ exclude = [
|
||||
[tool.poe.tasks]
|
||||
|
||||
dev-setup.sequence = [
|
||||
{cmd = "uv sync"},
|
||||
# 3.13 is chosen because it's the version used in the latest Bonsai.
|
||||
{cmd = "uv sync --python 3.13"},
|
||||
{cmd = "uv pip install -e ./src/bsdd/"},
|
||||
{cmd = "uv pip install -e ./src/ifcopenshell-python/[advanced,dev]"},
|
||||
{cmd = "uv pip install -e ./src/ifcedit/"},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
black==26.3.1
|
||||
ruff==0.15.22
|
||||
ruff==0.16.0
|
||||
poethepoet
|
||||
ty==0.0.61
|
||||
gersemi==0.26.1
|
||||
ty==0.0.63
|
||||
gersemi==0.28.0
|
||||
|
||||
@@ -10,6 +10,7 @@ if bonsai_lib_path:
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import urllib.parse
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import pystache
|
||||
@@ -18,8 +19,53 @@ from aiohttp import web
|
||||
|
||||
sio_port = 8080 # default port
|
||||
|
||||
|
||||
def get_asset_version() -> str:
|
||||
"""A cache-busting token appended to locally served static asset URLs.
|
||||
|
||||
Browsers otherwise keep serving a stale cached copy of static/js and
|
||||
static/css after Bonsai ships a code change, until the user does a hard
|
||||
refresh. Using the Bonsai version (which includes the build's commit
|
||||
hash) means the token changes on every shipped update.
|
||||
"""
|
||||
if bonsai_version:
|
||||
return urllib.parse.quote(bonsai_version, safe="")
|
||||
# Fallback for standalone runs without BONSAI_VERSION set (e.g. running
|
||||
# sioserver.py directly outside of Blender): derive a token from the
|
||||
# newest mtime among the static assets, so it still changes whenever the
|
||||
# shipped files change.
|
||||
static_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
|
||||
latest_mtime = 0
|
||||
for root, _dirs, files in os.walk(static_dir):
|
||||
for name in files:
|
||||
latest_mtime = max(latest_mtime, int(os.path.getmtime(os.path.join(root, name))))
|
||||
return f"dev-{latest_mtime}"
|
||||
|
||||
|
||||
asset_version = get_asset_version()
|
||||
|
||||
|
||||
@web.middleware
|
||||
async def no_cache_static_middleware(request: web.Request, handler):
|
||||
"""Force revalidation of locally served static assets.
|
||||
|
||||
Query-string version stamping (see `asset_version`) busts the cache for
|
||||
the HTML-referenced entry points, but JS files that statically import
|
||||
other local modules (e.g. cost.js/gantt.js importing utilities/costui.js)
|
||||
reference those modules by an un-stamped relative path. Marking all
|
||||
/static/ and /jsgantt/ responses as no-cache makes browsers always
|
||||
revalidate with the server (a cheap conditional GET / 304 when nothing
|
||||
changed), so nested imports also pick up shipped changes without
|
||||
requiring a hard refresh.
|
||||
"""
|
||||
response = await handler(request)
|
||||
if request.path.startswith("/static/") or request.path.startswith("/jsgantt/"):
|
||||
response.headers["Cache-Control"] = "no-cache, must-revalidate"
|
||||
return response
|
||||
|
||||
|
||||
sio = socketio.AsyncServer(cors_allowed_origins="*", async_mode="aiohttp", max_http_buffer_size=10000000)
|
||||
app = web.Application()
|
||||
app = web.Application(middlewares=[no_cache_static_middleware])
|
||||
sio.attach(app)
|
||||
|
||||
|
||||
@@ -199,28 +245,28 @@ class BlenderNamespace(socketio.AsyncNamespace):
|
||||
async def schedules(request):
|
||||
with open("templates/index.html", "r") as f:
|
||||
template = f.read()
|
||||
html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version})
|
||||
html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version, "v": asset_version})
|
||||
return web.Response(text=html_content, content_type="text/html")
|
||||
|
||||
|
||||
async def costing(request):
|
||||
with open("templates/costing.html", "r") as f:
|
||||
template = f.read()
|
||||
html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version})
|
||||
html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version, "v": asset_version})
|
||||
return web.Response(text=html_content, content_type="text/html")
|
||||
|
||||
|
||||
async def sequencing(request):
|
||||
with open("templates/gantt.html", "r") as f:
|
||||
template = f.read()
|
||||
html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version})
|
||||
html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version, "v": asset_version})
|
||||
return web.Response(text=html_content, content_type="text/html")
|
||||
|
||||
|
||||
async def documentation(request):
|
||||
with open("templates/drawings.html", "r") as f:
|
||||
template = f.read()
|
||||
html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version})
|
||||
html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version, "v": asset_version})
|
||||
return web.Response(text=html_content, content_type="text/html")
|
||||
|
||||
|
||||
@@ -229,7 +275,7 @@ async def documentation(request):
|
||||
async def demo(request):
|
||||
with open("templates/demo.html", "r") as f:
|
||||
template = f.read()
|
||||
html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version})
|
||||
html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version, "v": asset_version})
|
||||
return web.Response(text=html_content, content_type="text/html")
|
||||
|
||||
|
||||
|
||||
@@ -250,6 +250,14 @@ export class CostUI {
|
||||
},
|
||||
});
|
||||
|
||||
CostUI.addRibbonButton({
|
||||
text: "Download CSV",
|
||||
icon: "fa-solid fa-file-csv",
|
||||
callback: () => {
|
||||
CostUI.downloadCsv();
|
||||
},
|
||||
});
|
||||
|
||||
CostUI.addRibbonButton({
|
||||
text: "Hide Schedules",
|
||||
icon: "fa-regular fa-eye-slash",
|
||||
@@ -523,6 +531,81 @@ export class CostUI {
|
||||
}
|
||||
}
|
||||
|
||||
static downloadCsv() {
|
||||
const tables = document.querySelectorAll("table[id^='cost-items-']");
|
||||
if (tables.length === 0) {
|
||||
alert("No cost schedule loaded to export!");
|
||||
return;
|
||||
}
|
||||
tables.forEach((table) => {
|
||||
const scheduleId = table.id.split("-").pop();
|
||||
const csv = CostUI.tableToCsv(table);
|
||||
if (csv === null) {
|
||||
return;
|
||||
}
|
||||
const nameEl = document.querySelector(
|
||||
"#cost-schedule-container-" + scheduleId + " .form-header span"
|
||||
);
|
||||
const scheduleName = nameEl
|
||||
? nameEl.textContent
|
||||
: "cost_schedule_" + scheduleId;
|
||||
CostUI.triggerCsvDownload(csv, scheduleName + ".csv");
|
||||
});
|
||||
}
|
||||
|
||||
static tableToCsv(table) {
|
||||
const escapeCsvCell = (value) => {
|
||||
const text = (value === null || value === undefined ? "" : value)
|
||||
.toString()
|
||||
.trim();
|
||||
if (/[",\n]/.test(text)) {
|
||||
return '"' + text.replace(/"/g, '""') + '"';
|
||||
}
|
||||
return text;
|
||||
};
|
||||
|
||||
const cellText = (cell) => {
|
||||
const input = cell.querySelector("input");
|
||||
return input ? input.value : cell.innerText;
|
||||
};
|
||||
|
||||
// The Actions column only holds buttons (edit/delete/etc), not data.
|
||||
const isDataColumn = (column) => column && column !== "Actions";
|
||||
|
||||
const headerCells = Array.from(table.querySelectorAll("thead th")).filter(
|
||||
(th) => isDataColumn(th.getAttribute("data-column"))
|
||||
);
|
||||
if (headerCells.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rows = [headerCells.map((th) => escapeCsvCell(th.textContent)).join(",")];
|
||||
|
||||
table.querySelectorAll("tbody tr").forEach((row) => {
|
||||
const cells = Array.from(row.children).filter((cell) =>
|
||||
isDataColumn(cell.getAttribute("data-column"))
|
||||
);
|
||||
if (cells.length === 0) {
|
||||
return; // e.g. the "No cost items found" placeholder row.
|
||||
}
|
||||
rows.push(cells.map((cell) => escapeCsvCell(cellText(cell))).join(","));
|
||||
});
|
||||
|
||||
return rows.join("\n");
|
||||
}
|
||||
|
||||
static triggerCsvDownload(csvContent, filename) {
|
||||
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
static createCostTable({ costSchedule, currency, callbacks }) {
|
||||
const preferences = CostUI.getColumnPreferences();
|
||||
const isScheduleOfRates = costSchedule.PredefinedType === "SCHEDULEOFRATES";
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<link
|
||||
rel="stylesheet"
|
||||
type="text/css"
|
||||
href="/static/css/cost.css"
|
||||
href="/static/css/cost.css?v={{v}}"
|
||||
id="index-stylesheet"
|
||||
/>
|
||||
<link
|
||||
@@ -21,7 +21,7 @@
|
||||
/>
|
||||
<script
|
||||
type="text/javascript"
|
||||
src="/static/js/jquery.min.js"
|
||||
src="/static/js/jquery.min.js?v={{v}}"
|
||||
></script>
|
||||
<script
|
||||
type="text/javascript"
|
||||
@@ -34,7 +34,7 @@
|
||||
<script>
|
||||
var SOCKET_PORT = {{port}};
|
||||
</script>
|
||||
<script type="module" defer src="./static/js/cost.js"></script>
|
||||
<script type="module" defer src="./static/js/cost.js?v={{v}}"></script>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
|
||||
@@ -12,14 +12,14 @@
|
||||
/>
|
||||
<!-- here we request the CSS file from the server, -->
|
||||
<!-- using registered static path in the server -->
|
||||
<link rel="stylesheet" href="/static/css/demo.css" id="demo-stylesheet" />
|
||||
<link rel="stylesheet" href="/static/css/demo.css?v={{v}}" id="demo-stylesheet" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.6.0/css/all.min.css"
|
||||
/>
|
||||
<script
|
||||
type="text/javascript"
|
||||
src="/static/js/jquery.min.js"
|
||||
src="/static/js/jquery.min.js?v={{v}}"
|
||||
></script>
|
||||
<script
|
||||
type="text/javascript"
|
||||
@@ -33,7 +33,7 @@
|
||||
</script>
|
||||
<!-- here we request the JS file from the server-->
|
||||
<!-- using registered static path in the server -->
|
||||
<script defer src="./static/js/demo.js"></script>
|
||||
<script defer src="./static/js/demo.js?v={{v}}"></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- the navigation bar at the top of the page. -->
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="/static/css/drawings.css"
|
||||
href="/static/css/drawings.css?v={{v}}"
|
||||
id="drawings-stylesheet"
|
||||
/>
|
||||
<link
|
||||
@@ -24,7 +24,7 @@
|
||||
/>
|
||||
<script
|
||||
type="text/javascript"
|
||||
src="/static/js/jquery.min.js"
|
||||
src="/static/js/jquery.min.js?v={{v}}"
|
||||
></script>
|
||||
<script
|
||||
type="text/javascript"
|
||||
@@ -45,7 +45,7 @@
|
||||
<script>
|
||||
var SOCKET_PORT = {{port}};
|
||||
</script>
|
||||
<script defer src="./static/js/drawings.js"></script>
|
||||
<script defer src="./static/js/drawings.js?v={{v}}"></script>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
|
||||
@@ -9,25 +9,25 @@
|
||||
type="image/x-icon"
|
||||
href="https://bonsaibim.org/assets/images/favicon-blender.png"
|
||||
/>
|
||||
<link rel="stylesheet" type="text/css" href="/jsgantt/jsgantt.css" />
|
||||
<link rel="stylesheet" href="/static/css/gantt.css" id="gantt-stylesheet" />
|
||||
<link rel="stylesheet" type="text/css" href="/jsgantt/jsgantt.css?v={{v}}" />
|
||||
<link rel="stylesheet" href="/static/css/gantt.css?v={{v}}" id="gantt-stylesheet" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.6.0/css/all.min.css"
|
||||
/>
|
||||
<script
|
||||
type="text/javascript"
|
||||
src="/static/js/jquery.min.js"
|
||||
src="/static/js/jquery.min.js?v={{v}}"
|
||||
></script>
|
||||
<script
|
||||
type="text/javascript"
|
||||
src="https://cdn.socket.io/4.0.0/socket.io.min.js"
|
||||
></script>
|
||||
<script type="text/javascript" src="./jsgantt/jsgantt.js"></script>
|
||||
<script type="text/javascript" src="./jsgantt/jsgantt.js?v={{v}}"></script>
|
||||
<script>
|
||||
var SOCKET_PORT = {{port}};
|
||||
</script>
|
||||
<script type="module" defer src="./static/js/gantt.js"></script>
|
||||
<script type="module" defer src="./static/js/gantt.js?v={{v}}"></script>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="no-print">
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
type="image/x-icon"
|
||||
href="https://bonsaibim.org/assets/images/favicon-blender.png"
|
||||
/>
|
||||
<link rel="stylesheet" href="/static/css/index.css" id="index-stylesheet" />
|
||||
<link rel="stylesheet" href="/static/css/index.css?v={{v}}" id="index-stylesheet" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
id="tabulator-stylesheet"
|
||||
@@ -21,7 +21,7 @@
|
||||
/>
|
||||
<script
|
||||
type="text/javascript"
|
||||
src="/static/js/jquery.min.js"
|
||||
src="/static/js/jquery.min.js?v={{v}}"
|
||||
></script>
|
||||
<script
|
||||
type="text/javascript"
|
||||
@@ -34,7 +34,7 @@
|
||||
<script>
|
||||
var SOCKET_PORT = {{port}};
|
||||
</script>
|
||||
<script defer src="./static/js/index.js"></script>
|
||||
<script defer src="./static/js/index.js?v={{v}}"></script>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
|
||||
@@ -418,9 +418,10 @@ def get_user(ifc: ifcopenshell.file) -> Union[ifcopenshell.entity_instance, None
|
||||
|
||||
|
||||
def viewport_shading_changed_callback(area: bpy.types.Area) -> None:
|
||||
shading = area.spaces.active.shading.type
|
||||
if shading == "RENDERED":
|
||||
tool.Style.get_style_props().active_style_type = "External"
|
||||
shading_type = area.spaces.active.shading.type
|
||||
tool.Style.restore_material_style_types(shading_type)
|
||||
if shading_type == "SOLID":
|
||||
area.spaces.active.shading.color_type = "MATERIAL"
|
||||
|
||||
|
||||
def subscribe_to_viewport_shading_changes():
|
||||
|
||||
@@ -133,10 +133,6 @@ class MaterialCreator:
|
||||
if shape_has_openings and coords.is_a("IfcIndexedTextureMap"):
|
||||
continue
|
||||
tool.Loader.load_indexed_map(coords, self.mesh)
|
||||
elif tool.Style.get_texture_style(material):
|
||||
# No explicit coordinate mapping (e.g. IFC2X3 has no IsMappedBy,
|
||||
# and IFC4 COORD uses generated UVs). Bake XY→UV as fallback.
|
||||
tool.Loader.load_generated_uv_map(self.mesh)
|
||||
|
||||
def assign_material_slots_to_faces(self) -> None:
|
||||
if not self.mesh["ios_materials"]:
|
||||
|
||||
@@ -108,6 +108,7 @@ classes = (
|
||||
operator.SelectAssignedProduct,
|
||||
operator.SelectSimilarTextLiteralValue,
|
||||
operator.ToggleTargetView,
|
||||
operator.ToggleDrawingCategorySelection,
|
||||
operator.OpenDocumentationWebUi,
|
||||
operator.FilterSelectedObjectsIfIntersectedByCamera,
|
||||
prop.Variable,
|
||||
|
||||
@@ -2310,7 +2310,11 @@ class SelectAllDrawings(bpy.types.Operator):
|
||||
|
||||
def execute(self, context):
|
||||
props = tool.Drawing.get_document_props()
|
||||
# When filtering to sheeted drawings only, act on the visible drawings only.
|
||||
sheeted_ids = tool.Drawing.get_sheeted_drawing_ids() if props.show_drawings_on_sheets_only else None
|
||||
for drawing in props.drawings:
|
||||
if sheeted_ids is not None and drawing.is_drawing and drawing.ifc_definition_id not in sheeted_ids:
|
||||
continue
|
||||
if drawing.is_selected != self.select_all:
|
||||
drawing.is_selected = self.select_all
|
||||
return {"FINISHED"}
|
||||
@@ -3870,6 +3874,26 @@ class ToggleTargetView(bpy.types.Operator):
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class ToggleDrawingCategorySelection(bpy.types.Operator):
|
||||
bl_idname = "bim.toggle_drawing_category_selection"
|
||||
bl_label = "Toggle Category Selection"
|
||||
bl_description = "Select or deselect all drawings in this view category"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
target_view: bpy.props.StringProperty()
|
||||
|
||||
if TYPE_CHECKING:
|
||||
target_view: str
|
||||
|
||||
def execute(self, context):
|
||||
drawings = tool.Drawing.get_visible_drawings_in_category(self.target_view)
|
||||
# If everything visible in the category is already selected, deselect all; otherwise select all.
|
||||
new_state = not all(d.is_selected for d in drawings)
|
||||
for drawing in drawings:
|
||||
drawing.is_selected = new_state
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class ExpandSheet(bpy.types.Operator):
|
||||
bl_idname = "bim.expand_sheet"
|
||||
bl_label = "Expand Sheet"
|
||||
|
||||
@@ -409,6 +409,12 @@ class DocProperties(PropertyGroup):
|
||||
options=set(),
|
||||
)
|
||||
is_editing_drawings: BoolProperty(name="Is Editing Drawings", default=False)
|
||||
show_drawings_on_sheets_only: BoolProperty(
|
||||
name="Show Only Drawings on Sheets",
|
||||
description="Only show drawings that are placed on a sheet",
|
||||
default=False,
|
||||
options=set(),
|
||||
)
|
||||
is_editing_schedules: BoolProperty(name="Is Editing Schedules", default=False)
|
||||
is_editing_references: BoolProperty(name="Is Editing References", default=False)
|
||||
target_view: EnumProperty(
|
||||
@@ -439,6 +445,7 @@ class DocProperties(PropertyGroup):
|
||||
should_use_annotation_cache: bool
|
||||
should_draw_linked_projects: bool
|
||||
is_editing_drawings: bool
|
||||
show_drawings_on_sheets_only: bool
|
||||
is_editing_schedules: bool
|
||||
is_editing_references: bool
|
||||
target_view: Literal["PLAN_VIEW", "ELEVATION_VIEW", "SECTION_VIEW", "REFLECTED_PLAN_VIEW", "MODEL_VIEW"]
|
||||
|
||||
@@ -341,6 +341,7 @@ class BIM_PT_drawings(Panel):
|
||||
self.layout.template_list(
|
||||
"BIM_UL_drawinglist", "", self.props, "drawings", self.props, "active_drawing_index"
|
||||
)
|
||||
self.layout.prop(self.props, "show_drawings_on_sheets_only")
|
||||
|
||||
|
||||
class BIM_PT_schedules(Panel):
|
||||
@@ -873,8 +874,8 @@ class BIM_UL_drawinglist(bpy.types.UIList):
|
||||
layout.label(text="", translate=False)
|
||||
return
|
||||
|
||||
row = layout.row(align=True)
|
||||
if item.is_drawing:
|
||||
row = layout.row(align=True)
|
||||
row.label(text="", icon="BLANK1")
|
||||
selected_icon = "CHECKBOX_HLT" if item.is_selected else "CHECKBOX_DEHLT"
|
||||
row.prop(item, "is_selected", text="", icon=selected_icon, emboss=False)
|
||||
@@ -895,6 +896,9 @@ class BIM_UL_drawinglist(bpy.types.UIList):
|
||||
item.ifc_definition_id
|
||||
)
|
||||
else:
|
||||
# Give category headers a distinct inset background so they stand out from drawing rows.
|
||||
box = layout.box()
|
||||
row = box.row(align=True)
|
||||
if item.target_view == "PLAN_VIEW":
|
||||
icon = "UV_FACESEL"
|
||||
elif item.target_view == "ELEVATION_VIEW":
|
||||
@@ -915,7 +919,55 @@ class BIM_UL_drawinglist(bpy.types.UIList):
|
||||
op = row.operator("bim.toggle_target_view", text="", emboss=False, icon="DISCLOSURE_TRI_RIGHT")
|
||||
op.target_view = item.target_view
|
||||
op.option = "EXPAND"
|
||||
row.prop(item, "name", text="", icon=icon, emboss=False)
|
||||
group = tool.Drawing.get_visible_drawings_in_category(item.target_view)
|
||||
all_selected = bool(group) and all(d.is_selected for d in group)
|
||||
row.operator(
|
||||
"bim.toggle_drawing_category_selection",
|
||||
text="",
|
||||
icon="CHECKBOX_HLT" if all_selected else "CHECKBOX_DEHLT",
|
||||
emboss=False,
|
||||
).target_view = item.target_view
|
||||
row.separator(factor=0.5, type="SPACE")
|
||||
# Clicking the header name toggles expand/contract, same as the disclosure triangle.
|
||||
op = row.operator("bim.toggle_target_view", text=item.name, icon=icon, emboss=False)
|
||||
op.target_view = item.target_view
|
||||
op.option = "CONTRACT" if item.is_expanded else "EXPAND"
|
||||
|
||||
def filter_items(self, context, data: DocProperties, propname: str):
|
||||
drawings = getattr(data, propname)
|
||||
helper_funcs = bpy.types.UI_UL_list
|
||||
|
||||
flt_flags = []
|
||||
flt_neworder = []
|
||||
|
||||
if self.filter_name:
|
||||
flt_flags = helper_funcs.filter_items_by_name(
|
||||
self.filter_name,
|
||||
self.bitflag_filter_item,
|
||||
drawings,
|
||||
"name",
|
||||
reverse=self.use_filter_sort_reverse,
|
||||
)
|
||||
if not flt_flags:
|
||||
flt_flags = [self.bitflag_filter_item] * len(drawings)
|
||||
|
||||
props = tool.Drawing.get_document_props()
|
||||
if props.show_drawings_on_sheets_only:
|
||||
ifc_file = tool.Ifc.get()
|
||||
sheeted_ids = tool.Drawing.get_sheeted_drawing_ids()
|
||||
# Target view headers are only shown if they contain a sheeted drawing.
|
||||
sheeted_target_views = {
|
||||
tool.Drawing.get_drawing_target_view(ifc_file.by_id(drawing_id)) for drawing_id in sheeted_ids
|
||||
}
|
||||
for i, item in enumerate(drawings):
|
||||
if item.is_drawing:
|
||||
is_visible = item.ifc_definition_id in sheeted_ids
|
||||
else:
|
||||
is_visible = item.target_view in sheeted_target_views
|
||||
if not is_visible:
|
||||
flt_flags[i] &= ~self.bitflag_filter_item
|
||||
|
||||
return flt_flags, flt_neworder
|
||||
|
||||
|
||||
class BIM_UL_sheets(bpy.types.UIList):
|
||||
|
||||
@@ -56,6 +56,7 @@ classes = (
|
||||
operator.RemoveMaterial,
|
||||
operator.RemoveMaterialSet,
|
||||
operator.RemoveProfile,
|
||||
operator.RenameMaterial,
|
||||
operator.ReorderMaterialSetItem,
|
||||
operator.SelectByMaterial,
|
||||
operator.SelectMaterialInMaterialsUI,
|
||||
|
||||
@@ -102,6 +102,26 @@ class EditMaterial(bpy.types.Operator, tool.Ifc.Operator):
|
||||
core.edit_material(tool.Ifc, tool.Material, material=tool.Ifc.get().by_id(self.material))
|
||||
|
||||
|
||||
class RenameMaterial(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.rename_material"
|
||||
bl_label = "Rename Material"
|
||||
bl_description = "Rename an IfcMaterial"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
material: bpy.props.IntProperty()
|
||||
name: bpy.props.StringProperty(name="Name")
|
||||
|
||||
def invoke(self, context, event):
|
||||
material = tool.Ifc.get().by_id(self.material)
|
||||
self.name = material.Name or ""
|
||||
return context.window_manager.invoke_props_dialog(self)
|
||||
|
||||
def draw(self, context):
|
||||
self.layout.prop(self, "name")
|
||||
|
||||
def _execute(self, context):
|
||||
core.rename_material(tool.Ifc, tool.Material, material=tool.Ifc.get().by_id(self.material), name=self.name)
|
||||
|
||||
|
||||
class DisableEditingMaterial(bpy.types.Operator):
|
||||
bl_idname = "bim.disable_editing_material"
|
||||
bl_label = "Disable Editing Material"
|
||||
|
||||
@@ -162,7 +162,7 @@ class ProjectLibraryData:
|
||||
library_file = IfcStore.library_file
|
||||
if library_file is None or library_file.schema == "IFC2X3":
|
||||
return results
|
||||
root = tool.Project.get_root_context(library_file)
|
||||
root = library_file.by_type("IfcProject")[0]
|
||||
results.append((str(root.id()), f"{root.is_a()} {root.Name or 'Unnamed'}", root.Description or ""))
|
||||
for library_id, data in cls.data["project_libraries"].items():
|
||||
results.append((str(library_id), data["Name"] or "Unnamed", data["Description"] or ""))
|
||||
|
||||
@@ -281,7 +281,7 @@ class RefreshLibrary(bpy.types.Operator):
|
||||
elements = {e for e in elements if not tool.Project.is_element_assigned_to_project_library(e, rels)}
|
||||
self.props.add_library_project_library("Unassigned", len(elements), 0, False)
|
||||
|
||||
root_context = tool.Project.get_root_context(library_file)
|
||||
root_context = library_file.by_type("IfcProject")[0]
|
||||
hierarchy = tool.Project.get_project_hierarchy(library_file)
|
||||
tool.Project.load_project_libraries_to_ui(root_context, hierarchy)
|
||||
return {"FINISHED"}
|
||||
@@ -761,21 +761,22 @@ class EditProjectLibrary(bpy.types.Operator):
|
||||
attributes = bonsai.bim.helper.export_attributes(props.project_library_attributes)
|
||||
ifcopenshell.api.attribute.edit_attributes(library_file, project_library, attributes)
|
||||
|
||||
# Update parent library.
|
||||
# Update parent library. Tear down the old IfcRelDeclares/IfcRelNests before
|
||||
# creating the new one; a library must have exactly one of the two, never both.
|
||||
previous_parent_library = tool.Project.get_parent_library(project_library)
|
||||
new_parent_library = library_file.by_id(int(props.parent_library))
|
||||
if previous_parent_library != new_parent_library:
|
||||
if previous_parent_library is None:
|
||||
# Edited library was a root in a library-only file; nest it under the new parent.
|
||||
if previous_parent_library is not None:
|
||||
if previous_parent_library.is_a("IfcProject"):
|
||||
ifcopenshell.api.project.unassign_declaration(
|
||||
library_file, [project_library], previous_parent_library
|
||||
)
|
||||
else:
|
||||
ifcopenshell.api.nest.unassign_object(library_file, [project_library])
|
||||
if new_parent_library.is_a("IfcProject"):
|
||||
ifcopenshell.api.project.assign_declaration(library_file, [project_library], new_parent_library)
|
||||
else:
|
||||
ifcopenshell.api.nest.assign_object(library_file, [project_library], new_parent_library)
|
||||
elif previous_parent_library.is_a("IfcProject"):
|
||||
# Then new one is IfcProjectLibrary.
|
||||
ifcopenshell.api.nest.assign_object(library_file, [project_library], new_parent_library)
|
||||
else: # Previous is IfcProjectLibrary.
|
||||
ifcopenshell.api.nest.unassign_object(library_file, [project_library])
|
||||
# If new one is IfcProject, then it's already assigned by default.
|
||||
if new_parent_library.is_a("IfcProjectLibrary"):
|
||||
ifcopenshell.api.nest.assign_object(library_file, [project_library], new_parent_library)
|
||||
|
||||
props.is_editing_project_library = False
|
||||
bpy.ops.bim.refresh_library()
|
||||
@@ -809,12 +810,9 @@ class AddProjectLibrary(bpy.types.Operator):
|
||||
props = tool.Project.get_project_props()
|
||||
library_file = IfcStore.library_file
|
||||
assert library_file
|
||||
root_context = tool.Project.get_root_context(library_file)
|
||||
root_context = library_file.by_type("IfcProject")[0]
|
||||
project_library = ifcopenshell.api.root.create_entity(library_file, "IfcProjectLibrary")
|
||||
if root_context.is_a("IfcProject"):
|
||||
ifcopenshell.api.project.assign_declaration(library_file, [project_library], root_context)
|
||||
else:
|
||||
ifcopenshell.api.nest.assign_object(library_file, [project_library], root_context)
|
||||
ifcopenshell.api.project.assign_declaration(library_file, [project_library], root_context)
|
||||
ProjectLibraryData.load() # Update enum.
|
||||
props.selected_project_library = str(project_library.id())
|
||||
props.is_editing_project_library = True
|
||||
@@ -1301,6 +1299,10 @@ class LoadProjectElements(bpy.types.Operator):
|
||||
tool.Project.set_default_modeling_dimensions()
|
||||
tool.Root.reload_grid_decorator()
|
||||
bonsai.bim.handler.refresh_ui_data()
|
||||
for screen in bpy.data.screens:
|
||||
for area in screen.areas:
|
||||
if area.type == "VIEW_3D":
|
||||
bonsai.bim.handler.viewport_shading_changed_callback(area)
|
||||
return {"FINISHED"}
|
||||
|
||||
def get_decomposition_elements(self) -> set[ifcopenshell.entity_instance]:
|
||||
@@ -1578,10 +1580,13 @@ class LoadLink(bpy.types.Operator, tool.Ifc.Operator):
|
||||
json_filepath = self.filepath_.with_suffix(".ifc.cache.json")
|
||||
|
||||
def should_clear_cache() -> bool:
|
||||
if not self.use_cache:
|
||||
return True
|
||||
# Nothing to clear if the cache file was never created (e.g. a
|
||||
# fresh link). Check this first so os.remove below is never
|
||||
# called on a non-existent path, regardless of use_cache.
|
||||
if not blend_filepath.exists():
|
||||
return False
|
||||
if not self.use_cache:
|
||||
return True
|
||||
data = json.loads(json_filepath.read_text())
|
||||
# Empty 'query' - model loaded without custom query.
|
||||
# Missing 'query' - model was loaded before custom queries were introduced in Bonsai.
|
||||
|
||||
@@ -104,7 +104,7 @@ def get_footing_length(o: bpy.types.Object) -> float:
|
||||
return get_length(o)
|
||||
if predefined_type == "FOOTING_BEAM" or predefined_type == "STRIP_FOOTING":
|
||||
return get_z(o)
|
||||
elif predefined_type == "PAD_FOOTING":
|
||||
elif predefined_type == "PAD_FOOTING" or predefined_type == "PILE_CAP":
|
||||
return max(get_x(o), get_y(o))
|
||||
else:
|
||||
return get_length(o)
|
||||
@@ -197,12 +197,26 @@ def get_footing_height(o: bpy.types.Object) -> float:
|
||||
return get_height(o)
|
||||
if predefined_type == "FOOTING_BEAM" or predefined_type == "STRIP_FOOTING":
|
||||
return get_y(o)
|
||||
elif predefined_type == "PAD_FOOTING":
|
||||
elif predefined_type == "PAD_FOOTING" or predefined_type == "PILE_CAP":
|
||||
return get_z(o)
|
||||
else:
|
||||
return get_height(o)
|
||||
|
||||
|
||||
def get_footing_width(o: bpy.types.Object) -> float:
|
||||
element = tool.Ifc.get_entity(o)
|
||||
assert element
|
||||
predefined_type = ifcopenshell.util.element.get_predefined_type(element)
|
||||
if not predefined_type:
|
||||
return get_width(o)
|
||||
if predefined_type == "FOOTING_BEAM" or predefined_type == "STRIP_FOOTING":
|
||||
return get_x(o)
|
||||
elif predefined_type == "PAD_FOOTING" or predefined_type == "PILE_CAP":
|
||||
return get_width(o)
|
||||
else:
|
||||
return get_width(o)
|
||||
|
||||
|
||||
def get_height(o: bpy.types.Object) -> float:
|
||||
"""_summary_: Returns the height of the object bounding box
|
||||
|
||||
@@ -225,7 +239,7 @@ def get_opening_depth(obj: bpy.types.Object) -> float:
|
||||
if is_opening_horizontal(obj):
|
||||
return get_height(obj)
|
||||
else:
|
||||
return get_width(obj)
|
||||
return get_y(obj)
|
||||
|
||||
|
||||
def get_opening_mapping_area(obj: bpy.types.Object) -> float:
|
||||
|
||||
@@ -132,5 +132,12 @@ class SelectSimilarData:
|
||||
if pset.endswith("Common"):
|
||||
keys.extend([f'/.*Common/."{name}"' for name in properties.keys() if name != "id"])
|
||||
else:
|
||||
keys.extend([f"{pset}.{name}" for name in properties.keys() if name != "id"])
|
||||
pset_part = f'"{pset}"' if " " in pset else pset
|
||||
keys.extend(
|
||||
[
|
||||
f'{pset_part}."{name}"' if " " in name else f"{pset_part}.{name}"
|
||||
for name in properties.keys()
|
||||
if name != "id"
|
||||
]
|
||||
)
|
||||
return [(k, k, "") for k in keys]
|
||||
|
||||
@@ -45,6 +45,7 @@ class SpatialTool(WorkSpaceTool):
|
||||
("bim.spatial_hotkey", {"type": "T", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_T")]}),
|
||||
("bim.spatial_hotkey", {"type": "G", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_G")]}),
|
||||
("bim.spatial_hotkey", {"type": "H", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_H")]}),
|
||||
("bim.spatial_hotkey", {"type": "Q", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_Q")]}),
|
||||
)
|
||||
|
||||
def draw_settings(context, layout, ws_tool):
|
||||
@@ -184,3 +185,9 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator):
|
||||
|
||||
def hotkey_S_G(self):
|
||||
bpy.ops.bim.generate_space()
|
||||
|
||||
def hotkey_S_Q(self):
|
||||
# Mirrors BimTool.hotkey_S_Q so quantities can be (re)calculated without switching tools.
|
||||
if not bpy.context.selected_objects:
|
||||
return
|
||||
bpy.ops.bim.perform_quantity_take_off()
|
||||
|
||||
@@ -45,7 +45,9 @@ classes = (
|
||||
operator.SelectByStyle,
|
||||
operator.SelectStyleInStylesUI,
|
||||
operator.SetAssetMaterialToExternalStyle,
|
||||
operator.SuggestShadeFromExternalStyle,
|
||||
operator.UnlinkStyle,
|
||||
operator.TogglePreferIfcShading,
|
||||
operator.UpdateCurrentStyle,
|
||||
operator.UpdateStyleColours,
|
||||
operator.UpdateStyleTextures,
|
||||
|
||||
@@ -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 colorsys
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Union
|
||||
@@ -238,13 +239,15 @@ class UpdateCurrentStyle(bpy.types.Operator):
|
||||
if not isinstance(obj.data, (bpy.types.Mesh, bpy.types.Curve)):
|
||||
continue
|
||||
for mat in obj.data.materials:
|
||||
if (
|
||||
mat
|
||||
and mat not in updated_materials
|
||||
and (msprops_ := tool.Style.get_material_style_props(mat)).ifc_definition_id != 0
|
||||
):
|
||||
msprops_.active_style_type = current_style_type
|
||||
updated_materials.add(mat)
|
||||
if not mat:
|
||||
continue
|
||||
msprops_ = tool.Style.get_material_style_props(mat)
|
||||
if msprops_.ifc_definition_id == 0:
|
||||
continue
|
||||
if mat in updated_materials:
|
||||
continue
|
||||
msprops_.active_style_type = current_style_type
|
||||
updated_materials.add(mat)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
@@ -457,10 +460,14 @@ class ActivateExternalStyle(bpy.types.Operator):
|
||||
self.report({"ERROR"}, f"Error loading external style for \"{material.name}\" - {db['msg']}")
|
||||
return {"CANCELLED"}
|
||||
|
||||
self.copy_material_attributes(db["data_block"], material)
|
||||
ext_mat = db["data_block"]
|
||||
self.copy_material_attributes(ext_mat, material)
|
||||
if tool.Style.get_use_nodes(material):
|
||||
tool.Blender.copy_node_graph(material, db["data_block"])
|
||||
bpy.data.materials.remove(db["data_block"])
|
||||
if material.get("bim_dual_branch"):
|
||||
tool.Style.update_external_branch(material, ext_mat)
|
||||
else:
|
||||
tool.Style.setup_dual_branch(material, ext_mat)
|
||||
bpy.data.materials.remove(ext_mat)
|
||||
return {"FINISHED"}
|
||||
|
||||
def copy_material_attributes(self, source, target):
|
||||
@@ -503,6 +510,218 @@ class ActivateExternalStyle(bpy.types.Operator):
|
||||
set_prop(prop_name)
|
||||
|
||||
|
||||
class TogglePreferIfcShading(bpy.types.Operator):
|
||||
bl_idname = "bim.toggle_prefer_ifc_shading"
|
||||
bl_label = "Toggle Flat/Pretty"
|
||||
bl_description = (
|
||||
"Toggle between Flat (IFC-native shading) and Pretty (external .blend style) for ALL styles.\n\n"
|
||||
"SHIFT+CLICK to apply to this style only"
|
||||
)
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
material_name: bpy.props.StringProperty(name="Material Name", default="", options={"SKIP_SAVE"})
|
||||
single_only: bpy.props.BoolProperty(name="Single Only", default=False, options={"SKIP_SAVE"})
|
||||
|
||||
def invoke(self, context, event):
|
||||
if event.shift:
|
||||
self.single_only = True
|
||||
return self.execute(context)
|
||||
|
||||
def execute(self, context):
|
||||
wm = context.window_manager
|
||||
space = tool.Blender.get_view3d_space()
|
||||
is_solid = space and space.shading.type == "SOLID"
|
||||
|
||||
if is_solid:
|
||||
if space.shading.color_type == "TEXTURE":
|
||||
space.shading.color_type = "MATERIAL"
|
||||
else:
|
||||
meshes_needing_uv = []
|
||||
for obj in bpy.context.scene.objects:
|
||||
if not isinstance(obj.data, bpy.types.Mesh):
|
||||
continue
|
||||
for slot in obj.material_slots:
|
||||
mat = slot.material
|
||||
if not mat or not tool.Blender.get_ifc_definition_id(mat):
|
||||
continue
|
||||
style_elements = tool.Style.get_style_elements(mat)
|
||||
if style_elements.get("IfcSurfaceStyleWithTextures") and not obj.data.uv_layers:
|
||||
meshes_needing_uv.append(obj.data)
|
||||
break
|
||||
wm.progress_begin(0, max(len(meshes_needing_uv), 1))
|
||||
try:
|
||||
for i, mesh in enumerate(meshes_needing_uv):
|
||||
tool.Loader.load_generated_uv_map(mesh)
|
||||
wm.progress_update(i)
|
||||
finally:
|
||||
wm.progress_end()
|
||||
space.shading.color_type = "TEXTURE"
|
||||
return {"FINISHED"}
|
||||
|
||||
if self.single_only:
|
||||
mat = bpy.data.materials.get(self.material_name)
|
||||
if not mat:
|
||||
return {"CANCELLED"}
|
||||
msprops = tool.Style.get_material_style_props(mat)
|
||||
msprops.prefer_ifc_shading = not msprops.prefer_ifc_shading
|
||||
else:
|
||||
# Default: apply to all IFC materials
|
||||
source_mat = bpy.data.materials.get(self.material_name)
|
||||
new_value = not tool.Style.get_material_style_props(source_mat).prefer_ifc_shading if source_mat else True
|
||||
ifc_mats = [m for m in bpy.data.materials if tool.Blender.get_ifc_definition_id(m)]
|
||||
for mat in ifc_mats:
|
||||
tool.Style.get_material_style_props(mat).prefer_ifc_shading = new_value
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class SuggestShadeFromExternalStyle(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.suggest_shade_from_external_style"
|
||||
bl_label = "Suggest Shade from External Style"
|
||||
bl_description = (
|
||||
"Generate a Shade style (Surface Colour + Transparency) from the external .blend style.\n\n"
|
||||
"ALT+CLICK to apply to all styles with an external .blend style"
|
||||
)
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
material_name: bpy.props.StringProperty(name="Material Name", default="", options={"SKIP_SAVE"})
|
||||
all_styles: bpy.props.BoolProperty(name="All Styles", default=False, options={"SKIP_SAVE"})
|
||||
value_offset: bpy.props.FloatProperty(
|
||||
name="Value",
|
||||
description="Offset added to the colour's value (-1 = fully dark, 0 = unchanged, +1 = fully light)",
|
||||
default=0.0,
|
||||
min=-1.0,
|
||||
max=1.0,
|
||||
step=1,
|
||||
precision=2,
|
||||
options={"SKIP_SAVE"},
|
||||
)
|
||||
saturation_factor: bpy.props.FloatProperty(
|
||||
name="Saturation",
|
||||
description="Scale applied to the colour's saturation (0 = greyscale, 1 = unchanged, >1 = more saturated)",
|
||||
default=1.0,
|
||||
min=0.0,
|
||||
max=2.0,
|
||||
step=1,
|
||||
precision=2,
|
||||
options={"SKIP_SAVE"},
|
||||
)
|
||||
|
||||
def invoke(self, context, event):
|
||||
if event.alt:
|
||||
self.all_styles = True
|
||||
return context.window_manager.invoke_props_dialog(self)
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
layout.prop(self, "value_offset", slider=True)
|
||||
layout.prop(self, "saturation_factor", slider=True)
|
||||
if self.all_styles:
|
||||
layout.label(text="Will apply to all external styles", icon="INFO")
|
||||
|
||||
def _execute(self, context):
|
||||
if self.all_styles:
|
||||
candidates = [
|
||||
(mat, tool.Style.get_style_elements(mat))
|
||||
for mat in bpy.data.materials
|
||||
if tool.Blender.get_ifc_definition_id(mat)
|
||||
]
|
||||
candidates = [(mat, se) for mat, se in candidates if tool.Style.has_blender_external_style(se)]
|
||||
wm = context.window_manager
|
||||
wm.progress_begin(0, max(len(candidates), 1))
|
||||
count = 0
|
||||
color_cache: dict[tuple[str, str, str], tuple | None] = {}
|
||||
try:
|
||||
for i, (mat, style_elements) in enumerate(candidates):
|
||||
wm.progress_update(i)
|
||||
if self._apply_to_material(
|
||||
mat, style_elements, self.value_offset, self.saturation_factor, color_cache
|
||||
):
|
||||
count += 1
|
||||
finally:
|
||||
wm.progress_end()
|
||||
self.report({"INFO"}, f"Shade style generated for {count} style(s).")
|
||||
else:
|
||||
mat = bpy.data.materials.get(self.material_name)
|
||||
if not mat:
|
||||
return {"CANCELLED"}
|
||||
style_elements = tool.Style.get_style_elements(mat)
|
||||
if not tool.Style.has_blender_external_style(style_elements):
|
||||
self.report({"ERROR"}, "No external .blend style assigned. Please assign an external style first.")
|
||||
return {"CANCELLED"}
|
||||
self._apply_to_material(mat, style_elements, self.value_offset, self.saturation_factor)
|
||||
props = tool.Style.get_style_props()
|
||||
if props.is_editing:
|
||||
core.load_styles(tool.Style, style_type=props.style_type)
|
||||
|
||||
def _apply_to_material(
|
||||
self,
|
||||
material: bpy.types.Material,
|
||||
style_elements: dict,
|
||||
value_offset: float = 0.0,
|
||||
saturation_factor: float = 1.0,
|
||||
color_cache: "dict[tuple[str, str, str], tuple | None] | None" = None,
|
||||
) -> bool:
|
||||
external_style = style_elements["IfcExternallyDefinedSurfaceStyle"]
|
||||
style_path = Path(tool.Ifc.resolve_uri(external_style.Location))
|
||||
data_block_type, data_block = external_style.Identification.split("/")
|
||||
|
||||
cache_key = (str(style_path), data_block_type, data_block)
|
||||
if color_cache is not None and cache_key in color_cache:
|
||||
cached = color_cache[cache_key]
|
||||
if cached is None:
|
||||
return False # previously failed for this path
|
||||
surface_colour, transparency = cached
|
||||
else:
|
||||
try:
|
||||
db = tool.Blender.append_data_block(str(style_path), data_block_type, data_block)
|
||||
except OSError as e:
|
||||
self.report({"WARNING"}, f'Could not open blend file for "{material.name}": {e}')
|
||||
if color_cache is not None:
|
||||
color_cache[cache_key] = None
|
||||
return False
|
||||
if not db["data_block"]:
|
||||
self.report({"WARNING"}, f'Could not load external style for "{material.name}": {db["msg"]}')
|
||||
if color_cache is not None:
|
||||
color_cache[cache_key] = None
|
||||
return False
|
||||
|
||||
ext_mat = db["data_block"]
|
||||
surface_colour, transparency = tool.Style.get_representative_material_color(ext_mat)
|
||||
bpy.data.materials.remove(ext_mat)
|
||||
if color_cache is not None:
|
||||
color_cache[cache_key] = (surface_colour, transparency)
|
||||
|
||||
if value_offset != 0.0 or saturation_factor != 1.0:
|
||||
h, s, v = colorsys.rgb_to_hsv(*surface_colour)
|
||||
v = max(0.0, min(1.0, v + value_offset))
|
||||
s = max(0.0, min(1.0, s * saturation_factor))
|
||||
surface_colour = colorsys.hsv_to_rgb(h, s, v)
|
||||
|
||||
ifc_style = tool.Ifc.get_entity(material)
|
||||
attributes: dict = {
|
||||
"SurfaceColour": {
|
||||
"Name": None,
|
||||
"Red": surface_colour[0],
|
||||
"Green": surface_colour[1],
|
||||
"Blue": surface_colour[2],
|
||||
},
|
||||
}
|
||||
if tool.Ifc.get_schema() != "IFC2X3":
|
||||
attributes["Transparency"] = transparency
|
||||
|
||||
shading_style = style_elements.get("IfcSurfaceStyleShading")
|
||||
if shading_style:
|
||||
tool.Ifc.run("style.edit_surface_style", style=shading_style, attributes=attributes)
|
||||
else:
|
||||
tool.Ifc.run(
|
||||
"style.add_surface_style",
|
||||
style=ifc_style,
|
||||
ifc_class="IfcSurfaceStyleShading",
|
||||
attributes=attributes,
|
||||
)
|
||||
material.diffuse_color = (*surface_colour, 1.0 - transparency)
|
||||
tool.Style.sync_flat_branch_shading(material, surface_colour, transparency)
|
||||
return True
|
||||
|
||||
|
||||
class DisableEditingStyles(bpy.types.Operator):
|
||||
bl_idname = "bim.disable_editing_styles"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@@ -372,6 +372,16 @@ def update_shading_style(self: "BIMStyleProperties", context: bpy.types.Context)
|
||||
tool.Style.switch_shading(blender_material, self.active_style_type)
|
||||
|
||||
|
||||
def update_prefer_ifc_shading(self: "BIMStyleProperties", context: bpy.types.Context) -> None:
|
||||
style_elements = tool.Style.get_style_elements(self.id_data)
|
||||
has_external = tool.Style.has_blender_external_style(style_elements)
|
||||
if self.prefer_ifc_shading or not has_external:
|
||||
self.active_style_type = "Shading"
|
||||
else:
|
||||
self.active_style_type = "External"
|
||||
self.id_data.update_tag()
|
||||
|
||||
|
||||
class BIMStyleProperties(PropertyGroup):
|
||||
ifc_definition_id: IntProperty(name="IFC Definition ID")
|
||||
active_style_type: EnumProperty(
|
||||
@@ -381,9 +391,19 @@ class BIMStyleProperties(PropertyGroup):
|
||||
default="Shading",
|
||||
update=update_shading_style,
|
||||
)
|
||||
prefer_ifc_shading: BoolProperty(
|
||||
name="Flat / Pretty",
|
||||
description=(
|
||||
"Toggle between Flat (IFC-native shading) and Pretty (external .blend style). "
|
||||
"When set to Flat, viewport switches to Material Preview or Rendered will not activate the external style."
|
||||
),
|
||||
default=False,
|
||||
update=update_prefer_ifc_shading,
|
||||
)
|
||||
is_renaming: BoolProperty(description="Used to prevent triggering handler callback.", default=False)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
ifc_definition_id: int
|
||||
active_style_type: tool.Style.StyleType
|
||||
prefer_ifc_shading: bool
|
||||
is_renaming: bool
|
||||
|
||||
@@ -110,6 +110,11 @@ class BIM_PT_styles(Panel):
|
||||
op = row.operator("bim.update_current_style", icon="FILE_REFRESH", text="")
|
||||
op.style_id = style.ifc_definition_id
|
||||
|
||||
if active_style and self.props.style_type == "IfcSurfaceStyle":
|
||||
if material := style.blender_material:
|
||||
msprops = tool.Style.get_material_style_props(material)
|
||||
self.draw_style_status_row(material, msprops)
|
||||
|
||||
if self.props.style_type == "IfcSurfaceStyle":
|
||||
self.layout.label(text="Surface Style Element:")
|
||||
col = self.layout.column(align=True)
|
||||
@@ -161,6 +166,66 @@ class BIM_PT_styles(Panel):
|
||||
edit_label = "Save Lighting Style"
|
||||
self.draw_edit_ui(edit_label)
|
||||
|
||||
def draw_style_status_row(self, material: bpy.types.Material, msprops) -> None:
|
||||
space = tool.Blender.get_view3d_space()
|
||||
box = self.layout.box()
|
||||
|
||||
obj = bpy.context.active_object
|
||||
|
||||
parts = []
|
||||
if space:
|
||||
shading_type = space.shading.type
|
||||
shading_labels = {
|
||||
"SOLID": "Solid",
|
||||
"MATERIAL": "Material Preview",
|
||||
"RENDERED": "Rendered",
|
||||
"WIREFRAME": "Wireframe",
|
||||
}
|
||||
parts.append(f"Viewport: {shading_labels.get(shading_type, shading_type)}")
|
||||
else:
|
||||
parts.append("No 3D viewport")
|
||||
shading_type = None
|
||||
|
||||
if obj:
|
||||
obj_has_uv = isinstance(obj.data, bpy.types.Mesh) and bool(obj.data.uv_layers)
|
||||
uv_label = "UV \u2713" if obj_has_uv else "UV \u2717"
|
||||
parts.append(f"Selected Object: {obj.name} {uv_label}")
|
||||
else:
|
||||
parts.append("Selected Object: None")
|
||||
|
||||
if shading_type == "SOLID":
|
||||
is_flat = space.shading.color_type != "TEXTURE"
|
||||
mode_label = "Flat"
|
||||
dep_label = "Shade"
|
||||
if not is_flat:
|
||||
mode_label = "Pretty"
|
||||
dep_label = "Texture \u2192 Shade"
|
||||
elif shading_type in ("MATERIAL", "RENDERED"):
|
||||
is_flat = msprops.prefer_ifc_shading
|
||||
mode_label = "Flat"
|
||||
dep_label = "Render+Texture \u2192 Render \u2192 Shade"
|
||||
if not is_flat:
|
||||
mode_label = "Pretty"
|
||||
dep_label = "External \u2192 Render+Texture \u2192 Render \u2192 Shade"
|
||||
else:
|
||||
is_flat = False
|
||||
mode_label = ""
|
||||
dep_label = ""
|
||||
|
||||
row1 = box.row(align=True)
|
||||
row1.label(text=" | ".join(parts))
|
||||
|
||||
if mode_label:
|
||||
row2 = box.row(align=True)
|
||||
row2.label(text=f"Current Mode: {mode_label} \u2014 {dep_label}")
|
||||
|
||||
row3 = box.row(align=True)
|
||||
row3.alignment = "RIGHT"
|
||||
op = row3.operator("bim.suggest_shade_from_external_style", text="", icon="BRUSHES_ALL")
|
||||
op.material_name = material.name
|
||||
op = row3.operator("bim.toggle_prefer_ifc_shading", text="", icon="UV_SYNC_SELECT")
|
||||
op.material_name = material.name
|
||||
|
||||
def draw_surface_style_shading(self):
|
||||
row = self.layout.row()
|
||||
row.prop(self.props, "surface_colour")
|
||||
|
||||
@@ -41,6 +41,7 @@ import bonsai.bim.helper
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.ifc import is_cache_locked_by_other_process
|
||||
from bonsai.bim.module.bsdd.prop import BIMBSDDProperties, BSDDProperty
|
||||
from bonsai.bim.module.material.operator import SelectByMaterial
|
||||
from bonsai.bim.module.model import prop as _model_prop
|
||||
from bonsai.bim.module.model import ui as _model_ui
|
||||
from bonsai.bim.module.pset.prop import IfcProperty
|
||||
@@ -1854,6 +1855,21 @@ def draw_statusbar(self, context):
|
||||
|
||||
def draw_custom_context_menu(self: bpy.types.Menu, context: bpy.types.Context) -> None:
|
||||
# https://blender.stackexchange.com/a/275555/86891
|
||||
|
||||
# Context menu for material name buttons (e.g. `bim.select_by_material`),
|
||||
# offering a quick "Rename Material" entry instead of having to look up
|
||||
# the material in the scene Materials panel to rename it.
|
||||
button_operator = getattr(context, "button_operator", None)
|
||||
if button_operator is not None and button_operator.bl_rna.identifier == SelectByMaterial.bl_rna.identifier:
|
||||
ifc_file = tool.Ifc.get()
|
||||
material = ifc_file.by_id(button_operator.material) if ifc_file else None
|
||||
if material is not None and material.is_a("IfcMaterial"):
|
||||
assert self.layout
|
||||
self.layout.separator()
|
||||
op = self.layout.operator("bim.rename_material", text="Rename Material", icon="GREASEPENCIL")
|
||||
op.material = material.id()
|
||||
return
|
||||
|
||||
if (
|
||||
not hasattr(context, "button_pointer")
|
||||
or not hasattr(context, "button_prop")
|
||||
|
||||
@@ -107,6 +107,15 @@ def disable_editing_material(material_tool: type[tool.Material]) -> None:
|
||||
material_tool.disable_editing_material()
|
||||
|
||||
|
||||
def rename_material(
|
||||
ifc: type[tool.Ifc], material_tool: type[tool.Material], material: ifcopenshell.entity_instance, name: str
|
||||
) -> None:
|
||||
ifc.run("material.edit_material", material=material, attributes={"Name": name})
|
||||
if material_tool.is_editing_materials():
|
||||
material_tool.import_material_definitions(material_tool.get_active_material_type())
|
||||
material_tool.refresh()
|
||||
|
||||
|
||||
def assign_material(
|
||||
ifc: type[tool.Ifc],
|
||||
material_tool: type[tool.Material],
|
||||
|
||||
@@ -222,7 +222,6 @@ def generate_space(
|
||||
|
||||
if element and element.is_a("IfcSpace"):
|
||||
spatial.set_space_representation_from_polygon(active_obj, element, space_polygon, h, polygon_is_si=True)
|
||||
spatial.translate_obj_to_z_location(active_obj, z)
|
||||
else:
|
||||
if relating_type:
|
||||
name = model.generate_occurrence_name(relating_type, "IfcSpace")
|
||||
|
||||
@@ -667,6 +667,7 @@ class Material:
|
||||
def is_editing_materials(cls): pass
|
||||
def is_material_used_in_sets(cls, material): pass
|
||||
def load_material_attributes(cls, material): pass
|
||||
def refresh(cls): pass
|
||||
def replace_material_with_material_profile(cls, element): pass
|
||||
def update_elements_using_material(cls, material): pass
|
||||
|
||||
|
||||
@@ -803,6 +803,40 @@ class Blender(bonsai.core.tool.Blender):
|
||||
# restore shader editor settings
|
||||
shader_editor.pin = previous_pin_setting
|
||||
|
||||
@classmethod
|
||||
def copy_node_graph_additive(
|
||||
cls, material_to: bpy.types.Material, material_from: bpy.types.Material
|
||||
) -> bpy.types.ShaderNodeOutputMaterial | None:
|
||||
"""Paste nodes from material_from alongside the existing nodes in material_to.
|
||||
|
||||
Unlike copy_node_graph this does NOT clear the existing node tree first.
|
||||
Returns the OUTPUT_MATERIAL node that was added from material_from, or None.
|
||||
"""
|
||||
temp_override = cls.get_shader_editor_context()
|
||||
shader_editor = temp_override["space"]
|
||||
|
||||
before_names = {n.name for n in material_to.node_tree.nodes}
|
||||
|
||||
previous_pin_setting = shader_editor.pin
|
||||
shader_editor.pin = True
|
||||
shader_editor.node_tree = material_from.node_tree
|
||||
|
||||
for node in material_from.node_tree.nodes:
|
||||
node.select = True
|
||||
with bpy.context.temp_override(**temp_override):
|
||||
bpy.ops.node.clipboard_copy()
|
||||
|
||||
shader_editor.node_tree = material_to.node_tree
|
||||
with bpy.context.temp_override(**temp_override):
|
||||
bpy.ops.node.clipboard_paste(offset=(0, 0))
|
||||
|
||||
shader_editor.pin = previous_pin_setting
|
||||
|
||||
for node in material_to.node_tree.nodes:
|
||||
if node.name not in before_names and node.type == "OUTPUT_MATERIAL":
|
||||
return node
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_material_node(
|
||||
cls, blender_material: bpy.types.Material, node_type: str, kwargs: Optional[dict] = {}
|
||||
|
||||
@@ -138,7 +138,6 @@ class Bsdd(bonsai.core.tool.Bsdd):
|
||||
def get_dictionaries(cls) -> list[bsdd.DictionaryContractV1]:
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
baseurl = getattr(prefs, "bsdd_baseurl", "https://api.bsdd.buildingsmart.org/api/")
|
||||
cls.client = bsdd.Client()
|
||||
if hasattr(cls.client, "baseurl"):
|
||||
cls.client.baseurl = baseurl
|
||||
response = cls.client.get_dictionary(include_test_dictionaries=prefs.bsdd_load_test_dictionaries)
|
||||
|
||||
@@ -1270,6 +1270,17 @@ class ClipBox:
|
||||
def on_depsgraph_update_caps(cls, scene, depsgraph) -> None:
|
||||
"""Depsgraph entry-point — guard, then delegate to the
|
||||
modal-aware debounce in :meth:`_handle_cap_tick`."""
|
||||
# Same file-load danger window as on_depsgraph_update: a real
|
||||
# depsgraph tick during load (between load_pre and the new file's
|
||||
# first paint) must not re-arm a cap-rebuild timer. _on_load_pre
|
||||
# already cancels any in-flight timer via _cancel_pending_cap_rebuild;
|
||||
# without this gate, a depsgraph_update_post event firing later in
|
||||
# the same load (Blender fires these while building the new file's
|
||||
# scene) would immediately reschedule one via _handle_cap_tick,
|
||||
# undoing that cancellation and re-arming against regions whose GPU
|
||||
# state is not yet wired.
|
||||
if cls._file_loading:
|
||||
return
|
||||
if getattr(bpy.context, "screen", None) is None:
|
||||
return
|
||||
if cls._active_scene_props(scene) is None:
|
||||
|
||||
@@ -2887,6 +2887,50 @@ class Drawing(bonsai.core.tool.Drawing):
|
||||
break
|
||||
return sheet_references
|
||||
|
||||
@classmethod
|
||||
def get_sheeted_drawing_ids(cls) -> set[int]:
|
||||
"""Get the IFC ids of all drawings that are placed on at least one sheet."""
|
||||
ifc_file = tool.Ifc.get()
|
||||
sheet_locations: set[Union[str, None]] = set()
|
||||
for sheet in ifc_file.by_type("IfcDocumentInformation"):
|
||||
if sheet.Scope != "SHEET":
|
||||
continue
|
||||
for reference in cls.get_document_references(sheet):
|
||||
sheet_locations.add(reference.Location)
|
||||
if not sheet_locations:
|
||||
return set()
|
||||
result: set[int] = set()
|
||||
for drawing in ifc_file.by_type("IfcAnnotation"):
|
||||
if drawing.ObjectType != "DRAWING":
|
||||
continue
|
||||
drawing_document = cls.get_drawing_document(drawing)
|
||||
if drawing_document and drawing_document.Location in sheet_locations:
|
||||
result.add(drawing.id())
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def get_visible_drawings_in_category(cls, target_view: str) -> list[DrawingProperties]:
|
||||
"""Get the drawing items in a target view category that are currently visible in the drawing list.
|
||||
|
||||
Grouping is positional: individual drawing items don't carry their own ``target_view``, they belong to
|
||||
the most recent header item above them. Only expanded categories contribute drawing items to the
|
||||
collection, so a collapsed category yields an empty list. Respects the ``show_drawings_on_sheets_only``
|
||||
filter so that select-all only affects visible drawings.
|
||||
"""
|
||||
props = cls.get_document_props()
|
||||
drawings: list[DrawingProperties] = []
|
||||
in_category = False
|
||||
for item in props.drawings:
|
||||
if not item.is_drawing:
|
||||
# Header row: we're inside the requested category until the next header.
|
||||
in_category = item.target_view == target_view
|
||||
elif in_category:
|
||||
drawings.append(item)
|
||||
if props.show_drawings_on_sheets_only:
|
||||
sheeted_ids = cls.get_sheeted_drawing_ids()
|
||||
drawings = [d for d in drawings if d.ifc_definition_id in sheeted_ids]
|
||||
return drawings
|
||||
|
||||
@classmethod
|
||||
def get_camera_matrix(cls, camera: bpy.types.Object) -> Matrix:
|
||||
matrix_world = camera.matrix_world.copy().normalized()
|
||||
|
||||
@@ -146,6 +146,12 @@ class Material(bonsai.core.tool.Material):
|
||||
|
||||
MaterialsData.data["material_styles_data"] = MaterialsData.material_styles_data()
|
||||
|
||||
@classmethod
|
||||
def refresh(cls) -> None:
|
||||
from bonsai.bim.module.material.data import refresh as refresh_material_data
|
||||
|
||||
refresh_material_data()
|
||||
|
||||
@classmethod
|
||||
def is_editing_materials(cls) -> bool:
|
||||
props = tool.Material.get_material_props()
|
||||
|
||||
@@ -47,7 +47,15 @@ class Nest(bonsai.core.tool.Nest):
|
||||
return False
|
||||
if relating_object == related_object:
|
||||
return False
|
||||
is_compatible_class = relating_object.is_a("IfcElement") and related_object.is_a("IfcElement")
|
||||
# IfcRelNests.RelatingObject/RelatedObjects are typed as the general
|
||||
# IfcObjectDefinition, so nesting is schema-legal both between element
|
||||
# occurrences (the common case, e.g. a faucet nested into a sink) and
|
||||
# between element types (e.g. an assembly type nesting its component
|
||||
# types). Mixing an occurrence with a type isn't a real modeling
|
||||
# pattern, so only allow same-kind pairs. See #2283.
|
||||
is_compatible_class = (relating_object.is_a("IfcElement") and related_object.is_a("IfcElement")) or (
|
||||
relating_object.is_a("IfcTypeProduct") and related_object.is_a("IfcTypeProduct")
|
||||
)
|
||||
if not is_compatible_class:
|
||||
return False
|
||||
# Prevent cyclic references: walk up the full hierarchy from the
|
||||
|
||||
@@ -168,12 +168,12 @@ class Polyline(bonsai.core.tool.Polyline):
|
||||
distance = (mouse_vector - last_point).length
|
||||
if distance < 0:
|
||||
return
|
||||
angle, orientation_angle, angle_round_threshold = None, None, None
|
||||
angle, orientation_angle = None, None
|
||||
angle_round_threshold = 1000 # Avoids rounding when distance is too big
|
||||
if distance > 0:
|
||||
angle = tool.Cad.angle_3_vectors(
|
||||
second_to_last_point, last_point, mouse_vector, new_angle=None, degrees=True
|
||||
)
|
||||
angle_round_threshold = 1000 # Avoids rounding when distance is too big
|
||||
|
||||
# Round angle to the nearest 0.05
|
||||
angle = round(angle / 0.05) * 0.05 if distance < angle_round_threshold else angle
|
||||
@@ -189,7 +189,7 @@ class Polyline(bonsai.core.tool.Polyline):
|
||||
angle = 0
|
||||
orientation_angle = 0
|
||||
if input_ui:
|
||||
assert angle is not None and orientation_angle is not None and angle_round_threshold is not None
|
||||
assert angle is not None and orientation_angle is not None
|
||||
if should_round:
|
||||
angle_snap = tool.Snap.get_angle_snap_value(context)
|
||||
angle = angle_snap * round(angle / angle_snap) if distance < angle_round_threshold else angle
|
||||
|
||||
@@ -391,29 +391,14 @@ class Project(bonsai.core.tool.Project):
|
||||
def get_parent_library(
|
||||
cls, project_library: ifcopenshell.entity_instance
|
||||
) -> Union[ifcopenshell.entity_instance, None]:
|
||||
"""Return the IfcContext that declares or nests ``project_library``.
|
||||
|
||||
Returns ``None`` when ``project_library`` is itself the root of a
|
||||
library-only file (no IfcRelNests, no IfcRelDeclares).
|
||||
"""
|
||||
"""Return the IfcContext that declares or nests ``project_library``, or ``None``
|
||||
if neither relationship is present."""
|
||||
if nests := project_library.Nests:
|
||||
return nests[0].RelatingObject
|
||||
if has_context := project_library.HasContext:
|
||||
return has_context[0].RelatingContext
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_root_context(cls, ifc_file: ifcopenshell.file) -> ifcopenshell.entity_instance:
|
||||
"""Return the file's root IfcContext.
|
||||
|
||||
Prefers IfcProject if present, otherwise falls back to IfcProjectLibrary —
|
||||
library-only files are valid per IFC4+ and contain no IfcProject. Caller is
|
||||
responsible for the IFC2X3 guard; IfcContext does not exist in that schema.
|
||||
"""
|
||||
if projects := ifc_file.by_type("IfcProject"):
|
||||
return projects[0]
|
||||
return ifc_file.by_type("IfcProjectLibrary")[0]
|
||||
|
||||
@classmethod
|
||||
def get_project_hierarchy(cls, ifc_file: ifcopenshell.file) -> HiearchyDict:
|
||||
"""Get project hierarchy in the following form:
|
||||
|
||||
@@ -597,6 +597,146 @@ class Style(bonsai.core.tool.Style):
|
||||
external_style = style_elements.get("IfcExternallyDefinedSurfaceStyle", None)
|
||||
return bool(external_style and external_style.Location and external_style.Location.endswith(".blend"))
|
||||
|
||||
@classmethod
|
||||
def _color_from_principled(cls, node: bpy.types.Node) -> tuple[tuple[float, float, float], float]:
|
||||
color = cls._resolve_color_socket(node.inputs["Base Color"])
|
||||
alpha_socket = node.inputs["Alpha"]
|
||||
alpha_source = cls._upstream_color_source(alpha_socket)
|
||||
if alpha_source and alpha_source[0] == "IMAGE":
|
||||
pixels = alpha_source[1].pixels[:]
|
||||
n = len(pixels) // 4
|
||||
step = max(1, n // 4096)
|
||||
a_sum = sum(pixels[i * 4 + 3] for i in range(0, n, step))
|
||||
count = len(range(0, n, step)) or 1
|
||||
transparency = 1.0 - (a_sum / count)
|
||||
else:
|
||||
transparency = 1.0 - alpha_socket.default_value
|
||||
return color, transparency
|
||||
|
||||
@classmethod
|
||||
def _color_from_shader_socket(
|
||||
cls, socket: bpy.types.NodeSocket, seen: set[str] | None = None
|
||||
) -> tuple[tuple[float, float, float], float] | None:
|
||||
if seen is None:
|
||||
seen = set()
|
||||
for link in socket.links:
|
||||
node = link.from_node
|
||||
if node.name in seen:
|
||||
continue
|
||||
seen.add(node.name)
|
||||
if node.type == "BSDF_PRINCIPLED":
|
||||
return cls._color_from_principled(node)
|
||||
if node.type in ("BSDF_DIFFUSE", "DIFFUSE_BSDF"):
|
||||
return cls._resolve_color_socket(node.inputs["Color"]), 0.0
|
||||
if node.type == "BSDF_GLASS":
|
||||
return cls._resolve_color_socket(node.inputs["Color"]), 0.0
|
||||
if node.type in ("MIX_SHADER", "ADD_SHADER"):
|
||||
for inp in node.inputs:
|
||||
if inp.type == "SHADER" and inp.is_linked:
|
||||
result = cls._color_from_shader_socket(inp, seen)
|
||||
if result:
|
||||
return result
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_representative_material_color(
|
||||
cls, material: bpy.types.Material
|
||||
) -> tuple[tuple[float, float, float], float]:
|
||||
if material.node_tree:
|
||||
nodes = material.node_tree.nodes
|
||||
output_node = next((n for n in nodes if n.type == "OUTPUT_MATERIAL" and n.is_active_output), None) or next(
|
||||
(n for n in nodes if n.type == "OUTPUT_MATERIAL"), None
|
||||
)
|
||||
if output_node:
|
||||
result = cls._color_from_shader_socket(output_node.inputs["Surface"])
|
||||
if result:
|
||||
return result
|
||||
# Fallback: scan all shader nodes if no output node or graph traversal found nothing
|
||||
for node in nodes:
|
||||
if node.type == "BSDF_PRINCIPLED":
|
||||
return cls._color_from_principled(node)
|
||||
for node in nodes:
|
||||
if node.type in ("BSDF_DIFFUSE", "DIFFUSE_BSDF"):
|
||||
return cls._resolve_color_socket(node.inputs["Color"]), 0.0
|
||||
for node in nodes:
|
||||
if node.type == "BSDF_GLASS":
|
||||
return cls._resolve_color_socket(node.inputs["Color"]), 0.0
|
||||
color = tuple(material.diffuse_color[:3])
|
||||
transparency = 1.0 - material.diffuse_color[3]
|
||||
return color, transparency
|
||||
|
||||
@classmethod
|
||||
def _collect_upstream_sources(cls, socket: bpy.types.NodeSocket, seen: set[str]) -> list[tuple[str, object]]:
|
||||
"""Recursively collect all upstream colour/image sources reachable from *socket*."""
|
||||
results = []
|
||||
for link in socket.links:
|
||||
node = link.from_node
|
||||
if node.name in seen:
|
||||
continue
|
||||
seen.add(node.name)
|
||||
if node.type == "TEX_IMAGE":
|
||||
results.append(("IMAGE", node.image))
|
||||
elif node.type == "VALTORGB":
|
||||
results.append(("COLORRAMP", node))
|
||||
else:
|
||||
for inp in node.inputs:
|
||||
if inp.is_linked:
|
||||
results.extend(cls._collect_upstream_sources(inp, seen))
|
||||
return results
|
||||
|
||||
@classmethod
|
||||
def _upstream_color_source(
|
||||
cls, socket: bpy.types.NodeSocket, seen: set[str] | None = None
|
||||
) -> tuple[str, object] | None:
|
||||
sources = cls._collect_upstream_sources(socket, set() if seen is None else seen)
|
||||
# Prefer a concrete image texture over a colour ramp (which may be greyscale/procedural).
|
||||
for s in sources:
|
||||
if s[0] == "IMAGE":
|
||||
return s
|
||||
for s in sources:
|
||||
if s[0] == "COLORRAMP":
|
||||
return s
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _resolve_color_socket(cls, socket: bpy.types.NodeSocket) -> tuple[float, float, float]:
|
||||
source = cls._upstream_color_source(socket)
|
||||
if source is None:
|
||||
return tuple(socket.default_value[:3])
|
||||
kind, obj = source
|
||||
if kind == "IMAGE":
|
||||
return cls._average_image_color(obj)
|
||||
if kind == "COLORRAMP":
|
||||
return cls._average_colorramp_color(obj)
|
||||
return tuple(socket.default_value[:3])
|
||||
|
||||
@staticmethod
|
||||
def _average_image_color(image: bpy.types.Image) -> tuple[float, float, float]:
|
||||
pixels = image.pixels[:]
|
||||
n = len(pixels) // 4
|
||||
if n == 0:
|
||||
return (0.5, 0.5, 0.5)
|
||||
step = max(1, n // 4096)
|
||||
r_sum = g_sum = b_sum = 0.0
|
||||
count = 0
|
||||
for i in range(0, n, step):
|
||||
base = i * 4
|
||||
r_sum += pixels[base]
|
||||
g_sum += pixels[base + 1]
|
||||
b_sum += pixels[base + 2]
|
||||
count += 1
|
||||
return (r_sum / count, g_sum / count, b_sum / count)
|
||||
|
||||
@staticmethod
|
||||
def _average_colorramp_color(node: bpy.types.Node) -> tuple[float, float, float]:
|
||||
elements = node.color_ramp.elements
|
||||
if not elements:
|
||||
return (0.5, 0.5, 0.5)
|
||||
r = sum(e.color[0] for e in elements) / len(elements)
|
||||
g = sum(e.color[1] for e in elements) / len(elements)
|
||||
b = sum(e.color[2] for e in elements) / len(elements)
|
||||
return (r, g, b)
|
||||
|
||||
@classmethod
|
||||
def is_editing_styles(cls) -> bool:
|
||||
props = cls.get_style_props()
|
||||
@@ -674,9 +814,179 @@ class Style(bonsai.core.tool.Style):
|
||||
props = cls.get_material_style_props(blender_material)
|
||||
props.active_style_type = props.active_style_type
|
||||
|
||||
@classmethod
|
||||
def get_branch_outputs(
|
||||
cls, material: bpy.types.Material
|
||||
) -> tuple[bpy.types.ShaderNode | None, bpy.types.ShaderNode | None]:
|
||||
"""Return (external_output_node, flat_output_node), or (None, None) if not dual-branch."""
|
||||
if not material.node_tree:
|
||||
return None, None
|
||||
ext = material.node_tree.nodes.get("BIM_Output_External")
|
||||
fast = material.node_tree.nodes.get("BIM_Output_Flat")
|
||||
return ext, fast
|
||||
|
||||
@classmethod
|
||||
def _remove_external_branch(cls, material: bpy.types.Material) -> None:
|
||||
"""Remove all nodes reachable from BIM_Output_External (walks links backwards)."""
|
||||
if not material.node_tree:
|
||||
return
|
||||
nodes = material.node_tree.nodes
|
||||
output = nodes.get("BIM_Output_External")
|
||||
if not output:
|
||||
return
|
||||
to_remove: set[str] = set()
|
||||
stack = [output]
|
||||
while stack:
|
||||
node = stack.pop()
|
||||
if node.name in to_remove:
|
||||
continue
|
||||
to_remove.add(node.name)
|
||||
for inp in node.inputs:
|
||||
for link in inp.links:
|
||||
stack.append(link.from_node)
|
||||
for name in list(to_remove):
|
||||
n = nodes.get(name)
|
||||
if n:
|
||||
nodes.remove(n)
|
||||
|
||||
@classmethod
|
||||
def _build_flat_branch_nodes(cls, material: bpy.types.Material) -> bpy.types.ShaderNode:
|
||||
"""Add a Principled BSDF flat-branch to material's existing node tree.
|
||||
|
||||
Reads IfcSurfaceStyleRendering or IfcSurfaceStyleShading from the linked IFC entity.
|
||||
Defaults to a white BSDF when no IFC shading data is available.
|
||||
Returns the new Material Output node (named BIM_Output_Flat, is_active_output=False).
|
||||
"""
|
||||
from mathutils import Vector
|
||||
|
||||
style_elements = cls.get_style_elements(material)
|
||||
nodes = material.node_tree.nodes
|
||||
links = material.node_tree.links
|
||||
|
||||
bsdf = nodes.new("ShaderNodeBsdfPrincipled")
|
||||
bsdf.location = Vector((10, -600))
|
||||
output = nodes.new("ShaderNodeOutputMaterial")
|
||||
output.name = "BIM_Output_Flat"
|
||||
output.location = Vector((300, -600))
|
||||
output.is_active_output = False
|
||||
links.new(bsdf.outputs["BSDF"], output.inputs["Surface"])
|
||||
|
||||
rendering_style = None
|
||||
shading_only = None
|
||||
for surface_style in style_elements.values():
|
||||
if surface_style.is_a() == "IfcSurfaceStyleShading":
|
||||
shading_only = surface_style
|
||||
elif surface_style.is_a("IfcSurfaceStyleRendering"):
|
||||
rendering_style = surface_style
|
||||
shading_only = None
|
||||
|
||||
if rendering_style:
|
||||
d = tool.Loader.surface_style_to_dict(rendering_style)
|
||||
if d.get("DiffuseColour"):
|
||||
ctype, cval = d["DiffuseColour"]
|
||||
if ctype == "IfcColourRgb":
|
||||
bsdf.inputs["Base Color"].default_value = cval + (1,)
|
||||
solid_color = cval
|
||||
else:
|
||||
cval = tuple(v * cval for v in d["SurfaceColour"])
|
||||
bsdf.inputs["Base Color"].default_value = cval + (1,)
|
||||
solid_color = cval
|
||||
else:
|
||||
r, g, b = d["SurfaceColour"]
|
||||
bsdf.inputs["Base Color"].default_value = (r, g, b, 1.0)
|
||||
solid_color = (r, g, b)
|
||||
if d.get("SpecularColour"):
|
||||
ctype, cval = d["SpecularColour"]
|
||||
if ctype == "IfcNormalisedRatioMeasure":
|
||||
bsdf.inputs["Metallic"].default_value = cval
|
||||
if d.get("SpecularHighlight"):
|
||||
bsdf.inputs["Roughness"].default_value = d["SpecularHighlight"]
|
||||
transparency = d.get("Transparency") or 0.0
|
||||
bsdf.inputs["Alpha"].default_value = 1 - transparency
|
||||
if transparency > 0:
|
||||
material.blend_method = "BLEND"
|
||||
material.diffuse_color = solid_color + (1.0 - transparency,)
|
||||
elif shading_only:
|
||||
d = tool.Loader.surface_style_to_dict(shading_only)
|
||||
r, g, b = d["SurfaceColour"]
|
||||
alpha = 1 - (d.get("Transparency") or 0.0)
|
||||
bsdf.inputs["Base Color"].default_value = (r, g, b, 1.0)
|
||||
bsdf.inputs["Alpha"].default_value = alpha
|
||||
if alpha < 1.0:
|
||||
material.blend_method = "BLEND"
|
||||
material.diffuse_color = (r, g, b, alpha)
|
||||
# else: leave default white Principled BSDF
|
||||
return output
|
||||
|
||||
@classmethod
|
||||
def setup_dual_branch(cls, material: bpy.types.Material, ext_material: bpy.types.Material) -> bool:
|
||||
"""Build a dual-branch node tree: flat branch from IFC data + external branch from ext_material.
|
||||
|
||||
Clears any existing nodes and builds both branches from scratch.
|
||||
External branch output (BIM_Output_External) is set active — Pretty mode.
|
||||
Flat branch output (BIM_Output_Flat) is inactive — Flat mode.
|
||||
Returns True on success; False if no shader editor is available (falls back to single-branch).
|
||||
"""
|
||||
cls.set_use_nodes(material, True)
|
||||
for n in material.node_tree.nodes[:]:
|
||||
material.node_tree.nodes.remove(n)
|
||||
|
||||
cls._build_flat_branch_nodes(material)
|
||||
|
||||
ext_output = tool.Blender.copy_node_graph_additive(material, ext_material)
|
||||
if not ext_output:
|
||||
# No shader editor available: fall back to single-branch
|
||||
tool.Blender.copy_node_graph(material, ext_material)
|
||||
return False
|
||||
|
||||
ext_output.name = "BIM_Output_External"
|
||||
ext_output.is_active_output = True
|
||||
material["bim_dual_branch"] = True
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def update_external_branch(cls, material: bpy.types.Material, ext_material: bpy.types.Material) -> None:
|
||||
"""Replace the external-branch nodes of an already dual-branch material."""
|
||||
cls._remove_external_branch(material)
|
||||
ext_output = tool.Blender.copy_node_graph_additive(material, ext_material)
|
||||
if ext_output:
|
||||
ext_output.name = "BIM_Output_External"
|
||||
ext_output.is_active_output = True
|
||||
fast = material.node_tree.nodes.get("BIM_Output_Flat")
|
||||
if fast:
|
||||
fast.is_active_output = False
|
||||
|
||||
@classmethod
|
||||
def sync_flat_branch_shading(
|
||||
cls, material: bpy.types.Material, surface_colour: tuple[float, float, float], transparency: float
|
||||
) -> None:
|
||||
"""Update the flat-branch Principled BSDF with new shading values.
|
||||
|
||||
Call this after creating or editing IfcSurfaceStyleShading so the flat branch
|
||||
stays in sync without requiring a full setup_dual_branch rebuild.
|
||||
"""
|
||||
if not material.node_tree:
|
||||
return
|
||||
fast_output = material.node_tree.nodes.get("BIM_Output_Flat")
|
||||
if not fast_output:
|
||||
return
|
||||
for link in fast_output.inputs["Surface"].links:
|
||||
if link.from_node.type == "BSDF_PRINCIPLED":
|
||||
bsdf = link.from_node
|
||||
r, g, b = surface_colour
|
||||
bsdf.inputs["Base Color"].default_value = (r, g, b, 1.0)
|
||||
bsdf.inputs["Alpha"].default_value = 1.0 - transparency
|
||||
break
|
||||
|
||||
@classmethod
|
||||
def switch_shading(cls, blender_material: bpy.types.Material, style_type: StyleType) -> None:
|
||||
if style_type == "External":
|
||||
ext, fast = cls.get_branch_outputs(blender_material)
|
||||
if ext and fast:
|
||||
ext.is_active_output = True
|
||||
fast.is_active_output = False
|
||||
blender_material.update_tag()
|
||||
return
|
||||
try:
|
||||
bpy.ops.bim.activate_external_style(material_name=blender_material.name)
|
||||
except RuntimeError as error:
|
||||
@@ -684,21 +994,41 @@ class Style(bonsai.core.tool.Style):
|
||||
return
|
||||
raise error
|
||||
elif style_type == "Shading":
|
||||
ext, fast = cls.get_branch_outputs(blender_material)
|
||||
if ext and fast:
|
||||
fast.is_active_output = True
|
||||
ext.is_active_output = False
|
||||
blender_material.update_tag()
|
||||
return
|
||||
style_elements = tool.Style.get_style_elements(blender_material)
|
||||
rendering_style = None
|
||||
texture_style = None
|
||||
shading_only_style = None
|
||||
|
||||
for surface_style in style_elements.values():
|
||||
if surface_style.is_a() == "IfcSurfaceStyleShading":
|
||||
shading_only_style = surface_style
|
||||
tool.Loader.create_surface_style_shading(blender_material, surface_style)
|
||||
elif surface_style.is_a("IfcSurfaceStyleRendering"):
|
||||
rendering_style = surface_style
|
||||
shading_only_style = None # rendering overrides shading-only path
|
||||
tool.Loader.create_surface_style_rendering(blender_material, surface_style)
|
||||
elif surface_style.is_a("IfcSurfaceStyleWithTextures"):
|
||||
texture_style = surface_style
|
||||
|
||||
if rendering_style and texture_style:
|
||||
tool.Loader.create_surface_style_with_textures(blender_material, rendering_style, texture_style)
|
||||
elif shading_only_style and not rendering_style:
|
||||
# create a minimal Principled BSDF so Material Preview/Rendered shows the colour instead of white.
|
||||
tool.Style.set_use_nodes(blender_material, True)
|
||||
tool.Loader.restart_material_node_tree(blender_material)
|
||||
bsdf = tool.Blender.get_material_node(blender_material, "BSDF_PRINCIPLED")
|
||||
if bsdf:
|
||||
r, g, b, a = blender_material.diffuse_color
|
||||
bsdf.inputs["Base Color"].default_value = (r, g, b, 1)
|
||||
bsdf.inputs["Alpha"].default_value = a
|
||||
if a < 1.0:
|
||||
blender_material.blend_method = "BLEND"
|
||||
else:
|
||||
assert False, f"Invalid style type found: {style_type}"
|
||||
|
||||
@@ -744,3 +1074,36 @@ class Style(bonsai.core.tool.Style):
|
||||
elements = ifcopenshell.util.element.get_elements_by_style(tool.Ifc.get(), style)
|
||||
objects = [tool.Ifc.get_object(e) for e in elements]
|
||||
tool.Geometry.reload_representation(objects)
|
||||
|
||||
_last_shading_type: str | None = None
|
||||
|
||||
@classmethod
|
||||
def restore_material_style_types(cls, shading_type: str) -> None:
|
||||
"""Set each IFC material's active_style_type to the richest available for the given viewport mode.
|
||||
|
||||
In SOLID mode all materials use "Shading".
|
||||
In MATERIAL_PREVIEW / RENDERED, materials with an external .blend style use "External"
|
||||
unless prefer_ifc_shading is set on that material.
|
||||
"""
|
||||
if cls._last_shading_type == shading_type:
|
||||
return
|
||||
cls._last_shading_type = shading_type
|
||||
|
||||
for material in bpy.data.materials:
|
||||
if not tool.Blender.get_ifc_definition_id(material):
|
||||
continue
|
||||
props = cls.get_material_style_props(material)
|
||||
style_elements = cls.get_style_elements(material)
|
||||
if shading_type == "SOLID":
|
||||
props.active_style_type = "Shading"
|
||||
shading = style_elements.get("IfcSurfaceStyleRendering") or style_elements.get("IfcSurfaceStyleShading")
|
||||
if shading:
|
||||
d = tool.Loader.surface_style_to_dict(shading)
|
||||
alpha = 1.0 - (d.get("Transparency") or 0.0)
|
||||
material.diffuse_color = d["SurfaceColour"] + (alpha,)
|
||||
else: # MATERIAL_PREVIEW or RENDERED
|
||||
if cls.has_blender_external_style(style_elements) and not props.prefer_ifc_shading:
|
||||
props.active_style_type = "External"
|
||||
else:
|
||||
props.active_style_type = "Shading"
|
||||
material.update_tag()
|
||||
|
||||
@@ -15,6 +15,7 @@ Example usage:
|
||||
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -27,6 +28,17 @@ if sys.platform not in available_platforms:
|
||||
print(f"Currently only available on {', '.join(available_platforms)}. Not available on {sys.platform}.")
|
||||
exit(1)
|
||||
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--skip-binaries",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Skip copying compiled dependencies (e.g. ifcopenshell_wrapper) to the repo. "
|
||||
"Useful if you already have the latest binaries in the repo and don't want them to be overridden."
|
||||
),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# ---------------------------
|
||||
# SETTINGS.
|
||||
# ---------------------------
|
||||
@@ -125,17 +137,20 @@ def main() -> None:
|
||||
path.unlink()
|
||||
subprocess.check_call(("git", "checkout", "--", symlinks_glob), cwd=REPO_PATH)
|
||||
|
||||
print("Copying compiled dependencies to the repo...")
|
||||
dest = REPO_PATH / "src" / "ifcopenshell-python" / "ifcopenshell"
|
||||
for path in PACKAGE_PATH.glob("ifcopenshell/*_wrapper*"):
|
||||
if path.suffix.lower() == ".pyi":
|
||||
continue
|
||||
dest_ = dest / path.name
|
||||
print(f"Copying {path} -> {dest_}")
|
||||
try:
|
||||
shutil.copy(path, dest_)
|
||||
except shutil.SameFileError:
|
||||
pass
|
||||
if args.skip_binaries:
|
||||
print("Skipping copying compiled dependencies to the repo...")
|
||||
else:
|
||||
print("Copying compiled dependencies to the repo...")
|
||||
dest = REPO_PATH / "src" / "ifcopenshell-python" / "ifcopenshell"
|
||||
for path in PACKAGE_PATH.glob("ifcopenshell/*_wrapper*"):
|
||||
if path.suffix.lower() == ".pyi":
|
||||
continue
|
||||
dest_ = dest / path.name
|
||||
print(f"Copying {path} -> {dest_}")
|
||||
try:
|
||||
shutil.copy(path, dest_)
|
||||
except shutil.SameFileError:
|
||||
pass
|
||||
|
||||
print("Symlinking extension to the git repo...")
|
||||
# fmt: off
|
||||
|
||||
@@ -12,16 +12,19 @@ Scenario: Ensure added booleans are marked as manual
|
||||
And I click "OK"
|
||||
And the object "IfcFurniture/Unnamed" exists
|
||||
And I toggle edit mode
|
||||
And the object "Item/IfcExtrudedAreaSolid/73" exists
|
||||
And the variable "extrusion" is "{ifc}.by_type('IfcExtrudedAreaSolid')[0].id()"
|
||||
And the object "Item/IfcExtrudedAreaSolid/{extrusion}" exists
|
||||
And I open the "Add Item" menu
|
||||
When I click "Half Space Solid"
|
||||
And the object "Item/IfcHalfSpaceSolid/90" exists
|
||||
And the variable "half_space" is "{ifc}.by_type('IfcHalfSpaceSolid')[0].id()"
|
||||
And the variable "boolean" is "{ifc}.by_type('IfcBooleanResult')[0].id()"
|
||||
And the object "Item/IfcHalfSpaceSolid/{half_space}" exists
|
||||
And I deselect all objects
|
||||
And I toggle edit mode
|
||||
And I select the object "IfcFurniture/Unnamed"
|
||||
And I look at the "Property Sets" panel
|
||||
Then I see "BBIM_Boolean"
|
||||
And I see "[91]"
|
||||
And I see "[{boolean}]"
|
||||
|
||||
Scenario: Ensure removed booleans are unmarked as manual
|
||||
Given an empty IFC project
|
||||
@@ -33,17 +36,20 @@ Scenario: Ensure removed booleans are unmarked as manual
|
||||
And I click "OK"
|
||||
And the object "IfcFurniture/Unnamed" exists
|
||||
And I toggle edit mode
|
||||
And the object "Item/IfcExtrudedAreaSolid/73" exists
|
||||
And the variable "extrusion" is "{ifc}.by_type('IfcExtrudedAreaSolid')[0].id()"
|
||||
And the object "Item/IfcExtrudedAreaSolid/{extrusion}" exists
|
||||
And I open the "Add Item" menu
|
||||
And I click "Half Space Solid"
|
||||
And the variable "half_space" is "{ifc}.by_type('IfcHalfSpaceSolid')[0].id()"
|
||||
And the variable "boolean" is "{ifc}.by_type('IfcBooleanResult')[0].id()"
|
||||
And I deselect all objects
|
||||
And I toggle edit mode
|
||||
And I select the object "IfcFurniture/Unnamed"
|
||||
And I toggle edit mode
|
||||
And I select the object "Item/IfcHalfSpaceSolid/90"
|
||||
And I select the object "Item/IfcHalfSpaceSolid/{half_space}"
|
||||
When I delete the selected objects
|
||||
And I toggle edit mode
|
||||
And I select the object "IfcFurniture/Unnamed"
|
||||
And I look at the "Property Sets" panel
|
||||
Then I don't see "BBIM_Boolean"
|
||||
And I don't see "[91]"
|
||||
And I don't see "[{boolean}]"
|
||||
|
||||
@@ -32,64 +32,91 @@ from test.bim.bootstrap import NewIfc
|
||||
pytestmark = pytest.mark.project
|
||||
|
||||
|
||||
def _make_library_only_file(*, with_child: bool = False) -> ifcopenshell.file:
|
||||
"""Build a minimal IFC4 file containing only an IfcProjectLibrary (no IfcProject).
|
||||
def _make_library_file(*, with_child: bool = False) -> ifcopenshell.file:
|
||||
"""Build a spec-valid IFC4 library file: IfcProject + IfcProjectLibrary declared to it.
|
||||
|
||||
Per IFC4+, a file must contain at least one IfcContext; IfcProjectLibrary is a
|
||||
valid root on its own. ``with_child=True`` nests a sub-library under the root via
|
||||
IfcRelNests, mirroring real authored library files.
|
||||
Per the IFC Project Context concept template, every project data set (library
|
||||
files included) shall contain exactly one IfcProject, and IfcProjectLibrary
|
||||
instances are assigned to it via IfcRelDeclares. This matches how every library
|
||||
file shipped in bonsai/bim/data/libraries is actually authored. ``with_child=True``
|
||||
also nests a sub-library under the root via IfcRelNests.
|
||||
"""
|
||||
library_file = ifcopenshell.api.project.create_file(version="IFC4")
|
||||
project = ifcopenshell.api.root.create_entity(library_file, ifc_class="IfcProject", name="Demo Project")
|
||||
root = ifcopenshell.api.root.create_entity(library_file, ifc_class="IfcProjectLibrary", name="RootLib")
|
||||
ifcopenshell.api.project.assign_declaration(library_file, definitions=[root], relating_context=project)
|
||||
if with_child:
|
||||
child = ifcopenshell.api.root.create_entity(library_file, ifc_class="IfcProjectLibrary", name="ChildLib")
|
||||
ifcopenshell.api.nest.assign_object(library_file, [child], root)
|
||||
return library_file
|
||||
|
||||
|
||||
class TestLibraryOnlyFile(NewIfc):
|
||||
def test_get_root_context_returns_project_library_when_no_project(self):
|
||||
library_file = _make_library_only_file()
|
||||
assert not library_file.by_type("IfcProject")
|
||||
class TestLibraryFile(NewIfc):
|
||||
"""Project-library UI code operating on a spec-valid model (IfcProject root).
|
||||
|
||||
root = tool.Project.get_root_context(library_file)
|
||||
A file containing only IfcProjectLibrary and no IfcProject is not valid IFC and
|
||||
is not supported; see test_parent_libraries_enum_raises_for_a_file_without_a_project.
|
||||
"""
|
||||
|
||||
assert root.is_a("IfcProjectLibrary")
|
||||
assert root.Name == "RootLib"
|
||||
def test_parent_libraries_enum_raises_for_a_file_without_a_project(self):
|
||||
library_file = ifcopenshell.api.project.create_file(version="IFC4")
|
||||
ifcopenshell.api.root.create_entity(library_file, ifc_class="IfcProjectLibrary", name="RootLib")
|
||||
IfcStore.library_file = library_file
|
||||
try:
|
||||
with pytest.raises(IndexError):
|
||||
ProjectLibraryData.parent_libraries_enum()
|
||||
finally:
|
||||
IfcStore.library_file = None
|
||||
|
||||
def test_get_parent_library_returns_none_for_root_library(self):
|
||||
library_file = _make_library_only_file()
|
||||
def test_get_parent_library_returns_project_for_declared_root_library(self):
|
||||
library_file = _make_library_file()
|
||||
project = library_file.by_type("IfcProject")[0]
|
||||
root = library_file.by_type("IfcProjectLibrary")[0]
|
||||
|
||||
assert tool.Project.get_parent_library(root) is None
|
||||
assert tool.Project.get_parent_library(root) == project
|
||||
|
||||
def test_get_project_hierarchy_skips_root_library(self):
|
||||
library_file = _make_library_only_file(with_child=True)
|
||||
def test_get_parent_library_returns_the_library_for_a_nested_sub_library(self):
|
||||
library_file = _make_library_file(with_child=True)
|
||||
root = next(lib for lib in library_file.by_type("IfcProjectLibrary") if lib.Name == "RootLib")
|
||||
child = next(lib for lib in library_file.by_type("IfcProjectLibrary") if lib.Name == "ChildLib")
|
||||
|
||||
assert tool.Project.get_parent_library(child) == root
|
||||
|
||||
def test_get_parent_library_returns_none_for_an_orphaned_library(self):
|
||||
library_file = ifcopenshell.api.project.create_file(version="IFC4")
|
||||
orphan = ifcopenshell.api.root.create_entity(library_file, ifc_class="IfcProjectLibrary", name="Orphan")
|
||||
|
||||
assert tool.Project.get_parent_library(orphan) is None
|
||||
|
||||
def test_get_project_hierarchy_roots_libraries_under_the_project(self):
|
||||
library_file = _make_library_file(with_child=True)
|
||||
project = library_file.by_type("IfcProject")[0]
|
||||
root = next(lib for lib in library_file.by_type("IfcProjectLibrary") if lib.Name == "RootLib")
|
||||
child = next(lib for lib in library_file.by_type("IfcProjectLibrary") if lib.Name == "ChildLib")
|
||||
|
||||
hierarchy = tool.Project.get_project_hierarchy(library_file)
|
||||
|
||||
assert root in hierarchy
|
||||
assert root in hierarchy[project]
|
||||
assert child in hierarchy[root]
|
||||
|
||||
def test_project_library_data_loads_without_crash(self):
|
||||
IfcStore.library_file = _make_library_only_file()
|
||||
def test_project_library_data_loads_with_unique_enum_keys(self):
|
||||
IfcStore.library_file = _make_library_file(with_child=True)
|
||||
try:
|
||||
ProjectLibraryData.is_loaded = False
|
||||
ProjectLibraryData.load()
|
||||
assert ProjectLibraryData.is_loaded
|
||||
enum = ProjectLibraryData.data["parent_libraries_enum"]
|
||||
assert len(enum) == 1
|
||||
assert enum[0][1].startswith("IfcProjectLibrary ")
|
||||
keys = [entry[0] for entry in enum]
|
||||
assert len(keys) == len(set(keys))
|
||||
assert enum[0][1].startswith("IfcProject ")
|
||||
finally:
|
||||
IfcStore.library_file = None
|
||||
ProjectLibraryData.is_loaded = False
|
||||
|
||||
def test_refresh_library_succeeds_on_library_only_file(self):
|
||||
def test_refresh_library_succeeds(self):
|
||||
import bpy
|
||||
|
||||
IfcStore.library_file = _make_library_only_file(with_child=True)
|
||||
IfcStore.library_file = _make_library_file(with_child=True)
|
||||
try:
|
||||
result = bpy.ops.bim.refresh_library()
|
||||
assert result == {"FINISHED"}
|
||||
@@ -97,13 +124,65 @@ class TestLibraryOnlyFile(NewIfc):
|
||||
IfcStore.library_file = None
|
||||
ProjectLibraryData.is_loaded = False
|
||||
|
||||
def test_add_project_library_nests_under_root_when_no_project(self):
|
||||
def test_edit_project_library_moves_a_project_declared_library_under_another_library(self):
|
||||
import bpy
|
||||
|
||||
IfcStore.library_file = _make_library_only_file()
|
||||
library_file = _make_library_file()
|
||||
project = library_file.by_type("IfcProject")[0]
|
||||
root = library_file.by_type("IfcProjectLibrary")[0]
|
||||
target = ifcopenshell.api.root.create_entity(library_file, ifc_class="IfcProjectLibrary", name="TargetLib")
|
||||
ifcopenshell.api.project.assign_declaration(library_file, definitions=[target], relating_context=project)
|
||||
IfcStore.library_file = library_file
|
||||
try:
|
||||
props = tool.Project.get_project_props()
|
||||
props.selected_project_library = str(root.id())
|
||||
props.is_editing_project_library = True
|
||||
props.parent_library = str(target.id())
|
||||
|
||||
result = bpy.ops.bim.edit_project_library()
|
||||
|
||||
assert result == {"FINISHED"}
|
||||
assert tool.Project.get_parent_library(root) == target
|
||||
assert root.Nests and root.Nests[0].RelatingObject == target
|
||||
assert not root.HasContext
|
||||
finally:
|
||||
if props.is_editing_project_library:
|
||||
props.is_editing_project_library = False
|
||||
IfcStore.library_file = None
|
||||
ProjectLibraryData.is_loaded = False
|
||||
|
||||
def test_edit_project_library_moves_a_nested_library_back_under_the_project(self):
|
||||
import bpy
|
||||
|
||||
library_file = _make_library_file(with_child=True)
|
||||
project = library_file.by_type("IfcProject")[0]
|
||||
child = next(lib for lib in library_file.by_type("IfcProjectLibrary") if lib.Name == "ChildLib")
|
||||
IfcStore.library_file = library_file
|
||||
try:
|
||||
props = tool.Project.get_project_props()
|
||||
props.selected_project_library = str(child.id())
|
||||
props.is_editing_project_library = True
|
||||
props.parent_library = str(project.id())
|
||||
|
||||
result = bpy.ops.bim.edit_project_library()
|
||||
|
||||
assert result == {"FINISHED"}
|
||||
assert tool.Project.get_parent_library(child) == project
|
||||
assert child.HasContext and child.HasContext[0].RelatingContext == project
|
||||
assert not child.Nests
|
||||
finally:
|
||||
if props.is_editing_project_library:
|
||||
props.is_editing_project_library = False
|
||||
IfcStore.library_file = None
|
||||
ProjectLibraryData.is_loaded = False
|
||||
|
||||
def test_add_project_library_declares_new_library_under_the_project_root(self):
|
||||
import bpy
|
||||
|
||||
IfcStore.library_file = _make_library_file()
|
||||
library_file = IfcStore.library_file
|
||||
try:
|
||||
root = library_file.by_type("IfcProjectLibrary")[0]
|
||||
project = library_file.by_type("IfcProject")[0]
|
||||
before = set(library_file.by_type("IfcProjectLibrary"))
|
||||
|
||||
result = bpy.ops.bim.add_project_library()
|
||||
@@ -113,9 +192,9 @@ class TestLibraryOnlyFile(NewIfc):
|
||||
new_libraries = after - before
|
||||
assert len(new_libraries) == 1
|
||||
new_library = next(iter(new_libraries))
|
||||
assert new_library.Nests
|
||||
assert new_library.Nests[0].RelatingObject == root
|
||||
assert not new_library.HasContext
|
||||
assert new_library.HasContext
|
||||
assert new_library.HasContext[0].RelatingContext == project
|
||||
assert not new_library.Nests
|
||||
finally:
|
||||
IfcStore.library_file = None
|
||||
ProjectLibraryData.is_loaded = False
|
||||
|
||||
@@ -21,6 +21,10 @@ from typing import Any, Optional, Union
|
||||
|
||||
|
||||
class bSDDClientStub:
|
||||
def __init__(self):
|
||||
# Mirrors bsdd.Client so tool.Bsdd.identifier_url() works against the stub.
|
||||
self.baseurl = "https://api.bsdd.buildingsmart.org/api/"
|
||||
|
||||
def get_dictionary(self, dictionary_uri=None, include_test_dictionaries=False):
|
||||
dicts = {
|
||||
"dictionaries": [
|
||||
|
||||
@@ -187,7 +187,14 @@ class PanelSpy:
|
||||
after = ""
|
||||
if self.spied_labels:
|
||||
after = self.spied_labels[-1]
|
||||
spied_operator = {"operator": operator, "icon": icon, "text": text, "kwargs": {}, "after": after}
|
||||
spied_operator = {
|
||||
"operator": operator,
|
||||
"icon": icon,
|
||||
"text": text,
|
||||
"kwargs": {},
|
||||
"after": after,
|
||||
"bl_idname": bl_idname,
|
||||
}
|
||||
self.spied_operators.append(spied_operator)
|
||||
return OperatorSpy(spied_operator)
|
||||
elif self.spied_attr == "panel":
|
||||
@@ -210,6 +217,14 @@ class OperatorSpy:
|
||||
else:
|
||||
self.spied_data["kwargs"][name] = value
|
||||
|
||||
@property
|
||||
def bl_rna(self) -> Any:
|
||||
# Mirror the real `UILayout.operator()` return value (an OperatorProperties
|
||||
# instance), which exposes `.bl_rna` so panel code such as
|
||||
# `"module" in op.bl_rna.properties` (bonsai/bim/helper.py) also works when
|
||||
# drawing is spied on during BDD tests.
|
||||
return getattr(bpy.types, self.spied_data["bl_idname"]).bl_rna
|
||||
|
||||
|
||||
class TemplateListSpy(PanelSpy):
|
||||
items: bpy.types.bpy_prop_collection_idprop[bpy.types.PropertyGroup]
|
||||
@@ -453,6 +468,7 @@ def i_trigger_operator(operator):
|
||||
@then(parsers.parse('I see "{text}"'))
|
||||
def i_see_text(text):
|
||||
assert panel_spy
|
||||
text = replace_variables(text)
|
||||
panel_spy.refresh_spy()
|
||||
assert [l for l in panel_spy.spied_labels if text in l], f"Text {text} not found in {panel_spy.spied_labels}"
|
||||
|
||||
@@ -587,6 +603,7 @@ def i_select_the_row_where_i_see_text_in_the_nth_list(text, nth):
|
||||
@then(parsers.parse('I don\'t see "{text}"'))
|
||||
def i_dont_see_text(text):
|
||||
assert panel_spy
|
||||
text = replace_variables(text)
|
||||
panel_spy.refresh_spy()
|
||||
assert not [l for l in panel_spy.spied_labels if text in l], f"Text {text} found in {panel_spy.spied_labels}"
|
||||
|
||||
@@ -773,7 +790,11 @@ def i_create_default_mep_types():
|
||||
with bpy.context.temp_override(active_object=bpy.data.objects["IfcActuatorType/ACTUATOR"]):
|
||||
bpy.ops.bim.add_port()
|
||||
# port at cube's left side
|
||||
bpy.data.objects["IfcDistributionPort/Port"].location = (-0.5, 0, 0)
|
||||
# Newly created ports are never given an explicit IFC `.Name` (see
|
||||
# `core/system.py:create_port_at_cursor` / `tool/system.py`), so
|
||||
# `tool.Loader.get_name()` falls back to the standard "Unnamed" convention
|
||||
# used throughout Bonsai for freshly-created, not-yet-named elements.
|
||||
bpy.data.objects["IfcDistributionPort/Unnamed"].location = (-0.5, 0, 0)
|
||||
bpy.ops.bim.hide_ports()
|
||||
|
||||
|
||||
@@ -1073,6 +1094,7 @@ def then_the_object_name_is_placed_in_the_collection_collection(name: str, colle
|
||||
@given(parsers.parse('additionally the object "{name}" is selected'))
|
||||
@when(parsers.parse('additionally the object "{name}" is selected'))
|
||||
def additionally_the_object_name_is_selected(name):
|
||||
name = replace_variables(name)
|
||||
obj = bpy.context.scene.objects.get(name)
|
||||
if not obj:
|
||||
total = len(bpy.context.scene.objects)
|
||||
@@ -1153,6 +1175,7 @@ def nothing_happens():
|
||||
@when(parsers.parse('the object "{name}" exists'))
|
||||
@then(parsers.parse('the object "{name}" exists'))
|
||||
def the_object_name_exists(name: str) -> bpy.types.Object:
|
||||
name = replace_variables(name)
|
||||
# Some objects from linked collections may share the same name. This disambiguates them.
|
||||
if name.startswith("Col:"):
|
||||
_, collection_name, name = name.split(":")
|
||||
@@ -1167,6 +1190,7 @@ def the_object_name_exists(name: str) -> bpy.types.Object:
|
||||
|
||||
@then(parsers.parse('the object "{name}" does not exist'))
|
||||
def the_object_name_does_not_exist(name) -> None:
|
||||
name = replace_variables(name)
|
||||
obj = bpy.data.objects.get(name)
|
||||
assert obj is None, f'The object "{name}" exists'
|
||||
|
||||
|
||||
@@ -93,6 +93,22 @@ class TestRemoveMaterialSet:
|
||||
subject.remove_material_set(ifc, material, material="material")
|
||||
|
||||
|
||||
class TestRenameMaterial:
|
||||
def test_renaming_a_material(self, ifc, material):
|
||||
ifc.run("material.edit_material", material="material", attributes={"Name": "name"}).should_be_called()
|
||||
material.is_editing_materials().should_be_called().will_return(False)
|
||||
material.refresh().should_be_called()
|
||||
subject.rename_material(ifc, material, material="material", name="name")
|
||||
|
||||
def test_renaming_a_material_and_reloading_imported_materials(self, ifc, material):
|
||||
ifc.run("material.edit_material", material="material", attributes={"Name": "name"}).should_be_called()
|
||||
material.is_editing_materials().should_be_called().will_return(True)
|
||||
material.get_active_material_type().should_be_called().will_return("material_type")
|
||||
material.import_material_definitions("material_type").should_be_called()
|
||||
material.refresh().should_be_called()
|
||||
subject.rename_material(ifc, material, material="material", name="name")
|
||||
|
||||
|
||||
class TestLoadMaterials:
|
||||
def test_run(self, material):
|
||||
material.import_material_definitions("material_type").should_be_called()
|
||||
|
||||
@@ -497,6 +497,8 @@ class TestGitMergetool:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
ifc_path = os.path.join(tmpdir, "model.ifc")
|
||||
mock_repo = mock.MagicMock()
|
||||
# A clean mergetool resolution leaves no unmerged blobs in the index.
|
||||
mock_repo.index.unmerged_blobs.return_value = {}
|
||||
IfcGitRepo.repo = mock_repo
|
||||
result = IfcGit.git_mergetool("ifcmerge", ifc_path)
|
||||
assert result is None
|
||||
@@ -511,6 +513,8 @@ class TestGitMergetool:
|
||||
report_path = ifc_path + ".ifcmerge"
|
||||
open(report_path, "w").close()
|
||||
mock_repo = mock.MagicMock()
|
||||
# A clean mergetool resolution leaves no unmerged blobs in the index.
|
||||
mock_repo.index.unmerged_blobs.return_value = {}
|
||||
IfcGitRepo.repo = mock_repo
|
||||
result = IfcGit.git_mergetool("ifcmerge", ifc_path)
|
||||
assert result is None
|
||||
|
||||
@@ -139,6 +139,17 @@ class TestImportMaterialDefinitions(NewFile):
|
||||
assert props.materials[0].total_elements == 0
|
||||
|
||||
|
||||
class TestRefresh(NewFile):
|
||||
def test_run(self):
|
||||
from bonsai.bim.module.material.data import MaterialsData, ObjectMaterialData
|
||||
|
||||
MaterialsData.is_loaded = True
|
||||
ObjectMaterialData.is_loaded = True
|
||||
subject.refresh()
|
||||
assert MaterialsData.is_loaded is False
|
||||
assert ObjectMaterialData.is_loaded is False
|
||||
|
||||
|
||||
class TestIsEditingMaterials(NewFile):
|
||||
def test_run(self):
|
||||
props = tool.Material.get_material_props()
|
||||
|
||||
@@ -56,3 +56,31 @@ class TestValidateInput(NewFile):
|
||||
|
||||
# Angle.
|
||||
assert subject.validate_input("25", "A") == (True, "25.0")
|
||||
|
||||
|
||||
class TestCalculateDistanceAndAngle(NewFile):
|
||||
def test_it_does_not_crash_when_distance_is_zero_and_should_round(self, monkeypatch):
|
||||
# Regression test for #8597: right after placing the first polyline
|
||||
# point, the initial mouse sample can equal the last placed point
|
||||
# (distance == 0), e.g. entering the viewport on a YZ plane wall.
|
||||
# angle_round_threshold used to only be assigned in the
|
||||
# `distance > 0` branch, crashing when should_round reads it here.
|
||||
# get_increment_snap_value requires a real 3D viewport rv3d, which
|
||||
# is unrelated to this bug, so it's stubbed out for a headless run.
|
||||
monkeypatch.setattr(tool.Snap, "get_increment_snap_value", classmethod(lambda cls, context: 1.0))
|
||||
|
||||
polyline_props = tool.Model.get_polyline_props()
|
||||
mouse_point = polyline_props.snap_mouse_point.add()
|
||||
mouse_point.x, mouse_point.y, mouse_point.z = 0, 0, 0
|
||||
|
||||
tool_state = subject.create_tool_state()
|
||||
tool_state.is_input_on = False
|
||||
tool_state.use_default_container = False
|
||||
tool_state.plane_method = "YZ"
|
||||
|
||||
input_ui = subject.create_input_ui(input_options=["D", "A", "X", "Y", "Z"])
|
||||
|
||||
subject.calculate_distance_and_angle(bpy.context, input_ui, tool_state, should_round=True)
|
||||
|
||||
assert input_ui.get_number_value("D") == 0
|
||||
assert input_ui.get_number_value("A") == 0
|
||||
|
||||
@@ -288,3 +288,27 @@ class TestGenerateSpace(NewFile):
|
||||
)
|
||||
)
|
||||
assert np.allclose(TEST_VERTS, sorted([tuple(v.co) for v in mesh.vertices]))
|
||||
|
||||
def test_regenerate_space_preserves_z_location(self):
|
||||
bpy.ops.bim.create_project()
|
||||
ifc = tool.Ifc.get()
|
||||
scene = bpy.context.scene
|
||||
product = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcWall")
|
||||
bpy.ops.mesh.primitive_cube_add(size=10, location=(0, 0, 4))
|
||||
obj = bpy.data.objects["Cube"]
|
||||
scene.collection.objects.link(obj)
|
||||
tool.Ifc.link(product, obj)
|
||||
scene.cursor.location = (0, 0, 0)
|
||||
|
||||
bpy.ops.bim.generate_space()
|
||||
space = bpy.data.objects["IfcSpace/Space"]
|
||||
space.location.z = 5
|
||||
bpy.context.view_layer.update()
|
||||
|
||||
bpy.context.view_layer.objects.active = space
|
||||
space.select_set(True)
|
||||
obj.select_set(False)
|
||||
|
||||
bpy.ops.bim.generate_space()
|
||||
|
||||
assert np.isclose(space.location.z, 5), f"Expected z=5, got {space.location.z}"
|
||||
|
||||
+17
-2
@@ -26,7 +26,9 @@ import webbrowser
|
||||
from typing import TYPE_CHECKING, Any, Literal, Optional, TypedDict
|
||||
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter
|
||||
from typing_extensions import NotRequired
|
||||
from urllib3.util import Retry
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import ifcopenshell
|
||||
@@ -517,12 +519,25 @@ class Client:
|
||||
self.auth_endpoint = "https://buildingsmartservices.b2clogin.com/tfp/buildingsmartservices.onmicrosoft.com/b2c_1_signupsignin/oauth2/v2.0/authorize"
|
||||
self.token_endpoint = "https://buildingsmartservices.b2clogin.com/tfp/buildingsmartservices.onmicrosoft.com/b2c_1_signupsignin/oauth2/v2.0/token"
|
||||
self.client_id = "4aba821f-d4ff-498b-a462-c2837dbbba70"
|
||||
# The bSDD API is aggressively rate limited (HTTP 429). Retry transient
|
||||
# failures with backoff instead of immediately raising, honouring the
|
||||
# server's `Retry-After` header when present.
|
||||
self.session = requests.Session()
|
||||
retries = Retry(
|
||||
total=5,
|
||||
backoff_factor=1,
|
||||
status_forcelist=[429, 500, 502, 503, 504],
|
||||
respect_retry_after_header=True,
|
||||
allowed_methods=["GET", "POST"],
|
||||
)
|
||||
self.session.mount("https://", HTTPAdapter(max_retries=retries))
|
||||
self.session.mount("http://", HTTPAdapter(max_retries=retries))
|
||||
|
||||
def get(self, endpoint, params=None, is_auth_required=False):
|
||||
headers = {"User-Agent": "IfcOpenShell.bSDD.py/0.8.0"}
|
||||
if is_auth_required:
|
||||
headers["Authorization"] = "Bearer " + self.get_access_token()
|
||||
response = requests.get(f"{self.baseurl}{endpoint}", timeout=10, headers=headers, params=params or None)
|
||||
response = self.session.get(f"{self.baseurl}{endpoint}", timeout=10, headers=headers, params=params or None)
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.HTTPError as e:
|
||||
@@ -539,7 +554,7 @@ class Client:
|
||||
old_baseurl = "https://bs-dd-api-prototype.azurewebsites.net/"
|
||||
if is_auth_required:
|
||||
headers["Authorization"] = "Bearer " + self.get_access_token()
|
||||
return requests.get(f"{old_baseurl}{endpoint}", timeout=10, headers=headers, params=params or None).json()
|
||||
return self.session.get(f"{old_baseurl}{endpoint}", timeout=10, headers=headers, params=params or None).json()
|
||||
|
||||
def post(self):
|
||||
pass # TODO
|
||||
|
||||
@@ -188,9 +188,9 @@
|
||||
},
|
||||
"IfcCovering": {
|
||||
"Qto_CoveringBaseQuantities": {
|
||||
"GrossArea": "gross_get_max_side_area",
|
||||
"NetArea": "net_get_max_side_area",
|
||||
"Width": "gross_get_min_xyz"
|
||||
"GrossArea": "gross_get_covering_area",
|
||||
"NetArea": "net_get_covering_area",
|
||||
"Width": "gross_get_covering_width"
|
||||
}
|
||||
},
|
||||
"IfcCurtainWall": {
|
||||
@@ -318,12 +318,12 @@
|
||||
"GrossSurfaceArea": null,
|
||||
"GrossVolume": null,
|
||||
"GrossWeight": "gross_get_weight",
|
||||
"Height": "net_get_z",
|
||||
"Length": "net_get_max_xy",
|
||||
"Height": "net_get_footing_height",
|
||||
"Length": "net_get_footing_length",
|
||||
"NetVolume": "net_get_volume",
|
||||
"NetWeight": "net_get_weight",
|
||||
"OuterSurfaceArea": null,
|
||||
"Width": null
|
||||
"Width": "net_get_footing_width"
|
||||
}
|
||||
},
|
||||
"IfcHeatExchanger": {
|
||||
@@ -383,11 +383,11 @@
|
||||
},
|
||||
"IfcOpeningElement": {
|
||||
"Qto_OpeningElementBaseQuantities": {
|
||||
"Area": "gross_get_max_side_area",
|
||||
"Depth": "gross_get_z",
|
||||
"Height": "gross_get_y",
|
||||
"Area": "gross_get_opening_area",
|
||||
"Depth": "gross_get_opening_depth",
|
||||
"Height": "gross_get_opening_height",
|
||||
"Volume": "gross_get_volume",
|
||||
"Width": "gross_get_x"
|
||||
"Width": "gross_get_opening_width"
|
||||
}
|
||||
},
|
||||
"IfcOutlet": {
|
||||
@@ -529,16 +529,16 @@
|
||||
"Qto_SpaceBaseQuantities": {
|
||||
"FinishCeilingHeight": null,
|
||||
"FinishFloorHeight": null,
|
||||
"GrossCeilingArea": null,
|
||||
"GrossFloorArea": null,
|
||||
"GrossPerimeter": null,
|
||||
"GrossVolume": null,
|
||||
"GrossCeilingArea": "gross_get_top_area",
|
||||
"GrossFloorArea": "gross_get_footprint_area",
|
||||
"GrossPerimeter": "gross_get_footprint_perimeter",
|
||||
"GrossVolume": "gross_get_volume",
|
||||
"GrossWallArea": null,
|
||||
"Height": null,
|
||||
"NetCeilingArea": null,
|
||||
"NetFloorArea": null,
|
||||
"Height": "net_get_z",
|
||||
"NetCeilingArea": "net_get_top_area",
|
||||
"NetFloorArea": "net_get_footprint_area",
|
||||
"NetPerimeter": null,
|
||||
"NetVolume": null,
|
||||
"NetVolume": "net_get_volume",
|
||||
"NetWallArea": null
|
||||
}
|
||||
},
|
||||
@@ -606,13 +606,13 @@
|
||||
},
|
||||
"IfcWall": {
|
||||
"Qto_WallBaseQuantities": {
|
||||
"GrossFootprintArea": null,
|
||||
"GrossFootprintArea": "gross_get_footprint_area",
|
||||
"GrossSideArea": "gross_get_side_area",
|
||||
"GrossVolume": "gross_get_volume",
|
||||
"GrossWeight": "gross_get_weight",
|
||||
"Height": "net_get_z",
|
||||
"Length": "net_get_x",
|
||||
"NetFootprintArea": null,
|
||||
"NetFootprintArea": "net_get_footprint_area",
|
||||
"NetSideArea": "net_get_side_area",
|
||||
"NetVolume": "net_get_volume",
|
||||
"NetWeight": "net_get_weight",
|
||||
|
||||
@@ -323,7 +323,7 @@
|
||||
"NetVolume": "get_net_volume",
|
||||
"NetWeight": "get_net_weight",
|
||||
"OuterSurfaceArea": "get_outer_surface_area",
|
||||
"Width": "get_width"
|
||||
"Width": "get_footing_width"
|
||||
}
|
||||
},
|
||||
"IfcHeatExchanger": {
|
||||
@@ -387,7 +387,7 @@
|
||||
"Depth": "get_opening_depth",
|
||||
"Height": "get_opening_height",
|
||||
"Volume": "get_net_volume",
|
||||
"Width": "get_length"
|
||||
"Width": "get_x"
|
||||
}
|
||||
},
|
||||
"IfcOutlet": {
|
||||
|
||||
@@ -204,9 +204,9 @@
|
||||
},
|
||||
"IfcCovering + IfcCoveringType": {
|
||||
"Qto_CoveringBaseQuantities": {
|
||||
"GrossArea": "gross_get_max_side_area",
|
||||
"NetArea": "net_get_max_side_area",
|
||||
"Width": "gross_get_min_xyz"
|
||||
"GrossArea": "gross_get_covering_area",
|
||||
"NetArea": "net_get_covering_area",
|
||||
"Width": "gross_get_covering_width"
|
||||
}
|
||||
},
|
||||
"IfcCurtainWall + IfcCurtainWallType": {
|
||||
@@ -265,21 +265,21 @@
|
||||
},
|
||||
"IfcEarthworksCut": {
|
||||
"Qto_EarthworksCutBaseQuantities": {
|
||||
"Depth": null,
|
||||
"Length": null,
|
||||
"Depth": "net_get_z",
|
||||
"Length": "net_get_x",
|
||||
"LooseVolume": null,
|
||||
"UndisturbedVolume": null,
|
||||
"UndisturbedVolume": "net_get_volume",
|
||||
"Weight": null,
|
||||
"Width": null
|
||||
"Width": "net_get_y"
|
||||
}
|
||||
},
|
||||
"IfcEarthworksFill": {
|
||||
"Qto_EarthworksFillBaseQuantities": {
|
||||
"CompactedVolume": null,
|
||||
"Depth": null,
|
||||
"Length": null,
|
||||
"CompactedVolume": "net_get_volume",
|
||||
"Depth": "net_get_z",
|
||||
"Length": "net_get_x",
|
||||
"LooseVolume": null,
|
||||
"Width": null
|
||||
"Width": "net_get_y"
|
||||
}
|
||||
},
|
||||
"IfcElectricAppliance + IfcElectricApplianceType": {
|
||||
@@ -363,12 +363,12 @@
|
||||
"GrossSurfaceArea": null,
|
||||
"GrossVolume": null,
|
||||
"GrossWeight": "gross_get_weight",
|
||||
"Height": "net_get_z",
|
||||
"Length": "net_get_max_xy",
|
||||
"Height": "net_get_footing_height",
|
||||
"Length": "net_get_footing_length",
|
||||
"NetVolume": "net_get_volume",
|
||||
"NetWeight": "net_get_weight",
|
||||
"OuterSurfaceArea": null,
|
||||
"Width": null
|
||||
"Width": "net_get_footing_width"
|
||||
}
|
||||
},
|
||||
"IfcGeotechnicalStratum": {
|
||||
@@ -472,11 +472,11 @@
|
||||
},
|
||||
"IfcOpeningElement": {
|
||||
"Qto_OpeningElementBaseQuantities": {
|
||||
"Area": "gross_get_max_side_area",
|
||||
"Depth": "gross_get_z",
|
||||
"Height": "gross_get_y",
|
||||
"Area": "gross_get_opening_area",
|
||||
"Depth": "gross_get_opening_depth",
|
||||
"Height": "gross_get_opening_height",
|
||||
"Volume": "gross_get_volume",
|
||||
"Width": "gross_get_x"
|
||||
"Width": "gross_get_opening_width"
|
||||
}
|
||||
},
|
||||
"IfcOutlet + IfcOutletType": {
|
||||
@@ -585,11 +585,11 @@
|
||||
},
|
||||
"IfcReinforcedSoil": {
|
||||
"Qto_ReinforcedSoilBaseQuantities": {
|
||||
"Area": null,
|
||||
"Depth": null,
|
||||
"Length": null,
|
||||
"Volume": null,
|
||||
"Width": null
|
||||
"Area": "net_get_footprint_area",
|
||||
"Depth": "net_get_z",
|
||||
"Length": "net_get_x",
|
||||
"Volume": "net_get_volume",
|
||||
"Width": "net_get_y"
|
||||
}
|
||||
},
|
||||
"IfcReinforcingElement + IfcReinforcingElementType": {
|
||||
@@ -665,16 +665,16 @@
|
||||
"Qto_SpaceBaseQuantities": {
|
||||
"FinishCeilingHeight": null,
|
||||
"FinishFloorHeight": null,
|
||||
"GrossCeilingArea": null,
|
||||
"GrossFloorArea": null,
|
||||
"GrossPerimeter": null,
|
||||
"GrossVolume": null,
|
||||
"GrossCeilingArea": "gross_get_top_area",
|
||||
"GrossFloorArea": "gross_get_footprint_area",
|
||||
"GrossPerimeter": "gross_get_footprint_perimeter",
|
||||
"GrossVolume": "gross_get_volume",
|
||||
"GrossWallArea": null,
|
||||
"Height": null,
|
||||
"NetCeilingArea": null,
|
||||
"NetFloorArea": null,
|
||||
"Height": "net_get_z",
|
||||
"NetCeilingArea": "net_get_top_area",
|
||||
"NetFloorArea": "net_get_footprint_area",
|
||||
"NetPerimeter": null,
|
||||
"NetVolume": null,
|
||||
"NetVolume": "net_get_volume",
|
||||
"NetWallArea": null
|
||||
}
|
||||
},
|
||||
@@ -769,13 +769,13 @@
|
||||
},
|
||||
"IfcWall + IfcWallType": {
|
||||
"Qto_WallBaseQuantities": {
|
||||
"GrossFootPrintArea": null,
|
||||
"GrossFootPrintArea": "gross_get_footprint_area",
|
||||
"GrossSideArea": "gross_get_side_area",
|
||||
"GrossVolume": "gross_get_volume",
|
||||
"GrossWeight": "gross_get_weight",
|
||||
"Height": "net_get_z",
|
||||
"Length": "net_get_x",
|
||||
"NetFootPrintArea": null,
|
||||
"NetFootPrintArea": "net_get_footprint_area",
|
||||
"NetSideArea": "net_get_side_area",
|
||||
"NetVolume": "net_get_volume",
|
||||
"NetWeight": "net_get_weight",
|
||||
|
||||
@@ -363,12 +363,12 @@
|
||||
"GrossSurfaceArea": "get_gross_surface_area",
|
||||
"GrossVolume": "get_gross_volume",
|
||||
"GrossWeight": "get_gross_weight",
|
||||
"Height": "get_height",
|
||||
"Length": "get_length",
|
||||
"Height": "get_footing_height",
|
||||
"Length": "get_footing_length",
|
||||
"NetVolume": "get_net_volume",
|
||||
"NetWeight": "get_net_weight",
|
||||
"OuterSurfaceArea": "get_outer_surface_area",
|
||||
"Width": "get_width"
|
||||
"Width": "get_footing_width"
|
||||
}
|
||||
},
|
||||
"IfcGeotechnicalStratum": {
|
||||
@@ -476,7 +476,7 @@
|
||||
"Depth": "get_opening_depth",
|
||||
"Height": "get_opening_height",
|
||||
"Volume": "get_net_volume",
|
||||
"Width": "get_length"
|
||||
"Width": "get_x"
|
||||
}
|
||||
},
|
||||
"IfcOutlet + IfcOutletType": {
|
||||
@@ -585,11 +585,11 @@
|
||||
},
|
||||
"IfcReinforcedSoil": {
|
||||
"Qto_ReinforcedSoilBaseQuantities": {
|
||||
"Area": null,
|
||||
"Depth": null,
|
||||
"Length": null,
|
||||
"Volume": null,
|
||||
"Width": null
|
||||
"Area": "get_net_footprint_area",
|
||||
"Depth": "get_height",
|
||||
"Length": "get_length",
|
||||
"Volume": "get_net_volume",
|
||||
"Width": "get_width"
|
||||
}
|
||||
},
|
||||
"IfcReinforcingElement + IfcReinforcingElementType": {
|
||||
|
||||
@@ -386,12 +386,71 @@ class Ifc5Dwriter:
|
||||
"PredefinedType": cost_schedule.PredefinedType,
|
||||
}
|
||||
|
||||
# Presentation formats (.ods / .xlsx) mirror exactly what the Bonsai cost
|
||||
# panel shows for a cost item: ID (Identification), Name, Quantity,
|
||||
# Value (RateSubtotal) and the calculated Total Cost. Everything else
|
||||
# (internal bookkeeping columns, Description, Unit, per-category cost
|
||||
# breakdowns) is bonsai/csv2ifc round-trip plumbing and stays out of the
|
||||
# presentation formats. The .csv format keeps the full column set since
|
||||
# csv2ifc reads those extra columns back in on import.
|
||||
PRESENTATION_COLUMNS = ("Identification", "Name", "Quantity", "RateSubtotal", "TotalPrice")
|
||||
|
||||
# Header text as shown in presentation formats, matching the Bonsai cost
|
||||
# panel's own column labels (see BIM_UL_cost_items_trait.draw_header).
|
||||
PRESENTATION_LABELS = {
|
||||
"Identification": "ID",
|
||||
"RateSubtotal": "Value",
|
||||
"TotalPrice": "Total Cost",
|
||||
}
|
||||
|
||||
def multiply_cells(self, cell1, cell2):
|
||||
return "={}*{}".format(cell1, cell2)
|
||||
|
||||
def sum_cells(self, list_of_cells):
|
||||
return "=SUM({})".format(",".join(list_of_cells))
|
||||
|
||||
def get_visible_headers(self, schedule_id: int) -> list[str]:
|
||||
"""Internal column keys shown in presentation formats, in panel order."""
|
||||
headers = self.sheet_data[schedule_id]["headers"]
|
||||
return [h for h in self.PRESENTATION_COLUMNS if h in headers]
|
||||
|
||||
def get_display_label(self, column: str) -> str:
|
||||
"""Header text to write for a column in presentation formats."""
|
||||
return self.PRESENTATION_LABELS.get(column, column)
|
||||
|
||||
def is_numeric_column(self, column: str) -> bool:
|
||||
return column in ("Quantity", "RateSubtotal", "TotalPrice") or column.endswith(" Cost")
|
||||
|
||||
def get_total_price_formula(self, schedule_id: int, cost_item_index: int, first_data_row: int) -> Union[str, None]:
|
||||
"""Spreadsheet formula for the TotalPrice cell of a cost item, or None for a plain value.
|
||||
|
||||
Sum items get ``=SUM(...)`` over the TotalPrice cells of their direct
|
||||
children, leaf items with a quantity and a rate get ``=Quantity*RateSubtotal``.
|
||||
Assumes one cost item per row, in ``cost_items`` order, starting at
|
||||
``first_data_row`` (1-based).
|
||||
"""
|
||||
items = self.sheet_data[schedule_id]["cost_items"]
|
||||
headers = self.get_visible_headers(schedule_id)
|
||||
if "TotalPrice" not in headers:
|
||||
return None
|
||||
item = items[cost_item_index]
|
||||
col = lambda name: self.column_indexes[headers.index(name)]
|
||||
if item["ItemIsASum"]:
|
||||
prefix = item["Hierarchy"] + "."
|
||||
child_rows = [
|
||||
first_data_row + i
|
||||
for i, other in enumerate(items)
|
||||
if other["Hierarchy"].startswith(prefix) and "." not in other["Hierarchy"][len(prefix) :]
|
||||
]
|
||||
if child_rows:
|
||||
total_col = col("TotalPrice")
|
||||
return self.sum_cells(["{}{}".format(total_col, r) for r in child_rows])
|
||||
return None
|
||||
if "Quantity" in headers and "RateSubtotal" in headers and item.get("Quantity") and item.get("RateSubtotal"):
|
||||
row = first_data_row + cost_item_index
|
||||
return self.multiply_cells("{}{}".format(col("Quantity"), row), "{}{}".format(col("RateSubtotal"), row))
|
||||
return None
|
||||
|
||||
def get_cell_position(self, schedule_id, attribute):
|
||||
def get_position_in_list(item, item_list):
|
||||
try:
|
||||
@@ -482,32 +541,25 @@ class Ifc5DOdsWriter(Ifc5Dwriter):
|
||||
assert False, type
|
||||
row.addElement(cell)
|
||||
|
||||
def add_cost_item_rows(table, cost_data):
|
||||
first_data_row = 6 # 3 metadata rows, 1 blank row, 1 header row.
|
||||
|
||||
def add_cost_item_rows(table, cost_data, cost_item_index):
|
||||
row = TableRow()
|
||||
self.row_count += 1
|
||||
style = self.colours.get(cost_data["Index"])
|
||||
|
||||
for i, column in enumerate(self.sheet_data[cost_schedule.id()]["headers"]):
|
||||
if column == "Total Price" and cost_data["Quantity"] != 0 and cost_data["Rate Subtotal"]:
|
||||
cell_quantity = self.get_cell_position(cost_schedule.id(), "Quantity")
|
||||
cell_subtotal_rate = self.get_cell_position(cost_schedule.id(), "Rate Subtotal")
|
||||
value = self.multiply_cells(cell_quantity, cell_subtotal_rate)
|
||||
cell = TableCell(formula=value, stylename=self.colours.get(cost_data["Index"]))
|
||||
for column in self.get_visible_headers(cost_schedule.id()):
|
||||
value = cost_data.get(column, "")
|
||||
formula = None
|
||||
if column == "TotalPrice":
|
||||
formula = self.get_total_price_formula(cost_schedule.id(), cost_item_index, first_data_row)
|
||||
if formula:
|
||||
cell = TableCell(formula=formula, stylename=style)
|
||||
elif self.is_numeric_column(column) and isinstance(value, (int, float)):
|
||||
cell = TableCell(valuetype="float", value=value, stylename=style)
|
||||
else:
|
||||
value = cost_data.get(column, "")
|
||||
cell = TableCell(valuetype="string", stylename=self.colours.get(cost_data["Index"]))
|
||||
cell = TableCell(valuetype="string", stylename=style)
|
||||
cell.addElement(P(text=value))
|
||||
# TODO:FIX QUANTITY AND COST TO SHOW AS NUMBERS AND CURRENCIES
|
||||
# elif "Cost" in column or "Rate" in column:
|
||||
# value = cost_data.get(column, "")
|
||||
# cell = TableCell(valuetype="string", stylename=self.colours.get(cost_data["Index"]))
|
||||
# cell.addElement(P(text=value))
|
||||
# # cell.addElement(P(text=u"${}".format(value))) # The current displayed value
|
||||
# print("Should add rate ", "${}".format(value))
|
||||
# elif "Quantity" in column:
|
||||
# value = cost_data.get(column, "")
|
||||
# cell = TableCell(valuetype="float", stylename=self.colours.get(cost_data["Index"]))
|
||||
# print("Should add quantity",value)
|
||||
# cell.addElement(P(text=value))
|
||||
row.addElement(cell)
|
||||
table.addElement(row)
|
||||
|
||||
@@ -534,20 +586,22 @@ class Ifc5DOdsWriter(Ifc5Dwriter):
|
||||
table.addElement(new)
|
||||
|
||||
header_row = TableRow()
|
||||
for header in self.sheet_data[cost_schedule.id()]["headers"]:
|
||||
add_cell(type="text", value=header, row=header_row, style="fed8b1")
|
||||
for header in self.get_visible_headers(cost_schedule.id()):
|
||||
add_cell(type="text", value=self.get_display_label(header), row=header_row, style="fed8b1")
|
||||
table.addElement(header_row)
|
||||
|
||||
self.row_count = 5
|
||||
for cost_item_data in self.sheet_data[cost_schedule.id()]["cost_items"]:
|
||||
add_cost_item_rows(table, cost_item_data)
|
||||
for i, cost_item_data in enumerate(self.sheet_data[cost_schedule.id()]["cost_items"]):
|
||||
add_cost_item_rows(table, cost_item_data, i)
|
||||
|
||||
self.doc.spreadsheet.addElement(table)
|
||||
|
||||
|
||||
class Ifc5DXlsxWriter(Ifc5Dwriter):
|
||||
def write(self) -> None:
|
||||
import xlsxwriter
|
||||
# openpyxl rather than xlsxwriter: it is what ifccsv already uses and
|
||||
# what ships with Bonsai, so XLSX export works out of the box there.
|
||||
import openpyxl
|
||||
|
||||
super().write()
|
||||
os.makedirs(self.output, exist_ok=True)
|
||||
@@ -558,24 +612,31 @@ class Ifc5DXlsxWriter(Ifc5Dwriter):
|
||||
else:
|
||||
file_name += cost_schedule.Name or ""
|
||||
self.file_path = os.path.join(self.output, "{}.xlsx".format(file_name))
|
||||
self.workbook = xlsxwriter.Workbook(self.file_path)
|
||||
self.workbook = openpyxl.Workbook()
|
||||
self.workbook.remove(self.workbook.active)
|
||||
for cost_schedule in self.cost_schedules:
|
||||
self.write_table(cost_schedule)
|
||||
self.workbook.close()
|
||||
self.workbook.save(self.file_path)
|
||||
|
||||
def write_table(self, cost_schedule):
|
||||
worksheet = self.workbook.add_worksheet(self.sheet_data[cost_schedule.id()]["Name"])
|
||||
headers = self.sheet_data[cost_schedule.id()]["headers"]
|
||||
for i, header in enumerate(headers):
|
||||
worksheet.write(0, i, header)
|
||||
import re
|
||||
|
||||
row = 1
|
||||
for cost_item_data in self.sheet_data[cost_schedule.id()]["cost_items"]:
|
||||
col = 0
|
||||
sheet_id = cost_schedule.id()
|
||||
title = re.sub(r"[\[\]:*?/\\]", "_", self.sheet_data[sheet_id]["Name"])[:31]
|
||||
worksheet = self.workbook.create_sheet(title)
|
||||
headers = self.get_visible_headers(sheet_id)
|
||||
worksheet.append([self.get_display_label(h) for h in headers])
|
||||
|
||||
first_data_row = 2 # Row 1 is the header.
|
||||
for i, cost_item_data in enumerate(self.sheet_data[sheet_id]["cost_items"]):
|
||||
row = []
|
||||
for header in headers:
|
||||
worksheet.write(row, col, cost_item_data.get(header, ""))
|
||||
col += 1
|
||||
row += 1
|
||||
formula = None
|
||||
if header == "TotalPrice":
|
||||
formula = self.get_total_price_formula(sheet_id, i, first_data_row)
|
||||
# openpyxl treats strings starting with "=" as formulas.
|
||||
row.append(formula if formula else cost_item_data.get(header, None))
|
||||
worksheet.append(row)
|
||||
|
||||
|
||||
class Ifc5DPdfWriter(Ifc5Dwriter):
|
||||
|
||||
+183
-1
@@ -254,8 +254,52 @@ class IfcOpenShell(QtoCalculator):
|
||||
"get_segment_length": Function(
|
||||
"IfcLengthMeasure", "Segment Length", "Intelligently guesses the length of flow segments"
|
||||
),
|
||||
"get_opening_width": Function(
|
||||
"IfcLengthMeasure", "Opening Width", "The width of an opening, guessing the opening orientation"
|
||||
),
|
||||
"get_opening_height": Function(
|
||||
"IfcLengthMeasure", "Opening Height", "The height of an opening, guessing the opening orientation"
|
||||
),
|
||||
"get_opening_depth": Function(
|
||||
"IfcLengthMeasure",
|
||||
"Opening Depth",
|
||||
"The depth of an opening (through the voided element), guessing the opening orientation",
|
||||
),
|
||||
"get_footing_length": Function(
|
||||
"IfcLengthMeasure",
|
||||
"Footing Length",
|
||||
"The footing length. For beam-like footings (STRIP_FOOTING, FOOTING_BEAM) this is the "
|
||||
"extruded run along the local Z axis. For slab-like footings (PAD_FOOTING, PILE_CAP) and "
|
||||
"other predefined types it is the longer footprint side (the larger of local X or Y).",
|
||||
),
|
||||
"get_footing_width": Function(
|
||||
"IfcLengthMeasure",
|
||||
"Footing Width",
|
||||
"The footing width. For beam-like footings (STRIP_FOOTING, FOOTING_BEAM) this is the "
|
||||
"cross section width along the local X axis. For slab-like footings (PAD_FOOTING, PILE_CAP) "
|
||||
"and other predefined types it is the shorter footprint side (the smaller of local X or Y).",
|
||||
),
|
||||
"get_footing_height": Function(
|
||||
"IfcLengthMeasure",
|
||||
"Footing Height",
|
||||
"The footing height. For beam-like footings (STRIP_FOOTING, FOOTING_BEAM) this is the "
|
||||
"cross section height along the local Y axis. For slab-like footings (PAD_FOOTING, PILE_CAP) "
|
||||
"and other predefined types it is the thickness along the local Z axis.",
|
||||
),
|
||||
"get_covering_width": Function(
|
||||
"IfcLengthMeasure",
|
||||
"Covering Width",
|
||||
"The covering's thickness: the side area axis for AXIS2 (e.g. wall finishes), "
|
||||
"otherwise the local Z depth (e.g. floor or ceiling finishes)",
|
||||
),
|
||||
# IfcAreaMeasure
|
||||
"get_area": Function("IfcAreaMeasure", "Area", "The total surface area of the element"),
|
||||
"get_covering_area": Function(
|
||||
"IfcAreaMeasure",
|
||||
"Covering Area",
|
||||
"The covering's side area for AXIS2 (e.g. wall finishes), otherwise its footprint "
|
||||
"area (e.g. floor or ceiling finishes)",
|
||||
),
|
||||
"get_footprint_area": Function(
|
||||
"IfcAreaMeasure",
|
||||
"Footprint Area",
|
||||
@@ -276,6 +320,9 @@ class IfcOpenShell(QtoCalculator):
|
||||
"Side area",
|
||||
"The side (non-projected) are of the shape as seen from the local Y-axis",
|
||||
),
|
||||
"get_opening_area": Function(
|
||||
"IfcAreaMeasure", "Opening Area", "The area of an opening, guessing the opening orientation"
|
||||
),
|
||||
"get_top_area": Function(
|
||||
"IfcAreaMeasure",
|
||||
"Top area",
|
||||
@@ -298,10 +345,25 @@ class IfcOpenShell(QtoCalculator):
|
||||
functions[f"gross_{k}"] = Function(v.measure, f"Gross {v.name}", v.description)
|
||||
functions[f"net_{k}"] = Function(v.measure, f"Net {v.name}", v.description)
|
||||
|
||||
# Predefined-type-aware footing functions. They read the element's predefined type to
|
||||
# pick the correct local axis, so they receive the element (not just the geometry) and
|
||||
# cannot live in ifcopenshell.util.shape.
|
||||
footing_functions = (
|
||||
"get_footing_length",
|
||||
"get_footing_width",
|
||||
"get_footing_height",
|
||||
)
|
||||
|
||||
internal_functions = (
|
||||
"get_segment_length",
|
||||
"get_weight",
|
||||
)
|
||||
"get_opening_width",
|
||||
"get_opening_height",
|
||||
"get_opening_depth",
|
||||
"get_opening_area",
|
||||
"get_covering_width",
|
||||
"get_covering_area",
|
||||
) + footing_functions
|
||||
|
||||
@classmethod
|
||||
def calculate(cls, ifc_file, elements, qtos, results):
|
||||
@@ -365,6 +427,20 @@ class IfcOpenShell(QtoCalculator):
|
||||
value = cls.get_weight(element, geometry, calculation_type)
|
||||
if value is None:
|
||||
continue
|
||||
elif formula.startswith("get_opening_"):
|
||||
value = cls.get_opening_quantity(geometry, formula)
|
||||
value = cls.unit_converter.convert(value, IfcOpenShell.raw_functions[formula].measure)
|
||||
elif formula in cls.footing_functions:
|
||||
value = getattr(cls, formula)(element, geometry)
|
||||
if value is None:
|
||||
continue
|
||||
value = cls.unit_converter.convert(value, cls.raw_functions[formula].measure)
|
||||
elif formula == "get_covering_width":
|
||||
value = cls.get_covering_width(element, geometry)
|
||||
value = cls.unit_converter.convert(value, "IfcLengthMeasure")
|
||||
elif formula == "get_covering_area":
|
||||
value = cls.get_covering_area(element, geometry)
|
||||
value = cls.unit_converter.convert(value, "IfcAreaMeasure")
|
||||
else:
|
||||
value = formula_functions[formula](geometry)
|
||||
assert isinstance(value, (float, int))
|
||||
@@ -389,6 +465,34 @@ class IfcOpenShell(QtoCalculator):
|
||||
)
|
||||
return iterators
|
||||
|
||||
@classmethod
|
||||
def get_opening_quantity(cls, geometry: ifcopenshell.geom.ShapeType, formula: str) -> float:
|
||||
"""Get an opening dimension or area, guessing the opening orientation.
|
||||
|
||||
Vertical (wall) openings are measured in a Z-up local frame: X along
|
||||
the voided element, Y through it, Z vertical. An opening is treated as
|
||||
horizontal (e.g. voiding a slab) when its Z extent is smaller than
|
||||
both X and Y, matching the Blender calculator's heuristic.
|
||||
|
||||
:param geometry: Geometry output calculated by IfcOpenShell
|
||||
:param formula: One of the ``get_opening_*`` internal function names.
|
||||
:return: The dimension or area in SI units.
|
||||
"""
|
||||
x = ifcopenshell.util.shape.get_x(geometry)
|
||||
y = ifcopenshell.util.shape.get_y(geometry)
|
||||
z = ifcopenshell.util.shape.get_z(geometry)
|
||||
is_horizontal = z < x and z < y
|
||||
if formula == "get_opening_width":
|
||||
return x
|
||||
if formula == "get_opening_height":
|
||||
return min(x, y) if is_horizontal else z
|
||||
if formula == "get_opening_depth":
|
||||
return z if is_horizontal else y
|
||||
assert formula == "get_opening_area"
|
||||
if is_horizontal:
|
||||
return ifcopenshell.util.shape.get_footprint_area(geometry)
|
||||
return ifcopenshell.util.shape.get_side_area(geometry)
|
||||
|
||||
@classmethod
|
||||
def get_segment_length(cls, element: ifcopenshell.entity_instance) -> Union[float, None]:
|
||||
"""Get segment length.
|
||||
@@ -420,6 +524,36 @@ class IfcOpenShell(QtoCalculator):
|
||||
z = item.Depth
|
||||
return max([x, y, z])
|
||||
|
||||
# Footings are authored two ways, so a single static axis rule cannot be correct for both.
|
||||
# Beam-like footings (STRIP_FOOTING, FOOTING_BEAM) are a profile extruded along the local Z
|
||||
# axis, so the run (Length) is local Z and the cross section sits on local X (Width) and
|
||||
# local Y (Height). Slab-like footings (PAD_FOOTING, PILE_CAP) have their footprint on the
|
||||
# local X/Y plane and their thickness (Height) on local Z. These functions branch on the
|
||||
# predefined type to pick the right axis. This mirrors the Blender calculator's
|
||||
# get_footing_length / get_footing_width / get_footing_height.
|
||||
_beam_like_footings = ("STRIP_FOOTING", "FOOTING_BEAM")
|
||||
|
||||
@classmethod
|
||||
def get_footing_length(cls, element: ifcopenshell.entity_instance, geometry: ifcopenshell.geom.ShapeType) -> float:
|
||||
predefined_type = ifcopenshell.util.element.get_predefined_type(element)
|
||||
if predefined_type in cls._beam_like_footings:
|
||||
return ifcopenshell.util.shape.get_z(geometry)
|
||||
return max(ifcopenshell.util.shape.get_x(geometry), ifcopenshell.util.shape.get_y(geometry))
|
||||
|
||||
@classmethod
|
||||
def get_footing_width(cls, element: ifcopenshell.entity_instance, geometry: ifcopenshell.geom.ShapeType) -> float:
|
||||
predefined_type = ifcopenshell.util.element.get_predefined_type(element)
|
||||
if predefined_type in cls._beam_like_footings:
|
||||
return ifcopenshell.util.shape.get_x(geometry)
|
||||
return min(ifcopenshell.util.shape.get_x(geometry), ifcopenshell.util.shape.get_y(geometry))
|
||||
|
||||
@classmethod
|
||||
def get_footing_height(cls, element: ifcopenshell.entity_instance, geometry: ifcopenshell.geom.ShapeType) -> float:
|
||||
predefined_type = ifcopenshell.util.element.get_predefined_type(element)
|
||||
if predefined_type in cls._beam_like_footings:
|
||||
return ifcopenshell.util.shape.get_y(geometry)
|
||||
return ifcopenshell.util.shape.get_z(geometry)
|
||||
|
||||
@classmethod
|
||||
def get_weight(
|
||||
cls,
|
||||
@@ -472,6 +606,53 @@ class IfcOpenShell(QtoCalculator):
|
||||
mass += mass_per_length * item.Depth
|
||||
return mass
|
||||
|
||||
@staticmethod
|
||||
def get_covering_parametric_axis(element: ifcopenshell.entity_instance) -> Union[str, None]:
|
||||
"""Get an IfcCovering's layer set direction, as authored by Bonsai's covering type.
|
||||
|
||||
:param element: IFC element entity.
|
||||
:return: ``"AXIS2"`` for wall-like coverings, ``"AXIS3"`` for slab-like
|
||||
coverings (e.g. floors or ceilings), or ``None`` if the covering's
|
||||
type has no ``EPset_Parametric.LayerSetDirection``.
|
||||
"""
|
||||
relating_type = ifcopenshell.util.element.get_type(element)
|
||||
if not relating_type:
|
||||
return None
|
||||
parametric = ifcopenshell.util.element.get_psets(relating_type).get("EPset_Parametric")
|
||||
if not parametric:
|
||||
return None
|
||||
return parametric.get("LayerSetDirection")
|
||||
|
||||
@classmethod
|
||||
def get_covering_area(cls, element: ifcopenshell.entity_instance, geometry: ifcopenshell.geom.ShapeType) -> float:
|
||||
"""Get a covering's area, following its layer set direction.
|
||||
|
||||
AXIS2 (wall-like) coverings report the local Y-facing side area,
|
||||
while AXIS3 coverings and coverings without a layer set direction
|
||||
(e.g. freeform profiles) report the projected footprint area. This
|
||||
mirrors how ``gross_get_side_area``/``net_get_side_area`` are
|
||||
already used for ``Qto_WallBaseQuantities.*SideArea`` in this same
|
||||
rule set.
|
||||
"""
|
||||
if cls.get_covering_parametric_axis(element) == "AXIS2":
|
||||
return ifcopenshell.util.shape.get_side_area(geometry)
|
||||
return ifcopenshell.util.shape.get_footprint_area(geometry)
|
||||
|
||||
@classmethod
|
||||
def get_covering_width(cls, element: ifcopenshell.entity_instance, geometry: ifcopenshell.geom.ShapeType) -> float:
|
||||
"""Get a covering's width (i.e. thickness), following its layer set direction.
|
||||
|
||||
AXIS2 (wall-like) coverings report the local Y depth, while AXIS3
|
||||
coverings and coverings without a layer set direction report the
|
||||
local Z depth. This mirrors how ``net_get_y`` is already used for
|
||||
``Qto_WallBaseQuantities.Width`` in this same rule set, rather than
|
||||
the ``min(X, Y)`` heuristic used by the Blender-side
|
||||
:func:`bonsai.bim.module.qto.calculator.get_width`.
|
||||
"""
|
||||
if cls.get_covering_parametric_axis(element) == "AXIS2":
|
||||
return ifcopenshell.util.shape.get_y(geometry)
|
||||
return ifcopenshell.util.shape.get_z(geometry)
|
||||
|
||||
|
||||
class Blender(QtoCalculator):
|
||||
"""Calculates geometry based on currently loaded Blender objects."""
|
||||
@@ -495,6 +676,7 @@ class Blender(QtoCalculator):
|
||||
"get_width": Function("IfcLengthMeasure", "Width", ""),
|
||||
"get_footing_height": Function("IfcLengthMeasure", "Height", ""),
|
||||
"get_footing_length": Function("IfcLengthMeasure", "Length", ""),
|
||||
"get_footing_width": Function("IfcLengthMeasure", "Width", ""),
|
||||
# IfcAreaMeasure
|
||||
"get_covering_gross_area": Function("IfcAreaMeasure", "Covering Gross Area", ""),
|
||||
"get_covering_net_area": Function("IfcAreaMeasure", "Covering Net Area", ""),
|
||||
|
||||
@@ -20,10 +20,14 @@ dependencies = [
|
||||
"typing_extensions",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
advanced = [
|
||||
"typst",
|
||||
]
|
||||
[project.optional-dependencies]
|
||||
advanced = [
|
||||
"typst",
|
||||
]
|
||||
spreadsheet = [
|
||||
"odfpy",
|
||||
"openpyxl",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "http://ifcopenshell.org"
|
||||
|
||||
@@ -120,6 +120,34 @@ class TestCsv2Ifc:
|
||||
assert len(list(Path(temp_csv_dir).glob("*.ods"))) == 1
|
||||
assert len(list(Path(temp_csv_dir).glob("*.xlsx"))) == 1
|
||||
|
||||
def test_xlsx_columns_match_cost_panel(self):
|
||||
"""ODS/XLSX are presentation formats: they must show exactly what the
|
||||
Bonsai cost panel shows (ID, Name, Quantity, Value, Total Cost), no
|
||||
internal bookkeeping columns, no Description/Unit, no per-category
|
||||
cost breakdown. See #6251."""
|
||||
import openpyxl
|
||||
|
||||
ifc_file = self.setup_ifc_file()
|
||||
csv_filepath = Path(__file__).parent.parent / "sample_cost_schedule_house_FR.csv"
|
||||
ifc5d.csv2ifc.Csv2Ifc(str(csv_filepath), ifc_file).execute()
|
||||
|
||||
with tempfile.TemporaryDirectory("w") as temp_dir:
|
||||
writer = ifc5d.ifc5Dspreadsheet.Ifc5DXlsxWriter(ifc_file, temp_dir)
|
||||
writer.write()
|
||||
workbook = openpyxl.load_workbook(next(Path(temp_dir).glob("*.xlsx")))
|
||||
worksheet = workbook.active
|
||||
|
||||
headers = [cell.value for cell in next(worksheet.iter_rows())]
|
||||
assert headers == ["ID", "Name", "Quantity", "Value", "Total Cost"]
|
||||
|
||||
# A leaf item (has quantity and value) gets Quantity * Value.
|
||||
leaf_row = next(row for row in worksheet.iter_rows(min_row=2) if row[0].value == "DB.1.1")
|
||||
assert leaf_row[4].value == "=C{}*D{}".format(leaf_row[0].row, leaf_row[0].row)
|
||||
|
||||
# A parent/sum item gets the sum of its direct children's Total Cost.
|
||||
parent_row = next(row for row in worksheet.iter_rows(min_row=2) if row[0].value == "DB.1")
|
||||
assert parent_row[4].value.startswith("=SUM(")
|
||||
|
||||
|
||||
class TestSerialiseCostQuantities:
|
||||
def test_quantity_name_with_special_characters_round_trips_as_json(self):
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user