Compare commits

..

17 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Generated with the assistance of an AI coding tool.

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 09:31:40 -05:00
200 changed files with 2665 additions and 3871 deletions
+3 -2
View File
@@ -109,7 +109,7 @@ jobs:
# Ensure Bonsai and ifcsverchok enable/disable works before uploading to extensions repo.
# Download Blender.
wget -q -O blender.tar.xz https://download.blender.org/release/Blender5.2/blender-5.2.0-linux-x64.tar.xz
wget -q -O blender.tar.xz https://download.blender.org/release/Blender5.1/blender-5.1.0-linux-x64.tar.xz
tar -xf blender.tar.xz
# Setup Blender.
@@ -179,7 +179,8 @@ jobs:
blender --online-mode --command extension install --enable --sync sun_position
cd IfcOpenShell/src/bonsai
pip install -r requirements-dev.txt
pip install pytest-blender
pip install pytest-bdd
blender --background --python scripts/setup_pytest.py
blender --python-expr "import bonsai; print(bonsai.bbim_semver); import ifcopenshell; print(ifcopenshell.version)" --background
make test
+11 -17
View File
@@ -27,7 +27,10 @@ jobs:
- name: Install dependencies
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
cat requirements-tools.txt | xargs -L1 uv tool install
uv tool install ruff
uv tool install black
uv tool install poethepoet
uv tool install ty==0.0.34
# black doesn't catch all syntax errors, so we check them explicitly.
- name: Check syntax errors
@@ -55,17 +58,11 @@ jobs:
black --diff --check . | black-codeclimate | python .github/workflows/black_to_github_annotations.py
continue-on-error: true
- name: ty check (venv setup)
run: poe ty-venv
- name: ty check (bonsai)
id: ty-bonsai
run: poe ty-bonsai
continue-on-error: true
- name: ty check (ios)
id: ty-ios
run: poe ty-ios
- name: ty check
id: ty
run: |
poe ty-venv
poe ty
continue-on-error: true
- name: Ruff check
@@ -115,10 +112,7 @@ jobs:
if [ "${{ steps.ruff.outcome }}" != "success" ]; then
echo "::error::Ruff check failed, see Summary or 'ruff' step for the details." && ERROR=1
fi
if [ "${{ steps.ty-bonsai.outcome }}" != "success" ]; then
echo "::error::ty check (bonsai) failed, see 'ty check (bonsai)' step for the details." && ERROR=1
fi
if [ "${{ steps.ty-ios.outcome }}" != "success" ]; then
echo "::error::ty check (ios) failed, see 'ty check (ios)' step for the details." && ERROR=1
if [ "${{ steps.ty.outcome }}" != "success" ]; then
echo "::error::ty check failed, see 'ty check' step for the details." && ERROR=1
fi
exit $ERROR
-4
View File
@@ -4,8 +4,6 @@
/_deps-vs*-x*-installed/
/_installed-vs*-x*/
/build/
/build.log
/output/
/src/examples/build/
# ifctester docs output
/src/ifctester/test/build/
@@ -24,7 +22,6 @@
__pycache__
*.py.bak
venv
uv.lock
# Visual Studio Code files
.vscode
@@ -130,7 +127,6 @@ src/ifcopenshell-python/ifcopenshell/express/*.exp.cache.dat
# temp files from AI coding tools
*.claude
CLAUDE.local.md
*.py.tmp*
*.json.tmp*
+4 -8
View File
@@ -314,12 +314,8 @@ if(WASM_BUILD)
else()
# @todo review this, shouldn't this be all possible header-only now?
# ... or rewritten using C++17 features?
# Boost.System has been header-only since 1.69 and its compiled stub library
# was dropped in newer Boost, so requesting it as a component makes
# find_package fail on Boost 1.70 and up (for example Boost 1.90). It is
# still pulled in transitively by thread / iostreams where needed, so do not
# request it explicitly.
set(BOOST_COMPONENTS
system
program_options
regex
thread
@@ -563,8 +559,8 @@ if(COMPILE_SCHEMA)
# Bootstrap the parser
message(STATUS "Compiling schema, this will take a while...")
execute_process(
COMMAND ${PYTHON_EXECUTABLE} bootstrap.py
WORKING_DIRECTORY ../src/ifcopenshell-python/ifcopenshell/express
COMMAND ${PYTHON_EXECUTABLE} bootstrap.py express.bnf
WORKING_DIRECTORY ../src/ifcexpressparser
OUTPUT_FILE express_parser.py
RESULT_VARIABLE SUCCESS
)
@@ -575,7 +571,7 @@ if(COMPILE_SCHEMA)
# Generate code
execute_process(
COMMAND ${PYTHON_EXECUTABLE} ../ifcopenshell-python/ifcopenshell/express/express_parser.py ../../${COMPILE_SCHEMA}
COMMAND ${PYTHON_EXECUTABLE} ../ifcexpressparser/express_parser.py ../../${COMPILE_SCHEMA}
WORKING_DIRECTORY ../src/ifcparse
OUTPUT_VARIABLE COMPILED_SCHEMA_NAME
)
-3
View File
@@ -1,3 +0,0 @@
.env
*.pyc
__pycache__
-3
View File
@@ -1,3 +0,0 @@
.env
*.pyc
__pycache__
-21
View File
@@ -1,21 +0,0 @@
#!/usr/bin/env bash
# .ifcos_env
# register autocompletes. just source the file in your shell, i.e.
# source .ifcos_env
.ifcos_env() {
local cur prev opts
COMPREPLY=()
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}"
opts="create update up down restart build attach logs ps config remove help"
# Basic static completion
COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
return 0
}
# Register the completion for the command "ifcos_env"
complete -F .ifcos_env ./ifcos_env
-67
View File
@@ -1,67 +0,0 @@
FROM rockylinux:9
# Update system, enable CRB (needed by some EPEL packages) and install EPEL,
# then install required packages + some common tools for a bit of command
# line comfort. Combined into one layer so a later `create` always installs
# against packages from the same dnf update, rather than layering fresh
# installs on top of a stale cached "update" layer.
RUN dnf update -y && \
dnf install -y epel-release && \
dnf config-manager --set-enabled crb && \
dnf install -y --allowerasing --setopt=install_weak_deps=False --setopt=tsflags=nodocs \
bash-completion vim git curl wget which tree htop sudo \
gcc gcc-c++ autoconf automake bison make zip cmake \
python3 python3-pip \
bzip2 patch mesa-libGL-devel libffi-devel fontconfig-devel \
sqlite-devel bzip2-devel zlib-devel openssl-devel xz-devel \
readline-devel ncurses-devel libuuid-devel git-lfs \
findutils xz byacc ccache && \
git lfs install --system && \
dnf clean all && \
rm -rf /var/cache/dnf
# Trust bind-mounted repos regardless of which user (root or builder) or host
# UID owns them, rather than a per-user config that only one of them sees.
RUN git config --system --add safe.directory '*'
# Configure ccache. CCACHE_MAXSIZE (not `ccache -M`) because /ccache is a
# volume mount point at runtime - anything `ccache -M` writes to a config
# file under it during this build gets shadowed once the real volume is
# mounted, so the size cap only actually takes effect via the env var.
# 2G is generous: a full build (IfcParse+IfcGeom+IfcConvert+wrapper, one
# Python version) measures ~300MB, and the volume is now shared across all
# checkouts (see compose.yaml), so this covers several diverging branches.
ENV CCACHE_DIR=/ccache
ENV CCACHE_MAXSIZE=2G
ENV PATH="/usr/lib/ccache:$PATH"
# Non-root user matching the host UID/GID that bind-mounts the repo (default
# 1000:1000, the common single-user-Linux-box case), so files the build
# creates under the mount keep sane, non-root ownership on the host side.
# Override with --build-arg USER_UID=$(id -u) --build-arg USER_GID=$(id -g)
# if your host user has a different UID/GID.
ARG USER_UID=1000
ARG USER_GID=1000
# groupadd fails outright if USER_GID is already taken by an existing
# system group - which happens whenever a host's primary GID collides with
# one baked into the rockylinux9 base image. The main real-world case is
# macOS, where the default user's primary group is "staff" at GID 20, and
# GID 20 is "games" on RHEL-family images. Only create the "builder" group
# when that GID is actually free; otherwise useradd just attaches to
# whichever group already owns it. Either way the builder user ends up
# with the right GID for bind-mount ownership, which is all that matters.
RUN (getent group "${USER_GID}" >/dev/null || groupadd -g "${USER_GID}" builder) \
&& useradd -m -u "${USER_UID}" -g "${USER_GID}" -s /bin/bash builder \
&& echo "builder ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/builder
# Copied while still root: /bin is not writable by the builder user.
COPY --from=ghcr.io/astral-sh/uv:0.11.27 /uv /uvx /bin/
USER builder
WORKDIR /__w/IfcOpenShell/IfcOpenShell
# Installed as builder so managed Python interpreters land under builder's
# $HOME, matching the user that actually runs the build.
RUN uv python install
CMD ["sleep", "infinity"]
-78
View File
@@ -1,78 +0,0 @@
Docker build environment
========================
This is a small utility to make it easy to compile a perfect `_ifcopenshell_wrapper.cpython-*-x86_64-linux-gnu.so`
files.
The reason for this tool is that I was trying to follow the web page directions, and my build was behaving differently
to the release builds. Eventually I concluded that the differences between toolchains on the RHEL based rocky9 image
and Ubuntu were just too great. Getting the build setup was already a lot of trial and error, so I thought I'd spend
more time trying to reuse the github actions that perform the build, using a utility called `act`. I learnt a lot, in
particular how much time, energy, and bandwidth Github waste. I also realised I was most of the way to a regular docker
setup anyway, so I might as well just do that. So I've deconstructed all the github action steps, and turned it into
a local docker build environment that uses the exact same base, tools, libraries, and build command/flags etc.
Right now a Github action will:
- launch the rocky9 base
- upgrade all the packages
- install a bunch of extra tools
- do a recursive checkout of your repo
- checkout the build repository
- unpack dependencies
- run the build script, making all python versions (5? right now I think)
- create the .zip release files
And it does _all_ of that _every_ time. This is not a fault of the action writers - it's just how Github seems to work.
These dockers tools do the following differently, and it's actually a bit more powerful too:
- build the base image once.
- update the packages once.
- install the extra tools once.
- the repository is the one on your host, that gets bind mounted in the container as the working directory.
- by adding an environment variable to .env, restricts to compiling for just a single python version.
- when the build is finished the created files are right there under your local repositry (but not added to git) for
ease of access
- each repository can have it's own build environment container.
- the image is shared between those environments.
- the containers share the ccache, so additional envs should get a helping hand.
- it has a simple set of user friendly commands to drive it all.
For example:
``` bash
# To see the commands (a superset of docker compose commands)
./ifcos_env
# Enable autocomplete of commands
source .ifcos_env
# First time commands
./ifcos_env create
./ifcos_env up
./ifcos_env build
# install and test library
# find an issue
# edit code
./ifcos_env build
# and so on. When done stop and optionally delete the container
./ifcos_env stop
./ifcos_env remove
```
To limit the build to one python version just add
``` bash
PY_TGT=py-311
```
or whichever version your Blender requires.
You might see UNIQUE_ID in the .env file too. This keeps containers for separate folders, separate.
System requirements
1. Linux-x64 only at this time.
2. Docker and docker-compose need to be installed.
3. Have a good amount of disk space. (image is in /var (typically the root partition) and will be about 1.7 GB)
4. The build action will create about 10GB in your repository folder. Make sure this partition is spacious
particularly if you intent on having multiple clones building.
5. ... I think that covers most of it.
-186
View File
@@ -1,186 +0,0 @@
---
name: ifcopenshell-docker-build
description: >-
Build a real ifcopenshell_wrapper (.so + .py) and IfcConvert locally via
the docker/ifcos_env toolchain, then wire them into a checkout for
running C++-dependent parts of the test suite (geometry, the SWIG
wrapper stub, the C++ parser). Use whenever a task needs to compile
IfcOpenShell's C++ core rather than just read/patch source - e.g.
reproducing or fixing a bug in src/ifcgeom, src/ifcparse, src/ifcwrap,
or validating util/scripts/validate_stub.py against the actual
generated wrapper.
---
# Building IfcOpenShell locally with docker/ifcos_env
`docker/` mirrors the project's GitHub Actions build environment locally,
in a persistent, non-root container with ccache so repeat builds are fast.
See `docker/README.md` for the design rationale. Pure-Python changes don't
need any of this - only reach for it when you need a real compiled
`_ifcopenshell_wrapper*.so` or `IfcConvert` binary.
## Placement
This `docker/` folder must live as a direct child of the repo root you want
to build (sibling of `src/`, `cmake/`, etc.) - `compose.yaml` and
`ifcos_env` resolve the repo via `../` relative to wherever `docker/`
itself sits, and bind-mount it into the container. If you're setting this
up in a fresh clone, copy the whole `docker/` directory there first.
## Setup
```bash
cd docker
./ifcos_env create # build the image (shared by name across all your clones/checkouts, so usually instant after the first time anywhere)
./ifcos_env up # create + start the container, clone/unpack the third-party dependency cache (~10GB, one-time per container)
./ifcos_env build # full build: all deps + IfcParse + IfcGeom + IfcConvert + the Python wrapper, for one Python version
```
`PY_TGT` and `UNIQUE_ID` live in `docker/.env` - `PY_TGT` (e.g. `py-311`)
restricts the build to one Python version instead of building five;
`UNIQUE_ID` is a hash of the folder path, recalculated on every `up`, so
each checkout gets its own container/volumes automatically.
A full first build takes ~1.5 hours (mostly compiling IfcOpenShell's own
C++, not the cached third-party deps). After that, ccache makes incremental
rebuilds of a couple of touched `.cpp` files **under a minute**.
## Container lifecycle
The container is long-lived (`sleep infinity`) so exec'd commands and
ccache state persist between builds. Commands map directly onto Docker
Compose's own container-vs-image distinction:
```bash
./ifcos_env up # create the container if it doesn't exist, then start it (runs ready_repo too)
./ifcos_env stop # stop the container, keep it around
./ifcos_env start # start it back up (same container, same filesystem layer)
./ifcos_env restart # stop, then start
./ifcos_env down # remove the container (and its network) entirely
./ifcos_env recreate # down, then up - a fresh container
```
Named volumes (`ccache`) and the bind-mounted repo/`build/` are unaffected
by `down`/`recreate` - only the container itself goes away, and `up`
recreates it from the image.
## Fast iteration
Pass a target to `build` to skip the parts you don't need:
```bash
./ifcos_env build IfcConvert # only the executables (IfcConvert, IfcGeomServer) - skips the Python wrapper entirely
./ifcos_env build IfcOpenShell-Python # only the SWIG Python wrapper - skips executables entirely
./ifcos_env build # no target = everything (needed the first time, or after touching shared headers)
```
Use this to keep the edit -> rebuild -> test loop fast when debugging: if
you're only touching `src/ifcgeom/`, build `IfcConvert`; if you're only
exercising the Python API, build `IfcOpenShell-Python`.
## Where the artifacts land
Build output goes to `<repo_root>/build/Linux/x86_64/install/` on the host
(bind-mounted, not just inside the container), owned by you (see
"Container user" below):
- `ifcopenshell/bin/IfcConvert` - the CLI binary
- `python-<version>/lib/python<X.Y>/site-packages/ifcopenshell/_ifcopenshell_wrapper*.so`
and `ifcopenshell_wrapper.py` - the compiled wrapper + its generated
Python glue
## Testing against a checkout (automated / AI-driven)
`_ifcopenshell_wrapper*.so` and `ifcopenshell_wrapper.py` are already
gitignored under `src/ifcopenshell-python/ifcopenshell/`, which is exactly
where a normal in-tree build would put them - copy the two files there:
```bash
SRC=build/Linux/x86_64/install/python-3.11.8/lib/python3.11/site-packages/ifcopenshell
cp "$SRC/_ifcopenshell_wrapper.cpython-311-x86_64-linux-gnu.so" src/ifcopenshell-python/ifcopenshell/
cp "$SRC/ifcopenshell_wrapper.py" src/ifcopenshell-python/ifcopenshell/
```
Then, to run the test suite against it:
```bash
export PATH="$PWD/build/Linux/x86_64/install/ifcopenshell/bin:$PATH" # for IfcConvert-dependent tests
cd src/ifcopenshell-python/test
PYTHONPATH="$PWD/.." python3.11 -m pytest -p no:pytest-blender .
```
(`-p no:pytest-blender` avoids the pytest-blender plugin trying to find a
`blender` executable and failing collection entirely, even for non-Blender
tests.) You'll need the matching Python version's `pip install`s too
(numpy, shapely, isodate, lark, tabulate, pytest, ... - whatever the
modules under test import) since this is a bare interpreter, not the
project's pixi env.
**This is the pattern to use for automated or AI-driven verification.**
Don't use `try` (below) for that - it overwrites files in a real, live
Blender installation, which isn't something an automated/AI workflow
should ever do without the human explicitly asking for it in the moment.
## Testing in Blender itself (human only)
`try` copies the built wrapper straight into your actual Blender/Bonsai
extension install, for manual in-Blender testing:
```bash
./ifcos_env try
```
It reads `BLENDER_USER_RESOURCE` from `.env` - set this to wherever
Blender's user resource folder for the Bonsai extension actually lives on
your system, which depends on your own Blender setup:
```bash
# in docker/.env
BLENDER_USER_RESOURCE=~/.config/blender/bonsai/
```
`try` figures out the built Python version from `build/.../install/`
(disambiguating with `PY_TGT` if more than one version was built) and
copies the wrapper to
`$BLENDER_USER_RESOURCE/extensions/.local/lib/python<X.Y>/site-packages/ifcopenshell/`.
## Container user
The image runs as a non-root `builder` user, UID/GID matching your host
account (passed as `--build-arg` by `create` from `id -u`/`id -g`, so it
adjusts automatically - no manual flag needed even if you're not 1000:1000).
Files the build creates under the bind mount come out owned by you, not
root. Passwordless `sudo` is available inside the container (e.g. via
`attach`) for the rare case you need root for something ad hoc.
If you're picking up an existing checkout that was previously built with
an older, root-based image, you may hit `Permission denied` the first time
you run `up`/`build` under the new image - `build/`, `.git/modules/`, the
`ccache` volume, `output/`, and `build.log` can all be left root-owned from
before. Fix it once via the container's own root (no host `sudo` needed):
```bash
docker exec -u root -w /__w/IfcOpenShell/IfcOpenShell <container-name> \
chown -R "$(id -u)":"$(id -g)" .git/modules build output build.log /ccache
```
(`<container-name>` is `ifcopenshell-<UNIQUE_ID>` - see `docker ps -a`.)
## Other things worth knowing
- **Linux x64 only.** `compose.yaml` pins `platform: linux/amd64`; on an
ARM host (e.g. Apple Silicon) this build isn't available.
- **The final "Package .zip archives" step of `build()` has a pre-existing
bash syntax error**, unrelated to compilation - the actual build already
succeeded by that point (look for `Built IfcOpenShell...` in the output),
so this is safe to ignore if you only need the raw artifacts under
`build/.../install/`, not packaged release zips.
- **`test_mmaped_stream` and similar `USE_MMAP`-dependent tests will fail**
against this build - `nix/build-all.py` is invoked with `USE_MMAP=OFF`
here. Not a bug in your code if you see it fail.
- Only the bind-mounted `<repo>/build` lives on the host filesystem your
repo is checked out on. Anything the container writes *outside* that
mount lives in the container's own writable layer under Docker's data
root (commonly `/var/lib/docker`, i.e. usually your root partition) -
keep an eye on `df -h /` if you're running several of these containers
at once.
-15
View File
@@ -1,15 +0,0 @@
name: ifcopenshell-${UNIQUE_ID}
services:
ifcopenshell:
container_name: ifcopenshell-${UNIQUE_ID}
image: ifcopenshell-build-env:updated
platform: linux/amd64
volumes:
- type: bind
source: ../
target: /__w/IfcOpenShell/IfcOpenShell
- ccache:/ccache
volumes:
ccache:
name: ifcopenshell-ccache-shared
-339
View File
@@ -1,339 +0,0 @@
#!/bin/bash
# ================== CONFIG ==================
SCRIPT_NAME=$(basename "$0")
ENV_FILE=".env"
WORKDIR="/__w/IfcOpenShell/IfcOpenShell"
NAMEPREFIX=ifcopenshell
function set_env() {
# Load .env file if it exists
if [[ -f "$ENV_FILE" ]]; then
set -a
source "$ENV_FILE"
set +a
echo "✅ Loaded environment variables from $ENV_FILE"
else
echo "⚠️ No $ENV_FILE found, proceeding without it."
fi
}
set_env
# ================ FUNCTIONS =================
function create() {
echo "⭐ Creating image: ifcopenshell-build-env"
docker build -f Dockerfile \
--build-arg USER_UID="$(id -u)" --build-arg USER_GID="$(id -g)" \
-t ifcopenshell-build-env:updated .
}
function update() {
# The Dockerfile always builds FROM a clean rockylinux:9 and does
# `dnf update -y` as its first step, so re-running create() is enough
# to get fresh packages.
echo "⚡ Updating image: ifcopenshell-build-env"
create
}
function up() {
# Creates the container if it doesn't exist yet (and starts it either
# way) - this is the one that needs ready_repo, since a freshly created
# container has no submodules/dependency cache in place yet.
echo "🚀 Creating/starting stack: ifcopenshell-${UNIQUE_ID}"
unique # Update UNIQUE_ID first
docker compose up -d "$@" # Container must exist before ready_repo can exec into it.
ready_repo # Ensure repo is recursive, and the build repo is in place.
}
function down() {
# Removes the container (and its network) entirely. Named volumes
# (ccache) and the bind-mounted repo/build/ survive; up() will recreate
# the container from scratch next time.
echo "🔥 Removing stack: ifcopenshell-${UNIQUE_ID}"
docker compose down "$@"
}
function stop() {
# Stops the existing container without removing it - the container,
# its filesystem layer, and its exec history all remain intact.
echo "🛑 Stopping stack: ifcopenshell-${UNIQUE_ID}"
docker compose stop "$@"
}
function start() {
# Starts a previously-stopped container back up. Does nothing (and
# won't create anything) if the container doesn't exist - use up() for
# that.
echo "▶️ Starting stack: ifcopenshell-${UNIQUE_ID}"
docker compose start "$@"
}
function restart() {
echo "🔄 Restarting stack (stop, then start)..."
stop
start
}
function recreate() {
echo "♻️ Recreating stack (down, then up)..."
down
up
}
function logs() {
echo "📜 Showing logs..."
docker compose logs -f "$@"
}
function ps() {
docker compose ps
}
function config() {
echo "🔍 Validated compose configuration:"
docker compose config
}
function remove() {
# Lower-level than down(): removes already-stopped containers without
# touching the compose network. Mostly useful after a plain stop().
echo "🗑️ Removing stopped containers: ifcopenshell-${UNIQUE_ID}"
docker compose rm "$@"
}
function unique() {
echo "🔧 Making stack name folder specific..."
REGEX="^UNIQUE_ID="
if [[ ! -f "$ENV_FILE" ]] || ! grep -qE "$REGEX" "$ENV_FILE"; then
echo -e "\nUNIQUE_ID=dummy\n" >> "$ENV_FILE"
fi
export UNIQUE_ID="$(pwd | sha256sum | cut -c -8)"
# `sed -i` takes incompatible syntax between GNU sed (Linux) and BSD sed
# (macOS) - `-si` is GNU-only and errors as "illegal option -- s" under
# BSD/macOS sed. Avoid -i altogether and do the in-place edit via a temp
# file + mv instead, which behaves identically with either sed.
local tmp_file
tmp_file="$(mktemp "${ENV_FILE}.XXXXXX")"
sed "s/^UNIQUE_ID=.*$/UNIQUE_ID=${UNIQUE_ID}/" "$ENV_FILE" > "$tmp_file"
mv "$tmp_file" "$ENV_FILE"
set_env
}
function ready_repo() {
echo "👍 Getting the repo ready to build..."
docker exec -i -w "${WORKDIR}" "${NAMEPREFIX}-${UNIQUE_ID}" bash -c '
set -euo pipefail # Recommended for robustness
git submodule update --init --recursive
if [[ ! -d "build" ]]; then
git clone -b rockylinux9-x64 https://github.com/IfcOpenShell/build-outputs.git build
else
cd build
git pull
cd ..
fi
if [[ ! -d "build/Linux/x86_64/install/boost-1.86.0/" ]]; then
cd build
uv run ../nix/cache_dependencies.py unpack
cd ..
fi
'
}
function build() {
echo "☕ Execute the build, go make yourself a cuppa... I'll be a while"
local BUILD_TARGET="$1"
docker exec -i -w "${WORKDIR}" -e PY_TGT="${PY_TGT}" -e BUILD_TARGET="${BUILD_TARGET}" "${NAMEPREFIX}-${UNIQUE_ID}" bash -c '
set -o pipefail
CXXFLAGS="-O3" CFLAGS="-O3 ${DARWIN_C_SOURCE}" ADD_COMMIT_SHA=1 BUILD_CFG=Release uv run ./nix/build-all.py -v ${PY_TGT:+-$PY_TGT} --diskcleanup ${BUILD_TARGET} 2>&1 | tee build.log
'
echo "🎒 Pack Dependencies"
docker exec -i -w "${WORKDIR}" "${NAMEPREFIX}-${UNIQUE_ID}" bash -c '
cd build
uv run ../nix/cache_dependencies.py pack
'
echo "🎁 Package .zip archives"
docker exec -i -w "${WORKDIR}" -e GITHUB_SHA="$(git rev-parse HEAD)" "${NAMEPREFIX}-${UNIQUE_ID}" bash -c '
OUTPUT_DIR=${PWD}/output
VERSION=v`cat VERSION`
mkdir -p ${OUTPUT_DIR}
cd ./build/`uname`/*/install/ifcopenshell
ls -d python-* | while read py_version; do
postfix=`echo ${py_version: -1} | sed s/[0-9]//`
numbers=`echo $py_version | grep -oE "[0-9]+\.[0-9]+" | tr -d "."`
py_version_major=python-${numbers}$postfix
pushd . > /dev/null
cd $py_version
if [ ! -d ifcopenshell ]; then
mkdir ../ifcopenshell_
mv * ../ifcopenshell_
mv ../ifcopenshell_ ifcopenshell
fi
[ -d ifcopenshell/__pycache__ ] && rm -rf ifcopenshell/__pycache__
find ifcopenshell -name "*.pyc" -delete
zip -r -qq ifcopenshell-${py_version_major}-${VERSION}-${GITHUB_SHA:0:7}-linux64.zip ifcopenshell/*
mv *.zip ${OUTPUT_DIR}/
popd > /dev/null
done
cd bin
if compgen -G "./*.zip" > /dev/null; then
rm *.zip 2>&1 >/dev/null || true
ls | while read exe; do
zip -qq -r ${exe}-${VERSION}-${GITHUB_SHA:0:7}-linux64.zip $exe
done
mv *.zip ${OUTPUT_DIR}/
cd ..
'
}
function attach() {
echo "🔦 Connect to interactive shell"
docker exec -it -w "${WORKDIR}" "${NAMEPREFIX}-${UNIQUE_ID}" /bin/bash
}
function try() {
# Copies the freshly built wrapper into your actual Blender/Bonsai
# installation for manual, in-Blender testing. This is a human-only
# convenience: it overwrites files in your live Blender setup, so it's
# not something that should run unattended as part of an automated or
# AI-driven build/test loop (which should instead copy the wrapper into
# the repo's own src/ifcopenshell-python/ifcopenshell/ - see SKILL.md).
echo "🚴 Copying build artifacts into your Blender resource folder for testing"
if [[ -z "${BLENDER_USER_RESOURCE:-}" ]]; then
echo "❌ BLENDER_USER_RESOURCE is not set in .env."
echo " Add a line pointing at wherever Blender's user resource folder for"
echo " the Bonsai extension actually is on your system, e.g.:"
echo " BLENDER_USER_RESOURCE=~/.config/blender/bonsai/"
return 1
fi
# Normalise: expand a leading ~ (in case it was quoted in .env and so
# never went through shell tilde-expansion when set_env sourced it),
# then resolve to an absolute, symlink-free path.
local resource="${BLENDER_USER_RESOURCE/#\~/$HOME}"
resource="$(realpath -m "$resource")"
local install_dir="../build/Linux/x86_64/install"
local py_dirs=("$install_dir"/python-*)
if [[ ${#py_dirs[@]} -gt 1 && -n "${PY_TGT:-}" ]]; then
# PY_TGT is compact (py-311); the install dirs are dotted
# (python-3.11.8) - reinsert the dot (assumes a single-digit major
# version, true for the Python 3.x line) before matching.
local py_tgt_digits="${PY_TGT#py-}"
local py_tgt_dotted="${py_tgt_digits:0:1}.${py_tgt_digits:1}"
local filtered=() d
for d in "${py_dirs[@]}"; do
[[ "$(basename "$d")" == "python-${py_tgt_dotted}."* ]] && filtered+=("$d")
done
[[ ${#filtered[@]} -gt 0 ]] && py_dirs=("${filtered[@]}")
fi
if [[ ${#py_dirs[@]} -ne 1 || ! -d "${py_dirs[0]}" ]]; then
echo "❌ Expected exactly one built python-* dir under $install_dir, found ${#py_dirs[@]}."
echo " Run 'build' first, or set PY_TGT in .env to disambiguate a multi-version build."
return 1
fi
local py_minor
py_minor="$(basename "${py_dirs[0]}" | grep -oE '[0-9]+\.[0-9]+')"
local wrapper_dir="${py_dirs[0]}/lib/python${py_minor}/site-packages/ifcopenshell"
if [[ ! -f "$wrapper_dir/ifcopenshell_wrapper.py" ]]; then
echo "❌ Built wrapper not found at $wrapper_dir - run 'build' first."
return 1
fi
local target="$resource/extensions/.local/lib/python${py_minor}/site-packages/ifcopenshell"
mkdir -p "$target"
cp "$wrapper_dir"/_ifcopenshell_wrapper*.so "$target/"
cp "$wrapper_dir"/ifcopenshell_wrapper.py "$target/"
echo "✅ Copied wrapper into $target"
}
function clean() {
# Host-side only - doesn't touch the container, image, or ccache volume.
echo "💎 Clean the build and output folder up"
if [[ -d "../build" ]]; then
rm -rf ../build
fi
if [[ -d "../output" ]]; then
rm -rf ../output
fi
}
function help() {
cat <<EOF
Usage: ./$SCRIPT_NAME <command>
Available commands:
create Build the rocky9-based image
update Rebuild the image fresh, picking up OS package updates
up Create the container if it doesn't exist yet, and start it
down Remove the container entirely (docker compose down)
stop Stop the container without removing it
start Start a previously-stopped container
restart stop, then start (same container, no recreation)
recreate down, then up (fresh container)
build Execute the IfcOpenShell build
attach Connect to an interactive shell in the container
try Copy the built wrapper into your Blender resource folder
(human-only - see BLENDER_USER_RESOURCE below, and SKILL.md
for the AI/automated-testing equivalent)
clean Remove the build and output folders
logs Follow container logs
ps Show running containers
config Validate and show compose config
remove Remove stopped containers (docker compose rm)
help Show this help
Environment variables from .env are automatically loaded, including:
PY_TGT Restrict the build to one Python version, e.g. py-311
UNIQUE_ID Recalculated automatically on every 'up', don't set by hand
BLENDER_USER_RESOURCE Where 'try' copies the wrapper for manual testing, e.g.
~/.config/blender/bonsai/
EOF
}
# ================= MAIN =================
case "$1" in
create) create ;;
update) update ;;
up) up "${@:2}" ;;
down) down "${@:2}" ;;
stop) stop "${@:2}" ;;
start) start "${@:2}" ;;
restart) restart ;;
recreate) recreate ;;
build) build "${@:2}" ;;
attach) attach ;;
try) try ;;
clean) clean ;;
logs) logs "${@:2}" ;;
ps) ps ;;
config) config ;;
remove) remove ;;
help|-h|--help) help ;;
"")
echo "❌ No command provided."
help
;;
*)
echo "❌ Unknown command: $1"
echo "Type './$SCRIPT_NAME help' for available commands."
exit 1
;;
esac
+400
View File
@@ -0,0 +1,400 @@
<!-- This file was generated with the assistance of an AI coding tool. -->
# Linked file features — queries, styles, transforms, and multi-linking for linked IFC models
> **Living dev note** for the `Linked_File_Features` branch/PR. Read before working
> on the feature; append decisions and findings as the PR is refined. This is *not* user
> documentation — at merge it is removed or its durable parts promoted to code comments.
> See [README.md](README.md) for the convention (introduced on the
> `opening-template-on-type` branch; not yet on this branch's base).
## Problem
Linked IFC models (`bim.link_ifc`) had several gaps that made them hard to use as a
"reference in other trades' models" workflow:
- One shared `.ifc.cache.blend` per IFC file meant the **same file could not be linked
twice with different selector queries** — both links showed whichever query was cached
first in-session, and whichever was cached last after reopening (Blender reuses one
library datablock per path).
- The selector query was not durably stored anywhere in the host IFC, so save → reopen
lost or cross-wired the filter; a scripted `bpy.ops.bim.reload_link()` also wiped it.
- Linked geometry got **flat diffuse-only materials** — external `.blend` styles
(`IfcExternallyDefinedSurfaceStyle`) and per-layer materials (layerset slicing) that
the normal import applies were ignored.
- Moving a linked model required an explicit enable-edit → move → save dance on the
active link only, with save/cancel buttons in the panel header.
- The Explore tool's highlight broke (GPU type errors), drew at the link's *original*
location when the link had been moved, and `bim.append_inspected_linked_element`
placed appended elements at the original location too.
## Key facts established
- **Cache architecture**: `LoadLink.link_ifc` generates a Python script and runs a
background Blender subprocess that executes `bim.load_linked_project` and saves a
`.ifc.cache.blend`. The host session then *links* (not appends) the `IfcProject/...`
collection from that blend and instances it via an empty (the link "handle").
Georeferencing metadata lives in a sidecar `.cache.json`; extracted properties in
`.cache.sqlite` (whole file, query-independent — deliberately shared across queries).
- **Blender reuses an in-session library per path.** Loading the same blend path twice
yields the same library/collection. This is what broke multi-query linking with a
shared cache filename, and why per-query *filenames* (not cache invalidation) are the
fix.
- **Last-used operator properties** are reused on the next *interactive* invocation
(UI button), while scripted `bpy.ops` calls always start from defaults. LoadLink's
internal `self.query = link.query` fallback assignment was remembered by Blender and
leaked into the next button click (`operator_query='IfcWindow'` for the door link).
Any `is_property_set()`-based logic is corrupted the same way. Fix: `SKIP_SAVE` on
volatile props. **A GUI-only bug like this is invisible to scripted repro** — both
headless and windowed `--python` test runs passed while the manual flow failed.
- **`IfcDocumentReference`** per link: attribute index 1 (`Identification`) already
stores the link's 4×4 transformation (existing Bonsai convention). `Description`
(IFC4+; **absent in IFC2X3**) now stores the selector query. One
`IfcDocumentInformation` (Scope `LINKED_MODEL`) per file, one reference per link.
- **Geometry iterator materials**: `material.instance_id()` is the STEP id of the
`IfcSurfaceStyle` — or of an `IfcMaterial` when the item has a material but no style,
hence the `is_a("IfcSurfaceStyle")` guard when resolving external styles.
- **External styles**: `IfcExternallyDefinedSurfaceStyle.Location` (`.blend`, relative
paths resolve against the *linked* IFC, not the host) + `Identification` in
`data_block_type/name` form (e.g. `materials/Brick`), same convention as
`bim.activate_external_style`.
- **Chunk pipeline dedups materials by RGBA color** (`np.unique` on a color array), so
style identity must ride along as an extra column to survive — added only for styles
that actually resolve to an external material, so plain colored styles dedupe exactly
as before.
- **`slice_layerset_mesh` needs a local-space, per-element mesh** (bisect planes are in
object space), which the chunk path can't provide (world-space, many elements per
mesh) — hence routing multi-layer elements through the instanced path. Its
`dissolve_limit` produces **ngons**, which broke the Explore highlight's
triangles-from-`polygon.vertices` assumption downstream.
- **ID properties round-trip as `IDPropertyArray`**, not plain lists (verified in
4.5.7: empty list → flat `IDPropertyArray`; nested lists → list of `IDPropertyArray`
items), and `GPUIndexBuf` rejects them — selection geometry must be converted to
plain tuples on read.
- **`scene.ray_cast` returns the hit instance's world matrix** (link empty matrix
included). For instanced occurrence objects the object's own local matrix is *not*
identity, so resolving the instancing empty must compare against
`empty.matrix_world @ obj.matrix_world`, not the empty's matrix alone.
- **Link matrix math**: the handle empty's matrix is `inv(L) @ T @ G` (L = host local
matrix from georef props, T = stored transformation, G = linked model's global
matrix from the cache json). The world-space displacement of a moved link is
therefore `inv(L) @ T @ L` — no json read needed (`calculate_link_delta_matrix`).
- **Undo consistency of auto-saved moves**: Blender undo of a handle move fires another
depsgraph update, so the handler re-saves the reverted matrix — stored state stays
consistent without transactions (a handler can't open one).
## Design
### Per-query caches + query persistence (multi-linking)
`tool.Project.get_link_cache_paths(filepath, query)` appends `.md5(query)[:8]` to the
cache blend/json names; the empty query keeps the legacy un-suffixed names so existing
caches stay valid. Every cache-path consumer goes through it — `link_ifc` build and
invalidation, the subprocess json write, model-origin/georef indicator reads,
`calculate_link_matrix`, `save_link_transformation`, and the per-link
selectability/wireframe/visibility toggles (which match collections *by library
filepath* and would otherwise affect every link of the file at once).
The query persists on each link's `IfcDocumentReference.Description` (written by
`LinkIfc` and `ReloadLink`); `load_linked_models_from_ifc` restores from it, with a
legacy-JSON fallback that only applies when the file has a **single** link (with
several links the shared JSON can't say which link it belonged to). IFC2X3 hosts have
no `Description` — custom queries are not restorable there (accepted).
`LoadLink`/`ReloadLink` volatile properties are `SKIP_SAVE` (see key facts). Cache
clearing tolerates a missing blend (a reload with a brand-new query points at a
not-yet-existing filename).
### Include/Exclude filter pair
The selector grammar's only cross-group combiner is `+` (union) and the `parent`
facet cannot express "not under X" (its `!=`/regex paths also match by GlobalId, so
negation removes everything with any parent), which makes set differences like
"group members minus the slabs under aggregate X" structurally inexpressible in one
query string. Links therefore carry an **Exclude** query beside the include —
mirroring `EPset_Drawing`'s Include/Exclude pattern: final set = include (or the
default set when empty) exclude, applied in `LoadLinkedProject` and per link in
`create_drawing`.
- **Cache key**: `get_link_cache_paths` hashes `md5(query + "\0" + exclude)` when an
exclude exists; include-only filters keep the pre-exclude `md5(query)` so existing
caches stay valid; empty filter keeps legacy un-suffixed names. Keying on query
alone would let same-include/different-exclude links silently serve each other's
geometry.
- **Persistence**: `encode_link_filter`/`decode_link_filter` — a plain include is
stored in `Description` as-is (backwards compatible); an exclude, a `loaded`
state or a custom display name promotes the value to
`{"include": …, "exclude": …, "loaded": …, "name": …}` JSON. Decode treats
non-JSON as a legacy include string. The display name (`Link.display_name`,
double-click the list row to rename; file path shows as placeholder while
unset) exists to tell apart several links of the same file.
- Exclude applies on top of the **default** element set too, so
"everything except X" needs no explicit include.
- UI labels are **Include**/**Exclude** (matching the drawing pattern), but the
property identifier stays `query` for script (`bpy.ops.bim.link_ifc(query=…)`)
and persistence compatibility.
- Verified headless: `query=""`/`exclude="IfcDoor"` loads only the window;
same file with a different filter gets its own cache; both filters survive
save → reopen → reload.
### Auto-load on open
Links that were **loaded and visible** at IFC save time auto-load when the project
is reopened. `ExportIFC` calls `tool.Project.update_linked_models_state()`, which
rewrites each reference's `Description` with a `loaded` flag
(`is_loaded and not is_hidden`); `load_linked_models_from_ifc` replays flagged
links via `load_link` after restoring the list (missing files warn and skip so
they can't break project open). The flag extends the same JSON blob as the
exclude — plain legacy strings decode as no-autoload. Trade-off: project open
pays the link-load cost up front (fast on cache hit; a missing cache rebuilds in
a background Blender, same as clicking Load). Verified headless: loaded+visible
auto-loads; unloaded and loaded-but-hidden links stay unloaded.
### Long-term serialization target: STEP Part 21 Edition 3
STEP p21e3 defines the standards-track version of this feature's persistence:
`ANCHOR`/`REFERENCE` sections (clauses 910) let one file import entities from
another via URI + fragment, and **anchor tags** (`{tagname: value}`) are the
designated slot for out-of-schema metadata — a cleaner home than the
`Description` JSON blob (see the review-round discussion). ifcopenshell does not
implement these sections yet ([#668](https://github.com/IfcOpenShell/IfcOpenShell/issues/668),
open, unassigned); if it ever does, the migration path is: link →
`REFERENCE` to the linked file's project anchor, filter/transform/loaded
metadata → anchor tags. Keeping the blob behind
`encode_link_filter`/`decode_link_filter` makes that a two-function change.
Two p21e3 design points this branch already conforms to:
- **Identity**: p21e3 distinguishes volatile file-scoped entity numbers
(`#100` fragments) from durable anchors/UUIDs — the same lesson behind our
STEP-id collision fixes (GUID-based matching, `element.file` guards). Raw
STEP ids must never cross a file boundary; IFC GlobalIds map 1:1 onto
p21e3 UUID anchors.
- **Transport** (clause A.4): exchange structures plus referenced resources
can ship as one ZIP archive with references resolving inside it. Our posix,
optionally relative `Location`s resolved via `resolve_uri` are exactly the
invariants a future "package project with links" export would need.
Even full p21e3 support would not cover per-link transforms, filters, or load
state — a `REFERENCE` imports entities, it does not place a model — so the
app-level metadata remains; only its container would change.
### External styles + layerset slicing in the linked loader
`LoadLinkedProject.get_external_material(style_id)` resolves a style id → appended
Blender material from the external `.blend`, cached two ways (per style id; per
appended data-block, so styles sharing one material don't append duplicates). Appended
materials get their stale `ifc_definition_id` cleared (the source `.blend` may have
been authored in a Bonsai session; the id would be misread in the linked file *and*
in the host once the cache links in). Applied in both loading paths — instanced
occurrences directly, chunks via the style-id column.
Multi-layer elements (`IfcMaterialLayerSetUsage`, >1 layer) route through the
instanced path and get `slice_layerset_mesh`, which gained a pluggable
`style_to_material` resolver (defaults to the old `tool.Ifc.get_object` for the normal
import) — the linked resolver prefers the external material, falling back to a flat
diffuse from the style's shading colour. Also fixed there: newly appended layer
materials are registered in the dedup dict (two layers sharing one style used to
append it twice).
Trade-off: layered walls become individual instanced objects instead of chunk members;
meshes shared between elements (same geometry id) bake the slice from the first
element's layerset usage — same behaviour as the normal importer.
### Reload Link dialog
`bim.reload_link` now exposes File Path (+ browse button), Use Relative Path
(defaulting to the stored path form), Use Cache (default off = old always-rebuild
behaviour), the False Origin Mode project props, and Query. A file browser can't open
from inside a props dialog, so the browse button runs `bim.select_link_filepath`
(fileselect) which *reopens* the reload dialog with the chosen path, carrying the
in-progress dialog state through the round trip (op props are baked at draw time).
Path changes update `link.name`/`filepath` and, with a host IFC, the reference
`Location` + document name — which is why `ReloadLink` became a `tool.Ifc.Operator`.
Script calls without arguments preserve all stored link values via `is_property_set`.
`bim.reload_all_links` (refresh button beside Link IFC in the panel header) reloads
every *loaded* link via argument-less `reload_link` calls — each link's stored
path/query/exclude replay and its cache rebuilds from disk. Unloaded links are left
alone. Deliberately expensive: one background cache rebuild per link.
### Per-row lock toggle + auto-saved transforms
Link editing moved from the panel header into each list row as a lock/unlock icon:
unlock (`bim.enable_editing_link`) frees the handle; **any movement is persisted
immediately** by a `depsgraph_update_post` handler (lazy — ticks without transform
updates cost ~nothing); lock (`bim.disable_editing_link`) saves and locks.
`bim.edit_link` and the explicit save step are **removed**; cancel/restore semantics
no longer exist (undo or move it back). The save math lives in
`tool.Project.save_link_transformation`. Enable/disable take a `link_index`
(default 1 = active link) so several links can be edited at once and script calls
stay compatible.
### Explore tool + append fixes for moved links
- Highlight triangles come from `mesh.calc_loop_triangles()` filtered to the queried
element's polygon range (ngon-safe); edges keep `polygon.edge_keys` (no diagonals).
- `get_selected_geometry` converts the ID-prop round trip to plain tuples (GPU
rejects `IDPropertyArray`); TRIS drawing gated on its own data.
- `QueryLinkedElement` passes the ray-cast instance matrix through;
`find_obj_root` compares it against `empty @ obj_local` and falls back to the
collection's only instance when no matrix is available (select-by-GUID flow).
- `bim.append_inspected_linked_element` pre-multiplies the imported object's matrix by
`calculate_link_delta_matrix(link)`, matching the link by the queried instance's
root empty first (filepath alone is ambiguous with several links per file). The
element's IFC placement syncs to the moved location on save — intended.
### Drawings (`create_drawing`) — moved links and per-link queries
- The linework serializer opened linked IFCs raw, so a moved link's elements were
drawn at their *original* coordinates (usually outside the drawing extents —
"linked objects disappear from prints after moving the link").
- The stored link transformation is already the **model-space** delta (that is how
`save_link_transformation` derives it), which is exactly the space the serializer
works in — so it can be baked straight into the geometry iterator via the existing
`model-offset`/`model-rotation` settings. The mapping composes
`Trans(model-offset) @ Rot(model-rotation)` (see `mapping.cpp`), matching the
`Trans(t) @ Rot(R)` decomposition of the rigid link matrix; `model-rotation` is a
quaternion passed as `(x, y, z, w)`. The pre-existing 2mm plan-view Z-offset simply
adds onto the translation (translations commute).
- The serialization loop previously collected files in a dict keyed by filepath, which
**collapsed same-file links into one pass** (one transform — the last link's — and
no query awareness): with two links of one file, only one showed in the drawing.
It now iterates one entry per link (`(path, file, transform, query)` tuples), and
intersects each link's drawing elements with
`ifcopenshell.util.selector.filter_elements(ifc, link.query)` so the drawing shows
what that link actually displays in the viewport.
- `tool.Project.get_link_transformation_matrix(link)` is the shared accessor for the
stored 4×4 (None when identity/absent).
- Verified headless with the window/door kit: moved window offset in the SVG by
exactly 5m × scale; unmoved door at its native position; both links present.
### Drawings — `.cut` styling for linked models (BISECT cut mode)
- The default **BISECT** cut mode deletes the OpenCASCADE serializer's cut linework
(`remove_cut_linework`) and regenerates cuts by bisecting **Blender mesh objects**
(`generate_bisect_linework` over `context.visible_objects`). Linked models are
instanced collections with no mesh objects, so their cuts were deleted and never
regenerated — linked elements only ever appeared as `projection`, and the `.cut`
CSS rule never applied to them. Long-standing gap, unrelated to moved links
(A/B-tested against pre-branch code: identical).
- Fix: `remove_cut_linework` only removes cut groups whose guid resolves in the
**host** file — linked elements keep the serializer's cut geometry, which the
merge step then classes as `cut`.
- **Cross-file STEP-id collision**: `tool.Ifc.get_object(linked_entity)` resolves the
entity's STEP id against the *host* session's id map and can return an arbitrary
host object (in the test project: the drawing camera, crashing
`generate_material_layers` with "expected 'Mesh' found 'Camera'"). Guarded via
`element.file is tool.Ifc.get()` in `generate_material_layers` and the merge step.
- **Paint order**: the projection-under-cut convention was enforced only in
OPENCASCADE mode (`move_projection_to_bottom`); BISECT appends its own cut paths
last so it never needed it — but the retained serializer cuts of linked models are
emitted *before* the projections. BISECT now runs the same pass; `BringToFront`
(`move_elements_to_top`) still gets the final say.
- Known limitation: linked cut paths are raw serializer output — they skip the
shapely path-closing/merging and the material-layer hatching pass (both need host
Blender objects). Stroke + fill from `.cut` CSS apply; layered hatching inside
linked cuts is a candidate follow-up.
- Debugging note: merged cut groups carry member guids as CSS *classes*, not as the
`ifcopenshell:guid` attribute — inspect both when checking cut output.
## Deferred refactors (deliberate)
- **Upstream `exclude=` on `filter_elements`** — the includeexclude set difference
is hand-rolled twice (links, drawings) because the selector grammar has no
difference operator and `parent` negation is broken by design (its `!=`/regex
paths also match GlobalIds, so negation strips everything that has a parent).
The right home is an `exclude=` parameter on
`ifcopenshell.util.selector.filter_elements`, documented in
`selector_syntax.rst` together with the `parent`-negation limitation. Deferred
to a separate ifcopenshell-python PR (different review audience; would widen
this PR mid-review). Once it lands, both Bonsai call sites collapse.
- **Core/tool ceremony skipped** — the new `tool.Project` methods have no
`core/tool.py` interface declarations and no `bonsai/core` orchestration
functions, matching the pre-existing linked-model code (which bypasses the
core layer wholesale; `LoadLinkedProject` is flagged "prototyping" upstream).
Interfaces nobody calls through wouldn't add testability — the pure helpers
(`encode_link_filter`/`decode_link_filter`, `get_link_cache_paths`) are
covered directly in `test/tool/test_project.py` instead. Revisit if the
linked-model subsystem is ever promoted out of prototype status.
## Review round 1 (PR #8242, falken10vdl) — decisions
- **Path-form mismatch → duplicate documents (confirmed bug, fixed).**
`get_linked_models_documents()` keyed documents by the *stored* `Location`, so
linking the same file first relative then absolute (or vice versa) created a second
`IfcDocumentInformation`. Both sides of the lookup now normalize through
`tool.Ifc.resolve_uri()` before matching.
- **`Description` for the query — kept.** It is implementation metadata in an IFC
attribute, but consistent with the existing convention on these same references
(`Identification` stores the 4×4 transformation, a bigger stretch). References are
Bonsai-managed (`Scope="LINKED_MODEL"`), so user-description collisions are unlikely.
A cleaner consolidated convention (query + transform + options in one serialized
attribute) is a candidate follow-up, deliberately out of scope here.
- **`md5(query)[:8]` — kept.** 32 bits ≈ birthday collision at ~65k distinct queries
*per file*; and a collision is not silent: the cache JSON stores the full query and
`should_clear_cache()` compares it, so a colliding cache is detected and rebuilt
(self-healing).
- **Depsgraph autosave vs save-on-lock — autosave kept.** Save-on-lock alone loses the
"what you see is what's saved" guarantee (move + save project without locking =
silently dropped move) and loses undo tracking (undo fires a depsgraph update that
re-saves the reverted transform). The handler early-outs when no links exist and only
works on ticks containing an object-transform update while a link is unlocked.
## Status — implemented (verified in Blender, incl. headless + GUI repro runs)
Six commits on `Linked_File_Features`:
- `0096c0f6a2` reload_link without a query preserves the stored one.
- `40db55e52d` external styles + layerset slicing for linked models
(`project/operator.py`, `tool/loader.py`).
- `d210d4c814` full Reload Link dialog + `bim.select_link_filepath`.
- `3dc161f0f2` per-row lock toggle, auto-save handler, `edit_link` removed
(`project/operator.py`, `project/ui.py`, `project/__init__.py`, `tool/project.py`).
- `0571d22855` Explore highlight (ngons, IDPropertyArray), moved-link highlight,
append placement (`tool/project.py`, `project/operator.py`, `project/decorator.py`).
- `c14592ec0a` per-query caches, Description persistence, SKIP_SAVE.
Plus:
- `ee43ed5526` review-round path normalization in `get_linked_models_documents` /
`LinkIfc` (see Review round 1).
- `1669cbcd43` drawing support for moved links and per-link queries in
`create_drawing` (`drawing/operator.py`, `tool/project.py`).
- `.cut` styling for linked models in BISECT cut mode + STEP-id collision guards +
paint order (`drawing/operator.py`) — committed together with this note update.
End-to-end verified with a two-links-one-file kit (window/door, distinct queries):
correct visuals on load, after save → reopen → reload, in both headless and windowed
Blender.
## Things to test / verify
- **IFC2X3 host**: `Description` doesn't exist — link queries silently not restored on
reopen (legacy fallback only for single-link files). Acceptable? Warn?
- **Relative-path links** (`use_relative_path`) through the whole cycle: cache paths,
reference `Location`, reload path change, query restore. The duplicate-document case
(same file linked relative then absolute) is fixed — verify one document with two
references via `IfcDocumentInformation.HasDocumentReferences`.
- Same file linked twice, **both moved differently**: Explore highlight and append
placement per instance (root-empty matching), per-link visibility toggles.
- External styles with **image textures**: paths relative to the style's source
`.blend` may not resolve from the cache blend's location (shared limitation with the
normal import path).
- Stale cache orphans: per-query filenames accumulate one blend+json pair per distinct
query next to the IFC; nothing auto-deletes them. Cleanup on unlink? Document?
- Mid-drag auto-save writes the IFC reference outside Bonsai's transaction system —
confirm no undo-stack weirdness in longer editing sessions.
- Layerset slicing on meshes shared by elements with *different* usages (offset/sense)
bakes the first element's slice — same as normal import, but worth a look with types.
- `bim.select_link_filepath` round trip when the reload dialog was opened for a
non-active link, and dialog-state carry-over after editing the query *then* browsing.
- **Drawing SVG guid cache vs moved links**: `create_drawing` skips elements whose
guids already exist in the drawing's SVG (`cached_linework`, invalidated only for
*edited host objects*). Moving a link does not invalidate its elements, so a
regenerated drawing keeps their old positions until the SVG is deleted. Candidate
fix: subtract a moved link's guids from `cached_linework` (compare stored transform
against the one recorded at last generation).
- Same element appearing in two links of one file (overlapping queries) serializes
twice with different transforms; the SVG guid cache keeps whichever came first on
regeneration. Degenerate case — probably fine to ignore, but note it.
+5 -15
View File
@@ -50,7 +50,7 @@ Used environment variables:
- ``NO_CLEAN`` - do not clean `ifcopenshell` build directories but continue working on current build
(installed dependencies are never cleared).
By default option is disabled, to enable pass any value from `1`, `on`, `true`.
- ``IFCOS_SCHEMAS`` - schemas to be built; defaults to cmake default (8 schemas), to be supplied as `2x3;4;4x3_add2`
- ``IFCOS_SCHEMAS`` - schemas to be built; defaults to cmake default (IFC2X3; IFC4; IFC4X3_ADD2) - to be supplied as `2x3;4`
- ``USE_OCCT`` - whether to use official Open CASCADE instead of Community Edition
(`true` by default, any other value is considered `false`)
- ``WASM_PYTHON_PATH`` - path to WASM Python installation,
@@ -155,7 +155,7 @@ MPFR_VERSION = "3.1.6" # latest is 4.1.0
CGAL_VERSION = "v5.6.3"
USD_VERSION = "23.05"
TBB_VERSION = "2021.9.0"
ROCKSDB_VERSION = "10.4.2"
ROCKSDB_VERSION = "9.11.2"
ZSTD_VERSION = "1.5.7"
# binaries
cp = "cp"
@@ -627,10 +627,9 @@ def build_dependency(
build_tool_args: "list[str]",
download_url: str,
download_name: str,
*,
download_tool: Literal["py", "git"] = download_tool_default,
revision: "Union[str, None]" = None,
patch: list[str] | None = None,
patch: "Union[str, list[str], None]" = None,
shell=None,
pre_compile_subs: "Sequence[tuple[str, str, str]]" = (),
additional_files: "Union[dict[str, str], None]" = None,
@@ -715,6 +714,8 @@ def build_dependency(
urlretrieve(url, os.path.join(extract_dir, path))
if patch is not None:
if isinstance(patch, str):
patch = [patch]
for p in patch:
patch_abs = (SCRIPT_PATH / p).absolute().__str__()
if os.path.exists(patch_abs):
@@ -723,8 +724,6 @@ def build_dependency(
except Exception as e:
# Assert that the patch has already been applied
run(["patch", "-p1", "--batch", "--reverse", "--dry-run", "-i", patch_abs], cwd=extract_dir)
else:
raise FileNotFoundError(patch_abs)
if shell is not None:
sp.run(shell, shell=True, check=True, cwd=extract_dir)
@@ -1172,14 +1171,6 @@ if "cgal" in targets:
os.environ["CC"] = MAC_CROSS_COMPILE_INTEL_CC
gmp_args.extend(MAC_CROSS_COMPILE_INTEL_AUTOCONF_HOST_ARGS)
# Fixes configure failing to find a working compiler under GCC 15's default -std=gnu23.
# Issue presumably will be resolved in any next gmp version, but currently the last one is 6.3.0.
# Patch is just applying fix from upstream meantion below:
# https://gmplib.org/list-archives/gmp-bugs/2025-February/005561.html
gmp_patches = ["./patches/gmp/001-fix-std23.patch"]
if GMP_VERSION != "6.3.0":
raise Exception(f"GMP_VERSION changed to {GMP_VERSION}, check whether {gmp_patches} is still needed.")
build_dependency(
name=f"gmp-{GMP_VERSION}",
mode="autoconf",
@@ -1187,7 +1178,6 @@ if "cgal" in targets:
pre_compile_subs=(
[("build/config.h", "HAVE_OBSTACK_VPRINTF 1", "HAVE_OBSTACK_VPRINTF 0")] if "wasm" in flags else []
),
patch=gmp_patches,
# Sometimes ftp.gnu.org is very slow, use ftpmirror.gnu.org as a workaround.
download_url="https://ftpmirror.gnu.org/gnu/gmp/",
download_name=f"gmp-{GMP_VERSION}.tar.bz2",
-27
View File
@@ -1,27 +0,0 @@
Fixes configure failing to find a working compiler under GCC 15's default
-std=gnu23 (upstream fix: https://gmplib.org/repo/gmp/rev/8e7bb4ae7a18).
Upstream fix is patching `acinclude.m4`, but since in the release tarball
all macros are already expanded to `configure` script, so we're patching
all occurrences of that macro.
--- a/configure
+++ b/configure
@@ -6568,7 +6568,7 @@
#if defined (__GNUC__) && ! defined (__cplusplus)
typedef unsigned long long t1;typedef t1*t2;
-void g(){}
+void g(int,t1 const*,t1,t2,t1 const*,int){}
void h(){}
static __inline__ t1 e(t2 rp,t2 up,int n,t1 v0)
{t1 c,x,r;int i;if(v0){c=1;for(i=1;i<n;i++){x=up[i];r=x+1;rp[i]=r;}}return c;}
@@ -8187,7 +8187,7 @@
#if defined (__GNUC__) && ! defined (__cplusplus)
typedef unsigned long long t1;typedef t1*t2;
-void g(){}
+void g(int,t1 const*,t1,t2,t1 const*,int){}
void h(){}
static __inline__ t1 e(t2 rp,t2 up,int n,t1 v0)
{t1 c,x,r;int i;if(v0){c=1;for(i=1;i<n;i++){x=up[i];r=x+1;rp[i]=r;}}return c;}
+32
View File
@@ -0,0 +1,32 @@
http://git.dev.opencascade.org/gitweb/?p=occt.git;a=commitdiff;h=0ab4e621833f4eae945a3762c9a29ee12e2eec53#patch1
diff --git a/src/HLRBRep/HLRBRep_InternalAlgo.cxx b/src/HLRBRep/HLRBRep_InternalAlgo.cxx
index ca885ca..c13cb06 100644 (file)
--- a/src/HLRBRep/HLRBRep_InternalAlgo.cxx
+++ b/src/HLRBRep/HLRBRep_InternalAlgo.cxx
@@ -165,7 +165,7 @@ void HLRBRep_InternalAlgo::Update ()
SB.Bounds(v1,v2,e1,e2,f1,f2);
for (Standard_Integer e = e1; e <= e2; e++) {
- HLRBRep_EdgeData ed = aEDataArray.ChangeValue(e);
+ HLRBRep_EdgeData& ed = aEDataArray.ChangeValue(e);
HLRAlgo::DecodeMinMax(ed.MinMax(), TheMin, TheMax);
if (FirstTime) {
FirstTime = Standard_False;
@@ -307,7 +307,7 @@ void HLRBRep_InternalAlgo::InitEdgeStatus ()
Standard_Integer nf = myDS->NbFaces();
for (Standard_Integer e = 1; e <= ne; e++) {
- HLRBRep_EdgeData ed = aEDataArray.ChangeValue(e);
+ HLRBRep_EdgeData& ed = aEDataArray.ChangeValue(e);
if (ed.Selected()) ed.Status().ShowAll();
}
// for (Standard_Integer f = 1; f <= nf; f++) {
@@ -368,7 +368,7 @@ void HLRBRep_InternalAlgo::Select ()
Standard_Integer nf = myDS->NbFaces();
for (Standard_Integer e = 1; e <= ne; e++) {
- HLRBRep_EdgeData ed = aEDataArray.ChangeValue(e);
+ HLRBRep_EdgeData& ed = aEDataArray.ChangeValue(e);
ed.Selected(Standard_True);
}
+22
View File
@@ -0,0 +1,22 @@
From a0deb4ce8b43cf3c8b8c0a4225c6be5296446dbd Mon Sep 17 00:00:00 2001
From: Adam Eri <adam.eri@blackmirror.media>
Date: Tue, 3 Sep 2019 23:30:20 +0200
Subject: [PATCH] Resolves compile error on macOS
Resolves "no member named 'isnan' in namespace 'std'" on macOS
---
GeneratedSaxParser/src/GeneratedSaxParserUtils.cpp | 1 +
1 file changed, 1 insertion(+)
diff --git a/GeneratedSaxParser/src/GeneratedSaxParserUtils.cpp b/GeneratedSaxParser/src/GeneratedSaxParserUtils.cpp
index 1f9a3eef..dd6f5c59 100644
--- a/GeneratedSaxParser/src/GeneratedSaxParserUtils.cpp
+++ b/GeneratedSaxParser/src/GeneratedSaxParserUtils.cpp
@@ -10,6 +10,7 @@
#include "GeneratedSaxParserUtils.h"
#include <math.h>
+#include <cmath>
#include <memory>
#include <string.h>
#include <limits>
+86 -42
View File
@@ -1,8 +1,13 @@
[project]
name = "IfcOpenShell"
version = "0.0.0"
# Don't provide requires-python explicitly
# allowing pyprojects to set their own (e.g. bonsai and general ifcopenshell version differ).
dependencies = [
"black==26.3.1",
"ruff==0.15.12",
"poethepoet",
"ty==0.0.32",
"gersemi==0.26.1",
]
[tool.black]
line-length = 120
@@ -38,7 +43,6 @@ exclude = [
# then they will be inherited by projects' .toml files.
# This allows using assuming different Python version for different projects.
[tool.ruff]
line-length = 120
exclude = [
# Submodules.
"src/ifcopenshell-python/ifcopenshell/express",
@@ -79,39 +83,92 @@ ignore = [
]
[tool.ty.rules]
all = "error"
all = "ignore"
# Structural rules (no deep type inference needed, easier to adapt).
abstract-method-in-final-class = "error"
ambiguous-protocol-member = "error"
conflicting-declarations = "error"
conflicting-metaclass = "error"
cyclic-class-definition = "error"
cyclic-type-alias-definition = "error"
dataclass-field-order = "error"
duplicate-base = "error"
duplicate-kw-only = "error"
empty-body = "error"
escape-character-in-forward-annotation = "error"
final-on-non-method = "error"
final-without-value = "error"
ignore-comment-unknown-rule = "error"
implicit-concatenated-string-type-annotation = "error"
inconsistent-mro = "error"
ineffective-final = "error"
instance-layout-conflict = "error"
invalid-dataclass = "error"
invalid-dataclass-override = "error"
invalid-enum-member-annotation = "error"
invalid-explicit-override = "error"
invalid-frozen-dataclass-subclass = "error"
invalid-generic-class = "error"
invalid-generic-enum = "error"
invalid-ignore-comment = "error"
invalid-legacy-positional-parameter = "error"
invalid-legacy-type-variable = "error"
invalid-named-tuple = "error"
invalid-newtype = "error"
invalid-overload = "error"
invalid-paramspec = "error"
invalid-protocol = "error"
invalid-syntax-in-forward-annotation = "error"
invalid-total-ordering = "error"
invalid-type-alias-type = "error"
invalid-type-checking-constant = "error"
invalid-type-guard-definition = "error"
invalid-type-variable-bound = "error"
invalid-type-variable-constraints = "error"
invalid-typed-dict-header = "error"
invalid-typed-dict-statement = "error"
override-of-final-method = "error"
override-of-final-variable = "error"
possibly-missing-import = "error"
possibly-missing-submodule = "error"
# Has false positives due to ty walrus operator bug.
possibly-unresolved-reference = "ignore"
# Maybe later, requires to specify element types for all generics.
missing-type-argument = "ignore"
# Conflicts with `bpy` props defined using annotations.
invalid-type-form = "ignore"
# possibly-unresolved-reference = "error"
raw-string-type-annotation = "error"
redundant-final-classvar = "error"
shadowed-type-variable = "error"
subclass-of-final-class = "error"
super-call-in-named-tuple-method = "error"
unavailable-implicit-super-arguments = "error"
unbound-type-variable = "error"
undefined-reveal = "error"
unresolved-global = "error"
unresolved-import = "error"
unresolved-reference = "error"
unused-ignore-comment = "error"
unused-type-ignore-comment = "error"
useless-overload-body = "error"
# Non-structural rules:
deprecated = "error"
zero-stepsize-in-slice = "error"
possibly-missing-implicit-call = "error"
unused-awaitable = "error"
# Function argument rules:
# Conflicts with `ifcopenshell.api.geometry.add_representation` type of callables we have, confusing them with a module.
call-non-callable = "ignore"
# bpy is missing some context manager implementations.
invalid-context-manager = "ignore"
# Doesn't go well with `bpy.ops.xxx.yyy`.
unresolved-attribute = "ignore"
# call-non-callable = "error"
conflicting-argument-forms = "error"
# Too many false positives.
invalid-argument-type = "ignore"
invalid-method-override = "ignore"
invalid-assignment = "ignore"
invalid-parameter-default = "ignore"
missing-override-decorator = "ignore"
invalid-yield = "ignore"
invalid-return-type = "ignore"
non-callable-init-subclass = "ignore"
not-iterable = "ignore"
possibly-missing-attribute = "ignore"
no-matching-overload = "ignore"
not-subscriptable = "ignore"
unsupported-dynamic-base = "ignore"
unsupported-operator = "ignore"
type-assertion-failure = "ignore"
# invalid-argument-type = "error"
missing-argument = "error"
parameter-already-assigned = "error"
positional-only-parameter-as-kwarg = "error"
too-many-positional-arguments = "error"
unknown-argument = "error"
# Has a lot of warnings due to current ty walrus operator issues.
# index-out-of-bounds = "error"
# unresolved-attribute = "error"
[tool.ty.environment]
extra-paths = [
@@ -158,18 +215,6 @@ exclude = [
[tool.poe.tasks]
dev-setup.sequence = [
{cmd = "uv sync"},
{cmd = "uv pip install -e ./src/bsdd/"},
{cmd = "uv pip install -e ./src/ifcopenshell-python/[advanced,dev]"},
{cmd = "uv pip install -e ./src/ifcedit/"},
{cmd = "uv pip install -e ./src/ifcpatch/"},
{cmd = "uv pip install -e ./src/ifcquery/"},
{cmd = "uv pip install -e './src/ifcmcp/[mcp]'"},
{cmd = "uv pip install -r src/bonsai/requirements-dev.txt"},
]
dev-setup.help = "Install repo packages in editable mode"
ruff = "ruff check"
black = "black ."
@@ -198,7 +243,6 @@ cmake-format = "gersemi . --in-place"
# --ignore unresolved-reference: walrus operator false positives in ty.
cmd = """
ty check
nix/
src/bcf
src/bsdd
src/ifc2ca
-5
View File
@@ -1,5 +0,0 @@
black==26.3.1
ruff==0.15.12
poethepoet
ty==0.0.59
gersemi==0.26.1
-2
View File
@@ -320,11 +320,9 @@ def loadIfcStore(scene: bpy.types.Scene) -> None:
IfcStore.purge()
refresh_ui_data()
if not tool.Ifc.get():
tool.Autosave.cancel_timer()
return
tool.Ifc.schema()
IfcStore.relink_all_objects()
tool.Autosave.reset_timer()
@persistent
+1 -1
View File
@@ -46,7 +46,7 @@ IFC_CONNECTED_TYPE = Union[bpy.types.Material, bpy.types.Object]
class OperationData(TypedDict):
id: int
guid: NotRequired[str]
obj: NotRequired[str]
obj: str
class EditObjectOperationData(TypedDict):
+1 -6
View File
@@ -980,13 +980,8 @@ class IfcImporter:
if unit.Name == "METRE":
if not unit.Prefix:
bpy.context.scene.unit_settings.length_unit = "METERS"
elif f"{unit.Prefix}METERS" in ("KILOMETERS", "CENTIMETERS", "MILLIMETERS", "MICROMETERS"):
bpy.context.scene.unit_settings.length_unit = f"{unit.Prefix}METERS"
else:
# Blender's length_unit enum has no entry for other
# SI prefixes (e.g. DECIMETERS), so fall back to
# adaptive display instead of failing to open.
bpy.context.scene.unit_settings.length_unit = "ADAPTIVE"
bpy.context.scene.unit_settings.length_unit = f"{unit.Prefix}METERS"
else:
bpy.context.scene.unit_settings.system = "IMPERIAL"
name = unit.Name.lower()
@@ -843,6 +843,7 @@ class AddBoundary(bpy.types.Operator, tool.Ifc.Operator):
settings = ifcopenshell.geom.settings()
shape = ifcopenshell.geom.create_shape(settings, opening)
mat = Matrix(ifcopenshell.util.shape.get_shape_matrix(shape))
mat.translation = (0, 0, 0)
opening_bm = bmesh.new()
verts = ifcopenshell.util.shape.get_vertices(shape.geometry)
for vert in verts:
+6 -3
View File
@@ -156,13 +156,16 @@ class BrickschemaReferencesData:
for rel in getattr(tool.Ifc.get_entity(bpy.context.active_object), "HasAssociations", []):
if rel.is_a("IfcRelAssociatesLibrary"):
reference = rel.RelatingLibrary
identification = tool.Document.get_external_reference_id(reference)
if not identification or "#" not in identification:
if tool.Ifc.get_schema() == "IFC2X3" and "#" not in reference.ItemReference:
continue
if tool.Ifc.get_schema() != "IFC2X3" and "#" not in reference.Identification:
continue
results.append(
{
"id": reference.id(),
"identification": identification,
"identification": (
reference.ItemReference if tool.Ifc.get_schema() == "IFC2X3" else reference.Identification
),
"name": reference.Name or "Unnamed",
}
)
+22 -11
View File
@@ -127,25 +127,36 @@ class ObjectDocumentData:
identification = None
if is_information:
identification = tool.Document.get_document_information_id(relating_document)
if tool.Ifc.get_schema() == "IFC2X3":
identification = relating_document.DocumentId
else:
identification = relating_document.Identification
location = getattr(relating_document, "Location", None)
description = getattr(relating_document, "Description", "No description")
else:
description = relating_document.Description
referenced_document = tool.Document.get_reference_document(relating_document)
if tool.Ifc.get_schema() == "IFC2X3":
reference_to_document = relating_document.ReferenceToDocument
if not name and reference_to_document:
name = reference_to_document[0].Name
if not name and referenced_document:
name = referenced_document.Name
identification = relating_document.ItemReference
if not identification and reference_to_document:
identification = reference_to_document[0].DocumentId
location = relating_document.Location
else:
referenced_document = relating_document.ReferencedDocument
if not name and referenced_document:
name = referenced_document.Name
identification = tool.Document.get_external_reference_id(relating_document)
if not identification and referenced_document:
identification = tool.Document.get_document_information_id(referenced_document)
identification = relating_document.Identification
if not identification and referenced_document:
identification = referenced_document.Identification
location = relating_document.Location
# IFC2X3 IfcDocumentInformation has no Location to fall back to.
if location is None and referenced_document and tool.Ifc.get_schema() != "IFC2X3":
location = referenced_document.Location
location = relating_document.Location
if location is None and referenced_document:
location = referenced_document.Location
location = cls.convert_to_file_uri(location) if location else None
+3 -18
View File
@@ -82,15 +82,7 @@ import math
from collections.abc import Callable, Iterator
from dataclasses import dataclass
from enum import Enum
from typing import (
TYPE_CHECKING,
Any,
ClassVar,
Literal,
Optional,
Protocol,
runtime_checkable,
)
from typing import Any, ClassVar, Literal, Optional, Protocol, runtime_checkable
import blf
import bpy
@@ -113,9 +105,6 @@ from mathutils.kdtree import KDTree
import bonsai.tool as tool
from bonsai.bim.module.drawing.shaders import ExtrusionGuidesShader
if TYPE_CHECKING:
import bmesh
SNAP_POINT_SIZE = 10.0
SNAP_POINT_COLOR = (1.0, 0.5, 0.0, 1.0)
SNAP_MAX_RADIUS = 50.0
@@ -2046,9 +2035,7 @@ class TexturedQuadGizmoMixin(StaticTrisGizmoMixin):
def setup(self) -> None:
super().setup()
from bonsai.bim.module.drawing import (
gizmo_textures, # ty: ignore[unresolved-import]
)
from bonsai.bim.module.drawing import gizmo_textures
self._quad_batch = batch_for_shader(
gizmo_textures.get_shader(),
@@ -2057,9 +2044,7 @@ class TexturedQuadGizmoMixin(StaticTrisGizmoMixin):
)
def draw(self, context: bpy.types.Context) -> None:
from bonsai.bim.module.drawing import (
gizmo_textures, # ty: ignore[unresolved-import]
)
from bonsai.bim.module.drawing import gizmo_textures
texture = gizmo_textures.get_icon_texture(self.icon_name)
if texture is None:
@@ -189,20 +189,14 @@ def format_distance(
if hasattr(length_unit, "Prefix") and length_unit.Prefix:
unit_length = length_unit.Prefix + length_unit.Name
unit_length_mapping = {
"MILE": "MILES",
"FOOT": "FEET",
"INCH": "INCHES",
"KILOMETRE": "KILOMETERS",
"METRE": "METERS",
"DECIMETRE": "DECIMETERS",
"CENTIMETRE": "CENTIMETERS",
"MILLIMETRE": "MILLIMETERS",
"MICROMETRE": "MICROMETERS",
}
# Fall through for units without a dedicated formatter (e.g.
# HECTOMETRE) so they use the adaptive branch instead of a
# KeyError (#8255).
unit_length = unit_length_mapping.get(unit_length, unit_length)
unit_length = unit_length_mapping[unit_length]
# For now we only format area in IFC Units
if area_unit := ifcopenshell.util.unit.get_project_unit(tool.Ifc.get(), "AREAUNIT"):
area_unit_symbol = " " + ifcopenshell.util.unit.get_unit_symbol(area_unit)
@@ -602,7 +602,7 @@ class CreateDrawing(bpy.types.Operator):
context_type: Literal["body", "annotation"],
drawing_elements: set[ifcopenshell.entity_instance],
target_view: str,
link_matrix: Optional[Matrix] = None,
link_transform: Optional[np.ndarray] = None,
) -> None:
drawing_elements = drawing_elements.copy()
contexts_: list[list[int]] = getattr(contexts, context_type)
@@ -614,19 +614,22 @@ class CreateDrawing(bpy.types.Operator):
geom_settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS)
geom_settings.set("iterator-output", ifcopenshell.ifcopenshell_wrapper.NATIVE)
is_plan = ifc.by_id(context[0]).ContextType == "Plan" and "PLAN_VIEW" in target_view
z_offset = (0.002 if target_view == "PLAN_VIEW" else -0.002) if is_plan else 0.0
if link_matrix is not None:
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc)
t = link_matrix.to_translation()
offset = (t.x / unit_scale, t.y / unit_scale, t.z / unit_scale + z_offset)
geom_settings.set("model-offset", offset)
q = link_matrix.to_quaternion()
geom_settings.set("model-rotation", (q.x, q.y, q.z, q.w))
elif z_offset:
offset = np.zeros(3)
if ifc.by_id(context[0]).ContextType == "Plan" and "PLAN_VIEW" in target_view:
# A 2mm Z offset to combat Z-fighting in plan or RCPs
geom_settings.set("model-offset", (0.0, 0.0, z_offset))
offset[2] = 0.002 if target_view == "PLAN_VIEW" else -0.002
if link_transform is not None:
# Bake a moved link's transformation into the geometry. The
# mapping composes Trans(model-offset) @ Rot(model-rotation),
# matching the Trans(t) @ Rot(R) decomposition of the rigid
# link matrix, so the Z offset above simply adds on.
offset += link_transform[:3, 3]
quaternion = Matrix(link_transform.tolist()).to_quaternion()
geom_settings.set(
"model-rotation", (quaternion.x, quaternion.y, quaternion.z, quaternion.w)
)
if offset.any():
geom_settings.set("model-offset", tuple(float(o) for o in offset))
geom_settings.set("context-ids", context)
it = ifcopenshell.geom.iterator(
@@ -679,6 +682,10 @@ class CreateDrawing(bpy.types.Operator):
if "projection" in el.get("class", "").split():
continue
element = self.get_element_by_guid(el.get("{http://www.ifcopenshell.org/ns}guid"))
if element is None or element.file is not tool.Ifc.get():
# Linked model element - no Blender object to bisect, and its
# STEP id must not be resolved against the host session.
continue
if not (obj := tool.Ifc.get_object(element)):
continue
if not (material := ifcopenshell.util.element.get_material(element)):
@@ -934,16 +941,25 @@ class CreateDrawing(bpy.types.Operator):
bim_props = tool.Blender.get_bim_props()
prefs = tool.Blender.get_addon_preferences()
# Map ifc_path → (ifc_file, link_matrix); main file has no link_matrix (None)
files: dict[str, tuple[ifcopenshell.file, Optional[Matrix]]] = {bim_props.ifc_file: (tool.Ifc.get(), None)}
props = tool.Project.get_project_props()
# One entry per file *and* per link - the same file can be linked
# several times with different queries and transformations, so links
# cannot be collapsed into a dict keyed by filepath.
# Each entry is (path, file, link transformation or None, link query, link exclude).
file_entries: list[tuple[str, ifcopenshell.file, Optional[np.ndarray], str, str]] = [
(bim_props.ifc_file, tool.Ifc.get(), None, "", "")
]
for link in props.get_loaded_links_for_drawings():
try:
link_matrix = tool.Project.calculate_link_matrix(link)
except Exception:
link_matrix = None
files[link.filepath] = (self.get_linked_file(link), link_matrix)
file_entries.append(
(
link.filepath,
self.get_linked_file(link),
tool.Project.get_link_transformation_matrix(link),
link.query,
link.exclude,
)
)
target_view = ifcopenshell.util.element.get_psets(self.camera_element)["EPset_Drawing"]["TargetView"]
self.setup_serialiser(target_view)
@@ -957,7 +973,7 @@ class CreateDrawing(bpy.types.Operator):
raycast_objs = set()
elements_with_faces = set()
for ifc_path, (ifc, link_matrix) in files.items():
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()
@@ -965,6 +981,11 @@ 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():
@@ -980,9 +1001,11 @@ class CreateDrawing(bpy.types.Operator):
# A drawing prioritises a target view context first, followed by a model view context as a fallback.
# Specifically for PLAN_VIEW and REFLECTED_PLAN_VIEW, any Plan context is also prioritised.
contexts = self.get_linework_contexts(ifc, target_view)
self.serialize_contexts_elements(ifc, tree, contexts, "body", drawing_elements, target_view, link_matrix)
self.serialize_contexts_elements(
ifc, tree, contexts, "annotation", drawing_elements, target_view, link_matrix
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:
@@ -1038,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)
@@ -1433,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")
@@ -1470,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
@@ -1706,12 +1747,6 @@ class CreateDrawing(bpy.types.Operator):
key=lambda a: (
tool.Drawing.get_annotation_z_index(a),
1 if ifcopenshell.util.element.get_predefined_type(a) == "TEXT" else 0,
# Deterministic tiebreaker so equal-priority annotations keep a
# stable order across sessions. Without it the order comes from
# the set union above, which depends on entity hashes (and thus
# the file pointer), shuffling annotations between Blender
# restarts. See #6608.
a.id(),
),
)
@@ -2349,9 +2384,7 @@ class ActivateDrawingBase(tool.Ifc.Operator):
"Activates the selected drawing view.\n\n"
+ "ALT+CLICK to keep the viewport position.\n\n"
+ "SHIFT+CLICK to load a quick preview of the drawing view.\n\n"
+ "SHIFT+CTRL+CLICK to load the annotations of all selected drawings without switching views, "
+ "then select their cameras (the first selected drawing's camera becomes active).\n\n"
+ "SHIFT+CTRL+ALT+CLICK to do the same but also select the annotations, not just the cameras"
+ "SHIFT+CTRL+CLICK to load the annotations of all selected drawings without switching views"
)
drawing: bpy.props.IntProperty()
@@ -2373,25 +2406,16 @@ class ActivateDrawingBase(tool.Ifc.Operator):
default=False,
options={"SKIP_SAVE"},
)
include_annotations_in_selection: bpy.props.BoolProperty(
name="Include Annotations In Selection",
description="Also select the loaded annotation objects, not just the drawing cameras.",
default=False,
options={"SKIP_SAVE"},
)
if TYPE_CHECKING:
drawing: int
should_view_from_camera: bool
use_quick_preview: bool
load_selected_annotations: bool
include_annotations_in_selection: bool
def invoke(self, context, event) -> set["rna_enums.OperatorReturnItems"]:
if event.type == "LEFTMOUSE" and event.shift and event.ctrl:
self.load_selected_annotations = True
if event.alt:
self.include_annotations_in_selection = True
return self.execute(context)
if event.type == "LEFTMOUSE" and event.alt:
self.should_view_from_camera = False
@@ -2406,34 +2430,15 @@ class ActivateDrawingBase(tool.Ifc.Operator):
bpy.ops.bim.load_drawings()
if self.load_selected_annotations:
objs_to_select = []
active_camera = None
for d in props.drawings:
if not (d.is_drawing and d.is_selected):
continue
selected_drawing = tool.Ifc.get().by_id(d.ifc_definition_id)
# Importing the camera (if missing) ensures the drawing's
# collection exists so the annotations get collected into it.
if not (camera := tool.Ifc.get_object(selected_drawing)):
camera = tool.Drawing.import_drawing(selected_drawing)
group = tool.Drawing.get_drawing_group(selected_drawing)
tool.Drawing.import_annotations_in_group(group)
if active_camera is None:
active_camera = camera
objs_to_select.append(camera)
if self.include_annotations_in_selection:
for element in tool.Drawing.get_group_elements(group) or []:
if element.is_a("IfcAnnotation") and element.ObjectType != "DRAWING":
if annotation_obj := tool.Ifc.get_object(element):
objs_to_select.append(annotation_obj)
# Select the checked drawings' objects, with the first drawing's camera as active.
bpy.ops.object.select_all(action="DESELECT")
for obj in objs_to_select:
obj.select_set(True)
if active_camera is not None:
context.view_layer.objects.active = active_camera
if not tool.Ifc.get_object(selected_drawing):
tool.Drawing.import_drawing(selected_drawing)
tool.Drawing.import_annotations_in_group(tool.Drawing.get_drawing_group(selected_drawing))
return {"FINISHED"}
drawing = tool.Ifc.get().by_id(self.drawing)
@@ -2522,9 +2527,7 @@ class ActivateDrawing(bpy.types.Operator, ActivateDrawingBase):
"Activates the selected drawing view.\n\n"
+ "ALT+CLICK to keep the viewport position.\n\n"
+ "SHIFT+CLICK to load a quick preview of the drawing view.\n\n"
+ "SHIFT+CTRL+CLICK to load the annotations of all selected drawings without switching views, "
+ "then select their cameras (the first selected drawing's camera becomes active).\n\n"
+ "SHIFT+CTRL+ALT+CLICK to do the same but also select the annotations, not just the cameras"
+ "SHIFT+CTRL+CLICK to load the annotations of all selected drawings without switching views"
)
@@ -3586,7 +3589,7 @@ class EditSheet(bpy.types.Operator, tool.Ifc.Operator):
if sheet.is_a("IfcDocumentInformation"):
self.document_type = "SHEET"
self.name = sheet.Name
self.identification = tool.Document.get_document_information_id(sheet)
self.identification = sheet.DocumentId if tool.Ifc.get_schema() == "IFC2X3" else sheet.Identification
elif sheet.is_a("IfcDocumentReference") and tool.Drawing.get_reference_description(sheet) == "TITLEBLOCK":
self.document_type = "TITLEBLOCK"
else:
@@ -903,8 +903,12 @@ class SvgWriter:
continue
sheet = tool.Drawing.get_reference_document(sheet_reference)
if sheet:
reference_id = tool.Document.get_external_reference_id(sheet_reference) or "-"
sheet_id = tool.Document.get_document_information_id(sheet) or "-"
if tool.Ifc.get_schema() == "IFC2X3":
reference_id = sheet_reference.ItemReference or "-"
sheet_id = sheet.DocumentId or "-"
else:
reference_id = sheet_reference.Identification or "-"
sheet_id = sheet.Identification or "-"
return (reference_id, sheet_id)
break
return ("-", "-")
@@ -581,11 +581,7 @@ class UpdateRepresentation(bpy.types.Operator, tool.Ifc.Operator):
if has_openings and not self.apply_openings:
# Meshlike things with openings can only be updated without openings applied.
if self.from_ui:
self.report(
{"ERROR"},
f"Object '{obj.name}' has openings. "
"ALT+click the button to bake the openings into the new representation.",
)
self.report({"ERROR"}, f"Object '{obj.name}' has openings - representation cannot be updated.")
return
if not product.is_a("IfcGridAxis"):
+3 -1
View File
@@ -103,7 +103,9 @@ class LibraryReferencesData:
results.append(
{
"id": library.id(),
"identification": tool.Document.get_external_reference_id(library),
"identification": (
library.ItemReference if tool.Ifc.get_schema() == "IFC2X3" else library.Identification
),
"name": library.Name or "Unnamed",
}
)
@@ -418,13 +418,6 @@ class ImportQuickFavorites(bpy.types.Operator):
bl_description = "Import operators from Blender's Quick Favorites menu, including their configured properties"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
if bpy.app.version[:2] not in tool.Misc.QuickFavorites.OFFSET_USER_MENUS:
cls.poll_message_set(f"Blender version {bpy.app.version_string} is not supported.")
return False
return True
def execute(self, context) -> set["rna_enums.OperatorReturnItems"]:
props = tool.Misc.get_misc_props()
props.quick_favorites.clear()
@@ -27,7 +27,6 @@ import bonsai.tool as tool
from . import (
array,
covering,
decorator,
door,
external,
grid,
@@ -38,7 +38,6 @@ import numpy as np
from ifcopenshell.util.shape_builder import ShapeBuilder
from mathutils import Matrix, Vector
import bonsai.core.geometry
import bonsai.core.root
import bonsai.tool as tool
from bonsai.bim.module.drawing import gizmos as gizmo
@@ -138,7 +138,7 @@ def update_bbim_railing_pset(element: ifcopenshell.entity_instance, railing_data
def generate_wall_mounted_handrail_preview(
obj: bpy.types.Object,
props: "prop.BIMRailingProperties",
props: "BIMRailingProperties",
path_data: dict[str, Any],
si_conversion: float,
) -> None:
@@ -860,9 +860,7 @@ class GizmoRailingSchematic(bpy.types.GizmoGroup, gizmo.BaseSchematicGizmoGroup)
terminal_world = anchor + billboard_rot @ view_rotation @ terminal_local
self.terminal_gizmo.matrix_basis = gizmo.billboarded_at(terminal_world, billboard_rot, 0.18)
def update_editing_gizmos(
self, context: bpy.types.Context, mw: "Matrix", props: "prop.BIMRailingProperties"
) -> None:
def update_editing_gizmos(self, context: bpy.types.Context, mw: "Matrix", props: "BIMRailingProperties") -> None:
"""Hide the pen gizmo while polyline path-edit is active; reposition the cycle icon.
The base class shows the pen gizmo whenever ``is_editing`` is False,
@@ -18,7 +18,7 @@
import json
from pathlib import Path
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, cast
import bpy
import ifcopenshell
@@ -122,8 +122,8 @@ class ExecuteIfcPatch(bpy.types.Operator):
if props.should_load_from_memory and tool.Ifc.get():
args["file"] = tool.Ifc.get()
else:
args["input"] = props.ifc_patch_input
args["file"] = ifcopenshell.open(props.ifc_patch_input)
args["input"] = cast(str, props.ifc_patch_input)
args["file"] = cast(ifcopenshell.file, ifcopenshell.open(props.ifc_patch_input))
# Store this in case the patch recipe resets the Blender session, such as by loading a new project.
ifc_patch_output = props.ifc_patch_output or props.ifc_patch_input
@@ -18,8 +18,6 @@
import bpy
import bonsai.tool as tool
from . import decorator, gizmo, operator, prop, ui, workspace
classes = (
@@ -47,7 +45,6 @@ classes = (
operator.DisableEditingHeader,
operator.DisableEditingLink,
operator.EditHeader,
operator.EditLink,
operator.EditProjectLibrary,
operator.EnableCulling,
operator.EnableEditingHeader,
@@ -60,8 +57,6 @@ classes = (
operator.LinkIfc,
operator.LoadBlendMetadataAndIFC,
operator.LoadLink,
operator.AutosavePrompt,
operator.LoadAutosavedRecoveryPopup,
operator.LoadLinkedProject,
operator.LoadProject,
operator.LoadProjectElements,
@@ -71,6 +66,7 @@ classes = (
operator.QueryLinkedElement,
operator.RefreshClippingPlanes,
operator.RefreshLibrary,
operator.ReloadAllLinks,
operator.ReloadLink,
operator.RemoveProjectLibrary,
operator.RevertProject,
@@ -78,6 +74,7 @@ classes = (
operator.SaveLibraryFile,
operator.SelectLibraryFile,
operator.SelectLinkedModelElement,
operator.SelectLinkFilepath,
operator.SelectLinkHandle,
operator.ToggleFilterCategories,
operator.ToggleLinkSelectability,
@@ -113,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)
@@ -140,10 +170,11 @@ def register():
def unregister():
if not bpy.app.background:
bpy.utils.unregister_tool(workspace.ExploreTool)
tool.Autosave.cancel_timer()
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
)
+390 -265
View File
@@ -985,10 +985,8 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
),
default=False,
)
skip_autosave_recovery: bpy.props.BoolProperty(default=False, options={"HIDDEN", "SKIP_SAVE"})
use_detailed_tooltip: bpy.props.BoolProperty(default=False, options={"HIDDEN"})
filename_ext = ".ifc"
skip_recent: bpy.props.BoolProperty(default=False, options={"HIDDEN", "SKIP_SAVE"})
if TYPE_CHECKING:
filepath: str
@@ -997,7 +995,6 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
use_relative_path: bool
should_start_fresh_session: bool
import_without_ifc_data: bool
skip_autosave_recovery: bool
use_detailed_tooltip: bool
@classmethod
@@ -1044,33 +1041,7 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
return tooltip
def check_autosave_recovery(self, context: bpy.types.Context) -> bool:
if self.skip_autosave_recovery:
return False
autosaved_filepath = tool.Autosave.get_newer_autosaved_path(self.get_filepath_abs())
if not autosaved_filepath:
return False
# Fire-and-forget: don't propagate this popup's own RUNNING_MODAL
# return value up as if *this* operator were running modally too -
# we never call modal_handler_add() on ourselves, so the window
# manager would be left tracking a modal operator with no handler,
# corrupting its operator bookkeeping until it crashes later when
# the (real) popup modal handler is closed.
bpy.ops.bim.load_autosaved_recovery_popup(
"INVOKE_DEFAULT",
original_filepath=str(self.get_filepath_abs()),
autosaved_filepath=autosaved_filepath,
is_advanced=self.is_advanced,
use_relative_path=self.use_relative_path,
should_start_fresh_session=self.should_start_fresh_session,
import_without_ifc_data=self.import_without_ifc_data,
)
return True
def execute(self, context):
if self.check_autosave_recovery(context):
return {"FINISHED"}
if (
tool.Blender.get_addon_preferences().save_metadata_blend_file
and self.should_start_fresh_session
@@ -1165,8 +1136,7 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
props.should_save_metadata_for_this_file = metadata_doc is not None
tool.Blender.register_toolbar()
if not self.skip_recent:
tool.Project.add_recent_ifc_project(self.get_filepath_abs())
tool.Project.add_recent_ifc_project(self.get_filepath_abs())
if self.is_advanced:
pass
@@ -1179,13 +1149,10 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
except:
bonsai.last_error = traceback.format_exc()
raise
tool.Autosave.reset_timer()
return {"FINISHED"}
def invoke(self, context, event):
if self.filepath:
if self.check_autosave_recovery(context):
return {"FINISHED"}
return self.execute(context)
return ImportHelper.invoke(self, context, event)
@@ -1393,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\"'."
),
)
@@ -1410,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
@@ -1428,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()
@@ -1450,18 +1427,26 @@ class LinkIfc(bpy.types.Operator, ImportHelper, tool.Ifc.Operator):
new = props.links.add()
if tool.Ifc.get():
if not (document := existing_links.get(filepath)):
# Look up by resolved absolute path so a file already linked
# with a relative Location (or vice versa) reuses its document.
resolved_filepath = Path(tool.Ifc.resolve_uri(filepath)).as_posix()
if not (document := existing_links.get(resolved_filepath)):
document = ifcopenshell.api.document.add_information(tool.Ifc.get())
document.Name = Path(filepath).name
document.Scope = "LINKED_MODEL"
reference = ifcopenshell.api.document.add_reference(tool.Ifc.get(), information=document)
reference[1] = ",".join([str(o) for o in np.eye(4).flatten().tolist()])
reference.Location = filepath.replace("\\", "/")
# Persist the filter per reference (Description is IFC4+ only).
description = tool.Project.encode_link_filter(self.query, self.exclude, loaded=True)
if description and hasattr(reference, "Description"):
reference.Description = description
new.ifc_definition_id = reference.id()
new.name = filepath
new.filepath = filepath
new.query = self.query
bpy.ops.bim.load_link(link_index=-1, use_cache=self.use_cache, query=self.query)
new.exclude = self.exclude
bpy.ops.bim.load_link(link_index=-1, use_cache=self.use_cache, query=self.query, exclude=self.exclude)
class UnlinkIfc(bpy.types.Operator, tool.Ifc.Operator):
@@ -1520,21 +1505,28 @@ class LoadLink(bpy.types.Operator, tool.Ifc.Operator):
bl_options = {"REGISTER", "UNDO"}
bl_description = "Load the selected file"
# SKIP_SAVE: Blender reuses an operator's last-used property values on the
# next interactive invocation, which would leak one link's query/cache
# settings into another link's load.
link_index: bpy.props.IntProperty(name="Link Index")
use_cache: bpy.props.BoolProperty(name="Use Cache", default=True)
query: bpy.props.StringProperty()
use_cache: bpy.props.BoolProperty(name="Use Cache", default=True, options={"SKIP_SAVE"})
query: bpy.props.StringProperty(options={"SKIP_SAVE"})
exclude: bpy.props.StringProperty(options={"SKIP_SAVE"})
if TYPE_CHECKING:
link_index: int
use_cache: bool
query: str
exclude: str
def _execute(self, context):
self.link = tool.Project.get_project_props().links[self.link_index]
# Fall back to the Link's stored query so callers that omit it
# Fall back to the Link's stored filter so callers that omit it
# still replay the filter the link was created with.
if not self.query and self.link.query:
self.query = self.link.query
if not self.exclude and self.link.exclude:
self.exclude = self.link.exclude
filepath = Path(tool.Ifc.resolve_uri(self.link.filepath))
if not filepath.exists():
self.report({"ERROR"}, f"File does not exist: '{filepath}'")
@@ -1571,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():
@@ -1614,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}}")
@@ -1663,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
@@ -1682,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
@@ -1695,43 +1685,211 @@ except Exception as e:
self.link.georeferenced = "FULL_COMPATIBLE" if crs_name == data["model_crs"] else "NOT_COMPATIBLE"
class ReloadLink(bpy.types.Operator):
class ReloadLink(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.reload_link"
bl_label = "Reload Link"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Reload the selected file"
bl_description = "Reload the selected file, optionally changing its file path and load options"
# SKIP_SAVE: this operator distinguishes "provided" from "unset" properties
# via is_property_set, so last-used property retention between interactive
# invocations would leak one link's settings into another's reload.
link_index: bpy.props.IntProperty(name="Link Index")
filepath: bpy.props.StringProperty(
name="File Path",
description="Path to the linked IFC file",
options={"SKIP_SAVE"},
)
use_relative_path: bpy.props.BoolProperty(
name="Use Relative Path",
description="Whether to store linked model path relative to the currently opened IFC file.",
default=False,
options={"SKIP_SAVE"},
)
use_cache: bpy.props.BoolProperty(
name="Use Cache",
description="Reuse the cached geometry if it's still valid instead of reprocessing the IFC",
default=False,
options={"SKIP_SAVE"},
)
query: bpy.props.StringProperty(
name="Query",
name="Include",
description=(
"Custom selector query to use to load element from a linked model. E.g. 'IfcElement'.\n\n"
"Default query - IfcElement, but excluding IfcProxy, IfcSpatialStructureElement, IfcSpatialElement, IfcFeatureElement."
"Selector query for the elements to load from the linked model. E.g. 'IfcElement'.\n\n"
"Default when empty - IfcElement, but excluding IfcProxy, IfcSpatialStructureElement, IfcSpatialElement, IfcFeatureElement."
),
options={"SKIP_SAVE"},
)
exclude: bpy.props.StringProperty(
name="Exclude",
description=(
"Selector query whose matches are excluded from the loaded elements.\n\n"
"Applied on top of the query (or the default set), providing the set "
"difference a single query cannot express. E.g. 'IfcSlab, parent=\"X\"'."
),
options={"SKIP_SAVE"},
)
if TYPE_CHECKING:
link_index: int
filepath: str
use_relative_path: bool
use_cache: bool
query: str
exclude: str
def invoke(self, context, event):
link = tool.Project.get_project_props().links[self.link_index]
self.query = link.query
# Properties may arrive pre-set when the dialog is reopened
# by bim.select_link_filepath - don't clobber them.
if not self.properties.is_property_set("filepath"):
self.filepath = link.filepath
if not self.properties.is_property_set("use_relative_path"):
self.use_relative_path = not Path(link.filepath).is_absolute()
if not self.properties.is_property_set("query"):
self.query = link.query
if not self.properties.is_property_set("exclude"):
self.exclude = link.exclude
return context.window_manager.invoke_props_dialog(self)
def draw(self, context):
assert self.layout
pprops = tool.Project.get_project_props()
row = self.layout.row(align=True)
row.prop(self, "filepath")
op = row.operator("bim.select_link_filepath", text="", icon="FILEBROWSER")
op.link_index = self.link_index
# Carry the current dialog state through the file browser round-trip.
op.use_relative_path = self.use_relative_path
op.use_cache = self.use_cache
op.query = self.query
op.exclude = self.exclude
row = self.layout.row()
row.prop(self, "use_relative_path")
row = self.layout.row()
row.prop(self, "use_cache")
row = self.layout.row()
row.label(text="False Origin Mode:")
row = self.layout.row()
row.prop(pprops, "false_origin_mode", text="")
if pprops.false_origin_mode == "MANUAL":
row = self.layout.row()
row.prop(pprops, "false_origin")
row = self.layout.row()
row.prop(pprops, "project_north")
self.layout.prop(self, "query", placeholder="IfcElement")
self.layout.prop(self, "exclude", placeholder='IfcSlab, parent="..."')
def execute(self, context):
def _execute(self, context):
link = tool.Project.get_project_props().links[self.link_index]
# An unset query means the operator was called without the dialog
# (e.g. from a script) - preserve the link's stored query instead
# of overwriting it with the empty default.
# Unset properties mean the operator was called without the dialog
# (e.g. from a script) - preserve the link's stored values instead
# of overwriting them with the defaults.
if self.properties.is_property_set("query"):
link.query = self.query
if self.properties.is_property_set("exclude"):
link.exclude = self.exclude
filepath = self.filepath if self.properties.is_property_set("filepath") else link.filepath
if self.properties.is_property_set("use_relative_path"):
use_relative_path = self.use_relative_path
else:
use_relative_path = not Path(link.filepath).is_absolute()
abs_filepath = Path(tool.Ifc.resolve_uri(filepath))
if not abs_filepath.exists():
self.report({"ERROR"}, f"File does not exist: '{abs_filepath}'")
return {"CANCELLED"}
filepath = tool.Ifc.get_uri(abs_filepath, use_relative_path=use_relative_path)
if filepath != link.filepath:
link.name = filepath
link.filepath = filepath
if tool.Ifc.get() and link.ifc_definition_id:
reference = tool.Ifc.get().by_id(link.ifc_definition_id)
reference.Location = filepath.replace("\\", "/")
if document := tool.Document.get_reference_document(reference):
document.Name = Path(filepath).name
if tool.Ifc.get() and link.ifc_definition_id:
reference = tool.Ifc.get().by_id(link.ifc_definition_id)
if hasattr(reference, "Description"):
reference.Description = tool.Project.encode_link_filter(
link.query, link.exclude, loaded=True, display_name=link.display_name
)
bpy.ops.bim.unload_link(link_index=self.link_index)
return bpy.ops.bim.load_link(link_index=self.link_index, use_cache=False, query=link.query) or {"FINISHED"}
return bpy.ops.bim.load_link(
link_index=self.link_index, use_cache=self.use_cache, query=link.query, exclude=link.exclude
) or {"FINISHED"}
class ReloadAllLinks(bpy.types.Operator):
bl_idname = "bim.reload_all_links"
bl_label = "Reload All Links"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Reload all loaded linked models from disk, rebuilding their caches"
@classmethod
def poll(cls, context):
if not any(link.is_loaded for link in tool.Project.get_project_props().links):
cls.poll_message_set("No loaded links to reload.")
return False
return True
def execute(self, context):
props = tool.Project.get_project_props()
reloaded = 0
for i, link in enumerate(props.links):
if not link.is_loaded:
continue
# Called without filter properties, reload_link preserves each
# link's stored path, query and exclude.
bpy.ops.bim.reload_link(link_index=i)
reloaded += 1
self.report({"INFO"}, f"Reloaded {reloaded} linked model(s).")
return {"FINISHED"}
class SelectLinkFilepath(bpy.types.Operator):
bl_idname = "bim.select_link_filepath"
bl_label = "Select Link File Path"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
bl_description = "Select a new file path for the linked model and return to the reload dialog"
link_index: bpy.props.IntProperty(name="Link Index")
filepath: bpy.props.StringProperty(subtype="FILE_PATH", options={"SKIP_SAVE", "HIDDEN"})
filter_glob: bpy.props.StringProperty(default="*.ifc", options={"HIDDEN"})
# Reload dialog state carried through the file browser round-trip.
use_relative_path: bpy.props.BoolProperty(options={"HIDDEN"})
use_cache: bpy.props.BoolProperty(options={"HIDDEN"})
query: bpy.props.StringProperty(options={"HIDDEN"})
exclude: bpy.props.StringProperty(options={"HIDDEN"})
if TYPE_CHECKING:
link_index: int
filepath: str
filter_glob: str
use_relative_path: bool
use_cache: bool
query: str
exclude: str
def invoke(self, context, event):
link = tool.Project.get_project_props().links[self.link_index]
self.filepath = tool.Ifc.resolve_uri(link.filepath)
context.window_manager.fileselect_add(self)
return {"RUNNING_MODAL"}
def execute(self, context):
bpy.ops.bim.reload_link(
"INVOKE_DEFAULT",
link_index=self.link_index,
filepath=self.filepath,
use_relative_path=self.use_relative_path,
use_cache=self.use_cache,
query=self.query,
exclude=self.exclude,
)
return {"FINISHED"}
class ToggleLinkSelectability(bpy.types.Operator):
@@ -1749,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():
@@ -1786,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)
@@ -1828,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)
@@ -1840,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)
@@ -1980,7 +2099,6 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
json_compact: bpy.props.BoolProperty(name="Export Compact IFCJSON", default=False)
should_save_as: bpy.props.BoolProperty(name="Should Save As", default=False, options={"HIDDEN"})
use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=False)
skip_recent: bpy.props.BoolProperty(default=False, options={"HIDDEN", "SKIP_SAVE"})
if TYPE_CHECKING:
filter_glob: str
@@ -2041,23 +2159,13 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
return {"FINISHED"}
def _execute(self, context):
project_props = tool.Project.get_project_props()
project_props.use_relative_project_path = self.use_relative_path
# Fallback if filepath is not set
if not getattr(self, "filepath", None) or self.filepath.strip() in ("", ".ifc"):
props = tool.Blender.get_bim_props()
if props.ifc_file:
self.filepath = str(tool.Blender.ensure_blender_path_is_abs(Path(props.ifc_file)))
else:
self.report({"ERROR"}, "No filepath available for saving.")
return {"CANCELLED"}
committed, failed_commits = tool.Parametric.commit_pending_edits()
# Previews are session-transient — discard rather than commit. Sibling
# 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).
@@ -2115,8 +2223,7 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
settings.logger.info("Export finished in {:.2f} seconds".format(time.time() - start))
print("Export finished in {:.2f} seconds".format(time.time() - start))
# New project created in Bonsai should be in recent projects too.
if not self.skip_recent:
tool.Project.add_recent_ifc_project(Path(output_file))
tool.Project.add_recent_ifc_project(Path(output_file))
props = tool.Project.get_project_props()
if props.use_relative_project_path and bpy.data.is_saved:
output_file = os.path.relpath(output_file, bpy.path.abspath("//"))
@@ -2150,7 +2257,6 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
)
bonsai.bim.handler.refresh_ui_data()
tool.Autosave.reset_timer()
@classmethod
def description(cls, context, properties):
@@ -2159,123 +2265,6 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
return "Save the IFC file. Will save both .IFC/.BLEND files if synced together"
class LoadAutosavedRecoveryPopup(bpy.types.Operator):
bl_idname = "bim.load_autosaved_recovery_popup"
bl_label = "Recover Autosaved File"
bl_options = {"REGISTER", "UNDO"}
original_filepath: bpy.props.StringProperty(options={"SKIP_SAVE"})
autosaved_filepath: bpy.props.StringProperty(options={"SKIP_SAVE"})
is_advanced: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"})
use_relative_path: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"})
should_start_fresh_session: bpy.props.BoolProperty(default=True, options={"SKIP_SAVE"})
import_without_ifc_data: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"})
def draw(self, context):
layout = self.layout
layout.label(text="A newer autosaved copy was found:", icon="INFO")
layout.label(text=os.path.basename(self.autosaved_filepath))
layout.separator()
layout.label(text="Do you want to load the autosaved version instead?")
layout.label(text="(Cancel will load the original)")
def invoke(self, context, event):
# invoke_props_dialog is modal - unlike invoke_popup/popup_menu, it
# isn't dismissed by the mouse simply leaving its bounds. It always
# renders both a fixed "Cancel" button and this confirm_text one, so
# the question is framed as Yes/Cancel rather than adding separate
# Load buttons on top.
return context.window_manager.invoke_props_dialog(
self, width=420, title="Recover Autosaved File", confirm_text="Yes"
)
def _load_kwargs(self, filepath: str, skip_recent: bool) -> dict:
return dict(
filepath=filepath,
skip_autosave_recovery=True, # Prevent infinite loop
is_advanced=self.is_advanced,
use_relative_path=self.use_relative_path,
should_start_fresh_session=self.should_start_fresh_session,
import_without_ifc_data=self.import_without_ifc_data,
skip_recent=skip_recent,
)
@staticmethod
def _defer(callback) -> None:
def on_timer() -> None:
callback()
return None
# bim.load_project (with should_start_fresh_session, our default)
# calls wm.read_homefile(), which tears down the window
# manager/screens/regions. Calling that synchronously from this
# dialog's execute()/cancel() - themselves invoked from deep inside
# Blender's modal handling for this popup's button click - frees
# data that the still-on-stack caller dereferences once we return,
# segfaulting Blender. Deferring by one timer tick runs the reload
# after the popup's own modal handling has fully unwound. The
# callback only closes over plain values (not `self`), since the
# operator instance itself may no longer be valid by the time the
# timer fires.
bpy.app.timers.register(on_timer, first_interval=0.0)
def execute(self, context):
kwargs = self._load_kwargs(self.autosaved_filepath, skip_recent=True)
original_filepath = self.original_filepath
def load_and_repoint() -> None:
bpy.ops.bim.load_project(**kwargs)
# Re-point tracking at the original path so future saves write
# back to it, not "_autosaved.ifc".
tool.Ifc.set_path(original_filepath)
self._defer(load_and_repoint)
return {"FINISHED"}
def cancel(self, context):
# Also reached via Escape or a click outside the dialog, not just Cancel.
kwargs = self._load_kwargs(self.original_filepath, skip_recent=False)
self._defer(lambda: bpy.ops.bim.load_project(**kwargs))
class AutosavePrompt(bpy.types.Operator):
bl_idname = "bim.autosave_prompt"
bl_label = "Autosave Reminder"
bl_options = set()
def invoke(self, context, event):
return context.window_manager.invoke_props_dialog(
self, width=400, confirm_text="Save", title="Autosave Reminder"
)
def draw(self, context):
layout = self.layout
layout.label(text="The autosave timer has expired.", icon="INFO")
layout.label(text="Would you like to save your IFC project now?")
def execute(self, context):
# Get current IFC path
props = tool.Blender.get_bim_props()
current_ifc_path = props.ifc_file
if not current_ifc_path:
self.report({"WARNING"}, "No IFC file path set. Please save manually.")
tool.Autosave.reset_timer()
return {"CANCELLED"}
# Call save_project with explicit filepath using EXEC_DEFAULT
result = bpy.ops.bim.save_project(
"EXEC_DEFAULT", filepath=current_ifc_path, should_save_as=False, skip_recent=True
)
tool.Autosave.reset_timer()
return result
def cancel(self, context):
tool.Autosave.reset_timer()
return {"CANCELLED"}
class LoadLinkedProject(bpy.types.Operator, ImportHelper):
bl_idname = "bim.load_linked_project"
bl_label = "Load Project For Viewing Only"
@@ -2284,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.
@@ -2348,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)
@@ -2355,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,
@@ -2373,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
@@ -2416,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)
@@ -2435,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:
@@ -2456,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)
@@ -2545,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)
@@ -2563,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)
@@ -2575,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,
@@ -2648,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)
@@ -2773,6 +2877,27 @@ class AppendInspectedLinkedElement(AppendLibraryElement):
if element_type and tool.Ifc.get_object(element_type) is None:
self.import_type_from_ifc(element_type, context)
# If the link was moved, place the appended element where the link
# is displayed rather than at its original coordinates.
obj = tool.Ifc.get_object(element)
if isinstance(obj, bpy.types.Object):
# Prefer matching the link by the queried instance's root empty -
# the same file may be linked several times (different queries)
# and moved to different locations.
root = props.queried_obj_root
linked_filepath = Path(queried_obj["ifc_filepath"])
link_match = None
for link in props.links:
if root is not None and tool.Project.get_link_empty_handle(link) == root:
link_match = link
break
if link_match is None and Path(tool.Ifc.resolve_uri(link.filepath)) == linked_filepath:
link_match = link
if link_match:
delta = tool.Project.calculate_link_delta_matrix(link_match)
if not delta.is_identity:
obj.matrix_world = delta @ obj.matrix_world
return {"FINISHED"}
+17 -2
View File
@@ -261,8 +261,21 @@ class Link(PropertyGroup):
default=0,
)
query: StringProperty(
name="Query",
description="Selector query used to filter elements when loading the linked model",
name="Include",
description="Selector query for the elements to load from the linked model",
default="",
)
exclude: StringProperty(
name="Exclude",
description="Selector query whose matches are excluded when loading the linked model",
default="",
)
display_name: StringProperty(
name="Name",
description=(
"Optional display name to tell links apart (e.g. when the same file "
"is linked several times). Shows the file path when empty"
),
default="",
)
@@ -281,6 +294,8 @@ class Link(PropertyGroup):
empty_handle: Union[bpy.types.Object, None]
ifc_definition_id: int
query: str
exclude: str
display_name: str
class EditedObj(PropertyGroup):
+8 -7
View File
@@ -492,17 +492,13 @@ class BIM_PT_links(Panel):
row = self.layout.row(align=True)
row.operator("bim.link_ifc")
row.operator("bim.reload_all_links", text="", icon="FILE_REFRESH")
if self.props.links:
if self.props.active_link:
row = self.layout.row(align=True)
row.alignment = "RIGHT"
index = self.props.active_link_index
if self.props.active_link.is_loaded:
if self.props.active_link.is_editing:
row.operator("bim.edit_link", text="", icon="CHECKMARK")
row.operator("bim.disable_editing_link", text="", icon="CANCEL")
else:
row.operator("bim.enable_editing_link", text="", icon="GREASEPENCIL")
row.operator("bim.select_linked_model_element", icon="VIEWZOOM", text="")
row.operator("bim.select_link_handle", text="", icon="OBJECT_DATA").link_index = index
row.operator("bim.unload_link", text="", icon="UNLINKED").link_index = index
@@ -643,7 +639,12 @@ class BIM_UL_links(UIList):
if item.has_transformation:
row.label(text="", icon="OBJECT_ORIGIN")
row.label(text=item.filepath)
# Double-click to rename; shows the file path while unset.
row.prop(item, "display_name", text="", emboss=False, placeholder=item.filepath)
if item.is_editing:
row.operator("bim.disable_editing_link", text="", icon="UNLOCKED", emboss=False).link_index = index
else:
row.operator("bim.enable_editing_link", text="", icon="LOCKED", emboss=False).link_index = index
icon = "RESTRICT_SELECT_OFF" if item.is_selectable else "RESTRICT_SELECT_ON"
row.operator("bim.toggle_link_selectability", text="", icon=icon, emboss=False).link_index = index
icon = "CUBE" if item.is_wireframe else "MESH_CUBE"
@@ -655,7 +656,7 @@ class BIM_UL_links(UIList):
op.link_index = index
op.mode = "VISIBLE"
else:
row.label(text=item.filepath)
row.prop(item, "display_name", text="", emboss=False, placeholder=item.filepath)
class BIM_PT_purge(Panel):
+6 -19
View File
@@ -27,7 +27,6 @@ import ifcopenshell.api.material
import ifcopenshell.api.pset
import ifcopenshell.api.root
import ifcopenshell.util.element
import ifcopenshell.util.representation
import ifcopenshell.util.schema
import ifcopenshell.util.shape_builder
import ifcopenshell.util.type
@@ -130,25 +129,13 @@ class ReassignClass(bpy.types.Operator, tool.Ifc.Operator):
same_ifc_product = element.is_a(ifc_product)
if not same_ifc_product:
# A spatial element (e.g. IfcSite) anchors the containment
# hierarchy, so only allow reassigning it to another family when
# it actually carries geometry - i.e. it's a real modelled thing
# (a bench dropped onto IfcSite -> IfcFurniture) rather than an
# empty spatial container we'd be turning into a loose element.
# IfcSpatialStructureElement covers IFC2X3, which has no
# IfcSpatialElement supertype.
is_spatial = element.is_a("IfcSpatialElement") or element.is_a("IfcSpatialStructureElement")
if is_spatial:
has_geometry = (
next(ifcopenshell.util.representation.get_representations_iter(element), None) is not None
if not (element.is_a("IfcElement") and ifc_product == "IfcElementType") and not (
element.is_a("IfcElementType") and ifc_product == "IfcElement"
):
self.report(
{"ERROR"}, f"Not supported class reassignment for object '{obj.name}' -> {ifc_product}."
)
if not has_geometry:
self.report(
{"ERROR"},
f"Cannot reassign '{obj.name}' ({element.is_a()}) to {ifc_product}: "
"a spatial element can only be reassigned to another class when it has geometry.",
)
return {"CANCELLED"}
return {"CANCELLED"}
props = tool.Blender.get_object_bim_props(obj)
props.is_reassigning_class = False
@@ -71,11 +71,7 @@ class LoadByDirection(TypedDict):
ProcessedLoad = TypedDict(
"ProcessedLoad",
{
"linear loads": dict[str, LoadByDirection] | None,
"max linear load": float,
"discrete loads": list[list[DiscreteConfigItem]],
},
{"linear loads": LoadByDirection, "max linear load": float, "discrete loads": list[list[DiscreteConfigItem]]},
)
@@ -849,16 +845,13 @@ class ShaderInfo:
v = l1[1] + fac * (pos - l1[0])
return v
def interpolate(self, pos: float, loadinfo: list[LoadConfigItem], start: int, end: int) -> np.ndarray:
def interpolate(self, pos: float, loadinfo: list[LoadConfigItem], start: int, end: int, key: str) -> np.ndarray:
"""interpolate the result vectors between load poits"""
result = np.zeros(6)
for i in range(6):
# [position, force_component]
value1 = [loadinfo[start]["pos"], loadinfo[start]["load values"][i]]
# [position, force_component]
value2 = [loadinfo[end]["pos"], loadinfo[end]["load values"][i]]
# interpolated [position, force_component]
result[i] = self.interp1d(value1, value2, pos)
value1 = [loadinfo[start]["pos"], loadinfo[start][key][i]] # [position, force_component]
value2 = [loadinfo[end]["pos"], loadinfo[end][key][i]] # [position, force_component]
result[i] = self.interp1d(value1, value2, pos) # interpolated [position, force_component]
return result
def get_before_and_after(self, pos: float, load_config_list: list[list[LoadConfigItem]]) -> dict[str, list[float]]:
@@ -902,8 +895,8 @@ class ShaderInfo:
load_before += config[end]["load values"]
elif end - start == 1:
load_before += self.interpolate(pos, config, start, end)
load_after += self.interpolate(pos, config, start, end)
load_before += self.interpolate(pos, config, start, end, "load values")
load_after += self.interpolate(pos, config, start, end, "load values")
start += 1
end -= 1
return_value = {"before": load_before.tolist(), "after": load_after.tolist()}
-46
View File
@@ -577,43 +577,6 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
should_disable_undo_on_save: BoolProperty(
name="Disable Undo When Saving (Faster saves, no undo for you!)", default=False
)
def update_autosave_settings(self, context: bpy.types.Context) -> None:
if self.autosave_enabled:
tool.Autosave.reset_timer()
else:
tool.Autosave.cancel_timer()
autosave_enabled: BoolProperty(
name="Enable IFC Autosave Timer",
description="Periodically remind you to save or automatically create a backup copy of the IFC file",
default=False,
update=update_autosave_settings,
)
autosave_interval_minutes: bpy.props.IntProperty(
name="Autosave Interval (Minutes)",
description="Time between autosave reminders or backups. The timer resets whenever you open or save a project",
default=10,
min=1,
max=1440,
update=update_autosave_settings,
)
autosave_mode: bpy.props.EnumProperty(
name="Autosave Mode",
items=[
(
"PROMPT",
"Prompt to Save",
"Show a dialog offering to save the IFC project when the timer expires",
),
(
"BACKUP",
"Automatic Backup",
"Save a backup copy as filename_autosaved.ifc when the timer expires",
),
],
default="PROMPT",
)
should_stream: BoolProperty(name="Stream Data From IFC-SPF (Only for advanced users)", default=False)
should_always_cache: BoolProperty(
name="Always Cache Geometry",
@@ -726,9 +689,6 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
bsdd_load_test_dictionaries: bool
bsdd_baseurl: str
should_disable_undo_on_save: bool
autosave_enabled: bool
autosave_interval_minutes: int
autosave_mode: Literal["PROMPT", "BACKUP"]
should_stream: bool
should_always_cache: bool
occurrence_name_style: Literal["CLASS", "TYPE", "CUSTOM"]
@@ -877,12 +837,6 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
def draw_other_settings(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
layout.prop(self, "opening_focus_opacity")
layout.prop(self, "should_disable_undo_on_save")
layout.separator()
layout.label(text="Autosave:")
layout.prop(self, "autosave_enabled")
if self.autosave_enabled:
layout.prop(self, "autosave_interval_minutes")
layout.prop(self, "autosave_mode")
layout.prop(self, "should_stream")
layout.prop(self, "should_always_cache")
layout.label(text="bSDD:")
+51 -11
View File
@@ -302,15 +302,23 @@ def add_drawing(
context=drawing.get_body_context(),
ifc_representation_class=None,
)
drawings_parent_group = drawing.ensure_drawings_parent_group()
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":
@@ -342,8 +350,20 @@ def add_drawing(
)
drawing.setup_shading_styles_path(shading_styles_path)
drawings_parent_document = drawing.ensure_drawings_parent_document()
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)
@@ -372,9 +392,17 @@ 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 = drawing_tool.ensure_drawings_parent_group()
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])
@@ -394,7 +422,19 @@ def duplicate_drawing(
old_reference = drawing_tool.get_drawing_document(new_drawing)
ifc.run("document.unassign_document", products=[new_drawing], document=old_reference)
drawings_parent_document = drawing_tool.ensure_drawings_parent_document()
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)
+64
View File
@@ -0,0 +1,64 @@
# 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.
from __future__ import annotations
import math
from collections.abc import Iterable
from typing import TYPE_CHECKING
import bonsai.core.geometry
if TYPE_CHECKING:
import bpy
import bonsai.tool as tool
Z_ROTATION_ALIGNMENT_TOLERANCE = 1e-9
def _z_rotation_diff(target_z: float, source_z: float) -> float:
"""Signed Z-Euler difference wrapped to [-π, π]."""
return (target_z - source_z + math.pi) % (2 * math.pi) - math.pi
def copy_z_rotation_to_selected(
ifc: type[tool.Ifc],
geometry: type[tool.Geometry],
surveyor: type[tool.Surveyor],
*,
active: bpy.types.Object,
targets: Iterable[bpy.types.Object],
flip: bool = False,
) -> int:
"""Apply ``active``'s Z-Euler rotation to each target."""
source_z = surveyor.get_z_rotation(active)
if flip:
source_z += math.pi
rotated = 0
for obj in targets:
if abs(_z_rotation_diff(surveyor.get_z_rotation(obj), source_z)) < Z_ROTATION_ALIGNMENT_TOLERANCE:
continue
surveyor.set_z_rotation(obj, source_z)
rotated += 1
if ifc.get_entity(obj) is not None:
bonsai.core.geometry.edit_object_placement(ifc, geometry, surveyor, obj=obj)
return rotated
+4 -3
View File
@@ -254,6 +254,7 @@ class Cost:
def get_cost_schedule(cls, cost_schedule): pass
def get_cost_value_attributes(cls): pass
def get_cost_value_unit_component(cls): pass
def get_direct_cost_item_products(cls): pass
def get_highlighted_cost_item(cls): pass
def get_products(cls, related_object_type): pass
def get_schedule_cost_items(cls, cost_schedule): pass
@@ -354,8 +355,6 @@ class Drawing:
def enable_editing_schedules(cls): pass
def enable_editing_sheets(cls): pass
def enable_editing_text(cls, obj): pass
def ensure_drawings_parent_document(cls): pass
def ensure_drawings_parent_group(cls): pass
def ensure_unique_drawing_name(cls, name): pass
def ensure_unique_identification(cls, identification): pass
def export_font_size(cls, obj): pass
@@ -805,7 +804,7 @@ class Profile:
@interface
class Parametric:
def get_geom_generation(cls): pass
def get_geom_generation(cls) -> int: pass
def refresh_post_commit(cls, operator) -> None: pass
@@ -1171,6 +1170,8 @@ class Style:
@interface
class Surveyor:
def get_absolute_matrix(cls, obj): pass
def get_z_rotation(cls, obj): pass
def set_z_rotation(cls, obj, z): pass
@interface
-3
View File
@@ -80,6 +80,3 @@ from bonsai.tool.type import Type
from bonsai.tool.unit import Unit
from bonsai.tool.wall import Wall
from bonsai.tool.web import Web
# Have to move after import of tool.drawing
from bonsai.tool.autosave import Autosave # isort: skip
-194
View File
@@ -1,194 +0,0 @@
# 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.
from __future__ import annotations
import atexit
import logging
import os
from collections.abc import Callable
from pathlib import Path
from typing import Union
import bpy
import bonsai.tool as tool
from bonsai.bim import export_ifc
from bonsai.bim.module.model import preview_base
AUTOSAVING_SUFFIX = "_autosaving.ifc"
AUTOSAVED_SUFFIX = "_autosaved.ifc"
_timer_callback: Union[Callable[[], None], None] = None
# See cleanup_stale_autosave() for why this is a cached plain string rather
# than looked up live.
_active_ifc_path_cache: Union[str, None] = None
class Autosave:
@classmethod
def get_paths(cls, ifc_path: Union[str, Path]) -> tuple[Path, Path, Path]:
path = Path(ifc_path)
stem = path.stem if path.suffix.lower() == ".ifc" else path.name
parent = path.parent
main_path = path if path.suffix.lower() == ".ifc" else parent / f"{stem}.ifc"
autosaving_path = parent / f"{stem}{AUTOSAVING_SUFFIX}"
autosaved_path = parent / f"{stem}{AUTOSAVED_SUFFIX}"
return main_path, autosaving_path, autosaved_path
@classmethod
def get_active_ifc_path(cls) -> Union[Path, None]:
props = tool.Blender.get_bim_props()
if not props.ifc_file:
return None
path = tool.Blender.ensure_blender_path_is_abs(Path(props.ifc_file))
if path.suffix.lower() != ".ifc":
return None
return path
@classmethod
def _update_active_ifc_path_cache(cls) -> None:
global _active_ifc_path_cache
ifc_path = cls.get_active_ifc_path()
_active_ifc_path_cache = ifc_path.as_posix() if ifc_path is not None else None
@classmethod
def is_enabled(cls) -> bool:
return bool(tool.Blender.get_addon_preferences().autosave_enabled)
@classmethod
def get_interval_seconds(cls) -> float:
minutes = tool.Blender.get_addon_preferences().autosave_interval_minutes
return max(1.0, float(minutes) * 60.0)
@classmethod
def is_eligible(cls) -> bool:
return cls.is_enabled() and tool.Ifc.get() is not None and cls.get_active_ifc_path() is not None
@classmethod
def cancel_timer(cls) -> None:
global _timer_callback
if _timer_callback is not None and bpy.app.timers.is_registered(_timer_callback):
bpy.app.timers.unregister(_timer_callback)
_timer_callback = None
@classmethod
def reset_timer(cls) -> None:
cls.cancel_timer()
cls._update_active_ifc_path_cache()
if not cls.is_eligible():
return
def on_timer() -> Union[float, None]:
cls._on_timer_expired()
# Reschedule by returning the next interval rather than calling
# reset_timer(), which would unregister this timer from within
# its own callback. Blender frees the timer's internal registry
# entry on that manual unregister, then frees it again when the
# callback returns - a double free that corrupts the heap and
# crashes Blender shortly after (e.g. when the prompt dialog
# spawned below is next interacted with).
return cls.get_interval_seconds() if cls.is_eligible() else None
global _timer_callback
_timer_callback = on_timer
bpy.app.timers.register(on_timer, first_interval=cls.get_interval_seconds())
@classmethod
def _on_timer_expired(cls) -> None:
if not cls.is_eligible():
return
prefs = tool.Blender.get_addon_preferences()
bim_props = tool.Blender.get_bim_props()
if bim_props.is_dirty:
if prefs.autosave_mode == "PROMPT":
bpy.ops.bim.autosave_prompt("INVOKE_DEFAULT")
elif prefs.autosave_mode == "BACKUP":
try:
cls.perform_backup(bpy.context)
except Exception as error:
print(f"Bonsai: autosave backup failed: {error}")
@classmethod
def perform_backup(cls, context: bpy.types.Context) -> None:
ifc_path = cls.get_active_ifc_path()
if ifc_path is None:
return
_, autosaving_path, autosaved_path = cls.get_paths(ifc_path)
autosaving_path.parent.mkdir(parents=True, exist_ok=True)
tool.Parametric.commit_pending_edits()
preview_base.discard_pending_previews(context.scene)
logger = logging.getLogger("ExportIFC")
output_file = autosaving_path.as_posix().replace("\\", "/")
settings = export_ifc.IfcExportSettings.factory(context, output_file, logger)
export_ifc.IfcExporter(settings).export()
try:
os.replace(autosaving_path, autosaved_path)
except OSError:
if autosaving_path.is_file():
autosaving_path.unlink(missing_ok=True)
raise
@classmethod
def get_newer_autosaved_path(cls, ifc_path: Union[str, Path]) -> Union[str, None]:
path = Path(ifc_path)
if path.suffix.lower() != ".ifc" or not path.is_file():
return None
_, _, autosaved_path = cls.get_paths(path)
if not autosaved_path.is_file():
return None
if autosaved_path.stat().st_mtime > path.stat().st_mtime:
return autosaved_path.as_posix().replace("\\", "/")
return None
@classmethod
def cleanup_stale_autosave(cls) -> None:
"""Remove the active IFC's autosave file(s) on a graceful shutdown.
Registered via `atexit`, which only runs on a normal interpreter
shutdown - never on an actual crash. So a deliberate quit (whether
the user saved or chose "don't save") clears the recovery file and
won't prompt on next startup, while a genuine crash leaves it in
place for recovery, since no atexit callbacks fire then.
Deliberately reads only `_active_ifc_path_cache` - a plain string
kept up to date by `reset_timer()` - rather than touching `bpy` here.
By the time `atexit` fires, Blender's own C++ side is torn down far
enough that even reading `bpy.context.scene` aborts the process
(std::bad_optional_access) instead of raising a catchable exception.
"""
if _active_ifc_path_cache is None:
return
try:
_, autosaving_path, autosaved_path = cls.get_paths(_active_ifc_path_cache)
autosaving_path.unlink(missing_ok=True)
autosaved_path.unlink(missing_ok=True)
except Exception:
pass
atexit.register(Autosave.cleanup_stale_autosave)
+4 -3
View File
@@ -192,9 +192,10 @@ class Brick(bonsai.core.tool.Brick):
def get_brick(cls, element: ifcopenshell.entity_instance) -> Union[str, None]:
for rel in element.HasAssociations:
if rel.is_a("IfcRelAssociatesLibrary"):
identification = tool.Document.get_external_reference_id(rel.RelatingLibrary)
if identification and "#" in identification:
return identification
if tool.Ifc.get_schema() == "IFC2X3" and "#" in rel.RelatingLibrary.ItemReference:
return rel.RelatingLibrary.ItemReference
if tool.Ifc.get_schema() != "IFC2X3" and "#" in rel.RelatingLibrary.Identification:
return rel.RelatingLibrary.Identification
@classmethod
def get_brick_class(cls, element: ifcopenshell.entity_instance) -> Union[str, None]:
+1 -2
View File
@@ -261,8 +261,7 @@ class Document(bonsai.core.tool.Document):
def get_reference_document(cls, reference: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance | None:
# TODO: migrate to util.document and replace all instances
if reference.file.schema == "IFC2X3":
reference_to_document = reference.ReferenceToDocument
return reference_to_document[0] if reference_to_document else None
return (reference.ReferenceToDocument or (None))[0]
return reference.ReferencedDocument
@classmethod
+11 -31
View File
@@ -38,7 +38,6 @@ import ifcopenshell.api.context
import ifcopenshell.api.document
import ifcopenshell.api.drawing
import ifcopenshell.api.geometry
import ifcopenshell.api.group
import ifcopenshell.api.pset
import ifcopenshell.api.root
import ifcopenshell.geom
@@ -774,32 +773,6 @@ class Drawing(bonsai.core.tool.Drawing):
def get_drawing_target_view(cls, drawing: ifcopenshell.entity_instance) -> str:
return ifcopenshell.util.element.get_psets(drawing).get("EPset_Drawing", {}).get("TargetView", "MODEL_VIEW")
@classmethod
def ensure_drawings_parent_document(cls) -> ifcopenshell.entity_instance:
ifc_file = tool.Ifc.get()
for document in ifc_file.by_type("IfcDocumentInformation"):
if document.Name == "DRAWINGS" and document.Scope == "DRAWINGS":
return document
document = ifcopenshell.api.document.add_information(ifc_file)
if ifc_file.schema == "IFC2X3":
attributes = {"DocumentId": "DRAWINGS", "Name": "DRAWINGS", "Scope": "DRAWINGS"}
else:
attributes = {"Identification": "DRAWINGS", "Name": "DRAWINGS", "Scope": "DRAWINGS"}
ifcopenshell.api.document.edit_information(ifc_file, information=document, attributes=attributes)
return document
@classmethod
def ensure_drawings_parent_group(cls) -> ifcopenshell.entity_instance:
ifc_file = tool.Ifc.get()
for group in ifc_file.by_type("IfcGroup"):
if group.Name == "DRAWINGS" and group.ObjectType == "DRAWINGS":
return group
group = ifcopenshell.api.group.add_group(ifc_file)
ifcopenshell.api.group.edit_group(
ifc_file, group=group, attributes={"Name": "DRAWINGS", "ObjectType": "DRAWINGS"}
)
return group
@classmethod
def get_group_elements(cls, group: ifcopenshell.entity_instance) -> list[ifcopenshell.entity_instance]:
for rel in group.IsGroupedBy or []:
@@ -1167,7 +1140,10 @@ class Drawing(bonsai.core.tool.Drawing):
new = documents_collection.add()
new.ifc_definition_id = schedule.id()
new.name = schedule.Name or "Unnamed"
new.identification = tool.Document.get_document_information_id(schedule) or ""
if tool.Ifc.get_schema() == "IFC2X3":
new.identification = schedule.DocumentId
else:
new.identification = schedule.Identification
@classmethod
def get_sheet_identification(cls, sheet: ifcopenshell.entity_instance) -> str:
@@ -1208,7 +1184,10 @@ class Drawing(bonsai.core.tool.Drawing):
new.ifc_definition_id = reference.id()
new.is_sheet = False
new.identification = tool.Document.get_external_reference_id(reference) or ""
if tool.Ifc.get_schema() == "IFC2X3":
new.identification = reference.ItemReference or ""
else:
new.identification = reference.Identification or ""
new.name = os.path.basename(reference.Location)
new.reference_type = reference_description
@@ -2444,8 +2423,9 @@ class Drawing(bonsai.core.tool.Drawing):
def get_reference_document(
cls, reference: ifcopenshell.entity_instance
) -> Union[ifcopenshell.entity_instance, None]:
# TODO: migrate to document.get_reference_document.
return tool.Document.get_reference_document(reference)
if tool.Ifc.get_schema() == "IFC2X3":
return reference.ReferenceToDocument[0]
return reference.ReferencedDocument
@classmethod
def select_assigned_product(cls, context: bpy.types.Context) -> None:
+2 -1
View File
@@ -33,6 +33,7 @@ from typing import (
Optional,
TypeGuard,
Union,
cast,
get_args,
)
@@ -2186,7 +2187,7 @@ class Geometry(bonsai.core.tool.Geometry):
setattr(item, attribute.name, attribute.get_value())
if item.is_a("IfcSweptAreaSolid"):
item_profile = props.item_profile
item_profile = cast(str, props.item_profile)
profile = item.SweptArea
profile_name: Union[str, None] = profile.ProfileName
if item_profile == "-":
+1 -2
View File
@@ -29,7 +29,6 @@ from typing import TYPE_CHECKING, Any, Union
import bpy
import bonsai.core.tool
import bonsai.tool as tool
from bonsai.bim import import_ifc
from bonsai.bim.ifc import IfcStore
@@ -51,7 +50,7 @@ if TYPE_CHECKING:
from bonsai.bim.module.ifcgit.prop import IfcGitProperties
class IfcGit(bonsai.core.tool.IfcGit):
class IfcGit:
STEP_IDS = dict[str, set[int]]
@classmethod
+17 -3
View File
@@ -23,7 +23,7 @@ import os
import re
from math import atan, radians
from pathlib import Path
from typing import Any, Optional, Union, cast
from typing import Any, Callable, Optional, Union, cast
import bmesh
import bpy
@@ -197,6 +197,7 @@ class Loader(bonsai.core.tool.Loader):
cls, blender_material: bpy.types.Material, surface_style: ifcopenshell.entity_instance
) -> None:
surface_style = cls.surface_style_to_dict(surface_style)
surface_style: dict[str, Any]
cls.create_surface_style_shading(blender_material, surface_style)
@@ -1072,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"):
@@ -1120,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):
-4
View File
@@ -112,14 +112,10 @@ class Misc(bonsai.core.tool.Misc):
reading data and never writing, to avoid the possibility of corrupting user preferences.
"""
# Byte offset of UserDef.user_menus within the UserDef C struct, per (major, minor)
# Blender version. Shifts whenever UserDef's fields change, so must be re-derived
# per version (e.g. from that Blender build's SDNA).
OFFSET_USER_MENUS: dict[tuple[int, int], int] = {
(4, 5): 10032,
(5, 0): 10032,
(5, 1): 10032,
(5, 2): 10800,
}
@classmethod
-1
View File
@@ -59,7 +59,6 @@ from ifcopenshell.util.shape_builder import ShapeBuilder, np_to_3d
from mathutils import Matrix, Vector
import bonsai.core.geometry
import bonsai.core.model
import bonsai.core.tool
import bonsai.tool as tool
from bonsai.bim import import_ifc
+240 -35
View File
@@ -18,6 +18,7 @@
from __future__ import annotations
import hashlib
import json
import os
import shutil
@@ -89,10 +90,90 @@ class Project(bonsai.core.tool.Project):
else:
link.empty_handle = empty
@classmethod
def get_link_cache_paths(cls, filepath: Union[Path, str], query: str, exclude: str = "") -> tuple[Path, Path]:
"""Get the (blend, json) cache paths for a linked model's filter.
Cache files are per-filter so the same IFC file can be linked several
times with different include/exclude queries without the caches
overwriting each other. An empty filter keeps the legacy un-suffixed
names, and an include-only filter keeps the pre-exclude hash so
existing caches stay valid.
"""
filepath = Path(filepath)
if not query and not exclude:
suffix = ""
elif not exclude:
suffix = "." + hashlib.md5(query.encode("utf-8")).hexdigest()[:8]
else:
suffix = "." + hashlib.md5(f"{query}\0{exclude}".encode("utf-8")).hexdigest()[:8]
return (
filepath.with_suffix(f".ifc.cache{suffix}.blend"),
filepath.with_suffix(f".ifc.cache{suffix}.json"),
)
@classmethod
def encode_link_filter(
cls, query: str, exclude: str, loaded: bool = False, display_name: str = ""
) -> Union[str, None]:
"""Serialize a link's filter and state for IfcDocumentReference.Description.
A plain include query is stored as-is (backwards compatible); an
exclude, a loaded state or a display name promotes the value to a
small JSON blob. The loaded flag makes the link auto-load on the
next project open.
"""
if exclude or loaded or display_name:
return json.dumps({"include": query, "exclude": exclude, "loaded": loaded, "name": display_name})
return query or None
@classmethod
def decode_link_filter(cls, description: Union[str, None]) -> tuple[str, str, bool, str]:
"""Get (query, exclude, loaded, display_name) from a Description written by encode_link_filter."""
if not description:
return "", "", False, ""
if description.startswith("{"):
try:
data = json.loads(description)
if isinstance(data, dict):
return (
data.get("include", "") or "",
data.get("exclude", "") or "",
bool(data.get("loaded", False)),
data.get("name", "") or "",
)
except json.JSONDecodeError:
pass
return description, "", False, ""
@classmethod
def update_linked_models_state(cls) -> None:
"""Persist each link's loaded/visible state onto its document reference.
Called at IFC save time so links that were loaded and visible
auto-load the next time the project is opened.
"""
if not tool.Ifc.get():
return
for link in cls.get_project_props().links:
if not link.ifc_definition_id:
continue
try:
reference = tool.Ifc.get().by_id(link.ifc_definition_id)
except RuntimeError:
continue
if hasattr(reference, "Description"):
reference.Description = cls.encode_link_filter(
link.query,
link.exclude,
loaded=link.is_loaded and not link.is_hidden,
display_name=link.display_name,
)
@classmethod
def calculate_link_matrix(cls, link: Link) -> Matrix:
filepath = Path(tool.Ifc.resolve_uri(link.filepath))
with open(filepath.with_suffix(".ifc.cache.json"), "r") as f:
with open(cls.get_link_cache_paths(filepath, link.query, link.exclude)[1], "r") as f:
metadata = json.load(f)
rot = ifcopenshell.util.shape_builder.np_rotation_matrix(
@@ -117,6 +198,86 @@ class Project(bonsai.core.tool.Project):
local_matrix[:, 3][:3] = [float(o) for o in gprops.model_origin_si.split(",")]
return Matrix(np.linalg.inv(local_matrix) @ global_matrix)
@classmethod
def get_link_transformation_matrix(cls, link: Link) -> Union[npt.NDArray[np.float64], None]:
"""Get the link's saved 4x4 transformation in model coordinates, or None when identity."""
if tool.Ifc.get():
transformation = tool.Ifc.get().by_id(link.ifc_definition_id)[1] # Identification
else:
transformation = link.transformation
if not transformation:
return None
matrix = np.fromstring(transformation, sep=",", dtype=np.float64).reshape(4, 4)
if np.allclose(matrix, np.eye(4)):
return None
return matrix
@classmethod
def calculate_link_delta_matrix(cls, link: Link) -> Matrix:
"""Get the matrix mapping the link's unmoved world positions to its moved ones.
Returns identity when the link has no saved transformation.
"""
if tool.Ifc.get():
transformation = tool.Ifc.get().by_id(link.ifc_definition_id)[1] # Identification
else:
transformation = link.transformation
if not transformation:
return Matrix.Identity(4)
transformation = np.fromstring(transformation, sep=",", dtype=np.float64).reshape(4, 4)
if np.allclose(transformation, np.eye(4)):
return Matrix.Identity(4)
gprops = tool.Georeference.get_georeference_props()
rot = ifcopenshell.util.shape_builder.np_rotation_matrix(radians(-float(gprops.model_project_north)), 4, "Z")
local_matrix = rot @ np.eye(4)
local_matrix[:, 3][:3] = [float(o) for o in gprops.model_origin_si.split(",")]
# Link empty matrix is inv(local) @ transformation @ global (see
# calculate_link_matrix), so moved = inv(local) @ T @ local @ unmoved.
return Matrix(np.linalg.inv(local_matrix) @ transformation @ local_matrix)
@classmethod
def save_link_transformation(cls, link: Link) -> None:
"""Persist the link handle's current world matrix as the link's saved transformation."""
obj = cls.get_link_empty_handle(link)
assert obj
new_obj_matrix = np.array(obj.matrix_world)
filepath = Path(tool.Ifc.resolve_uri(link.filepath))
with open(cls.get_link_cache_paths(filepath, link.query, link.exclude)[1], "r") as f:
metadata = json.load(f)
rot = ifcopenshell.util.shape_builder.np_rotation_matrix(
radians(-float(metadata["model_project_north"])), 4, "Z"
)
global_matrix = rot @ np.eye(4)
global_matrix[:, 3][:3] = [float(o) for o in metadata["model_origin_si"].split(",")]
gprops = tool.Georeference.get_georeference_props()
rot = ifcopenshell.util.shape_builder.np_rotation_matrix(radians(-float(gprops.model_project_north)), 4, "Z")
local_matrix = rot @ np.eye(4)
local_matrix[:, 3][:3] = [float(o) for o in gprops.model_origin_si.split(",")]
# obj_matrix is typically calculated as:
# obj_matrix = np.linalg.inv(local_matrix) @ transformation @ global_matrix
identity_blender_matrix = np.linalg.inv(local_matrix) @ global_matrix
if np.allclose(new_obj_matrix, identity_blender_matrix, atol=1e-5):
link.has_transformation = False
transformation = ",".join(map(str, np.eye(4).reshape(-1)))
else:
transformed_global_matrix = local_matrix @ new_obj_matrix
transformation = transformed_global_matrix @ np.linalg.inv(global_matrix)
link.has_transformation = True
transformation = ",".join(map(str, transformation.reshape(-1)))
if tool.Ifc.get():
reference = tool.Ifc.get().by_id(link.ifc_definition_id)
reference[1] = transformation
else:
link.transformation = transformation
@classmethod
def append_all_types_from_template(cls, template: str) -> None:
# TODO refactor
@@ -309,11 +470,17 @@ class Project(bonsai.core.tool.Project):
@classmethod
def get_linked_models_documents(cls) -> dict[str, ifcopenshell.entity_instance]:
"""Get linked model documents keyed by resolved absolute filepath (posix form).
Locations are stored either relative or absolute depending on how the
link was created - resolving before keying ensures both forms of the
same file match one document.
"""
linked_docs = {}
for doc in tool.Ifc.get().by_type("IfcDocumentInformation"):
if doc.Scope == "LINKED_MODEL":
for reference in tool.Drawing.get_document_references(doc):
linked_docs[Path(reference.Location).as_posix()] = doc
linked_docs[Path(tool.Ifc.resolve_uri(reference.Location)).as_posix()] = doc
break
return linked_docs
@@ -321,27 +488,52 @@ class Project(bonsai.core.tool.Project):
def load_linked_models_from_ifc(cls) -> None:
links = tool.Project.get_project_props().links
links.clear()
references: list[ifcopenshell.entity_instance] = []
for doc in tool.Ifc.get().by_type("IfcDocumentInformation"):
if doc.Scope != "LINKED_MODEL":
continue
for reference in tool.Drawing.get_document_references(doc):
filepath = reference.Location
link = links.add()
link.name = filepath
link.filepath = filepath
link.ifc_definition_id = reference.id()
link.has_transformation = False
if reference[1]:
m = np.fromstring(reference[1], sep=",", dtype=np.float64).reshape(4, 4)
link.has_transformation = not np.allclose(m, np.eye(4))
# The selector query used at link time is persisted only in the
# sidecar cache JSON; restore it so Reload/Load replay the filter.
references.extend(tool.Drawing.get_document_references(doc))
location_counts: defaultdict[str, int] = defaultdict(int)
for reference in references:
location_counts[reference.Location] += 1
autoload_indices: list[int] = []
for reference in references:
filepath = reference.Location
link = links.add()
link.name = filepath
link.filepath = filepath
link.ifc_definition_id = reference.id()
link.has_transformation = False
if reference[1]:
m = np.fromstring(reference[1], sep=",", dtype=np.float64).reshape(4, 4)
link.has_transformation = not np.allclose(m, np.eye(4))
# The selector filter used at link time is persisted per
# reference in its Description (IFC4+); restore it so
# Reload/Load replay the filter.
query, exclude, loaded, display_name = cls.decode_link_filter(getattr(reference, "Description", None))
if not query and not exclude and location_counts[filepath] == 1:
# Fall back to the legacy sidecar cache JSON where older
# versions persisted the query. Only unambiguous: with
# several links to one file the shared JSON can't say
# which link it belonged to.
json_filepath = Path(tool.Ifc.resolve_uri(filepath)).with_suffix(".ifc.cache.json")
if json_filepath.exists():
try:
link.query = json.loads(json_filepath.read_text()).get("query", "")
query = json.loads(json_filepath.read_text()).get("query", "")
except (OSError, json.JSONDecodeError):
pass
link.query = query
link.exclude = exclude
link.display_name = display_name
if loaded:
autoload_indices.append(len(links) - 1)
# Links that were loaded and visible at save time load automatically.
for i in autoload_indices:
if not Path(tool.Ifc.resolve_uri(links[i].filepath)).exists():
print(f"WARNING: Not auto-loading missing linked model: {links[i].filepath}")
continue
bpy.ops.bim.load_link(link_index=i)
@classmethod
def get_project_library_elements(
@@ -858,9 +1050,16 @@ class Project(bonsai.core.tool.Project):
selected_vertices = [obj.matrix_world @ mesh.vertices[vi].co for vi in vert_map]
for polygon in guid_polygons:
selected_tris.append(tuple(vert_map[vi] for vi in polygon.vertices))
selected_edges.extend(tuple([vert_map[vi] for vi in e]) for e in polygon.edge_keys)
# Polygons are not necessarily triangles (e.g. layerset-sliced
# meshes contain ngons), so triangles come from the loop triangles.
mesh.calc_loop_triangles()
polygon_range = range(*slice_.indices(len(mesh.polygons)))
for tri in mesh.loop_triangles:
if tri.polygon_index in polygon_range:
selected_tris.append(tuple(vert_map[vi] for vi in tri.vertices))
obj["selected_vertices"] = selected_vertices
obj["selected_edges"] = selected_edges
obj["selected_tris"] = selected_tris
@@ -897,11 +1096,9 @@ class Project(bonsai.core.tool.Project):
from bonsai.bim.module.project.data import LinksData
from bonsai.bim.module.project.decorator import ProjectDecorator
# Not sure if there's a difference between `instance_matrix` coming from `ray_cast`
# and usual `matrix_world`, maybe we can just get it from object always.
if instance_matrix is None:
instance_matrix = obj.matrix_world
# `instance_matrix` is the world matrix of the hit collection instance
# from `ray_cast` (link empty matrix included). Without it, the root
# empty is resolved as the collection's only instance.
cls.deselect_queried_linked_element()
cls.set_queried_linked_element(obj, guid, instance_matrix)
cls.select_linked_element_geom(obj, guid)
@@ -958,7 +1155,7 @@ class Project(bonsai.core.tool.Project):
ProjectDecorator.install(context)
@classmethod
def set_queried_linked_element(cls, obj: bpy.types.Object, guid: str, instance_matrix: Matrix) -> None:
def set_queried_linked_element(cls, obj: bpy.types.Object, guid: str, instance_matrix: Matrix | None) -> None:
props = tool.Project.get_project_props()
props.queried_obj = obj
props.queried_obj_root = cls.find_obj_root(obj, instance_matrix)
@@ -977,17 +1174,22 @@ class Project(bonsai.core.tool.Project):
del obj[field]
@classmethod
def find_obj_root(cls, obj: bpy.types.Object, matrix: Matrix) -> bpy.types.Object | None:
def find_obj_root(cls, obj: bpy.types.Object, matrix: Matrix | None) -> bpy.types.Object | None:
collections = set(obj.users_collection)
for o in bpy.data.objects:
if (
o.type != "EMPTY"
or o.instance_type != "COLLECTION"
or o.instance_collection not in collections
or not np.allclose(matrix, o.matrix_world, atol=1e-4)
):
continue
return o
candidates = [
o
for o in bpy.data.objects
if o.type == "EMPTY" and o.instance_type == "COLLECTION" and o.instance_collection in collections
]
if matrix is not None:
# `matrix` is the instance's world matrix - the instancing
# empty's matrix combined with the object's own local matrix
# (non-identity for instanced occurrence objects).
for o in candidates:
if np.allclose(matrix, np.array(o.matrix_world) @ np.array(obj.matrix_world), atol=1e-4):
return o
if len(candidates) == 1:
return candidates[0]
class SelectedGeometry(NamedTuple):
selected_vertices: list[tuple[float, float, float]]
@@ -996,8 +1198,11 @@ class Project(bonsai.core.tool.Project):
@classmethod
def get_selected_geometry(cls, obj: bpy.types.Object) -> SelectedGeometry:
# ID properties are returned as IDPropertyArrays (the whole
# property when empty, the items otherwise), which the GPU module
# rejects as batch indices - convert to plain tuples.
return cls.SelectedGeometry(
obj["selected_vertices"],
obj["selected_edges"],
obj["selected_tris"],
[tuple(v) for v in obj["selected_vertices"]],
[tuple(e) for e in obj["selected_edges"]],
[tuple(t) for t in obj["selected_tris"]],
)
+1 -1
View File
@@ -232,7 +232,7 @@ class Raycast(bonsai.core.tool.Raycast):
return final_2d, v2
@classmethod
def intersect_mouse_2d_bounding_box(cls, mouse_pos: tuple[int, int], bbox: list[float]):
def intersect_mouse_2d_bounding_box(cls, mouse_pos: tuple[int, int], bbox: list[float, float, float, float]):
x, y = mouse_pos
xmin, xmax, ymin, ymax = bbox
+2 -2
View File
@@ -38,8 +38,8 @@ Hope your day's going well. :)
<script>
// Define the mapping of versions to URLs
const versionURLs = {
stable: 'https://docs.bonsaibim.org/',
unstable: 'https://docs-unstable.bonsaibim.org/',
stable: 'http://docs.bonsaibim.org/',
unstable: 'http://docs-unstable.bonsaibim.org/',
// Add more versions here as needed
};
@@ -44,6 +44,8 @@ When a new Blender version is released and supported:
* - File
- What to update
* - ``.github/workflows/ci-bonsai.yml``
- ``pyver`` matrix
* - ``.github/workflows/ci-bonsai-daily.yml``
- Blender download URL
@@ -59,10 +61,6 @@ When Blender ships with a new Python version:
- What to update
* - ``.github/workflows/ci-lint.yaml``
- ``MIN_BLENDER_PY_VERSION``
* - ``.github/workflows/ci-bonsai.yml``
- ``pyver`` matrix
* - ``.github/workflows/ci-bonsai-daily.yml``
- ``pyver`` matrix
* - ``.github/scripts/publish-bonsai-releases.py``
- ``CURRENT_PYTHON_VERSION``
* - ``src/bonsai/Makefile``
@@ -77,3 +77,18 @@ the image below. Three simple open source online viewers you can test with are
<https://3dviewer.net/>`__.
.. image:: images/ifc-pipeline.png
Placing occurrences of an element type
--------------------------------------
TODO
Changing the locations of elements
----------------------------------
TODO
Modeling a simple building
--------------------------
TODO
+6
View File
@@ -18,6 +18,12 @@ dependencies = [
"ifcopenshell",
]
[project.optional-dependencies]
dev = [
"pytest-blender",
"pytest-bdd",
]
[project.urls]
Homepage = "http://bonsaibim.org"
Documentation = "https://docs.bonsaibim.org"
-3
View File
@@ -1,3 +0,0 @@
pytest
pytest-blender
pytest-bdd
+1 -3
View File
@@ -1,4 +1,3 @@
#!/usr/bin/env python3
"""Setup Bonsai Development Environment.
Script links existing Bonsai installation to the provided IfcOpenShell repository.
@@ -79,8 +78,7 @@ BONSAI_PATH = find_bonsai_path()
# ---------------------------
# Never changed by user.
BLENDER_VERSION_INT = tuple(map(int, BLENDER_VERSION.split(".")))
PYTHON_VERSION = "3.13" if BLENDER_VERSION_INT >= (5, 1) else "3.11"
PYTHON_VERSION = "3.13" if BLENDER_VERSION == "5.1" else "3.11"
PACKAGE_PATH = BLENDER_PATH / rf"extensions/.local/lib/python{PYTHON_VERSION}/site-packages"
+2 -2
View File
@@ -12,7 +12,7 @@ Scenario: Ensure added booleans are marked as manual
And I click "OK"
And the object "IfcFurniture/Unnamed" exists
And I toggle edit mode
And the object "Item/IfcExtrudedAreaSolid/73" exists
And the object "Item/IfcExtrudedAreaSolid/77" exists
And I open the "Add Item" menu
When I click "Half Space Solid"
And the object "Item/IfcHalfSpaceSolid/90" exists
@@ -33,7 +33,7 @@ Scenario: Ensure removed booleans are unmarked as manual
And I click "OK"
And the object "IfcFurniture/Unnamed" exists
And I toggle edit mode
And the object "Item/IfcExtrudedAreaSolid/73" exists
And the object "Item/IfcExtrudedAreaSolid/77" exists
And I open the "Add Item" menu
And I click "Half Space Solid"
And I deselect all objects
+5 -5
View File
@@ -24,7 +24,7 @@ Scenario: Add element - an element with no geometry
When I click "OK"
And I select the object "IfcFurniture/Unnamed"
And I toggle edit mode
Then the object "Item/IfcExtrudedAreaSolid/73" exists
Then the object "Item/IfcExtrudedAreaSolid/77" exists
Scenario: Add element - an element with extrusion geometry
Given an empty IFC project
@@ -36,7 +36,7 @@ Scenario: Add element - an element with extrusion geometry
When I click "OK"
And I select the object "IfcFurniture/Unnamed"
And I toggle edit mode
Then the object "Item/IfcExtrudedAreaSolid/73" exists
Then the object "Item/IfcExtrudedAreaSolid/77" exists
Scenario: Add element - an element with custom tessellation geometry
Given an empty IFC project
@@ -48,7 +48,7 @@ Scenario: Add element - an element with custom tessellation geometry
When I click "OK"
And I select the object "IfcFurniture/Unnamed"
And I toggle edit mode
Then the object "Item/IfcPolygonalFaceSet/72" exists
Then the object "Item/IfcPolygonalFaceSet/76" exists
Scenario: Add element - an element with tessellation geometry from an object
Given an empty IFC project
@@ -62,8 +62,8 @@ Scenario: Add element - an element with tessellation geometry from an object
When I click "OK"
And I select the object "IfcFurniture/Unnamed"
And I toggle edit mode
Then the object "Item/IfcPolygonalFaceSet/72" exists
And the object "Item/IfcPolygonalFaceSet/72" dimensions are "2,2,2"
Then the object "Item/IfcPolygonalFaceSet/76" exists
And the object "Item/IfcPolygonalFaceSet/76" dimensions are "2,2,2"
Scenario: Reassign class
Given an empty IFC project
@@ -12,7 +12,7 @@ Scenario: Add element - a structural point connection
And I make the collection "IfcStructuralItem" visible
And I select the object "IfcStructuralPointConnection/Foo"
And I toggle edit mode
Then the object "Item/IfcVertexPoint/65" exists
Then the object "Item/IfcVertexPoint/69" exists
Scenario: Add element - a structural curve member
Given an empty IFC project
@@ -25,7 +25,7 @@ Scenario: Add element - a structural curve member
And I make the collection "IfcStructuralItem" visible
And I select the object "IfcStructuralCurveMember/Foo"
And I toggle edit mode
Then the object "Item/IfcEdge/68" exists
Then the object "Item/IfcEdge/72" exists
Scenario: Add element - a structural surface member
Given an empty IFC project
@@ -38,7 +38,7 @@ Scenario: Add element - a structural surface member
And I make the collection "IfcStructuralItem" visible
And I select the object "IfcStructuralSurfaceMember/Foo"
And I toggle edit mode
Then the object "Item/IfcFace/70" exists
Then the object "Item/IfcFace/74" exists
Scenario: Load structural analysis models
Given an empty IFC project
@@ -35,7 +35,6 @@ from unittest.mock import Mock, patch
import bpy
import ifcopenshell
import ifcopenshell.api.pset
import pytest
import bonsai.tool as tool
@@ -37,8 +37,6 @@ from unittest.mock import patch
import bpy
import ifcopenshell
import ifcopenshell.api.pset
import ifcopenshell.util.element
import pytest
import bonsai.tool as tool
@@ -146,9 +146,7 @@ def test_fit_flow_segments_with_single_segment_dispatches_obstruction():
mep.tool.Model, "get_flow_segment_profile", return_value=segment_profile
), patch.object(mep.MEPAddObstruction, "_execute", return_value=None) as obstruction, patch.object(
mep.MEPAddBend, "_execute", return_value=None
) as bend, patch.object(
mep.MEPAddTransition, "_execute", return_value=None
) as transition:
) as bend, patch.object(mep.MEPAddTransition, "_execute", return_value=None) as transition:
mep.FitFlowSegments._execute(op, context=context)
assert obstruction.call_count == 1
@@ -180,9 +178,7 @@ def test_fit_flow_segments_refuses_mixed_pipe_and_duct():
mep.tool.Model, "get_flow_segment_profile", return_value=profile
), patch.object(mep.MEPAddObstruction, "_execute", return_value=None) as obstruction, patch.object(
mep.MEPAddBend, "_execute", return_value=None
) as bend, patch.object(
mep.MEPAddTransition, "_execute", return_value=None
) as transition:
) as bend, patch.object(mep.MEPAddTransition, "_execute", return_value=None) as transition:
mep.FitFlowSegments._execute(op, context=context)
obstruction.assert_not_called()
@@ -173,9 +173,8 @@ def test_gizmo_group_class_wiring(gizmo_cls_name, bl_idname, is_element_predicat
predicate = getattr(tool.Parametric, is_element_predicate)
fake_element = Mock()
fake_element.is_a.return_value = True
with (
patch.object(tool.Parametric, is_element_predicate, side_effect=predicate) as p,
patch.object(tool.System, "has_parametric_body", return_value=True),
with patch.object(tool.Parametric, is_element_predicate, side_effect=predicate) as p, patch.object(
tool.System, "has_parametric_body", return_value=True
):
cls.is_element_type(fake_element)
assert p.called, f"{gizmo_cls_name}.is_element_type did not delegate to Parametric.{is_element_predicate}"
@@ -1,68 +0,0 @@
# 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.
import os
import time
from pathlib import Path
import pytest
from bonsai.tool.autosave import AUTOSAVED_SUFFIX, AUTOSAVING_SUFFIX, Autosave
pytestmark = pytest.mark.project
class TestAutosavePaths:
def test_get_paths_for_ifc_file(self):
main_path, autosaving_path, autosaved_path = Autosave.get_paths("/tmp/myfile.ifc")
assert main_path == Path("/tmp/myfile.ifc")
assert autosaving_path == Path(f"/tmp/myfile{AUTOSAVING_SUFFIX}")
assert autosaved_path == Path(f"/tmp/myfile{AUTOSAVED_SUFFIX}")
def test_get_newer_autosaved_path_when_missing(self, tmp_path):
ifc_path = tmp_path / "myfile.ifc"
ifc_path.write_text("ifc")
assert Autosave.get_newer_autosaved_path(ifc_path) is None
def test_get_newer_autosaved_path_when_older(self, tmp_path):
ifc_path = tmp_path / "myfile.ifc"
autosaved_path = tmp_path / f"myfile{AUTOSAVED_SUFFIX}"
ifc_path.write_text("ifc")
autosaved_path.write_text("autosaved")
past = time.time() - 10
os.utime(ifc_path, (past, past))
os.utime(autosaved_path, (time.time(), time.time()))
assert Autosave.get_newer_autosaved_path(ifc_path) == autosaved_path.as_posix()
def test_get_newer_autosaved_path_when_not_newer(self, tmp_path):
ifc_path = tmp_path / "myfile.ifc"
autosaved_path = tmp_path / f"myfile{AUTOSAVED_SUFFIX}"
ifc_path.write_text("ifc")
autosaved_path.write_text("autosaved")
now = time.time()
os.utime(ifc_path, (now, now))
past = now - 10
os.utime(autosaved_path, (past, past))
assert Autosave.get_newer_autosaved_path(ifc_path) is None
def test_get_newer_autosaved_path_ignores_non_ifc(self, tmp_path):
path = tmp_path / "myfile.ifczip"
path.write_text("zip")
assert Autosave.get_newer_autosaved_path(path) is None
+2 -2
View File
@@ -30,7 +30,7 @@ from collections.abc import Generator
from inspect import signature
from math import radians
from pathlib import Path
from typing import Any, Union, cast
from typing import Any, Union
import bpy
import ifcopenshell
@@ -140,7 +140,7 @@ class PanelSpy:
else:
props = kwargs.get("data")
name = kwargs.get("property")
props = cast(bpy.types.bpy_struct, props)
props: bpy.types.bpy_struct
text = kwargs.get("text", props.bl_rna.properties[name].name)
icon = kwargs.get("icon", None)
prop_type = props.bl_rna.properties[name].type
@@ -139,5 +139,6 @@ def test_every_cancel_ops_entry_has_a_real_preview_propertygroup() -> None:
orphaned = [attr for attr, _op in preview_base.PREVIEW_CANCEL_OPS if attr not in declared_attrs]
assert not orphaned, (
"PREVIEW_CANCEL_OPS contains entries whose PointerProperty child no longer "
f"exists on {UMBRELLA_CLASS}. Drop the stale tuple(s):\n " + "\n ".join(orphaned)
f"exists on {UMBRELLA_CLASS}. Drop the stale tuple(s):\n "
+ "\n ".join(orphaned)
)
+2 -12
View File
@@ -347,13 +347,11 @@ class TestAddDrawing:
context="context",
ifc_representation_class=None,
).should_be_called().will_return("element")
drawing.ensure_drawings_parent_group().should_be_called().will_return("drawings_parent_group")
ifc.run("group.add_group").should_be_called().will_return("group")
ifc.run(
"group.edit_group", group="group", attributes={"Name": "name", "ObjectType": "DRAWING"}
).should_be_called()
ifc.run("group.assign_group", group="group", products=["element"]).should_be_called()
ifc.run("group.assign_group", group="drawings_parent_group", products=["group"]).should_be_called()
collector.assign("obj").should_be_called()
ifc.run("pset.add_pset", product="element", name="EPset_Drawing").should_be_called().will_return("pset")
drawing.get_default_drawing_resource_path("Stylesheet").should_be_called().will_return("stylesheet.css")
@@ -383,11 +381,8 @@ class TestAddDrawing:
"CurrentShadingStyle": "Blender Default",
},
).should_be_called()
drawing.ensure_drawings_parent_document().should_be_called().will_return("drawings_parent_document")
drawing.get_default_drawing_path("name").should_be_called().will_return("uri")
ifc.run("document.add_information", parent="drawings_parent_document").should_be_called().will_return(
"information"
)
ifc.run("document.add_information").should_be_called().will_return("information")
ifc.run("document.add_reference", information="information").should_be_called().will_return("reference")
ifc.get_schema().should_be_called().will_return("IFC4")
ifc.run(
@@ -411,13 +406,11 @@ class TestDuplicateDrawing:
drawing.set_name("new_drawing", "unique_name").should_be_called()
drawing.get_drawing_group("new_drawing").should_be_called().will_return("group")
ifc.run("group.unassign_group", group="group", products=["new_drawing"]).should_be_called()
drawing.ensure_drawings_parent_group().should_be_called().will_return("drawings_parent_group")
ifc.run("group.add_group").should_be_called().will_return("new_group")
ifc.run(
"group.edit_group", group="new_group", attributes={"Name": "unique_name", "ObjectType": "DRAWING"}
).should_be_called()
ifc.run("group.assign_group", group="new_group", products=["new_drawing"]).should_be_called()
ifc.run("group.assign_group", group="drawings_parent_group", products=["new_group"]).should_be_called()
drawing.get_group_elements("group").should_be_called().will_return(["drawing", "annotation"])
ifc.get_object("annotation").should_be_called().will_return("annotation_obj")
geometry.duplicate_ifc_objects(["annotation_obj"]).should_be_called().will_return(
@@ -432,10 +425,7 @@ class TestDuplicateDrawing:
drawing.get_drawing_document("new_drawing").should_be_called().will_return("old_reference")
ifc.run("document.unassign_document", products=["new_drawing"], document="old_reference").should_be_called()
drawing.ensure_drawings_parent_document().should_be_called().will_return("drawings_parent_document")
ifc.run("document.add_information", parent="drawings_parent_document").should_be_called().will_return(
"information"
)
ifc.run("document.add_information").should_be_called().will_return("information")
ifc.run("document.add_reference", information="information").should_be_called().will_return("reference")
ifc.get_schema().should_be_called().will_return("IFC4")
drawing.get_default_drawing_path("unique_name").should_be_called().will_return("drawing_path")
-1
View File
@@ -24,7 +24,6 @@ import time
import bpy
import ifcopenshell
import ifcopenshell.util.element
import pytest
from bonsai import tool as tool
-1
View File
@@ -203,7 +203,6 @@ class TestGetDebugInfo(NewFile):
"bonsai_version",
"bonsai_commit_hash",
"bonsai_commit_date",
"bonsai_git_branch",
"last_actions",
"last_error",
}
-1
View File
@@ -23,7 +23,6 @@ import bpy
import ifcopenshell
import ifcopenshell.api.geometry
import ifcopenshell.api.material
import ifcopenshell.api.pset
import ifcopenshell.api.root
import ifcopenshell.api.style
import ifcopenshell.api.type
+69
View File
@@ -501,3 +501,72 @@ class TestGettingLinkedElementGeomSlice:
obj = cast(bpy.types.Object, obj)
slice_ = subject.Link.get_linked_element_geom_slice(obj, "aaa")
assert range(15)[slice_] == range(5)
class TestEncodeDecodeLinkFilter:
def test_plain_include_round_trip(self):
assert subject.encode_link_filter("IfcWall", "") == "IfcWall"
assert subject.decode_link_filter("IfcWall") == ("IfcWall", "", False, "")
def test_empty_filter_encodes_to_none(self):
assert subject.encode_link_filter("", "") is None
assert subject.decode_link_filter(None) == ("", "", False, "")
assert subject.decode_link_filter("") == ("", "", False, "")
def test_exclude_promotes_to_json(self):
encoded = subject.encode_link_filter('IfcElement, group="X"', 'IfcSlab, parent="Y"')
assert encoded.startswith("{")
assert subject.decode_link_filter(encoded) == ('IfcElement, group="X"', 'IfcSlab, parent="Y"', False, "")
def test_loaded_promotes_to_json(self):
encoded = subject.encode_link_filter("IfcWall", "", loaded=True)
assert encoded.startswith("{")
assert subject.decode_link_filter(encoded) == ("IfcWall", "", True, "")
def test_loaded_without_filter(self):
encoded = subject.encode_link_filter("", "", loaded=True)
assert subject.decode_link_filter(encoded) == ("", "", True, "")
def test_legacy_non_json_decodes_as_include(self):
legacy = 'IfcElement, location="House - Type B"'
assert subject.decode_link_filter(legacy) == (legacy, "", False, "")
def test_malformed_json_decodes_as_include(self):
assert subject.decode_link_filter("{not json") == ("{not json", "", False, "")
def test_display_name_promotes_to_json(self):
encoded = subject.encode_link_filter("IfcWall", "", display_name="North Wing")
assert encoded.startswith("{")
assert subject.decode_link_filter(encoded) == ("IfcWall", "", False, "North Wing")
class TestGetLinkCachePaths:
def test_empty_filter_keeps_legacy_names(self):
blend, json_ = subject.get_link_cache_paths("/x/File A.ifc", "")
assert blend.name == "File A.ifc.cache.blend"
assert json_.name == "File A.ifc.cache.json"
def test_include_only_hash_matches_pre_exclude_formula(self):
# Existing caches were keyed by md5(query)[:8]; they must stay valid.
import hashlib
blend, _ = subject.get_link_cache_paths("/x/File A.ifc", "IfcWall")
expected = hashlib.md5(b"IfcWall").hexdigest()[:8]
assert blend.name == f"File A.ifc.cache.{expected}.blend"
def test_blend_and_json_share_a_suffix(self):
blend, json_ = subject.get_link_cache_paths("/x/File A.ifc", "IfcWall", "IfcDoor")
assert blend.name.removesuffix("blend") == json_.name.removesuffix("json")
def test_same_include_different_exclude_do_not_collide(self):
# The reason the cache key hashes both strings: same-include links
# with different excludes must not serve each other's geometry.
a, _ = subject.get_link_cache_paths("/x/f.ifc", "IfcElement", "IfcSlab")
b, _ = subject.get_link_cache_paths("/x/f.ifc", "IfcElement", "IfcDoor")
c, _ = subject.get_link_cache_paths("/x/f.ifc", "IfcElement", "")
assert len({a.name, b.name, c.name}) == 3
def test_exclude_only_distinct_from_empty_filter(self):
a, _ = subject.get_link_cache_paths("/x/f.ifc", "", "IfcDoor")
b, _ = subject.get_link_cache_paths("/x/f.ifc", "", "")
assert a.name != b.name
+1 -22
View File
@@ -21,7 +21,6 @@ import http.server
import time
import urllib.parse
import uuid
import warnings
import webbrowser
from typing import TYPE_CHECKING, Any, Literal, Optional, TypedDict
@@ -522,17 +521,7 @@ class Client:
headers = {"User-Agent": "IfcOpenShell.bSDD.py/0.8.0"}
if is_auth_required:
headers["Authorization"] = "Bearer " + self.get_access_token()
response = requests.get(f"{self.baseurl}{endpoint}", timeout=10, headers=headers, params=params or None)
try:
response.raise_for_status()
except requests.exceptions.HTTPError as e:
try:
data = response.json()
message = data.get("message", data.get("error", str(e)))
except requests.exceptions.JSONDecodeError:
message = response.text or str(e)
raise requests.exceptions.HTTPError(f"{e}: {message}", response=response) from e
return response.json()
return requests.get(f"{self.baseurl}{endpoint}", timeout=10, headers=headers, params=params or None).json()
def _get_deprecated(self, endpoint, params=None, is_auth_required=False):
headers = {"User-Agent": "IfcOpenShell.bSDD.py/0.8.0"}
@@ -780,16 +769,6 @@ class Client:
Get Class details
this API replaces Classification
"""
# Not very well documented on bsdd side,
# the deprecation note only occurs when you run into rate limit.
# See https://github.com/buildingSMART/bSDD/issues/149
if include_class_properties:
warnings.warn(
"include_class_properties=True is deprecated and heavily rate-limited by the bSDD API. "
"Use get_class_properties() instead.",
DeprecationWarning,
stacklevel=2,
)
endpoint = f"Class/v{version}"
params = {
"Uri": class_uri,
+1 -2
View File
@@ -31,7 +31,6 @@ def test_get_nbs_classes():
def test_get_class():
uri_light_fixture = next(l for l in get_ifc_classes()["classes"] if "IfcLightFixture" == l["code"])["uri"]
# TODO: fix deprecation warning.
ifc4x3_light_fixture = client.get_class(uri_light_fixture)
assert "Maintenance Factor" and "Light Fixture Mounting Type" in [
l["name"] for l in ifc4x3_light_fixture["classProperties"]
@@ -40,7 +39,7 @@ def test_get_class():
def test_get_class_relations():
uri_light_fixture = next(l for l in get_ifc_classes()["classes"] if "IfcLightFixture" == l["code"])["uri"]
ifc4x3_light_fixture_relations = client.get_class_relations(uri_light_fixture, True)
ifc4x3_light_fixture_relations = client.get_class_properties(uri_light_fixture, True)
assert "Electrical unit for light-line system" and "Tubelight system" in [
r["className"] for r in ifc4x3_light_fixture_relations["classRelations"]
]
+1 -6
View File
@@ -28,7 +28,6 @@ class P62Ifc:
self.file = None
self.work_plan = None
self.project = {}
self.default_calendar_id = None
self.calendars = {}
self.wbs = {}
self.root_activites = []
@@ -90,7 +89,6 @@ class P62Ifc:
self.ns = {"pr": root.tag[1:].partition("}")[0]}
project = root.find("pr:Project", self.ns)
self.project["Name"] = project.findtext("pr:Name") or "Unnamed"
self.default_calendar_id = project.findtext("pr:ActivityDefaultCalendarObjectId", namespaces=self.ns)
self.parse_calendar_xml(root)
self.parse_calendar_xml(project)
self.parse_wbs_xml(project)
@@ -176,9 +174,6 @@ class P62Ifc:
self.wbs[wbs_id]["activities"].append(activity_id)
else:
self.root_activites.append(activity_id)
# CalendarObjectId is optional in the P6 schema: an activity without one
# inherits the project's ActivityDefaultCalendarObjectId.
calendar_id = activity.findtext("pr:CalendarObjectId", namespaces=self.ns)
self.activities[activity_id] = {
"Name": activity.find("pr:Name", self.ns).text,
"Identification": activity.find("pr:Id", self.ns).text,
@@ -186,7 +181,7 @@ class P62Ifc:
"FinishDate": datetime.datetime.fromisoformat(activity.find("pr:FinishDate", self.ns).text),
"PlannedDuration": activity.find("pr:PlannedDuration", self.ns).text,
"Status": activity.find("pr:Status", self.ns).text,
"CalendarObjectId": calendar_id or self.default_calendar_id,
"CalendarObjectId": activity.find("pr:CalendarObjectId", self.ns).text,
"ifc": None,
}
+4 -12
View File
@@ -57,8 +57,7 @@ class CsvHeader(TypedDict):
# Formula
Formula: NotRequired[str]
# QuantityClass: 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
@@ -82,11 +81,6 @@ MAIN_CSV_HEADER_COLUMNS.extend(
)
class CostRate(TypedDict):
Schedule: str | None
RateID: str | None
class CostItem(TypedDict):
children: list[CostItem]
ifc: NotRequired[ifcopenshell.entity_instance]
@@ -102,10 +96,8 @@ class CostItem(TypedDict):
Property: Union[str, None]
Query: Union[str, None]
CostRate: CostRate | None
Formula: Union[str, None]
# QuantityClass: Union[str, None]
#QuantityClass: Union[str, None]
class Csv2Ifc:
# Inputs.
@@ -245,7 +237,7 @@ class Csv2Ifc:
cost_values = float(cost_values) if cost_values else None
if self.has_rates:
cost_rate: CostRate = {
cost_rate = {
"Schedule": row[(self.headers["RateSchedule"])] if "RateSchedule" in self.headers else None,
"RateID": row[(self.headers["RateID"])] if "RateID" in self.headers else None,
}
@@ -428,7 +420,7 @@ class Csv2Ifc:
products=results,
formula=cost_item["Formula"],
ifc_class=ifc_quantity_class,
)
)
self.create_cost_items(cost_item["children"], cost_item["ifc"])
+2 -3
View File
@@ -26,8 +26,7 @@ import logging
import os
import time
from collections import Counter
from typing import Optional, Union
from typing_extensions import TypedDict
from typing import Optional, TypedDict, Union
import ifcopenshell
import ifcopenshell.util.cost
@@ -36,7 +35,7 @@ import ifcopenshell.util.element
import ifcopenshell.util.unit
class CostItem(TypedDict, extra_items=float):
class CostItem(TypedDict):
# Exported columns.
Index: int
Hierarchy: str
-1
View File
@@ -17,7 +17,6 @@ classifiers = [
]
dependencies = [
"ifcopenshell",
"typing_extensions",
]
[project.optional-dependencies]
+1 -4
View File
@@ -53,8 +53,6 @@ class ClashResult(TypedDict):
p1: list[float]
p2: list[float]
distance: float
# Added by `Clasher.smart_group_clashes`.
smart_group: NotRequired[int]
class ClashSet(TypedDict):
@@ -288,8 +286,7 @@ class Clasher:
positions = []
for clash in clashes.values():
# Midpoint of p1/p2 as an approximation of the clash location for clustering purposes.
positions.append([(a + b) / 2 for a, b in zip(clash["p1"], clash["p2"])])
positions.append(clash["position"])
data = np.array(positions)
+10 -21
View File
@@ -252,10 +252,6 @@ int main(int argc, char** argv) {
("stderr-progress", "output progress to stderr stream")
("yes,y", "answer 'yes' automatically to possible confirmation queries (e.g. overwriting an existing output file)")
("no-progress", "suppress possible progress bar type of prints that use carriage return")
("fail-on-error", "return a non-zero exit code when one or more errors were logged during "
"geometry conversion (e.g. an element failed to convert). By default IfcConvert exits "
"successfully as long as an output file could be written, even if some elements were "
"silently dropped. Enable this flag so scripts and CI can detect partial conversions.")
("log-format", po::value<std::string>(&log_format), "log format: plain or json")
("log-file", new po::typed_value<path_t, char_t>(&log_file), "redirect log output to file");
@@ -453,7 +449,6 @@ int main(int argc, char** argv) {
const bool mmap = vmap.count("mmap") != 0;
const bool no_progress = vmap.count("no-progress") != 0;
const bool fail_on_error = vmap.count("fail-on-error") != 0;
const bool quiet = vmap.count("quiet") != 0;
const bool stderr_progress = vmap.count("stderr-progress") != 0;
@@ -675,7 +670,7 @@ int main(int argc, char** argv) {
time_t start, end;
time(&start);
if (output_extension == XML) {
XmlSerializer s(ifc_file, IfcUtil::path::to_utf8(output_temp_filename), &logger);
XmlSerializer s(ifc_file, IfcUtil::path::to_utf8(output_temp_filename), logger);
logger.Status("Writing XML output...");
s.finalize();
} else {
@@ -834,14 +829,14 @@ int main(int argc, char** argv) {
if (output_extension == OBJ) {
// Do not use temp file for MTL as it's such a small file.
const path_t mtl_filename = change_extension(output_filename, MTL);
serializer = boost::make_shared<WaveFrontOBJSerializer>(IfcUtil::path::to_utf8(output_temp_filename), IfcUtil::path::to_utf8(mtl_filename), geometry_settings, serializer_settings, &logger);
serializer = boost::make_shared<WaveFrontOBJSerializer>(IfcUtil::path::to_utf8(output_temp_filename), IfcUtil::path::to_utf8(mtl_filename), geometry_settings, serializer_settings, logger);
#ifdef WITH_OPENCOLLADA
} else if (output_extension == DAE) {
serializer = boost::make_shared<ColladaSerializer>(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings, &logger);
serializer = boost::make_shared<ColladaSerializer>(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings, logger);
#endif
#ifdef WITH_GLTF
} else if (output_extension == GLB) {
serializer = boost::make_shared<GltfSerializer>(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings, &logger);
serializer = boost::make_shared<GltfSerializer>(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings, logger);
#endif
#ifdef WITH_USD
} else if (output_extension == USD || output_extension == USDA || output_extension == USDC) {
@@ -858,15 +853,15 @@ int main(int argc, char** argv) {
serializer = boost::make_shared<IgesSerializer>(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings, logger);
} else if (output_extension == SVG) {
geometry_settings.get<ifcopenshell::geometry::settings::IteratorOutput>().value = ifcopenshell::geometry::settings::NATIVE;
serializer = boost::make_shared<SvgSerializer>(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings, &logger);
serializer = boost::make_shared<SvgSerializer>(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings, logger);
#ifdef WITH_HDF5
} else if (output_extension == HDF) {
geometry_settings.get<ifcopenshell::geometry::settings::IteratorOutput>().value = ifcopenshell::geometry::settings::NATIVE;
serializer = boost::make_shared<HdfSerializer>(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings, false, &logger);
#endif
serializer = boost::make_shared<HdfSerializer>(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings, false, logger);
#endif
#endif
} else if (output_extension == TTL) {
serializer = boost::make_shared<TtlWktSerializer>(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings, &logger);
serializer = boost::make_shared<TtlWktSerializer>(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings, logger);
} else {
cerr_ << "[Error] Unknown output filename extension '" << output_extension << "'\n";
write_log(!quiet);
@@ -890,7 +885,6 @@ int main(int argc, char** argv) {
}
if (!serializer->ready()) {
logger.Error("SYS", 25, "Unable to open output file '" + IfcUtil::path::to_utf8(output_filename) + "' for writing; check that the directory exists and is writable");
IfcUtil::path::delete_file(IfcUtil::path::to_utf8(output_temp_filename));
write_log(!quiet);
return EXIT_FAILURE;
@@ -1011,7 +1005,7 @@ int main(int argc, char** argv) {
if (!vmap.count("cache-file")) {
cache_file = input_filename + CACHE + HDF;
}
cache.reset(new HdfSerializer(IfcUtil::path::to_utf8(cache_file), geometry_settings, serializer_settings, false, &logger));
cache.reset(new HdfSerializer(IfcUtil::path::to_utf8(cache_file), geometry_settings, serializer_settings, false, logger));
context_iterator->set_cache(cache.get());
}
#endif
@@ -1226,11 +1220,6 @@ int main(int argc, char** argv) {
successful = false;
}
if (fail_on_error && logger.MaxSeverity() >= Logger::LOG_ERROR) {
logger.Error("SYS", 26, "Errors encountered during processing, failing due to --fail-on-error.");
successful = false;
}
if (logger.Verbosity() == Logger::LOG_PERF) {
logger.PrintPerformanceStats();
}
@@ -1290,7 +1279,7 @@ bool init_input_file(const std::string& filename, IfcParse::IfcFile*& ifc_file,
#ifdef WITH_IFCXML
if (boost::ends_with(boost::to_lower_copy(filename), ".ifcxml")) {
ifc_file = IfcParse::parse_ifcxml(filename, &logger);
ifc_file = IfcParse::parse_ifcxml(filename, logger);
} else
#endif
{
+4 -10
View File
@@ -51,10 +51,8 @@ class IfcDiff:
:param old: IFC file object for the old model
:param new: IFC file object for the new model
:param relationships: List of relationships to check. None means that
attributes and geometry are compared, so changes such as a modified or
removed PredefinedType are reported. See RELATIONSHIP_TYPE for available
relationships.
:param relationships: List of relationships to check. None means that only
geometry is compared. See RELATIONSHIP_TYPE for available relationships.
:param is_shallow: True if you want only the first difference to be listed.
False if you want all differences to be checked. Choosing False means
that comparisons will take longer.
@@ -88,7 +86,7 @@ class IfcDiff:
self.new = new
self.change_register = {}
self.representation_ids = {}
self.relationships = relationships or ["attributes", "geometry"]
self.relationships = relationships or ["geometry"]
self.precision = 1e-4
self.is_shallow = is_shallow
self.filter_elements = filter_elements
@@ -437,11 +435,7 @@ if __name__ == "__main__":
"-r",
"--relationships",
type=str,
help=(
'A list of space-separated relationships, chosen from "attributes", "geometry", '
'"type", "property", "container", "aggregate", "classification". '
'Defaults to "attributes geometry" when omitted.'
),
help='A list of space-separated relationships, chosen from "type", "property", "container", "aggregate", "classification"',
default="",
)
args = parser.parse_args()
-17
View File
@@ -77,23 +77,6 @@ class TestIfcDiff:
assert ifc_diff.deleted_elements == set()
assert ifc_diff.change_register == {wall.GlobalId: {"attributes_changed": True}}
def test_changed_predefined_type_is_caught_by_default(self):
# Regression test for #8214: a plain diff (no relationships specified)
# must report a modified or removed PredefinedType. Previously the
# default only compared geometry, so attribute-only edits were missed.
ifc_file = setup_project()
wall = ifcopenshell.api.root.create_entity(ifc_file, ifc_class="IfcWall", name="Foo")
wall.PredefinedType = "SOLIDWALL"
new_file = ifc_file.from_string(ifc_file.to_string())
new_file.by_id(wall.id()).PredefinedType = "NOTDEFINED"
ifc_diff = ifcdiff.IfcDiff(ifc_file, new_file)
ifc_diff.diff()
assert ifc_diff.added_elements == set()
assert ifc_diff.deleted_elements == set()
assert ifc_diff.change_register == {wall.GlobalId: {"attributes_changed": True}}
def test_changed_geometry(self):
ifc_file = setup_project()
wall = ifcopenshell.api.root.create_entity(ifc_file, ifc_class="IfcWall", name="Foo")
+1 -1
View File
@@ -258,7 +258,7 @@ ifcedit quantify run model.ifc IFC4QtoBaseQuantities -o model_qto.ifc
Options:
- `--selector <query>` -- ifcopenshell selector to restrict elements (default: all `IfcElement` and `IfcSpace`)
- `--selector <query>` -- ifcopenshell selector to restrict elements (default: all `IfcElement`)
- `-o, --output <path>` -- write to a different file instead of overwriting the input
Note: `quantify run` writes geometry-based measurements and requires the
+1 -3
View File
@@ -244,9 +244,7 @@ def main():
qrun_parser = quantify_sub.add_parser("run", help="Run QTO on an IFC file")
qrun_parser.add_argument("ifc_file", help="Path to the IFC file")
qrun_parser.add_argument("rule_name", help="QTO rule name (e.g. IFC4QtoBaseQuantities)")
qrun_parser.add_argument(
"--selector", help="ifcopenshell selector to restrict elements (default: all IfcElement and IfcSpace)"
)
qrun_parser.add_argument("--selector", help="ifcopenshell selector to restrict elements (default: all IfcElement)")
qrun_parser.add_argument("-o", "--output", help="Output file path (default: overwrite input)")
args, extra = parser.parse_known_args()
+1 -3
View File
@@ -60,11 +60,9 @@ def coerce_value(
# Union / Optional
if origin is typing.Union:
non_none_types = [a for a in args if a is not type(None)]
if isinstance(value_str, str) and value_str.lower() == "none":
if value_str.lower() == "none":
if type(None) in args:
return None
if value_str is None and type(None) in args:
return None
# Try each non-None type in order
for t in non_none_types:
try:
+1 -1
View File
@@ -30,7 +30,7 @@ def run_quantify(model: ifcopenshell.file, rule: str, selector: str | None = Non
if selector:
elements = set(ifcopenshell.util.selector.filter_elements(model, selector))
else:
elements = set(model.by_type("IfcElement")) | set(model.by_type("IfcSpace"))
elements = set(model.by_type("IfcElement"))
results = quantify(model, elements, rule_sets[rule])
edit_qtos(model, results)
-10
View File
@@ -57,16 +57,6 @@ class TestOptionalCoercion:
def test_optional_int(self):
assert coerce_value("42", Optional[int]) == 42
def test_optional_entity_native_int(self, model):
# MCP callers pass JSON-decoded native types (int), not CLI strings.
wall = model.by_type("IfcWall")[0]
result = coerce_value(wall.id(), Optional[ifcopenshell.entity_instance], model)
assert result == wall
def test_optional_entity_native_none(self, model):
# JSON null decodes to Python None, not the string "none".
assert coerce_value(None, Optional[ifcopenshell.entity_instance], model) is None
class TestUnionCoercion:
def test_union_str_int(self):
-17
View File
@@ -85,20 +85,3 @@ class TestRunQuantify:
def test_empty_selector_runs_on_all(self, quantify_model):
result = run_quantify(quantify_model, "IFC4QtoBaseQuantities", selector=None)
assert result["ok"] is True
def test_default_selector_includes_spaces(self, quantify_model, monkeypatch):
"""IfcSpace is not a subtype of IfcElement, so the default scope must add it explicitly."""
import ifc5d.qto
seen_elements = {}
def fake_quantify(ifc_file, elements, rules):
seen_elements["elements"] = elements
return {}
monkeypatch.setattr(ifc5d.qto, "quantify", fake_quantify)
space = ifcopenshell.api.root.create_entity(quantify_model, ifc_class="IfcSpace", name="TestSpace")
run_quantify(quantify_model, "IFC4QtoBaseQuantities")
assert space in seen_elements["elements"]
-6
View File
@@ -28,7 +28,6 @@ import ifcopenshell.util.fm
import ifcopenshell.util.placement
import ifcopenshell.util.shape
import ifcopenshell.util.system
import ifcopenshell.util.unit
from ifcopenshell.util.shape_builder import np_matrix_to_euler
# The original BIMServer plugin has a function called ifcToCOBie:
@@ -921,11 +920,6 @@ def get_coordinate_data_(element: ifcopenshell.entity_instance) -> Generator[dic
verts = ifcopenshell.util.shape.get_shape_vertices(shape, shape.geometry)
categories = ("box-lowerleft", "box-upperright")
bbox = ifcopenshell.util.shape.get_bbox(verts)
# Geometry vertices are in SI metres, but Floor rows use the raw placement in
# project length units. Convert space points to project units so the whole
# Coordinate sheet is consistent with the Facility LinearUnits.
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(element.file)
bbox = [point / unit_scale for point in bbox]
base_data = base_data | {
"Category": "point",
"SheetName": "Space",
+2 -2
View File
@@ -361,8 +361,8 @@ namespace ifcopenshell {
struct CircleSegments : public SettingBase<CircleSegments, int> {
static constexpr const char* const name = "circle-segments";
static constexpr const char* const description = "Number of segments to approximate full circles in the CGAL kernel. When 0 (the default) the segment count is derived from mesher-linear-deflection instead, so curves stay within the deflection tolerance regardless of radius.";
static constexpr int defaultvalue = 0;
static constexpr const char* const description = "Number of segments to approximate full circles in CGAL kernel.";
static constexpr int defaultvalue = 16;
};
struct CgalSmoothAngleDegrees : public SettingBase<CgalSmoothAngleDegrees, double> {
+1 -35
View File
@@ -391,11 +391,6 @@ namespace {
}
};
// Representative radius used to size the polygonal approximation of a conic.
// For an ellipse the larger semi-axis is the conservative choice.
inline double conic_radius(const taxonomy::circle::ptr& c) { return c->radius; }
inline double conic_radius(const taxonomy::ellipse::ptr& e) { return e->radius > e->radius2 ? e->radius : e->radius2; }
struct cgal_curve_creation_visitor {
Settings& settings_;
parameter_range param;
@@ -430,36 +425,7 @@ namespace {
if (b <= a) {
b += 2 * M_PI;
}
const double span = std::fabs(a - b);
// CircleSegments controls how conics (circles, ellipses, arcs) are approximated
// in the CGAL kernel. Two modes, one or the other:
// - CircleSegments == 0 (the default): the segment count is derived from
// MesherLinearDeflection, so the chord deviation stays within the mesher's
// linear deflection regardless of radius. This matches the deflection based
// meshing the OpenCascade kernel already does and fixes issue #8051, where
// large radius arcs (curved curtain wall mullions) collapsed to straight chords
// because a fixed segment count is radius agnostic.
// - CircleSegments > 0: it is used directly as the number of segments for a full
// circle, giving deterministic, radius independent output.
int num_segments;
const int circle_segments = settings_.get<settings::CircleSegments>().get();
if (circle_segments > 0) {
num_segments = (int)std::ceil(span / (2 * M_PI) * circle_segments);
} else {
const double radius = conic_radius(t);
const double deflection = settings_.get<settings::MesherLinearDeflection>().get();
if (deflection > 0. && radius > deflection) {
const double max_segment_angle = 2.0 * std::acos(1.0 - deflection / radius);
num_segments = (int)std::ceil(span / max_segment_angle);
} else {
// Radius within the deflection tolerance (or no deflection set): a chord per
// quarter turn already keeps the deviation within tolerance.
num_segments = (int)std::ceil(span / (M_PI / 2.));
}
}
if (num_segments < 1) {
num_segments = 1;
}
int num_segments = (int)std::ceil(std::fabs(a - b) / (2 * M_PI) * settings_.get<settings::CircleSegments>().get());
double du = (b - a) / num_segments;
taxonomy::point3 P;
// @nb for loop is not inclusive of the both end points
-22
View File
@@ -31,7 +31,6 @@
#include <ShapeFix_Shape.hxx>
#include <ShapeFix_ShapeTolerance.hxx>
#include <BRep_Tool.hxx>
#include <BRepExtrema_DistShapeShape.hxx>
#include <Standard_Macro.hxx>
#include <TopoDS_Shape.hxx>
@@ -357,27 +356,6 @@ bool OpenCascadeKernel::convert(const taxonomy::face::ptr face, TopoDS_Shape& re
return false;
}
// #527: A face whose inner boundary intersects the outer boundary (or
// another inner boundary) is invalid per the schema. Open Cascade heals or
// drops such a face silently, so the intended hole is lost with no
// diagnostic. The distance between two non-intersecting loops is strictly
// positive; a distance at (or below) the modelling precision means the
// boundaries touch or cross. Emit a clear warning so the invalid input is
// not silently lost. wires() is ordered outer-first, inner-bounds after.
if (fd.wires().size() > 1) {
const auto& fwires = fd.wires();
bool reported = false;
for (size_t i = 1; i < fwires.size() && !reported; ++i) {
for (size_t j = 0; j < i && !reported; ++j) {
BRepExtrema_DistShapeShape dss(fwires[i], fwires[j]);
if (dss.IsDone() && dss.Value() < precision_) {
logger().Warning("GEO", 402, "Face inner boundary intersects another face boundary", face->instance);
reported = true;
}
}
}
}
if (fd.surface().IsNull()) {
// Use the first wire to find a plane manually for polygonal wires
const TopoDS_Wire& wire = fd.wires().front();

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