mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-07 08:21:42 +00:00
Compare commits
79 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9fd119bf95 | |||
| 94ba41d9ea | |||
| cd5897d10d | |||
| e0b97c574b | |||
| 429cab8b1b | |||
| 328ca6d387 | |||
| 12ecdf2aba | |||
| 8f9164bf72 | |||
| 403308a923 | |||
| 236da3c75a | |||
| cf58c675db | |||
| 5ea11817ad | |||
| 6d90048acd | |||
| cdb594b5c2 | |||
| 028e593939 | |||
| 42a05cf976 | |||
| a97276b8b1 | |||
| 9ae79b42dd | |||
| c01433cb6c | |||
| da50d22ed5 | |||
| 9191baf067 | |||
| c299f0b191 | |||
| 8f3a1d7412 | |||
| e0a1988044 | |||
| 644b92263d | |||
| 61642d2ba3 | |||
| 4776bd7639 | |||
| b2d58d0b81 | |||
| 7322263a5e | |||
| 49de7dbcb1 | |||
| bade0647e8 | |||
| 58cfab48e6 | |||
| fa597536e1 | |||
| ee2b357d74 | |||
| b1be7d92e6 | |||
| d2381ad6c6 | |||
| 5e539890f1 | |||
| 6b4c0194ff | |||
| 1614791775 | |||
| b549e65ad9 | |||
| ee5d672493 | |||
| 135f4cf023 | |||
| 608d9ead0e | |||
| a0ce930994 | |||
| eafa158ca0 | |||
| 20b68ce0a9 | |||
| 5db955d40c | |||
| df27f86237 | |||
| 0aaafaedc9 | |||
| 7881f5992f | |||
| 679fe4dcae | |||
| a89621b179 | |||
| 110e4050c8 | |||
| 6dafb7a5c2 | |||
| 4cedeec813 | |||
| 6314d9c818 | |||
| 9bbd2b1854 | |||
| b5d36aacf6 | |||
| 6d3bed1f7d | |||
| 0096c0f6a2 | |||
| 661be0d66d | |||
| 62ed650b75 | |||
| da4b2f6eee | |||
| 00ec587296 | |||
| 2a05528b6d | |||
| a5f7f0cd93 | |||
| 1fd7329122 | |||
| f3e047d78e | |||
| 041306c5f0 | |||
| 5fba0026dd | |||
| 4d92a64206 | |||
| 9d2de117a9 | |||
| 6ee3c7a15f | |||
| fdf9970685 | |||
| de65e50fb5 | |||
| 714105b9fd | |||
| f0b5ab860f | |||
| 528964ca56 | |||
| 2c2d0f2434 |
@@ -10,9 +10,13 @@ on:
|
||||
- 'src/ifcgeomserver/**'
|
||||
- 'src/ifcjni/**'
|
||||
- 'src/ifcmax/**'
|
||||
- 'src/ifc5d/**'
|
||||
- 'src/ifcedit/**'
|
||||
- 'src/ifcmcp/**'
|
||||
- 'src/ifcopenshell-python/**'
|
||||
- '!src/ifcopenshell-python/docs/**'
|
||||
- 'src/ifcparse/**'
|
||||
- 'src/ifcquery/**'
|
||||
- 'src/ifcwrap/**'
|
||||
- 'src/qtviewer/**'
|
||||
- 'src/svgfill/**'
|
||||
@@ -51,7 +55,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install xmlschema xsdata numpy lxml pytest isodate lark networkx tabulate python-dateutil shapely pyparsing
|
||||
pip install xmlschema xsdata numpy lxml pytest isodate lark networkx tabulate python-dateutil shapely pyparsing psutil
|
||||
pip install src/bcf --no-deps
|
||||
pip install pytest-xdist==3.8.0
|
||||
|
||||
@@ -252,13 +256,26 @@ jobs:
|
||||
pip install deepdiff
|
||||
cd ../ifcdiff && make test || ERROR=1
|
||||
cd ../ifcpatch && make test || ERROR=1
|
||||
pip install -e ../ifc5d --no-deps
|
||||
pip install odfpy xlsxwriter
|
||||
cd ../ifc5d && make test || ERROR=1
|
||||
pip install -e ../ifcquery --no-deps
|
||||
cd ../ifcquery && make test || ERROR=1
|
||||
pip install -e ../ifcedit --no-deps
|
||||
cd ../ifcedit && make test || ERROR=1
|
||||
pip install mcp
|
||||
pip install -e ../ifcmcp --no-deps
|
||||
cd ../ifcmcp && make test || ERROR=1
|
||||
pip install -e ../ifctester --no-deps
|
||||
cd ../ifctester && make test || ERROR=1
|
||||
make build-ids-docs || ERROR=1
|
||||
# Run mathutils related tests at the end to ensure no other code is relying on mathutils.
|
||||
# mathutils only has pre-built wheels for Python 3.13+; skip on older versions.
|
||||
cd ../ifcopenshell-python
|
||||
pip install mathutils
|
||||
make test-mathutils || ERROR=1
|
||||
if python -c "import sys; sys.exit(0 if sys.version_info >= (3, 13) else 1)"; then
|
||||
pip install mathutils
|
||||
make test-mathutils || ERROR=1
|
||||
fi
|
||||
if [ $ERROR -ne 0 ]; then
|
||||
echo "One or more tests failed";
|
||||
exit 1;
|
||||
|
||||
+13
-7
@@ -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")
|
||||
|
||||
@@ -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 9–10) 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 include−exclude 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.
|
||||
@@ -5,7 +5,7 @@ FILE_NAME('EPset_Drawing.ifc','2020-01-01T00:00:00',$,$,'EPset_Drawing','EPset_D
|
||||
FILE_SCHEMA(('IFC4'));
|
||||
ENDSEC;
|
||||
DATA;
|
||||
#1=IFCPROPERTYSETTEMPLATE('2JhNIvqZrFnAgxfhK0XVQX',$,'EPset_Drawing','',.PSET_OCCURRENCEDRIVEN.,'IfcAnnotation/DRAWING',(#23,#22,#27,#24,#19,#12,#26,#9,#8,#7,#6,#4,#18,#11,#5,#20,#25,#14,#10,#17,#28,#16,#3,#21,#13,#15,#2));
|
||||
#1=IFCPROPERTYSETTEMPLATE('2JhNIvqZrFnAgxfhK0XVQX',$,'EPset_Drawing','',.PSET_OCCURRENCEDRIVEN.,'IfcAnnotation/DRAWING',(#23,#22,#27,#24,#29,#30,#19,#12,#26,#9,#8,#7,#6,#4,#18,#11,#5,#20,#25,#14,#10,#17,#28,#16,#3,#21,#13,#15,#2));
|
||||
#2=IFCSIMPLEPROPERTYTEMPLATE('23JavTMk98ZxXhrUEnjAcf',$,'TargetView','',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
|
||||
#3=IFCSIMPLEPROPERTYTEMPLATE('1yVWUt5H9DAOuu0OaMMLpe',$,'Scale','The scale of this drawing represented as a numerator and denominator, such as 1/100',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
|
||||
#4=IFCSIMPLEPROPERTYTEMPLATE('3gsuPBtU93b8f0gg1pjkq6',$,'HumanScale','The scale of this drawing in human readable format, such as 1:100',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
|
||||
@@ -33,5 +33,7 @@ DATA;
|
||||
#26=IFCSIMPLEPROPERTYTEMPLATE('2iwERDOW55Pf4hCbuFRe1Q',$,'FillMode','Method to fill areas seen in projection',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
|
||||
#27=IFCSIMPLEPROPERTYTEMPLATE('1YF$qLzBzF19Io8aB2N8cE',$,'CutMode','Method for cutting geometry',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
|
||||
#28=IFCSIMPLEPROPERTYTEMPLATE('1YSnFzurrEyRNtoLdmmddP',$,'BringToFront','The objects with these SVG classes will render in front of all other objects.Ex: IfcBeam, IfcColumn',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.);
|
||||
#29=IFCSIMPLEPROPERTYTEMPLATE('0lP6Y8q9v2QhDnR4sT7uVx',$,'PerspectiveShiftX','Horizontal perspective camera shift stored as drawing metadata using Blender camera shift units.',.P_SINGLEVALUE.,'IfcReal',$,$,$,$,$,.READWRITE.);
|
||||
#30=IFCSIMPLEPROPERTYTEMPLATE('2mR8b1NcW5EoFyG7hJ9kLp',$,'PerspectiveShiftY','Vertical perspective camera shift stored as drawing metadata using Blender camera shift units.',.P_SINGLEVALUE.,'IfcReal',$,$,$,$,$,.READWRITE.);
|
||||
ENDSEC;
|
||||
END-ISO-10303-21;
|
||||
|
||||
@@ -50,6 +50,9 @@ def set_active_camera_resolution(scene: bpy.types.Scene) -> None:
|
||||
if camera.type != props.camera_type:
|
||||
camera.type = props.camera_type
|
||||
|
||||
if props.update_props and (drawing := tool.Ifc.get_entity(camera_obj)):
|
||||
tool.Drawing.sync_perspective_camera_shifts(drawing, camera)
|
||||
|
||||
ortho_scale, aspect_ratio = props.get_scale_and_aspect_ratio()
|
||||
scene_render = scene.render
|
||||
if (camera.ortho_scale != ortho_scale) or not tool.Cad.is_x(
|
||||
|
||||
@@ -57,7 +57,7 @@ import shapely
|
||||
from bpy_extras.image_utils import load_image
|
||||
from bpy_extras.io_utils import ImportHelper
|
||||
from lxml import etree
|
||||
from mathutils import Color, Vector
|
||||
from mathutils import Color, Matrix, Vector
|
||||
|
||||
import bonsai.bim.export_ifc
|
||||
import bonsai.bim.handler
|
||||
@@ -602,6 +602,7 @@ class CreateDrawing(bpy.types.Operator):
|
||||
context_type: Literal["body", "annotation"],
|
||||
drawing_elements: set[ifcopenshell.entity_instance],
|
||||
target_view: str,
|
||||
link_transform: Optional[np.ndarray] = None,
|
||||
) -> None:
|
||||
drawing_elements = drawing_elements.copy()
|
||||
contexts_: list[list[int]] = getattr(contexts, context_type)
|
||||
@@ -613,9 +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)
|
||||
|
||||
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, 0.002 if target_view == "PLAN_VIEW" else -0.002))
|
||||
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(
|
||||
@@ -668,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)):
|
||||
@@ -923,11 +941,25 @@ class CreateDrawing(bpy.types.Operator):
|
||||
|
||||
bim_props = tool.Blender.get_bim_props()
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
files = {bim_props.ifc_file: tool.Ifc.get()}
|
||||
|
||||
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():
|
||||
files[link.filepath] = self.get_linked_file(link)
|
||||
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)
|
||||
@@ -935,7 +967,13 @@ class CreateDrawing(bpy.types.Operator):
|
||||
tree = ifcopenshell.geom.tree()
|
||||
tree.enable_face_styles(True)
|
||||
|
||||
for ifc_path, ifc 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()
|
||||
@@ -943,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)
|
||||
self.serialize_contexts_elements(ifc, tree, contexts, "annotation", drawing_elements, target_view)
|
||||
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"):
|
||||
@@ -1005,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)
|
||||
@@ -1017,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"}
|
||||
)
|
||||
@@ -1410,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")
|
||||
@@ -1447,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
|
||||
|
||||
@@ -604,6 +604,7 @@ class BIMCameraProperties(PropertyGroup):
|
||||
return tool.Blender.get_active_uilist_element(dprops.drawing_styles, self.active_drawing_style_index)
|
||||
|
||||
# For now, this JSON dump are all the parameters that determine a camera's "Block representation"
|
||||
# Perspective camera shift is stored in EPset_Drawing and intentionally excluded here.
|
||||
# By checking this, you will know whether or not the camera IFC representation needs to be refreshed
|
||||
def update_representation(self, matrix_world: Matrix) -> bool:
|
||||
"""Update ``representation`` based on current camera properties and the provided world matrix.
|
||||
|
||||
@@ -99,6 +99,10 @@ class BIM_PT_camera(Panel):
|
||||
if props.target_view == "MODEL_VIEW":
|
||||
row = self.layout.row()
|
||||
row.prop(props, "camera_type")
|
||||
if props.camera_type == "PERSP":
|
||||
row = self.layout.row(align=True)
|
||||
row.prop(camera_data, "shift_x", text="Camera Shift X/Y:")
|
||||
row.prop(camera_data, "shift_y", text="")
|
||||
|
||||
row = self.layout.row()
|
||||
row.prop(props, "linework_mode")
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import bpy
|
||||
import ifcopenshell.util.unit
|
||||
from bpy.types import Menu, Panel, UIList
|
||||
|
||||
import ifcopenshell.util.unit
|
||||
import bonsai.bim
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.helper import prop_with_search
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -33,6 +33,7 @@ from mathutils import Vector
|
||||
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.module.drawing import gizmos as gizmo
|
||||
from bonsai.bim.module.model.opening import is_filling_supported
|
||||
from bonsai.bim.module.model.wall import (
|
||||
_get_wall_geom_cached,
|
||||
_wall_camera_facing_icon_y,
|
||||
@@ -52,6 +53,20 @@ def is_supported_host(element) -> bool:
|
||||
return tool.Parametric.is_path_connectable_wall(element) or element.is_a("IfcSlab") or element.is_a("IfcRoof")
|
||||
|
||||
|
||||
def is_supported_filling_or_opening(element) -> bool:
|
||||
"""Total predicate for the add-opening gizmo poll. ``None`` (raw Blender
|
||||
mesh) is accepted because the operator converts unclassified meshes
|
||||
into ``IfcOpeningElement`` instances. ``IfcOpeningElement`` is accepted
|
||||
because reassigning an existing opening to a new host is a legal path
|
||||
through the operator. Otherwise defer to the generator's own
|
||||
supported-filling predicate."""
|
||||
if element is None:
|
||||
return True
|
||||
if element.is_a("IfcOpeningElement"):
|
||||
return True
|
||||
return is_filling_supported(element)
|
||||
|
||||
|
||||
def _resolve_active_host(context: bpy.types.Context, n_selected: int):
|
||||
"""Shared poll prologue: gizmo gate + selection cardinality + active-in-
|
||||
selected + IFC entity lookup + supported-host predicate. Returns the
|
||||
@@ -72,12 +87,14 @@ def _resolve_active_host(context: bpy.types.Context, n_selected: int):
|
||||
|
||||
|
||||
class GizmoHostAddOpening(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin):
|
||||
"""Activates when a host element (wall / slab / roof) is the active object
|
||||
and exactly one other selected object is *not* itself a host.
|
||||
"""Activates when exactly two objects are selected and one is a fillable
|
||||
host (wall / slab / roof) while the other is a valid filling (door /
|
||||
window / existing opening, or a plain Blender mesh).
|
||||
|
||||
Renders a single ``VIEW3D_GT_add_opening`` icon at the void object's
|
||||
projected location on the host. A click dispatches ``bim.add_opening``,
|
||||
which handles any element exposing the ``HasOpenings`` inverse.
|
||||
Selection-order independent: the host role is identified by class, not
|
||||
by active state. The "+" icon anchors on the host's surface regardless
|
||||
of which object was clicked first. The dispatched ``bim.add_opening``
|
||||
operator also handles either order.
|
||||
|
||||
Per-frame positioning keeps the icon facing the camera as the viewport
|
||||
orbits."""
|
||||
@@ -90,22 +107,29 @@ class GizmoHostAddOpening(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: bpy.types.Context) -> bool:
|
||||
element = _resolve_active_host(context, n_selected=2)
|
||||
if element is None:
|
||||
if not _wall_gizmo_poll_gate(context):
|
||||
return False
|
||||
# The operator itself filters on HasOpenings, but checking here keeps
|
||||
# the icon from appearing on host classes that can't accept openings
|
||||
# in the active IFC schema.
|
||||
if not hasattr(element, "HasOpenings"):
|
||||
selected = list(tool.Blender.get_selected_objects())
|
||||
if len(selected) != 2:
|
||||
return False
|
||||
active = context.active_object
|
||||
other = next(o for o in tool.Blender.get_selected_objects() if o is not active)
|
||||
# Host + host pairings are claimed by host-specific gizmos (wall-join,
|
||||
# extend-vertical, …) — suppress here so the add-opening icon never
|
||||
# stacks on top of them.
|
||||
if is_supported_host(tool.Ifc.get_entity(other)):
|
||||
if active is None or active not in selected:
|
||||
return False
|
||||
return True
|
||||
a_element = tool.Ifc.get_entity(selected[0])
|
||||
b_element = tool.Ifc.get_entity(selected[1])
|
||||
return cls._is_apply_opening_pair(a_element, b_element) or cls._is_apply_opening_pair(b_element, a_element)
|
||||
|
||||
@staticmethod
|
||||
def _is_apply_opening_pair(host_element, filling_element) -> bool:
|
||||
"""``host_element`` qualifies as a fillable host AND ``filling_element``
|
||||
qualifies as a filling. Used twice with the operands swapped so the
|
||||
gizmo polls true regardless of which of the two selected objects is
|
||||
active."""
|
||||
if not is_supported_host(host_element):
|
||||
return False
|
||||
if not hasattr(host_element, "HasOpenings"):
|
||||
return False
|
||||
return is_supported_filling_or_opening(filling_element)
|
||||
|
||||
def setup(self, context: bpy.types.Context) -> None:
|
||||
default_color, highlight_color = self.get_decoration_colors()
|
||||
@@ -114,18 +138,20 @@ class GizmoHostAddOpening(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin
|
||||
)
|
||||
|
||||
def position_gizmos(self, context: bpy.types.Context) -> None:
|
||||
host_obj = context.active_object
|
||||
if not host_obj:
|
||||
selected = list(tool.Blender.get_selected_objects())
|
||||
if len(selected) != 2:
|
||||
return
|
||||
selected = tool.Blender.get_selected_objects()
|
||||
other = next((o for o in selected if o is not host_obj), None)
|
||||
if not other:
|
||||
return
|
||||
element = tool.Ifc.get_entity(host_obj)
|
||||
if not element:
|
||||
a, b = selected[0], selected[1]
|
||||
a_element = tool.Ifc.get_entity(a)
|
||||
b_element = tool.Ifc.get_entity(b)
|
||||
if is_supported_host(a_element):
|
||||
host_obj, host_element, other = a, a_element, b
|
||||
elif is_supported_host(b_element):
|
||||
host_obj, host_element, other = b, b_element, a
|
||||
else:
|
||||
return
|
||||
|
||||
if tool.Parametric.is_path_connectable_wall(element):
|
||||
if tool.Parametric.is_path_connectable_wall(host_element):
|
||||
world_pos = wall_anchor(context, self, host_obj, other)
|
||||
else:
|
||||
world_pos = layer3_anchor(host_obj, other)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -240,6 +240,15 @@ def _store_batch_in_cache(cache_key: tuple[int, str], batch: "gpu.types.GPUBatch
|
||||
_batch_cache[cache_key] = (epoch, batch)
|
||||
|
||||
|
||||
def is_filling_supported(element) -> bool:
|
||||
"""True when Bonsai's opening generator can derive an opening from this
|
||||
element. IFC's schema permits any IfcElement as a filling; Bonsai
|
||||
currently supports only IfcDoor and IfcWindow because those are the
|
||||
classes with OverallWidth/OverallHeight attributes (or their types'
|
||||
ELEVATION_VIEW profiles) that the generator can consume."""
|
||||
return element is not None and element.is_a() in ("IfcDoor", "IfcWindow")
|
||||
|
||||
|
||||
class FilledOpeningGenerator:
|
||||
def generate(
|
||||
self,
|
||||
@@ -608,6 +617,25 @@ class RecalculateFill(bpy.types.Operator, tool.Ifc.Operator):
|
||||
return self._recalculate_fills(context)
|
||||
|
||||
def _recalculate_fills(self, context):
|
||||
# Refresh each selected filling's mapped opening source before
|
||||
# recutting the host. Dedup by source id covers the common shared-
|
||||
# source case in one rewrite while leaving unrelated sibling sources
|
||||
# untouched.
|
||||
seen_source_ids: set[int] = set()
|
||||
for obj in context.selected_objects:
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element or not element.FillsVoids:
|
||||
continue
|
||||
opening = element.FillsVoids[0].RelatingOpeningElement
|
||||
body = tool.Geometry.get_body_representation(opening)
|
||||
if body is None:
|
||||
continue
|
||||
source = tool.Geometry.resolve_mapped_representation(body)
|
||||
if source.id() in seen_source_ids:
|
||||
continue
|
||||
seen_source_ids.add(source.id())
|
||||
tool.Model.regenerate_filling_opening_body(element)
|
||||
|
||||
for obj in context.selected_objects:
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element or not element.FillsVoids:
|
||||
@@ -958,27 +986,29 @@ class EditOpenings(Operator, tool.Ifc.Operator):
|
||||
for opening_element in opening_elements:
|
||||
opening_obj = tool.Ifc.get_object(opening_element)
|
||||
|
||||
similar_openings = bonsai.core.geometry.get_similar_openings(tool.Ifc, opening_element)
|
||||
similar_openings_building_objs = bonsai.core.geometry.get_similar_openings_building_objs(
|
||||
tool.Ifc, similar_openings
|
||||
)
|
||||
building_objs.update(similar_openings_building_objs)
|
||||
|
||||
if opening_obj:
|
||||
if tool.Ifc.is_edited(opening_obj):
|
||||
tool.Geometry.run_geometry_update_representation(obj=opening_obj)
|
||||
bonsai.core.geometry.edit_similar_opening_placement(
|
||||
tool.Geometry, opening_element, similar_openings
|
||||
)
|
||||
elif tool.Ifc.is_moved(opening_obj):
|
||||
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=opening_obj)
|
||||
opening_edited = tool.Ifc.is_edited(opening_obj)
|
||||
opening_moved = tool.Ifc.is_moved(opening_obj)
|
||||
# Sibling walls only need a viewport-level refresh when the
|
||||
# opening's shape or placement actually changed — a pure
|
||||
# show/hide toggle leaves them in their existing state.
|
||||
if opening_edited or opening_moved:
|
||||
similar_openings = bonsai.core.geometry.get_similar_openings(tool.Ifc, opening_element)
|
||||
similar_openings_building_objs = bonsai.core.geometry.get_similar_openings_building_objs(
|
||||
tool.Ifc, similar_openings
|
||||
)
|
||||
building_objs.update(similar_openings_building_objs)
|
||||
if opening_edited:
|
||||
tool.Geometry.run_geometry_update_representation(obj=opening_obj)
|
||||
else:
|
||||
bonsai.core.geometry.edit_object_placement(
|
||||
tool.Ifc, tool.Geometry, tool.Surveyor, obj=opening_obj
|
||||
)
|
||||
bonsai.core.geometry.edit_similar_opening_placement(
|
||||
tool.Geometry, opening_element, similar_openings
|
||||
)
|
||||
building_objs.update(self.get_all_building_objects_of_similar_openings(opening_element))
|
||||
|
||||
building_objs.update(
|
||||
self.get_all_building_objects_of_similar_openings(opening_element)
|
||||
) # NB this has nothing to do with clone similar_opening
|
||||
tool.Ifc.unlink(element=opening_element)
|
||||
if props.representation_obj == opening_obj:
|
||||
props.representation_obj = None
|
||||
|
||||
@@ -22,9 +22,9 @@ from collections.abc import Iterable
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import bpy
|
||||
import ifcopenshell.util.unit
|
||||
from bpy.types import Panel
|
||||
|
||||
import ifcopenshell.util.unit
|
||||
import bonsai.bim
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.helper import prop_with_search
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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,17 +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
|
||||
bpy.ops.bim.load_link(link_index=-1, use_cache=self.use_cache, query=self.query)
|
||||
new.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):
|
||||
@@ -1481,17 +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 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}'")
|
||||
@@ -1528,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():
|
||||
@@ -1571,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}}")
|
||||
@@ -1620,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
|
||||
|
||||
@@ -1639,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
|
||||
@@ -1652,20 +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="Include",
|
||||
description=(
|
||||
"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]
|
||||
# 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):
|
||||
link = tool.Project.get_project_props().links[self.link_index]
|
||||
# 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=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):
|
||||
bpy.ops.bim.unload_link(link_index=self.link_index)
|
||||
return bpy.ops.bim.load_link(link_index=self.link_index, use_cache=False) or {"FINISHED"}
|
||||
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):
|
||||
@@ -1683,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():
|
||||
@@ -1720,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)
|
||||
@@ -1762,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)
|
||||
@@ -1774,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)
|
||||
|
||||
@@ -1979,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).
|
||||
@@ -2086,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.
|
||||
@@ -2150,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)
|
||||
@@ -2157,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,
|
||||
@@ -2175,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
|
||||
@@ -2218,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)
|
||||
|
||||
@@ -2237,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:
|
||||
@@ -2258,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)
|
||||
@@ -2347,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)
|
||||
@@ -2365,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)
|
||||
@@ -2377,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,
|
||||
@@ -2450,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)
|
||||
@@ -2575,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"}
|
||||
|
||||
|
||||
|
||||
@@ -260,6 +260,24 @@ class Link(PropertyGroup):
|
||||
description="STEP ID of the IfcDocumentReference when linked to a parent IFC project. Zero when no parent IFC exists",
|
||||
default=0,
|
||||
)
|
||||
query: StringProperty(
|
||||
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="",
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
name: str
|
||||
@@ -275,6 +293,9 @@ class Link(PropertyGroup):
|
||||
include_in_drawings: bool
|
||||
empty_handle: Union[bpy.types.Object, None]
|
||||
ifc_definition_id: int
|
||||
query: str
|
||||
exclude: str
|
||||
display_name: str
|
||||
|
||||
|
||||
class EditedObj(PropertyGroup):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -744,7 +744,7 @@ class EnableEditingSurfaceStyle(bpy.types.Operator):
|
||||
if self.ifc_class == "IfcSurfaceStyleLighting":
|
||||
|
||||
def callback(attribute_name: str, _: object, data: dict[str, Any]) -> None:
|
||||
assert attributes
|
||||
assert attributes is not None
|
||||
color = attributes.add()
|
||||
assert isinstance(color, ColourRgb)
|
||||
color.name = attribute_name
|
||||
@@ -782,34 +782,40 @@ class EditSurfaceStyle(bpy.types.Operator, tool.Ifc.Operator):
|
||||
def _execute(self, context):
|
||||
self.props = tool.Style.get_style_props()
|
||||
self.style = tool.Ifc.get().by_id(self.props.is_editing_style)
|
||||
prev_update_graph = self.props.update_graph
|
||||
self.props["update_graph"] = False
|
||||
|
||||
style_elements = tool.Style.get_style_elements(self.style)
|
||||
# NOTE: currently this operator is used to edit existing (and only existing) IfcSurfaceStyles
|
||||
# or new or existing IfcSurfaceStyle components (shading, etc)
|
||||
# which is kind of confusing.
|
||||
if self.props.is_editing_class == "IfcSurfaceStyle":
|
||||
self.surface_style = self.style
|
||||
else:
|
||||
self.surface_style = style_elements.get(self.props.is_editing_class, None)
|
||||
self.shading_style = style_elements.get("IfcSurfaceStyleShading", None)
|
||||
self.rendering_style = style_elements.get("IfcSurfaceStyleRendering", None)
|
||||
self.texture_style = style_elements.get("IfcSurfaceStyleWithTextures", None)
|
||||
try:
|
||||
style_elements = tool.Style.get_style_elements(self.style)
|
||||
|
||||
if self.surface_style:
|
||||
result = self.edit_existing_style()
|
||||
else:
|
||||
result = self.add_new_style()
|
||||
# NOTE: currently this operator is used to edit existing (and only existing) IfcSurfaceStyles
|
||||
# or new or existing IfcSurfaceStyle components (shading, etc)
|
||||
# which is kind of confusing.
|
||||
if self.props.is_editing_class == "IfcSurfaceStyle":
|
||||
self.surface_style = self.style
|
||||
else:
|
||||
self.surface_style = style_elements.get(self.props.is_editing_class, None)
|
||||
self.shading_style = style_elements.get("IfcSurfaceStyleShading", None)
|
||||
self.rendering_style = style_elements.get("IfcSurfaceStyleRendering", None)
|
||||
self.texture_style = style_elements.get("IfcSurfaceStyleWithTextures", None)
|
||||
|
||||
if result:
|
||||
return result
|
||||
if self.surface_style:
|
||||
result = self.edit_existing_style()
|
||||
else:
|
||||
result = self.add_new_style()
|
||||
|
||||
tool.Style.disable_editing()
|
||||
core.load_styles(tool.Style, style_type=self.props.style_type)
|
||||
if result:
|
||||
return result
|
||||
|
||||
# restore selected style type
|
||||
material = tool.Ifc.get_object(self.style)
|
||||
msprops = tool.Style.get_material_style_props(material)
|
||||
msprops.active_style_type = msprops.active_style_type
|
||||
tool.Style.disable_editing()
|
||||
core.load_styles(tool.Style, style_type=self.props.style_type)
|
||||
|
||||
# restore selected style type
|
||||
material = tool.Ifc.get_object(self.style)
|
||||
msprops = tool.Style.get_material_style_props(material)
|
||||
msprops.active_style_type = msprops.active_style_type
|
||||
finally:
|
||||
self.props["update_graph"] = prev_update_graph
|
||||
|
||||
def edit_existing_style(self) -> None:
|
||||
ifc_file = tool.Ifc.get()
|
||||
@@ -1231,4 +1237,5 @@ class RemoveSurfaceStyle(bpy.types.Operator, tool.Ifc.Operator):
|
||||
surface_style = tool.Style.get_style_elements(style)[props.is_editing_class]
|
||||
ifcopenshell.api.style.remove_surface_style(ifc_file, surface_style)
|
||||
core.disable_editing_style(tool.Style)
|
||||
core.load_styles(tool.Style, style_type=props.style_type)
|
||||
return {"FINISHED"}
|
||||
|
||||
@@ -185,6 +185,13 @@ class ColourRgb(PropertyGroup):
|
||||
# to fit blender.bim.helper.draw_attribute
|
||||
is_optional = False
|
||||
special_type = ""
|
||||
data_type = ""
|
||||
ifc_class = ""
|
||||
use_explorer_ui = False
|
||||
|
||||
@property
|
||||
def display_name(self):
|
||||
return self.name
|
||||
|
||||
def get_value_name(self, *args, **kwargs):
|
||||
return "color_value"
|
||||
|
||||
@@ -176,8 +176,30 @@ class BIM_PT_styles(Panel):
|
||||
row.prop(self.props, "reflectance_method")
|
||||
|
||||
if self.props.reflectance_method not in ("PHYSICAL", "NOTDEFINED", "FLAT"):
|
||||
self.layout.label(text="Supported reflectance methods are:")
|
||||
self.layout.label(text="PHYSICAL / NOTDEFINED / FLAT")
|
||||
self.layout.label(
|
||||
text=f"{self.props.reflectance_method} will be skipped: only PHYSICAL / NOTDEFINED / FLAT are supported",
|
||||
icon="ERROR",
|
||||
)
|
||||
elif self.props.reflectance_method in ("PHYSICAL", "NOTDEFINED"):
|
||||
if self.props.specular_colour_class == "IfcColourRgb":
|
||||
self.layout.label(
|
||||
text="Metallic color is IFC-only in PHYSICAL/NOTDEFINED and does not affect Blender appearance",
|
||||
icon="ERROR",
|
||||
)
|
||||
elif self.props.reflectance_method == "FLAT":
|
||||
if self.props.diffuse_colour_class == "IfcNormalisedRatioMeasure":
|
||||
self.layout.label(
|
||||
text="Emissive ratio is IFC-only in FLAT Reflectance method and does not affect Blender appearance",
|
||||
icon="ERROR",
|
||||
)
|
||||
self.layout.label(
|
||||
text="Specular value is IFC-only in FLAT Reflectance method and does not affect Blender appearance",
|
||||
icon="ERROR",
|
||||
)
|
||||
self.layout.label(
|
||||
text="Highlight value is IFC-only in FLAT Reflectance method and does not affect Blender appearance",
|
||||
icon="ERROR",
|
||||
)
|
||||
|
||||
row = self.layout.row(align=True)
|
||||
row.label(text="Emissive" if self.props.reflectance_method == "FLAT" else "Diffuse")
|
||||
@@ -232,6 +254,8 @@ class BIM_PT_styles(Panel):
|
||||
row.operator("bim.add_surface_texture", text="", icon="ADD")
|
||||
if textures:
|
||||
self.layout.prop(self.props, "uv_mode")
|
||||
if self.props.uv_mode in ("Generated", "Camera"):
|
||||
self.layout.label(text="Not available in SOLID Mode", icon="INFO")
|
||||
|
||||
for i, texture in enumerate(textures):
|
||||
split = self.layout.split(factor=0.30, align=True)
|
||||
@@ -244,6 +268,22 @@ class BIM_PT_styles(Panel):
|
||||
op_clear = row.operator("bim.remove_texture_map", text="", icon="X")
|
||||
op_path.texture_map_index = op_clear.texture_map_index = i
|
||||
|
||||
reflectance = self.props.reflectance_method
|
||||
mode = texture.mode
|
||||
if reflectance == "FLAT":
|
||||
if mode != "EMISSIVE":
|
||||
self.layout.label(
|
||||
text=f"{mode} will be skipped: only EMISSIVE is supported for Render Reflectance FLAT",
|
||||
icon="ERROR",
|
||||
)
|
||||
elif reflectance in ("PHYSICAL", "NOTDEFINED"):
|
||||
_SUPPORTED = {"DIFFUSE", "NORMAL", "METALLICROUGHNESS", "EMISSIVE", "OCCLUSION"}
|
||||
if mode not in _SUPPORTED:
|
||||
self.layout.label(
|
||||
text=f"{mode} will be skipped: not supported for Render Reflectance PHYSICAL/NOTDEFINED",
|
||||
icon="ERROR",
|
||||
)
|
||||
|
||||
def draw_externally_defined_surface_style(self):
|
||||
row = self.layout.row()
|
||||
op = row.operator("bim.browse_external_style", icon="APPEND_BLEND", text="Append From Blend File")
|
||||
@@ -252,10 +292,17 @@ class BIM_PT_styles(Panel):
|
||||
bonsai.bim.helper.draw_attributes(self.props.external_style_attributes, self.layout, enable_search=True)
|
||||
|
||||
def draw_refraction_surface_style(self):
|
||||
self.layout.label(
|
||||
text="Refraction values are IFC-only and do not affect Blender surface appearance",
|
||||
icon="ERROR",
|
||||
)
|
||||
bonsai.bim.helper.draw_attributes(self.props.refraction_style_attributes, self.layout, enable_search=True)
|
||||
row = self.layout.row(align=True)
|
||||
|
||||
def draw_lighting_surface_style(self):
|
||||
self.layout.label(
|
||||
text="Lighting values are IFC-only and do not affect Blender surface appearance",
|
||||
icon="ERROR",
|
||||
)
|
||||
bonsai.bim.helper.draw_attributes(self.props.lighting_style_colours, self.layout)
|
||||
|
||||
def draw_edit_ui(self, edit_label: str):
|
||||
|
||||
@@ -26,7 +26,7 @@ import bonsai.bim.handler
|
||||
import bonsai.core.geometry
|
||||
import bonsai.core.root
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.module.model.opening import FilledOpeningGenerator
|
||||
from bonsai.bim.module.model.opening import FilledOpeningGenerator, is_filling_supported
|
||||
|
||||
|
||||
class AddOpening(bpy.types.Operator, tool.Ifc.Operator):
|
||||
@@ -34,11 +34,13 @@ class AddOpening(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_label = "Apply Opening"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
bl_description = (
|
||||
"Apply opening objects to an Element.\n\n"
|
||||
"The Element and the openings to be applied should be selected. The order of selection is not important.\n"
|
||||
"Opening can be just a Blender mesh object.\n\n"
|
||||
"Shift+click: keep the filling at its current matrix_world — skip the wall-axis snap "
|
||||
"and the rl1/rl2 Z-elevation default that the regular click applies."
|
||||
"Cuts openings in a wall, slab, or roof using selected shape objects — "
|
||||
"doors, windows, existing openings, or plain (non-IFC) meshes. "
|
||||
"Selection order doesn't matter.\n\n"
|
||||
"Doors and windows also fill the opening. Other IFC classes are currently "
|
||||
"unsupported by the opening generator and get skipped with a warning.\n\n"
|
||||
"Shift+click: keep each opening at its shape object's current position "
|
||||
"instead of snapping to the wall."
|
||||
)
|
||||
|
||||
# Toggled by ``invoke`` when the user holds SHIFT during a gizmo / hotkey
|
||||
@@ -84,8 +86,14 @@ class AddOpening(bpy.types.Operator, tool.Ifc.Operator):
|
||||
self.report({"INFO"}, "You can't add an opening to another opening.")
|
||||
continue
|
||||
elif not element1.is_a("IfcOpeningElement") and not element2.is_a("IfcOpeningElement"):
|
||||
if element1.is_a("IfcWindow") or element1.is_a("IfcDoor"): # Add a fill to an element.
|
||||
if is_filling_supported(element1): # Add a fill to an element.
|
||||
obj1, obj2 = obj2, obj1
|
||||
elif not is_filling_supported(element2):
|
||||
self.report(
|
||||
{"INFO"},
|
||||
f"Cannot apply {element2.is_a()} as an opening — Bonsai currently supports only IfcDoor and IfcWindow as parametric fillings.",
|
||||
)
|
||||
continue
|
||||
FilledOpeningGenerator().generate(
|
||||
obj2,
|
||||
obj1,
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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``
|
||||
|
||||
@@ -78,6 +78,7 @@ if TYPE_CHECKING:
|
||||
|
||||
class Drawing(bonsai.core.tool.Drawing):
|
||||
ANNOTATION_DATA_TYPE = Literal["empty", "curve", "mesh"]
|
||||
PERSPECTIVE_CAMERA_SHIFT_PROPERTIES = ("PerspectiveShiftX", "PerspectiveShiftY")
|
||||
DOCUMENT_TYPE = Literal["SCHEDULE", "REFERENCE"]
|
||||
LocationHintLiteral = Literal["PERSPECTIVE", "ORTHOGRAPHIC", "NORTH", "SOUTH", "EAST", "WEST"]
|
||||
LOCATION_HINT_LITERALS = ("PERSPECTIVE", "ORTHOGRAPHIC", "NORTH", "SOUTH", "EAST", "WEST")
|
||||
@@ -453,6 +454,41 @@ class Drawing(bonsai.core.tool.Drawing):
|
||||
camera.matrix_world = matrix
|
||||
return camera
|
||||
|
||||
@classmethod
|
||||
def get_perspective_camera_shifts(cls, drawing: ifcopenshell.entity_instance) -> dict[str, float]:
|
||||
pset = ifcopenshell.util.element.get_pset(drawing, "EPset_Drawing") or {}
|
||||
shift_x_prop, shift_y_prop = cls.PERSPECTIVE_CAMERA_SHIFT_PROPERTIES
|
||||
return {
|
||||
"shift_x": float(pset.get(shift_x_prop, 0.0) or 0.0),
|
||||
"shift_y": float(pset.get(shift_y_prop, 0.0) or 0.0),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def sync_perspective_camera_shifts(cls, drawing: ifcopenshell.entity_instance, camera: bpy.types.Camera) -> None:
|
||||
if camera.type != "PERSP":
|
||||
return
|
||||
|
||||
shift_x_prop, shift_y_prop = cls.PERSPECTIVE_CAMERA_SHIFT_PROPERTIES
|
||||
current_shifts = cls.get_perspective_camera_shifts(drawing)
|
||||
new_shifts = {"shift_x": float(camera.shift_x or 0.0), "shift_y": float(camera.shift_y or 0.0)}
|
||||
if tool.Cad.is_x(current_shifts["shift_x"], new_shifts["shift_x"]) and tool.Cad.is_x(
|
||||
current_shifts["shift_y"], new_shifts["shift_y"]
|
||||
):
|
||||
return
|
||||
|
||||
ifc_file = tool.Ifc.get()
|
||||
pset = tool.Pset.get_element_pset(drawing, "EPset_Drawing")
|
||||
if not pset:
|
||||
pset = ifcopenshell.api.pset.add_pset(ifc_file, product=drawing, name="EPset_Drawing")
|
||||
ifcopenshell.api.pset.edit_pset(
|
||||
ifc_file,
|
||||
pset=pset,
|
||||
properties={
|
||||
shift_x_prop: new_shifts["shift_x"],
|
||||
shift_y_prop: new_shifts["shift_y"],
|
||||
},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def create_svg_schedule(cls, schedule: ifcopenshell.entity_instance) -> None:
|
||||
import bonsai.bim.module.drawing.scheduler as scheduler
|
||||
@@ -1009,6 +1045,8 @@ class Drawing(bonsai.core.tool.Drawing):
|
||||
camera_props.has_annotation = True
|
||||
camera_props.target_view = "PLAN_VIEW"
|
||||
camera_props.is_nts = False
|
||||
camera.shift_x = 0.0
|
||||
camera.shift_y = 0.0
|
||||
|
||||
pset = ifcopenshell.util.element.get_pset(drawing, "EPset_Drawing")
|
||||
if pset:
|
||||
@@ -1044,6 +1082,10 @@ class Drawing(bonsai.core.tool.Drawing):
|
||||
camera_props.fill_mode = str(pset["FillMode"])
|
||||
if "CutMode" in pset:
|
||||
camera_props.cut_mode = str(pset["CutMode"])
|
||||
if camera.type == "PERSP":
|
||||
shifts = cls.get_perspective_camera_shifts(drawing)
|
||||
camera.shift_x = shifts["shift_x"]
|
||||
camera.shift_y = shifts["shift_y"]
|
||||
|
||||
camera_props.update_props = update_props
|
||||
|
||||
@@ -2398,13 +2440,13 @@ class Drawing(bonsai.core.tool.Drawing):
|
||||
@classmethod
|
||||
def is_drawing_active(cls) -> bool:
|
||||
camera = bpy.context.scene.camera
|
||||
area = tool.Blender.get_view3d_area()
|
||||
return bool(
|
||||
camera is not None
|
||||
and camera.type == "CAMERA"
|
||||
and tool.Blender.get_ifc_definition_id(camera)
|
||||
and area is not None
|
||||
)
|
||||
if not (camera is not None and camera.type == "CAMERA" and tool.Blender.get_ifc_definition_id(camera)):
|
||||
return False
|
||||
# A VIEW_3D area is meaningless (and unobtainable) in background
|
||||
# mode, but isn't otherwise required to generate a drawing.
|
||||
if bpy.app.background:
|
||||
return True
|
||||
return tool.Blender.get_view3d_area() is not None
|
||||
|
||||
@classmethod
|
||||
def is_camera_orthographic(cls) -> bool:
|
||||
@@ -2535,10 +2577,19 @@ class Drawing(bonsai.core.tool.Drawing):
|
||||
has_context = True
|
||||
break
|
||||
|
||||
linked_handles: set[bpy.types.Object] = set()
|
||||
for link in tool.Project.get_project_props().get_loaded_links_for_drawings():
|
||||
try:
|
||||
handle = tool.Project.get_link_empty_handle(link)
|
||||
except Exception:
|
||||
continue
|
||||
if handle:
|
||||
linked_handles.add(handle)
|
||||
|
||||
visible_objects = []
|
||||
for obj in bpy.context.view_layer.objects:
|
||||
if element := tool.Ifc.get_entity(obj):
|
||||
if element in filtered_elements:
|
||||
if element in filtered_elements or obj in linked_handles:
|
||||
visible_objects.append(obj)
|
||||
else:
|
||||
if obj.hide_get() is False:
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -189,7 +189,7 @@ class Loader(bonsai.core.tool.Loader):
|
||||
uv_mode = "Generated"
|
||||
elif coordinates.is_a("IfcTextureCoordinateGenerator") and coordinates.Mode == "COORD-EYE":
|
||||
uv_mode = "Camera"
|
||||
surface_texture["uv_mode"] = uv_mode or "Generated"
|
||||
surface_texture["uv_mode"] = uv_mode or "UV"
|
||||
return surface_texture
|
||||
|
||||
@classmethod
|
||||
@@ -315,7 +315,7 @@ class Loader(bonsai.core.tool.Loader):
|
||||
image_url = str(image_url)
|
||||
if is_relative and bpy.data.filepath:
|
||||
image_url = bpy.path.relpath(image_url)
|
||||
return bpy.data.images.load(image_url)
|
||||
return bpy.data.images.load(image_url, check_existing=True)
|
||||
|
||||
elif texture["type"] == "IfcBlobTexture":
|
||||
# https://blender.stackexchange.com/questions/173206/how-to-efficiently-convert-a-pil-image-to-bpy-types-image
|
||||
@@ -472,12 +472,23 @@ class Loader(bonsai.core.tool.Loader):
|
||||
print(f"{mode} Mode texture will be skipped.")
|
||||
continue
|
||||
|
||||
if (image := get_image) is None:
|
||||
if (image := get_image()) is None:
|
||||
continue
|
||||
|
||||
# remove RGB node from `create_surface_style_rendering`
|
||||
prev_node = bsdf.inputs[2].links[0].from_node
|
||||
blender_material.node_tree.nodes.remove(prev_node)
|
||||
# Replace whatever currently feeds the FLAT color input (RGB or previous texture chain).
|
||||
for link in list(bsdf.inputs[2].links):
|
||||
prev_node = link.from_node
|
||||
blender_material.node_tree.links.remove(link)
|
||||
if prev_node.type == "TEX_IMAGE":
|
||||
# Remove linked texture coordinate node if it's no longer used.
|
||||
for vec_link in list(prev_node.inputs["Vector"].links):
|
||||
coord_node = vec_link.from_node
|
||||
blender_material.node_tree.links.remove(vec_link)
|
||||
if coord_node.type == "TEX_COORD" and not any(o.links for o in coord_node.outputs):
|
||||
blender_material.node_tree.nodes.remove(coord_node)
|
||||
blender_material.node_tree.nodes.remove(prev_node)
|
||||
elif prev_node.type == "RGB":
|
||||
blender_material.node_tree.nodes.remove(prev_node)
|
||||
|
||||
node = blender_material.node_tree.nodes.new(type="ShaderNodeTexImage")
|
||||
node.location = bsdf.location - Vector((200, 250))
|
||||
@@ -1062,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"):
|
||||
@@ -1110,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):
|
||||
|
||||
+132
-37
@@ -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(
|
||||
@@ -2060,47 +2096,86 @@ class Model(bonsai.core.tool.Model):
|
||||
return (vertices, edges, faces)
|
||||
|
||||
@classmethod
|
||||
def update_simple_openings(cls, element: ifcopenshell.entity_instance) -> None:
|
||||
def regenerate_filling_opening_body(cls, filling: ifcopenshell.entity_instance) -> Optional[bpy.types.Object]:
|
||||
"""Regenerate only the mapped source used by ``filling``'s opening so
|
||||
it matches ``filling``'s current parametric dimensions.
|
||||
|
||||
Returns the voided host Blender object so the caller can recut it,
|
||||
or ``None`` if ``filling`` has no opening to refresh or the host is
|
||||
an aggregate (no mesh data to recut against)."""
|
||||
from bonsai.bim.module.model.opening import FilledOpeningGenerator
|
||||
|
||||
ifc_file = tool.Ifc.get()
|
||||
fillings = {e: tool.Ifc.get_object(e) for e in tool.Array.get_parametric_propagation_targets(element)}
|
||||
if not filling.FillsVoids:
|
||||
return None
|
||||
|
||||
voided_objs = set()
|
||||
has_replaced_opening_representation = False
|
||||
ifc_file = tool.Ifc.get()
|
||||
opening = filling.FillsVoids[0].RelatingOpeningElement
|
||||
voided_obj = tool.Ifc.get_object(opening.VoidsElements[0].RelatingBuildingElement)
|
||||
if voided_obj is None or voided_obj.data is None:
|
||||
return None
|
||||
|
||||
old_representation = tool.Geometry.get_body_representation(opening)
|
||||
if old_representation is None:
|
||||
return voided_obj
|
||||
old_representation = tool.Geometry.resolve_mapped_representation(old_representation)
|
||||
|
||||
ifcopenshell.api.geometry.unassign_representation(ifc_file, product=opening, representation=old_representation)
|
||||
|
||||
filling_obj = tool.Ifc.get_object(filling)
|
||||
new_representation = FilledOpeningGenerator().generate_opening_from_filling(
|
||||
filling, filling_obj, voided_obj.dimensions[1]
|
||||
)
|
||||
|
||||
for inverse in ifc_file.get_inverse(old_representation):
|
||||
ifcopenshell.util.element.replace_attribute(inverse, old_representation, new_representation)
|
||||
|
||||
ifcopenshell.api.geometry.remove_representation(ifc_file, representation=old_representation)
|
||||
|
||||
return voided_obj
|
||||
|
||||
@classmethod
|
||||
def regenerate_simple_opening_bodies(cls, element: ifcopenshell.entity_instance) -> set:
|
||||
"""Regenerate every distinct mapped opening source within ``element``'s
|
||||
type-occurrence family so each one matches the family's current
|
||||
parametric dimensions.
|
||||
|
||||
Most occurrences share a single mapped source — refreshing it once
|
||||
propagates to every filling via inverse-substitution. Some families,
|
||||
especially those imported from foreign authoring tools, fragment into
|
||||
several mapped sources for the same type; dedup is by source id so
|
||||
every distinct source gets one refresh. Returns the set of Blender
|
||||
objects whose host representation needs a viewport-level recut
|
||||
(callers handle the recut themselves)."""
|
||||
ifc_file = tool.Ifc.get()
|
||||
fillings = list(tool.Array.get_parametric_propagation_targets(element))
|
||||
|
||||
voided_objs: set = set()
|
||||
seen_source_ids: set[int] = set()
|
||||
for filling in fillings:
|
||||
if not filling.FillsVoids:
|
||||
continue
|
||||
|
||||
opening = filling.FillsVoids[0].RelatingOpeningElement
|
||||
voided_obj = tool.Ifc.get_object(opening.VoidsElements[0].RelatingBuildingElement)
|
||||
voided_objs.add(voided_obj)
|
||||
if voided_obj is not None:
|
||||
voided_objs.add(voided_obj)
|
||||
|
||||
# We assume all occurrences of the same element type (e.g. a window)
|
||||
# will use openings of the same thickness.
|
||||
# Generator we use by default will create a really thick opening representation
|
||||
# to make sure it will fit for walls with different thickness.
|
||||
if has_replaced_opening_representation:
|
||||
body = tool.Geometry.get_body_representation(opening)
|
||||
if body is None:
|
||||
continue
|
||||
source = tool.Geometry.resolve_mapped_representation(body)
|
||||
if source.id() in seen_source_ids:
|
||||
continue
|
||||
seen_source_ids.add(source.id())
|
||||
|
||||
old_representation = ifcopenshell.util.representation.get_representation(
|
||||
opening, "Model", "Body", "MODEL_VIEW"
|
||||
)
|
||||
old_representation = tool.Geometry.resolve_mapped_representation(old_representation)
|
||||
ifcopenshell.api.geometry.unassign_representation(
|
||||
ifc_file, product=opening, representation=old_representation
|
||||
)
|
||||
cls.regenerate_filling_opening_body(filling)
|
||||
|
||||
new_representation = FilledOpeningGenerator().generate_opening_from_filling(
|
||||
filling, fillings[filling], voided_obj.dimensions[1]
|
||||
)
|
||||
return voided_objs
|
||||
|
||||
for inverse in ifc_file.get_inverse(old_representation):
|
||||
ifcopenshell.util.element.replace_attribute(inverse, old_representation, new_representation)
|
||||
|
||||
ifcopenshell.api.geometry.remove_representation(ifc_file, representation=old_representation)
|
||||
|
||||
has_replaced_opening_representation = True
|
||||
@classmethod
|
||||
def update_simple_openings(cls, element: ifcopenshell.entity_instance) -> None:
|
||||
voided_objs = cls.regenerate_simple_opening_bodies(element)
|
||||
fillings = {e: tool.Ifc.get_object(e) for e in tool.Array.get_parametric_propagation_targets(element)}
|
||||
|
||||
tool.Model.reload_body_representation(voided_objs)
|
||||
if fillings:
|
||||
@@ -3108,6 +3183,26 @@ class Model(bonsai.core.tool.Model):
|
||||
obj = tool.Ifc.get_object(rel.RelatingElement)
|
||||
tool.Geometry.commit_placement_if_moved(obj)
|
||||
queue.add((rel.RelatingElement, obj))
|
||||
|
||||
# Sync filling and opening placements so subsequent wall recuts
|
||||
# operate on the up-to-date opening positions — a filling moved
|
||||
# along the wall's reference line otherwise stays cut at its old
|
||||
# spot.
|
||||
for element, wall in queue:
|
||||
if not wall:
|
||||
continue
|
||||
for rel in getattr(element, "HasOpenings", []) or []:
|
||||
opening = rel.RelatedOpeningElement
|
||||
for fill_rel in getattr(opening, "HasFillings", []) or []:
|
||||
filling = fill_rel.RelatedBuildingElement
|
||||
filling_obj = tool.Ifc.get_object(filling)
|
||||
if filling_obj is None or not tool.Ifc.is_moved(filling_obj):
|
||||
continue
|
||||
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=filling_obj)
|
||||
ifcopenshell.api.geometry.edit_object_placement(
|
||||
tool.Ifc.get(), product=opening, matrix=filling_obj.matrix_world
|
||||
)
|
||||
|
||||
for element, wall in queue:
|
||||
if not wall:
|
||||
continue
|
||||
|
||||
@@ -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,19 +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))
|
||||
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:
|
||||
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(
|
||||
@@ -850,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
|
||||
@@ -889,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)
|
||||
@@ -950,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)
|
||||
@@ -969,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]]
|
||||
@@ -988,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"]],
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
|
||||
@@ -455,6 +455,7 @@ Scenario: Select Cost Schedule Products
|
||||
And I press "bim.assign_cost_item_quantity(cost_item={cost_item}, related_object_type='PRODUCT', prop_name='')"
|
||||
When I press "bim.select_cost_schedule_products(cost_schedule={cost_schedule})"
|
||||
Then nothing happens
|
||||
|
||||
Scenario: Load Cost Item Types
|
||||
Given an empty IFC project
|
||||
And I press "bim.add_cost_schedule"
|
||||
@@ -465,3 +466,30 @@ Scenario: Load Cost Item Types
|
||||
When I press "bim.add_cost_item(cost_item={cost_item})"
|
||||
And I press "bim.load_cost_item_types"
|
||||
Then nothing happens
|
||||
|
||||
Scenario: Import one cost schedule from CSV
|
||||
Given an empty IFC project
|
||||
When I press "bim.import_cost_schedule_csv(filepath='{cwd}/test/files/Ex1-BoQ-without-query.csv')"
|
||||
And the variable "cost_schedule" is "{ifc}.by_type('IfcCostSchedule')[0].id()"
|
||||
And I press "bim.enable_editing_cost_items(cost_schedule={cost_schedule})"
|
||||
And I press "bim.add_summary_cost_item()"
|
||||
And the variable "cost_item" is "{ifc}.by_type('IfcCostItem')[0].id()"
|
||||
And I press "bim.add_cost_item(cost_item={cost_item})"
|
||||
Then nothing happens
|
||||
|
||||
Scenario: Import multiple cost schedules from CSV
|
||||
Given an empty IFC project
|
||||
When I press "bim.import_cost_schedule_csv(filepath='{cwd}/test/files/Ex1-BoQ-without-query.csv')"
|
||||
When I press "bim.import_cost_schedule_csv(filepath='{cwd}/test/files/Ex2-SoR.csv')"
|
||||
When I press "bim.import_cost_schedule_csv(filepath='{cwd}/test/files/Ex3-BoQ-with-query.csv')"
|
||||
When I press "bim.import_cost_schedule_csv(filepath='{cwd}/test/files/Ex4-BoQ-with-description.csv')"
|
||||
When I press "bim.import_cost_schedule_csv(filepath='{cwd}/test/files/Ex5-SoR-with-description.csv')"
|
||||
When I press "bim.import_cost_schedule_csv(filepath='{cwd}/test/files/Ex6-BoQ-with-categories.csv')"
|
||||
When I press "bim.import_cost_schedule_csv(filepath='{cwd}/test/files/Ex7-BoQ-with-Rates.csv')"
|
||||
When I press "bim.import_cost_schedule_csv(filepath='{cwd}/test/files/Ex8-BoQ-with-formula.csv')"
|
||||
And the variable "cost_schedule" is "{ifc}.by_type('IfcCostSchedule')[0].id()"
|
||||
And I press "bim.enable_editing_cost_items(cost_schedule={cost_schedule})"
|
||||
And I press "bim.add_summary_cost_item()"
|
||||
And the variable "cost_item" is "{ifc}.by_type('IfcCostItem')[0].id()"
|
||||
And I press "bim.add_cost_item(cost_item={cost_item})"
|
||||
Then nothing happens
|
||||
|
||||
@@ -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
|
||||
@@ -51,22 +51,29 @@ _IFC_CLASS_BY_KIND = {
|
||||
"slab": "IfcSlab",
|
||||
"roof": "IfcRoof",
|
||||
"plain": "IfcDiscreteAccessory",
|
||||
"door": "IfcDoor",
|
||||
"window": "IfcWindow",
|
||||
"opening": "IfcOpeningElement",
|
||||
"covering": "IfcCovering",
|
||||
}
|
||||
|
||||
|
||||
class _FakeIfcEntity:
|
||||
"""Minimal stand-in for an ``ifcopenshell.entity_instance`` in poll tests.
|
||||
|
||||
Provides the two surfaces the gizmo's poll consults: ``is_a(type_name)``
|
||||
(used directly by ``is_supported_host`` for slab/roof) and an optional
|
||||
``HasOpenings`` attribute (probed by the poll's ``hasattr`` guard)."""
|
||||
Mirrors ``ifcopenshell.entity_instance.is_a``'s two call shapes:
|
||||
``is_a("Foo")`` returns True when the entity's class is ``Foo``, and
|
||||
``is_a()`` returns the class name as a string. ``HasOpenings`` is
|
||||
optional so the poll's ``hasattr`` guard branch is reachable."""
|
||||
|
||||
def __init__(self, ifc_class: str, has_openings: bool = True):
|
||||
self._ifc_class = ifc_class
|
||||
if has_openings:
|
||||
self.HasOpenings = ()
|
||||
|
||||
def is_a(self, type_name: str) -> bool:
|
||||
def is_a(self, type_name: str | None = None):
|
||||
if type_name is None:
|
||||
return self._ifc_class
|
||||
return self._ifc_class == type_name
|
||||
|
||||
|
||||
@@ -168,6 +175,40 @@ def test_poll_rejects_host_host_pairs(active_kind, other_kind, patched_tool):
|
||||
assert _run_poll(patched_tool, active_kind=active_kind, other_kind=other_kind) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("filling_kind", ["door", "window", "opening", "mesh"])
|
||||
def test_poll_accepts_host_with_supported_filling(filling_kind, patched_tool):
|
||||
"""The apply-opening gizmo must activate when the secondary selection
|
||||
is a class the operator can dispatch on: ``IfcDoor`` / ``IfcWindow``
|
||||
(filled openings), ``IfcOpeningElement`` (existing opening reassigned
|
||||
to a new host), or a raw Blender mesh (converted to an opening)."""
|
||||
assert _run_poll(patched_tool, active_kind="wall", other_kind=filling_kind) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("non_filling_kind", ["covering", "plain"])
|
||||
def test_poll_rejects_host_with_non_filling(non_filling_kind, patched_tool):
|
||||
"""An IFC entity whose class the apply-opening operator can't dispatch
|
||||
on must keep the gizmo hidden — clicking it would otherwise dispatch
|
||||
the operator on a class whose geometry the opening generator can't
|
||||
derive, causing a deep traceback in the geometry kernel."""
|
||||
assert _run_poll(patched_tool, active_kind="wall", other_kind=non_filling_kind) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("filling_kind", ["door", "window", "opening", "mesh"])
|
||||
def test_poll_accepts_filling_active_with_host_other(filling_kind, patched_tool):
|
||||
"""The poll must be selection-order independent: the icon should appear
|
||||
whether the user clicked the host first or the filling first. The
|
||||
operator handles either order, so the gizmo should match."""
|
||||
assert _run_poll(patched_tool, active_kind=filling_kind, other_kind="wall") is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("non_filling_kind", ["covering", "plain"])
|
||||
def test_poll_rejects_non_filling_active_with_host_other(non_filling_kind, patched_tool):
|
||||
"""The selection-order independence must not loosen the filling
|
||||
predicate — covering + wall stays rejected regardless of which is
|
||||
active."""
|
||||
assert _run_poll(patched_tool, active_kind=non_filling_kind, other_kind="wall") is False
|
||||
|
||||
|
||||
def test_poll_rejects_active_host_without_has_openings(patched_tool):
|
||||
# Real-world equivalent: an IFC class that the active schema strips
|
||||
# ``HasOpenings`` from (e.g., a non-element subtype). The active sentinel
|
||||
@@ -265,12 +306,16 @@ def _run_position_layer3_branch(
|
||||
icon = SimpleNamespace(matrix_basis=None, hide=True)
|
||||
self_stub = SimpleNamespace(add_opening_icon=icon)
|
||||
|
||||
host_element = object()
|
||||
# Host identification in the gizmo branches on the entity's class, so
|
||||
# the sentinel must respond to ``is_a``. The non-host selection has no
|
||||
# IFC entity (mesh-like) and is accepted as a filling.
|
||||
host_element = _FakeIfcEntity("IfcSlab")
|
||||
entity_map = {id(host_obj): host_element, id(other): None}
|
||||
with contextlib.ExitStack() as stack:
|
||||
stack.enter_context(
|
||||
patched_tool(
|
||||
selected_list=selected,
|
||||
entity=host_element,
|
||||
entity=lambda o: entity_map.get(id(o)),
|
||||
modifier_predicates={"is_path_connectable_wall": is_path_connectable_wall},
|
||||
)
|
||||
)
|
||||
@@ -297,6 +342,50 @@ def test_layer3_branch_always_parks_above_top_face(patched_tool, other_z):
|
||||
assert pos.z == pytest.approx(0.2 + BaseParametricGizmoGroup.ICON_Z_OFFSET)
|
||||
|
||||
|
||||
def test_position_gizmos_identifies_host_by_class_when_selected_second(patched_tool):
|
||||
"""Host role in ``position_gizmos`` is resolved by IFC class, not by
|
||||
active-object position — so a slab clicked SECOND (filling first,
|
||||
host active or not) still anchors the icon correctly on the slab.
|
||||
This pins the selection-order independence of the positioner (the
|
||||
poll's independence is covered separately by the poll parametrize)."""
|
||||
from bonsai.bim.module.drawing import gizmos as gizmo_module
|
||||
from bonsai.bim.module.model.host_add_opening_gizmo import GizmoHostAddOpening
|
||||
|
||||
other = SimpleNamespace(matrix_world=Matrix.Translation(Vector((0.7, 0.4, 1.0))))
|
||||
host_obj = SimpleNamespace(matrix_world=Matrix.Identity(4), bound_box=[(0.0, 0.0, 0.0), (0.0, 0.0, 0.2)] * 4)
|
||||
# Host at index 1; the filling (no IFC entity) sits at index 0 as active.
|
||||
selected = [other, host_obj]
|
||||
context = SimpleNamespace(active_object=other)
|
||||
icon = SimpleNamespace(matrix_basis=None, hide=True)
|
||||
self_stub = SimpleNamespace(add_opening_icon=icon)
|
||||
|
||||
entity_map = {id(host_obj): _FakeIfcEntity("IfcSlab"), id(other): None}
|
||||
with contextlib.ExitStack() as stack:
|
||||
stack.enter_context(
|
||||
patched_tool(
|
||||
selected_list=selected,
|
||||
entity=lambda o: entity_map.get(id(o)),
|
||||
modifier_predicates={"is_path_connectable_wall": False},
|
||||
)
|
||||
)
|
||||
stack.enter_context(patch.object(gizmo_module, "get_billboard_rotation", return_value=Matrix.Identity(4)))
|
||||
stack.enter_context(
|
||||
patch.object(
|
||||
gizmo_module, "billboarded_at", side_effect=lambda pos, rot, scale=0.5: Matrix.Translation(pos)
|
||||
)
|
||||
)
|
||||
GizmoHostAddOpening.position_gizmos(self_stub, context)
|
||||
|
||||
# Icon anchors on the host's top face (slab bound_box top-Z = 0.2) at
|
||||
# the void's XY — same result as when the host was at index 0.
|
||||
from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup
|
||||
|
||||
pos = icon.matrix_basis.translation
|
||||
assert pos.x == pytest.approx(0.7)
|
||||
assert pos.y == pytest.approx(0.4)
|
||||
assert pos.z == pytest.approx(0.2 + BaseParametricGizmoGroup.ICON_Z_OFFSET)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# is_supported_host() — predicate totality
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# 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.
|
||||
|
||||
"""AST contract: ``RecalculateFill`` must invoke
|
||||
``regenerate_simple_opening_bodies`` before recutting hosts.
|
||||
|
||||
Hosts recut with a surgical mesh-only path don't refresh the shared mapped
|
||||
opening source — so any change to a parametric filling's dimensions stays
|
||||
invisible at the opening boundary until the body representation is
|
||||
regenerated. Pinning the call site forces future refactors to keep the
|
||||
regen step in place."""
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
def _recalculate_fill_body_source() -> str:
|
||||
from bonsai.bim.module.model import opening as opening_module
|
||||
|
||||
source = Path(opening_module.__file__).read_text(encoding="utf-8")
|
||||
tree = ast.parse(source)
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ClassDef) and node.name == "RecalculateFill":
|
||||
for child in node.body:
|
||||
if isinstance(child, ast.FunctionDef) and child.name == "_recalculate_fills":
|
||||
return ast.unparse(child)
|
||||
raise AssertionError("RecalculateFill._recalculate_fills was not found in opening.py")
|
||||
|
||||
|
||||
def test_recalculate_fill_regenerates_opening_bodies_before_recut():
|
||||
body = _recalculate_fill_body_source()
|
||||
assert "regenerate_filling_opening_body" in body, (
|
||||
"RecalculateFill._recalculate_fills must call "
|
||||
"tool.Model.regenerate_filling_opening_body for each selected "
|
||||
"filling before recutting the host. Without that call the host is "
|
||||
"recut against a stale shared mapped opening source, so changes "
|
||||
"to filling dimensions never surface."
|
||||
)
|
||||
@@ -40,8 +40,7 @@ class TestCopyClass:
|
||||
collector.assign("obj").should_be_called()
|
||||
subject.copy_class(ifc, collector, geometry, root, obj="obj")
|
||||
|
||||
# def test_copy_with_new_geometry_copied_from_the_old(self, ifc, collector, geometry, root):
|
||||
def test_AAAAAAAAAAAA(self, ifc, collector, geometry, root):
|
||||
def test_copy_with_new_geometry_copied_from_the_old(self, ifc, collector, geometry, root):
|
||||
ifc.get_entity("obj").should_be_called().will_return("original_element")
|
||||
root.is_element_a("original_element", "IfcRelSpaceBoundary").should_be_called().will_return(False)
|
||||
root.get_object_representation("obj").should_be_called().will_return("representation")
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
Index,Identification,Name,Unit,Value,Quantity
|
||||
1,E.01,Walls,m3,,
|
||||
2,E.01.01,Ground floor walls,m3,100,42
|
||||
2,E.01.02,First floor walls,m3,200,35
|
||||
1,A.02,Paintings,m2,,
|
||||
2,A.03,Paintings with water,m2,,
|
||||
3,B.05,White paintings,m2,25,45
|
||||
3,B.06,Colored paintings,m2,32,33
|
||||
2,C-01,Paintings with machine,m2,17,133
|
||||
2,C-02,Decorated paintings,m2,40,8
|
||||
|
@@ -0,0 +1,7 @@
|
||||
Index,Identification,Name,Unit,Value,Quantity
|
||||
1,A,Group A,,,
|
||||
2,A.02,Paintings,m2,20,1
|
||||
2,A.03,Paintings with water,m2,23,1
|
||||
1,C,Group C,,,
|
||||
2,C-01,Paintings with machine,m2,32,1
|
||||
2,C-02,Decorated paintings,m2,40,2
|
||||
|
@@ -0,0 +1,10 @@
|
||||
Index,Identification,Name,Unit,Value,Quantity,Query,Property
|
||||
1,E.01,Walls,m3,,,,
|
||||
2,E.01.01,Ground floor walls,m3,100,,"IfcWall, location=""Ground Floor""",GrossVolume
|
||||
2,E.01.02,First floor walls,m3,200,,"IfcWall, location=""First Floor""",GrossVolume
|
||||
1,A.02,Paintings,m2,,,,
|
||||
2,A.03,Paintings with water,m2,,,,
|
||||
3,B.05,White paintings,m2,25,45,,
|
||||
3,B.06,Colored paintings,m2,32,33,,
|
||||
2,C-01,Paintings with machine,m2,17,133,,
|
||||
2,C-02,Decorated paintings,m2,40,8,,
|
||||
|
@@ -0,0 +1,10 @@
|
||||
Index,Identification,Name,Unit,Value,Quantity,Description
|
||||
1,E.01,Walls,m3,,,
|
||||
2,E.01.01,Ground floor walls,m3,100,42,"Semi-solid blocks of plain-faced common brick, with an apparent density (excluding holes) of 800 kg/m³; minor drilling 45%; apparent thermal conductivity 0.21 W/mK; characteristic mechanical strength parallel to the holes greater than or equal to 10 N/mm2, perpendicular to the holes greater than or equal to 2N/mm2"
|
||||
2,E.01.02,First floor walls,m3,200,35,"Semi-solid blocks of plain-faced common brick, with an apparent density (excluding holes) of 800 kg/m³; minor drilling 45%; apparent thermal conductivity 0.21 W/mK; characteristic mechanical strength parallel to the holes greater than or equal to 10 N/mm2, perpendicular to the holes greater than or equal to 2N/mm2"
|
||||
1,A.02,Painting,m2,,,"Painting with washable water-based wall paint for indoor/outdoor. The price includes and compensates the costs for the supply of paint, any scaffolding up to a maximum height of 4 m from the support surface, the costs for the protection of furniture, fixed systems or the protection of floors, the cleaning of the surfaces to be treated through the use of rags or net purposes in order to remove residues that can be easily removed. The cost of occasional and partial grouting of surfaces, in order to eliminate any small scratches, including sanding of the grouted parts, is also to be considered included and compensated. For 2 coats with brush or roller."
|
||||
2,A.03,Washable painting,m2,,,"Supply and installation of washable tempera paint for interiors and exteriors. The price includes and compensates for the costs of supplying the paint, any scaffolding up to a maximum height of 4 meters from the support surface, the costs of protecting furnishings, fixed installations, or floors, and cleaning the surfaces to be treated using rags or clean brushes to remove easily removable residues. On previously prepared plaster. Apply two coats with a brush or roller. (Tempera colors from the color chart)."
|
||||
3,B.05,White paintings,m2,25,45,
|
||||
3,B.06,Colored paintings,m2,32,33,
|
||||
2,C-01,External painting,m2,17,133,"Painting with plastic coating. The price includes and compensates for the costs of supplying the paint, any scaffolding up to a maximum height of 4 meters from the support surface, the costs of protecting furnishings, fixed systems, or floors, and cleaning the surfaces to be treated using rags or clean brushes to remove easily removable residues. On already prepared plaster. For 2 coats (interior textured finish)."
|
||||
2,C-02,Decorated paintings,m2,40,8,
|
||||
|
@@ -0,0 +1,7 @@
|
||||
Index,Identification,Name,Unit,Value,Quantity,Description
|
||||
1,A,Group A,,,,
|
||||
2,A.02,Paintings,m2,20,1,Paint made by the best painter in the world
|
||||
2,A.03,Paintings with water,m2,23,1,
|
||||
1,C,Group C,,,,
|
||||
2,C-01,Paintings with machine,m2,32,1,Best painting in the world painted with the best painted machine accordingly with ISO9001
|
||||
2,C-02,Decorated paintings,m2,40,2,
|
||||
|
@@ -0,0 +1,10 @@
|
||||
Index,Identification,Name,Unit,Material,Labor,Quantity
|
||||
1,E.01,Walls,m3,,,
|
||||
2,E.01.01,Ground floor walls,m3,55,45,42
|
||||
2,E.01.02,First floor walls,m3,120,80,35
|
||||
1,A.02,Painting,m2,,,
|
||||
2,A.03,Washable painting,m2,,,
|
||||
3,B.05,White paintings,m2,,,45
|
||||
3,B.06,Colored paintings,m2,,,33
|
||||
2,C-01,External painting,m2,,,133
|
||||
2,C-02,Decorated paintings,m2,,,8
|
||||
|
@@ -0,0 +1,10 @@
|
||||
Index,Identification,Name,Unit,Value,Quantity,RateSchedule,RateID
|
||||
1,E.01,Walls,m3,,,,
|
||||
2,E.01.01,Ground floor walls,m3,100,42,,
|
||||
2,E.01.02,First floor walls,m3,200,35,,
|
||||
1,A.02,Paintings,m2,,,,
|
||||
2,A.03,Paintings with water,m2,,,,
|
||||
3,B.05,White paintings,m2,,45,Ex2-SoR,A.03
|
||||
3,B.06,Colored paintings,m2,32,33,,
|
||||
2,C-01,Paintings with machine,m2,17,133,,
|
||||
2,C-02,Decorated paintings,m2,40,8,,
|
||||
|
@@ -0,0 +1,14 @@
|
||||
Index,Identification,Name,Unit,Value,Quantity,Query,Property,Formula
|
||||
1,E.01,Walls,m3,,,,,
|
||||
2,E.01.01,Ground floor walls,m3,100,,"IfcWall, location=""Ground Floor""",GrossVolume,
|
||||
2,E.01.02,First floor walls,m3,200,,"IfcWall, location=""First Floor""",GrossVolume,
|
||||
1,A.02,Paintings,m2,,,,,
|
||||
2,A.03,Paintings with water,m2,,,,,
|
||||
3,B.05,White paintings,m2,25,,IfcWall,GrossVolume,
|
||||
3,B.06,Colored paintings,m2,32,33,,,
|
||||
3,B.07,Double paintings,m,,,IfcWall,,NetSideArea*2
|
||||
2,C-01,Paintings with machine,m2,17,133,,,
|
||||
2,C-02,Decorated paintings,m2,40,8,,,
|
||||
1,D,Reinforcements,,,,,,
|
||||
2,D.1,Walls reinforcements weight,kg,,,IfcWall,,Pset_ConcreteElementGeneral.ReinforcementVolumeRatio * GrossVolume
|
||||
2,D.2,Beams reinforcements weight,kg,,,,,
|
||||
|
@@ -73,6 +73,83 @@ class TestCreateCamera(NewFile):
|
||||
assert obj.users_collection == tuple()
|
||||
|
||||
|
||||
class TestImportCameraProps(NewFile):
|
||||
def test_imports_perspective_camera_shifts_from_drawing_pset(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
drawing = ifc.createIfcAnnotation(ObjectType="DRAWING")
|
||||
pset = ifcopenshell.api.pset.add_pset(ifc, product=drawing, name="EPset_Drawing")
|
||||
ifcopenshell.api.pset.edit_pset(
|
||||
ifc,
|
||||
pset=pset,
|
||||
properties={"PerspectiveShiftX": 0.125, "PerspectiveShiftY": -0.375},
|
||||
)
|
||||
camera = bpy.data.cameras.new("Camera")
|
||||
camera.type = "PERSP"
|
||||
|
||||
subject.import_camera_props(drawing, camera)
|
||||
|
||||
assert camera.shift_x == pytest.approx(0.125)
|
||||
assert camera.shift_y == pytest.approx(-0.375)
|
||||
|
||||
def test_non_perspective_import_defaults_camera_shifts_to_zero(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
drawing = ifc.createIfcAnnotation(ObjectType="DRAWING")
|
||||
pset = ifcopenshell.api.pset.add_pset(ifc, product=drawing, name="EPset_Drawing")
|
||||
ifcopenshell.api.pset.edit_pset(
|
||||
ifc,
|
||||
pset=pset,
|
||||
properties={"PerspectiveShiftX": 0.125, "PerspectiveShiftY": -0.375},
|
||||
)
|
||||
camera = bpy.data.cameras.new("Camera")
|
||||
camera.type = "ORTHO"
|
||||
camera.shift_x = 1.0
|
||||
camera.shift_y = -1.0
|
||||
|
||||
subject.import_camera_props(drawing, camera)
|
||||
|
||||
assert camera.shift_x == 0.0
|
||||
assert camera.shift_y == 0.0
|
||||
|
||||
|
||||
class TestSyncPerspectiveCameraShifts(NewFile):
|
||||
def test_round_trips_perspective_camera_shifts_through_drawing_pset(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
drawing = ifc.createIfcAnnotation(ObjectType="DRAWING")
|
||||
camera = bpy.data.cameras.new("Camera")
|
||||
camera.type = "PERSP"
|
||||
camera.shift_x = 0.25
|
||||
camera.shift_y = -0.5
|
||||
|
||||
subject.sync_perspective_camera_shifts(drawing, camera)
|
||||
|
||||
pset = ifcopenshell.util.element.get_pset(drawing, "EPset_Drawing")
|
||||
assert pset["PerspectiveShiftX"] == pytest.approx(0.25)
|
||||
assert pset["PerspectiveShiftY"] == pytest.approx(-0.5)
|
||||
|
||||
reloaded_camera = bpy.data.cameras.new("ReloadedCamera")
|
||||
reloaded_camera.type = "PERSP"
|
||||
subject.import_camera_props(drawing, reloaded_camera)
|
||||
|
||||
assert reloaded_camera.shift_x == pytest.approx(0.25)
|
||||
assert reloaded_camera.shift_y == pytest.approx(-0.5)
|
||||
|
||||
def test_ignores_non_perspective_camera_shifts(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
drawing = ifc.createIfcAnnotation(ObjectType="DRAWING")
|
||||
camera = bpy.data.cameras.new("Camera")
|
||||
camera.type = "ORTHO"
|
||||
camera.shift_x = 0.25
|
||||
camera.shift_y = -0.5
|
||||
|
||||
subject.sync_perspective_camera_shifts(drawing, camera)
|
||||
|
||||
assert ifcopenshell.util.element.get_pset(drawing, "EPset_Drawing") is None
|
||||
|
||||
|
||||
class TestCreateSvgSheet(NewFile):
|
||||
def test_run(self):
|
||||
ifc = ifcopenshell.file()
|
||||
@@ -961,3 +1038,27 @@ class TestAddReferenceImage(NewFile):
|
||||
|
||||
uv_node = material_nodes["Texture Coordinate"]
|
||||
assert len(uv_node.outputs["Generated"].links[:]) == 1
|
||||
|
||||
|
||||
class TestIsDrawingActive(NewFile):
|
||||
def test_no_active_camera(self):
|
||||
bpy.context.scene.camera = None
|
||||
assert subject.is_drawing_active() is False
|
||||
|
||||
def test_active_camera_without_ifc_definition(self):
|
||||
bpy.context.scene.camera = subject.create_camera("Camera", mathutils.Matrix(), "PERSPECTIVE", "PLAN_VIEW")
|
||||
assert subject.is_drawing_active() is False
|
||||
|
||||
def test_ifc_linked_camera_in_background_mode(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
camera_obj = subject.create_camera("Camera", mathutils.Matrix(), "PERSPECTIVE", "PLAN_VIEW")
|
||||
drawing = ifc.createIfcAnnotation(ObjectType="DRAWING")
|
||||
tool.Ifc.link(drawing, camera_obj)
|
||||
bpy.context.scene.camera = camera_obj
|
||||
|
||||
# The test suite itself runs Blender in background mode, where no
|
||||
# VIEW_3D area can ever exist -- this is exactly the case the fix
|
||||
# addresses, so this assertion documents that assumption.
|
||||
assert bpy.app.background is True
|
||||
assert subject.is_drawing_active() is True
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -294,64 +294,125 @@ class TestLoadLinkedModels(NewFile):
|
||||
assert props.links[1].ifc_definition_id == reference2.id()
|
||||
assert props.links[1].has_transformation is True
|
||||
|
||||
def test_load_linked_models_restores_query_from_cache_json(self):
|
||||
"""The selector query used at link time is persisted only in the
|
||||
sidecar cache JSON. Reopening the host IFC must restore it onto the
|
||||
Link PropertyGroup so subsequent Reload/Load replay the same filter."""
|
||||
ifc = ifcopenshell.file()
|
||||
props = tool.Project.get_project_props()
|
||||
ifcopenshell.api.root.create_entity(ifc, "IfcProject")
|
||||
document = ifcopenshell.api.document.add_information(ifc)
|
||||
document.Scope = "LINKED_MODEL"
|
||||
with NamedTemporaryFile(suffix=".ifc.cache.json", mode="w", delete=False) as tmp:
|
||||
json.dump({"query": "IfcElement, ! IfcOpeningElement"}, tmp)
|
||||
json_path = Path(tmp.name)
|
||||
try:
|
||||
ifc_filepath = tmp.name.replace(".ifc.cache.json", ".ifc")
|
||||
reference = ifcopenshell.api.document.add_reference(ifc, document)
|
||||
reference.Location = Path(ifc_filepath).as_posix()
|
||||
reference.Identification = ""
|
||||
tool.Ifc.set(ifc)
|
||||
subject.load_linked_models_from_ifc()
|
||||
assert len(props.links) == 1
|
||||
assert props.links[0].query == "IfcElement, ! IfcOpeningElement"
|
||||
finally:
|
||||
json_path.unlink(missing_ok=True)
|
||||
|
||||
def test_load_linked_models_query_defaults_empty_without_cache_json(self):
|
||||
"""When no sidecar cache JSON exists, the restored Link's query field
|
||||
must default to the empty string. Empty query is the documented signal
|
||||
for the load path to apply no selector filter."""
|
||||
ifc = ifcopenshell.file()
|
||||
props = tool.Project.get_project_props()
|
||||
ifcopenshell.api.root.create_entity(ifc, "IfcProject")
|
||||
document = ifcopenshell.api.document.add_information(ifc)
|
||||
document.Scope = "LINKED_MODEL"
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
ifc_path = Path(tmpdir) / "no-cache.ifc"
|
||||
reference = ifcopenshell.api.document.add_reference(ifc, document)
|
||||
reference.Location = ifc_path.as_posix()
|
||||
reference.Identification = ""
|
||||
tool.Ifc.set(ifc)
|
||||
subject.load_linked_models_from_ifc()
|
||||
assert len(props.links) == 1
|
||||
assert props.links[0].query == ""
|
||||
|
||||
|
||||
class TestCalculateLinkMatrix(NewFile):
|
||||
def _write_cache_json(self, payload: dict) -> Path:
|
||||
"""Write ``payload`` to a fresh sidecar cache JSON path and return it.
|
||||
|
||||
On Windows, ``NamedTemporaryFile(delete=True)`` holds an exclusive
|
||||
handle for the ``with`` block's duration, so the code-under-test
|
||||
cannot open the same path — hence the manual write + unlink pattern.
|
||||
"""
|
||||
tmp = NamedTemporaryFile(suffix=".ifc.cache.json", mode="w", delete=False)
|
||||
try:
|
||||
json.dump(payload, tmp)
|
||||
finally:
|
||||
tmp.close()
|
||||
return Path(tmp.name)
|
||||
|
||||
def test_linking_a_model_without_an_offset_to_our_session_with_no_offset(self):
|
||||
props = tool.Project.get_project_props()
|
||||
gprops = tool.Georeference.get_georeference_props()
|
||||
with NamedTemporaryFile(suffix=".ifc.cache.json", mode="w", delete=True) as tmp:
|
||||
json_path = self._write_cache_json({"model_project_north": "0", "model_origin_si": "0,0,0"})
|
||||
try:
|
||||
link = props.links.add()
|
||||
link.filepath = tmp.name.replace(".ifc.cache.json", ".ifc")
|
||||
json.dump({"model_project_north": "0", "model_origin_si": "0,0,0"}, tmp)
|
||||
tmp.flush()
|
||||
link.filepath = str(json_path).replace(".ifc.cache.json", ".ifc")
|
||||
gprops.model_project_north = "0"
|
||||
gprops.model_origin_si = "0,0,0"
|
||||
assert np.allclose(subject.calculate_link_matrix(link), np.eye(4))
|
||||
finally:
|
||||
json_path.unlink(missing_ok=True)
|
||||
|
||||
def test_linking_an_offset_model_to_our_session_with_no_offset(self):
|
||||
props = tool.Project.get_project_props()
|
||||
gprops = tool.Georeference.get_georeference_props()
|
||||
with NamedTemporaryFile(suffix=".ifc.cache.json", mode="w", delete=True) as tmp:
|
||||
json_path = self._write_cache_json({"model_project_north": "0", "model_origin_si": "5,0,0"})
|
||||
try:
|
||||
link = props.links.add()
|
||||
link.filepath = tmp.name.replace(".ifc.cache.json", ".ifc")
|
||||
json.dump({"model_project_north": "0", "model_origin_si": "5,0,0"}, tmp)
|
||||
tmp.flush()
|
||||
link.filepath = str(json_path).replace(".ifc.cache.json", ".ifc")
|
||||
gprops.model_project_north = "0"
|
||||
gprops.model_origin_si = "0,0,0"
|
||||
m = np.eye(4)
|
||||
m[0][3] = 5
|
||||
assert np.allclose(subject.calculate_link_matrix(link), m)
|
||||
finally:
|
||||
json_path.unlink(missing_ok=True)
|
||||
|
||||
def test_linking_an_offset_model_to_our_session_with_offset(self):
|
||||
props = tool.Project.get_project_props()
|
||||
gprops = tool.Georeference.get_georeference_props()
|
||||
with NamedTemporaryFile(suffix=".ifc.cache.json", mode="w", delete=True) as tmp:
|
||||
json_path = self._write_cache_json({"model_project_north": "0", "model_origin_si": "5,0,0"})
|
||||
try:
|
||||
link = props.links.add()
|
||||
link.filepath = tmp.name.replace(".ifc.cache.json", ".ifc")
|
||||
json.dump({"model_project_north": "0", "model_origin_si": "5,0,0"}, tmp)
|
||||
tmp.flush()
|
||||
link.filepath = str(json_path).replace(".ifc.cache.json", ".ifc")
|
||||
gprops.model_project_north = "0"
|
||||
gprops.model_origin_si = "2,0,0"
|
||||
m = np.eye(4)
|
||||
m[0][3] = 3
|
||||
assert np.allclose(subject.calculate_link_matrix(link), m)
|
||||
finally:
|
||||
json_path.unlink(missing_ok=True)
|
||||
|
||||
def test_linking_an_offset_model_to_our_session_with_offset_and_transformation(self):
|
||||
props = tool.Project.get_project_props()
|
||||
gprops = tool.Georeference.get_georeference_props()
|
||||
with NamedTemporaryFile(suffix=".ifc.cache.json", mode="w", delete=True) as tmp:
|
||||
json_path = self._write_cache_json({"model_project_north": "0", "model_origin_si": "5,0,0"})
|
||||
try:
|
||||
link = props.links.add()
|
||||
link.filepath = tmp.name.replace(".ifc.cache.json", ".ifc")
|
||||
link.filepath = str(json_path).replace(".ifc.cache.json", ".ifc")
|
||||
transformation = np.eye(4)
|
||||
transformation[0][3] = 4
|
||||
link.transformation = ",".join(map(str, transformation.reshape(-1)))
|
||||
json.dump({"model_project_north": "0", "model_origin_si": "5,0,0"}, tmp)
|
||||
tmp.flush()
|
||||
gprops.model_project_north = "0"
|
||||
gprops.model_origin_si = "2,0,0"
|
||||
m = np.eye(4)
|
||||
m[0][3] = 7
|
||||
assert np.allclose(subject.calculate_link_matrix(link), m)
|
||||
finally:
|
||||
json_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
class TestLoadingIfcSqlite(NewFile):
|
||||
@@ -440,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
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
Index,Identification,Name,Unit,Value,Quantity,Query,Property,Formula
|
||||
1,E.01,Walls,m3,,,,,
|
||||
2,E.01.01,Ground floor walls,m3,100,,"IfcWall, location=""Ground Floor""",GrossVolume,
|
||||
2,E.01.02,First floor walls,m3,200,,"IfcWall, location=""First Floor""",GrossVolume,
|
||||
1,A.02,Paintings,m2,,,,,
|
||||
2,A.03,Paintings with water,m2,,,,,
|
||||
3,B.05,White paintings,m2,25,,IfcWall,GrossVolume,
|
||||
3,B.06,Colored paintings,m2,32,33,,,
|
||||
3,B.07,Double paintings,m,,,IfcWall,,NetSideArea*2
|
||||
2,C-01,Paintings with machine,m2,17,133,,,
|
||||
2,C-02,Decorated paintings,m2,40,8,,,
|
||||
1,D,Reinforcements,,,,,,
|
||||
2,D.1,Walls reinforcements weight,kg,,,IfcWall,,Pset_ConcreteElementGeneral.ReinforcementVolumeRatio * GrossVolume
|
||||
2,D.2,Beams reinforcements weight,kg,,,,,
|
||||
|
@@ -39,6 +39,7 @@ See example files as a CSV file format reference:
|
||||
- Ex5 - SoR_with_description.csv (a simple SoR with description column)
|
||||
- Ex6 - BoQ with categories.csv (a simple BoQ with categories columns)
|
||||
- Ex7 - BoQ with Rates.csv (a simple BoQ that connect to an existing SoR. It needs an already loaded SoR.)
|
||||
- Ex8 - Boq with formula.csv (a simple BoQ with formula field used to calculate quantities when specified)
|
||||
- `sample_cost_schedule_house_FR.csv` / `.ods`
|
||||
- `schedule.csv`, `rates.csv` (schedule of rates example)
|
||||
|
||||
|
||||
@@ -55,6 +55,9 @@ class CsvHeader(TypedDict):
|
||||
RateSchedule: NotRequired[str]
|
||||
RateID: NotRequired[str]
|
||||
|
||||
# Formula
|
||||
Formula: NotRequired[str]
|
||||
#QuantityClass: NotRequired[str]
|
||||
|
||||
# Currently we assume that if column is not part of the main header,
|
||||
# then it is a cost value category. So here we list any additional column
|
||||
@@ -65,6 +68,8 @@ MAIN_CSV_HEADER_COLUMNS.extend(
|
||||
# Not sure what this for but it's present in sample .csv.
|
||||
"Subtotal",
|
||||
# Columns from exporter.
|
||||
"ItemIsASum",
|
||||
"Quantities",
|
||||
"RateSubtotal",
|
||||
"TotalPrice",
|
||||
# Deprecated columns from exporter, shouldn't be exported any longer.
|
||||
@@ -91,6 +96,8 @@ class CostItem(TypedDict):
|
||||
Property: Union[str, None]
|
||||
Query: Union[str, None]
|
||||
|
||||
Formula: Union[str, None]
|
||||
#QuantityClass: Union[str, None]
|
||||
|
||||
class Csv2Ifc:
|
||||
# Inputs.
|
||||
@@ -108,6 +115,7 @@ class Csv2Ifc:
|
||||
categories: dict[str, int]
|
||||
has_categories: bool
|
||||
has_rates: bool
|
||||
has_formula: bool
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -163,9 +171,12 @@ class Csv2Ifc:
|
||||
if not self.headers:
|
||||
self.has_categories = True
|
||||
self.has_rates = False
|
||||
self.has_formula = False
|
||||
self.headers = {col: i for i, col in enumerate(row) if col}
|
||||
if "RateSchedule" in self.headers and "RateID" in self.headers:
|
||||
self.has_rates = True
|
||||
if "Formula" in self.headers:
|
||||
self.has_formula = True
|
||||
if "Value" in self.headers:
|
||||
self.has_categories = False
|
||||
else:
|
||||
@@ -233,6 +244,11 @@ class Csv2Ifc:
|
||||
else:
|
||||
cost_rate = None
|
||||
|
||||
if self.has_formula:
|
||||
cost_formula = row[(self.headers["Formula"])] if "Formula" in self.headers else None
|
||||
else:
|
||||
cost_formula = None
|
||||
|
||||
return {
|
||||
"Identification": str(identification) if identification else None,
|
||||
"Name": str(name) if name else None,
|
||||
@@ -244,6 +260,7 @@ class Csv2Ifc:
|
||||
"Query": query,
|
||||
"children": [],
|
||||
"CostRate": cost_rate,
|
||||
"Formula": cost_formula,
|
||||
}
|
||||
|
||||
def create_ifc(self) -> None:
|
||||
@@ -320,6 +337,7 @@ class Csv2Ifc:
|
||||
|
||||
if cost_rate.get("Schedule") and cost_rate.get("RateID"):
|
||||
# if cost_rate["Schedule"] is not "":
|
||||
rate_cost_schedule = None
|
||||
schedules = self.file.by_type("IfcCostSchedule")
|
||||
for schedule in schedules:
|
||||
if schedule.Name == cost_rate["Schedule"]:
|
||||
@@ -381,17 +399,28 @@ class Csv2Ifc:
|
||||
# and some query in "Query" column.
|
||||
# If query is provided it will override the defined value
|
||||
# due current behaviour in cost.assign_cost_item_quantity.
|
||||
if results:
|
||||
if results and not cost_item["Formula"]:
|
||||
ifcopenshell.api.cost.assign_cost_item_quantity(
|
||||
self.file,
|
||||
cost_item=cost_item["ifc"],
|
||||
products=results,
|
||||
prop_name=prop_name,
|
||||
)
|
||||
elif not quantity:
|
||||
elif not quantity and not cost_item["Formula"]:
|
||||
quantity = ifcopenshell.api.cost.add_cost_item_quantity(
|
||||
self.file, cost_item=cost_item["ifc"], ifc_class=quantity_class
|
||||
)
|
||||
if cost_item["Formula"]:
|
||||
results = ifcopenshell.util.selector.filter_elements(self.file, cost_item["Query"])
|
||||
results = [r for r in results]
|
||||
ifc_quantity_class = ifcopenshell.util.unit.get_symbol_quantity_class(cost_item["Unit"])
|
||||
quantity = ifcopenshell.api.cost.assign_cost_item_quantity(
|
||||
self.file,
|
||||
cost_item=cost_item["ifc"],
|
||||
products=results,
|
||||
formula=cost_item["Formula"],
|
||||
ifc_class=ifc_quantity_class,
|
||||
)
|
||||
|
||||
self.create_cost_items(cost_item["children"], cost_item["ifc"])
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ Begin learning IFC
|
||||
------------------
|
||||
|
||||
IFC has three versions published by ISO: **IFC2X3** from 2007, **IFC4** from
|
||||
2017, and **IFC4X3** in draft form. Each version improves on the previous
|
||||
2017, and **IFC4X3** from 2024. Each version improves on the previous
|
||||
version, and will have different **IFC Classes** with different attributes and
|
||||
different **IFC Concepts**.
|
||||
|
||||
@@ -82,8 +82,9 @@ You can access the official documentation here:
|
||||
|
||||
.. tip::
|
||||
|
||||
It is recommended to use IFC4. However, the IFC4X3 documentation is a lot
|
||||
more friendly to newcomers.
|
||||
For most buildings, IFC4 is recommended. For infrastructure projects (road,
|
||||
railway, bridge, and other civil elements), use IFC4X3. The IFC4X3 documentation
|
||||
is also generally more newcomer-friendly.
|
||||
|
||||
The official ISO documentation is written for a technical audience and may be
|
||||
overwhelming. This guide will take you slowly through the core concepts, and
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -16,10 +16,15 @@
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import ifcopenshell.api.cost
|
||||
import ifcopenshell.api.control
|
||||
import ast
|
||||
import operator
|
||||
from typing import Any
|
||||
|
||||
import ifcopenshell.api.control
|
||||
import ifcopenshell.api.cost
|
||||
import ifcopenshell.util.element
|
||||
|
||||
|
||||
def assign_cost_item_quantity(
|
||||
@@ -27,6 +32,8 @@ def assign_cost_item_quantity(
|
||||
cost_item: ifcopenshell.entity_instance,
|
||||
products: list[ifcopenshell.entity_instance],
|
||||
prop_name: str = "",
|
||||
formula: str = "",
|
||||
ifc_class: str = "IfcQuantityLength",
|
||||
) -> None:
|
||||
"""Adds a cost item quantity that is parametrically connected to a product
|
||||
|
||||
@@ -57,6 +64,12 @@ def assign_cost_item_quantity(
|
||||
:param prop_name: The name of the quantity. If this is not specified,
|
||||
then it is assumed that there is no calculated quantity, and the
|
||||
number of objects are counted instead.
|
||||
:param formula: The string that contains the formula
|
||||
:param ifc_class: The quantity class of the calculated value if the formula is
|
||||
specified. Can be ['IfcQuantityCount', 'IfcQuantityNumber',
|
||||
'IfcQuantityLength', 'IfcQuantityArea', 'IfcQuantityVolume',
|
||||
'IfcQuantityWeight', 'IfcQuantityTime']. Check
|
||||
ifcopenshell.util.unit.QUANTITY_CLASS for more info.
|
||||
:return: None
|
||||
|
||||
Example:
|
||||
@@ -84,6 +97,18 @@ def assign_cost_item_quantity(
|
||||
# item.
|
||||
ifcopenshell.api.cost.assign_cost_item_quantity(model,
|
||||
cost_item=item, products=[slab], prop_name="NetVolume")
|
||||
|
||||
# Now let's use the formula in order to calculate the quantity value.
|
||||
# For example, let's say that a IfcWall has the reinfocement volume ratio
|
||||
# stored in the Pset_ConcreteElementGeneral.ReinforcementVolumeRatio
|
||||
# and of course it has also the gross volume stored in the
|
||||
# Qto_WallBaseQuantities.GrossVolume. So we can add an IfcQuantity that stores the
|
||||
# reinforcement volume calculated with reinfocement volume ratio * gross volume.
|
||||
ifcopenshell.api.cost.assign_cost_item_quantity(model,
|
||||
cost_item=item, products=[wall],
|
||||
formula="Pset_ConcreteElementGeneral.ReinforcementVolumeRatio * NetVolume"
|
||||
ifc_class="IfcQuantityVolume")
|
||||
|
||||
"""
|
||||
usecase = Usecase()
|
||||
usecase.file = file
|
||||
@@ -91,6 +116,8 @@ def assign_cost_item_quantity(
|
||||
"cost_item": cost_item,
|
||||
"products": products or [],
|
||||
"prop_name": prop_name,
|
||||
"formula": formula,
|
||||
"ifc_class" : ifc_class
|
||||
}
|
||||
return usecase.execute()
|
||||
|
||||
@@ -100,12 +127,50 @@ class Usecase:
|
||||
settings: dict[str, Any]
|
||||
|
||||
def execute(self):
|
||||
if self.settings["prop_name"]:
|
||||
if self.settings["prop_name"] or self.settings["formula"]:
|
||||
self.quantities = set(self.settings["cost_item"].CostQuantities or [])
|
||||
for product in self.settings["products"]:
|
||||
if product.is_a("IfcSpatialElement"):
|
||||
continue
|
||||
self.assign_cost_control(related_object=product, cost_item=self.settings["cost_item"])
|
||||
if self.settings["formula"]:
|
||||
tree = ast.parse(self.settings["formula"], mode = "eval")
|
||||
collector = VariableExtractor()
|
||||
collector.visit(tree)
|
||||
variables = collector.variables
|
||||
|
||||
for variable in variables:
|
||||
getter = self.get_value_from_pset if "." in variable else self.get_value_from_qset
|
||||
value = getter(product, variable)
|
||||
|
||||
if value is None:
|
||||
print(
|
||||
f"WARNING: Variable '{variable}' in product '{product.Name}' "
|
||||
f"is missing (None). Check Pset/Qset or property name."
|
||||
)
|
||||
elif value == 0:
|
||||
print(
|
||||
f"WARNING: Variable '{variable}' in product '{product.Name}' "
|
||||
f"has value 0. Verify if this is correct."
|
||||
)
|
||||
|
||||
evaluator = FormulaEvaluator(values)
|
||||
result = evaluator.visit(tree.body)
|
||||
|
||||
new_quantity = None
|
||||
for quantity in self.quantities:
|
||||
if quantity.Formula == self.settings["formula"] and len(self.settings["products"]) == 1: #Todo improve it
|
||||
new_quantity = quantity
|
||||
self.settings["ifc_class"] = quantity.is_a()
|
||||
continue
|
||||
if new_quantity is None:
|
||||
new_quantity = self.file.create_entity(self.settings["ifc_class"], Name="Unnamed")
|
||||
new_quantity.Formula = self.settings["formula"]
|
||||
self.quantities.add(new_quantity)
|
||||
|
||||
new_quantity[3] = result
|
||||
continue
|
||||
|
||||
if self.settings["prop_name"]:
|
||||
if (
|
||||
self.settings["cost_item"].CostQuantities
|
||||
@@ -113,11 +178,30 @@ class Usecase:
|
||||
):
|
||||
continue
|
||||
self.add_quantity_from_related_object(product)
|
||||
if self.settings["prop_name"]:
|
||||
if self.settings["prop_name"] or self.settings["formula"]:
|
||||
self.settings["cost_item"].CostQuantities = list(self.quantities)
|
||||
else:
|
||||
self.update_cost_item_count()
|
||||
|
||||
def get_value_from_pset(
|
||||
self,
|
||||
product:ifcopenshell.entity_instance,
|
||||
v: str,
|
||||
) -> float:
|
||||
pset_name = v.split(".")[0]
|
||||
pset = ifcopenshell.util.element.get_pset(product, pset_name)
|
||||
pset_property_name = v.split(".")[1]
|
||||
return (pset or {}).get(pset_property_name,None)
|
||||
|
||||
def get_value_from_qset(
|
||||
self,
|
||||
product:ifcopenshell.entity_instance,
|
||||
v: str,
|
||||
) -> float:
|
||||
qtos = ifcopenshell.util.element.get_psets(product, qtos_only = True)
|
||||
quantities = next(iter(qtos.values()), {})
|
||||
return (quantities or {}).get(v,None)
|
||||
|
||||
def assign_cost_control(
|
||||
self, related_object: ifcopenshell.entity_instance, cost_item: ifcopenshell.entity_instance
|
||||
) -> ifcopenshell.entity_instance:
|
||||
@@ -158,3 +242,55 @@ class Usecase:
|
||||
if not obj.is_a("IfcConstructionResource"):
|
||||
count += 1
|
||||
quantity[3] = count
|
||||
|
||||
OPERATORS = {
|
||||
ast.Add: operator.add,
|
||||
ast.Sub: operator.sub,
|
||||
ast.Mult: operator.mul,
|
||||
ast.Div: operator.truediv,
|
||||
ast.Pow: operator.pow,
|
||||
ast.USub: operator.neg,
|
||||
}
|
||||
|
||||
def build_full_name(node):
|
||||
#used for variables with dots
|
||||
parts = []
|
||||
while isinstance(node, ast.Attribute):
|
||||
parts.append(node.attr)
|
||||
node = node.value
|
||||
|
||||
if isinstance(node, ast.Name):
|
||||
parts.append(node.id)
|
||||
|
||||
return ".".join(reversed(parts))
|
||||
|
||||
class VariableExtractor(ast.NodeVisitor):
|
||||
def __init__(self):
|
||||
self.variables = set()
|
||||
|
||||
def visit_Name(self, node):
|
||||
self.variables.add(node.id)
|
||||
|
||||
def visit_Attribute(self, node):
|
||||
self.variables.add(build_full_name(node))
|
||||
|
||||
class FormulaEvaluator(ast.NodeVisitor):
|
||||
def __init__(self, values):
|
||||
self.values = values
|
||||
|
||||
def visit_BinOp(self, node):
|
||||
left = self.visit(node.left)
|
||||
right = self.visit(node.right)
|
||||
return OPERATORS[type(node.op)](left, right)
|
||||
|
||||
def visit_Name(self, node):
|
||||
return self.values[node.id]
|
||||
|
||||
def visit_Attribute(self, node):
|
||||
return self.values[build_full_name(node)]
|
||||
|
||||
def visit_Constant(self, node):
|
||||
return node.value
|
||||
|
||||
def generic_visit(self, node):
|
||||
raise ValueError(f"Operation not permitted: {type(node).__name__}")
|
||||
|
||||
@@ -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",))
|
||||
|
||||
@@ -74,14 +74,29 @@ def test_opening_unicode():
|
||||
|
||||
@pytest.mark.skipif(psutil is None, reason="psutil not installed")
|
||||
def test_memusage_partial_open():
|
||||
m0 = psutil.Process().memory_info().rss
|
||||
f = ifcopenshell.open(fn)
|
||||
m1 = psutil.Process().memory_info().rss
|
||||
g = ifcopenshell.open(fn, bypass_types=("IfcRepresentationItem",))
|
||||
m2 = psutil.Process().memory_info().rss
|
||||
# arbitrary...
|
||||
expected_ratio = 0.75
|
||||
assert (m2 - m1) < (m1 - m0) * expected_ratio
|
||||
# Run in a subprocess to ensure the file is not already in the process page
|
||||
# cache from earlier tests, which would make both RSS deltas read as zero.
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
script = f"""
|
||||
import psutil
|
||||
import ifcopenshell
|
||||
|
||||
fn = {repr(fn)}
|
||||
m0 = psutil.Process().memory_info().rss
|
||||
f = ifcopenshell.open(fn)
|
||||
m1 = psutil.Process().memory_info().rss
|
||||
g = ifcopenshell.open(fn, bypass_types=("IfcRepresentationItem",))
|
||||
m2 = psutil.Process().memory_info().rss
|
||||
expected_ratio = 0.75
|
||||
assert (m2 - m1) < (m1 - m0) * expected_ratio, (
|
||||
f"bypass_types did not reduce memory: normal open added {{m1 - m0}} bytes, "
|
||||
f"bypass open added {{m2 - m1}} bytes (expected < {{(m1 - m0) * expected_ratio:.0f}})"
|
||||
)
|
||||
"""
|
||||
result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr or result.stdout
|
||||
|
||||
|
||||
def test_rocks():
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,8 +110,8 @@ class Patcher(ifcpatch.BasePatcher):
|
||||
pass
|
||||
if element.is_a("IfcProject"):
|
||||
proj = self.new.add(element)
|
||||
for ctx in element.RepresentationContexts:
|
||||
for coop in ctx.HasCoordinateOperation:
|
||||
for ctx in element.RepresentationContexts or ():
|
||||
for coop in getattr(ctx, 'HasCoordinateOperation', ()):
|
||||
self.new.add(coop)
|
||||
return proj
|
||||
return ifcopenshell.api.project.append_asset(
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user