Compare commits

...

154 Commits

Author SHA1 Message Date
Ryan Schultz ac18195361 Merge v0.8.0 into light/newUI
Resolved conflict in light/operator.py by keeping the modular
re-export architecture from light/newUI and incorporating
RADIANCE_OT_select_camera that was missed during refactoring.
Added it to render.py, registered in __init__.py, and restored
the eyedropper button in ui.py.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 07:59:14 -05:00
Andrej730 e6258ab4a8 Bump VERSION to 0.8.6
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 19:40:25 +05:00
Andrej730 5db65f4041 maintenance - list all things we do on release 2026-04-13 19:39:14 +05:00
Andrej730 89ce32fdfd Remove redundant docs-deployment.yml workflow
The https://github.com/IfcOpenShell/website repo already has bonsai-docs.yml workflow that does the same thing - builds Bonsai docs from the main repo and deploys to bonsaibim_org_docs, so this workflow is redundant and confusing.
2026-04-13 19:39:14 +05:00
Andrej730 763a31a31d readme: fix ifcsverchok badge filter 2026-04-13 19:38:25 +05:00
Andrej730 4b8c612647 fix ifcmcp package name inconsistency 2026-04-13 18:44:01 +05:00
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
Chirag Singh 394eb54f80 Light: Fix lint errors (unused imports, import sorting, formatting) 2026-04-03 01:40:32 +05:30
Chirag Singh f91f44cd4a Light/ Ran black formatting 2026-04-03 01:30:59 +05:30
Chirag Singh d835577750 Merge branch 'light/newUI' of https://github.com/IfcOpenShell/IfcOpenShell into light/newUI 2026-04-03 01:22:37 +05:30
Chirag Singh fffb444b20 Merge v0.8.0 into light/newUI 2026-04-03 01:11:16 +05:30
Chirag Singh a3f212c021 Light: UI revamp with sub-panels, module split, falsecolor via system Radiance 2026-04-03 01:07:48 +05:30
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
Andrej730 da470c5135 Fix missing but used initial_t var 2026-04-01 10:37:23 +05:00
Andrej730 214cd44f8e Fix missing view3d_utils import 2026-04-01 10:37:07 +05:00
Andrej730 4bff2fa554 Fix ruff 2026-04-01 10:37:07 +05:00
Andrej730 9d78df392d black . 2026-04-01 10:37:07 +05:00
Andrej730 86bef0a254 typing 2026-04-01 10:37:06 +05:00
Andrej730 05bf59d360 ci-bonsai-daily - bump Blender version to 5.1 2026-04-01 10:37:06 +05:00
Bruno Postle 17eaef778a api.geometry.connect_path: add connection_geometry parameter
IfcRelConnectsPathElements has an optional ConnectionGeometry attribute for
recording the geometric cut-plane between adjacent elements, but there was
no way to set it via the API.

Generated with the assistance of an AI coding tool.
2026-03-30 07:30:38 +01:00
Bruno Postle f46be80193 Add api.structural.assign_product, assign_to_building, and api.geometry.add_topology_representation
assign_product creates IfcRelAssignsToProduct linking a structural member to
a physical building element. assign_to_building creates IfcRelServicesBuildings
linking a structural analysis model to a building. add_topology_representation
creates IfcTopologyRepresentation for structural elements, inferring the
representation type from the item class.

Generated with the assistance of an AI coding tool.
2026-03-30 07:28:01 +01:00
Bruno Postle be05d771a2 api.boundary.edit_attributes: add PhysicalOrVirtualBoundary and InternalOrExternalBoundary params
Both attributes are required by the IFC schema but were not settable via
the API function. Add physical_or_virtual and internal_or_external parameters
with "NOTDEFINED" defaults for backward compatibility. Update Bonsai boundary
panel to expose both fields in the editor.

Generated with the assistance of an AI coding tool.
2026-03-30 07:25:22 +01:00
Bruno Postle c214d255c9 Fix api.boundary.assign_connection_geometry TypeError
TypeError: attribute 'DirectionRatios' for entity 'IFC4.IfcDirection' is
    expecting value of type 'AGGREGATE OF DOUBLE', got 'ndarray'
2026-03-29 22:04:59 +01:00
Bruno Postle 0d8ba71384 Fix typo in api.boundary.assign_connection_geometry 2026-03-29 21:46:29 +01:00
Bruno Postle 1c26ee86c9 ifcquery/ifcedit: enable shell scripting by composing query and edit commands
Add --format ids to ifcquery to output step IDs suitable for piping into
ifcedit parameters. Add ifcedit foreach to apply an operation to every
element in a query result. Extend clash and relations output so --format ids
extracts all involved element IDs, enabling one-liners like clash detection
piped directly into render.

Generated with the assistance of an AI coding tool.
2026-03-29 15:17:22 +01:00
dependabot[bot] 0ed96d32dd Bump picomatch from 4.0.2 to 4.0.4 in /src/ifctester/webapp
Bumps [picomatch](https://github.com/micromatch/picomatch) from 4.0.2 to 4.0.4.
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/4.0.2...4.0.4)

---
updated-dependencies:
- dependency-name: picomatch
  dependency-version: 4.0.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-28 16:53:31 +11:00
dependabot[bot] f96526195d Bump actions/deploy-pages from 4 to 5
Bumps [actions/deploy-pages](https://github.com/actions/deploy-pages) from 4 to 5.
- [Release notes](https://github.com/actions/deploy-pages/releases)
- [Commits](https://github.com/actions/deploy-pages/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/deploy-pages
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-28 16:53:12 +11:00
dependabot[bot] fd29481d65 Bump hendrikmuhs/ccache-action from 1.2.21 to 1.2.22
Bumps [hendrikmuhs/ccache-action](https://github.com/hendrikmuhs/ccache-action) from 1.2.21 to 1.2.22.
- [Release notes](https://github.com/hendrikmuhs/ccache-action/releases)
- [Commits](https://github.com/hendrikmuhs/ccache-action/compare/v1.2.21...v1.2.22)

---
updated-dependencies:
- dependency-name: hendrikmuhs/ccache-action
  dependency-version: 1.2.22
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-28 16:53:03 +11:00
dependabot[bot] 24b48497f0 Bump actions/configure-pages from 5 to 6
Bumps [actions/configure-pages](https://github.com/actions/configure-pages) from 5 to 6.
- [Release notes](https://github.com/actions/configure-pages/releases)
- [Commits](https://github.com/actions/configure-pages/compare/v5...v6)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-28 16:52:57 +11:00
dependabot[bot] 2b9822f141 Bump mamba-org/setup-micromamba from 2 to 3
Bumps [mamba-org/setup-micromamba](https://github.com/mamba-org/setup-micromamba) from 2 to 3.
- [Release notes](https://github.com/mamba-org/setup-micromamba/releases)
- [Commits](https://github.com/mamba-org/setup-micromamba/compare/v2...v3)

---
updated-dependencies:
- dependency-name: mamba-org/setup-micromamba
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-28 16:52:52 +11:00
dependabot[bot] 3d4db13fc1 Bump ruff from 0.15.7 to 0.15.8
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.15.7 to 0.15.8.
- [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.7...0.15.8)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-28 16:52:46 +11:00
Bruno Perdigão 31b571322b Snap: improve handling with objects that are partially behind the camera. 2026-03-27 15:05:02 -03:00
Bruno Perdigão cef5d41b54 Snap - Improves logic from previous commit.
Previous commit: Snap - Refactor x-ray mode handling
to prevent double raycasting
2026-03-27 15:05:02 -03:00
Bruno Perdigão d7b2358d58 Snap - Refactor x-ray mode handling to prevent double raycasting 2026-03-27 15:05:02 -03:00
Bruno Perdigão cafe5aa7f7 Rename variable - small refactor 2026-03-27 15:05:01 -03:00
Bruno Perdigão de34e73451 Remove unnecessary comments. 2026-03-27 15:05:01 -03:00
Bruno Perdigão 5721a8b602 Snap: improve performance of wireframe objects intersection.
Enhances the performance of mouse intersection checks for wireframe objects.
Details:
- Calculated the intersection with the mouse in 2D pixels first.
- Converted objects to a BVH Tree to reduce the number of edges checked against the mouse position.
2026-03-27 15:04:48 -03:00
Bruno Postle f820214500 ifcmcp: fail early with clear message when mcp package is not installed
mcp is an optional dependency so that the embedded API (embedded.py) can
be used from Pyodide without pulling in pydantic-core and the rest of the
MCP protocol stack, which may not be available in all WASM environments.
2026-03-27 08:44:52 +00:00
Bruno Postle dae913e06a ifcmcp: sse,streamable-http transports and --help 2026-03-26 07:02:50 +00:00
Dion Moult 1a849395c2 Typo crashing edit tools panel when non-wall with wall selected
Fix #7034

bpy.ops.bim.extend_to_underside doesn't exist - the correct operator
name is bim.extend_walls_to_underside. The AttributeError killed the
entire panel draw, hiding mirror, align, aggregation, and QTO buttons.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 13:38:05 +11:00
Dion Moult 611273a20a Fix add_georeferencing silently failing with orphan CRS or conversion
If a file had an IfcProjectedCRS without an IfcCoordinateOperation (or
vice versa), add_georeferencing would return early without creating the
missing entity. This caused edit_georeferencing to crash with IndexError.
Now detects the inconsistent state, cleans up, and recreates both.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 15:00:32 +11:00
Bruno Postle db68195310 Add ifcmcp: MCP server for IFC model querying and editing (#7847)
ifcmcp is a new Model Context Protocol server that wraps ifcquery and ifcedit, holding an IFC model in memory across tool calls. It is the preferred way to interact with IFC models from AI assistants and MCP-compatible clients.

Setup:

claude mcp add --transport stdio ifc -- python3 -m ifcmcp

Session tools: ifc_load, ifc_save

Query tools: ifc_summary, ifc_tree, ifc_info, ifc_select, ifc_relations, ifc_clash, ifc_validate, ifc_schedule, ifc_cost, ifc_schema, ifc_contexts, ifc_materials, ifc_plot, ifc_render, ifc_shape, ifc_shape_list, ifc_shape_docs

Edit discovery: ifc_list, ifc_docs

Edit execution: ifc_edit, ifc_quantify

The model stays in memory between calls - ifc_edit does not auto-save; call ifc_save explicitly when done.

Depends on both ifcquery and ifcedit

Generated with the assistance of an AI coding tool.
2026-03-23 23:54:17 +00:00
Bruno Postle 29079e8cba ifcquery README: add contexts, materials, plot, render subcommands (#7848) 2026-03-23 23:51:33 +00:00
Bruno Postle 6bf4259298 Add ifcedit: CLI wrapper for ifcopenshell.api mutation functions (#7846)
ifcedit is a new command-line tool for executing ifcopenshell.api mutations from the shell. It wraps the entire API surface — any function callable via ifcopenshell.api can be invoked without writing Python.

Subcommands:

    list [module] — list all API modules, or functions within a module
    docs <module.function> — full documentation (params, types, descriptions)
    run <file> <module.function> [--param value ...] — execute a mutation; overwrites input file by default, or use -o <output> to write elsewhere; --dry-run validates without executing
    quantify list — list available QTO rules
    quantify run <file> <rule> — run quantity take-off, writing IfcElementQuantity psets back to the file

Parameter coercion: entity references can be passed as step IDs (strings); lists, dicts, booleans, and None are handled automatically.

Usage:

python3 -m ifcedit run model.ifc root.remove_product --product 42
python3 -m ifcedit docs geometry.edit_object_placement

Generated with the assistance of an AI coding tool.
2026-03-23 23:45:42 +00:00
Bruno Postle 7cd40bf8cb Add ifcquery CLI tool for IFC model interrogation (#7845)
ifcquery is a new command-line tool for querying and inspecting IFC models. All output is JSON.

Subcommands:

    summary — schema version, entity counts, project metadata
    tree — full spatial hierarchy (Project → Site → Building → Storeys → Spaces → Elements)
    info <id> — deep inspection of any entity by step ID (attributes, psets, placement matrix, type, material)
    select <query> — filter elements using ifcopenshell selector syntax
    relations <id> — relationships for an element; --traverse up walks to IfcProject
    clash <id> — geometric intersection and clearance detection
    validate — schema/constraint validation; --rules adds EXPRESS checks
    schedule — work schedules with nested task trees
    cost — cost schedules with nested cost item trees
    schema <class> — IFC class documentation from the model's schema version
    plot — SVG plan drawing
    render — 3D geometry rendering
    contexts — geometric representation contexts
    materials — material assignments

Usage:

python3 -m ifcquery <file.ifc> <subcommand> [args]

Generated with the assistance of an AI coding tool.
2026-03-23 23:29:32 +00:00
Bruno Postle 8b8f78095d geometry_creation.rst: add sections for assemblies, clipping normals, openings (#7844)
Generated with the assistance of an AI coding tool.
2026-03-23 23:02:15 +00:00
Bruno Postle 23ba9e4db0 Add geometry.clip_solid, clip_solid_bounded, and copy_representation APIs (#7843)
* Add geometry.clip_solid API
* Add geometry.clip_solid_bounded API
* Add geometry.copy_representation API
Deep-copies the named representation from a source element to a target
element.

Generated with the assistance of an AI coding tool.
2026-03-23 23:00:12 +00:00
Bruno Postle 1aec991f08 api: docstring improvements across geometry, sequence, and feature modules (#7842)
* Doc clarification for api.sequence.assign_process
* Doc clarification for api.geometry.edit_object_placement
* Doc clarification for api.feature.remove_feature
* Doc clarification for api.geometry.add_wall_representation clippings normal
* regenerate_wall_representation: document BBIM_Boolean preservation requirement

Generated with the assistance of an AI coding tool.
2026-03-23 22:57:28 +00:00
Bruno Postle bddf9b85f8 shape_builder: complete docstrings and return type annotations (#7841)
* shape_builder: complete docstrings and return type annotations
* shape_builder: warn about mixed item types in get_representation
* shape_builder: fix half_space_solid agreement_flag docstring

Generated with the assistance of an AI coding tool.
2026-03-23 22:54:55 +00:00
Chirag Singh c627d59bda Light: Optimized radiance rendering workflow 2026-03-21 18:25:49 +05:30
Ryan Schultz fd13cd6c13 Fix AttributeError by removing duplicate bim.enum_property_search operator
The light module (PR #5452) defined its own EnumPropertySearch class with
bl_idname = "bim.enum_property_search", conflicting with the main
BIM_OT_enum_property_search in operator.py. The light module's version
lacked should_click_ok and other properties, so whichever class registered
last caused an AttributeError when helper.py tried to set op.should_click_ok.

Remove the duplicate EnumPropertySearch and SetEnumProperty operators from
light/operator.py and __init__.py entirely, and replace the local
prop_with_search/get_enum_items helpers in light/ui.py with an import of
the canonical prop_with_search from bonsai.bim.helper.

Generated with the assistance of an AI coding tool.
2026-03-11 17:44:47 -05:00
Ryan Schultz afd6f422ee Fix AttributeError in prop_with_search caused by duplicate bl_idname
The light module's EnumPropertySearch operator was using the same
bl_idname ("bim.enum_property_search") as the main BIM_OT_enum_property_search
in operator.py, but only defined prop_name and search_term properties,
lacking should_click_ok and others. Whichever class registered last would
overwrite the other, causing an AttributeError when helper.py tried to
set op.should_click_ok on the stripped-down version.

Renamed the light module operator to "radiance.enum_property_search" and
updated the matching call in light/ui.py.

Generated with the assistance of an AI coding tool.
2026-03-11 17:39:00 -05:00
falken10vdl bb661ce998 Add bpy import to light list module
To avoid error:
File "/home/falken10vdl/.config/blender/5.0/extensions/.local/lib/python3.11/site-packages/bonsai/bim/module/light/list.py", line 33, in <module>
class MATERIAL_UL_radiance_materials(bpy.types.UIList):
^^^
NameError: name 'bpy' is not defined

Cheers!
2026-03-11 09:42:18 +01:00
Chirag Singh f40377b93d Merge branch 'v0.8.0' of https://github.com/IfcOpenShell/IfcOpenShell into light/newUI 2026-03-08 23:01:34 +05:30
Chirag Singh 30210b0bf6 Merge branch 'light/newUI' of https://github.com/IfcOpenShell/IfcOpenShell into light/newUI 2026-02-10 13:41:12 +05:30
Chirag Singh f7d438f759 Merge branch 'v0.8.0' of https://github.com/IfcOpenShell/IfcOpenShell into light/newUI 2026-02-10 13:37:05 +05:30
Ryan Schultz 8534abe0e0 Try this again. 2025-11-27 09:19:00 -06:00
Ryan Schultz 323d6db4d1 Revert "trying @falken10vdl's suggestion https://github.com/IfcOpenShell/IfcOpenShell/pull/5452#issuecomment-3552472786"
This reverts commit 99e20cae64.
2025-11-27 09:14:14 -06:00
Ryan Schultz 99e20cae64 trying @falken10vdl's suggestion https://github.com/IfcOpenShell/IfcOpenShell/pull/5452#issuecomment-3552472786 2025-11-27 08:22:34 -06:00
Chirag Singh 5e10752bd7 Light: Fixed all issues 2025-11-27 18:21:45 +05:30
Chirag Singh 39839cff66 Ran balck Formatting 2025-11-15 21:43:48 +05:30
Chirag Singh a570d81fd7 Light(Radiance): Implemented IES File Mapping | Render False Color Images | Updated UI 2025-11-15 21:40:13 +05:30
Chirag Singh f6e23c0462 Light: Ran Black Formatting 2025-11-07 22:44:59 +05:30
Chirag Singh dcd87260e7 Light: Removed Comments, Added Sky render options in UI 2025-11-07 22:44:03 +05:30
Chirag Singh 7d148456c0 Light: Updated Radiance Exported code based on latest Pyradiance library update 2025-11-07 22:08:17 +05:30
Chirag Singh 55568e122d Ran black formatting 2025-11-07 21:26:26 +05:30
Chirag Singh 776112bf1d split obj generation separate from other material rad / rtm generation 2025-11-07 21:25:46 +05:30
227 changed files with 19983 additions and 1825 deletions
+1 -1
View File
@@ -53,7 +53,7 @@ jobs:
python ../nix/cache_dependencies.py unpack
- name: ccache
uses: hendrikmuhs/ccache-action@v1.2.21
uses: hendrikmuhs/ccache-action@v1.2.22
with:
key: mac-${{ matrix.arch }}
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
python ../IfcOpenShell/nix/cache_dependencies.py unpack
- name: ccache
uses: hendrikmuhs/ccache-action@v1.2.21
uses: hendrikmuhs/ccache-action@v1.2.22
with:
key: ubuntu-22.04-${{ runner.arch }}
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
python3 ../nix/cache_dependencies.py unpack
- name: ccache
uses: hendrikmuhs/ccache-action@v1.2.21
uses: hendrikmuhs/ccache-action@v1.2.22
with:
key: ubuntu-22.04-${{ runner.arch }}-rockylinux9
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
python3 ../nix/cache_dependencies.py unpack
- name: ccache
uses: hendrikmuhs/ccache-action@v1.2.21
uses: hendrikmuhs/ccache-action@v1.2.22
with:
key: ubuntu-22.04-${{ runner.arch }}-rockylinux9
+1 -1
View File
@@ -52,7 +52,7 @@ jobs:
}
- name: ccache
uses: hendrikmuhs/ccache-action@v1.2.21
uses: hendrikmuhs/ccache-action@v1.2.22
with:
key: win-${{ matrix.arch }}
# Windows ccache needs ~1GB
+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.0.1-linux-x64.tar.xz
wget -q -O blender.tar.xz https://download.blender.org/release/Blender5.1/blender-5.1.0-linux-x64.tar.xz
tar -xf blender.tar.xz
# Setup Blender.
@@ -122,7 +122,7 @@ jobs:
pip install -r requirements.txt
python setup_extensions_repo.py --last-tag
cd ..
bonsai_zip="$(pwd)/$(ls bonsai_unstable_repo/bonsai_py311*-linux-x64.zip)"
bonsai_zip="$(pwd)/$(ls bonsai_unstable_repo/bonsai_py313*-linux-x64.zip)"
# Install Bonsai.
blender --command extension install-file -r user_default -e $bonsai_zip
+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
@@ -24,7 +24,7 @@ jobs:
if: |
github.repository == 'IfcOpenShell/IfcOpenShell'
steps:
- uses: mamba-org/setup-micromamba@v2 # https://github.com/mamba-org/setup-micromamba
- uses: mamba-org/setup-micromamba@v3 # https://github.com/mamba-org/setup-micromamba
with:
environment-name: test-env
create-args: >-
@@ -84,7 +84,7 @@ jobs:
run: |
curl -L https://github.com/phracker/MacOSX-SDKs/releases/download/11.3/MacOSX10.13.sdk.tar.xz | tar -xvJf - -C /Users/runner/work/
- uses: mamba-org/setup-micromamba@v2 # https://github.com/mamba-org/setup-micromamba
- uses: mamba-org/setup-micromamba@v3 # https://github.com/mamba-org/setup-micromamba
with:
environment-name: test-env
create-args: >-
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
-
name: ccache
uses: hendrikmuhs/ccache-action@v1.2.21
uses: hendrikmuhs/ccache-action@v1.2.22
-
name: Build ifcopenshell
+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}"
+1 -1
View File
@@ -79,7 +79,7 @@ jobs:
libhdf5-dev libcgal-dev libeigen3-dev
- name: ccache
uses: hendrikmuhs/ccache-action@v1.2.21
uses: hendrikmuhs/ccache-action@v1.2.22
with:
key: ubuntu-22.04-${{ runner.arch }}
-36
View File
@@ -1,36 +0,0 @@
name: Build and Deploy Stable Documentation
on:
workflow_dispatch: # Manual trigger
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.x'
- name: Install dependencies
run: |
cd src/bonsai/docs
pip install -r requirements.txt # Run pip install from the docs directory
- name: Build documentation
run: |
cd src/bonsai/docs
make html
- name: Deploy to GitHub Pages (Stable)
uses: peaceiris/actions-gh-pages@v4
with:
deploy_key: ${{ secrets.ACTIONS_DEPLOY_KEY }}
external_repository: IfcOpenShell/bonsaibim_org_docs
publish_branch: main
cname: docs.bonsaibim.org
publish_dir: src/bonsai/docs/_build/html
+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@v5
- 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@v4
git add .
if git diff --cached --quiet; then
echo "No changes to commit"
exit 0
fi
git commit -m "$(git log --oneline -1)"
git push origin gh-pages
+5 -2
View File
@@ -50,11 +50,14 @@ 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/ifcopenshell-mcp?label=PyPI&color=006dad)](https://pypi.org/project/ifcopenshell-mcp/) |
| [ifcopenshell-python](https://docs.ifcopenshell.org/ifcopenshell-python.html) | Python library for IFC manipulation | LGPL-3.0-or-later\* | [![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/) |
| [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)
| [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](https://img.shields.io/github/v/release/ifcopenshell/ifcopenshell?filter=ifcsverchok-*.*.*&label=GitHub&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/) |
The IfcOpenShell C++ codebase is split into multiple interal libraries:
+1 -1
View File
@@ -1 +1 @@
0.8.5
0.8.6
+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.7",
"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
+2 -2
View File
@@ -64,8 +64,8 @@ class MaterialCreator:
mesh: Union[OBJECT_DATA_TYPE, None],
shape_has_openings: bool,
) -> None:
if (((rep := getattr(element, "Representation", ...)) is not ... and not rep) or
((rep := getattr(element, "RepresentationMaps", ...)) is not ... and not rep)
if ((rep := getattr(element, "Representation", ...)) is not ... and not rep) or (
(rep := getattr(element, "RepresentationMaps", ...)) is not ... and not rep
):
return
@@ -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
@@ -377,6 +377,8 @@ class EnableEditingBoundary(bpy.types.Operator):
obj = tool.Ifc.get_object(entity)
if entity and obj:
setattr(bprops, blender_property, obj)
bprops.physical_or_virtual = boundary.PhysicalOrVirtualBoundary or "NOTDEFINED"
bprops.internal_or_external = boundary.InternalOrExternalBoundary or "NOTDEFINED"
return {"FINISHED"}
@@ -392,6 +394,8 @@ class DisableEditingBoundary(bpy.types.Operator):
bprops.is_editing = False
for ifc_attribute, blender_property in EDITABLE_ATTRIBUTES.items():
setattr(bprops, blender_property, None)
bprops.physical_or_virtual = "NOTDEFINED"
bprops.internal_or_external = "NOTDEFINED"
return {"FINISHED"}
@@ -411,6 +415,8 @@ class EditBoundaryAttributes(bpy.types.Operator, tool.Ifc.Operator):
obj = getattr(bprops, blender_property, None)
entity = tool.Ifc.get_entity(obj)
attributes[blender_property] = entity
attributes["physical_or_virtual"] = bprops.physical_or_virtual
attributes["internal_or_external"] = bprops.internal_or_external
ifcopenshell.api.boundary.edit_attributes(tool.Ifc.get(), entity=boundary, **attributes)
bpy.ops.bim.disable_editing_boundary()
return {"FINISHED"}
@@ -21,6 +21,7 @@ from typing import TYPE_CHECKING, Union
import bpy
from bpy.props import (
BoolProperty,
EnumProperty,
PointerProperty,
)
from bpy.types import PropertyGroup
@@ -50,12 +51,43 @@ def element_filter(self: "BIMObjectBoundaryProperties", object: bpy.types.Object
return False
def get_internal_or_external_items(
self: "BIMObjectBoundaryProperties", context: bpy.types.Context | None
) -> list[tuple[str, str, str]]:
items = [
("INTERNAL", "Internal", ""),
("EXTERNAL", "External", ""),
]
ifc = tool.Ifc.get()
if not ifc or ifc.schema != "IFC2X3":
items += [
("EXTERNAL_EARTH", "External Earth", ""),
("EXTERNAL_WATER", "External Water", ""),
("EXTERNAL_FIRE", "External Fire", ""),
]
items.append(("NOTDEFINED", "Not Defined", ""))
return items
class BIMObjectBoundaryProperties(PropertyGroup):
is_editing: BoolProperty(name="Is Editing")
relating_space: PointerProperty(name="RelatingSpace", type=bpy.types.Object, poll=space_filter)
related_building_element: PointerProperty(name="RelatedBuildingElement", type=bpy.types.Object, poll=element_filter)
parent_boundary: PointerProperty(name="ParentBoundary", type=bpy.types.Object, poll=boundary_filter)
corresponding_boundary: PointerProperty(name="CorrespondingBoundary", type=bpy.types.Object, poll=boundary_filter)
physical_or_virtual: EnumProperty(
name="PhysicalOrVirtualBoundary",
items=[
("PHYSICAL", "Physical", ""),
("VIRTUAL", "Virtual", ""),
("NOTDEFINED", "Not Defined", ""),
],
default="NOTDEFINED",
)
internal_or_external: EnumProperty(
name="InternalOrExternalBoundary",
items=get_internal_or_external_items,
)
if TYPE_CHECKING:
is_editing: bool
@@ -63,6 +95,8 @@ class BIMObjectBoundaryProperties(PropertyGroup):
related_building_element: Union[bpy.types.Object, None]
parent_boundary: Union[bpy.types.Object, None]
corresponding_boundary: Union[bpy.types.Object, None]
physical_or_virtual: str
internal_or_external: str # values depend on schema: IFC2X3 omits EXTERNAL_EARTH/WATER/FIRE
class BIMBoundaryProperties(PropertyGroup):
@@ -77,6 +77,10 @@ class BIM_PT_Boundary(Panel):
self.draw_relation_editor(boundary, "RelatedBuildingElement", "related_building_element")
self.draw_relation_editor(boundary, "ParentBoundary", "parent_boundary")
self.draw_relation_editor(boundary, "CorrespondingBoundary", "corresponding_boundary")
row = self.layout.row()
row.prop(self.bprops, "physical_or_virtual")
row = self.layout.row()
row.prop(self.bprops, "internal_or_external")
else:
row = self.layout.row()
row.operator("bim.enable_editing_boundary", icon="GREASEPENCIL", text="Edit")
@@ -84,6 +88,8 @@ class BIM_PT_Boundary(Panel):
self.draw_relation_data(boundary, "RelatedBuildingElement")
self.draw_relation_data(boundary, "ParentBoundary")
self.draw_relation_data(boundary, "CorrespondingBoundary")
self.draw_enum_data(boundary, "PhysicalOrVirtualBoundary")
self.draw_enum_data(boundary, "InternalOrExternalBoundary")
if hasattr(boundary, "InnerBoundaries"):
for i, inner_boundary in enumerate(getattr(boundary, "InnerBoundaries", ())):
row = self.layout.row(align=True)
@@ -110,6 +116,11 @@ class BIM_PT_Boundary(Panel):
else:
row.label(text="")
def draw_enum_data(self, boundary, ifc_attribute: str):
row = self.layout.row(align=True)
row.label(text=ifc_attribute)
row.label(text=getattr(boundary, ifc_attribute, "") or "")
def draw_relation_editor(self, boundary, ifc_attribute: str, blender_property: str):
if hasattr(boundary, ifc_attribute):
row = self.layout.row(align=True)
+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):
+29 -17
View File
@@ -23,7 +23,7 @@ from pathlib import Path
import bpy
import pyradiance
from . import list, operator, prop, ui
from . import export, ies, list, material, prepare, prop, render, solar, ui
def get_pyradiance_path():
@@ -31,31 +31,43 @@ def get_pyradiance_path():
classes = (
operator.ExportOBJ,
operator.ImportLatLong,
operator.ImportTrueNorth,
operator.MoveSunPathTo3DCursor,
operator.RadianceRender,
operator.ViewFromSun,
operator.LightPickCoordinates,
operator.LightSetTimeToNow,
operator.RefreshIFCMaterials,
operator.UnmapMaterial,
operator.RADIANCE_OT_select_camera,
operator.RADIANCE_OT_export_material_mappings,
operator.RADIANCE_OT_import_material_mappings,
operator.RADIANCE_OT_open_spectraldb,
export.ExportOBJ,
solar.ImportLatLong,
solar.ImportTrueNorth,
solar.MoveSunPathTo3DCursor,
render.RadianceRender,
render.FalseColorRadiance,
render.RADIANCE_OT_select_camera,
solar.ViewFromSun,
solar.LightPickCoordinates,
solar.LightSetTimeToNow,
material.RefreshIFCMaterials,
material.UnmapMaterial,
material.RADIANCE_OT_export_material_mappings,
material.RADIANCE_OT_import_material_mappings,
material.RADIANCE_OT_open_spectraldb,
prepare.PrepareRadianceScene,
ies.AddIESLight,
ies.RemoveIESLight,
export.CleanupRadianceFiles,
prop.RadianceMaterial,
prop.IESLight,
prop.BIMSolarProperties,
prop.RadianceExporterProperties,
ui.BIM_PT_radiance_exporter,
ui.BIM_PT_radiance_scene_setup,
ui.BIM_PT_radiance_materials,
ui.BIM_PT_radiance_lighting,
ui.BIM_PT_radiance_render_settings,
ui.BIM_PT_radiance_pipeline,
ui.BIM_PT_solar,
list.MATERIAL_UL_radiance_materials,
list.MATERIAL_UL_ies_lights,
)
def register():
bpy.types.Scene.BIMRadianceExporeterProperies = bpy.props.PointerProperty(type=prop.RadianceExporterProperties)
bpy.types.Scene.BIMRadianceExporterProperties = bpy.props.PointerProperty(type=prop.RadianceExporterProperties)
bpy.types.Scene.BIMSolarProperties = bpy.props.PointerProperty(type=prop.BIMSolarProperties)
if pyradiance:
@@ -68,5 +80,5 @@ def register():
def unregister():
del bpy.types.Scene.BIMRadianceExporeterProperies
del bpy.types.Scene.BIMRadianceExporterProperties
del bpy.types.Scene.BIMSolarProperties
@@ -0,0 +1,488 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2020, 2021 Dion Moult <dion@thinkmoult.com>
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import multiprocessing
import os
from pathlib import Path
import bpy
import ifcopenshell
import ifcopenshell.geom
import bonsai.tool as tool
from bonsai.bim.module.light.shared import ifc_materials, linked_model_exports
class ExportOBJ(bpy.types.Operator):
"""Exports the IFC File to OBJ"""
bl_idname = "export_scene.radiance"
bl_label = "Export"
bl_description = "Export the IFC to OBJ"
@classmethod
def poll(cls, context):
if not tool.Ifc.get():
cls.poll_message_set("No IFC file loaded in Bonsai.")
return False
props = tool.Blender.get_radiance_exporter_props()
if not props.output_dir:
cls.poll_message_set("Output directory is not set.")
return False
return True
def _get_geom_settings(self):
"""Create standard geometry and serializer settings for OBJ export."""
settings = ifcopenshell.geom.settings()
serializer_settings = ifcopenshell.geom.serializer_settings()
settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.SURFACES_AND_SOLIDS)
settings.set("apply-default-materials", True)
serializer_settings.set("use-element-guids", True)
settings.set("use-world-coords", True)
return settings, serializer_settings
def _get_exportable_elements(self, ifc_file, filter_visibility=True):
"""Get the list of elements to export from an IFC file."""
if ifc_file.schema in ("IFC2X3", "IFC4"):
elements = ifc_file.by_type("IfcElement") + ifc_file.by_type("IfcProxy")
else:
elements = ifc_file.by_type("IfcElement")
elements += ifc_file.by_type("IfcSite")
elements = [e for e in elements if not e.is_a("IfcFeatureElement") or e.is_a("IfcSurfaceFeature")]
if not filter_visibility:
return elements
# Filter by visibility in Blender.
# We use hide_get() (user-toggled eye icon) instead of visible_get()
# because visible_get() also considers collection/view-layer visibility
# which incorrectly excludes objects in linked aggregate sub-collections.
visible_elements = []
for element in elements:
blender_obj = tool.Ifc.get_object(element)
if blender_obj is None:
# No Blender object (linked aggregate copies, or not yet represented)
# Include by default — the geometry exists in the IFC file
visible_elements.append(element)
continue
if not blender_obj.hide_get():
visible_elements.append(element)
else:
print(f"Skipping hidden element: {element.GlobalId if hasattr(element, 'GlobalId') else element.id()}")
return visible_elements
def _export_ifc_to_obj(self, ifc_file, obj_path, mtl_path, settings, serializer_settings, elements):
"""Export elements from an IFC file to OBJ format. Returns collected material names."""
materials_collected = []
serialiser = ifcopenshell.geom.serializers.obj(obj_path, mtl_path, settings, serializer_settings)
serialiser.setFile(ifc_file)
serialiser.setUnitNameAndMagnitude("METER", 1.0)
serialiser.writeHeader()
print(f"Exporting {len(elements)} elements to {obj_path}")
iterator = ifcopenshell.geom.iterator(settings, ifc_file, multiprocessing.cpu_count(), include=elements)
if iterator.initialize():
while True:
shape = iterator.get()
for material in shape.geometry.materials:
materials_collected.append(material.name)
serialiser.write(shape)
if not iterator.next():
break
serialiser.finalize()
return materials_collected
def _sync_moved_object_placements(self):
"""Sync Blender object positions to IFC ObjectPlacements for all moved objects.
This is critical for linked aggregate copies: their IFC ObjectPlacements
are initially copies of the original's placement. After the user moves them
in Blender, the IFC placements are stale until explicitly synced. Without
this, the iterator with use-world-coords=True exports all copies at the
original position.
"""
import bonsai.core.geometry as core_geometry
synced = 0
for obj in bpy.data.objects:
element = tool.Ifc.get_entity(obj)
if element is None:
continue
if not element.is_a("IfcProduct"):
continue
try:
if tool.Ifc.is_moved(obj):
core_geometry.edit_object_placement(
ifc=tool.Ifc,
geometry=tool.Geometry,
surveyor=tool.Surveyor,
obj=obj,
apply_scale=False,
)
synced += 1
except Exception as e:
print(f"Could not sync placement for {obj.name}: {e}")
if synced:
print(f"Synced {synced} moved object placement(s) to IFC before export")
def _export_collection_instances_obj(self, context, output_dir):
"""Export Blender collection instances as per-parent OBJ files.
Collection instances (empties with instance_type='COLLECTION') are purely
Blender constructs they don't exist in the IFC file. The ifcopenshell
iterator ignores them entirely, so we must export their geometry via
Blender's depsgraph which evaluates all instances with correct world transforms.
Each collection instance parent gets its own OBJ file because obj2mesh
cannot handle all instances in a single file ("too many patch triangles").
"""
depsgraph = context.evaluated_depsgraph_get()
# Find which objects are collection instance parents (visible, not hidden)
# Skip collections that belong to linked IFC models — those are already
# exported by _export_linked_models() via the IFC serializer.
instance_parents = set()
for obj in bpy.data.objects:
if obj.instance_type == "COLLECTION" and obj.instance_collection is not None:
if not obj.visible_get():
continue
# Check if this collection contains linked IFC objects
coll = obj.instance_collection
is_linked_ifc = any("guids" in child for child in coll.all_objects if child.type == "MESH")
if is_linked_ifc:
print(f"Skipping collection instance '{obj.name}' (linked IFC model, exported via IFC serializer)")
continue
instance_parents.add(obj.name)
if not instance_parents:
return
print(f"Found {len(instance_parents)} collection instance(s) to export")
# Export one OBJ per collection instance parent
identity = [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]
total_meshes = 0
for parent_name in sorted(instance_parents):
obj_path = os.path.join(output_dir, f"instance_{parent_name}.obj")
vert_offset = 0
mesh_count = 0
with open(obj_path, "w") as f:
f.write(f"# Collection instance geometry for {parent_name}\n")
f.write("usemtl white\n\n")
for dep_inst in depsgraph.object_instances:
if not dep_inst.is_instance:
continue
if dep_inst.parent is None:
continue
if dep_inst.parent.original.name != parent_name:
continue
eval_obj = dep_inst.object
if eval_obj.type != "MESH":
continue
try:
mesh = eval_obj.to_mesh()
except RuntimeError:
continue
if mesh is None:
continue
matrix = dep_inst.matrix_world
f.write(f"g obj_{mesh_count}\n")
for v in mesh.vertices:
co = matrix @ v.co
f.write(f"v {co.x} {co.y} {co.z}\n")
mesh.calc_loop_triangles()
for tri in mesh.loop_triangles:
i0 = vert_offset + tri.vertices[0] + 1
i1 = vert_offset + tri.vertices[1] + 1
i2 = vert_offset + tri.vertices[2] + 1
f.write(f"f {i0} {i1} {i2}\n")
vert_offset += len(mesh.vertices)
mesh_count += 1
eval_obj.to_mesh_clear()
if mesh_count == 0:
try:
os.remove(obj_path)
except OSError:
pass
continue
# Identity matrix — geometry is already at world coordinates
linked_model_exports.append((obj_path, "", identity))
total_meshes += mesh_count
print(f" {parent_name}: {mesh_count} meshes, {vert_offset} vertices")
if total_meshes > 0:
print(f"Exported {total_meshes} instanced meshes across {len(linked_model_exports)} file(s)")
def _export_non_ifc_meshes(self, context, output_dir):
"""Export visible Blender mesh objects that have no IFC entity.
These are plain Blender geometry (e.g. manually added planes, cubes)
that don't exist in any IFC file. They are skipped by the IFC iterator
and by the collection instance exporter, so we handle them separately.
"""
non_ifc_meshes = []
for obj in bpy.data.objects:
if obj.type != "MESH":
continue
if not obj.visible_get():
continue
# Skip objects that have an IFC entity (handled by main/linked IFC export)
if tool.Ifc.get_entity(obj) is not None:
continue
# Skip objects inside instanced collections (handled by collection instance export)
if any(col.library for col in obj.users_collection):
continue
# Skip linked IFC element objects (have "guids" custom prop)
if "guids" in obj:
continue
non_ifc_meshes.append(obj)
if not non_ifc_meshes:
return
obj_path = os.path.join(output_dir, "blender_meshes.obj")
vert_offset = 0
mesh_count = 0
identity = [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]
depsgraph = context.evaluated_depsgraph_get()
with open(obj_path, "w") as f:
f.write("# Non-IFC Blender mesh geometry\n")
f.write("usemtl white\n\n")
for obj in non_ifc_meshes:
eval_obj = obj.evaluated_get(depsgraph)
try:
mesh = eval_obj.to_mesh()
except RuntimeError:
continue
if mesh is None:
continue
matrix = obj.matrix_world
f.write(f"g {obj.name}\n")
for v in mesh.vertices:
co = matrix @ v.co
f.write(f"v {co.x} {co.y} {co.z}\n")
mesh.calc_loop_triangles()
for tri in mesh.loop_triangles:
i0 = vert_offset + tri.vertices[0] + 1
i1 = vert_offset + tri.vertices[1] + 1
i2 = vert_offset + tri.vertices[2] + 1
f.write(f"f {i0} {i1} {i2}\n")
vert_offset += len(mesh.vertices)
mesh_count += 1
eval_obj.to_mesh_clear()
if mesh_count == 0:
try:
os.remove(obj_path)
except OSError:
pass
return
linked_model_exports.append((obj_path, "", identity))
print(f"Exported {mesh_count} non-IFC Blender mesh(es) ({vert_offset} vertices)")
def execute(self, context):
ifc_materials.clear()
linked_model_exports.clear()
props = tool.Blender.get_radiance_exporter_props()
output_dir = props.output_dir
props.is_exporting = True
# Sync all moved Blender object positions to IFC before export.
self._sync_moved_object_placements()
settings, serializer_settings = self._get_geom_settings()
ifc_file = tool.Ifc.get()
# --- Export main model ---
obj_file_path = os.path.join(output_dir, "model.obj")
mtl_file_path = os.path.join(output_dir, "model.mtl")
visible_elements = self._get_exportable_elements(ifc_file, filter_visibility=True)
mats = self._export_ifc_to_obj(
ifc_file, obj_file_path, mtl_file_path, settings, serializer_settings, visible_elements
)
ifc_materials.extend(mats)
self.report({"INFO"}, f"Exported main model OBJ to: {obj_file_path}")
# --- Export linked models (external IFC files) ---
self._export_linked_models(ifc_file, output_dir, settings, serializer_settings)
# --- Export collection instances (Blender-level linked copies) ---
self._export_collection_instances_obj(context, output_dir)
# --- Export non-IFC Blender meshes (plain geometry with no IFC entity) ---
self._export_non_ifc_meshes(context, output_dir)
props.is_exporting = False
total_linked = len(linked_model_exports)
if total_linked:
self.report({"INFO"}, f"Also exported {total_linked} linked/instanced model(s)")
return {"FINISHED"}
def _export_linked_models(self, main_ifc_file, output_dir, settings, serializer_settings):
"""Detect and export all linked IFC models."""
try:
project_props = tool.Project.get_project_props()
except Exception:
print("Could not access project properties for linked models")
return
for idx, link in enumerate(project_props.links):
if not link.is_loaded:
print(f"Skipping linked model '{link.name}' (not loaded)")
continue
# Check if the link's empty handle is hidden in Blender
try:
link_empty = tool.Project.get_link_empty_handle(link)
if link_empty is not None and not link_empty.visible_get():
print(f"Skipping linked model '{link.name}' (hidden in viewport)")
continue
except Exception:
pass # If we can't check visibility, export anyway
try:
filepath = Path(tool.Ifc.resolve_uri(link.filepath))
except Exception as e:
print(f"Could not resolve path for linked model '{link.name}': {e}")
continue
if not filepath.exists():
print(f"Linked IFC file not found: {filepath}")
continue
print(f"Exporting linked model {idx}: {filepath.name}")
try:
linked_ifc = ifcopenshell.open(str(filepath))
except Exception as e:
print(f" Failed to open linked IFC: {e}")
continue
link_obj_path = os.path.join(output_dir, f"linked_{idx}.obj")
link_mtl_path = os.path.join(output_dir, f"linked_{idx}.mtl")
# For linked models we don't filter by Blender visibility
# (their objects are collection instances, not individually tracked)
elements = self._get_exportable_elements(linked_ifc, filter_visibility=False)
if not elements:
print(f" No exportable elements found in linked model")
continue
mats = self._export_ifc_to_obj(
linked_ifc, link_obj_path, link_mtl_path, settings, serializer_settings, elements
)
ifc_materials.extend(mats)
# Get the link transformation matrix
try:
link_matrix = tool.Project.calculate_link_matrix(link)
matrix_list = [list(row) for row in link_matrix]
except Exception as e:
print(f" Could not calculate link matrix: {e}, using identity")
matrix_list = [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]
linked_model_exports.append((link_obj_path, link_mtl_path, matrix_list))
print(f" Exported {len(elements)} elements from linked model '{link.name}'")
class CleanupRadianceFiles(bpy.types.Operator):
"""Delete all generated Radiance files from the output directory"""
bl_idname = "radiance.cleanup_files"
bl_label = "Cleanup Radiance Files"
bl_description = "Remove all generated files (OBJ, MTL, RTM, RAD, HDR, TIFF, DAT) from the output directory"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
props = tool.Blender.get_radiance_exporter_props()
if not props.output_dir:
cls.poll_message_set("Output directory is not set.")
return False
return True
def execute(self, context):
import bonsai.bim.module.light.shared as shared
props = tool.Blender.get_radiance_exporter_props()
output_dir = props.output_dir
if not os.path.isdir(output_dir):
self.report({"WARNING"}, f"Output directory does not exist: {output_dir}")
return {"CANCELLED"}
cleanup_patterns = (
"model.obj",
"model.mtl",
"model.rtm",
"sky.rad",
"materials.rad",
"scene.rad",
"ascene.oct",
"mascene.oct",
"ascene.amb",
)
cleanup_extensions = (".hdr", ".tiff", ".rad", ".dat")
cleanup_prefixes = ("instance_", "linked_", "blender_meshes")
removed = 0
for filename in os.listdir(output_dir):
filepath = os.path.join(output_dir, filename)
if not os.path.isfile(filepath):
continue
is_generated = (
filename in cleanup_patterns
or os.path.splitext(filename)[1].lower() in cleanup_extensions
or any(filename.startswith(p) for p in cleanup_prefixes)
)
if is_generated:
try:
os.remove(filepath)
removed += 1
except OSError as e:
print(f"Failed to remove {filepath}: {e}")
# Reset the global scene reference
shared.scene = None
self.report({"INFO"}, f"Cleaned up {removed} generated files from {output_dir}")
return {"FINISHED"}
+80
View File
@@ -0,0 +1,80 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2020, 2021 Dion Moult <dion@thinkmoult.com>
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
from pathlib import Path
import bpy
from bpy_extras.io_utils import ImportHelper
import bonsai.tool as tool
class AddIESLight(bpy.types.Operator, ImportHelper):
"""Upload and add an IES light fixture to the scene"""
bl_idname = "radiance.add_ies_light"
bl_label = "Add IES Light"
bl_options = {"REGISTER", "UNDO"}
filename_ext = ".ies"
filter_glob: bpy.props.StringProperty(default="*.ies;*.IES", options={"HIDDEN"})
def execute(self, context):
props = tool.Blender.get_radiance_exporter_props()
# Create new IES light entry
ies_light = props.ies_lights.add()
# Store as relative path if the blend file is saved
if bpy.data.filepath:
ies_light.ies_file_path = bpy.path.relpath(self.filepath)
else:
ies_light.ies_file_path = self.filepath
ies_light.rotation_z = 0.0
ies_light.is_enabled = True
# Set as active
props.active_ies_light_index = len(props.ies_lights) - 1
self.report({"INFO"}, f"Added IES light: {Path(self.filepath).name}")
return {"FINISHED"}
class RemoveIESLight(bpy.types.Operator):
"""Remove an IES light fixture mapping"""
bl_idname = "radiance.remove_ies_light"
bl_label = "Remove IES Light"
bl_options = {"REGISTER", "UNDO"}
index: bpy.props.IntProperty()
def execute(self, context):
props = tool.Blender.get_radiance_exporter_props()
if 0 <= self.index < len(props.ies_lights):
props.ies_lights.remove(self.index)
# Adjust active index if needed
if props.active_ies_light_index >= len(props.ies_lights):
props.active_ies_light_index = len(props.ies_lights) - 1
self.report({"INFO"}, "IES light removed")
return {"FINISHED"}
self.report({"WARNING"}, "Invalid IES light index")
return {"CANCELLED"}
+40 -5
View File
@@ -18,21 +18,18 @@
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import TYPE_CHECKING
import bpy
if TYPE_CHECKING:
from bonsai.bim.module.light.prop import (
IESLight,
RadianceExporterProperties,
RadianceMaterial,
)
with open(os.path.join(os.path.dirname(__file__), "spectraldb.json"), "r") as f:
spectraldb = json.load(f)
class MATERIAL_UL_radiance_materials(bpy.types.UIList):
def draw_item(
@@ -64,3 +61,41 @@ class MATERIAL_UL_radiance_materials(bpy.types.UIList):
op.material_index = index
else:
row.label(text="Not Mapped (White)")
class MATERIAL_UL_ies_lights(bpy.types.UIList):
"""UIList for displaying IES light fixtures."""
def draw_item(
self,
context,
layout: bpy.types.UILayout,
data: RadianceExporterProperties,
item: IESLight,
icon,
active_data,
active_propname,
index,
) -> None:
if self.layout_type in {"DEFAULT", "COMPACT"}:
row = layout.row(align=True)
# Enable/disable checkbox (visible toggle)
row.prop(item, "is_enabled", text="", emboss=True)
# IES file name
if item.ies_file_path:
filename = Path(item.ies_file_path).name
row.label(text=filename, icon="FILE")
else:
row.label(text="(No file selected)", icon="ERROR")
# Target: collection or single object
if item.use_collection:
row.prop(item, "target_collection", text="", icon="OUTLINER_COLLECTION", emboss=False)
else:
row.prop(item, "target_object", text="", emboss=False)
# Remove button (X icon - negative action)
op = row.operator("radiance.remove_ies_light", text="", icon="X")
op.index = index
@@ -0,0 +1,136 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2020, 2021 Dion Moult <dion@thinkmoult.com>
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import json
import webbrowser
import bpy
from bpy_extras.io_utils import ExportHelper, ImportHelper
import bonsai.tool as tool
class RefreshIFCMaterials(bpy.types.Operator):
bl_idname = "bim.refresh_ifc_materials"
bl_label = "Refresh IFC Materials"
bl_description = "Refresh the list of IFC materials for mapping"
@classmethod
def poll(cls, context):
if not tool.Ifc.get():
cls.poll_message_set("No IFC file loaded in Bonsai.")
return False
return True
def execute(self, context):
props = tool.Blender.get_radiance_exporter_props()
ifc_file = tool.Ifc.get()
props.materials.clear()
for style in ifc_file.by_type("IfcSurfaceStyle"):
for render_item in style.Styles:
if render_item.is_a("IfcSurfaceStyleRendering"):
style_id = f"IfcSurfaceStyleRendering-{render_item.id()}"
style_name = style.Name or f"Unnamed Style {render_item.id()}"
# Extract color
color = (1.0, 1.0, 1.0) # Default white
if render_item.SurfaceColour:
color = (
render_item.SurfaceColour.Red,
render_item.SurfaceColour.Green,
render_item.SurfaceColour.Blue,
)
material = props.add_material_mapping(style_id, style_name)
material.color = color
material.category = ""
material.subcategory = ""
material.is_mapped = False
props.active_material_index = 0 if props.materials else -1
self.report({"INFO"}, f"Refreshed {len(props.materials)} IFC materials")
return {"FINISHED"}
class UnmapMaterial(bpy.types.Operator):
bl_idname = "bim.unmap_material"
bl_label = "Unmap Material"
bl_options = {"REGISTER", "UNDO"}
material_index: bpy.props.IntProperty()
def execute(self, context):
props = tool.Blender.get_radiance_exporter_props()
material = props.materials[self.material_index]
props.unmap_material(material.name)
return {"FINISHED"}
class RADIANCE_OT_export_material_mappings(bpy.types.Operator, ExportHelper):
bl_idname = "radiance.export_material_mappings"
bl_label = "Export Material Mappings"
bl_description = "Export material mappings to a JSON file"
filename_ext = ".json"
filter_glob: bpy.props.StringProperty(default="*.json", options={"HIDDEN"})
def execute(self, context):
props = tool.Blender.get_radiance_exporter_props()
mappings = {}
for material in props.materials:
if material.is_mapped:
mappings[material.style_id] = {
"name": material.name,
"category": material.category,
"subcategory": material.subcategory,
}
with open(self.filepath, "w") as f:
json.dump(mappings, f, indent=4)
self.report({"INFO"}, f"Material mappings exported to {self.filepath}")
return {"FINISHED"}
class RADIANCE_OT_import_material_mappings(bpy.types.Operator, ImportHelper):
bl_idname = "radiance.import_material_mappings"
bl_label = "Import Material Mappings"
bl_description = "Import material mappings from a JSON file"
filename_ext = ".json"
def execute(self, context):
props = tool.Blender.get_radiance_exporter_props()
props.import_mappings(self.filepath)
self.report({"INFO"}, f"Material mappings imported from {self.filepath}")
return {"FINISHED"}
class RADIANCE_OT_open_spectraldb(bpy.types.Operator):
bl_idname = "radiance.open_spectraldb"
bl_label = "Open SpectralDB"
bl_description = "Open the SpectralDB website for reference"
def execute(self, context):
webbrowser.open("https://spectraldb.com")
return {"FINISHED"}
+41 -713
View File
@@ -16,716 +16,44 @@
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import json
import math
import multiprocessing
import os
import time
import webbrowser
from datetime import datetime
from math import radians
from pathlib import Path
from typing import TYPE_CHECKING, Union
import bpy
import ifcopenshell
import ifcopenshell.geom
import ifcopenshell.ifcopenshell_wrapper as W
import ifcopenshell.util.geolocation
import pyradiance as pr
import requests
from bpy_extras.io_utils import ExportHelper, ImportHelper
from mathutils import Vector
import bonsai.tool as tool
from bonsai.bim.module.light.data import SolarData
ifc_materials = []
with open(os.path.join(os.path.dirname(__file__), "spectraldb.json"), "r") as f:
spectraldb = json.load(f)
class ExportOBJ(bpy.types.Operator):
"""Exports the IFC File to OBJ"""
bl_idname = "export_scene.radiance"
bl_label = "Export"
bl_description = "Export the IFC to OBJ"
@classmethod
def poll(cls, context):
props = tool.Blender.get_radiance_exporter_props()
if not props.should_load_from_memory and not props.ifc_file:
cls.poll_message_set("Select an IFC file or use 'load from memory' if it's loaded in Bonsai.")
return False
return True
def execute(self, context):
# Get the output directory
props = tool.Blender.get_radiance_exporter_props()
should_load_from_memory = props.should_load_from_memory
output_dir = props.output_dir
props.is_exporting = True
# Conversion from IFC to OBJ
# Settings for obj
settings = ifcopenshell.geom.settings()
serializer_settings = ifcopenshell.geom.serializer_settings()
settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.SURFACES_AND_SOLIDS)
settings.set("apply-default-materials", True)
serializer_settings.set("use-element-guids", True)
settings.set("use-world-coords", True)
ifc_file: ifcopenshell.file
if should_load_from_memory:
ifc_file = tool.Ifc.get()
else:
ifc_file_path = props.ifc_file
ifc_file = ifcopenshell.open(ifc_file_path)
obj_file_path = os.path.join(output_dir, "model.obj")
mtl_file_path = os.path.join(output_dir, "model.mtl")
serialiser = ifcopenshell.geom.serializers.obj(obj_file_path, mtl_file_path, settings, serializer_settings)
serialiser.setFile(ifc_file)
serialiser.setUnitNameAndMagnitude("METER", 1.0)
serialiser.writeHeader()
if ifc_file.schema in ("IFC2X3", "IFC4"):
elements = ifc_file.by_type("IfcElement") + ifc_file.by_type("IfcProxy")
else:
elements = ifc_file.by_type("IfcElement")
elements += ifc_file.by_type("IfcSite")
elements = [e for e in elements if not e.is_a("IfcFeatureElement") or e.is_a("IfcSurfaceFeature")]
iterator = ifcopenshell.geom.iterator(settings, ifc_file, multiprocessing.cpu_count(), include=elements)
if iterator.initialize():
while True:
shape = iterator.get()
assert isinstance(shape, W.TriangulationElement)
materials = shape.geometry.materials
for material in materials:
ifc_materials.append(material.name)
serialiser.write(shape)
if not iterator.next():
break
serialiser.finalize()
props.is_exporting = False
self.report({"INFO"}, "Exported OBJ file to: {}".format(obj_file_path))
return {"FINISHED"}
class RadianceRender(bpy.types.Operator):
"""Radiance Rendering"""
bl_idname = "render_scene.radiance"
bl_label = "Render"
bl_description = "Renders the scene using Radiance"
def execute(self, context):
print("Starting Radiance rendering process...")
props = tool.Blender.get_radiance_exporter_props()
resolution_x, resolution_y = props.radiance_resolution_x, props.radiance_resolution_y
assert context.scene
context.scene.render.resolution_x = resolution_x
context.scene.render.resolution_y = resolution_y
aspect_ratio = resolution_x / resolution_y
quality = props.radiance_quality.upper()
detail = props.radiance_detail.upper()
variability = props.radiance_variability.upper()
output_dir = props.output_dir
output_file_name = props.output_file_name
output_file_format = props.output_file_format
use_hdr = props.use_hdr
choose_hdr_image = props.choose_hdr_image
print(f"Resolution: {resolution_x}x{resolution_y}")
print(f"Quality: {quality}, Detail: {detail}, Variability: {variability}")
print(f"Output directory: {output_dir}")
if use_hdr:
hdr_image = "noon_grass_2k.hdr"
hdr_mask = "noon_grass_2k_mask.hdr"
sky_map_cal = "skymap.cal"
hdr_image_path = os.path.join(os.path.dirname(__file__), "HDRs", hdr_image)
hdr_mask_path = os.path.join(os.path.dirname(__file__), "HDRs", hdr_mask)
sky_map_cal_path = os.path.join(os.path.dirname(__file__), "HDRs", sky_map_cal)
obj_file_path = os.path.join(output_dir, "model.obj")
sun_props = tool.Blender.get_solar_props()
sun_pos_props = tool.Blender.get_sun_props()
assert sun_pos_props
sky_file_path = os.path.join(output_dir, "sky.rad")
# latitude = sun_props.latitude
# longitude = sun_props.longitude
# month = sun_props.month
# day = sun_props.day
# hour = sun_props.hour
# minute = sun_props.minute
# print("Sun Properties:")
# print("Latitude: ", latitude)
# print("Longitude: ", longitude)
# print("Timezone: ", timezone)
# print("Month: ", month)
# print("Day: ", day)
# print("Hour: ", hour)
# print("Minute: ", minute)
print("Setting up camera...")
if props.use_active_camera:
camera = context.scene.camera
else:
camera = props.selected_camera
if camera is None:
self.report({"ERROR"}, "No active camera found in the scene. Please add a camera and set it as active.")
return {"CANCELLED"}
# Get camera position and direction
camera_position, camera_direction = self.get_camera_data(camera)
print(f"Camera position: {camera_position}")
print(f"Camera direction: {camera_direction}")
# sun_position = tool.Blender.get_addon("sun_position")
# azimuth, elevation = sun_position.sun_calc.get_sun_coordinates(
# sun_pos_props.time,
# sun_pos_props.latitude,
# sun_pos_props.longitude,
# -sun_pos_props.UTC_zone,
# sun_pos_props.month,
# sun_pos_props.day,
# sun_pos_props.year,
# )
dt = datetime(sun_pos_props.year, sun_props.month, sun_props.day, sun_props.hour, sun_props.minute)
sky_description = pr.gensky(
dt=dt,
# azimuth=64.1,
# altitude=-26.6,
latitude=sun_props.latitude,
longitude=sun_props.longitude,
year=sun_pos_props.year,
timezone=-int(sun_props.UTC_zone),
# sunny_with_sun=False,
# sunny_without_sun=False,
# cloudy=False,
# ground_reflectance=0.2,
# turbidity=3.0,
)
sky_description_str = sky_description.decode("utf-8")
# Write all this to file
# skyfunc glow sky_glow
# 0
# 0
# 4 .9 .9 1.15 0
# sky_glow source sky
# 0
# 0
# 4 0 0 1 180
# skyfunc glow ground_glow
# 0
# 0
# 4 1.4 .9 .6 0
# ground_glow source ground
# 0
# 0
# 4 0 0 -1 180
if use_hdr and choose_hdr_image == "Noon":
with open(sky_file_path, "w") as f:
f.write(sky_description_str)
f.write("\n")
# f.write("skyfunc glow sky_glow\n0\n0\n4 .9 .9 1.15 0\n")
# f.write("sky_glow source sky\n0\n0\n4 0 0 1 180\n")
# f.write("skyfunc glow ground_glow\n0\n0\n4 1.4 .9 .6 0\n")
# f.write("ground_glow source ground\n0\n0\n4 0 0 -1 180\n")
f.write(
'''void colorpict env_map
7 red green blue "'''
+ hdr_image_path
+ '''" "'''
+ sky_map_cal_path
+ '''" map_u map_v
0
1 0.5
# This is a multiplier to colour balance the env map
# In this case, it provides a rough ground luminance from 3k-5k
env_map colorfunc env_colour
4 100 100 100 .
0
0
# .37 .57 1.5 is measured from a HDRI image
# It is multiplied by a factor such that grey(r,g,b) = 1
skyfunc colorfunc sky_colour
4 .64 .99 2.6 .
0
0
void mixpict composite
7 env_colour sky_colour grey "'''
+ hdr_mask_path
+ '''" "'''
+ sky_map_cal_path
+ """" map_u map_v
0
2 0.5 1
composite glow env_map_glow
0
0
4 1 1 1 0
env_map_glow source sky
0
0
4 0 0 1 180
env_colour glow ground_glow
0
0
4 1 1 1 0
ground_glow source ground
0
0
4 0 0 -1 180"""
)
elif not use_hdr:
with open(sky_file_path, "w") as f:
f.write(sky_description_str)
f.write("\n")
# f.write("skyfunc glow sky_glow\n0\n0\n4 .9 .9 1.15 0\n")
# f.write("sky_glow source sky\n0\n0\n4 0 0 1 180\n")
# f.write("skyfunc glow ground_glow\n0\n0\n4 1.4 .9 .6 0\n")
# f.write("ground_glow source ground\n0\n0\n4 0 0 -1 180\n")
props = tool.Blender.get_radiance_exporter_props()
data = props.get_mappings_dict()
materials_file = os.path.join(output_dir, "materials.rad")
written_materials = set()
all_materials = set(ifc_materials)
with open(materials_file, "w") as file:
# Write default materials
default_materials = [
"void plastic white\n0\n0\n5 0.8 0.8 0.8 0 0\n",
# "void plastic blue_plastic\n0\n0\n5 0.1 0.2 0.8 0.05 0.1\n",
# "void plastic red_plastic\n0\n0\n5 0.8 0.1 0.2 0.05 0.1\n",
# "void metal silver_metal\n0\n0\n5 0.8 0.8 0.8 0.9 0.1\n",
# "void glass clear_glass\n0\n0\n3 0.96 0.96 0.96\n",
# "void light white_light\n0\n0\n3 1.0 1.0 1.0\n",
# "void trans olive_trans\n0\n0\n7 0.6 0.7 0.4 0.05 0.05 0.7 0.2\n",
]
for material in default_materials:
file.write(material)
written_materials.add(material.split()[2]) # Add material name to written set
for style_id in all_materials:
material = next((m for m in props.materials if m.style_id == style_id), None)
if material and material.is_mapped:
category, subcategory = material.category, material.subcategory
if category in spectraldb and subcategory in spectraldb[category]:
material_def = spectraldb[category][subcategory]
material_name = material_def.split()[2]
if material_name not in written_materials:
file.write(material_def + "\n")
written_materials.add(material_name)
file.write(f"inherit alias {style_id} {material_name}\n")
else:
file.write(f"inherit alias {style_id} white\n")
else:
# If the material is not mapped, alias it to white
file.write(f"inherit alias {style_id} white\n")
self.report({"INFO"}, f"Exported Materials Rad file to: {materials_file}")
# Run obj2mesh
rtm_file_path = os.path.join(output_dir, "model.rtm")
mesh_file_path = save_obj2mesh_output(obj_file_path, rtm_file_path, matfiles=[materials_file])
# subprocess.run(["obj2mesh", "-a", materials_file, obj_file_path, rtm_file_path])
self.report({"INFO"}, "obj2mesh output: {}".format(mesh_file_path))
scene_file = os.path.join(output_dir, "scene.rad")
with open(scene_file, "w") as file:
file.write('void mesh model\n1 "' + rtm_file_path + '"\n0\n0\n')
self.report({"INFO"}, "Exported Scene file to: {}".format(scene_file))
print("Setting up Radiance scene...")
scene = pr.Scene("ascene")
material_path = os.path.join(output_dir, "materials.rad")
scene_path = os.path.join(output_dir, "scene.rad")
scene.add_material(material_path)
scene.add_surface(scene_path)
scene.add_source(sky_file_path)
print("Setting up view...")
assert isinstance(camera.data, bpy.types.Camera)
if camera.data.type == "PERSP":
# Perspective camera
camera_fov = camera.data.angle
# Calculate vertical FOV based on the desired aspect ratio
vertical_fov = 2 * math.atan(math.tan(camera_fov / 2) / aspect_ratio)
aview = pr.View(
vtype="v", # Perspective view
position=camera_position,
direction=camera_direction,
vup=(0, 0, 1), # Assuming Z is up
horiz=math.degrees(camera_fov),
vert=math.degrees(vertical_fov),
)
else: # 'ORTHO'
# Orthographic camera
# Calculate the view size based on the camera's orthographic scale
ortho_scale = camera.data.ortho_scale
view_width = ortho_scale
view_height = ortho_scale / aspect_ratio
aview = pr.View(
vtype="l", # Parallel projection (orthographic)
position=camera_position,
direction=camera_direction,
vup=(0, 0, 1), # Assuming Z is up
horiz=view_width,
vert=view_height,
)
scene.add_view(aview)
print("Starting render...")
start_time = time.time()
image = pr.render(
scene,
ambbounce=1,
resolution=(resolution_x, resolution_y),
quality=quality,
detail=detail,
variability=variability,
nproc=multiprocessing.cpu_count(),
)
end_time = time.time()
print(f"Render completed in {end_time - start_time:.2f} seconds")
output_hdr_path = os.path.join(output_dir, f"{output_file_name}.{output_file_format.lower()}")
print(f"Saving HDR output to: {output_hdr_path}")
if output_file_format == "HDR":
with open(output_hdr_path, "wb") as wtr:
wtr.write(image)
else:
pass
print("Applying tone mapping...")
pcond_image = pr.pcond(hdr=output_hdr_path, human=True)
tiff_path = os.path.join(output_dir, f"{output_file_name}.tiff")
print(f"Saving TIFF output to: {tiff_path}")
pr.ra_tiff(inp=pcond_image, out=tiff_path, lzw=True)
print("Radiance rendering process completed successfully.")
self.report({"INFO"}, "Radiance rendering completed. Output: {}".format(tiff_path))
return {"FINISHED"}
def get_active_camera(self, context):
props = tool.Blender.get_radiance_exporter_props()
if props.use_active_camera:
return context.scene.camera
else:
return props.selected_camera
def get_camera_data(self, camera):
# Get camera position
position = camera.matrix_world.to_translation()
# Get camera direction
direction = camera.matrix_world.to_quaternion() @ Vector((0, 0, -1))
direction.normalize()
return (position.x, position.y, position.z), (direction.x, direction.y, direction.z)
def getResolution(self, context):
props = tool.Blender.get_radiance_exporter_props()
resolution_x = props.radiance_resolution_x
resolution_y = props.radiance_resolution_y
return resolution_x, resolution_y
def save_obj2mesh_output(inp: Union[bytes, str, Path], output_file: str, **kwargs):
output_bytes = pr.obj2mesh(inp, **kwargs)
with open(output_file, "wb") as f:
f.write(output_bytes)
return output_file
class ImportTrueNorth(bpy.types.Operator):
bl_idname = "bim.import_true_north"
bl_label = "Import True North"
bl_description = "Imports the True North from your IFC geometric context"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
if not tool.Ifc.get():
return False
if not SolarData.is_loaded:
SolarData.load()
return SolarData.data["true_north"] is not None
def execute(self, context):
props = tool.Blender.get_solar_props()
for context in tool.Ifc.get().by_type("IfcGeometricRepresentationContext", include_subtypes=False):
if not context.TrueNorth:
continue
value = context.TrueNorth.DirectionRatios
props.true_north = radians(ifcopenshell.util.geolocation.yaxis2angle(*value[:2]))
return {"FINISHED"}
class ImportLatLong(bpy.types.Operator):
bl_idname = "bim.import_lat_long"
bl_label = "Import Latitude / Longitude"
bl_description = "Imports the latitude / longitude from an IfcSite"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
props = tool.Blender.get_solar_props()
site = tool.Ifc.get().by_id(int(props.sites))
if site.RefLatitude and site.RefLongitude:
props.latitude = ifcopenshell.util.geolocation.dms2dd(*site.RefLatitude)
props.longitude = ifcopenshell.util.geolocation.dms2dd(*site.RefLongitude)
return {"FINISHED"}
class MoveSunPathTo3DCursor(bpy.types.Operator):
bl_idname = "bim.move_sun_path_to_3d_cursor"
bl_label = "Move Sun Path To 3D Cursor"
bl_description = "Shifts the visualisation of the Sun Path to the 3D cursor"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
props = tool.Blender.get_solar_props()
assert context.scene
props.sun_path_origin = context.scene.cursor.location
tool.Blender.update_viewport()
return {"FINISHED"}
class ViewFromSun(bpy.types.Operator):
bl_idname = "bim.view_from_sun"
bl_label = "View From Sun"
bl_description = "Views your model as if you were looking from the perspective of the sun"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
if not (camera := bpy.data.objects.get("SunPathCamera")):
camera = bpy.data.objects.new("SunPathCamera", bpy.data.cameras.new("SunPathCamera"))
assert isinstance(camera.data, bpy.types.Camera)
assert context.scene
camera.data.type = "ORTHO"
camera.data.ortho_scale = 100 # The default of 6m is too small
context.scene.collection.objects.link(camera)
tool.Blender.activate_camera(camera)
props = tool.Blender.get_solar_props()
props.hour = props.hour # Just to refresh camera position
return {"FINISHED"}
class LightPickCoordinates(bpy.types.Operator):
bl_idname = "bim.light_pick_coordinates"
bl_label = "Pick Coordinates"
bl_description = (
"Open web browser with Google Maps to pick coordinates (Right Mouse Click in maps to copy selected location).\n\n"
"ALT+Click to insert current location based on the current IP-address (using ip-api.com)."
)
bl_options = {"REGISTER", "UNDO"}
use_current_location: bpy.props.BoolProperty(options={"SKIP_SAVE"}) # pyright: ignore[reportRedeclaration]
if TYPE_CHECKING:
use_current_location: bool
def invoke(self, context, event):
if event.alt:
self.use_current_location = True
return self.execute(context)
def execute(self, context):
props = tool.Blender.get_solar_props()
if not self.use_current_location:
zoom = 13.5
url = f"https://www.google.com/maps/@{props.latitude},{props.longitude},{zoom}z"
webbrowser.open(url)
return {"FINISHED"}
response = requests.get("http://ip-api.com/json/")
data = response.json()
props.latitude = data["lat"]
props.longitude = data["lon"]
return {"FINISHED"}
class LightSetTimeToNow(bpy.types.Operator):
bl_idname = "bim.light_set_time_to_now"
bl_label = "Now"
bl_description = "Set time to current local time."
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
props = tool.Blender.get_solar_props()
props.set_from_datetime(datetime.now())
return {"FINISHED"}
class RefreshIFCMaterials(bpy.types.Operator):
bl_idname = "bim.refresh_ifc_materials"
bl_label = "Refresh IFC Materials"
bl_description = "Refresh the list of IFC materials for mapping"
def execute(self, context):
props = tool.Blender.get_radiance_exporter_props()
ifc_file: ifcopenshell.file
ifc_file = tool.Ifc.get() if props.should_load_from_memory else ifcopenshell.open(props.ifc_file)
props.materials.clear()
for style in ifc_file.by_type("IfcSurfaceStyle"):
for render_item in style.Styles:
if render_item.is_a("IfcSurfaceStyleRendering"):
style_id = f"IfcSurfaceStyleRendering-{render_item.id()}"
style_name = style.Name or f"Unnamed Style {render_item.id()}"
# Extract color and transparency
color = (1.0, 1.0, 1.0) # Default white
transparency = 0.0 # Default opaque
if render_item.SurfaceColour:
color = (
render_item.SurfaceColour.Red,
render_item.SurfaceColour.Green,
render_item.SurfaceColour.Blue,
)
if hasattr(render_item, "Transparency") and render_item.Transparency is not None:
transparency = render_item.Transparency
# Add material with color
material = props.add_material_mapping(style_id, style_name)
material.color = color
# If transparency is high, consider it as glass
if transparency > 0.5:
material.category = "Glass"
material.subcategory = "Clear Glass"
material.is_mapped = True
props.active_material_index = 0 if props.materials else -1
self.report({"INFO"}, f"Refreshed {len(props.materials)} IFC materials")
return {"FINISHED"}
class UnmapMaterial(bpy.types.Operator):
bl_idname = "bim.unmap_material"
bl_label = "Unmap Material"
bl_options = {"REGISTER", "UNDO"}
material_index: bpy.props.IntProperty()
def execute(self, context):
props = tool.Blender.get_radiance_exporter_props()
material = props.materials[self.material_index]
props.unmap_material(material.name)
return {"FINISHED"}
class RADIANCE_OT_select_camera(bpy.types.Operator):
bl_idname = "radiance.select_camera"
bl_label = "Select Camera"
bl_description = "Select a camera from the viewport"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
return context.object is not None and context.object.type == "CAMERA"
def execute(self, context):
props = tool.Blender.get_radiance_exporter_props()
props.selected_camera = context.object
props.use_active_camera = False
return {"FINISHED"}
class RADIANCE_OT_export_material_mappings(bpy.types.Operator, ExportHelper):
bl_idname = "radiance.export_material_mappings"
bl_label = "Export Material Mappings"
bl_description = "Export material mappings to a JSON file"
filename_ext = ".json"
filter_glob: bpy.props.StringProperty(default="*.json", options={"HIDDEN"})
def execute(self, context):
props = tool.Blender.get_radiance_exporter_props()
mappings = {}
for material in props.materials:
if material.is_mapped:
mappings[material.style_id] = {
"name": material.name,
"category": material.category,
"subcategory": material.subcategory,
}
with open(self.filepath, "w") as f:
json.dump(mappings, f, indent=4)
self.report({"INFO"}, f"Material mappings exported to {self.filepath}")
return {"FINISHED"}
class RADIANCE_OT_import_material_mappings(bpy.types.Operator, ImportHelper):
bl_idname = "radiance.import_material_mappings"
bl_label = "Import Material Mappings"
bl_description = "Import material mappings from a JSON file"
filename_ext = ".json"
def execute(self, context):
props = tool.Blender.get_radiance_exporter_props()
props.import_mappings(self.filepath)
self.report({"INFO"}, f"Material mappings imported from {self.filepath}")
return {"FINISHED"}
class RADIANCE_OT_open_spectraldb(bpy.types.Operator):
bl_idname = "radiance.open_spectraldb"
bl_label = "Open SpectralDB"
bl_description = "Open the SpectralDB website for reference"
def execute(self, context):
webbrowser.open("https://spectraldb.com")
return {"FINISHED"}
"""Thin re-export module for backward compatibility.
All operator classes are defined in their respective submodules:
- export.py: ExportOBJ, CleanupRadianceFiles
- prepare.py: PrepareRadianceScene
- render.py: RadianceRender, FalseColorRadiance, RADIANCE_OT_select_camera
- solar.py: ImportTrueNorth, ImportLatLong, MoveSunPathTo3DCursor,
ViewFromSun, LightPickCoordinates, LightSetTimeToNow
- material.py: RefreshIFCMaterials, UnmapMaterial,
RADIANCE_OT_export_material_mappings,
RADIANCE_OT_import_material_mappings,
RADIANCE_OT_open_spectraldb
- ies.py: AddIESLight, RemoveIESLight
EnumPropertySearch and SetEnumProperty are provided by the global
bonsai.bim.operator module (bl_idname "bim.enum_property_search").
"""
from bonsai.bim.module.light.export import CleanupRadianceFiles, ExportOBJ # noqa: F401
from bonsai.bim.module.light.ies import AddIESLight, RemoveIESLight # noqa: F401
from bonsai.bim.module.light.material import ( # noqa: F401
RADIANCE_OT_export_material_mappings,
RADIANCE_OT_import_material_mappings,
RADIANCE_OT_open_spectraldb,
RefreshIFCMaterials,
UnmapMaterial,
)
from bonsai.bim.module.light.prepare import PrepareRadianceScene # noqa: F401
from bonsai.bim.module.light.render import ( # noqa: F401
FalseColorRadiance,
RADIANCE_OT_select_camera,
RadianceRender,
)
from bonsai.bim.module.light.solar import ( # noqa: F401
ImportLatLong,
ImportTrueNorth,
LightPickCoordinates,
LightSetTimeToNow,
MoveSunPathTo3DCursor,
ViewFromSun,
)
@@ -0,0 +1,741 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2020, 2021 Dion Moult <dion@thinkmoult.com>
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import math
import os
import threading
import time
from datetime import datetime
from pathlib import Path
from typing import Union
import bpy
import pyradiance as pr
from mathutils import Vector
import bonsai.tool as tool
from bonsai.bim.module.light.prop import spectraldb
from bonsai.bim.module.light.shared import ifc_materials, linked_model_exports
def _matrix_to_xform_args(matrix: list[list[float]]) -> str:
"""Decompose a 4x4 matrix into Radiance xform arguments.
Decomposes into Z/Y/X Euler rotations + translation.
The matrix is expected in row-major format (Blender convention).
xform applies transforms right-to-left, so we write: -rx X -ry Y -rz Z -t tx ty tz
"""
from mathutils import Matrix as MMatrix
m = MMatrix(matrix)
translation = m.to_translation()
euler = m.to_euler("XYZ")
parts = []
rx = math.degrees(euler.x)
ry = math.degrees(euler.y)
rz = math.degrees(euler.z)
if abs(rx) > 1e-6:
parts.append(f"-rx {rx:.6f}")
if abs(ry) > 1e-6:
parts.append(f"-ry {ry:.6f}")
if abs(rz) > 1e-6:
parts.append(f"-rz {rz:.6f}")
parts.append(f"-t {translation.x:.6f} {translation.y:.6f} {translation.z:.6f}")
return " ".join(parts)
def save_obj2mesh_output(inp: Union[bytes, str, Path], output_file: str, **kwargs):
try:
output_bytes = pr.obj2mesh(inp, **kwargs)
with open(output_file, "wb") as f:
f.write(output_bytes)
return output_file
except Exception as e:
print(f"ERROR in obj2mesh conversion:")
print(f" Input file: {inp}")
print(f" Output file: {output_file}")
print(f" Additional args: {kwargs}")
print(f" Error: {str(e)}")
raise
def convert_ies_to_radiance(
ies_file_path: str,
output_dir: str,
lamp_type: str = "",
lamp_color: tuple[float, float, float] = (1.0, 1.0, 1.0),
multiply_factor: float = 1.0,
radius: float = 0.0,
) -> tuple[str, str]:
"""Convert IES file to Radiance format using pyradiance.
Args:
ies_file_path: Path to the .ies file
output_dir: Directory where .rad and .dat files will be saved
lamp_type: Type of lamp (e.g., 'LED', 'metal halide')
lamp_color: RGB color tuple (0.0-1.0 each)
multiply_factor: Brightness multiplier (0.1-10.0)
radius: Illum sphere radius (0 = use IES geometry)
Returns:
Tuple of (rad_file_path, dat_file_path)
"""
try:
ies_path = Path(bpy.path.abspath(ies_file_path))
base_name = ies_path.stem
output_path = os.path.join(output_dir, base_name)
kwargs = {"outname": output_path}
if lamp_type:
kwargs["lamp_type"] = lamp_type
if lamp_color != (1.0, 1.0, 1.0):
kwargs["lamp_color"] = lamp_color
if multiply_factor != 1.0:
kwargs["multiply_factor"] = multiply_factor
if radius > 0.0:
kwargs["radius"] = radius
pr.ies2rad(ies_path, **kwargs)
rad_file = os.path.join(output_dir, f"{base_name}.rad")
dat_file = os.path.join(output_dir, f"{base_name}.dat")
return rad_file, dat_file
except Exception as e:
raise RuntimeError(f"Failed to convert IES file '{ies_file_path}': {str(e)}")
class PrepareRadianceScene(bpy.types.Operator):
"""Prepares the Radiance scene (runs heavy work in background thread)"""
bl_idname = "scene.prepare_radiance"
bl_label = "Prepare Radiance Scene"
bl_description = "Prepares the Radiance scene by creating necessary files and setting up the view"
_timer = None
_thread: Union[threading.Thread, None] = None
_error: Union[str, None] = None
_start_time: float = 0.0
@classmethod
def poll(cls, context):
props = tool.Blender.get_radiance_exporter_props()
if not props.output_dir:
cls.poll_message_set("Output directory is not set.")
return False
if props.is_preparing:
cls.poll_message_set("Scene preparation is already in progress.")
return False
return True
def get_camera_data(self, camera):
position = camera.matrix_world.to_translation()
direction = camera.matrix_world.to_quaternion() @ Vector((0, 0, -1))
direction.normalize()
up = camera.matrix_world.to_quaternion() @ Vector((0, 1, 0))
up.normalize()
if abs(direction.dot(up)) > 0.99:
if abs(direction.dot(Vector((0, 1, 0)))) > 0.99:
reference = Vector((1, 0, 0))
else:
reference = Vector((0, 1, 0))
right = direction.cross(reference)
if right.length < 0.01:
reference = Vector((0, 0, 1))
right = direction.cross(reference)
right.normalize()
up = right.cross(direction)
up.normalize()
if up.length < 0.01:
up = Vector((0, 0, 1))
else:
up.normalize()
return (
(position.x, position.y, position.z),
(direction.x, direction.y, direction.z),
(up.x, up.y, up.z),
)
def execute(self, context):
print("Starting Radiance scene preparation...")
props = tool.Blender.get_radiance_exporter_props()
output_dir = props.output_dir
obj_file_path = os.path.join(output_dir, "model.obj")
if not os.path.exists(obj_file_path):
error_msg = "OBJ file not found. Please run 'Export Geometry for Simulation' first."
self.report({"ERROR"}, error_msg)
print(f"ERROR: {error_msg}")
print(f" Expected file: {obj_file_path}")
return {"CANCELLED"}
resolution_x, resolution_y = props.radiance_resolution_x, props.radiance_resolution_y
assert context.scene
context.scene.render.resolution_x = resolution_x
context.scene.render.resolution_y = resolution_y
aspect_ratio = resolution_x / resolution_y
use_hdr = props.use_hdr
choose_hdr_image = props.choose_hdr_image
print(f"Resolution: {resolution_x}x{resolution_y}")
print(f"Output directory: {output_dir}")
print(f"Found OBJ file: {obj_file_path} ({os.path.getsize(obj_file_path)} bytes)")
hdr_image_path = ""
hdr_mask_path = ""
sky_map_cal_path = ""
if use_hdr:
hdr_image_path = os.path.join(os.path.dirname(__file__), "HDRs", "noon_grass_2k.hdr")
hdr_mask_path = os.path.join(os.path.dirname(__file__), "HDRs", "noon_grass_2k_mask.hdr")
sky_map_cal_path = os.path.join(os.path.dirname(__file__), "HDRs", "skymap.cal")
sky_file_path = os.path.join(output_dir, "sky.rad")
print("Setting up camera...")
if props.use_active_camera:
camera = context.scene.camera
else:
camera = props.selected_camera
if camera is None:
self.report({"ERROR"}, "No active camera found in the scene. Please add a camera and set it as active.")
return {"CANCELLED"}
camera_position, camera_direction, camera_up = self.get_camera_data(camera)
camera_type = camera.data.type
camera_fov = camera.data.angle if camera_type == "PERSP" else 0.0
camera_ortho_scale = camera.data.ortho_scale if camera_type == "ORTHO" else 0.0
print(f"Camera position: {camera_position}")
print(f"Camera direction: {camera_direction}")
print(f"Camera up: {camera_up}")
# Collect sky generation data (must be on main thread)
sky_data = None
if props.use_sun:
sun_props = tool.Blender.get_solar_props()
sun_pos_props = tool.Blender.get_sun_props()
if not sun_pos_props:
self.report(
{"ERROR"}, "Sun position addon not available. Enable 'Sun Position' addon or disable 'Use Sun'."
)
return {"CANCELLED"}
sky_data = {
"year": sun_props.year,
"month": sun_props.month,
"day": sun_props.day,
"hour": sun_props.hour,
"minute": sun_props.minute,
"latitude": sun_props.latitude,
"longitude": sun_props.longitude,
"UTC_zone": sun_props.UTC_zone,
"sun_year": sun_pos_props.year,
"sky_condition": props.sky_condition,
"ground_reflectance": props.ground_reflectance,
"turbidity": props.turbidity,
}
# Collect IES light data (must be on main thread)
ies_light_data = []
for idx, ies_light in enumerate(props.ies_lights):
if not ies_light.is_enabled or not ies_light.ies_file_path:
ies_light_data.append(None)
continue
target_empties = ies_light.get_target_empties()
if not target_empties:
ies_light_data.append(None)
continue
positions = []
for obj in target_empties:
try:
positions.append((obj.location.x, obj.location.y, obj.location.z))
except ReferenceError:
continue
if not positions:
ies_light_data.append(None)
continue
ies_light_data.append(
{
"ies_file_path": ies_light.ies_file_path,
"lamp_type": ies_light.lamp_type,
"lamp_color": ies_light.lamp_color,
"multiply_factor": ies_light.multiply_factor,
"radius": ies_light.radius,
"rotation_z": ies_light.rotation_z,
"positions": positions,
"is_enabled": ies_light.is_enabled,
}
)
# Collect material mapping data (must be on main thread)
material_mappings = []
for m in props.materials:
material_mappings.append(
{
"style_id": m.style_id,
"is_mapped": m.is_mapped,
"category": m.category,
"subcategory": m.subcategory,
}
)
linked_exports_snapshot = list(linked_model_exports)
# All Blender data collected — now launch background thread
props.is_preparing = True
self._start_time = time.time()
self._error = None
import bonsai.bim.module.light.shared as shared
shared.scene = None
self._thread = threading.Thread(
target=self._prepare_worker,
args=(
output_dir,
obj_file_path,
sky_file_path,
use_hdr,
choose_hdr_image,
hdr_image_path,
hdr_mask_path,
sky_map_cal_path,
sky_data,
ies_light_data,
material_mappings,
linked_exports_snapshot,
camera_position,
camera_direction,
camera_up,
camera_type,
camera_fov,
camera_ortho_scale,
aspect_ratio,
),
daemon=True,
)
self._thread.start()
wm = context.window_manager
self._timer = wm.event_timer_add(0.5, window=context.window)
wm.modal_handler_add(self)
self.report({"INFO"}, "Scene preparation started in background...")
context.window.cursor_set("WAIT")
return {"RUNNING_MODAL"}
def _prepare_worker(
self,
output_dir,
obj_file_path,
sky_file_path,
use_hdr,
choose_hdr_image,
hdr_image_path,
hdr_mask_path,
sky_map_cal_path,
sky_data,
ies_light_data,
material_mappings,
linked_exports_snapshot,
camera_position,
camera_direction,
camera_up,
camera_type,
camera_fov,
camera_ortho_scale,
aspect_ratio,
):
"""Runs in a background thread — no Blender API calls allowed here."""
try:
self._do_prepare(
output_dir,
obj_file_path,
sky_file_path,
use_hdr,
choose_hdr_image,
hdr_image_path,
hdr_mask_path,
sky_map_cal_path,
sky_data,
ies_light_data,
material_mappings,
linked_exports_snapshot,
camera_position,
camera_direction,
camera_up,
camera_type,
camera_fov,
camera_ortho_scale,
aspect_ratio,
)
except Exception as e:
self._error = str(e)
import traceback
traceback.print_exc()
def _do_prepare(
self,
output_dir,
obj_file_path,
sky_file_path,
use_hdr,
choose_hdr_image,
hdr_image_path,
hdr_mask_path,
sky_map_cal_path,
sky_data,
ies_light_data,
material_mappings,
linked_exports_snapshot,
camera_position,
camera_direction,
camera_up,
camera_type,
camera_fov,
camera_ortho_scale,
aspect_ratio,
):
"""The actual preparation logic (no Blender API)."""
import bonsai.bim.module.light.shared as shared
# --- Generate sky file ---
if sky_data is not None:
dt = datetime(sky_data["year"], sky_data["month"], sky_data["day"], sky_data["hour"], sky_data["minute"])
print(f"Sun position data for Radiance gensky:")
print(f" DateTime: {dt}")
print(f" Latitude: {sky_data['latitude']}°")
print(f" Longitude: {sky_data['longitude']}°")
sky_condition = sky_data["sky_condition"]
longitude_for_gensky = -sky_data["longitude"]
timezone_for_gensky = -int(sky_data["UTC_zone"]) * 15
sky_description = pr.gensky(
dt=dt,
latitude=sky_data["latitude"],
longitude=longitude_for_gensky,
year=sky_data["sun_year"],
timezone=timezone_for_gensky,
sunny_with_sun=sky_condition == "SUNNY_WITH_SUN",
sunny_without_sun=sky_condition == "SUNNY_WITHOUT_SUN",
cloudy=sky_condition == "CLOUDY",
ground_reflectance=sky_data["ground_reflectance"],
turbidity=sky_data["turbidity"],
)
sky_description_str = sky_description.decode("utf-8")
if use_hdr and choose_hdr_image == "Noon":
with open(sky_file_path, "w") as f:
f.write(sky_description_str)
f.write("\n")
f.write(
'''void colorpict env_map
7 red green blue "'''
+ hdr_image_path
+ '''" "'''
+ sky_map_cal_path
+ '''" map_u map_v
0
1 0.5
# This is a multiplier to colour balance the env map
# In this case, it provides a rough ground luminance from 3k-5k
env_map colorfunc env_colour
4 100 100 100 .
0
0
# .37 .57 1.5 is measured from a HDRI image
# It is multiplied by a factor such that grey(r,g,b) = 1
skyfunc colorfunc sky_colour
4 .64 .99 2.6 .
0
0
void mixpict composite
7 env_colour sky_colour grey "'''
+ hdr_mask_path
+ '''" "'''
+ sky_map_cal_path
+ """" map_u map_v
0
2 0.5 1
composite glow env_map_glow
0
0
4 1 1 1 0
env_map_glow source sky
0
0
4 0 0 1 180
env_colour glow ground_glow
0
0
4 1 1 1 0
ground_glow source ground
0
0
4 0 0 -1 180"""
)
elif not use_hdr:
with open(sky_file_path, "w") as f:
f.write(sky_description_str)
f.write("\n")
f.write("skyfunc glow sky_glow\n0\n0\n4 .9 .9 1.15 0\n")
f.write("sky_glow source sky\n0\n0\n4 0 0 1 180\n")
f.write("skyfunc glow ground_glow\n0\n0\n4 1.4 .9 .6 0\n")
f.write("ground_glow source ground\n0\n0\n4 0 0 -1 180\n")
else:
print("Skipping sky generation (use_sun is False)...")
# --- Write materials.rad ---
materials_file = os.path.join(output_dir, "materials.rad")
written_materials = set()
all_materials = set(ifc_materials)
with open(materials_file, "w") as file:
default_materials = [
"void plastic white\n0\n0\n5 0.8 0.8 0.8 0 0\n",
]
for material in default_materials:
file.write(material)
written_materials.add(material.split()[2])
for style_id in all_materials:
mat_data = next((m for m in material_mappings if m["style_id"] == style_id), None)
if mat_data and mat_data["is_mapped"]:
category, subcategory = mat_data["category"], mat_data["subcategory"]
if category in spectraldb and subcategory in spectraldb[category]:
material_def = spectraldb[category][subcategory]
material_name = material_def.split()[2]
if material_name not in written_materials:
file.write(material_def + "\n")
written_materials.add(material_name)
file.write(f"inherit alias {style_id} {material_name}\n")
else:
file.write(f"inherit alias {style_id} white\n")
else:
file.write(f"inherit alias {style_id} white\n")
print(f"Exported Materials Rad file to: {materials_file}")
# --- Convert IES light files ---
print("Processing IES light files...")
converted_ies_lights = {}
for idx, ies_data in enumerate(ies_light_data):
if ies_data is None:
continue
try:
rad_file, dat_file = convert_ies_to_radiance(
ies_data["ies_file_path"],
output_dir,
lamp_type=ies_data["lamp_type"],
lamp_color=ies_data["lamp_color"],
multiply_factor=ies_data["multiply_factor"],
radius=ies_data["radius"],
)
converted_ies_lights[idx] = (rad_file, dat_file)
print(f" Converted IES light {idx}: {Path(ies_data['ies_file_path']).name}")
except Exception as e:
print(f" ERROR converting IES light {idx}: {str(e)}")
# --- Convert OBJ to RTM ---
print(f"OBJ file size: {os.path.getsize(obj_file_path)} bytes")
print(f"Materials file size: {os.path.getsize(materials_file)} bytes")
print(f"Converting OBJ to RTM format...")
rtm_file_path = os.path.join(output_dir, "model.rtm")
mesh_file_path = save_obj2mesh_output(obj_file_path, rtm_file_path, matfiles=[materials_file])
print(f"obj2mesh output: {mesh_file_path}")
# Convert linked model OBJs to RTM
linked_rtm_files = []
for link_idx, (link_obj_path, link_mtl_path, link_matrix) in enumerate(linked_exports_snapshot):
if not os.path.exists(link_obj_path):
print(f"Linked model OBJ not found: {link_obj_path}")
continue
link_rtm_path = os.path.join(output_dir, f"linked_{link_idx}.rtm")
try:
save_obj2mesh_output(link_obj_path, link_rtm_path, matfiles=[materials_file])
linked_rtm_files.append((link_rtm_path, link_matrix))
print(f"Converted linked model {link_idx} to RTM")
except Exception as e:
print(f"Failed to convert linked model {link_idx} to RTM: {e}")
# --- Write scene.rad ---
scene_file = os.path.join(output_dir, "scene.rad")
with open(scene_file, "w") as file:
file.write('void mesh model\n1 "' + rtm_file_path + '"\n0\n0\n')
file.write("\n")
if linked_rtm_files:
file.write("\n# Linked Models\n")
for link_idx, (link_rtm_path, link_matrix) in enumerate(linked_rtm_files):
is_identity = all(
abs(link_matrix[i][j] - (1.0 if i == j else 0.0)) < 1e-6 for i in range(4) for j in range(4)
)
if is_identity:
file.write(f'void mesh linked_{link_idx}\n1 "{link_rtm_path}"\n0\n0\n\n')
print(f"Added linked model {link_idx} to scene (identity, inline mesh)")
else:
link_rad_path = os.path.join(output_dir, f"linked_{link_idx}.rad")
with open(link_rad_path, "w") as link_file:
link_file.write(f'void mesh linked_{link_idx}\n1 "{link_rtm_path}"\n0\n0\n')
xform_args = _matrix_to_xform_args(link_matrix)
file.write(f'!xform {xform_args} "{link_rad_path}"\n')
print(f"Added linked model {link_idx} to scene with xform: {xform_args}")
# IES light fixtures
if ies_light_data:
file.write("\n# IES Light Fixtures\n")
for idx, ies_data in enumerate(ies_light_data):
if ies_data is None:
continue
z_rot = math.degrees(ies_data["rotation_z"])
for pos in ies_data["positions"]:
if idx in converted_ies_lights:
rad_path = converted_ies_lights[idx][0]
rad_filename = Path(rad_path).name
file.write(f'!xform -rz {z_rot} -t {pos[0]} {pos[1]} {pos[2]} "{rad_filename}"\n')
else:
rad_base = Path(ies_data["ies_file_path"]).stem
rad_filename = f"{rad_base}.rad"
file.write(f'# !xform -rz {z_rot} -t {pos[0]} {pos[1]} {pos[2]} "{rad_filename}"\n')
print(f"Exported Scene file to: {scene_file}")
# --- Validate light sources ---
has_sky = sky_data is not None
has_ies_lights = len(converted_ies_lights) > 0
if not has_sky and not has_ies_lights:
raise RuntimeError("No light sources available. Please enable 'Use Sun' or add and map IES light fixtures.")
# --- Build pr.Scene ---
print("Setting up Radiance scene...")
new_scene = pr.Scene("ascene")
material_path = os.path.join(output_dir, "materials.rad")
scene_path = os.path.join(output_dir, "scene.rad")
new_scene.add_material(material_path)
new_scene.add_surface(scene_path)
if has_sky:
new_scene.add_source(sky_file_path)
print(f"Added sky light source")
if has_ies_lights:
print(f"Added {len(converted_ies_lights)} IES light source(s)")
print("Setting up view...")
if camera_type == "PERSP":
vertical_fov = 2 * math.atan(math.tan(camera_fov / 2) / aspect_ratio)
aview = pr.create_default_view()
aview.type = "v"
aview.vp = camera_position
aview.vdir = camera_direction
aview.vu = camera_up
aview.horiz = math.degrees(camera_fov)
aview.vert = math.degrees(vertical_fov)
else:
view_width = camera_ortho_scale
view_height = camera_ortho_scale / aspect_ratio
aview = pr.create_default_view()
aview.type = "l"
aview.vp = camera_position
aview.vdir = camera_direction
aview.vu = camera_up
aview.horiz = view_width
aview.vert = view_height
new_scene.add_view(aview)
# Set the global scene reference (thread-safe assignment)
shared.scene = new_scene
print("Scene preparation complete.")
def modal(self, context, event):
if event.type == "TIMER":
if self._thread is not None and self._thread.is_alive():
return {"RUNNING_MODAL"}
# Thread finished — clean up
self._cleanup_timer(context)
props = tool.Blender.get_radiance_exporter_props()
props.is_preparing = False
context.window.cursor_set("DEFAULT")
if self._error:
self.report({"ERROR"}, f"Scene preparation failed: {self._error}")
return {"CANCELLED"}
elapsed = time.time() - self._start_time
self.report({"INFO"}, f"Radiance scene prepared successfully in {elapsed:.1f}s")
print(f"Scene preparation completed in {elapsed:.2f} seconds")
return {"FINISHED"}
elif event.type == "ESC":
self._cleanup_timer(context)
props = tool.Blender.get_radiance_exporter_props()
props.is_preparing = False
context.window.cursor_set("DEFAULT")
self.report({"WARNING"}, "Scene preparation cannot be cancelled mid-operation")
return {"RUNNING_MODAL"}
return {"PASS_THROUGH"}
def _cleanup_timer(self, context):
if self._timer is not None:
context.window_manager.event_timer_remove(self._timer)
self._timer = None
+312 -29
View File
@@ -16,6 +16,7 @@
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import calendar
import datetime
import json
import os
@@ -43,7 +44,6 @@ from bonsai.bim.module.light.data import SolarData
from bonsai.bim.module.light.decorator import SolarDecorator
sun_position = tool.Blender.get_addon("sun_position")
now = datetime.datetime.now()
with open(os.path.join(os.path.dirname(__file__), "spectraldb.json"), "r") as f:
spectraldb: dict[str, dict[str, str]] = json.load(f)
@@ -72,8 +72,8 @@ def update_coordinates(self: "BIMSolarProperties", context: bpy.types.Context) -
def update_latlong(self: "BIMSolarProperties", context: bpy.types.Context) -> None:
sun_props = tool.Blender.get_sun_props()
assert sun_props
sun_props.longitude = sun_props.longitude
sun_props.latitude = sun_props.latitude
sun_props.longitude = self.longitude
sun_props.latitude = self.latitude
self["coordinates"] = sun_props.coordinates
update_sun_path(self)
@@ -135,6 +135,14 @@ def update_resolution(self: "RadianceExporterProperties", context: bpy.types.Con
context.scene.render.resolution_y = self.radiance_resolution_y
def update_day(self: "BIMSolarProperties", context: bpy.types.Context) -> None:
"""Clamp the day to the valid range for the current month/year."""
max_day = calendar.monthrange(self.year, self.month)[1]
if self.day > max_day:
self["day"] = max_day
update_sun_path(self)
def update_sun_path(self: "BIMSolarProperties", context: Union[bpy.types.Context, None] = None) -> None:
if not SolarData.is_loaded:
SolarData.load()
@@ -152,9 +160,13 @@ def update_sun_path(self: "BIMSolarProperties", context: Union[bpy.types.Context
sun_props.sun_distance = self.sun_path_size
sun_props.latitude = self.latitude
sun_props.longitude = self.longitude
# Clamp day to valid range for the current month/year
max_day = calendar.monthrange(self.year, self.month)[1]
day = min(self.day, max_day)
sun_props.year = self.year
sun_props.month = self.month
sun_props.day = self.day
sun_props.day = day
sun_props.time = self.hour + (self.minute / 60)
# Preserve IFC sign convention
sun_props.north_offset = self.true_north * -1
@@ -181,8 +193,11 @@ def update_sun_path(self: "BIMSolarProperties", context: Union[bpy.types.Context
)
sun_vector = sun_position.sun_calc.get_sun_vector(azimuth, elevation) * sun_props.sun_distance
props.sun_position = sun_vector
# sun_vector.z = max(0, sun_vector.z)
# Light direction is a bit weird?
# Update Blender viewport light direction for shadow visualization
# This coordinate transformation converts from sun_position addon's coordinate system
# to Blender's display.light_direction coordinate system
# Note: This only affects viewport shading, not the Radiance rendering
mat = Matrix(((-1.0, 0.0, 0.0, 0.0), (0.0, 0, 1.0, 0.0), (-0.0, -1.0, 0, 0.0), (0.0, 0.0, 0.0, 1.0))).inverted()
rotation_euler = Euler((elevation - pi / 2, 0, -azimuth))
rotation_quaternion = rotation_euler.to_quaternion()
@@ -193,6 +208,9 @@ def update_sun_path(self: "BIMSolarProperties", context: Union[bpy.types.Context
assert bpy.context.scene
assert bpy.context.scene.display
# Set viewport light direction based on sun position
# If sun is below horizon (z < 0), use default upward direction
# Otherwise, use calculated direction from sun position
if sun_vector.z < 0:
bpy.context.scene.display.light_direction = mat @ Vector((0, 0, 1))
else:
@@ -223,6 +241,106 @@ class RadianceMaterial(PropertyGroup):
color: tuple[float, float, float]
class IESLight(PropertyGroup):
"""Represents a mapping between an IES light file and scene Empty objects.
Supports two targeting modes:
- Object mode: target a single Empty object
- Collection mode: target all Empty objects in a collection
"""
ies_file_path: StringProperty(
name="IES File Path",
description="Path to the IES luminaire data file",
subtype="FILE_PATH",
default="",
)
use_collection: BoolProperty(
name="Use Collection",
description="Apply this light to all Empty objects in a collection instead of a single object",
default=False,
)
target_object: PointerProperty(
type=bpy.types.Object,
name="Target Object",
description="Empty object where the light fixture will be placed",
poll=lambda self, obj: obj.type == "EMPTY",
)
target_collection: PointerProperty(
type=bpy.types.Collection,
name="Target Collection",
description="Collection of Empty objects where the light fixture will be placed",
)
rotation_z: FloatProperty(
name="Rotation Z",
description="Rotation around Z-axis in degrees (-180 to 180)",
min=-180.0,
max=180.0,
default=0.0,
subtype="ANGLE",
)
is_enabled: BoolProperty(
name="Enabled",
description="Include this light in the Radiance export",
default=True,
)
# Additional lamp properties for customization
lamp_type: StringProperty(
name="Lamp Type",
description="Type of lamp (e.g., 'LED', 'metal halide', 'fluorescent')",
default="",
)
lamp_color: FloatVectorProperty(
name="Lamp Color",
description="Lamp color (RGB) for custom color adjustments",
subtype="COLOR",
default=(1.0, 1.0, 1.0),
min=0.0,
max=1.0,
size=3,
)
multiply_factor: FloatProperty(
name="Brightness Factor",
description="Multiply all output quantities by this factor (0.1 to 10.0)",
min=0.1,
max=10.0,
default=1.0,
)
radius: FloatProperty(
name="Illum Sphere Radius",
description="Radius of illum sphere (ignores geometry from IES file). 0 = use IES geometry",
min=0.0,
max=10.0,
default=0.0,
)
def get_target_empties(self) -> list[bpy.types.Object]:
"""Return all Empty objects this light targets."""
if self.use_collection and self.target_collection is not None:
return [obj for obj in self.target_collection.all_objects if obj.type == "EMPTY" and not obj.hide_get()]
elif not self.use_collection and self.target_object is not None:
try:
_ = self.target_object.name
if not self.target_object.hide_get():
return [self.target_object]
except ReferenceError:
pass
return []
if TYPE_CHECKING:
ies_file_path: str
use_collection: bool
target_object: Union[bpy.types.Object, None]
target_collection: Union[bpy.types.Collection, None]
rotation_z: float
is_enabled: bool
lamp_type: str
lamp_color: tuple[float, float, float]
multiply_factor: float
radius: float
class RadianceExporterProperties(PropertyGroup):
def update_output_dir(self, context) -> None:
@@ -233,6 +351,9 @@ class RadianceExporterProperties(PropertyGroup):
if self.ifc_file:
self.ifc_file = bpy.path.abspath(self.ifc_file)
def get_categories(self, context):
return sorted([(k, k, "") for k in spectraldb.keys()])
def add_material_mapping(self, style_id: str, style_name: str) -> RadianceMaterial:
item = self.materials.add()
item.name = style_name
@@ -247,7 +368,10 @@ class RadianceExporterProperties(PropertyGroup):
mappings = json.load(f)
for style_id, mapping in mappings.items():
material = self.get_material_mapping(mapping["name"])
# Try matching by style_id first, fall back to name
material = self.get_material_mapping_by_id(style_id)
if material is None:
material = self.get_material_mapping(mapping["name"])
if material:
material.style_id = style_id
material.category = mapping["category"]
@@ -259,11 +383,14 @@ class RadianceExporterProperties(PropertyGroup):
new_material.subcategory = mapping["subcategory"]
new_material.is_mapped = True
def get_material_mapping_by_id(self, style_id: str) -> Union[RadianceMaterial, None]:
return next((item for item in self.materials if item.style_id == style_id), None)
def get_material_mapping(self, style_name: str) -> Union[RadianceMaterial, None]:
return next((item for item in self.materials if item.name == style_name), None)
def set_material_mapping(self, style_id: str, style_name: str, category: str, subcategory: str) -> None:
item = self.get_material_mapping(style_name)
item = self.get_material_mapping_by_id(style_id) or self.get_material_mapping(style_name)
if item:
item.category = category
item.subcategory = subcategory
@@ -279,8 +406,12 @@ class RadianceExporterProperties(PropertyGroup):
if item.category and item.subcategory
}
def unmap_material(self, style_name: str) -> None:
item = self.get_material_mapping(style_name)
def unmap_material(self, style_name: str, style_id: str = "") -> None:
item = None
if style_id:
item = self.get_material_mapping_by_id(style_id)
if item is None:
item = self.get_material_mapping(style_name)
if item:
item.category = ""
item.subcategory = ""
@@ -290,6 +421,14 @@ class RadianceExporterProperties(PropertyGroup):
name="Is Exporting", description="Whether the OBJ export is in progress", default=False
)
is_preparing: bpy.props.BoolProperty(
name="Is Preparing", description="Whether scene preparation is in progress", default=False
)
is_rendering: bpy.props.BoolProperty(
name="Is Rendering", description="Whether a Radiance render is in progress", default=False
)
categories = [
("Wall", "Wall", ""),
("Floor", "Floor", ""),
@@ -316,16 +455,13 @@ class RadianceExporterProperties(PropertyGroup):
print(f"Material '{active_material.name}' mapped to {self.category} - {self.subcategory}")
category: bpy.props.EnumProperty(
items=categories, name="Category", description="Material category", update=update_material_mapping
items=get_categories, name="Category", description="Material category", update=update_material_mapping
)
def get_subcategories(self, context: bpy.types.Context) -> tool.Blender.BLENDER_ENUM_ITEMS:
global SUBCATEGORIES_ENUM_ITEMS # ty: ignore[unresolved-global]
if self.category in spectraldb:
SUBCATEGORIES_ENUM_ITEMS = [(k, k, "") for k in spectraldb[self.category].keys()]
else:
SUBCATEGORIES_ENUM_ITEMS = []
return SUBCATEGORIES_ENUM_ITEMS
return sorted([(k, k, "") for k in spectraldb[self.category].keys()])
return []
subcategory: bpy.props.EnumProperty(
items=get_subcategories, name="Subcategory", description="Material subcategory", update=update_material_mapping
@@ -334,12 +470,9 @@ class RadianceExporterProperties(PropertyGroup):
materials: CollectionProperty(type=RadianceMaterial)
active_material_index: IntProperty()
# material_mappings: bpy.props.CollectionProperty(type=bpy.types.PropertyGroup, name="Material Mappings")
# material_mappings: CollectionProperty(type=MaterialMapping)
should_load_from_memory: BoolProperty(
name="Load from Memory",
default=False,
default=True,
)
radiance_resolution_x: IntProperty(
@@ -348,6 +481,13 @@ class RadianceExporterProperties(PropertyGroup):
radiance_resolution_y: IntProperty(
name="Y", description="Vertical resolution of the output image", default=1080, min=1, update=update_resolution
)
radiance_bin_dir: StringProperty(
name="Radiance Bin",
description="Path to the Radiance bin folder (containing falsecolor, pcomb, etc.)",
default="",
subtype="DIR_PATH",
)
output_dir: StringProperty(
name="Output Directory",
description="Directory to output Radiance files",
@@ -363,6 +503,15 @@ class RadianceExporterProperties(PropertyGroup):
update=lambda self, context: self.update_ifc_file(context),
)
ambient_bounces: IntProperty(
name="Ambient Bounces",
description="Number of indirect light bounces. Higher = more light fills dark areas but slower. "
"1 is minimal, 2-3 recommended for IES-only scenes, 4+ for complex interiors",
min=0,
max=8,
default=2,
)
radiance_quality: EnumProperty(
name="Quality",
description="Radiance rendering quality",
@@ -393,14 +542,15 @@ class RadianceExporterProperties(PropertyGroup):
name="Output File Format",
description="Format of the output image file",
items=[
("HDR", "HDR + Tiff", "High Dynamic Range"),
("HDR", "HDR", "High Dynamic Range (HDR) file only"),
("HDR_TIFF", "HDR + Tiff", "High Dynamic Range (HDR) and Tiff files"),
],
default="HDR",
default="HDR_TIFF",
)
use_hdr: BoolProperty(
name="Use HDR",
description="Use HDR image format",
name="HDR Environment Map",
description="Use an HDR image as the sky dome for realistic environment lighting and reflections",
default=True,
)
@@ -413,6 +563,40 @@ class RadianceExporterProperties(PropertyGroup):
default="Noon",
)
use_sun: BoolProperty(
name="Use Sun",
description="Use sun position data to generate sky. If disabled, generates a default sky without sun",
default=True,
)
# Sky generation parameters
sky_condition: EnumProperty(
name="Sky Condition",
description="Type of sky condition to generate",
items=[
("SUNNY_WITH_SUN", "Sunny with Sun", "Clear sky with direct sun"),
("SUNNY_WITHOUT_SUN", "Sunny without Sun", "Clear sky without direct sun"),
("CLOUDY", "Cloudy", "Overcast sky condition"),
],
default="SUNNY_WITH_SUN",
)
ground_reflectance: FloatProperty(
name="Ground Reflectance",
description="Ground reflectance value (0.0 to 1.0)",
min=0.0,
max=1.0,
default=0.2,
)
turbidity: FloatProperty(
name="Turbidity",
description="Atmospheric turbidity (1.0 to 10.0). Lower values = clearer sky",
min=1.0,
max=10.0,
default=3.0,
)
use_active_camera: BoolProperty(
name="Use Active Camera", description="Use the active camera in the scene", default=True
)
@@ -424,8 +608,90 @@ class RadianceExporterProperties(PropertyGroup):
poll=lambda self, object: object.type == "CAMERA",
)
ies_lights: CollectionProperty(
type=IESLight,
name="IES Lights",
description="Collection of IES light fixtures mapped to scene objects",
)
active_ies_light_index: IntProperty(
name="Active IES Light Index",
description="Index of the active IES light in the collection",
default=-1,
)
# False color analysis properties
use_false_color: BoolProperty(
name="Generate False Color Image",
description="Generate a false color HDR image for illuminance analysis",
default=False,
)
false_color_label: EnumProperty(
name="Legend Label Unit",
description="Unit for the false color legend",
items=[
("fc", "Foot-Candles", "US lighting standard unit"),
("lux", "Lux", "International lighting standard unit"),
("cd/m2", "Candela per m²", "Luminance unit"),
],
default="fc",
)
false_color_scale: FloatProperty(
name="Scale Factor",
description="Maximum scale value for the false color legend",
min=0.1,
max=100000.0,
default=3.0,
)
false_color_steps: IntProperty(
name="Legend Steps",
description="Number of divisions on the legend. Contour increment = Scale / Steps. "
"E.g. Scale=20, Steps=10 → contours at 2, 4, 6, 8...",
min=2,
max=50,
default=10,
)
false_color_contour_lines: BoolProperty(
name="Enable Contour Lines",
description="Add contour lines to the false color image",
default=True,
)
false_color_contour_mode: EnumProperty(
name="Contour Mode",
description="Contour line display mode",
items=[
(
"WITH_BG",
"Contour Lines with Background",
"Show contour lines overlaid on the colored false color background",
),
("WITHOUT_BG", "Contour Lines Only", "Show only contour lines without the false color background"),
],
default="WITH_BG",
)
false_color_multiplier: FloatProperty(
name="Multiplier",
description="Conversion multiplier (179.0 for lux, 16.6295 for foot-candles)",
min=0.1,
max=1000.0,
default=16.629505759940542,
)
false_color_output_name: StringProperty(
name="False Color Output Name",
description="Name of the false color output file (without extension)",
default="false_color",
)
if TYPE_CHECKING:
is_exporting: bool
is_preparing: bool
is_rendering: bool
category: str
subcategory: str
materials: bpy.types.bpy_prop_collection_idprop[RadianceMaterial]
@@ -433,8 +699,10 @@ class RadianceExporterProperties(PropertyGroup):
should_load_from_memory: bool
radiance_resolution_x: int
radiance_resolution_y: int
radiance_bin_dir: str
output_dir: str
ifc_file: str
ambient_bounces: int
radiance_quality: Literal["LOW", "MEDIUM", "HIGH"]
radiance_detail: Literal["LOW", "MEDIUM", "HIGH"]
radiance_variability: Literal["LOW", "MEDIUM", "HIGH"]
@@ -442,8 +710,22 @@ class RadianceExporterProperties(PropertyGroup):
output_file_format: Literal["HDR"]
use_hdr: bool
choose_hdr_image: Literal["Noon"]
use_sun: bool
sky_condition: Literal["SUNNY_WITH_SUN", "SUNNY_WITHOUT_SUN", "CLOUDY"]
ground_reflectance: float
turbidity: float
use_active_camera: bool
selected_camera: Union[bpy.types.Object, None]
ies_lights: bpy.types.bpy_prop_collection_idprop[IESLight]
active_ies_light_index: int
use_false_color: bool
false_color_label: Literal["fc", "lux", "cd/m2"]
false_color_scale: float
false_color_steps: int
false_color_contour_lines: bool
false_color_contour_mode: Literal["WITH_BG", "WITHOUT_BG"]
false_color_multiplier: float
false_color_output_name: str
class BIMSolarProperties(PropertyGroup):
@@ -470,11 +752,12 @@ class BIMSolarProperties(PropertyGroup):
)
timezone: StringProperty(name="Timezone", default="Etc/GMT")
true_north: FloatProperty(name="True North", min=-pi, max=pi, subtype="ANGLE", update=update_sun_path)
year: IntProperty(name="Year", min=1, max=9999, default=now.year, update=update_sun_path)
month: IntProperty(name="Month", min=1, max=12, default=now.month, update=update_sun_path)
day: IntProperty(name="Date", min=1, max=31, default=now.day, update=update_sun_path)
hour: IntProperty(name="Hour", min=0, max=23, default=now.hour, update=update_sun_path)
minute: IntProperty(name="Minute", min=0, max=59, default=now.minute, update=update_sun_path)
# Defaults are static; use the "Now" button (LightSetTimeToNow) to set current time.
year: IntProperty(name="Year", min=1, max=9999, default=2025, update=update_day)
month: IntProperty(name="Month", min=1, max=12, default=1, update=update_day)
day: IntProperty(name="Date", min=1, max=31, default=1, update=update_day)
hour: IntProperty(name="Hour", min=0, max=23, default=12, update=update_sun_path)
minute: IntProperty(name="Minute", min=0, max=59, default=0, update=update_sun_path)
sun_position: FloatVectorProperty(name="Sun Position", subtype="XYZ", default=(0, 0, 0))
sun_path_origin: FloatVectorProperty(name="Sun Path Origin", subtype="XYZ", default=(0, 0, 0))
sun_path_size: FloatProperty(name="Sun Path Size", min=0.1, default=50, update=update_sun_path)
@@ -0,0 +1,328 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2020, 2021 Dion Moult <dion@thinkmoult.com>
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import multiprocessing
import os
import subprocess
import threading
import time
from pathlib import Path
from typing import Union
import bpy
import pyradiance as pr
import bonsai.bim.module.light.shared as shared
import bonsai.tool as tool
# pyradiance's bundled Radiance binaries
_PYRAD_BIN = Path(pr.__file__).parent / "bin"
class RadianceRender(bpy.types.Operator):
"""Radiance Rendering (runs in background thread)"""
bl_idname = "render_scene.radiance"
bl_label = "Render"
bl_description = "Renders the scene using Radiance"
_timer = None
_thread: Union[threading.Thread, None] = None
_result_image: Union[bytes, None] = None
_error: Union[str, None] = None
_start_time: float = 0.0
@classmethod
def poll(cls, context):
props = tool.Blender.get_radiance_exporter_props()
if not props.output_dir:
cls.poll_message_set("Output directory is not set.")
return False
if props.is_rendering:
cls.poll_message_set("A render is already in progress.")
return False
if shared.scene is None:
cls.poll_message_set("Radiance scene not prepared. Please run 'Prepare Scene' (Step 2) first.")
return False
return True
def execute(self, context):
props = tool.Blender.get_radiance_exporter_props()
resolution_x, resolution_y = props.radiance_resolution_x, props.radiance_resolution_y
context.scene.render.resolution_x = resolution_x
context.scene.render.resolution_y = resolution_y
quality = props.radiance_quality.upper()
detail = props.radiance_detail.upper()
variability = props.radiance_variability.upper()
ambient_bounces = props.ambient_bounces
output_dir = props.output_dir
props.is_rendering = True
self._start_time = time.time()
self._result_image = None
self._error = None
self._thread = threading.Thread(
target=self._render_worker,
args=(shared.scene, output_dir, resolution_x, resolution_y, quality, detail, variability, ambient_bounces),
daemon=True,
)
self._thread.start()
wm = context.window_manager
self._timer = wm.event_timer_add(0.5, window=context.window)
wm.modal_handler_add(self)
self.report({"INFO"}, "Radiance render started in background...")
context.window.cursor_set("WAIT")
return {"RUNNING_MODAL"}
def _render_worker(self, render_scene, output_dir, res_x, res_y, quality, detail, variability, ambient_bounces):
"""Runs in a background thread — no Blender API calls allowed here."""
cwd_saved = os.getcwd()
try:
os.chdir(output_dir)
self._result_image = pr.render(
render_scene,
ambbounce=ambient_bounces,
resolution=(res_x, res_y),
quality=quality,
detail=detail,
variability=variability,
nproc=multiprocessing.cpu_count(),
)
except Exception as e:
self._error = str(e)
finally:
os.chdir(cwd_saved)
def modal(self, context, event):
if event.type == "TIMER":
if self._thread is not None and self._thread.is_alive():
return {"RUNNING_MODAL"}
self._cleanup_timer(context)
props = tool.Blender.get_radiance_exporter_props()
props.is_rendering = False
context.window.cursor_set("DEFAULT")
if self._error:
self.report({"ERROR"}, f"Radiance render failed: {self._error}")
return {"CANCELLED"}
elapsed = time.time() - self._start_time
print(f"Render completed in {elapsed:.2f} seconds")
output_dir = props.output_dir
output_file_name = props.output_file_name
output_file_format = props.output_file_format
output_hdr_path = os.path.join(output_dir, f"{output_file_name}.hdr")
print(f"Saving HDR output to: {output_hdr_path}")
with open(output_hdr_path, "wb") as wtr:
wtr.write(self._result_image)
if output_file_format == "HDR_TIFF":
print("Applying tone mapping...")
pcond_image = pr.pcond(hdr=output_hdr_path, human=True)
tiff_path = os.path.join(output_dir, f"{output_file_name}.tiff")
print(f"Saving TIFF output to: {tiff_path}")
pr.ra_tiff(inp=pcond_image, out=tiff_path, lzw=True)
print("Radiance rendering process completed successfully.")
self.report({"INFO"}, f"Radiance rendering completed. HDR Output: {output_hdr_path}")
if output_file_format == "HDR_TIFF":
self.report({"INFO"}, f"TIFF Output: {tiff_path}")
for area in context.screen.areas:
area.tag_redraw()
return {"FINISHED"}
elif event.type == "ESC":
self._cleanup_timer(context)
props = tool.Blender.get_radiance_exporter_props()
props.is_rendering = False
context.window.cursor_set("DEFAULT")
self.report({"WARNING"}, "Render cancelled by user. Background process may still be running.")
return {"CANCELLED"}
return {"PASS_THROUGH"}
def _cleanup_timer(self, context):
if self._timer is not None:
context.window_manager.event_timer_remove(self._timer)
self._timer = None
class FalseColorRadiance(bpy.types.Operator):
"""Generate false color HDR image for illuminance analysis"""
bl_idname = "render_scene.false_color_radiance"
bl_label = "Generate False Color Image"
bl_description = "Generate a false color HDR image for illuminance analysis"
@classmethod
def poll(cls, context):
props = tool.Blender.get_radiance_exporter_props()
if not props.output_dir:
cls.poll_message_set("Output directory is not set.")
return False
return True
def execute(self, context):
props = tool.Blender.get_radiance_exporter_props()
output_dir = props.output_dir
output_file_name = props.output_file_name
hdr_path = os.path.join(output_dir, f"{output_file_name}.hdr")
if not os.path.exists(hdr_path):
self.report(
{"ERROR"},
f"HDR file not found at: {hdr_path}. Please run 'Radiance Render' first.",
)
return {"CANCELLED"}
fc_scale = (
str(int(props.false_color_scale))
if props.false_color_scale == int(props.false_color_scale)
else str(props.false_color_scale)
)
# Multiplier converts Radiance raw values to display units
# fc (foot-candles) = 16.629..., lux & cd/m2 = 179.0
multiplier = 179.0 if props.false_color_label in ("lux", "cd/m2") else 16.629505759940542
print(
f"False color parameters: label={props.false_color_label}, scale={fc_scale}, "
f"steps={props.false_color_steps}, multiplier={multiplier}, "
f"contour={props.false_color_contour_lines}"
)
try:
fc_output_name = props.false_color_output_name
fc_hdr_path = os.path.join(output_dir, f"{fc_output_name}.hdr")
# Find falsecolor binary from user-specified Radiance bin directory
radiance_bin_dir = os.path.normpath(props.radiance_bin_dir) if props.radiance_bin_dir else ""
if radiance_bin_dir:
falsecolor_bin = os.path.join(radiance_bin_dir, "falsecolor.exe")
if not os.path.exists(falsecolor_bin):
falsecolor_bin = os.path.join(radiance_bin_dir, "falsecolor")
if not os.path.exists(falsecolor_bin):
self.report({"ERROR"}, f"falsecolor not found in: {radiance_bin_dir}")
return {"CANCELLED"}
else:
import shutil
falsecolor_bin = shutil.which("falsecolor") or shutil.which("falsecolor.exe")
if not falsecolor_bin:
self.report(
{"ERROR"}, "Set the Radiance Bin path in False Color settings, or add Radiance to system PATH."
)
return {"CANCELLED"}
radiance_bin_dir = os.path.dirname(falsecolor_bin)
radiance_lib = os.path.join(os.path.dirname(radiance_bin_dir), "lib")
print(f"Using falsecolor: {falsecolor_bin}")
print(f"Radiance bin: {radiance_bin_dir}, lib: {radiance_lib}")
cmd = [falsecolor_bin]
cmd.extend(["-m", str(multiplier)])
cmd.extend(["-s", fc_scale])
cmd.extend(["-n", str(props.false_color_steps)])
cmd.extend(["-l", props.false_color_label])
if props.false_color_contour_lines:
cmd.append("-cl")
if props.false_color_contour_mode == "WITH_BG":
cmd.extend(["-ip", hdr_path])
else:
cmd.extend(["-i", hdr_path])
else:
cmd.extend(["-ip", hdr_path])
# Setup environment so falsecolor can find pcomb, psign, pcompos etc.
env = os.environ.copy()
if os.path.exists(radiance_bin_dir):
env["PATH"] = radiance_bin_dir + os.pathsep + env.get("PATH", "")
if os.path.exists(radiance_lib):
env["RAYPATH"] = "." + os.pathsep + radiance_lib
print(f"Running: {' '.join(cmd)}")
# Write output to file via redirection to avoid Windows stdout binary corruption
cmd_str = subprocess.list2cmdline(cmd) + f' > "{fc_hdr_path}"'
result = subprocess.run(cmd_str, shell=True, stderr=subprocess.PIPE, env=env, cwd=output_dir)
if result.returncode != 0:
error_msg = result.stderr.decode() if result.stderr else "Unknown error"
self.report({"ERROR"}, f"falsecolor failed: {error_msg}")
return {"CANCELLED"}
fc_size = os.path.getsize(fc_hdr_path) if os.path.exists(fc_hdr_path) else 0
print(f"False color HDR generated: {fc_hdr_path} ({fc_size} bytes)")
self.report({"INFO"}, f"False color image generated: {fc_hdr_path}")
# Generate TIFF version
try:
pcond_fc_image = pr.pcond(hdr=fc_hdr_path, human=True)
fc_tiff_path = os.path.join(output_dir, f"{fc_output_name}.tiff")
pr.ra_tiff(inp=pcond_fc_image, out=fc_tiff_path, lzw=True)
print(f"False color TIFF generated: {fc_tiff_path}")
self.report({"INFO"}, f"False color TIFF also generated: {fc_tiff_path}")
except Exception as e:
self.report({"WARNING"}, f"TIFF generation failed: {str(e)}")
return {"FINISHED"}
except subprocess.CalledProcessError as e:
# Clean up incomplete HDR file on failure
if os.path.exists(fc_hdr_path):
os.remove(fc_hdr_path)
error_msg = f"falsecolor failed: {e.stderr.decode() if e.stderr else str(e)}"
self.report({"ERROR"}, error_msg)
return {"CANCELLED"}
except FileNotFoundError:
self.report({"ERROR"}, "falsecolor not found. Please install Radiance and add it to PATH.")
return {"CANCELLED"}
except Exception as e:
self.report({"ERROR"}, f"Failed to generate false color image: {str(e)}")
import traceback
traceback.print_exc()
return {"CANCELLED"}
class RADIANCE_OT_select_camera(bpy.types.Operator):
bl_idname = "radiance.select_camera"
bl_label = "Select Camera"
bl_description = "Select a camera from the viewport"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
return context.object is not None and context.object.type == "CAMERA"
def execute(self, context):
props = tool.Blender.get_radiance_exporter_props()
props.selected_camera = context.object
props.use_active_camera = False
return {"FINISHED"}
@@ -0,0 +1,33 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2020, 2021 Dion Moult <dion@thinkmoult.com>
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
"""Shared module-level state for the light/radiance pipeline.
These globals are populated by ExportOBJ, consumed by PrepareRadianceScene,
and read by RadianceRender. They must live in a shared module so that
all operator files can access the same state.
"""
# Collected IFC material names from the most recent export
ifc_materials: list[str] = []
# The prepared pyradiance Scene object (set by PrepareRadianceScene, read by RadianceRender)
scene = None
# Info about exported linked models: list of (obj_path, mtl_path, link_matrix_4x4)
linked_model_exports: list[tuple[str, str, list[list[float]]]] = []
+148
View File
@@ -0,0 +1,148 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2020, 2021 Dion Moult <dion@thinkmoult.com>
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import webbrowser
from datetime import datetime
from math import radians
from typing import TYPE_CHECKING
import bpy
import ifcopenshell.util.geolocation
import requests
import bonsai.tool as tool
from bonsai.bim.module.light.data import SolarData
class ImportTrueNorth(bpy.types.Operator):
bl_idname = "bim.import_true_north"
bl_label = "Import True North"
bl_description = "Imports the True North from your IFC geometric context"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
if not tool.Ifc.get():
return False
if not SolarData.is_loaded:
SolarData.load()
return SolarData.data["true_north"] is not None
def execute(self, context):
props = tool.Blender.get_solar_props()
for context in tool.Ifc.get().by_type("IfcGeometricRepresentationContext", include_subtypes=False):
if not context.TrueNorth:
continue
value = context.TrueNorth.DirectionRatios
props.true_north = radians(ifcopenshell.util.geolocation.yaxis2angle(*value[:2]))
return {"FINISHED"}
class ImportLatLong(bpy.types.Operator):
bl_idname = "bim.import_lat_long"
bl_label = "Import Latitude / Longitude"
bl_description = "Imports the latitude / longitude from an IfcSite"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
props = tool.Blender.get_solar_props()
site = tool.Ifc.get().by_id(int(props.sites))
if site.RefLatitude and site.RefLongitude:
props.latitude = ifcopenshell.util.geolocation.dms2dd(*site.RefLatitude)
props.longitude = ifcopenshell.util.geolocation.dms2dd(*site.RefLongitude)
return {"FINISHED"}
class MoveSunPathTo3DCursor(bpy.types.Operator):
bl_idname = "bim.move_sun_path_to_3d_cursor"
bl_label = "Move Sun Path To 3D Cursor"
bl_description = "Shifts the visualisation of the Sun Path to the 3D cursor"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
props = tool.Blender.get_solar_props()
assert context.scene
props.sun_path_origin = context.scene.cursor.location
tool.Blender.update_viewport()
return {"FINISHED"}
class ViewFromSun(bpy.types.Operator):
bl_idname = "bim.view_from_sun"
bl_label = "View From Sun"
bl_description = "Views your model as if you were looking from the perspective of the sun"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
if not (camera := bpy.data.objects.get("SunPathCamera")):
camera = bpy.data.objects.new("SunPathCamera", bpy.data.cameras.new("SunPathCamera"))
assert isinstance(camera.data, bpy.types.Camera)
assert context.scene
camera.data.type = "ORTHO"
camera.data.ortho_scale = 100 # The default of 6m is too small
context.scene.collection.objects.link(camera)
tool.Blender.activate_camera(camera)
props = tool.Blender.get_solar_props()
props.hour = props.hour # Just to refresh camera position
return {"FINISHED"}
class LightPickCoordinates(bpy.types.Operator):
bl_idname = "bim.light_pick_coordinates"
bl_label = "Pick Coordinates"
bl_description = (
"Open web browser with Google Maps to pick coordinates (Right Mouse Click in maps to copy selected location).\n\n"
"ALT+Click to insert current location based on the current IP-address (using ip-api.com)."
)
bl_options = {"REGISTER", "UNDO"}
use_current_location: bpy.props.BoolProperty(options={"SKIP_SAVE"}) # pyright: ignore[reportRedeclaration]
if TYPE_CHECKING:
use_current_location: bool
def invoke(self, context, event):
if event.alt:
self.use_current_location = True
return self.execute(context)
def execute(self, context):
props = tool.Blender.get_solar_props()
if not self.use_current_location:
zoom = 13.5
url = f"https://www.google.com/maps/@{props.latitude},{props.longitude},{zoom}z"
webbrowser.open(url)
return {"FINISHED"}
response = requests.get("http://ip-api.com/json/")
data = response.json()
props.latitude = data["lat"]
props.longitude = data["lon"]
return {"FINISHED"}
class LightSetTimeToNow(bpy.types.Operator):
bl_idname = "bim.light_set_time_to_now"
bl_label = "Now"
bl_description = "Set time to current local time."
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
props = tool.Blender.get_solar_props()
props.set_from_datetime(datetime.now())
return {"FINISHED"}
+235 -43
View File
@@ -22,52 +22,97 @@ from typing import TYPE_CHECKING
import bpy
import bonsai.tool as tool
from bonsai.bim.helper import prop_with_search
from bonsai.bim.module.light.data import SolarData
# ---------------------------------------------------------------------------
# Root panel (replaces the old "Radiance Exporter" nested panel)
# ---------------------------------------------------------------------------
class BIM_PT_radiance_exporter(bpy.types.Panel):
"""Creates a Panel in the render properties window"""
bl_label = "Radiance Exporter"
bl_idname = "BIM_PT_radiance_exporter"
bl_options = {"DEFAULT_CLOSED"}
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "scene"
bl_parent_id = "BIM_PT_tab_lighting"
bl_options = {"HIDE_HEADER"}
def draw(self, context):
pass
# ---------------------------------------------------------------------------
# 1. Scene Setup
# ---------------------------------------------------------------------------
class BIM_PT_radiance_scene_setup(bpy.types.Panel):
bl_label = "Scene Setup"
bl_idname = "BIM_PT_radiance_scene_setup"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "scene"
bl_parent_id = "BIM_PT_radiance_exporter"
def draw(self, context):
assert self.layout
layout = self.layout
props = tool.Blender.get_radiance_exporter_props()
if tool.Ifc.get():
row = self.layout.row()
row.prop(props, "should_load_from_memory")
if not tool.Ifc.get() or not props.should_load_from_memory:
row = self.layout.row(align=True)
row.prop(props, "ifc_file")
row = layout.row()
row.prop(props, "output_dir")
layout.separator()
row = layout.row()
layout.label(text="Info: Unmapped materials default to white")
row.prop(props, "use_active_camera")
if not props.use_active_camera:
row = layout.row()
row.prop(props, "selected_camera")
row.operator("radiance.select_camera", text="", icon="EYEDROPPER")
row = layout.row(align=True)
row.label(text="Resolution")
row.prop(props, "radiance_resolution_x", text="X")
row.prop(props, "radiance_resolution_y", text="Y")
# ---------------------------------------------------------------------------
# 2. Materials
# ---------------------------------------------------------------------------
class BIM_PT_radiance_materials(bpy.types.Panel):
bl_label = "Materials"
bl_idname = "BIM_PT_radiance_materials"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "scene"
bl_parent_id = "BIM_PT_radiance_exporter"
bl_options = {"DEFAULT_CLOSED"}
def draw(self, context):
layout = self.layout
props = tool.Blender.get_radiance_exporter_props()
layout.label(text="Unmapped materials default to white", icon="INFO")
row = layout.row()
row.template_list("MATERIAL_UL_radiance_materials", "", props, "materials", props, "active_material_index")
row.operator("radiance.open_spectraldb", text="", icon="WORLD") # Globe icon
row.operator("radiance.open_spectraldb", text="", icon="WORLD")
if len(props.materials) > 0:
col = layout.column(align=True)
col.prop(props, "category")
prop_with_search(col, props, "category")
if props.category:
col.prop(props, "subcategory")
prop_with_search(col, props, "subcategory")
if props.active_material_index >= 0 and props.active_material_index < len(props.materials):
if 0 <= props.active_material_index < len(props.materials):
active_material = props.materials[props.active_material_index]
if active_material.category and active_material.subcategory:
layout.label(
text=f"Mapped: {active_material.name} to {active_material.category} - {active_material.subcategory}"
text=f"Mapped: {active_material.name} -> {active_material.category} - {active_material.subcategory}"
)
else:
layout.label(text=f"Select category and subcategory for: {active_material.name}")
@@ -78,28 +123,95 @@ class BIM_PT_radiance_exporter(bpy.types.Panel):
row = layout.row()
row.operator("bim.refresh_ifc_materials", text="Refresh IFC Materials")
layout.separator()
row = layout.row()
layout.label(text="Step 1: Export geometry for simulation")
row = layout.row()
row.operator("export_scene.radiance", text="Export Geometry for Simulation")
# ---------------------------------------------------------------------------
# 3. Lighting (Environment + IES)
# ---------------------------------------------------------------------------
layout.separator()
class BIM_PT_radiance_lighting(bpy.types.Panel):
bl_label = "Lighting"
bl_idname = "BIM_PT_radiance_lighting"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "scene"
bl_parent_id = "BIM_PT_radiance_exporter"
def draw(self, context):
layout = self.layout
props = tool.Blender.get_radiance_exporter_props()
# Environment
box = layout.box()
box.label(text="Camera Settings")
box.label(text="Environment", icon="WORLD")
row = box.row()
row.prop(props, "use_active_camera")
if not props.use_active_camera:
row.prop(props, "use_hdr")
row = box.row()
row.prop(props, "use_sun")
if props.use_sun:
box.prop(props, "sky_condition")
row = box.row()
row.prop(props, "selected_camera")
row.operator("radiance.select_camera", text="", icon="EYEDROPPER")
row.prop(props, "ground_reflectance")
row = box.row()
row.prop(props, "turbidity")
row = box.row(align=True)
row.label(text="Resolution")
row.prop(props, "radiance_resolution_x", text="X")
row.prop(props, "radiance_resolution_y", text="Y")
layout.separator()
# IES Light Fixtures
box = layout.box()
box.label(text="IES Light Fixtures", icon="LIGHT_POINT")
row = box.row()
row.template_list("MATERIAL_UL_ies_lights", "", props, "ies_lights", props, "active_ies_light_index")
col = row.column(align=True)
col.operator("radiance.add_ies_light", text="", icon="ADD")
if len(props.ies_lights) > 0 and 0 <= props.active_ies_light_index < len(props.ies_lights):
active_light = props.ies_lights[props.active_ies_light_index]
col = box.column(align=True)
row = col.row()
row.prop(active_light, "use_collection", text="Target Collection", icon="OUTLINER_COLLECTION")
row = col.row()
if active_light.use_collection:
row.prop(active_light, "target_collection", text="Collection")
if active_light.target_collection:
empties = [o for o in active_light.target_collection.all_objects if o.type == "EMPTY"]
row = col.row()
row.label(text=f"{len(empties)} empty object(s) in collection", icon="INFO")
else:
row.prop(active_light, "target_object", text="Object")
row = col.row()
row.label(text="Rotation Z")
row.prop(active_light, "rotation_z", text="")
row = col.row()
row.prop(active_light, "lamp_color")
split = col.split(factor=0.5)
split.prop(active_light, "multiply_factor")
split.prop(active_light, "radius")
# ---------------------------------------------------------------------------
# 4. Render Settings
# ---------------------------------------------------------------------------
class BIM_PT_radiance_render_settings(bpy.types.Panel):
bl_label = "Render Settings"
bl_idname = "BIM_PT_radiance_render_settings"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "scene"
bl_parent_id = "BIM_PT_radiance_exporter"
bl_options = {"DEFAULT_CLOSED"}
def draw(self, context):
layout = self.layout
props = tool.Blender.get_radiance_exporter_props()
row = layout.row()
row.prop(props, "radiance_quality")
@@ -110,6 +222,9 @@ class BIM_PT_radiance_exporter(bpy.types.Panel):
row = layout.row()
row.prop(props, "radiance_variability")
row = layout.row()
row.prop(props, "ambient_bounces")
layout.separator()
row = layout.row()
@@ -117,20 +232,94 @@ class BIM_PT_radiance_exporter(bpy.types.Panel):
row = layout.row()
row.prop(props, "output_file_format")
# ---------------------------------------------------------------------------
# 5. Pipeline (Steps 1-4 + Cleanup)
# ---------------------------------------------------------------------------
class BIM_PT_radiance_pipeline(bpy.types.Panel):
bl_label = "Pipeline"
bl_idname = "BIM_PT_radiance_pipeline"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "scene"
bl_parent_id = "BIM_PT_radiance_exporter"
def draw(self, context):
layout = self.layout
props = tool.Blender.get_radiance_exporter_props()
# Step 1
box = layout.box()
row = box.row()
row.label(text="Step 1: Export Geometry")
row = box.row()
if props.is_exporting:
row.label(text="Exporting...", icon="SORTTIME")
else:
row.operator("export_scene.radiance", text="Export Geometry")
# Step 2
box = layout.box()
row = box.row()
row.label(text="Step 2: Prepare Scene")
row = box.row()
if props.is_preparing:
row.label(text="Preparing scene...", icon="SORTTIME")
else:
row.operator("scene.prepare_radiance", text="Prepare Scene")
# Step 3
box = layout.box()
row = box.row()
row.label(text="Step 3: Render")
row = box.row()
if props.is_rendering:
row.label(text="Rendering...", icon="RENDER_STILL")
else:
row.operator("render_scene.radiance", text="Radiance Render")
# Step 4: False Color
box = layout.box()
row = box.row()
row.label(text="Step 4: False Color Analysis")
row = box.row()
row.prop(props, "radiance_bin_dir")
row = box.row()
row.prop(props, "false_color_label")
split = box.split(factor=0.5)
split.prop(props, "false_color_scale")
split.prop(props, "false_color_steps")
row = box.row()
row.label(text="Output Name")
row.prop(props, "false_color_output_name", text="")
row = box.row()
row.prop(props, "false_color_contour_lines")
if props.false_color_contour_lines:
row = box.row()
row.prop(props, "false_color_contour_mode")
row = box.row()
row.operator("render_scene.false_color_radiance", text="Generate False Color Image")
layout.separator()
# Cleanup
row = layout.row()
row.prop(props, "use_hdr")
row.operator("radiance.cleanup_files", text="Cleanup Generated Files", icon="TRASH")
if props.use_hdr:
row = layout.row()
row.prop(props, "choose_hdr_image")
row = layout.row()
layout.label(text="Step 2: Run the simulation")
row = layout.row()
row.operator("render_scene.radiance", text="Radiance Render")
row.enabled = not props.is_exporting
# ---------------------------------------------------------------------------
# Solar Panel (unchanged)
# ---------------------------------------------------------------------------
class BIM_PT_solar(bpy.types.Panel):
@@ -248,7 +437,10 @@ class BIM_PT_solar(bpy.types.Panel):
row = self.layout.row()
sun_props = tool.Blender.get_sun_props()
assert sun_props
row.prop(sun_props.sun_object.data, "energy", text="Sun Intensity")
if sun_props.sun_object is not None and sun_props.sun_object.data is not None:
row.prop(sun_props.sun_object.data, "energy", text="Sun Intensity")
else:
row.label(text="Sun object not found. Toggle shadow mode to recreate.", icon="ERROR")
row = self.layout.row(align=True)
row.operator("bim.view_from_sun", icon="LIGHT_HEMI")
@@ -136,7 +136,7 @@ class SplitAlongEdge(bpy.types.Operator, tool.Ifc.Operator):
"Will unassign element from a type if type has a representation."
)
bl_options = {"REGISTER", "UNDO"}
mode: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
mode: bpy.props.EnumProperty(
default="BOOLEAN",
items=tuple((i, i, "") for i in get_args(SplitAlongEdgeMode)),
)
@@ -359,7 +359,7 @@ class ConfirmQuickFavoriteOperator(bpy.types.Operator):
bl_idname = "bim.confirm_quick_favorite_operator"
bl_label = "Confirm Operator"
bl_options = {"REGISTER", "UNDO"}
index: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
index: bpy.props.IntProperty()
if TYPE_CHECKING:
index: int
@@ -452,10 +452,8 @@ class MoveQuickFavoritesItem(bpy.types.Operator):
bl_idname = "bim.move_quick_favorites_item"
bl_label = "Move Quick Favorites Item"
bl_options = {"REGISTER", "UNDO"}
index: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
direction: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
items=[("UP", "Up", ""), ("DOWN", "Down", "")]
)
index: bpy.props.IntProperty()
direction: bpy.props.EnumProperty(items=[("UP", "Up", ""), ("DOWN", "Down", "")])
if TYPE_CHECKING:
index: int
@@ -474,7 +472,7 @@ class RemoveQuickFavoritesItem(bpy.types.Operator):
bl_idname = "bim.remove_quick_favorites_item"
bl_label = "Remove Quick Favorites Item"
bl_options = {"REGISTER", "UNDO"}
index: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
index: bpy.props.IntProperty()
if TYPE_CHECKING:
index: int
+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
@@ -421,7 +421,7 @@ class PolylineOperator:
tool.Polyline.calculate_x_y_and_z(context, self.input_ui, self.tool_state)
tool.Blender.update_viewport()
return {"RUNNING_MODAL"}
return {"RUNNING_MODAL"}
def set_offset(self, context: bpy.types.Context, relating_type: ifcopenshell.entity_instance) -> None:
props = tool.Model.get_model_props()
@@ -461,6 +461,7 @@ class PolylineOperator:
self.tool_state.axis_method = None
self.tool_state.plane_method = None
self.tool_state.mode = "Mouse"
tool.Raycast.clear_snap_objs()
self.visible_objs = tool.Raycast.get_visible_objects(context)
for obj in self.visible_objs:
if bbox_2d := tool.Raycast.get_on_screen_2d_bounding_boxes(context, obj):
@@ -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,
@@ -943,7 +943,7 @@ class EditObjectUI:
if "LAYER2" in AuthoringData.data["selected_material_usages"]:
row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row
add_layout_hotkey_operator(
cls.layout, "Extend To Underside", "S_E", bpy.ops.bim.extend_to_underside.__doc__, ui_context
cls.layout, "Extend To Underside", "S_E", bpy.ops.bim.extend_walls_to_underside.__doc__, ui_context
)
if AuthoringData.data["is_flippable_element"]:
@@ -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:
@@ -1102,7 +1088,7 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
else:
return self.finish_loading_project(context)
def finish_loading_project(self, context):
def finish_loading_project(self, context: bpy.types.Context) -> set["rna_enums.OperatorReturnItems"]:
try:
filepath = self.get_filepath()
if not self.is_existing_ifc_file():
@@ -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
+1 -1
View File
@@ -1888,7 +1888,7 @@ class Blender(bonsai.core.tool.Blender):
@classmethod
def get_radiance_exporter_props(cls) -> RadianceExporterProperties:
assert (scene := bpy.context.scene)
return scene.BIMRadianceExporeterProperies # pyright: ignore[reportAttributeAccessIssue]
return scene.BIMRadianceExporterProperties # pyright: ignore[reportAttributeAccessIssue]
@classmethod
def get_fm_props(cls) -> BIMFMProperties:
+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()
+619 -13
View File
@@ -16,12 +16,16 @@
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
import math
from typing import Union
import bmesh
import bpy
import mathutils
import numpy as np
from bpy_extras import view3d_utils
from mathutils import Vector
import bonsai.core.tool
@@ -41,6 +45,7 @@ class Raycast(bonsai.core.tool.Raycast):
(0, -offset),
(offset, -offset),
)
snap_objs = []
@classmethod
def get_visible_objects(cls, context: bpy.types.Context):
@@ -69,8 +74,10 @@ class Raycast(bonsai.core.tool.Raycast):
rv3d = context.region_data
assert rv3d
view_location = rv3d.view_matrix.inverted().translation
view_normal = rv3d.view_rotation @ mathutils.Vector((0.0, 0.0, -1.0))
obj_matrix = obj.matrix_world.copy()
bbox = [obj_matrix @ Vector(v) for v in obj.bound_box]
bbox_edges = [(0, 1), (1, 2), (2, 3), (3, 0), (4, 5), (5, 6), (6, 7), (7, 4), (0, 4), (1, 5), (2, 6), (3, 7)]
transposed_bbox: list[Vector] = []
bbox_2d: list[float] = []
@@ -94,8 +101,25 @@ class Raycast(bonsai.core.tool.Raycast):
for v in bbox:
coord_2d = tool.Cad.location_3d_to_region_2d_np(context.region, context.space_data.region_3d, v)
if coord_2d is not None:
transposed_bbox.append(coord_2d)
transposed_bbox.append(coord_2d)
if not any(transposed_bbox):
transposed_bbox = []
# If there are None values in transposed_bbox it means that there are vertices behind the camera
# so we get the intersection of the edge with the region border
# new_bbox = []
if any(transposed_bbox) and not all(transposed_bbox):
new_bbox = transposed_bbox.copy()
new_bbox = [x for x in new_bbox if x is not None]
for edge in bbox_edges:
if (transposed_bbox[edge[0]] is None) ^ (transposed_bbox[edge[1]] is None):
point, _ = cls.intersect_edge_region_border(
context.region, context.space_data, rv3d, bbox[edge[0]], bbox[edge[1]]
)
if point:
new_bbox.append(point)
if new_bbox:
transposed_bbox = new_bbox
region = context.region
borders = (0, region.width, 0, region.height)
@@ -117,6 +141,96 @@ class Raycast(bonsai.core.tool.Raycast):
return (obj, bbox_2d)
return None
def intersect_edge_region_border(region, space, rv3d, v1, v2):
def segment_intersect_near_plane(view_matrix, clip_start, p_world_a, p_world_b):
a_view = view_matrix @ p_world_a
b_view = view_matrix @ p_world_b
z_near = -clip_start
za = a_view.z
zb = b_view.z
denom = zb - za
if denom == 0.0:
return None, None
t = (z_near - za) / denom
if t < 0.0 or t > 1.0:
return None, None
p_view = a_view.lerp(b_view, t)
cam_world = view_matrix.inverted()
p_world = cam_world @ p_view
return p_world, t
def is_inside_region(pt2d, region):
return 0.0 <= pt2d.x <= region.width and 0.0 <= pt2d.y <= region.height
def clamp_to_region_border(point2d, region):
x, y = point2d
x_clamped = max(0.0, min(region.width, x))
y_clamped = max(0.0, min(region.height, y))
return Vector((x_clamped, y_clamped))
def find_nearby_onscreen_point(region, rv3d, p1, p2, initial_t_on_segment, max_iters=40, step=0.05):
"""
Use iterative approach: move t toward 0. Returns the first point that is inside region border
"""
t = initial_t_on_segment
for i in range(max_iters):
test_3d = p1.lerp(p2, t)
test_2d = view3d_utils.location_3d_to_region_2d(region, rv3d, test_3d)
if test_2d is not None and is_inside_region(test_2d, region):
return test_3d, test_2d, t
# move t toward 0 by reducing it by a fraction of its current value
t -= step
# if t is already very small, break
if t <= 1e-6:
break
return None, None, None
# Ensures that all the calculation uses the same direction based on which point is on the screen
if view3d_utils.location_3d_to_region_2d(region, rv3d, v1):
onscreen_vert = v1
offscreen_vert = v2
else:
onscreen_vert = v2
offscreen_vert = v1
# v2, v1 = v1, v2
clip_start = space.clip_start
view_mat = rv3d.view_matrix
inter_world, t_on_ab = segment_intersect_near_plane(view_mat, clip_start, onscreen_vert, offscreen_vert)
if inter_world is None:
print("No intersection with viewport near plane found for the segment.")
return None, None
init_2d = view3d_utils.location_3d_to_region_2d(region, rv3d, inter_world)
if init_2d is not None and is_inside_region(init_2d, region):
final_world = inter_world
final_2d = init_2d
final_t = t_on_ab
else:
found_world, found_2d, found_t = find_nearby_onscreen_point(
region, rv3d, onscreen_vert, offscreen_vert, t_on_ab, max_iters=600, step=0.01
)
if found_world is None:
if init_2d is None:
print("Initial projection invalid and iterative search failed.")
return None, None
# fallback: clamp projected point to border via manual mapping
final_2d = clamp_to_region_border(init_2d, region)
final_world = None
final_t = None
# print("Iterative search failed; using clamped 2D:", final_2d)
else:
final_world = found_world
final_2d = found_2d
final_t = found_t
# print(f"Found onscreen point at t={final_t:.4f}")
# print("Final 2D:", final_2d)
return final_2d, v2
@classmethod
def intersect_mouse_2d_bounding_box(cls, mouse_pos: tuple[int, int], bbox: list[float, float, float, float]):
x, y = mouse_pos
@@ -232,6 +346,158 @@ class Raycast(bonsai.core.tool.Raycast):
else:
return None, None, None
@classmethod
def ray_cast_by_proximity_2d(
cls,
context: bpy.types.Context,
event: bpy.types.Event,
snap_obj: SnapObj,
):
def divide_vector(start, end, n):
points = []
delta = (end - start) / n
for i in range(1, n):
point = start + i * delta
points.append(point)
return points
region = context.region
rv3d = context.region_data
mouse_pos = event.mouse_region_x, event.mouse_region_y
ray_origin, ray_target, ray_direction = cls.get_viewport_ray_data(context, event)
points = []
try:
loc = tool.Cad.region_2d_to_location_3d_np(region, rv3d, mouse_pos, ray_direction)
except:
loc = Vector((0, 0, 0))
verts_2d = [
view3d_utils.location_3d_to_region_2d(region, rv3d, v) for v in snap_obj.verts_3d
] # Numpy version is worst in performance
intersected = snap_obj.raycast_boxes(
context, event, snap_obj.root, intersected=[], rays=(ray_origin, ray_direction)
)
edges = []
for it in intersected:
edges.extend(it.edges)
edges = set(edges)
edge_verts = {}
for e in edges:
verts_idx = tuple(snap_obj.obj.data.edges[e].vertices)
verts = snap_obj.obj.data.vertices
v1 = snap_obj.obj.matrix_world @ verts[verts_idx[0]].co
v1_2d = verts_2d[verts_idx[0]]
v2 = snap_obj.obj.matrix_world @ verts[verts_idx[1]].co
v2_2d = verts_2d[verts_idx[1]]
if (v1_2d is None) ^ (v2_2d is None):
point, _ = cls.intersect_edge_region_border(region, context.space_data, rv3d, v1, v2)
if v1_2d is None:
edge_verts[e] = (point, v2_2d)
else:
edge_verts[e] = (v1_2d, point)
else:
edge_verts[e] = (v1_2d, v2_2d)
snap_threshold = 10.0
for i, point in enumerate(verts_2d):
if not point:
continue
distance = (Vector(mouse_pos) - point).length
if distance <= snap_threshold:
snap_point = {
"object": snap_obj.obj,
"type": "Vertex",
"point": snap_obj.verts_3d[i],
"distance": distance / 10,
}
points.append(snap_point)
count = 0
selected_edges = {}
for e in edges:
p0, p1 = edge_verts[e]
p0x, p0y = p0
p1x, p1y = p1
px, py = mouse_pos
# segment vector = p1 - p0
sx = p1x - p0x
sy = p1y - p0y
# seg length squared
seg_len_sq = sx * sx + sy * sy
if seg_len_sq == 0.0:
# degenerate segment: skip it
continue
# project (p - p0) onto seg: t = dot(p-p0, seg) / |seg|^2
apx = px - p0x
apy = py - p0y
t = (apx * sx + apy * sy) / seg_len_sq
# clamp to segment
if t <= 0.0:
t_clamped = 0.0
cx, cy = p0x, p0y
elif t >= 1.0:
t_clamped = 1.0
cx, cy = p1x, p1y
else:
t_clamped = t
cx = p0x + sx * t_clamped
cy = p0y + sy * t_clamped
dx = px - cx
dy = py - cy
dist = math.hypot(dx, dy)
if dist <= snap_threshold:
selected_edges[dist] = e
if selected_edges:
min_dist = float("inf")
for key in selected_edges:
if key < min_dist:
min_dist = key
idx = snap_obj.obj.data.edges[selected_edges[min_dist]].vertices
edge_verts = (snap_obj.verts_3d[idx[0]], snap_obj.verts_3d[idx[1]])
division_points = divide_vector(
edge_verts[0], edge_verts[1], 2
) # TODO Make it work for different divisions
for division_point in division_points:
intersection = tool.Cad.point_on_edge(division_point, (ray_target, loc))
distance = (division_point - intersection).length
if distance < snap_threshold:
snap_point = {
"object": snap_obj.obj,
"type": "Edge Center",
"point": division_point.copy(),
"distance": distance,
}
points.append(snap_point)
intersection = tool.Cad.intersect_edges_v2((ray_target, loc), edge_verts)
if intersection[0]:
if tool.Cad.is_point_on_edge(intersection[1], edge_verts):
distance = (intersection[1] - intersection[0]).length
if distance < snap_threshold:
snap_point = {
"object": snap_obj.obj,
"type": "Edge",
"point": intersection[1].copy(),
"edge_verts": edge_verts,
"distance": distance,
}
points.append(snap_point)
return points
@classmethod
def ray_cast_by_proximity(
cls,
@@ -457,7 +723,9 @@ class Raycast(bonsai.core.tool.Raycast):
if bbox_2d:
if tool.Raycast.intersect_mouse_2d_bounding_box(mouse_pos, bbox_2d):
if tool.Raycast.object_is_visible_in_clipping_plane(obj):
objs_to_raycast.append(obj)
snap_obj = cls.create_snap_obj(obj)
if snap_obj is not None:
objs_to_raycast.append(snap_obj)
return objs_to_raycast
@@ -474,12 +742,6 @@ class Raycast(bonsai.core.tool.Raycast):
face_index = None
# Wireframes
if obj.type in {"EMPTY", "CURVE"} or (hasattr(obj.data, "polygons") and len(obj.data.polygons) == 0):
snap_points = tool.Raycast.ray_cast_by_proximity(context, event, obj)
if snap_points:
hit = sorted(snap_points, key=lambda x: x["distance"])[0]["point"]
if hit:
hit_world = obj.original.matrix_world @ hit
return obj, hit_world, face_index
return None, None, None
# Meshes
else:
@@ -514,19 +776,20 @@ class Raycast(bonsai.core.tool.Raycast):
ray_origin, ray_target, ray_direction = cls.get_viewport_ray_data(context, event)
for obj in objs_to_raycast:
for snap_obj in objs_to_raycast:
if not include_wireframes and (
obj.type in {"EMPTY", "CURVE"} or (hasattr(obj.data, "polygons") and len(obj.data.polygons) == 0)
snap_obj.obj.type in {"EMPTY", "CURVE"}
or (hasattr(snap_obj.obj.data, "polygons") and len(snap_obj.obj.data.polygons) == 0)
):
continue
snap_obj, hit, face_index = cls.cast_rays_to_single_object(context, event, obj)
hit_obj, hit, face_index = cls.cast_rays_to_single_object(context, event, snap_obj.obj)
if hit is not None:
length_squared = (hit - ray_origin).length_squared
if best_obj is None or length_squared < best_length_squared:
best_length_squared = length_squared
best_obj = snap_obj
best_obj = hit_obj
best_hit = hit
best_face_index = face_index
@@ -536,6 +799,79 @@ class Raycast(bonsai.core.tool.Raycast):
else:
return None, None, None
@classmethod
def ray_cast_and_get_closest_to_camera_snaps(
cls,
context: bpy.types.Context,
event: bpy.types.Event,
objs_to_raycast: list[bpy.types.Object],
) -> Union[tuple[bpy.types.Object, Vector, int], tuple[None, None, None]]:
closest_length_squared = 1.0
closest_obj = None
closest_hit = None
closest_face_index = None
ray_origin, ray_target, ray_direction = cls.get_viewport_ray_data(context, event)
closest_snaps = []
hit = None
for snap_obj in objs_to_raycast:
if snap_obj.obj.type in {"EMPTY", "CURVE"} or (
hasattr(snap_obj.obj.data, "polygons") and len(snap_obj.obj.data.polygons) == 0
):
# For wireframe objects we have to test all the snaps to see which is closer
snap_points = tool.Raycast.ray_cast_by_proximity_2d(context, event, snap_obj)
closest_wf_hit = None
closest_wf_length_squared = 1.0
closest_wf_point = None
if snap_points:
for point in snap_points:
point["group"] = "Wireframe"
closest_snaps.append(point)
length = (point["point"] - ray_origin).length_squared
if closest_wf_hit is None or length < closest_wf_length_squared:
closest_wf_length_squared = length
closest_wf_hit = point["point"]
closest_wf_point = point
if closest_wf_point:
hit_obj = closest_wf_point["object"]
hit = closest_wf_point["point"]
face_index = None
else:
# Solid objects
hit_obj, hit, face_index = cls.cast_rays_to_single_object(context, event, snap_obj.obj)
if hit:
snap_point = {
"point": hit,
"type": "Face",
"group": "Object",
"object": hit_obj,
"face_index": face_index,
"distance": 9, # High value so it has low priority
}
closest_snaps.append(snap_point)
# Here we test which is closer, including wireframe and solid objects
if hit is not None:
length_squared = (hit - ray_origin).length_squared
if closest_obj is None or length_squared < closest_length_squared:
closest_length_squared = length_squared
closest_obj = hit_obj
closest_hit = hit
closest_face_index = face_index
# Label snaps from the closest object
if closest_obj is not None:
for snap in closest_snaps:
if snap["object"] == closest_obj:
snap["is_closest_to_camera"] = True
return closest_snaps
@classmethod
def calculate_snap_threshold(cls, view_distance):
snap_threshold = view_distance / 100
@@ -547,3 +883,273 @@ class Raycast(bonsai.core.tool.Raycast):
if lens < 50:
snap_threshold *= value
return snap_threshold
@classmethod
def create_snap_obj(cls, obj):
if obj.data is None or not isinstance(obj.data, bpy.types.Mesh):
return None
for i, snap_obj in enumerate(cls.snap_objs):
if obj.name == snap_obj.obj.name:
# Handle objects modified while a modal operator is active.
# Example: adding a door or window alters the wall geometry.
if len(obj.data.vertices) != len(snap_obj.verts_3d):
cls.snap_objs.pop(i)
snap_obj = SnapObj(obj)
cls.snap_objs.append(snap_obj)
for v1, v2 in zip(obj.data.vertices, snap_obj.verts_3d):
if (obj.matrix_world @ v1.co) != v2:
cls.snap_objs.pop(i)
snap_obj = SnapObj(obj)
cls.snap_objs.append(snap_obj)
return snap_obj
snap_obj = SnapObj(obj)
cls.snap_objs.append(snap_obj)
return snap_obj
@classmethod
def clear_snap_objs(cls):
TreeNode.__clear_all__()
SnapObj.__clear_all__()
cls.snap_objs.clear()
class TreeNode:
all = []
def __init__(self, box: tuple):
self.__class__.all.append(self)
self.box = box
self.child_a = None
self.child_b = None
self.edges = []
def __clear_all__():
for instance in TreeNode.all:
del instance
TreeNode.all.clear()
class SnapObj:
max_depth = 9
all = []
def __init__(self, obj: bpy.types.Object):
self.__class__.all.append(self)
self.obj = obj
self.root = self._create_root_node()
self.root.edges = [e.index for e in obj.data.edges]
self.split_box(self.root, 0)
self.verts_3d = [obj.matrix_world @ v.co for v in obj.data.vertices]
self.snap_points = []
def __clear_all__():
for instance in SnapObj.all:
del instance
SnapObj.all.clear()
def _create_root_node(self) -> TreeNode:
bbox = tool.Blender.get_object_bounding_box(self.obj)
min_point = self.obj.matrix_world @ bbox["min_point"]
max_point = self.obj.matrix_world @ bbox["max_point"]
new_bbox = self.expand_bounding_box((min_point, max_point))
return TreeNode(new_bbox)
def divide_bounding_box_along_longest_axis(
self, min_pt: Vector, max_pt: Vector
) -> Union[tuple[Vector, Vector], tuple[Vector, Vector]]:
"""
Divide a bounding box into two equal parts along the axis with the longest dimension.
Args:
min_pt: The minimum point of the bounding box.
max_pt: The maximum point of the bounding box.
Returns:
list: A list of two tuples, each containing the minimum and maximum points of the divided boxes.
"""
# Calculate the dimensions of the box
dx = max_pt.x - min_pt.x
dy = max_pt.y - min_pt.y
dz = max_pt.z - min_pt.z
# Determine the axis with the longest dimension
if dx >= dy and dx >= dz:
# Divide along the x-axis
mid_x = min_pt.x + dx / 2
box1 = (min_pt, Vector((mid_x, max_pt.y, max_pt.z)))
box2 = (Vector((mid_x, min_pt.y, min_pt.z)), max_pt)
elif dy >= dx and dy >= dz:
# Divide along the y-axis
mid_y = min_pt.y + dy / 2
box1 = (min_pt, Vector((max_pt.x, mid_y, max_pt.z)))
box2 = (Vector((min_pt.x, mid_y, min_pt.z)), max_pt)
else:
# Divide along the z-axis
mid_z = min_pt.z + dz / 2
box1 = (min_pt, Vector((max_pt.x, max_pt.y, mid_z)))
box2 = (Vector((min_pt.x, min_pt.y, mid_z)), max_pt)
return [box1, box2]
def expand_bounding_box(self, box: tuple[Vector, Vector], offset: float = 0.1) -> tuple[Vector, Vector]:
"""
Expand a 3D bounding box by a given offset.
Args:
min_pt: The minimum point of the bounding box.
max_pt: The maximum point of the bounding box.
offset: The offset to expand the bounding box by.
Returns:
tuple: A tuple containing the new minimum and maximum points of the expanded bounding box.
"""
min_pt, max_pt = box
# Calculate the new minimum and maximum points
new_min_pt = Vector((min_pt.x - offset, min_pt.y - offset, min_pt.z - offset))
new_max_pt = Vector((max_pt.x + offset, max_pt.y + offset, max_pt.z + offset))
return new_min_pt, new_max_pt
def split_box(self, parent: TreeNode, depth: int):
"""
Splits the bounding box creating two child nodes to compose a BVH Tree recursively.
Args:
parent: the TreeNode instance that represents the parent node of a BVH Tree.
depth: the depth of the BVH Tree no be used in recursion.
"""
if depth > self.max_depth:
return
box_a, box_b = self.divide_bounding_box_along_longest_axis(parent.box[0], parent.box[1])
parent.child_a = TreeNode(box_a)
parent.child_b = TreeNode(box_b)
edges_a = []
edges_b = []
for e in parent.edges:
verts_idx = [v for v in self.obj.data.edges[e].vertices]
verts_coords = []
for idx in verts_idx:
if idx < len(self.obj.data.vertices):
verts_coords.append(self.obj.matrix_world @ self.obj.data.vertices[idx].co)
if self.line_intersects_box(verts_coords[0], verts_coords[1], parent.child_a.box):
edges_a.append(e)
if self.line_intersects_box(verts_coords[0], verts_coords[1], parent.child_b.box):
edges_b.append(e)
parent.child_a.edges = edges_a
parent.child_b.edges = edges_b
self.split_box(parent.child_a, depth + 1)
self.split_box(parent.child_b, depth + 1)
def raycast_box(
self, context: bpy.types.Context, event: bpy.types.Event, node: TreeNode, rays: tuple[Vector, Vector]
) -> bool:
"""
Raycast bounding box.
Args:
context: Blender context.
event: Blender event.
node: a TreeNode instance.
rays: tuple containing ray origin and ray direction
Returns:
True if hits the box or False otherwise.
"""
box = node.box
min_v = box[0]
max_v = box[1]
t_min = 0.0
t_max = float("inf")
ray_origin, ray_dir = rays
inv_dir = Vector((1.0 / r if r != 0.0 else 1e32) for r in (ray_dir.x, ray_dir.y, ray_dir.z))
# X
tx1 = (min_v.x - ray_origin.x) * inv_dir[0]
tx2 = (max_v.x - ray_origin.x) * inv_dir[0]
tmin = min(tx1, tx2)
tmax = max(tx1, tx2)
# Y
ty1 = (min_v.y - ray_origin.y) * inv_dir[1]
ty2 = (max_v.y - ray_origin.y) * inv_dir[1]
tmin = max(tmin, min(ty1, ty2))
tmax = min(tmax, max(ty1, ty2))
# Z
tz1 = (min_v.z - ray_origin.z) * inv_dir[2]
tz2 = (max_v.z - ray_origin.z) * inv_dir[2]
tmin = max(tmin, min(tz1, tz2))
tmax = min(tmax, max(tz1, tz2))
return (tmax >= max(tmin, t_min)) and (tmin <= t_max)
def line_intersects_box(self, v1: mathutils.Vector, v2: mathutils.Vector, box: tuple) -> bool:
"""
Check if a line segment intersects an axis-aligned bounding box (AABB).
Args:
v1: The first endpoint of the line segment as a mathutils.Vector.
v2: The second endpoint of the line segment as a mathutils.Vector.
box: A tuple containing the minimum and maximum points of the AABB, where each point is a mathutils.Vector.
Returns:
bool: True if the segment [v1, v2] intersects the AABB; otherwise, False.
"""
bmin, bmax = box
dir = v2 - v1
tmin = 0.0
tmax = 1.0
for i in range(3):
if abs(dir[i]) < 1e-12:
# Line is parallel to slab. If origin not within slab -> no hit.
if v1[i] < bmin[i] or v1[i] > bmax[i]:
return False
else:
ood = 1.0 / dir[i]
t1 = (bmin[i] - v1[i]) * ood
t2 = (bmax[i] - v1[i]) * ood
if t1 > t2:
t1, t2 = t2, t1
if t1 > tmin:
tmin = t1
if t2 < tmax:
tmax = t2
if tmin > tmax:
return False
# If any overlap in [0,1] exists, there's intersection
return (tmax >= 0.0) and (tmin <= 1.0)
def raycast_boxes(
self,
context: bpy.types.Context,
event: bpy.Types.Event,
node: TreeNode,
intersected: Union[TreeNode] = [],
rays: tuple[Vector, Vector] = (),
) -> Union[TreeNode]:
"""
Raycast bounding box subdivisions recursively.
Args:
context: Blender context.
event: Blender event.
node: a TreeNode instance.
intersected: list of intersected boxes to use in recursion.
rays: tuple containing ray origin and ray direction
Returns:
tuple: a list of TreeNode instances that represent the subdivided boxes hit by the ray cast.
"""
if not node.child_a:
intersected.append(node)
return intersected
intersects_a = self.raycast_box(context, event, node.child_a, rays)
intersects_b = self.raycast_box(context, event, node.child_b, rays)
if intersects_a:
intersected = self.raycast_boxes(context, event, node.child_a, intersected, rays)
if intersects_b:
intersected = self.raycast_boxes(context, event, node.child_b, intersected, rays)
return intersected
+27 -48
View File
@@ -388,57 +388,36 @@ class Snap(bonsai.core.tool.Snap):
# Objects
objs_to_raycast = tool.Raycast.filter_objects_to_raycast(context, event, objs_2d_bbox)
# Wireframes
# For wireframe we have to get all the objects so we can further calculate edge intersection
for snap_obj in objs_to_raycast:
if snap_obj.type in {"EMPTY", "CURVE"} or (snap_obj.type == "MESH" and len(snap_obj.data.polygons) == 0):
snap_points = tool.Raycast.ray_cast_by_proximity(context, event, snap_obj)
if snap_points:
for point in snap_points:
point["group"] = "Wireframe"
detected_snaps.append(point)
closest_snaps = tool.Raycast.ray_cast_and_get_closest_to_camera_snaps(context, event, objs_to_raycast)
detected_snaps.extend(closest_snaps)
if (space.shading.type == "SOLID" and space.shading.show_xray) or (
xray_mode = (space.shading.type == "SOLID" and space.shading.show_xray) or (
space.shading.type == "WIREFRAME" and space.shading.show_xray_wireframe
):
results = []
for obj in objs_to_raycast:
results.append(tool.Raycast.cast_rays_to_single_object(context, event, obj))
else:
results = []
results.append(tool.Raycast.cast_rays_and_get_best_object(context, event, objs_to_raycast))
)
for result in results:
snap_obj = result[0]
hit = result[1]
face_index = result[2]
if hit is not None:
# Wireframes
if snap_obj.type in {"EMPTY", "CURVE"} or (
snap_obj.type == "MESH" and len(snap_obj.data.polygons) == 0
):
continue
# Meshes
else:
# Add face snap
snap_point = {
"point": hit,
"type": "Face",
"group": "Object",
"object": snap_obj,
"face_index": face_index,
"distance": 9, # High value so it has low priority
}
detected_snaps.append(snap_point)
# Add vertex and edge snap
snap_points = tool.Raycast.ray_cast_by_proximity(
context, event, snap_obj, snap_obj.data.polygons[face_index]
)
if snap_points:
for point in snap_points:
point["group"] = "Object"
detected_snaps.append(point)
for snap_obj in objs_to_raycast:
for snap in closest_snaps:
if snap_obj.obj == snap["object"]:
if xray_mode:
if "face_index" in snap and snap["face_index"] is not None:
snap_points = tool.Raycast.ray_cast_by_proximity_2d(context, event, snap_obj)
for point in snap_points:
point["group"] = "Object"
detected_snaps.append(point)
else:
# If it is a solid object that is closest to camera it ignores all the rest
if (
"is_closest_to_camera" in snap
and snap["is_closest_to_camera"]
and snap["group"] == "Object"
):
closest_snap = [snap] # discards objects that aren't the closest
if "face_index" in snap and snap["face_index"] is not None:
snap_points = tool.Raycast.ray_cast_by_proximity_2d(context, event, snap_obj)
for point in snap_points:
point["group"] = "Object"
closest_snap.append(point)
detected_snaps = closest_snap
# snap to cut geometry (e.g. in plan view)
if CutDecorator.installed:
+1 -5
View File
@@ -997,9 +997,7 @@ class Spatial(bonsai.core.tool.Spatial):
return obj
@classmethod
def set_obj_origin_to_polygon_center(
cls, obj: bpy.types.Object, poly: Polygon, polygon_is_si: bool = True
) -> None:
def set_obj_origin_to_polygon_center(cls, obj: bpy.types.Object, poly: Polygon, polygon_is_si: bool = True) -> None:
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
centroid = poly.centroid
if polygon_is_si:
@@ -1007,7 +1005,6 @@ class Spatial(bonsai.core.tool.Spatial):
else:
obj.location = Vector((centroid.x * unit_scale, centroid.y * unit_scale, 0))
@classmethod
def get_2d_vertices_from_polygon(
cls,
@@ -1195,7 +1192,6 @@ class Spatial(bonsai.core.tool.Spatial):
) -> None:
bonsai.core.type.assign_type(ifc, tool.Model, type, element=element, type=relating_type)
@classmethod
def set_space_visibility(cls, is_visible: bool) -> None:
if tool.Ifc.get().schema == "IFC2X3":
@@ -7,7 +7,7 @@ Python code formatters
For Python code formatting, we use `Black code formatter <https://pypi.org/project/black/>`__,
black settings are stored in the repository's pyproject.toml.
We have GitHub workflow `ci-black-formatting` to maintain black formatting across the repository.
We have GitHub workflow `ci-lint` to maintain black formatting across the repository.
``black`` can be installed using ``pip install black`` and files can be formatted with the following example command:
@@ -13,7 +13,7 @@ When adding or removing a supported Python version, update the following:
* - File
- What to update
* - ``.github/workflows/ci-black-formatting.yaml``
* - ``.github/workflows/ci-lint.yaml``
- ``MIN_IOS_PY_VERSION``
* - ``.github/workflows/ci-ifcopenshell-python-pypi.yml``
- ``pyver`` matrix
@@ -44,6 +44,8 @@ When a new Blender version is released and supported:
* - File
- What to update
* - ``.github/workflows/ci-bonsai.yml``
- ``pyver`` matrix
* - ``.github/workflows/ci-bonsai-daily.yml``
- Blender download URL
@@ -57,9 +59,47 @@ When Blender ships with a new Python version:
* - File
- What to update
* - ``.github/workflows/ci-black-formatting.yaml``
* - ``.github/workflows/ci-lint.yaml``
- ``MIN_BLENDER_PY_VERSION``
* - ``src/bonsai/Makefile``
- ``SUPPORTED_PYVERSIONS``
* - ``src/bonsai/scripts/dev_environment.py``
- ``PYTHON_VERSION`` mapping (Blender version, bundled Python version)
Release
-------
Notes:
- Typically all packages are released at once using the same version schema
- The ``README.md`` badges can serve as a visual reference for what versions have been released
Things to update:
- ``.github/workflows/ci-bcf-pypi.yml`` - release `bcf-client <https://pypi.org/project/bcf-client/>`_ to PyPI
- ``.github/workflows/ci-bonsai.yml`` - release bonsai in GitHub releases
- ``.github/workflows/ci-bsdd-pypi.yaml`` - release `bsdd <https://pypi.org/project/bsdd/>`_ to PyPI
- ``.github/workflows/ci-ifc4d-pypi.yaml`` - release `ifc4d <https://pypi.org/project/ifc4d/>`_ to PyPI
- ``.github/workflows/ci-ifc5d-pypi.yaml`` - release `ifc5d <https://pypi.org/project/ifc5d/>`_ to PyPI
- ``.github/workflows/ci-ifcclash-pypi.yaml`` - release `ifcclash <https://pypi.org/project/ifcclash/>`_ to PyPI
- ``.github/workflows/ci-ifcconvert.yml`` - release ifcconvert binaries in GitHub releases
- ``.github/workflows/ci-ifccsv-pypi.yaml`` - release `ifccsv <https://pypi.org/project/ifccsv/>`_ to PyPI
- ``.github/workflows/ci-ifcdiff-pypi.yaml`` - release `ifcdiff <https://pypi.org/project/ifcdiff/>`_ to PyPI
- ``.github/workflows/ci-ifcedit-pypi.yaml`` - release `ifcedit <https://pypi.org/project/ifcedit/>`_ to PyPI
- ``.github/workflows/ci-ifcfm-pypi.yaml`` - release `ifcfm <https://pypi.org/project/ifcfm/>`_ to PyPI
- ``.github/workflows/ci-ifccityjson-pypi.yaml`` - release `ifccityjson <https://pypi.org/project/ifccityjson/>`_ to PyPI
- ``.github/workflows/ci-ifcmcp-pypi.yaml`` - release `ifcopenshell-mcp <https://pypi.org/project/ifcopenshell-mcp/>`_ to PyPI
- ``.github/workflows/ci-ifcopenshell-python.yml`` - release ifcopenshell-python binaries in GitHub releases
- ``.github/workflows/ci-ifcopenshell-python-pypi.yml`` - release `ifcopenshell <https://pypi.org/project/ifcopenshell/>`_ wheels to PyPI
- ``.github/workflows/ci-ifcpatch-pypi.yaml`` - release `ifcpatch <https://pypi.org/project/ifcpatch/>`_ to PyPI
- ``.github/workflows/ci-ifcquery-pypi.yaml`` - release `ifcquery <https://pypi.org/project/ifcquery/>`_ to PyPI
- ``.github/workflows/ci-ifcsverchok.yml`` - release ifcsverchok Blender add-on in GitHub releases
- ``.github/workflows/ci-ifctester-pypi.yml`` - release `ifctester <https://pypi.org/project/ifctester/>`_ to PyPI
- ``.github/workflows/ci-pyodide-wasm-release.yml`` - release pyodide wasm wheel to `wasm-wheels <https://github.com/IfcOpenShell/wasm-wheels>`_
- Release Bonsai Blender extension - zip files from ci-bonsai.yml releases should be uploaded manually to `Blender extensions platform <https://extensions.blender.org/add-ons/bonsai/>`_
- Publishing documentation and websites (see `website <https://github.com/IfcOpenShell/website>`_ repository):
- `ifcopenshell-docs.yml` - builds and publishes IfcOpenShell documentation to `docs.ifcopenshell.org <https://docs.ifcopenshell.org>`_ (`ifcopenshell_org_docs <https://github.com/IfcOpenShell/ifcopenshell_org_docs>`_ repo)
- `bonsai-docs.yml` - builds and publishes Bonsai documentation to `docs.bonsaibim.org <https://docs.bonsaibim.org>`_ (`bonsaibim_org_docs <https://github.com/IfcOpenShell/bonsaibim_org_docs>`_ repo)
- `main.yml` - publishes `bonsaibim.org <https://bonsaibim.org>`_ (`bonsaibim_org_static_html <https://github.com/IfcOpenShell/bonsaibim_org_static_html>`_ repo) and `ifcopenshell.org <https://ifcopenshell.org>`_ (`ifcopenshell_org_static_html <https://github.com/IfcOpenShell/ifcopenshell_org_static_html>`_ repo)
- ``VERSION`` to the release version - **UPDATE THIS LAST** as all workflows above typically depend on it to set the version correctly
+38
View File
@@ -396,6 +396,44 @@ Scenario: Add a slab
And the object "IfcSlab/Slab" bottom left corner is at "0,0,0"
And the object "IfcSlab/Slab" top right corner is at "1,1,0.2"
Scenario: Extend walls to underside
Given an empty IFC project
And I load the demo construction library
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
And I press "bim.add_occurrence"
And I set "scene.BIMModelProperties.ifc_class" to "IfcSlabType"
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcSlabType') if e.Name == 'FLR200'][0].id()"
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
And I press "bim.add_occurrence"
And the object "IfcSlab/Slab" is moved to "0,0,2.5"
When the object "IfcWall/Wall" is selected
And additionally the object "IfcSlab/Slab" is selected
And I look at the tool header
And I click "Extend To Underside"
Then the object "IfcWall/Wall" dimensions are "1,0.1,2.5"
Scenario: Extend walls to underside - extending to a tessellated gable roof
Given an empty IFC project
And I load the demo construction library
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
And I press "bim.add_occurrence"
# Create gable roof: a cube turned into a prism with a ridge.
And I add a cube of size "1" at "0.5,0.05,3"
And the object "Cube" is selected
And I evaluate expression "obj = bpy.context.active_object; [setattr(v.co, 'y', 0) for v in obj.data.vertices if v.co.z > 0]"
And I set "scene.BIMRootProperties.ifc_product" to "IfcElement"
And I set "scene.BIMRootProperties.ifc_class" to "IfcRoof"
And I press "bim.assign_class"
When the object "IfcWall/Wall" is selected
And additionally the object "IfcRoof/Cube" is selected
And I look at the tool header
And I click "Extend To Underside"
Then the object "IfcWall/Wall" dimensions are "1,0.1,2.5"
Scenario: Enable editing a slab profile
Given an empty IFC project
And I load the demo construction library
+31 -1
View File
@@ -133,7 +133,11 @@ class PanelSpy:
self.spied_labels.append(kwargs["text"])
return self
elif self.spied_attr == "prop":
props, name = args
if args:
props, name = args
else:
props = kwargs.get("data")
name = kwargs.get("property")
props: bpy.types.bpy_struct
text = kwargs.get("text", props.bl_rna.properties[name].name)
icon = kwargs.get("icon", None)
@@ -390,6 +394,32 @@ def i_look_at_the_panel_panel(panel: str) -> None:
panel_spy.refresh_spy()
@given(parsers.parse("I look at the tool header"))
@when(parsers.parse("I look at the tool header"))
@then(parsers.parse("I look at the tool header"))
def i_look_at_the_tool_header() -> None:
from bonsai.bim.module.model.workspace import EditObjectUI
class MockRegion:
type = "UI"
class MockContext:
def __getattr__(self, name):
if name == "region":
return MockRegion()
return getattr(bpy.context, name)
global panel_spy
panel_spy = PanelSpy(EditObjectUI)
panel_spy.is_spy_dirty = False
panel_spy.spied_attr = None
panel_spy.spied_labels = []
panel_spy.spied_props = []
panel_spy.spied_operators = []
panel_spy.spied_lists = []
EditObjectUI.draw(MockContext(), panel_spy)
@given(parsers.parse('I open the "{name}" menu'))
@when(parsers.parse('I open the "{name}" menu'))
@then(parsers.parse('I open the "{name}" menu'))
+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

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