mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-06 07:51:47 +00:00
Compare commits
122 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f59942c453 | |||
| ab7d9fdf4a | |||
| 5436467fc5 | |||
| 1c7e134d78 | |||
| 97ee4eaef0 | |||
| 30517770e0 | |||
| 5b1ec85f75 | |||
| 12123baafe | |||
| 0a1c54cedc | |||
| 12144f76d3 | |||
| ca6e950496 | |||
| c478da5257 | |||
| c28251a1b1 | |||
| 9bc0588d21 | |||
| 918cc65a0d | |||
| 7350ccd25e | |||
| c1f146966c | |||
| 24acfeaf45 | |||
| 5d748c5b04 | |||
| 5bcf8685ab | |||
| 8f64f75abb | |||
| 434ea74f22 | |||
| fbf2946b69 | |||
| 7af0ec13e5 | |||
| 29e5e0fd1a | |||
| d7de4f8df0 | |||
| 252bd6f4f6 | |||
| 3b28c92414 | |||
| fcbec74521 | |||
| 0f7f960b29 | |||
| b7fc5daf82 | |||
| 1d7a8ca249 | |||
| eaf7950677 | |||
| 95851ff94c | |||
| 3a6881ca17 | |||
| c5210a4f82 | |||
| e3cadab406 | |||
| d5b551874d | |||
| be3aef59fa | |||
| bcc631bf5f | |||
| 6c18ac9605 | |||
| 9d42f4c1ee | |||
| fdb2947345 | |||
| 5e784e4175 | |||
| fb81c88a5f | |||
| c014ce2b46 | |||
| ff65719074 | |||
| 3491e4c91b | |||
| 9f3adc9154 | |||
| f6c6203408 | |||
| 39a376df95 | |||
| 70a4fdbf95 | |||
| 0a41d2e016 | |||
| 0b5eab8549 | |||
| 6f9d54c2af | |||
| 9ea302cdf1 | |||
| c057e79f17 | |||
| da470c5135 | |||
| 214cd44f8e | |||
| 4bff2fa554 | |||
| 9d78df392d | |||
| 86bef0a254 | |||
| 05bf59d360 | |||
| 17eaef778a | |||
| f46be80193 | |||
| be05d771a2 | |||
| c214d255c9 | |||
| 0d8ba71384 | |||
| 1c26ee86c9 | |||
| 0ed96d32dd | |||
| f96526195d | |||
| fd29481d65 | |||
| 24b48497f0 | |||
| 2b9822f141 | |||
| 3d4db13fc1 | |||
| 31b571322b | |||
| cef5d41b54 | |||
| d7b2358d58 | |||
| cafe5aa7f7 | |||
| de34e73451 | |||
| 5721a8b602 | |||
| f820214500 | |||
| dae913e06a | |||
| 1a849395c2 | |||
| 611273a20a | |||
| db68195310 | |||
| 29079e8cba | |||
| 6bf4259298 | |||
| 7cd40bf8cb | |||
| 8b8f78095d | |||
| 23ba9e4db0 | |||
| 1aec991f08 | |||
| bddf9b85f8 | |||
| f679c63a18 | |||
| e6cc0e7813 | |||
| cf2acfc649 | |||
| 60ebb99fda | |||
| ab96add772 | |||
| d8de623086 | |||
| a3f2e061fb | |||
| f51a4673db | |||
| 75b8d4f218 | |||
| b8136d4762 | |||
| ecde429d36 | |||
| 1771b34449 | |||
| 41469acbc8 | |||
| 547b22199f | |||
| 94c15213f6 | |||
| fca258fb07 | |||
| bcfad8d96d | |||
| aa5f5120e0 | |||
| 81986bcbfb | |||
| 03de69814a | |||
| 4dc6a0f2bc | |||
| 47f058341b | |||
| 845a13ba83 | |||
| 530841967e | |||
| e95da857d6 | |||
| d587d1ac11 | |||
| 6117417b89 | |||
| 0398584c69 | |||
| 0c69f85d5e |
@@ -53,7 +53,7 @@ jobs:
|
||||
python ../nix/cache_dependencies.py unpack
|
||||
|
||||
- name: ccache
|
||||
uses: hendrikmuhs/ccache-action@v1.2.20
|
||||
uses: hendrikmuhs/ccache-action@v1.2.22
|
||||
with:
|
||||
key: mac-${{ matrix.arch }}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
python ../IfcOpenShell/nix/cache_dependencies.py unpack
|
||||
|
||||
- name: ccache
|
||||
uses: hendrikmuhs/ccache-action@v1.2.20
|
||||
uses: hendrikmuhs/ccache-action@v1.2.22
|
||||
with:
|
||||
key: ubuntu-22.04-${{ runner.arch }}
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
python3 ../nix/cache_dependencies.py unpack
|
||||
|
||||
- name: ccache
|
||||
uses: hendrikmuhs/ccache-action@v1.2.20
|
||||
uses: hendrikmuhs/ccache-action@v1.2.22
|
||||
with:
|
||||
key: ubuntu-22.04-${{ runner.arch }}-rockylinux9
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
python3 ../nix/cache_dependencies.py unpack
|
||||
|
||||
- name: ccache
|
||||
uses: hendrikmuhs/ccache-action@v1.2.20
|
||||
uses: hendrikmuhs/ccache-action@v1.2.22
|
||||
with:
|
||||
key: ubuntu-22.04-${{ runner.arch }}-rockylinux9
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
}
|
||||
|
||||
- name: ccache
|
||||
uses: hendrikmuhs/ccache-action@v1.2.20
|
||||
uses: hendrikmuhs/ccache-action@v1.2.22
|
||||
with:
|
||||
key: win-${{ matrix.arch }}
|
||||
# Windows ccache needs ~1GB
|
||||
|
||||
@@ -109,7 +109,7 @@ jobs:
|
||||
# Ensure Bonsai and ifcsverchok enable/disable works before uploading to extensions repo.
|
||||
|
||||
# Download Blender.
|
||||
wget -q -O blender.tar.xz https://download.blender.org/release/Blender5.0/blender-5.0.1-linux-x64.tar.xz
|
||||
wget -q -O blender.tar.xz https://download.blender.org/release/Blender5.1/blender-5.1.0-linux-x64.tar.xz
|
||||
tar -xf blender.tar.xz
|
||||
|
||||
# Setup Blender.
|
||||
@@ -122,7 +122,7 @@ jobs:
|
||||
pip install -r requirements.txt
|
||||
python setup_extensions_repo.py --last-tag
|
||||
cd ..
|
||||
bonsai_zip="$(pwd)/$(ls bonsai_unstable_repo/bonsai_py311*-linux-x64.zip)"
|
||||
bonsai_zip="$(pwd)/$(ls bonsai_unstable_repo/bonsai_py313*-linux-x64.zip)"
|
||||
|
||||
# Install Bonsai.
|
||||
blender --command extension install-file -r user_default -e $bonsai_zip
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
name: ci-ifcedit-pypi
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
activate:
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
github.repository == 'IfcOpenShell/IfcOpenShell'
|
||||
steps:
|
||||
- name: Set env
|
||||
run: echo ok go
|
||||
|
||||
build:
|
||||
needs: activate
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Compile
|
||||
run: |
|
||||
pip install build
|
||||
cd src/ifcedit &&
|
||||
make dist IS_STABLE=TRUE
|
||||
- name: Publish a Python distribution to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
user: __token__
|
||||
password: ${{ secrets.PYPI_API_TOKEN }}
|
||||
packages_dir: src/ifcedit/dist
|
||||
@@ -0,0 +1,36 @@
|
||||
name: ci-ifcmcp-pypi
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
activate:
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
github.repository == 'IfcOpenShell/IfcOpenShell'
|
||||
steps:
|
||||
- name: Set env
|
||||
run: echo ok go
|
||||
|
||||
build:
|
||||
needs: activate
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Compile
|
||||
run: |
|
||||
pip install build
|
||||
cd src/ifcmcp &&
|
||||
make dist IS_STABLE=TRUE
|
||||
- name: Publish a Python distribution to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
user: __token__
|
||||
password: ${{ secrets.PYPI_API_TOKEN }}
|
||||
packages_dir: src/ifcmcp/dist
|
||||
verbose: true
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
if: |
|
||||
github.repository == 'IfcOpenShell/IfcOpenShell'
|
||||
steps:
|
||||
- uses: mamba-org/setup-micromamba@v2 # https://github.com/mamba-org/setup-micromamba
|
||||
- uses: mamba-org/setup-micromamba@v3 # https://github.com/mamba-org/setup-micromamba
|
||||
with:
|
||||
environment-name: test-env
|
||||
create-args: >-
|
||||
|
||||
@@ -84,7 +84,7 @@ jobs:
|
||||
run: |
|
||||
curl -L https://github.com/phracker/MacOSX-SDKs/releases/download/11.3/MacOSX10.13.sdk.tar.xz | tar -xvJf - -C /Users/runner/work/
|
||||
|
||||
- uses: mamba-org/setup-micromamba@v2 # https://github.com/mamba-org/setup-micromamba
|
||||
- uses: mamba-org/setup-micromamba@v3 # https://github.com/mamba-org/setup-micromamba
|
||||
with:
|
||||
environment-name: test-env
|
||||
create-args: >-
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
|
||||
-
|
||||
name: ccache
|
||||
uses: hendrikmuhs/ccache-action@v1.2.20
|
||||
uses: hendrikmuhs/ccache-action@v1.2.22
|
||||
|
||||
-
|
||||
name: Build ifcopenshell
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
name: ci-ifcquery-pypi
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
activate:
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
github.repository == 'IfcOpenShell/IfcOpenShell'
|
||||
steps:
|
||||
- name: Set env
|
||||
run: echo ok go
|
||||
|
||||
build:
|
||||
needs: activate
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Compile
|
||||
run: |
|
||||
pip install build
|
||||
cd src/ifcquery &&
|
||||
make dist IS_STABLE=TRUE
|
||||
- name: Publish a Python distribution to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
user: __token__
|
||||
password: ${{ secrets.PYPI_API_TOKEN }}
|
||||
packages_dir: src/ifcquery/dist
|
||||
@@ -79,7 +79,7 @@ jobs:
|
||||
libhdf5-dev libcgal-dev libeigen3-dev
|
||||
|
||||
- name: ccache
|
||||
uses: hendrikmuhs/ccache-action@v1.2.20
|
||||
uses: hendrikmuhs/ccache-action@v1.2.22
|
||||
with:
|
||||
key: ubuntu-22.04-${{ runner.arch }}
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
name: Deploy AI chat App to static page repo
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
pages: write
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'src/ifcchat/**'
|
||||
- '.github/workflows/publish-aichat-app.yaml'
|
||||
branches:
|
||||
- v0.8.0
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
activate:
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
github.repository == 'IfcOpenShell/IfcOpenShell'
|
||||
steps:
|
||||
- name: Set env
|
||||
run: echo ok go
|
||||
|
||||
build:
|
||||
needs: activate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout (recursive)
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
fetch-depth: 0
|
||||
- name: Checkout intermediate Pages repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: IfcOpenShell/aichat_ifcopenshell_org_static_html
|
||||
ref: gh-pages
|
||||
path: output
|
||||
token: ${{ secrets.WEBSITE_PUBLISH }}
|
||||
- name: Sync demo app into target subfolder
|
||||
run: |
|
||||
rsync -av --delete --exclude='.git/' src/ifcchat/ output/
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.x"
|
||||
- name: Download wheels
|
||||
working-directory: output/
|
||||
run: |
|
||||
pip download ifcquery==0.8.5 ifcopenshell-mcp==0.8.5 ifcedit==0.8.5 lark==1.3.1 isodate==0.7.2 --no-deps -d ./dist
|
||||
- name: Commit and push if changed
|
||||
working-directory: output
|
||||
run: |
|
||||
git config --global user.name 'IfcOpenBot'
|
||||
git config --global user.email 'IfcOpenBot@users.noreply.github.com'
|
||||
|
||||
git add .
|
||||
if git diff --cached --quiet; then
|
||||
echo "No changes to commit"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git commit -m "$(git log --oneline -1)"
|
||||
git push origin gh-pages
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Deploy Pyodide Demo App to GitHub Pages
|
||||
name: Deploy Pyodide Demo App to static page repo
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
@@ -11,6 +11,7 @@ on:
|
||||
- '.github/workflows/publish-pyodide-demo-app.yml'
|
||||
branches:
|
||||
- v0.8.0
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
activate:
|
||||
@@ -30,21 +31,27 @@ jobs:
|
||||
with:
|
||||
submodules: recursive
|
||||
fetch-depth: 0
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v5
|
||||
- name: Upload static files as artifact
|
||||
id: deployment
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
- name: Checkout intermediate Pages repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
path: src/pyodide/demo-app/
|
||||
repository: IfcOpenShell/wasm_ifcopenshell_org_static_html
|
||||
ref: gh-pages
|
||||
path: output
|
||||
token: ${{ secrets.WEBSITE_PUBLISH }}
|
||||
- name: Sync demo app into target subfolder
|
||||
run: |
|
||||
rsync -av --delete --exclude='.git/' src/pyodide/demo-app/ output/
|
||||
- name: Commit and push if changed
|
||||
working-directory: output
|
||||
run: |
|
||||
git config --global user.name 'IfcOpenBot'
|
||||
git config --global user.email 'IfcOpenBot@users.noreply.github.com'
|
||||
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
git add .
|
||||
if git diff --cached --quiet; then
|
||||
echo "No changes to commit"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git commit -m "$(git log --oneline -1)"
|
||||
git push origin gh-pages
|
||||
|
||||
+5
-3
@@ -14,6 +14,7 @@
|
||||
/src/ifcmax/out/
|
||||
/src/ifcwrap/out/
|
||||
/src/qtviewer/out/
|
||||
/src/ifctester/webapp/public/pyodide/
|
||||
|
||||
/win/BuildDepsCache*.txt
|
||||
|
||||
@@ -85,10 +86,11 @@ src/bonsai/bonsai/translations.py
|
||||
# bonsai external dependencies (cloned for just ty checks)
|
||||
src/bonsai/external_dependencies/
|
||||
|
||||
# bonsai test temp files
|
||||
# bonsai test temp/cache files
|
||||
src/bonsai/test/files/temp
|
||||
src/bonsai/test/files/basic.ifc.cache.blend
|
||||
src/bonsai/test/files/basic.ifc.cache.sqlite
|
||||
src/bonsai/test/files/*.cache.blend
|
||||
src/bonsai/test/files/*.cache.json
|
||||
src/bonsai/test/files/*.cache.sqlite
|
||||
|
||||
# bonsai data
|
||||
src/bonsai/bonsai/bim/data/build/
|
||||
|
||||
@@ -50,10 +50,13 @@ Contents
|
||||
| [ifcconvert](https://docs.ifcopenshell.org/ifcconvert.html) | CLI app to convert IFC to many other formats | LGPL-3.0-or-later\* | [](https://docs.ifcopenshell.org/ifcconvert/installation.html) [](https://github.com/IfcOpenShell/IfcOpenShell/releases?q=ifcconvert&expanded=true)
|
||||
| [ifccsv](https://docs.ifcopenshell.org/ifccsv.html) | Library and CLI app to export and import schedules from IFC | LGPL-3.0-or-later | [](https://pypi.org/project/ifccsv/) |
|
||||
| [ifcdiff](https://docs.ifcopenshell.org/ifcdiff.html) | Compare changes between IFC models | LGPL-3.0-or-later | [](https://pypi.org/project/ifcdiff/) |
|
||||
| [ifcedit](https://docs.ifcopenshell.org/ifcedit.html) | CLI wrapper for ifcopenshell.api IFC model mutation functions | LGPL-3.0-or-later | [](https://pypi.org/project/ifcedit/) |
|
||||
| [ifcfm](https://docs.ifcopenshell.org/ifcfm.html) | Extract IFC data for FM handover requirements | LGPL-3.0-or-later | [](https://pypi.org/project/ifcfm/) |
|
||||
| [ifcmax](https://docs.ifcopenshell.org/ifcmax.html) | Historic extension for IFC support in 3DS Max | LGPL-3.0-or-later\* | [](https://docs.ifcopenshell.org/ifcmax.html)
|
||||
| [ifcmcp](https://docs.ifcopenshell.org/ifcmcp.html) | MCP server for querying and editing IFC building models | LGPL-3.0-or-later | [](https://pypi.org/project/ifcmcp/) |
|
||||
| [ifcopenshell-python](https://docs.ifcopenshell.org/ifcopenshell-python.html) | Python library for IFC manipulation | LGPL-3.0-or-later\* | [](https://docs.ifcopenshell.org/ifcopenshell-python/installation.html) [](https://github.com/IfcOpenShell/IfcOpenShell/releases?q=ifcopenshell-python&expanded=true) [](https://pypi.org/project/ifcopenshell/) [](https://anaconda.org/conda-forge/ifcopenshell) [](https://anaconda.org/ifcopenshell/ifcopenshell) [](https://hub.docker.com/r/aecgeeks/ifcopenshell) [](https://aur.archlinux.org/packages/ifcopenshell) [](https://aur.archlinux.org/packages/ifcopenshell-git) [Pyodide WASM Wheels](https://github.com/IfcOpenShell/wasm-wheels#pyodide-test-wheels) |
|
||||
| [ifcpatch](https://docs.ifcopenshell.org/ifcpatch.html) | Utility to run pre-packaged scripts to manipulate IFCs | LGPL-3.0-or-later | [](https://pypi.org/project/ifcpatch/) |
|
||||
| [ifcquery](https://docs.ifcopenshell.org/ifcquery.html) | CLI tool for querying and inspecting IFC building models | LGPL-3.0-or-later | [](https://pypi.org/project/ifcquery/) |
|
||||
| [ifcsverchok](https://docs.ifcopenshell.org/ifcsverchok.html) | Blender Add-on for visual node programming with IFC | GPL-3.0-or-later | [](https://github.com/IfcOpenShell/IfcOpenShell/releases?q=ifcsverchok&expanded=true)
|
||||
| [ifctester](https://docs.ifcopenshell.org/ifctester.html) | Library, CLI and webapp for IDS model auditing | LGPL-3.0-or-later | [](https://pypi.org/project/ifctester/) |
|
||||
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ name = "IfcOpenShell"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"black==26.3.1",
|
||||
"ruff==0.15.6",
|
||||
"ruff==0.15.8",
|
||||
"poethepoet",
|
||||
"gersemi==0.26.1",
|
||||
]
|
||||
|
||||
+5
-3
@@ -48,6 +48,7 @@ VERSION_PATCH:=$(shell cat '../../VERSION' | cut -d '.' -f 3)
|
||||
VERSION_DATE:=$(shell date '+%y%m%d')
|
||||
LAST_COMMIT_HASH:=$(shell git rev-parse HEAD)
|
||||
LAST_COMMIT_DATE:=$(shell git show -s --format=%cI)
|
||||
LAST_GIT_BRANCH:=$(shell git rev-parse --abbrev-ref HEAD)
|
||||
PYPI_IMP:=cp
|
||||
|
||||
ifdef PYVERSION
|
||||
@@ -63,6 +64,7 @@ PYNUMBER:=3$(PYMINOR)
|
||||
PYPI_VERSION:=3.$(PYMINOR)
|
||||
endif # def PYVERSION
|
||||
|
||||
IFCMERGE_VERSION:=2026-04-02
|
||||
|
||||
ifdef PLATFORM
|
||||
SUPPORTED_PLATFORMS := linux macos macosm1 win
|
||||
@@ -239,10 +241,9 @@ endif
|
||||
|
||||
# required for three-way git merging
|
||||
ifeq ($(PLATFORM), win)
|
||||
cd build/bonsai/libs/bin && wget https://github.com/brunopostle/ifcmerge/releases/download/2025-01-26/ifcmerge.zip
|
||||
cd build/bonsai/libs/bin && unzip ifcmerge.zip && rm ifcmerge.zip
|
||||
cd build/bonsai/libs/bin && wget https://github.com/brunopostle/ifcmerge/releases/download/$(IFCMERGE_VERSION)/ifcmerge.exe
|
||||
else
|
||||
cd build/bonsai/libs/bin && wget https://raw.githubusercontent.com/brunopostle/ifcmerge/main/ifcmerge && chmod +x ifcmerge
|
||||
cd build/bonsai/libs/bin && wget https://raw.githubusercontent.com/brunopostle/ifcmerge/$(IFCMERGE_VERSION)/ifcmerge && chmod +x ifcmerge
|
||||
endif
|
||||
|
||||
# Generate translations module for Bonsai build
|
||||
@@ -261,6 +262,7 @@ else
|
||||
$(SED) "s/0.0.0/$(VERSION)-alpha$(VERSION_DATE)/" build/bonsai/blender_manifest.toml
|
||||
$(SED) "s/8888888/$(LAST_COMMIT_HASH)/" build/bonsai/__init__.py
|
||||
$(SED) "s/9999999/$(LAST_COMMIT_DATE)/" build/bonsai/__init__.py
|
||||
$(SED) "s/7777777/$(LAST_GIT_BRANCH)/" build/bonsai/__init__.py
|
||||
$(SED) 's/version = "0.0.0"/version = "$(VERSION)-alpha$(VERSION_DATE)"/' build/pyproject.toml
|
||||
endif
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ from typing import TYPE_CHECKING, Any, Union
|
||||
|
||||
last_commit_hash = "8888888"
|
||||
last_commit_date = "9999999"
|
||||
last_git_branch = "7777777"
|
||||
|
||||
|
||||
def get_last_commit_hash() -> Union[str, None]:
|
||||
@@ -60,6 +61,15 @@ def get_last_commit_date() -> Union[str, None]:
|
||||
return last_commit_date
|
||||
|
||||
|
||||
def get_git_branch() -> Union[str, None]:
|
||||
# Using this weird way to write 7777777,
|
||||
# so makefile won't accidentally replace it here
|
||||
# we'll be able to distinguish branch from placeholder value.
|
||||
if last_git_branch == str(7_777777):
|
||||
return None
|
||||
return last_git_branch
|
||||
|
||||
|
||||
# Accessed from bonsai extension:
|
||||
bbim_semver: dict[str, Any] = {}
|
||||
|
||||
@@ -125,6 +135,7 @@ def get_debug_info(*, bonsai_failed_to_load: bool = False) -> dict[str, Any]:
|
||||
"bonsai_version": bbim_version,
|
||||
"bonsai_commit_hash": get_last_commit_hash(),
|
||||
"bonsai_commit_date": get_last_commit_date(),
|
||||
"bonsai_git_branch": get_git_branch(),
|
||||
"last_actions": last_actions,
|
||||
"last_error": last_error,
|
||||
}
|
||||
@@ -251,10 +262,12 @@ if IN_BLENDER:
|
||||
|
||||
global last_commit_hash
|
||||
global last_commit_date
|
||||
global last_git_branch
|
||||
path = Path(__file__).resolve().parent
|
||||
repo = git.Repo(str(path), search_parent_directories=True)
|
||||
last_commit_hash = repo.head.object.hexsha
|
||||
last_commit_date = repo.head.object.committed_datetime.isoformat()
|
||||
last_git_branch = repo.active_branch.name
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
@@ -45,16 +45,19 @@ from bonsai.bim.module.nest.decorator import NestDecorator
|
||||
|
||||
cwd = os.path.dirname(os.path.realpath(__file__))
|
||||
global_subscription_owner = object()
|
||||
# Separate owner for per-object msgbus subscriptions (name, active_material_index).
|
||||
# Using a dedicated owner allows clearing all per-object subscriptions at once
|
||||
# during undo/redo without affecting other global subscriptions.
|
||||
object_subscription_owner = object()
|
||||
|
||||
|
||||
def name_callback(obj: Union[bpy.types.Object, bpy.types.Material], data: str) -> None:
|
||||
try:
|
||||
obj.name
|
||||
except:
|
||||
# The object is invalid but somehow still has a callback. Clear all
|
||||
# msgbus subscriptions to prevent useless further triggers.
|
||||
bpy.msgbus.clear_by_owner(obj)
|
||||
return # In case the object RNA is gone during an undo / redo operation
|
||||
# The object is invalid but somehow still has a callback.
|
||||
# This can occur during undo/redo when the Python wrapper is stale.
|
||||
return
|
||||
# Blender names are up to 63 UTF-8 bytes
|
||||
if len(bytes(obj.name, "utf-8")) >= 63:
|
||||
return
|
||||
@@ -189,7 +192,7 @@ def subscribe_to(obj: bpy.types.ID, data_path: str, callback: Callable[[bpy.type
|
||||
return
|
||||
bpy.msgbus.subscribe_rna(
|
||||
key=subscribe_to,
|
||||
owner=obj,
|
||||
owner=object_subscription_owner,
|
||||
args=(
|
||||
obj,
|
||||
data_path,
|
||||
|
||||
@@ -316,11 +316,8 @@ class IfcStore:
|
||||
del IfcStore.id_map[data["id"]]
|
||||
if "guid" in data:
|
||||
del IfcStore.guid_map[data["guid"]]
|
||||
obj = IfcStore.get_object_by_name(data["obj"])
|
||||
if obj is None:
|
||||
# obj was just created during this step and didn't existed before.
|
||||
return
|
||||
bpy.msgbus.clear_by_owner(obj)
|
||||
# Note: msgbus subscriptions are cleared globally during
|
||||
# rebuild_element_maps which runs after every undo/redo.
|
||||
|
||||
@staticmethod
|
||||
def commit_link_element(data: OperationData) -> None:
|
||||
@@ -367,10 +364,8 @@ class IfcStore:
|
||||
del IfcStore.id_map[data["id"]]
|
||||
if "guid" in data:
|
||||
del IfcStore.guid_map[data["guid"]]
|
||||
obj = IfcStore.get_object_by_name(data["obj"])
|
||||
# obj might be removed after unlink.
|
||||
if not obj:
|
||||
bpy.msgbus.clear_by_owner(obj)
|
||||
# Note: msgbus subscriptions are cleared globally during
|
||||
# rebuild_element_maps which runs after every undo/redo.
|
||||
|
||||
@staticmethod
|
||||
def unlink_element(
|
||||
|
||||
@@ -64,8 +64,8 @@ class MaterialCreator:
|
||||
mesh: Union[OBJECT_DATA_TYPE, None],
|
||||
shape_has_openings: bool,
|
||||
) -> None:
|
||||
if ((rep := getattr(element, "Representation", ...) is not ...) and not rep) or (
|
||||
(rep := getattr(element, "RepresentationMaps", ...) is not ...) and not rep
|
||||
if ((rep := getattr(element, "Representation", ...)) is not ... and not rep) or (
|
||||
(rep := getattr(element, "RepresentationMaps", ...)) is not ... and not rep
|
||||
):
|
||||
return
|
||||
|
||||
|
||||
@@ -73,6 +73,22 @@ def poll_related_object(self: "BIMObjectAggregateProperties", related_obj: bpy.t
|
||||
return True
|
||||
|
||||
|
||||
def update_relating_object(self, context):
|
||||
if self.relating_object:
|
||||
ifc_id = tool.Blender.get_object_bim_props(self.relating_object).ifc_definition_id
|
||||
if ifc_id:
|
||||
bpy.ops.bim.aggregate_assign_object(relating_object=ifc_id)
|
||||
bpy.ops.bim.disable_editing_aggregate()
|
||||
|
||||
|
||||
def update_related_object(self, context):
|
||||
if self.related_object:
|
||||
ifc_id = tool.Blender.get_object_bim_props(self.related_object).ifc_definition_id
|
||||
if ifc_id:
|
||||
bpy.ops.bim.aggregate_assign_object(related_object=ifc_id)
|
||||
bpy.ops.bim.disable_editing_aggregate()
|
||||
|
||||
|
||||
def update_aggregate_decorator(self, context):
|
||||
if self.aggregate_decorator:
|
||||
AggregateDecorator.install(bpy.context)
|
||||
@@ -89,12 +105,15 @@ def update_aggregate_mode_decorator(self, context):
|
||||
|
||||
class BIMObjectAggregateProperties(PropertyGroup):
|
||||
is_editing: BoolProperty(name="Is Editing")
|
||||
relating_object: PointerProperty(name="Relating Whole", type=bpy.types.Object, poll=poll_relating_object)
|
||||
relating_object: PointerProperty(
|
||||
name="Relating Whole", type=bpy.types.Object, poll=poll_relating_object, update=update_relating_object
|
||||
)
|
||||
related_object: PointerProperty(
|
||||
name="Related Part",
|
||||
description="Related Part, will be used to derive the Relating Object",
|
||||
type=bpy.types.Object,
|
||||
poll=poll_related_object,
|
||||
update=update_related_object,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
@@ -377,6 +377,8 @@ class EnableEditingBoundary(bpy.types.Operator):
|
||||
obj = tool.Ifc.get_object(entity)
|
||||
if entity and obj:
|
||||
setattr(bprops, blender_property, obj)
|
||||
bprops.physical_or_virtual = boundary.PhysicalOrVirtualBoundary or "NOTDEFINED"
|
||||
bprops.internal_or_external = boundary.InternalOrExternalBoundary or "NOTDEFINED"
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
@@ -392,6 +394,8 @@ class DisableEditingBoundary(bpy.types.Operator):
|
||||
bprops.is_editing = False
|
||||
for ifc_attribute, blender_property in EDITABLE_ATTRIBUTES.items():
|
||||
setattr(bprops, blender_property, None)
|
||||
bprops.physical_or_virtual = "NOTDEFINED"
|
||||
bprops.internal_or_external = "NOTDEFINED"
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
@@ -411,6 +415,8 @@ class EditBoundaryAttributes(bpy.types.Operator, tool.Ifc.Operator):
|
||||
obj = getattr(bprops, blender_property, None)
|
||||
entity = tool.Ifc.get_entity(obj)
|
||||
attributes[blender_property] = entity
|
||||
attributes["physical_or_virtual"] = bprops.physical_or_virtual
|
||||
attributes["internal_or_external"] = bprops.internal_or_external
|
||||
ifcopenshell.api.boundary.edit_attributes(tool.Ifc.get(), entity=boundary, **attributes)
|
||||
bpy.ops.bim.disable_editing_boundary()
|
||||
return {"FINISHED"}
|
||||
|
||||
@@ -21,6 +21,7 @@ from typing import TYPE_CHECKING, Union
|
||||
import bpy
|
||||
from bpy.props import (
|
||||
BoolProperty,
|
||||
EnumProperty,
|
||||
PointerProperty,
|
||||
)
|
||||
from bpy.types import PropertyGroup
|
||||
@@ -50,12 +51,43 @@ def element_filter(self: "BIMObjectBoundaryProperties", object: bpy.types.Object
|
||||
return False
|
||||
|
||||
|
||||
def get_internal_or_external_items(
|
||||
self: "BIMObjectBoundaryProperties", context: bpy.types.Context | None
|
||||
) -> list[tuple[str, str, str]]:
|
||||
items = [
|
||||
("INTERNAL", "Internal", ""),
|
||||
("EXTERNAL", "External", ""),
|
||||
]
|
||||
ifc = tool.Ifc.get()
|
||||
if not ifc or ifc.schema != "IFC2X3":
|
||||
items += [
|
||||
("EXTERNAL_EARTH", "External Earth", ""),
|
||||
("EXTERNAL_WATER", "External Water", ""),
|
||||
("EXTERNAL_FIRE", "External Fire", ""),
|
||||
]
|
||||
items.append(("NOTDEFINED", "Not Defined", ""))
|
||||
return items
|
||||
|
||||
|
||||
class BIMObjectBoundaryProperties(PropertyGroup):
|
||||
is_editing: BoolProperty(name="Is Editing")
|
||||
relating_space: PointerProperty(name="RelatingSpace", type=bpy.types.Object, poll=space_filter)
|
||||
related_building_element: PointerProperty(name="RelatedBuildingElement", type=bpy.types.Object, poll=element_filter)
|
||||
parent_boundary: PointerProperty(name="ParentBoundary", type=bpy.types.Object, poll=boundary_filter)
|
||||
corresponding_boundary: PointerProperty(name="CorrespondingBoundary", type=bpy.types.Object, poll=boundary_filter)
|
||||
physical_or_virtual: EnumProperty(
|
||||
name="PhysicalOrVirtualBoundary",
|
||||
items=[
|
||||
("PHYSICAL", "Physical", ""),
|
||||
("VIRTUAL", "Virtual", ""),
|
||||
("NOTDEFINED", "Not Defined", ""),
|
||||
],
|
||||
default="NOTDEFINED",
|
||||
)
|
||||
internal_or_external: EnumProperty(
|
||||
name="InternalOrExternalBoundary",
|
||||
items=get_internal_or_external_items,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
is_editing: bool
|
||||
@@ -63,6 +95,8 @@ class BIMObjectBoundaryProperties(PropertyGroup):
|
||||
related_building_element: Union[bpy.types.Object, None]
|
||||
parent_boundary: Union[bpy.types.Object, None]
|
||||
corresponding_boundary: Union[bpy.types.Object, None]
|
||||
physical_or_virtual: str
|
||||
internal_or_external: str # values depend on schema: IFC2X3 omits EXTERNAL_EARTH/WATER/FIRE
|
||||
|
||||
|
||||
class BIMBoundaryProperties(PropertyGroup):
|
||||
|
||||
@@ -77,6 +77,10 @@ class BIM_PT_Boundary(Panel):
|
||||
self.draw_relation_editor(boundary, "RelatedBuildingElement", "related_building_element")
|
||||
self.draw_relation_editor(boundary, "ParentBoundary", "parent_boundary")
|
||||
self.draw_relation_editor(boundary, "CorrespondingBoundary", "corresponding_boundary")
|
||||
row = self.layout.row()
|
||||
row.prop(self.bprops, "physical_or_virtual")
|
||||
row = self.layout.row()
|
||||
row.prop(self.bprops, "internal_or_external")
|
||||
else:
|
||||
row = self.layout.row()
|
||||
row.operator("bim.enable_editing_boundary", icon="GREASEPENCIL", text="Edit")
|
||||
@@ -84,6 +88,8 @@ class BIM_PT_Boundary(Panel):
|
||||
self.draw_relation_data(boundary, "RelatedBuildingElement")
|
||||
self.draw_relation_data(boundary, "ParentBoundary")
|
||||
self.draw_relation_data(boundary, "CorrespondingBoundary")
|
||||
self.draw_enum_data(boundary, "PhysicalOrVirtualBoundary")
|
||||
self.draw_enum_data(boundary, "InternalOrExternalBoundary")
|
||||
if hasattr(boundary, "InnerBoundaries"):
|
||||
for i, inner_boundary in enumerate(getattr(boundary, "InnerBoundaries", ())):
|
||||
row = self.layout.row(align=True)
|
||||
@@ -110,6 +116,11 @@ class BIM_PT_Boundary(Panel):
|
||||
else:
|
||||
row.label(text="")
|
||||
|
||||
def draw_enum_data(self, boundary, ifc_attribute: str):
|
||||
row = self.layout.row(align=True)
|
||||
row.label(text=ifc_attribute)
|
||||
row.label(text=getattr(boundary, ifc_attribute, "") or "")
|
||||
|
||||
def draw_relation_editor(self, boundary, ifc_attribute: str, blender_property: str):
|
||||
if hasattr(boundary, ifc_attribute):
|
||||
row = self.layout.row(align=True)
|
||||
|
||||
@@ -1426,6 +1426,7 @@ class CreateDrawing(bpy.types.Operator):
|
||||
"/Pset_.*Common/.Status",
|
||||
"EPset_Status.Status",
|
||||
"EPset_Status.UserDefinedStatus",
|
||||
"Material.Name",
|
||||
]
|
||||
|
||||
group = root.find("{http://www.w3.org/2000/svg}g")
|
||||
|
||||
@@ -69,6 +69,7 @@ classes = (
|
||||
operator.RemoveRepresentation,
|
||||
operator.RemoveRepresentationItem,
|
||||
operator.RemoveRepresentationItemFromShapeAspect,
|
||||
operator.SelectByRepresentationType,
|
||||
operator.SelectConnection,
|
||||
operator.SelectRepresentationItem,
|
||||
operator.SwitchRepresentation,
|
||||
|
||||
@@ -417,6 +417,55 @@ class SelectConnection(bpy.types.Operator, tool.Ifc.Operator):
|
||||
core.select_connection(tool.Geometry, connection=tool.Ifc.get().by_id(self.connection))
|
||||
|
||||
|
||||
class SelectByRepresentationType(bpy.types.Operator):
|
||||
bl_idname = "bim.select_by_representation_type"
|
||||
bl_label = "Select By Representation Type"
|
||||
bl_description = (
|
||||
"Select objects whose active representation matches this type. "
|
||||
"Ctrl+Click to also include objects that have this type in any representation (active or not)"
|
||||
)
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
representation_type: bpy.props.StringProperty()
|
||||
select_inactive: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"})
|
||||
|
||||
def invoke(self, context, event):
|
||||
self.select_inactive = event.ctrl
|
||||
return self.execute(context)
|
||||
|
||||
def execute(self, context):
|
||||
ifc = tool.Ifc.get()
|
||||
if not ifc:
|
||||
return {"CANCELLED"}
|
||||
# Strip the "*" suffix used for mapped/resolved representations.
|
||||
target_type = self.representation_type.rstrip("*")
|
||||
matched = 0
|
||||
for obj in context.visible_objects:
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element:
|
||||
obj.select_set(False)
|
||||
continue
|
||||
if self.select_inactive:
|
||||
# Ctrl: match any representation on the element, active or not.
|
||||
has_type = any(
|
||||
(ifcopenshell.util.representation.resolve_representation(rep).RepresentationType or "") == target_type
|
||||
for rep in ifcopenshell.util.representation.get_representations_iter(element)
|
||||
)
|
||||
else:
|
||||
# Default: match only the currently active (displayed) representation.
|
||||
active_rep = tool.Geometry.get_active_representation(obj)
|
||||
if active_rep is None:
|
||||
obj.select_set(False)
|
||||
continue
|
||||
resolved = ifcopenshell.util.representation.resolve_representation(active_rep)
|
||||
has_type = (resolved.RepresentationType or "") == target_type
|
||||
obj.select_set(has_type)
|
||||
if has_type:
|
||||
matched += 1
|
||||
mode = "any representation" if self.select_inactive else "active representation"
|
||||
self.report({"INFO"}, f"Selected {matched} object(s) with RepresentationType '{target_type}' ({mode})")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class RemoveConnection(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.remove_connection"
|
||||
bl_label = "Remove Connection"
|
||||
@@ -2289,7 +2338,7 @@ class OverrideModeSetEdit(bpy.types.Operator, tool.Ifc.Operator):
|
||||
elif obj in pprops.clipping_planes_objs:
|
||||
self.report({"ERROR"}, "Clipping planes cannot be edited")
|
||||
elif element:
|
||||
if not obj.data:
|
||||
if not obj.data or obj.type not in ("MESH", "CURVE"):
|
||||
self.report({"INFO"}, "No geometry to edit")
|
||||
elif tool.Geometry.is_locked(element):
|
||||
self.report({"ERROR"}, lock_error_message(obj.name))
|
||||
|
||||
@@ -152,7 +152,12 @@ class BIM_PT_representations(Panel):
|
||||
row.label(text=representation["ContextType"])
|
||||
row.label(text=representation["ContextIdentifier"])
|
||||
row.label(text=representation["TargetView"])
|
||||
row.label(text=representation["RepresentationType"])
|
||||
op = row.operator(
|
||||
"bim.select_by_representation_type",
|
||||
text=representation["RepresentationType"],
|
||||
emboss=False,
|
||||
)
|
||||
op.representation_type = representation["RepresentationType"]
|
||||
op = row.operator(
|
||||
"bim.switch_representation",
|
||||
icon="FILE_REFRESH" if representation["is_active"] else "OUTLINER_DATA_MESH",
|
||||
|
||||
@@ -34,8 +34,10 @@ classes = (
|
||||
operator.Fetch,
|
||||
operator.Merge,
|
||||
operator.ObjectLog,
|
||||
operator.SelectConflictEntity,
|
||||
operator.Push,
|
||||
operator.RefreshGit,
|
||||
operator.RenameBranch,
|
||||
operator.SwitchRevision,
|
||||
operator.InstallGit,
|
||||
operator.RunGitDiff,
|
||||
|
||||
@@ -21,65 +21,71 @@ class IfcGitData:
|
||||
|
||||
@classmethod
|
||||
def load(cls):
|
||||
repo = None
|
||||
if bool(tool.Ifc.get()):
|
||||
path_ifc = tool.Ifc.get_path()
|
||||
if os.path.isfile(path_ifc):
|
||||
repo = tool.IfcGit.repo_from_path(path_ifc)
|
||||
|
||||
cls.data = {
|
||||
"repo": cls.repo(),
|
||||
"remotes": cls.remotes(),
|
||||
"branch_names": cls.branch_names(),
|
||||
"remote_names": cls.remote_names(),
|
||||
"remote_urls": cls.remote_urls(),
|
||||
"repo": repo,
|
||||
"remotes": repo.remotes if repo else None,
|
||||
"branch_names": cls.branch_names(repo),
|
||||
"tag_names": cls.tag_names(repo),
|
||||
"remote_names": cls.remote_names(repo),
|
||||
"remote_urls": {r.name: r.url for r in repo.remotes} if repo else {},
|
||||
"path_ifc": cls.path_ifc(),
|
||||
"branches_by_hexsha": cls.branches_by_hexsha(),
|
||||
"tags_by_hexsha": cls.tags_by_hexsha(),
|
||||
"name_ifc": cls.name_ifc(),
|
||||
"name_ifc": cls.name_ifc(repo),
|
||||
"dir_name": cls.dir_name(),
|
||||
"base_name": cls.base_name(),
|
||||
"working_dir": cls.working_dir(),
|
||||
"untracked_files": cls.untracked_files(),
|
||||
"is_detached": cls.is_detached(),
|
||||
"active_branch_name": cls.active_branch_name(),
|
||||
"is_dirty": cls.is_dirty(),
|
||||
"commit": cls.commit(),
|
||||
"current_revision": cls.current_revision(),
|
||||
"working_dir": repo.working_dir if repo else None,
|
||||
"ifc_is_untracked": cls.ifc_is_untracked(repo),
|
||||
"is_detached": repo.head.is_detached if repo else None,
|
||||
"active_branch_name": repo.active_branch.name if repo and not repo.head.is_detached else None,
|
||||
"is_dirty": cls.is_dirty(repo),
|
||||
"current_revision": cls.current_revision(repo),
|
||||
"git_exe": cls.git_exe(),
|
||||
"ifcmerge_exe": cls.ifcmerge_exe(),
|
||||
}
|
||||
cls.is_loaded = True
|
||||
|
||||
@classmethod
|
||||
def repo(cls):
|
||||
if bool(tool.Ifc.get()):
|
||||
path_ifc = tool.Ifc.get_path()
|
||||
if os.path.isfile(path_ifc):
|
||||
return tool.IfcGit.repo_from_path(path_ifc)
|
||||
return None
|
||||
def branch_names(cls, repo):
|
||||
if not repo or not repo.heads:
|
||||
return []
|
||||
names = sorted([b.name for b in repo.branches])
|
||||
if "main" in names:
|
||||
names.remove("main")
|
||||
names = ["main"] + names
|
||||
if repo.remotes:
|
||||
for remote in repo.remotes:
|
||||
for ref in remote.refs:
|
||||
names.append(ref.name)
|
||||
return names
|
||||
|
||||
@classmethod
|
||||
def remotes(cls):
|
||||
if cls.repo():
|
||||
return cls.repo().remotes
|
||||
return None
|
||||
def tag_names(cls, repo):
|
||||
if not repo:
|
||||
return []
|
||||
return [t.name for t in repo.tags]
|
||||
|
||||
@classmethod
|
||||
def branch_names(cls):
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def remote_names(cls):
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def remote_urls(cls):
|
||||
result = {}
|
||||
if cls.repo():
|
||||
for remote in cls.repo().remotes:
|
||||
result[remote.name] = remote.url
|
||||
return result
|
||||
def remote_names(cls, repo):
|
||||
if not repo:
|
||||
return []
|
||||
names = sorted([r.name for r in repo.remotes])
|
||||
if "origin" in names:
|
||||
names.remove("origin")
|
||||
names = ["origin"] + names
|
||||
return names
|
||||
|
||||
@classmethod
|
||||
def path_ifc(cls):
|
||||
path_ifc = tool.Ifc.get_path()
|
||||
if os.path.isfile(path_ifc):
|
||||
return tool.Ifc.get_path()
|
||||
return path_ifc
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
@@ -88,7 +94,8 @@ class IfcGitData:
|
||||
if tool.IfcGitRepo.repo.branches:
|
||||
return tool.IfcGit.branches_by_hexsha(tool.IfcGitRepo.repo)
|
||||
except AttributeError:
|
||||
return {}
|
||||
pass
|
||||
return {}
|
||||
|
||||
@classmethod
|
||||
def tags_by_hexsha(cls):
|
||||
@@ -97,12 +104,11 @@ class IfcGitData:
|
||||
return {}
|
||||
|
||||
@classmethod
|
||||
def name_ifc(cls):
|
||||
if bool(tool.Ifc.get()):
|
||||
def name_ifc(cls, repo):
|
||||
if bool(tool.Ifc.get()) and repo:
|
||||
path_ifc = tool.Ifc.get_path()
|
||||
if tool.IfcGitRepo.repo and os.path.isfile(path_ifc):
|
||||
working_dir = tool.IfcGitRepo.repo.working_dir
|
||||
return os.path.relpath(path_ifc, working_dir)
|
||||
if os.path.isfile(path_ifc):
|
||||
return os.path.relpath(path_ifc, repo.working_dir)
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
@@ -122,49 +128,28 @@ class IfcGitData:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def working_dir(cls):
|
||||
if cls.repo():
|
||||
return cls.repo().working_dir
|
||||
def ifc_is_untracked(cls, repo):
|
||||
"""Return True if the IFC file exists in the repo but has not been added to git."""
|
||||
if not repo:
|
||||
return False
|
||||
path_ifc = tool.Ifc.get_path()
|
||||
if not os.path.isfile(path_ifc):
|
||||
return False
|
||||
return not bool(repo.git.ls_files(path_ifc))
|
||||
|
||||
@classmethod
|
||||
def untracked_files(cls):
|
||||
if cls.repo():
|
||||
return cls.repo().untracked_files
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def is_detached(cls):
|
||||
if cls.repo():
|
||||
return cls.repo().head.is_detached
|
||||
|
||||
@classmethod
|
||||
def active_branch_name(cls):
|
||||
if cls.repo() and not cls.is_detached():
|
||||
return cls.repo().active_branch.name
|
||||
|
||||
@classmethod
|
||||
def is_dirty(cls):
|
||||
if cls.repo() and cls.git_exe():
|
||||
def is_dirty(cls, repo):
|
||||
if repo and cls.git_exe():
|
||||
path_ifc = tool.Ifc.get_path()
|
||||
if os.path.isfile(path_ifc):
|
||||
return cls.repo().is_dirty(path=path_ifc)
|
||||
return repo.is_dirty(path=path_ifc)
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def commit(cls):
|
||||
def current_revision(cls, repo):
|
||||
props = tool.IfcGit.get_ifcgit_props()
|
||||
if cls.repo() and len(props.ifcgit_commits) > 0:
|
||||
item = props.ifcgit_commits[props.commit_index]
|
||||
try:
|
||||
return cls.repo().commit(rev=item.hexsha)
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
@classmethod
|
||||
def current_revision(cls):
|
||||
props = tool.IfcGit.get_ifcgit_props()
|
||||
if cls.repo() and cls.repo().head.is_valid() and len(props.ifcgit_commits) > 0:
|
||||
return tool.IfcGitRepo.repo.commit()
|
||||
if repo and repo.head.is_valid() and len(props.ifcgit_commits) > 0:
|
||||
return repo.commit()
|
||||
|
||||
@classmethod
|
||||
def git_exe(cls):
|
||||
|
||||
@@ -120,11 +120,11 @@ class CommitChanges(bpy.types.Operator):
|
||||
if props.commit_message == "":
|
||||
return False
|
||||
if repo:
|
||||
if props.new_branch_name in [branch.name for branch in repo.branches]:
|
||||
if props.new_branch_name in IfcGitData.data["branch_names"]:
|
||||
cls.poll_message_set("Branch already exists!")
|
||||
return False
|
||||
elif not tool.IfcGit.is_valid_ref_format(props.new_branch_name):
|
||||
if repo.head.is_detached:
|
||||
if IfcGitData.data["is_detached"]:
|
||||
cls.poll_message_set("Branch name is invalid or empty!")
|
||||
return False
|
||||
elif props.new_branch_name != "":
|
||||
@@ -134,10 +134,17 @@ class CommitChanges(bpy.types.Operator):
|
||||
|
||||
def execute(self, context):
|
||||
|
||||
repo = IfcGitData.data["repo"]
|
||||
core.commit_changes(tool.IfcGit, tool.Ifc, repo)
|
||||
core.refresh_revision_list(tool.IfcGit, repo, tool.Ifc)
|
||||
props = tool.IfcGit.get_ifcgit_props()
|
||||
commit_message = props.commit_message
|
||||
new_branch_name = props.new_branch_name
|
||||
core.commit_changes(tool.IfcGit, tool.Ifc, commit_message, new_branch_name)
|
||||
props.new_branch_name = ""
|
||||
props.commit_message = ""
|
||||
core.refresh_revision_list(tool.IfcGit, tool.Ifc)
|
||||
refresh()
|
||||
IfcGitData.load()
|
||||
if new_branch_name:
|
||||
props.display_branch = new_branch_name
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
@@ -157,7 +164,7 @@ class AddTag(bpy.types.Operator):
|
||||
repo = IfcGitData.data["repo"]
|
||||
if repo and (
|
||||
not tool.IfcGit.is_valid_ref_format(props.new_tag_name)
|
||||
or props.new_tag_name in [tag.name for tag in repo.tags]
|
||||
or props.new_tag_name in IfcGitData.data["tag_names"]
|
||||
):
|
||||
return False
|
||||
return True
|
||||
@@ -165,8 +172,12 @@ class AddTag(bpy.types.Operator):
|
||||
def execute(self, context):
|
||||
|
||||
repo = IfcGitData.data["repo"]
|
||||
core.add_tag(tool.IfcGit, repo)
|
||||
core.refresh_revision_list(tool.IfcGit, repo, tool.Ifc)
|
||||
props = tool.IfcGit.get_ifcgit_props()
|
||||
item = props.ifcgit_commits[props.commit_index]
|
||||
core.add_tag(tool.IfcGit, repo, item.hexsha, props.new_tag_name, props.new_tag_message)
|
||||
props.new_tag_name = ""
|
||||
props.new_tag_message = ""
|
||||
core.refresh_revision_list(tool.IfcGit, tool.Ifc)
|
||||
refresh()
|
||||
return {"FINISHED"}
|
||||
|
||||
@@ -183,7 +194,7 @@ class DeleteTag(bpy.types.Operator):
|
||||
|
||||
repo = IfcGitData.data["repo"]
|
||||
core.delete_tag(tool.IfcGit, repo, self.tag_name)
|
||||
core.refresh_revision_list(tool.IfcGit, repo, tool.Ifc)
|
||||
core.refresh_revision_list(tool.IfcGit, tool.Ifc)
|
||||
refresh()
|
||||
return {"FINISHED"}
|
||||
|
||||
@@ -191,7 +202,7 @@ class DeleteTag(bpy.types.Operator):
|
||||
class RefreshGit(bpy.types.Operator):
|
||||
"""Refresh revision list"""
|
||||
|
||||
bl_label = ""
|
||||
bl_label = "Refresh"
|
||||
bl_idname = "ifcgit.refresh"
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
@@ -205,8 +216,7 @@ class RefreshGit(bpy.types.Operator):
|
||||
|
||||
def execute(self, context):
|
||||
|
||||
repo = IfcGitData.data["repo"]
|
||||
core.refresh_revision_list(tool.IfcGit, repo, tool.Ifc)
|
||||
core.refresh_revision_list(tool.IfcGit, tool.Ifc)
|
||||
refresh()
|
||||
tool.IfcGit.decolourise()
|
||||
return {"FINISHED"}
|
||||
@@ -215,7 +225,7 @@ class RefreshGit(bpy.types.Operator):
|
||||
class DisplayRevision(bpy.types.Operator):
|
||||
"""Colourise objects by selected revision"""
|
||||
|
||||
bl_label = ""
|
||||
bl_label = "Colourise Revision"
|
||||
bl_idname = "ifcgit.display_revision"
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
@@ -250,7 +260,7 @@ class DisplayUncommitted(bpy.types.Operator):
|
||||
class SwitchRevision(bpy.types.Operator):
|
||||
"""Switches the repository to the selected revision and reloads the IFC file"""
|
||||
|
||||
bl_label = ""
|
||||
bl_label = "Switch Revision"
|
||||
bl_idname = "ifcgit.switch_revision"
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
@@ -268,7 +278,7 @@ class SwitchRevision(bpy.types.Operator):
|
||||
|
||||
|
||||
class Merge(bpy.types.Operator):
|
||||
"""Merges the selected branch into working branch"""
|
||||
"""Merges the selected branch into working branch.\nCtrl+click to preview without merging"""
|
||||
|
||||
bl_label = "Merge this branch"
|
||||
bl_idname = "ifcgit.merge"
|
||||
@@ -282,15 +292,84 @@ class Merge(bpy.types.Operator):
|
||||
return True
|
||||
return False
|
||||
|
||||
def execute(self, context):
|
||||
def invoke(self, context, event):
|
||||
if event.ctrl:
|
||||
core.dry_run_merge(tool.IfcGit, tool.Ifc, self)
|
||||
refresh()
|
||||
return {"FINISHED"}
|
||||
return self.execute(context)
|
||||
|
||||
if core.merge_branch(tool.IfcGit, tool.Ifc, self):
|
||||
def execute(self, context):
|
||||
if core.merge_branch(tool.IfcGit, tool.Ifc, self) is not False:
|
||||
refresh()
|
||||
return {"FINISHED"}
|
||||
else:
|
||||
return {"CANCELLED"}
|
||||
|
||||
|
||||
class SelectConflictEntity(bpy.types.Operator):
|
||||
"""Select the conflicting entity in the viewport"""
|
||||
|
||||
bl_label = "Select Conflict Entity"
|
||||
bl_idname = "ifcgit.select_conflict_entity"
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
step_id: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
|
||||
|
||||
if TYPE_CHECKING:
|
||||
step_id: int
|
||||
|
||||
def execute(self, context):
|
||||
model = tool.Ifc.get()
|
||||
if not model:
|
||||
return {"CANCELLED"}
|
||||
|
||||
try:
|
||||
entity = model.by_id(self.step_id)
|
||||
except Exception:
|
||||
self.report({"WARNING"}, f"Entity #{self.step_id} not found (may have been deleted locally)")
|
||||
return {"CANCELLED"}
|
||||
|
||||
obj = tool.Ifc.get_object(entity)
|
||||
if obj is None:
|
||||
# Walk inverse references up to 5 hops to find nearest entity with a Blender object
|
||||
visited = {entity.id()}
|
||||
queue = [entity]
|
||||
for _ in range(5):
|
||||
next_queue = []
|
||||
for ent in queue:
|
||||
for inv in model.get_inverse(ent):
|
||||
if inv.id() in visited:
|
||||
continue
|
||||
visited.add(inv.id())
|
||||
obj = tool.Ifc.get_object(inv)
|
||||
if obj is not None:
|
||||
break
|
||||
next_queue.append(inv)
|
||||
if obj is not None:
|
||||
break
|
||||
if obj is not None:
|
||||
break
|
||||
queue = next_queue
|
||||
|
||||
if obj is None:
|
||||
self.report({"INFO"}, f"No viewport representation found for #{self.step_id} ({entity.is_a()})")
|
||||
return {"CANCELLED"}
|
||||
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
obj.select_set(True)
|
||||
context.view_layer.objects.active = obj
|
||||
for area in context.screen.areas:
|
||||
if area.type == "VIEW_3D":
|
||||
region = next((r for r in area.regions if r.type == "WINDOW"), None)
|
||||
if region:
|
||||
with context.temp_override(area=area, region=region):
|
||||
bpy.ops.view3d.view_selected()
|
||||
break
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class Push(bpy.types.Operator):
|
||||
"""Pushes the working branch to selected remote"""
|
||||
|
||||
@@ -314,9 +393,9 @@ class Fetch(bpy.types.Operator):
|
||||
|
||||
def execute(self, context):
|
||||
props = tool.IfcGit.get_ifcgit_props()
|
||||
repo = IfcGitData.data["repo"]
|
||||
remote = repo.remotes[props.select_remote]
|
||||
remote.fetch()
|
||||
core.fetch(tool.IfcGit, props.select_remote)
|
||||
core.refresh_revision_list(tool.IfcGit, tool.Ifc)
|
||||
refresh()
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
@@ -336,7 +415,7 @@ class AddRemote(bpy.types.Operator):
|
||||
not repo
|
||||
or not tool.IfcGit.is_valid_ref_format(props.remote_name)
|
||||
or not props.remote_url
|
||||
or props.remote_name in [remote.name for remote in repo.remotes]
|
||||
or props.remote_name in IfcGitData.data["remote_names"]
|
||||
):
|
||||
return False
|
||||
return True
|
||||
@@ -344,8 +423,11 @@ class AddRemote(bpy.types.Operator):
|
||||
def execute(self, context):
|
||||
|
||||
repo = IfcGitData.data["repo"]
|
||||
core.add_remote(tool.IfcGit, repo)
|
||||
core.refresh_revision_list(tool.IfcGit, repo, tool.Ifc)
|
||||
props = tool.IfcGit.get_ifcgit_props()
|
||||
core.add_remote(tool.IfcGit, repo, props.remote_name, props.remote_url)
|
||||
props.remote_name = ""
|
||||
props.remote_url = ""
|
||||
core.refresh_revision_list(tool.IfcGit, tool.Ifc)
|
||||
refresh()
|
||||
return {"FINISHED"}
|
||||
|
||||
@@ -360,8 +442,19 @@ class DeleteRemote(bpy.types.Operator):
|
||||
def execute(self, context):
|
||||
|
||||
repo = IfcGitData.data["repo"]
|
||||
core.delete_remote(tool.IfcGit, repo)
|
||||
core.refresh_revision_list(tool.IfcGit, repo, tool.Ifc)
|
||||
props = tool.IfcGit.get_ifcgit_props()
|
||||
remote_name = props.select_remote
|
||||
if props.display_branch.startswith(remote_name + "/"):
|
||||
active = IfcGitData.data["active_branch_name"]
|
||||
if active:
|
||||
props.display_branch = active
|
||||
else:
|
||||
local_branches = [b for b in IfcGitData.data["branch_names"] if "/" not in b]
|
||||
if local_branches:
|
||||
props.display_branch = local_branches[0]
|
||||
core.delete_remote(tool.IfcGit, repo, remote_name)
|
||||
tool.IfcGit.select_first_remote()
|
||||
core.refresh_revision_list(tool.IfcGit, tool.Ifc)
|
||||
refresh()
|
||||
return {"FINISHED"}
|
||||
|
||||
@@ -375,8 +468,8 @@ class ObjectLog(bpy.types.Operator):
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
if not (obj := context.active_object):
|
||||
cls.poll_message_set("No Active Object")
|
||||
if not (obj := context.active_object) or not obj.select_get():
|
||||
cls.poll_message_set("No selected object")
|
||||
elif not tool.Blender.get_ifc_definition_id(obj):
|
||||
cls.poll_message_set("Active Object doesn't have an IFC definition")
|
||||
else:
|
||||
@@ -445,3 +538,37 @@ class RunGitDiff(bpy.types.Operator):
|
||||
def execute(self, context):
|
||||
core.run_git_diff(tool.IfcGit, self, self.save_to_temp)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class RenameBranch(bpy.types.Operator):
|
||||
"""Rename the current branch"""
|
||||
|
||||
bl_label = "Rename Branch"
|
||||
bl_idname = "ifcgit.rename_branch"
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
new_name: bpy.props.StringProperty(name="New name") # pyright: ignore[reportRedeclaration]
|
||||
|
||||
if TYPE_CHECKING:
|
||||
new_name: str
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
IfcGitData.make_sure_is_loaded()
|
||||
if not IfcGitData.data["repo"]:
|
||||
return False
|
||||
if IfcGitData.data["is_detached"]:
|
||||
return False
|
||||
if IfcGitData.data["is_dirty"]:
|
||||
return False
|
||||
return True
|
||||
|
||||
def invoke(self, context, event):
|
||||
self.new_name = IfcGitData.data["active_branch_name"]
|
||||
return context.window_manager.invoke_props_dialog(self)
|
||||
|
||||
def execute(self, context):
|
||||
repo = IfcGitData.data["repo"]
|
||||
core.rename_branch(tool.IfcGit, repo, self.new_name)
|
||||
refresh()
|
||||
return {"FINISHED"}
|
||||
|
||||
@@ -17,28 +17,14 @@ from bonsai.bim.module.ifcgit.data import IfcGitData
|
||||
def git_branches(self: "IfcGitProperties", context: bpy.types.Context) -> tool.Blender.BLENDER_ENUM_ITEMS:
|
||||
# NOTE "Python must keep a reference to the strings returned by
|
||||
# the callback or Blender will misbehave or even crash"
|
||||
IfcGitData.data["branch_names"] = sorted([branch.name for branch in IfcGitData.data["repo"].heads])
|
||||
|
||||
if "main" in IfcGitData.data["branch_names"]:
|
||||
IfcGitData.data["branch_names"].remove("main")
|
||||
IfcGitData.data["branch_names"] = ["main"] + IfcGitData.data["branch_names"]
|
||||
|
||||
if IfcGitData.data["remotes"]:
|
||||
for remote in IfcGitData.data["remotes"]:
|
||||
for remote_branch in remote.refs:
|
||||
IfcGitData.data["branch_names"].append(remote_branch.name)
|
||||
|
||||
return [(myname, myname, myname) for myname in IfcGitData.data["branch_names"]]
|
||||
# Branch list (local + remote, main first) is computed once in IfcGitData.load()
|
||||
IfcGitData.make_sure_is_loaded()
|
||||
return [(name, name, name) for name in IfcGitData.data["branch_names"]]
|
||||
|
||||
|
||||
def git_remotes(self: "IfcGitProperties", context: bpy.types.Context) -> tool.Blender.BLENDER_ENUM_ITEMS:
|
||||
IfcGitData.data["remote_names"] = sorted([remote.name for remote in IfcGitData.data["remotes"]])
|
||||
|
||||
if "origin" in IfcGitData.data["remote_names"]:
|
||||
IfcGitData.data["remote_names"].remove("origin")
|
||||
IfcGitData.data["remote_names"] = ["origin"] + IfcGitData.data["remote_names"]
|
||||
|
||||
return [(myname, myname, myname) for myname in IfcGitData.data["remote_names"]]
|
||||
IfcGitData.make_sure_is_loaded()
|
||||
return [(name, name, name) for name in IfcGitData.data["remote_names"]]
|
||||
|
||||
|
||||
def update_revlist(self: "IfcGitProperties", context: bpy.types.Context) -> None:
|
||||
@@ -90,6 +76,7 @@ class IfcGitListItem(PropertyGroup):
|
||||
name="Commit Message",
|
||||
default="",
|
||||
)
|
||||
committed_date: IntProperty(name="Committed Date", default=0)
|
||||
tags: CollectionProperty(type=IfcGitTag, name="List of revision tags")
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -98,6 +85,7 @@ class IfcGitListItem(PropertyGroup):
|
||||
author_name: str
|
||||
author_email: str
|
||||
message: str
|
||||
committed_date: int
|
||||
tags: bpy.types.bpy_prop_collection_idprop[IfcGitTag]
|
||||
|
||||
|
||||
@@ -151,6 +139,11 @@ class IfcGitProperties(PropertyGroup):
|
||||
],
|
||||
update=update_revlist,
|
||||
)
|
||||
merge_conflicts: StringProperty(
|
||||
name="Merge Conflicts",
|
||||
description="JSON report from last failed merge attempt",
|
||||
default="",
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
ifcgit_commits: bpy.types.bpy_prop_collection_idprop[IfcGitListItem]
|
||||
@@ -165,3 +158,4 @@ class IfcGitProperties(PropertyGroup):
|
||||
display_branch: str
|
||||
select_remote: str
|
||||
ifcgit_filter: Literal["all", "tagged", "relevant"]
|
||||
merge_conflicts: str
|
||||
|
||||
@@ -52,7 +52,7 @@ class IFCGIT_PT_panel(bpy.types.Panel):
|
||||
if IfcGitData.data["repo"] and os.path.exists(IfcGitData.data["repo"].git_dir):
|
||||
name_ifc = IfcGitData.data["name_ifc"]
|
||||
row.label(text=IfcGitData.data["working_dir"], icon="SYSTEM")
|
||||
if name_ifc in IfcGitData.data["untracked_files"]:
|
||||
if IfcGitData.data["ifc_is_untracked"]:
|
||||
row.operator(
|
||||
"ifcgit.addfile",
|
||||
text="Add '" + name_ifc + "' to repository",
|
||||
@@ -112,15 +112,13 @@ class IFCGIT_PT_panel(bpy.types.Panel):
|
||||
row.label(text="Working branch: Detached HEAD")
|
||||
else:
|
||||
row.label(text="Working branch: " + IfcGitData.data["active_branch_name"])
|
||||
row.operator("ifcgit.rename_branch", icon="GREASEPENCIL", text="")
|
||||
|
||||
grouped = layout.row()
|
||||
column = grouped.column()
|
||||
row = column.row()
|
||||
row = layout.row()
|
||||
row.prop(props, "display_branch", text="Browse branch")
|
||||
row.prop(props, "ifcgit_filter", text="Filter revisions")
|
||||
|
||||
row = column.row()
|
||||
row.template_list(
|
||||
layout.template_list(
|
||||
"COMMIT_UL_List",
|
||||
"The_List",
|
||||
props,
|
||||
@@ -128,20 +126,64 @@ class IFCGIT_PT_panel(bpy.types.Panel):
|
||||
props,
|
||||
"commit_index",
|
||||
)
|
||||
column = grouped.column()
|
||||
row = column.row()
|
||||
|
||||
row = layout.row(align=True)
|
||||
row.operator("ifcgit.refresh", icon="FILE_REFRESH")
|
||||
|
||||
if not is_dirty:
|
||||
|
||||
row = column.row()
|
||||
row.operator("ifcgit.display_revision", icon="SELECT_DIFFERENCE")
|
||||
|
||||
row = column.row()
|
||||
row.operator("ifcgit.switch_revision", icon="CURRENT_FILE")
|
||||
row.operator("ifcgit.merge", icon="SYSTEM")
|
||||
|
||||
row = column.row()
|
||||
row.operator("ifcgit.merge", icon="EXPERIMENTAL", text="")
|
||||
conflicts = tool.IfcGit.get_merge_conflicts()
|
||||
if conflicts is not None:
|
||||
box = layout.box()
|
||||
box.alert = True
|
||||
row = box.row()
|
||||
row.label(
|
||||
text=f"Merge failed \u2014 {len(conflicts)} conflict(s)",
|
||||
icon="ERROR",
|
||||
)
|
||||
for conflict in conflicts:
|
||||
col = box.column(align=True)
|
||||
conflict_type = conflict.get("type", "")
|
||||
entity_id = conflict.get("entity_id", "?")
|
||||
local_id = conflict.get("original_local_id")
|
||||
|
||||
if conflict_type == "attribute_conflict":
|
||||
entity_class = conflict.get("entity_class", "Entity")
|
||||
attr_idx = conflict.get("attribute_index", "?")
|
||||
desc = f"#{entity_id} {entity_class}: attribute {attr_idx} conflict"
|
||||
elif conflict_type == "entity_deleted_and_modified":
|
||||
entity_class = conflict.get("entity_class", "Entity")
|
||||
desc = f"#{entity_id} {entity_class}: " + conflict.get("message", "deleted/modified conflict")
|
||||
elif conflict_type == "class_changed":
|
||||
desc = (
|
||||
f"#{entity_id}: class changed "
|
||||
+ conflict.get("base_class", "?")
|
||||
+ " \u2192 "
|
||||
+ conflict.get("modified_class", "?")
|
||||
)
|
||||
elif conflict_type == "required_entity_deleted":
|
||||
desc = f"#{entity_id}: " + conflict.get("message", "required entity deleted")
|
||||
else:
|
||||
desc = f"#{entity_id}: {conflict_type}"
|
||||
|
||||
row = col.row(align=True)
|
||||
row.label(text=desc)
|
||||
if local_id:
|
||||
op = row.operator(
|
||||
"ifcgit.select_conflict_entity",
|
||||
text="",
|
||||
icon="RESTRICT_SELECT_OFF",
|
||||
)
|
||||
op.step_id = local_id
|
||||
|
||||
if conflict_type == "attribute_conflict":
|
||||
sub = col.column(align=True)
|
||||
sub.scale_y = 0.75
|
||||
sub.label(text=f" Base: {conflict.get('base_value', '')}")
|
||||
sub.label(text=f" Local: {conflict.get('local_value', '')}")
|
||||
sub.label(text=f" Remote: {conflict.get('remote_value', '')}")
|
||||
|
||||
if not props.ifcgit_commits:
|
||||
return
|
||||
@@ -216,13 +258,7 @@ class COMMIT_UL_List(bpy.types.UIList):
|
||||
):
|
||||
|
||||
current_revision = IfcGitData.data["current_revision"]
|
||||
|
||||
# TODO Figure how this "item" can be acesse in "data.py"
|
||||
# so it's possible to move the ".commit"
|
||||
try:
|
||||
commit = IfcGitData.data["repo"].commit(rev=item.hexsha)
|
||||
except ValueError:
|
||||
return
|
||||
current_hexsha = current_revision.hexsha if current_revision else None
|
||||
|
||||
lookup = IfcGitData.data["branches_by_hexsha"]
|
||||
refs = ""
|
||||
@@ -236,11 +272,11 @@ class COMMIT_UL_List(bpy.types.UIList):
|
||||
for tag in lookup[item.hexsha]:
|
||||
refs += "{" + tag.name + "} "
|
||||
|
||||
if commit == current_revision:
|
||||
layout.label(text="[HEAD] " + refs + commit.message.split("\n")[0], icon="DECORATE_KEYFRAME")
|
||||
if item.hexsha == current_hexsha:
|
||||
layout.label(text="[HEAD] " + refs + item.message.split("\n")[0], icon="DECORATE_KEYFRAME")
|
||||
else:
|
||||
layout.label(text=refs + commit.message.split("\n")[0], icon="DECORATE_ANIMATE")
|
||||
layout.label(text=time.strftime("%c", time.localtime(commit.committed_date)))
|
||||
layout.label(text=refs + item.message.split("\n")[0], icon="DECORATE_ANIMATE")
|
||||
layout.label(text=time.strftime("%c", time.localtime(item.committed_date)))
|
||||
|
||||
def draw_filter(self, context, layout):
|
||||
|
||||
|
||||
@@ -540,6 +540,16 @@ class AddBoolean(Operator, tool.Ifc.Operator):
|
||||
booleans = ifcopenshell.api.geometry.add_boolean(tool.Ifc.get(), first_item, second_items, props.operator)
|
||||
|
||||
rep_obj = tool.Geometry.get_geometry_props().representation_obj
|
||||
if booleans:
|
||||
# Users typically select two top-level items and expect the
|
||||
# operand to be absorbed into the boolean, not remain as a
|
||||
# standalone item alongside it.
|
||||
representation = tool.Geometry.get_active_representation(rep_obj)
|
||||
representation = ifcopenshell.util.representation.resolve_representation(representation)
|
||||
second_items_set = set(second_items)
|
||||
new_items = [i for i in representation.Items if i not in second_items_set]
|
||||
if new_items:
|
||||
representation.Items = new_items
|
||||
rep_element = tool.Ifc.get_entity(rep_obj)
|
||||
tool.Model.mark_manual_booleans(rep_element, booleans)
|
||||
tool.Geometry.reload_representation(rep_obj)
|
||||
|
||||
@@ -421,7 +421,7 @@ class PolylineOperator:
|
||||
tool.Polyline.calculate_x_y_and_z(context, self.input_ui, self.tool_state)
|
||||
|
||||
tool.Blender.update_viewport()
|
||||
return {"RUNNING_MODAL"}
|
||||
return {"RUNNING_MODAL"}
|
||||
|
||||
def set_offset(self, context: bpy.types.Context, relating_type: ifcopenshell.entity_instance) -> None:
|
||||
props = tool.Model.get_model_props()
|
||||
@@ -461,6 +461,7 @@ class PolylineOperator:
|
||||
self.tool_state.axis_method = None
|
||||
self.tool_state.plane_method = None
|
||||
self.tool_state.mode = "Mouse"
|
||||
tool.Raycast.clear_snap_objs()
|
||||
self.visible_objs = tool.Raycast.get_visible_objects(context)
|
||||
for obj in self.visible_objs:
|
||||
if bbox_2d := tool.Raycast.get_on_screen_2d_bounding_boxes(context, obj):
|
||||
|
||||
@@ -943,7 +943,7 @@ class EditObjectUI:
|
||||
if "LAYER2" in AuthoringData.data["selected_material_usages"]:
|
||||
row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row
|
||||
add_layout_hotkey_operator(
|
||||
cls.layout, "Extend To Underside", "S_E", bpy.ops.bim.extend_to_underside.__doc__, ui_context
|
||||
cls.layout, "Extend To Underside", "S_E", bpy.ops.bim.extend_walls_to_underside.__doc__, ui_context
|
||||
)
|
||||
|
||||
if AuthoringData.data["is_flippable_element"]:
|
||||
|
||||
@@ -1102,7 +1102,7 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
|
||||
else:
|
||||
return self.finish_loading_project(context)
|
||||
|
||||
def finish_loading_project(self, context):
|
||||
def finish_loading_project(self, context: bpy.types.Context) -> set["rna_enums.OperatorReturnItems"]:
|
||||
try:
|
||||
filepath = self.get_filepath()
|
||||
if not self.is_existing_ifc_file():
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import bpy
|
||||
@@ -384,6 +385,18 @@ class BIM_PT_new_project_wizard(Panel):
|
||||
row = self.layout.row()
|
||||
row.operator("bim.create_project")
|
||||
|
||||
if shutil.which("git"):
|
||||
git_props = context.scene.IfcGitProperties
|
||||
box = self.layout.box()
|
||||
row = box.row()
|
||||
row.label(text="Clone a remote Git repository")
|
||||
row = box.row()
|
||||
row.prop(git_props, "remote_url")
|
||||
row = box.row()
|
||||
row.prop(git_props, "local_folder")
|
||||
row = box.row()
|
||||
row.operator("ifcgit.clone_repo", icon="IMPORT")
|
||||
|
||||
|
||||
class BIM_PT_project_library(Panel):
|
||||
bl_label = "Project Library"
|
||||
|
||||
@@ -51,21 +51,11 @@ def add_instance_flooring_covering_from_cursor(
|
||||
if isinstance(space_polygon, str):
|
||||
return
|
||||
|
||||
bm = spatial.get_bmesh_from_polygon(space_polygon, h=0, polygon_is_si=True)
|
||||
name = "Covering"
|
||||
mesh = spatial.get_named_mesh_from_bmesh(name=name, bmesh=bm)
|
||||
|
||||
obj = spatial.get_named_obj_from_mesh(name, mesh)
|
||||
|
||||
obj = spatial.create_object("Covering")
|
||||
spatial.set_obj_origin_to_cursor_position_and_zero_elevation(obj)
|
||||
spatial.translate_obj_to_z_location(obj, z)
|
||||
points = spatial.get_2d_vertices_from_obj(obj)
|
||||
points = spatial.get_scaled_2d_vertices(points)
|
||||
spatial.assign_type_to_obj(obj)
|
||||
|
||||
spatial.assign_swept_area_outer_curve_from_2d_vertices(obj, vertices=points)
|
||||
body = spatial.get_body_representation(obj)
|
||||
spatial.regen_obj_representation(obj, body)
|
||||
spatial.set_covering_representation_from_polygon(obj, space_polygon, polygon_is_si=True)
|
||||
|
||||
|
||||
def add_instance_ceiling_covering_from_cursor(
|
||||
@@ -95,21 +85,11 @@ def add_instance_ceiling_covering_from_cursor(
|
||||
if isinstance(space_polygon, str):
|
||||
return
|
||||
|
||||
bm = spatial.get_bmesh_from_polygon(space_polygon, h=0, polygon_is_si=True)
|
||||
name = "Covering"
|
||||
mesh = spatial.get_named_mesh_from_bmesh(name=name, bmesh=bm)
|
||||
|
||||
obj = spatial.get_named_obj_from_mesh(name, mesh)
|
||||
|
||||
obj = spatial.create_object("Covering")
|
||||
spatial.set_obj_origin_to_cursor_position_and_zero_elevation(obj)
|
||||
spatial.translate_obj_to_z_location(obj, z + ceiling_height)
|
||||
points = spatial.get_2d_vertices_from_obj(obj)
|
||||
points = spatial.get_scaled_2d_vertices(points)
|
||||
spatial.assign_type_to_obj(obj)
|
||||
|
||||
spatial.assign_swept_area_outer_curve_from_2d_vertices(obj, vertices=points)
|
||||
body = spatial.get_body_representation(obj)
|
||||
spatial.regen_obj_representation(obj, body)
|
||||
spatial.set_covering_representation_from_polygon(obj, space_polygon, polygon_is_si=True)
|
||||
|
||||
|
||||
def regen_selected_covering_object(root: type[tool.Root], spatial: type[tool.Spatial]) -> None:
|
||||
@@ -127,19 +107,7 @@ def regen_selected_covering_object(root: type[tool.Root], spatial: type[tool.Spa
|
||||
if isinstance(space_polygon, str):
|
||||
return
|
||||
|
||||
bm = spatial.get_bmesh_from_polygon(space_polygon, h=0, polygon_is_si=True)
|
||||
|
||||
name = "Aux"
|
||||
mesh = spatial.get_named_mesh_from_bmesh(name=name, bmesh=bm)
|
||||
mesh = spatial.get_transformed_mesh_from_local_to_global(mesh)
|
||||
obj = spatial.get_named_obj_from_mesh(name, mesh)
|
||||
|
||||
points = spatial.get_2d_vertices_from_obj(obj)
|
||||
points = spatial.get_scaled_2d_vertices(points)
|
||||
|
||||
spatial.assign_swept_area_outer_curve_from_2d_vertices(active_obj, vertices=points)
|
||||
body = spatial.get_body_representation(active_obj)
|
||||
spatial.regen_obj_representation(active_obj, body)
|
||||
spatial.set_covering_representation_from_polygon(active_obj, space_polygon, polygon_is_si=True)
|
||||
|
||||
|
||||
# TODO CHECK IF IT IS POSSIBLE TO CREATE ONLY ONE CORE FUNCTION FOR _FROM_WALLS
|
||||
@@ -151,22 +119,13 @@ def add_instance_flooring_coverings_from_walls(root: type[tool.Root], spatial: t
|
||||
union = spatial.get_union_shape_from_selected_objects()
|
||||
for i, linear_ring in enumerate(union.interiors):
|
||||
poly = spatial.get_buffered_poly_from_linear_ring(linear_ring)
|
||||
bm = spatial.get_bmesh_from_polygon(poly, h=0, polygon_is_si=False)
|
||||
|
||||
name = "Covering" + str(i)
|
||||
obj = spatial.get_named_obj_from_bmesh(name, bmesh=bm)
|
||||
|
||||
spatial.set_obj_origin_to_bboxcenter(obj)
|
||||
obj = spatial.create_object(name)
|
||||
spatial.set_obj_origin_to_polygon_center(obj, poly, polygon_is_si=False)
|
||||
spatial.translate_obj_to_z_location(obj, z)
|
||||
|
||||
points = spatial.get_2d_vertices_from_obj(obj)
|
||||
points = spatial.get_scaled_2d_vertices(points)
|
||||
|
||||
spatial.assign_type_to_obj(obj)
|
||||
|
||||
spatial.assign_swept_area_outer_curve_from_2d_vertices(obj, vertices=points)
|
||||
body = spatial.get_body_representation(obj)
|
||||
spatial.regen_obj_representation(obj, body)
|
||||
spatial.set_covering_representation_from_polygon(obj, poly, polygon_is_si=False)
|
||||
|
||||
|
||||
def add_instance_ceiling_coverings_from_walls(
|
||||
@@ -179,22 +138,13 @@ def add_instance_ceiling_coverings_from_walls(
|
||||
union = spatial.get_union_shape_from_selected_objects()
|
||||
for i, linear_ring in enumerate(union.interiors):
|
||||
poly = spatial.get_buffered_poly_from_linear_ring(linear_ring)
|
||||
bm = spatial.get_bmesh_from_polygon(poly, h=0, polygon_is_si=False)
|
||||
|
||||
name = "Covering" + str(i)
|
||||
obj = spatial.get_named_obj_from_bmesh(name, bmesh=bm)
|
||||
|
||||
spatial.set_obj_origin_to_bboxcenter(obj)
|
||||
obj = spatial.create_object(name)
|
||||
spatial.set_obj_origin_to_polygon_center(obj, poly, polygon_is_si=False)
|
||||
spatial.translate_obj_to_z_location(obj, z)
|
||||
|
||||
points = spatial.get_2d_vertices_from_obj(obj)
|
||||
points = spatial.get_scaled_2d_vertices(points)
|
||||
|
||||
spatial.assign_type_to_obj(obj)
|
||||
|
||||
spatial.assign_swept_area_outer_curve_from_2d_vertices(obj, vertices=points)
|
||||
body = spatial.get_body_representation(obj)
|
||||
spatial.regen_obj_representation(obj, body)
|
||||
spatial.set_covering_representation_from_polygon(obj, poly, polygon_is_si=False)
|
||||
|
||||
|
||||
class NoDefaultContainer(Exception):
|
||||
|
||||
@@ -56,42 +56,50 @@ def discard_uncommitted(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc]) -> None:
|
||||
ifcgit.load_project(path_ifc)
|
||||
|
||||
|
||||
def commit_changes(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc], repo: git.Repo) -> None:
|
||||
def commit_changes(
|
||||
ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc], commit_message: str, new_branch_name: str = ""
|
||||
) -> None:
|
||||
"""Commit and create new branches as required"""
|
||||
path_ifc = ifc.get_path()
|
||||
|
||||
if repo.head.is_detached:
|
||||
ifcgit.git_commit(path_ifc)
|
||||
ifcgit.create_new_branch()
|
||||
if ifcgit.is_head_detached():
|
||||
ifcgit.git_commit(path_ifc, commit_message)
|
||||
ifcgit.create_new_branch(new_branch_name)
|
||||
else:
|
||||
ifcgit.checkout_new_branch(path_ifc)
|
||||
ifcgit.git_commit(path_ifc)
|
||||
if new_branch_name:
|
||||
ifcgit.checkout_new_branch(path_ifc, new_branch_name)
|
||||
ifcgit.git_commit(path_ifc, commit_message)
|
||||
|
||||
|
||||
def add_tag(ifcgit: type[tool.IfcGit], repo: git.Repo) -> None:
|
||||
ifcgit.add_tag(repo)
|
||||
def add_tag(ifcgit: type[tool.IfcGit], repo: git.Repo, hexsha: str, tag_name: str, tag_message: str = "") -> None:
|
||||
ifcgit.add_tag(repo, hexsha, tag_name, tag_message)
|
||||
|
||||
|
||||
def delete_tag(ifcgit: type[tool.IfcGit], repo: git.Repo, tag_name: git.TagReference) -> None:
|
||||
ifcgit.delete_tag(repo, tag_name)
|
||||
|
||||
|
||||
def add_remote(ifcgit: type[tool.IfcGit], repo: git.Repo) -> None:
|
||||
ifcgit.add_remote(repo)
|
||||
def add_remote(ifcgit: type[tool.IfcGit], repo: git.Repo, remote_name: str, remote_url: str) -> None:
|
||||
ifcgit.add_remote(repo, remote_name, remote_url)
|
||||
|
||||
|
||||
def delete_remote(ifcgit: type[tool.IfcGit], repo: git.Repo) -> None:
|
||||
ifcgit.delete_remote(repo)
|
||||
def delete_remote(ifcgit: type[tool.IfcGit], repo: git.Repo, remote_name: str) -> None:
|
||||
ifcgit.delete_remote(repo, remote_name)
|
||||
|
||||
|
||||
def rename_branch(ifcgit: type[tool.IfcGit], repo: git.Repo, new_name: str) -> None:
|
||||
ifcgit.rename_branch(repo, new_name)
|
||||
|
||||
|
||||
def push(ifcgit: type[tool.IfcGit], repo: git.Repo, remote_name: str, operator: bpy.types.Operator) -> None:
|
||||
error_message = ifcgit.push(repo, remote_name, repo.active_branch.name)
|
||||
error_message = ifcgit.push(repo, remote_name, ifcgit.get_active_branch_name())
|
||||
if error_message:
|
||||
operator.report({"ERROR"}, error_message)
|
||||
|
||||
|
||||
def refresh_revision_list(ifcgit: type[tool.IfcGit], repo: git.Repo, ifc: type[tool.Ifc]) -> None:
|
||||
if repo.heads:
|
||||
def refresh_revision_list(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc]) -> None:
|
||||
ifcgit.clear_merge_conflicts()
|
||||
if ifcgit.repo_has_commits():
|
||||
ifcgit.refresh_revision_list(ifc.get_path())
|
||||
|
||||
|
||||
@@ -125,10 +133,73 @@ def switch_revision(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc]) -> None:
|
||||
ifcgit.decolourise()
|
||||
|
||||
|
||||
def merge_branch(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc], operator: bpy.types.Operator) -> None:
|
||||
def merge_branch(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc], operator: bpy.types.Operator) -> bool | None:
|
||||
path_ifc = ifc.get_path()
|
||||
ifcgit.config_ifcmerge()
|
||||
ifcgit.execute_merge(path_ifc, operator)
|
||||
|
||||
branch_name = ifcgit.get_selected_branch()
|
||||
if branch_name is None:
|
||||
return
|
||||
|
||||
mergetool = ifcgit.get_merge_tool(branch_name)
|
||||
merge_result = ifcgit.git_merge(branch_name)
|
||||
|
||||
if merge_result == "error":
|
||||
operator.report({"ERROR"}, "Unknown IFC Merge failure")
|
||||
return False
|
||||
elif merge_result == "conflict":
|
||||
conflicts = ifcgit.git_mergetool(mergetool, path_ifc)
|
||||
if conflicts is not None:
|
||||
ifcgit.git_merge_abort()
|
||||
ifcgit.store_merge_conflicts(conflicts)
|
||||
operator.report({"WARNING"}, "Merge failed — see the conflict report in the panel below")
|
||||
return False
|
||||
ifcgit.commit_merge(path_ifc)
|
||||
|
||||
ifcgit.clear_merge_conflicts()
|
||||
ifcgit.set_display_branch()
|
||||
ifcgit.git_checkout(path_ifc)
|
||||
ifcgit.load_project(path_ifc)
|
||||
ifcgit.refresh_revision_list(path_ifc)
|
||||
ifcgit.decolourise()
|
||||
|
||||
|
||||
def dry_run_merge(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc], operator: bpy.types.Operator) -> None:
|
||||
path_ifc = ifc.get_path()
|
||||
ifcgit.config_ifcmerge()
|
||||
|
||||
branch_name = ifcgit.get_selected_branch()
|
||||
if branch_name is None:
|
||||
return
|
||||
|
||||
mergetool = ifcgit.get_merge_tool(branch_name)
|
||||
merge_result = ifcgit.git_merge_no_commit(branch_name)
|
||||
|
||||
if merge_result == "error":
|
||||
try:
|
||||
ifcgit.git_merge_abort()
|
||||
except Exception:
|
||||
pass
|
||||
operator.report({"ERROR"}, "Unknown IFC Merge failure")
|
||||
return
|
||||
|
||||
if merge_result == "conflict":
|
||||
conflicts = ifcgit.git_mergetool(mergetool, path_ifc)
|
||||
ifcgit.git_merge_abort()
|
||||
if conflicts is not None:
|
||||
ifcgit.store_merge_conflicts(conflicts)
|
||||
operator.report({"WARNING"}, "Merge preview: conflicts found — see the panel below")
|
||||
else:
|
||||
ifcgit.clear_merge_conflicts()
|
||||
operator.report({"INFO"}, "Merge preview: no conflicts")
|
||||
else:
|
||||
# Clean merge or already up to date — abort the pending merge state if any
|
||||
try:
|
||||
ifcgit.git_merge_abort()
|
||||
except Exception:
|
||||
pass
|
||||
ifcgit.clear_merge_conflicts()
|
||||
operator.report({"INFO"}, "Merge preview: no conflicts")
|
||||
|
||||
|
||||
def entity_log(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc], step_id: int, operator: bpy.types.Operator) -> None:
|
||||
@@ -145,5 +216,9 @@ def install_git(ifcgit: type[tool.IfcGit], operator: bpy.types.Operator) -> None
|
||||
print("install_git() not implemented")
|
||||
|
||||
|
||||
def fetch(ifcgit: type[tool.IfcGit], remote_name: str) -> None:
|
||||
ifcgit.fetch(remote_name)
|
||||
|
||||
|
||||
def run_git_diff(ifcgit: type[tool.IfcGit], operator: bpy.types.Operator, save_to_temp: bool) -> None:
|
||||
ifcgit.run_git_diff(operator, save_to_temp)
|
||||
|
||||
@@ -219,13 +219,8 @@ def generate_space(
|
||||
else:
|
||||
assert space_polygon
|
||||
|
||||
bm = spatial.get_bmesh_from_polygon(space_polygon, h=h, polygon_is_si=True)
|
||||
|
||||
mesh = spatial.get_named_mesh_from_bmesh(name="Space", bmesh=bm)
|
||||
|
||||
if element and element.is_a("IfcSpace"):
|
||||
mesh = spatial.get_transformed_mesh_from_local_to_global(mesh)
|
||||
spatial.edit_active_space_obj_from_mesh(mesh)
|
||||
spatial.set_space_representation_from_polygon(active_obj, element, space_polygon, h, polygon_is_si=True)
|
||||
spatial.translate_obj_to_z_location(active_obj, z)
|
||||
else:
|
||||
if relating_type:
|
||||
@@ -233,12 +228,13 @@ def generate_space(
|
||||
else:
|
||||
name = "Space"
|
||||
|
||||
obj = spatial.get_named_obj_from_mesh(name, mesh)
|
||||
obj = spatial.create_object(name)
|
||||
spatial.set_obj_origin_to_cursor_position_and_zero_elevation(obj)
|
||||
spatial.translate_obj_to_z_location(obj, z)
|
||||
spatial.assign_ifcspace_class_to_obj(obj)
|
||||
|
||||
element = ifc.get_entity(obj)
|
||||
spatial.set_space_representation_from_polygon(obj, element, space_polygon, h, polygon_is_si=True)
|
||||
|
||||
if relating_type:
|
||||
spatial.assign_relating_type_to_element(ifc, type, element, relating_type)
|
||||
@@ -257,16 +253,16 @@ def generate_spaces_from_walls(
|
||||
for i, linear_ring in enumerate(union.interiors):
|
||||
poly = spatial.get_buffered_poly_from_linear_ring(linear_ring)
|
||||
|
||||
bm = spatial.get_bmesh_from_polygon(poly, h, polygon_is_si=False)
|
||||
|
||||
name = "Space" + str(i)
|
||||
|
||||
obj = spatial.get_named_obj_from_bmesh(name, bmesh=bm)
|
||||
|
||||
spatial.set_obj_origin_to_bboxcenter_and_zero_elevation(obj)
|
||||
obj = spatial.create_object(name)
|
||||
spatial.set_obj_origin_to_polygon_center(obj, poly, polygon_is_si=False)
|
||||
spatial.translate_obj_to_z_location(obj, z)
|
||||
spatial.assign_ifcspace_class_to_obj(obj)
|
||||
|
||||
element = ifc.get_entity(obj)
|
||||
spatial.set_space_representation_from_polygon(obj, element, poly, h, polygon_is_si=False)
|
||||
|
||||
|
||||
def toggle_space_visibility(ifc: type[tool.Ifc], spatial: type[tool.Spatial]) -> None:
|
||||
model = ifc.get()
|
||||
|
||||
@@ -535,6 +535,60 @@ class Ifc:
|
||||
def get_all_element_occurrences(cls, element): pass
|
||||
|
||||
|
||||
@interface
|
||||
class IfcGit:
|
||||
def add_file_to_repo(cls, repo, path_file): pass
|
||||
def add_remote(cls, repo, remote_name, remote_url): pass
|
||||
def add_tag(cls, repo, hexsha, tag_name, tag_message): pass
|
||||
def branches_by_hexsha(cls, repo): pass
|
||||
def checkout_new_branch(cls, path_file, branch_name): pass
|
||||
def clear_commits_list(cls): pass
|
||||
def clone_repo(cls, remote_url, local_folder): pass
|
||||
def colourise(cls, step_ids): pass
|
||||
def config_ifcmerge(cls): pass
|
||||
def create_new_branch(cls, branch_name): pass
|
||||
def decolourise(cls): pass
|
||||
def delete_remote(cls, repo, remote_name): pass
|
||||
def delete_tag(cls, repo, tag_name): pass
|
||||
def dos2unix(cls, path_file): pass
|
||||
def commit_merge(cls, path_ifc): pass
|
||||
def entity_log(cls, path_ifc, step_id): pass
|
||||
def fetch(cls, remote_name): pass
|
||||
def get_commits_list(cls, path_ifc, lookup): pass
|
||||
def get_merge_tool(cls, branch_name): pass
|
||||
def get_selected_branch(cls): pass
|
||||
def git_merge(cls, branch_name): pass
|
||||
def git_merge_abort(cls): pass
|
||||
def git_merge_no_commit(cls, branch_name): pass
|
||||
def git_mergetool(cls, mergetool, path_ifc): pass
|
||||
def store_merge_conflicts(cls, conflicts): pass
|
||||
def clear_merge_conflicts(cls): pass
|
||||
def get_merge_conflicts(cls): pass
|
||||
def set_display_branch(cls): pass
|
||||
def get_active_branch_name(cls): pass
|
||||
def get_ifcgit_props(cls): pass
|
||||
def get_modified_step_ids(cls, step_ids): pass
|
||||
def get_path_dir(cls, path_ifc): pass
|
||||
def get_revisions_step_ids(cls): pass
|
||||
def is_head_detached(cls): pass
|
||||
def repo_has_commits(cls): pass
|
||||
def git_checkout(cls, path_file): pass
|
||||
def git_commit(cls, path_file, commit_message): pass
|
||||
def ifc_diff_ids(cls, repo, hash_a, hash_b, path_ifc): pass
|
||||
def init_repo(cls, path_dir): pass
|
||||
def install_git_windows(cls, operator): pass
|
||||
def is_valid_ref_format(cls, string): pass
|
||||
def load_anyifc(cls, repo): pass
|
||||
def load_project(cls, path_ifc): pass
|
||||
def push(cls, repo, remote_name, branch_name): pass
|
||||
def refresh_revision_list(cls, path_ifc): pass
|
||||
def repo_from_path(cls, path): pass
|
||||
def run_git_diff(cls, operator, save_to_temp): pass
|
||||
def switch_to_revision_item(cls): pass
|
||||
def tags_by_hexsha(cls, repo): pass
|
||||
def update_step_ids(cls, step_ids, modified_step_ids): pass
|
||||
|
||||
|
||||
@interface
|
||||
class Layer:
|
||||
pass
|
||||
@@ -999,14 +1053,12 @@ class Spatial:
|
||||
def get_purged_inner_holes_poly(cls, union_geom, min_area): pass
|
||||
def get_poly_valid_interior_list(cls, poly, min_area, interiors_list): pass
|
||||
def get_buffered_poly_from_linear_ring(cls, linear_ring): pass
|
||||
def get_bmesh_from_polygon(cls, poly, h, polygon_is_si=False): pass
|
||||
def get_named_obj_from_bmesh(cls, name, bmesh): pass
|
||||
def get_named_obj_from_mesh(cls, name, mesh): pass
|
||||
def get_named_mesh_from_bmesh(cls, name, bmesh): pass
|
||||
def get_transformed_mesh_from_local_to_global(cls, mesh): pass
|
||||
def edit_active_space_obj_from_mesh(cls, mesh): pass
|
||||
def set_obj_origin_to_bboxcenter(cls, obj): pass
|
||||
def set_obj_origin_to_bboxcenter_and_zero_elevation(cls, obj): pass
|
||||
def get_2d_vertices_from_polygon(cls, poly, obj, polygon_is_si=True): pass
|
||||
def set_extrusion_representation_from_polygon(cls, obj, element, poly, depth_ifc, polygon_is_si=True): pass
|
||||
def set_space_representation_from_polygon(cls, obj, element, poly, h, polygon_is_si=True): pass
|
||||
def set_covering_representation_from_polygon(cls, obj, poly, polygon_is_si=True): pass
|
||||
def create_object(cls, name): pass
|
||||
def set_obj_origin_to_polygon_center(cls, obj, poly, polygon_is_si=True): pass
|
||||
def set_obj_origin_to_cursor_position_and_zero_elevation(cls, obj): pass
|
||||
def get_selected_objects(cls): pass
|
||||
def get_active_obj(cls): pass
|
||||
@@ -1014,14 +1066,9 @@ class Spatial:
|
||||
def get_active_obj_height(cls): pass
|
||||
def get_relating_type_id(cls): pass
|
||||
def translate_obj_to_z_location(cls, obj, z): pass
|
||||
def get_2d_vertices_from_obj(cls, obj): pass
|
||||
def get_scaled_2d_vertices(cls, points): pass
|
||||
def assign_swept_area_outer_curve_from_2d_vertices(cls, obj, vertices): pass
|
||||
def get_body_representation(cls, obj): pass
|
||||
def assign_ifcspace_class_to_obj(cls, obj): pass
|
||||
def assign_type_to_obj(cls, obj): pass
|
||||
def assign_relating_type_to_element(cls, ifc, type, element, relating_type): pass
|
||||
def regen_obj_representation(cls, obj, body): pass
|
||||
def toggle_spaces_visibility_wired_and_textured(cls, spaces): pass
|
||||
def toggle_hide_spaces(cls, spaces): pass
|
||||
def set_default_container(cls, container): pass
|
||||
|
||||
@@ -49,21 +49,40 @@ class Aggregate(bonsai.core.tool.Aggregate):
|
||||
related_object = tool.Ifc.get_entity(related_obj)
|
||||
if not relating_object or not related_object:
|
||||
return False
|
||||
if relating_object == related_object:
|
||||
return False
|
||||
|
||||
is_compatible_class = False
|
||||
if (relating_object.is_a("IfcElement") or relating_object.is_a("IfcElementType")) and related_object.is_a(
|
||||
"IfcElement"
|
||||
):
|
||||
return True
|
||||
if tool.Ifc.get_schema() == "IFC2X3":
|
||||
is_compatible_class = True
|
||||
elif tool.Ifc.get_schema() == "IFC2X3":
|
||||
if relating_object.is_a("IfcSpatialStructureElement") and related_object.is_a("IfcSpatialStructureElement"):
|
||||
return True
|
||||
if relating_object.is_a("IfcProject") and related_object.is_a("IfcSpatialStructureElement"):
|
||||
return True
|
||||
is_compatible_class = True
|
||||
elif relating_object.is_a("IfcProject") and related_object.is_a("IfcSpatialStructureElement"):
|
||||
is_compatible_class = True
|
||||
else:
|
||||
if relating_object.is_a("IfcSpatialElement") and related_object.is_a("IfcSpatialElement"):
|
||||
return True
|
||||
if relating_object.is_a("IfcProject") and related_object.is_a("IfcSpatialElement"):
|
||||
return True
|
||||
return False
|
||||
is_compatible_class = True
|
||||
elif relating_object.is_a("IfcProject") and related_object.is_a("IfcSpatialElement"):
|
||||
is_compatible_class = True
|
||||
|
||||
if not is_compatible_class:
|
||||
return False
|
||||
|
||||
# Prevent cyclic references: walk up the full hierarchy from the
|
||||
# proposed parent and reject if we encounter the proposed child.
|
||||
ancestor = ifcopenshell.util.element.get_parent(relating_object)
|
||||
seen = {relating_object}
|
||||
while ancestor:
|
||||
if ancestor == related_object:
|
||||
return False
|
||||
if ancestor in seen:
|
||||
break
|
||||
seen.add(ancestor)
|
||||
ancestor = ifcopenshell.util.element.get_parent(ancestor)
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def has_physical_body_representation(cls, element: ifcopenshell.entity_instance) -> bool:
|
||||
|
||||
@@ -857,6 +857,8 @@ class Drawing(bonsai.core.tool.Drawing):
|
||||
|
||||
@classmethod
|
||||
def edit_text_literals(cls, obj: bpy.types.Object, literal_attributes: dict) -> None:
|
||||
if not literal_attributes:
|
||||
return
|
||||
assert (element := tool.Ifc.get_entity(obj))
|
||||
assert (rep := cls.get_annotation_representation(element))
|
||||
to_remove = [i for i in rep.Items if i.is_a("IfcTextLiteral")]
|
||||
|
||||
@@ -1407,8 +1407,6 @@ class Geometry(bonsai.core.tool.Geometry):
|
||||
:param representation_item: item to remove.
|
||||
:param element: item's element. Is used to unmark manual booleans.
|
||||
"""
|
||||
# NOTE: we assume it's not the last representation item
|
||||
# otherwise we probably would need to remove representation too
|
||||
# NOTE: a lot of shared code with `geometry.remove_representation`
|
||||
ifc_file = tool.Ifc.get()
|
||||
shape_aspects: list[ifcopenshell.entity_instance] = []
|
||||
@@ -1467,7 +1465,10 @@ class Geometry(bonsai.core.tool.Geometry):
|
||||
cls.remove_representation_items_from_shape_aspect([representation_item], shape_aspect)
|
||||
|
||||
if representation:
|
||||
representation.Items = tuple(set(representation.Items) - {representation_item})
|
||||
new_items = tuple(set(representation.Items) - {representation_item})
|
||||
if not new_items:
|
||||
return
|
||||
representation.Items = new_items
|
||||
also_consider = list(consider_inverses)
|
||||
ifcopenshell.util.element.remove_deep2(ifc_file, representation_item, also_consider=also_consider)
|
||||
|
||||
|
||||
@@ -197,12 +197,16 @@ class Ifc(bonsai.core.tool.Ifc):
|
||||
if not cls.get():
|
||||
return
|
||||
|
||||
# Clear all per-object msgbus subscriptions at once using the dedicated
|
||||
# owner. After undo/redo, per-object Python wrappers have new
|
||||
# identities so clearing by individual obj would miss stale
|
||||
# subscriptions registered with the old wrappers.
|
||||
bpy.msgbus.clear_by_owner(bonsai.bim.handler.object_subscription_owner)
|
||||
|
||||
for obj in bpy.data.objects:
|
||||
if obj.library:
|
||||
continue
|
||||
|
||||
bpy.msgbus.clear_by_owner(obj)
|
||||
|
||||
element = cls.get_entity(obj)
|
||||
if not element:
|
||||
continue
|
||||
@@ -217,8 +221,6 @@ class Ifc(bonsai.core.tool.Ifc):
|
||||
if obj.library:
|
||||
continue
|
||||
|
||||
bpy.msgbus.clear_by_owner(obj)
|
||||
|
||||
style = cls.get_entity(obj)
|
||||
if not style:
|
||||
continue
|
||||
|
||||
@@ -18,13 +18,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Literal, Union
|
||||
from typing import TYPE_CHECKING, Any, Union
|
||||
|
||||
import bpy
|
||||
|
||||
@@ -128,39 +129,27 @@ class IfcGit:
|
||||
cls.dos2unix(path_file)
|
||||
repo.index.add(os.path.normpath(path_file))
|
||||
repo.index.commit(message="Added " + os.path.relpath(path_file, repo.working_dir))
|
||||
bpy.ops.ifcgit.refresh()
|
||||
|
||||
@classmethod
|
||||
def git_checkout(cls, path_file: str) -> None:
|
||||
IfcGitRepo.repo.git.checkout(path_file)
|
||||
|
||||
@classmethod
|
||||
def checkout_new_branch(cls, path_file: str) -> None:
|
||||
def checkout_new_branch(cls, path_file: str, branch_name: str) -> None:
|
||||
"""Create a branch and move uncommitted changes to this branch"""
|
||||
props = cls.get_ifcgit_props()
|
||||
if props.new_branch_name:
|
||||
IfcGitRepo.repo.git.checkout(b=props.new_branch_name)
|
||||
props.display_branch = props.new_branch_name
|
||||
props.new_branch_name = ""
|
||||
bpy.ops.ifcgit.refresh()
|
||||
IfcGitRepo.repo.git.checkout(b=branch_name)
|
||||
|
||||
@classmethod
|
||||
def git_commit(cls, path_file: str) -> None:
|
||||
props = cls.get_ifcgit_props()
|
||||
def git_commit(cls, path_file: str, commit_message: str) -> None:
|
||||
repo = IfcGitRepo.repo
|
||||
if os.name == "nt":
|
||||
cls.dos2unix(path_file)
|
||||
repo.index.add(os.path.normpath(path_file))
|
||||
repo.index.commit(message=props.commit_message)
|
||||
props.commit_message = ""
|
||||
repo.index.commit(message=commit_message)
|
||||
|
||||
@classmethod
|
||||
def add_tag(cls, repo: git.Repo) -> None:
|
||||
props = cls.get_ifcgit_props()
|
||||
item = props.ifcgit_commits[props.commit_index]
|
||||
repo.create_tag(props.new_tag_name, ref=item.hexsha, message=props.new_tag_message)
|
||||
props.new_tag_name = ""
|
||||
props.new_tag_message = ""
|
||||
def add_tag(cls, repo: git.Repo, hexsha: str, tag_name: str, tag_message: str = "") -> None:
|
||||
repo.create_tag(tag_name, ref=hexsha, message=tag_message)
|
||||
|
||||
@classmethod
|
||||
def delete_tag(cls, repo: git.Repo, tag_name: git.TagReference) -> None:
|
||||
@@ -168,20 +157,17 @@ class IfcGit:
|
||||
repo.delete_tag(tag_name)
|
||||
|
||||
@classmethod
|
||||
def add_remote(cls, repo: git.Repo) -> None:
|
||||
props = cls.get_ifcgit_props()
|
||||
repo.create_remote(name=props.remote_name, url=props.remote_url)
|
||||
props.remote_name = ""
|
||||
props.remote_url = ""
|
||||
def rename_branch(cls, repo: git.Repo, new_name: str) -> None:
|
||||
repo.active_branch.rename(new_name)
|
||||
|
||||
@classmethod
|
||||
def delete_remote(cls, repo: git.Repo) -> None:
|
||||
props = cls.get_ifcgit_props()
|
||||
remote_name = props.select_remote
|
||||
def add_remote(cls, repo: git.Repo, remote_name: str, remote_url: str) -> None:
|
||||
repo.create_remote(name=remote_name, url=remote_url)
|
||||
|
||||
@classmethod
|
||||
def delete_remote(cls, repo: git.Repo, remote_name: str) -> None:
|
||||
if remote_name in repo.remotes:
|
||||
repo.delete_remote(remote_name)
|
||||
if repo.remotes:
|
||||
props.select_remote = repo.remotes[0].name
|
||||
|
||||
@classmethod
|
||||
def push(cls, repo: git.Repo, remote_name: str, branch_name: str) -> Union[str, None]:
|
||||
@@ -193,16 +179,25 @@ class IfcGit:
|
||||
return exc.stderr
|
||||
|
||||
@classmethod
|
||||
def create_new_branch(cls) -> None:
|
||||
"""Convert a detached HEAD into a branch"""
|
||||
props = cls.get_ifcgit_props()
|
||||
repo = IfcGitRepo.repo
|
||||
new_branch = repo.create_head(props.new_branch_name)
|
||||
new_branch.checkout()
|
||||
props.display_branch = props.new_branch_name
|
||||
props.new_branch_name = ""
|
||||
def is_head_detached(cls) -> bool:
|
||||
return bool(IfcGitRepo.repo.head.is_detached)
|
||||
|
||||
bpy.ops.ifcgit.refresh()
|
||||
@classmethod
|
||||
def repo_has_commits(cls) -> bool:
|
||||
if IfcGitRepo.repo:
|
||||
return bool(IfcGitRepo.repo.heads)
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def get_active_branch_name(cls) -> str:
|
||||
return IfcGitRepo.repo.active_branch.name
|
||||
|
||||
@classmethod
|
||||
def create_new_branch(cls, branch_name: str) -> None:
|
||||
"""Convert a detached HEAD into a branch"""
|
||||
repo = IfcGitRepo.repo
|
||||
new_branch = repo.create_head(branch_name)
|
||||
new_branch.checkout()
|
||||
|
||||
@classmethod
|
||||
def clear_commits_list(cls) -> None:
|
||||
@@ -243,6 +238,7 @@ class IfcGit:
|
||||
list_item.message = commit.message
|
||||
list_item.author_name = commit.author.name
|
||||
list_item.author_email = commit.author.email
|
||||
list_item.committed_date = int(commit.committed_date)
|
||||
if commit in commits_relevant:
|
||||
list_item.relevant = True
|
||||
if commit.hexsha in lookup:
|
||||
@@ -286,14 +282,23 @@ class IfcGit:
|
||||
|
||||
bpy.data.orphans_purge(do_recursive=True) # ty:ignore[unknown-argument]
|
||||
|
||||
from bonsai.bim.module.root.data import IfcClassData
|
||||
from bonsai.bim.module.model.data import AuthoringData
|
||||
import bonsai.bim.handler
|
||||
|
||||
AuthoringData.type_thumbnails = {}
|
||||
|
||||
IfcClassData.is_loaded = False
|
||||
|
||||
settings = import_ifc.IfcImportSettings.factory(bpy.context, path_ifc, logging.getLogger("ImportIFC"))
|
||||
settings.should_setup_viewport_camera = False
|
||||
ifc_importer = import_ifc.IfcImporter(settings)
|
||||
ifc_importer.execute()
|
||||
tool.Project.load_project_pset_templates()
|
||||
tool.Project.load_default_thumbnails()
|
||||
tool.Project.set_default_context()
|
||||
tool.Project.set_default_modeling_dimensions()
|
||||
tool.Root.reload_grid_decorator()
|
||||
bonsai.bim.handler.refresh_ui_data()
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
|
||||
@classmethod
|
||||
@@ -393,20 +398,43 @@ class IfcGit:
|
||||
model = tool.Ifc.get()
|
||||
modified_step_ids = {"modified": set()}
|
||||
|
||||
for step_id in step_ids["modified"] | step_ids["added"]:
|
||||
try:
|
||||
entity = model.by_id(step_id)
|
||||
except:
|
||||
continue
|
||||
if entity.is_a("IfcProductDefinitionShape"):
|
||||
def collect(entity, depth=0):
|
||||
if depth > 2:
|
||||
return
|
||||
if entity.is_a("IfcProduct"):
|
||||
modified_step_ids["modified"].add(entity.id())
|
||||
elif entity.is_a("IfcProductDefinitionShape"):
|
||||
for product in entity.ShapeOfProduct:
|
||||
modified_step_ids["modified"].add(product.id())
|
||||
elif entity.is_a("IfcObjectPlacement"):
|
||||
for product in entity.PlacesObject:
|
||||
modified_step_ids["modified"].add(product.id())
|
||||
elif entity.is_a("IfcTypeProduct") and entity.Types:
|
||||
for related_object in entity.Types[0].RelatedObjects:
|
||||
modified_step_ids["modified"].add(related_object.id())
|
||||
elif entity.is_a("IfcTypeProduct"):
|
||||
for rel in entity.Types:
|
||||
for obj in rel.RelatedObjects:
|
||||
modified_step_ids["modified"].add(obj.id())
|
||||
elif entity.is_a("IfcShapeRepresentation"):
|
||||
for prod_rep in entity.OfProductRepresentation:
|
||||
for product in prod_rep.ShapeOfProduct:
|
||||
modified_step_ids["modified"].add(product.id())
|
||||
elif entity.is_a("IfcRepresentationItem"):
|
||||
for referencing in model.get_inverse(entity):
|
||||
if referencing.is_a("IfcShapeRepresentation"):
|
||||
collect(referencing, depth + 1)
|
||||
elif entity.is_a("IfcPropertySet"):
|
||||
for rel in entity.DefinesOccurrence:
|
||||
for obj in rel.RelatedObjects:
|
||||
modified_step_ids["modified"].add(obj.id())
|
||||
elif entity.is_a("IfcProperty"):
|
||||
for pset in entity.PartOfPset:
|
||||
collect(pset, depth + 1)
|
||||
|
||||
for step_id in step_ids["modified"] | step_ids["added"]:
|
||||
try:
|
||||
entity = model.by_id(step_id)
|
||||
except:
|
||||
continue
|
||||
collect(entity)
|
||||
|
||||
return modified_step_ids
|
||||
|
||||
@@ -458,38 +486,56 @@ class IfcGit:
|
||||
if item.hexsha in lookup:
|
||||
for branch in lookup[item.hexsha]:
|
||||
if branch.name == props.display_branch:
|
||||
if isinstance(branch, git.RemoteReference):
|
||||
# Checking out a remote branch tip goes to detached HEAD.
|
||||
# Pre-fill the new branch name field with the local equivalent
|
||||
# so the user isn't blocked from committing without a hint.
|
||||
local_name = branch.remote_head
|
||||
props.new_branch_name = cls._unique_branch_name(repo, local_name)
|
||||
branch.checkout()
|
||||
return
|
||||
# NOTE this is calling the git binary in a subprocess
|
||||
repo.git.checkout(item.hexsha)
|
||||
|
||||
@classmethod
|
||||
def _unique_branch_name(cls, repo: git.Repo, name: str) -> str:
|
||||
"""Return name if unused, otherwise name-2, name-3, etc."""
|
||||
existing = {h.name for h in repo.heads}
|
||||
if name not in existing:
|
||||
return name
|
||||
i = 2
|
||||
while f"{name}-{i}" in existing:
|
||||
i += 1
|
||||
return f"{name}-{i}"
|
||||
|
||||
@classmethod
|
||||
def delete_collection(cls, blender_collection: bpy.types.Collection) -> None:
|
||||
for obj in blender_collection.objects:
|
||||
bpy.data.objects.remove(obj, do_unlink=True)
|
||||
bpy.data.collections.remove(blender_collection)
|
||||
|
||||
@classmethod
|
||||
def is_valid_branch_name(cls, new_branch_name: str):
|
||||
"""Check if a branch name is valid and doesn't conflict with existing branches"""
|
||||
if not cls.is_valid_ref_format(new_branch_name):
|
||||
return False
|
||||
if new_branch_name in [branch.name for branch in IfcGitRepo.repo.branches]:
|
||||
return False
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def config_ifcmerge(cls) -> None:
|
||||
config_reader = IfcGitRepo.repo.config_reader()
|
||||
section = 'mergetool "ifcmerge"'
|
||||
new_cmd = "ifcmerge $BASE $LOCAL $REMOTE $MERGED > $MERGED.ifcmerge"
|
||||
if not config_reader.has_section(section):
|
||||
with IfcGitRepo.repo.config_writer() as config_writer:
|
||||
config_writer.set_value(section, "cmd", "ifcmerge $BASE $LOCAL $REMOTE $MERGED")
|
||||
config_writer.set_value(section, "cmd", new_cmd)
|
||||
config_writer.set_value(section, "trustExitCode", True)
|
||||
elif config_reader.get_value(section, "cmd") != new_cmd:
|
||||
with IfcGitRepo.repo.config_writer() as config_writer:
|
||||
config_writer.set_value(section, "cmd", new_cmd)
|
||||
config_writer.set_value(section, "trustExitCode", True)
|
||||
section = 'mergetool "ifcmerge-forward"'
|
||||
new_cmd = "ifcmerge --prioritise-local $BASE $LOCAL $REMOTE $MERGED > $MERGED.ifcmerge"
|
||||
if not config_reader.has_section(section):
|
||||
with IfcGitRepo.repo.config_writer() as config_writer:
|
||||
config_writer.set_value(section, "cmd", "ifcmerge $BASE $REMOTE $LOCAL $MERGED")
|
||||
config_writer.set_value(section, "cmd", new_cmd)
|
||||
config_writer.set_value(section, "trustExitCode", True)
|
||||
elif config_reader.get_value(section, "cmd") != new_cmd:
|
||||
with IfcGitRepo.repo.config_writer() as config_writer:
|
||||
config_writer.set_value(section, "cmd", new_cmd)
|
||||
config_writer.set_value(section, "trustExitCode", True)
|
||||
|
||||
@classmethod
|
||||
@@ -519,49 +565,113 @@ class IfcGit:
|
||||
output.write(line + b"\n")
|
||||
|
||||
@classmethod
|
||||
def execute_merge(cls, path_ifc: str, operator: bpy.types.Operator) -> Union[None, Literal[False]]:
|
||||
def get_selected_branch(cls) -> Union[str, None]:
|
||||
"""Return the name of the branch at the selected commit matching display_branch, or None."""
|
||||
props = cls.get_ifcgit_props()
|
||||
repo = IfcGitRepo.repo
|
||||
item = props.ifcgit_commits[props.commit_index]
|
||||
lookup = cls.branches_by_hexsha(repo)
|
||||
if item.hexsha in lookup:
|
||||
for branch in lookup[item.hexsha]:
|
||||
if branch.name == props.display_branch:
|
||||
# this is a branch!
|
||||
if re.match("^(origin/)?(HEAD|main|master)$", branch.name):
|
||||
# preserve remote IDs in origin/main or main
|
||||
mergetool = "ifcmerge"
|
||||
else:
|
||||
# rewrite remote IDs
|
||||
mergetool = "ifcmerge-forward"
|
||||
try:
|
||||
# NOTE this is calling the git binary in a subprocess
|
||||
repo.git.merge(branch)
|
||||
except git.exc.GitCommandError:
|
||||
# merge is expected to fail, run ifcmerge
|
||||
try:
|
||||
repo.git.mergetool(tool=mergetool)
|
||||
except git.exc.GitCommandError as exc:
|
||||
message = re.sub("( stderr: '|')", "", exc.stderr)
|
||||
# ifcmerge failed, rollback
|
||||
repo.git.merge(abort=True)
|
||||
if item.hexsha not in lookup:
|
||||
return None
|
||||
for branch in lookup[item.hexsha]:
|
||||
if branch.name == props.display_branch:
|
||||
return branch.name
|
||||
return None
|
||||
|
||||
operator.report({"ERROR"}, "IFC Merge failed:" + message)
|
||||
return False
|
||||
else:
|
||||
if os.name == "nt":
|
||||
cls.dos2unix(path_ifc)
|
||||
repo.index.add(os.path.normpath(path_ifc))
|
||||
repo.git.commit("--no-edit")
|
||||
except git.exc.GitError:
|
||||
operator.report({"ERROR"}, "Unknown IFC Merge failure")
|
||||
return False
|
||||
@classmethod
|
||||
def get_merge_tool(cls, branch_name: str) -> str:
|
||||
if re.match("^(origin/)?(HEAD|main|master)$", branch_name):
|
||||
return "ifcmerge"
|
||||
return "ifcmerge-forward"
|
||||
|
||||
props.display_branch = repo.active_branch.name
|
||||
@classmethod
|
||||
def git_merge(cls, branch_name: str) -> Union[str, None]:
|
||||
"""Attempt a git merge. Returns None on clean merge, 'conflict' on expected
|
||||
GitCommandError, or 'error' on an unknown GitError."""
|
||||
repo = IfcGitRepo.repo
|
||||
branch = repo.branches[branch_name]
|
||||
try:
|
||||
repo.git.merge(branch)
|
||||
return None
|
||||
except git.exc.GitCommandError:
|
||||
return "conflict"
|
||||
except git.exc.GitError:
|
||||
return "error"
|
||||
|
||||
cls.load_project(path_ifc)
|
||||
cls.refresh_revision_list(path_ifc)
|
||||
cls.decolourise()
|
||||
@classmethod
|
||||
def git_merge_no_commit(cls, branch_name: str) -> Union[str, None]:
|
||||
"""Attempt a git merge without committing (always leaves a merge state to abort).
|
||||
Returns None on clean merge, 'conflict' on conflict, or 'error' on unknown failure."""
|
||||
repo = IfcGitRepo.repo
|
||||
branch = repo.branches[branch_name]
|
||||
try:
|
||||
repo.git.merge(branch, no_commit=True, no_ff=True)
|
||||
return None
|
||||
except git.exc.GitCommandError:
|
||||
return "conflict"
|
||||
except git.exc.GitError:
|
||||
return "error"
|
||||
|
||||
@classmethod
|
||||
def git_mergetool(cls, mergetool: str, path_ifc: str) -> Union[list, None]:
|
||||
"""Run ifcmerge tool. Returns None on success, list of conflict dicts on failure."""
|
||||
repo = IfcGitRepo.repo
|
||||
report_path = path_ifc + ".ifcmerge"
|
||||
try:
|
||||
repo.git.mergetool(tool=mergetool)
|
||||
except git.exc.GitCommandError:
|
||||
pass
|
||||
|
||||
conflicts = None
|
||||
if os.path.exists(report_path):
|
||||
try:
|
||||
with open(report_path) as f:
|
||||
content = f.read().strip()
|
||||
if content:
|
||||
data = json.loads(content)
|
||||
conflicts = data.get("conflicts", [])
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
try:
|
||||
os.remove(report_path)
|
||||
except OSError:
|
||||
pass
|
||||
return conflicts
|
||||
|
||||
@classmethod
|
||||
def store_merge_conflicts(cls, conflicts: list) -> None:
|
||||
cls.get_ifcgit_props().merge_conflicts = json.dumps(conflicts)
|
||||
|
||||
@classmethod
|
||||
def clear_merge_conflicts(cls) -> None:
|
||||
cls.get_ifcgit_props().merge_conflicts = ""
|
||||
|
||||
@classmethod
|
||||
def get_merge_conflicts(cls) -> Union[list, None]:
|
||||
raw = cls.get_ifcgit_props().merge_conflicts
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def git_merge_abort(cls) -> None:
|
||||
IfcGitRepo.repo.git.merge(abort=True)
|
||||
|
||||
@classmethod
|
||||
def commit_merge(cls, path_ifc: str) -> None:
|
||||
repo = IfcGitRepo.repo
|
||||
if os.name == "nt":
|
||||
cls.dos2unix(path_ifc)
|
||||
repo.index.add(os.path.normpath(path_ifc))
|
||||
repo.git.commit("--no-edit")
|
||||
|
||||
@classmethod
|
||||
def set_display_branch(cls) -> None:
|
||||
props = cls.get_ifcgit_props()
|
||||
props.display_branch = IfcGitRepo.repo.active_branch.name
|
||||
|
||||
@classmethod
|
||||
def entity_log(cls, path_ifc: str, step_id: int) -> str:
|
||||
@@ -589,6 +699,18 @@ class IfcGit:
|
||||
except FileNotFoundError:
|
||||
operator.report({"ERROR"}, "Winget is not available. Make sure Windows Package Manager is installed.")
|
||||
|
||||
@classmethod
|
||||
def select_first_remote(cls) -> None:
|
||||
props = cls.get_ifcgit_props()
|
||||
repo = IfcGitRepo.repo
|
||||
if repo and repo.remotes:
|
||||
props.select_remote = repo.remotes[0].name
|
||||
|
||||
@classmethod
|
||||
def fetch(cls, remote_name: str) -> None:
|
||||
repo = IfcGitRepo.repo
|
||||
repo.remotes[remote_name].fetch()
|
||||
|
||||
@classmethod
|
||||
def run_git_diff(cls, operator: bpy.types.Operator, save_to_temp: bool) -> None:
|
||||
path = tool.Ifc.get_path()
|
||||
|
||||
@@ -45,9 +45,23 @@ class Nest(bonsai.core.tool.Nest):
|
||||
related_object = tool.Ifc.get_entity(related_obj)
|
||||
if not relating_object or not related_object:
|
||||
return False
|
||||
if relating_object.is_a("IfcElement") and related_object.is_a("IfcElement"):
|
||||
return True
|
||||
return False
|
||||
if relating_object == related_object:
|
||||
return False
|
||||
is_compatible_class = relating_object.is_a("IfcElement") and related_object.is_a("IfcElement")
|
||||
if not is_compatible_class:
|
||||
return False
|
||||
# Prevent cyclic references: walk up the full hierarchy from the
|
||||
# proposed parent and reject if we encounter the proposed child.
|
||||
ancestor = ifcopenshell.util.element.get_parent(relating_object)
|
||||
seen = {relating_object}
|
||||
while ancestor:
|
||||
if ancestor == related_object:
|
||||
return False
|
||||
if ancestor in seen:
|
||||
break
|
||||
seen.add(ancestor)
|
||||
ancestor = ifcopenshell.util.element.get_parent(ancestor)
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def disable_editing(cls, obj: bpy.types.Object) -> None:
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Union
|
||||
|
||||
import bmesh
|
||||
@@ -24,9 +27,11 @@ import mathutils
|
||||
import numpy as np
|
||||
from mathutils import Vector
|
||||
|
||||
from bpy_extras import view3d_utils
|
||||
|
||||
import bonsai.core.tool
|
||||
import bonsai.tool as tool
|
||||
|
||||
from bpy_extras import view3d_utils
|
||||
|
||||
class Raycast(bonsai.core.tool.Raycast):
|
||||
offset = 10
|
||||
@@ -41,6 +46,7 @@ class Raycast(bonsai.core.tool.Raycast):
|
||||
(0, -offset),
|
||||
(offset, -offset),
|
||||
)
|
||||
snap_objs = []
|
||||
|
||||
@classmethod
|
||||
def get_visible_objects(cls, context: bpy.types.Context):
|
||||
@@ -69,8 +75,10 @@ class Raycast(bonsai.core.tool.Raycast):
|
||||
rv3d = context.region_data
|
||||
assert rv3d
|
||||
view_location = rv3d.view_matrix.inverted().translation
|
||||
view_normal = rv3d.view_rotation @ mathutils.Vector((0.0, 0.0, -1.0))
|
||||
obj_matrix = obj.matrix_world.copy()
|
||||
bbox = [obj_matrix @ Vector(v) for v in obj.bound_box]
|
||||
bbox_edges = [(0, 1), (1, 2), (2, 3), (3, 0), (4, 5), (5, 6), (6, 7), (7, 4), (0, 4), (1, 5), (2, 6), (3, 7)]
|
||||
|
||||
transposed_bbox: list[Vector] = []
|
||||
bbox_2d: list[float] = []
|
||||
@@ -94,8 +102,25 @@ class Raycast(bonsai.core.tool.Raycast):
|
||||
|
||||
for v in bbox:
|
||||
coord_2d = tool.Cad.location_3d_to_region_2d_np(context.region, context.space_data.region_3d, v)
|
||||
if coord_2d is not None:
|
||||
transposed_bbox.append(coord_2d)
|
||||
transposed_bbox.append(coord_2d)
|
||||
|
||||
if not any(transposed_bbox):
|
||||
transposed_bbox = []
|
||||
# If there are None values in transposed_bbox it means that there are vertices behind the camera
|
||||
# so we get the intersection of the edge with the region border
|
||||
# new_bbox = []
|
||||
if any(transposed_bbox) and not all(transposed_bbox):
|
||||
new_bbox = transposed_bbox.copy()
|
||||
new_bbox = [x for x in new_bbox if x is not None]
|
||||
for edge in bbox_edges:
|
||||
if (transposed_bbox[edge[0]] is None) ^ (transposed_bbox[edge[1]] is None):
|
||||
point, _ = cls.intersect_edge_region_border(
|
||||
context.region, context.space_data, rv3d, bbox[edge[0]], bbox[edge[1]]
|
||||
)
|
||||
if point:
|
||||
new_bbox.append(point)
|
||||
if new_bbox:
|
||||
transposed_bbox = new_bbox
|
||||
|
||||
region = context.region
|
||||
borders = (0, region.width, 0, region.height)
|
||||
@@ -117,6 +142,96 @@ class Raycast(bonsai.core.tool.Raycast):
|
||||
return (obj, bbox_2d)
|
||||
return None
|
||||
|
||||
def intersect_edge_region_border(region, space, rv3d, v1, v2):
|
||||
def segment_intersect_near_plane(view_matrix, clip_start, p_world_a, p_world_b):
|
||||
a_view = view_matrix @ p_world_a
|
||||
b_view = view_matrix @ p_world_b
|
||||
z_near = -clip_start
|
||||
za = a_view.z
|
||||
zb = b_view.z
|
||||
denom = zb - za
|
||||
if denom == 0.0:
|
||||
return None, None
|
||||
t = (z_near - za) / denom
|
||||
if t < 0.0 or t > 1.0:
|
||||
return None, None
|
||||
p_view = a_view.lerp(b_view, t)
|
||||
cam_world = view_matrix.inverted()
|
||||
p_world = cam_world @ p_view
|
||||
return p_world, t
|
||||
|
||||
def is_inside_region(pt2d, region):
|
||||
return 0.0 <= pt2d.x <= region.width and 0.0 <= pt2d.y <= region.height
|
||||
|
||||
def clamp_to_region_border(point2d, region):
|
||||
x, y = point2d
|
||||
x_clamped = max(0.0, min(region.width, x))
|
||||
y_clamped = max(0.0, min(region.height, y))
|
||||
return Vector((x_clamped, y_clamped))
|
||||
|
||||
def find_nearby_onscreen_point(region, rv3d, p1, p2, initial_t_on_segment, max_iters=40, step=0.05):
|
||||
"""
|
||||
Use iterative approach: move t toward 0. Returns the first point that is inside region border
|
||||
"""
|
||||
t = initial_t_on_segment
|
||||
for i in range(max_iters):
|
||||
test_3d = p1.lerp(p2, t)
|
||||
test_2d = view3d_utils.location_3d_to_region_2d(region, rv3d, test_3d)
|
||||
if test_2d is not None and is_inside_region(test_2d, region):
|
||||
return test_3d, test_2d, t
|
||||
# move t toward 0 by reducing it by a fraction of its current value
|
||||
t -= step
|
||||
# if t is already very small, break
|
||||
if t <= 1e-6:
|
||||
break
|
||||
|
||||
return None, None, None
|
||||
|
||||
# Ensures that all the calculation uses the same direction based on which point is on the screen
|
||||
if view3d_utils.location_3d_to_region_2d(region, rv3d, v1):
|
||||
onscreen_vert = v1
|
||||
offscreen_vert = v2
|
||||
else:
|
||||
onscreen_vert = v2
|
||||
offscreen_vert = v1
|
||||
# v2, v1 = v1, v2
|
||||
|
||||
clip_start = space.clip_start
|
||||
view_mat = rv3d.view_matrix
|
||||
inter_world, t_on_ab = segment_intersect_near_plane(view_mat, clip_start, onscreen_vert, offscreen_vert)
|
||||
|
||||
if inter_world is None:
|
||||
print("No intersection with viewport near plane found for the segment.")
|
||||
return None, None
|
||||
|
||||
init_2d = view3d_utils.location_3d_to_region_2d(region, rv3d, inter_world)
|
||||
|
||||
if init_2d is not None and is_inside_region(init_2d, region):
|
||||
final_world = inter_world
|
||||
final_2d = init_2d
|
||||
final_t = t_on_ab
|
||||
else:
|
||||
found_world, found_2d, found_t = find_nearby_onscreen_point(
|
||||
region, rv3d, onscreen_vert, offscreen_vert, t_on_ab, max_iters=600, step=0.01
|
||||
)
|
||||
if found_world is None:
|
||||
if init_2d is None:
|
||||
print("Initial projection invalid and iterative search failed.")
|
||||
return None, None
|
||||
# fallback: clamp projected point to border via manual mapping
|
||||
final_2d = clamp_to_region_border(init_2d, region)
|
||||
final_world = None
|
||||
final_t = None
|
||||
# print("Iterative search failed; using clamped 2D:", final_2d)
|
||||
else:
|
||||
final_world = found_world
|
||||
final_2d = found_2d
|
||||
final_t = found_t
|
||||
# print(f"Found onscreen point at t={final_t:.4f}")
|
||||
|
||||
# print("Final 2D:", final_2d)
|
||||
return final_2d, v2
|
||||
|
||||
@classmethod
|
||||
def intersect_mouse_2d_bounding_box(cls, mouse_pos: tuple[int, int], bbox: list[float, float, float, float]):
|
||||
x, y = mouse_pos
|
||||
@@ -232,6 +347,158 @@ class Raycast(bonsai.core.tool.Raycast):
|
||||
else:
|
||||
return None, None, None
|
||||
|
||||
@classmethod
|
||||
def ray_cast_by_proximity_2d(
|
||||
cls,
|
||||
context: bpy.types.Context,
|
||||
event: bpy.types.Event,
|
||||
snap_obj: SnapObj,
|
||||
):
|
||||
|
||||
def divide_vector(start, end, n):
|
||||
points = []
|
||||
delta = (end - start) / n
|
||||
for i in range(1, n):
|
||||
point = start + i * delta
|
||||
points.append(point)
|
||||
return points
|
||||
|
||||
region = context.region
|
||||
rv3d = context.region_data
|
||||
mouse_pos = event.mouse_region_x, event.mouse_region_y
|
||||
ray_origin, ray_target, ray_direction = cls.get_viewport_ray_data(context, event)
|
||||
points = []
|
||||
|
||||
try:
|
||||
loc = tool.Cad.region_2d_to_location_3d_np(region, rv3d, mouse_pos, ray_direction)
|
||||
except:
|
||||
loc = Vector((0, 0, 0))
|
||||
|
||||
verts_2d = [
|
||||
view3d_utils.location_3d_to_region_2d(region, rv3d, v) for v in snap_obj.verts_3d
|
||||
] # Numpy version is worst in performance
|
||||
|
||||
intersected = snap_obj.raycast_boxes(
|
||||
context, event, snap_obj.root, intersected=[], rays=(ray_origin, ray_direction)
|
||||
)
|
||||
edges = []
|
||||
for it in intersected:
|
||||
edges.extend(it.edges)
|
||||
edges = set(edges)
|
||||
|
||||
edge_verts = {}
|
||||
for e in edges:
|
||||
verts_idx = tuple(snap_obj.obj.data.edges[e].vertices)
|
||||
verts = snap_obj.obj.data.vertices
|
||||
v1 = snap_obj.obj.matrix_world @ verts[verts_idx[0]].co
|
||||
v1_2d = verts_2d[verts_idx[0]]
|
||||
v2 = snap_obj.obj.matrix_world @ verts[verts_idx[1]].co
|
||||
v2_2d = verts_2d[verts_idx[1]]
|
||||
if (v1_2d is None) ^ (v2_2d is None):
|
||||
point, _ = cls.intersect_edge_region_border(region, context.space_data, rv3d, v1, v2)
|
||||
if v1_2d is None:
|
||||
edge_verts[e] = (point, v2_2d)
|
||||
else:
|
||||
edge_verts[e] = (v1_2d, point)
|
||||
else:
|
||||
edge_verts[e] = (v1_2d, v2_2d)
|
||||
|
||||
snap_threshold = 10.0
|
||||
|
||||
for i, point in enumerate(verts_2d):
|
||||
if not point:
|
||||
continue
|
||||
distance = (Vector(mouse_pos) - point).length
|
||||
if distance <= snap_threshold:
|
||||
snap_point = {
|
||||
"object": snap_obj.obj,
|
||||
"type": "Vertex",
|
||||
"point": snap_obj.verts_3d[i],
|
||||
"distance": distance / 10,
|
||||
}
|
||||
points.append(snap_point)
|
||||
|
||||
count = 0
|
||||
selected_edges = {}
|
||||
for e in edges:
|
||||
p0, p1 = edge_verts[e]
|
||||
p0x, p0y = p0
|
||||
p1x, p1y = p1
|
||||
px, py = mouse_pos
|
||||
|
||||
# segment vector = p1 - p0
|
||||
sx = p1x - p0x
|
||||
sy = p1y - p0y
|
||||
|
||||
# seg length squared
|
||||
seg_len_sq = sx * sx + sy * sy
|
||||
|
||||
if seg_len_sq == 0.0:
|
||||
# degenerate segment: skip it
|
||||
continue
|
||||
|
||||
# project (p - p0) onto seg: t = dot(p-p0, seg) / |seg|^2
|
||||
apx = px - p0x
|
||||
apy = py - p0y
|
||||
t = (apx * sx + apy * sy) / seg_len_sq
|
||||
|
||||
# clamp to segment
|
||||
if t <= 0.0:
|
||||
t_clamped = 0.0
|
||||
cx, cy = p0x, p0y
|
||||
elif t >= 1.0:
|
||||
t_clamped = 1.0
|
||||
cx, cy = p1x, p1y
|
||||
else:
|
||||
t_clamped = t
|
||||
cx = p0x + sx * t_clamped
|
||||
cy = p0y + sy * t_clamped
|
||||
|
||||
dx = px - cx
|
||||
dy = py - cy
|
||||
dist = math.hypot(dx, dy)
|
||||
if dist <= snap_threshold:
|
||||
selected_edges[dist] = e
|
||||
|
||||
if selected_edges:
|
||||
min_dist = float("inf")
|
||||
for key in selected_edges:
|
||||
if key < min_dist:
|
||||
min_dist = key
|
||||
|
||||
idx = snap_obj.obj.data.edges[selected_edges[min_dist]].vertices
|
||||
edge_verts = (snap_obj.verts_3d[idx[0]], snap_obj.verts_3d[idx[1]])
|
||||
division_points = divide_vector(
|
||||
edge_verts[0], edge_verts[1], 2
|
||||
) # TODO Make it work for different divisions
|
||||
for division_point in division_points:
|
||||
intersection = tool.Cad.point_on_edge(division_point, (ray_target, loc))
|
||||
distance = (division_point - intersection).length
|
||||
if distance < snap_threshold:
|
||||
snap_point = {
|
||||
"object": snap_obj.obj,
|
||||
"type": "Edge Center",
|
||||
"point": division_point.copy(),
|
||||
"distance": distance,
|
||||
}
|
||||
points.append(snap_point)
|
||||
|
||||
intersection = tool.Cad.intersect_edges_v2((ray_target, loc), edge_verts)
|
||||
if intersection[0]:
|
||||
if tool.Cad.is_point_on_edge(intersection[1], edge_verts):
|
||||
distance = (intersection[1] - intersection[0]).length
|
||||
if distance < snap_threshold:
|
||||
snap_point = {
|
||||
"object": snap_obj.obj,
|
||||
"type": "Edge",
|
||||
"point": intersection[1].copy(),
|
||||
"edge_verts": edge_verts,
|
||||
"distance": distance,
|
||||
}
|
||||
points.append(snap_point)
|
||||
|
||||
return points
|
||||
|
||||
@classmethod
|
||||
def ray_cast_by_proximity(
|
||||
cls,
|
||||
@@ -457,7 +724,9 @@ class Raycast(bonsai.core.tool.Raycast):
|
||||
if bbox_2d:
|
||||
if tool.Raycast.intersect_mouse_2d_bounding_box(mouse_pos, bbox_2d):
|
||||
if tool.Raycast.object_is_visible_in_clipping_plane(obj):
|
||||
objs_to_raycast.append(obj)
|
||||
snap_obj = cls.create_snap_obj(obj)
|
||||
if snap_obj is not None:
|
||||
objs_to_raycast.append(snap_obj)
|
||||
|
||||
return objs_to_raycast
|
||||
|
||||
@@ -474,12 +743,6 @@ class Raycast(bonsai.core.tool.Raycast):
|
||||
face_index = None
|
||||
# Wireframes
|
||||
if obj.type in {"EMPTY", "CURVE"} or (hasattr(obj.data, "polygons") and len(obj.data.polygons) == 0):
|
||||
snap_points = tool.Raycast.ray_cast_by_proximity(context, event, obj)
|
||||
if snap_points:
|
||||
hit = sorted(snap_points, key=lambda x: x["distance"])[0]["point"]
|
||||
if hit:
|
||||
hit_world = obj.original.matrix_world @ hit
|
||||
return obj, hit_world, face_index
|
||||
return None, None, None
|
||||
# Meshes
|
||||
else:
|
||||
@@ -514,19 +777,20 @@ class Raycast(bonsai.core.tool.Raycast):
|
||||
|
||||
ray_origin, ray_target, ray_direction = cls.get_viewport_ray_data(context, event)
|
||||
|
||||
for obj in objs_to_raycast:
|
||||
for snap_obj in objs_to_raycast:
|
||||
if not include_wireframes and (
|
||||
obj.type in {"EMPTY", "CURVE"} or (hasattr(obj.data, "polygons") and len(obj.data.polygons) == 0)
|
||||
snap_obj.obj.type in {"EMPTY", "CURVE"}
|
||||
or (hasattr(snap_obj.obj.data, "polygons") and len(snap_obj.obj.data.polygons) == 0)
|
||||
):
|
||||
continue
|
||||
|
||||
snap_obj, hit, face_index = cls.cast_rays_to_single_object(context, event, obj)
|
||||
hit_obj, hit, face_index = cls.cast_rays_to_single_object(context, event, snap_obj.obj)
|
||||
|
||||
if hit is not None:
|
||||
length_squared = (hit - ray_origin).length_squared
|
||||
if best_obj is None or length_squared < best_length_squared:
|
||||
best_length_squared = length_squared
|
||||
best_obj = snap_obj
|
||||
best_obj = hit_obj
|
||||
best_hit = hit
|
||||
best_face_index = face_index
|
||||
|
||||
@@ -536,6 +800,79 @@ class Raycast(bonsai.core.tool.Raycast):
|
||||
else:
|
||||
return None, None, None
|
||||
|
||||
@classmethod
|
||||
def ray_cast_and_get_closest_to_camera_snaps(
|
||||
cls,
|
||||
context: bpy.types.Context,
|
||||
event: bpy.types.Event,
|
||||
objs_to_raycast: list[bpy.types.Object],
|
||||
) -> Union[tuple[bpy.types.Object, Vector, int], tuple[None, None, None]]:
|
||||
closest_length_squared = 1.0
|
||||
closest_obj = None
|
||||
closest_hit = None
|
||||
closest_face_index = None
|
||||
|
||||
ray_origin, ray_target, ray_direction = cls.get_viewport_ray_data(context, event)
|
||||
|
||||
closest_snaps = []
|
||||
hit = None
|
||||
|
||||
for snap_obj in objs_to_raycast:
|
||||
if snap_obj.obj.type in {"EMPTY", "CURVE"} or (
|
||||
hasattr(snap_obj.obj.data, "polygons") and len(snap_obj.obj.data.polygons) == 0
|
||||
):
|
||||
# For wireframe objects we have to test all the snaps to see which is closer
|
||||
snap_points = tool.Raycast.ray_cast_by_proximity_2d(context, event, snap_obj)
|
||||
closest_wf_hit = None
|
||||
closest_wf_length_squared = 1.0
|
||||
closest_wf_point = None
|
||||
if snap_points:
|
||||
for point in snap_points:
|
||||
point["group"] = "Wireframe"
|
||||
closest_snaps.append(point)
|
||||
length = (point["point"] - ray_origin).length_squared
|
||||
if closest_wf_hit is None or length < closest_wf_length_squared:
|
||||
closest_wf_length_squared = length
|
||||
closest_wf_hit = point["point"]
|
||||
closest_wf_point = point
|
||||
|
||||
if closest_wf_point:
|
||||
hit_obj = closest_wf_point["object"]
|
||||
hit = closest_wf_point["point"]
|
||||
face_index = None
|
||||
|
||||
else:
|
||||
# Solid objects
|
||||
hit_obj, hit, face_index = cls.cast_rays_to_single_object(context, event, snap_obj.obj)
|
||||
|
||||
if hit:
|
||||
snap_point = {
|
||||
"point": hit,
|
||||
"type": "Face",
|
||||
"group": "Object",
|
||||
"object": hit_obj,
|
||||
"face_index": face_index,
|
||||
"distance": 9, # High value so it has low priority
|
||||
}
|
||||
closest_snaps.append(snap_point)
|
||||
|
||||
# Here we test which is closer, including wireframe and solid objects
|
||||
if hit is not None:
|
||||
length_squared = (hit - ray_origin).length_squared
|
||||
if closest_obj is None or length_squared < closest_length_squared:
|
||||
closest_length_squared = length_squared
|
||||
closest_obj = hit_obj
|
||||
closest_hit = hit
|
||||
closest_face_index = face_index
|
||||
|
||||
# Label snaps from the closest object
|
||||
if closest_obj is not None:
|
||||
for snap in closest_snaps:
|
||||
if snap["object"] == closest_obj:
|
||||
snap["is_closest_to_camera"] = True
|
||||
|
||||
return closest_snaps
|
||||
|
||||
@classmethod
|
||||
def calculate_snap_threshold(cls, view_distance):
|
||||
snap_threshold = view_distance / 100
|
||||
@@ -547,3 +884,273 @@ class Raycast(bonsai.core.tool.Raycast):
|
||||
if lens < 50:
|
||||
snap_threshold *= value
|
||||
return snap_threshold
|
||||
|
||||
@classmethod
|
||||
def create_snap_obj(cls, obj):
|
||||
if obj.data is None or not isinstance(obj.data, bpy.types.Mesh):
|
||||
return None
|
||||
for i, snap_obj in enumerate(cls.snap_objs):
|
||||
if obj.name == snap_obj.obj.name:
|
||||
# Handle objects modified while a modal operator is active.
|
||||
# Example: adding a door or window alters the wall geometry.
|
||||
if len(obj.data.vertices) != len(snap_obj.verts_3d):
|
||||
cls.snap_objs.pop(i)
|
||||
snap_obj = SnapObj(obj)
|
||||
cls.snap_objs.append(snap_obj)
|
||||
for v1, v2 in zip(obj.data.vertices, snap_obj.verts_3d):
|
||||
if (obj.matrix_world @ v1.co) != v2:
|
||||
cls.snap_objs.pop(i)
|
||||
snap_obj = SnapObj(obj)
|
||||
cls.snap_objs.append(snap_obj)
|
||||
return snap_obj
|
||||
snap_obj = SnapObj(obj)
|
||||
cls.snap_objs.append(snap_obj)
|
||||
return snap_obj
|
||||
|
||||
@classmethod
|
||||
def clear_snap_objs(cls):
|
||||
TreeNode.__clear_all__()
|
||||
SnapObj.__clear_all__()
|
||||
cls.snap_objs.clear()
|
||||
|
||||
|
||||
class TreeNode:
|
||||
all = []
|
||||
|
||||
def __init__(self, box: tuple):
|
||||
self.__class__.all.append(self)
|
||||
self.box = box
|
||||
self.child_a = None
|
||||
self.child_b = None
|
||||
self.edges = []
|
||||
|
||||
def __clear_all__():
|
||||
for instance in TreeNode.all:
|
||||
del instance
|
||||
TreeNode.all.clear()
|
||||
|
||||
|
||||
class SnapObj:
|
||||
max_depth = 9
|
||||
all = []
|
||||
|
||||
def __init__(self, obj: bpy.types.Object):
|
||||
self.__class__.all.append(self)
|
||||
self.obj = obj
|
||||
self.root = self._create_root_node()
|
||||
self.root.edges = [e.index for e in obj.data.edges]
|
||||
self.split_box(self.root, 0)
|
||||
self.verts_3d = [obj.matrix_world @ v.co for v in obj.data.vertices]
|
||||
self.snap_points = []
|
||||
|
||||
def __clear_all__():
|
||||
for instance in SnapObj.all:
|
||||
del instance
|
||||
SnapObj.all.clear()
|
||||
|
||||
def _create_root_node(self) -> TreeNode:
|
||||
bbox = tool.Blender.get_object_bounding_box(self.obj)
|
||||
min_point = self.obj.matrix_world @ bbox["min_point"]
|
||||
max_point = self.obj.matrix_world @ bbox["max_point"]
|
||||
new_bbox = self.expand_bounding_box((min_point, max_point))
|
||||
return TreeNode(new_bbox)
|
||||
|
||||
def divide_bounding_box_along_longest_axis(
|
||||
self, min_pt: Vector, max_pt: Vector
|
||||
) -> Union[tuple[Vector, Vector], tuple[Vector, Vector]]:
|
||||
"""
|
||||
Divide a bounding box into two equal parts along the axis with the longest dimension.
|
||||
|
||||
Args:
|
||||
min_pt: The minimum point of the bounding box.
|
||||
max_pt: The maximum point of the bounding box.
|
||||
|
||||
Returns:
|
||||
list: A list of two tuples, each containing the minimum and maximum points of the divided boxes.
|
||||
"""
|
||||
|
||||
# Calculate the dimensions of the box
|
||||
dx = max_pt.x - min_pt.x
|
||||
dy = max_pt.y - min_pt.y
|
||||
dz = max_pt.z - min_pt.z
|
||||
|
||||
# Determine the axis with the longest dimension
|
||||
if dx >= dy and dx >= dz:
|
||||
# Divide along the x-axis
|
||||
mid_x = min_pt.x + dx / 2
|
||||
box1 = (min_pt, Vector((mid_x, max_pt.y, max_pt.z)))
|
||||
box2 = (Vector((mid_x, min_pt.y, min_pt.z)), max_pt)
|
||||
elif dy >= dx and dy >= dz:
|
||||
# Divide along the y-axis
|
||||
mid_y = min_pt.y + dy / 2
|
||||
box1 = (min_pt, Vector((max_pt.x, mid_y, max_pt.z)))
|
||||
box2 = (Vector((min_pt.x, mid_y, min_pt.z)), max_pt)
|
||||
else:
|
||||
# Divide along the z-axis
|
||||
mid_z = min_pt.z + dz / 2
|
||||
box1 = (min_pt, Vector((max_pt.x, max_pt.y, mid_z)))
|
||||
box2 = (Vector((min_pt.x, min_pt.y, mid_z)), max_pt)
|
||||
|
||||
return [box1, box2]
|
||||
|
||||
def expand_bounding_box(self, box: tuple[Vector, Vector], offset: float = 0.1) -> tuple[Vector, Vector]:
|
||||
"""
|
||||
Expand a 3D bounding box by a given offset.
|
||||
|
||||
Args:
|
||||
min_pt: The minimum point of the bounding box.
|
||||
max_pt: The maximum point of the bounding box.
|
||||
offset: The offset to expand the bounding box by.
|
||||
|
||||
Returns:
|
||||
tuple: A tuple containing the new minimum and maximum points of the expanded bounding box.
|
||||
"""
|
||||
|
||||
min_pt, max_pt = box
|
||||
# Calculate the new minimum and maximum points
|
||||
new_min_pt = Vector((min_pt.x - offset, min_pt.y - offset, min_pt.z - offset))
|
||||
new_max_pt = Vector((max_pt.x + offset, max_pt.y + offset, max_pt.z + offset))
|
||||
|
||||
return new_min_pt, new_max_pt
|
||||
|
||||
def split_box(self, parent: TreeNode, depth: int):
|
||||
"""
|
||||
Splits the bounding box creating two child nodes to compose a BVH Tree recursively.
|
||||
|
||||
Args:
|
||||
parent: the TreeNode instance that represents the parent node of a BVH Tree.
|
||||
depth: the depth of the BVH Tree no be used in recursion.
|
||||
"""
|
||||
if depth > self.max_depth:
|
||||
return
|
||||
box_a, box_b = self.divide_bounding_box_along_longest_axis(parent.box[0], parent.box[1])
|
||||
parent.child_a = TreeNode(box_a)
|
||||
parent.child_b = TreeNode(box_b)
|
||||
edges_a = []
|
||||
edges_b = []
|
||||
for e in parent.edges:
|
||||
verts_idx = [v for v in self.obj.data.edges[e].vertices]
|
||||
verts_coords = []
|
||||
for idx in verts_idx:
|
||||
if idx < len(self.obj.data.vertices):
|
||||
verts_coords.append(self.obj.matrix_world @ self.obj.data.vertices[idx].co)
|
||||
if self.line_intersects_box(verts_coords[0], verts_coords[1], parent.child_a.box):
|
||||
edges_a.append(e)
|
||||
if self.line_intersects_box(verts_coords[0], verts_coords[1], parent.child_b.box):
|
||||
edges_b.append(e)
|
||||
parent.child_a.edges = edges_a
|
||||
parent.child_b.edges = edges_b
|
||||
self.split_box(parent.child_a, depth + 1)
|
||||
self.split_box(parent.child_b, depth + 1)
|
||||
|
||||
def raycast_box(
|
||||
self, context: bpy.types.Context, event: bpy.types.Event, node: TreeNode, rays: tuple[Vector, Vector]
|
||||
) -> bool:
|
||||
"""
|
||||
Raycast bounding box.
|
||||
|
||||
Args:
|
||||
context: Blender context.
|
||||
event: Blender event.
|
||||
node: a TreeNode instance.
|
||||
rays: tuple containing ray origin and ray direction
|
||||
|
||||
Returns:
|
||||
True if hits the box or False otherwise.
|
||||
"""
|
||||
box = node.box
|
||||
min_v = box[0]
|
||||
max_v = box[1]
|
||||
t_min = 0.0
|
||||
t_max = float("inf")
|
||||
ray_origin, ray_dir = rays
|
||||
inv_dir = Vector((1.0 / r if r != 0.0 else 1e32) for r in (ray_dir.x, ray_dir.y, ray_dir.z))
|
||||
# X
|
||||
tx1 = (min_v.x - ray_origin.x) * inv_dir[0]
|
||||
tx2 = (max_v.x - ray_origin.x) * inv_dir[0]
|
||||
tmin = min(tx1, tx2)
|
||||
tmax = max(tx1, tx2)
|
||||
# Y
|
||||
ty1 = (min_v.y - ray_origin.y) * inv_dir[1]
|
||||
ty2 = (max_v.y - ray_origin.y) * inv_dir[1]
|
||||
tmin = max(tmin, min(ty1, ty2))
|
||||
tmax = min(tmax, max(ty1, ty2))
|
||||
# Z
|
||||
tz1 = (min_v.z - ray_origin.z) * inv_dir[2]
|
||||
tz2 = (max_v.z - ray_origin.z) * inv_dir[2]
|
||||
tmin = max(tmin, min(tz1, tz2))
|
||||
tmax = min(tmax, max(tz1, tz2))
|
||||
return (tmax >= max(tmin, t_min)) and (tmin <= t_max)
|
||||
|
||||
def line_intersects_box(self, v1: mathutils.Vector, v2: mathutils.Vector, box: tuple) -> bool:
|
||||
"""
|
||||
Check if a line segment intersects an axis-aligned bounding box (AABB).
|
||||
|
||||
Args:
|
||||
v1: The first endpoint of the line segment as a mathutils.Vector.
|
||||
v2: The second endpoint of the line segment as a mathutils.Vector.
|
||||
box: A tuple containing the minimum and maximum points of the AABB, where each point is a mathutils.Vector.
|
||||
|
||||
Returns:
|
||||
bool: True if the segment [v1, v2] intersects the AABB; otherwise, False.
|
||||
"""
|
||||
bmin, bmax = box
|
||||
dir = v2 - v1
|
||||
tmin = 0.0
|
||||
tmax = 1.0
|
||||
|
||||
for i in range(3):
|
||||
if abs(dir[i]) < 1e-12:
|
||||
# Line is parallel to slab. If origin not within slab -> no hit.
|
||||
if v1[i] < bmin[i] or v1[i] > bmax[i]:
|
||||
return False
|
||||
else:
|
||||
ood = 1.0 / dir[i]
|
||||
t1 = (bmin[i] - v1[i]) * ood
|
||||
t2 = (bmax[i] - v1[i]) * ood
|
||||
if t1 > t2:
|
||||
t1, t2 = t2, t1
|
||||
if t1 > tmin:
|
||||
tmin = t1
|
||||
if t2 < tmax:
|
||||
tmax = t2
|
||||
if tmin > tmax:
|
||||
return False
|
||||
|
||||
# If any overlap in [0,1] exists, there's intersection
|
||||
return (tmax >= 0.0) and (tmin <= 1.0)
|
||||
|
||||
def raycast_boxes(
|
||||
self,
|
||||
context: bpy.types.Context,
|
||||
event: bpy.Types.Event,
|
||||
node: TreeNode,
|
||||
intersected: Union[TreeNode] = [],
|
||||
rays: tuple[Vector, Vector] = (),
|
||||
) -> Union[TreeNode]:
|
||||
"""
|
||||
Raycast bounding box subdivisions recursively.
|
||||
|
||||
Args:
|
||||
context: Blender context.
|
||||
event: Blender event.
|
||||
node: a TreeNode instance.
|
||||
intersected: list of intersected boxes to use in recursion.
|
||||
rays: tuple containing ray origin and ray direction
|
||||
|
||||
Returns:
|
||||
tuple: a list of TreeNode instances that represent the subdivided boxes hit by the ray cast.
|
||||
"""
|
||||
if not node.child_a:
|
||||
intersected.append(node)
|
||||
return intersected
|
||||
|
||||
intersects_a = self.raycast_box(context, event, node.child_a, rays)
|
||||
intersects_b = self.raycast_box(context, event, node.child_b, rays)
|
||||
if intersects_a:
|
||||
intersected = self.raycast_boxes(context, event, node.child_a, intersected, rays)
|
||||
|
||||
if intersects_b:
|
||||
intersected = self.raycast_boxes(context, event, node.child_b, intersected, rays)
|
||||
|
||||
return intersected
|
||||
|
||||
@@ -388,57 +388,36 @@ class Snap(bonsai.core.tool.Snap):
|
||||
|
||||
# Objects
|
||||
objs_to_raycast = tool.Raycast.filter_objects_to_raycast(context, event, objs_2d_bbox)
|
||||
# Wireframes
|
||||
# For wireframe we have to get all the objects so we can further calculate edge intersection
|
||||
for snap_obj in objs_to_raycast:
|
||||
if snap_obj.type in {"EMPTY", "CURVE"} or (snap_obj.type == "MESH" and len(snap_obj.data.polygons) == 0):
|
||||
snap_points = tool.Raycast.ray_cast_by_proximity(context, event, snap_obj)
|
||||
if snap_points:
|
||||
for point in snap_points:
|
||||
point["group"] = "Wireframe"
|
||||
detected_snaps.append(point)
|
||||
closest_snaps = tool.Raycast.ray_cast_and_get_closest_to_camera_snaps(context, event, objs_to_raycast)
|
||||
detected_snaps.extend(closest_snaps)
|
||||
|
||||
if (space.shading.type == "SOLID" and space.shading.show_xray) or (
|
||||
xray_mode = (space.shading.type == "SOLID" and space.shading.show_xray) or (
|
||||
space.shading.type == "WIREFRAME" and space.shading.show_xray_wireframe
|
||||
):
|
||||
results = []
|
||||
for obj in objs_to_raycast:
|
||||
results.append(tool.Raycast.cast_rays_to_single_object(context, event, obj))
|
||||
else:
|
||||
results = []
|
||||
results.append(tool.Raycast.cast_rays_and_get_best_object(context, event, objs_to_raycast))
|
||||
)
|
||||
|
||||
for result in results:
|
||||
snap_obj = result[0]
|
||||
hit = result[1]
|
||||
face_index = result[2]
|
||||
if hit is not None:
|
||||
# Wireframes
|
||||
if snap_obj.type in {"EMPTY", "CURVE"} or (
|
||||
snap_obj.type == "MESH" and len(snap_obj.data.polygons) == 0
|
||||
):
|
||||
continue
|
||||
# Meshes
|
||||
else:
|
||||
# Add face snap
|
||||
snap_point = {
|
||||
"point": hit,
|
||||
"type": "Face",
|
||||
"group": "Object",
|
||||
"object": snap_obj,
|
||||
"face_index": face_index,
|
||||
"distance": 9, # High value so it has low priority
|
||||
}
|
||||
detected_snaps.append(snap_point)
|
||||
|
||||
# Add vertex and edge snap
|
||||
snap_points = tool.Raycast.ray_cast_by_proximity(
|
||||
context, event, snap_obj, snap_obj.data.polygons[face_index]
|
||||
)
|
||||
if snap_points:
|
||||
for point in snap_points:
|
||||
point["group"] = "Object"
|
||||
detected_snaps.append(point)
|
||||
for snap_obj in objs_to_raycast:
|
||||
for snap in closest_snaps:
|
||||
if snap_obj.obj == snap["object"]:
|
||||
if xray_mode:
|
||||
if "face_index" in snap and snap["face_index"] is not None:
|
||||
snap_points = tool.Raycast.ray_cast_by_proximity_2d(context, event, snap_obj)
|
||||
for point in snap_points:
|
||||
point["group"] = "Object"
|
||||
detected_snaps.append(point)
|
||||
else:
|
||||
# If it is a solid object that is closest to camera it ignores all the rest
|
||||
if (
|
||||
"is_closest_to_camera" in snap
|
||||
and snap["is_closest_to_camera"]
|
||||
and snap["group"] == "Object"
|
||||
):
|
||||
closest_snap = [snap] # discards objects that aren't the closest
|
||||
if "face_index" in snap and snap["face_index"] is not None:
|
||||
snap_points = tool.Raycast.ray_cast_by_proximity_2d(context, event, snap_obj)
|
||||
for point in snap_points:
|
||||
point["group"] = "Object"
|
||||
closest_snap.append(point)
|
||||
detected_snaps = closest_snap
|
||||
|
||||
# snap to cut geometry (e.g. in plan view)
|
||||
if CutDecorator.installed:
|
||||
|
||||
+121
-152
@@ -21,13 +21,13 @@ from __future__ import annotations
|
||||
import json
|
||||
from collections import defaultdict
|
||||
from collections.abc import Generator, Iterable
|
||||
from math import pi
|
||||
from typing import TYPE_CHECKING, Any, Literal, Optional, Union
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.attribute
|
||||
import ifcopenshell.api.geometry
|
||||
import ifcopenshell.api.type
|
||||
import ifcopenshell.geom
|
||||
import ifcopenshell.util.classification
|
||||
@@ -991,114 +991,104 @@ class Spatial(bonsai.core.tool.Spatial):
|
||||
return poly
|
||||
|
||||
@classmethod
|
||||
def get_bmesh_from_polygon(cls, poly: Polygon, h: float, polygon_is_si: bool = False) -> bmesh.types.BMesh:
|
||||
"""
|
||||
:param h: Height, in meters.
|
||||
:param polygon_is_si: Should be True if `poly` is defined in meters.
|
||||
"""
|
||||
mat = Matrix()
|
||||
bm = bmesh.new()
|
||||
bm.verts.index_update()
|
||||
bm.edges.index_update()
|
||||
|
||||
mat_invert = mat.inverted()
|
||||
si_conversion = 1.0 if polygon_is_si else ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
||||
new_verts = [
|
||||
# bm.verts.new(mat_invert @ (Vector([v[0], v[1], 0]) * si_conversion)) for v in poly.exterior.coords[0:-1]
|
||||
bm.verts.new(mat_invert @ (Vector([v[0], v[1], 0]) * si_conversion))
|
||||
for v in shapely.get_exterior_ring(poly).coords[0:-1]
|
||||
]
|
||||
[bm.edges.new((new_verts[i], new_verts[i + 1])) for i in range(len(new_verts) - 1)]
|
||||
bm.edges.new((new_verts[len(new_verts) - 1], new_verts[0]))
|
||||
|
||||
bm.verts.index_update()
|
||||
bm.edges.index_update()
|
||||
|
||||
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-5)
|
||||
bmesh.ops.triangle_fill(bm, edges=bm.edges)
|
||||
bmesh.ops.dissolve_limit(bm, angle_limit=pi / 180 * 5, verts=bm.verts, edges=bm.edges)
|
||||
|
||||
if h != 0:
|
||||
extrusion = bmesh.ops.extrude_face_region(bm, geom=bm.faces)
|
||||
extruded_verts = [g for g in extrusion["geom"] if isinstance(g, bmesh.types.BMVert)]
|
||||
bmesh.ops.translate(bm, vec=[0.0, 0.0, h], verts=extruded_verts)
|
||||
|
||||
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
|
||||
|
||||
return bm
|
||||
|
||||
@classmethod
|
||||
def get_named_obj_from_bmesh(cls, name: str, bmesh: bmesh.types.BMesh) -> bpy.types.Object:
|
||||
mesh = cls.get_named_mesh_from_bmesh(name, bmesh)
|
||||
obj = cls.get_named_obj_from_mesh(name, mesh)
|
||||
return obj
|
||||
|
||||
@classmethod
|
||||
def get_named_obj_from_mesh(cls, name: str, mesh: bpy.types.Mesh) -> bpy.types.Object:
|
||||
def create_object(cls, name: str) -> bpy.types.Object:
|
||||
mesh = bpy.data.meshes.new(name=name)
|
||||
obj = bpy.data.objects.new(name, mesh)
|
||||
return obj
|
||||
|
||||
@classmethod
|
||||
def get_named_mesh_from_bmesh(cls, name: str, bmesh: bmesh.types.BMesh) -> bpy.types.Mesh:
|
||||
mesh = bpy.data.meshes.new(name=name)
|
||||
bmesh.to_mesh(mesh)
|
||||
bmesh.free()
|
||||
return mesh
|
||||
def set_obj_origin_to_polygon_center(cls, obj: bpy.types.Object, poly: Polygon, polygon_is_si: bool = True) -> None:
|
||||
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
||||
centroid = poly.centroid
|
||||
if polygon_is_si:
|
||||
obj.location = Vector((centroid.x, centroid.y, 0))
|
||||
else:
|
||||
obj.location = Vector((centroid.x * unit_scale, centroid.y * unit_scale, 0))
|
||||
|
||||
@classmethod
|
||||
def get_transformed_mesh_from_local_to_global(cls, mesh: bpy.types.Mesh) -> bpy.types.Mesh:
|
||||
active_obj = cls.get_active_obj()
|
||||
mat = active_obj.matrix_world
|
||||
mesh.transform(mat.inverted())
|
||||
mesh.update()
|
||||
return mesh
|
||||
def get_2d_vertices_from_polygon(
|
||||
cls,
|
||||
poly: Polygon,
|
||||
obj: bpy.types.Object,
|
||||
polygon_is_si: bool = True,
|
||||
) -> list[list[float]]:
|
||||
"""Convert a world-space shapely polygon to 2D vertices in obj's local space, in IFC file units.
|
||||
|
||||
:param poly: The polygon in world space.
|
||||
:param obj: The Blender object whose local space is used.
|
||||
:param polygon_is_si: True if polygon coords are in SI, False if in IFC file units.
|
||||
:return: List of [x, y] coordinates (not closed).
|
||||
"""
|
||||
ifc_file = tool.Ifc.get()
|
||||
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file)
|
||||
bpy.context.view_layer.update()
|
||||
mat_inv = obj.matrix_world.inverted()
|
||||
coords_2d = []
|
||||
for v in shapely.get_exterior_ring(poly).coords[:-1]:
|
||||
world_si = Vector((v[0], v[1], 0))
|
||||
if not polygon_is_si:
|
||||
world_si = world_si * unit_scale
|
||||
local_si = mat_inv @ world_si
|
||||
coords_2d.append([local_si.x / unit_scale, local_si.y / unit_scale])
|
||||
return coords_2d
|
||||
|
||||
@classmethod
|
||||
def edit_active_space_obj_from_mesh(cls, mesh: bpy.types.Mesh) -> None:
|
||||
active_obj = bpy.context.active_object
|
||||
old_mesh = active_obj.data
|
||||
old_mesh_name = old_mesh.name
|
||||
assert active_obj and isinstance(old_mesh, bpy.types.Mesh)
|
||||
tool.Geometry.get_mesh_props(mesh).ifc_definition_id = tool.Geometry.get_mesh_props(old_mesh).ifc_definition_id
|
||||
tool.Geometry.change_object_data(active_obj, mesh, is_global=True)
|
||||
tool.Ifc.edit(active_obj)
|
||||
tool.Blender.remove_data_block(old_mesh)
|
||||
# Rename after old mesh is removed to avoid .001 suffix.
|
||||
mesh.name = old_mesh_name
|
||||
def set_extrusion_representation_from_polygon(
|
||||
cls,
|
||||
obj: bpy.types.Object,
|
||||
element: ifcopenshell.entity_instance,
|
||||
poly: Polygon,
|
||||
depth_ifc: float,
|
||||
polygon_is_si: bool = True,
|
||||
) -> None:
|
||||
"""Create or replace the IFC body representation from a polygon extrusion.
|
||||
|
||||
:param obj: The Blender object.
|
||||
:param element: The IFC product entity.
|
||||
:param poly: The polygon in world space.
|
||||
:param depth_ifc: The extrusion depth in IFC file units.
|
||||
:param polygon_is_si: True if polygon coords are in SI, False if in IFC file units.
|
||||
"""
|
||||
ifc_file = tool.Ifc.get()
|
||||
builder = ifcopenshell.util.shape_builder.ShapeBuilder(ifc_file)
|
||||
|
||||
coords_2d = cls.get_2d_vertices_from_polygon(poly, obj, polygon_is_si)
|
||||
|
||||
curve = builder.polyline(coords_2d, closed=True)
|
||||
item = builder.extrude(curve, magnitude=depth_ifc)
|
||||
|
||||
old_body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
|
||||
if old_body:
|
||||
context = old_body.ContextOfItems
|
||||
ifcopenshell.api.geometry.unassign_representation(ifc_file, product=element, representation=old_body)
|
||||
ifcopenshell.api.geometry.remove_representation(ifc_file, representation=old_body)
|
||||
else:
|
||||
context = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW")
|
||||
|
||||
new_body = builder.get_representation(context, item)
|
||||
ifcopenshell.api.geometry.assign_representation(ifc_file, product=element, representation=new_body)
|
||||
bonsai.core.geometry.switch_representation(
|
||||
tool.Ifc,
|
||||
tool.Geometry,
|
||||
obj=obj,
|
||||
representation=new_body,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def set_obj_origin_to_bboxcenter(cls, obj: bpy.types.Object) -> None:
|
||||
mat = obj.matrix_world
|
||||
inverted = mat.inverted()
|
||||
local_bbox_center = 0.125 * sum((Vector(b) for b in obj.bound_box), Vector())
|
||||
global_bbox_center = mat @ local_bbox_center
|
||||
def set_space_representation_from_polygon(
|
||||
cls,
|
||||
obj: bpy.types.Object,
|
||||
element: ifcopenshell.entity_instance,
|
||||
poly: Polygon,
|
||||
h: float,
|
||||
polygon_is_si: bool = True,
|
||||
) -> None:
|
||||
"""Create or replace the IFC body representation of a space from a polygon.
|
||||
|
||||
oldLoc = obj.location
|
||||
newLoc = global_bbox_center
|
||||
diff = newLoc - oldLoc
|
||||
for vert in obj.data.vertices:
|
||||
aux_vector = mat @ vert.co
|
||||
aux_vector = aux_vector - diff
|
||||
vert.co = inverted @ aux_vector
|
||||
obj.location = newLoc
|
||||
|
||||
@classmethod
|
||||
def set_obj_origin_to_bboxcenter_and_zero_elevation(cls, obj: bpy.types.Object) -> None:
|
||||
mat = obj.matrix_world
|
||||
inverted = mat.inverted()
|
||||
local_bbox_center = 0.125 * sum((Vector(b) for b in obj.bound_box), Vector())
|
||||
global_bbox_center = mat @ local_bbox_center
|
||||
global_obj_origin = global_bbox_center
|
||||
global_obj_origin.z = 0
|
||||
|
||||
oldLoc = obj.location
|
||||
newLoc = global_obj_origin
|
||||
diff = newLoc - oldLoc
|
||||
for vert in obj.data.vertices:
|
||||
aux_vector = mat @ vert.co
|
||||
aux_vector = aux_vector - diff
|
||||
vert.co = inverted @ aux_vector
|
||||
obj.location = newLoc
|
||||
:param h: The height in SI (meters).
|
||||
"""
|
||||
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
||||
cls.set_extrusion_representation_from_polygon(obj, element, poly, h / unit_scale, polygon_is_si)
|
||||
|
||||
@classmethod
|
||||
def set_obj_origin_to_cursor_position_and_zero_elevation(cls, obj: bpy.types.Object) -> None:
|
||||
@@ -1145,65 +1135,53 @@ class Spatial(bonsai.core.tool.Spatial):
|
||||
if z != 0:
|
||||
obj.location = obj.location + Vector((0, 0, z))
|
||||
|
||||
@classmethod
|
||||
def get_2d_vertices_from_obj(cls, obj: bpy.types.Object) -> list[tuple]:
|
||||
points = []
|
||||
vectors = [v.co for v in obj.data.vertices.values()]
|
||||
for vector in vectors:
|
||||
points.append(vector.xy)
|
||||
|
||||
points.append(vectors[0].xy)
|
||||
return points
|
||||
|
||||
@classmethod
|
||||
def get_scaled_2d_vertices(cls, points: list[Vector]) -> list[tuple[float, float]]:
|
||||
model = tool.Ifc.get()
|
||||
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(model)
|
||||
_points = []
|
||||
for p in points:
|
||||
_p = list(p)
|
||||
_p[0] /= unit_scale
|
||||
_p[1] /= unit_scale
|
||||
_points.append(_p)
|
||||
return _points
|
||||
|
||||
@classmethod
|
||||
def assign_swept_area_outer_curve_from_2d_vertices(cls, obj: bpy.types.Object, vertices: list[Vector]) -> None:
|
||||
body = cls.get_body_representation(obj)
|
||||
model = tool.Ifc.get()
|
||||
extrusion = tool.Model.get_extrusion(body)
|
||||
area = extrusion.SweptArea
|
||||
old_area = area.OuterCurve
|
||||
|
||||
builder = ifcopenshell.util.shape_builder.ShapeBuilder(model)
|
||||
outer_curve = builder.polyline(vertices, closed=True)
|
||||
|
||||
area.OuterCurve = outer_curve
|
||||
ifcopenshell.util.element.remove_deep2(tool.Ifc.get(), old_area)
|
||||
|
||||
@classmethod
|
||||
def get_body_representation(cls, obj: bpy.types.Object) -> Union[ifcopenshell.entity_instance, None]:
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
return ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
|
||||
|
||||
@classmethod
|
||||
def assign_ifcspace_class_to_obj(cls, obj: bpy.types.Object) -> None:
|
||||
bpy.ops.bim.assign_class(obj=obj.name, ifc_class="IfcSpace")
|
||||
bonsai.core.root.assign_class(
|
||||
tool.Ifc,
|
||||
tool.Collector,
|
||||
tool.Root,
|
||||
obj=obj,
|
||||
ifc_class="IfcSpace",
|
||||
should_add_representation=False,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def assign_type_to_obj(cls, obj: bpy.types.Object) -> None:
|
||||
# TODO this code looks in the wrong spot and suspicious
|
||||
props = tool.Model.get_model_props()
|
||||
ifc_file = tool.Ifc.get()
|
||||
relating_type_id = props.relating_type_id
|
||||
relating_type = tool.Ifc.get().by_id(int(relating_type_id))
|
||||
relating_type = ifc_file.by_id(int(relating_type_id))
|
||||
ifc_class = relating_type.is_a()
|
||||
instance_class = ifcopenshell.util.type.get_applicable_entities(ifc_class, ifc_file.schema)[0]
|
||||
bpy.ops.bim.assign_class(obj=obj.name, ifc_class=instance_class)
|
||||
bonsai.core.root.assign_class(
|
||||
tool.Ifc,
|
||||
tool.Collector,
|
||||
tool.Root,
|
||||
obj=obj,
|
||||
ifc_class=instance_class,
|
||||
should_add_representation=False,
|
||||
)
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
assert element
|
||||
ifcopenshell.api.type.assign_type(ifc_file, related_objects=[element], relating_type=relating_type)
|
||||
|
||||
@classmethod
|
||||
def set_covering_representation_from_polygon(
|
||||
cls,
|
||||
obj: bpy.types.Object,
|
||||
poly: Polygon,
|
||||
polygon_is_si: bool = True,
|
||||
) -> None:
|
||||
"""Create the covering body representation from a polygon, extruded by the type's material layer thickness."""
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
relating_type = ifcopenshell.util.element.get_type(element)
|
||||
material = ifcopenshell.util.element.get_material(relating_type, should_skip_usage=True)
|
||||
depth = 0.0
|
||||
if material and material.is_a("IfcMaterialLayerSet"):
|
||||
depth = sum(layer.LayerThickness for layer in material.MaterialLayers)
|
||||
cls.set_extrusion_representation_from_polygon(obj, element, poly, depth, polygon_is_si)
|
||||
|
||||
@classmethod
|
||||
def assign_relating_type_to_element(
|
||||
cls,
|
||||
@@ -1214,15 +1192,6 @@ class Spatial(bonsai.core.tool.Spatial):
|
||||
) -> None:
|
||||
bonsai.core.type.assign_type(ifc, tool.Model, type, element=element, type=relating_type)
|
||||
|
||||
@classmethod
|
||||
def regen_obj_representation(cls, obj: bpy.types.Object, body: ifcopenshell.entity_instance) -> None:
|
||||
bonsai.core.geometry.switch_representation(
|
||||
tool.Ifc,
|
||||
tool.Geometry,
|
||||
obj=obj,
|
||||
representation=body,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def set_space_visibility(cls, is_visible: bool) -> None:
|
||||
if tool.Ifc.get().schema == "IFC2X3":
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Feature: Covering
|
||||
Covers covering tool.
|
||||
|
||||
Scenario: Execute generate flooring coverings from walls
|
||||
Scenario: Add flooring from walls
|
||||
Given an empty IFC project
|
||||
And I load the demo construction library
|
||||
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
|
||||
@@ -24,9 +24,9 @@ Scenario: Execute generate flooring coverings from walls
|
||||
And the cursor is at "0,2.0,0"
|
||||
And I set "scene.BIMModelProperties.length" to "1.9"
|
||||
And I press "bim.add_occurrence"
|
||||
# add_instance_flooring_coverings_from_walls is expecting FLOORING predefined type.
|
||||
# Set COV30 predefined type to FLOORING.
|
||||
And the object "IfcCoveringType/COV30" is selected
|
||||
And I look at the "Attributes" panel
|
||||
And I look at the "Object Attributes" panel
|
||||
And I click "Edit"
|
||||
And I set the "PredefinedType" property to "FLOORING"
|
||||
And I click "Save Attributes"
|
||||
@@ -42,3 +42,108 @@ Scenario: Execute generate flooring coverings from walls
|
||||
Then the object "IfcCovering/Covering0" exists
|
||||
And the object "IfcCovering/Covering0" is at "1.8,1.05,0.0"
|
||||
And the object "IfcCovering/Covering0" dimensions are "3.4,1.9,0.03"
|
||||
|
||||
Scenario: Add ceiling from walls
|
||||
Given an empty IFC project
|
||||
And I load the demo construction library
|
||||
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
|
||||
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
|
||||
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
|
||||
And I press "bim.add_occurrence"
|
||||
And the object "IfcWall/Wall" is selected
|
||||
And I press "bim.change_layer_length(length=3.6)"
|
||||
And the cursor is at "3.6,0.1,3"
|
||||
And I set "scene.BIMModelProperties.length" to "2.0"
|
||||
And I press "bim.add_occurrence"
|
||||
And the cursor is at "3.5,2.1,3"
|
||||
And I set "scene.BIMModelProperties.length" to "3.5"
|
||||
And I press "bim.add_occurrence"
|
||||
And the cursor is at "0,2.0,0"
|
||||
And I set "scene.BIMModelProperties.length" to "1.9"
|
||||
And I press "bim.add_occurrence"
|
||||
# Set COV30 predefined type to CEILING.
|
||||
And the object "IfcCoveringType/COV30" is selected
|
||||
And I look at the "Object Attributes" panel
|
||||
And I click "Edit"
|
||||
And I set the "PredefinedType" property to "CEILING"
|
||||
And I click "Save Attributes"
|
||||
# Run the operator with ceiling height = 2.7 (default).
|
||||
When the object "IfcWall/Wall" is selected
|
||||
And additionally the object "IfcWall/Wall.001" is selected
|
||||
And additionally the object "IfcWall/Wall.002" is selected
|
||||
And additionally the object "IfcWall/Wall.003" is selected
|
||||
And I set "scene.BIMModelProperties.ifc_class" to "IfcCoveringType"
|
||||
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcCoveringType') if e.Name == 'COV30'][0].id()"
|
||||
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
|
||||
And I press "bim.add_instance_ceiling_coverings_from_walls"
|
||||
Then the object "IfcCovering/Covering0" exists
|
||||
And the object "IfcCovering/Covering0" is at "1.8,1.05,2.7"
|
||||
And the object "IfcCovering/Covering0" dimensions are "3.4,1.9,0.03"
|
||||
|
||||
Scenario: Add flooring from cursor
|
||||
Given an empty IFC project
|
||||
And I load the demo construction library
|
||||
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
|
||||
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
|
||||
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
|
||||
And I press "bim.add_occurrence"
|
||||
And the cursor is at "1.1,0,0"
|
||||
And I press "bim.add_occurrence"
|
||||
And the object "IfcWall/Wall.001" is selected
|
||||
And I press "bim.hotkey(hotkey='S_R')"
|
||||
And the cursor is at "0,.9,0"
|
||||
And I press "bim.add_occurrence"
|
||||
And the cursor is at "-1,0,0"
|
||||
And I press "bim.add_occurrence"
|
||||
And the object "IfcWall/Wall.003" is selected
|
||||
And I press "bim.hotkey(hotkey='S_R')"
|
||||
And the object "IfcWall/Wall.003" is moved to "0,0,0"
|
||||
# Set COV30 predefined type to FLOORING.
|
||||
And the object "IfcCoveringType/COV30" is selected
|
||||
And I look at the "Object Attributes" panel
|
||||
And I click "Edit"
|
||||
And I set the "PredefinedType" property to "FLOORING"
|
||||
And I click "Save Attributes"
|
||||
# Generate covering from cursor inside the room.
|
||||
When the cursor is at "0.5,0.5,0"
|
||||
And I deselect all objects
|
||||
And I set "scene.BIMModelProperties.ifc_class" to "IfcCoveringType"
|
||||
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcCoveringType') if e.Name == 'COV30'][0].id()"
|
||||
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
|
||||
And I press "bim.add_instance_flooring_covering_from_cursor"
|
||||
Then the object "IfcCovering/Covering" exists
|
||||
And the object "IfcCovering/Covering" dimensions are "1,0.8,0.03"
|
||||
|
||||
Scenario: Add ceiling from cursor
|
||||
Given an empty IFC project
|
||||
And I load the demo construction library
|
||||
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
|
||||
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
|
||||
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
|
||||
And I press "bim.add_occurrence"
|
||||
And the cursor is at "1.1,0,0"
|
||||
And I press "bim.add_occurrence"
|
||||
And the object "IfcWall/Wall.001" is selected
|
||||
And I press "bim.hotkey(hotkey='S_R')"
|
||||
And the cursor is at "0,.9,0"
|
||||
And I press "bim.add_occurrence"
|
||||
And the cursor is at "-1,0,0"
|
||||
And I press "bim.add_occurrence"
|
||||
And the object "IfcWall/Wall.003" is selected
|
||||
And I press "bim.hotkey(hotkey='S_R')"
|
||||
And the object "IfcWall/Wall.003" is moved to "0,0,0"
|
||||
# Set COV30 predefined type to CEILING.
|
||||
And the object "IfcCoveringType/COV30" is selected
|
||||
And I look at the "Object Attributes" panel
|
||||
And I click "Edit"
|
||||
And I set the "PredefinedType" property to "CEILING"
|
||||
And I click "Save Attributes"
|
||||
# Generate covering from cursor inside the room.
|
||||
When the cursor is at "0.5,0.5,0"
|
||||
And I deselect all objects
|
||||
And I set "scene.BIMModelProperties.ifc_class" to "IfcCoveringType"
|
||||
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcCoveringType') if e.Name == 'COV30'][0].id()"
|
||||
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
|
||||
And I press "bim.add_instance_ceiling_covering_from_cursor"
|
||||
Then the object "IfcCovering/Covering" exists
|
||||
And the object "IfcCovering/Covering" dimensions are "1,0.8,0.03"
|
||||
|
||||
@@ -396,6 +396,44 @@ Scenario: Add a slab
|
||||
And the object "IfcSlab/Slab" bottom left corner is at "0,0,0"
|
||||
And the object "IfcSlab/Slab" top right corner is at "1,1,0.2"
|
||||
|
||||
Scenario: Extend walls to underside
|
||||
Given an empty IFC project
|
||||
And I load the demo construction library
|
||||
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
|
||||
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
|
||||
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
|
||||
And I press "bim.add_occurrence"
|
||||
And I set "scene.BIMModelProperties.ifc_class" to "IfcSlabType"
|
||||
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcSlabType') if e.Name == 'FLR200'][0].id()"
|
||||
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
|
||||
And I press "bim.add_occurrence"
|
||||
And the object "IfcSlab/Slab" is moved to "0,0,2.5"
|
||||
When the object "IfcWall/Wall" is selected
|
||||
And additionally the object "IfcSlab/Slab" is selected
|
||||
And I look at the tool header
|
||||
And I click "Extend To Underside"
|
||||
Then the object "IfcWall/Wall" dimensions are "1,0.1,2.5"
|
||||
|
||||
Scenario: Extend walls to underside - extending to a tessellated gable roof
|
||||
Given an empty IFC project
|
||||
And I load the demo construction library
|
||||
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
|
||||
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
|
||||
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
|
||||
And I press "bim.add_occurrence"
|
||||
# Create gable roof: a cube turned into a prism with a ridge.
|
||||
And I add a cube of size "1" at "0.5,0.05,3"
|
||||
And the object "Cube" is selected
|
||||
And I evaluate expression "obj = bpy.context.active_object; [setattr(v.co, 'y', 0) for v in obj.data.vertices if v.co.z > 0]"
|
||||
And I set "scene.BIMRootProperties.ifc_product" to "IfcElement"
|
||||
And I set "scene.BIMRootProperties.ifc_class" to "IfcRoof"
|
||||
And I press "bim.assign_class"
|
||||
When the object "IfcWall/Wall" is selected
|
||||
And additionally the object "IfcRoof/Cube" is selected
|
||||
And I look at the tool header
|
||||
And I click "Extend To Underside"
|
||||
Then the object "IfcWall/Wall" dimensions are "1,0.1,2.5"
|
||||
|
||||
Scenario: Enable editing a slab profile
|
||||
Given an empty IFC project
|
||||
And I load the demo construction library
|
||||
|
||||
@@ -133,7 +133,11 @@ class PanelSpy:
|
||||
self.spied_labels.append(kwargs["text"])
|
||||
return self
|
||||
elif self.spied_attr == "prop":
|
||||
props, name = args
|
||||
if args:
|
||||
props, name = args
|
||||
else:
|
||||
props = kwargs.get("data")
|
||||
name = kwargs.get("property")
|
||||
props: bpy.types.bpy_struct
|
||||
text = kwargs.get("text", props.bl_rna.properties[name].name)
|
||||
icon = kwargs.get("icon", None)
|
||||
@@ -390,6 +394,32 @@ def i_look_at_the_panel_panel(panel: str) -> None:
|
||||
panel_spy.refresh_spy()
|
||||
|
||||
|
||||
@given(parsers.parse("I look at the tool header"))
|
||||
@when(parsers.parse("I look at the tool header"))
|
||||
@then(parsers.parse("I look at the tool header"))
|
||||
def i_look_at_the_tool_header() -> None:
|
||||
from bonsai.bim.module.model.workspace import EditObjectUI
|
||||
|
||||
class MockRegion:
|
||||
type = "UI"
|
||||
|
||||
class MockContext:
|
||||
def __getattr__(self, name):
|
||||
if name == "region":
|
||||
return MockRegion()
|
||||
return getattr(bpy.context, name)
|
||||
|
||||
global panel_spy
|
||||
panel_spy = PanelSpy(EditObjectUI)
|
||||
panel_spy.is_spy_dirty = False
|
||||
panel_spy.spied_attr = None
|
||||
panel_spy.spied_labels = []
|
||||
panel_spy.spied_props = []
|
||||
panel_spy.spied_operators = []
|
||||
panel_spy.spied_lists = []
|
||||
EditObjectUI.draw(MockContext(), panel_spy)
|
||||
|
||||
|
||||
@given(parsers.parse('I open the "{name}" menu'))
|
||||
@when(parsers.parse('I open the "{name}" menu'))
|
||||
@then(parsers.parse('I open the "{name}" menu'))
|
||||
|
||||
@@ -102,6 +102,13 @@ def geometry():
|
||||
prophet.verify()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ifcgit():
|
||||
prophet = Prophecy(bonsai.core.tool.IfcGit)
|
||||
yield prophet
|
||||
prophet.verify()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def georeference():
|
||||
prophet = Prophecy(bonsai.core.tool.Georeference)
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2025 Dion Moult <dion@thinkmoult.com>
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
import pytest
|
||||
|
||||
import bonsai.core.ifcgit as subject
|
||||
from test.core.bootstrap import ifcgit, ifc
|
||||
|
||||
|
||||
class MockOperator:
|
||||
def __init__(self):
|
||||
self.reports = []
|
||||
|
||||
def report(self, level, message):
|
||||
self.reports.append((level, message))
|
||||
|
||||
|
||||
class TestCreateRepo:
|
||||
def test_run(self, ifcgit, ifc):
|
||||
ifc.get_path().should_be_called().will_return("path/to/model.ifc")
|
||||
ifcgit.get_path_dir("path/to/model.ifc").should_be_called().will_return("path/to")
|
||||
ifcgit.init_repo("path/to").should_be_called()
|
||||
subject.create_repo(ifcgit, ifc)
|
||||
|
||||
|
||||
class TestAddFile:
|
||||
def test_run(self, ifcgit, ifc):
|
||||
ifc.get_path().should_be_called().will_return("path/to/model.ifc")
|
||||
ifcgit.repo_from_path("path/to/model.ifc").should_be_called().will_return("repo")
|
||||
ifcgit.add_file_to_repo("repo", "path/to/model.ifc").should_be_called()
|
||||
subject.add_file(ifcgit, ifc)
|
||||
|
||||
|
||||
class TestCloneRepo:
|
||||
def test_successful_clone(self, ifcgit):
|
||||
ifcgit.clone_repo("http://example.com/repo.git", "/local/folder").should_be_called().will_return("repo")
|
||||
ifcgit.load_anyifc("repo").should_be_called()
|
||||
op = MockOperator()
|
||||
subject.clone_repo(ifcgit, "http://example.com/repo.git", "/local/folder", operator=op)
|
||||
assert op.reports == [({"INFO"}, "Repository cloned")]
|
||||
|
||||
def test_failed_clone_reports_error(self, ifcgit):
|
||||
ifcgit.clone_repo("http://example.com/repo.git", "/local/folder").should_be_called().will_return(None)
|
||||
op = MockOperator()
|
||||
subject.clone_repo(ifcgit, "http://example.com/repo.git", "/local/folder", operator=op)
|
||||
assert op.reports == [({"ERROR"}, "Clone failed")]
|
||||
|
||||
|
||||
class TestDiscardUncommitted:
|
||||
def test_run(self, ifcgit, ifc):
|
||||
ifc.get_path().should_be_called().will_return("path/to/model.ifc")
|
||||
ifcgit.git_checkout("path/to/model.ifc").should_be_called()
|
||||
ifcgit.load_project("path/to/model.ifc").should_be_called()
|
||||
subject.discard_uncommitted(ifcgit, ifc)
|
||||
|
||||
|
||||
class TestCommitChanges:
|
||||
def test_commit_on_branch_without_new_branch(self, ifcgit, ifc):
|
||||
ifc.get_path().should_be_called().will_return("path/to/model.ifc")
|
||||
ifcgit.is_head_detached().should_be_called().will_return(False)
|
||||
ifcgit.git_commit("path/to/model.ifc", "my message").should_be_called()
|
||||
subject.commit_changes(ifcgit, ifc, "my message", "")
|
||||
|
||||
def test_commit_on_branch_with_new_branch(self, ifcgit, ifc):
|
||||
ifc.get_path().should_be_called().will_return("path/to/model.ifc")
|
||||
ifcgit.is_head_detached().should_be_called().will_return(False)
|
||||
ifcgit.checkout_new_branch("path/to/model.ifc", "feature").should_be_called()
|
||||
ifcgit.git_commit("path/to/model.ifc", "my message").should_be_called()
|
||||
subject.commit_changes(ifcgit, ifc, "my message", "feature")
|
||||
|
||||
def test_commit_on_detached_head(self, ifcgit, ifc):
|
||||
ifc.get_path().should_be_called().will_return("path/to/model.ifc")
|
||||
ifcgit.is_head_detached().should_be_called().will_return(True)
|
||||
ifcgit.git_commit("path/to/model.ifc", "my message").should_be_called()
|
||||
ifcgit.create_new_branch("feature").should_be_called()
|
||||
subject.commit_changes(ifcgit, ifc, "my message", "feature")
|
||||
|
||||
|
||||
class TestAddTag:
|
||||
def test_run(self, ifcgit):
|
||||
ifcgit.add_tag("repo", "abc123", "v1.0", "Release notes").should_be_called()
|
||||
subject.add_tag(ifcgit, "repo", "abc123", "v1.0", "Release notes")
|
||||
|
||||
|
||||
class TestDeleteTag:
|
||||
def test_run(self, ifcgit):
|
||||
ifcgit.delete_tag("repo", "v1.0").should_be_called()
|
||||
subject.delete_tag(ifcgit, "repo", "v1.0")
|
||||
|
||||
|
||||
class TestAddRemote:
|
||||
def test_run(self, ifcgit):
|
||||
ifcgit.add_remote("repo", "origin", "http://example.com").should_be_called()
|
||||
subject.add_remote(ifcgit, "repo", "origin", "http://example.com")
|
||||
|
||||
|
||||
class TestDeleteRemote:
|
||||
def test_run(self, ifcgit):
|
||||
ifcgit.delete_remote("repo", "origin").should_be_called()
|
||||
subject.delete_remote(ifcgit, "repo", "origin")
|
||||
|
||||
|
||||
class TestPush:
|
||||
def test_push_succeeds_silently(self, ifcgit):
|
||||
ifcgit.get_active_branch_name().should_be_called().will_return("main")
|
||||
ifcgit.push("repo", "origin", "main").should_be_called().will_return(None)
|
||||
subject.push(ifcgit, "repo", "origin", operator=None)
|
||||
|
||||
def test_push_failure_reports_error(self, ifcgit):
|
||||
ifcgit.get_active_branch_name().should_be_called().will_return("main")
|
||||
ifcgit.push("repo", "origin", "main").should_be_called().will_return("stderr: rejected")
|
||||
op = MockOperator()
|
||||
subject.push(ifcgit, "repo", "origin", operator=op)
|
||||
assert op.reports == [({"ERROR"}, "stderr: rejected")]
|
||||
|
||||
|
||||
class TestRefreshRevisionList:
|
||||
def test_refreshes_when_repo_has_heads(self, ifcgit, ifc):
|
||||
ifcgit.clear_merge_conflicts().should_be_called()
|
||||
ifcgit.repo_has_commits().should_be_called().will_return(True)
|
||||
ifc.get_path().should_be_called().will_return("path/to/model.ifc")
|
||||
ifcgit.refresh_revision_list("path/to/model.ifc").should_be_called()
|
||||
subject.refresh_revision_list(ifcgit, ifc)
|
||||
|
||||
def test_skips_when_repo_has_no_heads(self, ifcgit, ifc):
|
||||
ifcgit.clear_merge_conflicts().should_be_called()
|
||||
ifcgit.repo_has_commits().should_be_called().will_return(False)
|
||||
subject.refresh_revision_list(ifcgit, ifc)
|
||||
# nothing else should be called — Prophecy will verify
|
||||
|
||||
|
||||
class TestColouriseRevision:
|
||||
def test_skips_when_no_step_ids(self, ifcgit):
|
||||
ifcgit.get_revisions_step_ids().should_be_called().will_return(None)
|
||||
subject.colourise_revision(ifcgit)
|
||||
|
||||
def test_colourises_with_step_ids(self, ifcgit):
|
||||
ifcgit.get_revisions_step_ids().should_be_called().will_return("step_ids")
|
||||
ifcgit.get_modified_step_ids("step_ids").should_be_called().will_return("modified_step_ids")
|
||||
ifcgit.update_step_ids("step_ids", "modified_step_ids").should_be_called().will_return("final_step_ids")
|
||||
ifcgit.colourise("final_step_ids").should_be_called()
|
||||
subject.colourise_revision(ifcgit)
|
||||
|
||||
|
||||
class TestColouriseUncommitted:
|
||||
def test_skips_when_no_step_ids(self, ifcgit, ifc):
|
||||
ifc.get_path().should_be_called().will_return("path/to/model.ifc")
|
||||
ifcgit.ifc_diff_ids("repo", None, "HEAD", "path/to/model.ifc").should_be_called().will_return(None)
|
||||
subject.colourise_uncommitted(ifcgit, ifc, "repo")
|
||||
|
||||
def test_colourises_with_step_ids(self, ifcgit, ifc):
|
||||
ifc.get_path().should_be_called().will_return("path/to/model.ifc")
|
||||
ifcgit.ifc_diff_ids("repo", None, "HEAD", "path/to/model.ifc").should_be_called().will_return("step_ids")
|
||||
ifcgit.get_modified_step_ids("step_ids").should_be_called().will_return("modified_step_ids")
|
||||
ifcgit.update_step_ids("step_ids", "modified_step_ids").should_be_called().will_return("final_step_ids")
|
||||
ifcgit.colourise("final_step_ids").should_be_called()
|
||||
subject.colourise_uncommitted(ifcgit, ifc, "repo")
|
||||
|
||||
|
||||
class TestSwitchRevision:
|
||||
def test_run(self, ifcgit, ifc):
|
||||
ifc.get_path().should_be_called().will_return("path/to/model.ifc")
|
||||
ifcgit.switch_to_revision_item().should_be_called()
|
||||
ifcgit.load_project("path/to/model.ifc").should_be_called()
|
||||
ifcgit.refresh_revision_list("path/to/model.ifc").should_be_called()
|
||||
ifcgit.decolourise().should_be_called()
|
||||
subject.switch_revision(ifcgit, ifc)
|
||||
|
||||
|
||||
class TestMergeBranch:
|
||||
def test_no_branch_at_selected_commit(self, ifcgit, ifc):
|
||||
ifc.get_path().should_be_called().will_return("path/to/model.ifc")
|
||||
ifcgit.config_ifcmerge().should_be_called()
|
||||
ifcgit.get_selected_branch().should_be_called().will_return(None)
|
||||
subject.merge_branch(ifcgit, ifc, operator=None)
|
||||
|
||||
def test_clean_merge(self, ifcgit, ifc):
|
||||
ifc.get_path().should_be_called().will_return("path/to/model.ifc")
|
||||
ifcgit.config_ifcmerge().should_be_called()
|
||||
ifcgit.get_selected_branch().should_be_called().will_return("feature")
|
||||
ifcgit.get_merge_tool("feature").should_be_called().will_return("ifcmerge-forward")
|
||||
ifcgit.git_merge("feature").should_be_called().will_return(None)
|
||||
ifcgit.clear_merge_conflicts().should_be_called()
|
||||
ifcgit.set_display_branch().should_be_called()
|
||||
ifcgit.git_checkout("path/to/model.ifc").should_be_called()
|
||||
ifcgit.load_project("path/to/model.ifc").should_be_called()
|
||||
ifcgit.refresh_revision_list("path/to/model.ifc").should_be_called()
|
||||
ifcgit.decolourise().should_be_called()
|
||||
subject.merge_branch(ifcgit, ifc, operator=None)
|
||||
|
||||
def test_conflict_mergetool_success(self, ifcgit, ifc):
|
||||
ifc.get_path().should_be_called().will_return("path/to/model.ifc")
|
||||
ifcgit.config_ifcmerge().should_be_called()
|
||||
ifcgit.get_selected_branch().should_be_called().will_return("feature")
|
||||
ifcgit.get_merge_tool("feature").should_be_called().will_return("ifcmerge-forward")
|
||||
ifcgit.git_merge("feature").should_be_called().will_return("conflict")
|
||||
ifcgit.git_mergetool("ifcmerge-forward", "path/to/model.ifc").should_be_called().will_return(None)
|
||||
ifcgit.commit_merge("path/to/model.ifc").should_be_called()
|
||||
ifcgit.clear_merge_conflicts().should_be_called()
|
||||
ifcgit.set_display_branch().should_be_called()
|
||||
ifcgit.git_checkout("path/to/model.ifc").should_be_called()
|
||||
ifcgit.load_project("path/to/model.ifc").should_be_called()
|
||||
ifcgit.refresh_revision_list("path/to/model.ifc").should_be_called()
|
||||
ifcgit.decolourise().should_be_called()
|
||||
subject.merge_branch(ifcgit, ifc, operator=None)
|
||||
|
||||
def test_conflict_mergetool_failure(self, ifcgit, ifc):
|
||||
conflicts = [{"type": "attribute_conflict", "entity_id": 42}]
|
||||
ifc.get_path().should_be_called().will_return("path/to/model.ifc")
|
||||
ifcgit.config_ifcmerge().should_be_called()
|
||||
ifcgit.get_selected_branch().should_be_called().will_return("feature")
|
||||
ifcgit.get_merge_tool("feature").should_be_called().will_return("ifcmerge-forward")
|
||||
ifcgit.git_merge("feature").should_be_called().will_return("conflict")
|
||||
ifcgit.git_mergetool("ifcmerge-forward", "path/to/model.ifc").should_be_called().will_return(conflicts)
|
||||
ifcgit.git_merge_abort().should_be_called()
|
||||
ifcgit.store_merge_conflicts(conflicts).should_be_called()
|
||||
op = MockOperator()
|
||||
subject.merge_branch(ifcgit, ifc, op)
|
||||
assert op.reports == [({"WARNING"}, "Merge failed — see the conflict report in the panel below")]
|
||||
|
||||
def test_unknown_merge_error(self, ifcgit, ifc):
|
||||
ifc.get_path().should_be_called().will_return("path/to/model.ifc")
|
||||
ifcgit.config_ifcmerge().should_be_called()
|
||||
ifcgit.get_selected_branch().should_be_called().will_return("feature")
|
||||
ifcgit.get_merge_tool("feature").should_be_called().will_return("ifcmerge-forward")
|
||||
ifcgit.git_merge("feature").should_be_called().will_return("error")
|
||||
op = MockOperator()
|
||||
subject.merge_branch(ifcgit, ifc, op)
|
||||
assert op.reports == [({"ERROR"}, "Unknown IFC Merge failure")]
|
||||
|
||||
|
||||
class TestDryRunMerge:
|
||||
def test_no_branch_at_selected_commit(self, ifcgit, ifc):
|
||||
ifc.get_path().should_be_called().will_return("path/to/model.ifc")
|
||||
ifcgit.config_ifcmerge().should_be_called()
|
||||
ifcgit.get_selected_branch().should_be_called().will_return(None)
|
||||
subject.dry_run_merge(ifcgit, ifc, operator=None)
|
||||
|
||||
def test_clean_merge_preview(self, ifcgit, ifc):
|
||||
ifc.get_path().should_be_called().will_return("path/to/model.ifc")
|
||||
ifcgit.config_ifcmerge().should_be_called()
|
||||
ifcgit.get_selected_branch().should_be_called().will_return("feature")
|
||||
ifcgit.get_merge_tool("feature").should_be_called().will_return("ifcmerge-forward")
|
||||
ifcgit.git_merge_no_commit("feature").should_be_called().will_return(None)
|
||||
ifcgit.git_merge_abort().should_be_called()
|
||||
ifcgit.clear_merge_conflicts().should_be_called()
|
||||
op = MockOperator()
|
||||
subject.dry_run_merge(ifcgit, ifc, op)
|
||||
assert op.reports == [({"INFO"}, "Merge preview: no conflicts")]
|
||||
|
||||
def test_conflict_preview_shows_report(self, ifcgit, ifc):
|
||||
conflicts = [{"type": "attribute_conflict", "entity_id": 42}]
|
||||
ifc.get_path().should_be_called().will_return("path/to/model.ifc")
|
||||
ifcgit.config_ifcmerge().should_be_called()
|
||||
ifcgit.get_selected_branch().should_be_called().will_return("feature")
|
||||
ifcgit.get_merge_tool("feature").should_be_called().will_return("ifcmerge-forward")
|
||||
ifcgit.git_merge_no_commit("feature").should_be_called().will_return("conflict")
|
||||
ifcgit.git_mergetool("ifcmerge-forward", "path/to/model.ifc").should_be_called().will_return(conflicts)
|
||||
ifcgit.git_merge_abort().should_be_called()
|
||||
ifcgit.store_merge_conflicts(conflicts).should_be_called()
|
||||
op = MockOperator()
|
||||
subject.dry_run_merge(ifcgit, ifc, op)
|
||||
assert op.reports == [({"WARNING"}, "Merge preview: conflicts found — see the panel below")]
|
||||
|
||||
def test_conflict_preview_mergetool_succeeds(self, ifcgit, ifc):
|
||||
ifc.get_path().should_be_called().will_return("path/to/model.ifc")
|
||||
ifcgit.config_ifcmerge().should_be_called()
|
||||
ifcgit.get_selected_branch().should_be_called().will_return("feature")
|
||||
ifcgit.get_merge_tool("feature").should_be_called().will_return("ifcmerge-forward")
|
||||
ifcgit.git_merge_no_commit("feature").should_be_called().will_return("conflict")
|
||||
ifcgit.git_mergetool("ifcmerge-forward", "path/to/model.ifc").should_be_called().will_return(None)
|
||||
ifcgit.git_merge_abort().should_be_called()
|
||||
ifcgit.clear_merge_conflicts().should_be_called()
|
||||
op = MockOperator()
|
||||
subject.dry_run_merge(ifcgit, ifc, op)
|
||||
assert op.reports == [({"INFO"}, "Merge preview: no conflicts")]
|
||||
|
||||
|
||||
class TestEntityLog:
|
||||
def test_run(self, ifcgit, ifc):
|
||||
ifc.get_path().should_be_called().will_return("path/to/model.ifc")
|
||||
ifcgit.entity_log("path/to/model.ifc", 42).should_be_called().will_return("log text")
|
||||
op = MockOperator()
|
||||
subject.entity_log(ifcgit, ifc, 42, op)
|
||||
assert op.reports == [({"ERROR"}, "log text")]
|
||||
|
||||
|
||||
class TestInstallGit:
|
||||
def test_windows(self, ifcgit):
|
||||
import unittest.mock as mock
|
||||
|
||||
with mock.patch("platform.system", return_value="Windows"):
|
||||
ifcgit.install_git_windows(operator="op").should_be_called()
|
||||
subject.install_git(ifcgit, "op")
|
||||
|
||||
def test_non_windows_does_nothing(self, ifcgit):
|
||||
import unittest.mock as mock
|
||||
|
||||
with mock.patch("platform.system", return_value="Linux"):
|
||||
subject.install_git(ifcgit, "op")
|
||||
# no tool method should be called — Prophecy will verify
|
||||
|
||||
|
||||
class TestFetch:
|
||||
def test_run(self, ifcgit):
|
||||
ifcgit.fetch("origin").should_be_called()
|
||||
subject.fetch(ifcgit, "origin")
|
||||
|
||||
|
||||
class TestRunGitDiff:
|
||||
def test_run(self, ifcgit):
|
||||
ifcgit.run_git_diff("operator", False).should_be_called()
|
||||
subject.run_git_diff(ifcgit, "operator", False)
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.aggregate
|
||||
import ifcopenshell.api.context
|
||||
import ifcopenshell.api.geometry
|
||||
import ifcopenshell.api.root
|
||||
@@ -99,6 +100,42 @@ class TestCanAggregate(NewFile):
|
||||
subelement_obj = bpy.data.objects.new("Object", None)
|
||||
assert subject.can_aggregate(element_obj, subelement_obj) is False
|
||||
|
||||
def test_element_cannot_aggregate_to_itself(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
element = ifc.createIfcElementAssembly()
|
||||
element_obj = bpy.data.objects.new("Object", None)
|
||||
tool.Ifc.link(element, element_obj)
|
||||
assert subject.can_aggregate(element_obj, element_obj) is False
|
||||
|
||||
def test_cyclic_aggregation_is_prevented(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
assembly_a = ifc.createIfcElementAssembly()
|
||||
assembly_a_obj = bpy.data.objects.new("AssemblyA", None)
|
||||
tool.Ifc.link(assembly_a, assembly_a_obj)
|
||||
beam = ifc.createIfcBeam()
|
||||
beam_obj = bpy.data.objects.new("Beam", None)
|
||||
tool.Ifc.link(beam, beam_obj)
|
||||
ifcopenshell.api.aggregate.assign_object(ifc, products=[beam], relating_object=assembly_a)
|
||||
assert subject.can_aggregate(beam_obj, assembly_a_obj) is False
|
||||
|
||||
def test_deep_cyclic_aggregation_is_prevented(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
assembly_a = ifc.createIfcElementAssembly()
|
||||
assembly_a_obj = bpy.data.objects.new("AssemblyA", None)
|
||||
tool.Ifc.link(assembly_a, assembly_a_obj)
|
||||
assembly_b = ifc.createIfcElementAssembly()
|
||||
assembly_b_obj = bpy.data.objects.new("AssemblyB", None)
|
||||
tool.Ifc.link(assembly_b, assembly_b_obj)
|
||||
beam = ifc.createIfcBeam()
|
||||
beam_obj = bpy.data.objects.new("Beam", None)
|
||||
tool.Ifc.link(beam, beam_obj)
|
||||
ifcopenshell.api.aggregate.assign_object(ifc, products=[assembly_b], relating_object=assembly_a)
|
||||
ifcopenshell.api.aggregate.assign_object(ifc, products=[beam], relating_object=assembly_b)
|
||||
assert subject.can_aggregate(beam_obj, assembly_a_obj) is False
|
||||
|
||||
|
||||
class TestHasPhysicalBodyRepresentation(NewFile):
|
||||
def test_run(self):
|
||||
|
||||
@@ -0,0 +1,581 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2025 Dion Moult <dion@thinkmoult.com>
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import bonsai.core.tool
|
||||
from bonsai.tool.ifcgit import IfcGit, IfcGitRepo
|
||||
from test.bim.bootstrap import NewFile
|
||||
|
||||
try:
|
||||
import git
|
||||
import git.exc
|
||||
|
||||
HAS_GIT = True
|
||||
except ImportError:
|
||||
HAS_GIT = False
|
||||
|
||||
import pytest
|
||||
|
||||
requires_git = pytest.mark.skipif(not HAS_GIT, reason="GitPython not available")
|
||||
|
||||
|
||||
def _make_repo(tmpdir: str) -> "git.Repo":
|
||||
"""Initialise a git repo with user config (needed for commits)."""
|
||||
repo = git.Repo.init(tmpdir)
|
||||
with repo.config_writer() as cfg:
|
||||
cfg.set_value("user", "name", "Test User")
|
||||
cfg.set_value("user", "email", "test@example.com")
|
||||
return repo
|
||||
|
||||
|
||||
def _commit_ifc(repo: "git.Repo", tmpdir: str, content: str, message: str) -> str:
|
||||
"""Write content to model.ifc, stage and commit it. Returns commit hexsha."""
|
||||
ifc_path = os.path.join(tmpdir, "model.ifc")
|
||||
with open(ifc_path, "w") as f:
|
||||
f.write(content)
|
||||
repo.index.add([os.path.normpath(ifc_path)])
|
||||
commit = repo.index.commit(message)
|
||||
return commit.hexsha
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Interface conformance
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestImplementsTool(NewFile):
|
||||
def test_run(self):
|
||||
assert isinstance(IfcGit(), bonsai.core.tool.IfcGit)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pure-logic (no git required)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIsValidRefFormat(NewFile):
|
||||
def test_simple_name(self):
|
||||
assert IfcGit.is_valid_ref_format("main")
|
||||
|
||||
def test_name_with_slash(self):
|
||||
assert IfcGit.is_valid_ref_format("feature/my-feature")
|
||||
|
||||
def test_name_with_numbers(self):
|
||||
assert IfcGit.is_valid_ref_format("release-2024")
|
||||
|
||||
def test_rejects_leading_dot(self):
|
||||
assert not IfcGit.is_valid_ref_format(".hidden")
|
||||
|
||||
def test_rejects_leading_dash(self):
|
||||
assert not IfcGit.is_valid_ref_format("-branch")
|
||||
|
||||
def test_rejects_space(self):
|
||||
assert not IfcGit.is_valid_ref_format("my branch")
|
||||
|
||||
def test_rejects_double_dot(self):
|
||||
assert not IfcGit.is_valid_ref_format("my..branch")
|
||||
|
||||
def test_rejects_tilde(self):
|
||||
assert not IfcGit.is_valid_ref_format("my~branch")
|
||||
|
||||
def test_rejects_caret(self):
|
||||
assert not IfcGit.is_valid_ref_format("my^branch")
|
||||
|
||||
def test_rejects_trailing_dot(self):
|
||||
assert not IfcGit.is_valid_ref_format("branch.")
|
||||
|
||||
def test_rejects_trailing_slash(self):
|
||||
assert not IfcGit.is_valid_ref_format("branch/")
|
||||
|
||||
def test_rejects_dot_lock_suffix(self):
|
||||
assert not IfcGit.is_valid_ref_format("branch.lock")
|
||||
|
||||
def test_rejects_empty_string(self):
|
||||
assert not IfcGit.is_valid_ref_format("")
|
||||
|
||||
def test_rejects_at_brace(self):
|
||||
assert not IfcGit.is_valid_ref_format("branch@{upstream}")
|
||||
|
||||
|
||||
class TestGetPathDir(NewFile):
|
||||
def test_returns_parent_directory(self):
|
||||
assert IfcGit.get_path_dir("/some/path/model.ifc") == "/some/path"
|
||||
|
||||
def test_handles_nested_path(self):
|
||||
assert IfcGit.get_path_dir("/a/b/c/d.ifc") == "/a/b/c"
|
||||
|
||||
def test_returns_absolute_path(self):
|
||||
result = IfcGit.get_path_dir("/a/b/model.ifc")
|
||||
assert os.path.isabs(result)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# File I/O
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDos2Unix(NewFile):
|
||||
def test_converts_crlf_to_lf(self):
|
||||
with tempfile.NamedTemporaryFile(suffix=".ifc", delete=False, mode="wb") as f:
|
||||
f.write(b"line1\r\nline2\r\nline3\r\n")
|
||||
path = f.name
|
||||
try:
|
||||
IfcGit.dos2unix(path)
|
||||
with open(path, "rb") as f:
|
||||
assert f.read() == b"line1\nline2\nline3\n"
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_lf_only_file_is_unchanged(self):
|
||||
with tempfile.NamedTemporaryFile(suffix=".ifc", delete=False, mode="wb") as f:
|
||||
f.write(b"line1\nline2\nline3\n")
|
||||
path = f.name
|
||||
try:
|
||||
IfcGit.dos2unix(path)
|
||||
with open(path, "rb") as f:
|
||||
assert f.read() == b"line1\nline2\nline3\n"
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_empty_file_unchanged(self):
|
||||
with tempfile.NamedTemporaryFile(suffix=".ifc", delete=False, mode="wb") as f:
|
||||
f.write(b"")
|
||||
path = f.name
|
||||
try:
|
||||
IfcGit.dos2unix(path)
|
||||
with open(path, "rb") as f:
|
||||
assert f.read() == b""
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Git repo initialisation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestInitRepo(NewFile):
|
||||
@requires_git
|
||||
def test_creates_git_repo(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
IfcGitRepo.repo = None
|
||||
IfcGit.init_repo(tmpdir)
|
||||
assert IfcGitRepo.repo is not None
|
||||
assert os.path.isdir(IfcGitRepo.repo.git_dir)
|
||||
IfcGitRepo.repo = None
|
||||
|
||||
@requires_git
|
||||
def test_creates_info_attributes_file(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
IfcGitRepo.repo = None
|
||||
IfcGit.init_repo(tmpdir)
|
||||
attrs_path = os.path.join(IfcGitRepo.repo.git_dir, "info", "attributes")
|
||||
assert os.path.isfile(attrs_path)
|
||||
with open(attrs_path) as f:
|
||||
assert "*.ifc text" in f.read()
|
||||
IfcGitRepo.repo = None
|
||||
|
||||
|
||||
class TestRepoFromPath(NewFile):
|
||||
@requires_git
|
||||
def test_finds_repo_from_file_in_root(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
IfcGitRepo.repo = None
|
||||
repo = _make_repo(tmpdir)
|
||||
ifc_path = os.path.join(tmpdir, "model.ifc")
|
||||
open(ifc_path, "w").close()
|
||||
result = IfcGit.repo_from_path(ifc_path)
|
||||
assert result is not None
|
||||
assert os.path.abspath(result.working_dir) == os.path.abspath(tmpdir)
|
||||
IfcGitRepo.repo = None
|
||||
|
||||
@requires_git
|
||||
def test_finds_repo_from_subdirectory(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
IfcGitRepo.repo = None
|
||||
_make_repo(tmpdir)
|
||||
subdir = os.path.join(tmpdir, "sub", "dir")
|
||||
os.makedirs(subdir)
|
||||
result = IfcGit.repo_from_path(subdir)
|
||||
assert result is not None
|
||||
IfcGitRepo.repo = None
|
||||
|
||||
@requires_git
|
||||
def test_returns_none_for_path_outside_any_repo(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
IfcGitRepo.repo = None
|
||||
# a plain directory with no .git anywhere above it (using /tmp directly
|
||||
# is safe since /tmp is not a git repo on this system)
|
||||
result = IfcGit.repo_from_path("/nonexistent/path/that/does/not/exist")
|
||||
assert result is None
|
||||
IfcGitRepo.repo = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Git repo configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestConfigInfoAttributes(NewFile):
|
||||
@requires_git
|
||||
def test_creates_attributes_file(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
repo = _make_repo(tmpdir)
|
||||
IfcGit.config_info_attributes(repo)
|
||||
attrs_path = os.path.join(repo.git_dir, "info", "attributes")
|
||||
assert os.path.isfile(attrs_path)
|
||||
with open(attrs_path) as f:
|
||||
assert "*.ifc text" in f.read()
|
||||
|
||||
@requires_git
|
||||
def test_does_not_overwrite_existing_attributes(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
repo = _make_repo(tmpdir)
|
||||
attrs_path = os.path.join(repo.git_dir, "info", "attributes")
|
||||
os.makedirs(os.path.dirname(attrs_path), exist_ok=True)
|
||||
with open(attrs_path, "w") as f:
|
||||
f.write("*.png binary\n")
|
||||
IfcGit.config_info_attributes(repo)
|
||||
with open(attrs_path) as f:
|
||||
content = f.read()
|
||||
assert "*.png binary" in content # original content preserved
|
||||
|
||||
|
||||
class TestConfigPush(NewFile):
|
||||
@requires_git
|
||||
def test_sets_push_defaults(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
repo = _make_repo(tmpdir)
|
||||
IfcGit.config_push(repo)
|
||||
reader = repo.config_reader()
|
||||
assert reader.get_value("push", "default") == "current"
|
||||
assert reader.get_value("push", "autoSetupRemote") is True
|
||||
|
||||
@requires_git
|
||||
def test_does_not_overwrite_existing_push_section(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
repo = _make_repo(tmpdir)
|
||||
with repo.config_writer() as w:
|
||||
w.set_value("push", "default", "simple")
|
||||
IfcGit.config_push(repo)
|
||||
reader = repo.config_reader()
|
||||
assert reader.get_value("push", "default") == "simple"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Branch / tag lookups
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBranchesByHexsha(NewFile):
|
||||
@requires_git
|
||||
def test_maps_head_commit_to_active_branch(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
repo = _make_repo(tmpdir)
|
||||
_commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init")
|
||||
result = IfcGit.branches_by_hexsha(repo)
|
||||
head_sha = repo.head.commit.hexsha
|
||||
assert head_sha in result
|
||||
names = [b.name for b in result[head_sha]]
|
||||
assert repo.active_branch.name in names
|
||||
|
||||
@requires_git
|
||||
def test_returns_empty_dict_when_no_commits(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
repo = _make_repo(tmpdir)
|
||||
result = IfcGit.branches_by_hexsha(repo)
|
||||
assert result == {}
|
||||
|
||||
@requires_git
|
||||
def test_includes_both_branches_when_pointing_to_same_commit(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
repo = _make_repo(tmpdir)
|
||||
_commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init")
|
||||
repo.create_head("feature")
|
||||
result = IfcGit.branches_by_hexsha(repo)
|
||||
head_sha = repo.head.commit.hexsha
|
||||
names = [b.name for b in result[head_sha]]
|
||||
assert len(names) == 2
|
||||
|
||||
|
||||
class TestTagsByHexsha(NewFile):
|
||||
@requires_git
|
||||
def test_returns_empty_dict_when_no_tags(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
repo = _make_repo(tmpdir)
|
||||
_commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init")
|
||||
assert IfcGit.tags_by_hexsha(repo) == {}
|
||||
|
||||
@requires_git
|
||||
def test_maps_commit_to_annotated_tag(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
repo = _make_repo(tmpdir)
|
||||
_commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init")
|
||||
repo.create_tag("v1.0", message="Release 1.0")
|
||||
result = IfcGit.tags_by_hexsha(repo)
|
||||
head_sha = repo.head.commit.hexsha
|
||||
assert head_sha in result
|
||||
assert result[head_sha][0].name == "v1.0"
|
||||
|
||||
@requires_git
|
||||
def test_maps_commit_to_lightweight_tag(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
repo = _make_repo(tmpdir)
|
||||
_commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init")
|
||||
repo.create_tag("v0.1")
|
||||
result = IfcGit.tags_by_hexsha(repo)
|
||||
head_sha = repo.head.commit.hexsha
|
||||
assert head_sha in result
|
||||
|
||||
|
||||
class TestDeleteTag(NewFile):
|
||||
@requires_git
|
||||
def test_removes_existing_tag(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
repo = _make_repo(tmpdir)
|
||||
_commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init")
|
||||
repo.create_tag("v1.0")
|
||||
IfcGit.delete_tag(repo, "v1.0")
|
||||
assert "v1.0" not in [t.name for t in repo.tags]
|
||||
|
||||
@requires_git
|
||||
def test_does_not_raise_for_nonexistent_tag(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
repo = _make_repo(tmpdir)
|
||||
_commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init")
|
||||
IfcGit.delete_tag(repo, "does-not-exist") # must not raise
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# IFC diff parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIfcDiffIds(NewFile):
|
||||
@requires_git
|
||||
def test_detects_modified_entity(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
repo = _make_repo(tmpdir)
|
||||
sha_a = _commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init")
|
||||
ifc_path = os.path.join(tmpdir, "model.ifc")
|
||||
sha_b = _commit_ifc(repo, tmpdir, "#1=IFCPROJECT('xyz',$,$,$,$,$,$,$,$);\n", "update")
|
||||
result = IfcGit.ifc_diff_ids(repo, sha_a, sha_b, ifc_path)
|
||||
assert 1 in result["modified"]
|
||||
assert result["added"] == set()
|
||||
assert result["removed"] == set()
|
||||
|
||||
@requires_git
|
||||
def test_detects_added_entity(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
repo = _make_repo(tmpdir)
|
||||
sha_a = _commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init")
|
||||
ifc_path = os.path.join(tmpdir, "model.ifc")
|
||||
sha_b = _commit_ifc(
|
||||
repo,
|
||||
tmpdir,
|
||||
"#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n#2=IFCSITE('new',$,$,$,$,$,$,$,$,$,$,$,$);\n",
|
||||
"add site",
|
||||
)
|
||||
result = IfcGit.ifc_diff_ids(repo, sha_a, sha_b, ifc_path)
|
||||
assert 2 in result["added"]
|
||||
assert 1 not in result["modified"]
|
||||
assert result["removed"] == set()
|
||||
|
||||
@requires_git
|
||||
def test_detects_removed_entity(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
repo = _make_repo(tmpdir)
|
||||
sha_a = _commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init")
|
||||
ifc_path = os.path.join(tmpdir, "model.ifc")
|
||||
sha_b = _commit_ifc(repo, tmpdir, "", "clear")
|
||||
result = IfcGit.ifc_diff_ids(repo, sha_a, sha_b, ifc_path)
|
||||
assert 1 in result["removed"]
|
||||
assert result["added"] == set()
|
||||
assert result["modified"] == set()
|
||||
|
||||
@requires_git
|
||||
def test_no_changes_between_identical_commits(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
repo = _make_repo(tmpdir)
|
||||
sha = _commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init")
|
||||
ifc_path = os.path.join(tmpdir, "model.ifc")
|
||||
result = IfcGit.ifc_diff_ids(repo, sha, sha, ifc_path)
|
||||
assert result["modified"] == set()
|
||||
assert result["added"] == set()
|
||||
assert result["removed"] == set()
|
||||
|
||||
@requires_git
|
||||
def test_diff_against_working_tree_with_none_hash_a(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
repo = _make_repo(tmpdir)
|
||||
_commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init")
|
||||
ifc_path = os.path.join(tmpdir, "model.ifc")
|
||||
# Make an uncommitted change in working tree
|
||||
with open(ifc_path, "w") as f:
|
||||
f.write("#1=IFCPROJECT('xyz',$,$,$,$,$,$,$,$);\n")
|
||||
result = IfcGit.ifc_diff_ids(repo, None, "HEAD", ifc_path)
|
||||
assert 1 in result["modified"]
|
||||
|
||||
@requires_git
|
||||
def test_handles_multiple_changed_entities(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
repo = _make_repo(tmpdir)
|
||||
sha_a = _commit_ifc(
|
||||
repo,
|
||||
tmpdir,
|
||||
"#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n#2=IFCSITE('s',$,$,$,$,$,$,$,$,$,$,$,$);\n",
|
||||
"init",
|
||||
)
|
||||
ifc_path = os.path.join(tmpdir, "model.ifc")
|
||||
sha_b = _commit_ifc(
|
||||
repo,
|
||||
tmpdir,
|
||||
"#1=IFCPROJECT('xyz',$,$,$,$,$,$,$,$);\n#2=IFCSITE('t',$,$,$,$,$,$,$,$,$,$,$,$);\n",
|
||||
"update both",
|
||||
)
|
||||
result = IfcGit.ifc_diff_ids(repo, sha_a, sha_b, ifc_path)
|
||||
assert 1 in result["modified"]
|
||||
assert 2 in result["modified"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Merge conflict report — store / clear / get
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStoreClearGetMergeConflicts(NewFile):
|
||||
def test_round_trip(self):
|
||||
conflicts = [{"type": "attribute_conflict", "entity_id": 42}]
|
||||
IfcGit.store_merge_conflicts(conflicts)
|
||||
result = IfcGit.get_merge_conflicts()
|
||||
assert result == conflicts
|
||||
|
||||
def test_get_returns_none_when_empty(self):
|
||||
IfcGit.clear_merge_conflicts()
|
||||
assert IfcGit.get_merge_conflicts() is None
|
||||
|
||||
def test_clear_removes_stored_conflicts(self):
|
||||
IfcGit.store_merge_conflicts([{"type": "class_changed"}])
|
||||
IfcGit.clear_merge_conflicts()
|
||||
assert IfcGit.get_merge_conflicts() is None
|
||||
|
||||
def test_get_returns_none_on_corrupt_json(self):
|
||||
import bpy
|
||||
|
||||
bpy.context.scene.IfcGitProperties.merge_conflicts = "not valid json {"
|
||||
assert IfcGit.get_merge_conflicts() is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# git_mergetool — report file reading
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGitMergetool:
|
||||
@requires_git
|
||||
def test_returns_none_when_report_file_absent(self):
|
||||
import unittest.mock as mock
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
ifc_path = os.path.join(tmpdir, "model.ifc")
|
||||
mock_repo = mock.MagicMock()
|
||||
IfcGitRepo.repo = mock_repo
|
||||
result = IfcGit.git_mergetool("ifcmerge", ifc_path)
|
||||
assert result is None
|
||||
IfcGitRepo.repo = None
|
||||
|
||||
@requires_git
|
||||
def test_returns_none_when_report_file_empty(self):
|
||||
import unittest.mock as mock
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
ifc_path = os.path.join(tmpdir, "model.ifc")
|
||||
report_path = ifc_path + ".ifcmerge"
|
||||
open(report_path, "w").close()
|
||||
mock_repo = mock.MagicMock()
|
||||
IfcGitRepo.repo = mock_repo
|
||||
result = IfcGit.git_mergetool("ifcmerge", ifc_path)
|
||||
assert result is None
|
||||
assert not os.path.exists(report_path)
|
||||
IfcGitRepo.repo = None
|
||||
|
||||
@requires_git
|
||||
def test_parses_conflict_report_and_deletes_file(self):
|
||||
import json
|
||||
import unittest.mock as mock
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
ifc_path = os.path.join(tmpdir, "model.ifc")
|
||||
report_path = ifc_path + ".ifcmerge"
|
||||
conflicts = [{"type": "attribute_conflict", "entity_id": 5}]
|
||||
with open(report_path, "w") as f:
|
||||
json.dump({"status": "failed", "conflicts": conflicts}, f)
|
||||
mock_repo = mock.MagicMock()
|
||||
mock_repo.git.mergetool.side_effect = git.exc.GitCommandError("mergetool", 1)
|
||||
IfcGitRepo.repo = mock_repo
|
||||
result = IfcGit.git_mergetool("ifcmerge", ifc_path)
|
||||
assert result == conflicts
|
||||
assert not os.path.exists(report_path)
|
||||
IfcGitRepo.repo = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# config_ifcmerge — cmd format and update
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestConfigIfcmerge:
|
||||
@requires_git
|
||||
def test_writes_redirect_cmd_on_first_call(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
repo = _make_repo(tmpdir)
|
||||
IfcGitRepo.repo = repo
|
||||
IfcGit.config_ifcmerge()
|
||||
reader = repo.config_reader()
|
||||
cmd = reader.get_value('mergetool "ifcmerge"', "cmd")
|
||||
assert "> $MERGED.ifcmerge" in cmd
|
||||
IfcGitRepo.repo = None
|
||||
|
||||
@requires_git
|
||||
def test_updates_cmd_missing_redirect(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
repo = _make_repo(tmpdir)
|
||||
IfcGitRepo.repo = repo
|
||||
with repo.config_writer() as w:
|
||||
w.set_value('mergetool "ifcmerge"', "cmd", "ifcmerge $BASE $LOCAL $REMOTE $MERGED")
|
||||
w.set_value('mergetool "ifcmerge"', "trustExitCode", True)
|
||||
IfcGit.config_ifcmerge()
|
||||
reader = repo.config_reader()
|
||||
cmd = reader.get_value('mergetool "ifcmerge"', "cmd")
|
||||
assert "> $MERGED.ifcmerge" in cmd
|
||||
IfcGitRepo.repo = None
|
||||
|
||||
@requires_git
|
||||
def test_forward_tool_writes_redirect_cmd(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
repo = _make_repo(tmpdir)
|
||||
IfcGitRepo.repo = repo
|
||||
IfcGit.config_ifcmerge()
|
||||
reader = repo.config_reader()
|
||||
cmd = reader.get_value('mergetool "ifcmerge-forward"', "cmd")
|
||||
assert "--prioritise-local" in cmd
|
||||
assert "> $MERGED.ifcmerge" in cmd
|
||||
IfcGitRepo.repo = None
|
||||
@@ -19,6 +19,7 @@
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api
|
||||
import ifcopenshell.api.nest
|
||||
import ifcopenshell.api.spatial
|
||||
|
||||
import bonsai.core.tool
|
||||
@@ -51,6 +52,42 @@ class TestCanNest(NewFile):
|
||||
subelement_obj = bpy.data.objects.new("Object", None)
|
||||
assert subject.can_nest(element_obj, subelement_obj) is False
|
||||
|
||||
def test_element_cannot_nest_to_itself(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
element = ifc.createIfcWall()
|
||||
element_obj = bpy.data.objects.new("Object", None)
|
||||
tool.Ifc.link(element, element_obj)
|
||||
assert subject.can_nest(element_obj, element_obj) is False
|
||||
|
||||
def test_cyclic_nesting_is_prevented(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
wall_a = ifc.createIfcWall()
|
||||
wall_a_obj = bpy.data.objects.new("WallA", None)
|
||||
tool.Ifc.link(wall_a, wall_a_obj)
|
||||
wall_b = ifc.createIfcWall()
|
||||
wall_b_obj = bpy.data.objects.new("WallB", None)
|
||||
tool.Ifc.link(wall_b, wall_b_obj)
|
||||
ifcopenshell.api.nest.assign_object(ifc, related_objects=[wall_b], relating_object=wall_a)
|
||||
assert subject.can_nest(wall_b_obj, wall_a_obj) is False
|
||||
|
||||
def test_deep_cyclic_nesting_is_prevented(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
wall_a = ifc.createIfcWall()
|
||||
wall_a_obj = bpy.data.objects.new("WallA", None)
|
||||
tool.Ifc.link(wall_a, wall_a_obj)
|
||||
wall_b = ifc.createIfcWall()
|
||||
wall_b_obj = bpy.data.objects.new("WallB", None)
|
||||
tool.Ifc.link(wall_b, wall_b_obj)
|
||||
wall_c = ifc.createIfcWall()
|
||||
wall_c_obj = bpy.data.objects.new("WallC", None)
|
||||
tool.Ifc.link(wall_c, wall_c_obj)
|
||||
ifcopenshell.api.nest.assign_object(ifc, related_objects=[wall_b], relating_object=wall_a)
|
||||
ifcopenshell.api.nest.assign_object(ifc, related_objects=[wall_c], relating_object=wall_b)
|
||||
assert subject.can_nest(wall_c_obj, wall_a_obj) is False
|
||||
|
||||
|
||||
class TestDisableEditing(NewFile):
|
||||
def test_run(self):
|
||||
|
||||
@@ -28,6 +28,7 @@ dist:
|
||||
mkdir -p dist
|
||||
cp -r $(PACKAGE_NAME) build/
|
||||
cp pyproject.toml build/
|
||||
if [ -f README.md ]; then cp README.md build/; fi
|
||||
ifeq ($(IS_STABLE), TRUE)
|
||||
$(SED) 's/version = "0.0.0"/version = "$(VERSION)"/' build/pyproject.toml
|
||||
ifdef IS_MODULE
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ai-chat.ifcopenshell.org
|
||||
@@ -0,0 +1,10 @@
|
||||
IfcOpenShell AI Assistant
|
||||
=========================
|
||||
|
||||
A web-based client-side (pyodide + OpenAI, Anthropic, Gemini, or OpenRouter API) model interrogation and generation API based on: ifcedit, ifcquery and ifcmcp (ifcopenshell-mcp) packaged in a HTML+JS application.
|
||||
|
||||
### Setup instructions
|
||||
|
||||
```
|
||||
pip download ifcquery==0.8.5 ifcopenshell-mcp==0.8.5 ifcedit==0.8.5 lark==1.3.1 isodate==0.7.2 --no-deps -d ./dist
|
||||
```
|
||||
@@ -0,0 +1,184 @@
|
||||
// This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
function parseArguments(argumentsText) {
|
||||
if (!argumentsText) return {};
|
||||
try {
|
||||
return JSON.parse(argumentsText);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function toAnthropicTools(tools = []) {
|
||||
return tools.map((tool) => ({
|
||||
name: tool.function.name,
|
||||
description: tool.function.description,
|
||||
input_schema: tool.function.parameters,
|
||||
}));
|
||||
}
|
||||
|
||||
function toAnthropicAssistantContent(message) {
|
||||
const content = [];
|
||||
|
||||
if (message.content) {
|
||||
content.push({ type: "text", text: message.content });
|
||||
}
|
||||
|
||||
for (const toolCall of message.tool_calls ?? []) {
|
||||
content.push({
|
||||
type: "tool_use",
|
||||
id: toolCall.id,
|
||||
name: toolCall.function.name,
|
||||
input: parseArguments(toolCall.function.arguments),
|
||||
});
|
||||
}
|
||||
|
||||
if (content.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return content.length === 1 && content[0].type === "text" ? content[0].text : content;
|
||||
}
|
||||
|
||||
function toAnthropicUserContent(message) {
|
||||
return typeof message.content === "string" ? message.content : JSON.stringify(message.content ?? "");
|
||||
}
|
||||
|
||||
function toAnthropicToolResult(message) {
|
||||
return {
|
||||
type: "tool_result",
|
||||
tool_use_id: message.tool_call_id,
|
||||
content: typeof message.content === "string" ? message.content : JSON.stringify(message.content ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
function splitSystemAndMessages(messages = []) {
|
||||
const system = [];
|
||||
const anthropicMessages = [];
|
||||
let pendingToolResults = [];
|
||||
|
||||
const flushToolResults = () => {
|
||||
if (pendingToolResults.length === 0) return;
|
||||
anthropicMessages.push({ role: "user", content: pendingToolResults });
|
||||
pendingToolResults = [];
|
||||
};
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.role === "system") {
|
||||
if (message.content) {
|
||||
system.push(message.content);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.role === "tool") {
|
||||
pendingToolResults.push(toAnthropicToolResult(message));
|
||||
continue;
|
||||
}
|
||||
|
||||
flushToolResults();
|
||||
|
||||
if (message.role === "user") {
|
||||
anthropicMessages.push({
|
||||
role: "user",
|
||||
content: toAnthropicUserContent(message),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.role === "assistant") {
|
||||
anthropicMessages.push({
|
||||
role: "assistant",
|
||||
content: toAnthropicAssistantContent(message),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
flushToolResults();
|
||||
|
||||
return {
|
||||
system: system.join("\n\n"),
|
||||
messages: anthropicMessages,
|
||||
};
|
||||
}
|
||||
|
||||
function toChatCompletionResponse(response) {
|
||||
const text = [];
|
||||
const toolCalls = [];
|
||||
|
||||
for (const block of response.content ?? []) {
|
||||
if (block.type === "text") {
|
||||
text.push(block.text);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (block.type === "tool_use") {
|
||||
toolCalls.push({
|
||||
id: block.id,
|
||||
type: "function",
|
||||
function: {
|
||||
name: block.name,
|
||||
arguments: JSON.stringify(block.input ?? {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const message = { role: "assistant" };
|
||||
const content = text.join("\n").trim();
|
||||
|
||||
if (content) {
|
||||
message.content = content;
|
||||
}
|
||||
|
||||
if (toolCalls.length) {
|
||||
message.tool_calls = toolCalls;
|
||||
}
|
||||
|
||||
return {
|
||||
choices: [
|
||||
{
|
||||
message,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export async function chat({ apiKey, model, messages, tools }) {
|
||||
const request = splitSystemAndMessages(messages);
|
||||
const anthropicTools = toAnthropicTools(tools);
|
||||
|
||||
// Mark the last tool with cache_control so the entire tool list is cached
|
||||
if (anthropicTools.length > 0) {
|
||||
anthropicTools[anthropicTools.length - 1].cache_control = { type: "ephemeral" };
|
||||
}
|
||||
|
||||
const body = {
|
||||
model,
|
||||
max_tokens: 4096,
|
||||
messages: request.messages,
|
||||
tools: anthropicTools,
|
||||
};
|
||||
|
||||
if (request.system) {
|
||||
body.system = [{ type: "text", text: request.system, cache_control: { type: "ephemeral" } }];
|
||||
}
|
||||
|
||||
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": apiKey,
|
||||
"anthropic-version": "2023-06-01",
|
||||
"anthropic-dangerous-direct-browser-access": "true",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Anthropic error ${res.status}: ${text}`);
|
||||
}
|
||||
|
||||
return toChatCompletionResponse(await res.json());
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
function getChatCompletionsUrl(baseURL) {
|
||||
const root = (baseURL || "https://api.openai.com/v1").replace(/\/+$/, "");
|
||||
return `${root}/chat/completions`;
|
||||
}
|
||||
|
||||
export async function chat({ apiKey, baseURL, model, messages, tools }) {
|
||||
const res = await fetch(getChatCompletionsUrl(baseURL), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({ model, messages, tools }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`OpenAI error ${res.status}: ${text}`);
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
function getChatCompletionsUrl(baseURL) {
|
||||
const root = (baseURL || "https://openrouter.ai/api/v1").replace(/\/+$/, "");
|
||||
return `${root}/chat/completions`;
|
||||
}
|
||||
|
||||
export async function chat({ apiKey, baseURL, model, messages, tools }) {
|
||||
const res = await fetch(getChatCompletionsUrl(baseURL), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({ model, messages, tools }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`OpenRouter error ${res.status}: ${text}`);
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
@@ -0,0 +1,652 @@
|
||||
// app.js
|
||||
import * as openaiApi from "./api_openai.js";
|
||||
import * as anthropicApi from "./api_anthropic.js";
|
||||
import * as openrouterApi from "./api_openrouter.js";
|
||||
|
||||
const PROVIDERS = {
|
||||
openai: {
|
||||
api: openaiApi,
|
||||
apiKeyLabel: "OpenAI API key",
|
||||
apiKeyPlaceholder: "sk-...",
|
||||
baseUrlLabel: "Base URL",
|
||||
baseUrlPlaceholder: "https://api.openai.com/v1",
|
||||
baseUrlDefault: "https://api.openai.com/v1",
|
||||
models: [
|
||||
{
|
||||
value: "gpt-5.2",
|
||||
label: "gpt-5.2"
|
||||
},
|
||||
{
|
||||
value: "gpt-5.2-chat-latest",
|
||||
label: "gpt-5.2-chat-latest"
|
||||
},
|
||||
{
|
||||
value: "gpt-5",
|
||||
label: "gpt-5"
|
||||
},
|
||||
{
|
||||
value: "gpt-5-chat-latest",
|
||||
label: "gpt-5-chat-latest"
|
||||
},
|
||||
{
|
||||
value: "gpt-5-mini",
|
||||
label: "gpt-5-mini"
|
||||
},
|
||||
{
|
||||
value: "gpt-5-nano",
|
||||
label: "gpt-5-nano"
|
||||
},
|
||||
{
|
||||
value: "gpt-4.1",
|
||||
label: "gpt-4.1"
|
||||
},
|
||||
{
|
||||
value: "gpt-4.1-mini",
|
||||
label: "gpt-4.1-mini"
|
||||
},
|
||||
{
|
||||
value: "gpt-4.1-nano",
|
||||
label: "gpt-4.1-nano"
|
||||
},
|
||||
],
|
||||
},
|
||||
anthropic: {
|
||||
api: anthropicApi,
|
||||
apiKeyLabel: "Anthropic API key",
|
||||
apiKeyPlaceholder: "sk-ant-...",
|
||||
models: [
|
||||
{
|
||||
value: "claude-sonnet-4-6",
|
||||
label: "claude-sonnet-4-6"
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-6",
|
||||
label: "claude-opus-4-6"
|
||||
},
|
||||
{
|
||||
value: "claude-haiku-4-5-20251001",
|
||||
label: "claude-haiku-4-5"
|
||||
},
|
||||
],
|
||||
},
|
||||
gemini: {
|
||||
api: openaiApi,
|
||||
apiKeyLabel: "Gemini API key",
|
||||
apiKeyPlaceholder: "AIza...",
|
||||
baseUrlLabel: "Base URL",
|
||||
baseUrlPlaceholder: "https://generativelanguage.googleapis.com/v1beta/openai/",
|
||||
baseUrlDefault: "https://generativelanguage.googleapis.com/v1beta/openai/",
|
||||
models: [
|
||||
{
|
||||
value: "gemini-3-flash-preview",
|
||||
label: "gemini-3-flash-preview"
|
||||
},
|
||||
{
|
||||
value: "gemini-2.5-flash",
|
||||
label: "gemini-2.5-flash"
|
||||
},
|
||||
{
|
||||
value: "gemini-2.5-pro",
|
||||
label: "gemini-2.5-pro"
|
||||
},
|
||||
],
|
||||
},
|
||||
openrouter: {
|
||||
api: openrouterApi,
|
||||
apiKeyLabel: "OpenRouter API key",
|
||||
apiKeyPlaceholder: "sk-or-v1-...",
|
||||
baseUrlLabel: "Base URL",
|
||||
baseUrlPlaceholder: "https://openrouter.ai/api/v1",
|
||||
baseUrlDefault: "https://openrouter.ai/api/v1",
|
||||
models: [
|
||||
{
|
||||
value: "openai/gpt-oss-20b",
|
||||
label: "gpt-oss-20b"
|
||||
},
|
||||
{
|
||||
value: "openai/gpt-oss-120b",
|
||||
label: "gpt-oss-120b"
|
||||
},
|
||||
{
|
||||
value: "mistralai/mistral-small-3.2-24b-instruct",
|
||||
label: "mistral-small-3.2"
|
||||
},
|
||||
{
|
||||
value: "openai/gpt-4.1",
|
||||
label: "gpt-4.1"
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-sonnet-4-5",
|
||||
label: "claude-sonnet-4-5"
|
||||
},
|
||||
{
|
||||
value: "google/gemini-2.5-pro-preview",
|
||||
label: "gemini-2.5-pro"
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
const statusEl = $("status");
|
||||
const msgsEl = $("msgs");
|
||||
const sendBtn = $("send");
|
||||
const inputEl = $("input");
|
||||
const apiKeyEl = $("apiKey");
|
||||
const apiKeyLabelEl = $("apiKeyLabel");
|
||||
const baseUrlRowEl = $("baseUrlRow");
|
||||
const baseUrlLabelEl = $("baseUrlLabel");
|
||||
const baseUrlEl = $("baseUrl");
|
||||
const thinkingIndicatorEl = $("thinkingIndicator");
|
||||
const modelEl = $("model");
|
||||
const providerEls = document.querySelectorAll('input[name="provider"]');
|
||||
const ifcFileEl = $("ifcFile");
|
||||
const newBtn = $("newModel");
|
||||
const downloadBtn = $("downloadIfc");
|
||||
|
||||
function getProviderValue() {
|
||||
return document.querySelector('input[name="provider"]:checked')?.value || "openai";
|
||||
}
|
||||
|
||||
function onProviderChange() {
|
||||
const provider = PROVIDERS[getProviderValue()];
|
||||
apiKeyLabelEl.innerHTML = `${provider.apiKeyLabel}<span class="small">stored in browser memory; only sent to provider servers</span>`;
|
||||
apiKeyEl.placeholder = provider.apiKeyPlaceholder;
|
||||
baseUrlRowEl.hidden = !provider.baseUrlDefault;
|
||||
if (provider.baseUrlDefault) {
|
||||
baseUrlLabelEl.innerHTML = `${provider.baseUrlLabel}<span class="small">override the API endpoint for OpenAI-compatible providers</span>`;
|
||||
baseUrlEl.placeholder = provider.baseUrlPlaceholder;
|
||||
baseUrlEl.value = provider.baseUrlDefault;
|
||||
} else {
|
||||
baseUrlEl.value = "";
|
||||
baseUrlEl.placeholder = "";
|
||||
}
|
||||
modelEl.innerHTML = provider.models.map(m => `<option value="${m.value}">${m.label}</option>`).join("");
|
||||
}
|
||||
|
||||
for (const providerEl of providerEls) {
|
||||
providerEl.addEventListener("change", onProviderChange);
|
||||
}
|
||||
onProviderChange();
|
||||
|
||||
function setBusy(isBusy, reason = "") {
|
||||
const controls = [
|
||||
$("send"),
|
||||
$("newModel"),
|
||||
$("downloadIfc"),
|
||||
$("ifcFile"),
|
||||
];
|
||||
|
||||
for (const el of controls) el.disabled = isBusy;
|
||||
|
||||
$("input").disabled = isBusy;
|
||||
|
||||
const browseBtn = $("browseBtn");
|
||||
if (browseBtn) {
|
||||
browseBtn.classList.toggle("disabled", isBusy);
|
||||
browseBtn.setAttribute("aria-disabled", isBusy ? "true" : "false");
|
||||
browseBtn.tabIndex = isBusy ? -1 : 0;
|
||||
}
|
||||
|
||||
sendBtn.innerHTML = isBusy
|
||||
? `<span class="spinner"></span>`
|
||||
: `Send <span class="material-icons">send</span>`;
|
||||
|
||||
setStatus(isBusy ? (reason || "Working…") : "Ready");
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
return text
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function sanitizeUrl(url) {
|
||||
try {
|
||||
const parsed = new URL(url, window.location.href);
|
||||
if (["http:", "https:", "mailto:"].includes(parsed.protocol)) {
|
||||
return parsed.href;
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderInlineMarkdown(text) {
|
||||
const placeholders = [];
|
||||
const addPlaceholder = (html) => {
|
||||
const token = `@@MD${placeholders.length}@@`;
|
||||
placeholders.push({ token, html });
|
||||
return token;
|
||||
};
|
||||
|
||||
let rendered = text;
|
||||
|
||||
rendered = rendered.replace(/`([^`]+)`/g, (_, code) => addPlaceholder(`<code>${escapeHtml(code)}</code>`));
|
||||
rendered = rendered.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label, url) => {
|
||||
const href = sanitizeUrl(url);
|
||||
if (!href) {
|
||||
return `${label} (${url})`;
|
||||
}
|
||||
return addPlaceholder(
|
||||
`<a href="${escapeHtml(href)}" target="_blank" rel="noreferrer noopener">${escapeHtml(label)}</a>`
|
||||
);
|
||||
});
|
||||
|
||||
rendered = escapeHtml(rendered);
|
||||
rendered = rendered.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
||||
rendered = rendered.replace(/\*([^*]+)\*/g, "<em>$1</em>");
|
||||
rendered = rendered.replace(/_([^_]+)_/g, "<em>$1</em>");
|
||||
|
||||
for (const placeholder of placeholders) {
|
||||
rendered = rendered.replaceAll(placeholder.token, placeholder.html);
|
||||
}
|
||||
|
||||
return rendered;
|
||||
}
|
||||
|
||||
function renderMarkdown(text) {
|
||||
const lines = String(text).replace(/\r\n?/g, "\n").split("\n");
|
||||
const html = [];
|
||||
let paragraphLines = [];
|
||||
let quoteLines = [];
|
||||
let listType = null;
|
||||
let listItems = [];
|
||||
|
||||
const flushParagraph = () => {
|
||||
if (!paragraphLines.length) return;
|
||||
html.push(`<p>${renderInlineMarkdown(paragraphLines.join(" "))}</p>`);
|
||||
paragraphLines = [];
|
||||
};
|
||||
|
||||
const flushQuote = () => {
|
||||
if (!quoteLines.length) return;
|
||||
const quoteBody = quoteLines.map((line) => renderInlineMarkdown(line)).join("<br />");
|
||||
html.push(`<blockquote><p>${quoteBody}</p></blockquote>`);
|
||||
quoteLines = [];
|
||||
};
|
||||
|
||||
const flushList = () => {
|
||||
if (!listItems.length || !listType) return;
|
||||
const items = listItems.map((item) => `<li>${renderInlineMarkdown(item)}</li>`).join("");
|
||||
html.push(`<${listType}>${items}</${listType}>`);
|
||||
listType = null;
|
||||
listItems = [];
|
||||
};
|
||||
|
||||
const flushAll = () => {
|
||||
flushParagraph();
|
||||
flushQuote();
|
||||
flushList();
|
||||
};
|
||||
|
||||
for (let index = 0; index < lines.length; index++) {
|
||||
const line = lines[index];
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (trimmed.startsWith("```")) {
|
||||
flushAll();
|
||||
const language = trimmed.slice(3).trim();
|
||||
const codeLines = [];
|
||||
index += 1;
|
||||
while (index < lines.length && !lines[index].trim().startsWith("```")) {
|
||||
codeLines.push(lines[index]);
|
||||
index += 1;
|
||||
}
|
||||
const languageClass = language ? ` class="language-${escapeHtml(language)}"` : "";
|
||||
html.push(`<pre><code${languageClass}>${escapeHtml(codeLines.join("\n"))}</code></pre>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!trimmed) {
|
||||
flushAll();
|
||||
continue;
|
||||
}
|
||||
|
||||
const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/);
|
||||
if (headingMatch) {
|
||||
flushAll();
|
||||
const level = headingMatch[1].length;
|
||||
html.push(`<h${level}>${renderInlineMarkdown(headingMatch[2])}</h${level}>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const quoteMatch = trimmed.match(/^>\s?(.*)$/);
|
||||
if (quoteMatch) {
|
||||
flushParagraph();
|
||||
flushList();
|
||||
quoteLines.push(quoteMatch[1]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (quoteLines.length) {
|
||||
flushQuote();
|
||||
}
|
||||
|
||||
const unorderedListMatch = trimmed.match(/^[-*]\s+(.+)$/);
|
||||
if (unorderedListMatch) {
|
||||
flushParagraph();
|
||||
if (listType && listType !== "ul") {
|
||||
flushList();
|
||||
}
|
||||
listType = "ul";
|
||||
listItems.push(unorderedListMatch[1]);
|
||||
continue;
|
||||
}
|
||||
|
||||
const orderedListMatch = trimmed.match(/^\d+\.\s+(.+)$/);
|
||||
if (orderedListMatch) {
|
||||
flushParagraph();
|
||||
if (listType && listType !== "ol") {
|
||||
flushList();
|
||||
}
|
||||
listType = "ol";
|
||||
listItems.push(orderedListMatch[1]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (listItems.length) {
|
||||
flushList();
|
||||
}
|
||||
|
||||
paragraphLines.push(trimmed);
|
||||
}
|
||||
|
||||
flushAll();
|
||||
|
||||
return html.join("");
|
||||
}
|
||||
|
||||
function addMessage(role, text) {
|
||||
if (text.ok) {
|
||||
text = text.data;
|
||||
}
|
||||
if (typeof text !== "string") {
|
||||
text = JSON.stringify(text, null, 2);
|
||||
}
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = `msg ${role}`;
|
||||
wrap.innerHTML = `
|
||||
<div class="role ${role}">${role}${role === "tool" ? '<span class="chevron">▶</span>' : ''}</div>
|
||||
<div class="bubble"></div>`;
|
||||
const bubble = wrap.querySelector(".bubble");
|
||||
if (role === "assistant") {
|
||||
bubble.classList.add("markdown-content");
|
||||
bubble.innerHTML = renderMarkdown(text);
|
||||
} else {
|
||||
bubble.textContent = text;
|
||||
}
|
||||
bubble.onclick = function () {
|
||||
if (bubble.scrollHeight > 100 && role === "tool") {
|
||||
const expanded = bubble.style.maxHeight === 'none';
|
||||
bubble.style.maxHeight = expanded ? '' : 'none';
|
||||
bubble.style.borderBottom = expanded ? '' : 'dotted 2px gray';
|
||||
wrap.querySelector(".chevron").style.transform = expanded ? '' : 'rotate(90deg)';
|
||||
}
|
||||
}
|
||||
msgsEl.insertBefore(wrap, thinkingIndicatorEl);
|
||||
msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||
}
|
||||
|
||||
function setStatus(text) {
|
||||
statusEl.textContent = text;
|
||||
thinkingIndicatorEl.hidden = text !== "Thinking…";
|
||||
msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||
}
|
||||
|
||||
const worker = new Worker("./ifc_worker.js", { type: "module" });
|
||||
|
||||
function callWorker(type, payload = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = crypto.randomUUID();
|
||||
const onMsg = (ev) => {
|
||||
const msg = ev.data;
|
||||
if (!msg || msg.id !== id) return;
|
||||
worker.removeEventListener("message", onMsg);
|
||||
if (msg.ok) resolve(msg);
|
||||
else reject(new Error(msg.error || "Worker error"));
|
||||
};
|
||||
worker.addEventListener("message", onMsg);
|
||||
worker.postMessage({ id, type, payload });
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Tool schemas (should match ifcmcp.core openai_tools()) ----
|
||||
const tools = [
|
||||
{
|
||||
type: "function", function: { name: "ifc_new", description: "Create a new empty IFC model in memory. Valid schemas: IFC4, IFC2X3, IFC4X3 (for IFC 4.3).",
|
||||
parameters: { type: "object", properties: { schema: { type: "string", enum: ["IFC4", "IFC2X3", "IFC4X3"] } }, required: [], additionalProperties: false } }
|
||||
},
|
||||
{
|
||||
type: "function", function: { name: "ifc_summary", description: "Get a concise overview of the loaded IFC model.",
|
||||
parameters: { type: "object", properties: {}, required: [], additionalProperties: false } }
|
||||
},
|
||||
{
|
||||
type: "function", function: { name: "ifc_tree", description: "Get the full spatial hierarchy tree.",
|
||||
parameters: { type: "object", properties: {}, required: [], additionalProperties: false } }
|
||||
},
|
||||
{
|
||||
type: "function", function: { name: "ifc_select", description: "Select elements using ifcopenshell selector syntax (e.g. 'IfcWall').",
|
||||
parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"], additionalProperties: false } }
|
||||
},
|
||||
{
|
||||
type: "function", function: { name: "ifc_info", description: "Inspect an entity by STEP id.",
|
||||
parameters: { type: "object", properties: { element_id: { type: "integer" } }, required: ["element_id"], additionalProperties: false } }
|
||||
},
|
||||
{
|
||||
type: "function", function: { name: "ifc_relations", description: "Get relationships for an element. traverse='up' walks to IfcProject.",
|
||||
parameters: {
|
||||
type: "object", properties: { element_id: { type: "integer" }, traverse: { type: "string" } },
|
||||
required: ["element_id"], additionalProperties: false
|
||||
} }
|
||||
},
|
||||
{
|
||||
type: "function", function: { name: "ifc_clash", description: "Run clash/clearance checks for an element.",
|
||||
parameters: {
|
||||
type: "object", properties: { element_id: { type: "integer" }, clearance: { type: "number" }, tolerance: { type: "number" }, scope: { type: "string" } },
|
||||
required: ["element_id"], additionalProperties: false
|
||||
} }
|
||||
},
|
||||
{
|
||||
type: "function", function: { name: "ifc_list", description: "List ifcopenshell.api modules or functions within a module.",
|
||||
parameters: { type: "object", properties: { module: { type: "string" } }, required: [], additionalProperties: false } }
|
||||
},
|
||||
{
|
||||
type: "function", function: { name: "ifc_docs", description: "Get documentation for an ifcopenshell.api function, 'module.function'.",
|
||||
parameters: { type: "object", properties: { function_path: { type: "string" } }, required: ["function_path"], additionalProperties: false } }
|
||||
},
|
||||
{
|
||||
type: "function", function: { name: "ifc_edit", description: "Execute an ifcopenshell.api mutation; params is a JSON string of stringly-typed kwargs.",
|
||||
parameters: { type: "object", properties: { function_path: { type: "string" }, params: { type: "string" } }, required: ["function_path"], additionalProperties: false } }
|
||||
},
|
||||
{
|
||||
type: "function", function: { name: "ifc_validate", description: "Validate the loaded model. Returns valid bool and list of issues.",
|
||||
parameters: { type: "object", properties: { express_rules: { type: "boolean" } }, required: [], additionalProperties: false } }
|
||||
},
|
||||
{
|
||||
type: "function", function: { name: "ifc_schedule", description: "List work schedules and nested tasks. Use max_depth=1 for top-level phases only on large projects.",
|
||||
parameters: { type: "object", properties: { max_depth: { type: "integer" } }, required: [], additionalProperties: false } }
|
||||
},
|
||||
{
|
||||
type: "function", function: { name: "ifc_cost", description: "List cost schedules and nested cost items. Use max_depth=1 for top-level sections only on large BoQs.",
|
||||
parameters: { type: "object", properties: { max_depth: { type: "integer" } }, required: [], additionalProperties: false } }
|
||||
},
|
||||
{
|
||||
type: "function", function: { name: "ifc_schema", description: "Return IFC class documentation for an entity type.",
|
||||
parameters: { type: "object", properties: { entity_type: { type: "string" } }, required: ["entity_type"], additionalProperties: false } }
|
||||
},
|
||||
{
|
||||
type: "function", function: { name: "ifc_quantify", description: "Run quantity take-off (QTO) on the model. Modifies model in-place; call ifc_save() after.",
|
||||
parameters: { type: "object", properties: { rule: { type: "string" }, selector: { type: "string" } }, required: ["rule"], additionalProperties: false } }
|
||||
},
|
||||
];
|
||||
|
||||
const SYSTEM_INSTRUCTIONS = `
|
||||
You are an IFC copilot running in a browser. You can call tools to inspect or modify the currently loaded IFC model.
|
||||
Rules:
|
||||
- If the user asks about model contents (counts, lists, properties, hierarchy), use tools like ifc_summary/ifc_select/ifc_info/ifc_tree.
|
||||
- If the user asks to change the model, prefer: (1) ifc_list to find candidate API modules, (2) ifc_docs for the exact function signature, then (3) ifc_edit.
|
||||
- If there is no model and the user wants to create one, call ifc_new.
|
||||
- After edits, explain what changed and suggest downloading the IFC.
|
||||
Be concise. Avoid dumping huge trees unless asked.
|
||||
`;
|
||||
|
||||
let messages = []; // running conversation state (Chat Completions style)
|
||||
|
||||
const MAX_TOOL_RESULT_CHARS = 0;
|
||||
const MAX_HISTORY_MESSAGES = 40;
|
||||
|
||||
function truncateToolResult(text) {
|
||||
if (MAX_TOOL_RESULT_CHARS == 0 || text.length <= MAX_TOOL_RESULT_CHARS) return text;
|
||||
return text.slice(0, MAX_TOOL_RESULT_CHARS) + "\n... (truncated)";
|
||||
}
|
||||
|
||||
function trimHistory() {
|
||||
if (messages.length <= MAX_HISTORY_MESSAGES) return;
|
||||
// Find a safe cut point — don't break mid-tool-call sequence.
|
||||
// Walk forward from the trim target to find a user message boundary.
|
||||
let cut = messages.length - MAX_HISTORY_MESSAGES;
|
||||
while (cut < messages.length && messages[cut].role !== "user") {
|
||||
cut++;
|
||||
}
|
||||
if (cut > 0 && cut < messages.length) {
|
||||
messages.splice(0, cut);
|
||||
}
|
||||
}
|
||||
|
||||
async function runAgentTurn(userText) {
|
||||
const apiKey = apiKeyEl.value.trim();
|
||||
if (!apiKey) throw new Error("Missing API key");
|
||||
|
||||
const provider = PROVIDERS[getProviderValue()];
|
||||
const { chat } = provider.api;
|
||||
const baseURL = provider.baseUrlDefault ? baseUrlEl.value.trim() : undefined;
|
||||
|
||||
messages.push({ role: "user", content: userText });
|
||||
trimHistory();
|
||||
|
||||
for (let i = 0; i < 64; i++) {
|
||||
const response = await chat({
|
||||
apiKey,
|
||||
baseURL,
|
||||
model: modelEl.value,
|
||||
messages: [{ role: "system", content: SYSTEM_INSTRUCTIONS }, ...messages],
|
||||
tools,
|
||||
});
|
||||
|
||||
const message = response.choices?.[0]?.message;
|
||||
if (!message) throw new Error("No message in response");
|
||||
|
||||
messages.push(message);
|
||||
|
||||
if (message.content) addMessage("assistant", message.content);
|
||||
|
||||
const calls = message.tool_calls ?? [];
|
||||
if (calls.length === 0) return;
|
||||
|
||||
for (const call of calls) {
|
||||
let args = {};
|
||||
try { args = call.function.arguments ? JSON.parse(call.function.arguments) : {}; }
|
||||
catch { args = {}; }
|
||||
|
||||
addMessage("tool", `→ ${call.function.name}(${JSON.stringify(args)})`);
|
||||
|
||||
const toolRes = await callWorker("toolCall", { name: call.function.name, args });
|
||||
|
||||
const fullResult = JSON.stringify(toolRes.result);
|
||||
|
||||
messages.push({
|
||||
role: "tool",
|
||||
tool_call_id: call.id,
|
||||
content: truncateToolResult(fullResult),
|
||||
});
|
||||
|
||||
// Show full result in UI, but only truncated version goes to the LLM
|
||||
addMessage("tool", `← ${call.function.name}: ${JSON.stringify(toolRes.result, null, 2)}`);
|
||||
}
|
||||
}
|
||||
|
||||
addMessage("assistant", "I hit the tool-call loop limit. Try narrowing your request.");
|
||||
}
|
||||
|
||||
sendBtn.onclick = async () => {
|
||||
const text = inputEl.value.trim();
|
||||
if (!text) return;
|
||||
inputEl.value = "";
|
||||
addMessage("user", text);
|
||||
try {
|
||||
setBusy(true, "Thinking…");
|
||||
await runAgentTurn(text);
|
||||
setBusy(false, "Ready");
|
||||
} catch (e) {
|
||||
setBusy(false, "Error");
|
||||
addMessage("assistant", `Error: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
inputEl.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendBtn.click();
|
||||
}
|
||||
});
|
||||
|
||||
ifcFileEl.onchange = async () => {
|
||||
const f = ifcFileEl.files?.[0];
|
||||
if (!f) return;
|
||||
setBusy(true, "Loading IFC into Pyodide…");
|
||||
const buf = await f.arrayBuffer();
|
||||
try {
|
||||
const r = await callWorker("loadIfc", { filename: f.name, bytes: buf }, [buf]);
|
||||
addMessage("assistant", r.result);
|
||||
setBusy(false, "Ready");
|
||||
} catch (e) {
|
||||
setStatus(true, "Error");
|
||||
addMessage("assistant", `Load error: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
newBtn.onclick = async () => {
|
||||
try {
|
||||
setBusy(true, "Creating new model…");
|
||||
const r = await callWorker("toolCall", { name: "ifc_new", args: { schema: "IFC4X3" } });
|
||||
addMessage("assistant", `New model: ${JSON.stringify(r.result)}`);
|
||||
setBusy(false, "Ready");
|
||||
} catch (e) {
|
||||
setBusy(true, "Error");
|
||||
addMessage("assistant", `Error: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
downloadBtn.onclick = async () => {
|
||||
try {
|
||||
setBusy(true, "Exporting IFC…");
|
||||
const r = await callWorker("exportIfc", {});
|
||||
const blob = new Blob([r.bytes], { type: "application/octet-stream" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = r.filename || "model.ifc";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
setBusy(false, "Ready");
|
||||
} catch (e) {
|
||||
setBusy(true, "Error");
|
||||
addMessage("assistant", `Export error: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
setBusy(true, "Initializing Pyodide and IfcOpenShell for in-memory IFC access…");
|
||||
await callWorker("init", {});
|
||||
setBusy(false, "Ready");
|
||||
} catch (e) {
|
||||
setBusy(true, "Error");
|
||||
addMessage("assistant", `Worker init failed: ${e.message}`);
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,110 @@
|
||||
// ifc_worker.js (MODULE WORKER)
|
||||
import { loadPyodide } from "https://cdn.jsdelivr.net/pyodide/v0.29.3/full/pyodide.mjs";
|
||||
|
||||
let pyodide = null;
|
||||
let callToolPy = null;
|
||||
let initPromise = null;
|
||||
|
||||
function ok(id, extra = {}, transfer = []) {
|
||||
self.postMessage({ id, ok: true, ...extra }, transfer);
|
||||
}
|
||||
function fail(id, error) {
|
||||
self.postMessage({ id, ok: false, error: String(error?.message || error) });
|
||||
}
|
||||
|
||||
async function ensurePyodide() {
|
||||
if (initPromise) return initPromise;
|
||||
|
||||
initPromise = (async () => {
|
||||
// Passing indexURL avoids some environments failing to infer it from the module URL. :contentReference[oaicite:3]{index=3}
|
||||
pyodide = await loadPyodide({
|
||||
indexURL: "https://cdn.jsdelivr.net/pyodide/v0.29.3/full/",
|
||||
});
|
||||
|
||||
await pyodide.loadPackage("micropip");
|
||||
await pyodide.loadPackage("numpy");
|
||||
await pyodide.loadPackage("shapely");
|
||||
await pyodide.loadPackage("typing-extensions");
|
||||
|
||||
const micropip = pyodide.pyimport("micropip");
|
||||
micropip.install("python-dateutil")
|
||||
|
||||
// Detect python minor version (3.12 vs 3.13) and pick a matching wheel.
|
||||
const pyVer = pyodide.runPython(`
|
||||
import sys
|
||||
f"{sys.version_info.major}.{sys.version_info.minor}"
|
||||
`);
|
||||
|
||||
const wheelUrl =
|
||||
pyVer === "3.13"
|
||||
? "https://ifcopenshell.github.io/wasm-wheels/ifcopenshell-0.8.3+34a1bc6-cp313-cp313-emscripten_4_0_9_wasm32.whl"
|
||||
: "https://ifcopenshell.github.io/wasm-wheels/ifcopenshell-0.8.2+d50e806-cp312-cp312-emscripten_3_1_58_wasm32.whl";
|
||||
|
||||
await micropip.install(wheelUrl);
|
||||
|
||||
await micropip.install([
|
||||
"./dist/ifcquery-0.8.5-py3-none-any.whl",
|
||||
"./dist/ifcedit-0.8.5-py3-none-any.whl",
|
||||
"./dist/ifcopenshell_mcp-0.8.5-py3-none-any.whl",
|
||||
"./dist/lark-1.3.1-py3-none-any.whl",
|
||||
"./dist/isodate-0.7.2-py3-none-any.whl",
|
||||
])
|
||||
await pyodide.runPythonAsync(`
|
||||
from ifcmcp.embedded import call_tool as _call_tool
|
||||
`);
|
||||
callToolPy = pyodide.globals.get("_call_tool");
|
||||
})();
|
||||
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
function callTool(name, args) {
|
||||
const pyArgs = pyodide.toPy(args);
|
||||
const res = callToolPy(name, pyArgs);
|
||||
pyArgs.destroy();
|
||||
const resJs = res.toJs({ dict_converter: Object.fromEntries });
|
||||
res.destroy();
|
||||
return resJs;
|
||||
}
|
||||
|
||||
self.onmessage = async (ev) => {
|
||||
const { id, type, payload } = ev.data || {};
|
||||
try {
|
||||
if (type === "init") {
|
||||
await ensurePyodide();
|
||||
ok(id, { result: "ok" });
|
||||
return;
|
||||
}
|
||||
|
||||
await ensurePyodide();
|
||||
|
||||
if (type === "loadIfc") {
|
||||
const { filename, bytes } = payload;
|
||||
const path = `/tmp/${filename || "model.ifc"}`;
|
||||
pyodide.FS.mkdirTree("/tmp");
|
||||
pyodide.FS.writeFile(path, new Uint8Array(bytes));
|
||||
const result = callTool("ifc_load", { path });
|
||||
ok(id, { result });
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "exportIfc") {
|
||||
const path = "/tmp/export.ifc";
|
||||
const result = callTool("ifc_save", { path });
|
||||
const data = pyodide.FS.readFile(path);
|
||||
ok(id, { result, filename: "export.ifc", bytes: data }, [data.buffer]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "toolCall") {
|
||||
const { name, args } = payload;
|
||||
const result = callTool(name, args || {});
|
||||
ok(id, { result });
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`Unknown message type: ${type}`);
|
||||
} catch (e) {
|
||||
fail(id, e);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,127 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>IfcOpenShell AI Assistant</title>
|
||||
<link rel="stylesheet" href="./style.css">
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<main>
|
||||
<section class="side">
|
||||
<div class="row">
|
||||
<label>Provider</label>
|
||||
<div class="provider-tabs" role="radiogroup" aria-label="Provider">
|
||||
<label class="provider-tab">
|
||||
<input type="radio" name="provider" value="openai" checked />
|
||||
<span>OpenAI</span>
|
||||
</label>
|
||||
<label class="provider-tab">
|
||||
<input type="radio" name="provider" value="anthropic" />
|
||||
<span>Claude</span>
|
||||
</label>
|
||||
<label class="provider-tab">
|
||||
<input type="radio" name="provider" value="gemini" />
|
||||
<span>Gemini</span>
|
||||
</label>
|
||||
<label class="provider-tab">
|
||||
<input type="radio" name="provider" value="openrouter" />
|
||||
<span>OpenRouter</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label>Model</label>
|
||||
<select id="model" style="width: 100%;"></select>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label id="apiKeyLabel">API key<span class="small">stored in browser memory; only sent to provider servers</span></label>
|
||||
<input id="apiKey" type="password" placeholder="sk-..." autocomplete="off" style="width: 100%;" />
|
||||
</div>
|
||||
|
||||
<div class="row" id="baseUrlRow">
|
||||
<label id="baseUrlLabel">Base URL<span class="small">override the API endpoint for OpenAI-compatible providers</span></label>
|
||||
<input id="baseUrl" type="text" placeholder="https://api.openai.com/v1" autocomplete="off" style="width: 100%;" />
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="row" style="margin-bottom: 10px;">
|
||||
<label>IFC model context<span class="small">stored in browser memory; only information extracted through MCP commands sent to provider servers</span></label>
|
||||
|
||||
<div class="btn-row">
|
||||
<label class="btn" id="browseBtn" for="ifcFile" role="button" tabindex="0">
|
||||
<span class="material-icons">folder_open</span>
|
||||
Browse
|
||||
</label>
|
||||
|
||||
<button class="btn" id="newModel" type="button">
|
||||
<span class="material-icons">add_box</span>
|
||||
New IFC
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input id="ifcFile" type="file" accept=".ifc,.ifczip,.ifcxml,.zip" hidden />
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<button class="btn btn-wide" id="downloadIfc" type="button">
|
||||
<span class="material-icons">download</span>
|
||||
Download IFC
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="row">
|
||||
<label>Tips</label>
|
||||
<div class="status">
|
||||
• Upload an IFC, then ask “Summarize the model” or “List all IfcWalls”.<br />
|
||||
• Try “Add a new site and building named X” (will use ifc_edit).
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label>About</label>
|
||||
<div class="status">
|
||||
Chat with your IFC model. This app uses <span class="code">ifcopenshell-mcp</span> to interact with an IFC model. IFC is in its raw-serialized form provides little textual context on the meaning of attributes, the intent behind element instances and the resulting geometrical form. By using the IfcOpenShell high-level API through an MCP-like interface (Model Context Protocol; allows for structured interaction with APIs and tools) these limitations are addressed and the model can be interrogated on a higher semantic abstraction level. You can also use <span class="code">ifcopenshell-mcp</span> on your local machine with any LLM provider that supports the MCP protocol.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="chat">
|
||||
<header>
|
||||
<img src="https://ifcopenshell.org/assets/images/logo.png" style="float: left; padding: 10px 20px 10px 0; ">
|
||||
<div class="row">
|
||||
<strong>IfcOpenShell AI Assistant</strong>
|
||||
</div>
|
||||
<div class="row">
|
||||
|
||||
Status: <span class="status" id="status">Booting…</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="msgs" id="msgs">
|
||||
<div class="thinking-indicator" id="thinkingIndicator" hidden>
|
||||
<span class="spinner"></span>
|
||||
<span>thinking...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="composer">
|
||||
<div class="inner">
|
||||
<textarea id="input" placeholder="Ask or instruct about the IFC model…"></textarea>
|
||||
<button id="send" class="btn">Send <span class="material-icons">send</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script type="module" src="./app.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,370 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
header {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
header input,
|
||||
header select {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
main {
|
||||
display: grid;
|
||||
grid-template-columns: 320px 1fr;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.side {
|
||||
border-right: 1px solid #ddd;
|
||||
padding: 12px;
|
||||
overflow: auto;
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
.chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.msgs {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.msg {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.thinking-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #555;
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
.thinking-indicator[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.thinking-indicator .spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.msg .role {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
margin: 12px 0 4px 0;
|
||||
}
|
||||
|
||||
.role.user {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.msg .bubble {
|
||||
padding: 0;
|
||||
border-radius: 10px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.msg.assistant .bubble {
|
||||
padding: 10px 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.markdown-content > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.markdown-content > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.markdown-content p,
|
||||
.markdown-content ul,
|
||||
.markdown-content ol,
|
||||
.markdown-content blockquote,
|
||||
.markdown-content pre {
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.markdown-content h1,
|
||||
.markdown-content h2,
|
||||
.markdown-content h3,
|
||||
.markdown-content h4,
|
||||
.markdown-content h5,
|
||||
.markdown-content h6 {
|
||||
margin: 0 0 12px 0;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.markdown-content ul,
|
||||
.markdown-content ol {
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.markdown-content blockquote {
|
||||
margin-left: 0;
|
||||
padding-left: 12px;
|
||||
border-left: 3px solid #ddd;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.markdown-content code {
|
||||
padding: 1px 4px;
|
||||
border-radius: 4px;
|
||||
background: #f2f2f2;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 90%;
|
||||
}
|
||||
|
||||
.markdown-content pre {
|
||||
overflow-x: auto;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
background: #f4f4f4;
|
||||
}
|
||||
|
||||
.markdown-content pre code {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.markdown-content a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.msg.user .bubble {
|
||||
padding: 10px 20px;
|
||||
background: #eee;
|
||||
width: 50%;
|
||||
margin-left: auto;
|
||||
border: solid 1px #ddd;
|
||||
}
|
||||
|
||||
.msg.tool .bubble {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 60%;
|
||||
max-height: 100px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
margin-left: 6px;
|
||||
transition: transform 0.2s;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.composer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.composer textarea {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
height: 88px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.composer button {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.composer .inner {
|
||||
border: solid 1px #ddd;
|
||||
border-radius: 20px;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.spinner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid #aaa;
|
||||
border-top-color: #333;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
section > .row > label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
opacity: 0.75;
|
||||
margin-bottom: 6px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
section > .row > label .small {
|
||||
font-weight: normal;
|
||||
display: block;
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
.side .row {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.provider-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
background: #00000010;
|
||||
padding: 6px 0 0 6px;
|
||||
}
|
||||
|
||||
.provider-tab {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.provider-tab input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.provider-tab span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 38px;
|
||||
padding: 8px;
|
||||
border: 1px solid #d0d0d0;
|
||||
border-radius: 2px 2px 0 0;
|
||||
background: #e8e8e8;
|
||||
color: #555;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||
font-size: 75%;
|
||||
}
|
||||
|
||||
.provider-tab input:checked + span {
|
||||
background: #f9f9f9;
|
||||
border-color: #999;
|
||||
color: #111;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.provider-tab input:focus-visible + span {
|
||||
outline: 2px solid #666;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.row button {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
|
||||
hr {
|
||||
border: dashed 1px #ddd;
|
||||
}
|
||||
|
||||
.btn-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn-row > .btn,
|
||||
.btn-row > button.btn {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #d0d0d0;
|
||||
background: #eee;
|
||||
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #ddd;
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.btn-wide {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.material-icons {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.btn.disabled,
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#input {
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#input:focus,
|
||||
#input:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.code {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 90%;
|
||||
background-color: #eee;
|
||||
border: solid 1px #ddd;
|
||||
padding: 2px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
header .row:nth-child(2) {
|
||||
padding: 15px 0 0 0;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
PACKAGE_NAME:=ifcedit
|
||||
include ../common.mk
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
pytest tests
|
||||
|
||||
.PHONY: qa
|
||||
qa:
|
||||
black .
|
||||
@@ -0,0 +1,305 @@
|
||||
<!-- This file was generated with the assistance of an AI coding tool. -->
|
||||
# ifcedit
|
||||
|
||||
A CLI wrapper that exposes all 350+ `ifcopenshell.api` mutation functions as
|
||||
shell commands. Functions are auto-discovered at runtime via introspection --
|
||||
no hardcoded list to maintain.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install ifcedit
|
||||
```
|
||||
|
||||
Requires `ifcopenshell`.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
ifcedit <command> [options] [--format json|text]
|
||||
```
|
||||
|
||||
Three subcommands: `list` to discover functions, `docs` to read their
|
||||
documentation, and `run` to execute them.
|
||||
|
||||
## Subcommands
|
||||
|
||||
### list
|
||||
|
||||
Discover available API modules and their functions.
|
||||
|
||||
**List all modules:**
|
||||
|
||||
```bash
|
||||
ifcedit list
|
||||
```
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"module": "root",
|
||||
"description": "Functions for creating project-level entities",
|
||||
"functions": ["create_entity", "remove_product", "copy_class"],
|
||||
"count": 3
|
||||
},
|
||||
{
|
||||
"module": "spatial",
|
||||
"description": "Functions for managing spatial relationships",
|
||||
"functions": ["assign_container", "unassign_container"],
|
||||
"count": 2
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**List functions in a module:**
|
||||
|
||||
```bash
|
||||
ifcedit list root
|
||||
```
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "create_entity",
|
||||
"description": "Create an IFC entity with optional initial attributes",
|
||||
"params": [
|
||||
{"name": "ifc_class", "type": "str", "required": true},
|
||||
{"name": "name", "type": "Optional[str]"}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### docs
|
||||
|
||||
Show full documentation for a specific function, including parameter
|
||||
descriptions from docstrings and return type.
|
||||
|
||||
```bash
|
||||
ifcedit docs root.create_entity
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"module": "root",
|
||||
"function": "create_entity",
|
||||
"description": "Create an IFC entity with optional initial attributes",
|
||||
"long_description": "This function creates a new entity instance...",
|
||||
"params": [
|
||||
{
|
||||
"name": "ifc_class",
|
||||
"type": "str",
|
||||
"required": true,
|
||||
"description": "The IFC class name (e.g. 'IfcWall', 'IfcProject')"
|
||||
},
|
||||
{
|
||||
"name": "name",
|
||||
"type": "Optional[str]",
|
||||
"description": "Optional name attribute"
|
||||
}
|
||||
],
|
||||
"return_type": "ifcopenshell.entity_instance",
|
||||
"return_description": "The newly created entity instance"
|
||||
}
|
||||
```
|
||||
|
||||
### run
|
||||
|
||||
Execute an API function against an IFC file. Parameters are passed as
|
||||
`--key value` pairs after the function name.
|
||||
|
||||
```bash
|
||||
ifcedit run model.ifc root.create_entity --ifc_class IfcWall --name "My Wall"
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"result": {"id": 42, "type": "IfcWall", "name": "My Wall"}
|
||||
}
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
- `-o, --output <path>` -- write to a different file instead of overwriting the input
|
||||
- `--dry-run` -- validate parameters without executing or saving
|
||||
|
||||
```bash
|
||||
# Save to a new file
|
||||
ifcedit run model.ifc root.create_entity -o out.ifc --ifc_class IfcWall
|
||||
|
||||
# Validate without executing
|
||||
ifcedit run model.ifc root.create_entity --dry-run --ifc_class IfcWall
|
||||
```
|
||||
|
||||
Dry-run output shows the resolved parameters:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"dry_run": true,
|
||||
"module": "root",
|
||||
"function": "create_entity",
|
||||
"args": {"ifc_class": "IfcWall", "name": "My Wall"}
|
||||
}
|
||||
```
|
||||
|
||||
## Parameter type coercion
|
||||
|
||||
CLI strings are automatically converted to the types expected by each API
|
||||
function, using the function's type annotations:
|
||||
|
||||
| Type | CLI input | Python value |
|
||||
|------|-----------|--------------|
|
||||
| `str` | `"hello"` | `"hello"` |
|
||||
| `int` | `"42"` or `"#42"` | `42` |
|
||||
| `float` | `"3.14"` | `3.14` |
|
||||
| `bool` | `"true"`, `"1"`, `"yes"` | `True` |
|
||||
| `Optional[X]` | `"none"` | `None` |
|
||||
| `entity_instance` | `"42"` or `"#42"` | resolved from model by step ID |
|
||||
| `list[entity_instance]` | `"5,6,7"` or `"[5, 6, 7]"` | list of resolved entities |
|
||||
| `dict` | `'{"key": "val"}'` | parsed JSON object |
|
||||
| `Literal["A", "B"]` | `"A"` | validated against allowed values |
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
# Create a project
|
||||
ifcedit run model.ifc root.create_entity --ifc_class IfcProject --name "My Project"
|
||||
|
||||
# Assign an element to a storey
|
||||
ifcedit run model.ifc spatial.assign_container --products 10 --relating_structure 4
|
||||
|
||||
# Assign multiple elements at once
|
||||
ifcedit run model.ifc aggregate.assign_object --products "5,6,7" --relating_object 1
|
||||
|
||||
# Add a property set
|
||||
ifcedit run model.ifc pset.add_pset --product 10 --name "Pset_WallCommon"
|
||||
|
||||
# Edit properties
|
||||
ifcedit run model.ifc pset.edit_pset --pset 15 \
|
||||
--properties '{"IsExternal": true, "FireRating": "2HR"}'
|
||||
```
|
||||
|
||||
### foreach
|
||||
|
||||
Apply an API function to each element in a JSON array read from stdin.
|
||||
`{field}` placeholders in argument values are substituted with fields from
|
||||
each JSON object. The model is opened once and saved once regardless of how
|
||||
many elements are processed.
|
||||
|
||||
```bash
|
||||
ifcquery model.ifc select 'IfcWindow' | ifcedit foreach model.ifc root.remove_product --product {id}
|
||||
```
|
||||
|
||||
```json
|
||||
{"ok": true, "count": 36, "errors": []}
|
||||
```
|
||||
|
||||
Placeholder tokens match the fields emitted by `ifcquery` — typically `{id}`,
|
||||
`{type}`, and `{name}`:
|
||||
|
||||
```bash
|
||||
ifcquery model.ifc select 'IfcDoor' | ifcedit foreach model.ifc attribute.edit_attributes \
|
||||
--product {id} --attributes '{"Name": "Door"}'
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
- `-o, --output <path>` -- write to a different file instead of overwriting the input
|
||||
|
||||
**Output:**
|
||||
|
||||
- `count` -- number of elements successfully processed
|
||||
- `errors` -- list of per-element failures, each with `index`, `item`, and `error`; processing continues past errors
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"count": 34,
|
||||
"errors": [
|
||||
{"index": 2, "item": {"id": 55, "type": "IfcWindow", "name": "W03"}, "error": "Entity #55 not found in model"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Exit code is 1 if any element failed.
|
||||
|
||||
### quantify
|
||||
|
||||
Run quantity take-off (QTO) on an IFC file, computing physical measurements
|
||||
(volume, area, length, count, weight) and writing them back as
|
||||
`IfcElementQuantity` property sets. Uses `ifc5d` rules.
|
||||
|
||||
**List available rules:**
|
||||
|
||||
```bash
|
||||
ifcedit quantify list
|
||||
```
|
||||
|
||||
```json
|
||||
[
|
||||
{"name": "IFC4QtoBaseQuantities"},
|
||||
{"name": "IFC4X3QtoBaseQuantities"}
|
||||
]
|
||||
```
|
||||
|
||||
**Run QTO on a file:**
|
||||
|
||||
```bash
|
||||
ifcedit quantify run model.ifc IFC4QtoBaseQuantities
|
||||
ifcedit quantify run model.ifc IFC4QtoBaseQuantities --selector IfcWall
|
||||
ifcedit quantify run model.ifc IFC4QtoBaseQuantities -o model_qto.ifc
|
||||
```
|
||||
|
||||
```json
|
||||
{"ok": true, "rule": "IFC4QtoBaseQuantities", "elements_quantified": 42}
|
||||
```
|
||||
|
||||
Options:
|
||||
|
||||
- `--selector <query>` -- ifcopenshell selector to restrict elements (default: all `IfcElement`)
|
||||
- `-o, --output <path>` -- write to a different file instead of overwriting the input
|
||||
|
||||
Note: `quantify run` writes geometry-based measurements and requires the
|
||||
IfcOpenShell C++ geometry bindings for elements with computed quantities.
|
||||
|
||||
## Error handling
|
||||
|
||||
Errors are reported in the JSON response:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"error": "Entity #999 not found in model"
|
||||
}
|
||||
```
|
||||
|
||||
Exit code is 0 on success, 1 on error.
|
||||
|
||||
## Relationship to ifcquery
|
||||
|
||||
`ifcedit` and `ifcquery` are complementary tools:
|
||||
|
||||
- **ifcquery** reads and inspects IFC models (summary, tree, info, select, relations, clash, validate, schedule, cost, schema, contexts, materials, plot, render)
|
||||
- **ifcedit** modifies IFC models by wrapping `ifcopenshell.api` functions, and runs QTO via `quantify`
|
||||
|
||||
A typical workflow: inspect with `ifcquery`, look up the right API function
|
||||
with `ifcedit docs`, then apply changes with `ifcedit run`.
|
||||
|
||||
The two tools also compose directly in shell scripts. Use `ifcquery --format ids`
|
||||
to feed a list of IDs into a `run` parameter, or pipe `ifcquery select` JSON
|
||||
into `ifcedit foreach` to apply an operation to every matching element:
|
||||
|
||||
```bash
|
||||
# Aggregate — pass all IDs as a list parameter
|
||||
ifcedit run model.ifc spatial.unassign_container \
|
||||
--products "$(ifcquery model.ifc --format ids select 'IfcWall')"
|
||||
|
||||
# Fan-out — one operation per element, model opened and saved once
|
||||
ifcquery model.ifc select 'IfcWindow' | ifcedit foreach model.ifc root.remove_product --product {id}
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
LGPLv3+ -- see the IfcOpenShell project license.
|
||||
@@ -0,0 +1,20 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions
|
||||
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
|
||||
#
|
||||
# This file is part of IfcEdit.
|
||||
#
|
||||
# IfcEdit is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# IfcEdit is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcEdit. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
__version__ = version = "0.0.0"
|
||||
@@ -0,0 +1,265 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions
|
||||
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
|
||||
#
|
||||
# This file is part of IfcEdit.
|
||||
#
|
||||
# IfcEdit is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# IfcEdit is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcEdit. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
import ifcopenshell
|
||||
|
||||
from ifcedit.discover import function_docs, list_functions, list_modules
|
||||
from ifcedit.foreach import run_foreach
|
||||
from ifcedit.quantify import list_rules, run_quantify
|
||||
from ifcedit.run import run_api
|
||||
|
||||
|
||||
def format_output(data, fmt: str) -> str:
|
||||
if fmt == "json":
|
||||
return json.dumps(data, indent=2, ensure_ascii=False)
|
||||
elif fmt == "text":
|
||||
return _format_text(data)
|
||||
return json.dumps(data, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
def _format_text(data, indent: int = 0) -> str:
|
||||
prefix = " " * indent
|
||||
lines = []
|
||||
if isinstance(data, dict):
|
||||
for key, value in data.items():
|
||||
if isinstance(value, (dict, list)):
|
||||
lines.append(f"{prefix}{key}:")
|
||||
lines.append(_format_text(value, indent + 1))
|
||||
else:
|
||||
lines.append(f"{prefix}{key}: {value}")
|
||||
elif isinstance(data, list):
|
||||
for item in data:
|
||||
if isinstance(item, dict):
|
||||
lines.append(_format_text(item, indent))
|
||||
lines.append("")
|
||||
else:
|
||||
lines.append(f"{prefix}- {item}")
|
||||
else:
|
||||
lines.append(f"{prefix}{data}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def cmd_list(args):
|
||||
if args.module:
|
||||
try:
|
||||
functions = list_functions(args.module)
|
||||
except Exception as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(format_output(functions, args.output_format))
|
||||
else:
|
||||
modules = list_modules()
|
||||
print(format_output(modules, args.output_format))
|
||||
|
||||
|
||||
def cmd_docs(args):
|
||||
parts = args.function_path.split(".")
|
||||
if len(parts) != 2:
|
||||
print("Error: function path must be 'module.function' (e.g. root.create_entity)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
module, function = parts
|
||||
try:
|
||||
docs = function_docs(module, function)
|
||||
except Exception as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(format_output(docs, args.output_format))
|
||||
|
||||
|
||||
def cmd_run(args, extra_args):
|
||||
try:
|
||||
model = ifcopenshell.open(args.ifc_file)
|
||||
except Exception as e:
|
||||
print(f"Error: Could not open IFC file: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
parts = args.function_path.split(".")
|
||||
if len(parts) != 2:
|
||||
print("Error: function path must be 'module.function' (e.g. root.create_entity)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
module, function = parts
|
||||
|
||||
# Parse extra --key value arguments into a dict
|
||||
raw_kwargs = _parse_extra_args(extra_args)
|
||||
|
||||
if args.dry_run:
|
||||
result = {"ok": True, "dry_run": True, "module": module, "function": function, "args": raw_kwargs}
|
||||
else:
|
||||
result = run_api(model, module, function, raw_kwargs)
|
||||
|
||||
if result["ok"]:
|
||||
output_path = args.output or args.ifc_file
|
||||
model.write(output_path)
|
||||
|
||||
print(format_output(result, args.output_format))
|
||||
if not result["ok"]:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _parse_extra_args(extra: list[str]) -> dict[str, str]:
|
||||
"""Parse a list of ['--key', 'value', ...] into a dict."""
|
||||
kwargs = {}
|
||||
i = 0
|
||||
while i < len(extra):
|
||||
arg = extra[i]
|
||||
if arg.startswith("--"):
|
||||
key = arg[2:]
|
||||
if i + 1 < len(extra) and not extra[i + 1].startswith("--"):
|
||||
kwargs[key] = extra[i + 1]
|
||||
i += 2
|
||||
else:
|
||||
# Flag without value — treat as "true"
|
||||
kwargs[key] = "true"
|
||||
i += 1
|
||||
else:
|
||||
print(f"Error: Unexpected argument: {arg}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return kwargs
|
||||
|
||||
|
||||
def cmd_foreach(args, extra_args):
|
||||
try:
|
||||
model = ifcopenshell.open(args.ifc_file)
|
||||
except Exception as e:
|
||||
print(f"Error: Could not open IFC file: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
parts = args.function_path.split(".")
|
||||
if len(parts) != 2:
|
||||
print("Error: function path must be 'module.function' (e.g. root.create_entity)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
module, function = parts
|
||||
|
||||
raw_kwargs_template = _parse_extra_args(extra_args)
|
||||
|
||||
try:
|
||||
stdin_data = json.load(sys.stdin)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"Error: Could not parse JSON from stdin: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if not isinstance(stdin_data, list):
|
||||
print("Error: stdin must be a JSON array", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
result = run_foreach(model, module, function, raw_kwargs_template, stdin_data)
|
||||
|
||||
if result["ok"]:
|
||||
output_path = args.output or args.ifc_file
|
||||
model.write(output_path)
|
||||
|
||||
print(format_output(result, args.output_format))
|
||||
if not result["ok"]:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def cmd_quantify(args, extra_args):
|
||||
if args.quantify_command == "list":
|
||||
result = list_rules()
|
||||
print(format_output(result, args.output_format))
|
||||
elif args.quantify_command == "run":
|
||||
try:
|
||||
model = ifcopenshell.open(args.ifc_file)
|
||||
except Exception as e:
|
||||
print(f"Error: Could not open IFC file: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
selector = args.selector or None
|
||||
result = run_quantify(model, args.rule_name, selector=selector)
|
||||
if result["ok"]:
|
||||
output_path = args.output or args.ifc_file
|
||||
model.write(output_path)
|
||||
print(format_output(result, args.output_format))
|
||||
if not result["ok"]:
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("Error: quantify requires a subcommand: list or run", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="ifcedit",
|
||||
description="CLI wrapper for ifcopenshell.api IFC model mutation functions",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--format",
|
||||
choices=["json", "text"],
|
||||
default="json",
|
||||
dest="output_format",
|
||||
help="Output format (default: json)",
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
# list
|
||||
list_parser = subparsers.add_parser("list", help="List API modules or functions in a module")
|
||||
list_parser.add_argument("module", nargs="?", help="Module name (omit to list all modules)")
|
||||
|
||||
# docs
|
||||
docs_parser = subparsers.add_parser("docs", help="Show full documentation for an API function")
|
||||
docs_parser.add_argument("function_path", help="module.function (e.g. root.create_entity)")
|
||||
|
||||
# run
|
||||
run_parser = subparsers.add_parser("run", help="Execute an API function on an IFC file")
|
||||
run_parser.add_argument("ifc_file", help="Path to the IFC file")
|
||||
run_parser.add_argument("function_path", help="module.function (e.g. root.create_entity)")
|
||||
run_parser.add_argument("-o", "--output", help="Output file path (default: overwrite input)")
|
||||
run_parser.add_argument("--dry-run", action="store_true", help="Validate without executing or saving")
|
||||
|
||||
# foreach
|
||||
foreach_parser = subparsers.add_parser(
|
||||
"foreach",
|
||||
help="Apply an API function to each element in a JSON array read from stdin",
|
||||
)
|
||||
foreach_parser.add_argument("ifc_file", help="Path to the IFC file")
|
||||
foreach_parser.add_argument("function_path", help="module.function (e.g. attribute.edit_attributes)")
|
||||
foreach_parser.add_argument("-o", "--output", help="Output file path (default: overwrite input)")
|
||||
|
||||
# quantify
|
||||
quantify_parser = subparsers.add_parser("quantify", help="Quantity take-off (QTO) using ifc5d rules")
|
||||
quantify_sub = quantify_parser.add_subparsers(dest="quantify_command")
|
||||
quantify_sub.add_parser("list", help="List available QTO rule names")
|
||||
qrun_parser = quantify_sub.add_parser("run", help="Run QTO on an IFC file")
|
||||
qrun_parser.add_argument("ifc_file", help="Path to the IFC file")
|
||||
qrun_parser.add_argument("rule_name", help="QTO rule name (e.g. IFC4QtoBaseQuantities)")
|
||||
qrun_parser.add_argument("--selector", help="ifcopenshell selector to restrict elements (default: all IfcElement)")
|
||||
qrun_parser.add_argument("-o", "--output", help="Output file path (default: overwrite input)")
|
||||
|
||||
args, extra = parser.parse_known_args()
|
||||
|
||||
if args.command == "list":
|
||||
cmd_list(args)
|
||||
elif args.command == "docs":
|
||||
cmd_docs(args)
|
||||
elif args.command == "run":
|
||||
cmd_run(args, extra)
|
||||
elif args.command == "foreach":
|
||||
cmd_foreach(args, extra)
|
||||
elif args.command == "quantify":
|
||||
cmd_quantify(args, extra)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,180 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions
|
||||
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
|
||||
#
|
||||
# This file is part of IfcEdit.
|
||||
#
|
||||
# IfcEdit is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# IfcEdit is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcEdit. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import typing
|
||||
|
||||
import ifcopenshell
|
||||
|
||||
|
||||
def coerce_value(
|
||||
value_str: str,
|
||||
type_hint,
|
||||
model: ifcopenshell.file | None = None,
|
||||
lookup_file: ifcopenshell.file | None = None,
|
||||
):
|
||||
"""Convert a CLI string argument to the proper Python type based on a type hint.
|
||||
|
||||
Args:
|
||||
value_str: The raw string from the CLI.
|
||||
type_hint: The type annotation from the function signature.
|
||||
model: The main open IFC model, needed to resolve entity instance references by ID.
|
||||
lookup_file: Override file for entity resolution (e.g. a library file for
|
||||
project.append_asset). When provided, entity IDs are looked up here instead
|
||||
of in model.
|
||||
|
||||
Returns:
|
||||
The converted Python value.
|
||||
|
||||
Raises:
|
||||
ValueError: If the value cannot be converted.
|
||||
TypeError: If the type hint is not supported.
|
||||
"""
|
||||
# When a library file has been opened, entity IDs are resolved from it, not the main model.
|
||||
effective_lookup = lookup_file if lookup_file is not None else model
|
||||
|
||||
if type_hint is None:
|
||||
return value_str
|
||||
|
||||
origin = typing.get_origin(type_hint)
|
||||
args = typing.get_args(type_hint)
|
||||
|
||||
# Union / Optional
|
||||
if origin is typing.Union:
|
||||
non_none_types = [a for a in args if a is not type(None)]
|
||||
if value_str.lower() == "none":
|
||||
if type(None) in args:
|
||||
return None
|
||||
# Try each non-None type in order
|
||||
for t in non_none_types:
|
||||
try:
|
||||
return coerce_value(value_str, t, model, lookup_file)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
raise ValueError(f"Cannot convert '{value_str}' to any of {non_none_types}")
|
||||
|
||||
# Literal
|
||||
if origin is typing.Literal:
|
||||
allowed = args
|
||||
if value_str in [str(a) for a in allowed]:
|
||||
# return the actual literal value with proper type
|
||||
for a in allowed:
|
||||
if str(a) == value_str:
|
||||
return a
|
||||
raise ValueError(f"'{value_str}' is not one of: {', '.join(repr(a) for a in allowed)}")
|
||||
|
||||
# list types
|
||||
if origin is list:
|
||||
if args and _is_entity_type(args[0]):
|
||||
return _coerce_entity_list(value_str, effective_lookup)
|
||||
if args:
|
||||
items = _split_list(value_str)
|
||||
return [coerce_value(item.strip(), args[0], model, lookup_file) for item in items]
|
||||
return _split_list(value_str)
|
||||
|
||||
# dict types
|
||||
if origin is dict:
|
||||
return _floatify_numeric_lists(json.loads(value_str))
|
||||
|
||||
# Simple types
|
||||
if type_hint is str:
|
||||
return value_str
|
||||
if type_hint is int:
|
||||
return int(value_str.lstrip("#"))
|
||||
if type_hint is float:
|
||||
return float(value_str)
|
||||
if type_hint is bool:
|
||||
return value_str.lower() in ("true", "1", "yes")
|
||||
|
||||
# ifcopenshell.file — open from path string
|
||||
if type_hint is ifcopenshell.file:
|
||||
return ifcopenshell.open(value_str)
|
||||
|
||||
# entity_instance
|
||||
if _is_entity_type(type_hint):
|
||||
return _coerce_entity(value_str, effective_lookup)
|
||||
|
||||
# Fallback: try json.loads for complex types, then plain string
|
||||
try:
|
||||
return json.loads(value_str)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return value_str
|
||||
|
||||
|
||||
def _is_entity_type(hint) -> bool:
|
||||
"""Check if a type hint refers to ifcopenshell.entity_instance."""
|
||||
if hint is ifcopenshell.entity_instance:
|
||||
return True
|
||||
if isinstance(hint, type) and issubclass(hint, ifcopenshell.entity_instance):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _coerce_entity(value_str: str | int, lookup_file: ifcopenshell.file | None) -> ifcopenshell.entity_instance:
|
||||
"""Resolve a step ID string like '123' or '#123' to an entity instance."""
|
||||
if lookup_file is None:
|
||||
raise ValueError("Cannot resolve entity reference without an IFC model")
|
||||
if isinstance(value_str, int):
|
||||
entity_id = value_str
|
||||
else:
|
||||
entity_id = int(value_str.strip().lstrip("#"))
|
||||
try:
|
||||
return lookup_file.by_id(entity_id)
|
||||
except RuntimeError:
|
||||
raise ValueError(f"Entity #{entity_id} not found in model")
|
||||
|
||||
|
||||
def _coerce_entity_list(value_str: str, lookup_file: ifcopenshell.file | None) -> list[ifcopenshell.entity_instance]:
|
||||
"""Resolve a comma-separated list of step IDs to entity instances."""
|
||||
items = _split_list(value_str)
|
||||
return [_coerce_entity(item.strip(), lookup_file) for item in items]
|
||||
|
||||
|
||||
def _floatify_numeric_lists(obj):
|
||||
"""Recursively convert lists of numbers to lists of floats.
|
||||
|
||||
IFC C++ bindings require Python floats (not ints) for AGGREGATE OF DOUBLE
|
||||
attributes (e.g. DirectionRatios, Coordinates). JSON parsing produces ints
|
||||
for whole numbers like 0, which causes a TypeError at the binding level.
|
||||
"""
|
||||
if isinstance(obj, dict):
|
||||
return {k: _floatify_numeric_lists(v) for k, v in obj.items()}
|
||||
if (
|
||||
isinstance(obj, list)
|
||||
and obj
|
||||
and all(isinstance(v, (int, float)) for v in obj)
|
||||
and any(isinstance(v, float) for v in obj)
|
||||
):
|
||||
return [float(v) for v in obj]
|
||||
return obj
|
||||
|
||||
|
||||
def _split_list(value_str: str) -> list[str]:
|
||||
"""Split a comma-separated string, handling JSON arrays too."""
|
||||
value_str = value_str.strip()
|
||||
if value_str.startswith("["):
|
||||
try:
|
||||
parsed = json.loads(value_str)
|
||||
if isinstance(parsed, list):
|
||||
return [json.dumps(item) if isinstance(item, (dict, list)) else str(item) for item in parsed]
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return [item.strip() for item in value_str.split(",") if item.strip()]
|
||||
@@ -0,0 +1,282 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions
|
||||
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
|
||||
#
|
||||
# This file is part of IfcEdit.
|
||||
#
|
||||
# IfcEdit is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# IfcEdit is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcEdit. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import inspect
|
||||
import re
|
||||
import typing
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _api_package_path() -> Path:
|
||||
"""Return the filesystem path to the ifcopenshell.api package."""
|
||||
import ifcopenshell.api
|
||||
|
||||
return Path(ifcopenshell.api.__file__).parent
|
||||
|
||||
|
||||
def list_modules() -> list[dict]:
|
||||
"""List all API modules with their function counts and descriptions.
|
||||
|
||||
Returns a list of dicts: [{"module": "root", "description": "...", "functions": [...], "count": 4}, ...]
|
||||
"""
|
||||
api_path = _api_package_path()
|
||||
modules = []
|
||||
for child in sorted(api_path.iterdir()):
|
||||
if not child.is_dir() or child.name.startswith("_"):
|
||||
continue
|
||||
init_file = child / "__init__.py"
|
||||
if not init_file.exists():
|
||||
continue
|
||||
try:
|
||||
mod = importlib.import_module(f"ifcopenshell.api.{child.name}")
|
||||
except Exception:
|
||||
continue
|
||||
all_names = getattr(mod, "__all__", [])
|
||||
if not all_names:
|
||||
continue
|
||||
description = ""
|
||||
if mod.__doc__:
|
||||
description = mod.__doc__.strip().split("\n")[0]
|
||||
modules.append(
|
||||
{
|
||||
"module": child.name,
|
||||
"description": description,
|
||||
"functions": list(all_names),
|
||||
"count": len(all_names),
|
||||
}
|
||||
)
|
||||
return modules
|
||||
|
||||
|
||||
def list_functions(module: str) -> list[dict]:
|
||||
"""List functions in an API module with one-line descriptions and parameter info.
|
||||
|
||||
Returns a list of dicts: [{"name": "create_entity", "description": "...", "params": [...]}]
|
||||
"""
|
||||
mod = importlib.import_module(f"ifcopenshell.api.{module}")
|
||||
all_names = getattr(mod, "__all__", [])
|
||||
functions = []
|
||||
for name in all_names:
|
||||
fn = _get_underlying_function(module, name)
|
||||
if fn is None:
|
||||
continue
|
||||
description = ""
|
||||
if fn.__doc__:
|
||||
description = fn.__doc__.strip().split("\n")[0]
|
||||
params = _extract_params(fn)
|
||||
functions.append(
|
||||
{
|
||||
"name": name,
|
||||
"description": description,
|
||||
"params": params,
|
||||
}
|
||||
)
|
||||
return functions
|
||||
|
||||
|
||||
def function_docs(module: str, function: str) -> dict:
|
||||
"""Full documentation for a single API function.
|
||||
|
||||
Returns a dict with: module, function, description, params (with types/defaults/descriptions), return_type
|
||||
"""
|
||||
fn = _get_underlying_function(module, function)
|
||||
if fn is None:
|
||||
raise ValueError(f"Function '{module}.{function}' not found")
|
||||
|
||||
description = ""
|
||||
long_description = ""
|
||||
if fn.__doc__:
|
||||
description, long_description = _parse_docstring_body(fn.__doc__)
|
||||
|
||||
params = _extract_params(fn)
|
||||
param_descriptions = _parse_param_docs(fn.__doc__ or "")
|
||||
for param in params:
|
||||
if param["name"] in param_descriptions:
|
||||
param["description"] = param_descriptions[param["name"]]
|
||||
|
||||
return_type = _format_type_hint(typing.get_type_hints(fn).get("return"))
|
||||
return_description = _parse_return_doc(fn.__doc__ or "")
|
||||
|
||||
result = {
|
||||
"module": module,
|
||||
"function": function,
|
||||
"description": description,
|
||||
"long_description": long_description,
|
||||
"params": params,
|
||||
}
|
||||
if return_type:
|
||||
result["return_type"] = return_type
|
||||
if return_description:
|
||||
result["return_description"] = return_description
|
||||
return result
|
||||
|
||||
|
||||
def _get_underlying_function(module: str, function: str):
|
||||
"""Get the actual function object (unwrapping the listener wrapper if needed)."""
|
||||
try:
|
||||
fn_module = importlib.import_module(f"ifcopenshell.api.{module}.{function}")
|
||||
fn = getattr(fn_module, function, None)
|
||||
return fn
|
||||
except (ImportError, AttributeError):
|
||||
return None
|
||||
|
||||
|
||||
def _extract_params(fn) -> list[dict]:
|
||||
"""Extract parameter info from a function's signature and type hints."""
|
||||
sig = inspect.signature(fn)
|
||||
try:
|
||||
hints = typing.get_type_hints(fn)
|
||||
except Exception:
|
||||
hints = {}
|
||||
|
||||
params = []
|
||||
for name, param in sig.parameters.items():
|
||||
if name == "file" or name == "self":
|
||||
continue
|
||||
info = {"name": name}
|
||||
if name in hints:
|
||||
info["type"] = _format_type_hint(hints[name])
|
||||
if param.default is not inspect.Parameter.empty:
|
||||
info["default"] = _serialize_default(param.default)
|
||||
else:
|
||||
info["required"] = True
|
||||
params.append(info)
|
||||
return params
|
||||
|
||||
|
||||
def _format_type_hint(hint) -> str | None:
|
||||
"""Format a type hint to a readable string."""
|
||||
import ifcopenshell
|
||||
|
||||
if hint is None:
|
||||
return None
|
||||
if hint is type(None):
|
||||
return "None"
|
||||
# ifcopenshell.file params are passed as a file path string
|
||||
if hint is ifcopenshell.file:
|
||||
return "file_path"
|
||||
origin = typing.get_origin(hint)
|
||||
args = typing.get_args(hint)
|
||||
|
||||
# Union (including Optional)
|
||||
if origin is typing.Union:
|
||||
formatted = [_format_type_hint(a) for a in args]
|
||||
# Optional[X] is Union[X, None] — render as "Optional[X]"
|
||||
if len(formatted) == 2 and "None" in formatted:
|
||||
inner = next(f for f in formatted if f != "None")
|
||||
return f"Optional[{inner}]"
|
||||
return " | ".join(formatted)
|
||||
|
||||
# Literal
|
||||
if origin is typing.Literal:
|
||||
values = ", ".join(repr(a) for a in args)
|
||||
return f"Literal[{values}]"
|
||||
|
||||
# Generic types (list, dict, etc.)
|
||||
if origin is not None:
|
||||
origin_name = getattr(origin, "__name__", str(origin))
|
||||
if args:
|
||||
inner = ", ".join(_format_type_hint(a) for a in args)
|
||||
return f"{origin_name}[{inner}]"
|
||||
return origin_name
|
||||
|
||||
# Simple types
|
||||
return getattr(hint, "__name__", str(hint))
|
||||
|
||||
|
||||
def _serialize_default(value):
|
||||
"""Serialize a default value to something JSON-friendly."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (str, int, float, bool)):
|
||||
return value
|
||||
return repr(value)
|
||||
|
||||
|
||||
def _parse_docstring_body(docstring: str) -> tuple[str, str]:
|
||||
"""Parse the summary and long description from a docstring."""
|
||||
lines = docstring.strip().split("\n")
|
||||
summary = lines[0].strip() if lines else ""
|
||||
body_lines = []
|
||||
in_body = False
|
||||
for line in lines[1:]:
|
||||
stripped = line.strip()
|
||||
if stripped.startswith(":param") or stripped.startswith(":return"):
|
||||
break
|
||||
if stripped.startswith("Example"):
|
||||
break
|
||||
if not in_body and not stripped:
|
||||
in_body = True
|
||||
continue
|
||||
if in_body:
|
||||
body_lines.append(stripped)
|
||||
|
||||
long_description = " ".join(body_lines).strip()
|
||||
# collapse multiple spaces
|
||||
long_description = re.sub(r"\s+", " ", long_description)
|
||||
return summary, long_description
|
||||
|
||||
|
||||
_FIELD_MARKER = re.compile(r":(?:param|returns?|rtype|type|raises?)\b")
|
||||
|
||||
|
||||
def _parse_param_docs(docstring: str) -> dict[str, str]:
|
||||
"""Extract :param name: description lines from a docstring."""
|
||||
params = {}
|
||||
current_param = None
|
||||
current_lines = []
|
||||
for line in docstring.split("\n"):
|
||||
stripped = line.strip()
|
||||
match = re.match(r":param\s+(\w+):\s*(.*)", stripped)
|
||||
if match:
|
||||
if current_param:
|
||||
params[current_param] = " ".join(current_lines).strip()
|
||||
current_param = match.group(1)
|
||||
current_lines = [match.group(2)]
|
||||
elif current_param and stripped and not _FIELD_MARKER.match(stripped):
|
||||
current_lines.append(stripped)
|
||||
elif _FIELD_MARKER.match(stripped) or (stripped == "" and current_param):
|
||||
if current_param:
|
||||
params[current_param] = " ".join(current_lines).strip()
|
||||
current_param = None
|
||||
current_lines = []
|
||||
if current_param:
|
||||
params[current_param] = " ".join(current_lines).strip()
|
||||
# collapse whitespace
|
||||
return {k: re.sub(r"\s+", " ", v) for k, v in params.items()}
|
||||
|
||||
|
||||
def _parse_return_doc(docstring: str) -> str:
|
||||
"""Extract :return: description from a docstring."""
|
||||
lines = []
|
||||
in_return = False
|
||||
for line in docstring.split("\n"):
|
||||
stripped = line.strip()
|
||||
match = re.match(r":return:\s*(.*)", stripped)
|
||||
if match:
|
||||
in_return = True
|
||||
lines = [match.group(1)]
|
||||
elif in_return:
|
||||
if _FIELD_MARKER.match(stripped) or stripped == "":
|
||||
break
|
||||
lines.append(stripped)
|
||||
return re.sub(r"\s+", " ", " ".join(lines).strip())
|
||||
@@ -0,0 +1,76 @@
|
||||
# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions
|
||||
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
|
||||
#
|
||||
# This file is part of IfcEdit.
|
||||
#
|
||||
# IfcEdit is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# IfcEdit is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcEdit. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ifcopenshell
|
||||
|
||||
from ifcedit.run import run_api
|
||||
|
||||
|
||||
def _substitute(template: str, item: dict) -> str:
|
||||
"""Replace {key} placeholders in template with values from item."""
|
||||
for key, value in item.items():
|
||||
template = template.replace(f"{{{key}}}", str(value))
|
||||
return template
|
||||
|
||||
|
||||
def run_foreach(
|
||||
model: ifcopenshell.file,
|
||||
module: str,
|
||||
function: str,
|
||||
raw_kwargs_template: dict[str, str],
|
||||
items: list[dict],
|
||||
) -> dict:
|
||||
"""Apply an API function to each item in a list, substituting {field} placeholders.
|
||||
|
||||
Opens the model once, applies the mutation for every item, and returns a summary.
|
||||
The caller is responsible for saving the model.
|
||||
|
||||
Args:
|
||||
model: The open IFC model (mutated in place).
|
||||
module: API module name (e.g. "root").
|
||||
function: Function name (e.g. "remove_product").
|
||||
raw_kwargs_template: Arg templates with {field} placeholders, e.g. {"product": "{id}"}.
|
||||
items: List of dicts (e.g. from ifcquery select output).
|
||||
|
||||
Returns:
|
||||
{"ok": True, "count": N, "errors": []} on full success,
|
||||
{"ok": False, "count": N, "errors": [{...}]} if any item failed.
|
||||
"""
|
||||
errors = []
|
||||
count = 0
|
||||
|
||||
for i, item in enumerate(items):
|
||||
if not isinstance(item, dict):
|
||||
errors.append({"index": i, "item": item, "error": "item is not a dict"})
|
||||
continue
|
||||
|
||||
substituted = {k: _substitute(v, item) for k, v in raw_kwargs_template.items()}
|
||||
result = run_api(model, module, function, substituted)
|
||||
|
||||
if result["ok"]:
|
||||
count += 1
|
||||
else:
|
||||
errors.append({"index": i, "item": item, "error": result["error"]})
|
||||
|
||||
return {
|
||||
"ok": len(errors) == 0,
|
||||
"count": count,
|
||||
"errors": errors,
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import ifcopenshell
|
||||
|
||||
AVAILABLE_RULES = ["IFC4QtoBaseQuantities", "IFC4X3QtoBaseQuantities"]
|
||||
|
||||
|
||||
def list_rules() -> list[dict[str, str]]:
|
||||
"""Return a list of available quantification rule names."""
|
||||
return [{"name": name} for name in AVAILABLE_RULES]
|
||||
|
||||
|
||||
def run_quantify(model: ifcopenshell.file, rule: str, selector: str | None = None) -> dict[str, Any]:
|
||||
"""Run quantity take-off on the model using the named rule.
|
||||
|
||||
Modifies the model in-place by adding/updating IfcElementQuantity psets.
|
||||
Returns a summary dict with ok, rule, and elements_quantified.
|
||||
"""
|
||||
from ifc5d.qto import edit_qtos, quantify
|
||||
from ifc5d.qto import rules as rule_sets
|
||||
|
||||
if rule not in rule_sets:
|
||||
return {"ok": False, "error": f"Unknown rule: {rule}. Available: {list(rule_sets.keys())}"}
|
||||
|
||||
import ifcopenshell.util.selector
|
||||
|
||||
if selector:
|
||||
elements = set(ifcopenshell.util.selector.filter_elements(model, selector))
|
||||
else:
|
||||
elements = set(model.by_type("IfcElement"))
|
||||
|
||||
results = quantify(model, elements, rule_sets[rule])
|
||||
edit_qtos(model, results)
|
||||
return {"ok": True, "rule": rule, "elements_quantified": len(results)}
|
||||
@@ -0,0 +1,148 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions
|
||||
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
|
||||
#
|
||||
# This file is part of IfcEdit.
|
||||
#
|
||||
# IfcEdit is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# IfcEdit is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcEdit. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import inspect
|
||||
import typing
|
||||
|
||||
import ifcopenshell
|
||||
|
||||
from ifcedit.coerce import coerce_value
|
||||
|
||||
|
||||
def _is_file_type(hint) -> bool:
|
||||
"""Check if a type hint refers to ifcopenshell.file (or Optional[ifcopenshell.file])."""
|
||||
if hint is ifcopenshell.file:
|
||||
return True
|
||||
origin = typing.get_origin(hint)
|
||||
args = typing.get_args(hint)
|
||||
if origin is typing.Union and ifcopenshell.file in args:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def run_api(
|
||||
model: ifcopenshell.file,
|
||||
module: str,
|
||||
function: str,
|
||||
raw_kwargs: dict[str, str],
|
||||
) -> dict:
|
||||
"""Execute an ifcopenshell.api function with CLI-provided string arguments.
|
||||
|
||||
Args:
|
||||
model: The open IFC model.
|
||||
module: API module name (e.g. "root").
|
||||
function: Function name (e.g. "create_entity").
|
||||
raw_kwargs: String keyword arguments from the CLI.
|
||||
|
||||
Returns:
|
||||
A dict with {"ok": True, "result": ...} on success,
|
||||
or {"ok": False, "error": "..."} on failure.
|
||||
"""
|
||||
try:
|
||||
fn = _import_function(module, function)
|
||||
except (ImportError, AttributeError) as e:
|
||||
return {"ok": False, "error": f"Cannot find function '{module}.{function}': {e}"}
|
||||
|
||||
try:
|
||||
hints = typing.get_type_hints(fn)
|
||||
except Exception:
|
||||
hints = {}
|
||||
|
||||
sig = inspect.signature(fn)
|
||||
coerced_kwargs = {}
|
||||
|
||||
# Pass 1: coerce ifcopenshell.file-typed params first (e.g. library= in append_asset).
|
||||
# The opened file is then used as the lookup file for entity resolution in pass 2.
|
||||
opened_files: list[ifcopenshell.file] = []
|
||||
for name, value_str in raw_kwargs.items():
|
||||
if name not in sig.parameters:
|
||||
return {"ok": False, "error": f"Unknown parameter '{name}' for {module}.{function}"}
|
||||
hint = hints.get(name)
|
||||
if not _is_file_type(hint):
|
||||
continue
|
||||
try:
|
||||
coerced = coerce_value(value_str, hint, model)
|
||||
coerced_kwargs[name] = coerced
|
||||
if isinstance(coerced, ifcopenshell.file):
|
||||
opened_files.append(coerced)
|
||||
except (ValueError, TypeError) as e:
|
||||
return {"ok": False, "error": f"Cannot convert parameter '{name}': {e}"}
|
||||
|
||||
# Pass 2: coerce remaining params. Entity instance IDs are resolved from the opened
|
||||
# library file (if any), since you are always appending from another file, never
|
||||
# from the current model.
|
||||
lookup_file = opened_files[0] if opened_files else None
|
||||
for name, value_str in raw_kwargs.items():
|
||||
if name in coerced_kwargs:
|
||||
continue
|
||||
if name not in sig.parameters:
|
||||
return {"ok": False, "error": f"Unknown parameter '{name}' for {module}.{function}"}
|
||||
hint = hints.get(name)
|
||||
try:
|
||||
coerced_kwargs[name] = coerce_value(value_str, hint, model, lookup_file=lookup_file)
|
||||
except (ValueError, TypeError) as e:
|
||||
return {"ok": False, "error": f"Cannot convert parameter '{name}': {e}"}
|
||||
|
||||
# Determine if the function takes 'file' as its first parameter
|
||||
first_param = next(iter(sig.parameters), None)
|
||||
try:
|
||||
if first_param == "file":
|
||||
result = fn(model, **coerced_kwargs)
|
||||
else:
|
||||
result = fn(**coerced_kwargs)
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
|
||||
|
||||
return {"ok": True, "result": serialize_result(result)}
|
||||
|
||||
|
||||
def _import_function(module: str, function: str):
|
||||
"""Import and return the underlying function from ifcopenshell.api."""
|
||||
fn_module = importlib.import_module(f"ifcopenshell.api.{module}.{function}")
|
||||
fn = getattr(fn_module, function)
|
||||
return fn
|
||||
|
||||
|
||||
def serialize_result(value) -> object:
|
||||
"""Serialize an API result to a JSON-friendly structure."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, ifcopenshell.entity_instance):
|
||||
return _serialize_entity(value)
|
||||
if isinstance(value, (list, tuple, set, frozenset)):
|
||||
return [serialize_result(item) for item in value]
|
||||
if isinstance(value, dict):
|
||||
return {str(k): serialize_result(v) for k, v in value.items()}
|
||||
if isinstance(value, (str, int, float, bool)):
|
||||
return value
|
||||
return str(value)
|
||||
|
||||
|
||||
def _serialize_entity(entity: ifcopenshell.entity_instance) -> dict:
|
||||
"""Serialize an entity instance to a summary dict."""
|
||||
result = {
|
||||
"id": entity.id(),
|
||||
"type": entity.is_a(),
|
||||
}
|
||||
if hasattr(entity, "Name") and entity.Name:
|
||||
result["name"] = entity.Name
|
||||
return result
|
||||
@@ -0,0 +1,33 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "ifcedit"
|
||||
version = "0.0.0"
|
||||
authors = [
|
||||
{ name="Bruno Postle", email="bruno@postle.net" },
|
||||
]
|
||||
description = "CLI wrapper for ifcopenshell.api IFC model mutation functions"
|
||||
readme = "README.md"
|
||||
keywords = ["IFC", "BIM", "API"]
|
||||
classifiers = [
|
||||
"Programming Language :: Python :: 3",
|
||||
"License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)",
|
||||
]
|
||||
dependencies = ["ifcopenshell", "ifc5d"]
|
||||
|
||||
[project.scripts]
|
||||
ifcedit = "ifcedit.__main__:main"
|
||||
|
||||
[project.urls]
|
||||
Homepage = "http://ifcopenshell.org"
|
||||
Documentation = "https://docs.ifcopenshell.org"
|
||||
Issues = "https://github.com/IfcOpenShell/IfcOpenShell/issues"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["ifcedit*"]
|
||||
exclude = ["test*"]
|
||||
|
||||
[tool.ruff]
|
||||
extend = "../../pyproject.toml"
|
||||
@@ -0,0 +1 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
@@ -0,0 +1,63 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.aggregate
|
||||
import ifcopenshell.api.material
|
||||
import ifcopenshell.api.owner.settings
|
||||
import ifcopenshell.api.project
|
||||
import ifcopenshell.api.pset
|
||||
import ifcopenshell.api.root
|
||||
import ifcopenshell.api.spatial
|
||||
import ifcopenshell.api.unit
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def model():
|
||||
"""Create an IFC4 model with a spatial hierarchy and a wall."""
|
||||
f = ifcopenshell.api.project.create_file()
|
||||
ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0]
|
||||
ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0]
|
||||
|
||||
project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="TestProject")
|
||||
ifcopenshell.api.unit.assign_unit(f)
|
||||
|
||||
site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="TestSite")
|
||||
building = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuilding", name="TestBuilding")
|
||||
storey = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="Ground Floor")
|
||||
|
||||
ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project)
|
||||
ifcopenshell.api.aggregate.assign_object(f, products=[building], relating_object=site)
|
||||
ifcopenshell.api.aggregate.assign_object(f, products=[storey], relating_object=building)
|
||||
|
||||
wall = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall001")
|
||||
ifcopenshell.api.spatial.assign_container(f, products=[wall], relating_structure=storey)
|
||||
|
||||
return f
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def model_file(model, tmp_path):
|
||||
"""Write the model fixture to a temp file and return the path."""
|
||||
path = tmp_path / "test.ifc"
|
||||
model.write(str(path))
|
||||
return str(path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def library():
|
||||
"""Create an IFC4 library with a single IfcWallType asset."""
|
||||
lib = ifcopenshell.api.project.create_file()
|
||||
ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0]
|
||||
ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0]
|
||||
ifcopenshell.api.root.create_entity(lib, ifc_class="IfcProject", name="TestLibrary")
|
||||
ifcopenshell.api.unit.assign_unit(lib)
|
||||
ifcopenshell.api.root.create_entity(lib, ifc_class="IfcWallType", name="WAL01")
|
||||
return lib
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def library_file(library, tmp_path):
|
||||
"""Write the library fixture to a temp file and return the path."""
|
||||
path = tmp_path / "library.ifc"
|
||||
library.write(str(path))
|
||||
return str(path)
|
||||
@@ -0,0 +1,158 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
import json
|
||||
from typing import Literal, Optional, Union
|
||||
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.project
|
||||
import pytest
|
||||
|
||||
from ifcedit.coerce import coerce_value
|
||||
|
||||
|
||||
class TestStringCoercion:
|
||||
def test_plain_string(self):
|
||||
assert coerce_value("hello", str) == "hello"
|
||||
|
||||
def test_empty_string(self):
|
||||
assert coerce_value("", str) == ""
|
||||
|
||||
|
||||
class TestIntCoercion:
|
||||
def test_plain_int(self):
|
||||
assert coerce_value("42", int) == 42
|
||||
|
||||
def test_hash_prefix(self):
|
||||
assert coerce_value("#42", int) == 42
|
||||
|
||||
def test_negative(self):
|
||||
assert coerce_value("-5", int) == -5
|
||||
|
||||
|
||||
class TestFloatCoercion:
|
||||
def test_plain_float(self):
|
||||
assert coerce_value("3.14", float) == pytest.approx(3.14)
|
||||
|
||||
def test_integer_as_float(self):
|
||||
assert coerce_value("5", float) == 5.0
|
||||
|
||||
|
||||
class TestBoolCoercion:
|
||||
def test_true_values(self):
|
||||
for val in ("true", "True", "TRUE", "1", "yes"):
|
||||
assert coerce_value(val, bool) is True
|
||||
|
||||
def test_false_values(self):
|
||||
for val in ("false", "False", "0", "no"):
|
||||
assert coerce_value(val, bool) is False
|
||||
|
||||
|
||||
class TestOptionalCoercion:
|
||||
def test_optional_string(self):
|
||||
assert coerce_value("hello", Optional[str]) == "hello"
|
||||
|
||||
def test_optional_none(self):
|
||||
assert coerce_value("none", Optional[str]) is None
|
||||
assert coerce_value("None", Optional[str]) is None
|
||||
|
||||
def test_optional_int(self):
|
||||
assert coerce_value("42", Optional[int]) == 42
|
||||
|
||||
|
||||
class TestUnionCoercion:
|
||||
def test_union_str_int(self):
|
||||
# Tries str first (or int first depending on order), both work
|
||||
result = coerce_value("hello", Union[str, int])
|
||||
assert result == "hello"
|
||||
|
||||
def test_union_int_none(self):
|
||||
result = coerce_value("42", Union[int, None])
|
||||
assert result == 42
|
||||
|
||||
|
||||
class TestLiteralCoercion:
|
||||
def test_valid_literal(self):
|
||||
assert coerce_value("IFC4", Literal["IFC2X3", "IFC4", "IFC4X3"]) == "IFC4"
|
||||
|
||||
def test_invalid_literal(self):
|
||||
with pytest.raises(ValueError, match="not one of"):
|
||||
coerce_value("IFC5", Literal["IFC2X3", "IFC4", "IFC4X3"])
|
||||
|
||||
|
||||
class TestDictCoercion:
|
||||
def test_json_dict(self):
|
||||
result = coerce_value('{"IsExternal": true, "FireRating": "2HR"}', dict[str, object])
|
||||
assert result == {"IsExternal": True, "FireRating": "2HR"}
|
||||
|
||||
def test_mixed_float_int_list_coerced_to_float(self):
|
||||
# [0.419, 0, 0.908] — JSON integer 0 mixed with floats must become float
|
||||
# so ifcopenshell AGGREGATE OF DOUBLE attributes (e.g. DirectionRatios) don't reject the list
|
||||
result = coerce_value('{"DirectionRatios": [0.419, 0, 0.908]}', dict[str, object])
|
||||
assert result["DirectionRatios"] == pytest.approx([0.419, 0.0, 0.908])
|
||||
assert all(isinstance(v, float) for v in result["DirectionRatios"])
|
||||
|
||||
def test_pure_int_list_not_coerced(self):
|
||||
# All-integer lists (e.g. face indices) must stay as ints
|
||||
result = coerce_value('{"CoordIndex": [0, 1, 2]}', dict[str, object])
|
||||
assert result["CoordIndex"] == [0, 1, 2]
|
||||
assert all(isinstance(v, int) for v in result["CoordIndex"])
|
||||
|
||||
|
||||
class TestListCoercion:
|
||||
def test_comma_separated(self):
|
||||
result = coerce_value("a,b,c", list[str])
|
||||
assert result == ["a", "b", "c"]
|
||||
|
||||
def test_json_array(self):
|
||||
result = coerce_value("[1, 2, 3]", list[int])
|
||||
assert result == [1, 2, 3]
|
||||
|
||||
|
||||
class TestEntityCoercion:
|
||||
def test_entity_by_id(self, model):
|
||||
wall = model.by_type("IfcWall")[0]
|
||||
result = coerce_value(str(wall.id()), ifcopenshell.entity_instance, model)
|
||||
assert result == wall
|
||||
|
||||
def test_entity_with_hash(self, model):
|
||||
wall = model.by_type("IfcWall")[0]
|
||||
result = coerce_value(f"#{wall.id()}", ifcopenshell.entity_instance, model)
|
||||
assert result == wall
|
||||
|
||||
def test_entity_not_found(self, model):
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
coerce_value("999999", ifcopenshell.entity_instance, model)
|
||||
|
||||
def test_entity_list(self, model):
|
||||
wall = model.by_type("IfcWall")[0]
|
||||
result = coerce_value(str(wall.id()), list[ifcopenshell.entity_instance], model)
|
||||
assert len(result) == 1
|
||||
assert result[0] == wall
|
||||
|
||||
def test_entity_list_multiple(self, model):
|
||||
wall = model.by_type("IfcWall")[0]
|
||||
storey = model.by_type("IfcBuildingStorey")[0]
|
||||
result = coerce_value(f"{wall.id()},{storey.id()}", list[ifcopenshell.entity_instance], model)
|
||||
assert len(result) == 2
|
||||
|
||||
def test_entity_no_model(self):
|
||||
with pytest.raises(ValueError, match="without an IFC model"):
|
||||
coerce_value("42", ifcopenshell.entity_instance, None)
|
||||
|
||||
|
||||
class TestFileCoercion:
|
||||
def test_opens_file_from_path(self, model_file):
|
||||
result = coerce_value(model_file, ifcopenshell.file)
|
||||
assert isinstance(result, ifcopenshell.file)
|
||||
|
||||
def test_entity_from_lookup_file(self, model_file):
|
||||
lib = ifcopenshell.open(model_file)
|
||||
wall = lib.by_type("IfcWall")[0]
|
||||
empty_model = ifcopenshell.api.project.create_file()
|
||||
result = coerce_value(str(wall.id()), ifcopenshell.entity_instance, empty_model, lookup_file=lib)
|
||||
assert result.id() == wall.id()
|
||||
assert result.is_a("IfcWall")
|
||||
|
||||
|
||||
class TestFallback:
|
||||
def test_no_type_hint(self):
|
||||
assert coerce_value("hello", None) == "hello"
|
||||
@@ -0,0 +1,104 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
from ifcedit.discover import function_docs, list_functions, list_modules
|
||||
|
||||
|
||||
class TestListModules:
|
||||
def test_returns_list(self):
|
||||
result = list_modules()
|
||||
assert isinstance(result, list)
|
||||
assert len(result) > 0
|
||||
|
||||
def test_module_structure(self):
|
||||
result = list_modules()
|
||||
for entry in result:
|
||||
assert "module" in entry
|
||||
assert "description" in entry
|
||||
assert "functions" in entry
|
||||
assert "count" in entry
|
||||
assert isinstance(entry["functions"], list)
|
||||
assert entry["count"] == len(entry["functions"])
|
||||
|
||||
def test_known_modules_present(self):
|
||||
result = list_modules()
|
||||
module_names = [m["module"] for m in result]
|
||||
for expected in ("root", "spatial", "pset", "aggregate", "unit"):
|
||||
assert expected in module_names
|
||||
|
||||
def test_root_module_has_functions(self):
|
||||
result = list_modules()
|
||||
root = next(m for m in result if m["module"] == "root")
|
||||
assert "create_entity" in root["functions"]
|
||||
assert root["count"] >= 3
|
||||
|
||||
|
||||
class TestListFunctions:
|
||||
def test_root_functions(self):
|
||||
result = list_functions("root")
|
||||
assert isinstance(result, list)
|
||||
names = [f["name"] for f in result]
|
||||
assert "create_entity" in names
|
||||
|
||||
def test_function_structure(self):
|
||||
result = list_functions("root")
|
||||
for fn in result:
|
||||
assert "name" in fn
|
||||
assert "description" in fn
|
||||
assert "params" in fn
|
||||
|
||||
def test_create_entity_params(self):
|
||||
result = list_functions("root")
|
||||
create = next(f for f in result if f["name"] == "create_entity")
|
||||
param_names = [p["name"] for p in create["params"]]
|
||||
assert "ifc_class" in param_names
|
||||
assert "name" in param_names
|
||||
|
||||
def test_pset_functions(self):
|
||||
result = list_functions("pset")
|
||||
names = [f["name"] for f in result]
|
||||
assert "add_pset" in names
|
||||
assert "edit_pset" in names
|
||||
|
||||
|
||||
class TestFunctionDocs:
|
||||
def test_create_entity_docs(self):
|
||||
result = function_docs("root", "create_entity")
|
||||
assert result["module"] == "root"
|
||||
assert result["function"] == "create_entity"
|
||||
assert result["description"]
|
||||
assert isinstance(result["params"], list)
|
||||
assert len(result["params"]) > 0
|
||||
|
||||
def test_params_have_types(self):
|
||||
result = function_docs("root", "create_entity")
|
||||
for param in result["params"]:
|
||||
assert "name" in param
|
||||
assert "type" in param
|
||||
|
||||
def test_params_have_descriptions(self):
|
||||
result = function_docs("root", "create_entity")
|
||||
ifc_class = next(p for p in result["params"] if p["name"] == "ifc_class")
|
||||
assert "description" in ifc_class
|
||||
assert len(ifc_class["description"]) > 0
|
||||
|
||||
def test_return_type(self):
|
||||
result = function_docs("root", "create_entity")
|
||||
assert "return_type" in result
|
||||
|
||||
def test_assign_container_docs(self):
|
||||
result = function_docs("spatial", "assign_container")
|
||||
assert result["module"] == "spatial"
|
||||
param_names = [p["name"] for p in result["params"]]
|
||||
assert "products" in param_names
|
||||
assert "relating_structure" in param_names
|
||||
|
||||
def test_unknown_function_raises(self):
|
||||
import pytest
|
||||
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
function_docs("root", "nonexistent_function")
|
||||
|
||||
def test_edit_pset_docs(self):
|
||||
result = function_docs("pset", "edit_pset")
|
||||
param_names = [p["name"] for p in result["params"]]
|
||||
assert "pset" in param_names
|
||||
assert "properties" in param_names
|
||||
@@ -0,0 +1,85 @@
|
||||
# Tests for ifcedit.foreach
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.owner.settings
|
||||
import ifcopenshell.api.project
|
||||
import ifcopenshell.api.root
|
||||
import ifcopenshell.api.spatial
|
||||
import ifcopenshell.api.unit
|
||||
import pytest
|
||||
|
||||
from ifcedit.foreach import _substitute, run_foreach
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def model(model):
|
||||
return model
|
||||
|
||||
|
||||
class TestSubstitute:
|
||||
def test_single_field(self):
|
||||
assert _substitute("--product {id}", {"id": 42}) == "--product 42"
|
||||
|
||||
def test_multiple_fields(self):
|
||||
result = _substitute("{type} #{id} ({name})", {"id": 5, "type": "IfcWall", "name": "W1"})
|
||||
assert result == "IfcWall #5 (W1)"
|
||||
|
||||
def test_no_placeholder(self):
|
||||
assert _substitute("hello", {"id": 1}) == "hello"
|
||||
|
||||
def test_unknown_placeholder_unchanged(self):
|
||||
assert _substitute("{unknown}", {"id": 1}) == "{unknown}"
|
||||
|
||||
|
||||
class TestRunForeach:
|
||||
def _items(self, model, ifc_class):
|
||||
return [{"id": e.id(), "type": e.is_a(), "name": e.Name} for e in model.by_type(ifc_class)]
|
||||
|
||||
def test_rename_single(self, model):
|
||||
items = self._items(model, "IfcWall")
|
||||
result = run_foreach(
|
||||
model, "attribute", "edit_attributes", {"product": "{id}", "attributes": '{"Name": "R"}'}, items
|
||||
)
|
||||
assert result["ok"] is True
|
||||
assert result["count"] == 1
|
||||
assert result["errors"] == []
|
||||
assert model.by_type("IfcWall")[0].Name == "R"
|
||||
|
||||
def test_rename_multiple(self, model):
|
||||
items = self._items(model, "IfcElement")
|
||||
result = run_foreach(
|
||||
model, "attribute", "edit_attributes", {"product": "{id}", "attributes": '{"Name": "X"}'}, items
|
||||
)
|
||||
assert result["ok"] is True
|
||||
assert result["count"] == len(items)
|
||||
|
||||
def test_empty_list(self, model):
|
||||
result = run_foreach(model, "root", "remove_product", {"product": "{id}"}, [])
|
||||
assert result["ok"] is True
|
||||
assert result["count"] == 0
|
||||
assert result["errors"] == []
|
||||
|
||||
def test_bad_id_collects_error(self, model):
|
||||
items = [{"id": 999999, "type": "IfcWall", "name": "X"}]
|
||||
result = run_foreach(model, "root", "remove_product", {"product": "{id}"}, items)
|
||||
assert result["ok"] is False
|
||||
assert result["count"] == 0
|
||||
assert len(result["errors"]) == 1
|
||||
assert result["errors"][0]["index"] == 0
|
||||
|
||||
def test_non_dict_item_collects_error(self, model):
|
||||
result = run_foreach(model, "root", "remove_product", {"product": "{id}"}, ["not_a_dict"])
|
||||
assert result["ok"] is False
|
||||
assert len(result["errors"]) == 1
|
||||
|
||||
def test_partial_failure_counts_successes(self, model):
|
||||
wall_id = model.by_type("IfcWall")[0].id()
|
||||
items = [
|
||||
{"id": wall_id, "type": "IfcWall", "name": "W"},
|
||||
{"id": 999999, "type": "IfcWall", "name": "Bad"},
|
||||
]
|
||||
result = run_foreach(
|
||||
model, "attribute", "edit_attributes", {"product": "{id}", "attributes": '{"Name": "Ok"}'}, items
|
||||
)
|
||||
assert result["ok"] is False
|
||||
assert result["count"] == 1
|
||||
assert len(result["errors"]) == 1
|
||||
@@ -0,0 +1,211 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import ifcopenshell
|
||||
import pytest
|
||||
|
||||
|
||||
def run_ifcedit(*args, stdin=None):
|
||||
"""Run ifcedit as a subprocess and return (stdout, stderr, returncode)."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "ifcedit", *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
input=stdin,
|
||||
)
|
||||
return result.stdout, result.stderr, result.returncode
|
||||
|
||||
|
||||
class TestListCommand:
|
||||
def test_list_all_modules(self):
|
||||
stdout, stderr, rc = run_ifcedit("list")
|
||||
assert rc == 0
|
||||
data = json.loads(stdout)
|
||||
assert isinstance(data, list)
|
||||
module_names = [m["module"] for m in data]
|
||||
assert "root" in module_names
|
||||
assert "spatial" in module_names
|
||||
|
||||
def test_list_module_functions(self):
|
||||
stdout, stderr, rc = run_ifcedit("list", "root")
|
||||
assert rc == 0
|
||||
data = json.loads(stdout)
|
||||
assert isinstance(data, list)
|
||||
names = [f["name"] for f in data]
|
||||
assert "create_entity" in names
|
||||
|
||||
def test_list_text_format(self):
|
||||
stdout, stderr, rc = run_ifcedit("--format", "text", "list")
|
||||
assert rc == 0
|
||||
assert "root" in stdout
|
||||
|
||||
|
||||
class TestDocsCommand:
|
||||
def test_docs_create_entity(self):
|
||||
stdout, stderr, rc = run_ifcedit("docs", "root.create_entity")
|
||||
assert rc == 0
|
||||
data = json.loads(stdout)
|
||||
assert data["module"] == "root"
|
||||
assert data["function"] == "create_entity"
|
||||
assert "params" in data
|
||||
|
||||
def test_docs_invalid_path(self):
|
||||
stdout, stderr, rc = run_ifcedit("docs", "invalid_path")
|
||||
assert rc != 0
|
||||
assert "module.function" in stderr
|
||||
|
||||
def test_docs_unknown_function(self):
|
||||
stdout, stderr, rc = run_ifcedit("docs", "root.nonexistent")
|
||||
assert rc != 0
|
||||
|
||||
|
||||
class TestRunCommand:
|
||||
def test_create_entity(self, model_file):
|
||||
stdout, stderr, rc = run_ifcedit(
|
||||
"run", model_file, "root.create_entity", "--ifc_class", "IfcWall", "--name", "CLIWall"
|
||||
)
|
||||
assert rc == 0, f"stderr: {stderr}"
|
||||
data = json.loads(stdout)
|
||||
assert data["ok"] is True
|
||||
assert data["result"]["type"] == "IfcWall"
|
||||
assert data["result"]["name"] == "CLIWall"
|
||||
|
||||
def test_dry_run(self, model_file):
|
||||
stdout, stderr, rc = run_ifcedit("run", model_file, "root.create_entity", "--dry-run", "--ifc_class", "IfcWall")
|
||||
assert rc == 0
|
||||
data = json.loads(stdout)
|
||||
assert data["ok"] is True
|
||||
assert data["dry_run"] is True
|
||||
|
||||
def test_output_to_different_file(self, model_file, tmp_path):
|
||||
output = str(tmp_path / "output.ifc")
|
||||
stdout, stderr, rc = run_ifcedit(
|
||||
"run", model_file, "root.create_entity", "-o", output, "--ifc_class", "IfcSlab"
|
||||
)
|
||||
assert rc == 0, f"stderr: {stderr}"
|
||||
data = json.loads(stdout)
|
||||
assert data["ok"] is True
|
||||
|
||||
import os
|
||||
|
||||
assert os.path.exists(output)
|
||||
|
||||
def test_run_error_bad_function(self, model_file):
|
||||
stdout, stderr, rc = run_ifcedit("run", model_file, "root.nonexistent")
|
||||
assert rc != 0
|
||||
|
||||
def test_run_invalid_function_path(self, model_file):
|
||||
stdout, stderr, rc = run_ifcedit("run", model_file, "invalid_path")
|
||||
assert rc != 0
|
||||
assert "module.function" in stderr
|
||||
|
||||
|
||||
class TestForeachCommand:
|
||||
def _select_json(self, model, ifc_class):
|
||||
"""Build a JSON array like ifcquery select would produce."""
|
||||
elements = model.by_type(ifc_class)
|
||||
return json.dumps([{"id": e.id(), "type": e.is_a(), "name": getattr(e, "Name", None)} for e in elements])
|
||||
|
||||
def test_foreach_rename(self, model, model_file):
|
||||
walls_json = self._select_json(model, "IfcWall")
|
||||
stdout, stderr, rc = run_ifcedit(
|
||||
"foreach",
|
||||
model_file,
|
||||
"attribute.edit_attributes",
|
||||
"--product",
|
||||
"{id}",
|
||||
"--attributes",
|
||||
'{"Name": "Renamed"}',
|
||||
stdin=walls_json,
|
||||
)
|
||||
assert rc == 0, f"stderr: {stderr}"
|
||||
data = json.loads(stdout)
|
||||
assert data["ok"] is True
|
||||
assert data["count"] == 1
|
||||
assert data["errors"] == []
|
||||
updated = ifcopenshell.open(model_file)
|
||||
assert updated.by_type("IfcWall")[0].Name == "Renamed"
|
||||
|
||||
def test_foreach_multiple_elements(self, model, model_file):
|
||||
# Build a two-item list by selecting all IfcObject (includes spatial structure + elements)
|
||||
elements_json = self._select_json(model, "IfcObject")
|
||||
items = json.loads(elements_json)
|
||||
assert len(items) >= 2
|
||||
stdout, stderr, rc = run_ifcedit(
|
||||
"foreach",
|
||||
model_file,
|
||||
"attribute.edit_attributes",
|
||||
"--product",
|
||||
"{id}",
|
||||
"--attributes",
|
||||
'{"Name": "Bulk"}',
|
||||
stdin=elements_json,
|
||||
)
|
||||
assert rc == 0, f"stderr: {stderr}"
|
||||
data = json.loads(stdout)
|
||||
assert data["ok"] is True
|
||||
assert data["count"] == len(items)
|
||||
|
||||
def test_foreach_empty_list(self, model_file):
|
||||
stdout, stderr, rc = run_ifcedit(
|
||||
"foreach",
|
||||
model_file,
|
||||
"attribute.edit_attributes",
|
||||
"--product",
|
||||
"{id}",
|
||||
"--attributes",
|
||||
'{"Name": "X"}',
|
||||
stdin="[]",
|
||||
)
|
||||
assert rc == 0
|
||||
data = json.loads(stdout)
|
||||
assert data["ok"] is True
|
||||
assert data["count"] == 0
|
||||
|
||||
def test_foreach_invalid_json_stdin(self, model_file):
|
||||
stdout, stderr, rc = run_ifcedit(
|
||||
"foreach",
|
||||
model_file,
|
||||
"root.remove_product",
|
||||
"--product",
|
||||
"{id}",
|
||||
stdin="not json",
|
||||
)
|
||||
assert rc != 0
|
||||
assert "Error" in stderr
|
||||
|
||||
def test_foreach_not_array_stdin(self, model_file):
|
||||
stdout, stderr, rc = run_ifcedit(
|
||||
"foreach",
|
||||
model_file,
|
||||
"root.remove_product",
|
||||
"--product",
|
||||
"{id}",
|
||||
stdin='{"id": 1}',
|
||||
)
|
||||
assert rc != 0
|
||||
assert "Error" in stderr
|
||||
|
||||
def test_foreach_output_to_different_file(self, model, model_file, tmp_path):
|
||||
import os
|
||||
|
||||
output = str(tmp_path / "out.ifc")
|
||||
walls_json = self._select_json(model, "IfcWall")
|
||||
stdout, stderr, rc = run_ifcedit(
|
||||
"foreach",
|
||||
model_file,
|
||||
"attribute.edit_attributes",
|
||||
"-o",
|
||||
output,
|
||||
"--product",
|
||||
"{id}",
|
||||
"--attributes",
|
||||
'{"Name": "OutFile"}',
|
||||
stdin=walls_json,
|
||||
)
|
||||
assert rc == 0, f"stderr: {stderr}"
|
||||
assert os.path.exists(output)
|
||||
updated = ifcopenshell.open(output)
|
||||
assert updated.by_type("IfcWall")[0].Name == "OutFile"
|
||||
@@ -0,0 +1,87 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
from __future__ import annotations
|
||||
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.aggregate
|
||||
import ifcopenshell.api.owner.settings
|
||||
import ifcopenshell.api.project
|
||||
import ifcopenshell.api.root
|
||||
import ifcopenshell.api.spatial
|
||||
import ifcopenshell.api.unit
|
||||
import pytest
|
||||
|
||||
from ifcedit.quantify import AVAILABLE_RULES, list_rules, run_quantify
|
||||
|
||||
|
||||
class TestListRules:
|
||||
def test_returns_list(self):
|
||||
result = list_rules()
|
||||
assert isinstance(result, list)
|
||||
|
||||
def test_each_entry_has_name(self):
|
||||
result = list_rules()
|
||||
for entry in result:
|
||||
assert "name" in entry
|
||||
|
||||
def test_ifc4_rule_present(self):
|
||||
result = list_rules()
|
||||
names = [r["name"] for r in result]
|
||||
assert "IFC4QtoBaseQuantities" in names
|
||||
|
||||
def test_ifc4x3_rule_present(self):
|
||||
result = list_rules()
|
||||
names = [r["name"] for r in result]
|
||||
assert "IFC4X3QtoBaseQuantities" in names
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def quantify_model():
|
||||
"""Create an IFC4 model with a wall element."""
|
||||
f = ifcopenshell.api.project.create_file()
|
||||
ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0]
|
||||
ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0]
|
||||
|
||||
project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="TestProject")
|
||||
ifcopenshell.api.unit.assign_unit(f)
|
||||
|
||||
site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="TestSite")
|
||||
building = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuilding", name="TestBuilding")
|
||||
storey = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="Ground Floor")
|
||||
|
||||
ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project)
|
||||
ifcopenshell.api.aggregate.assign_object(f, products=[building], relating_object=site)
|
||||
ifcopenshell.api.aggregate.assign_object(f, products=[storey], relating_object=building)
|
||||
|
||||
wall = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall001")
|
||||
ifcopenshell.api.spatial.assign_container(f, products=[wall], relating_structure=storey)
|
||||
|
||||
return f
|
||||
|
||||
|
||||
class TestRunQuantify:
|
||||
def test_returns_ok_true(self, quantify_model):
|
||||
result = run_quantify(quantify_model, "IFC4QtoBaseQuantities")
|
||||
assert result["ok"] is True
|
||||
|
||||
def test_returns_rule_name(self, quantify_model):
|
||||
result = run_quantify(quantify_model, "IFC4QtoBaseQuantities")
|
||||
assert result["rule"] == "IFC4QtoBaseQuantities"
|
||||
|
||||
def test_returns_elements_quantified(self, quantify_model):
|
||||
result = run_quantify(quantify_model, "IFC4QtoBaseQuantities")
|
||||
assert "elements_quantified" in result
|
||||
assert isinstance(result["elements_quantified"], int)
|
||||
|
||||
def test_unknown_rule_returns_error(self, quantify_model):
|
||||
result = run_quantify(quantify_model, "NonExistentRule")
|
||||
assert result["ok"] is False
|
||||
assert "error" in result
|
||||
|
||||
def test_selector_restricts_elements(self, quantify_model):
|
||||
result = run_quantify(quantify_model, "IFC4QtoBaseQuantities", selector="IfcWall")
|
||||
assert result["ok"] is True
|
||||
assert result["rule"] == "IFC4QtoBaseQuantities"
|
||||
|
||||
def test_empty_selector_runs_on_all(self, quantify_model):
|
||||
result = run_quantify(quantify_model, "IFC4QtoBaseQuantities", selector=None)
|
||||
assert result["ok"] is True
|
||||
@@ -0,0 +1,97 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.project
|
||||
import ifcopenshell.api.pset
|
||||
import ifcopenshell.api.root
|
||||
|
||||
from ifcedit.run import run_api, serialize_result
|
||||
|
||||
|
||||
class TestRunApi:
|
||||
def test_create_entity(self, model):
|
||||
result = run_api(model, "root", "create_entity", {"ifc_class": "IfcWall", "name": "NewWall"})
|
||||
assert result["ok"] is True
|
||||
assert result["result"]["type"] == "IfcWall"
|
||||
assert result["result"]["name"] == "NewWall"
|
||||
assert isinstance(result["result"]["id"], int)
|
||||
|
||||
def test_create_entity_default_class(self, model):
|
||||
result = run_api(model, "root", "create_entity", {})
|
||||
assert result["ok"] is True
|
||||
assert result["result"]["type"] == "IfcBuildingElementProxy"
|
||||
|
||||
def test_assign_container(self, model):
|
||||
wall = ifcopenshell.api.root.create_entity(model, ifc_class="IfcWall", name="TestWall2")
|
||||
storey = model.by_type("IfcBuildingStorey")[0]
|
||||
result = run_api(
|
||||
model,
|
||||
"spatial",
|
||||
"assign_container",
|
||||
{"products": str(wall.id()), "relating_structure": str(storey.id())},
|
||||
)
|
||||
assert result["ok"] is True
|
||||
assert result["result"]["type"] == "IfcRelContainedInSpatialStructure"
|
||||
|
||||
def test_add_pset(self, model):
|
||||
wall = model.by_type("IfcWall")[0]
|
||||
result = run_api(model, "pset", "add_pset", {"product": str(wall.id()), "name": "Pset_WallCommon"})
|
||||
assert result["ok"] is True
|
||||
assert result["result"]["type"] == "IfcPropertySet"
|
||||
|
||||
def test_unknown_function(self, model):
|
||||
result = run_api(model, "root", "nonexistent", {})
|
||||
assert result["ok"] is False
|
||||
assert "Cannot find" in result["error"]
|
||||
|
||||
def test_unknown_parameter(self, model):
|
||||
result = run_api(model, "root", "create_entity", {"bogus_param": "value"})
|
||||
assert result["ok"] is False
|
||||
assert "Unknown parameter" in result["error"]
|
||||
|
||||
def test_bad_entity_reference(self, model):
|
||||
result = run_api(model, "pset", "add_pset", {"product": "999999", "name": "Pset_WallCommon"})
|
||||
assert result["ok"] is False
|
||||
assert "not found" in result["error"]
|
||||
|
||||
|
||||
class TestAppendAsset:
|
||||
def test_append_asset_from_library(self, model, library_file):
|
||||
lib = ifcopenshell.open(library_file)
|
||||
wall_type = lib.by_type("IfcWallType")[0]
|
||||
result = run_api(
|
||||
model,
|
||||
"project",
|
||||
"append_asset",
|
||||
{"library": library_file, "element": str(wall_type.id())},
|
||||
)
|
||||
assert result["ok"] is True
|
||||
assert result["result"]["type"] == "IfcWallType"
|
||||
assert model.by_type("IfcWallType"), "wall type should have been appended to the model"
|
||||
|
||||
|
||||
class TestSerializeResult:
|
||||
def test_none(self):
|
||||
assert serialize_result(None) is None
|
||||
|
||||
def test_string(self):
|
||||
assert serialize_result("hello") == "hello"
|
||||
|
||||
def test_int(self):
|
||||
assert serialize_result(42) == 42
|
||||
|
||||
def test_entity(self, model):
|
||||
wall = model.by_type("IfcWall")[0]
|
||||
result = serialize_result(wall)
|
||||
assert result["id"] == wall.id()
|
||||
assert result["type"] == "IfcWall"
|
||||
assert result["name"] == "Wall001"
|
||||
|
||||
def test_list(self, model):
|
||||
walls = model.by_type("IfcWall")
|
||||
result = serialize_result(walls)
|
||||
assert isinstance(result, list)
|
||||
assert all(isinstance(r, dict) for r in result)
|
||||
|
||||
def test_dict(self):
|
||||
result = serialize_result({"key": "value"})
|
||||
assert result == {"key": "value"}
|
||||
@@ -52,6 +52,15 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcPointByDistanceExpression* i
|
||||
if (inst->OffsetVertical().has_value()) {
|
||||
auto offset_vertical = inst->OffsetVertical().get() * length_unit_;
|
||||
o += offset_vertical * z;
|
||||
|
||||
auto tmp1 = (z * offset_vertical).eval();
|
||||
auto tmp2 = (Eigen::Vector3d(0, 0, 1) * offset_vertical).eval();
|
||||
auto tmp3 = (tmp1 - tmp2).eval();
|
||||
|
||||
std::ostringstream oss;
|
||||
oss << "local z: " << z.x() << "," << z.y() << "," << z.z() << "; delta: " << tmp3.x() << "," << tmp3.y() << "," << tmp3.z();
|
||||
auto osss = oss.str();
|
||||
std::wcout << osss.c_str() << std::endl;
|
||||
}
|
||||
|
||||
if (inst->OffsetLongitudinal().has_value()) {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
PACKAGE_NAME:=ifcmcp
|
||||
include ../common.mk
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
pytest tests
|
||||
|
||||
.PHONY: qa
|
||||
qa:
|
||||
black .
|
||||
@@ -0,0 +1,400 @@
|
||||
<!-- This file was generated with the assistance of an AI coding tool. -->
|
||||
# ifcmcp
|
||||
|
||||
An MCP (Model Context Protocol) server that wraps `ifcquery` and `ifcedit`,
|
||||
holding the IFC model in memory across tool calls for fast interactive editing
|
||||
sessions.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install ifcmcp
|
||||
```
|
||||
|
||||
Requires `ifcopenshell`, `ifcquery`, and `ifcedit`. The `mcp` package is an optional dependency needed to run the server; install it with `pip install ifcmcp[mcp]` or add `mcp` separately.
|
||||
|
||||
## Running the server
|
||||
|
||||
```bash
|
||||
ifcmcp
|
||||
```
|
||||
|
||||
This starts the server on stdio transport, suitable for use with Claude Code
|
||||
or any MCP client.
|
||||
|
||||
### Claude Code configuration
|
||||
|
||||
Use the `claude mcp add` command:
|
||||
|
||||
```bash
|
||||
claude mcp add --transport stdio ifc -- ifcmcp
|
||||
```
|
||||
|
||||
Or create a `.mcp.json` file in your project root:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"ifc": {
|
||||
"type": "stdio",
|
||||
"command": "ifcmcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
After adding the server, restart Claude Code for the tools to become available.
|
||||
Then load a model by asking Claude to use `ifc_load`:
|
||||
|
||||
```
|
||||
load model.ifc using ifc_load
|
||||
```
|
||||
|
||||
## Tools
|
||||
|
||||
### Session
|
||||
|
||||
#### ifc_new
|
||||
|
||||
Create a new empty IFC model in memory, replacing any currently loaded model.
|
||||
|
||||
```
|
||||
ifc_new()
|
||||
ifc_new(schema="IFC4X3")
|
||||
```
|
||||
|
||||
Default schema is `IFC4`.
|
||||
|
||||
#### ifc_load
|
||||
|
||||
Open an IFC file into memory.
|
||||
|
||||
```
|
||||
ifc_load(path="/path/to/model.ifc")
|
||||
-> "Loaded /path/to/model.ifc: schema IFC4, 1847 entities"
|
||||
```
|
||||
|
||||
#### ifc_reset
|
||||
|
||||
Unload the current model from memory, freeing all session state.
|
||||
|
||||
```
|
||||
ifc_reset()
|
||||
```
|
||||
|
||||
#### ifc_save
|
||||
|
||||
Write the in-memory model to disk. Empty path overwrites the original file.
|
||||
|
||||
```
|
||||
ifc_save()
|
||||
ifc_save(path="/path/to/output.ifc")
|
||||
```
|
||||
|
||||
### Query tools
|
||||
|
||||
All query tools require a model to be loaded first via `ifc_load`.
|
||||
|
||||
#### ifc_summary
|
||||
|
||||
Model overview: schema, entity counts, project info.
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": "IFC4",
|
||||
"total_entities": 1847,
|
||||
"project": {"id": 1, "name": "Office Building"},
|
||||
"types": {"IfcWall": 42, "IfcSlab": 12, "IfcWindow": 36}
|
||||
}
|
||||
```
|
||||
|
||||
#### ifc_tree
|
||||
|
||||
Full spatial hierarchy from IfcProject down through sites, buildings, storeys,
|
||||
and contained elements.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"type": "IfcProject",
|
||||
"name": "Office Building",
|
||||
"children": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "IfcSite",
|
||||
"children": [{"id": 3, "type": "IfcBuilding", "children": ["..."]}]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### ifc_info
|
||||
|
||||
Deep inspection of an entity by step ID: attributes, property sets, type,
|
||||
material, container, and 4x4 placement matrix.
|
||||
|
||||
```
|
||||
ifc_info(element_id=10)
|
||||
```
|
||||
|
||||
#### ifc_select
|
||||
|
||||
Filter elements using ifcopenshell selector syntax.
|
||||
|
||||
```
|
||||
ifc_select(query="IfcWall")
|
||||
ifc_select(query="IfcWindow")
|
||||
```
|
||||
|
||||
Returns a sorted list of `{"id", "type", "name"}` references.
|
||||
|
||||
#### ifc_relations
|
||||
|
||||
Show all relationships for an element: hierarchy, children, type, groups,
|
||||
systems, material, connections.
|
||||
|
||||
```
|
||||
ifc_relations(element_id=10)
|
||||
ifc_relations(element_id=10, traverse="up")
|
||||
```
|
||||
|
||||
With `traverse="up"`, walks the hierarchy from element up to IfcProject.
|
||||
|
||||
#### ifc_contexts
|
||||
|
||||
List all geometric representation contexts and subcontexts in the loaded model.
|
||||
|
||||
```
|
||||
ifc_contexts()
|
||||
```
|
||||
|
||||
#### ifc_materials
|
||||
|
||||
List all materials and material sets in the loaded model, with their assigned elements.
|
||||
|
||||
```
|
||||
ifc_materials()
|
||||
```
|
||||
|
||||
#### ifc_clash
|
||||
|
||||
Check an element for geometric intersections and clearance violations.
|
||||
|
||||
```
|
||||
ifc_clash(element_id=10)
|
||||
ifc_clash(element_id=10, clearance=0.5, scope="all")
|
||||
```
|
||||
|
||||
Parameters:
|
||||
|
||||
- `clearance` -- minimum clearance distance in meters (0.0 = no clearance check)
|
||||
- `tolerance` -- intersection tolerance in meters (default: 0.002)
|
||||
- `scope` -- `"storey"` or `"all"` (default: `"storey"`)
|
||||
|
||||
#### ifc_validate
|
||||
|
||||
Check the model for schema and constraint violations.
|
||||
|
||||
```
|
||||
ifc_validate()
|
||||
ifc_validate(express_rules=True)
|
||||
```
|
||||
|
||||
Returns `{"valid": true, "issues": []}` or `{"valid": false, "issues": [{"level": "ERROR", "message": "..."}]}`.
|
||||
|
||||
#### ifc_schedule
|
||||
|
||||
List all work schedules and their nested task trees.
|
||||
|
||||
```
|
||||
ifc_schedule()
|
||||
ifc_schedule(max_depth=1) # top-level phases only
|
||||
```
|
||||
|
||||
`max_depth` limits subtask expansion. At the cutoff, `subtasks` is replaced
|
||||
with `{"truncated": true, "count": N}` so you know children exist without
|
||||
fetching them all. Omit for unlimited depth.
|
||||
|
||||
#### ifc_cost
|
||||
|
||||
List all cost schedules and their nested cost item trees.
|
||||
|
||||
```
|
||||
ifc_cost()
|
||||
ifc_cost(max_depth=2) # top two levels of the BoQ
|
||||
```
|
||||
|
||||
`max_depth` limits cost item expansion, same truncation convention as
|
||||
`ifc_schedule`.
|
||||
|
||||
#### ifc_schema
|
||||
|
||||
Return IFC class documentation for any entity type, using the loaded model's
|
||||
schema version.
|
||||
|
||||
```
|
||||
ifc_schema(entity_type="IfcWall")
|
||||
ifc_schema(entity_type="IfcBuildingStorey")
|
||||
```
|
||||
|
||||
Returns description, predefined types, spec URL, and attribute descriptions.
|
||||
Returns `{"error": "Unknown entity: Foo"}` for unrecognised types.
|
||||
|
||||
#### ifc_quantify
|
||||
|
||||
Run quantity take-off (QTO) on the loaded model using an `ifc5d` rule.
|
||||
Computes physical measurements (volume, area, length, count, weight) and
|
||||
writes them back as `IfcElementQuantity` property sets. Modifies the model
|
||||
in-place -- call `ifc_save()` when done.
|
||||
|
||||
```
|
||||
ifc_quantify(rule="IFC4QtoBaseQuantities")
|
||||
ifc_quantify(rule="IFC4QtoBaseQuantities", selector="IfcWall")
|
||||
```
|
||||
|
||||
Available rules: `IFC4QtoBaseQuantities`, `IFC4X3QtoBaseQuantities`.
|
||||
|
||||
`selector` is an optional ifcopenshell selector to restrict which elements
|
||||
are quantified (default: all `IfcElement`).
|
||||
|
||||
Returns `{"ok": true, "rule": "...", "elements_quantified": 42}`.
|
||||
|
||||
### Drawing and rendering tools
|
||||
|
||||
#### ifc_plot
|
||||
|
||||
Generate a 2D technical drawing of the loaded model and return it as an inline image.
|
||||
|
||||
```
|
||||
ifc_plot()
|
||||
ifc_plot(selector="IfcWall", view="floorplan", scale=0.01, output_path="/tmp/plan.svg")
|
||||
ifc_plot(element_ids=[10, 11], view="floorplan")
|
||||
```
|
||||
|
||||
Parameters:
|
||||
|
||||
- `selector` -- ifcopenshell selector to restrict plotted elements
|
||||
- `element_ids` -- step IDs of elements to highlight; others are faded
|
||||
- `view` -- `"floorplan"` (default), `"elevation"`, `"section"`, or `"auto"`
|
||||
- `width_mm`, `height_mm` -- paper size in mm (default: 297 x 420)
|
||||
- `scale` -- model-to-paper ratio (default: 0.01 = 1:100)
|
||||
- `png_width`, `png_height` -- raster output size in pixels (default: 1024 x 1024)
|
||||
- `output_path` -- optional path to also save to disk (`.svg` for vector, otherwise PNG)
|
||||
|
||||
Returns an inline PNG the LLM can inspect. Requires `ifcopenshell.draw`.
|
||||
|
||||
#### ifc_render
|
||||
|
||||
Render the loaded model to a 3D PNG image.
|
||||
|
||||
```
|
||||
ifc_render()
|
||||
ifc_render(selector="IfcWall", view="iso", output_path="/tmp/model.png")
|
||||
ifc_render(element_ids=[10, 11], view="south")
|
||||
```
|
||||
|
||||
Parameters:
|
||||
|
||||
- `selector` -- ifcopenshell selector to restrict rendered elements
|
||||
- `element_ids` -- step IDs of elements to highlight; others are shown translucent
|
||||
- `view` -- `"iso"` (default), `"top"`, `"south"`, `"north"`, `"east"`, or `"west"`
|
||||
- `output_path` -- optional path to save the PNG to disk
|
||||
|
||||
Returns an inline PNG. Requires `pyvista` and the IfcOpenShell C++ geometry bindings.
|
||||
|
||||
### Shape builder tools
|
||||
|
||||
#### ifc_shape_list
|
||||
|
||||
List all available `ShapeBuilder` methods with brief descriptions.
|
||||
|
||||
```
|
||||
ifc_shape_list()
|
||||
```
|
||||
|
||||
#### ifc_shape_docs
|
||||
|
||||
Show full documentation for a specific `ShapeBuilder` method.
|
||||
|
||||
```
|
||||
ifc_shape_docs(method="extrude")
|
||||
ifc_shape_docs(method="create_ellipse")
|
||||
```
|
||||
|
||||
#### ifc_shape
|
||||
|
||||
Execute a `ShapeBuilder` method on the loaded model.
|
||||
|
||||
```
|
||||
ifc_shape(method="extrude", params='{"profile": "42", "magnitude": 3.0}')
|
||||
```
|
||||
|
||||
`params` is a JSON string; entity references are resolved by step ID (same coercion as `ifc_edit`).
|
||||
|
||||
### Edit discovery tools
|
||||
|
||||
#### ifc_list
|
||||
|
||||
List all API modules, or functions within a specific module.
|
||||
|
||||
```
|
||||
ifc_list() # all modules
|
||||
ifc_list(module="root") # functions in the root module
|
||||
```
|
||||
|
||||
#### ifc_docs
|
||||
|
||||
Show full documentation for an API function including parameters, types,
|
||||
defaults, and descriptions.
|
||||
|
||||
```
|
||||
ifc_docs(function_path="root.create_entity")
|
||||
```
|
||||
|
||||
### Edit execution
|
||||
|
||||
#### ifc_edit
|
||||
|
||||
Execute an `ifcopenshell.api` mutation function. Parameters are passed as a
|
||||
JSON string with string values that get coerced by ifcedit's type system.
|
||||
|
||||
```
|
||||
ifc_edit(
|
||||
function_path="root.create_entity",
|
||||
params='{"ifc_class": "IfcWall", "name": "My Wall"}'
|
||||
)
|
||||
```
|
||||
|
||||
Returns `{"ok": true, "result": ...}` or `{"ok": false, "error": "..."}`.
|
||||
|
||||
Does NOT auto-save -- call `ifc_save()` when ready to write changes to disk.
|
||||
|
||||
**Parameter coercion:**
|
||||
|
||||
| Type | JSON value | Python value |
|
||||
|------|------------|--------------|
|
||||
| `entity_instance` | `"42"` | resolved from model by step ID |
|
||||
| `list[entity_instance]` | `"5,6,7"` | list of resolved entities |
|
||||
| `dict` | `'{"key": "val"}'` | parsed JSON object |
|
||||
| `bool` | `"true"` | `True` |
|
||||
| `Optional[X]` | `"none"` | `None` |
|
||||
|
||||
## Typical workflow
|
||||
|
||||
1. **Load** a model: `ifc_load`
|
||||
2. **Inspect** with query tools: `ifc_summary`, `ifc_tree`, `ifc_select`, `ifc_info`, `ifc_relations`
|
||||
3. **Validate** if needed: `ifc_validate`
|
||||
4. **Browse schedules / costs**: `ifc_schedule`, `ifc_cost` (use `max_depth=1` first on large projects)
|
||||
5. **Look up IFC classes**: `ifc_schema`
|
||||
6. **Find** the right API function: `ifc_list`, `ifc_docs`
|
||||
7. **Edit** the model: `ifc_edit`
|
||||
8. **Quantify** elements: `ifc_quantify` (writes QTO psets in-place)
|
||||
9. **Verify** changes with query tools
|
||||
10. **Save** when satisfied: `ifc_save`
|
||||
|
||||
The model stays in memory across all calls, so multi-step editing sessions
|
||||
are fast -- no file I/O between operations.
|
||||
|
||||
## License
|
||||
|
||||
LGPLv3+ -- see the IfcOpenShell project license.
|
||||
@@ -0,0 +1,20 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
# IfcMCP - MCP server for IFC building models
|
||||
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
|
||||
#
|
||||
# This file is part of IfcMCP.
|
||||
#
|
||||
# IfcMCP is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# IfcMCP is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcMCP. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
__version__ = version = "0.0.0"
|
||||
@@ -0,0 +1,48 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
import argparse
|
||||
|
||||
from ifcmcp import __version__
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="python3 -m ifcmcp",
|
||||
description=(
|
||||
"ifcmcp — MCP server for IFC building models.\n\n"
|
||||
"Runs a Model Context Protocol server over stdio so that MCP clients\n"
|
||||
"can query and edit IFC files without writing them to disk between\n"
|
||||
"operations.\n\n"
|
||||
"Add to .mcp.json to configure:\n"
|
||||
' {"mcpServers": {"ifc": {"type": "stdio", "command": "python3", "args": ["-m", "ifcmcp"]}}}'
|
||||
),
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument("--version", action="version", version=f"ifcmcp {__version__}")
|
||||
parser.add_argument(
|
||||
"--transport",
|
||||
choices=["stdio", "sse", "streamable-http"],
|
||||
default="stdio",
|
||||
help="MCP transport to use (default: stdio)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
from mcp.server.fastmcp import FastMCP # noqa: F401
|
||||
except ImportError:
|
||||
import sys
|
||||
|
||||
print(
|
||||
"error: the 'mcp' package is required to run the server.\n" "Install it with: pip install mcp",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
from ifcmcp.server import build_server
|
||||
|
||||
server = build_server()
|
||||
server.run(transport=args.transport)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,731 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
from __future__ import annotations
|
||||
|
||||
# inside ifcmcp/core.py
|
||||
import json
|
||||
from collections.abc import Callable # noqa: F401 — Callable used in helpers below
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import ifcopenshell
|
||||
from ifcedit.discover import function_docs, list_functions, list_modules
|
||||
from ifcedit.quantify import run_quantify
|
||||
from ifcedit.run import run_api
|
||||
from ifcquery import clash as clash_mod
|
||||
from ifcquery import contexts as contexts_mod
|
||||
from ifcquery import cost as cost_mod
|
||||
from ifcquery import (
|
||||
info,
|
||||
relations,
|
||||
schedule,
|
||||
schema,
|
||||
select,
|
||||
summary,
|
||||
tree,
|
||||
)
|
||||
from ifcquery import (
|
||||
materials as materials_mod,
|
||||
)
|
||||
from ifcquery import (
|
||||
plot as plot_mod,
|
||||
)
|
||||
from ifcquery import (
|
||||
render as render_mod,
|
||||
)
|
||||
from ifcquery import validate as validate_mod
|
||||
|
||||
|
||||
def _jsonify(x: Any) -> Any:
|
||||
"""Convert IfcOpenShell objects / iterables into JSON-safe primitives."""
|
||||
if x is None or isinstance(x, (str, int, float, bool)):
|
||||
return x
|
||||
|
||||
# numpy arrays (and any array-like with tolist)
|
||||
if hasattr(x, "tolist"):
|
||||
return x.tolist()
|
||||
|
||||
# IfcOpenShell entity instances: normalize
|
||||
if isinstance(x, ifcopenshell.entity_instance):
|
||||
return {
|
||||
"id": int(x.id()),
|
||||
"type": x.is_a(),
|
||||
"repr": str(x),
|
||||
"name": getattr(x, "Name", None),
|
||||
}
|
||||
|
||||
if isinstance(x, dict):
|
||||
return {str(k): _jsonify(v) for k, v in x.items()}
|
||||
|
||||
if isinstance(x, (list, tuple, set)):
|
||||
return [_jsonify(v) for v in x]
|
||||
|
||||
# Try JSON as-is, else fallback to string
|
||||
try:
|
||||
json.dumps(x)
|
||||
return x
|
||||
except Exception:
|
||||
return str(x)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shape builder helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _list_shape_methods() -> list[dict]:
|
||||
"""Introspect ShapeBuilder and return a summary of all public methods."""
|
||||
import inspect
|
||||
|
||||
from ifcedit.discover import _extract_params
|
||||
from ifcopenshell.util.shape_builder import ShapeBuilder
|
||||
|
||||
results = []
|
||||
for name, fn in inspect.getmembers(ShapeBuilder, predicate=inspect.isfunction):
|
||||
if name.startswith("_"):
|
||||
continue
|
||||
doc = fn.__doc__ or ""
|
||||
description = doc.strip().split("\n")[0] if doc.strip() else ""
|
||||
results.append({"method": name, "description": description, "params": _extract_params(fn)})
|
||||
return results
|
||||
|
||||
|
||||
def _shape_method_docs(method_name: str) -> dict:
|
||||
"""Return full documentation for a single ShapeBuilder method."""
|
||||
import typing
|
||||
|
||||
from ifcedit.discover import (
|
||||
_extract_params,
|
||||
_format_type_hint,
|
||||
_parse_docstring_body,
|
||||
_parse_param_docs,
|
||||
_parse_return_doc,
|
||||
)
|
||||
from ifcopenshell.util.shape_builder import ShapeBuilder
|
||||
|
||||
if method_name.startswith("_"):
|
||||
raise ValueError(f"ShapeBuilder has no method '{method_name}'")
|
||||
fn = getattr(ShapeBuilder, method_name, None)
|
||||
if fn is None:
|
||||
raise ValueError(f"ShapeBuilder has no method '{method_name}'")
|
||||
|
||||
doc = fn.__doc__ or ""
|
||||
description, long_description = _parse_docstring_body(doc)
|
||||
params = _extract_params(fn)
|
||||
for param in params:
|
||||
param_desc = _parse_param_docs(doc)
|
||||
if param["name"] in param_desc:
|
||||
param["description"] = param_desc[param["name"]]
|
||||
|
||||
try:
|
||||
hints = typing.get_type_hints(fn)
|
||||
except Exception:
|
||||
hints = {}
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"method": method_name,
|
||||
"description": description,
|
||||
"long_description": long_description,
|
||||
"params": params,
|
||||
}
|
||||
return_type = _format_type_hint(hints.get("return"))
|
||||
if return_type:
|
||||
result["return_type"] = return_type
|
||||
return_description = _parse_return_doc(doc)
|
||||
if return_description:
|
||||
result["return_description"] = return_description
|
||||
return result
|
||||
|
||||
|
||||
def _coerce_shape_params(fn: Callable, raw_kwargs: dict, model: ifcopenshell.file) -> dict:
|
||||
"""Coerce JSON-parsed kwargs to proper Python types for a ShapeBuilder method."""
|
||||
import inspect
|
||||
import typing
|
||||
|
||||
sig = inspect.signature(fn)
|
||||
try:
|
||||
hints = typing.get_type_hints(fn)
|
||||
except Exception:
|
||||
hints = {}
|
||||
|
||||
return {
|
||||
key: _coerce_shape_value(value, hints.get(key), model)
|
||||
for key, value in raw_kwargs.items()
|
||||
if key in sig.parameters and key != "self"
|
||||
}
|
||||
|
||||
|
||||
def _coerce_shape_value(value: Any, hint: Any, model: ifcopenshell.file) -> Any:
|
||||
"""Convert a single JSON-parsed value to the correct Python type."""
|
||||
import typing
|
||||
|
||||
if hint is None or value is None:
|
||||
return value
|
||||
|
||||
origin = typing.get_origin(hint)
|
||||
args = typing.get_args(hint)
|
||||
|
||||
# Optional[X] / Union — try each non-None branch in order
|
||||
if origin is typing.Union:
|
||||
if value is None:
|
||||
return None
|
||||
for t in (a for a in args if a is not type(None)):
|
||||
try:
|
||||
return _coerce_shape_value(value, t, model)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
return value
|
||||
|
||||
# entity_instance: resolve integer or "#N" string step ID
|
||||
if hint is ifcopenshell.entity_instance or (
|
||||
isinstance(hint, type) and issubclass(hint, ifcopenshell.entity_instance)
|
||||
):
|
||||
entity_id = int(str(value).lstrip("#"))
|
||||
entity = model.by_id(entity_id)
|
||||
if entity is None:
|
||||
raise ValueError(f"Entity #{entity_id} not found in model")
|
||||
return entity
|
||||
|
||||
# Sequence[entity_instance]: resolve each element in the list
|
||||
import collections.abc
|
||||
|
||||
if origin is not None and issubclass(origin, collections.abc.Sequence) and not isinstance(value, str):
|
||||
if args and (
|
||||
args[0] is ifcopenshell.entity_instance
|
||||
or (isinstance(args[0], type) and issubclass(args[0], ifcopenshell.entity_instance))
|
||||
):
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_coerce_shape_value(v, args[0], model) for v in value]
|
||||
|
||||
# bool: JSON gives actual bools; also accept string representations
|
||||
if hint is bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return str(value).lower() in ("true", "1", "yes")
|
||||
|
||||
# Everything else (float, int, VectorType lists, dicts, Literals) passes through
|
||||
return value
|
||||
|
||||
|
||||
class IfcSessionError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class IfcSession:
|
||||
"""In-memory IFC session (no FastMCP dependency).
|
||||
|
||||
Designed to work in:
|
||||
- FastMCP server (single global session)
|
||||
- Embedded runtimes like Pyodide (one session per browser tab/worker)
|
||||
"""
|
||||
|
||||
model: ifcopenshell.file | None = None
|
||||
model_path: str | None = None
|
||||
|
||||
# -----------------
|
||||
# Session lifecycle
|
||||
# -----------------
|
||||
def _require_model(self) -> ifcopenshell.file:
|
||||
if self.model is None:
|
||||
raise IfcSessionError("No model loaded. Call ifc_load() or ifc_new() first.")
|
||||
return self.model
|
||||
|
||||
def ifc_new(self, schema: str = "IFC4") -> dict[str, Any]:
|
||||
"""Create a new empty IFC model in memory."""
|
||||
self.model = ifcopenshell.file(schema=schema)
|
||||
self.model_path = None
|
||||
return {"ok": True, "schema": self.model.schema, "entities": sum(1 for _ in self.model)}
|
||||
|
||||
def ifc_load(self, path: str) -> str:
|
||||
"""Open an IFC file into memory. Returns confirmation string."""
|
||||
self.model = ifcopenshell.open(path)
|
||||
self.model_path = path
|
||||
count = sum(1 for _ in self.model)
|
||||
return f"Loaded {path}: schema {self.model.schema}, {count} entities"
|
||||
|
||||
def ifc_save(self, path: str = "") -> str:
|
||||
"""Write the in-memory model to disk. Empty path overwrites the original file."""
|
||||
model = self._require_model()
|
||||
target = path if path else self.model_path
|
||||
if not target:
|
||||
raise IfcSessionError("No path specified and no original path available.")
|
||||
model.write(target)
|
||||
return f"Saved to {target}"
|
||||
|
||||
def ifc_reset(self) -> dict[str, Any]:
|
||||
"""Drop the in-memory model."""
|
||||
self.model = None
|
||||
self.model_path = None
|
||||
return {"ok": True}
|
||||
|
||||
# -------------
|
||||
# Query tools
|
||||
# -------------
|
||||
def ifc_summary(self) -> dict[str, Any]:
|
||||
"""Model overview: schema, entity counts, project info."""
|
||||
return summary.summary(self._require_model())
|
||||
|
||||
def ifc_tree(self) -> dict[str, Any] | list[dict[str, Any]]:
|
||||
"""Full spatial hierarchy tree (Project -> Site -> Building -> Storeys -> Elements)."""
|
||||
return tree.tree(self._require_model())
|
||||
|
||||
def ifc_info(self, element_id: int) -> dict[str, Any]:
|
||||
"""Deep inspection of an entity by step ID (attributes, psets, placement, type, material)."""
|
||||
model = self._require_model()
|
||||
element = model.by_id(element_id)
|
||||
if element is None:
|
||||
raise IfcSessionError(f"Element #{element_id} not found.")
|
||||
return info.info(model, element)
|
||||
|
||||
def ifc_select(self, query: str) -> list[dict[str, Any]]:
|
||||
"""Filter elements using ifcopenshell selector syntax (e.g. 'IfcWall', 'IfcWindow')."""
|
||||
return select.select(self._require_model(), query)
|
||||
|
||||
def ifc_relations(self, element_id: int, traverse: str = "") -> dict[str, Any] | list[dict[str, Any]]:
|
||||
"""Show relationships for an element. Set traverse='up' to walk hierarchy to IfcProject."""
|
||||
model = self._require_model()
|
||||
element = model.by_id(element_id)
|
||||
if element is None:
|
||||
raise IfcSessionError(f"Element #{element_id} not found.")
|
||||
return relations.relations(model, element, traverse=traverse if traverse else None)
|
||||
|
||||
def ifc_clash(
|
||||
self,
|
||||
element_id: int,
|
||||
clearance: float = 0.0,
|
||||
tolerance: float = 0.002,
|
||||
scope: str = "storey",
|
||||
) -> dict[str, Any]:
|
||||
"""Check element for geometric clashes. clearance=0.0 means no clearance check."""
|
||||
model = self._require_model()
|
||||
element = model.by_id(element_id)
|
||||
if element is None:
|
||||
raise IfcSessionError(f"Element #{element_id} not found.")
|
||||
return clash_mod.clash(
|
||||
model,
|
||||
element,
|
||||
clearance=clearance if clearance and clearance > 0.0 else None,
|
||||
tolerance=tolerance,
|
||||
scope=scope,
|
||||
)
|
||||
|
||||
def ifc_contexts(self) -> list[dict[str, Any]]:
|
||||
"""List all geometric representation contexts and subcontexts with their step IDs."""
|
||||
return contexts_mod.contexts(self._require_model())
|
||||
|
||||
def ifc_materials(self) -> list[dict[str, Any]]:
|
||||
"""List all materials and material sets (layers, constituents, profiles)."""
|
||||
return materials_mod.materials(self._require_model())
|
||||
|
||||
# ------------------------
|
||||
# Edit discovery + execute
|
||||
# ------------------------
|
||||
def ifc_list(self, module: str = "") -> list[dict]:
|
||||
"""List all API modules, or functions within a module. Empty module = all modules."""
|
||||
return list_functions(module) if module else list_modules()
|
||||
|
||||
def ifc_docs(self, function_path: str) -> dict:
|
||||
"""Show full documentation for an API function. Input format: 'module.function'."""
|
||||
module, function = function_path.split(".", 1)
|
||||
return function_docs(module, function)
|
||||
|
||||
def ifc_edit(self, function_path: str, params: Any = "{}") -> dict:
|
||||
"""Execute an ifcopenshell.api mutation.
|
||||
|
||||
params may be:
|
||||
- JSON string
|
||||
- dict (from tool calling / JS)
|
||||
- JsProxy (handled upstream in embedded.py)
|
||||
"""
|
||||
model = self._require_model()
|
||||
module, function = function_path.split(".", 1)
|
||||
|
||||
if isinstance(params, str):
|
||||
raw_kwargs = json.loads(params) if params.strip() else {}
|
||||
elif isinstance(params, dict):
|
||||
raw_kwargs = params
|
||||
else:
|
||||
# e.g. list/None/etc
|
||||
raw_kwargs = dict(params) if params is not None else {}
|
||||
|
||||
res = run_api(model, module, function, raw_kwargs)
|
||||
return _jsonify(res)
|
||||
|
||||
# ------------------------
|
||||
# Extended query + edit tools
|
||||
# ------------------------
|
||||
def ifc_validate(self, express_rules: bool = False) -> dict[str, Any]:
|
||||
"""Validate the loaded model. Returns {'valid': bool, 'issues': [...]}."""
|
||||
return validate_mod.validate(self._require_model(), express_rules=express_rules)
|
||||
|
||||
def ifc_schedule(self, max_depth: int | None = None) -> list[dict[str, Any]]:
|
||||
"""List work schedules and nested tasks from the model.
|
||||
|
||||
max_depth limits subtask expansion (None = unlimited). At the cutoff,
|
||||
subtasks is replaced with {"truncated": True, "count": N}.
|
||||
"""
|
||||
return schedule.schedule(self._require_model(), max_depth=max_depth)
|
||||
|
||||
def ifc_cost(self, max_depth: int | None = None) -> list[dict[str, Any]]:
|
||||
"""List cost schedules and nested cost items from the model.
|
||||
|
||||
max_depth limits cost item expansion (None = unlimited). At the cutoff,
|
||||
subitems is replaced with {"truncated": True, "count": N}.
|
||||
"""
|
||||
return cost_mod.cost(self._require_model(), max_depth=max_depth)
|
||||
|
||||
def ifc_schema(self, entity_type: str) -> dict[str, Any]:
|
||||
"""Return IFC class documentation for entity_type using the model's schema version."""
|
||||
return schema.schema(self._require_model(), entity_type)
|
||||
|
||||
def ifc_plot(
|
||||
self,
|
||||
selector: str = "",
|
||||
element_ids: list[int] | None = None,
|
||||
view: str = "floorplan",
|
||||
width_mm: float = 297.0,
|
||||
height_mm: float = 420.0,
|
||||
scale: float = 1.0 / 100.0,
|
||||
png_width: int = 1024,
|
||||
png_height: int = 1024,
|
||||
output_format: str = "png",
|
||||
) -> bytes:
|
||||
"""Generate a 2D technical drawing (floor plan, elevation, or section) and return image bytes.
|
||||
|
||||
Uses ifcopenshell.draw to produce SVG output which is rasterised to PNG via CairoSVG
|
||||
when output_format is 'png'.
|
||||
|
||||
:param selector: ifcopenshell selector to restrict plotted elements
|
||||
(e.g. ``'IfcWall'``). Omit to plot the whole model.
|
||||
:param element_ids: Step IDs of elements to highlight. Other elements
|
||||
are faded to 10% opacity so the subject stands out.
|
||||
:param view: Drawing view — ``floorplan`` (default), ``elevation``,
|
||||
``section``, or ``auto``.
|
||||
:param width_mm: Paper width in mm (default 297 = A4).
|
||||
:param height_mm: Paper height in mm (default 420 = A4).
|
||||
:param scale: Model-to-paper scale ratio (default 0.01 = 1:100).
|
||||
:param png_width: Raster output width in pixels (default 1024).
|
||||
:param png_height: Raster output height in pixels (default 1024).
|
||||
:param output_format: ``'svg'`` or ``'png'`` (default ``'png'``).
|
||||
:return: SVG or PNG bytes depending on output_format.
|
||||
"""
|
||||
model = self._require_model()
|
||||
return plot_mod.plot(
|
||||
model,
|
||||
output_format=output_format,
|
||||
selector=selector if selector else None,
|
||||
element_ids=element_ids,
|
||||
view=view,
|
||||
width_mm=width_mm,
|
||||
height_mm=height_mm,
|
||||
scale=scale,
|
||||
png_width=png_width,
|
||||
png_height=png_height,
|
||||
)
|
||||
|
||||
def ifc_render(
|
||||
self,
|
||||
selector: str = "",
|
||||
element_ids: list[int] | None = None,
|
||||
view: str = "iso",
|
||||
) -> bytes:
|
||||
"""Render the loaded model to a PNG image and return raw bytes.
|
||||
|
||||
:param selector: ifcopenshell selector to restrict rendered elements
|
||||
(e.g. ``'IfcWall'``). Omit to render the whole model.
|
||||
:param element_ids: Step IDs of elements to highlight. Other elements
|
||||
are rendered in translucent grey.
|
||||
:param view: Camera angle: ``iso``, ``top``, ``south``, ``north``,
|
||||
``east``, or ``west``.
|
||||
:return: PNG image as raw bytes.
|
||||
"""
|
||||
model = self._require_model()
|
||||
return render_mod.render(
|
||||
model,
|
||||
selector=selector if selector else None,
|
||||
element_ids=element_ids,
|
||||
view=view,
|
||||
)
|
||||
|
||||
# ------------------------
|
||||
# Shape builder tools
|
||||
# ------------------------
|
||||
def ifc_shape_list(self) -> list[dict]:
|
||||
"""List all ShapeBuilder geometry methods with one-line descriptions and parameter names."""
|
||||
return _list_shape_methods()
|
||||
|
||||
def ifc_shape_docs(self, method: str) -> dict:
|
||||
"""Full documentation for a ShapeBuilder method: params, types, return value."""
|
||||
return _shape_method_docs(method)
|
||||
|
||||
def ifc_shape(self, method: str, params: Any = "{}") -> dict:
|
||||
"""Call a ShapeBuilder method by name. Returns the created entity's step ID.
|
||||
|
||||
params is a JSON string of keyword arguments. Pass entity references as integer
|
||||
step IDs; vectors as JSON arrays (e.g. [1.0, 0.0, 0.0]).
|
||||
"""
|
||||
model = self._require_model()
|
||||
|
||||
from ifcopenshell.util.shape_builder import ShapeBuilder
|
||||
|
||||
if method.startswith("_"):
|
||||
raise IfcSessionError(f"Private method '{method}' is not accessible")
|
||||
fn = getattr(ShapeBuilder, method, None)
|
||||
if fn is None:
|
||||
return {"ok": False, "error": f"ShapeBuilder has no method '{method}'"}
|
||||
|
||||
if isinstance(params, str):
|
||||
raw_kwargs = json.loads(params) if params.strip() else {}
|
||||
elif isinstance(params, dict):
|
||||
raw_kwargs = params
|
||||
else:
|
||||
raw_kwargs = {}
|
||||
|
||||
try:
|
||||
coerced = _coerce_shape_params(fn, raw_kwargs, model)
|
||||
result = fn(ShapeBuilder(model), **coerced)
|
||||
return {"ok": True, "result": _jsonify(result)}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
|
||||
|
||||
def ifc_quantify(self, rule: str, selector: str = "") -> dict[str, Any]:
|
||||
"""Run quantity take-off on the model using the named rule.
|
||||
|
||||
Modifies the model in-place; call ifc_save() after.
|
||||
"""
|
||||
model = self._require_model()
|
||||
return run_quantify(model, rule, selector=selector if selector else None)
|
||||
|
||||
# ------------------------
|
||||
# Generic dispatcher + tool specs for LLMs
|
||||
# ------------------------
|
||||
def dispatch(self, name: str, args: dict[str, Any] | None = None) -> Any:
|
||||
args = args or {}
|
||||
fn = getattr(self, name, None)
|
||||
if not callable(fn):
|
||||
raise IfcSessionError(f"Unknown tool: {name}")
|
||||
return _jsonify(fn(**args))
|
||||
|
||||
def openai_tools(self) -> list[dict[str, Any]]:
|
||||
"""Tool schemas in the OpenAI 'Responses API' format (type=function)."""
|
||||
# Keep schemas tight so the model calls tools correctly.
|
||||
return [
|
||||
{
|
||||
"type": "function",
|
||||
"name": "ifc_new",
|
||||
"description": "Create a new empty IFC model in memory.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"schema": {"type": "string", "description": "IFC schema, e.g. IFC4"}},
|
||||
"required": [],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "ifc_summary",
|
||||
"description": "Get a concise overview of the loaded IFC model.",
|
||||
"parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": False},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "ifc_tree",
|
||||
"description": "Get the full spatial hierarchy tree.",
|
||||
"parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": False},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "ifc_select",
|
||||
"description": "Select elements using ifcopenshell selector syntax (e.g. 'IfcWall').",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"query": {"type": "string"}},
|
||||
"required": ["query"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "ifc_info",
|
||||
"description": "Inspect an entity by STEP id.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"element_id": {"type": "integer"}},
|
||||
"required": ["element_id"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "ifc_relations",
|
||||
"description": "Get relationships for an element. traverse='up' walks to IfcProject.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"element_id": {"type": "integer"}, "traverse": {"type": "string"}},
|
||||
"required": ["element_id"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "ifc_clash",
|
||||
"description": "Run clash/clearance checks for an element.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"element_id": {"type": "integer"},
|
||||
"clearance": {"type": "number"},
|
||||
"tolerance": {"type": "number"},
|
||||
"scope": {"type": "string", "description": "storey or all"},
|
||||
},
|
||||
"required": ["element_id"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "ifc_contexts",
|
||||
"description": "List all geometric representation contexts and subcontexts with their step IDs, context type, identifier, and target view. Use this to find the context ID required for geometry-creation API calls.",
|
||||
"parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": False},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "ifc_materials",
|
||||
"description": "List all materials and material sets (IfcMaterial, IfcMaterialLayerSet, IfcMaterialConstituentSet, IfcMaterialProfileSet) with their layers, constituents, or profiles.",
|
||||
"parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": False},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "ifc_list",
|
||||
"description": "List ifcopenshell.api modules or functions within a module.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"module": {"type": "string"}},
|
||||
"required": [],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "ifc_docs",
|
||||
"description": "Get documentation for an ifcopenshell.api function, 'module.function'.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"function_path": {"type": "string"}},
|
||||
"required": ["function_path"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "ifc_edit",
|
||||
"description": "Execute an ifcopenshell.api mutation; params is a JSON string of stringly-typed kwargs.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"function_path": {"type": "string"}, "params": {"type": "string"}},
|
||||
"required": ["function_path"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "ifc_validate",
|
||||
"description": "Validate the loaded model. Returns valid bool and list of issues.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"express_rules": {"type": "boolean", "description": "Also check EXPRESS rules (slower)"}
|
||||
},
|
||||
"required": [],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "ifc_schedule",
|
||||
"description": "List work schedules and nested tasks. Use max_depth=1 for top-level phases only on large projects.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"max_depth": {
|
||||
"type": "integer",
|
||||
"description": "Max levels of subtask expansion (omit for unlimited)",
|
||||
}
|
||||
},
|
||||
"required": [],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "ifc_cost",
|
||||
"description": "List cost schedules and nested cost items. Use max_depth=1 for top-level sections only on large BoQs.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"max_depth": {
|
||||
"type": "integer",
|
||||
"description": "Max levels of cost item expansion (omit for unlimited)",
|
||||
}
|
||||
},
|
||||
"required": [],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "ifc_schema",
|
||||
"description": "Return IFC class documentation for an entity type.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"entity_type": {"type": "string", "description": "IFC entity type, e.g. IfcWall"}},
|
||||
"required": ["entity_type"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "ifc_quantify",
|
||||
"description": "Run quantity take-off (QTO) on the model. Modifies model in-place; call ifc_save() after.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"rule": {"type": "string", "description": "QTO rule name, e.g. IFC4QtoBaseQuantities"},
|
||||
"selector": {
|
||||
"type": "string",
|
||||
"description": "ifcopenshell selector to restrict elements (default: all IfcElement)",
|
||||
},
|
||||
},
|
||||
"required": ["rule"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "ifc_render",
|
||||
"description": (
|
||||
"Render the loaded IFC model to a PNG image for visual inspection. "
|
||||
"Use selector to restrict which elements are rendered (e.g. a single storey). "
|
||||
"Use element_ids to highlight elements against a greyed-out background. "
|
||||
"Returns base64-encoded PNG bytes."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"selector": {"type": "string", "description": "ifcopenshell selector (default: whole model)"},
|
||||
"element_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "integer"},
|
||||
"description": "Step IDs of elements to highlight",
|
||||
},
|
||||
"view": {
|
||||
"type": "string",
|
||||
"enum": ["iso", "top", "south", "north", "east", "west"],
|
||||
"description": "Camera angle (default: iso)",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,60 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from ifcmcp.core import IfcSession
|
||||
|
||||
session = IfcSession()
|
||||
|
||||
# Optional imports only available under Pyodide
|
||||
try:
|
||||
from pyodide.ffi import JsProxy, to_py # type: ignore
|
||||
except Exception: # pragma: no cover
|
||||
JsProxy = None # type: ignore
|
||||
to_py = None # type: ignore
|
||||
|
||||
|
||||
def _coerce_args(args: Any) -> dict[str, Any]:
|
||||
"""Convert JS objects / JsProxy / mappings into a real Python dict."""
|
||||
if args is None:
|
||||
return {}
|
||||
|
||||
# Pyodide: JS object arrives as JsProxy; convert recursively to Python.
|
||||
if JsProxy is not None and isinstance(args, JsProxy):
|
||||
# dict_converter=dict ensures JS object -> Python dict (not Map)
|
||||
return to_py(args, dict_converter=dict)
|
||||
|
||||
# Already a Python dict
|
||||
if isinstance(args, dict):
|
||||
return args
|
||||
|
||||
# Any Mapping-like object
|
||||
if isinstance(args, Mapping):
|
||||
return dict(args)
|
||||
|
||||
# Last resort: try dict() coercion
|
||||
try:
|
||||
return dict(args)
|
||||
except Exception as e:
|
||||
raise TypeError(f"Tool args must be a mapping/dict; got {type(args)}") from e
|
||||
|
||||
|
||||
def tools_openai() -> list[dict[str, Any]]:
|
||||
return session.openai_tools()
|
||||
|
||||
|
||||
def call_tool(name: str, args: Any = None) -> dict[str, Any]:
|
||||
"""
|
||||
Non-throwing tool dispatcher.
|
||||
Always returns: {"ok": bool, "data": ...} or {"ok": false, "error": "...", "error_type": "...", ...}
|
||||
"""
|
||||
try:
|
||||
py_args = _coerce_args(args)
|
||||
data = session.dispatch(name, py_args)
|
||||
return {"ok": True, "data": data}
|
||||
|
||||
except Exception as e:
|
||||
# Keep it short; avoid full tracebacks in tool output unless debugging.
|
||||
return {"ok": False, "error_type": type(e).__name__, "error": str(e)}
|
||||
@@ -0,0 +1,231 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from typing import Any
|
||||
|
||||
from ifcmcp.core import IfcSession
|
||||
|
||||
try:
|
||||
from mcp.server.fastmcp import FastMCP # type: ignore
|
||||
from mcp.types import ImageContent # type: ignore
|
||||
except Exception: # pragma: no cover
|
||||
FastMCP = None # type: ignore
|
||||
ImageContent = None # type: ignore
|
||||
|
||||
|
||||
def build_server() -> Any:
|
||||
"""Create the FastMCP server if the dependency is available."""
|
||||
if FastMCP is None:
|
||||
raise ImportError(
|
||||
"FastMCP is not installed. Install with: pip install ifcmcp[mcp] " "(or add 'mcp' to your environment)."
|
||||
)
|
||||
|
||||
session = IfcSession()
|
||||
|
||||
server = FastMCP(
|
||||
name="ifc-mcp",
|
||||
instructions=(
|
||||
"MCP server for querying and editing IFC building models. "
|
||||
"Load a file first with ifc_load, then use query/edit tools. "
|
||||
"Save changes with ifc_save."
|
||||
),
|
||||
)
|
||||
|
||||
# ---- Lifecycle ----
|
||||
@server.tool()
|
||||
def ifc_new(schema: str = "IFC4") -> dict[str, Any]:
|
||||
return session.ifc_new(schema=schema)
|
||||
|
||||
@server.tool()
|
||||
def ifc_load(path: str) -> str:
|
||||
return session.ifc_load(path)
|
||||
|
||||
@server.tool()
|
||||
def ifc_save(path: str = "") -> str:
|
||||
return session.ifc_save(path)
|
||||
|
||||
@server.tool()
|
||||
def ifc_reset() -> dict[str, Any]:
|
||||
return session.ifc_reset()
|
||||
|
||||
# ---- Query ----
|
||||
@server.tool()
|
||||
def ifc_summary() -> dict[str, Any]:
|
||||
return session.ifc_summary()
|
||||
|
||||
@server.tool()
|
||||
def ifc_tree() -> dict[str, Any] | list[dict[str, Any]]:
|
||||
return session.ifc_tree()
|
||||
|
||||
@server.tool()
|
||||
def ifc_info(element_id: int) -> dict[str, Any]:
|
||||
return session.ifc_info(element_id)
|
||||
|
||||
@server.tool()
|
||||
def ifc_select(query: str) -> list[dict[str, Any]]:
|
||||
return session.ifc_select(query)
|
||||
|
||||
@server.tool()
|
||||
def ifc_relations(element_id: int, traverse: str = "") -> dict[str, Any] | list[dict[str, Any]]:
|
||||
return session.ifc_relations(element_id, traverse=traverse)
|
||||
|
||||
@server.tool()
|
||||
def ifc_clash(
|
||||
element_id: int,
|
||||
clearance: float = 0.0,
|
||||
tolerance: float = 0.002,
|
||||
scope: str = "storey",
|
||||
) -> dict[str, Any]:
|
||||
return session.ifc_clash(
|
||||
element_id=element_id,
|
||||
clearance=clearance,
|
||||
tolerance=tolerance,
|
||||
scope=scope,
|
||||
)
|
||||
|
||||
@server.tool()
|
||||
def ifc_contexts() -> list[dict[str, Any]]:
|
||||
return session.ifc_contexts()
|
||||
|
||||
@server.tool()
|
||||
def ifc_materials() -> list[dict[str, Any]]:
|
||||
return session.ifc_materials()
|
||||
|
||||
# ---- Edit ----
|
||||
@server.tool()
|
||||
def ifc_list(module: str = "") -> list[dict]:
|
||||
return session.ifc_list(module=module)
|
||||
|
||||
@server.tool()
|
||||
def ifc_docs(function_path: str) -> dict:
|
||||
return session.ifc_docs(function_path=function_path)
|
||||
|
||||
@server.tool()
|
||||
def ifc_edit(function_path: str, params: str = "{}") -> dict:
|
||||
return session.ifc_edit(function_path=function_path, params=params)
|
||||
|
||||
# ---- Extended query + edit ----
|
||||
@server.tool()
|
||||
def ifc_validate(express_rules: bool = False) -> dict[str, Any]:
|
||||
return session.ifc_validate(express_rules=express_rules)
|
||||
|
||||
@server.tool()
|
||||
def ifc_schedule(max_depth: int | None = None) -> list[dict[str, Any]]:
|
||||
return session.ifc_schedule(max_depth=max_depth)
|
||||
|
||||
@server.tool()
|
||||
def ifc_cost(max_depth: int | None = None) -> list[dict[str, Any]]:
|
||||
return session.ifc_cost(max_depth=max_depth)
|
||||
|
||||
@server.tool()
|
||||
def ifc_schema(entity_type: str) -> dict[str, Any]:
|
||||
return session.ifc_schema(entity_type=entity_type)
|
||||
|
||||
@server.tool()
|
||||
def ifc_quantify(rule: str, selector: str = "") -> dict[str, Any]:
|
||||
return session.ifc_quantify(rule=rule, selector=selector)
|
||||
|
||||
# ---- Shape builder ----
|
||||
@server.tool()
|
||||
def ifc_shape_list() -> list[dict]:
|
||||
return session.ifc_shape_list()
|
||||
|
||||
@server.tool()
|
||||
def ifc_shape_docs(method: str) -> dict:
|
||||
return session.ifc_shape_docs(method=method)
|
||||
|
||||
@server.tool()
|
||||
def ifc_shape(method: str, params: str = "{}") -> dict:
|
||||
return session.ifc_shape(method=method, params=params)
|
||||
|
||||
@server.tool(structured_output=False)
|
||||
def ifc_plot(
|
||||
selector: str = "",
|
||||
element_ids: list[int] | None = None,
|
||||
view: str = "floorplan",
|
||||
width_mm: float = 297.0,
|
||||
height_mm: float = 420.0,
|
||||
scale: float = 1.0 / 100.0,
|
||||
png_width: int = 1024,
|
||||
png_height: int = 1024,
|
||||
output_path: str = "",
|
||||
) -> list[ImageContent]:
|
||||
"""Generate a 2D technical drawing of the loaded IFC model.
|
||||
|
||||
Returns an inline PNG image (floor plan, elevation, or section) that the
|
||||
LLM can inspect to understand the 2D layout of the model. If
|
||||
``output_path`` is provided the drawing is also saved to disk — as SVG
|
||||
when the path ends in ``.svg``, otherwise as PNG.
|
||||
|
||||
:param selector: ifcopenshell selector to restrict plotted elements
|
||||
(e.g. ``'IfcWall'``). Omit to plot the whole model.
|
||||
:param element_ids: Step IDs of elements to highlight. Other elements
|
||||
are faded so the subject stands out.
|
||||
:param view: Drawing view — ``floorplan`` (default), ``elevation``,
|
||||
``section``, or ``auto``.
|
||||
:param width_mm: Paper width in mm (default 297 = A4 landscape width).
|
||||
:param height_mm: Paper height in mm (default 420 = A4 landscape height).
|
||||
:param scale: Model-to-paper scale ratio (default 0.01 = 1:100).
|
||||
:param png_width: Raster output width in pixels (default 1024).
|
||||
:param png_height: Raster output height in pixels (default 1024).
|
||||
:param output_path: Optional file path to save the drawing to disk.
|
||||
"""
|
||||
png_bytes = session.ifc_plot(
|
||||
selector=selector,
|
||||
element_ids=element_ids,
|
||||
view=view,
|
||||
width_mm=width_mm,
|
||||
height_mm=height_mm,
|
||||
scale=scale,
|
||||
png_width=png_width,
|
||||
png_height=png_height,
|
||||
output_format="png",
|
||||
)
|
||||
if output_path:
|
||||
if output_path.endswith(".svg"):
|
||||
svg_bytes = session.ifc_plot(
|
||||
selector=selector,
|
||||
element_ids=element_ids,
|
||||
view=view,
|
||||
width_mm=width_mm,
|
||||
height_mm=height_mm,
|
||||
scale=scale,
|
||||
output_format="svg",
|
||||
)
|
||||
with open(output_path, "wb") as f:
|
||||
f.write(svg_bytes)
|
||||
else:
|
||||
with open(output_path, "wb") as f:
|
||||
f.write(png_bytes)
|
||||
return [ImageContent(type="image", data=base64.b64encode(png_bytes).decode(), mimeType="image/png")]
|
||||
|
||||
@server.tool(structured_output=False)
|
||||
def ifc_render(
|
||||
selector: str = "",
|
||||
element_ids: list[int] | None = None,
|
||||
view: str = "iso",
|
||||
output_path: str = "",
|
||||
) -> list[ImageContent]:
|
||||
"""Render the loaded IFC model to a PNG image.
|
||||
|
||||
Returns an inline image the LLM can inspect to understand the spatial
|
||||
layout of the model or a specific element in context. If
|
||||
``output_path`` is provided the PNG is also saved to that file path.
|
||||
|
||||
:param selector: ifcopenshell selector to restrict rendered elements
|
||||
(e.g. ``'IfcWall'``, ``'IfcBuildingStorey[Name="0"]'``).
|
||||
Omit to render the whole model.
|
||||
:param element_ids: Step IDs of elements to highlight. Other elements
|
||||
are rendered in translucent grey so the subject stands out.
|
||||
:param view: Camera angle — ``iso`` (default), ``top``, ``south``,
|
||||
``north``, ``east``, or ``west``.
|
||||
:param output_path: Optional file path to save the PNG to disk.
|
||||
"""
|
||||
png_bytes = session.ifc_render(selector=selector, element_ids=element_ids, view=view)
|
||||
if output_path:
|
||||
with open(output_path, "wb") as f:
|
||||
f.write(png_bytes)
|
||||
return [ImageContent(type="image", data=base64.b64encode(png_bytes).decode(), mimeType="image/png")]
|
||||
|
||||
return server
|
||||
@@ -0,0 +1,36 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "ifcopenshell-mcp"
|
||||
version = "0.0.0"
|
||||
authors = [
|
||||
{ name="Bruno Postle", email="bruno@postle.net" },
|
||||
]
|
||||
description = "MCP server for querying and editing IFC building models"
|
||||
readme = "README.md"
|
||||
keywords = ["IFC", "BIM", "MCP"]
|
||||
classifiers = [
|
||||
"Programming Language :: Python :: 3",
|
||||
"License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)",
|
||||
]
|
||||
dependencies = ["ifcopenshell", "ifcquery", "ifcedit"]
|
||||
|
||||
[project.optional-dependencies]
|
||||
mcp = ["mcp"]
|
||||
|
||||
[project.scripts]
|
||||
ifcmcp = "ifcmcp.__main__:main"
|
||||
|
||||
[project.urls]
|
||||
Homepage = "http://ifcopenshell.org"
|
||||
Documentation = "https://docs.ifcopenshell.org"
|
||||
Issues = "https://github.com/IfcOpenShell/IfcOpenShell/issues"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["ifcmcp*"]
|
||||
exclude = ["test*"]
|
||||
|
||||
[tool.ruff]
|
||||
extend = "../../pyproject.toml"
|
||||
@@ -0,0 +1 @@
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user