mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-21 04:32:23 +00:00
Compare commits
344 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dd902bf0f8 | |||
| 371aabfef6 | |||
| 1c22fa0669 | |||
| a91b1da28b | |||
| 89cb551dbb | |||
| 102ac551b3 | |||
| 137a890256 | |||
| de7520418b | |||
| e8a93846dd | |||
| 96941463c0 | |||
| 19bff92a47 | |||
| 66f3593de1 | |||
| bb17cfbc40 | |||
| 4913b84af7 | |||
| 8734b419e5 | |||
| 49f6f64e5c | |||
| dc82171013 | |||
| 084c87ea06 | |||
| 59e5b2b1b8 | |||
| fcae3d90af | |||
| 486b858d56 | |||
| 95c62cc70e | |||
| 6ca38f8bf6 | |||
| e0a504417c | |||
| 8b8fafa698 | |||
| 5f467cd8fa | |||
| 8242efac97 | |||
| 22f25f0098 | |||
| fed739847e | |||
| 56273de443 | |||
| 6901533368 | |||
| 4a91e6a87d | |||
| 03b8b9c1bd | |||
| 8f937052b8 | |||
| b1bad23a64 | |||
| d18c05d4bd | |||
| cee2dbcc85 | |||
| a755e96c13 | |||
| 9c1a10190d | |||
| a4f7db45cd | |||
| 6df9aeda2c | |||
| 1c598a981e | |||
| 742561c56d | |||
| 05a15e84a6 | |||
| a900f2c437 | |||
| 0a297babea | |||
| bba2f30816 | |||
| d0c8c84e7b | |||
| 7f153375b2 | |||
| d25c2583da | |||
| 027e2406be | |||
| e864a961ee | |||
| d8f37b2376 | |||
| acfcf6e14e | |||
| 5bc7e4b98b | |||
| c8d39cc481 | |||
| 1cf9373000 | |||
| d8c9d1a47b | |||
| 0993c1b0f1 | |||
| 22384e136f | |||
| 6dea7a5110 | |||
| 424e70ac86 | |||
| fceb8911f6 | |||
| 94f4dbcf31 | |||
| 391f8363bb | |||
| 210861eda1 | |||
| a2012ace2e | |||
| 05e328339f | |||
| cd612dc988 | |||
| 44ba6e8963 | |||
| c3a10694ba | |||
| da5ebf172e | |||
| c1173dfc78 | |||
| fde502daa1 | |||
| 2eb2d65710 | |||
| 0d8e9f0edc | |||
| 3c773d71d3 | |||
| 43f5a79f99 | |||
| 12935c83de | |||
| e7249fe934 | |||
| cf257aa20a | |||
| 7d0b6f6fd8 | |||
| 554c7174e3 | |||
| bd436765bf | |||
| 5270318570 | |||
| 8fcaa18171 | |||
| 8d0a6ecc16 | |||
| bfea57c617 | |||
| 18b79a4360 | |||
| 05cd19592d | |||
| a1efdccb4b | |||
| 6ad5fbb27e | |||
| 884e7ba326 | |||
| 82f188fbe6 | |||
| 31ddc0ecdb | |||
| 02198e3f56 | |||
| b1899b1a8d | |||
| 39d583a26a | |||
| 609b6959bc | |||
| 2c7d25cffc | |||
| e893552f24 | |||
| bae1eddda9 | |||
| ad62dd6d91 | |||
| d565dc3ff3 | |||
| a340e6cf9a | |||
| 359693c562 | |||
| 016c278748 | |||
| f2b655fcf6 | |||
| 0bb6df6a6b | |||
| 2257d7930a | |||
| 98ff457fd6 | |||
| f07afda09c | |||
| 4670715ef3 | |||
| 5b2721c1c4 | |||
| 0d5fa0c20c | |||
| 802ddf8ff9 | |||
| ea4747ccb1 | |||
| e20cea221b | |||
| ae66550cae | |||
| 5ccb655e10 | |||
| 278c1e5068 | |||
| 095e4a1677 | |||
| de5eb9641f | |||
| 4597be7fda | |||
| ca2e866c45 | |||
| e84767c22b | |||
| f7add7f412 | |||
| 8ffdb8f0b9 | |||
| e0e143bab5 | |||
| 8f3eaa35d7 | |||
| 81b1efdfd6 | |||
| 4a9af13626 | |||
| e9d577890e | |||
| 7f29850022 | |||
| 92c3b4c308 | |||
| 600d3a3460 | |||
| bea4e38e65 | |||
| 5386c9ec69 | |||
| ecf0a5a4e1 | |||
| 540f3acf52 | |||
| b3d29c4081 | |||
| 0d3849737c | |||
| a2db0a68a4 | |||
| 3e2869b6aa | |||
| db1a2705a3 | |||
| e7787b6aad | |||
| 6f0327d4b5 | |||
| 6341d7dd31 | |||
| 51971dd493 | |||
| 309e20009c | |||
| a06e5ecdf5 | |||
| 1ca2e12f92 | |||
| b9e5739088 | |||
| 30cdffc27a | |||
| 3607fbb762 | |||
| b73cdd1a0e | |||
| e21bd1ac96 | |||
| 633c613da2 | |||
| 8282f691e8 | |||
| 7bc64bef1a | |||
| 81a7c5b50e | |||
| 3b3ed47e89 | |||
| f54c917ded | |||
| 5c6444d5ea | |||
| 6282634a70 | |||
| 40267aa068 | |||
| 1efedfd3cc | |||
| ddfe3bce20 | |||
| 8f7c8dc1d2 | |||
| eea2398e07 | |||
| 35be7f4190 | |||
| 4f929e90a7 | |||
| f898089b27 | |||
| 4e4553201b | |||
| 29f5132510 | |||
| ae938d425b | |||
| 5161b0a3f8 | |||
| 3015f758ba | |||
| dbe68b48f2 | |||
| 094d96c735 | |||
| ed6e8d831e | |||
| 930678e3d2 | |||
| 4e3cc63de1 | |||
| 7b64dd338b | |||
| 71612e0780 | |||
| 9aae8f0329 | |||
| 175efcfffe | |||
| 643a2e1c1f | |||
| 3c5c8e44cb | |||
| fc89ffeb19 | |||
| 4bedb40d8a | |||
| a34c36d22e | |||
| e5ed7b53d4 | |||
| 069ef20c46 | |||
| 0b122ae1f5 | |||
| a0cc4b874b | |||
| 2f88778c9f | |||
| 0a752e09eb | |||
| 03662d2016 | |||
| 99f409280a | |||
| 0c9d3ea6d7 | |||
| 37fa4e9076 | |||
| 1ec273f508 | |||
| 574bcfa6c5 | |||
| 09ffdd2028 | |||
| d0c5bd5e85 | |||
| c03a7fe117 | |||
| 8596d53a4a | |||
| c78e16eafb | |||
| 68fea7bd45 | |||
| 90366f8236 | |||
| d3c21d7a81 | |||
| cd77c557e9 | |||
| 3110c98429 | |||
| 0e2a62d3b7 | |||
| f532624dc1 | |||
| f0e3056d0a | |||
| 298eca0ab6 | |||
| 1f17d73f3e | |||
| ababb49ae7 | |||
| 07a5c59359 | |||
| 8c8ef5c32b | |||
| 36fa53122a | |||
| 1dace18d26 | |||
| 5b4c1089cf | |||
| 83a3131276 | |||
| 6f6bebf387 | |||
| bfac12dbe7 | |||
| d33055bb72 | |||
| d08a4e0706 | |||
| 06eca938d7 | |||
| 543d9f8588 | |||
| b40e4378b3 | |||
| 1089de14f0 | |||
| 1b35533917 | |||
| c42a7f32d0 | |||
| 14e9846e35 | |||
| 37c6aea092 | |||
| 9b13dc8dd6 | |||
| 89c66f62bf | |||
| 18363c0d19 | |||
| 782aa4f88f | |||
| 13c71d7a8a | |||
| cb7e7331e6 | |||
| 4850e2a07c | |||
| fd980abcc2 | |||
| b022ca7e70 | |||
| 325db2e57f | |||
| 046ceb452a | |||
| 9e19735275 | |||
| 6ee05b646b | |||
| e62921171c | |||
| 6c47123781 | |||
| d448fa95e3 | |||
| 57de09d236 | |||
| a25f522cc3 | |||
| e067c2b834 | |||
| e2905d6f0e | |||
| ead061a98a | |||
| fae85e63cc | |||
| bccea6d932 | |||
| 1cc93784cd | |||
| f1e93581ec | |||
| 91ae631c7d | |||
| b599ee1040 | |||
| d2cc66fdf0 | |||
| 3824e7b449 | |||
| aa10784154 | |||
| 2018bcb3c1 | |||
| 11006f0ef5 | |||
| a44f72287b | |||
| c6849073d9 | |||
| b2fc0c00cc | |||
| 2d5883f966 | |||
| 4621fc9269 | |||
| 1840e3d1a8 | |||
| becd38c77d | |||
| 7d6c6bd523 | |||
| a425bb6da0 | |||
| a19d398c78 | |||
| f616c4049c | |||
| 20ccf2b455 | |||
| a07f56db6f | |||
| 724cdb446e | |||
| 95b3f7dac4 | |||
| 603cedc487 | |||
| fe6e9d86ae | |||
| 8e42f35db3 | |||
| 9d69a712ac | |||
| dc127471c5 | |||
| 95a094d596 | |||
| 4ec643d4e1 | |||
| c089c11cd6 | |||
| 8afba9a665 | |||
| 5c4046e054 | |||
| d82e1da907 | |||
| 8c2e1226c9 | |||
| ed8821486d | |||
| 742bfac144 | |||
| 1f4c4204d0 | |||
| 43f78605f2 | |||
| 6cd1375cf9 | |||
| 2d1250c18f | |||
| bee61cba11 | |||
| 33f072e0a7 | |||
| 490fa92dc0 | |||
| cf3393b6a0 | |||
| fbbc92c5d7 | |||
| 574827016a | |||
| 18c9140700 | |||
| 9e165319e6 | |||
| 64bf807baa | |||
| 136befde86 | |||
| 1a4d750ecd | |||
| 0a5dd78774 | |||
| 94427ebcb3 | |||
| 9147938c36 | |||
| ffcc02fb99 | |||
| e4e4f31d20 | |||
| 2dd002bcd8 | |||
| 7e5248da29 | |||
| 07a01bcf54 | |||
| 0a9e29ce45 | |||
| 0604db06e9 | |||
| 991556fbe3 | |||
| b66b04b001 | |||
| 5c9213426f | |||
| 69a4ad35a1 | |||
| 7f4d9e31c3 | |||
| ae79996eb6 | |||
| f5b2358c2e | |||
| f9c791de1b | |||
| 5b85a2c38b | |||
| bc46a8f85b | |||
| c57209fbce | |||
| 088a6b7204 | |||
| f2f4d626a5 | |||
| 572a5655cf | |||
| ccd91c0ff0 | |||
| a1d2c902f3 | |||
| 3ae361bb3b | |||
| 2d7521ed88 | |||
| 7098beb819 | |||
| f09ca658f1 |
@@ -0,0 +1,101 @@
|
||||
name: Build Bonsai Viewer Autodesk Connector
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
paths:
|
||||
- 'src/bonsaiviewer-autodesk/**'
|
||||
- '.github/workflows/build-bonsaiviewer-autodesk.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'src/bonsaiviewer-autodesk/**'
|
||||
- '.github/workflows/build-bonsaiviewer-autodesk.yml'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: test-py${{ matrix.python-version }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Exercise the floor and a current version of the supported range
|
||||
# (pyproject requires-python = ">=3.11").
|
||||
python-version: ['3.11', '3.13']
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/bonsaiviewer-autodesk
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
# The connector imports tkinter/customtkinter (via the test suite's
|
||||
# connector coverage), so fail loudly here if Tk is missing.
|
||||
- name: Verify tkinter is available
|
||||
run: python -c "import tkinter; print('Tk', tkinter.TkVersion)"
|
||||
|
||||
- name: Install package and test deps
|
||||
run: python -m pip install ".[test]"
|
||||
|
||||
- name: Run pytest
|
||||
run: python -m pytest -q
|
||||
|
||||
build:
|
||||
name: ${{ matrix.os_label }}-${{ matrix.arch }}
|
||||
needs: test
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# Linux: build on the oldest reasonable glibc (Ubuntu 22.04 / glibc
|
||||
# 2.35) so the bundle runs on older distributions.
|
||||
- os_label: linux
|
||||
arch: x86_64
|
||||
runner: ubuntu-22.04
|
||||
# macOS Apple Silicon.
|
||||
- os_label: macos
|
||||
arch: arm64
|
||||
runner: macos-14
|
||||
# macOS Intel. macos-13 is the last Intel runner GitHub provides.
|
||||
- os_label: macos
|
||||
arch: x86_64
|
||||
runner: macos-13
|
||||
# Windows x86_64.
|
||||
- os_label: windows
|
||||
arch: x86_64
|
||||
runner: windows-latest
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/bonsaiviewer-autodesk
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
# actions/setup-python ships Python with tkinter on all three OSes via
|
||||
# python-build-standalone, but verify so a missing-tk regression fails
|
||||
# the build loudly instead of inside PyInstaller.
|
||||
- name: Verify tkinter is available
|
||||
run: python -c "import tkinter; print('Tk', tkinter.TkVersion)"
|
||||
|
||||
- name: Install package and build deps
|
||||
run: python -m pip install ".[build]"
|
||||
|
||||
- name: Build connector bundle
|
||||
run: python packaging/build.py
|
||||
|
||||
- name: Upload connector zip
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: autodesk-${{ matrix.os_label }}-${{ matrix.arch }}
|
||||
path: src/bonsaiviewer-autodesk/dist/autodesk-${{ matrix.os_label }}-${{ matrix.arch }}.zip
|
||||
if-no-files-found: error
|
||||
@@ -110,7 +110,20 @@ jobs:
|
||||
run: |
|
||||
VERSION=v`cat VERSION`
|
||||
cd ./build/`uname`/*/10.15/install/ifcopenshell
|
||||
mkdir ~/output
|
||||
mkdir -p ~/output
|
||||
install_root="$PWD"
|
||||
|
||||
stage_runtime_payload() {
|
||||
dest="$1"
|
||||
while IFS= read -r runtime_file; do
|
||||
cp -L "$runtime_file" "$dest/"
|
||||
done < <(
|
||||
for runtime_dir in "$install_root/bin" "$install_root/lib" "$install_root/lib64"; do
|
||||
[ -d "$runtime_dir" ] || continue
|
||||
find "$runtime_dir" -type f \( -name "*.so" -o -name "*.so.*" -o -name "*.dylib" -o -name "*.dll" \)
|
||||
done
|
||||
)
|
||||
}
|
||||
|
||||
ls -d python-* | while read py_version; do
|
||||
postfix=`echo ${py_version: -1} | sed s/[0-9]//`
|
||||
@@ -125,18 +138,25 @@ jobs:
|
||||
fi
|
||||
[ -d ifcopenshell/__pycache__ ] && rm -rf ifcopenshell/__pycache__
|
||||
find ifcopenshell -name "*.pyc" -delete
|
||||
zip -r -qq ifcopenshell-${py_version_major}-${VERSION}-${GITHUB_SHA:0:7}-macos${{ matrix.oldarch }}64.zip ifcopenshell/*
|
||||
stage_runtime_payload ifcopenshell
|
||||
zip -r -qq ifcopenshell-${py_version_major}-${VERSION}-${GITHUB_SHA:0:7}-macos${{ matrix.oldarch }}64.zip ifcopenshell
|
||||
mv *.zip ~/output
|
||||
popd > /dev/null
|
||||
done
|
||||
|
||||
cd bin
|
||||
rm *.zip || true
|
||||
ls | while read exe; do
|
||||
zip -qq -r ${exe}-${VERSION}-${GITHUB_SHA:0:7}-macos${{ matrix.oldarch }}64.zip $exe
|
||||
rm -f "$install_root"/bin/*.zip
|
||||
find "$install_root/bin" -maxdepth 1 -type f -perm /111 ! -name "*.zip" ! -name "*.so" ! -name "*.so.*" ! -name "*.dylib" ! -name "*.dll" | while read exe_path; do
|
||||
exe=`basename "$exe_path"`
|
||||
package_dir="$install_root/.package-${exe}"
|
||||
rm -rf "$package_dir"
|
||||
mkdir -p "$package_dir"
|
||||
cp "$exe_path" "$package_dir/"
|
||||
stage_runtime_payload "$package_dir"
|
||||
pushd "$package_dir" > /dev/null
|
||||
zip -qq -r "$HOME/output/${exe}-${VERSION}-${GITHUB_SHA:0:7}-macos${{ matrix.oldarch }}64.zip" .
|
||||
popd > /dev/null
|
||||
rm -rf "$package_dir"
|
||||
done
|
||||
mv *.zip ~/output
|
||||
cd ..
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@v6
|
||||
|
||||
@@ -40,6 +40,18 @@ jobs:
|
||||
NEW_FILE=`echo $FILE | sed "s/-/+${GITHUB_SHA:0:7}-/2"`
|
||||
mv $FILE $NEW_FILE
|
||||
|
||||
- name: Order wheel shared objects
|
||||
run: |
|
||||
python ./IfcOpenShell/pyodide/order_pyodide_wheel_shared_objects.py dist/ifcopenshell-*.whl
|
||||
|
||||
- name: Split packages
|
||||
run: |
|
||||
VERSION=v`cat ./IfcOpenShell/VERSION`
|
||||
mkdir -p dist-modular
|
||||
python ./IfcOpenShell/pyodide/split_pyodide_ifcopenshell_wheel.py dist/ifcopenshell-*.whl ./dist-modular
|
||||
cd dist-modular
|
||||
zip -r -qq ifcopenshell-modular-${VERSION}-${GITHUB_SHA:0:7}-pyodide.zip *.whl
|
||||
|
||||
- name: Upload Build Logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
@@ -86,3 +98,4 @@ jobs:
|
||||
- name: Upload .zip archives to S3
|
||||
run: |
|
||||
aws s3 cp dist s3://ifcopenshell-builds/ --recursive --exclude "*" --include "*.whl"
|
||||
aws s3 cp dist-modular s3://ifcopenshell-builds/ --recursive --exclude "*" --include "*.zip"
|
||||
|
||||
@@ -12,13 +12,16 @@ jobs:
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
dnf update -y
|
||||
dnf install -y epel-release
|
||||
dnf install -y gcc gcc-c++ git autoconf automake bison make zip cmake python3 python3-pip \
|
||||
bzip2 patch mesa-libGL-devel libffi-devel fontconfig-devel \
|
||||
sqlite-devel bzip2-devel zlib-devel openssl-devel xz-devel \
|
||||
readline-devel ncurses-devel libffi-devel libuuid-devel git-lfs \
|
||||
findutils xz byacc
|
||||
python3 -m pip install typing_extensions
|
||||
findutils xz byacc patchelf libxkbcommon-devel \
|
||||
python3.11 python3.11-pip python3.11-tkinter
|
||||
python3 -m pip install typing_extensions aqtinstall
|
||||
git config --global --add safe.directory '*'
|
||||
python3.11 -c "import tkinter; print('Tk', tkinter.TkVersion)"
|
||||
|
||||
- name: Install aws cli
|
||||
run: |
|
||||
@@ -56,7 +59,7 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
CXXFLAGS="-O3" CFLAGS="-O3 ${DARWIN_C_SOURCE}" ADD_COMMIT_SHA=1 BUILD_CFG=Release python3 ./nix/build-all.py -v --diskcleanup 2>&1 | tee build.log
|
||||
CXXFLAGS="-O3" CFLAGS="-O3 ${DARWIN_C_SOURCE}" ADD_COMMIT_SHA=1 BUILD_CFG=Release BUILD_BONSAIVIEWER=ON python3 ./nix/build-all.py -v --diskcleanup --shared 2>&1 | tee build.log
|
||||
|
||||
- name: Upload Build Logs
|
||||
if: always()
|
||||
@@ -83,10 +86,107 @@ jobs:
|
||||
git push || true
|
||||
|
||||
- name: Package .zip archives
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION=v`cat VERSION`
|
||||
python3.11 -m pip install "src/bonsaiviewer-autodesk[build]"
|
||||
python3.11 src/bonsaiviewer-autodesk/packaging/build.py
|
||||
autodesk_connector_dir="$PWD/src/bonsaiviewer-autodesk/dist/autodesk"
|
||||
test -d "$autodesk_connector_dir"
|
||||
|
||||
cd ./build/`uname`/*/install/ifcopenshell
|
||||
mkdir ~/output
|
||||
mkdir -p ~/output
|
||||
install_root="$PWD"
|
||||
QT6_VERSION="${QT6_VERSION:-6.8.3}"
|
||||
|
||||
if [ -z "${QT_DIR:-}" ]; then
|
||||
for qt_candidate in "$(dirname "$install_root")"/qt6-${QT6_VERSION}-*/${QT6_VERSION}/*; do
|
||||
if [ -d "$qt_candidate/lib" ]; then
|
||||
QT_DIR="$qt_candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
ensure_soname_links() {
|
||||
dest="$1"
|
||||
find "$dest" -maxdepth 1 -type f -name "*.so*" | while IFS= read -r shared_object; do
|
||||
soname=$(readelf -d "$shared_object" 2>/dev/null | sed -n 's/.*(SONAME).*Shared library: \[\(.*\)\].*/\1/p' | head -n 1)
|
||||
[ -n "$soname" ] || continue
|
||||
[ -e "$dest/$soname" ] && continue
|
||||
ln -s "$(basename "$shared_object")" "$dest/$soname"
|
||||
done
|
||||
}
|
||||
|
||||
stage_runtime_payload() {
|
||||
dest="$1"
|
||||
include_geometry_writers="${2:-1}"
|
||||
while IFS= read -r runtime_file; do
|
||||
if [ "$include_geometry_writers" != "1" ] && [[ "$(basename "$runtime_file")" == ifcopenshell.geometry.writer.* ]]; then
|
||||
continue
|
||||
fi
|
||||
cp -P "$runtime_file" "$dest/"
|
||||
done < <(
|
||||
for runtime_dir in "$install_root/bin" "$install_root/lib" "$install_root/lib64"; do
|
||||
[ -d "$runtime_dir" ] || continue
|
||||
find "$runtime_dir" \( -type f -o -type l \) \( -name "*.so" -o -name "*.so.*" -o -name "*.dylib" -o -name "*.dll" \)
|
||||
done
|
||||
)
|
||||
ensure_soname_links "$dest"
|
||||
}
|
||||
|
||||
stage_qt_runtime_payload() {
|
||||
exe_path="$1"
|
||||
dest="$2"
|
||||
[ -n "${QT_DIR:-}" ] && [ -d "$QT_DIR/lib" ] || return 0
|
||||
|
||||
if ! LD_LIBRARY_PATH="$QT_DIR/lib:${LD_LIBRARY_PATH:-}" ldd "$exe_path" 2>/dev/null | grep -q "libQt6"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
find "$QT_DIR/lib" -maxdepth 1 \( -type f -o -type l \) -name "*.so*" -exec cp -P {} "$dest/" \;
|
||||
ensure_soname_links "$dest"
|
||||
|
||||
if [ -d "$QT_DIR/plugins" ]; then
|
||||
pushd "$QT_DIR/plugins" > /dev/null
|
||||
find . \( -type f -o -type l \) -name "*.so*" | while IFS= read -r plugin_file; do
|
||||
mkdir -p "$dest/plugins/$(dirname "$plugin_file")"
|
||||
cp -P "$plugin_file" "$dest/plugins/$plugin_file"
|
||||
done
|
||||
popd > /dev/null
|
||||
if [ -d "$dest/plugins" ]; then
|
||||
find "$dest/plugins" -type f -name "*.so*" -exec patchelf --set-rpath '$ORIGIN/../..:$ORIGIN' {} \;
|
||||
fi
|
||||
fi
|
||||
|
||||
find "$dest" -maxdepth 1 -type f -name "*.so*" -exec patchelf --set-rpath '$ORIGIN' {} \;
|
||||
|
||||
printf "[Paths]\nPrefix = .\n" > "$dest/qt.conf"
|
||||
}
|
||||
|
||||
check_runtime_dependencies() {
|
||||
package_dir="$1"
|
||||
missing=0
|
||||
while IFS= read -r binary_file; do
|
||||
readelf -h "$binary_file" >/dev/null 2>&1 || continue
|
||||
if ! env -u LD_LIBRARY_PATH ldd "$binary_file" > "$package_dir/.ldd.out" 2>&1; then
|
||||
echo "ldd failed for $binary_file"
|
||||
cat "$package_dir/.ldd.out"
|
||||
missing=1
|
||||
continue
|
||||
fi
|
||||
if grep -q "not found" "$package_dir/.ldd.out"; then
|
||||
echo "Missing runtime dependencies for $binary_file"
|
||||
grep "not found" "$package_dir/.ldd.out"
|
||||
missing=1
|
||||
fi
|
||||
done < <(find "$package_dir" -type f \( -perm /111 -o -name "*.so" -o -name "*.so.*" \))
|
||||
rm -f "$package_dir/.ldd.out"
|
||||
if [ "$missing" -ne 0 ]; then
|
||||
echo "Runtime dependency check found issues; continuing packaging."
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
ls -d python-* | while read py_version; do
|
||||
postfix=`echo ${py_version: -1} | sed s/[0-9]//`
|
||||
@@ -101,18 +201,32 @@ jobs:
|
||||
fi
|
||||
[ -d ifcopenshell/__pycache__ ] && rm -rf ifcopenshell/__pycache__
|
||||
find ifcopenshell -name "*.pyc" -delete
|
||||
zip -r -qq ifcopenshell-${py_version_major}-${VERSION}-${GITHUB_SHA:0:7}-linux64.zip ifcopenshell/*
|
||||
stage_runtime_payload ifcopenshell
|
||||
zip -y -r -qq ifcopenshell-${py_version_major}-${VERSION}-${GITHUB_SHA:0:7}-linux64.zip ifcopenshell
|
||||
mv *.zip ~/output
|
||||
popd > /dev/null
|
||||
done
|
||||
|
||||
cd bin
|
||||
rm *.zip || true
|
||||
ls | while read exe; do
|
||||
zip -qq -r ${exe}-${VERSION}-${GITHUB_SHA:0:7}-linux64.zip $exe
|
||||
rm -f "$install_root"/bin/*.zip
|
||||
find "$install_root/bin" -maxdepth 1 -type f -perm /111 ! -name "*.zip" ! -name "*.so" ! -name "*.so.*" ! -name "*.dylib" ! -name "*.dll" | while read exe_path; do
|
||||
exe=`basename "$exe_path"`
|
||||
package_dir="$install_root/.package-${exe}"
|
||||
rm -rf "$package_dir"
|
||||
mkdir -p "$package_dir"
|
||||
cp "$exe_path" "$package_dir/"
|
||||
patchelf --set-rpath '$ORIGIN' "$package_dir/$exe"
|
||||
stage_runtime_payload "$package_dir" 0
|
||||
stage_qt_runtime_payload "$exe_path" "$package_dir"
|
||||
if [ "$exe" = "BonsaiViewer" ]; then
|
||||
mkdir -p "$package_dir/connectors"
|
||||
cp -a "$autodesk_connector_dir" "$package_dir/connectors/"
|
||||
fi
|
||||
check_runtime_dependencies "$package_dir"
|
||||
pushd "$package_dir" > /dev/null
|
||||
zip -y -qq -r "$HOME/output/${exe}-${VERSION}-${GITHUB_SHA:0:7}-linux64.zip" .
|
||||
popd > /dev/null
|
||||
rm -rf "$package_dir"
|
||||
done
|
||||
mv *.zip ~/output
|
||||
cd ..
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@v6
|
||||
|
||||
@@ -12,13 +12,16 @@ jobs:
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
dnf update -y
|
||||
dnf install -y epel-release
|
||||
dnf install -y gcc gcc-c++ git autoconf automake bison make zip cmake python3 python3-pip \
|
||||
bzip2 patch mesa-libGL-devel libffi-devel fontconfig-devel \
|
||||
sqlite-devel bzip2-devel zlib-devel openssl-devel xz-devel \
|
||||
readline-devel ncurses-devel libffi-devel libuuid-devel git-lfs \
|
||||
findutils xz byacc
|
||||
python3 -m pip install typing_extensions
|
||||
findutils xz byacc patchelf libxkbcommon-devel \
|
||||
python3.11 python3.11-pip python3.11-tkinter
|
||||
python3 -m pip install typing_extensions aqtinstall
|
||||
git config --global --add safe.directory '*'
|
||||
python3.11 -c "import tkinter; print('Tk', tkinter.TkVersion)"
|
||||
|
||||
- name: Install aws cli
|
||||
run: |
|
||||
@@ -56,7 +59,7 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
CXXFLAGS="-O3" CFLAGS="-O3 ${DARWIN_C_SOURCE}" ADD_COMMIT_SHA=1 BUILD_CFG=Release python3 ./nix/build-all.py -v --diskcleanup 2>&1 | tee build.log
|
||||
CXXFLAGS="-O3" CFLAGS="-O3 ${DARWIN_C_SOURCE}" ADD_COMMIT_SHA=1 BUILD_CFG=Release BUILD_BONSAIVIEWER=ON python3 ./nix/build-all.py -v --diskcleanup --shared 2>&1 | tee build.log
|
||||
|
||||
- name: Upload Build Logs
|
||||
if: always()
|
||||
@@ -83,10 +86,107 @@ jobs:
|
||||
git push || true
|
||||
|
||||
- name: Package .zip archives
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION=v`cat VERSION`
|
||||
python3.11 -m pip install "src/bonsaiviewer-autodesk[build]"
|
||||
python3.11 src/bonsaiviewer-autodesk/packaging/build.py
|
||||
autodesk_connector_dir="$PWD/src/bonsaiviewer-autodesk/dist/autodesk"
|
||||
test -d "$autodesk_connector_dir"
|
||||
|
||||
cd ./build/`uname`/*/install/ifcopenshell
|
||||
mkdir ~/output
|
||||
mkdir -p ~/output
|
||||
install_root="$PWD"
|
||||
QT6_VERSION="${QT6_VERSION:-6.8.3}"
|
||||
|
||||
if [ -z "${QT_DIR:-}" ]; then
|
||||
for qt_candidate in "$(dirname "$install_root")"/qt6-${QT6_VERSION}-*/${QT6_VERSION}/*; do
|
||||
if [ -d "$qt_candidate/lib" ]; then
|
||||
QT_DIR="$qt_candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
ensure_soname_links() {
|
||||
dest="$1"
|
||||
find "$dest" -maxdepth 1 -type f -name "*.so*" | while IFS= read -r shared_object; do
|
||||
soname=$(readelf -d "$shared_object" 2>/dev/null | sed -n 's/.*(SONAME).*Shared library: \[\(.*\)\].*/\1/p' | head -n 1)
|
||||
[ -n "$soname" ] || continue
|
||||
[ -e "$dest/$soname" ] && continue
|
||||
ln -s "$(basename "$shared_object")" "$dest/$soname"
|
||||
done
|
||||
}
|
||||
|
||||
stage_runtime_payload() {
|
||||
dest="$1"
|
||||
include_geometry_writers="${2:-1}"
|
||||
while IFS= read -r runtime_file; do
|
||||
if [ "$include_geometry_writers" != "1" ] && [[ "$(basename "$runtime_file")" == ifcopenshell.geometry.writer.* ]]; then
|
||||
continue
|
||||
fi
|
||||
cp -P "$runtime_file" "$dest/"
|
||||
done < <(
|
||||
for runtime_dir in "$install_root/bin" "$install_root/lib" "$install_root/lib64"; do
|
||||
[ -d "$runtime_dir" ] || continue
|
||||
find "$runtime_dir" \( -type f -o -type l \) \( -name "*.so" -o -name "*.so.*" -o -name "*.dylib" -o -name "*.dll" \)
|
||||
done
|
||||
)
|
||||
ensure_soname_links "$dest"
|
||||
}
|
||||
|
||||
stage_qt_runtime_payload() {
|
||||
exe_path="$1"
|
||||
dest="$2"
|
||||
[ -n "${QT_DIR:-}" ] && [ -d "$QT_DIR/lib" ] || return 0
|
||||
|
||||
if ! LD_LIBRARY_PATH="$QT_DIR/lib:${LD_LIBRARY_PATH:-}" ldd "$exe_path" 2>/dev/null | grep -q "libQt6"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
find "$QT_DIR/lib" -maxdepth 1 \( -type f -o -type l \) -name "*.so*" -exec cp -P {} "$dest/" \;
|
||||
ensure_soname_links "$dest"
|
||||
|
||||
if [ -d "$QT_DIR/plugins" ]; then
|
||||
pushd "$QT_DIR/plugins" > /dev/null
|
||||
find . \( -type f -o -type l \) -name "*.so*" | while IFS= read -r plugin_file; do
|
||||
mkdir -p "$dest/plugins/$(dirname "$plugin_file")"
|
||||
cp -P "$plugin_file" "$dest/plugins/$plugin_file"
|
||||
done
|
||||
popd > /dev/null
|
||||
if [ -d "$dest/plugins" ]; then
|
||||
find "$dest/plugins" -type f -name "*.so*" -exec patchelf --set-rpath '$ORIGIN/../..:$ORIGIN' {} \;
|
||||
fi
|
||||
fi
|
||||
|
||||
find "$dest" -maxdepth 1 -type f -name "*.so*" -exec patchelf --set-rpath '$ORIGIN' {} \;
|
||||
|
||||
printf "[Paths]\nPrefix = .\n" > "$dest/qt.conf"
|
||||
}
|
||||
|
||||
check_runtime_dependencies() {
|
||||
package_dir="$1"
|
||||
missing=0
|
||||
while IFS= read -r binary_file; do
|
||||
readelf -h "$binary_file" >/dev/null 2>&1 || continue
|
||||
if ! env -u LD_LIBRARY_PATH ldd "$binary_file" > "$package_dir/.ldd.out" 2>&1; then
|
||||
echo "ldd failed for $binary_file"
|
||||
cat "$package_dir/.ldd.out"
|
||||
missing=1
|
||||
continue
|
||||
fi
|
||||
if grep -q "not found" "$package_dir/.ldd.out"; then
|
||||
echo "Missing runtime dependencies for $binary_file"
|
||||
grep "not found" "$package_dir/.ldd.out"
|
||||
missing=1
|
||||
fi
|
||||
done < <(find "$package_dir" -type f \( -perm /111 -o -name "*.so" -o -name "*.so.*" \))
|
||||
rm -f "$package_dir/.ldd.out"
|
||||
if [ "$missing" -ne 0 ]; then
|
||||
echo "Runtime dependency check found issues; continuing packaging."
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
ls -d python-* | while read py_version; do
|
||||
postfix=`echo ${py_version: -1} | sed s/[0-9]//`
|
||||
@@ -101,18 +201,32 @@ jobs:
|
||||
fi
|
||||
[ -d ifcopenshell/__pycache__ ] && rm -rf ifcopenshell/__pycache__
|
||||
find ifcopenshell -name "*.pyc" -delete
|
||||
zip -r -qq ifcopenshell-${py_version_major}-${VERSION}-${GITHUB_SHA:0:7}-linuxarm64.zip ifcopenshell/*
|
||||
stage_runtime_payload ifcopenshell
|
||||
zip -y -r -qq ifcopenshell-${py_version_major}-${VERSION}-${GITHUB_SHA:0:7}-linuxarm64.zip ifcopenshell
|
||||
mv *.zip ~/output
|
||||
popd > /dev/null
|
||||
done
|
||||
|
||||
cd bin
|
||||
rm *.zip || true
|
||||
ls | while read exe; do
|
||||
zip -qq -r ${exe}-${VERSION}-${GITHUB_SHA:0:7}-linuxarm64.zip $exe
|
||||
rm -f "$install_root"/bin/*.zip
|
||||
find "$install_root/bin" -maxdepth 1 -type f -perm /111 ! -name "*.zip" ! -name "*.so" ! -name "*.so.*" ! -name "*.dylib" ! -name "*.dll" | while read exe_path; do
|
||||
exe=`basename "$exe_path"`
|
||||
package_dir="$install_root/.package-${exe}"
|
||||
rm -rf "$package_dir"
|
||||
mkdir -p "$package_dir"
|
||||
cp "$exe_path" "$package_dir/"
|
||||
patchelf --set-rpath '$ORIGIN' "$package_dir/$exe"
|
||||
stage_runtime_payload "$package_dir" 0
|
||||
stage_qt_runtime_payload "$exe_path" "$package_dir"
|
||||
if [ "$exe" = "BonsaiViewer" ]; then
|
||||
mkdir -p "$package_dir/connectors"
|
||||
cp -a "$autodesk_connector_dir" "$package_dir/connectors/"
|
||||
fi
|
||||
check_runtime_dependencies "$package_dir"
|
||||
pushd "$package_dir" > /dev/null
|
||||
zip -y -qq -r "$HOME/output/${exe}-${VERSION}-${GITHUB_SHA:0:7}-linuxarm64.zip" .
|
||||
popd > /dev/null
|
||||
rm -rf "$package_dir"
|
||||
done
|
||||
mv *.zip ~/output
|
||||
cd ..
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@v6
|
||||
|
||||
@@ -48,7 +48,11 @@ jobs:
|
||||
run: |
|
||||
cd ${{ matrix.deps_dir }}
|
||||
Get-ChildItem -Path . -Filter 'cache-*.zip' | ForEach-Object {
|
||||
7z x $_.FullName
|
||||
Write-Host "Extracting $($_.Name)"
|
||||
7z x -bso0 -bsp0 $_.FullName
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Failed to extract $($_.Name) with 7z exit code $LASTEXITCODE."
|
||||
}
|
||||
}
|
||||
|
||||
- name: ccache
|
||||
@@ -59,6 +63,24 @@ jobs:
|
||||
# and with default 500MB some cache gets deleted, leading to misses.
|
||||
max-size: 5000MB
|
||||
|
||||
- name: Set up Python for connector build
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
# The connector's PyInstaller bundle embeds a tkinter GUI; verify Tk is
|
||||
# present so a missing-tk regression fails here, not inside the build.
|
||||
- name: Verify tkinter is available
|
||||
run: python -c "import tkinter; print('Tk', tkinter.TkVersion)"
|
||||
|
||||
# Build the Autodesk connector before the C++ build: build-all-win.py
|
||||
# bundles it next to BonsaiViewer.exe while archiving the executables.
|
||||
- name: Build Autodesk connector
|
||||
working-directory: src/bonsaiviewer-autodesk
|
||||
run: |
|
||||
python -m pip install ".[build]"
|
||||
python packaging/build.py
|
||||
|
||||
- name: Run Build Script And Pack .zip Archives
|
||||
shell: cmd
|
||||
env:
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
sudo apt-get install --no-install-recommends \
|
||||
git cmake gcc g++ libboost-all-dev python3-all-dev swig libpcre3-dev libxml2-dev \
|
||||
libocct-foundation-dev libocct-modeling-algorithms-dev libocct-modeling-data-dev libocct-ocaf-dev libocct-visualization-dev libocct-data-exchange-dev \
|
||||
libhdf5-dev libcgal-dev nlohmann-json3-dev libeigen3-dev
|
||||
libcgal-dev nlohmann-json3-dev libeigen3-dev
|
||||
|
||||
-
|
||||
name: ccache
|
||||
@@ -60,7 +60,6 @@ jobs:
|
||||
-DMPFR_INCLUDE_DIR=/usr/include \
|
||||
-DGMP_LIBRARY_DIR=/usr/lib/x86_64-linux-gnu \
|
||||
-DMPFR_LIBRARY_DIR=/usr/lib/x86_64-linux-gnu \
|
||||
-DHDF5_INCLUDE_DIR=/usr/include/hdf5/serial \
|
||||
-DGLTF_SUPPORT=On \
|
||||
-DJSON_INCLUDE_DIR=/usr/include \
|
||||
-DEIGEN_DIR=/usr/include/eigen3 \
|
||||
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
libtbb-dev nlohmann-json3-dev \
|
||||
libocct-foundation-dev libocct-modeling-algorithms-dev libocct-modeling-data-dev libocct-ocaf-dev libocct-visualization-dev libocct-data-exchange-dev \
|
||||
${OCCT_CMAKE_DEPS} \
|
||||
libhdf5-dev libcgal-dev libeigen3-dev
|
||||
libcgal-dev libeigen3-dev
|
||||
|
||||
- name: ccache
|
||||
uses: hendrikmuhs/ccache-action@v1.2.22
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
swig libpcre3-dev libxml2-dev \
|
||||
libtbb-dev nlohmann-json3-dev \
|
||||
libocct-foundation-dev libocct-modeling-algorithms-dev libocct-modeling-data-dev libocct-ocaf-dev libocct-visualization-dev libocct-data-exchange-dev \
|
||||
libhdf5-dev libcgal-dev opencollada-dev
|
||||
libcgal-dev opencollada-dev
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
@@ -64,7 +64,6 @@ jobs:
|
||||
-DMPFR_INCLUDE_DIR=/usr/include \
|
||||
-DGMP_LIBRARY_DIR=/usr/lib/x86_64-linux-gnu \
|
||||
-DMPFR_LIBRARY_DIR=/usr/lib/x86_64-linux-gnu \
|
||||
-DHDF5_INCLUDE_DIR=/usr/include/hdf5/serial \
|
||||
-DOPENCOLLADA_INCLUDE_DIR=/usr/include/opencollada \
|
||||
-DOPENCOLLADA_LIBRARY_DIR=/usr/lib/opencollada/ \
|
||||
../cmake
|
||||
|
||||
@@ -128,3 +128,7 @@ src/ifcopenshell-python/ifcopenshell/express/*.exp.cache.dat
|
||||
*.claude
|
||||
*.py.tmp*
|
||||
*.json.tmp*
|
||||
|
||||
# bonsaiviewer-autodesk connector build artifacts
|
||||
/src/bonsaiviewer-autodesk/build/
|
||||
/src/bonsaiviewer-autodesk/dist/
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ RUN echo "deb http://archive.ubuntu.com/ubuntu focal-proposed main restricted" |
|
||||
libboost-all-dev \
|
||||
libocct-foundation-dev libocct-modeling-algorithms-dev libocct-modeling-data-dev \
|
||||
libocct-ocaf-dev libocct-visualization-dev libocct-data-exchange-dev \
|
||||
libhdf5-serial-dev python3-pytest ; \
|
||||
python3-pytest ; \
|
||||
rm -rf /var/lib/apt/lists/* ;
|
||||
|
||||
COPY . /home/IfcOpenShell/
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
mkdir -p build && cd build
|
||||
|
||||
cmake ../cmake \
|
||||
-G Ninja \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DPython_EXECUTABLE=/home/dion/Projects/env/bin/python3.11 \
|
||||
-DPython_INCLUDE_DIR=/usr/include/python3.11 \
|
||||
-DBUILD_IFCPYTHON=ON \
|
||||
-DBUILD_IFCGEOM=ON \
|
||||
-DBUILD_CONVERT=ON \
|
||||
-DBUILD_GEOMSERVER=OFF \
|
||||
-DBUILD_EXAMPLES=OFF \
|
||||
-DWITH_OPENCASCADE=ON \
|
||||
-DWITH_CGAL=ON \
|
||||
-DWITH_MANIFOLD=ON \
|
||||
-DGLTF_SUPPORT=ON \
|
||||
-DIFCXML_SUPPORT=OFF \
|
||||
-DCOLLADA_SUPPORT=OFF \
|
||||
-DSCHEMA_VERSIONS="2x3;4;4x3_add2" \
|
||||
-DOCC_INCLUDE_DIR=/usr/include/opencascade \
|
||||
-DOCC_LIBRARY_DIR=/usr/lib64/opencascade
|
||||
|
||||
ninja
|
||||
|
||||
cp ifcwrap/_ifcopenshell_wrapper*.so ifcwrap/ifcopenshell_wrapper.py \
|
||||
../src/ifcopenshell-python/ifcopenshell/
|
||||
+208
-214
@@ -18,28 +18,19 @@
|
||||
################################################################################
|
||||
|
||||
cmake_minimum_required(VERSION 3.21)
|
||||
if(NOT DEFINED CMAKE_CXX_STANDARD)
|
||||
if (NOT DEFINED CMAKE_CXX_STANDARD)
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
endif()
|
||||
if(CMAKE_CXX_STANDARD LESS 17)
|
||||
if (CMAKE_CXX_STANDARD LESS 17)
|
||||
message(FATAL_ERROR "C++17 or newer is required.")
|
||||
endif()
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON) # not necessary, but encouraged
|
||||
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
|
||||
|
||||
if(VERSION_OVERRIDE)
|
||||
file(READ "../VERSION" "RELEASE_VERSION_")
|
||||
string(STRIP "${RELEASE_VERSION_}" RELEASE_VERSION)
|
||||
message(STATUS "Detected version '${RELEASE_VERSION}'")
|
||||
else()
|
||||
set(RELEASE_VERSION "0.8.0")
|
||||
endif()
|
||||
|
||||
add_definitions(-D_DISABLE_CONSTEXPR_MUTEX_CONSTRUCTOR)
|
||||
|
||||
if(POLICY CMP0141) # 3.25+
|
||||
# Has to be set before `project` to take effect.
|
||||
cmake_policy(SET CMP0141 NEW) # Support for `CMAKE_MSVC_DEBUG_INFORMATION_FORMAT`.
|
||||
cmake_policy(SET CMP0141 NEW) # Support for `CMAKE_MSVC_DEBUG_INFORMATION_FORMAT`.
|
||||
endif()
|
||||
if(POLICY CMP0144) # 3.27
|
||||
cmake_policy(SET CMP0144 NEW) # find_package() uses upper-case <PACKAGENAME>_ROOT variables.
|
||||
@@ -48,8 +39,6 @@ if(POLICY CMP0167) # 3.30
|
||||
cmake_policy(SET CMP0167 OLD)
|
||||
endif()
|
||||
|
||||
project(IfcOpenShell VERSION ${RELEASE_VERSION})
|
||||
|
||||
if(NOT CMAKE_BUILD_TYPE)
|
||||
set(CMAKE_BUILD_TYPE "Release")
|
||||
endif()
|
||||
@@ -65,64 +54,69 @@ endif()
|
||||
option(MINIMAL_BUILD "The build is to make a minimal version of IFC converter from OCCT into IFC." OFF)
|
||||
option(WASM_BUILD "Build a WebAssembly binary." OFF)
|
||||
|
||||
option(
|
||||
ENABLE_BUILD_OPTIMIZATIONS
|
||||
"Enable certain compiler and linker optimizations on RelWithDebInfo and Release builds."
|
||||
OFF
|
||||
)
|
||||
option(BUILD_SHARED_LIBS "Build IfcParse and IfcGeom as shared libs (SO/DLL)." OFF)
|
||||
option(ENABLE_BUILD_OPTIMIZATIONS "Enable certain compiler and linker optimizations on RelWithDebInfo and Release builds." OFF)
|
||||
option(BUILD_SHARED_LIBS "Build IfcOpenShell as shared libraries (required)." ON)
|
||||
option(MSVC_PARALLEL_BUILD "Multi-threaded compilation in Microsoft Visual Studio (/MP)" OFF)
|
||||
option(USE_VLD "Use Visual Leak Detector for debugging memory leaks, MSVC-only." OFF)
|
||||
option(USE_MMAP "Adds a command line options to parse IFC files from memory mapped files using Boost.Iostreams" OFF)
|
||||
option(NO_WARN "Disable all warnings" OFF)
|
||||
option(CREATE_BUNDLE "Copy .so files and don't create RPATHS or SOVERSION symlinks" )
|
||||
|
||||
option(BUILD_IFCGEOM "Build IfcGeom." ON)
|
||||
option(BUILD_IFCPYTHON "Build IfcPython." ON)
|
||||
option(BUILD_IFCPARSE_EXPERIMENTAL_WRAPPER "Build the experimental Clang-generated ifcparse Python wrapper." OFF)
|
||||
option(BUILD_CONVERT "Build IfcConvert executable." ON)
|
||||
option(BUILD_DOCUMENTATION "Build IfcOpenShell Documentation." OFF)
|
||||
option(BUILD_EXAMPLES "Build example applications." OFF)
|
||||
option(BUILD_EXAMPLES "Build example applications." ON)
|
||||
option(BUILD_GEOMSERVER "Build IfcGeomServer executable (Open CASCADE is required)." ON)
|
||||
option(BUILD_IFCMAX "Build IfcMax, a 3ds Max plug-in, Windows-only." OFF)
|
||||
option(BUILD_QTVIEWER "Build IfcOpenShell Qt GUI Viewer" OFF) # QtViewer requires Qt6
|
||||
option(BUILD_BONSAIVIEWER "Build Bonsai Viewer" OFF) # Requires Qt6 + OpenGL 4.5
|
||||
option(BUILD_BONSAIVIEWER_TESTS "Build unit tests for Bonsai Viewer core (fetches Catch2 v3)" OFF)
|
||||
option(BUILD_PACKAGE "" OFF)
|
||||
# Most users probably need just common schemas,
|
||||
# but we're keeping it `OFF` by default to avoid disruption
|
||||
# (e.g. all Python distribution would need to adapt this option to be set).
|
||||
|
||||
option(
|
||||
BUILD_ONLY_COMMON_SCHEMAS
|
||||
"Build only common IFC schemas (2x3, 4, 4x3_add2). By default all schemas will be built."
|
||||
IFCOPENSHELL_DEPLOY_QT_RUNTIME
|
||||
"Deploy Qt runtime dependencies for installed Qt applications."
|
||||
ON
|
||||
)
|
||||
option(
|
||||
IFCOPENSHELL_DEPLOY_QT_TRANSLATIONS
|
||||
"Deploy Qt translation catalogs with installed Qt applications."
|
||||
OFF
|
||||
)
|
||||
option(SCHEMA_VERSIONS "Explicitly specify schemas to build." "")
|
||||
|
||||
option(WITH_OPENCASCADE "Enable geometry interpretation using Open CASCADE" ON)
|
||||
option(WITH_CGAL "Enable geometry interpretation using CGAL" ON)
|
||||
option(WITH_MANIFOLD "Enable geometry interpretation using Manifold" OFF)
|
||||
option(COLLADA_SUPPORT "Build IfcConvert with COLLADA support (requires OpenCOLLADA)." ON)
|
||||
option(GLTF_SUPPORT "Build IfcConvert with glTF support (requires json.hpp)." OFF)
|
||||
option(HDF5_SUPPORT "Enable HDF5 support (requires HDF5, zlib)" ON)
|
||||
option(WITH_PROJ "Enable output of Earth-Centered Earth-Fixed glTF output using the PROJ library" OFF)
|
||||
option(IFCXML_SUPPORT "Build IfcParse with ifcXML support (requires libxml2)." ON)
|
||||
option(USD_SUPPORT "Build IfcConvert with USD support (requires pixar's USD library)." OFF)
|
||||
option(WITH_RELATIONSHIP_VALIDATION "Build IfcConvert with option to validate geometrical relationships." OFF)
|
||||
option(WITH_ROCKSDB "Support a RocksDB key-value store as a file backend in IfcOpenShell" OFF)
|
||||
option(WITH_ZSTD "Use Zstd compression in RocksDB writes" OFF)
|
||||
|
||||
option(USERSPACE_PYTHON_PREFIX "Installs IfcPython for the current user only instead of system-wide." OFF)
|
||||
option(USE_DEBUG_PYTHON "Use debug binaries when building Debug IfcPython on Windows." OFF)
|
||||
option(ADD_COMMIT_SHA "Add commit sha and branch in version number, requires git" OFF)
|
||||
option(
|
||||
VERSION_OVERRIDE
|
||||
"Override the version defined in buildinfo.cpp with the file VERSION in the repository root"
|
||||
OFF
|
||||
)
|
||||
option(USE_CCACHE "Enable use of ccache if it's available from PATH." ON)
|
||||
option(VERSION_OVERRIDE "Override the version defined in buildinfo.cpp with the file VERSION in the repository root" OFF)
|
||||
|
||||
set(PYTHON_MODULE_INSTALL_DIR
|
||||
""
|
||||
CACHE PATH
|
||||
set(
|
||||
PYTHON_MODULE_INSTALL_DIR
|
||||
"" CACHE PATH
|
||||
"Directory to install IfcPython package to. By default package is installed in found Python's site-packages."
|
||||
)
|
||||
|
||||
if (VERSION_OVERRIDE)
|
||||
file(READ "../VERSION" "RELEASE_VERSION_")
|
||||
string(STRIP "${RELEASE_VERSION_}" RELEASE_VERSION)
|
||||
message(STATUS "Detected version '${RELEASE_VERSION}'")
|
||||
else()
|
||||
set(RELEASE_VERSION "0.8.0")
|
||||
endif()
|
||||
|
||||
project(IfcOpenShell VERSION ${RELEASE_VERSION})
|
||||
|
||||
# Make sure CMake modules in this project are found first
|
||||
list(PREPEND CMAKE_MODULE_PATH ${PROJECT_SOURCE_DIR})
|
||||
|
||||
@@ -133,18 +127,16 @@ if(MINIMAL_BUILD)
|
||||
set(WITH_CGAL OFF)
|
||||
set(COLLADA_SUPPORT OFF)
|
||||
set(GLTF_SUPPORT OFF)
|
||||
set(HDF5_SUPPORT OFF)
|
||||
set(IFCXML_SUPPORT OFF)
|
||||
set(USD_SUPPORT OFF)
|
||||
endif()
|
||||
|
||||
if((BUILD_CONVERT OR BUILD_GEOMSERVER OR BUILD_IFCPYTHON) AND (NOT BUILD_IFCGEOM))
|
||||
if((BUILD_CONVERT OR BUILD_GEOMSERVER OR BUILD_IFCPYTHON) AND(NOT BUILD_IFCGEOM))
|
||||
message(STATUS "'IfcGeom' is required with current outputs")
|
||||
set(BUILD_IFCGEOM ON)
|
||||
endif()
|
||||
|
||||
find_program(CCACHE_FOUND ccache)
|
||||
if(USE_CCACHE AND CCACHE_FOUND)
|
||||
if(CCACHE_FOUND)
|
||||
message(STATUS "`ccache` is found, using it as a compiler launcher.")
|
||||
set_property(GLOBAL PROPERTY RULE_LAUNCH_COMPILE "${CCACHE_FOUND}")
|
||||
if(MSVC)
|
||||
@@ -153,15 +145,17 @@ if(USE_CCACHE AND CCACHE_FOUND)
|
||||
set(CMAKE_MSVC_DEBUG_INFORMATION_FORMAT "$<$<CONFIG:Debug,RelWithDebInfo>:Embedded>")
|
||||
# Not needed for Ninja.
|
||||
if(CMAKE_GENERATOR MATCHES "Visual Studio")
|
||||
file(COPY_FILE ${CCACHE_FOUND} ${CMAKE_BINARY_DIR}/cl.exe ONLY_IF_DIFFERENT)
|
||||
set(CMAKE_VS_GLOBALS "CLToolExe=cl.exe" "CLToolPath=${CMAKE_BINARY_DIR}" "UseMultiToolTask=true")
|
||||
file(COPY_FILE
|
||||
${CCACHE_FOUND} ${CMAKE_BINARY_DIR}/cl.exe
|
||||
ONLY_IF_DIFFERENT)
|
||||
set(CMAKE_VS_GLOBALS
|
||||
"CLToolExe=cl.exe"
|
||||
"CLToolPath=${CMAKE_BINARY_DIR}"
|
||||
"UseMultiToolTask=true"
|
||||
)
|
||||
endif()
|
||||
endif()
|
||||
endif()
|
||||
mark_as_advanced(CCACHE_FOUND)
|
||||
|
||||
# Variable to accumulate swig definitions from various submodules.
|
||||
set(SWIG_DEFINES "")
|
||||
|
||||
if(MSVC AND MSVC_PARALLEL_BUILD)
|
||||
add_definitions("/MP")
|
||||
@@ -177,28 +171,23 @@ endif()
|
||||
|
||||
include(GNUInstallDirs)
|
||||
|
||||
set(IFCOPENSHELL_EXPORT_TARGETS "${PROJECT_NAME}Targets")
|
||||
|
||||
# On Windows Release and Debug binaries are not compatible.
|
||||
# So we add a postfix to avoid issues and allow release and debug installations to coexist.
|
||||
if(WIN32)
|
||||
set(CMAKE_DEBUG_POSTFIX "_d")
|
||||
if(NOT INCLUDEDIR)
|
||||
set(INCLUDEDIR include)
|
||||
endif()
|
||||
if(NOT IS_ABSOLUTE ${INCLUDEDIR})
|
||||
set(INCLUDEDIR ${CMAKE_INSTALL_INCLUDEDIR})
|
||||
endif()
|
||||
message(STATUS "INCLUDEDIR: ${INCLUDEDIR}")
|
||||
|
||||
if(BUILD_SHARED_LIBS)
|
||||
add_definitions(-DIFC_SHARED_BUILD)
|
||||
if(MSVC)
|
||||
message(
|
||||
WARNING
|
||||
"Building DLLs against the static VC run-time. This is not recommended if the DLLs are to be redistributed."
|
||||
)
|
||||
# C4521: 'identifier' : class 'type' needs to have dll-interface to be used by clients of class 'type2'
|
||||
# There will be couple hundreds of these so suppress them away, https://msdn.microsoft.com/en-us/library/esew7y1w.aspx
|
||||
add_definitions(-wd4251)
|
||||
endif()
|
||||
if(MSVC)
|
||||
message(WARNING "Building DLLs against the static VC run-time. This is not recommended if the DLLs are to be redistributed.")
|
||||
# C4521: 'identifier' : class 'type' needs to have dll-interface to be used by clients of class 'type2'
|
||||
# There will be couple hundreds of these so suppress them away, https://msdn.microsoft.com/en-us/library/esew7y1w.aspx
|
||||
add_definitions(-wd4251)
|
||||
endif()
|
||||
|
||||
UNIFY_ENVVARS_AND_CACHE(BOOST_ROOT)
|
||||
UNIFY_ENVVARS_AND_CACHE(BOOST_LIBRARYDIR)
|
||||
|
||||
if(NOT MINIMAL_BUILD)
|
||||
UNIFY_ENVVARS_AND_CACHE(PYTHON_INCLUDE_DIR)
|
||||
@@ -214,71 +203,75 @@ foreach(option_flag IN LISTS option_flags)
|
||||
convert_env_var_to_bool("${option_flag}")
|
||||
endforeach()
|
||||
|
||||
if(WITH_CGAL)
|
||||
find_package(CGAL REQUIRED)
|
||||
set(SWIG_DEFINES ${SWIG_DEFINES} -DIFOPSH_WITH_CGAL)
|
||||
list(APPEND GEOMETRY_KERNELS cgal)
|
||||
endif()
|
||||
|
||||
if(BUILD_IFCGEOM AND WITH_OPENCASCADE)
|
||||
find_package(OpenCASCADE REQUIRED)
|
||||
add_definitions(-DIFOPSH_WITH_OPENCASCADE)
|
||||
# Map OpenCASCADE_LIBRARIES variable from OpenCASCADEConfig.cmake to OpenCASCADE_LIBRARIES used by kernel generic cmake file
|
||||
set(OpenCASCADE_LIBRARIES ${OpenCASCADE_LIBRARIES})
|
||||
set(SWIG_DEFINES ${SWIG_DEFINES} -DIFOPSH_WITH_OPENCASCADE)
|
||||
list(APPEND GEOMETRY_KERNELS opencascade)
|
||||
endif()
|
||||
|
||||
set(GLTF_LIBRARIES "")
|
||||
message(STATUS "BUILD_IFCGEOM WITH_MANIFOLD: ${BUILD_IFCGEOM} ${WITH_MANIFOLD}")
|
||||
|
||||
if(BUILD_IFCGEOM AND WITH_MANIFOLD)
|
||||
find_package(manifold CONFIG REQUIRED)
|
||||
if(TARGET manifold::manifold)
|
||||
set(MANIFOLD_LIBRARIES manifold::manifold)
|
||||
elseif(TARGET manifold)
|
||||
set(MANIFOLD_LIBRARIES manifold)
|
||||
else()
|
||||
message(FATAL_ERROR "Unable to determine manifold target")
|
||||
endif()
|
||||
list(APPEND GEOMETRY_KERNELS manifold)
|
||||
endif()
|
||||
|
||||
if(BUILD_IFCGEOM)
|
||||
list(APPEND GEOMETRY_KERNELS passthrough)
|
||||
endif()
|
||||
|
||||
if(GLTF_SUPPORT)
|
||||
find_package(nlohmann_json REQUIRED)
|
||||
set(GLTF_LIBRARIES nlohmann_json::nlohmann_json)
|
||||
add_definitions(-DWITH_GLTF)
|
||||
set(SWIG_DEFINES ${SWIG_DEFINES} -DWITH_GLTF)
|
||||
endif()
|
||||
|
||||
# Add USD support to serializers
|
||||
set(USD_LIBRARIES "")
|
||||
if(USD_SUPPORT)
|
||||
find_package(USD REQUIRED)
|
||||
set(USD_LIBRARIES pxr::USD)
|
||||
endif(USD_SUPPORT)
|
||||
|
||||
set(ROCKSDB_LIBRARIES "")
|
||||
if(WITH_ROCKSDB)
|
||||
if (WITH_ROCKSDB)
|
||||
# Temporaily mess with CMAKE_FIND_PACKAGE_PREFER_CONFIG to help RocksDB
|
||||
# find it's zstd dependency on Windows.
|
||||
# Only do it on Windows, otherwise it might create problems as
|
||||
# findzstd and zstd-config target names do not match.
|
||||
# https://github.com/facebook/rocksdb/pull/13975
|
||||
if(WIN32)
|
||||
set(TEMP CMAKE_FIND_PACKAGE_PREFER_CONFIG)
|
||||
set(CMAKE_FIND_PACKAGE_PREFER_CONFIG TRUE)
|
||||
endif()
|
||||
find_package(RocksDB CONFIG REQUIRED)
|
||||
mark_as_advanced(RocksDB_DIR)
|
||||
if(WIN32)
|
||||
set(CMAKE_FIND_PACKAGE_PREFER_CONFIG ${TEMP})
|
||||
endif()
|
||||
|
||||
message(STATUS "RocksDB: found at '${RocksDB_DIR}'.")
|
||||
add_library(IFCOPENSHELL_RocksDB INTERFACE)
|
||||
set(ROCKSDB_LIBRARIES "IFCOPENSHELL_RocksDB")
|
||||
target_compile_definitions(IFCOPENSHELL_RocksDB INTERFACE IFOPSH_WITH_ROCKSDB)
|
||||
set(SWIG_DEFINES ${SWIG_DEFINES} -DIFOPSH_WITH_ROCKSDB)
|
||||
# Shared binaries for `rocksdb` only support limited API (only `c.h`), but we use `db.h` API.
|
||||
# So rocksdb supported only as a static library.
|
||||
# See https://github.com/facebook/rocksdb/issues/981.
|
||||
target_link_libraries(IFCOPENSHELL_RocksDB INTERFACE RocksDB::rocksdb)
|
||||
|
||||
if(WITH_ZSTD)
|
||||
if (WITH_ZSTD)
|
||||
# @todo do we actually need the zstd include dir or rather just pass
|
||||
# the libzstd.a along with the rocksdb library when needed and feature
|
||||
# detect based on rocksdb API?
|
||||
find_package(zstd CONFIG REQUIRED)
|
||||
mark_as_advanced(zstd_DIR)
|
||||
message(STATUS "zstd: found at '${zstd_DIR}'.")
|
||||
target_link_libraries(IFCOPENSHELL_RocksDB INTERFACE zstd::libzstd_static)
|
||||
endif()
|
||||
|
||||
install(TARGETS IFCOPENSHELL_RocksDB EXPORT ${IFCOPENSHELL_EXPORT_TARGETS})
|
||||
endif()
|
||||
|
||||
# Find Boost: On win32 the (hardcoded) default is to use static libraries and
|
||||
# runtime, when doing running conda-build we pick what conda prepared for us.
|
||||
if(WIN32 AND NOT DEFINED ENV{CONDA_BUILD})
|
||||
if(WIN32 AND("$ENV{CONDA_BUILD}" STREQUAL ""))
|
||||
set(Boost_USE_STATIC_LIBS ON)
|
||||
set(Boost_USE_STATIC_RUNTIME OFF)
|
||||
set(Boost_USE_MULTITHREADED ON)
|
||||
@@ -309,14 +302,8 @@ if(WASM_BUILD)
|
||||
else()
|
||||
# @todo review this, shouldn't this be all possible header-only now?
|
||||
# ... or rewritten using C++17 features?
|
||||
set(BOOST_COMPONENTS
|
||||
system
|
||||
program_options
|
||||
regex
|
||||
thread
|
||||
date_time
|
||||
iostreams
|
||||
)
|
||||
# set(BOOST_COMPONENTS system program_options regex thread date_time iostreams)
|
||||
set(BOOST_COMPONENTS program_options regex thread date_time iostreams)
|
||||
endif()
|
||||
|
||||
if(USE_MMAP)
|
||||
@@ -326,17 +313,6 @@ if(USE_MMAP)
|
||||
else()
|
||||
set(BOOST_COMPONENTS ${BOOST_COMPONENTS} iostreams)
|
||||
endif()
|
||||
|
||||
add_definitions(-DUSE_MMAP)
|
||||
endif()
|
||||
|
||||
# Handle CGAL after Boost settings are set, since CGAL will use them too.
|
||||
# Do `find_package(Boost)` only after this, to make sure `FindBoost` finds correct components.
|
||||
# Otherwise it will find components needed for CGAL and we might some libraries.
|
||||
if(WITH_CGAL)
|
||||
find_package(CGAL REQUIRED)
|
||||
set(CGAL_LIBRARIES IFCOPENSHELL_CGAL)
|
||||
list(APPEND GEOMETRY_KERNELS cgal)
|
||||
endif()
|
||||
|
||||
find_package(Boost REQUIRED COMPONENTS ${BOOST_COMPONENTS})
|
||||
@@ -345,17 +321,8 @@ message(STATUS "Boost libraries found in ${Boost_LIBRARY_DIRS}")
|
||||
|
||||
if(COLLADA_SUPPORT)
|
||||
find_package(OpenCOLLADA REQUIRED)
|
||||
add_definitions(-DWITH_OPENCOLLADA)
|
||||
endif()
|
||||
|
||||
if(HDF5_SUPPORT)
|
||||
find_package(HDF5 REQUIRED COMPONENTS C CXX)
|
||||
set(IFCOPENSHELL_LIBRARIES ${IFCOPENSHELL_LIBRARIES} hdf5::hdf5_cpp)
|
||||
|
||||
add_definitions(-DWITH_HDF5)
|
||||
set(SWIG_DEFINES ${SWIG_DEFINES} -DWITH_HDF5)
|
||||
endif(HDF5_SUPPORT)
|
||||
|
||||
if(ENABLE_BUILD_OPTIMIZATIONS)
|
||||
if(MSVC)
|
||||
# NOTE: RelWithDebInfo and Release use O2 (= /Ox /Gl /Gy/ = Og /Oi /Ot /Oy /Ob2 /Gs /GF /Gy) by default,
|
||||
@@ -368,20 +335,12 @@ if(ENABLE_BUILD_OPTIMIZATIONS)
|
||||
|
||||
# Linker
|
||||
# /OPT:REF enables also /OPT:ICF and disables INCREMENTAL
|
||||
set(LINKER_FLAGS_RELEASE "/LTCG /OPT:REF")
|
||||
# /OPT:NOICF is recommended when /DEBUG is used (http://msdn.microsoft.com/en-us/library/xe4t6fc1.aspx)
|
||||
set(LINKER_FLAGS_RELWITHDEBINFO "/DEBUG /OPT:NOICF")
|
||||
set(CMAKE_SHARED_LINKER_FLAGS_RELEASE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE} /LTCG /OPT:REF")
|
||||
|
||||
set(CMAKE_SHARED_LINKER_FLAGS_RELEASE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE} ${LINKER_FLAGS_RELEASE}")
|
||||
set(CMAKE_SHARED_LINKER_FLAGS_RELWITHDEBINFO
|
||||
"${CMAKE_SHARED_LINKER_FLAGS_RELEASE} ${LINKER_FLAGS_RELWITHDEBINFO}"
|
||||
)
|
||||
set(CMAKE_EXE_LINKER_FLAGS_RELEASE "${CMAKE_EXE_LINKER_FLAGS_RELEASE} ${LINKER_FLAGS_RELEASE}")
|
||||
set(CMAKE_EXE_LINKER_FLAGS_RELWITHDEBINFO "${CMAKE_EXE_LINKER_FLAGS_RELEASE} ${LINKER_FLAGS_RELWITHDEBINFO}")
|
||||
set(CMAKE_MODULE_LINKER_FLAGS_RELEASE "${CMAKE_MODULE_LINKER_FLAGS_RELEASE} ${LINKER_FLAGS_RELEASE}")
|
||||
set(CMAKE_MODULE_LINKER_FLAGS_RELWITHDEBINFO
|
||||
"${CMAKE_MODULE_LINKER_FLAGS_RELEASE} ${LINKER_FLAGS_RELWITHDEBINFO}"
|
||||
)
|
||||
# /OPT:NOICF is recommended when /DEBUG is used (http://msdn.microsoft.com/en-us/library/xe4t6fc1.aspx)
|
||||
set(CMAKE_SHARED_LINKER_FLAGS_RELWITHDEBINFO "${CMAKE_SHARED_LINKER_FLAGS_RELEASE} /DEBUG /OPT:NOICF")
|
||||
set(CMAKE_EXE_LINKER_FLAGS_RELEASE "${CMAKE_EXE_LINKER_FLAGS_RELEASE} /LTCG /OPT:REF")
|
||||
set(CMAKE_EXE_LINKER_FLAGS_RELWITHDEBINFO "${CMAKE_EXE_LINKER_FLAGS_RELEASE} /DEBUG /OPT:NOICF")
|
||||
else()
|
||||
# GCC-like: Release should use O3 but RelWithDebInfo 02 so enforce 03. Anything other useful that could be added here?
|
||||
set(CMAKE_CXX_FLAGS_RELEASE "${CMAKE_CXX_FLAGS_RELEASE} -O3")
|
||||
@@ -430,7 +389,7 @@ if(MSVC)
|
||||
endif()
|
||||
|
||||
# Enforce standards-conformance on VS > 2015, older Boost versions fail to compile with this
|
||||
if(MSVC_VERSION GREATER 1900 AND (Boost_MAJOR_VERSION GREATER 1 OR Boost_MINOR_VERSION GREATER 66))
|
||||
if(MSVC_VERSION GREATER 1900 AND(Boost_MAJOR_VERSION GREATER 1 OR Boost_MINOR_VERSION GREATER 66))
|
||||
add_definitions(-permissive-)
|
||||
endif()
|
||||
|
||||
@@ -449,11 +408,11 @@ if(MSVC)
|
||||
# endforeach()
|
||||
# endif()
|
||||
|
||||
add_definitions(-D_ENABLE_EXTENDED_ALIGNED_STORAGE)
|
||||
# See #5158.
|
||||
if(CMAKE_CXX_COMPILER_VERSION VERSION_GREATER_EQUAL 19.40)
|
||||
add_definitions(-D_DISABLE_CONSTEXPR_MUTEX_CONSTRUCTOR)
|
||||
endif()
|
||||
add_definitions(-D_ENABLE_EXTENDED_ALIGNED_STORAGE)
|
||||
# See #5158.
|
||||
if(CMAKE_CXX_COMPILER_VERSION VERSION_GREATER_EQUAL 19.40)
|
||||
add_definitions(-D_DISABLE_CONSTEXPR_MUTEX_CONSTRUCTOR)
|
||||
endif()
|
||||
else()
|
||||
add_definitions(-Wall -Wextra)
|
||||
|
||||
@@ -463,10 +422,7 @@ else()
|
||||
add_definitions(-Wno-maybe-uninitialized)
|
||||
endif()
|
||||
|
||||
if(
|
||||
CMAKE_CXX_COMPILER_ID MATCHES "GNU"
|
||||
AND (CMAKE_CXX_COMPILER_VERSION VERSION_GREATER 9.0 OR CMAKE_CXX_COMPILER_VERSION VERSION_EQUAL 9.0)
|
||||
)
|
||||
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU" AND(CMAKE_CXX_COMPILER_VERSION VERSION_GREATER 9.0 OR CMAKE_CXX_COMPILER_VERSION VERSION_EQUAL 9.0))
|
||||
# OpenCascade spews a lot of deprecated-copy warnings
|
||||
add_definitions(-Wno-deprecated-copy)
|
||||
endif()
|
||||
@@ -477,45 +433,28 @@ else()
|
||||
endif()
|
||||
endif(MSVC)
|
||||
|
||||
include_directories(${OPENCOLLADA_INCLUDE_DIRS} ${Boost_INCLUDE_DIRS} ${HDF5_INCLUDE_DIR})
|
||||
include_directories(${INCLUDE_DIRECTORIES}
|
||||
${Boost_INCLUDE_DIRS}
|
||||
${CGAL_INCLUDE_DIR} ${GMP_INCLUDE_DIR} ${MPFR_INCLUDE_DIR}
|
||||
)
|
||||
|
||||
if(NOT SCHEMA_VERSIONS)
|
||||
# `WASM_BUILD` - super arbitrarily try to keep size down at least a little bit
|
||||
if(BUILD_ONLY_COMMON_SCHEMAS OR WASM_BUILD)
|
||||
if(WASM_BUILD)
|
||||
# super arbitrarily try to keep size down at least a little bit
|
||||
set(SCHEMA_VERSIONS "2x3" "4" "4x3_add2")
|
||||
else()
|
||||
set(SCHEMA_VERSIONS
|
||||
"2x3"
|
||||
"4"
|
||||
"4x1"
|
||||
"4x2"
|
||||
"4x3"
|
||||
"4x3_tc1"
|
||||
"4x3_add1"
|
||||
"4x3_add2"
|
||||
)
|
||||
set(SCHEMA_VERSIONS "2x3" "4" "4x1" "4x2" "4x3" "4x3_tc1" "4x3_add1" "4x3_add2")
|
||||
endif()
|
||||
endif()
|
||||
|
||||
message(STATUS "IFC SCHEMA_VERSIONS that will be used for the build: ${SCHEMA_VERSIONS}.")
|
||||
|
||||
set(SCHEMA_DEFINITIONS "")
|
||||
foreach(schema ${SCHEMA_VERSIONS})
|
||||
list(APPEND SCHEMA_DEFINITIONS "-DHAS_SCHEMA_${schema}")
|
||||
endforeach()
|
||||
|
||||
string(REPLACE ";" ")(" schema_version_seq "(${SCHEMA_VERSIONS})")
|
||||
list(APPEND SCHEMA_DEFINITIONS "-DSCHEMA_SEQ=${schema_version_seq}")
|
||||
|
||||
if(COMPILE_SCHEMA)
|
||||
# @todo, this appears to be untested at the moment
|
||||
find_package(PythonInterp)
|
||||
|
||||
if(NOT PYTHONINTERP_FOUND)
|
||||
message(
|
||||
FATAL_ERROR
|
||||
"A Python interpreter is necessary when COMPILE_SCHEMA is enabled. Disable COMPILE_SCHEMA or fix Python paths to proceed."
|
||||
)
|
||||
message(FATAL_ERROR "A Python interpreter is necessary when COMPILE_SCHEMA is enabled. Disable COMPILE_SCHEMA or fix Python paths to proceed.")
|
||||
endif()
|
||||
|
||||
set(IFC_RELEASE_NOT_USED ${SCHEMA_VERSIONS})
|
||||
@@ -535,10 +474,7 @@ if(COMPILE_SCHEMA)
|
||||
|
||||
if("${PYPARSING_FOUND}" STREQUAL "-1")
|
||||
message(STATUS "Installing pyparsing")
|
||||
execute_process(
|
||||
COMMAND ${PYTHON_EXECUTABLE} -m pip "install" --user pyparsing
|
||||
RESULT_VARIABLE SUCCESS
|
||||
)
|
||||
execute_process(COMMAND ${PYTHON_EXECUTABLE} -m pip "install" --user pyparsing RESULT_VARIABLE SUCCESS)
|
||||
|
||||
if(NOT "${SUCCESS}" STREQUAL "0")
|
||||
execute_process(COMMAND pip "install" --user pyparsing RESULT_VARIABLE SUCCESS)
|
||||
@@ -553,23 +489,19 @@ if(COMPILE_SCHEMA)
|
||||
|
||||
# Bootstrap the parser
|
||||
message(STATUS "Compiling schema, this will take a while...")
|
||||
execute_process(
|
||||
COMMAND ${PYTHON_EXECUTABLE} bootstrap.py express.bnf
|
||||
execute_process(COMMAND ${PYTHON_EXECUTABLE} bootstrap.py express.bnf
|
||||
WORKING_DIRECTORY ../src/ifcexpressparser
|
||||
OUTPUT_FILE express_parser.py
|
||||
RESULT_VARIABLE SUCCESS
|
||||
)
|
||||
RESULT_VARIABLE SUCCESS)
|
||||
|
||||
if(NOT "${SUCCESS}" STREQUAL "0")
|
||||
message(FATAL_ERROR "Failed to bootstrap parser. Make sure pyparsing is installed")
|
||||
endif()
|
||||
|
||||
# Generate code
|
||||
execute_process(
|
||||
COMMAND ${PYTHON_EXECUTABLE} ../ifcexpressparser/express_parser.py ../../${COMPILE_SCHEMA}
|
||||
execute_process(COMMAND ${PYTHON_EXECUTABLE} ../ifcexpressparser/express_parser.py ../../${COMPILE_SCHEMA}
|
||||
WORKING_DIRECTORY ../src/ifcparse
|
||||
OUTPUT_VARIABLE COMPILED_SCHEMA_NAME
|
||||
)
|
||||
OUTPUT_VARIABLE COMPILED_SCHEMA_NAME)
|
||||
|
||||
# Prevent the schema that had just been compiled from being excluded
|
||||
foreach(schema ${SCHEMA_VERSIONS})
|
||||
@@ -584,17 +516,40 @@ if(NOT Boost_VERSION LESS 105800)
|
||||
add_definitions(-DBOOST_OPTIONAL_USE_OLD_DEFINITION_OF_NONE)
|
||||
endif()
|
||||
|
||||
add_subdirectory(../src/plugin plugin)
|
||||
|
||||
add_subdirectory(../src/ifcparse ifcparse)
|
||||
set(IFCOPENSHELL_LIBRARIES IfcParse)
|
||||
|
||||
if(BUILD_IFCPARSE_EXPERIMENTAL_WRAPPER)
|
||||
add_subdirectory(../src/wrappergen wrappergen)
|
||||
endif()
|
||||
|
||||
if(BUILD_IFCGEOM)
|
||||
# CGAL::CGAL target already has dependencies resolved.
|
||||
if(WITH_CGAL AND CGAL_DIR)
|
||||
set(CGAL_LIBRARIES CGAL::CGAL)
|
||||
message(STATUS "Using found CGAL package at '${CGAL_DIR}'")
|
||||
elseif(WITH_CGAL AND NOT CGAL_DIR)
|
||||
find_library(libGMP NAMES gmp mpir PATHS ${GMP_LIBRARY_DIR} NO_DEFAULT_PATH)
|
||||
find_library(libMPFR NAMES mpfr PATHS ${MPFR_LIBRARY_DIR} NO_DEFAULT_PATH)
|
||||
if(NOT libGMP)
|
||||
message(FATAL_ERROR "Unable to find GMP library files, aborting")
|
||||
endif()
|
||||
if(NOT libMPFR)
|
||||
message(FATAL_ERROR "Unable to find MPFR library files, aborting")
|
||||
endif()
|
||||
|
||||
list(APPEND CGAL_LIBRARIES "${libMPFR}")
|
||||
list(APPEND CGAL_LIBRARIES "${libGMP}")
|
||||
endif()
|
||||
|
||||
add_subdirectory(../src/ifcgeom ifcgeom)
|
||||
endif(BUILD_IFCGEOM)
|
||||
|
||||
if(BUILD_CONVERT OR BUILD_IFCPYTHON)
|
||||
if(BUILD_CONVERT OR BUILD_IFCPYTHON OR BUILD_BONSAIVIEWER)
|
||||
add_subdirectory(../src/serializers serializers)
|
||||
set(IFCOPENSHELL_LIBRARIES ${IFCOPENSHELL_LIBRARIES} ${SERIALIZER_SCHEMA_LIBRARIES})
|
||||
endif(BUILD_CONVERT OR BUILD_IFCPYTHON)
|
||||
endif(BUILD_CONVERT OR BUILD_IFCPYTHON OR BUILD_BONSAIVIEWER)
|
||||
|
||||
if(BUILD_CONVERT)
|
||||
add_subdirectory(../src/ifcconvert ifcconvert)
|
||||
@@ -613,8 +568,8 @@ if(ADD_COMMIT_SHA)
|
||||
endif()
|
||||
|
||||
if(GIT_FOUND)
|
||||
if(VERSION_OVERRIDE)
|
||||
set(git_branch ${RELEASE_VERSION})
|
||||
if (VERSION_OVERRIDE)
|
||||
set (git_branch ${RELEASE_VERSION})
|
||||
else()
|
||||
message("git found: ${GIT_EXECUTABLE} with version ${GIT_VERSION_STRING}")
|
||||
execute_process(
|
||||
@@ -626,8 +581,8 @@ if(ADD_COMMIT_SHA)
|
||||
string(REPLACE "\n" ";" git_branch_list "${git_branches}")
|
||||
|
||||
foreach(git_branch_candidate IN ITEMS ${git_branch_list})
|
||||
string(REPLACE "*" "" git_branch_candidate_temp "${git_branch_candidate}")
|
||||
string(STRIP "${git_branch_candidate_temp}" git_branch_candidate_2)
|
||||
string(REPLACE "*" "" git_branch_candidate_temp "${git_branch_candidate}")
|
||||
string(STRIP "${git_branch_candidate_temp}" git_branch_candidate_2)
|
||||
if(NOT git_branch_candidate_2 MATCHES "^HEAD$")
|
||||
string(REPLACE "/" ";" git_branch_candidate_2_list "${git_branch_candidate_2}")
|
||||
list(GET git_branch_candidate_2_list -1 git_branch)
|
||||
@@ -645,13 +600,13 @@ if(ADD_COMMIT_SHA)
|
||||
message(STATUS "IfcOpenShell branch: \"${git_branch}\"")
|
||||
message(STATUS "IfcOpenShell commit: \"${git_sha}\"")
|
||||
|
||||
if("${git_branch}" STREQUAL "" OR "${git_sha}" STREQUAL "")
|
||||
if ("${git_branch}" STREQUAL "" OR "${git_sha}" STREQUAL "")
|
||||
message(FATAL_ERROR "Unable to determine commit sha and/or branch")
|
||||
endif()
|
||||
|
||||
target_compile_definitions(
|
||||
IfcParse
|
||||
PRIVATE -DIFCOPENSHELL_BRANCH=${git_branch} -DIFCOPENSHELL_COMMIT=${git_sha}
|
||||
target_compile_definitions(IfcParse PRIVATE
|
||||
-DIFCOPENSHELL_BRANCH=${git_branch}
|
||||
-DIFCOPENSHELL_COMMIT=${git_sha}
|
||||
)
|
||||
endif()
|
||||
endif(ADD_COMMIT_SHA)
|
||||
@@ -669,10 +624,6 @@ if(BUILD_DOCUMENTATION)
|
||||
add_subdirectory(../docs docs)
|
||||
endif()
|
||||
|
||||
if(BUILD_IFCPYTHON)
|
||||
add_subdirectory(../src/ifcwrap ifcwrap)
|
||||
endif()
|
||||
|
||||
if(BUILD_EXAMPLES)
|
||||
add_subdirectory(../src/examples examples)
|
||||
endif()
|
||||
@@ -685,32 +636,82 @@ if(BUILD_IFCPYTHON AND WITH_CGAL)
|
||||
add_subdirectory(../src/svgfill svgfill)
|
||||
endif()
|
||||
|
||||
if(BUILD_IFCPYTHON)
|
||||
add_subdirectory(../src/ifcwrap ifcwrap)
|
||||
endif()
|
||||
|
||||
if(BUILD_QTVIEWER)
|
||||
add_subdirectory(../src/qtviewer qtviewer)
|
||||
endif()
|
||||
|
||||
if(BUILD_IFCGEOM)
|
||||
# install(FILES ${IFCGEOM_H_FILES}
|
||||
# DESTINATION ${INCLUDEDIR}/ifcgeom
|
||||
# )
|
||||
|
||||
install(FILES ${SCHEMA_AGNOSTIC_H_FILES}
|
||||
DESTINATION ${INCLUDEDIR}/ifcgeom
|
||||
)
|
||||
|
||||
file(GLOB SERIALIZATION_H_FILES ../src/ifcgeom/serialization/*.h)
|
||||
install(FILES ${SERIALIZATION_H_FILES}
|
||||
DESTINATION ${INCLUDEDIR}/ifcgeom/serialization
|
||||
)
|
||||
|
||||
foreach(kernel ${GEOMETRY_KERNELS})
|
||||
file(GLOB IFCGEOM_H_FILES ../src/ifcgeom/kernels/${kernel}/*.h)
|
||||
install(FILES ${IFCGEOM_H_FILES}
|
||||
DESTINATION ${INCLUDEDIR}/ifcgeom/kernels/${kernel}
|
||||
)
|
||||
endforeach()
|
||||
|
||||
install(TARGETS ${IFCGEOM_SCHEMA_LIBRARIES} ${kernel_libraries} IfcGeom)
|
||||
endif(BUILD_IFCGEOM)
|
||||
if(BUILD_BONSAIVIEWER)
|
||||
if(BUILD_BONSAIVIEWER_TESTS)
|
||||
# Catch2 v3 — fetched on demand. Test option is OFF by default so the
|
||||
# default build remains offline-capable.
|
||||
include(FetchContent)
|
||||
FetchContent_Declare(
|
||||
Catch2
|
||||
GIT_REPOSITORY https://github.com/catchorg/Catch2.git
|
||||
GIT_TAG v3.5.4
|
||||
GIT_SHALLOW TRUE
|
||||
)
|
||||
FetchContent_MakeAvailable(Catch2)
|
||||
list(APPEND CMAKE_MODULE_PATH ${catch2_SOURCE_DIR}/extras)
|
||||
include(CTest)
|
||||
include(Catch)
|
||||
enable_testing()
|
||||
endif()
|
||||
|
||||
add_subdirectory(../src/ifcviewer ifcviewer)
|
||||
add_subdirectory(../src/ifcviewer-minimal ifcviewer-minimal)
|
||||
add_subdirectory(../src/bonsaiviewer bonsaiviewer)
|
||||
endif()
|
||||
|
||||
# Cmake uninstall target
|
||||
if(NOT TARGET uninstall)
|
||||
configure_file(
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/cmake_uninstall.cmake.in"
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/cmake_uninstall.cmake"
|
||||
IMMEDIATE
|
||||
@ONLY
|
||||
)
|
||||
IMMEDIATE @ONLY)
|
||||
|
||||
add_custom_target(uninstall COMMAND ${CMAKE_COMMAND} -P ${CMAKE_CURRENT_BINARY_DIR}/cmake_uninstall.cmake)
|
||||
add_custom_target(uninstall
|
||||
COMMAND ${CMAKE_COMMAND} -P ${CMAKE_CURRENT_BINARY_DIR}/cmake_uninstall.cmake)
|
||||
endif()
|
||||
|
||||
# Packaging
|
||||
list(APPEND CPACK_SOURCE_IGNORE_FILES "/\\\\.git" "/build/" "/.pytest_cache/" "/__pycache__/")
|
||||
list(APPEND CPACK_SOURCE_IGNORE_FILES
|
||||
"/\\\\.git"
|
||||
"/build/"
|
||||
"/.pytest_cache/"
|
||||
"/__pycache__/"
|
||||
)
|
||||
set(CPACK_SOURCE_INSTALLED_DIRECTORIES "${CMAKE_SOURCE_DIR}/..;/")
|
||||
set(CPACK_PACKAGE_NAME
|
||||
"${PROJECT_NAME}-${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}.${PROJECT_VERSION_PATCH}${EXTRA_VERSION}"
|
||||
)
|
||||
set(CPACK_PACKAGE_NAME "${PROJECT_NAME}-${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}.${PROJECT_VERSION_PATCH}${EXTRA_VERSION}")
|
||||
set(CPACK_SOURCE_PACKAGE_FILE_NAME "${PROJECT_NAME}-${PROJECT_VERSION}${EXTRA_VERSION}")
|
||||
set(CPACK_PACKAGE_FILE_NAME
|
||||
"${PROJECT_NAME}-${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}.${PROJECT_VERSION_PATCH}${EXTRA_VERSION}-${CMAKE_SYSTEM_NAME}"
|
||||
)
|
||||
SET(CPACK_PACKAGE_FILE_NAME "${PROJECT_NAME}-${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}.${PROJECT_VERSION_PATCH}${EXTRA_VERSION}-${CMAKE_SYSTEM_NAME}")
|
||||
set(CPACK_PACKAGE_DIRECTORY "${PROJECT_BINARY_DIR}/assets")
|
||||
set(CPACK_PACKAGE_DESCRIPTION_SUMMARY "IfcOpenShell")
|
||||
set(CPACK_PACKAGE_DESCRIPTION "IfcOpenShell.")
|
||||
@@ -723,7 +724,6 @@ set(CPACK_PACKAGE_VERSION_PATCH "${PROJECT_VERSION_PATCH}")
|
||||
set(CPACK_GENERATOR "TGZ;DEB")
|
||||
set(CPACK_SOURCE_GENERATOR "TGZ")
|
||||
|
||||
set(BOOST_DEPS "")
|
||||
foreach(COMPONENT IN ITEMS ${BOOST_COMPONENTS})
|
||||
string(REPLACE "_" "-" COMP ${COMPONENT})
|
||||
set(BOOST_DEPS "${BOOST_DEPS}, libboost-${COMP}-dev")
|
||||
@@ -731,19 +731,13 @@ endforeach(COMPONENT)
|
||||
|
||||
set(CPACK_DEBIAN_PACKAGE_NAME "${PROJECT_NAME}")
|
||||
set(CPACK_DEBIAN_PACKAGE_MAINTAINER "${CPACK_PACKAGE_CONTACT}")
|
||||
set(CPACK_DEBIAN_PACKAGE_DEPENDS
|
||||
"python3, libxml2, libocct-foundation-dev, libocct-modeling-algorithms-dev, libocct-modeling-data-dev, libocct-ocaf-dev, libocct-visualization-dev, libocct-data-exchange-dev, libhdf5-serial-dev, libpython3-dev, python3-pytest ${BOOST_DEPS}"
|
||||
)
|
||||
set(CPACK_DEBIAN_PACKAGE_DEPENDS "python3, libxml2, libocct-foundation-dev, libocct-modeling-algorithms-dev, libocct-modeling-data-dev, libocct-ocaf-dev, libocct-visualization-dev, libocct-data-exchange-dev, libpython3-dev, python3-pytest ${BOOST_DEPS}")
|
||||
set(CPACK_DEBIAN_PACKAGE_DESCRIPTION_SUMMARY "${CPACK_PACKAGE_DESCRIPTION_SUMMARY}")
|
||||
set(CPACK_DEBIAN_PACKAGE_DESCRIPTION "${CPACK_PACKAGE_DESCRIPTION}")
|
||||
set(CPACK_DEBIAN_PACKAGE_PRIORITY "optional")
|
||||
set(CPACK_DEBIAN_PACKAGE_SECTION "science")
|
||||
set(CPACK_DEBIAN_PACKAGE_VERSION
|
||||
"${CPACK_PACKAGE_VERSION_MAJOR}.${CPACK_PACKAGE_VERSION_MINOR}.${CPACK_PACKAGE_VERSION_PATCH}${EXTRA_VERSION}"
|
||||
)
|
||||
set(CPACK_DEBIAN_PACKAGE_VERSION "${CPACK_PACKAGE_VERSION_MAJOR}.${CPACK_PACKAGE_VERSION_MINOR}.${CPACK_PACKAGE_VERSION_PATCH}${EXTRA_VERSION}")
|
||||
set(CPACK_DEBIAN_ARCHITECTURE "${CMAKE_SYSTEM_PROCESSOR}")
|
||||
# set(CPACK_DEBIAN_PACKAGE_CONTROL_EXTRA "${CMAKE_SOURCE_DIR}/cmake/debian/postinst")
|
||||
|
||||
include(CPack)
|
||||
|
||||
include(package_export.cmake)
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
"BUILD_CONVERT": "ON",
|
||||
"BUILD_IFCMAX": "OFF",
|
||||
"IFCXML_SUPPORT": "ON",
|
||||
"HDF5_SUPPORT": "ON",
|
||||
"SCHEMA_VERSIONS": "4x3_add2",
|
||||
"CMAKE_GENERATOR_PLATFORM": "",
|
||||
"CMAKE_GENERATOR_TOOLSET": ""
|
||||
@@ -50,8 +49,6 @@
|
||||
"MPFR_LIBRARY_DIR": "$env{LIBRARY_PREFIX}/lib",
|
||||
"Boost_LIBRARY_DIR": "$env{LIBRARY_PREFIX}/lib",
|
||||
"Boost_INCLUDE_DIR": "$env{LIBRARY_PREFIX}/include",
|
||||
"HDF5_INCLUDE_DIR": "$env{LIBRARY_PREFIX}/include",
|
||||
"HDF5_LIBRARY_DIR": "$env{LIBRARY_PREFIX}/lib",
|
||||
"ZLIB_INCLUDE_DIR": "$env{LIBRARY_PREFIX}/include"
|
||||
}
|
||||
},
|
||||
@@ -110,4 +107,4 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
# - `GMP_LIBRARY_DIR`
|
||||
# - `MPFR_INCLUDE_DIR`
|
||||
# - `MPFR_LIBRARY_DIR`
|
||||
# If input variables are not specified, try to find HDF5 config.
|
||||
# If input variables are not specified, try to find CGAL config.
|
||||
# Input variables could also be provided as environment variables.
|
||||
#
|
||||
# Output targets:
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
#
|
||||
# Input variables:
|
||||
# - `HDF5_INCLUDE_DIR`
|
||||
# - `HDF5_LIBRARY_DIR`
|
||||
# - `HDF5_LIBRARIES`
|
||||
# If input variables are not specified, try to find HDF5 config.
|
||||
# Input variables could also be provided as environment variables.
|
||||
#
|
||||
# Output variables:
|
||||
# - `HDF5_INCLUDE_DIR`
|
||||
# - `HDF5_LIBRARY_DIR`
|
||||
# - `HDF5_LIBRARIES`
|
||||
#
|
||||
|
||||
UNIFY_ENVVARS_AND_CACHE(HDF5_INCLUDE_DIR)
|
||||
UNIFY_ENVVARS_AND_CACHE(HDF5_LIBRARY_DIR)
|
||||
UNIFY_ENVVARS_AND_CACHE(HDF5_LIBRARIES)
|
||||
|
||||
# To avoid cyclic calls to this file
|
||||
list(REMOVE_ITEM CMAKE_MODULE_PATH ${CMAKE_SOURCE_DIR})
|
||||
|
||||
if(NOT HDF5_INCLUDE_DIR)
|
||||
message(STATUS "No HDF5 include directory specified")
|
||||
else()
|
||||
set(HDF5_INCLUDE_DIR "${HDF5_INCLUDE_DIR}" CACHE FILEPATH "HDF5 header files")
|
||||
endif()
|
||||
|
||||
if(NOT HDF5_LIBRARY_DIR)
|
||||
message(STATUS "No HDF5 library directory specified")
|
||||
else()
|
||||
set(HDF5_LIBRARY_DIR "${HDF5_LIBRARY_DIR}" CACHE FILEPATH "HDF5 library files")
|
||||
endif()
|
||||
|
||||
if(HDF5_LIBRARY_DIR)
|
||||
# result of the HDF5 ctest package
|
||||
# Find zlib using cmake find_library. How should this be implemented?
|
||||
# FIND_LIBRARY(NAMES z libz libz_debug PATHS ... NO_DEFAULT_PATH)
|
||||
if(NOT DEFINED ENV{CONDA_BUILD})
|
||||
# result of the HDF5 ctest package
|
||||
if(WIN32)
|
||||
set(zlib_post lib)
|
||||
set(lib_ext lib)
|
||||
else()
|
||||
set(lib_ext a)
|
||||
endif()
|
||||
|
||||
if("${CMAKE_BUILD_TYPE}" STREQUAL "Debug")
|
||||
set(debug_postfix "_debug")
|
||||
endif()
|
||||
|
||||
set(HDF5_LIBRARIES
|
||||
"${HDF5_LIBRARY_DIR}/libhdf5_cpp${debug_postfix}.${lib_ext}"
|
||||
"${HDF5_LIBRARY_DIR}/libhdf5${debug_postfix}.${lib_ext}"
|
||||
"${HDF5_LIBRARY_DIR}/libz${zlib_post}${debug_postfix}.${lib_ext}"
|
||||
"${HDF5_LIBRARY_DIR}/libsz${debug_postfix}.${lib_ext}"
|
||||
"${HDF5_LIBRARY_DIR}/libaec${debug_postfix}.${lib_ext}"
|
||||
)
|
||||
else()
|
||||
message(STATUS "Packaging hdf5 and zlib for conda distribution")
|
||||
|
||||
if(${CMAKE_SYSTEM_NAME} MATCHES "Darwin")
|
||||
# macOS
|
||||
set(zlib_post libz)
|
||||
set(lib_ext dylib)
|
||||
set(HDF5_LIBRARIES
|
||||
"${HDF5_LIBRARY_DIR}/libhdf5_cpp.${lib_ext}"
|
||||
"${HDF5_LIBRARY_DIR}/libhdf5.${lib_ext}"
|
||||
"${HDF5_LIBRARY_DIR}/${zlib_post}.${lib_ext}"
|
||||
)
|
||||
else()
|
||||
# linux and windows
|
||||
# Find HDF5 package
|
||||
find_package(HDF5 REQUIRED COMPONENTS C CXX)
|
||||
# Find ZLIB package
|
||||
find_package(ZLIB REQUIRED)
|
||||
# Include directories
|
||||
include_directories(${HDF5_INCLUDE_DIRS} ${ZLIB_INCLUDE_DIRS})
|
||||
# Link libraries
|
||||
set(HDF5_LIBRARIES ${HDF5_LIBRARIES} ${ZLIB_LIBRARIES})
|
||||
message(STATUS "HDF5 libraries: ${HDF5_LIBRARIES}")
|
||||
endif()
|
||||
endif()
|
||||
endif()
|
||||
|
||||
if(NOT HDF5_INCLUDE_DIR OR NOT HDF5_LIBRARY_DIR)
|
||||
# First try to find it as a config.
|
||||
find_package(HDF5 CONFIG)
|
||||
mark_as_advanced(HDF5_DIR)
|
||||
if(HDF5_DIR)
|
||||
message(STATUS "HDF5: found config at '${HDF5_DIR}'.")
|
||||
set(HDF5_LIBRARIES hdf5_cpp-static)
|
||||
else()
|
||||
# If it failed, still try to find as a module.
|
||||
# E.g. on Ubuntu `libhdf5-dev` doesn't provie hdf5-config.cmake.
|
||||
# Will automatically fill HDF5_LIBRARIES and HDF5_INCLUDE_DIR.
|
||||
find_package(HDF5 COMPONENTS CXX)
|
||||
if(NOT HDF5_INCLUDE_DIR)
|
||||
message(
|
||||
FATAL_ERROR
|
||||
"HDF5_INCLUDE_DIR is not provided (current value: '${HDF5_INCLUDE_DIR}'). "
|
||||
"HDF5_LIBRARY_DIR is not provided (current value: '${HDF5_LIBRARY_DIR}'). "
|
||||
"Also could not find HDF5 package (neither module or config)."
|
||||
)
|
||||
endif()
|
||||
endif()
|
||||
endif()
|
||||
|
||||
# Restore module path.
|
||||
list(PREPEND CMAKE_MODULE_PATH ${CMAKE_SOURCE_DIR})
|
||||
@@ -139,7 +139,8 @@ if(NOT OpenCOLLADA_DIR)
|
||||
endif()
|
||||
endif(NOT OpenCOLLADA_DIR)
|
||||
|
||||
if(OPENCOLLADA_FOUND)
|
||||
add_definitions(-DWITH_OPENCOLLADA)
|
||||
set(SWIG_DEFINES ${SWIG_DEFINES} -DWITH_OPENCOLLADA)
|
||||
if(OPENCOLLADA_FOUND AND NOT TARGET OpenCOLLADA::OpenCOLLADA)
|
||||
add_library(OpenCOLLADA::OpenCOLLADA INTERFACE IMPORTED)
|
||||
target_include_directories(OpenCOLLADA::OpenCOLLADA INTERFACE ${OPENCOLLADA_INCLUDE_DIRS})
|
||||
target_link_libraries(OpenCOLLADA::OpenCOLLADA INTERFACE ${OPENCOLLADA_LIBRARIES})
|
||||
endif()
|
||||
|
||||
+12
-6
@@ -6,7 +6,7 @@
|
||||
# Input variables could also be provided as environment variables.
|
||||
#
|
||||
# Output targets:
|
||||
# - `PROJ::proj`
|
||||
# - `proj::proj`
|
||||
#
|
||||
|
||||
# To avoid cyclic calls to this file
|
||||
@@ -34,10 +34,12 @@ if((NOT PROJ_INCLUDE_DIR AND NOT PROJ_LIBRARIES))
|
||||
message(FATAL_ERROR "Unable to find PROJ libraries in: ${PROJ_LIBRARY_DIR}")
|
||||
endif()
|
||||
|
||||
add_library(PROJ::proj INTERFACE IMPORTED)
|
||||
target_include_directories(PROJ::proj INTERFACE "${PROJ_INCLUDE_DIR}")
|
||||
target_link_libraries(PROJ::proj INTERFACE ${PROJ_LIBRARIES})
|
||||
target_link_directories(PROJ::proj INTERFACE "${PROJ_LIBRARY}")
|
||||
if(NOT TARGET proj::proj)
|
||||
add_library(proj::proj INTERFACE IMPORTED)
|
||||
target_include_directories(proj::proj INTERFACE "${PROJ_INCLUDE_DIR}")
|
||||
target_link_libraries(proj::proj INTERFACE ${PROJ_LIBRARIES})
|
||||
target_link_directories(proj::proj INTERFACE "${PROJ_LIBRARY}")
|
||||
endif()
|
||||
endif()
|
||||
else()
|
||||
find_library(PROJ_LIBRARY NAMES proj PATHS ${PROJ_LIBRARY_DIR})
|
||||
@@ -50,7 +52,11 @@ else()
|
||||
|
||||
set(PROJ_INCLUDE_DIR ${PROJ_INCLUDE_DIR} CACHE FILEPATH "PROJ header files")
|
||||
message(STATUS "Looking for PROJ include files in: ${PROJ_INCLUDE_DIR}")
|
||||
include_directories(${PROJ_INCLUDE_DIR})
|
||||
if(NOT TARGET proj::proj)
|
||||
add_library(proj::proj INTERFACE IMPORTED)
|
||||
target_include_directories(proj::proj INTERFACE "${PROJ_INCLUDE_DIR}")
|
||||
target_link_libraries(proj::proj INTERFACE ${PROJ_LIBRARIES})
|
||||
endif()
|
||||
endif()
|
||||
|
||||
list(PREPEND CMAKE_MODULE_PATH ${CMAKE_SOURCE_DIR})
|
||||
|
||||
@@ -64,7 +64,6 @@ set(USD_LIBRARIES
|
||||
find_library(USD_LIBRARY NAMES ${USD_LIBRARIES} PATHS ${USD_LIBRARY_DIR})
|
||||
if(USD_LIBRARY)
|
||||
message(STATUS "USD libraries ${USD_LIBRARIES} found in: ${USD_LIBRARY_DIR}")
|
||||
link_directories(${USD_LIBRARY_DIR})
|
||||
else()
|
||||
message(FATAL_ERROR "Unable to find USD libraries in: ${USD_LIBRARY_DIR}")
|
||||
endif()
|
||||
@@ -82,5 +81,3 @@ if(MSVC)
|
||||
endif()
|
||||
|
||||
target_compile_definitions(pxr::USD INTERFACE PXR_STATIC WITH_USD)
|
||||
|
||||
set(SWIG_DEFINES ${SWIG_DEFINES} -DWITH_USD)
|
||||
|
||||
@@ -41,6 +41,108 @@ macro(SET_INSTALL_RPATHS _target _paths)
|
||||
set_target_properties(${_target} PROPERTIES INSTALL_RPATH "${${_target}_rpaths}")
|
||||
endmacro()
|
||||
|
||||
function(ifcopenshell_plugin_target TARGET)
|
||||
set_target_properties(${TARGET} PROPERTIES PREFIX "")
|
||||
endfunction()
|
||||
|
||||
function(ifcopenshell_wasm_plugin_link_options TARGET REGISTRATION_SYMBOL)
|
||||
ifcopenshell_plugin_target(${TARGET})
|
||||
|
||||
if(NOT WASM_BUILD)
|
||||
return()
|
||||
endif()
|
||||
|
||||
cmake_parse_arguments(PLUGIN "" "OPTIMIZATION" "" ${ARGN})
|
||||
if(NOT PLUGIN_OPTIMIZATION)
|
||||
set(PLUGIN_OPTIMIZATION -O1)
|
||||
endif()
|
||||
|
||||
set(plugin_symbols
|
||||
ifcopenshell_plugin_abi_v1
|
||||
ifcopenshell_plugin_metadata_v1
|
||||
${REGISTRATION_SYMBOL}
|
||||
)
|
||||
|
||||
target_link_options(${TARGET} PRIVATE "SHELL:-s SIDE_MODULE=2" ${PLUGIN_OPTIMIZATION})
|
||||
foreach(symbol IN LISTS plugin_symbols)
|
||||
target_link_options(${TARGET} PRIVATE "LINKER:--export=${symbol}")
|
||||
endforeach()
|
||||
endfunction()
|
||||
|
||||
function(ifcopenshell_deploy_qt_runtime TARGET)
|
||||
if(NOT IFCOPENSHELL_DEPLOY_QT_RUNTIME)
|
||||
return()
|
||||
endif()
|
||||
|
||||
if(NOT TARGET ${TARGET})
|
||||
message(FATAL_ERROR "Cannot deploy Qt runtime for unknown target '${TARGET}'.")
|
||||
endif()
|
||||
|
||||
get_target_property(target_type ${TARGET} TYPE)
|
||||
if(NOT target_type STREQUAL "EXECUTABLE")
|
||||
message(FATAL_ERROR "Qt runtime deployment target '${TARGET}' is not an executable.")
|
||||
endif()
|
||||
|
||||
if(NOT DEFINED QT_DEFAULT_MAJOR_VERSION)
|
||||
if(DEFINED QT_VERSION)
|
||||
set(QT_DEFAULT_MAJOR_VERSION ${QT_VERSION})
|
||||
else()
|
||||
set(QT_DEFAULT_MAJOR_VERSION 6)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
if(NOT TARGET Qt${QT_DEFAULT_MAJOR_VERSION}::Core)
|
||||
set(qt_find_args Qt${QT_DEFAULT_MAJOR_VERSION} COMPONENTS Core REQUIRED)
|
||||
if(DEFINED QT_DIR AND NOT QT_DIR STREQUAL "")
|
||||
list(APPEND qt_find_args PATHS ${QT_DIR})
|
||||
endif()
|
||||
find_package(${qt_find_args})
|
||||
endif()
|
||||
|
||||
if(COMMAND _qt_internal_setup_deploy_support)
|
||||
if(NOT DEFINED QT_CMAKE_EXPORT_NAMESPACE AND TARGET Qt${QT_DEFAULT_MAJOR_VERSION}::Core)
|
||||
set(QT_CMAKE_EXPORT_NAMESPACE Qt${QT_DEFAULT_MAJOR_VERSION})
|
||||
endif()
|
||||
|
||||
if(QT_DEFAULT_MAJOR_VERSION EQUAL 6 AND TARGET Qt6::Core)
|
||||
get_target_property(qt_core_type Qt6::Core TYPE)
|
||||
if(qt_core_type STREQUAL "SHARED_LIBRARY")
|
||||
set(QT6_IS_SHARED_LIBS_BUILD ON)
|
||||
else()
|
||||
set(QT6_IS_SHARED_LIBS_BUILD OFF)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
_qt_internal_setup_deploy_support()
|
||||
endif()
|
||||
|
||||
set(deploy_args
|
||||
TARGET ${TARGET}
|
||||
OUTPUT_SCRIPT deploy_script
|
||||
NO_UNSUPPORTED_PLATFORM_ERROR
|
||||
)
|
||||
|
||||
if(NOT IFCOPENSHELL_DEPLOY_QT_TRANSLATIONS)
|
||||
list(APPEND deploy_args NO_TRANSLATIONS)
|
||||
endif()
|
||||
|
||||
list(APPEND deploy_args ${ARGN})
|
||||
|
||||
if(COMMAND qt_generate_deploy_app_script)
|
||||
qt_generate_deploy_app_script(${deploy_args})
|
||||
elseif(COMMAND qt6_generate_deploy_app_script)
|
||||
qt6_generate_deploy_app_script(${deploy_args})
|
||||
else()
|
||||
message(WARNING
|
||||
"Qt runtime deployment requested for '${TARGET}', but this Qt version "
|
||||
"does not provide qt_generate_deploy_app_script()."
|
||||
)
|
||||
return()
|
||||
endif()
|
||||
|
||||
install(SCRIPT ${deploy_script})
|
||||
endfunction()
|
||||
|
||||
# Get a list of all OPTION flags from the CMakeLists.txt and store in an output LIST
|
||||
function(get_all_option_flags output_list)
|
||||
# Read the contents of the CMakeLists.txt
|
||||
|
||||
@@ -22,9 +22,6 @@ cmake -G "Ninja" ^
|
||||
-D GMP_LIBRARY_DIR:FILEPATH="%LIBRARY_PREFIX%\lib" ^
|
||||
-D MPFR_LIBRARY_DIR:FILEPATH="%LIBRARY_PREFIX%\lib" ^
|
||||
-D COLLADA_SUPPORT=OFF ^
|
||||
-D HDF5_SUPPORT=ON ^
|
||||
-D HDF5_INCLUDE_DIR="%LIBRARY_PREFIX%\include" ^
|
||||
-D HDF5_LIBRARY_DIR="%LIBRARY_PREFIX%\lib" ^
|
||||
-D JSON_INCLUDE_DIR="%LIBRARY_PREFIX%\include" ^
|
||||
-D PYTHON_INCLUDE_DIR=%PREFIX%\include ^
|
||||
-D PYTHON_EXECUTABLE:FILEPATH=%PREFIX%\python.exe ^
|
||||
@@ -37,7 +34,6 @@ cmake -G "Ninja" ^
|
||||
-D GLTF_SUPPORT:BOOL=ON ^
|
||||
-D BUILD_CONVERT:BOOL=ON ^
|
||||
-D BUILD_IFCMAX:BOOL=OFF ^
|
||||
-D IFCXML_SUPPORT:BOOL=ON ^
|
||||
-D Boost_LIBRARY_DIR:FILEPATH="%LIBRARY_PREFIX%\lib" ^
|
||||
-D Boost_INCLUDE_DIR:FILEPATH="%LIBRARY_PREFIX%\include" ^
|
||||
-D Boost_USE_STATIC_LIBS:BOOL=OFF ^
|
||||
|
||||
+1
-5
@@ -24,9 +24,6 @@ cmake ${CMAKE_ARGS} -G Ninja \
|
||||
-DMPFR_LIBRARY_DIR=$PREFIX/lib \
|
||||
-DOCC_INCLUDE_DIR=$PREFIX/include/opencascade \
|
||||
-DOCC_LIBRARY_DIR=$PREFIX/lib \
|
||||
-DHDF5_SUPPORT:BOOL=ON \
|
||||
-DHDF5_INCLUDE_DIR=$PREFIX/include \
|
||||
-DHDF5_LIBRARY_DIR=$PREFIX/lib \
|
||||
-DJSON_INCLUDE_DIR=$PREFIX/include \
|
||||
-DCGAL_INCLUDE_DIR=$PREFIX/include \
|
||||
-DLIBXML2_INCLUDE_DIR=$PREFIX/include/libxml2 \
|
||||
@@ -34,7 +31,6 @@ cmake ${CMAKE_ARGS} -G Ninja \
|
||||
-DEIGEN_DIR:FILEPATH=$PREFIX/include/eigen3 \
|
||||
-DCOLLADA_SUPPORT:BOOL=OFF \
|
||||
-DBUILD_EXAMPLES:BOOL=OFF \
|
||||
-DIFCXML_SUPPORT:BOOL=ON \
|
||||
-DGLTF_SUPPORT:BOOL=ON \
|
||||
-DBUILD_CONVERT:BOOL=ON \
|
||||
-DBUILD_IFCPYTHON:BOOL=ON \
|
||||
@@ -47,4 +43,4 @@ ninja
|
||||
|
||||
ninja install -j 1
|
||||
|
||||
python "${RECIPE_DIR}/update_version_init.py" "${PKG_VERSION}" "${SP_DIR}/ifcopenshell/__init__.py"
|
||||
python "${RECIPE_DIR}/update_version_init.py" "${PKG_VERSION}" "${SP_DIR}/ifcopenshell/__init__.py"
|
||||
|
||||
@@ -26,8 +26,6 @@ c_stdlib_version:
|
||||
- 2.17 # [linux]
|
||||
- 10.13 # [osx and x86_64]
|
||||
- 11.0 # [osx and arm64]
|
||||
hdf5:
|
||||
- 1.14.6
|
||||
libboost_devel:
|
||||
- '1.86'
|
||||
libxml2:
|
||||
|
||||
@@ -33,7 +33,6 @@ requirements:
|
||||
- occt
|
||||
- libxml2
|
||||
- cgal-cpp
|
||||
- hdf5
|
||||
- eigen
|
||||
- mpfr
|
||||
- nlohmann_json
|
||||
|
||||
+317
@@ -0,0 +1,317 @@
|
||||
# Build fix: remove `boost_system` from CMake components
|
||||
|
||||
`Boost.System` became header-only in Boost 1.69. Boost 1.90.0 no longer ships a compiled library or CMake config for it, so `find_package(Boost REQUIRED COMPONENTS system ...)` fails.
|
||||
|
||||
## Fix
|
||||
|
||||
`cmake/CMakeLists.txt`:
|
||||
|
||||
```diff
|
||||
- set(BOOST_COMPONENTS system program_options regex thread date_time iostreams)
|
||||
+ set(BOOST_COMPONENTS program_options regex thread date_time iostreams)
|
||||
```
|
||||
|
||||
The headers are still available; no linking is needed.
|
||||
|
||||
# Build fix: add `template` keyword for dependent template member calls
|
||||
|
||||
Calling a template member function through a dependent expression (e.g. `storage->has_attribute_value<T>(...)` where `storage`'s type depends on a template parameter) requires the `template` keyword to disambiguate from a less-than comparison.
|
||||
|
||||
## Error
|
||||
|
||||
```
|
||||
src/ifcparse/IfcParse.cpp:1856:67: error: expected primary-expression before '>' token
|
||||
1856 | if (storage->has_attribute_value<express::Base>(attr_index)) {
|
||||
| ^
|
||||
```
|
||||
|
||||
Six identical errors at lines 1856, 1865, 1896, 1905, 1934, 1943.
|
||||
|
||||
## Fix
|
||||
|
||||
`src/ifcparse/IfcParse.cpp`:
|
||||
|
||||
```diff
|
||||
-storage->has_attribute_value<express::Base>(attr_index)
|
||||
+storage->template has_attribute_value<express::Base>(attr_index)
|
||||
|
||||
-storage->has_attribute_value<Blank>(attr_index)
|
||||
+storage->template has_attribute_value<Blank>(attr_index)
|
||||
```
|
||||
|
||||
Applied at all six call sites in `in_memory_file_storage::read_from_stream`.
|
||||
|
||||
# Linker fix: missing explicit template instantiations for `InstanceStreamer`
|
||||
|
||||
`InstanceStreamer` is a class template with methods defined in `IfcParse.cpp`, not the header. Without explicit instantiations, the linker can't find the symbols when the SWIG wrapper loads.
|
||||
|
||||
## Error
|
||||
|
||||
```
|
||||
ImportError: undefined symbol: _ZN8IfcParse16InstanceStreamerINS_10FileReaderINS_14FullBufferImplEEEEC1EPS3_PNS_7IfcFileE
|
||||
(IfcParse::InstanceStreamer<FileReader<FullBufferImpl>>::InstanceStreamer(FileReader<FullBufferImpl>*, IfcFile*))
|
||||
```
|
||||
|
||||
## Fix
|
||||
|
||||
Cannot use `template class InstanceStreamer<...>` because some constructors have `static_assert` guards that reject certain reader types. Instead, instantiate each member function individually per reader type, only including the constructors valid for that type.
|
||||
|
||||
`src/ifcparse/IfcParse.cpp` (after the last `InstanceStreamer` method definition):
|
||||
|
||||
```cpp
|
||||
// FullBufferImpl
|
||||
template IfcParse::InstanceStreamer<FileReader<FullBufferImpl>>::InstanceStreamer(IfcParse::IfcFile*);
|
||||
template IfcParse::InstanceStreamer<FileReader<FullBufferImpl>>::InstanceStreamer(const std::string&, bool, IfcParse::IfcFile*);
|
||||
template IfcParse::InstanceStreamer<FileReader<FullBufferImpl>>::InstanceStreamer(void*, int, IfcParse::IfcFile*);
|
||||
template IfcParse::InstanceStreamer<FileReader<FullBufferImpl>>::InstanceStreamer(FileReader<FullBufferImpl>*, IfcParse::IfcFile*);
|
||||
// ... plus ensure_header, initialize_header, hasSemicolon, semicolonCount,
|
||||
// pushPage, bypassTypes, readInstance
|
||||
|
||||
// PushedSequentialImpl — same pattern, different valid constructors
|
||||
|
||||
// MMapFileReader (ifdef USE_MMAP) — same pattern
|
||||
```
|
||||
|
||||
# Linker fix: `FullBufferImpl` missing buffer constructor
|
||||
|
||||
SWIG's `stream_from_string` calls `InstanceStreamer<FileReader<FullBufferImpl>>(void*, int, IfcFile*)`, but the `(void*, int)` constructor previously hit a `static_assert` for `FullBufferImpl` — it only allowed `PushedSequentialImpl`.
|
||||
|
||||
## Error
|
||||
|
||||
```
|
||||
ImportError: undefined symbol: _ZN8IfcParse16InstanceStreamerINS_10FileReaderINS_14FullBufferImplEEEEC1EPviPNS_7IfcFileE
|
||||
(InstanceStreamer<FileReader<FullBufferImpl>>::InstanceStreamer(void*, int, IfcFile*))
|
||||
```
|
||||
|
||||
## Fix
|
||||
|
||||
Three changes to make `FullBufferImpl` support buffer-based and default construction:
|
||||
|
||||
`src/ifcparse/FileReader.h` — add buffer constructor to `FullBufferImpl`:
|
||||
|
||||
```diff
|
||||
class IFC_PARSE_API FullBufferImpl {
|
||||
public:
|
||||
explicit FullBufferImpl(const std::string& fn);
|
||||
+ FullBufferImpl(void* data, size_t length);
|
||||
```
|
||||
|
||||
`src/ifcparse/FileReader.h` — add `FileReader(void*, size_t)` forwarding constructor:
|
||||
|
||||
```diff
|
||||
+ FileReader(void* data, size_t length)
|
||||
+ : cursor_(0) {
|
||||
+ if constexpr (std::is_same_v<Impl, FullBufferImpl>) {
|
||||
+ impl_ = std::make_shared<Impl>(data, length);
|
||||
+ } else {
|
||||
+ static_assert(...);
|
||||
+ }
|
||||
+ }
|
||||
```
|
||||
|
||||
`src/ifcparse/FileReader.cpp` — implement the constructor:
|
||||
|
||||
```cpp
|
||||
FullBufferImpl::FullBufferImpl(void* data, size_t length)
|
||||
: buf_(static_cast<char*>(data), static_cast<char*>(data) + length)
|
||||
, size_(length) {
|
||||
}
|
||||
```
|
||||
|
||||
`src/ifcparse/IfcParse.cpp` — extend the two `InstanceStreamer` constructors to accept `FullBufferImpl`:
|
||||
|
||||
```diff
|
||||
// InstanceStreamer(IfcFile*):
|
||||
+ } else if constexpr (std::is_same_v<Reader, FileReader<FullBufferImpl>>) {
|
||||
+ owned_stream_ = std::make_unique<Reader>(nullptr, (size_t)0);
|
||||
|
||||
// InstanceStreamer(void*, int, IfcFile*):
|
||||
+ } else if constexpr (std::is_same_v<Reader, FileReader<FullBufferImpl>>) {
|
||||
+ owned_stream_ = std::make_unique<Reader>(data, (size_t)length);
|
||||
```
|
||||
|
||||
# Runtime fix: segfault in `parse_context::push()` due to vector reallocation
|
||||
|
||||
`parse_context_pool` stores nodes in a `std::vector<parse_context>`. During parsing, `load()` takes a `parse_context&` parameter and calls `context.push()`, which calls `pool_->make()`. If the pool's vector reallocates (via `emplace_back`), all existing references into the vector — including the `context` reference held by the caller — become dangling. Subsequent access through the dangling reference causes a segfault.
|
||||
|
||||
Triggered by larger IFC files (e.g. `ISSUE_159_kleine_Wohnung_R22.ifc`, 9.5 MB) that cause enough pool growth to trigger reallocation.
|
||||
|
||||
## Error
|
||||
|
||||
```
|
||||
Thread 1 received signal SIGSEGV, Segmentation fault.
|
||||
0x... in IfcParse::parse_context::push()
|
||||
#1 in_memory_file_storage::load(...) // context& is dangling after reallocation
|
||||
#2 in_memory_file_storage::load(...) // parent call
|
||||
#3 InstanceStreamer::readInstance()
|
||||
```
|
||||
|
||||
## Fix
|
||||
|
||||
`src/ifcparse/storage.h` — change the pool container from `std::vector` to `std::deque`, which does not invalidate references on `push_back`/`emplace_back`:
|
||||
|
||||
```diff
|
||||
+#include <deque>
|
||||
|
||||
struct parse_context_pool {
|
||||
- std::vector<parse_context> nodes_;
|
||||
+ std::deque<parse_context> nodes_;
|
||||
```
|
||||
|
||||
# Runtime fix: `express::Base` comparison operators throw on null/expired instances
|
||||
|
||||
`express::Base::operator<` and `operator==` called `data()`, which throws `std::runtime_error("Trying to access deleted instance reference")` when the internal `weak_ptr` is expired. A default-constructed `express::Base` (the value-type equivalent of a null pointer) always has an expired `weak_ptr`.
|
||||
|
||||
## Why this model triggers it
|
||||
|
||||
The bug requires two conditions to coincide:
|
||||
|
||||
1. A representation is shared by **more than one product** (via `IfcRepresentationMap` / `IfcMappedItem`).
|
||||
2. At least one of those products has **no material association**, so `get_single_material_association()` returns `express::Base{}` (the null equivalent).
|
||||
|
||||
In `advanced_model.ifc`, Body representations like `#449` (Body/Brep) have a single `IfcRepresentationMap` (`#453`) with 13 `IfcMappedItem` usages, meaning 13 products share the geometry. Some of those products (e.g. `IfcFlowTerminal` instances) have no `IfcRelAssociatesMaterial`, so `get_single_material_association` returns `express::Base{}`.
|
||||
|
||||
Smaller or simpler models don't hit this because either:
|
||||
- Every representation maps to only 1 product → `reuse_ok_` short-circuits at `products.size() == 1` before reaching the material check.
|
||||
- Every product has a material association → no null `express::Base` is ever inserted into the set.
|
||||
|
||||
## Exact call sequence
|
||||
|
||||
```
|
||||
Iterator::initialize()
|
||||
try {
|
||||
mapping::get_representations(reps, filters_)
|
||||
addRepresentationsFromDefaultContexts(representations)
|
||||
→ collects reps from subcontexts in order:
|
||||
Axis (#115): 143 reps
|
||||
Body (#117): 7550 reps
|
||||
FootPrint (#119): 12 reps
|
||||
|
||||
for (auto representation : representations):
|
||||
|
||||
── Axis reps (indices 0–142) ──────────────────────────
|
||||
products_represented_by(rep, rmap)
|
||||
→ OfProductRepresentation: 1 product each
|
||||
filter_products(products, filters) → 1 product
|
||||
reuse_ok_(ifcproducts)
|
||||
→ products.size() == 1 → return true ← SHORT-CIRCUIT, no material check
|
||||
representation_mapped_to(rep) → null (no MappedItem)
|
||||
→ task created. 143 tasks accumulated.
|
||||
|
||||
── First Body rep #449 (Body/Brep) ────────────────────
|
||||
products_represented_by(#449, rmap)
|
||||
→ OfProductRepresentation: empty
|
||||
→ RepresentationMap: 1 map (#453)
|
||||
→ MapUsage: 13 MappedItems → traces through to 13 IfcProducts
|
||||
filter_products(products, filters) → 13 products
|
||||
reuse_ok_(ifcproducts) ← CRASH HERE
|
||||
→ products.size() == 1? NO (13 products)
|
||||
→ for each product:
|
||||
find_openings(product) → OK
|
||||
get_single_material_association(product)
|
||||
→ some products have no IfcRelAssociatesMaterial
|
||||
→ returns express::Base{} (expired weak_ptr)
|
||||
associated_single_materials.insert(result)
|
||||
→ std::set::insert calls operator<
|
||||
→ operator< calls data()
|
||||
→ data() calls data_.lock() → expired → THROWS
|
||||
"Trying to access deleted instance reference"
|
||||
|
||||
} catch (const std::exception& e) {
|
||||
Logger::Error(e) ← exception caught here, get_representations aborted
|
||||
}
|
||||
|
||||
→ reps contains only the 143 Axis tasks created before the throw
|
||||
→ all 143 Axis reps have Curve2D geometry → map(representation) returns null
|
||||
→ no valid elements produced → initialize() returns false
|
||||
```
|
||||
|
||||
In the old pointer-based code, `reuse_ok_` used `std::set<const IfcUtil::IfcBaseEntity*>` and `get_single_material_association` returned `nullptr`. Inserting `nullptr` into a `std::set<T*>` is a plain pointer comparison — no dereference, no throw. The refactoring to `std::set<express::Base>` changed the comparison from pointer comparison to `express::Base::operator<`, which unconditionally dereferences through `data()`.
|
||||
|
||||
## Error
|
||||
|
||||
```
|
||||
[Error] Trying to access deleted instance reference
|
||||
[Notice] Created 143 tasks for 143 products ← only Axis reps; all Body reps lost
|
||||
initialize() returned: False
|
||||
```
|
||||
|
||||
## Fix
|
||||
|
||||
`src/ifcparse/express.h` — use `weak_ptr::lock().get()` instead of `data()` so that expired pointers compare as `nullptr` (matching old raw-pointer semantics):
|
||||
|
||||
```diff
|
||||
bool operator<(const Base& other) const {
|
||||
- return data() < other.data();
|
||||
+ auto a = data_.lock();
|
||||
+ auto b = other.data_.lock();
|
||||
+ return a.get() < b.get();
|
||||
}
|
||||
|
||||
bool operator==(const Base& other) const {
|
||||
- return data() == other.data();
|
||||
+ auto a = data_.lock();
|
||||
+ auto b = other.data_.lock();
|
||||
+ return a.get() == b.get();
|
||||
}
|
||||
```
|
||||
|
||||
# Runtime fix: `entity_instance` missing `get_inverse` due to SWIG `%rename` collision
|
||||
|
||||
Accessing inverse attributes (e.g. `element.IsDecomposedBy`) on any entity raises `AttributeError: entity instance of type 'IFC2X3.IfcProject' has no attribute 'get_inverse'`.
|
||||
|
||||
## Why
|
||||
|
||||
`entity_instance_mixin.__getattr__` (line 106 of `entity_instance.py`) calls `self.get_inverse(name)` when it detects an inverse attribute. Since the mixin inherits into the SWIG-generated `entity_instance` class (via the `object = custom_base` hack in `IfcParseWrapper.i:936`), `self.get_inverse` must resolve to a method on the SWIG class.
|
||||
|
||||
However, `IfcParseWrapper.i:70` has a global rename:
|
||||
|
||||
```
|
||||
%rename("get_inverses_by_declaration") get_inverse;
|
||||
```
|
||||
|
||||
This was intended for `ifcopenshell::file::get_inverse` (which takes an entity + declaration and returns instances by reference), but SWIG `%rename` is global — it also renames the `%extend express::Base` method `get_inverse(const std::string& a)` at line 551. So the Python-side `entity_instance` class exposes the method as `get_inverses_by_declaration`, not `get_inverse`.
|
||||
|
||||
The old code (`v0.8.0`) didn't hit this because `__getattr__` called `self.wrapped_data.get_inverse(name)` on an inner `ifcopenshell_wrapper.entity_instance` object — but in that old layout, the inner object was constructed differently and the rename didn't apply the same way (or the method had a different path). In the new mixin approach, `self` **is** the SWIG object, so the rename is directly visible.
|
||||
|
||||
## Fix
|
||||
|
||||
`src/ifcwrap/IfcParseWrapper.i` — override the global rename specifically for `express::Base::get_inverse`, restoring the original name on entity instances:
|
||||
|
||||
```diff
|
||||
+%rename("get_inverse") express::Base::get_inverse;
|
||||
%rename("get_inverses_by_declaration") get_inverse;
|
||||
```
|
||||
|
||||
Add this line **before** the global rename (or anywhere before the `%extend express::Base` block). This scoped rename takes precedence for `express::Base`, so:
|
||||
- `entity_instance.get_inverse(name)` works as the mixin expects
|
||||
- `file.get_inverses_by_declaration(...)` keeps its intended name
|
||||
|
||||
## Python-side workaround
|
||||
|
||||
`entity_instance.py:106` — call the method by its SWIG-renamed name:
|
||||
|
||||
```diff
|
||||
- vs = self.get_inverse(name)
|
||||
+ vs = self.get_inverses_by_declaration(name)
|
||||
```
|
||||
|
||||
# Runtime fix: `entity_instance` class no longer importable from `entity_instance` module
|
||||
|
||||
The class rename from `entity_instance` to `entity_instance_mixin` broke external code that does `from ifcopenshell.entity_instance import entity_instance`.
|
||||
|
||||
## Error
|
||||
|
||||
```
|
||||
ImportError: cannot import name 'entity_instance' from 'ifcopenshell.entity_instance'
|
||||
```
|
||||
|
||||
Triggered at import time via `ifcopenshell.util.pset` (and likely other modules).
|
||||
|
||||
## Fix
|
||||
|
||||
`src/ifcopenshell-python/ifcopenshell/entity_instance.py` — add a backwards-compatible alias at the bottom of the module:
|
||||
|
||||
```python
|
||||
entity_instance = entity_instance_mixin
|
||||
```
|
||||
+176
-113
@@ -107,7 +107,6 @@ import logging
|
||||
import multiprocessing
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
|
||||
# @todo temporary for expired mpfr.org certificate on 2023-04-08
|
||||
@@ -142,6 +141,7 @@ logger.addHandler(ch)
|
||||
PROJECT_NAME = "IfcOpenShell"
|
||||
USE_CURRENT_PYTHON_VERSION = os.getenv("USE_CURRENT_PYTHON_VERSION")
|
||||
ADD_COMMIT_SHA = os.getenv("ADD_COMMIT_SHA")
|
||||
BUILD_BONSAIVIEWER = os.getenv("BUILD_BONSAIVIEWER", "").lower() in {"1", "on", "true", "yes"}
|
||||
|
||||
PYTHON_VERSIONS = ["3.10.3", "3.11.8", "3.12.1", "3.13.6", "3.14.0"]
|
||||
JSON_VERSION = "3.11.3"
|
||||
@@ -153,7 +153,6 @@ PCRE_VERSION = "8.41"
|
||||
LIBXML2_VERSION = "2.13.8"
|
||||
SWIG_VERSION = "4.2.1"
|
||||
OPENCOLLADA_VERSION = "v1.6.68"
|
||||
HDF5_VERSION = "1.13.1"
|
||||
|
||||
GMP_VERSION = "6.3.0"
|
||||
MPFR_VERSION = "3.1.6" # latest is 4.1.0
|
||||
@@ -162,6 +161,9 @@ USD_VERSION = "23.05"
|
||||
TBB_VERSION = "2021.9.0"
|
||||
ROCKSDB_VERSION = "9.11.2"
|
||||
ZSTD_VERSION = "1.5.7"
|
||||
MANIFOLD_VERSION = "3.2.1"
|
||||
QT6_VERSION = os.getenv("QT6_VERSION", "6.8.3")
|
||||
|
||||
# binaries
|
||||
cp = "cp"
|
||||
bash = "bash"
|
||||
@@ -329,12 +331,13 @@ cecho(""" - IFC Schemas to compile. If not provided, fallback to default provide
|
||||
""")
|
||||
|
||||
dependency_tree: "dict[str, tuple[str, ...]]" = {
|
||||
"IfcParse": ("boost", "libxml2", "hdf5", "rocksdb"),
|
||||
"IfcGeom": ("IfcParse", "occ", "json", "cgal", "eigen", "OpenCOLLADA"),
|
||||
"IfcParse": ("boost", "libxml2", "rocksdb"),
|
||||
"IfcGeom": ("IfcParse", "occ", "manifold", "json", "cgal", "eigen", "OpenCOLLADA"),
|
||||
"IfcConvert": ("IfcGeom",),
|
||||
"OpenCOLLADA": ("libxml2", "pcre"),
|
||||
"IfcGeomServer": ("IfcGeom",),
|
||||
"IfcOpenShell-Python": ("python", "swig", "IfcGeom"),
|
||||
"BonsaiViewer": ("IfcGeom", "qt6"),
|
||||
"swig": (),
|
||||
"boost": (),
|
||||
"libxml2": (),
|
||||
@@ -342,11 +345,12 @@ dependency_tree: "dict[str, tuple[str, ...]]" = {
|
||||
"occ": (),
|
||||
"pcre": (),
|
||||
"json": (),
|
||||
"hdf5": (),
|
||||
"cgal": (),
|
||||
"eigen": (),
|
||||
"rocksdb": ("zstd",),
|
||||
"zstd": (),
|
||||
"manifold": (),
|
||||
"qt6": (),
|
||||
# 'usd': ('boost', 'oneTBB')
|
||||
}
|
||||
|
||||
@@ -400,9 +404,12 @@ else:
|
||||
targets = set(dependency_tree.keys())
|
||||
|
||||
targets = set(t for t in targets if "without-%s" % t.lower() not in flags)
|
||||
if not explicit_targets and not BUILD_BONSAIVIEWER:
|
||||
targets.difference_update({"BonsaiViewer", "qt6"})
|
||||
if BUILD_BONSAIVIEWER:
|
||||
targets.update(gather_dependencies("BonsaiViewer"))
|
||||
if WASM:
|
||||
SKIP_TARGETS_FOR_WASM = {
|
||||
"hdf5",
|
||||
"rocksdb",
|
||||
"opencollada",
|
||||
"swig",
|
||||
@@ -410,6 +417,8 @@ if WASM:
|
||||
"IfcGeom",
|
||||
"IfcConvert",
|
||||
"IfcGeomServer",
|
||||
"BonsaiViewer",
|
||||
"qt6",
|
||||
}
|
||||
SKIP_TARGETS_FOR_WASM = {t.lower() for t in SKIP_TARGETS_FOR_WASM}
|
||||
skip_targets = {t for t in targets if t.lower() in SKIP_TARGETS_FOR_WASM}
|
||||
@@ -583,6 +592,11 @@ def run_cmake(arg1, cmake_args: "list[str]", cmake_dir: Union[str, None] = None,
|
||||
]
|
||||
)
|
||||
|
||||
if not any("BUILD_SHARED_LIBS" in f for f in cmake_args):
|
||||
cmake_flags.append(
|
||||
f"-DBUILD_SHARED_LIBS={OFF_ON[not BUILD_STATIC]}",
|
||||
)
|
||||
|
||||
run(
|
||||
[
|
||||
*wasm,
|
||||
@@ -591,7 +605,6 @@ def run_cmake(arg1, cmake_args: "list[str]", cmake_dir: Union[str, None] = None,
|
||||
*cmake_flags,
|
||||
*cmake_args,
|
||||
f"-DCMAKE_BUILD_TYPE={BUILD_CFG}",
|
||||
f"-DBUILD_SHARED_LIBS={OFF_ON[not BUILD_STATIC]}",
|
||||
f"-DCMAKE_SHARED_LINKER_FLAGS={os.environ['LDFLAGS']}",
|
||||
],
|
||||
cwd=cwd,
|
||||
@@ -626,7 +639,6 @@ def build_dependency(
|
||||
mode: Literal[
|
||||
"cmake",
|
||||
"autoconf",
|
||||
"ctest",
|
||||
"bjam",
|
||||
],
|
||||
build_tool_args: "list[str]",
|
||||
@@ -733,23 +745,7 @@ def build_dependency(
|
||||
if shell is not None:
|
||||
sp.run(shell, shell=True, check=True, cwd=extract_dir)
|
||||
|
||||
if mode == "ctest":
|
||||
try:
|
||||
run(
|
||||
["ctest", "-S", "HDF5config.cmake,BUILD_GENERATOR=Unix", "-C", BUILD_CFG, "-V", "-O", "hdf5.log"],
|
||||
cwd=extract_dir,
|
||||
)
|
||||
except Exception as e:
|
||||
print("-" * 70)
|
||||
print(open(os.path.join(extract_dir, "hdf5.log")))
|
||||
print("-" * 70)
|
||||
raise e
|
||||
run([tar, "-xf", kwargs["ctest_result"] + ".tar.gz"], cwd=os.path.join(extract_dir, "build"))
|
||||
shutil.copytree(
|
||||
os.path.join(extract_dir, "build", kwargs["ctest_result"], kwargs["ctest_result_path"]),
|
||||
os.path.join(DEPS_DIR, "install", name),
|
||||
)
|
||||
elif mode != "bjam":
|
||||
if mode != "bjam":
|
||||
extract_build_dir = os.path.join(extract_dir, *([cmake_dir] if cmake_dir else []), "build")
|
||||
if os.path.exists(extract_build_dir):
|
||||
shutil.rmtree(extract_build_dir)
|
||||
@@ -788,6 +784,61 @@ def build_dependency(
|
||||
shutil.rmtree(build_dir, ignore_errors=True)
|
||||
|
||||
|
||||
def get_qt6_aqt_config() -> "tuple[str, str, str]":
|
||||
if platform.system() != "Linux":
|
||||
raise ValueError("Automatic Qt6 installation with aqtinstall is only configured for Linux builds.")
|
||||
|
||||
machine = platform.machine().lower()
|
||||
if machine in {"x86_64", "amd64"}:
|
||||
return "linux", "linux_gcc_64", "gcc_64"
|
||||
if machine in {"aarch64", "arm64"}:
|
||||
return "linux_arm64", "linux_gcc_arm64", "gcc_arm64"
|
||||
|
||||
raise ValueError(f"Automatic Qt6 installation is not configured for architecture '{platform.machine()}'.")
|
||||
|
||||
|
||||
def install_qt6() -> str:
|
||||
host, qt_arch, install_suffix = get_qt6_aqt_config()
|
||||
qt_install_root = INSTALL_DIR / f"qt6-{QT6_VERSION}-{install_suffix}"
|
||||
qt_dir = qt_install_root / QT6_VERSION / install_suffix
|
||||
os.environ["QT_DIR"] = str(qt_dir)
|
||||
|
||||
qt_config = qt_dir / "lib" / "cmake" / "Qt6" / "Qt6Config.cmake"
|
||||
qt_core = qt_dir / "lib" / "libQt6Core.so.6"
|
||||
qt_svg = qt_dir / "lib" / "cmake" / "Qt6Svg" / "Qt6SvgConfig.cmake"
|
||||
if qt_config.exists() and qt_core.exists() and qt_svg.exists():
|
||||
logger.info(f"Found existing Qt6 at {qt_dir}, skipping")
|
||||
return str(qt_dir)
|
||||
|
||||
os.makedirs(qt_install_root, exist_ok=True)
|
||||
run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"aqt",
|
||||
"install-qt",
|
||||
host,
|
||||
"desktop",
|
||||
QT6_VERSION,
|
||||
qt_arch,
|
||||
"-O",
|
||||
str(qt_install_root),
|
||||
# Keep the install lean by filtering archives: qtbase provides
|
||||
# Core/Gui/Widgets (and the Qt6::CorePrivate target), qtsvg provides
|
||||
# Qt6::Svg. Both are base-Qt archives, not add-on modules.
|
||||
"--archives",
|
||||
"icu",
|
||||
"qtbase",
|
||||
"qtsvg",
|
||||
]
|
||||
)
|
||||
|
||||
if not (qt_config.exists() and qt_core.exists() and qt_svg.exists()):
|
||||
raise RuntimeError(f"Qt6 installation did not produce a usable Qt at {qt_dir}.")
|
||||
|
||||
return str(qt_dir)
|
||||
|
||||
|
||||
cecho("Collecting dependencies:", GREEN)
|
||||
|
||||
# Set compiler flags for 32bit builds on 64bit system
|
||||
@@ -845,37 +896,6 @@ os.environ["LDFLAGS"] = LDFLAGS
|
||||
# @tfk: this is no longer needed
|
||||
# build_dependency(name="cmake-%s" % (CMAKE_VERSION,), mode="autoconf", build_tool_args=[], download_url="https://cmake.org/files/v%s" % (CMAKE_VERSION_2,), download_name="cmake-%s.tar.gz" % (CMAKE_VERSION,))
|
||||
|
||||
if "hdf5" in targets:
|
||||
# not supported
|
||||
orig = [os.environ[f] for f in compiler_flags]
|
||||
for f in compiler_flags:
|
||||
os.environ[f] = re.sub(r"-flto(=\w+)?", "", os.environ[f])
|
||||
|
||||
HDF5_UNDERSCORE = "_".join(HDF5_VERSION.split("."))
|
||||
HDF5_MAJOR = ".".join(HDF5_VERSION.split(".")[:-1])
|
||||
dependency_name = f"hdf5-{HDF5_VERSION}"
|
||||
build_dependency(
|
||||
name=dependency_name,
|
||||
mode="cmake",
|
||||
build_tool_args=[
|
||||
f"-DCMAKE_INSTALL_PREFIX={DEPS_DIR}/install/{dependency_name}",
|
||||
"-DHDF5_ENABLE_Z_LIB_SUPPORT=OFF",
|
||||
"-DBUILD_TESTING=OFF",
|
||||
"-DHDF5_BUILD_TOOLS=OFF",
|
||||
"-DHDF5_BUILD_EXAMPLES=OFF",
|
||||
"-DBUILD_SHARED_LIBS=OFF",
|
||||
"-DHDF5_BUILD_UTILS=OFF",
|
||||
"-DHDF5_BUILD_CPP_LIB=ON",
|
||||
*MAC_CROSS_COMPILE_INTEL_ARGS,
|
||||
],
|
||||
download_url=f"https://github.com/HDFGroup/hdf5/archive/refs/tags/",
|
||||
download_name=f"hdf5-{HDF5_UNDERSCORE}.tar.gz",
|
||||
)
|
||||
|
||||
for f, o in zip(compiler_flags, orig):
|
||||
os.environ[f] = o
|
||||
|
||||
|
||||
if "json" in targets:
|
||||
dependency_name = f"json-{JSON_VERSION}"
|
||||
build_dependency(
|
||||
@@ -994,6 +1014,33 @@ elif "occ" in targets:
|
||||
download_name=f"OCE-{OCE_VERSION}.tar.gz",
|
||||
)
|
||||
|
||||
if "manifold" in targets:
|
||||
dependency_name = f"manifold-{MANIFOLD_VERSION}"
|
||||
patches = []
|
||||
if WASM:
|
||||
patches.append("./patches/manifold/install-metadata-for-emscripten.patch")
|
||||
build_dependency(
|
||||
name=dependency_name,
|
||||
mode="cmake",
|
||||
build_tool_args=[
|
||||
f"-DCMAKE_INSTALL_PREFIX={DEPS_DIR}/install/{dependency_name}",
|
||||
"-DMANIFOLD_PAR=OFF",
|
||||
"-DMANIFOLD_CROSS_SECTION=OFF",
|
||||
"-DMANIFOLD_PYBIND=OFF",
|
||||
"-DMANIFOLD_JSBIND=OFF",
|
||||
"-DMANIFOLD_CBIND=OFF",
|
||||
"-DMANIFOLD_TEST=OFF",
|
||||
"-DMANIFOLD_EXPORT=OFF",
|
||||
"-DMANIFOLD_DOWNLOADS=OFF",
|
||||
*MAC_CROSS_COMPILE_INTEL_ARGS,
|
||||
],
|
||||
download_url="https://github.com/elalish/manifold.git",
|
||||
download_name="manifold",
|
||||
download_tool=download_tool_git,
|
||||
revision=f"v{MANIFOLD_VERSION}",
|
||||
patch=patches,
|
||||
)
|
||||
|
||||
if "libxml2" in targets:
|
||||
OLD_CC = ""
|
||||
if MAC_CROSS_COMPILE_INTEL:
|
||||
@@ -1297,6 +1344,9 @@ if "rocksdb" in targets:
|
||||
revision=f"v{ROCKSDB_VERSION}",
|
||||
)
|
||||
|
||||
if "qt6" in targets:
|
||||
install_qt6()
|
||||
|
||||
cecho("Building IfcOpenShell:", GREEN)
|
||||
|
||||
IFCOS_DIR = os.path.join(DEPS_DIR, "build", "ifcopenshell")
|
||||
@@ -1304,8 +1354,8 @@ if os.environ.get("NO_CLEAN", "").lower() not in {"1", "on", "true"}:
|
||||
if os.path.exists(IFCOS_DIR):
|
||||
shutil.rmtree(IFCOS_DIR)
|
||||
os.makedirs(IFCOS_DIR, exist_ok=True)
|
||||
executables_dir = os.path.join(IFCOS_DIR, "executables")
|
||||
os.makedirs(executables_dir, exist_ok=True)
|
||||
ifcos_build_dir = os.path.join(IFCOS_DIR, "build")
|
||||
os.makedirs(ifcos_build_dir, exist_ok=True)
|
||||
|
||||
|
||||
cmake_args = [
|
||||
@@ -1314,6 +1364,7 @@ cmake_args = [
|
||||
"-DBUILD_SHARED_LIBS=" + OFF_ON[not BUILD_STATIC],
|
||||
"-DGLTF_SUPPORT=ON",
|
||||
"-DBoost_NO_BOOST_CMAKE=On",
|
||||
"-DCREATE_BUNDLE=On",
|
||||
"-DADD_COMMIT_SHA=" + ("On" if ADD_COMMIT_SHA else "Off"),
|
||||
"-DVERSION_OVERRIDE=" + ("On" if ADD_COMMIT_SHA else "Off"),
|
||||
*MAC_CROSS_COMPILE_INTEL_ARGS,
|
||||
@@ -1363,6 +1414,10 @@ elif "occ" in targets:
|
||||
occ_library_dir = f"{DEPS_DIR}/install/oce-{OCE_VERSION}/lib"
|
||||
cmake_args.extend(["-DOCC_INCLUDE_DIR=" + occ_include_dir, "-DOCC_LIBRARY_DIR=" + occ_library_dir])
|
||||
|
||||
if "manifold" in targets:
|
||||
cmake_args_prefix_path.append(f"{DEPS_DIR}/install/manifold-{MANIFOLD_VERSION}")
|
||||
cmake_args.append("-DWITH_MANIFOLD=On")
|
||||
|
||||
if "OpenCOLLADA" in targets:
|
||||
# pcre is a dependency of OpenCOLLADA, but since we `find_package`,
|
||||
# we don't need to add it explicitly here as cmake will find it from the config.
|
||||
@@ -1377,11 +1432,6 @@ else:
|
||||
if "libxml2" in targets:
|
||||
cmake_args_prefix_path.append(f"{DEPS_DIR}/install/libxml2-{LIBXML2_VERSION}")
|
||||
|
||||
if "hdf5" in targets:
|
||||
cmake_args_prefix_path.append(f"{DEPS_DIR}/install/hdf5-{HDF5_VERSION}")
|
||||
else:
|
||||
cmake_args.append("-DHDF5_SUPPORT=Off")
|
||||
|
||||
if "usd" in targets:
|
||||
cmake_args.append("-DUSD_SUPPORT=ON")
|
||||
cmake_args_prefix_path.extend(
|
||||
@@ -1408,40 +1458,44 @@ if "rocksdb" in targets:
|
||||
if "swig" in targets:
|
||||
cmake_args_prefix_path.append(f"{DEPS_DIR}/install/swig-{SWIG_VERSION}")
|
||||
|
||||
if not WASM and (not explicit_targets or {"IfcGeom", "IfcConvert", "IfcGeomServer"} & set(explicit_targets)):
|
||||
if os.environ.get("QT_DIR"):
|
||||
cmake_args_prefix_path.append(os.environ["QT_DIR"])
|
||||
cmake_args.append(f"-DQT_DIR={os.environ['QT_DIR']}")
|
||||
|
||||
build_bonsaiviewer = BUILD_BONSAIVIEWER or "BonsaiViewer" in targets
|
||||
|
||||
ifcos_build_args = [
|
||||
f"-DBUILD_IFCGEOM={OFF_ON['IfcGeom' in targets]}",
|
||||
f"-DBUILD_GEOMSERVER={OFF_ON['IfcGeomServer' in targets]}",
|
||||
f"-DBUILD_CONVERT={OFF_ON['IfcConvert' in targets]}",
|
||||
f"-DBUILD_BONSAIVIEWER={OFF_ON[build_bonsaiviewer]}",
|
||||
f"-DCMAKE_INSTALL_PREFIX={DEPS_DIR}/install/ifcopenshell",
|
||||
]
|
||||
|
||||
if not WASM and (
|
||||
build_bonsaiviewer
|
||||
or not explicit_targets
|
||||
or {"IfcGeom", "IfcConvert", "IfcGeomServer", "BonsaiViewer"} & set(explicit_targets)
|
||||
):
|
||||
logger.info("\rConfiguring executables...")
|
||||
|
||||
exec_args = [
|
||||
f"-DBUILD_IFCGEOM={OFF_ON['IfcGeom' in targets]}",
|
||||
f"-DBUILD_GEOMSERVER={OFF_ON['IfcGeomServer' in targets]}",
|
||||
f"-DBUILD_CONVERT={OFF_ON['IfcConvert' in targets]}",
|
||||
*ifcos_build_args,
|
||||
f"-DBUILD_IFCPYTHON=OFF",
|
||||
f"-DCMAKE_INSTALL_PREFIX={DEPS_DIR}/install/ifcopenshell",
|
||||
]
|
||||
|
||||
run_cmake("", exec_args + cmake_args + get_cmake_args_prefix_path(), cmake_dir=CMAKE_DIR, cwd=executables_dir)
|
||||
run_cmake("", exec_args + cmake_args + get_cmake_args_prefix_path(), cmake_dir=CMAKE_DIR, cwd=ifcos_build_dir)
|
||||
|
||||
logger.info("\rBuilding executables... ")
|
||||
|
||||
run([make, f"-j{IFCOS_NUM_BUILD_PROCS}", "VERBOSE=1"], cwd=executables_dir)
|
||||
run([make, "install/strip" if BUILD_CFG == "Release" else "install"], cwd=executables_dir)
|
||||
run([make, f"-j{IFCOS_NUM_BUILD_PROCS}", "VERBOSE=1"], cwd=ifcos_build_dir)
|
||||
run([make, "install/strip" if BUILD_CFG == "Release" else "install"], cwd=ifcos_build_dir)
|
||||
|
||||
if "IfcOpenShell-Python" in targets:
|
||||
# On OSX the actual Python library is not linked against.
|
||||
ADDITIONAL_ARGS = ""
|
||||
wrapper_ldflags = ""
|
||||
if platform.system() == "Darwin":
|
||||
ADDITIONAL_ARGS = "-Wl,-undefined,dynamic_lookup"
|
||||
|
||||
# NOTE: We don't use `CXXFLAGS` for wrappers, so wrapper is compiled with different flags
|
||||
# (e.g. ` -fdata-sections` is missing, which is set by default for executables)
|
||||
# So cache doesn't match and running build-all.py builds most of ifcopenshell libraries twice.
|
||||
os.environ["CPPFLAGS"] = f"{CXXFLAGS_MINIMAL} {ADDITIONAL_ARGS}"
|
||||
os.environ["CXXFLAGS"] = f"{CXXFLAGS_MINIMAL} {ADDITIONAL_ARGS}"
|
||||
os.environ["CFLAGS"] = f"{CFLAGS_MINIMAL} {ADDITIONAL_ARGS}"
|
||||
os.environ["LDFLAGS"] = f"{LDFLAGS} {ADDITIONAL_ARGS}"
|
||||
|
||||
python_dir = os.path.join(IFCOS_DIR, "pythonwrapper")
|
||||
os.makedirs(python_dir, exist_ok=True)
|
||||
# On OSX the actual Python library is not linked against.
|
||||
wrapper_ldflags = "-Wl,-undefined,dynamic_lookup"
|
||||
|
||||
def compile_python_wrapper(
|
||||
python_version: str,
|
||||
@@ -1456,10 +1510,6 @@ if "IfcOpenShell-Python" in targets:
|
||||
|
||||
logger.info(f"\rConfiguring python {python_version} wrapper...")
|
||||
|
||||
cache_path = os.path.join(python_dir, "CMakeCache.txt")
|
||||
if os.path.exists(cache_path):
|
||||
os.remove(cache_path)
|
||||
|
||||
if python_path:
|
||||
# We couldn't just prefix PATH and have to provide all variables explicitly,
|
||||
# see ifcwrap/cmake for the details.
|
||||
@@ -1473,27 +1523,38 @@ if "IfcOpenShell-Python" in targets:
|
||||
)
|
||||
|
||||
assert python_include
|
||||
run_cmake(
|
||||
"",
|
||||
cmake_args
|
||||
+ get_cmake_args_prefix_path()
|
||||
+ [
|
||||
*([f"-DPYTHON_EXECUTABLE={python_executable}"] if python_executable else []),
|
||||
# Needed because pyodide is expecting setup.py to be in the root.
|
||||
*([f"-DPYTHON_MODULE_INSTALL_DIR={REPO_PATH}"] * WASM),
|
||||
f"-DPYTHON_INCLUDE_DIR={python_include}",
|
||||
f"-DCMAKE_INSTALL_PREFIX={DEPS_DIR}/install/ifcopenshell/tmp",
|
||||
"-DUSERSPACE_PYTHON_PREFIX="
|
||||
+ ["Off", "On"][os.environ.get("PYTHON_USER_SITE", "").lower() in {"1", "on", "true"}],
|
||||
],
|
||||
cmake_dir=CMAKE_DIR,
|
||||
cwd=python_dir,
|
||||
)
|
||||
old_ldflags = os.environ["LDFLAGS"]
|
||||
if wrapper_ldflags:
|
||||
os.environ["LDFLAGS"] = f"{old_ldflags} {wrapper_ldflags}"
|
||||
|
||||
try:
|
||||
run_cmake(
|
||||
"",
|
||||
ifcos_build_args
|
||||
+ [
|
||||
"-DBUILD_IFCPYTHON=ON",
|
||||
]
|
||||
+ cmake_args
|
||||
+ get_cmake_args_prefix_path()
|
||||
+ [
|
||||
*([f"-DPYTHON_EXECUTABLE={python_executable}"] if python_executable else []),
|
||||
# Needed because pyodide is expecting setup.py to be in the root.
|
||||
*([f"-DPYTHON_MODULE_INSTALL_DIR={REPO_PATH}"] * WASM),
|
||||
f"-DPYTHON_INCLUDE_DIR={python_include}",
|
||||
f"-DCMAKE_INSTALL_PREFIX={DEPS_DIR}/install/ifcopenshell/tmp",
|
||||
"-DUSERSPACE_PYTHON_PREFIX="
|
||||
+ ["Off", "On"][os.environ.get("PYTHON_USER_SITE", "").lower() in {"1", "on", "true"}],
|
||||
],
|
||||
cmake_dir=CMAKE_DIR,
|
||||
cwd=ifcos_build_dir,
|
||||
)
|
||||
finally:
|
||||
os.environ["LDFLAGS"] = old_ldflags
|
||||
|
||||
logger.info(f"\rBuilding python {python_version} wrapper... ")
|
||||
|
||||
run([make, f"-j{IFCOS_NUM_BUILD_PROCS}", "ifcopenshell_wrapper", "VERBOSE=1"], cwd=python_dir)
|
||||
run([make, "install/local"], cwd=os.path.join(python_dir, "ifcwrap"))
|
||||
run([make, f"-j{IFCOS_NUM_BUILD_PROCS}", "ifcopenshell_wrapper", "VERBOSE=1"], cwd=ifcos_build_dir)
|
||||
run([make, "install/local"], cwd=os.path.join(ifcos_build_dir, "ifcwrap"))
|
||||
|
||||
if python_executable:
|
||||
run([python_executable, "-m", "ensurepip"])
|
||||
@@ -1508,12 +1569,14 @@ if "IfcOpenShell-Python" in targets:
|
||||
|
||||
if platform.system() != "Darwin":
|
||||
if BUILD_CFG == "Release":
|
||||
# TODO: This symbol name depends on the Python version?
|
||||
so = glob.glob(os.path.join(module_dir, "_ifcopenshell_wrapper*.so"))[0]
|
||||
if "wasm" in flags:
|
||||
run(["wasm-strip", so, "-k", "dylink.0"])
|
||||
else:
|
||||
run([strip, "-s", "-K", "PyInit__ifcopenshell_wrapper", so], cwd=module_dir)
|
||||
for so in glob.glob(os.path.join(module_dir, "*.so")):
|
||||
if "wasm" in flags:
|
||||
run(["wasm-strip", so, "-k", "dylink.0"])
|
||||
elif os.path.basename(so).startswith("_ifcopenshell_wrapper"):
|
||||
# TODO: This symbol name depends on the Python version?
|
||||
run([strip, "-s", "-K", "PyInit__ifcopenshell_wrapper", so], cwd=module_dir)
|
||||
else:
|
||||
run([strip, "--strip-unneeded", so], cwd=module_dir)
|
||||
|
||||
return module_dir
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
diff --git a/CMakeLists.txt b/CMakeLists.txt
|
||||
index 42e403b..764562f 100644
|
||||
--- a/CMakeLists.txt
|
||||
+++ b/CMakeLists.txt
|
||||
@@ -249,11 +249,6 @@ set_source_files_properties(
|
||||
PROPERTIES GENERATED TRUE
|
||||
)
|
||||
|
||||
-# If it's an EMSCRIPTEN build, we're done
|
||||
-if(EMSCRIPTEN)
|
||||
- return()
|
||||
-endif()
|
||||
-
|
||||
# CMake exports
|
||||
configure_file(
|
||||
cmake/manifoldConfig.cmake.in
|
||||
@@ -34,7 +34,6 @@ environments:
|
||||
- conda: https://conda.anaconda.org/conda-forge/linux-64/gxx-13.3.0-h9576a4e_1.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/linux-64/gxx_impl_linux-64-13.3.0-hdbfa832_1.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/linux-64/gxx_linux-64-13.3.0-h6834431_7.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/linux-64/hdf5-1.14.4-nompi_h2d575fe_105.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/linux-64/icu-75.1-he02047a_0.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/linux-64/imath-3.1.12-h7955e40_0.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/linux-64/jxrlib-1.1-hd590300_3.conda
|
||||
@@ -149,7 +148,6 @@ environments:
|
||||
- conda: https://conda.anaconda.org/conda-forge/osx-64/freeimage-3.18.0-h7cd8ba8_22.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/osx-64/freetype-2.12.1-h60636b9_2.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/osx-64/gmp-6.3.0-hf036a51_2.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/osx-64/hdf5-1.14.4-nompi_h1607680_105.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/osx-64/icu-75.1-h120a0e1_0.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/osx-64/imath-3.1.12-h2016aa1_0.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/osx-64/jxrlib-1.1-h10d778d_3.conda
|
||||
@@ -243,7 +241,6 @@ environments:
|
||||
- conda: https://conda.anaconda.org/conda-forge/win-64/freeimage-3.18.0-h8310ca0_22.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/win-64/freetype-2.12.1-hdaf720e_2.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/win-64/gmp-6.3.0-hfeafd45_2.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/win-64/hdf5-1.14.4-nompi_hd5d9e70_105.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/win-64/imath-3.1.12-hbb528cf_0.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/win-64/intel-openmp-2024.2.1-h57928b3_1083.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/win-64/jxrlib-1.1-hcfcfb64_3.conda
|
||||
@@ -349,7 +346,6 @@ environments:
|
||||
- conda: https://conda.anaconda.org/conda-forge/linux-64/gxx-13.3.0-h9576a4e_1.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/linux-64/gxx_impl_linux-64-13.3.0-hdbfa832_1.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/linux-64/gxx_linux-64-13.3.0-h6834431_7.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/linux-64/hdf5-1.14.4-nompi_h2d575fe_105.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/linux-64/icu-75.1-he02047a_0.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/linux-64/imath-3.1.12-h7955e40_0.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/linux-64/jxrlib-1.1-hd590300_3.conda
|
||||
@@ -464,7 +460,6 @@ environments:
|
||||
- conda: https://conda.anaconda.org/conda-forge/osx-64/freeimage-3.18.0-h7cd8ba8_22.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/osx-64/freetype-2.12.1-h60636b9_2.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/osx-64/gmp-6.3.0-hf036a51_2.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/osx-64/hdf5-1.14.4-nompi_h1607680_105.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/osx-64/icu-75.1-h120a0e1_0.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/osx-64/imath-3.1.12-h2016aa1_0.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/osx-64/jxrlib-1.1-h10d778d_3.conda
|
||||
@@ -558,7 +553,6 @@ environments:
|
||||
- conda: https://conda.anaconda.org/conda-forge/win-64/freeimage-3.18.0-h8310ca0_22.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/win-64/freetype-2.12.1-hdaf720e_2.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/win-64/gmp-6.3.0-hfeafd45_2.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/win-64/hdf5-1.14.4-nompi_hd5d9e70_105.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/win-64/imath-3.1.12-hbb528cf_0.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/win-64/intel-openmp-2024.2.1-h57928b3_1083.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/win-64/jxrlib-1.1-hcfcfb64_3.conda
|
||||
@@ -743,7 +737,6 @@ environments:
|
||||
- conda: https://conda.anaconda.org/conda-forge/linux-64/gxx-13.3.0-h9576a4e_1.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/linux-64/gxx_impl_linux-64-13.3.0-hdbfa832_1.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/linux-64/gxx_linux-64-13.3.0-h6834431_7.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/linux-64/hdf5-1.14.4-nompi_h2d575fe_105.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/linux-64/icu-75.1-he02047a_0.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/linux-64/imath-3.1.12-h7955e40_0.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/linux-64/jxrlib-1.1-hd590300_3.conda
|
||||
@@ -858,7 +851,6 @@ environments:
|
||||
- conda: https://conda.anaconda.org/conda-forge/osx-64/freeimage-3.18.0-h7cd8ba8_22.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/osx-64/freetype-2.12.1-h60636b9_2.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/osx-64/gmp-6.3.0-hf036a51_2.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/osx-64/hdf5-1.14.4-nompi_h1607680_105.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/osx-64/icu-75.1-h120a0e1_0.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/osx-64/imath-3.1.12-h2016aa1_0.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/osx-64/jxrlib-1.1-h10d778d_3.conda
|
||||
@@ -952,7 +944,6 @@ environments:
|
||||
- conda: https://conda.anaconda.org/conda-forge/win-64/freeimage-3.18.0-h8310ca0_22.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/win-64/freetype-2.12.1-hdaf720e_2.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/win-64/gmp-6.3.0-hfeafd45_2.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/win-64/hdf5-1.14.4-nompi_hd5d9e70_105.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/win-64/imath-3.1.12-hbb528cf_0.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/win-64/intel-openmp-2024.2.1-h57928b3_1083.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/win-64/jxrlib-1.1-hcfcfb64_3.conda
|
||||
@@ -1068,7 +1059,6 @@ environments:
|
||||
- conda: https://conda.anaconda.org/conda-forge/linux-64/gxx_impl_linux-64-14.3.0-he663afc_4.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/linux-64/gxx_linux-64-14.3.0-ha7acb78_11.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.3.0-pyhcf101f3_0.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/linux-64/hdf5-1.14.6-nompi_h6e4c0c1_103.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/noarch/hpack-4.1.0-pyhd8ed1ab_0.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/linux-64/icu-75.1-he02047a_0.conda
|
||||
@@ -1239,7 +1229,6 @@ environments:
|
||||
- conda: https://conda.anaconda.org/conda-forge/osx-64/geos-3.13.1-h502464c_0.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/osx-64/gmp-6.3.0-hf036a51_2.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.3.0-pyhcf101f3_0.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/osx-64/hdf5-1.14.6-nompi_hc8237f9_103.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/noarch/hpack-4.1.0-pyhd8ed1ab_0.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/osx-64/icu-75.1-h120a0e1_0.conda
|
||||
@@ -1380,7 +1369,6 @@ environments:
|
||||
- conda: https://conda.anaconda.org/conda-forge/win-64/geos-3.13.1-h9ea8674_0.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/win-64/gmp-6.3.0-hfeafd45_2.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.3.0-pyhcf101f3_0.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/win-64/hdf5-1.14.6-nompi_he30205f_103.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/noarch/hpack-4.1.0-pyhd8ed1ab_0.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda
|
||||
- conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.10-pyhd8ed1ab_1.conda
|
||||
@@ -2990,106 +2978,6 @@ packages:
|
||||
- pkg:pypi/h2?source=compressed-mapping
|
||||
size: 95967
|
||||
timestamp: 1756364871835
|
||||
- conda: https://conda.anaconda.org/conda-forge/linux-64/hdf5-1.14.4-nompi_h2d575fe_105.conda
|
||||
sha256: 93d2bfc672f3ee0988d277ce463330a467f3686d3f7ee37812a3d8ca11776d77
|
||||
md5: d76fff0092b6389a12134ddebc0929bd
|
||||
depends:
|
||||
- __glibc >=2.17,<3.0.a0
|
||||
- libaec >=1.1.3,<2.0a0
|
||||
- libcurl >=8.10.1,<9.0a0
|
||||
- libgcc >=13
|
||||
- libgfortran
|
||||
- libgfortran5 >=13.3.0
|
||||
- libstdcxx >=13
|
||||
- libzlib >=1.3.1,<2.0a0
|
||||
- openssl >=3.4.0,<4.0a0
|
||||
license: BSD-3-Clause
|
||||
license_family: BSD
|
||||
size: 3950601
|
||||
timestamp: 1733003331788
|
||||
- conda: https://conda.anaconda.org/conda-forge/linux-64/hdf5-1.14.6-nompi_h6e4c0c1_103.conda
|
||||
sha256: 4f173af9e2299de7eee1af3d79e851bca28ee71e7426b377e841648b51d48614
|
||||
md5: c74d83614aec66227ae5199d98852aaf
|
||||
depends:
|
||||
- __glibc >=2.17,<3.0.a0
|
||||
- libaec >=1.1.4,<2.0a0
|
||||
- libcurl >=8.14.1,<9.0a0
|
||||
- libgcc >=14
|
||||
- libgfortran
|
||||
- libgfortran5 >=14.3.0
|
||||
- libstdcxx >=14
|
||||
- libzlib >=1.3.1,<2.0a0
|
||||
- openssl >=3.5.1,<4.0a0
|
||||
license: BSD-3-Clause
|
||||
license_family: BSD
|
||||
purls: []
|
||||
size: 3710057
|
||||
timestamp: 1753357500665
|
||||
- conda: https://conda.anaconda.org/conda-forge/osx-64/hdf5-1.14.4-nompi_h1607680_105.conda
|
||||
sha256: 56500937894b1ca917e1ae1bea64b873a9eec57d581173579189d0b1f590db26
|
||||
md5: 12ebafc40b10d4bf519e4c2074c52aef
|
||||
depends:
|
||||
- __osx >=10.13
|
||||
- libaec >=1.1.3,<2.0a0
|
||||
- libcurl >=8.10.1,<9.0a0
|
||||
- libcxx >=18
|
||||
- libgfortran 5.*
|
||||
- libgfortran5 >=13.2.0
|
||||
- libzlib >=1.3.1,<2.0a0
|
||||
- openssl >=3.4.0,<4.0a0
|
||||
license: BSD-3-Clause
|
||||
license_family: BSD
|
||||
size: 3732340
|
||||
timestamp: 1733003702265
|
||||
- conda: https://conda.anaconda.org/conda-forge/osx-64/hdf5-1.14.6-nompi_hc8237f9_103.conda
|
||||
sha256: e41d22f672b1fbe713d22cf69630abffaee68bdb38a500a708fc70e6f639357f
|
||||
md5: 3f1df98f96e0c369d94232712c9b87d0
|
||||
depends:
|
||||
- __osx >=10.13
|
||||
- libaec >=1.1.4,<2.0a0
|
||||
- libcurl >=8.14.1,<9.0a0
|
||||
- libcxx >=19
|
||||
- libgfortran
|
||||
- libgfortran5 >=14.3.0
|
||||
- libgfortran5 >=15.1.0
|
||||
- libzlib >=1.3.1,<2.0a0
|
||||
- openssl >=3.5.1,<4.0a0
|
||||
license: BSD-3-Clause
|
||||
license_family: BSD
|
||||
purls: []
|
||||
size: 3522832
|
||||
timestamp: 1753358062940
|
||||
- conda: https://conda.anaconda.org/conda-forge/win-64/hdf5-1.14.4-nompi_hd5d9e70_105.conda
|
||||
sha256: e8ced65c604a3b9e4803758a25149d71d8096f186fe876817a0d1d97190550c0
|
||||
md5: 4381be33460283890c34341ecfa42d97
|
||||
depends:
|
||||
- libaec >=1.1.3,<2.0a0
|
||||
- libcurl >=8.10.1,<9.0a0
|
||||
- libzlib >=1.3.1,<2.0a0
|
||||
- openssl >=3.4.0,<4.0a0
|
||||
- ucrt >=10.0.20348.0
|
||||
- vc >=14.2,<15
|
||||
- vc14_runtime >=14.29.30139
|
||||
license: BSD-3-Clause
|
||||
license_family: BSD
|
||||
size: 2048450
|
||||
timestamp: 1733003052575
|
||||
- conda: https://conda.anaconda.org/conda-forge/win-64/hdf5-1.14.6-nompi_he30205f_103.conda
|
||||
sha256: 0a90263b97e9860cec6c2540160ff1a1fff2a609b3d96452f8716ae63489dac5
|
||||
md5: f1f7aaf642cefd2190582550eaca4658
|
||||
depends:
|
||||
- libaec >=1.1.4,<2.0a0
|
||||
- libcurl >=8.14.1,<9.0a0
|
||||
- libzlib >=1.3.1,<2.0a0
|
||||
- openssl >=3.5.1,<4.0a0
|
||||
- ucrt >=10.0.20348.0
|
||||
- vc >=14.3,<15
|
||||
- vc14_runtime >=14.44.35208
|
||||
license: BSD-3-Clause
|
||||
license_family: BSD
|
||||
purls: []
|
||||
size: 2031491
|
||||
timestamp: 1753357255237
|
||||
- conda: https://conda.anaconda.org/conda-forge/noarch/hpack-4.1.0-pyhd8ed1ab_0.conda
|
||||
sha256: 6ad78a180576c706aabeb5b4c8ceb97c0cb25f1e112d76495bff23e3779948ba
|
||||
md5: 0a802cb9888dd14eeefc611f05c40b6e
|
||||
|
||||
@@ -31,7 +31,6 @@ occt = { version = "*", build = "*novtk*" }
|
||||
cgal-cpp = "*"
|
||||
numpy = "*"
|
||||
lark = "*"
|
||||
hdf5 = "*"
|
||||
eigen = "*"
|
||||
mpfr = "*"
|
||||
gmp = "*"
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
#!/usr/bin/bash
|
||||
set -ex
|
||||
|
||||
PYODIDE_VERSION=0.29.3
|
||||
PYODIDE_BUILD_VERSION=0.33.0
|
||||
PYODIDE_XBUILDENV_ROOT="${HOME}/.cache/.pyodide-xbuildenv-${PYODIDE_BUILD_VERSION}"
|
||||
PYODIDE_XBUILDENV="${PYODIDE_XBUILDENV_ROOT}/${PYODIDE_VERSION}"
|
||||
|
||||
# Script is assuming that it will be possible to execute it multiple times
|
||||
# therefore we're clearing venv each time and ignoring existing 'emsdk' folder.
|
||||
|
||||
@@ -11,14 +16,15 @@ source .venv/bin/activate
|
||||
|
||||
# Install pyodide cross build environment.
|
||||
# Instructions: https://pyodide.org/en/stable/development/building-packages.html
|
||||
uv pip install pyodide-build
|
||||
uv pip install "pyodide-build==${PYODIDE_BUILD_VERSION}"
|
||||
# `uv run` is required, so xbuildenv would skip using `pip`.
|
||||
uv run pyodide xbuildenv install
|
||||
uv run pyodide xbuildenv install "${PYODIDE_VERSION}"
|
||||
uv run pyodide xbuildenv install-emscripten
|
||||
|
||||
EMSDK_ROOT=$(pyodide config get emscripten_dir)
|
||||
source ${EMSDK_ROOT}/emsdk_env.sh
|
||||
EMSDK_ROOT="${PYODIDE_XBUILDENV}/emsdk"
|
||||
source "${EMSDK_ROOT}/emsdk_env.sh"
|
||||
which emcc
|
||||
emcc --version
|
||||
|
||||
mkdir -p packages/ifcopenshell
|
||||
VERSION=`cat IfcOpenShell/VERSION`
|
||||
@@ -29,6 +35,12 @@ sed -i s/0.8.0/$VERSION/g packages/ifcopenshell/meta.yaml
|
||||
# Otherwise pyodide build path typically includes package version, so cached cmake configs might break.
|
||||
export BUILD_DIR=`readlink -f ifcopenshell_build`
|
||||
|
||||
# Sat, 25 Apr 2026 12:11:39 GMT 2026-04-25 12:11:39,173 - DEBUG - running
|
||||
# command `make -j5 ifcopenshell_wrapper VERBOSE=1` in directory
|
||||
# '/home/runner/work/IfcOpenShell/IfcOpenShell/ifcopenshell_build/Linux/wasm/build/ifcopenshell/build'
|
||||
# Sat, 25 Apr 2026 12:18:01 GMT Error: Process completed with exit code 143.
|
||||
export IFCOS_NUM_BUILD_PROCS=1
|
||||
|
||||
# Use build-recipes-no-deps first, so logs would be printed to stdout.
|
||||
pyodide build-recipes-no-deps ifcopenshell
|
||||
pyodide build-recipes ifcopenshell --install
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env python3
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
"""Order Pyodide wheel shared objects so wasm side modules load safely."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SCHEMA_ORDER = {
|
||||
"ifc2x3": 0,
|
||||
"ifc4": 1,
|
||||
"ifc4x1": 2,
|
||||
"ifc4x2": 3,
|
||||
"ifc4x3": 4,
|
||||
"ifc4x3_add1": 5,
|
||||
"ifc4x3_add2": 6,
|
||||
}
|
||||
|
||||
MAIN_SHARED_OBJECT_RE = re.compile(r"^_ifcopenshell_wrapper(?:\.|$)")
|
||||
SCHEMA_PLUGIN_RE = re.compile(r"^ifcopenshell\.parse\.schema\.([^.]+)\.so$")
|
||||
MAPPING_PLUGIN_RE = re.compile(r"^ifcopenshell\.geometry\.mapping\.([^.]+)\.so$")
|
||||
DOCUMENT_PLUGIN_RE = re.compile(r"^ifcopenshell\.document\.[^.]+\.([^.]+)\.so$")
|
||||
GEOMETRY_SERIALIZATION_PLUGIN_RE = re.compile(r"^ifcopenshell\.geometry\.serialization\.([^.]+)\.so$")
|
||||
|
||||
|
||||
def schema_key(schema: str) -> tuple[int, str]:
|
||||
schema = schema.lower()
|
||||
return SCHEMA_ORDER.get(schema, len(SCHEMA_ORDER)), schema
|
||||
|
||||
|
||||
def shared_object_sort_key(filename: str, index: int) -> tuple[int, tuple[int, str], str, int]:
|
||||
basename = Path(filename).name
|
||||
if MAIN_SHARED_OBJECT_RE.match(basename):
|
||||
return 0, schema_key(""), basename, index
|
||||
|
||||
if match := SCHEMA_PLUGIN_RE.match(basename):
|
||||
return 1, schema_key(match.group(1)), basename, index
|
||||
|
||||
if match := MAPPING_PLUGIN_RE.match(basename):
|
||||
return 2, schema_key(match.group(1)), basename, index
|
||||
|
||||
if match := DOCUMENT_PLUGIN_RE.match(basename):
|
||||
return 3, schema_key(match.group(1)), basename, index
|
||||
|
||||
if match := GEOMETRY_SERIALIZATION_PLUGIN_RE.match(basename):
|
||||
return 4, schema_key(match.group(1)), basename, index
|
||||
|
||||
return 5, schema_key(""), basename, index
|
||||
|
||||
|
||||
def ordered_infos(infos: list[zipfile.ZipInfo]) -> list[zipfile.ZipInfo]:
|
||||
shared_infos = [(index, info) for index, info in enumerate(infos) if info.filename.endswith(".so")]
|
||||
ordered_shared_infos = [
|
||||
info for index, info in sorted(shared_infos, key=lambda item: shared_object_sort_key(item[1].filename, item[0]))
|
||||
]
|
||||
ordered_shared_iter = iter(ordered_shared_infos)
|
||||
return [next(ordered_shared_iter) if info.filename.endswith(".so") else info for info in infos]
|
||||
|
||||
|
||||
def zip_info_for_write(source: zipfile.ZipInfo) -> zipfile.ZipInfo:
|
||||
info = zipfile.ZipInfo(source.filename)
|
||||
info.date_time = source.date_time
|
||||
info.compress_type = source.compress_type
|
||||
info.comment = source.comment
|
||||
info.create_system = source.create_system
|
||||
info.external_attr = source.external_attr
|
||||
info.extra = source.extra
|
||||
return info
|
||||
|
||||
|
||||
def shared_object_names(infos: list[zipfile.ZipInfo]) -> list[str]:
|
||||
return [info.filename for info in infos if info.filename.endswith(".so")]
|
||||
|
||||
|
||||
def rewrite_wheel(wheel: Path, ordered: list[zipfile.ZipInfo]) -> None:
|
||||
fd, temp_name = tempfile.mkstemp(prefix=f".{wheel.name}.", suffix=".tmp", dir=wheel.parent)
|
||||
os.close(fd)
|
||||
temp_path = Path(temp_name)
|
||||
try:
|
||||
with zipfile.ZipFile(wheel) as zin, zipfile.ZipFile(
|
||||
temp_path, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9
|
||||
) as zout:
|
||||
for info in ordered:
|
||||
zout.writestr(zip_info_for_write(info), zin.read(info))
|
||||
os.replace(temp_path, wheel)
|
||||
finally:
|
||||
if temp_path.exists():
|
||||
temp_path.unlink()
|
||||
|
||||
|
||||
def order_wheel(wheel: Path, check: bool) -> bool:
|
||||
wheel = wheel.resolve()
|
||||
if wheel.suffix != ".whl":
|
||||
raise ValueError(f"not a wheel: {wheel}")
|
||||
|
||||
with zipfile.ZipFile(wheel) as zf:
|
||||
infos = zf.infolist()
|
||||
|
||||
ordered = ordered_infos(infos)
|
||||
changed = shared_object_names(infos) != shared_object_names(ordered)
|
||||
if check:
|
||||
if changed:
|
||||
print(f"{wheel}: shared object order needs updating")
|
||||
return False
|
||||
print(f"{wheel}: shared object order is already valid")
|
||||
return True
|
||||
|
||||
if changed:
|
||||
rewrite_wheel(wheel, ordered)
|
||||
print(f"{wheel}: reordered shared objects")
|
||||
else:
|
||||
print(f"{wheel}: shared object order is already valid")
|
||||
return True
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("wheel", type=Path, help="Wheel to rewrite in place")
|
||||
parser.add_argument("--check", action="store_true", help="Only validate the current shared object order")
|
||||
args = parser.parse_args()
|
||||
|
||||
return 0 if order_wheel(args.wheel, args.check) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,297 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Split optional IfcOpenShell Pyodide payloads into separate wheels."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import csv
|
||||
import hashlib
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import zipfile
|
||||
from email.parser import Parser
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MAIN_SHARED_OBJECT_RE = re.compile(r"(^|/)_ifcopenshell_wrapper(?:\.|$)")
|
||||
PURE_PYTHON_PACKAGE_NAME = "ifcopenshell-pure-python"
|
||||
PURE_PYTHON_PREFIXES = (
|
||||
"ifcopenshell/api/",
|
||||
"ifcopenshell/express/",
|
||||
"ifcopenshell/mvd/",
|
||||
"ifcopenshell/simple_spf/",
|
||||
)
|
||||
|
||||
|
||||
def wheel_parts(path: Path) -> tuple[str, str, str, str, str]:
|
||||
if path.suffix != ".whl":
|
||||
raise ValueError(f"not a wheel: {path}")
|
||||
stem = path.name[:-4]
|
||||
left, py_tag, abi_tag, platform_tag = stem.rsplit("-", 3)
|
||||
dist, version = left.rsplit("-", 1)
|
||||
return dist, version, py_tag, abi_tag, platform_tag
|
||||
|
||||
|
||||
def safe_name(name: str) -> str:
|
||||
return re.sub(r"[-_.]+", "-", name).lower().strip("-")
|
||||
|
||||
|
||||
def wheel_escape(value: str) -> str:
|
||||
return re.sub(r"[^\w\d.]+", "_", value, flags=re.UNICODE)
|
||||
|
||||
|
||||
def wheel_version_escape(value: str) -> str:
|
||||
return re.sub(r"[^\w\d.+]+", "_", value, flags=re.UNICODE)
|
||||
|
||||
|
||||
def dist_info_dir(name: str, version: str) -> str:
|
||||
return f"{wheel_escape(name)}-{wheel_version_escape(version)}.dist-info"
|
||||
|
||||
|
||||
def sha256_record_value(data: bytes) -> str:
|
||||
digest = hashlib.sha256(data).digest()
|
||||
return "sha256=" + base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def make_info(name: str, *, source: zipfile.ZipInfo | None = None, mode: int | None = None) -> zipfile.ZipInfo:
|
||||
info = zipfile.ZipInfo(name)
|
||||
if source is not None:
|
||||
info.date_time = source.date_time
|
||||
info.external_attr = source.external_attr
|
||||
info.comment = source.comment
|
||||
info.create_system = source.create_system
|
||||
else:
|
||||
info.date_time = time.localtime(time.time())[:6]
|
||||
info.external_attr = ((mode if mode is not None else 0o644) & 0xFFFF) << 16
|
||||
info.create_system = 3
|
||||
info.compress_type = zipfile.ZIP_DEFLATED
|
||||
return info
|
||||
|
||||
|
||||
def write_record(zf: zipfile.ZipFile, entries: dict[str, bytes | None], record_name: str) -> None:
|
||||
rows: list[list[str]] = []
|
||||
for name in sorted(entries):
|
||||
data = entries[name]
|
||||
if name == record_name:
|
||||
rows.append([name, "", ""])
|
||||
elif data is None:
|
||||
raise ValueError(f"missing bytes for RECORD entry {name}")
|
||||
else:
|
||||
rows.append([name, sha256_record_value(data), str(len(data))])
|
||||
|
||||
buf = io.StringIO(newline="")
|
||||
writer = csv.writer(buf, lineterminator="\n")
|
||||
writer.writerows(rows)
|
||||
zf.writestr(make_info(record_name), buf.getvalue().encode("utf-8"))
|
||||
|
||||
|
||||
def read_original_metadata(zf: zipfile.ZipFile) -> tuple[str, str, str]:
|
||||
metadata_names = [n for n in zf.namelist() if n.endswith(".dist-info/METADATA")]
|
||||
wheel_names = [n for n in zf.namelist() if n.endswith(".dist-info/WHEEL")]
|
||||
record_names = [n for n in zf.namelist() if n.endswith(".dist-info/RECORD")]
|
||||
if len(metadata_names) != 1 or len(wheel_names) != 1 or len(record_names) != 1:
|
||||
raise ValueError("expected exactly one METADATA, WHEEL, and RECORD in the source wheel")
|
||||
return metadata_names[0], wheel_names[0], record_names[0]
|
||||
|
||||
|
||||
def shared_package_name(so_path: str) -> str:
|
||||
stem = Path(so_path).name.removesuffix(".so")
|
||||
stem = re.sub(r"[^A-Za-z0-9]+", "-", stem).strip("-")
|
||||
return safe_name(stem)
|
||||
|
||||
|
||||
def is_pure_python_split_path(path: str) -> bool:
|
||||
return any(path.startswith(prefix) for prefix in PURE_PYTHON_PREFIXES)
|
||||
|
||||
|
||||
def build_wheel(
|
||||
output_dir: Path,
|
||||
package_name: str,
|
||||
version: str,
|
||||
tag: str,
|
||||
root_is_purelib: bool,
|
||||
summary: str,
|
||||
payloads: list[tuple[zipfile.ZipInfo, bytes]],
|
||||
license_files: dict[str, bytes],
|
||||
) -> Path:
|
||||
di = dist_info_dir(package_name, version)
|
||||
wheel_name = f"{wheel_escape(package_name)}-{wheel_version_escape(version)}-{tag}.whl"
|
||||
out = output_dir / wheel_name
|
||||
record_name = f"{di}/RECORD"
|
||||
entries: dict[str, bytes | None] = {}
|
||||
|
||||
metadata = (
|
||||
"Metadata-Version: 2.4\n"
|
||||
f"Name: {package_name}\n"
|
||||
f"Version: {version}\n"
|
||||
f"Summary: {summary}\n"
|
||||
"License-File: COPYING\n"
|
||||
"License-File: COPYING.LESSER\n"
|
||||
"\n"
|
||||
).encode("utf-8")
|
||||
wheel = (
|
||||
"Wheel-Version: 1.0\n"
|
||||
"Generator: split_pyodide_ifcopenshell_wheel.py\n"
|
||||
f"Root-Is-Purelib: {str(root_is_purelib).lower()}\n"
|
||||
f"Tag: {tag}\n"
|
||||
"\n"
|
||||
).encode("utf-8")
|
||||
|
||||
with zipfile.ZipFile(out, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as zf:
|
||||
for info, data in payloads:
|
||||
zf.writestr(make_info(info.filename, source=info), data)
|
||||
entries[info.filename] = data
|
||||
|
||||
metadata_name = f"{di}/METADATA"
|
||||
wheel_meta_name = f"{di}/WHEEL"
|
||||
zf.writestr(make_info(metadata_name), metadata)
|
||||
zf.writestr(make_info(wheel_meta_name), wheel)
|
||||
entries[metadata_name] = metadata
|
||||
entries[wheel_meta_name] = wheel
|
||||
|
||||
for basename, data in license_files.items():
|
||||
name = f"{di}/licenses/{basename}"
|
||||
zf.writestr(make_info(name), data)
|
||||
entries[name] = data
|
||||
|
||||
entries[record_name] = None
|
||||
write_record(zf, entries, record_name)
|
||||
return out
|
||||
|
||||
|
||||
def rewrite_main_wheel(source: Path, target: Path, split_paths: set[str]) -> None:
|
||||
with zipfile.ZipFile(source) as zin, zipfile.ZipFile(
|
||||
target, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9
|
||||
) as zout:
|
||||
_, _, record_name = read_original_metadata(zin)
|
||||
entries: dict[str, bytes | None] = {}
|
||||
for info in zin.infolist():
|
||||
if info.filename in split_paths or info.filename == record_name:
|
||||
continue
|
||||
data = zin.read(info.filename)
|
||||
zout.writestr(make_info(info.filename, source=info), data)
|
||||
entries[info.filename] = data
|
||||
entries[record_name] = None
|
||||
write_record(zout, entries, record_name)
|
||||
|
||||
|
||||
def verify_wheel(path: Path) -> None:
|
||||
with zipfile.ZipFile(path) as zf:
|
||||
zf.testzip()
|
||||
metadata_name, wheel_name, record_name = read_original_metadata(zf)
|
||||
Parser().parsestr(zf.read(metadata_name).decode("utf-8"))
|
||||
wheel_text = zf.read(wheel_name).decode("utf-8")
|
||||
if "Wheel-Version:" not in wheel_text or "Tag:" not in wheel_text:
|
||||
raise ValueError(f"invalid WHEEL metadata in {path}")
|
||||
|
||||
record_rows = list(csv.reader(io.StringIO(zf.read(record_name).decode("utf-8"))))
|
||||
names = {row[0] for row in record_rows}
|
||||
missing = set(zf.namelist()) - names
|
||||
if missing:
|
||||
raise ValueError(f"{path} RECORD is missing entries: {sorted(missing)[:5]}")
|
||||
for name, digest, size in record_rows:
|
||||
if name == record_name:
|
||||
continue
|
||||
data = zf.read(name)
|
||||
if digest != sha256_record_value(data) or size != str(len(data)):
|
||||
raise ValueError(f"{path} RECORD mismatch for {name}")
|
||||
|
||||
|
||||
def split_wheel(wheel_path: Path, output_dir: Path) -> None:
|
||||
wheel_path = wheel_path.expanduser().resolve()
|
||||
if not wheel_path.exists():
|
||||
raise FileNotFoundError(wheel_path)
|
||||
|
||||
output_dir = output_dir.expanduser().resolve()
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
main_wheel_path = output_dir / wheel_path.name
|
||||
if main_wheel_path.resolve(strict=False) == wheel_path:
|
||||
raise ValueError("output directory must not point to the input wheel location")
|
||||
|
||||
_, version, py_tag, abi_tag, platform_tag = wheel_parts(wheel_path)
|
||||
binary_tag = f"{py_tag}-{abi_tag}-{platform_tag}"
|
||||
pure_tag = "py3-none-any"
|
||||
|
||||
with zipfile.ZipFile(wheel_path) as zf:
|
||||
file_infos = [info for info in zf.infolist() if not info.is_dir()]
|
||||
so_infos = [info for info in file_infos if info.filename.endswith(".so")]
|
||||
split_so_infos = [info for info in so_infos if not MAIN_SHARED_OBJECT_RE.search(Path(info.filename).name)]
|
||||
pure_python_infos = [info for info in file_infos if is_pure_python_split_path(info.filename)]
|
||||
if not split_so_infos and not pure_python_infos:
|
||||
raise RuntimeError("no secondary .so files or pure Python subpackages found to split")
|
||||
|
||||
license_files = {
|
||||
Path(info.filename).name: zf.read(info.filename)
|
||||
for info in file_infos
|
||||
if ".dist-info/licenses/" in info.filename
|
||||
}
|
||||
split_so_payloads = [(info, zf.read(info.filename)) for info in split_so_infos]
|
||||
pure_python_payloads = [(info, zf.read(info.filename)) for info in pure_python_infos]
|
||||
|
||||
created_wheels: list[Path] = []
|
||||
for info, data in split_so_payloads:
|
||||
package_name = shared_package_name(info.filename)
|
||||
created_wheels.append(
|
||||
build_wheel(
|
||||
output_dir,
|
||||
package_name,
|
||||
version,
|
||||
binary_tag,
|
||||
False,
|
||||
f"Pyodide shared library split from IfcOpenShell ({Path(info.filename).name}).",
|
||||
[(info, data)],
|
||||
license_files,
|
||||
)
|
||||
)
|
||||
|
||||
if pure_python_payloads:
|
||||
created_wheels.append(
|
||||
build_wheel(
|
||||
output_dir,
|
||||
PURE_PYTHON_PACKAGE_NAME,
|
||||
version,
|
||||
pure_tag,
|
||||
True,
|
||||
"Pure Python subpackages split from IfcOpenShell.",
|
||||
pure_python_payloads,
|
||||
license_files,
|
||||
)
|
||||
)
|
||||
|
||||
temp_main_wheel = output_dir / f".{wheel_path.name}.tmp"
|
||||
try:
|
||||
rewrite_main_wheel(
|
||||
wheel_path,
|
||||
temp_main_wheel,
|
||||
{info.filename for info, _ in split_so_payloads + pure_python_payloads},
|
||||
)
|
||||
verify_wheel(temp_main_wheel)
|
||||
for created in created_wheels:
|
||||
verify_wheel(created)
|
||||
os.replace(temp_main_wheel, main_wheel_path)
|
||||
finally:
|
||||
if temp_main_wheel.exists():
|
||||
temp_main_wheel.unlink()
|
||||
|
||||
|
||||
def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Extract optional IfcOpenShell Pyodide payloads into separate wheel artifacts."
|
||||
)
|
||||
parser.add_argument("wheel", help="IfcOpenShell Pyodide wheel to split")
|
||||
parser.add_argument("output_dir", help="Directory for generated wheels")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = parse_args(sys.argv[1:] if argv is None else argv)
|
||||
split_wheel(Path(args.wheel), Path(args.output_dir))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -14,6 +14,8 @@ def test_ifcopenshell_import(selenium):
|
||||
import micropip
|
||||
await micropip.install(f"./{WHEEL_FILENAME}")
|
||||
import ifcopenshell
|
||||
from pathlib import Path
|
||||
ifcopenshell.set_plugin_search_paths([str(Path(ifcopenshell.__file__).parent)])
|
||||
ifc_file = ifcopenshell.file()
|
||||
wall = ifc_file.create_entity("IfcWall")
|
||||
wall1 = ifc_file.by_type("IfcWall")[0]
|
||||
|
||||
@@ -316,7 +316,7 @@ def build_viewpoint(element: entity_instance) -> mdl.VisualizationInfo:
|
||||
Returns:
|
||||
The BCF viewpoint definition.
|
||||
"""
|
||||
ifc_file = element.wrapped_data.file
|
||||
ifc_file = element.file
|
||||
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file)
|
||||
elem_placement = ifcopenshell.util.placement.get_local_placement(element.ObjectPlacement)
|
||||
elem_placement[:3, 3] *= unit_scale
|
||||
|
||||
@@ -316,7 +316,7 @@ def build_viewpoint(element: entity_instance) -> mdl.VisualizationInfo:
|
||||
Returns:
|
||||
The BCF viewpoint definition.
|
||||
"""
|
||||
ifc_file = element.wrapped_data.file
|
||||
ifc_file = element.file
|
||||
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file)
|
||||
elem_placement = ifcopenshell.util.placement.get_local_placement(element.ObjectPlacement)
|
||||
elem_placement[:3, 3] *= unit_scale
|
||||
|
||||
-1
@@ -1 +0,0 @@
|
||||
This cache folder contains .h5 files. These files cache IFC geometry for performance only. You may safely clear the contents of this cache folder without losing data.
|
||||
@@ -43,7 +43,6 @@ class IfcExporter:
|
||||
def export(self):
|
||||
self.file = tool.Ifc.get()
|
||||
self.set_header()
|
||||
IfcStore.update_cache()
|
||||
self.sync_all_objects()
|
||||
extension = self.ifc_export_settings.output_file.split(".")[-1].lower()
|
||||
if extension == "ifczip":
|
||||
|
||||
@@ -187,7 +187,7 @@ def import_attributes(
|
||||
info = {a.name(): None for a in attributes}
|
||||
info["type"] = element
|
||||
else:
|
||||
assert (entity := element.wrapped_data.declaration().as_entity())
|
||||
assert (entity := element.declaration().as_entity())
|
||||
attributes = entity.all_attributes()
|
||||
info = element.get_info()
|
||||
for attribute in attributes:
|
||||
|
||||
@@ -18,9 +18,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import traceback
|
||||
import uuid
|
||||
@@ -31,7 +29,6 @@ from typing import Literal, NotRequired, Optional, TypedDict, Union
|
||||
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
import ifcopenshell.geom
|
||||
import ifcopenshell.ifcopenshell_wrapper
|
||||
from ifcopenshell.file import UndoSystemError
|
||||
|
||||
@@ -72,8 +69,6 @@ class IfcStore:
|
||||
"""Should be set only using ``tool.Ifc.set``."""
|
||||
|
||||
schema: Optional[ifcopenshell.ifcopenshell_wrapper.schema_definition] = None
|
||||
cache: Optional[ifcopenshell.ifcopenshell_wrapper.HdfSerializer] = None
|
||||
cache_path: Optional[str] = None
|
||||
id_map: dict[int, IFC_CONNECTED_TYPE] = {}
|
||||
guid_map: dict[str, IFC_CONNECTED_TYPE] = {}
|
||||
edited_objs: set[bpy.types.Object] = set()
|
||||
@@ -95,8 +90,6 @@ class IfcStore:
|
||||
IfcStore.path = ""
|
||||
IfcStore.file = None
|
||||
IfcStore.schema = None
|
||||
IfcStore.cache = None
|
||||
IfcStore.cache_path = None
|
||||
IfcStore.id_map = {}
|
||||
IfcStore.guid_map = {}
|
||||
IfcStore.edited_objs = set()
|
||||
@@ -130,74 +123,6 @@ class IfcStore:
|
||||
if IfcStore.path and not os.path.isabs(IfcStore.path):
|
||||
IfcStore.path = os.path.abspath(os.path.join(bpy.path.abspath("//"), IfcStore.path))
|
||||
|
||||
@staticmethod
|
||||
def generate_cache_path() -> str:
|
||||
"""Generate cache path based on the active file and it's path."""
|
||||
assert IfcStore.file
|
||||
ifc_key = IfcStore.path + IfcStore.file.header.file_name.time_stamp
|
||||
ifc_hash = hashlib.md5(ifc_key.encode("utf-8")).hexdigest()
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
cache_path = os.path.join(prefs.cache_dir, f"{ifc_hash}.h5")
|
||||
return cache_path
|
||||
|
||||
@staticmethod
|
||||
def get_cache() -> ifcopenshell.geom.serializers.hdf5 | None:
|
||||
"""Get existing cache for the current file or create a new one.
|
||||
|
||||
.h5 cache name reflects IFC filepath and it's current header's timestamp.
|
||||
"""
|
||||
if IfcStore.cache is None and IfcStore.path:
|
||||
cache_path = IfcStore.generate_cache_path()
|
||||
os.makedirs(os.path.dirname(cache_path), exist_ok=True)
|
||||
IfcStore.cache_path = cache_path
|
||||
cache_path = Path(IfcStore.cache_path)
|
||||
cache_settings = ifcopenshell.geom.settings()
|
||||
serializer_settings = ifcopenshell.geom.serializer_settings()
|
||||
cache_preexists = cache_path.exists()
|
||||
try:
|
||||
IfcStore.cache = ifcopenshell.geom.serializers.hdf5(
|
||||
IfcStore.cache_path, cache_settings, serializer_settings
|
||||
)
|
||||
if cache_preexists:
|
||||
print(f"Successfully loaded existing cache: {cache_path.name}.")
|
||||
else:
|
||||
print("New cache was created.")
|
||||
except Exception as e:
|
||||
if cache_preexists:
|
||||
print(f"Failed to create a cache from existing file '{cache_path.name}': {str(e)}.")
|
||||
else:
|
||||
print(f"Failed to create a cache: {str(e)}.")
|
||||
# No point to trying again the same operation.
|
||||
return
|
||||
|
||||
os.remove(IfcStore.cache_path)
|
||||
try:
|
||||
IfcStore.cache = ifcopenshell.geom.serializers.hdf5(
|
||||
IfcStore.cache_path, cache_settings, serializer_settings
|
||||
)
|
||||
print("New cache was created.")
|
||||
except Exception as e:
|
||||
print(f"Failed to create a cache: {str(e)}.")
|
||||
return
|
||||
return IfcStore.cache
|
||||
|
||||
@staticmethod
|
||||
def update_cache() -> None:
|
||||
"""Update cache filename after timestamp was updated."""
|
||||
if not IfcStore.cache:
|
||||
return
|
||||
assert IfcStore.cache_path
|
||||
new_cache_path = IfcStore.generate_cache_path()
|
||||
IfcStore.cache = None
|
||||
try:
|
||||
shutil.move(IfcStore.cache_path, new_cache_path)
|
||||
except PermissionError:
|
||||
try:
|
||||
shutil.copy2(IfcStore.cache_path, new_cache_path)
|
||||
except PermissionError:
|
||||
pass # Well we tried. No cache for you!
|
||||
IfcStore.get_cache()
|
||||
|
||||
@staticmethod
|
||||
def load_file(path: str) -> None:
|
||||
if not os.path.isfile(path):
|
||||
|
||||
@@ -721,10 +721,6 @@ class IfcImporter:
|
||||
iterator = ifcopenshell.geom.iterator(
|
||||
settings, self.file, include=products, geometry_library=self.ifc_import_settings.geometry_library
|
||||
)
|
||||
if self.ifc_import_settings.should_cache:
|
||||
cache = IfcStore.get_cache()
|
||||
if cache:
|
||||
iterator.set_cache(cache)
|
||||
valid_file = iterator.initialize()
|
||||
if not valid_file:
|
||||
return results
|
||||
@@ -1267,7 +1263,6 @@ class IfcImportSettings:
|
||||
self.should_merge_materials_by_colour = False
|
||||
self.should_load_geometry = True
|
||||
self.should_clean_mesh = False
|
||||
self.should_cache = True
|
||||
self.deflection_tolerance = 0.05 # Default is 0.001, but I find this to be more practical
|
||||
self.angular_tolerance = 0.5
|
||||
self.void_limit = 30
|
||||
@@ -1295,7 +1290,6 @@ class IfcImportSettings:
|
||||
context=None, input_file: Optional[str] = None, logger: Optional[logging.Logger] = None
|
||||
) -> IfcImportSettings:
|
||||
scene_diff = tool.Blender.get_diff_props()
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
props = tool.Project.get_project_props()
|
||||
settings = IfcImportSettings()
|
||||
settings.input_file = input_file
|
||||
@@ -1308,7 +1302,6 @@ class IfcImportSettings:
|
||||
settings.should_merge_materials_by_colour = props.should_merge_materials_by_colour
|
||||
settings.should_load_geometry = props.should_load_geometry
|
||||
settings.should_clean_mesh = props.should_clean_mesh
|
||||
settings.should_cache = prefs.should_always_cache or props.should_cache
|
||||
settings.deflection_tolerance = props.deflection_tolerance
|
||||
settings.angular_tolerance = props.angular_tolerance
|
||||
settings.void_limit = props.void_limit
|
||||
|
||||
@@ -206,7 +206,7 @@ class CostSchedulesData:
|
||||
data["UnitSymbol"] = ifcopenshell.util.unit.get_unit_symbol(unit)
|
||||
if quantity.is_a("IfcPhysicalSimpleQuantity"):
|
||||
measure_class = (
|
||||
quantity.wrapped_data.declaration()
|
||||
quantity.declaration()
|
||||
.as_entity()
|
||||
.attribute_by_index(3)
|
||||
.type_of_attribute()
|
||||
|
||||
@@ -37,7 +37,6 @@ classes = (
|
||||
operator.PrintObjectPlacement,
|
||||
operator.PrintUnusedElementStats,
|
||||
operator.ProfileImportIFC,
|
||||
operator.PurgeHdf5Cache,
|
||||
operator.PurgeUnusedElementsByClass,
|
||||
operator.PurgeUnusedObjects,
|
||||
operator.RestartBlender,
|
||||
|
||||
@@ -90,7 +90,7 @@ class PrintIfcFile(bpy.types.Operator):
|
||||
return tool.Ifc.get()
|
||||
|
||||
def execute(self, context):
|
||||
print(tool.Ifc.get().wrapped_data.to_string())
|
||||
print(tool.Ifc.get().to_string())
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
@@ -570,17 +570,6 @@ class SelectExpressFile(bpy.types.Operator, ImportHelper):
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class PurgeHdf5Cache(bpy.types.Operator):
|
||||
bl_idname = "bim.purge_hdf5_cache"
|
||||
bl_label = "Purge HDF5 Cache"
|
||||
bl_description = "Clean up HDF5 cache files except the ones that currently loaded"
|
||||
|
||||
def execute(self, context):
|
||||
core.purge_hdf5_cache(tool.Debug)
|
||||
self.report({"INFO"}, "HDF5 cache purged.")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class OverrideDisplayType(bpy.types.Operator):
|
||||
bl_idname = "bim.override_display_type"
|
||||
bl_label = "Override Display Type"
|
||||
|
||||
@@ -64,9 +64,6 @@ class BIM_PT_debug(Panel):
|
||||
row = layout.row()
|
||||
row.operator("bim.copy_debug_information")
|
||||
|
||||
row = layout.row()
|
||||
row.operator("bim.purge_hdf5_cache")
|
||||
|
||||
row = layout.row()
|
||||
row.operator("bim.update_representation", text="Manually Save Representation")
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import multiprocessing
|
||||
@@ -901,10 +900,7 @@ class CreateDrawing(bpy.types.Operator):
|
||||
# All very hackish whilst prototyping
|
||||
exporter = bonsai.bim.export_ifc.IfcExporter(None)
|
||||
exporter.file = tool.Ifc.get()
|
||||
invalidated_elements = exporter.sync_all_objects()
|
||||
invalidated_guids = [e.GlobalId for e in invalidated_elements if hasattr(e, "GlobalId")]
|
||||
if cache := IfcStore.get_cache():
|
||||
[cache.remove(guid) for guid in invalidated_guids]
|
||||
exporter.sync_all_objects()
|
||||
|
||||
# If we have already calculated it in the SVG in the past, don't recalculate
|
||||
edited_guids = set()
|
||||
@@ -922,7 +918,6 @@ class CreateDrawing(bpy.types.Operator):
|
||||
cached_linework -= edited_guids
|
||||
|
||||
bim_props = tool.Blender.get_bim_props()
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
files = {bim_props.ifc_file: tool.Ifc.get()}
|
||||
|
||||
props = tool.Project.get_project_props()
|
||||
@@ -935,12 +930,8 @@ class CreateDrawing(bpy.types.Operator):
|
||||
tree = ifcopenshell.geom.tree()
|
||||
tree.enable_face_styles(True)
|
||||
|
||||
for ifc_path, ifc in files.items():
|
||||
for ifc in files.values():
|
||||
# Don't use draw.main() just whilst we're prototyping and experimenting
|
||||
# TODO: hash paths are never used
|
||||
ifc_hash = hashlib.md5(ifc_path.encode("utf-8")).hexdigest()
|
||||
ifc_cache_path = os.path.join(prefs.cache_dir, f"{ifc_hash}.h5")
|
||||
|
||||
self.serialiser.setFile(ifc)
|
||||
drawing_elements = tool.Drawing.get_drawing_elements(self.camera_element, ifc_file=ifc)
|
||||
|
||||
|
||||
@@ -576,9 +576,6 @@ class UpdateRepresentation(bpy.types.Operator, tool.Ifc.Operator):
|
||||
self.report({"ERROR"}, f"Object '{obj.name}' has openings - representation cannot be updated.")
|
||||
return
|
||||
|
||||
if not product.is_a("IfcGridAxis"):
|
||||
tool.Geometry.clear_cache(product)
|
||||
|
||||
if product.is_a("IfcGridAxis"):
|
||||
# Grid geometry does not follow the "representation" paradigm and needs to be treated specially
|
||||
tool.Model.create_axis_curve(obj, product)
|
||||
@@ -790,7 +787,7 @@ def lock_error_message(name: str) -> str:
|
||||
|
||||
|
||||
def calc_delete_is_batch(ifc_file: ifcopenshell.file, context: bpy.types.Context) -> bool:
|
||||
total_elements = len(tool.Ifc.get().wrapped_data.entity_names())
|
||||
total_elements = len(tool.Ifc.get().entity_names())
|
||||
total_polygons = sum([len(o.data.polygons) for o in context.selected_objects if o.type == "MESH"])
|
||||
# These numbers are a bit arbitrary, but basically batching is only
|
||||
# really necessary on large models and large geometry removals.
|
||||
|
||||
@@ -1488,7 +1488,6 @@ class LoadLink(bpy.types.Operator, tool.Ifc.Operator):
|
||||
|
||||
def link_ifc(self) -> Union[set[str], None]:
|
||||
blend_filepath = self.filepath_.with_suffix(".ifc.cache.blend")
|
||||
h5_filepath = self.filepath_.with_suffix(".ifc.cache.h5")
|
||||
json_filepath = self.filepath_.with_suffix(".ifc.cache.json")
|
||||
|
||||
def should_clear_cache() -> bool:
|
||||
|
||||
@@ -345,14 +345,6 @@ class BIMProjectProperties(PropertyGroup):
|
||||
),
|
||||
default=False,
|
||||
)
|
||||
should_cache: BoolProperty(
|
||||
name="Cache",
|
||||
description=(
|
||||
"Cache loaded geometry to .h5 file in your cache directory (see in preferences) "
|
||||
"for faster imports and geometry reloads."
|
||||
),
|
||||
default=False,
|
||||
)
|
||||
deflection_tolerance: FloatProperty(name="Deflection Tolerance", default=0.05)
|
||||
angular_tolerance: FloatProperty(name="Angular Tolerance", default=0.5)
|
||||
void_limit: IntProperty(
|
||||
@@ -521,7 +513,6 @@ class BIMProjectProperties(PropertyGroup):
|
||||
should_merge_materials_by_colour: bool
|
||||
should_load_geometry: bool
|
||||
should_clean_mesh: bool
|
||||
should_cache: bool
|
||||
deflection_tolerance: float
|
||||
angular_tolerance: float
|
||||
void_limit: int
|
||||
|
||||
@@ -207,8 +207,6 @@ class BIM_PT_project(Panel):
|
||||
row = self.layout.row()
|
||||
row.prop(pprops, "should_clean_mesh")
|
||||
row = self.layout.row()
|
||||
row.prop(pprops, "should_cache")
|
||||
row = self.layout.row()
|
||||
row.prop(pprops, "should_load_geometry")
|
||||
row = self.layout.row()
|
||||
row.prop(pprops, "should_merge_materials_by_colour")
|
||||
|
||||
@@ -95,7 +95,7 @@ class ColourByPropertyData:
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element:
|
||||
return default
|
||||
keys = [a.name() for a in element.wrapped_data.declaration().as_entity().all_attributes()]
|
||||
keys = [a.name() for a in element.declaration().as_entity().all_attributes()]
|
||||
psets = ifcopenshell.util.element.get_psets(element)
|
||||
for pset, properties in psets.items():
|
||||
if pset.endswith("Common"):
|
||||
@@ -126,7 +126,7 @@ class SelectSimilarData:
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element:
|
||||
return []
|
||||
keys = [a.name() for a in element.wrapped_data.declaration().as_entity().all_attributes()]
|
||||
keys = [a.name() for a in element.declaration().as_entity().all_attributes()]
|
||||
psets = ifcopenshell.util.element.get_psets(element, psets_only=True)
|
||||
for pset, properties in psets.items():
|
||||
if pset.endswith("Common"):
|
||||
|
||||
@@ -207,7 +207,6 @@ class RemoveOpening(bpy.types.Operator, tool.Ifc.Operator):
|
||||
representation=representation,
|
||||
)
|
||||
tool.Geometry.unlock_scale_object_with_openings(obj)
|
||||
tool.Geometry.clear_cache(element)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
|
||||
@@ -665,10 +665,6 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
|
||||
name="Disable Undo When Saving (Faster saves, no undo for you!)", default=False
|
||||
)
|
||||
should_stream: BoolProperty(name="Stream Data From IFC-SPF (Only for advanced users)", default=False)
|
||||
should_always_cache: BoolProperty(
|
||||
name="Always Cache Geometry",
|
||||
description="Whether to always cache geometry regardless of 'Cache' setting during Advanced Project Load.",
|
||||
)
|
||||
occurrence_name_style: bpy.props.EnumProperty(
|
||||
items=[("CLASS", "By Class", ""), ("TYPE", "By Type", ""), ("CUSTOM", "Custom", "")],
|
||||
name="Occurrence Name Style",
|
||||
@@ -777,7 +773,6 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
|
||||
bsdd_baseurl: str
|
||||
should_disable_undo_on_save: bool
|
||||
should_stream: bool
|
||||
should_always_cache: bool
|
||||
occurrence_name_style: Literal["CLASS", "TYPE", "CUSTOM"]
|
||||
occurrence_name_function: str
|
||||
gizmos: GizmoPreferences
|
||||
@@ -976,7 +971,6 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
|
||||
layout.prop(self, "opening_focus_opacity")
|
||||
layout.prop(self, "should_disable_undo_on_save")
|
||||
layout.prop(self, "should_stream")
|
||||
layout.prop(self, "should_always_cache")
|
||||
layout.label(text="bSDD:")
|
||||
layout.prop(self, "bsdd_load_preview_dictionaries")
|
||||
layout.prop(self, "bsdd_load_inactive_dictionaries")
|
||||
|
||||
@@ -30,10 +30,6 @@ def parse_express(debug: type[tool.Debug], filename: str) -> None:
|
||||
debug.add_schema_identifier(debug.load_express(filename))
|
||||
|
||||
|
||||
def purge_hdf5_cache(debug: type[tool.Debug]) -> None:
|
||||
debug.purge_hdf5_cache()
|
||||
|
||||
|
||||
def purge_unused_elements(ifc: type[tool.Ifc], debug: type[tool.Debug], ifc_class: str) -> int:
|
||||
ifc_file = ifc.get()
|
||||
unused_elements = [i for i in ifc_file.by_type(ifc_class) if ifc_file.get_total_inverses(i) == 0]
|
||||
|
||||
@@ -44,7 +44,6 @@ def edit_object_placement(
|
||||
element = ifc.get_entity(obj)
|
||||
if not element:
|
||||
return
|
||||
geometry.clear_cache(element)
|
||||
if apply_scale:
|
||||
geometry.clear_scale(obj)
|
||||
geometry.get_blender_offset_type(obj)
|
||||
@@ -125,7 +124,6 @@ def switch_representation(
|
||||
|
||||
element = ifc.get_entity(obj)
|
||||
assert element
|
||||
geometry.clear_cache(element)
|
||||
geometry.reimport_element_representations(obj, representation, apply_openings=apply_openings)
|
||||
|
||||
|
||||
|
||||
@@ -283,7 +283,6 @@ class Cost:
|
||||
class Debug:
|
||||
def add_schema_identifier(cls, schema): pass
|
||||
def load_express(cls, filename): pass
|
||||
def purge_hdf5_cache(cls): pass
|
||||
|
||||
|
||||
@interface
|
||||
@@ -422,7 +421,6 @@ class Feature:
|
||||
@interface
|
||||
class Geometry:
|
||||
def change_object_data(cls, obj, data, is_global=False): pass
|
||||
def clear_cache(cls, element): pass
|
||||
def clear_modifiers(cls, obj): pass
|
||||
def clear_scale(cls, obj): pass
|
||||
def copy_data_links(cls, data, copied_entities) -> None: pass
|
||||
|
||||
@@ -59,17 +59,6 @@ class Debug(bonsai.core.tool.Debug):
|
||||
ifcopenshell.register_schema(schema)
|
||||
return schema.schema
|
||||
|
||||
@classmethod
|
||||
def purge_hdf5_cache(cls) -> None:
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
cache_dir = prefs.cache_dir
|
||||
filelist = [f for f in os.listdir(cache_dir) if f.endswith(".h5")]
|
||||
for f in filelist:
|
||||
try:
|
||||
os.remove(os.path.join(cache_dir, f))
|
||||
except PermissionError:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def debug_bmesh(cls, bm: bmesh.types.BMesh, name: str = "Debug") -> bpy.types.Object:
|
||||
mesh = bpy.data.meshes.new("Debug")
|
||||
|
||||
@@ -73,7 +73,6 @@ import bonsai.core.style
|
||||
import bonsai.core.system
|
||||
import bonsai.core.tool
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.ifc import IfcStore
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bonsai.bim.module.geometry.prop import (
|
||||
@@ -109,16 +108,6 @@ class Geometry(bonsai.core.tool.Geometry):
|
||||
raise Exception("user_remap is not supported for meshes in EDIT mode")
|
||||
old_data.user_remap(new_data)
|
||||
|
||||
@classmethod
|
||||
def get_cache(cls) -> Union[ifcopenshell.geom.serializers.hdf5, None]:
|
||||
return IfcStore.get_cache()
|
||||
|
||||
@classmethod
|
||||
def clear_cache(cls, element: ifcopenshell.entity_instance) -> None:
|
||||
cache = IfcStore.get_cache()
|
||||
if cache and hasattr(element, "GlobalId"):
|
||||
cache.remove(element.GlobalId)
|
||||
|
||||
@classmethod
|
||||
def clear_modifiers(cls, obj: bpy.types.Object) -> None:
|
||||
for modifier in obj.modifiers:
|
||||
@@ -818,7 +807,6 @@ class Geometry(bonsai.core.tool.Geometry):
|
||||
if not cls.has_data_users(old_data):
|
||||
cls.delete_data(old_data)
|
||||
cls.clear_modifiers(obj)
|
||||
cls.clear_cache(element)
|
||||
|
||||
# Import swept disk solids as Blender curves if possible.
|
||||
elements_without_openings = {e for e in elements if not getattr(e, "HasOpenings", False)}
|
||||
@@ -1809,7 +1797,7 @@ class Geometry(bonsai.core.tool.Geometry):
|
||||
item = tool.Ifc.get().by_id(props.ifc_definition_id)
|
||||
allowed_attributes = [
|
||||
a.name()
|
||||
for a in item.wrapped_data.declaration().as_entity().all_attributes()
|
||||
for a in item.declaration().as_entity().all_attributes()
|
||||
if a.type_of_attribute()._is("IfcLengthMeasure")
|
||||
]
|
||||
|
||||
|
||||
@@ -22,9 +22,6 @@ props = tool.Georeference.get_georeference_props()
|
||||
|
||||
props = tool.Project.get_project_props()
|
||||
|
||||
# Generally recommended to disable caching for stability right now
|
||||
props.should_cache = False
|
||||
|
||||
# If you are not authoring, it is recommended to enable this.
|
||||
# When enabled, types, openings, and non geometric elements are not loaded.
|
||||
props.is_coordinating = True
|
||||
|
||||
@@ -9,7 +9,6 @@ class BlenderImporter:
|
||||
|
||||
def __init__(self):
|
||||
self.file = ifcopenshell.open("/home/dion/untitled.ifc")
|
||||
self.cache_path = "cache.h5"
|
||||
self.should_use_cpu_multiprocessing = True
|
||||
self.deflection_tolerance = 0.001
|
||||
self.angular_tolerance = 0.5
|
||||
@@ -82,9 +81,6 @@ class BlenderImporter:
|
||||
iterator = ifcopenshell.geom.iterator(settings, self.file, multiprocessing.cpu_count(), include=products)
|
||||
else:
|
||||
iterator = ifcopenshell.geom.iterator(settings, self.file, include=products)
|
||||
cache = self.get_cache()
|
||||
if cache:
|
||||
iterator.set_cache(cache)
|
||||
valid_file = iterator.initialize()
|
||||
if not valid_file:
|
||||
return results
|
||||
@@ -113,13 +109,4 @@ class BlenderImporter:
|
||||
print("Done creating geometry")
|
||||
return results
|
||||
|
||||
def get_cache(self):
|
||||
cache_settings = ifcopenshell.geom.settings()
|
||||
serializer_settings = ifcopenshell.geom.serializer_settings()
|
||||
try:
|
||||
return ifcopenshell.geom.serializers.hdf5(self.cache_path, cache_settings, serializer_settings)
|
||||
except:
|
||||
return
|
||||
|
||||
|
||||
BlenderImporter().execute()
|
||||
|
||||
@@ -25,9 +25,3 @@ class TestParseExpress:
|
||||
debug.load_express("filename").should_be_called().will_return("schema")
|
||||
debug.add_schema_identifier("schema").should_be_called()
|
||||
subject.parse_express(debug, "filename")
|
||||
|
||||
|
||||
class TestPurgeHdf5Cache:
|
||||
def test_run(self, debug):
|
||||
debug.purge_hdf5_cache().should_be_called()
|
||||
subject.purge_hdf5_cache(debug)
|
||||
|
||||
@@ -24,7 +24,6 @@ from test.core.bootstrap import geometry, ifc, style, surveyor
|
||||
class TestEditObjectPlacement:
|
||||
def predict(self, ifc, geometry, surveyor):
|
||||
ifc.get_entity("obj").should_be_called().will_return("element")
|
||||
geometry.clear_cache("element").should_be_called()
|
||||
geometry.clear_scale("obj").should_be_called()
|
||||
geometry.get_blender_offset_type("obj").should_be_called()
|
||||
surveyor.get_absolute_matrix("obj").should_be_called().will_return("matrix")
|
||||
@@ -197,7 +196,6 @@ class TestSwitchRepresentation:
|
||||
def test_switching_to_a_representation(self, ifc, geometry):
|
||||
geometry.get_object_data("obj").should_be_called().will_return("current_obj_data")
|
||||
ifc.get_entity("obj").should_be_called().will_return("element")
|
||||
geometry.clear_cache("element").should_be_called()
|
||||
geometry.reimport_element_representations("obj", "mapped_rep", apply_openings=True).should_be_called()
|
||||
subject.switch_representation(
|
||||
ifc,
|
||||
|
||||
@@ -17,8 +17,6 @@
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.style
|
||||
@@ -55,24 +53,6 @@ class TestLoadExpress(NewFile):
|
||||
os.remove(schema_path + ".cache.dat")
|
||||
|
||||
|
||||
class TestPurgeHdf5Cache(NewFile):
|
||||
def test_run(self):
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
cache_dir = Path(prefs.cache_dir)
|
||||
test_file = cache_dir / "test.h5"
|
||||
test_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
test_file.touch()
|
||||
|
||||
# Ensure it can skip currently loaded cache.
|
||||
loaded_file_path = test_file.with_stem("test_loaded")
|
||||
loaded_file = open(loaded_file_path, "w")
|
||||
|
||||
subject.purge_hdf5_cache()
|
||||
# On Unix loaded files are not locked.
|
||||
paths = [loaded_file_path] if os.name == "nt" else []
|
||||
assert [f for f in cache_dir.iterdir() if f.suffix == ".h5"] == paths
|
||||
|
||||
|
||||
class TestMergeIdenticalObject(NewFile):
|
||||
def test_merge_identical_styles(self):
|
||||
tool.Ifc.set(ifc := ifcopenshell.file())
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from bonsaiviewer_autodesk.connector import AutodeskConnector
|
||||
from bonsaiviewer_autodesk.rpc import JsonRpcHost
|
||||
from bonsaiviewer_autodesk.ui import ensure_tk_app
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ensure_tk_app()
|
||||
connector = AutodeskConnector()
|
||||
host = JsonRpcHost(connector.handlers(), stdin=sys.stdin, stdout=sys.stdout, stderr=sys.stderr)
|
||||
return host.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,739 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import secrets
|
||||
import urllib.parse
|
||||
import webbrowser
|
||||
from dataclasses import asdict, dataclass
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
import httpx
|
||||
import keyring
|
||||
import keyring.errors
|
||||
|
||||
from bonsaiviewer_autodesk.rpc import JSONRPC_INTERNAL_ERROR, RpcError
|
||||
|
||||
|
||||
Progress = Callable[[str, str, "int | None"], None]
|
||||
|
||||
# (host, port, path, expected_state) -> authorization code
|
||||
CallbackWaiter = Callable[[str, int, str, str], str]
|
||||
|
||||
|
||||
def _utcnow() -> dt.datetime:
|
||||
return dt.datetime.now(dt.timezone.utc)
|
||||
|
||||
|
||||
def _no_keyring_error() -> RpcError:
|
||||
return RpcError(
|
||||
JSONRPC_INTERNAL_ERROR,
|
||||
"No secure keyring backend is available. On macOS, use Keychain; "
|
||||
"on Windows, use Credential Manager; on Linux, install a Secret Service "
|
||||
"backend such as gnome-keyring or KWallet.",
|
||||
)
|
||||
|
||||
|
||||
class KeyringTokenStore:
|
||||
def __init__(self, *, service_name: str, username: str) -> None:
|
||||
self.service_name = service_name
|
||||
self.username = username
|
||||
|
||||
def load(self) -> dict[str, Any] | None:
|
||||
try:
|
||||
raw = keyring.get_password(self.service_name, self.username)
|
||||
except keyring.errors.NoKeyringError as exc:
|
||||
raise _no_keyring_error() from exc
|
||||
return json.loads(raw) if raw else None
|
||||
|
||||
def save(self, value: dict[str, Any]) -> None:
|
||||
try:
|
||||
keyring.set_password(self.service_name, self.username, json.dumps(value))
|
||||
except keyring.errors.NoKeyringError as exc:
|
||||
raise _no_keyring_error() from exc
|
||||
|
||||
def delete(self) -> None:
|
||||
try:
|
||||
keyring.delete_password(self.service_name, self.username)
|
||||
except keyring.errors.PasswordDeleteError:
|
||||
pass
|
||||
except keyring.errors.NoKeyringError as exc:
|
||||
raise _no_keyring_error() from exc
|
||||
|
||||
|
||||
def _base64url(value: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def generate_code_verifier() -> str:
|
||||
return _base64url(secrets.token_bytes(48))
|
||||
|
||||
|
||||
def generate_code_challenge(verifier: str) -> str:
|
||||
return _base64url(hashlib.sha256(verifier.encode("ascii")).digest())
|
||||
|
||||
|
||||
@dataclass
|
||||
class StoredToken:
|
||||
client_id: str
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
access_token_expires_at_utc: str
|
||||
refresh_token_expires_at_utc: str
|
||||
scope: str
|
||||
|
||||
@property
|
||||
def access_token_expires_at(self) -> dt.datetime:
|
||||
return dt.datetime.fromisoformat(self.access_token_expires_at_utc)
|
||||
|
||||
@property
|
||||
def refresh_token_expires_at(self) -> dt.datetime:
|
||||
return dt.datetime.fromisoformat(self.refresh_token_expires_at_utc)
|
||||
|
||||
|
||||
def _noop_progress(_phase: str, _message: str, _percent: int | None = None) -> None:
|
||||
return
|
||||
|
||||
|
||||
def wait_for_oauth_callback(host: str, port: int, path: str, expected_state: str) -> str:
|
||||
"""Block on a single OAuth redirect to ``http://host:port/path`` and return
|
||||
the authorization code. Raises ``RpcError`` on an OAuth error, a ``state``
|
||||
mismatch, or a missing code. This is the default ``callback_waiter`` for
|
||||
:class:`AuthSessionService`; tests inject a stub instead."""
|
||||
result: dict[str, str] = {}
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self) -> None:
|
||||
parsed = urllib.parse.urlparse(self.path)
|
||||
if parsed.path != path:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
return
|
||||
query = urllib.parse.parse_qs(parsed.query)
|
||||
result["state"] = query.get("state", [""])[0]
|
||||
result["code"] = query.get("code", [""])[0]
|
||||
result["error"] = query.get("error", [""])[0]
|
||||
body = b"<html><body><h2>Authentication complete. You can close this window.</h2></body></html>"
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, format: str, *args: object) -> None:
|
||||
return
|
||||
|
||||
server = HTTPServer((host, port), Handler)
|
||||
server.handle_request()
|
||||
server.server_close()
|
||||
|
||||
if result.get("error"):
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Autodesk returned OAuth error '{result['error']}'.")
|
||||
if result.get("state") != expected_state:
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, "OAuth state mismatch.")
|
||||
code = result.get("code", "")
|
||||
if not code:
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, "OAuth callback did not return an authorization code.")
|
||||
return code
|
||||
|
||||
|
||||
class AuthSessionService:
|
||||
authorize_endpoint = "https://developer.api.autodesk.com/authentication/v2/authorize"
|
||||
token_endpoint = "https://developer.api.autodesk.com/authentication/v2/token"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
client_id: str,
|
||||
callback_url: str,
|
||||
scope: str,
|
||||
token_store: KeyringTokenStore,
|
||||
transport: httpx.BaseTransport | None = None,
|
||||
now: Callable[[], dt.datetime] | None = None,
|
||||
callback_waiter: CallbackWaiter | None = None,
|
||||
) -> None:
|
||||
self.client_id = client_id
|
||||
self.callback_url = callback_url
|
||||
self.scope = scope
|
||||
self.token_store = token_store
|
||||
self.http = httpx.Client(timeout=60, transport=transport)
|
||||
self._now = now or _utcnow
|
||||
self._callback_waiter = callback_waiter or wait_for_oauth_callback
|
||||
|
||||
def get_token(self) -> StoredToken | None:
|
||||
raw = self.token_store.load()
|
||||
return StoredToken(**raw) if raw else None
|
||||
|
||||
def ensure_access_token(self, progress: Progress = _noop_progress) -> str:
|
||||
token = self.get_token()
|
||||
now = self._now()
|
||||
if token and token.access_token_expires_at > now + dt.timedelta(minutes=1):
|
||||
return token.access_token
|
||||
if token and token.refresh_token_expires_at > now + dt.timedelta(minutes=1):
|
||||
return self._refresh(token, progress).access_token
|
||||
return self.login_interactive(progress).access_token
|
||||
|
||||
def login_interactive(self, progress: Progress = _noop_progress) -> StoredToken:
|
||||
progress("auth", "Preparing Autodesk sign-in", None)
|
||||
verifier = generate_code_verifier()
|
||||
challenge = generate_code_challenge(verifier)
|
||||
state = secrets.token_hex(16)
|
||||
callback = urllib.parse.urlparse(self.callback_url)
|
||||
if callback.scheme != "http" or callback.hostname not in {"127.0.0.1", "localhost"}:
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, "Callback URL must be http://localhost or http://127.0.0.1.")
|
||||
|
||||
query = urllib.parse.urlencode(
|
||||
{
|
||||
"response_type": "code",
|
||||
"client_id": self.client_id,
|
||||
"redirect_uri": self.callback_url,
|
||||
"scope": self.scope,
|
||||
"code_challenge": challenge,
|
||||
"code_challenge_method": "S256",
|
||||
"state": state,
|
||||
}
|
||||
)
|
||||
authorize_url = f"{self.authorize_endpoint}?{query}"
|
||||
|
||||
progress("auth", "Opening browser for Autodesk sign-in", None)
|
||||
webbrowser.open(authorize_url)
|
||||
code = self._callback_waiter(
|
||||
callback.hostname or "127.0.0.1",
|
||||
callback.port or 80,
|
||||
callback.path or "/",
|
||||
state,
|
||||
)
|
||||
|
||||
progress("auth", "Exchanging authorization code for token", None)
|
||||
response = self.http.post(
|
||||
self.token_endpoint,
|
||||
data={
|
||||
"client_id": self.client_id,
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"code_verifier": verifier,
|
||||
"redirect_uri": self.callback_url,
|
||||
},
|
||||
)
|
||||
if response.is_error:
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Token exchange failed: {response.text}")
|
||||
token = self._token_from_payload(response.json())
|
||||
self.token_store.save(asdict(token))
|
||||
progress("auth", "Signed in to Autodesk", 100)
|
||||
return token
|
||||
|
||||
def _refresh(self, token: StoredToken, progress: Progress) -> StoredToken:
|
||||
progress("auth", "Refreshing Autodesk session", None)
|
||||
response = self.http.post(
|
||||
self.token_endpoint,
|
||||
data={
|
||||
"client_id": self.client_id,
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": token.refresh_token,
|
||||
"scope": self.scope,
|
||||
},
|
||||
)
|
||||
if response.is_error:
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Token refresh failed: {response.text}")
|
||||
refreshed = self._token_from_payload(response.json())
|
||||
self.token_store.save(asdict(refreshed))
|
||||
progress("auth", "Session refreshed", 100)
|
||||
return refreshed
|
||||
|
||||
def _token_from_payload(self, payload: dict[str, Any]) -> StoredToken:
|
||||
now = self._now()
|
||||
refresh_ttl = int(payload.get("refresh_token_expires_in", 15 * 24 * 60 * 60))
|
||||
return StoredToken(
|
||||
client_id=self.client_id,
|
||||
access_token=payload["access_token"],
|
||||
refresh_token=payload["refresh_token"],
|
||||
access_token_expires_at_utc=(now + dt.timedelta(seconds=int(payload["expires_in"]) - 30)).isoformat(),
|
||||
refresh_token_expires_at_utc=(now + dt.timedelta(seconds=refresh_ttl - 30)).isoformat(),
|
||||
scope=self.scope,
|
||||
)
|
||||
|
||||
class ApsClient:
|
||||
def __init__(self, auth: AuthSessionService, *, transport: httpx.BaseTransport | None = None) -> None:
|
||||
self.auth = auth
|
||||
self.http = httpx.Client(timeout=120, transport=transport)
|
||||
|
||||
# Browsing -----------------------------------------------------------------
|
||||
|
||||
def list_hubs(self) -> list[dict[str, Any]]:
|
||||
payload = self._get_json("https://developer.api.autodesk.com/project/v1/hubs")
|
||||
hubs = [
|
||||
{
|
||||
"id": item["id"],
|
||||
"name": item["attributes"]["name"],
|
||||
"extension_type": item["attributes"]["extension"]["type"],
|
||||
}
|
||||
for item in payload.get("data", [])
|
||||
]
|
||||
hubs.sort(key=lambda h: (h["name"] or "").casefold())
|
||||
return hubs
|
||||
|
||||
def list_projects(self, hub_id: str) -> list[dict[str, Any]]:
|
||||
url = f"https://developer.api.autodesk.com/project/v1/hubs/{hub_id}/projects"
|
||||
projects: list[dict[str, Any]] = []
|
||||
while url:
|
||||
payload = self._get_json(url)
|
||||
for item in payload.get("data", []):
|
||||
projects.append(
|
||||
{
|
||||
"id": item["id"],
|
||||
"name": item["attributes"]["name"],
|
||||
"extension_type": item["attributes"]["extension"]["type"],
|
||||
"root_folder_id": item["relationships"]["rootFolder"]["data"]["id"],
|
||||
}
|
||||
)
|
||||
url = payload.get("links", {}).get("next", {}).get("href", "") or ""
|
||||
projects.sort(key=lambda p: (p["name"] or "").casefold())
|
||||
return projects
|
||||
|
||||
def list_top_folders(self, hub_id: str, project_id: str) -> list[dict[str, Any]]:
|
||||
payload = self._get_json(
|
||||
f"https://developer.api.autodesk.com/project/v1/hubs/{hub_id}/projects/{project_id}/topFolders"
|
||||
)
|
||||
folders = [self._entry(item) for item in payload.get("data", [])]
|
||||
folders.sort(key=lambda e: (e.get("display_name") or "").casefold())
|
||||
return folders
|
||||
|
||||
def list_folder_contents(
|
||||
self,
|
||||
project_id: str,
|
||||
folder_id: str,
|
||||
*,
|
||||
object_types: list[str] | None = None,
|
||||
extension_filter: Callable[[dict[str, Any]], bool] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
url = f"https://developer.api.autodesk.com/data/v1/projects/{project_id}/folders/{folder_id}/contents"
|
||||
if object_types:
|
||||
query = [("filter[type]", value) for value in object_types]
|
||||
url = f"{url}?{urllib.parse.urlencode(query, doseq=True)}"
|
||||
entries: list[dict[str, Any]] = []
|
||||
while url:
|
||||
payload = self._get_json(url)
|
||||
for item in payload.get("data", []):
|
||||
entry = self._entry(item)
|
||||
if extension_filter and entry["type"] == "items" and not extension_filter(entry):
|
||||
continue
|
||||
entries.append(entry)
|
||||
url = payload.get("links", {}).get("next", {}).get("href", "") or ""
|
||||
entries.sort(key=lambda e: (e.get("display_name") or "").casefold())
|
||||
return entries
|
||||
|
||||
def get_item(self, project_id: str, item_id: str) -> dict[str, Any]:
|
||||
"""Return the item plus its current tip in a single request.
|
||||
|
||||
``hidden`` reflects the item's soft-delete state (BIM 360 / ACC mark
|
||||
deleted items as ``hidden: true``; the storage URL may still resolve
|
||||
to a stale copy, so callers must check this before downloading).
|
||||
"""
|
||||
payload = self._get_json(
|
||||
f"https://developer.api.autodesk.com/data/v1/projects/{urllib.parse.quote(project_id, safe='')}"
|
||||
f"/items/{urllib.parse.quote(item_id, safe='')}?include=tip"
|
||||
)
|
||||
item = payload["data"]
|
||||
item_attributes = item.get("attributes", {})
|
||||
parent_folder_id = self._relationship_id(item, "parent")
|
||||
tip_id = self._relationship_id(item, "tip")
|
||||
tip: dict[str, Any] | None = None
|
||||
for included in payload.get("included", []):
|
||||
if included.get("type") == "versions" and included.get("id") == tip_id:
|
||||
tip = included
|
||||
break
|
||||
|
||||
if tip is None:
|
||||
return {
|
||||
"id": item["id"],
|
||||
"display_name": item_attributes.get("displayName")
|
||||
or item_attributes.get("name")
|
||||
or item["id"],
|
||||
"hidden": True,
|
||||
"version_id": None,
|
||||
"storage_id": None,
|
||||
"version_number": None,
|
||||
"last_modified_time_utc": None,
|
||||
"last_modified_user_name": None,
|
||||
"parent_folder_id": parent_folder_id,
|
||||
}
|
||||
|
||||
tip_attributes = tip.get("attributes", {})
|
||||
return {
|
||||
"id": item["id"],
|
||||
"display_name": tip_attributes.get("displayName")
|
||||
or tip_attributes.get("name")
|
||||
or item_attributes.get("displayName")
|
||||
or item["id"],
|
||||
"hidden": bool(item_attributes.get("hidden", False)),
|
||||
"version_id": tip["id"],
|
||||
"storage_id": self._relationship_id(tip, "storage"),
|
||||
"version_number": tip_attributes.get("versionNumber"),
|
||||
"last_modified_time_utc": tip_attributes.get("lastModifiedTime"),
|
||||
"last_modified_user_name": tip_attributes.get("lastModifiedUserName"),
|
||||
"parent_folder_id": parent_folder_id,
|
||||
}
|
||||
|
||||
# Download / upload --------------------------------------------------------
|
||||
|
||||
def download_storage_to_file(
|
||||
self,
|
||||
storage_id: str,
|
||||
destination_path: Path,
|
||||
*,
|
||||
progress: Callable[[str, int | None, int | None, int | None], None] | None = None,
|
||||
) -> None:
|
||||
bucket_key, object_key = self._parse_storage_id(storage_id)
|
||||
signed_url = self._get_signed_download_url(bucket_key, object_key)
|
||||
self._download_to_file(signed_url, destination_path, progress)
|
||||
|
||||
def upload_file_to_folder(
|
||||
self,
|
||||
project_id: str,
|
||||
folder_id: str,
|
||||
local_path: Path,
|
||||
*,
|
||||
display_name: str | None = None,
|
||||
progress: Callable[[str, int | None, int | None, int | None], None] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if not local_path.exists():
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Local file '{local_path}' does not exist.")
|
||||
file_name = display_name or local_path.name
|
||||
storage_id = self._create_storage(project_id, folder_id, file_name)
|
||||
bucket_key, object_key = self._parse_storage_id(storage_id)
|
||||
self._upload_local_file_to_oss(bucket_key, object_key, local_path, progress)
|
||||
existing_item = self._find_item_in_folder(project_id, folder_id, file_name)
|
||||
if existing_item is not None:
|
||||
return self._create_version(project_id, existing_item["id"], file_name, storage_id)
|
||||
return self._create_item(project_id, folder_id, file_name, storage_id)
|
||||
|
||||
# HTTP helpers -------------------------------------------------------------
|
||||
|
||||
def _get_json(self, url: str) -> dict[str, Any]:
|
||||
token = self.auth.ensure_access_token()
|
||||
try:
|
||||
response = self.http.get(url, headers={"Authorization": f"Bearer {token}"})
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
body = exc.response.text.strip()
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, body or f"HTTP {exc.response.status_code}") from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, str(exc)) from exc
|
||||
|
||||
def _post_json(self, url: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
token = self.auth.ensure_access_token()
|
||||
try:
|
||||
response = self.http.post(
|
||||
url,
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/vnd.api+json",
|
||||
"Accept": "application/vnd.api+json",
|
||||
},
|
||||
json=payload,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
body = exc.response.text.strip()
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, body or f"HTTP {exc.response.status_code}") from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, str(exc)) from exc
|
||||
|
||||
def _get_signed_download_url(self, bucket_key: str, object_key: str) -> str:
|
||||
payload = self._get_json(
|
||||
"https://developer.api.autodesk.com/oss/v2/buckets/"
|
||||
f"{urllib.parse.quote(bucket_key, safe='')}/objects/"
|
||||
f"{urllib.parse.quote(object_key, safe='')}/signeds3download"
|
||||
)
|
||||
url = payload.get("url")
|
||||
if not isinstance(url, str) or not url:
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, "Signed download URL response did not contain a URL.")
|
||||
return url
|
||||
|
||||
def _download_to_file(
|
||||
self,
|
||||
url: str,
|
||||
destination_path: Path,
|
||||
progress: Callable[[str, int | None, int | None, int | None], None] | None,
|
||||
) -> None:
|
||||
try:
|
||||
with self.http.stream("GET", url) as response:
|
||||
response.raise_for_status()
|
||||
total_bytes: int | None = None
|
||||
header_value = response.headers.get("Content-Length")
|
||||
if header_value and header_value.isdigit():
|
||||
total_bytes = int(header_value)
|
||||
downloaded_bytes = 0
|
||||
with open(destination_path, "wb") as handle:
|
||||
for chunk in response.iter_bytes():
|
||||
handle.write(chunk)
|
||||
downloaded_bytes += len(chunk)
|
||||
if progress and total_bytes:
|
||||
percent = min(100, int((downloaded_bytes / total_bytes) * 100))
|
||||
progress(destination_path.name, percent, downloaded_bytes, total_bytes)
|
||||
elif progress:
|
||||
progress(destination_path.name, None, downloaded_bytes, total_bytes)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
body = exc.response.text.strip()
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, body or f"HTTP {exc.response.status_code}") from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, str(exc)) from exc
|
||||
|
||||
def _create_storage(self, project_id: str, folder_id: str, file_name: str) -> str:
|
||||
payload = self._post_json(
|
||||
f"https://developer.api.autodesk.com/data/v1/projects/{urllib.parse.quote(project_id, safe='')}/storage",
|
||||
{
|
||||
"jsonapi": {"version": "1.0"},
|
||||
"data": {
|
||||
"type": "objects",
|
||||
"attributes": {"name": file_name},
|
||||
"relationships": {"target": {"data": {"type": "folders", "id": folder_id}}},
|
||||
},
|
||||
},
|
||||
)
|
||||
storage_id = payload.get("data", {}).get("id")
|
||||
if not isinstance(storage_id, str) or not storage_id:
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, "Storage creation did not return an object id.")
|
||||
return storage_id
|
||||
|
||||
def _upload_local_file_to_oss(
|
||||
self,
|
||||
bucket_key: str,
|
||||
object_key: str,
|
||||
local_path: Path,
|
||||
progress: Callable[[str, int | None, int | None, int | None], None] | None,
|
||||
) -> None:
|
||||
file_size = local_path.stat().st_size
|
||||
chunk_size = 5 * 1024 * 1024
|
||||
total_parts = max(1, math.ceil(file_size / chunk_size))
|
||||
upload_key: str | None = None
|
||||
parts_uploaded = 0
|
||||
bytes_uploaded = 0
|
||||
|
||||
with open(local_path, "rb") as handle:
|
||||
while parts_uploaded < total_parts:
|
||||
parts_to_request = min(total_parts - parts_uploaded, 5)
|
||||
first_part = parts_uploaded + 1
|
||||
signed = self._get_signed_upload_urls(
|
||||
bucket_key,
|
||||
object_key,
|
||||
upload_key=upload_key,
|
||||
first_part=first_part,
|
||||
parts=parts_to_request,
|
||||
)
|
||||
if upload_key is None:
|
||||
upload_key = signed.get("uploadKey")
|
||||
urls = signed.get("urls", [])
|
||||
if not isinstance(urls, list) or not urls:
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, "Upload URL response did not contain upload URLs.")
|
||||
for url in urls:
|
||||
if parts_uploaded >= total_parts:
|
||||
break
|
||||
chunk = handle.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
self._put_bytes(str(url), chunk)
|
||||
parts_uploaded += 1
|
||||
bytes_uploaded += len(chunk)
|
||||
if progress:
|
||||
percent = 100 if file_size == 0 else min(100, int((bytes_uploaded / file_size) * 100))
|
||||
progress(local_path.name, percent, bytes_uploaded, file_size)
|
||||
|
||||
if not upload_key:
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, "Upload did not return an upload key.")
|
||||
self._complete_signed_upload(bucket_key, object_key, upload_key)
|
||||
|
||||
def _get_signed_upload_urls(
|
||||
self,
|
||||
bucket_key: str,
|
||||
object_key: str,
|
||||
*,
|
||||
upload_key: str | None,
|
||||
first_part: int,
|
||||
parts: int,
|
||||
) -> dict[str, Any]:
|
||||
token = self.auth.ensure_access_token()
|
||||
params: dict[str, Any] = {"minutesExpiration": 10, "firstPart": first_part, "parts": parts}
|
||||
if upload_key:
|
||||
params["uploadKey"] = upload_key
|
||||
try:
|
||||
response = self.http.get(
|
||||
"https://developer.api.autodesk.com/oss/v2/buckets/"
|
||||
f"{urllib.parse.quote(bucket_key, safe='')}/objects/"
|
||||
f"{urllib.parse.quote(object_key, safe='')}/signeds3upload",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
params=params,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
body = exc.response.text.strip()
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, body or f"HTTP {exc.response.status_code}") from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, str(exc)) from exc
|
||||
|
||||
def _complete_signed_upload(self, bucket_key: str, object_key: str, upload_key: str) -> None:
|
||||
token = self.auth.ensure_access_token()
|
||||
try:
|
||||
response = self.http.post(
|
||||
"https://developer.api.autodesk.com/oss/v2/buckets/"
|
||||
f"{urllib.parse.quote(bucket_key, safe='')}/objects/"
|
||||
f"{urllib.parse.quote(object_key, safe='')}/signeds3upload",
|
||||
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
||||
json={"uploadKey": upload_key},
|
||||
)
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
body = exc.response.text.strip()
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, body or f"HTTP {exc.response.status_code}") from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, str(exc)) from exc
|
||||
|
||||
def _put_bytes(self, url: str, content: bytes) -> None:
|
||||
try:
|
||||
response = self.http.put(url, content=content, headers={"Content-Type": "application/octet-stream"})
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
body = exc.response.text.strip()
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, body or f"HTTP {exc.response.status_code}") from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, str(exc)) from exc
|
||||
|
||||
def _find_item_in_folder(self, project_id: str, folder_id: str, file_name: str) -> dict[str, Any] | None:
|
||||
children = self.list_folder_contents(project_id, folder_id, object_types=["items"])
|
||||
return next(
|
||||
(
|
||||
child for child in children
|
||||
if self._entry_name_matches(child, file_name)
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
def _create_version(self, project_id: str, item_id: str, file_name: str, storage_id: str) -> dict[str, Any]:
|
||||
payload = self._post_json(
|
||||
f"https://developer.api.autodesk.com/data/v1/projects/{urllib.parse.quote(project_id, safe='')}/versions",
|
||||
{
|
||||
"jsonapi": {"version": "1.0"},
|
||||
"data": {
|
||||
"type": "versions",
|
||||
"attributes": {
|
||||
"name": file_name,
|
||||
"extension": {"type": "versions:autodesk.bim360:File", "version": "1.0"},
|
||||
},
|
||||
"relationships": {
|
||||
"item": {"data": {"type": "items", "id": item_id}},
|
||||
"storage": {"data": {"type": "objects", "id": storage_id}},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
version = payload["data"]
|
||||
attributes = version.get("attributes", {})
|
||||
return {
|
||||
"item_id": item_id,
|
||||
"version_id": version["id"],
|
||||
"display_name": file_name,
|
||||
"version_number": attributes.get("versionNumber"),
|
||||
"last_modified_time_utc": attributes.get("lastModifiedTime"),
|
||||
"last_modified_user_name": attributes.get("lastModifiedUserName"),
|
||||
}
|
||||
|
||||
def _create_item(self, project_id: str, folder_id: str, file_name: str, storage_id: str) -> dict[str, Any]:
|
||||
payload = self._post_json(
|
||||
f"https://developer.api.autodesk.com/data/v1/projects/{urllib.parse.quote(project_id, safe='')}/items",
|
||||
{
|
||||
"jsonapi": {"version": "1.0"},
|
||||
"data": {
|
||||
"type": "items",
|
||||
"attributes": {
|
||||
"displayName": file_name,
|
||||
"extension": {"type": "items:autodesk.bim360:File", "version": "1.0"},
|
||||
},
|
||||
"relationships": {
|
||||
"tip": {"data": {"type": "versions", "id": "1"}},
|
||||
"parent": {"data": {"type": "folders", "id": folder_id}},
|
||||
},
|
||||
},
|
||||
"included": [
|
||||
{
|
||||
"type": "versions",
|
||||
"id": "1",
|
||||
"attributes": {
|
||||
"name": file_name,
|
||||
"extension": {"type": "versions:autodesk.bim360:File", "version": "1.0"},
|
||||
},
|
||||
"relationships": {"storage": {"data": {"type": "objects", "id": storage_id}}},
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
item = payload["data"]
|
||||
version_id = "1"
|
||||
version_number: Any = 1
|
||||
last_modified_time: Any = None
|
||||
last_modified_user: Any = None
|
||||
for included in payload.get("included", []):
|
||||
if included.get("type") == "versions":
|
||||
version_id = included.get("id") or version_id
|
||||
attributes = included.get("attributes", {})
|
||||
version_number = attributes.get("versionNumber", version_number)
|
||||
last_modified_time = attributes.get("lastModifiedTime")
|
||||
last_modified_user = attributes.get("lastModifiedUserName")
|
||||
break
|
||||
return {
|
||||
"item_id": item["id"],
|
||||
"version_id": version_id,
|
||||
"display_name": file_name,
|
||||
"version_number": version_number,
|
||||
"last_modified_time_utc": last_modified_time,
|
||||
"last_modified_user_name": last_modified_user,
|
||||
}
|
||||
|
||||
# Static helpers -----------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _parse_storage_id(storage_id: str) -> tuple[str, str]:
|
||||
marker = "urn:adsk.objects:os.object:"
|
||||
if not storage_id.startswith(marker):
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Unsupported storage identifier '{storage_id}'.")
|
||||
path = storage_id[len(marker):]
|
||||
slash = path.find("/")
|
||||
if slash <= 0 or slash == len(path) - 1:
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Malformed storage identifier '{storage_id}'.")
|
||||
return path[:slash], path[slash + 1 :]
|
||||
|
||||
@staticmethod
|
||||
def _entry(item: dict[str, Any]) -> dict[str, Any]:
|
||||
attributes = item["attributes"]
|
||||
return {
|
||||
"id": item["id"],
|
||||
"type": item["type"],
|
||||
"display_name": attributes.get("displayName") or attributes.get("name") or "",
|
||||
"name": attributes.get("name"),
|
||||
"extension_type": attributes.get("extension", {}).get("type", ""),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _relationship_id(data: dict[str, Any], name: str) -> str | None:
|
||||
rel_data = data.get("relationships", {}).get(name, {}).get("data")
|
||||
if isinstance(rel_data, dict):
|
||||
rel_id = rel_data.get("id")
|
||||
return rel_id if isinstance(rel_id, str) and rel_id else None
|
||||
if isinstance(rel_data, list) and rel_data:
|
||||
rel_id = rel_data[0].get("id")
|
||||
return rel_id if isinstance(rel_id, str) and rel_id else None
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _entry_name_matches(entry: dict[str, Any], expected_name: str) -> bool:
|
||||
display_name = str(entry.get("display_name") or "").lower()
|
||||
raw_name = str(entry.get("name") or "").lower()
|
||||
expected = expected_name.lower()
|
||||
return display_name == expected or raw_name == expected
|
||||
@@ -0,0 +1,62 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def cache_root() -> Path:
|
||||
system = platform.system()
|
||||
if system == "Windows":
|
||||
base = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~")
|
||||
root = Path(base) / "bonsaiviewer-autodesk" / "Cache"
|
||||
elif system == "Darwin":
|
||||
root = Path.home() / "Library" / "Caches" / "bonsaiviewer-autodesk"
|
||||
else:
|
||||
base = os.environ.get("XDG_CACHE_HOME") or os.path.expanduser("~/.cache")
|
||||
root = Path(base) / "bonsaiviewer-autodesk"
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return root
|
||||
|
||||
|
||||
def _short_hash(*parts: str) -> str:
|
||||
joined = "\x1f".join(parts)
|
||||
return hashlib.sha256(joined.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
def ifcfed_dir(project_id: str, item_id: str) -> Path:
|
||||
"""Stable directory for an .ifcfed. Re-downloads overwrite in place so the
|
||||
viewer's open path remains valid across sync operations."""
|
||||
return cache_root() / "ifcfeds" / _short_hash(project_id, item_id)
|
||||
|
||||
|
||||
def model_dir(project_id: str, item_id: str, version_id: str) -> Path:
|
||||
"""Per-version directory for a model. A new resolved version → a new
|
||||
directory, satisfying the spec's invariant that sidecars regenerate when
|
||||
the model file changes."""
|
||||
return cache_root() / "models" / _short_hash(project_id, item_id, version_id)
|
||||
|
||||
|
||||
def prepare_sole_child_dir(directory: Path) -> Path:
|
||||
"""Clear the directory so the file we write is the only child."""
|
||||
if directory.exists():
|
||||
shutil.rmtree(directory)
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
return directory
|
||||
|
||||
|
||||
def write_manifest(ifcfed_path: Path, manifest: dict[str, Any]) -> Path:
|
||||
manifest_path = ifcfed_path.with_name(ifcfed_path.name + ".manifest")
|
||||
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
||||
return manifest_path
|
||||
|
||||
|
||||
def read_manifest(ifcfed_path: Path) -> dict[str, Any] | None:
|
||||
manifest_path = ifcfed_path.with_name(ifcfed_path.name + ".manifest")
|
||||
if not manifest_path.exists():
|
||||
return None
|
||||
return json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
@@ -0,0 +1,563 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from bonsaiviewer_autodesk import cache, settings
|
||||
from bonsaiviewer_autodesk.autodesk import ApsClient, AuthSessionService, KeyringTokenStore
|
||||
from bonsaiviewer_autodesk.rpc import JSONRPC_INTERNAL_ERROR, JSONRPC_INVALID_PARAMS, RpcError
|
||||
from bonsaiviewer_autodesk.ui import BrowseDialog, SettingsDialog, prompt_for_filename, run_with_progress
|
||||
|
||||
|
||||
ApsProgress = Callable[[str, "int | None", "int | None", "int | None"], None]
|
||||
Report = Callable[[str, str, "int | None", "str | None"], None]
|
||||
|
||||
|
||||
def _format_bytes(value: int) -> str:
|
||||
"""Render a byte count as a short human-readable string (e.g. '3.4 MB')."""
|
||||
if value < 1024:
|
||||
return f"{value} B"
|
||||
scaled = float(value)
|
||||
for unit in ("KB", "MB", "GB", "TB"):
|
||||
scaled /= 1024.0
|
||||
if scaled < 1024 or unit == "TB":
|
||||
return f"{scaled:.1f} {unit}"
|
||||
return f"{value} B"
|
||||
|
||||
|
||||
def _progress_detail(percent: int | None, done: int | None, total: int | None) -> str:
|
||||
"""Build the stats line shown beneath the filename, e.g. '45%, 4.5 MB / 10.0 MB'."""
|
||||
parts: list[str] = []
|
||||
if percent is not None:
|
||||
parts.append(f"{percent}%")
|
||||
if done is not None and total:
|
||||
parts.append(f"{_format_bytes(done)} / {_format_bytes(total)}")
|
||||
elif done is not None:
|
||||
parts.append(_format_bytes(done))
|
||||
return ", ".join(parts)
|
||||
|
||||
|
||||
def _download_callback(report: Report, index: int = 0, total: int = 0) -> ApsProgress:
|
||||
"""Adapt ProgressDialog.report to the APS download callback.
|
||||
|
||||
index/total render "(i/N)" suffix when batching; pass 0 (the default) for
|
||||
single-file downloads to omit the suffix.
|
||||
"""
|
||||
def cb(name: str, percent: int | None, bytes_done: int | None, bytes_total: int | None) -> None:
|
||||
suffix = f" ({index}/{total})" if total else ""
|
||||
detail = _progress_detail(percent, bytes_done, bytes_total)
|
||||
report("download", f"Downloading {name}{suffix}", percent, detail)
|
||||
return cb
|
||||
|
||||
|
||||
def _upload_callback(report: Report) -> ApsProgress:
|
||||
def cb(name: str, percent: int | None, bytes_done: int | None, bytes_total: int | None) -> None:
|
||||
detail = _progress_detail(percent, bytes_done, bytes_total)
|
||||
report("upload", f"Uploading {name}", percent, detail)
|
||||
return cb
|
||||
|
||||
|
||||
CONNECTOR_ID = "autodesk"
|
||||
KEYRING_SERVICE = "bonsaiviewer-autodesk"
|
||||
DEFAULT_SCOPE = "data:read data:write data:create"
|
||||
|
||||
|
||||
class AutodeskConnector:
|
||||
def __init__(self) -> None:
|
||||
self.auth: AuthSessionService | None = None
|
||||
self.aps: ApsClient | None = None
|
||||
self.reload_credentials()
|
||||
|
||||
def reload_credentials(self) -> None:
|
||||
"""Rebuild auth + APS from current settings. Safe to call any time."""
|
||||
client_id = settings.load_client_id()
|
||||
if not client_id:
|
||||
self.auth = None
|
||||
self.aps = None
|
||||
return
|
||||
token_store = KeyringTokenStore(service_name=KEYRING_SERVICE, username=client_id)
|
||||
callback_url = f"http://localhost:{settings.stored_callback_port()}/"
|
||||
self.auth = AuthSessionService(
|
||||
client_id=client_id,
|
||||
callback_url=callback_url,
|
||||
scope=DEFAULT_SCOPE,
|
||||
token_store=token_store,
|
||||
)
|
||||
self.aps = ApsClient(self.auth)
|
||||
|
||||
def _require_aps(self) -> tuple[AuthSessionService, ApsClient]:
|
||||
if self.auth is None or self.aps is None:
|
||||
raise RpcError(
|
||||
JSONRPC_INTERNAL_ERROR,
|
||||
"Autodesk client id is not configured. Open the connector settings to set it.",
|
||||
)
|
||||
return self.auth, self.aps
|
||||
|
||||
def handlers(self) -> dict[str, Any]:
|
||||
return {
|
||||
"pull_ifcfed_interactive": self.pull_ifcfed_interactive,
|
||||
"pull_ifcfed": self.pull_ifcfed,
|
||||
"pull_models": self.pull_models,
|
||||
"pull_models_interactive": self.pull_models_interactive,
|
||||
"push_ifcfed_interactive": self.push_ifcfed_interactive,
|
||||
"push_ifcfed": self.push_ifcfed,
|
||||
"push_model_interactive": self.push_model_interactive,
|
||||
"push_model": self.push_model,
|
||||
"open_settings": self.open_settings,
|
||||
}
|
||||
|
||||
# ---- open_settings ------------------------------------------------------
|
||||
|
||||
def open_settings(self, _params: Any) -> dict[str, Any]:
|
||||
SettingsDialog(connector=self).run()
|
||||
return {}
|
||||
|
||||
# ---- pull_ifcfed_interactive --------------------------------------------
|
||||
|
||||
def pull_ifcfed_interactive(self, _params: Any) -> dict[str, Any]:
|
||||
auth, aps = self._require_aps()
|
||||
chosen = BrowseDialog(auth=auth, aps=aps, mode="ifcfed").run()
|
||||
hub = chosen["hub"]
|
||||
project = chosen["project"]
|
||||
entry = chosen["entries"][0]
|
||||
path = run_with_progress(
|
||||
"Downloading project",
|
||||
lambda report: self._download_ifcfed(
|
||||
aps=aps,
|
||||
hub_id=hub["id"],
|
||||
project_id=project["id"],
|
||||
item_id=entry["id"],
|
||||
display_name=entry["display_name"],
|
||||
progress=_download_callback(report),
|
||||
),
|
||||
)
|
||||
return {"path": str(path)}
|
||||
|
||||
# ---- pull_ifcfed --------------------------------------------------------
|
||||
|
||||
def pull_ifcfed(self, params: Any) -> dict[str, Any]:
|
||||
_, aps = self._require_aps()
|
||||
manifest = _require_object(params, "params")
|
||||
hub_id = _require_string(manifest, "hub_id")
|
||||
project_id = _require_string(manifest, "project_id")
|
||||
item_id = _require_string(manifest, "item_id")
|
||||
display_name = manifest.get("display_name") or item_id
|
||||
path = run_with_progress(
|
||||
"Downloading project",
|
||||
lambda report: self._download_ifcfed(
|
||||
aps=aps,
|
||||
hub_id=hub_id,
|
||||
project_id=project_id,
|
||||
item_id=item_id,
|
||||
display_name=display_name,
|
||||
progress=_download_callback(report),
|
||||
),
|
||||
)
|
||||
return {"path": str(path)}
|
||||
|
||||
def _download_ifcfed(
|
||||
self,
|
||||
*,
|
||||
aps: ApsClient,
|
||||
hub_id: str,
|
||||
project_id: str,
|
||||
item_id: str,
|
||||
display_name: str,
|
||||
progress: ApsProgress | None = None,
|
||||
) -> Path:
|
||||
item = aps.get_item(project_id, item_id)
|
||||
if item["hidden"]:
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Autodesk item '{item_id}' has been deleted.")
|
||||
storage_id = item["storage_id"]
|
||||
if not isinstance(storage_id, str):
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Autodesk item '{item_id}' has no downloadable storage.")
|
||||
file_name = item["display_name"] or display_name or item_id
|
||||
if not file_name.lower().endswith(".ifcfed"):
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Item '{file_name}' is not an .ifcfed file.")
|
||||
|
||||
directory = cache.prepare_sole_child_dir(cache.ifcfed_dir(project_id, item_id))
|
||||
ifcfed_path = directory / file_name
|
||||
aps.download_storage_to_file(storage_id, ifcfed_path, progress=progress)
|
||||
cache.write_manifest(
|
||||
ifcfed_path,
|
||||
{
|
||||
"connector": CONNECTOR_ID,
|
||||
"hub_id": hub_id,
|
||||
"project_id": project_id,
|
||||
"item_id": item_id,
|
||||
"display_name": file_name,
|
||||
},
|
||||
)
|
||||
return ifcfed_path
|
||||
|
||||
# ---- pull_models --------------------------------------------------------
|
||||
|
||||
def pull_models(self, params: Any) -> list[dict[str, Any] | None]:
|
||||
_, aps = self._require_aps()
|
||||
models = _require_array(params, "params")
|
||||
total = len(models)
|
||||
|
||||
def work(report: Report) -> list[dict[str, Any] | None]:
|
||||
results: list[dict[str, Any] | None] = []
|
||||
for index, model in enumerate(models):
|
||||
callback = _download_callback(report, index=index + 1, total=total)
|
||||
try:
|
||||
results.append(self._resolve_model(aps, model, progress=callback))
|
||||
except RpcError as exc:
|
||||
print(f"pull_models[{index}] skipped: {exc.message}", file=sys.stderr)
|
||||
results.append(None)
|
||||
except Exception as exc:
|
||||
print(f"pull_models[{index}] skipped: {exc}", file=sys.stderr)
|
||||
traceback.print_exc(file=sys.stderr)
|
||||
results.append(None)
|
||||
return results
|
||||
|
||||
return run_with_progress("Downloading models", work)
|
||||
|
||||
def _resolve_model(
|
||||
self,
|
||||
aps: ApsClient,
|
||||
model: Any,
|
||||
*,
|
||||
progress: ApsProgress | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
if not isinstance(model, dict):
|
||||
raise RpcError(JSONRPC_INVALID_PARAMS, "Each model entry must be an object.")
|
||||
source = model.get("source")
|
||||
if not isinstance(source, dict):
|
||||
raise RpcError(JSONRPC_INVALID_PARAMS, "Each model entry must have a 'source' object.")
|
||||
if source.get("connector") != CONNECTOR_ID:
|
||||
raise RpcError(JSONRPC_INVALID_PARAMS, f"Source connector is not '{CONNECTOR_ID}'.")
|
||||
project_id = _require_string(source, "project_id")
|
||||
item_id = _require_string(source, "item_id")
|
||||
display_name_hint = model.get("display_name") or item_id
|
||||
|
||||
item = aps.get_item(project_id, item_id)
|
||||
if item["hidden"]:
|
||||
print(f"Autodesk item '{item_id}' is hidden/deleted; returning null.", file=sys.stderr)
|
||||
return None
|
||||
|
||||
storage_id = item["storage_id"]
|
||||
if not isinstance(storage_id, str):
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Autodesk item '{item_id}' has no downloadable storage.")
|
||||
file_name = item["display_name"] or display_name_hint
|
||||
version_id = item["version_id"]
|
||||
|
||||
directory = cache.model_dir(project_id, item_id, version_id)
|
||||
model_path = directory / file_name
|
||||
if not model_path.exists():
|
||||
cache.prepare_sole_child_dir(directory)
|
||||
aps.download_storage_to_file(storage_id, model_path, progress=progress)
|
||||
|
||||
return {
|
||||
"path": str(model_path),
|
||||
"metadata": _build_metadata(item),
|
||||
}
|
||||
|
||||
# ---- pull_models_interactive --------------------------------------------
|
||||
|
||||
def pull_models_interactive(self, _params: Any) -> list[dict[str, Any]]:
|
||||
auth, aps = self._require_aps()
|
||||
chosen = BrowseDialog(auth=auth, aps=aps, mode="model").run()
|
||||
hub = chosen["hub"]
|
||||
project = chosen["project"]
|
||||
entries = chosen["entries"]
|
||||
total = len(entries)
|
||||
|
||||
def work(report: Report) -> list[dict[str, Any]]:
|
||||
results: list[dict[str, Any]] = []
|
||||
for index, entry in enumerate(entries):
|
||||
callback = _download_callback(report, index=index + 1, total=total)
|
||||
try:
|
||||
result = self._download_picked_model(aps, hub, project, entry, callback)
|
||||
except RpcError as exc:
|
||||
print(f"pull_models_interactive[{index}] skipped: {exc.message}", file=sys.stderr)
|
||||
continue
|
||||
except Exception as exc:
|
||||
print(f"pull_models_interactive[{index}] skipped: {exc}", file=sys.stderr)
|
||||
traceback.print_exc(file=sys.stderr)
|
||||
continue
|
||||
if result is not None:
|
||||
results.append(result)
|
||||
return results
|
||||
|
||||
return run_with_progress("Downloading models", work)
|
||||
|
||||
def _download_picked_model(
|
||||
self,
|
||||
aps: ApsClient,
|
||||
hub: dict[str, Any],
|
||||
project: dict[str, Any],
|
||||
entry: dict[str, Any],
|
||||
progress: ApsProgress,
|
||||
) -> dict[str, Any] | None:
|
||||
item = aps.get_item(project["id"], entry["id"])
|
||||
if item["hidden"]:
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Autodesk item '{entry['id']}' has been deleted.")
|
||||
storage_id = item["storage_id"]
|
||||
if not isinstance(storage_id, str):
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Autodesk item '{entry['id']}' has no downloadable storage.")
|
||||
|
||||
file_name = item["display_name"] or entry["display_name"] or entry["id"]
|
||||
version_id = item["version_id"]
|
||||
|
||||
directory = cache.model_dir(project["id"], entry["id"], version_id)
|
||||
model_path = directory / file_name
|
||||
if not model_path.exists():
|
||||
cache.prepare_sole_child_dir(directory)
|
||||
aps.download_storage_to_file(storage_id, model_path, progress=progress)
|
||||
|
||||
return {
|
||||
"display_name": file_name,
|
||||
"source": {
|
||||
"connector": CONNECTOR_ID,
|
||||
"hub_id": hub["id"],
|
||||
"project_id": project["id"],
|
||||
"item_id": entry["id"],
|
||||
},
|
||||
"path": str(model_path),
|
||||
"metadata": _build_metadata(item),
|
||||
}
|
||||
|
||||
# ---- push_ifcfed_interactive --------------------------------------------
|
||||
|
||||
def push_ifcfed_interactive(self, params: Any) -> dict[str, Any]:
|
||||
auth, aps = self._require_aps()
|
||||
params_obj = _require_object(params, "params")
|
||||
local_path = Path(_require_string(params_obj, "path"))
|
||||
if not local_path.exists():
|
||||
raise RpcError(JSONRPC_INVALID_PARAMS, f"Local file '{local_path}' does not exist.")
|
||||
if not local_path.name.lower().endswith(".ifcfed"):
|
||||
raise RpcError(JSONRPC_INVALID_PARAMS, "push_ifcfed_interactive expects an .ifcfed file.")
|
||||
|
||||
chosen = BrowseDialog(auth=auth, aps=aps, mode="destination").run()
|
||||
hub = chosen["hub"]
|
||||
project = chosen["project"]
|
||||
folder = chosen["entries"][0]
|
||||
|
||||
file_name = prompt_for_filename(
|
||||
title="Save Project",
|
||||
label="Save .ifcfed as:",
|
||||
default=local_path.name,
|
||||
)
|
||||
if not file_name:
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, "User cancelled save to cloud.")
|
||||
if not file_name.lower().endswith(".ifcfed"):
|
||||
file_name = file_name + ".ifcfed"
|
||||
|
||||
uploaded = run_with_progress(
|
||||
"Uploading project",
|
||||
lambda report: aps.upload_file_to_folder(
|
||||
project["id"],
|
||||
folder["id"],
|
||||
local_path,
|
||||
display_name=file_name,
|
||||
progress=_upload_callback(report),
|
||||
),
|
||||
)
|
||||
|
||||
directory = cache.prepare_sole_child_dir(cache.ifcfed_dir(project["id"], uploaded["item_id"]))
|
||||
cached_path = directory / file_name
|
||||
cached_path.write_bytes(local_path.read_bytes())
|
||||
cache.write_manifest(
|
||||
cached_path,
|
||||
{
|
||||
"connector": CONNECTOR_ID,
|
||||
"hub_id": hub["id"],
|
||||
"project_id": project["id"],
|
||||
"item_id": uploaded["item_id"],
|
||||
"display_name": file_name,
|
||||
},
|
||||
)
|
||||
return {"path": str(cached_path)}
|
||||
|
||||
# ---- push_ifcfed --------------------------------------------------------
|
||||
|
||||
def push_ifcfed(self, params: Any) -> dict[str, Any]:
|
||||
_, aps = self._require_aps()
|
||||
params_obj = _require_object(params, "params")
|
||||
local_path = Path(_require_string(params_obj, "path"))
|
||||
if not local_path.exists():
|
||||
raise RpcError(JSONRPC_INVALID_PARAMS, f"Local file '{local_path}' does not exist.")
|
||||
if not local_path.name.lower().endswith(".ifcfed"):
|
||||
raise RpcError(JSONRPC_INVALID_PARAMS, "push_ifcfed expects an .ifcfed file.")
|
||||
|
||||
manifest = params_obj.get("manifest")
|
||||
if not isinstance(manifest, dict):
|
||||
raise RpcError(JSONRPC_INVALID_PARAMS, "'manifest' must be a JSON object.")
|
||||
if manifest.get("connector") != CONNECTOR_ID:
|
||||
raise RpcError(JSONRPC_INVALID_PARAMS, f"Manifest connector is not '{CONNECTOR_ID}'.")
|
||||
hub_id = _require_string(manifest, "hub_id")
|
||||
project_id = _require_string(manifest, "project_id")
|
||||
item_id = _require_string(manifest, "item_id")
|
||||
|
||||
item = aps.get_item(project_id, item_id)
|
||||
if item["hidden"]:
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Autodesk item '{item_id}' has been deleted.")
|
||||
folder_id = item.get("parent_folder_id")
|
||||
if not isinstance(folder_id, str) or not folder_id:
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Cannot resolve parent folder for item '{item_id}'.")
|
||||
file_name = manifest.get("display_name") or item.get("display_name") or local_path.name
|
||||
|
||||
uploaded = run_with_progress(
|
||||
"Uploading project",
|
||||
lambda report: aps.upload_file_to_folder(
|
||||
project_id,
|
||||
folder_id,
|
||||
local_path,
|
||||
display_name=file_name,
|
||||
progress=_upload_callback(report),
|
||||
),
|
||||
)
|
||||
|
||||
directory = cache.prepare_sole_child_dir(cache.ifcfed_dir(project_id, uploaded["item_id"]))
|
||||
cached_path = directory / file_name
|
||||
cached_path.write_bytes(local_path.read_bytes())
|
||||
cache.write_manifest(
|
||||
cached_path,
|
||||
{
|
||||
"connector": CONNECTOR_ID,
|
||||
"hub_id": hub_id,
|
||||
"project_id": project_id,
|
||||
"item_id": uploaded["item_id"],
|
||||
"display_name": file_name,
|
||||
},
|
||||
)
|
||||
return {"path": str(cached_path)}
|
||||
|
||||
# ---- push_model_interactive ---------------------------------------------
|
||||
|
||||
def push_model_interactive(self, params: Any) -> dict[str, Any]:
|
||||
auth, aps = self._require_aps()
|
||||
params_obj = _require_object(params, "params")
|
||||
local_path = Path(_require_string(params_obj, "path"))
|
||||
if not local_path.exists():
|
||||
raise RpcError(JSONRPC_INVALID_PARAMS, f"Local file '{local_path}' does not exist.")
|
||||
|
||||
chosen = BrowseDialog(auth=auth, aps=aps, mode="destination").run()
|
||||
hub = chosen["hub"]
|
||||
project = chosen["project"]
|
||||
folder = chosen["entries"][0]
|
||||
|
||||
file_name = prompt_for_filename(
|
||||
title="Save Model",
|
||||
label="Save model as:",
|
||||
default=local_path.name,
|
||||
)
|
||||
if not file_name:
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, "User cancelled save to cloud.")
|
||||
|
||||
uploaded = run_with_progress(
|
||||
"Uploading model",
|
||||
lambda report: aps.upload_file_to_folder(
|
||||
project["id"],
|
||||
folder["id"],
|
||||
local_path,
|
||||
display_name=file_name,
|
||||
progress=_upload_callback(report),
|
||||
),
|
||||
)
|
||||
|
||||
directory = cache.model_dir(project["id"], uploaded["item_id"], uploaded["version_id"])
|
||||
cache.prepare_sole_child_dir(directory)
|
||||
cached_path = directory / file_name
|
||||
cached_path.write_bytes(local_path.read_bytes())
|
||||
|
||||
return {
|
||||
"display_name": file_name,
|
||||
"path": str(cached_path),
|
||||
"source": {
|
||||
"connector": CONNECTOR_ID,
|
||||
"hub_id": hub["id"],
|
||||
"project_id": project["id"],
|
||||
"item_id": uploaded["item_id"],
|
||||
},
|
||||
"metadata": _build_metadata(uploaded),
|
||||
}
|
||||
|
||||
# ---- push_model ---------------------------------------------------------
|
||||
|
||||
def push_model(self, params: Any) -> dict[str, Any]:
|
||||
_, aps = self._require_aps()
|
||||
params_obj = _require_object(params, "params")
|
||||
local_path = Path(_require_string(params_obj, "path"))
|
||||
if not local_path.exists():
|
||||
raise RpcError(JSONRPC_INVALID_PARAMS, f"Local file '{local_path}' does not exist.")
|
||||
|
||||
source = params_obj.get("source")
|
||||
if not isinstance(source, dict):
|
||||
raise RpcError(JSONRPC_INVALID_PARAMS, "'source' must be a JSON object.")
|
||||
if source.get("connector") != CONNECTOR_ID:
|
||||
raise RpcError(JSONRPC_INVALID_PARAMS, f"Source connector is not '{CONNECTOR_ID}'.")
|
||||
hub_id = _require_string(source, "hub_id")
|
||||
project_id = _require_string(source, "project_id")
|
||||
item_id = _require_string(source, "item_id")
|
||||
|
||||
item = aps.get_item(project_id, item_id)
|
||||
if item["hidden"]:
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Autodesk item '{item_id}' has been deleted.")
|
||||
folder_id = item.get("parent_folder_id")
|
||||
if not isinstance(folder_id, str) or not folder_id:
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Cannot resolve parent folder for item '{item_id}'.")
|
||||
file_name = item.get("display_name") or local_path.name
|
||||
|
||||
uploaded = run_with_progress(
|
||||
"Uploading model",
|
||||
lambda report: aps.upload_file_to_folder(
|
||||
project_id,
|
||||
folder_id,
|
||||
local_path,
|
||||
display_name=file_name,
|
||||
progress=_upload_callback(report),
|
||||
),
|
||||
)
|
||||
|
||||
directory = cache.model_dir(project_id, uploaded["item_id"], uploaded["version_id"])
|
||||
cache.prepare_sole_child_dir(directory)
|
||||
cached_path = directory / file_name
|
||||
cached_path.write_bytes(local_path.read_bytes())
|
||||
|
||||
return {
|
||||
"source": {
|
||||
"connector": CONNECTOR_ID,
|
||||
"hub_id": hub_id,
|
||||
"project_id": project_id,
|
||||
"item_id": uploaded["item_id"],
|
||||
},
|
||||
"metadata": _build_metadata(uploaded),
|
||||
}
|
||||
|
||||
|
||||
def _require_object(params: Any, name: str) -> dict[str, Any]:
|
||||
if not isinstance(params, dict):
|
||||
raise RpcError(JSONRPC_INVALID_PARAMS, f"'{name}' must be a JSON object.")
|
||||
return params
|
||||
|
||||
|
||||
def _require_array(params: Any, name: str) -> list[Any]:
|
||||
if not isinstance(params, list):
|
||||
raise RpcError(JSONRPC_INVALID_PARAMS, f"'{name}' must be a JSON array.")
|
||||
return params
|
||||
|
||||
|
||||
def _require_string(obj: dict[str, Any], key: str) -> str:
|
||||
value = obj.get(key)
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise RpcError(JSONRPC_INVALID_PARAMS, f"Missing required string field '{key}'.")
|
||||
return value
|
||||
|
||||
|
||||
def _build_metadata(version_info: dict[str, Any]) -> dict[str, Any]:
|
||||
metadata: dict[str, Any] = {}
|
||||
version_number = version_info.get("version_number")
|
||||
if version_number is not None:
|
||||
metadata["revision"] = f"v{version_number}"
|
||||
last_modified = version_info.get("last_modified_time_utc")
|
||||
if isinstance(last_modified, str) and last_modified:
|
||||
metadata["date"] = last_modified
|
||||
author = version_info.get("last_modified_user_name")
|
||||
if isinstance(author, str) and author:
|
||||
metadata["author"] = author
|
||||
return metadata
|
||||
@@ -0,0 +1,111 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import traceback
|
||||
from typing import Any, Callable, TextIO
|
||||
|
||||
|
||||
JSONRPC_PARSE_ERROR = -32700
|
||||
JSONRPC_INVALID_REQUEST = -32600
|
||||
JSONRPC_METHOD_NOT_FOUND = -32601
|
||||
JSONRPC_INVALID_PARAMS = -32602
|
||||
JSONRPC_INTERNAL_ERROR = -32603
|
||||
|
||||
|
||||
class RpcError(Exception):
|
||||
def __init__(self, code: int, message: str, data: Any | None = None) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.data = data
|
||||
|
||||
|
||||
Handler = Callable[[Any], Any]
|
||||
|
||||
|
||||
class JsonRpcHost:
|
||||
def __init__(
|
||||
self,
|
||||
handlers: dict[str, Handler],
|
||||
*,
|
||||
stdin: TextIO = sys.stdin,
|
||||
stdout: TextIO = sys.stdout,
|
||||
stderr: TextIO = sys.stderr,
|
||||
) -> None:
|
||||
self.handlers = handlers
|
||||
self.stdin = stdin
|
||||
self.stdout = stdout
|
||||
self.stderr = stderr
|
||||
|
||||
def run(self) -> int:
|
||||
for line in self.stdin:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
self._handle_line(line)
|
||||
return 0
|
||||
|
||||
def _handle_line(self, line: str) -> None:
|
||||
message_id: Any = None
|
||||
try:
|
||||
try:
|
||||
message = json.loads(line)
|
||||
except json.JSONDecodeError as exc:
|
||||
self._respond_error(None, JSONRPC_PARSE_ERROR, f"Parse error: {exc}")
|
||||
return
|
||||
|
||||
if not isinstance(message, dict):
|
||||
self._respond_error(None, JSONRPC_INVALID_REQUEST, "Request must be a JSON object")
|
||||
return
|
||||
if message.get("jsonrpc") != "2.0":
|
||||
self._respond_error(message.get("id"), JSONRPC_INVALID_REQUEST, "Missing or wrong 'jsonrpc' version")
|
||||
return
|
||||
|
||||
message_id = message.get("id")
|
||||
method = message.get("method")
|
||||
if not isinstance(method, str):
|
||||
self._respond_error(message_id, JSONRPC_INVALID_REQUEST, "Missing 'method' string")
|
||||
return
|
||||
|
||||
params = message.get("params", None)
|
||||
if params is not None and not isinstance(params, (dict, list)):
|
||||
self._respond_error(message_id, JSONRPC_INVALID_PARAMS, "'params' must be a JSON object or array")
|
||||
return
|
||||
|
||||
handler = self.handlers.get(method)
|
||||
if handler is None:
|
||||
self._respond_error(message_id, JSONRPC_METHOD_NOT_FOUND, f"Unknown method '{method}'")
|
||||
return
|
||||
|
||||
try:
|
||||
result = handler(params)
|
||||
except RpcError as exc:
|
||||
self._respond_error(message_id, exc.code, exc.message, exc.data)
|
||||
return
|
||||
except Exception as exc:
|
||||
print(f"Handler '{method}' raised: {exc}", file=self.stderr)
|
||||
traceback.print_exc(file=self.stderr)
|
||||
self._respond_error(message_id, JSONRPC_INTERNAL_ERROR, str(exc))
|
||||
return
|
||||
|
||||
if message_id is not None:
|
||||
self._respond_result(message_id, result)
|
||||
except Exception as exc:
|
||||
print(f"Unhandled host error: {exc}", file=self.stderr)
|
||||
traceback.print_exc(file=self.stderr)
|
||||
self._respond_error(message_id, JSONRPC_INTERNAL_ERROR, str(exc))
|
||||
|
||||
def _respond_result(self, message_id: Any, result: Any) -> None:
|
||||
self._write({"jsonrpc": "2.0", "id": message_id, "result": result})
|
||||
|
||||
def _respond_error(self, message_id: Any, code: int, message: str, data: Any | None = None) -> None:
|
||||
error: dict[str, Any] = {"code": code, "message": message}
|
||||
if data is not None:
|
||||
error["data"] = data
|
||||
self._write({"jsonrpc": "2.0", "id": message_id, "error": error})
|
||||
|
||||
def _write(self, payload: dict[str, Any]) -> None:
|
||||
line = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
|
||||
self.stdout.write(line + "\n")
|
||||
self.stdout.flush()
|
||||
@@ -0,0 +1,71 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
DEFAULT_CALLBACK_PORT = 8080
|
||||
|
||||
|
||||
def config_root() -> Path:
|
||||
system = platform.system()
|
||||
if system == "Windows":
|
||||
base = os.environ.get("APPDATA") or os.path.expanduser("~")
|
||||
root = Path(base) / "bonsaiviewer-autodesk"
|
||||
elif system == "Darwin":
|
||||
root = Path.home() / "Library" / "Application Support" / "bonsaiviewer-autodesk"
|
||||
else:
|
||||
base = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
|
||||
root = Path(base) / "bonsaiviewer-autodesk"
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return root
|
||||
|
||||
|
||||
def _settings_path() -> Path:
|
||||
return config_root() / "settings.json"
|
||||
|
||||
|
||||
def _read() -> dict[str, Any]:
|
||||
path = _settings_path()
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def _write(data: dict[str, Any]) -> None:
|
||||
_settings_path().write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def load_client_id() -> str:
|
||||
"""The client id persisted in settings.json, or "" if none is set."""
|
||||
return str(_read().get("client_id", "")).strip()
|
||||
|
||||
|
||||
def save_client_id(client_id: str) -> None:
|
||||
data = _read()
|
||||
data["client_id"] = client_id.strip()
|
||||
_write(data)
|
||||
|
||||
|
||||
def stored_callback_port() -> int:
|
||||
value = _read().get("callback_port", DEFAULT_CALLBACK_PORT)
|
||||
try:
|
||||
port = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return DEFAULT_CALLBACK_PORT
|
||||
return port if 1 <= port <= 65535 else DEFAULT_CALLBACK_PORT
|
||||
|
||||
|
||||
def save_callback_port(port: int) -> None:
|
||||
if not 1 <= port <= 65535:
|
||||
raise ValueError("Callback port must be between 1 and 65535.")
|
||||
data = _read()
|
||||
data["callback_port"] = port
|
||||
_write(data)
|
||||
@@ -0,0 +1,810 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
from typing import TYPE_CHECKING, Any, Callable, Literal, TypeVar
|
||||
|
||||
import customtkinter as ctk
|
||||
|
||||
from bonsaiviewer_autodesk import settings
|
||||
from bonsaiviewer_autodesk.autodesk import ApsClient, AuthSessionService, KeyringTokenStore
|
||||
from bonsaiviewer_autodesk.rpc import JSONRPC_INTERNAL_ERROR, RpcError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bonsaiviewer_autodesk.connector import AutodeskConnector
|
||||
|
||||
|
||||
MODEL_EXTENSIONS = (".ifc", ".ifcview", ".rdb", ".rdbview")
|
||||
Mode = Literal["ifcfed", "model", "destination"]
|
||||
|
||||
|
||||
# --- root + Treeview style ---------------------------------------------------
|
||||
# Tk's default ttk.Treeview looks like Windows 95 in any theme; force-style it
|
||||
# to match the surrounding CTk dark theme. Every other widget uses CTk defaults.
|
||||
|
||||
_root: ctk.CTk | None = None
|
||||
|
||||
|
||||
def ensure_tk_app() -> ctk.CTk:
|
||||
global _root
|
||||
if _root is None:
|
||||
ctk.set_appearance_mode("Dark")
|
||||
ctk.set_default_color_theme("blue")
|
||||
_root = ctk.CTk()
|
||||
_root.withdraw()
|
||||
_apply_treeview_style()
|
||||
return _root
|
||||
|
||||
|
||||
def _apply_treeview_style() -> None:
|
||||
style = ttk.Style()
|
||||
try:
|
||||
style.theme_use("clam")
|
||||
except tk.TclError:
|
||||
pass
|
||||
style.configure(
|
||||
"Treeview",
|
||||
background="#2b2b2b",
|
||||
foreground="#dce4ee",
|
||||
fieldbackground="#2b2b2b",
|
||||
borderwidth=0,
|
||||
rowheight=26,
|
||||
)
|
||||
style.map(
|
||||
"Treeview",
|
||||
background=[("selected", "#1f6aa5")],
|
||||
foreground=[("selected", "#ffffff")],
|
||||
)
|
||||
style.layout("Treeview", [("Treeview.treearea", {"sticky": "nswe"})])
|
||||
|
||||
|
||||
# --- base modal --------------------------------------------------------------
|
||||
|
||||
|
||||
class _BaseDialog(ctk.CTkToplevel):
|
||||
def __init__(self, title: str, *, size: tuple[int, int], resizable: bool = True) -> None:
|
||||
super().__init__(ensure_tk_app())
|
||||
self.title(title)
|
||||
self.geometry(f"{size[0]}x{size[1]}")
|
||||
if not resizable:
|
||||
self.resizable(False, False)
|
||||
self.protocol("WM_DELETE_WINDOW", self._on_close)
|
||||
self.result: Any = None
|
||||
self.withdraw()
|
||||
|
||||
def _on_close(self) -> None:
|
||||
try:
|
||||
self.grab_release()
|
||||
except tk.TclError:
|
||||
pass
|
||||
self.destroy()
|
||||
|
||||
def _center_on_screen(self) -> None:
|
||||
self.update_idletasks()
|
||||
w = self.winfo_width()
|
||||
h = self.winfo_height()
|
||||
x = (self.winfo_screenwidth() - w) // 2
|
||||
y = (self.winfo_screenheight() - h) // 2
|
||||
self.geometry(f"+{x}+{y}")
|
||||
|
||||
def run(self) -> Any:
|
||||
root = ensure_tk_app()
|
||||
self._center_on_screen()
|
||||
self.deiconify()
|
||||
self.lift()
|
||||
self.focus_force()
|
||||
try:
|
||||
self.grab_set()
|
||||
except tk.TclError:
|
||||
pass
|
||||
self.wait_window()
|
||||
try:
|
||||
root.update()
|
||||
root.update_idletasks()
|
||||
except tk.TclError:
|
||||
pass
|
||||
return self.result
|
||||
|
||||
|
||||
# --- progress ----------------------------------------------------------------
|
||||
|
||||
|
||||
class ProgressDialog(_BaseDialog):
|
||||
"""Fixed-size progress dialog: a title line, a stats line, and a bar.
|
||||
|
||||
Both text lines are single-line and middle-elided with '…' so a long
|
||||
filename can never reflow the layout or resize the window.
|
||||
"""
|
||||
|
||||
_WIDTH = 560
|
||||
|
||||
def __init__(self, title: str = "Working", parent: tk.Misc | None = None) -> None:
|
||||
super().__init__(title, size=(self._WIDTH, 160), resizable=False)
|
||||
|
||||
self._text_font = ctk.CTkFont()
|
||||
|
||||
body = ctk.CTkFrame(self)
|
||||
body.pack(fill="both", expand=True, padx=20, pady=20)
|
||||
|
||||
self.title_label = ctk.CTkLabel(body, text="Working…", anchor="w", font=self._text_font)
|
||||
self.title_label.pack(fill="x", padx=16, pady=(16, 0))
|
||||
|
||||
# Initialised with a space so the line reserves its height before the
|
||||
# first report(); a single-line label never grows taller than this.
|
||||
self.detail_label = ctk.CTkLabel(body, text=" ", anchor="w", font=self._text_font)
|
||||
self.detail_label.pack(fill="x", padx=16, pady=(2, 0))
|
||||
|
||||
self.bar = ctk.CTkProgressBar(body, mode="indeterminate")
|
||||
self.bar.pack(fill="x", padx=16, pady=(14, 16))
|
||||
self.bar.start()
|
||||
self._determinate = False
|
||||
|
||||
# Derive the height from the laid-out content (font-driven) rather than
|
||||
# hardcoding it, then lock it. Single-line labels keep it stable no
|
||||
# matter how long the text is.
|
||||
self.update_idletasks()
|
||||
self.geometry(f"{self._WIDTH}x{self.winfo_reqheight()}")
|
||||
|
||||
self._center_on_screen()
|
||||
self.deiconify()
|
||||
self.lift()
|
||||
self.update()
|
||||
|
||||
def report(
|
||||
self,
|
||||
_phase: str,
|
||||
message: str,
|
||||
percent: int | None = None,
|
||||
detail: str | None = None,
|
||||
) -> None:
|
||||
try:
|
||||
width = self._text_area_width()
|
||||
self.title_label.configure(text=self._elide_middle(message, width))
|
||||
self.detail_label.configure(text=self._elide_middle(detail or " ", width))
|
||||
if percent is None:
|
||||
if self._determinate:
|
||||
self.bar.configure(mode="indeterminate")
|
||||
self.bar.start()
|
||||
self._determinate = False
|
||||
else:
|
||||
if not self._determinate:
|
||||
self.bar.stop()
|
||||
self.bar.configure(mode="determinate")
|
||||
self._determinate = True
|
||||
self.bar.set(max(0.0, min(1.0, percent / 100.0)))
|
||||
self.update()
|
||||
except tk.TclError:
|
||||
pass
|
||||
|
||||
def _text_area_width(self) -> int:
|
||||
"""Pixels available for label text, in the same scaled space as the font."""
|
||||
width = self.title_label.winfo_width()
|
||||
if width <= 1: # not laid out yet
|
||||
return self._WIDTH - 2 * 20 - 2 * 16
|
||||
return max(40, width - 6)
|
||||
|
||||
def _elide_middle(self, text: str, max_width: int) -> str:
|
||||
"""Middle-truncate text with '…' so it fits max_width without wrapping."""
|
||||
font = self._text_font
|
||||
if font.measure(text) <= max_width:
|
||||
return text
|
||||
ellipsis = "…"
|
||||
keep = len(text) - 1
|
||||
while keep > 0:
|
||||
head = (keep + 1) // 2
|
||||
tail = keep - head
|
||||
candidate = text[:head] + ellipsis + (text[-tail:] if tail else "")
|
||||
if font.measure(candidate) <= max_width:
|
||||
return candidate
|
||||
keep -= 1
|
||||
return ellipsis
|
||||
|
||||
|
||||
class _ProgressContext:
|
||||
def __init__(self, parent: tk.Misc | None, message: str) -> None:
|
||||
self.parent = parent
|
||||
self.message = message
|
||||
self.dialog: ProgressDialog | None = None
|
||||
|
||||
def __enter__(self) -> Callable[..., None]:
|
||||
self.dialog = ProgressDialog(self.message, self.parent)
|
||||
return self.dialog.report
|
||||
|
||||
def __exit__(self, *_exc: object) -> None:
|
||||
if self.dialog is not None:
|
||||
try:
|
||||
self.dialog.withdraw()
|
||||
self.dialog.destroy()
|
||||
except tk.TclError:
|
||||
pass
|
||||
self.dialog = None
|
||||
try:
|
||||
root = ensure_tk_app()
|
||||
root.update_idletasks()
|
||||
root.update()
|
||||
except tk.TclError:
|
||||
pass
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
# A progress sink: (phase, message, percent, detail) -> None.
|
||||
Report = Callable[[str, str, "int | None", "str | None"], None]
|
||||
|
||||
|
||||
class _ProgressBridge:
|
||||
"""Thread-safe hand-off of the latest progress report to the UI thread.
|
||||
|
||||
``work`` runs on a worker thread and must never touch Tk; it calls
|
||||
:meth:`report`, which only stashes the most recent update. The main thread
|
||||
drains it via :meth:`take` and applies it to the dialog. Intermediate
|
||||
updates are coalesced — only the latest matters for a progress bar.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._pending: tuple[str, str, int | None, str | None] | None = None
|
||||
|
||||
def report(
|
||||
self,
|
||||
phase: str,
|
||||
message: str,
|
||||
percent: int | None = None,
|
||||
detail: str | None = None,
|
||||
) -> None:
|
||||
with self._lock:
|
||||
self._pending = (phase, message, percent, detail)
|
||||
|
||||
def take(self) -> tuple[str, str, int | None, str | None] | None:
|
||||
with self._lock:
|
||||
pending, self._pending = self._pending, None
|
||||
return pending
|
||||
|
||||
|
||||
def run_with_progress(
|
||||
message: str,
|
||||
work: Callable[[Report], T],
|
||||
*,
|
||||
parent: tk.Misc | None = None,
|
||||
) -> T:
|
||||
"""Show a ProgressDialog and run ``work`` on a worker thread.
|
||||
|
||||
Tkinter is single-threaded and only repaints while its event loop runs, so
|
||||
a long upload/download executed inline would freeze the dialog. Worse, on
|
||||
Windows ``CTkToplevel`` withdraws itself at construction and re-shows via a
|
||||
delayed ``after()`` callback — without a running loop that callback never
|
||||
fires and the window stays invisible for the whole transfer.
|
||||
|
||||
So the blocking ``work`` runs on a background thread while the main thread
|
||||
pumps the Tk event loop here. ``work`` receives a thread-safe ``report``
|
||||
callback; its updates are marshalled back to the UI thread. Returns work's
|
||||
result, or re-raises (on the main thread) whatever exception it raised.
|
||||
"""
|
||||
root = ensure_tk_app()
|
||||
dialog = ProgressDialog(message, parent)
|
||||
bridge = _ProgressBridge()
|
||||
outcome: dict[str, Any] = {}
|
||||
|
||||
def runner() -> None:
|
||||
try:
|
||||
outcome["value"] = work(bridge.report)
|
||||
except BaseException as exc: # noqa: BLE001 - re-raised on the main thread
|
||||
outcome["error"] = exc
|
||||
|
||||
thread = threading.Thread(target=runner, name="autodesk-progress", daemon=True)
|
||||
thread.start()
|
||||
|
||||
try:
|
||||
while thread.is_alive():
|
||||
pending = bridge.take()
|
||||
try:
|
||||
if pending is not None:
|
||||
dialog.report(*pending)
|
||||
else:
|
||||
root.update()
|
||||
except tk.TclError:
|
||||
break
|
||||
time.sleep(0.03)
|
||||
thread.join()
|
||||
final = bridge.take()
|
||||
if final is not None:
|
||||
try:
|
||||
dialog.report(*final)
|
||||
except tk.TclError:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
dialog.withdraw()
|
||||
dialog.destroy()
|
||||
except tk.TclError:
|
||||
pass
|
||||
try:
|
||||
root.update_idletasks()
|
||||
root.update()
|
||||
except tk.TclError:
|
||||
pass
|
||||
|
||||
if "error" in outcome:
|
||||
raise outcome["error"]
|
||||
return outcome["value"]
|
||||
|
||||
|
||||
# --- browse ------------------------------------------------------------------
|
||||
|
||||
|
||||
class BrowseDialog(_BaseDialog):
|
||||
"""Hub → project → folder tree → file/folder picker."""
|
||||
|
||||
def __init__(self, *, auth: AuthSessionService, aps: ApsClient, mode: Mode) -> None:
|
||||
titles = {
|
||||
"ifcfed": ("Open Project From Autodesk", "Open"),
|
||||
"model": ("Add Model From Autodesk", "Add"),
|
||||
"destination": ("Choose Autodesk Destination", "Select"),
|
||||
}
|
||||
title, action_label = titles[mode]
|
||||
super().__init__(title, size=(920, 620))
|
||||
|
||||
self.auth = auth
|
||||
self.aps = aps
|
||||
self.mode: Mode = mode
|
||||
self.multi_select = mode == "model"
|
||||
self.selected_hub: dict[str, Any] | None = None
|
||||
self.selected_project: dict[str, Any] | None = None
|
||||
self.selected_entries: list[dict[str, Any]] = []
|
||||
self._tree_entries: dict[str, dict[str, Any]] = {}
|
||||
self._project_entries: dict[str, dict[str, Any]] = {}
|
||||
|
||||
self._build_ui(action_label)
|
||||
|
||||
def _build_ui(self, action_label: str) -> None:
|
||||
root = ctk.CTkFrame(self, fg_color="transparent")
|
||||
root.pack(fill="both", expand=True, padx=16, pady=16)
|
||||
root.grid_rowconfigure(1, weight=1)
|
||||
root.grid_columnconfigure(0, weight=1)
|
||||
|
||||
top = ctk.CTkFrame(root, fg_color="transparent")
|
||||
top.grid(row=0, column=0, sticky="ew", pady=(0, 12))
|
||||
top.grid_columnconfigure(1, weight=1)
|
||||
|
||||
self.sign_in_button = ctk.CTkButton(top, text="Sign In", command=self._sign_in)
|
||||
self.sign_in_button.grid(row=0, column=0, padx=(0, 8), sticky="w")
|
||||
|
||||
self.hub_combo = ctk.CTkOptionMenu(
|
||||
top,
|
||||
values=["Select hub"],
|
||||
command=self._hub_changed,
|
||||
anchor="w",
|
||||
)
|
||||
self.hub_combo.grid(row=0, column=1, sticky="ew")
|
||||
self.hub_combo.configure(state="disabled")
|
||||
|
||||
split = ctk.CTkFrame(root, fg_color="transparent")
|
||||
split.grid(row=1, column=0, sticky="nsew")
|
||||
split.grid_rowconfigure(0, weight=1)
|
||||
split.grid_columnconfigure(0, weight=3, uniform="col")
|
||||
split.grid_columnconfigure(1, weight=7, uniform="col")
|
||||
|
||||
self.projects_frame = ctk.CTkFrame(split)
|
||||
self.projects_frame.grid(row=0, column=0, sticky="nsew", padx=(0, 8))
|
||||
self.tree_frame = ctk.CTkFrame(split)
|
||||
self.tree_frame.grid(row=0, column=1, sticky="nsew")
|
||||
|
||||
self.projects = self._make_treeview(self.projects_frame, "PROJECTS")
|
||||
self.projects.bind("<<TreeviewSelect>>", lambda _e: self._project_changed())
|
||||
|
||||
tree_selectmode = "extended" if self.multi_select else "browse"
|
||||
self.tree = self._make_treeview(self.tree_frame, "FOLDERS", selectmode=tree_selectmode)
|
||||
self.tree.bind("<<TreeviewSelect>>", lambda _e: self._tree_selection_changed())
|
||||
self.tree.bind("<<TreeviewOpen>>", self._on_tree_open)
|
||||
|
||||
self.status = ctk.CTkLabel(root, text="Sign in to browse Autodesk projects.", anchor="w")
|
||||
self.status.grid(row=2, column=0, sticky="ew", pady=(12, 12))
|
||||
|
||||
actions = ctk.CTkFrame(root, fg_color="transparent")
|
||||
actions.grid(row=3, column=0, sticky="ew")
|
||||
actions.grid_columnconfigure(0, weight=1)
|
||||
self.cancel_button = ctk.CTkButton(actions, text="Cancel", command=self._on_close, fg_color="transparent", border_width=1)
|
||||
self.cancel_button.grid(row=0, column=1, padx=(0, 8))
|
||||
self.action_button = ctk.CTkButton(actions, text=action_label, command=self._confirm)
|
||||
self.action_button.grid(row=0, column=2)
|
||||
self.action_button.configure(state="disabled")
|
||||
|
||||
def _make_treeview(self, parent: ctk.CTkFrame, header: str, selectmode: str = "browse") -> ttk.Treeview:
|
||||
ctk.CTkLabel(parent, text=header, anchor="w").pack(fill="x", padx=12, pady=(8, 0))
|
||||
body = ctk.CTkFrame(parent, fg_color="transparent")
|
||||
body.pack(fill="both", expand=True, padx=8, pady=8)
|
||||
body.grid_rowconfigure(0, weight=1)
|
||||
body.grid_columnconfigure(0, weight=1)
|
||||
|
||||
tree = ttk.Treeview(body, show="tree", selectmode=selectmode)
|
||||
tree.grid(row=0, column=0, sticky="nsew")
|
||||
scrollbar = ctk.CTkScrollbar(body, orientation="vertical", command=tree.yview)
|
||||
scrollbar.grid(row=0, column=1, sticky="ns")
|
||||
tree.configure(yscrollcommand=scrollbar.set)
|
||||
return tree
|
||||
|
||||
# --- sign-in & population ------------------------------------------------
|
||||
|
||||
def run(self) -> dict[str, Any]:
|
||||
if self.auth.get_token() is not None:
|
||||
self._populate_hubs_silently()
|
||||
outcome = super().run()
|
||||
if outcome is None:
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, "User cancelled the Autodesk picker.")
|
||||
return outcome
|
||||
|
||||
def _populate_hubs_silently(self) -> None:
|
||||
try:
|
||||
hubs = self.aps.list_hubs()
|
||||
self._fill_hubs(hubs)
|
||||
self.status.configure(text="Signed in. Select a hub.")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _sign_in(self) -> None:
|
||||
with self._with_progress("Signing in to Autodesk") as report:
|
||||
try:
|
||||
self.auth.login_interactive(report)
|
||||
hubs = self.aps.list_hubs()
|
||||
self._fill_hubs(hubs)
|
||||
self.status.configure(text="Signed in. Select a hub.")
|
||||
except Exception as exc:
|
||||
show_error(title="Sign In Failed", message=str(exc))
|
||||
|
||||
def _fill_hubs(self, hubs: list[dict[str, Any]]) -> None:
|
||||
self._hubs_by_name = {hub["name"]: hub for hub in hubs}
|
||||
values = ["Select hub"] + list(self._hubs_by_name.keys())
|
||||
self.hub_combo.configure(values=values, state="normal")
|
||||
self.hub_combo.set("Select hub")
|
||||
|
||||
def _hub_changed(self, label: str) -> None:
|
||||
if label == "Select hub":
|
||||
return
|
||||
hub = getattr(self, "_hubs_by_name", {}).get(label)
|
||||
if not isinstance(hub, dict):
|
||||
return
|
||||
self.selected_hub = hub
|
||||
self.selected_project = None
|
||||
self.selected_entry = None
|
||||
self._clear_projects()
|
||||
self._clear_tree()
|
||||
self._refresh_action_button()
|
||||
with self._with_progress("Loading Autodesk projects"):
|
||||
try:
|
||||
projects = self.aps.list_projects(hub["id"])
|
||||
self._project_entries = {}
|
||||
for project in projects:
|
||||
iid = self.projects.insert("", "end", text=project["name"])
|
||||
self._project_entries[iid] = project
|
||||
self.status.configure(text=f"Hub: {hub['name']}. Select a project.")
|
||||
except Exception as exc:
|
||||
show_error(title="Load Projects Failed", message=str(exc))
|
||||
|
||||
def _project_changed(self) -> None:
|
||||
selection = self.projects.selection()
|
||||
if not selection or self.selected_hub is None:
|
||||
return
|
||||
project = self._project_entries.get(selection[0])
|
||||
if not isinstance(project, dict):
|
||||
return
|
||||
self.selected_project = project
|
||||
self.selected_entries = []
|
||||
self._refresh_action_button()
|
||||
self._clear_tree()
|
||||
with self._with_progress("Loading top folders"):
|
||||
try:
|
||||
top_folders = self.aps.list_top_folders(self.selected_hub["id"], project["id"])
|
||||
for entry in top_folders:
|
||||
self._insert_tree_entry("", entry)
|
||||
if self.mode == "destination":
|
||||
self.status.configure(text=f"Project: {project['name']}. Browse folders and choose a destination.")
|
||||
else:
|
||||
self.status.configure(text=f"Project: {project['name']}. Browse folders and pick a file.")
|
||||
except Exception as exc:
|
||||
show_error(title="Load Project Failed", message=str(exc))
|
||||
|
||||
def _insert_tree_entry(self, parent_iid: str, entry: dict[str, Any]) -> str:
|
||||
label = entry.get("display_name") or entry.get("name") or entry.get("id", "?")
|
||||
iid = self.tree.insert(parent_iid, "end", text=label)
|
||||
self._tree_entries[iid] = entry
|
||||
if entry.get("type") == "folders":
|
||||
placeholder = self.tree.insert(iid, "end", text="Loading…")
|
||||
self._tree_entries[placeholder] = {"__placeholder__": True}
|
||||
return iid
|
||||
|
||||
def _on_tree_open(self, _event: tk.Event) -> None:
|
||||
selection = self.tree.focus()
|
||||
if not selection:
|
||||
return
|
||||
entry = self._tree_entries.get(selection)
|
||||
if not isinstance(entry, dict) or entry.get("type") != "folders" or self.selected_project is None:
|
||||
return
|
||||
children = self.tree.get_children(selection)
|
||||
if len(children) != 1:
|
||||
return
|
||||
only = self._tree_entries.get(children[0])
|
||||
if not (isinstance(only, dict) and only.get("__placeholder__")):
|
||||
return
|
||||
|
||||
self.tree.delete(children[0])
|
||||
self._tree_entries.pop(children[0], None)
|
||||
|
||||
with self._with_progress("Loading folder contents"):
|
||||
try:
|
||||
object_types = ["folders"] if self.mode == "destination" else ["folders", "items"]
|
||||
contents = self.aps.list_folder_contents(
|
||||
self.selected_project["id"],
|
||||
entry["id"],
|
||||
object_types=object_types,
|
||||
extension_filter=self._extension_filter(),
|
||||
)
|
||||
for child in contents:
|
||||
self._insert_tree_entry(selection, child)
|
||||
except Exception as exc:
|
||||
show_error(title="Load Folder Failed", message=str(exc))
|
||||
|
||||
def _extension_filter(self) -> Callable[[dict[str, Any]], bool] | None:
|
||||
if self.mode == "ifcfed":
|
||||
return lambda entry: (entry.get("display_name") or "").lower().endswith(".ifcfed")
|
||||
if self.mode == "model":
|
||||
return lambda entry: (entry.get("display_name") or "").lower().endswith(MODEL_EXTENSIONS)
|
||||
return None
|
||||
|
||||
def _tree_selection_changed(self) -> None:
|
||||
selection = self.tree.selection()
|
||||
entries: list[dict[str, Any]] = []
|
||||
for iid in selection:
|
||||
entry = self._tree_entries.get(iid)
|
||||
if isinstance(entry, dict) and not entry.get("__placeholder__"):
|
||||
entries.append(entry)
|
||||
self.selected_entries = entries
|
||||
|
||||
if not entries:
|
||||
pass
|
||||
elif len(entries) == 1:
|
||||
entry = entries[0]
|
||||
name = entry.get("display_name") or entry.get("name") or entry.get("id", "?")
|
||||
kind = entry.get("type", "entry")
|
||||
self.status.configure(text=f"Selected {kind}: {name}")
|
||||
else:
|
||||
valid_count = sum(1 for e in entries if self._is_valid_selection(e))
|
||||
self.status.configure(text=f"Selected {valid_count} of {len(entries)} items.")
|
||||
self._refresh_action_button()
|
||||
|
||||
def _is_valid_selection(self, entry: dict[str, Any]) -> bool:
|
||||
if self.mode == "destination":
|
||||
return entry.get("type") == "folders"
|
||||
if self.mode == "ifcfed":
|
||||
return (
|
||||
entry.get("type") == "items"
|
||||
and (entry.get("display_name") or "").lower().endswith(".ifcfed")
|
||||
)
|
||||
return (
|
||||
entry.get("type") == "items"
|
||||
and (entry.get("display_name") or "").lower().endswith(MODEL_EXTENSIONS)
|
||||
)
|
||||
|
||||
def _valid_entries(self) -> list[dict[str, Any]]:
|
||||
return [e for e in self.selected_entries if self._is_valid_selection(e)]
|
||||
|
||||
def _refresh_action_button(self) -> None:
|
||||
enabled = self.selected_project is not None and bool(self._valid_entries())
|
||||
self.action_button.configure(state="normal" if enabled else "disabled")
|
||||
|
||||
def _confirm(self) -> None:
|
||||
valid = self._valid_entries()
|
||||
if not self.selected_hub or not self.selected_project or not valid:
|
||||
return
|
||||
self.result = {
|
||||
"hub": self.selected_hub,
|
||||
"project": self.selected_project,
|
||||
"entries": valid,
|
||||
}
|
||||
self._on_close()
|
||||
|
||||
def _clear_projects(self) -> None:
|
||||
for iid in self.projects.get_children():
|
||||
self.projects.delete(iid)
|
||||
self._project_entries.clear()
|
||||
|
||||
def _clear_tree(self) -> None:
|
||||
for iid in self.tree.get_children():
|
||||
self.tree.delete(iid)
|
||||
self._tree_entries.clear()
|
||||
|
||||
def _with_progress(self, message: str) -> _ProgressContext:
|
||||
return _ProgressContext(self, message)
|
||||
|
||||
|
||||
# --- filename prompt ---------------------------------------------------------
|
||||
|
||||
|
||||
class _FilenamePrompt(_BaseDialog):
|
||||
def __init__(self, *, title: str, label: str, default: str) -> None:
|
||||
super().__init__(title, size=(440, 170), resizable=False)
|
||||
body = ctk.CTkFrame(self, fg_color="transparent")
|
||||
body.pack(fill="both", expand=True, padx=20, pady=20)
|
||||
|
||||
ctk.CTkLabel(body, text=label, anchor="w").pack(fill="x")
|
||||
self.entry = ctk.CTkEntry(body)
|
||||
self.entry.pack(fill="x", pady=(8, 16))
|
||||
self.entry.insert(0, default)
|
||||
self.entry.select_range(0, "end")
|
||||
self.entry.focus_set()
|
||||
|
||||
buttons = ctk.CTkFrame(body, fg_color="transparent")
|
||||
buttons.pack(fill="x")
|
||||
buttons.grid_columnconfigure(0, weight=1)
|
||||
ctk.CTkButton(buttons, text="Cancel", command=self._on_close, fg_color="transparent", border_width=1).grid(row=0, column=1, padx=(0, 8))
|
||||
ctk.CTkButton(buttons, text="OK", command=self._confirm).grid(row=0, column=2)
|
||||
|
||||
self.bind("<Return>", lambda _e: self._confirm())
|
||||
self.bind("<Escape>", lambda _e: self._on_close())
|
||||
|
||||
def _confirm(self) -> None:
|
||||
value = self.entry.get().strip()
|
||||
self.result = value or None
|
||||
self._on_close()
|
||||
|
||||
|
||||
def prompt_for_filename(*, title: str, label: str, default: str) -> str | None:
|
||||
return _FilenamePrompt(title=title, label=label, default=default).run()
|
||||
|
||||
|
||||
# --- message dialogs (CTk-styled replacements for tkinter.messagebox) --------
|
||||
|
||||
|
||||
class _ConfirmDialog(_BaseDialog):
|
||||
def __init__(self, *, title: str, message: str) -> None:
|
||||
super().__init__(title, size=(440, 180), resizable=False)
|
||||
body = ctk.CTkFrame(self, fg_color="transparent")
|
||||
body.pack(fill="both", expand=True, padx=20, pady=20)
|
||||
|
||||
ctk.CTkLabel(body, text=message, anchor="w", wraplength=380, justify="left").pack(fill="x", pady=(0, 20))
|
||||
|
||||
buttons = ctk.CTkFrame(body, fg_color="transparent")
|
||||
buttons.pack(fill="x")
|
||||
buttons.grid_columnconfigure(0, weight=1)
|
||||
ctk.CTkButton(buttons, text="No", command=self._on_close, fg_color="transparent", border_width=1).grid(row=0, column=1, padx=(0, 8))
|
||||
ctk.CTkButton(buttons, text="Yes", command=self._confirm).grid(row=0, column=2)
|
||||
|
||||
self.bind("<Return>", lambda _e: self._confirm())
|
||||
self.bind("<Escape>", lambda _e: self._on_close())
|
||||
|
||||
def _confirm(self) -> None:
|
||||
self.result = True
|
||||
self._on_close()
|
||||
|
||||
|
||||
class _AlertDialog(_BaseDialog):
|
||||
def __init__(self, *, title: str, message: str) -> None:
|
||||
super().__init__(title, size=(440, 180), resizable=False)
|
||||
body = ctk.CTkFrame(self, fg_color="transparent")
|
||||
body.pack(fill="both", expand=True, padx=20, pady=20)
|
||||
|
||||
ctk.CTkLabel(body, text=message, anchor="w", wraplength=380, justify="left").pack(fill="x", pady=(0, 20))
|
||||
|
||||
buttons = ctk.CTkFrame(body, fg_color="transparent")
|
||||
buttons.pack(fill="x")
|
||||
buttons.grid_columnconfigure(0, weight=1)
|
||||
ctk.CTkButton(buttons, text="OK", command=self._on_close).grid(row=0, column=1)
|
||||
|
||||
self.bind("<Return>", lambda _e: self._on_close())
|
||||
self.bind("<Escape>", lambda _e: self._on_close())
|
||||
|
||||
|
||||
def confirm(*, title: str, message: str) -> bool:
|
||||
return bool(_ConfirmDialog(title=title, message=message).run())
|
||||
|
||||
|
||||
def show_error(*, title: str, message: str) -> None:
|
||||
_AlertDialog(title=title, message=message).run()
|
||||
|
||||
|
||||
# --- settings ----------------------------------------------------------------
|
||||
|
||||
|
||||
class SettingsDialog(_BaseDialog):
|
||||
"""Edit the APS client id and sign out."""
|
||||
|
||||
def __init__(self, *, connector: "AutodeskConnector") -> None:
|
||||
super().__init__("Autodesk Connector Settings", size=(520, 400), resizable=False)
|
||||
self.connector = connector
|
||||
|
||||
client_id = settings.load_client_id()
|
||||
callback_port = settings.stored_callback_port()
|
||||
|
||||
body = ctk.CTkFrame(self, fg_color="transparent")
|
||||
body.pack(fill="both", expand=True, padx=24, pady=24)
|
||||
|
||||
ctk.CTkLabel(
|
||||
body,
|
||||
text="Autodesk Platform Services",
|
||||
anchor="w",
|
||||
font=ctk.CTkFont(size=14, weight="bold"),
|
||||
).pack(fill="x")
|
||||
ctk.CTkLabel(
|
||||
body,
|
||||
text="The connector signs in to Autodesk using a PKCE flow. The client id below comes from your APS application.",
|
||||
anchor="w",
|
||||
wraplength=460,
|
||||
justify="left",
|
||||
).pack(fill="x", pady=(2, 16))
|
||||
|
||||
ctk.CTkLabel(body, text="APS client id", anchor="w").pack(fill="x")
|
||||
self.client_id_entry = ctk.CTkEntry(body, placeholder_text="Paste your APS client id")
|
||||
self.client_id_entry.pack(fill="x", pady=(6, 8))
|
||||
self.client_id_entry.insert(0, client_id)
|
||||
|
||||
ctk.CTkLabel(body, text="OAuth callback port", anchor="w").pack(fill="x")
|
||||
self.callback_port_entry = ctk.CTkEntry(body, placeholder_text=str(settings.DEFAULT_CALLBACK_PORT))
|
||||
self.callback_port_entry.pack(fill="x", pady=(6, 8))
|
||||
self.callback_port_entry.insert(0, str(callback_port))
|
||||
|
||||
self.status_label = ctk.CTkLabel(
|
||||
body,
|
||||
text=f"Signed in as {client_id}" if client_id else "No client id configured.",
|
||||
anchor="w",
|
||||
wraplength=460,
|
||||
justify="left",
|
||||
)
|
||||
self.status_label.pack(fill="x", pady=(0, 16))
|
||||
|
||||
buttons = ctk.CTkFrame(body, fg_color="transparent")
|
||||
buttons.pack(fill="x")
|
||||
buttons.grid_columnconfigure(1, weight=1)
|
||||
|
||||
self.signout_button = ctk.CTkButton(
|
||||
buttons,
|
||||
text="Sign Out",
|
||||
command=self._sign_out,
|
||||
fg_color="transparent",
|
||||
border_width=1,
|
||||
)
|
||||
self.signout_button.grid(row=0, column=0, sticky="w")
|
||||
if not client_id:
|
||||
self.signout_button.configure(state="disabled")
|
||||
|
||||
ctk.CTkButton(
|
||||
buttons,
|
||||
text="Close",
|
||||
command=self._on_close,
|
||||
fg_color="transparent",
|
||||
border_width=1,
|
||||
).grid(row=0, column=2, padx=(0, 8))
|
||||
ctk.CTkButton(buttons, text="Save", command=self._save).grid(row=0, column=3)
|
||||
|
||||
def _save(self) -> None:
|
||||
new_id = self.client_id_entry.get().strip()
|
||||
try:
|
||||
callback_port = int(self.callback_port_entry.get().strip())
|
||||
settings.save_callback_port(callback_port)
|
||||
except ValueError:
|
||||
show_error(title="Invalid Callback Port", message="Callback port must be a number between 1 and 65535.")
|
||||
return
|
||||
settings.save_client_id(new_id)
|
||||
try:
|
||||
self.connector.reload_credentials()
|
||||
except Exception as exc:
|
||||
show_error(title="Reload Failed", message=str(exc))
|
||||
return
|
||||
self._on_close()
|
||||
|
||||
def _sign_out(self) -> None:
|
||||
client_id = settings.load_client_id()
|
||||
if not client_id:
|
||||
return
|
||||
if not confirm(
|
||||
title="Sign Out",
|
||||
message=f"Forget the stored Autodesk session for {client_id}?",
|
||||
):
|
||||
return
|
||||
try:
|
||||
KeyringTokenStore(service_name="bonsaiviewer-autodesk", username=client_id).delete()
|
||||
except RpcError as exc:
|
||||
show_error(title="Sign Out Failed", message=exc.message)
|
||||
return
|
||||
self.status_label.configure(text="Signed out. Next operation will prompt for sign-in.")
|
||||
self.signout_button.configure(state="disabled")
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"id": "autodesk",
|
||||
"name": "Autodesk Forma",
|
||||
"version": "0.1.0",
|
||||
"exec": "bonsaiviewer-autodesk"
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
# PyInstaller spec for the Bonsai Viewer Autodesk connector (Tk + CustomTkinter).
|
||||
#
|
||||
# The connector talks JSON-RPC over stdio, so `console=True` is required to
|
||||
# attach stdin/stdout on Windows. The Bonsai Viewer is expected to spawn the
|
||||
# connector with the OS's "hide console window" flag on Windows
|
||||
# (CREATE_NO_WINDOW) so end users never see a console pop up.
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(SPECPATH).resolve().parent
|
||||
|
||||
# keyring uses entry points for backends — PyInstaller can't trace them
|
||||
# without hints. Bundle every backend; the right one is picked at runtime
|
||||
# per OS.
|
||||
HIDDEN_IMPORTS = [
|
||||
"keyring.backends.SecretService",
|
||||
"keyring.backends.macOS",
|
||||
"keyring.backends.Windows",
|
||||
"keyring.backends.fail",
|
||||
"keyring.backends.chainer",
|
||||
]
|
||||
|
||||
|
||||
a = Analysis(
|
||||
[str(PROJECT_ROOT / "bonsaiviewer_autodesk" / "__main__.py")],
|
||||
pathex=[str(PROJECT_ROOT)],
|
||||
binaries=[],
|
||||
datas=[],
|
||||
hiddenimports=HIDDEN_IMPORTS,
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=[
|
||||
# Test / docs.
|
||||
"test", "unittest", "pydoc_data",
|
||||
# Protocols / formats we never touch. (email, html, http.cookies and
|
||||
# http.cookiejar are required by http.server / httpx and must stay.)
|
||||
"xmlrpc", "sqlite3", "ftplib", "imaplib", "poplib", "nntplib",
|
||||
"smtplib", "telnetlib", "wsgiref",
|
||||
# Concurrency we never use (asyncio is needed by httpx → anyio).
|
||||
"multiprocessing", "concurrent.futures.process",
|
||||
# Build / packaging tools.
|
||||
"setuptools", "pip", "distutils", "ensurepip", "lib2to3",
|
||||
# Heavy stdlib bits with no callers.
|
||||
"decimal", "_decimal",
|
||||
# tkinter test modules.
|
||||
"tkinter.test", "test.test_tk",
|
||||
],
|
||||
noarchive=False,
|
||||
)
|
||||
|
||||
pyz = PYZ(a.pure, a.zipped_data)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
[],
|
||||
exclude_binaries=True,
|
||||
name="bonsaiviewer-autodesk",
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=False,
|
||||
console=True,
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
)
|
||||
|
||||
coll = COLLECT(
|
||||
exe,
|
||||
a.binaries,
|
||||
a.zipfiles,
|
||||
a.datas,
|
||||
strip=False,
|
||||
upx=False,
|
||||
upx_exclude=[],
|
||||
name="bonsaiviewer-autodesk",
|
||||
)
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Build the Autodesk connector bundle for the current OS.
|
||||
|
||||
Each OS builds on itself (PyInstaller does not cross-compile). The output is a
|
||||
single zip ready to drop into the Bonsai Viewer connectors directory:
|
||||
|
||||
dist/autodesk-<os>-<arch>.zip
|
||||
autodesk/
|
||||
connector.json
|
||||
bonsaiviewer-autodesk[.exe]
|
||||
_internal/...
|
||||
|
||||
Usage:
|
||||
|
||||
pip install -e ".[build]"
|
||||
python packaging/build.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
PACKAGING_DIR = PROJECT_ROOT / "packaging"
|
||||
SPEC_FILE = PACKAGING_DIR / "bonsaiviewer-autodesk.spec"
|
||||
DIST_DIR = PROJECT_ROOT / "dist"
|
||||
BUILD_DIR = PROJECT_ROOT / "build"
|
||||
|
||||
CONNECTOR_FOLDER_NAME = "autodesk"
|
||||
PYINSTALLER_OUTPUT_NAME = "bonsaiviewer-autodesk"
|
||||
|
||||
|
||||
def _platform_tag() -> str:
|
||||
system = platform.system()
|
||||
if system == "Darwin":
|
||||
os_name = "macos"
|
||||
elif system == "Windows":
|
||||
os_name = "windows"
|
||||
else:
|
||||
os_name = system.lower()
|
||||
|
||||
machine = platform.machine().lower()
|
||||
if machine in {"amd64", "x86_64"}:
|
||||
arch = "x86_64"
|
||||
elif machine in {"arm64", "aarch64"}:
|
||||
arch = "arm64"
|
||||
else:
|
||||
arch = machine
|
||||
|
||||
return f"{os_name}-{arch}"
|
||||
|
||||
|
||||
def _clean() -> None:
|
||||
for path in (DIST_DIR, BUILD_DIR):
|
||||
if path.exists():
|
||||
shutil.rmtree(path)
|
||||
|
||||
|
||||
def _run_pyinstaller() -> Path:
|
||||
subprocess.check_call(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"PyInstaller",
|
||||
str(SPEC_FILE),
|
||||
"--noconfirm",
|
||||
"--distpath",
|
||||
str(DIST_DIR),
|
||||
"--workpath",
|
||||
str(BUILD_DIR),
|
||||
],
|
||||
cwd=PROJECT_ROOT,
|
||||
)
|
||||
produced = DIST_DIR / PYINSTALLER_OUTPUT_NAME
|
||||
if not produced.is_dir():
|
||||
raise SystemExit(f"PyInstaller did not produce expected folder: {produced}")
|
||||
return produced
|
||||
|
||||
|
||||
def _assemble_connector_folder(pyinstaller_output: Path) -> Path:
|
||||
connector_dir = DIST_DIR / CONNECTOR_FOLDER_NAME
|
||||
if connector_dir.exists():
|
||||
shutil.rmtree(connector_dir)
|
||||
pyinstaller_output.rename(connector_dir)
|
||||
|
||||
# The source-controlled connector.json uses the bare entry-point name so
|
||||
# `pip install -e .` works for development. For the bundled folder, the
|
||||
# binary lives next to connector.json, so rewrite `exec` to a relative path.
|
||||
manifest = json.loads((PROJECT_ROOT / "connector.json").read_text(encoding="utf-8"))
|
||||
binary_name = "bonsaiviewer-autodesk.exe" if platform.system() == "Windows" else "bonsaiviewer-autodesk"
|
||||
manifest["exec"] = f"./{binary_name}"
|
||||
(connector_dir / "connector.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
return connector_dir
|
||||
|
||||
|
||||
def _zip_connector_folder(tag: str) -> Path:
|
||||
archive_base = DIST_DIR / f"{CONNECTOR_FOLDER_NAME}-{tag}"
|
||||
return Path(shutil.make_archive(str(archive_base), "zip", DIST_DIR, CONNECTOR_FOLDER_NAME))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
tag = _platform_tag()
|
||||
print(f"Building Autodesk connector for {tag}")
|
||||
_clean()
|
||||
pyinstaller_output = _run_pyinstaller()
|
||||
connector_dir = _assemble_connector_folder(pyinstaller_output)
|
||||
archive = _zip_connector_folder(tag)
|
||||
print(f"Connector folder: {connector_dir}")
|
||||
print(f"Distribution zip: {archive}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,32 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=69", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "bonsaiviewer-autodesk"
|
||||
version = "0.1.0"
|
||||
description = "Autodesk cloud connector for Bonsai Viewer"
|
||||
readme = { text = "Autodesk cloud connector for Bonsai Viewer. See src/bonsaiviewer/docs/connectors/autodesk.rst in the IfcOpenShell repository.", content-type = "text/x-rst" }
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"customtkinter>=5.2",
|
||||
"httpx>=0.27",
|
||||
"keyring>=25.2"
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
build = ["pyinstaller>=6.0"]
|
||||
test = ["pytest>=8"]
|
||||
|
||||
[tool.setuptools]
|
||||
include-package-data = true
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["bonsaiviewer_autodesk*"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
[project.scripts]
|
||||
bonsaiviewer-autodesk = "bonsaiviewer_autodesk.__main__:main"
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Shared fixtures for the bonsaiviewer-autodesk test suite."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import keyring
|
||||
import keyring.backend
|
||||
import keyring.errors
|
||||
import pytest
|
||||
|
||||
from bonsaiviewer_autodesk import cache as cache_module
|
||||
from bonsaiviewer_autodesk import settings as settings_module
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cache_dir(tmp_path, monkeypatch):
|
||||
"""Redirect ``bonsaiviewer_autodesk.cache`` at an isolated tmp directory.
|
||||
|
||||
Patches ``cache_root`` itself rather than ``XDG_CACHE_HOME`` so the
|
||||
redirect holds on every platform — on macOS/Windows ``cache_root`` ignores
|
||||
the XDG variables. Returns the directory ``cache_root()`` now resolves to.
|
||||
"""
|
||||
root = tmp_path / "cache" / "bonsaiviewer-autodesk"
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setattr(cache_module, "cache_root", lambda: root)
|
||||
return root
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config_dir(tmp_path, monkeypatch):
|
||||
"""Redirect ``bonsaiviewer_autodesk.settings`` at an isolated tmp directory.
|
||||
|
||||
Patches ``config_root`` directly (platform-independent). Returns the
|
||||
directory ``config_root()`` now resolves to.
|
||||
"""
|
||||
root = tmp_path / "config" / "bonsaiviewer-autodesk"
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setattr(settings_module, "config_root", lambda: root)
|
||||
return root
|
||||
|
||||
|
||||
class InMemoryKeyring(keyring.backend.KeyringBackend):
|
||||
"""A keyring backend that keeps secrets in a dict — never touches the OS."""
|
||||
|
||||
priority = 1 # type: ignore[assignment]
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._store: dict[tuple[str, str], str] = {}
|
||||
|
||||
def get_password(self, service: str, username: str) -> str | None:
|
||||
return self._store.get((service, username))
|
||||
|
||||
def set_password(self, service: str, username: str, password: str) -> None:
|
||||
self._store[(service, username)] = password
|
||||
|
||||
def delete_password(self, service: str, username: str) -> None:
|
||||
try:
|
||||
del self._store[(service, username)]
|
||||
except KeyError as exc:
|
||||
raise keyring.errors.PasswordDeleteError("not found") from exc
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def memory_keyring():
|
||||
"""Swap in the in-memory keyring backend for the duration of a test."""
|
||||
backend = InMemoryKeyring()
|
||||
previous = keyring.get_keyring()
|
||||
keyring.set_keyring(backend)
|
||||
try:
|
||||
yield backend
|
||||
finally:
|
||||
keyring.set_keyring(previous)
|
||||
@@ -0,0 +1,676 @@
|
||||
"""Tests for the Autodesk auth + APS client (``bonsaiviewer_autodesk.autodesk``).
|
||||
|
||||
HTTP is mocked through the injectable ``transport`` seam using
|
||||
``httpx.MockTransport``; time through the injectable ``now`` clock; and the
|
||||
OAuth redirect through the injectable ``callback_waiter``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
|
||||
import httpx
|
||||
import keyring.errors
|
||||
import pytest
|
||||
|
||||
from bonsaiviewer_autodesk import autodesk
|
||||
from bonsaiviewer_autodesk.rpc import RpcError
|
||||
|
||||
|
||||
# --- HTTP routing ------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_response(spec: dict) -> httpx.Response:
|
||||
kwargs = {key: spec[key] for key in ("json", "text", "content", "headers") if key in spec}
|
||||
return httpx.Response(spec.get("status", 200), **kwargs)
|
||||
|
||||
|
||||
class Router:
|
||||
"""A tiny httpx.MockTransport router.
|
||||
|
||||
Routes match on HTTP method plus a substring of the request URL, in
|
||||
declaration order. Pass multiple specs to a route to return them in turn
|
||||
(the last one repeats); every request is recorded on ``requests``.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._routes: list[tuple[str, str, deque]] = []
|
||||
self.requests: list[httpx.Request] = []
|
||||
|
||||
def add(self, method: str, contains: str, *specs: dict) -> "Router":
|
||||
self._routes.append((method, contains, deque(specs or ({},))))
|
||||
return self
|
||||
|
||||
def _handle(self, request: httpx.Request) -> httpx.Response:
|
||||
self.requests.append(request)
|
||||
for method, contains, specs in self._routes:
|
||||
if request.method == method and contains in str(request.url):
|
||||
spec = specs[0] if len(specs) == 1 else specs.popleft()
|
||||
return _build_response(spec)
|
||||
return httpx.Response(404, text=f"unrouted {request.method} {request.url}")
|
||||
|
||||
@property
|
||||
def transport(self) -> httpx.MockTransport:
|
||||
return httpx.MockTransport(self._handle)
|
||||
|
||||
def count(self, method: str, contains: str) -> int:
|
||||
return sum(
|
||||
1 for r in self.requests if r.method == method and contains in str(r.url)
|
||||
)
|
||||
|
||||
|
||||
# --- fakes -------------------------------------------------------------------
|
||||
|
||||
|
||||
class FakeTokenStore:
|
||||
"""In-memory stand-in for KeyringTokenStore."""
|
||||
|
||||
def __init__(self, initial: dict | None = None) -> None:
|
||||
self.value = initial
|
||||
|
||||
def load(self) -> dict | None:
|
||||
return self.value
|
||||
|
||||
def save(self, value: dict) -> None:
|
||||
self.value = value
|
||||
|
||||
def delete(self) -> None:
|
||||
self.value = None
|
||||
|
||||
|
||||
class FakeAuth:
|
||||
"""Minimal AuthSessionService stand-in for ApsClient tests."""
|
||||
|
||||
def ensure_access_token(self) -> str:
|
||||
return "fake-token"
|
||||
|
||||
|
||||
FIXED_NOW = dt.datetime(2026, 1, 1, 12, 0, 0, tzinfo=dt.timezone.utc)
|
||||
|
||||
|
||||
def iso(offset_seconds: int) -> str:
|
||||
return (FIXED_NOW + dt.timedelta(seconds=offset_seconds)).isoformat()
|
||||
|
||||
|
||||
def stored_token_dict(*, access_offset: int, refresh_offset: int) -> dict:
|
||||
return {
|
||||
"client_id": "cid",
|
||||
"access_token": "current-access",
|
||||
"refresh_token": "current-refresh",
|
||||
"access_token_expires_at_utc": iso(access_offset),
|
||||
"refresh_token_expires_at_utc": iso(refresh_offset),
|
||||
"scope": "data:read",
|
||||
}
|
||||
|
||||
|
||||
def make_auth(
|
||||
*,
|
||||
router: Router | None = None,
|
||||
token_store: FakeTokenStore | None = None,
|
||||
callback_waiter=None,
|
||||
callback_url: str = "http://localhost:8080/",
|
||||
) -> autodesk.AuthSessionService:
|
||||
return autodesk.AuthSessionService(
|
||||
client_id="cid",
|
||||
callback_url=callback_url,
|
||||
scope="data:read",
|
||||
token_store=token_store or FakeTokenStore(),
|
||||
transport=(router or Router()).transport,
|
||||
now=lambda: FIXED_NOW,
|
||||
callback_waiter=callback_waiter,
|
||||
)
|
||||
|
||||
|
||||
def make_client(router: Router) -> autodesk.ApsClient:
|
||||
return autodesk.ApsClient(FakeAuth(), transport=router.transport)
|
||||
|
||||
|
||||
# --- pure helpers ------------------------------------------------------------
|
||||
|
||||
|
||||
def test_base64url_strips_padding():
|
||||
assert autodesk._base64url(b"\x00") == "AA"
|
||||
assert "=" not in autodesk._base64url(b"\x00\x00")
|
||||
|
||||
|
||||
def test_generate_code_verifier_is_url_safe_and_unique():
|
||||
verifier = autodesk.generate_code_verifier()
|
||||
assert not set(verifier) & set("=+/")
|
||||
assert autodesk.generate_code_verifier() != autodesk.generate_code_verifier()
|
||||
|
||||
|
||||
def test_generate_code_challenge_matches_rfc7636_vector():
|
||||
# RFC 7636 Appendix B test vector.
|
||||
verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
||||
assert (
|
||||
autodesk.generate_code_challenge(verifier)
|
||||
== "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
|
||||
)
|
||||
|
||||
|
||||
def test_parse_storage_id_splits_bucket_and_object():
|
||||
bucket, obj = autodesk.ApsClient._parse_storage_id(
|
||||
"urn:adsk.objects:os.object:wip.dm.prod/abc-123.ifc"
|
||||
)
|
||||
assert bucket == "wip.dm.prod"
|
||||
assert obj == "abc-123.ifc"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad",
|
||||
[
|
||||
"not-a-urn",
|
||||
"urn:adsk.objects:os.object:bucketonly",
|
||||
"urn:adsk.objects:os.object:/object",
|
||||
"urn:adsk.objects:os.object:bucket/",
|
||||
],
|
||||
)
|
||||
def test_parse_storage_id_rejects_malformed(bad):
|
||||
with pytest.raises(RpcError):
|
||||
autodesk.ApsClient._parse_storage_id(bad)
|
||||
|
||||
|
||||
def test_entry_extracts_fields():
|
||||
item = {
|
||||
"id": "x",
|
||||
"type": "items",
|
||||
"attributes": {
|
||||
"displayName": "Model.ifc",
|
||||
"extension": {"type": "items:autodesk.bim360:File"},
|
||||
},
|
||||
}
|
||||
assert autodesk.ApsClient._entry(item) == {
|
||||
"id": "x",
|
||||
"type": "items",
|
||||
"display_name": "Model.ifc",
|
||||
"name": None,
|
||||
"extension_type": "items:autodesk.bim360:File",
|
||||
}
|
||||
|
||||
|
||||
def test_relationship_id_handles_dict_list_and_missing():
|
||||
data = {
|
||||
"relationships": {
|
||||
"parent": {"data": {"id": "p1"}},
|
||||
"files": {"data": [{"id": "f1"}, {"id": "f2"}]},
|
||||
}
|
||||
}
|
||||
assert autodesk.ApsClient._relationship_id(data, "parent") == "p1"
|
||||
assert autodesk.ApsClient._relationship_id(data, "files") == "f1"
|
||||
assert autodesk.ApsClient._relationship_id(data, "missing") is None
|
||||
|
||||
|
||||
def test_entry_name_matches_is_case_insensitive():
|
||||
assert autodesk.ApsClient._entry_name_matches({"display_name": "Model.IFC"}, "model.ifc")
|
||||
assert autodesk.ApsClient._entry_name_matches({"name": "Model.IFC"}, "model.ifc")
|
||||
assert not autodesk.ApsClient._entry_name_matches({"display_name": "other.ifc"}, "model.ifc")
|
||||
|
||||
|
||||
# --- KeyringTokenStore -------------------------------------------------------
|
||||
|
||||
|
||||
def test_keyring_token_store_round_trip(memory_keyring):
|
||||
store = autodesk.KeyringTokenStore(service_name="svc", username="user")
|
||||
assert store.load() is None
|
||||
store.save({"access_token": "abc"})
|
||||
assert store.load() == {"access_token": "abc"}
|
||||
store.delete()
|
||||
assert store.load() is None
|
||||
|
||||
|
||||
def test_keyring_token_store_delete_missing_is_noop(memory_keyring):
|
||||
store = autodesk.KeyringTokenStore(service_name="svc", username="user")
|
||||
store.delete() # no entry to delete — must not raise
|
||||
|
||||
|
||||
def test_keyring_missing_backend_surfaces_friendly_error(monkeypatch):
|
||||
def boom(*_args, **_kwargs):
|
||||
raise keyring.errors.NoKeyringError("no backend")
|
||||
|
||||
monkeypatch.setattr(autodesk.keyring, "get_password", boom)
|
||||
store = autodesk.KeyringTokenStore(service_name="svc", username="user")
|
||||
with pytest.raises(RpcError) as excinfo:
|
||||
store.load()
|
||||
assert "keyring" in excinfo.value.message.lower()
|
||||
|
||||
|
||||
# --- token lifecycle / injected clock ---------------------------------------
|
||||
|
||||
|
||||
def test_ensure_access_token_returns_unexpired_token_without_http():
|
||||
store = FakeTokenStore(stored_token_dict(access_offset=3600, refresh_offset=100_000))
|
||||
router = Router()
|
||||
auth = make_auth(router=router, token_store=store)
|
||||
assert auth.ensure_access_token() == "current-access"
|
||||
assert router.requests == []
|
||||
|
||||
|
||||
def test_ensure_access_token_refreshes_when_access_expired():
|
||||
store = FakeTokenStore(stored_token_dict(access_offset=-100, refresh_offset=100_000))
|
||||
router = Router().add(
|
||||
"POST",
|
||||
"/authentication/v2/token",
|
||||
{
|
||||
"json": {
|
||||
"access_token": "refreshed-access",
|
||||
"refresh_token": "refreshed-refresh",
|
||||
"expires_in": 3600,
|
||||
"refresh_token_expires_in": 200_000,
|
||||
}
|
||||
},
|
||||
)
|
||||
auth = make_auth(router=router, token_store=store)
|
||||
assert auth.ensure_access_token() == "refreshed-access"
|
||||
assert store.value["access_token"] == "refreshed-access"
|
||||
assert router.count("POST", "/authentication/v2/token") == 1
|
||||
|
||||
|
||||
def test_ensure_access_token_logs_in_when_both_tokens_expired(monkeypatch):
|
||||
monkeypatch.setattr(autodesk.webbrowser, "open", lambda _url: None)
|
||||
store = FakeTokenStore() # nothing stored at all
|
||||
router = Router().add(
|
||||
"POST",
|
||||
"/authentication/v2/token",
|
||||
{
|
||||
"json": {
|
||||
"access_token": "logged-in-access",
|
||||
"refresh_token": "logged-in-refresh",
|
||||
"expires_in": 3600,
|
||||
}
|
||||
},
|
||||
)
|
||||
auth = make_auth(router=router, token_store=store, callback_waiter=lambda *_a: "auth-code")
|
||||
assert auth.ensure_access_token() == "logged-in-access"
|
||||
assert store.value["access_token"] == "logged-in-access"
|
||||
|
||||
|
||||
def test_token_from_payload_uses_injected_clock():
|
||||
auth = make_auth()
|
||||
token = auth._token_from_payload(
|
||||
{"access_token": "a", "refresh_token": "r", "expires_in": 3600}
|
||||
)
|
||||
assert token.access_token_expires_at_utc == iso(3600 - 30)
|
||||
# Default refresh TTL is 15 days, minus the same 30s safety margin.
|
||||
assert token.refresh_token_expires_at_utc == iso(15 * 24 * 60 * 60 - 30)
|
||||
|
||||
|
||||
def test_login_interactive_rejects_non_local_callback():
|
||||
auth = make_auth(callback_url="https://example.com/callback")
|
||||
with pytest.raises(RpcError, match="Callback URL"):
|
||||
auth.login_interactive()
|
||||
|
||||
|
||||
def test_login_interactive_exchanges_code_for_token(monkeypatch):
|
||||
opened: list[str] = []
|
||||
monkeypatch.setattr(autodesk.webbrowser, "open", lambda url: opened.append(url))
|
||||
store = FakeTokenStore()
|
||||
router = Router().add(
|
||||
"POST",
|
||||
"/authentication/v2/token",
|
||||
{
|
||||
"json": {
|
||||
"access_token": "fresh-access",
|
||||
"refresh_token": "fresh-refresh",
|
||||
"expires_in": 3600,
|
||||
}
|
||||
},
|
||||
)
|
||||
auth = make_auth(router=router, token_store=store, callback_waiter=lambda *_a: "the-code")
|
||||
token = auth.login_interactive()
|
||||
assert token.access_token == "fresh-access"
|
||||
assert store.value["access_token"] == "fresh-access"
|
||||
assert opened and opened[0].startswith(autodesk.AuthSessionService.authorize_endpoint)
|
||||
|
||||
|
||||
def test_token_exchange_failure_raises_rpc_error(monkeypatch):
|
||||
monkeypatch.setattr(autodesk.webbrowser, "open", lambda _url: None)
|
||||
router = Router().add(
|
||||
"POST", "/authentication/v2/token", {"status": 400, "text": "invalid_grant"}
|
||||
)
|
||||
auth = make_auth(router=router, callback_waiter=lambda *_a: "the-code")
|
||||
with pytest.raises(RpcError, match="invalid_grant"):
|
||||
auth.login_interactive()
|
||||
|
||||
|
||||
# --- wait_for_oauth_callback (real loopback socket) --------------------------
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with socket.socket() as probe:
|
||||
probe.bind(("127.0.0.1", 0))
|
||||
return probe.getsockname()[1]
|
||||
|
||||
|
||||
def _get_with_retry(url: str, params: dict, timeout: float = 5.0) -> None:
|
||||
"""Fire one GET, retrying only while the server has not yet bound."""
|
||||
deadline = time.time() + timeout
|
||||
while True:
|
||||
try:
|
||||
httpx.get(url, params=params)
|
||||
return
|
||||
except httpx.ConnectError:
|
||||
if time.time() > deadline:
|
||||
raise
|
||||
time.sleep(0.02)
|
||||
|
||||
|
||||
def drive_callback(expected_state: str, query: dict, path: str = "/cb") -> dict:
|
||||
"""Run ``wait_for_oauth_callback`` in a thread and fire one redirect at it."""
|
||||
port = _free_port()
|
||||
outcome: dict = {}
|
||||
|
||||
def server() -> None:
|
||||
try:
|
||||
outcome["code"] = autodesk.wait_for_oauth_callback(
|
||||
"127.0.0.1", port, path, expected_state
|
||||
)
|
||||
except BaseException as exc: # noqa: BLE001 - re-raised to the test
|
||||
outcome["error"] = exc
|
||||
|
||||
thread = threading.Thread(target=server, daemon=True)
|
||||
thread.start()
|
||||
_get_with_retry(f"http://127.0.0.1:{port}{path}", query)
|
||||
thread.join(timeout=5)
|
||||
return outcome
|
||||
|
||||
|
||||
def test_wait_for_oauth_callback_returns_authorization_code():
|
||||
outcome = drive_callback("state-123", {"state": "state-123", "code": "the-code"})
|
||||
assert outcome.get("code") == "the-code"
|
||||
|
||||
|
||||
def test_wait_for_oauth_callback_rejects_state_mismatch():
|
||||
outcome = drive_callback("expected-state", {"state": "tampered", "code": "c"})
|
||||
assert isinstance(outcome.get("error"), RpcError)
|
||||
assert "state mismatch" in outcome["error"].message.lower()
|
||||
|
||||
|
||||
def test_wait_for_oauth_callback_reports_oauth_error():
|
||||
outcome = drive_callback("state-123", {"state": "state-123", "error": "access_denied"})
|
||||
assert isinstance(outcome.get("error"), RpcError)
|
||||
assert "access_denied" in outcome["error"].message
|
||||
|
||||
|
||||
def test_wait_for_oauth_callback_requires_a_code():
|
||||
outcome = drive_callback("state-123", {"state": "state-123"})
|
||||
assert isinstance(outcome.get("error"), RpcError)
|
||||
assert "authorization code" in outcome["error"].message.lower()
|
||||
|
||||
|
||||
# --- ApsClient browsing ------------------------------------------------------
|
||||
|
||||
|
||||
def _hub(hub_id: str, name: str) -> dict:
|
||||
return {
|
||||
"id": hub_id,
|
||||
"attributes": {"name": name, "extension": {"type": "hubs:autodesk.core:Hub"}},
|
||||
}
|
||||
|
||||
|
||||
def _project(project_id: str, name: str) -> dict:
|
||||
return {
|
||||
"id": project_id,
|
||||
"attributes": {
|
||||
"name": name,
|
||||
"extension": {"type": "projects:autodesk.bim360:Project"},
|
||||
},
|
||||
"relationships": {"rootFolder": {"data": {"id": f"root-{project_id}"}}},
|
||||
}
|
||||
|
||||
|
||||
def test_list_hubs_sorts_case_insensitively():
|
||||
router = Router().add(
|
||||
"GET",
|
||||
"/project/v1/hubs",
|
||||
{"json": {"data": [_hub("h2", "Beta"), _hub("h1", "alpha")]}},
|
||||
)
|
||||
hubs = make_client(router).list_hubs()
|
||||
assert [h["name"] for h in hubs] == ["alpha", "Beta"]
|
||||
assert hubs[0]["id"] == "h1"
|
||||
|
||||
|
||||
def test_list_projects_follows_pagination():
|
||||
router = Router()
|
||||
router.add(
|
||||
"GET",
|
||||
"page=2",
|
||||
{"json": {"data": [_project("p2", "Zeta")], "links": {}}},
|
||||
)
|
||||
router.add(
|
||||
"GET",
|
||||
"/hubs/h/projects",
|
||||
{
|
||||
"json": {
|
||||
"data": [_project("p1", "Alpha")],
|
||||
"links": {
|
||||
"next": {
|
||||
"href": "https://developer.api.autodesk.com/project/v1/hubs/h/projects?page=2"
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
projects = make_client(router).list_projects("h")
|
||||
assert [p["id"] for p in projects] == ["p1", "p2"]
|
||||
assert projects[0]["root_folder_id"] == "root-p1"
|
||||
|
||||
|
||||
def test_get_item_returns_tip_details():
|
||||
router = Router().add(
|
||||
"GET",
|
||||
"/items/",
|
||||
{
|
||||
"json": {
|
||||
"data": {
|
||||
"id": "item-1",
|
||||
"attributes": {"displayName": "Model.ifcfed", "hidden": False},
|
||||
"relationships": {
|
||||
"parent": {"data": {"id": "folder-1"}},
|
||||
"tip": {"data": {"id": "v3"}},
|
||||
},
|
||||
},
|
||||
"included": [
|
||||
{
|
||||
"type": "versions",
|
||||
"id": "v3",
|
||||
"attributes": {
|
||||
"versionNumber": 3,
|
||||
"lastModifiedTime": "2026-01-01T00:00:00Z",
|
||||
"lastModifiedUserName": "Dion",
|
||||
},
|
||||
"relationships": {
|
||||
"storage": {
|
||||
"data": {"id": "urn:adsk.objects:os.object:b/o"}
|
||||
}
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
},
|
||||
)
|
||||
item = make_client(router).get_item("proj-1", "item-1")
|
||||
assert item["hidden"] is False
|
||||
assert item["version_id"] == "v3"
|
||||
assert item["storage_id"] == "urn:adsk.objects:os.object:b/o"
|
||||
assert item["version_number"] == 3
|
||||
assert item["parent_folder_id"] == "folder-1"
|
||||
assert item["last_modified_user_name"] == "Dion"
|
||||
|
||||
|
||||
def test_get_item_without_tip_is_treated_as_hidden():
|
||||
router = Router().add(
|
||||
"GET",
|
||||
"/items/",
|
||||
{"json": {"data": {"id": "item-1", "attributes": {}, "relationships": {}}}},
|
||||
)
|
||||
item = make_client(router).get_item("proj-1", "item-1")
|
||||
assert item["hidden"] is True
|
||||
assert item["storage_id"] is None
|
||||
assert item["version_id"] is None
|
||||
|
||||
|
||||
def test_list_folder_contents_applies_extension_filter():
|
||||
router = Router().add(
|
||||
"GET",
|
||||
"/contents",
|
||||
{
|
||||
"json": {
|
||||
"data": [
|
||||
{
|
||||
"id": "f1",
|
||||
"type": "folders",
|
||||
"attributes": {"name": "Sub", "extension": {"type": "t"}},
|
||||
},
|
||||
{
|
||||
"id": "i1",
|
||||
"type": "items",
|
||||
"attributes": {"displayName": "keep.ifcfed", "extension": {}},
|
||||
},
|
||||
{
|
||||
"id": "i2",
|
||||
"type": "items",
|
||||
"attributes": {"displayName": "skip.txt", "extension": {}},
|
||||
},
|
||||
],
|
||||
"links": {},
|
||||
}
|
||||
},
|
||||
)
|
||||
entries = make_client(router).list_folder_contents(
|
||||
"proj",
|
||||
"folder",
|
||||
extension_filter=lambda e: (e["display_name"] or "").endswith(".ifcfed"),
|
||||
)
|
||||
ids = {e["id"] for e in entries}
|
||||
assert ids == {"f1", "i1"} # folders kept, non-.ifcfed item dropped
|
||||
|
||||
|
||||
def test_get_json_maps_http_error_to_rpc_error():
|
||||
router = Router().add(
|
||||
"GET", "/project/v1/hubs", {"status": 403, "text": "Forbidden: bad token"}
|
||||
)
|
||||
with pytest.raises(RpcError, match="Forbidden"):
|
||||
make_client(router).list_hubs()
|
||||
|
||||
|
||||
# --- download / upload -------------------------------------------------------
|
||||
|
||||
|
||||
def test_download_storage_to_file_writes_content_and_reports_progress(tmp_path):
|
||||
router = Router()
|
||||
router.add(
|
||||
"GET",
|
||||
"/signeds3download",
|
||||
{"json": {"url": "https://signed.example/blob"}},
|
||||
)
|
||||
router.add("GET", "signed.example/blob", {"content": b"hello world"})
|
||||
dest = tmp_path / "out.bin"
|
||||
seen: list = []
|
||||
make_client(router).download_storage_to_file(
|
||||
"urn:adsk.objects:os.object:bucket/object",
|
||||
dest,
|
||||
progress=lambda name, pct, done, total: seen.append((name, pct, done, total)),
|
||||
)
|
||||
assert dest.read_bytes() == b"hello world"
|
||||
assert seen[-1][1] == 100 # final progress callback reports 100%
|
||||
|
||||
|
||||
def test_signed_download_url_missing_raises():
|
||||
router = Router().add("GET", "/signeds3download", {"json": {}})
|
||||
with pytest.raises(RpcError, match="URL"):
|
||||
make_client(router).download_storage_to_file(
|
||||
"urn:adsk.objects:os.object:bucket/object", "/tmp/ignored"
|
||||
)
|
||||
|
||||
|
||||
def _upload_router() -> Router:
|
||||
router = Router()
|
||||
router.add(
|
||||
"POST", "/storage", {"json": {"data": {"id": "urn:adsk.objects:os.object:bk/obj"}}}
|
||||
)
|
||||
router.add(
|
||||
"GET",
|
||||
"/signeds3upload",
|
||||
{"json": {"uploadKey": "ukey", "urls": ["https://up.example/part1"]}},
|
||||
)
|
||||
router.add("PUT", "up.example/part1", {"status": 200})
|
||||
router.add("POST", "/signeds3upload", {"status": 200, "json": {}})
|
||||
return router
|
||||
|
||||
|
||||
def test_upload_file_creates_new_item_when_folder_is_empty(tmp_path):
|
||||
local = tmp_path / "model.ifc"
|
||||
local.write_bytes(b"x" * 1024)
|
||||
router = _upload_router()
|
||||
router.add("GET", "/contents", {"json": {"data": [], "links": {}}})
|
||||
router.add(
|
||||
"POST",
|
||||
"/items",
|
||||
{
|
||||
"json": {
|
||||
"data": {"id": "new-item"},
|
||||
"included": [
|
||||
{
|
||||
"type": "versions",
|
||||
"id": "v1",
|
||||
"attributes": {
|
||||
"versionNumber": 1,
|
||||
"lastModifiedTime": "2026-01-01T00:00:00Z",
|
||||
"lastModifiedUserName": "Dion",
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
},
|
||||
)
|
||||
result = make_client(router).upload_file_to_folder(
|
||||
"proj", "folder", local, display_name="model.ifc"
|
||||
)
|
||||
assert result["item_id"] == "new-item"
|
||||
assert result["version_id"] == "v1"
|
||||
assert result["version_number"] == 1
|
||||
assert router.count("PUT", "up.example/part1") == 1
|
||||
|
||||
|
||||
def test_upload_file_creates_a_version_when_item_exists(tmp_path):
|
||||
local = tmp_path / "model.ifc"
|
||||
local.write_bytes(b"x" * 1024)
|
||||
router = _upload_router()
|
||||
router.add(
|
||||
"GET",
|
||||
"/contents",
|
||||
{
|
||||
"json": {
|
||||
"data": [
|
||||
{
|
||||
"id": "existing-item",
|
||||
"type": "items",
|
||||
"attributes": {"displayName": "model.ifc", "extension": {}},
|
||||
}
|
||||
],
|
||||
"links": {},
|
||||
}
|
||||
},
|
||||
)
|
||||
router.add(
|
||||
"POST",
|
||||
"/versions",
|
||||
{"json": {"data": {"id": "v7", "attributes": {"versionNumber": 7}}}},
|
||||
)
|
||||
result = make_client(router).upload_file_to_folder(
|
||||
"proj", "folder", local, display_name="model.ifc"
|
||||
)
|
||||
assert result["item_id"] == "existing-item"
|
||||
assert result["version_id"] == "v7"
|
||||
assert result["version_number"] == 7
|
||||
|
||||
|
||||
def test_upload_rejects_missing_local_file(tmp_path):
|
||||
with pytest.raises(RpcError, match="does not exist"):
|
||||
make_client(Router()).upload_file_to_folder(
|
||||
"proj", "folder", tmp_path / "missing.ifc"
|
||||
)
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Tests for the on-disk cache layout (``bonsaiviewer_autodesk.cache``)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from bonsaiviewer_autodesk import cache
|
||||
|
||||
|
||||
def test_cache_root_uses_xdg_on_linux(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(cache.platform, "system", lambda: "Linux")
|
||||
monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "xdg"))
|
||||
root = cache.cache_root()
|
||||
assert root == tmp_path / "xdg" / "bonsaiviewer-autodesk"
|
||||
assert root.is_dir()
|
||||
|
||||
|
||||
def test_cache_root_uses_localappdata_on_windows(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(cache.platform, "system", lambda: "Windows")
|
||||
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path / "appdata"))
|
||||
root = cache.cache_root()
|
||||
assert root == tmp_path / "appdata" / "bonsaiviewer-autodesk" / "Cache"
|
||||
assert root.is_dir()
|
||||
|
||||
|
||||
def test_cache_root_uses_library_caches_on_macos(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(cache.platform, "system", lambda: "Darwin")
|
||||
monkeypatch.setattr(cache.Path, "home", lambda: tmp_path / "home")
|
||||
root = cache.cache_root()
|
||||
assert root == tmp_path / "home" / "Library" / "Caches" / "bonsaiviewer-autodesk"
|
||||
assert root.is_dir()
|
||||
|
||||
|
||||
def test_ifcfed_dir_is_deterministic(cache_dir):
|
||||
first = cache.ifcfed_dir("project-1", "item-1")
|
||||
second = cache.ifcfed_dir("project-1", "item-1")
|
||||
assert first == second
|
||||
|
||||
|
||||
def test_ifcfed_dir_varies_with_inputs(cache_dir):
|
||||
assert cache.ifcfed_dir("p", "item-1") != cache.ifcfed_dir("p", "item-2")
|
||||
assert cache.ifcfed_dir("p1", "item") != cache.ifcfed_dir("p2", "item")
|
||||
|
||||
|
||||
def test_model_dir_is_per_version(cache_dir):
|
||||
v1 = cache.model_dir("p", "item", "v1")
|
||||
v2 = cache.model_dir("p", "item", "v2")
|
||||
assert v1 != v2
|
||||
assert cache.model_dir("p", "item", "v1") == v1
|
||||
|
||||
|
||||
def test_prepare_sole_child_dir_wipes_existing_contents(cache_dir):
|
||||
directory = cache.ifcfed_dir("p", "item")
|
||||
directory.mkdir(parents=True)
|
||||
(directory / "stale.txt").write_text("old")
|
||||
|
||||
result = cache.prepare_sole_child_dir(directory)
|
||||
|
||||
assert result == directory
|
||||
assert directory.is_dir()
|
||||
assert list(directory.iterdir()) == []
|
||||
|
||||
|
||||
def test_prepare_sole_child_dir_creates_when_absent(cache_dir):
|
||||
directory = cache.model_dir("p", "item", "v1")
|
||||
assert not directory.exists()
|
||||
cache.prepare_sole_child_dir(directory)
|
||||
assert directory.is_dir()
|
||||
|
||||
|
||||
def test_manifest_round_trips(cache_dir):
|
||||
directory = cache.prepare_sole_child_dir(cache.ifcfed_dir("p", "item"))
|
||||
ifcfed_path = directory / "model.ifcfed"
|
||||
ifcfed_path.write_text("data")
|
||||
manifest = {"connector": "autodesk", "item_id": "item", "hub_id": "h"}
|
||||
|
||||
manifest_path = cache.write_manifest(ifcfed_path, manifest)
|
||||
|
||||
assert manifest_path.name == "model.ifcfed.manifest"
|
||||
assert cache.read_manifest(ifcfed_path) == manifest
|
||||
|
||||
|
||||
def test_read_manifest_returns_none_when_absent(cache_dir):
|
||||
directory = cache.prepare_sole_child_dir(cache.ifcfed_dir("p", "item"))
|
||||
assert cache.read_manifest(directory / "model.ifcfed") is None
|
||||
@@ -0,0 +1,304 @@
|
||||
"""Tests for the RPC handlers (``bonsaiviewer_autodesk.connector``).
|
||||
|
||||
The non-interactive handlers are exercised against a fake ``ApsClient`` so no
|
||||
network or GUI is touched; ``run_with_progress`` is stubbed out.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from bonsaiviewer_autodesk import cache, connector, settings
|
||||
from bonsaiviewer_autodesk.rpc import RpcError
|
||||
|
||||
|
||||
# --- fakes / fixtures --------------------------------------------------------
|
||||
|
||||
|
||||
def _fake_run_with_progress(_message, work, *, parent=None):
|
||||
"""Run ``work`` inline with a no-op report — no worker thread, no GUI."""
|
||||
return work(lambda *_args, **_kwargs: None)
|
||||
|
||||
|
||||
class FakeAps:
|
||||
"""Stand-in for ApsClient covering only what the handlers call."""
|
||||
|
||||
def __init__(self, *, item: dict | None = None, items: dict | None = None) -> None:
|
||||
self._item = item
|
||||
self._items = items or {}
|
||||
self.downloaded: list[tuple] = []
|
||||
self.uploaded: list[tuple] = []
|
||||
|
||||
def get_item(self, _project_id: str, item_id: str) -> dict:
|
||||
if item_id in self._items:
|
||||
return dict(self._items[item_id])
|
||||
assert self._item is not None, f"no canned item for {item_id!r}"
|
||||
return dict(self._item)
|
||||
|
||||
def download_storage_to_file(self, storage_id, destination_path, *, progress=None) -> None:
|
||||
Path(destination_path).write_bytes(b"<ifc data>")
|
||||
self.downloaded.append((storage_id, Path(destination_path)))
|
||||
|
||||
def upload_file_to_folder(
|
||||
self, project_id, folder_id, local_path, *, display_name=None, progress=None
|
||||
) -> dict:
|
||||
self.uploaded.append((project_id, folder_id, Path(local_path), display_name))
|
||||
return {
|
||||
"item_id": "uploaded-item",
|
||||
"version_id": "v1",
|
||||
"version_number": 1,
|
||||
"last_modified_time_utc": "2026-01-01T00:00:00Z",
|
||||
"last_modified_user_name": "Dion",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_connector(config_dir, cache_dir, monkeypatch):
|
||||
"""Build an AutodeskConnector wired to a fake ApsClient."""
|
||||
monkeypatch.setattr(connector, "run_with_progress", _fake_run_with_progress)
|
||||
|
||||
def _make(aps: FakeAps) -> connector.AutodeskConnector:
|
||||
conn = connector.AutodeskConnector()
|
||||
conn.aps = aps
|
||||
conn.auth = object() # only identity matters to _require_aps
|
||||
return conn
|
||||
|
||||
return _make
|
||||
|
||||
|
||||
def _ifcfed_item(**overrides) -> dict:
|
||||
item = {
|
||||
"id": "item-1",
|
||||
"hidden": False,
|
||||
"storage_id": "urn:adsk.objects:os.object:bucket/object",
|
||||
"display_name": "Project.ifcfed",
|
||||
"version_id": "v1",
|
||||
"version_number": 1,
|
||||
"last_modified_time_utc": None,
|
||||
"last_modified_user_name": None,
|
||||
"parent_folder_id": "folder-1",
|
||||
}
|
||||
item.update(overrides)
|
||||
return item
|
||||
|
||||
|
||||
# --- pure helpers ------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value,expected",
|
||||
[
|
||||
(0, "0 B"),
|
||||
(512, "512 B"),
|
||||
(1024, "1.0 KB"),
|
||||
(1536, "1.5 KB"),
|
||||
(5 * 1024 * 1024, "5.0 MB"),
|
||||
],
|
||||
)
|
||||
def test_format_bytes(value, expected):
|
||||
assert connector._format_bytes(value) == expected
|
||||
|
||||
|
||||
def test_progress_detail_combines_percent_and_bytes():
|
||||
detail = connector._progress_detail(45, 4_500_000, 10_000_000)
|
||||
assert detail.startswith("45%, ")
|
||||
assert " / " in detail
|
||||
|
||||
|
||||
def test_progress_detail_percent_only():
|
||||
assert connector._progress_detail(50, None, None) == "50%"
|
||||
|
||||
|
||||
def test_progress_detail_bytes_without_total():
|
||||
assert connector._progress_detail(None, 2048, None) == "2.0 KB"
|
||||
|
||||
|
||||
def test_progress_detail_empty_when_nothing_known():
|
||||
assert connector._progress_detail(None, None, None) == ""
|
||||
|
||||
|
||||
def test_build_metadata_maps_all_fields():
|
||||
metadata = connector._build_metadata(
|
||||
{
|
||||
"version_number": 3,
|
||||
"last_modified_time_utc": "2026-01-01T00:00:00Z",
|
||||
"last_modified_user_name": "Dion",
|
||||
}
|
||||
)
|
||||
assert metadata == {
|
||||
"revision": "v3",
|
||||
"date": "2026-01-01T00:00:00Z",
|
||||
"author": "Dion",
|
||||
}
|
||||
|
||||
|
||||
def test_build_metadata_omits_missing_fields():
|
||||
assert connector._build_metadata({}) == {}
|
||||
assert connector._build_metadata({"version_number": None}) == {}
|
||||
|
||||
|
||||
def test_require_string_rejects_missing_or_blank():
|
||||
assert connector._require_string({"k": "v"}, "k") == "v"
|
||||
with pytest.raises(RpcError):
|
||||
connector._require_string({"k": " "}, "k")
|
||||
with pytest.raises(RpcError):
|
||||
connector._require_string({}, "k")
|
||||
|
||||
|
||||
def test_require_object_and_array_type_checks():
|
||||
assert connector._require_object({"a": 1}, "p") == {"a": 1}
|
||||
assert connector._require_array([1, 2], "p") == [1, 2]
|
||||
with pytest.raises(RpcError):
|
||||
connector._require_object([], "p")
|
||||
with pytest.raises(RpcError):
|
||||
connector._require_array({}, "p")
|
||||
|
||||
|
||||
# --- credential wiring -------------------------------------------------------
|
||||
|
||||
|
||||
def test_reload_credentials_without_client_id_leaves_aps_unset(config_dir):
|
||||
conn = connector.AutodeskConnector()
|
||||
assert conn.aps is None
|
||||
assert conn.auth is None
|
||||
with pytest.raises(RpcError, match="client id"):
|
||||
conn._require_aps()
|
||||
|
||||
|
||||
def test_reload_credentials_with_client_id_builds_aps(config_dir):
|
||||
settings.save_client_id("my-client-id")
|
||||
conn = connector.AutodeskConnector()
|
||||
assert conn.aps is not None
|
||||
assert conn.auth is not None
|
||||
|
||||
|
||||
# --- pull_ifcfed -------------------------------------------------------------
|
||||
|
||||
|
||||
def test_pull_ifcfed_downloads_and_writes_manifest(make_connector):
|
||||
aps = FakeAps(item=_ifcfed_item())
|
||||
conn = make_connector(aps)
|
||||
|
||||
result = conn.pull_ifcfed({"hub_id": "h", "project_id": "p", "item_id": "item-1"})
|
||||
|
||||
path = Path(result["path"])
|
||||
assert path.exists()
|
||||
assert path.name == "Project.ifcfed"
|
||||
assert aps.downloaded # the fake actually got asked to download
|
||||
|
||||
manifest = cache.read_manifest(path)
|
||||
assert manifest["connector"] == "autodesk"
|
||||
assert manifest["item_id"] == "item-1"
|
||||
assert manifest["hub_id"] == "h"
|
||||
|
||||
|
||||
def test_pull_ifcfed_rejects_non_ifcfed_file(make_connector):
|
||||
aps = FakeAps(item=_ifcfed_item(display_name="model.ifc"))
|
||||
conn = make_connector(aps)
|
||||
with pytest.raises(RpcError, match="ifcfed"):
|
||||
conn.pull_ifcfed({"hub_id": "h", "project_id": "p", "item_id": "item-1"})
|
||||
|
||||
|
||||
def test_pull_ifcfed_rejects_deleted_item(make_connector):
|
||||
aps = FakeAps(item=_ifcfed_item(hidden=True))
|
||||
conn = make_connector(aps)
|
||||
with pytest.raises(RpcError, match="deleted"):
|
||||
conn.pull_ifcfed({"hub_id": "h", "project_id": "p", "item_id": "item-1"})
|
||||
|
||||
|
||||
def test_pull_ifcfed_requires_string_fields(make_connector):
|
||||
conn = make_connector(FakeAps(item=_ifcfed_item()))
|
||||
with pytest.raises(RpcError, match="item_id"):
|
||||
conn.pull_ifcfed({"hub_id": "h", "project_id": "p"})
|
||||
|
||||
|
||||
# --- pull_models -------------------------------------------------------------
|
||||
|
||||
|
||||
def test_pull_models_skips_failures_with_none(make_connector):
|
||||
aps = FakeAps(
|
||||
items={
|
||||
"good": _ifcfed_item(id="good", display_name="good.ifc"),
|
||||
"gone": _ifcfed_item(id="gone", hidden=True),
|
||||
}
|
||||
)
|
||||
conn = make_connector(aps)
|
||||
models = [
|
||||
{"source": {"connector": "autodesk", "project_id": "p", "item_id": "good"}},
|
||||
{"source": {"connector": "autodesk", "project_id": "p", "item_id": "gone"}},
|
||||
{"source": {"connector": "other", "project_id": "p", "item_id": "good"}},
|
||||
]
|
||||
|
||||
results = conn.pull_models(models)
|
||||
|
||||
assert len(results) == 3
|
||||
assert results[0] is not None and Path(results[0]["path"]).exists()
|
||||
assert results[1] is None # hidden/deleted item
|
||||
assert results[2] is None # wrong connector -> RpcError, swallowed
|
||||
|
||||
|
||||
def test_pull_models_requires_a_json_array(make_connector):
|
||||
conn = make_connector(FakeAps(item=_ifcfed_item()))
|
||||
with pytest.raises(RpcError, match="array"):
|
||||
conn.pull_models({"not": "an array"})
|
||||
|
||||
|
||||
# --- push_ifcfed -------------------------------------------------------------
|
||||
|
||||
|
||||
def test_push_ifcfed_uploads_and_caches_with_manifest(make_connector, tmp_path):
|
||||
local = tmp_path / "local.ifcfed"
|
||||
local.write_bytes(b"ifcfed bytes")
|
||||
aps = FakeAps(item=_ifcfed_item())
|
||||
conn = make_connector(aps)
|
||||
|
||||
result = conn.push_ifcfed(
|
||||
{
|
||||
"path": str(local),
|
||||
"manifest": {
|
||||
"connector": "autodesk",
|
||||
"hub_id": "h",
|
||||
"project_id": "p",
|
||||
"item_id": "item-1",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert aps.uploaded
|
||||
cached = Path(result["path"])
|
||||
assert cached.exists()
|
||||
assert cache.read_manifest(cached)["item_id"] == "uploaded-item"
|
||||
|
||||
|
||||
def test_push_ifcfed_rejects_missing_local_file(make_connector):
|
||||
conn = make_connector(FakeAps(item=_ifcfed_item()))
|
||||
with pytest.raises(RpcError, match="does not exist"):
|
||||
conn.push_ifcfed(
|
||||
{
|
||||
"path": "/no/such/file.ifcfed",
|
||||
"manifest": {
|
||||
"connector": "autodesk",
|
||||
"hub_id": "h",
|
||||
"project_id": "p",
|
||||
"item_id": "item-1",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_push_ifcfed_rejects_non_ifcfed_extension(make_connector, tmp_path):
|
||||
local = tmp_path / "local.ifc"
|
||||
local.write_bytes(b"data")
|
||||
conn = make_connector(FakeAps(item=_ifcfed_item()))
|
||||
with pytest.raises(RpcError, match="ifcfed"):
|
||||
conn.push_ifcfed({"path": str(local), "manifest": {"connector": "autodesk"}})
|
||||
|
||||
|
||||
def test_push_ifcfed_rejects_foreign_connector_manifest(make_connector, tmp_path):
|
||||
local = tmp_path / "local.ifcfed"
|
||||
local.write_bytes(b"data")
|
||||
conn = make_connector(FakeAps(item=_ifcfed_item()))
|
||||
with pytest.raises(RpcError, match="connector"):
|
||||
conn.push_ifcfed({"path": str(local), "manifest": {"connector": "other"}})
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Tests for the JSON-RPC host (``bonsaiviewer_autodesk.rpc``)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
|
||||
from bonsaiviewer_autodesk.rpc import (
|
||||
JSONRPC_INTERNAL_ERROR,
|
||||
JSONRPC_INVALID_PARAMS,
|
||||
JSONRPC_INVALID_REQUEST,
|
||||
JSONRPC_METHOD_NOT_FOUND,
|
||||
JSONRPC_PARSE_ERROR,
|
||||
JsonRpcHost,
|
||||
RpcError,
|
||||
)
|
||||
|
||||
|
||||
def run(line: str, handlers: dict | None = None) -> tuple[list[dict], str]:
|
||||
"""Drive a ``JsonRpcHost`` over in-memory streams.
|
||||
|
||||
Returns ``(responses, stderr_text)`` where ``responses`` is the parsed
|
||||
JSON written to stdout, one element per line.
|
||||
"""
|
||||
out = io.StringIO()
|
||||
err = io.StringIO()
|
||||
JsonRpcHost(
|
||||
handlers or {},
|
||||
stdin=io.StringIO(line),
|
||||
stdout=out,
|
||||
stderr=err,
|
||||
).run()
|
||||
responses = [json.loads(piece) for piece in out.getvalue().splitlines() if piece]
|
||||
return responses, err.getvalue()
|
||||
|
||||
|
||||
def test_parse_error_for_invalid_json():
|
||||
responses, _ = run("this is not json\n")
|
||||
assert responses[0]["error"]["code"] == JSONRPC_PARSE_ERROR
|
||||
assert responses[0]["id"] is None
|
||||
|
||||
|
||||
def test_request_must_be_a_json_object():
|
||||
responses, _ = run("[1, 2, 3]\n")
|
||||
assert responses[0]["error"]["code"] == JSONRPC_INVALID_REQUEST
|
||||
|
||||
|
||||
def test_wrong_jsonrpc_version_keeps_id():
|
||||
responses, _ = run('{"jsonrpc": "1.0", "id": 7, "method": "go"}\n')
|
||||
assert responses[0]["error"]["code"] == JSONRPC_INVALID_REQUEST
|
||||
assert responses[0]["id"] == 7
|
||||
|
||||
|
||||
def test_missing_method_string():
|
||||
responses, _ = run('{"jsonrpc": "2.0", "id": 1}\n')
|
||||
assert responses[0]["error"]["code"] == JSONRPC_INVALID_REQUEST
|
||||
|
||||
|
||||
def test_params_must_be_object_or_array():
|
||||
responses, _ = run('{"jsonrpc": "2.0", "id": 1, "method": "go", "params": 5}\n')
|
||||
assert responses[0]["error"]["code"] == JSONRPC_INVALID_PARAMS
|
||||
|
||||
|
||||
def test_unknown_method():
|
||||
responses, _ = run('{"jsonrpc": "2.0", "id": 1, "method": "nope"}\n')
|
||||
assert responses[0]["error"]["code"] == JSONRPC_METHOD_NOT_FOUND
|
||||
|
||||
|
||||
def test_successful_result_round_trip():
|
||||
responses, _ = run(
|
||||
'{"jsonrpc": "2.0", "id": 42, "method": "echo", "params": {"x": 1}}\n',
|
||||
{"echo": lambda params: params},
|
||||
)
|
||||
assert responses == [{"jsonrpc": "2.0", "id": 42, "result": {"x": 1}}]
|
||||
|
||||
|
||||
def test_rpc_error_is_forwarded_with_code_and_data():
|
||||
def handler(_params):
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, "boom", data={"detail": "x"})
|
||||
|
||||
responses, _ = run(
|
||||
'{"jsonrpc": "2.0", "id": 1, "method": "go"}\n',
|
||||
{"go": handler},
|
||||
)
|
||||
assert responses[0]["error"] == {
|
||||
"code": JSONRPC_INTERNAL_ERROR,
|
||||
"message": "boom",
|
||||
"data": {"detail": "x"},
|
||||
}
|
||||
|
||||
|
||||
def test_unexpected_exception_becomes_internal_error():
|
||||
def handler(_params):
|
||||
raise ValueError("kaboom")
|
||||
|
||||
responses, stderr = run(
|
||||
'{"jsonrpc": "2.0", "id": 1, "method": "go"}\n',
|
||||
{"go": handler},
|
||||
)
|
||||
assert responses[0]["error"]["code"] == JSONRPC_INTERNAL_ERROR
|
||||
assert responses[0]["error"]["message"] == "kaboom"
|
||||
assert "Traceback" in stderr
|
||||
|
||||
|
||||
def test_notification_runs_handler_but_writes_no_response():
|
||||
calls: list[int] = []
|
||||
responses, _ = run(
|
||||
'{"jsonrpc": "2.0", "method": "go"}\n',
|
||||
{"go": lambda _params: calls.append(1)},
|
||||
)
|
||||
assert calls == [1]
|
||||
assert responses == []
|
||||
|
||||
|
||||
def test_blank_lines_skipped_and_requests_processed_in_order():
|
||||
line = (
|
||||
'{"jsonrpc": "2.0", "id": 1, "method": "go"}\n'
|
||||
"\n"
|
||||
" \n"
|
||||
'{"jsonrpc": "2.0", "id": 2, "method": "go"}\n'
|
||||
)
|
||||
responses, _ = run(line, {"go": lambda _params: "ok"})
|
||||
assert [r["id"] for r in responses] == [1, 2]
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Tests for settings persistence (``bonsaiviewer_autodesk.settings``)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from bonsaiviewer_autodesk import settings
|
||||
|
||||
|
||||
def _write_settings_json(config_dir, data: dict) -> None:
|
||||
(config_dir / "settings.json").write_text(json.dumps(data), encoding="utf-8")
|
||||
|
||||
|
||||
def test_config_root_uses_xdg_on_linux(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(settings.platform, "system", lambda: "Linux")
|
||||
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg"))
|
||||
root = settings.config_root()
|
||||
assert root == tmp_path / "xdg" / "bonsaiviewer-autodesk"
|
||||
assert root.is_dir()
|
||||
|
||||
|
||||
def test_config_root_uses_appdata_on_windows(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(settings.platform, "system", lambda: "Windows")
|
||||
monkeypatch.setenv("APPDATA", str(tmp_path / "appdata"))
|
||||
root = settings.config_root()
|
||||
assert root == tmp_path / "appdata" / "bonsaiviewer-autodesk"
|
||||
assert root.is_dir()
|
||||
|
||||
|
||||
def test_config_root_uses_application_support_on_macos(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(settings.platform, "system", lambda: "Darwin")
|
||||
monkeypatch.setattr(settings.Path, "home", lambda: tmp_path / "home")
|
||||
root = settings.config_root()
|
||||
assert root == tmp_path / "home" / "Library" / "Application Support" / "bonsaiviewer-autodesk"
|
||||
assert root.is_dir()
|
||||
|
||||
|
||||
def test_save_and_load_client_id_strips_whitespace(config_dir):
|
||||
settings.save_client_id(" abc123 ")
|
||||
assert settings.load_client_id() == "abc123"
|
||||
|
||||
|
||||
def test_load_client_id_empty_when_nothing_configured(config_dir):
|
||||
assert settings.load_client_id() == ""
|
||||
|
||||
|
||||
def test_callback_port_round_trips(config_dir):
|
||||
settings.save_callback_port(9001)
|
||||
assert settings.stored_callback_port() == 9001
|
||||
|
||||
|
||||
def test_callback_port_defaults_when_unset(config_dir):
|
||||
assert settings.stored_callback_port() == settings.DEFAULT_CALLBACK_PORT
|
||||
|
||||
|
||||
def test_callback_port_defaults_on_non_numeric_value(config_dir):
|
||||
_write_settings_json(config_dir, {"callback_port": "not-a-number"})
|
||||
assert settings.stored_callback_port() == settings.DEFAULT_CALLBACK_PORT
|
||||
|
||||
|
||||
def test_callback_port_defaults_on_out_of_range_value(config_dir):
|
||||
_write_settings_json(config_dir, {"callback_port": 70000})
|
||||
assert settings.stored_callback_port() == settings.DEFAULT_CALLBACK_PORT
|
||||
|
||||
|
||||
def test_save_callback_port_rejects_out_of_range(config_dir):
|
||||
with pytest.raises(ValueError):
|
||||
settings.save_callback_port(0)
|
||||
with pytest.raises(ValueError):
|
||||
settings.save_callback_port(70000)
|
||||
|
||||
|
||||
def test_corrupt_settings_file_is_treated_as_empty(config_dir):
|
||||
(config_dir / "settings.json").write_text("{ not valid json", encoding="utf-8")
|
||||
assert settings.load_client_id() == ""
|
||||
assert settings.stored_callback_port() == settings.DEFAULT_CALLBACK_PORT
|
||||
|
||||
|
||||
def test_save_client_id_preserves_other_keys(config_dir):
|
||||
settings.save_callback_port(9001)
|
||||
settings.save_client_id("abc")
|
||||
assert settings.stored_callback_port() == 9001
|
||||
assert settings.load_client_id() == "abc"
|
||||
@@ -0,0 +1,133 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
################################################################################
|
||||
# #
|
||||
# This file is part of IfcOpenShell. #
|
||||
# #
|
||||
# IfcOpenShell is free software: you can redistribute it and/or modify #
|
||||
# it under the terms of the Lesser GNU General Public License as published by #
|
||||
# the Free Software Foundation, either version 3.0 of the License, or #
|
||||
# (at your option) any later version. #
|
||||
# #
|
||||
# IfcOpenShell is distributed in the hope that it will be useful, #
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
|
||||
# Lesser GNU General Public License for more details. #
|
||||
# #
|
||||
# You should have received a copy of the Lesser GNU General Public License #
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>. #
|
||||
# #
|
||||
################################################################################
|
||||
|
||||
message("Running CMakeLists.txt in /src/bonsaiviewer")
|
||||
|
||||
set(QT_VERSION 6 CACHE STRING "Qt version")
|
||||
# Qt6::CorePrivate is exposed differently across Qt versions: Qt 6.8 ships the
|
||||
# target inside Qt6Core (no CorePrivate config package), while Qt 6.10 provides
|
||||
# it only as a separate CorePrivate package. OPTIONAL_COMPONENTS finds that
|
||||
# package where it exists without failing where it does not; the Qt6::CorePrivate
|
||||
# target ends up available either way for the link step below.
|
||||
find_package(Qt${QT_VERSION} REQUIRED
|
||||
COMPONENTS Core Gui Widgets Svg
|
||||
OPTIONAL_COMPONENTS CorePrivate
|
||||
PATHS ${QT_DIR})
|
||||
|
||||
set(BONSAIVIEWER_FILES
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/main.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ElementRegistry.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ElementRegistry.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ViewerSettings.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ViewerSettings.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/SessionState.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/SessionState.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/MainWindow.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/MainWindow.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/Measurement.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/Measurement.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/components/SvgIcon.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/components/SvgIcon.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/components/Style.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/components/Style.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/components/Dialog.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/components/Dialog.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/components/Tabs.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/components/Tabs.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/components/KeyValueTable.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/components/KeyValueTable.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/components/Buttons.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/components/Buttons.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/components/Section.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/components/Section.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/components/Panel.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/components/Panel.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/modules/connectors/Discovery.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/modules/connectors/Discovery.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/modules/connectors/PickerDialog.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/modules/connectors/PickerDialog.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/modules/connectors/Process.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/modules/connectors/Process.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/modules/connectors/Registry.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/modules/connectors/Registry.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/modules/models/AddModelDialog.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/modules/models/AddModelDialog.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/modules/models/SettingsDialog.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/modules/models/SettingsDialog.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/modules/models/SettingsView.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/modules/models/SettingsView.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/modules/models/Types.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/modules/models/Commands.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/modules/models/Commands.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/modules/models/FederationItemModel.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/modules/models/FederationItemModel.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/modules/models/Panel.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/modules/models/Panel.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/modules/models/View.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/modules/models/View.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/modules/todo/Panel.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/modules/todo/Panel.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/modules/properties/Types.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/modules/properties/Panel.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/modules/properties/Panel.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/modules/properties/View.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/modules/properties/View.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/modules/project/Commands.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/modules/project/Commands.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/modules/project/RecentProjects.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/modules/project/RecentProjects.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/modules/project/SaveProjectDialog.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/modules/project/SaveProjectDialog.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/modules/settings/Dialog.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/modules/settings/Dialog.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/modules/spatial_hierarchy/Types.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/modules/spatial_hierarchy/Panel.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/modules/spatial_hierarchy/Panel.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/modules/spatial_hierarchy/View.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/modules/spatial_hierarchy/View.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/modules/viewport/Panel.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/modules/viewport/Panel.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/modules/viewport/Commands.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/modules/viewport/Commands.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/modules/viewport/View.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/modules/viewport/View.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/bonsaiviewer_resources.qrc
|
||||
)
|
||||
|
||||
add_executable(BonsaiViewer ${BONSAIVIEWER_FILES})
|
||||
|
||||
set_target_properties(BonsaiViewer PROPERTIES
|
||||
AUTOMOC ON
|
||||
AUTORCC ON
|
||||
WIN32_EXECUTABLE ON
|
||||
MACOSX_BUNDLE ON
|
||||
)
|
||||
|
||||
target_link_libraries(BonsaiViewer PRIVATE
|
||||
IfcViewer
|
||||
Qt${QT_VERSION}::Core
|
||||
Qt${QT_VERSION}::CorePrivate
|
||||
Qt${QT_VERSION}::Gui
|
||||
Qt${QT_VERSION}::Svg
|
||||
Qt${QT_VERSION}::Widgets
|
||||
)
|
||||
|
||||
install(TARGETS BonsaiViewer EXPORT ${IFCOPENSHELL_EXPORT_TARGETS})
|
||||
ifcopenshell_deploy_qt_runtime(BonsaiViewer)
|
||||
@@ -0,0 +1,126 @@
|
||||
// This file was generated with the assistance of an AI coding tool.
|
||||
/********************************************************************************
|
||||
* *
|
||||
* This file is part of IfcOpenShell. *
|
||||
* *
|
||||
* IfcOpenShell is free software: you can redistribute it and/or modify *
|
||||
* it under the terms of the Lesser GNU General Public License as published by *
|
||||
* the Free Software Foundation, either version 3.0 of the License, or *
|
||||
* (at your option) any later version. *
|
||||
* *
|
||||
* IfcOpenShell is distributed in the hope that it will be useful, *
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||
* Lesser GNU General Public License for more details. *
|
||||
* *
|
||||
* You should have received a copy of the Lesser GNU General Public License *
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||
* *
|
||||
********************************************************************************/
|
||||
|
||||
#include "ElementRegistry.h"
|
||||
|
||||
#include "../ifcviewer/GeometryStreamer.h"
|
||||
#include "../ifcviewer/SceneLoader.h"
|
||||
#include "../ifcviewer/SidecarCache.h"
|
||||
|
||||
namespace bonsaiviewer {
|
||||
|
||||
ElementRegistry::ElementRegistry(QObject* parent)
|
||||
: QObject(parent)
|
||||
{
|
||||
}
|
||||
|
||||
void ElementRegistry::bindLoader(SceneLoader* loader) {
|
||||
loader_ = loader;
|
||||
connect(loader, &SceneLoader::sidecarElementsReady,
|
||||
this, &ElementRegistry::onSidecarElementsReady);
|
||||
connect(loader, &SceneLoader::streamedElementsReady,
|
||||
this, &ElementRegistry::onStreamedElementsReady);
|
||||
}
|
||||
|
||||
void ElementRegistry::clear() {
|
||||
elements_.clear();
|
||||
}
|
||||
|
||||
void ElementRegistry::removeModel(uint32_t model_id) {
|
||||
for (auto it = elements_.begin(); it != elements_.end();) {
|
||||
if (it->second.model_id == model_id) {
|
||||
it = elements_.erase(it);
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<BasicElementInfo> ElementRegistry::basicElementInfoForModel(uint32_t model_id) const {
|
||||
std::vector<BasicElementInfo> result;
|
||||
result.reserve(elements_.size());
|
||||
for (const auto& [object_id, info] : elements_) {
|
||||
(void)object_id;
|
||||
if (info.model_id != model_id) continue;
|
||||
result.push_back(info);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
std::optional<BasicElementInfo> ElementRegistry::findBasicElementInfo(uint32_t object_id) const {
|
||||
auto it = elements_.find(object_id);
|
||||
if (it == elements_.end()) return std::nullopt;
|
||||
return it->second;
|
||||
}
|
||||
|
||||
std::optional<express::Base> ElementRegistry::findEntity(uint32_t object_id) const {
|
||||
if (!loader_) return std::nullopt;
|
||||
|
||||
auto info = findBasicElementInfo(object_id);
|
||||
if (!info) return std::nullopt;
|
||||
|
||||
auto* file = loader_->ifcFile(info->model_id);
|
||||
if (!file) return std::nullopt;
|
||||
|
||||
try {
|
||||
auto instance = file->instance_by_id(info->ifc_id);
|
||||
if (!instance) return std::nullopt;
|
||||
return instance;
|
||||
} catch (...) {
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
|
||||
void ElementRegistry::onSidecarElementsReady(uint32_t /*mid*/,
|
||||
std::vector<PackedElementInfo> elements,
|
||||
std::string string_table) {
|
||||
auto str = [&](uint32_t offset, uint32_t length) -> QString {
|
||||
if (length == 0 || offset + length > string_table.size()) return {};
|
||||
return QString::fromStdString(string_table.substr(offset, length));
|
||||
};
|
||||
|
||||
for (const auto& pe : elements) {
|
||||
BasicElementInfo info;
|
||||
info.object_id = pe.object_id;
|
||||
info.model_id = pe.model_id;
|
||||
info.ifc_id = pe.ifc_id;
|
||||
info.parent_id = pe.parent_id;
|
||||
info.guid = str(pe.guid_offset, pe.guid_length);
|
||||
info.name = str(pe.name_offset, pe.name_length);
|
||||
info.type = str(pe.type_offset, pe.type_length);
|
||||
elements_[info.object_id] = info;
|
||||
}
|
||||
}
|
||||
|
||||
void ElementRegistry::onStreamedElementsReady(uint32_t /*mid*/, std::vector<ElementInfo> elements) {
|
||||
for (const auto& e : elements) {
|
||||
BasicElementInfo info;
|
||||
info.object_id = e.object_id;
|
||||
info.model_id = e.model_id;
|
||||
info.ifc_id = e.ifc_id;
|
||||
info.parent_id = e.parent_id;
|
||||
info.guid = QString::fromStdString(e.guid);
|
||||
info.name = QString::fromStdString(e.name);
|
||||
info.type = QString::fromStdString(e.type);
|
||||
elements_[info.object_id] = info;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace bonsaiviewer
|
||||
@@ -0,0 +1,72 @@
|
||||
// This file was generated with the assistance of an AI coding tool.
|
||||
/********************************************************************************
|
||||
* *
|
||||
* This file is part of IfcOpenShell. *
|
||||
* *
|
||||
* IfcOpenShell is free software: you can redistribute it and/or modify *
|
||||
* it under the terms of the Lesser GNU General Public License as published by *
|
||||
* the Free Software Foundation, either version 3.0 of the License, or *
|
||||
* (at your option) any later version. *
|
||||
* *
|
||||
* IfcOpenShell is distributed in the hope that it will be useful, *
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||
* Lesser GNU General Public License for more details. *
|
||||
* *
|
||||
* You should have received a copy of the Lesser GNU General Public License *
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||
* *
|
||||
********************************************************************************/
|
||||
|
||||
#ifndef IFCINTERFACE_ELEMENTREGISTRY_H
|
||||
#define IFCINTERFACE_ELEMENTREGISTRY_H
|
||||
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include "../ifcparse/express.h"
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
class SceneLoader;
|
||||
struct PackedElementInfo;
|
||||
struct ElementInfo;
|
||||
|
||||
namespace bonsaiviewer {
|
||||
|
||||
struct BasicElementInfo {
|
||||
uint32_t object_id = 0;
|
||||
uint32_t model_id = 0;
|
||||
int ifc_id = 0;
|
||||
int parent_id = 0;
|
||||
QString guid;
|
||||
QString name;
|
||||
QString type;
|
||||
};
|
||||
|
||||
class ElementRegistry : public QObject {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit ElementRegistry(QObject* parent = nullptr);
|
||||
|
||||
void bindLoader(SceneLoader* loader);
|
||||
void clear();
|
||||
void removeModel(uint32_t model_id);
|
||||
std::vector<BasicElementInfo> basicElementInfoForModel(uint32_t model_id) const;
|
||||
std::optional<BasicElementInfo> findBasicElementInfo(uint32_t object_id) const;
|
||||
std::optional<express::Base> findEntity(uint32_t object_id) const;
|
||||
|
||||
private:
|
||||
void onSidecarElementsReady(uint32_t mid,
|
||||
std::vector<PackedElementInfo> elements,
|
||||
std::string string_table);
|
||||
void onStreamedElementsReady(uint32_t mid, std::vector<ElementInfo> elements);
|
||||
|
||||
SceneLoader* loader_ = nullptr;
|
||||
std::unordered_map<uint32_t, BasicElementInfo> elements_;
|
||||
};
|
||||
|
||||
} // namespace bonsaiviewer
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,564 @@
|
||||
// This file was generated with the assistance of an AI coding tool.
|
||||
/********************************************************************************
|
||||
* *
|
||||
* This file is part of IfcOpenShell. *
|
||||
* *
|
||||
* IfcOpenShell is free software: you can redistribute it and/or modify *
|
||||
* it under the terms of the Lesser GNU General Public License as published by *
|
||||
* the Free Software Foundation, either version 3.0 of the License, or *
|
||||
* (at your option) any later version. *
|
||||
* *
|
||||
* IfcOpenShell is distributed in the hope that it will be useful, *
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||
* Lesser GNU General Public License for more details. *
|
||||
* *
|
||||
* You should have received a copy of the Lesser GNU General Public License *
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||
* *
|
||||
********************************************************************************/
|
||||
|
||||
#include "MainWindow.h"
|
||||
|
||||
#include "../ifcviewer/AppSettings.h"
|
||||
#include "../ifcviewer/Federation.h"
|
||||
#include "../ifcviewer/ViewportWindow.h"
|
||||
#include "SessionState.h"
|
||||
#include "components/Buttons.h"
|
||||
#include "components/Panel.h"
|
||||
#include "components/Style.h"
|
||||
#include "components/Tabs.h"
|
||||
#include "modules/models/Commands.h"
|
||||
#include "modules/todo/Panel.h"
|
||||
#include "modules/models/View.h"
|
||||
#include "modules/models/Panel.h"
|
||||
#include "modules/project/Commands.h"
|
||||
#include "modules/project/RecentProjects.h"
|
||||
#include "modules/properties/View.h"
|
||||
#include "modules/properties/Panel.h"
|
||||
#include "modules/settings/Dialog.h"
|
||||
#include "modules/spatial_hierarchy/View.h"
|
||||
#include "modules/spatial_hierarchy/Panel.h"
|
||||
#include "modules/viewport/Commands.h"
|
||||
#include "modules/viewport/Panel.h"
|
||||
#include "modules/viewport/View.h"
|
||||
|
||||
#include <QAction>
|
||||
#include <QDockWidget>
|
||||
#include <QFileInfo>
|
||||
#include <QHBoxLayout>
|
||||
#include <QIcon>
|
||||
#include <QLabel>
|
||||
#include <QMenu>
|
||||
#include <QMessageBox>
|
||||
#include <QShortcut>
|
||||
#include <QSignalBlocker>
|
||||
#include <QStackedWidget>
|
||||
#include <QStatusBar>
|
||||
#include <QProgressBar>
|
||||
#include <QToolButton>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
namespace bonsaiviewer::shell {
|
||||
|
||||
MainWindow::MainWindow(QWidget* parent)
|
||||
: QMainWindow(parent)
|
||||
{
|
||||
session_state_ = new bonsaiviewer::SessionState(this);
|
||||
auto on_mutated = [this]() {
|
||||
setWindowModified(true);
|
||||
updateWindowTitle();
|
||||
};
|
||||
auto on_clean_state = [this]() {
|
||||
setWindowModified(false);
|
||||
updateWindowTitle();
|
||||
};
|
||||
connect(session_state_, &bonsaiviewer::SessionState::federationChanged, this, on_mutated);
|
||||
connect(session_state_, &bonsaiviewer::SessionState::modelsChanged, this, on_mutated);
|
||||
connect(session_state_, &bonsaiviewer::SessionState::projectReset, this, on_clean_state);
|
||||
connect(session_state_, &bonsaiviewer::SessionState::projectOpened, this, on_clean_state);
|
||||
connect(session_state_, &bonsaiviewer::SessionState::projectSaved, this, on_clean_state);
|
||||
|
||||
// Every successful open/save (local or cloud) feeds the recent list.
|
||||
auto remember_recent = [](const QString& path) {
|
||||
modules::project::RecentProjects::add(path);
|
||||
};
|
||||
connect(session_state_, &bonsaiviewer::SessionState::projectOpened, this, remember_recent);
|
||||
connect(session_state_, &bonsaiviewer::SessionState::projectSaved, this, remember_recent);
|
||||
setupChrome();
|
||||
setupViewport();
|
||||
setupPanels();
|
||||
setupStatus();
|
||||
setupLoader();
|
||||
setupRibbon();
|
||||
resize(1720, 980);
|
||||
}
|
||||
|
||||
void MainWindow::setupChrome() {
|
||||
setObjectName("appWindow");
|
||||
setDockOptions(QMainWindow::AllowNestedDocks |
|
||||
QMainWindow::AllowTabbedDocks |
|
||||
QMainWindow::GroupedDragging);
|
||||
|
||||
auto bind_shortcut = [this](const QKeySequence& sequence, auto fn) {
|
||||
auto* shortcut = new QShortcut(sequence, this);
|
||||
shortcut->setContext(Qt::WindowShortcut);
|
||||
connect(shortcut, &QShortcut::activated, this, fn);
|
||||
};
|
||||
bind_shortcut(QKeySequence("Ctrl+Shift+L"), [this]() {
|
||||
modules::viewport::commands::toggleDistance(*viewport_widget_->viewport());
|
||||
});
|
||||
bind_shortcut(QKeySequence("Ctrl+Shift+A"), [this]() {
|
||||
modules::viewport::commands::toggleArea(*viewport_widget_->viewport());
|
||||
});
|
||||
bind_shortcut(QKeySequence("Ctrl+Shift+V"), [this]() {
|
||||
modules::viewport::commands::toggleVolume(*viewport_widget_->viewport());
|
||||
});
|
||||
bind_shortcut(QKeySequence(Qt::SHIFT | Qt::Key_F), [this]() {
|
||||
modules::viewport::commands::fly(*session_state_, *viewport_widget_->viewport());
|
||||
});
|
||||
bind_shortcut(QKeySequence(Qt::Key_K), [this]() {
|
||||
modules::viewport::commands::toggleSection(*session_state_, *viewport_widget_->viewport());
|
||||
});
|
||||
bind_shortcut(QKeySequence(Qt::SHIFT | Qt::Key_K), [this]() {
|
||||
modules::viewport::commands::clearSection(*session_state_, *viewport_widget_->viewport());
|
||||
});
|
||||
bind_shortcut(QKeySequence(Qt::Key_H), [this]() {
|
||||
modules::viewport::commands::hideSelected(*viewport_widget_->viewport());
|
||||
});
|
||||
bind_shortcut(QKeySequence(Qt::SHIFT | Qt::Key_H), [this]() {
|
||||
modules::viewport::commands::isolateSelected(*viewport_widget_->viewport());
|
||||
});
|
||||
bind_shortcut(QKeySequence(Qt::ALT | Qt::Key_H), [this]() {
|
||||
modules::viewport::commands::showAll(*viewport_widget_->viewport());
|
||||
});
|
||||
updateWindowTitle();
|
||||
}
|
||||
|
||||
QToolButton* MainWindow::makePanelToggle(const QString& text, QDockWidget* dock) {
|
||||
auto* button = components::buttons::makeButton(text, ":/icons/sidebar-expand.svg", this);
|
||||
button->setCheckable(true);
|
||||
button->setChecked(dock->isVisible());
|
||||
connect(button, &QToolButton::toggled, dock, [dock](bool checked) {
|
||||
dock->setVisible(checked);
|
||||
if (checked) dock->raise();
|
||||
});
|
||||
connect(dock, &QDockWidget::visibilityChanged, button, [button](bool visible) {
|
||||
const QSignalBlocker blocker(button);
|
||||
button->setChecked(visible);
|
||||
});
|
||||
return button;
|
||||
}
|
||||
|
||||
void MainWindow::populateRecentMenu(QMenu* menu) {
|
||||
menu->clear();
|
||||
|
||||
const QStringList recent = modules::project::RecentProjects::list();
|
||||
if (recent.isEmpty()) {
|
||||
QAction* empty = menu->addAction("No Recent Projects");
|
||||
empty->setEnabled(false);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const QString& path : recent) {
|
||||
QAction* action = menu->addAction(QFileInfo(path).fileName());
|
||||
action->setToolTip(path);
|
||||
connect(action, &QAction::triggered, this, [this, path]() {
|
||||
const bool opened = modules::project::commands::openProjectPath(
|
||||
*session_state_, *this, *viewport_widget_->viewport(), path);
|
||||
// A recent entry that no longer loads is dropped so it stops
|
||||
// cluttering the menu (e.g. moved/deleted file).
|
||||
if (!opened && !QFileInfo::exists(path)) {
|
||||
modules::project::RecentProjects::remove(path);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
menu->addSeparator();
|
||||
QAction* clear = menu->addAction("Clear Recent Projects");
|
||||
connect(clear, &QAction::triggered, this, []() {
|
||||
modules::project::RecentProjects::clear();
|
||||
});
|
||||
}
|
||||
|
||||
QWidget* MainWindow::buildHomeRibbonPage() {
|
||||
auto* page = new QFrame(this);
|
||||
page->setObjectName("ribbonPage");
|
||||
auto* row = new QHBoxLayout(page);
|
||||
row->setContentsMargins(6, 4, 6, 4);
|
||||
row->setSpacing(0);
|
||||
|
||||
auto* new_project = components::buttons::makeButton("New Project", ":/icons/plus-square.svg", this);
|
||||
connect(new_project, &QToolButton::clicked, this, [this]() {
|
||||
modules::project::commands::newProject(
|
||||
*session_state_, *this, *viewport_widget_->viewport());
|
||||
});
|
||||
auto* open_project = components::buttons::makeButton("Open Project", ":/icons/download-square.svg", this);
|
||||
connect(open_project, &QToolButton::clicked, this, [this]() {
|
||||
modules::project::commands::openProject(
|
||||
*session_state_, *this, *viewport_widget_->viewport());
|
||||
});
|
||||
auto* open_cloud = components::buttons::makeButton("Open Cloud", ":/icons/cloud-square.svg", this);
|
||||
connect(open_cloud, &QToolButton::clicked, this, [this]() {
|
||||
modules::project::commands::openCloudProject(
|
||||
*session_state_, *this, *viewport_widget_->viewport());
|
||||
});
|
||||
auto* open_recent = components::buttons::makeButton("Open Recent", ":/icons/clock-rotate-right.svg", this);
|
||||
auto* recent_menu = new QMenu(open_recent);
|
||||
recent_menu->setToolTipsVisible(true);
|
||||
open_recent->setMenu(recent_menu);
|
||||
open_recent->setPopupMode(QToolButton::InstantPopup);
|
||||
connect(recent_menu, &QMenu::aboutToShow, this, [this, recent_menu]() {
|
||||
populateRecentMenu(recent_menu);
|
||||
});
|
||||
auto* save_project = components::buttons::makeButton("Save Project", ":/icons/floppy-disk.svg", this);
|
||||
connect(save_project, &QToolButton::clicked, this, [this]() {
|
||||
modules::project::commands::saveProjectDialog(*session_state_, *this);
|
||||
});
|
||||
|
||||
auto* add_model = components::buttons::makeButton("Add Model", ":/icons/cube.svg", this);
|
||||
connect(add_model, &QToolButton::clicked, this, [this]() {
|
||||
modules::models::commands::addModel(*session_state_, *this);
|
||||
});
|
||||
auto* sync_from_cloud = components::buttons::makeButton("Sync From Cloud", ":/icons/refresh-double.svg", this);
|
||||
connect(sync_from_cloud, &QToolButton::clicked, this, [this]() {
|
||||
modules::project::commands::syncCloudProject(
|
||||
*session_state_, *this, *viewport_widget_->viewport());
|
||||
});
|
||||
|
||||
auto* settings_button = components::buttons::makeButton("Settings", ":/icons/settings.svg", this);
|
||||
connect(settings_button, &QToolButton::clicked, this, [this]() {
|
||||
modules::settings::SettingsDialog dialog(session_state_, this);
|
||||
dialog.exec();
|
||||
});
|
||||
|
||||
components::buttons::addButtonGroups(row, {
|
||||
components::buttons::makeButtonGroup("PROJECT", {new_project, open_project, open_cloud, open_recent, save_project}, this),
|
||||
components::buttons::makeButtonGroup("MODELS", {add_model, sync_from_cloud}, this),
|
||||
components::buttons::makeButtonGroup("SETTINGS", {settings_button}, this),
|
||||
});
|
||||
row->addStretch(1);
|
||||
return page;
|
||||
}
|
||||
|
||||
QWidget* MainWindow::buildNavigateRibbonPage() {
|
||||
auto* page = new QFrame(this);
|
||||
page->setObjectName("ribbonPage");
|
||||
auto* row = new QHBoxLayout(page);
|
||||
row->setContentsMargins(2, 4, 2, 4);
|
||||
row->setSpacing(0);
|
||||
|
||||
auto* set_home = components::buttons::makeButton("Set Home", ":/icons/home.svg", this);
|
||||
connect(set_home, &QToolButton::clicked, this, [this]() {
|
||||
modules::viewport::commands::setHome(*session_state_, *viewport_widget_->viewport());
|
||||
});
|
||||
auto* go_home = components::buttons::makeButton("Go Home", ":/icons/home-alt.svg", this);
|
||||
connect(go_home, &QToolButton::clicked, this, [this]() {
|
||||
modules::viewport::commands::goHome(*session_state_, *viewport_widget_->viewport());
|
||||
});
|
||||
auto* view_all = components::buttons::makeButton("View All", ":/icons/cube-scan.svg", this);
|
||||
connect(view_all, &QToolButton::clicked, this, [this]() {
|
||||
if (viewport_widget_) viewport_widget_->viewport()->viewAll();
|
||||
});
|
||||
auto* view_selected = components::buttons::makeButton("View Selected", ":/icons/cube-scan-solid.svg", this);
|
||||
connect(view_selected, &QToolButton::clicked, this, [this]() {
|
||||
modules::viewport::commands::viewSelected(*viewport_widget_->viewport());
|
||||
});
|
||||
|
||||
auto* plan_view = components::buttons::makeButton("Plan", ":/icons/planimetry.svg", this);
|
||||
connect(plan_view, &QToolButton::clicked, this, [this]() {
|
||||
if (viewport_widget_) viewport_widget_->viewport()->setStandardView(90.0f, 90.0f);
|
||||
});
|
||||
auto* front_view = components::buttons::makeButton("Front", ":/icons/city.svg", this);
|
||||
connect(front_view, &QToolButton::clicked, this, [this]() {
|
||||
if (viewport_widget_) viewport_widget_->viewport()->setStandardView(0.0f, 0.0f);
|
||||
});
|
||||
auto* side_view = components::buttons::makeButton("Side", ":/icons/building.svg", this);
|
||||
connect(side_view, &QToolButton::clicked, this, [this]() {
|
||||
if (viewport_widget_) viewport_widget_->viewport()->setStandardView(90.0f, 0.0f);
|
||||
});
|
||||
auto* align_object = components::buttons::makeButton("Align Object", ":/icons/cellar.svg", this);
|
||||
connect(align_object, &QToolButton::clicked, this, [this]() {
|
||||
session_state_->setStatusMessage("Orientation", "Align to object coming soon");
|
||||
});
|
||||
auto* projection_button = components::buttons::makeButton("Perspective", ":/icons/perspective-view.svg", this);
|
||||
connect(projection_button, &QToolButton::clicked, this, [this, projection_button]() {
|
||||
if (!viewport_widget_) return;
|
||||
viewport_widget_->viewport()->toggleProjection();
|
||||
projection_button->setText(
|
||||
viewport_widget_->viewport()->projectionOrtho() ? "Ortho" : "Perspective");
|
||||
});
|
||||
|
||||
auto* fly_mode = components::buttons::makeButton("Fly", ":/icons/drone.svg", this);
|
||||
connect(fly_mode, &QToolButton::clicked, this, [this]() {
|
||||
modules::viewport::commands::fly(*session_state_, *viewport_widget_->viewport());
|
||||
});
|
||||
auto* section_mode = components::buttons::makeButton("Section", ":/icons/cube-cut-with-curve.svg", this);
|
||||
connect(section_mode, &QToolButton::clicked, this, [this]() {
|
||||
modules::viewport::commands::toggleSection(*session_state_, *viewport_widget_->viewport());
|
||||
});
|
||||
|
||||
components::buttons::addButtonGroups(row, {
|
||||
components::buttons::makeButtonGroup("CAMERA", {set_home, go_home, view_all, view_selected}, this),
|
||||
components::buttons::makeButtonGroup("ORIENTATION", {plan_view, front_view, side_view, align_object, projection_button}, this),
|
||||
components::buttons::makeButtonGroup("MODE", {fly_mode, section_mode}, this),
|
||||
});
|
||||
row->addStretch(1);
|
||||
return page;
|
||||
}
|
||||
|
||||
QWidget* MainWindow::buildInspectRibbonPage() {
|
||||
auto* page = new QFrame(this);
|
||||
page->setObjectName("ribbonPage");
|
||||
auto* row = new QHBoxLayout(page);
|
||||
row->setContentsMargins(2, 4, 2, 4);
|
||||
row->setSpacing(0);
|
||||
|
||||
auto* hide_selected = components::buttons::makeButton("Hide", ":/icons/eye-closed.svg", this);
|
||||
connect(hide_selected, &QToolButton::clicked, this, [this]() {
|
||||
modules::viewport::commands::hideSelected(*viewport_widget_->viewport());
|
||||
});
|
||||
auto* isolate_selected = components::buttons::makeButton("Isolate", ":/icons/eye-solid.svg", this);
|
||||
connect(isolate_selected, &QToolButton::clicked, this, [this]() {
|
||||
modules::viewport::commands::isolateSelected(*viewport_widget_->viewport());
|
||||
});
|
||||
auto* show_all = components::buttons::makeButton("Show All", ":/icons/eye.svg", this);
|
||||
connect(show_all, &QToolButton::clicked, this, [this]() {
|
||||
modules::viewport::commands::showAll(*viewport_widget_->viewport());
|
||||
});
|
||||
auto* invert_selection = components::buttons::makeButton("Invert", ":/icons/intersect.svg", this);
|
||||
connect(invert_selection, &QToolButton::clicked, this, [this]() {
|
||||
modules::viewport::commands::invertVisibility(*viewport_widget_->viewport());
|
||||
});
|
||||
|
||||
auto* distance = components::buttons::makeButton("Distance", ":/icons/select-edge3d.svg", this);
|
||||
connect(distance, &QToolButton::clicked, this, [this]() {
|
||||
modules::viewport::commands::toggleDistance(*viewport_widget_->viewport());
|
||||
});
|
||||
auto* area = components::buttons::makeButton("Area", ":/icons/select-face3d.svg", this);
|
||||
connect(area, &QToolButton::clicked, this, [this]() {
|
||||
modules::viewport::commands::toggleArea(*viewport_widget_->viewport());
|
||||
});
|
||||
auto* volume = components::buttons::makeButton("Volume", ":/icons/select-point3d.svg", this);
|
||||
connect(volume, &QToolButton::clicked, this, [this]() {
|
||||
modules::viewport::commands::toggleVolume(*viewport_widget_->viewport());
|
||||
});
|
||||
|
||||
components::buttons::addButtonGroups(row, {
|
||||
components::buttons::makeButtonGroup("SELECTION", {hide_selected, isolate_selected, show_all, invert_selection}, this),
|
||||
components::buttons::makeButtonGroup("MEASURE", {distance, area, volume}, this),
|
||||
});
|
||||
row->addStretch(1);
|
||||
return page;
|
||||
}
|
||||
|
||||
QWidget* MainWindow::buildPanelsRibbonPage() {
|
||||
auto* page = new QFrame(this);
|
||||
page->setObjectName("ribbonPage");
|
||||
auto* row = new QHBoxLayout(page);
|
||||
row->setContentsMargins(2, 4, 2, 4);
|
||||
row->setSpacing(0);
|
||||
|
||||
components::buttons::addButtonGroups(row, {
|
||||
components::buttons::makeButtonGroup("DATA", {
|
||||
makePanelToggle("Models", models_panel_),
|
||||
makePanelToggle("Spatial", spatial_panel_),
|
||||
makePanelToggle("Layers", layers_panel_),
|
||||
makePanelToggle("Properties", properties_panel_)
|
||||
}, this),
|
||||
components::buttons::makeButtonGroup("QUERY", {
|
||||
makePanelToggle("Views", stored_views_panel_),
|
||||
makePanelToggle("Search", search_panel_),
|
||||
makePanelToggle("Sheets", spreadsheet_panel_),
|
||||
makePanelToggle("Audit", audit_panel_)
|
||||
}, this),
|
||||
components::buttons::makeButtonGroup("COLLABORATE", {
|
||||
makePanelToggle("Clash", clash_panel_),
|
||||
makePanelToggle("Issues", issues_panel_)
|
||||
}, this),
|
||||
});
|
||||
row->addStretch(1);
|
||||
return page;
|
||||
}
|
||||
|
||||
void MainWindow::setupRibbon() {
|
||||
auto* shell = new QFrame(this);
|
||||
|
||||
auto* shell_layout = new QVBoxLayout(shell);
|
||||
shell_layout->setContentsMargins(0, 0, 0, 0);
|
||||
shell_layout->setSpacing(0);
|
||||
|
||||
ribbon_tabs_ = new components::TabBar(shell);
|
||||
ribbon_tabs_->addTab("Home");
|
||||
ribbon_tabs_->addTab("Navigate");
|
||||
ribbon_tabs_->addTab("Inspect");
|
||||
ribbon_tabs_->addTab("Panels");
|
||||
ribbon_tabs_->setCurrentIndex(0);
|
||||
|
||||
auto* ribbon_band = new QFrame(shell);
|
||||
ribbon_band->setObjectName("ribbonBand");
|
||||
auto* band_layout = new QVBoxLayout(ribbon_band);
|
||||
band_layout->setContentsMargins(0, 0, 0, 0);
|
||||
band_layout->setSpacing(0);
|
||||
|
||||
ribbon_pages_ = new QStackedWidget(ribbon_band);
|
||||
ribbon_pages_->addWidget(buildHomeRibbonPage());
|
||||
ribbon_pages_->addWidget(buildNavigateRibbonPage());
|
||||
ribbon_pages_->addWidget(buildInspectRibbonPage());
|
||||
ribbon_pages_->addWidget(buildPanelsRibbonPage());
|
||||
|
||||
band_layout->addWidget(ribbon_pages_);
|
||||
shell_layout->addWidget(ribbon_tabs_);
|
||||
shell_layout->addWidget(ribbon_band);
|
||||
|
||||
connect(ribbon_tabs_, &components::TabBar::currentChanged,
|
||||
ribbon_pages_, &QStackedWidget::setCurrentIndex);
|
||||
|
||||
setMenuWidget(shell);
|
||||
}
|
||||
|
||||
void MainWindow::setupViewport() {
|
||||
viewport_widget_ = new modules::viewport::ViewportPanel(this);
|
||||
setCentralWidget(viewport_widget_);
|
||||
}
|
||||
|
||||
void MainWindow::setupPanels() {
|
||||
models_panel_ = new modules::models::ModelsPanel(
|
||||
session_state_, viewport_widget_->viewport(), this);
|
||||
spatial_panel_ = new modules::spatial_hierarchy::SpatialHierarchyPanel(this);
|
||||
properties_panel_ = new modules::properties::PropertiesPanel(this);
|
||||
|
||||
models_view_ = new modules::models::ModelsPanelView(models_panel_, session_state_, this);
|
||||
spatial_view_ = new modules::spatial_hierarchy::SpatialHierarchyPanelView(spatial_panel_, session_state_, this);
|
||||
properties_view_ = new modules::properties::PropertiesPanelView(properties_panel_, session_state_, this);
|
||||
|
||||
layers_panel_ = new components::Panel("Layers", new modules::todo::TodoPanel("Layers", this), this);
|
||||
stored_views_panel_ = new components::Panel(
|
||||
"Stored Views", new modules::todo::TodoPanel("Stored Views", this), this);
|
||||
search_panel_ = new components::Panel(
|
||||
"Search and Query", new modules::todo::TodoPanel("Search and Query", this), this);
|
||||
spreadsheet_panel_ = new components::Panel(
|
||||
"Spreadsheet", new modules::todo::TodoPanel("Spreadsheet", this), this);
|
||||
audit_panel_ = new components::Panel("Audit", new modules::todo::TodoPanel("Audit", this), this);
|
||||
clash_panel_ = new components::Panel("Clash", new modules::todo::TodoPanel("Clash", this), this);
|
||||
issues_panel_ = new components::Panel("Issues", new modules::todo::TodoPanel("Issues", this), this);
|
||||
|
||||
addDockWidget(Qt::LeftDockWidgetArea, models_panel_);
|
||||
addDockWidget(Qt::LeftDockWidgetArea, spatial_panel_);
|
||||
splitDockWidget(models_panel_, spatial_panel_, Qt::Vertical);
|
||||
|
||||
addDockWidget(Qt::RightDockWidgetArea, properties_panel_);
|
||||
addDockWidget(Qt::RightDockWidgetArea, layers_panel_);
|
||||
addDockWidget(Qt::RightDockWidgetArea, stored_views_panel_);
|
||||
addDockWidget(Qt::RightDockWidgetArea, search_panel_);
|
||||
addDockWidget(Qt::RightDockWidgetArea, spreadsheet_panel_);
|
||||
addDockWidget(Qt::RightDockWidgetArea, audit_panel_);
|
||||
addDockWidget(Qt::RightDockWidgetArea, clash_panel_);
|
||||
addDockWidget(Qt::RightDockWidgetArea, issues_panel_);
|
||||
|
||||
tabifyDockWidget(properties_panel_, layers_panel_);
|
||||
tabifyDockWidget(layers_panel_, stored_views_panel_);
|
||||
tabifyDockWidget(stored_views_panel_, search_panel_);
|
||||
tabifyDockWidget(search_panel_, spreadsheet_panel_);
|
||||
tabifyDockWidget(spreadsheet_panel_, audit_panel_);
|
||||
tabifyDockWidget(audit_panel_, clash_panel_);
|
||||
tabifyDockWidget(clash_panel_, issues_panel_);
|
||||
properties_panel_->raise();
|
||||
|
||||
layers_panel_->hide();
|
||||
stored_views_panel_->hide();
|
||||
search_panel_->hide();
|
||||
spreadsheet_panel_->hide();
|
||||
audit_panel_->hide();
|
||||
clash_panel_->hide();
|
||||
issues_panel_->hide();
|
||||
|
||||
resizeDocks({models_panel_, properties_panel_}, {290, 330}, Qt::Horizontal);
|
||||
resizeDocks({models_panel_, spatial_panel_}, {280, 240}, Qt::Vertical);
|
||||
}
|
||||
|
||||
void MainWindow::setupStatus() {
|
||||
status_mode_label_ = new QLabel("Ready", this);
|
||||
status_selection_label_ = new QLabel("No selection", this);
|
||||
status_perf_label_ = new QLabel(this);
|
||||
status_progress_bar_ = new QProgressBar(this);
|
||||
status_perf_label_->setVisible(AppSettings::instance().showStats());
|
||||
status_progress_bar_->setMaximumWidth(200);
|
||||
status_progress_bar_->setVisible(false);
|
||||
|
||||
statusBar()->setSizeGripEnabled(false);
|
||||
statusBar()->addWidget(status_mode_label_);
|
||||
statusBar()->addWidget(status_selection_label_, 1);
|
||||
statusBar()->addPermanentWidget(status_perf_label_);
|
||||
statusBar()->addPermanentWidget(status_progress_bar_);
|
||||
|
||||
connect(&AppSettings::instance(), &AppSettings::showStatsChanged, this, [this](bool show) {
|
||||
status_perf_label_->setVisible(show);
|
||||
if (!show) status_perf_label_->clear();
|
||||
});
|
||||
connect(session_state_, &bonsaiviewer::SessionState::statusMessageChanged,
|
||||
this, [this](const QString& mode, const QString& detail) {
|
||||
status_mode_label_->setText(mode);
|
||||
status_selection_label_->setText(detail);
|
||||
});
|
||||
connect(session_state_, &bonsaiviewer::SessionState::progressBegan, this, [this](const QString&) {
|
||||
// Start in indeterminate mode (spinning bar). The first concrete
|
||||
// setProgress(...) call below switches it to a determinate 0-100 bar.
|
||||
status_progress_bar_->setRange(0, 0);
|
||||
status_progress_bar_->setVisible(true);
|
||||
});
|
||||
connect(session_state_, &bonsaiviewer::SessionState::progressChanged, this, [this](int percent) {
|
||||
if (status_progress_bar_->maximum() == 0) status_progress_bar_->setRange(0, 100);
|
||||
status_progress_bar_->setValue(percent);
|
||||
});
|
||||
connect(session_state_, &bonsaiviewer::SessionState::progressEnded, this, [this]() {
|
||||
status_progress_bar_->setVisible(false);
|
||||
});
|
||||
session_state_->setStatusMessage("Ready", "No selection");
|
||||
}
|
||||
|
||||
void MainWindow::setupLoader() {
|
||||
session_state_->createLoader(viewport_widget_->viewport());
|
||||
viewport_view_ = new modules::viewport::ViewportView(
|
||||
session_state_, viewport_widget_->viewport(), this);
|
||||
|
||||
// Load errors surface through SessionState as a session-level signal; the
|
||||
// status text + progress are already cleared there, we only show the modal.
|
||||
connect(session_state_, &bonsaiviewer::SessionState::loadError, this,
|
||||
[this](const QString& message) {
|
||||
QMessageBox::warning(this, "Bonsai Viewer", message);
|
||||
});
|
||||
|
||||
connect(viewport_widget_->viewport(), &ViewportWindow::frameStatsUpdated, this,
|
||||
[this](const ViewportWindow::FrameStats& s) {
|
||||
if (!status_perf_label_->isVisible()) return;
|
||||
status_perf_label_->setText(
|
||||
QString("%1 fps | %2 ms | %3/%4 obj | %5/%6 tri | %7 draws")
|
||||
.arg(s.fps, 0, 'f', 1)
|
||||
.arg(s.frame_time_ms, 0, 'f', 1)
|
||||
.arg(s.visible_objects)
|
||||
.arg(s.total_objects)
|
||||
.arg(s.visible_triangles)
|
||||
.arg(s.total_triangles)
|
||||
.arg(s.gl_draw_calls));
|
||||
});
|
||||
connect(viewport_widget_->viewport(), &ViewportWindow::objectPicked,
|
||||
this, [this](uint32_t object_id) {
|
||||
session_state_->setSelectedObjectId(object_id);
|
||||
session_state_->notifySelectionChanged();
|
||||
});
|
||||
}
|
||||
|
||||
void MainWindow::updateWindowTitle() {
|
||||
auto* federation = session_state_->federation();
|
||||
const QString project_path = federation->filePath();
|
||||
if (project_path.isEmpty() && federation->models().empty()) {
|
||||
setWindowTitle("Bonsai Viewer");
|
||||
} else if (project_path.isEmpty()) {
|
||||
setWindowTitle("untitled[*] - Bonsai Viewer");
|
||||
} else {
|
||||
setWindowTitle(QFileInfo(project_path).fileName() + "[*] - Bonsai Viewer");
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace bonsaiviewer::shell
|
||||
@@ -0,0 +1,96 @@
|
||||
// This file was generated with the assistance of an AI coding tool.
|
||||
/********************************************************************************
|
||||
* *
|
||||
* This file is part of IfcOpenShell. *
|
||||
* *
|
||||
* IfcOpenShell is free software: you can redistribute it and/or modify *
|
||||
* it under the terms of the Lesser GNU General Public License as published by *
|
||||
* the Free Software Foundation, either version 3.0 of the License, or *
|
||||
* (at your option) any later version. *
|
||||
* *
|
||||
* IfcOpenShell is distributed in the hope that it will be useful, *
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||
* Lesser GNU General Public License for more details. *
|
||||
* *
|
||||
* You should have received a copy of the Lesser GNU General Public License *
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||
* *
|
||||
********************************************************************************/
|
||||
|
||||
#ifndef IFCINTERFACE_SHELL_MAINWINDOW_H
|
||||
#define IFCINTERFACE_SHELL_MAINWINDOW_H
|
||||
|
||||
#include <QHash>
|
||||
#include <QMainWindow>
|
||||
#include <QStringList>
|
||||
|
||||
class QLabel;
|
||||
class QDockWidget;
|
||||
class QMenu;
|
||||
class QProgressBar;
|
||||
class QStackedWidget;
|
||||
class QToolButton;
|
||||
namespace bonsaiviewer { class SessionState; }
|
||||
namespace bonsaiviewer::components { class TabBar; }
|
||||
namespace bonsaiviewer::modules::models { class ModelsPanel; }
|
||||
namespace bonsaiviewer::modules::models { class ModelsPanelView; }
|
||||
namespace bonsaiviewer::modules::spatial_hierarchy { class SpatialHierarchyPanel; }
|
||||
namespace bonsaiviewer::modules::spatial_hierarchy { class SpatialHierarchyPanelView; }
|
||||
namespace bonsaiviewer::modules::properties { class PropertiesPanel; }
|
||||
namespace bonsaiviewer::modules::properties { class PropertiesPanelView; }
|
||||
namespace bonsaiviewer::modules::viewport { class ViewportView; }
|
||||
namespace bonsaiviewer::modules::viewport { class ViewportPanel; }
|
||||
|
||||
namespace bonsaiviewer::shell {
|
||||
|
||||
class MainWindow : public QMainWindow {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit MainWindow(QWidget* parent = nullptr);
|
||||
|
||||
private:
|
||||
void setupChrome();
|
||||
void setupRibbon();
|
||||
void setupViewport();
|
||||
void setupPanels();
|
||||
void setupStatus();
|
||||
void setupLoader();
|
||||
QWidget* buildHomeRibbonPage();
|
||||
QWidget* buildNavigateRibbonPage();
|
||||
QWidget* buildInspectRibbonPage();
|
||||
QWidget* buildPanelsRibbonPage();
|
||||
void updateWindowTitle();
|
||||
QToolButton* makePanelToggle(const QString& text, QDockWidget* dock);
|
||||
// Rebuilds `menu` from the persisted recent-projects list. Wired to the
|
||||
// menu's aboutToShow so it always reflects the current MRU state.
|
||||
void populateRecentMenu(QMenu* menu);
|
||||
|
||||
private:
|
||||
QLabel* status_mode_label_ = nullptr;
|
||||
QLabel* status_selection_label_ = nullptr;
|
||||
QLabel* status_perf_label_ = nullptr;
|
||||
QProgressBar* status_progress_bar_ = nullptr;
|
||||
bonsaiviewer::components::TabBar* ribbon_tabs_ = nullptr;
|
||||
QStackedWidget* ribbon_pages_ = nullptr;
|
||||
bonsaiviewer::modules::viewport::ViewportPanel* viewport_widget_ = nullptr;
|
||||
bonsaiviewer::modules::viewport::ViewportView* viewport_view_ = nullptr;
|
||||
bonsaiviewer::SessionState* session_state_ = nullptr;
|
||||
bonsaiviewer::modules::models::ModelsPanel* models_panel_ = nullptr;
|
||||
bonsaiviewer::modules::spatial_hierarchy::SpatialHierarchyPanel* spatial_panel_ = nullptr;
|
||||
QDockWidget* layers_panel_ = nullptr;
|
||||
bonsaiviewer::modules::properties::PropertiesPanel* properties_panel_ = nullptr;
|
||||
QDockWidget* stored_views_panel_ = nullptr;
|
||||
QDockWidget* search_panel_ = nullptr;
|
||||
QDockWidget* spreadsheet_panel_ = nullptr;
|
||||
QDockWidget* audit_panel_ = nullptr;
|
||||
QDockWidget* clash_panel_ = nullptr;
|
||||
QDockWidget* issues_panel_ = nullptr;
|
||||
bonsaiviewer::modules::models::ModelsPanelView* models_view_ = nullptr;
|
||||
bonsaiviewer::modules::spatial_hierarchy::SpatialHierarchyPanelView* spatial_view_ = nullptr;
|
||||
bonsaiviewer::modules::properties::PropertiesPanelView* properties_view_ = nullptr;
|
||||
};
|
||||
|
||||
} // namespace bonsaiviewer::shell
|
||||
|
||||
#endif
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,162 @@
|
||||
// This file was generated with the assistance of an AI coding tool.
|
||||
/********************************************************************************
|
||||
* *
|
||||
* This file is part of IfcOpenShell. *
|
||||
* *
|
||||
* IfcOpenShell is free software: you can redistribute it and/or modify *
|
||||
* it under the terms of the Lesser GNU General Public License as published by *
|
||||
* the Free Software Foundation, either version 3.0 of the License, or *
|
||||
* (at your option) any later version. *
|
||||
* *
|
||||
* IfcOpenShell is distributed in the hope that it will be useful, *
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||
* Lesser GNU General Public License for more details. *
|
||||
* *
|
||||
* You should have received a copy of the Lesser GNU General Public License *
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||
* *
|
||||
********************************************************************************/
|
||||
|
||||
#ifndef IFCINTERFACE_MEASUREMENT_H
|
||||
#define IFCINTERFACE_MEASUREMENT_H
|
||||
|
||||
#include <cstddef>
|
||||
#include "ViewportWindow.h"
|
||||
#include <QString>
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
// Sum of mesh-local volumes (m³) of every instance whose object_id is in
|
||||
// `object_ids`. Groups by (model, mesh) so each unique mesh is read back
|
||||
// from the GPU at most once per call; instances of the same mesh are scaled
|
||||
// by |det(placement_3x3)| to pick up mapped-item scale/mirror. Volume is
|
||||
// taken as the absolute value of the signed-tetrahedra sum, so winding
|
||||
// convention does not matter. Returns 0.0 for empty input or when nothing
|
||||
// resolves. Recomputes from scratch on every call — no cache.
|
||||
double volumeOfObjects(ViewportWindow& vp,
|
||||
const std::vector<uint32_t>& object_ids);
|
||||
|
||||
// Per-object volumes (m³). Same algorithm as volumeOfObjects but
|
||||
// attributed per id rather than summed. Skips ids that don't resolve
|
||||
// to a live instance, so the result may be shorter than the input.
|
||||
// Used by MainWindow's volume readout to drive both the total HUD and
|
||||
// the per-object overlay labels.
|
||||
std::vector<std::pair<uint32_t, double>>
|
||||
volumesPerObject(ViewportWindow& vp,
|
||||
const std::vector<uint32_t>& object_ids);
|
||||
|
||||
// Click-to-accumulate area measurement. Each pick resolves the screen
|
||||
// click to a (instance, triangle) using ViewportWindow's primitives,
|
||||
// expands it into the connected coplanar patch (BFS over shared edges,
|
||||
// dot(normal, seed_normal) > 0.9999), then either adds or removes that
|
||||
// patch from the running set depending on whether the seed triangle was
|
||||
// already in. Alt-click skips the BFS expansion (single-triangle).
|
||||
// Picks on different instances (even of the same mesh) are kept as
|
||||
// separate patches and their areas are summed.
|
||||
//
|
||||
// On every pick the world-space triangles of the running set are pushed
|
||||
// to ViewportWindow::setHighlightTriangles for in-viewport shading.
|
||||
// State is cleared on construction, on clear(), and is expected to be
|
||||
// reset by the host (e.g. when the viewport's area tool toggles off).
|
||||
class AreaMeasurement {
|
||||
public:
|
||||
AreaMeasurement();
|
||||
|
||||
// Main entry point: handle one click in area-tool mode. alt = true
|
||||
// suppresses BFS expansion. Logs the per-click delta and running total
|
||||
// via qInfo. Misses are silent.
|
||||
void onPick(ViewportWindow& vp, int x, int y, bool alt);
|
||||
|
||||
// Wipe all accumulated triangles, per-mesh adjacency caches, and the
|
||||
// viewport overlay.
|
||||
void clear(ViewportWindow& vp);
|
||||
|
||||
double totalArea() const { return total_area_m2_; }
|
||||
size_t triangleCount() const { return selected_.size(); }
|
||||
|
||||
private:
|
||||
// Cached per-mesh data: triangles + edge→triangles adjacency. Keyed
|
||||
// by (model_id << 32) | mesh_id. Filled lazily on first pick of that
|
||||
// mesh, dropped on clear().
|
||||
struct MeshCache {
|
||||
std::vector<float> positions; // 3 * N_verts
|
||||
std::vector<uint32_t> indices; // 3 * N_tris
|
||||
std::vector<float> tri_normals; // 3 * N_tris (unit, mesh-local)
|
||||
std::vector<float> tri_areas; // N_tris
|
||||
// edge_key (min<<32 | max) → list of triangle indices touching it.
|
||||
std::unordered_map<uint64_t, std::vector<uint32_t>> edges;
|
||||
};
|
||||
MeshCache* meshCache(ViewportWindow& vp, uint32_t model_id, uint32_t mesh_id);
|
||||
|
||||
// Per-selected-triangle record. The composed transform is captured at
|
||||
// pick time so the overlay rebuild doesn't have to re-query the
|
||||
// viewport for it (and so the overlay keeps working if the picked
|
||||
// instance later goes hidden).
|
||||
struct SelectedTri {
|
||||
uint32_t model_id;
|
||||
uint32_t mesh_id;
|
||||
uint32_t tri;
|
||||
float composed_transform[16];
|
||||
};
|
||||
|
||||
// Selection key: object_id (high 32) | tri index (low 32). Packing
|
||||
// by object_id rather than mesh_id means two distinct instances of
|
||||
// the same mesh contribute independently, as the user spec'd.
|
||||
static uint64_t triKey(uint32_t object_id, uint32_t tri) {
|
||||
return (uint64_t(object_id) << 32) | uint64_t(tri);
|
||||
}
|
||||
|
||||
void rebuildHighlight(ViewportWindow& vp);
|
||||
|
||||
std::unordered_map<uint64_t, MeshCache> mesh_cache_;
|
||||
std::unordered_map<uint64_t, SelectedTri> selected_;
|
||||
double total_area_m2_ = 0.0;
|
||||
};
|
||||
|
||||
// Click-to-place length / angle / area measurement. Each pick appends a
|
||||
// world-space point. The readout adapts to the point count:
|
||||
//
|
||||
// 1 point → "laser-measure" mode: 6 rays (±surface-normal, ±tangent₁,
|
||||
// ±tangent₂ in the surface's own basis) trace into the scene.
|
||||
// On a wall this gives thickness + floor-to-ceiling height +
|
||||
// length-along-wall in one click. Tangent₁ is world up
|
||||
// projected onto the surface plane (Gram-Schmidt against the
|
||||
// normal); tangent₂ = normal × tangent₁.
|
||||
// 2 points → straight-line distance plus axis-aligned ΔX/ΔY/ΔZ
|
||||
// 3 points → angle at the middle vertex plus the triangle's area
|
||||
// 4+ → polygon area: best-fit-plane shoelace if the points are
|
||||
// near-coplanar (RMS plane distance < 1e-3 of the bounding
|
||||
// box), else fan-triangulated from the first point
|
||||
//
|
||||
// Clicked points are pushed to the viewport overlay as small dots and
|
||||
// the connecting polyline (or the laser rays for 1-point); readouts
|
||||
// go to the multi-line HUD.
|
||||
class LengthMeasurement {
|
||||
public:
|
||||
LengthMeasurement();
|
||||
|
||||
void onPick(ViewportWindow& vp, int x, int y, bool alt);
|
||||
void removeLastPoint(ViewportWindow& vp);
|
||||
void clear(ViewportWindow& vp);
|
||||
|
||||
size_t pointCount() const { return points_.size(); }
|
||||
|
||||
private:
|
||||
void rebuildOverlay(ViewportWindow& vp);
|
||||
void rebuildLaserOverlay(ViewportWindow& vp);
|
||||
QString formatReadout() const;
|
||||
|
||||
std::vector<std::array<float, 3>> points_;
|
||||
std::vector<std::array<float, 3>> normals_; // surface normal at each pick
|
||||
|
||||
// Captured at the very first pick of a fresh sequence and never
|
||||
// updated afterwards. Used by the 1-pt laser BFS to re-locate the
|
||||
// mesh-local position of points_[0] without re-picking. Stays valid
|
||||
// while points_[0] does (pop_back never touches the first element).
|
||||
ViewportWindow::MeshLocalPick first_pick_{};
|
||||
};
|
||||
|
||||
#endif // IFCINTERFACE_MEASUREMENT_H
|
||||
@@ -0,0 +1,186 @@
|
||||
// This file was generated with the assistance of an AI coding tool.
|
||||
/********************************************************************************
|
||||
* *
|
||||
* This file is part of IfcOpenShell. *
|
||||
* *
|
||||
* IfcOpenShell is free software: you can redistribute it and/or modify *
|
||||
* it under the terms of the Lesser GNU General Public License as published by *
|
||||
* the Free Software Foundation, either version 3.0 of the License, or *
|
||||
* (at your option) any later version. *
|
||||
* *
|
||||
* IfcOpenShell is distributed in the hope that it will be useful, *
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||
* Lesser GNU General Public License for more details. *
|
||||
* *
|
||||
* You should have received a copy of the Lesser GNU General Public License *
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||
* *
|
||||
********************************************************************************/
|
||||
|
||||
#include "SessionState.h"
|
||||
|
||||
#include "ElementRegistry.h"
|
||||
#include "modules/connectors/Registry.h"
|
||||
#include "../ifcviewer/Federation.h"
|
||||
#include "../ifcviewer/SceneLoader.h"
|
||||
|
||||
namespace bonsaiviewer {
|
||||
|
||||
SessionState::SessionState(QObject* parent)
|
||||
: QObject(parent)
|
||||
, federation_(new Federation(this))
|
||||
, element_registry_(new ElementRegistry(this))
|
||||
, connector_registry_(new modules::connectors::ConnectorRegistry(this))
|
||||
{
|
||||
}
|
||||
|
||||
void SessionState::createLoader(ViewportWindow* viewport) {
|
||||
Q_ASSERT(!loader_);
|
||||
loader_ = new SceneLoader(viewport, this);
|
||||
loader_->setShouldReadSidecar(true);
|
||||
loader_->setShouldWriteSidecar(true);
|
||||
element_registry_->bindLoader(loader_);
|
||||
|
||||
auto format_elapsed = [](qint64 ms) {
|
||||
return (ms >= 1000)
|
||||
? QString::number(ms / 1000.0, 'f', 2) + " s"
|
||||
: QString::number(ms) + " ms";
|
||||
};
|
||||
|
||||
// Translate low-level loader events into session-level signals, status
|
||||
// text, and progress so views don't need to subscribe to the loader.
|
||||
connect(loader_, &SceneLoader::loadStarted, this,
|
||||
[this](uint32_t, const QString& display_name) {
|
||||
setStatusMessage("Loading", display_name);
|
||||
beginProgress(display_name);
|
||||
});
|
||||
connect(loader_, &SceneLoader::progressChanged, this, &SessionState::setProgress);
|
||||
connect(loader_, &SceneLoader::loadedFromSidecar, this,
|
||||
[this, format_elapsed](uint32_t mid, qint64 elapsed_ms) {
|
||||
setStatusMessage("Loaded",
|
||||
QString("%1 from cache in %2")
|
||||
.arg(loader_->displayName(mid))
|
||||
.arg(format_elapsed(elapsed_ms)));
|
||||
endProgress();
|
||||
emit modelGeometryReady(mid);
|
||||
});
|
||||
connect(loader_, &SceneLoader::loadedFromStream, this,
|
||||
[this, format_elapsed](uint32_t mid, qint64 elapsed_ms) {
|
||||
setStatusMessage("Loaded",
|
||||
QString("%1 streamed in %2")
|
||||
.arg(loader_->displayName(mid))
|
||||
.arg(format_elapsed(elapsed_ms)));
|
||||
endProgress();
|
||||
emit modelGeometryReady(mid);
|
||||
});
|
||||
connect(loader_, &SceneLoader::loadCancelled, this, [this](uint32_t mid) {
|
||||
setStatusMessage("Cancelled", loader_->displayName(mid));
|
||||
endProgress();
|
||||
});
|
||||
connect(loader_, &SceneLoader::loadError, this,
|
||||
[this](uint32_t, const QString& message) {
|
||||
setStatusMessage("Error", message);
|
||||
endProgress();
|
||||
emit loadError(message);
|
||||
});
|
||||
connect(loader_, &SceneLoader::allLoadsFinished, this, [this]() {
|
||||
setStatusMessage("Loaded", QString("%1 model(s)").arg(loader_->modelCount()));
|
||||
});
|
||||
}
|
||||
|
||||
void SessionState::setSelectedObjectId(uint32_t object_id) {
|
||||
selected_object_id_ = object_id;
|
||||
}
|
||||
|
||||
void SessionState::setStatusMessage(const QString& mode, const QString& detail) {
|
||||
status_mode_ = mode;
|
||||
status_detail_ = detail;
|
||||
emit statusMessageChanged(status_mode_, status_detail_);
|
||||
}
|
||||
|
||||
void SessionState::beginProgress(const QString& label) {
|
||||
emit progressBegan(label);
|
||||
}
|
||||
|
||||
void SessionState::setProgress(int percent) {
|
||||
emit progressChanged(percent);
|
||||
}
|
||||
|
||||
void SessionState::endProgress() {
|
||||
emit progressEnded();
|
||||
}
|
||||
|
||||
void SessionState::setModelMapping(const QString& fed_id, uint32_t model_id) {
|
||||
fed_id_to_model_id_[fed_id] = model_id;
|
||||
model_id_to_fed_id_[model_id] = fed_id;
|
||||
}
|
||||
|
||||
void SessionState::removeModelMappingByFedId(const QString& fed_id) {
|
||||
cloud_metadata_.remove(fed_id);
|
||||
auto it = fed_id_to_model_id_.find(fed_id);
|
||||
if (it == fed_id_to_model_id_.end()) return;
|
||||
model_id_to_fed_id_.remove(it.value());
|
||||
fed_id_to_model_id_.erase(it);
|
||||
}
|
||||
|
||||
void SessionState::clearModelMappings() {
|
||||
fed_id_to_model_id_.clear();
|
||||
model_id_to_fed_id_.clear();
|
||||
cloud_metadata_.clear();
|
||||
}
|
||||
|
||||
void SessionState::setCloudMetadata(const QString& fed_id, const QVariantMap& metadata) {
|
||||
if (metadata.isEmpty()) cloud_metadata_.remove(fed_id);
|
||||
else cloud_metadata_.insert(fed_id, metadata);
|
||||
}
|
||||
|
||||
QVariantMap SessionState::cloudMetadata(const QString& fed_id) const {
|
||||
return cloud_metadata_.value(fed_id);
|
||||
}
|
||||
|
||||
uint32_t SessionState::modelIdForFedId(const QString& fed_id) const {
|
||||
return fed_id_to_model_id_.value(fed_id, 0);
|
||||
}
|
||||
|
||||
QString SessionState::fedIdForModelId(uint32_t model_id) const {
|
||||
return model_id_to_fed_id_.value(model_id);
|
||||
}
|
||||
|
||||
QList<uint32_t> SessionState::modelIds() const {
|
||||
return model_id_to_fed_id_.keys();
|
||||
}
|
||||
|
||||
void SessionState::notifySelectionChanged() {
|
||||
emit selectionChanged(selected_object_id_);
|
||||
}
|
||||
|
||||
void SessionState::notifyModelsChanged() {
|
||||
emit modelsChanged();
|
||||
}
|
||||
|
||||
void SessionState::notifyFederationChanged() {
|
||||
emit federationChanged();
|
||||
}
|
||||
|
||||
void SessionState::notifyVisibilityChanged() {
|
||||
emit visibilityChanged();
|
||||
}
|
||||
|
||||
void SessionState::notifyModelGeometryReady(uint32_t model_id) {
|
||||
emit modelGeometryReady(model_id);
|
||||
}
|
||||
|
||||
void SessionState::notifyProjectOpened(const QString& path) {
|
||||
emit projectOpened(path);
|
||||
}
|
||||
|
||||
void SessionState::notifyProjectSaved(const QString& path) {
|
||||
emit projectSaved(path);
|
||||
}
|
||||
|
||||
void SessionState::notifyProjectReset() {
|
||||
emit projectReset();
|
||||
}
|
||||
|
||||
} // namespace bonsaiviewer
|
||||
@@ -0,0 +1,129 @@
|
||||
// This file was generated with the assistance of an AI coding tool.
|
||||
/********************************************************************************
|
||||
* *
|
||||
* This file is part of IfcOpenShell. *
|
||||
* *
|
||||
* IfcOpenShell is free software: you can redistribute it and/or modify *
|
||||
* it under the terms of the Lesser GNU General Public License as published by *
|
||||
* the Free Software Foundation, either version 3.0 of the License, or *
|
||||
* (at your option) any later version. *
|
||||
* *
|
||||
* IfcOpenShell is distributed in the hope that it will be useful, *
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||
* Lesser GNU General Public License for more details. *
|
||||
* *
|
||||
* You should have received a copy of the Lesser GNU General Public License *
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||
* *
|
||||
********************************************************************************/
|
||||
|
||||
#ifndef IFCINTERFACE_SESSIONSTATE_H
|
||||
#define IFCINTERFACE_SESSIONSTATE_H
|
||||
|
||||
#include <QHash>
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QVariantMap>
|
||||
|
||||
class Federation;
|
||||
class SceneLoader;
|
||||
class ViewportWindow;
|
||||
|
||||
namespace bonsaiviewer {
|
||||
|
||||
class ElementRegistry;
|
||||
|
||||
namespace modules::connectors { class ConnectorRegistry; }
|
||||
|
||||
class SessionState : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit SessionState(QObject* parent = nullptr);
|
||||
|
||||
// Owned by SessionState once it can be tied to a viewport. Wires
|
||||
// loader → element registry signals internally. Call exactly once.
|
||||
void createLoader(ViewportWindow* viewport);
|
||||
|
||||
Federation* federation() const { return federation_; }
|
||||
SceneLoader* loader() const { return loader_; }
|
||||
ElementRegistry* elementRegistry() const { return element_registry_; }
|
||||
modules::connectors::ConnectorRegistry* connectorRegistry() const { return connector_registry_; }
|
||||
QString statusMode() const { return status_mode_; }
|
||||
QString statusDetail() const { return status_detail_; }
|
||||
|
||||
void setSelectedObjectId(uint32_t object_id);
|
||||
uint32_t selectedObjectId() const { return selected_object_id_; }
|
||||
void setStatusMessage(const QString& mode, const QString& detail);
|
||||
|
||||
// Generic progress reporting for any long-running operation (load,
|
||||
// convert, export, ...). Subscribers (the status bar) react to the
|
||||
// signals; they do not need to know which operation is running.
|
||||
void beginProgress(const QString& label);
|
||||
void setProgress(int percent);
|
||||
void endProgress();
|
||||
|
||||
void setModelMapping(const QString& fed_id, uint32_t model_id);
|
||||
void removeModelMappingByFedId(const QString& fed_id);
|
||||
void clearModelMappings();
|
||||
|
||||
// Per-session cloud metadata returned by connectors (revision/date/
|
||||
// author/...). Not persisted to the .ifcfed; display only. Lifetime
|
||||
// is tied to the fed_id — removeModelMappingByFedId and
|
||||
// clearModelMappings drop the matching entries.
|
||||
void setCloudMetadata(const QString& fed_id, const QVariantMap& metadata);
|
||||
QVariantMap cloudMetadata(const QString& fed_id) const;
|
||||
uint32_t modelIdForFedId(const QString& fed_id) const;
|
||||
QString fedIdForModelId(uint32_t model_id) const;
|
||||
QList<uint32_t> modelIds() const;
|
||||
|
||||
void notifySelectionChanged();
|
||||
void notifyModelsChanged();
|
||||
void notifyFederationChanged();
|
||||
void notifyVisibilityChanged();
|
||||
void notifyModelGeometryReady(uint32_t model_id);
|
||||
void notifyProjectOpened(const QString& path);
|
||||
void notifyProjectSaved(const QString& path);
|
||||
void notifyProjectReset();
|
||||
|
||||
signals:
|
||||
void projectOpened(const QString& path);
|
||||
void projectSaved(const QString& path);
|
||||
void projectReset();
|
||||
void modelsChanged();
|
||||
// Fires whenever a command has mutated the federation (groups, transforms,
|
||||
// origin, config). Always implies the project is now dirty; callers do not
|
||||
// emit this on save/load/reset — projectSaved/Opened/Reset cover those.
|
||||
void federationChanged();
|
||||
void visibilityChanged();
|
||||
// Fires when a model's geometry has been pushed to the viewport. Fires
|
||||
// for both sidecar-cache and stream loads; subscribers that just need to
|
||||
// re-derive view state (e.g. ViewportView::refresh) listen to this.
|
||||
void modelGeometryReady(uint32_t model_id);
|
||||
// Fires when SceneLoader reports a load failure. SessionState turns the
|
||||
// raw loader signal into a session-level one so views (e.g. the MessageBox)
|
||||
// can subscribe without touching the loader directly.
|
||||
void loadError(const QString& message);
|
||||
void selectionChanged(uint32_t object_id);
|
||||
void statusMessageChanged(const QString& mode, const QString& detail);
|
||||
void progressBegan(const QString& label);
|
||||
void progressChanged(int percent);
|
||||
void progressEnded();
|
||||
|
||||
private:
|
||||
Federation* federation_ = nullptr;
|
||||
SceneLoader* loader_ = nullptr;
|
||||
ElementRegistry* element_registry_ = nullptr;
|
||||
modules::connectors::ConnectorRegistry* connector_registry_ = nullptr;
|
||||
uint32_t selected_object_id_ = 0;
|
||||
QString status_mode_;
|
||||
QString status_detail_;
|
||||
QHash<QString, uint32_t> fed_id_to_model_id_;
|
||||
QHash<uint32_t, QString> model_id_to_fed_id_;
|
||||
QHash<QString, QVariantMap> cloud_metadata_;
|
||||
};
|
||||
|
||||
} // namespace bonsaiviewer
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,189 @@
|
||||
// This file was generated with the assistance of an AI coding tool.
|
||||
/********************************************************************************
|
||||
* *
|
||||
* This file is part of IfcOpenShell. *
|
||||
* *
|
||||
* IfcOpenShell is free software: you can redistribute it and/or modify *
|
||||
* it under the terms of the Lesser GNU General Public License as published by *
|
||||
* the Free Software Foundation, either version 3.0 of the License, or *
|
||||
* (at your option) any later version. *
|
||||
* *
|
||||
* IfcOpenShell is distributed in the hope that it will be useful, *
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||
* Lesser GNU General Public License for more details. *
|
||||
* *
|
||||
* You should have received a copy of the Lesser GNU General Public License *
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||
* *
|
||||
********************************************************************************/
|
||||
|
||||
#include "ViewerSettings.h"
|
||||
|
||||
#include "components/Style.h"
|
||||
|
||||
#include <QColor>
|
||||
#include <QSettings>
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr const char* kThemeModeKey = "interface/theme/mode";
|
||||
constexpr const char* kThemeColorPrefix = "interface/theme/colors/";
|
||||
|
||||
using Spec = bonsaiviewer::ViewerSettings::ThemeColorSpec;
|
||||
|
||||
const std::vector<Spec> kThemeColorSpecs = {
|
||||
{"app_background", "App Background", bonsaiviewer::components::style::palette::app_background, "#eef1f5"},
|
||||
{"border", "Border", bonsaiviewer::components::style::palette::border, "#c9d1dc"},
|
||||
{"selection_background", "Selection Background", bonsaiviewer::components::style::palette::selection_background,
|
||||
"#2f9e44"},
|
||||
{"tab_bar_background", "Tab Bar Background", bonsaiviewer::components::style::palette::tab_bar_background,
|
||||
"#eef1f5"},
|
||||
{"tab_background", "Tab Background", bonsaiviewer::components::style::palette::tab_background, "#e3e8ef"},
|
||||
{"ribbon_background", "Ribbon Background", bonsaiviewer::components::style::palette::ribbon_background,
|
||||
"#e3e8ef"},
|
||||
{"ribbon_button_hover", "Ribbon Button Hover", bonsaiviewer::components::style::palette::ribbon_button_hover,
|
||||
"#d6dde7"},
|
||||
{"ribbon_button_pressed", "Ribbon Button Pressed", bonsaiviewer::components::style::palette::ribbon_button_pressed,
|
||||
"#cad3df"},
|
||||
{"viewport_shell_background", "Viewport Shell Background",
|
||||
bonsaiviewer::components::style::palette::viewport_shell_background, "#dbe1e8"},
|
||||
{"viewport_background", "Viewport Background", bonsaiviewer::components::style::palette::viewport_background,
|
||||
"#eef2f6"},
|
||||
{"panel_background", "Panel Background", bonsaiviewer::components::style::palette::panel_background, "#ffffff"},
|
||||
{"control_background", "Control Background", bonsaiviewer::components::style::palette::control_background,
|
||||
"#f5f7fa"},
|
||||
{"control_border_focus", "Control Border Focus", bonsaiviewer::components::style::palette::control_border_focus,
|
||||
"#7c8ca3"},
|
||||
{"box_background", "Box Background", bonsaiviewer::components::style::palette::box_background, "#f5f7fa"},
|
||||
{"scroll_handle", "Scroll Handle", bonsaiviewer::components::style::palette::scroll_handle, "#b1bac8"},
|
||||
{"scroll_handle_hover", "Scroll Handle Hover", bonsaiviewer::components::style::palette::scroll_handle_hover,
|
||||
"#929daf"},
|
||||
{"status_background", "Status Background", bonsaiviewer::components::style::palette::status_background,
|
||||
"#edf1f5"},
|
||||
{"section_header_background", "Section Header Background",
|
||||
bonsaiviewer::components::style::palette::section_header_background, "#eef2f6"},
|
||||
{"primary_text", "Primary Text", bonsaiviewer::components::style::palette::primary_text, "#1f2937"},
|
||||
{"secondary_text", "Secondary Text", bonsaiviewer::components::style::palette::secondary_text, "#5b6676"},
|
||||
{"disabled_text", "Disabled Text", bonsaiviewer::components::style::palette::disabled_text, "#8b95a3"},
|
||||
{"warning_text", "Warning Text", bonsaiviewer::components::style::palette::warning_text, "#b26b00"},
|
||||
{"selection_text", "Selection Text", bonsaiviewer::components::style::palette::selection_text, "#ffffff"},
|
||||
{"hover_text", "Hover Text", bonsaiviewer::components::style::palette::hover_text, "#0f172a"},
|
||||
{"icon_color", "Icon Color", "#e7ebf2", "#445066"},
|
||||
{"icon_active_color", "Icon Active Color", "#ffffff", "#101828"},
|
||||
{"icon_disabled_color", "Icon Disabled Color", "#6f7988", "#98a2b3"},
|
||||
{"icon_accent_color", "Accent Icon Color", "#39b54a", "#2f9e44"},
|
||||
{"icon_accent_active_color", "Accent Icon Active Color", "#53c763", "#267e37"},
|
||||
};
|
||||
|
||||
int colorIndexForKey(const QString& key) {
|
||||
for (size_t i = 0; i < kThemeColorSpecs.size(); ++i) {
|
||||
if (QString::fromUtf8(kThemeColorSpecs[i].key) == key) {
|
||||
return static_cast<int>(i);
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
QString normalizedColor(const QString& value, const QString& fallback) {
|
||||
const QString trimmed = value.trimmed();
|
||||
if (!QColor::isValidColorName(trimmed)) return fallback;
|
||||
return QColor(trimmed).name(QColor::HexRgb);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
namespace bonsaiviewer {
|
||||
|
||||
ViewerSettings& ViewerSettings::instance() {
|
||||
static ViewerSettings inst;
|
||||
return inst;
|
||||
}
|
||||
|
||||
const std::vector<ViewerSettings::ThemeColorSpec>& ViewerSettings::themeColorSpecs() {
|
||||
return kThemeColorSpecs;
|
||||
}
|
||||
|
||||
ViewerSettings::ViewerSettings() {
|
||||
custom_colors_.resize(kThemeColorSpecs.size());
|
||||
load();
|
||||
}
|
||||
|
||||
ViewerSettings::ThemeMode ViewerSettings::themeMode() const {
|
||||
return theme_mode_;
|
||||
}
|
||||
|
||||
void ViewerSettings::setThemeMode(ThemeMode mode) {
|
||||
if (theme_mode_ == mode) return;
|
||||
theme_mode_ = mode;
|
||||
persist();
|
||||
emit themeModeChanged(mode);
|
||||
emit themeChanged();
|
||||
}
|
||||
|
||||
QString ViewerSettings::color(const QString& key) const {
|
||||
const int index = colorIndexForKey(key);
|
||||
if (index < 0) return {};
|
||||
|
||||
const auto& spec = kThemeColorSpecs[static_cast<size_t>(index)];
|
||||
switch (theme_mode_) {
|
||||
case ThemeMode::Dark:
|
||||
return QString::fromUtf8(spec.dark_default);
|
||||
case ThemeMode::Light:
|
||||
return QString::fromUtf8(spec.light_default);
|
||||
case ThemeMode::Custom:
|
||||
return customColor(key);
|
||||
}
|
||||
return QString::fromUtf8(spec.dark_default);
|
||||
}
|
||||
|
||||
QString ViewerSettings::customColor(const QString& key) const {
|
||||
const int index = colorIndexForKey(key);
|
||||
if (index < 0) return {};
|
||||
const auto& spec = kThemeColorSpecs[static_cast<size_t>(index)];
|
||||
const QString& stored = custom_colors_[static_cast<size_t>(index)];
|
||||
return stored.isEmpty() ? QString::fromUtf8(spec.dark_default) : stored;
|
||||
}
|
||||
|
||||
void ViewerSettings::setCustomColor(const QString& key, const QString& value) {
|
||||
const int index = colorIndexForKey(key);
|
||||
if (index < 0) return;
|
||||
|
||||
const auto& spec = kThemeColorSpecs[static_cast<size_t>(index)];
|
||||
const QString normalized = normalizedColor(value, QString::fromUtf8(spec.dark_default));
|
||||
QString& stored = custom_colors_[static_cast<size_t>(index)];
|
||||
if (stored == normalized) return;
|
||||
stored = normalized;
|
||||
persist();
|
||||
if (theme_mode_ == ThemeMode::Custom) {
|
||||
emit themeChanged();
|
||||
}
|
||||
}
|
||||
|
||||
void ViewerSettings::load() {
|
||||
QSettings settings;
|
||||
int raw_mode = settings.value(kThemeModeKey, static_cast<int>(ThemeMode::Dark)).toInt();
|
||||
if (raw_mode < static_cast<int>(ThemeMode::Dark) || raw_mode > static_cast<int>(ThemeMode::Custom)) {
|
||||
raw_mode = static_cast<int>(ThemeMode::Dark);
|
||||
}
|
||||
theme_mode_ = static_cast<ThemeMode>(raw_mode);
|
||||
|
||||
for (size_t i = 0; i < kThemeColorSpecs.size(); ++i) {
|
||||
const auto& spec = kThemeColorSpecs[i];
|
||||
const QString stored = settings.value(QString::fromUtf8(kThemeColorPrefix) + QString::fromUtf8(spec.key),
|
||||
QString::fromUtf8(spec.dark_default))
|
||||
.toString();
|
||||
custom_colors_[i] = normalizedColor(stored, QString::fromUtf8(spec.dark_default));
|
||||
}
|
||||
}
|
||||
|
||||
void ViewerSettings::persist() const {
|
||||
QSettings settings;
|
||||
settings.setValue(kThemeModeKey, static_cast<int>(theme_mode_));
|
||||
for (size_t i = 0; i < kThemeColorSpecs.size(); ++i) {
|
||||
settings.setValue(QString::fromUtf8(kThemeColorPrefix) + QString::fromUtf8(kThemeColorSpecs[i].key),
|
||||
custom_colors_[i]);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace bonsaiviewer
|
||||
@@ -0,0 +1,72 @@
|
||||
// This file was generated with the assistance of an AI coding tool.
|
||||
/********************************************************************************
|
||||
* *
|
||||
* This file is part of IfcOpenShell. *
|
||||
* *
|
||||
* IfcOpenShell is free software: you can redistribute it and/or modify *
|
||||
* it under the terms of the Lesser GNU General Public License as published by *
|
||||
* the Free Software Foundation, either version 3.0 of the License, or *
|
||||
* (at your option) any later version. *
|
||||
* *
|
||||
* IfcOpenShell is distributed in the hope that it will be useful, *
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||
* Lesser GNU General Public License for more details. *
|
||||
* *
|
||||
* You should have received a copy of the Lesser GNU General Public License *
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||
* *
|
||||
********************************************************************************/
|
||||
|
||||
#ifndef IFCINTERFACE_INTERFACESETTINGS_H
|
||||
#define IFCINTERFACE_INTERFACESETTINGS_H
|
||||
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <vector>
|
||||
|
||||
namespace bonsaiviewer {
|
||||
|
||||
class ViewerSettings : public QObject {
|
||||
Q_OBJECT
|
||||
public:
|
||||
enum class ThemeMode {
|
||||
Dark = 0,
|
||||
Light = 1,
|
||||
Custom = 2,
|
||||
};
|
||||
Q_ENUM(ThemeMode)
|
||||
|
||||
struct ThemeColorSpec {
|
||||
const char* key;
|
||||
const char* label;
|
||||
const char* dark_default;
|
||||
const char* light_default;
|
||||
};
|
||||
|
||||
static ViewerSettings& instance();
|
||||
static const std::vector<ThemeColorSpec>& themeColorSpecs();
|
||||
|
||||
ThemeMode themeMode() const;
|
||||
void setThemeMode(ThemeMode mode);
|
||||
|
||||
QString color(const QString& key) const;
|
||||
QString customColor(const QString& key) const;
|
||||
void setCustomColor(const QString& key, const QString& value);
|
||||
|
||||
signals:
|
||||
void themeModeChanged(ThemeMode mode);
|
||||
void themeChanged();
|
||||
|
||||
private:
|
||||
ViewerSettings();
|
||||
void load();
|
||||
void persist() const;
|
||||
|
||||
ThemeMode theme_mode_ = ThemeMode::Dark;
|
||||
std::vector<QString> custom_colors_;
|
||||
};
|
||||
|
||||
} // namespace bonsaiviewer
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,50 @@
|
||||
<!-- This file was generated with the assistance of an AI coding tool. -->
|
||||
<RCC>
|
||||
<qresource prefix="/fonts">
|
||||
<file alias="DMSans-VariableFont_opsz,wght.ttf">../ifctester/webapp/public/fonts/dmsans/DMSans-VariableFont_opsz,wght.ttf</file>
|
||||
</qresource>
|
||||
<qresource prefix="/icons">
|
||||
<file alias="plus-square.svg">icons/plus-square.svg</file>
|
||||
<file alias="download-square.svg">icons/download-square.svg</file>
|
||||
<file alias="cloud-square.svg">icons/cloud-square.svg</file>
|
||||
<file alias="clock-rotate-right.svg">icons/clock-rotate-right.svg</file>
|
||||
<file alias="floppy-disk.svg">icons/floppy-disk.svg</file>
|
||||
<file alias="floppy-disk-arrow-in.svg">icons/floppy-disk-arrow-in.svg</file>
|
||||
<file alias="cube.svg">icons/cube.svg</file>
|
||||
<file alias="refresh-double.svg">icons/refresh-double.svg</file>
|
||||
<file alias="settings.svg">icons/settings.svg</file>
|
||||
<file alias="home.svg">icons/home.svg</file>
|
||||
<file alias="home-alt.svg">icons/home-alt.svg</file>
|
||||
<file alias="cube-scan.svg">icons/cube-scan.svg</file>
|
||||
<file alias="cube-scan-solid.svg">icons/cube-scan-solid.svg</file>
|
||||
<file alias="planimetry.svg">icons/planimetry.svg</file>
|
||||
<file alias="city.svg">icons/city.svg</file>
|
||||
<file alias="building.svg">icons/building.svg</file>
|
||||
<file alias="cellar.svg">icons/cellar.svg</file>
|
||||
<file alias="perspective-view.svg">icons/perspective-view.svg</file>
|
||||
<file alias="drone.svg">icons/drone.svg</file>
|
||||
<file alias="cube-cut-with-curve.svg">icons/cube-cut-with-curve.svg</file>
|
||||
<file alias="folder-plus.svg">icons/folder-plus.svg</file>
|
||||
<file alias="folder-minus.svg">icons/folder-minus.svg</file>
|
||||
<file alias="folder.svg">icons/folder.svg</file>
|
||||
<file alias="minus-square.svg">icons/minus-square.svg</file>
|
||||
<file alias="eye-closed.svg">icons/eye-closed.svg</file>
|
||||
<file alias="eye.svg">icons/eye.svg</file>
|
||||
<file alias="eye-solid.svg">icons/eye-solid.svg</file>
|
||||
<file alias="intersect.svg">icons/intersect.svg</file>
|
||||
<file alias="select-edge3d.svg">icons/select-edge3d.svg</file>
|
||||
<file alias="select-face3d.svg">icons/select-face3d.svg</file>
|
||||
<file alias="select-point3d.svg">icons/select-point3d.svg</file>
|
||||
<file alias="frame-alt.svg">icons/frame-alt.svg</file>
|
||||
<file alias="square3d-from-center.svg">icons/square3d-from-center.svg</file>
|
||||
<file alias="filter.svg">icons/filter.svg</file>
|
||||
<file alias="cube-dots.svg">icons/cube-dots.svg</file>
|
||||
<file alias="cursor-pointer.svg">icons/cursor-pointer.svg</file>
|
||||
<file alias="sidebar-expand.svg">icons/sidebar-expand.svg</file>
|
||||
<file alias="check.svg">icons/check.svg</file>
|
||||
<file alias="xmark-circle.svg">icons/xmark-circle.svg</file>
|
||||
<file alias="database.svg">icons/database.svg</file>
|
||||
<file alias="database-restore.svg">icons/database-restore.svg</file>
|
||||
<file alias="cube-bandage.svg">icons/cube-bandage.svg</file>
|
||||
</qresource>
|
||||
</RCC>
|
||||
@@ -0,0 +1,85 @@
|
||||
// This file was generated with the assistance of an AI coding tool.
|
||||
/********************************************************************************
|
||||
* *
|
||||
* This file is part of IfcOpenShell. *
|
||||
* *
|
||||
* IfcOpenShell is free software: you can redistribute it and/or modify *
|
||||
* it under the terms of the Lesser GNU General Public License as published by *
|
||||
* the Free Software Foundation, either version 3.0 of the License, or *
|
||||
* (at your option) any later version. *
|
||||
* *
|
||||
* IfcOpenShell is distributed in the hope that it will be useful, *
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||
* Lesser GNU General Public License for more details. *
|
||||
* *
|
||||
* You should have received a copy of the Lesser GNU General Public License *
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||
* *
|
||||
********************************************************************************/
|
||||
|
||||
#include "Buttons.h"
|
||||
|
||||
#include "SvgIcon.h"
|
||||
|
||||
#include <QBoxLayout>
|
||||
#include <QFrame>
|
||||
#include <QHBoxLayout>
|
||||
#include <QLabel>
|
||||
#include <QToolButton>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
namespace bonsaiviewer::components::buttons {
|
||||
|
||||
QToolButton* makeButton(const QString& text,
|
||||
const QString& icon_path,
|
||||
QWidget* parent) {
|
||||
auto* button = new QToolButton(parent);
|
||||
button->setObjectName("ribbonButton");
|
||||
button->setToolButtonStyle(Qt::ToolButtonTextUnderIcon);
|
||||
button->setIcon(components::icons::makeAccentSvgIcon(icon_path));
|
||||
button->setIconSize(QSize(20, 20));
|
||||
button->setText(text);
|
||||
button->setMinimumSize(QSize(90, 68));
|
||||
button->setAutoRaise(false);
|
||||
return button;
|
||||
}
|
||||
|
||||
QWidget* makeButtonGroup(const QString& title,
|
||||
const QList<QToolButton*>& buttons,
|
||||
QWidget* parent,
|
||||
int vertical_spacing) {
|
||||
auto* group = new QFrame(parent);
|
||||
group->setObjectName("ribbonGroup");
|
||||
|
||||
auto* group_layout = new QVBoxLayout(group);
|
||||
group_layout->setContentsMargins(8, 6, 8, 4);
|
||||
group_layout->setSpacing(vertical_spacing);
|
||||
|
||||
auto* button_row = new QHBoxLayout();
|
||||
button_row->setContentsMargins(0, 0, 0, 0);
|
||||
button_row->setSpacing(4);
|
||||
for (auto* button : buttons) {
|
||||
button_row->addWidget(button);
|
||||
}
|
||||
|
||||
auto* label = new QLabel(title, group);
|
||||
label->setObjectName("ribbonGroupLabel");
|
||||
label->setProperty("textRole", "secondary");
|
||||
label->setAlignment(Qt::AlignCenter);
|
||||
|
||||
group_layout->addLayout(button_row);
|
||||
group_layout->addWidget(label);
|
||||
return group;
|
||||
}
|
||||
|
||||
void addButtonGroups(QBoxLayout* row, const QList<QWidget*>& groups) {
|
||||
for (int i = 0; i < groups.size(); ++i) {
|
||||
// The divider belongs *between* groups; the last group never draws a
|
||||
// trailing one. The stylesheet keys off this dynamic property.
|
||||
groups[i]->setProperty("separator", i + 1 < groups.size());
|
||||
row->addWidget(groups[i]);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace bonsaiviewer::components::buttons
|
||||
@@ -0,0 +1,48 @@
|
||||
// This file was generated with the assistance of an AI coding tool.
|
||||
/********************************************************************************
|
||||
* *
|
||||
* This file is part of IfcOpenShell. *
|
||||
* *
|
||||
* IfcOpenShell is free software: you can redistribute it and/or modify *
|
||||
* it under the terms of the Lesser GNU General Public License as published by *
|
||||
* the Free Software Foundation, either version 3.0 of the License, or *
|
||||
* (at your option) any later version. *
|
||||
* *
|
||||
* IfcOpenShell is distributed in the hope that it will be useful, *
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||
* Lesser GNU General Public License for more details. *
|
||||
* *
|
||||
* You should have received a copy of the Lesser GNU General Public License *
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||
* *
|
||||
********************************************************************************/
|
||||
|
||||
#ifndef IFCINTERFACE_COMPONENTS_BUTTONS_H
|
||||
#define IFCINTERFACE_COMPONENTS_BUTTONS_H
|
||||
|
||||
#include <QList>
|
||||
|
||||
class QBoxLayout;
|
||||
class QToolButton;
|
||||
class QWidget;
|
||||
|
||||
namespace bonsaiviewer::components::buttons {
|
||||
|
||||
QToolButton* makeButton(const QString& text,
|
||||
const QString& icon_path,
|
||||
QWidget* parent);
|
||||
|
||||
QWidget* makeButtonGroup(const QString& title,
|
||||
const QList<QToolButton*>& buttons,
|
||||
QWidget* parent,
|
||||
int vertical_spacing = 4);
|
||||
|
||||
// Adds button groups to a ribbon row, drawing a vertical divider between
|
||||
// adjacent groups but never after the last one. Centralising the decision
|
||||
// here means a row can't end up with a dangling trailing separator.
|
||||
void addButtonGroups(QBoxLayout* row, const QList<QWidget*>& groups);
|
||||
|
||||
} // namespace bonsaiviewer::components::buttons
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,151 @@
|
||||
// This file was generated with the assistance of an AI coding tool.
|
||||
/********************************************************************************
|
||||
* *
|
||||
* This file is part of IfcOpenShell. *
|
||||
* *
|
||||
* IfcOpenShell is free software: you can redistribute it and/or modify *
|
||||
* it under the terms of the Lesser GNU General Public License as published by *
|
||||
* the Free Software Foundation, either version 3.0 of the License, or *
|
||||
* (at your option) any later version. *
|
||||
* *
|
||||
* IfcOpenShell is distributed in the hope that it will be useful, *
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||
* Lesser GNU General Public License for more details. *
|
||||
* *
|
||||
* You should have received a copy of the Lesser GNU General Public License *
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||
* *
|
||||
********************************************************************************/
|
||||
|
||||
#include "Dialog.h"
|
||||
|
||||
#include "Style.h"
|
||||
#include "Tabs.h"
|
||||
|
||||
#include <QDialog>
|
||||
#include <QFrame>
|
||||
#include <QScrollArea>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
namespace bonsaiviewer::components {
|
||||
|
||||
Dialog::Dialog(QWidget* parent, bool scrollable)
|
||||
: QDialog(parent)
|
||||
{
|
||||
auto* outer_layout = new QVBoxLayout(this);
|
||||
outer_layout->setContentsMargins(style::metrics::padding,
|
||||
style::metrics::padding,
|
||||
style::metrics::padding,
|
||||
style::metrics::padding);
|
||||
outer_layout->setSpacing(0);
|
||||
|
||||
auto* frame = new QFrame(this);
|
||||
frame->setObjectName("panel");
|
||||
auto* frame_layout = new QVBoxLayout(frame);
|
||||
frame_layout->setContentsMargins(0,
|
||||
style::metrics::section_body_padding,
|
||||
0,
|
||||
style::metrics::section_body_padding);
|
||||
frame_layout->setSpacing(0);
|
||||
|
||||
// A non-scrollable dialog must not be wrapped in a QScrollArea. The scroll
|
||||
// area caps its own sizeHint at 36x24 character cells, and dialogs size
|
||||
// themselves with QLayout::SetFixedSize — so any content wider/taller than
|
||||
// that cap is turned into scrollbars instead of growing the dialog. Only
|
||||
// use a scroll area when scrolling is actually wanted (mirrors Panel).
|
||||
auto* body = new QWidget(frame);
|
||||
body->setObjectName("panelScrollBody");
|
||||
body_layout_ = new QVBoxLayout(body);
|
||||
body_layout_->setContentsMargins(0, 0, 0, 0);
|
||||
body_layout_->setSpacing(style::metrics::section_body_padding);
|
||||
body_layout_->setAlignment(Qt::AlignTop);
|
||||
|
||||
if (scrollable) {
|
||||
auto* scroll = new QScrollArea(frame);
|
||||
scroll->setWidgetResizable(true);
|
||||
scroll->setFrameShape(QFrame::NoFrame);
|
||||
scroll->setWidget(body);
|
||||
frame_layout->addWidget(scroll, 1);
|
||||
} else {
|
||||
frame_layout->addWidget(body);
|
||||
}
|
||||
|
||||
auto* footer = new QWidget(frame);
|
||||
footer_layout_ = new QVBoxLayout(footer);
|
||||
footer_layout_->setContentsMargins(style::metrics::section_body_padding,
|
||||
style::metrics::section_body_padding,
|
||||
style::metrics::section_body_padding,
|
||||
0);
|
||||
footer_layout_->setSpacing(style::metrics::section_body_padding);
|
||||
footer_layout_->setAlignment(Qt::AlignTop);
|
||||
frame_layout->addWidget(footer);
|
||||
|
||||
outer_layout->addWidget(frame);
|
||||
}
|
||||
|
||||
void Dialog::addBodyWidget(QWidget* widget) {
|
||||
body_layout_->addWidget(widget);
|
||||
}
|
||||
|
||||
void Dialog::addFooterWidget(QWidget* widget) {
|
||||
footer_layout_->addWidget(widget);
|
||||
}
|
||||
|
||||
TabbedDialog::TabbedDialog(QWidget* parent)
|
||||
: QDialog(parent)
|
||||
{
|
||||
auto* outer_layout = new QVBoxLayout(this);
|
||||
outer_layout->setContentsMargins(style::metrics::padding,
|
||||
style::metrics::padding,
|
||||
style::metrics::padding,
|
||||
style::metrics::padding);
|
||||
outer_layout->setSpacing(0);
|
||||
|
||||
auto* frame = new QFrame(this);
|
||||
frame->setObjectName("panel");
|
||||
auto* frame_layout = new QVBoxLayout(frame);
|
||||
frame_layout->setContentsMargins(0,
|
||||
style::metrics::section_body_padding,
|
||||
0,
|
||||
style::metrics::section_body_padding);
|
||||
frame_layout->setSpacing(0);
|
||||
|
||||
tabs_ = new TabWidget(frame);
|
||||
frame_layout->addWidget(tabs_, 1);
|
||||
|
||||
auto* footer = new QWidget(frame);
|
||||
footer_layout_ = new QVBoxLayout(footer);
|
||||
footer_layout_->setContentsMargins(style::metrics::section_body_padding,
|
||||
style::metrics::section_body_padding,
|
||||
style::metrics::section_body_padding,
|
||||
0);
|
||||
footer_layout_->setSpacing(style::metrics::section_body_padding);
|
||||
footer_layout_->setAlignment(Qt::AlignTop);
|
||||
frame_layout->addWidget(footer);
|
||||
|
||||
outer_layout->addWidget(frame);
|
||||
}
|
||||
|
||||
void TabbedDialog::addTab(const QString& title, QWidget* widget) {
|
||||
auto* scroll = new QScrollArea(tabs_);
|
||||
scroll->setWidgetResizable(true);
|
||||
scroll->setFrameShape(QFrame::NoFrame);
|
||||
|
||||
auto* scroll_body = new QWidget(scroll);
|
||||
scroll_body->setObjectName("panelScrollBody");
|
||||
auto* layout = new QVBoxLayout(scroll_body);
|
||||
layout->setContentsMargins(0, 0, 0, 0);
|
||||
layout->setSpacing(style::metrics::section_body_padding);
|
||||
layout->setAlignment(Qt::AlignTop);
|
||||
layout->addWidget(widget);
|
||||
|
||||
scroll->setWidget(scroll_body);
|
||||
tabs_->addTab(scroll, title);
|
||||
}
|
||||
|
||||
void TabbedDialog::addFooterWidget(QWidget* widget) {
|
||||
footer_layout_->addWidget(widget);
|
||||
}
|
||||
|
||||
} // namespace bonsaiviewer::components
|
||||
@@ -0,0 +1,63 @@
|
||||
// This file was generated with the assistance of an AI coding tool.
|
||||
/********************************************************************************
|
||||
* *
|
||||
* This file is part of IfcOpenShell. *
|
||||
* *
|
||||
* IfcOpenShell is free software: you can redistribute it and/or modify *
|
||||
* it under the terms of the Lesser GNU General Public License as published by *
|
||||
* the Free Software Foundation, either version 3.0 of the License, or *
|
||||
* (at your option) any later version. *
|
||||
* *
|
||||
* IfcOpenShell is distributed in the hope that it will be useful, *
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||
* Lesser GNU General Public License for more details. *
|
||||
* *
|
||||
* You should have received a copy of the Lesser GNU General Public License *
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||
* *
|
||||
********************************************************************************/
|
||||
|
||||
#ifndef IFCINTERFACE_COMPONENTS_DIALOG_H
|
||||
#define IFCINTERFACE_COMPONENTS_DIALOG_H
|
||||
|
||||
#include <QDialog>
|
||||
|
||||
class QVBoxLayout;
|
||||
class QWidget;
|
||||
class QTabWidget;
|
||||
|
||||
namespace bonsaiviewer::components {
|
||||
|
||||
class Dialog : public QDialog {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit Dialog(QWidget* parent = nullptr,
|
||||
bool scrollable = false);
|
||||
|
||||
void addBodyWidget(QWidget* widget);
|
||||
void addFooterWidget(QWidget* widget);
|
||||
|
||||
private:
|
||||
QVBoxLayout* body_layout_ = nullptr;
|
||||
QVBoxLayout* footer_layout_ = nullptr;
|
||||
};
|
||||
|
||||
class TabbedDialog : public QDialog {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit TabbedDialog(QWidget* parent = nullptr);
|
||||
|
||||
void addTab(const QString& title, QWidget* widget);
|
||||
void addFooterWidget(QWidget* widget);
|
||||
|
||||
private:
|
||||
QTabWidget* tabs_ = nullptr;
|
||||
QVBoxLayout* footer_layout_ = nullptr;
|
||||
};
|
||||
|
||||
} // namespace bonsaiviewer::components
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,72 @@
|
||||
// This file was generated with the assistance of an AI coding tool.
|
||||
/********************************************************************************
|
||||
* *
|
||||
* This file is part of IfcOpenShell. *
|
||||
* *
|
||||
* IfcOpenShell is free software: you can redistribute it and/or modify *
|
||||
* it under the terms of the Lesser GNU General Public License as published by *
|
||||
* the Free Software Foundation, either version 3.0 of the License, or *
|
||||
* (at your option) any later version. *
|
||||
* *
|
||||
* IfcOpenShell is distributed in the hope that it will be useful, *
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||
* Lesser GNU General Public License for more details. *
|
||||
* *
|
||||
* You should have received a copy of the Lesser GNU General Public License *
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||
* *
|
||||
********************************************************************************/
|
||||
|
||||
#include "KeyValueTable.h"
|
||||
|
||||
#include "SvgIcon.h"
|
||||
|
||||
#include <QGridLayout>
|
||||
#include <QLabel>
|
||||
|
||||
namespace bonsaiviewer::components {
|
||||
|
||||
KeyValueTable::KeyValueTable(const QList<KeyValueTableRow>& rows, QWidget* parent)
|
||||
: QWidget(parent)
|
||||
{
|
||||
setObjectName("keyValueTable");
|
||||
|
||||
auto* layout = new QGridLayout(this);
|
||||
layout->setContentsMargins(0, 0, 0, 0);
|
||||
layout->setHorizontalSpacing(12);
|
||||
layout->setVerticalSpacing(6);
|
||||
layout->setColumnStretch(1, 1);
|
||||
|
||||
int row_index = 0;
|
||||
for (const auto& row_data : rows) {
|
||||
auto* key = new QLabel(row_data.key, this);
|
||||
key->setProperty("textRole", "secondary");
|
||||
if (row_data.key_minimum_width > 0) {
|
||||
key->setMinimumWidth(row_data.key_minimum_width);
|
||||
}
|
||||
|
||||
auto* value = new QLabel(row_data.value, this);
|
||||
value->setObjectName(row_data.value_object_name.isEmpty()
|
||||
? "keyValueValueLabel"
|
||||
: row_data.value_object_name);
|
||||
value->setWordWrap(true);
|
||||
value->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
|
||||
|
||||
layout->addWidget(key, row_index, 0, Qt::AlignLeft | Qt::AlignTop);
|
||||
layout->addWidget(value, row_index, 1);
|
||||
|
||||
if (!row_data.trailing_icon_path.isEmpty()) {
|
||||
auto* icon = new QLabel(this);
|
||||
icon->setObjectName(row_data.trailing_icon_object_name.isEmpty()
|
||||
? "keyValueTrailingIconLabel"
|
||||
: row_data.trailing_icon_object_name);
|
||||
icon->setPixmap(icons::makeSvgPixmap(row_data.trailing_icon_path, QSize(14, 14)));
|
||||
icon->setAlignment(Qt::AlignRight | Qt::AlignVCenter);
|
||||
layout->addWidget(icon, row_index, 2, Qt::AlignRight | Qt::AlignTop);
|
||||
}
|
||||
++row_index;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace bonsaiviewer::components
|
||||
@@ -0,0 +1,47 @@
|
||||
// This file was generated with the assistance of an AI coding tool.
|
||||
/********************************************************************************
|
||||
* *
|
||||
* This file is part of IfcOpenShell. *
|
||||
* *
|
||||
* IfcOpenShell is free software: you can redistribute it and/or modify *
|
||||
* it under the terms of the Lesser GNU General Public License as published by *
|
||||
* the Free Software Foundation, either version 3.0 of the License, or *
|
||||
* (at your option) any later version. *
|
||||
* *
|
||||
* IfcOpenShell is distributed in the hope that it will be useful, *
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||
* Lesser GNU General Public License for more details. *
|
||||
* *
|
||||
* You should have received a copy of the Lesser GNU General Public License *
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||
* *
|
||||
********************************************************************************/
|
||||
|
||||
#ifndef IFCINTERFACE_COMPONENTS_KEYVALUETABLE_H
|
||||
#define IFCINTERFACE_COMPONENTS_KEYVALUETABLE_H
|
||||
|
||||
#include <QList>
|
||||
#include <QString>
|
||||
#include <QWidget>
|
||||
|
||||
namespace bonsaiviewer::components {
|
||||
|
||||
struct KeyValueTableRow {
|
||||
QString key;
|
||||
QString value;
|
||||
QString value_object_name = "keyValueValueLabel";
|
||||
QString trailing_icon_path;
|
||||
QString trailing_icon_object_name;
|
||||
int key_minimum_width = 0;
|
||||
};
|
||||
|
||||
class KeyValueTable : public QWidget {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit KeyValueTable(const QList<KeyValueTableRow>& rows, QWidget* parent = nullptr);
|
||||
};
|
||||
|
||||
} // namespace bonsaiviewer::components
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,140 @@
|
||||
// This file was generated with the assistance of an AI coding tool.
|
||||
/********************************************************************************
|
||||
* *
|
||||
* This file is part of IfcOpenShell. *
|
||||
* *
|
||||
* IfcOpenShell is free software: you can redistribute it and/or modify *
|
||||
* it under the terms of the Lesser GNU General Public License as published by *
|
||||
* the Free Software Foundation, either version 3.0 of the License, or *
|
||||
* (at your option) any later version. *
|
||||
* *
|
||||
* IfcOpenShell is distributed in the hope that it will be useful, *
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||
* Lesser GNU General Public License for more details. *
|
||||
* *
|
||||
* You should have received a copy of the Lesser GNU General Public License *
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||
* *
|
||||
********************************************************************************/
|
||||
|
||||
#include "Panel.h"
|
||||
|
||||
#include "Style.h"
|
||||
#include "SvgIcon.h"
|
||||
|
||||
#include <QDockWidget>
|
||||
#include <QFrame>
|
||||
#include <QHBoxLayout>
|
||||
#include <QLabel>
|
||||
#include <QMenu>
|
||||
#include <QScrollArea>
|
||||
#include <QToolButton>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
namespace bonsaiviewer::components {
|
||||
|
||||
namespace {
|
||||
|
||||
class DockTitleBar : public QWidget {
|
||||
public:
|
||||
explicit DockTitleBar(const QString& title,
|
||||
bool has_settings = false,
|
||||
std::function<void()> on_settings = {},
|
||||
QWidget* parent = nullptr)
|
||||
: QWidget(parent)
|
||||
{
|
||||
auto* layout = new QHBoxLayout(this);
|
||||
layout->setContentsMargins(10, 6, 6, 6);
|
||||
layout->setSpacing(6);
|
||||
|
||||
auto* text = new QLabel(title.toUpper(), this);
|
||||
text->setObjectName("panelTitleText");
|
||||
|
||||
layout->addWidget(text);
|
||||
layout->addStretch(1);
|
||||
if (has_settings) {
|
||||
auto* settings = new QToolButton(this);
|
||||
settings->setIcon(icons::makeSvgIcon(":/icons/settings.svg"));
|
||||
settings->setAutoRaise(true);
|
||||
settings->setCursor(Qt::ArrowCursor);
|
||||
settings->setFixedSize(18, 18);
|
||||
settings->setObjectName("panelTitleButton");
|
||||
settings->setToolTip(QString("%1 settings").arg(title));
|
||||
connect(settings, &QToolButton::clicked, this, [on_settings = std::move(on_settings)]() {
|
||||
if (on_settings) on_settings();
|
||||
});
|
||||
layout->addWidget(settings);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
Panel::Panel(const QString& title, QWidget* content, QWidget* parent, bool has_settings, bool scrollable)
|
||||
: QDockWidget(title, parent)
|
||||
{
|
||||
auto* outer = new QFrame();
|
||||
auto* outer_layout = new QVBoxLayout(outer);
|
||||
outer_layout->setContentsMargins(style::metrics::padding,
|
||||
style::metrics::padding,
|
||||
style::metrics::padding,
|
||||
style::metrics::padding);
|
||||
outer_layout->setSpacing(0);
|
||||
|
||||
auto* frame = new QFrame(outer);
|
||||
frame->setObjectName("panel");
|
||||
auto* frame_layout = new QVBoxLayout(frame);
|
||||
frame_layout->setContentsMargins(0, style::metrics::section_body_padding, 0, style::metrics::section_body_padding);
|
||||
frame_layout->setSpacing(0);
|
||||
|
||||
if (scrollable) {
|
||||
auto* scroll = new QScrollArea(frame);
|
||||
scroll->setWidgetResizable(true);
|
||||
scroll->setFrameShape(QFrame::NoFrame);
|
||||
|
||||
auto* scroll_body = new QWidget(scroll);
|
||||
scroll_body->setObjectName("panelScrollBody");
|
||||
body_layout_ = new QVBoxLayout(scroll_body);
|
||||
body_layout_->setContentsMargins(0, 0, 0, 0);
|
||||
body_layout_->setSpacing(style::metrics::section_body_padding);
|
||||
body_layout_->setAlignment(Qt::AlignTop);
|
||||
|
||||
scroll->setWidget(scroll_body);
|
||||
frame_layout->addWidget(scroll);
|
||||
} else {
|
||||
auto* body = new QWidget(frame);
|
||||
body_layout_ = new QVBoxLayout(body);
|
||||
body_layout_->setContentsMargins(0, 0, 0, 0);
|
||||
body_layout_->setSpacing(style::metrics::section_body_padding);
|
||||
body_layout_->setAlignment(Qt::AlignTop);
|
||||
frame_layout->addWidget(body);
|
||||
}
|
||||
|
||||
if (content) {
|
||||
addBodyWidget(content);
|
||||
}
|
||||
outer_layout->addWidget(frame);
|
||||
|
||||
setObjectName(title);
|
||||
setFeatures(QDockWidget::DockWidgetMovable |
|
||||
QDockWidget::DockWidgetFloatable |
|
||||
QDockWidget::DockWidgetClosable);
|
||||
setTitleBarWidget(new DockTitleBar(title, has_settings, [this]() {
|
||||
emit settingsRequested();
|
||||
}, this));
|
||||
setWidget(outer);
|
||||
}
|
||||
|
||||
void Panel::addBodyWidget(QWidget* widget) {
|
||||
body_layout_->addWidget(widget);
|
||||
}
|
||||
|
||||
void Panel::clearBodyWidgets() {
|
||||
while (auto* item = body_layout_->takeAt(0)) {
|
||||
if (auto* widget = item->widget()) widget->deleteLater();
|
||||
delete item;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace bonsaiviewer::components
|
||||
@@ -0,0 +1,53 @@
|
||||
// This file was generated with the assistance of an AI coding tool.
|
||||
/********************************************************************************
|
||||
* *
|
||||
* This file is part of IfcOpenShell. *
|
||||
* *
|
||||
* IfcOpenShell is free software: you can redistribute it and/or modify *
|
||||
* it under the terms of the Lesser GNU General Public License as published by *
|
||||
* the Free Software Foundation, either version 3.0 of the License, or *
|
||||
* (at your option) any later version. *
|
||||
* *
|
||||
* IfcOpenShell is distributed in the hope that it will be useful, *
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||
* Lesser GNU General Public License for more details. *
|
||||
* *
|
||||
* You should have received a copy of the Lesser GNU General Public License *
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||
* *
|
||||
********************************************************************************/
|
||||
|
||||
#ifndef IFCINTERFACE_COMPONENTS_PANEL_PANELCHROME_H
|
||||
#define IFCINTERFACE_COMPONENTS_PANEL_PANELCHROME_H
|
||||
|
||||
#include <QDockWidget>
|
||||
|
||||
class QWidget;
|
||||
class QVBoxLayout;
|
||||
|
||||
namespace bonsaiviewer::components {
|
||||
|
||||
class Panel : public QDockWidget {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit Panel(const QString& title,
|
||||
QWidget* content = nullptr,
|
||||
QWidget* parent = nullptr,
|
||||
bool has_settings = false,
|
||||
bool scrollable = false);
|
||||
|
||||
void addBodyWidget(QWidget* widget);
|
||||
void clearBodyWidgets();
|
||||
|
||||
signals:
|
||||
void settingsRequested();
|
||||
|
||||
private:
|
||||
QVBoxLayout* body_layout_ = nullptr;
|
||||
};
|
||||
|
||||
} // namespace bonsaiviewer::components
|
||||
|
||||
#endif
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user