Compare commits

...

40 Commits

Author SHA1 Message Date
Ryan Schultz 9fd119bf95 Bonsai: custom display names for links
Each link row draws an editable display_name (double-click to rename)
with the file path as placeholder while unset, so several links of the
same file can be told apart. The name persists in the same Description
JSON blob as the filter and loaded state (new name key, written at
save time and by reload_link), restores on project open, and plain
legacy strings still decode unchanged. Decode tests updated to the
four-tuple with a name round-trip case.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 10:37:57 -05:00
Ryan Schultz 94ba41d9ea Bonsai: unit tests for link filter helpers + refactor notes
Adds test/tool coverage for the pure link helpers:
encode/decode_link_filter (plain round-trip, JSON promotion for
exclude and loaded, legacy and malformed decode) and
get_link_cache_paths (legacy names, include-only hash pinned to the
pre-exclude formula so existing caches stay valid, and the
same-include/different-exclude collision case the key exists to
prevent). 12 tests, verified passing under Blender python.

Documents the deliberate undo-system exemption on the link transform
autosave handler, and records the deferred refactors in the dev note:
an upstream exclude= parameter for filter_elements (separate
ifcopenshell-python PR) and the skipped core/tool interface ceremony.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 10:06:15 -05:00
Ryan Schultz cd5897d10d Note STEP p21e3 as the long-term serialization target in the dev note
ANCHOR/REFERENCE sections and anchor tags (unimplemented in
ifcopenshell, #668) are the standards-track home for the link
reference and its metadata; records the migration path, the identity
and archive-transport design points the branch already conforms to,
and the scope that would remain app-level regardless.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 09:31:02 -05:00
Ryan Schultz e0b97c574b Bonsai: auto-load links that were loaded and visible at save time
At IFC save time each link reference Description gains a loaded flag
(is_loaded and not is_hidden), extending the same JSON blob that
carries the include/exclude filter; plain legacy strings decode as
no-autoload. On project open, load_linked_models_from_ifc replays
flagged links via load_link after restoring the list, warning and
skipping missing files so they cannot break the open.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 09:08:51 -05:00
Ryan Schultz 429cab8b1b Note Include/Exclude UI labels in the dev note
The displayed labels changed from Query to Include to pair with
Exclude; the property identifier stays query for script and
persistence compatibility.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:05:10 -05:00
Ryan Schultz 328ca6d387 changed 'Query' to 'Include' 2026-07-10 16:24:56 -05:00
Ryan Schultz 12ecdf2aba Bonsai: reload-all-links button in the Links panel header
bim.reload_all_links reloads every loaded linked model via
argument-less reload_link calls, so each link replays its stored
path/query/exclude and rebuilds its cache from disk. Unloaded links
are left alone. Drawn as a refresh button beside Link IFC.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 15:51:59 -05:00
Ryan Schultz 8f9164bf72 Bonsai: include/exclude filter pair for linked models
A single selector query cannot express set differences (the grammar
only unions groups, and the parent facet cannot negate), so links now
carry an Exclude query beside the include, mirroring the drawing
Include/Exclude pattern: final set = include (or the default set when
empty) minus exclude. Applied in LoadLinkedProject and per link in
create_drawing so prints match the viewport.

The cache key hashes both strings when an exclude exists - keying on
the query alone would let same-include/different-exclude links serve
each other's geometry. Include-only filters keep the pre-exclude hash
and empty filters the legacy names, so existing caches stay valid.
Persistence in IfcDocumentReference.Description stays backwards
compatible: a plain include is stored as-is, an exclude promotes the
value to a small JSON blob, and non-JSON decodes as a legacy include.

The Exclude field appears in Link IFC and the Reload Link dialog
(carried through the file browser round trip, SKIP_SAVE like the rest).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 15:26:33 -05:00
Ryan Schultz 403308a923 Bonsai: keep linked models cut linework in BISECT cut mode
BISECT cut mode deletes the serializer cut linework and regenerates it
by bisecting Blender mesh objects, which linked models do not have -
their cuts were deleted and never regenerated, so linked elements only
appeared as projections and the .cut CSS rule never applied to them.
remove_cut_linework now only removes cut groups whose guid resolves in
the host file, keeping the serializer cut geometry for linked models.

Resolving a linked entity STEP id via tool.Ifc.get_object cross-matches
into the host session and can return an arbitrary host object (e.g. the
drawing camera), so generate_material_layers and the linework merge now
guard on element.file identity.

BISECT mode also runs move_projection_to_bottom like OPENCASCADE mode:
its own bisect cuts are appended last, but the retained serializer cuts
of linked models are emitted before the projections and would paint
underneath.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 09:34:08 -05:00
Ryan Schultz 236da3c75a Bonsai: draw moved and multi-linked models at their displayed locations
create_drawing opened linked IFCs raw, so a moved link serialized at
its original coordinates and its elements fell outside the drawing.
The stored link transformation is the model-space delta, so it is now
baked into the linework iterator via the model-offset/model-rotation
settings (Trans @ Rot composition matches the rigid matrix
decomposition; the plan-view Z offset adds onto the translation).

The serialization loop also collapsed same-file links into a dict
keyed by filepath, dropping all but the last link. It now iterates one
entry per link and intersects each link's drawing elements with its
selector query, so drawings show what each link displays in the
viewport. Adds tool.Project.get_link_transformation_matrix as the
shared accessor for the stored 4x4.

Verified headless: window link moved +5m appears offset by exactly
5m x scale; unmoved door link at its native position; both present.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 09:34:08 -05:00
Ryan Schultz cf58c675db Bonsai: match linked model documents by resolved path
get_linked_models_documents keyed documents by the stored Location, so
linking the same file first with a relative path and then an absolute
one (or vice versa) created a duplicate IfcDocumentInformation. Both
the keys and the LinkIfc lookup now normalize through resolve_uri.

Also record the PR #8242 review round decisions in the dev note.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 09:31:41 -05:00
Ryan Schultz 5ea11817ad Add dev-notes for Linked_File_Features branch
Living design note per the docs/dev-notes convention: problem, key
facts (library-per-path reuse, SKIP_SAVE last-used-property retention,
IfcDocumentReference conventions, link matrix math), per-feature design
decisions, commit map, and open test items.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 09:31:41 -05:00
Ryan Schultz 6d90048acd Bonsai: per-query caches so one IFC can be linked with several queries
Linking the same file twice with different queries previously collided
on the single shared .ifc.cache.blend: Blender reuses the loaded
library per path, so both links displayed whichever query was cached
first (and the other after reopening). Cache blend/json filenames now
include a hash of the query (tool.Project.get_link_cache_paths), so
each query gets its own library. The empty query keeps the legacy
names, and the property sqlite stays shared since it always contains
the whole file. All cache-path consumers were updated, including the
per-link selectability/visibility toggles which would otherwise affect
every link of the file at once.

Query persistence moves from the shared sidecar JSON to the per-link
IfcDocumentReference.Description (IFC4+, written by link_ifc and
reload_link), restored on project load with a legacy JSON fallback
that only applies when a file has a single link. The appended-element
placement now matches links by the queried instance root empty since
filepath alone is ambiguous with several links per file.

LoadLink and ReloadLink volatile properties are marked SKIP_SAVE:
Blender reuses last-used operator properties on the next interactive
invocation, which leaked one link's query into another's load (and
would corrupt ReloadLink's is_property_set logic the same way).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 09:31:41 -05:00
Ryan Schultz cdb594b5c2 Bonsai: fix Explore tool highlight and append placement for linked models
The queried-element highlight broke in two ways: layerset-sliced linked
meshes contain ngons, so highlight triangles are now built from
calc_loop_triangles instead of polygon vertices; and ID properties read
back as IDPropertyArrays which GPUIndexBuf rejects, so selection
geometry is converted to plain tuples. TRIS drawing is also gated on
its own data instead of piggybacking on the edges check.

Moved links now highlight at their displayed location: the ray-cast
instance matrix is passed through to select_linked_element, and
find_obj_root compares it against the empty and object matrices
combined (instanced occurrence objects have non-identity local
matrices), falling back to the collection's only instance when no
matrix is available (e.g. select by GUID).

bim.append_inspected_linked_element also places the appended element
where the moved link is displayed, using the new
tool.Project.calculate_link_delta_matrix helper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 09:31:41 -05:00
Ryan Schultz 028e593939 Bonsai: per-row lock toggle with auto-saved link transforms
Link editing moves from the links header row into each list row as a
lock/unlock toggle. Unlocking (bim.enable_editing_link) frees the
handle for moving; any transform is persisted immediately by a
depsgraph_update_post handler, so bim.edit_link and its explicit save
step are removed. Locking (bim.disable_editing_link) saves the current
location and locks the handle instead of restoring the old position -
cancel/restore semantics no longer exist.

The save math from EditLink now lives in
tool.Project.save_link_transformation. Enable/disable operators accept
a link_index (default -1 = active link), so several links can be edited
at once and script calls stay backward compatible.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 09:31:41 -05:00
Ryan Schultz 42a05cf976 Bonsai: full load options in the Reload Link dialog
The reload_link dialog previously only exposed the query. It now also
offers Use Relative Path (defaulting to the stored path form), Use
Cache (default off, matching the old always-rebuild behavior), the
False Origin Mode project settings, and an editable file path with a
browse button.

Since a file browser cannot open from inside a props dialog, the browse
button runs a new bim.select_link_filepath operator that opens the
browser preselected at the current file and reopens the reload dialog
with the chosen path, carrying the in-progress dialog state through the
round trip.

Changing the path updates the link name/filepath and, when a host IFC
exists, the IfcDocumentReference.Location and document name - so
ReloadLink is now a tool.Ifc.Operator to keep those edits transactional.
Script calls without arguments still preserve all stored link values.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 09:31:40 -05:00
Ryan Schultz a97276b8b1 Bonsai: load external styles and layerset slicing for linked IFC models
Linked models previously flattened every style to a flat diffuse-color
material. Now, styles carrying an IfcExternallyDefinedSurfaceStyle that
points to a .blend file get the referenced material appended into the
link's .cache.blend, in both the chunked and instanced loading paths.
Relative style locations resolve against the linked IFC, and appended
materials are deduplicated and stripped of stale IFC ids.

Multi-layer elements (IfcMaterialLayerSetUsage) are now routed through
the instanced path and sliced with slice_layerset_mesh so each layer
shows its material style, using the external material when available.
slice_layerset_mesh gained a pluggable style-to-material resolver and
no longer appends duplicate materials for layers sharing one style.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 09:31:40 -05:00
Gorgious56 9ae79b42dd Merge pull request #8398 from Gorgious56/batch-array-duplicate-helper
Batch array duplicate helper
2026-07-08 15:27:53 +02:00
Gorgious56 c01433cb6c Bonsai: spec typed test doubles for Blender + dataclass mocks
Convert bare Mock() to Mock(spec=bpy.types.Object) for Blender-object
stand-ins in TestRecalculateWallsWithNewConnections, TestMEPActionGuards,
and TestRecreateAggregateIteratesAllNew so typos on the Blender API
fail loudly instead of silently returning a MagicMock.

Replace the ad-hoc Mock() ConnectionRecord stand-in in
TestRecreateConnectionsZipsPairs with a real ConnectionRecord instance,
which pins field names at construction and catches drift if the
dataclass fields ever get renamed.

IFC entity mocks remain bare Mock() intentionally: entity_instance
attributes are schema-driven at runtime rather than defined statically
on the class, so spec= would refuse the .GlobalId / .HasFillings /
.ConnectedTo attribute writes the tests need.

Relates to #8088.

Generated with the assistance of an AI coding tool.
2026-07-08 15:02:21 +02:00
Gorgious56 da50d22ed5 Bonsai: route array-regen selection through tool.Blender utilities
Rewrite tool.Array.select_only_parent as a thin call to
tool.Blender.select_and_activate_single_object; drop the ad-hoc
per-child deselect loop and the unused parent_element parameter.

Replace the tail parent_obj.select_set(True) in _regenerate_array_body
with tool.Blender.set_object_selection, which wraps select_set in the
hidden-object try/except the utility already owns.

Relates to #8088.

Generated with the assistance of an AI coding tool.
2026-07-08 14:53:29 +02:00
Gorgious56 9191baf067 Bonsai: hide array-child gizmos + converge regen selection
Add tool.Array.is_array_child helper. Port decorator and MEP
action gizmos (lock, pen, join) hide on array children — writes
on children get wiped by the next regen, and the port topology
is inherited from the parent.

Introduce tool.Array.select_only_parent and wire it into both
bim.regenerate_array and bim.finish_editing_array so post-regen
state converges on parent-only-selected + active. Grow and shrink
paths otherwise diverge (grow left new children selected alongside
the parent; shrink left only the parent).

Relates to #8088.

Generated with the assistance of an AI coding tool.
2026-07-08 13:19:42 +02:00
Gorgious56 c299f0b191 Bonsai: iterate every duplicated entity in relationship recreation
Sweep [0]-indexing in recreate_aggregate, recreate_connections,
and recreate_port_connections so batched N-child duplicates
recreate relationships on every new child, not just the first.
Single-source callers unaffected (loop collapses to one iteration
on 1-element lists).

Relates to #8088.

Generated with the assistance of an AI coding tool.
2026-07-08 13:19:26 +02:00
Gorgious56 8f3a1d7412 Bonsai: batch array-duplicate + defensive guards
Replace N sequential duplicate_ifc_objects([parent]) calls in
_regenerate_array_body with one duplicate_ifc_object_n_times call
per layer, batching the fixed per-call overhead (snapshot gather,
UI refresh, decorator reload).

Guard batch_host_recut drain against dead StructRNA refs and prune
orphan array-child GUIDs at regen so outliner-delete of a
Bonsai-managed child cannot crash subsequent regenerate_array.

Recalculate walls after recreate_connections so Shift+D of
connected walls produces correct junction geometry without a
manual regen step.

Relates to #8088.

Generated with the assistance of an AI coding tool.
2026-07-08 13:13:14 +02:00
Petru Conduraru e0a1988044 Follow IfcRelAdheresToElement so IfcSurfaceFeature road markings import #8375
IfcSurfaceFeature (e.g. road markings) adheres to a host element through
IfcRelAdheresToElement, a [1:1] cardinality hierarchical relationship in the
same family as aggregation, containment and nesting since IFC4.3. The spatial
traversal never followed it, so surface features had no resolvable parent or
container: on import they landed in the Unsorted collection instead of the
host's spatial collection, and were dropped entirely in DECOMPOSITION filter
mode.

Add get_adhered_element (feature to host) to the get_parent resolver chain and
walk HasSurfaceFeatures in get_decomposition, plus a get_surface_features helper
mirroring get_parts/get_contained. With get_parent resolving adherence,
get_container now returns the host's spatial container, so tool.Collector places
surface features under the host. Also follow HasSurfaceFeatures in the Bonsai
DECOMPOSITION filter path so they load in that mode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 11:16:08 +02:00
Richard Brice 644b92263d Merge branch 'v0.8.0' of https://github.com/IfcOpenShell/IfcOpenShell into v0.8.0 2026-07-07 12:00:27 -07:00
Richard Brice 61642d2ba3 Fixes computation of fallback position for linear placement. PlacementRelTo was improperly ignored 2026-07-07 11:59:47 -07:00
Petru Conduraru 4776bd7639 Atomic IFC file writes to prevent corruption on interrupted save (#4797)
file.write() streamed directly onto the target path, so a crash mid-write
left a truncated file with dangling STEP references. Serialize to a temp
file in the same directory, then atomically rename it onto the target.

- New IfcUtil::path::atomic_rename_file: std::rename on POSIX, MoveFileExW
  with MOVEFILE_REPLACE_EXISTING on Windows. Unlike rename_file it never
  unlinks the destination first, so there is no window where it goes missing.
- Fully in C++/swig (per aothms), so the FILE_NAME header is untouched: it
  comes from the model header, not the output path (verified empirically).
- Temp lives next to the target so the rename stays on one filesystem.
- Stream is closed before the rename (Windows cannot move an open file).
- On any write error the temp is removed and the original target is intact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 14:22:44 +02:00
Petru Conduraru b2d58d0b81 cmake: read the VERSION file unconditionally so builds report the real version #8164
IfcConvert --version reported 0.8.0 on a plain source build even though the
VERSION file says 0.8.6 (#8164). buildinfo.cpp already falls back to the
IFCOPENSHELL_VERSION_STRING macro and CMake already passes it as
${RELEASE_VERSION}, but RELEASE_VERSION was only read from the VERSION file
when VERSION_OVERRIDE was on. A default build (VERSION_OVERRIDE off,
ADD_COMMIT_SHA off, as the nixpkgs package builds it) fell through to the
hardcoded "0.8.0", so the fallback macro carried the stale value.

Read the VERSION file unconditionally so RELEASE_VERSION is always the real
version. VERSION_OVERRIDE still governs the branch name embedded when
ADD_COMMIT_SHA is on, and project()/CPack now also reflect the true version.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 10:05:03 +02:00
Petru Conduraru 7322263a5e GltfSerializer: clamp roughnessFactor into the valid glTF range #8073
roughnessFactor was computed as 1/specularity. An IfcSpecularExponent of
0 produced infinity, which nlohmann::json serialises as null and makes
the glTF invalid; exponents below 1 produced values above 1, which glTF
also forbids. Map exponents <= 1 to full roughness and keep 1/exponent
above that, so the factor always lands in [0, 1].

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:33:47 +02:00
Petru Conduraru 49de7dbcb1 ExtractElements: handle IfcProject without RepresentationContexts #8199
The georeferencing fix (e6dc582) iterates IfcProject.RepresentationContexts
unconditionally, but the attribute is OPTIONAL and None on projects without
contexts, crashing every extraction on such files with
TypeError: 'NoneType' object is not iterable.

Also extend the #8199 regression test to assert element placements are
copied verbatim, so extraction can never bake map coordinates into local
placements.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:24:34 +02:00
Petru Conduraru bade0647e8 util.unit: scale RepresentationContext Precision on unit conversion #6127
IfcGeometricRepresentationContext.Precision is typed as a plain IfcReal
but is interpreted in the project length unit, so the IfcLengthMeasure
traversal in convert_file_length_units never touched it. A model
converted from mm to m kept a Precision of e.g. 0.01 (fine in mm, huge
in m), which breaks downstream geometry interpretation such as
IfcConvert boolean cleanup.

Subcontexts derive Precision from their parent, so only root contexts
are scaled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:21:25 +02:00
Petru Conduraru 58cfab48e6 entity_instance: get_info_2 falls back to get_info for unsupported args #4270
get_info_2 advertises the same signature as get_info but raised a bare
AssertionError for anything the C++ fast path does not implement --
including its own default arguments (recursive=False).

Use the fast path when recursive=True, return_type=dict and ignore=()
hold, and delegate to the pure Python get_info otherwise. As noted in
the issue, without recursion there is no meaningful performance gain to
lose by delegating.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:15:32 +02:00
Petru Conduraru fa597536e1 IfcParse: drop ostringstream from format_double per review #7696
std::to_chars is locale-independent, so the ostringstream and imbue(locale)
are no longer needed. Build the REAL string with plain std::string operations.
Output is unchanged (verified in standalone compile: same shortest values, all
round-trip). Addresses review feedback on #8309.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:14:24 +02:00
Petru Conduraru ee2b357d74 IfcParse: serialize REALs with shortest round-trip form #7696
format_double formatted doubles with setprecision(max_digits10) (17 digits),
which padded clean values with noise: 0.0174532925199433 was rewritten as
0.017453292519943299 and 1.E-05 as 1.0000000000000001E-05. Every REAL in a file
changed on save, producing enormous diffs for anyone version-controlling IFC.
Use std::to_chars, which emits the shortest string that round-trips exactly
(like Python's repr), then keep the existing mantissa/exponent formatting.

Verified in a standalone compile of the exact function logic: the reporter's
values become 0.0174532925199433 and 1.E-05, 0.1 stays 0.1, and every tested
value (including a denormal) round-trips back to the identical double.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:14:24 +02:00
Petru Conduraru b1be7d92e6 ifcwrap: accept numpy scalars in aggregate type check #5873
check_aggregate_of_type used an exact type comparison (element->ob_type ==
type_obj), so a numpy array was rejected because its elements are numpy scalars
(numpy.float64) rather than direct float instances. For the numeric types,
accept subclasses: PyFloat_Check for double (numpy.float64 subclasses float) and
PyLong_Check (excluding bool) for int. The SPF REAL vs INTEGER distinction is
kept, so a float is not accepted where an int is expected and vice versa.

This replaces the earlier Python-side walk() approach, which the maintainer
preferred not to take since walk() is removed in v0.9. Verified with a runtime
red-green (built as a shared lib, called via ctypes): the old check rejects
np.array([3.0, 4.0]) and the new one accepts it, plain lists still work, an int
list is still rejected where a REAL is expected, and bool is rejected for INTEGER.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:12:41 +02:00
Petru Conduraru d2381ad6c6 IfcParse: don't strip delimiters from a single-character token #5683
asStringRef removes the first and last characters of a string, enumeration
or binary token to drop the delimiters, guarded only by !str.empty(). A
malformed single-character token (e.g. a bare '.' left when a fuzzer turns
'.PHYSICAL.' into '.)HYSICAL.') has length 1, so the first erase empties the
string and the second erase(str.begin()) runs on an empty string. That is
undefined behaviour: benign on a normal build, but it aborts (or throws
std::length_error from a later append) under a hardened libstdc++ with
_GLIBCXX_ASSERTIONS, which is why this file only segfaulted on the Fedora
build. Require at least two characters before stripping.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 12:28:17 +02:00
Petru Conduraru 5e539890f1 buildinfo: report the release version instead of a hardcoded fallback #8164
When ADD_COMMIT_SHA is off (the default for release tarballs), buildinfo.cpp
fell back to a hardcoded "0.8.0", so a 0.8.5/0.8.6 build reported 0.8.0 from
IfcConvert --version and in written file headers. Pass CMake's RELEASE_VERSION
(read from the VERSION file) to IfcParse as IFCOPENSHELL_VERSION_STRING and use
it as the fallback, mirroring how the branch/commit defines are handled. The
commit-sha build and the last-resort literal are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 11:30:26 +02:00
falken10vdl 6b4c0194ff Merge pull request #7093 from falken10vdl/group_drawings_in_groups_and_dcos
Organize drawings under a parent called DRAWINGS in ifc groups and ifc documents
2026-07-06 08:40:49 +02:00
Bruno Postle 1614791775 Fix SHAPELY fill mode dropping surface fills for all but the last linked file
generate_linework() loops over the main file plus any linked models
(added in 5db955d4), reassigning drawing_elements each iteration. The
SHAPELY fill pass builds elements_with_faces/raycast_objs from
drawing_elements, but did so once *after* that loop finished, so it
only ever saw whichever file was processed last -- silently dropping
surface fills for every other file, including the main model whenever
any link was loaded.

Accumulate elements_with_faces/raycast_objs across every file inside
the loop instead of capturing drawing_elements once after it ends.

Generated with the assistance of an AI coding tool.
2026-07-05 23:45:43 +01:00
falken10vdl 20b68ce0a9 Organize drawings under a parent called DRAWINGS in ifc groups and ifc documents 2026-07-03 14:52:29 +02:00
39 changed files with 2733 additions and 429 deletions
+13 -7
View File
@@ -27,13 +27,14 @@ endif()
set(CMAKE_CXX_STANDARD_REQUIRED ON) # not necessary, but encouraged
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
if(VERSION_OVERRIDE)
file(READ "../VERSION" "RELEASE_VERSION_")
string(STRIP "${RELEASE_VERSION_}" RELEASE_VERSION)
message(STATUS "Detected version '${RELEASE_VERSION}'")
else()
set(RELEASE_VERSION "0.8.0")
endif()
# The VERSION file in the repository root is the single source of truth for the
# release version. Read it unconditionally so a plain source build reports the
# real version through buildinfo.cpp instead of the stale hardcoded 0.8.0
# fallback (see #8164). VERSION_OVERRIDE still controls the branch name used
# when ADD_COMMIT_SHA embeds a commit sha.
file(READ "../VERSION" "RELEASE_VERSION_")
string(STRIP "${RELEASE_VERSION_}" RELEASE_VERSION)
message(STATUS "Detected version '${RELEASE_VERSION}'")
add_definitions(-D_DISABLE_CONSTEXPR_MUTEX_CONSTRUCTOR)
@@ -660,6 +661,11 @@ if(ADD_COMMIT_SHA)
endif()
endif(ADD_COMMIT_SHA)
# Always expose the release version (from the VERSION file) to buildinfo.cpp so
# that a build without commit-sha info reports the correct version instead of a
# stale hardcoded fallback. See #8164.
target_compile_definitions(IfcParse PRIVATE IFCOPENSHELL_VERSION_STRING=${RELEASE_VERSION})
if(MSVC)
# @todo still needs to be understood better, but the cgal and cgal-simple kernel cause multiply defined boost lambda placeholders _1 ... _3
set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} /FORCE:MULTIPLE")
+400
View File
@@ -0,0 +1,400 @@
<!-- This file was generated with the assistance of an AI coding tool. -->
# Linked file features — queries, styles, transforms, and multi-linking for linked IFC models
> **Living dev note** for the `Linked_File_Features` branch/PR. Read before working
> on the feature; append decisions and findings as the PR is refined. This is *not* user
> documentation — at merge it is removed or its durable parts promoted to code comments.
> See [README.md](README.md) for the convention (introduced on the
> `opening-template-on-type` branch; not yet on this branch's base).
## Problem
Linked IFC models (`bim.link_ifc`) had several gaps that made them hard to use as a
"reference in other trades' models" workflow:
- One shared `.ifc.cache.blend` per IFC file meant the **same file could not be linked
twice with different selector queries** — both links showed whichever query was cached
first in-session, and whichever was cached last after reopening (Blender reuses one
library datablock per path).
- The selector query was not durably stored anywhere in the host IFC, so save → reopen
lost or cross-wired the filter; a scripted `bpy.ops.bim.reload_link()` also wiped it.
- Linked geometry got **flat diffuse-only materials** — external `.blend` styles
(`IfcExternallyDefinedSurfaceStyle`) and per-layer materials (layerset slicing) that
the normal import applies were ignored.
- Moving a linked model required an explicit enable-edit → move → save dance on the
active link only, with save/cancel buttons in the panel header.
- The Explore tool's highlight broke (GPU type errors), drew at the link's *original*
location when the link had been moved, and `bim.append_inspected_linked_element`
placed appended elements at the original location too.
## Key facts established
- **Cache architecture**: `LoadLink.link_ifc` generates a Python script and runs a
background Blender subprocess that executes `bim.load_linked_project` and saves a
`.ifc.cache.blend`. The host session then *links* (not appends) the `IfcProject/...`
collection from that blend and instances it via an empty (the link "handle").
Georeferencing metadata lives in a sidecar `.cache.json`; extracted properties in
`.cache.sqlite` (whole file, query-independent — deliberately shared across queries).
- **Blender reuses an in-session library per path.** Loading the same blend path twice
yields the same library/collection. This is what broke multi-query linking with a
shared cache filename, and why per-query *filenames* (not cache invalidation) are the
fix.
- **Last-used operator properties** are reused on the next *interactive* invocation
(UI button), while scripted `bpy.ops` calls always start from defaults. LoadLink's
internal `self.query = link.query` fallback assignment was remembered by Blender and
leaked into the next button click (`operator_query='IfcWindow'` for the door link).
Any `is_property_set()`-based logic is corrupted the same way. Fix: `SKIP_SAVE` on
volatile props. **A GUI-only bug like this is invisible to scripted repro** — both
headless and windowed `--python` test runs passed while the manual flow failed.
- **`IfcDocumentReference`** per link: attribute index 1 (`Identification`) already
stores the link's 4×4 transformation (existing Bonsai convention). `Description`
(IFC4+; **absent in IFC2X3**) now stores the selector query. One
`IfcDocumentInformation` (Scope `LINKED_MODEL`) per file, one reference per link.
- **Geometry iterator materials**: `material.instance_id()` is the STEP id of the
`IfcSurfaceStyle` — or of an `IfcMaterial` when the item has a material but no style,
hence the `is_a("IfcSurfaceStyle")` guard when resolving external styles.
- **External styles**: `IfcExternallyDefinedSurfaceStyle.Location` (`.blend`, relative
paths resolve against the *linked* IFC, not the host) + `Identification` in
`data_block_type/name` form (e.g. `materials/Brick`), same convention as
`bim.activate_external_style`.
- **Chunk pipeline dedups materials by RGBA color** (`np.unique` on a color array), so
style identity must ride along as an extra column to survive — added only for styles
that actually resolve to an external material, so plain colored styles dedupe exactly
as before.
- **`slice_layerset_mesh` needs a local-space, per-element mesh** (bisect planes are in
object space), which the chunk path can't provide (world-space, many elements per
mesh) — hence routing multi-layer elements through the instanced path. Its
`dissolve_limit` produces **ngons**, which broke the Explore highlight's
triangles-from-`polygon.vertices` assumption downstream.
- **ID properties round-trip as `IDPropertyArray`**, not plain lists (verified in
4.5.7: empty list → flat `IDPropertyArray`; nested lists → list of `IDPropertyArray`
items), and `GPUIndexBuf` rejects them — selection geometry must be converted to
plain tuples on read.
- **`scene.ray_cast` returns the hit instance's world matrix** (link empty matrix
included). For instanced occurrence objects the object's own local matrix is *not*
identity, so resolving the instancing empty must compare against
`empty.matrix_world @ obj.matrix_world`, not the empty's matrix alone.
- **Link matrix math**: the handle empty's matrix is `inv(L) @ T @ G` (L = host local
matrix from georef props, T = stored transformation, G = linked model's global
matrix from the cache json). The world-space displacement of a moved link is
therefore `inv(L) @ T @ L` — no json read needed (`calculate_link_delta_matrix`).
- **Undo consistency of auto-saved moves**: Blender undo of a handle move fires another
depsgraph update, so the handler re-saves the reverted matrix — stored state stays
consistent without transactions (a handler can't open one).
## Design
### Per-query caches + query persistence (multi-linking)
`tool.Project.get_link_cache_paths(filepath, query)` appends `.md5(query)[:8]` to the
cache blend/json names; the empty query keeps the legacy un-suffixed names so existing
caches stay valid. Every cache-path consumer goes through it — `link_ifc` build and
invalidation, the subprocess json write, model-origin/georef indicator reads,
`calculate_link_matrix`, `save_link_transformation`, and the per-link
selectability/wireframe/visibility toggles (which match collections *by library
filepath* and would otherwise affect every link of the file at once).
The query persists on each link's `IfcDocumentReference.Description` (written by
`LinkIfc` and `ReloadLink`); `load_linked_models_from_ifc` restores from it, with a
legacy-JSON fallback that only applies when the file has a **single** link (with
several links the shared JSON can't say which link it belonged to). IFC2X3 hosts have
no `Description` — custom queries are not restorable there (accepted).
`LoadLink`/`ReloadLink` volatile properties are `SKIP_SAVE` (see key facts). Cache
clearing tolerates a missing blend (a reload with a brand-new query points at a
not-yet-existing filename).
### Include/Exclude filter pair
The selector grammar's only cross-group combiner is `+` (union) and the `parent`
facet cannot express "not under X" (its `!=`/regex paths also match by GlobalId, so
negation removes everything with any parent), which makes set differences like
"group members minus the slabs under aggregate X" structurally inexpressible in one
query string. Links therefore carry an **Exclude** query beside the include —
mirroring `EPset_Drawing`'s Include/Exclude pattern: final set = include (or the
default set when empty) exclude, applied in `LoadLinkedProject` and per link in
`create_drawing`.
- **Cache key**: `get_link_cache_paths` hashes `md5(query + "\0" + exclude)` when an
exclude exists; include-only filters keep the pre-exclude `md5(query)` so existing
caches stay valid; empty filter keeps legacy un-suffixed names. Keying on query
alone would let same-include/different-exclude links silently serve each other's
geometry.
- **Persistence**: `encode_link_filter`/`decode_link_filter` — a plain include is
stored in `Description` as-is (backwards compatible); an exclude, a `loaded`
state or a custom display name promotes the value to
`{"include": …, "exclude": …, "loaded": …, "name": …}` JSON. Decode treats
non-JSON as a legacy include string. The display name (`Link.display_name`,
double-click the list row to rename; file path shows as placeholder while
unset) exists to tell apart several links of the same file.
- Exclude applies on top of the **default** element set too, so
"everything except X" needs no explicit include.
- UI labels are **Include**/**Exclude** (matching the drawing pattern), but the
property identifier stays `query` for script (`bpy.ops.bim.link_ifc(query=…)`)
and persistence compatibility.
- Verified headless: `query=""`/`exclude="IfcDoor"` loads only the window;
same file with a different filter gets its own cache; both filters survive
save → reopen → reload.
### Auto-load on open
Links that were **loaded and visible** at IFC save time auto-load when the project
is reopened. `ExportIFC` calls `tool.Project.update_linked_models_state()`, which
rewrites each reference's `Description` with a `loaded` flag
(`is_loaded and not is_hidden`); `load_linked_models_from_ifc` replays flagged
links via `load_link` after restoring the list (missing files warn and skip so
they can't break project open). The flag extends the same JSON blob as the
exclude — plain legacy strings decode as no-autoload. Trade-off: project open
pays the link-load cost up front (fast on cache hit; a missing cache rebuilds in
a background Blender, same as clicking Load). Verified headless: loaded+visible
auto-loads; unloaded and loaded-but-hidden links stay unloaded.
### Long-term serialization target: STEP Part 21 Edition 3
STEP p21e3 defines the standards-track version of this feature's persistence:
`ANCHOR`/`REFERENCE` sections (clauses 910) let one file import entities from
another via URI + fragment, and **anchor tags** (`{tagname: value}`) are the
designated slot for out-of-schema metadata — a cleaner home than the
`Description` JSON blob (see the review-round discussion). ifcopenshell does not
implement these sections yet ([#668](https://github.com/IfcOpenShell/IfcOpenShell/issues/668),
open, unassigned); if it ever does, the migration path is: link →
`REFERENCE` to the linked file's project anchor, filter/transform/loaded
metadata → anchor tags. Keeping the blob behind
`encode_link_filter`/`decode_link_filter` makes that a two-function change.
Two p21e3 design points this branch already conforms to:
- **Identity**: p21e3 distinguishes volatile file-scoped entity numbers
(`#100` fragments) from durable anchors/UUIDs — the same lesson behind our
STEP-id collision fixes (GUID-based matching, `element.file` guards). Raw
STEP ids must never cross a file boundary; IFC GlobalIds map 1:1 onto
p21e3 UUID anchors.
- **Transport** (clause A.4): exchange structures plus referenced resources
can ship as one ZIP archive with references resolving inside it. Our posix,
optionally relative `Location`s resolved via `resolve_uri` are exactly the
invariants a future "package project with links" export would need.
Even full p21e3 support would not cover per-link transforms, filters, or load
state — a `REFERENCE` imports entities, it does not place a model — so the
app-level metadata remains; only its container would change.
### External styles + layerset slicing in the linked loader
`LoadLinkedProject.get_external_material(style_id)` resolves a style id → appended
Blender material from the external `.blend`, cached two ways (per style id; per
appended data-block, so styles sharing one material don't append duplicates). Appended
materials get their stale `ifc_definition_id` cleared (the source `.blend` may have
been authored in a Bonsai session; the id would be misread in the linked file *and*
in the host once the cache links in). Applied in both loading paths — instanced
occurrences directly, chunks via the style-id column.
Multi-layer elements (`IfcMaterialLayerSetUsage`, >1 layer) route through the
instanced path and get `slice_layerset_mesh`, which gained a pluggable
`style_to_material` resolver (defaults to the old `tool.Ifc.get_object` for the normal
import) — the linked resolver prefers the external material, falling back to a flat
diffuse from the style's shading colour. Also fixed there: newly appended layer
materials are registered in the dedup dict (two layers sharing one style used to
append it twice).
Trade-off: layered walls become individual instanced objects instead of chunk members;
meshes shared between elements (same geometry id) bake the slice from the first
element's layerset usage — same behaviour as the normal importer.
### Reload Link dialog
`bim.reload_link` now exposes File Path (+ browse button), Use Relative Path
(defaulting to the stored path form), Use Cache (default off = old always-rebuild
behaviour), the False Origin Mode project props, and Query. A file browser can't open
from inside a props dialog, so the browse button runs `bim.select_link_filepath`
(fileselect) which *reopens* the reload dialog with the chosen path, carrying the
in-progress dialog state through the round trip (op props are baked at draw time).
Path changes update `link.name`/`filepath` and, with a host IFC, the reference
`Location` + document name — which is why `ReloadLink` became a `tool.Ifc.Operator`.
Script calls without arguments preserve all stored link values via `is_property_set`.
`bim.reload_all_links` (refresh button beside Link IFC in the panel header) reloads
every *loaded* link via argument-less `reload_link` calls — each link's stored
path/query/exclude replay and its cache rebuilds from disk. Unloaded links are left
alone. Deliberately expensive: one background cache rebuild per link.
### Per-row lock toggle + auto-saved transforms
Link editing moved from the panel header into each list row as a lock/unlock icon:
unlock (`bim.enable_editing_link`) frees the handle; **any movement is persisted
immediately** by a `depsgraph_update_post` handler (lazy — ticks without transform
updates cost ~nothing); lock (`bim.disable_editing_link`) saves and locks.
`bim.edit_link` and the explicit save step are **removed**; cancel/restore semantics
no longer exist (undo or move it back). The save math lives in
`tool.Project.save_link_transformation`. Enable/disable take a `link_index`
(default 1 = active link) so several links can be edited at once and script calls
stay compatible.
### Explore tool + append fixes for moved links
- Highlight triangles come from `mesh.calc_loop_triangles()` filtered to the queried
element's polygon range (ngon-safe); edges keep `polygon.edge_keys` (no diagonals).
- `get_selected_geometry` converts the ID-prop round trip to plain tuples (GPU
rejects `IDPropertyArray`); TRIS drawing gated on its own data.
- `QueryLinkedElement` passes the ray-cast instance matrix through;
`find_obj_root` compares it against `empty @ obj_local` and falls back to the
collection's only instance when no matrix is available (select-by-GUID flow).
- `bim.append_inspected_linked_element` pre-multiplies the imported object's matrix by
`calculate_link_delta_matrix(link)`, matching the link by the queried instance's
root empty first (filepath alone is ambiguous with several links per file). The
element's IFC placement syncs to the moved location on save — intended.
### Drawings (`create_drawing`) — moved links and per-link queries
- The linework serializer opened linked IFCs raw, so a moved link's elements were
drawn at their *original* coordinates (usually outside the drawing extents —
"linked objects disappear from prints after moving the link").
- The stored link transformation is already the **model-space** delta (that is how
`save_link_transformation` derives it), which is exactly the space the serializer
works in — so it can be baked straight into the geometry iterator via the existing
`model-offset`/`model-rotation` settings. The mapping composes
`Trans(model-offset) @ Rot(model-rotation)` (see `mapping.cpp`), matching the
`Trans(t) @ Rot(R)` decomposition of the rigid link matrix; `model-rotation` is a
quaternion passed as `(x, y, z, w)`. The pre-existing 2mm plan-view Z-offset simply
adds onto the translation (translations commute).
- The serialization loop previously collected files in a dict keyed by filepath, which
**collapsed same-file links into one pass** (one transform — the last link's — and
no query awareness): with two links of one file, only one showed in the drawing.
It now iterates one entry per link (`(path, file, transform, query)` tuples), and
intersects each link's drawing elements with
`ifcopenshell.util.selector.filter_elements(ifc, link.query)` so the drawing shows
what that link actually displays in the viewport.
- `tool.Project.get_link_transformation_matrix(link)` is the shared accessor for the
stored 4×4 (None when identity/absent).
- Verified headless with the window/door kit: moved window offset in the SVG by
exactly 5m × scale; unmoved door at its native position; both links present.
### Drawings — `.cut` styling for linked models (BISECT cut mode)
- The default **BISECT** cut mode deletes the OpenCASCADE serializer's cut linework
(`remove_cut_linework`) and regenerates cuts by bisecting **Blender mesh objects**
(`generate_bisect_linework` over `context.visible_objects`). Linked models are
instanced collections with no mesh objects, so their cuts were deleted and never
regenerated — linked elements only ever appeared as `projection`, and the `.cut`
CSS rule never applied to them. Long-standing gap, unrelated to moved links
(A/B-tested against pre-branch code: identical).
- Fix: `remove_cut_linework` only removes cut groups whose guid resolves in the
**host** file — linked elements keep the serializer's cut geometry, which the
merge step then classes as `cut`.
- **Cross-file STEP-id collision**: `tool.Ifc.get_object(linked_entity)` resolves the
entity's STEP id against the *host* session's id map and can return an arbitrary
host object (in the test project: the drawing camera, crashing
`generate_material_layers` with "expected 'Mesh' found 'Camera'"). Guarded via
`element.file is tool.Ifc.get()` in `generate_material_layers` and the merge step.
- **Paint order**: the projection-under-cut convention was enforced only in
OPENCASCADE mode (`move_projection_to_bottom`); BISECT appends its own cut paths
last so it never needed it — but the retained serializer cuts of linked models are
emitted *before* the projections. BISECT now runs the same pass; `BringToFront`
(`move_elements_to_top`) still gets the final say.
- Known limitation: linked cut paths are raw serializer output — they skip the
shapely path-closing/merging and the material-layer hatching pass (both need host
Blender objects). Stroke + fill from `.cut` CSS apply; layered hatching inside
linked cuts is a candidate follow-up.
- Debugging note: merged cut groups carry member guids as CSS *classes*, not as the
`ifcopenshell:guid` attribute — inspect both when checking cut output.
## Deferred refactors (deliberate)
- **Upstream `exclude=` on `filter_elements`** — the includeexclude set difference
is hand-rolled twice (links, drawings) because the selector grammar has no
difference operator and `parent` negation is broken by design (its `!=`/regex
paths also match GlobalIds, so negation strips everything that has a parent).
The right home is an `exclude=` parameter on
`ifcopenshell.util.selector.filter_elements`, documented in
`selector_syntax.rst` together with the `parent`-negation limitation. Deferred
to a separate ifcopenshell-python PR (different review audience; would widen
this PR mid-review). Once it lands, both Bonsai call sites collapse.
- **Core/tool ceremony skipped** — the new `tool.Project` methods have no
`core/tool.py` interface declarations and no `bonsai/core` orchestration
functions, matching the pre-existing linked-model code (which bypasses the
core layer wholesale; `LoadLinkedProject` is flagged "prototyping" upstream).
Interfaces nobody calls through wouldn't add testability — the pure helpers
(`encode_link_filter`/`decode_link_filter`, `get_link_cache_paths`) are
covered directly in `test/tool/test_project.py` instead. Revisit if the
linked-model subsystem is ever promoted out of prototype status.
## Review round 1 (PR #8242, falken10vdl) — decisions
- **Path-form mismatch → duplicate documents (confirmed bug, fixed).**
`get_linked_models_documents()` keyed documents by the *stored* `Location`, so
linking the same file first relative then absolute (or vice versa) created a second
`IfcDocumentInformation`. Both sides of the lookup now normalize through
`tool.Ifc.resolve_uri()` before matching.
- **`Description` for the query — kept.** It is implementation metadata in an IFC
attribute, but consistent with the existing convention on these same references
(`Identification` stores the 4×4 transformation, a bigger stretch). References are
Bonsai-managed (`Scope="LINKED_MODEL"`), so user-description collisions are unlikely.
A cleaner consolidated convention (query + transform + options in one serialized
attribute) is a candidate follow-up, deliberately out of scope here.
- **`md5(query)[:8]` — kept.** 32 bits ≈ birthday collision at ~65k distinct queries
*per file*; and a collision is not silent: the cache JSON stores the full query and
`should_clear_cache()` compares it, so a colliding cache is detected and rebuilt
(self-healing).
- **Depsgraph autosave vs save-on-lock — autosave kept.** Save-on-lock alone loses the
"what you see is what's saved" guarantee (move + save project without locking =
silently dropped move) and loses undo tracking (undo fires a depsgraph update that
re-saves the reverted transform). The handler early-outs when no links exist and only
works on ticks containing an object-transform update while a link is unlocked.
## Status — implemented (verified in Blender, incl. headless + GUI repro runs)
Six commits on `Linked_File_Features`:
- `0096c0f6a2` reload_link without a query preserves the stored one.
- `40db55e52d` external styles + layerset slicing for linked models
(`project/operator.py`, `tool/loader.py`).
- `d210d4c814` full Reload Link dialog + `bim.select_link_filepath`.
- `3dc161f0f2` per-row lock toggle, auto-save handler, `edit_link` removed
(`project/operator.py`, `project/ui.py`, `project/__init__.py`, `tool/project.py`).
- `0571d22855` Explore highlight (ngons, IDPropertyArray), moved-link highlight,
append placement (`tool/project.py`, `project/operator.py`, `project/decorator.py`).
- `c14592ec0a` per-query caches, Description persistence, SKIP_SAVE.
Plus:
- `ee43ed5526` review-round path normalization in `get_linked_models_documents` /
`LinkIfc` (see Review round 1).
- `1669cbcd43` drawing support for moved links and per-link queries in
`create_drawing` (`drawing/operator.py`, `tool/project.py`).
- `.cut` styling for linked models in BISECT cut mode + STEP-id collision guards +
paint order (`drawing/operator.py`) — committed together with this note update.
End-to-end verified with a two-links-one-file kit (window/door, distinct queries):
correct visuals on load, after save → reopen → reload, in both headless and windowed
Blender.
## Things to test / verify
- **IFC2X3 host**: `Description` doesn't exist — link queries silently not restored on
reopen (legacy fallback only for single-link files). Acceptable? Warn?
- **Relative-path links** (`use_relative_path`) through the whole cycle: cache paths,
reference `Location`, reload path change, query restore. The duplicate-document case
(same file linked relative then absolute) is fixed — verify one document with two
references via `IfcDocumentInformation.HasDocumentReferences`.
- Same file linked twice, **both moved differently**: Explore highlight and append
placement per instance (root-empty matching), per-link visibility toggles.
- External styles with **image textures**: paths relative to the style's source
`.blend` may not resolve from the cache blend's location (shared limitation with the
normal import path).
- Stale cache orphans: per-query filenames accumulate one blend+json pair per distinct
query next to the IFC; nothing auto-deletes them. Cleanup on unlink? Document?
- Mid-drag auto-save writes the IFC reference outside Bonsai's transaction system —
confirm no undo-stack weirdness in longer editing sessions.
- Layerset slicing on meshes shared by elements with *different* usages (offset/sense)
bakes the first element's slice — same as normal import, but worth a look with types.
- `bim.select_link_filepath` round trip when the reload dialog was opened for a
non-active link, and dialog-state carry-over after editing the query *then* browsing.
- **Drawing SVG guid cache vs moved links**: `create_drawing` skips elements whose
guids already exist in the drawing's SVG (`cached_linework`, invalidated only for
*edited host objects*). Moving a link does not invalidate its elements, so a
regenerated drawing keeps their old positions until the SVG is deleted. Candidate
fix: subtract a moved link's guids from `cached_linework` (compare stored transform
against the one recorded at last generation).
- Same element appearing in two links of one file (overlapping queries) serializes
twice with different transforms; the SVG guid cache keeps whichever came first on
regeneration. Degenerate case — probably fine to ignore, but note it.
@@ -602,7 +602,7 @@ class CreateDrawing(bpy.types.Operator):
context_type: Literal["body", "annotation"],
drawing_elements: set[ifcopenshell.entity_instance],
target_view: str,
link_matrix: Optional[Matrix] = None,
link_transform: Optional[np.ndarray] = None,
) -> None:
drawing_elements = drawing_elements.copy()
contexts_: list[list[int]] = getattr(contexts, context_type)
@@ -614,19 +614,22 @@ class CreateDrawing(bpy.types.Operator):
geom_settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS)
geom_settings.set("iterator-output", ifcopenshell.ifcopenshell_wrapper.NATIVE)
is_plan = ifc.by_id(context[0]).ContextType == "Plan" and "PLAN_VIEW" in target_view
z_offset = (0.002 if target_view == "PLAN_VIEW" else -0.002) if is_plan else 0.0
if link_matrix is not None:
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc)
t = link_matrix.to_translation()
offset = (t.x / unit_scale, t.y / unit_scale, t.z / unit_scale + z_offset)
geom_settings.set("model-offset", offset)
q = link_matrix.to_quaternion()
geom_settings.set("model-rotation", (q.x, q.y, q.z, q.w))
elif z_offset:
offset = np.zeros(3)
if ifc.by_id(context[0]).ContextType == "Plan" and "PLAN_VIEW" in target_view:
# A 2mm Z offset to combat Z-fighting in plan or RCPs
geom_settings.set("model-offset", (0.0, 0.0, z_offset))
offset[2] = 0.002 if target_view == "PLAN_VIEW" else -0.002
if link_transform is not None:
# Bake a moved link's transformation into the geometry. The
# mapping composes Trans(model-offset) @ Rot(model-rotation),
# matching the Trans(t) @ Rot(R) decomposition of the rigid
# link matrix, so the Z offset above simply adds on.
offset += link_transform[:3, 3]
quaternion = Matrix(link_transform.tolist()).to_quaternion()
geom_settings.set(
"model-rotation", (quaternion.x, quaternion.y, quaternion.z, quaternion.w)
)
if offset.any():
geom_settings.set("model-offset", tuple(float(o) for o in offset))
geom_settings.set("context-ids", context)
it = ifcopenshell.geom.iterator(
@@ -679,6 +682,10 @@ class CreateDrawing(bpy.types.Operator):
if "projection" in el.get("class", "").split():
continue
element = self.get_element_by_guid(el.get("{http://www.ifcopenshell.org/ns}guid"))
if element is None or element.file is not tool.Ifc.get():
# Linked model element - no Blender object to bisect, and its
# STEP id must not be resolved against the host session.
continue
if not (obj := tool.Ifc.get_object(element)):
continue
if not (material := ifcopenshell.util.element.get_material(element)):
@@ -934,16 +941,25 @@ class CreateDrawing(bpy.types.Operator):
bim_props = tool.Blender.get_bim_props()
prefs = tool.Blender.get_addon_preferences()
# Map ifc_path → (ifc_file, link_matrix); main file has no link_matrix (None)
files: dict[str, tuple[ifcopenshell.file, Optional[Matrix]]] = {bim_props.ifc_file: (tool.Ifc.get(), None)}
props = tool.Project.get_project_props()
# One entry per file *and* per link - the same file can be linked
# several times with different queries and transformations, so links
# cannot be collapsed into a dict keyed by filepath.
# Each entry is (path, file, link transformation or None, link query, link exclude).
file_entries: list[tuple[str, ifcopenshell.file, Optional[np.ndarray], str, str]] = [
(bim_props.ifc_file, tool.Ifc.get(), None, "", "")
]
for link in props.get_loaded_links_for_drawings():
try:
link_matrix = tool.Project.calculate_link_matrix(link)
except Exception:
link_matrix = None
files[link.filepath] = (self.get_linked_file(link), link_matrix)
file_entries.append(
(
link.filepath,
self.get_linked_file(link),
tool.Project.get_link_transformation_matrix(link),
link.query,
link.exclude,
)
)
target_view = ifcopenshell.util.element.get_psets(self.camera_element)["EPset_Drawing"]["TargetView"]
self.setup_serialiser(target_view)
@@ -951,7 +967,13 @@ class CreateDrawing(bpy.types.Operator):
tree = ifcopenshell.geom.tree()
tree.enable_face_styles(True)
for ifc_path, (ifc, link_matrix) in files.items():
# Accumulated across every file in the loop below (main model plus any
# linked models) so the SHAPELY fill pass after the loop covers all of
# them, not just whichever file happened to be processed last.
raycast_objs = set()
elements_with_faces = set()
for ifc_path, ifc, link_transform, link_query, link_exclude in file_entries:
# Don't use draw.main() just whilst we're prototyping and experimenting
# TODO: hash paths are never used
ifc_hash = hashlib.md5(ifc_path.encode("utf-8")).hexdigest()
@@ -959,14 +981,32 @@ class CreateDrawing(bpy.types.Operator):
self.serialiser.setFile(ifc)
drawing_elements = tool.Drawing.get_drawing_elements(self.camera_element, ifc_file=ifc)
if link_query:
# Draw only what the link's selector filter loaded in the viewport.
drawing_elements &= ifcopenshell.util.selector.filter_elements(ifc, link_query)
if link_exclude:
drawing_elements -= ifcopenshell.util.selector.filter_elements(ifc, link_exclude)
if self.cprops.fill_mode == "SHAPELY":
for element in drawing_elements.copy():
if element.is_a("IfcAnnotation"):
continue
obj = tool.Ifc.get_object(element)
if obj and obj.type == "MESH" and len(obj.data.polygons):
elements_with_faces.add(element.GlobalId)
raycast_objs.add(obj)
# Get all representation contexts to see what we're dealing with.
# Drawings only draw bodies and annotations (and facetation, due to a Revit bug).
# A drawing prioritises a target view context first, followed by a model view context as a fallback.
# Specifically for PLAN_VIEW and REFLECTED_PLAN_VIEW, any Plan context is also prioritised.
contexts = self.get_linework_contexts(ifc, target_view)
self.serialize_contexts_elements(ifc, tree, contexts, "body", drawing_elements, target_view, link_matrix)
self.serialize_contexts_elements(ifc, tree, contexts, "annotation", drawing_elements, target_view, link_matrix)
self.serialize_contexts_elements(
ifc, tree, contexts, "body", drawing_elements, target_view, link_transform
)
self.serialize_contexts_elements(
ifc, tree, contexts, "annotation", drawing_elements, target_view, link_transform
)
if tool.Ifc.get() == ifc and self.camera_element not in drawing_elements:
with profile("Camera element"):
@@ -1021,6 +1061,10 @@ class CreateDrawing(bpy.types.Operator):
if self.cprops.generate_material_layers:
self.generate_material_layers(context, root)
self.merge_linework_and_add_metadata(root)
# Bisect cut linework is appended after the projections, but the
# retained serializer cuts of linked models precede them - enforce
# the projection-under-cut convention like OPENCASCADE mode does.
self.move_projection_to_bottom(root)
self.move_elements_to_top(root)
elif self.cprops.cut_mode == "OPENCASCADE":
self.move_projection_to_bottom(root)
@@ -1033,16 +1077,6 @@ class CreateDrawing(bpy.types.Operator):
# shapely variant
group = root.find("{http://www.w3.org/2000/svg}g")
raycast_objs = set()
elements_with_faces = set()
for element in drawing_elements.copy():
if element.is_a("IfcAnnotation"):
continue
obj = tool.Ifc.get_object(element)
if obj and obj.type == "MESH" and len(obj.data.polygons):
elements_with_faces.add(element.GlobalId)
raycast_objs.add(obj)
projections = root.xpath(
".//svg:g[contains(@class, 'projection')]", namespaces={"svg": "http://www.w3.org/2000/svg"}
)
@@ -1426,9 +1460,21 @@ class CreateDrawing(bpy.types.Operator):
continue
def remove_cut_linework(self, root):
"""Remove host elements' cut linework so bisecting can regenerate it.
Linked model elements keep the serializer's cut geometry - bisect
linework is generated from Blender mesh objects, and linked models
are instanced collections without any.
"""
ifc_file = tool.Ifc.get()
for el in root.findall(".//{http://www.w3.org/2000/svg}g[@{http://www.ifcopenshell.org/ns}guid]"):
if "projection" not in el.get("class", "").split():
el.getparent().remove(el)
if "projection" in el.get("class", "").split():
continue
try:
ifc_file.by_guid(el.get("{http://www.ifcopenshell.org/ns}guid"))
except RuntimeError:
continue # Linked model element.
el.getparent().remove(el)
def merge_linework_and_add_metadata(self, root):
join_criteria = ifcopenshell.util.element.get_pset(self.camera_element, "EPset_Drawing", "JoinCriteria")
@@ -1463,7 +1509,9 @@ class CreateDrawing(bpy.types.Operator):
classes.append("cut")
el.set("class", " ".join(classes))
obj = tool.Ifc.get_object(element)
# Resolving a linked element's STEP id against the host session
# would return an arbitrary host object.
obj = tool.Ifc.get_object(element) if element is not None and element.file is tool.Ifc.get() else None
if not obj: # This is a linked model object. For now, do nothing.
continue
+6 -3
View File
@@ -329,6 +329,7 @@ class _ArrayEditMixin(ParametricEditMixinBase):
# Unhide the (possibly newly-regenerated) children so the user sees
# the committed result. Mirrors the hide in ``_enable_one``.
cls._set_children_visibility(element, hidden=False)
tool.Array.select_only_parent(obj, context)
@classmethod
def _cancel_one(cls, obj: bpy.types.Object) -> None:
@@ -421,9 +422,9 @@ class RegenerateArray(bpy.types.Operator, tool.Ifc.Operator):
pset = ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array")
arrays = json.loads(pset["Data"])
pset = tool.Ifc.get().by_id(pset["id"])
# Coalesce host recuts: the child-delete loop, the regenerate, and the
# per-child opening mirror all touch the same host body. Without batching,
# an N-child wipe-then-regen costs N+1 recuts; this collapses to one.
# Coalesce host recuts across the child-delete loop, the regenerate,
# and the per-child opening mirror: each fans out its own host body
# recut without the batch wrapper.
with tool.Geometry.batch_host_recut():
for array in arrays:
for child in set(array["children"]):
@@ -442,6 +443,8 @@ class RegenerateArray(bpy.types.Operator, tool.Ifc.Operator):
tool.Model.regenerate_array(parent, arrays)
tool.Array.constrain_children_to_parent(parent_element)
tool.Array.select_only_parent(parent, context)
class RemoveArray(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.remove_array"
@@ -1677,6 +1677,11 @@ def _n_mep_selected(n: int) -> bool:
element = tool.Ifc.get_entity(selected_obj)
if element is None or not tool.System.is_mep_element(element):
return False
# Array children mirror their parent's port topology. Writable MEP
# actions on a child get wiped by the next array regen, so gate the
# icons out at the visibility layer.
if tool.Array.is_array_child(element):
return False
return True
@@ -2555,6 +2560,8 @@ def _active_is_flow_segment(obj: bpy.types.Object) -> bool:
element = tool.Ifc.get_entity(obj)
if element is None or not element.is_a("IfcFlowSegment"):
return False
if tool.Array.is_array_child(element):
return False
return tool.System.has_parametric_body(element)
@@ -2584,6 +2591,8 @@ def _active_is_bend_fitting(obj: bpy.types.Object) -> bool:
element = tool.Ifc.get_entity(obj)
if not _is_bend_fitting(element):
return False
if tool.Array.is_array_child(element):
return False
element_type = ifcopenshell.util.element.get_type(element)
if element_type is None:
return False
@@ -45,7 +45,6 @@ classes = (
operator.DisableEditingHeader,
operator.DisableEditingLink,
operator.EditHeader,
operator.EditLink,
operator.EditProjectLibrary,
operator.EnableCulling,
operator.EnableEditingHeader,
@@ -67,6 +66,7 @@ classes = (
operator.QueryLinkedElement,
operator.RefreshClippingPlanes,
operator.RefreshLibrary,
operator.ReloadAllLinks,
operator.ReloadLink,
operator.RemoveProjectLibrary,
operator.RevertProject,
@@ -74,6 +74,7 @@ classes = (
operator.SaveLibraryFile,
operator.SelectLibraryFile,
operator.SelectLinkedModelElement,
operator.SelectLinkFilepath,
operator.SelectLinkHandle,
operator.ToggleFilterCategories,
operator.ToggleLinkSelectability,
@@ -109,12 +110,45 @@ classes = (
addon_keymaps = []
@bpy.app.handlers.persistent
def _autosave_link_transforms(scene, depsgraph):
"""Persist link transformations whenever an editing link's handle is moved.
Deliberate exemption from the transaction rule in
docs/guides/development/undo_system.rst: a handler cannot run inside
execute_ifc_operator, so this IFC write is not undo-tracked. It stays
consistent anyway because undoing the move fires another depsgraph
update, which re-saves the reverted matrix.
"""
import bonsai.tool as tool
props = tool.Project.get_project_props()
if not props.links:
return
handles = None
for update in depsgraph.updates:
if not update.is_updated_transform or not isinstance(update.id, bpy.types.Object):
continue
if handles is None:
# Built lazily so ticks without transform updates stay cheap.
handles = {}
for link in props.links:
if link.is_loaded and link.is_editing and (handle := tool.Project.get_link_empty_handle(link)):
handles[handle] = link
if not handles:
return
if link := handles.get(update.id.original):
tool.Project.save_link_transformation(link)
def register():
if not bpy.app.background:
bpy.utils.register_tool(workspace.ExploreTool, after={"builtin.transform"}, separator=True, group=False)
bpy.types.Scene.BIMProjectProperties = bpy.props.PointerProperty(type=prop.BIMProjectProperties)
bpy.types.Scene.MeasureToolSettings = bpy.props.PointerProperty(type=prop.MeasureToolSettings)
bpy.app.handlers.load_post.append(decorator.toggle_decorations_on_load)
if _autosave_link_transforms not in bpy.app.handlers.depsgraph_update_post:
bpy.app.handlers.depsgraph_update_post.append(_autosave_link_transforms)
bpy.types.TOPBAR_MT_file_import.append(ui.file_import_menu)
bpy.types.TOPBAR_MT_file.prepend(ui.file_menu)
bpy.types.TOPBAR_MT_file_context_menu.prepend(ui.file_menu)
@@ -139,6 +173,8 @@ def unregister():
del bpy.types.Scene.BIMProjectProperties
del bpy.types.Scene.MeasureToolSettings
bpy.app.handlers.load_post.remove(decorator.toggle_decorations_on_load)
if _autosave_link_transforms in bpy.app.handlers.depsgraph_update_post:
bpy.app.handlers.depsgraph_update_post.remove(_autosave_link_transforms)
bpy.types.TOPBAR_MT_file.remove(ui.file_menu)
bpy.types.TOPBAR_MT_file_context_menu.remove(ui.file_menu)
@@ -99,6 +99,7 @@ class ProjectDecorator:
if geom.selected_edges:
self.draw_batch("LINES", selected_vertices, selected_elements_color, geom.selected_edges)
if geom.selected_tris:
self.draw_batch(
"TRIS", selected_vertices, tool.Blender.transparent_color(selected_elements_color), geom.selected_tris
)
+393 -98
View File
@@ -1294,6 +1294,11 @@ class LoadProjectElements(bpy.types.Operator):
if element.IsDecomposedBy:
for subelement in element.IsDecomposedBy[0].RelatedObjects:
decomposed_elements.add(subelement)
# IfcSurfaceFeature (e.g. road markings) adhere to a host element
# via IfcRelAdheresToElement, a [1:1] hierarchical relationship in
# the same family as aggregation, containment and nesting (IFC4.3).
for rel in getattr(element, "HasSurfaceFeatures", ()):
decomposed_elements.update(rel.RelatedSurfaceFeatures)
if decomposed_elements:
self.append_decomposed_elements(decomposed_elements)
elements.update(decomposed_elements)
@@ -1355,10 +1360,18 @@ class LinkIfc(bpy.types.Operator, ImportHelper, tool.Ifc.Operator):
)
use_cache: bpy.props.BoolProperty(name="Use Cache", default=True)
query: bpy.props.StringProperty(
name="Query",
name="Include",
description=(
"Custom selector query to use to load element from a linked model. E.g. 'IfcElement'.\n\n"
"Default query - IfcElement, but excluding IfcProxy, IfcSpatialStructureElement, IfcSpatialElement, IfcFeatureElement."
"Selector query for the elements to load from the linked model. E.g. 'IfcElement'.\n\n"
"Default when empty - IfcElement, but excluding IfcProxy, IfcSpatialStructureElement, IfcSpatialElement, IfcFeatureElement."
),
)
exclude: bpy.props.StringProperty(
name="Exclude",
description=(
"Selector query whose matches are excluded from the loaded elements.\n\n"
"Applied on top of the query (or the default set), providing the set "
"difference a single query cannot express. E.g. 'IfcSlab, parent=\"X\"'."
),
)
@@ -1372,6 +1385,7 @@ class LinkIfc(bpy.types.Operator, ImportHelper, tool.Ifc.Operator):
use_relative_path: bool
use_cache: bool
query: str
exclude: str
def draw(self, context):
assert self.layout
@@ -1390,6 +1404,7 @@ class LinkIfc(bpy.types.Operator, ImportHelper, tool.Ifc.Operator):
row = self.layout.row()
row.prop(pprops, "project_north")
self.layout.prop(self, "query", placeholder="IfcElement")
self.layout.prop(self, "exclude", placeholder='IfcSlab, parent="..."')
def _execute(self, context):
start = time.time()
@@ -1412,18 +1427,26 @@ class LinkIfc(bpy.types.Operator, ImportHelper, tool.Ifc.Operator):
new = props.links.add()
if tool.Ifc.get():
if not (document := existing_links.get(filepath)):
# Look up by resolved absolute path so a file already linked
# with a relative Location (or vice versa) reuses its document.
resolved_filepath = Path(tool.Ifc.resolve_uri(filepath)).as_posix()
if not (document := existing_links.get(resolved_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("\\", "/")
# Persist the filter per reference (Description is IFC4+ only).
description = tool.Project.encode_link_filter(self.query, self.exclude, loaded=True)
if description and hasattr(reference, "Description"):
reference.Description = description
new.ifc_definition_id = reference.id()
new.name = filepath
new.filepath = filepath
new.query = self.query
bpy.ops.bim.load_link(link_index=-1, use_cache=self.use_cache, query=self.query)
new.exclude = self.exclude
bpy.ops.bim.load_link(link_index=-1, use_cache=self.use_cache, query=self.query, exclude=self.exclude)
class UnlinkIfc(bpy.types.Operator, tool.Ifc.Operator):
@@ -1482,21 +1505,28 @@ class LoadLink(bpy.types.Operator, tool.Ifc.Operator):
bl_options = {"REGISTER", "UNDO"}
bl_description = "Load the selected file"
# SKIP_SAVE: Blender reuses an operator's last-used property values on the
# next interactive invocation, which would leak one link's query/cache
# settings into another link's load.
link_index: bpy.props.IntProperty(name="Link Index")
use_cache: bpy.props.BoolProperty(name="Use Cache", default=True)
query: bpy.props.StringProperty()
use_cache: bpy.props.BoolProperty(name="Use Cache", default=True, options={"SKIP_SAVE"})
query: bpy.props.StringProperty(options={"SKIP_SAVE"})
exclude: bpy.props.StringProperty(options={"SKIP_SAVE"})
if TYPE_CHECKING:
link_index: int
use_cache: bool
query: str
exclude: str
def _execute(self, context):
self.link = tool.Project.get_project_props().links[self.link_index]
# Fall back to the Link's stored query so callers that omit it
# Fall back to the Link's stored filter so callers that omit it
# still replay the filter the link was created with.
if not self.query and self.link.query:
self.query = self.link.query
if not self.exclude and self.link.exclude:
self.exclude = self.link.exclude
filepath = Path(tool.Ifc.resolve_uri(self.link.filepath))
if not filepath.exists():
self.report({"ERROR"}, f"File does not exist: '{filepath}'")
@@ -1533,22 +1563,21 @@ class LoadLink(bpy.types.Operator, tool.Ifc.Operator):
self.link.is_loaded = False
def link_ifc(self) -> Union[set[str], None]:
blend_filepath = self.filepath_.with_suffix(".ifc.cache.blend")
h5_filepath = self.filepath_.with_suffix(".ifc.cache.h5")
json_filepath = self.filepath_.with_suffix(".ifc.cache.json")
blend_filepath, json_filepath = tool.Project.get_link_cache_paths(self.filepath_, self.query, self.exclude)
def should_clear_cache() -> bool:
if not self.use_cache:
return True
if not blend_filepath.exists():
return False
if not json_filepath.exists():
return True
data = json.loads(json_filepath.read_text())
# Empty 'query' - model loaded without custom query.
# Missing 'query' - model was loaded before custom queries were introduced in Bonsai.
query = data.get("query", "")
return query != self.query
return data.get("query", "") != self.query or data.get("exclude", "") != self.exclude
if should_clear_cache():
if should_clear_cache() and blend_filepath.exists():
os.remove(blend_filepath)
if not blend_filepath.exists():
@@ -1576,7 +1605,7 @@ def run():
pprops.project_north = "{pprops.project_north}"
# Use absolute path to be safe from cwd changes.
try:
bpy.ops.bim.load_linked_project(filepath=r"{str(self.filepath_)}", query={repr(self.query)})
bpy.ops.bim.load_linked_project(filepath=r"{str(self.filepath_)}", query={repr(self.query)}, exclude={repr(self.exclude)})
except RuntimeError as e:
# Operator failed (returned CANCELLED with error report)
print(f"Failed to load linked project: {{e}}")
@@ -1625,7 +1654,7 @@ except Exception as e:
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")
json_filepath = tool.Project.get_link_cache_paths(self.filepath_, self.query, self.exclude)[1]
if not json_filepath.exists():
return
@@ -1644,8 +1673,7 @@ except Exception as e:
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")
json_filepath = tool.Project.get_link_cache_paths(self.filepath_, self.query, self.exclude)[1]
if not json_filepath.exists():
self.link.georeferenced = "NONE"
return
@@ -1657,43 +1685,211 @@ except Exception as e:
self.link.georeferenced = "FULL_COMPATIBLE" if crs_name == data["model_crs"] else "NOT_COMPATIBLE"
class ReloadLink(bpy.types.Operator):
class ReloadLink(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.reload_link"
bl_label = "Reload Link"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Reload the selected file"
bl_description = "Reload the selected file, optionally changing its file path and load options"
# SKIP_SAVE: this operator distinguishes "provided" from "unset" properties
# via is_property_set, so last-used property retention between interactive
# invocations would leak one link's settings into another's reload.
link_index: bpy.props.IntProperty(name="Link Index")
filepath: bpy.props.StringProperty(
name="File Path",
description="Path to the linked IFC file",
options={"SKIP_SAVE"},
)
use_relative_path: bpy.props.BoolProperty(
name="Use Relative Path",
description="Whether to store linked model path relative to the currently opened IFC file.",
default=False,
options={"SKIP_SAVE"},
)
use_cache: bpy.props.BoolProperty(
name="Use Cache",
description="Reuse the cached geometry if it's still valid instead of reprocessing the IFC",
default=False,
options={"SKIP_SAVE"},
)
query: bpy.props.StringProperty(
name="Query",
name="Include",
description=(
"Custom selector query to use to load element from a linked model. E.g. 'IfcElement'.\n\n"
"Default query - IfcElement, but excluding IfcProxy, IfcSpatialStructureElement, IfcSpatialElement, IfcFeatureElement."
"Selector query for the elements to load from the linked model. E.g. 'IfcElement'.\n\n"
"Default when empty - IfcElement, but excluding IfcProxy, IfcSpatialStructureElement, IfcSpatialElement, IfcFeatureElement."
),
options={"SKIP_SAVE"},
)
exclude: bpy.props.StringProperty(
name="Exclude",
description=(
"Selector query whose matches are excluded from the loaded elements.\n\n"
"Applied on top of the query (or the default set), providing the set "
"difference a single query cannot express. E.g. 'IfcSlab, parent=\"X\"'."
),
options={"SKIP_SAVE"},
)
if TYPE_CHECKING:
link_index: int
filepath: str
use_relative_path: bool
use_cache: bool
query: str
exclude: str
def invoke(self, context, event):
link = tool.Project.get_project_props().links[self.link_index]
self.query = link.query
# Properties may arrive pre-set when the dialog is reopened
# by bim.select_link_filepath - don't clobber them.
if not self.properties.is_property_set("filepath"):
self.filepath = link.filepath
if not self.properties.is_property_set("use_relative_path"):
self.use_relative_path = not Path(link.filepath).is_absolute()
if not self.properties.is_property_set("query"):
self.query = link.query
if not self.properties.is_property_set("exclude"):
self.exclude = link.exclude
return context.window_manager.invoke_props_dialog(self)
def draw(self, context):
assert self.layout
pprops = tool.Project.get_project_props()
row = self.layout.row(align=True)
row.prop(self, "filepath")
op = row.operator("bim.select_link_filepath", text="", icon="FILEBROWSER")
op.link_index = self.link_index
# Carry the current dialog state through the file browser round-trip.
op.use_relative_path = self.use_relative_path
op.use_cache = self.use_cache
op.query = self.query
op.exclude = self.exclude
row = self.layout.row()
row.prop(self, "use_relative_path")
row = self.layout.row()
row.prop(self, "use_cache")
row = self.layout.row()
row.label(text="False Origin Mode:")
row = self.layout.row()
row.prop(pprops, "false_origin_mode", text="")
if pprops.false_origin_mode == "MANUAL":
row = self.layout.row()
row.prop(pprops, "false_origin")
row = self.layout.row()
row.prop(pprops, "project_north")
self.layout.prop(self, "query", placeholder="IfcElement")
self.layout.prop(self, "exclude", placeholder='IfcSlab, parent="..."')
def execute(self, context):
def _execute(self, context):
link = tool.Project.get_project_props().links[self.link_index]
# An unset query means the operator was called without the dialog
# (e.g. from a script) - preserve the link's stored query instead
# of overwriting it with the empty default.
# Unset properties mean the operator was called without the dialog
# (e.g. from a script) - preserve the link's stored values instead
# of overwriting them with the defaults.
if self.properties.is_property_set("query"):
link.query = self.query
if self.properties.is_property_set("exclude"):
link.exclude = self.exclude
filepath = self.filepath if self.properties.is_property_set("filepath") else link.filepath
if self.properties.is_property_set("use_relative_path"):
use_relative_path = self.use_relative_path
else:
use_relative_path = not Path(link.filepath).is_absolute()
abs_filepath = Path(tool.Ifc.resolve_uri(filepath))
if not abs_filepath.exists():
self.report({"ERROR"}, f"File does not exist: '{abs_filepath}'")
return {"CANCELLED"}
filepath = tool.Ifc.get_uri(abs_filepath, use_relative_path=use_relative_path)
if filepath != link.filepath:
link.name = filepath
link.filepath = filepath
if tool.Ifc.get() and link.ifc_definition_id:
reference = tool.Ifc.get().by_id(link.ifc_definition_id)
reference.Location = filepath.replace("\\", "/")
if document := tool.Document.get_reference_document(reference):
document.Name = Path(filepath).name
if tool.Ifc.get() and link.ifc_definition_id:
reference = tool.Ifc.get().by_id(link.ifc_definition_id)
if hasattr(reference, "Description"):
reference.Description = tool.Project.encode_link_filter(
link.query, link.exclude, loaded=True, display_name=link.display_name
)
bpy.ops.bim.unload_link(link_index=self.link_index)
return bpy.ops.bim.load_link(link_index=self.link_index, use_cache=False, query=link.query) or {"FINISHED"}
return bpy.ops.bim.load_link(
link_index=self.link_index, use_cache=self.use_cache, query=link.query, exclude=link.exclude
) or {"FINISHED"}
class ReloadAllLinks(bpy.types.Operator):
bl_idname = "bim.reload_all_links"
bl_label = "Reload All Links"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Reload all loaded linked models from disk, rebuilding their caches"
@classmethod
def poll(cls, context):
if not any(link.is_loaded for link in tool.Project.get_project_props().links):
cls.poll_message_set("No loaded links to reload.")
return False
return True
def execute(self, context):
props = tool.Project.get_project_props()
reloaded = 0
for i, link in enumerate(props.links):
if not link.is_loaded:
continue
# Called without filter properties, reload_link preserves each
# link's stored path, query and exclude.
bpy.ops.bim.reload_link(link_index=i)
reloaded += 1
self.report({"INFO"}, f"Reloaded {reloaded} linked model(s).")
return {"FINISHED"}
class SelectLinkFilepath(bpy.types.Operator):
bl_idname = "bim.select_link_filepath"
bl_label = "Select Link File Path"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
bl_description = "Select a new file path for the linked model and return to the reload dialog"
link_index: bpy.props.IntProperty(name="Link Index")
filepath: bpy.props.StringProperty(subtype="FILE_PATH", options={"SKIP_SAVE", "HIDDEN"})
filter_glob: bpy.props.StringProperty(default="*.ifc", options={"HIDDEN"})
# Reload dialog state carried through the file browser round-trip.
use_relative_path: bpy.props.BoolProperty(options={"HIDDEN"})
use_cache: bpy.props.BoolProperty(options={"HIDDEN"})
query: bpy.props.StringProperty(options={"HIDDEN"})
exclude: bpy.props.StringProperty(options={"HIDDEN"})
if TYPE_CHECKING:
link_index: int
filepath: str
filter_glob: str
use_relative_path: bool
use_cache: bool
query: str
exclude: str
def invoke(self, context, event):
link = tool.Project.get_project_props().links[self.link_index]
self.filepath = tool.Ifc.resolve_uri(link.filepath)
context.window_manager.fileselect_add(self)
return {"RUNNING_MODAL"}
def execute(self, context):
bpy.ops.bim.reload_link(
"INVOKE_DEFAULT",
link_index=self.link_index,
filepath=self.filepath,
use_relative_path=self.use_relative_path,
use_cache=self.use_cache,
query=self.query,
exclude=self.exclude,
)
return {"FINISHED"}
class ToggleLinkSelectability(bpy.types.Operator):
@@ -1711,7 +1907,7 @@ class ToggleLinkSelectability(bpy.types.Operator):
props = tool.Project.get_project_props()
link = props.links[self.link_index]
self.library_filepath = tool.Blender.ensure_blender_path_is_abs(
Path(link.filepath).with_suffix(".ifc.cache.blend")
tool.Project.get_link_cache_paths(link.filepath, link.query, link.exclude)[0]
)
link.is_selectable = (is_selectable := not link.is_selectable)
for collection in self.get_linked_collections():
@@ -1748,7 +1944,7 @@ class ToggleLinkVisibility(bpy.types.Operator):
props = tool.Project.get_project_props()
link = props.links[self.link_index]
self.library_filepath = tool.Blender.ensure_blender_path_is_abs(
Path(link.filepath).with_suffix(".ifc.cache.blend")
tool.Project.get_link_cache_paths(link.filepath, link.query, link.exclude)[0]
)
if self.mode == "WIREFRAME":
self.toggle_wireframe(link)
@@ -1790,10 +1986,16 @@ 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"
bl_description = "Unlock the link's position for editing. Any movement is saved automatically"
link_index: bpy.props.IntProperty(name="Link Index", default=-1)
if TYPE_CHECKING:
link_index: int
def execute(self, context):
link = tool.Project.get_project_props().active_link
props = tool.Project.get_project_props()
link = props.active_link if self.link_index == -1 else props.links[self.link_index]
assert link
link.is_editing = True
obj = tool.Project.get_link_empty_handle(link)
@@ -1802,70 +2004,25 @@ class EnableEditingLink(bpy.types.Operator):
return {"FINISHED"}
class DisableEditingLink(bpy.types.Operator):
class DisableEditingLink(bpy.types.Operator, tool.Ifc.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"
bl_description = "Lock the link at its current location"
def execute(self, context):
link = tool.Project.get_project_props().active_link
assert link
link.is_editing = False
obj = tool.Project.get_link_empty_handle(link)
assert obj
obj.matrix_world = tool.Project.calculate_link_matrix(link)
tool.Geometry.lock_object(obj)
return {"FINISHED"}
link_index: bpy.props.IntProperty(name="Link Index", default=-1)
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"
if TYPE_CHECKING:
link_index: int
def _execute(self, context):
link = tool.Project.get_project_props().active_link
props = tool.Project.get_project_props()
link = props.active_link if self.link_index == -1 else props.links[self.link_index]
assert link
link.is_editing = False
obj = tool.Project.get_link_empty_handle(link)
assert obj
new_obj_matrix = obj.matrix_world
filepath = Path(tool.Ifc.resolve_uri(link.filepath))
with open(filepath.with_suffix(".ifc.cache.json"), "r") as f:
metadata = json.load(f)
rot = ifcopenshell.util.shape_builder.np_rotation_matrix(
radians(-float(metadata["model_project_north"])), 4, "Z"
)
global_matrix = rot @ np.eye(4)
global_matrix[:, 3][:3] = [float(o) for o in metadata["model_origin_si"].split(",")]
gprops = tool.Georeference.get_georeference_props()
rot = ifcopenshell.util.shape_builder.np_rotation_matrix(radians(-float(gprops.model_project_north)), 4, "Z")
local_matrix = rot @ np.eye(4)
local_matrix[:, 3][:3] = [float(o) for o in gprops.model_origin_si.split(",")]
# obj_matrix is typically calculated as:
# obj_matrix = np.linalg.inv(local_matrix) @ transformation @ global_matrix
identity_blender_matrix = np.linalg.inv(local_matrix) @ global_matrix
if np.allclose(np.array(new_obj_matrix), identity_blender_matrix, atol=1e-5):
link.has_transformation = False
transformation = ",".join(map(str, np.eye(4).reshape(-1)))
else:
transformed_global_matrix = local_matrix @ np.array(new_obj_matrix)
transformation = transformed_global_matrix @ np.linalg.inv(global_matrix)
link.has_transformation = True
transformation = ",".join(map(str, transformation.reshape(-1)))
if tool.Ifc.get():
reference = tool.Ifc.get().by_id(link.ifc_definition_id)
reference[1] = transformation
else:
link.transformation = transformation
tool.Project.save_link_transformation(link)
obj.matrix_world = tool.Project.calculate_link_matrix(link)
tool.Geometry.lock_object(obj)
@@ -2007,6 +2164,8 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
# gizmo polls gate on each preview's is_active flag, and a stuck flag
# persisted through the save would silently hide them on reload.
preview_base.discard_pending_previews(context.scene)
# Links loaded and visible right now auto-load on the next open.
tool.Project.update_linked_models_state()
# Suffix is appended to the IFC save-success report below so the auto-commit
# info isn't immediately overwritten by the success message in Blender's
# status bar (only the latest self.report({"INFO"}, ...) sticks).
@@ -2114,14 +2273,23 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper):
query: bpy.props.StringProperty()
"""See ``bim.link_ifc``."""
exclude: bpy.props.StringProperty()
"""See ``bim.link_ifc``."""
if TYPE_CHECKING:
query: str
exclude: str
file: ifcopenshell.file
meshes: dict[str, bpy.types.Mesh]
# Material names is derived from diffuse as in 'r-g-b-a'.
blender_mats: dict[str, bpy.types.Material]
# Materials appended from external .blend styles, keyed by style id.
# None means the style has no loadable external .blend style.
external_style_mats: dict[int, Union[bpy.types.Material, None]]
# Appended data-blocks keyed by (filepath, data_block_type, name)
# so styles sharing the same external material don't append duplicates.
appended_external_blocks: dict[tuple[str, str, str], Union[bpy.types.Material, None]]
def invoke(self, context, event):
# Invoke is for debugging purposes, users are not intended to use this method really.
@@ -2178,6 +2346,9 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper):
else:
self.elements |= set(self.file.by_type("IfcSpatialElement"))
self.elements -= set(self.file.by_type("IfcFeatureElement"))
if self.exclude:
# The set difference a single selector query cannot express.
self.elements -= ifcopenshell.util.selector.filter_elements(self.file, self.exclude)
if tool.Loader.settings.false_origin_mode == "MANUAL" and tool.Loader.settings.false_origin:
tool.Loader.set_manual_blender_offset(self.file)
@@ -2185,7 +2356,7 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper):
tool.Loader.guess_false_origin(self.file)
tool.Georeference.set_model_origin()
self.json_filepath = self.filepath + ".cache.json"
self.json_filepath = str(tool.Project.get_link_cache_paths(self.filepath, self.query, self.exclude)[1])
data = {
"model_is_georeferenced": gprops.model_is_georeferenced,
"model_crs": gprops.model_crs,
@@ -2203,10 +2374,14 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper):
"false_origin": pprops.false_origin,
"project_north": pprops.project_north,
"query": self.query,
"exclude": self.exclude,
}
with open(self.json_filepath, "w") as f:
json.dump(data, f)
self.external_style_mats = {}
self.appended_external_blocks = {}
for settings in tool.Loader.settings.context_settings:
if not self.elements:
break
@@ -2246,8 +2421,10 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper):
mat = tuple(mat)
blender_mat = blender_mats.get(mat, None)
if not blender_mat:
blender_mat = bpy.data.materials.new("Chunk")
blender_mat.diffuse_color = mat
blender_mat = self.get_external_material(int(mat[4]))
if not blender_mat:
blender_mat = bpy.data.materials.new("Chunk")
blender_mat.diffuse_color = mat[:4]
blender_mats[mat] = blender_mat
mat_results.append(blender_mat)
@@ -2265,11 +2442,16 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper):
while True: # Main loop.
shape = iterator.get()
assert isinstance(shape, W.TriangulationElement)
results.add(self.file.by_id(shape.id))
element = self.file.by_id(shape.id)
results.add(element)
geometry = shape.geometry
# Elements with a lot of geometry benefit from instancing to save memory
if ifcopenshell.util.shape.get_faces(geometry).shape[0] > 333: # 333 tris
# Elements with a lot of geometry benefit from instancing to save memory.
# Multi-layer elements also take this path as they need their own
# local-space mesh to be sliced into per-layer materials.
if ifcopenshell.util.shape.get_faces(geometry).shape[0] > 333 or self.is_multilayer_element(
element
): # 333 tris
self.process_occurrence(shape)
if not iterator.next():
if not chunked_verts:
@@ -2286,9 +2468,15 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper):
ms = np.vstack([default_mat, ifcopenshell.util.shape.get_material_colors(shape.geometry)])
mi = ifcopenshell.util.shape.get_faces_material_style_ids(shape.geometry)
# Style ids ride along as a 5th column so styles with
# external .blend materials survive the per-color dedup.
style_ids = np.zeros((len(ms), 1))
for geom_material_idx, geom_material in enumerate(shape.geometry.materials):
if not geom_material.instance_id():
ms[geom_material_idx + 1] = (0.8, 0.8, 0.8, 1)
elif self.get_external_material(geom_material.instance_id()):
style_ids[geom_material_idx + 1] = geom_material.instance_id()
ms = np.hstack((ms, style_ids))
chunked_materials.append(ms)
chunked_material_ids.append(mi + material_offset + 1)
material_offset += len(ms)
@@ -2375,12 +2563,14 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper):
diffuse = (material.diffuse.r(), material.diffuse.g(), material.diffuse.b(), alpha)
else:
diffuse = (0.8, 0.8, 0.8, 1) # Blender's default material
material_name = f"{diffuse[0]}-{diffuse[1]}-{diffuse[2]}-{diffuse[3]}"
blender_mat = self.blender_mats.get(material_name, None)
blender_mat = self.get_external_material(material.instance_id())
if not blender_mat:
blender_mat = bpy.data.materials.new(material_name)
blender_mat.diffuse_color = diffuse
self.blender_mats[material_name] = blender_mat
material_name = f"{diffuse[0]}-{diffuse[1]}-{diffuse[2]}-{diffuse[3]}"
blender_mat = self.blender_mats.get(material_name, None)
if not blender_mat:
blender_mat = bpy.data.materials.new(material_name)
blender_mat.diffuse_color = diffuse
self.blender_mats[material_name] = blender_mat
slot_index = mesh.materials.find(material.name)
if slot_index == -1:
mesh.materials.append(blender_mat)
@@ -2393,6 +2583,8 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper):
mesh.polygons.foreach_set("material_index", material_index)
mesh.update()
mesh = tool.Loader.slice_layerset_mesh(element, mesh, style_to_material=self.get_style_material)
self.meshes[geometry.id] = mesh
obj = bpy.data.objects.new(tool.Loader.get_name(element), mesh)
@@ -2405,6 +2597,88 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper):
self.collection.objects.link(obj)
def get_external_material(self, style_id: int) -> Union[bpy.types.Material, None]:
"""Get the Blender material referenced by a style's external .blend style, if it has one.
The material is appended from the external .blend file on first use and
cached, so it ends up saved inside the link's .cache.blend.
"""
if not style_id:
return None
if style_id in self.external_style_mats:
return self.external_style_mats[style_id]
material = None
# instance_id may also refer to an IfcMaterial when the item has
# a material but no style, hence the class check.
style = self.file.by_id(style_id)
external = None
if style.is_a("IfcSurfaceStyle"):
external = next((s for s in style.Styles if s.is_a("IfcExternallyDefinedSurfaceStyle")), None)
if (
external
and external.Location
and external.Location.endswith(".blend")
and external.Identification
and "/" in external.Identification
):
location = Path(external.Location)
if not location.is_absolute():
# Relative locations are relative to the linked IFC, not the host.
location = Path(self.filepath).parent / location
data_block_type, data_block = external.Identification.split("/", 1)
key = (str(location), data_block_type, data_block)
if key in self.appended_external_blocks:
material = self.appended_external_blocks[key]
elif not location.exists():
print(f"WARNING. External style file not found for {style}: '{location}'")
self.appended_external_blocks[key] = None
else:
db = tool.Blender.append_data_block(str(location), data_block_type, data_block)
material = db["data_block"]
if not isinstance(material, bpy.types.Material):
print(f"WARNING. Failed to load external style for {style}: {db['msg'] or 'not a material'}")
material = None
else:
# The source .blend may have been authored in a Bonsai session -
# unlink any stale IFC id so it's not misinterpreted here or in the host.
tool.Style.get_material_style_props(material).ifc_definition_id = 0
self.appended_external_blocks[key] = material
self.external_style_mats[style_id] = material
return material
def is_multilayer_element(self, element: ifcopenshell.entity_instance) -> bool:
material = ifcopenshell.util.element.get_material(element)
return bool(
material and material.is_a("IfcMaterialLayerSetUsage") and len(material.ForLayerSet.MaterialLayers) > 1
)
def get_style_material(self, style: ifcopenshell.entity_instance) -> Union[bpy.types.Material, None]:
"""Resolve a style to a Blender material for slice_layerset_mesh.
Prefers the style's external .blend material, falling back to a flat
diffuse material as used for the rest of the linked geometry.
"""
if material := self.get_external_material(style.id()):
return material
# IfcSurfaceStyleRendering is a subclass of IfcSurfaceStyleShading.
shading = next((s for s in style.Styles if s.is_a("IfcSurfaceStyleShading")), None)
if shading:
colour = shading.SurfaceColour
alpha = 1.0 - (getattr(shading, "Transparency", None) or 0.0)
diffuse = (colour.Red, colour.Green, colour.Blue, alpha)
else:
diffuse = (0.8, 0.8, 0.8, 1.0)
material_name = f"{diffuse[0]}-{diffuse[1]}-{diffuse[2]}-{diffuse[3]}"
material = self.blender_mats.get(material_name, None)
if not material:
material = bpy.data.materials.new(material_name)
material.diffuse_color = diffuse
self.blender_mats[material_name] = material
return material
def create_object(
self,
verts: np.ndarray,
@@ -2478,7 +2752,7 @@ class QueryLinkedElement(bpy.types.Operator):
guid = tool.Project.Link.get_guid_by_face_index(obj, face_index)
assert guid is not None
tool.Project.Link.select_linked_element(context, obj, guid)
tool.Project.Link.select_linked_element(context, obj, guid, instance_matrix)
self.report({"INFO"}, f"Loaded data for {guid}")
ProjectDecorator.install(bpy.context)
@@ -2603,6 +2877,27 @@ class AppendInspectedLinkedElement(AppendLibraryElement):
if element_type and tool.Ifc.get_object(element_type) is None:
self.import_type_from_ifc(element_type, context)
# If the link was moved, place the appended element where the link
# is displayed rather than at its original coordinates.
obj = tool.Ifc.get_object(element)
if isinstance(obj, bpy.types.Object):
# Prefer matching the link by the queried instance's root empty -
# the same file may be linked several times (different queries)
# and moved to different locations.
root = props.queried_obj_root
linked_filepath = Path(queried_obj["ifc_filepath"])
link_match = None
for link in props.links:
if root is not None and tool.Project.get_link_empty_handle(link) == root:
link_match = link
break
if link_match is None and Path(tool.Ifc.resolve_uri(link.filepath)) == linked_filepath:
link_match = link
if link_match:
delta = tool.Project.calculate_link_delta_matrix(link_match)
if not delta.is_identity:
obj.matrix_world = delta @ obj.matrix_world
return {"FINISHED"}
+17 -2
View File
@@ -261,8 +261,21 @@ class Link(PropertyGroup):
default=0,
)
query: StringProperty(
name="Query",
description="Selector query used to filter elements when loading the linked model",
name="Include",
description="Selector query for the elements to load from the linked model",
default="",
)
exclude: StringProperty(
name="Exclude",
description="Selector query whose matches are excluded when loading the linked model",
default="",
)
display_name: StringProperty(
name="Name",
description=(
"Optional display name to tell links apart (e.g. when the same file "
"is linked several times). Shows the file path when empty"
),
default="",
)
@@ -281,6 +294,8 @@ class Link(PropertyGroup):
empty_handle: Union[bpy.types.Object, None]
ifc_definition_id: int
query: str
exclude: str
display_name: str
class EditedObj(PropertyGroup):
+8 -7
View File
@@ -492,17 +492,13 @@ class BIM_PT_links(Panel):
row = self.layout.row(align=True)
row.operator("bim.link_ifc")
row.operator("bim.reload_all_links", text="", icon="FILE_REFRESH")
if self.props.links:
if self.props.active_link:
row = self.layout.row(align=True)
row.alignment = "RIGHT"
index = self.props.active_link_index
if self.props.active_link.is_loaded:
if self.props.active_link.is_editing:
row.operator("bim.edit_link", text="", icon="CHECKMARK")
row.operator("bim.disable_editing_link", text="", icon="CANCEL")
else:
row.operator("bim.enable_editing_link", text="", icon="GREASEPENCIL")
row.operator("bim.select_linked_model_element", icon="VIEWZOOM", text="")
row.operator("bim.select_link_handle", text="", icon="OBJECT_DATA").link_index = index
row.operator("bim.unload_link", text="", icon="UNLINKED").link_index = index
@@ -643,7 +639,12 @@ class BIM_UL_links(UIList):
if item.has_transformation:
row.label(text="", icon="OBJECT_ORIGIN")
row.label(text=item.filepath)
# Double-click to rename; shows the file path while unset.
row.prop(item, "display_name", text="", emboss=False, placeholder=item.filepath)
if item.is_editing:
row.operator("bim.disable_editing_link", text="", icon="UNLOCKED", emboss=False).link_index = index
else:
row.operator("bim.enable_editing_link", text="", icon="LOCKED", emboss=False).link_index = index
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"
@@ -655,7 +656,7 @@ class BIM_UL_links(UIList):
op.link_index = index
op.mode = "VISIBLE"
else:
row.label(text=item.filepath)
row.prop(item, "display_name", text="", emboss=False, placeholder=item.filepath)
class BIM_PT_purge(Panel):
+57 -2
View File
@@ -302,9 +302,23 @@ def add_drawing(
context=drawing.get_body_context(),
ifc_representation_class=None,
)
drawings_parent_group = None
for group in ifc.get().by_type("IfcGroup"):
if group.Name == "DRAWINGS" and group.ObjectType == "DRAWINGS":
drawings_parent_group = group
break
if not drawings_parent_group:
drawings_parent_group = ifc.run("group.add_group")
ifc.run("group.edit_group", group=drawings_parent_group, attributes={"Name": "DRAWINGS", "ObjectType": "DRAWINGS"})
group = ifc.run("group.add_group")
ifc.run("group.edit_group", group=group, attributes={"Name": drawing_name, "ObjectType": "DRAWING"})
ifc.run("group.assign_group", group=group, products=[element])
ifc.run("group.assign_group", group=drawings_parent_group, products=[group])
collector.assign(camera)
pset = ifc.run("pset.add_pset", product=element, name="EPset_Drawing")
if drawing.get_unit_system() == "METRIC":
@@ -335,7 +349,22 @@ def add_drawing(
},
)
drawing.setup_shading_styles_path(shading_styles_path)
information = ifc.run("document.add_information")
drawings_parent_document = None
for document in ifc.get().by_type("IfcDocumentInformation"):
if document.Name == "DRAWINGS" and document.Scope == "DRAWINGS":
drawings_parent_document = document
break
if not drawings_parent_document:
drawings_parent_document = ifc.run("document.add_information")
if ifc.get_schema() == "IFC2X3":
attributes = {"DocumentId": "DRAWINGS", "Name": "DRAWINGS", "Scope": "DRAWINGS"}
else:
attributes = {"Identification": "DRAWINGS", "Name": "DRAWINGS", "Scope": "DRAWINGS"}
ifc.run("document.edit_information", information=drawings_parent_document, attributes=attributes)
information = ifc.run("document.add_information", parent=drawings_parent_document)
uri = drawing.get_default_drawing_path(drawing_name)
reference = ifc.run("document.add_reference", information=information)
if ifc.get_schema() == "IFC2X3":
@@ -363,9 +392,21 @@ def duplicate_drawing(
drawing_tool.set_name(new_drawing, drawing_name)
group = drawing_tool.get_drawing_group(new_drawing)
ifc.run("group.unassign_group", group=group, products=[new_drawing])
drawings_parent_group = None
for parent_group in ifc.get().by_type("IfcGroup"):
if parent_group.Name == "DRAWINGS" and parent_group.ObjectType == "DRAWINGS":
drawings_parent_group = parent_group
break
if not drawings_parent_group:
drawings_parent_group = ifc.run("group.add_group")
ifc.run("group.edit_group", group=drawings_parent_group, attributes={"Name": "DRAWINGS", "ObjectType": "DRAWINGS"})
new_group = ifc.run("group.add_group")
ifc.run("group.edit_group", group=new_group, attributes={"Name": drawing_name, "ObjectType": "DRAWING"})
ifc.run("group.assign_group", group=new_group, products=[new_drawing])
ifc.run("group.assign_group", group=drawings_parent_group, products=[new_group])
if should_duplicate_annotations:
new_annotations: list[ifcopenshell.entity_instance] = []
annotation_objs = [ifc.get_object(a) for a in drawing_tool.get_group_elements(group) if a != drawing]
@@ -381,7 +422,21 @@ def duplicate_drawing(
old_reference = drawing_tool.get_drawing_document(new_drawing)
ifc.run("document.unassign_document", products=[new_drawing], document=old_reference)
information = ifc.run("document.add_information")
drawings_parent_document = None
for document in ifc.get().by_type("IfcDocumentInformation"):
if document.Name == "DRAWINGS" and document.Scope == "DRAWINGS":
drawings_parent_document = document
break
if not drawings_parent_document:
drawings_parent_document = ifc.run("document.add_information")
if ifc.get_schema() == "IFC2X3":
attributes = {"DocumentId": "DRAWINGS", "Name": "DRAWINGS", "Scope": "DRAWINGS"}
else:
attributes = {"Identification": "DRAWINGS", "Name": "DRAWINGS", "Scope": "DRAWINGS"}
ifc.run("document.edit_information", information=drawings_parent_document, attributes=attributes)
information = ifc.run("document.add_information", parent=drawings_parent_document)
uri = drawing_tool.get_default_drawing_path(drawing_name)
reference = ifc.run("document.add_reference", information=information)
if ifc.get_schema() == "IFC2X3":
+19
View File
@@ -178,6 +178,25 @@ class Array(bonsai.core.tool.Array):
element_root = cls.get_array_root_guid(element)
return [o for o in occurrences if cls.get_array_root_guid(o) == element_root]
@classmethod
def select_only_parent(cls, parent_obj: bpy.types.Object, context: bpy.types.Context) -> None:
"""Post-condition for the user-facing regenerate and finish-edit paths:
only ``parent_obj`` is selected + active. Grow and shrink otherwise
diverge on which objects stay selected, surfacing an inconsistency."""
tool.Blender.select_and_activate_single_object(context, parent_obj)
@classmethod
def is_array_child(cls, element: entity_instance) -> bool:
"""True when ``element`` is a child of a parametric array — has a
BBIM_Array pset whose Parent GUID points to a different element.
Lighter than ``get_child_layer_index`` (no ``by_guid`` lookup, no
Data parse); suitable for per-element checks in draw handlers."""
pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
if not pset:
return False
parent_guid = pset.get("Parent")
return bool(parent_guid) and parent_guid != element.GlobalId
@classmethod
def get_child_layer_index(cls, child_element: entity_instance) -> int | None:
"""Index of the layer that produced ``child_element``, or ``None``
+54 -59
View File
@@ -248,32 +248,30 @@ class Duplicate(bonsai.core.tool.Duplicate):
old_to_new: dict[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]],
) -> None:
for element, data in relationship.items():
try:
new_relating_element = old_to_new.get(data.relating_element)[0]
new_related_element = old_to_new.get(data.related_element)[0]
except (KeyError, IndexError, TypeError):
continue
new_rel = tool.Ifc.run(
"geometry.connect_path",
relating_element=new_relating_element,
related_element=new_related_element,
relating_connection=data.relating_connection_type,
related_connection=data.related_connection_type,
)
new_relating_elements = old_to_new.get(data.relating_element) or []
new_related_elements = old_to_new.get(data.related_element) or []
# connect_path hardcodes priorities to []; restore them post-hoc.
priority_attrs: dict[str, Any] = {}
if data.relating_priorities:
priority_attrs["RelatingPriorities"] = data.relating_priorities
if data.related_priorities:
priority_attrs["RelatedPriorities"] = data.related_priorities
if new_rel is not None and priority_attrs:
try:
tool.Ifc.run("attribute.edit_attributes", product=new_rel, attributes=priority_attrs)
except (RuntimeError, ifcopenshell.Error) as e:
cls._emit_warning(
f"connection priority restore failed for {new_rel}; "
f"duplicate has empty RelatingPriorities/RelatedPriorities: {e}"
)
for new_relating_element, new_related_element in zip(new_relating_elements, new_related_elements):
new_rel = tool.Ifc.run(
"geometry.connect_path",
relating_element=new_relating_element,
related_element=new_related_element,
relating_connection=data.relating_connection_type,
related_connection=data.related_connection_type,
)
if new_rel is not None and priority_attrs:
try:
tool.Ifc.run("attribute.edit_attributes", product=new_rel, attributes=priority_attrs)
except (RuntimeError, ifcopenshell.Error) as e:
cls._emit_warning(
f"connection priority restore failed for {new_rel}; "
f"duplicate has empty RelatingPriorities/RelatedPriorities: {e}"
)
@classmethod
def recreate_port_connections(
@@ -283,46 +281,43 @@ class Duplicate(bonsai.core.tool.Duplicate):
) -> None:
"""Recreate ``IfcRelConnectsPorts`` between duplicates; skip records whose duplicate's port count diverges from the snapshot."""
for relating_element, records in snapshot.by_element.items():
new_relatings = old_to_new.get(relating_element) or []
expected_relating = snapshot.port_counts.get(relating_element)
for record in records:
related_element = record.related_element
try:
new_relating = old_to_new[relating_element][0]
new_related = old_to_new[related_element][0]
except (KeyError, IndexError):
continue
new_relating_ports = tool.System.get_ports(new_relating)
new_related_ports = tool.System.get_ports(new_related)
expected_relating = snapshot.port_counts.get(relating_element)
if expected_relating is not None and len(new_relating_ports) != expected_relating:
cls._emit_warning(
f"port reconnect skipped — duplicate has {len(new_relating_ports)} ports, "
f"snapshot had {expected_relating}"
)
continue
new_relateds = old_to_new.get(related_element) or []
expected_related = snapshot.port_counts.get(related_element)
if expected_related is not None and len(new_related_ports) != expected_related:
cls._emit_warning(
f"port reconnect skipped — duplicate has {len(new_related_ports)} ports, "
f"snapshot had {expected_related}"
)
continue
for new_relating, new_related in zip(new_relatings, new_relateds):
new_relating_ports = tool.System.get_ports(new_relating)
new_related_ports = tool.System.get_ports(new_related)
try:
new_port_a = new_relating_ports[record.relating_port_index]
new_port_b = new_related_ports[record.related_port_index]
except IndexError:
cls._emit_warning(
f"port reconnect skipped — record references port index past the duplicate's port list"
)
continue
try:
tool.Ifc.run(
"system.connect_port",
port1=new_port_a,
port2=new_port_b,
direction=record.direction or "NOTDEFINED",
)
except (RuntimeError, ifcopenshell.Error) as e:
cls._emit_warning(f"port reconnect failed between duplicates: {e}")
if expected_relating is not None and len(new_relating_ports) != expected_relating:
cls._emit_warning(
f"port reconnect skipped — duplicate has {len(new_relating_ports)} ports, "
f"snapshot had {expected_relating}"
)
continue
if expected_related is not None and len(new_related_ports) != expected_related:
cls._emit_warning(
f"port reconnect skipped — duplicate has {len(new_related_ports)} ports, "
f"snapshot had {expected_related}"
)
continue
try:
new_port_a = new_relating_ports[record.relating_port_index]
new_port_b = new_related_ports[record.related_port_index]
except IndexError:
cls._emit_warning(
f"port reconnect skipped — record references port index past the duplicate's port list"
)
continue
try:
tool.Ifc.run(
"system.connect_port",
port1=new_port_a,
port2=new_port_b,
direction=record.direction or "NOTDEFINED",
)
except (RuntimeError, ifcopenshell.Error) as e:
cls._emit_warning(f"port reconnect failed between duplicates: {e}")
+221 -95
View File
@@ -163,13 +163,21 @@ class Geometry(bonsai.core.tool.Geometry):
cls._host_update_queue = {}
cls._host_recut_queue = {}
for voided_obj in update_queue.values():
if not voided_obj or not voided_obj.data:
try:
if not voided_obj or not voided_obj.data:
continue
except ReferenceError:
# Blender object was deleted while the batch was open
# (e.g. user removed it via the outliner mid-op).
continue
if tool.Ifc.get_entity(voided_obj) is None:
continue
bpy.ops.bim.update_representation(obj=voided_obj.name)
for voided_obj, _ in recut_queue.values():
if not voided_obj or not voided_obj.data:
try:
if not voided_obj or not voided_obj.data:
continue
except ReferenceError:
continue
if tool.Ifc.get_entity(voided_obj) is None:
continue
@@ -2481,99 +2489,16 @@ class Geometry(bonsai.core.tool.Geometry):
old_obj_name_to_new_obj_name: dict[str, str] = {}
for obj in objects_to_duplicate:
element = tool.Ifc.get_entity(obj)
if element:
if element.is_a("IfcAnnotation") and element.ObjectType == "DRAWING":
tool.Blender.deselect_object(obj)
continue # For now, don't copy drawings until we stabilise a bit more. It's tricky.
elif tool.Geometry.is_locked(element):
tool.Blender.deselect_object(obj)
continue
elif tool.Geometry.is_representation_item(obj):
cls.duplicate_ifc_item(obj)
continue
tracked_opening_type = tool.Model.get_tracked_opening_type(obj)
is_tracked_opening = bool(tracked_opening_type)
keep_data_linked = linked and not element and not is_tracked_opening
# Prior to duplicating, sync the object placement to make decomposition recreation more stable.
cls.commit_placement_if_moved(obj, apply_scale=False)
new_obj = obj.copy()
temp_data = None
# Currently for optimization we do not apply pending changes (scale or changed .data)
# to the original and duplicated objects.
# Keep new object edited if original is.
if tool.Ifc.is_edited(obj, ignore_scale=True):
tool.Ifc.edit(new_obj)
if obj.data and not keep_data_linked:
# assure root.copy_class won't replace the previous mesh globally
temp_data = obj.data.copy()
new_obj.data = temp_data
# Unlink from previous boolean element
# and keep object tracked for decorations.
if is_tracked_opening:
mprops = tool.Geometry.get_mesh_props(new_obj.data)
mprops.ifc_boolean_id = 0
tool.Root.add_tracked_opening(new_obj, tracked_opening_type)
if obj == active_object:
new_active_obj = new_obj
for collection in obj.users_collection:
collection.objects.link(new_obj)
obj.select_set(False)
new_obj.select_set(True)
old_obj_name_to_new_obj_name[obj.name] = new_obj.name
if not element:
continue
# clear object's collection so it will be able to have it's own
tool.Blender.get_object_bim_props(new_obj).collection = None
# copy the actual class
new = bonsai.core.root.copy_class(tool.Ifc, tool.Collector, tool.Geometry, tool.Root, obj=new_obj)
# clean up the orphaned mesh with ifc id of the original object to avoid confusion
# IfcGridAxis keeps the same mesh data (it's pointing to ifc id 0, so it's not a problem)
if new and temp_data and not new.is_a("IfcGridAxis"):
if new.is_a("IfcRelSpaceBoundary"):
surface = new.ConnectionGeometry.SurfaceOnRelatingElement
temp_data.name = f"0/{surface.id()}"
tool.Ifc.link(surface, temp_data)
else:
tool.Blender.remove_data_block(temp_data)
if new:
# TODO: handle array data for other cases of duplication
array_data = arrays_to_duplicate.get(obj, None)
tool.Model.handle_array_on_copied_element(new, array_data)
if array_data:
for child in tool.Array.get_all_children_objects(new):
child.select_set(True)
# TODO: add new array children to recreate their decomposition too
old_to_new[element] = [new]
if new.is_a("IfcRelSpaceBoundary"):
tool.Boundary.decorate_boundary(new_obj)
# Slab-trim booleans (from extend_walls_to_underside) belong to
# the source wall's connection, not the copy. Strip them so the
# duplicate reverts to its pre-clip extrusion — mirrors the way
# filling rels are dropped while manual booleans persist on copy.
# Reload the body when something was stripped so the viewport
# immediately shows the unclipped geometry; otherwise the user
# sees a stale mesh until they Shift+G, which is easy to miss.
if new.is_a("IfcWall"):
if tool.Model.strip_underside_booleans(new):
tool.Model.reload_body_representation(new_obj)
# HasOpenings rels don't follow object duplication, so
# the duplicate's body must rebuild to match its current
# opening set.
else:
tool.Model.regenerate_wall(new_obj)
new_active = cls._duplicate_ifc_object_once(
obj,
active_object,
linked,
arrays_to_duplicate,
old_to_new,
old_obj_name_to_new_obj_name,
)
if new_active is not None:
new_active_obj = new_active
# Remap Blender parent relationships for duplicated objects
for old_obj_name, new_obj_name in old_obj_name_to_new_obj_name.items():
@@ -2601,10 +2526,211 @@ class Geometry(bonsai.core.tool.Geometry):
# Recreate decompositions
tool.Duplicate.recreate_decompositions(decomposition_relationships, old_to_new)
cls.remove_linked_aggregate_data(old_to_new)
# In-loop regenerate_wall runs before recreate_connections, so any new
# walls that just received an IfcRelConnectsPathElements have stale
# junction geometry — recalculate them now that their connection graph
# is complete.
cls._recalculate_walls_with_new_connections(old_to_new)
bonsai.bim.handler.refresh_ui_data()
tool.Root.reload_grid_decorator()
return old_to_new, new_active_obj or active_object
@classmethod
def duplicate_ifc_object_n_times(
cls, source: bpy.types.Object, count: int
) -> dict[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]]:
"""N-way duplicate of a single source.
Same per-copy semantics as duplicate_ifc_objects (IFC class copy,
decomposition + connection recreation, body regen for walls), but
bypasses the set() dedupe and the arrays_to_duplicate pre-scan so
callers building a fresh array don't pay per-call overhead N times.
Returns the same old_to_new dict shape, with the source element
mapping to the N new entities."""
if count <= 0:
return {}
sources = {source}
decomposition_relationships = tool.Duplicate.get_decomposition_relationships(sources)
connection_relationships = tool.Duplicate.get_connection_relationships(sources)
port_connection_snapshot = tool.Duplicate.get_port_connection_relationships(sources)
old_to_new: dict[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]] = {}
old_obj_name_to_new_obj_name: dict[str, str] = {}
for _ in range(count):
cls._duplicate_ifc_object_once(
source,
None,
False,
{},
old_to_new,
old_obj_name_to_new_obj_name,
keep_source_selected=True,
)
for old_obj_name, new_obj_name in old_obj_name_to_new_obj_name.items():
new_obj = bpy.data.objects.get(new_obj_name)
if new_obj and new_obj.parent and new_obj.parent.name in old_obj_name_to_new_obj_name:
world_matrix = new_obj.matrix_world.copy()
new_parent_name = old_obj_name_to_new_obj_name[new_obj.parent.name]
new_parent = bpy.data.objects.get(new_parent_name)
if new_parent:
new_obj.parent = new_parent
new_obj.matrix_world = world_matrix
for old in old_to_new.keys():
if old.is_a("IfcElementAssembly"):
tool.Root.recreate_aggregate(old_to_new)
cls.remove_old_connections(old_to_new)
tool.Duplicate.recreate_connections(connection_relationships, old_to_new)
tool.Duplicate.recreate_port_connections(port_connection_snapshot, old_to_new)
tool.Duplicate.recreate_decompositions(decomposition_relationships, old_to_new)
cls.remove_linked_aggregate_data(old_to_new)
cls._recalculate_walls_with_new_connections(old_to_new)
bonsai.bim.handler.refresh_ui_data()
tool.Root.reload_grid_decorator()
return old_to_new
@classmethod
def _duplicate_ifc_object_once(
cls,
obj: bpy.types.Object,
active_object: Optional[bpy.types.Object],
linked: bool,
arrays_to_duplicate: dict[bpy.types.Object, Any],
old_to_new: dict[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]],
old_obj_name_to_new_obj_name: dict[str, str],
keep_source_selected: bool = False,
) -> Optional[bpy.types.Object]:
"""Per-source body of the duplicate flow. Mutates old_to_new and
old_obj_name_to_new_obj_name in place. Returns new_obj when obj is
the active_object, else None.
keep_source_selected: when True, skip the source deselect so batched
callers can run N iterations without N×2 select flips and without
needing a post-loop restore on the source."""
new_active_obj: Optional[bpy.types.Object] = None
element = tool.Ifc.get_entity(obj)
if element:
if element.is_a("IfcAnnotation") and element.ObjectType == "DRAWING":
tool.Blender.deselect_object(obj)
return None # For now, don't copy drawings until we stabilise a bit more. It's tricky.
elif tool.Geometry.is_locked(element):
tool.Blender.deselect_object(obj)
return None
elif tool.Geometry.is_representation_item(obj):
cls.duplicate_ifc_item(obj)
return None
tracked_opening_type = tool.Model.get_tracked_opening_type(obj)
is_tracked_opening = bool(tracked_opening_type)
keep_data_linked = linked and not element and not is_tracked_opening
# Prior to duplicating, sync the object placement to make decomposition recreation more stable.
cls.commit_placement_if_moved(obj, apply_scale=False)
new_obj = obj.copy()
temp_data = None
# Currently for optimization we do not apply pending changes (scale or changed .data)
# to the original and duplicated objects.
# Keep new object edited if original is.
if tool.Ifc.is_edited(obj, ignore_scale=True):
tool.Ifc.edit(new_obj)
if obj.data and not keep_data_linked:
# assure root.copy_class won't replace the previous mesh globally
temp_data = obj.data.copy()
new_obj.data = temp_data
# Unlink from previous boolean element
# and keep object tracked for decorations.
if is_tracked_opening:
mprops = tool.Geometry.get_mesh_props(new_obj.data)
mprops.ifc_boolean_id = 0
tool.Root.add_tracked_opening(new_obj, tracked_opening_type)
if obj == active_object:
new_active_obj = new_obj
for collection in obj.users_collection:
collection.objects.link(new_obj)
if not keep_source_selected:
obj.select_set(False)
new_obj.select_set(True)
old_obj_name_to_new_obj_name[obj.name] = new_obj.name
if not element:
return new_active_obj
# clear object's collection so it will be able to have it's own
tool.Blender.get_object_bim_props(new_obj).collection = None
# copy the actual class
new = bonsai.core.root.copy_class(tool.Ifc, tool.Collector, tool.Geometry, tool.Root, obj=new_obj)
# clean up the orphaned mesh with ifc id of the original object to avoid confusion
# IfcGridAxis keeps the same mesh data (it's pointing to ifc id 0, so it's not a problem)
if new and temp_data and not new.is_a("IfcGridAxis"):
if new.is_a("IfcRelSpaceBoundary"):
surface = new.ConnectionGeometry.SurfaceOnRelatingElement
temp_data.name = f"0/{surface.id()}"
tool.Ifc.link(surface, temp_data)
else:
tool.Blender.remove_data_block(temp_data)
if new:
# TODO: handle array data for other cases of duplication
array_data = arrays_to_duplicate.get(obj, None)
tool.Model.handle_array_on_copied_element(new, array_data)
if array_data:
for child in tool.Array.get_all_children_objects(new):
child.select_set(True)
# TODO: add new array children to recreate their decomposition too
old_to_new.setdefault(element, []).append(new)
if new.is_a("IfcRelSpaceBoundary"):
tool.Boundary.decorate_boundary(new_obj)
# Slab-trim booleans (from extend_walls_to_underside) belong to
# the source wall's connection, not the copy. Strip them so the
# duplicate reverts to its pre-clip extrusion — mirrors the way
# filling rels are dropped while manual booleans persist on copy.
# Reload the body when something was stripped so the viewport
# immediately shows the unclipped geometry; otherwise the user
# sees a stale mesh until they Shift+G, which is easy to miss.
if new.is_a("IfcWall"):
if tool.Model.strip_underside_booleans(new):
tool.Model.reload_body_representation(new_obj)
# HasOpenings rels don't follow object duplication, so
# the duplicate's body must rebuild to match its current
# opening set.
else:
tool.Model.regenerate_wall(new_obj)
return new_active_obj
@classmethod
def _recalculate_walls_with_new_connections(
cls, old_to_new: dict[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]]
) -> None:
"""Recalculate new IfcWall duplicates that just received an
``IfcRelConnectsPathElements``. The in-loop ``regenerate_wall`` runs
before ``recreate_connections``, so wall body geometry doesn't reflect
the junction until this second pass."""
walls_to_recalc: list[bpy.types.Object] = []
for new_list in old_to_new.values():
for new_entity in new_list:
if not new_entity.is_a("IfcWall"):
continue
if not (getattr(new_entity, "ConnectedTo", None) or getattr(new_entity, "ConnectedFrom", None)):
continue
new_obj = tool.Ifc.get_object(new_entity)
if new_obj is not None:
walls_to_recalc.append(new_obj)
if walls_to_recalc:
tool.Model.recalculate_walls(walls_to_recalc)
@classmethod
def duplicate_ifc_item(cls, obj: bpy.types.Object) -> None:
props = tool.Geometry.get_geometry_props()
+16 -3
View File
@@ -23,7 +23,7 @@ import os
import re
from math import atan, radians
from pathlib import Path
from typing import Any, Optional, Union, cast
from typing import Any, Callable, Optional, Union, cast
import bmesh
import bpy
@@ -1073,7 +1073,19 @@ class Loader(bonsai.core.tool.Loader):
return mesh
@classmethod
def slice_layerset_mesh(cls, element: ifcopenshell.entity_instance, mesh: bpy.types.Mesh) -> bpy.types.Mesh:
def slice_layerset_mesh(
cls,
element: ifcopenshell.entity_instance,
mesh: bpy.types.Mesh,
style_to_material: Optional[Callable[[ifcopenshell.entity_instance], Union[bpy.types.Material, None]]] = None,
) -> bpy.types.Mesh:
"""Bisect a layerset element's mesh at layer boundaries and assign each layer its material style.
:param style_to_material: Callback resolving an IfcSurfaceStyle to a Blender material.
Defaults to the IFC-linked material, which only works for the actively edited project.
"""
if style_to_material is None:
style_to_material = tool.Ifc.get_object
if not (material := ifcopenshell.util.element.get_material(element)):
return mesh
elif material.is_a("IfcMaterialLayerSetUsage"):
@@ -1121,7 +1133,8 @@ class Loader(bonsai.core.tool.Loader):
continue
if (material_index := styles.get(style, None)) is None:
material_index = len(mesh.materials)
mesh.materials.append(tool.Ifc.get_object(style))
mesh.materials.append(style_to_material(style))
styles[style] = material_index
if i == last_i:
for face in bisect_geom["geom"]:
if isinstance(face, bmesh.types.BMFace):
+46 -10
View File
@@ -1247,6 +1247,35 @@ class Model(bonsai.core.tool.Model):
with tool.Geometry.batch_host_recut():
cls._regenerate_array_body(parent_obj, data, array_layers_to_apply)
@classmethod
def _prune_orphan_array_children(cls, array: dict[str, Any]) -> None:
"""Drop GUIDs from ``array['children']`` whose IFC entity or Blender
object is no longer alive, and cascade-remove the orphan IFC entity
if it still exists. Outliner / keyboard delete of a Bonsai-managed
object bypasses ``bim.delete``'s cascade, leaving dangling opening
and filling references that later confuse regen and crash the
``batch_host_recut`` drain."""
live_guids: list[str] = []
ifc_file = tool.Ifc.get()
for guid in array["children"]:
try:
element = ifc_file.by_guid(guid)
except RuntimeError:
continue
obj = tool.Ifc.get_object(element)
try:
is_live = obj is not None and obj.data is not None
except ReferenceError:
is_live = False
if is_live:
live_guids.append(guid)
continue
try:
ifcopenshell.api.root.remove_product(ifc_file, product=element)
except (RuntimeError, ifcopenshell.Error):
pass
array["children"] = live_guids
@classmethod
def _regenerate_array_body(
cls, parent_obj: bpy.types.Object, data: list[dict[str, Any]], array_layers_to_apply: Iterable[int]
@@ -1262,6 +1291,7 @@ class Model(bonsai.core.tool.Model):
obj_stack = [parent_obj]
for array_i, array in enumerate(data):
cls._prune_orphan_array_children(array)
child_i = 0
existing_children = set(array["children"])
total_existing_children = len(array["children"])
@@ -1275,6 +1305,14 @@ class Model(bonsai.core.tool.Model):
else:
base_offset = Vector([array["x"], array["y"], array["z"]]) * unit_scale
target_new_in_this_layer = (array["count"] - 1) * len(obj_stack)
missing_count = max(0, target_new_in_this_layer - total_existing_children)
new_entities_pool: list[ifcopenshell.entity_instance] = []
if missing_count > 0:
batch_old_to_new = tool.Geometry.duplicate_ifc_object_n_times(parent_obj, missing_count)
new_entities_pool = batch_old_to_new.get(parent_element, [])
new_entities_iter = iter(new_entities_pool)
for i in range(array["count"]):
if i == 0:
continue
@@ -1292,8 +1330,13 @@ class Model(bonsai.core.tool.Model):
child_obj = tool.Ifc.get_object(child_element)
assert child_obj
except (IndexError, RuntimeError, AssertionError):
old_to_new, _ = tool.Geometry.duplicate_ifc_objects([parent_obj])
child_element = next(iter(old_to_new.values()))[0]
try:
child_element = next(new_entities_iter)
except StopIteration:
# Stale-GUID mid-list left the pool exhausted; fall back
# to a one-off duplicate so the layer can still complete.
old_to_new, _ = tool.Geometry.duplicate_ifc_objects([parent_obj])
child_element = next(iter(old_to_new.values()))[0]
child_obj = tool.Ifc.get_object(child_element)
# add child pset
@@ -1361,14 +1404,7 @@ class Model(bonsai.core.tool.Model):
tool.Ifc.get(), pset=pset, properties={"Data": json_data, "Parent": parent_element.GlobalId}
)
# Post-condition: parent is selected on return. duplicate_ifc_objects
# deselects the source on every call inside the regen loop; without
# this restore, callers get a deselected parent for arrays with N >= 2.
# TODO: batch the per-child duplicate_ifc_objects([parent]) calls into
# a single N-way duplicate — N depsgraph churns + N select/deselect
# flips is wasteful, and a batched duplicate would also remove the
# need for this restore.
parent_obj.select_set(True)
tool.Blender.set_object_selection(parent_obj, True)
@classmethod
def mirror_parent_void_fillings_to_children(
+240 -35
View File
@@ -18,6 +18,7 @@
from __future__ import annotations
import hashlib
import json
import os
import shutil
@@ -89,10 +90,90 @@ class Project(bonsai.core.tool.Project):
else:
link.empty_handle = empty
@classmethod
def get_link_cache_paths(cls, filepath: Union[Path, str], query: str, exclude: str = "") -> tuple[Path, Path]:
"""Get the (blend, json) cache paths for a linked model's filter.
Cache files are per-filter so the same IFC file can be linked several
times with different include/exclude queries without the caches
overwriting each other. An empty filter keeps the legacy un-suffixed
names, and an include-only filter keeps the pre-exclude hash so
existing caches stay valid.
"""
filepath = Path(filepath)
if not query and not exclude:
suffix = ""
elif not exclude:
suffix = "." + hashlib.md5(query.encode("utf-8")).hexdigest()[:8]
else:
suffix = "." + hashlib.md5(f"{query}\0{exclude}".encode("utf-8")).hexdigest()[:8]
return (
filepath.with_suffix(f".ifc.cache{suffix}.blend"),
filepath.with_suffix(f".ifc.cache{suffix}.json"),
)
@classmethod
def encode_link_filter(
cls, query: str, exclude: str, loaded: bool = False, display_name: str = ""
) -> Union[str, None]:
"""Serialize a link's filter and state for IfcDocumentReference.Description.
A plain include query is stored as-is (backwards compatible); an
exclude, a loaded state or a display name promotes the value to a
small JSON blob. The loaded flag makes the link auto-load on the
next project open.
"""
if exclude or loaded or display_name:
return json.dumps({"include": query, "exclude": exclude, "loaded": loaded, "name": display_name})
return query or None
@classmethod
def decode_link_filter(cls, description: Union[str, None]) -> tuple[str, str, bool, str]:
"""Get (query, exclude, loaded, display_name) from a Description written by encode_link_filter."""
if not description:
return "", "", False, ""
if description.startswith("{"):
try:
data = json.loads(description)
if isinstance(data, dict):
return (
data.get("include", "") or "",
data.get("exclude", "") or "",
bool(data.get("loaded", False)),
data.get("name", "") or "",
)
except json.JSONDecodeError:
pass
return description, "", False, ""
@classmethod
def update_linked_models_state(cls) -> None:
"""Persist each link's loaded/visible state onto its document reference.
Called at IFC save time so links that were loaded and visible
auto-load the next time the project is opened.
"""
if not tool.Ifc.get():
return
for link in cls.get_project_props().links:
if not link.ifc_definition_id:
continue
try:
reference = tool.Ifc.get().by_id(link.ifc_definition_id)
except RuntimeError:
continue
if hasattr(reference, "Description"):
reference.Description = cls.encode_link_filter(
link.query,
link.exclude,
loaded=link.is_loaded and not link.is_hidden,
display_name=link.display_name,
)
@classmethod
def calculate_link_matrix(cls, link: Link) -> Matrix:
filepath = Path(tool.Ifc.resolve_uri(link.filepath))
with open(filepath.with_suffix(".ifc.cache.json"), "r") as f:
with open(cls.get_link_cache_paths(filepath, link.query, link.exclude)[1], "r") as f:
metadata = json.load(f)
rot = ifcopenshell.util.shape_builder.np_rotation_matrix(
@@ -117,6 +198,86 @@ class Project(bonsai.core.tool.Project):
local_matrix[:, 3][:3] = [float(o) for o in gprops.model_origin_si.split(",")]
return Matrix(np.linalg.inv(local_matrix) @ global_matrix)
@classmethod
def get_link_transformation_matrix(cls, link: Link) -> Union[npt.NDArray[np.float64], None]:
"""Get the link's saved 4x4 transformation in model coordinates, or None when identity."""
if tool.Ifc.get():
transformation = tool.Ifc.get().by_id(link.ifc_definition_id)[1] # Identification
else:
transformation = link.transformation
if not transformation:
return None
matrix = np.fromstring(transformation, sep=",", dtype=np.float64).reshape(4, 4)
if np.allclose(matrix, np.eye(4)):
return None
return matrix
@classmethod
def calculate_link_delta_matrix(cls, link: Link) -> Matrix:
"""Get the matrix mapping the link's unmoved world positions to its moved ones.
Returns identity when the link has no saved transformation.
"""
if tool.Ifc.get():
transformation = tool.Ifc.get().by_id(link.ifc_definition_id)[1] # Identification
else:
transformation = link.transformation
if not transformation:
return Matrix.Identity(4)
transformation = np.fromstring(transformation, sep=",", dtype=np.float64).reshape(4, 4)
if np.allclose(transformation, np.eye(4)):
return Matrix.Identity(4)
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(",")]
# Link empty matrix is inv(local) @ transformation @ global (see
# calculate_link_matrix), so moved = inv(local) @ T @ local @ unmoved.
return Matrix(np.linalg.inv(local_matrix) @ transformation @ local_matrix)
@classmethod
def save_link_transformation(cls, link: Link) -> None:
"""Persist the link handle's current world matrix as the link's saved transformation."""
obj = cls.get_link_empty_handle(link)
assert obj
new_obj_matrix = np.array(obj.matrix_world)
filepath = Path(tool.Ifc.resolve_uri(link.filepath))
with open(cls.get_link_cache_paths(filepath, link.query, link.exclude)[1], "r") as f:
metadata = json.load(f)
rot = ifcopenshell.util.shape_builder.np_rotation_matrix(
radians(-float(metadata["model_project_north"])), 4, "Z"
)
global_matrix = rot @ np.eye(4)
global_matrix[:, 3][:3] = [float(o) for o in metadata["model_origin_si"].split(",")]
gprops = tool.Georeference.get_georeference_props()
rot = ifcopenshell.util.shape_builder.np_rotation_matrix(radians(-float(gprops.model_project_north)), 4, "Z")
local_matrix = rot @ np.eye(4)
local_matrix[:, 3][:3] = [float(o) for o in gprops.model_origin_si.split(",")]
# obj_matrix is typically calculated as:
# obj_matrix = np.linalg.inv(local_matrix) @ transformation @ global_matrix
identity_blender_matrix = np.linalg.inv(local_matrix) @ global_matrix
if np.allclose(new_obj_matrix, identity_blender_matrix, atol=1e-5):
link.has_transformation = False
transformation = ",".join(map(str, np.eye(4).reshape(-1)))
else:
transformed_global_matrix = local_matrix @ new_obj_matrix
transformation = transformed_global_matrix @ np.linalg.inv(global_matrix)
link.has_transformation = True
transformation = ",".join(map(str, transformation.reshape(-1)))
if tool.Ifc.get():
reference = tool.Ifc.get().by_id(link.ifc_definition_id)
reference[1] = transformation
else:
link.transformation = transformation
@classmethod
def append_all_types_from_template(cls, template: str) -> None:
# TODO refactor
@@ -309,11 +470,17 @@ class Project(bonsai.core.tool.Project):
@classmethod
def get_linked_models_documents(cls) -> dict[str, ifcopenshell.entity_instance]:
"""Get linked model documents keyed by resolved absolute filepath (posix form).
Locations are stored either relative or absolute depending on how the
link was created - resolving before keying ensures both forms of the
same file match one document.
"""
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
linked_docs[Path(tool.Ifc.resolve_uri(reference.Location)).as_posix()] = doc
break
return linked_docs
@@ -321,27 +488,52 @@ class Project(bonsai.core.tool.Project):
def load_linked_models_from_ifc(cls) -> None:
links = tool.Project.get_project_props().links
links.clear()
references: list[ifcopenshell.entity_instance] = []
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))
# The selector query used at link time is persisted only in the
# sidecar cache JSON; restore it so Reload/Load replay the filter.
references.extend(tool.Drawing.get_document_references(doc))
location_counts: defaultdict[str, int] = defaultdict(int)
for reference in references:
location_counts[reference.Location] += 1
autoload_indices: list[int] = []
for reference in references:
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))
# The selector filter used at link time is persisted per
# reference in its Description (IFC4+); restore it so
# Reload/Load replay the filter.
query, exclude, loaded, display_name = cls.decode_link_filter(getattr(reference, "Description", None))
if not query and not exclude and location_counts[filepath] == 1:
# Fall back to the legacy sidecar cache JSON where older
# versions persisted the query. Only unambiguous: with
# several links to one file the shared JSON can't say
# which link it belonged to.
json_filepath = Path(tool.Ifc.resolve_uri(filepath)).with_suffix(".ifc.cache.json")
if json_filepath.exists():
try:
link.query = json.loads(json_filepath.read_text()).get("query", "")
query = json.loads(json_filepath.read_text()).get("query", "")
except (OSError, json.JSONDecodeError):
pass
link.query = query
link.exclude = exclude
link.display_name = display_name
if loaded:
autoload_indices.append(len(links) - 1)
# Links that were loaded and visible at save time load automatically.
for i in autoload_indices:
if not Path(tool.Ifc.resolve_uri(links[i].filepath)).exists():
print(f"WARNING: Not auto-loading missing linked model: {links[i].filepath}")
continue
bpy.ops.bim.load_link(link_index=i)
@classmethod
def get_project_library_elements(
@@ -858,9 +1050,16 @@ class Project(bonsai.core.tool.Project):
selected_vertices = [obj.matrix_world @ mesh.vertices[vi].co for vi in vert_map]
for polygon in guid_polygons:
selected_tris.append(tuple(vert_map[vi] for vi in polygon.vertices))
selected_edges.extend(tuple([vert_map[vi] for vi in e]) for e in polygon.edge_keys)
# Polygons are not necessarily triangles (e.g. layerset-sliced
# meshes contain ngons), so triangles come from the loop triangles.
mesh.calc_loop_triangles()
polygon_range = range(*slice_.indices(len(mesh.polygons)))
for tri in mesh.loop_triangles:
if tri.polygon_index in polygon_range:
selected_tris.append(tuple(vert_map[vi] for vi in tri.vertices))
obj["selected_vertices"] = selected_vertices
obj["selected_edges"] = selected_edges
obj["selected_tris"] = selected_tris
@@ -897,11 +1096,9 @@ class Project(bonsai.core.tool.Project):
from bonsai.bim.module.project.data import LinksData
from bonsai.bim.module.project.decorator import ProjectDecorator
# Not sure if there's a difference between `instance_matrix` coming from `ray_cast`
# and usual `matrix_world`, maybe we can just get it from object always.
if instance_matrix is None:
instance_matrix = obj.matrix_world
# `instance_matrix` is the world matrix of the hit collection instance
# from `ray_cast` (link empty matrix included). Without it, the root
# empty is resolved as the collection's only instance.
cls.deselect_queried_linked_element()
cls.set_queried_linked_element(obj, guid, instance_matrix)
cls.select_linked_element_geom(obj, guid)
@@ -958,7 +1155,7 @@ class Project(bonsai.core.tool.Project):
ProjectDecorator.install(context)
@classmethod
def set_queried_linked_element(cls, obj: bpy.types.Object, guid: str, instance_matrix: Matrix) -> None:
def set_queried_linked_element(cls, obj: bpy.types.Object, guid: str, instance_matrix: Matrix | None) -> None:
props = tool.Project.get_project_props()
props.queried_obj = obj
props.queried_obj_root = cls.find_obj_root(obj, instance_matrix)
@@ -977,17 +1174,22 @@ class Project(bonsai.core.tool.Project):
del obj[field]
@classmethod
def find_obj_root(cls, obj: bpy.types.Object, matrix: Matrix) -> bpy.types.Object | None:
def find_obj_root(cls, obj: bpy.types.Object, matrix: Matrix | None) -> bpy.types.Object | None:
collections = set(obj.users_collection)
for o in bpy.data.objects:
if (
o.type != "EMPTY"
or o.instance_type != "COLLECTION"
or o.instance_collection not in collections
or not np.allclose(matrix, o.matrix_world, atol=1e-4)
):
continue
return o
candidates = [
o
for o in bpy.data.objects
if o.type == "EMPTY" and o.instance_type == "COLLECTION" and o.instance_collection in collections
]
if matrix is not None:
# `matrix` is the instance's world matrix - the instancing
# empty's matrix combined with the object's own local matrix
# (non-identity for instanced occurrence objects).
for o in candidates:
if np.allclose(matrix, np.array(o.matrix_world) @ np.array(obj.matrix_world), atol=1e-4):
return o
if len(candidates) == 1:
return candidates[0]
class SelectedGeometry(NamedTuple):
selected_vertices: list[tuple[float, float, float]]
@@ -996,8 +1198,11 @@ class Project(bonsai.core.tool.Project):
@classmethod
def get_selected_geometry(cls, obj: bpy.types.Object) -> SelectedGeometry:
# ID properties are returned as IDPropertyArrays (the whole
# property when empty, the items otherwise), which the GPU module
# rejects as batch indices - convert to plain tuples.
return cls.SelectedGeometry(
obj["selected_vertices"],
obj["selected_edges"],
obj["selected_tris"],
[tuple(v) for v in obj["selected_vertices"]],
[tuple(e) for e in obj["selected_edges"]],
[tuple(t) for t in obj["selected_tris"]],
)
+27 -25
View File
@@ -373,35 +373,37 @@ class Root(bonsai.core.tool.Root):
try:
new_aggregate = old_to_new[old_aggregate]
except:
bonsai.core.aggregate.unassign_object(
tool.Ifc,
tool.Aggregate,
tool.Collector,
relating_obj=tool.Ifc.get_object(old_aggregate),
related_obj=tool.Ifc.get_object(new[0]),
)
continue
bonsai.core.aggregate.assign_object(
tool.Ifc,
tool.Aggregate,
tool.Collector,
relating_obj=tool.Ifc.get_object(new_aggregate[0]),
related_obj=tool.Ifc.get_object(new[0]),
)
# Make sure that the array children also get reassigned to the correct aggregate
pset = ifcopenshell.util.element.get_pset(new[0], "BBIM_Array")
if pset:
array_children = tool.Array.get_all_children_objects(new[0])
for obj in array_children:
bonsai.core.aggregate.assign_object(
for new_entity in new:
bonsai.core.aggregate.unassign_object(
tool.Ifc,
tool.Aggregate,
tool.Collector,
relating_obj=tool.Ifc.get_object(new_aggregate[0]),
related_obj=tool.Ifc.get_object(tool.Ifc.get_entity(obj)),
relating_obj=tool.Ifc.get_object(old_aggregate),
related_obj=tool.Ifc.get_object(new_entity),
)
continue
for new_entity in new:
bonsai.core.aggregate.assign_object(
tool.Ifc,
tool.Aggregate,
tool.Collector,
relating_obj=tool.Ifc.get_object(new_aggregate[0]),
related_obj=tool.Ifc.get_object(new_entity),
)
# Make sure that the array children also get reassigned to the correct aggregate
pset = ifcopenshell.util.element.get_pset(new_entity, "BBIM_Array")
if pset:
array_children = tool.Array.get_all_children_objects(new_entity)
for obj in array_children:
bonsai.core.aggregate.assign_object(
tool.Ifc,
tool.Aggregate,
tool.Collector,
relating_obj=tool.Ifc.get_object(new_aggregate[0]),
related_obj=tool.Ifc.get_object(tool.Ifc.get_entity(obj)),
)
if new_aggregate is None:
return
+7
View File
@@ -357,6 +357,13 @@ class System(bonsai.core.tool.System):
if not cls.is_mep_element(element):
continue
# Array children inherit port topology from their parent's IFC
# entity, but their positions are derived — drawing ports on every
# copy of an arrayed segment doubles up markers and misleads the
# user into thinking each copy has its own port network.
if tool.Array.is_array_child(element):
continue
selected_element = element in connected_elements
verts_pos = []
@@ -0,0 +1,714 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Tests for the batched array-duplicate path.
`tool.Geometry.duplicate_ifc_object_n_times` lifts the per-call overhead of
`duplicate_ifc_objects` (snapshot, UI refresh, decorator reload, select
flips) out of the per-child loop in `_regenerate_array_body`. These tests
pin three contracts:
1. N-way batched duplicate produces N distinct entities mapped from the
source under `old_to_new[source_element]`, and the source object stays
selected throughout (no per-iteration deselect).
2. Per-layer batching collapses the N independent UI refreshes into one.
3. End-to-end array regen still yields the same number and shape of
children as the per-call baseline."""
import json
from unittest.mock import patch
import bpy
import ifcopenshell
import pytest
import bonsai.tool as tool
from test.bim.bootstrap import NewFile
pytestmark = pytest.mark.model
def _build_actuator(name: str = "Actuator") -> tuple[bpy.types.Object, ifcopenshell.entity_instance]:
"""Minimal IfcActuator + cube — matches the test_array_batch_recut.py shape."""
bpy.ops.bim.create_project()
bpy.ops.mesh.primitive_cube_add()
obj = bpy.context.active_object
obj.name = name
rprops = tool.Root.get_root_props()
rprops.ifc_product = "IfcElement"
bpy.ops.bim.assign_class(ifc_class="IfcActuator", predefined_type="ELECTRICACTUATOR", userdefined_type="")
element = tool.Ifc.get_entity(obj)
return obj, element
def _build_actuator_with_array_pset(
count: int, x: float = 1.0
) -> tuple[bpy.types.Object, ifcopenshell.entity_instance, list[dict]]:
obj, element = _build_actuator()
parent_data = [
{
"children": [],
"count": count,
"method": "OFFSET",
"x": x,
"y": 0.0,
"z": 0.0,
"use_local_space": False,
"sync_children": False,
}
]
pset = ifcopenshell.api.pset.add_pset(tool.Ifc.get(), product=element, name="BBIM_Array")
ifcopenshell.api.pset.edit_pset(
tool.Ifc.get(),
pset=pset,
properties={"Data": json.dumps(parent_data), "Parent": element.GlobalId},
)
return obj, element, parent_data
class TestDuplicateIfcObjectNTimes(NewFile):
def test_returns_empty_dict_for_zero_count(self):
obj, _ = _build_actuator()
result = tool.Geometry.duplicate_ifc_object_n_times(obj, 0)
assert result == {}
def test_returns_empty_dict_for_negative_count(self):
obj, _ = _build_actuator()
result = tool.Geometry.duplicate_ifc_object_n_times(obj, -3)
assert result == {}
def test_produces_n_distinct_entities(self):
obj, element = _build_actuator()
result = tool.Geometry.duplicate_ifc_object_n_times(obj, 5)
new_entities = result.get(element)
assert new_entities is not None
assert len(new_entities) == 5
assert len({e.id() for e in new_entities}) == 5
for new_entity in new_entities:
assert new_entity.is_a("IfcActuator")
assert new_entity.GlobalId != element.GlobalId
def test_source_stays_selected_after_batch(self):
obj, _ = _build_actuator()
obj.select_set(True)
tool.Geometry.duplicate_ifc_object_n_times(obj, 4)
assert obj in bpy.context.selected_objects, "source object must remain selected across batched duplicates"
def test_each_new_entity_has_blender_object(self):
obj, element = _build_actuator()
result = tool.Geometry.duplicate_ifc_object_n_times(obj, 3)
for new_entity in result[element]:
new_obj = tool.Ifc.get_object(new_entity)
assert new_obj is not None
assert new_obj is not obj
class TestBatchedRefreshUIDataCallCount(NewFile):
def test_n_times_calls_refresh_ui_data_once(self):
obj, _ = _build_actuator()
with patch("bonsai.bim.handler.refresh_ui_data") as refresh_mock:
tool.Geometry.duplicate_ifc_object_n_times(obj, 8)
assert (
refresh_mock.call_count == 1
), f"batched 8-way duplicate must call refresh_ui_data once, got {refresh_mock.call_count}"
def test_n_times_calls_reload_grid_decorator_once(self):
obj, _ = _build_actuator()
with patch.object(tool.Root, "reload_grid_decorator") as reload_mock:
tool.Geometry.duplicate_ifc_object_n_times(obj, 8)
assert reload_mock.call_count == 1
class TestRegenerateArrayEndToEnd(NewFile):
def test_regenerate_array_creates_expected_children(self):
obj, element, parent_data = _build_actuator_with_array_pset(count=8)
bpy.context.view_layer.objects.active = obj
tool.Model.regenerate_array(obj, parent_data)
layer = parent_data[0]
assert len(layer["children"]) == 7, "8-element array means 7 new children (parent + 7)"
for child_guid in layer["children"]:
child_element = tool.Ifc.get().by_guid(child_guid)
assert child_element is not None
assert child_element.is_a("IfcActuator")
child_pset = ifcopenshell.util.element.get_pset(child_element, "BBIM_Array")
assert child_pset is not None
assert child_pset["Parent"] == element.GlobalId
def test_regenerate_array_parent_stays_selected(self):
obj, element, parent_data = _build_actuator_with_array_pset(count=4)
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
tool.Model.regenerate_array(obj, parent_data)
assert (
obj in bpy.context.selected_objects
), "regenerate_array must leave parent_obj selected on return (post-condition)"
def test_regen_operator_leaves_only_parent_selected_and_active(self):
"""Post-condition parity between grow and shrink for the user-facing
``bim.regenerate_array`` operator: only the parent is selected + active;
every child is deselected. Pre-fix the grow path left new children
selected, creating inconsistency with the shrink path.
Scoped to the operator, not the tool method ``remove_array`` and
``apply_array`` also invoke ``tool.Model.regenerate_array`` internally
but expect a different post-selection state (children stay selected
for user follow-up work)."""
obj, element, parent_data = _build_actuator_with_array_pset(count=6)
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
bpy.ops.bim.regenerate_array()
assert obj in bpy.context.selected_objects
assert bpy.context.view_layer.objects.active is obj
parent_pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
parent_data_after = json.loads(parent_pset["Data"])
for child_guid in parent_data_after[0]["children"]:
child_element = tool.Ifc.get().by_guid(child_guid)
child_obj = tool.Ifc.get_object(child_element)
assert (
child_obj not in bpy.context.selected_objects
), f"child {child_obj.name} must be deselected on regenerate_array return"
def test_regen_operator_after_shrink_still_leaves_only_parent_selected(self):
obj, element, parent_data = _build_actuator_with_array_pset(count=6)
bpy.context.view_layer.objects.active = obj
bpy.ops.bim.regenerate_array()
parent_pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
arrays = json.loads(parent_pset["Data"])
arrays[0]["count"] = 3
pset_entity = tool.Ifc.get().by_id(parent_pset["id"])
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset_entity, properties={"Data": json.dumps(arrays)})
bpy.ops.bim.regenerate_array()
assert obj in bpy.context.selected_objects
assert bpy.context.view_layer.objects.active is obj
parent_pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
arrays_after = json.loads(parent_pset["Data"])
for child_guid in arrays_after[0]["children"]:
child_element = tool.Ifc.get().by_guid(child_guid)
child_obj = tool.Ifc.get_object(child_element)
assert child_obj not in bpy.context.selected_objects
def test_regenerate_array_child_positions_match_offset(self):
obj, element, parent_data = _build_actuator_with_array_pset(count=4, x=2.5)
bpy.context.view_layer.objects.active = obj
parent_x = obj.matrix_world.translation.x
tool.Model.regenerate_array(obj, parent_data)
layer = parent_data[0]
for i, child_guid in enumerate(layer["children"], start=1):
child_element = tool.Ifc.get().by_guid(child_guid)
child_obj = tool.Ifc.get_object(child_element)
expected_x = parent_x + 2.5 * i
assert child_obj.matrix_world.translation.x == pytest.approx(
expected_x
), f"child {i}: expected x≈{expected_x}, got {child_obj.matrix_world.translation.x}"
class TestRegenerateArrayUIRefreshCoalesces(NewFile):
def test_n_children_grow_calls_refresh_ui_data_once_per_layer(self):
obj, element, parent_data = _build_actuator_with_array_pset(count=8)
bpy.context.view_layer.objects.active = obj
with patch("bonsai.bim.handler.refresh_ui_data") as refresh_mock:
tool.Model.regenerate_array(obj, parent_data)
assert refresh_mock.call_count == 1, (
"growing an array layer from 0 to 7 children must call refresh_ui_data once, "
f"got {refresh_mock.call_count}"
)
def test_n_children_grow_calls_reload_grid_decorator_once_per_layer(self):
obj, element, parent_data = _build_actuator_with_array_pset(count=8)
bpy.context.view_layer.objects.active = obj
with patch.object(tool.Root, "reload_grid_decorator") as reload_mock:
tool.Model.regenerate_array(obj, parent_data)
assert reload_mock.call_count == 1
class TestRecreateAggregateIteratesAllNew(NewFile):
"""Pins the [0]-indexing sweep in tool/root.py recreate_aggregate. When the
new-list has N>1 entries (the batched-duplicate shape), every entry must be
aggregate-assigned, not just new[0]."""
def test_iterates_assign_object_per_new_entity_when_old_has_aggregate(self):
from unittest.mock import Mock
old_assembly = Mock()
old_assembly.is_a = lambda c: c == "IfcElementAssembly"
old_parent_aggregate = Mock()
old_parent_aggregate.is_a = lambda c: False
new_assemblies = [Mock(), Mock(), Mock()]
new_parent_aggregate = [Mock()]
old_to_new = {old_assembly: new_assemblies, old_parent_aggregate: new_parent_aggregate}
with patch(
"ifcopenshell.util.element.get_aggregate",
side_effect=lambda e: old_parent_aggregate if e is old_assembly else None,
), patch("bonsai.core.aggregate.assign_object") as assign_mock, patch(
"ifcopenshell.util.element.get_pset", return_value=None
), patch.object(
tool.Ifc, "get_object", side_effect=lambda e: Mock(spec=bpy.types.Object)
), patch.object(
tool.Blender, "select_and_activate_single_object"
):
tool.Root.recreate_aggregate(old_to_new)
assert (
assign_mock.call_count == 3
), f"recreate_aggregate must assign each of N new entities (not just new[0]); got {assign_mock.call_count}"
def test_iterates_unassign_object_per_new_entity_when_aggregate_missing(self):
from unittest.mock import Mock
old_assembly = Mock()
old_assembly.is_a = lambda c: c == "IfcElementAssembly"
old_parent_aggregate = Mock()
new_assemblies = [Mock(), Mock(), Mock()]
old_to_new = {old_assembly: new_assemblies} # parent aggregate NOT in old_to_new
with patch(
"ifcopenshell.util.element.get_aggregate",
side_effect=lambda e: old_parent_aggregate if e is old_assembly else None,
), patch("bonsai.core.aggregate.unassign_object") as unassign_mock, patch.object(
tool.Ifc, "get_object", side_effect=lambda e: Mock(spec=bpy.types.Object)
):
tool.Root.recreate_aggregate(old_to_new)
assert unassign_mock.call_count == 3, (
f"recreate_aggregate must unassign each of N new entities when parent aggregate is missing; "
f"got {unassign_mock.call_count}"
)
class TestRecreateConnectionsZipsPairs(NewFile):
"""Pins the [0]-indexing sweep in tool/duplicate.py recreate_connections. When
both sides of a connection are duplicated N times, zip-pair the N new
relating with N new related; when only one side is duplicated, skip."""
def _make_connection_data(self):
from unittest.mock import Mock
from bonsai.tool.duplicate import ConnectionRecord
return ConnectionRecord(
type="path",
relating_element=Mock(),
related_element=Mock(),
relating_connection_type="ATSTART",
related_connection_type="ATEND",
relating_priorities=[],
related_priorities=[],
)
def test_zips_n_pairs_when_both_sides_duplicated(self):
from unittest.mock import Mock
data = self._make_connection_data()
old_to_new = {
data.relating_element: [Mock(), Mock(), Mock()],
data.related_element: [Mock(), Mock(), Mock()],
}
relationship = {Mock(): data}
with patch.object(tool.Ifc, "run", return_value=None) as run_mock:
tool.Duplicate.recreate_connections(relationship, old_to_new)
connect_calls = [c for c in run_mock.call_args_list if c.args and c.args[0] == "geometry.connect_path"]
assert (
len(connect_calls) == 3
), f"zip-pair must create 3 connect_path calls for 3-vs-3 batched duplicate; got {len(connect_calls)}"
def test_skips_when_other_side_not_duplicated(self):
from unittest.mock import Mock
data = self._make_connection_data()
# Only relating side is in old_to_new; related side was NOT duplicated.
old_to_new = {data.relating_element: [Mock(), Mock(), Mock()]}
relationship = {Mock(): data}
with patch.object(tool.Ifc, "run", return_value=None) as run_mock:
tool.Duplicate.recreate_connections(relationship, old_to_new)
connect_calls = [c for c in run_mock.call_args_list if c.args and c.args[0] == "geometry.connect_path"]
assert (
connect_calls == []
), "when only one side of a connection is in old_to_new, no connections should be recreated"
def test_single_pair_case_unchanged(self):
"""Pre-sweep behavior (1 source -> 1 new) must still work — zip with two 1-element lists."""
from unittest.mock import Mock
data = self._make_connection_data()
old_to_new = {
data.relating_element: [Mock()],
data.related_element: [Mock()],
}
relationship = {Mock(): data}
with patch.object(tool.Ifc, "run", return_value=None) as run_mock:
tool.Duplicate.recreate_connections(relationship, old_to_new)
connect_calls = [c for c in run_mock.call_args_list if c.args and c.args[0] == "geometry.connect_path"]
assert len(connect_calls) == 1
class TestRecalculateWallsWithNewConnections(NewFile):
"""Pins the post-connection wall recalc: after ``recreate_connections``
wires new IfcRelConnectsPathElements onto duplicated walls, the wall
bodies must be re-recalculated because the in-loop ``regenerate_wall``
fired before the connections existed. Otherwise the junction geometry
stays stale and the user has to manually regen."""
def test_walls_with_new_connections_are_recalculated(self):
from unittest.mock import Mock
wall_new = Mock()
wall_new.is_a = lambda c: c == "IfcWall"
wall_new.ConnectedTo = [Mock()]
wall_new.ConnectedFrom = []
wall_obj = Mock(spec=bpy.types.Object)
old_to_new = {Mock(): [wall_new]}
with patch.object(tool.Ifc, "get_object", return_value=wall_obj), patch.object(
tool.Model, "recalculate_walls"
) as recalc_mock:
tool.Geometry._recalculate_walls_with_new_connections(old_to_new)
assert recalc_mock.call_count == 1
assert recalc_mock.call_args.args[0] == [wall_obj]
def test_walls_without_connections_are_skipped(self):
from unittest.mock import Mock
wall_new = Mock()
wall_new.is_a = lambda c: c == "IfcWall"
wall_new.ConnectedTo = []
wall_new.ConnectedFrom = []
old_to_new = {Mock(): [wall_new]}
with patch.object(tool.Ifc, "get_object", return_value=Mock(spec=bpy.types.Object)), patch.object(
tool.Model, "recalculate_walls"
) as recalc_mock:
tool.Geometry._recalculate_walls_with_new_connections(old_to_new)
assert recalc_mock.call_count == 0, "walls with no new connections must not trigger a recalc pass"
def test_non_wall_entities_are_skipped(self):
from unittest.mock import Mock
actuator_new = Mock()
actuator_new.is_a = lambda c: c == "IfcActuator"
actuator_new.ConnectedTo = [Mock()]
old_to_new = {Mock(): [actuator_new]}
with patch.object(tool.Ifc, "get_object", return_value=Mock(spec=bpy.types.Object)), patch.object(
tool.Model, "recalculate_walls"
) as recalc_mock:
tool.Geometry._recalculate_walls_with_new_connections(old_to_new)
assert recalc_mock.call_count == 0
def test_multiple_new_walls_collected_into_one_call(self):
from unittest.mock import Mock
wall_a_new = Mock()
wall_a_new.is_a = lambda c: c == "IfcWall"
wall_a_new.ConnectedTo = [Mock()]
wall_a_new.ConnectedFrom = []
wall_b_new = Mock()
wall_b_new.is_a = lambda c: c == "IfcWall"
wall_b_new.ConnectedTo = []
wall_b_new.ConnectedFrom = [Mock()]
objs = {wall_a_new: Mock(spec=bpy.types.Object), wall_b_new: Mock(spec=bpy.types.Object)}
old_to_new = {Mock(): [wall_a_new], Mock(): [wall_b_new]}
with patch.object(tool.Ifc, "get_object", side_effect=lambda e: objs.get(e)), patch.object(
tool.Model, "recalculate_walls"
) as recalc_mock:
tool.Geometry._recalculate_walls_with_new_connections(old_to_new)
assert recalc_mock.call_count == 1
assert set(recalc_mock.call_args.args[0]) == {objs[wall_a_new], objs[wall_b_new]}
class TestMEPActionGuardsAgainstArrayChildren(NewFile):
"""Pins the array-child guards on the three MEP-action visibility helpers.
Writable MEP actions (add fitting, remove terminal, join, re-edit bend)
applied to an array child get wiped by the next regen gating the icons
at the visibility layer prevents that footgun."""
def test_active_is_flow_segment_returns_false_for_array_child(self):
from unittest.mock import Mock
from bonsai.bim.module.model.mep import _active_is_flow_segment
obj = Mock(spec=bpy.types.Object)
element = Mock()
element.is_a = lambda c: c == "IfcFlowSegment"
with patch.object(tool.Ifc, "get_entity", return_value=element), patch.object(
tool.Array, "is_array_child", return_value=True
), patch.object(tool.System, "has_parametric_body", return_value=True):
assert _active_is_flow_segment(obj) is False
def test_active_is_flow_segment_true_for_non_array_parent(self):
from unittest.mock import Mock
from bonsai.bim.module.model.mep import _active_is_flow_segment
obj = Mock(spec=bpy.types.Object)
element = Mock()
element.is_a = lambda c: c == "IfcFlowSegment"
with patch.object(tool.Ifc, "get_entity", return_value=element), patch.object(
tool.Array, "is_array_child", return_value=False
), patch.object(tool.System, "has_parametric_body", return_value=True):
assert _active_is_flow_segment(obj) is True
def test_active_is_bend_fitting_returns_false_for_array_child(self):
from unittest.mock import Mock
from bonsai.bim.module.model.mep import _active_is_bend_fitting
obj = Mock(spec=bpy.types.Object)
element = Mock()
with patch.object(tool.Ifc, "get_entity", return_value=element), patch(
"bonsai.bim.module.model.mep._is_bend_fitting", return_value=True
), patch.object(tool.Array, "is_array_child", return_value=True):
assert _active_is_bend_fitting(obj) is False
def test_n_mep_selected_returns_false_when_any_selected_is_array_child(self):
from unittest.mock import Mock
from bonsai.bim.module.model.mep import _n_mep_selected
obj_a = Mock(spec=bpy.types.Object)
obj_b = Mock(spec=bpy.types.Object)
element_a = Mock()
element_b = Mock()
def is_array_child(el):
return el is element_b
with patch.object(tool.Blender, "get_selected_objects", return_value=[obj_a, obj_b]), patch.object(
tool.Ifc, "get_entity", side_effect=lambda o: element_a if o is obj_a else element_b
), patch.object(tool.System, "is_mep_element", return_value=True), patch.object(
tool.Array, "is_array_child", side_effect=is_array_child
):
assert _n_mep_selected(2) is False
class TestSelectOnlyParent(NewFile):
"""Pins ``tool.Array.select_only_parent`` — the shared helper wired into
both ``bim.regenerate_array`` and ``bim.finish_editing_array`` so the
grow / shrink / edit-commit paths converge on the same post-condition:
only the parent is selected + active."""
def test_deselects_children_selects_and_activates_parent(self):
obj, element, parent_data = _build_actuator_with_array_pset(count=4)
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
tool.Model.regenerate_array(obj, parent_data)
for child_guid in parent_data[0]["children"]:
child_element = tool.Ifc.get().by_guid(child_guid)
child_obj = tool.Ifc.get_object(child_element)
child_obj.select_set(True)
tool.Array.select_only_parent(obj, bpy.context)
assert obj in bpy.context.selected_objects
assert bpy.context.view_layer.objects.active is obj
for child_guid in parent_data[0]["children"]:
child_element = tool.Ifc.get().by_guid(child_guid)
child_obj = tool.Ifc.get_object(child_element)
assert child_obj not in bpy.context.selected_objects
class TestIsArrayChild(NewFile):
"""Pins ``tool.Array.is_array_child`` — the light helper used by the port
decorator (and any future per-element guard) to skip array children."""
def test_returns_false_when_no_bbim_array_pset(self):
from unittest.mock import Mock
element = Mock()
with patch("ifcopenshell.util.element.get_pset", return_value=None):
assert tool.Array.is_array_child(element) is False
def test_returns_false_on_the_array_parent_itself(self):
from unittest.mock import Mock
element = Mock()
element.GlobalId = "PARENT_GUID"
with patch("ifcopenshell.util.element.get_pset", return_value={"Parent": "PARENT_GUID"}):
assert tool.Array.is_array_child(element) is False
def test_returns_true_when_parent_guid_points_elsewhere(self):
from unittest.mock import Mock
element = Mock()
element.GlobalId = "CHILD_GUID"
with patch("ifcopenshell.util.element.get_pset", return_value={"Parent": "PARENT_GUID"}):
assert tool.Array.is_array_child(element) is True
class TestOrphanArrayChildPrune(NewFile):
"""Outliner / keyboard delete of a Bonsai-managed array child bypasses
``bim.delete``'s cascade, leaving the IFC entity and its opening / filling
refs behind. Regen must prune these orphans before the main loop or the
stale registry entry corrupts the ``batch_host_recut`` drain."""
def test_orphan_ifc_entity_pruned_from_children_list(self):
obj, element, parent_data = _build_actuator_with_array_pset(count=4)
bpy.context.view_layer.objects.active = obj
tool.Model.regenerate_array(obj, parent_data)
assert len(parent_data[0]["children"]) == 3
orphan_guid = parent_data[0]["children"][1]
orphan_element = tool.Ifc.get().by_guid(orphan_guid)
orphan_obj = tool.Ifc.get_object(orphan_element)
assert orphan_obj is not None
bpy.data.objects.remove(orphan_obj, do_unlink=True)
tool.Model.regenerate_array(obj, parent_data)
assert (
orphan_guid not in parent_data[0]["children"]
), "orphan GUID must be pruned from array['children'] once its Blender object is dead"
try:
still_there = tool.Ifc.get().by_guid(orphan_guid)
except RuntimeError:
still_there = None
assert still_there is None, "orphan IFC entity must be cascade-removed, not left as a leak"
def test_regen_completes_when_child_deleted_outside_bim_cascade(self):
obj, element, parent_data = _build_actuator_with_array_pset(count=6)
bpy.context.view_layer.objects.active = obj
tool.Model.regenerate_array(obj, parent_data)
victim_guid = parent_data[0]["children"][2]
victim_element = tool.Ifc.get().by_guid(victim_guid)
victim_obj = tool.Ifc.get_object(victim_element)
bpy.data.objects.remove(victim_obj, do_unlink=True)
tool.Model.regenerate_array(obj, parent_data)
assert len(parent_data[0]["children"]) == 5, "regen must rebuild to the target count after pruning the orphan"
for guid in parent_data[0]["children"]:
child = tool.Ifc.get().by_guid(guid)
child_obj = tool.Ifc.get_object(child)
assert child_obj is not None, "every surviving child must have a live Blender object"
class TestRecreatePortConnectionsZipsPairs(NewFile):
"""Pins the [0]-indexing sweep in tool/duplicate.py recreate_port_connections.
When both sides of a port-to-port connection are duplicated N times, the
connection must be recreated on every pair of new siblings not just the
first. Matters for arrayed MEP segments (pipes / ducts / cables) where each
child in the array should stay connected to its neighbour after regen."""
def _make_snapshot(self, relating_element, records, port_counts):
from bonsai.tool.duplicate import PortConnectionSnapshot
return PortConnectionSnapshot(
by_element={relating_element: records},
port_counts=port_counts,
)
def _make_record(self, related_element, relating_port_index=0, related_port_index=0, direction="SOURCE"):
from bonsai.tool.duplicate import PortConnectionRecord
return PortConnectionRecord(
relating_port_index=relating_port_index,
related_element=related_element,
related_port_index=related_port_index,
direction=direction,
)
def test_zips_n_pairs_when_both_sides_duplicated(self):
from unittest.mock import Mock
relating_old = Mock()
related_old = Mock()
record = self._make_record(related_old)
snapshot = self._make_snapshot(relating_old, [record], port_counts={})
old_to_new = {
relating_old: [Mock(), Mock(), Mock()],
related_old: [Mock(), Mock(), Mock()],
}
fake_ports = [Mock(), Mock()]
with patch.object(tool.System, "get_ports", return_value=fake_ports), patch.object(
tool.Ifc, "run", return_value=None
) as run_mock:
tool.Duplicate.recreate_port_connections(snapshot, old_to_new)
connect_calls = [c for c in run_mock.call_args_list if c.args and c.args[0] == "system.connect_port"]
assert (
len(connect_calls) == 3
), f"zip-pair must create 3 connect_port calls for 3-vs-3 batched MEP duplicate; got {len(connect_calls)}"
def test_skips_when_other_side_not_duplicated(self):
from unittest.mock import Mock
relating_old = Mock()
related_old = Mock()
record = self._make_record(related_old)
snapshot = self._make_snapshot(relating_old, [record], port_counts={})
# Only relating side is in old_to_new.
old_to_new = {relating_old: [Mock(), Mock(), Mock()]}
with patch.object(tool.System, "get_ports", return_value=[Mock()]), patch.object(
tool.Ifc, "run", return_value=None
) as run_mock:
tool.Duplicate.recreate_port_connections(snapshot, old_to_new)
connect_calls = [c for c in run_mock.call_args_list if c.args and c.args[0] == "system.connect_port"]
assert connect_calls == [], "when only one side is in old_to_new, no port connections should be recreated"
def test_single_pair_case_unchanged(self):
"""Pre-sweep behavior (1 source -> 1 new) must still work — zip with two 1-element lists."""
from unittest.mock import Mock
relating_old = Mock()
related_old = Mock()
record = self._make_record(related_old)
snapshot = self._make_snapshot(relating_old, [record], port_counts={})
old_to_new = {relating_old: [Mock()], related_old: [Mock()]}
with patch.object(tool.System, "get_ports", return_value=[Mock()]), patch.object(
tool.Ifc, "run", return_value=None
) as run_mock:
tool.Duplicate.recreate_port_connections(snapshot, old_to_new)
connect_calls = [c for c in run_mock.call_args_list if c.args and c.args[0] == "system.connect_port"]
assert len(connect_calls) == 1
@@ -346,7 +346,9 @@ def test_active_is_flow_segment_classifies_segment_vs_fitting():
fitting_elem.is_a = lambda c: c == "IfcFlowFitting"
plain = Mock()
with patch("bonsai.bim.module.model.mep.tool.System.has_parametric_body", return_value=True):
with patch("bonsai.bim.module.model.mep.tool.System.has_parametric_body", return_value=True), patch(
"bonsai.bim.module.model.mep.tool.Array.is_array_child", return_value=False
):
with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=segment_elem):
assert _active_is_flow_segment(plain) is True
with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=fitting_elem):
@@ -164,6 +164,62 @@ def test_stale_element_skipped_at_drain():
assert recut.call_count == 0
class _DeadStructRNA:
"""Simulates a Blender object whose StructRNA has been removed — every
attribute access raises ReferenceError. Enqueue this as voided_obj to
reproduce the outliner-mid-batch-delete crash."""
def __getattr__(self, name):
raise ReferenceError("StructRNA of type Object has been removed")
def __bool__(self):
raise ReferenceError("StructRNA of type Object has been removed")
def test_dead_structrna_recut_skipped_at_drain():
"""Blender object is deleted while the batch is open (outliner delete +
manual DEL bypass the bim.delete cascade). The drain must skip it silently
not raise so unrelated hosts in the same batch still get their recut."""
from bonsai import tool
dead_obj = _DeadStructRNA()
live_obj = _mock_voided_obj("LiveWall")
rep = Mock()
def get_entity(obj):
# Called only when the guard clears — for the dead ref, guard short-circuits first.
return _mock_element(2)
with patch("bonsai.core.geometry.switch_representation") as recut, patch.object(
tool.Ifc, "get_entity", side_effect=get_entity
), patch.object(tool.Geometry, "get_active_representation", return_value=rep):
with tool.Geometry.batch_host_recut():
tool.Geometry._host_recut_queue[999] = (dead_obj, rep)
tool.Geometry.recut_host(live_obj, rep)
assert recut.call_count == 1, "live host must still get its recut despite a dead sibling in the queue"
drained_obj = recut.call_args.kwargs["obj"]
assert drained_obj is live_obj
def test_dead_structrna_update_skipped_at_drain():
"""Same guarantee for update_representation drain path."""
from bonsai import tool
dead_obj = _DeadStructRNA()
live_obj = _mock_voided_obj("LiveWall")
bpy_ops_mock = Mock()
with patch("bonsai.tool.geometry.bpy.ops", new=bpy_ops_mock), patch.object(
tool.Ifc, "get_entity", return_value=_mock_element(42)
), patch.object(tool.Geometry, "get_active_representation", return_value=Mock()):
with tool.Geometry.batch_host_recut():
tool.Geometry._host_update_queue[999] = dead_obj
tool.Geometry.update_host_representation(live_obj)
assert bpy_ops_mock.bim.update_representation.call_count == 1
def test_exception_inside_batch_still_resets_state():
from bonsai import tool
+4 -4
View File
@@ -630,15 +630,15 @@ class TestUsingArrays(NewFile):
def test_remove_array_first_to_last(self):
self.setup_array(add_second_layer=True)
bpy.ops.bim.remove_array(item=0)
assert len(bpy.context.selected_objects) == 3
assert len(self._array_objects()) == 3
bpy.ops.bim.remove_array(item=0)
assert len(bpy.context.selected_objects) == 1
assert len(self._array_objects()) == 1
def test_apply_array_1_layer(self):
self.setup_array()
bpy.ops.bim.apply_array()
objs = bpy.context.selected_objects
objs = self._array_objects()
assert len(objs) == 4
# check BBIM_Array psets are removed
for obj in objs:
@@ -664,7 +664,7 @@ class TestUsingArrays(NewFile):
self.setup_array(sync_children=True)
bpy.ops.bim.apply_array()
objs = bpy.context.selected_objects
objs = self._array_objects()
assert len(objs) == 4
# check BBIM_Array psets are removed
for obj in objs:
+69
View File
@@ -501,3 +501,72 @@ class TestGettingLinkedElementGeomSlice:
obj = cast(bpy.types.Object, obj)
slice_ = subject.Link.get_linked_element_geom_slice(obj, "aaa")
assert range(15)[slice_] == range(5)
class TestEncodeDecodeLinkFilter:
def test_plain_include_round_trip(self):
assert subject.encode_link_filter("IfcWall", "") == "IfcWall"
assert subject.decode_link_filter("IfcWall") == ("IfcWall", "", False, "")
def test_empty_filter_encodes_to_none(self):
assert subject.encode_link_filter("", "") is None
assert subject.decode_link_filter(None) == ("", "", False, "")
assert subject.decode_link_filter("") == ("", "", False, "")
def test_exclude_promotes_to_json(self):
encoded = subject.encode_link_filter('IfcElement, group="X"', 'IfcSlab, parent="Y"')
assert encoded.startswith("{")
assert subject.decode_link_filter(encoded) == ('IfcElement, group="X"', 'IfcSlab, parent="Y"', False, "")
def test_loaded_promotes_to_json(self):
encoded = subject.encode_link_filter("IfcWall", "", loaded=True)
assert encoded.startswith("{")
assert subject.decode_link_filter(encoded) == ("IfcWall", "", True, "")
def test_loaded_without_filter(self):
encoded = subject.encode_link_filter("", "", loaded=True)
assert subject.decode_link_filter(encoded) == ("", "", True, "")
def test_legacy_non_json_decodes_as_include(self):
legacy = 'IfcElement, location="House - Type B"'
assert subject.decode_link_filter(legacy) == (legacy, "", False, "")
def test_malformed_json_decodes_as_include(self):
assert subject.decode_link_filter("{not json") == ("{not json", "", False, "")
def test_display_name_promotes_to_json(self):
encoded = subject.encode_link_filter("IfcWall", "", display_name="North Wing")
assert encoded.startswith("{")
assert subject.decode_link_filter(encoded) == ("IfcWall", "", False, "North Wing")
class TestGetLinkCachePaths:
def test_empty_filter_keeps_legacy_names(self):
blend, json_ = subject.get_link_cache_paths("/x/File A.ifc", "")
assert blend.name == "File A.ifc.cache.blend"
assert json_.name == "File A.ifc.cache.json"
def test_include_only_hash_matches_pre_exclude_formula(self):
# Existing caches were keyed by md5(query)[:8]; they must stay valid.
import hashlib
blend, _ = subject.get_link_cache_paths("/x/File A.ifc", "IfcWall")
expected = hashlib.md5(b"IfcWall").hexdigest()[:8]
assert blend.name == f"File A.ifc.cache.{expected}.blend"
def test_blend_and_json_share_a_suffix(self):
blend, json_ = subject.get_link_cache_paths("/x/File A.ifc", "IfcWall", "IfcDoor")
assert blend.name.removesuffix("blend") == json_.name.removesuffix("json")
def test_same_include_different_exclude_do_not_collide(self):
# The reason the cache key hashes both strings: same-include links
# with different excludes must not serve each other's geometry.
a, _ = subject.get_link_cache_paths("/x/f.ifc", "IfcElement", "IfcSlab")
b, _ = subject.get_link_cache_paths("/x/f.ifc", "IfcElement", "IfcDoor")
c, _ = subject.get_link_cache_paths("/x/f.ifc", "IfcElement", "")
assert len({a.name, b.name, c.name}) == 3
def test_exclude_only_distinct_from_empty_filter(self):
a, _ = subject.get_link_cache_paths("/x/f.ifc", "", "IfcDoor")
b, _ = subject.get_link_cache_paths("/x/f.ifc", "", "")
assert a.name != b.name
@@ -16,8 +16,6 @@
# 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.placement
from ifcopenshell import entity_instance
@@ -36,7 +34,7 @@ def update_fallback_position(file: ifcopenshell.file, lp: entity_instance):
if not lp.CartesianPosition:
lp.CartesianPosition = file.createIfcAxis2Placement3D(Location=file.createIfcCartesianPoint((0.0, 0.0, 0.0)))
p = np.array(ifcopenshell.util.placement.get_axis2placement(lp.RelativePlacement))
p = ifcopenshell.util.placement.get_local_placement(lp)
x = float(p[0, 3])
y = float(p[1, 3])
@@ -642,16 +642,16 @@ class entity_instance:
return_type: type[dict] = dict,
ignore: Sequence[str] = (),
) -> dict[str, Any]:
"""More perfomant version of `.get_info()` but with limited arguments values.\n
Method has exactly the same signature as `.get_info()` but it doesn't support getting information non-recursively.
Currently supported arguments values:
* recursive: `True` (will fail with default `False` value from `.get_info()`)
* return_type: `dict`
* ignore: `()` (empty tuple)
"""More perfomant version of `.get_info()`.\n
Method has exactly the same signature as `.get_info()`, but the fast C++
path only implements ``recursive=True``, ``return_type=dict`` and
``ignore=()``. Any other combination falls back to the pure Python
`.get_info()`, where no meaningful performance gain is possible anyway
as the cost is dominated by the recursive traversal.
"""
assert recursive
assert return_type is dict
assert len(ignore) == 0
return ifcopenshell_wrapper.get_info_cpp(self.wrapped_data, include_identifier)
if recursive and return_type is dict and not ignore:
return ifcopenshell_wrapper.get_info_cpp(self.wrapped_data, include_identifier)
return self.get_info(
include_identifier=include_identifier, recursive=recursive, return_type=return_type, ignore=ignore
)
@@ -1125,7 +1125,8 @@ def get_decomposition(element: ifcopenshell.entity_instance, is_recursive=True)
"""
Retrieves all subelements of an element based on the spatial decomposition
hierarchy. This includes all subspaces and elements contained in subspaces,
parts of an aggregate, all openings, and all fills of any openings.
parts of an aggregate, all openings, all fills of any openings, and any
surface features adhering to an element (IFC4.3 and above).
:param element: The IFC element
:return: The decomposition of the element
@@ -1161,6 +1162,10 @@ def get_decomposition(element: ifcopenshell.entity_instance, is_recursive=True)
related = rel.RelatedObjects
queue.extend(related)
results.update(related)
for rel in getattr(element, "HasSurfaceFeatures", []):
related = rel.RelatedSurfaceFeatures
queue.extend(related)
results.update(related)
if not is_recursive:
break
return results
@@ -1251,6 +1256,8 @@ def get_parent(
- Nesting: components are attached to a host parent
- Filling: the physical element fills an opening, such as a window filling a hole
- Voiding: the opening voids another physical element, such as a hole in a wall
- Adherence: a surface feature adheres to a host element, such as a road
marking adhering to a road course (IFC4.3 and above)
:param element: Any physical or spatial element in the tree
:param ifc_class: Optionally filter the type of parent you're after. For
@@ -1270,6 +1277,7 @@ def get_parent(
or get_nest(element)
or get_filled_void(element)
or get_voided_element(element)
or get_adhered_element(element)
)
if not ifc_class:
@@ -1321,6 +1329,28 @@ def get_voided_element(element: ifcopenshell.entity_instance) -> Union[ifcopensh
return rel[0].RelatingBuildingElement
def get_adhered_element(element: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]:
"""If the element is a surface feature, get the element it adheres to
In IFC4.3 an IfcSurfaceFeature (such as a road marking) adheres to a host
element through the IfcRelAdheresToElement relationship. This is a [1:1]
cardinality hierarchical relationship, in the same family as aggregation,
containment and nesting.
:param element: The IfcSurfaceFeature
:return: The host element that the surface feature adheres to
Example:
.. code:: python
marking = file.by_type("IfcSurfaceFeature")[0]
host = ifcopenshell.util.element.get_adhered_element(marking)
"""
if rel := getattr(element, "AdheresToElement", None):
return rel[0].RelatingElement
def get_aggregate(element: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]:
"""
Retrieves the aggregate parent of an element.
@@ -1415,6 +1445,29 @@ def get_contained(element: ifcopenshell.entity_instance) -> list[ifcopenshell.en
return objects
def get_surface_features(element: ifcopenshell.entity_instance) -> list[ifcopenshell.entity_instance]:
"""Retrieves the surface features that adhere to an element.
In IFC4.3 an IfcSurfaceFeature (such as a road marking) adheres to a host
element through the IfcRelAdheresToElement relationship.
:param element: The IFC element
:return: The surface features adhering to the element
Example:
.. code:: python
element = file.by_type("IfcCourse")[0]
markings = ifcopenshell.util.element.get_surface_features(element)
"""
objects: list[ifcopenshell.entity_instance] = []
if has_surface_features := getattr(element, "HasSurfaceFeatures", ()):
for rel in has_surface_features:
objects.extend(rel.RelatedSurfaceFeatures)
return objects
def get_components(
element: ifcopenshell.entity_instance, include_ports: bool = False
) -> list[ifcopenshell.entity_instance]:
@@ -912,6 +912,13 @@ def convert_file_length_units(ifc_file: ifcopenshell.file, target_units: str = "
new_value = convert_value(val)
setattr(element, attr.name(), new_value)
# IfcGeometricRepresentationContext.Precision is typed as a plain IfcReal
# but is interpreted in the project length unit, so it must be scaled too.
# Subcontexts derive Precision from their parent and cannot be set.
for context in file_patched.by_type("IfcGeometricRepresentationContext", include_subtypes=False):
if context.Precision is not None:
context.Precision = convert_unit(context.Precision, old_length, new_length)
has_map_unit = False
if (
ifc_file.schema == "IFC2X3"
@@ -64,3 +64,14 @@ class TestGetInfo2(test.bootstrap.IFC4):
"Outer": {"CfsFaces": None, "type": "IfcClosedShell"},
"type": "IfcFacetedBrep",
}
def test_unsupported_arguments_fall_back_to_get_info(self):
# Regression test for #4270: get_info_2 raised a bare AssertionError
# when called with its own default arguments (recursive=False) or any
# other combination the C++ fast path does not implement. It must
# delegate to get_info instead of crashing.
brep = self.file.create_entity("IfcFacetedBrep")
shell = self.file.create_entity("IfcClosedShell")
brep.Outer = shell
assert brep.get_info_2() == brep.get_info()
assert brep.get_info_2(recursive=True, ignore=("Outer",)) == brep.get_info(recursive=True, ignore=("Outer",))
@@ -19,6 +19,7 @@
from math import pi
import numpy as np
import pytest
import ifcopenshell.api.context
import ifcopenshell.api.georeference
@@ -258,6 +259,23 @@ class TestConvertFileLengthUnits(test.bootstrap.IFC2X3):
assert max(i.id() for i in output) == len(output.wrapped_data.entity_names()) + 1
assert subject.get_full_unit_name(subject.get_project_unit(output, "LENGTHUNIT")) == "METRE"
def test_precision_conversion(self):
# Regression test for #6127: IfcGeometricRepresentationContext.Precision
# is typed IfcReal but interpreted in the project length unit, so it must
# be scaled along with the length measures.
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
unit = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT", prefix="MILLI")
ifcopenshell.api.unit.assign_unit(self.file, units=[unit])
context = ifcopenshell.api.context.add_context(self.file, context_type="Model")
context.Precision = 0.01
# Subcontexts derive Precision from the parent and must be left alone.
ifcopenshell.api.context.add_context(
self.file, context_type="Model", context_identifier="Body", target_view="MODEL_VIEW", parent=context
)
output = subject.convert_file_length_units(self.file, target_units="METER")
new_context = output.by_type("IfcGeometricRepresentationContext", include_subtypes=False)[0]
assert new_context.Precision == pytest.approx(0.00001)
def test_attribute_conversion(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
unit = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT", prefix="MILLI")
+25 -13
View File
@@ -39,6 +39,7 @@
#include <stdlib.h>
#include <string>
#include <iomanip>
#include <charconv>
#ifdef USE_MMAP
#include <boost/filesystem/path.hpp>
@@ -449,7 +450,14 @@ const std::string& TokenFunc::asStringRef(const Token& token) {
}
std::string& str = token.lexer->GetTempString();
token.lexer->TokenString(token.startPos, str);
if ((isString(token) || isEnumeration(token) || isBinary(token)) && !str.empty()) {
// A well-formed string/enumeration/binary token has both delimiters (e.g.
// '...', .XXX., "...."), so at least two characters. Malformed input from a
// fuzzer can produce a single-character token (e.g. a bare '.' left by
// ".)" instead of ".PHYSICAL."); stripping both ends would then erase past
// the end of an already-empty string, which is undefined behaviour and
// aborts under hardened standard libraries (_GLIBCXX_ASSERTIONS). Require
// two characters before stripping. See #5683.
if ((isString(token) || isEnumeration(token) || isBinary(token)) && str.size() >= 2) {
//remove start+end characters in-place
str.erase(str.end() - 1);
str.erase(str.begin());
@@ -746,25 +754,29 @@ namespace {
// the output of the C++ ostream formatting operation.
// REAL = [ SIGN ] DIGIT { DIGIT } "." { DIGIT } [ "E" [ SIGN ] DIGIT { DIGIT } ] .
static std::string format_double(const double& d) {
std::ostringstream oss;
oss.imbue(std::locale::classic());
oss << std::setprecision(std::numeric_limits<double>::max_digits10) << d;
const std::string str = oss.str();
oss.str("");
// Use the shortest representation that round-trips exactly (like
// Python's repr) instead of max_digits10. max_digits10 padded clean
// values with noise digits (0.0174532925199433 -> 0.017453292519943299),
// which rewrote every REAL and produced huge diffs when a file was
// re-saved. See #7696.
// std::to_chars is locale-independent, so no ostringstream/imbue is
// needed here.
char buf[64];
const auto res = std::to_chars(buf, buf + sizeof(buf), d);
const std::string str(buf, res.ptr);
std::string::size_type e = str.find('e');
if (e == std::string::npos) {
e = str.find('E');
}
const std::string mantissa = str.substr(0, e);
oss << mantissa;
if (mantissa.find('.') == std::string::npos) {
oss << ".";
std::string result = str.substr(0, e);
if (result.find('.') == std::string::npos) {
result += '.';
}
if (e != std::string::npos) {
oss << "E";
oss << str.substr(e + 1);
result += 'E';
result += str.substr(e + 1);
}
return oss.str();
return result;
}
static std::string format_binary(const boost::dynamic_bitset<>& b) {
+17
View File
@@ -353,6 +353,17 @@ IFC_PARSE_API bool IfcUtil::path::rename_file(const std::string& old_filename, c
return success;
}
IFC_PARSE_API bool IfcUtil::path::atomic_rename_file(const std::string& old_filename, const std::string& new_filename) {
std::wstring old_filename_w = from_utf8(old_filename);
std::wstring new_filename_w = from_utf8(new_filename);
// MOVEFILE_REPLACE_EXISTING makes the replace atomic on NTFS (no unlink
// of the destination first). MOVEFILE_WRITE_THROUGH waits until the move
// is flushed to disk before returning.
const bool success = !!MoveFileExW(old_filename_w.c_str(), new_filename_w.c_str(),
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH);
return success;
}
IFC_PARSE_API bool IfcUtil::path::delete_file(const std::string& filename) {
std::wstring filename_w = from_utf8(filename);
const bool success = !!DeleteFileW(filename_w.c_str());
@@ -368,6 +379,12 @@ IFC_PARSE_API bool IfcUtil::path::rename_file(const std::string& old_filename, c
return std::rename(old_filename.c_str(), new_filename.c_str()) == 0;
}
IFC_PARSE_API bool IfcUtil::path::atomic_rename_file(const std::string& old_filename, const std::string& new_filename) {
// POSIX rename() atomically replaces an existing destination on the same
// filesystem, so there is no window in which new_filename is missing.
return std::rename(old_filename.c_str(), new_filename.c_str()) == 0;
}
IFC_PARSE_API bool IfcUtil::path::delete_file(const std::string& filename) {
return std::remove(filename.c_str()) != 0;
}
+4
View File
@@ -30,6 +30,10 @@
#if defined(IFCOPENSHELL_BRANCH) && defined(IFCOPENSHELL_COMMIT)
const char *IFCOPENSHELL_VERSION = STRINGIFY(IFCOPENSHELL_BRANCH) "-" STRINGIFY(IFCOPENSHELL_COMMIT);
#elif defined(IFCOPENSHELL_VERSION_STRING)
// Set from CMake's RELEASE_VERSION (the repository VERSION file) so a release
// build without commit-sha info still reports the correct version. See #8164.
const char *IFCOPENSHELL_VERSION = STRINGIFY(IFCOPENSHELL_VERSION_STRING);
#else
const char *IFCOPENSHELL_VERSION = "0.8.0";
#endif
+7
View File
@@ -37,6 +37,13 @@ namespace path {
IFC_PARSE_API bool delete_file(const std::string& filename);
IFC_PARSE_API bool rename_file(const std::string& old_filename, const std::string& new_filename);
/// Atomically renames old_filename onto new_filename, replacing an existing
/// destination in a single filesystem operation. Unlike rename_file(), the
/// destination is never unlinked before the rename, so an interruption can
/// never leave the destination missing. This requires both paths to live on
/// the same filesystem. Returns true on success.
IFC_PARSE_API bool atomic_rename_file(const std::string& old_filename, const std::string& new_filename);
#if defined(_MSC_VER) && defined(_UNICODE)
/// Uses windows.h string conversion functions
@@ -110,7 +110,7 @@ class Patcher(ifcpatch.BasePatcher):
pass
if element.is_a("IfcProject"):
proj = self.new.add(element)
for ctx in element.RepresentationContexts:
for ctx in element.RepresentationContexts or ():
for coop in getattr(ctx, 'HasCoordinateOperation', ()):
self.new.add(coop)
return proj
+11 -1
View File
@@ -21,10 +21,12 @@ import os
import ifcopenshell
import ifcopenshell.api.aggregate
import ifcopenshell.api.context
import ifcopenshell.api.geometry
import ifcopenshell.api.georeference
import ifcopenshell.api.root
import ifcopenshell.api.spatial
import ifcopenshell.util.element
import numpy
import pytest
import ifcpatch
@@ -96,7 +98,10 @@ class TestExtractElements(test.bootstrap.IFC4):
self.file,
coordinate_operation={"Eastings": 100000.0, "Northings": 200000.0},
)
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
matrix = numpy.eye(4)
matrix[:3, 3] = [5.0, 10.0, 2.0]
ifcopenshell.api.geometry.edit_object_placement(self.file, product=wall, matrix=matrix)
output = ifcpatch.execute({"file": self.file, "recipe": "ExtractElements", "arguments": ["IfcWall"]})
@@ -105,6 +110,11 @@ class TestExtractElements(test.bootstrap.IFC4):
conversion = output.by_type("IfcMapConversion")[0]
assert conversion.Eastings == 100000.0
assert conversion.Northings == 200000.0
# Placements must be copied verbatim: extraction must not bake map
# coordinates (or any other georeferencing transform) into the local
# placements of the extracted elements.
wall_new = output.by_type("IfcWall")[0]
assert wall_new.ObjectPlacement.RelativePlacement.Location.Coordinates == (5.0, 10.0, 2.0)
@pytest.mark.skipif(
"IFC4X3" not in ifcopenshell.ifcopenshell_wrapper.schema_names(),
+45 -5
View File
@@ -117,10 +117,51 @@ PyObject* get_feature(const std::string& x) {
%{
#include <fstream>
#include <random>
static const std::string& helper_fn_declaration_get_name(const IfcParse::declaration* decl) {
return decl->name();
}
// Atomic IFC/STEP write (issue #4797): serialize to a temporary file next to
// the destination, then atomically rename it onto the destination. If the
// process is interrupted mid-write, the destination is never truncated or
// left with dangling STEP references; at most a stray temp file remains, which
// the caller can safely ignore. Keeping the temp in the same directory means
// the rename stays on a single filesystem and is therefore atomic. The temp
// path never leaks into the FILE_NAME header, which is derived from the model
// header, not the output path.
template <typename T>
static void helper_fn_atomic_write(T& file_obj, const std::string& fn) {
std::random_device rd;
const std::string temp_fn = fn + "." + std::to_string(rd()) + ".tmp";
{
// Same open mode as a plain write so the bytes are identical.
std::ofstream f(IfcUtil::path::from_utf8(temp_fn).c_str());
if (!f.good()) {
// The temp file could not be created (e.g. directory not
// writable). Nothing was touched; report as a normal write error.
throw std::runtime_error("Failed to write to path: '" + fn + "', check folder and file permissions.");
}
f << file_obj;
f.flush();
if (!f.good()) {
// Serialization failed (e.g. disk full). Clean up the partial temp
// and abort. The existing destination is left intact.
f.close();
IfcUtil::path::delete_file(temp_fn);
throw std::runtime_error("Failed to write to path: '" + fn + "', the file may be incomplete.");
}
// The ofstream destructor at the end of this scope closes the stream.
// On Windows the file must be closed before it can be renamed.
}
if (!IfcUtil::path::atomic_rename_file(temp_fn, fn)) {
IfcUtil::path::delete_file(temp_fn);
throw std::runtime_error("Failed to write to path: '" + fn + "', could not replace the existing file.");
}
}
static IfcUtil::ArgumentType helper_fn_attribute_type(const IfcUtil::IfcBaseClass* inst, unsigned i) {
const IfcParse::parameter_type* pt = 0;
if (inst->declaration().as_entity()) {
@@ -219,11 +260,10 @@ private:
}
void write(const std::string& fn) {
std::ofstream f(IfcUtil::path::from_utf8(fn).c_str());
if (!f.good()) {
throw std::runtime_error("Failed to write to path: '" + fn + "', check folder and file permissions.");
}
f << (*$self);
// Atomic write: serialize to a temp file next to the target, then
// atomically rename it into place, so an interrupted write can never
// corrupt the destination (issue #4797).
helper_fn_atomic_write(*$self, fn);
}
std::string to_string() {
+13 -3
View File
@@ -34,9 +34,19 @@
if (PySequence_Size(aggregate) == -1) return false;
for(Py_ssize_t i = 0; i < PySequence_Size(aggregate); ++i) {
PyObject* element = PySequence_GetItem(aggregate, i);
// This is equivalent to the PyFloat_CheckExact macro. This means
// that direct instances of int, float, str, etc. need to be used.
bool b = element->ob_type == type_obj;
// Accept the exact type or, for the numeric types, a subclass such
// as a numpy scalar (numpy.float64 subclasses float), so that numpy
// arrays can be assigned. The REAL vs INTEGER distinction is kept: a
// float is not accepted where an int is expected and vice versa, and
// bool (a subclass of int) is still rejected for INTEGER. See #5873.
bool b;
if (type_obj == static_cast<void*>(&PyFloat_Type)) {
b = PyFloat_Check(element);
} else if (type_obj == static_cast<void*>(&PyLong_Type)) {
b = PyLong_Check(element) && !PyBool_Check(element);
} else {
b = element->ob_type == type_obj;
}
Py_DECREF(element);
if (!b) {
return false;
+7 -3
View File
@@ -108,9 +108,13 @@ int GltfSerializer::writeMaterial(const ifcopenshell::geometry::taxonomy::style:
base[3] = 1. - style->transparency;
}
if (style->has_specularity())
json_["materials"].push_back({ {"name", style->name}, {"doubleSided", true}, {"pbrMetallicRoughness", {{"baseColorFactor", base}, {"metallicFactor", 0}, {"roughnessFactor", 1.0 / style->specularity}}}});
else
if (style->has_specularity()) {
// glTF requires roughnessFactor in [0, 1]. A specular exponent of 0
// previously produced 1/0 = inf, which nlohmann::json serialises as
// null and makes the file invalid; exponents below 1 exceeded 1. #8073
const double roughness = style->specularity > 1.0 ? 1.0 / style->specularity : 1.0;
json_["materials"].push_back({ {"name", style->name}, {"doubleSided", true}, {"pbrMetallicRoughness", {{"baseColorFactor", base}, {"metallicFactor", 0}, {"roughnessFactor", roughness}}}});
} else
json_["materials"].push_back({ {"name", style->name}, {"doubleSided", true}, {"pbrMetallicRoughness", {{"baseColorFactor", base}, {"metallicFactor", 0}}}});
if (style->transparency == style->transparency && style->transparency > 1.e-9) {