Compare commits

..

5 Commits

Author SHA1 Message Date
Ryan Schultz 60063ac1c7 Add tests for IfcGridAxis fixes
- Add test_create_axis_curve.py with three unit tests: basic
  polyline creation, safe removal of an unshared existing curve,
  and preservation of a shared curve when only one referencing
  axis is updated (regression for the shallow-copy duplication bug)
- Add feature scenario "Export IFC - with duplicate-of-duplicate
  grid axis locations preserved" to project.feature, reproducing
  the case where duplicates of duplicates lost their positions on
  save/reload
2026-07-10 19:22:03 -05:00
Ryan Schultz f2d3e226b4 Fix IfcGridAxis unlock not working in IFC4X3
In IFC4X3, IfcGrid is a subtype of IfcPositioningElement, but
IfcGridAxis is not. The unlock handler only fetched
IfcPositioningElement instances, so grid axes were never
unlocked and remained immovable.

Fix: include IfcGridAxis in the element list for the IFC4X3
(non-IFC2X3/IFC4) branch of update_grid_is_locked.
2026-07-10 19:22:03 -05:00
Ryan Schultz c0889c7f10 Fix IfcGridAxis duplication losing geometry on save
When duplicating an IfcGridAxis one or more times before saving,
duplicates shared the same IfcPolyline as the source via shallow copy.
This caused two issues: (1) updating any one axis's AxisCurve during
export would destroy the shared curve, corrupting others; (2) duplicates
whose matrix_world checksum happened to match their current position were
skipped entirely by the is_moved guard, so their moved position was never
written to IFC.

Three fixes:

- geometry.py: call create_axis_curve immediately after copy_class for
  IfcGridAxis duplicates, so each new axis owns its AxisCurve from the
  moment of duplication rather than sharing the source's.

- create_axis_curve.py: only remove the old AxisCurve when its inverse
  count drops to zero, preventing destruction of curves still referenced
  by other axes.

- export_ifc.py: move the IfcGridAxis branch before the is_moved guard.
  Grid axes store position in AxisCurve geometry rather than
  ObjectPlacement, so is_moved is not a reliable gate. The internal
  matrices_differ check is the correct decision point, and
  record_object_position at the end keeps checksums in sync.

Generated with the assistance of an AI coding tool.
2026-07-10 19:22:03 -05:00
Ryan Schultz e609f10559 Fix grid axis annotation misalignment when axis is moved
After moving an IfcGridAxis in Blender, the drawing annotation
was not tracking the axis to its new visual position. Two issues
were found and fixed:

1. generate_grid_axis_reference_points used the IFC AxisCurve
   geometry (via create_shape) with the grid object's matrix_world.
   After a save, the IFC AxisCurve is updated but the Blender mesh
   is not rebuilt, causing the two sources to diverge. The fix reads
   the axis object's Blender mesh vertices directly with
   axis_obj.matrix_world, which always matches what Blender renders.

2. When no Blender axis object exists, falls back to reading IFC
   geometry with the grid object's matrix_world (unchanged behavior).

Minor refactors: extracted matrices_differ variable in
sync_grid_axis_object_placement (export_ifc.py and drawing.py) and
extracted grid_placement variable in create_axis_curve.py for clarity.

Generated with the assistance of an AI coding tool.
2026-07-10 19:04:44 -05:00
Ryan Schultz e70ce17431 Fix grid decorations missing due to geolocation offset
generate_grid_axis_reference_points was building the grid-to-world
transform using get_local_placement(grid.ObjectPlacement), which
returns raw IFC world coordinates. When the project uses a
geolocation offset (survey point shift), these coordinates are
hundreds of meters from the Blender world origin, placing grid
vertices far outside the camera's ortho bounds and causing
clip_segment to return None for every axis.

Fix by using tool.Ifc.get_object(grid).matrix_world instead, which
already has the importer-applied geolocation offset baked in,
keeping the coordinate space consistent with the camera.

Generated with the assistance of an AI coding tool.
2026-07-10 19:04:44 -05:00
270 changed files with 1821 additions and 5727 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*
+1 -1
View File
@@ -18,7 +18,7 @@ and many other libraries, CLI apps, and more. Support is also provided for auxil
For more information, see:
* [IfcOpenShell Website](https://ifcopenshell.org)
* [IfcOpenShell Website](http://ifcopenshell.org)
* [IfcOpenShell Documentation](https://docs.ifcopenshell.org)
* [IfcOpenShell C++ Installation](https://docs.ifcopenshell.org/ifcopenshell/installation.html)
* [IfcOpenShell Python Installation](https://docs.ifcopenshell.org/ifcopenshell-python/installation.html)
+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
+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",
-1
View File
@@ -68,7 +68,6 @@ def unpack_dependencies(install_dir: Path) -> None:
if __name__ == "__main__":
action = None
if len(sys.argv) != 2 or (action := sys.argv[1].lower()) not in ("pack", "unpack"):
print(__doc__)
sys.exit(1)
-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>
+89 -40
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,36 +83,92 @@ ignore = [
]
[tool.ty.rules]
all = "error"
all = "ignore"
# Structural rules (no deep type inference needed, easier to adapt).
# 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"
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 = "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"
# 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 = [
@@ -155,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 ."
@@ -192,9 +240,9 @@ format.sequence = ["black", "ruff"]
cmake-format = "gersemi . --in-place"
[tool.poe.tasks.ty-ios]
# --ignore unresolved-reference: walrus operator false positives in ty.
cmd = """
ty check
nix/
src/bcf
src/bsdd
src/ifc2ca
@@ -209,6 +257,7 @@ cmd = """
src/ifcpatch
src/ifctester
--python=src/ifcopenshell-python/.venv
--ignore unresolved-reference
"""
[tool.poe.tasks.bonsai-deps]
-5
View File
@@ -1,5 +0,0 @@
black==26.3.1
ruff==0.15.22
poethepoet
ty==0.0.61
gersemi==0.26.1
-6
View File
@@ -188,8 +188,6 @@ class BcfClient:
response.raise_for_status()
return response.status_code, response.text
except requests.exceptions.HTTPError as errh:
response = errh.response
assert response is not None
print(f"message: {response.reason}' '{response.status_code}, {errh}")
return response.status_code, response.reason
@@ -208,8 +206,6 @@ class BcfClient:
response.raise_for_status()
return response.status_code, response.text
except requests.exceptions.HTTPError as errh:
response = errh.response
assert response is not None
print(f"message: {response.reason}' '{response.status_code}, {errh}")
return response.status_code, response.reason
@@ -226,8 +222,6 @@ class BcfClient:
response.raise_for_status()
return response.status_code, response.text
except requests.exceptions.HTTPError as errh:
response = errh.response
assert response is not None
print(f"message: {response.reason}' '{response.status_code}, {errh}")
return response.status_code, response.reason
+4 -22
View File
@@ -24,28 +24,10 @@ a text, a tspan { fill: blue !important; text-decoration: underline;}
a:hover { cursor: pointer; }
.cut { fill: black; stroke: black; stroke-linecap: 'round'; stroke-width: 0.35; fill-rule: evenodd; }
.projection { fill: white; stroke: black; stroke-linecap: 'round'; stroke-width: 0.25; }
/* SVG edge classification (issue #3668): see edge-classification.md. These select directly on
the <path> element (each classified projection edge carries its own class), so they win over
the inherited .projection rule above regardless of specificity. */
path.outline { stroke: black; stroke-width: 0.35; stroke-opacity: 1; }
path.boundary { stroke: black; stroke-width: 0.3; stroke-opacity: 0.9; }
path.crease { stroke: black; stroke-width: 0.25; stroke-opacity: 0.85; }
path.sharp { stroke: black; stroke-width: 0.18; stroke-opacity: 0.7; }
path.flush { stroke: black; stroke-width: 0.1; stroke-opacity: 0.4; }
/* Debug CSS for troubleshooting edge classification */
/*
path.outline { stroke: black; stroke-width: 0.35; stroke-opacity: 1; }
path.boundary { stroke: orange; stroke-width: 0.3; stroke-opacity: 0.9; }
path.crease { stroke: green; stroke-width: 0.25; stroke-opacity: 0.85; }
path.sharp { stroke: red; stroke-width: 0.18; stroke-opacity: 0.7; }
path.flush { stroke: blue; stroke-width: 0.1; stroke-opacity: 0.4; }
*/
.surface {fill: white; stroke-width: 0.1;}
.annotation { fill: none; stroke: black; stroke-linecap: 'round'; stroke-width: 0.3; }
.IfcAnnotation { fill: none; stroke: black; stroke-linecap: 'round'; stroke-width: 0.3; }
/* .IfcGeographicElement { fill: none; stroke: rgb(150, 150, 150); stroke-linecap: 'round'; stroke-dasharray: 1, 2;} */
.surface { stroke: none; fill: #fff; fill-rule: evenodd; }
.annotation { fill: none; stroke: black; stroke-linecap: 'round'; stroke-width: 0.25; }
.IfcAnnotation { fill: none; stroke: black; stroke-linecap: 'round'; stroke-width: 0.25; }
.IfcGeographicElement { fill: none; stroke: black; stroke-linecap: 'round'; stroke-width: 1; }
.PredefinedType-LINEWORK { stroke: black; stroke-width: 0.25; }
.PredefinedType-LINEWORK.dashed { stroke-dasharray: 3, 2; }
.PredefinedType-LINEWORK.fine { stroke-width: 0.18; stroke: #777777; }
@@ -5,7 +5,7 @@ FILE_NAME('EPset_Drawing.ifc','2020-01-01T00:00:00',$,$,'EPset_Drawing','EPset_D
FILE_SCHEMA(('IFC4'));
ENDSEC;
DATA;
#1=IFCPROPERTYSETTEMPLATE('2JhNIvqZrFnAgxfhK0XVQX',$,'EPset_Drawing','',.PSET_OCCURRENCEDRIVEN.,'IfcAnnotation/DRAWING',(#23,#22,#27,#24,#29,#30,#19,#12,#26,#9,#8,#7,#6,#4,#18,#11,#5,#20,#25,#14,#10,#17,#28,#16,#3,#21,#13,#15,#2,#31,#32,#33,#34,#35,#36,#37));
#1=IFCPROPERTYSETTEMPLATE('2JhNIvqZrFnAgxfhK0XVQX',$,'EPset_Drawing','',.PSET_OCCURRENCEDRIVEN.,'IfcAnnotation/DRAWING',(#23,#22,#27,#24,#29,#30,#19,#12,#26,#9,#8,#7,#6,#4,#18,#11,#5,#20,#25,#14,#10,#17,#28,#16,#3,#21,#13,#15,#2));
#2=IFCSIMPLEPROPERTYTEMPLATE('23JavTMk98ZxXhrUEnjAcf',$,'TargetView','',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
#3=IFCSIMPLEPROPERTYTEMPLATE('1yVWUt5H9DAOuu0OaMMLpe',$,'Scale','The scale of this drawing represented as a numerator and denominator, such as 1/100',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
#4=IFCSIMPLEPROPERTYTEMPLATE('3gsuPBtU93b8f0gg1pjkq6',$,'HumanScale','The scale of this drawing in human readable format, such as 1:100',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
@@ -35,12 +35,5 @@ DATA;
#28=IFCSIMPLEPROPERTYTEMPLATE('1YSnFzurrEyRNtoLdmmddP',$,'BringToFront','The objects with these SVG classes will render in front of all other objects.Ex: IfcBeam, IfcColumn',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.);
#29=IFCSIMPLEPROPERTYTEMPLATE('0lP6Y8q9v2QhDnR4sT7uVx',$,'PerspectiveShiftX','Horizontal perspective camera shift stored as drawing metadata using Blender camera shift units.',.P_SINGLEVALUE.,'IfcReal',$,$,$,$,$,.READWRITE.);
#30=IFCSIMPLEPROPERTYTEMPLATE('2mR8b1NcW5EoFyG7hJ9kLp',$,'PerspectiveShiftY','Vertical perspective camera shift stored as drawing metadata using Blender camera shift units.',.P_SINGLEVALUE.,'IfcReal',$,$,$,$,$,.READWRITE.);
#31=IFCSIMPLEPROPERTYTEMPLATE('1cFVJnqT13m8ItkMHaI1tp',$,'UseEdgeClassification','Enable the boundary/outline/sharp/crease/flush SVG edge classification scheme (issue #3668). When false, drawings use the original unclassified linework.',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
#32=IFCSIMPLEPROPERTYTEMPLATE('2kB$mxBgnBUvhjh0Ti0c4P',$,'RenderCreases','Whether to render ''crease'' (concave) edges. Only relevant when UseEdgeClassification is enabled.',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
#33=IFCSIMPLEPROPERTYTEMPLATE('3MSIJNW$T8r9Hl12kk0BY$',$,'ValleyAngleMinDegrees','Minimum concave dihedral deviation from flat, in degrees, for a projection edge to be classified as ''crease'' rather than ''flush''.',.P_SINGLEVALUE.,'IfcReal',$,$,$,$,$,.READWRITE.);
#34=IFCSIMPLEPROPERTYTEMPLATE('2epSGfC4bFM9gb1X7zBIp4',$,'RenderSharp','Whether to render ''sharp'' (convex) edges. Only relevant when UseEdgeClassification is enabled.',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
#35=IFCSIMPLEPROPERTYTEMPLATE('3TZwsEjkr5WRDKcgrYzSIA',$,'RidgeAngleMinDegrees','Minimum convex dihedral deviation from flat, in degrees, for a projection edge to be classified as ''sharp'' rather than ''flush''.',.P_SINGLEVALUE.,'IfcReal',$,$,$,$,$,.READWRITE.);
#36=IFCSIMPLEPROPERTYTEMPLATE('2Jua$lO754vgZOkBoHM2gA',$,'RenderFlush','Whether to render ''flush'' edges (dihedral deviation below both ridge/valley thresholds). Only relevant when UseEdgeClassification is enabled.',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
#37=IFCSIMPLEPROPERTYTEMPLATE('1zM9sia2L8RQDnWZxgUwlZ',$,'JoinClasses','Comma separated list of IFC classes whose cut linework will be joined together when they meet (e.g. mitred at a corner).\X2\000A\X0\Defaults to ''IfcWall,IfcSlab'' if not set. Override to also join other classes, such as ''IfcWall,IfcSlab,IfcCovering''.',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.);
ENDSEC;
END-ISO-10303-21;
@@ -244,7 +244,7 @@ function addGanttElement(blenderId, tasks, workSched, filename) {
vShowTaskInfoLink: 1, // Show link in tool tip (0/1)
vShowEndWeekDate: 0, // Show/Hide the date for the last day of the week in header for daily
vUseSingleCell: 10000, // Set the threshold cell per table row (Helps performance for large data.
vFormatArr: ["Hour", "Day", "Week", "Month", "Quarter"], // vUseSingleCell keeps Hour usable on large charts.
vFormatArr: ["Day", "Week", "Month", "Quarter"], // Even with setUseSingleCell using Hour format on such a large chart can cause issues in some browsers,
vShowRes: true, // Disable the resource column.
vShowComp: false, // Disable the completion column.
vShowDur: false, // Disable the duration column, because jsgantt doesn't calculate durations the way we want.
+4 -3
View File
@@ -120,10 +120,10 @@ class IfcExporter:
# updata_representation will run edit_object_placement if object is scaled
# and had no openings.
return element
if not tool.Ifc.is_moved(obj):
return
if element.is_a("IfcGridAxis"):
return self.sync_grid_axis_object_placement(obj, element)
if not tool.Ifc.is_moved(obj):
return
if not hasattr(element, "ObjectPlacement"):
return
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj)
@@ -134,7 +134,8 @@ class IfcExporter:
grid_obj = tool.Ifc.get_object(grid)
if grid_obj:
self.sync_object_placement(grid_obj)
if grid_obj.matrix_world != obj.matrix_world:
matrices_differ = grid_obj.matrix_world != obj.matrix_world
if matrices_differ:
bpy.ops.bim.update_representation(obj=obj.name)
tool.Geometry.record_object_position(obj)
-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 -8
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()
@@ -1103,14 +1098,12 @@ class IfcImporter:
vertices = [[v[i], v[i + 1], v[i + 2], 1] for i in range(0, len(v), 3)]
edges = [[e[i], e[i + 1]] for i in range(0, len(e), 2)]
v2 = None
polyline = None
for edge in edges:
v1 = vertices[edge[0]]
if v1 != v2:
polyline = curve.splines.new("POLY")
polyline.points[-1].co = mathutils.Vector(v1)
v2 = vertices[edge[1]]
assert polyline is not None
polyline.points.add(1)
polyline.points[-1].co = mathutils.Vector(v2)
edges_item_ids = ifcopenshell.util.shape.get_edges_representation_item_ids(geometry).tolist()
@@ -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:
@@ -1059,7 +1060,6 @@ class AddBoundary(bpy.types.Operator, tool.Ifc.Operator):
return tool.Ifc.get().createIfcConnectionSurfaceGeometry(surface)
def export_surface(self, polygon, target_face_matrix):
ifc_file = tool.Ifc.get()
x_axis = target_face_matrix.col[0][:3]
z_axis = target_face_matrix.col[2][:3]
p1 = target_face_matrix.translation
@@ -1072,20 +1072,18 @@ class AddBoundary(bpy.types.Operator, tool.Ifc.Operator):
placement = builder.create_axis2_placement_3d([o / self.unit_scale for o in p1], z_axis, x_axis)
surface.BasisSurface = tool.Ifc.get().create_entity("IfcPlane", placement)
schema = ifc_file.schema
if schema != "IFC2X3":
if tool.Ifc.get().schema != "IFC2X3":
points = [tool.Model.convert_si_to_unit(list(co)) for co in polygon.exterior.coords]
point_list = tool.Ifc.get().createIfcCartesianPointList2D(points)
outer_boundary = tool.Ifc.get().createIfcIndexedPolyCurve(point_list, None, False)
inner_boundaries: list[ifcopenshell.entity_instance] = []
inner_boundaries = []
for interior in polygon.interiors:
points = [tool.Model.convert_si_to_unit(list(co)) for co in interior.coords]
point_list = tool.Ifc.get().createIfcCartesianPointList2D(points)
inner_boundaries.append(tool.Ifc.get().createIfcIndexedPolyCurve(point_list, None, False))
else:
# TODO:
raise NotImplementedError(schema)
pass # TODO
surface.OuterBoundary = outer_boundary
surface.InnerBoundaries = inner_boundaries
+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",
}
)
@@ -400,7 +400,6 @@ class CadOffset(bpy.types.Operator):
[verts.update(e.verts) for e in edges]
# Use the viewport angle to determine the offset direction
wp = None
for area in bpy.context.screen.areas:
if area.type == "VIEW_3D":
# Don't ask me, I don't know.
@@ -410,7 +409,6 @@ class CadOffset(bpy.types.Operator):
z = area.spaces.active.region_3d.view_rotation @ Vector((0, 0, 1))
wp = Matrix([x, y, z, Vector((0, 0, 0))]).to_4x4().transposed()
break
assert wp is not None
rotation = Matrix.Rotation(pi / 2, 2, "Z")
rotation_i = Matrix.Rotation(-pi / 2, 2, "Z")
@@ -478,7 +478,6 @@ class ChangeClassificationLevel(bpy.types.Operator):
def execute(self, context):
props = tool.Classification.get_classification_props()
props.available_library_references.clear()
reference = None
for reference in IfcStore.classification_file.by_id(self.parent_id).HasReferences:
new = props.available_library_references.add()
new.identification = reference.Identification or ""
@@ -486,7 +485,6 @@ class ChangeClassificationLevel(bpy.types.Operator):
new.ifc_definition_id = reference.id()
new.has_references = bool(reference.HasReferences)
new.referenced_source
assert reference
if reference.ReferencedSource.is_a("IfcClassificationReference"):
props.active_library_referenced_source = reference.ReferencedSource.ReferencedSource.id()
else:
@@ -156,8 +156,6 @@ class CostSchedulesData:
values = root_element.CostValues
elif root_element.is_a("IfcConstructionResource"):
values = root_element.BaseCosts
else:
assert False, root_element
for cost_value in values or []:
cls._load_cost_value(root_element, data, cost_value)
# data["CostValues"].append(cost_value.id())
+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
@@ -47,7 +47,6 @@ classes = (
operator.CleanWireframes,
operator.ContractSheet,
operator.ConvertSVGToDXF,
operator.CopyAnnotationToDrawing,
operator.CopyTextToSelection,
operator.CreateDrawing,
operator.CreateSheets,
@@ -425,12 +425,10 @@ class BaseDecorator:
blf.size(font_id, font_size_px)
w, h = None, None
if box_alignment or center or vcenter:
w, h = blf.dimensions(font_id, text)
if box_alignment:
assert w is not None and h is not None
box_alignment_offset = Vector((0, 0))
if "bottom" in box_alignment:
pass
@@ -452,12 +450,10 @@ class BaseDecorator:
else:
# horizontal centering
if center:
assert w is not None
pos -= Vector((cos, sin)) * w * 0.5
# vertical centering
if vcenter:
assert h is not None
pos -= Vector((-sin, cos)) * h * 0.5
# side-shifting
@@ -1005,8 +1001,6 @@ class FallDecorator(BaseDecorator):
O = A.copy()
O.z = B.z
run = (B - O).length
angle_tg = None
if run != 0:
angle_tg = rise / run
angle = round(degrees(atan(angle_tg)))
@@ -1024,7 +1018,6 @@ class FallDecorator(BaseDecorator):
elif object_type == "SLOPE_PERCENT":
if angle == 90:
return "-"
assert angle_tg is not None
return f"{round(angle_tg * 100)} %"
return "NO DATA"
@@ -1256,7 +1249,6 @@ class SectionLevelDecorator(BaseDecorator):
}
# process edges
text_position, text_dir = None, None
for edge in edges_original:
v0, v1 = winspace_verts[edge[0]], winspace_verts[edge[1]]
start_i = len(output_verts)
@@ -1562,39 +1554,32 @@ class SectionDecorator(BaseDecorator):
v0, v1 = winspace_verts[edge[0]], winspace_verts[edge[1]]
start_i = len(output_verts)
circle_head = None
if display_start_circle or display_end_circle:
circle_head = get_circle_head(circle_size)
triangle_head, divider_offset, edge_dir_circle = None, None, None
display_symbol = display_start_symbol or display_end_symbol
if display_symbol or connect_markers:
if display_start_symbol or display_end_symbol or connect_markers:
edge_dir = (v1 - v0).normalized()
side = (edge_dir.yx * Vector((1, -1))).to_3d()
edge_dir_circle = edge_dir * circle_size
if display_symbol:
triangle_head = get_triangle_head(edge_dir, -side, triangle_length, triangle_width)
divider_offset = []
divider_offset.append(edge_dir_circle if connect_markers else edge_dir_circle * 3)
divider_offset.append(edge_dir_circle)
if display_start_symbol or display_end_symbol:
triangle_head = get_triangle_head(edge_dir, -side, triangle_length, triangle_width)
divider_offset = []
divider_offset.append(edge_dir_circle if connect_markers else edge_dir_circle * 3)
divider_offset.append(edge_dir_circle)
if display_start_circle:
assert circle_head is not None
start_i = add_verts_sequence([v + v0 for v in circle_head], start_i, **out_kwargs, closed=True)
# circle middle divider
if not display_start_symbol:
assert divider_offset is not None
start_i = add_verts_sequence(
[v0 + divider_offset[0], v0 - divider_offset[1]], start_i, **out_kwargs
)
if display_start_symbol:
assert triangle_head is not None
start_i = add_verts_sequence([v + v0 for v in triangle_head], start_i, **out_kwargs, closed=True)
if display_end_circle:
assert circle_head is not None
start_i = add_verts_sequence([v + v1 for v in circle_head], start_i, **out_kwargs, closed=True)
# circle middle divider
if not display_end_symbol:
@@ -1603,11 +1588,9 @@ class SectionDecorator(BaseDecorator):
)
if display_end_symbol:
assert triangle_head is not None
start_i = add_verts_sequence([v + v1 for v in triangle_head], start_i, **out_kwargs, closed=True)
if connect_markers:
assert edge_dir_circle is not None
gap = []
gap.append(edge_dir_circle if display_start_symbol else Vector((0, 0, 0)))
gap.append(edge_dir_circle if display_end_symbol else Vector((0, 0, 0)))
@@ -1694,12 +1677,6 @@ class CutDecorator:
selected_elements_color = self.addon_prefs.decorator_color_selected
self.fallback_colour = (0.3, 0.3, 0.3, 1)
# Evaluate camera movement once per redraw rather than twice per object: is_camera_moved()
# runs eval()/numpy on the camera matrix and, as a side effect, refreshes the stored
# checksum on the first True result - so calling it per object also made the second call
# (fill) see an already-updated checksum and skip recalculating when it shouldn't.
self.camera_moved = self.is_camera_moved()
all_vertices = []
all_edges = []
selected_vertices = []
@@ -1825,35 +1802,23 @@ class CutDecorator:
# Currently selected objects must be recalculated as they may be being moved / edited.
# If the camera is selected, we also recalculate as the user may be moving the camera.
is_selected = obj.select_get()
recalc_cut = not has_cut_cache or is_selected or self.camera_moved
recalc_fill = not has_fill_cache or is_selected or self.camera_moved
if not (recalc_cut or recalc_fill):
return
# The intersection test builds a bmesh and scans every vertex; both recalculations need
# the same answer, so compute it once here rather than once in each.
is_intersecting = tool.Drawing.is_intersecting_camera(obj, context.scene.camera)
if recalc_cut:
self.recalculate_cut(context, obj, element, is_intersecting)
if recalc_fill:
self.recalculate_fill(context, obj, element, is_intersecting)
if not has_cut_cache or obj.select_get() or self.is_camera_moved():
self.recalculate_cut(context, obj, element)
if not has_fill_cache or obj.select_get() or self.is_camera_moved():
self.recalculate_fill(context, obj, element)
def recalculate_cut(
self, context, obj: bpy.types.Object, element: ifcopenshell.entity_instance, is_intersecting: bool
) -> None:
if is_intersecting:
def recalculate_cut(self, context, obj: bpy.types.Object, element: ifcopenshell.entity_instance) -> None:
if tool.Drawing.is_intersecting_camera(obj, context.scene.camera):
verts, edges = tool.Drawing.bisect_mesh(obj, context.scene.camera)
DecoratorData.cut_cache[element.id()] = (verts, edges)
else:
DecoratorData.cut_cache[element.id()] = (False, False)
def recalculate_fill(
self, context, obj: bpy.types.Object, element: ifcopenshell.entity_instance, is_intersecting: bool
) -> None:
def recalculate_fill(self, context, obj: bpy.types.Object, element: ifcopenshell.entity_instance) -> None:
element_id = element.id()
if not is_intersecting:
if not tool.Drawing.is_intersecting_camera(obj, context.scene.camera):
DecoratorData.fill_cache[element_id] = {}
return
@@ -1906,8 +1871,6 @@ class CutDecorator:
layer_set = material
offset = 0
sense_factor = 1
else:
assert False, material
if len(layer_set.MaterialLayers) == 1:
material = layer_set.MaterialLayers[0].Material
@@ -1934,8 +1897,6 @@ class CutDecorator:
co = Vector((0.0, 0.0, offset))
no = tool.Drawing.get_extrusion_vector(element).normalized()
no = Vector([1.0, 0.0, 0.0])
else:
assert False, usage
no *= sense_factor
last_i = len(layer_set.MaterialLayers) - 1
+1 -12
View File
@@ -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)
@@ -225,11 +219,9 @@ def format_distance(
unit_system, unit_length, unit_fraction = unit_mapping[custom_unit]
value *= unit_scale
tx_dist = None
# Imperial Formatting
if unit_system == "IMPERIAL":
toInches = None
if in_unit_length:
if unit_length == "INCHES":
toInches = 1
@@ -243,7 +235,6 @@ def format_distance(
toInches = 1550
inPerFoot = 144
assert toInches is not None
decInches = value * toInches
decFeet = decInches / 12
@@ -386,7 +377,6 @@ def format_distance(
if precision and isinstance(precision, float):
value = precision * round(float(value) / precision)
fmt = None
if decimal_places is not None:
fmt = "%1." + str(decimal_places) + "f"
@@ -469,7 +459,6 @@ def format_distance(
assert f"Unexpected unit_system - '{unit_system}'."
# tx_dist = fmt % value
assert tx_dist is not None
return tx_dist
@@ -228,90 +228,6 @@ class DuplicateDrawing(bpy.types.Operator, tool.Ifc.Operator):
)
def get_copy_annotation_target_drawings(self, context):
global COPY_ANNOTATION_TARGET_DRAWINGS_ENUM
drawings = [e for e in tool.Ifc.get().by_type("IfcAnnotation") if e.ObjectType == "DRAWING"]
drawings.sort(key=lambda d: d.Name or "")
COPY_ANNOTATION_TARGET_DRAWINGS_ENUM = [(str(d.id()), d.Name or "Unnamed", "") for d in drawings]
return COPY_ANNOTATION_TARGET_DRAWINGS_ENUM
COPY_ANNOTATION_TARGET_DRAWINGS_ENUM = []
class CopyAnnotationToDrawing(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.copy_annotation_to_drawing"
bl_label = "Copy Annotation To Drawing"
bl_description = (
"Copy the selected annotations to another drawing.\n\n"
"The copies become independent annotations assigned to the chosen drawing, "
"placed in its view plane. The originals stay in their current drawing"
)
bl_options = {"REGISTER", "UNDO"}
target_drawing: bpy.props.EnumProperty(name="Target Drawing", items=get_copy_annotation_target_drawings)
if TYPE_CHECKING:
target_drawing: str
@classmethod
def poll(cls, context):
if not tool.Ifc.get():
cls.poll_message_set("No IFC project loaded.")
return False
if not cls.get_selected_annotations(context):
cls.poll_message_set("No annotation selected.")
return False
return True
@classmethod
def get_selected_annotations(cls, context) -> list[ifcopenshell.entity_instance]:
return [
element
for obj in context.selected_objects
if (element := tool.Ifc.get_entity(obj))
and element.is_a("IfcAnnotation")
and element.ObjectType != "DRAWING"
]
def invoke(self, context, event):
assert context.window_manager
return context.window_manager.invoke_props_dialog(self)
def draw(self, context):
assert self.layout
row = self.layout.row()
row.prop(self, "target_drawing")
def _execute(self, context):
if not self.target_drawing:
self.report({"ERROR"}, "No target drawing selected.")
return {"CANCELLED"}
target_drawing = tool.Ifc.get().by_id(int(self.target_drawing))
annotations = self.get_selected_annotations(context)
previous_selection = [obj for a in annotations if (obj := tool.Ifc.get_object(a))]
previous_active = context.view_layer.objects.active
copied = core.copy_annotations_to_drawing(
tool.Ifc,
tool.Collector,
tool.Drawing,
tool.Geometry,
annotations=annotations,
target_drawing=target_drawing,
)
for obj in context.selected_objects:
obj.select_set(False)
for obj in previous_selection:
if obj.name in context.view_layer.objects:
obj.select_set(True)
if previous_active and previous_active.name in context.view_layer.objects:
context.view_layer.objects.active = previous_active
skipped = len(annotations) - len(copied)
message = f"Copied {len(copied)} annotations to {target_drawing.Name or 'Unnamed'}."
if skipped:
message += f" Skipped {skipped} already in that drawing."
self.report({"INFO"}, message)
class CreateDrawing(bpy.types.Operator):
"""Creates/refreshes a .svg drawing
@@ -782,8 +698,6 @@ class CreateDrawing(bpy.types.Operator):
layer_set = material
offset = 0
sense_factor = 1
else:
assert False, material
camera_matrix_i = context.scene.camera.matrix_world.inverted()
@@ -808,6 +722,7 @@ class CreateDrawing(bpy.types.Operator):
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.000001)
bmesh.ops.triangle_fill(bm, use_dissolve=True, edges=bm.edges)
prev_co = None
if not usage:
sense_factor = 1 # Assume the extrusion vector points in the direction sense
no = tool.Drawing.get_extrusion_vector(element).normalized()
@@ -824,8 +739,6 @@ class CreateDrawing(bpy.types.Operator):
co = Vector((0.0, 0.0, offset))
no = tool.Drawing.get_extrusion_vector(element).normalized()
no = Vector([1.0, 0.0, 0.0])
else:
assert False, usage
no *= sense_factor
last_i = len(layer_set.MaterialLayers) - 1
for i, layer in enumerate(layer_set.MaterialLayers):
@@ -993,10 +906,6 @@ class CreateDrawing(bpy.types.Operator):
if os.path.isfile(svg_path) and self.props.should_use_linework_cache:
return svg_path
ifc = tool.Ifc.get()
semantics = None
pairs = None
# in case of printing multiple drawings we need to sync just once
if self.sync and self.drawing_index == 0:
with profile("sync"):
@@ -1400,18 +1309,6 @@ class CreateDrawing(bpy.types.Operator):
self.svg_settings = ifcopenshell.geom.settings()
self.svg_settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS)
self.svg_settings.set("iterator-output", ifcopenshell.ifcopenshell_wrapper.NATIVE)
# SVG edge classification (issue #3668). See edge-classification.md. Settings are
# per-drawing, stored in EPset_Drawing and read into self.cprops by import_camera_props.
try:
self.svg_settings.set("svg-use-edge-classification", self.cprops.use_edge_classification)
self.svg_settings.set("svg-render-crease-edges", self.cprops.render_creases)
self.svg_settings.set("svg-valley-angle-min-degrees", self.cprops.valley_angle_min_degrees)
self.svg_settings.set("svg-render-sharp-edges", self.cprops.render_sharp)
self.svg_settings.set("svg-ridge-angle-min-degrees", self.cprops.ridge_angle_min_degrees)
self.svg_settings.set("svg-emit-flush-edges", self.cprops.render_flush)
except Exception:
# Backwards compatibility with older ifcopenshell builds that don't expose these keys.
pass
self.svg_buffer = ifcopenshell.geom.serializers.buffer()
self.serialiser_settings = ifcopenshell.geom.serializer_settings()
self.serialiser = ifcopenshell.geom.serializers.svg(
@@ -1555,15 +1452,6 @@ class CreateDrawing(bpy.types.Operator):
"Material.Name",
]
join_classes = ifcopenshell.util.element.get_pset(self.camera_element, "EPset_Drawing", "JoinClasses")
if join_classes:
join_classes = tuple(c.strip() for c in join_classes.split(",") if c.strip())
else:
# Architectural convention only merges these objects by default. E.g. pipe
# segments and fittings shouldn't merge. Users may override this per-drawing
# via the EPset_Drawing.JoinClasses property (e.g. to also join IfcCovering).
join_classes = ("IfcWall", "IfcSlab")
group = root.find("{http://www.w3.org/2000/svg}g")
joined_paths = {}
self.is_manifold_cache = {}
@@ -1665,7 +1553,8 @@ class CreateDrawing(bpy.types.Operator):
)
path.attrib["d"] = d
if not any(element.is_a(c) for c in join_classes):
# Architectural convention only merges these objects. E.g. pipe segments and fittings shouldn't merge.
if not element.is_a("IfcWall") and not element.is_a("IfcSlab"):
continue
keys = []
@@ -1817,12 +1706,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(),
),
)
@@ -2460,9 +2343,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()
@@ -2484,25 +2365,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
@@ -2517,34 +2389,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)
@@ -2633,9 +2486,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"
)
@@ -3697,7 +3548,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:
@@ -536,50 +536,6 @@ class BIMCameraProperties(PropertyGroup):
default=True,
update=get_update_layer_callback("has_annotation", "HasAnnotation"),
)
use_edge_classification: BoolProperty(
name="Use Edge Classification",
description="Classify projection edges into boundary/outline/sharp/crease/flush "
"instead of drawing all linework identically. See edge-classification.md",
default=False,
update=get_update_layer_callback("use_edge_classification", "UseEdgeClassification"),
)
render_creases: BoolProperty(
name="Render Creases",
description="Render 'crease' (concave) projection edges",
default=True,
update=get_update_layer_callback("render_creases", "RenderCreases"),
)
valley_angle_min_degrees: FloatProperty(
name="Valley Angle Minimum",
description="Minimum concave dihedral deviation from flat, in degrees, for a projection "
"edge to be classified as 'crease' rather than 'flush'",
default=12.0,
min=0.0,
max=180.0,
update=get_update_layer_callback("valley_angle_min_degrees", "ValleyAngleMinDegrees"),
)
render_sharp: BoolProperty(
name="Render Sharp",
description="Render 'sharp' (convex) projection edges",
default=True,
update=get_update_layer_callback("render_sharp", "RenderSharp"),
)
ridge_angle_min_degrees: FloatProperty(
name="Ridge Angle Minimum",
description="Minimum convex dihedral deviation from flat, in degrees, for a projection "
"edge to be classified as 'sharp' rather than 'flush'",
default=45.0,
min=0.0,
max=180.0,
update=get_update_layer_callback("ridge_angle_min_degrees", "RidgeAngleMinDegrees"),
)
render_flush: BoolProperty(
name="Render Flush",
description="Render 'flush' projection edges (dihedral deviation below both ridge/valley "
"thresholds). Omitted by default",
default=False,
update=get_update_layer_callback("render_flush", "RenderFlush"),
)
target_view: EnumProperty(
name="Target View",
default="PLAN_VIEW",
@@ -110,14 +110,12 @@ class Scheduler:
y = self.margin
rows = list(sheet.iter_rows())
total_rows = len(rows)
x = None
for i, row in enumerate(rows):
# The last row may contain only null values
if i == (total_rows - 1) and not [c for c in row if c.value is not None]:
continue
x = self.margin
unmerged_height = None
for cell in row:
if isinstance(cell, openpyxl.cell.cell.MergedCell):
column_letter = openpyxl.utils.get_column_letter(cell.column)
@@ -232,11 +230,8 @@ class Scheduler:
)
x += unmerged_width
assert unmerged_height is not None
y += unmerged_height
assert x is not None
total_width = x + self.margin
total_height = y + self.margin
self.svg["width"] = "{}mm".format(total_width)
@@ -380,7 +375,6 @@ class Scheduler:
tri = 0
stop_iterating_over_rows = False
# TODO: row spans support?
x = None
for tr in table.getElementsByType(TableRow):
if stop_iterating_over_rows:
break
@@ -497,7 +491,6 @@ class Scheduler:
tri += 1
y += height
assert x is not None
total_width = x + self.margin
total_height = y + self.margin
self.svg["width"] = "{}mm".format(total_width)
@@ -102,16 +102,16 @@ void angle_circle_head(
in vec4 circle_start, in float circle_angle,
in bool counterclockwise,
out vec4 head[CIRCLE_SEGS+1], out float angle_segs) {
// 1 added to CIRCLE_SEGS because we're number of vertices
// for n segments is n+1
float angle_d;
angle_d = PI * 2 / CIRCLE_SEGS; // 30d
// need to bottom clamp it to 1, otherwise it causes Blender crash at extruding the curve
angle_segs = max(1, ceil(circle_angle / angle_d));
angle_d = circle_angle / angle_segs;
for(int i = 0; i < (angle_segs + 1); i++) {
float angle = angle_d * i;
if (counterclockwise) {
@@ -143,7 +143,7 @@ void cross_head(in vec4 dir, in float size, out vec4 head[3]) {
#define do_vertex(pos, e) (do_vertex_util(pos, vec2(-(e).y, (e).x) / winsize.xy))
#define do_vertex_win(pos, e) ( do_vertex( WIN2CLIP( pos ), e ) )
// if vertex is shared by two segments of the line still need to emit it twice
// if vertex is shared by two segments of the line still need to emit it twice
// to avoid smoothing artifacts
// don't forget to initialize `vec2 EDGE_DIR` for macro to work
// `pos0` / `pos1` - vertex position in clip space
@@ -197,13 +197,10 @@ void do_circle_head(vec4 pos_w, vec4 head[CIRCLE_SEGS]) {
def add_verts_sequence(verts, start_i, output_verts, output_edges, closed=False):
"""Add sequence of verts to output lists, returns next vertex index"""
i = None
for i, v in enumerate(verts[:-1], start_i):
output_verts.append(v)
output_edges.append((i, i + 1))
output_verts.append(verts[-1])
assert i is not None
if closed:
output_edges.append((i + 1, start_i))
return i + 2
@@ -276,7 +273,7 @@ class BaseShader:
FRAG_GLSL = """
uniform vec4 color;
uniform float lineWidth;
in float smoothline;
out vec4 fragColor;
void main() {
@@ -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 ("-", "-")
@@ -1449,7 +1453,6 @@ class SvgWriter:
angle_tg = rise / run
angle = round(degrees(atan(angle_tg)))
else:
angle_tg = None
angle = 90
# ues SLOPE_ANGLE as default
@@ -1463,7 +1466,6 @@ class SvgWriter:
elif object_type == "SLOPE_PERCENT":
if angle == 90:
return "-"
assert angle_tg is not None
return f"{round(angle_tg * 100)} %"
tag = element.Description or get_label_text()
+2 -17
View File
@@ -113,19 +113,6 @@ class BIM_PT_camera(Panel):
row.prop(props, "fill_mode")
row = self.layout.row()
row.prop(props, "cut_mode")
row = self.layout.row()
row.prop(props, "use_edge_classification")
if props.use_edge_classification:
row = self.layout.row()
row.prop(props, "render_creases")
row.prop(props, "valley_angle_min_degrees")
row = self.layout.row()
row.prop(props, "render_sharp")
row.prop(props, "ridge_angle_min_degrees")
row = self.layout.row()
row.prop(props, "render_flush")
row = self.layout.row()
row.prop(props, "width")
row = self.layout.row()
@@ -332,8 +319,6 @@ class BIM_PT_drawings(Panel):
row3.separator(factor=0.5, type="SPACE")
row3.operator("bim.copy_annotation_to_drawing", icon="PASTEDOWN", text="")
row3.operator("bim.select_all_drawings", icon="CHECKBOX_HLT", text="")
row3.operator("bim.create_drawing", text="", icon="OUTPUT")
row3.operator("bim.convert_svg_to_dxf", text="", icon="SEQ_PREVIEW").view = active_drawing.name
@@ -979,14 +964,14 @@ class BIM_UL_sheets(bpy.types.UIList):
if self.filter_name:
filter_name = self.filter_name.lower()
active_sheet_index = None
active_sheet = None
for sheet in data.sheets:
if sheet.is_sheet:
active_sheet = sheet
active_sheet_index = len(flt_flags)
if filter_name in sheet.name.lower() or filter_name in sheet.identification.lower():
flt_flags.append(self.bitflag_filter_item)
if not sheet.is_sheet:
assert active_sheet_index is not None
flt_flags[active_sheet_index] = self.bitflag_filter_item
else:
flt_flags.append(0)
@@ -225,9 +225,6 @@ class AnnotationToolUI:
def draw_edit_object_interface(cls, context):
if DecoratorData.get_text_data(bpy.context.active_object):
add_layout_hotkey_operator(cls.layout, "Edit Text", "S_E", "")
if bpy.ops.bim.copy_annotation_to_drawing.poll():
row = cls.layout.row(align=True)
row.operator("bim.copy_annotation_to_drawing", icon="PASTEDOWN", text="Copy To Drawing")
@classmethod
def draw_type_selection_interface(cls):
@@ -75,13 +75,9 @@ class Helper:
for face in bm.faces:
if len(face.verts) > 4:
potential_faces.append(face)
# TODO: replace with next(..., None)
face = None
for face in potential_faces:
if face.normal.z < -0.1:
break
assert face is not None
profile = [l.vert.index for l in face.loops]
extrusion = self.detect_extrusion_edge(bm, face)
@@ -112,12 +108,10 @@ class Helper:
if not potential_faces:
potential_faces = bm.faces
# TODO: replace with next(..., None)
face = None
for face in potential_faces:
if face.normal.z < -0.1:
break
assert face is not None
profile = [l.vert.index for l in face.loops]
extrusion = self.detect_extrusion_edge(bm, face)
@@ -151,12 +145,9 @@ class Helper:
if total_verts > 4:
potential_faces.append(face)
# TODO: replace with next(..., None)
face = None
for face in potential_faces:
if face.normal.z < -0.1:
break
assert face is not None
end_faces = []
end_face_normal = face.normal
@@ -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"):
@@ -3527,15 +3523,12 @@ class EditRepresentationItemShapeAspect(bpy.types.Operator, tool.Ifc.Operator):
if props.representation_item_shape_aspect == "NEW":
active_representation = tool.Geometry.get_active_representation(obj)
# find IfcProductRepresentationSelect based on current representation
product_shape = None
if hasattr(element, "Representation"): # IfcProduct
product_shape = element.Representation
else: # IfcTypeProduct
for representation_map in element.RepresentationMaps:
if representation_map.MappedRepresentation == active_representation:
product_shape = representation_map
assert product_shape is not None
previous_shape_aspect_id = props.active_item.shape_aspect_id
# will be None if item didn't had a shape aspect
previous_shape_aspect = tool.Ifc.get_entity_by_id(previous_shape_aspect_id)
@@ -3885,8 +3878,6 @@ class AddSweptAreaSolidItem(bpy.types.Operator, tool.Ifc.Operator):
curve = builder.rectangle(size=Vector((0.5, 0.5)) / unit_scale)
elif self.shape == "CYLINDER":
curve = builder.circle(radius=0.25 / unit_scale)
else:
assert False, self.shape
item = builder.extrude(
curve,
magnitude=0.5 / unit_scale,
@@ -4124,31 +4115,6 @@ class OverrideMoveSelect(bpy.types.Operator):
self.new_active_obj = obj
return {"FINISHED"}
# Get arrays
ifc_file = tool.Ifc.get()
array_parents_to_move: list[bpy.types.Object] = []
for obj in list(context.selected_objects):
element = tool.Ifc.get_entity(obj)
if not element:
continue
pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
if not pset:
continue
parent_element = ifc_file.by_guid(pset["Parent"])
parent_obj = tool.Ifc.get_object(parent_element)
if parent_obj not in array_parents_to_move:
array_parents_to_move.append(parent_obj)
if element.GlobalId != pset["Parent"]:
obj.select_set(False)
if array_parents_to_move:
for parent_obj in array_parents_to_move:
parent_element = tool.Ifc.get_entity(parent_obj)
for array_obj in tool.Array.get_all_objects(parent_element):
array_obj.select_set(True)
self.new_active_obj = parent_obj
return {"FINISHED"}
# Get nests
props = tool.Nest.get_nest_props()
not_editing_objs = [o.obj for o in props.not_editing_objects]
+1 -39
View File
@@ -163,52 +163,14 @@ class AssignGroup(bpy.types.Operator, tool.Ifc.Operator):
def _execute(self, context):
if not self.is_assigning:
return bpy.ops.bim.unassign_group(group=self.group)
ifc_file = tool.Ifc.get()
group = ifc_file.by_id(self.group)
products = [
element
for o in tool.Blender.get_selected_objects(include_active=False)
if (element := tool.Ifc.get_entity(o))
]
relocated_annotations = self.unassign_from_previous_drawing(ifc_file, group, products)
ifcopenshell.api.group.assign_group(ifc_file, products=products, group=group)
self.relocate_annotations_to_drawing(relocated_annotations, group)
ifcopenshell.api.group.assign_group(tool.Ifc.get(), products=products, group=tool.Ifc.get().by_id(self.group))
self.report({"INFO"}, f"Assigned {len(products)} objects to group.")
def unassign_from_previous_drawing(self, ifc_file, group, products) -> list[ifcopenshell.entity_instance]:
"""Assigning an annotation to a group that represents a drawing means the
annotation should belong to that drawing only, so it needs to leave
whichever drawing it was previously part of, instead of ending up
visible in both at once.
"""
new_drawing = tool.Drawing.get_group_drawing(group)
if not new_drawing:
return []
relocated = []
for product in products:
if not product.is_a("IfcAnnotation") or product.ObjectType == "DRAWING":
continue
old_drawing = tool.Drawing.get_annotation_drawing(product)
if not old_drawing or old_drawing.id() == new_drawing.id():
continue
if old_group := tool.Drawing.get_drawing_group(old_drawing):
ifcopenshell.api.group.unassign_group(ifc_file, products=[product], group=old_group)
relocated.append(product)
return relocated
def relocate_annotations_to_drawing(self, products, group) -> None:
"""Move the relocated annotations into the new drawing's collection and
depth, now that they have actually been assigned to its group.
"""
if not products:
return
new_drawing = tool.Drawing.get_group_drawing(group)
new_camera = tool.Ifc.get_object(new_drawing) or tool.Drawing.import_drawing(new_drawing)
for product in products:
if obj := tool.Ifc.get_object(product):
tool.Drawing.ensure_annotation_in_drawing_plane(obj, camera=new_camera)
tool.Collector.assign(obj)
class UnassignGroup(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.unassign_group"
+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",
}
)
@@ -156,7 +156,6 @@ class RadianceRender(bpy.types.Operator):
print(f"Quality: {quality}, Detail: {detail}, Variability: {variability}")
print(f"Output directory: {output_dir}")
hdr_image_path, hdr_mask_path, sky_map_cal_path = None, None
if use_hdr:
hdr_image = "noon_grass_2k.hdr"
hdr_mask = "noon_grass_2k_mask.hdr"
@@ -255,9 +254,6 @@ class RadianceRender(bpy.types.Operator):
# 4 0 0 -1 180
if use_hdr and choose_hdr_image == "Noon":
assert hdr_image_path is not None
assert hdr_mask_path is not None
assert sky_map_cal_path is not None
with open(sky_file_path, "w") as f:
f.write(sky_description_str)
@@ -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()
@@ -564,7 +564,6 @@ class SelectAllArrayObjects(bpy.types.Operator):
except RuntimeError:
self.report({"ERROR"}, f"Objects that don't have an array parent, were deselected.")
object.select_set(False)
continue
array_objects = tool.Array.get_all_objects(parent_element)
tool.Blender.set_objects_selection(
@@ -93,30 +93,6 @@ def _stroke_lines_alpha(
gpu.state.blend_set("NONE")
def _connected_components(
vertex_groups: dict[int, list[bmesh.types.BMVert]],
) -> list[list[bmesh.types.BMVert]]:
"""Split each vertex group's members into their connected components,
since a duplicated arc/circle loop shares its source loop's group index."""
components = []
for verts in vertex_groups.values():
remaining = set(verts)
while remaining:
seed = remaining.pop()
stack = [seed]
component = [seed]
while stack:
v = stack.pop()
for edge in v.link_edges:
other = edge.other_vert(v)
if other in remaining:
remaining.discard(other)
stack.append(other)
component.append(other)
components.append(component)
return components
class ProfileDecorator:
installed = None
@@ -289,7 +265,7 @@ class ProfileDecorator:
# Draw arcs
arc_centroids = []
arc_segments = []
for arc in _connected_components(arcs):
for arc in arcs.values():
if len(arc) != 3:
continue
sorted_arc = [None, None, None]
@@ -316,7 +292,7 @@ class ProfileDecorator:
# Draw circles
circle_centroids = []
circle_segments = []
for circle in _connected_components(circles):
for circle in circles.values():
if len(circle) != 2:
continue
p1 = obj.matrix_world @ circle[0].co
@@ -408,8 +408,6 @@ class MEPGenerator:
compare = tool.Cad.is_x(requested_value, fitting_value, compare_precision)
elif isinstance(fitting_value, list):
compare = tool.Cad.are_vectors_equal(requested_value, Vector(fitting_value), precision)
else:
assert False, f"{key} {second_key}"
return compare
ignore_keys = []
@@ -478,13 +476,11 @@ class MEPGenerator:
if predefined_type == "OBSTRUCTION":
return packed_data
start_port = None
for port in ports:
port_local_position = V(*port.ObjectPlacement.RelativePlacement.Location.Coordinates)
if tool.Cad.is_x(port_local_position.length, 0.0):
start_port = port
break
assert start_port is not None
connected_port = tool.System.get_connected_port(start_port)
connected_element = tool.System.get_port_relating_element(connected_port)
@@ -325,7 +325,7 @@ class AddOccurrence(bpy.types.Operator, tool.Ifc.Operator):
if self.from_invoke and str(self.relating_type_id) in AuthoringData.data["relating_type_id"]:
props.relating_type_id = str(self.relating_type_id)
building_obj, building_element = None, None
building_obj = None
if len(context.selected_objects) == 1 and context.active_object:
building_obj = context.active_object
building_element = tool.Ifc.get_entity(building_obj)
@@ -593,8 +593,6 @@ class DumbProfileJoiner:
axisl = (profile2.matrix_world.inverted() @ axis1[1]) - (profile2.matrix_world.inverted() @ axis1[0])
elif connection1 == "ATSTART":
axisl = (profile2.matrix_world.inverted() @ axis1[0]) - (profile2.matrix_world.inverted() @ axis1[1])
else:
assert False, connection1
xy_angle = degrees(Vector((1, 0)).angle_signed(axisl.normalized().to_2d()))
if xy_angle >= -135 and xy_angle <= -45:
closest_plane = "bottom"
@@ -619,8 +617,6 @@ class DumbProfileJoiner:
axisl = (profile1.matrix_world.inverted() @ axis2[1]) - (profile1.matrix_world.inverted() @ axis2[0])
elif connection2 == "ATSTART":
axisl = (profile1.matrix_world.inverted() @ axis2[0]) - (profile1.matrix_world.inverted() @ axis2[1])
else:
assert False, connection2
xy_angle2 = degrees(Vector((1, 0)).angle_signed(axisl.normalized().to_2d()))
if xy_angle2 >= -135 and xy_angle2 <= -45:
closest_plane2 = "bottom"
@@ -848,8 +844,6 @@ class DumbProfileJoiner:
else:
y_axis = obj.matrix_world.to_quaternion() @ Vector((0, 1, 0))
z_axis = obj.matrix_world.to_quaternion() @ Vector((-1, 0, 0))
else:
assert False, plane
return self.create_matrix(p, x_axis, y_axis, z_axis)
def create_matrix(self, p: Vector, x: Vector, y: Vector, z: Vector) -> Matrix:
@@ -508,7 +508,6 @@ class EditSketchExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator):
converter.run()
profile = tool.Ifc.get().createIfcArbitraryClosedProfileDef("AREA")
curve = None
for path in converter.paths:
points = []
lines = path[0]
@@ -518,7 +517,6 @@ class EditSketchExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator):
points.append(tool.Ifc.get().createIfcCartesianPoint(local_point))
points.append(points[0])
curve = tool.Ifc.get().createIfcPolyline(points)
assert curve
profile.OuterCurve = curve
old_profile = extrusion.SweptArea
@@ -1577,7 +1577,6 @@ class DumbWallJoiner:
# Get the ATEND connection from wall1 to use it in wall2
relating_element = None
connections = element1.ConnectedTo
relating_connection, description = ..., ...
for conn in connections:
if conn.is_a("IfcRelConnectsPathElements") and conn.RelatingConnectionType == "ATEND":
relating_element = conn.RelatedElement
@@ -1592,7 +1591,6 @@ class DumbWallJoiner:
description = conn.Description
bonsai.core.geometry.remove_connection(tool.Geometry, connection=conn)
if relating_element:
assert relating_connection is not ... and description is not ...
ifcopenshell.api.geometry.connect_path(
tool.Ifc.get(),
relating_element=relating_element,
@@ -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 = (
@@ -60,8 +58,6 @@ classes = (
operator.LinkIfc,
operator.LoadBlendMetadataAndIFC,
operator.LoadLink,
operator.AutosavePrompt,
operator.LoadAutosavedRecoveryPopup,
operator.LoadLinkedProject,
operator.LoadProject,
operator.LoadProjectElements,
@@ -140,7 +136,6 @@ 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)
@@ -714,8 +714,6 @@ class AppendLibraryElement(bpy.types.Operator, tool.Ifc.Operator):
representations = element.RepresentationMaps or []
elif element.is_a("IfcProduct"):
representations = [element.Representation] if element.Representation else []
else:
assert False, element
for representation in representations or []:
for element in self.file.traverse(representation):
if not element.is_a("IfcRepresentationItem") or not element.StyledByItem:
@@ -987,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
@@ -999,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
@@ -1046,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
@@ -1167,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
@@ -1181,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)
@@ -1982,7 +1947,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
@@ -2031,7 +1995,6 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
project_props = tool.Project.get_project_props()
prefs = tool.Blender.get_addon_preferences()
project_props.use_relative_project_path = self.use_relative_path
old_history_size, old_undo_steps = None, None
if prefs.should_disable_undo_on_save:
old_history_size = tool.Ifc.get().history_size
old_undo_steps = context.preferences.edit.undo_steps
@@ -2039,24 +2002,11 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
context.preferences.edit.undo_steps = 0
IfcStore.execute_ifc_operator(self, context)
if prefs.should_disable_undo_on_save:
assert old_history_size is not None and old_undo_steps is not None
tool.Ifc.get().history_size = old_history_size
context.preferences.edit.undo_steps = old_undo_steps
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
@@ -2119,8 +2069,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("//"))
@@ -2154,7 +2103,6 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
)
bonsai.bim.handler.refresh_ui_data()
tool.Autosave.reset_timer()
@classmethod
def description(cls, context, properties):
@@ -2163,123 +2111,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"
@@ -113,8 +113,6 @@ class EditPset(bpy.types.Operator, tool.Ifc.Operator):
elif props.active_pset_type == "QTO":
pset = ifcopenshell.api.pset.add_qto(self.file, product=element, name=props.active_pset_name)
props.active_pset_id = pset.id()
else:
assert False
if self.properties:
properties = json.loads(self.properties)
@@ -228,8 +228,6 @@ def get_qto_name(self: "PsetProperties", context: bpy.types.Context) -> tool.Ble
if "bpy.data.objects" in pset_type:
if prop_type == "PsetProperties":
results = get_object_qto_name(self, context)
else:
assert False
elif prop_type == "TaskPsetProperties":
results = get_task_qto_names(self, context)
elif prop_type == "ResourcePsetProperties":
-1
View File
@@ -480,7 +480,6 @@ class BIM_PT_material_psets(Panel):
def draw(self, context):
assert self.layout
props = tool.Material.get_material_props()
ifc_definition_id = None
if material := props.active_material:
ifc_definition_id = material.ifc_definition_id
+10 -29
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
@@ -413,13 +400,12 @@ class UnlinkObject(bpy.types.Operator, tool.Ifc.Operator):
skip_invoke: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"})
def _execute(self, context):
objects: list[bpy.types.Object]
if self.obj:
requested_obj = bpy.data.objects.get(self.obj)
objects = [requested_obj] if requested_obj is not None else []
objects = [bpy.data.objects.get(self.obj)]
else:
objects = context.selected_objects
objects: list[bpy.types.Object]
for obj in objects:
was_active_object = obj == context.active_object
@@ -631,13 +617,10 @@ class AddElement(bpy.types.Operator, tool.Ifc.Operator):
local_z = wall_matrix.to_3x3() @ Vector((0, 0, 1))
direction_sense = getattr(usage, "DirectionSense", "POSITIVE")
layer_set_direction = usage.LayerSetDirection
if layer_set_direction == "AXIS2":
if usage.LayerSetDirection == "AXIS2":
z_axis = tuple(local_y) if direction_sense == "POSITIVE" else tuple(-local_y)
elif layer_set_direction == "AXIS3":
elif usage.LayerSetDirection == "AXIS3":
z_axis = tuple(local_z) if direction_sense == "POSITIVE" else tuple(-local_z)
else:
assert False, layer_set_direction
item = builder.extrude(
profile,
@@ -767,8 +750,6 @@ class AddElement(bpy.types.Operator, tool.Ifc.Operator):
WebThickness=default_web_thickness / unit_scale,
FlangeThickness=default_flange_thickness / unit_scale,
)
else:
assert False, representation_template
rel = ifcopenshell.api.material.assign_material(
tool.Ifc.get(), products=[element], type="IfcMaterialProfileSet"
@@ -1009,7 +1009,6 @@ class ColourByProperty(Operator):
palette = props.palette
is_qualitative = palette in ("tab10", "paired")
colours = None
if is_qualitative:
colours = tool.Search.get_qualitative_palette(palette)
@@ -1036,7 +1035,6 @@ class ColourByProperty(Operator):
if value in colourscheme:
colourscheme[value]["total"] += 1
else:
assert colours is not None
colourscheme[value] = {"colour": next(colours)[0:3], "total": 1}
obj.color = (*colourscheme[value]["colour"], 1)
else:
@@ -1141,7 +1139,6 @@ class SelectByProperty(Operator):
is_qualitative = palette in ("tab10", "paired")
values = None
if not is_qualitative:
values = []
for colour in props.colourscheme:
@@ -12,7 +12,7 @@ function create_gantt_chart(json_data) {
vShowTaskInfoLink: 1, // Show link in tool tip (0/1)
vShowEndWeekDate: 0, // Show/Hide the date for the last day of the week in header for daily
vUseSingleCell: 10000, // Set the threshold cell per table row (Helps performance for large data.
vFormatArr: ['Hour', 'Day', 'Week', 'Month', 'Quarter'], // vUseSingleCell keeps Hour usable on large charts.
vFormatArr: ['Day', 'Week', 'Month', 'Quarter'], // Even with setUseSingleCell using Hour format on such a large chart can cause issues in some browsers,
vShowRes: true, // Disable the resource column.
vShowComp: false, // Disable the completion column.
vShowDur: false, // Disable the duration column, because jsgantt doesn't calculate durations the way we want.
+2 -4
View File
@@ -281,11 +281,11 @@ class BIM_PT_work_schedules(Panel):
def draw_task_operators(self) -> None:
row = self.layout.row(align=True)
row.alignment = "RIGHT"
task, ifc_definition_id = None, None
ifc_definition_id = None
if self.tprops.tasks and self.props.active_task_index < len(self.tprops.tasks):
task = self.tprops.tasks[self.props.active_task_index]
ifc_definition_id = task.ifc_definition_id
if task and ifc_definition_id:
if ifc_definition_id:
if self.props.active_task_id:
if self.props.editing_task_type == "TASKTIME":
row.operator("bim.edit_task_time", text="", icon="CHECKMARK")
@@ -341,8 +341,6 @@ class BIM_PT_work_schedules(Panel):
row.prop(self.props, "other_columns", text="")
column_type, name = self.props.other_columns.split(".")
data_type = "string"
else:
assert False, column_type
row.operator("bim.set_task_sort_column", text="", icon="SORTALPHA").column = f"{column_type}.{name}"
row.prop(
self.props, "is_sort_reversed", text="", icon="SORT_DESC" if self.props.is_sort_reversed else "SORT_ASC"
@@ -516,7 +516,7 @@ class SetContainerVisibility(bpy.types.Operator):
if self.mode == "ISOLATE":
if tool.Ifc.get_schema() == "IFC2X3":
containers = tool.Ifc.get().by_type("IfcSpatialStructureElement")
else:
elif tool.Ifc.get_schema() != "IFC2X3":
containers = set(tool.Ifc.get().by_type("IfcSpatialElement"))
containers -= set(tool.Ifc.get().by_type("IfcSpatialZone"))
for container in containers:
+1 -1
View File
@@ -128,7 +128,7 @@ def update_grid_is_locked(self: "BIMGridProperties", context: bpy.types.Context)
if tool.Ifc.get().schema in ("IFC2X3", "IFC4"):
elements = tool.Ifc.get().by_type("IfcGrid") + tool.Ifc.get().by_type("IfcGridAxis")
else:
elements = tool.Ifc.get().by_type("IfcPositioningElement")
elements = tool.Ifc.get().by_type("IfcPositioningElement") + tool.Ifc.get().by_type("IfcGridAxis")
for element in elements:
if obj := tool.Ifc.get_object(element):
if self.is_locked:
@@ -125,7 +125,6 @@ class BIM_PT_spatial_decomposition(Panel):
row.label(text="Warning: No Default Container", icon="ERROR")
row.operator("bim.import_spatial_decomposition", icon="FILE_REFRESH", text="")
ifc_definition_id = None
if self.props.active_container:
ifc_definition_id = self.props.active_container.ifc_definition_id
row = self.layout.row(align=True)
@@ -171,7 +170,6 @@ class BIM_PT_spatial_decomposition(Panel):
if not self.props.active_container:
return
assert ifc_definition_id is not None
container_has_elements = bool(self.props.total_elements)
if container_has_elements:
@@ -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()}
-1
View File
@@ -102,7 +102,6 @@ class BIM_PT_styles(Panel):
# style ui tools
if active_style:
style = active_style
row = self.layout.row(align=True)
if material := style.blender_material:
msprops = tool.Style.get_material_style_props(material)
+38 -150
View File
@@ -20,7 +20,6 @@ from typing import TYPE_CHECKING
import bpy
import ifcopenshell.api.attribute
import ifcopenshell.api.material
import ifcopenshell.api.type
import ifcopenshell.util.element
import ifcopenshell.util.representation
@@ -116,94 +115,51 @@ class UnassignType(bpy.types.Operator, tool.Ifc.Operator):
if TYPE_CHECKING:
related_object: str
@staticmethod
def _reattach_styles(file: ifcopenshell.file, copied_entities: dict[int, ifcopenshell.entity_instance]) -> None:
"""copy_deep only follows forward references, so IfcStyledItem (an inverse,
``StyledByItem``) is not carried onto the copied geometry. Re-create a
styled item on each copy that points at the same presentation styles as
the original, so the unmapped occurrence keeps its appearance."""
for original_id, copied in copied_entities.items():
original = file.by_id(original_id)
for styled_item in getattr(original, "StyledByItem", None) or []:
file.create_entity(
"IfcStyledItem",
Item=copied,
Styles=styled_item.Styles,
Name=styled_item.Name,
)
@staticmethod
def unassign_and_unmap(obj: bpy.types.Object) -> None:
"""Unassign the type from ``obj`` and bake a private copy of any mapped
representation onto it, so the occurrence keeps its geometry, styles, and
material once the type (the source of all three) is gone."""
def _execute(self, context):
def exclude_callback(attribute):
return attribute.is_a("IfcProfileDef") and attribute.ProfileName
file = tool.Ifc.get()
element = tool.Ifc.get_entity(obj)
if not element or not element.is_a("IfcObject"):
return
# Capture the material inherited from the type before we sever the link,
# but only if the occurrence has no material of its own to override it.
own_material = ifcopenshell.util.element.get_material(element, should_inherit=False)
inherited_material = ifcopenshell.util.element.get_material(element, should_inherit=True)
ifcopenshell.api.type.unassign_type(file, related_objects=[element])
if element.Representation:
new_active_representation = None
active_representation = tool.Geometry.get_active_representation(obj)
active_context = active_representation.ContextOfItems
representations = []
for representation in element.Representation.Representations:
resolved_representation = ifcopenshell.util.representation.resolve_representation(representation)
if representation == resolved_representation:
representations.append(representation)
else:
# We must unmap representations, carrying over their styles.
copied_entities: dict[int, ifcopenshell.entity_instance] = {}
copied_representation = ifcopenshell.util.element.copy_deep(
file,
resolved_representation,
exclude=["IfcGeometricRepresentationContext"],
exclude_callback=exclude_callback,
copied_entities=copied_entities,
)
UnassignType._reattach_styles(file, copied_entities)
representations.append(copied_representation)
if representation.ContextOfItems == active_context:
new_active_representation = copied_representation
element.Representation.Representations = representations
if new_active_representation:
bonsai.core.geometry.switch_representation(
tool.Ifc,
tool.Geometry,
obj=obj,
representation=new_active_representation,
)
# Bake the inherited material down onto the occurrence now that its type
# link (and, in the delete-type case, the type itself) is gone. Usages are
# occurrence-specific and never inherited, so they need no handling here.
if inherited_material is not None and own_material is None:
material_type = inherited_material.is_a()
if material_type not in ("IfcMaterialLayerSetUsage", "IfcMaterialProfileSetUsage"):
ifcopenshell.api.material.assign_material(
file, products=[element], type=material_type, material=inherited_material
)
def _execute(self, context):
self.file = tool.Ifc.get()
if self.related_object:
related_objects = [bpy.data.objects[self.related_object]]
else:
related_objects = tool.Blender.get_selected_objects()
for obj in related_objects:
self.unassign_and_unmap(obj)
element = tool.Ifc.get_entity(obj)
if not element or not element.is_a("IfcObject"):
continue
ifcopenshell.api.type.unassign_type(self.file, related_objects=[element])
if element.Representation:
new_active_representation = None
active_representation = tool.Geometry.get_active_representation(obj)
active_context = active_representation.ContextOfItems
representations = []
for representation in element.Representation.Representations:
resolved_representation = ifcopenshell.util.representation.resolve_representation(representation)
if representation == resolved_representation:
representations.append(representation)
else:
# We must unmap representations.
copied_representation = ifcopenshell.util.element.copy_deep(
tool.Ifc.get(),
resolved_representation,
exclude=["IfcGeometricRepresentationContext"],
exclude_callback=exclude_callback,
)
representations.append(copied_representation)
if representation.ContextOfItems == active_context:
new_active_representation = copied_representation
element.Representation.Representations = representations
if new_active_representation:
bonsai.core.geometry.switch_representation(
tool.Ifc,
tool.Geometry,
obj=obj,
representation=new_active_representation,
)
return {"FINISHED"}
@@ -349,82 +305,14 @@ class SelectTypeObjects(bpy.types.Operator):
class RemoveType(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.remove_type"
bl_label = "Delete Type"
bl_description = (
"Delete this type. Its occurrences are kept but become untyped.\n\n"
"SHIFT+Click to also delete every occurrence of this type in the project"
)
bl_label = "Remove Type"
bl_options = {"REGISTER", "UNDO"}
element: bpy.props.IntProperty()
also_delete_instances: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"})
if TYPE_CHECKING:
element: int
also_delete_instances: bool
@staticmethod
def _detach_type_material_set(element: ifcopenshell.entity_instance) -> None:
"""Cascade-free removal of the type's IfcMaterialLayerSet / IfcMaterialProfileSet
association, called just before the type is deleted.
``remove_product`` would otherwise route the type's material association
through ``unassign_material``, which deletes *every* usage of that set
across the model (documented behaviour, with an upstream TODO calling it
too aggressive) stripping the material off the very occurrences we are
trying to keep. By unhooking the type<->set link by hand here, the type
has no material at delete time, so that cascade never fires and the set
plus the occurrences' usages survive intact."""
file = tool.Ifc.get()
material = ifcopenshell.util.element.get_material(element, should_inherit=False)
if not material or material.is_a() not in ("IfcMaterialLayerSet", "IfcMaterialProfileSet"):
return
for rel in list(getattr(element, "HasAssociations", None) or []):
if not (rel.is_a("IfcRelAssociatesMaterial") and rel.RelatingMaterial == material):
continue
remaining = [o for o in rel.RelatedObjects if o != element]
if remaining:
rel.RelatedObjects = remaining
else:
history = rel.OwnerHistory
file.remove(rel)
if history:
ifcopenshell.util.element.remove_deep2(file, history)
def invoke(self, context, event):
self.also_delete_instances = event.shift
if self.also_delete_instances:
element = tool.Ifc.get().by_id(self.element)
count = len(ifcopenshell.util.element.get_types(element))
return context.window_manager.invoke_confirm(
self,
event,
title="Delete Type and Occurrences",
message=f"This will delete the type and all {count} of its occurrences.",
confirm_text="Delete",
)
return self.execute(context)
def _execute(self, context):
element = tool.Ifc.get().by_id(self.element)
occurrences = ifcopenshell.util.element.get_types(element)
if self.also_delete_instances:
for occurrence in occurrences:
occurrence_obj = tool.Ifc.get_object(occurrence)
if occurrence_obj:
tool.Geometry.delete_ifc_object(occurrence_obj)
else:
# Keep the occurrences: bake their (previously type-mapped) geometry,
# styles, and inherited material onto each one so nothing is lost when
# the type is deleted...
for occurrence in occurrences:
occ_obj = tool.Ifc.get_object(occurrence)
if occ_obj:
UnassignType.unassign_and_unmap(occ_obj)
# ...and keep any layer/profile-set material usages alive across the deletion.
self._detach_type_material_set(element)
obj = tool.Ifc.get_object(element)
if obj:
tool.Geometry.delete_ifc_object(obj)
tool.Geometry.delete_ifc_object(obj)
class RenameType(bpy.types.Operator, tool.Ifc.Operator):
+1 -3
View File
@@ -144,10 +144,8 @@ class BIM_PT_type_attributes(Panel):
bonsai.bim.helper.draw_attributes(props.type_attributes, layout)
else:
row = layout.row(align=True)
row = layout.row()
row.operator("bim.enable_editing_type_attributes", icon="GREASEPENCIL", text="Edit")
op = row.operator("bim.remove_type", icon="TRASH", text="")
op.element = TypeData.data["relating_type"]["id"]
for attribute in TypeData.data["relating_type_attributes"]:
row = layout.row(align=True)
@@ -72,7 +72,6 @@ class AddOpening(bpy.types.Operator, tool.Ifc.Operator):
opening_objects = [obj for obj in selected_objects if obj != target_object]
obj1 = ...
for opening_obj in opening_objects:
element1 = tool.Ifc.get_entity(target_object)
obj1 = target_object
@@ -197,7 +196,6 @@ class AddOpening(bpy.types.Operator, tool.Ifc.Operator):
bpy.data.objects.remove(obj2)
tool.Model.purge_scene_openings()
assert obj1 is not ...
context.view_layer.objects.active = obj1
return {"FINISHED"}
-50
View File
@@ -284,13 +284,11 @@ class GizmoPreferences(bpy.types.PropertyGroup):
draw_gizmos_in_3d_viewport: bool
_gizmo_pref_entry = None
for _gizmo_pref_entry in tool.Parametric.EDIT_TYPES:
GizmoPreferences.__annotations__[_gizmo_pref_entry.name] = BoolProperty(
name=_gizmo_pref_entry.name.replace("_", " ").title(),
default=True,
)
assert _gizmo_pref_entry is not None
del _gizmo_pref_entry
@@ -396,14 +394,12 @@ class DefaultParameters(bpy.types.PropertyGroup):
and gives the create operator a preset to copy from."""
_default_params_entry = None
for _default_params_entry in tool.Parametric.EDIT_TYPES:
if not _default_params_entry.has_default_parameters:
continue
DefaultParameters.__annotations__[_default_params_entry.name] = bpy.props.PointerProperty(
type=getattr(_model_prop, _default_params_entry.props_attr),
)
assert _default_params_entry is not None
del _default_params_entry
@@ -581,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",
@@ -730,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"]
@@ -881,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:")
+3 -7
View File
@@ -74,11 +74,10 @@ def add_instance_ceiling_covering_from_cursor(
if not relating_type.is_a("IfcCoveringType"):
relating_type = None
ceiling_height = None
if selected_objects and active_obj:
x, y, z, _, _ = spatial.get_x_y_z_h_mat_from_obj(active_obj)
x, y, z, h, mat = spatial.get_x_y_z_h_mat_from_obj(active_obj)
else:
x, y, z, _, _ = spatial.get_x_y_z_h_mat_from_cursor()
x, y, z, h, mat = spatial.get_x_y_z_h_mat_from_cursor()
ceiling_height = covering.get_z_from_ceiling_height()
space_polygon = spatial.get_space_polygon_from_context_visible_objects(x, y)
@@ -88,7 +87,6 @@ def add_instance_ceiling_covering_from_cursor(
obj = spatial.create_object("Covering")
spatial.set_obj_origin_to_cursor_position_and_zero_elevation(obj)
assert ceiling_height is not None
spatial.translate_obj_to_z_location(obj, z + ceiling_height)
spatial.assign_type_to_obj(obj)
spatial.set_covering_representation_from_polygon(obj, space_polygon, polygon_is_si=True)
@@ -102,9 +100,7 @@ def regen_selected_covering_object(root: type[tool.Root], spatial: type[tool.Spa
selected_objects = spatial.get_selected_objects()
if selected_objects and active_obj:
x, y, _, _, _ = spatial.get_x_y_z_h_mat_from_obj(active_obj)
else:
assert False, "Object has to be active and selected."
x, y, z, h, mat = spatial.get_x_y_z_h_mat_from_obj(active_obj)
space_polygon = spatial.get_space_polygon_from_context_visible_objects(x, y)
+50 -37
View File
@@ -303,7 +303,17 @@ def add_drawing(
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"})
@@ -342,7 +352,19 @@ 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)
@@ -373,7 +395,17 @@ def duplicate_drawing(
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"})
@@ -394,7 +426,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)
@@ -411,37 +455,6 @@ def duplicate_drawing(
return new_drawing
def copy_annotations_to_drawing(
ifc: type[tool.Ifc],
collector: type[tool.Collector],
drawing_tool: type[tool.Drawing],
geometry: type[tool.Geometry],
annotations: list[ifcopenshell.entity_instance],
target_drawing: ifcopenshell.entity_instance,
) -> list[ifcopenshell.entity_instance]:
"""Duplicate annotations into another drawing, leaving the originals untouched."""
target_group = drawing_tool.get_drawing_group(target_drawing)
if not target_group:
return []
annotations = [a for a in annotations if drawing_tool.get_annotation_drawing(a) != target_drawing]
annotation_objs = [obj for a in annotations if (obj := ifc.get_object(a))]
if not annotation_objs:
return []
camera = ifc.get_object(target_drawing) or drawing_tool.import_drawing(target_drawing)
old_to_new, _ = geometry.duplicate_ifc_objects(annotation_objs)
copied: list[ifcopenshell.entity_instance] = []
for new_elements in old_to_new.values():
for new_element in new_elements:
if old_group := drawing_tool.get_drawing_group(new_element):
ifc.run("group.unassign_group", group=old_group, products=[new_element])
ifc.run("group.assign_group", group=target_group, products=[new_element])
new_obj = ifc.get_object(new_element)
drawing_tool.ensure_annotation_in_drawing_plane(new_obj, camera)
collector.assign(new_obj, should_clean_users_collection=True)
copied.append(new_element)
return copied
def remove_drawing(
ifc: type[tool.Ifc], drawing_tool: type[tool.Drawing], drawing: ifcopenshell.entity_instance
) -> None:
@@ -528,7 +541,6 @@ def add_annotation(
drawing_tool.show_decorations()
obj = drawing_tool.create_annotation_object(drawing, object_type)
element = ifc.get_entity(obj)
relating_type_rep = None
if not element: # Brand new annotation
relating_type_rep = drawing_tool.get_annotation_representation(relating_type) if relating_type else None
element = drawing_tool.run_root_assign_class(
@@ -614,7 +626,8 @@ def sync_references(
for reference_element in potential_reference_elements:
if not drawing_tool.get_drawing_reference_annotation(drawing, reference_element):
if annotation := drawing_tool.generate_reference_annotation(drawing, reference_element, context):
annotation = drawing_tool.generate_reference_annotation(drawing, reference_element, context)
if annotation:
ifc.run("drawing.assign_product", relating_product=reference_element, related_object=annotation)
ifc.run("group.assign_group", group=group, products=[annotation])
collector.assign(ifc.get_object(annotation))
+65
View File
@@ -0,0 +1,65 @@
# 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) # ty: ignore[missing-argument]
if flip:
source_z += math.pi
rotated = 0
for obj in targets:
target_z = surveyor.get_z_rotation(obj) # ty: ignore[missing-argument]
if abs(_z_rotation_diff(target_z, source_z)) < Z_ROTATION_ALIGNMENT_TOLERANCE:
continue
surveyor.set_z_rotation(obj, source_z) # ty: ignore[missing-argument]
rotated += 1
if ifc.get_entity(obj) is not None:
bonsai.core.geometry.edit_object_placement(ifc, geometry, surveyor, obj=obj)
return rotated
+3 -6
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,9 +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_annotation_in_drawing_plane(cls, obj, camera=None): 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
@@ -368,7 +366,6 @@ class Drawing:
def generate_reference_attributes(cls, reference, **attributes): pass
def generate_sheet_identification(cls): pass
def get_annotation_context(cls, target_view, object_type=None): pass
def get_annotation_drawing(cls, element): pass
def get_annotation_representation(cls, element_type): pass
def get_assigned_product(cls, element): pass
def get_assigned_product_workaround(cls, element): pass
@@ -386,7 +383,6 @@ class Drawing:
def get_drawing_group(cls, drawing): pass
def get_drawing_references(cls, drawing): pass
def get_drawing_target_view(cls, drawing): pass
def get_group_drawing(cls, group): pass
def get_group_elements(cls, group): pass
def get_ifc_representation_class(cls, object_type): pass
def get_name(cls, element): pass
@@ -400,7 +396,6 @@ class Drawing:
def get_unit_system(cls): pass
def import_assigned_product(cls, obj): pass
def import_documents(cls, document_type): pass
def import_drawing(cls, drawing): pass
def import_drawings(cls): pass
def import_sheets(cls): pass
def import_text_attributes(cls, obj): pass
@@ -1175,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]:
-2
View File
@@ -981,7 +981,6 @@ class Cad:
has_found_connected_edge = True
loops.append(loop)
new_verts = None
for loop in loops:
all_verts = {v.index for e in loop for v in e.verts}
possible_v1s = []
@@ -1085,7 +1084,6 @@ class Cad:
break
v1 = v2
assert new_verts is not None
return new_verts
+2 -1
View File
@@ -120,7 +120,8 @@ class Collector(bonsai.core.tool.Collector):
project_obj = tool.Ifc.get_object(tool.Ifc.get().by_type("IfcProject")[0])
cls.link_collection_child_safe(tool.Blender.get_object_bim_props(project_obj).collection, collection)
elif element.is_a("IfcAnnotation") and (drawing_obj := cls.get_annotation_drawing_obj(element)):
cls.link_collection_object_safe(tool.Blender.get_object_bim_props(drawing_obj).collection, obj)
target_collection = tool.Blender.get_object_bim_props(drawing_obj).collection
cls.link_collection_object_safe(target_collection, obj)
elif container := ifcopenshell.util.element.get_container(element):
while container.is_a("IfcSpace"):
container = ifcopenshell.util.element.get_aggregate(container)
-2
View File
@@ -280,8 +280,6 @@ class Cost(bonsai.core.tool.Cost):
new = props.cost_item_processes.add()
elif related_object.is_a("IfcResource"):
new = props.cost_item_resources.add()
else:
assert False, related_object
new.ifc_definition_id = related_object.id()
new.name = related_object.Name or "Unnamed"
+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
+35 -70
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
@@ -756,17 +755,6 @@ class Drawing(bonsai.core.tool.Drawing):
if rel.is_a("IfcRelAssignsToGroup") and rel.RelatingGroup.ObjectType == "DRAWING":
return rel.RelatingGroup
@classmethod
def get_group_drawing(cls, group: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]:
"""Get the drawing that owns this group, if the group represents a drawing."""
if group.ObjectType != "DRAWING":
return None
for rel in group.IsGroupedBy or []:
for related_object in rel.RelatedObjects:
if related_object.is_a("IfcAnnotation") and related_object.ObjectType == "DRAWING":
return related_object
return None
@classmethod
def get_drawing_document(cls, drawing: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance:
for rel in drawing.HasAssociations:
@@ -785,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 []:
@@ -1083,12 +1045,6 @@ class Drawing(bonsai.core.tool.Drawing):
camera_props.has_annotation = True
camera_props.target_view = "PLAN_VIEW"
camera_props.is_nts = False
camera_props.use_edge_classification = False
camera_props.render_creases = True
camera_props.valley_angle_min_degrees = 12.0
camera_props.render_sharp = True
camera_props.ridge_angle_min_degrees = 45.0
camera_props.render_flush = False
camera.shift_x = 0.0
camera.shift_y = 0.0
@@ -1118,18 +1074,6 @@ class Drawing(bonsai.core.tool.Drawing):
camera_props.has_annotation = bool(pset["HasAnnotation"])
if "IsNTS" in pset:
camera_props.is_nts = bool(pset["IsNTS"])
if "UseEdgeClassification" in pset:
camera_props.use_edge_classification = bool(pset["UseEdgeClassification"])
if "RenderCreases" in pset:
camera_props.render_creases = bool(pset["RenderCreases"])
if "ValleyAngleMinDegrees" in pset:
camera_props.valley_angle_min_degrees = float(pset["ValleyAngleMinDegrees"])
if "RenderSharp" in pset:
camera_props.render_sharp = bool(pset["RenderSharp"])
if "RidgeAngleMinDegrees" in pset:
camera_props.ridge_angle_min_degrees = float(pset["RidgeAngleMinDegrees"])
if "RenderFlush" in pset:
camera_props.render_flush = bool(pset["RenderFlush"])
if "DPI" in pset:
camera_props.dpi = int(pset["DPI"])
if "LineworkMode" in pset:
@@ -1196,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:
@@ -1237,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
@@ -2003,19 +1953,29 @@ class Drawing(bonsai.core.tool.Drawing):
if camera.data.type != "ORTHO":
return
settings = ifcopenshell.geom.settings()
settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS)
geometry = ifcopenshell.geom.create_shape(settings, axis.AxisCurve)
verts = ifcopenshell.util.shape.get_vertices(geometry)
grid = (axis.PartOfU or axis.PartOfV or axis.PartOfW)[0]
m = ifcopenshell.util.placement.get_local_placement(grid.ObjectPlacement)
axis_obj = tool.Ifc.get_object(axis)
if axis_obj and axis_obj.data and len(axis_obj.data.vertices) >= 2:
m = np.array(axis_obj.matrix_world)
verts = [np.array(v.co) for v in axis_obj.data.vertices[:2]]
else:
settings = ifcopenshell.geom.settings()
settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS)
geometry = ifcopenshell.geom.create_shape(settings, axis.AxisCurve)
verts = list(ifcopenshell.util.shape.get_vertices(geometry)[:2])
grid_obj = tool.Ifc.get_object(grid)
if grid_obj:
m = np.array(grid_obj.matrix_world)
else:
m = ifcopenshell.util.placement.get_local_placement(grid.ObjectPlacement)
im = camera.matrix_world.inverted()
v1, v2 = [im @ Vector((m @ np.append(v, 1.0))[:3]) for v in verts[:2]]
v1, v2 = [im @ Vector((m @ np.append(v[:3], 1.0))[:3]) for v in verts]
target_view = tool.Drawing.get_drawing_target_view(drawing)
if target_view in ("PLAN_VIEW", "REFLECTED_PLAN_VIEW"):
bounds = helper.ortho_view_frame(camera.data)
if not (points := helper.clip_segment(bounds, [v1, v2])):
points = helper.clip_segment(bounds, [v1, v2])
if not points:
return
elif target_view in ("ELEVATION_VIEW", "SECTION_VIEW"):
bounds = helper.ortho_view_frame(camera.data)
@@ -2233,6 +2193,7 @@ class Drawing(bonsai.core.tool.Drawing):
def sync_object_placement(cls, obj: bpy.types.Object) -> Union[ifcopenshell.entity_instance, None]:
blender_matrix = np.array(obj.matrix_world)
element = tool.Ifc.get_entity(obj)
is_moved = tool.Ifc.is_moved(obj)
if tool.Geometry.is_scaled(obj):
bpy.ops.bim.update_representation(obj=obj.name)
return element
@@ -2249,7 +2210,8 @@ class Drawing(bonsai.core.tool.Drawing):
grid_obj = tool.Ifc.get_object(grid)
if grid_obj:
cls.sync_object_placement(grid_obj)
if grid_obj.matrix_world != obj.matrix_world:
matrices_differ = grid_obj.matrix_world != obj.matrix_world
if matrices_differ:
bpy.ops.bim.update_representation(obj=obj.name)
tool.Geometry.record_object_position(obj)
@@ -2473,8 +2435,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:
@@ -2604,15 +2567,16 @@ class Drawing(bonsai.core.tool.Drawing):
if not obj:
continue
current_representation = tool.Geometry.get_active_representation(obj)
current_representation_subcontext = None
if current_representation:
subcontext = current_representation.ContextOfItems
current_representation_subcontext = tool.Geometry.get_subcontext_parameters(subcontext)
has_context = False
for subcontext in subcontexts:
# prioritize already active representation if it matches the subcontext
# (element could have multiple representations in the same subcontext)
if current_representation_subcontext and subcontext == current_representation_subcontext:
if current_representation and subcontext == current_representation_subcontext:
has_context = True
break
priority_representation = ifcopenshell.util.representation.get_representation(element, *subcontext)
if priority_representation:
@@ -2622,6 +2586,7 @@ class Drawing(bonsai.core.tool.Drawing):
obj=obj,
representation=priority_representation,
)
has_context = True
break
linked_handles: set[bpy.types.Object] = set()
+2 -2
View File
@@ -23,6 +23,7 @@ from typing import TYPE_CHECKING
import bpy
import ifcopenshell.api.feature
import ifcopenshell.util.representation
import bonsai.core.geometry
import bonsai.core.tool
@@ -49,7 +50,6 @@ class Feature(bonsai.core.tool.Feature):
has_visible_openings = True
break
element_had_openings = None
for feature_obj in feature_objs:
feature_element = tool.Ifc.get_entity(feature_obj)
@@ -58,6 +58,7 @@ class Feature(bonsai.core.tool.Feature):
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=featured_obj)
element_had_openings = tool.Geometry.has_openings(featured_element)
body_context = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Model", "Body")
ifcopenshell.api.feature.add_feature(tool.Ifc.get(), feature=feature_element, element=featured_element)
if tool.Ifc.is_moved(feature_obj):
@@ -72,7 +73,6 @@ class Feature(bonsai.core.tool.Feature):
if voided_obj.data:
if tool.Ifc.is_edited(voided_obj):
voided_element_ = tool.Ifc.get_entity(voided_obj)
assert element_had_openings is not None
if element_had_openings or (voided_element_ != featured_element and voided_element_.HasOpenings):
voided_obj.scale = (1.0, 1.0, 1.0)
tool.Ifc.finish_edit(voided_obj)
+9 -18
View File
@@ -33,6 +33,7 @@ from typing import (
Optional,
TypeGuard,
Union,
cast,
get_args,
)
@@ -757,7 +758,6 @@ class Geometry(bonsai.core.tool.Geometry):
# its centroid not obscured (tested via raycasting) by any other
# face.
distance = max(obj.dimensions.xyz)
min_y, max_z = None, None
if axis == "+Z":
max_z = max([co[2] for co in obj.bound_box]) + 0.002
direction = Vector((0, 0, -1))
@@ -772,10 +772,8 @@ class Geometry(bonsai.core.tool.Geometry):
if direction.dot(face.normal) > 0:
continue
if axis == "+Z":
assert max_z is not None
face_centroid_at_max = Vector((*face.calc_center_median().xy, max_z))
elif axis == "-Y":
assert min_y is not None
centroid = face.calc_center_median()
face_centroid_at_max = Vector((centroid.x, min_y, centroid.z))
face_centroid_at_max = obj.matrix_world @ face_centroid_at_max
@@ -1151,9 +1149,6 @@ class Geometry(bonsai.core.tool.Geometry):
settings.set("layerset-first", True)
settings.set("keep-bounding-boxes", True)
settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS)
settings.set("mesher-linear-deflection", ifc_import_settings.deflection_tolerance)
settings.set("mesher-angular-deflection", ifc_import_settings.angular_tolerance)
geometry_library = ifc_import_settings.geometry_library
ifc_importer = bonsai.bim.import_ifc.IfcImporter(ifc_import_settings)
ifc_importer.file = tool.Ifc.get()
@@ -1165,11 +1160,7 @@ class Geometry(bonsai.core.tool.Geometry):
shape = None
if elements:
iterator = ifcopenshell.geom.iterator(
settings,
tool.Ifc.get(),
multiprocessing.cpu_count(),
include=elements,
geometry_library=geometry_library,
settings, tool.Ifc.get(), multiprocessing.cpu_count(), include=elements
)
else:
iterator = None # For example, when switching representation of a type with no occurrences
@@ -1224,9 +1215,7 @@ class Geometry(bonsai.core.tool.Geometry):
for element in element_types:
if obj := tool.Ifc.get_object(element):
if representation := ifcopenshell.util.representation.get_representation(element, context):
geometry = ifcopenshell.geom.create_shape(
settings, representation, geometry_library=geometry_library
)
geometry = ifcopenshell.geom.create_shape(settings, representation)
mesh_name = tool.Loader.get_mesh_name_from_shape(geometry)
mesh = meshes.get(mesh_name)
if mesh is None:
@@ -1897,7 +1886,6 @@ class Geometry(bonsai.core.tool.Geometry):
"""NOTE: we assume that all items belonged to the same representation and to the same shape aspect"""
ifc_file = tool.Ifc.get()
previous_shape_aspect = None
base_representation = None
for inverse in ifc_file.get_inverse(representation_items[0]):
if inverse.is_a("IfcShapeRepresentation"):
if inverse.OfShapeAspect:
@@ -1907,7 +1895,6 @@ class Geometry(bonsai.core.tool.Geometry):
previous_shape_aspect = inverse.OfShapeAspect[0]
else:
base_representation = inverse
assert base_representation
# remove item from previous shape aspect
if previous_shape_aspect:
@@ -2200,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 == "-":
@@ -2225,7 +2212,6 @@ class Geometry(bonsai.core.tool.Geometry):
assert item
obj.data.clear_geometry()
cartesian_point_offset = None
if item.is_a("IfcHalfSpaceSolid"):
bm = bmesh.new()
bmesh.ops.create_grid(bm, size=0.5)
@@ -2684,6 +2670,11 @@ class Geometry(bonsai.core.tool.Geometry):
# copy the actual class
new = bonsai.core.root.copy_class(tool.Ifc, tool.Collector, tool.Geometry, tool.Root, obj=new_obj)
# Give each duplicated IfcGridAxis its own AxisCurve so it doesn't
# share geometry with the source axis.
if new and new.is_a("IfcGridAxis"):
tool.Model.create_axis_curve(new_obj, new)
# clean up the orphaned mesh with ifc id of the original object to avoid confusion
# IfcGridAxis keeps the same mesh data (it's pointing to ifc id 0, so it's not a problem)
if new and temp_data and not new.is_a("IfcGridAxis"):
+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
+4 -10
View File
@@ -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)
@@ -1087,21 +1088,18 @@ class Loader(bonsai.core.tool.Loader):
bm = bmesh.new()
bm.from_mesh(mesh)
prev_co = None
layer_set_direction = usage.LayerSetDirection
if layer_set_direction == "AXIS2":
if usage.LayerSetDirection == "AXIS2":
co = Vector((0.0, offset, 0.0))
no = cls.get_extrusion_vector(element).normalized()
no = no.cross(Vector([1.0, 0.0, 0.0]))
elif layer_set_direction == "AXIS3":
elif usage.LayerSetDirection == "AXIS3":
co = Vector((0.0, 0.0, offset))
no = cls.get_extrusion_vector(element).normalized()
no = Vector([0.0, 0.0, 1.0])
elif layer_set_direction == "AXIS1":
elif usage.LayerSetDirection == "AXIS1":
co = Vector((0.0, 0.0, offset))
no = cls.get_extrusion_vector(element).normalized()
no = Vector([1.0, 0.0, 0.0])
else:
assert False, layer_set_direction
no *= sense_factor
# Cache this
body = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Model", "Body", "MODEL_VIEW")
@@ -1111,7 +1109,6 @@ class Loader(bonsai.core.tool.Loader):
if style := tool.Ifc.get_entity(material):
styles[style] = i
last_i = len(layer_set.MaterialLayers) - 1
bisect_geom = None
for i, layer in enumerate(layer_set.MaterialLayers):
if i != last_i:
prev_co = co.copy()
@@ -1125,7 +1122,6 @@ class Loader(bonsai.core.tool.Loader):
if (material_index := styles.get(style, None)) is None:
material_index = len(mesh.materials)
mesh.materials.append(tool.Ifc.get_object(style))
assert bisect_geom is not None
if i == last_i:
for face in bisect_geom["geom"]:
if isinstance(face, bmesh.types.BMFace):
@@ -1291,7 +1287,6 @@ class Loader(bonsai.core.tool.Loader):
polyline.material_index = material_index
return polyline
item = None
for item_data, item_style in zip(rep_items, item_styles):
item = item_data["item"]
@@ -1319,7 +1314,6 @@ class Loader(bonsai.core.tool.Loader):
polyline.points.add(1)
polyline.points[-1].co = native_data["matrix"] @ Vector(v2)
assert item is not None
curve.bevel_depth = unit_scale * item.Radius
thickness = None
if (inner_radius := item.InnerRadius) and (thickness := max(item.Radius - inner_radius, 0)):
-6
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
@@ -220,12 +216,10 @@ class Misc(bonsai.core.tool.Misc):
related_objects.append((element, ifcopenshell.util.placement.get_storey_elevation(element)))
related_objects = sorted(related_objects, key=lambda e: e[1])
storey_elevation = None
i = None
for i, related_object in enumerate(related_objects):
if related_object[0] == storey:
storey_elevation = related_object[1]
break
assert i is not None
if i + total_storeys < len(related_objects):
next_storey_elevation = related_objects[i + total_storeys][1]
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
+34 -48
View File
@@ -2292,18 +2292,32 @@ class Model(bonsai.core.tool.Model):
deform_layer = bm.verts.layers.deform.active
# Sanity check
group_verts = {"IFCARCINDEX": {}, "IFCCIRCLE": {}}
if deform_layer:
for vert in bm.verts:
vert_group_indices = tool.Blender.bmesh_get_vertex_groups(vert, deform_layer)
is_circle = any(gi in groups["IFCCIRCLE"] for gi in vert_group_indices)
is_arc = any(gi in groups["IFCARCINDEX"] for gi in vert_group_indices)
if (is_circle or is_arc) and not vert.link_edges:
return (False, "CIRCLE" if is_circle else "3POINT_ARC")
is_circle = False
for group_index in vert_group_indices:
group_type = "IFCARCINDEX" if group_index in groups["IFCARCINDEX"] else "IFCCIRCLE"
group_verts[group_type].setdefault(group_index, 0)
group_verts[group_type][group_index] += 1
if group_type == "IFCCIRCLE":
is_circle = True
if is_circle:
pass # Circles are allowed to be unclosed
elif len(vert.link_edges) != 2: # Unclosed loop or forked loop
return (False, "UNCLOSED_LOOP")
for group_type, group_counts in group_verts.items():
if group_type == "IFCARCINDEX":
for group_count in group_counts.values():
if group_count != 3: # Each arc needs 3 verts
return (False, "3POINT_ARC")
elif group_type == "IFCCIRCLE":
for group_count in group_counts.values():
if group_count != 2: # Each circle needs 2 verts
return (False, "CIRCLE")
loop_edges = list(bm.edges)
# Create loops from edges
@@ -2326,28 +2340,6 @@ class Model(bonsai.core.tool.Model):
has_found_connected_edge = True
loops.append(loop)
# Sanity check, per loop rather than across the whole mesh
if deform_layer:
for loop in loops:
loop_group_counts = {"IFCARCINDEX": {}, "IFCCIRCLE": {}}
loop_verts = {v for edge in loop for v in edge.verts}
for vert in loop_verts:
for group_index in tool.Blender.bmesh_get_vertex_groups(vert, deform_layer):
if group_index in groups["IFCARCINDEX"]:
group_type = "IFCARCINDEX"
elif group_index in groups["IFCCIRCLE"]:
group_type = "IFCCIRCLE"
else:
continue
loop_group_counts[group_type].setdefault(group_index, 0)
loop_group_counts[group_type][group_index] += 1
for group_count in loop_group_counts["IFCARCINDEX"].values():
if group_count != 3: # Each arc needs 3 verts
return (False, "3POINT_ARC")
for group_count in loop_group_counts["IFCCIRCLE"].values():
if group_count != 2: # Each circle needs 2 verts
return (False, "CIRCLE")
tmp = ifcopenshell.file(schema=tool.Ifc.get().schema)
def is_in_group(v: bmesh.types.BMVert, group_name: str) -> bool:
@@ -2528,11 +2520,27 @@ class Model(bonsai.core.tool.Model):
deform_layer = bm.verts.layers.deform.active
# Sanity check
group_verts = {"IFCARCINDEX": {}, "IFCCIRCLE": {}}
if deform_layer:
for vert in bm.verts:
vert_group_indices = tool.Blender.bmesh_get_vertex_groups(vert, deform_layer)
for group_index in vert_group_indices:
group_type = "IFCARCINDEX" if group_index in groups["IFCARCINDEX"] else "IFCCIRCLE"
group_verts[group_type].setdefault(group_index, 0)
group_verts[group_type][group_index] += 1
if len(vert.link_edges) > 2: # Forked loop
return (False, "FORKED_LOOP")
for group_type, group_counts in group_verts.items():
if group_type == "IFCARCINDEX":
for group_count in group_counts.values():
if group_count != 3: # Each arc needs 3 verts
return (False, "3POINT_ARC")
elif group_type == "IFCCIRCLE":
for group_count in group_counts.values():
if group_count != 2: # Each circle needs 2 verts
return (False, "CIRCLE")
loop_edges = list(bm.edges)
# Create loops from edges
@@ -2555,28 +2563,6 @@ class Model(bonsai.core.tool.Model):
has_found_connected_edge = True
loops.append(loop)
# Sanity check, per loop rather than across the whole mesh
if deform_layer:
for loop in loops:
loop_group_counts = {"IFCARCINDEX": {}, "IFCCIRCLE": {}}
loop_verts = {v for edge in loop for v in edge.verts}
for vert in loop_verts:
for group_index in tool.Blender.bmesh_get_vertex_groups(vert, deform_layer):
if group_index in groups["IFCARCINDEX"]:
group_type = "IFCARCINDEX"
elif group_index in groups["IFCCIRCLE"]:
group_type = "IFCCIRCLE"
else:
continue
loop_group_counts[group_type].setdefault(group_index, 0)
loop_group_counts[group_type][group_index] += 1
for group_count in loop_group_counts["IFCARCINDEX"].values():
if group_count != 3: # Each arc needs 3 verts
return (False, "3POINT_ARC")
for group_count in loop_group_counts["IFCCIRCLE"].values():
if group_count != 2: # Each circle needs 2 verts
return (False, "CIRCLE")
tmp = ifcopenshell.file(schema=tool.Ifc.get().schema)
def is_in_group(v: bmesh.types.BMVert, group_name: str) -> bool:
-2
View File
@@ -641,8 +641,6 @@ del _edit_type_names
# call sites can reference ``tool.Parametric.ROOF`` directly. Renaming a
# registry entry renames the constant; a typo at the call site surfaces as
# AttributeError at module load.
_entry = None
for _entry in Parametric.EDIT_TYPES:
setattr(Parametric, _entry.name.upper(), _entry)
assert _entry is not None
del _entry
-2
View File
@@ -168,7 +168,6 @@ class Polyline(bonsai.core.tool.Polyline):
distance = (mouse_vector - last_point).length
if distance < 0:
return
angle, orientation_angle, angle_round_threshold = None, None, None
if distance > 0:
angle = tool.Cad.angle_3_vectors(
second_to_last_point, last_point, mouse_vector, new_angle=None, degrees=True
@@ -189,7 +188,6 @@ class Polyline(bonsai.core.tool.Polyline):
angle = 0
orientation_angle = 0
if input_ui:
assert angle is not None and orientation_angle is not None and angle_round_threshold is not None
if should_round:
angle_snap = tool.Snap.get_angle_snap_value(context)
angle = angle_snap * round(angle / angle_snap) if distance < angle_round_threshold else angle
+3 -6
View File
@@ -370,21 +370,18 @@ class Project(bonsai.core.tool.Project):
props = cls.get_project_props()
active_library_breadcrumb = props.get_active_library_breadcrumb()
change_back = False
breadcrumb = None
if active_library_breadcrumb:
name = active_library_breadcrumb.name
breadcrumb_type = active_library_breadcrumb.breadcrumb_type
library_id = active_library_breadcrumb.library_id
breadcrumb = (name, breadcrumb_type, library_id)
change_back = True
bpy.ops.bim.rewind_library()
if change_back:
assert breadcrumb
bpy.ops.bim.change_library_element(
element_name=breadcrumb[0],
breadcrumb_type=breadcrumb[1],
library_id=breadcrumb[2],
element_name=name,
breadcrumb_type=breadcrumb_type,
library_id=library_id,
)
@classmethod
-3
View File
@@ -124,9 +124,6 @@ class Pset(bonsai.core.tool.Pset):
return bpy.context.scene.GroupPsetProperties
elif obj_type == "Zone":
return bpy.context.scene.ZonePsetProperties
elif obj_type == "Cost":
# No psets for cost items currently.
assert False, obj_type
assert_never(obj_type)
@classmethod
+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
-4
View File
@@ -505,8 +505,6 @@ class Search(bonsai.core.tool.Search):
(0.773, 0.922, 0.816),
(0.871, 0.957, 0.894),
]
else:
assert False, theme
if value < min_val:
value = min_val
@@ -576,10 +574,8 @@ class ImportFilterQueryTransformer(lark.Transformer):
new = self.filter_groups.add()
global_ids = []
is_first_group = len(self.filter_groups) == 1
new2 = None
for filter_index, arg in enumerate(args):
if arg["type"] == "instance" and global_ids:
assert new2
if "bpy.data.texts" in new2.value:
data_name = new2.value.split("bpy.data.texts")[1][2:-2]
bpy.data.texts[data_name].write("," + arg["value"])

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