Compare commits

...

115 Commits

Author SHA1 Message Date
Andrej730 9adc8e484a Bonsai - bump ifcopenshell build #7330 2025-11-12 22:34:42 +05:00
kaplus e8eb5e4d0a Add continue statement to skip duplicate calculations
Skip general path to avoid duplicate calculations.
2025-11-12 13:14:38 +01:00
Andrej730 080222ffe6 black . 2025-11-12 12:56:35 +05:00
Ryan Schultz abef655482 Fix #6938 - Have parent facet select the parent aggregate as well. 2025-11-11 20:19:38 -06:00
Ryan Schultz 85b3f831dc copy query for aggregates into clipboard, to align with common UI pattern. 2025-11-11 20:17:37 -06:00
falken10vdl d8ece32d62 Add markdown-it-py and mdurl dependencies for drawing module annotations (#7346) 2025-11-11 16:33:19 -06:00
Ryan Schultz 0df7117d37 Sort annotations to ensure FILLAREA types are drawn first (at the bottom) 2025-11-11 14:26:55 -06:00
sboddy 91d67e6371 Improve Merge/Mirror error messages to reduce confusion. (#6911) 2025-11-11 13:57:52 -06:00
Ryan Schultz 87d1b0637a Close #7294: Add target-view and scale classes to SVG parent group from IFC data
Reads from EPset_Drawing.TargetView and EPset_Drawing.Scale to add
CSS classes to the linework parent group for view-specific styling.
2025-11-11 12:47:58 -06:00
Esteban Dugueperoux b8adddc648 fix test_number_formatting tests and add cases (#7332) 2025-11-11 11:25:00 -06:00
Andrej730 25d3edc537 Avoid producing test artifacts in src/bonsai 2025-11-11 19:56:57 +05:00
Andrej730 7f8cb80281 Fix TestDrawingStyles after 44cc127 2025-11-11 19:56:57 +05:00
Andrej730 a2ce2ea23a Fix TestDisableEditingText crash after 679fa6d 2025-11-11 19:56:56 +05:00
Andrej730 7f7aef0e6e Fix duplicate drawing core test (8ffa6fc) 2025-11-11 19:56:56 +05:00
Andrej730 821b5380ea Fix copy to container test (ddbe627) 2025-11-11 19:56:56 +05:00
Andrej730 e8bcbc83a4 Update operator.py 2025-11-11 19:56:56 +05:00
Andrej730 091af3c1c9 Add todo for unused expression (17d6b8a) 2025-11-11 19:56:55 +05:00
Andrej730 4cc50301dd Remove unused variable (17d6b8a) 2025-11-11 19:56:55 +05:00
Andrej730 f643a1615e Fix failing aggregate duplication test after 17d6b8a 2025-11-11 19:56:55 +05:00
Andrej730 71e78c67f8 Add visibility and lock steps to geometry.feature
Added steps to set 'is_visible' to TRUE and 'is_locked' to FALSE in the 'Spatial Decomposition' panel for the 'Override paste buffer - with active IFC data' scenario. This ensures the test covers visibility and lock state handling for IFC objects.
2025-11-11 19:56:55 +05:00
Andrej730 e7c4a19d9e bim.override_object_duplicate_move - sync poll with object.duplicate_move 2025-11-11 19:56:54 +05:00
Andrej730 6c31d43e72 Fix feature tests after a11f81f 2025-11-11 19:56:54 +05:00
Andrej730 83b56d591b Fix error activating drawing style
Example error:
```
TypeError: bpy_struct: item.attr = val: enum "Default" not found in ('city.exr', 'courtyard.exr', 'forest.exr', 'interior.exr', 'night.exr', 'studio.exr', 'sunrise.exr', 'sunset.exr')
```

There was a bug in shading_styles.json where we were using `Default` for `studio_light`, which is only available for `SOLID` shading type. Changed value to `forest.exr`, added code to handle old bugged value since users already copied and possibly modified shading_styles.json
2025-11-11 19:56:54 +05:00
Andrej730 e5b26067ab typing 2025-11-11 19:56:54 +05:00
Andrej730 a4fae94be8 test_feature - add separate non-debug option to save ifc project
Also clean up temp folder before save (met issues when tests were using not updated shading_styles.json)
2025-11-11 19:56:53 +05:00
Andrej730 af4851f03a Fixing bsdd feature tests (a929ab0) 2025-11-10 18:58:02 +05:00
Andrej730 155d4c44ae PanelSpy - fix error handling methods that have a return value
E.g. `ReferenceUI` has `self.get_object_name(context)` - since value wasn't returned, it was returning `None`, breaking the tests.
2025-11-10 18:58:02 +05:00
Andrej730 48119baf97 test_feature - non-background mode for debugging 2025-11-10 18:58:02 +05:00
Andrej730 dd1989aebb test_feature - use pformat to print props for readibility 2025-11-10 18:58:01 +05:00
Andrej730 0806430700 TemplateListSpy - add repr 2025-11-10 18:58:01 +05:00
Andrej730 2d97f6638b test_feature - add debugpy option 2025-11-10 18:58:01 +05:00
Andrej730 b10e5ad4d6 PanelSpy - target not all callables
Because sometimes we have `Data` classes as panel attributes and they're also callable.
2025-11-10 18:58:01 +05:00
Andrej730 24f991ceb1 Fix feature tests after a11f81f 2025-11-10 18:58:01 +05:00
Andrej730 ac11baa0c4 Fix the_object_name_does_not_exist not checking for errors
`assert True` was introduced in 1c1035e and original implementation with `assert False` was removed in b0d8101
2025-11-10 18:58:01 +05:00
Andrej730 1609a77b77 typing for PanelSpy related options 2025-11-10 18:58:00 +05:00
Andrej730 dd2f091a52 object.delete and outliner.delete overrides to reuse original poll methods for consistency 2025-11-10 18:58:00 +05:00
Andrej730 a7bf0e7d5e Fix remove drawing feature test (f11d79f) 2025-11-10 18:58:00 +05:00
Andrej730 19bdd3e622 i_set_the_prop_property_to_value - don't fail silently on incorrect bool values 2025-11-10 18:58:00 +05:00
Andrej730 e1bf891ec6 PanelSpy - also support draw_header for panels 2025-11-10 18:58:00 +05:00
Andrej730 4009834b97 PanelSpy - rename panel attr to blender_panel to avoid conflicts with panel method
Also added support for `panel` method.
2025-11-10 18:57:59 +05:00
Andrej730 77f5e1fbad PanelSpy - add typing 2025-11-10 18:57:59 +05:00
Andrej730 c4eb77d544 Blender 5.0 - update shadint_styles eevee name 2025-11-10 18:57:59 +05:00
Andrej730 8a4ea43239 Blender 5.0 - fix error activating drawing styles
Some props were removed in Blender 5.0
2025-11-10 18:57:59 +05:00
Thomas Krijnen f056e22f60 ruff 2025-11-10 11:59:10 +01:00
Thomas Krijnen 05e81040ce black 2025-11-10 11:53:33 +01:00
Thomas Krijnen 8d8133e780 submodule 2025-11-10 11:45:05 +01:00
Thomas Krijnen dceb35782e Add platform tag for linuxarm64 in Makefile #7325 2025-11-10 11:17:24 +01:00
Thomas Krijnen 2c5f0c6cf2 Add Linux ARM 64bit to CI workflow #7325 2025-11-10 11:16:27 +01:00
Thomas Krijnen abd667684d Supply CGAL_WITH_GMPXX to ifcopenshell configure #7330 2025-11-10 10:48:24 +01:00
Thomas Krijnen 31b5f88497 Rename WITH_GMPXX to CGAL_WITH_GMPXX in build script #7330 2025-11-10 10:33:48 +01:00
Thomas Krijnen 77a7026d33 Disable GMPXX in CGAL build #733 2025-11-10 10:15:14 +01:00
Thomas Krijnen 9c5f3f9754 Disable GMPXX in CGAL build #7330 2025-11-10 09:51:38 +01:00
Esteban DUGUEPEROUX 5d56025014 cmake: Separate ifcparse build in its own CMakeLists.txt file 2025-11-10 09:42:51 +01:00
Bruno Postle ad62c75f4f ifcclash ifccsv ifcdiff ifcpatch: README.md stubs
The pyproject.toml files were referencing these missing files
2025-11-09 20:52:12 +00:00
Ryan Schultz 7a5bd03216 Close #7220: Duplicate multiple selected Linked Aggregates at one time 2025-11-09 13:45:09 -06:00
Ryan Schultz 593e0e28a9 fix #4189: local_view turns off/on text decorations, as well as cut decorator. 2025-11-08 16:35:46 -06:00
Massimo Fabbro ed2b2de79a Add profiler in ifcopenshell util and ifc5d quantify function time
Useful for quantify function optimization
2025-11-08 20:00:35 +01:00
Ryan Schultz 5a511eecc1 Fix #4014: Unassign annotation from all products before reassignment
The edit_assigned_product() function now removes the annotation from all
existing IfcRelAssignsToProduct relationships instead of just one, preventing
annotations from being incorrectly assigned to multiple products simultaneously.

This fixes issues where leader lines wouldn't update correctly because the
annotation retained old product assignments.
2025-11-08 12:46:33 -06:00
Bruno Postle 83e0fa1287 Bump python bindings maximum python version
Python 3.14 is now a thing and the build seems ok
2025-11-08 17:38:56 +00:00
Ryan Schultz a2dbdbf8cc Fix #7274: Add unique CSS class suffix to all selectors in sheet drawings
Previously, when multiple CSS selectors were comma-separated in a rule,
only the last selector received the unique drawing ID suffix. This caused
style conflicts when multiple drawings were placed on the same sheet.

Now all selectors in comma-separated lists receive the unique suffix,
ensuring proper style isolation between drawings.

Example:
Before: .cut.Status-DEMOLISH, .cut.Option-D.d2156 { ... }
After:  .cut.Status-DEMOLISH.d2156, .cut.Option-D.d2156 { ... }
2025-11-08 10:41:43 -06:00
Ryan Schultz 3d68bbc0ca Fix #7287: to correctly position IfcAnnotation on RCP's. 2025-11-08 09:11:38 -06:00
Thomas Krijnen 40f9f261ae submodule 2025-11-07 16:22:51 +01:00
Andrej730 c08cc58904 Blender 5.0 - fix opening Web UI
In Blender 5.0 some theme attributes were removed and moved to other places and code broke.

In the commit I've also switched the code to less dynamic approach, so static type checker could caught potential errors.
2025-11-07 19:13:44 +05:00
Andrej730 ebdb122a22 Fix error removing systems 6469f79 2025-11-07 19:13:44 +05:00
Andrej730 5de2752685 light ui - use calendar for simplicity 2025-11-07 19:13:44 +05:00
Andrej730 d2245a95c7 Blender 5.0 - fix issues related to EEVEE renamed again
Ahh, it was a mistake warning about non-`_NEXT` values previously - didn't realized that this name was temporary for Blender.
2025-11-07 19:13:43 +05:00
Andrej730 47075e95bd Blender 5.0 - fix error enabling Rendered mode in Solar Analysis 2025-11-07 19:13:43 +05:00
Thomas Krijnen 9d4c980e02 black 2025-11-07 11:50:33 +01:00
Thomas Krijnen 53c1a1537d ruff 2025-11-07 11:50:18 +01:00
Thomas Krijnen 7db2f66685 WKT footprint massaging 2025-11-07 10:59:33 +01:00
Thomas Krijnen 6dd34b1fa4 Fix failing test by bumping commit ref #7193 2025-11-07 10:59:10 +01:00
Ryan Schultz 862713cda4 related to #7298 - skip material, as well. 2025-11-06 20:56:56 -06:00
Ryan Schultz 06c1668e42 Closes #7298 - skips count on csv import. 2025-11-06 13:20:55 -06:00
Thomas Krijnen 5f9f0f26eb submodule 2025-11-06 19:06:51 +01:00
Andrej730 8db22cdaf7 test_model - fix incorrect stair profile values in generation tests 2025-11-06 18:46:28 +05:00
Andrej730 3bed9262e5 Fix miscalculated stair lengths in some cases (f52aafd)
`get_active_stair_calculated_params` wasn't updated when zero tread runs support was added and it's still was relying on a convention that `0.0` custom tread run = no custom tread run.
2025-11-06 18:46:06 +05:00
Andrej730 0c171a457b stair generation - fix bug with extended top nib with zero first tread run
`s0 = V_(custom_first_last_tread_run[0] or tread_run, tread_rise) + td_vector` was assuming `0` means "no custom first tread run", not it means actually "zero first tread run", so could should be adjusted

before - https://files.catbox.moe/5ovqnh.mp4
after - https://files.catbox.moe/srorl2.mp4
2025-11-06 18:44:49 +05:00
Andrej730 b5e8dfd863 generate_stair_2d_profile - add typing 2025-11-06 18:44:49 +05:00
Thomas Krijnen c9df87fc25 submodule 2025-11-06 10:48:12 +01:00
Esteban DUGUEPEROUX 30925a4b3c fix format 2025-11-05 19:26:22 +01:00
Esteban DUGUEPEROUX ccf0efffce cmake: Simplify hdf5 linkage 2025-11-05 19:26:22 +01:00
Andrej730 169c4392a9 test_model - simplify stair generation kwargs 2025-11-05 19:30:36 +05:00
Andrej730 e543785959 Fix breaking tests for stair length calculations with custom zero tread run (f52aafd)
In both cases it was providing arguments `(0,0)`, but was assuming only one tread will be zeroed.
2025-11-05 19:30:36 +05:00
Andrej730 3617d119cb Fix breaking old stair generation tests after f52aafd
Before f52aafd default `(0,0)` meant that there are no custom tread runs, but after the change it actually means that runs for first and last treads are 0. So default value should be adjusted.
Added explicit `None` value for clarity.
2025-11-05 19:30:36 +05:00
Andrej730 5d1f28c3cb validate_object_selection - don't invalidate active objects when they're not in view layer
Since it's not illegal in Blender and can be helpful - e.g. when you were assigning class to ifc space, you would lose selection completely and would need to reselect object to continue working on it.
2025-11-05 19:19:49 +05:00
Andrej730 6f04eb12d0 black . 2025-11-05 19:18:11 +05:00
Ryan Schultz b46136e9f8 For stairs, Lock total length now takes first and last tread widths into account as well. 2025-11-05 06:55:03 -06:00
Andrej730 c3fe2d0ee3 build_win - revert schema versions limit
Committed by accident when debugging 🥲
2025-11-05 14:05:00 +05:00
Andrej730 aeed371888 ifcopenshell.file - add todo notes for temp workarounds, make workaround more explicit 2025-11-04 19:24:22 +05:00
Andrej730 a50c8869a5 bonsai - fix ifcsqlite import (546a042) 2025-11-04 19:24:21 +05:00
Andrej730 04d1fe4052 sql, stream - more clear error message when accessing wrapped_data 2025-11-04 19:24:21 +05:00
Andrej730 4e1d0bcf22 Fix failing selecting products tests (3aa5ddb07)
After 3aa5ddb07 `select_products` adds to current selection, instead of resetting it.

So couple tests started to fail since they create new objects and `view_layer` is not yet updated (previously `bpy.ops.object.select_all(action="DESELECT")` was also implicitly updating view layer).
```
FAILED test/tool/test_spatial.py::TestSelectProducts::test_select_products - AssertionError: assert bpy.data.objects['Object'] in []
FAILED test/tool/test_system.py::TestSelectSystemProducts::test_run - AssertionError: assert bpy.data.objects['Object'] in []
```
2025-11-04 19:24:21 +05:00
Andrej730 d2126e5929 Blender 5.0 - avoid using deprecated use_nodes 2025-11-04 19:24:20 +05:00
Andrej730 3bdfbebc02 Blender 5.0 - accomodate new mathutils buffer protocol type
Long story short - since 5.0 `np.array(Vector())` is now producing `np.float32` instead of `np.float64`. So we have to provide `dtype` explicitly to support both <5.0 and >= 5.0.

See https://projects.blender.org/blender/blender/issues/149283
2025-11-03 19:32:59 +05:00
Andrej730 1a9bd1891e blender 5.0 - fix moved bl_ui_utils module (bonsai is now launchable in 5.0) 2025-11-03 18:33:34 +05:00
Andrej730 1599bc14b3 Fix core/test_system.py (5de96c9) 2025-11-03 18:33:34 +05:00
Andrej730 30b74ab09c blender 5.0 - ensure the latest module is used for type checking 2025-11-03 18:33:34 +05:00
Andrej730 24bee01f01 black . 2025-11-03 18:33:34 +05:00
Ryan Schultz 7329c485ea Fix quantify() regression where by_type queries and grouped iteration could apply QTOs to all entities in the model instead of the intended element subset. 2025-10-31 10:03:52 -05:00
Andrej730 9c93b12084 Blender 5.0 - fix bpy_restrict_state error
It's probably will be very popular bug, when someone would try to migrate to Blender 5.0 using old Bonsai.

Example traceback:

```
  File "Blender\5.0\extensions\.local\lib\python3.11\site-packages\bonsai\bim\prop.py", line 34, in <module>
    import bonsai.bim.schema
  File "Blender\5.0\extensions\.local\lib\python3.11\site-packages\bonsai\bim\schema.py", line 23, in <module>
    import bpy_restrict_state
ModuleNotFoundError: No module named 'bpy_restrict_state'
```
2025-10-31 18:50:53 +05:00
Andrej730 b97c7d39ad dev_environment.py - small error message tweak 2025-10-31 18:50:52 +05:00
Andrej730 fa0c1c5d2a dev_environment.py - fix typo 2025-10-31 18:50:52 +05:00
Andrej730 e4c49ce57b Fix missing Serializers target when building just IfcGeomServer
Noticed testing command from #7277. I believe Serializers it not actually needed to build IfcGeomServer.

Example error:
```
/usr/bin/ld: cannot find -lSerializers: No such file or directory
```
2025-10-31 18:50:52 +05:00
Andrej730 3ae77c68e8 build-all.py - add VERBOSE=1 for building executables 2025-10-31 18:50:52 +05:00
Andrej730 8b1cb8e7ae black . 2025-10-31 18:50:52 +05:00
Andrej730 ce0348518d cmake - note future cmake bug fixes 2025-10-31 18:50:52 +05:00
Andrej730 a660027b93 test_package - ensure wasm package is present for active build commit 2025-10-31 16:09:22 +05:00
Thomas Krijnen 44ef409335 Don't check for drawing semantics on qto or predefpset #7304 2025-10-31 09:56:54 +01:00
Ryan Schultz 089edff1db fix for #7296: handle openings without Blender representation in get_gross_top_area (#7297) 2025-10-30 20:40:06 -05:00
Thomas Krijnen bd5a1b5bda Account 2x3/4 differences in Shading/Rendering::Transparency() 2025-10-30 18:35:20 +01:00
Andrej730 ba3517f3aa build-all.py - switch to dynamic_lookup for macos builds
1) `-undefined,suppress` is deprecated, linker is sending warnings about it and it seems to be superseded by `dynamic_lookup`
2) `-flat_namespace` is not compatible with GMP when building for intel, for some reason and results in horrible errors during linking:

Example error:
```
ld: branch8 out of range 366651 in ___gmpn_add_nc
final section layout:
    __TEXT               addr=0x00000000, size=0x02d09000, fileOffset=0x00000000
        __text           addr=0x00001400, size=0x0264a5e4, fileOffset=0x00001400
        __stubs          addr=0x0264b9e4, size=0x0000423c, fileOffset=0x0264b9e4
        __stub_helper    addr=0x0264fc20, size=0x00006c58, fileOffset=0x0264fc20
        __gcc_except_tab addr=0x02656878, size=0x002efc04, fileOffset=0x02656878
        __const          addr=0x02946480, size=0x001cdc18, fileOffset=0x02946480
        __cstring        addr=0x02b140a0, size=0x0014324b, fileOffset=0x02b140a0
        __unwind_info    addr=0x02c572ec, size=0x0008d2d0, fileOffset=0x02c572ec
        __eh_frame       addr=0x02ce45c0, size=0x00024a30, fileOffset=0x02ce45c0
    __DATA_CONST         addr=0x02d09000, size=0x0022b000, fileOffset=0x02d09000
        __got            addr=0x02d09000, size=0x00001dc8, fileOffset=0x02d09000
        __mod_init_func  addr=0x02d0adc8, size=0x00000690, fileOffset=0x02d0adc8
        __const          addr=0x02d0b460, size=0x00227fd0, fileOffset=0x02d0b460
    __DATA               addr=0x02f34000, size=0x000c3000, fileOffset=0x02f34000
        __la_symbol_ptr  addr=0x02f34000, size=0x000056a0, fileOffset=0x02f34000
        __data           addr=0x02f396a0, size=0x0005f7b8, fileOffset=0x02f396a0
        __thread_vars    addr=0x02f98e58, size=0x000006d8, fileOffset=0x02f98e58
        __thread_ptrs    addr=0x02f99530, size=0x00000178, fileOffset=0x02f99530
        __thread_data    addr=0x02f996b0, size=0x000000d8, fileOffset=0x02f996b0
        __thread_bss     addr=0x02f99790, size=0x00018850, fileOffset=0x02f99790
        __bss            addr=0x02fb1fe0, size=0x0003b0c0, fileOffset=0x00000000
        __common         addr=0x02fed0a0, size=0x00009098, fileOffset=0x00000000
    __LINKEDIT           addr=0x02ff7000, size=0x0148e000, fileOffset=0x02fb2000
clang: error: linker command failed with exit code 1 (use -v to see invocation)
```
2025-10-30 19:45:24 +05:00
Andrej730 7f144b7b95 build-all.py - remove unused wasm flags
`-Wl,-undefined,suppress` have no meaning in emscripten and they are just ignored. And emscripten is ignoring undefined symbols by default without any flags.
2025-10-30 19:45:24 +05:00
Andrej730 19ac0b6c38 build-all.py - add VERBOSE=1 to the wrapper make command 2025-10-30 19:45:24 +05:00
Andrej730 3562437293 build-all.py - fix missing mac cross compilation host args in gmp
I guess something has changed in gmp 6.3.0 and this issue become more apparent.

Example error:
```
tmp-cnd_sub_n.s:133:14: error: tmp-cnd_add_n.sbrackets expression not supported on this target:
130 stp x8, x9, [x1,#16]
             ^
:2tmp-cnd_sub_n.s: :error: 134invalid instruction mnemonic 'bic':
6 bic x7, x13, x0:
error:  invalid instruction mnemonic 'cset'^
~Lrt: cset x0, cc~

     ^~~tmp-cnd_add_n.s~:
131:2: error: invalid instruction mnemonic 'adcs'
 adcs x8, x10, x6
 ^~~~
tmp-cnd_add_n.s:132:2: error: invalid instruction mnemonic 'adcs'
 adcs x9, x11, x7
 ^~~~
tmp-cnd_add_n.s:133:14: error: brackets expression not supported on this target
 stp x8, x9, [x1,#16]
             ^
tmp-cnd_add_n.s:134:6: error: invalid instruction mnemonic 'cset'
Lrt: cset x0, cs
     ^~~~
```
2025-10-30 19:45:24 +05:00
Andrej730 e8ece71d22 black . 2025-10-30 19:45:24 +05:00
84 changed files with 1654 additions and 779 deletions
@@ -34,6 +34,10 @@ jobs:
name: "Linux 64bit",
short_name: linux64
}
- {
name: "Linux ARM 64bit",
short_name: linuxarm64
}
- {
name: "MacOS Intel 64bit",
short_name: macos64
-3
View File
@@ -88,9 +88,6 @@ src/bonsai/bonsai/bim/data/gantt/jsgantt.js
src/bonsai/bonsai/bim/data/gantt/jsgantt.css
src/bonsai/bonsai/bim/data/webui/static/js/jquery.min.js
src/bonsai/drawings
src/bonsai/layouts
# ifcopenshell swig and compiled files
src/ifcopenshell-python/ifcopenshell/_ifcopenshell_wrapper*.so
src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.py
+8 -145
View File
@@ -104,6 +104,9 @@ endif()
project(IfcOpenShell VERSION ${RELEASE_VERSION})
# Make sure CMake modules in this project are found first
list(PREPEND CMAKE_MODULE_PATH ${PROJECT_SOURCE_DIR})
if(MINIMAL_BUILD)
message(STATUS "Setting options for minimal build")
set(BUILD_GEOMSERVER OFF)
@@ -259,7 +262,7 @@ if(WITH_CGAL)
else()
set(CGAL_Boost_USE_STATIC_LIBS "${Boost_USE_STATIC_LIBS}")
endif()
find_package(CGAL REQUIRED)
find_package(CGAL REQUIRED)
if(NOT CGAL_DIR)
message(
FATAL_ERROR
@@ -730,91 +733,7 @@ endif()
if(HDF5_SUPPORT)
if("${HDF5_INCLUDE_DIR}" STREQUAL "")
message(STATUS "No HDF5 include directory specified")
else()
set(HDF5_INCLUDE_DIR "${HDF5_INCLUDE_DIR}" CACHE FILEPATH "HDF5 header files")
endif()
if("${HDF5_LIBRARY_DIR}" STREQUAL "")
message(STATUS "No HDF5 library directory specified")
else()
set(HDF5_LIBRARY_DIR "${HDF5_LIBRARY_DIR}" CACHE FILEPATH "HDF5 library files")
endif()
if(HDF5_LIBRARY_DIR)
# result of the HDF5 ctest package
# Find zlib using cmake find_library. How should this be implemented?
# FIND_LIBRARY(NAMES z libz libz_debug PATHS ... NO_DEFAULT_PATH)
if("$ENV{CONDA_BUILD}" STREQUAL "")
# result of the HDF5 ctest package
if(WIN32)
set(zlib_post lib)
set(lib_ext lib)
else()
set(lib_ext a)
endif()
if("${CMAKE_BUILD_TYPE}" STREQUAL "Debug")
set(debug_postfix "_debug")
endif()
set(HDF5_LIBRARIES
"${HDF5_LIBRARY_DIR}/libhdf5_cpp${debug_postfix}.${lib_ext}"
"${HDF5_LIBRARY_DIR}/libhdf5${debug_postfix}.${lib_ext}"
"${HDF5_LIBRARY_DIR}/libz${zlib_post}${debug_postfix}.${lib_ext}"
"${HDF5_LIBRARY_DIR}/libsz${debug_postfix}.${lib_ext}"
"${HDF5_LIBRARY_DIR}/libaec${debug_postfix}.${lib_ext}"
)
else()
message(STATUS "Packaging hdf5 and zlib for conda distribution")
if(${CMAKE_SYSTEM_NAME} MATCHES "Darwin")
# macOS
set(zlib_post libz)
set(lib_ext dylib)
set(HDF5_LIBRARIES
"${HDF5_LIBRARY_DIR}/libhdf5_cpp.${lib_ext}"
"${HDF5_LIBRARY_DIR}/libhdf5.${lib_ext}"
"${HDF5_LIBRARY_DIR}/${zlib_post}.${lib_ext}"
)
else()
# linux and windows
# Find HDF5 package
find_package(HDF5 REQUIRED COMPONENTS C CXX)
# Find ZLIB package
find_package(ZLIB REQUIRED)
# Include directories
include_directories(${HDF5_INCLUDE_DIRS} ${ZLIB_INCLUDE_DIRS})
# Link libraries
set(HDF5_LIBRARIES ${HDF5_LIBRARIES} ${ZLIB_LIBRARIES})
message(STATUS "HDF5 libraries: ${HDF5_LIBRARIES}")
endif()
endif()
endif()
if(NOT HDF5_INCLUDE_DIR OR NOT HDF5_LIBRARY_DIR)
# First try to find it as a config.
find_package(HDF5 CONFIG)
if(HDF5_DIR)
message(STATUS "HDF5: found config at '${HDF5_DIR}'.")
set(HDF5_LIBRARIES hdf5_cpp-static)
else()
# If it failed, still try to find as a module.
# E.g. on Ubuntu `libhdf5-dev` doesn't provie hdf5-config.cmake.
# Will automatically fill HDF5_LIBRARIES and HDF5_INCLUDE_DIR.
find_package(HDF5 COMPONENTS CXX)
if(NOT HDF5_INCLUDE_DIR)
message(
FATAL_ERROR
"HDF5_INCLUDE_DIR is not provided (current value: '${HDF5_INCLUDE_DIR}'). "
"HDF5_LIBRARY_DIR is not provided (current value: '${HDF5_LIBRARY_DIR}'). "
"Also could not find HDF5 package (neither module or config)."
)
endif()
endif()
endif()
find_package(HDF5 REQUIRED COMPONENTS C CXX)
add_definitions(-DWITH_HDF5)
set(SWIG_DEFINES ${SWIG_DEFINES} -DWITH_HDF5)
@@ -948,7 +867,7 @@ endif()
include_directories(${INCLUDE_DIRECTORIES} ${OCC_INCLUDE_DIR} ${OPENCOLLADA_INCLUDE_DIRS}
${Boost_INCLUDE_DIRS} ${LIBXML2_INCLUDE_DIR} ${JSON_INCLUDE_DIR} ${HDF5_INCLUDE_DIR}
${Boost_INCLUDE_DIRS} ${LIBXML2_INCLUDE_DIR} ${JSON_INCLUDE_DIR} ${HDF5_INCLUDE_DIRS}
${EIGEN_DIR} ${CGAL_INCLUDE_DIR} ${GMP_INCLUDE_DIR} ${MPFR_INCLUDE_DIR} ${USD_INCLUDE_DIR}
${TBB_INCLUDE_DIR}
)
@@ -1038,6 +957,7 @@ if(NOT Boost_VERSION LESS 105800)
add_definitions(-DBOOST_OPTIONAL_USE_OLD_DEFINITION_OF_NONE)
endif()
add_subdirectory(../src/ifcparse ifcparse)
set(IFCOPENSHELL_LIBRARIES IfcParse)
if(BUILD_IFCGEOM)
@@ -1070,52 +990,6 @@ if(BUILD_CONVERT OR BUILD_IFCPYTHON)
endif()
endif()
# IfcParse
file(GLOB IFCPARSE_H_FILES_ALL ../src/ifcparse/*.h)
file(GLOB IFCPARSE_CPP_FILES_ALL ../src/ifcparse/*.cpp)
foreach(file ${IFCPARSE_H_FILES_ALL})
get_filename_component(filename "${file}" NAME)
if(NOT "${filename}" MATCHES "[0-9]")
list(APPEND IFCPARSE_H_FILES "${file}")
endif()
endforeach()
foreach(file ${IFCPARSE_CPP_FILES_ALL})
get_filename_component(filename "${file}" NAME)
if(NOT "${filename}" MATCHES "[0-9]")
list(APPEND IFCPARSE_CPP_FILES "${file}")
endif()
endforeach()
foreach(schema ${SCHEMA_VERSIONS})
list(APPEND IFCPARSE_H_FILES
../src/ifcparse/Ifc${schema}.h
../src/ifcparse/Ifc${schema}-definitions.h
)
list(APPEND IFCPARSE_CPP_FILES
../src/ifcparse/Ifc${schema}.cpp
../src/ifcparse/Ifc${schema}-schema.cpp
)
endforeach()
set(IFCPARSE_FILES ${IFCPARSE_CPP_FILES} ${IFCPARSE_H_FILES})
add_library(IfcParse ${IFCPARSE_FILES})
target_link_libraries(IfcParse ${STDCPPFS})
set_target_properties(IfcParse PROPERTIES COMPILE_FLAGS -DIFC_PARSE_EXPORTS VERSION "${PROJECT_VERSION}" SOVERSION "${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}")
if(LibXml2_DIR)
target_compile_definitions(IfcParse PRIVATE ${LIBXML2_DEFINITIONS})
endif()
if(WASM_BUILD)
target_link_libraries(IfcParse ${BCRYPT_LIBRARIES} ${LIBXML2_LIBRARIES})
else()
target_link_libraries(IfcParse ${Boost_LIBRARIES} ${BCRYPT_LIBRARIES} ${LIBXML2_LIBRARIES})
endif()
if(BUILD_IFCGEOM)
# CGAL::CGAL target already has dependencies resolved.
if(WITH_CGAL AND CGAL_DIR)
@@ -1309,7 +1183,7 @@ if(BUILD_GEOMSERVER)
file(GLOB H_FILES ../src/ifcgeomserver/*.h)
set(SOURCE_FILES ${CPP_FILES} ${H_FILES})
add_executable(IfcGeomServer ${SOURCE_FILES})
target_link_libraries(IfcGeomServer IfcGeom IfcParse Serializers ${kernel_libraries} ${OPENCASCADE_LIBRARIES} ${Boost_LIBRARIES})
target_link_libraries(IfcGeomServer IfcGeom IfcParse ${kernel_libraries} ${OPENCASCADE_LIBRARIES} ${Boost_LIBRARIES})
if((NOT WIN32) AND BUILD_SHARED_LIBS)
SET_INSTALL_RPATHS(IfcGeomServer "${IFCOPENSHELL_LIBRARY_DIR};${OCC_LIBRARY_DIR};${Boost_LIBRARY_DIRS}")
@@ -1406,17 +1280,6 @@ if(BUILD_QTVIEWER)
add_subdirectory(../src/qtviewer qtviewer)
endif()
# CMake installation targets
install(FILES ${IFCPARSE_H_FILES}
DESTINATION ${INCLUDEDIR}/ifcparse
)
install(TARGETS IfcParse
ARCHIVE DESTINATION ${LIBDIR}
LIBRARY DESTINATION ${LIBDIR}
RUNTIME DESTINATION ${BINDIR}
)
if(BUILD_IFCGEOM)
# install(FILES ${IFCGEOM_H_FILES}
# DESTINATION ${INCLUDEDIR}/ifcgeom
+88
View File
@@ -0,0 +1,88 @@
# To avoid cyclic calls to this file
list(REMOVE_ITEM CMAKE_MODULE_PATH ${PROJECT_SOURCE_DIR})
if("${HDF5_INCLUDE_DIR}" STREQUAL "")
message(STATUS "No HDF5 include directory specified")
else()
set(HDF5_INCLUDE_DIR "${HDF5_INCLUDE_DIR}" CACHE FILEPATH "HDF5 header files")
endif()
if("${HDF5_LIBRARY_DIR}" STREQUAL "")
message(STATUS "No HDF5 library directory specified")
else()
set(HDF5_LIBRARY_DIR "${HDF5_LIBRARY_DIR}" CACHE FILEPATH "HDF5 library files")
endif()
if(HDF5_LIBRARY_DIR)
# result of the HDF5 ctest package
# Find zlib using cmake find_library. How should this be implemented?
# FIND_LIBRARY(NAMES z libz libz_debug PATHS ... NO_DEFAULT_PATH)
if("$ENV{CONDA_BUILD}" STREQUAL "")
# result of the HDF5 ctest package
if(WIN32)
set(zlib_post lib)
set(lib_ext lib)
else()
set(lib_ext a)
endif()
if("${CMAKE_BUILD_TYPE}" STREQUAL "Debug")
set(debug_postfix "_debug")
endif()
set(HDF5_LIBRARIES
"${HDF5_LIBRARY_DIR}/libhdf5_cpp${debug_postfix}.${lib_ext}"
"${HDF5_LIBRARY_DIR}/libhdf5${debug_postfix}.${lib_ext}"
"${HDF5_LIBRARY_DIR}/libz${zlib_post}${debug_postfix}.${lib_ext}"
"${HDF5_LIBRARY_DIR}/libsz${debug_postfix}.${lib_ext}"
"${HDF5_LIBRARY_DIR}/libaec${debug_postfix}.${lib_ext}"
)
else()
message(STATUS "Packaging hdf5 and zlib for conda distribution")
if(${CMAKE_SYSTEM_NAME} MATCHES "Darwin")
# macOS
set(zlib_post libz)
set(lib_ext dylib)
set(HDF5_LIBRARIES
"${HDF5_LIBRARY_DIR}/libhdf5_cpp.${lib_ext}"
"${HDF5_LIBRARY_DIR}/libhdf5.${lib_ext}"
"${HDF5_LIBRARY_DIR}/${zlib_post}.${lib_ext}"
)
else()
# linux and windows
# Find HDF5 package
find_package(HDF5 REQUIRED COMPONENTS C CXX)
# Find ZLIB package
find_package(ZLIB REQUIRED)
# Include directories
include_directories(${HDF5_INCLUDE_DIRS} ${ZLIB_INCLUDE_DIRS})
# Link libraries
set(HDF5_LIBRARIES ${HDF5_LIBRARIES} ${ZLIB_LIBRARIES})
message(STATUS "HDF5 libraries: ${HDF5_LIBRARIES}")
endif()
endif()
endif()
if(NOT HDF5_INCLUDE_DIR OR NOT HDF5_LIBRARY_DIR)
# First try to find it as a config.
find_package(HDF5 CONFIG)
if(HDF5_DIR)
message(STATUS "HDF5: found config at '${HDF5_DIR}'.")
set(HDF5_LIBRARIES hdf5_cpp-static)
else()
# If it failed, still try to find as a module.
# E.g. on Ubuntu `libhdf5-dev` doesn't provie hdf5-config.cmake.
# Will automatically fill HDF5_LIBRARIES and HDF5_INCLUDE_DIR.
find_package(HDF5 COMPONENTS CXX)
if(NOT HDF5_INCLUDE_DIR)
message(
FATAL_ERROR
"HDF5_INCLUDE_DIR is not provided (current value: '${HDF5_INCLUDE_DIR}'). "
"HDF5_LIBRARY_DIR is not provided (current value: '${HDF5_LIBRARY_DIR}'). "
"Also could not find HDF5 package (neither module or config)."
)
endif()
endif()
endif()
+8 -6
View File
@@ -382,11 +382,13 @@ if MAC_CROSS_COMPILE_INTEL:
MAC_CROSS_COMPILE_INTEL_BJAM_ARGS = ["architecture=x86"]
MAC_CROSS_COMPILE_INTEL_CXX = "clang++ -arch x86_64"
MAC_CROSS_COMPILE_INTEL_CC = "clang -arch x86_64"
MAC_CROSS_COMPILE_INTEL_AUTOCONF_HOST_ARGS = ["--host=x86_64-apple-darwin"]
else:
MAC_CROSS_COMPILE_INTEL_ARGS = []
MAC_CROSS_COMPILE_INTEL_BJAM_ARGS = []
MAC_CROSS_COMPILE_INTEL_CXX = ""
MAC_CROSS_COMPILE_INTEL_CC = ""
MAC_CROSS_COMPILE_INTEL_AUTOCONF_HOST_ARGS = []
OFF_ON = ["OFF", "ON"]
BUILD_STATIC = "shared" not in flags
@@ -1195,7 +1197,9 @@ if "cgal" in targets:
OLD_CC = None
if MAC_CROSS_COMPILE_INTEL:
OLD_CC = os.environ.get("CC")
# Otherwise it's using arm64 `gcc` and fails to build gmp.
os.environ["CC"] = MAC_CROSS_COMPILE_INTEL_CC
gmp_args.extend(MAC_CROSS_COMPILE_INTEL_AUTOCONF_HOST_ARGS)
build_dependency(
name=f"gmp-{GMP_VERSION}",
@@ -1374,6 +1378,7 @@ if "cgal" in targets:
cmake_args_prefix_path.append(f"{DEPS_DIR}/install/cgal-{CGAL_VERSION}")
cmake_args_prefix_path.append(f"{DEPS_DIR}/install/gmp-{GMP_VERSION}")
cmake_args_prefix_path.append(f"{DEPS_DIR}/install/mpfr-{MPFR_VERSION}")
cmake_args.append(f"-DCGAL_WITH_GMPXX=Off")
if "occ" in targets and USE_OCCT:
cmake_args_prefix_path.append(f"{DEPS_DIR}/install/occt-{OCCT_VERSION}")
@@ -1441,17 +1446,14 @@ if not WASM and (not explicit_targets or {"IfcGeom", "IfcConvert", "IfcGeomServe
logger.info("\rBuilding executables... ")
run([make, f"-j{IFCOS_NUM_BUILD_PROCS}"], cwd=executables_dir)
run([make, f"-j{IFCOS_NUM_BUILD_PROCS}", "VERBOSE=1"], cwd=executables_dir)
run([make, "install/strip" if BUILD_CFG == "Release" else "install"], cwd=executables_dir)
if "IfcOpenShell-Python" in targets:
# On OSX the actual Python library is not linked against.
ADDITIONAL_ARGS = ""
if platform.system() == "Darwin":
ADDITIONAL_ARGS = "-Wl,-flat_namespace,-undefined,suppress"
if "wasm" in flags:
ADDITIONAL_ARGS = "-Wl,-undefined,suppress"
ADDITIONAL_ARGS = "-Wl,-undefined,dynamic_lookup"
# NOTE: We don't use `CXXFLAGS` for wrappers, so wrapper is compiled with different flags
# (e.g. ` -fdata-sections` is missing, which is set by default for executables)
@@ -1516,7 +1518,7 @@ if "IfcOpenShell-Python" in targets:
logger.info(f"\rBuilding python {python_version} wrapper... ")
run([make, f"-j{IFCOS_NUM_BUILD_PROCS}", "ifcopenshell_wrapper"], cwd=python_dir)
run([make, f"-j{IFCOS_NUM_BUILD_PROCS}", "ifcopenshell_wrapper", "VERBOSE=1"], cwd=python_dir)
run([make, "install/local"], cwd=os.path.join(python_dir, "ifcwrap"))
if python_executable:
+4 -1
View File
@@ -87,7 +87,7 @@ BLENDER_PLATFORM:=windows-x64
endif
# Current build commit hash.
OLD:=6924012
OLD:=e8eb5e4
.PHONY: bump
bump:
ifndef NEW
@@ -155,6 +155,9 @@ endif
cd build && . env/$(VENV_ACTIVATE) && $(PIP) download six --dest=./wheels
# Required by drawing module
cd build && . env/$(VENV_ACTIVATE) && $(PIP) download lxml $(PYPI_PLATFORM) --python-version $(PYPI_VERSION) --implementation $(PYPI_IMP) --only-binary=:all: --dest=./wheels
# Required by drawing module for markdown hyperlinks in annotations
cd build && . env/$(VENV_ACTIVATE) && $(PIP) download markdown-it-py --dest=./wheels
cd build && . env/$(VENV_ACTIVATE) && $(PIP) download mdurl --dest=./wheels
# Required by qto and drawing module
cd build && . env/$(VENV_ACTIVATE) && $(PIP) download shapely $(PYPI_PLATFORM) --python-version $(PYPI_VERSION) --implementation $(PYPI_IMP) --only-binary=:all: --dest=./wheels
# Required by the BIM type manager thumbnail generator
@@ -7,12 +7,6 @@
1.0,
1.0
],
"scene.render.bake_bias": 0.0010000000474974513,
"scene.render.bake_margin": 16,
"scene.render.bake_margin_type": "ADJACENT_FACES",
"scene.render.bake_samples": 256,
"scene.render.bake_type": "NORMALS",
"scene.render.bake_user_scale": 0.0,
"scene.render.border_max_x": 1.0,
"scene.render.border_max_y": 1.0,
"scene.render.border_min_x": 0.0,
@@ -54,11 +48,6 @@
"scene.render.stamp_note_text": "",
"scene.render.threads": 20,
"scene.render.threads_mode": "AUTO",
"scene.render.use_bake_clear": true,
"scene.render.use_bake_lores_mesh": false,
"scene.render.use_bake_multires": false,
"scene.render.use_bake_selected_to_active": false,
"scene.render.use_bake_user_scale": false,
"scene.render.use_border": false,
"scene.render.use_compositing": true,
"scene.render.use_crop_to_border": false,
@@ -253,12 +242,6 @@
1.0,
1.0
],
"scene.render.bake_bias": 0.0010000000474974513,
"scene.render.bake_margin": 16,
"scene.render.bake_margin_type": "ADJACENT_FACES",
"scene.render.bake_samples": 256,
"scene.render.bake_type": "NORMALS",
"scene.render.bake_user_scale": 0.0,
"scene.render.border_max_x": 1.0,
"scene.render.border_max_y": 1.0,
"scene.render.border_min_x": 0.0,
@@ -300,11 +283,6 @@
"scene.render.stamp_note_text": "",
"scene.render.threads": 20,
"scene.render.threads_mode": "AUTO",
"scene.render.use_bake_clear": true,
"scene.render.use_bake_lores_mesh": false,
"scene.render.use_bake_multires": false,
"scene.render.use_bake_selected_to_active": false,
"scene.render.use_bake_user_scale": false,
"scene.render.use_border": false,
"scene.render.use_compositing": true,
"scene.render.use_crop_to_border": false,
@@ -377,7 +355,7 @@
1.0,
1.0
],
"scene.display.shading.studio_light": "Default",
"scene.display.shading.studio_light": "forest.exr",
"scene.display.shading.studiolight_background_alpha": 0.0,
"scene.display.shading.studiolight_background_blur": 0.0,
"scene.display.shading.studiolight_intensity": 0.0,
@@ -503,18 +481,12 @@
"scene.eevee.shadow_ray_count": 1,
"scene.eevee.shadow_step_count": 6,
"scene.eevee.shadow_resolution_scale": 1.0,
"scene.render.bake_bias": 0.0010000000474974513,
"scene.render.bake_margin": 16,
"scene.render.bake_margin_type": "ADJACENT_FACES",
"scene.render.bake_samples": 256,
"scene.render.bake_type": "NORMALS",
"scene.render.bake_user_scale": 0.0,
"scene.render.border_max_x": 1.0,
"scene.render.border_max_y": 1.0,
"scene.render.border_min_x": 0.0,
"scene.render.border_min_y": 0.0,
"scene.render.dither_intensity": 1.0,
"scene.render.engine": "BLENDER_EEVEE_NEXT",
"scene.render.engine": "BLENDER_EEVEE",
"scene.render.film_transparent": false,
"scene.render.filter_size": 1.5,
"scene.render.fps": 24,
@@ -550,11 +522,6 @@
"scene.render.stamp_note_text": "",
"scene.render.threads": 20,
"scene.render.threads_mode": "AUTO",
"scene.render.use_bake_clear": true,
"scene.render.use_bake_lores_mesh": false,
"scene.render.use_bake_multires": false,
"scene.render.use_bake_selected_to_active": false,
"scene.render.use_bake_user_scale": false,
"scene.render.use_border": false,
"scene.render.use_compositing": true,
"scene.render.use_crop_to_border": false,
@@ -627,7 +594,7 @@
0.800000011920929,
0.800000011920929
],
"scene.display.shading.studio_light": "Default",
"scene.display.shading.studio_light": "forest.exr",
"scene.display.shading.studiolight_background_alpha": 0.0,
"scene.display.shading.studiolight_background_blur": 0.0,
"scene.display.shading.studiolight_intensity": 0.0,
@@ -321,6 +321,21 @@ class BIM_OT_select_aggregate(bpy.types.Operator):
aggregate_obj.select_set(True)
bpy.context.view_layer.objects.active = aggregate_obj
# copy selection query to clipboard
result = ""
for aggregate in all_parts:
aggregate_class = aggregate.is_a()
aggregate_name = aggregate.Name or ""
query = f'parent = "{aggregate_name}"'
if not result:
result = query
else:
result += f" + {query}"
if result:
bpy.context.window_manager.clipboard = result
self.report({"INFO"}, f"({result}) was copied to the clipboard.")
self.one_level_deep = False # <-- forcibly reset
return {"FINISHED"}
+2 -2
View File
@@ -235,8 +235,8 @@ class DecoratorData:
cut_cache = {}
slice_cache = {}
fill_cache = {}
camera_location_checksum = None
camera_rotation_checksum = None
camera_location_checksum = ""
camera_rotation_checksum = ""
@classmethod
def clear_cache(cls):
@@ -1641,6 +1641,13 @@ class CutDecorator:
if not context.scene.camera:
return
# Check if any viewport is in local view - skip decorations if so
for area in context.screen.areas:
if area.type == "VIEW_3D":
space = area.spaces.active
if isinstance(space, bpy.types.SpaceView3D) and space.local_view:
return # Don't draw decorations in local view
self.addon_prefs = tool.Blender.get_addon_preferences()
selected_elements_color = self.addon_prefs.decorator_color_selected
self.fallback_colour = (0.3, 0.3, 0.3, 1)
@@ -1731,20 +1738,23 @@ class CutDecorator:
def cache_camera_matrix(self):
obj = bpy.context.scene.camera
DecoratorData.camera_location_checksum = repr(np.array(obj.matrix_world.translation).tobytes())
DecoratorData.camera_rotation_checksum = repr(np.array(obj.matrix_world.to_3x3()).tobytes())
# Explicit `dtype` for Blender <5.0 compatibility.
DecoratorData.camera_location_checksum = repr(
np.array(obj.matrix_world.translation, dtype=np.float32).tobytes()
)
DecoratorData.camera_rotation_checksum = repr(np.array(obj.matrix_world.to_3x3(), dtype=np.float32).tobytes())
def is_camera_moved(self):
if not DecoratorData.camera_location_checksum:
self.cache_camera_matrix()
return True # Let's be conservative
obj = bpy.context.scene.camera
loc_check = np.frombuffer(eval(DecoratorData.camera_location_checksum))
loc_check = np.frombuffer(eval(DecoratorData.camera_location_checksum), dtype=np.float32)
loc_real = np.array(obj.matrix_world.translation).flatten()
if not np.allclose(loc_check, loc_real, atol=1e-4): # 0.1 mm
self.cache_camera_matrix()
return True
rot_check = np.frombuffer(eval(DecoratorData.camera_rotation_checksum)).reshape(3, 3)
rot_check = np.frombuffer(eval(DecoratorData.camera_rotation_checksum), dtype=np.float32).reshape(3, 3)
rot_real = np.array(obj.matrix_world.to_3x3())
rot_dot = np.dot(rot_check, rot_real.T)
angle_rad = np.arccos(np.clip((np.trace(rot_dot) - 1) / 2, -1, 1))
@@ -1983,6 +1993,13 @@ class DecorationsHandler:
decorator.font_id = font_id
def __call__(self, context):
# Check if any viewport is in local view - skip decorations if so
for area in context.screen.areas:
if area.type == "VIEW_3D":
space = area.spaces.active
if isinstance(space, bpy.types.SpaceView3D) and space.local_view:
return # Don't draw decorations in local view
if not DrawingsData.is_loaded:
DrawingsData.load()
@@ -932,6 +932,31 @@ class CreateDrawing(bpy.types.Operator):
return svg_path
# Add target_view and scale classes to the parent group from IFC data
if group is not None:
existing_classes = group.get("class", "").split()
# Add target_view class
if hasattr(self, "cprops") and getattr(self.cprops, "target_view", None):
target_view_class = tool.Drawing.canonicalise_class_name(str(self.cprops.target_view))
target_view_full_class = f"target-view-{target_view_class}"
if target_view_full_class not in existing_classes:
existing_classes.append(target_view_full_class)
# Add scale class from EPset_Drawing.Scale
drawing_pset = ifcopenshell.util.element.get_pset(self.camera_element, "EPset_Drawing")
if drawing_pset and drawing_pset.get("Scale"):
scale_value = drawing_pset["Scale"]
# Remove "1/" prefix if it exists
if isinstance(scale_value, str) and scale_value.startswith("1/"):
scale_value = scale_value[2:]
scale_class = tool.Drawing.canonicalise_class_name(str(scale_value))
scale_full_class = f"scale-{scale_class}"
if scale_full_class not in existing_classes:
existing_classes.append(scale_full_class)
group.set("class", " ".join(existing_classes))
if self.cprops.cut_mode == "BISECT":
self.remove_cut_linework(root)
self.generate_bisect_linework(context, root)
@@ -1287,11 +1312,6 @@ class CreateDrawing(bpy.types.Operator):
tool.Drawing.canonicalise_class_name(key) + "-" + tool.Drawing.canonicalise_class_name(str(value))
)
# ─── Target View ───────────────────────────────────────────
if getattr(self.cprops, "target_view", None):
target_view_class = tool.Drawing.canonicalise_class_name(str(self.cprops.target_view))
classes.append(f"target-view-{target_view_class}")
return classes
def is_manifold(self, obj) -> bool:
@@ -2701,21 +2721,59 @@ class ActivateDrawingStyle(bpy.types.Operator, tool.Ifc.Operator):
def set_raster_style(self, context: bpy.types.Context) -> None:
scene = context.scene # Do not remove. It is used in exec later
assert (space := tool.Blender.get_view3d_space()) # Do not remove. It is used in exec later
style = json.loads(self.drawing_style.raster_style)
style: dict[str, Any] = json.loads(self.drawing_style.raster_style)
VIEWPORT_SHADING_TYPE = "scene.display.shading.type"
def preprocess(path: str, value: Any) -> tuple[str, Any, bool, bool]:
warning = False
skip = False
# @25.05.12
if path == "scene.render.engine" and value == "BLENDER_EEVEE":
value = "BLENDER_EEVEE_NEXT"
BLENDER_5_REMOVED = (
"scene.render.bake_bias",
"scene.render.bake_margin",
"scene.render.bake_margin_type",
"scene.render.bake_samples",
"scene.render.bake_type",
"scene.render.bake_user_scale",
"scene.render.use_bake_clear",
"scene.render.use_bake_lores_mesh",
"scene.render.use_bake_multires",
"scene.render.use_bake_selected_to_active",
"scene.render.use_bake_user_scale",
)
# 25.11.07, Blender 5+
if path == "scene.render.engine" and value in ("BLENDER_EEVEE", "BLENDER_EEVEE_NEXT"):
if value == "BLENDER_EEVEE_NEXT":
print(
f"Warning: Value 'BLENDER_EEVEE_NEXT' is outdated for property '{path}' "
"since Blender 5.0 and should be replaced with 'BLENDER_EEVEE' in shading_styles.json."
)
warning = True
value = tool.Blender.get_eevee_name()
elif tool.Blender.BLENDER_5 and path in BLENDER_5_REMOVED:
print(
f"Warning: Value 'BLENDER_EEVEE' is outdated for property '{path}' "
"since Blender 4.2 and should be replaced with 'BLENDER_EEVEE_NEXT' in shading_styles.json."
f"Warning: Property '{path}' is removed "
"since Blender 5.0 and should be also removed from shading_styles.json."
)
warning = True
skip = True
# @25.11.11
elif (
path == "scene.display.shading.studio_light"
and value == "Default"
and style[VIEWPORT_SHADING_TYPE] in ("RENDERED", "MATERIAL")
):
value = "forest.exr"
print(
f"Warning: Value 'Default' for property '{path}' and "
f"'{VIEWPORT_SHADING_TYPE}' = '{style[VIEWPORT_SHADING_TYPE]}' is outdated "
"and should be replaced with 'forest.exr' in shading_styles.json."
)
warning = True
# @25.05.12
if path == "scene.display.shading.wireframe_color_type" and value == "MATERIAL":
elif path == "scene.display.shading.wireframe_color_type" and value == "MATERIAL":
value = "THEME"
print(
f"Warning: Value 'MATERIAL' is outdated for property '{path}' "
@@ -2749,7 +2807,16 @@ class ActivateDrawingStyle(bpy.types.Operator, tool.Ifc.Operator):
return path, value, warning, skip
for path, value in style.items():
paths = list(style.keys())
PRIORITY_PATHS = (
# `scene.display.shading.studio_light` values depend on `.type`
# so we got to set it first.
VIEWPORT_SHADING_TYPE,
)
paths.sort(key=lambda p: p not in PRIORITY_PATHS)
for path in paths:
value = style[path]
path, value, warning, skip = preprocess(path, value)
self.has_warnings_during_activation |= warning
@@ -3414,6 +3481,15 @@ class LoadDrawings(bpy.types.Operator, tool.Ifc.Operator):
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
# Operator is not accessible through UI if IFC project is not saved,
# but adding poll check to avoid using Drawings UI in scripts with unsaved project.
if tool.Ifc.get_path():
return True
cls.poll_message_set("IFC project is not saved.")
return False
def _execute(self, context):
core.load_drawings(tool.Drawing)
@@ -341,17 +341,29 @@ class SheetBuilder:
assert style_data is not None
text = ""
brackets_level = 0
selector_buffer = "" # Buffer to accumulate selectors across lines
for l in style_data:
if l == "{":
if brackets_level == 0:
cur_line = text.splitlines()[-1]
text = text[: -len(cur_line)]
# Get all accumulated selector text (may span multiple lines)
# Find where the last rule ended (after last }) or start of text
last_close = text.rfind("}")
if last_close == -1:
selector_text = text
text = ""
else:
selector_text = text[last_close + 1 :]
text = text[: last_close + 1]
# Process all selectors (split by comma)
css_selectors = []
# making sure cases like "text, tspan" will be
# converted to "text.prefix, tspan.prefix"
for css_selector in cur_line.split(","):
css_selector = f"{css_selector.strip()}.{prefix}"
css_selectors.append(css_selector)
for css_selector in selector_text.split(","):
css_selector = css_selector.strip()
if css_selector: # Only process non-empty selectors
css_selector = f"{css_selector}.{prefix}"
css_selectors.append(css_selector)
text += ", ".join(css_selectors) + " "
brackets_level += 1
elif l == "}":
@@ -202,6 +202,15 @@ class SvgWriter:
) -> Self:
self.precision = precision
self.decimal_places = decimal_places
# Sort annotations to ensure FILLAREA types are drawn first (at the bottom)
def sort_key(element):
predefined_type = ifcopenshell.util.element.get_predefined_type(element)
# Return 0 for FILLAREA to draw them first, 1 for everything else
return 0 if predefined_type == "FILL_AREA" else 1
annotations = sorted(annotations, key=sort_key)
for element in annotations:
obj = tool.Ifc.get_object(element)
if (
+1 -1
View File
@@ -116,7 +116,7 @@ class BIM_PT_camera(Panel):
# See #6686.
if (
props.has_underlay
and str(render.engine) == "BLENDER_EEVEE_NEXT"
and str(render.engine) == tool.Blender.get_eevee_name()
and ((megapixels := (render.resolution_x * render.resolution_y / 10**6)) > MEGAPIXELS_WARNING_THRESHOLD)
):
box = self.layout.box()
+131 -73
View File
@@ -809,9 +809,17 @@ class OverrideDelete(bpy.types.Operator):
@classmethod
def poll(cls, context):
return len(context.selected_objects) > 0
# Match `object.delete` poll for consistency.
# `object.delete` poll just checks for OBJECT mode.
poll = bpy.ops.object.delete.poll()
if poll:
return True
cls.poll_message_set("Only available in OBJECT mode")
return False
def execute(self, context):
if not context.selected_objects:
return {"FINISHED"}
# Deep magick from the dawn of time
if tool.Ifc.get() is None:
bpy.ops.object.delete(use_global=self.use_global, confirm=self.confirm)
@@ -823,6 +831,8 @@ class OverrideDelete(bpy.types.Operator):
return IfcStore.execute_ifc_operator(self, context)
def invoke(self, context, event):
if not context.selected_objects:
return {"FINISHED"}
assert context.window_manager
ifc_file = tool.Ifc.get()
if ifc_file is None:
@@ -1030,9 +1040,18 @@ class OverrideOutlinerDelete(bpy.types.Operator, tool.Ifc.Operator):
@classmethod
def poll(cls, context) -> bool:
return len(getattr(context, "selected_ids", [])) > 0
# Match `outliner.delete` poll for consistency.
# `outliner.delete` just checks `area.type` == `OUTLINER`.
poll = bpy.ops.outliner.delete.poll()
if poll:
return True
cls.poll_message_set("Only available from Outliner.")
return False
def execute(self, context):
if len(getattr(context, "selected_ids", [])) == 0:
return {"FINISHED"}
# In this override, we don't check self.hierarchy. This effectively
# makes Delete and Delete Hierarchy identical. This is on purpose, since
# non-hierarchical deletion may imply a whole bunch of potentially
@@ -1051,6 +1070,9 @@ class OverrideOutlinerDelete(bpy.types.Operator, tool.Ifc.Operator):
return {"FINISHED"}
def invoke(self, context, event):
if len(getattr(context, "selected_ids", [])) == 0:
return {"FINISHED"}
assert context.window_manager
ifc_file = tool.Ifc.get()
if ifc_file:
@@ -1121,10 +1143,18 @@ class OverrideDuplicateMove(bpy.types.Operator):
is_interactive: bpy.props.BoolProperty(name="Is Interactive", default=True)
@classmethod
def poll(cls, context):
return len(context.selected_objects) > 0
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()
if poll:
return True
cls.poll_message_set("Only available in OBJECT mode")
return False
def execute(self, context):
if not context.selected_objects:
return {"FINISHED"}
return OverrideDuplicateMove.execute_duplicate_operator(self, context, linked=False)
def _execute(self, context):
@@ -1147,7 +1177,7 @@ class OverrideDuplicateMove(bpy.types.Operator):
@staticmethod
def execute_ifc_duplicate_operator(operator: bpy.types.Operator, context: bpy.types.Context, linked: bool = False):
objects_to_remove = set()
objects_to_remove: set[bpy.types.Object] = set()
for obj in context.selected_objects:
element = tool.Ifc.get_entity(obj)
@@ -1205,6 +1235,9 @@ class DuplicateMoveLinkedAggregateMacro(bpy.types.Macro):
bl_options = {"REGISTER", "UNDO"}
OldToNewType = dict[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]]
class DuplicateMoveLinkedAggregate(bpy.types.Operator):
bl_idname = "bim.object_duplicate_move_linked_aggregate"
bl_label = "IFC Duplicate and Move Linked Aggregate"
@@ -1227,9 +1260,9 @@ class DuplicateMoveLinkedAggregate(bpy.types.Operator):
self.new_active_obj = None
self.group_name = "BBIM_Linked_Aggregate"
self.pset_name = "BBIM_Linked_Aggregate"
old_to_new = {}
all_old_to_new = {} # Track all duplicates created
def select_objects_and_add_data(element):
def select_objects_and_add_data(element: ifcopenshell.entity_instance) -> None:
add_linked_aggregate_group(element)
obj = tool.Ifc.get_object(element)
obj.select_set(True)
@@ -1243,8 +1276,6 @@ class DuplicateMoveLinkedAggregate(bpy.types.Operator):
select_objects_and_add_data(part)
else:
index = add_linked_aggregate_pset(part, index)
# index += 1
obj = tool.Ifc.get_object(part)
obj.select_set(True)
@@ -1257,20 +1288,15 @@ class DuplicateMoveLinkedAggregate(bpy.types.Operator):
if not pset:
pset = ifcopenshell.api.pset.add_pset(ifc_file, product=element, name=self.pset_name)
ifcopenshell.api.pset.edit_pset(
ifc_file,
pset=pset,
properties=properties,
)
index += 1
else:
pass
return index
def add_linked_aggregate_group(element):
def add_linked_aggregate_group(element: ifcopenshell.entity_instance) -> None:
linked_aggregate_group = None
product_groups_name = [
r.RelatingGroup.Name
@@ -1283,25 +1309,21 @@ class DuplicateMoveLinkedAggregate(bpy.types.Operator):
linked_aggregate_group = ifcopenshell.api.group.add_group(ifc_file, name=self.group_name)
ifcopenshell.api.group.assign_group(ifc_file, products=[element], group=linked_aggregate_group)
def custom_incremental_naming_for_element_assembly(old_to_new):
def custom_incremental_naming_for_element_assembly(old_to_new: OldToNewType) -> None:
for new in old_to_new.values():
if new[0].is_a("IfcElementAssembly"):
group_elements: list[ifcopenshell.entity_instance] = next(
r.RelatedObjects
for r in getattr(new[0], "HasAssignments", []) or []
if r.is_a("IfcRelAssignsToGroup")
if "BBIM_Linked_Aggregate" in r.RelatingGroup.Name
)
pset = ifcopenshell.util.element.get_pset(new[0], "BBIM_Linked_Aggregate")
new_obj = tool.Ifc.get_object(new[0])
new_obj.name = pset["Name"] + "_" + str(pset["Aggregate_Index"])
new_obj.name = f"{pset['Name']}_{pset['Aggregate_Index']:02d}"
def get_max_index(parts):
psets = [ifcopenshell.util.element.get_pset(p, "BBIM_Linked_Aggregate") for p in parts]
index = [i["Index"] for i in psets if i]
if len(index) > 0:
index = max(index)
psets = [
ifcopenshell.util.element.get_pset(p, "BBIM_Linked_Aggregate")
for p in parts
if ifcopenshell.util.element.get_pset(p, "BBIM_Linked_Aggregate")
]
if psets:
index = max([i["Index"] for i in psets if i])
return index
else:
return 0
@@ -1315,23 +1337,28 @@ class DuplicateMoveLinkedAggregate(bpy.types.Operator):
if r.is_a("IfcRelAssignsToGroup")
if "BBIM_Linked_Aggregate" in r.RelatingGroup.Name
]
ifcopenshell.api.group.assign_group(ifc_file, group=linked_aggregate_group[0], products=new)
group_elements: list[ifcopenshell.entity_instance] = next(
r.RelatedObjects
for r in getattr(new[0], "HasAssignments", []) or []
if r.is_a("IfcRelAssignsToGroup")
if "BBIM_Linked_Aggregate" in r.RelatingGroup.Name
)
if linked_aggregate_group:
ifcopenshell.api.group.assign_group(ifc_file, group=linked_aggregate_group[0], products=new)
pset = ifcopenshell.util.element.get_pset(old, "BBIM_Linked_Aggregate")
if pset:
new_pset = ifcopenshell.api.pset.add_pset(ifc_file, product=new[0], name=self.pset_name)
if pset["Index"] == 0:
group_elements = []
if new[0].is_a("IfcElementAssembly"):
group_elements = next(
(
r.RelatedObjects
for r in getattr(new[0], "HasAssignments", []) or []
if r.is_a("IfcRelAssignsToGroup")
and "BBIM_Linked_Aggregate" in r.RelatingGroup.Name
),
[],
)
properties = {
"Index": pset["Index"],
"Name": pset["Name"],
"Aggregate_Index": len(group_elements) - 1,
"Aggregate_Index": len(group_elements) - 1 if group_elements else 0,
}
else:
properties = {"Index": pset["Index"]}
@@ -1351,43 +1378,73 @@ class DuplicateMoveLinkedAggregate(bpy.types.Operator):
location_diff = new_obj.location - base_obj_location
new_obj.location = context.scene.cursor.location + location_diff
if len(context.selected_objects) != 1:
# Process multiple selected aggregates
selected_aggregates = []
for obj in context.selected_objects:
selected_element = tool.Ifc.get_entity(obj)
if not selected_element:
continue
# Find the aggregate element
if selected_element.is_a("IfcElementAssembly"):
selected_aggregates.append(selected_element)
elif selected_element.Decomposes:
if selected_element.Decomposes[0].RelatingObject.is_a("IfcElementAssembly"):
selected_aggregates.append(selected_element.Decomposes[0].RelatingObject)
# Remove duplicates
selected_aggregates = list(set(selected_aggregates))
if not selected_aggregates:
self.report({"INFO"}, "No Linked Aggregates selected.")
return {"FINISHED"}
selected_obj = context.selected_objects[0]
selected_element = tool.Ifc.get_entity(selected_obj)
assert selected_element
if selected_element.is_a("IfcElementAssembly"):
pass
elif selected_element.Decomposes:
if selected_element.Decomposes[0].RelatingObject.is_a("IfcElementAssembly"):
selected_element = selected_element.Decomposes[0].RelatingObject
selected_obj = tool.Ifc.get_object(selected_element)
else:
self.report({"INFO"}, "Object is not part of a IfcElementAssembly.")
return {"FINISHED"}
select_objects_and_add_data(selected_element)
old_to_new = OverrideDuplicateMove.execute_ifc_duplicate_operator(self, context, linked=True)
tool.Root.recreate_aggregate(old_to_new)
copy_linked_aggregate_data(old_to_new)
custom_incremental_naming_for_element_assembly(old_to_new)
if location_from_3d_cursor:
get_location_from_3d_cursor(old_to_new, selected_element)
# Deselect all first
bpy.ops.object.select_all(action="DESELECT")
new_aggregate = old_to_new[selected_element][0]
tool.Blender.set_active_object(tool.Ifc.get_object(new_aggregate))
# Process each selected aggregate
for aggregate in selected_aggregates:
aggregate_obj = tool.Ifc.get_object(aggregate)
# Select and prepare the aggregate for duplication
select_objects_and_add_data(aggregate)
# Duplicate the aggregate
old_to_new = OverrideDuplicateMove.execute_ifc_duplicate_operator(self, context, linked=True)
all_old_to_new.update(old_to_new) # Collect all duplicates
# Recreate aggregate structure
tool.Root.recreate_aggregate(old_to_new)
# Copy linked aggregate data
copy_linked_aggregate_data(old_to_new)
# Apply custom naming
custom_incremental_naming_for_element_assembly(old_to_new)
# Apply 3D cursor location if requested
if location_from_3d_cursor:
get_location_from_3d_cursor(old_to_new, aggregate)
# Deselect for next iteration
bpy.ops.object.select_all(action="DESELECT")
# Select all newly created aggregates
for aggregate in selected_aggregates:
if aggregate in all_old_to_new:
new_aggregate = all_old_to_new[aggregate][0]
new_aggregate_obj = tool.Ifc.get_object(new_aggregate)
if new_aggregate_obj:
new_aggregate_obj.select_set(True)
# Set active object to the first new aggregate
if all_old_to_new:
first_new_aggregate = next(iter(all_old_to_new.values()))[0]
context.view_layer.objects.active = tool.Ifc.get_object(first_new_aggregate)
bonsai.bim.handler.refresh_ui_data()
return old_to_new
return all_old_to_new
class DuplicateLinkedAggregateTo3dCursor(bpy.types.Operator):
@@ -1423,7 +1480,7 @@ class RefreshLinkedAggregate(bpy.types.Operator, tool.Ifc.Operator):
self.pset_name = "BBIM_Linked_Aggregate"
refresh_start_time = time()
old_to_new = {}
original_data: dict[int, dict[int, str]] = {}
original_data: dict[int, dict[int, dict[str, Any]]] = {}
def delete_objects(element: ifcopenshell.entity_instance) -> None:
"""Remove IfcElementAssembly and it's parts."""
@@ -1469,8 +1526,8 @@ class RefreshLinkedAggregate(bpy.types.Operator, tool.Ifc.Operator):
)
tool.Blender.update_viewport()
def get_original_data(element: ifcopenshell.entity_instance) -> dict[int, dict[int, str]]:
group = next(
def get_original_data(element: ifcopenshell.entity_instance) -> dict[int, dict[int, dict[str, Any]]]:
group: int = next(
r.RelatingGroup
for r in getattr(element, "HasAssignments", []) or []
if r.is_a("IfcRelAssignsToGroup")
@@ -1478,8 +1535,8 @@ class RefreshLinkedAggregate(bpy.types.Operator, tool.Ifc.Operator):
).id()
original_data[group] = {}
pset = ifcopenshell.util.element.get_pset(element, self.pset_name)
index = pset["Index"]
pset: dict[str, Any] = ifcopenshell.util.element.get_pset(element, self.pset_name)
index: int = pset["Index"]
annotations = get_assignments(element)
container = ifcopenshell.util.element.get_container(element)
original_data[group][index] = {
@@ -1493,6 +1550,7 @@ class RefreshLinkedAggregate(bpy.types.Operator, tool.Ifc.Operator):
if parts:
for part in parts:
if part.is_a("IfcElementAssembly"):
# TODO: unused expression.
original_data | get_original_data(part)
else:
try:
+5 -4
View File
@@ -96,11 +96,12 @@ def update_shadow_mode(self: "BIMSolarProperties", context: bpy.types.Context) -
sun_props = tool.Blender.get_sun_props()
assert sun_props
if sun_props.sun_object is None:
bpy.ops.object.light_add(type="SUN", radius=1, align="WORLD", location=(0, 0, 0), scale=(1, 1, 1))
bpy.ops.object.move_to_collection(collection_index=0)
sun_props.sun_object = bpy.context.active_object
light = bpy.data.lights.new(name="Sun", type="SUN")
sun = bpy.data.objects.new("Sun", light)
context.scene.collection.objects.link(sun)
sun_props.sun_object = sun
update_sun_path(self)
context.scene.render.engine = "BLENDER_EEVEE_NEXT"
context.scene.render.engine = tool.Blender.get_eevee_name()
assert context.scene.display
assert context.scene.display.shading
context.scene.display.shading.light = "FLAT"
+2 -18
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 bpy
import bonsai.tool as tool
from typing import TYPE_CHECKING
@@ -192,24 +193,7 @@ class BIM_PT_solar(bpy.types.Panel):
row = self.layout.row()
row.prop(props, "year")
row = self.layout.row(align=True)
row.prop(
props,
"month",
text={
1: "January",
2: "February",
3: "March",
4: "April",
5: "May",
6: "June",
7: "July",
8: "August",
9: "September",
10: "October",
11: "November",
12: "December",
}[props.month],
)
row.prop(props, "month", text=calendar.month_name[props.month])
row.prop(props, "day")
row = self.layout.row(align=True)
+124 -9
View File
@@ -348,24 +348,136 @@ class BIMArrayProperties(PropertyGroup):
def update_total_length_target(self: "BIMStairProperties", context: bpy.types.Context) -> None:
self["tread_run"] = self.total_length_target / (self.number_of_treads + 1)
"""Update tread_run when total_length_target changes"""
# Calculate available length for default treads
available_length = self.total_length_target
n_default_treads = self.number_of_treads + 1 # number of risers
# Subtract custom first tread if not locked and not zero
if not self.custom_tread_lock and self.custom_first_last_tread_run[0] != 0:
available_length -= self.custom_first_last_tread_run[0]
n_default_treads -= 1
# Subtract custom last tread if not locked and not zero
if not self.custom_tread_lock and self.custom_first_last_tread_run[1] != 0:
available_length -= self.custom_first_last_tread_run[1]
n_default_treads -= 1
# Calculate tread_run for remaining treads
if n_default_treads > 0:
self["tread_run"] = available_length / n_default_treads
else:
# All treads are custom, just use target length
self["tread_run"] = self.total_length_target / (self.number_of_treads + 1)
def update_tread_run(self: "BIMStairProperties", context: bpy.types.Context) -> None:
"""Update either number_of_treads or total_length_target when tread_run changes"""
if self.total_length_lock:
self["number_of_treads"] = int((self.total_length_target / self.tread_run) - 1)
# Calculate how much length custom treads take up
custom_length = 0
n_custom_treads = 0
if not self.custom_tread_lock:
if self.custom_first_last_tread_run[0] != 0:
custom_length += self.custom_first_last_tread_run[0]
n_custom_treads += 1
if self.custom_first_last_tread_run[1] != 0:
custom_length += self.custom_first_last_tread_run[1]
n_custom_treads += 1
# Calculate how many default treads fit in remaining space
available_length = self.total_length_target - custom_length
if self.tread_run > 0:
n_default_treads = available_length / self.tread_run
total_treads = n_default_treads + n_custom_treads
# number_of_treads = number_of_risers - 1
self["number_of_treads"] = int(total_treads - 1)
else:
self["total_length_target"] = (self.number_of_treads + 1) * self.tread_run
# Calculate total length from current settings
n_default_treads = self.number_of_treads + 1
total_length = 0
if not self.custom_tread_lock:
if self.custom_first_last_tread_run[0] != 0:
total_length += self.custom_first_last_tread_run[0]
n_default_treads -= 1
if self.custom_first_last_tread_run[1] != 0:
total_length += self.custom_first_last_tread_run[1]
n_default_treads -= 1
total_length += n_default_treads * self.tread_run
self["total_length_target"] = total_length
def update_number_of_treads(self: "BIMStairProperties", context: bpy.types.Context) -> None:
"""Update either tread_run or total_length_target when number_of_treads changes"""
if self.total_length_lock:
self["tread_run"] = self.total_length_target / (self.number_of_treads + 1)
# Calculate available length for default treads
available_length = self.total_length_target
n_default_treads = self.number_of_treads + 1
if not self.custom_tread_lock:
if self.custom_first_last_tread_run[0] != 0:
available_length -= self.custom_first_last_tread_run[0]
n_default_treads -= 1
if self.custom_first_last_tread_run[1] != 0:
available_length -= self.custom_first_last_tread_run[1]
n_default_treads -= 1
if n_default_treads > 0:
self["tread_run"] = available_length / n_default_treads
else:
self["tread_run"] = self.total_length_target / (self.number_of_treads + 1)
else:
self["total_length_target"] = (self.number_of_treads + 1) * self.tread_run
# Calculate total length from current settings
n_default_treads = self.number_of_treads + 1
total_length = 0
if not self.custom_tread_lock:
if self.custom_first_last_tread_run[0] != 0:
total_length += self.custom_first_last_tread_run[0]
n_default_treads -= 1
if self.custom_first_last_tread_run[1] != 0:
total_length += self.custom_first_last_tread_run[1]
n_default_treads -= 1
total_length += n_default_treads * self.tread_run
self["total_length_target"] = total_length
StairType = Literal["CONCRETE", "WOOD/STEEL", "GENERIC"]
def update_custom_first_last_tread_run(self: "BIMStairProperties", context: bpy.types.Context) -> None:
"""Update tread_run or total_length when custom treads change"""
if self.total_length_lock:
# Recalculate tread_run to maintain total length
available_length = self.total_length_target
n_default_treads = self.number_of_treads + 1
if not self.custom_tread_lock:
if self.custom_first_last_tread_run[0] != 0:
available_length -= self.custom_first_last_tread_run[0]
n_default_treads -= 1
if self.custom_first_last_tread_run[1] != 0:
available_length -= self.custom_first_last_tread_run[1]
n_default_treads -= 1
if n_default_treads > 0:
self["tread_run"] = available_length / n_default_treads
else:
# Recalculate total length
n_default_treads = self.number_of_treads + 1
total_length = 0
if not self.custom_tread_lock:
if self.custom_first_last_tread_run[0] != 0:
total_length += self.custom_first_last_tread_run[0]
n_default_treads -= 1
if self.custom_first_last_tread_run[1] != 0:
total_length += self.custom_first_last_tread_run[1]
n_default_treads -= 1
total_length += n_default_treads * self.tread_run
self["total_length_target"] = total_length
class BIMStairProperties(PropertyGroup):
@@ -408,7 +520,7 @@ class BIMStairProperties(PropertyGroup):
has_top_nib: bpy.props.BoolProperty(name="Has Top Nib", default=True)
stair_type: bpy.props.EnumProperty(
name="Stair Type",
items=[(i, i.replace("/", " / ").title(), "") for i in get_args(StairType)],
items=[(i, i.replace("/", " / ").title(), "") for i in get_args(tool.Model.StairType)],
default="CONCRETE",
update=validate_nosing_value,
)
@@ -425,6 +537,7 @@ class BIMStairProperties(PropertyGroup):
min=0,
unit="LENGTH",
size=2,
update=update_custom_first_last_tread_run, # Added update callback
)
nosing_length: bpy.props.FloatProperty(
name="Nosing Length",
@@ -492,16 +605,18 @@ class BIMStairProperties(PropertyGroup):
}
stair_kwargs.update(generic_props)
non_si_units_props = self.non_si_units_props
# If locked, use tread_run for both first and last treads
if self.custom_tread_lock:
stair_kwargs["custom_first_last_tread_run"] = (self.tread_run, self.tread_run)
non_si_units_props += ("custom_first_last_tread_run",)
stair_kwargs["custom_first_last_tread_run"] = (None, None)
else:
stair_kwargs["custom_first_last_tread_run"] = self.custom_first_last_tread_run
if not convert_to_project_units:
return stair_kwargs
stair_kwargs = tool.Model.convert_data_to_project_units(stair_kwargs, self.non_si_units_props)
stair_kwargs = tool.Model.convert_data_to_project_units(stair_kwargs, non_si_units_props)
return stair_kwargs
def get_props_kwargs_for_ifc_export(self, convert_to_project_units=False, stair_type=None):
+7 -3
View File
@@ -17,7 +17,6 @@
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import bpy
import bl_ui_utils.layout
import bonsai.bim
import bonsai.tool as tool
from bpy.types import Panel, Menu
@@ -36,7 +35,12 @@ from bonsai.bim.module.model.stair import regenerate_stair_mesh
from bonsai.bim.module.model.railing import update_railing_modifier_bmesh
from bonsai.bim.module.model.roof import update_roof_modifier_bmesh
from collections.abc import Iterable
from typing import Any
from typing import Any, TYPE_CHECKING
if TYPE_CHECKING or bpy.app.version >= (5, 0, 0):
import _bl_ui_utils.layout as bl_ui_utils_layout
else:
import bl_ui_utils.layout as bl_ui_utils_layout
class BIM_MT_type_manager_menu(bpy.types.Menu):
@@ -56,7 +60,7 @@ class BIM_MT_type_menu(bpy.types.Menu):
def draw(self, context):
props = tool.Model.get_model_props()
layout = self.layout
with bl_ui_utils.layout.operator_context(layout, "INVOKE_REGION_WIN"):
with bl_ui_utils_layout.operator_context(layout, "INVOKE_REGION_WIN"):
op = layout.operator("bim.rename_type", icon="GREASEPENCIL", text="Rename Type")
op.element = props.menu_relating_type_id
op = layout.operator("bim.select_type", icon="OBJECT_DATA")
@@ -1322,6 +1322,20 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator):
if not bpy.context.selected_objects:
return
if self.active_material_usage == "LAYER2":
if len(bpy.context.selected_objects) != 2:
self.report(
{"ERROR"},
"Exactly two LAYER2 items (walls, railings, etc) must be selected to perform a merge.",
)
return
for item in bpy.context.selected_objects:
element = tool.Ifc.get_entity(item)
if tool.Model.get_usage_type(element) != "LAYER2":
self.report(
{"ERROR"},
"Both selected items must be LAYER2 (walls, railings, etc) to perform a merge.",
)
return
bpy.ops.bim.merge_wall()
else:
if len(bpy.context.selected_objects) == 1:
+17 -2
View File
@@ -859,8 +859,23 @@ def get_gross_top_area(obj: bpy.types.Object, angle: float = 45) -> float:
entity = ifc.by_guid(opening_id)
open_obj = tool.Ifc.get_object(entity)
assert isinstance(open_obj, bpy.types.Object)
opening_area += get_net_top_area(open_obj, angle=angle)
# Check if the opening has a Blender object representation
if open_obj is None:
# If no Blender object exists, create a temporary mesh from the IFC geometry
try:
mesh = get_gross_element_mesh(entity)
temp_obj = bpy.data.objects.new("TempOpening", mesh)
opening_area += get_net_top_area(temp_obj, angle=angle)
delete_obj(temp_obj)
delete_mesh(mesh)
except Exception as e:
# If we can't create geometry, skip this opening
print(f"Warning: Could not calculate opening area for {opening_id}: {e}")
continue
else:
# Opening has a Blender representation, use it directly
opening_area += get_net_top_area(open_obj, angle=angle)
else:
continue
+5 -2
View File
@@ -22,6 +22,7 @@ import ifcopenshell.api
import bonsai.tool as tool
import bonsai.core.qto as core
from bonsai.bim.module.qto import helper
from ifcopenshell.util.profiler import Profiler
class CalculateCircleRadius(bpy.types.Operator):
@@ -148,7 +149,8 @@ class CalculateSingleQuantity(bpy.types.Operator, tool.Ifc.Operator):
}
ifc_file = tool.Ifc.get()
results = ifc5d.qto.quantify(ifc_file, elements, rules)
with Profiler("Quantify function time:"):
results = ifc5d.qto.quantify(ifc_file, elements, rules)
ifc5d.qto.edit_qtos(ifc_file, results)
not_quantified_elements = elements - set(results.keys())
@@ -190,7 +192,8 @@ class PerformQuantityTakeOff(bpy.types.Operator, tool.Ifc.Operator):
) -> set[ifcopenshell.entity_instance]:
rules = ifc5d.qto.rules[rule]
ifc_file = tool.Ifc.get()
results = ifc5d.qto.quantify(ifc_file, elements, rules)
with Profiler("Quantify function time:"):
results = ifc5d.qto.quantify(ifc_file, elements, rules)
ifc5d.qto.edit_qtos(ifc_file, results)
not_quantified_elements = elements - set(results.keys())
return not_quantified_elements
@@ -21,6 +21,7 @@ import ifcopenshell.util.element
import bonsai.tool as tool
import bonsai.core.spatial as core
import bonsai.bim.handler
from typing import TYPE_CHECKING
class ReferenceStructure(bpy.types.Operator, tool.Ifc.Operator):
@@ -233,7 +234,7 @@ class RemoveContainer(bpy.types.Operator, tool.Ifc.Operator):
class CopyToContainer(bpy.types.Operator, tool.Ifc.Operator):
"""
Copies selected 3D elements in the viewport to the selected spatial containers
Copies selected 3D elements in the viewport to the container selected in Spatial Manager.
Example: bulk copy a wall to multiple storeys
@@ -242,9 +243,13 @@ class CopyToContainer(bpy.types.Operator, tool.Ifc.Operator):
Copying containers to other containers currently is not supported."""
bl_idname = "bim.copy_to_container"
bl_label = "Copy To Container"
bl_label = "Copy to Container"
bl_options = {"REGISTER", "UNDO"}
container: bpy.props.IntProperty()
container: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
if TYPE_CHECKING:
container: int
def _execute(self, context):
objs = tool.Spatial.get_selected_objects_without_containers()
@@ -455,7 +455,7 @@ class ActivateExternalStyle(bpy.types.Operator):
return {"CANCELLED"}
self.copy_material_attributes(db["data_block"], material)
if material.use_nodes:
if tool.Style.get_use_nodes(material):
tool.Blender.copy_node_graph(material, db["data_block"])
bpy.data.materials.remove(db["data_block"])
return {"FINISHED"}
+4 -3
View File
@@ -99,7 +99,8 @@ class BIMSystemProperties(PropertyGroup):
systems: CollectionProperty(name="Systems", type=System)
expanded_groups_json: StringProperty(name="Expanded Systems JSON", default="[]")
"""See `expanded_groups_json`. We name it "groups" for compatibility with Group UI code."""
active_system_index: IntProperty(name="Active System Index")
# Named not as `active_system_index` to match `BIMGroupProperties`.
active_group_index: IntProperty(name="Active System Index")
active_system_id: IntProperty(name="Active System Id")
edited_system_id: IntProperty(name="Edited System Id")
system_class: EnumProperty(items=get_system_class, name="Class")
@@ -113,7 +114,7 @@ class BIMSystemProperties(PropertyGroup):
is_adding: bool
systems: bpy.types.bpy_prop_collection_idprop[System]
expanded_groups_json: str
active_system_index: int
active_group_index: int
active_system_id: int
edited_system_id: int
system_class: str
@@ -121,7 +122,7 @@ class BIMSystemProperties(PropertyGroup):
@property
def active_system_ui_item(self) -> Union[System, None]:
return tool.Blender.get_active_uilist_element(self.systems, self.active_system_index)
return tool.Blender.get_active_uilist_element(self.systems, self.active_group_index)
def update_active_zone_index(self: "BIMZoneProperties", context: object) -> None:
+1 -1
View File
@@ -114,7 +114,7 @@ class BIM_PT_systems(Panel):
self.props,
"systems",
self.props,
"active_system_index",
"active_group_index",
)
if self.props.edited_system_id:
+2 -2
View File
@@ -786,7 +786,7 @@ class BIM_OT_add_section_plane(bpy.types.Operator):
material = bpy.data.materials.get("Section Override")
if not material:
material = bpy.data.materials.new("Section Override")
material.use_nodes = True
tool.Style.set_use_nodes(material, True)
props = tool.Blender.get_bim_props()
if props.should_section_selected_objects:
@@ -809,7 +809,7 @@ class BIM_OT_add_section_plane(bpy.types.Operator):
def override_materials(self):
override = bpy.data.node_groups.get("Section Override")
for material in bpy.data.materials:
material.use_nodes = True
tool.Style.set_use_nodes(material, True)
if material.node_tree.nodes.get("Section Override"):
continue
# In EEVEE rendering engine, `blend_mode` is deprecated and replaced by `surface_render_method`
+6 -1
View File
@@ -20,7 +20,12 @@ import ifcopenshell
import ifcopenshell.util.pset
import bonsai.tool as tool
import bpy
import bpy_restrict_state
from typing import TYPE_CHECKING
if TYPE_CHECKING or bpy.app.version >= (5, 0, 0):
import _bpy_restrict_state as bpy_restrict_state
else:
import bpy_restrict_state
class IfcSchema:
+14 -3
View File
@@ -60,10 +60,21 @@ def edit_assigned_product(
) -> None:
element = ifc.get_entity(obj)
assert element
existing_product = drawing.get_assigned_product(element)
if existing_product != product:
if existing_product:
# Get ALL existing products, not just one
existing_products = []
if hasattr(element, "HasAssignments"):
for rel in element.HasAssignments:
if rel.is_a("IfcRelAssignsToProduct"):
existing_products.append(rel.RelatingProduct)
# Only proceed if the assignment is different
if not (len(existing_products) == 1 and existing_products[0] == product):
# Unassign from ALL existing products
for existing_product in existing_products:
ifc.run("drawing.unassign_product", relating_product=existing_product, related_object=element)
# Assign to the new product
if product:
ifc.run("drawing.assign_product", relating_product=product, related_object=element)
+14
View File
@@ -308,6 +308,7 @@ class Document:
class Drawing:
def activate_drawing(cls, camera): pass
def add_literal(cls, **attributes): pass
def clear_annotation_relationships(cls, drawing): pass
def copy_representation(cls, source, dest): pass
def create_annotation_context(cls, target_view, object_type=None): pass
def create_annotation_object(cls, drawing, object_type): pass
@@ -485,6 +486,19 @@ class Georeference:
def xyz2enh(cls, coordinates): pass
@interface
class Group:
def get_group_props(cls): pass
def get_groups_data(cls, group_type): pass
def import_groups(cls, group_type): pass
def enable_group_editing_ui(cls) : pass
def disable_group_editing_ui(cls): pass
def disable_editing_group(cls): pass
def set_active_group_to_edit(cls, group): pass
def toggle_group(cls, group, group_type, option): pass
def update_uilist_index(cls, group_type): pass
@interface
class Ifc:
def get(cls): pass
+14 -2
View File
@@ -100,6 +100,7 @@ class Blender(bonsai.core.tool.Blender):
- (identifier, name, description, icon, number)
"""
BLENDER_ENUM_ITEMS = Iterable[BLENDER_ENUM_ITEM]
BLENDER_5 = bpy.app.version >= (5, 0, 0)
@classmethod
def activate_camera(cls, obj: bpy.types.Object) -> None:
@@ -416,7 +417,7 @@ class Blender(bonsai.core.tool.Blender):
cls, blender_material: bpy.types.Material, node_type: str, kwargs: Optional[dict] = {}
) -> Union[bpy.types.ShaderNode, None]:
"""returns first node from the `blender_material` shader graph with type `node_type`"""
if not blender_material.use_nodes:
if not tool.Style.get_use_nodes(blender_material):
return
nodes = blender_material.node_tree.nodes
for node in nodes:
@@ -709,7 +710,7 @@ class Blender(bonsai.core.tool.Blender):
new_selected_objects = [o for o in selected_objects if cls.is_valid_data_block(o) and o in view_layer_objects]
if active_object and (not cls.is_valid_data_block(active_object) or active_object not in view_layer_objects):
if active_object and not cls.is_valid_data_block(active_object):
active_object = None
return context, active_object, new_selected_objects
@@ -2083,3 +2084,14 @@ class Blender(bonsai.core.tool.Blender):
assert bpy.context.preferences
if props_updated and bpy.context.preferences.use_preferences_save:
bpy.ops.wm.save_userpref()
@classmethod
def get_eevee_name(cls) -> Literal["BLENDER_EEVEE"] | Literal["BLENDER_EEVEE_NEXT"]:
"""Convenience method to get correct eevee render engine name in multiple Blender versions.
In Blender 4.2 eevee was renamed to "eevee next".
In Blender 5 eevee next is now just "eevee" again.
"""
if cls.BLENDER_5:
return "BLENDER_EEVEE"
return "BLENDER_EEVEE_NEXT"
+2 -1
View File
@@ -1969,7 +1969,8 @@ class Drawing(bonsai.core.tool.Drawing):
@classmethod
def get_default_annotation_matrix(cls, camera, matrix_world=None):
if matrix_world is None:
matrix_world = camera.matrix_world.copy()
# Use normalized camera matrix (without scale) for RCP compatibility
matrix_world = cls.get_camera_matrix(camera)
annotation_offset = Vector((0, 0, -camera.data.clip_start - 0.05))
annotation_offset = camera.matrix_world.to_quaternion() @ annotation_offset
matrix_world.translation += annotation_offset
+5 -4
View File
@@ -1146,8 +1146,9 @@ class Geometry(bonsai.core.tool.Geometry):
def record_object_position(cls, obj: bpy.types.Object) -> None:
# These are recorded separately because they have different numerical tolerances
props = tool.Blender.get_object_bim_props(obj)
props.location_checksum = repr(np.array(obj.matrix_world.translation).tobytes())
props.rotation_checksum = repr(np.array(obj.matrix_world.to_3x3()).tobytes())
# Explicit dtype for Blender <5.0 compatibility.
props.location_checksum = repr(np.array(obj.matrix_world.translation, dtype=np.float32).tobytes())
props.rotation_checksum = repr(np.array(obj.matrix_world.to_3x3(), dtype=np.float32).tobytes())
@classmethod
def remove_connection(cls, connection: ifcopenshell.entity_instance) -> None:
@@ -1261,7 +1262,7 @@ class Geometry(bonsai.core.tool.Geometry):
if tool.Ifc.get().schema == "IFC2X3":
return False
for slot in obj.material_slots:
if slot.material and slot.material.use_nodes:
if slot.material and tool.Style.get_use_nodes(slot.material):
for node in slot.material.node_tree.nodes:
if node.type == "TEX_COORD" and node.outputs["UV"].links:
return True
@@ -2020,7 +2021,7 @@ class Geometry(bonsai.core.tool.Geometry):
continue
if not (style := tool.Ifc.get_entity(material)):
style = ifcopenshell.api.style.add_style(ifc_file, name=material.name)
if material.use_nodes:
if tool.Style.get_use_nodes(material):
ifc_class = "IfcSurfaceStyleRendering"
attributes = tool.Style.get_surface_rendering_attributes(material)
else:
+2 -2
View File
@@ -110,11 +110,11 @@ class Ifc(bonsai.core.tool.Ifc):
oprops = tool.Blender.get_object_bim_props(obj)
if not oprops.location_checksum:
return True # Let's be conservative
loc_check = np.frombuffer(eval(oprops.location_checksum))
loc_check = np.frombuffer(eval(oprops.location_checksum), dtype=np.float32)
loc_real = np.array(obj.matrix_world.translation).flatten()
if not np.allclose(loc_check, loc_real, atol=1e-4): # 0.1 mm
return True
rot_check = np.frombuffer(eval(oprops.rotation_checksum)).reshape(3, 3)
rot_check = np.frombuffer(eval(oprops.rotation_checksum), dtype=np.float32).reshape(3, 3)
rot_real = np.array(obj.matrix_world.to_3x3())
rot_dot = np.dot(rot_check, rot_real.T)
angle_rad = np.arccos(np.clip((np.trace(rot_dot) - 1) / 2, -1, 1))
+3 -3
View File
@@ -116,7 +116,7 @@ class Loader(bonsai.core.tool.Loader):
if transparency := surface_style.get("Transparency", None):
alpha = 1 - transparency
blender_material.diffuse_color = surface_style["SurfaceColour"] + (alpha,)
blender_material.use_nodes = False
tool.Style.set_use_nodes(blender_material, False)
@classmethod
def restart_material_node_tree(cls, blender_material: bpy.types.Material) -> None:
@@ -204,7 +204,7 @@ class Loader(bonsai.core.tool.Loader):
# TODO: reset pins to default values if no values passed
if reflectance_method in ["PHYSICAL", "NOTDEFINED"]:
blender_material.use_nodes = True
tool.Style.set_use_nodes(blender_material, True)
cls.restart_material_node_tree(blender_material)
bsdf = tool.Blender.get_material_node(blender_material, "BSDF_PRINCIPLED")
assert bsdf
@@ -231,7 +231,7 @@ class Loader(bonsai.core.tool.Loader):
blender_material.blend_method = "BLEND"
elif reflectance_method == "FLAT":
blender_material.use_nodes = True
tool.Style.set_use_nodes(blender_material, True)
cls.restart_material_node_tree(blender_material)
output = tool.Blender.get_material_node(blender_material, "OUTPUT_MATERIAL")
+150 -46
View File
@@ -44,11 +44,8 @@ import mathutils
from math import atan, cos, degrees, pi, radians
from mathutils import Matrix, Vector
from copy import deepcopy
from functools import partial
from bonsai.bim import import_ifc
from bonsai.bim.module.model.data import AuthoringData, RailingData, RoofData, WindowData, DoorData
from bonsai.bim.module.model.opening import FilledOpeningGenerator
from ifcopenshell.util.shape_builder import ShapeBuilder, np_to_3d
from typing import Optional, Union, TypeVar, Any, Literal, TYPE_CHECKING, TypedDict, assert_never
from collections.abc import Iterable, Sequence
@@ -1103,6 +1100,7 @@ class Model(bonsai.core.tool.Model):
return
from PIL import Image, ImageDraw
from bonsai.bim.module.model.data import AuthoringData
obj = tool.Ifc.get_object(element)
if not obj:
@@ -1208,6 +1206,8 @@ class Model(bonsai.core.tool.Model):
Allows postponing the thumbnail update until it is actually needed by the user.
"""
from bonsai.bim.module.model.data import AuthoringData
element_id = element.id()
if element_id not in AuthoringData.type_thumbnails:
return
@@ -1311,23 +1311,34 @@ class Model(bonsai.core.tool.Model):
@classmethod
def is_parametric_roof_active(cls) -> bool:
from bonsai.bim.module.model.data import RoofData
return bool((RoofData.is_loaded or not RoofData.load()) and RoofData.data["pset_data"])
@classmethod
def is_parametric_railing_active(cls) -> bool:
from bonsai.bim.module.model.data import RailingData
return bool((RailingData.is_loaded or not RailingData.load()) and RailingData.data["pset_data"])
@classmethod
def is_parametric_window_active(cls) -> bool:
from bonsai.bim.module.model.data import WindowData
return bool((WindowData.is_loaded or not WindowData.load()) and WindowData.data["pset_data"])
@classmethod
def is_parametric_door_active(cls) -> bool:
from bonsai.bim.module.model.data import DoorData
return bool((DoorData.is_loaded or not DoorData.load()) and DoorData.data["pset_data"])
CustomTreadRunType = Union[tuple[float, float], tuple[None, None]]
@classmethod
def get_active_stair_calculated_params(cls, pset_data: Optional[dict[str, Any]] = None) -> dict[str, Any]:
props = bpy.context.active_object.BIMStairProperties
assert (obj := bpy.context.active_object)
props = tool.Model.get_stair_props(obj)
if props.is_editing:
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
@@ -1338,38 +1349,48 @@ class Model(bonsai.core.tool.Model):
last_tread_run = props.custom_first_last_tread_run[1] / si_conversion
nosing_length = props.nosing_length / si_conversion
else:
number_of_treads = pset_data["number_of_treads"]
height = pset_data["height"]
tread_run = pset_data["tread_run"]
assert pset_data
number_of_treads: int = pset_data["number_of_treads"]
height: float = pset_data["height"]
tread_run: float = pset_data["tread_run"]
# use .get to not break the old .ifc models
custom_first_last_tread_run = pset_data.get("custom_first_last_tread_run", (0, 0))
custom_first_last_tread_run: tool.Model.CustomTreadRunType = pset_data.get(
"custom_first_last_tread_run", (0, 0)
)
first_tread_run, last_tread_run = custom_first_last_tread_run
nosing_length = pset_data.get("nosing_length", 0)
calculated_params = {}
calculated_params: dict[str, Any] = {}
number_of_rises = number_of_treads + 1
calculated_params["Number of Risers"] = number_of_rises
calculated_params["Tread Rise"] = round(height / number_of_rises, 5)
# calculate stair length
# Start with all treads using default tread_run
n_default_tread_runs = number_of_rises
length = 0
if first_tread_run != 0:
length = 0.0
# If first tread has custom width (non-None), use it instead of default.
if first_tread_run is not None:
n_default_tread_runs -= 1
length += first_tread_run
if last_tread_run != 0:
# If last tread has custom width (non-None), use it instead of default
if last_tread_run is not None:
n_default_tread_runs -= 1
if n_default_tread_runs >= 0:
length += last_tread_run
length += tread_run * max(n_default_tread_runs, 0)
# nosing overlaps
# are not part of the tread run
# so they don't affect the stair length
# except the first tread's nosing
length += last_tread_run
# Add remaining default tread runs
length += tread_run * n_default_tread_runs
# Handle nosing length effects on total length
# Nosing overlaps don't affect tread run spacing,
# but the first tread's nosing extends the total length
if nosing_length > 0: # nosing overlaps
length += nosing_length
if nosing_length < 0: # tread gaps
if nosing_length < 0: # tread gaps between treads
length += abs(nosing_length) * number_of_treads
calculated_params["Length"] = round(length, 5)
pitch = height / length
pitch_formatted = str(round(pitch * 100, 1)) + " % / " + str(round(degrees(atan(pitch)), 1)) + " deg"
@@ -1377,35 +1398,60 @@ class Model(bonsai.core.tool.Model):
return calculated_params
StairType = Literal["CONCRETE", "WOOD/STEEL", "GENERIC"]
@classmethod
def generate_stair_2d_profile(
cls,
number_of_treads,
height,
width,
tread_run,
stair_type,
number_of_treads: int,
height: float,
width: float,
tread_run: float,
stair_type: StairType,
# WOOD/STEEL CONCRETE STAIR ARGUMENTS
tread_depth=None,
tread_depth: Union[float, None] = None,
# CONCRETE STAIR ARGUMENTS
has_top_nib=None,
top_slab_depth=None,
base_slab_depth=None,
custom_first_last_tread_run=(0, 0),
nosing_length=0,
has_top_nib: Union[bool, None] = None,
top_slab_depth: Union[float, None] = None,
base_slab_depth: Union[float, None] = None,
custom_first_last_tread_run: Union[tuple[float, float], tuple[None, None]] = (None, None),
nosing_length: float = 0.0,
# CONCRETE GENERIC STAIR ARGUMENTS
nosing_depth=0,
):
nosing_depth: float = 0.0,
) -> tuple[list[Vector], list[tuple[int, ...]], list[[list[int]]]]:
"""returns a tuple of stair profile data: (vertices, edges, faces)"""
vertices = []
edges = []
faces = []
vertices: list[Vector] = []
edges: list[tuple[int, ...]] = []
faces: list[[list[int]]] = []
number_of_risers = number_of_treads + 1
tread_rise = height / number_of_risers
nosing_overlap = max(nosing_length, 0)
nosing_tread_gap = -min(nosing_length, 0)
nosing_overlap_offset = -V_(nosing_overlap, 0)
first_tread_run = custom_first_last_tread_run[0] if custom_first_last_tread_run[0] is not None else tread_run
# ============ DEBUG LOGGING START ============
print("\n" + "=" * 80)
print("STAIR GENERATION DEBUG - WOOD/STEEL TYPE")
print("=" * 80)
print(f"Input Parameters:")
print(f" stair_type: {stair_type}")
print(f" number_of_treads: {number_of_treads}")
print(f" height: {height}")
print(f" width: {width}")
print(f" tread_run: {tread_run}")
print(f" tread_depth: {tread_depth}")
print(f" custom_first_last_tread_run: {custom_first_last_tread_run}")
print(f" nosing_length: {nosing_length}")
print(f"\nCalculated Parameters:")
print(f" number_of_risers: {number_of_risers}")
print(f" tread_rise: {tread_rise}")
print(f" nosing_overlap: {nosing_overlap}")
print(f" nosing_tread_gap: {nosing_tread_gap}")
print(f" nosing_overlap_offset: {nosing_overlap_offset}")
print("=" * 80 + "\n")
# ============ DEBUG LOGGING END ============
def define_generic_stair_treads():
vertices.append(Vector([0, 0]))
@@ -1421,7 +1467,12 @@ class Model(bonsai.core.tool.Model):
elif nosing_depth == 0:
default_tread_verts = (V_(-nosing_overlap, tread_rise), V_(tread_run, tread_rise))
else: # nosing_overlap > 0 nosing_depth > 0
# kind of L shape
# kind of L shape:
# (2)●───────────────────────────●(3)
# |
# |
# ●──────────────●
# (1) (0)
default_tread_verts = (
V_(0, tread_rise - nosing_depth),
V_(-nosing_overlap, tread_rise - nosing_depth),
@@ -1470,16 +1521,25 @@ class Model(bonsai.core.tool.Model):
current_offset += tread_offset
if stair_type == "WOOD/STEEL":
builder = ShapeBuilder(None)
print("\n" + "-" * 80)
print("WOOD/STEEL STAIR GENERATION PROCESS")
print("-" * 80)
assert tread_depth is not None
# full tread rectangle
def get_tread_verts(*args, **kwargs):
fn = partial(builder.get_rectangle_coords, position=V_(0, -(tread_depth - tread_rise)))
return [Vector(x) for x in fn(*args, **kwargs)]
def get_tread_verts(size: Vector) -> list[Vector]:
coords = ShapeBuilder.get_rectangle_coords(position=V_(0, -(tread_depth - tread_rise)), size=size)
return [Vector(x) for x in coords]
default_tread_verts = get_tread_verts(size=V_(tread_run + nosing_overlap, tread_depth))
default_tread_offset = V_(tread_run + nosing_tread_gap, tread_rise)
print(f"\nDefault Tread Configuration:")
print(f" default_tread_verts: {default_tread_verts}")
print(f" default_tread_offset: {default_tread_offset}")
print(f" tread rectangle size: ({tread_run + nosing_overlap}, {tread_depth})")
def get_tread_data(i):
# Check if this is first or last tread with custom run
current_tread_run = None
@@ -1488,28 +1548,48 @@ class Model(bonsai.core.tool.Model):
elif i == number_of_risers - 1 and custom_first_last_tread_run[1] is not None:
current_tread_run = custom_first_last_tread_run[1]
print(f"\n Tread {i}:")
print(f" Is first tread: {i == 0}")
print(f" Is last tread: {i == number_of_risers - 1}")
print(f" current_tread_run: {current_tread_run}")
if current_tread_run is not None:
tread_offset = default_tread_offset.copy()
tread_offset.x = current_tread_run + nosing_tread_gap
# Handle zero-width treads
if current_tread_run == 0:
print(f" → Zero-width tread, skipping geometry")
print(f" → tread_offset: {tread_offset}")
return tread_offset, ()
tread_verts = get_tread_verts(size=V_(current_tread_run + nosing_overlap, tread_depth))
print(f" → Custom tread")
print(f" → tread_offset: {tread_offset}")
print(f" → tread_verts: {tread_verts}")
print(f" → tread rectangle size: ({current_tread_run + nosing_overlap}, {tread_depth})")
return tread_offset, tread_verts
print(f" → Using default tread")
print(f" → tread_offset: {default_tread_offset}")
return default_tread_offset, default_tread_verts
# each tread is a separate shape
cur_offset = V_(0, 0)
tread_index = 0
print(f"\nGenerating Treads:")
print(f" Number of risers to process: {number_of_risers}")
for i in range(number_of_risers):
tread_offset, tread_verts = get_tread_data(i)
# Skip adding vertices/edges for zero-width treads
if tread_verts:
cur_trade_shape = [v + cur_offset + nosing_overlap_offset for v in tread_verts]
print(f" Adding geometry at cur_offset: {cur_offset}")
print(f" Final vertices (after offset): {cur_trade_shape}")
vertices.extend(cur_trade_shape)
cur_vertex = tread_index * 4
@@ -1520,9 +1600,19 @@ class Model(bonsai.core.tool.Model):
(cur_vertex + 3, cur_vertex),
)
edges.extend(verts_to_add)
print(f" Edges added: {verts_to_add}")
tread_index += 1
else:
print(f" Skipped (no geometry)")
cur_offset += tread_offset
print(f" New cur_offset: {cur_offset}")
print(f"\nFinal Results:")
print(f" Total vertices: {len(vertices)}")
print(f" Total edges: {len(edges)}")
print(f" Total treads generated: {tread_index}")
print("-" * 80 + "\n")
elif stair_type == "GENERIC":
define_generic_stair_treads()
@@ -1538,6 +1628,11 @@ class Model(bonsai.core.tool.Model):
elif stair_type == "CONCRETE":
define_generic_stair_treads()
assert has_top_nib is not None
assert top_slab_depth is not None
assert base_slab_depth is not None
assert tread_depth is not None
# add the nibs
# basically we define stair bottom line as a line at `tread_depth` distance
# from the tread diagonal line
@@ -1551,15 +1646,20 @@ class Model(bonsai.core.tool.Model):
# s0 is just a sampled point from the bottom line
# we stick to the third point as the first point
# is affected by customized tread run
s0 = V_(custom_first_last_tread_run[0] or tread_run, tread_rise) + td_vector
s0 = V_(first_tread_run, tread_rise) + td_vector
# comes from y = stair_tan * x + b
b = s0.y - stair_tan * s0.x
def get_point_on_2d_line(x=None, y=None):
if y is None:
def get_point_on_2d_line(
x: Union[float, None] = None,
y: Union[float, None] = None,
) -> Vector:
if x is not None and y is None:
y = stair_tan * x + b
elif x is None:
elif x is None and y is not None:
x = (y - b) / stair_tan
else:
assert False
return V_(x, y)
# top nib
@@ -1603,11 +1703,13 @@ class Model(bonsai.core.tool.Model):
else:
raise Exception(f"Unsupported stair type: {stair_type}")
vertices = (v.to_3d().xzy for v in vertices)
vertices = [v.to_3d().xzy for v in vertices]
return (vertices, edges, faces)
@classmethod
def update_simple_openings(cls, element: ifcopenshell.entity_instance) -> None:
from bonsai.bim.module.model.opening import FilledOpeningGenerator
ifc_file = tool.Ifc.get()
fillings = {e: tool.Ifc.get_object(e) for e in tool.Ifc.get_all_element_occurrences(element)}
@@ -2170,6 +2272,8 @@ class Model(bonsai.core.tool.Model):
@classmethod
def add_filled_opening(cls, voided_obj: bpy.types.Object, filling_obj: bpy.types.Object) -> None:
from bonsai.bim.module.model.opening import FilledOpeningGenerator
FilledOpeningGenerator().generate(filling_obj, voided_obj)
@classmethod
+9
View File
@@ -18,6 +18,7 @@
from __future__ import annotations
import os
import shutil
import bpy
import ifcopenshell
import ifcopenshell.api.document
@@ -502,3 +503,11 @@ class Project(bonsai.core.tool.Project):
d2 = v3 - v2
normals.append((v1, d1.cross(d2).normalized()))
return normals
@classmethod
def save_test_project(cls) -> None:
TMP = Path.cwd() / "test/files/temp"
# Clean up previous test project.
if TMP.exists():
shutil.rmtree(TMP)
bpy.ops.bim.save_project(filepath=(TMP / "test_project.ifc").__str__(), should_save_as=True)
+1 -1
View File
@@ -1020,7 +1020,7 @@ class Sequence(bonsai.core.tool.Sequence):
def set_material(name, r, g, b):
material = bpy.data.materials.new(name)
material.use_nodes = True
tool.Style.set_use_nodes(material, True)
tool.Blender.get_material_node(material, "BSDF_PRINCIPLED").inputs[0].default_value = (r, g, b, 1.0)
return material
+4 -1
View File
@@ -175,9 +175,12 @@ class Spatial(bonsai.core.tool.Spatial):
@classmethod
def select_products(cls, products: Iterable[ifcopenshell.entity_instance], unhide: bool = False) -> None:
assert (view_layer := bpy.context.view_layer)
# Update view layer, otherwise `objects` might be missing just created objects.
view_layer.update()
for product in products:
obj = tool.Ifc.get_object(product)
if obj and bpy.context.view_layer.objects.get(obj.name):
if obj and view_layer.objects.get(obj.name):
if unhide:
obj.hide_set(False)
obj.select_set(True)
+15 -1
View File
@@ -63,9 +63,23 @@ class Style(bonsai.core.tool.Style):
def get_material_style_props(cls, material: bpy.types.Material) -> BIMStyleProperties:
return material.BIMStyleProperties
@classmethod
def get_use_nodes(cls, obj: bpy.types.Material) -> bool:
"""Since Blender 5.0 ``use_nodes`` are always ``True`` and considered deprecated."""
if tool.Blender.BLENDER_5:
return True
return obj.use_nodes
@classmethod
def set_use_nodes(cls, obj: bpy.types.Material, use_nodes: bool) -> None:
"""Since Blender 5.0 ``use_nodes`` are always ``True`` and considered deprecated."""
if tool.Blender.BLENDER_5:
return
obj.use_nodes = use_nodes
@classmethod
def can_support_rendering_style(cls, obj: bpy.types.Material) -> bool:
return obj.use_nodes and hasattr(obj.node_tree, "nodes")
return tool.Blender.BLENDER_5 or (obj.use_nodes and hasattr(obj.node_tree, "nodes"))
@classmethod
def delete_object(cls, obj: bpy.types.Material) -> None:
+46 -28
View File
@@ -28,7 +28,8 @@ import bonsai.tool as tool
import ifcopenshell.api.sequence
import ifcopenshell.api.classification
import ifcopenshell.api.cost
from typing import Any, Optional, TYPE_CHECKING
from typing import Any, Optional, TYPE_CHECKING, Union
from mathutils import Color
import time
import socket
import sys
@@ -790,8 +791,8 @@ class Web(bonsai.core.tool.Web):
calculates the mixed colors, and sends the theme data to the Web UI.
"""
def get_color(theme, attribute) -> tuple[Any, ...]:
return tuple(getattr(theme, attribute)[:])
def to_tuple(theme: Union[Color, bpy.types.bpy_prop_array[float]]) -> tuple[float, ...]:
return tuple(theme[:])
def mix_colors(color1, color2):
alpha1 = color1[3] * 255 if len(color1) > 3 else 255
@@ -803,33 +804,50 @@ class Web(bonsai.core.tool.Web):
blue = (color1[2] * (255 - alpha2) + color2[2] * alpha2) / 255
return red, green, blue, alpha / 255
prefs = bpy.context.preferences.themes[0].preferences.space
panel = prefs.panelcolors
view_3d = bpy.context.preferences.themes[0].view_3d
top_bar = bpy.context.preferences.themes[0].topbar.space
info = bpy.context.preferences.themes[0].info
outliner = bpy.context.preferences.themes[0].outliner
ui = bpy.context.preferences.themes[0].user_interface
assert (blender_preferences := bpy.context.preferences)
blender_theme = blender_preferences.themes[0]
prefs = blender_theme.preferences.space
if tool.Blender.BLENDER_5 or TYPE_CHECKING:
theme_ui = blender_theme.user_interface
theme_regions = blender_theme.regions
panel_header, panel_back, panel_sub_back = (
theme_ui.panel_header,
theme_ui.panel_back,
theme_ui.panel_sub_back,
)
tab_back, tab_outline = theme_regions.sidebars.tab_back, theme_ui.wcol_tab.outline
else:
# Removed in Blender 5.0.
panel = prefs.panelcolors
tab_back, tab_outline = prefs.tab_back, prefs.tab_outline
panel_header, panel_back, panel_sub_back = panel.header, panel.back, panel.sub_back
view_3d = blender_theme.view_3d
top_bar = blender_theme.topbar.space
info = blender_theme.info
outliner = blender_theme.outliner
ui = blender_theme.user_interface
theme = {
"window_background": get_color(prefs, "back"),
"text": get_color(prefs, "text"),
"tab_background": get_color(prefs, "tab_back"),
"tab_outline": get_color(prefs, "tab_outline"),
"panel_header": get_color(panel, "header"),
"panel_background": mix_colors(get_color(panel, "back"), get_color(panel, "sub_back")),
"top_bar_header": get_color(top_bar, "header"),
"top_bar_header_text": get_color(top_bar, "header_text"),
"active_object": get_color(view_3d, "object_active"),
"selected_object": get_color(view_3d, "object_selected"),
"info_warning": get_color(info, "info_warning_text"),
"odd_row": get_color(outliner.space, "back"),
"even_row": mix_colors(get_color(outliner.space, "back"), get_color(outliner, "row_alternate")),
"active_highlight": get_color(outliner, "active"),
"active_text_highlight": get_color(outliner, "active_object"),
"button_background": get_color(ui.wcol_tool, "inner"),
"button_test": get_color(ui.wcol_tool, "text"),
"button_border": get_color(ui.wcol_tool, "outline"),
"window_background": prefs.back[:],
"text": to_tuple(prefs.text),
"tab_background": to_tuple(tab_back),
"tab_outline": to_tuple(tab_outline),
"panel_header": to_tuple(panel_header),
"panel_background": mix_colors(to_tuple(panel_back), to_tuple(panel_sub_back)),
"top_bar_header": to_tuple(top_bar.header),
"top_bar_header_text": to_tuple(top_bar.header_text),
"active_object": to_tuple(view_3d.object_active),
"selected_object": to_tuple(view_3d.object_selected),
"info_warning": to_tuple(info.info_warning_text),
"odd_row": to_tuple(outliner.space.back),
"even_row": mix_colors(to_tuple(outliner.space.back), to_tuple(outliner.row_alternate)),
"active_highlight": to_tuple(outliner.active),
"active_text_highlight": to_tuple(outliner.active_object),
"button_background": to_tuple(ui.wcol_tool.inner),
"button_test": to_tuple(ui.wcol_tool.text),
"button_border": to_tuple(ui.wcol_tool.outline),
}
cls.send_webui_data(theme, "theme", "theme_data", use_web_data=False)
+2 -2
View File
@@ -47,11 +47,11 @@ elif sys.platform == "darwin":
elif sys.platform == "linux":
BLENDER_PATH = Path.home() / f".config/blender/{BLENDER_VERSION}"
else:
raise RuntimeError("Unsupported platform")
raise RuntimeError(f"Unsupported platform: {sys.platform}")
BONSAI_PATH_CANDIDATES = (
# Installed from Bonsai Unstalble Repo.
# Installed from Bonsai Unstable Repo.
BLENDER_PATH / r"extensions/raw_githubusercontent_com/bonsai",
# Installed via offline installation.
BLENDER_PATH / r"extensions/user_default/bonsai",
@@ -89,10 +89,17 @@ class SectionCutawayManager:
SectionCutawayManager.offset_previous_nodes(previous_section_compare, offset_x, offset_y)
@staticmethod
def override_materials():
def set_use_nodes(obj: bpy.types.Material, use_nodes: bool) -> None:
if bpy.app.version >= (5, 0, 0):
# Since Blender 5.0 `use_nodes` are always `True` and considered deprecated.
return
obj.use_nodes = use_nodes
@classmethod
def override_materials(cls):
override = SectionCutawayManager.get_section_tree()
for material in bpy.data.materials:
material.use_nodes = True
cls.set_use_nodes(material, True)
if material.node_tree.nodes.get(override.name):
continue
material.blend_method = "HASHED"
@@ -261,12 +268,12 @@ class SectionCutawayManager:
section_compare.name = "Last Section Compare"
@staticmethod
def add_default_material_if_none_exists(context):
@classmethod
def add_default_material_if_none_exists(cls, context):
material = bpy.data.materials.get("Section Override")
if not material:
material = bpy.data.materials.new("Section Override")
material.use_nodes = True
cls.set_use_nodes(material, True)
if context.scene.SectionProperties.should_section_selected_objects:
objects = list(context.selected_objects)
+23 -33
View File
@@ -2,10 +2,10 @@
Feature: bSDD
Scenario: Load bSDD dictionaries
Given an empty Blender session
Given an empty IFC project
And I look at the "buildingSMART Data Dictionary" panel
When I click "Load bSDD Dictionaries"
Then I see "Selected dictionary"
Then I see "Selected Dictionary:"
And I see "LCA" in the "1st" list
Scenario: Add classification from bSDD - add all dictionaries
@@ -15,8 +15,7 @@ Scenario: Add classification from bSDD - add all dictionaries
When I click "is_active" in the row where I see "LCA" in the "1st" list
And I click "is_active" in the row where I see "BonsaiTestDict" in the "1st" list
And I look at the "Classifications" panel
And I set the "classification_source" property to "buildingSMART Data Dictionary"
And I set the "active_dictionary" property to "All Dictionaries"
And I set the "classification_source" property to "All Active bSDDs"
And I click "Add Classification From bSDD"
Then I see "LCA"
And I see "BonsaiTestDict"
@@ -27,8 +26,7 @@ Scenario: Add classification from bSDD - add single dictionary
And I click "Load bSDD Dictionaries"
When I click "is_active" in the row where I see "BonsaiTestDict" in the "1st" list
And I look at the "Classifications" panel
And I set the "classification_source" property to "buildingSMART Data Dictionary"
And I set the "active_dictionary" property to "BonsaiTestDict"
And I set the "classification_source" property to "BonsaiTestDict"
And I click "Add Classification From bSDD"
Then I see "BonsaiTestDict"
And I don't see "LCA"
@@ -41,8 +39,7 @@ Scenario: Search bSDD classifications - search all dictionaries
And I click "is_active" in the row where I see "BonsaiTestDict" in the "1st" list
And I select the object "IfcSite/My Site"
And I look at the "Classification References" panel
And I set the "classification_source" property to "buildingSMART Data Dictionary"
And I set the "active_dictionary" property to "All Dictionaries"
And I set the "classification_source" property to "All Active bSDDs"
And I click "VIEWZOOM"
Then I see "Acidification" in the "1st" list
And I see "BonsaiReferenceA" in the "1st" list
@@ -54,8 +51,7 @@ Scenario: Search bSDD classifications - search single dictionary
And I click "is_active" in the row where I see "BonsaiTestDict" in the "1st" list
And I select the object "IfcSite/My Site"
And I look at the "Classification References" panel
When I set the "classification_source" property to "buildingSMART Data Dictionary"
And I set the "active_dictionary" property to "BonsaiTestDict"
When I set the "classification_source" property to "BonsaiTestDict"
And I click "VIEWZOOM"
Then I see "BonsaiReferenceA" in the "1st" list
And I don't see "Acidification" in the "1st" list
@@ -68,8 +64,7 @@ Scenario: Add classification reference from bSDD - add classification only
And I click "is_active" in the row where I see "BonsaiTestDict" in the "1st" list
And I select the object "IfcSite/My Site"
And I look at the "Classification References" panel
And I set the "classification_source" property to "buildingSMART Data Dictionary"
And I set the "active_dictionary" property to "All Dictionaries"
And I set the "classification_source" property to "All Active bSDDs"
And I click "VIEWZOOM"
Then I see "Acidification" in the "1st" list
When I select the row where I see "Acidification" in the "1st" list
@@ -77,29 +72,24 @@ Scenario: Add classification reference from bSDD - add classification only
Then I see "Acidification"
And I don't see "BonsaiReferenceA"
Scenario: Get bSDD classification properties - get associated properties then add classification
Scenario: Get bSDD classification properties - get and add associated properties
Given an empty IFC project
And I look at the "buildingSMART Data Dictionary" panel
And I click "Load bSDD Dictionaries"
When I click "is_active" in the row where I see "LCA" in the "1st" list
And I select the object "IfcSite/My Site"
And I look at the "Classification References" panel
And I set the "classification_source" property to "buildingSMART Data Dictionary"
And I set the "active_dictionary" property to "All Dictionaries"
And I click "VIEWZOOM"
When I select the row where I see "Acidification" in the "1st" list
And I click "COPY_ID"
Then I see "SizeSet"
And I see "Height"
And I see "Volume"
And I see "No References"
When I set the "Height" property to "42"
And I click "Add Classification Reference"
Then I see "Acidification"
And I don't see "No References"
When I look at the "Property Sets" panel
Then I see "SizeSet"
And I see "Height"
And I see "42"
And I see "Volume"
And I see "0"
And I look at the "Property Sets" panel
And I set the "pset_name" property to "All Data Dictionaries"
And I click "FILE_REFRESH"
When I select the row where I see "Height" in the "2nd" list
And I click "is_selected" in the row where I see "Height" in the "2nd" list
And I click "is_selected" in the row where I see "Volume" in the "2nd" list
Then I look at the "Property Sets" panel
And I set the "height" property to "42"
And I set the "volume" property to "23"
When I click "Add bSDD Properties"
Then I look at the "Property Sets" panel
And I see "height"
And I see "42.0"
And I see "volume"
And I see "23.0"
+21 -17
View File
@@ -9,7 +9,7 @@ Scenario: Duplicate drawing
And I set the "Products" property to "IfcElement"
And I set the "Class" property to "IfcWall"
And I click "Assign IFC Class"
And I save sample test files
And I save IFC project
And I look at the "Drawings" panel
And I click "IMPORT"
And I click "ADD"
@@ -27,7 +27,7 @@ Scenario: Duplicate drawing - without duplicating annotations
And I set the "Products" property to "IfcElement"
And I set the "Class" property to "IfcWall"
And I click "Assign IFC Class"
And I save sample test files
And I save IFC project
And I look at the "Drawings" panel
And I click "IMPORT"
And I click "ADD"
@@ -50,7 +50,7 @@ Scenario: Duplicate drawing - with duplicating annotations
And I set the "Products" property to "IfcElement"
And I set the "Class" property to "IfcWall"
And I click "Assign IFC Class"
And I save sample test files
And I save IFC project
And I look at the "Drawings" panel
And I click "IMPORT"
And I click "ADD"
@@ -73,7 +73,7 @@ Scenario: Create drawing
And I set the "Products" property to "IfcElement"
And I set the "Class" property to "IfcWall"
And I click "Assign IFC Class"
And I save sample test files
And I save IFC project
And I look at the "Drawings" panel
And I click "IMPORT"
And I click "ADD"
@@ -93,7 +93,7 @@ Scenario: Create drawing after deleting a duplicated object
And I set the "Class" property to "IfcWall"
And I click "Assign IFC Class"
And I duplicate the selected objects
And I save sample test files
And I save IFC project
And I look at the "Drawings" panel
And I click "IMPORT"
And I click "ADD"
@@ -112,7 +112,7 @@ Scenario: Activate drawing preserves visibility for non-ifc objects
And I add a cube
And the object "Cube" is visible
And the object "Cube.001" is not visible
And I save sample test files
And I save IFC project
And I look at the "Drawings" panel
And I click "IMPORT"
And I click "ADD"
@@ -126,7 +126,7 @@ Scenario: Activate drawing preserves selection
Given an empty IFC project
And I add a cube
And the object "Cube" is selected
And I save sample test files
And I save IFC project
And I look at the "Drawings" panel
And I click "IMPORT"
And I click "ADD"
@@ -143,12 +143,13 @@ Scenario: Remove drawing
And I set the "Products" property to "IfcElement"
And I set the "Class" property to "IfcWall"
And I click "Assign IFC Class"
And I save sample test files
And I save IFC project
And I look at the "Drawings" panel
And I click "IMPORT"
And I click "ADD"
And I press "bim.toggle_target_view(option="EXPAND", target_view='PLAN_VIEW')"
And I select the "PLAN_VIEW" item in the "BIM_UL_drawinglist" list
And the variable "drawing" is "IfcStore.get_file().by_type('IfcAnnotation')[0].id()"
When I click "VIEW_CAMERA_UNSELECTED" in the row where I see "PLAN_VIEW" in the "1st" list
Then the collection "IfcAnnotation/PLAN_VIEW" exists
When I press "bim.remove_drawing(drawing={drawing})"
@@ -156,13 +157,16 @@ Scenario: Remove drawing
Scenario: Remove drawing - via object deletion
Given an empty IFC project
And I save IFC project
And I add a cube
And the object "Cube" is selected
And I look at the "Class" panel
And I set the "Products" property to "IfcElement"
And I set the "Class" property to "IfcWall"
And I click "Assign IFC Class"
And I press "bim.add_drawing"
And I look at the "Drawings" panel
And I click "IMPORT"
And I click "ADD"
And the variable "drawing" is "IfcStore.get_file().by_type('IfcAnnotation')[0].id()"
And the collection "IfcAnnotation/PLAN_VIEW" exists
And the object "IfcAnnotation/PLAN_VIEW" is selected
@@ -177,7 +181,7 @@ Scenario: Remove drawing - deleting active drawing
And I set the "Products" property to "IfcElement"
And I set the "Class" property to "IfcWall"
And I click "Assign IFC Class"
And I save sample test files
And I save IFC project
And I look at the "Drawings" panel
And I click "IMPORT"
And I click "ADD"
@@ -192,7 +196,7 @@ Scenario: Add annotation - text
Given an empty IFC project
And I add a cube
And the object "Cube" is selected
And I save sample test files
And I save IFC project
And I look at the "Drawings" panel
And I click "IMPORT"
And I click "ADD"
@@ -206,7 +210,7 @@ Scenario: Add annotation - auto create context if it doesn't exist
Given an empty IFC project
And I add a cube
And the object "Cube" is selected
And I save sample test files
And I save IFC project
And I look at the "Geometric Representation Contexts" panel
And I see "Plan"
And I click the "X" after the text "Plan"
@@ -227,7 +231,7 @@ Scenario: Create drawing - using shapely fill mode
And I set the "Products" property to "IfcElement"
And I set the "Class" property to "IfcWall"
And I click "Assign IFC Class"
And I save sample test files
And I save IFC project
And I look at the "Drawings" panel
And I click "IMPORT"
And I set the "location_hint" property to "My Storey"
@@ -243,7 +247,7 @@ Scenario: Create drawing - using shapely fill mode
Scenario: Add sheet
Given an empty IFC project
And I save sample test files
And I save IFC project
And I look at the "Sheets" panel
And I click "IMPORT"
When I click "ADD"
@@ -252,7 +256,7 @@ Scenario: Add sheet
Scenario: Create sheet
Given an empty IFC project
And I save sample test files
And I save IFC project
And I look at the "Sheets" panel
And I click "IMPORT"
And I click "ADD"
@@ -269,7 +273,7 @@ Scenario: Add drawing to sheet
And I set the "Products" property to "IfcElement"
And I set the "Class" property to "IfcWall"
And I click "Assign IFC Class"
And I save sample test files
And I save IFC project
And I look at the "Drawings" panel
And I click "IMPORT"
And I click "ADD"
@@ -294,7 +298,7 @@ Scenario: Create sheet - with a drawing added to it
And I set the "Products" property to "IfcElement"
And I set the "Class" property to "IfcWall"
And I click "Assign IFC Class"
And I save sample test files
And I save IFC project
And I look at the "Drawings" panel
And I click "IMPORT"
And I click "ADD"
+30 -5
View File
@@ -292,14 +292,31 @@ Scenario: Override duplicate move - with active IFC data
And I set the "Class" property to "IfcWall"
And I click "Assign IFC Class"
And the object "IfcWall/Cube" is selected
And additionally the object "IfcBuildingStorey/My Storey" is selected
And I set "scene.BIMSpatialDecompositionProperties.is_locked" to "False"
When I duplicate the selected objects
Then the object "IfcWall/Cube" exists
And the object "IfcWall/Cube" is an "IfcWall"
And the object "IfcWall/Cube.001" exists
And the object "IfcWall/Cube.001" is an "IfcWall"
And the object "IfcWall/Cube.001" has a "Tessellation" representation of "Model/Body/MODEL_VIEW"
Scenario: Override duplicate move - with locked elements
Given an empty IFC project
And I add a cube
And the object "Cube" is selected
And I look at the "Class" panel
And I set the "Products" property to "IfcElement"
And I set the "Class" property to "IfcWall"
And I click "Assign IFC Class"
And the object "IfcWall/Cube" is selected
And I look at the "Spatial Decomposition" panel
And I set the "is_visible" property to "TRUE"
And additionally the object "IfcBuildingStorey/My Storey" is selected
Then I press "bim.override_object_duplicate_move" and expect error "Error: 'IfcBuildingStorey/My Storey' is locked. Unlock it via the Spatial panel in the Project Overview tab."
And the object "IfcWall/Cube" exists
And the object "IfcWall/Cube" is an "IfcWall"
And the object "IfcWall/Cube.001" exists
And the object "IfcWall/Cube.001" is an "IfcWall"
And the object "IfcWall/Cube.001" has a "Tessellation" representation of "Model/Body/MODEL_VIEW"
And the object "IfcBuildingStorey/My Storey.001" does not exist
Scenario: Override duplicate move - with unlocked elements
@@ -310,8 +327,10 @@ Scenario: Override duplicate move - with unlocked elements
And I set the "Products" property to "IfcElement"
And I set the "Class" property to "IfcWall"
And I click "Assign IFC Class"
And I look at the "Spatial Decomposition" panel
And I set the "is_visible" property to "TRUE"
And I set the "is_locked" property to "FALSE"
And the object "IfcBuildingStorey/My Storey" is selected
And I set "scene.BIMSpatialDecompositionProperties.is_locked" to "False"
When I duplicate the selected objects
Then the object "IfcBuildingStorey/My Storey.001" exists
And the object "IfcBuildingStorey/My Storey.001" is an "IfcBuildingStorey"
@@ -507,6 +526,8 @@ Scenario: Override duplicate move linked - with active IFC data
And I set the "Products" property to "IfcElement"
And I set the "Class" property to "IfcWall"
And I click "Assign IFC Class"
And I look at the "Spatial Decomposition" panel
And I set the "is_visible" property to "TRUE"
And the object "IfcWall/Cube" is selected
And additionally the object "IfcBuildingStorey/My Storey" is selected
When I press "object.duplicate_move_linked"
@@ -538,6 +559,9 @@ Scenario: Override paste buffer - with active IFC data
And I set the "Class" property to "IfcWall"
And I click "Assign IFC Class"
And the object "IfcWall/Cube" is selected
And I look at the "Spatial Decomposition" panel
And I set the "is_visible" property to "TRUE"
And I set the "is_locked" property to "FALSE"
And additionally the object "IfcBuildingStorey/My Storey" is selected
When I press "view3d.copybuffer"
# IFC elements unlinked on paste for safety
@@ -555,8 +579,9 @@ Scenario: Duplicate linked aggregate
When I duplicate linked aggregate the selected objects
Then the object "IfcWall/Wall_01.001" exists
And the object "IfcWall/Wall_02.001" exists
And the object "IfcElementAssembly/Assembly_01" exists
Then the object "IfcElementAssembly/Assembly" and "IfcElementAssembly/Assembly_01" belong to the same Linked Aggregate Group
# msgbus updates don't happen in background mode, so obj is not renamed to "IfcElementAssembly/Assembly_01"
And the object "Assembly_01" exists
Then the object "IfcElementAssembly/Assembly" and "Assembly_01" belong to the same Linked Aggregate Group
Scenario: Refresh linked aggregate
Given I load the IFC test file "/test/files/linked-aggregates.ifc"
+4 -2
View File
@@ -872,8 +872,10 @@ Scenario: Export IFC - with basic contents and saving as a relative path
Scenario: Export IFC - with deleted objects synchronised
Given an empty IFC project
When the object "IfcBuildingStorey/My Storey" is selected
And I set "scene.BIMSpatialDecompositionProperties.is_locked" to "False"
And I look at the "Spatial Decomposition" panel
When I set the "is_visible" property to "TRUE"
And the object "IfcBuildingStorey/My Storey" is selected
And I set the "is_locked" property to "FALSE"
And I delete the selected objects
And I press "bim.save_project(filepath='{cwd}/test/files/temp/export.ifc')"
And an empty Blender session is started
+5 -1
View File
@@ -167,14 +167,18 @@ Scenario: Copy a wall
Scenario: Copy a storey - when locked
Given an empty IFC project
And I look at the "Spatial Decomposition" panel
And I set the "is_visible" property to "TRUE"
And the object "IfcBuildingStorey/My Storey" is selected
Then I expect an error "Error: 'IfcBuildingStorey/My Storey' is locked. Unlock it via the Spatial panel in the Project Overview tab." when "i_duplicate_the_selected_objects()"
Then the object "IfcBuildingStorey/My Storey.001" does not exist
Scenario: Copy a storey - when unlocked
Given an empty IFC project
And I look at the "Spatial Decomposition" panel
And I set the "is_visible" property to "TRUE"
And the object "IfcBuildingStorey/My Storey" is selected
And I set "scene.BIMSpatialDecompositionProperties.is_locked" to "False"
And I set the "is_locked" property to "FALSE"
When I duplicate the selected objects
Then the object "IfcBuildingStorey/My Storey" and "IfcBuildingStorey/My Storey.001" are different elements
And the object "IfcBuildingStorey/My Storey" is in the collection "IfcBuildingStorey/My Storey"
+6 -4
View File
@@ -15,6 +15,7 @@ Scenario: Set default container
Scenario: Select container
Given an empty IFC project
And I look at the "Spatial Decomposition" panel
And I set the "is_visible" property to "TRUE"
When I select the row where I see "My Building" in the "1st" list
And I click "OBJECT_DATA"
Then the object "IfcBuilding/My Building" is selected
@@ -81,10 +82,10 @@ Scenario: Copy to container
And I set the "Products" property to "IfcElement"
And I set the "Class" property to "IfcWall"
And I click "Assign IFC Class"
And the object "IfcSite/My Site" is selected
And additionally the object "IfcWall/Cube" is selected
And I press "bim.enable_editing_container"
When I press "bim.copy_to_container"
And I look at the "Spatial Decomposition" panel
When I select the "My Site" item in the "BIM_UL_containers_manager" list
And the object "IfcWall/Cube" is selected
When I click "MOD_DISPLACE"
Then the object "IfcWall/Cube.001" is in the collection "IfcSite/My Site"
Scenario: Reference structure
@@ -222,6 +223,7 @@ Scenario: Set element visibility - Hide spatial container
Scenario: Select spatial container
Given an empty IFC project
And I look at the "Spatial Decomposition" panel
And I set the "is_visible" property to "TRUE"
When I select the "My Site" item in the "BIM_UL_containers_manager" list
And I click "OBJECT_DATA"
Then the object "IfcSite/My Site" is selected
+121 -54
View File
@@ -16,9 +16,13 @@
# 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 os
import types
import bpy
import pytest
import shutil
import pprint
import traceback
import webbrowser
import numpy as np
@@ -54,33 +58,47 @@ webbrowser.open = lambda x: True
tool.Bsdd.client = test.bim.stub.bSDDClientStub()
PYTEST_BLENDER_NO_BACKGROUND = bool(os.getenv("PYTEST_BLENDER_NO_BACKGROUND"))
"""Option to run Blender not in background.
Can be useful for debugging, but has caveats - can't use ``wm.read_homefile``
as resets the ``bpy.context`` and some it's members become `None`.
"""
TMP = Path(f"{variables['cwd']}/test/files/temp")
class PanelSpy:
def __init__(self, panel: type[bpy.types.Panel]):
def __init__(self, blender_panel: type[bpy.types.Panel]):
self.is_spy_dirty = True
self.panel = panel
self.blender_panel = blender_panel
def refresh_spy(self):
def refresh_spy(self) -> None:
if self.is_spy_dirty:
self.is_spy_dirty = False
self.spied_attr: Union[str, None] = None
self.spied_labels: list[str] = []
self.spied_props: list[dict[str, Any]] = []
self.spied_operators: list[dict[str, Any]] = []
self.spied_lists: list[dict[str, Any]] = []
self.panel.draw(self, bpy.context)
self.spied_lists: list[TemplateListSpy] = []
if hasattr(self.blender_panel, "draw_header"):
self.blender_panel.draw_header(self, bpy.context)
self.blender_panel.draw(self, bpy.context)
def __getattr__(self, attr):
def __getattr__(self, attr: str) -> PanelSpy | Any:
self.spied_attr = attr
if annotation := self.panel.__annotations__.get(attr, None):
if annotation := self.blender_panel.__annotations__.get(attr, None):
return annotation.keywords.get("default", None) # An operator property
if attr == "layout":
return self
if hasattr(self.panel, attr) and not callable(getattr(self.panel, attr)):
return getattr(self.panel, attr)
sentinel = object()
attr_value = getattr(self.blender_panel, attr, sentinel)
# Don't use `callable`, because we might have `Data` classes as panel attributes.
if attr_value is not sentinel and not isinstance(attr_value, types.FunctionType):
return attr_value
return self
def __call__(self, *args, **kwargs):
def __call__(self, *args: Any, **kwargs: Any) -> PanelSpy | TemplateListSpy | OperatorSpy | Any:
if self.spied_attr in ("row", "column", "box", "separator", "menu", "operator_menu_enum", "split"):
return self
elif self.spied_attr == "template_list":
@@ -149,15 +167,20 @@ class PanelSpy:
spied_operator = {"operator": operator, "icon": icon, "text": text, "kwargs": {}, "after": after}
self.spied_operators.append(spied_operator)
return OperatorSpy(spied_operator)
elif self.spied_attr == "panel":
default_closed = kwargs.get("default_closed", False)
header = self
panel = None if default_closed else self
return header, panel
else:
getattr(self.panel, self.spied_attr)(self, *args, **kwargs)
return getattr(self.blender_panel, self.spied_attr)(self, *args, **kwargs)
class OperatorSpy:
def __init__(self, spied_data):
def __init__(self, spied_data: dict[str, Any]):
self.spied_data = spied_data
def __setattr__(self, name, value):
def __setattr__(self, name: str, value: Any) -> None:
if name == "spied_data":
# Allow direct setting of spied_data only during initialization
super().__setattr__(name, value)
@@ -166,17 +189,19 @@ class OperatorSpy:
class TemplateListSpy(PanelSpy):
def __init__(self, template_list: type[bpy.types.UIList], spied_data: dict) -> None:
items: bpy.types.bpy_prop_collection_idprop[bpy.types.PropertyGroup]
active_index: int
active_item: bpy.types.PropertyGroup | None
blender_panel: type[bpy.types.UIList]
def __init__(self, template_list: type[bpy.types.UIList], spied_data: dict[str, Any]):
self.spied_data = spied_data
self.items = getattr(self.spied_data["dataptr"], self.spied_data["propname"])
self.active_index = getattr(self.spied_data["active_dataptr"], self.spied_data["active_propname"])
try:
self.active_item = self.items[self.active_index]
except:
self.active_item = None
self.panel = template_list
self.active_item = tool.Blender.get_active_uilist_element(self.items, self.active_index)
self.blender_panel = template_list
self.rows = []
self.rows: list[TemplateListItemSpy] = []
for item in self.items:
self.rows.append(TemplateListItemSpy(self, item))
@@ -188,16 +213,19 @@ class TemplateListSpy(PanelSpy):
except:
assert False, f"Could not set active index {index}"
def __repr__(self) -> str:
return f"TemplateListSpy({self.spied_data['listtype_name']}, {len(self.items)} items, active_index={self.active_index})"
class TemplateListItemSpy(PanelSpy):
def __init__(self, parent: TemplateListSpy, item):
self.panel = parent.panel
self.blender_panel = blender_panel = parent.blender_panel
self.spied_attr: Union[str, None] = None
self.spied_labels: list[str] = []
self.spied_props: list[dict[str, Any]] = []
self.spied_operators: list[dict[str, Any]] = []
if len(signature(parent.panel.draw_item).parameters) == 8:
parent.panel.draw_item(
if len(signature(blender_panel.draw_item).parameters) == 8:
blender_panel.draw_item(
self,
bpy.context,
self,
@@ -208,7 +236,7 @@ class TemplateListItemSpy(PanelSpy):
parent.spied_data["active_propname"],
)
else:
parent.panel.draw_item(
blender_panel.draw_item(
self,
bpy.context,
self,
@@ -222,7 +250,8 @@ class TemplateListItemSpy(PanelSpy):
)
ui_name_cache = {}
ui_name_cache: dict[str, str] = {}
"""Mapping of UI panel names to their bl_idnames (e.g. `xxx` in `bpy.types.xxx`)."""
panel_spy: Union[PanelSpy, None] = None
@@ -271,7 +300,8 @@ def an_untestable_scenario():
@when("an empty Blender session is started")
def an_empty_blender_session():
IfcStore.purge()
bpy.ops.wm.read_homefile(app_template="")
if not PYTEST_BLENDER_NO_BACKGROUND:
bpy.ops.wm.read_homefile(app_template="")
if len(bpy.data.objects) > 0:
bpy.data.batch_remove(bpy.data.objects)
bpy.ops.outliner.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True)
@@ -300,6 +330,17 @@ def an_empty_ifc_2x3_project():
bpy.ops.bim.create_project()
@given(parsers.parse("I save IFC project"))
@when(parsers.parse("I save IFC project"))
@then(parsers.parse("I save IFC project"))
def saving_ifc_project() -> None:
# Remove old temp files to avoid conflicts with non-updated assets.
if TMP.exists():
shutil.rmtree(TMP)
filepath = TMP / "test_project.ifc"
bpy.ops.bim.save_project(filepath=filepath.__str__(), should_save_as=True)
@given("the Brickschema is stubbed")
def the_brickschema_is_stubbed():
# This makes things run faster since we don't need to load the entire brick schema
@@ -415,18 +456,21 @@ def i_dont_see_text_in_the_nth_list(text, nth):
@given(parsers.parse('I click "{button}" in the row where I see "{text}" in the "{nth}" list'))
@when(parsers.parse('I click "{button}" in the row where I see "{text}" in the "{nth}" list'))
@then(parsers.parse('I click "{button}" in the row where I see "{text}" in the "{nth}" list'))
def i_click_button_in_the_row_where_i_see_text_in_the_nth_list(button, text, nth):
def i_click_button_in_the_row_where_i_see_text_in_the_nth_list(button: str, text: str, nth: str) -> None:
"""
:param button: The text or icon of the button to click.
:param button: The text or icon or name of the button/prop to click.
:param text: The text to search for in the row.
:param nth: E.g. "1st", "2nd", "5th".
"""
assert panel_spy
panel_spy.refresh_spy()
nth = int("".join([c for c in nth if c.isnumeric()]))
if len(panel_spy.spied_lists) < nth:
assert False, f"{nth} list does not exist. Actual number of lists: {len(panel_spy.spied_lists)}"
nth_ = int("".join([c for c in nth if c.isnumeric()]))
if len(panel_spy.spied_lists) < nth_:
assert False, f"{nth_} list does not exist. Actual number of lists: {len(panel_spy.spied_lists)}"
debug = []
for i, template_list in enumerate(panel_spy.spied_lists):
if i + 1 != nth:
if i + 1 != nth_:
continue
for row in template_list.rows:
is_row = False
@@ -440,7 +484,8 @@ def i_click_button_in_the_row_where_i_see_text_in_the_nth_list(button, text, nth
is_row = True
if is_row:
_i_click_button_on_panel(button, row)
return True
# Success.
return
debug = "\n".join(debug)
assert False, f"Could not see '{text}' in any list. We saw:\n{debug}"
@@ -502,7 +547,7 @@ def i_see_the_prop_property(prop):
panel_spy.refresh_spy()
assert [
p for p in panel_spy.spied_props if prop in (p["name"], p["text"], p["icon"])
], f"Property {prop} not found in {panel_spy.spied_props}"
], f"Property {prop} not found in {pprint.pformat(panel_spy.spied_props)}"
@given(parsers.parse('I don\'t see the "{prop}" property'))
@@ -513,7 +558,7 @@ def i_dont_see_the_prop_property(prop):
panel_spy.refresh_spy()
assert not [
p for p in panel_spy.spied_props if prop in (p["name"], p["text"], p["icon"])
], f"Property {prop} not found in {panel_spy.spied_props}"
], f"Property {prop} not found in {pprint.pformat(panel_spy.spied_props)}"
@given(parsers.parse('I see the "{prop}" property is "{value}"'))
@@ -528,7 +573,7 @@ def i_see_the_prop_property_is_value(prop, value):
spied_prop["value"] == value
), f"Property {prop} value is not {value} - it is actually {spied_prop['value']}"
return
assert False, f"Property {prop} not found in {panel_spy.spied_props}"
assert False, f"Property {prop} not found in {pprint.pformat(panel_spy.spied_props)}"
@given(parsers.parse('I set the "{prop}" property to "{value}"'))
@@ -538,12 +583,14 @@ def i_set_the_prop_property_to_value(prop: str, value: str):
"""
:param prop: Could be either property name, property text, property icon
or property index (e.g. "1st", "2nd", "5th").
:param value:
For boolean propeties - 'TRUE' or 'FALSE'.
"""
value = value.strip()
assert panel_spy
panel_spy.refresh_spy()
is_nth = False
if prop[0].isnumeric() and (prop.endswith("st") or prop.endswith("nd") or prop.endswith("th")):
if prop[0].isnumeric() and prop.endswith(("st", "nd", "th")):
is_nth = True
for nth, spied_prop in enumerate(panel_spy.spied_props):
if is_nth and nth != int(prop[:-2]) - 1:
@@ -555,6 +602,8 @@ def i_set_the_prop_property_to_value(prop: str, value: str):
setattr(spied_prop["props"], spied_prop["name"], True)
elif value == "FALSE":
setattr(spied_prop["props"], spied_prop["name"], False)
else:
assert False, f"Unexpected value for BOOLEAN property: '{value}'. Allowed values: TRUE, FALSE."
elif spied_prop["prop_type"] == "FLOAT":
setattr(spied_prop["props"], spied_prop["name"], float(value))
elif spied_prop["prop_type"] == "INT":
@@ -590,12 +639,16 @@ def the_name_list_has_total_items(name, total):
@given(parsers.parse('I select the "{item_name}" item in the "{list_name}" list'))
@when(parsers.parse('I select the "{item_name}" item in the "{list_name}" list'))
@then(parsers.parse('I can select the "{item_name}" item in the "{list_name}" list'))
def i_select_the_item_name_item_in_the_list_name_list(item_name, list_name):
def i_select_the_item_name_item_in_the_list_name_list(item_name: str, list_name: str) -> None:
"""
:param item_name: The ``.name`` of the item to select.
:param list_name: List type name, e.g. ``BIM_UL_containers_manager``.
"""
assert panel_spy
panel_spy.refresh_spy()
for template_list in panel_spy.spied_lists:
if list_name == template_list.spied_data["listtype_name"]:
item_names = []
item_names: list[str] = []
for i, item in enumerate(template_list.items):
item_names.append(item.name)
if item.name == item_name:
@@ -739,9 +792,11 @@ def i_press_operator_and_expect_error(operator, error_msg):
assert False, f"Operator bpy.ops.{operator} ran without exception '{error_msg}'"
# TODO: We should rename 'I press' to 'I run' or 'I execute'
# to avoid confusion with 'I click'.
@given(parsers.parse('I press "{operator}"'))
@when(parsers.parse('I press "{operator}"'))
def i_press_operator(operator):
def i_press_operator(operator: str) -> types.NoneType:
operator = replace_variables(operator)
try:
if "(" in operator:
@@ -753,7 +808,7 @@ def i_press_operator(operator):
assert False, f"Failed to run operator bpy.ops.{operator} because of {e}"
def _i_click_button_on_panel(button, panel_spy):
def _i_click_button_on_panel(button: str, panel_spy: PanelSpy) -> None:
for spied_operator in panel_spy.spied_operators:
if spied_operator["text"] == button or spied_operator["icon"] == button:
spied_operator["operator"]("INVOKE_DEFAULT", **spied_operator["kwargs"])
@@ -766,11 +821,12 @@ def _i_click_button_on_panel(button, panel_spy):
setattr(spied_prop["props"], spied_prop["name"], not bool(val))
panel_spy.is_spy_dirty = True
return
if button == "OK" and panel_spy.panel.bl_rna.base.name == "Operator":
if button == "OK" and panel_spy.blender_panel.bl_rna.base.name == "Operator":
# Clicked confirm on an operator's draw dialog
return i_press_operator(panel_spy.panel.bl_idname)
return i_press_operator(panel_spy.blender_panel.bl_idname)
debug = "\n".join([f"{i} {v}" for i, v in enumerate(panel_spy.spied_operators)])
debug += f"\nHere is the text we see:\n{panel_spy.spied_labels}\n... and props:\n {panel_spy.spied_props}"
debug_props = pprint.pformat(panel_spy.spied_props)
debug += f"\nHere is the text we see:\n{panel_spy.spied_labels}\n... and props:\n {debug_props}"
assert False, f"Could not find {button}:\n{debug}"
@@ -996,11 +1052,9 @@ def the_object_name_exists(name: str) -> bpy.types.Object:
@then(parsers.parse('the object "{name}" does not exist'))
def the_object_name_does_not_exist(name) -> bpy.types.Object:
def the_object_name_does_not_exist(name) -> None:
obj = bpy.data.objects.get(name)
if not obj:
assert True, f'The object "{name}" exists'
return obj
assert obj is None, f'The object "{name}" exists'
@given(parsers.parse('the collection "{name}" exists'))
@@ -1729,19 +1783,19 @@ def i_fail():
@given(parsers.parse("I save sample test files"))
@when(parsers.parse("I save sample test files"))
@then(parsers.parse("I save sample test files"))
def saving_sample_test_files(and_open_in_blender=None):
filepath = f"{variables['cwd']}/test/files/temp/sample_test_file"
def saving_sample_test_files() -> None:
filepath = TMP / "sample_test_file"
print(f"Saved to {filepath}")
bpy.ops.bim.save_project(filepath=f"{filepath}.ifc", should_save_as=True)
bpy.ops.wm.save_as_mainfile(filepath=f"{filepath}.blend")
bpy.ops.bim.save_project(filepath=filepath.with_suffix(".ifc").__str__(), should_save_as=True)
bpy.ops.wm.save_as_mainfile(filepath=filepath.with_suffix(".blend").__str__())
@given(parsers.parse("I load test blend file"))
@when(parsers.parse("I load test blend file"))
@then(parsers.parse("I load test blend file"))
def opening_sample_test_files_in_blender():
filepath = f"{variables['cwd']}/test/files/temp/sample_test_file.blend"
bpy.ops.wm.open_mainfile(filepath=filepath, display_file_selector=False)
filepath = TMP / "sample_test_file.blend"
bpy.ops.wm.open_mainfile(filepath=filepath.__str__(), display_file_selector=False)
# TODO: merge to single fixture with `saving_sample_test_files`; add "and wait"
@@ -1763,3 +1817,16 @@ def run_pdb():
import pdb
pdb.set_trace()
@given(parsers.parse("I start debugpy"))
@when(parsers.parse("I start debugpy"))
@then(parsers.parse("I start debugpy"))
def run_debugpy() -> types.NoneType:
import debugpy
debugpy.listen(5678)
debugpy.wait_for_client()
# Set a breakpoint here.
print("debugpy connected")
+7
View File
@@ -107,6 +107,13 @@ def georeference():
prophet.verify()
@pytest.fixture
def group():
prophet = Prophecy(bonsai.core.tool.Group)
yield prophet
prophet.verify()
@pytest.fixture
def library():
prophet = Prophecy(bonsai.core.tool.Library)
+1
View File
@@ -398,6 +398,7 @@ class TestDuplicateDrawing:
drawing.get_name("drawing").should_be_called().will_return("name")
drawing.ensure_unique_drawing_name("name").should_be_called().will_return("unique_name")
ifc.run("root.copy_class", product="drawing").should_be_called().will_return("new_drawing")
drawing.clear_annotation_relationships("new_drawing").should_be_called()
drawing.copy_representation("drawing", "new_drawing").should_be_called()
drawing.set_name("new_drawing", "unique_name").should_be_called()
drawing.get_drawing_group("new_drawing").should_be_called().will_return("group")
+13 -5
View File
@@ -18,7 +18,7 @@
import bonsai.core.system as subject
from test.core.bootstrap import ifc, system, spatial
from test.core.bootstrap import ifc, system, spatial, group
class TestLoadSystems:
@@ -37,10 +37,17 @@ class TestDisableSystemEditingUI:
class TestAddSystem:
def test_run(self, ifc, system):
def test_run(self, ifc, group, system):
ifc.run("system.add_system", ifc_class="ifc_class").should_be_called()
system.import_systems().should_be_called()
subject.add_system(ifc, system, ifc_class="ifc_class")
subject.add_system(ifc, group, system, ifc_class="ifc_class", parent_system=None)
def test_run_with_parent_system(self, ifc, group, system):
ifc.run("system.add_system", ifc_class="ifc_class").should_be_called().will_return("new_system")
ifc.run("group.assign_group", products=["new_system"], group="parent_system").should_be_called()
group.toggle_group("parent_system", "IfcSystem", "EXPAND").should_be_called()
system.import_systems().should_be_called()
subject.add_system(ifc, group, system, ifc_class="ifc_class", parent_system="parent_system")
class TestEditSystem:
@@ -53,10 +60,11 @@ class TestEditSystem:
class TestRemoveSystem:
def test_run(self, ifc, system):
def test_run(self, ifc, group, system):
ifc.run("system.remove_system", system="system").should_be_called()
system.import_systems().should_be_called()
subject.remove_system(ifc, system, system="system")
group.update_uilist_index("IfcSystem").should_be_called()
subject.remove_system(ifc, group, system, system="system")
class TestEnableEditingSystem:
+2 -2
View File
@@ -37,7 +37,7 @@ class TestImplementsTool(NewFile):
class TestCopyNodeGraph(NewFile):
def test_run(self):
material_to = bpy.data.materials.new("material_to")
material_to.use_nodes = True
tool.Style.set_use_nodes(material_to, True)
assert material_to.node_tree
material_to_nodes = material_to.node_tree.nodes
assert len(material_to_nodes) == 2
@@ -46,7 +46,7 @@ class TestCopyNodeGraph(NewFile):
assert len(material_to_nodes) == 0
material_from = bpy.data.materials.new("material_from")
material_from.use_nodes = True
tool.Style.set_use_nodes(material_from, True)
subject.copy_node_graph(material_to, material_from)
assert len(material_to_nodes) == 2
+8 -9
View File
@@ -155,6 +155,9 @@ class TestDisableEditingText(NewFile):
props = tool.Drawing.get_text_props(obj)
props.is_editing = True
subject.disable_editing_text(obj)
# Props might get invalidated, since no properties set.
props = tool.Drawing.get_text_props(obj)
assert props.is_editing == False
@@ -812,8 +815,9 @@ class TestDrawingMaintainingSheetPosition(NewFile):
def test_run(self):
props = tool.Drawing.get_document_props()
bpy.ops.bim.create_project()
tool.Project.save_test_project()
ifc = tool.Ifc.get()
sheet_path = Path.cwd() / "layouts" / "A01 - UNTITLED.svg"
sheet_path = Path(tool.Ifc.get_path()).parent / "layouts" / "A01 - UNTITLED.svg"
bpy.ops.mesh.primitive_cube_add(size=10, location=(0, 0, 4))
obj = bpy.data.objects["Cube"]
@@ -867,6 +871,7 @@ class TestDrawingMaintainingSheetPosition(NewFile):
class TestDrawingStyles(NewFile):
def setup_project_with_drawing(self):
bpy.ops.bim.create_project()
tool.Project.save_test_project()
bpy.ops.bim.load_drawings()
bpy.ops.bim.add_drawing()
ifc = tool.Ifc.get()
@@ -878,19 +883,13 @@ class TestDrawingStyles(NewFile):
props = tool.Drawing.get_document_props()
self.drawing_styles = props.drawing_styles
def test_drawing_styles_not_loaded_if_underlay_is_inactive(self):
def test_drawing_styles_are_loaded_even_if_underlay_is_inactive(self):
self.setup_project_with_drawing()
assert len(self.drawing_styles) == 0
def test_drawing_styles_loaded_on_underlay_enabled(self):
self.setup_project_with_drawing()
assert (scene := bpy.context.scene) and (camera := scene.camera)
props = tool.Drawing.get_camera_props(camera)
props.has_underlay = True
assert len(self.drawing_styles) == 3
def test_drawing_styles_reload(self):
self.setup_project_with_drawing()
self.drawing_styles.clear()
bpy.ops.bim.reload_drawing_styles()
assert len(self.drawing_styles) == 3
+5 -5
View File
@@ -321,8 +321,8 @@ class TestRecordObjectPosition(NewFile):
obj = bpy.data.objects.new("Object", None)
props = tool.Blender.get_object_bim_props(obj)
subject.record_object_position(obj)
assert props.location_checksum == repr(np.array(obj.matrix_world.translation).tobytes())
assert props.rotation_checksum == repr(np.array(obj.matrix_world.to_3x3()).tobytes())
assert props.location_checksum == repr(np.array(obj.matrix_world.translation, dtype=np.float32).tobytes())
assert props.rotation_checksum == repr(np.array(obj.matrix_world.to_3x3(), dtype=np.float32).tobytes())
class TestRemoveConnection(NewFile):
@@ -488,7 +488,7 @@ class TestShouldGenerateUVs(NewFile):
obj = bpy.data.objects.new("Object", bpy.data.meshes.new("Mesh"))
material = bpy.data.materials.new("Material")
obj.data.materials.append(material)
material.use_nodes = False
tool.Style.set_use_nodes(material, False)
assert subject.should_generate_uvs(obj) is False
def test_needs_texture_coordinates_with_a_uv_output(self):
@@ -497,7 +497,7 @@ class TestShouldGenerateUVs(NewFile):
obj = bpy.data.objects.new("Object", bpy.data.meshes.new("Mesh"))
material = bpy.data.materials.new("Material")
obj.data.materials.append(material)
material.use_nodes = True
tool.Style.set_use_nodes(material, True)
bsdf = tool.Blender.get_material_node(material, "BSDF_PRINCIPLED")
node = material.node_tree.nodes.new(type="ShaderNodeTexImage")
@@ -513,7 +513,7 @@ class TestShouldGenerateUVs(NewFile):
obj = bpy.data.objects.new("Object", bpy.data.meshes.new("Mesh"))
material = bpy.data.materials.new("Material")
obj.data.materials.append(material)
material.use_nodes = True
tool.Style.set_use_nodes(material, True)
bsdf = tool.Blender.get_material_node(material, "BSDF_PRINCIPLED")
node = material.node_tree.nodes.new(type="ShaderNodeTexImage")
+52 -91
View File
@@ -30,6 +30,7 @@ import bonsai.core.tool
import bonsai.tool as tool
import numpy as np
import json
from typing import Any
from test.bim.bootstrap import NewFile
from bonsai.tool.model import Model as subject
from ifcopenshell.util.shape_builder import V, ShapeBuilder
@@ -159,7 +160,7 @@ class TestStairCalculatedParams(NewFile):
"number_of_treads": 3,
"height": 1.0,
"tread_run": 0.3,
"custom_first_last_tread_run": (0.0, 0.0),
"custom_first_last_tread_run": (None, None),
"nosing_length": 0.0,
}
calculated_data_base = {
@@ -179,14 +180,14 @@ class TestStairCalculatedParams(NewFile):
# zero-width first tread
pset_data = pset_data_base.copy()
calculated_data = calculated_data_base.copy()
pset_data["custom_first_last_tread_run"] = (0.0, 0.0)
pset_data["custom_first_last_tread_run"] = (0.0, None)
calculated_data["Length"] = 0.9 # Only 3 treads at 0.3 each
self.compare_data(pset_data, calculated_data)
# zero-width last tread
pset_data = pset_data_base.copy()
calculated_data = calculated_data_base.copy()
pset_data["custom_first_last_tread_run"] = (0.3, 0.0)
pset_data["custom_first_last_tread_run"] = (None, 0.0)
calculated_data["Length"] = 0.9 # Only 3 treads at 0.3 each
self.compare_data(pset_data, calculated_data)
@@ -222,18 +223,20 @@ class TestGenerateStair2DProfile(NewFile):
for vert, vert_gen in zip(verts, verts_gen, strict=True):
assert np.allclose(vert, V(vert_gen), atol=0.01)
CONCRETE_STAIR_KWARGS: dict[str, Any] = {
"base_slab_depth": 0.25,
"has_top_nib": False,
"height": 1.0,
"number_of_treads": 3,
"stair_type": "CONCRETE",
"top_slab_depth": 0.25,
"tread_depth": 0.25,
"tread_run": 0.3,
"width": 1.2,
}
def test_create_concrete_stair(self):
kwargs = {
"base_slab_depth": 0.25,
"has_top_nib": False,
"height": 1.0,
"number_of_treads": 3,
"stair_type": "CONCRETE",
"top_slab_depth": 0.25,
"tread_depth": 0.25,
"tread_run": 0.3,
"width": 1.2,
}
kwargs = self.CONCRETE_STAIR_KWARGS.copy()
verts_data = (
V(0.0, 0, 0.0),
V(0.0, 0, 0.25),
@@ -269,17 +272,8 @@ class TestGenerateStair2DProfile(NewFile):
self.compare_data(generated_profile, expected_profile)
def test_create_concrete_stair_nib(self):
kwargs = {
"base_slab_depth": 0.25,
"has_top_nib": True,
"height": 1.0,
"number_of_treads": 3,
"stair_type": "CONCRETE",
"top_slab_depth": 0.25,
"tread_depth": 0.25,
"tread_run": 0.3,
"width": 1.2,
}
kwargs = self.CONCRETE_STAIR_KWARGS.copy()
kwargs["has_top_nib"] = True
verts_data = (
V(0.0, 0, 0.0),
V(0.0, 0, 0.25),
@@ -318,19 +312,8 @@ class TestGenerateStair2DProfile(NewFile):
self.compare_data(generated_profile, expected_profile)
def test_create_concrete_stair_zero_width_first_tread(self):
"""Test concrete stair with zero-width first tread"""
kwargs = {
"base_slab_depth": 0.25,
"has_top_nib": False,
"height": 1.0,
"number_of_treads": 3,
"stair_type": "CONCRETE",
"top_slab_depth": 0.25,
"tread_depth": 0.25,
"tread_run": 0.3,
"width": 1.2,
"custom_first_last_tread_run": (0.0, 0.0),
}
kwargs = self.CONCRETE_STAIR_KWARGS.copy()
kwargs["custom_first_last_tread_run"] = (0.0, None)
verts_data = (
V(0.0, 0, 0.0),
# First tread skipped - goes straight to second tread
@@ -340,8 +323,8 @@ class TestGenerateStair2DProfile(NewFile):
V(0.6, 0, 0.75),
V(0.6, 0, 1.0),
V(0.9, 0, 1.0),
V(0.9, 0, 0.67457),
V(0.0, 0, -0.25),
V(0.9, 0, 0.6745729),
V(0.0, 0, -0.0754271),
)
edges_data = (
(0, 1),
@@ -361,19 +344,8 @@ class TestGenerateStair2DProfile(NewFile):
self.compare_data(generated_profile, expected_profile)
def test_create_concrete_stair_zero_width_last_tread(self):
"""Test concrete stair with zero-width last tread"""
kwargs = {
"base_slab_depth": 0.25,
"has_top_nib": False,
"height": 1.0,
"number_of_treads": 3,
"stair_type": "CONCRETE",
"top_slab_depth": 0.25,
"tread_depth": 0.25,
"tread_run": 0.3,
"width": 1.2,
"custom_first_last_tread_run": (0.0, 0.0),
}
kwargs = self.CONCRETE_STAIR_KWARGS.copy()
kwargs["custom_first_last_tread_run"] = (None, 0.0)
verts_data = (
V(0.0, 0, 0.0),
V(0.0, 0, 0.25),
@@ -383,7 +355,7 @@ class TestGenerateStair2DProfile(NewFile):
V(0.6, 0, 0.75),
V(0.9, 0, 0.75),
# Last tread skipped
V(0.9, 0, 0.67457),
V(0.9, 0, 0.42457),
V(0.1, 0, -0.25),
V(0.0, 0, -0.25),
)
@@ -405,15 +377,17 @@ class TestGenerateStair2DProfile(NewFile):
generated_profile = subject.generate_stair_2d_profile(**kwargs)
self.compare_data(generated_profile, expected_profile)
WOOD_STEEL_STAIR_KWARGS: dict[str, Any] = {
"height": 1.0,
"number_of_treads": 3,
"stair_type": "WOOD/STEEL",
"tread_depth": 0.25,
"tread_run": 0.3,
"width": 1.2,
}
def test_create_wood_steel_stair(self):
kwargs = {
"height": 1.0,
"number_of_treads": 3,
"stair_type": "WOOD/STEEL",
"tread_depth": 0.25,
"tread_run": 0.3,
"width": 1.2,
}
kwargs = self.WOOD_STEEL_STAIR_KWARGS.copy()
verts_data = (
V(0.0, 0, 0.0),
V(0.3, 0, 0.0),
@@ -458,16 +432,8 @@ class TestGenerateStair2DProfile(NewFile):
self.compare_data(generated_profile, expected_profile)
def test_create_wood_steel_stair_zero_width_first_tread(self):
"""Test wood/steel stair with zero-width first tread"""
kwargs = {
"height": 1.0,
"number_of_treads": 3,
"stair_type": "WOOD/STEEL",
"tread_depth": 0.25,
"tread_run": 0.3,
"width": 1.2,
"custom_first_last_tread_run": (0.0, 0.0),
}
kwargs = self.WOOD_STEEL_STAIR_KWARGS.copy()
kwargs["custom_first_last_tread_run"] = (0.0, None)
verts_data = (
# First tread skipped - start at second tread
V(0.0, 0, 0.25),
@@ -506,15 +472,9 @@ class TestGenerateStair2DProfile(NewFile):
def test_create_wood_steel_stair_zero_width_last_tread(self):
"""Test wood/steel stair with zero-width last tread"""
kwargs = {
"height": 1.0,
"number_of_treads": 3,
"stair_type": "WOOD/STEEL",
"tread_depth": 0.25,
"tread_run": 0.3,
"width": 1.2,
"custom_first_last_tread_run": (0.0, 0.0),
}
kwargs = self.WOOD_STEEL_STAIR_KWARGS.copy()
kwargs["custom_first_last_tread_run"] = (None, 0.0)
verts_data = (
V(0.0, 0, 0.0),
V(0.3, 0, 0.0),
@@ -551,8 +511,16 @@ class TestGenerateStair2DProfile(NewFile):
generated_profile = subject.generate_stair_2d_profile(**kwargs)
self.compare_data(generated_profile, expected_profile)
GENERIC_STAIR_KWARGS: dict[str, Any] = {
"height": 1.0,
"number_of_treads": 3,
"stair_type": "GENERIC",
"tread_run": 0.3,
"width": 1.2,
}
def test_create_generic_stair(self):
kwargs = {"height": 1.0, "number_of_treads": 3, "stair_type": "GENERIC", "tread_run": 0.3, "width": 1.2}
kwargs = self.GENERIC_STAIR_KWARGS.copy()
verts_data = (
V(0.0, 0, 0.0),
V(0.0, 0, 0.25),
@@ -585,15 +553,8 @@ class TestGenerateStair2DProfile(NewFile):
self.compare_data(generated_profile, expected_profile)
def test_create_generic_stair_zero_width_treads(self):
"""Test generic stair with zero-width first and last treads"""
kwargs = {
"height": 1.0,
"number_of_treads": 3,
"stair_type": "GENERIC",
"tread_run": 0.3,
"width": 1.2,
"custom_first_last_tread_run": (0.0, 0.0),
}
kwargs = self.GENERIC_STAIR_KWARGS.copy()
kwargs["custom_first_last_tread_run"] = (0.0, 0.0)
verts_data = (
V(0.0, 0, 0.0),
# First tread skipped
+13 -8
View File
@@ -22,6 +22,7 @@ import ifcopenshell
import ifcopenshell.api
import ifcopenshell.api.root
import ifcopenshell.util.representation
import pytest
import bonsai.core.tool
import bonsai.tool as tool
from test.bim.bootstrap import NewFile
@@ -37,12 +38,16 @@ class TestImplementsTool(NewFile):
class TestCanSupportRenderingStyle(NewFile):
def test_anything_with_nodes_can_support_a_rendering_style(self):
obj = bpy.data.materials.new("Material")
obj.use_nodes = True
tool.Style.set_use_nodes(obj, True)
assert subject.can_support_rendering_style(obj) is True
@pytest.mark.skipif(
tool.Blender.BLENDER_5,
reason="Since Blender 5.0 `use_nodes` are always `True` and deprecated.",
)
def test_without_nodes_we_do_not_support_rendering(self):
obj = bpy.data.materials.new("Material")
obj.use_nodes = False
tool.Style.set_use_nodes(obj, False)
assert subject.can_support_rendering_style(obj) is False
@@ -125,7 +130,7 @@ class TestGetSurfaceRenderingAttributes(NewFile):
def test_get_different_surface_and_diffuse_colours_from_a_principled_bsdf(self):
obj = bpy.data.materials.new("Material")
obj.diffuse_color = [1, 1, 1, 1]
obj.use_nodes = True
tool.Style.set_use_nodes(obj, True)
node = tool.Blender.get_material_node(obj, "BSDF_PRINCIPLED")
assert node
node.inputs["Alpha"].default_value = 0.8
@@ -153,7 +158,7 @@ class TestGetSurfaceRenderingAttributes(NewFile):
def test_get_rendering_styles_from_a_glossy_bsdf(self):
obj = bpy.data.materials.new("Material")
obj.diffuse_color = [1, 1, 1, 1]
obj.use_nodes = True
tool.Style.set_use_nodes(obj, True)
output = tool.Blender.get_material_node(obj, "OUTPUT_MATERIAL")
node = tool.Blender.get_material_node(obj, "BSDF_PRINCIPLED")
assert obj.node_tree and node
@@ -185,7 +190,7 @@ class TestGetSurfaceRenderingAttributes(NewFile):
def test_get_rendering_styles_from_a_diffuse_bsdf(self):
obj = bpy.data.materials.new("Material")
obj.diffuse_color = [1, 1, 1, 1]
obj.use_nodes = True
tool.Style.set_use_nodes(obj, True)
output = tool.Blender.get_material_node(obj, "OUTPUT_MATERIAL")
node = tool.Blender.get_material_node(obj, "BSDF_PRINCIPLED")
assert obj.node_tree and node and output
@@ -217,7 +222,7 @@ class TestGetSurfaceRenderingAttributes(NewFile):
def test_get_rendering_styles_from_a_glass_bsdf(self):
obj = bpy.data.materials.new("Material")
obj.diffuse_color = [1, 1, 1, 1]
obj.use_nodes = True
tool.Style.set_use_nodes(obj, True)
output = tool.Blender.get_material_node(obj, "OUTPUT_MATERIAL")
node = tool.Blender.get_material_node(obj, "BSDF_PRINCIPLED")
assert obj.node_tree and node and output
@@ -249,7 +254,7 @@ class TestGetSurfaceRenderingAttributes(NewFile):
def test_get_rendering_styles_from_a_emission_bsdf(self):
obj = bpy.data.materials.new("Material")
obj.diffuse_color = [1, 1, 1, 1]
obj.use_nodes = True
tool.Style.set_use_nodes(obj, True)
output = tool.Blender.get_material_node(obj, "OUTPUT_MATERIAL")
node = tool.Blender.get_material_node(obj, "BSDF_PRINCIPLED")
assert obj.node_tree and node and output
@@ -280,7 +285,7 @@ class TestGetSurfaceRenderingAttributes(NewFile):
def test_other_unsupported_bsdfs_copy_the_rendering_style_from_the_shading_colours_as_a_fallback(self):
obj = bpy.data.materials.new("Material")
obj.diffuse_color = [1, 1, 1, 1]
obj.use_nodes = True
tool.Style.set_use_nodes(obj, True)
output = tool.Blender.get_material_node(obj, "OUTPUT_MATERIAL")
node = tool.Blender.get_material_node(obj, "BSDF_PRINCIPLED")
assert obj.node_tree and node and output
+24 -9
View File
@@ -92,24 +92,39 @@ def quantify(ifc_file: ifcopenshell.file, elements: set[ifcopenshell.entity_inst
for calculator, queries in rules["calculators"].items():
calculator = calculators[calculator]
if not set(m.lower() for m in queries.keys()) - lower_case_entity_names(ifc_file.schema_identifier):
# all defined queries are actually simple entity names: instead of looping over all
# calculators we loop over the entity types so that we don't have to repeatedly query
# the model, especially when the set of elements is small.
# Cache schema entity names once per file
schema_entity_names = lower_case_entity_names(ifc_file.schema_identifier)
# Fast path: all queries are simple entity names
if not set(m.lower() for m in queries.keys()) - schema_entity_names:
casenorm = {k.lower(): k for k in queries.keys()}
pred = lambda inst: inst.is_a().lower()
for ty, elements in itertools.groupby(sorted(elements, key=pred), key=pred):
for ty, group in itertools.groupby(sorted(elements, key=pred), key=pred):
group_elements = list(group)
# check entity type and its supertypes for matching QTO rules
for sty in entity_supertypes(ifc_file.schema_identifier, ty):
if qtos := queries.get(casenorm.get(sty)):
calculator.calculate(ifc_file, list(elements), qtos, results)
calculator.calculate(ifc_file, group_elements, qtos, results)
continue # Skip general path to avoid duplicate calculations
# Fallback: per-query evaluation
for query, qtos in queries.items():
if query.lower() in lower_case_entity_names(ifc_file.schema_identifier):
# by_type is faster than selector parsing
filtered_elements = ifc_file.by_type(query)
# Simple entity name: use by_type but restrict to incoming elements
if query.lower() in schema_entity_names:
by_type_all = ifc_file.by_type(query)
# ensure we don't expand beyond provided elements subset
if isinstance(elements, set):
filtered_elements = [e for e in by_type_all if e in elements]
else:
elements_set = set(elements)
filtered_elements = [e for e in by_type_all if e in elements_set]
else:
filtered_elements = ifcopenshell.util.selector.filter_elements(ifc_file, query, elements)
if filtered_elements:
calculator.calculate(ifc_file, filtered_elements, qtos, results)
return results
@@ -176,7 +176,8 @@ def import_ifc(filename, use_names, process_relations, blender_booleans):
mat.blend_method = "HASHED"
mat.use_screen_refraction = True
mat.refraction_depth = 0.1
mat.use_nodes = True
if bpy.app.version >= (5, 0, 0):
mat.use_nodes = True
bsdf = next(n for n in mat.node_tree.nodes if n.type == "BSDF_PRINCIPLED")
bsdf.inputs[15].default_value = v
else:
+11
View File
@@ -0,0 +1,11 @@
# IfcClash
IFC clash detection library to handle intersections, collisions, and clearance checks between building elements in IFC models.
## Features
- **Multiple detection modes**: intersection, collision, and clearance checking
- **Flexible clash sets**: Compare elements within a single model or between multiple IFC files
- **Smart grouping**: Automatically group related clashes
- **Export options**: Export clash results to BCF-XML or JSON formats
- **Element filtering**: Use selectors to specify which elements to check for clashes
+12
View File
@@ -0,0 +1,12 @@
# IfcCSV
A utility to interact with IFC data through CSV. Export and import IFC data from and to CSV, ODS, XLSX formats, and pandas DataFrames. Enables bulk editing of IFC properties and schedules using spreadsheet tools.
## Features
- **Export IFC data**: Extract element properties and attributes to spreadsheet formats
- **Import changes**: Update IFC models from modified spreadsheets
- **Multiple formats**: Support for CSV, ODS (OpenDocument Spreadsheet), XLSX (Excel), and pandas DataFrames
- **Flexible queries**: Use selectors to specify which elements and attributes to export
- **Data transformations**: Group, summarize, format, and sort results
- **Preserve existing data**: Update existing spreadsheets while preserving formatting
+8
View File
@@ -489,6 +489,9 @@ class IfcCsv:
bool_false: str,
concat: str,
) -> None:
# Patterns to skip during import (substrings)
SKIP_PATTERNS = {"count", "material"}
try:
element = ifc_file.by_guid(row[0])
except:
@@ -506,6 +509,11 @@ class IfcCsv:
elif value == bool_false:
value = False
key = attributes[i] or headers[i]
# Skip keys containing certain patterns
if any(pattern in key.lower() for pattern in SKIP_PATTERNS):
continue
ifcopenshell.util.selector.set_element_value(ifc_file, element, key, value, concat=concat)
+19
View File
@@ -0,0 +1,19 @@
# IfcDiff
Compare IFC models for changes to geometry, properties, and structure. Compares the differences between two IFC models, including changes to geometry, properties, relationships, and spatial structure. Outputs differences in JSON format for further processing or reporting.
## Features
- **Element tracking**: Identify added and deleted elements between models
- **Relationship comparison**: Track changes in multiple relationship types:
- Geometry
- Attributes
- Type assignments
- Properties
- Spatial containers
- Aggregations
- Classifications
- **Flexible filtering**: Compare only specific element types using IFC selectors
- **Configurable depth**: Choose between shallow (first difference only) or deep (all differences) comparison
- **JSON export**: Export comparison results in JSON format for further processing
- **Precision control**: Configure geometric comparison precision
+1 -1
View File
@@ -8,7 +8,7 @@ version = "0.0.0"
authors = [
{ name="Dion Moult", email="dion@thinkmoult.com" },
]
description = "Compares the differences between two IFC models"
description = "Compare IFC models for changes to geometry, properties, and structure"
readme = "README.md"
keywords = ["IFC", "Diff"]
classifiers = [
+12 -1
View File
@@ -670,12 +670,23 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcSurfaceStyle* style) {
if (rendering_style->TransmissionColour()) {
// Not supported
}
#ifndef SCHEMA_IfcSurfaceStyleShading_HAS_Transparency
// ifc2x3
if (rendering_style->Transparency()) {
const double d = *rendering_style->Transparency();
surface_style->transparency = d;
}
#endif
}
#ifdef SCHEMA_IfcSurfaceStyleShading_HAS_Transparency
// ifc4 and onwards
if (shading->Transparency()) {
const double d = *shading->Transparency();
surface_style->transparency = d;
}
#endif
return surface_style;
}
+6 -2
View File
@@ -55,6 +55,9 @@ endif
ifeq ($(PLATFORM), linux64)
PLATFORMTAG:=manylinux_2_31_x86_64
endif
ifeq ($(PLATFORM), linuxarm64)
PLATFORMTAG:=manylinux_2_31_aarch64
endif
ifeq ($(PLATFORM), macos64)
PLATFORMTAG:=macosx_10_15_x86_64
endif
@@ -66,8 +69,9 @@ PLATFORMTAG:=win_amd64
endif
BINARY_VERSION:=0.8.4
IOS_URL:=https://s3.amazonaws.com/ifcopenshell-builds/ifcopenshell-python-$(PYNUMBER)-v$(BINARY_VERSION)-6924012-$(PLATFORM).zip
IFCCONVERT_URL:=https://s3.amazonaws.com/ifcopenshell-builds/IfcConvert-v$(BINARY_VERSION)-6924012-$(PLATFORM).zip
BUILD_COMMIT:=e8eb5e4
IOS_URL:=https://s3.amazonaws.com/ifcopenshell-builds/ifcopenshell-python-$(PYNUMBER)-v$(BINARY_VERSION)-$(BUILD_COMMIT)-$(PLATFORM).zip
IFCCONVERT_URL:=https://s3.amazonaws.com/ifcopenshell-builds/IfcConvert-v$(BINARY_VERSION)-$(BUILD_COMMIT)-$(PLATFORM).zip
.PHONY: build-urls
build-urls:
+9 -4
View File
@@ -25,6 +25,7 @@ import zipfile
import functools
import ifcopenshell
import weakref
import types
from pathlib import Path
from typing import Any, Optional, TYPE_CHECKING, Union, overload, Literal, TypedDict
from collections.abc import Callable, Generator
@@ -248,6 +249,8 @@ READ_ERROR = ifcopenshell_wrapper.file_open_status.READ_ERROR
NO_HEADER = ifcopenshell_wrapper.file_open_status.NO_HEADER
UNSUPPORTED_SCHEMA = ifcopenshell_wrapper.file_open_status.UNSUPPORTED_SCHEMA
INVALID_SYNTAX = ifcopenshell_wrapper.file_open_status.INVALID_SYNTAX
# TODO: Workaround for old builds, remove after build stabilizes.
try:
UNKNOWN = ifcopenshell_wrapper.file_open_status.UNKNOWN
except:
@@ -535,7 +538,6 @@ class file:
"""
wrapped_data: ifcopenshell_wrapper.file
header: file_header
units: dict[str, entity_instance] = {}
history_size: int = 64
history: list[Transaction]
@@ -1049,10 +1051,13 @@ class file:
return self.wrapped_data.to_string()
@property
def header(self):
try:
def header(self) -> file_header:
# TODO: Workaround for old builds, remove after build stabilizes.
# TODO: No need for `wrapped_data.header` to be a method - should use `@property`?
header = self.wrapped_data.header
if isinstance(header, types.MethodType):
return file_header(self, self.wrapped_data.header())
except:
else:
return self.wrapped_data.header
@property
+12 -2
View File
@@ -18,6 +18,7 @@
from __future__ import annotations
import re
import json
import types
import numpy as np
import numpy.typing as npt
import ifcopenshell
@@ -300,8 +301,9 @@ class sqlite(file):
# Override to avoid clean up data unrelated to sqlite file.
pass
def wrapped_data(self) -> NoReturn:
class_name = str(type(self))
@property
def wrapped_data(self) -> NoReturn: # pyright: ignore[reportIncompatibleVariableOverride]
class_name = type(self).__name__
raise Exception(
f"No `wrapped_data` for {class_name}. `ifcopenshell.{class_name}` is probably confused with `ifcopenshell.file`."
)
@@ -315,6 +317,14 @@ class sqlite(file):
# The best option we've got for mimicing `file.schema_identifier`.
return self._schema
@property
def header(self) -> types.SimpleNamespace:
# Mimicking `file_header` object from `ifcopenshell.file`.
header = types.SimpleNamespace(
file_description=types.SimpleNamespace(description=(self.mvd_str,)),
)
return header
class sqlite_entity(entity_instance):
sqlite_wrapper: sqlite_wrapper
@@ -287,12 +287,17 @@ try:
# Override to avoid clean up unrelated to stream file.
pass
def wrapped_data(self) -> NoReturn:
class_name = str(type(self))
@property
def wrapped_data(self) -> NoReturn: # pyright: ignore[reportIncompatibleVariableOverride]
class_name = type(self).__name__
raise Exception(
f"No `wrapped_data` for {class_name}. `ifcopenshell.{class_name}` is probably confused with `ifcopenshell.file`."
)
@property
def header(self) -> NoReturn:
raise NotImplementedError("`stream.header` is not implemented yet.")
@property
def schema(self) -> ifcopenshell.util.schema.IFC_SCHEMA:
return self._schema
@@ -0,0 +1,35 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2021 Dion Moult <dion@thinkmoult.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
from timeit import default_timer as timer
class Profiler:
"""
A python context manager timing utility, useful for measure functions performances
"""
def __init__(self, task):
self.task = task
def __enter__(self):
self.start = timer()
def __exit__(self, *args):
print(f"{self.task} {timer() - self.start:.6f} s")
@@ -225,10 +225,10 @@ class FormatTransformer(lark.Transformer):
if not args:
return True
token = args[0]
if hasattr(token, 'type'):
return token.type == 'TRUE'
if hasattr(token, "type"):
return token.type == "TRUE"
value = str(token).lower()
if hasattr(token, 'value'):
if hasattr(token, "value"):
value = str(token.value).lower()
return value in ("true", "1", "yes")
@@ -259,6 +259,7 @@ class FormatTransformer(lark.Transformer):
)
def imperial_length(self, args):
args = list(filter(lambda x: x is not None, args))
if len(args) == 2:
input_unit, output_unit = "foot", "foot"
value, precision = args
@@ -275,13 +276,14 @@ class FormatTransformer(lark.Transformer):
value, precision, input_unit, output_unit, suppress_zero_inches = args
input_unit = "inch" if input_unit == "inch" else "foot"
output_unit = "inch" if output_unit == "inch" else "foot"
return ifcopenshell.util.unit.format_length(
float(value), int(precision),
suppress_zero_inches=suppress_zero_inches,
unit_system="imperial",
input_unit=input_unit,
output_unit=output_unit
float(value),
int(precision),
suppress_zero_inches=(suppress_zero_inches if suppress_zero_inches is not None else False),
unit_system="imperial",
input_unit=input_unit,
output_unit=output_unit,
)
def int(self, args: list[str]) -> str:
@@ -1026,13 +1028,6 @@ class FacetTransformer(lark.Transformer):
):
parents.add(parent)
for rel in self.file.by_type("IfcRelVoidsElement"):
parent = rel.RelatingBuildingElement
if parent and (
self.compare(parent.Name, comparison, value) or self.compare(parent.GlobalId, comparison, value)
):
parents.add(parent)
for rel in self.file.by_type("IfcRelFillsElement"):
parent = rel.RelatingOpeningElement
if parent and (
@@ -1040,15 +1035,19 @@ class FacetTransformer(lark.Transformer):
):
parents.add(parent)
# Get all children of the matched parents
children: set[ifcopenshell.entity_instance] = set()
for parent in parents:
children |= set(ifcopenshell.util.element.get_decomposition(parent))
# Combine parents and children into a single result set
result = parents | children
self.add_default_elements()
if comparison == "=":
self.elements = self.elements & children
self.elements = self.elements & result
else:
self.elements -= children
self.elements -= result
def query(self, args):
keys, comparison, value = args
@@ -405,7 +405,8 @@ class ShapeBuilder:
ifc_curve = self.file.createIfcIndexedPolyCurve(Points=ifc_points, Segments=ifc_segments)
return ifc_curve
def get_rectangle_coords(self, size: VectorType = (1.0, 1.0), position: Optional[VectorType] = None) -> np.ndarray:
@staticmethod
def get_rectangle_coords(size: VectorType = (1.0, 1.0), position: Optional[VectorType] = None) -> np.ndarray:
"""
Get rectangle coords arranged as below:
+1 -1
View File
@@ -10,7 +10,7 @@ authors = [
]
description = "Python bindings, utility functions, and high-level API for IfcOpenShell"
readme = "README.md"
requires-python = ">=3.9,<3.14"
requires-python = ">=3.9,<3.15"
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)",
+53 -11
View File
@@ -18,13 +18,14 @@
import http.client
from pathlib import Path
from collections.abc import Sequence
from typing_extensions import assert_never
from urllib.parse import urlparse
import pytest
import ifcopenshell
import ifcopenshell.api
import ifcopenshell.util.element
import test.bootstrap
try:
from bs4 import BeautifulSoup
except:
pass
# Where it's also reflected:
# - .github/workflows/ci-ifcopenshell-python.yml
@@ -33,6 +34,10 @@ import test.bootstrap
SUPPORTED_PY_VERSIONS = ("39", "310", "311", "312", "313", "314")
SUPPORTED_PLATFORMS = ("win64", "linux64", "macos64", "macosm164")
WASM_SUPPORTED_PY_VERSIONS = ("313",)
WASM_PLATFORM = "pyodide_2025_0_wasm32"
WASM_TEMPLATE = "https://s3.amazonaws.com/ifcopenshell-builds/ifcopenshell-{BINARY_VERSION}%2B{BUILD_COMMIT}-cp{PYNUMBER}-cp{PYNUMBER}-{PLATFORM}.whl"
class TestPackageSupportedPlatforms:
def test_run(self) -> None:
@@ -52,16 +57,53 @@ class TestPackageSupportedPlatforms:
return line.partition(":=")[2]
BINARY_VERSION = find_make_var("BINARY_VERSION")
URL_TYPES = ("IOS_URL", "IFCCONVERT_URL")
BUILD_COMMIT = find_make_var("BUILD_COMMIT")
missing_urls: set[str] = set()
required_urls: list[str] = []
base_kwargs = {
"BINARY_VERSION": BINARY_VERSION,
"BUILD_COMMIT": BUILD_COMMIT,
}
# Non-WASM binaries.
URL_TYPES = ("IOS_URL", "IFCCONVERT_URL")
for url_type in URL_TYPES:
url_template = find_make_var(url_type)
url_template = url_template.replace("$(", "{").replace(")", "}")
for platform in SUPPORTED_PLATFORMS:
for pyver in SUPPORTED_PY_VERSIONS:
url = url_template.format(PYNUMBER=pyver, PLATFORM=platform, BINARY_VERSION=BINARY_VERSION)
if url not in build_html:
missing_urls.add(url)
kwargs = base_kwargs | {"PLATFORM": platform}
if url_type == "IOS_URL":
for pyver in SUPPORTED_PY_VERSIONS:
url = url_template.format(**kwargs, PYNUMBER=pyver)
required_urls.append(url)
elif url_type == "IFCCONVERT_URL":
url = url_template.format(**kwargs)
required_urls.append(url)
else:
assert_never(url_type)
# WASM wheels.
for pyver in WASM_SUPPORTED_PY_VERSIONS:
url = WASM_TEMPLATE.format(
PYNUMBER=pyver,
PLATFORM=WASM_PLATFORM,
BINARY_VERSION=BINARY_VERSION,
BUILD_COMMIT=BUILD_COMMIT,
)
required_urls.append(url)
# Verify all required URLs are present in the build HTML.
missing_urls: Sequence[str]
if "BeautifulSoup" in globals():
missing_urls = set(required_urls) - set(a["href"] for a in BeautifulSoup(build_html).find_all("a"))
else:
missing_urls = []
for url in required_urls:
if url not in build_html:
missing_urls.append(url)
assert not missing_urls
@@ -73,6 +73,10 @@ class TestFormat:
assert subject.format('imperial_length("3.123", 2)') == "3' - 1 1/2\""
assert subject.format('imperial_length("123.123", 2, "inch", "foot")') == "10' - 3\""
assert subject.format('imperial_length("123.123", 2, "inch", "inch")') == '123"'
assert subject.format('imperial_length(3.0, 4, "foot", "foot", true)') == "3'"
assert subject.format('imperial_length(3.0, 4, "foot", "foot", True)') == "3'"
assert subject.format('imperial_length(3.0, 4, "foot", "foot", false)') == "3' - 0\""
assert subject.format('imperial_length(3.0, 4, "foot", "foot", False)') == "3' - 0\""
class TestGetElementValue(test.bootstrap.IFC4):
@@ -302,7 +306,7 @@ class TestFilterElements(test.bootstrap.IFC4):
assert subject.filter_elements(self.file, "IfcWall, parent=Project") == {element, element2, element3}
assert subject.filter_elements(self.file, "IfcWall, parent=Space") == {element}
assert subject.filter_elements(self.file, "IfcWall, parent=G") == {element, element2, element3}
assert subject.filter_elements(self.file, "IfcWall, parent=Element2") == {element3}
assert subject.filter_elements(self.file, "IfcWall, parent=Element2") == {element2, element3}
assert subject.filter_elements(self.file, "IfcWall, parent=Space") == {element}
def test_selecting_multiple_filter_groups(self):
+62
View File
@@ -0,0 +1,62 @@
file(GLOB IFCPARSE_H_FILES_ALL *.h)
file(GLOB IFCPARSE_CPP_FILES_ALL *.cpp)
foreach(file ${IFCPARSE_H_FILES_ALL})
get_filename_component(filename "${file}" NAME)
if(NOT "${filename}" MATCHES "[0-9]")
list(APPEND IFCPARSE_H_FILES "${file}")
endif()
endforeach()
foreach(file ${IFCPARSE_CPP_FILES_ALL})
get_filename_component(filename "${file}" NAME)
if(NOT "${filename}" MATCHES "[0-9]")
list(APPEND IFCPARSE_CPP_FILES "${file}")
endif()
endforeach()
message(STATUS "SCHEMA_VERSIONS from ifcparse: ${SCHEMA_VERSIONS}")
foreach(schema ${SCHEMA_VERSIONS})
list(APPEND IFCPARSE_H_FILES
Ifc${schema}.h
Ifc${schema}-definitions.h
)
list(APPEND IFCPARSE_CPP_FILES
Ifc${schema}.cpp
Ifc${schema}-schema.cpp
)
endforeach()
set(IFCPARSE_FILES ${IFCPARSE_CPP_FILES} ${IFCPARSE_H_FILES})
add_library(IfcParse ${IFCPARSE_FILES})
set_target_properties(IfcParse PROPERTIES COMPILE_FLAGS -DIFC_PARSE_EXPORTS VERSION "${PROJECT_VERSION}" SOVERSION "${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}")
if(IFCXML_SUPPORT)
if(LibXml2_DIR)
target_compile_definitions(IfcParse PRIVATE ${LIBXML2_DEFINITIONS})
endif()
add_definitions(-DWITH_IFCXML)
target_link_libraries(IfcParse LibXml2::LibXml2)
endif()
if(WASM_BUILD)
target_link_libraries(IfcParse ${BCRYPT_LIBRARIES})
else()
target_link_libraries(IfcParse ${Boost_LIBRARIES} ${BCRYPT_LIBRARIES})
endif()
# CMake installation targets
install(FILES ${IFCPARSE_H_FILES}
DESTINATION ${INCLUDEDIR}/ifcparse
)
install(TARGETS IfcParse
ARCHIVE DESTINATION ${LIBDIR}
LIBRARY DESTINATION ${LIBDIR}
RUNTIME DESTINATION ${BINDIR}
)
+59
View File
@@ -0,0 +1,59 @@
# IfcPatch
Utility for applying modification recipes to IFC files. IfcPatch enables programmatic editing and correction of IFC models through a collection of patches that can fix common issues, migrate data, or transform models.
## Available Recipes
### Alignment and Linear Placement
- **AddGeometricRepresentationToAlignment** - Add geometric representations to alignment elements
- **AddLinearPlacementFallbackPosition** - Add fallback positions for linear placements
- **AddZeroLengthSegmentToAlignment** - Add zero-length segments to alignments
- **PatchStationReferentPosition** - Patch station referent positions
### Data Conversion and Migration
- **AGS2IFC** - Convert AGS (geotechnical) data to IFC
- **ConvertLengthUnit** - Convert between length units
- **ConvertNestToAggregate** - Convert nesting relationships to aggregations
- **ConvertPropertiesToQuantities** - Convert properties to quantities
- **Migrate** - Migrate between IFC schema versions
### Element Extraction and Manipulation
- **ExtractElements** - Extract specific elements into a new model
- **ExtractPropertiesToSQLite** - Export properties to SQLite database
- **Ifc2Sql** - Convert IFC to SQL database
- **TessellateElements** - Tessellate elements into triangulated geometry
### Fixing Software-Specific Issues
- **FixArchiCADToRevitDoorSwings** - Fix door swing orientation from ArchiCAD for Revit
- **FixArchiCADToRevitSpaces** - Fix space data from ArchiCAD for Revit
- **FixRevit2025TINs** - Fix TIN (Triangulated Irregular Network) issues from Revit 2025
- **FixRevitClassificationCodeTypes** - Fix classification code data types from Revit
- **FixRevitTINs** - Fix TIN issues from Revit
### Geometry and Representation
- **AssignConstituentFractions** - Assign fractions to material constituents
- **DowngradeIndexedPolyCurve** - Convert indexed poly curves to simpler representations
- **RemoveSiteRepresentation** - Remove geometric representation from site elements
### Merging and Optimization
- **MergeDuplicateTypes** - Merge duplicate element types
- **MergeProjects** - Merge multiple IFC projects
- **MergeStyles** - Merge duplicate styles
- **Optimise** - Optimize file size and structure
- **PurgeData** - Remove unused data from model
### Spatial and Coordinate Systems
- **OffsetObjectPlacements** - Offset object placement coordinates
- **OffsetStoreyElevations** - Offset storey elevations
- **ResetAbsoluteCoordinates** - Reset absolute coordinates to origin
- **ResetSpatialElementLocations** - Reset spatial element locations
- **SetFalseOrigin** - Set a false origin for coordinates
- **SetRefElevation** - Set reference elevation
- **SetWorldCoordinateSystem** - Set world coordinate system
- **SplitByBuildingStorey** - Split model by building storeys
### Utilities
- **RecycleNonRootedElements** - Remove or recycle non-rooted elements
- **RegenerateGlobalIds** - Regenerate GlobalId values for elements
- **RemoveRevitUniformatClassification** - Remove Revit Uniformat classification
- **UnsharePsets** - Unshare property sets between elements
+1 -1
View File
@@ -8,7 +8,7 @@ version = "0.0.0"
authors = [
{ name="Dion Moult", email="dion@thinkmoult.com" },
]
description = "IFC patching utility"
description = "Utility for applying modification recipes to IFC files"
readme = "README.md"
keywords = ["IFC", "BIM"]
classifiers = [
+4 -3
View File
@@ -88,7 +88,8 @@ SET_PROPERTY(
)
# On Windows there is '_d' prefix for debug builds - e.g. `_d.cp311-win_amd64.pyd`.
if(WIN32)
# In 4.2+ it's done automatically when discovering `Development.Module`.
if(WIN32 AND CMAKE_VERSION VERSION_LESS "4.2")
set(Python_DEBUG_POSTFIX "_d")
endif()
if (CMAKE_VERSION VERSION_GREATER_EQUAL "4.2")
@@ -113,8 +114,8 @@ if (WASM_BUILD)
# Possibly in the future we can clean up linked libs and try `=1`.
# SIDE_MODULE=2 - add to .so only explicitly exported functions (`-s EXPORTED_FUNCTIONS).
# Have to provide it here to override possible `=1` coming from pyodide.
if(CMAKE_GENERATOR MATCHES "Makefile")
# Accomodate bug in cmake makefile generator:
if(CMAKE_GENERATOR MATCHES "Makefile" AND CMAKE_VERSION VERSION_LESS "4.2")
# Accomodate bug in cmake makefile generator (fixed in 4.2+).
# https://gitlab.kitware.com/cmake/cmake/-/issues/27326
string(
REPLACE "SIDE_MODULE=1" "SIDE_MODULE=2"
+3
View File
@@ -496,6 +496,9 @@ namespace {
for (auto& rel : *rels) {
if (rel->declaration().is("IfcRelDefinesByProperties")) {
auto pset = ((IfcUtil::IfcBaseClass*) ((IfcUtil::IfcBaseEntity*) rel)->get("RelatingPropertyDefinition"))->as<IfcUtil::IfcBaseEntity>();
if (!pset->declaration().is("IfcPropertySet")) {
continue;
}
std::string pset_name;
if (!pset->get("Name").isNull()) {
pset_name = (std::string) pset->get("Name");
+86 -44
View File
@@ -31,6 +31,10 @@
#include <TopoDS.hxx>
#include <Bnd_Box.hxx>
#include <gp_Pln.hxx>
#include <TopoDS_Wire.hxx>
#include <BRepBuilderAPI_MakeFace.hxx>
#include <GProp_GProps.hxx>
#include <BRepGProp.hxx>
#endif
@@ -396,56 +400,94 @@ void TtlWktSerializer::write(const IfcGeom::BRepElement* brep_obj) {
filename_.stream << ttl_object_id(brep_obj) << " geo:hasMetricLength " << height << " .\n\n";
gp_Pln pln(gp_Pnt(0, 0, zmin + section_height), gp::DZ());
std::map<double, std::string> polygons_by_area;
Handle(TopTools_HSequenceOfShape) wires = new TopTools_HSequenceOfShape();
double rectangle_area = (x2 - x1) * (y2 - y1);
bool emitted_warning = false;
size_t N = 0;
TopoDS_Iterator it(compound);
// Iterate over components of compound to have better chance of matching section edges to closed wires
for (; it.More(); it.Next()) {
Handle(TopTools_HSequenceOfShape) edges = new TopTools_HSequenceOfShape();
TopoDS_Shape result = BRepAlgoAPI_Section(it.Value(), pln);
for (int iter = 0; iter < 10; ++iter) {
{
TopExp_Explorer exp(result, TopAbs_EDGE);
for (; exp.More(); exp.Next()) {
edges->Append(exp.Current());
}
}
gp_Pln pln(gp_Pnt(0, 0, zmin + section_height + iter * (height - 1.) / 10.), gp::DZ());
ShapeAnalysis_FreeBounds::ConnectEdgesToWires(edges, 1e-4, false, wires);
for (int i = 1; i <= wires->Length(); ++i) {
const TopoDS_Wire& wire = TopoDS::Wire(wires->Value(i));
BRepTools_WireExplorer it(wire);
std::vector<double> loop_coords;
for (; it.More(); it.Next()) {
const auto& v = it.CurrentVertex();
auto pnt = BRep_Tool::Pnt(v);
loop_coords.push_back(pnt.X());
loop_coords.push_back(pnt.Y());
loop_coords.push_back(pnt.Z());
}
std::vector<int> loop_idxs(loop_coords.size() / 3);
for (int i = 0; i < loop_idxs.size(); ++i) {
loop_idxs[i] = i;
}
Handle(TopTools_HSequenceOfShape) wires = new TopTools_HSequenceOfShape();
std::string postfix = "_section_geometry_" + std::to_string(N++);
size_t N = 0;
TopoDS_Iterator it(compound);
// Iterate over components of compound to have better chance of matching section edges to closed wires
for (; it.More(); it.Next()) {
Handle(TopTools_HSequenceOfShape) edges = new TopTools_HSequenceOfShape();
TopoDS_Shape result = BRepAlgoAPI_Section(it.Value(), pln);
filename_.stream << ttl_object_id(brep_obj) << " geo:hasGeometry " << ttl_object_id(brep_obj, postfix.c_str()) << " .\n\n";
filename_.stream << ttl_object_id(brep_obj, postfix.c_str()) << " a geo:Geometry ;\n";
filename_.stream << " geo:asWKT " << escape_for_turtle(
IfcUtil::convert_utf8(
capture_output(
emit_line_component,
loop_coords,
loop_idxs,
true,
POLYGON))
) << "^^geo:wktLiteral .\n\n";
}
}
{
TopExp_Explorer exp(result, TopAbs_EDGE);
for (; exp.More(); exp.Next()) {
edges->Append(exp.Current());
}
}
ShapeAnalysis_FreeBounds::ConnectEdgesToWires(edges, 1e-4, false, wires);
for (int i = 1; i <= wires->Length(); ++i) {
const TopoDS_Wire& wire = TopoDS::Wire(wires->Value(i));
if (!wire.Closed()) {
continue;
}
BRepBuilderAPI_MakeFace mf(wire);
if (!mf.IsDone()) {
continue;
}
auto face = mf.Face();
// calculate face area
GProp_GProps props;
BRepGProp::SurfaceProperties(face, props);
auto area = props.Mass();
BRepTools_WireExplorer it(wire);
std::vector<double> loop_coords;
for (; it.More(); it.Next()) {
const auto& v = it.CurrentVertex();
auto pnt = BRep_Tool::Pnt(v);
loop_coords.push_back(pnt.X());
loop_coords.push_back(pnt.Y());
loop_coords.push_back(pnt.Z());
}
std::vector<int> loop_idxs(loop_coords.size() / 3);
for (int i = 0; i < loop_idxs.size(); ++i) {
loop_idxs[i] = i;
}
std::string postfix = "_section_geometry_" + std::to_string(N++);
std::ostringstream oss;
oss << ttl_object_id(brep_obj) << " geo:hasGeometry " << ttl_object_id(brep_obj, postfix.c_str()) << " .\n\n";
oss << ttl_object_id(brep_obj, postfix.c_str()) << " a geo:Geometry ;\n";
oss << " geo:asWKT " << escape_for_turtle(IfcUtil::convert_utf8(capture_output(emit_line_component, loop_coords, loop_idxs, true, POLYGON))) << "^^geo:wktLiteral .\n\n";
polygons_by_area[area] = oss.str();
}
}
if (polygons_by_area.size() > 0) {
if ((polygons_by_area.rbegin()->first > (0.6 * rectangle_area)) || (height < (1. + 1.e-5))) {
// Found sufficiently large polygon
if (emitted_warning) {
Logger::Warning("Found larger polygon area (" + std::to_string(polygons_by_area.rbegin()->first) + ").");
}
break;
} else if (!emitted_warning) {
Logger::Warning("Section polygon area is small compared to bounding box area (" + std::to_string(polygons_by_area.rbegin()->first) + " < " + std::to_string(0.6 * rectangle_area) + "). Trying again with different section height.");
emitted_warning = true;
}
}
}
if (polygons_by_area.size() > 0) {
// Emit polygon with largest area
auto it = polygons_by_area.rbegin();
filename_.stream << it->second;
}
#endif
}
-1
View File
@@ -48,7 +48,6 @@ def build() -> None:
"-DGLTF_SUPPORT=ON",
"-DADD_COMMIT_SHA=ON",
"-DVERSION_OVERRIDE=ON",
"-DSCHEMA_VERSIONS=4",
]
)
run([str(REPO_WIN / "install-ifcopenshell.bat"), "vs2022-x64", "Release"])