Compare commits

...

89 Commits

Author SHA1 Message Date
Andrej730 16723d11ca ci-pyodide-wasm-release - add tag when pushing release 2026-04-13 17:45:37 +05:00
Andrej730 67238c4ac1 ci-pyodide-wasm-release - use BUILD_REPO_TOKEN 2026-04-13 17:40:02 +05:00
Andrej730 20229aa88c README.md: add pyodide-wasm-wheels tag badge 2026-04-13 16:43:20 +05:00
Andrej730 7788ae86c9 build-all.py: descriptive error for missing SSL support 2026-04-13 16:23:41 +05:00
Bruno Postle 002b7c5d6e ifcquery, ifcmcp: better bot selector syntax hints 2026-04-10 22:09:56 +01:00
Thomas Krijnen e7db239647 inverse access in schema 2026-04-10 21:46:39 +02:00
Thomas Krijnen 158756e921 arrange_polygons: settings, simplify based on growing boxes; more... 2026-04-10 21:46:39 +02:00
Andrej730 a3efa7e9ee util.element - fix IfcComplexProperty KeyError when verbose=True (#7921)
Introduced by me in b77df1892
2026-04-10 19:11:42 +05:00
Andrej730 4896946e78 ty ignore some upstream bpy stubs issues 2026-04-10 19:11:41 +05:00
Andrej730 588f365366 Remove unused ty ignores - issue is resolved upsteam in stubs 2026-04-10 19:11:41 +05:00
Andrej730 fa8770c14d ty - drop rules removed from recent version of ty 2026-04-10 19:11:41 +05:00
Andrej730 0cf831133e ci-lint - add ty type check 2026-04-10 19:11:41 +05:00
Andrej730 98338e0831 Rename ci-black-formatting workflow to ci-lint 2026-04-10 18:06:43 +05:00
Andrej730 5a3160eb62 black . 2026-04-10 18:02:47 +05:00
Andrej730 80a9df8f52 Ignore pyright warnings for bpy stubs
See https://github.com/nutti/fake-bpy-module/discussions/440
2026-04-10 18:01:19 +05:00
Andrej730 8eb0060d4a Get rid of pyright ignore reportRedeclaration noise
Welp, it was helping to point out untyped props, but it is getting too noisy now.
2026-04-10 17:55:16 +05:00
falken10vdl 51a338e4c8 Suppress reportRedeclaration in Pyright config 2026-04-10 17:49:14 +05:00
Andrej730 7169dcd053 Create ci-pyodide-wasm-release.yml 2026-04-10 17:29:46 +05:00
Andrej730 b9d4ea38b0 Script for packing pyodide wheel 2026-04-10 17:29:46 +05:00
Andrej730 c8f46cfb69 build_pyodide.sh - use emsdk from pyodide 2026-04-10 16:22:04 +05:00
Andrej730 6242251d3c Fix typo 2026-04-10 16:22:04 +05:00
Andrej730 1689960257 Maintenence - document ci-bonsai.yml update 2026-04-10 16:20:50 +05:00
dependabot[bot] c509f1d3ee Bump vite from 6.4.1 to 6.4.2 in /src/ifctester/webapp
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 6.4.1 to 6.4.2.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v6.4.2/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.4.2/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 6.4.2
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-10 16:16:33 +05:00
Dion Moult 217bfed847 Add py313 to stable build 2026-04-10 19:05:54 +10:00
Bruno Postle b4558f7f75 Fix ruff import ordering complaints 2026-04-09 01:04:14 +01:00
dependabot[bot] ebd5fe854f Bump ruff from 0.15.8 to 0.15.9
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.15.8 to 0.15.9.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.15.8...0.15.9)

---
updated-dependencies:
- dependency-name: ruff
  dependency-version: 0.15.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-09 09:59:16 +10:00
dependabot[bot] 06cfd0931c Bump actions/setup-python from 5 to 6
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5 to 6.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-09 09:59:10 +10:00
dependabot[bot] 90bd7d26ac Bump actions/checkout from 4 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-09 09:59:03 +10:00
Thomas Krijnen 3fbf01f446 partial revert of 24acfea 2026-04-08 13:48:23 +02:00
Bruno Postle 26a1955cba Fix compilation failure introduced in 24acfea 2026-04-07 23:34:30 +01:00
Bruno Postle 27d9cae8ff Bonsai, bump ifcmerge.exe to working version with deps
Don't leave a broken repo if ifcmerge is misinstalled.
Fix bug where only local branches could be merged.
Fix gitch where merge commits were not considered relevant.
2026-04-07 22:25:31 +01:00
Thomas Krijnen a751c1cce3 ifcchat: compaction 2026-04-07 09:46:45 +02:00
Thomas Krijnen 9d4307d343 ifcchat: Throttling of messages based on estimated token counts 2026-04-07 09:46:11 +02:00
Ryan Schultz ab7d9fdf4a Auto-assign aggregate on eyedropper pick
Add update callbacks to the relating_object and related_object
PointerProperties so that selecting an object via the eyedropper
in BIM_PT_aggregate immediately calls aggregate_assign_object
and closes the editing panel, removing the need to click the
checkmark button manually.

Generated with the assistance of an AI coding tool.
2026-04-05 16:43:03 -05:00
Ryan Schultz 5436467fc5 Whoops, this was supposed to be a PR...
Revert "Fix #3742: Remove coplanar boundary lines between adjacent same-material elements in Bonsai SVG drawings"

This reverts commit 1c7e134d78.
2026-04-04 13:49:02 -05:00
Ryan Schultz 1c7e134d78 Fix #3742: Remove coplanar boundary lines between adjacent same-material elements in Bonsai SVG drawings
Adds `remove_coplanar_boundary_lines()` to operator.py (Bonsai uses this
path, not draw.py's main()). After `merge_linework_and_add_metadata()`
assigns material CSS classes, this post-processes the SVG to delete
projection line segments that appear in two or more adjacent, coplanar
elements with the same material and presentation style.

Key design decisions:
- Material identity: compared via sorted IFC material ID tuples from
  `get_materials()`, not CSS class names — avoids false matches between
  unrelated `material-null` elements.
- Presentation style identity: compared via IFC IfcPresentationStyle IDs
  from `StyledByItem` on geometry representation items — handles elements
  with no material but distinct visual styles.
- Physical adjacency: confirmed by a 3D shared-vertex test (tol=0.01 m)
  after a quick AABB guard, rejecting elements whose 2D projections
  overlap but sit at different depths.
- Coplanarity: determined by the dominant (largest-area) face normal of
  each Blender mesh object — area-weighted averages are unreliable for
  slabs whose equal top/bottom faces cancel out. Folded walls sharing an
  edge but meeting at an angle are correctly rejected (normal dot ≪ 1.0).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 13:42:49 -05:00
Bruno Perdigão 97ee4eaef0 See #7888 - Fix snap when object changes during modal operator.
Handle cases where the snapped target is modified while a modal operator
is active (e.g., adding a door or window that alters the wall geometry).
2026-04-04 14:30:59 -03:00
Thomas Krijnen 30517770e0 Revert default tool output truncation 2026-04-04 13:12:47 +02:00
DesertSpringsCivil 5b1ec85f75 feat: Reduce token usage in ifcchat and default to IFC4X3
- Add Anthropic prompt caching (cache_control on system prompt and
  tools) to reduce repeated token costs by ~90%
- Truncate large tool results in conversation history (2000 char cap)
  to prevent context bloat from ifc_tree/ifc_select responses
- Add sliding window (40 messages) on conversation history, trimming
  at user message boundaries to avoid breaking tool-call sequences
- Default "New IFC" button to IFC4X3 schema instead of IFC4
- Constrain ifc_new schema parameter with enum to prevent invalid
  schema strings like "IFC4X3ADD2"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 13:11:14 +02:00
Stephen Boddy 12123baafe Update the pyver as 3.13 is default in 5.1 now 2026-04-04 04:22:12 +01:00
Stephen Boddy 0a1c54cedc Fix the ci-bonsai-daily blender url 2026-04-04 03:56:08 +01:00
Bruno Postle 12144f76d3 Merge branch 'ifcgit-features' into v0.8.0 2026-04-04 00:10:33 +01:00
Bruno Postle ca6e950496 ifcgit: conflict report panel and dry-run merge preview
Parse ifcmerge JSON output and display a per-conflict breakdown in the
panel when merge fails. Ctrl+click on the Merge button previews
conflicts without committing. Add SelectConflictEntity operator to
select and frame the conflicting object in the 3D viewport.

Generated with the assistance of an AI coding tool.
2026-04-03 13:28:14 +01:00
Thomas Krijnen c478da5257 Remove pro 2026-04-03 11:36:56 +02:00
Thomas Krijnen c28251a1b1 Add CNAME file 2026-04-03 11:30:33 +02:00
Thomas Krijnen 9bc0588d21 Update openai model list 2026-04-03 11:30:23 +02:00
Thomas Krijnen 918cc65a0d Provider selection as tabs 2026-04-03 11:22:43 +02:00
Thomas Krijnen 7350ccd25e Tweak header padding 2026-04-03 11:14:40 +02:00
Thomas Krijnen c1f146966c The end of open source? Just regurgitate some markdown parsing code. 2026-04-03 11:03:44 +02:00
Thomas Krijnen 24acfeaf45 Thinking indicator under chat 2026-04-03 10:59:09 +02:00
Thomas Krijnen 5d748c5b04 Add Gemini option 2026-04-03 10:49:36 +02:00
Thomas Krijnen 5bcf8685ab Merge remote-tracking branch 'origin/feat/ifcchat-claude' into v0.8.0 2026-04-03 10:26:29 +02:00
geronimi73 8f64f75abb add favourite models 2026-04-03 09:46:44 +02:00
geronimi73 434ea74f22 format this mess 2026-04-03 09:46:44 +02:00
geronimi73 fbf2946b69 Update index.html 2026-04-03 09:46:44 +02:00
geronimi73 7af0ec13e5 move model to sidebar 2026-04-03 09:46:44 +02:00
geronimi73 29e5e0fd1a chevrons for tool result expansion 2026-04-03 09:46:44 +02:00
geronimi73 d7de4f8df0 dont freeze UI on error 2026-04-03 09:46:44 +02:00
geronimi73 252bd6f4f6 openai by default 2026-04-03 09:46:44 +02:00
geronimi73 3b28c92414 html too big -> styles into sep. file 2026-04-03 09:46:44 +02:00
geronimi73 fcbec74521 spinner 2026-04-03 09:46:44 +02:00
geronimi73 0f7f960b29 let claude code openrouter compatibility 2026-04-03 09:46:44 +02:00
geronimi73 b7fc5daf82 ui: choose openai/openrouter 2026-04-03 09:46:44 +02:00
geronimi73 1d7a8ca249 separate API calls 2026-04-03 09:46:44 +02:00
Ryan Schultz eaf7950677 Fix TypeError in ray_cast_by_proximity_2d degenerate edge
A degenerate edge (zero-length segment) caused an early `return`
of a tuple instead of continuing the loop, resulting in a
TypeError when snap.py iterated the result and tried to assign
`point["group"]` on a float.

Generated with the assistance of an AI coding tool.
2026-04-02 23:24:35 -03:00
DesertSpringsCivil 95851ff94c feat: Add Anthropic Claude API support to ifcchat
Add a provider selector (OpenAI / Anthropic) to the ifcchat web UI,
allowing users to use their Anthropic API key with Claude models
instead of only OpenAI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 18:50:27 -06:00
Bruno Postle 3a6881ca17 ifcgit: add rename branch button next to working branch label
See #7577

Generated with the assistance of an AI coding tool.
2026-04-03 00:20:22 +01:00
Bruno Postle c5210a4f82 ifcgit: sync load_project post-import steps with project operator
See #7578
2026-04-03 00:03:34 +01:00
Bruno Postle e3cadab406 ifcgit: add clone widget to new project wizard (#7579) 2026-04-02 23:42:39 +01:00
Bruno Postle d5b551874d ifcgit: pre-fill branch name when switching to a remote branch tip
When a remote branch tip is checked out (resulting in detached HEAD),
the new-branch name field is now pre-filled with the local equivalent
of the remote branch name (generating a unique suffix if that name is
already taken), so the commit button is immediately usable.

See #7580

Generated with the assistance of an AI coding tool.
2026-04-02 23:16:40 +01:00
Bruno Postle be3aef59fa ifcgit: move action buttons below revision list in a labelled row
Generated with the assistance of an AI coding tool.
2026-04-02 22:45:33 +01:00
Bruno Postle bcc631bf5f ifcgit: improve colourise to find products via geometry and property changes
Generated with the assistance of an AI coding tool.
2026-04-02 22:44:56 +01:00
Bruno Postle 6c18ac9605 ifcgit: use --prioritise-local flag for ifcmerge-forward mergetool 2026-04-02 22:42:29 +01:00
Bruno Postle 9d42f4c1ee Update Bonsai to latest ifcmerge (#7581 #3096)
This version has some functional differences:
- Structured JSON error message instead of free text (on STDOUT not STDERR)
- New --prioritise-local flag to control which side wins in merge conflicts (not used by Bonsai yet)
- IfcLocalPlacement conflicts now auto-resolve instead of failing the merge (partial solution to #6885)
- Float values are normalised when comparing entities (workaround for #7696)
2026-04-02 07:54:20 +01:00
Ryan Schultz fdb2947345 Add git branch to system info debug output
Include bonsai_git_branch in get_debug_info(). For dev environments
using the GitPython-based update_commit_data() path, the branch is
read from repo.active_branch.name. For built extensions, a 7777777
placeholder is replaced at build time via the Makefile, matching the
existing pattern for bonsai_commit_hash and bonsai_commit_date.

Generated with the assistance of an AI coding tool.
2026-04-01 19:45:22 -05:00
Bruno Postle 5e784e4175 Refactor ifcgit, fix UI bugs and performance
Move all business logic into bonsai core and tool. Performance fixes to
minimise file IO, various minor bug fixes and tests.

Generated with the assistance of an AI coding tool.
2026-04-02 00:03:03 +01:00
Thomas Krijnen fb81c88a5f initial ai chat src 2026-04-01 15:56:13 +02:00
Thomas Krijnen c014ce2b46 initial ai chat src 2026-04-01 15:52:22 +02:00
Thomas Krijnen ff65719074 initial ai chat src 2026-04-01 15:50:24 +02:00
Thomas Krijnen 3491e4c91b initial ai chat src 2026-04-01 15:46:45 +02:00
Thomas Krijnen 9f3adc9154 initial ai chat src 2026-04-01 15:36:27 +02:00
Thomas Krijnen f6c6203408 initial ai chat src 2026-04-01 15:33:43 +02:00
falken10vdl 39a376df95 intersect_edge_region_border: Change return statements to return None, None for no intersection
In order to fix error of the type:
              |     point, _ = cls.intersect_edge_region_border(
                            |     ^^^^^^^^
                            | TypeError: cannot unpack non-iterable NoneType object

a tuple is expected.
2026-04-01 08:46:40 -03:00
Ryan Schultz 70a4fdbf95 Fix #7878: Fix snapping crash with non-mesh objects
Two bugs introduced in 31b571322:
- SnapObj assumed obj.data is always a Mesh; non-mesh
  objects (empties, lights, etc.) have obj.data = None,
  causing an AttributeError on obj.data.edges.
- view3d_utils was used but never imported.

Generated with the assistance of an AI coding tool.
2026-04-01 08:14:51 -03:00
Thomas Krijnen 0a41d2e016 Rename project from 'ifcmcp' to 'ifcopenshell-mcp' 2026-04-01 11:09:49 +02:00
Thomas Krijnen 0b5eab8549 Enable verbose output for PyPI deployment 2026-04-01 09:24:35 +02:00
Bruno Postle 6f9d54c2af ifcquery, ifcedit: update docs for --format ids and foreach subcommand
Add --format ids to the ifcquery.rst format description and a new
"Scripting with ifcedit" section showing composition examples.  Add
the foreach subcommand to ifcedit.rst with usage examples.
2026-04-01 08:53:09 +02:00
Bruno Postle 9ea302cdf1 ifcquery, ifcedit, ifcmcp: add documentation
Add ifcquery, ifcedit and ifcmcp to the README contents table, the
Sphinx docs toctree and introduction utilities table. Add new .rst
pages for each package documenting subcommands, installation, usage,
and parameter types. Fix plot and render CLI examples in ifcquery
README to use -o/--out-format flags. Update ifcmcp README to use the
installed ifcmcp command rather than python3 -m ifcmcp.

Generated with the assistance of an AI coding tool.
2026-04-01 08:53:09 +02:00
Bruno Postle c057e79f17 ifcquery, ifcedit, ifcmcp: add Makefiles and PyPI publish workflows
These three packages were added to src/ but lacked the Makefile needed
by common.mk to build distribution wheels, and the GitHub Actions
workflow to publish them to PyPI.

Adds make dist / make test / make qa targets and ci-*-pypi.yaml
workflows matching the pattern used by ifcpatch, ifcclash, etc.
2026-04-01 08:53:09 +02:00
101 changed files with 5987 additions and 844 deletions
+2 -2
View File
@@ -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.1.0-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
+6 -1
View File
@@ -24,7 +24,7 @@ jobs:
strategy:
fail-fast: false
matrix:
pyver: [py311, py312]
pyver: [py311, py312, py313]
config:
- {
name: "Windows Build",
@@ -42,6 +42,11 @@ jobs:
name: "MacOS ARM Build",
short_name: macosm1,
}
exclude:
# Python 3.13 is needed for Blender 5.1+ and Blender dropped Intel Mac support in 5.0.
- pyver: py313
config:
short_name: macos
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
+35
View File
@@ -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
+36
View File
@@ -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
+35
View File
@@ -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
@@ -1,4 +1,4 @@
name: ci-black-formatting
name: ci-lint
on:
push:
@@ -30,6 +30,7 @@ jobs:
uv tool install ruff
uv tool install black
uv tool install poethepoet
uv tool install ty
# black doesn't catch all syntax errors, so we check them explicitly.
- name: Check syntax errors
@@ -57,6 +58,13 @@ jobs:
black --diff --check . | black-codeclimate | python .github/workflows/black_to_github_annotations.py
continue-on-error: true
- name: ty check
id: ty
run: |
poe ty-venv
poe ty
continue-on-error: true
- name: Ruff check
id: ruff
run: |
@@ -105,4 +113,7 @@ jobs:
if [ "${{ steps.ruff.outcome }}" != "success" ]; then
echo "::error::Ruff check failed, see Summary or 'ruff' step for the details." && ERROR=1
fi
if [ "${{ steps.ty.outcome }}" != "success" ]; then
echo "::error::ty check failed, see 'ty check' step for the details." && ERROR=1
fi
exit $ERROR
@@ -0,0 +1,46 @@
name: Release Pyodide WASM Wheel
on:
workflow_dispatch:
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout IfcOpenShell
uses: actions/checkout@v6
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Build wheel
working-directory: pyodide
run: uv run pack_wheel.py --build
- name: Find wheel
id: wheel
run: |
WHEEL=$(ls pyodide/dist/ifcopenshell-*.whl)
echo "path=$WHEEL" >> $GITHUB_OUTPUT
echo "name=$(basename $WHEEL)" >> $GITHUB_OUTPUT
- name: Checkout wasm-wheels
uses: actions/checkout@v6
with:
repository: IfcOpenShell/wasm-wheels
path: wasm-wheels
token: ${{ secrets.BUILD_REPO_TOKEN }}
- name: Commit and push wheel to wasm-wheels
run: |
WHEEL_NAME="${{ steps.wheel.outputs.name }}"
cp "${{ steps.wheel.outputs.path }}" "wasm-wheels/$WHEEL_NAME"
cd wasm-wheels
git config user.name "IfcOpenBot"
git config user.email "ifcopenbot@ifcopenshell.org"
git add "$WHEEL_NAME"
git commit -m "Add $WHEEL_NAME"
VERSION=$(cat ../VERSION)
git tag "v${VERSION}"
git push origin main
git push origin "v${VERSION}"
+65
View File
@@ -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@v6
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@v6
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
+24 -17
View File
@@ -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@v6
- name: Upload static files as artifact
id: deployment
uses: actions/upload-pages-artifact@v4
- name: Checkout intermediate Pages repo
uses: actions/checkout@v6
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@v5
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
+4 -1
View File
@@ -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\* | [![Official](https://img.shields.io/badge/IfcOpenShell.org-Download-70ba35)](https://docs.ifcopenshell.org/ifcconvert/installation.html) [![GitHub](https://img.shields.io/github/v/release/ifcopenshell/ifcopenshell?filter=ifcconvert-*&label=GitHub&color=f6f8fa)](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 | [![PyPI](https://img.shields.io/pypi/v/ifccsv?label=PyPI&color=006dad)](https://pypi.org/project/ifccsv/) |
| [ifcdiff](https://docs.ifcopenshell.org/ifcdiff.html) | Compare changes between IFC models | LGPL-3.0-or-later | [![PyPI](https://img.shields.io/pypi/v/ifcdiff?label=PyPI&color=006dad)](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 | [![PyPI](https://img.shields.io/pypi/v/ifcedit?label=PyPI&color=006dad)](https://pypi.org/project/ifcedit/) |
| [ifcfm](https://docs.ifcopenshell.org/ifcfm.html) | Extract IFC data for FM handover requirements | LGPL-3.0-or-later | [![PyPI](https://img.shields.io/pypi/v/ifcfm?label=PyPI&color=006dad)](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\* | [![Official](https://img.shields.io/badge/IfcOpenShell.org-Download-70ba35)](https://docs.ifcopenshell.org/ifcmax.html)
| [ifcopenshell-python](https://docs.ifcopenshell.org/ifcopenshell-python.html) | Python library for IFC manipulation | LGPL-3.0-or-later\* | [![Official](https://img.shields.io/badge/IfcOpenShell.org-Download-70ba35)](https://docs.ifcopenshell.org/ifcopenshell-python/installation.html) [![GitHub](https://img.shields.io/github/v/release/ifcopenshell/ifcopenshell?filter=ifcopenshell-python-*&label=GitHub&color=f6f8fa)](https://github.com/IfcOpenShell/IfcOpenShell/releases?q=ifcopenshell-python&expanded=true) [![PyPI](https://img.shields.io/pypi/v/ifcopenshell?label=PyPI&color=006dad)](https://pypi.org/project/ifcopenshell/) [![Anaconda](https://img.shields.io/conda/vn/conda-forge/ifcopenshell?label=Anaconda&color=43b02a)](https://anaconda.org/conda-forge/ifcopenshell) [![Anaconda](https://img.shields.io/conda/vn/ifcopenshell/ifcopenshell?label=Anaconda-Unstable&color=43b02a)](https://anaconda.org/ifcopenshell/ifcopenshell) [![Docker](https://img.shields.io/docker/pulls/aecgeeks/ifcopenshell?label=Docker&color=1D63ED)](https://hub.docker.com/r/aecgeeks/ifcopenshell) [![AUR](https://img.shields.io/aur/version/ifcopenshell?label=AUR&color=1793d1)](https://aur.archlinux.org/packages/ifcopenshell) [![AUR Unstable](https://img.shields.io/aur/version/ifcopenshell-git?label=AUR-Unstable&color=1793d1)](https://aur.archlinux.org/packages/ifcopenshell-git) [Pyodide WASM Wheels](https://github.com/IfcOpenShell/wasm-wheels#pyodide-test-wheels) |
| [ifcmcp](https://docs.ifcopenshell.org/ifcmcp.html) | MCP server for querying and editing IFC building models | LGPL-3.0-or-later | [![PyPI](https://img.shields.io/pypi/v/ifcmcp?label=PyPI&color=006dad)](https://pypi.org/project/ifcmcp/) |
| [ifcopenshell-python](https://docs.ifcopenshell.org/ifcopenshell-python.html) | Python library for IFC manipulation | LGPL-3.0-or-later\* | [![Official](https://img.shields.io/badge/IfcOpenShell.org-Download-70ba35)](https://docs.ifcopenshell.org/ifcopenshell-python/installation.html) [![GitHub](https://img.shields.io/github/v/release/ifcopenshell/ifcopenshell?filter=ifcopenshell-python-*&label=GitHub&color=f6f8fa)](https://github.com/IfcOpenShell/IfcOpenShell/releases?q=ifcopenshell-python&expanded=true) [![PyPI](https://img.shields.io/pypi/v/ifcopenshell?label=PyPI&color=006dad)](https://pypi.org/project/ifcopenshell/) [![Anaconda](https://img.shields.io/conda/vn/conda-forge/ifcopenshell?label=Anaconda&color=43b02a)](https://anaconda.org/conda-forge/ifcopenshell) [![Anaconda](https://img.shields.io/conda/vn/ifcopenshell/ifcopenshell?label=Anaconda-Unstable&color=43b02a)](https://anaconda.org/ifcopenshell/ifcopenshell) [![Docker](https://img.shields.io/docker/pulls/aecgeeks/ifcopenshell?label=Docker&color=1D63ED)](https://hub.docker.com/r/aecgeeks/ifcopenshell) [![AUR](https://img.shields.io/aur/version/ifcopenshell?label=AUR&color=1793d1)](https://aur.archlinux.org/packages/ifcopenshell) [![AUR Unstable](https://img.shields.io/aur/version/ifcopenshell-git?label=AUR-Unstable&color=1793d1)](https://aur.archlinux.org/packages/ifcopenshell-git) [![Pyodide WASM Wheels tag](https://img.shields.io/github/v/tag/ifcopenshell/wasm-wheels?sort=semver&label=pyodide-wasm-wheels)](https://github.com/IfcOpenShell/wasm-wheels) |
| [ifcpatch](https://docs.ifcopenshell.org/ifcpatch.html) | Utility to run pre-packaged scripts to manipulate IFCs | LGPL-3.0-or-later | [![PyPI](https://img.shields.io/pypi/v/ifcpatch?label=PyPI&color=006dad)](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 | [![PyPI](https://img.shields.io/pypi/v/ifcquery?label=PyPI&color=006dad)](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 | [![GitHub Unstable](https://img.shields.io/github/v/release/ifcopenshell/ifcopenshell?filter=ifcsverchok-*.*.*.*&label=GitHub-Unstable&color=f6f8fa)](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 | [![PyPI](https://img.shields.io/pypi/v/ifctester?label=PyPI&color=006dad)](https://pypi.org/project/ifctester/) |
+12 -3
View File
@@ -1094,10 +1094,19 @@ if "python" in targets and not USE_CURRENT_PYTHON_VERSION and "wasm" not in flag
f"http://www.python.org/ftp/python/{PYTHON_VERSION}/",
f"Python-{PYTHON_VERSION}.tgz",
)
python_bin = INSTALL_DIR / f"python-{PYTHON_VERSION}" / "bin" / "python3"
python_install = INSTALL_DIR / f"python-{PYTHON_VERSION}"
python_bin = python_install / "bin" / "python3"
# `_ssl` module is present -> we will be able to install `numpy` later
# to verify IfcOpenShell installation
run([str(python_bin), "-c", "import _ssl"])
try:
run([str(python_bin), "-c", "import _ssl"])
except RuntimeError:
print(
"ERROR: Python was built without SSL support (_ssl module is missing). "
f"To fix this: remove the installed Python at {python_install}; "
"install OpenSSL development libraries and re-run."
)
raise
if MAC_CROSS_COMPILE_INTEL:
assert original_path
@@ -1515,7 +1524,7 @@ if "IfcOpenShell-Python" in targets:
)
# Copy setup.py where pyodide build system expects it.
shutil.copy(REPO_PATH / "pyodide" / "setup.py", REPO_PATH)
# Empty pyproject so it's contents won't affect the resulting wheelthe the
# Empty pyproject so it's contents won't affect the resulting wheel
# otherwise the wheel will use version and dependencies from toml, not setup.py.
(REPO_PATH / "pyproject.toml").write_text("")
+3 -10
View File
@@ -14,18 +14,11 @@ source .venv/bin/activate
uv pip install pyodide-build
# `uv run` is required, so xbuildenv would skip using `pip`.
uv run pyodide xbuildenv install
uv run pyodide xbuildenv install-emscripten
# Emscripten doesn't come with xbuildenv.
if [ ! -d emsdk ]; then
git clone https://github.com/emscripten-core/emsdk
fi
pushd emsdk
PYODIDE_EMSCRIPTEN_VERSION=$(pyodide config get emscripten_version)
./emsdk install ${PYODIDE_EMSCRIPTEN_VERSION}
./emsdk activate ${PYODIDE_EMSCRIPTEN_VERSION}
source emsdk_env.sh
EMSDK_ROOT=$(pyodide config get emscripten_dir)
source ${EMSDK_ROOT}/emsdk_env.sh
which emcc
popd
mkdir -p packages/ifcopenshell
VERSION=`cat IfcOpenShell/VERSION`
+232
View File
@@ -0,0 +1,232 @@
#
# /// script
# # Latest Pyodide build env versions are listed here:
# # https://pyodide.github.io/pyodide/api/pyodide-cross-build-environments.json
# # https://github.com/pyodide/pyodide-build/blob/main/pyodide_build/xbuildenv_releases.py
# requires-python = "==3.13.2"
# dependencies = [
# "requests",
# "setuptools",
# ]
# ///
"""
Pack an IfcOpenShell WASM wheel using Pyodide build system.
Usage:
uv run make_wheel.py # Show this help
uv run make_wheel.py --build # Build wheel
uv run make_wheel.py --clean # Clean build artifacts and exit
"""
import argparse
import os
import re
import shutil
import subprocess
import time
import zipfile
from pathlib import Path
from urllib.parse import quote
import requests
# Get repo root (parent of this script's parent directory)
REPO_ROOT = Path(__file__).parent.parent
PYODIDE_DIR = REPO_ROOT / "pyodide"
BUILD_DIR = PYODIDE_DIR / "build"
# Hardcoded path (Windows packing workaround with --dev flag)
PYODIDE_BUILD = Path(r"L:\Projects\Github\pyodide-build")
# Wheel platform tag (from PYODIDE_EMSCRIPTEN_VERSION in pyodide-build/Makefile.envs)
WHEEL_PLATFORM_TAG = "emscripten_4_0_9_wasm32"
# Location where ifcopenshell will be extracted
IFCOPENSHELL_DIR = PYODIDE_DIR / "ifcopenshell"
class WheelBuilder:
@staticmethod
def extract_ifcopenshell_from_git(dst: Path) -> None:
"""Extract ifcopenshell directory from git repo into destination."""
Tools.rmrf(dst)
print(f"Extracting ifcopenshell from git to {dst}...")
# Use git ls-files piped to git checkout-index to avoid copying
# untracked or ignored files from the actual repo.
ls_proc = subprocess.Popen(
["git", "ls-files", "-z", "src/ifcopenshell-python/ifcopenshell"],
cwd=REPO_ROOT,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
checkout_proc = subprocess.Popen(
["git", "checkout-index", "-z", "--prefix", "pyodide/", "--stdin"],
cwd=REPO_ROOT,
stdin=ls_proc.stdout,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
assert ls_proc.stdout is not None
ls_proc.stdout.close()
checkout_proc.communicate()
if checkout_proc.returncode != 0:
assert checkout_proc.stderr is not None
raise RuntimeError(f"Failed to extract: {checkout_proc.stderr.decode()}")
# Move src/ifcopenshell-python/ifcopenshell to ifcopenshell.
temp_src = PYODIDE_DIR / "src" / "ifcopenshell-python" / "ifcopenshell"
shutil.move(temp_src, dst)
# Clean up temporary src directory.
Tools.rmrf(PYODIDE_DIR / "src")
print("✓ Extracted ifcopenshell from git")
@staticmethod
def get_wheel_url(makefile_path: Path) -> str:
"""Get S3 wheel URL based on BINARY_VERSION and BUILD_COMMIT from Makefile."""
def parse_makefile_vars() -> dict[str, str]:
content = makefile_path.read_text()
vars: dict[str, str] = {}
for match in re.finditer(r"^(BINARY_VERSION|BUILD_COMMIT):=(.+)$", content, re.MULTILINE):
vars[match.group(1)] = match.group(2).strip()
return vars
vars: dict[str, str] = parse_makefile_vars()
binary_version = vars["BINARY_VERSION"]
build_commit = vars["BUILD_COMMIT"]
filename = f"ifcopenshell-{binary_version}+{build_commit}-cp313-cp313-pyodide_2025_0_wasm32.whl"
encoded_filename = quote(filename, safe="")
return f"https://s3.amazonaws.com/ifcopenshell-builds/{encoded_filename}"
@staticmethod
def download_and_extract_so(url: str, build_dir: Path) -> tuple[Path, Path]:
"""Download wheel from URL and extract .so and .py files."""
py_wrapper_filename = "ifcopenshell_wrapper.py"
build_dir.mkdir(parents=True, exist_ok=True)
wheel_path = build_dir / url.rsplit("/", 1)[-1]
if wheel_path.exists():
print(f"Using cached wheel: {wheel_path}")
else:
print(f"Downloading {url}...")
response = requests.get(url)
response.raise_for_status()
wheel_path.write_bytes(response.content)
print("Extracting _ifcopenshell_wrapper files...")
with zipfile.ZipFile(wheel_path) as zf:
so_files = [f for f in zf.namelist() if f.endswith(".so")]
py_files = [f for f in zf.namelist() if f.endswith(py_wrapper_filename)]
assert so_files, "No .so file found in wheel"
assert py_files, f"No {py_wrapper_filename} file found in wheel"
so_file = so_files[0]
so_dst = build_dir / Path(so_file).name
so_dst.write_bytes(zf.read(so_file))
py_file = py_files[0]
py_dst = build_dir / Path(py_file).name
py_dst.write_bytes(zf.read(py_file))
return so_dst, py_dst
class Tools:
@staticmethod
def run(
cmd: list[str],
cwd: Path | None = None,
) -> None:
print(f"$ {' '.join(cmd)}")
subprocess.check_call(cmd, cwd=cwd)
@staticmethod
def create_symlink(dst: Path, src: Path) -> None:
Tools.rmrf(dst)
dst.symlink_to(src)
@staticmethod
def rmrf(path: Path) -> None:
if path.exists() or path.is_symlink():
if path.is_dir() and not path.is_symlink():
shutil.rmtree(path)
else:
path.unlink()
def clean() -> None:
"""Remove build artifacts."""
paths_to_remove = (
BUILD_DIR,
PYODIDE_DIR / ".pyodide_build",
PYODIDE_DIR / "dist",
PYODIDE_DIR / "ifcopenshell.egg-info",
PYODIDE_DIR / "src",
IFCOPENSHELL_DIR,
)
for path in paths_to_remove:
if path.exists() or path.is_symlink():
print(f"Removing {path}...")
Tools.rmrf(path)
print("✓ Clean complete")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__, add_help=False)
parser.add_argument("--build", action="store_true", help="Build the wheel")
parser.add_argument("--clean", action="store_true", help="Clean build folder")
parser.add_argument(
"--dev",
action="store_true",
help="Use editable pyodide-build from hardcoded path (Windows packing workaround)",
)
args = parser.parse_args()
if not args.build and not args.clean:
print(__doc__)
return
if args.clean:
clean()
return
start_time = time.time()
WheelBuilder.extract_ifcopenshell_from_git(IFCOPENSHELL_DIR)
print("Downloading and extracting _ifcopenshell_wrapper files...")
makefile = REPO_ROOT / "src" / "ifcopenshell-python" / "Makefile"
wheel_url = WheelBuilder.get_wheel_url(makefile)
so_file, py_file = WheelBuilder.download_and_extract_so(wheel_url, BUILD_DIR)
Tools.create_symlink(IFCOPENSHELL_DIR / Path(so_file).name, so_file)
Tools.create_symlink(IFCOPENSHELL_DIR / Path(py_file).name, py_file)
print("Installing pyodide-build...")
if args.dev:
Tools.run(["uv", "pip", "install", "-e", str(PYODIDE_BUILD)])
else:
Tools.run(["uv", "pip", "install", "pyodide-build"])
print("Building with pyodide...")
# Use --no-isolation due to pyodide-build Windows support issues:
# symlink_unisolated_packages fails with missing `_sysconfigdata_$(CPYTHON_ABI_FLAGS)_emscripten_wasm32-emscripten.py`.
# Hardcode platform name since pyodide doesn't yet support overriding wheel tags on Windows.
#
# Use `LEGACY_PLATFORM` since pyodide 0.34.1 introduced new tag for wheels `pyemscripten`,
# which doesn't work with pyodide itself yet - https://github.com/pyodide/pyodide/issues/6177.
os.environ["USE_LEGACY_PLATFORM"] = "1"
Tools.run(["pyodide", "build", f"-C--build-option=--plat-name={WHEEL_PLATFORM_TAG}"])
elapsed = time.time() - start_time
print(f"\n✓ Done! ({elapsed:.1f}s)")
if __name__ == "__main__":
main()
+39 -1
View File
@@ -2,12 +2,16 @@
# because `tool.setuptools.ext-modules` is still experimental in pyproject.toml
# and we need it to get the wheel suffix right.
import os
import sys
from pathlib import Path
import tomllib
from setuptools import Extension, find_packages, setup
from setuptools.command.build_ext import build_ext
REPO_FOLDER = Path(__file__).parent
# Detect repo folder: if setup.py is in pyodide folder, go to parent
SETUP_DIR = Path(__file__).parent
REPO_FOLDER = SETUP_DIR.parent if SETUP_DIR.name == "pyodide" else SETUP_DIR
def get_version() -> str:
@@ -25,6 +29,39 @@ def get_dependencies() -> list[str]:
return dependencies
class UnixBuildExt(build_ext):
"""Customize ``build_ext`` to support packing on Windows."""
def finalize_options(self):
from distutils import sysconfig
super().finalize_options()
if sys.platform == "win32":
self.compiler = "unix"
# Configure sysconfig for Windows builds
# CCSHARED is the only variable that's not customizable with env vars.
# Basically avoiding this:
# File ".venv\Lib\site-packages\setuptools\_distutils\sysconfig.py", line 366, in customize_compiler
# compiler_so=cc_cmd + ' ' + ccshared,
# ~~~~~~~~~~~~~^~~~~~~~~~
# TypeError: can only concatenate str (not "NoneType") to str
sysconfig.get_config_vars() # Initialize config cache
if sysconfig._config_vars.get("CCSHARED") is None:
sysconfig._config_vars["CCSHARED"] = "-fPIC"
# Override compiler type before it's instantiated
# Set Emscripten compiler environment variables
os.environ["CC"] = "emcc"
os.environ["CXX"] = "em++"
os.environ["CFLAGS"] = ""
os.environ["CXXFLAGS"] = ""
os.environ["LDSHARED"] = "emcc -shared"
os.environ["AR"] = "emar"
os.environ["ARFLAGS"] = "rcs"
os.environ["SETUPTOOLS_EXT_SUFFIX"] = ".cpython-313-wasm32-emscripten.so"
setup(
name="ifcopenshell",
version=get_version(),
@@ -44,4 +81,5 @@ setup(
},
# Has to provide extension to get the correct wheel suffix.
ext_modules=[Extension("ifcopenshell._ifcopenshell_wrapper", sources=[])],
cmdclass={"build_ext": UnixBuildExt},
)
+6 -4
View File
@@ -3,8 +3,9 @@ name = "IfcOpenShell"
version = "0.0.0"
dependencies = [
"black==26.3.1",
"ruff==0.15.8",
"ruff==0.15.9",
"poethepoet",
"ty==0.0.29",
"gersemi==0.26.1",
]
@@ -28,6 +29,9 @@ extend-exclude = '''
reportInvalidTypeForm = false
disableBytesTypePromotions = true
reportUnnecessaryTypeIgnoreComment = true
reportRedeclaration = false
# Ignore warnings from bpy stubs missing actual source files.
reportMissingModuleSource = false
# Pylance doesn't respect gitignore, so we have to exclude files manually here
# to avoid VS Code slowing down.
# https://github.com/microsoft/pylance-release/issues/5169
@@ -84,7 +88,6 @@ all = "ignore"
# Structural rules (no deep type inference needed, easier to adapt).
abstract-method-in-final-class = "error"
ambiguous-protocol-member = "error"
byte-string-type-annotation = "error"
conflicting-declarations = "error"
conflicting-metaclass = "error"
cyclic-class-definition = "error"
@@ -96,7 +99,6 @@ empty-body = "error"
escape-character-in-forward-annotation = "error"
final-on-non-method = "error"
final-without-value = "error"
fstring-type-annotation = "error"
ignore-comment-unknown-rule = "error"
implicit-concatenated-string-type-annotation = "error"
inconsistent-mro = "error"
@@ -224,7 +226,7 @@ ty.sequence = ["ty-bonsai", "ty-ios"]
ty.help = "Run ty type checker. Requires ty-venv to be set up first."
ty-bonsai = "ty check src/bonsai --python=src/bonsai/.venv"
ty-venv.sequence = ["ty-venv-bonsai", "ty-venv-ios"]
ty-venv.sequence = ["bonsai-deps", "ty-venv-bonsai", "ty-venv-ios"]
ty-venv-bonsai.sequence = [
{cmd = "uv venv src/bonsai/.venv --python=3.11 --allow-existing"},
+5 -3
View File
@@ -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-07
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
+13
View File
@@ -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
@@ -101,7 +101,7 @@ class AggregateDecorator:
cls.is_installed = False
def dotted_line_shader(self):
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") # ty:ignore[too-many-positional-arguments]
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface")
vert_out.smooth("FLOAT", "v_ArcLength")
shader_info = gpu.types.GPUShaderCreateInfo()
+20 -1
View File
@@ -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:
@@ -295,13 +295,13 @@ class ExplorerShowUIPopup(bpy.types.Operator):
bl_description = "Show Explorer UI to select element as attribute value or edit it."
bl_options = {"REGISTER", "UNDO"}
ifc_class: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
ifc_class: bpy.props.StringProperty()
"""Element IFC class."""
attribute_name: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
attribute_name: bpy.props.StringProperty()
"""IFC class attribute name."""
data_path: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
data_path: bpy.props.StringProperty()
"""Full data path"""
preselect_ifc_id: bpy.props.IntProperty(options={"SKIP_SAVE"}) # pyright: ignore[reportRedeclaration]
preselect_ifc_id: bpy.props.IntProperty(options={"SKIP_SAVE"})
"""IFC id to preselect in the popup."""
if TYPE_CHECKING:
@@ -41,7 +41,7 @@ class BIMAttributeProperties(PropertyGroup):
class ExplorerEntity(PropertyGroup):
ifc_definition_id: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
ifc_definition_id: bpy.props.IntProperty()
if TYPE_CHECKING:
ifc_definition_id: int
@@ -60,7 +60,7 @@ class BIMExplorerProperties(PropertyGroup):
self.property_unset("editing_entity_id")
self.entity_attributes.clear()
is_loaded: BoolProperty( # pyright: ignore[reportRedeclaration]
is_loaded: BoolProperty(
name="Toggle Explorer UI",
update=update_is_loaded,
)
@@ -76,15 +76,15 @@ class BIMExplorerProperties(PropertyGroup):
def update_ifc_class(self, context: object) -> None:
tool.Attribute.refresh_uilist_entities()
ifc_class: EnumProperty( # pyright: ignore[reportRedeclaration]
ifc_class: EnumProperty(
name="IFC Class To Search",
items=get_ifc_class,
update=update_ifc_class,
)
entities: CollectionProperty(type=ExplorerEntity) # pyright: ignore[reportRedeclaration]
active_entity_index: IntProperty() # pyright: ignore[reportRedeclaration]
editing_entity_id: IntProperty() # pyright: ignore[reportRedeclaration]
entity_attributes: CollectionProperty(type=Attribute) # pyright: ignore[reportRedeclaration]
entities: CollectionProperty(type=ExplorerEntity)
active_entity_index: IntProperty()
editing_entity_id: IntProperty()
entity_attributes: CollectionProperty(type=Attribute)
if TYPE_CHECKING:
is_loaded: bool
+4 -10
View File
@@ -201,16 +201,10 @@ class ExecuteIfcClash(bpy.types.Operator, ExportHelper):
"ALT+click to run a quick clash without selecting a file to save."
)
filter_glob: bpy.props.StringProperty( # pyright: ignore[reportRedeclaration]
default="*.bcf;*.json", options={"HIDDEN"}
)
format: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
name="Format", items=[(i, i, "") for i in ("bcf", "json")]
)
filepath: bpy.props.StringProperty( # pyright: ignore[reportRedeclaration]
subtype="FILE_PATH", options={"SKIP_SAVE"}
)
quick_clash: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
filter_glob: bpy.props.StringProperty(default="*.bcf;*.json", options={"HIDDEN"})
format: bpy.props.EnumProperty(name="Format", items=[(i, i, "") for i in ("bcf", "json")])
filepath: bpy.props.StringProperty(subtype="FILE_PATH", options={"SKIP_SAVE"})
quick_clash: bpy.props.BoolProperty(
options={"SKIP_SAVE"},
)
+4 -4
View File
@@ -37,12 +37,12 @@ from bonsai.bim.prop import BIMFilterGroup, StrProperty
class ClashSource(PropertyGroup):
name: StringProperty( # pyright: ignore[reportRedeclaration]
name: StringProperty(
name="File",
description="Absolute filepath to existing .ifc file to use as a clash source.",
)
filter_groups: CollectionProperty(type=BIMFilterGroup, name="Filter Groups") # pyright: ignore[reportRedeclaration]
mode: EnumProperty( # pyright: ignore[reportRedeclaration]
filter_groups: CollectionProperty(type=BIMFilterGroup, name="Filter Groups")
mode: EnumProperty(
items=[
("a", "All Elements", "All elements will be used for clashing"),
("i", "Include", "Only the selected elements are included for clashing"),
@@ -62,7 +62,7 @@ class Clash(PropertyGroup):
b_global_id: StringProperty(name="B")
a_name: StringProperty(name="A Name")
b_name: StringProperty(name="B Name")
clash_type: EnumProperty( # pyright: ignore[reportRedeclaration]
clash_type: EnumProperty(
name="Clash Type",
items=tuple((i, i, "") for i in CLASH_TYPE_ITEMS),
)
@@ -87,7 +87,7 @@ class CopyCostSchedule(bpy.types.Operator, tool.Ifc.Operator):
bl_label = "Copy Cost Schedule"
bl_description = "Create a duplicate of the provided cost schedule."
bl_options = {"REGISTER", "UNDO"}
cost_schedule: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
cost_schedule: bpy.props.IntProperty()
if TYPE_CHECKING:
cost_schedule: int
@@ -260,14 +260,14 @@ class CreateAllShapes(bpy.types.Operator):
)
bl_options = {"REGISTER"}
geometry_library: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
geometry_library: bpy.props.EnumProperty(
name="Geometry Library",
description="Geometry library to use for testing shape creation.",
items=[(i, i, "") for i in get_args(ifcopenshell.geom.GEOMETRY_LIBRARY)],
# By default use the same library as used for importing ifc project.
default="hybrid-cgal-simple-opencascade",
)
custom_geometry_library: bpy.props.StringProperty( # pyright: ignore[reportRedeclaration]
custom_geometry_library: bpy.props.StringProperty(
name="Custom Geometry Library",
description="Provide a custom geometry library name, will override the 'geometry library' property.",
)
@@ -781,7 +781,7 @@ class PurgeUnusedObjects(bpy.types.Operator, tool.Ifc.Operator):
bl_label = "Purge Unused Objects"
bl_options = {"REGISTER", "UNDO"}
object_type: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
object_type: bpy.props.EnumProperty(
name="Object Type",
items=((s, s.capitalize(), "") for s in get_args(tool.Debug.PurgeMergeObjectType)),
)
@@ -827,7 +827,7 @@ class MergeIdenticalObjects(bpy.types.Operator, tool.Ifc.Operator):
)
bl_options = {"REGISTER", "UNDO"}
object_type: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
object_type: bpy.props.EnumProperty(
name="Object Type",
items=((s, s.capitalize(), "") for s in get_args(tool.Debug.PurgeMergeObjectType)),
)
@@ -1073,7 +1073,7 @@ class ChangeLogLevel(bpy.types.Operator):
bl_options = {"REGISTER"}
bl_description = "Change general log level across all Python code in Blender"
log_level: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
log_level: bpy.props.EnumProperty(
name="Log Level",
items=[(i, i, "") for i in get_args(LogLevelType)],
default="WARNING",
@@ -246,17 +246,17 @@ class CreateDrawing(bpy.types.Operator):
+ "Add the CTRL modifier to optionally open drawings to view them as\n"
+ "they are created"
)
print_all: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
print_all: bpy.props.BoolProperty(
name="Print All",
default=False,
options={"SKIP_SAVE"},
)
open_viewer: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
open_viewer: bpy.props.BoolProperty(
name="Open in Viewer",
default=False,
options={"SKIP_SAVE"},
)
sync: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
sync: bpy.props.BoolProperty(
name="Sync Before Creating Drawing",
description="Could save some time if you're sure IFC and current Blender session are already in sync",
default=True,
@@ -2322,14 +2322,14 @@ class ActivateDrawingBase(tool.Ifc.Operator):
+ "SHIFT+CLICK to load a quick preview of the drawing view"
)
drawing: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
should_view_from_camera: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
drawing: bpy.props.IntProperty()
should_view_from_camera: bpy.props.BoolProperty(
name="Should View From Camera",
description="Move view to the activated drawing's camera position.",
default=True,
options={"SKIP_SAVE"},
)
use_quick_preview: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
use_quick_preview: bpy.props.BoolProperty(
name="Use Quick Preview",
description="Just move the camera to the drawing view, without loading anything else.",
default=False,
@@ -3635,14 +3635,12 @@ class ToggleTargetView(bpy.types.Operator):
bl_label = "Toggle Target View"
bl_options = {"REGISTER", "UNDO"}
target_view: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
toggle_all: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
target_view: bpy.props.StringProperty()
toggle_all: bpy.props.BoolProperty(
default=False,
options={"SKIP_SAVE"},
)
option: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
items=[(i, i, "") for i in get_args(ToggleOption)]
)
option: bpy.props.EnumProperty(items=[(i, i, "") for i in get_args(ToggleOption)])
if TYPE_CHECKING:
target_view: str
+2 -2
View File
@@ -860,13 +860,13 @@ class BIMTextProperties(PropertyGroup):
is_editing: BoolProperty(name="Is Editing", default=False)
literals: CollectionProperty(name="Literals", type=LiteralProps)
newline_at: IntProperty(name="Newline At")
symbol: EnumProperty( # pyright: ignore[reportRedeclaration]
symbol: EnumProperty(
name="Symbol",
description="Symbol from symbols.svg to use for this text.",
items=[(s, s, "") for s in ["NO SYMBOL", "CUSTOM SYMBOL"] + tool.Drawing.DEFAULT_SYMBOLS],
default="NO SYMBOL",
)
custom_symbol: StringProperty( # pyright: ignore[reportRedeclaration]
custom_symbol: StringProperty(
name="Custom Symbol",
description="Non-default symbol to use for this text.",
)
@@ -85,7 +85,7 @@ class EditObjectPlacement(bpy.types.Operator, tool.Ifc.Operator):
class OverrideMeshSeparate(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.override_mesh_separate"
bl_label = "IFC Mesh Separate"
blender_op = bpy.ops.mesh.separate.get_rna_type()
blender_op = bpy.ops.mesh.separate.get_rna_type() # ty: ignore[missing-argument]
bl_description = blender_op.description + ".\nAlso makes sure changes are in sync with IFC."
bl_options = {"REGISTER", "UNDO"}
blender_type_prop = blender_op.properties["type"]
@@ -246,7 +246,7 @@ class OverrideMeshSeparate(bpy.types.Operator, tool.Ifc.Operator):
class OverrideOriginSet(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.override_origin_set"
blender_op = bpy.ops.object.origin_set.get_rna_type()
blender_op = bpy.ops.object.origin_set.get_rna_type() # ty: ignore[missing-argument]
bl_label = "IFC Origin Set"
bl_description = (
blender_op.description + ".\nAlso makes sure changes are in sync with IFC (operator works only on IFC objects)"
@@ -801,7 +801,7 @@ def calc_delete_is_batch(ifc_file: ifcopenshell.file, context: bpy.types.Context
class OverrideDelete(bpy.types.Operator):
bl_idname = "bim.override_object_delete"
bl_label = "IFC Delete"
blender_op = bpy.ops.object.delete.get_rna_type()
blender_op = bpy.ops.object.delete.get_rna_type() # ty: ignore[missing-argument]
bl_description = (
blender_op.description
+ ".\nAlso makes sure changes in sync with IFC."
@@ -821,7 +821,7 @@ class OverrideDelete(bpy.types.Operator):
def poll(cls, context):
# Match `object.delete` poll for consistency.
# `object.delete` poll just checks for OBJECT mode.
poll = bpy.ops.object.delete.poll()
poll = bpy.ops.object.delete.poll() # ty: ignore[missing-argument]
if poll:
return True
cls.poll_message_set("Only available in OBJECT mode")
@@ -1045,7 +1045,7 @@ class SelectedIdsData(NamedTuple):
class OverrideOutlinerDelete(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.override_outliner_delete"
bl_label = "IFC Delete"
blender_op = bpy.ops.outliner.delete.get_rna_type()
blender_op = bpy.ops.outliner.delete.get_rna_type() # ty: ignore[missing-argument]
bl_description = (
blender_op.description
+ ".\nAlso makes sure changes in sync with IFC."
@@ -1060,7 +1060,7 @@ class OverrideOutlinerDelete(bpy.types.Operator, tool.Ifc.Operator):
def poll(cls, context) -> bool:
# Match `outliner.delete` poll for consistency.
# `outliner.delete` just checks `area.type` == `OUTLINER`.
poll = bpy.ops.outliner.delete.poll()
poll = bpy.ops.outliner.delete.poll() # ty: ignore[missing-argument]
if poll:
return True
cls.poll_message_set("Only available from Outliner.")
@@ -1164,7 +1164,7 @@ class OverrideDuplicateMove(bpy.types.Operator):
def poll(cls, context) -> bool:
# Match `object.duplicate_move` poll for consistency.
# `object.duplicate_move` poll checks for OBJECT mode.
poll = bpy.ops.object.duplicate_move.poll()
poll = bpy.ops.object.duplicate_move.poll() # ty: ignore[missing-argument]
if poll:
return True
cls.poll_message_set("Only available in OBJECT mode")
@@ -1908,7 +1908,7 @@ class RefreshLinkedAggregate(bpy.types.Operator, tool.Ifc.Operator):
class OverrideJoin(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.override_object_join"
bl_label = "IFC Join"
blender_op = bpy.ops.mesh.separate.get_rna_type()
blender_op = bpy.ops.mesh.separate.get_rna_type() # ty: ignore[missing-argument]
bl_description = (
blender_op.description
+ ".\nAlso makes sure changes are in sync with IFC."
@@ -1926,7 +1926,7 @@ class OverrideJoin(bpy.types.Operator, tool.Ifc.Operator):
@classmethod
def poll(cls, context):
if not bpy.ops.object.join.poll():
if not bpy.ops.object.join.poll(): # ty: ignore[missing-argument]
cls.poll_message_set("Active object is not EDITable.")
return False
if not context.selected_editable_objects:
@@ -43,11 +43,11 @@ class ToggleGroup(bpy.types.Operator, tool.Ifc.Operator):
bl_label = "Toggle Group"
bl_options = {"REGISTER", "UNDO"}
ifc_definition_id: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
group_type: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
ifc_definition_id: bpy.props.IntProperty()
group_type: bpy.props.EnumProperty(
items=[(i, i, "") for i in get_args(tool.Group.GroupType)],
)
option: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
option: bpy.props.EnumProperty(
items=[(i, i, "") for i in get_args(tool.Group.ToggleOption)],
)
@@ -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,
+64 -79
View File
@@ -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):
+155 -28
View File
@@ -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()
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:
@@ -422,7 +515,7 @@ class RunGitDiff(bpy.types.Operator):
)
bl_options = set()
save_to_temp: bpy.props.BoolProperty(options={"SKIP_SAVE"}) # pyright: ignore[reportRedeclaration]
save_to_temp: bpy.props.BoolProperty(options={"SKIP_SAVE"})
if TYPE_CHECKING:
save_to_temp: bool
@@ -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")
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"}
+13 -19
View File
@@ -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
+62 -26
View File
@@ -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):
@@ -272,21 +272,21 @@ class RadianceRender(bpy.types.Operator):
+ '''" map_u map_v
0
1 0.5
# This is a multiplier to colour balance the env map
# In this case, it provides a rough ground luminance from 3k-5k
env_map colorfunc env_colour
4 100 100 100 .
0
0
# .37 .57 1.5 is measured from a HDRI image
# It is multiplied by a factor such that grey(r,g,b) = 1
skyfunc colorfunc sky_colour
4 .64 .99 2.6 .
0
0
void mixpict composite
7 env_colour sky_colour grey "'''
+ hdr_mask_path
@@ -295,22 +295,22 @@ void mixpict composite
+ """" map_u map_v
0
2 0.5 1
composite glow env_map_glow
0
0
4 1 1 1 0
env_map_glow source sky
0
0
4 0 0 1 180
env_colour glow ground_glow
0
0
4 1 1 1 0
ground_glow source ground
0
0
@@ -566,7 +566,7 @@ class LightPickCoordinates(bpy.types.Operator):
)
bl_options = {"REGISTER", "UNDO"}
use_current_location: bpy.props.BoolProperty(options={"SKIP_SAVE"}) # pyright: ignore[reportRedeclaration]
use_current_location: bpy.props.BoolProperty(options={"SKIP_SAVE"})
if TYPE_CHECKING:
use_current_location: bool
@@ -136,7 +136,7 @@ class SplitAlongEdge(bpy.types.Operator, tool.Ifc.Operator):
"Will unassign element from a type if type has a representation."
)
bl_options = {"REGISTER", "UNDO"}
mode: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
mode: bpy.props.EnumProperty(
default="BOOLEAN",
items=tuple((i, i, "") for i in get_args(SplitAlongEdgeMode)),
)
@@ -359,7 +359,7 @@ class ConfirmQuickFavoriteOperator(bpy.types.Operator):
bl_idname = "bim.confirm_quick_favorite_operator"
bl_label = "Confirm Operator"
bl_options = {"REGISTER", "UNDO"}
index: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
index: bpy.props.IntProperty()
if TYPE_CHECKING:
index: int
@@ -452,10 +452,8 @@ class MoveQuickFavoritesItem(bpy.types.Operator):
bl_idname = "bim.move_quick_favorites_item"
bl_label = "Move Quick Favorites Item"
bl_options = {"REGISTER", "UNDO"}
index: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
direction: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
items=[("UP", "Up", ""), ("DOWN", "Down", "")]
)
index: bpy.props.IntProperty()
direction: bpy.props.EnumProperty(items=[("UP", "Up", ""), ("DOWN", "Down", "")])
if TYPE_CHECKING:
index: int
@@ -474,7 +472,7 @@ class RemoveQuickFavoritesItem(bpy.types.Operator):
bl_idname = "bim.remove_quick_favorites_item"
bl_label = "Remove Quick Favorites Item"
bl_options = {"REGISTER", "UNDO"}
index: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
index: bpy.props.IntProperty()
if TYPE_CHECKING:
index: int
+21 -21
View File
@@ -36,9 +36,9 @@ QuickFavoriteValueType = Literal["float_value", "bool_value", "int_value", "stri
class QuickFavoriteEnumItem(PropertyGroup):
name: StringProperty(name="Name", default="") # pyright: ignore[reportRedeclaration]
display_name: StringProperty(name="Display Name", default="") # pyright: ignore[reportRedeclaration]
description: StringProperty(name="Description", default="") # pyright: ignore[reportRedeclaration]
name: StringProperty(name="Name", default="")
display_name: StringProperty(name="Display Name", default="")
description: StringProperty(name="Description", default="")
if TYPE_CHECKING:
name: str
@@ -51,19 +51,19 @@ def get_enum_items(self: "QuickFavoriteProperty", context: bpy.types.Context | N
class QuickFavoriteProperty(PropertyGroup):
name: StringProperty(name="Name", default="") # pyright: ignore[reportRedeclaration]
display_name: StringProperty(name="Display Name", default="") # pyright: ignore[reportRedeclaration]
value_prop: EnumProperty( # pyright: ignore[reportRedeclaration]
name: StringProperty(name="Name", default="")
display_name: StringProperty(name="Display Name", default="")
value_prop: EnumProperty(
name="Value Prop",
items=tuple((v, v, "") for v in get_args(QuickFavoriteValueType)),
)
string_value: StringProperty(name="String Value", default="") # pyright: ignore[reportRedeclaration]
float_value: FloatProperty(name="Float Value", default=0.0) # pyright: ignore[reportRedeclaration]
int_value: IntProperty(name="Int Value", default=0) # pyright: ignore[reportRedeclaration]
bool_value: BoolProperty(name="Bool Value", default=False) # pyright: ignore[reportRedeclaration]
enum_value: EnumProperty(name="Enum Value", items=get_enum_items) # pyright: ignore[reportRedeclaration]
enum_items: CollectionProperty(type=QuickFavoriteEnumItem) # pyright: ignore[reportRedeclaration]
is_active: BoolProperty( # pyright: ignore[reportRedeclaration]
string_value: StringProperty(name="String Value", default="")
float_value: FloatProperty(name="Float Value", default=0.0)
int_value: IntProperty(name="Int Value", default=0)
bool_value: BoolProperty(name="Bool Value", default=False)
enum_value: EnumProperty(name="Enum Value", items=get_enum_items)
enum_items: CollectionProperty(type=QuickFavoriteEnumItem)
is_active: BoolProperty(
name="Is Active",
description="Only active properties will be added to the operator when invoked from Quick Favorites",
default=False,
@@ -100,20 +100,20 @@ def get_operator_suggestions(self: "QuickFavoritesItem", context: bpy.types.Cont
class QuickFavoritesItem(PropertyGroup):
is_expanded: BoolProperty(name="Is Expanded", default=False) # pyright: ignore[reportRedeclaration]
search: StringProperty( # pyright: ignore[reportRedeclaration]
is_expanded: BoolProperty(name="Is Expanded", default=False)
search: StringProperty(
name="Search",
default="",
search=get_operator_suggestions,
# Resetting `search_options`, allowing users only to use suggestions.
search_options=set(),
)
properties: CollectionProperty(type=QuickFavoriteProperty) # pyright: ignore[reportRedeclaration]
operator_id: StringProperty( # pyright: ignore[reportRedeclaration]
properties: CollectionProperty(type=QuickFavoriteProperty)
operator_id: StringProperty(
name="Operator ID",
default="",
)
label: StringProperty( # pyright: ignore[reportRedeclaration]
label: StringProperty(
name="Label",
description="Label that will be used in Quick Favorites for this operator",
default="",
@@ -139,15 +139,15 @@ class QuickFavoritesItem(PropertyGroup):
class BIMMiscProperties(PropertyGroup):
total_storeys: IntProperty( # pyright: ignore[reportRedeclaration]
total_storeys: IntProperty(
name="Total Storeys",
description="Number of storeys above object's storey to take into account for resizing",
default=1,
)
override_colour: FloatVectorProperty( # pyright: ignore[reportRedeclaration]
override_colour: FloatVectorProperty(
name="Override Colour", subtype="COLOR", default=(1, 0, 0, 1), min=0.0, max=1.0, size=4
)
quick_favorites: CollectionProperty(type=QuickFavoritesItem) # pyright: ignore[reportRedeclaration]
quick_favorites: CollectionProperty(type=QuickFavoritesItem)
if TYPE_CHECKING:
total_storeys: int
@@ -545,7 +545,7 @@ class ChangeTypePage(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.change_type_page"
bl_label = "Change Type Page"
bl_options = {"REGISTER"}
page: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
page: bpy.props.IntProperty()
if TYPE_CHECKING:
page: int
@@ -271,7 +271,7 @@ class ExtendProfile(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.extend_profile"
bl_label = "Extend Profile"
bl_options = {"REGISTER", "UNDO"}
join_type: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
join_type: bpy.props.EnumProperty(
items=[("-", "Unjoin", ""), ("L", "L", ""), ("V", "V", ""), ("T", "T", "")],
default="-",
)
+4 -4
View File
@@ -1729,20 +1729,20 @@ def poll_sverchok_nodes(self: "BIMExternalParametricGeometryProperties", node_tr
class BIMExternalParametricGeometryProperties(bpy.types.PropertyGroup):
is_editing: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
is_editing: bpy.props.BoolProperty(
name="Is Editing Paramteric Geometry",
description="Toggle editing parametric geometry.",
default=False,
update=update_is_editing,
)
geometry_source: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
geometry_source: bpy.props.EnumProperty(
name="Geometry Source",
items=[
("GEONODES", "Geometry Nodes", ""),
("IFCSVERCHOK", "IFC Sverchok", ""),
],
)
geo_nodes: bpy.props.PointerProperty( # pyright: ignore[reportRedeclaration]
geo_nodes: bpy.props.PointerProperty(
name="Geometry Nodes",
description="Geometry nodes tree to use as a source for representation.",
type=bpy.types.GeometryNodeTree,
@@ -1750,7 +1750,7 @@ class BIMExternalParametricGeometryProperties(bpy.types.PropertyGroup):
poll=lambda self, node_tree: not node_tree.name.startswith("BBIM_EPG"),
)
sverchok_nodes: bpy.props.PointerProperty( # pyright: ignore[reportRedeclaration]
sverchok_nodes: bpy.props.PointerProperty(
name="Sverchok Nodes",
description="Sverchok node tree to use as a source for representation.",
type=bpy.types.NodeTree,
@@ -101,7 +101,7 @@ class NestDecorator:
cls.is_installed = False
def dotted_line_shader(self):
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") # ty:ignore[too-many-positional-arguments]
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface")
vert_out.smooth("FLOAT", "v_ArcLength")
shader_info = gpu.types.GPUShaderCreateInfo()
+27 -27
View File
@@ -33,7 +33,7 @@ class EnableEditingPerson(bpy.types.Operator):
bl_idname = "bim.enable_editing_person"
bl_label = "Enable Editing Person"
bl_options = {"REGISTER", "UNDO"}
person: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
person: bpy.props.IntProperty()
if TYPE_CHECKING:
person: int
@@ -75,7 +75,7 @@ class RemovePerson(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.remove_person"
bl_label = "Remove Person"
bl_options = {"REGISTER", "UNDO"}
person: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
person: bpy.props.IntProperty()
if TYPE_CHECKING:
person: int
@@ -88,7 +88,7 @@ class AddPersonAttribute(bpy.types.Operator):
bl_idname = "bim.add_person_attribute"
bl_label = "Add Person Attribute"
bl_options = {"REGISTER", "UNDO"}
name: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
name: bpy.props.EnumProperty(
items=tuple((i, i, "") for i in get_args(tool.Owner.PersonAttributeType)),
)
@@ -104,10 +104,10 @@ class RemovePersonAttribute(bpy.types.Operator):
bl_idname = "bim.remove_person_attribute"
bl_label = "Remove Person Attribute"
bl_options = {"REGISTER", "UNDO"}
name: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
name: bpy.props.EnumProperty(
items=tuple((i, i, "") for i in get_args(tool.Owner.PersonAttributeType)),
)
id: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
id: bpy.props.IntProperty()
if TYPE_CHECKING:
name: tool.Owner.PersonAttributeType # pyright: ignore[reportIncompatibleVariableOverride]
@@ -122,7 +122,7 @@ class EnableEditingRole(bpy.types.Operator):
bl_idname = "bim.enable_editing_role"
bl_label = "Enable Editing Role"
bl_options = {"REGISTER", "UNDO"}
role: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
role: bpy.props.IntProperty()
if TYPE_CHECKING:
role: int
@@ -146,7 +146,7 @@ class AddRole(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.add_role"
bl_label = "Add Role"
bl_options = {"REGISTER", "UNDO"}
parent: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
parent: bpy.props.IntProperty()
if TYPE_CHECKING:
parent: int
@@ -168,7 +168,7 @@ class RemoveRole(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.remove_role"
bl_label = "Remove Role"
bl_options = {"REGISTER", "UNDO"}
role: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
role: bpy.props.IntProperty()
if TYPE_CHECKING:
role: int
@@ -181,8 +181,8 @@ class AddAddress(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.add_address"
bl_label = "Add Address"
bl_options = {"REGISTER", "UNDO"}
parent: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
ifc_class: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
parent: bpy.props.IntProperty()
ifc_class: bpy.props.EnumProperty(
items=tuple((i, i, "") for i in get_args(ADDRESS_TYPE)),
)
@@ -198,7 +198,7 @@ class AddAddressAttribute(bpy.types.Operator):
bl_idname = "bim.add_address_attribute"
bl_label = "Add Address Attribute"
bl_options = {"REGISTER", "UNDO"}
name: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
name: bpy.props.EnumProperty(
items=tuple((i, i, "") for i in get_args(tool.Owner.AddressAttributeType)),
)
@@ -214,10 +214,10 @@ class RemoveAddressAttribute(bpy.types.Operator):
bl_idname = "bim.remove_address_attribute"
bl_label = "Remove Address Attribute"
bl_options = {"REGISTER", "UNDO"}
name: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
name: bpy.props.EnumProperty(
items=tuple((i, i, "") for i in get_args(tool.Owner.AddressAttributeType)),
)
id: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
id: bpy.props.IntProperty()
if TYPE_CHECKING:
name: tool.Owner.AddressAttributeType # pyright: ignore[reportIncompatibleVariableOverride]
@@ -232,7 +232,7 @@ class EnableEditingAddress(bpy.types.Operator):
bl_idname = "bim.enable_editing_address"
bl_label = "Enable Editing Address"
bl_options = {"REGISTER", "UNDO"}
address: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
address: bpy.props.IntProperty()
if TYPE_CHECKING:
address: int
@@ -265,7 +265,7 @@ class RemoveAddress(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.remove_address"
bl_label = "Remove Address"
bl_options = {"REGISTER", "UNDO"}
address: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
address: bpy.props.IntProperty()
if TYPE_CHECKING:
address: int
@@ -278,7 +278,7 @@ class EnableEditingOrganisation(bpy.types.Operator):
bl_idname = "bim.enable_editing_organisation"
bl_label = "Enable Editing Organisation"
bl_options = {"REGISTER", "UNDO"}
organisation: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
organisation: bpy.props.IntProperty()
if TYPE_CHECKING:
organisation: int
@@ -320,7 +320,7 @@ class RemoveOrganisation(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.remove_organisation"
bl_label = "Remove Organisation"
bl_options = {"REGISTER", "UNDO"}
organisation: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
organisation: bpy.props.IntProperty()
if TYPE_CHECKING:
organisation: int
@@ -333,8 +333,8 @@ class AddPersonAndOrganisation(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.add_person_and_organisation"
bl_label = "Add Person And Organisation"
bl_options = {"REGISTER", "UNDO"}
person: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
organisation: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
person: bpy.props.IntProperty()
organisation: bpy.props.IntProperty()
if TYPE_CHECKING:
person: int
@@ -350,7 +350,7 @@ class RemovePersonAndOrganisation(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.remove_person_and_organisation"
bl_label = "Remove Person And Organisation"
bl_options = {"REGISTER", "UNDO"}
person_and_organisation: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
person_and_organisation: bpy.props.IntProperty()
if TYPE_CHECKING:
person_and_organisation: int
@@ -365,7 +365,7 @@ class SetUser(bpy.types.Operator):
bl_idname = "bim.set_user"
bl_label = "Set User"
bl_options = {"REGISTER", "UNDO"}
user: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
user: bpy.props.IntProperty()
if TYPE_CHECKING:
user: int
@@ -401,7 +401,7 @@ class EnableEditingActor(bpy.types.Operator):
bl_idname = "bim.enable_editing_actor"
bl_label = "Enable Editing Actor"
bl_options = {"REGISTER", "UNDO"}
actor: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
actor: bpy.props.IntProperty()
if TYPE_CHECKING:
actor: int
@@ -434,7 +434,7 @@ class RemoveActor(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.remove_actor"
bl_label = "Remove Actor"
bl_options = {"REGISTER", "UNDO"}
actor: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
actor: bpy.props.IntProperty()
if TYPE_CHECKING:
actor: int
@@ -447,7 +447,7 @@ class AssignActor(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.assign_actor"
bl_label = "Assign Actor"
bl_options = {"REGISTER", "UNDO"}
actor: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
actor: bpy.props.IntProperty()
if TYPE_CHECKING:
actor: int
@@ -462,7 +462,7 @@ class UnassignActor(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.unassign_actor"
bl_label = "Unassign Actor"
bl_options = {"REGISTER", "UNDO"}
actor: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
actor: bpy.props.IntProperty()
if TYPE_CHECKING:
actor: int
@@ -481,7 +481,7 @@ class RemoveApplication(bpy.types.Operator, tool.Ifc.Operator):
"Remove provided IfcApplication."
"\n\nFor safety will only work on applications without inverses (they are typically marked as '(unused)'."
)
application_id: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
application_id: bpy.props.IntProperty()
if TYPE_CHECKING:
application_id: int
@@ -525,7 +525,7 @@ class EnableEditingApplication(bpy.types.Operator):
bl_idname = "bim.enable_editing_application"
bl_label = "Enable Editing Application"
bl_options = {"REGISTER", "UNDO"}
application_id: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
application_id: bpy.props.IntProperty()
if TYPE_CHECKING:
application_id: int
@@ -86,9 +86,7 @@ class NewProject(bpy.types.Operator):
bl_label = "New Project"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Start a new IFC project in a fresh session"
preset: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
items=[(i, i, "") for i in get_args(PresetType)]
)
preset: bpy.props.EnumProperty(items=[(i, i, "") for i in get_args(PresetType)])
if TYPE_CHECKING:
preset: PresetType
@@ -178,13 +176,9 @@ class SelectLibraryFile(bpy.types.Operator, IFCFileSelector, ImportHelper):
bl_description = (
"Select an IFC file that can be used as a library.\n\nALT+click to reload the current loaded library file."
)
filter_glob: bpy.props.StringProperty(
default="*.ifc;*.ifczip;*.ifcxml", options={"HIDDEN"}
) # pyright: ignore[reportRedeclaration]
append_all: bpy.props.BoolProperty(default=False) # pyright: ignore[reportRedeclaration]
use_relative_path: bpy.props.BoolProperty(
name="Use Relative Path", default=False
) # pyright: ignore[reportRedeclaration]
filter_glob: bpy.props.StringProperty(default="*.ifc;*.ifczip;*.ifcxml", options={"HIDDEN"})
append_all: bpy.props.BoolProperty(default=False)
use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=False)
if TYPE_CHECKING:
filter_glob: str
@@ -568,7 +562,7 @@ class AppendLibraryElementByQuery(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.append_library_element_by_query"
bl_label = "Append Library Element By Query"
query: bpy.props.StringProperty(name="Query") # pyright: ignore[reportRedeclaration]
query: bpy.props.StringProperty(name="Query")
if TYPE_CHECKING:
query: str
@@ -600,11 +594,9 @@ class AppendLibraryElement(bpy.types.Operator, tool.Ifc.Operator):
"Append element to the current project.\n\n"
"ALT+CLICK to skip reusing materials, profiles, styles based on their name (may result in duplicates)"
)
definition: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
prop_index: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
assume_unique_by_name: bpy.props.BoolProperty(
name="Assume Unique By Name", default=True, options={"SKIP_SAVE"}
) # pyright: ignore[reportRedeclaration]
definition: bpy.props.IntProperty()
prop_index: bpy.props.IntProperty()
assume_unique_by_name: bpy.props.BoolProperty(name="Assume Unique By Name", default=True, options={"SKIP_SAVE"})
if TYPE_CHECKING:
definition: int
@@ -959,28 +951,24 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
bl_label = "Load Project"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Load an existing IFC project"
filepath: bpy.props.StringProperty(
subtype="FILE_PATH", options={"SKIP_SAVE"}
) # pyright: ignore[reportRedeclaration]
filter_glob: bpy.props.StringProperty(
default="*.ifc;*.ifczip;*.ifcxml;*.ifcsqlite", options={"HIDDEN"}
) # pyright: ignore[reportRedeclaration]
is_advanced: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
filepath: bpy.props.StringProperty(subtype="FILE_PATH", options={"SKIP_SAVE"})
filter_glob: bpy.props.StringProperty(default="*.ifc;*.ifczip;*.ifcxml;*.ifcsqlite", options={"HIDDEN"})
is_advanced: bpy.props.BoolProperty(
name="Enable Advanced Mode",
description="Load IFC file with advanced settings. Checking this option will skip loading IFC file and will open advanced load settings",
default=False,
)
use_relative_path: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
use_relative_path: bpy.props.BoolProperty(
name="Use Relative Path",
description="Store the IFC project path relative to the .blend file. Requires .blend file to be saved",
default=False,
)
should_start_fresh_session: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
should_start_fresh_session: bpy.props.BoolProperty(
name="Should Start Fresh Session",
description="Clear current Blender session before loading IFC. Not supported with 'Use Relative Path' option",
default=True,
)
import_without_ifc_data: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
import_without_ifc_data: bpy.props.BoolProperty(
name="Import Without IFC Data",
description=(
"Import IFC objects as Blender objects without any IFC metadata and authoring capabilities."
@@ -988,9 +976,7 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
),
default=False,
)
use_detailed_tooltip: bpy.props.BoolProperty(
default=False, options={"HIDDEN"}
) # pyright: ignore[reportRedeclaration]
use_detailed_tooltip: bpy.props.BoolProperty(default=False, options={"HIDDEN"})
filename_ext = ".ifc"
if TYPE_CHECKING:
@@ -1300,7 +1286,7 @@ class ToggleFilterCategories(bpy.types.Operator):
bl_idname = "bim.toggle_filter_categories"
bl_label = "Toggle Filter Categories"
bl_options = {"REGISTER", "UNDO"}
should_select: bpy.props.BoolProperty(name="Should Select", default=True) # pyright: ignore[reportRedeclaration]
should_select: bpy.props.BoolProperty(name="Should Select", default=True)
if TYPE_CHECKING:
should_select: bool
@@ -1327,7 +1313,7 @@ class LinkIfc(bpy.types.Operator, ImportHelper, tool.Ifc.Operator):
default=False,
)
use_cache: bpy.props.BoolProperty(name="Use Cache", default=True)
query: bpy.props.StringProperty( # pyright: ignore[reportRedeclaration]
query: bpy.props.StringProperty(
name="Query",
description=(
"Custom selector query to use to load element from a linked model. E.g. 'IfcElement'.\n\n"
@@ -1404,7 +1390,7 @@ class UnlinkIfc(bpy.types.Operator, tool.Ifc.Operator):
bl_options = {"REGISTER", "UNDO"}
bl_description = "Remove the selected file from the link list"
link_index: bpy.props.IntProperty(name="Link Index") # pyright: ignore[reportRedeclaration]
link_index: bpy.props.IntProperty(name="Link Index")
if TYPE_CHECKING:
link_index: int
@@ -1428,7 +1414,7 @@ class UnloadLink(bpy.types.Operator, tool.Ifc.Operator):
bl_options = {"REGISTER", "UNDO"}
bl_description = "Unload the selected linked file"
link_index: bpy.props.IntProperty(name="Link Index") # pyright: ignore[reportRedeclaration]
link_index: bpy.props.IntProperty(name="Link Index")
if TYPE_CHECKING:
link_index: int
@@ -1454,9 +1440,9 @@ class LoadLink(bpy.types.Operator, tool.Ifc.Operator):
bl_options = {"REGISTER", "UNDO"}
bl_description = "Load the selected file"
link_index: bpy.props.IntProperty(name="Link Index") # pyright: ignore[reportRedeclaration]
use_cache: bpy.props.BoolProperty(name="Use Cache", default=True) # pyright: ignore[reportRedeclaration]
query: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
link_index: bpy.props.IntProperty(name="Link Index")
use_cache: bpy.props.BoolProperty(name="Use Cache", default=True)
query: bpy.props.StringProperty()
if TYPE_CHECKING:
link_index: int
@@ -1631,7 +1617,7 @@ class ReloadLink(bpy.types.Operator):
bl_options = {"REGISTER", "UNDO"}
bl_description = "Reload the selected file"
link_index: bpy.props.IntProperty(name="Link Index") # pyright: ignore[reportRedeclaration]
link_index: bpy.props.IntProperty(name="Link Index")
if TYPE_CHECKING:
link_index: int
@@ -1647,7 +1633,7 @@ class ToggleLinkSelectability(bpy.types.Operator):
bl_options = {"REGISTER", "UNDO"}
bl_description = "Toggle selectability"
link_index: bpy.props.IntProperty(name="Link Index") # pyright: ignore[reportRedeclaration]
link_index: bpy.props.IntProperty(name="Link Index")
if TYPE_CHECKING:
link_index: int
@@ -1679,8 +1665,8 @@ class ToggleLinkVisibility(bpy.types.Operator):
bl_options = {"REGISTER", "UNDO"}
bl_description = "Toggle visibility between SOLID and WIREFRAME"
link_index: bpy.props.IntProperty(name="Link Index") # pyright: ignore[reportRedeclaration]
mode: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
link_index: bpy.props.IntProperty(name="Link Index")
mode: bpy.props.EnumProperty(
name="Visibility Mode",
items=((i, i, "") for i in ("WIREFRAME", "VISIBLE")),
)
@@ -1821,7 +1807,7 @@ class SelectLinkHandle(bpy.types.Operator):
bl_options = {"REGISTER", "UNDO"}
bl_description = "Select link empty object handle"
link_index: bpy.props.IntProperty(name="Link Index") # pyright: ignore[reportRedeclaration]
link_index: bpy.props.IntProperty(name="Link Index")
if TYPE_CHECKING:
link_index: int
@@ -1843,7 +1829,7 @@ class SelectLinkedModelElement(bpy.types.Operator):
bl_options = {"REGISTER"}
bl_description = "Select an element in the currently selected linked model by providing GlobalId."
guid: bpy.props.StringProperty(name="GlobalId") # pyright: ignore[reportRedeclaration]
guid: bpy.props.StringProperty(name="GlobalId")
if TYPE_CHECKING:
guid: str
@@ -1882,21 +1868,11 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
bl_options = {"REGISTER", "UNDO"}
filename_ext = ".ifc"
supported_filexts = (".ifc", ".ifczip", ".ifcjson")
filter_glob: bpy.props.StringProperty(
default=";".join(f"*{ext}" for ext in supported_filexts), options={"HIDDEN"}
) # pyright: ignore[reportRedeclaration]
json_version: bpy.props.EnumProperty(
items=[("4", "4", ""), ("5a", "5a", "")], name="IFC JSON Version"
) # pyright: ignore[reportRedeclaration]
json_compact: bpy.props.BoolProperty(
name="Export Compact IFCJSON", default=False
) # pyright: ignore[reportRedeclaration]
should_save_as: bpy.props.BoolProperty(
name="Should Save As", default=False, options={"HIDDEN"}
) # pyright: ignore[reportRedeclaration]
use_relative_path: bpy.props.BoolProperty(
name="Use Relative Path", default=False
) # pyright: ignore[reportRedeclaration]
filter_glob: bpy.props.StringProperty(default=";".join(f"*{ext}" for ext in supported_filexts), options={"HIDDEN"})
json_version: bpy.props.EnumProperty(items=[("4", "4", ""), ("5a", "5a", "")], name="IFC JSON Version")
json_compact: bpy.props.BoolProperty(name="Export Compact IFCJSON", default=False)
should_save_as: bpy.props.BoolProperty(name="Should Save As", default=False, options={"HIDDEN"})
use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=False)
if TYPE_CHECKING:
filter_glob: str
@@ -2053,7 +2029,7 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper):
bl_description = "Operator is used to load a project .cache.blend to then link it to the IFC file."
bl_options = {"REGISTER", "UNDO"}
query: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
query: bpy.props.StringProperty()
"""See ``bim.link_ifc``."""
if TYPE_CHECKING:
@@ -2443,8 +2419,8 @@ class HideQueriedLinkedElement(bpy.types.Operator):
)
bl_options = {"REGISTER", "UNDO"}
unhide_all: bpy.props.BoolProperty(options={"SKIP_SAVE"}) # pyright: ignore[reportRedeclaration]
hide_all_except: bpy.props.BoolProperty(options={"SKIP_SAVE"}) # pyright: ignore[reportRedeclaration]
unhide_all: bpy.props.BoolProperty(options={"SKIP_SAVE"})
hide_all_except: bpy.props.BoolProperty(options={"SKIP_SAVE"})
if TYPE_CHECKING:
unhide_all: bool
@@ -2918,12 +2894,8 @@ class IFCFileHandlerOperator(bpy.types.Operator):
bl_label = "Import .ifc file"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
directory: bpy.props.StringProperty(
subtype="FILE_PATH", options={"SKIP_SAVE", "HIDDEN"}
) # pyright: ignore[reportRedeclaration]
files: bpy.props.CollectionProperty(
type=bpy.types.OperatorFileListElement, options={"SKIP_SAVE", "HIDDEN"}
) # pyright: ignore[reportRedeclaration]
directory: bpy.props.StringProperty(subtype="FILE_PATH", options={"SKIP_SAVE", "HIDDEN"})
files: bpy.props.CollectionProperty(type=bpy.types.OperatorFileListElement, options={"SKIP_SAVE", "HIDDEN"})
if TYPE_CHECKING:
directory: str
@@ -2978,7 +2950,7 @@ class MeasureTool(bpy.types.Operator, PolylineOperator):
bl_label = "Measure Tool"
bl_options = {"REGISTER", "UNDO"}
measure_type: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
measure_type: bpy.props.StringProperty()
if TYPE_CHECKING:
measure_type: str
@@ -3077,7 +3049,7 @@ class MeasureFaceAreaTool(bpy.types.Operator, PolylineOperator):
bl_label = "Measure Face Area Tool"
bl_options = {"REGISTER", "UNDO"}
measure_type: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
measure_type: bpy.props.StringProperty()
if TYPE_CHECKING:
measure_type: str
@@ -3379,7 +3351,7 @@ class LoadBlendMetadataAndIFC(bpy.types.Operator):
bl_idname = "bim.load_blend_metadata_and_ifc"
bl_label = "Load Blend Metadata and IFC"
bl_options = {"REGISTER", "UNDO"}
filepath: bpy.props.StringProperty(name="IFC File Path", default="") # pyright: ignore[reportRedeclaration]
filepath: bpy.props.StringProperty(name="IFC File Path", default="")
if TYPE_CHECKING:
filepath: str
+1 -1
View File
@@ -345,7 +345,7 @@ class BIMProjectProperties(PropertyGroup):
),
default=False,
)
should_cache: BoolProperty( # pyright: ignore[reportRedeclaration]
should_cache: BoolProperty(
name="Cache",
description=(
"Cache loaded geometry to .h5 file in your cache directory (see in preferences) "
@@ -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"
@@ -240,7 +240,7 @@ class CopyPropertyToSelection(bpy.types.Operator, tool.Ifc.Operator):
bl_label = "Copy Property To Selection"
bl_options = {"REGISTER", "UNDO"}
name: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
name: bpy.props.StringProperty()
if TYPE_CHECKING:
name: str
@@ -280,10 +280,10 @@ class BIM_OT_add_property_to_edit(bpy.types.Operator):
bl_label = "Add Property to Edit"
bl_idname = "bim.add_property_to_edit"
bl_options = {"REGISTER", "UNDO"}
option: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
option: bpy.props.EnumProperty(
items=[(t, t, "") for t in tool.Pset.BULK_OPERATION_TYPES],
)
index: bpy.props.IntProperty(default=-1) # pyright: ignore[reportRedeclaration]
index: bpy.props.IntProperty(default=-1)
if TYPE_CHECKING:
option: tool.Pset.BulkOperationType
@@ -307,9 +307,9 @@ class BIM_OT_remove_property_to_edit(bpy.types.Operator):
bl_label = "Remove Property from Editing"
bl_idname = "bim.remove_property_to_edit"
bl_options = {"REGISTER", "UNDO"}
index: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
index2: bpy.props.IntProperty(default=-1) # pyright: ignore[reportRedeclaration]
option: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
index: bpy.props.IntProperty()
index2: bpy.props.IntProperty(default=-1)
option: bpy.props.EnumProperty(
items=[(t, t, "") for t in tool.Pset.BULK_OPERATION_TYPES],
)
@@ -336,7 +336,7 @@ class BIM_OT_bulk_edit_clear_list(bpy.types.Operator):
bl_label = "Clear List of Properties"
bl_idname = "bim.pset_bulk_edit_clear_list"
bl_options = {"REGISTER", "UNDO"}
option: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
option: bpy.props.EnumProperty(
items=[(t, t, "") for t in tool.Pset.BULK_OPERATION_TYPES],
)
+3 -3
View File
@@ -368,9 +368,9 @@ class GlobalPsetProperties(PropertyGroup):
qto_filter: StringProperty(name="Qto Filter", options={"TEXTEDIT_UPDATE"})
# Bulk operations.
psets_to_delete: CollectionProperty(type=DeletePsetEntry) # pyright: ignore[reportRedeclaration]
psets_to_rename: CollectionProperty(type=RenamePropertyEntry) # pyright: ignore[reportRedeclaration]
psets_to_add_edit: CollectionProperty(type=AddEditPropertyEntry) # pyright: ignore[reportRedeclaration]
psets_to_delete: CollectionProperty(type=DeletePsetEntry)
psets_to_rename: CollectionProperty(type=RenamePropertyEntry)
psets_to_add_edit: CollectionProperty(type=AddEditPropertyEntry)
if TYPE_CHECKING:
pset_filter: str
@@ -799,7 +799,7 @@ class SelectQueryElements(Operator):
bl_description = "Select elements matching an provided selector query"
bl_options = {"REGISTER", "UNDO"}
query: StringProperty(name="Query") # pyright: ignore[reportRedeclaration]
query: StringProperty(name="Query")
if TYPE_CHECKING:
query: str
@@ -829,12 +829,12 @@ class SaveSearch(Operator, tool.Ifc.Operator):
# Extra item so it will be easy to select current text.
return [text] + SaveSearch.name_search_items
name: StringProperty( # pyright: ignore[reportRedeclaration]
name: StringProperty(
name="Name",
search=get_name_search_items,
search_options={"SORT"},
)
module: StringProperty() # pyright: ignore[reportRedeclaration]
module: StringProperty()
def update_use_all_ifcgroups(self, context: object = None) -> None:
ifc_file = tool.Ifc.get()
@@ -845,7 +845,7 @@ class SaveSearch(Operator, tool.Ifc.Operator):
}
self.name_search_items[:] = natsorted(groups)
use_all_ifcgroups: BoolProperty( # pyright: ignore[reportRedeclaration]
use_all_ifcgroups: BoolProperty(
name="Use Any IfcGroup",
description=(
"By default we're targeting only IfcGroups with SEARCH ObjectType "
@@ -106,7 +106,7 @@ class ActivateStatusFilters(bpy.types.Operator):
bl_description = "Filter and display objects based on currently selected IFC statuses"
bl_options = {"REGISTER", "UNDO"}
only_if_enabled: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
only_if_enabled: bpy.props.BoolProperty(
name="Only If Filters are Enabled",
description="Activate status filters only in case if they were enabled from the UI before.",
default=False,
@@ -137,7 +137,7 @@ class SelectStatusFilter(bpy.types.Operator):
bl_description = "Select elements with currently selected status"
bl_options = {"REGISTER", "UNDO"}
status: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
status: bpy.props.StringProperty()
if TYPE_CHECKING:
status: tool.Sequence.ElementStatusUI
@@ -156,7 +156,7 @@ class AssignStatus(bpy.types.Operator, tool.Ifc.Operator):
bl_description = "Assign status to the selected elements.\n\nAlt+CLICK to unassign the status."
bl_options = {"REGISTER", "UNDO"}
should_override_previous_status: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
should_override_previous_status: bpy.props.BoolProperty(
name="Override Previous Status",
description=(
"Whether assigning new status should override previous one.\n\n"
@@ -165,8 +165,8 @@ class AssignStatus(bpy.types.Operator, tool.Ifc.Operator):
),
default=True,
)
status: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
should_unassign_status: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
status: bpy.props.StringProperty()
should_unassign_status: bpy.props.BoolProperty(
options={"SKIP_SAVE"},
)
@@ -415,7 +415,7 @@ class CopyWorkSchedule(bpy.types.Operator, tool.Ifc.Operator):
bl_label = "Copy Work Schedule"
bl_description = "Create a duplicate of the provided work schedule."
bl_options = {"REGISTER", "UNDO"}
work_schedule: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
work_schedule: bpy.props.IntProperty()
if TYPE_CHECKING:
work_schedule: int
@@ -412,7 +412,7 @@ WorkPlanEditingType = Literal["-", "ATTRIBUTES", "SCHEDULES", "WORK_SCHEDULE", "
class BIMWorkPlanProperties(PropertyGroup):
work_plan_attributes: CollectionProperty(name="Work Plan Attributes", type=Attribute)
editing_type: EnumProperty( # pyright: ignore[reportRedeclaration]
editing_type: EnumProperty(
items=[(i, i, "") for i in get_args(WorkPlanEditingType)],
)
work_plans: CollectionProperty(name="Work Plans", type=WorkPlan)
@@ -430,8 +430,8 @@ class BIMWorkPlanProperties(PropertyGroup):
class IFCStatus(PropertyGroup):
name: StringProperty() # pyright: ignore[reportRedeclaration]
is_visible: BoolProperty( # pyright: ignore[reportRedeclaration]
name: StringProperty()
is_visible: BoolProperty(
name="Is Visible", default=True, update=lambda x, y: (None, bpy.ops.bim.activate_status_filters())[0]
)
@@ -220,7 +220,7 @@ class CopyToContainer(bpy.types.Operator, tool.Ifc.Operator):
bl_label = "Copy to Container"
bl_options = {"REGISTER", "UNDO"}
container: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
container: bpy.props.IntProperty()
if TYPE_CHECKING:
container: int
@@ -167,7 +167,7 @@ class EnableEditingStructuralBoundaryCondition(bpy.types.Operator):
bl_idname = "bim.enable_editing_structural_boundary_condition"
bl_label = "Enable Editing Structural Boundary Condition"
bl_options = {"REGISTER", "UNDO"}
boundary_condition: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
boundary_condition: bpy.props.IntProperty()
if TYPE_CHECKING:
boundary_condition: int
@@ -186,7 +186,7 @@ class EditStructuralBoundaryCondition(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.edit_structural_boundary_condition"
bl_label = "Edit Structural Boundary Condition"
bl_options = {"REGISTER", "UNDO"}
connection: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
connection: bpy.props.IntProperty()
if TYPE_CHECKING:
connection: int
@@ -917,7 +917,7 @@ class EnableEditingBoundaryCondition(bpy.types.Operator):
bl_idname = "bim.enable_editing_boundary_condition"
bl_label = "Enable Editing Boundary Condition"
bl_options = {"REGISTER", "UNDO"}
boundary_condition: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
boundary_condition: bpy.props.IntProperty()
if TYPE_CHECKING:
boundary_condition: int
@@ -83,7 +83,7 @@ class DecorationShader:
PARALLEL DISTRIBUTED FORCE,
DISTRIBUTED MOMENT,
"""
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") # ty:ignore[too-many-positional-arguments]
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface")
vert_out.smooth("VEC3", "forces")
vert_out.smooth("VEC3", "co")
@@ -203,7 +203,7 @@ class DecorationShader:
"""param: pattern: type of pattern
SINGLE FORCE,
SINGLE MOMENT"""
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") # ty: ignore[too-many-positional-arguments]
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface")
vert_out.smooth("VEC3", "co")
shader_info = gpu.types.GPUShaderCreateInfo()
@@ -253,7 +253,7 @@ class DecorationShader:
def get_planar_shader(self) -> gpu.types.GPUShader:
"""shader for planar loads"""
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") # ty: ignore[too-many-positional-arguments]
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface")
vert_out.smooth("VEC3", "co")
shader_info = gpu.types.GPUShaderCreateInfo()
@@ -54,7 +54,7 @@ class AddSystem(bpy.types.Operator, tool.Ifc.Operator):
bl_label = "Add System"
bl_options = {"REGISTER", "UNDO"}
parent_system_id: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
parent_system_id: bpy.props.IntProperty()
if TYPE_CHECKING:
parent_system_id: int
+3 -3
View File
@@ -26,16 +26,16 @@ from bpy.types import PropertyGroup
class WebProperties(PropertyGroup):
webserver_port: IntProperty( # pyright: ignore[reportRedeclaration]
webserver_port: IntProperty(
name="Webserver Port",
min=0,
max=65535,
)
is_running: BoolProperty( # pyright: ignore[reportRedeclaration]
is_running: BoolProperty(
name="Webserver Running Status",
default=False,
)
is_connected: BoolProperty( # pyright: ignore[reportRedeclaration]
is_connected: BoolProperty(
name="Connection Status",
default=False,
)
+6 -6
View File
@@ -159,9 +159,9 @@ class SelectURIAttribute(bpy.types.Operator, ImportHelper):
bl_label = "Select URI Attribute"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Select a local file"
attribute_data_path: bpy.props.StringProperty(name="Data Path") # pyright: ignore[reportRedeclaration]
attribute_data_path: bpy.props.StringProperty(name="Data Path")
"""Full data path to `Attribute`/string property."""
use_relative_path: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
use_relative_path: bpy.props.BoolProperty(
name="Use Relative Path",
default=False,
)
@@ -601,7 +601,7 @@ class CreateMacBonsaiApp(bpy.types.Operator):
"ALT+click to uninstall Bonsai app if it was installed previously."
)
uninstall: bpy.props.BoolProperty(options={"SKIP_SAVE"}) # pyright: ignore[reportRedeclaration]
uninstall: bpy.props.BoolProperty(options={"SKIP_SAVE"})
if TYPE_CHECKING:
uninstall: bool
@@ -1667,7 +1667,7 @@ class BIM_OT_attribute_add_subitem(bpy.types.Operator):
bl_description = "Add subitem to the current attribute"
bl_options = {"REGISTER", "UNDO"}
data_path: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
data_path: bpy.props.StringProperty()
"""Full data path."""
if TYPE_CHECKING:
@@ -1691,9 +1691,9 @@ class BIM_OT_attribute_remove_subitem(bpy.types.Operator):
bl_description = "Add subitem to the current attribute"
bl_options = {"REGISTER", "UNDO"}
data_path: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
data_path: bpy.props.StringProperty()
"""Full data path."""
index: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
index: bpy.props.IntProperty()
if TYPE_CHECKING:
data_path: str
+2 -2
View File
@@ -333,7 +333,7 @@ class Attribute(PropertyGroup):
filter_glob: StringProperty()
is_null: BoolProperty(name="Is Null", update=update_is_null)
is_selected: BoolProperty(name="Is Selected", default=False)
subitems_values: CollectionProperty(type=StrProperty) # pyright: ignore[reportRedeclaration]
subitems_values: CollectionProperty(type=StrProperty)
# Attribute parameters.
is_optional: BoolProperty(name="Is Optional")
@@ -342,7 +342,7 @@ class Attribute(PropertyGroup):
value_max: FloatProperty(description="This is used to validate int_value and float_value")
value_max_constraint: BoolProperty(default=False, description="True if the numerical value has an upper bound")
special_type: StringProperty(name="Special Value Type", default="")
use_explorer_ui: BoolProperty() # pyright: ignore[reportRedeclaration]
use_explorer_ui: BoolProperty()
metadata: StringProperty(name="Metadata", description="For storing some additional information about the attribute")
update: StringProperty(name="Update", description="Custom update function to be executed")
+1 -1
View File
@@ -665,7 +665,7 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
name="Disable Undo When Saving (Faster saves, no undo for you!)", default=False
)
should_stream: BoolProperty(name="Stream Data From IFC-SPF (Only for advanced users)", default=False)
should_always_cache: BoolProperty( # pyright: ignore[reportRedeclaration]
should_always_cache: BoolProperty(
name="Always Cache Geometry",
description="Whether to always cache geometry regardless of 'Cache' setting during Advanced Project Load.",
)
+95 -17
View File
@@ -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,76 @@ 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()
if conflicts:
ifcgit.store_merge_conflicts(conflicts)
operator.report({"WARNING"}, "Merge failed — see the conflict report in the panel below")
else:
operator.report({"ERROR"}, "Merge tool failed — check that ifcmerge is installed correctly")
return False
ifcgit.commit_merge(path_ifc)
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 +219,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)
+54
View File
@@ -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
+231 -99
View File
@@ -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:
@@ -222,7 +217,7 @@ class IfcGit:
rev=[props.display_branch],
)
)
commits_relevant = list(
commits_relevant = set(
git.objects.commit.Commit.iter_items(
repo=repo,
rev=[props.display_branch],
@@ -230,11 +225,17 @@ class IfcGit:
)
)
def is_relevant(commit):
if commit in commits_relevant:
return True
# Merge commits are relevant too
return len(commit.parents) > 1 and any(p in commits_relevant for p in commit.parents)
for commit in commits:
if props.ifcgit_filter == "tagged" and commit.hexsha not in lookup:
continue
elif props.ifcgit_filter == "relevant" and commit not in commits_relevant:
elif props.ifcgit_filter == "relevant" and not is_relevant(commit):
continue
props.ifcgit_commits.add()
@@ -243,7 +244,8 @@ class IfcGit:
list_item.message = commit.message
list_item.author_name = commit.author.name
list_item.author_email = commit.author.email
if commit in commits_relevant:
list_item.committed_date = int(commit.committed_date)
if is_relevant(commit):
list_item.relevant = True
if commit.hexsha in lookup:
for tag in lookup[commit.hexsha]:
@@ -284,16 +286,25 @@ class IfcGit:
if re.match("^Ifc", obj.name):
bpy.data.objects.remove(obj, do_unlink=True)
bpy.data.orphans_purge(do_recursive=True) # ty:ignore[unknown-argument]
bpy.data.orphans_purge(do_recursive=True)
import bonsai.bim.handler
from bonsai.bim.module.model.data import AuthoringData
from bonsai.bim.module.root.data import IfcClassData
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 +404,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 +492,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 +571,117 @@ 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.refs[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.refs[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 as e:
print(f"ifcgit: mergetool failed: {e}")
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
if conflicts is None and repo.index.unmerged_blobs():
conflicts = []
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 +709,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()
+22 -10
View File
@@ -25,11 +25,12 @@ import bmesh
import bpy
import mathutils
import numpy as np
from bpy_extras import view3d_utils
from mathutils import Vector
import bonsai.core.tool
import bonsai.tool as tool
from bpy_extras import view3d_utils
class Raycast(bonsai.core.tool.Raycast):
offset = 10
@@ -200,7 +201,7 @@ class Raycast(bonsai.core.tool.Raycast):
if inter_world is None:
print("No intersection with viewport near plane found for the segment.")
return
return None, None
init_2d = view3d_utils.location_3d_to_region_2d(region, rv3d, inter_world)
@@ -215,7 +216,7 @@ class Raycast(bonsai.core.tool.Raycast):
if found_world is None:
if init_2d is None:
print("Initial projection invalid and iterative search failed.")
return
return None, None
# fallback: clamp projected point to border via manual mapping
final_2d = clamp_to_region_border(init_2d, region)
final_world = None
@@ -432,11 +433,8 @@ class Raycast(bonsai.core.tool.Raycast):
seg_len_sq = sx * sx + sy * sy
if seg_len_sq == 0.0:
# degenerate segment: return distance to p0
dx = px - p0x
dy = py - p0y
dist = math.hypot(dx, dy)
return dist, (p0x, p0y), 0.0
# degenerate segment: skip it
continue
# project (p - p0) onto seg: t = dot(p-p0, seg) / |seg|^2
apx = px - p0x
@@ -726,7 +724,8 @@ class Raycast(bonsai.core.tool.Raycast):
if tool.Raycast.intersect_mouse_2d_bounding_box(mouse_pos, bbox_2d):
if tool.Raycast.object_is_visible_in_clipping_plane(obj):
snap_obj = cls.create_snap_obj(obj)
objs_to_raycast.append(snap_obj)
if snap_obj is not None:
objs_to_raycast.append(snap_obj)
return objs_to_raycast
@@ -887,8 +886,21 @@ class Raycast(bonsai.core.tool.Raycast):
@classmethod
def create_snap_obj(cls, obj):
for snap_obj in cls.snap_objs:
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)
@@ -7,7 +7,7 @@ Python code formatters
For Python code formatting, we use `Black code formatter <https://pypi.org/project/black/>`__,
black settings are stored in the repository's pyproject.toml.
We have GitHub workflow `ci-black-formatting` to maintain black formatting across the repository.
We have GitHub workflow `ci-lint` to maintain black formatting across the repository.
``black`` can be installed using ``pip install black`` and files can be formatted with the following example command:
@@ -13,7 +13,7 @@ When adding or removing a supported Python version, update the following:
* - File
- What to update
* - ``.github/workflows/ci-black-formatting.yaml``
* - ``.github/workflows/ci-lint.yaml``
- ``MIN_IOS_PY_VERSION``
* - ``.github/workflows/ci-ifcopenshell-python-pypi.yml``
- ``pyver`` matrix
@@ -44,6 +44,8 @@ When a new Blender version is released and supported:
* - File
- What to update
* - ``.github/workflows/ci-bonsai.yml``
- ``pyver`` matrix
* - ``.github/workflows/ci-bonsai-daily.yml``
- Blender download URL
@@ -57,7 +59,7 @@ When Blender ships with a new Python version:
* - File
- What to update
* - ``.github/workflows/ci-black-formatting.yaml``
* - ``.github/workflows/ci-lint.yaml``
- ``MIN_BLENDER_PY_VERSION``
* - ``src/bonsai/Makefile``
- ``SUPPORTED_PYVERSIONS``
+7
View File
@@ -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)
+329
View File
@@ -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 ifc, ifcgit
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)
+581
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
ai-chat.ifcopenshell.org
+10
View File
@@ -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
```
+184
View File
@@ -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());
}
+20
View File
@@ -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();
}
+20
View File
@@ -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();
}
+771
View File
@@ -0,0 +1,771 @@
// 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 compactingIndicatorEl = $("compactingIndicator");
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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
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…";
compactingIndicatorEl.hidden = text !== "Compacting…";
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.
- In case of type errors on api functions, retry providing values as strings (for example in the case of the matrix in geometry.edit_object_placement).
- After edits, explain what changed and suggest downloading the IFC.
Be concise. Avoid dumping huge trees unless asked.
`;
let messages = []; // running conversation state (Chat Completions style)
const MAX_TOOL_RESULT_CHARS = 0;
const MAX_HISTORY_MESSAGES = 40;
const ESTIMATED_CHARS_PER_TOKEN = 4;
const MAX_ESTIMATED_TOKENS_PER_MINUTE = 24000;
const COMPACT_WHEN_ESTIMATED_TOKENS = 18000;
const KEEP_RAW_TURN_GROUPS = 1;
const minuteTokenMap = new Map();
function truncateToolResult(text) {
if (MAX_TOOL_RESULT_CHARS == 0 || text.length <= MAX_TOOL_RESULT_CHARS) return text;
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);
}
}
function getEstimatedTokenMinuteLog(firstIterationMinuteBucket) {
return Array.from(minuteTokenMap.entries())
.filter(([minuteBucket]) => minuteBucket >= firstIterationMinuteBucket)
.sort(([leftMinuteBucket], [rightMinuteBucket]) => leftMinuteBucket - rightMinuteBucket)
.map(([minuteBucket, estimatedTokens]) => ({
timestamp: new Date(minuteBucket * 60000).toISOString(),
estimated_tokens: estimatedTokens,
}));
}
async function chatWithMinuteDelay({ chat, apiKey, baseURL, model, messages, tools }) {
const estimatedTokens = Math.max(
1,
Math.ceil(JSON.stringify({ model, messages, ...(tools ? { tools } : {}) }).length / ESTIMATED_CHARS_PER_TOKEN)
);
let currentMinuteBucket = Math.floor(Date.now() / 60000);
const estimateTokenUsage = (minuteTokenMap.get(currentMinuteBucket) ?? 0) + estimatedTokens;
if (estimateTokenUsage > MAX_ESTIMATED_TOKENS_PER_MINUTE) {
currentMinuteBucket += 1;
await new Promise((resolve) => setTimeout(() => resolve(), 60000));
}
minuteTokenMap.set(currentMinuteBucket, (minuteTokenMap.get(currentMinuteBucket) ?? 0) + estimatedTokens);
return {
minuteBucket: currentMinuteBucket,
response: await chat({ apiKey, baseURL, model, messages, tools }),
};
}
async function compactHistoryWithLLM(chat, apiKey, baseURL, model) {
const estimatedTokens = Math.max(
1,
Math.ceil(JSON.stringify([{ role: "system", content: SYSTEM_INSTRUCTIONS }, ...messages]).length / ESTIMATED_CHARS_PER_TOKEN)
);
if (messages.length <= MAX_HISTORY_MESSAGES && estimatedTokens <= COMPACT_WHEN_ESTIMATED_TOKENS) return null;
const { prefix, groups } = messages.reduce((acc, message) => {
if (message.role === "user") {
acc.groups.push([message]);
} else if (acc.groups.length) {
acc.groups[acc.groups.length - 1].push(message);
} else {
acc.prefix.push(message);
}
return acc;
}, { prefix: [], groups: [] });
if (groups.length <= KEEP_RAW_TURN_GROUPS) return null;
const compacted = [...prefix, ...groups.slice(0, -KEEP_RAW_TURN_GROUPS).flat()];
if (!compacted.length) return null;
setStatus("Compacting…");
try {
const before = {
message_count: messages.length,
turn_group_count: groups.length,
estimated_tokens: estimatedTokens,
};
const { minuteBucket, response } = await chatWithMinuteDelay({
chat,
apiKey,
baseURL,
model,
messages: [
{
role: "system",
content: "Summarize older IFC chat context for continuation. Preserve user goals, model state and schema, edits already applied, important ids, names, selectors, and unresolved questions. Be concise, factual, and use short markdown bullets. Do not mention that this is a summary."
},
{ role: "user", content: JSON.stringify(compacted) },
],
});
const summary = response.choices?.[0]?.message?.content?.trim();
if (!summary) return minuteBucket;
messages = [
{ role: "assistant", content: `[Context summary]\n${summary}` },
...groups.slice(-KEEP_RAW_TURN_GROUPS).flat(),
];
console.log("History compaction before", before);
console.log("History compaction after", {
message_count: messages.length,
turn_group_count: messages.filter((message) => message.role === "user").length,
estimated_tokens: Math.max(
1,
Math.ceil(JSON.stringify([{ role: "system", content: SYSTEM_INSTRUCTIONS }, ...messages]).length / ESTIMATED_CHARS_PER_TOKEN)
),
});
return minuteBucket;
} finally {
setStatus("Thinking…");
}
}
async function runAgentTurn(userText) {
const apiKey = apiKeyEl.value.trim();
if (!apiKey) throw new Error("Missing API key");
const provider = PROVIDERS[getProviderValue()];
const { chat } = provider.api;
const baseURL = provider.baseUrlDefault ? baseUrlEl.value.trim() : undefined;
let firstIterationMinuteBucket = null;
messages.push({ role: "user", content: userText });
for (let i = 0; i < 64; i++) {
const compactedMinuteBucket = await compactHistoryWithLLM(chat, apiKey, baseURL, modelEl.value);
if (firstIterationMinuteBucket === null && compactedMinuteBucket !== null) {
firstIterationMinuteBucket = compactedMinuteBucket;
}
if (messages.length > MAX_HISTORY_MESSAGES * 2) trimHistory();
const messages_with_system = [{ role: "system", content: SYSTEM_INSTRUCTIONS }, ...messages];
const { minuteBucket, response } = await chatWithMinuteDelay({
chat,
apiKey,
baseURL,
model: modelEl.value,
messages: messages_with_system,
tools,
});
if (firstIterationMinuteBucket === null) {
firstIterationMinuteBucket = minuteBucket;
}
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) {
console.log("Estimated token usage by minute", getEstimatedTokenMinuteLog(firstIterationMinuteBucket));
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}`);
}
})();
+110
View File
@@ -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);
}
};
+131
View File
@@ -0,0 +1,131 @@
<!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 class="thinking-indicator" id="compactingIndicator" hidden>
<span class="spinner"></span>
<span>compacting...</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>
+370
View File
@@ -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;
}
+10
View File
@@ -0,0 +1,10 @@
PACKAGE_NAME:=ifcedit
include ../common.mk
.PHONY: test
test:
pytest tests
.PHONY: qa
qa:
black .
+10
View File
@@ -0,0 +1,10 @@
PACKAGE_NAME:=ifcmcp
include ../common.mk
.PHONY: test
test:
pytest tests
.PHONY: qa
qa:
black .
+3 -4
View File
@@ -16,7 +16,7 @@ Requires `ifcopenshell`, `ifcquery`, and `ifcedit`. The `mcp` package is an opti
## Running the server
```bash
python3 -m ifcmcp
ifcmcp
```
This starts the server on stdio transport, suitable for use with Claude Code
@@ -27,7 +27,7 @@ or any MCP client.
Use the `claude mcp add` command:
```bash
claude mcp add --transport stdio ifc -- python3 -m ifcmcp
claude mcp add --transport stdio ifc -- ifcmcp
```
Or create a `.mcp.json` file in your project root:
@@ -37,8 +37,7 @@ Or create a `.mcp.json` file in your project root:
"mcpServers": {
"ifc": {
"type": "stdio",
"command": "python3",
"args": ["-m", "ifcmcp"]
"command": "ifcmcp"
}
}
}
+12 -2
View File
@@ -278,7 +278,12 @@ class IfcSession:
return info.info(model, element)
def ifc_select(self, query: str) -> list[dict[str, Any]]:
"""Filter elements using ifcopenshell selector syntax (e.g. 'IfcWall', 'IfcWindow')."""
"""Filter elements using ifcopenshell selector syntax.
Examples: ``IfcWall``, ``IfcWall, IfcColumn``, ``! IfcWall``,
``IfcWall, Name = "My Wall"``, ``type = "Concrete Wall"``,
``material = "Concrete"``.
"""
return select.select(self._require_model(), query)
def ifc_relations(self, element_id: int, traverse: str = "") -> dict[str, Any] | list[dict[str, Any]]:
@@ -536,7 +541,12 @@ class IfcSession:
{
"type": "function",
"name": "ifc_select",
"description": "Select elements using ifcopenshell selector syntax (e.g. 'IfcWall').",
"description": (
"Select elements using ifcopenshell selector syntax. "
"Examples: 'IfcWall', 'IfcWall, IfcColumn', '! IfcWall', "
"'IfcWall, Name = \"My Wall\"', 'type = \"Concrete Wall\"', "
"'material = \"Concrete\"'."
),
"parameters": {
"type": "object",
"properties": {"query": {"type": "string"}},
+1 -1
View File
@@ -3,7 +3,7 @@ requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "ifcmcp"
name = "ifcopenshell-mcp"
version = "0.0.0"
authors = [
{ name="Bruno Postle", email="bruno@postle.net" },
+119
View File
@@ -0,0 +1,119 @@
.. This file was generated with the assistance of an AI coding tool.
IfcEdit
=======
IfcEdit is a CLI wrapper for the full ``ifcopenshell.api`` mutation API. It
exposes all editor functions — over 350 across 30+ modules — without requiring
you to write a Python script. It supports four subcommands:
- **list** — list all API modules, or all functions within a module
- **docs** — show full documentation for a function (parameters, types, descriptions)
- **run** — execute a mutation against an IFC file
- **foreach** — apply an API function to each element in a JSON array read from stdin
- **quantify** — run quantity take-off using ifc5d rules; requires the IfcOpenShell C++ geometry bindings
Installation
------------
::
pip install ifcedit
Or install from source:
1. :doc:`Install IfcOpenShell <ifcopenshell-python/installation>`
2. `Clone the IfcOpenShell repository <https://github.com/IfcOpenShell/IfcOpenShell>`_.
3. ``cd /path/to/IfcOpenShell/src/ifcedit``
4. ``pip install .``
Usage
-----
Discover available API functions::
$ ifcedit list
$ ifcedit list root
$ ifcedit list geometry
Read documentation for a function::
$ ifcedit docs root.remove_product
$ ifcedit docs type.assign_type
Execute a mutation (overwrites the input file by default)::
$ ifcedit run model.ifc root.remove_product --product 42
$ ifcedit run model.ifc type.assign_type --related_objects 10 --relating_type 20
Write to a separate output file::
$ ifcedit run model.ifc root.create_entity -o output.ifc --ifc_class IfcWall
Dry-run to validate without modifying the file::
$ ifcedit run model.ifc root.remove_product --dry-run --product 42
Apply an API function to each element in a JSON array from stdin (``{field}``
placeholders are substituted from each item; model is opened and saved once)::
$ ifcquery model.ifc select 'IfcWindow' | ifcedit foreach model.ifc root.remove_product --product {id}
$ ifcquery model.ifc select 'IfcDoor' | ifcedit foreach model.ifc attribute.edit_attributes \
--product {id} --attributes '{"Name": "Door"}'
Write to a separate output file instead of overwriting::
$ ifcquery model.ifc select 'IfcWall' | ifcedit foreach model.ifc root.remove_product -o output.ifc --product {id}
Quantity take-off (writes ``IfcElementQuantity`` psets back to the file; requires C++ geometry bindings)::
$ ifcedit quantify list
$ ifcedit quantify run model.ifc IFC4QtoBaseQuantities
$ ifcedit quantify run model.ifc IFC4QtoBaseQuantities --selector IfcWall
$ ifcedit quantify run model.ifc IFC4QtoBaseQuantities -o model_qto.ifc
Parameter types
---------------
IfcEdit automatically coerces CLI string arguments to the correct Python types
using the type hints on each API function:
.. list-table::
:header-rows: 1
* - 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"``
- list of resolved entities
* - ``dict``
- ``'{"key": "val"}'``
- parsed JSON object
* - ``Literal["A", "B"]``
- ``"A"``
- validated against allowed values
.. seealso::
Use :doc:`IfcQuery <ifcquery>` for read-only inspection of IFC files, and
:doc:`IfcMCP <ifcmcp>` for interactive AI-assisted editing with an in-memory
session.
+110
View File
@@ -0,0 +1,110 @@
.. This file was generated with the assistance of an AI coding tool.
IfcMCP
======
IfcMCP is an MCP (Model Context Protocol) server that exposes IfcOpenShell
query and edit tools to AI coding assistants such as Claude. It wraps
:doc:`IfcQuery <ifcquery>` and :doc:`IfcEdit <ifcedit>`, holding the IFC model
in memory across tool calls so no file I/O is required between operations.
The ``ifcmcp`` package can also be used directly as a Python library without
the MCP server layer.
Installation
------------
To use IfcMCP as an MCP server, install it together with the ``mcp`` package::
pip install 'ifcmcp[mcp]'
If you only want to use the library directly (without an MCP client)::
pip install ifcmcp
Or install from source:
1. :doc:`Install IfcOpenShell <ifcopenshell-python/installation>`
2. `Clone the IfcOpenShell repository <https://github.com/IfcOpenShell/IfcOpenShell>`_.
3. ``cd /path/to/IfcOpenShell/src/ifcmcp``
4. ``pip install '.[mcp]'``
Setup
-----
Add the server to your MCP client. For Claude Code::
claude mcp add --transport stdio ifc -- ifcmcp
Or add to ``.mcp.json``:
.. code-block:: json
{
"mcpServers": {
"ifc": {
"type": "stdio",
"command": "ifcmcp"
}
}
}
Available tools
---------------
**Session tools**
- ``ifc_new(schema="IFC4")`` — create a new empty model in memory
- ``ifc_load(path)`` — open an IFC file into memory
- ``ifc_reset()`` — unload the current model, freeing all session state
- ``ifc_save(path="")`` — write model to disk; empty path overwrites the original
**Query tools**
- ``ifc_summary()`` — schema version, entity counts, project metadata
- ``ifc_tree()`` — full spatial hierarchy
- ``ifc_info(element_id)`` — deep inspection by step ID
- ``ifc_select(query)`` — filter elements by IFC class
- ``ifc_relations(element_id, traverse="")`` — relationships for an element
- ``ifc_clash(element_id, ...)`` — geometric intersection and clearance checks
- ``ifc_validate(express_rules=False)`` — schema and constraint validation
- ``ifc_schedule(max_depth=None)`` — work schedules with nested task trees
- ``ifc_cost(max_depth=None)`` — cost schedules with nested cost item trees
- ``ifc_schema(entity_type)`` — IFC class documentation
- ``ifc_contexts()`` — geometric representation contexts
- ``ifc_materials()`` — material definitions
**Drawing and rendering tools**
- ``ifc_plot(...)`` — generate a 2D drawing via ``ifcopenshell.draw`` and return it as an inline image the AI assistant can inspect; SVG always available, PNG requires ``cairosvg``
- ``ifc_render(...)`` — off-screen 3D render returned as an inline PNG image the AI assistant can inspect; requires ``pyvista`` and the IfcOpenShell C++ geometry bindings
**ShapeBuilder tools**
- ``ifc_shape_list()`` — list all available ``ShapeBuilder`` methods
- ``ifc_shape_docs(method)`` — documentation for a specific ``ShapeBuilder`` method
- ``ifc_shape(method, params="{}")`` — execute a ``ShapeBuilder`` method; entity references resolved by step ID
**Edit tools**
- ``ifc_list(module="")`` — list API modules or functions
- ``ifc_docs(function_path)`` — documentation for an API function
- ``ifc_edit(function_path, params="{}")`` — execute an ``ifcopenshell.api`` mutation
- ``ifc_quantify(rule, selector="")`` — run quantity take-off; writes ``IfcElementQuantity`` psets in-place
Typical workflow
----------------
.. code-block:: text
ifc_load("/path/to/model.ifc")
ifc_summary()
ifc_tree()
ifc_info(42)
ifc_edit("root.remove_product", '{"product": "42"}')
ifc_save()
.. seealso::
:doc:`IfcQuery <ifcquery>` and :doc:`IfcEdit <ifcedit>` provide the same
functionality as standalone CLI tools for scripting and automation.
+102
View File
@@ -0,0 +1,102 @@
.. This file was generated with the assistance of an AI coding tool.
IfcQuery
========
IfcQuery is a CLI tool for querying and inspecting IFC building models. It
provides read-only subcommands for common inspection tasks, all outputting JSON
so results can be piped into other tools.
Subcommands:
- **summary** — schema version, entity counts, project metadata
- **tree** — full spatial hierarchy (IfcProject → Site → Building → Storeys → Spaces → Elements)
- **info** — deep inspection of any entity by step ID (attributes, property sets, placement matrix, type, material)
- **select** — filter elements by IFC class using the IfcOpenShell selector syntax
- **relations** — relationships for an element; use ``--traverse up`` to walk the hierarchy to IfcProject
- **clash** — geometric intersection and clearance checks; requires the IfcOpenShell C++ geometry bindings
- **validate** — schema and constraint validation; add ``--rules`` for a full EXPRESS check
- **schedule** — work schedules with nested task trees
- **cost** — cost schedules with nested cost item trees
- **schema** — IFC class documentation using the loaded model's schema version
- **contexts** — geometric representation contexts
- **materials** — material definitions (IfcMaterial, layer sets, constituent sets, profile sets)
- **plot** — generate a drawing (SVG or PNG) using ``ifcopenshell.draw``; PNG output requires ``cairosvg``
- **render** — off-screen 3D render to a PNG image; requires ``pyvista`` and the IfcOpenShell C++ geometry bindings
All subcommands accept ``--format json|text|ids`` to control output (default: ``json``):
- ``json`` — structured JSON, suitable for piping to ``jq`` or ``ifcedit foreach``
- ``text`` — indented human-readable output
- ``ids`` — comma-separated step IDs extracted from list results, suitable for piping directly into ``ifcedit run`` parameters
Installation
------------
::
pip install ifcquery
For PNG output from ``plot``, also install ``cairosvg``::
pip install cairosvg
For 3D rendering with ``render``, also install ``pyvista``::
pip install pyvista
Or install from source:
1. :doc:`Install IfcOpenShell <ifcopenshell-python/installation>`
2. `Clone the IfcOpenShell repository <https://github.com/IfcOpenShell/IfcOpenShell>`_.
3. ``cd /path/to/IfcOpenShell/src/ifcquery``
4. ``pip install .``
Usage
-----
::
$ ifcquery model.ifc summary
$ ifcquery model.ifc tree
$ ifcquery model.ifc info 42
$ ifcquery model.ifc select 'IfcWall'
$ ifcquery model.ifc relations 42
$ ifcquery model.ifc relations 42 --traverse up
$ ifcquery model.ifc validate
$ ifcquery model.ifc validate --rules
$ ifcquery model.ifc schedule
$ ifcquery model.ifc cost
$ ifcquery model.ifc schema IfcWall
$ ifcquery model.ifc materials
$ ifcquery model.ifc plot -o floorplan.svg --out-format svg --view floorplan
$ ifcquery model.ifc plot -o floorplan.png --view floorplan
$ ifcquery model.ifc render -o model.png
$ ifcquery model.ifc --format ids select 'IfcWall'
Scripting with ifcedit
----------------------
``ifcquery`` and ``ifcedit`` are designed to compose. Use ``--format ids`` to
pass query results directly into ``ifcedit run`` parameters, or pipe JSON into
``ifcedit foreach`` to apply an operation to every matching element::
# 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}
# Render an element highlighted against everything related to it
$ ifcquery model.ifc render -o relations.png \
--element "$(ifcquery model.ifc --format ids relations 42)"
# Render a clash — subject and clashing elements highlighted together
$ ifcquery model.ifc render -o clash.png \
--element "$(ifcquery model.ifc --format ids clash 42)"
.. seealso::
Use :doc:`IfcEdit <ifcedit>` to make mutations to IFC files from the command
line, and :doc:`IfcMCP <ifcmcp>` for interactive AI-assisted editing.
+3
View File
@@ -30,9 +30,12 @@ Let's learn IfcOpenShell!
ifcclash
ifccsv
ifcdiff
ifcedit
ifcfm
ifcmax
ifcmcp
ifcpatch
ifcquery
ifcsverchok
ifctester
other
@@ -69,9 +69,12 @@ IfcOpenShell is a modular ecosystem of tools that work together, where each tool
"`IfcClash <https://docs.ifcopenshell.org/ifcclash.html>`_", "A CLI utility and library that lets you perform clash detection on one or more IFC models. Clashes are defined in terms of clash sets with filters using the IFC query syntax."
"`IfcCSV <https://docs.ifcopenshell.org/ifccsv.html>`_", "View and edit IFC data using spreadsheets or tabular datasets, such as CSV, ODS, XLSX, Pandas DataFrames, and regular Python lists."
"`IfcDiff <https://docs.ifcopenshell.org/ifcdiff.html>`_", "A CLI utility and library that lets you compare the changes between two IFC models."
"`IfcEdit <https://docs.ifcopenshell.org/ifcedit.html>`_", "A CLI wrapper for all ifcopenshell.api mutation functions. Browse available API modules, read per-function documentation, and run any API function against an IFC file from the command line."
"`IfcFM <https://docs.ifcopenshell.org/ifcfm.html>`_", "A highly standards-compliant tool (e.g. COBie 2.4, COBie 3.0, AOH-BSEM) to convert FM data in IFC databases to spreadsheets and other machine readable formats, such as ODS, XLSX, CSV, Pandas, XML, and JSON."
"`IfcMax <https://docs.ifcopenshell.org/ifcmax.html>`_", "A 3ds Max importer plugin able to import the IFC file format."
"`IfcMCP <https://docs.ifcopenshell.org/ifcmcp.html>`_", "An MCP (Model Context Protocol) server that exposes IfcOpenShell query and edit tools to AI coding assistants. Loads a model into memory and keeps it there across tool calls, so no file I/O is needed between operations."
"`IfcPatch <https://docs.ifcopenshell.org/ifcpatch.html>`_", "A CLI utility and library that lets you run and distribute predetermined modifications on an IFC file, known as a patch recipe. Useful in deploying a data pipeline or batch-fixing external models."
"`IfcQuery <https://docs.ifcopenshell.org/ifcquery.html>`_", "A CLI tool for querying and inspecting IFC building models. Subcommands cover spatial hierarchy, element inspection, relationship traversal, clash detection, schema documentation, work schedules, and cost schedules."
"`IfcSverchok <https://docs.ifcopenshell.org/ifcsverchok.html>`_", "A node based visual programming add-on for Blender to interact with IFC and Sverchok."
"`IfcTester <https://docs.ifcopenshell.org/ifctester.html>`_", "Author and read Information Delivery Specification (IDS) files. You can validate IFC models against IDS and generate reports in multiple formats. It works from the command line, as a web app, or as a library."
"`VoxelisationToolkit <https://github.com/opensourceBIM/voxelization_toolkit>`_", "Converts .ifc geometry into voxels, and lets you perform voxel based geometric analysis."
+2 -1
View File
@@ -42,6 +42,7 @@ WHITE = numpy.array((1.0, 1.0, 1.0))
DO_NOTHING = lambda *args: None
ARRANGE_POLYGON_SETTINGS = W.arrange_polygon_settings() if hasattr(W, 'arrange_polygon_settings') else None
@dataclass
class draw_settings:
@@ -536,7 +537,7 @@ def main(
*(tup for i, tup in enumerate(zip(path_objects, section_polies, polies)) if has_relevant_zone(i))
)
arranged = W.arrange_polygons(polies)
arranged = W.arrange_polygons(*filter(None, (ARRANGE_POLYGON_SETTINGS,)), polies)
svg_data_3 = W.polygons_to_svg(arranged, False)
dom3 = parseString(svg_data_3)
svg3 = dom3.childNodes[0]
@@ -469,7 +469,7 @@ def get_properties(
del data["HasProperties"]
results[prop_name] = data
if verbose:
results[prop_name] = {"id": data["id"], "class": data["class"], "value": results[prop_name]}
results[prop_name] = {"id": data["id"], "class": data["type"], "value": results[prop_name]}
return results
@@ -307,6 +307,35 @@ class TestGetPropertiesIFC4(test.bootstrap.IFC4):
}
}
def test_getting_complex_properties_verbose(self):
element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
pset = ifcopenshell.api.pset.add_pset(self.file, product=element, name="pset")
complex_property = self.file.create_entity("IfcComplexProperty", Name="prop", UsageName="usage_name")
ifcopenshell.api.pset.edit_pset(self.file, pset=complex_property, properties={"a": "b"})
pset.HasProperties = [complex_property]
properties = subject.get_properties(pset.HasProperties, verbose=True)
prop_value = properties["prop"]["value"]
nested_prop = prop_value["properties"]["a"]
assert properties == {
"prop": {
"id": complex_property.id(),
"class": "IfcComplexProperty",
"value": {
"UsageName": "usage_name",
"id": complex_property.id(),
"type": "IfcComplexProperty",
"properties": {
"a": {
"id": nested_prop["id"],
"class": "IfcPropertySingleValue",
"value": "b",
"value_type": "IfcLabel",
}
},
},
}
}
class TestGetElementsUsingPset(test.bootstrap.IFC4):
def test_run(self):
+1
View File
@@ -358,6 +358,7 @@ class IFC_PARSE_API entity : public declaration {
const std::vector<const entity*>& subtypes() const { return subtypes_; }
const std::vector<const attribute*>& attributes() const { return attributes_; }
const std::vector<const inverse_attribute*>& inverse_attributes() const { return inverse_attributes_; }
const std::vector<bool>& derived() const { return derived_; }
const std::vector<const attribute*> all_attributes() const {
+10
View File
@@ -0,0 +1,10 @@
PACKAGE_NAME:=ifcquery
include ../common.mk
.PHONY: test
test:
pytest tests
.PHONY: qa
qa:
black .
+7 -6
View File
@@ -382,12 +382,14 @@ ifcquery model.ifc materials
Generate a 2D technical drawing (floor plan, elevation, or section) of the model and write it to a file.
```bash
ifcquery model.ifc plot output.svg
ifcquery model.ifc plot output.png --view floorplan --scale 0.01
ifcquery model.ifc plot -o output.svg --out-format svg
ifcquery model.ifc plot -o output.png --view floorplan --scale 0.01
```
Options:
- `-o, --output <file>` -- output file path (default: `<ifc_file>.svg` or `<ifc_file>.png`)
- `--out-format {svg,png,base64}` -- output format (default: `png`)
- `--view {floorplan,elevation,section,auto}` -- drawing view (default: `floorplan`)
- `--scale <ratio>` -- model-to-paper scale ratio (default: 0.01 = 1:100)
- `--width-mm <mm>` -- paper width in mm (default: 297)
@@ -395,16 +397,15 @@ Options:
- `--png-width <px>` -- raster output width in pixels (default: 1024)
- `--png-height <px>` -- raster output height in pixels (default: 1024)
Writes SVG when the output path ends in `.svg`, otherwise PNG.
Requires the IfcOpenShell drawing module (`ifcopenshell.draw`).
Requires the IfcOpenShell drawing module (`ifcopenshell.draw`). PNG output additionally requires `cairosvg`.
### render
Render a 3D view of the model geometry to a PNG file.
```bash
ifcquery model.ifc render output.png
ifcquery model.ifc render output.png --view iso --selector IfcWall
ifcquery model.ifc render -o output.png
ifcquery model.ifc render -o output.png --view iso --selector IfcWall
```
Options:
+10 -1
View File
@@ -26,7 +26,16 @@ import ifcopenshell.util.selector
def select(model: ifcopenshell.file, query: str) -> list[dict[str, Any]]:
"""Filter elements using selector syntax and return matching element summaries."""
"""Filter elements using ifcopenshell selector syntax and return matching element summaries.
Examples:
- ``IfcWall`` all walls
- ``IfcWall, IfcColumn`` walls and columns
- ``! IfcWall`` everything except walls
- ``IfcWall, Name = "My Wall"`` walls with a specific name attribute
- ``type = "Concrete Wall"`` elements assigned that type product
- ``material = "Concrete"`` elements with that material
"""
elements = ifcopenshell.util.selector.filter_elements(model, query)
results = []
for element in sorted(elements, key=lambda e: e.id()):
@@ -32,7 +32,7 @@ class SvIfcSbExtrude(bpy.types.Node, SverchCustomTreeNode, ifcsverchok.helper.Sv
bl_idname = "SvIfcSbExtrude"
bl_label = "IFC Extrude"
extrude_axis: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
extrude_axis: bpy.props.EnumProperty(
default="Z",
items=[
("X", "X", "Interpret curve as in XY plane and extrude along X+."),
+4 -4
View File
@@ -33,7 +33,7 @@
"tailwindcss": "^4.0.0",
"tw-animate-css": "^1.3.2",
"typescript": "^5.8.3",
"vite": "^6.4.1"
"vite": "^6.4.2"
}
},
"node_modules/@ampproject/remapping": {
@@ -3264,9 +3264,9 @@
"license": "MIT"
},
"node_modules/vite": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
"dev": true,
"license": "MIT",
"dependencies": {
+1 -1
View File
@@ -30,7 +30,7 @@
"tailwindcss": "^4.0.0",
"typescript": "^5.8.3",
"tw-animate-css": "^1.3.2",
"vite": "^6.4.1"
"vite": "^6.4.2"
},
"dependencies": {
"eventemitter3": "^5.0.1",
+3 -2
View File
@@ -1166,6 +1166,7 @@ ifcopenshell::geometry::taxonomy::item::ptr try_upcast(PyObject* obj0, swig_type
%ignore svgfill::line_segments_to_polygons;
%ignore svgfill::svg_to_polygons;
%ignore svgfill::arrange_polygons;
%ignore svgfill::abstract_arrangement;
%template(svg_line_segments) std::vector<std::array<svgfill::point_2, 2>>;
%template(svg_groups_of_line_segments) std::vector<std::vector<std::array<svgfill::point_2, 2>>>;
@@ -1287,9 +1288,9 @@ ifcopenshell::geometry::taxonomy::item::ptr try_upcast(PyObject* obj0, swig_type
}
}
std::vector<svgfill::polygon_2> arrange_polygons(const std::vector<svgfill::polygon_2>& polygons) {
std::vector<svgfill::polygon_2> arrange_polygons(svgfill::arrange_polygon_settings settings, const std::vector<svgfill::polygon_2>& polygons) {
std::vector<svgfill::polygon_2> r;
if (svgfill::arrange_polygons(polygons, r)) {
if (svgfill::arrange_polygons(settings, polygons, r)) {
return r;
} else {
throw std::runtime_error("Failed to arrange polygons");
File diff suppressed because it is too large Load Diff
+7
View File
@@ -346,6 +346,13 @@ public:
}
}
template <typename T>
void from_arrangement(T& arr) {
for (auto it = arr.edges_begin(); it != arr.edges_end(); ++it) {
insert(it->source()->point(), it->target()->point());
}
}
void assert_symmetric() {
#ifdef SVGFILL_DEBUG
#if 0
+12
View File
@@ -483,6 +483,18 @@ public:
return ps;
}
size_t delete_same_facet_edge_pairs() {
size_t n_deleted = 0;
for (auto it = arr.edges_begin(); it != arr.edges_end();) {
decltype(it) current = it++;
if (current->face() == current->twin()->face()) {
arr.remove_edge(current);
n_deleted++;
}
}
return n_deleted;
}
void merge(const std::vector<int>& edge_indices) {
if (edge_indices.empty()) {
return;

Some files were not shown because too many files have changed in this diff Show More