Compare commits

...

105 Commits

Author SHA1 Message Date
Ryan Schultz 8e02ab5b30 Fix #7718: Fix FallDecorator label calculation for all slope annotation types
- Fix wrong dict key type in decoration.py: DecoratorData.data["fall"] is
  keyed by obj.name (str) but was looked up with obj (Object), causing
  object_type to always be None
- Apply obj.matrix_world transform to spline points before computing rise/run
  in both decoration.py and svgwriter.py; local coordinates have Z=0 for flat
  annotations, world coordinates correctly reflect elevation change
- Use hypotenuse (segment_length) instead of run as the denominator for
  SLOPE_FRACTION label display

Generated with the assistance of an AI coding tool.
2026-02-24 14:33:51 -06:00
Thomas Krijnen 2d7a556dd5 Fix sectioned solid cap #7674 2026-02-24 11:26:18 +01:00
Dion Moult dcc25038f9 Fix #7646. Bug with layer thumbnail orientation. 2026-02-24 10:03:33 +11:00
Dion Moult 0d382119dd Fix docs table for selector syntax 2026-02-24 09:53:57 +11:00
Dion Moult 41afaaec0d Revert "Fix #7681: Fix isolate_objects ignoring hide_select/hide_viewport (#7710)"
This reverts commit 7d8c7a2c3d.
2026-02-24 09:53:28 +11:00
Dion Moult f69ea82789 Revert "Fix #7646: Fix layer thumbnail orientation for IFC types"
This reverts commit 514cbb49cc.
2026-02-24 09:53:27 +11:00
Dion Moult 2f5c71588e Revert "Table was not rendering correctly."
This reverts commit 9ff4a7f0e0.
2026-02-24 09:53:24 +11:00
Ryan Schultz 7d8c7a2c3d Fix #7681: Fix isolate_objects ignoring hide_select/hide_viewport (#7710)
Objects with hide_select=True could not be selected during
isolation, causing hide_view_set to incorrectly hide them.
Objects with hide_viewport=True had their H-key hide state
modified as a side effect of hide_view_clear/hide_view_set.
Both are now left unaffected by bim.activate_drawing.

Generated with the assistance of an AI coding tool.
2026-02-23 07:09:18 -06:00
Ryan Schultz 514cbb49cc Fix #7646: Fix layer thumbnail orientation for IFC types
Use EPset_Parametric.LayerSetDirection exclusively to
determine horizontal vs vertical layer rendering in type
thumbnails, rather than hardcoding IfcSlabType checks.
Also fix line drawing to use the is_horizontal flag
consistently.

Generated with the assistance of an AI coding tool.
2026-02-22 17:46:10 -06:00
Ryan Schultz 9ff4a7f0e0 Table was not rendering correctly.
Fix Sphinx docs: replace csv-table with list-table for formatting functions

The documentation table of formatting/query functions was not rendering
because `.. csv-table::` requires strict RFC4180 CSV escaping. The table
contains nested quotes, inch marks (e.g. `3' - 0"`), backticks, and code
examples, which cause the CSV parser in docutils to treat rows as malformed
and drop the entire directive.

Replaced the directive with `.. list-table::`, which parses reStructuredText
instead of CSV and safely supports inline code, quotes, and multi-line cells.

Also moved the examples text outside the directive block and ensured a blank
line after the table so Sphinx does not interpret following paragraphs as
table rows.

No content changes — documentation now renders correctly.

Generated with the assistance of an AI coding tool.
2026-02-22 15:10:29 -06:00
dependabot[bot] 8afe05601e Bump tar from 7.5.7 to 7.5.9 in /src/ifctester/webapp
Bumps [tar](https://github.com/isaacs/node-tar) from 7.5.7 to 7.5.9.
- [Release notes](https://github.com/isaacs/node-tar/releases)
- [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/isaacs/node-tar/compare/v7.5.7...v7.5.9)

---
updated-dependencies:
- dependency-name: tar
  dependency-version: 7.5.9
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-20 08:56:50 +11:00
falken10vdl 1751c36c67 AddReferenceImage: implement option to show texture in solid mode (#7689) 2026-02-19 21:02:57 +11:00
falken10vdl d4388ec76d AddReferenceImage: fix regression with IFC2X3 support, refactor to no longer depend on add_representation or update_representation, remove legacy style updating functionality
* Enhance AddReferenceImage operator to use file browser instead of independent popup dialogue

* Fix dimensions assertion in TestAddReferenceImage

* Remove error in return in _execute (it is not execute)

* Add  IFC2X3 support to AddReferenceImage

* Adde unit="LENGTH" to the x/y properties (every length dimension everywhere in the UI is in project length units. No need to say it explicitly)

* Manually create the texture always, not just for IFC2X3

* Add poll method to AddReferenceImage operator to check for loaded IFC project

* Refactor AddReferenceImage to add representation manually following pattern in root/operator.py's bim.add_element

* Improve File explorer options between new and select from existing project Ifc Reference Images

* Refactor get_existing_reference_images to use selector for filtering image annotations

* No extra args needed after should_add_representation is False

* Doing clean=True deletes everything

* Don't manually add geometry and materials, don't call bpy.ops. Only create IFC data, then use preexisting loading functions to create geometry.

* Black formatting, also now we can start to remove this operator as it becomes obsolete

* Consolidate duplicate UV generation into Loader.load_generated_uv_map

Replace 3 identical XY-UV baking blocks (create_object IMAGE,
bm_add_image_plane, ImageScalingTool) with a single reusable
classmethod in tool.Loader.

* Fix IFC4 texture display in Solid viewport Texture mode

IFC4 IfcTextureCoordinateGenerator Mode=COORD is used, load_texture_maps
falls back to load_generated_uv_map to bake XY-UV data onto the mesh.

* Fix IFC2X3 texture display

* This looks wrong

* Remove legacy override image feature, because we now have a proper styles and texture manager

* Remove legacy override existing image element, because we now have a dedicated styles texture manager

* Remove unnecessary roundtrip to bmesh and mesh

---------

Co-authored-by: Dion Moult <dion@thinkmoult.com>
2026-02-19 11:56:10 +11:00
falken10vdl 7141f2cf90 Fixes to PR7607 (Linked IFC Projects): Wireframe toggle. More permisive to get has_transformation = False. Show enable_editing_link if link is loaded 2026-02-19 10:53:01 +11:00
Sebastian Schilling 418d410b5c moved change of bsdd baseurl change to addon settings 2026-02-18 09:11:25 +11:00
Sebastian Schilling a5461c0748 buildingSMART Data Dictionary module: added textfield to change data dictionary url 2026-02-18 09:11:25 +11:00
Bruno Postle 291e815770 Add AGENTS.md contributor guide
Guidelines for external contributors using AI coding tools,
covering licensing, AI disclosure requirements, PR scope,
commit style, code formatting, and testing expectations.

Generated with the assistance of an AI coding tool.
2026-02-18 09:09:46 +11:00
Dion Moult ed500d58ba For consistency, maxfail=1 for module tool tests 2026-02-17 18:16:33 +11:00
Dion Moult 8023a992da Fix tests where panel name and tab panel name is identical
For now probably just easier to skip tabs. They are just containers and
not worth testing. Famous last words :)
2026-02-17 18:16:20 +11:00
Dion Moult fcc80ad14a Simplify add reference image size implementation and fix segfaulting tests
Previously, there was a dance between invoke, execute, and draw. This
can probably be resolved, but is a high-risk for undo bugs. This
simplifies the logic flow to just a traditional _invoke -> _execute.

I add a new feature test to at least make sure it does something, and
this also fixes the segfault in tool tests as it no longer requires the
launching of the file browser.
2026-02-17 18:11:13 +11:00
José Aliste c6b14d1474 Fixes snap angle.
In my previous commit, I mistakenly believed that there was an API change from
snap_angle_increment to snap_angle_increment_3d
But since the feature was introduced in blender 4.2 the setting is called
snap_angle_increment_3d.
2026-02-17 13:39:03 +11:00
Dion Moult 37fe0ad993 Reimplement adding multiple references / schedules cf5ffad9af
Previously it was implemented inline. This now implements it as a
tool.Blender function with tests. Also the previous tests didn't
actually run and weren't actually testing any tools despite being in a
tool tests.
2026-02-17 11:18:24 +11:00
Dion Moult e20e286168 Revert "feat(drawing): support multiple file selection in Add Reference"
This reverts commit cf5ffad9af.
2026-02-16 18:25:54 +11:00
Dion Moult 8c0bed0c61 Stub open command so running tests doesn't keep on launching apps 2026-02-16 18:24:37 +11:00
Dion Moult 8cfb162851 Fix #7656. Regression in text editing where leaders were accidentally removed. Added tests. 2026-02-16 17:57:09 +11:00
dependabot[bot] 379c74b31f Bump ruff from 0.15.0 to 0.15.1
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.15.0 to 0.15.1.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.15.0...0.15.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-16 14:57:13 +11:00
Dion Moult 1d1f158fe2 Remove no longer relevant invoke code for linking IFCs 2026-02-16 08:23:35 +11:00
Richard Brice 65d5df7801 IfcAxis2PlacementLinear mapping used with IfcSectionedSurface and IfcSectionedSolidHorizontal
IfcSectionedSurface and IfcSectionedSolidHorizontal both of CrossSectionPositions attributes which are lists of IfcAxis2PlacementLinear. The implementation of each class used its own bespoke mapping of IfcAxis2PlacementLinear, which were identical to each other and slightly different than IfcAxis2PlacementLinear. Now the two sectioned classes use the one and only mapping for IfcAxis2PlacementLinear
2026-02-15 11:16:07 -08:00
falken10vdl b246998f68 Linked IFC projects enhancement (multiple links to same project file) (#7607)
* Linked IFC projects enhancement (multiple links to same project file)

- Implement link management system using UUIDs as identifiers to support multiple links to the same IFC file
- Add georeferencing compatibility detection and UI display (NONE, NOT_COMPATIBLE, PARTIAL_COMPATIBLE, FULL_COMPATIBLE)
- Support for duplicate link creation with Shift+D shortcut and automatic position offset
- Add false origin and project north calculation from 3D cursor for MANUAL mode
- Only store one cache per file, regardless of the amount of links
- Prevent duplicate links based on filepath and position comparison
- Improve error handling for missing files and loading failures
- Update tests

* Remove duplicate georef UI

I try to avoid duplicate UI (especially for one that can be as
sophisticated as georef - e.g. missing is WCS) as it means double the
code, double the tests, potential user confusion. BTW the note about
vertical datum isn't quite accurate as it may be included in the CRS
definition so vertical datum is optional.

* Remove depsgraph_update_post handler for update_link_ui_on_transform as per core developer feedback

* Move get_projected_crs to geolocation module

* Refactor get_projected_crs to simplify as per core developer feedback

* Remove unused import of bonsai.tool from project module

* Use IfcDocumentInformation per linked file and IfcDocumentReference for locaiton information

* Refactor SaveBlendMetadataFile operator to remove  try-except blocks and remove linked projects collections since they are recreated by bonsai

* Cleanup removing empty collection instances for linked models in metadata.blend file and call determine_georeferencing_compatibility on link reload

* Add locking mechanism for linked models and update UI to reflect lock status

* Update logic that track IFC to execute_ifc_duplicate_operator instead of having it in execute() which does not track IFC undo/redo

* Refactor link handling to use get_link_empty_handle and set_link_empty_handle methods which in turn use the standard blender-ifc integrations patters (tool.Ifc.get_object(doc_reference) and tool.Ifc.link(doc_reference, empty_handle)

* remove operator.DuplicateLink and move it to tool.Project.duplicate_link()

* Refactor link handling to use sequential identifiers (no need for STEP ID DocRef)

* Refactor IFC linking logic to handle cases without a parent IFC file loaded. Firts link flase origin becomes parent origin

* Lock should not affect selection.

This makes it consistent with grid / spatial lock, and also toggle
selectability is already implemented.

* Remove unnecessary check for loaded library as Blender seems to do this internally already

* Rename util to get_crs because in IFC4X3 you can also have geographic CRS not just projected

* Remove unnecessary call to determine_georeferencing_compatibility

This function is already always called prior to calculate_link_position
so shouldn't be called here. It's also a very expensive function: as it
currently stands, just to link a single IFC, ifcopenshell.open() is
called 3 times. This reduces it to 2.

* Store CRS as metadata for linked models, and compare metadata when indicating georeferencing compatibility

Previously, to check georeferencing compatibility, ifcopenshell.open()
was used. When linking large models, this adds considerable time and
memory usage. This instead captures the georef as standard metadata in
our .cache.json. This now reduces the ifcopenshell.open() calls back
down to only 1 as necessary (see previous commit).

* Use link index instead of link name to fetch link collection item

Link name runs into issues with name uniqueness. This is why you created
a function for "get next link ID". After this refactoring, we can no
longer worry about uniqueness and that function may be removed.

* Simplify reloadlink into just unload and reload (with cache disabled)

This function should not be responsible for editing any data.

* Remove unnecessary get_next_link_id as names no longer need uniqueness

This now frees up the name variable to track a more meaningful, human
name like IfcDocumentInformation's Name attribute.

* Rewrite get / set link_empty_handle to just use the link directly

This prevents needless logic to fetch the link and also removes issues
related to duplicate names.

* Temporarily remove logic in prop callback

Right now, pretty much all the logic is done in a prop callback. In
general logic in prop callbacks should be minimised, since it's hard to
test and easily triggered as a domino effect of another change, and may
also impact undo/redo.

* Remove code that unnecessarily removes cache

This code removes cache, which means any project unlinking an IFC auto
clears the cache for any other project which doesn't make sense, and
also breaks the ability to readd it quickly.

* Rewrite link, unlink, load, and unload IFC

There were a few issues tackled here:

 - Operators that change any IFC data must use tool.Ifc.Operator and
_execute, otherwise undo/redo will break. That's one of the risks of
using prop callbacks, as it is not explicit when an IFC edit happens.
 - The usage of IfcDocumentReference was not correct. The Location
should store the URL, _not_ the position. The position should be in the
Identification attribute.
 - The URL was stored in IfcDocumentInformation location, which does not
work in IFC2X3. There are a few changes here to make it IFC2X3
compatible.
 - Generally move logic in operators, not prop callback.

* Remove restriction around manual mode.

Users should be able to use manual mode if they want.

* Restore AUTOMATIC mode to identical behaviour to file open

This is the first step to reusing cache files agnostic of the host.

* Revert tests for a fresh start for updating tests

* Revert "test_feature - clean up .ifc.cache. files after test was executed"

This reverts commit 99ae768ddf.

* Update tests and reimplement calculations for matrix of empty handle

Previously, the empty would always be placed at the origin, unless a
"position" offset was present. This is a problem, because the "position"
is simply a local offset relative to the Blender cache! If the cache was
regenerated, the offsets would be outdated. Also, the cache appeared in
different locations depending on the false origin mode, so the offset
would mean different things to different people.

Instead, a more robust method is:

 1. When you link a file, a Blender cache is generated. The Blender
origin of this cache is arbitrary! It depends on the user's false origin
mode and is purely a Blender session specific thing.
 2. When you load a link, a link is _always_ loaded into the correct
location with regards to IFC global coordinates. All math is done from
the perspective of IFC.
 3. If you choose to transform (move / rotate / scale!?) this link from
its correct location, that gets recorded as a 4x4 transformation matrix.
Note: I haven't implemented this properly yet.

Tests all pass, with a minor modification to the new behaviour that
false origin mode now won't affect the location it ends up in, only the
generation of the cache.

* Remove arbitrary convention around display name

Not needed anymore now that A/M/D is a detail and not significant on
actual coordinates, and also that the UUID is no longer needed.

* Simplify implementation of loading linked models when opening an IFC

* Move link matrix calculation from operator to tool for reuse

* Implement editing link location and calculation of transformation matrix

I changed my mind on the is_locked thing, since it isn't clear to the
user that locking need to be done to save changes.

* Remove old is_locked, prop update callback no longer needed (dedicated operator instead), remove old calculation code

* Simplify code related to placed_as_per_georef

* For now, simple skip for duplicate / delete

IMO duplicate / delete / move a link are very rare and explicit
operations.

* Update tests

* Remove host_model coordinate data as cache is no longer host model dependent

* Move icons outside list because there are too many

* Minor tweaks

---------

Co-authored-by: Dion Moult <dionmoult@gmail.com>
Co-authored-by: Dion Moult <dion@thinkmoult.com>
2026-02-15 19:28:43 +11:00
Ryan Schultz a88c5938dc typos 2026-02-14 12:18:29 -06:00
Thomas Krijnen 7978f1fb08 Fix compilation on gcc #7666 2026-02-13 10:17:28 +01:00
ssg3d 7b4889d2ec Update IfcParse.cpp
IfcOpenshell read file, and write file without changes. This round trip introduces truncation noise. It should not hurt to increase the precision to keep this clean.
2026-02-13 10:03:05 +01:00
José Aliste 740fcf7768 Use Blender's angle snap setting in wall.py and profile.py
Replace hardcoded 5-degree angle snapping with Blender's
snap_angle_increment setting in create_wall_from_2_points()
and create_profile_from_2_points().

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-11 22:29:43 -03:00
José Aliste 067e04b564 Use Blender's angle snap setting in model/polyline.py
Replace hardcoded 5-degree angle snapping with Blender's
snap_angle_increment setting in handle_lock_axis() for:
- Initial angle rounding when locking axis (A key)
- Angle rounding and increments on Shift+Wheel scroll

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-11 22:29:43 -03:00
José Aliste cd95f46db5 Use Blender's angle snap setting in tool/polyline.py
Replace hardcoded 5-degree angle snapping with Blender's
snap_angle_increment setting in calculate_distance_and_angle().

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-11 22:29:43 -03:00
José Aliste 348e48b49c Add get_angle_snap_value() helper to tool/snap.py
This function retrieves the angle snap increment from Blender's
tool_settings.snap_angle_increment property, which was added in
Blender 4.2. This allows users to configure the angle snap value
through Blender's native UI instead of using hardcoded values.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-11 22:29:43 -03:00
Thomas Krijnen 6bb8abdc5a Fix for 4.2 schema after a46cdbb907 2026-02-11 12:11:23 +01:00
Thomas Krijnen b7a8c9b330 Fix for 4.2 schema after a46cdbb907 2026-02-11 11:43:58 +01:00
Thomas Krijnen e6780973da arrange_poly: Refactor into logical blocks; add timing 2026-02-10 14:01:10 +01:00
Thomas Krijnen c6072e416c N-Section Lofting for Non-Polygonal (Curved) Shapes #7658 2026-02-10 11:04:43 +01:00
Thomas Krijnen f5686fff26 Simplify destructor by removing null check #7650 2026-02-10 09:25:02 +01:00
Thomas Krijnen a46cdbb907 Ignore site placement also for site geometry - add queue #7654 2026-02-09 21:37:21 +01:00
Thomas Krijnen a4d5d4e19a Ignore site placement also for site geometry #7654 2026-02-09 21:21:10 +01:00
dependabot[bot] 17b88fff22 Bump aws-actions/configure-aws-credentials from 5 to 6
Bumps [aws-actions/configure-aws-credentials](https://github.com/aws-actions/configure-aws-credentials) from 5 to 6.
- [Release notes](https://github.com/aws-actions/configure-aws-credentials/releases)
- [Changelog](https://github.com/aws-actions/configure-aws-credentials/blob/main/CHANGELOG.md)
- [Commits](https://github.com/aws-actions/configure-aws-credentials/compare/v5...v6)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-08 19:17:56 +11:00
dependabot[bot] 3fe9980aa4 Bump ruff from 0.14.14 to 0.15.0
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.14.14 to 0.15.0.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.14.14...0.15.0)

---
updated-dependencies:
- dependency-name: ruff
  dependency-version: 0.15.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-08 19:17:45 +11:00
Dion Moult dffa3515c0 Reimplement sort / reverse / join function to format language, simplify text annotation variables, add tests
Previously, sort, reverse list, and join functionality was implemented
as special cases in Bonsai itself. Given that it has usecases
(especially in material lists, but any sort of list applies) I've moved
this function into the IOS formatting language.

The IOS formatting language previously wasn't capable of this, but the
awesome addition by @falken10vdl made the formatting language accept
queries inline, so that means it can handle lists. I also added tests
for all the new functions and expression syntax (+-*/ operators).

I simplified the code that gets the evaluated text literal - previously
it seems to call format() multiple times.
2026-02-08 19:17:12 +11:00
Bruno Postle 1d5108f934 Fix linking for builds with -Wl,--as-needed 2026-02-06 16:51:41 +00:00
Dion Moult 02fab6eee2 Fix #7634. Support formatting signed numbers. 2026-02-06 16:35:04 +11:00
Dion Moult 5e9f97a0c7 Fix #5220. See #7629. See #7505. Reimplement text bulk copying using established copy attribute paradigm.
Previously, copy attribution was coupled with text editing. This meant
that you couldn't just do something like change the font or alignment
without also affecting literals. Now like most apps you can just select
bunch of text and change font size etc, using the same UI look and feel
that copying attribute has when editing attributes.

This refactor also removes the need for explicit props tracking each
possible attribute to copy, and the settings collection group. Bulk
applying is now done in core with no calls to UI.
2026-02-06 16:12:14 +11:00
Richard Brice 2692341d3a Fixes typo 2026-02-05 15:10:33 -08:00
Richard Brice a54b21b838 Fixes documentation and adds test for expected type 2026-02-05 04:37:58 -08:00
Andrej730 79ef6eb55c validate_object_selection - fix missing check for active object not present in a view layer 2026-02-04 19:58:26 +05:00
Andrej730 7e1843b962 dev_environment - support Blender 5.1 with Python 3.13 2026-02-04 19:58:25 +05:00
Andrej730 7411da2ae8 bim.search - fix error when unselectable objects get in the way #7635 2026-02-04 19:58:25 +05:00
Andrej730 2f586d6b9e bonsai prefs - shorten some description strings for readibility 2026-02-04 19:58:25 +05:00
Andrej730 f10d4602fc typing 2026-02-04 19:58:25 +05:00
Andrej730 50d00eba20 black . 2026-02-04 19:58:25 +05:00
Andrej730 95480a231c Move handling legacy mathutils buffer types to separate methods 2026-02-04 19:58:24 +05:00
Andrej730 a62b177b19 Bonsai - fix error drag'n'dropping ifc files in Blender 4.5.6
Example error:
```
Error: Couldn't find IFC file: 'L:/OD_Cool_Car_Guys.ifc'.
Traceback (most recent call last):
  File "\Blender Foundation\Blender\4.5\extensions\.local\lib\python3.11\site-packages\bonsai\bim\module\project\operator.py", line 2759, in invoke
    return bpy.ops.bim.load_project(filepath=(filepath / clean_up_path(self.files[0].name)).as_posix())
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "\blender-4.5.6-lts.a78963ed6435\4.5\scripts\modules\bpy\ops.py", line 109, in __call__
    ret = _op_call(self.idname_py(), kw)
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
RuntimeError: Error: Couldn't find IFC file: 'L:/OD_Cool_Car_Guys.ifc'.
```
2026-02-03 19:20:05 +05:00
Andrej730 d6b636909f bonsai Makefile - revert d745882
Since https://github.com/LBNL-ETA/pyradiance/issues/56 is resolved now.
2026-02-03 14:55:14 +05:00
Andrej730 abf0d6f81a bonsai chaching - save ears for those running the tests 2026-02-03 14:55:14 +05:00
Andrej730 015431fa1f bonsai - drop unrelated pypi aud module
Turned out `aud` module we had in our makefile had nothing to do with Blender built-in `uad` module 🫣
So no need to install anything from PyPI since this module is generally available in Blender
2026-02-03 14:55:14 +05:00
Andrej730 95cc52a6f3 bonsai - revive very important chaching sound
`get_data_dir_path` doesn't have `filename` argument, therefore leading to an error
2026-02-03 14:55:14 +05:00
Dion Moult 9dff1441d7 See #7632. See #5848. Just disable indexed colour map loading for now, it's pretty rare.
Famous last words, at least the KDTree implementation is much faster.
2026-02-03 18:23:39 +11:00
Dion Moult fdf23ece85 Fix #7632. See #2824. Optimise vertex matching trick for IfcIndexedColourMap. 2026-02-03 18:20:49 +11:00
Dion Moult 1445f89f19 See #7629. Partially reimplement bulk text editing.
This isn't complete yet, but it hopefully demonstrates a preferred
implementation:

 * Logic in core, not operator
 * Loop done in core, without needing to call other core functions, so
the overhead of enabling and disabling editing per object is removed. No
more Blender logic, just straight editing in IFC.
 * Reuse existing function to grab text attributes instead of
reimplementing it twice.
 * Remove dead code, there seems to be a function
apply_to_selected_objects which was completely unused and duplicated
code twice.
2026-02-03 18:09:15 +11:00
Dion Moult fe395f9024 Revert "fixes #7629: close editing mode for text objects after batch edit"
This reverts commit 950e147bde.
2026-02-03 09:13:26 +11:00
Ryan Schultz 950e147bde fixes #7629: close editing mode for text objects after batch edit
Fix issue where selected text annotations remained in editing mode after
applying changes. Now properly restores original editing state for each
selected object.
2026-02-02 09:34:28 -06:00
Thomas Krijnen c385b93701 Add option --make-volume to apply BOPAlgo_MakerVolume API to non-manifold first operands in opening subtraction 2026-02-01 21:08:11 +01:00
falken10vdl 2d2e8e51aa Merge pull request #7611 from falken10vdl/connect-ports-for-cable-polyline-operator
connect the ports when creating several conected IfcFlowSegment with polyline
2026-02-01 09:21:27 +01:00
falken10vdl 13f03b7487 Merge pull request #7619 from falken10vdl/MEP-Segment-tool-add-rectangular-hollow-profile
MEP-Segment-tool-add-rectangular-hollow-and-U-shape-profile
2026-02-01 09:00:14 +01:00
Richard Brice c6257a4b3a Fixes errors in ifc_curve_rebar.cpp
M_PI was not defined,. Replaced it with boost::math::constants::pi<double>

Calculation of area was incorrect. Area of a circle = PI*r*r = PI*dia*dia/4
2026-01-31 05:30:36 -08:00
dependabot[bot] 9b8b05a570 Bump gersemi from 0.25.1 to 0.25.4
Bumps [gersemi](https://github.com/BlankSpruce/gersemi) from 0.25.1 to 0.25.4.
- [Release notes](https://github.com/BlankSpruce/gersemi/releases)
- [Changelog](https://github.com/BlankSpruce/gersemi/blob/master/CHANGELOG.md)
- [Commits](https://github.com/BlankSpruce/gersemi/compare/0.25.1...0.25.4)

---
updated-dependencies:
- dependency-name: gersemi
  dependency-version: 0.25.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-31 10:04:17 +11:00
Bruno Postle 611a898ada IFC Git close and reopen repo after cloning
Apparently on Windows clone can leave 'stale processes'
2026-01-30 20:07:04 +00:00
Andrej730 6409f41cdf Bonsai Makefile - sort of simplify pyversion check 2026-01-30 16:05:27 +05:00
Andrej730 35dc029898 bonsai - add a dockerfile 2026-01-30 16:05:27 +05:00
Andrej730 2f72346845 bonsai Makefile - fix platform tag to use the latest pyradiance
Apparently `pip download` is matching tag exactly, which was leading to Linux using pyradiance 0.5.3 instead of 1.1.5.
2026-01-30 16:05:27 +05:00
Andrej730 40e4894f8e ci-bonsai-daily - exclude intel mac build for Python 3.13
Since Blender dropped support for it in Blender 5.0 and Python 3.13 is only needed for Blender 5.1.
2026-01-30 16:05:27 +05:00
Andrej730 91ee2b9222 bonsai Makefile - require Blender 5.1 for Python 3.13 2026-01-30 16:05:27 +05:00
Andrej730 ac4ffd91e7 Bonsai Makefile - drop wheel renaming workaround #5743 2026-01-30 16:05:27 +05:00
Andrej730 d74588246a bonsai Makefile - workaround for pyradiance in Python 3.13
Until https://github.com/LBNL-ETA/pyradiance/issues/53 is resolved.
2026-01-30 16:05:26 +05:00
Andrej730 e8a4590d0a Bonsai Makefile - error for missing PYVERSION 2026-01-30 16:05:26 +05:00
Andrej730 38f9f309e7 Bonsai Makefile - enable Python 3.13 build 2026-01-30 16:05:26 +05:00
Andrej730 47ac909a81 ruff - sort imports 2026-01-30 16:05:26 +05:00
Andrej730 6a63c651d3 black . 2026-01-30 16:05:26 +05:00
falken10vdl 2727bec929 Reorder representation_template to improve clarity and organization as per user feedback 2026-01-30 10:24:53 +01:00
dependabot[bot] 22f91e11b3 Bump tar from 7.5.6 to 7.5.7 in /src/ifctester/webapp
Bumps [tar](https://github.com/isaacs/node-tar) from 7.5.6 to 7.5.7.
- [Release notes](https://github.com/isaacs/node-tar/releases)
- [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/isaacs/node-tar/compare/v7.5.6...v7.5.7)

---
updated-dependencies:
- dependency-name: tar
  dependency-version: 7.5.7
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-30 17:57:12 +11:00
Dion Moult 398cd7571e Add array feature tests
I'm thinking of moving array into its own module. The "model" module is
getting a bit full and it'll only grow bigger in the future.
2026-01-30 17:32:30 +11:00
Dion Moult 83d97d7e95 Fix #7616. Make regenerate array an operator instead of an array preference
Sync children was a bit odd because it's not actually an "array
parameter" per se, just a way to regenerate. It's now an operator.

There was a deeper issue I encountered where the way arrays work is that
they duplicate the parent element. (first encountered in e51d2d )
However, the duplication code has special array handling too. To avoid
issues with this cyclical coupling the previous solution was to
reimplement object duplication (with all sorts of pitfalls that has).
Now, I've tried to decouple it further by clearing all array psets prior
to any change, and readding the pset after everything has been
regenerated.

This can be improved upon but I don't feel confident until there is more
comprehensive test coverage for the duplicate operator.
2026-01-30 17:18:02 +11:00
falken10vdl e47694458f Add support for FLOW_SEGMENT_U_SHAPE in the AddElement operator 2026-01-29 23:21:14 +01:00
falken10vdl 98a3b2b132 Merge remote-tracking branch 'upstream/v0.8.0' into MEP-Segment-tool-add-rectangular-hollow-profile 2026-01-29 13:17:53 +01:00
falken10vdl d06f5a6cb9 Add thickness dimension to profile name 2026-01-29 13:12:47 +01:00
falken10vdl 966751a928 Add support for FLOW_SEGMENT_RECTANGULAR_HOLLOW in profile creation in the MEP Segment Tool 2026-01-29 13:10:54 +01:00
Dion Moult c1aa1dedba Fix #7615. Forgot a line when refactoring type geometry regeneration. Added test. 2026-01-29 19:33:44 +11:00
Dion Moult cc995db98b Minor fix to 46a6356 to use the collector tool
This has a few advantages:

 - The collection logic is centralised
 - The collection logic is configurable based on the collection mode
 - The name is not hardcoded
2026-01-29 19:12:02 +11:00
Ryan Schultz 0e8c98a7bb Fix: Handle emoji encoding error in dev_environment.py
Prevents UnicodeEncodeError on Windows when displaying success message.
2026-01-28 18:34:14 -06:00
Dion Moult 5ce6d927f3 Revert "fix #7537 - Layer thickness correct when slab is rotated and few other features... "
This reverts commit 7f87f1fb89.
2026-01-28 23:39:45 +11:00
Dion Moult 34303c2cfe Revert "fix to https://github.com/IfcOpenShell/IfcOpenShell/commit/7f87f1fb89fb001320223a4d85e6267f342bf13c: have rotation around the object's origin, not the world origin"
This reverts commit 249e68f163.
2026-01-28 23:28:14 +11:00
Dion Moult 6cfb6d74fe Revert "fix #7559: DirectionSense works again."
This reverts commit d79524b087.
2026-01-28 23:25:35 +11:00
Dion Moult e76c2a70e6 Add tests for aggregate containment changing 2026-01-28 23:18:57 +11:00
Dion Moult 53e5f84730 Fix failing core unit tests 2026-01-28 21:49:25 +11:00
Dion Moult bcff5c7449 Remove unused tool 2026-01-28 21:49:13 +11:00
Dion Moult 6f78d60c9f Minor fix for tests to prevent ambiguous labeling 2026-01-28 21:22:19 +11:00
falken10vdl 8a32d7bea0 Add EstablishPathDirection operator and update UI to integrate it 2026-01-27 18:05:19 +01:00
falken10vdl fed98451d4 connect the ports when creating several conected IfcFlowSegment with polyline 2026-01-27 01:32:06 +01:00
105 changed files with 3346 additions and 3214 deletions
+1 -1
View File
@@ -139,7 +139,7 @@ jobs:
cd ..
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v5
uses: aws-actions/configure-aws-credentials@v6
with:
aws-access-key-id: ${{ secrets.AWS_UPLOAD_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_UPLOAD_SECRET_ACCESS_KEY }}
+1 -1
View File
@@ -77,7 +77,7 @@ jobs:
git push || echo "Push failed"
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v5
uses: aws-actions/configure-aws-credentials@v6
with:
aws-access-key-id: ${{ secrets.AWS_UPLOAD_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_UPLOAD_SECRET_ACCESS_KEY }}
+1 -1
View File
@@ -118,7 +118,7 @@ jobs:
cd ..
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v5
uses: aws-actions/configure-aws-credentials@v6
with:
aws-access-key-id: ${{ secrets.AWS_UPLOAD_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_UPLOAD_SECRET_ACCESS_KEY }}
+1 -1
View File
@@ -118,7 +118,7 @@ jobs:
cd ..
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v5
uses: aws-actions/configure-aws-credentials@v6
with:
aws-access-key-id: ${{ secrets.AWS_UPLOAD_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_UPLOAD_SECRET_ACCESS_KEY }}
+1 -1
View File
@@ -73,7 +73,7 @@ jobs:
git push || echo "Push failed"
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v5
uses: aws-actions/configure-aws-credentials@v6
with:
aws-access-key-id: ${{ secrets.AWS_UPLOAD_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_UPLOAD_SECRET_ACCESS_KEY }}
+5
View File
@@ -53,6 +53,11 @@ jobs:
name: "MacOS ARM Build",
short_name: macosm1,
}
exclude:
# Python 3.13 is needed for Blender 5.1+ and Blender dropped Intel Mac support in 5.0.
- pyver: py313
config:
short_name: macos
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
-1
View File
@@ -53,7 +53,6 @@ jobs:
python -m pip install --upgrade pip
pip install xmlschema xsdata numpy lxml pytest isodate lark networkx tabulate python-dateutil shapely
pip install src/bcf --no-deps
pip install git+https://github.com/zdhoward/aud
pip install pytest-xdist==3.8.0
- name: Install C++ dependencies
+2
View File
@@ -82,6 +82,8 @@ src/bonsai/bonsai/translations.py
# bonsai test temp files
src/bonsai/test/files/temp
src/bonsai/test/files/basic.ifc.cache.blend
src/bonsai/test/files/basic.ifc.cache.sqlite
# bonsai data
src/bonsai/bonsai/bim/data/build/
+153
View File
@@ -0,0 +1,153 @@
<!-- This file was generated with the assistance of an AI coding tool. -->
# AGENTS.md
Guidelines for AI coding agents contributing to IfcOpenShell. This file is
intended to be read by all AI agents regardless of platform (Claude Code,
Copilot, Cursor, etc.) in addition to any tool-specific configuration files.
Human contributors using AI tools should also read this document carefully,
as they are responsible for ensuring their contributions comply with these
guidelines.
## Project Overview
IfcOpenShell is an open source library for working with Industry Foundation
Classes (IFC). It provides C++ and Python APIs, geometry processing, and an
ecosystem of tools including IfcConvert and the Bonsai Blender add-on.
## Licensing
All contributions must be compatible with the project's licensing:
- **Library code** (everything except Bonsai): **LGPL-3.0-or-later**
- **Bonsai** (`src/bonsai/`): **GPL-3.0-or-later**
There is no Contributor License Agreement (CLA). By submitting a pull request,
you agree that your contribution is licensed under the applicable license above.
## Indicating AI-Generated Code
Contributors must clearly indicate when code has been generated or
substantially written by an AI tool.
### Commits
Commits that modify existing code must include a note in the **body** of the
commit message (not the subject line) indicating that the change was
AI-generated. For example:
```
Fix off-by-one error in element iteration
The loop termination condition was incorrect when processing
IfcRelAggregates relationships.
Generated with the assistance of an AI coding tool.
```
### New Files
New files that are AI-generated must include a comment near the top of the
file indicating this. Use the appropriate comment syntax for the language:
```python
# This file was generated with the assistance of an AI coding tool.
```
```cpp
// This file was generated with the assistance of an AI coding tool.
```
### Pull Requests
Pull requests containing AI-generated code must indicate in the PR description
which parts of the contribution are AI-generated. If the entire PR is
AI-generated, state that clearly. If only specific commits or files are
AI-generated, identify them.
## Pull Request Guidelines
### Scope and Size
- Each pull request should address a **single issue or feature**.
- Do not mix unrelated changes (e.g., bug fixes with refactoring or style
changes) in the same PR.
- Large pull requests should be broken down into **multiple small, standalone
commits** that are each easy to review independently. Rewrite commit history
for this purpose if necessary.
- PRs that are minimal, focused solutions to a specific problem are much more
likely to be accepted.
### What to Avoid
- **Over-engineering**: Do not add features, abstractions, or configurability
beyond what is needed to solve the immediate problem.
- **Scope creep**: Do not make changes to files or code that are not directly
related to the task at hand.
- **Unnecessary additions**: Do not add docstrings, comments, type annotations,
or error handling to code you did not otherwise need to change.
- **Cosmetic changes**: Do not reformat, rename, or reorganize code that is
unrelated to your change.
## Commit Messages
- The **subject line** must be **50 characters or less**.
- Use the **imperative mood** (e.g., "Fix crash in geometry kernel", not
"Fixed crash" or "Fixes crash").
- A commit message can be a single line if the purpose is obvious from the
subject alone.
- Otherwise, add a blank line after the subject followed by a short explanation
of a few lines in the body.
## Code Style
### Python
- **Line length**: 120 characters
- **Formatter**: black
- **Linter**: ruff
- Configuration is in `pyproject.toml`
### C++
- **Standard**: C++17 minimum
- **Formatter**: clang-format (configuration in `.clang-format`)
- **Linter**: clang-tidy (configuration in `.clang-tidy`)
Run linters and formatters **before submitting** your pull request. Do not rely
on CI to catch formatting issues.
## Testing
- Pull requests with test coverage are **much more likely to be merged**.
- If tests are appropriate and feasible for your change, they should be
included.
- Tests are not required for every change (e.g., documentation-only changes),
but the expectation is that testable code changes come with tests.
- Python tests use **pytest** and are located in `test/` or `tests/` directories
within each package under `src/`.
- Run the existing test suite for the package you modified before submitting.
## Architecture Quick Reference
### Directory Structure
- `src/ifcparse/` — C++ IFC file parsing
- `src/ifcgeom/` — C++ geometry processing (OpenCASCADE and CGAL kernels)
- `src/serializers/` — Output format serializers (glTF, Collada, SVG, etc.)
- `src/ifcwrap/` — SWIG Python bindings
- `src/ifcconvert/` — CLI conversion tool
- `src/ifcopenshell-python/` — Python API (`ifcopenshell` package)
- `src/bonsai/` — Blender add-on (GPL-3.0-or-later)
- `src/ifctester/` — IDS model auditing
- `src/ifcpatch/` — IFC file manipulation scripts
- `src/ifcdiff/` — IFC model comparison
- `src/ifcclash/` — Clash detection
- `src/ifccsv/` — Schedule import/export
### IFC Schema Versions
The library supports IFC2x3 TC1, IFC4 Add2 TC1, IFC4x1, IFC4x2, and
IFC4x3 Add2. Schema-specific code is compiled conditionally. Be aware of
which schema versions your change affects.
+2 -2
View File
@@ -3,9 +3,9 @@ name = "IfcOpenShell"
version = "0.0.0"
dependencies = [
"black==26.1.0",
"ruff==0.14.14",
"ruff==0.15.1",
"poethepoet",
"gersemi==0.25.1",
"gersemi==0.25.4",
]
[tool.black]
+22
View File
@@ -0,0 +1,22 @@
# Build Bonsai .zip.
# Usage:
# # While in `bonsai` folder.
# docker build -t bonsai-dist .
# # Output zip file will be in `dist` folder.
# # See possible values for variables in `Makefile`'s `dist` target description.
# docker run --rm -v "$PWD/../../:/work" -w /work -e PLATFORM=win -e PYVERSION=py313 bonsai-dist
FROM ubuntu:24.04
RUN apt-get update
RUN apt-get install -y make git wget unzip zip
# Python
RUN apt-get install -y software-properties-common
RUN add-apt-repository ppa:deadsnakes/ppa
RUN apt-get update
RUN apt-get install -y python3.11 python3.11-venv
RUN apt-get install -y npm
WORKDIR /work
CMD cd src/bonsai && make dist PLATFORM=$PLATFORM PYVERSION=$PYVERSION
+37 -21
View File
@@ -48,18 +48,33 @@ VERSION_PATCH:=$(shell cat '../../VERSION' | cut -d '.' -f 3)
VERSION_DATE:=$(shell date '+%y%m%d')
LAST_COMMIT_HASH:=$(shell git rev-parse HEAD)
LAST_COMMIT_DATE:=$(shell git show -s --format=%cI)
PYVERSION:=py310
PYPI_IMP:=cp
ifeq ($(PYVERSION), py311)
PYLIBDIR:=python3.11
PYNUMBER:=311
PYPI_VERSION:=3.11
ifdef PYVERSION
SUPPORTED_PYVERSIONS := py311 py312 py313
ifeq ($(filter $(PYVERSION),$(SUPPORTED_PYVERSIONS)),)
$(error Unsupported PYVERSION=$(PYVERSION). Must be one of $(SUPPORTED_PYVERSIONS))
endif
PYMINOR:=$(subst py3,,$(PYVERSION))
PYLIBDIR:=python3.$(PYMINOR)
PYNUMBER:=3$(PYMINOR)
PYPI_VERSION:=3.$(PYMINOR)
endif # def PYVERSION
ifdef PLATFORM
SUPPORTED_PLATFORMS := linux macos macosm1 win
ifeq ($(filter $(PLATFORM),$(SUPPORTED_PLATFORMS)),)
$(error Unsupported PLATFORM=$(PLATFORM). Must be one of $(SUPPORTED_PLATFORMS))
endif
ifeq ($(PLATFORM),macos)
ifeq ($(PYVERSION),py313)
$(error Blender 5.1 with Python 3.13 doesn't support intel macOS.)
endif
ifeq ($(PYVERSION), py312)
PYLIBDIR:=python3.12
PYNUMBER:=312
PYPI_VERSION:=3.12
endif
ifeq ($(PLATFORM), linux)
@@ -86,6 +101,8 @@ PYPI_PLATFORM:=--platform win_amd64
BLENDER_PLATFORM:=windows-x64
endif
endif # def PLATFORM
# Current build commit hash.
OLD:=e8eb5e4
.PHONY: bump
@@ -99,7 +116,10 @@ endif
.PHONY: dist
dist:
ifndef PLATFORM
$(error PLATFORM is not set)
$(error PLATFORM is not set. Example values: $(SUPPORTED_PLATFORMS).)
endif
ifndef PYVERSION
$(error PYVERSION is not set. Example values: $(SUPPORTED_PYVERSIONS). )
endif
rm -rf build
mkdir -p build
@@ -125,9 +145,6 @@ endif
cd ../ifc5d && make dist && mv dist/*.whl ../bonsai/build/wheels/
cd ../ifccityjson && make dist && mv dist/*.whl ../bonsai/build/wheels/
cd build && . env/$(VENV_ACTIVATE) && $(PIP) download GitPython --dest=./wheels
# Provides audio playback for costing
# This is a REALLY IMPORTANT feature
cd build && . env/$(VENV_ACTIVATE) && $(PIP) wheel git+https://github.com/zdhoward/aud --wheel-dir=./wheels
# IfcOpenShell dependency - support for new typing features
cd build && . env/$(VENV_ACTIVATE) && $(PIP) download typing_extensions --dest=./wheels
# Required by IfcCSV
@@ -189,7 +206,7 @@ endif
cd build && . env/$(VENV_ACTIVATE) && $(PIP) download tzfpy $(PYPI_PLATFORM) --python-version $(PYPI_VERSION) --implementation $(PYPI_IMP) --only-binary=:all: --dest=./wheels
# pyradiance is using different platform versions than defaults in our makefile.
ifeq ($(PLATFORM), linux)
cd build && . env/$(VENV_ACTIVATE) && $(PIP) download pyradiance --platform manylinux_2_35_x86_64 --python-version $(PYPI_VERSION) --implementation $(PYPI_IMP) --only-binary=:all: --dest=./wheels
cd build && . env/$(VENV_ACTIVATE) && $(PIP) download pyradiance --platform manylinux_2_28_x86_64 --python-version $(PYPI_VERSION) --implementation $(PYPI_IMP) --only-binary=:all: --dest=./wheels
else ifeq ($(PLATFORM), macos)
cd build && . env/$(VENV_ACTIVATE) && $(PIP) download pyradiance --platform macosx_10_13_x86_64 --python-version $(PYPI_VERSION) --implementation $(PYPI_IMP) --only-binary=:all: --dest=./wheels
else
@@ -267,6 +284,11 @@ else
$(SED) 's/version = "0.0.0"/version = "$(VERSION)-alpha$(VERSION_DATE)"/' build/pyproject.toml
endif
# Blender 5.1+ requires Python 3.13.
ifeq ($(PYVERSION), py313)
$(SED) 's/blender_version_min = "4\.2\.0"/blender_version_min = "5.1.0"/' build/bonsai/blender_manifest.toml
endif
$(SED) "s/os-arch/$(BLENDER_PLATFORM)/" build/bonsai/blender_manifest.toml
# Provides bonsai Add-on functionality
@@ -288,12 +310,6 @@ endif
fi
mv build/wheels/*.whl build/bonsai/wheels/
# Temporary workaround for Blender not handling non-3.11 wheels #5743.
# Use '-n' as on macos x86-64 doesn't have a binary wheel and it autoincludes 'cp311'.
prev_whl_name=$$(find build/bonsai/wheels/tzfpy-*.whl); \
whl_name=$$(echo $$prev_whl_name | sed "s/-cp39-/-cp$(PYNUMBER)-/"); \
mv --update=none "$$prev_whl_name" "$$whl_name";
ifneq ($(PLATFORM), linux)
# Safeguard: in case one of `pip download` will break,
# it will produce a linux wheel for non-linux build (our github action machine is using linux).
@@ -355,7 +371,7 @@ test-tool:
ifndef MODULE
pytest test/tool
else
pytest test/tool/test_$(MODULE).py
pytest test/tool/test_$(MODULE).py --maxfail=1
endif
# Reregistering test is not added to the standard test suite because during unregister
-1
View File
@@ -52,7 +52,6 @@ class IfcExporter:
self.set_header()
IfcStore.update_cache()
self.sync_all_objects()
tool.Project.save_linked_models_to_ifc()
extension = self.ifc_export_settings.output_file.split(".")[-1].lower()
if extension == "ifczip":
with tempfile.TemporaryDirectory() as unzipped_path:
+5 -44
View File
@@ -85,7 +85,7 @@ class MaterialCreator:
if element.is_a("IfcTypeProduct"):
self.parse_element_type_material_styles(element)
self.parsed_meshes.add(self.mesh.name)
if not self.ifc_import_settings.load_indexed_maps:
if self.ifc_import_settings.load_indexed_maps:
self.load_texture_maps(shape_has_openings)
self.assign_material_slots_to_faces()
tool.Geometry.record_object_materials(obj)
@@ -117,7 +117,6 @@ class MaterialCreator:
for texture in texture_style.Textures or []:
if coords := getattr(texture, "IsMappedBy", None):
coords = coords[0]
# IfcTextureCoordinateGenerator handled in the style shader graph
if coords.is_a("IfcIndexedTextureMap"):
return coords
# TODO: support IfcTextureMap
@@ -135,6 +134,10 @@ class MaterialCreator:
if shape_has_openings and coords.is_a("IfcIndexedTextureMap"):
continue
tool.Loader.load_indexed_map(coords, self.mesh)
elif tool.Style.get_texture_style(material):
# No explicit coordinate mapping (e.g. IFC2X3 has no IsMappedBy,
# and IFC4 COORD uses generated UVs). Bake XY→UV as fallback.
tool.Loader.load_generated_uv_map(self.mesh)
def assign_material_slots_to_faces(self) -> None:
if not self.mesh["ios_materials"]:
@@ -892,48 +895,6 @@ class IfcImporter:
obj, tool.Loader.apply_blender_offset_to_matrix_world(obj, self.get_element_matrix(element))
)
if element.is_a("IfcAnnotation") and getattr(element, "ObjectType", None) == "IMAGE":
image = None
if obj.data and obj.data.materials and obj.data.materials[0]:
material = obj.data.materials[0]
if material.use_nodes and material.node_tree:
for node in material.node_tree.nodes:
if node.type == "TEX_IMAGE" and node.image:
image = node.image
break
if image:
import bmesh
bm = bmesh.new()
bm.from_mesh(obj.data)
if not bm.loops.layers.uv:
uv_layer = bm.loops.layers.uv.new()
else:
uv_layer = bm.loops.layers.uv.active
if bm.verts:
min_x = min(v.co.x for v in bm.verts)
max_x = max(v.co.x for v in bm.verts)
min_y = min(v.co.y for v in bm.verts)
max_y = max(v.co.y for v in bm.verts)
width = max_x - min_x
height = max_y - min_y
for face in bm.faces:
for loop in face.loops:
vert = loop.vert
u = (vert.co.x - min_x) / width if width > 0 else 0.5
v = (vert.co.y - min_y) / height if height > 0 else 0.5
u = max(0.0, min(1.0, u))
v = max(0.0, min(1.0, v))
loop[uv_layer].uv = (u, v)
bm.to_mesh(obj.data)
bm.free()
obj.data.update()
return obj
def load_existing_meshes(self) -> None:
+2 -2
View File
@@ -163,7 +163,7 @@ class BIMBSDDProperties(PropertyGroup):
default=False,
)
classification_psets: CollectionProperty(name="Classification Psets", type=BSDDPset)
if TYPE_CHECKING:
active_dictionary: str
active_dictionary: str
@@ -182,7 +182,7 @@ class BIMBSDDProperties(PropertyGroup):
should_filter_ifc_class: bool
use_only_ifc_properties: bool
classification_psets: bpy.types.bpy_prop_collection_idprop[BSDDPset]
@property
def active_class(self) -> Union[BSDDClassification, None]:
return tool.Blender.get_active_uilist_element(self.classes, self.active_class_index)
+1 -1
View File
@@ -24,6 +24,7 @@ import bpy
from bpy.types import Panel, UIList
import bonsai.tool as tool
import bsdd
from bonsai.bim.module.bsdd.data import BSDDData
if TYPE_CHECKING:
@@ -83,7 +84,6 @@ class BIM_PT_bsdd(Panel):
row = self.layout.row()
row.operator("bim.load_bsdd_dictionaries")
class BIM_UL_bsdd_dictionaries(UIList):
def draw_item(
self,
@@ -45,6 +45,7 @@ classes = (
operator.CleanWireframes,
operator.ContractSheet,
operator.ConvertSVGToDXF,
operator.CopyTextToSelection,
operator.CreateDrawing,
operator.CreateSheets,
operator.DisableAddAnnotationType,
@@ -115,7 +116,6 @@ classes = (
prop.BIMCameraProperties,
prop.ElementValueRow,
prop.LiteralProps,
prop.LiteralApplySettings,
prop.BIMTextProperties,
prop.BIMAssignedProductProperties,
prop.BIMAnnotationProperties,
+1 -25
View File
@@ -355,8 +355,6 @@ class DecoratorData:
font_size = FONT_SIZES[font_size_type]
symbol = tool.Drawing.get_annotation_symbol(element)
newline_at = pset_data.get("Newline_At", 0)
reverse_list = pset_data.get("Reverse_List", False)
list_separator = pset_data.get("List_Separator") or ", "
# other attributes
literals = tool.Drawing.get_text_literal(obj, return_list=True)
@@ -367,15 +365,10 @@ class DecoratorData:
for literal in literals:
literal_value = literal.Literal
try:
eval_value = cls.evaluate_formatting_expressions(literal_value, product)
current_value = tool.Drawing.replace_text_literal_variables(eval_value, product)
except Exception:
current_value = literal_value
literal_data = {
"Literal": literal_value,
"BoxAlignment": literal.BoxAlignment,
"CurrentValue": current_value,
"CurrentValue": tool.Drawing.replace_text_literal_variables(literal_value, product),
}
literals_data.append(literal_data)
@@ -384,8 +377,6 @@ class DecoratorData:
"FontSize": font_size,
"Symbol": symbol,
"Newline_At": newline_at,
"Reverse_List": reverse_list,
"List_Separator": list_separator,
}
@classmethod
@@ -403,21 +394,6 @@ class DecoratorData:
return element
@classmethod
def evaluate_formatting_expressions(cls, text: str, element=None) -> str:
"""Evaluate formatting expressions wrapped in backticks using ifcopenshell.util.selector.format, always passing element context"""
import re
def evaluate_expression(match):
try:
expression = match.group(1)
result = ifcopenshell.util.selector.format(expression, element)
return str(result)
except Exception as e:
return match.group(0)
return re.sub(r"``([^`]+)``", evaluate_expression, text)
@classmethod
def get_element_value_by_key(cls, element: ifcopenshell.entity_instance, key: str):
"""Get element value by its key using IfcOpenShell selector syntax"""
@@ -997,7 +997,7 @@ class FallDecorator(BaseDecorator):
# generate label text
# same function as in svgwriter.py
def get_label_text():
B, A = [v.co.xyz for v in spline_points[:2]]
B, A = [obj.matrix_world @ v.co.xyz for v in spline_points[:2]]
rise = abs(A.z - B.z)
O = A.copy()
O.z = B.z
@@ -1009,13 +1009,14 @@ class FallDecorator(BaseDecorator):
angle = 90
# uses SLOPE_ANGLE as default
object_type = DecoratorData.data["fall"].get(obj, {}).get("object_type", None)
object_type = DecoratorData.data["fall"].get(obj.name, {}).get("object_type", None)
if object_type in ("FALL", "SLOPE_ANGLE"):
return f"{angle}°"
elif object_type == "SLOPE_FRACTION":
if angle == 90:
return "-"
return f"{self.format_value(context, rise)} / {self.format_value(context, run)}"
segment_length = (B - A).length
return f"{self.format_value(context, rise)} / {self.format_value(context, segment_length)}"
elif object_type == "SLOPE_PERCENT":
if angle == 90:
return "-"
@@ -1762,40 +1763,34 @@ class CutDecorator:
shader.uniform_float("color", color)
batch.draw(shader)
def cache_camera_matrix(self):
def cache_camera_matrix(self) -> None:
assert bpy.context.scene and bpy.context.scene.camera
obj = bpy.context.scene.camera
# Explicit `dtype` for Blender <5.0 compatibility.
DecoratorData.camera_location_checksum = repr(
np.array(obj.matrix_world.translation, dtype=np.float32).tobytes()
tool.Blender.np_array_legacy(obj.matrix_world.translation).tobytes()
)
DecoratorData.camera_rotation_checksum = repr(np.array(obj.matrix_world.to_3x3(), dtype=np.float32).tobytes())
DecoratorData.camera_rotation_checksum = repr(tool.Blender.np_array_legacy(obj.matrix_world.to_3x3()).tobytes())
def is_camera_moved(self):
def is_camera_moved(self) -> bool:
if not DecoratorData.camera_location_checksum:
self.cache_camera_matrix()
return True # Let's be conservative
assert bpy.context.scene and bpy.context.scene.camera
obj = bpy.context.scene.camera
# Handle both old float64 and new float32 checksums for version compatibility
loc_checksum_bytes = eval(DecoratorData.camera_location_checksum)
if len(loc_checksum_bytes) == 24: # Old format: 3 * 8 bytes (float64)
loc_check = np.frombuffer(loc_checksum_bytes, dtype=np.float64).astype(np.float32)
else: # New format: 3 * 4 bytes (float32)
loc_check = np.frombuffer(loc_checksum_bytes, dtype=np.float32)
loc_real = np.array(obj.matrix_world.translation, dtype=np.float32).flatten()
loc_checksum_bytes: bytes = eval(DecoratorData.camera_location_checksum)
loc_check = tool.Blender.np_frombuffer_legacy(loc_checksum_bytes, 3)
loc_real = tool.Blender.np_array_legacy(obj.matrix_world.translation)
if not np.allclose(loc_check, loc_real, atol=1e-4): # 0.1 mm
self.cache_camera_matrix()
return True
# Handle both old float64 and new float32 checksums for version compatibility
rot_checksum_bytes = eval(DecoratorData.camera_rotation_checksum)
if len(rot_checksum_bytes) == 72: # Old format: 9 * 8 bytes (float64)
rot_check = np.frombuffer(rot_checksum_bytes, dtype=np.float64).astype(np.float32).reshape(3, 3)
else: # New format: 9 * 4 bytes (float32)
rot_check = np.frombuffer(rot_checksum_bytes, dtype=np.float32).reshape(3, 3)
rot_real = np.array(obj.matrix_world.to_3x3(), dtype=np.float32)
rot_checksum_bytes: bytes = eval(DecoratorData.camera_rotation_checksum)
rot_check = tool.Blender.np_frombuffer_legacy(rot_checksum_bytes, 9)
rot_real = tool.Blender.np_array_legacy(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))
if angle_rad > 0.0017453292519943296: # 0.1 degrees
+125 -347
View File
@@ -40,6 +40,7 @@ from typing import (
import bmesh
import bpy
import logging
import ifcopenshell
import ifcopenshell.api
import ifcopenshell.api.document
@@ -50,6 +51,7 @@ import ifcopenshell.ifcopenshell_wrapper
import ifcopenshell.util.element
import ifcopenshell.util.representation
import ifcopenshell.util.selector
import ifcopenshell.util.shape_builder
import ifcopenshell.util.unit
import numpy as np
import shapely
@@ -59,6 +61,7 @@ from bpy_extras.io_utils import ImportHelper
from lxml import etree
from mathutils import Color, Matrix, Vector
import bonsai.bim.import_ifc
import bonsai.bim.export_ifc
import bonsai.bim.handler
import bonsai.bim.helper
@@ -138,7 +141,7 @@ class AddAnnotationType(bpy.types.Operator, tool.Ifc.Operator):
element.ApplicableOccurrence = f"IfcAnnotation/{object_type}"
if props.create_representation_for_type and object_type == "IMAGE":
bpy.ops.bim.add_reference_image("INVOKE_DEFAULT", use_existing_object_by_name=obj.name)
bpy.ops.bim.add_reference_image("INVOKE_DEFAULT", existing_object_by_name=obj.name)
class EnableAddAnnotationType(bpy.types.Operator):
@@ -928,7 +931,7 @@ class CreateDrawing(bpy.types.Operator):
props = tool.Project.get_project_props()
for link in props.get_loaded_links_for_drawings():
files[link.name] = self.get_linked_file(link)
files[link.filepath] = self.get_linked_file(link)
target_view = ifcopenshell.util.element.get_psets(self.camera_element)["EPset_Drawing"]["TargetView"]
self.setup_serialiser(target_view)
@@ -1374,7 +1377,7 @@ class CreateDrawing(bpy.types.Operator):
return True
def get_linked_file(self, link: "Link") -> ifcopenshell.file:
link_path = link.name
link_path = link.filepath
ifc_file = IfcStore.session_files.get(link_path, None)
if ifc_file is not None:
return ifc_file
@@ -1759,7 +1762,7 @@ class AddAnnotation(bpy.types.Operator, tool.Ifc.Operator):
enable_editing=True,
)
if props.object_type == "IMAGE":
bpy.ops.bim.add_reference_image("INVOKE_DEFAULT", use_existing_object_by_name=obj.name)
bpy.ops.bim.add_reference_image("INVOKE_DEFAULT", existing_object_by_name=obj.name)
class AddSheet(bpy.types.Operator, tool.Ifc.Operator):
@@ -2896,12 +2899,16 @@ class AddSchedule(bpy.types.Operator, tool.Ifc.Operator, ImportHelper):
bl_options = {"REGISTER", "UNDO"}
bl_description = "Add an .ods, .xls or .xlsx file as a schedule"
files: bpy.props.CollectionProperty(name="Files", type=bpy.types.OperatorFileListElement)
directory: bpy.props.StringProperty(subtype="DIR_PATH")
filter_glob: bpy.props.StringProperty(default="*.ods;*.xls;*.xlsx", options={"HIDDEN"})
use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=True)
def _execute(self, context):
filepath = tool.Ifc.get_uri(self.filepath, use_relative_path=self.use_relative_path)
core.add_document(tool.Ifc, tool.Drawing, "SCHEDULE", uri=filepath)
for filepath in tool.Blender.get_selected_files(
self.directory, self.files, use_relative_path=self.use_relative_path
):
core.add_document(tool.Ifc, tool.Drawing, "SCHEDULE", uri=filepath)
class RemoveSchedule(bpy.types.Operator, tool.Ifc.Operator):
@@ -3090,23 +3097,16 @@ class AddReference(bpy.types.Operator, tool.Ifc.Operator, ImportHelper):
bl_description = "Import a .svg file to the project as a reference"
bl_options = {"REGISTER", "UNDO"}
files: bpy.props.CollectionProperty(name="Files", type=bpy.types.OperatorFileListElement)
directory: bpy.props.StringProperty(subtype="DIR_PATH")
filter_glob: bpy.props.StringProperty(default="*.svg", options={"HIDDEN"})
use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=True)
filename_ext = ".svg"
files: bpy.props.CollectionProperty(type=bpy.types.OperatorFileListElement)
directory: bpy.props.StringProperty(subtype="DIR_PATH")
def _execute(self, context):
# Handle both single and multiple file selection
if self.files:
for file_elem in self.files:
filepath = os.path.join(self.directory, file_elem.name)
uri = tool.Ifc.get_uri(filepath, use_relative_path=self.use_relative_path)
core.add_document(tool.Ifc, tool.Drawing, "REFERENCE", uri=uri)
else:
# Fallback for single file (backward compatibility)
filepath = tool.Ifc.get_uri(self.filepath, use_relative_path=self.use_relative_path)
for filepath in tool.Blender.get_selected_files(
self.directory, self.files, use_relative_path=self.use_relative_path
):
core.add_document(tool.Ifc, tool.Drawing, "REFERENCE", uri=filepath)
@@ -3165,6 +3165,7 @@ class EditTextPopup(bpy.types.Operator):
bpy.ops.bim.disable_editing_text()
def execute(self, context):
# TODO: check for possible subtle undo bug here
# can't use invoke() because this operator
# will be run indirectly by hotkey
# so we use execute() and track whether it's the first run of the operator
@@ -3181,169 +3182,34 @@ class EditText(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.edit_text"
bl_label = "Edit Text"
bl_description = "Save changes to the text annotation and\ndisable the text editing options"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
obj = context.active_object
props = tool.Drawing.get_text_props(obj)
captured_apply_settings = {
"apply_font_size_to_all": props.apply_font_size_to_all,
"apply_newline_to_all": props.apply_newline_to_all,
"font_size": props.font_size,
"newline_at": props.newline_at,
"literals": [],
}
for i, literal in enumerate(props.literals):
literal_data = {
"attributes": [
(attr.string_value, attr.enum_value if attr.data_type == "enum" else attr.string_value)
for attr in literal.attributes
],
"box_alignment": literal.box_alignment[:] if hasattr(literal, "box_alignment") else None,
"element_value_rows": [
{
"category": row.category,
"element_key": row.element_key,
"formatted_value": row.formatted_value,
"separator": row.separator,
}
for row in literal.element_value_rows
],
"product_used": literal.product_used.name if literal.product_used else None,
}
if i < len(props.literal_apply_settings):
apply_settings = props.literal_apply_settings[i]
literal_data["apply_text_to_all"] = apply_settings.apply_text_to_all
literal_data["apply_path_to_all"] = apply_settings.apply_path_to_all
literal_data["apply_box_alignment_to_all"] = apply_settings.apply_box_alignment_to_all
else:
literal_data["apply_text_to_all"] = False
literal_data["apply_path_to_all"] = False
literal_data["apply_box_alignment_to_all"] = False
captured_apply_settings["literals"].append(literal_data)
obj["_bonsai_element_value_rows_backup"] = json.dumps(captured_apply_settings["literals"])
core.edit_text(tool.Drawing, obj=obj)
self.apply_to_selected_objects_with_captured_data(context, obj, captured_apply_settings)
core.edit_text(tool.Drawing, obj=tool.Blender.get_active_object())
tool.Blender.update_viewport()
return {"FINISHED"}
def apply_to_selected_objects(self, context, active_obj, active_props):
"""Apply changes to other selected text objects based on toggle settings"""
selected_objects = [obj for obj in context.selected_objects if obj != active_obj]
class CopyTextToSelection(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.copy_text_to_selection"
bl_label = "Copy Text To Selection"
bl_description = "Copy text formatting or literals to selected objects"
bl_options = {"REGISTER", "UNDO"}
attribute: bpy.props.StringProperty()
for obj in selected_objects:
element = tool.Ifc.get_entity(obj)
if not element or not tool.Drawing.is_annotation_object_type(element, ["TEXT", "TEXT_LEADER"]):
continue
obj_props = tool.Drawing.get_text_props(obj)
needs_update = False
if active_props.apply_font_size_to_all:
obj_props.font_size = active_props.font_size
needs_update = True
if active_props.apply_newline_to_all:
obj_props.newline_at = active_props.newline_at
needs_update = True
for i, active_literal in enumerate(active_props.literals):
if i >= len(obj_props.literals):
continue
obj_props.ensure_literal_apply_settings(len(obj_props.literals))
obj_literal = obj_props.literals[i]
if i < len(active_props.literal_apply_settings):
active_settings = active_props.literal_apply_settings[i]
if active_settings.apply_text_to_all:
if len(active_literal.attributes) > 0 and len(obj_literal.attributes) > 0:
obj_literal.attributes[0].string_value = active_literal.attributes[0].string_value
needs_update = True
if active_settings.apply_path_to_all:
if len(active_literal.attributes) > 1 and len(obj_literal.attributes) > 1:
if (
active_literal.attributes[1].data_type == "enum"
and obj_literal.attributes[1].data_type == "enum"
):
obj_literal.attributes[1].enum_value = active_literal.attributes[1].enum_value
else:
obj_literal.attributes[1].string_value = active_literal.attributes[1].string_value
needs_update = True
if active_settings.apply_box_alignment_to_all:
obj_literal.box_alignment = active_literal.box_alignment[:]
needs_update = True
if needs_update:
core.edit_text(tool.Drawing, obj=obj)
def apply_to_selected_objects_with_captured_data(self, context, active_obj, captured_data):
"""Apply changes to other selected text objects using captured apply settings"""
selected_objects = [obj for obj in context.selected_objects if obj != active_obj]
for obj in selected_objects:
element = tool.Ifc.get_entity(obj)
if not element:
continue
if not tool.Drawing.is_annotation_object_type(element, ["TEXT", "TEXT_LEADER"]):
continue
obj_props = tool.Drawing.get_text_props(obj)
if len(obj_props.literals) == 0:
core.enable_editing_text(tool.Drawing, obj=obj)
obj_props.ensure_literal_apply_settings(len(obj_props.literals))
needs_update = False
if captured_data["apply_font_size_to_all"]:
obj_props.font_size = captured_data["font_size"]
needs_update = True
if captured_data["apply_newline_to_all"]:
obj_props.newline_at = captured_data["newline_at"]
needs_update = True
for i, captured_literal in enumerate(captured_data["literals"]):
if i >= len(obj_props.literals):
continue
obj_literal = obj_props.literals[i]
if captured_literal["apply_text_to_all"]:
if len(captured_literal["attributes"]) > 0 and len(obj_literal.attributes) > 0:
new_value = captured_literal["attributes"][0][0] # [0] = string_value
obj_literal.attributes[0].string_value = new_value
needs_update = True
if captured_literal["apply_path_to_all"]:
if len(captured_literal["attributes"]) > 1 and len(obj_literal.attributes) > 1:
new_value = captured_literal["attributes"][1][1] # [1] = enum_value or string_value
if obj_literal.attributes[1].data_type == "enum":
obj_literal.attributes[1].enum_value = new_value
else:
obj_literal.attributes[1].string_value = new_value
needs_update = True
if captured_literal["apply_box_alignment_to_all"] and captured_literal["box_alignment"]:
obj_literal.box_alignment = captured_literal["box_alignment"]
needs_update = True
if needs_update:
core.edit_text(tool.Drawing, obj=obj)
def _execute(self, context):
apply_objs = [
obj
for obj in tool.Blender.get_selected_objects()
if (element := tool.Ifc.get_entity(obj))
and tool.Drawing.is_annotation_object_type(element, ["TEXT", "TEXT_LEADER"])
]
core.copy_text_to_selection(
tool.Drawing,
attribute=self.attribute,
attribute_obj=tool.Blender.get_active_object(),
apply_objs=apply_objs,
)
tool.Blender.update_viewport()
class EnableEditingText(bpy.types.Operator, tool.Ifc.Operator):
@@ -3358,8 +3224,6 @@ class EnableEditingText(bpy.types.Operator, tool.Ifc.Operator):
props = tool.Drawing.get_text_props(obj)
core.enable_editing_text(tool.Drawing, obj=obj)
props.ensure_literal_apply_settings(len(props.literals))
text_element = tool.Ifc.get_entity(obj)
assigned_product_entity = tool.Drawing.get_assigned_product(text_element) if text_element else None
assigned_product_obj = tool.Ifc.get_object(assigned_product_entity) if assigned_product_entity else None
@@ -3448,9 +3312,6 @@ class AddTextLiteral(bpy.types.Operator):
box_alignment_mask = [False] * 9
box_alignment_mask[6] = True # bottom_left box_alignment
literal_props.box_alignment = box_alignment_mask
props.ensure_literal_apply_settings(len(props.literals))
return {"FINISHED"}
@@ -3469,9 +3330,6 @@ class RemoveTextLiteral(bpy.types.Operator):
props = tool.Drawing.get_text_props(obj)
props.literals.remove(self.literal_prop_id)
tool.Blender.update_viewport()
props.ensure_literal_apply_settings(len(props.literals))
return {"FINISHED"}
@@ -3947,83 +3805,51 @@ class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator, ImportHelper):
use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=True)
filter_image: bpy.props.BoolProperty(default=True, options={"HIDDEN", "SKIP_SAVE"})
filter_folder: bpy.props.BoolProperty(default=True, options={"HIDDEN", "SKIP_SAVE"})
override_existing_image: bpy.props.BoolProperty(
name="Override Existing Image",
default=True,
description=(
"Override image if it was previously loaded to Blender. If disabled, will always create a new image"
),
)
use_existing_object_by_name: bpy.props.StringProperty(
name="Use Existing Object By Name",
description="Existing object name to add a style with reference image to. If not provided will create a new object.",
options={"SKIP_SAVE"},
)
x_length: bpy.props.FloatProperty(
name="X Length",
description="Width of the reference image in project units",
description="Width of the reference image",
default=1.0,
min=0.001,
soft_min=0.01,
precision=3,
unit="LENGTH",
)
y_length: bpy.props.FloatProperty(
name="Y Length",
description="Height of the reference image in project units",
description="Height of the reference image",
default=1.0,
min=0.001,
soft_min=0.01,
precision=3,
unit="LENGTH",
)
show_texture_solid_mode: bpy.props.BoolProperty(
name="Show Texture in Solid mode (slow)",
description="Show Texture in Solid mode (slow)",
default=False,
)
show_dimensions_dialog: bpy.props.BoolProperty(default=False, options={"HIDDEN", "SKIP_SAVE"})
def draw(self, context):
layout = self.layout
if getattr(self, "show_dimensions_dialog", False):
if tool.Ifc.get():
length_unit = ifcopenshell.util.unit.get_project_unit(tool.Ifc.get(), "LENGTHUNIT")
if length_unit:
unit_name = ifcopenshell.util.unit.get_full_unit_name(length_unit).lower()
else:
unit_name = "project units"
layout.label(text=f"Set Reference Image Dimensions (in {unit_name}):")
else:
layout.label(text="Set Reference Image Dimensions (in project units):")
layout.separator()
layout.prop(self, "x_length")
layout.prop(self, "y_length")
else:
if Path(tool.Ifc.get_path()).is_file():
layout.prop(self, "use_relative_path")
else:
self.use_relative_path = False
layout.label(text="Save the .ifc file first ")
layout.label(text="to use relative paths.")
layout.prop(self, "override_existing_image")
layout.prop(self, "use_existing_object_by_name")
@classmethod
def poll(cls, context):
if not tool.Ifc.get():
cls.poll_message_set("No IFC project is loaded.")
return False
return True
def invoke(self, context, event):
if not getattr(self, "show_dimensions_dialog", False):
context.window_manager.fileselect_add(self)
return {"RUNNING_MODAL"}
else:
return context.window_manager.invoke_props_dialog(self)
self._last_filepath = ""
return super().invoke(context, event)
def check(self, context):
if not hasattr(self, "_last_filepath"):
self._last_filepath = ""
if self.filepath and self.filepath != self._last_filepath:
self._last_filepath = self.filepath
def execute(self, context):
if not getattr(self, "show_dimensions_dialog", False):
abs_path = Path(self.filepath).absolute().resolve()
if self.override_existing_image:
params = {"check_existing": True, "force_reload": True}
else:
params = {"check_existing": False}
try:
image = load_image(abs_path.name, str(abs_path.parent), **params)
if abs_path.exists() and abs_path.is_file():
image = load_image(abs_path.name, str(abs_path.parent), check_existing=False)
image_width_px = image.size[0]
image_height_px = image.size[1]
aspect_ratio = image_width_px / image_height_px
@@ -4036,17 +3862,24 @@ class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator, ImportHelper):
self.y_length = 1.0
bpy.data.images.remove(image)
return True
except Exception as e:
self.report({"ERROR"}, f"Failed to load image: {str(e)}")
return {"CANCELLED"}
return False
self.show_dimensions_dialog = True
return context.window_manager.invoke_props_dialog(self)
def draw(self, context):
layout = self.layout
if Path(tool.Ifc.get_path()).is_file():
layout.prop(self, "use_relative_path")
else:
self.use_relative_path = False
layout.prop(self, "show_texture_solid_mode")
layout.prop(self, "x_length")
layout.prop(self, "y_length")
return self._execute(context)
def _execute(self, context):
project_props = tool.Project.get_project_props()
project_props.load_indexed_maps = self.show_texture_solid_mode
space = tool.Blender.get_view3d_space()
if space.shading.color_type != "TEXTURE":
space.shading.color_type = "TEXTURE"
@@ -4059,121 +3892,66 @@ class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator, ImportHelper):
image_filepath = Path(tool.Ifc.get_uri(self.filepath, use_relative_path=self.use_relative_path))
ifc_file = tool.Ifc.get()
if self.override_existing_image:
params = {"check_existing": True, "force_reload": True}
else:
params = {"check_existing": False}
params = {"check_existing": False}
image = load_image(abs_path.name, str(abs_path.parent), **params)
def bm_add_image_plane(mesh):
bm = tool.Blender.get_bmesh_for_mesh(mesh, clean=True)
mesh = bpy.data.meshes.new(image_filepath.stem)
obj = bpy.data.objects.new(image_filepath.stem, mesh)
element = tool.Drawing.run_root_assign_class(
obj=obj, ifc_class="IfcAnnotation", predefined_type="IMAGE", should_add_representation=False
)
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file)
plane_scale = Vector((self.x_length * unit_scale / 2.0, self.y_length * unit_scale / 2.0, 1.0))
matrix = Matrix.LocRotScale(None, None, plane_scale)
bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=1, matrix=matrix, calc_uvs=False)
builder = ifcopenshell.util.shape_builder.ShapeBuilder(ifc_file)
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file)
hx = self.x_length * 0.5 / unit_scale
hy = self.y_length * 0.5 / unit_scale
verts = [(-hx, -hy, 0.0), ( hx, -hy, 0.0), ( hx, hy, 0.0), (-hx, hy, 0.0)]
item = builder.mesh(verts, [[0, 1, 2, 3]])
if not bm.loops.layers.uv:
uv_layer = bm.loops.layers.uv.new()
else:
uv_layer = bm.loops.layers.uv.active
ifc_context = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW")
representation = builder.get_representation(ifc_context, [item])
ifcopenshell.api.geometry.assign_representation(ifc_file, element, representation)
min_x = min(v.co.x for v in bm.verts)
max_x = max(v.co.x for v in bm.verts)
min_y = min(v.co.y for v in bm.verts)
max_y = max(v.co.y for v in bm.verts)
width = max_x - min_x
height = max_y - min_y
for face in bm.faces:
for loop in face.loops:
vert = loop.vert
u = (vert.co.x - min_x) / width if width > 0 else 0.5
v = (vert.co.y - min_y) / height if height > 0 else 0.5
u = max(0.0, min(1.0, u))
v = max(0.0, min(1.0, v))
loop[uv_layer].uv = (u, v)
tool.Blender.apply_bmesh(mesh, bm)
if self.use_existing_object_by_name:
obj = bpy.data.objects[self.use_existing_object_by_name]
bm_add_image_plane(obj.data)
bpy.ops.bim.update_representation(obj=obj.name, ifc_representation_class="")
else:
temp_mesh = bpy.data.meshes.new("temp_mesh")
bm_add_image_plane(temp_mesh)
obj = bpy.data.objects.new(image_filepath.stem, temp_mesh)
tool.Drawing.run_root_assign_class(
obj=obj,
ifc_class="IfcAnnotation",
predefined_type="IMAGE",
should_add_representation=True,
context=ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW"),
ifc_representation_class=None,
)
tool.Blender.remove_data_block(temp_mesh)
element = tool.Ifc.get_entity(obj)
if element and isinstance(obj.data, bpy.types.Mesh):
representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
if representation and representation.Items:
item_id = representation.Items[0].id()
num_faces = len(obj.data.polygons)
obj.data["ios_item_ids"] = [item_id] * num_faces
tool.Blender.Attribute.fill_attribute(obj.data, "ios_item_ids", "FACE", "INT", [item_id] * num_faces)
for item in representation.Items:
if item.is_a("IfcPolygonalFaceSet") and item.Coordinates:
new_coords = []
for vertex in obj.data.vertices:
co = obj.matrix_world @ vertex.co
new_coords.append([co.x, co.y, co.z])
item.Coordinates.CoordList = new_coords
tool.Blender.set_active_object(obj)
material = bpy.data.materials.new(name=image_filepath.stem)
obj.data.materials.append(None) # new slot
obj.material_slots[0].material = material
bpy.ops.bim.add_style()
style = tool.Ifc.get_entity(material)
assert style
tool.Style.assign_style_to_object(style, obj)
style = ifcopenshell.api.style.add_style(tool.Ifc.get(), name=image_filepath.stem)
ifcopenshell.api.style.assign_representation_styles(
ifc_file, shape_representation=representation, styles=[style]
)
# TODO: IfcSurfaceStyleRendering is unnecessary here, added it only because
# we don't support IfcSurfaceStyleWithTextures without Rendering yet
shading_attributes = {
"SurfaceColour": {
"Red": 1.0,
"Green": 1.0,
"Blue": 1.0,
},
"SurfaceColour": {"Red": 1.0, "Green": 1.0, "Blue": 1.0},
"Transparency": 0.0,
"ReflectanceMethod": "NOTDEFINED",
}
ifcopenshell.api.style.add_surface_style(
tool.Ifc.get(),
style=style,
ifc_class="IfcSurfaceStyleRendering",
attributes=shading_attributes,
tool.Ifc.get(), style=style, ifc_class="IfcSurfaceStyleRendering", attributes=shading_attributes
)
texture = ifc_file.create_entity("IfcImageTexture", Mode="DIFFUSE", URLReference=image_filepath.as_posix())
textures = [texture]
ifc_file.create_entity("IfcTextureCoordinateGenerator", Maps=textures, Mode="COORD") # UV map
ifcopenshell.api.style.add_surface_style(
ifc_file,
style=style,
ifc_class="IfcSurfaceStyleWithTextures",
attributes={"Textures": textures},
)
tool.Style.reload_material_from_ifc(material)
tool.Geometry.record_object_materials(obj)
return {"FINISHED"}
if tool.Ifc.get_schema() == "IFC2X3":
texture = ifc_file.create_entity(
"IfcImageTexture",
RepeatS=True,
RepeatT=True,
TextureType="TEXTURE",
UrlReference=image_filepath.as_posix(),
)
else:
texture = ifc_file.create_entity("IfcImageTexture", Mode="DIFFUSE", URLReference=image_filepath.as_posix())
ifc_file.create_entity("IfcTextureCoordinateGenerator", Maps=[texture], Mode="COORD")
textures = [texture]
ifcopenshell.api.style.add_surface_style(
ifc_file, style=style, ifc_class="IfcSurfaceStyleWithTextures", attributes={"Textures": textures}
)
logger = logging.getLogger("ImportIFC")
ifc_import_settings = bonsai.bim.import_ifc.IfcImportSettings.factory(bpy.context, None, logger)
ifc_importer = bonsai.bim.import_ifc.IfcImporter(ifc_import_settings)
ifc_importer.file = tool.Ifc.get()
ifc_importer.create_style(style)
bonsai.core.geometry.switch_representation(tool.Ifc, tool.Geometry, obj=obj, representation=representation)
class ConvertSVGToDXF(bpy.types.Operator):
@@ -5056,7 +4834,7 @@ class ShowCategoryHelp(bpy.types.Operator):
class AddElementValueRow(bpy.types.Operator):
bl_idname = "bim.add_element_value_row"
bl_label = "Add Element"
bl_label = "Add Element Value Row"
bl_description = "Add a new element value row"
bl_options = {"REGISTER", "UNDO"}
+30 -52
View File
@@ -872,22 +872,20 @@ class LiteralProps(PropertyGroup):
category_for_adding: str
class LiteralApplySettings(PropertyGroup):
literal_index: IntProperty(name="Literal Index")
apply_text_to_all: BoolProperty(name="Apply Text to All", default=False)
apply_path_to_all: BoolProperty(name="Apply Path to All", default=False)
apply_box_alignment_to_all: BoolProperty(name="Apply Box Alignment to All", default=False)
if TYPE_CHECKING:
literal_index: int
apply_text_to_all: bool
apply_path_to_all: bool
apply_box_alignment_to_all: bool
class BIMTextProperties(PropertyGroup):
is_editing: BoolProperty(name="Is Editing", default=False)
literals: CollectionProperty(name="Literals", type=LiteralProps)
newline_at: IntProperty(name="Newline At")
symbol: EnumProperty( # pyright: ignore[reportRedeclaration]
name="Symbol",
description="Symbol from symbols.svg to use for this text.",
items=[(s, s, "") for s in ["NO SYMBOL", "CUSTOM SYMBOL"] + tool.Drawing.DEFAULT_SYMBOLS],
default="NO SYMBOL",
)
custom_symbol: StringProperty( # pyright: ignore[reportRedeclaration]
name="Custom Symbol",
description="Non-default symbol to use for this text.",
)
font_size: EnumProperty(
items=[
("1.8", "1.8 - Small", ""),
@@ -899,52 +897,34 @@ class BIMTextProperties(PropertyGroup):
default="2.5",
name="Font Size",
)
newline_at: IntProperty(name="Newline At")
reverse_list: BoolProperty(name="Reverse List", description="Reverses the order of any list.", default=False)
list_separator: StringProperty( # pyright: ignore[reportRedeclaration]
name="List Separator",
description="Text used to separate lists. Uses a comma (, ) if empty.",
align_horizontal: EnumProperty(
items=[
("left", "Left", "", "ALIGN_LEFT", 0),
("middle", "Middle", "", "ALIGN_CENTER", 1),
("right", "Right", "", "ALIGN_RIGHT", 2),
],
default="left",
name="Horizontal Alignment",
)
symbol: EnumProperty( # pyright: ignore[reportRedeclaration]
name="Symbol",
description="Symbol from symbols.svg to use for this text.",
items=[(s, s, "") for s in ["NO SYMBOL", "CUSTOM SYMBOL"] + tool.Drawing.DEFAULT_SYMBOLS],
default="NO SYMBOL",
align_vertical: EnumProperty(
items=[
("top", "Top", "", "ALIGN_TOP", 0),
("middle", "Middle", "", "ALIGN_MIDDLE", 1),
("bottom", "Bottom", "", "ALIGN_BOTTOM", 2),
],
default="middle",
name="Vertical Alignment",
)
custom_symbol: StringProperty( # pyright: ignore[reportRedeclaration]
name="Custom Symbol",
description="Non-default symbol to use for this text.",
)
apply_font_size_to_all: BoolProperty(
name="Apply Font Size to All", description="Apply font size changes to all selected text objects", default=False
)
apply_newline_to_all: BoolProperty(
name="Apply Newline to All", description="Apply newline changes to all selected text objects", default=False
)
literal_apply_settings: CollectionProperty(name="Literal Apply Settings", type=LiteralApplySettings)
def ensure_literal_apply_settings(self, literal_count: int):
"""Ensure we have apply settings for all literals"""
while len(self.literal_apply_settings) > literal_count:
self.literal_apply_settings.remove(len(self.literal_apply_settings) - 1)
while len(self.literal_apply_settings) < literal_count:
setting = self.literal_apply_settings.add()
setting.literal_index = len(self.literal_apply_settings) - 1
if TYPE_CHECKING:
is_editing: bool
literals: bpy.types.bpy_prop_collection_idprop[LiteralProps]
font_size: str
newline_at: int
reverse_list: bool
list_separator: str
symbol: Union[str, Literal["NO SYMBOL", "CUSTOM SYMBOL"]]
custom_symbol: str
apply_font_size_to_all: bool
apply_newline_to_all: bool
font_size: str
align_horizontal: str
align_vertical: str
def get_symbol(self) -> Union[str, None]:
if self.symbol == "NO SYMBOL":
@@ -979,8 +959,6 @@ class BIMTextProperties(PropertyGroup):
"FontSize": float(self.font_size),
"Newline_At": int(self.newline_at),
"Symbol": self.get_symbol(),
"Reverse_List": self.reverse_list,
"List_Separator": self.list_separator or ", ",
}
return text_data
@@ -989,11 +989,6 @@ class SvgWriter:
symbol = tool.Drawing.get_annotation_symbol(element)
newline_at = tool.Drawing.get_newline_at(element)
# Get reverse_list and list_separator from EPset_Annotation
pset_data = ifcopenshell.util.element.get_pset(element, "EPset_Annotation") or {}
reverse_list = pset_data.get("Reverse_List", False)
list_separator = pset_data.get("List_Separator") or ", "
template_text_fields = []
if symbol:
symbol_transform = self.get_symbol_transform(text_position_svg_str, angle, text_obj)
@@ -1010,7 +1005,7 @@ class SvgWriter:
# NOTE: zip makes sure that we iterate over the shortest list
for field, text_literal in zip(template_text_fields, text_literals):
field.text = tool.Drawing.replace_text_literal_variables(
text_literal.Literal, product or element, reverse_list, list_separator
text_literal.Literal, product or element
)
field.attrib["class"] = classes_str
@@ -1030,9 +1025,7 @@ class SvgWriter:
line_number = 0
for text_literal in text_literals:
text = tool.Drawing.replace_text_literal_variables(
text_literal.Literal, product or element, reverse_list, list_separator
)
text = tool.Drawing.replace_text_literal_variables(text_literal.Literal, product or element)
text_segments = parse_markdown_it(text)
@@ -1453,7 +1446,7 @@ class SvgWriter:
# generate label text
# same function as in decoration.py
def get_label_text():
B, A = [v.co.xyz for v in points[:2]]
B, A = [matrix_world @ v.co.xyz for v in points[:2]]
rise = abs(A.z - B.z)
O = A.copy()
O.z = B.z
+61 -78
View File
@@ -89,7 +89,7 @@ class BIM_PT_camera(Panel):
for link in links:
row = panel.row(align=True)
split = row.split(factor=0.9)
split.label(text=link.name, icon="FILE")
split.label(text=link.filepath, icon="FILE")
split.prop(link, "include_in_drawings", text="")
else:
panel.label(text="No IFC projects linked and loaded.")
@@ -644,58 +644,56 @@ class BIM_PT_text(Panel):
row.operator("bim.add_text_literal", icon="ADD", text="Add Literal")
else:
row.operator("bim.edit_text", icon="CHECKMARK")
row.operator("bim.add_text_literal", icon="ADD", text="")
row.operator("bim.disable_editing_text", icon="CANCEL", text="")
row = self.layout.row(align=True)
row.prop(props, "font_size")
row.prop(props, "apply_font_size_to_all", text="", icon="COPYDOWN")
row.operator("bim.copy_text_to_selection", text="", icon="COPYDOWN").attribute = "FONT_SIZE"
row = self.layout.row()
row.label(text="Alignment")
row.prop(props, "align_horizontal", text="", expand=True)
row.prop(props, "align_vertical", text="", expand=True)
row.operator("bim.copy_text_to_selection", text="", icon="COPYDOWN").attribute = "ALIGNMENT"
row = self.layout.row(align=True)
row.prop(props, "newline_at")
row = self.layout.row(align=True)
row.prop(props, "reverse_list")
row = self.layout.row(align=True)
row.prop(props, "list_separator")
row.operator("bim.copy_text_to_selection", text="", icon="COPYDOWN").attribute = "WRAP_LENGTH"
row = self.layout.row(align=True)
row.prop(props, "symbol")
if props.symbol == "CUSTOM SYMBOL":
row = self.layout.row(align=True)
row.prop(props, "custom_symbol", text="")
row.prop(props, "apply_newline_to_all", text="", icon="COPYDOWN")
select_op = row.operator("bim.select_similar_text_literal_value", text="", icon="RESTRICT_SELECT_OFF")
select_op.literal_value = str(props.newline_at)
select_op.attribute_type = "newline"
row.operator("bim.copy_text_to_selection", text="", icon="COPYDOWN").attribute = "SYMBOL"
row = self.layout.row(align=True)
row.label(text="Literals:")
row.operator("bim.copy_text_to_selection", text="", icon="COPYDOWN").attribute = "LITERALS"
row.operator("bim.add_text_literal", icon="ADD", text="")
for i, literal_props in enumerate(props.literals):
box = self.layout.box()
row = box.row(align=True)
row.label(text=f"Literal[{i}]:")
if i > 0:
row.operator("bim.order_text_literal_up", icon="TRIA_UP", text="").literal_prop_id = i
if i < len(props.literals) - 1:
row.operator("bim.order_text_literal_down", icon="TRIA_DOWN", text="").literal_prop_id = i
row.operator("bim.remove_text_literal", icon="X", text="").literal_prop_id = i
if len(literal_props.attributes) > 0 and i < len(props.literal_apply_settings):
if len(literal_props.attributes):
row = box.row(align=True)
bonsai.bim.helper.draw_attribute(literal_props.attributes[0], row, enable_search=True)
if i > 0:
row.operator("bim.order_text_literal_up", icon="TRIA_UP", text="").literal_prop_id = i
if i < len(props.literals) - 1:
row.operator("bim.order_text_literal_down", icon="TRIA_DOWN", text="").literal_prop_id = i
row.operator("bim.remove_text_literal", icon="X", text="").literal_prop_id = i
expand_icon = "DOWNARROW_HLT" if getattr(literal_props, "show_element_values", False) else "RIGHTARROW"
op = row.operator("bim.toggle_element_values_panel", icon=expand_icon, text="")
op.literal_prop_id = i
row.prop(props.literal_apply_settings[i], "apply_text_to_all", text="", icon="COPYDOWN")
element = tool.Ifc.get_entity(obj)
assigned_element = tool.Drawing.get_assigned_product(element) or element
resolved_value = tool.Drawing.replace_text_literal_variables(
literal_props.attributes[0].string_value,
assigned_element,
props.reverse_list,
props.list_separator,
)
row = box.row(align=True)
row.label(text="CurrentValue:")
@@ -779,8 +777,6 @@ class BIM_PT_text(Panel):
else:
row.prop(attr, "string_value", text="Path")
select_value = attr.string_value
if i < len(props.literal_apply_settings):
row.prop(props.literal_apply_settings[i], "apply_path_to_all", text="", icon="COPYDOWN")
other_attributes = [a for a in literal_props.attributes[2:] if a.name != "BoxAlignment"]
if other_attributes:
@@ -800,10 +796,6 @@ class BIM_PT_text(Panel):
col = row.column(align=True)
alignment_label_row = col.row(align=True)
alignment_label_row.label(text=" Text box alignment:")
if i < len(props.literal_apply_settings):
alignment_label_row.prop(
props.literal_apply_settings[i], "apply_box_alignment_to_all", text="", icon="COPYDOWN"
)
box_alignment_value = (
literal_props.attributes[
@@ -824,61 +816,52 @@ class BIM_PT_text(Panel):
props = tool.Drawing.get_text_props(obj)
if props.is_editing:
self.draw_text_editing_ui(context)
else:
text_data = DecoratorData.get_text_data(obj)
return self.draw_text_editing_ui(context)
text_data = DecoratorData.get_text_data(obj)
row = self.layout.row()
row.operator("bim.enable_editing_text", icon="GREASEPENCIL")
row = self.layout.row()
row.operator("bim.enable_editing_text", icon="GREASEPENCIL")
row = self.layout.row(align=True)
row.label(text="FontSize")
click_op = row.operator(
"bim.select_similar_text_literal_value", text=str(text_data["FontSize"]), emboss=False
)
click_op.literal_value = str(text_data["FontSize"])
click_op.attribute_type = "font_size"
click_op.display_text = str(text_data["FontSize"])
row = self.layout.row(align=True)
row.label(text="FontSize")
click_op = row.operator("bim.select_similar_text_literal_value", text=str(text_data["FontSize"]), emboss=False)
click_op.literal_value = str(text_data["FontSize"])
click_op.attribute_type = "font_size"
click_op.display_text = str(text_data["FontSize"])
row = self.layout.row(align=True)
row.label(text="Newline_At")
click_op = row.operator(
"bim.select_similar_text_literal_value", text=str(text_data["Newline_At"]), emboss=False
)
click_op.literal_value = str(text_data["Newline_At"])
click_op.attribute_type = "newline"
click_op.display_text = str(text_data["Newline_At"])
row = self.layout.row(align=True)
row.label(text="Reverse_List")
row.label(text=str(text_data["Reverse_List"]))
row = self.layout.row(align=True)
row.label(text="List_Separator")
row.label(text=str(text_data["List_Separator"]))
row = self.layout.row(align=True)
row.label(text="Newline_At")
click_op = row.operator(
"bim.select_similar_text_literal_value", text=str(text_data["Newline_At"]), emboss=False
)
click_op.literal_value = str(text_data["Newline_At"])
click_op.attribute_type = "newline"
click_op.display_text = str(text_data["Newline_At"])
for i, literal_data in enumerate(text_data["Literals"]):
box = self.layout.box()
box.label(text=f"Literal[{i}]:")
for i, literal_data in enumerate(text_data["Literals"]):
box = self.layout.box()
box.label(text=f"Literal[{i}]:")
# Combine both approaches: clickable attributes from PR #7292 and display from PR #7106
for attribute in literal_data:
row = box.row(align=True)
row.label(text=attribute)
click_op = row.operator(
"bim.select_similar_text_literal_value",
text=str(literal_data[attribute]),
emboss=False,
)
click_op.literal_value = str(literal_data[attribute])
click_op.literal_index = i
if attribute == "Literal":
click_op.attribute_type = "literal"
elif attribute == "Path":
click_op.attribute_type = "path"
elif attribute == "BoxAlignment":
click_op.attribute_type = "box_alignment"
else:
click_op.attribute_type = "text"
click_op.display_text = str(literal_data[attribute])
# Combine both approaches: clickable attributes from PR #7292 and display from PR #7106
for attribute in literal_data:
row = box.row(align=True)
row.label(text=attribute)
click_op = row.operator(
"bim.select_similar_text_literal_value",
text=str(literal_data[attribute]),
emboss=False,
)
click_op.literal_value = str(literal_data[attribute])
click_op.literal_index = i
if attribute == "Literal":
click_op.attribute_type = "literal"
elif attribute == "Path":
click_op.attribute_type = "path"
elif attribute == "BoxAlignment":
click_op.attribute_type = "box_alignment"
else:
click_op.attribute_type = "text"
click_op.display_text = str(literal_data[attribute])
class BIM_UL_drawinglist(bpy.types.UIList):
@@ -902,6 +902,7 @@ class OverrideDelete(bpy.types.Operator):
if not is_valid_data_block:
continue
element = tool.Ifc.get_entity(obj)
if element:
if tool.Geometry.is_locked(element):
@@ -912,6 +913,9 @@ class OverrideDelete(bpy.types.Operator):
if ifcopenshell.util.element.get_pset(element, "BBIM_Array"):
self.report({"INFO"}, "Elements that are part of an array cannot be deleted.")
continue
if element.is_a("IfcDocumentReference"):
self.report({"INFO"}, "Linked models cannot be deleted.")
continue
if element.is_a("IfcGridAxis"):
# Deleting the last W axis is OK
if ((grid := element.PartOfU) and len(grid[0].UAxes) == 1) or (
@@ -1208,6 +1212,11 @@ class OverrideDuplicateMove(bpy.types.Operator):
operator.report({"ERROR"}, f"Drawing '{obj.name}' not duplicated.")
continue
if element.is_a("IfcDocumentReference"):
objects_to_remove.add(obj)
operator.report({"ERROR"}, f"Linked model '{obj.name}' not duplicated.")
continue
if tool.Geometry.is_locked(element):
objects_to_remove.add(obj)
operator.report({"ERROR"}, lock_error_message(obj.name))
@@ -51,7 +51,7 @@ class RemoveGeoreferencing(bpy.types.Operator, tool.Ifc.Operator):
bl_description = "Remove the georeferencing"
def _execute(self, context):
core.remove_georeferencing(tool.Ifc)
core.remove_georeferencing(tool.Ifc, tool.Georeference)
class EditGeoreferencing(bpy.types.Operator, tool.Ifc.Operator):
@@ -224,16 +224,12 @@ class BIMGeoreferenceProperties(PropertyGroup):
x_axis_ordinate: StringProperty(name="X Axis Ordinate", update=update_grid_north_vector)
x_axis_is_null: BoolProperty(name="X Axis Is Null")
# These are only for reference to capture data about a host model from a linked model
# If you relink a model from a new host origin, we can autodetect it in theory with this
host_model_origin: StringProperty(name="Host Model Origin")
host_model_origin_si: StringProperty(name="Host Model Origin SI")
host_model_project_north: StringProperty(name="Host Model Angle to Grid North")
# This is the ENH in project units and SI units of the Blender session's 0,0,0.
# These are only for reference, using tool.Georeference.set_model_origin on
# project load, project create, and when linking for the first time from an
# empty Blender session.
model_is_georeferenced: BoolProperty(name="Model Is Georeferenced")
model_crs: StringProperty(name="Model CRS")
model_origin: StringProperty(name="Model Origin")
model_origin_si: StringProperty(name="Model Origin SI")
model_project_north: StringProperty(name="Model Angle to Grid North")
@@ -275,10 +271,6 @@ class BIMGeoreferenceProperties(PropertyGroup):
x_axis_ordinate: str
x_axis_is_null: bool
host_model_origin: str
host_model_origin_si: str
host_model_project_north: str
model_origin: str
model_origin_si: str
model_project_north: str
@@ -50,9 +50,10 @@ classes = (
array.EditArray,
array.EnableEditingArray,
array.ApplyArray,
array.RegenerateArray,
array.RemoveArray,
array.SelectArrayParent,
array.SelectAllArrayObjects,
array.SelectArrayParent,
array.Input3DCursorXArray,
array.Input3DCursorYArray,
array.Input3DCursorZArray,
+38 -21
View File
@@ -61,7 +61,6 @@ class AddArray(bpy.types.Operator, tool.Ifc.Operator):
"y": 0.0,
"z": 0.0,
"use_local_space": True,
"sync_children": False,
"method": "OFFSET",
}
@@ -80,28 +79,27 @@ class AddArray(bpy.types.Operator, tool.Ifc.Operator):
pset=pset,
properties={"Parent": element.GlobalId, "Data": ifc_file.create_entity("IfcText", json.dumps(data))},
)
return {"FINISHED"}
class DisableEditingArray(bpy.types.Operator, tool.Ifc.Operator):
class DisableEditingArray(bpy.types.Operator):
bl_idname = "bim.disable_editing_array"
bl_label = "Disable Editing Array"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
def execute(self, context):
obj = context.active_object
assert obj
tool.Model.get_array_props(obj).is_editing = -1
return {"FINISHED"}
class EnableEditingArray(bpy.types.Operator, tool.Ifc.Operator):
class EnableEditingArray(bpy.types.Operator):
bl_idname = "bim.enable_editing_array"
bl_label = "Enable Editing Array"
bl_options = {"REGISTER", "UNDO"}
item: bpy.props.IntProperty()
def _execute(self, context):
def execute(self, context):
obj = context.active_object
assert obj
element = tool.Ifc.get_entity(obj)
@@ -122,11 +120,9 @@ class EnableEditingArray(bpy.types.Operator, tool.Ifc.Operator):
props.y = data["y"] * si_conversion
props.z = data["z"] * si_conversion
props.use_local_space = data.get("use_local_space", False)
props.sync_children = data.get("sync_children", False)
props.method = data.get("method", "OFFSET")
props.is_editing = self.item
return {"FINISHED"}
@@ -151,31 +147,25 @@ class EditArray(bpy.types.Operator, tool.Ifc.Operator):
"y": props.y / si_conversion,
"z": props.z / si_conversion,
"use_local_space": props.use_local_space,
"sync_children": props.sync_children,
"method": props.method,
}
props.is_editing = -1
try:
parent = tool.Ifc.get_object(tool.Ifc.get().by_guid(pset["Parent"]))
parent_element = tool.Ifc.get().by_guid(pset["Parent"])
parent = tool.Ifc.get_object(parent_element)
except:
return {"FINISHED"}
tool.Blender.Modifier.Array.remove_constraints(parent_element)
tool.Model.regenerate_array(parent, data)
pset = tool.Ifc.get().by_id(pset["id"])
data = tool.Ifc.get().createIfcText(json.dumps(data))
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Data": data})
tool.Blender.Modifier.Array.set_children_lock_state(element, self.item, True)
tool.Blender.Modifier.Array.constrain_children_to_parent(element)
# clears the relating_array_object so it doesn't show again next time
props.relating_array_object = None
return {"FINISHED"}
class ApplyArray(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.apply_array"
@@ -192,6 +182,33 @@ class ApplyArray(bpy.types.Operator, tool.Ifc.Operator):
return {"FINISHED"}
class RegenerateArray(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.regenerate_array"
bl_label = "Regenerate Array"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
obj = context.active_object
element = tool.Ifc.get_entity(obj)
pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
try:
parent_element = tool.Ifc.get().by_guid(pset["Parent"])
parent = tool.Ifc.get_object(parent_element)
except:
return {"FINISHED"}
pset = ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array")
arrays = json.loads(pset["Data"])
pset = tool.Ifc.get().by_id(pset["id"])
for array in arrays:
for child in set(array["children"]):
if child_obj := tool.Ifc.get_object(tool.Ifc.get().by_guid(child)):
tool.Geometry.delete_ifc_object(child_obj)
array["children"].clear()
print("cleared array", arrays)
tool.Model.regenerate_array(obj, arrays)
tool.Blender.Modifier.Array.constrain_children_to_parent(element)
class RemoveArray(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.remove_array"
bl_label = "Remove Array"
@@ -216,7 +233,8 @@ class RemoveArray(bpy.types.Operator, tool.Ifc.Operator):
props.is_editing = -1
try:
parent = tool.Ifc.get_object(tool.Ifc.get().by_guid(pset["Parent"]))
parent_element = tool.Ifc.get().by_guid(pset["Parent"])
parent = tool.Ifc.get_object(parent_element)
except:
return {"FINISHED"}
@@ -226,9 +244,10 @@ class RemoveArray(bpy.types.Operator, tool.Ifc.Operator):
if not self.keep_objs:
data[self.item]["count"] = 1
tool.Blender.Modifier.Array.remove_constraints(parent_element)
tool.Model.regenerate_array(parent, data, array_layers_to_apply=[self.item] if self.keep_objs else [])
pset = tool.Ifc.get().by_id(pset["id"])
pset = tool.Pset.get_element_pset(element, "BBIM_Array")
if len(data) == 1:
ifcopenshell.api.pset.remove_pset(tool.Ifc.get(), product=element, pset=pset)
else:
@@ -237,8 +256,6 @@ class RemoveArray(bpy.types.Operator, tool.Ifc.Operator):
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Data": data})
tool.Blender.Modifier.Array.constrain_children_to_parent(element)
return {"FINISHED"}
class SelectArrayParent(bpy.types.Operator):
bl_idname = "bim.select_array_parent"
@@ -211,22 +211,21 @@ class PolylineOperator:
context.workspace.status_text_set(draw_instructions)
def handle_lock_axis(self, context: bpy.types.Context, event: bpy.types.Event) -> None:
angle_snap = tool.Snap.get_angle_snap_value(context)
if event.value == "PRESS" and event.type == "A":
self.tool_state.lock_axis = False if self.tool_state.lock_axis else True
if self.tool_state.lock_axis:
self.tool_state.snap_angle = self.input_ui.get_number_value("WORLD_ANGLE")
# Round to the closest 5
self.tool_state.snap_angle = round(self.tool_state.snap_angle / 5) * 5
self.tool_state.snap_angle = round(self.tool_state.snap_angle / angle_snap) * angle_snap
if event.shift and event.type in {"WHEELUPMOUSE", "WHEELDOWNMOUSE"}:
self.tool_state.lock_axis = True
self.tool_state.snap_angle = self.input_ui.get_number_value("WORLD_ANGLE")
# Round to the closest 5
self.tool_state.snap_angle = round(self.tool_state.snap_angle / 5) * 5
self.tool_state.snap_angle = round(self.tool_state.snap_angle / angle_snap) * angle_snap
if event.type in {"WHEELUPMOUSE"}:
self.tool_state.snap_angle += 5
self.tool_state.snap_angle += angle_snap
else:
self.tool_state.snap_angle -= 5
self.tool_state.snap_angle -= angle_snap
self.handle_mouse_move(context, event)
detected_snaps = tool.Snap.detect_snapping_points(context, event, self.objs_2d_bbox, self.tool_state)
self.snapping_points = tool.Snap.select_snapping_points(context, event, self.tool_state, detected_snaps)
@@ -17,7 +17,7 @@
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import copy
from math import atan2, degrees, pi
from math import atan2, degrees, pi, radians
from typing import Any, Literal, Optional, Union
import bmesh
@@ -195,8 +195,8 @@ class DumbProfileGenerator:
if should_round:
# Round to nearest 50mm (yes, metric for now)
self.length = 0.05 * round(length / 0.05)
# Round to nearest 5 degrees
nearest_degree = (pi / 180) * 5
angle_snap = tool.Snap.get_angle_snap_value(bpy.context)
nearest_degree = radians(angle_snap)
self.rotation = nearest_degree * round(self.rotation / nearest_degree)
self.location = coords[0]
data["obj"] = self.create_profile()
@@ -1131,8 +1131,13 @@ class DrawPolylineProfile(bpy.types.Operator, PolylineOperator, tool.Ifc.Operato
MEPGenerator().setup_ports(profile1["obj"])
else:
connect_IfcFlowSegments = tool.Ifc.get_entity(profiles[0]["obj"]).is_a("IfcFlowSegment")
for profile1, profile2 in zip(profiles[:-1], profiles[1:]):
DumbProfileJoiner().join_V(profile2["obj"], profile1["obj"])
if connect_IfcFlowSegments:
bpy.ops.bim.mep_connect_elements(
obj1_name=profile1["obj"].name, obj2_name=profile2["obj"].name
)
def modal(self, context, event):
return IfcStore.execute_ifc_operator(self, context, event, method="MODAL")
@@ -387,11 +387,6 @@ class BIMArrayProperties(PropertyGroup):
name="Method",
default="OFFSET",
)
sync_children: bpy.props.BoolProperty(
name="Sync Children",
description="Regenerate all children based on the parent object",
default=False,
)
relating_array_object: bpy.props.PointerProperty(
type=bpy.types.Object,
name="Copy Array Properties",
+83 -240
View File
@@ -231,17 +231,16 @@ class DumbSlabPlaner:
for inverse in tool.Ifc.get().get_inverse(layer_set):
if not inverse.is_a("IfcMaterialLayerSetUsage") or inverse.LayerSetDirection != "AXIS3":
continue
if tool.Ifc.get().schema == "IFC2X3":
for rel in tool.Ifc.get().get_inverse(inverse):
if not rel.is_a("IfcRelAssociatesMaterial"):
continue
for element in rel.RelatedObjects:
self.change_thickness(element, total_thickness, preserve_offset=True)
self.change_thickness(element, total_thickness)
else:
for rel in inverse.AssociatedTo:
for element in rel.RelatedObjects:
self.change_thickness(element, total_thickness, preserve_offset=True)
self.change_thickness(element, total_thickness)
def regenerate_from_occurence(self, element, material_set_usage):
self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
@@ -251,12 +250,10 @@ class DumbSlabPlaner:
return
self.change_thickness(element, total_thickness)
def change_thickness(
self, element: ifcopenshell.entity_instance, thickness: float, preserve_offset: bool = False
) -> None:
def change_thickness(self, element: ifcopenshell.entity_instance, thickness: float) -> None:
self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
if tool.Model.get_usage_type(element) != "LAYER3":
return
layer_params = tool.Model.get_material_layer_parameters(element)
ifc_file = tool.Ifc.get()
body_context = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Model", "Body", "MODEL_VIEW")
@@ -274,48 +271,72 @@ class DumbSlabPlaner:
if representation:
extrusion = tool.Model.get_extrusion(representation)
if extrusion:
# TODO Right now we don't have a reliable way to calculate the existing x_angle only based solely on the extrusion direction.
# For instances, a 30 degrees angled extrusion with positive direction has the same extrusion direction as a
# -150 degrees angled extrusion with negative direction. The difference lies in the object's rotation.
# This means that things can get messy if the user changes the object x angle somehow. We have to figure out an alternative approach.
existing_x_angle = obj.rotation_euler.x
existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 0, tolerance=0.001) else existing_x_angle
existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, pi, tolerance=0.001) else existing_x_angle
existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 2 * pi, tolerance=0.001) else existing_x_angle
direction_ratios = Vector(extrusion.ExtrudedDirection.DirectionRatios)
offset_direction = direction_ratios.copy()
perpendicular_depth = thickness * abs(1 / cos(existing_x_angle))
perpendicular_offset = layer_offset * abs(1 / cos(existing_x_angle)) / self.unit_scale
# Calculate the actual extrusion angle from vertical
extrusion_angle = 0
if direction_ratios.length > 0:
cos_angle = direction_ratios.normalized().dot(Vector((0, 0, 1)))
extrusion_angle = acos(min(max(cos_angle, -1), 1))
# Only apply 1/cos factor when there's actual extrusion slope
if extrusion_angle > 1e-6:
perpendicular_depth = thickness * abs(1 / cos(extrusion_angle))
perpendicular_offset = layer_offset * abs(1 / cos(extrusion_angle))
else:
perpendicular_depth = thickness
perpendicular_offset = layer_offset
# Check angle and z direction to determine whether the extrusion direction is positive or negative
if (abs(existing_x_angle) < (pi / 2) and direction_ratios.z > 0) or (
abs(existing_x_angle) > (pi / 2) and direction_ratios.z < 0
):
# The extrusion direction is positive. If the layer_parameter is set to negative,
# then the we change the extrusion direction.
if layer_params["direction_sense"] == "NEGATIVE":
direction_ratios *= -1
elif (abs(existing_x_angle) > (pi / 2) and direction_ratios.z > 0) or (
abs(existing_x_angle) < (pi / 2) and direction_ratios.z < 0
):
# The extrusion direction is negative. If the layer_parameter is set to positive,
# then the we change the extrusion direction. And the offset direction should remain positive
# for either direction sense, so we change it.
offset_direction *= -1
if layer_params["direction_sense"] == "POSITIVE":
direction_ratios *= -1
extrusion.ExtrudedDirection.DirectionRatios = tuple(direction_ratios)
extrusion.Depth = perpendicular_depth
# Update position
ifc_position = extrusion.Position
position = offset_direction * perpendicular_offset
material = ifcopenshell.util.element.get_material(element)
if material:
if material.is_a("IfcMaterialLayerSetUsage"):
material.OffsetFromReferenceLine = position.z
if ifc_position:
ifc_position.Location.Coordinates = position
else:
tool.Model.add_extrusion_position(extrusion, position)
if direction_ratios.length > 0:
offset_vector = direction_ratios.normalized() * perpendicular_offset
position = offset_vector
material = ifcopenshell.util.element.get_material(element)
if material and material.is_a("IfcMaterialLayerSetUsage"):
# Only set offset if not preserving it (preserves independent offsets per instance)
if not preserve_offset:
material.OffsetFromReferenceLine = position.z
if ifc_position:
ifc_position.Location.Coordinates = position
else:
tool.Model.add_extrusion_position(extrusion, position)
bonsai.core.geometry.switch_representation(
tool.Ifc,
tool.Geometry,
obj=obj,
representation=representation,
)
else:
props = tool.Model.get_model_props()
x_angle = 0 if tool.Cad.is_x(props.x_angle, 0, tolerance=0.001) else props.x_angle
new_rep = ifcopenshell.api.geometry.add_slab_representation(
tool.Ifc.get(),
context=body_context,
depth=thickness * self.unit_scale,
x_angle=x_angle,
)
for inverse in tool.Ifc.get().get_inverse(representation):
ifcopenshell.util.element.replace_attribute(inverse, representation, new_rep)
bonsai.core.geometry.switch_representation(
tool.Ifc,
tool.Geometry,
obj=obj,
representation=new_rep,
)
bonsai.core.geometry.remove_representation(
tool.Ifc, tool.Geometry, obj=obj, representation=representation
)
return
else:
props = tool.Model.get_model_props()
x_angle = 0 if tool.Cad.is_x(props.x_angle, 0, tolerance=0.001) else props.x_angle
@@ -328,118 +349,13 @@ class DumbSlabPlaner:
ifcopenshell.api.geometry.assign_representation(
tool.Ifc.get(), product=element, representation=representation
)
bonsai.core.geometry.switch_representation(
tool.Ifc,
tool.Geometry,
obj=obj,
representation=representation,
)
def update_extrusion_direction(
element: ifcopenshell.entity_instance, new_direction_ratios: tuple, obj: bpy.types.Object = None
) -> None:
"""
Update extrusion direction while preserving overall object orientation.
Args:
element: The IFC element
new_direction_ratios: New extrusion direction ratios (x,y,z)
obj: Optional Blender object (will be fetched if not provided)
"""
if not obj:
obj = tool.Ifc.get_object(element)
if not obj:
return
representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
if not representation:
return
extrusion = tool.Model.get_extrusion(representation)
if not extrusion:
return
# Get current extrusion direction
old_direction = Vector(extrusion.ExtrudedDirection.DirectionRatios)
if old_direction.length == 0:
old_direction = Vector((0, 0, 1)) # Default
new_direction = Vector(new_direction_ratios)
if new_direction.length == 0:
new_direction = Vector((0, 0, 1)) # Default
# Normalize both directions
old_direction_normalized = old_direction.normalized()
new_direction_normalized = new_direction.normalized()
# Store current object matrix
old_matrix = obj.matrix_world.copy()
# Calculate the rotation needed to keep same orientation
# When extrusion direction changes from A to B relative to local coordinates,
# we need to rotate the object by the inverse of that change
# Calculate rotation from old to new direction
rotation_axis = old_direction_normalized.cross(new_direction_normalized)
if rotation_axis.length > 1e-6:
rotation_axis.normalized()
dot_product = old_direction_normalized.dot(new_direction_normalized)
angle = acos(min(max(dot_product, -1), 1))
# Apply INVERSE rotation to object to compensate
rotation_matrix = Matrix.Rotation(-angle, 4, rotation_axis)
# Update object rotation
obj.matrix_world = old_matrix @ rotation_matrix
bpy.context.view_layer.update()
# Update extrusion direction (keeping magnitude)
if old_direction.length > 0:
# Preserve the magnitude of the original direction vector
magnitude = old_direction.length
new_direction = new_direction_normalized * magnitude
extrusion.ExtrudedDirection.DirectionRatios = tuple(new_direction)
# Update depth based on new extrusion angle
extrusion_angle = 0
if new_direction.length > 0:
cos_angle = new_direction_normalized.dot(Vector((0, 0, 1)))
extrusion_angle = acos(min(max(cos_angle, -1), 1))
# Get current depth (perpendicular depth)
current_perpendicular_depth = extrusion.Depth
# If we have material layer info, calculate actual thickness
material = ifcopenshell.util.element.get_material(element)
actual_thickness = current_perpendicular_depth
if material and material.is_a("IfcMaterialLayerSetUsage"):
layer_set = material.ForLayerSet
actual_thickness = sum([l.LayerThickness for l in layer_set.MaterialLayers])
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
actual_thickness *= unit_scale
# Convert to perpendicular depth if needed
if extrusion_angle > 1e-6:
new_perpendicular_depth = actual_thickness * abs(1 / cos(extrusion_angle))
else:
new_perpendicular_depth = actual_thickness
extrusion.Depth = new_perpendicular_depth
# Update position offset if needed
if extrusion.Position:
# Recalculate offset based on new direction
material = ifcopenshell.util.element.get_material(element)
if material and material.is_a("IfcMaterialLayerSetUsage"):
offset = material.OffsetFromReferenceLine
if extrusion_angle > 1e-6:
perpendicular_offset = offset * abs(1 / cos(extrusion_angle))
else:
perpendicular_offset = offset
offset_vector = new_direction_normalized * perpendicular_offset
extrusion.Position.Location.Coordinates = tuple(offset_vector)
bonsai.core.geometry.switch_representation(
tool.Ifc,
tool.Geometry,
obj=obj,
representation=representation,
)
class EnableEditingSketchExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator):
@@ -716,8 +632,6 @@ class EnableEditingExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator):
existing_x_angle = tool.Model.get_existing_x_angle(extrusion)
layer_params = tool.Model.get_material_layer_parameters(element)
usage_type = tool.Model.get_usage_type(element)
# TODO: review #7537 properly, this is a quick fix but something doesn't seem right.
original_rotation_x = 0
if extrusion.Position:
@@ -732,49 +646,22 @@ class EnableEditingExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator):
tranlation_matrix = Matrix.Translation(rot_offset)
position = position @ tranlation_matrix
# For AXIS3 with dual rotation: Reset rotation to zero so profile is horizontal
if usage_type == "LAYER3":
# Store original rotation for later restoration
original_rotation_x = obj.rotation_euler.x
obj["pre_edit_rotation_x"] = original_rotation_x
# Reset rotation to zero - profile will be horizontal
current_z_rot = obj.rotation_euler.z
obj.rotation_euler.x = 0.0
obj.rotation_euler.z = current_z_rot
else:
# Original behavior: Restore Object rotation to zero
local_rot_mat = obj.rotation_euler.to_matrix()
rot_mat = Matrix.Rotation(-existing_x_angle, 4, "X")
new_rot_mat = local_rot_mat.to_4x4() @ rot_mat
new_rot_euler = new_rot_mat.to_euler()
obj.rotation_euler = new_rot_euler
# Restore Object rotation to zero
local_rot_mat = obj.rotation_euler.to_matrix()
rot_mat = Matrix.Rotation(-existing_x_angle, 4, "X")
new_rot_mat = local_rot_mat.to_4x4() @ rot_mat
new_rot_euler = new_rot_mat.to_euler()
obj.rotation_euler = new_rot_euler
else:
position = Matrix()
# Import profile with correct x_angle
if usage_type == "LAYER3":
# For LAYER3: Use x_angle=0 and scale by cos(rotation) to get horizontal projection
obj_x_rotation = original_rotation_x # Use stored original rotation
scale_factor = abs(cos(obj_x_rotation)) if abs(obj_x_rotation) > 1e-6 else 1.0
# Import with x_angle=0
tool.Model.import_profile(extrusion.SweptArea, obj=obj, position=position, x_angle=0)
# Scale the Y coordinates by cos(rotation) to get horizontal projection
bpy.ops.object.mode_set(mode="OBJECT")
for vert in obj.data.vertices:
vert.co.y *= scale_factor
else:
# For other types: Use existing_x_angle
tool.Model.import_profile(extrusion.SweptArea, obj=obj, position=position, x_angle=existing_x_angle)
tool.Model.import_profile(extrusion.SweptArea, obj=obj, position=position, x_angle=existing_x_angle)
bpy.ops.object.mode_set(mode="EDIT")
ProfileDecorator.install(context, exit_edit_mode_callback=lambda: disable_editing_extrusion_profile(context))
if not bpy.app.background:
tool.Blender.set_viewport_tool("bim.cad_tool")
return {"FINISHED"}
@@ -796,7 +683,6 @@ class EditExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator):
extrusion = tool.Model.get_extrusion(body)
existing_x_angle = tool.Model.get_existing_x_angle(extrusion)
layer_params = tool.Model.get_material_layer_parameters(element)
usage_type = tool.Model.get_usage_type(element)
if extrusion.Position:
position = Matrix(ifcopenshell.util.placement.get_axis2placement(extrusion.Position).tolist())
@@ -810,38 +696,17 @@ class EditExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator):
tranlation_matrix = Matrix.Translation(rot_offset)
position = position @ tranlation_matrix
# Restore rotation
if usage_type == "LAYER3":
# Restore original rotation from before editing
if "pre_edit_rotation_x" in obj:
current_z_rot = obj.rotation_euler.z
obj.rotation_euler.x = obj["pre_edit_rotation_x"]
obj.rotation_euler.z = current_z_rot
del obj["pre_edit_rotation_x"]
else:
# Original behavior
local_rot_mat = obj.rotation_euler.to_matrix()
rot_mat = Matrix.Rotation(existing_x_angle, 4, "X")
new_rot_mat = local_rot_mat.to_4x4() @ rot_mat
new_rot_euler = new_rot_mat.to_euler()
obj.rotation_euler = new_rot_euler
# Restore Object rotation to x_angle
local_rot_mat = obj.rotation_euler.to_matrix()
rot_mat = Matrix.Rotation(existing_x_angle, 4, "X")
new_rot_mat = local_rot_mat.to_4x4() @ rot_mat
new_rot_euler = new_rot_mat.to_euler()
obj.rotation_euler = new_rot_euler
else:
position = Matrix()
# Export profile with correct x_angle
if usage_type == "LAYER3":
# Scale Y coordinates back up before exporting
obj_x_rotation = obj.rotation_euler.x
scale_factor = abs(cos(obj_x_rotation)) if abs(obj_x_rotation) > 1e-6 else 1.0
# Un-scale the profile before exporting
for vert in obj.data.vertices:
vert.co.y /= scale_factor # Inverse of import scaling
profile = tool.Model.export_profile(obj, position=position, x_angle=0)
else:
profile = tool.Model.export_profile(obj, position=position, x_angle=existing_x_angle)
profile = tool.Model.export_profile(obj, position=position, x_angle=existing_x_angle)
if not profile:
@@ -893,28 +758,6 @@ class EditExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator):
tool.Ifc.get(), product=element, representation=new_footprint
)
footprint_context = ifcopenshell.util.representation.get_context(
tool.Ifc.get(), "Plan", "FootPrint", "SKETCH_VIEW"
)
if not footprint_context:
return
curves = [profile.OuterCurve]
if profile.is_a("IfcArbitraryProfileDefWithVoids"):
curves.extend(profile.InnerCurves)
new_footprint = ifcopenshell.api.geometry.add_footprint_representation(
tool.Ifc.get(), context=footprint_context, curves=curves
)
old_footprint = ifcopenshell.util.representation.get_representation(element, "Plan", "FootPrint", "SKETCH_VIEW")
if old_footprint:
for inverse in tool.Ifc.get().get_inverse(old_footprint):
ifcopenshell.util.element.replace_attribute(inverse, old_footprint, new_footprint)
bonsai.core.geometry.remove_representation(tool.Ifc, tool.Geometry, obj=obj, representation=old_footprint)
else:
ifcopenshell.api.geometry.assign_representation(
tool.Ifc.get(), product=element, representation=new_footprint
)
class ResetVertex(bpy.types.Operator):
bl_idname = "bim.reset_vertex"
+1 -1
View File
@@ -229,6 +229,7 @@ class BIM_PT_array(bpy.types.Panel):
if ArrayData.data["parameters"]:
row = self.layout.row(align=True)
row.label(text=ArrayData.data["parameters"]["parent_name"], icon="CON_CHILDOF")
row.operator("bim.regenerate_array", icon="FILE_REFRESH", text="")
row.operator("bim.select_array_parent", icon="OBJECT_DATA", text="")
row.operator("bim.select_all_array_objects", icon="RESTRICT_SELECT_OFF", text="")
@@ -246,7 +247,6 @@ class BIM_PT_array(bpy.types.Panel):
row.prop(props, "method")
row = box.row(align=True)
row.prop(props, "use_local_space")
row.prop(props, "sync_children")
col = box.column()
row = col.row(align=True)
row.prop(props, "x")
+47 -122
View File
@@ -403,42 +403,24 @@ class ChangeExtrusionDepth(bpy.types.Operator, tool.Ifc.Operator):
representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
if not representation:
continue
extrusion = tool.Model.get_extrusion(representation)
if not extrusion:
continue
# Get extrusion direction
x, y, z = extrusion.ExtrudedDirection.DirectionRatios
# Calculate angle from vertical
x_angle = Vector((0, 1)).angle_signed(Vector((y, z)))
# For sloped walls, compensate so VERTICAL height = target depth
cos_angle = cos(x_angle)
compensation_factor = abs(1 / cos_angle) if abs(cos_angle) > 1e-6 else 1.0
new_depth_ifc = (self.depth / si_conversion) * compensation_factor
extrusion.Depth = new_depth_ifc
# IMPORTANT: Refresh the geometry to reflect the IFC changes
bonsai.core.geometry.switch_representation(
tool.Ifc,
tool.Geometry,
obj=obj,
representation=representation,
)
extrusion.Depth = self.depth / si_conversion * (1 / cos(x_angle))
if tool.Model.get_usage_type(element) == "LAYER2":
for rel in element.ConnectedFrom:
if rel.is_a() == "IfcRelConnectsElements":
related_element = rel.RelatedElement
if related_element.is_a() == "IfcWall":
layer2_objs.append(tool.Ifc.get_object(related_element))
ifcopenshell.api.geometry.disconnect_element(
ifc_file,
relating_element=rel.RelatingElement,
related_element=element,
)
layer2_objs.append(obj)
if layer2_objs:
tool.Model.recalculate_walls(layer2_objs)
return {"FINISHED"}
@@ -458,143 +440,81 @@ class ChangeExtrusionXAngle(bpy.types.Operator, tool.Ifc.Operator):
def _execute(self, context):
layer2_objs: list[bpy.types.Object] = []
builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get())
x_angle = 0 if tool.Cad.is_x(self.x_angle, 0, tolerance=0.001) else self.x_angle
x_angle = 0 if tool.Cad.is_x(self.x_angle, pi, tolerance=0.001) else self.x_angle
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
x_angle = self.x_angle
selected_objs = tool.Model.get_selected_mesh_ifc_objects()
builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get())
for obj in context.selected_objects:
for obj in selected_objs:
element = tool.Ifc.get_entity(obj)
if not element:
continue
assert element
representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
if not representation:
continue
extrusion = tool.Model.get_extrusion(representation)
if not extrusion:
continue
# Get current object rotation matrix
obj_rotation = obj.matrix_world.to_3x3()
# Get current extrusion direction in LOCAL coordinates
current_local_direction = Vector(extrusion.ExtrudedDirection.DirectionRatios)
if current_local_direction.length == 0:
current_local_direction = Vector((0, 0, 1))
current_local_direction_normalized = current_local_direction.normalized()
# Calculate what the current extrusion direction is in WORLD coordinates
current_world_direction = obj_rotation @ current_local_direction_normalized
existing_x_angle = tool.Model.get_existing_x_angle(extrusion)
existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 0, tolerance=0.001) else existing_x_angle
existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, pi, tolerance=0.001) else existing_x_angle
# Calculate the NEW local extrusion direction based on x_angle
new_local_direction = Vector((0.0, sin(x_angle), cos(x_angle)))
# Check if extrusion direction is actually changing
current_local_norm = current_local_direction_normalized
new_local_norm = new_local_direction.normalized()
# Compare the LOCAL directions
local_direction_changed = (new_local_norm - current_local_norm).length > 1e-6
if tool.Model.get_usage_type(element) == "LAYER2":
x, y, z = extrusion.ExtrudedDirection.DirectionRatios
depth = extrusion.Depth / abs(1 / cos(existing_x_angle))
perpendicular_depth = depth * abs(1 / cos(x_angle))
# Update extrusion direction
if local_direction_changed:
extrusion.ExtrudedDirection.DirectionRatios = tuple(new_local_direction)
# Always update depth
extrusion.Depth = perpendicular_depth
extrusion.ExtrudedDirection.DirectionRatios = (0.0, sin(x_angle), cos(x_angle))
layer2_objs.append(obj)
extrusion.Depth = perpendicular_depth
else:
if tool.Model.get_usage_type(element) == "LAYER3":
# For slabs, handle polyline scaling
existing_obj_x_angle = obj.rotation_euler.x
existing_obj_x_angle = (
0 if tool.Cad.is_x(existing_obj_x_angle, 0, tolerance=0.001) else existing_obj_x_angle
)
existing_obj_x_angle = (
0 if tool.Cad.is_x(existing_obj_x_angle, pi, tolerance=0.001) else existing_obj_x_angle
)
existing_x_angle = obj.rotation_euler.x
existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 0, tolerance=0.001) else existing_x_angle
existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, pi, tolerance=0.001) else existing_x_angle
# Scale the polyline coordinates
coord_list = builder.get_polyline_coords(extrusion.SweptArea.OuterCurve)
coord_list = [
(p[0], p[1] * abs(cos(existing_x_angle))) for p in coord_list
] # Reset the transformation
] # Reset the transformation and returns to the original points with 0 degrees
coord_list = [
(p[0], p[1] * abs(1 / cos(x_angle))) for p in coord_list
] # Apply the transformation for the new x_angle
builder.set_polyline_coords(extrusion.SweptArea.OuterCurve, coord_list)
# Calculate new extrusion direction with direction sense
base_local_direction = Vector((0.0, sin(x_angle), cos(x_angle)))
# The extrusion direction calculated previously default to the positive direction
# Here we set the extrusion direction to negative if that's the case
direction_ratios = Vector((0.0, sin(x_angle), cos(x_angle)))
# direction_ratios = Vector(extrusion.ExtrudedDirection.DirectionRatios)
layer_params = tool.Model.get_material_layer_parameters(element)
perpendicular_depth = layer_params["thickness"] * abs(1 / cos(x_angle)) / unit_scale
perpendicular_offset = layer_params["offset"] * abs(1 / cos(x_angle)) / unit_scale
offset_direction = base_local_direction.copy()
offset_direction = direction_ratios.copy()
# Apply direction sense
final_local_direction = base_local_direction.copy()
if (abs(x_angle) < (pi / 2) and base_local_direction.z > 0) or (
abs(x_angle) > (pi / 2) and base_local_direction.z < 0
# Check angle and z direction to determine whether the extrusion direction is positive or negative
if (abs(x_angle) < (pi / 2) and direction_ratios.z > 0) or (
abs(x_angle) > (pi / 2) and direction_ratios.z < 0
):
# The extrusion direction is positive. If the layer_parameter is set to negative,
# then the we change the extrusion direction.
if layer_params["direction_sense"] == "NEGATIVE":
final_local_direction *= -1
elif (x_angle > (pi / 2) and base_local_direction.z > 0) or (
x_angle < (pi / 2) and base_local_direction.z < 0
direction_ratios *= -1
elif ((x_angle) > (pi / 2) and direction_ratios.z > 0) or (
(x_angle) < (pi / 2) and direction_ratios.z < 0
):
# The extrusion direction is negative. If the layer_parameter is set to positive,
# then the we change the extrusion direction.
# then the we change the extrusion direction. And the offset direction should remain positive
# for either direction sense, so we change it.
offset_direction *= -1
if layer_params["direction_sense"] == "POSITIVE":
final_local_direction *= -1
direction_ratios *= -1
# Check if extrusion direction actually changed
final_local_norm = final_local_direction.normalized()
local_direction_changed = (final_local_norm - current_local_norm).length > 1e-6
# Update extrusion properties
extrusion.ExtrudedDirection.DirectionRatios = tuple(final_local_direction)
extrusion.ExtrudedDirection.DirectionRatios = tuple(direction_ratios)
extrusion.Depth = perpendicular_depth
if extrusion.Position or perpendicular_offset != 0:
position = offset_direction * perpendicular_offset
tool.Model.add_extrusion_position(extrusion, position)
# Adjust object rotation if extrusion direction changed
if local_direction_changed:
# Calculate what the NEW world direction would be with current object rotation
expected_new_world_direction = obj_rotation @ final_local_norm
# The rotation needed is from expected_new_world_direction to current_world_direction
rotation_axis = expected_new_world_direction.cross(current_world_direction)
if rotation_axis.length > 1e-6:
rotation_axis.normalize()
dot_product = expected_new_world_direction.dot(current_world_direction)
angle = acos(min(max(dot_product, -1), 1))
# Rotate around object's own origin
# Decompose the matrix to get translation, rotation, scale
translation, rotation, scale = obj.matrix_world.decompose()
# Create rotation matrix and convert to quaternion
rotation_matrix = Matrix.Rotation(angle, 4, rotation_axis)
rotation_quat = rotation_matrix.to_quaternion()
# Apply rotation to existing rotation (quaternion multiplication)
new_rotation = rotation_quat @ rotation
# Reconstruct matrix_world with same translation, new rotation, same scale
obj.matrix_world = (
Matrix.Translation(translation) @ new_rotation.to_matrix().to_4x4() @ Matrix.Scale(1, 4)
)
bpy.context.view_layer.update()
bonsai.core.geometry.switch_representation(
tool.Ifc,
tool.Geometry,
@@ -602,6 +522,12 @@ class ChangeExtrusionXAngle(bpy.types.Operator, tool.Ifc.Operator):
representation=representation,
)
# Object rotation
current_z_rot = obj.rotation_euler.z
rot_mat = mathutils.Matrix.Rotation(x_angle, 4, "X")
obj.rotation_euler = rot_mat.to_euler()
obj.rotation_euler.z = current_z_rot
if layer2_objs:
tool.Model.recalculate_walls(layer2_objs)
return {"FINISHED"}
@@ -990,8 +916,8 @@ class DumbWallGenerator:
if should_round:
# Round to nearest 50mm (yes, metric for now)
self.length = 0.05 * round(length / 0.05)
# Round to nearest 5 degrees
nearest_degree = (math.pi / 180) * 5
angle_snap = tool.Snap.get_angle_snap_value(bpy.context)
nearest_degree = math.radians(angle_snap)
self.rotation = nearest_degree * round(self.rotation / nearest_degree)
self.location = coords[0]
data["obj"] = self.create_wall()
@@ -1096,7 +1022,6 @@ class DumbWallGenerator:
obj=obj,
representation=representation,
)
pset = ifcopenshell.api.pset.add_pset(self.file, product=element, name="EPset_Parametric")
ifcopenshell.api.pset.edit_pset(self.file, pset=pset, properties={"Engine": "Bonsai.DumbLayer2"})
material = ifcopenshell.util.element.get_material(element)
@@ -31,27 +31,31 @@ classes = (
operator.BIM_OT_load_clipping_planes,
operator.BIM_OT_save_clipping_planes,
operator.ChangeLibraryElement,
operator.ClearMeasurement,
operator.ClearRecentIFCProjects,
operator.CreateClippingPlane,
operator.CreateProject,
operator.DisableCulling,
operator.DisableEditingHeader,
operator.DisableEditingLink,
operator.EditHeader,
operator.EditLink,
operator.EditProjectLibrary,
operator.EnableCulling,
operator.EnableEditingHeader,
operator.EnableEditingLink,
operator.ExportIFC,
operator.FlipClippingPlane,
operator.IFCFileHandlerOperator,
operator.ImageScalingTool,
operator.LinkIfc,
operator.LoadBlendMetadataAndIFC,
operator.LoadLink,
operator.LoadLinkedProject,
operator.LoadProject,
operator.LoadProjectElements,
operator.MeasureTool,
operator.MeasureFaceAreaTool,
operator.ClearMeasurement,
operator.MeasureTool,
operator.NewProject,
operator.QueryLinkedElement,
operator.RefreshClippingPlanes,
@@ -69,7 +73,6 @@ classes = (
operator.UnassignLibraryDeclaration,
operator.UnlinkIfc,
operator.UnloadLink,
operator.LoadBlendMetadataAndIFC,
workspace.ExploreHotkey,
prop.LibraryBreadcrumb,
prop.LibraryElement,
+241 -144
View File
@@ -18,6 +18,7 @@
import datetime
import json
import math
import logging
import os
import subprocess
@@ -59,6 +60,15 @@ import bonsai.core.project as core
import bonsai.tool as tool
from bonsai.bim import export_ifc, import_ifc
from bonsai.bim.ifc import IfcStore
from bonsai.bim.ui import IFCFileSelector
from bonsai.bim import import_ifc
from bonsai.bim import export_ifc
from math import radians, degrees
from pathlib import Path
from collections import defaultdict
from mathutils import Vector, Matrix
from bpy.app.handlers import persistent
from ifcopenshell.geom import ShapeElementType
from bonsai.bim.module.model.decorator import FaceAreaDecorator, PolylineDecorator
from bonsai.bim.module.model.polyline import PolylineOperator
from bonsai.bim.module.project.data import LinksData, ProjectLibraryData
@@ -1321,7 +1331,7 @@ class ToggleFilterCategories(bpy.types.Operator):
return {"FINISHED"}
class LinkIfc(bpy.types.Operator, ImportHelper):
class LinkIfc(bpy.types.Operator, ImportHelper, tool.Ifc.Operator):
bl_idname = "bim.link_ifc"
bl_label = "Link IFC"
bl_options = {"REGISTER", "UNDO"}
@@ -1360,134 +1370,125 @@ class LinkIfc(bpy.types.Operator, ImportHelper):
row = self.layout.row()
row.prop(pprops, "project_north")
def execute(self, context):
def _execute(self, context):
start = time.time()
files = [f.name for f in self.files] if self.files else [self.filepath]
if not files or all(not f or not f.strip() for f in files):
self.report({"ERROR"}, "No file selected")
return {"CANCELLED"}
existing_links = tool.Project.get_linked_models_documents() if tool.Ifc.get() else {}
for filename in files:
if not filename or not filename.strip():
continue
filepath = Path(self.directory) / filename
if bpy.data.filepath and filepath.samefile(bpy.data.filepath):
self.report({"INFO"}, "Can't link the current .blend file")
continue
props = tool.Project.get_project_props()
new = props.links.add()
filepath = tool.Ifc.get_uri(filepath, use_relative_path=self.use_relative_path)
new = props.links.add()
if tool.Ifc.get():
if not (document := existing_links.get(filepath)):
document = ifcopenshell.api.document.add_information(tool.Ifc.get())
document.Name = Path(filepath).name
document.Scope = "LINKED_MODEL"
reference = ifcopenshell.api.document.add_reference(tool.Ifc.get(), information=document)
reference[1] = ",".join([str(o) for o in np.eye(4).flatten().tolist()])
reference.Location = filepath.replace("\\", "/")
new.ifc_definition_id = reference.id()
new.name = filepath
status = bpy.ops.bim.load_link(filepath=filepath, use_cache=self.use_cache)
if status == {"CANCELLED"}:
error_msg = (
f'Error processing IFC file "{filepath}" '
"was critical and blend file either wasn't saved or wasn't updated. "
"See logs above in system console for details."
)
print(error_msg)
self.report({"ERROR"}, error_msg)
return {"FINISHED"}
print(f"Finished linking {len(files)} IFCs", time.time() - start)
return {"FINISHED"}
new.filepath = filepath
bpy.ops.bim.load_link(link_index=-1, use_cache=self.use_cache)
class UnlinkIfc(bpy.types.Operator):
class UnlinkIfc(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.unlink_ifc"
bl_label = "Unlink IFC"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Remove the selected file from the link list"
filepath: bpy.props.StringProperty()
link_index: bpy.props.IntProperty(name="Link Index")
def execute(self, context):
filepath = Path(self.filepath).as_posix()
bpy.ops.bim.unload_link(filepath=filepath)
def _execute(self, context):
props = tool.Project.get_project_props()
index = props.links.find(filepath)
if index != -1:
props.links.remove(index)
return {"FINISHED"}
link = props.links[self.link_index]
bpy.ops.bim.unload_link(link_index=self.link_index)
if tool.Ifc.get():
reference = tool.Ifc.get().by_id(link.ifc_definition_id)
document = tool.Document.get_reference_document(reference)
ifcopenshell.api.document.remove_reference(tool.Ifc.get(), reference)
if document and not tool.Document.get_document_references(document):
ifcopenshell.api.document.remove_information(tool.Ifc.get(), document)
props.links.remove(self.link_index)
class UnloadLink(bpy.types.Operator):
class UnloadLink(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.unload_link"
bl_label = "Unload Link"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Unload the selected linked file"
filepath: bpy.props.StringProperty()
link_index: bpy.props.IntProperty(name="Link Index")
def execute(self, context):
filepath = tool.Blender.ensure_blender_path_is_abs(Path(self.filepath))
if filepath.suffix.lower() == ".ifc":
filepath = filepath.with_suffix(".ifc.cache.blend")
for library in list(bpy.data.libraries):
if tool.Blender.ensure_blender_path_is_abs(Path(library.filepath)) == filepath:
def _execute(self, context):
link = tool.Project.get_project_props().links[self.link_index]
if obj := tool.Project.get_link_empty_handle(link):
collection = obj.instance_collection
library = collection.library
tool.Ifc.unlink(obj=obj)
bpy.data.objects.remove(obj)
if collection.users == 0:
bpy.data.collections.remove(collection)
if not len([c for c in bpy.data.collections if c.library == library]):
bpy.data.libraries.remove(library)
props = tool.Project.get_project_props()
links = props.links
link = links[self.filepath]
# Let's assume that user might delete it.
if empty_handle := link.empty_handle:
bpy.data.objects.remove(empty_handle)
# following lines removes the library also when use_relative_path=True, otherwise it doesn't
libraries = bpy.data.libraries
for library in libraries:
if library.name == self.filepath + ".cache.blend":
bpy.data.libraries.remove(library)
link.is_loaded = False
if not any([l.is_loaded for l in links]):
ProjectDecorator.uninstall()
# we make sure we don't draw queried object from the file that was just unlinked
elif queried_obj := props.queried_obj:
queried_filepath = Path(queried_obj["ifc_filepath"])
if queried_filepath == filepath:
ProjectDecorator.uninstall()
return {"FINISHED"}
ProjectDecorator.uninstall()
class LoadLink(bpy.types.Operator):
class LoadLink(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.load_link"
bl_label = "Load Link"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Load the selected file"
filepath: bpy.props.StringProperty(name="Link Filepath")
link_index: bpy.props.IntProperty(name="Link Index")
use_cache: bpy.props.BoolProperty(name="Use Cache", default=True)
filepath_: Path
def execute(self, context):
filepath = Path(tool.Ifc.resolve_uri(self.filepath))
def _execute(self, context):
self.link = tool.Project.get_project_props().links[self.link_index]
filepath = Path(tool.Ifc.resolve_uri(self.link.filepath))
if not filepath.exists():
self.report({"ERROR"}, f"File does not exist: '{filepath}'")
return {"CANCELLED"}
self.filepath_ = filepath
if filepath.suffix.lower().endswith(".blend"):
self.link_blend(filepath)
elif filepath.suffix.lower().endswith(".ifc"):
status = self.link_ifc()
if status:
return status
return {"FINISHED"}
if filepath.suffix.lower().endswith(".ifc"):
return self.link_ifc()
def link_blend(self, filepath: Path) -> None:
with bpy.data.libraries.load(str(filepath), link=True) as (data_from, data_to):
data_to.scenes = data_from.scenes
link = tool.Project.get_project_props().links[self.filepath]
for scene in bpy.data.scenes:
if not scene.library or Path(scene.library.filepath) != filepath:
data_to.collections = [c for c in data_from.collections if "IfcProject" in c]
# Find the linked collection
for collection in bpy.data.collections:
if not collection.library or Path(collection.library.filepath) != filepath:
continue
for child in scene.collection.children:
if "IfcProject" not in child.name:
continue
empty = bpy.data.objects.new(child.name, None)
empty.instance_type = "COLLECTION"
empty.instance_collection = child
link.empty_handle = empty
bpy.context.scene.collection.objects.link(empty)
break
# Create unique empty instance for this link
empty_name = collection.name
empty = bpy.data.objects.new(empty_name, None)
empty.instance_type = "COLLECTION"
empty.instance_collection = collection
empty.matrix_world = Matrix(tool.Project.calculate_link_matrix(self.link))
tool.Project.set_link_empty_handle(self.link, empty)
bpy.context.scene.collection.objects.link(empty)
self.link.is_loaded = True
if tool.Ifc.get(): # For non-IFC projects, locking has no meaning
tool.Geometry.lock_object(empty)
tool.Blender.select_and_activate_single_object(bpy.context, empty)
break
link.is_loaded = True
tool.Blender.select_and_activate_single_object(bpy.context, empty)
else:
print(f"WARNING: No IfcProject collection found in {filepath}")
self.link.is_loaded = False
def link_ifc(self) -> Union[set[str], None]:
blend_filepath = self.filepath_.with_suffix(".ifc.cache.blend")
@@ -1502,14 +1503,12 @@ class LoadLink(bpy.types.Operator):
code = f"""
import bpy
import sys
def run():
import bonsai.tool as tool
gprops = tool.Georeference.get_georeference_props()
# Our model origin becomes their host model origin
gprops.host_model_origin = "{gprops.model_origin}"
gprops.host_model_origin_si = "{gprops.model_origin_si}"
gprops.host_model_project_north = "{gprops.model_project_north}"
gprops.has_blender_offset = {gprops.has_blender_offset}
gprops.blender_offset_x = "{gprops.blender_offset_x}"
gprops.blender_offset_y = "{gprops.blender_offset_y}"
@@ -1522,7 +1521,12 @@ def run():
pprops.false_origin = "{pprops.false_origin}"
pprops.project_north = "{pprops.project_north}"
# Use absolute path to be safe from cwd changes.
bpy.ops.bim.load_linked_project(filepath=r"{str(self.filepath_)}")
try:
bpy.ops.bim.load_linked_project(filepath=r"{str(self.filepath_)}")
except RuntimeError as e:
# Operator failed (returned CANCELLED with error report)
print(f"Failed to load linked project: {{e}}")
sys.exit(1)
# Use str instead of as_posix to avoid issues with Windows shared paths.
bpy.ops.wm.save_as_mainfile(filepath=r"{str(blend_filepath)}")
@@ -1556,14 +1560,17 @@ except Exception as e:
if not blend_filepath.exists() or blend_filepath.stat().st_mtime < t:
return {"CANCELLED"}
self.set_model_origin_from_link()
self.set_model_origin_from_link()
self.set_georeferencing_indicator()
self.link_blend(blend_filepath)
def set_model_origin_from_link(self) -> None:
if tool.Ifc.get():
return # The current model's coordinates always take priority.
if len(tool.Project.get_project_props().links) > 1:
return # Only the first link sets the origin
json_filepath = self.filepath_.with_suffix(".ifc.cache.json")
if not json_filepath.exists():
return
@@ -1576,21 +1583,36 @@ except Exception as e:
if (value := data.get(prop, None)) is not None:
setattr(gprops, prop, value)
def set_georeferencing_indicator(self) -> None:
if not tool.Ifc.get():
self.link.georeferenced = "NONE"
return
if not (crs_name := (ifcopenshell.util.geolocation.get_crs(tool.Ifc.get()) or {}).get("Name", "")):
self.link.georeferenced = "NONE"
return
reference = tool.Ifc.get().by_id(self.link.ifc_definition_id)
json_filepath = Path(reference.Location).with_suffix(".ifc.cache.json")
if not json_filepath.exists():
self.link.georeferenced = "NONE"
return
with open(json_filepath, "r") as f:
data = json.load(f)
if not data["model_is_georeferenced"]:
self.link.georeferenced = "NONE"
else:
self.link.georeferenced = "FULL_COMPATIBLE" if crs_name == data["model_crs"] else "NOT_COMPATIBLE"
class ReloadLink(bpy.types.Operator):
bl_idname = "bim.reload_link"
bl_label = "Reload Link"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Reload the selected file"
filepath: bpy.props.StringProperty()
link_index: bpy.props.IntProperty(name="Link Index")
def execute(self, context):
is_abs = os.path.isabs(Path(self.filepath))
use_relative_path = not is_abs
bpy.ops.bim.unlink_ifc(filepath=self.filepath)
filepath = tool.Ifc.resolve_uri(self.filepath)
status = bpy.ops.bim.link_ifc(filepath=filepath, use_cache=False, use_relative_path=use_relative_path)
return {"FINISHED"}
bpy.ops.bim.unload_link(link_index=self.link_index)
return bpy.ops.bim.load_link(link_index=self.link_index, use_cache=False) or {"FINISHED"}
class ToggleLinkSelectability(bpy.types.Operator):
@@ -1598,16 +1620,18 @@ class ToggleLinkSelectability(bpy.types.Operator):
bl_label = "Toggle Link Selectability"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Toggle selectability"
link: bpy.props.StringProperty(name="Linked IFC Filepath")
link_index: bpy.props.IntProperty(name="Link Index")
def execute(self, context):
props = tool.Project.get_project_props()
link = props.links[self.link]
self.library_filepath = tool.Blender.ensure_blender_path_is_abs(Path(self.link).with_suffix(".ifc.cache.blend"))
link = props.links[self.link_index]
self.library_filepath = tool.Blender.ensure_blender_path_is_abs(
Path(link.filepath).with_suffix(".ifc.cache.blend")
)
link.is_selectable = (is_selectable := not link.is_selectable)
for collection in self.get_linked_collections():
collection.hide_select = not is_selectable
if handle := link.empty_handle:
if handle := tool.Project.get_link_empty_handle(link):
handle.hide_select = not is_selectable
return {"FINISHED"}
@@ -1624,13 +1648,15 @@ class ToggleLinkVisibility(bpy.types.Operator):
bl_label = "Toggle Link Visibility"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Toggle visibility between SOLID and WIREFRAME"
link: bpy.props.StringProperty(name="Linked IFC Filepath")
link_index: bpy.props.IntProperty(name="Link Index")
mode: bpy.props.EnumProperty(name="Visibility Mode", items=((i, i, "") for i in ("WIREFRAME", "VISIBLE")))
def execute(self, context):
props = tool.Project.get_project_props()
link = props.links[self.link]
self.library_filepath = tool.Blender.ensure_blender_path_is_abs(Path(self.link).with_suffix(".ifc.cache.blend"))
link = props.links[self.link_index]
self.library_filepath = tool.Blender.ensure_blender_path_is_abs(
Path(link.filepath).with_suffix(".ifc.cache.blend")
)
if self.mode == "WIREFRAME":
self.toggle_wireframe(link)
elif self.mode == "VISIBLE":
@@ -1638,12 +1664,16 @@ class ToggleLinkVisibility(bpy.types.Operator):
return {"FINISHED"}
def toggle_wireframe(self, link: "Link") -> None:
linked_collections = self.get_linked_collections()
link.is_wireframe = not link.is_wireframe
display_type = "WIRE" if link.is_wireframe else "TEXTURED"
for collection in self.get_linked_collections():
for collection in linked_collections:
objs = filter(lambda obj: "IfcOpeningElement" not in obj.name, collection.all_objects)
for obj in objs:
obj.display_type = display_type
if handle := tool.Project.get_link_empty_handle(link):
handle.display_type = display_type
def toggle_visibility(self, link: "Link") -> None:
linked_collections = self.get_linked_collections()
@@ -1652,7 +1682,7 @@ class ToggleLinkVisibility(bpy.types.Operator):
layer_collections = tool.Blender.get_layer_collections_mapping(linked_collections)
for layer_collection in layer_collections.values():
layer_collection.exclude = is_hidden
if handle := link.empty_handle:
if handle := tool.Project.get_link_empty_handle(link):
handle.hide_set(is_hidden)
def get_linked_collections(self) -> list[bpy.types.Collection]:
@@ -1663,17 +1693,94 @@ class ToggleLinkVisibility(bpy.types.Operator):
]
class EnableEditingLink(bpy.types.Operator):
bl_idname = "bim.enable_editing_link"
bl_label = "Enable Editing Link"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Enable editing link location"
def execute(self, context):
link = tool.Project.get_project_props().active_link
link.is_editing = True
tool.Geometry.unlock_object(tool.Project.get_link_empty_handle(link))
return {"FINISHED"}
class DisableEditingLink(bpy.types.Operator):
bl_idname = "bim.disable_editing_link"
bl_label = "Disable Editing Link"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Disable editing link and restore to previously saved location"
def execute(self, context):
link = tool.Project.get_project_props().active_link
link.is_editing = False
obj = tool.Project.get_link_empty_handle(link)
obj.matrix_world = Matrix(tool.Project.calculate_link_matrix(link))
tool.Geometry.lock_object(obj)
return {"FINISHED"}
class EditLink(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.edit_link"
bl_label = "Edit Link"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Disable editing link and restore to previously saved location"
def _execute(self, context):
link = tool.Project.get_project_props().active_link
link.is_editing = False
obj = tool.Project.get_link_empty_handle(link)
new_obj_matrix = obj.matrix_world
filepath = Path(tool.Ifc.resolve_uri(link.filepath))
with open(filepath.with_suffix(".ifc.cache.json"), "r") as f:
metadata = json.load(f)
rot = ifcopenshell.util.shape_builder.np_rotation_matrix(
radians(-float(metadata["model_project_north"])), 4, "Z"
)
global_matrix = rot @ np.eye(4)
global_matrix[:, 3][:3] = [float(o) for o in metadata["model_origin_si"].split(",")]
gprops = tool.Georeference.get_georeference_props()
rot = ifcopenshell.util.shape_builder.np_rotation_matrix(radians(-float(gprops.model_project_north)), 4, "Z")
local_matrix = rot @ np.eye(4)
local_matrix[:, 3][:3] = [float(o) for o in gprops.model_origin_si.split(",")]
# obj_matrix is typically calculated as:
# obj_matrix = np.linalg.inv(local_matrix) @ transformation @ global_matrix
identity_blender_matrix = np.linalg.inv(local_matrix) @ global_matrix
if np.allclose(np.array(new_obj_matrix), identity_blender_matrix, atol=1e-5):
link.has_transformation = False
transformation = ",".join(map(str, np.eye(4).reshape(-1)))
else:
transformed_global_matrix = local_matrix @ np.array(new_obj_matrix)
transformation = transformed_global_matrix @ np.linalg.inv(global_matrix)
link.has_transformation = True
transformation = ",".join(map(str, transformation.reshape(-1)))
if tool.Ifc.get():
reference = tool.Ifc.get().by_id(link.ifc_definition_id)
reference[1] = transformation
else:
link.transformation = transformation
obj.matrix_world = Matrix(tool.Project.calculate_link_matrix(link))
tool.Geometry.lock_object(obj)
class SelectLinkHandle(bpy.types.Operator):
bl_idname = "bim.select_link_handle"
bl_label = "Select Link Handle"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Select link empty object handle"
index: bpy.props.IntProperty(name="Link Index")
link_index: bpy.props.IntProperty(name="Link Index")
def execute(self, context):
props = tool.Project.get_project_props()
link = props.links[self.index]
handle = link.empty_handle
link = props.links[self.link_index]
handle = tool.Project.get_link_empty_handle(link)
if not handle:
self.report({"ERROR"}, "Link has no empty handle (probably it was deleted).")
return {"CANCELLED"}
@@ -1866,7 +1973,14 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper):
print("Processing", self.filepath)
self.collection = bpy.data.collections.new("IfcProject/" + os.path.basename(self.filepath))
self.file = ifcopenshell.open(self.filepath)
try:
self.file = ifcopenshell.open(self.filepath)
except Exception as e:
self.report({"ERROR"}, f"Failed to open IFC file: {str(e)}")
bpy.data.collections.remove(self.collection)
return {"CANCELLED"}
tool.Ifc.set(self.file)
print("Finished opening")
@@ -1897,20 +2011,13 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper):
if tool.Loader.settings.false_origin_mode == "MANUAL" and tool.Loader.settings.false_origin:
tool.Loader.set_manual_blender_offset(self.file)
elif tool.Loader.settings.false_origin_mode == "AUTOMATIC":
if host_model_origin_si := gprops.host_model_origin_si:
host_model_origin_si = [float(o) / self.unit_scale for o in host_model_origin_si.split(",")]
tool.Loader.settings.false_origin = host_model_origin_si
tool.Loader.settings.project_north = float(gprops.host_model_project_north)
tool.Loader.set_manual_blender_offset(self.file)
else:
tool.Loader.guess_false_origin(self.file)
tool.Loader.guess_false_origin(self.file)
tool.Georeference.set_model_origin()
self.json_filepath = self.filepath + ".cache.json"
data = {
"host_model_origin": gprops.host_model_origin,
"host_model_origin_si": gprops.host_model_origin_si,
"host_model_project_north": gprops.host_model_project_north,
"model_is_georeferenced": gprops.model_is_georeferenced,
"model_crs": gprops.model_crs,
"model_origin": gprops.model_origin,
"model_origin_si": gprops.model_origin_si,
"model_project_north": gprops.model_project_north,
@@ -2735,17 +2842,27 @@ class IFCFileHandlerOperator(bpy.types.Operator):
# Keeping code in .invoke() as we'll probably add some
# popup windows later.
def clean_up_path(path: str) -> str:
# In Blender 4.5.6 there was a bug producing unncesseary double slash prefix
# breaking the paths. Issue is not present in 5.0+ and presumably will be solved in 4.5.7 too.
# https://projects.blender.org/blender/blender/issues/153822
if bpy.app.version == (4, 5, 6):
blender_prefix = "//"
if path.startswith(blender_prefix):
return path.removeprefix(blender_prefix)
return path
# `files` contain only .ifc files.
filepath = Path(self.directory)
# If user is just drag'n'dropping a single file -> load it as a new project,
# if they're holding ALT -> link the file/files to the current project.
if event.alt:
# Passing self.files directly results in TypeError.
serialized_files = [{"name": f.name} for f in self.files]
serialized_files = [{"name": clean_up_path(f.name)} for f in self.files]
return bpy.ops.bim.link_ifc(directory=self.directory, files=serialized_files)
else:
if len(self.files) == 1:
return bpy.ops.bim.load_project(filepath=(filepath / self.files[0].name).as_posix())
return bpy.ops.bim.load_project(filepath=(filepath / clean_up_path(self.files[0].name)).as_posix())
else:
self.report(
{"INFO"},
@@ -3131,29 +3248,9 @@ class ImageScalingTool(bpy.types.Operator, PolylineOperator):
bmesh.ops.scale(bm, vec=(scale_factor, scale_factor, 1.0), verts=bm.verts)
if bm.loops.layers.uv:
uv_layer = bm.loops.layers.uv.active
min_x = min(v.co.x for v in bm.verts)
max_x = max(v.co.x for v in bm.verts)
min_y = min(v.co.y for v in bm.verts)
max_y = max(v.co.y for v in bm.verts)
width = max_x - min_x
height = max_y - min_y
for face in bm.faces:
for loop in face.loops:
vert = loop.vert
u = (vert.co.x - min_x) / width if width > 0 else 0.5
v = (vert.co.y - min_y) / height if height > 0 else 0.5
u = max(0.0, min(1.0, u))
v = max(0.0, min(1.0, v))
loop[uv_layer].uv = (u, v)
bm.to_mesh(mesh)
bm.free()
tool.Loader.load_generated_uv_map(mesh)
mesh.update()
element = tool.Ifc.get_entity(self.target_object)
+43 -5
View File
@@ -16,8 +16,9 @@
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import os
import math
from collections.abc import Generator
from pathlib import Path
from typing import TYPE_CHECKING, Literal, Union, assert_never, get_args
import bpy
@@ -219,29 +220,62 @@ class FilterCategory(PropertyGroup):
class Link(PropertyGroup):
name: StringProperty(
name="Name",
name: StringProperty(name="Name")
filepath: StringProperty(
name="Filepath",
description="Filepath to linked .ifc file, stored in posix format (could be relative to .ifc file, not to .blend)",
)
transformation: StringProperty(
name="Transformation",
description="4x4 matrix transformation as a flattened comma separated list for the linked model",
default="",
)
georeferenced: EnumProperty(
name="Georeferenced",
description="Georeferencing status: compatibility between host and linked model",
items=[
("NONE", "No Georef", "Linked model has no georeferencing"),
("NOT_COMPATIBLE", "Not Compatible", "Has geo data but CRS differ from host"),
("FULL_COMPATIBLE", "Full Compatible", "Both CRS name and vertical datum match host"),
],
default="NONE",
)
has_transformation: BoolProperty(
name="Has Transformation",
description="Whether there is a transformation from its global coordinates",
default=False,
)
is_loaded: BoolProperty(name="Is Loaded", default=False)
is_editing: BoolProperty(name="Is Editing", description="Whether the link is being transformed", default=False)
is_selectable: BoolProperty(name="Is Selectable", default=True)
is_wireframe: BoolProperty(name="Is Wireframe", default=False)
is_hidden: BoolProperty(name="Is Hidden", default=False)
include_in_drawings: BoolProperty(name="Include in Drawings", default=True, options=set())
empty_handle: PointerProperty(
name="Empty Object Handle",
description="We use empty object handle to allow simple manipulations with a linked model (moving, scaling, rotating)",
description="Storage for empty handle. Used in non-IFC scenarios or temporarily during link creation",
type=bpy.types.Object,
)
ifc_definition_id: IntProperty(
name="IFC Definition ID",
description="STEP ID of the IfcDocumentReference when linked to a parent IFC project. Zero when no parent IFC exists",
default=0,
)
if TYPE_CHECKING:
name: str
filepath: str
transformation: str
georeferenced: Literal["NONE", "NOT_COMPATIBLE", "FULL_COMPATIBLE"]
has_transformation: bool
is_loaded: bool
is_editing: bool
is_selectable: bool
is_wireframe: bool
is_hidden: bool
include_in_drawings: bool
empty_handle: Union[bpy.types.Object, None]
ifc_definition_id: int
class EditedObj(PropertyGroup):
@@ -370,7 +404,7 @@ class BIMProjectProperties(PropertyGroup):
load_indexed_maps: BoolProperty(
name="Load Indexed Maps",
description="Load indexed maps (UV and color maps)",
default=True,
default=False, # Very slow and hackishly implemented
)
links: CollectionProperty(name="Links", type=Link)
active_link_index: IntProperty(name="Active Link Index")
@@ -424,6 +458,10 @@ class BIMProjectProperties(PropertyGroup):
clipping_planes_active_index: bpy.props.IntProperty(min=0, default=0, max=5)
edited_objs: bpy.props.CollectionProperty(type=EditedObj)
@property
def active_link(self) -> Union[Link, None]:
return tool.Blender.get_active_uilist_element(self.links, self.active_link_index)
@property
def active_clipping_plane(self) -> ObjProperty | None:
return tool.Blender.get_active_uilist_element(self.clipping_planes, self.clipping_planes_active_index)
+44 -49
View File
@@ -22,6 +22,7 @@ import os
from typing import TYPE_CHECKING
import bpy
import math
import ifcopenshell
from bpy.types import Menu, Panel, UIList
@@ -30,6 +31,7 @@ import bonsai.tool as tool
from bonsai.bim.helper import draw_attributes, prop_with_search
from bonsai.bim.ifc import IfcStore
from bonsai.bim.module.project.data import LinksData, ProjectData
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from bonsai.bim.module.project.prop import (
@@ -477,17 +479,27 @@ class BIM_PT_links(Panel):
def draw(self, context):
self.props = tool.Project.get_project_props()
row = self.layout.row(align=True)
row.operator("bim.link_ifc")
if self.props.links:
self.layout.template_list(
"BIM_UL_links",
"",
self.props,
"links",
self.props,
"active_link_index",
)
if self.props.active_link:
row = self.layout.row(align=True)
row.alignment = "RIGHT"
index = self.props.active_link_index
if self.props.active_link.is_loaded:
if self.props.active_link.is_editing:
row.operator("bim.edit_link", text="", icon="CHECKMARK")
row.operator("bim.disable_editing_link", text="", icon="CANCEL")
else:
row.operator("bim.enable_editing_link", text="", icon="GREASEPENCIL")
row.operator("bim.select_link_handle", text="", icon="OBJECT_DATA").link_index = index
row.operator("bim.unload_link", text="", icon="UNLINKED").link_index = index
row.operator("bim.reload_link", text="", icon="FILE_REFRESH").link_index = index
else:
row.operator("bim.load_link", text="", icon="LINKED").link_index = index
row.operator("bim.unlink_ifc", text="", icon="X").link_index = index
self.layout.template_list("BIM_UL_links", "", self.props, "links", self.props, "active_link_index")
if LinksData.enable_culling:
row = self.layout.row(align=True)
@@ -607,47 +619,30 @@ class BIM_UL_links(UIList):
active_propname,
index,
):
if item:
row = layout.row(align=True)
if item.is_loaded:
row.label(text=item.name)
op = row.operator(
"bim.toggle_link_selectability",
text="",
icon="RESTRICT_SELECT_OFF" if item.is_selectable else "RESTRICT_SELECT_ON",
emboss=False,
)
op.link = item.name
op = row.operator(
"bim.toggle_link_visibility",
text="",
icon="CUBE" if item.is_wireframe else "MESH_CUBE",
emboss=False,
)
op.link = item.name
op.mode = "WIREFRAME"
op = row.operator(
"bim.toggle_link_visibility",
text="",
icon="HIDE_ON" if item.is_hidden else "HIDE_OFF",
emboss=False,
)
op.link = item.name
op.mode = "VISIBLE"
op = row.operator("bim.select_link_handle", text="", icon="OBJECT_DATA")
op.index = index
op = row.operator("bim.unload_link", text="", icon="UNLINKED")
op.filepath = item.name
op = row.operator("bim.reload_link", text="", icon="FILE_REFRESH")
op.filepath = item.name
else:
row.prop(item, "name", text="")
op = row.operator("bim.select_uri_attribute", text="", icon="FILE_FOLDER")
op.attribute_data_path = tool.Blender.get_full_data_path(item, "name")
op = row.operator("bim.load_link", text="", icon="LINKED")
op.filepath = item.name
op = row.operator("bim.unlink_ifc", text="", icon="X")
op.filepath = item.name
row = layout.row(align=True)
if item.is_loaded:
if item.georeferenced == "NONE":
row.label(text="", icon="QUESTION")
elif item.georeferenced == "NOT_COMPATIBLE":
row.label(text="", icon="ERROR")
elif item.georeferenced == "FULL_COMPATIBLE":
row.label(text="", icon="WORLD")
if item.has_transformation:
row.label(text="", icon="OBJECT_ORIGIN")
row.label(text=item.filepath)
icon = "RESTRICT_SELECT_OFF" if item.is_selectable else "RESTRICT_SELECT_ON"
row.operator("bim.toggle_link_selectability", text="", icon=icon, emboss=False).link_index = index
icon = "CUBE" if item.is_wireframe else "MESH_CUBE"
op = row.operator("bim.toggle_link_visibility", text="", icon=icon, emboss=False)
op.link_index = index
op.mode = "WIREFRAME"
icon = "HIDE_ON" if item.is_hidden else "HIDE_OFF"
op = row.operator("bim.toggle_link_visibility", text="", icon=icon, emboss=False)
op.link_index = index
op.mode = "VISIBLE"
else:
row.label(text=item.filepath)
class BIM_PT_purge(Panel):
+67 -47
View File
@@ -134,23 +134,55 @@ class IfcClassData:
return [("FACE", "Face", "A planar face surface")]
templates = [
("EMPTY", "No Geometry", "Start with an empty object"),
None,
(
"OBJ",
"Tessellation From Object",
"Use an object as a template to create a new tessellation",
),
(
"MESH",
"Custom Tessellation",
"Create a basic tessellated or faceted cube",
),
(
"EXTRUSION",
"Custom Extruded Solid",
"An extrusion from an arbitrary profile",
),
]
if ifc_class in ("IfcWindowType", "IfcWindowStyle", "IfcWindow"):
templates.extend([None, ("WINDOW", "Window", "Parametric window")])
elif ifc_class in ("IfcDoorType", "IfcDoorStyle", "IfcDoor"):
templates.extend([None, ("DOOR", "Door", "Parametric door")])
elif ifc_class in ("IfcStairType", "IfcStairFlightType", "IfcStair", "IfcStairFlight"):
templates.extend([None, ("STAIR", "Stair", "Parametric stair")])
elif ifc_class in ("IfcRailingType", "IfcRailing"):
templates.extend([None, ("RAILING", "Railing", "Parametric railing")])
elif ifc_class in ("IfcRoofType", "IfcRoof", "IfcSlabType", "IfcSlab", "IfcCovering", "IfcCoveringType"):
templates.extend([None, ("ROOF", "Roof", "Parametric roof with a constant pitch")])
elif ifc_class and "Segment" in ifc_class:
templates.extend(
(
None,
(
"FLOW_SEGMENT_RECTANGULAR",
"Rectangular Distribution Segment",
"Works similarly to Profile, has distribution ports",
),
(
"FLOW_SEGMENT_RECTANGULAR_HOLLOW",
"Rectangular Hollow Distribution Segment",
"Works similarly to Profile, has distribution ports",
),
(
"FLOW_SEGMENT_CIRCULAR",
"Circular Distribution Segment",
"Works similarly to Profile, has distribution ports",
),
(
"FLOW_SEGMENT_CIRCULAR_HOLLOW",
"Circular Hollow Distribution Segment",
"Works similarly to Profile, has distribution ports",
),
)
)
if ifc_class and "IfcCableCarrierSegment" in ifc_class:
templates.extend(
(
(
"FLOW_SEGMENT_U_SHAPE",
"U-Shape Distribution Segment",
"Uses IfcUShapeProfileDef, has distribution ports",
),
)
)
if ifc_class.endswith("Type") or ifc_class.endswith("Style"):
templates.extend(
[
@@ -172,38 +204,26 @@ class IfcClassData:
),
]
)
if ifc_class in ("IfcWindowType", "IfcWindowStyle", "IfcWindow"):
templates.extend([None, ("WINDOW", "Window", "Parametric window")])
elif ifc_class in ("IfcDoorType", "IfcDoorStyle", "IfcDoor"):
templates.extend([None, ("DOOR", "Door", "Parametric door")])
elif ifc_class in ("IfcStairType", "IfcStairFlightType", "IfcStair", "IfcStairFlight"):
templates.extend([None, ("STAIR", "Stair", "Parametric stair")])
elif ifc_class in ("IfcRailingType", "IfcRailing"):
templates.extend([None, ("RAILING", "Railing", "Parametric railing")])
elif ifc_class in ("IfcRoofType", "IfcRoof", "IfcSlabType", "IfcSlab", "IfcCovering", "IfcCoveringType"):
templates.extend([None, ("ROOF", "Roof", "Parametric roof with a constant pitch")])
elif ifc_class and "Segment" in ifc_class:
templates.extend(
templates.extend(
[
None,
(
None,
(
"FLOW_SEGMENT_RECTANGULAR",
"Rectangular Distribution Segment",
"Works similarly to Profile, has distribution ports",
),
(
"FLOW_SEGMENT_CIRCULAR",
"Circular Distribution Segment",
"Works similarly to Profile, has distribution ports",
),
(
"FLOW_SEGMENT_CIRCULAR_HOLLOW",
"Circular Hollow Distribution Segment",
"Works similarly to Profile, has distribution ports",
),
)
)
"OBJ",
"Tessellation From Object",
"Use an object as a template to create a new tessellation",
),
(
"MESH",
"Custom Tessellation",
"Create a basic tessellated or faceted cube",
),
(
"EXTRUSION",
"Custom Extruded Solid",
"An extrusion from an arbitrary profile",
),
]
)
return templates
@classmethod
@@ -697,6 +697,26 @@ class AddElement(bpy.types.Operator, tool.Ifc.Operator):
XDim=default_x_dim / unit_scale,
YDim=default_y_dim / unit_scale,
)
elif representation_template == "FLOW_SEGMENT_RECTANGULAR_HOLLOW":
default_x_dim = 0.4
default_y_dim = 0.2
default_thickness = 0.005
default_inner_fillet_radius = 0.005
default_outer_fillet_radius = 0.005
profile_name = (
f"{props.ifc_class}-{default_x_dim*1000}x{default_y_dim*1000}x{default_thickness*1000}"
)
profile = tool.Ifc.get().create_entity(
"IfcRectangleHollowProfileDef",
ProfileName=profile_name,
ProfileType="AREA",
XDim=default_x_dim / unit_scale,
YDim=default_y_dim / unit_scale,
WallThickness=default_thickness / unit_scale,
InnerFilletRadius=default_inner_fillet_radius / unit_scale,
OuterFilletRadius=default_outer_fillet_radius / unit_scale,
)
elif representation_template == "FLOW_SEGMENT_CIRCULAR":
default_diameter = 0.1
profile_name = f"{props.ifc_class}-{default_diameter*1000}"
@@ -717,6 +737,21 @@ class AddElement(bpy.types.Operator, tool.Ifc.Operator):
Radius=(default_diameter / 2) / unit_scale,
WallThickness=default_thickness,
)
elif representation_template == "FLOW_SEGMENT_U_SHAPE":
default_depth = 0.4
default_flange_width = 0.2
default_web_thickness = 0.005
default_flange_thickness = 0.005
profile_name = f"{props.ifc_class}-{default_depth*1000}x{default_flange_width*1000}x{default_web_thickness*1000}x{default_flange_thickness*1000}"
profile = tool.Ifc.get().create_entity(
"IfcUShapeProfileDef",
ProfileName=profile_name,
ProfileType="AREA",
Depth=default_depth / unit_scale,
FlangeWidth=default_flange_width / unit_scale,
WebThickness=default_web_thickness / unit_scale,
FlangeThickness=default_flange_thickness / unit_scale,
)
rel = ifcopenshell.api.material.assign_material(
tool.Ifc.get(), products=[element], type="IfcMaterialProfileSet"
+44 -37
View File
@@ -45,8 +45,12 @@ import bonsai.tool as tool
from bonsai.bim.ifc import IfcStore
from bonsai.bim.prop import StrProperty
if TYPE_CHECKING:
from bonsai.bim.prop import BIMFacet
def draw_text_editor_header(self, context):
def draw_text_editor_header(self: bpy.types.TEXT_HT_header, context: bpy.types.Context) -> None:
assert isinstance(context.space_data, bpy.types.SpaceTextEditor)
if context.space_data.text and context.space_data.text.name.startswith("FilterQuery_"):
layout = self.layout
layout.separator()
@@ -160,6 +164,7 @@ class FilterValueSuggestions(Operator):
def draw(self, context):
layout = self.layout
assert layout
filter_groups = tool.Search.get_filter_groups(self.module)
ifc_filter = filter_groups[self.group_index].filters[self.filter_index]
@@ -202,8 +207,8 @@ class FilterValueSuggestions(Operator):
results_are_suggestions=True,
)
def get_suggestions(self, ifc_file, ifc_filter):
suggestions = set()
def get_suggestions(self, ifc_file: ifcopenshell.file, ifc_filter: "BIMFacet") -> set[str]:
suggestions: set[str] = set()
if ifc_filter.type == "entity":
suggestions = self.get_entity_suggestions(ifc_file)
@@ -236,8 +241,8 @@ class FilterValueSuggestions(Operator):
return suggestions
def build_hierarchy_path(self, element):
path = []
def build_hierarchy_path(self, element: ifcopenshell.entity_instance) -> list[str]:
path: list[str] = []
current = element
while current:
@@ -262,8 +267,8 @@ class FilterValueSuggestions(Operator):
return path
def get_entity_suggestions(self, ifc_file):
all_classes = set()
def get_entity_suggestions(self, ifc_file: ifcopenshell.file) -> set[str]:
all_classes: set[str] = set()
schema = tool.Ifc.schema()
for element_id in IfcStore.id_map.keys():
@@ -273,11 +278,13 @@ class FilterValueSuggestions(Operator):
try:
entity = schema.declaration_by_name(class_name).as_entity()
assert entity
current = entity
chain_names = [class_name]
while current.supertype():
supertype = current.supertype()
assert supertype
chain_names.insert(0, supertype.name())
current = supertype
@@ -295,8 +302,8 @@ class FilterValueSuggestions(Operator):
return all_classes
def get_type_suggestions(self, ifc_file):
suggestions = set()
def get_type_suggestions(self, ifc_file: ifcopenshell.file) -> set[str]:
suggestions: set[str] = set()
for element_type in ifc_file.by_type("IfcTypeObject"):
if element_type.Name:
hierarchy_path = self.build_hierarchy_path(element_type)
@@ -306,15 +313,15 @@ class FilterValueSuggestions(Operator):
suggestions.add(element_type.Name)
return suggestions
def get_material_suggestions(self, ifc_file):
def get_material_suggestions(self, ifc_file: ifcopenshell.file) -> set[str]:
suggestions = set()
for material in ifc_file.by_type("IfcMaterial"):
if material.Name:
suggestions.add(material.Name)
return suggestions
def get_location_suggestions(self, ifc_file):
suggestions = set()
def get_location_suggestions(self, ifc_file: ifcopenshell.file) -> set[str]:
suggestions: set[str] = set()
for spatial in ifc_file.by_type("IfcSpatialStructureElement"):
if spatial.Name:
hierarchy_path = self.build_hierarchy_path(spatial)
@@ -324,8 +331,8 @@ class FilterValueSuggestions(Operator):
suggestions.add(spatial.Name)
return suggestions
def get_group_suggestions(self, ifc_file):
suggestions = set()
def get_group_suggestions(self, ifc_file: ifcopenshell.file) -> set[str]:
suggestions: set[str] = set()
for group in ifc_file.by_type("IfcGroup"):
if group.Name:
hierarchy_path = self.build_hierarchy_path(group)
@@ -335,15 +342,15 @@ class FilterValueSuggestions(Operator):
suggestions.add(group.Name)
return suggestions
def get_classification_suggestions(self, ifc_file):
suggestions = set()
def get_classification_suggestions(self, ifc_file: ifcopenshell.file) -> set[str]:
suggestions: set[str] = set()
for ref in ifc_file.by_type("IfcClassificationReference"):
if ref.Identification:
suggestions.add(ref.Identification)
return suggestions
def get_parent_suggestions(self, ifc_file):
suggestions = set()
def get_parent_suggestions(self, ifc_file: ifcopenshell.file) -> set[str]:
suggestions: set[str] = set()
for element_id in IfcStore.id_map.keys():
try:
element = ifc_file.by_id(element_id)
@@ -371,9 +378,9 @@ class FilterValueSuggestions(Operator):
return suggestions
def get_instance_suggestions(self, ifc_file):
suggestions = set()
element_data = []
def get_instance_suggestions(self, ifc_file: ifcopenshell.file) -> set[str]:
suggestions: set[str] = set()
element_data: list[tuple[str, str]] = []
for element_id in IfcStore.id_map.keys():
try:
@@ -392,7 +399,7 @@ class FilterValueSuggestions(Operator):
except:
continue
display_counts = {}
display_counts: dict[str, int] = {}
for display_str, global_id in element_data:
display_counts[display_str] = display_counts.get(display_str, 0) + 1
@@ -404,8 +411,8 @@ class FilterValueSuggestions(Operator):
return suggestions
def get_property_sets(self, ifc_file):
psets = set()
def get_property_sets(self, ifc_file: ifcopenshell.file) -> set[str]:
psets: set[str] = set()
for element_id in IfcStore.id_map.keys():
try:
element = ifc_file.by_id(element_id)
@@ -418,8 +425,8 @@ class FilterValueSuggestions(Operator):
continue
return psets
def get_property_names(self, ifc_file, pset_name):
property_names = set()
def get_property_names(self, ifc_file: ifcopenshell.file, pset_name: str) -> set[str]:
property_names: set[str] = set()
for element_id in IfcStore.id_map.keys():
try:
element = ifc_file.by_id(element_id)
@@ -435,8 +442,8 @@ class FilterValueSuggestions(Operator):
continue
return property_names
def get_property_values(self, ifc_file, pset_name, property_name):
property_values = set()
def get_property_values(self, ifc_file: ifcopenshell.file, pset_name: str, property_name: str) -> set[str]:
property_values: set[str] = set()
for element_id in IfcStore.id_map.keys():
try:
element = ifc_file.by_id(element_id)
@@ -462,8 +469,8 @@ class FilterValueSuggestions(Operator):
continue
return property_values
def get_attribute_names(self, ifc_file):
attribute_names = set()
def get_attribute_names(self, ifc_file: ifcopenshell.file) -> set[str]:
attribute_names: set[str] = set()
schema = tool.Ifc.schema()
ifc_classes = set()
@@ -477,6 +484,7 @@ class FilterValueSuggestions(Operator):
for ifc_class in ifc_classes:
try:
entity = schema.declaration_by_name(ifc_class).as_entity()
assert entity
attributes = entity.all_attributes()
for attr in attributes:
attribute_names.add(attr.name())
@@ -485,8 +493,8 @@ class FilterValueSuggestions(Operator):
return attribute_names
def get_attribute_values(self, ifc_file, attribute_name):
attribute_values = set()
def get_attribute_values(self, ifc_file: ifcopenshell.file, attribute_name: str) -> set[str]:
attribute_values: set[str] = set()
for element_id in IfcStore.id_map.keys():
try:
@@ -780,11 +788,10 @@ class Search(Operator):
)
objs = [obj for e in results if isinstance(obj := tool.Ifc.get_object(e), bpy.types.Object)]
for obj in objs:
tool.Blender.set_object_selection(obj)
if objs:
tool.Blender.set_active_object(objs[0])
self.report({"INFO"}, f"{len(results)} Results.")
active_object = next(iter(objs), None)
selection = tool.Blender.validate_object_selection(context, active_object, objs)
tool.Blender.set_objects_selection(*selection, clear_previous_selection=False)
self.report({"INFO"}, f"{len(results)} Results, {len(selection.selected_objects)} Objects Selected")
return {"FINISHED"}
+1 -2
View File
@@ -99,8 +99,7 @@ def update_name(self: "BIMContainer", context: bpy.types.Context) -> None:
tool.Spatial.edit_container_name(element, self.name)
if obj := tool.Ifc.get_object(element):
tool.Root.set_object_name(obj, element)
if collection := tool.Blender.get_object_bim_props(obj).collection:
collection.name = f"{element.is_a()}/{element.Name or 'Unnamed'}"
tool.Collector.assign(obj)
bonsai.bim.handler.refresh_ui_data()
@@ -87,6 +87,7 @@ class RemoveStyle(bpy.types.Operator, tool.Ifc.Operator):
core.remove_style(tool.Ifc, tool.Style, style=tool.Ifc.get().by_id(self.style), reload_styles_ui=True)
# TODO: remove completely
class AddStyle(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.add_style"
bl_label = "Add Style"
@@ -37,6 +37,7 @@ classes = (
operator.EditZone,
operator.EnableEditingSystem,
operator.EnableEditingZone,
operator.EstablishPathDirection,
operator.HidePorts,
operator.LoadSystems,
operator.LoadZones,
@@ -317,17 +317,19 @@ class MEPConnectElements(bpy.types.Operator, tool.Ifc.Operator):
bl_label = "Connect MEP Elements"
bl_description = "Connects two selected elements by their closest located ports and adjusts them"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
if not len(context.selected_objects) == 2:
cls.poll_message_set("Need to select 2 objects.")
return False
return True
obj1_name: bpy.props.StringProperty(name="Object 1")
obj2_name: bpy.props.StringProperty(name="Object 2")
def _execute(self, context):
obj1 = context.active_object
obj2 = next(o for o in context.selected_objects if o != obj1)
if self.obj1_name and self.obj2_name:
obj1 = bpy.data.objects.get(self.obj1_name)
obj2 = bpy.data.objects.get(self.obj2_name)
else:
if not context.selected_objects or len(context.selected_objects) != 2:
self.report({"ERROR"}, "Need to select 2 objects.")
return {"CANCELLED"}
obj1 = context.active_object
obj2 = next(o for o in context.selected_objects if o != obj1)
tool.Model.sync_object_ifc_position(obj1)
tool.Model.sync_object_ifc_position(obj2)
@@ -477,6 +479,55 @@ class CycleFlowDirection(bpy.types.Operator, tool.Ifc.Operator):
return {"FINISHED"}
class EstablishPathDirection(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.establish_path_direction"
bl_label = "Establish Path Direction"
bl_description = "Propagates flow direction through connected flow segments with two ports"
bl_options = {"REGISTER", "UNDO"}
port_id: bpy.props.IntProperty()
def _execute(self, context):
connected_port = tool.Ifc.get().by_id(self.port_id)
if not connected_port or not connected_port.is_a("IfcDistributionPort"):
self.report({"ERROR"}, "Invalid port specified.")
return {"CANCELLED"}
direction_map = {
"SOURCE": "SINK",
"SINK": "SOURCE",
"SOURCEANDSINK": "SOURCEANDSINK",
"NOTDEFINED": "NOTDEFINED",
}
next_element = tool.System.get_port_relating_element(connected_port)
ports = tool.System.get_ports(next_element)
segments_processed = 0
while len(ports) == 2:
if ports[0].id() == connected_port.id():
other_port = ports[1]
else:
other_port = ports[0]
new_direction = direction_map.get(connected_port.FlowDirection, "NOTDEFINED")
other_port.FlowDirection = new_direction
segments_processed += 1
connected_port = tool.System.get_connected_port(other_port)
if not connected_port:
break
connected_port.FlowDirection = direction_map.get(other_port.FlowDirection, "NOTDEFINED")
next_element = tool.System.get_port_relating_element(connected_port)
if not next_element.is_a("IfcFlowSegment"):
print(f"DEBUG: next_element is not IfcFlowSegment, stopping")
break
ports = tool.System.get_ports(next_element)
self.report({"INFO"}, f"Established path direction through {segments_processed} flow segment(s).")
return {"FINISHED"}
class LoadZones(bpy.types.Operator):
bl_idname = "bim.load_zones"
bl_label = "Load Zones"
+5 -1
View File
@@ -296,7 +296,7 @@ class BIM_PT_port(Panel):
element = tool.Ifc.get_entity(context.active_object)
row = layout.row(align=True)
cols = [row.column(align=True) for i in range(9)]
cols = [row.column(align=True) for i in range(10)]
cols[3].scale_x = 1.0
cols[6].scale_x = 1.0
cols[8].scale_x = 1.33
@@ -333,12 +333,16 @@ class BIM_PT_port(Panel):
else:
cols[7].label(text="", icon="BLANK1")
cols[8].label(text="")
cols[9].operator("bim.establish_path_direction", text="", icon="CON_FOLLOWPATH").port_id = (
connected_port.id()
)
else:
cols[4].label(text="", icon="BLANK1")
cols[5].label(text="", icon="BLANK1")
cols[6].label(text="Port is disconnected")
cols[7].label(text="", icon="BLANK1")
cols[8].label(text="")
cols[9].label(text="", icon="BLANK1")
class BIM_PT_flow_controls(Panel):
+28 -46
View File
@@ -268,63 +268,45 @@ class SaveBlendMetadataFile(bpy.types.Operator):
import bpy
# Ensure all styles are loaded before attempting to remove them
try:
bpy.ops.bim.load_styles()
except Exception:
pass
bpy.ops.bim.load_styles()
# 1. Collect all IfcStyle material names
ifcstyle_material_names = []
try:
styles_props = getattr(bpy.context.scene, "BIMStylesProperties", None)
if styles_props is None and bpy.data.scenes:
styles_props = getattr(bpy.data.scenes[0], "BIMStylesProperties", None)
if styles_props:
for style in list(styles_props.styles):
material = getattr(style, "blender_material", None)
if material and material.name:
ifcstyle_material_names.append(material.name)
except Exception:
pass
styles_props = getattr(bpy.context.scene, "BIMStylesProperties", None)
if styles_props is None and bpy.data.scenes:
styles_props = getattr(bpy.data.scenes[0], "BIMStylesProperties", None)
if styles_props:
for style in list(styles_props.styles):
material = getattr(style, "blender_material", None)
if material and material.name:
ifcstyle_material_names.append(material.name)
# 2. Purge IfcStore
try:
from bonsai.bim.ifc import IfcStore
except ImportError:
IfcStore = None
if IfcStore:
try:
IfcStore.purge()
except Exception:
pass
from bonsai.bim.ifc import IfcStore
IfcStore.purge()
# 3. Remove all collections named IfcProject*
for collection in list(bpy.data.collections):
if collection.name.startswith('IfcProject'):
try:
bpy.data.collections.remove(collection, do_unlink=True)
except Exception:
pass
bpy.data.collections.remove(collection, do_unlink=True)
# 4. Purge orphaned data blocks after removing IfcProject collections
try:
bpy.ops.outliner.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True)
except Exception:
pass
# 4.1 Remove all collections from linked libraries (they will be recreated by bonsai)
for collection in list(bpy.data.collections):
if collection.library:
bpy.data.collections.remove(collection, do_unlink=True)
# 5. Remove all materials corresponding to the IfcStyles we collected
materials_removed = 0
try:
for mat_name in ifcstyle_material_names:
if mat_name in bpy.data.materials:
try:
bpy.data.materials.remove(bpy.data.materials[mat_name], do_unlink=True)
materials_removed += 1
except Exception:
pass
except Exception:
pass
# 4.2. Remove all empty objects that are collection instances for linked models
for obj in list(bpy.data.objects):
if obj.type == 'EMPTY' and obj.instance_type == 'COLLECTION' and obj.name.startswith('IfcProject/'):
bpy.data.objects.remove(obj, do_unlink=True)
# 5. Purge orphaned data blocks after removing IfcProject collections
bpy.ops.outliner.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True)
# 6. Remove all materials corresponding to the IfcStyles we collected
for mat_name in ifcstyle_material_names:
if mat_name in bpy.data.materials:
bpy.data.materials.remove(bpy.data.materials[mat_name], do_unlink=True)
bpy.ops.wm.save_as_mainfile(filepath=r'{blendmetadata_path}')
"""
+16 -4
View File
@@ -467,12 +467,12 @@ class DocPreferences(bpy.types.PropertyGroup):
classes_to_wireframe: StringProperty(
default="IfcVirtualElement",
name="Classes to Wireframe",
description="Upon import, these classes will display as wireframe.\nEx: IfcVirtualelement, IfcSpace",
description="Upon import, these classes will display as wireframe.\nEx: IfcVirtualElement, IfcSpace",
)
classes_no_cut: StringProperty(
default="IfcVirtualElement, IfcSpace",
name="Classes that are not cut",
description="The cut decoractor will be turned off for these classes\nEx: IfcVirtualelement, IfcSpace",
description="The cut decorator will be turned off for these classes\nEx: IfcVirtualElement, IfcSpace",
)
if TYPE_CHECKING:
@@ -660,6 +660,10 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
bsdd_load_test_dictionaries: BoolProperty(
name="Load Test Dictionaries", description="Load dictionaries that are for testing only", default=False
)
bsdd_baseurl: StringProperty(
name="bSDD API Base URL", description="Base URL for data dictionary API requests, e.g. https://api.bsdd.buildingsmart.org/api/",
default="https://api.bsdd.buildingsmart.org/api/",
)
should_disable_undo_on_save: BoolProperty(
name="Disable Undo When Saving (Faster saves, no undo for you!)", default=False
)
@@ -721,13 +725,20 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
chain_filter_with_set_operations: BoolProperty(
name="NEW Filter mode: Enable chained filters with set operations",
description="Enable chaining search filters with set operations: ADD (union: combine sets), SUBTRACT (difference: remove from set), FILTER (intersection: only elements in both sets), with autocomplete suggestions for filter values",
description=(
"Enable chaining search filters with set operations: "
"ADD (union: combine sets), SUBTRACT (difference: remove from set), "
"FILTER (intersection: only elements in both sets), with autocomplete suggestions for filter values"
),
default=False,
)
save_metadata_blend_file: BoolProperty(
name="Save non ifc data to metadata blend File",
description="Save session data (window layout, settings) to a metadata blend file alongside the IFC file. This file is automatically loaded when opening the project.",
description=(
"Save session data (window layout, settings) to a metadata blend file alongside the IFC file. "
"This file is automatically loaded when opening the project."
),
default=False,
)
metadata_blend_file_suffix: StringProperty(
@@ -965,6 +976,7 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
layout.prop(self, "bsdd_load_preview_dictionaries")
layout.prop(self, "bsdd_load_inactive_dictionaries")
layout.prop(self, "bsdd_load_test_dictionaries")
layout.prop(self, "bsdd_baseurl")
def draw_extras_settings(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
layout.prop(self, "container_hide_show_isolate")
+35 -3
View File
@@ -39,12 +39,44 @@ def disable_editing_text(drawing: type[tool.Drawing], obj: bpy.types.Object) ->
def edit_text(drawing: type[tool.Drawing], obj: bpy.types.Object) -> None:
drawing.synchronise_ifc_and_text_attributes(obj)
drawing.update_text_size_pset(obj)
drawing.update_text_annotation_properties(obj)
literal_attributes = drawing.export_text_literal_attributes(obj)
drawing.edit_text_font_size(obj, drawing.export_font_size(obj))
drawing.edit_text_wrap_length(obj, drawing.export_wrap_length(obj))
drawing.edit_text_symbol(obj, drawing.export_symbol(obj))
drawing.edit_text_literals(obj, literal_attributes)
drawing.disable_editing_text(obj)
def copy_text_to_selection(
drawing: type[tool.Drawing],
attribute: Literal["FONT_SIZE", "ALIGNMENT", "WRAP_LENGTH", "SYMBOL", "LITERALS"],
attribute_obj: bpy.types.Object,
apply_objs: list[bpy.types.Object],
) -> None:
if attribute == "FONT_SIZE":
data = drawing.export_font_size(attribute_obj)
elif attribute == "ALIGNMENT":
data = drawing.export_alignment(attribute_obj)
elif attribute == "WRAP_LENGTH":
data = drawing.export_wrap_length(attribute_obj)
elif attribute == "SYMBOL":
data = drawing.export_symbol(attribute_obj)
elif attribute == "LITERALS":
data = drawing.export_text_literal_attributes(attribute_obj)
for obj in apply_objs:
if attribute == "FONT_SIZE":
drawing.edit_text_font_size(obj, data)
elif attribute == "ALIGNMENT":
drawing.edit_text_alignment(obj, data)
elif attribute == "WRAP_LENGTH":
drawing.edit_text_wrap_length(obj, data)
elif attribute == "SYMBOL":
drawing.edit_text_symbol(obj, data)
elif attribute == "LITERALS":
drawing.edit_text_literals(obj, data)
drawing.disable_editing_text(obj)
def enable_editing_assigned_product(drawing: type[tool.Drawing], obj: bpy.types.Object) -> None:
drawing.enable_editing_assigned_product(obj)
drawing.import_assigned_product(obj)
+3 -1
View File
@@ -29,6 +29,7 @@ if TYPE_CHECKING:
def add_georeferencing(georeference: type[tool.Georeference]) -> None:
georeference.add_georeferencing()
georeference.set_model_origin()
def enable_editing_georeferencing(georeference: type[tool.Georeference]) -> None:
@@ -37,8 +38,9 @@ def enable_editing_georeferencing(georeference: type[tool.Georeference]) -> None
georeference.enable_editing()
def remove_georeferencing(ifc: type[tool.Ifc]) -> None:
def remove_georeferencing(ifc: type[tool.Ifc], georeference: type[tool.Georeference]) -> None:
ifc.run("georeference.remove_georeferencing")
georeference.set_model_origin()
def disable_editing_georeferencing(georeference: type[tool.Georeference]) -> None:
+10 -8
View File
@@ -334,6 +334,11 @@ class Drawing:
def disable_editing_sheets(cls): pass
def disable_editing_text(cls, obj): pass
def does_file_exist(cls, uri): pass
def edit_text_alignment(cls, obj, alignment): pass
def edit_text_font_size(cls, obj, size): pass
def edit_text_literals(cls, obj, literals): pass
def edit_text_symbol(cls, obj, symbol): pass
def edit_text_wrap_length(cls, obj, wrap_length): pass
def enable_editing(cls, obj): pass
def enable_editing_assigned_product(cls, obj): pass
def enable_editing_drawings(cls): pass
@@ -402,10 +407,7 @@ class Drawing:
def setup_shading_styles_path(cls, resource_path): pass
def show_decorations(cls): pass
def sync_object_placement(cls, obj): pass
def synchronise_ifc_and_text_attributes(cls, obj): pass
def update_embedded_svg_location(cls, uri, old_location, new_location): pass
def update_text_annotation_properties(cls, obj): pass
def update_text_size_pset(cls, obj): pass
@interface
@@ -951,11 +953,11 @@ class Spatial:
def get_active_container(cls): pass
def get_container(cls, element): pass
def get_decomposed_elements(cls, container, recursive): pass
def get_decomposition(cls, element): pass
def get_object_matrix(cls, obj): pass
def get_relative_object_matrix(cls, target_obj, relative_to_obj): pass
def get_selected_product_types(cls): pass
def get_root_element(cls, element): pass
def get_decomposition(cls, element): pass
def get_selected_product_types(cls): pass
def get_selected_products(cls): pass
def import_spatial_decomposition(cls): pass
def import_spatial_element(cls, element, level_index): pass
@@ -1125,15 +1127,15 @@ class Unit:
def disable_editing_units(cls): pass
def enable_editing_units(cls): pass
def export_unit_attributes(cls): pass
def get_currency_name(cls): pass
def get_project_currency_unit(cls): pass
def get_scene_unit_name(cls, unit_type): pass
def get_scene_unit_si_prefix(cls, name): pass
def import_unit_attributes(cls, unit): pass
def import_units(cls): pass
def is_scene_unit_metric(cls): pass
def is_si_unit(cls, name): pass
def is_unit_class(cls, unit, ifc_class): pass
def set_active_unit(cls, unit): pass
def get_project_currency_unit(cls): pass
def get_currency_name(cls): pass
@interface
class Voider:
+53 -5
View File
@@ -36,6 +36,7 @@ from typing import (
TYPE_CHECKING,
Any,
Literal,
NamedTuple,
Optional,
TypeVar,
Union,
@@ -49,7 +50,7 @@ import ifcopenshell.util.element
import numpy as np
import numpy.typing as npt
from ifcopenshell import entity_instance
from mathutils import Vector
from mathutils import Matrix, Vector
import bonsai.bim
import bonsai.core.tool
@@ -718,13 +719,18 @@ class Blender(bonsai.core.tool.Blender):
if active_object:
active_object.select_set(True)
class ObjectsSelectionArgs(NamedTuple):
context: bpy.types.Context
active_object: bpy.types.Object | None
selected_objects: list[bpy.types.Object]
@classmethod
def validate_object_selection(
cls,
context: bpy.types.Context,
active_object: Union[bpy.types.Object, None] = None,
selected_objects: Sequence[bpy.types.Object] = (),
) -> tuple[bpy.types.Context, Union[bpy.types.Object, None], list[bpy.types.Object]]:
) -> ObjectsSelectionArgs:
"""Validate object selection and return only valid objects.
Can be used before ``set_objects_selection`` to avoid errors
@@ -734,12 +740,15 @@ class Blender(bonsai.core.tool.Blender):
assert context.view_layer
view_layer_objects = set(context.view_layer.objects)
new_selected_objects = [o for o in selected_objects if cls.is_valid_data_block(o) and o in view_layer_objects]
def is_selectable(obj: bpy.types.Object) -> bool:
return cls.is_valid_data_block(obj) and obj in view_layer_objects
if active_object and not cls.is_valid_data_block(active_object):
new_selected_objects = [o for o in selected_objects if is_selectable(o)]
if active_object and not is_selectable(active_object):
active_object = None
return context, active_object, new_selected_objects
return cls.ObjectsSelectionArgs(context, active_object, new_selected_objects)
@classmethod
def clear_objects_selection(cls) -> None:
@@ -2127,3 +2136,42 @@ class Blender(bonsai.core.tool.Blender):
if cls.BLENDER_5:
return "BLENDER_EEVEE"
return "BLENDER_EEVEE_NEXT"
@classmethod
def np_frombuffer_legacy(cls, bytedata: bytes, n: int) -> npt.NDArray[np.float32]:
"""
Read ``n`` float values from ``bytedata``, regardless if they are stored as ``float32`` or ``float64``.
Needed to support .blend files saved in Blender <5.0.0.
Also allows to work with .blend files from 5.0.0+ in older Blender versions.
In ``bpy.app.version >= 5.0.0`` ``mathutils`` transitioned to use ``float32`` buffer type,
while in previous version they were using ``float64``.
In some cases we are storing raw bytes (e.g. object transforms cheksums), so old .blend files
might still have ``float64`` data stored.
See https://projects.blender.org/blender/blender/issues/149283
"""
if len(bytedata) == (n * 2):
return np.frombuffer(bytedata, dtype=np.float64).astype(np.float32)
return np.frombuffer(bytedata, dtype=np.float32)
@classmethod
def np_array_legacy(cls, mathutils_type: Union[Vector, Matrix]) -> npt.NDArray[np.float32]:
"""
Converts ``mathutils`` types to ``np.float32`` arrays, regardless of Blender version.
See ``np_frombuffer_legacy`` for more details.
"""
if cls.BLENDER_5:
return np.array(mathutils_type)
return np.array(mathutils_type, dtype=np.float32)
@classmethod
def get_selected_files(
cls, directory: str, files: bpy.types.OperatorFileListElement, use_relative_path=False
) -> list[Path]:
return [
tool.Ifc.get_uri(Path(directory) / f.name, use_relative_path=use_relative_path)
for f in files
if (Path(directory) / f.name).is_file()
]
+4
View File
@@ -123,6 +123,10 @@ class Bsdd(bonsai.core.tool.Bsdd):
@classmethod
def get_dictionaries(cls) -> list[bsdd.DictionaryContractV1]:
prefs = tool.Blender.get_addon_preferences()
baseurl = getattr(prefs, "bsdd_baseurl", "https://api.bsdd.buildingsmart.org/api/")
cls.client = bsdd.Client()
if hasattr(cls.client, "baseurl"):
cls.client.baseurl = baseurl
response = cls.client.get_dictionary(include_test_dictionaries=prefs.bsdd_load_test_dictionaries)
dicts = response.get("dictionaries") or []
statuses = ["Active"]
+2
View File
@@ -46,6 +46,8 @@ class Collector(bonsai.core.tool.Collector):
# Note that tool.Geometry.is_locked is only checked within the if
# statements for efficiency as it is a slow check.
tool.Geometry.lock_scale(obj)
if element.is_a("IfcSlab"):
tool.Geometry.lock_rotation(obj, x=True)
if element.is_a("IfcGridAxis"):
if tool.Geometry.is_locked(element):
+8 -14
View File
@@ -23,6 +23,7 @@ from collections.abc import Generator
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, Optional, Union, assert_never
import aud
import bpy
import ifcopenshell.api
import ifcopenshell.api.cost
@@ -143,25 +144,18 @@ class Cost(bonsai.core.tool.Cost):
@classmethod
def play_sound(cls) -> None:
if tool.Blender.get_addon_preferences().should_play_chaching_sound:
# Save ears for those running the tests in background mode.
if tool.Blender.get_addon_preferences().should_play_chaching_sound and not bpy.app.background:
cls.play_chaching_sound() # lol
@classmethod
def play_chaching_sound(cls) -> None:
# TODO: make pitch higher as costs rise
try:
import aud
device = aud.Device()
# chaching.mp3 is by Lucish_ CC-BY-3.0 https://freesound.org/people/Lucish_/sounds/554841/
sound = aud.Sound(tool.Blender.get_data_dir_path(filename="chaching.mp3").__str__())
handle = device.play(sound)
sound_buffered = aud.Sound.buffer(sound)
handle_buffered = device.play(sound_buffered)
handle.stop()
handle_buffered.stop()
except:
pass # ah well
device = aud.Device()
# chaching.mp3 is by Lucish_ CC-BY-3.0 https://freesound.org/people/Lucish_/sounds/554841/
filepath = tool.Blender.get_data_dir_path("chaching.mp3").__str__()
sound = aud.Sound(filepath)
device.play(sound)
@classmethod
def load_cost_schedule_tree(cls) -> None:
+8
View File
@@ -251,11 +251,19 @@ class Document(bonsai.core.tool.Document):
def get_document_references(
cls, document: ifcopenshell.entity_instance
) -> tuple[ifcopenshell.entity_instance, ...]:
# TODO: migrate to util.document and replace all instances
"""Get IfcDocumentReference.ReferencedDocuments, compatible with IFC2X3."""
if document.file.schema == "IFC2X3":
return document.DocumentReferences or ()
return document.HasDocumentReferences
@classmethod
def get_reference_document(cls, reference: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance | None:
# TODO: migrate to util.document and replace all instances
if reference.file.schema == "IFC2X3":
return (reference.ReferenceToDocument or (None))[0]
return reference.ReferencedDocument
@classmethod
def clear_active_document(cls) -> None:
props = cls.get_document_props()
+49 -74
View File
@@ -608,6 +608,25 @@ class Drawing(bonsai.core.tool.Drawing):
literals.append(literal_data)
return literals
@classmethod
def export_font_size(cls, obj: bpy.types.Object) -> str:
return float(cls.get_text_props(obj).font_size)
@classmethod
def export_alignment(cls, obj: bpy.types.Object) -> str:
props = cls.get_text_props(obj)
if (alignment := props.align_vertical + "-" + props.align_horizontal) == "middle-middle":
return "center"
return alignment
@classmethod
def export_wrap_length(cls, obj: bpy.types.Object) -> str:
return cls.get_text_props(obj).newline_at
@classmethod
def export_symbol(cls, obj: bpy.types.Object) -> str:
return cls.get_text_props(obj).get_symbol()
@classmethod
def create_annotation_context(
cls, target_view: str, object_type: Optional[str] = None
@@ -835,43 +854,14 @@ class Drawing(bonsai.core.tool.Drawing):
return props.is_editing_sheets
@classmethod
def synchronise_ifc_and_text_attributes(cls, obj: bpy.types.Object) -> None:
def edit_text_literals(cls, obj: bpy.types.Object, literal_attributes: dict) -> None:
assert (element := tool.Ifc.get_entity(obj))
assert (rep := cls.get_annotation_representation(element))
old_literals = cls.get_text_literal(obj, return_list=True)
assert isinstance(old_literals, list)
literals_attributes = cls.export_text_literal_attributes(obj)
props = cls.get_text_props(obj)
defined_ifc_ids = [l.ifc_definition_id for l in props.literals]
ifc_file = tool.Ifc.get()
added_literals: list[ifcopenshell.entity_instance] = []
new_literals: list[ifcopenshell.entity_instance] = []
for ifc_definition_id, attributes in zip(defined_ifc_ids, literals_attributes):
# making sure all literals from text edit exist in ifc
if ifc_definition_id == 0:
literal = cls.add_literal(**attributes)
added_literals.append(literal)
else:
literal = ifc_file.by_id(ifc_definition_id)
ifcopenshell.api.drawing.edit_text_literal(
ifc_file,
text_literal=literal,
attributes=attributes,
)
new_literals.append(literal)
removed_literals = set(old_literals) - set(new_literals)
# Add new literals and keep the order as defined in text props.
items = [i for i in rep.Items if i not in removed_literals] + added_literals
items.sort(key=lambda x: new_literals.index(x) if x in new_literals else -1)
rep.Items = items
# Remove from ifc the literals that were removed during the edit.
for literal in removed_literals:
ifcopenshell.util.element.remove_deep2(ifc_file, literal)
to_remove = [i for i in rep.Items if i.is_a("IfcTextLiteral")]
new_literals = [cls.add_literal(**a) for a in literal_attributes]
rep.Items = [i for i in rep.Items if not i.is_a("IfcTextLiteral")] + new_literals
for literal in to_remove:
ifcopenshell.util.element.remove_deep2(tool.Ifc.get(), literal)
@classmethod
def add_literal(cls, **attributes: str) -> ifcopenshell.entity_instance:
@@ -1212,8 +1202,6 @@ class Drawing(bonsai.core.tool.Drawing):
props.font_size = str(text_data["FontSize"])
props.newline_at = text_data["Newline_At"]
props.set_symbol(text_data["Symbol"])
props.reverse_list = text_data["Reverse_List"]
props.list_separator = text_data["List_Separator"]
@classmethod
def import_assigned_product(cls, obj: bpy.types.Object) -> None:
@@ -1310,7 +1298,7 @@ class Drawing(bonsai.core.tool.Drawing):
props.should_draw_decorations = True
@classmethod
def update_text_size_pset(cls, obj: bpy.types.Object) -> None:
def edit_text_font_size(cls, obj: bpy.types.Object, font_size: float) -> None:
"""updates pset `EPset_Annotation.Classes` value
based on current font size from `obj.BIMTextProperties.font_size`
"""
@@ -1320,7 +1308,6 @@ class Drawing(bonsai.core.tool.Drawing):
element = tool.Ifc.get_entity(obj)
assert element
# updating text font size in EPset_Annotation.Classes
font_size = float(props.font_size)
font_size_str = next((key for key in FONT_SIZES if FONT_SIZES[key] == font_size), None)
classes = ifcopenshell.util.element.get_pset(element, "EPset_Annotation", "Classes")
assert isinstance(classes, Union[str, None])
@@ -1341,34 +1328,32 @@ class Drawing(bonsai.core.tool.Drawing):
pset = tool.Pset.get_element_pset(element, "EPset_Annotation")
if not pset:
pset = ifcopenshell.api.pset.add_pset(ifc_file, product=element, name="EPset_Annotation")
ifcopenshell.api.pset.edit_pset(
ifc_file,
pset=pset,
properties={"Classes": classes},
)
ifcopenshell.api.pset.edit_pset(ifc_file, pset=pset, properties={"Classes": classes})
@classmethod
def update_text_annotation_properties(cls, obj: bpy.types.Object) -> None:
"""Update all EPset_Annotation properties from the text props"""
props = cls.get_text_props(obj)
def edit_text_wrap_length(cls, obj: bpy.types.Object, wrap_length: int) -> None:
element = tool.Ifc.get_entity(obj)
assert element
ifc_file = tool.Ifc.get()
pset = tool.Pset.get_element_pset(element, "EPset_Annotation")
if not pset:
pset = ifcopenshell.api.pset.add_pset(ifc_file, product=element, name="EPset_Annotation")
ifcopenshell.api.pset.edit_pset(ifc_file, pset=pset, properties={"Newline_At": wrap_length})
ifcopenshell.api.pset.edit_pset(
ifc_file,
pset=pset,
properties={
"Newline_At": int(props.newline_at),
"Symbol": props.get_symbol(),
"Reverse_List": props.reverse_list,
"List_Separator": props.list_separator or "",
},
)
@classmethod
def edit_text_symbol(cls, obj: bpy.types.Object, symbol: str) -> None:
element = tool.Ifc.get_entity(obj)
ifc_file = tool.Ifc.get()
pset = tool.Pset.get_element_pset(element, "EPset_Annotation")
if not pset:
pset = ifcopenshell.api.pset.add_pset(ifc_file, product=element, name="EPset_Annotation")
ifcopenshell.api.pset.edit_pset(ifc_file, pset=pset, properties={"Symbol": symbol})
@classmethod
def edit_text_alignment(cls, obj: bpy.types.Object, alignment: str) -> None:
ifc_literals = cls.get_text_literal(obj, return_list=True)
for ifc_literal in ifc_literals or []:
if ifc_literal.is_a("IfcTextLiteralWithExtent"):
ifc_literal.BoxAlignment = alignment
# TODO below this point is highly experimental prototype code with no tests
@@ -2112,31 +2097,21 @@ class Drawing(bonsai.core.tool.Drawing):
cls,
text: str,
product: Optional[ifcopenshell.entity_instance] = None,
reverse_list: bool = False,
list_separator: str = ", ",
) -> str:
if not product:
return text
if list_separator:
list_separator = list_separator.encode().decode("unicode_escape")
for command in re.findall("``.*?``", text):
for command in re.findall("``.+?``", text):
original_command = command
command_content = command[2:-2]
if command_content is None or str(command_content).strip().lower() == "none":
try:
text = text.replace(original_command, ifcopenshell.util.selector.format(command_content, product))
except Exception:
text = text.replace(original_command, "")
else:
try:
text = text.replace(original_command, ifcopenshell.util.selector.format(command_content, product))
except Exception:
text = text.replace(original_command, "")
for variable in re.findall("{{.*?}}", text):
value = ifcopenshell.util.selector.get_element_value(product, variable[2:-2])
if isinstance(value, (list, tuple)):
if reverse_list:
value = list_separator.join(str(v) for v in reversed(value))
else:
value = list_separator.join(str(v) for v in value)
value = ", ".join(str(v) for v in value)
text = text.replace(variable, str(value))
return text
+2 -3
View File
@@ -1152,9 +1152,8 @@ 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)
# 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())
props.location_checksum = repr(tool.Blender.np_array_legacy(obj.matrix_world.translation).tobytes())
props.rotation_checksum = repr(tool.Blender.np_array_legacy(obj.matrix_world.to_3x3()).tobytes())
@classmethod
def remove_connection(cls, connection: ifcopenshell.entity_instance) -> None:
+18
View File
@@ -315,6 +315,21 @@ class Georeference(bonsai.core.tool.Georeference):
)
return coordinates
@classmethod
def global2local(cls, matrix, is_specified_in_map_units: bool) -> tuple[float, float, float]:
matrix = ifcopenshell.util.geolocation.auto_global2local(tool.Ifc.get(), matrix, is_specified_in_map_units=is_specified_in_map_units)
props = cls.get_georeference_props()
if props.has_blender_offset:
matrix = ifcopenshell.util.geolocation.global2local(
matrix,
float(props.blender_offset_x),
float(props.blender_offset_y),
float(props.blender_offset_z),
float(props.blender_x_axis_abscissa),
float(props.blender_x_axis_ordinate),
)
return matrix
@classmethod
def import_plot(cls, filepath: str) -> None:
import bmesh
@@ -385,6 +400,9 @@ class Georeference(bonsai.core.tool.Georeference):
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
gprops = tool.Georeference.get_georeference_props()
e, n, h = cls.xyz2enh((0, 0, 0), should_return_in_map_units=False)
crs = ifcopenshell.util.geolocation.get_crs(tool.Ifc.get()) or {}
gprops.model_is_georeferenced = bool(crs)
gprops.model_crs = crs.get("Name", "") or ""
gprops.model_origin = f"{e},{n},{h}"
gprops.model_origin_si = f"{e * unit_scale},{n * unit_scale},{h * unit_scale}"
angle = ifcopenshell.util.geolocation.get_grid_north(tool.Ifc.get())
+7 -14
View File
@@ -115,24 +115,17 @@ class Ifc(bonsai.core.tool.Ifc):
return True # Let's be conservative
# Handle both old float64 and new float32 checksums for version compatibility
loc_checksum_bytes = eval(oprops.location_checksum)
if len(loc_checksum_bytes) == 24: # Old format: 3 * 8 bytes (float64)
loc_check = np.frombuffer(loc_checksum_bytes, dtype=np.float64).astype(np.float32)
else: # New format: 3 * 4 bytes (float32)
loc_check = np.frombuffer(loc_checksum_bytes, dtype=np.float32)
loc_real = np.array(obj.matrix_world.translation, dtype=np.float32).flatten()
loc_checksum_bytes: bytes = eval(oprops.location_checksum)
loc_check = tool.Blender.np_frombuffer_legacy(loc_checksum_bytes, 3)
loc_real = tool.Blender.np_array_legacy(obj.matrix_world.translation)
if not np.allclose(loc_check, loc_real, atol=1e-4): # 0.1 mm
return True
# Handle both old float64 and new float32 checksums for version compatibility
rot_checksum_bytes = eval(oprops.rotation_checksum)
if len(rot_checksum_bytes) == 72: # Old format: 9 * 8 bytes (float64)
rot_check = np.frombuffer(rot_checksum_bytes, dtype=np.float64).astype(np.float32).reshape(3, 3)
else: # New format: 9 * 4 bytes (float32)
rot_check = np.frombuffer(rot_checksum_bytes, dtype=np.float32).reshape(3, 3)
rot_real = np.array(obj.matrix_world.to_3x3(), dtype=np.float32)
rot_checksum_bytes: bytes = eval(oprops.rotation_checksum)
rot_check = tool.Blender.np_frombuffer_legacy(rot_checksum_bytes, 9)
rot_check = rot_check.reshape(3, 3)
rot_real = tool.Blender.np_array_legacy(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))
if angle_rad > 0.0017453292519943296: # 0.1 degrees
+4 -1
View File
@@ -64,10 +64,13 @@ class IfcGit:
@classmethod
def clone_repo(cls, remote_url: str, local_folder: str) -> git.Repo:
IfcGitRepo.repo = git.Repo.clone_from(
repo = git.Repo.clone_from(
url=remote_url,
to_path=local_folder,
)
# Close the repo to release stale subprocess
repo.close()
IfcGitRepo.repo = git.Repo(local_folder)
cls.config_info_attributes(IfcGitRepo.repo)
return IfcGitRepo.repo
+66 -151
View File
@@ -39,6 +39,7 @@ import numpy as np
import numpy.typing as npt
from ifcopenshell.util.shape_builder import np_to_4d
from mathutils import Matrix, Vector
from mathutils.kdtree import KDTree
import bonsai.bim.import_ifc
import bonsai.core.tool
@@ -178,7 +179,8 @@ class Loader(bonsai.core.tool.Loader):
def surface_texture_to_dict(cls, surface_texture):
if isinstance(surface_texture, dict):
return surface_texture
mappings = surface_texture.IsMappedBy or []
# IsMappedBy is an IFC4+ inverse attribute, not available in IFC2X3.
mappings = getattr(surface_texture, "IsMappedBy", None) or []
surface_texture = surface_texture.get_info()
uv_mode = None
if mappings:
@@ -187,7 +189,7 @@ class Loader(bonsai.core.tool.Loader):
uv_mode = "Generated"
elif coordinates.is_a("IfcTextureCoordinateGenerator") and coordinates.Mode == "COORD-EYE":
uv_mode = "Camera"
surface_texture["uv_mode"] = uv_mode or "UV"
surface_texture["uv_mode"] = uv_mode or "Generated"
return surface_texture
@classmethod
@@ -285,6 +287,9 @@ class Loader(bonsai.core.tool.Loader):
for texture in textures:
mode = texture.get("Mode", None)
# IFC2X3 IfcImageTexture has no Mode attribute; default to DIFFUSE.
if mode is None and texture["type"] == "IfcImageTexture":
mode = "DIFFUSE"
node = None
image_url = None
@@ -292,7 +297,8 @@ class Loader(bonsai.core.tool.Loader):
def get_image() -> Union[bpy.types.Image, None]:
# TODO: orphaned textures after shader recreated?
if texture["type"] == "IfcImageTexture":
original_image_url = texture["URLReference"]
# IFC2X3 uses UrlReference, IFC4+ uses URLReference.
original_image_url = texture.get("URLReference") or texture.get("UrlReference", "")
is_relative = not os.path.isabs(original_image_url)
nonlocal image_url
image_url = Path(original_image_url)
@@ -538,6 +544,33 @@ class Loader(bonsai.core.tool.Loader):
for colour in colours:
cls.load_indexed_map(colour, mesh)
@classmethod
def load_generated_uv_map(cls, mesh: bpy.types.Mesh) -> None:
bm = bmesh.new()
bm.from_mesh(mesh)
uv_layer = bm.loops.layers.uv.active or bm.loops.layers.uv.new("UVMap")
all_verts = [v.co for v in bm.verts]
if not all_verts:
bm.free()
return
min_x = min(v.x for v in all_verts)
max_x = max(v.x for v in all_verts)
min_y = min(v.y for v in all_verts)
max_y = max(v.y for v in all_verts)
width = max_x - min_x
height = max_y - min_y
for face in bm.faces:
for loop in face.loops:
u = (loop.vert.co.x - min_x) / width if width > 0 else 0.5
v = (loop.vert.co.y - min_y) / height if height > 0 else 0.5
loop[uv_layer].uv = (max(0.0, min(1.0, u)), max(0.0, min(1.0, v)))
bm.to_mesh(mesh)
bm.free()
@classmethod
def load_indexed_map(cls, index_map: ifcopenshell.entity_instance, mesh: bpy.types.Mesh) -> None:
"""Add data from index map as blender mesh attribute.
@@ -562,7 +595,18 @@ class Loader(bonsai.core.tool.Loader):
bm_verts = np.array([v.co for v in bm.verts])
coords_scaled = np.array(faceset.Coordinates.CoordList) * si_conversion
coordinates_remap = [np.argmin(np.sum((bm_verts - co) ** 2, axis=1)) for co in coords_scaled]
# See #2824. IfcIndexedColourMap is not natively handled by IfcOpenShell
# As a result, we map IFC coords to Blender coords (highly wasteful but...)
# coordinates_remap = [np.argmin(np.sum((bm_verts - co) ** 2, axis=1)) for co in coords_scaled]
# Because this is O(N*M), here is a faster KDTree implementation.
kd = KDTree(len(bm_verts))
for i, v in enumerate(bm_verts):
kd.insert((float(v[0]), float(v[1]), float(v[2])), i)
kd.balance()
coordinates_remap = np.empty(len(coords_scaled), dtype=np.int32)
for j, co in enumerate(coords_scaled):
_co, index, _dist = kd.find((float(co[0]), float(co[1]), float(co[2])))
coordinates_remap[j] = index
# ifc indices start with 1
remap_verts_to_blender = lambda ifc_verts: [coordinates_remap[i - 1] for i in ifc_verts]
@@ -1024,7 +1068,7 @@ class Loader(bonsai.core.tool.Loader):
elif material.is_a("IfcMaterialLayerSetUsage"):
usage = material
layer_set = material.ForLayerSet
offset = usage.OffsetFromReferenceLine
offset = usage.OffsetFromReferenceLine * cls.unit_scale
sense_factor = 1 if usage.DirectionSense == "POSITIVE" else -1
elif material.is_a("IfcMaterialLayerSet"):
usage = None
@@ -1033,168 +1077,61 @@ class Loader(bonsai.core.tool.Loader):
sense_factor = 1
else:
return mesh
if len(layer_set.MaterialLayers) == 1:
return mesh
# Get mesh bounds
if len(mesh.vertices) > 0:
z_coords = [v.co.z for v in mesh.vertices]
mesh_z_min = min(z_coords)
mesh_z_max = max(z_coords)
bm = bmesh.new()
bm.from_mesh(mesh)
prev_co = None
advance_direction = None
if not usage:
sense_factor = 1
sense_factor = 1 # Assume the extrusion vector points in the direction sense
no = cls.get_extrusion_vector(element).normalized()
co = Vector((0.0, 0.0, offset))
advance_direction = no
elif usage.LayerSetDirection == "AXIS2":
# Get local extrusion direction
local_extrusion = Vector([0.0, 0.0, 1.0])
if body := ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW"):
for item in ifcopenshell.util.representation.resolve_representation(body).Items:
while item.is_a("IfcBooleanResult"):
item = item.FirstOperand
if item.is_a("IfcExtrudedAreaSolid"):
local_extrusion = Vector(item.ExtrudedDirection.DirectionRatios).normalized()
break
# Thickness direction: perpendicular to extrusion and length
thickness_dir = local_extrusion.cross(Vector([1.0, 0.0, 0.0])).normalized()
if thickness_dir.y < 0:
thickness_dir = -thickness_dir
no = thickness_dir
# Find start point by projecting vertices onto thickness direction
if len(mesh.vertices) > 0:
projections = [Vector(v.co).dot(no) for v in mesh.vertices]
min_proj = min(projections)
max_proj = max(projections)
centroid = sum((Vector(v.co) for v in mesh.vertices), Vector()) / len(mesh.vertices)
centroid_proj = centroid.dot(no)
if sense_factor == 1:
start_proj = min_proj
else:
start_proj = max_proj
offset_dist = start_proj - centroid_proj
co = centroid + no * offset_dist
actual_mesh_height = max_proj - min_proj
else:
co = Vector((0.0, 0.0, 0.0))
advance_direction = thickness_dir
co = Vector((0.0, offset, 0.0))
no = cls.get_extrusion_vector(element).normalized()
no = no.cross(Vector([1.0, 0.0, 0.0]))
elif usage.LayerSetDirection == "AXIS3":
# AXIS3 layers go through slab thickness (local Z)
co = Vector((0.0, 0.0, offset))
no = cls.get_extrusion_vector(element).normalized()
no = Vector([0.0, 0.0, 1.0])
# Find start point by projecting vertices onto Z direction
if len(mesh.vertices) > 0:
projections = [Vector(v.co).dot(no) for v in mesh.vertices]
min_proj = min(projections)
max_proj = max(projections)
centroid = sum((Vector(v.co) for v in mesh.vertices), Vector()) / len(mesh.vertices)
centroid_proj = centroid.dot(no)
if sense_factor == 1:
start_proj = min_proj
else:
start_proj = max_proj
offset = start_proj - centroid_proj
co = centroid + no * offset
actual_mesh_height = max_proj - min_proj
else:
co = Vector((0.0, 0.0, 0.0))
advance_direction = no
elif usage.LayerSetDirection == "AXIS1":
co = Vector((0.0, 0.0, offset))
no = cls.get_extrusion_vector(element).normalized()
no = Vector([1.0, 0.0, 0.0])
advance_direction = no
# Apply DirectionSense
if usage and usage.LayerSetDirection == "AXIS2":
if sense_factor == -1:
advance_direction = -advance_direction
test_normal = -no
else:
test_normal = no
elif usage and usage.LayerSetDirection == "AXIS1":
no = no * sense_factor
advance_direction = advance_direction * sense_factor
test_normal = no
elif usage and usage.LayerSetDirection == "AXIS3":
if sense_factor == -1:
advance_direction = -advance_direction
test_normal = -no
else:
test_normal = no
else:
test_normal = no
# Cache material styles
no *= sense_factor
# Cache this
body = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Model", "Body", "MODEL_VIEW")
styles = {}
has_layer_styles = False
for i, material in enumerate(mesh.materials):
if style := tool.Ifc.get_entity(material):
styles[style] = i
layer_list = list(enumerate(layer_set.MaterialLayers))
# Calculate scale factor
total_layer_thickness = sum(layer.LayerThickness for _, layer in layer_list)
if "actual_mesh_height" not in locals():
actual_mesh_height = mesh_z_max - mesh_z_min if len(mesh.vertices) > 0 else total_layer_thickness
thickness_scale = actual_mesh_height / total_layer_thickness if total_layer_thickness > 0 else 1.0
last_i = len(layer_set.MaterialLayers) - 1
for idx, (original_i, layer) in enumerate(layer_list):
if idx != last_i:
for i, layer in enumerate(layer_set.MaterialLayers):
if i != last_i:
prev_co = co.copy()
advance_vector = advance_direction * layer.LayerThickness * thickness_scale
co += advance_vector
co += no * layer.LayerThickness * cls.unit_scale
bisect_geom = bmesh.ops.bisect_plane(
bm, geom=bm.verts[:] + bm.edges[:] + bm.faces[:], dist=0.0001, plane_co=co, plane_no=no
)
bmesh.ops.duplicate(bm, geom=bisect_geom["geom_cut"])
if not (style := ifcopenshell.util.representation.get_material_style(layer.Material, body)):
continue
if (material_index := styles.get(style, None)) is None:
material_index = len(mesh.materials)
mesh.materials.append(tool.Ifc.get_object(style))
if idx == last_i:
if i == last_i:
for face in bisect_geom["geom"]:
if isinstance(face, bmesh.types.BMFace):
center = face.calc_center_median()
if (center - co).dot(test_normal) >= 0:
if (center - co).dot(no) >= 0:
face.material_index = material_index
has_layer_styles = True
else:
for face in bisect_geom["geom"]:
if isinstance(face, bmesh.types.BMFace):
center = face.calc_center_median()
if (center - co).dot(test_normal) < 0 and (center - prev_co).dot(test_normal) >= 0:
if (center - co).dot(no) < 0 and (center - prev_co).dot(no) >= 0:
face.material_index = material_index
has_layer_styles = True
@@ -1207,35 +1144,13 @@ class Loader(bonsai.core.tool.Loader):
return mesh
@classmethod
def get_extrusion_vector(cls, element):
"""Get the extrusion direction in WORLD coordinates (accounting for object rotation)"""
if body := ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW"):
def get_extrusion_vector(cls, wall):
if body := ifcopenshell.util.representation.get_representation(wall, "Model", "Body", "MODEL_VIEW"):
for item in ifcopenshell.util.representation.resolve_representation(body).Items:
while item.is_a("IfcBooleanResult"):
item = item.FirstOperand
if item.is_a("IfcExtrudedAreaSolid"):
local_direction = Vector(item.ExtrudedDirection.DirectionRatios)
# Transform to world coordinates using object rotation
obj = tool.Ifc.get_object(element)
if obj:
# Apply object rotation to get actual world direction
world_direction = obj.matrix_world.to_3x3() @ local_direction
return world_direction
return local_direction
return Vector([0.0, 0.0, 1.0])
@classmethod
def get_local_extrusion_vector(cls, element):
"""Get the extrusion direction in LOCAL coordinates (from IFC, no object rotation)"""
if body := ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW"):
for item in ifcopenshell.util.representation.resolve_representation(body).Items:
while item.is_a("IfcBooleanResult"):
item = item.FirstOperand
if item.is_a("IfcExtrudedAreaSolid"):
local_direction = Vector(item.ExtrudedDirection.DirectionRatios)
return local_direction
return Vector(item.ExtrudedDirection.DirectionRatios)
return Vector([0.0, 0.0, 1.0])
@classmethod
+25 -35
View File
@@ -1051,6 +1051,7 @@ class Model(bonsai.core.tool.Model):
tool.Model.regenerate_array(obj, array_data)
array_pset = tool.Pset.get_element_pset(element, "BBIM_Array")
json_data = tool.Ifc.get().createIfcText(json.dumps(array_data))
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=array_pset, properties={"Data": json_data})
@@ -1063,22 +1064,17 @@ class Model(bonsai.core.tool.Model):
cls, parent_obj: bpy.types.Object, data: list[dict[str, Any]], array_layers_to_apply: Iterable[int] = tuple()
) -> None:
"""`array_layers_to_apply` - list of array layer indices to apply"""
tool.Blender.Modifier.Array.remove_constraints(tool.Ifc.get_entity(parent_obj))
parent_element = tool.Ifc.get_entity(parent_obj)
if pset := ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array"):
ifcopenshell.api.pset.remove_pset(
tool.Ifc.get(), product=parent_element, pset=tool.Ifc.get().by_id(pset["id"])
)
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
obj_stack = [parent_obj]
for array_i, array in enumerate(data):
# for `sync_children` we remove all previously generated children to regenerate them again
# to assure they are in complete sync (psets, etc) with the array parent
if array["sync_children"]:
removed_children = set(array["children"])
for removed_child in removed_children:
element = tool.Ifc.get().by_guid(removed_child)
if obj := tool.Ifc.get_object(element):
tool.Geometry.delete_ifc_object(obj)
array["children"].clear()
child_i = 0
existing_children = set(array["children"])
total_existing_children = len(array["children"])
@@ -1104,22 +1100,21 @@ class Model(bonsai.core.tool.Model):
child_obj = tool.Ifc.get_object(child_element)
assert child_obj
except:
old_to_new, _ = tool.Geometry.duplicate_ifc_objects([obj])
# TODO Is this correct to assume one child? I really
# don't understand the linked aggregates and array
# behaviour.
old_to_new, _ = tool.Geometry.duplicate_ifc_objects([parent_obj])
child_element = next(iter(old_to_new.values()))[0]
child_obj = tool.Ifc.get_object(child_element)
# add child pset
child_pset = tool.Pset.get_element_pset(child_element, "BBIM_Array")
if child_pset:
ifcopenshell.api.pset.edit_pset(
tool.Ifc.get(),
pset=child_pset,
properties={"Data": None},
should_purge=False,
if not (child_pset := tool.Pset.get_element_pset(child_element, "BBIM_Array")):
child_pset = ifcopenshell.api.pset.add_pset(
tool.Ifc.get(), product=child_element, name="BBIM_Array"
)
ifcopenshell.api.pset.edit_pset(
tool.Ifc.get(),
pset=child_pset,
properties={"Data": None, "Parent": parent_element.GlobalId},
should_purge=False,
)
# set child object position
new_matrix = obj.matrix_world.copy()
@@ -1155,6 +1150,12 @@ class Model(bonsai.core.tool.Model):
bpy.context.view_layer.update()
pset = ifcopenshell.api.pset.add_pset(tool.Ifc.get(), product=parent_element, name="BBIM_Array")
json_data = tool.Ifc.get().createIfcText(json.dumps(data))
ifcopenshell.api.pset.edit_pset(
tool.Ifc.get(), pset=pset, properties={"Data": json_data, "Parent": parent_element.GlobalId}
)
@classmethod
def replace_object_ifc_representation(
cls,
@@ -1243,18 +1244,7 @@ class Model(bonsai.core.tool.Model):
height = 100
is_horizontal = False
if element.is_a("IfcSlabType"):
is_horizontal = True
parametric = ifcopenshell.util.element.get_psets(element).get("EPset_Parametric")
if parametric:
layer_set_direction = parametric.get("LayerSetDirection", None)
if layer_set_direction == "AXIS2":
is_horizontal = False
elif layer_set_direction == "AXIS3":
is_horizontal = True
is_horizontal = cls.get_usage_type(element) == "LAYER3"
if is_horizontal:
width, height = height, width
@@ -1265,7 +1255,7 @@ class Model(bonsai.core.tool.Model):
del thicknesses[-1]
for thickness in thicknesses:
current_thickness += thickness
if element.is_a("IfcSlabType"):
if is_horizontal:
y = (current_thickness / total_thickness) * height
line = [x_offset, y_offset + y, x_offset + width, y_offset + y]
else:
+2 -1
View File
@@ -191,7 +191,8 @@ class Polyline(bonsai.core.tool.Polyline):
orientation_angle = 0
if input_ui:
if should_round:
angle = 5 * round(angle / 5) if distance < angle_round_threshold else angle
angle_snap = tool.Snap.get_angle_snap_value(context)
angle = angle_snap * round(angle / angle_snap) if distance < angle_round_threshold else angle
factor = tool.Snap.get_increment_snap_value(context)
distance = factor * round(distance / factor)
input_ui.set_value("X", mouse_vector.x)
+67 -58
View File
@@ -19,10 +19,14 @@
from __future__ import annotations
import os
import json
import math
import shutil
import numpy as np
from collections import defaultdict
from math import radians
from pathlib import Path
from typing import TYPE_CHECKING, NamedTuple, Optional, Union
from typing import TYPE_CHECKING, NamedTuple, Optional
import bpy
import ifcopenshell
@@ -58,6 +62,47 @@ class Project(bonsai.core.tool.Project):
assert (scene := bpy.context.scene)
return scene.MeasureToolSettings # pyright: ignore[reportAttributeAccessIssue]
@classmethod
def get_link_empty_handle(cls, link) -> bpy.types.Object | None:
if tool.Ifc.get():
return tool.Ifc.get_object(tool.Ifc.get().by_id(link.ifc_definition_id))
return link.empty_handle
@classmethod
def set_link_empty_handle(cls, link, empty: bpy.types.Object) -> None:
if tool.Ifc.get():
tool.Ifc.link(tool.Ifc.get().by_id(link.ifc_definition_id), empty)
else:
link.empty_handle = empty
@classmethod
def calculate_link_matrix(cls, link) -> None:
filepath = Path(tool.Ifc.resolve_uri(link.filepath))
with open(filepath.with_suffix(".ifc.cache.json"), "r") as f:
metadata = json.load(f)
rot = ifcopenshell.util.shape_builder.np_rotation_matrix(
radians(-float(metadata["model_project_north"])), 4, "Z"
)
global_matrix = rot @ np.eye(4)
global_matrix[:, 3][:3] = [float(o) for o in metadata["model_origin_si"].split(",")]
if tool.Ifc.get():
transformation = tool.Ifc.get().by_id(link.ifc_definition_id)[1] # Identification
else:
transformation = link.transformation
if transformation:
transformation = np.fromstring(transformation, sep=",", dtype=np.float64).reshape(4, 4)
if not np.allclose(transformation, np.eye(4)):
global_matrix = transformation @ global_matrix
gprops = tool.Georeference.get_georeference_props()
rot = ifcopenshell.util.shape_builder.np_rotation_matrix(radians(-float(gprops.model_project_north)), 4, "Z")
local_matrix = rot @ np.eye(4)
local_matrix[:, 3][:3] = [float(o) for o in gprops.model_origin_si.split(",")]
return np.linalg.inv(local_matrix) @ global_matrix
@classmethod
def append_all_types_from_template(cls, template: str) -> None:
# TODO refactor
@@ -249,68 +294,32 @@ class Project(bonsai.core.tool.Project):
tool.Root.reload_grid_decorator()
@classmethod
def get_linked_models_document(cls) -> Union[ifcopenshell.entity_instance, None]:
for document in tool.Ifc.get().by_type("IfcDocumentInformation"):
if document.Name == "BBIM_Linked_Models":
return document
def get_linked_models_documents(cls) -> dict[str, ifcopenshell.entity_instance]:
linked_docs = {}
for doc in tool.Ifc.get().by_type("IfcDocumentInformation"):
if doc.Scope == "LINKED_MODEL":
for reference in tool.Drawing.get_document_references(doc):
linked_docs[Path(reference.Location).as_posix()] = doc
break
return linked_docs
@classmethod
def load_linked_models_from_ifc(cls) -> None:
links = tool.Project.get_project_props().links
links.clear()
links_document = cls.get_linked_models_document()
if not links_document:
return
references = tool.Document.get_document_references(links_document)
if not references:
return
for reference in references:
link = links.add()
link.name = reference.Location
@classmethod
def save_linked_models_to_ifc(cls) -> None:
ifc_file = tool.Ifc.get()
links = tool.Project.get_project_props().links
filepaths: set[Path] = set()
for link in links:
filepaths.add(Path(link.name))
links_document = cls.get_linked_models_document()
if not filepaths and links_document is None:
return
paths_to_add = filepaths.copy()
references_to_remove: list[ifcopenshell.entity_instance] = []
if links_document:
references = tool.Document.get_document_references(links_document)
for reference in references:
# I guess got corrupted by the user.
if not (location := reference.Location):
references_to_remove.remove(reference)
continue
path = Path(location)
if path in paths_to_add:
paths_to_add.remove(path)
else:
references_to_remove.append(reference)
if paths_to_add:
if links_document is None:
links_document = ifcopenshell.api.document.add_information(ifc_file)
links_document.Name = "BBIM_Linked_Models"
links_document.Description = "Bonsai internal document containing references to currently linked models"
for path in paths_to_add:
reference = ifcopenshell.api.document.add_reference(ifc_file, links_document)
reference.Location = path.as_posix()
if references_to_remove:
for reference in references_to_remove:
ifcopenshell.api.document.remove_reference(ifc_file, reference)
for doc in tool.Ifc.get().by_type("IfcDocumentInformation"):
if doc.Scope != "LINKED_MODEL":
continue
for reference in tool.Drawing.get_document_references(doc):
filepath = reference.Location
link = links.add()
link.name = filepath
link.filepath = filepath
link.ifc_definition_id = reference.id()
link.has_transformation = False
if reference[1]:
m = np.fromstring(reference[1], sep=",", dtype=np.float64).reshape(4, 4)
link.has_transformation = not np.allclose(m, np.eye(4))
@classmethod
def get_project_library_elements(
+9
View File
@@ -114,6 +114,15 @@ class Snap(bonsai.core.tool.Snap):
return increment
@classmethod
def get_angle_snap_value(cls, context: bpy.types.Context) -> float:
"""Get the angle snap increment from Blender's tool settings.
:param context: Blender context
:return: Angle snap increment in degrees
"""
return math.degrees(context.scene.tool_settings.snap_angle_increment_3d)
@classmethod
def get_snap_points_on_raycasted_face(cls, context, event, obj, face_index):
matrix = obj.matrix_world.copy()
-5
View File
@@ -419,11 +419,6 @@ class Unit(bonsai.core.tool.Unit):
new.is_assigned = unit in assigned_units
new.ifc_class = unit.is_a()
@classmethod
def is_scene_unit_metric(cls) -> bool:
assert bpy.context.scene
return bpy.context.scene.unit_settings.system in ["METRIC", "NONE"]
@classmethod
def is_unit_class(cls, unit: ifcopenshell.entity_instance, ifc_class: str) -> bool:
return unit.is_a(ifc_class)
+1
View File
@@ -1,6 +1,7 @@
[pytest]
markers =
aggregate
array
attribute
boolean
boundary
+3 -2
View File
@@ -78,7 +78,8 @@ BONSAI_PATH = find_bonsai_path()
# ---------------------------
# Never changed by user.
PACKAGE_PATH = BLENDER_PATH / r"extensions/.local/lib/python3.11/site-packages"
PYTHON_VERSION = "3.13" if BLENDER_VERSION == "5.1" else "3.11"
PACKAGE_PATH = BLENDER_PATH / rf"extensions/.local/lib/python{PYTHON_VERSION}/site-packages"
def main() -> None:
@@ -193,7 +194,7 @@ def main() -> None:
print(f"Downloading {url} -> {filepath}")
urllib.request.urlretrieve(url, filepath)
input("Dev environment is all set. 🎉🎉\nPress Enter to continue..." "")
input("Dev environment is all set!! \nPress Enter to continue...")
if __name__ == "__main__":
+1
View File
@@ -34,6 +34,7 @@ from bonsai.bim.ifc import IfcStore
# Monkey-patch webbrowser opening since we want to test headlessly
webbrowser.open = lambda x: True
tool.Drawing.open_with_user_command = lambda x, y: True
variables = {"cwd": os.getcwd(), "ifc": "IfcStore.get_file()"}
+276
View File
@@ -0,0 +1,276 @@
@array
Feature: Array
Scenario: Add array
Given an empty IFC project
And I load the demo construction library
And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType"
And I add the construction type
When the object "IfcColumn/Column" is selected
And I look at the "Array" panel
And I see "No Array Found"
And I click "ADD"
Then the object "IfcColumn/Column" exists
And I see "Column"
And I see "1 Items"
And I don't see "No Array Found"
And the object "IfcColumn/Column" is at "0,0,0"
Scenario: Enable editing array
Given an empty IFC project
And I load the demo construction library
And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType"
And I add the construction type
And the object "IfcColumn/Column" is selected
And I look at the "Array" panel
And I click "ADD"
When I click "GREASEPENCIL"
Then I see "Count"
And I see "Method"
And I don't see "1 Items"
Scenario: Disable editing array
Given an empty IFC project
And I load the demo construction library
And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType"
And I add the construction type
And the object "IfcColumn/Column" is selected
And I look at the "Array" panel
And I click "ADD"
And I click "GREASEPENCIL"
When I click "CANCEL"
Then I see "1 Items"
And I don't see "Count"
Scenario: Edit array
Given an empty IFC project
And I load the demo construction library
And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType"
And I add the construction type
And the object "IfcColumn/Column" is selected
And I look at the "Array" panel
And I click "ADD"
And I click "GREASEPENCIL"
When I set the "Count" property to "2"
And I click "CHECKMARK"
Then the object "IfcColumn/Column.001" is in the collection "IfcBuildingStorey/My Storey"
And the object "IfcColumn/Column" is at "0,0,0"
And the object "IfcColumn/Column.001" is at "0,0,0"
And the object "IfcColumn/Column" dimensions are "0.5,0.6,3"
Scenario: Edit array - offset method
Given an empty IFC project
And I load the demo construction library
And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType"
And I add the construction type
And the object "IfcColumn/Column" is selected
And I look at the "Array" panel
And I click "ADD"
And I click "GREASEPENCIL"
When I set the "Count" property to "3"
And I set the "Method" property to "Offset"
And I set the "X" property to "1"
And I click "CHECKMARK"
Then the object "IfcColumn/Column.001" is in the collection "IfcBuildingStorey/My Storey"
And the object "IfcColumn/Column.002" is in the collection "IfcBuildingStorey/My Storey"
And the object "IfcColumn/Column" is at "0,0,0"
And the object "IfcColumn/Column.001" is at "1,0,0"
And the object "IfcColumn/Column.002" is at "2,0,0"
And the object "IfcColumn/Column" dimensions are "0.5,0.6,3"
And the object "IfcColumn/Column.001" dimensions are "0.5,0.6,3"
And the object "IfcColumn/Column.002" dimensions are "0.5,0.6,3"
Scenario: Edit array - distribute method
Given an empty IFC project
And I load the demo construction library
And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType"
And I add the construction type
And the object "IfcColumn/Column" is selected
And I look at the "Array" panel
And I click "ADD"
And I click "GREASEPENCIL"
When I set the "Count" property to "3"
And I set the "Method" property to "Distribute"
And I set the "X" property to "2"
And I click "CHECKMARK"
Then the object "IfcColumn/Column.001" is in the collection "IfcBuildingStorey/My Storey"
And the object "IfcColumn/Column.002" is in the collection "IfcBuildingStorey/My Storey"
And the object "IfcColumn/Column" is at "0,0,0"
And the object "IfcColumn/Column.001" is at "1,0,0"
And the object "IfcColumn/Column.002" is at "2,0,0"
And the object "IfcColumn/Column" dimensions are "0.5,0.6,3"
And the object "IfcColumn/Column.001" dimensions are "0.5,0.6,3"
And the object "IfcColumn/Column.002" dimensions are "0.5,0.6,3"
Scenario: Edit array - local space
Given an empty IFC project
And I load the demo construction library
And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType"
And I add the construction type
And the object "IfcColumn/Column" is rotated by "0,0,90" deg
And the object "IfcColumn/Column" is selected
And I look at the "Array" panel
And I click "ADD"
And I click "GREASEPENCIL"
When I set the "Count" property to "2"
And I set the "Method" property to "Offset"
And I set the "Use Local Space" property to "TRUE"
And I set the "X" property to "1"
And I click "CHECKMARK"
Then the object "IfcColumn/Column" is at "0,0,0"
And the object "IfcColumn/Column.001" is at "0,1,0"
Scenario: Edit array - world space
Given an empty IFC project
And I load the demo construction library
And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType"
And I add the construction type
And the object "IfcColumn/Column" is rotated by "0,0,90" deg
And the object "IfcColumn/Column" is selected
And I look at the "Array" panel
And I click "ADD"
And I click "GREASEPENCIL"
When I set the "Count" property to "2"
And I set the "Method" property to "Offset"
And I set the "Use Local Space" property to "FALSE"
And I set the "X" property to "1"
And I click "CHECKMARK"
Then the object "IfcColumn/Column" is at "0,0,0"
And the object "IfcColumn/Column.001" is at "1,0,0"
Scenario: Edit array - decrease count
Given an empty IFC project
And I load the demo construction library
And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType"
And I add the construction type
And the object "IfcColumn/Column" is selected
And I look at the "Array" panel
And I click "ADD"
And I click "GREASEPENCIL"
When I set the "Count" property to "3"
And I click "CHECKMARK"
And I click "GREASEPENCIL"
When I set the "Count" property to "2"
And I click "CHECKMARK"
Then the object "IfcColumn/Column.001" exists
And the object "IfcColumn/Column.002" does not exist
Scenario: Edit array - multiple arrays
Given an empty IFC project
And I load the demo construction library
And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType"
And I add the construction type
And the object "IfcColumn/Column" is selected
And I look at the "Array" panel
And I click "ADD"
And I click "GREASEPENCIL"
When I set the "Count" property to "3"
And I set the "Method" property to "Offset"
And I set the "X" property to "1"
And I click "CHECKMARK"
And I see "3 Items"
And I click "ADD"
And I click the "GREASEPENCIL" after the text "1 Items (Offset)"
And I set the "Count" property to "2"
And I set the "Method" property to "Offset"
And I set the "Y" property to "1"
And I click the "CHECKMARK" after the text "Count"
Then I see "3 Items"
And I see "2 Items"
Then the object "IfcColumn/Column.001" is in the collection "IfcBuildingStorey/My Storey"
And the object "IfcColumn/Column.002" is in the collection "IfcBuildingStorey/My Storey"
And the object "IfcColumn/Column.003" is in the collection "IfcBuildingStorey/My Storey"
And the object "IfcColumn/Column.004" is in the collection "IfcBuildingStorey/My Storey"
And the object "IfcColumn/Column.005" is in the collection "IfcBuildingStorey/My Storey"
And the object "IfcColumn/Column" is at "0,0,0"
And the object "IfcColumn/Column.001" is at "1,0,0"
And the object "IfcColumn/Column.002" is at "2,0,0"
And the object "IfcColumn/Column.003" is at "0,1,0"
And the object "IfcColumn/Column.004" is at "1,1,0"
And the object "IfcColumn/Column.005" is at "2,1,0"
And the object "IfcColumn/Column" dimensions are "0.5,0.6,3"
And the object "IfcColumn/Column.001" dimensions are "0.5,0.6,3"
And the object "IfcColumn/Column.002" dimensions are "0.5,0.6,3"
And the object "IfcColumn/Column.003" dimensions are "0.5,0.6,3"
And the object "IfcColumn/Column.004" dimensions are "0.5,0.6,3"
And the object "IfcColumn/Column.005" dimensions are "0.5,0.6,3"
Scenario: Remove array
Given an empty IFC project
And I load the demo construction library
And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType"
And I add the construction type
And the object "IfcColumn/Column" is selected
And I look at the "Array" panel
And I click "ADD"
And I click "GREASEPENCIL"
And I set the "Count" property to "2"
And I set the "Method" property to "Offset"
And I set the "X" property to "1"
And I click "CHECKMARK"
When I click "X"
Then the object "IfcColumn/Column" exists
And the object "IfcColumn/Column.001" does not exist
Scenario: Regenerate array
Given an empty IFC project
And I load the demo construction library
And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType"
And I add the construction type
And the object "IfcColumn/Column" is selected
And I look at the "Array" panel
And I click "ADD"
And I click "GREASEPENCIL"
And I set the "Count" property to "2"
And I click "CHECKMARK"
When I click "FILE_REFRESH"
Then the object "IfcColumn/Column" exists
And the object "IfcColumn/Column.001" exists
Scenario: Apply array
Given an empty IFC project
And I load the demo construction library
And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType"
And I add the construction type
And the object "IfcColumn/Column" is selected
And I look at the "Array" panel
And I click "ADD"
And I click "GREASEPENCIL"
And I set the "Count" property to "2"
And I click "CHECKMARK"
When I click "CHECKMARK"
Then the object "IfcColumn/Column" exists
And the object "IfcColumn/Column.001" exists
And I see "No Array Found"
Scenario: Select array parent
Given an empty IFC project
And I load the demo construction library
And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType"
And I add the construction type
And the object "IfcColumn/Column" is selected
And I look at the "Array" panel
And I click "ADD"
And I click "GREASEPENCIL"
And I set the "Count" property to "2"
And I click "CHECKMARK"
When the object "IfcColumn/Column.001" is selected
And I click "OBJECT_DATA"
Then the object "IfcColumn/Column" is selected
And the object "IfcColumn/Column.001" is not selected
Scenario: Select all array objects
Given an empty IFC project
And I load the demo construction library
And I set "scene.BIMModelProperties.ifc_class" to "IfcColumnType"
And I add the construction type
And the object "IfcColumn/Column" is selected
And I look at the "Array" panel
And I click "ADD"
And I click "GREASEPENCIL"
And I set the "Count" property to "2"
And I click "CHECKMARK"
When the object "IfcColumn/Column.001" is selected
And I click "RESTRICT_SELECT_OFF"
Then the object "IfcColumn/Column" is selected
And the object "IfcColumn/Column.001" is selected
+7 -6
View File
@@ -3,12 +3,6 @@ Feature: Drawing
Scenario: Duplicate drawing
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 I save IFC project
And I look at the "Drawings" panel
And I click "IMPORT"
@@ -315,3 +309,10 @@ Scenario: Create sheet - with a drawing added to it
And I click "IMAGE_PLANE"
When I click "OUTPUT"
Then the file "{ifc_dir}/sheets/A01 - UNTITLED.svg" should contain "IfcWall"
Scenario: Add reference image
Given an empty IFC project
And I save IFC project
When I press "bim.add_reference_image(filepath='{cwd}/test/files/image.jpg')"
Then the object "IfcAnnotation/image" exists
And the object "IfcAnnotation/image" dimensions are "1.0,0.565,0."
+67 -44
View File
@@ -677,31 +677,43 @@ Scenario: Load project elements - all georeferencing coordinate situations with
And the object "IfcActuator/J" has a vertex at "10.366,3.813,-1"
And the object "IfcActuator/J" has a vertex at "12.298,4.331,-1"
Scenario: Link IFC
Scenario: Link IFC - from an empty IFC project
Given an empty IFC project
When I link IFC project from "{cwd}/test/files/basic.ifc"
When I press "bim.link_ifc(filepath='{cwd}/test/files/basic.ifc')"
Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_loaded" is "True"
And the collection "IfcProject/basic.ifc" exists
And the object "Chunk" exists
And the object "Chunk" is placed in the collection "IfcProject/basic.ifc"
Scenario: Link IFC - disabled false origin mode
Given an empty IFC project
# Not currently possible via UI
And I set "scene.BIMProjectProperties.distance_limit" to "5"
And I set "scene.BIMProjectProperties.false_origin_mode" to "DISABLED"
When I link IFC project from "{cwd}/test/files/geolocation.ifc"
Then the object "Chunk" exists
And the object "Chunk" has a vertex at "2,2,-1"
And the object "Chunk" has a vertex at "9,-1,-1"
And the object "Chunk" has a vertex at "17,4,-1"
Scenario: Link IFC - from an empty IFC project - automatic false origin mode (0,0,0 will be the false origin)
Given an empty IFC project
# Not currently possible via UI
And I set "scene.BIMProjectProperties.distance_limit" to "5"
And I set "scene.BIMProjectProperties.false_origin_mode" to "AUTOMATIC"
When I link IFC project from "{cwd}/test/files/geolocation.ifc"
When I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation.ifc', use_cache=False)"
Then the object "Chunk" exists
And the object "Chunk" has a vertex at "2,2,-1"
And the object "Chunk" has a vertex at "9,-1,-1"
And the object "Chunk" has a vertex at "17,4,-1"
Scenario: Link IFC - from an empty IFC project - manual false origin mode
Given an empty IFC project
# Not currently possible via UI
And I set "scene.BIMProjectProperties.distance_limit" to "5"
And I set "scene.BIMProjectProperties.false_origin_mode" to "MANUAL"
And I set "scene.BIMProjectProperties.false_origin" to "10000,0,0"
When I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation.ifc', use_cache=False)"
Then the object "Chunk" exists
And the object "Chunk" has a vertex at "2,2,-1"
And the object "Chunk" has a vertex at "9,-1,-1"
And the object "Chunk" has a vertex at "17,4,-1"
Scenario: Link IFC - from an empty IFC project - disabled false origin mode
Given an empty IFC project
# Not currently possible via UI
And I set "scene.BIMProjectProperties.distance_limit" to "5"
And I set "scene.BIMProjectProperties.false_origin_mode" to "DISABLED"
When I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation.ifc', use_cache=False)"
Then the object "Chunk" exists
And the object "Chunk" has a vertex at "2,2,-1"
And the object "Chunk" has a vertex at "9,-1,-1"
@@ -712,31 +724,42 @@ Scenario: Link IFC - from an empty Blender session - automatic false origin mode
# Not currently possible via UI
And I set "scene.BIMProjectProperties.distance_limit" to "5"
And I set "scene.BIMProjectProperties.false_origin_mode" to "AUTOMATIC"
When I link IFC project from "{cwd}/test/files/geolocation.ifc"
When I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation.ifc', use_cache=False)"
Then the object "Chunk" exists
And the object "Chunk" has a vertex at "-11,-2,0"
And the object "Chunk" has a vertex at "-4,-5,0"
And the object "Chunk" has a vertex at "4,0,0"
Scenario: Link IFC - manual false origin mode
Scenario: Link IFC - from an empty Blender session - manual false origin mode
Given an empty Blender session
# Not currently possible via UI
And I set "scene.BIMProjectProperties.distance_limit" to "5"
And I set "scene.BIMProjectProperties.false_origin_mode" to "MANUAL"
And I set "scene.BIMProjectProperties.false_origin" to "10000,0,0"
When I link IFC project from "{cwd}/test/files/geolocation.ifc"
When I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation.ifc', use_cache=False)"
Then the object "Chunk" exists
And the object "Chunk" has a vertex at "-8,2,-1"
And the object "Chunk" has a vertex at "-1,-1,-1"
And the object "Chunk" has a vertex at "7,4,-1"
Scenario: Link IFC - automatic false origin mode - two different false origins and project norths - grid north is up because we start with geolocation.ifc
Scenario: Link IFC - from an empty Blender session - disabled false origin mode
Given an empty Blender session
# Not currently possible via UI
And I set "scene.BIMProjectProperties.distance_limit" to "5"
And I set "scene.BIMProjectProperties.false_origin_mode" to "DISABLED"
When I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation.ifc', use_cache=False)"
Then the object "Chunk" exists
And the object "Chunk" has a vertex at "2,2,-1"
And the object "Chunk" has a vertex at "9,-1,-1"
And the object "Chunk" has a vertex at "17,4,-1"
Scenario: Link IFC - from an empty Blender session - automatic false origin mode - two different false origins and project norths - grid north is up because we start with geolocation.ifc
Given an empty Blender session
# Not currently possible via UI
And I set "scene.BIMProjectProperties.distance_limit" to "5"
And I set "scene.BIMProjectProperties.false_origin_mode" to "AUTOMATIC"
When I link IFC project from "{cwd}/test/files/geolocation.ifc"
And I link IFC project from "{cwd}/test/files/geolocation-mapconversion-angle.ifc"
When I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation.ifc', use_cache=False)"
And I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation-mapconversion-angle.ifc', use_cache=False)"
Then the object "Col:IfcProject/geolocation.ifc:Chunk" exists
And the object "Col:IfcProject/geolocation-mapconversion-angle.ifc:Chunk" exists
And the object "Col:IfcProject/geolocation.ifc:Chunk" has a vertex at "-11,-2,0"
@@ -746,13 +769,13 @@ Scenario: Link IFC - automatic false origin mode - two different false origins a
And the object "Col:IfcProject/geolocation-mapconversion-angle.ifc:Chunk" has a vertex at "9.294,-9.366,0"
And the object "Col:IfcProject/geolocation-mapconversion-angle.ifc:Chunk" has a vertex at "18.722,-9.036,0"
Scenario: Link IFC - automatic false origin mode - two different false origins and project norths - project north is up because we start with geolocation-mapconversion-angle.ifc
Scenario: Link IFC - from an empty Blender session - automatic false origin mode - two different false origins and project norths - project north is up because we start with geolocation-mapconversion-angle.ifc
Given an empty Blender session
# Not currently possible via UI
And I set "scene.BIMProjectProperties.distance_limit" to "5"
And I set "scene.BIMProjectProperties.false_origin_mode" to "AUTOMATIC"
When I link IFC project from "{cwd}/test/files/geolocation-mapconversion-angle.ifc"
And I link IFC project from "{cwd}/test/files/geolocation.ifc"
When I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation-mapconversion-angle.ifc', use_cache=False)"
And I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation.ifc', use_cache=False)"
Then the object "Col:IfcProject/geolocation.ifc:Chunk" exists
And the object "Col:IfcProject/geolocation-mapconversion-angle.ifc:Chunk" exists
And the object "Col:IfcProject/geolocation-mapconversion-angle.ifc:Chunk" has a vertex at "-11,-2,0"
@@ -762,14 +785,14 @@ Scenario: Link IFC - automatic false origin mode - two different false origins a
And the object "Col:IfcProject/geolocation.ifc:Chunk" has a vertex at "-17.696,-7.866,0"
And the object "Col:IfcProject/geolocation.ifc:Chunk" has a vertex at "-13.268,0.464,0"
Scenario: Link IFC - automatic false origin mode - three identical false origins but different project and map units
Scenario: Link IFC - from an empty Blender session - automatic false origin mode - three identical false origins but different project and map units
Given an empty Blender session
# Not currently possible via UI
And I set "scene.BIMProjectProperties.distance_limit" to "5"
And I set "scene.BIMProjectProperties.false_origin_mode" to "AUTOMATIC"
When I link IFC project from "{cwd}/test/files/geolocation-unit1.ifc"
And I link IFC project from "{cwd}/test/files/geolocation-unit2.ifc"
And I link IFC project from "{cwd}/test/files/geolocation-unit3.ifc"
When I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation-unit1.ifc', use_cache=False)"
And I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation-unit2.ifc', use_cache=False)"
And I press "bim.link_ifc(filepath='{cwd}/test/files/geolocation-unit3.ifc', use_cache=False)"
Then the object "Col:IfcProject/geolocation-unit1.ifc:Chunk" exists
And the object "Col:IfcProject/geolocation-unit2.ifc:Chunk" exists
And the object "Col:IfcProject/geolocation-unit3.ifc:Chunk" exists
@@ -779,54 +802,54 @@ Scenario: Link IFC - automatic false origin mode - three identical false origins
Scenario: Toggle link visibility - wireframe mode
Given an empty IFC project
And I link IFC project from "{cwd}/test/files/basic.ifc"
When I press "bim.toggle_link_visibility(link='{cwd}/test/files/basic.ifc', mode='WIREFRAME')"
And I press "bim.link_ifc(filepath='{cwd}/test/files/basic.ifc')"
When I press "bim.toggle_link_visibility(link_index=0, mode='WIREFRAME')"
Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_wireframe" is "True"
And the object "Chunk" should display as "WIRE"
When I press "bim.toggle_link_visibility(link='{cwd}/test/files/basic.ifc', mode='WIREFRAME')"
When I press "bim.toggle_link_visibility(link_index=0, mode='WIREFRAME')"
Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_wireframe" is "False"
And the object "Chunk" should display as "TEXTURED"
Scenario: Toggle link selectability
Given an empty IFC project
And I link IFC project from "{cwd}/test/files/basic.ifc"
When I press "bim.toggle_link_selectability(link='{cwd}/test/files/basic.ifc')"
And I press "bim.link_ifc(filepath='{cwd}/test/files/basic.ifc')"
When I press "bim.toggle_link_selectability(link_index=0)"
Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_selectable" is "False"
And the collection "IfcProject/basic.ifc" is unselectable
When I press "bim.toggle_link_selectability(link='{cwd}/test/files/basic.ifc')"
When I press "bim.toggle_link_selectability(link_index=0)"
Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_selectable" is "True"
And the collection "IfcProject/basic.ifc" is selectable
Scenario: Toggle link visibility - visible mode
Given an empty IFC project
And I link IFC project from "{cwd}/test/files/basic.ifc"
When I press "bim.toggle_link_visibility(link='{cwd}/test/files/basic.ifc', mode='VISIBLE')"
And I press "bim.link_ifc(filepath='{cwd}/test/files/basic.ifc')"
When I press "bim.toggle_link_visibility(link_index=0, mode='VISIBLE')"
Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_hidden" is "True"
And the object "IfcProject/basic.ifc" is not visible
When I press "bim.toggle_link_visibility(link='{cwd}/test/files/basic.ifc', mode='VISIBLE')"
When I press "bim.toggle_link_visibility(link_index=0, mode='VISIBLE')"
Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_hidden" is "False"
And the object "IfcProject/basic.ifc" is visible
Scenario: Unload link
Given an empty Blender session
And I link IFC project from "{cwd}/test/files/basic.ifc"
When I press "bim.unload_link(filepath='{cwd}/test/files/basic.ifc')"
And I press "bim.link_ifc(filepath='{cwd}/test/files/basic.ifc')"
When I press "bim.unload_link(link_index=0)"
Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_loaded" is "False"
And the collection "IfcProject/basic.ifc" does not exist
Scenario: Load link
Given an empty Blender session
And I link IFC project from "{cwd}/test/files/basic.ifc"
And I press "bim.unload_link(filepath='{cwd}/test/files/basic.ifc')"
When I press "bim.load_link(filepath='{cwd}/test/files/basic.ifc')"
And I press "bim.link_ifc(filepath='{cwd}/test/files/basic.ifc')"
And I press "bim.unload_link(link_index=0)"
When I press "bim.load_link(link_index=0)"
Then "scene.BIMProjectProperties.links['{cwd}/test/files/basic.ifc'].is_loaded" is "True"
And the object "IfcProject/basic.ifc" exists
Scenario: Unlink IFC
Given an empty Blender session
And I link IFC project from "{cwd}/test/files/basic.ifc"
And I press "bim.unload_link(filepath='{cwd}/test/files/basic.ifc')"
When I press "bim.unlink_ifc(filepath='{cwd}/test/files/basic.ifc')"
And I press "bim.link_ifc(filepath='{cwd}/test/files/basic.ifc')"
And I press "bim.unload_link(link_index=0)"
When I press "bim.unlink_ifc(link_index=0)"
Then "scene.BIMProjectProperties.links.get('{cwd}/test/files/basic.ifc')" is "None"
And "scene.collection.children.get('IfcProject/basic.ifc')" is "None"
And the object "Chunk" does not exist
@@ -74,6 +74,50 @@ Scenario: Assign container
When I click "CHECKMARK"
Then the object "IfcWall/Cube" is in the collection "IfcSite/My Site"
Scenario: Assign container - assign an aggregate which also affects children
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
When I press "bim.add_aggregate"
Then the object "IfcElementAssembly/Default_Name" exists
And the object "IfcElementAssembly/Default_Name" is contained in object "IfcBuildingStorey/My Storey"
When I look at the "Spatial Decomposition" panel
And I select the "My Site" item in the "BIM_UL_containers_manager" list
And I click "Set Default"
And the object "IfcWall/Cube" is selected
And I look at the "Spatial Container" panel
And I click "GREASEPENCIL"
And I click "CHECKMARK"
Then the object "IfcWall/Cube" is in the collection "IfcSite/My Site"
And the object "IfcElementAssembly/Default_Name" is in the collection "IfcSite/My Site"
Scenario: Assign container - assign a child which also affects parents
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
When I press "bim.add_aggregate"
Then the object "IfcElementAssembly/Default_Name" exists
And the object "IfcElementAssembly/Default_Name" is contained in object "IfcBuildingStorey/My Storey"
When I look at the "Spatial Decomposition" panel
And I select the "My Site" item in the "BIM_UL_containers_manager" list
And I click "Set Default"
And the object "IfcElementAssembly/Default_Name" is selected
And I look at the "Spatial Container" panel
And I click "GREASEPENCIL"
And I click "CHECKMARK"
Then the object "IfcWall/Cube" is in the collection "IfcSite/My Site"
And the object "IfcElementAssembly/Default_Name" is in the collection "IfcSite/My Site"
Scenario: Copy to container
Given an empty IFC project
And I add a cube
+18 -1
View File
@@ -110,7 +110,7 @@ Scenario: Assign type - assign to a type with a material layer set, which automa
And the object "IfcWall/Unnamed" has a "100" thick layered material containing the material "Default"
And the object "IfcWall/Unnamed" dimensions are ".5,.1,.5"
Scenario: Assign type - assign to a different type with a material layer set
Scenario: Assign type - assign to a different type with a LAYER2 material layer set
Given an empty IFC project
And I add a cube
And the object "Cube" is selected
@@ -149,6 +149,23 @@ Scenario: Assign type - assign to a different type with a material layer set
Then the object "IfcWall/Cube" has a "200" thick layered material containing the material "Default"
And the object "IfcWall/Cube" dimensions are "1,.2,1"
Scenario: Assign type - assign to a different type with a LAYER3 material layer set
Given an empty IFC project
And I load the demo construction library
And I set "scene.BIMModelProperties.ifc_class" to "IfcSlabType"
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcSlabType') if e.Name == 'FLR200'][0].id()"
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
When I press "bim.add_occurrence"
Then the object "IfcSlab/Slab" is an "IfcSlab"
And the object "IfcSlab/Slab" dimensions are "1,1,0.2"
And the object "IfcSlab/Slab" bottom left corner is at "0,0,0"
And the object "IfcSlab/Slab" top right corner is at "1,1,0.2"
When I look at the "Type" panel
And I click "GREASEPENCIL"
And I set the "relating_type" property to "FLR300"
And I click "CHECKMARK"
Then the object "IfcSlab/Slab" dimensions are "1,1,0.3"
Scenario: Assign type - assign to a type with a material profile set
Given an empty IFC project
And I add a cube
+16 -37
View File
@@ -70,10 +70,7 @@ 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.cwd() / "test/files/temp"
TEST_FILES_DIR = Path.cwd() / "test/files"
CLEAN_LINKED_FILES_CACHE = False
TMP = Path(f"{variables['cwd']}/test/files/temp")
EPSET_DRAWING = Path.cwd() / "bonsai/bim/data/pset/EPset_Drawing.ifc"
EPSET_DRAWING_BYTES = EPSET_DRAWING.read_bytes()
@@ -283,6 +280,8 @@ def create_ui_name_cache():
try:
panel_type = getattr(bpy.types, bl_idname)
if panel_type.bl_rna.base.name == "Panel":
if "_tab_" in panel_type.bl_idname:
continue # Tab panels are just groups and not relevant in testing
ui_name_cache[panel_type.bl_label] = panel_type.bl_idname
elif panel_type.bl_rna.base.name == "Operator":
ui_name_cache[panel_type.bl_label] = bl_idname
@@ -311,25 +310,6 @@ def vectors_are_equal(v1, v2):
return all(is_x(v1[i], v2[i]) for i in range(len(v1)))
@pytest.fixture(scope="function", autouse=True)
def run_for_each_test() -> Generator[None]:
# Code before this runs before each test
yield
# Code after this runs after each test
global CLEAN_LINKED_FILES_CACHE
if CLEAN_LINKED_FILES_CACHE:
for filepath in TEST_FILES_DIR.glob("*.ifc.cache.*"):
filepath.unlink()
CLEAN_LINKED_FILES_CACHE = False
# pset_template tests are editing EPset_Drawing.ifc, so we need to restore it.
global RELOAD_EPSET_DRAWING
if RELOAD_EPSET_DRAWING:
EPSET_DRAWING.write_bytes(EPSET_DRAWING_BYTES)
RELOAD_EPSET_DRAWING = False
@given("an untestable scenario")
def an_untestable_scenario():
pass
@@ -384,16 +364,6 @@ def saving_ifc_project() -> None:
tool.Project.save_test_project()
@given(parsers.parse('I link IFC project from "{filepath}"'))
@when(parsers.parse('I link IFC project from "{filepath}"'))
@then(parsers.parse('I link IFC project from "{filepath}"'))
def i_link_ifc_project_from_filepath(filepath: str) -> None:
global CLEAN_LINKED_FILES_CACHE
filepath = replace_variables(filepath)
CLEAN_LINKED_FILES_CACHE = True
bpy.ops.bim.link_ifc(filepath=filepath, use_cache=False)
@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
@@ -1587,10 +1557,19 @@ def the_object_name_has_a_vertex_at_location(name, location):
is_pass = False
target = Vector([float(co) for co in location.split(",")])
verts = []
for v in obj.data.vertices:
verts.append(obj.matrix_world @ v.co)
if (verts[-1] - target).length < 0.001:
is_pass = True
depsgraph = bpy.context.evaluated_depsgraph_get()
obj_eval = obj.evaluated_get(depsgraph)
mesh = obj_eval.to_mesh(preserve_all_data_layers=False, depsgraph=depsgraph)
try:
for inst in depsgraph.object_instances:
if inst.object.original is obj:
mw = inst.matrix_world
for i, v in enumerate(mesh.vertices):
verts.append(mw @ v.co)
if (verts[-1] - target).length < 0.001:
is_pass = True
finally:
obj_eval.to_mesh_clear()
assert is_pass, f"No verts found at {location}: {verts}"
+1 -1
View File
@@ -17,7 +17,7 @@
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import bonsai.core.type as subject
from test.core.bootstrap import ifc, model, type, geometry
from test.core.bootstrap import geometry, ifc, model, type
class TestAssignType:
+70 -98
View File
@@ -22,119 +22,91 @@ from test.core.bootstrap import ifc, unit
class TestAssignSceneUnits:
def test_creating_and_assigning_metric_units(self, ifc, unit):
unit.is_scene_unit_metric().should_be_called().will_return(True)
unit.get_scene_unit_si_prefix("LENGTHUNIT").should_be_called().will_return("prefix")
unit.get_scene_unit_si_prefix("AREAUNIT").should_be_called().will_return("prefix")
unit.get_scene_unit_si_prefix("VOLUMEUNIT").should_be_called().will_return("prefix")
unit.add_mass_and_time_units().should_be_called().will_return(True)
unit.get_scene_unit_si_prefix("MASSUNIT").should_be_called().will_return("KILO")
unit.get_scene_unit_si_prefix("TIMEUNIT").should_be_called().will_return(None)
ifc.run("unit.add_si_unit", unit_type="LENGTHUNIT", prefix="prefix").should_be_called().will_return(
unit.get_scene_unit_name("LENGTHUNIT").should_be_called().will_return("length_name")
unit.get_scene_unit_name("AREAUNIT").should_be_called().will_return("area_name")
unit.get_scene_unit_name("VOLUMEUNIT").should_be_called().will_return("volume_name")
unit.get_scene_unit_name("MASSUNIT").should_be_called().will_return("mass_name")
unit.get_scene_unit_name("TIMEUNIT").should_be_called().will_return("time_name")
unit.is_si_unit("length_name").should_be_called().will_return(True)
unit.is_si_unit("area_name").should_be_called().will_return(True)
unit.is_si_unit("volume_name").should_be_called().will_return(True)
unit.is_si_unit("mass_name").should_be_called().will_return(True)
unit.is_si_unit("time_name").should_be_called().will_return(True)
unit.get_scene_unit_si_prefix("length_name").should_be_called().will_return("length_prefix")
unit.get_scene_unit_si_prefix("area_name").should_be_called().will_return("area_prefix")
unit.get_scene_unit_si_prefix("volume_name").should_be_called().will_return("volume_prefix")
unit.get_scene_unit_si_prefix("mass_name").should_be_called().will_return("mass_prefix")
unit.get_scene_unit_si_prefix("time_name").should_be_called().will_return("time_prefix")
ifc.run("unit.add_si_unit", unit_type="LENGTHUNIT", prefix="length_prefix").should_be_called().will_return(
"lengthunit"
)
ifc.run("unit.add_si_unit", unit_type="AREAUNIT", prefix="prefix").should_be_called().will_return("areaunit")
ifc.run("unit.add_si_unit", unit_type="VOLUMEUNIT", prefix="prefix").should_be_called().will_return(
ifc.run("unit.add_si_unit", unit_type="AREAUNIT", prefix="area_prefix").should_be_called().will_return(
"areaunit"
)
ifc.run("unit.add_si_unit", unit_type="VOLUMEUNIT", prefix="volume_prefix").should_be_called().will_return(
"volumeunit"
)
ifc.run("unit.add_si_unit", unit_type="MASSUNIT", prefix="KILO").should_be_called().will_return("massunit")
ifc.run("unit.add_si_unit", unit_type="TIMEUNIT", prefix=None).should_be_called().will_return("timeunit")
ifc.run("unit.add_conversion_based_unit", name="degree").should_be_called().will_return("planeangleunit")
ifc.run("unit.add_si_unit", unit_type="MASSUNIT", prefix="mass_prefix").should_be_called().will_return(
"massunit"
)
ifc.run("unit.add_si_unit", unit_type="TIMEUNIT", prefix="time_prefix").should_be_called().will_return(
"timeunit"
)
ifc.run(
"unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "planeangleunit", "massunit", "timeunit"]
"unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "massunit", "timeunit"]
).should_be_called()
subject.assign_scene_units(ifc, unit)
def test_creating_and_assigning_metric_units_without_mass_and_time(self, ifc, unit):
unit.is_scene_unit_metric().should_be_called().will_return(True)
unit.get_scene_unit_si_prefix("LENGTHUNIT").should_be_called().will_return("CENTI")
unit.get_scene_unit_si_prefix("AREAUNIT").should_be_called().will_return("CENTI")
unit.get_scene_unit_si_prefix("VOLUMEUNIT").should_be_called().will_return("CENTI")
unit.add_mass_and_time_units().should_be_called().will_return(False)
ifc.run("unit.add_si_unit", unit_type="LENGTHUNIT", prefix="CENTI").should_be_called().will_return("lengthunit")
ifc.run("unit.add_si_unit", unit_type="AREAUNIT", prefix="CENTI").should_be_called().will_return("areaunit")
ifc.run("unit.add_si_unit", unit_type="VOLUMEUNIT", prefix="CENTI").should_be_called().will_return("volumeunit")
ifc.run("unit.add_conversion_based_unit", name="degree").should_be_called().will_return("planeangleunit")
ifc.run("unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "planeangleunit"]).should_be_called()
def test_creating_and_assigning_only_specified_units(self, ifc, unit):
unit.get_scene_unit_name("LENGTHUNIT").should_be_called().will_return("length_name")
unit.get_scene_unit_name("AREAUNIT").should_be_called().will_return(None)
unit.get_scene_unit_name("VOLUMEUNIT").should_be_called().will_return(None)
unit.get_scene_unit_name("MASSUNIT").should_be_called().will_return(None)
unit.get_scene_unit_name("TIMEUNIT").should_be_called().will_return(None)
unit.is_si_unit("length_name").should_be_called().will_return(True)
unit.get_scene_unit_si_prefix("length_name").should_be_called().will_return("length_prefix")
ifc.run("unit.add_si_unit", unit_type="LENGTHUNIT", prefix="length_prefix").should_be_called().will_return(
"lengthunit"
)
ifc.run("unit.assign_unit", units=["lengthunit"]).should_be_called()
subject.assign_scene_units(ifc, unit)
def test_creating_and_assigning_imperial_units(self, ifc, unit):
unit.is_scene_unit_metric().should_be_called().will_return(False)
unit.get_scene_unit_name("LENGTHUNIT").should_be_called().will_return("foot")
unit.get_scene_unit_name("AREAUNIT").should_be_called().will_return("square foot")
unit.get_scene_unit_name("VOLUMEUNIT").should_be_called().will_return("cubic foot")
unit.add_mass_and_time_units().should_be_called().will_return(True)
unit.get_scene_unit_name("MASSUNIT").should_be_called().will_return("pound")
unit.get_scene_unit_name("TIMEUNIT").should_be_called().will_return("SECOND")
ifc.run("unit.add_conversion_based_unit", name="foot").should_be_called().will_return("lengthunit")
ifc.run("unit.add_conversion_based_unit", name="square foot").should_be_called().will_return("areaunit")
ifc.run("unit.add_conversion_based_unit", name="cubic foot").should_be_called().will_return("volumeunit")
ifc.run("unit.add_conversion_based_unit", name="pound").should_be_called().will_return("massunit")
ifc.run("unit.add_si_unit", unit_type="TIMEUNIT", prefix=None).should_be_called().will_return("timeunit")
ifc.run("unit.add_conversion_based_unit", name="degree").should_be_called().will_return("planeangleunit")
unit.get_scene_unit_name("LENGTHUNIT").should_be_called().will_return("length_name")
unit.get_scene_unit_name("AREAUNIT").should_be_called().will_return("area_name")
unit.get_scene_unit_name("VOLUMEUNIT").should_be_called().will_return("volume_name")
unit.get_scene_unit_name("MASSUNIT").should_be_called().will_return("mass_name")
unit.get_scene_unit_name("TIMEUNIT").should_be_called().will_return("time_name")
unit.is_si_unit("length_name").should_be_called().will_return(False)
unit.is_si_unit("area_name").should_be_called().will_return(False)
unit.is_si_unit("volume_name").should_be_called().will_return(False)
unit.is_si_unit("mass_name").should_be_called().will_return(False)
unit.is_si_unit("time_name").should_be_called().will_return(False)
ifc.run("unit.add_conversion_based_unit", name="length_name").should_be_called().will_return("lengthunit")
ifc.run("unit.add_conversion_based_unit", name="area_name").should_be_called().will_return("areaunit")
ifc.run("unit.add_conversion_based_unit", name="volume_name").should_be_called().will_return("volumeunit")
ifc.run("unit.add_conversion_based_unit", name="mass_name").should_be_called().will_return("massunit")
ifc.run("unit.add_conversion_based_unit", name="time_name").should_be_called().will_return("timeunit")
ifc.run(
"unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "planeangleunit", "massunit", "timeunit"]
"unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "massunit", "timeunit"]
).should_be_called()
subject.assign_scene_units(ifc, unit)
def test_creating_and_assigning_imperial_units_without_mass_and_time(self, ifc, unit):
unit.is_scene_unit_metric().should_be_called().will_return(False)
unit.get_scene_unit_name("LENGTHUNIT").should_be_called().will_return("yard")
unit.get_scene_unit_name("AREAUNIT").should_be_called().will_return("square yard")
unit.get_scene_unit_name("VOLUMEUNIT").should_be_called().will_return("cubic yard")
unit.add_mass_and_time_units().should_be_called().will_return(False)
ifc.run("unit.add_conversion_based_unit", name="yard").should_be_called().will_return("lengthunit")
ifc.run("unit.add_conversion_based_unit", name="square yard").should_be_called().will_return("areaunit")
ifc.run("unit.add_conversion_based_unit", name="cubic yard").should_be_called().will_return("volumeunit")
ifc.run("unit.add_conversion_based_unit", name="degree").should_be_called().will_return("planeangleunit")
ifc.run("unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "planeangleunit"]).should_be_called()
subject.assign_scene_units(ifc, unit)
def test_creating_metric_units_with_conversion_based_mass_and_time(self, ifc, unit):
unit.is_scene_unit_metric().should_be_called().will_return(True)
unit.get_scene_unit_si_prefix("LENGTHUNIT").should_be_called().will_return("MILLI")
unit.get_scene_unit_si_prefix("AREAUNIT").should_be_called().will_return(None)
unit.get_scene_unit_si_prefix("VOLUMEUNIT").should_be_called().will_return(None)
unit.add_mass_and_time_units().should_be_called().will_return(True)
unit.get_scene_unit_si_prefix("MASSUNIT").should_be_called().will_return("CONVERSION")
unit.get_scene_unit_name("MASSUNIT").should_be_called().will_return("tonne")
unit.get_scene_unit_si_prefix("TIMEUNIT").should_be_called().will_return("CONVERSION")
unit.get_scene_unit_name("TIMEUNIT").should_be_called().will_return("minute")
ifc.run("unit.add_si_unit", unit_type="LENGTHUNIT", prefix="MILLI").should_be_called().will_return("lengthunit")
ifc.run("unit.add_si_unit", unit_type="AREAUNIT", prefix=None).should_be_called().will_return("areaunit")
ifc.run("unit.add_si_unit", unit_type="VOLUMEUNIT", prefix=None).should_be_called().will_return("volumeunit")
ifc.run("unit.add_conversion_based_unit", name="tonne").should_be_called().will_return("massunit")
ifc.run("unit.add_conversion_based_unit", name="minute").should_be_called().will_return("timeunit")
ifc.run("unit.add_conversion_based_unit", name="degree").should_be_called().will_return("planeangleunit")
ifc.run(
"unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "planeangleunit", "massunit", "timeunit"]
).should_be_called()
subject.assign_scene_units(ifc, unit)
def test_creating_imperial_units_with_conversion_based_units(self, ifc, unit):
unit.is_scene_unit_metric().should_be_called().will_return(False)
unit.get_scene_unit_name("LENGTHUNIT").should_be_called().will_return("inch")
unit.get_scene_unit_name("AREAUNIT").should_be_called().will_return("square inch")
unit.get_scene_unit_name("VOLUMEUNIT").should_be_called().will_return("cubic inch")
unit.add_mass_and_time_units().should_be_called().will_return(True)
unit.get_scene_unit_name("MASSUNIT").should_be_called().will_return("ounce")
unit.get_scene_unit_name("TIMEUNIT").should_be_called().will_return("hour")
ifc.run("unit.add_conversion_based_unit", name="inch").should_be_called().will_return("lengthunit")
ifc.run("unit.add_conversion_based_unit", name="square inch").should_be_called().will_return("areaunit")
ifc.run("unit.add_conversion_based_unit", name="cubic inch").should_be_called().will_return("volumeunit")
ifc.run("unit.add_conversion_based_unit", name="ounce").should_be_called().will_return("massunit")
ifc.run("unit.add_conversion_based_unit", name="hour").should_be_called().will_return("timeunit")
ifc.run("unit.add_conversion_based_unit", name="degree").should_be_called().will_return("planeangleunit")
ifc.run(
"unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "planeangleunit", "massunit", "timeunit"]
).should_be_called()
def test_creating_both_metric_and_imperial_units(self, ifc, unit):
# I know British doctors measure with stones so...
unit.get_scene_unit_name("LENGTHUNIT").should_be_called().will_return("length_name")
unit.get_scene_unit_name("AREAUNIT").should_be_called().will_return("area_name")
unit.get_scene_unit_name("VOLUMEUNIT").should_be_called().will_return(None)
unit.get_scene_unit_name("MASSUNIT").should_be_called().will_return(None)
unit.get_scene_unit_name("TIMEUNIT").should_be_called().will_return(None)
unit.is_si_unit("length_name").should_be_called().will_return(True)
unit.is_si_unit("area_name").should_be_called().will_return(False)
unit.get_scene_unit_si_prefix("length_name").should_be_called().will_return("length_prefix")
ifc.run("unit.add_si_unit", unit_type="LENGTHUNIT", prefix="length_prefix").should_be_called().will_return(
"lengthunit"
)
ifc.run("unit.add_conversion_based_unit", name="area_name").should_be_called().will_return("areaunit")
ifc.run("unit.assign_unit", units=["lengthunit", "areaunit"]).should_be_called()
subject.assign_scene_units(ifc, unit)
+34
View File
@@ -24,8 +24,10 @@ import pytest
import bonsai.core.tool
import bonsai.tool as tool
import tempfile
from bonsai.tool.blender import Blender as subject
from test.bim.bootstrap import NewFile
from pathlib import Path
if TYPE_CHECKING:
import bpy.stub_internal.rna_enums as rna_enums
@@ -110,3 +112,35 @@ class TestBlenderErrorMessageExtraction(NewFile):
assert error_reports == []
bpy.utils.unregister_class(OBJECT_OT_test_fail_operator)
class TestGetSelectedFiles(NewFile):
def test_get_a_single_file(self) -> None:
with tempfile.NamedTemporaryFile() as f:
file = type("", (object,), {"name": f.name})()
assert subject.get_selected_files(Path(f.name).parent, [file]) == [f.name]
def test_get_multiple_files(self) -> None:
with tempfile.NamedTemporaryFile() as f:
with tempfile.NamedTemporaryFile() as g:
file = type("", (object,), {"name": f.name})()
file2 = type("", (object,), {"name": g.name})()
assert subject.get_selected_files(Path(f.name).parent, [file, file2]) == [f.name, g.name]
def test_exclude_directories(self) -> None:
with tempfile.NamedTemporaryFile() as f:
with tempfile.TemporaryDirectory() as d:
file = type("", (object,), {"name": f.name})()
directory = type("", (object,), {"name": d})()
assert subject.get_selected_files(Path(f.name).parent, [file, directory]) == [f.name]
def test_get_relative_paths(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
base_path = Path(tmp_dir)
with tempfile.NamedTemporaryFile(dir=tmp_dir, suffix=".ifc") as f:
tool.Ifc.set_path(str(f.name))
with tempfile.NamedTemporaryFile(dir=tmp_dir) as g:
file = type("", (object,), {"name": g.name})
assert subject.get_selected_files(Path(g.name).parent, [file], use_relative_path=True) == [
Path(g.name).name
]
+32 -70
View File
@@ -21,6 +21,7 @@ import xml.etree.ElementTree as ET
from pathlib import Path
import bpy
import pytest
import ifcopenshell
import ifcopenshell.api.drawing
import ifcopenshell.api.group
@@ -31,6 +32,7 @@ import ifcopenshell.util.element
import mathutils
import numpy as np
from mathutils import Vector
from ifcopenshell.util.shape_builder import ShapeBuilder
import bonsai.core.tool
import bonsai.tool as tool
@@ -150,6 +152,34 @@ class TestDisableEditingSheets(NewFile):
assert props.is_editing_sheets == False
class TestEditTextLiterals(NewFile):
def test_run(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
obj = bpy.data.objects.new("Object", None)
element = ifc.createIfcAnnotation()
element.Representation = ifc.createIfcProductDefinitionShape()
context = ifc.createIfcGeometricRepresentationSubContext(ContextType="Plan", ContextIdentifier="Annotation")
item = ifc.createIfcTextLiteralWithExtent(Literal="Literal", Path="RIGHT", BoxAlignment="bottom-left")
builder = ShapeBuilder(tool.Ifc.get())
polyline = builder.polyline([(0.,0.,0.), (1.,0.,0.)])
representation = ifc.createIfcShapeRepresentation(ContextOfItems=context, Items=[item, polyline])
element.Representation.Representations = [representation]
tool.Ifc.link(element, obj)
literal_attributes = [
{
"Literal": "Foo",
"Path": "RIGHT",
"BoxAlignment": "bottom-left",
}
]
subject.edit_text_literals(obj, literal_attributes)
assert len(ifc.by_type("IfcTextLiteralWithExtent")) == 1
literal = ifc.by_type("IfcTextLiteralWithExtent")[0]
assert literal in representation.Items
assert literal.Literal == "Foo"
class TestDisableEditingText(NewFile):
def test_run(self):
obj = bpy.data.objects.new("Object", None)
@@ -904,7 +934,7 @@ class TestAddReferenceImage(NewFile):
bpy.ops.bim.save_project(filepath=str(ifc_path), should_save_as=True)
filepath = Path("test/files/image.jpg").absolute()
bpy.ops.bim.add_reference_image(filepath=str(filepath))
bpy.ops.bim.add_reference_image(filepath=str(filepath), x_length=3.53982, y_length=2.0)
obj = bpy.data.objects["IfcAnnotation/image"]
assert obj is not None
@@ -927,72 +957,4 @@ class TestAddReferenceImage(NewFile):
assert texture_filepath == filepath
uv_node = material_nodes["Texture Coordinate"]
assert len(uv_node.outputs["Generated"].links[:]) == 1
class TestAddReference(NewFile):
def test_add_single_reference(self):
"""Test adding a single reference file (backward compatibility)"""
bpy.ops.bim.create_project()
ifc_path = Path("test/files/temp/test.ifc").absolute()
bpy.ops.bim.save_project(filepath=str(ifc_path), should_save_as=True)
# Create a temporary SVG file
svg_path = Path("test/files/temp/reference.svg").absolute()
svg_path.parent.mkdir(parents=True, exist_ok=True)
with open(svg_path, "w") as f:
f.write('<svg xmlns="http://www.w3.org/2000/svg"></svg>')
try:
# Add single reference
bpy.ops.bim.add_reference(filepath=str(svg_path))
# Verify reference was added
ifc = tool.Ifc.get()
references = [doc for doc in ifc.by_type("IfcDocumentInformation") if doc.Scope == "REFERENCE"]
assert len(references) == 1
assert references[0].Name == "reference"
finally:
# Cleanup
if svg_path.exists():
svg_path.unlink()
def test_add_multiple_references(self):
"""Test adding multiple reference files at once"""
bpy.ops.bim.create_project()
ifc_path = Path("test/files/temp/test.ifc").absolute()
bpy.ops.bim.save_project(filepath=str(ifc_path), should_save_as=True)
# Create temporary SVG files
temp_dir = Path("test/files/temp").absolute()
temp_dir.mkdir(parents=True, exist_ok=True)
svg_files = []
for i in range(3):
svg_path = temp_dir / f"reference_{i}.svg"
with open(svg_path, "w") as f:
f.write('<svg xmlns="http://www.w3.org/2000/svg"></svg>')
svg_files.append(svg_path)
try:
# Test by directly calling core.add_document multiple times
# (simulating what the operator does with multiple files)
ifc = tool.Ifc.get()
for svg_file in svg_files:
uri = tool.Ifc.get_uri(str(svg_file), use_relative_path=True)
from bonsai.bim import core
core.drawing.add_document(tool.Ifc, tool.Drawing, "REFERENCE", uri=uri)
# Verify all references were added
references = [doc for doc in ifc.by_type("IfcDocumentInformation") if doc.Scope == "REFERENCE"]
assert len(references) == 3
reference_names = {ref.Name for ref in references}
expected_names = {f"reference_{i}" for i in range(3)}
assert reference_names == expected_names
finally:
# Cleanup
for svg_file in svg_files:
if svg_file.exists():
svg_file.unlink()
assert len(uv_node.outputs["UV"].links[:]) == 1
+2 -2
View File
@@ -323,8 +323,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, dtype=np.float32).tobytes())
assert props.rotation_checksum == repr(np.array(obj.matrix_world.to_3x3(), dtype=np.float32).tobytes())
assert props.location_checksum == repr(tool.Blender.np_array_legacy(obj.matrix_world.translation).tobytes())
assert props.rotation_checksum == repr(tool.Blender.np_array_legacy(obj.matrix_world.to_3x3()).tobytes())
class TestRemoveConnection(NewFile):
+70 -72
View File
@@ -16,9 +16,12 @@
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import json
import contextlib
import tempfile
import numpy as np
from pathlib import Path
from tempfile import NamedTemporaryFile
import bpy
import ifcopenshell
@@ -263,7 +266,7 @@ class TestLoadLinkedModels(NewFile):
props = tool.Project.get_project_props()
ifcopenshell.api.root.create_entity(ifc, "IfcProject")
document = ifcopenshell.api.document.add_information(ifc)
document.Name = "BBIM_Linked_Models"
document.Name = "X"
tool.Ifc.set(ifc)
subject.load_linked_models_from_ifc()
assert len(props.links) == 0
@@ -273,86 +276,81 @@ class TestLoadLinkedModels(NewFile):
props = tool.Project.get_project_props()
ifcopenshell.api.root.create_entity(ifc, "IfcProject")
document = ifcopenshell.api.document.add_information(ifc)
document.Name = "BBIM_Linked_Models"
document.Scope = "LINKED_MODEL"
reference = ifcopenshell.api.document.add_reference(ifc, document)
linked_model_path = "test.ifc"
reference.Location = linked_model_path
reference.Location = "test.ifc"
reference.Identification = ""
reference2 = ifcopenshell.api.document.add_reference(ifc, document)
reference2.Location = "test2.ifc"
reference2.Identification = "1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16"
tool.Ifc.set(ifc)
subject.load_linked_models_from_ifc()
assert len(props.links) == 1
assert props.links[0].name == linked_model_path
assert len(props.links) == 2
assert props.links[0].name == "test.ifc"
assert props.links[0].ifc_definition_id == reference.id()
assert props.links[0].has_transformation is False
assert props.links[1].name == "test2.ifc"
assert props.links[1].ifc_definition_id == reference2.id()
assert props.links[1].has_transformation is True
class TestSaveLinkedModelsToIfc(NewFile):
def test_save_linked_models_to_ifc_no_links(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
subject.save_linked_models_to_ifc()
assert len(ifc.by_type("IfcDocumentInformation")) == 0
assert len(ifc.by_type("IfcDocumentReference")) == 0
def test_save_linked_models_to_ifc_paths_to_add(self):
ifc = ifcopenshell.file()
ifcopenshell.api.root.create_entity(ifc, "IfcProject")
class TestCalculateLinkMatrix(NewFile):
def test_linking_a_model_without_an_offset_to_our_session_with_no_offset(self):
props = tool.Project.get_project_props()
link = props.links.add()
linked_model_path = "test.ifc"
link.name = linked_model_path
tool.Ifc.set(ifc)
subject.save_linked_models_to_ifc()
assert len(documents := ifc.by_type("IfcDocumentInformation")) == 1
assert documents[0].Name == "BBIM_Linked_Models"
assert len(references := ifc.by_type("IfcDocumentReference")) == 1
assert references[0].Location == linked_model_path
gprops = tool.Georeference.get_georeference_props()
with NamedTemporaryFile(suffix=".ifc.cache.json", mode="w", delete=True) as tmp:
link = props.links.add()
link.filepath = tmp.name.replace(".ifc.cache.json", ".ifc")
json.dump({"model_project_north": "0", "model_origin_si": "0,0,0"}, tmp)
tmp.flush()
gprops.model_project_north = "0"
gprops.model_origin_si = "0,0,0"
assert np.allclose(subject.calculate_link_matrix(link), np.eye(4))
def test_save_linked_models_to_ifc_already_created_references(self):
ifc = ifcopenshell.file()
links = tool.Project.get_project_props().links
ifcopenshell.api.root.create_entity(ifc, "IfcProject")
def test_linking_an_offset_model_to_our_session_with_no_offset(self):
props = tool.Project.get_project_props()
gprops = tool.Georeference.get_georeference_props()
with NamedTemporaryFile(suffix=".ifc.cache.json", mode="w", delete=True) as tmp:
link = props.links.add()
link.filepath = tmp.name.replace(".ifc.cache.json", ".ifc")
json.dump({"model_project_north": "0", "model_origin_si": "5,0,0"}, tmp)
tmp.flush()
gprops.model_project_north = "0"
gprops.model_origin_si = "0,0,0"
m = np.eye(4)
m[0][3] = 5
assert np.allclose(subject.calculate_link_matrix(link), m)
document = ifcopenshell.api.document.add_information(ifc)
document.Name = "BBIM_Linked_Models"
document_id = document.id()
reference = ifcopenshell.api.document.add_reference(ifc, document)
linked_model_path = "test.ifc"
reference.Location = linked_model_path
reference_id = reference.id()
def test_linking_an_offset_model_to_our_session_with_offset(self):
props = tool.Project.get_project_props()
gprops = tool.Georeference.get_georeference_props()
with NamedTemporaryFile(suffix=".ifc.cache.json", mode="w", delete=True) as tmp:
link = props.links.add()
link.filepath = tmp.name.replace(".ifc.cache.json", ".ifc")
json.dump({"model_project_north": "0", "model_origin_si": "5,0,0"}, tmp)
tmp.flush()
gprops.model_project_north = "0"
gprops.model_origin_si = "2,0,0"
m = np.eye(4)
m[0][3] = 3
assert np.allclose(subject.calculate_link_matrix(link), m)
link = links.add()
linked_model_path = "test.ifc"
link.name = linked_model_path
tool.Ifc.set(ifc)
subject.save_linked_models_to_ifc()
# Information and references to stay intact.
assert len(documents := ifc.by_type("IfcDocumentInformation")) == 1
assert documents[0].id() == document_id
assert documents[0].Name == "BBIM_Linked_Models"
assert len(references := ifc.by_type("IfcDocumentReference")) == 1
assert references[0].id() == reference_id
assert references[0].Location == linked_model_path
def test_save_linked_models_to_ifc_references_to_remove(self):
ifc = ifcopenshell.file()
links = tool.Project.get_project_props().links
ifcopenshell.api.root.create_entity(ifc, "IfcProject")
document = ifcopenshell.api.document.add_information(ifc)
document.Name = "BBIM_Linked_Models"
document_id = document.id()
reference = ifcopenshell.api.document.add_reference(ifc, document)
linked_model_path = "test.ifc"
reference.Location = linked_model_path
tool.Ifc.set(ifc)
subject.save_linked_models_to_ifc()
links.clear()
# Remove reference for removed link.
assert len(documents := ifc.by_type("IfcDocumentInformation")) == 1
assert documents[0].id() == document_id
assert documents[0].Name == "BBIM_Linked_Models"
assert len(ifc.by_type("IfcDocumentReference")) == 0
def test_linking_an_offset_model_to_our_session_with_offset_and_transformation(self):
props = tool.Project.get_project_props()
gprops = tool.Georeference.get_georeference_props()
with NamedTemporaryFile(suffix=".ifc.cache.json", mode="w", delete=True) as tmp:
link = props.links.add()
link.filepath = tmp.name.replace(".ifc.cache.json", ".ifc")
transformation = np.eye(4)
transformation[0][3] = 4
link.transformation = ",".join(map(str, transformation.reshape(-1)))
json.dump({"model_project_north": "0", "model_origin_si": "5,0,0"}, tmp)
tmp.flush()
gprops.model_project_north = "0"
gprops.model_origin_si = "2,0,0"
m = np.eye(4)
m[0][3] = 7
assert np.allclose(subject.calculate_link_matrix(link), m)
class TestLoadingIfcSqlite(NewFile):
-12
View File
@@ -434,18 +434,6 @@ class TestImportUnits(NewFile):
assert second_prop.ifc_class == "IfcSIUnit"
class TestIsSceneUnitMetric(NewFile):
def test_run(self):
assert bpy.context.scene
props = bpy.context.scene.unit_settings
props.system = "METRIC"
assert subject.is_scene_unit_metric() is True
props.system = "IMPERIAL"
assert subject.is_scene_unit_metric() is False
props.system = "NONE"
assert subject.is_scene_unit_metric() is True
class TestIsUnitClass:
def test_run(self):
ifc = ifcopenshell.file()
+4 -1
View File
@@ -31,6 +31,9 @@
#include "ifcparse/Ifc2x3.h"
#include "ifcparse/IfcHierarchyHelper.h"
#include <boost/math/constants/constants.hpp>
const static double PI = boost::math::constants::pi<double>();
typedef std::string S;
typedef IfcParse::IfcGlobalId guid;
boost::none_t const null = boost::none;
@@ -41,7 +44,7 @@ void create_curve_rebar(IfcHierarchyHelper<IfcSchema>& file)
int R = 3 * dia;
int length = 12 * dia;
double crossSectionarea = M_PI * (dia / 2) * 2;
double crossSectionarea = PI * dia * dia / 4;
IfcSchema::IfcReinforcingBar* rebar = new IfcSchema::IfcReinforcingBar(
guid(), 0, S("test"), null,
null, 0, 0,
+1
View File
@@ -15,6 +15,7 @@ target_link_libraries(
IfcGeom
IfcParse
Serializers
${kernel_libraries}
${OpenCASCADE_LIBRARIES}
${Boost_LIBRARIES}
${HDF5_LIBRARIES}
+7 -1
View File
@@ -452,6 +452,12 @@ namespace ifcopenshell {
static constexpr bool defaultvalue = false;
};
struct MakeVolume : public SettingBase<MakeVolume, bool> {
static constexpr const char* const name = "make-volume";
static constexpr const char* const description = "Try to isolate and fix a valid volume from non-manifold elements prior to opening subtraction";
static constexpr bool defaultvalue = false;
};
struct DeferProcessingFirstElement : public SettingBase<DeferProcessingFirstElement, bool, true> {
static constexpr const char* const name = "defer-processing-first-element";
static constexpr const char* const description = "Don't process first element in Iterator::initialize call()";
@@ -647,7 +653,7 @@ namespace ifcopenshell {
};
class Settings : public SettingsContainer<
std::tuple<MesherLinearDeflection, MesherAngularDeflection, ReorientShells, LengthUnit, PlaneUnit, Precision, OutputDimensionality, LayersetFirst, DisableBooleanResult, NoWireIntersectionCheck, NoWireIntersectionTolerance, PrecisionFactor, DebugBooleanOperations, BooleanAttempt2d, SurfaceColour, WeldVertices, UseWorldCoords, UnifyShapes, UseMaterialNames, ConvertBackUnits, ContextIds, ContextTypes, ContextIdentifiers, IteratorOutput, DisableOpeningSubtractions, ApplyDefaultMaterials, DontEmitNormals, GenerateUvs, ApplyLayerSets, UseElementHierarchy, ValidateQuantities, EdgeArrows, BuildingLocalPlacement, SiteLocalPlacement, ForceSpaceTransparency, CircleSegments, CgalSmoothAngleDegrees, KeepBoundingBoxes, ComputeCurvature, FunctionStepType, FunctionStepParam, NoParallelMapping, PermissiveShapeReuse, ModelOffset, ModelRotation, TriangulationType, CgalEmitOriginalEdges, OcctNoCleanTriangulation, CacheShapes, DeferProcessingFirstElement, MaxOffset, MaxOffsetDeviation, ApplyOffset>
std::tuple<MesherLinearDeflection, MesherAngularDeflection, ReorientShells, LengthUnit, PlaneUnit, Precision, OutputDimensionality, LayersetFirst, DisableBooleanResult, NoWireIntersectionCheck, NoWireIntersectionTolerance, PrecisionFactor, DebugBooleanOperations, BooleanAttempt2d, SurfaceColour, WeldVertices, UseWorldCoords, UnifyShapes, UseMaterialNames, ConvertBackUnits, ContextIds, ContextTypes, ContextIdentifiers, IteratorOutput, DisableOpeningSubtractions, ApplyDefaultMaterials, DontEmitNormals, GenerateUvs, ApplyLayerSets, UseElementHierarchy, ValidateQuantities, EdgeArrows, BuildingLocalPlacement, SiteLocalPlacement, ForceSpaceTransparency, CircleSegments, CgalSmoothAngleDegrees, KeepBoundingBoxes, ComputeCurvature, FunctionStepType, FunctionStepParam, NoParallelMapping, PermissiveShapeReuse, ModelOffset, ModelRotation, TriangulationType, CgalEmitOriginalEdges, OcctNoCleanTriangulation, CacheShapes, DeferProcessingFirstElement, MaxOffset, MaxOffsetDeviation, ApplyOffset, MakeVolume>
>
{};
}
+2 -5
View File
@@ -12,11 +12,8 @@ ifcopenshell::geometry::Converter::Converter(std::unique_ptr<ifcopenshell::geome
settings_ = mapping_->settings();
}
ifcopenshell::geometry::Converter::~Converter()
{
if (mapping_ != nullptr) {
delete mapping_;
}
ifcopenshell::geometry::Converter::~Converter() {
delete mapping_;
}
namespace {
@@ -29,6 +29,7 @@
#include "base_utils.h"
#include <BRepPrimAPI_MakeRevol.hxx>
#include <BOPAlgo_MakerVolume.hxx>
namespace {
struct opening_sorter {
@@ -132,11 +133,24 @@ bool IfcGeom::OpenCascadeKernel::convert_openings(const IfcUtil::IfcBaseEntity*
parts.push_back(it3_shape);
}
for (auto& entity_part : parts) {
for (auto entity_part : parts) {
bool is_manifold = util::is_manifold(entity_part);
if (!is_manifold) {
Logger::Warning("Non-manifold first operand");
if (settings_.get<settings::MakeVolume>().get()) {
BOPAlgo_MakerVolume mv;
mv.AddArgument(entity_part);
mv.SetAvoidInternalShapes(true);
try {
mv.Perform();
entity_part = mv.Shape();
Logger::Warning("Sucessfully detected exterior volume to non-manifold first operand");
} catch (const Standard_Failure& e) {
Logger::Warning("MakeVolume failed: " + std::string(e.GetMessageString()), entity);
}
} else {
Logger::Warning("Non-manifold first operand, use --make-volume to try and make manifold");
}
}
TopoDS_Shape entity_part_result;
+77 -54
View File
@@ -82,49 +82,79 @@ bool OpenCascadeKernel::convert(const taxonomy::loft::ptr loft, TopoDS_Shape& re
}
if (non_polygonal) {
if (loft->children.size() == 2) {
BRep_Builder BB;
TopoDS_Shell comp;
BB.MakeShell(comp);
if (loft->children.size() < 2) {
Logger::Error("Not enough sections to loft");
return false;
}
std::vector<std::vector<TopoDS_Wire>> sections;
sections.reserve(loft->children.size());
TopoDS_Shape f0, f1;
if (!convert(std::static_pointer_cast<taxonomy::face>(loft->children.front()), f0) ||
!convert(std::static_pointer_cast<taxonomy::face>(loft->children.back()), f1))
{
TopoDS_Shape f0, f1;
// Convert all children to vectors of wires
for (const auto& child : loft->children) {
TopoDS_Shape shape;
if (!convert(std::static_pointer_cast<taxonomy::face>(child), shape)) {
return false;
}
if (shape.ShapeType() != TopAbs_FACE) {
return false;
}
// At least make sure to have outer wire consistent, but in reality
// this is probably not a concern given how to build up these faces
auto f = TopoDS::Face(shape);
if (child == loft->children.front()) {
f0 = f;
} else if (child == loft->children.back()) {
f1 = f;
}
auto outer = BRepTools::OuterWire(f);
sections.emplace_back();
sections.back().push_back(outer);
for (TopoDS_Iterator it(f); it.More(); it.Next()) {
if (outer != it.Value()) {
sections.back().push_back(TopoDS::Wire(it.Value()));
}
}
}
auto first_wire_count = sections.front().size();
for (auto& section : sections) {
if (section.size() != first_wire_count) {
Logger::Error("Inconsistent number of wires in sections");
return false;
}
if (f0.ShapeType() != TopAbs_FACE || f1.ShapeType() != TopAbs_FACE) {
}
BRep_Builder BB;
TopoDS_Shell comp;
BB.MakeShell(comp);
for (size_t i = 0; i < first_wire_count; ++i) {
// Rule=True uses linear interpolation.
// This is critical for preventing twists in roads/railings.
BRepOffsetAPI_ThruSections builder(false, true);
for (auto& ws : sections) {
builder.AddWire(ws[i]);
}
builder.Build();
if (!builder.IsDone()) {
return false;
}
TopExp_Explorer exp1(f0, TopAbs_WIRE);
TopExp_Explorer exp2(f1, TopAbs_WIRE);
for (; exp1.More() && exp2.More(); exp1.Next(), exp2.Next()) {
const auto& w1 = TopoDS::Wire(exp1.Current());
const auto& w2 = TopoDS::Wire(exp2.Current());
BRepOffsetAPI_ThruSections builder;
builder.AddWire(w1);
builder.AddWire(w2);
builder.Build();
if (!builder.IsDone()) {
return false;
}
for (TopExp_Explorer exp(builder.Shape(), TopAbs_FACE); exp.More(); exp.Next()) {
BB.Add(comp, exp.Current());
}
for (TopExp_Explorer exp(builder.Shape(), TopAbs_FACE); exp.More(); exp.Next()) {
BB.Add(comp, exp.Current());
}
BB.Add(comp, f0.Reversed());
BB.Add(comp, f1);
result = BRepBuilderAPI_MakeSolid(comp).Solid();
return true;
} else {
Logger::Error("Lofting more than two sections is not supported");
return false;
}
BB.Add(comp, f0.Reversed());
BB.Add(comp, f1);
result = BRepBuilderAPI_MakeSolid(comp).Solid();
return true;
}
TopTools_ListOfShape faces;
@@ -135,13 +165,6 @@ bool OpenCascadeKernel::convert(const taxonomy::loft::ptr loft, TopoDS_Shape& re
std::vector<TopoDS_Shape> shps(loft->children.size());
std::vector<std::vector<std::set<std::string>>> all_tags;
std::ostringstream oss;
loft->children[0]->print(oss);
loft->children[1]->print(oss);
auto s = oss.str();
std::wcout << s.c_str() << std::endl;
// First convert all taxonomy items to TopoDS_Wire/Face
for (auto it = loft->children.begin(); it < loft->children.end(); ++it) {
auto i = std::distance(loft->children.begin(), it);
@@ -237,6 +260,18 @@ bool OpenCascadeKernel::convert(const taxonomy::loft::ptr loft, TopoDS_Shape& re
return true;
*/
if (shps.size() < 2) {
Logger::Error("Not enough sections to loft");
return false;
}
if (shps[0].ShapeType() == TopAbs_FACE) {
// When processing a sectioned *surface* there are no
// begin and end caps that need to be added.
BB.Add(comp, shps.front().Reversed());
BB.Add(comp, shps.back());
}
// @todo this approach is
// potentially incorrect as there is no guarantee that the wires for
// subsequently placed profiles are traversed from an equivalent start vertex.
@@ -262,18 +297,6 @@ bool OpenCascadeKernel::convert(const taxonomy::loft::ptr loft, TopoDS_Shape& re
ws[0][i] = TopoDS::Wire(*fa[i]);
}
}
if (it->ShapeType() == TopAbs_FACE) {
// When processing a sectioned *surface* there are no
// begin and end caps that need to be added.
if (it == shps.begin()) {
// faces.Append(shps[0]);
BB.Add(comp, shps[0]);
}
if (jt == shps.end() - 1) {
// faces.Append(shps[1]);
BB.Add(comp, shps[1]);
}
}
if (!all_tags.empty()) {
// only open profiles have tags for now, so there is only one wire, no inner wires
@@ -21,7 +21,44 @@
#define mapping POSTFIX_SCHEMA(mapping)
using namespace ifcopenshell::geometry;
#include <deque>
taxonomy::ptr mapping::map_impl(const IfcSchema::IfcObjectPlacement* inst) {
if (placement_rel_to_type_ || placement_rel_to_instance_) {
using QueueItem = std::pair<const IfcUtil::IfcBaseEntity*, int>;
std::deque<QueueItem> q = {{inst, 0}};
while (!q.empty()) {
auto [placement_entity, depth] = q.front();
q.pop_front();
auto placement = placement_entity->as<typename IfcSchema::IfcObjectPlacement>();
if (!placement) {
continue;
}
auto self_places = placement->PlacesObject();
for (auto iter = self_places->begin(); iter != self_places->end(); ++iter) {
if ((placement_rel_to_type_ && (*iter)->declaration().is(*placement_rel_to_type_)) ||
(placement_rel_to_instance_ && (*iter)->as<IfcUtil::IfcBaseEntity>() == placement_rel_to_instance_)) {
return taxonomy::make<taxonomy::matrix4>();
}
}
// Look for two levels deep, we want to know if we're at or *above* the
// element we're ignoring, but we don't want to traverse the entire model.
#ifdef SCHEMA_IfcObjectPlacement_HAS_ReferencedByPlacements
if (depth < 2) {
auto refs = placement->ReferencedByPlacements();
for (auto& ref : *refs) {
q.emplace_back(ref, depth + 1);
}
}
#else
Logger::Warning("Using --site-local-placement or --building-local-placement on IFC4.2 might have issues");
#endif
}
}
const IfcSchema::IfcObjectPlacement* relative_to = nullptr;
const IfcUtil::IfcBaseInterface* transform;
@@ -61,33 +61,9 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcSectionedSolidHorizontal* in
longitudes.push_back(*pbde->DistanceAlong()->as<IfcSchema::IfcLengthMeasure>(true) * length_unit_);
// Corresponds to the profile X, Y directions (hopefully).
Eigen::Vector3d po(
pbde->OffsetLateral().get_value_or(0.),
// @todo I don't understand whether vertical is an offset relative to the tangent plane or to the global XY plane
pbde->OffsetVertical().get_value_or(0.),
0.
);
profile_offsets.push_back(po);
boost::optional<Eigen::Matrix3d> rot;
if (csp->Axis() && csp->RefDirection()) {
rot = taxonomy::matrix4(
Eigen::Vector3d(0, 0, 0),
taxonomy::cast<taxonomy::direction3>(map(csp->Axis()))->ccomponents(),
taxonomy::cast<taxonomy::direction3>(map(csp->RefDirection()))->ccomponents()).ccomponents().block<3,3>(0,0);
} else if (csp->Axis()) {
rot = taxonomy::matrix4(
Eigen::Vector3d(0, 0, 0),
taxonomy::cast<taxonomy::direction3>(map(csp->Axis()))->ccomponents()).ccomponents().block<3, 3>(0, 0);
} else if (csp->RefDirection()) {
rot = taxonomy::matrix4(
Eigen::Vector3d(0, 0, 0),
Eigen::Vector3d(0, 0, 1),
taxonomy::cast<taxonomy::direction3>(map(csp->RefDirection()))->ccomponents()
).ccomponents().block<3, 3>(0, 0);
}
auto linear_placement = taxonomy::cast<taxonomy::matrix4>(map(csp));
profile_offsets.push_back(linear_placement->ccomponents().block<3, 1>(0, 3));
boost::optional<Eigen::Matrix3d> rot(linear_placement->ccomponents().block<3,3>(0,0));
profile_rotations.push_back(rot);
}
if (faces.size() != profile_offsets.size()) {
+4 -29
View File
@@ -63,35 +63,10 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcSectionedSurface* inst) {
longitudes.push_back(*pbde->DistanceAlong()->as<IfcSchema::IfcLengthMeasure>(true) * length_unit_);
// Corresponds to the profile X, Y directions (hopefully).
Eigen::Vector3d po(
pbde->OffsetLateral().get_value_or(0.),
// @todo I don't understand whether vertical is an offset relative to the tangent plane or to the global XY plane
pbde->OffsetVertical().get_value_or(0.),
0.
);
profile_offsets.push_back(po);
boost::optional<Eigen::Matrix3d> rot;
if (csp->Axis() && csp->RefDirection()) {
rot = taxonomy::matrix4(
Eigen::Vector3d(0, 0, 0),
taxonomy::cast<taxonomy::direction3>(map(csp->Axis()))->ccomponents(),
taxonomy::cast<taxonomy::direction3>(map(csp->RefDirection()))->ccomponents()).ccomponents().block<3, 3>(0, 0);
} else if (csp->Axis()) {
rot = taxonomy::matrix4(
Eigen::Vector3d(0, 0, 0),
taxonomy::cast<taxonomy::direction3>(map(csp->Axis()))->ccomponents()).ccomponents().block<3, 3>(0, 0);
} else if (csp->RefDirection()) {
rot = taxonomy::matrix4(
Eigen::Vector3d(0, 0, 0),
Eigen::Vector3d(0, 0, 1),
taxonomy::cast<taxonomy::direction3>(map(csp->RefDirection()))->ccomponents())
.ccomponents()
.block<3, 3>(0, 0);
}
profile_rotations.push_back(rot);
auto linear_placement = taxonomy::cast<taxonomy::matrix4>(map(csp));
profile_offsets.push_back(linear_placement->ccomponents().block<3, 1>(0, 3));
boost::optional<Eigen::Matrix3d> rot(linear_placement->ccomponents().block<3, 3>(0, 0));
profile_rotations.push_back(rot);
}
#else
return nullptr;
@@ -252,6 +252,10 @@ nest formulas, for example ``concat(title("foo"), lower("Bar"))`` will produce
"``number({{value}}[, {{decimal_separator}}[, {{thousands_separator}}]])``", "``number(1234.56, "","", ""."")``", "``1.234,56``", "Formats {{value}} with an optional custom {{decimal_separator}} and {{thousands_separator}}. The default separators are ``.`` and ``,``."
"``metric_length({{value}}, {{precision}}, {{decimals}})``", "``metric_length(3.123, 0.1, 2)``", "``3.10``", "Rounds ``{{value}}`` to the nearest ``{{precision}}`` then displays using a certain amount of decimal places."
"``imperial_length({{value}}, {{precision}}, {{input_unit}}, {{output_unit}}, {{suppress_zero_inches}})``", "``imperial_length(3.0, 4, ""foot"", ""foot"", true)`` OR ``imperial_length(3.0, 4, ""foot"", ""foot"", false)``", "``3'`` OR ``3' - 0""``", "The ``{{value}}`` may be specified either as ``foot`` or ``inch`` depending on ``{{input_unit}}``. The ``{{value}}`` is then rounded to the nearest ``1/{{precision}}`` inch, then formatted using fractional feet and inches if ``{{output_unit}}`` is set to ``foot``, or just inches if ``{{output_unit}}`` is set to ``inch``. When ``{{suppress_zero_inches}}`` is ``true`` (default), measurements with zero inches will omit the inch portion (e.g., ``3'`` instead of ``3' - 0""``)."
"``sort({{values}})``", "``sort({{mats.Name}})``", "``Name1, Name2``", "Sorts a list of items."
"``reverse({{values}})``", "``reverse({{mats.Name}})``", "``Name2, Name1``", "Reverses a list of items."
"``join({{separator}}, {{values}})``", "``join(""-"", {{mats.Name}})``", "``Name1-Name2``", "Joins a list of items with a custom separator. By default, all lists a rendered as comma separated."
"``{{value1}}[+-*/]{{value2}}``", "``{{z}}+3``", "``5``", "Does arithmetic. Typical operators such as +, -, \*, and / are allowed and can be mixed with other variables and formatting functions."
When using queries in an IfcAnnotation tag surround with backticks.
Examples:
@@ -28,7 +28,7 @@ def _create_offset_curve_representation(
file: ifcopenshell.file, alignment: entity_instance, offsets: Sequence[entity_instance]
) -> None:
"""
Create geometric representation for the alignment based on an IfcPolyline
Create geometric representation for the alignment based on an IfcOffsetByDistances curve
:param alignment: The alignment for which the representation is being created
:return: None
@@ -36,6 +36,11 @@ def _create_offset_curve_representation(
expected_type = "IfcAlignment"
if not alignment.is_a(expected_type):
raise TypeError(f"Expected {expected_type} but got {alignment.is_a()}")
expected_type = "IfcPointByDistanceExpression"
for offset in offsets:
if not offset.is_a(expected_type):
raise TypeError(f"Expected {expected_type} but got {offset.is_a()}")
axis_geom_subcontext = ifcopenshell.api.alignment.get_axis_subcontext(file)
@@ -39,7 +39,7 @@ def create_as_offset_curve(
:param file:
:param name: name assigned to IfcAlignment.Name
:param offsets: offsets from the basis curve that defines the offset curve, expected to be IfcOffsetCurveByDistances.
:param offsets: offsets from the basis curve that defines the offset curve, expected to be IfcPointByDistanceExpression.
:param start_station: station value at the start of the alignment
:return: Returns an IfcAlignment
"""
@@ -100,15 +100,10 @@ class Usecase:
size = self.convert_si_to_unit(1)
points = ((0.0, 0.0), (size, 0.0), (size, size), (0.0, size), (0.0, 0.0))
if self.polyline:
# Only scale polyline if we have actual slope
if self.x_angle and abs(self.x_angle) > 1e-6:
points = [
(self.convert_si_to_unit(p[0]), self.convert_si_to_unit(p[1] * abs(1 / cos(self.x_angle))))
for p in self.polyline
]
else:
points = [(self.convert_si_to_unit(p[0]), self.convert_si_to_unit(p[1])) for p in self.polyline]
points = [
(self.convert_si_to_unit(p[0]), self.convert_si_to_unit(p[1] * abs(1 / cos(self.x_angle))))
for p in self.polyline
]
if self.file.schema == "IFC2X3":
curve = self.file.createIfcPolyline([self.file.createIfcCartesianPoint(p) for p in points])
else:
@@ -119,23 +114,21 @@ class Usecase:
else:
direction_ratios = (0.0, 0.0, 1.0)
offset_direction = direction_ratios # offset direction doesn't change if direction_sense is negative
extrusion_direction = self.file.createIfcDirection(direction_ratios)
if self.direction_sense == "NEGATIVE":
direction_ratios = tuple(-n for n in direction_ratios)
extrusion_direction = self.file.createIfcDirection(direction_ratios)
# Calculate depth based on extrusion angle
extrusion_angle = abs(self.x_angle) if self.x_angle else 0
if extrusion_angle > 1e-6:
perpendicular_depth = self.convert_si_to_unit(self.depth) * abs(1 / cos(extrusion_angle))
perpendicular_offset = self.convert_si_to_unit(self.offset) * abs(1 / cos(extrusion_angle))
else:
perpendicular_depth = self.convert_si_to_unit(self.depth)
perpendicular_offset = self.convert_si_to_unit(self.offset)
perpendicular_offset = self.convert_si_to_unit(self.offset) * abs(1 / cos(self.x_angle))
perpendicular_depth = self.convert_si_to_unit(self.depth) * abs(1 / cos(self.x_angle))
position = None
# default position for IFC2X3 where .Position is not optional
if self.file.schema == "IFC2X3" or self.offset != 0:
position_vector = (
direction_ratios[0] * perpendicular_offset,
direction_ratios[1] * perpendicular_offset,
direction_ratios[2] * perpendicular_offset,
offset_direction[0] * perpendicular_offset,
offset_direction[1] * perpendicular_offset,
offset_direction[2] * perpendicular_offset,
)
position = self.file.createIfcAxis2Placement3D(
self.file.createIfcCartesianPoint(position_vector),
@@ -85,6 +85,7 @@ class Usecase:
def create_item(self) -> ifcopenshell.entity_instance:
length = self.convert_si_to_unit(self.settings["length"])
thickness = self.convert_si_to_unit(self.settings["thickness"])
thickness *= 1 / cos(self.settings["x_angle"])
if self.settings["direction_sense"] == "NEGATIVE":
thickness *= -1
points = (
@@ -112,7 +113,7 @@ class Usecase:
self.file.createIfcDirection((1.0, 0.0, 0.0)),
),
extrusion_direction,
self.convert_si_to_unit(self.settings["height"]),
self.convert_si_to_unit(self.settings["height"]) * abs(1 / cos(self.settings["x_angle"])),
)
if self.settings["booleans"]:
extrusion = self.apply_booleans(extrusion)
@@ -18,7 +18,7 @@
import math
from decimal import ROUND_HALF_UP, Decimal
from typing import NamedTuple, Optional, Union
from typing import NamedTuple, Optional, Union, Any
import numpy as np
@@ -268,6 +268,15 @@ def get_helmert_transformation_parameters(ifc_file: ifcopenshell.file) -> Option
return HelmertTransformation(e, n, h, xaa, xao, scale, factor_x, factor_y, factor_z)
def get_crs(ifc_file: ifcopenshell.file) -> dict[str, Any]:
"""Get CRS information from an IFC file."""
if ifc_file.schema == "IFC2X3":
return ifcopenshell.util.element.get_pset(ifc_file.by_type("IfcProject")[0], "ePSet_ProjectedCRS")
for context in ifc_file.by_type("IfcGeometricRepresentationContext", include_subtypes=False):
if operation := context.HasCoordinateOperation:
return operation[0].TargetCRS.get_info()
def auto_z2e(ifc_file: ifcopenshell.file, z: float, should_return_in_map_units: bool = True) -> float:
"""Convert a Z coordinate to an elevation using model georeferencing data
@@ -144,7 +144,7 @@ format_grammar = lark.Lark(
| mul_div "*" function -> multiply
| mul_div "/" function -> divide
function: round | number | int | format_length | lower | upper | title | concat | substr | variable | ESCAPED_STRING | NUMBER | "(" expression ")"
function: round | number | int | format_length | lower | upper | title | concat | substr | sort | reverse | join | variable | ESCAPED_STRING | SIGNED_NUMBER | "(" expression ")"
variable: "{{" query_path "}}"
query_path: /[^}]+/
@@ -160,6 +160,9 @@ format_grammar = lark.Lark(
title: "title(" expression ")"
concat: "concat(" expression ("," expression)* ")"
substr: "substr(" expression "," SIGNED_INT ["," SIGNED_INT] ")"
sort: "sort(" expression ")"
reverse: "reverse(" expression ")"
join: "join(" ESCAPED_STRING "," expression ")"
boolean: TRUE | FALSE
TRUE: "true" | "True" | "TRUE"
@@ -201,6 +204,8 @@ class FormatTransformer(lark.Transformer):
self.element = element
def start(self, args):
if isinstance(args[0], (list, tuple)):
return ", ".join(args[0])
return args[0]
def expression(self, args):
@@ -208,18 +213,11 @@ class FormatTransformer(lark.Transformer):
def variable(self, args):
"""Handle variable substitution like {{z}} or {{Pset_Wall.FireRating}}"""
if self.element is None:
return "0" # Default value if no element context
query_path = args[0]
try:
value = get_element_value(self.element, query_path)
if value is None:
return "0"
# Convert to string for further processing
return str(value)
except:
return "0" # Return default on error
if self.element:
try:
return get_element_value(self.element, args[0])
except:
pass
def query_path(self, args):
"""Extract the query path from variable"""
@@ -301,6 +299,15 @@ class FormatTransformer(lark.Transformer):
elif len(args) == 2:
return str(args[0])[int(args[1]) :]
def sort(self, args):
return sorted(args[0])
def reverse(self, args):
return list(reversed(args[0]))
def join(self, args):
return args[0].join(args[1])
def boolean(self, args):
if not args:
return True
@@ -35,7 +35,7 @@ import ifcopenshell.util.selector as subject
import test.bootstrap
class TestFormat:
class TestFormat(test.bootstrap.IFC4):
def test_no_formatting(self):
assert subject.format("123") == "123"
assert subject.format('"123"') == "123"
@@ -54,6 +54,7 @@ class TestFormat:
def test_number_formatting(self):
assert subject.format("round(123, 5)") == "125"
assert subject.format('round("123", 5)') == "125"
assert subject.format('round(-123, 5)') == "-125"
assert subject.format("int(123.123)") == "123"
assert subject.format("int(123)") == "123"
assert subject.format("number(123)") == "123"
@@ -77,6 +78,38 @@ class TestFormat:
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\""
def test_variable_formatting(self):
assert subject.format('{{undefined}}') is None
assert subject.format('upper({{undefined}})') == "NONE"
assert subject.format('int({{undefined}})') == "0"
element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
assert subject.format('{{undefined}}', element) is None
assert subject.format('{{class}}', element) == "IfcWall"
assert subject.format('{{ class }}', element) == "IfcWall"
assert subject.format('upper({{ class }})', element) == "IFCWALL"
def test_list_formatting(self):
element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
material = ifcopenshell.api.material.add_material(self.file, name="CON01")
material2 = ifcopenshell.api.material.add_material(self.file, name="CON03")
material3 = ifcopenshell.api.material.add_material(self.file, name="CON02")
material_set = ifcopenshell.api.material.add_material_set(self.file, set_type="IfcMaterialLayerSet")
layer = ifcopenshell.api.material.add_layer(self.file, layer_set=material_set, material=material)
layer = ifcopenshell.api.material.add_layer(self.file, layer_set=material_set, material=material2)
layer = ifcopenshell.api.material.add_layer(self.file, layer_set=material_set, material=material3)
ifcopenshell.api.material.assign_material(self.file, products=[element], material=material_set)
assert subject.format('{{materials.Name}}', element) == "CON01, CON03, CON02"
assert subject.format('sort({{materials.Name}})', element) == "CON01, CON02, CON03"
assert subject.format('reverse({{materials.Name}})', element) == "CON02, CON03, CON01"
assert subject.format('join("-", {{materials.Name}})', element) == "CON01-CON03-CON02"
def test_expressions(self):
assert subject.format('2+3') == "5"
assert subject.format('-2+3') == "1"
assert subject.format('2-3') == "-1"
assert subject.format('3*2') == "6"
assert subject.format('3/2') == "1.5"
class TestGetElementValue(test.bootstrap.IFC4):
def test_selecting_an_elements_class_or_id_using_a_query(self):

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