Compare commits

...

86 Commits

Author SHA1 Message Date
DesertSpringsCivil 4043037484 style: Apply Black formatting to root/operator.py
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 10:00:35 -06:00
DesertSpringsCivil eb1e79ea5c fix: Route IfcAlignment creation through align_api.create()
Addresses Rick Brice's PR review feedback on #7785.

Root cause: the AddElement dialog was manually stitching together
IfcAlignment layout containers and calling create_representation(),
bypassing the validated construction sequence in align_api.create().
This left IfcGradientCurve.BaseCurve potentially None and skipped
zero-length segment and stationing referent setup.

Changes:
- root/operator.py: alignment templates now call align_api.create()
  directly (HORIZONTAL, GRADIENT, CANT) or _create_polyline_representation
  (POLYLINE_2D/3D), then manually link the result to the Blender object via
  tool.Ifc.link + tool.Collector.assign. The old generic core.assign_class
  path is retained for all non-alignment templates unchanged.
- tool/alignment.py: remove create_representation_structure() — superseded
  by the operator changes above.
- create_representation.py: revert the if layout_nest: guard; with
  align_api.create() as the entry point the zero-length segment always
  exists before create_representation is called.
- add_zero_length_segment.py: revert the BaseCurve None guard; the root
  cause (gradient curve created without a base curve) no longer occurs.
- alignment/operator.py: guard _create_geometric_representation call so it
  only runs when no curve representation exists yet; auto-invoke
  create_alignment_by_pi after PI picker finishes if >=2 PIs are defined.
- util/file.py: fix StopIteration on short IFC template files
  (next(ifc_file) → next(ifc_file, None) with break).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 18:16:09 -06:00
DesertSpringsCivil d2ee4f2f97 fix: Correct vertical/cant segment handling in alignment API
Two bugs prevented IfcAlignmentVertical segments from being added when
a geometric representation exists.

Bug 1 — _add_segment_to_curve.py:
Removed an unconditional `if not curve.is_a("IfcCompositeCurve")` guard
that was left over from when the function only supported horizontal
segments. The preceding if/elif/elif chain already validates the correct
curve type for each segment type; the redundant check always raised
TypeError for vertical (IfcGradientCurve) and cant
(IfcSegmentedReferenceCurve) segments.

Bug 2 — add_zero_length_segment.py:
Added a None guard before the recursive `add_zero_length_segment(file,
layout.BaseCurve)` call for IfcGradientCurve. When an IfcGradientCurve
is created without a BaseCurve (e.g. before a horizontal representation
exists), the recursive call previously crashed with AttributeError.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 16:56:33 -06:00
DesertSpringsCivil e09ce1bcd8 feat: Add alignment representation templates to AddElement dialog
Wire IfcAlignment creation into Bonsai's Add IFC Element flow with
representation template options (Horizontal, Gradient, Cant, 3D/2D
Polyline). Adds create_representation_structure() to tool.Alignment
which creates layout containers, geometric representation, and
polyline placeholders. Guards create_representation against empty
layout nests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 23:53:41 -07:00
DesertSpringsCivil 0b3967609c fix: Address PR #7589 review items - prefix rename, cleanup, copyright
- Rename all saikei.* operator idnames to civil.* per Bonsai convention
- Rename SAIKEI_OT_*, SAIKEI_PT_*, SAIKEI_UL_* classes to CIVIL_* prefix
- Rename SaikeiAlignmentProperties -> CivilAlignmentProperties
- Rename saikei_* Blender object custom property keys to civil_*
- Remove IOS-version-compat try/except fallback in _get_segment_vertices_in_model_units()
  now that Rick's segment_vertices() API accepts IfcAlignmentSegment directly
- Remove try/except wrapper around get_alignment() - call directly
- Add Michael Yoder copyright to __init__.py and operator.py
- Fix misleading coordinate comment (IFC -> global easting/northing)
- Document props.pis coordinate system (global E/N) in AlignmentPI and operator comments

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 13:39:19 -07:00
DesertSpringsCivil 9a7cd5b373 chore: Add Claude Code local config files to .gitignore
Ignore CLAUDE.md, CLAUDE.local.md, and .mcp.json so personal
Claude Code configuration (managed via private dotfiles repo)
doesn't pollute the shared repository.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 10:16:37 -07:00
Richard Brice 701635b81c revises get_mapped_segments
get_mapped_segments now looks for representations attached to alignment segments before using the more complex method of computing the index of segments in the composite curve.
updates segment_vertices to use get_mapped_segments
2026-02-25 07:51:17 -08:00
Dion Moult 88f6c7cf65 Remove unnecessary status panel 2026-02-25 11:03:51 +11:00
Dion Moult cbdf1a193e Remove create alignment operator, refactor to use tool.Alignment.get_active_alignment 2026-02-25 11:03:06 +11:00
Dion Moult 95048ea2dd Typo 2026-02-25 11:02:23 +11:00
Dion Moult 0e1a2510ff Refactor get alignment layouts into util 2026-02-25 10:24:30 +11:00
Dion Moult fff653b882 Consolidate adding alignments into Add Element interface 2026-02-25 10:24:18 +11:00
Dion Moult 160348df1f Purge unnecessary undo code 2026-02-25 10:20:31 +11:00
Dion Moult 278a35e729 Revert "Fix segment object parenting and add Claude Code to .gitignore"
This reverts commit e1bf717af5.
2026-02-25 09:16:18 +11:00
DesertSpringsCivil e1bf717af5 Fix segment object parenting and add Claude Code to .gitignore
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 13:54:51 -07:00
DesertSpringsCivil be30f9cbea Add undo/redo support to PI edit mode operator
Wrap SAIKEI_OT_enter_pi_edit_mode with Bonsai's IfcStore transaction
system so IFC segment changes from applying PI edits are tracked and
undoable via Ctrl+Z.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 13:54:50 -07:00
DesertSpringsCivil 4971546de9 Replace manual PI trigonometry with segment_vertices() API
Uses Rick Brice's new ifcopenshell.api.alignment.segment_vertices()
to extract PI positions from alignment segments via the C++ geometry
engine, replacing ~300 lines of hand-coded trig that only handled
LINE and CIRCULARARC. Now supports all segment types (CLOTHOID,
Helmert curves, etc.). Includes backward-compatible fallback for
IFC files without Axis/Segment representations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 13:54:50 -07:00
Richard Brice d9a3c2d5c2 alignment api cleanup
updates some documentation
removes some dead code
2026-02-24 12:44:34 -08:00
Richard Brice 5066fc5a29 black 2026-02-24 08:58:00 -08:00
Richard Brice 9041857277 Updates segment_vertices to handle IfcAlignmentSegment 2026-02-24 08:57:51 -08:00
Richard Brice 7e93a73c6a Updates alignment API function for creating a layout segment to include the segment geometric representation 2026-02-24 08:55:33 -08:00
Richard Brice 99640912c1 Adds alignment API function to get the alignment layout from one of its segments 2026-02-23 16:21:05 -08:00
Richard Brice 798ed0d502 Updates segment_vertices function
Renamed PI and CC to TI and NI
Fixes handling of units (now works correctly with US and SI units)
Changed tests to use US feet units
Fixed documentation
2026-02-23 08:08:48 -08:00
Richard Brice c3f325861c Adds segment_vertices function to alignment api 2026-02-22 15:47:16 -08:00
DesertSpringsCivil 8433999575 Refactor alignment module: fix bugs, remove dead code, enforce architecture
- Fix 4 runtime bugs: seg/s variable mismatch, missing float() wrappers,
  PI dict key mismatches ("x"/"y" -> "e"/"n"), float-to-StringProperty
- Remove ~470 lines of dead code across prop.py, core/alignment.py,
  tool/alignment.py, and operator.py
- Consolidate duplicate math functions from operator.py into tool layer
  (arc_length_at_pi, tangent_length_at_pi, tangent_segment_length)
- Move PI extraction logic from operator.py to tool/alignment.py
- Add IfcStore undo pattern to 7 IFC-modifying operators
- Core layer no longer calls IFC API directly (delegates via tool wrappers)
- Remove unused imports (math, IntProperty, Vector)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 19:40:44 -07:00
Dion Moult bba1ff3786 Merge branch 'v0.8.0' into saikei 2026-02-19 10:22:31 +11:00
Dion Moult a39e4c552a Now horizontal alignments are created in IFC local coords, remove redundant PI calculation code 2026-02-18 11:14:35 +11:00
Dion Moult 21c6384b7e Use existing create_mesh when creating segments to handle Blender specific georeferencing offsets 2026-02-18 11:13:25 +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
DesertSpringsCivil 07ef382c7e Refactor PI picker to use Bonsai polyline system
Replace custom SAIKEI_OT_pick_pi_from_viewport modal with a
PolylineOperator subclass, reusing Bonsai's proven polyline
infrastructure (same base class as wall/slab/profile drawing).

Gains: snapping, numeric D/A/X/Y input, axis locking, angle
locking, measurement display, undo-last-point, status bar hints.

Remove PIPickerDecorator (replaced by PolylineDecorator).
Fix EN string formatting in UI list display rows.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 11:29:30 -07: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
Dion Moult 65af40e7d5 Change from XY to EN and use strings not floats in Blender due to precision 2026-02-13 11:09:45 +11:00
Dion Moult cfe30a505e Readd numpad enter keybinding 2026-02-13 09:59:35 +11:00
Dion Moult 04677c1b80 Revert "Add Space key as alternative to Enter for PI Edit Mode apply"
This reverts commit 33a4639de5.
2026-02-13 09:58:00 +11:00
Dion Moult f06873d24e Remove try except blocks, rename piecewise-step to function-step, remove settings where default suffices 2026-02-13 09:57:51 +11:00
Dion Moult cd6e531120 Revert "Fix dev_environment.py Windows compatibility for symlink handling"
This reverts commit a37de44a24.
2026-02-13 09:14:35 +11:00
Dion Moult f9f0f1915f Remove claude 2026-02-13 09:14:32 +11: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
DesertSpringsCivil e0a1577ae0 Fix in-place alignment editing and curve visualization
- Add clear_layout_segments API to remove segments while preserving alignment ID
- Modify exit_pi_edit_mode to edit segments in-place instead of delete+recreate
- Fix curve visualization by using create_shape for segment vertices
- Fix evaluate_segment validation to handle negative-length curve segments

This prevents "Active alignment no longer exists" errors when editing PIs
and properly renders circular arcs regardless of turn direction.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 00:05:54 -07: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
DesertSpringsCivil 33a4639de5 Add Space key as alternative to Enter for PI Edit Mode apply
When using G key to move PIs, Blender's transform modal consumes
the Enter key. Adding Space as an alternative lets users apply
changes more easily. Also adds Numpad Enter support.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 20:46:19 -07:00
DesertSpringsCivil a3038528bb Add PI Edit Mode for moving alignment points with G key
Implements the ability to edit alignment PI (Point of Intersection)
positions after creation using Blender's standard transform tools:

- Back-calculate PI positions from existing IFC alignment segments
- Create temporary EMPTY objects at PI locations for editing
- Visual feedback via PIEditDecorator (yellow tangent lines, HUD)
- Modal operator handles G key movement, Enter to apply, Escape to cancel
- Regenerates alignment with new PI positions on apply
- Handles edge cases: single-segment, tangent-only, undo during edit

Architecture follows Bonsai patterns:
- Core layer: Business logic orchestration (enter/exit_pi_edit_mode)
- Tool layer: Math, IFC, and Blender implementations
- UI layer: Modal operator with PASS_THROUGH for standard transforms

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 20:26:41 -07:00
DesertSpringsCivil eea060b72d Local: Document git workflow for local-only files in CLAUDE.md
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 19:09:58 -07:00
DesertSpringsCivil 862697baae Local: Add CLAUDE.md project context
This file contains project-specific context for Claude Code sessions.
It should NOT be pushed to origin (protected by pre-push hook).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 19:00:58 -07:00
DesertSpringsCivil 9a3ae628aa Add rubber band visualization to PI picker modal
Add visual feedback during PI placement with PIPickerDecorator:
- Yellow tangent lines connecting placed PIs
- Rubber band line from last PI to cursor position
- Green circle markers at each PI location
- HUD text showing instructions and PI count

Follows Bonsai's established decorator pattern with GPU draw handlers.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 18:19:58 -07:00
DesertSpringsCivil 46da629c8b Create selectable curve geometry for alignment segments
Replace segment empty objects with actual curve geometry so that selecting
a segment in the Outliner highlights the corresponding line/curve in the
viewport.

Changes:
- Add get_segment_vertices() using IfcOpenShell's evaluate_segment() to
  sample points along individual segments via the geometry engine
- Replace _create_segment_empty with _create_segment_curve that creates
  Blender CURVE objects with actual geometry
- Remove single HorizontalCurve in favor of per-segment curves
- Supports all segment types (LINE, CIRCULARARC, CLOTHOID, spirals, etc.)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 16:41:04 -07:00
DesertSpringsCivil fd85b29777 Hide zero-length terminator segments from UI and suppress empty alignment warnings
Zero-length segments are required by IFC to mark alignment ends but should
be invisible to users. This change:
- Adds helper methods to detect zero-length and empty layouts
- Silently skips geometry generation for empty alignments (no error messages)
- Excludes zero-length segments from Outliner display
- Uses separate visible segment counter for consistent naming

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 15:34:07 -07:00
DesertSpringsCivil 95952d03f8 Fix PI picker viewport coordinate calculation and add georeference support
- Fix modal operator to use absolute mouse coordinates converted to 3D
  viewport region space, instead of event.mouse_region_x/y which are
  relative to whichever region received the event
- Store 3D viewport area, region, and region_data references in invoke()
  for consistent raycasting throughout modal operation
- Add coordinate transformation methods (blender_to_ifc_coordinates and
  ifc_to_blender_coordinates) for projects with geospatial Blender offsets
- Transform alignment curve vertices from IFC global to Blender local
  coordinates when has_blender_offset is enabled
- Add try/except for piecewise-step-size geometry setting in util.py

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 10:19:02 -07:00
DesertSpringsCivil 5ad40612fb Remove Import CSV button and PI Details panel from alignment UI
Simplifies the Horizontal Alignment panel by removing the Import
Alignment CSV button and the PI Details submenu that displayed
when selecting rows in the PI Editor list.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 10:17:44 -07:00
DesertSpringsCivil 6294c35a11 Handle RuntimeError in alignment geometry generation
Add RuntimeError handling for IfcOpenShell versions that don't support
the piecewise-step-type setting in geometry generation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 18:40:49 -07:00
DesertSpringsCivil de2a7c9527 Add CIVIL tab to Bonsai Properties sidebar for alignment tools
- Add new CIVIL tab (4th position) with CURVE_DATA icon
- Create BIM_PT_tab_horizontal_alignment panel container
- Move alignment UI from N-panel to Properties sidebar
- Remove old "Saikei Civil" N-panel tab

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 18:39:38 -07:00
DesertSpringsCivil a37de44a24 Fix dev_environment.py Windows compatibility for symlink handling
Git checkout with glob patterns (*.ifc) doesn't work on Windows.
This change:
- Expands glob via git ls-files and checks out files individually
- Skips symlink recreation if they already exist and are valid
- Refreshes git index before checkout to recognize deleted files

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 14:43:13 -07:00
DesertSpringsCivil c0b7256832 Use IfcOpenShell geometry engine for alignment visualization
Replace manual per-segment geometry creation with IfcOpenShell's
built-in generate_vertices() utility. This provides automatic support
for all curve types (CLOTHOID, spirals, etc.) and removes ~75 lines
of manual geometry code.

Changes:
- Add create_curve_from_representation() using IfcOpenShell geometry engine
- Add _create_segment_empty() for segment selection without geometry
- Update create_objects_for_layout_segments() to use new methods
- Delete manual geometry methods: create_object_for_segment(),
  _create_line_segment(), _create_arc_segment()

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 13:35:20 -07:00
DesertSpringsCivil 42db172827 Clean up unused imports in alignment operator module
Removed leftover imports from sequence module template:
- isodate, dateutil, calendar, datetime
- bonsai.bim.module.sequence.helper
- ifcopenshell.util.sequence, ifcopenshell.geom, ifcopenshell.util.selector
- Duplicate imports (os, json, ImportHelper, ifcopenshell.api.alignment)

These unused imports were causing silent module registration failures.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 11:18:35 +11:00
DesertSpringsCivil f52e95a05a Remove 11 unused operators from Saikei alignment module
Operators removed (not called in any UI):
- SAIKEI_OT_create_alignment_polyline
- SAIKEI_OT_create_alignment_offset
- SAIKEI_OT_add_vertical_layout
- SAIKEI_OT_add_layout_segment
- SAIKEI_OT_layout_horizontal_by_pi
- SAIKEI_OT_layout_vertical_by_pi
- SAIKEI_OT_create_representation
- SAIKEI_OT_create_segment_representations
- SAIKEI_OT_update_fallback_position
- SAIKEI_OT_validate_segments
- SAIKEI_OT_refresh_alignment_data

Also fixed poll_ifc4x3() and replaced all tool.Alignment.get_ifc_file()
calls with tool.Ifc.get() after previous refactoring removed that method.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 11:18:35 +11:00
DesertSpringsCivil 04ad0b92bd Refactor Saikei alignment module to follow Bonsai architecture
- Move math/calculation functions from core to tool layer
  (calculate_pi_geometry, calculate_deflection_angle, etc.)
- Remove duplicate get_ifc_file() wrappers, use tool.Ifc.get() directly
- Remove redundant ifc_definition_id manual settings (tool.Ifc.link handles this)
- Remove fallback object lookup methods (_find_object_by_ifc_id, _find_object_by_name_pattern)
- Simplify remove methods to use tool.Ifc.get_object() directly
- Clean up defensive try/except ImportError blocks
- Update is_ifc4x3() to use tool.Ifc.get_schema()
- Update license headers to Bonsai standard

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 11:18:35 +11:00
Dion Moult 8416ca46a7 More forgotten files 2026-01-22 11:23:26 +11:00
Dion Moult 10ce30b2ac Forgot to commit these files 2026-01-21 18:59:31 +11:00
Dion Moult 00c971fa2d Purge unnecessary Saikei boilerplate 2026-01-21 12:46:58 +11:00
Dion Moult b86bf45167 Remove claude 2026-01-21 12:32:11 +11:00
Dion Moult 22fd318d21 Initial commit of sakei code prior to any refactoring 2026-01-21 12:17:36 +11:00
76 changed files with 5769 additions and 1888 deletions
+7
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/
@@ -113,3 +115,8 @@ dev_environment.bat
src/ifcopenshell-python/ifcopenshell/express/*.exp
src/ifcopenshell-python/ifcopenshell/express/*.exp.cache.dat
# Claude Code local config (managed via dotfiles repo)
CLAUDE.md
CLAUDE.local.md
.mcp.json
+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.
+1 -1
View File
@@ -3,7 +3,7 @@ name = "IfcOpenShell"
version = "0.0.0"
dependencies = [
"black==26.1.0",
"ruff==0.15.0",
"ruff==0.15.1",
"poethepoet",
"gersemi==0.25.4",
]
+1 -1
View File
@@ -371,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
+2
View File
@@ -184,6 +184,8 @@ classes = [
ui.BIM_PT_tab_materials,
ui.BIM_PT_tab_styles,
ui.BIM_PT_tab_profiles,
# Civil infrastructure
ui.BIM_PT_tab_horizontal_alignment,
# Drawings and documents
ui.BIM_PT_tab_sheets,
ui.BIM_PT_tab_drawings,
-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:
@@ -1,5 +1,5 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2020, 2021 Dion Moult <dion@thinkmoult.com>
# Copyright (C) 2020, 2021 Dion Moult <dion@thinkmoult.com>, 2026 Michael Yoder <myoder@desertspringscivil.com>
#
# This file is part of Bonsai.
#
@@ -17,11 +17,37 @@
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import bpy
from bpy.app.handlers import persistent
from . import ui, prop, operator, decorator
# from . import ui, prop, operator
from . import operator
classes = (operator.ImportAlignmentCSV,)
classes = (
# Property groups (must be registered before classes that use them)
prop.AlignmentPI,
prop.AlignmentDisplayRow,
prop.CivilAlignmentProperties,
# UILists
ui.CIVIL_UL_alignment_pis,
operator.ImportAlignmentCSV,
# Operators - PI Management
operator.CIVIL_OT_add_pi,
operator.CIVIL_OT_remove_pi,
operator.CIVIL_OT_pick_pi_from_viewport,
operator.CIVIL_OT_recalculate_pis,
operator.CIVIL_OT_clear_pis,
# Operators - Creation
operator.CIVIL_OT_create_alignment_by_pi,
operator.CIVIL_OT_import_alignment_csv,
# Operators - Stationing
operator.CIVIL_OT_add_stationing_referent,
operator.CIVIL_OT_name_segments,
# Operators - PI Edit Mode
operator.CIVIL_OT_enter_pi_edit_mode,
# UI Panels (appear in Properties sidebar under CIVIL tab)
ui.CIVIL_PT_alignment_creation,
ui.CIVIL_PT_pi_editor,
ui.CIVIL_PT_alignment_stationing,
)
def menu_func_import(self, context):
@@ -29,8 +55,10 @@ def menu_func_import(self, context):
def register():
bpy.types.Scene.CivilAlignmentProperties = bpy.props.PointerProperty(type=prop.CivilAlignmentProperties)
bpy.types.TOPBAR_MT_file_import.append(menu_func_import)
def unregister():
bpy.types.TOPBAR_MT_file_import.remove(menu_func_import)
del bpy.types.Scene.CivilAlignmentProperties
@@ -0,0 +1,66 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2025, 2026 Michael Yoder <myoder@desertspringscivil.com>
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
"""Data caching layer for the alignment module
This module provides cached access to alignment data for UI display,
following Bonsai's data loading pattern.
"""
import bonsai.tool as tool
class AlignmentData:
"""Cached alignment data for UI display"""
data = {}
is_loaded = False
@classmethod
def load(cls):
"""Load alignment data from IFC file"""
cls.data = {
"alignments": [],
"active_alignment": None,
"segments": [],
}
ifc = tool.Ifc.get()
if ifc is None:
cls.is_loaded = True
return
# Load all alignments
alignments = ifc.by_type("IfcAlignment")
cls.data["alignments"] = [
{
"id": a.id(),
"name": a.Name or f"Alignment {a.id()}",
"global_id": a.GlobalId,
}
for a in alignments
]
cls.is_loaded = True
@classmethod
def refresh(cls):
"""Force refresh of alignment data"""
cls.is_loaded = False
cls.load()
@@ -0,0 +1,193 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2025, 2026 Michael Yoder <myoder@desertspringscivil.com>
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
"""Alignment module decorators for GPU visualization.
This module contains decorators for rendering visual feedback during
alignment-related operations, such as PI editing.
"""
import bpy
import blf
import gpu
import bonsai.tool as tool
from bpy.types import SpaceView3D
from gpu_extras.batch import batch_for_shader
class PIEditDecorator:
"""Decorator for visualizing PI edit mode.
This decorator provides visual feedback while the user is editing
PI (Point of Intersection) positions with standard Blender transform tools:
- Yellow lines connecting PI empties (tangent preview)
- HUD text showing instructions
The decorator reads positions directly from the PI empty objects,
which are updated by Blender's transform operators (G key).
"""
# Class-level state (cleared on uninstall)
is_installed = False
handlers = []
# References to PI empty objects
pi_empties = []
# Colors
COLOR_TANGENT_LINE = (1.0, 0.9, 0.2, 1.0) # Yellow for tangent lines
COLOR_HUD_TEXT = (1.0, 1.0, 1.0, 1.0) # White for HUD text
COLOR_EDIT_MODE_BG = (0.2, 0.4, 0.8, 0.8) # Blue tint for edit mode indicator
# Drawing parameters
LINE_WIDTH = 2.5
@classmethod
def install(cls, context, pi_empties):
"""Install decorator handlers for PI edit mode visualization.
Args:
context: Blender context
pi_empties: List of PI EMPTY objects to visualize
"""
if cls.is_installed:
cls.uninstall()
cls.pi_empties = pi_empties
handler = cls()
# POST_VIEW for 3D world-space drawing (tangent lines in 3D)
cls.handlers.append(
SpaceView3D.draw_handler_add(handler.draw_tangent_lines_3d, (context,), "WINDOW", "POST_VIEW")
)
# POST_PIXEL for 2D screen-space drawing (HUD)
cls.handlers.append(
SpaceView3D.draw_handler_add(handler.draw_hud, (context,), "WINDOW", "POST_PIXEL")
)
cls.is_installed = True
@classmethod
def uninstall(cls):
"""Remove all handlers and clear state."""
for handler in cls.handlers:
try:
SpaceView3D.draw_handler_remove(handler, "WINDOW")
except ValueError:
pass
cls.handlers = []
cls.is_installed = False
cls.pi_empties = []
@classmethod
def update_positions(cls, pi_empties):
"""Update the list of PI empties (called when positions change).
Args:
pi_empties: Updated list of PI EMPTY objects
"""
cls.pi_empties = pi_empties
def draw_batch_3d(self, shader_type, content_pos, color, indices=None):
"""Draw a batch of 3D primitives using GPU shader.
Args:
shader_type: Type of primitive ("LINES", "POINTS", etc.)
content_pos: List of 3D vertex positions
color: RGBA color tuple
indices: Optional list of index pairs for lines
"""
if not tool.Blender.validate_shader_batch_data(content_pos, indices):
return
shader = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR")
shader.bind()
# Get viewport size from active region
region = bpy.context.region
shader.uniform_float("viewportSize", (region.width, region.height))
shader.uniform_float("lineWidth", self.LINE_WIDTH)
batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices)
shader.uniform_float("color", color)
batch.draw(shader)
def draw_tangent_lines_3d(self, context):
"""Draw yellow tangent lines connecting PI empties in 3D space."""
if not self.pi_empties or len(self.pi_empties) < 2:
return
# Collect 3D positions from empties
positions = []
for empty in self.pi_empties:
if empty and empty.name in bpy.data.objects:
positions.append(tuple(empty.location))
if len(positions) < 2:
return
# Setup blending for line drawing
gpu.state.blend_set("ALPHA")
gpu.state.depth_test_set("LESS_EQUAL")
gpu.state.depth_mask_set(False)
# Build edges list
edges = [[i, i + 1] for i in range(len(positions) - 1)]
# Draw lines
self.draw_batch_3d("LINES", positions, self.COLOR_TANGENT_LINE, edges)
# Restore state
gpu.state.blend_set("NONE")
gpu.state.depth_test_set("NONE")
gpu.state.depth_mask_set(True)
def draw_hud(self, context):
"""Draw HUD text with edit mode instructions."""
region = context.region
if not region:
return
font_id = 0
font_size = tool.Blender.scale_font_size(14)
blf.size(font_id, font_size)
blf.enable(font_id, blf.SHADOW)
blf.shadow(font_id, 6, 0, 0, 0, 1) # Black shadow for readability
blf.color(font_id, *self.COLOR_HUD_TEXT)
# Position in top-left of viewport
margin = 20
line_height = 22
y_pos = region.height - margin
# Count valid empties
valid_count = sum(1 for e in self.pi_empties if e and e.name in bpy.data.objects)
# Instructions
instructions = [
"PI Edit Mode",
f"PIs: {valid_count}",
"",
"G: Move selected PI",
"ENTER: Apply changes",
"ESC: Cancel",
]
for i, line in enumerate(instructions):
blf.position(font_id, margin, y_pos - (i * line_height), 0)
blf.draw(font_id, line)
blf.disable(font_id, blf.SHADOW)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,215 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2025, 2026 Michael Yoder <myoder@desertspringscivil.com>
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
"""Property groups for the alignment module"""
import bpy
from bpy.types import PropertyGroup
from bpy.props import (
StringProperty,
FloatProperty,
IntProperty,
BoolProperty,
CollectionProperty,
EnumProperty,
)
def _on_radius_update(self, context):
"""Callback when radius property changes.
This dynamically imports the operator module to call on_radius_changed,
avoiding circular imports since prop.py is imported before operator.py.
"""
from . import operator as ops
ops.on_radius_changed(self, context)
class AlignmentPI(PropertyGroup):
"""Property group for a single PI (Point of Intersection)
In the PI method, alignments are defined by:
- Endpoint PIs: Start (POB) and End (POE) points
- Interior PIs: Points where tangents intersect, optionally with curves
"""
# Coordinates stored as global easting/northing (map coordinates).
# Coordinate flow: Blender coords -> xyz2enh() -> global E/N (stored here)
# global E/N -> enh2xyz(to_blender=False) -> local IFC coords (for IfcOpenShell API)
e: StringProperty(name="E", description="Easting (global map coordinates)", default="0.0")
n: StringProperty(name="N", description="Northing (global map coordinates)", default="0.0")
# PI Type
pi_type: EnumProperty(
name="Type",
description="Type of PI point",
items=[
("ENDPOINT", "Endpoint", "Start or end point (no curve)"),
("TANGENT", "Tangent", "Pass-through point (no curve)"),
("CURVE", "Curve", "Point of intersection with curve"),
],
default="TANGENT",
)
# Curve parameters (only used when pi_type == "CURVE")
radius: FloatProperty(
name="Radius",
description="Curve radius (0 = no curve, sharp angle)",
default=0.0,
min=0.0,
precision=3,
unit="LENGTH",
update=_on_radius_update,
)
# Computed/display values (updated by recalculate operator)
length_to_next: FloatProperty(
name="Length",
description="Length of tangent to next PI",
default=0.0,
precision=3,
unit="LENGTH",
)
direction_to_next: FloatProperty(
name="Direction",
description="Bearing/direction to next PI (degrees)",
default=0.0,
precision=4,
subtype="ANGLE",
)
# Station at this PI (computed)
station: FloatProperty(
name="Station",
description="Station value at this PI",
default=0.0,
precision=2,
)
class AlignmentDisplayRow(PropertyGroup):
"""Property group for interleaved point/segment display in the table.
This creates the Civil 3D-style view where points and segments
are shown on separate rows:
Point 1 (End)
Segment 1 (Tan)
Point 2 (Tan)
Segment 2 (Tan)
...
"""
# Row type discriminator
row_type: EnumProperty(
name="Row Type",
items=[
("POINT", "Point", "A PI point row"),
("SEGMENT", "Segment", "A segment row between points"),
],
default="POINT",
)
# Segment number (1, 2, 3...) - only for SEGMENT rows
segment_number: IntProperty(name="Segment #", default=0)
# Point index in the pis collection - for both types
# For POINT rows: the PI index
# For SEGMENT rows: the starting PI index of this segment
pi_index: IntProperty(name="PI Index", default=0)
# Display type string (End, Tan, Curve for points; Tan, Curve for segments)
display_type: StringProperty(name="Type", default="")
# Point coordinates (only for POINT rows)
e: StringProperty(name="E", default="0.0")
n: StringProperty(name="N", default="0.0")
# Segment properties (only for SEGMENT rows)
length: FloatProperty(name="Length", default=0.0, precision=2, unit="LENGTH")
radius: FloatProperty(name="Radius", default=0.0, precision=2, unit="LENGTH")
arc_length: FloatProperty(name="Arc Length", default=0.0, precision=2, unit="LENGTH")
class CivilAlignmentProperties(PropertyGroup):
"""Properties for the alignment module"""
# Active alignment selection
active_alignment_id: IntProperty(
name="Active Alignment ID",
description="IFC entity ID of the active alignment",
default=0,
)
active_alignment_name: StringProperty(
name="Active Alignment",
description="Name of the currently active alignment",
default="",
)
# New alignment creation properties
new_alignment_name: StringProperty(
name="Name",
description="Name for new alignment",
default="Alignment 1",
)
start_station: FloatProperty(
name="Start Station",
description="Starting station value (e.g., 10000 for 100+00)",
default=10000.0,
min=0.0,
)
# PI collection for PI method creation
pis: CollectionProperty(type=AlignmentPI)
active_pi_index: IntProperty(name="Active PI", default=0)
# Combined point/segment display rows (for Civil 3D-style table)
display_rows: CollectionProperty(type=AlignmentDisplayRow)
active_display_row_index: IntProperty(name="Active Display Row", default=0)
# PI Edit Mode state (for moving PIs with G key)
is_pi_edit_mode: BoolProperty(
name="PI Edit Mode Active",
description="Whether PI edit mode is currently active",
default=False,
)
pi_edit_alignment_id: IntProperty(
name="Editing Alignment ID",
description="IFC ID of alignment being edited in PI edit mode",
default=0,
)
# Display options
show_station_labels: BoolProperty(
name="Show Station Labels",
description="Show station labels along alignment",
default=True,
)
station_interval: FloatProperty(
name="Station Interval",
description="Interval between station markers",
default=100.0,
min=1.0,
unit="LENGTH",
)
@@ -0,0 +1,269 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2025, 2026 Michael Yoder <myoder@desertspringscivil.com>
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
"""UI panels for the alignment module
All panels appear in the Properties sidebar under the CIVIL tab,
nested under BIM_PT_tab_horizontal_alignment.
"""
import bpy
import bonsai.tool as tool
from bpy.types import Panel, UIList
def is_ifc4x3():
"""Check if the current IFC file is IFC4X3 schema"""
return tool.Ifc.get_schema() == "IFC4X3"
# =============================================================================
# UILists
# =============================================================================
class CIVIL_UL_alignment_pis(UIList):
"""UIList for displaying interleaved points and segments (Civil 3D style)
Row types:
- POINT rows: End (endpoint), Mid (interior PI without curve)
- SEGMENT rows: Tan (tangent line), Curve (circular arc)
When a Mid point has radius > 0, it becomes a Curve segment row.
"""
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
if self.layout_type in {"DEFAULT", "COMPACT"}:
row = layout.row(align=True)
if item.row_type == "POINT":
# Point row: No., Type, X, Y, Length, Radius
row.label(text="") # No segment number for points
# Type with point/dot icon
# "End" = endpoint (POB/POE), "Mid" = interior PI point
row.label(text=item.display_type, icon="DOT")
# X, Y coordinates - get actual PI for editing
pi = data.pis[item.pi_index] if item.pi_index < len(data.pis) else None
if pi:
sub = row.row(align=True)
sub.prop(pi, "e", text="")
sub.prop(pi, "n", text="")
else:
row.label(text=f"{float(item.e):.2f}")
row.label(text=f"{float(item.n):.2f}")
# Length column - empty for point rows
row.label(text="")
# Radius column - editable for Mid points (where curves can be added)
if item.display_type == "Mid" and pi:
row.prop(pi, "radius", text="")
else:
row.label(text="")
elif item.row_type == "SEGMENT":
if item.display_type == "Curve":
# Curve segment row: No., Type (arc icon), X, Y, Arc Length, Radius
row.label(text=f"{item.segment_number}")
row.label(text="Curve", icon="SPHERECURVE")
# Show PI coordinates on curve row
row.label(text=f"{float(item.e):.2f}")
row.label(text=f"{float(item.n):.2f}")
# Arc length
row.label(text=f"{item.arc_length:.2f}")
# Radius - editable so user can modify or delete curve (set to 0)
pi = data.pis[item.pi_index] if item.pi_index < len(data.pis) else None
if pi:
row.prop(pi, "radius", text="")
else:
row.label(text=f"{item.radius:.2f}")
else:
# Tangent segment row: No., Type (line icon), -, -, Length, -
row.label(text=f"{item.segment_number}")
row.label(text="Tan", icon="IPO_LINEAR")
# No X, Y for tangent segments
row.label(text="")
row.label(text="")
# Length
row.label(text=f"{item.length:.2f}")
# No radius for tangent segments
row.label(text="-")
elif self.layout_type == "GRID":
layout.alignment = "CENTER"
layout.label(text="", icon="DECORATE")
# =============================================================================
# Creation Sub-Panel
# =============================================================================
class CIVIL_PT_alignment_creation(Panel):
"""Sub-panel for alignment creation tools"""
bl_label = "Creation"
bl_idname = "CIVIL_PT_alignment_creation"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "scene"
bl_parent_id = "BIM_PT_tab_horizontal_alignment"
bl_options = {"DEFAULT_CLOSED"}
@classmethod
def poll(cls, context):
return tool.Blender.should_show_panel(context, "CIVIL", cls.bl_idname) and is_ifc4x3()
def draw(self, context):
layout = self.layout
props = context.scene.CivilAlignmentProperties
# New alignment properties
box = layout.box()
box.label(text="New Alignment:", icon="ADD")
box.prop(props, "new_alignment_name")
box.prop(props, "start_station")
# Creation operators
col = layout.column(align=True)
col.operator("civil.create_alignment_by_pi", icon="CURVE_DATA")
# =============================================================================
# PI Editor Sub-Panel
# =============================================================================
class CIVIL_PT_pi_editor(Panel):
"""Sub-panel for PI point table editor (Civil 3D style grid view)"""
bl_label = "PI Editor"
bl_idname = "CIVIL_PT_pi_editor"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "scene"
bl_parent_id = "BIM_PT_tab_horizontal_alignment"
bl_options = set() # Open by default
@classmethod
def poll(cls, context):
return tool.Blender.should_show_panel(context, "CIVIL", cls.bl_idname) and is_ifc4x3()
def draw(self, context):
layout = self.layout
props = context.scene.CivilAlignmentProperties
# PI Edit Mode indicator
if props.is_pi_edit_mode:
box = layout.box()
box.alert = True
box.label(text="PI Edit Mode Active", icon="EDITMODE_HLT")
col = box.column(align=True)
col.label(text="Move PIs with G key")
col.label(text="Press Enter to apply")
col.label(text="Press Escape to cancel")
layout.separator()
return # Don't show normal UI while in edit mode
# Edit existing alignment button
if props.active_alignment_id != 0:
box = layout.box()
box.label(text="Edit Alignment:", icon="EDITMODE_HLT")
box.operator("civil.enter_pi_edit_mode", icon="PIVOT_CURSOR", text="Edit PIs (G key)")
layout.separator()
# Header row with column labels
header = layout.row(align=True)
header.label(text="No.")
header.label(text="Type")
header.label(text="E")
header.label(text="N")
header.label(text="Length")
header.label(text="Radius")
# Combined point/segment list (interleaved view)
row = layout.row()
row.template_list(
"CIVIL_UL_alignment_pis",
"",
props,
"display_rows",
props,
"active_display_row_index",
rows=8,
)
# Side buttons for list management
col = row.column(align=True)
col.operator("civil.add_pi", icon="ADD", text="")
col.operator("civil.remove_pi", icon="REMOVE", text="")
col.separator()
col.operator("civil.pick_pi_from_viewport", icon="EYEDROPPER", text="")
# Bottom actions
layout.separator()
row = layout.row(align=True)
row.operator("civil.recalculate_pis", icon="FILE_REFRESH", text="Recalculate")
row.operator("civil.clear_pis", icon="TRASH", text="Clear All")
# =============================================================================
# Stationing Sub-Panel
# =============================================================================
class CIVIL_PT_alignment_stationing(Panel):
"""Sub-panel for stationing and referents"""
bl_label = "Stationing"
bl_idname = "CIVIL_PT_alignment_stationing"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "scene"
bl_parent_id = "BIM_PT_tab_horizontal_alignment"
bl_options = {"DEFAULT_CLOSED"}
@classmethod
def poll(cls, context):
return tool.Blender.should_show_panel(context, "CIVIL", cls.bl_idname) and is_ifc4x3()
def draw(self, context):
layout = self.layout
props = context.scene.CivilAlignmentProperties
# Station display options
box = layout.box()
box.label(text="Display:", icon="HIDE_OFF")
box.prop(props, "show_station_labels")
box.prop(props, "station_interval")
layout.separator()
# Stationing operators
col = layout.column(align=True)
col.operator("civil.add_stationing_referent", icon="EMPTY_AXIS")
col.operator("civil.name_segments", icon="FONT_DATA")
+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,
+29 -102
View File
@@ -928,7 +928,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 +1374,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
@@ -2896,12 +2896,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 +3094,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 +3162,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
@@ -3817,91 +3815,14 @@ class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator, ImportHelper):
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",
default=1.0,
min=0.001,
soft_min=0.01,
precision=3,
)
y_length: bpy.props.FloatProperty(
name="Y Length",
description="Height of the reference image in project units",
default=1.0,
min=0.001,
soft_min=0.01,
precision=3,
)
show_dimensions_dialog: bpy.props.BoolProperty(default=False, options={"HIDDEN", "SKIP_SAVE"})
size: bpy.props.FloatProperty(name="Size", description="Size of the reference image", default=1.0, unit="LENGTH")
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")
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)
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)
image_width_px = image.size[0]
image_height_px = image.size[1]
aspect_ratio = image_width_px / image_height_px
if aspect_ratio >= 1.0:
self.x_length = 1.0
self.y_length = 1.0 / aspect_ratio
else:
self.x_length = aspect_ratio
self.y_length = 1.0
bpy.data.images.remove(image)
except Exception as e:
self.report({"ERROR"}, f"Failed to load image: {str(e)}")
return {"CANCELLED"}
self.show_dimensions_dialog = True
return context.window_manager.invoke_props_dialog(self)
return self._execute(context)
if Path(tool.Ifc.get_path()).is_file():
self.layout.prop(self, "use_relative_path")
self.layout.prop(self, "override_existing_image")
self.layout.prop(self, "use_existing_object_by_name")
self.layout.prop(self, "size")
def _execute(self, context):
space = tool.Blender.get_view3d_space()
@@ -3922,11 +3843,19 @@ class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator, ImportHelper):
params = {"check_existing": False}
image = load_image(abs_path.name, str(abs_path.parent), **params)
aspect_ratio = image.size[0] / image.size[1]
if aspect_ratio >= 1.0: # Landscape
x_length = self.size
y_length = self.size / aspect_ratio
else:
x_length = self.size / aspect_ratio
y_length = self.size
def bm_add_image_plane(mesh):
bm = tool.Blender.get_bmesh_for_mesh(mesh, clean=True)
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))
plane_scale = Vector((x_length / 2.0, y_length / 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)
@@ -4030,8 +3959,6 @@ class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator, ImportHelper):
tool.Style.reload_material_from_ifc(material)
tool.Geometry.record_object_materials(obj)
return {"FINISHED"}
class ConvertSVGToDXF(bpy.types.Operator):
bl_idname = "bim.convert_svg_to_dxf"
+1 -1
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.")
@@ -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
@@ -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()
+2 -2
View File
@@ -916,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()
@@ -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,
+224 -120
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":
@@ -1652,7 +1678,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 +1689,95 @@ 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
# So let's calculate the transformation
transformed_global_matrix = local_matrix @ np.array(new_obj_matrix)
transformation = transformed_global_matrix @ np.linalg.inv(global_matrix)
if np.allclose(transformation, np.eye(4)):
link.has_transformation = True
transformation = ",".join(map(str, np.eye(4).reshape(-1)))
else:
link.has_transformation = False
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 +1970,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 +2008,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,
+42 -4
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):
@@ -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_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")
if self.props.active_link.is_loaded:
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):
+38 -1
View File
@@ -82,6 +82,8 @@ class IfcClassData:
feature_elements = ifcopenshell.util.schema.get_subtypes(entity)
for feature_element in feature_elements:
names.remove(feature_element.name())
if ifc_product == "IfcAlignment":
names.extend(("IfcAlignmentHorizontal", "IfcAlignmentVertical", "IfcAlignmentCant"))
version = tool.Ifc.get_schema()
return [(c, c, (get_entity_doc(version, c) or {}).get("description", "")) for c in sorted(names)]
@@ -136,7 +138,42 @@ class IfcClassData:
("EMPTY", "No Geometry", "Start with an empty object"),
]
if ifc_class in ("IfcWindowType", "IfcWindowStyle", "IfcWindow"):
if ifc_class in ("IfcAlignmentHorizontal", "IfcAlignmentVertical", "IfcAlignmentCant"):
return templates # layout containers have no direct representation per IFC 4.3 spec
if ifc_class == "IfcAlignment":
templates.extend(
[
None,
(
"ALIGNMENT_HORIZONTAL",
"Horizontal Alignment (2D)",
"2D horizontal-only alignment (IfcCompositeCurve)",
),
(
"ALIGNMENT_GRADIENT",
"Gradient Curve (3D)",
"3D alignment with horizontal and vertical layouts (IfcGradientCurve)",
),
(
"ALIGNMENT_CANT",
"Segmented Reference Curve",
"3D alignment with horizontal, vertical, and cant layouts (IfcSegmentedReferenceCurve)",
),
(
"ALIGNMENT_POLYLINE_3D",
"3D Survey Polyline",
"3D alignment from survey data (IfcPolyline with 3D points)",
),
(
"ALIGNMENT_POLYLINE_2D",
"2D Planning Polyline",
"2D alignment for early planning phases (IfcPolyline with 2D points)",
),
]
)
return templates
elif 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")])
+110 -26
View File
@@ -531,39 +531,96 @@ class AddElement(bpy.types.Operator, tool.Ifc.Operator):
if props.ifc_product == "IfcFeatureElement" and not props.featured_obj:
return self.report({"WARNING"}, "A featured element must be nominated.")
if "Alignment" in props.ifc_product and props.ifc_product != "IfcAlignment" and not props.featured_obj:
return self.report({"WARNING"}, "A parent alignment element must be nominated.")
ifc_context = None
if get_enum_items(props, "contexts", context):
ifc_context = int(props.contexts or "0") or None
if ifc_context:
ifc_context = tool.Ifc.get().by_id(ifc_context)
if representation_template in (
"EMPTY",
"LAYERSET_AXIS2",
"LAYERSET_AXIS3",
"PROFILESET",
) or representation_template.startswith("FLOW_SEGMENT_"):
mesh = None
elif representation_template == "OBJ" and not props.representation_obj:
mesh = None
alignment_templates = {
"ALIGNMENT_HORIZONTAL",
"ALIGNMENT_GRADIENT",
"ALIGNMENT_CANT",
"ALIGNMENT_POLYLINE_3D",
"ALIGNMENT_POLYLINE_2D",
}
if representation_template in alignment_templates:
import ifcopenshell.api.alignment as align_api
from ifcopenshell.api.alignment._create_polyline_representation import (
_create_polyline_representation,
)
ifc_file = tool.Ifc.get()
alignment_name = props.name or "Unnamed"
if representation_template == "ALIGNMENT_HORIZONTAL":
element = align_api.create(ifc_file, alignment_name, include_vertical=False, include_cant=False)
elif representation_template == "ALIGNMENT_GRADIENT":
element = align_api.create(ifc_file, alignment_name, include_vertical=True, include_cant=False)
elif representation_template == "ALIGNMENT_CANT":
element = align_api.create(ifc_file, alignment_name, include_vertical=True, include_cant=True)
elif representation_template == "ALIGNMENT_POLYLINE_3D":
element = ifc_file.createIfcAlignment(GlobalId=ifcopenshell.guid.new(), Name=alignment_name)
pts = [
ifc_file.createIfcCartesianPoint(Coordinates=(0.0, 0.0, 0.0)),
ifc_file.createIfcCartesianPoint(Coordinates=(1.0, 0.0, 0.0)),
]
_create_polyline_representation(ifc_file, element, pts)
project = ifc_file.by_type("IfcProject")
if project:
ifcopenshell.api.aggregate.assign_object(ifc_file, products=[element], relating_object=project[0])
elif representation_template == "ALIGNMENT_POLYLINE_2D":
element = ifc_file.createIfcAlignment(GlobalId=ifcopenshell.guid.new(), Name=alignment_name)
pts = [
ifc_file.createIfcCartesianPoint(Coordinates=(0.0, 0.0)),
ifc_file.createIfcCartesianPoint(Coordinates=(1.0, 0.0)),
]
_create_polyline_representation(ifc_file, element, pts)
project = ifc_file.by_type("IfcProject")
if project:
ifcopenshell.api.aggregate.assign_object(ifc_file, products=[element], relating_object=project[0])
element.Description = props.description or None
obj = bpy.data.objects.new(props.ifc_class[3:], None)
obj.name = alignment_name
obj.location = bpy.context.scene.cursor.location
tool.Root.set_object_name(obj, element)
tool.Ifc.link(element, obj)
tool.Collector.assign(obj)
else:
mesh = bpy.data.meshes.new("Mesh")
if representation_template in (
"EMPTY",
"LAYERSET_AXIS2",
"LAYERSET_AXIS3",
"PROFILESET",
) or representation_template.startswith("FLOW_SEGMENT_"):
mesh = None
elif representation_template == "OBJ" and not props.representation_obj:
mesh = None
else:
mesh = bpy.data.meshes.new("Mesh")
obj = bpy.data.objects.new(props.ifc_class[3:], mesh)
obj.name = props.name or "Unnamed"
obj.location = bpy.context.scene.cursor.location
element = core.assign_class(
tool.Ifc,
tool.Collector,
tool.Root,
obj=obj,
ifc_class=props.ifc_class,
predefined_type=predefined_type,
should_add_representation=False,
)
element.Description = props.description or None
obj = bpy.data.objects.new(props.ifc_class[3:], mesh)
obj.name = props.name or "Unnamed"
obj.location = bpy.context.scene.cursor.location
element = core.assign_class(
tool.Ifc,
tool.Collector,
tool.Root,
obj=obj,
ifc_class=props.ifc_class,
predefined_type=predefined_type,
should_add_representation=False,
)
element.Description = props.description or None
if representation_template == "EMTPY" or not ifc_context:
if representation_template == "EMPTY" or representation_template in alignment_templates:
pass
elif not ifc_context:
pass
elif representation_template == "OBJ" and props.representation_obj:
obj.matrix_world = props.representation_obj.matrix_world.copy()
@@ -821,9 +878,29 @@ class AddElement(bpy.types.Operator, tool.Ifc.Operator):
tool.Model.purge_scene_openings()
tool.Collector.assign(obj)
if props.featured_obj:
alignment = tool.Ifc.get_entity(props.featured_obj)
if props.ifc_class == "IfcAlignment":
ifcopenshell.api.aggregate.assign_object(tool.Ifc.get(), products=[element], relating_object=alignment)
elif props.ifc_class in ("IfcAlignmentHorizontal", "IfcAlignmentVertical", "IfcAlignmentCant"):
ifcopenshell.api.nest.assign_object(
tool.Ifc.get(), related_objects=[element], relating_object=alignment
)
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj)
tool.Blender.set_active_object(obj)
# After alignment creation, auto-invoke PI picker for layout-based templates
if (
props.ifc_class == "IfcAlignment"
and representation_template.startswith("ALIGNMENT_")
and representation_template not in ("ALIGNMENT_POLYLINE_2D", "ALIGNMENT_POLYLINE_3D")
):
civil_props = context.scene.CivilAlignmentProperties
civil_props.active_alignment_id = element.id()
civil_props.active_alignment_name = element.Name or "Unnamed"
bpy.ops.civil.pick_pi_from_viewport("INVOKE_DEFAULT")
def draw(self, context):
props = tool.Root.get_root_props()
self.layout.use_property_split = True
@@ -842,7 +919,7 @@ class AddElement(bpy.types.Operator, tool.Ifc.Operator):
if props.ifc_predefined_type == "USERDEFINED":
row = self.layout.row()
row.prop(props, "ifc_userdefined_type")
if props.ifc_product == "IfcFeatureElement":
if props.ifc_product in ("IfcFeatureElement", "IfcAlignment"):
row = self.layout.row()
row.prop(props, "featured_obj", text="Featured Object")
prop_with_search(self.layout, props, "representation_template", text="Representation", should_click_ok=True)
@@ -852,5 +929,12 @@ class AddElement(bpy.types.Operator, tool.Ifc.Operator):
elif props.representation_template == "PROFILESET":
row = self.layout.row()
prop_with_search(self.layout, props, "profile", text="Profile", should_click_ok=True)
if props.representation_template != "EMPTY":
alignment_templates = {
"ALIGNMENT_HORIZONTAL",
"ALIGNMENT_GRADIENT",
"ALIGNMENT_CANT",
"ALIGNMENT_POLYLINE_3D",
"ALIGNMENT_POLYLINE_2D",
}
if props.representation_template != "EMPTY" and props.representation_template not in alignment_templates:
prop_with_search(self.layout, props, "contexts", should_click_ok=True)
+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}')
"""
+9 -8
View File
@@ -498,15 +498,16 @@ def get_tab(
("PROJECT", "Project Overview", "", bonsai.bim.icons[icon_key].icon_id, 0),
("OBJECT", "Object Information", "", "FILE_3D", 1),
("GEOMETRY", "Geometry and Materials", "", "MATERIAL", 2),
("DRAWINGS", "Drawings and Documents", "", "DOCUMENTS", 3),
("SERVICES", "Services and Systems", "", "NETWORK_DRIVE", 4),
("STRUCTURE", "Structural Analysis", "", "EDITMODE_HLT", 5),
("SCHEDULING", "Costing and Scheduling", "", "NLA", 6),
("FM", "Facility Management", "", "PACKAGE", 7),
("QUALITY", "Quality and Coordination", "", "COMMUNITY", 8),
("BOOKMARK", "Bookmark", "", "SOLO_ON", 9),
("CIVIL", "Civil Infrastructure", "", "CURVE_DATA", 3),
("DRAWINGS", "Drawings and Documents", "", "DOCUMENTS", 4),
("SERVICES", "Services and Systems", "", "NETWORK_DRIVE", 5),
("STRUCTURE", "Structural Analysis", "", "EDITMODE_HLT", 6),
("SCHEDULING", "Costing and Scheduling", "", "NLA", 7),
("FM", "Facility Management", "", "PACKAGE", 8),
("QUALITY", "Quality and Coordination", "", "COMMUNITY", 9),
("BOOKMARK", "Bookmark", "", "SOLO_ON", 10),
None,
("BLENDER", "Blender Properties", "", "BLENDER", 10),
("BLENDER", "Blender Properties", "", "BLENDER", 11),
]
return get_tab.enum_items
+27 -2
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
)
@@ -972,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")
@@ -1678,6 +1683,25 @@ class BIM_PT_tab_profiles(Panel):
pass
# Civil Infrastructure tab panels
class BIM_PT_tab_horizontal_alignment(Panel):
bl_idname = "BIM_PT_tab_horizontal_alignment"
bl_label = "Horizontal Alignment"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "scene"
bl_order = 1
bim_tab_name = "CIVIL"
@classmethod
def poll(cls, context):
if tool.Blender.should_show_panel(context, cls.bim_tab_name, cls.bl_idname) and tool.Ifc.get():
return True
def draw(self, context):
pass
class BIM_PT_tab_sheets(Panel):
bl_idname = "BIM_PT_tab_sheets"
bl_label = "Sheets"
@@ -1864,6 +1888,7 @@ class UIData:
("PROJECT", bonsai.bim.icons[f"{color_mode}_ifc"].icon_id, True),
("OBJECT", "FILE_3D", is_ifc_project),
("GEOMETRY", "MATERIAL", is_ifc_project),
("CIVIL", "CURVE_DATA", is_ifc_project),
("DRAWINGS", "DOCUMENTS", is_ifc_project),
("SERVICES", "NETWORK_DRIVE", is_ifc_project),
("STRUCTURE", "EDITMODE_HLT", is_ifc_project),
+184
View File
@@ -0,0 +1,184 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2025, 2026 Michael Yoder <myoder@desertspringscivil.com>
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
"""Core alignment business logic - Orchestration only, NO bpy imports.
This module contains alignment-related business logic and workflow
orchestration. All calculations, algorithms, and IFC operations are
in the tool layer. Functions receive tool classes as parameters
following Bonsai's dependency injection pattern.
NOTE: Math, calculations, algorithms, and IFC API calls belong in
tool/alignment.py. This module only handles:
- Business rules and validation
- Workflow orchestration (calling tool methods in sequence)
- Decision-making about what should happen
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import ifcopenshell
from .. import tool
# =============================================================================
# PI Edit Mode Functions
# =============================================================================
def enter_pi_edit_mode(
ifc_tool: "type[tool.Ifc]",
alignment_tool: "type[tool.Alignment]",
alignment_id: int,
) -> list:
"""Enter PI edit mode for an alignment.
Business logic for entering PI edit mode:
1. Validates that the alignment exists
2. Validates that the alignment has a horizontal layout with real segments
3. Back-calculates PI positions from segments
4. Creates temporary EMPTY objects at each PI location
Args:
ifc_tool: The IFC tool class
alignment_tool: The Alignment tool class
alignment_id: The IFC ID of the alignment to edit
Returns:
List of created PI EMPTY objects
Raises:
ValueError: If alignment doesn't exist, has no horizontal layout,
or has no real segments
"""
# Validate alignment exists
ifc_file = ifc_tool.get()
if ifc_file is None:
raise ValueError("No IFC file loaded")
try:
alignment = ifc_file.by_id(alignment_id)
except RuntimeError:
raise ValueError(f"Alignment with ID {alignment_id} not found")
if not alignment.is_a("IfcAlignment"):
raise ValueError(f"Entity {alignment_id} is not an IfcAlignment")
# Validate alignment has horizontal layout (delegated to tool)
h_layout = alignment_tool.get_horizontal_layout(alignment)
if h_layout is None:
raise ValueError(f"Alignment '{alignment.Name}' has no horizontal layout")
# Validate layout has real segments (not just zero-length terminator)
if not alignment_tool.layout_has_real_segments(h_layout):
raise ValueError(f"Alignment '{alignment.Name}' has no editable segments")
# Back-calculate PI positions from segments
pis = alignment_tool.back_calculate_pis_from_alignment(alignment)
if len(pis) < 2:
raise ValueError(f"Alignment '{alignment.Name}' must have at least 2 PIs")
# Create temporary EMPTY objects at each PI location
empties = alignment_tool.create_pi_edit_empties(alignment, pis)
return empties
def exit_pi_edit_mode(
ifc_tool: "type[tool.Ifc]",
alignment_tool: "type[tool.Alignment]",
alignment_id: int,
apply: bool,
) -> bool:
"""Exit PI edit mode for an alignment.
Business logic for exiting PI edit mode:
1. If apply=True:
- Collect new PI positions from empties
- Validate the new configuration
- Update alignment segments in-place (preserves alignment ID)
2. Always:
- Remove temporary EMPTY objects
- Return success status
This function modifies the alignment segments in-place rather than
deleting and recreating the alignment. This preserves the alignment's
IFC entity ID, preventing stale reference issues.
Args:
ifc_tool: The IFC tool class
alignment_tool: The Alignment tool class
alignment_id: The IFC ID of the alignment being edited
apply: If True, update alignment with new PI positions
Returns:
True if successful
Raises:
ValueError: If alignment doesn't exist or update fails
"""
ifc_file = ifc_tool.get()
if ifc_file is None:
# No file loaded, just clean up empties
alignment_tool.remove_pi_edit_empties(alignment_id)
return True
# Get alignment
try:
alignment = ifc_file.by_id(alignment_id)
except RuntimeError:
# Alignment was deleted, just clean up empties
alignment_tool.remove_pi_edit_empties(alignment_id)
return True
if apply:
# Collect PI positions from empties
hpoints, radii = alignment_tool.collect_pis_from_empties(alignment_id)
if len(hpoints) < 2:
raise ValueError("At least 2 PIs are required")
# Get horizontal layout (delegated to tool)
h_layout = alignment_tool.get_horizontal_layout(alignment)
if h_layout is None:
raise ValueError("Alignment has no horizontal layout")
# Remove empties before modifying segments
alignment_tool.remove_pi_edit_empties(alignment_id)
# Remove Blender visualization for segments (not the whole hierarchy)
alignment_tool.remove_layout_segment_objects(h_layout)
# Clear existing IFC segments and add new ones (delegated to tool)
alignment_tool.clear_layout_segments(h_layout)
alignment_tool.layout_by_pi_method(h_layout, hpoints, radii)
# Refresh Blender visualization for new segments
layout_obj = ifc_tool.get_object(h_layout)
if layout_obj:
alignment_tool.create_objects_for_layout_segments(h_layout, layout_obj)
return True
else:
# Cancel - just remove empties without regenerating
alignment_tool.remove_pi_edit_empties(alignment_id)
return True
+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:
+1
View File
@@ -17,6 +17,7 @@
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
from bonsai.tool.aggregate import Aggregate
from bonsai.tool.alignment import Alignment
from bonsai.tool.attribute import Attribute
from bonsai.tool.bcf import Bcf
from bonsai.tool.blender import Blender
File diff suppressed because it is too large Load Diff
+10
View File
@@ -2165,3 +2165,13 @@ class Blender(bonsai.core.tool.Blender):
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"]
+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()
+4 -2
View File
@@ -857,9 +857,11 @@ class Drawing(bonsai.core.tool.Drawing):
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))
for literal in cls.get_text_literal(obj, return_list=True):
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)
rep.Items = [cls.add_literal(**a) for a in literal_attributes]
@classmethod
def add_literal(cls, **attributes: str) -> ifcopenshell.entity_instance:
+20 -2
View File
@@ -299,10 +299,10 @@ class Georeference(bonsai.core.tool.Georeference):
)
@classmethod
def enh2xyz(cls, coordinates: tuple[float, float, float]) -> tuple[float, float, float]:
def enh2xyz(cls, coordinates: tuple[float, float, float], to_blender: bool = True) -> tuple[float, float, float]:
coordinates = ifcopenshell.util.geolocation.auto_enh2xyz(tool.Ifc.get(), *coordinates)
props = cls.get_georeference_props()
if props.has_blender_offset:
if to_blender and props.has_blender_offset:
coordinates = ifcopenshell.util.geolocation.enh2xyz(
coordinates[0],
coordinates[1],
@@ -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())
+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(
+15 -14
View File
@@ -474,10 +474,10 @@ class Root(bonsai.core.tool.Root):
obj.name = obj.name.split("/", 1)[1]
@classmethod
def get_ifc_products(cls) -> tuple[str, ...]:
def get_ifc_products(cls) -> list[str]:
version = tool.Ifc.get_schema()
if version == "IFC2X3":
products = (
return [
"IfcElementType",
"IfcElement",
"IfcFeatureElement",
@@ -485,16 +485,17 @@ class Root(bonsai.core.tool.Root):
"IfcStructuralItem",
"IfcAnnotation",
"IfcRelSpaceBoundary",
)
else:
products = (
"IfcElementType",
"IfcElement",
"IfcFeatureElement",
"IfcSpatialElement",
"IfcSpatialElementType",
"IfcStructuralItem",
"IfcAnnotation",
"IfcRelSpaceBoundary",
)
]
products = [
"IfcElementType",
"IfcElement",
"IfcFeatureElement",
"IfcSpatialElement",
"IfcSpatialElementType",
"IfcStructuralItem",
"IfcAnnotation",
"IfcRelSpaceBoundary",
]
if version != "IFC4":
products.append("IfcAlignment")
return products
+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()
+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()"}
+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
+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}"
+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
]
+31 -69
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)
@@ -908,7 +938,7 @@ class TestAddReferenceImage(NewFile):
obj = bpy.data.objects["IfcAnnotation/image"]
assert obj is not None
assert tool.Cad.are_vectors_equal(obj.dimensions, Vector((3.53982, 2.0, 0.0)))
assert tool.Cad.are_vectors_equal(obj.dimensions, Vector((1.0, 0.565, 0.0)))
material = obj.active_material
assert material
@@ -928,71 +958,3 @@ class TestAddReferenceImage(NewFile):
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()
+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):
+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 {
+65 -35
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;
@@ -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;
@@ -51,6 +51,7 @@ from ._get_segment_start_point_label import register_referent_name_callback
from .add_stationing_referent import add_stationing_referent
from .add_vertical_layout import add_vertical_layout
from .add_zero_length_segment import add_zero_length_segment
from .clear_layout_segments import clear_layout_segments
from .create import create
from .create_as_offset_curve import create_as_offset_curve
from .create_as_polyline import create_as_polyline
@@ -62,6 +63,7 @@ from .create_segment_representations import create_segment_representations
from .distance_along_from_station import distance_along_from_station
from .get_alignment import get_alignment
from .get_alignment_layout_nest import get_alignment_layout_nest
from .get_alignment_layout import get_alignment_layout
from .get_alignment_layouts import get_alignment_layouts
from .get_alignment_segment_nest import get_alignment_segment_nest
from .get_alignment_start_station import get_alignment_start_station
@@ -86,13 +88,16 @@ from .layout_vertical_alignment_by_pi_method import (
layout_vertical_alignment_by_pi_method,
)
from .name_segments import name_segments
from .segment_vertices import segment_vertices
from .update_fallback_position import update_fallback_position
from ._create_geometric_representation import _create_geometric_representation
from .util import *
__all__ = [
"add_stationing_referent",
"add_vertical_layout",
"add_zero_length_segment",
"clear_layout_segments",
"create",
"create_as_offset_curve",
"create_as_polyline",
@@ -101,9 +106,11 @@ __all__ = [
"create_layout_segment",
"create_representation",
"create_segment_representations",
"_create_geometric_representation", # TODO I know I know
"distance_along_from_station",
"get_alignment",
"get_alignment_layout_nest",
"get_alignment_layout",
"get_alignment_layouts",
"get_alignment_segment_nest",
"get_alignment_start_station",
@@ -123,6 +130,7 @@ __all__ = [
"layout_horizontal_alignment_by_pi_method",
"layout_vertical_alignment_by_pi_method",
"name_segments",
"segment_vertices",
"register_referent_name_callback",
"update_fallback_position",
"get_mapped_segments",
@@ -109,6 +109,8 @@ def _add_segment_to_curve(file: ifcopenshell.file, segment: entity_instance, cur
at the end of the curve, but before the manditory zero length segment. The IfcCurveSegment.Transition for the segment
that preceeds the new segment is updated.
The geometric representation is also added to the IfcCurveSegment based on CT 4.1.7.1.1.4 Alignment Geometry - Segments
:param segment: The segment to be added to the curve
:param curve: The representation curve receiving the segment
:return: None
@@ -126,10 +128,6 @@ def _add_segment_to_curve(file: ifcopenshell.file, segment: entity_instance, cur
elif segment.DesignParameters.is_a("IfcAlignmentCantSegment") and not curve.is_a("IfcSegmentedReferenceCurve"):
raise TypeError(f"Expected to see IfcSegmentedReferenceCurve, instead received '{curve.is_a()}'.")
expected_type = "IfcCompositeCurve"
if not curve.is_a(expected_type):
raise TypeError(f"Expected to see {expected_type}, instead received {curve.is_a()}.")
# map the IfcAlignmentSegment to an IfcCurveSegment (or two in the case of helmert curves)
if segment.DesignParameters.is_a("IfcAlignmentHorizontalSegment"):
mapped_segments = _map_alignment_horizontal_segment(file, segment)
@@ -141,6 +139,23 @@ def _add_segment_to_curve(file: ifcopenshell.file, segment: entity_instance, cur
else:
assert False
items = []
for mapped_segment in mapped_segments:
if mapped_segment:
_add_curve_segment_to_composite_curve(file, mapped_segment, curve)
items.append(mapped_segment)
axis_geom_subcontext = ifcopenshell.api.alignment.get_axis_subcontext(file)
axis_representation = file.createIfcShapeRepresentation(
ContextOfItems=axis_geom_subcontext, RepresentationIdentifier="Axis", RepresentationType="Segment", Items=items
)
product = file.createIfcProductDefinitionShape(Representations=(axis_representation,))
layout = ifcopenshell.api.alignment.get_alignment_layout(segment)
alignment = ifcopenshell.api.alignment.get_alignment(layout)
if alignment != None:
segment.ObjectPlacement = alignment.ObjectPlacement
segment.Representation = product
@@ -36,7 +36,7 @@ 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):
@@ -0,0 +1,220 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import ifcopenshell
import ifcopenshell.api.alignment
import ifcopenshell.api.nest
import ifcopenshell.util.element
from ifcopenshell import entity_instance
def _is_zero_length_segment(segment: entity_instance) -> bool:
"""Check if segment is a zero-length terminator."""
dp = segment.DesignParameters
if dp.is_a("IfcAlignmentHorizontalSegment"):
return dp.SegmentLength == 0.0
elif dp.is_a("IfcAlignmentVerticalSegment"):
return dp.HorizontalLength == 0.0
elif dp.is_a("IfcAlignmentCantSegment"):
return dp.HorizontalLength == 0.0
return False
def clear_layout_segments(file: ifcopenshell.file, layout: entity_instance) -> None:
"""
Clear all segments from a layout while preserving the layout entity
and zero-length terminator.
This function removes:
- All real (non-zero-length) IfcAlignmentSegment entities from the layout
- Their associated IfcCurveSegment entities from the geometric representation
- Referents positioned on the removed segments
It preserves:
- The layout entity (IfcAlignmentHorizontal, IfcAlignmentVertical, or IfcAlignmentCant)
- The zero-length terminator segment (required by IFC spec)
- The alignment's main stationing referent
:param file: The IFC file
:param layout: An IfcAlignmentHorizontal, IfcAlignmentVertical, or IfcAlignmentCant
Example:
.. code:: python
alignment = model.by_type("IfcAlignment")[0]
h_layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
# Clear existing segments
ifcopenshell.api.alignment.clear_layout_segments(model, h_layout)
# Add new segments with updated PI positions
ifcopenshell.api.alignment.layout_horizontal_alignment_by_pi_method(
model, h_layout, new_hpoints, new_radii
)
"""
expected_types = ["IfcAlignmentHorizontal", "IfcAlignmentVertical", "IfcAlignmentCant"]
if layout.is_a() not in expected_types:
raise TypeError(f"Expected entity type to be one of {expected_types}, instead received {layout.is_a()}")
# Get the geometric curve for this layout
curve = ifcopenshell.api.alignment.get_layout_curve(layout)
# Get all segments from the layout
segments = ifcopenshell.api.alignment.get_layout_segments(layout)
if not segments:
return # Nothing to clear
# Identify segments to remove (all except zero-length terminator)
zero_length_segment = None
segments_to_remove = []
for segment in segments:
if _is_zero_length_segment(segment):
zero_length_segment = segment
else:
segments_to_remove.append(segment)
if not segments_to_remove:
return # Only zero-length terminator exists, nothing to clear
# Collect curve segments to remove before removing alignment segments
# (we need the nesting relationship to find mapped segments)
curve_segments_to_remove = []
for segment in segments_to_remove:
try:
mapped = ifcopenshell.api.alignment.get_mapped_segments(segment)
for cs in mapped:
if cs is not None:
curve_segments_to_remove.append(cs)
except (IndexError, AttributeError):
# Segment might not have curve representation yet
pass
# Remove referents positioned on segments being removed
for segment in segments_to_remove:
# Check for referents positioned relative to this segment
if hasattr(segment, "PositionedRelativeTo") and segment.PositionedRelativeTo:
for rel_pos in segment.PositionedRelativeTo:
referent = rel_pos.RelatingPositioningElement
if referent and referent.is_a("IfcReferent"):
# Remove the referent
ifcopenshell.api.run("root.remove_product", file, product=referent)
# Remove segments from nesting relationship
ifcopenshell.api.nest.unassign_object(file, related_objects=segments_to_remove)
# Remove segment entities
for segment in segments_to_remove:
# Remove design parameters
dp = segment.DesignParameters
if dp:
# Remove StartPoint if it exists
if hasattr(dp, "StartPoint") and dp.StartPoint:
file.remove(dp.StartPoint)
file.remove(dp)
# Remove the segment entity itself
file.remove(segment)
# Clear curve segments from the geometric representation
if curve and curve.Segments:
# Keep only the zero-length curve segment (last one)
if ifcopenshell.api.alignment.has_zero_length_segment(curve):
zero_length_curve_seg = curve.Segments[-1]
# Update curve to only contain zero-length segment
curve.Segments = (zero_length_curve_seg,)
else:
# No zero-length segment in curve, clear all
curve.Segments = ()
# Clean up removed curve segment entities
for cs in curve_segments_to_remove:
try:
# Remove the curve segment's parent curve and placement
if hasattr(cs, "ParentCurve") and cs.ParentCurve:
parent_curve = cs.ParentCurve
# Check if parent curve is used elsewhere
if file.get_total_inverses(parent_curve) <= 1:
# Remove placement if exists
if hasattr(parent_curve, "Position") and parent_curve.Position:
pos = parent_curve.Position
if hasattr(pos, "Location") and pos.Location:
if file.get_total_inverses(pos.Location) <= 1:
file.remove(pos.Location)
if hasattr(pos, "RefDirection") and pos.RefDirection:
if file.get_total_inverses(pos.RefDirection) <= 1:
file.remove(pos.RefDirection)
if file.get_total_inverses(pos) <= 1:
file.remove(pos)
file.remove(parent_curve)
# Remove placement on curve segment
if hasattr(cs, "Placement") and cs.Placement:
placement = cs.Placement
if hasattr(placement, "Location") and placement.Location:
if file.get_total_inverses(placement.Location) <= 1:
file.remove(placement.Location)
if hasattr(placement, "RefDirection") and placement.RefDirection:
if file.get_total_inverses(placement.RefDirection) <= 1:
file.remove(placement.RefDirection)
if file.get_total_inverses(placement) <= 1:
file.remove(placement)
# Remove the curve segment itself
file.remove(cs)
except Exception:
# Entity may have already been removed
pass
# Reset zero-length terminator to origin position
if zero_length_segment:
dp = zero_length_segment.DesignParameters
if dp.is_a("IfcAlignmentHorizontalSegment"):
# Reset StartPoint to origin
if dp.StartPoint:
dp.StartPoint.Coordinates = (0.0, 0.0)
dp.StartDirection = 0.0
elif dp.is_a("IfcAlignmentVerticalSegment"):
dp.StartDistAlong = 0.0
dp.StartHeight = 0.0
dp.StartGradient = 0.0
dp.EndGradient = 0.0
elif dp.is_a("IfcAlignmentCantSegment"):
dp.StartDistAlong = 0.0
dp.StartCantLeft = 0.0
dp.StartCantRight = 0.0
# Update the zero-length segment's referent
if hasattr(zero_length_segment, "PositionedRelativeTo") and zero_length_segment.PositionedRelativeTo:
for rel_pos in zero_length_segment.PositionedRelativeTo:
referent = rel_pos.RelatingPositioningElement
if referent and referent.is_a("IfcReferent"):
# Update referent position to origin
if hasattr(referent, "ObjectPlacement") and referent.ObjectPlacement:
placement = referent.ObjectPlacement
if hasattr(placement, "RelativePlacement") and placement.RelativePlacement:
rel_place = placement.RelativePlacement
if hasattr(rel_place, "Location") and rel_place.Location:
if hasattr(rel_place.Location, "DistanceAlong"):
rel_place.Location.DistanceAlong.wrappedValue = 0.0
if hasattr(placement, "CartesianPosition") and placement.CartesianPosition:
cart_pos = placement.CartesianPosition
if hasattr(cart_pos, "Location") and cart_pos.Location:
cart_pos.Location.Coordinates = (0.0, 0.0, 0.0)
@@ -80,14 +80,29 @@ def create(
if include_geometry:
_create_geometric_representation(file, alignment)
name = ifcopenshell.util.alignment.station_as_string(file, start_station)
referent = ifcopenshell.api.alignment.add_stationing_referent(
file, alignment, 0.0, start_station, name, alignment
)
name = ifcopenshell.util.alignment.station_as_string(file, start_station)
referent = ifcopenshell.api.alignment.add_stationing_referent(file, alignment, 0.0, start_station, name, alignment)
for layout in alignment_layouts:
_add_zero_length_segment(file, layout)
if include_geometry:
# add the representation to the zero length segment
curve = ifcopenshell.api.alignment.get_layout_curve(layout)
axis_geom_subcontext = ifcopenshell.api.alignment.get_axis_subcontext(file)
axis_representation = file.createIfcShapeRepresentation(
ContextOfItems=axis_geom_subcontext,
RepresentationIdentifier="Axis",
RepresentationType="Segment",
Items=(curve.Segments[-1],),
)
product = file.createIfcProductDefinitionShape(Representations=(axis_representation,))
layout.IsNestedBy[0].RelatedObjects[-1].ObjectPlacement = alignment.ObjectPlacement
layout.IsNestedBy[0].RelatedObjects[-1].Representation = product
# IFC 4.1.4.1.1 Alignment Aggregation To Project
project = file.by_type("IfcProject")[0]
if project:
@@ -30,91 +30,6 @@ from ifcopenshell.api.alignment._create_polyline_representation import (
)
def _create_layout(file: ifcopenshell.file, alignment: entity_instance, points: Sequence[entity_instance]):
"""
I don't believe it is required for polylines, but the validation serivce gives an error if the alignment doesn't have a layout
"""
include_vertical = False if points[0].Dim == 2 else True
alignment_layouts = []
alignment_layouts.append(file.createIfcAlignmentHorizontal(GlobalId=ifcopenshell.guid.new()))
if include_vertical:
alignment_layouts.append(file.createIfcAlignmentVertical(GlobalId=ifcopenshell.guid.new()))
ifcopenshell.api.nest.assign_object(file, related_objects=alignment_layouts, relating_object=alignment)
start_dist_along = 0.0
for p1, p2 in zip(points, points[1:]):
x1, y1, z1 = p1.Coordinates
x2, y2, z2 = p2.Coordinates
dir = math.atan2(y2 - y1, x2 - x1)
gradient = (z2 - z1) / (x2 - x1)
length = math.sqrt(math.pow((x2 - x1), 2.0) + math.pow((y2 - y1), 2.0))
hsegment = file.createIfcAlignmentSegment(
ifcopenshell.guid.new(),
DesignParameters=file.createIfcAlignmentHorizontalSegment(
StartPoint=p1,
StartDirection=dir,
StartRadiusOfCurvature=0.0,
EndRadiusOfCurvature=0.0,
SegmentLength=length,
PredefinedType="LINE",
),
)
ifcopenshell.api.nest.assign_object(file, related_objects=[hsegment], relating_object=alignment_layouts[0])
if include_vertical:
vsegment = file.createIfcAlignmentSegment(
ifcopenshell.guid.new(),
DesignParameters=file.createIfcAlignmentVerticalSegment(
StartDistAlong=start_dist_along,
HorizontalLength=length,
StartHeight=z1,
StartGradient=gradient,
EndGradient=gradient,
PredefinedType="CONSTANTGRADIENT",
),
)
ifcopenshell.api.nest.assign_object(file, related_objects=[vsegment], relating_object=alignment_layouts[1])
start_dist_along += length
# zero length segment
hsegment = file.createIfcAlignmentSegment(
ifcopenshell.guid.new(),
DesignParameters=file.createIfcAlignmentHorizontalSegment(
StartPoint=points[-1],
StartDirection=dir,
StartRadiusOfCurvature=0.0,
EndRadiusOfCurvature=0.0,
SegmentLength=0.0,
PredefinedType="LINE",
),
)
ifcopenshell.api.nest.assign_object(file, related_objects=[hsegment], relating_object=alignment_layouts[0])
if include_vertical:
vsegment = file.createIfcAlignmentSegment(
ifcopenshell.guid.new(),
DesignParameters=file.createIfcAlignmentVerticalSegment(
StartDistAlong=start_dist_along,
HorizontalLength=0.0,
StartHeight=points[-1].Coordinates[-1],
StartGradient=gradient,
EndGradient=gradient,
PredefinedType="CONSTANTGRADIENT",
),
)
ifcopenshell.api.nest.assign_object(file, related_objects=[vsegment], relating_object=alignment_layouts[1])
def create_as_polyline(
file: ifcopenshell.file,
name: str,
@@ -34,6 +34,8 @@ def create_layout_segment(
Creates a new IfcAlignmentSegment using the IfcAlignmentParameterSegment design parameters.
The new segment is appended to the layout alignment and the corresponding IfcCurveSegment is created in the geometric representation if it exists.
Additionally, if the geometric representation of the alignment exists, the segment's geometric representation is added to the IfcCurveSegment based on CT 4.1.7.1.1.4 Alignment Geometry - Segments
:param layout: The layout to receive the new layout segment. This parameter is expected to be IfcAlignmentHorizontal, IfcAlignmentVertical or IfcAlignmentCant
:param design_parameters: The parameters defining the segment. Expected to be the appropreate subclass of IfcAlignmentParameterSegment
:return: 4x4 matrix at end of segment as np.array intended to be used as the start point geometry for the next segment or None if there is the geometric representation is not defined.
@@ -0,0 +1,41 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
from collections.abc import Sequence
from ifcopenshell import entity_instance
def get_alignment_layout(segment: entity_instance) -> entity_instance:
"""
Returns the layout alignment that the segment is nested into
"""
expected_types = ["IfcAlignmentSegment"]
if not segment.is_a() in expected_types:
raise TypeError(
f"Expected entity type to be one of {[_ for _ in expected_types]}, instead received '{segment.is_a()}"
)
layout = None
layouts = ["IfcAlignmentHorizontal", "IfcAlignmentVertical", "IfcAlignmentCant"]
for nest in segment.Nests:
if nest.RelatingObject.is_a() in layouts:
layout = nest.RelatingObject
break
return layout
@@ -19,20 +19,12 @@
from collections.abc import Sequence
from ifcopenshell import entity_instance
import ifcopenshell.util.alignment
# TODO remove this function, use util directly
def get_alignment_layouts(alignment: entity_instance) -> Sequence[entity_instance]:
"""
Returns the layout alignments nested to this alignment
"""
layouts = []
for rel in alignment.IsNestedBy:
for layout in rel.RelatedObjects:
if (
layout.is_a("IfcAlignmentHorizontal")
or layout.is_a("IfcAlignmentVertical")
or layout.is_a("IfcAlignmentCant")
):
layouts.append(layout)
return layouts
return ifcopenshell.util.alignment.get_alignment_layouts(alignment)
@@ -44,6 +44,17 @@ def get_mapped_segments(layout_segment: entity_instance) -> Sequence[entity_inst
if not layout_segment.is_a(expected_type):
raise TypeError(f"Expected to see type '{expected_type}', instead received '{layout_segment.is_a()}'.")
# if the representation is attached directly to the layout segment, just get the representation curve
representations = ifcopenshell.util.representation.get_representations_iter(layout_segment)
for representation in representations:
if representation.RepresentationIdentifier == "Axis" and representation.RepresentationType == "Segment":
if len(representation.Items) == 1:
return (representation.Items[0],None)
else:
return representation.Items
# representation was not attached directly to the segment, so we have to find
# them from the composite curve
layout = layout_segment.Nests[0].RelatingObject
curve = ifcopenshell.api.alignment.get_layout_curve(layout)
@@ -0,0 +1,109 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import numpy as np
import ifcopenshell
import ifcopenshell.util.unit
from ifcopenshell import entity_instance, ifcopenshell_wrapper
def _intersect_lines(p1, d1, p2, d2):
x1, y1 = p1
dx1, dy1 = d1
x2, y2 = p2
dx2, dy2 = d2
det = dx1 * dy2 - dy1 * dx2
if abs(det) < 1e-12:
return None # lines are parallel
t = ((x2 - x1) * dy2 - (y2 - y1) * dx2) / det
x = x1 + t * dx1
y = y1 + t * dy1
return (x, y)
def segment_vertices(file: ifcopenshell.file, segment: entity_instance):
"""
Generates segment vertices. Segment vertices are at the start and end as well as the points where the tangents
at the start and end of the segment intersect (the TI point) and where lines
normal (perpendicular) to the start and end of the segment intersect (NI).
TI and NI are None if intersection points do not exist, such as in the case of a line.
:param curve_segment: A curve segment of type IfcAlignmentSegment or IfcCurveSegment
:return: tuples for Start, End, TI, NI
"""
supported_segment_types = ["IFCALIGNMENTSEGMENT", "IFCCURVESEGMENT"]
segment_type = segment.is_a().upper()
if not segment_type in supported_segment_types:
raise NotImplementedError(
f"Expected entity type to be one of {[_ for _ in supported_segment_types]}, got '{segment_type}"
)
# in the general case an IfcAlignmentSegment for a Helmert transition curve
# maps into two IfcCurveSegment geometric representations.
# For that reason, we have a start_segment_curve and and end_segment_curve.
# In the more common case, there is only one IfcCurveSegment geometric representation
# and start_segment_curve and end_segment_curve are equal
if segment_type == "IFCALIGNMENTSEGMENT":
segments = ifcopenshell.api.alignment.get_mapped_segments(segment)
start_segment_curve = segments[0]
end_segment_curve = start_segment_curve if segments[1] == None else segment[1]
else:
start_segment_curve = segment
end_segment_curve = segment
settings = ifcopenshell.geom.settings()
# get parameters at start of start_segment_curve
segment_fn = ifcopenshell_wrapper.map_shape(settings, start_segment_curve.wrapped_data)
segment_evaluator = ifcopenshell_wrapper.function_item_evaluator(settings, segment_fn)
s = segment_evaluator.evaluate(segment_fn.start())
start = np.array(s)
sx = float(start[0, 3])
sy = float(start[1, 3])
sdx = float(start[0, 0])
sdy = float(start[1, 0])
# get parameters at end of end_segment_curve
segment_fn = ifcopenshell_wrapper.map_shape(settings, end_segment_curve.wrapped_data)
segment_evaluator = ifcopenshell_wrapper.function_item_evaluator(settings, segment_fn)
e = segment_evaluator.evaluate(segment_fn.end())
end = np.array(e)
ex = float(end[0, 3])
ey = float(end[1, 3])
edx = float(end[0, 0])
edy = float(end[1, 0])
ti = _intersect_lines((sx, sy), (sdx, sdy), (ex, ey), (edx, edy)) # tangent intersection
sdx = float(start[0, 1])
sdy = float(start[1, 1])
edx = float(end[0, 1])
edy = float(end[1, 1])
ni = _intersect_lines((sx, sy), (sdx, sdy), (ex, ey), (edx, edy)) # normal intersection
return (sx, sy), (ex, ey), ti, ni
@@ -60,8 +60,24 @@ def evaluate_segment(segment: entity_instance, dist_along: float) -> np.ndarray:
segment_type = segment.is_a().upper()
if not segment_type in supported_segment_types:
raise NotImplementedError(f"Expected entity type 'IFCCURVESEGMENT', got '{segment_type}")
if dist_along > segment.SegmentLength:
raise ValueError(f"Provided value {dist_along=} is beyond the end of the segment ({segment.SegmentLength}).")
# Validate dist_along is within segment bounds
# SegmentLength can be negative (indicates curve direction), so we need to handle both cases
seg_len = (
segment.SegmentLength.wrappedValue if hasattr(segment.SegmentLength, "wrappedValue") else segment.SegmentLength
)
if seg_len >= 0:
# Positive length: valid range is 0 to seg_len
if dist_along < 0 or dist_along > seg_len:
raise ValueError(
f"Provided value {dist_along=} is beyond the end of the segment ({segment.SegmentLength})."
)
else:
# Negative length: valid range is seg_len to 0
if dist_along > 0 or dist_along < seg_len:
raise ValueError(
f"Provided value {dist_along=} is beyond the end of the segment ({segment.SegmentLength})."
)
s = ifcopenshell.geom.settings()
function_item = ifcopenshell_wrapper.map_shape(s, segment.wrapped_data)
@@ -90,8 +106,7 @@ def generate_vertices(rep_curve: entity_instance, distance_interval: float = 5.0
)
s = ifcopenshell.geom.settings()
s.set("piecewise-step-type", 0) # 0 = step-size is maximum step size, 1 = step-size is mininimum number of steps
s.set("piecewise-step-size", distance_interval)
s.set("function-step-param", distance_interval)
shape = ifcopenshell.geom.create_shape(s, rep_curve)
vertices = shape.verts
if len(vertices) == 0:
@@ -20,6 +20,7 @@ import math
import ifcopenshell
import ifcopenshell.util.unit
from typing import Sequence
def add_linear_placement_fallback_position(file: ifcopenshell.file) -> ifcopenshell.file:
@@ -109,3 +110,19 @@ def station_as_string(file: ifcopenshell.file, sta: float):
station_string = "-" + station_string
return station_string
def get_alignment_layouts(alignment: ifcopenshell.entity_instance) -> Sequence[ifcopenshell.entity_instance]:
"""
Returns the layout alignments nested to this alignment
"""
layouts = []
for rel in alignment.IsNestedBy:
for layout in rel.RelatedObjects:
if (
layout.is_a("IfcAlignmentHorizontal")
or layout.is_a("IfcAlignmentVertical")
or layout.is_a("IfcAlignmentCant")
):
layouts.append(layout)
return layouts
@@ -91,7 +91,9 @@ class IfcHeaderExtractor:
data = HeaderMetadata()
max_lines_to_parse = 50
for _ in range(max_lines_to_parse):
line = next(ifc_file)
line = next(ifc_file, None)
if line is None:
break
if isinstance(line, bytes):
line = line.decode("utf-8")
if line.startswith("FILE_DESCRIPTION"):
@@ -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
@@ -0,0 +1,49 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import ifcopenshell.api.alignment
import ifcopenshell.api.context
import ifcopenshell.api.unit
def test_get_alignment_layout():
file = ifcopenshell.file(schema="IFC4X3_ADD2")
project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
length = ifcopenshell.api.unit.add_si_unit(file, unit_type="LENGTHUNIT")
ifcopenshell.api.unit.assign_unit(file, units=[length])
geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model")
axis_model_representation_subcontext = ifcopenshell.api.context.add_context(
file,
context_type="Model",
context_identifier="Axis",
target_view="MODEL_VIEW",
parent=geometric_representation_context,
)
alignment = ifcopenshell.api.alignment.create(file, "Test", include_vertical=True, include_cant=True)
horiz = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
vert = ifcopenshell.api.alignment.get_vertical_layout(alignment)
cant = ifcopenshell.api.alignment.get_cant_layout(alignment)
assert horiz == ifcopenshell.api.alignment.get_alignment_layout(horiz.IsNestedBy[0].RelatedObjects[0])
assert vert == ifcopenshell.api.alignment.get_alignment_layout(vert.IsNestedBy[0].RelatedObjects[0])
assert cant == ifcopenshell.api.alignment.get_alignment_layout(cant.IsNestedBy[0].RelatedObjects[0])
test_get_alignment_layout()
@@ -0,0 +1,172 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import pytest
import ifcopenshell.api.alignment
import ifcopenshell.api.context
import ifcopenshell.api.unit
def unit_convert(unit_scale, p):
if p == None:
return p
x, y = p
return (x / unit_scale, y / unit_scale)
def test_segment_vertices():
file = ifcopenshell.file(schema="IFC4X3")
project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
length = ifcopenshell.api.unit.add_conversion_based_unit(file, name="foot")
angle = ifcopenshell.api.unit.add_si_unit(file, unit_type="PLANEANGLEUNIT")
ifcopenshell.api.unit.assign_unit(file, units=[length, angle])
geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model")
axis_model_representation_subcontext = ifcopenshell.api.context.add_context(
file,
context_type="Model",
context_identifier="Axis",
target_view="MODEL_VIEW",
parent=geometric_representation_context,
)
coordinates = [(500.0, 2500.0), (3340.0, 660.0), (4340.0, 5000.0), (7600.0, 4560.0), (8480.0, 2010.0)]
radii = [(1000.0), (1250.0), (950.0)]
vpoints = [(0.0, 100.0), (2000.0, 135.0), (5000.0, 105.0), (7400.0, 153.0), (9800.0, 105.0), (12800.0, 90.0)]
lengths = [(1600.0), (1200.0), (2000.0), (800.0)]
alignment = ifcopenshell.api.alignment.create_by_pi_method(
file, "TestAlignment", coordinates, radii, vpoints, lengths
)
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file)
# test the horizontal alignment geometry segments
expect = [
[(500.0, 2500.0), (2142.2379952109395, 1436.01482000418), None, None],
[
(2142.2379952109395, 1436.01482000418),
(3660.446122847804, 2050.7361731594674),
(3340.0, 659.9999999999998),
(2685.9792975637306, 2275.267699722618),
],
[(3660.4461228478035, 2050.7361731594674), (4084.115884236641, 3889.4629375870213), None, None],
[
(4084.115884236641, 3889.4629375870218),
(5469.395067206271, 4847.5663099476205),
(4340.0, 5000.000000000001),
(5302.199415841732, 3608.7985293830834),
],
[(5469.395067206271, 4847.56630994762), (7019.971366858418, 4638.286073184753), None, None],
[
(7019.971366858417, 4638.286073184753),
(7790.932128312586, 4006.7307645487535),
(7600.0, 4560.0),
(6892.902671821368, 3696.8225599557054),
],
[(7790.932128312587, 4006.7307645487535), (8480.0, 2010.0000000000002), None, None],
[(8480.0, 2010.0000000000002), (8480.0, 2010.0000000000002), None, None],
]
layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
segments = ifcopenshell.api.alignment.get_layout_segments(layout)
for segment, expected in zip(segments, expect):
s, e, ti, ni = ifcopenshell.api.alignment.segment_vertices(file, segment)
s = unit_convert(unit_scale, s)
e = unit_convert(unit_scale, e)
ti = unit_convert(unit_scale, ti)
ni = unit_convert(unit_scale, ni)
assert s == pytest.approx(expected[0])
assert e == pytest.approx(expected[1])
assert ti == pytest.approx(expected[2])
assert ni == pytest.approx(expected[3])
curve = ifcopenshell.api.alignment.get_basis_curve(alignment)
for segment, expected in zip(curve.Segments, expect):
s, e, ti, ni = ifcopenshell.api.alignment.segment_vertices(file, segment)
s = unit_convert(unit_scale, s)
e = unit_convert(unit_scale, e)
ti = unit_convert(unit_scale, ti)
ni = unit_convert(unit_scale, ni)
assert s == pytest.approx(expected[0])
assert e == pytest.approx(expected[1])
assert ti == pytest.approx(expected[2])
assert ni == pytest.approx(expected[3])
# test vertical curve segments
expect = [
[(0.0, 100.0), (1200.0, 121.0), None, None],
[
(1200.0, 121.0),
(2799.99999384661, 127.00000006153391),
(1999.9999969233054, 134.99999994615786),
(2218.1436363635016, -58058.63636362867),
],
[(2800.0, 127.0), (4400.0, 111.0), None, None],
[
(4400.0, 111.0),
(5599.999994508736, 116.9999998901747),
(4999.999997254367, 105.00000002745632),
(4800.039999999177, 40114.99999991764),
],
[(5600.0, 117.0), (6400.0, 133.0), None, None],
[
(6400.0, 133.0),
(8399.999995932576, 133.0000000813485),
(7399.999997966288, 152.99999995932575),
(7399.999999999187, -49866.99999995936),
],
[(8400.0, 133.0), (9400.0, 113.0), None, None],
[
(9400.0, 113.0),
(10199.99999633883, 103.00000001830585),
(9799.999998169415, 105.00000003661171),
(10466.733333334432, 53449.66666672164),
],
[(10200.0, 103.0), (12800.0, 90.0), None, None],
[(12800.0, 90.0), (12800.0, 90.0), None, None],
]
layout = ifcopenshell.api.alignment.get_vertical_layout(alignment)
segments = ifcopenshell.api.alignment.get_layout_segments(layout)
for segment, expected in zip(segments, expect):
s, e, ti, ni = ifcopenshell.api.alignment.segment_vertices(file, segment)
s = unit_convert(unit_scale, s)
e = unit_convert(unit_scale, e)
ti = unit_convert(unit_scale, ti)
ni = unit_convert(unit_scale, ni)
assert s == pytest.approx(expected[0])
assert e == pytest.approx(expected[1])
assert ti == pytest.approx(expected[2])
assert ni == pytest.approx(expected[3])
curve = ifcopenshell.api.alignment.get_curve(alignment)
for segment, expected in zip(curve.Segments, expect):
s, e, ti, ni = ifcopenshell.api.alignment.segment_vertices(file, segment)
s = unit_convert(unit_scale, s)
e = unit_convert(unit_scale, e)
ti = unit_convert(unit_scale, ti)
ni = unit_convert(unit_scale, ni)
assert s == pytest.approx(expected[0])
assert e == pytest.approx(expected[1])
assert ti == pytest.approx(expected[2])
assert ni == pytest.approx(expected[3])
test_segment_vertices()
+1 -1
View File
@@ -746,7 +746,7 @@ namespace {
static std::string format_double(const double& d) {
std::ostringstream oss;
oss.imbue(std::locale::classic());
oss << std::setprecision(std::numeric_limits<double>::digits10) << d;
oss << std::setprecision(std::numeric_limits<double>::max_digits10) << d;
const std::string str = oss.str();
oss.str("");
std::string::size_type e = str.find('e');
File diff suppressed because it is too large Load Diff
+12
View File
@@ -2,8 +2,10 @@
#define GRAPH_2D_H
#ifdef SVGFILL_DEBUG
#if 0
#include <nlohmann/json.hpp>
#endif
#endif
template <typename Kernel>
class Graph2D {
@@ -334,6 +336,16 @@ public:
return Graph2D(input_adjacency_list);
}
template <typename T>
void to_arrangement(T& arr) {
for (auto it = edges_begin(); it != edges_end(); ++it) {
if (it->first == it->second) {
continue;
}
CGAL::insert(arr, CGAL::Segment_2<Kernel>(it->first, it->second));
}
}
void assert_symmetric() {
#ifdef SVGFILL_DEBUG
#if 0