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
67 changed files with 260 additions and 1774 deletions
-3
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/
@@ -129,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 -5
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
-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
+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 -6
View File
@@ -980,13 +980,8 @@ class IfcImporter:
if unit.Name == "METRE":
if not unit.Prefix:
bpy.context.scene.unit_settings.length_unit = "METERS"
elif f"{unit.Prefix}METERS" in ("KILOMETERS", "CENTIMETERS", "MILLIMETERS", "MICROMETERS"):
bpy.context.scene.unit_settings.length_unit = f"{unit.Prefix}METERS"
else:
# Blender's length_unit enum has no entry for other
# SI prefixes (e.g. DECIMETERS), so fall back to
# adaptive display instead of failing to open.
bpy.context.scene.unit_settings.length_unit = "ADAPTIVE"
bpy.context.scene.unit_settings.length_unit = f"{unit.Prefix}METERS"
else:
bpy.context.scene.unit_settings.system = "IMPERIAL"
name = unit.Name.lower()
@@ -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)
@@ -1706,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(),
),
)
@@ -2349,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()
@@ -2373,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
@@ -2406,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)
@@ -2522,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"
)
@@ -3446,40 +3446,43 @@ class UnassignRepresentationItemStyle(bpy.types.Operator, tool.Ifc.Operator):
self.report({"ERROR"}, "Couldn't find any styles associated with the active representation item.")
return {"CANCELLED"}
# Resolve an object's active representation down to its base geometry items,
# unwrapping mapped items (Revit families) and boolean results (openings/cuts)
# so we reach the items that actually carry styles.
def get_base_representation_items(obj):
representation = tool.Geometry.get_active_representation(obj)
if not representation or not representation.is_a("IfcRepresentation"):
return
yield from ifcopenshell.util.representation.resolve_base_items(representation)
# Unassign matching styles from the active object itself
for style in active_styles:
tool.Style.assign_style_to_representation_item(active_representation_item, None)
tool.Geometry.reload_representation(active_obj)
break # No need to check further if one matching style is found
# Unassign the style from the active representation item.
tool.Style.assign_style_to_representation_item(active_representation_item, None)
tool.Geometry.reload_representation(active_obj)
# Iterate over other selected objects and unassign matching styles.
# Iterate over selected objects and unassign matching styles
for obj in context.selected_objects:
if obj == active_obj:
continue
continue # Skip the active object itself
for item in get_base_representation_items(obj):
item_styles = set()
if hasattr(item, "StyledByItem"):
for styled_by_item in item.StyledByItem:
# Get the IFC entity directly from the object
element = tool.Ifc.get_entity(obj)
if not element:
continue # Skip if no IFC entity is found
representation_item_id = element.Representation.Representations[0].Items[0].id()
representation_item = tool.Ifc.get_entity_by_id(representation_item_id)
if representation_item:
# Retrieve styles for the current representation item
styles = set()
if hasattr(representation_item, "StyledByItem"):
for styled_by_item in representation_item.StyledByItem:
if hasattr(styled_by_item, "Styles"):
item_styles.update(styled_by_item.Styles)
styles.update(styled_by_item.Styles)
if item_styles & active_styles:
tool.Style.assign_style_to_representation_item(item, None)
tool.Geometry.reload_representation(obj)
break # Only remove one matching style per object
# Unassign matching styles
for style in styles:
if style in active_styles:
tool.Style.assign_style_to_representation_item(representation_item, None)
tool.Geometry.reload_representation(obj)
break # No need to check further if one matching style is found
# Reload UI items
bpy.ops.bim.disable_editing_representation_items()
bpy.ops.bim.enable_editing_representation_items()
return {"FINISHED"}
class EnableEditingRepresentationItemShapeAspect(bpy.types.Operator, tool.Ifc.Operator):
@@ -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)
@@ -985,10 +985,8 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
),
default=False,
)
skip_autosave_recovery: bpy.props.BoolProperty(default=False, options={"HIDDEN", "SKIP_SAVE"})
use_detailed_tooltip: bpy.props.BoolProperty(default=False, options={"HIDDEN"})
filename_ext = ".ifc"
skip_recent: bpy.props.BoolProperty(default=False, options={"HIDDEN", "SKIP_SAVE"})
if TYPE_CHECKING:
filepath: str
@@ -997,7 +995,6 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
use_relative_path: bool
should_start_fresh_session: bool
import_without_ifc_data: bool
skip_autosave_recovery: bool
use_detailed_tooltip: bool
@classmethod
@@ -1044,33 +1041,7 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
return tooltip
def check_autosave_recovery(self, context: bpy.types.Context) -> bool:
if self.skip_autosave_recovery:
return False
autosaved_filepath = tool.Autosave.get_newer_autosaved_path(self.get_filepath_abs())
if not autosaved_filepath:
return False
# Fire-and-forget: don't propagate this popup's own RUNNING_MODAL
# return value up as if *this* operator were running modally too -
# we never call modal_handler_add() on ourselves, so the window
# manager would be left tracking a modal operator with no handler,
# corrupting its operator bookkeeping until it crashes later when
# the (real) popup modal handler is closed.
bpy.ops.bim.load_autosaved_recovery_popup(
"INVOKE_DEFAULT",
original_filepath=str(self.get_filepath_abs()),
autosaved_filepath=autosaved_filepath,
is_advanced=self.is_advanced,
use_relative_path=self.use_relative_path,
should_start_fresh_session=self.should_start_fresh_session,
import_without_ifc_data=self.import_without_ifc_data,
)
return True
def execute(self, context):
if self.check_autosave_recovery(context):
return {"FINISHED"}
if (
tool.Blender.get_addon_preferences().save_metadata_blend_file
and self.should_start_fresh_session
@@ -1165,8 +1136,7 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
props.should_save_metadata_for_this_file = metadata_doc is not None
tool.Blender.register_toolbar()
if not self.skip_recent:
tool.Project.add_recent_ifc_project(self.get_filepath_abs())
tool.Project.add_recent_ifc_project(self.get_filepath_abs())
if self.is_advanced:
pass
@@ -1179,13 +1149,10 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
except:
bonsai.last_error = traceback.format_exc()
raise
tool.Autosave.reset_timer()
return {"FINISHED"}
def invoke(self, context, event):
if self.filepath:
if self.check_autosave_recovery(context):
return {"FINISHED"}
return self.execute(context)
return ImportHelper.invoke(self, context, event)
@@ -1980,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
@@ -2041,18 +2007,6 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
return {"FINISHED"}
def _execute(self, context):
project_props = tool.Project.get_project_props()
project_props.use_relative_project_path = self.use_relative_path
# Fallback if filepath is not set
if not getattr(self, "filepath", None) or self.filepath.strip() in ("", ".ifc"):
props = tool.Blender.get_bim_props()
if props.ifc_file:
self.filepath = str(tool.Blender.ensure_blender_path_is_abs(Path(props.ifc_file)))
else:
self.report({"ERROR"}, "No filepath available for saving.")
return {"CANCELLED"}
committed, failed_commits = tool.Parametric.commit_pending_edits()
# Previews are session-transient — discard rather than commit. Sibling
# gizmo polls gate on each preview's is_active flag, and a stuck flag
@@ -2115,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("//"))
@@ -2150,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):
@@ -2159,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"
+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:
-46
View File
@@ -577,43 +577,6 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
should_disable_undo_on_save: BoolProperty(
name="Disable Undo When Saving (Faster saves, no undo for you!)", default=False
)
def update_autosave_settings(self, context: bpy.types.Context) -> None:
if self.autosave_enabled:
tool.Autosave.reset_timer()
else:
tool.Autosave.cancel_timer()
autosave_enabled: BoolProperty(
name="Enable IFC Autosave Timer",
description="Periodically remind you to save or automatically create a backup copy of the IFC file",
default=False,
update=update_autosave_settings,
)
autosave_interval_minutes: bpy.props.IntProperty(
name="Autosave Interval (Minutes)",
description="Time between autosave reminders or backups. The timer resets whenever you open or save a project",
default=10,
min=1,
max=1440,
update=update_autosave_settings,
)
autosave_mode: bpy.props.EnumProperty(
name="Autosave Mode",
items=[
(
"PROMPT",
"Prompt to Save",
"Show a dialog offering to save the IFC project when the timer expires",
),
(
"BACKUP",
"Automatic Backup",
"Save a backup copy as filename_autosaved.ifc when the timer expires",
),
],
default="PROMPT",
)
should_stream: BoolProperty(name="Stream Data From IFC-SPF (Only for advanced users)", default=False)
should_always_cache: BoolProperty(
name="Always Cache Geometry",
@@ -726,9 +689,6 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
bsdd_load_test_dictionaries: bool
bsdd_baseurl: str
should_disable_undo_on_save: bool
autosave_enabled: bool
autosave_interval_minutes: int
autosave_mode: Literal["PROMPT", "BACKUP"]
should_stream: bool
should_always_cache: bool
occurrence_name_style: Literal["CLASS", "TYPE", "CUSTOM"]
@@ -877,12 +837,6 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
def draw_other_settings(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
layout.prop(self, "opening_focus_opacity")
layout.prop(self, "should_disable_undo_on_save")
layout.separator()
layout.label(text="Autosave:")
layout.prop(self, "autosave_enabled")
if self.autosave_enabled:
layout.prop(self, "autosave_interval_minutes")
layout.prop(self, "autosave_mode")
layout.prop(self, "should_stream")
layout.prop(self, "should_always_cache")
layout.label(text="bSDD:")
+2 -1
View File
@@ -626,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))
+1
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
-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)
+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)
+20 -8
View File
@@ -1953,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)
@@ -2183,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
@@ -2199,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)
+5
View File
@@ -2670,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
+2 -2
View File
@@ -38,8 +38,8 @@ Hope your day's going well. :)
<script>
// Define the mapping of versions to URLs
const versionURLs = {
stable: 'https://docs.bonsaibim.org/',
unstable: 'https://docs-unstable.bonsaibim.org/',
stable: 'http://docs.bonsaibim.org/',
unstable: 'http://docs-unstable.bonsaibim.org/',
// Add more versions here as needed
};
@@ -77,3 +77,18 @@ the image below. Three simple open source online viewers you can test with are
<https://3dviewer.net/>`__.
.. image:: images/ifc-pipeline.png
Placing occurrences of an element type
--------------------------------------
TODO
Changing the locations of elements
----------------------------------
TODO
Modeling a simple building
--------------------------
TODO
+2 -2
View File
@@ -12,7 +12,7 @@ Scenario: Ensure added booleans are marked as manual
And I click "OK"
And the object "IfcFurniture/Unnamed" exists
And I toggle edit mode
And the object "Item/IfcExtrudedAreaSolid/73" exists
And the object "Item/IfcExtrudedAreaSolid/77" exists
And I open the "Add Item" menu
When I click "Half Space Solid"
And the object "Item/IfcHalfSpaceSolid/90" exists
@@ -33,7 +33,7 @@ Scenario: Ensure removed booleans are unmarked as manual
And I click "OK"
And the object "IfcFurniture/Unnamed" exists
And I toggle edit mode
And the object "Item/IfcExtrudedAreaSolid/73" exists
And the object "Item/IfcExtrudedAreaSolid/77" exists
And I open the "Add Item" menu
And I click "Half Space Solid"
And I deselect all objects
@@ -920,6 +920,21 @@ Scenario: Export IFC - with moved grid axis location synchronised
And I load previously saved IFC project
Then the object "IfcGridAxis/01" bottom left corner is at "1,-2,0"
Scenario: Export IFC - with duplicate-of-duplicate grid axis locations preserved
Given an empty IFC project
And I press "bim.add_grid"
And I set "scene.BIMGridProperties.is_locked" to "False"
And the object "IfcGridAxis/01" is selected
And I duplicate the selected objects
And the object "IfcGridAxis/01.001" is moved to "1,0,0"
And the object "IfcGridAxis/01.001" is selected
And I duplicate the selected objects
And the object "IfcGridAxis/01.002" is moved to "2,0,0"
When I save IFC project
And I load previously saved IFC project
Then the object "IfcGridAxis/01.001" bottom left corner is at "1,-2,0"
And the object "IfcGridAxis/01.002" bottom left corner is at "2,-2,0"
Scenario: Export IFC - with changed object scale ignored
Given an empty IFC project
And I add a cube
+5 -5
View File
@@ -24,7 +24,7 @@ Scenario: Add element - an element with no geometry
When I click "OK"
And I select the object "IfcFurniture/Unnamed"
And I toggle edit mode
Then the object "Item/IfcExtrudedAreaSolid/73" exists
Then the object "Item/IfcExtrudedAreaSolid/77" exists
Scenario: Add element - an element with extrusion geometry
Given an empty IFC project
@@ -36,7 +36,7 @@ Scenario: Add element - an element with extrusion geometry
When I click "OK"
And I select the object "IfcFurniture/Unnamed"
And I toggle edit mode
Then the object "Item/IfcExtrudedAreaSolid/73" exists
Then the object "Item/IfcExtrudedAreaSolid/77" exists
Scenario: Add element - an element with custom tessellation geometry
Given an empty IFC project
@@ -48,7 +48,7 @@ Scenario: Add element - an element with custom tessellation geometry
When I click "OK"
And I select the object "IfcFurniture/Unnamed"
And I toggle edit mode
Then the object "Item/IfcPolygonalFaceSet/72" exists
Then the object "Item/IfcPolygonalFaceSet/76" exists
Scenario: Add element - an element with tessellation geometry from an object
Given an empty IFC project
@@ -62,8 +62,8 @@ Scenario: Add element - an element with tessellation geometry from an object
When I click "OK"
And I select the object "IfcFurniture/Unnamed"
And I toggle edit mode
Then the object "Item/IfcPolygonalFaceSet/72" exists
And the object "Item/IfcPolygonalFaceSet/72" dimensions are "2,2,2"
Then the object "Item/IfcPolygonalFaceSet/76" exists
And the object "Item/IfcPolygonalFaceSet/76" dimensions are "2,2,2"
Scenario: Reassign class
Given an empty IFC project
@@ -12,7 +12,7 @@ Scenario: Add element - a structural point connection
And I make the collection "IfcStructuralItem" visible
And I select the object "IfcStructuralPointConnection/Foo"
And I toggle edit mode
Then the object "Item/IfcVertexPoint/65" exists
Then the object "Item/IfcVertexPoint/69" exists
Scenario: Add element - a structural curve member
Given an empty IFC project
@@ -25,7 +25,7 @@ Scenario: Add element - a structural curve member
And I make the collection "IfcStructuralItem" visible
And I select the object "IfcStructuralCurveMember/Foo"
And I toggle edit mode
Then the object "Item/IfcEdge/68" exists
Then the object "Item/IfcEdge/72" exists
Scenario: Add element - a structural surface member
Given an empty IFC project
@@ -38,7 +38,7 @@ Scenario: Add element - a structural surface member
And I make the collection "IfcStructuralItem" visible
And I select the object "IfcStructuralSurfaceMember/Foo"
And I toggle edit mode
Then the object "Item/IfcFace/70" exists
Then the object "Item/IfcFace/74" exists
Scenario: Load structural analysis models
Given an empty IFC project
@@ -1,68 +0,0 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
import os
import time
from pathlib import Path
import pytest
from bonsai.tool.autosave import AUTOSAVED_SUFFIX, AUTOSAVING_SUFFIX, Autosave
pytestmark = pytest.mark.project
class TestAutosavePaths:
def test_get_paths_for_ifc_file(self):
main_path, autosaving_path, autosaved_path = Autosave.get_paths("/tmp/myfile.ifc")
assert main_path == Path("/tmp/myfile.ifc")
assert autosaving_path == Path(f"/tmp/myfile{AUTOSAVING_SUFFIX}")
assert autosaved_path == Path(f"/tmp/myfile{AUTOSAVED_SUFFIX}")
def test_get_newer_autosaved_path_when_missing(self, tmp_path):
ifc_path = tmp_path / "myfile.ifc"
ifc_path.write_text("ifc")
assert Autosave.get_newer_autosaved_path(ifc_path) is None
def test_get_newer_autosaved_path_when_older(self, tmp_path):
ifc_path = tmp_path / "myfile.ifc"
autosaved_path = tmp_path / f"myfile{AUTOSAVED_SUFFIX}"
ifc_path.write_text("ifc")
autosaved_path.write_text("autosaved")
past = time.time() - 10
os.utime(ifc_path, (past, past))
os.utime(autosaved_path, (time.time(), time.time()))
assert Autosave.get_newer_autosaved_path(ifc_path) == autosaved_path.as_posix()
def test_get_newer_autosaved_path_when_not_newer(self, tmp_path):
ifc_path = tmp_path / "myfile.ifc"
autosaved_path = tmp_path / f"myfile{AUTOSAVED_SUFFIX}"
ifc_path.write_text("ifc")
autosaved_path.write_text("autosaved")
now = time.time()
os.utime(ifc_path, (now, now))
past = now - 10
os.utime(autosaved_path, (past, past))
assert Autosave.get_newer_autosaved_path(ifc_path) is None
def test_get_newer_autosaved_path_ignores_non_ifc(self, tmp_path):
path = tmp_path / "myfile.ifczip"
path.write_text("zip")
assert Autosave.get_newer_autosaved_path(path) is None
+1 -6
View File
@@ -28,7 +28,6 @@ class P62Ifc:
self.file = None
self.work_plan = None
self.project = {}
self.default_calendar_id = None
self.calendars = {}
self.wbs = {}
self.root_activites = []
@@ -90,7 +89,6 @@ class P62Ifc:
self.ns = {"pr": root.tag[1:].partition("}")[0]}
project = root.find("pr:Project", self.ns)
self.project["Name"] = project.findtext("pr:Name") or "Unnamed"
self.default_calendar_id = project.findtext("pr:ActivityDefaultCalendarObjectId", namespaces=self.ns)
self.parse_calendar_xml(root)
self.parse_calendar_xml(project)
self.parse_wbs_xml(project)
@@ -176,9 +174,6 @@ class P62Ifc:
self.wbs[wbs_id]["activities"].append(activity_id)
else:
self.root_activites.append(activity_id)
# CalendarObjectId is optional in the P6 schema: an activity without one
# inherits the project's ActivityDefaultCalendarObjectId.
calendar_id = activity.findtext("pr:CalendarObjectId", namespaces=self.ns)
self.activities[activity_id] = {
"Name": activity.find("pr:Name", self.ns).text,
"Identification": activity.find("pr:Id", self.ns).text,
@@ -186,7 +181,7 @@ class P62Ifc:
"FinishDate": datetime.datetime.fromisoformat(activity.find("pr:FinishDate", self.ns).text),
"PlannedDuration": activity.find("pr:PlannedDuration", self.ns).text,
"Status": activity.find("pr:Status", self.ns).text,
"CalendarObjectId": calendar_id or self.default_calendar_id,
"CalendarObjectId": activity.find("pr:CalendarObjectId", self.ns).text,
"ifc": None,
}
-11
View File
@@ -252,10 +252,6 @@ int main(int argc, char** argv) {
("stderr-progress", "output progress to stderr stream")
("yes,y", "answer 'yes' automatically to possible confirmation queries (e.g. overwriting an existing output file)")
("no-progress", "suppress possible progress bar type of prints that use carriage return")
("fail-on-error", "return a non-zero exit code when one or more errors were logged during "
"geometry conversion (e.g. an element failed to convert). By default IfcConvert exits "
"successfully as long as an output file could be written, even if some elements were "
"silently dropped. Enable this flag so scripts and CI can detect partial conversions.")
("log-format", po::value<std::string>(&log_format), "log format: plain or json")
("log-file", new po::typed_value<path_t, char_t>(&log_file), "redirect log output to file");
@@ -453,7 +449,6 @@ int main(int argc, char** argv) {
const bool mmap = vmap.count("mmap") != 0;
const bool no_progress = vmap.count("no-progress") != 0;
const bool fail_on_error = vmap.count("fail-on-error") != 0;
const bool quiet = vmap.count("quiet") != 0;
const bool stderr_progress = vmap.count("stderr-progress") != 0;
@@ -890,7 +885,6 @@ int main(int argc, char** argv) {
}
if (!serializer->ready()) {
logger.Error("SYS", 25, "Unable to open output file '" + IfcUtil::path::to_utf8(output_filename) + "' for writing; check that the directory exists and is writable");
IfcUtil::path::delete_file(IfcUtil::path::to_utf8(output_temp_filename));
write_log(!quiet);
return EXIT_FAILURE;
@@ -1226,11 +1220,6 @@ int main(int argc, char** argv) {
successful = false;
}
if (fail_on_error && logger.MaxSeverity() >= Logger::LOG_ERROR) {
logger.Error("SYS", 26, "Errors encountered during processing, failing due to --fail-on-error.");
successful = false;
}
if (logger.Verbosity() == Logger::LOG_PERF) {
logger.PrintPerformanceStats();
}
+4 -10
View File
@@ -51,10 +51,8 @@ class IfcDiff:
:param old: IFC file object for the old model
:param new: IFC file object for the new model
:param relationships: List of relationships to check. None means that
attributes and geometry are compared, so changes such as a modified or
removed PredefinedType are reported. See RELATIONSHIP_TYPE for available
relationships.
:param relationships: List of relationships to check. None means that only
geometry is compared. See RELATIONSHIP_TYPE for available relationships.
:param is_shallow: True if you want only the first difference to be listed.
False if you want all differences to be checked. Choosing False means
that comparisons will take longer.
@@ -88,7 +86,7 @@ class IfcDiff:
self.new = new
self.change_register = {}
self.representation_ids = {}
self.relationships = relationships or ["attributes", "geometry"]
self.relationships = relationships or ["geometry"]
self.precision = 1e-4
self.is_shallow = is_shallow
self.filter_elements = filter_elements
@@ -437,11 +435,7 @@ if __name__ == "__main__":
"-r",
"--relationships",
type=str,
help=(
'A list of space-separated relationships, chosen from "attributes", "geometry", '
'"type", "property", "container", "aggregate", "classification". '
'Defaults to "attributes geometry" when omitted.'
),
help='A list of space-separated relationships, chosen from "type", "property", "container", "aggregate", "classification"',
default="",
)
args = parser.parse_args()
-17
View File
@@ -77,23 +77,6 @@ class TestIfcDiff:
assert ifc_diff.deleted_elements == set()
assert ifc_diff.change_register == {wall.GlobalId: {"attributes_changed": True}}
def test_changed_predefined_type_is_caught_by_default(self):
# Regression test for #8214: a plain diff (no relationships specified)
# must report a modified or removed PredefinedType. Previously the
# default only compared geometry, so attribute-only edits were missed.
ifc_file = setup_project()
wall = ifcopenshell.api.root.create_entity(ifc_file, ifc_class="IfcWall", name="Foo")
wall.PredefinedType = "SOLIDWALL"
new_file = ifc_file.from_string(ifc_file.to_string())
new_file.by_id(wall.id()).PredefinedType = "NOTDEFINED"
ifc_diff = ifcdiff.IfcDiff(ifc_file, new_file)
ifc_diff.diff()
assert ifc_diff.added_elements == set()
assert ifc_diff.deleted_elements == set()
assert ifc_diff.change_register == {wall.GlobalId: {"attributes_changed": True}}
def test_changed_geometry(self):
ifc_file = setup_project()
wall = ifcopenshell.api.root.create_entity(ifc_file, ifc_class="IfcWall", name="Foo")
+1 -1
View File
@@ -258,7 +258,7 @@ ifcedit quantify run model.ifc IFC4QtoBaseQuantities -o model_qto.ifc
Options:
- `--selector <query>` -- ifcopenshell selector to restrict elements (default: all `IfcElement` and `IfcSpace`)
- `--selector <query>` -- ifcopenshell selector to restrict elements (default: all `IfcElement`)
- `-o, --output <path>` -- write to a different file instead of overwriting the input
Note: `quantify run` writes geometry-based measurements and requires the
+1 -3
View File
@@ -244,9 +244,7 @@ def main():
qrun_parser = quantify_sub.add_parser("run", help="Run QTO on an IFC file")
qrun_parser.add_argument("ifc_file", help="Path to the IFC file")
qrun_parser.add_argument("rule_name", help="QTO rule name (e.g. IFC4QtoBaseQuantities)")
qrun_parser.add_argument(
"--selector", help="ifcopenshell selector to restrict elements (default: all IfcElement and IfcSpace)"
)
qrun_parser.add_argument("--selector", help="ifcopenshell selector to restrict elements (default: all IfcElement)")
qrun_parser.add_argument("-o", "--output", help="Output file path (default: overwrite input)")
args, extra = parser.parse_known_args()
+1 -3
View File
@@ -60,11 +60,9 @@ def coerce_value(
# Union / Optional
if origin is typing.Union:
non_none_types = [a for a in args if a is not type(None)]
if isinstance(value_str, str) and value_str.lower() == "none":
if value_str.lower() == "none":
if type(None) in args:
return None
if value_str is None and type(None) in args:
return None
# Try each non-None type in order
for t in non_none_types:
try:
+1 -1
View File
@@ -30,7 +30,7 @@ def run_quantify(model: ifcopenshell.file, rule: str, selector: str | None = Non
if selector:
elements = set(ifcopenshell.util.selector.filter_elements(model, selector))
else:
elements = set(model.by_type("IfcElement")) | set(model.by_type("IfcSpace"))
elements = set(model.by_type("IfcElement"))
results = quantify(model, elements, rule_sets[rule])
edit_qtos(model, results)
-10
View File
@@ -57,16 +57,6 @@ class TestOptionalCoercion:
def test_optional_int(self):
assert coerce_value("42", Optional[int]) == 42
def test_optional_entity_native_int(self, model):
# MCP callers pass JSON-decoded native types (int), not CLI strings.
wall = model.by_type("IfcWall")[0]
result = coerce_value(wall.id(), Optional[ifcopenshell.entity_instance], model)
assert result == wall
def test_optional_entity_native_none(self, model):
# JSON null decodes to Python None, not the string "none".
assert coerce_value(None, Optional[ifcopenshell.entity_instance], model) is None
class TestUnionCoercion:
def test_union_str_int(self):
-17
View File
@@ -85,20 +85,3 @@ class TestRunQuantify:
def test_empty_selector_runs_on_all(self, quantify_model):
result = run_quantify(quantify_model, "IFC4QtoBaseQuantities", selector=None)
assert result["ok"] is True
def test_default_selector_includes_spaces(self, quantify_model, monkeypatch):
"""IfcSpace is not a subtype of IfcElement, so the default scope must add it explicitly."""
import ifc5d.qto
seen_elements = {}
def fake_quantify(ifc_file, elements, rules):
seen_elements["elements"] = elements
return {}
monkeypatch.setattr(ifc5d.qto, "quantify", fake_quantify)
space = ifcopenshell.api.root.create_entity(quantify_model, ifc_class="IfcSpace", name="TestSpace")
run_quantify(quantify_model, "IFC4QtoBaseQuantities")
assert space in seen_elements["elements"]
-6
View File
@@ -28,7 +28,6 @@ import ifcopenshell.util.fm
import ifcopenshell.util.placement
import ifcopenshell.util.shape
import ifcopenshell.util.system
import ifcopenshell.util.unit
from ifcopenshell.util.shape_builder import np_matrix_to_euler
# The original BIMServer plugin has a function called ifcToCOBie:
@@ -921,11 +920,6 @@ def get_coordinate_data_(element: ifcopenshell.entity_instance) -> Generator[dic
verts = ifcopenshell.util.shape.get_shape_vertices(shape, shape.geometry)
categories = ("box-lowerleft", "box-upperright")
bbox = ifcopenshell.util.shape.get_bbox(verts)
# Geometry vertices are in SI metres, but Floor rows use the raw placement in
# project length units. Convert space points to project units so the whole
# Coordinate sheet is consistent with the Facility LinearUnits.
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(element.file)
bbox = [point / unit_scale for point in bbox]
base_data = base_data | {
"Category": "point",
"SheetName": "Space",
+2 -2
View File
@@ -361,8 +361,8 @@ namespace ifcopenshell {
struct CircleSegments : public SettingBase<CircleSegments, int> {
static constexpr const char* const name = "circle-segments";
static constexpr const char* const description = "Number of segments to approximate full circles in the CGAL kernel. When 0 (the default) the segment count is derived from mesher-linear-deflection instead, so curves stay within the deflection tolerance regardless of radius.";
static constexpr int defaultvalue = 0;
static constexpr const char* const description = "Number of segments to approximate full circles in CGAL kernel.";
static constexpr int defaultvalue = 16;
};
struct CgalSmoothAngleDegrees : public SettingBase<CgalSmoothAngleDegrees, double> {
+1 -35
View File
@@ -391,11 +391,6 @@ namespace {
}
};
// Representative radius used to size the polygonal approximation of a conic.
// For an ellipse the larger semi-axis is the conservative choice.
inline double conic_radius(const taxonomy::circle::ptr& c) { return c->radius; }
inline double conic_radius(const taxonomy::ellipse::ptr& e) { return e->radius > e->radius2 ? e->radius : e->radius2; }
struct cgal_curve_creation_visitor {
Settings& settings_;
parameter_range param;
@@ -430,36 +425,7 @@ namespace {
if (b <= a) {
b += 2 * M_PI;
}
const double span = std::fabs(a - b);
// CircleSegments controls how conics (circles, ellipses, arcs) are approximated
// in the CGAL kernel. Two modes, one or the other:
// - CircleSegments == 0 (the default): the segment count is derived from
// MesherLinearDeflection, so the chord deviation stays within the mesher's
// linear deflection regardless of radius. This matches the deflection based
// meshing the OpenCascade kernel already does and fixes issue #8051, where
// large radius arcs (curved curtain wall mullions) collapsed to straight chords
// because a fixed segment count is radius agnostic.
// - CircleSegments > 0: it is used directly as the number of segments for a full
// circle, giving deterministic, radius independent output.
int num_segments;
const int circle_segments = settings_.get<settings::CircleSegments>().get();
if (circle_segments > 0) {
num_segments = (int)std::ceil(span / (2 * M_PI) * circle_segments);
} else {
const double radius = conic_radius(t);
const double deflection = settings_.get<settings::MesherLinearDeflection>().get();
if (deflection > 0. && radius > deflection) {
const double max_segment_angle = 2.0 * std::acos(1.0 - deflection / radius);
num_segments = (int)std::ceil(span / max_segment_angle);
} else {
// Radius within the deflection tolerance (or no deflection set): a chord per
// quarter turn already keeps the deviation within tolerance.
num_segments = (int)std::ceil(span / (M_PI / 2.));
}
}
if (num_segments < 1) {
num_segments = 1;
}
int num_segments = (int)std::ceil(std::fabs(a - b) / (2 * M_PI) * settings_.get<settings::CircleSegments>().get());
double du = (b - a) / num_segments;
taxonomy::point3 P;
// @nb for loop is not inclusive of the both end points
-22
View File
@@ -31,7 +31,6 @@
#include <ShapeFix_Shape.hxx>
#include <ShapeFix_ShapeTolerance.hxx>
#include <BRep_Tool.hxx>
#include <BRepExtrema_DistShapeShape.hxx>
#include <Standard_Macro.hxx>
#include <TopoDS_Shape.hxx>
@@ -357,27 +356,6 @@ bool OpenCascadeKernel::convert(const taxonomy::face::ptr face, TopoDS_Shape& re
return false;
}
// #527: A face whose inner boundary intersects the outer boundary (or
// another inner boundary) is invalid per the schema. Open Cascade heals or
// drops such a face silently, so the intended hole is lost with no
// diagnostic. The distance between two non-intersecting loops is strictly
// positive; a distance at (or below) the modelling precision means the
// boundaries touch or cross. Emit a clear warning so the invalid input is
// not silently lost. wires() is ordered outer-first, inner-bounds after.
if (fd.wires().size() > 1) {
const auto& fwires = fd.wires();
bool reported = false;
for (size_t i = 1; i < fwires.size() && !reported; ++i) {
for (size_t j = 0; j < i && !reported; ++j) {
BRepExtrema_DistShapeShape dss(fwires[i], fwires[j]);
if (dss.IsDone() && dss.Value() < precision_) {
logger().Warning("GEO", 402, "Face inner boundary intersects another face boundary", face->instance);
reported = true;
}
}
}
}
if (fd.surface().IsNull()) {
// Use the first wire to find a plane manually for polygonal wires
const TopoDS_Wire& wire = fd.wires().front();
@@ -1,93 +0,0 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser GNU General Public License as published by *
* the Free Software Foundation, either version 3.0 of the License, or *
* (at your option) any later version. *
* *
* IfcOpenShell is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "mapping.h"
#define mapping POSTFIX_SCHEMA(mapping)
using namespace ifcopenshell::geometry;
#include "../profile_helper.h"
// In IFC2X3 IfcAsymmetricIShapeProfileDef is a subtype of IfcIShapeProfileDef and is
// therefore dispatched (and handled) by the IfcIShapeProfileDef mapping. From IFC4
// onwards it is a standalone subtype of IfcParameterizedProfileDef with its own
// Bottom*/Top* attributes, so nothing mapped it and the extrusion came out empty.
// The presence of the standalone BottomFlangeWidth attribute is the discriminator:
// it is only defined in the schemas where the type is standalone (IFC4 / IFC4X3).
#ifdef SCHEMA_IfcAsymmetricIShapeProfileDef_HAS_BottomFlangeWidth
taxonomy::ptr mapping::map_impl(const IfcSchema::IfcAsymmetricIShapeProfileDef* inst) {
// Bottom flange (half width), overall depth (half), web (half thickness).
const double xb = inst->BottomFlangeWidth() / 2.0 * length_unit_;
const double xt = inst->TopFlangeWidth() / 2.0 * length_unit_;
const double y = inst->OverallDepth() / 2.0 * length_unit_;
const double d1 = inst->WebThickness() / 2.0 * length_unit_;
// Bottom flange thickness; top flange thickness defaults to the bottom one.
const double ftb = inst->BottomFlangeThickness() * length_unit_;
const double ftt = inst->TopFlangeThickness().get_value_or(inst->BottomFlangeThickness()) * length_unit_;
// Optional fillet radii (web/flange transition) and flange edge radii.
const double fb = inst->BottomFlangeFilletRadius().get_value_or(0.) * length_unit_;
const double ft_top = inst->TopFlangeFilletRadius().get_value_or(0.) * length_unit_;
const double feb = inst->BottomFlangeEdgeRadius().get_value_or(0.) * length_unit_;
const double fet = inst->TopFlangeEdgeRadius().get_value_or(0.) * length_unit_;
// Optional flange slopes: the inner edge of the flange rises towards the web.
const double bottomSlope = inst->BottomFlangeSlope().get_value_or(0.) * angle_unit_;
const double topSlope = inst->TopFlangeSlope().get_value_or(0.) * angle_unit_;
const double dyb = (xb - d1) * tan(bottomSlope);
const double dyt = (xt - d1) * tan(topSlope);
const double tol = settings_.get<settings::Precision>().get();
if (xb < tol || xt < tol || y < tol || d1 < tol || ftb < tol || ftt < tol) {
logger_.Message(Logger::LOG_NOTICE, "GEO", 264, "Skipping zero sized profile:", inst);
return nullptr;
}
taxonomy::matrix4::ptr m4;
bool has_position = true;
#ifdef SCHEMA_IfcParameterizedProfileDef_Position_IS_OPTIONAL
has_position = !!inst->Position();
#endif
if (has_position) {
m4 = taxonomy::cast<taxonomy::matrix4>(map(inst->Position()));
}
// Twelve corner points, running counter-clockwise from the bottom-left, with the
// bottom flange (xb) possibly wider than the top flange (xt). Fillet/edge radii are
// attached to the corner they round, matching the symmetric IfcIShapeProfileDef.
return profile_helper(m4, {
{{-xb,-y}},
{{xb,-y}},
{{xb,-y + ftb}, {feb}},
{{d1,-y + ftb + dyb},{fb} },
{{d1,y - ftt - dyt},{ft_top} },
{{xt,y - ftt}, {fet}},
{{xt,y}},
{{-xt,y}},
{{-xt,y - ftt}, {fet}},
{{-d1,y - ftt - dyt},{ft_top} },
{{-d1,-y + ftb + dyb},{fb} },
{{-xb,-y + ftb}, {feb}}
});
}
#endif
+11 -22
View File
@@ -39,25 +39,8 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcPolygonalFaceSet* inst) {
int max_index = (int)points.size();
// When the optional PnIndex is present, CoordIndex values do not index into
// CoordList directly but into PnIndex, which in turn remaps to CoordList.
// Both index levels are 1-based per the IFC specification.
auto pn_index = inst->PnIndex();
auto resolve = [&](int idx) -> const taxonomy::point3::ptr& {
if (pn_index) {
if (idx < 1 || idx > (int)pn_index->size()) {
throw IfcParse::IfcException("IfcPolygonalFaceSet PnIndex out of bounds for index " + boost::lexical_cast<std::string>(idx));
}
idx = (*pn_index)[idx - 1];
}
if (idx < 1 || idx > max_index) {
throw IfcParse::IfcException("IfcPolygonalFaceSet index out of bounds for index " + boost::lexical_cast<std::string>(idx));
}
return points[idx - 1];
};
auto shell = taxonomy::make<taxonomy::shell>();
for (auto& f : *polygonal_faces) {
auto fa = taxonomy::make<taxonomy::face>();
shell->children.push_back(fa);
@@ -69,14 +52,17 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcPolygonalFaceSet* inst) {
auto indices = f->CoordIndex();
taxonomy::point3::ptr previous;
for (std::vector<int>::const_iterator jt = indices.begin(); jt != indices.end(); ++jt) {
auto current = resolve(*jt);
if (*jt < 1 || *jt > max_index) {
throw IfcParse::IfcException("IfcPolygonalFaceSet index out of bounds for index " + boost::lexical_cast<std::string>(*jt));
}
auto current = points[(*jt) - 1];
if (jt != indices.begin()) {
loop->children.push_back(taxonomy::make<taxonomy::edge>(previous, current));
}
previous = current;
}
if (!indices.empty()) {
auto current = resolve(indices.front());
auto current = points[indices.front() - 1];
loop->children.push_back(taxonomy::make<taxonomy::edge>(previous, current));
}
}
@@ -91,14 +77,17 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcPolygonalFaceSet* inst) {
loop->external = false;
for (std::vector<int>::const_iterator jt = li.begin(); jt != li.end(); ++jt) {
auto current = resolve(*jt);
if (*jt < 1 || *jt > max_index) {
throw IfcParse::IfcException("IfcPolygonalFaceSet index out of bounds for index " + boost::lexical_cast<std::string>(*jt));
}
auto current = points[(*jt) - 1];
if (jt != li.begin()) {
loop->children.push_back(taxonomy::make<taxonomy::edge>(previous, current));
}
previous = current;
}
if (!li.empty()) {
auto current = resolve(li.front());
auto current = points[li.front() - 1];
loop->children.push_back(taxonomy::make<taxonomy::edge>(previous, current));
}
}
+4 -18
View File
@@ -39,23 +39,6 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcTriangulatedFaceSet* inst) {
int max_index = (int)points.size();
// When the optional PnIndex is present, CoordIndex values do not index into
// CoordList directly but into PnIndex, which in turn remaps to CoordList.
// Both index levels are 1-based per the IFC specification.
auto pn_index = inst->PnIndex();
auto resolve = [&](int idx) -> const taxonomy::point3::ptr& {
if (pn_index) {
if (idx < 1 || idx > (int)pn_index->size()) {
throw IfcParse::IfcException("IfcTriangulatedFaceSet PnIndex out of bounds for index " + boost::lexical_cast<std::string>(idx));
}
idx = (*pn_index)[idx - 1];
}
if (idx < 1 || idx > max_index) {
throw IfcParse::IfcException("IfcTriangulatedFaceSet index out of bounds for index " + boost::lexical_cast<std::string>(idx));
}
return points[idx - 1];
};
auto shell = taxonomy::make<taxonomy::shell>();
for (auto& indices : indices_list) {
@@ -68,7 +51,10 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcTriangulatedFaceSet* inst) {
loop->external = true;
taxonomy::point3::ptr first, previous;
for (std::vector<int>::const_iterator jt = indices.begin(); jt != indices.end(); ++jt) {
const taxonomy::point3::ptr& current = resolve(*jt);
if (*jt < 1 || *jt > max_index) {
throw IfcParse::IfcException("IfcTriangulatedFaceSet index out of bounds for index " + boost::lexical_cast<std::string>(*jt));
}
const taxonomy::point3::ptr& current = points[(*jt) - 1];
if (jt == indices.begin()) {
first = current;
} else {
+1 -5
View File
@@ -89,11 +89,7 @@ BIND(IfcRectangleHollowProfileDef);
BIND(IfcRectangleProfileDef);
BIND(IfcTrapeziumProfileDef);
BIND(IfcCShapeProfileDef);
// In IFC2X3 IfcAsymmetricIShapeProfileDef is a subtype of IfcIShapeProfileDef and is
// mapped by it; from IFC4 onwards it is a standalone type and needs its own binding.
#ifdef SCHEMA_IfcAsymmetricIShapeProfileDef_HAS_BottomFlangeWidth
BIND(IfcAsymmetricIShapeProfileDef);
#endif
// IfcAsymmetricIShapeProfileDef included
BIND(IfcIShapeProfileDef);
BIND(IfcLShapeProfileDef);
BIND(IfcTShapeProfileDef);
+1 -1
View File
@@ -255,7 +255,7 @@ ifc_quantify(rule="IFC4QtoBaseQuantities", selector="IfcWall")
Available rules: `IFC4QtoBaseQuantities`, `IFC4X3QtoBaseQuantities`.
`selector` is an optional ifcopenshell selector to restrict which elements
are quantified (default: all `IfcElement` and `IfcSpace`).
are quantified (default: all `IfcElement`).
Returns `{"ok": true, "rule": "...", "elements_quantified": 42}`.
+1 -1
View File
@@ -703,7 +703,7 @@ class IfcSession:
"rule": {"type": "string", "description": "QTO rule name, e.g. IFC4QtoBaseQuantities"},
"selector": {
"type": "string",
"description": "ifcopenshell selector to restrict elements (default: all IfcElement and IfcSpace)",
"description": "ifcopenshell selector to restrict elements (default: all IfcElement)",
},
},
"required": ["rule"],
@@ -311,12 +311,8 @@ CLI Manual
output.
--force-space-transparency arg Overrides transparency of spaces in
geometry output.
--circle-segments arg (= 0) Number of segments to approximate full
circles in the CGAL kernel. When 0 (the
default) the segment count is derived from
mesher-linear-deflection instead, so curves
stay within the deflection tolerance
regardless of radius.
--circle-segments arg (= 16) Number of segments to approximate full
circles in CGAL kernel.
--cgal-smooth-angle-degrees arg (= -1)
Angle in degrees under which adjacent
facets will have averaged vertex
@@ -72,8 +72,6 @@ Filtering is typically used to select any IFC element or type.
"``IfcPump, location=""Level 3""``", "Locations bubble up the hierarchy. So if a pump is in a space and that space is on Level 3, then you can say ""all pumps on level 3"" which will include that pump in the space."
"``IfcElement, query:""parent.Name""=""My Site""``", "Only elements *immediately* under ""My Site"" in the spatial hierarchy. Unlike the ``location`` and ``parent`` filters, which both match at any depth, the ``parent`` query key resolves the direct parent only, so nested storeys (and their contents) are excluded."
The filter elements syntax works by specifying one or more groups of filters
separated by a ``+`` character. Each filter group will return a set of filtered
elements, and these are unioned together.
@@ -89,11 +87,6 @@ The filters are chained and apply from left to right.
filter[, filter]*
Any part of a query may be commented out using a ``/* ... */`` block comment.
This lets you temporarily disable part of a query without deleting the text, for
example ``IfcWall + /* IfcSlab, material=concrete */`` selects only walls while
keeping the slab criteria on hand. Block comments may span multiple lines.
Below is the table of filters to choose from. Most of these filters will filter
previously added elements in your filter group based on their criteria.
@@ -118,15 +111,6 @@ will search through all IfcTypeProducts and IfcProducts in the IFC project.
"Parent", "Filter", "``parent{{=}}{{value}}``", "``parent=Foo`` specifies the criteria that elements must be a direct or indirect child in the spatial hierarchy to an element with a ``Name`` attribute with a value of ``Foo``."
"Query", "Filter", "``query:{{keys}}{{=}}{{value}}``", "``query:types.count=0`` specifies the criteria that elements must have zero type occurrences. The query keys corresponds to the syntax used in the `Getting element values`_ section"
.. note::
The ``location`` and ``parent`` filters both match at **any depth** in the
spatial hierarchy. To match only elements *immediately* contained in (or
aggregated under) a spatial element, use the ``parent`` query key, which
resolves the direct parent only. For example,
``query:"parent.Name"="My Site"`` selects elements directly under ``My
Site`` but excludes anything nested inside its sub-storeys or spaces.
When you specify a filter with a ``{{=}}`` check, you can choose from one of
the following comparison checks:
@@ -207,7 +191,7 @@ Valid keys are:
"``storey``", "Gets the first IfcBuildingStorey spatial element that an element is contained in."
"``building``", "Gets the first IfcBuilding spatial element that an element is contained in."
"``site``", "Gets the first IfcSite spatial element that an element is contained in."
"``parent``", "Gets the **immediate** parent element in the spatial hierarchy (the direct spatial container, or the direct aggregate/nest/fill/void parent). Combine with ``.Name`` in a query filter to match only immediate children, e.g. ``query:""parent.Name""=""My Site""``."
"``parent``", "Gets the parent element in the spatial hierarchy."
"``classification``", "Gets the element's classification reference(s)"
"``group``", "Gets the element's group(s)"
"``system``", "Gets the element's system(s). This is a subset of group(s)."
@@ -222,9 +206,6 @@ Valid keys are:
"``easting``", "Gets the map easting of the element's placement"
"``northing``", "Gets the map northing of the element's placement"
"``elevation``", "Gets the map elevation of the element's placement"
"``rotation_x``", "Gets the X Euler rotation of the element's placement in degrees"
"``rotation_y``", "Gets the Y Euler rotation of the element's placement in degrees"
"``rotation_z``", "Gets the Z Euler rotation of the element's placement in degrees (e.g. plan rotation of a symbol)"
"``count``", "If the previous key returns multiple things, count that list. Otherwise, return 1."
"``{{number}}``", "If the previous key returns multiple things, fetch the ``{{number}}`` index (e.g. 0, 1, 2, 3, etc) item in that list."
@@ -228,10 +228,10 @@ circle-segments
+------+-----------------------+---------+
| Type | IfcConvert Option | Default |
+======+=======================+=========+
| INT | ``--circle-segments`` | 0 |
| INT | ``--circle-segments`` | 16 |
+------+-----------------------+---------+
Number of segments to approximate full circles in the CGAL kernel. When 0 (the default) the segment count is derived from mesher-linear-deflection instead, so curves stay within the deflection tolerance regardless of radius.
Number of segments to approximate full circles in CGAL kernel.
context-identifiers
^^^^^^^^^^^^^^^^^^^
@@ -78,7 +78,8 @@ def create_axis_curve(
points /= unit_scale
grid = next(i for i in file.get_inverse(grid_axis) if i.is_a("IfcGrid"))
grid_matrix_i = np.linalg.inv(ifcopenshell.util.placement.get_local_placement(grid.ObjectPlacement))
grid_placement = ifcopenshell.util.placement.get_local_placement(grid.ObjectPlacement)
grid_matrix_i = np.linalg.inv(grid_placement)
p1, p2 = ifc_safe_vector_type(np_apply_matrix(points, grid_matrix_i))
grid_axis.AxisCurve = file.create_entity(
"IfcPolyline",
@@ -88,5 +89,5 @@ def create_axis_curve(
),
)
if existing_curve:
if existing_curve and file.get_total_inverses(existing_curve) == 0:
ifcopenshell.util.element.remove_deep2(file, existing_curve)
@@ -81,7 +81,7 @@ def assign_resource(
"""
if related_object.HasAssignments:
for assignment in related_object.HasAssignments:
if assignment.is_a("IfcRelAssignsToResource") and assignment.RelatingResource == relating_resource:
if assignment.is_a("IfclRelAssignsToResource") and assignment.RelatingResource == relating_resource:
return assignment
resource_of = None
@@ -112,14 +112,8 @@ def get_pset(
for relationship in is_defined_by:
if relationship.is_a("IfcRelDefinesByProperties"):
definition = relationship.RelatingPropertyDefinition
# IfcPropertySetDefinitionSet is a defined type wrapping a list
# of property set definitions, so unpack it into its members.
if definition.is_a("IfcPropertySetDefinitionSet"):
definitions = definition.wrappedValue
else:
definitions = (definition,)
pset = next((d for d in definitions if d.Name == name), None)
if pset:
if definition.Name == name:
pset = definition
break
if pset:
@@ -227,22 +221,15 @@ def get_psets(
for relationship in is_defined_by:
if relationship.is_a("IfcRelDefinesByProperties"):
definition = relationship.RelatingPropertyDefinition
# IfcPropertySetDefinitionSet is a defined type wrapping a list
# of property set definitions, so unpack it into its members.
if definition.is_a("IfcPropertySetDefinitionSet"):
definitions = definition.wrappedValue
else:
definitions = (definition,)
for definition in definitions:
if (
psets_only
and not definition.is_a("IfcPropertySet")
and not definition.is_a("IfcPreDefinedPropertySet")
):
continue
if qtos_only and not definition.is_a("IfcElementQuantity"):
continue
psets.setdefault(definition.Name, {}).update(get_property_definition(definition, verbose=verbose))
if (
psets_only
and not definition.is_a("IfcPropertySet")
and not definition.is_a("IfcPreDefinedPropertySet")
):
continue
if qtos_only and not definition.is_a("IfcElementQuantity"):
continue
psets.setdefault(definition.Name, {}).update(get_property_definition(definition, verbose=verbose))
return psets
@@ -18,7 +18,7 @@
import re
from collections.abc import Iterable
from decimal import Decimal, InvalidOperation
from decimal import Decimal
from types import EllipsisType
from typing import Any, Optional, Union
@@ -36,12 +36,11 @@ import ifcopenshell.util.placement
import ifcopenshell.util.pset
import ifcopenshell.util.schema
import ifcopenshell.util.shape
import ifcopenshell.util.shape_builder
import ifcopenshell.util.system
import ifcopenshell.util.unit
filter_elements_grammar = lark.Lark("""start: filter_group
filter_group: facet_list ("+" facet_list)* "+"?
filter_group: facet_list ("+" facet_list)*
facet_list: facet ("," facet)*
facet: instance | entity | attribute | type | material | query | classification | location | property | group | parent
@@ -109,10 +108,8 @@ filter_elements_grammar = lark.Lark("""start: filter_group
CR : /\\r/
LF : /\\n/
NEWLINE: (CR? LF)+
COMMENT: "/*" /.*?/s "*/"
%ignore WS // Disregard spaces in text
%ignore COMMENT // Allow /* ... */ block comments to toggle parts of a query
""")
get_element_grammar = lark.Lark("""start: keys
@@ -317,13 +314,7 @@ class FormatTransformer(lark.Transformer):
return value in ("true", "1", "yes")
def round(self, args):
try:
value = Decimal(0.0 if args[0] == "None" else args[0] or 0.0)
except InvalidOperation:
# The value is not numeric (e.g. a text property, or a value with
# a unit suffix like "12.5 m"). Rounding is meaningless here, so
# return it unchanged instead of crashing the whole expression.
return args[0]
value = Decimal(0.0 if args[0] == "None" else args[0] or 0.0)
nearest = Decimal(args[1])
result = round(value / nearest) * nearest
if nearest % 1 == 0:
@@ -492,13 +483,6 @@ def _get_element_value(element: ifcopenshell.entity_instance, keys: list[str]) -
value = enh[("easting", "northing", "elevation").index(key)]
else:
value = None
elif key in ("rotation_x", "rotation_y", "rotation_z") and hasattr(value, "ObjectPlacement"):
if getattr(value, "ObjectPlacement", None):
matrix = ifcopenshell.util.placement.get_local_placement(value.ObjectPlacement)
euler = ifcopenshell.util.shape_builder.np_matrix_to_euler(matrix)
value = float(np.degrees(euler[("rotation_x", "rotation_y", "rotation_z").index(key)]))
else:
value = None
elif isinstance(value, ifcopenshell.entity_instance):
if key == "Name" and value.is_a("IfcMaterialLayerSet"):
key = "LayerSetName" # This oddity in the IFC spec is annoying so we account for it.
@@ -707,19 +691,9 @@ def set_element_value(
return
elif key == "classification":
element = ifcopenshell.util.classification.get_references(element)
elif key in (
"x",
"y",
"z",
"easting",
"northing",
"elevation",
"rotation_x",
"rotation_y",
"rotation_z",
) and hasattr(element, "ObjectPlacement"):
elif key in ("x", "y", "z", "easting", "northing", "elevation") and hasattr(element, "ObjectPlacement"):
# TODO: add support
if key in ("easting", "northing", "elevation", "rotation_x", "rotation_y", "rotation_z"):
if key in ("easting", "northing", "elevation"):
return
placement = element.ObjectPlacement
@@ -0,0 +1,88 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2021 Dion Moult <dion@thinkmoult.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import numpy as np
import ifcopenshell.api.grid
import test.bootstrap
class TestCreateAxisCurve(test.bootstrap.IFC4):
def make_grid_with_axis(self, axis_tag="A"):
grid = self.file.createIfcGrid()
grid.ObjectPlacement = self.file.createIfcLocalPlacement(
RelativePlacement=self.file.createIfcAxis2Placement3D(
Location=self.file.createIfcCartesianPoint([0.0, 0.0, 0.0])
)
)
axis = ifcopenshell.api.grid.create_grid_axis(
self.file, axis_tag=axis_tag, same_sense=True, uvw_axes="UAxes", grid=grid
)
return grid, axis
def test_creates_a_polyline_axis_curve(self):
_, axis = self.make_grid_with_axis()
ifcopenshell.api.grid.create_axis_curve(
self.file, p1=np.array([0.0, 0.0, 0.0]), p2=np.array([10.0, 0.0, 0.0]), grid_axis=axis
)
assert axis.AxisCurve is not None
assert axis.AxisCurve.is_a("IfcPolyline")
assert len(axis.AxisCurve.Points) == 2
def test_replaces_existing_curve_when_unshared(self):
"""Calling create_axis_curve again on the same axis replaces the old curve
and removes the old curve from the file when nothing else references it."""
_, axis = self.make_grid_with_axis()
ifcopenshell.api.grid.create_axis_curve(
self.file, p1=np.array([0.0, 0.0, 0.0]), p2=np.array([10.0, 0.0, 0.0]), grid_axis=axis
)
old_curve_id = axis.AxisCurve.id()
ifcopenshell.api.grid.create_axis_curve(
self.file, p1=np.array([1.0, 0.0, 0.0]), p2=np.array([11.0, 0.0, 0.0]), grid_axis=axis
)
assert axis.AxisCurve.id() != old_curve_id
assert self.file.by_id(old_curve_id) is None
def test_does_not_remove_shared_curve(self):
"""When two axes share the same AxisCurve (e.g. after a shallow copy during
duplication), updating one axis must not destroy the curve still referenced
by the other axis."""
grid, axis = self.make_grid_with_axis()
axis2 = ifcopenshell.api.grid.create_grid_axis(
self.file, axis_tag="B", same_sense=True, uvw_axes="UAxes", grid=grid
)
ifcopenshell.api.grid.create_axis_curve(
self.file, p1=np.array([0.0, 0.0, 0.0]), p2=np.array([10.0, 0.0, 0.0]), grid_axis=axis
)
shared_curve = axis.AxisCurve
shared_curve_id = shared_curve.id()
# Simulate what copy_class produces: a duplicate axis that shares the
# source's AxisCurve rather than having its own copy.
axis2.AxisCurve = shared_curve
assert self.file.get_total_inverses(shared_curve) == 2
# Updating axis1's curve must not remove the curve that axis2 still needs.
ifcopenshell.api.grid.create_axis_curve(
self.file, p1=np.array([1.0, 0.0, 0.0]), p2=np.array([11.0, 0.0, 0.0]), grid_axis=axis
)
assert axis2.AxisCurve.id() == shared_curve_id
assert self.file.by_id(shared_curve_id) is not None
class TestCreateAxisCurveIFC2X3(test.bootstrap.IFC2X3, TestCreateAxisCurve):
pass
@@ -1,45 +0,0 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2024 Dion Moult <dion@thinkmoult.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import ifcopenshell.api.resource
import ifcopenshell.api.root
import test.bootstrap
class TestAssignResource(test.bootstrap.IFC4):
def test_assigning_a_new_object_to_a_resource(self):
self.file.create_entity("IfcProject")
resource = ifcopenshell.api.resource.add_resource(self.file, ifc_class="IfcCrewResource")
actor = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcActor")
rel = ifcopenshell.api.resource.assign_resource(self.file, relating_resource=resource, related_object=actor)
assert rel.is_a("IfcRelAssignsToResource")
assert rel.RelatingResource == resource
assert rel.RelatedObjects == (actor,)
def test_assigning_the_same_object_twice_does_not_duplicate_related_objects(self):
# Regression test for #8203: a typo in the duplicate guard
# ("IfclRelAssignsToResource") meant the guard never matched, so a
# repeat assignment appended the related object to RelatedObjects again.
self.file.create_entity("IfcProject")
resource = ifcopenshell.api.resource.add_resource(self.file, ifc_class="IfcCrewResource")
actor = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcActor")
rel1 = ifcopenshell.api.resource.assign_resource(self.file, relating_resource=resource, related_object=actor)
rel2 = ifcopenshell.api.resource.assign_resource(self.file, relating_resource=resource, related_object=actor)
assert rel1 == rel2
assert len(self.file.by_type("IfcRelAssignsToResource")) == 1
assert rel2.RelatedObjects == (actor,)
@@ -55,9 +55,6 @@ class TestFormat(test.bootstrap.IFC4):
assert subject.format("round(123, 5)") == "125"
assert subject.format('round("123", 5)') == "125"
assert subject.format("round(-123, 5)") == "-125"
# Non-numeric values must pass through unchanged instead of crashing (#6776).
assert subject.format('round("Level 1", 0.01)') == "Level 1"
assert subject.format('round("12.5 m", 0.01)') == "12.5 m"
assert subject.format("int(123.123)") == "123"
assert subject.format("int(123)") == "123"
assert subject.format("number(123)") == "123"
@@ -125,22 +122,6 @@ class TestGetElementValue(test.bootstrap.IFC4):
element.Name = "Foobar"
assert subject.get_element_value(element, "Name") == "Foobar"
def test_selecting_an_elements_rotation_using_a_query(self):
# Feature test for #6262: rotation_x/y/z value keys expose the
# placement's Euler angles in degrees, e.g. for GIS symbol placement.
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
ifcopenshell.api.unit.assign_unit(self.file)
element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
theta = np.radians(30)
matrix = np.eye(4)
matrix[:2, :2] = [[np.cos(theta), -np.sin(theta)], [np.sin(theta), np.cos(theta)]]
ifcopenshell.api.geometry.edit_object_placement(self.file, product=element, matrix=matrix, is_si=False)
assert subject.get_element_value(element, "rotation_x") == pytest.approx(0.0)
assert subject.get_element_value(element, "rotation_y") == pytest.approx(0.0)
assert subject.get_element_value(element, "rotation_z") == pytest.approx(30.0)
element_without_placement = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
assert subject.get_element_value(element_without_placement, "rotation_z") is None
def test_selecting_using_a_multiple_key_query(self):
element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
material = ifcopenshell.api.material.add_material(self.file, name="CON01")
@@ -370,22 +351,6 @@ class TestFilterElements(test.bootstrap.IFC4):
assert subject.filter_elements(self.file, "IfcWall, Name=Foo + IfcSlab") == {element, element2}
assert subject.filter_elements(self.file, "IfcWall, Name=Foo + IfcSlab, Name=Bar") == {element, element2}
def test_block_comments_are_ignored(self):
element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
element.Name = "Foo"
element2 = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcSlab")
element2.Name = "Bar"
# A /* ... */ block comment lets a query be toggled off without deleting the text.
assert subject.filter_elements(self.file, "IfcWall /* + IfcSlab */") == {element}
assert subject.filter_elements(self.file, "IfcWall + /* IfcSlab */") == {element}
assert subject.filter_elements(self.file, "/* IfcWall + */ IfcSlab") == {element2}
assert subject.filter_elements(self.file, "IfcWall /* commented */ + IfcSlab") == {element, element2}
# Comments may span multiple lines.
assert subject.filter_elements(self.file, "IfcWall + /* multi\nline\ncomment */ IfcSlab") == {element, element2}
# A /* sequence inside a quoted string is not treated as a comment.
element.Name = "a/*b"
assert subject.filter_elements(self.file, 'IfcWall, Name="a/*b"') == {element}
def test_using_elements_argument(self):
wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
slab = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcSlab")
-9
View File
@@ -187,15 +187,6 @@ void IfcUtil::sanitate_material_name(std::string& str) {
}
void IfcUtil::escape_xml(std::string& str) {
// Strip characters that are illegal in XML 1.0. Control characters other
// than tab (0x09), newline (0x0A) and carriage return (0x0D) are not valid
// XML 1.0 characters and cannot even be represented as numeric character
// references, so they would otherwise make the serialized XML/SVG output
// non-well-formed. Bytes belonging to a valid UTF-8 multibyte sequence are
// always >= 0x80, so filtering on the low control range leaves them intact.
str.erase(std::remove_if(str.begin(), str.end(), [](unsigned char c) {
return c < 0x20 && c != '\t' && c != '\n' && c != '\r';
}), str.end());
boost::replace_all(str, "&", "&amp;");
boost::replace_all(str, "\"", "&quot;");
boost::replace_all(str, "'", "&apos;");
@@ -305,7 +305,7 @@ ptree* descend(Logger& logger, ifcopenshell::geometry::abstract_mapping* mapping
<IfcSchema::IfcObject, IfcSchema::IfcRelDefinesByProperties, IfcSchema::IfcPropertySetDefinition>
(logger, object, &IfcSchema::IfcObject::IsDefinedBy, &IfcSchema::IfcRelDefinesByProperties::RelatingPropertyDefinition);
#ifdef SCHEMA_HAS_IfcPropertySetDefinitionSet
#ifdef SCHEMAS_HAS_IfcPropertySetDefinitionSet
aggregate_of<IfcSchema::IfcPropertySetDefinitionSet>::ptr property_set_sets = get_related
<IfcSchema::IfcObject, IfcSchema::IfcRelDefinesByProperties, IfcSchema::IfcPropertySetDefinitionSet>
(logger, object, &IfcSchema::IfcObject::IsDefinedBy, &IfcSchema::IfcRelDefinesByProperties::RelatingPropertyDefinition);