Compare commits

...

385 Commits

Author SHA1 Message Date
Josef Wienerroither 16566f1766 Merge branch 'v0.8.0' into ifcmax/initial-refresh 2026-07-16 08:03:29 +02:00
Andrej730 e01979187a build-all: fix issue building rocksdb on gcc 15
Example error: `error: ‘uint64_t’ has not been declared uint64_t blob_file_number, uint64_t total_blob_count,`

See https://github.com/facebook/rocksdb/issues/13365
2026-07-15 19:20:17 +05:00
Andrej730 816eba5145 build-all: ensure all patches are present
Also changed type to just `list[str]` to keep it simple.
2026-07-15 19:20:17 +05:00
Andrej730 4963bddd06 build-all: fix issue on gcc 15
Error was:
```
configure: error: could not find a working compiler, see config.log for details
```

config.log:
```
conftest.c: In function 'f':
conftest.c:12:48: error: too many arguments to function 'g'; expected 0, have 6
   12 | for(i=0;i<1;i++){if(e(got,got,9,d[i].n)==0)h();g(i,d[i].src,d[i].n,got,d[i].want,9);if(d[i].n)h();}}
      |                                                ^ ~
```
2026-07-15 19:20:17 +05:00
Andrej730 8718db63da pyproject: flip ty rules to error-by-default, review all new rules added since version bump 2026-07-15 19:20:17 +05:00
Andrej730 c013b9aca7 build-all: drop unused opencollada pr622 patch
Last reference to this file was dropped in 7ae685dbf, though the ref was
pointing to `/patches/opencollada/pr622.patch`, so IIUC
`patches/pr622.patch` was never used.
2026-07-15 19:20:17 +05:00
Andrej730 24e454ce0c build-all: drop unused occt patch
Introduced in e21277e80, reference removed
in 683cadeb7 when occt was bumped to 7.3.0 and switched to git-tag based
download.
2026-07-15 19:20:17 +05:00
Andrej730 cb497b37f7 pyproject: add nix script to ty check 2026-07-15 19:20:17 +05:00
Andrej730 71c6950a59 ifcclash: fix use of undefined clash["position"]
It's an artifact from the old hppfcl clasher dropped in 18c38b312
2026-07-15 19:20:17 +05:00
Andrej730 5a8aa0a659 bsdd: raise informative HTTPError
Previously we were just passing `.json()` which allowed too many request error slip in to later occur as missing attributes on the dictionaries.
2026-07-15 19:20:17 +05:00
Andrej730 f4526d152f bsdd: warn about include_class_properties deprecation
See https://github.com/buildingSMART/bSDD/issues/149
2026-07-15 19:20:17 +05:00
Andrej730 a7a7edfd27 bsdd: fix test_get_class_relations
`classRelations` doesn't exist on `ClassPropertiesContractV1`, probably was just a typo.
2026-07-15 19:20:17 +05:00
Andrej730 6ca8c8ac94 pyproject: add more packages to dev-setup 2026-07-15 19:20:17 +05:00
Andrej730 5273569b08 build-all: fix note about the schemas built by default 2026-07-15 19:20:17 +05:00
Andrej730 78712ead98 misc: more readable poll error for import_quick_favorites 2026-07-14 19:57:31 +05:00
Andrej730 6efb8a4373 misc: add Blender 5.2 offset for Quick Favorites user_menus 2026-07-14 19:57:31 +05:00
Andrej730 9e25c12b16 surveyor: drop never used dead code
Surveyor test was failing because `get_z_rotation` and `set_z_rotation` were not implemented.
The code was added in 230cbe1fd8, but it was never used.
2026-07-14 19:57:31 +05:00
Andrej730 0968d06780 Deduplicate code by reusing tool.document 2026-07-14 19:57:31 +05:00
Andrej730 3a8619726b core.drawing: deduplicate code, fix test
Core test was trying to access actual ifc data (`ifc.get().by_type("IfcGroup")` and was failing.
2026-07-14 19:57:31 +05:00
Andrej730 1b1da821f1 file.get_inverse: document with_attribute_indices overload 2026-07-14 19:29:34 +05:00
Andrej730 d772b24bd6 geometry.add_boolean: fix typo in the class name
🫣🫣
2026-07-14 19:29:34 +05:00
Andrej730 549f81a76e ios pyproject: add networkx stubs as dev dependency 2026-07-14 19:29:34 +05:00
Andrej730 e5c7206a37 express: fix use of non-existent ifcexpressparser
`express.bnf` arg wasn't handled since d506ad77b
`ifcexpressparser` waa moved inside `ifcopenshell-python` awhile ago too
2026-07-14 19:29:34 +05:00
Andrej730 5b968d5c75 pyparsing: fix using deprecated aliases
Deprecated since pyparsing 3.0 and produce runtime warnings. New function work exactly the same, except their name is pep8 compatible.
2026-07-14 19:29:34 +05:00
Andrej730 6075187720 express_parser: fix non-idempotent results 2026-07-14 18:42:12 +05:00
Andrej730 92528d84cd express: clean up trailing spaces 2026-07-14 18:42:12 +05:00
Andrej730 3955718145 express: update transpiled express rules using latest Python's AST
AST parser has changed a bit and there are some minor differences in the .py output. Updating files just to avoid seeing these diffs when rerunning rule compiler.
2026-07-14 18:42:12 +05:00
Andrej730 501246cd0b rule_compiler: fix error running on Python 3.14
Example error:
```
    ast.Str(s=node.attr),
    ^^^^^^^
AttributeError: module 'ast' has no attribute 'Str'
```

`ast.Str` was deprecated since 3.8 and was removed in 3.14, see https://docs.python.org/3/whatsnew/3.14.html#id9
2026-07-14 18:42:12 +05:00
Andrej730 1b863ff8be pyproject: add dev-setup poe task to setup environment for ide 2026-07-14 18:42:11 +05:00
Andrej730 dcd88b6cdd pyproject: Move tool deps from to requirements-tools.txt
Because uv was always trying to install when starting a venv in `ifcopenshell` folder, though they might be already available globally. And also they were listed twice - in pyproject and in the ci-lint.yml, now there's a single source of truth.
2026-07-14 18:42:05 +05:00
Andrej730 fb8b2ee878 bonsai pyproject: move pytest deps to requirements-dev.txt 2026-07-14 18:42:05 +05:00
Andrej730 8a00ce84cc geom/main.py: fix ty complaint 2026-07-14 14:56:06 +05:00
Andrej730 4032bbbd17 entity_instance.py: fix oveloads signatures (f93d79dc)
Without `/` overload implies that it also accepts kw args, while the implementation signature doesn't support them.
2026-07-14 14:56:06 +05:00
Andrej730 d728f09d86 Bump ty
Dropping `conflicting-argument-forms` rule as it was removed in ty 0.0.49.
2026-07-14 14:56:06 +05:00
Andrej730 3e9ef82448 maintenance.rst: move pyver matrix to bundled Python version section 2026-07-14 14:56:06 +05:00
Andrej730 a5c77fd096 ci-bonsai-daily: Use Blender 5.2 for tests 2026-07-14 14:56:06 +05:00
Andrej730 56ed79792e bonsai tests: fix test_failed_to_load_returns_only_base_keys (fdb2947) 2026-07-14 14:56:06 +05:00
Andrej730 9c91727402 express: drop Python 2 fallbacks 2026-07-14 14:56:06 +05:00
Andrej730 d183961280 pyproject: support formatting with ruff
Since it's black-compatible drop-in replacement and they can be used
almost interchangeably.
2026-07-14 14:56:06 +05:00
Andrej730 d30c25010c dev_environment.py: detect Python 3.13 on any Blender 5.1+ 2026-07-14 12:18:00 +05:00
Andrej730 97d1a6e488 dev_environment.py: add shebang and make executable 2026-07-14 12:12:54 +05:00
sboddy 6dec340161 Merge pull request #8576 from IfcOpenShell/fix/docker-macos-arm64-compat
docker: more robust in getting a GID, and editing the .env file.
2026-07-13 13:09:29 +01:00
Petru Conduraru 780739719f Bonsai docs: fix version switcher scheme mismatch (http vs https)
versionURLs in brand.html used http:// while the docs sites are
served over https://, so currentURL.includes(url) never matched and
the <select> never reflected/switched to Unstable. Fixes #8023.

Generated with the assistance of an AI coding tool.
2026-07-13 20:44:19 +10:00
Petru Conduraru 4a717ca7ff Fix ci-bonsai-daily: reconnect Cost/IfcGit tool interfaces (TestImplementsTool)
Two TestImplementsTool failures on v0.8.0:

- test_cost.py: Cost could not be instantiated because
  core.tool.Cost declared abstract get_direct_cost_item_products, which
  tool.cost.Cost never implements. The method is dead (zero call sites;
  get_cost_item_products(is_deep=False) already covers the 'direct'
  case), so remove the abstract declaration.
- test_ifcgit.py: tool.ifcgit.IfcGit was not declared as a subclass of
  its core.tool.IfcGit interface (unlike every sibling tool class), so
  the isinstance check failed. Add the base class (and the
  bonsai.core.tool import it needs). All 50 interface methods are
  already implemented on the concrete class.

No behaviour change. Verified in headless Blender: isinstance(Cost(), core.tool.Cost) and isinstance(IfcGit(), core.tool.IfcGit) both True (were TypeError / False); repo abstract-vs-impl diff confirms all IfcGit abstracts are implemented.

This change was made with the assistance of an AI tool.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 20:43:25 +10:00
Petru Conduraru 5a831e3d21 Fix ci-lint: black-format selector.py
black (the version CI's psf/black@stable resolves to) flags three spots
in util/selector.py: the chained .replace() in FormatTransformer.number,
the suppress_zero_inches kwarg in format_length, and the long
`elif key in (...) and hasattr(...)` placement-key tuple in
set_element_value. Reformat all three to black's multi-line style.
Formatting only, no behavioural change (all keys preserved).

This change was made with the assistance of an AI tool.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 20:42:25 +10:00
Petru Conduraru d30286225c Bonsai: deterministic annotation order in generated drawing SVGs (#6608)
generate_annotation built the annotation list from a set union and sorted it by
ZIndex and TEXT-ness only. Annotations that tied on that key kept set iteration
order, which follows entity hash (step id plus the process memory address), so
the order of tied annotations (for example a label and its background fill)
shuffled between Blender restarts and flipped their draw order.

Add the stable IFC step id as a final tiebreaker so the order is total and
session independent. Behavior preserving, no z-layer semantics changed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:41:41 +10:00
Bruno Postle 65695fb878 ifcedit: fix Optional[entity_instance] coercion crash on native JSON values
coerce_value assumed value_str was always a CLI string, but ifcmcp
passes JSON-decoded native types (int, None) straight through. Guard
the Union/Optional "none" check so it only calls .lower() on strings,
and handle native None explicitly.
2026-07-13 08:55:43 +01:00
Bruno Postle ab15750747 ifcedit: include IfcSpace in default QTO element scope
IfcSpace is not a subtype of IfcElement, so quantify.run_quantify()'s
default selector silently skipped all spaces, reporting
elements_quantified: 0 with no error or warning.

Generated with the assistance of an AI coding tool.
2026-07-13 09:45:25 +01:00
Petru Conduraru 694a44e638 ifc4d: tolerate activities without a CalendarObjectId in P6 import (#5617)
Importing a Primavera P6 XML crashed with
`AttributeError: 'NoneType' object has no attribute 'text'` in
P62Ifc.parse_activity_xml, which read
activity.find("pr:CalendarObjectId").text unconditionally. CalendarObjectId
is optional on a P6 Activity; when omitted, the activity inherits the
project's ActivityDefaultCalendarObjectId.

Capture the project default in parse_xml and fall back to it when an
activity has no CalendarObjectId (`calendar_id or self.default_calendar_id`).

Verified on the reporter's attached file (20241021 Cronograma.xml): 3 of 14
activities lack a CalendarObjectId and reproduced the exact crash on
v0.8.0; after the fix parse_xml completes and those activities resolve to
the project default calendar "2" (a valid calendar in the file). An
activity with an explicit CalendarObjectId keeps its own value.

Fixes the P6 re-import crash reported in #5617 (that issue tracks several
Gantt items; this addresses the import AttributeError).

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 17:15:36 +10:00
Petru Conduraru a3950ac191 util.element: read property sets inside an IfcPropertySetDefinitionSet (#6330)
get_pset and get_psets assumed RelatingPropertyDefinition is a single property
definition and read definition.Name directly. When it is an
IfcPropertySetDefinitionSet (a defined type wrapping a list of property set
definitions) that attribute access raised AttributeError, so an element whose
psets are grouped in a set returned none of them.

Unpack IfcPropertySetDefinitionSet into its members in both loops and process
each one. Single property definitions and the psets_only and qtos_only filters
are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 17:06:18 +10:00
Petru Conduraru 6f90badda8 Fix ci-bonsai-daily: renumber stale STEP ids in BDD feature fixtures
Several BDD scenarios hardcode absolute representation-item object names
whose trailing number is the IFC STEP line id
(f"Item/{item.is_a()}/{item.id()}"). Those ids drift when file-creation
order changes; a recent shift moved all of them by a uniform -4, so the
scenarios failed with "Item/.../NN does not exist".

The failing step (the_object_name_exists in test_feature.py) dumps the
full bpy.data.objects listing on failure, so the correct current ids are
recoverable directly from the CI log (run 29208793599, tested commit
36e21e882f, an ancestor of HEAD with only a .gitignore commit between).
Renumber to match:
  IfcExtrudedAreaSolid/77->73, IfcPolygonalFaceSet/76->72,
  IfcVertexPoint/69->65, IfcEdge/72->68, IfcFace/74->70.

Verified against the CI failure dump (a local build produces different
ids, so this is validated by CI's own object listing rather than a local
run). boolean.feature also hardcodes IfcHalfSpaceSolid/90 and panel text
[91] downstream of the failing assertion, which CI never reached and so
never dumped; left as-is to avoid guessing - they will print a fresh dump
next run for a follow-up if still stale.

This change was made with the assistance of an AI tool.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 16:49:33 +10:00
Petru Conduraru 8b05510d6c docker: fix GID collision and macOS sed portability
Two host-environment bugs in the build-env scripts that break on
macOS/Apple Silicon hosts, independent of target architecture:

- Dockerfile: groupadd fails outright when USER_GID collides with an
  existing system group in the rockylinux9 base image (e.g. macOS
  default user GID 20 "staff" collides with RHEL's GID 20 "games").
  Guard with getent so useradd attaches to the existing group instead.
- ifcos_env: `sed -si` is GNU-only syntax and errors under BSD/macOS
  sed. Do the UNIQUE_ID substitution via a portable temp-file + mv.

Per sboddy's review on the original PR: dropped the linux/amd64
platform-pin additions from this change. The stack already targets
Rocky9/x64 build outputs by design, and Docker Desktop on macOS has
no native container runtime regardless (it's a Linux VM either way),
so forcing the image to run under emulation doesn't produce anything
that's actually loadable into a native macOS Blender/Bonsai install.
That's a separate, harder problem worth solving via a native build
path instead (mirroring build_osx.yml), not by fighting emulation
here. These two fixes stand on their own merits on any host.

This change was made with the assistance of an AI tool.
2026-07-13 09:43:57 +03:00
Stephen Boddy b1470223d3 Share ccache volume across checkouts, cap at 2G
The ccache named volume had no explicit name, so Docker Compose
namespaced it under the per-checkout project name (derived from
UNIQUE_ID), giving each checkout its own cache even though
docker/README.md already documented them as shared. Give the volume
a fixed name so all checkouts attach the same one.

Measured cache size after a full build (IfcParse+IfcGeom+IfcConvert+
wrapper, one Python version) is ~300MB, only ~5% of the previous 5G
cap. Shrink CCACHE_MAXSIZE to 2G, which comfortably covers the shared
baseline plus per-branch deltas from several diverging checkouts.

Generated with the assistance of an AI coding tool.
2026-07-13 06:42:05 +01:00
Petru Conduraru f25b072fa0 docker: make the build env work on macOS / Apple Silicon hosts
Three host-portability fixes to the docker/ toolchain from #8564 so it
runs on macOS as well as Linux. All three are no-ops on native amd64
Linux.

1. Dockerfile: only groupadd when the target GID is free. macOS's default
   primary group `staff` is GID 20, which already exists as `games` in
   rockylinux:9, so `groupadd -g 20` aborted the image build. Guard with
   `getent group "${USER_GID}" || groupadd ...`; useradd -g accepts the
   existing GID.

2. ifcos_env unique(): replace GNU-only `sed -si` (BSD/macOS sed errors
   "illegal option -- s") with a portable `sed > tmp && mv` rewrite of the
   UNIQUE_ID line. Verified against macOS BSD sed.

3. create() + compose.yaml: build with an explicit `--platform linux/amd64`
   so the locally built image's platform matches the `platform:
   linux/amd64` pin in compose.yaml. Without it, on arm64 the local image
   is tagged linux/arm64, compose treats the platform-mismatched image as
   absent and tries to pull `ifcopenshell-build-env:updated` from Docker
   Hub (which does not exist -> access denied). Also add `pull_policy:
   never` as a safety net so a future mismatch surfaces as a clear "image
   not found" rather than a registry auth error.

Note: on Apple Silicon the amd64 build runs under emulation and a cold
full build is slow; ccache makes incremental rebuilds tolerable. A native
Linux/Intel host or CI remains the better choice for routine use, but these
fixes turn "hard broken" into "works with a caveat" on macOS.

This change was made with the assistance of an AI tool.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 06:44:55 +03:00
sboddy ffb867f254 Add .gitignore entries for docker build env (#8569) 2026-07-12 22:23:42 +01:00
sboddy 36e21e882f Merge pull request #8568 from sboddy/fix-autosave-recovery-segfault
Fix segfault on autosave recovery dialog Cancel
2026-07-12 22:00:06 +01:00
Stephen Boddy d0eca6fa90 Fix segfault closing autosave recovery dialog
Reported: Blender segfaults when clicking Cancel on the "newer
autosave found" recovery popup shown by LoadProject at startup.

Root cause: LoadProject.execute()/invoke() triggered the recovery
popup via bpy.ops.bim.load_autosaved_recovery_popup("INVOKE_DEFAULT",
...) and returned that call's result ({'RUNNING_MODAL'}) as their own
return value, without LoadProject itself ever calling
modal_handler_add(). Blender's window manager takes a RUNNING_MODAL
return as a promise the operator registered its own modal handler;
since it hadn't, the WM's operator bookkeeping was left corrupted -
silently, since this is heap/state corruption rather than an
immediate crash. It only surfaced later, when the real modal operator
(the popup) closed and the WM reconciled its modal stack, which lines
up with the crash occurring specifically on dialog close regardless
of which button was pressed. check_autosave_recovery() now returns a
plain bool and fires the popup fire-and-forget; LoadProject reports
its own honest {"FINISHED"}.

Also hardened, as defense in depth: LoadAutosavedRecoveryPopup's
execute()/cancel() call back into bim.load_project(...), which (with
should_start_fresh_session) calls wm.read_homefile() and tears down
the window manager/screens. Doing that synchronously from inside this
popup's own execute()/cancel() - itself invoked from deep inside
Blender's modal handling for the popup's button click - risks the
same class of use-after-free as the timer bug fixed in the previous
commit. The reload is now deferred by one timer tick so it runs after
the popup's modal handling has fully unwound, and the deferred
callback closes over plain values rather than `self`, since the
operator instance may not survive past cancel()/execute() returning.

This defer-only change was tried and tested first, on the (incorrect)
assumption it was the root cause: it produced a byte-for-byte
identical crash backtrace on retest, which is what pointed at the
RUNNING_MODAL bug above as the actual cause - the defer change alone
was insufficient because the corruption happens when the popup is
first shown, not when it's closed.

Generated with the assistance of an AI coding tool.
2026-07-12 21:52:09 +01:00
Stephen Boddy 6306ce0f80 Fix autosave timer self-unregister crash risk
The periodic autosave timer called reset_timer() at the end of its
own callback, which unregistered the timer that was still executing
(itself). Blender frees the timer's internal registry entry on that
manual unregister, then frees it again when the callback returns
None - a double free that corrupts the heap and can crash Blender
later, once the corrupted memory is reused.

Reschedule by returning the next interval from the callback instead,
which is the safe, documented way to repeat a bpy.app.timers
callback. External reset_timer() calls (from SaveProject,
LoadProject, AutosavePrompt) are unaffected since they run from a
separate call stack (UI events), not from inside the timer.

Found while investigating a segfault reported when cancelling the
autosave recovery popup; not itself the cause of that crash (see the
following commit), but the same reentrant-unregister pattern and a
real, independent latent bug in the periodic reminder path.

Generated with the assistance of an AI coding tool.
2026-07-12 21:51:56 +01:00
sboddy 53187ddae9 Merge pull request #8564 from sboddy/docker-build-env-tooling
Add a local docker build environment for IfcOpenShell (docker/)

See PR #8564 for full explanation.
2026-07-12 20:50:53 +01:00
Stephen Boddy 92c50ed3b4 Harden docker build tooling: non-root, clean lifecycle, try()
Dockerfile (renamed from Dockerfile_init, Dockerfile_update removed):
- Run as a non-root `builder` user matching the host UID/GID (passed as
  --build-arg by create() from id -u/id -g), so build output under the
  bind mount stays owned by the host user instead of root.
- Fix CCACHE_MAXSIZE: `ccache -M 5G` wrote its limit to a config file
  under /ccache at image-build time, but /ccache is a volume mount
  point, so that file gets shadowed by the (empty) volume the moment
  the container actually runs - the cap never took effect. Set
  CCACHE_MAXSIZE=5G as an image ENV instead.
- Dedupe ccache/libffi-devel, add --setopt=install_weak_deps=False
  --setopt=tsflags=nodocs, add `git lfs install --system`, combine the
  dnf update+install into one layer.
- Drop Dockerfile_update: it built FROM its own previous output, so
  every `update` call made the image strictly larger forever (Docker
  layers are append-only, `dnf clean` in a later layer can't shrink an
  earlier one). `update` now just calls create(), which already runs
  `dnf update -y` FROM a clean rockylinux:9 every time.

compose.yaml: pin platform: linux/amd64 so this doesn't silently run
under emulation on an ARM host.

ifcos_env:
- Split the previously-conflated stop/down into six distinct,
  Compose-native lifecycle commands: up (create-or-start), down
  (remove), stop, start, restart (stop+start, same container),
  recreate (down+up, fresh container). Previously `stop` was aliased
  to `down`, which silently removed the container instead of pausing
  it.
- Implement try(): copies the built wrapper into a real Blender/Bonsai
  install for manual testing, reading the target from a new
  BLENDER_USER_RESOURCE .env variable and auto-detecting the built
  Python version (disambiguating via PY_TGT for multi-version builds).
  Deliberately kept human-only - it mutates a live Blender install, so
  it shouldn't run unattended as part of an automated/AI workflow,
  which should instead copy the wrapper into the repo's own
  src/ifcopenshell-python/ifcopenshell/ (documented in SKILL.md).
- Fix unique(): the "has .env already got a UNIQUE_ID line" check
  referenced an unset $FILE instead of $ENV_FILE, so it always
  evaluated true and appended a fresh "UNIQUE_ID=dummy" line to .env
  on every single `up`.
- Minor: differentiate remove()'s log message from down()'s (no longer
  identical now that they're distinct operations), tidy help text
  alignment and a stray double-space typo in clean().

SKILL.md: rewritten as current-state documentation (no more "fixed in
this copy" changelog framing) covering the above, plus a migration
note for anyone hitting root-owned leftovers from an older image.

Verified by actually building the image and driving every new
lifecycle command (stop/start/restart keep the same container ID;
down+up and recreate produce a new one) and try() (including the
quoted-tilde BLENDER_USER_RESOURCE edge case) against the real container.

Generated with the assistance of an AI coding tool.
2026-07-12 20:35:01 +01:00
Stephen Boddy fa98aad469 First docker build environment
First functional version, but it needs some improvements and fixes
identified as I've used it personally on one thing, and when an AI
(Claude) used it to work through the CI test errors.

I had the AI make a SKILL.md file. If the AI indicates it needs to
build the ifcopenshell binary, use this and let it rip.
2026-07-12 18:43:27 +01:00
Petru Conduraru 980988f208 Bonsai: fix KeyError in format_distance for kilometre and mile units #8255
The project-unit to Blender-unit mapping in format_distance only knew
FOOT/INCH/METRE/DECIMETRE/CENTIMETRE/MILLIMETRE, so creating a project
with Kilometers or Miles in the New Project Wizard crashed with
KeyError: 'KILOMETRE' (or 'MILE') as soon as the spatial tree formatted
an elevation. Add the missing Blender-supported units (kilometre, mile,
micrometre) and fall through gracefully for anything else (for example
HECTOMETRE) so unknown units use the adaptive formatting branch instead
of raising.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 22:51:37 +10:00
Petru Conduraru d4805387ef Selector: add rotation_x/y/z value keys #6262
Expose the Euler rotation of an element's placement in degrees through
get_element_value, alongside the existing x/y/z and easting/northing/
elevation keys. This makes element rotation exportable through ifccsv,
e.g. for placing oriented symbols in GIS.

Adopts the approach agreed in the review of the stale PR #6272 by
@TZwielehner: reuse util.shape_builder.np_matrix_to_euler and do the
degree conversion inside get_element_value.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 22:49:43 +10:00
Petru Conduraru 69a4be68e8 Bonsai: fall back to adaptive units for unsupported SI prefixes #8074
Project loading set scene length_unit to f"{Prefix}METERS", but Blender's
enum only defines KILOMETERS, CENTIMETERS, MILLIMETERS and MICROMETERS.
A model with a DECIMETRE (or HECTO/DECA/etc.) length unit therefore raised
on the enum assignment and the file failed to open. Guard with the set of
supported values and fall back to ADAPTIVE display for the rest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 22:38:10 +10:00
Petru Conduraru 06da416b8f docs: remove TODO placeholder sections from the create-model quickstart #8208
The quickstart ended with three empty sections whose bodies were only
"TODO" (placing occurrences, changing locations, modeling a building),
which read as a dead end on docs.bonsaibim.org. The page now ends on the
completed save-and-view flow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 22:37:34 +10:00
Petru Conduraru 21ae78fbc2 resource.assign_resource: fix typo in duplicate guard #8203
The guard that avoids re-assigning the same object to the same resource
tested is_a("IfclRelAssignsToResource") (stray "l"), so it never matched.
A repeat assignment therefore fell through and appended the related object
to RelatedObjects a second time. Corrected to "IfcRelAssignsToResource".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 22:36:26 +10:00
Petru Conduraru 0a8ae14789 fix(ifcdiff): check attributes by default so PredefinedType changes are caught (#8214)
IfcDiff defaulted to relationships=["geometry"], so a plain diff only ever
compared geometry. Attribute-only edits on an element that kept its GlobalId
(a modified or removed PredefinedType, a renamed element, etc.) were silently
missed. The CLI made this worse: --relationships did not list "attributes" or
"geometry" as valid values, so there was no documented way to enable it.

The default is now ["attributes", "geometry"], so a plain `ifcdiff old new`
reports attribute changes alongside geometry changes. The CLI help and the
IfcDiff docstring now document all valid relationship values.

Added a regression test covering a PredefinedType change detected with the
default configuration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-12 22:29:31 +10:00
Petru Conduraru 2eea7728d2 fix(selector): round() should not crash on non-numeric values (#6776)
FormatTransformer.round() called Decimal() directly on the input value,
which raises decimal.InvalidOperation when the value is a non-numeric
string (a text property, or a value carrying a unit suffix like "12.5 m").
In a spreadsheet export this crashed the entire operation as soon as one
element carried such a value.

Now round() catches InvalidOperation and returns the value unchanged, the
same graceful-fallback convention used by add(). Numeric rounding is
unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-12 22:28:38 +10:00
Petru Conduraru 6b3cc54afc ifcfm: convert COBie Coordinate space points to project units (#5926)
In the cobie24 Coordinate sheet, Floor rows use get_local_placement, whose values
are in the project length unit, but Space rows come from ifcopenshell.geom
create_shape, whose vertices are in SI metres, and the space branch never scaled
them back. So on a non metre model (for example millimetres) the Coordinate sheet
mixed units a thousandfold apart and disagreed with the Facility sheet's declared
LinearUnits.

Scale the space bounding box by the project unit scale so the whole Coordinate
sheet is consistent. A metre model is unchanged since the scale is 1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 22:27:07 +10:00
Petru Conduraru 5c11946470 Support block comments in selector filter syntax (#5023)
The filter_elements selector grammar had no way to comment out part of a
query, so users had to delete and retype text to temporarily toggle a
facet. Add a /* ... */ block comment terminal that is ignored by the
lexer, and tolerate a trailing "+" so that commenting out the final
operand (e.g. "IfcWall + /* IfcSlab */") parses cleanly. Comments may
span multiple lines; a /* sequence inside a quoted string is not treated
as a comment. Only the filter grammar is affected, not get_element or
format which use "/" for regex and division.

Adds a regression test and documents the syntax.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 22:23:30 +10:00
Josef Wienerroither 06d8b738e2 Merge branch 'v0.8.0' into ifcmax/initial-refresh 2026-07-12 07:52:15 +02:00
Ryan Schultz 0b7e25a3ef Docs: clarify immediate vs. any-depth spatial selectors
The location and parent filters both match at any depth in the spatial
hierarchy, which surprises users who want only the elements immediately
under a given container. Document that the parent query key resolves the
direct parent only (e.g. query:"parent.Name"="My Site"), add a matching
filter example, and note the immediacy on the parent value key.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 16:44:22 -05:00
Ryan Schultz d16c283aef Add bulk-load of selected drawings' annotations (#8525)
SHIFT+CTRL+CLICK on Activate Drawing now imports the
annotations of all selected drawings without switching
the active view or camera, then selects their cameras with
the first as active. SHIFT+CTRL+ALT+CLICK also selects the
loaded annotation objects. The drawing camera is imported
when missing so annotations land in the correct collection.
Loading is idempotent.

Generated with the assistance of an AI coding tool.
2026-07-11 15:54:20 -05:00
Petru Conduraru a0f493b471 IfcConvert: report an error when the output file cannot be opened (#438)
Converting to a path whose directory does not exist (or is not writable)
failed silently: the serializer's ready() check correctly returned false,
but IfcConvert deleted the temp file and returned EXIT_FAILURE without any
message, so the user saw no reason for the failure.

Log a SYS error naming the output file before returning, matching the
existing "Unable to open output file" reporting used elsewhere.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 15:28:51 +02:00
Petru Conduraru e389939092 serializers: expand IfcPropertySetDefinitionSet in XML output (#6330)
Property sets contained in an IfcPropertySetDefinitionSet were exported as
an empty element in XML. The XmlSerializer already had a block to expand
such a set into its member property sets, but it was gated behind
#ifdef SCHEMAS_HAS_IfcPropertySetDefinitionSet while the schema generator
emits SCHEMA_HAS_IfcPropertySetDefinitionSet (singular). The plural spelling
is defined nowhere, so the block was dead code and a RelatingPropertyDefinition
holding a set produced nothing.

Correct the macro name so the set is expanded and its property sets are
serialized. The parse layer already reads these nested sets (they are
reachable from util.element), so this only completes the XML path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 15:26:49 +02:00
Petru Conduraru 380675e214 ifcparse: strip XML-illegal control characters in escape_xml (#2043, #3074)
escape_xml escaped the five XML metacharacters but passed control
characters (0x00 to 0x1F other than tab, newline and carriage return)
through unchanged. Those bytes are illegal in XML 1.0 and cannot be
represented even as numeric character references, so any IFC string
containing them produced non-well-formed XML and SVG output.

Strip those illegal control characters before escaping. Bytes belonging to
a valid UTF-8 multibyte sequence are always >= 0x80, so filtering on the low
control range leaves real text intact. This is the shared helper used by the
SVG serializer text and attribute sites (audited: all route through it) and
by the XML/Collada paths, so both reports are resolved at one place.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 15:20:47 +02:00
Petru Conduraru 3e55c5126c ifcgeom: honour PnIndex in triangulated and polygonal face sets (#3434)
IfcTriangulatedFaceSet and IfcPolygonalFaceSet used CoordIndex values to
index Coordinates.CoordList directly, ignoring the optional PnIndex
attribute. When PnIndex is present it remaps point references, so a
CoordIndex value i must resolve as CoordList[PnIndex[i-1]-1] (both 1-based).
Without the indirection any model carrying a PnIndex was built from the wrong
points.

Add a resolve() helper in both mappings that applies the PnIndex indirection
when present and is a plain bounds-checked lookup otherwise, with bounds
checks at both index levels. When PnIndex is absent the behavior is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 15:19:06 +02:00
Petru Conduraru 7e3d2f936d build: do not request the header-only Boost.System component (build against Boost 1.70+)
Boost.System has been header-only since Boost 1.69 and its compiled stub
library was removed in newer Boost, so listing system in the requested
find_package components makes configuration fail on Boost 1.70 and up (for
example Boost 1.90 errors with "Could not find boost_system"). Boost.System
is still pulled in transitively by thread / iostreams where it is needed, so
drop it from the explicit component list.

Verified: with this change IfcOpenShell configures and builds IfcConvert
cleanly against Homebrew Boost 1.90 and OpenCASCADE 7.9.2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 15:16:35 +02:00
Petru Conduraru 0d70812641 Make CGAL circle-segments 0-default deflection-driven (rework #8368)
Address maintainer request on #8368: instead of a deflection floor on top
of a fixed CircleSegments count, use one mode or the other. When
CircleSegments == 0 (the new default) the CGAL kernel derives the conic
segment count from MesherLinearDeflection, matching the deflection based
meshing OpenCascade already does and fixing #8051. When CircleSegments is
non zero it is used directly as a fixed, radius independent count.

CircleSegments is only read by the CGAL kernel; OpenCascade meshes by
deflection and never reads it, so the new default has no effect there.

Update the setting description and the ifcconvert / geometry-settings docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 15:12:53 +02:00
Petru Conduraru dd9fa65629 Fix cgal kernel under-tessellating large-radius arcs (#8051)
The CGAL kernels (cgal and cgal-simple) allocate arc segments as a
fraction of the full circle via CircleSegments, ignoring the radius.
A large-radius arc that spans a small angle therefore collapsed to a
single chord, turning curved curtain-wall mullions straight while the
OpenCascade kernel (which meshes by deflection) kept them curved.

evaluate_conic now also enforces a deflection-based floor on the number
of segments, keeping the chord deviation within mesher-linear-deflection,
matching OpenCascade. Small circles are unchanged (CircleSegments floor
still dominates); only large-radius curves get denser.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 15:12:53 +02:00
Petru Conduraru eb7324e7fc IfcConvert: add --fail-on-error to exit non-zero when conversion logs errors (#1118)
IfcConvert returned a success exit code even when geometry conversion logged
errors and silently dropped elements (for example a failed TopoDS::Shell build
under layerset slicing produced valid looking output with most objects
missing), so CI and scripts could not detect a partial conversion.

Add an opt-in --fail-on-error flag that makes IfcConvert exit non-zero when any
error was logged during processing, reusing the existing MaxSeverity based
failure check already used for --validate. The default exit behaviour is
unchanged, so pipelines that tolerate individual element failures are
unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 15:10:03 +02:00
Petru Conduraru 061bb90d50 Warn when a face inner boundary intersects another boundary (#527)
A face whose inner boundary crosses the outer boundary (or another inner
boundary) is invalid per the schema. Open Cascade silently heals or drops
such a face, so the intended hole is lost or the face is corrupted with no
diagnostic at all (the 2018 report saw a dropped face; on the current line
the face survives as wrong geometry, still silently).

After the wires are collected, if a face has inner boundaries, measure the
BRepExtrema distance between each inner wire and every earlier wire. Two
non intersecting loops have strictly positive distance, so a distance at
or below the modelling precision means the boundaries touch or cross; emit
a warning (GEO 402) naming the offending face. This is diagnostic only, no
geometry change.

The message is emitted via the kernel logger() rather than Logger::Root():
IfcConvert configures a local Logger and worker logs merge into it, while
Logger::Root() is a separate unconfigured singleton whose messages are
discarded (a latent issue affecting some existing GEO messages too).

Verified on OCC 7.9.2 with synthesized IFC4 faces: an inner triangle
crossing the outer edge, and one straddling the bottom edge, each emit one
GEO 402; a valid 4x4 hole emits none and triangulates identically (area
84.0), in both sequential and multithreaded runs. Pure inner self
intersection and full containment are distinct classes and intentionally
left untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 14:39:03 +02:00
Petru Conduraru a8d0ef3437 Add AI-generated marker to IfcAsymmetricIShapeProfileDef.cpp
Comply with AGENTS.md: new AI-generated files must carry a top-of-file
comment indicating AI assistance.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 14:24:10 +02:00
Petru Conduraru 438c0955f2 Map IfcAsymmetricIShapeProfileDef standalone in IFC4+ (#1367)
In IFC2X3 IfcAsymmetricIShapeProfileDef is a subtype of
IfcIShapeProfileDef, so the IfcIShapeProfileDef mapping dispatched it by
inheritance. From IFC4 onwards it is a standalone subtype of
IfcParameterizedProfileDef, so nothing mapped it and the extruded solid
came out empty (GEO326, 0 verts).

Add a dedicated map_impl that builds the twelve-point asymmetric section
(independent bottom/top flange widths, thicknesses, fillet/edge radii and
flange slopes), plus a guarded BIND. Both are wrapped in
SCHEMA_IfcAsymmetricIShapeProfileDef_HAS_BottomFlangeWidth, which is only
defined where the type is standalone, so IFC2X3 keeps its existing
subtype route unchanged.

Verified on OCC 7.9.2: an IFC4 asymmetric extrusion goes from 0 verts to
a correct 72-vert solid (bottom flange wider than top); IFC2X3 output is
unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 14:24:10 +02:00
Stephen Boddy b9deb9c63d Git ignores CLAUDE.local.md file
This allows a file that will be automatically picked up by Claude. It can either
be a copy of a CLAUDE.md, or a one line file pointing to a shared common file. i.e.

@~/.claude/conventions-ifcopenshell.md
2026-07-11 13:10:14 +01:00
Josef Wienerroither 4d8633d40d Merge branch 'v0.8.0' into ifcmax/initial-refresh 2026-07-11 13:50:38 +02:00
sboddy e14b3ec8a0 Merge pull request #8243 from sboddy/feature-5753-autosave
Feature #5753 - Autosave for ifc files

Merging because it could be a life saver. It is hidden behind an option and is off by default.

- Provides the option have an autosave file created periodically (duration in prefs).
- Can be set to save immediately or a dialog prompt to save, but can be dismissed.
- Removes the autosave when Blender quits cleanly.
- If the autosave file exists at startup, it will prompt which file to load.

_Every_ AI had a hand in this, but I have reviewed, understood and tested it. AI Credits go to:
Cursor, Grok, Copilot, and Claude.
2026-07-11 11:10:59 +01:00
Stephen Boddy c0d2c2ea24 Fix upstream ci-lint failures on this branch
- autosave.py: black formatting (blank line) and ruff's
  collections.abc.Callable import fix.
- project/__init__.py, tool/__init__.py: ruff import-sort fixes. The
  autosave import in tool/__init__.py is deliberately kept last (must
  come after tool.drawing, per its existing comment) via `# isort: skip`
  rather than letting ruff move it, which would reintroduce that bug.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-11 10:50:00 +01:00
Stephen Boddy 0ce6e94352 Make autosave recovery prompt properly modal
The recovery popup used invoke_popup, which is dismissed the instant
the mouse leaves its bounds - closing the prompt without loading
either file, and with no visible feedback that anything happened.

Switches to invoke_props_dialog, which blocks the rest of the UI and
is only dismissed by an explicit action. Since Blender always renders
both a fixed "Cancel" button and one labelled by confirm_text on that
dialog type, the prompt is reframed as a direct Yes/Cancel question
("Do you want to load the autosaved version instead?") instead of
adding separate Load Original/Load Autosave buttons on top of those.

Folds the load logic directly into the popup's execute()/cancel(), so
the now-redundant LoadAutosavedRecovery operator is removed.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-11 10:50:00 +01:00
Stephen Boddy be55400ec6 Remove stale autosave file on clean Blender quit
Previously the autosaved copy was only ever overwritten, never removed,
so a deliberate quit (whether the user saved or chose "don't save")
still nagged with a recovery prompt on next startup.

Registers an atexit cleanup that removes the active IFC's autosave
file(s) on a graceful interpreter shutdown. atexit never runs on an
actual crash, so a genuine crash still leaves the recovery file in
place as before.

The cleanup reads a cached plain-string path kept up to date by
reset_timer(), rather than looking it up live via bpy.context - by
the time atexit fires, Blender's C++ side is torn down far enough
that even a read-only bpy.context.scene access aborts the process
(std::bad_optional_access) instead of raising a catchable exception.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-11 10:50:00 +01:00
Stephen Boddy 6f1737bb58 Feature #5753 - Autosave for ifc files
Implemented as described in #5753, with two options:
- A nag dialog with save or cancel options.
- An autosaved file.

Settings are in preference to activate the feature (default: off), the period before prompting/saving,
and choosing between the two methods.

Prevent the autosave file being added to the recent files list when the user opens the original, but selects to open the autosaved version.

black/ruff

This commit was created using AI assistance. Cursor for the initial code, then Grok and I fixing all the errors
that Cursor made. Finally Copilot did a code review.

I have reviewed and tested the code, and I understand it, and it works and does not introduce any obvious bugs.

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Grok
Co-authored-by: Cursor
2026-07-11 10:39:48 +01:00
sboddy 256d5a63f1 Merge pull request #8495 from sboddy/lint-pass
Fix ci-lint failures: black formatting, ruff unused imports, ty type errors
2026-07-10 22:47:46 +01:00
Stephen Boddy c4605f2a8f Fix lint drift introduced by merging v0.8.0 into lint-pass
- add_stationing_referent.py: black reformat (new drift from v0.8.0).
- update_fallback_position.py: v0.8.0's changes to this file made the
  ifcopenshell.util.unit import (added in an earlier commit here) unused;
  removed per ruff.
2026-07-10 22:21:23 +01:00
Stephen Boddy 4a62ffe9ca Merge remote-tracking branch 'origin/lint-pass' into lint-pass 2026-07-10 22:20:29 +01:00
Stephen Boddy d5e890bccd Fix ty-ios type-check errors (ifcopenshell-python side)
poe ty's sequence only reaches ty-ios once ty-bonsai passes, so these
never surfaced until now:

- util/alignment.py: drop the stale `include_referent=False` kwarg from
  add_zero_length_segment() - that parameter was removed from the function's
  signature in 45ea5eb07 but this caller in a different file was missed,
  leaving a latent TypeError if this code path is ever exercised.
- ifcopenshell_wrapper.pyi: add the optional trailing `logger` parameter to
  parse_ifcxml/open/construct_iterator*, matching the real SWIG signatures
  in src/ifcwrap/*.i (all declare `Logger& logger = Logger::Root()`) that
  the hand-maintained stub never picked up.
- ifcopenshell/__init__.py: remove a stale `ty: ignore[unknown-argument]`
  comment that ty confirms is no longer suppressing anything.
- assign_cost_item_quantity.py: OPERATORS mixes 2-arg binary operators with
  the 1-arg `operator.neg` (for ast.USub), but FormulaEvaluator has no
  visit_UnaryOp so USub can never reach this lookup via visit_BinOp.
  Suppressed at the call site rather than touching the dict, since this
  looks like scaffolding for unary-minus support rather than dead code.
- Explicit submodule imports (ifcopenshell.geom / api.alignment / util.unit
  / api.aggregate / api.context / api.spatial) added where accessed but
  only reachable by accident of import order.
2026-07-10 22:19:56 +01:00
sboddy bba11aa619 Merge branch 'v0.8.0' into lint-pass 2026-07-10 21:53:44 +01:00
Stephen Boddy 9f848a73e1 Fix remaining ty type-check errors in tool.py, product.py, railing.py
- tool.py: drop the `-> int` annotation on the Parametric interface's
  get_geom_generation stub; its `pass` body implicitly returns None, which
  ty can't reconcile with the runtime @interface/@abstractmethod rewriting
  it never sees statically. Matches the file's other stubs (-> None).
- railing.py: qualify the "BIMRailingProperties" string annotations as
  "prop.BIMRailingProperties" on the two functions using it, since the bare
  name was never imported into this module's namespace.
- product.py: suppress ty's missing-argument errors on
  copy_z_rotation_to_selected's Surveyor.get_z_rotation/set_z_rotation
  calls with targeted ty: ignore comments. The function is unused and its
  two dependencies were never implemented on the concrete Surveyor tool;
  left as-is rather than deleted or implemented.
2026-07-10 21:45:31 +01:00
Stephen Boddy 4fb8af2278 Fix ty type-check errors: missing imports and unresolved names
- gizmos.py: TYPE_CHECKING-guard `import bmesh` for the string-literal
  annotation in build_schematic_mesh; suppress the still-unresolved
  gizmo_textures import in TexturedQuadGizmoMixin (WIP dependency, not dead
  code).
- model/__init__.py: register the `decorator` submodule, which unregister()
  already calls (would have raised NameError on addon disable).
- mep.py / tool/model.py: add explicit imports for bonsai.core.geometry and
  bonsai.core.model, previously only reachable by accident of import order.
- Test files: add explicit ifcopenshell.api.pset / ifcopenshell.util.element
  submodule imports used but not imported.
2026-07-10 21:27:10 +01:00
Stephen Boddy 78653a1708 Remove unused imports flagged by ruff
Fixes 23 unused-import violations, mostly in the alignment API module.
2026-07-10 20:42:49 +01:00
Stephen Boddy 216092150a Apply black formatting to fix CI lint-formatting drift
20 files had fallen out of sync with the project's black version;
running `black .` brings them back in line with no logic changes.
2026-07-10 20:42:18 +01:00
Richard Brice ade03b171a Fixes bug with fallback position introduced in 206cd6bb 2026-07-10 09:54:03 -07:00
Richard Brice b5c1b81ede Stationing referent can optionally be located relative to the basis_curve (default) or the alignment curve 2026-07-10 09:46:11 -07:00
Richard Brice 47a20f0c7c Locates positioning referent on the alignment curve, not the basis curve 2026-07-10 09:45:38 -07:00
Richard Brice 52d894298e Fixes double unit conversion when convert-back-units are used 2026-07-10 17:09:51 +02:00
Richard Brice 206cd6bbe1 Alignment API update for station and positioning referents. Fixes bug with fallback position. 2026-07-09 14:10:33 -07:00
Gorgious56 9ae79b42dd Merge pull request #8398 from Gorgious56/batch-array-duplicate-helper
Batch array duplicate helper
2026-07-08 15:27:53 +02:00
Gorgious56 c01433cb6c Bonsai: spec typed test doubles for Blender + dataclass mocks
Convert bare Mock() to Mock(spec=bpy.types.Object) for Blender-object
stand-ins in TestRecalculateWallsWithNewConnections, TestMEPActionGuards,
and TestRecreateAggregateIteratesAllNew so typos on the Blender API
fail loudly instead of silently returning a MagicMock.

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

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

Relates to #8088.

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

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

Relates to #8088.

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

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

Relates to #8088.

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

Relates to #8088.

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

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

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

Relates to #8088.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Generated with the assistance of an AI coding tool.
2026-07-05 23:45:43 +01:00
Bruno Postle b549e65ad9 bonsai: restore descriptive name for test_copy_with_new_geometry_copied_from_the_old
The underlying bug (has_material_styles bypassing the tool layer) was
already fixed by e76455913, which added the required mock expectation
here, but left the test under its quarantine placeholder name
test_AAAAAAAAAAAA. Restore the real name now that it genuinely passes.
2026-07-04 17:38:13 +01:00
Bruno Postle ee5d672493 tests: fix test_memusage_partial_open and add psutil to CI
test_memusage_partial_open was silently skipped in CI (psutil was
never installed there). Add psutil so it actually runs, and run the
RSS measurement in a subprocess so the fixture file isn't already in
the page cache from earlier tests, which was making both deltas read
as zero.

Generated with the assistance of an AI coding tool.
2026-07-04 17:32:44 +01:00
Bruno Postle 135f4cf023 tests: skip mathutils tests on Python < 3.13
mathutils only ships pre-built wheels for Python 3.13+ (verified
against PyPI's file list); on CI's Python 3.11, `pip install
mathutils` falls back to a slow/unreliable source build. Skip the
mathutils-dependent tests when the interpreter is too old instead.
2026-07-04 17:29:10 +01:00
Bruno Postle 608d9ead0e ci: add test coverage for ifc5d, ifcquery, ifcedit, ifcmcp
These packages already have their own pytest suites (ifc5d, ifcedit,
ifcmcp, ifcquery) but were never run in CI, so regressions in them
went unnoticed. Add path triggers and test steps for all four, plus
odfpy and xlsxwriter which ifc5d's spreadsheet export tests need and
mcp which ifcmcp's server tests need.

Generated with the assistance of an AI coding tool.
2026-07-04 17:22:42 +01:00
Bruno Postle a0ce930994 ifc5d: fix two csv2ifc bugs found by round-trip test
ItemIsASum and Quantities are exporter columns that were missing from
MAIN_CSV_HEADER_COLUMNS, causing them to be misidentified as numeric cost
value categories on re-import. Also initialise rate_cost_schedule to None
before the search loop to avoid UnboundLocalError when no match is found.

Generated with the assistance of an AI coding tool.
2026-07-04 16:08:43 +01:00
Bruno Postle eafa158ca0 Allow drawing generation in background mode
is_drawing_active() required an open VIEW_3D area purely as a poll()
gate for bim.create_drawing, even though SVG generation is
ifcopenshell.geom-based with no viewport dependency; skip that check
when bpy.app.background is true, since a viewport is neither
obtainable nor meaningful there. Interactive behaviour is unchanged.

Generated with the assistance of an AI coding tool.
2026-07-04 09:34:05 +01:00
falken10vdl 20b68ce0a9 Organize drawings under a parent called DRAWINGS in ifc groups and ifc documents 2026-07-03 14:52:29 +02:00
Bruno Postle 5db955d40c Apply link matrix when serialising linked drawings
Linked IFC files were included in SVG output but without their
world transform, causing geometry to appear at wrong coordinates.
Falls back to no transform if the link cache is unavailable.

Generated with the assistance of an AI coding tool.
2026-07-03 11:30:31 +01:00
Bruno Postle df27f86237 Fix linked drawings hidden on drawing activation
Link empty handles were missing from visible_objects, so linked
models were always hidden when activating a drawing.

Generated with the assistance of an AI coding tool.
2026-07-03 11:25:19 +01:00
falken10vdl 0aaafaedc9 Merge pull request #8014 from falken10vdl/surface-style-small-fixes
Surface styles fixes and message warnings
2026-07-03 09:27:12 +02:00
falken10vdl 7881f5992f Add warning when PHYSICAL/NOTDEFINED uses IfcColourRgb for Metallic, because this value is IFC-only and does not affect Blender appearance. 2026-07-03 09:02:57 +02:00
falken10vdl 679fe4dcae Add warnings for emissive and specular ratios in FLAT reflectance method (IFC only no Blender appearance) 2026-07-03 09:02:57 +02:00
falken10vdl a89621b179 Fix Lighting/refraction UI drawing crashes and add warning message that they are only IFC data not used by Blender for surface appearance 2026-07-03 09:02:57 +02:00
falken10vdl 110e4050c8 Add warning messaging for unsupported reflectance methods and texture modes 2026-07-03 09:02:56 +02:00
falken10vdl 6dafb7a5c2 Avoid duplicate image datablocks when loading textures 2026-07-03 09:02:56 +02:00
falken10vdl 4cedeec813 Fix FLAT+EMISSIVE texture loading crash 2026-07-03 09:02:56 +02:00
falken10vdl 6314d9c818 avoid full shader rebuilds in intermediate property write 2026-07-03 09:02:56 +02:00
falken10vdl 9bbd2b1854 Allow UV mode selection in Loader and add UI warning for SOLID Mode (no Generated or Camera UV) 2026-07-03 09:02:56 +02:00
falken10vdl b5d36aacf6 Load styles after removing surface style in RemoveSurfaceStyle operator so UI List is updated 2026-07-03 09:02:56 +02:00
sboddy 6d3bed1f7d Merge pull request #8238 from sboddy/copilot/featurecamera-shift-xy-drawings
Implement #5628 - Camera X/Y shift for perspective drawings
2026-07-02 21:14:55 +01:00
Ryan Schultz 0096c0f6a2 Bonsai: don't wipe link query when reload_link is called without one
bpy.ops.bim.reload_link(link_index=...) from a script skips invoke(),
so self.query stayed at its empty default and execute() overwrote the
link's stored query, reloading everything. Only update link.query when
the property was explicitly set (dialog or script argument), and reload
using the stored query.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 14:50:21 -05:00
Stephen Boddy 661be0d66d Tweak the Copilot generated UI code so it looks better 2026-07-02 19:59:52 +01:00
copilot-swe-agent[bot] 62ed650b75 Polish camera shift support 2026-07-02 17:47:30 +00:00
copilot-swe-agent[bot] da4b2f6eee Add camera shift sync 2026-07-02 17:46:11 +00:00
Gorgious56 00ec587296 Bonsai: persist link IFC query across reload
Store the selector query used at Link IFC time on the Link
PropertyGroup, restore it from the sidecar cache JSON on host
IFC reopen, and forward it through LoadLink and ReloadLink so
subsequent reloads replay the original filter instead of loading
every element. ReloadLink now opens a small dialog pre-populated
with the current query, allowing the user to edit it in place
without unlink-and-relink.

Also swap TestCalculateLinkMatrix off NamedTemporaryFile(delete=True)
which held an exclusive Windows handle and blocked the
code-under-test from reopening the sidecar path.

Closes #8219

Generated with the assistance of an AI coding tool.
2026-07-02 14:04:29 +02:00
Eivind Pagander Tysnes 2a05528b6d Documentation: Introduction to Ifc changed
After feedback on PR changed tip to be a single tip for easier and
more correct reading.
Removed 3 trailing backspaces
2026-07-02 21:39:22 +10:00
Eivind Pagander Tysnes a5f7f0cd93 Updated documentation
Updated the introduction to IFC to have IFC 4x3 be a published
version from 2024 and updated tips to recommend using IFC 4x3
for infractructure
2026-07-02 21:39:22 +10:00
Gorgious56 1fd7329122 Merge pull request #8234 from Gorgious56/fix-apply-opening-crash-on-non-fillings
Bonsai: fix Apply Opening crash on non-fillings
2026-07-02 12:11:06 +02:00
Thomas Krijnen f3e047d78e Schema compatibility #8230 2026-07-02 12:01:57 +02:00
Gorgious56 041306c5f0 Bonsai: extract is_filling_supported + guard aggregate hosts
Fold two related cleanups from post-PR review into one commit:

Shared filling predicate — the gizmo poll and AddOpening._add_openings
both need to decide whether an IFC entity is a Bonsai-supported filling
(IfcDoor / IfcWindow, the classes the opening generator can derive
geometry from). Centralise the check in bim.module.model.opening as
is_filling_supported so a schema-broadening tomorrow only edits one
predicate. The gizmo's own predicate is renamed
is_supported_filling_or_opening to reflect its wider domain (also
accepts None for raw meshes and IfcOpeningElement for reassignment).

Aggregate-host guard — regenerate_filling_opening_body returns the
voided host Blender object so callers can recut it. Aggregates have
no mesh data; returning them made callers hit switch_representation
against a None data-block. Guard on voided_obj.data is None and
return None so callers can skip cleanly.

Adds a direct position_gizmos test asserting host-at-index-1 (filling
active) still anchors on the slab — pins the class-based dispatch's
selection-order independence.

Generated with the assistance of an AI coding tool.
2026-07-02 11:44:33 +02:00
Gorgious56 5fba0026dd Bonsai: fix ruff import-sort drift in geometry+model ui
Both files interleaved bpy.types imports with ifcopenshell.util
imports, which ruff's I001 rejects for standard-library / third-party
ordering. Running ruff check --fix on the two files reorders them into
the isort-canonical shape with no behaviour change.

Generated with the assistance of an AI coding tool.
2026-07-02 09:19:50 +02:00
Gorgious56 4d92a64206 Bonsai: skip sibling refresh on show/hide toggle
EditOpenings.edit_openings unconditionally walked sibling wall sets
twice on every processed opening — once by mapped source id via
get_similar_openings_building_objs, once by filling type via
get_all_building_objects_of_similar_openings — and unioned both into
the building_objs recut set. reload_body_representation then hit every
one of those walls with a switch_representation call, even for the
show/hide toggle path where nothing about the opening changed.

Move both sibling-wall unions inside the is_edited / is_moved branch.
Pure show/hide (no shape edit, no move) now touches only the wall(s)
directly hosting the toggled openings. The edit and move paths still
refresh siblings the same as before, since a mapped-source rewrite
propagates the new shape to every sharing wall and each one needs a
recut.

Generated with the assistance of an AI coding tool.
2026-07-02 09:18:13 +02:00
Gorgious56 9d2de117a9 Bonsai: sync filling placements on wall regen
recalculate_walls commits the wall's own placement to IFC before
recreating its geometry but did not touch its fillings. A door moved
along the wall's reference line therefore stayed cut at its old
position when the user pressed SHIFT+G on the wall, because the wall
recut ran against the still-stale opening placement in IFC.

Walk each wall's HasOpenings and, for every filling whose Blender
matrix_world differs from its committed IFC placement (tool.Ifc.is_moved),
commit the filling's placement and propagate the new matrix to the
enclosing opening via ifcopenshell.api.geometry.edit_object_placement.
The subsequent recreate_wall pass then sees the fresh opening positions
and cuts at the right spot.

Generated with the assistance of an AI coding tool.
2026-07-02 09:13:04 +02:00
Gorgious56 6ee3c7a15f Bonsai: restore opening regen on recalculate_fill
Commit 82dd1d94d switched RecalculateFill from
bonsai.core.geometry.switch_representation to the surgical
tool.Geometry.recut_host to speed up batched host recuts. The trade-off
was intentional for that scope but dropped the implicit opening body
refresh that switch_representation used to provide: SHIFT+G on a door
whose parametric dimensions had drifted from its opening no longer
resized the opening, so the wall recut still hit a stale mapped source.

Extract a targeted single-source helper on tool.Model
(regenerate_filling_opening_body) that regenerates one filling's
mapped opening body via the existing FilledOpeningGenerator and
inverse-substitutes the new representation across every filling that
shares the mapped source. Refactor the family-wide caller
(update_simple_openings, used by the parametric-edit finish path) to
delegate to the same helper, deduped by source id so fragmented type
families where multiple mapped sources coexist all get refreshed.

Call the targeted helper at the top of RecalculateFill._recalculate_fills
for each distinct source among the selected fillings. All body-
representation lookups go through tool.Geometry.get_body_representation
rather than inlining the ("Model", "Body", "MODEL_VIEW") triple. An AST
forward-compat guard pins the call site.

Generated with the assistance of an AI coding tool.
2026-07-02 09:08:09 +02:00
Gorgious56 fdf9970685 Bonsai: fix Apply Opening crash on non-fillings
The + gizmo previously appeared whenever a fillable host and any
non-host object were selected, so clicking it against an IfcCovering
crashed the geometry kernel when the opening generator tried to derive
a shape it couldn't build (AttributeError on 'NoneType.wrapped_data').

Tighten the gizmo poll to require the secondary selection to be a
class the operator can dispatch on: IfcDoor, IfcWindow,
IfcOpeningElement, or a non-IFC mesh. Make the poll selection-order-
independent so either click order activates it. Validate the same
class set at the operator boundary so keymap or scripted invocations
report a clear warning instead of crashing.

The narrower Door/Window support in the opening generator is a Bonsai
implementation limit, not an IFC schema restriction —
IfcRelFillsElement.RelatedBuildingElement is typed as IfcElement and
the schema permits any subtype. The tooltip and inline comment on the
validation branch note this so a future reader knows the gate is
future-work, not schema-mandated.

Rewrite the operator's bl_description to end-user-friendly wording that
drops the internal terms matrix_world and rl1/rl2.

Fixes #8215.

Generated with the assistance of an AI coding tool.
2026-07-02 09:02:02 +02:00
Massimo Fabbro de65e50fb5 See #6570. Formula column other improvements 2026-07-02 08:40:58 +02:00
Massimo Fabbro 714105b9fd See #6570. Tests for import cost schedule from csv and minor fix 2026-07-02 08:40:58 +02:00
Massimo Fabbro f0b5ab860f See #6570. Formula column minor improvements and documentation 2026-07-02 08:40:58 +02:00
Massimo Fabbro 528964ca56 See #6570. Formula column for ifc5d import from csv
Now it's possible to specify the Formula column in the csv in order to calculate cost item quantities
2026-07-02 08:40:58 +02:00
Massimo Fabbro 2c2d0f2434 See #6570. Now it's possible to specify the formula in cost item quantity assignment 2026-07-02 08:40:58 +02:00
Ryan Schultz 340d4fb82a Honor ApplicableOccurrence in is_relating_type_compatible
Companion to the assign_type.py fix. The same class-pairing validation
added in 10ee5aef4f also gates the Bonsai-side type assignment UI via
tool.Type.is_relating_type_compatible, which the AssignType operator
uses to filter selectable objects. For annotation types (abstract
IfcTypeProduct), get_applicable_types(IfcAnnotation) is empty, so every
annotation was skipped with "No selected object can be typed by
IfcTypeProduct."

Honor the type's ApplicableOccurrence attribute as a fallback, matching
the core API fix. occurrence.is_a() handles subtypes and returns False
for unknown tokens, so free-form text is not trusted blindly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 11:03:35 +02:00
Ryan Schultz 65cd5701c3 Honor ApplicableOccurrence in assign_type class validation
The class-pairing validation added in 10ee5aef4f rejected every typed
annotation with "IfcTypeProduct cannot type IfcAnnotation ... (allowed
occurrence classes: <none>)".

The check derived allowed occurrence classes solely from the
buildingSMART implementer-agreement map, which has no entry for the
abstract IfcTypeProduct that Bonsai uses for annotation types (IFC4 has
no IfcAnnotationType). The intended occurrence class is declared in the
type's ApplicableOccurrence attribute (e.g. "IfcAnnotation/TEXT"), the
schema-defined mechanism for exactly this purpose.

Augment the allow-list with the ApplicableOccurrence class, but only
when its leading token resolves to a real entity in the schema so
free-form text is not trusted blindly. Genuine mismatches (e.g.
IfcWallType -> IfcWindow) are still rejected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 11:03:35 +02:00
Ryan Schultz febde1bbbb Closes #8226: Add bulk-load of selected drawings' annotations
SHIFT+CTRL+CLICK on Activate Drawing now imports the
annotations of all selected drawings without switching
the active view or camera. The drawing camera is imported
when missing so annotations are collected into the correct
drawing collection. Loading is idempotent.

Generated with the assistance of an AI coding tool.
2026-06-30 15:23:23 -05:00
Ryan Schultz 315835063c Fix #8225: Respect camera boundary for Include filter
The drawing Include filter replaced the camera-view element set
entirely, so elements outside the camera boundary were drawn.
Intersect the filter results with the camera-view set instead.

Generated with the assistance of an AI coding tool.
2026-06-30 13:10:12 -05:00
Gorgious56 1b0a2fd6de Merge pull request #8222 from Gorgious56/viewport-decorator-lifecycle-sweep
Bonsai: migrate viewport decorators onto canonical lifecycle helper
2026-06-30 15:45:08 +02:00
Gorgious56 c95c1905ed Bonsai: draw georef gizmo above 3D model geometry
The georef orientation gizmo (crosshair, project-north arrow,
grid-north arrow, true-north arrow, WCS leader) is a coordinate-
system overlay: its purpose is to communicate orientation regardless
of what the model contains, so it must remain visible regardless of
whether 3D geometry occupies the gizmo's z=0 footprint.

Wrap GeoreferenceDecorator.draw_geometry's draw cycle in a
gpu.state.depth_test_set("ALWAYS") / restore pair so the overlay
draws on top of any 3D geometry between the camera and the gizmo.
Matches the precedent set by the dashed-line overlay in
bim/module/model/opening.py.

Generated with the assistance of an AI coding tool.
2026-06-30 15:36:03 +02:00
Gorgious56 301dae103c Bonsai: guard decorator draws against None and empty lists
Three crashes that surfaced when viewport decorators ran against
selected non-IFC blender objects or top-level objects with no
aggregate parent:

- WallAxisDecorator.draw_wall_axis: tool.Ifc.get_entity(obj) returns
  None for a non-IFC selection (default cube, lamp, camera). The
  subsequent element.is_a("IfcWall") raised AttributeError on every
  redraw. Guard with `element and element.is_a(...)`.
- _ConnectedNetworkPathDecorator flow-segment loop: same shape;
  iterates entries that may be None, calls .is_a("IfcFlowSegment")
  unconditionally. Same guard.
- AggregateDecorator.draw_aggregate: indexes aggregates_list[-1]
  unconditionally in the else branch; raises IndexError when the
  selected element has no aggregate parent. Also leaves `aggregate`
  unbound across loop iterations in the `in_aggregate_mode` branch
  when `index <= 0`. Define `aggregate = None` per loop iteration
  and guard the [-1] indexing with `elif aggregates_list:`.

Generated with the assistance of an AI coding tool.
2026-06-30 14:06:53 +02:00
Gorgious56 85cd1c1923 Bonsai: migrate viewport decorators onto canonical base
Migrate 17 legacy viewport decorators (ClashDecorator, SolarDecorator,
MeasureDecorator, ItemDecorator, GeoreferenceDecorator, NestDecorator,
NestModeDecorator, GridDecorator, LoadsDecorator, AggregateDecorator,
AggregateModeDecorator, PolylineDecorator, ProductDecorator,
WallAxisDecorator, SlabDirectionDecorator, FaceAreaDecorator,
BoundingBoxDecorator) from hand-rolled install/uninstall lifecycles
onto the canonical tool.Blender.ViewportDecorator base. The legacy
uninstall removed each handler from Blender but never cleared
cls.handlers, growing a stale-reference list across enable/disable
cycles. The base's uninstall clears the list correctly.

State-derived install methods (ItemDecorator, ProductDecorator,
LoadsDecorator, PolylineDecorator) keep an install override per the
base's documented contract.

Drop the now-redundant per-class draw_batch copies and the module-
or method-scope transparent_color defs in favour of the base helpers
introduced in the preceding commit. system/decorator.py and
boundary/decorator.py keep their installed-flag lifecycle (different
pattern, no leak) but consume tool.Blender.transparent_color.

Add an AST forward-compat guard pinning the contract structurally:
any class declaring handlers = [] (Assign or AnnAssign) must subclass
tool.Blender.ViewportDecorator. Add a runtime regression on
ClashDecorator's install/uninstall cycle.

Generated with the assistance of an AI coding tool.
2026-06-30 14:03:17 +02:00
Gorgious56 091fc9e7e5 Bonsai: add viewport decorator base helpers
Add two helpers to tool.Blender that 17+ existing viewport decorators
re-implement byte-identically:

- ViewportDecorator.draw_batch(shader_type, content_pos, color, indices=None)
  collapses the validate + batch_for_shader + uniform_float + draw cycle
  every shader-driven decorator needs.
- Blender.transparent_color(color, alpha=0.1) is the RGBA-alpha-override
  helper duplicated across nest, project, aggregate, model, system module
  scopes plus six nested-def copies inside draw methods.

Pure additions on the tool/ layer with direct unit tests covering the
default-alpha, explicit-alpha, non-mutation, new-list-instance, and
validation-guard branches.

Generated with the assistance of an AI coding tool.
2026-06-30 13:59:15 +02:00
Gorgious56 a65f291a89 Bonsai: gate clip-box refresh timer across file load
A pending RegionView3D.update() timer registered before wm.open_mainfile()
fires during the load against freshly-allocated regions whose GPU contexts
are not yet wired, CTD-ing inside GPU_matrix_ortho_set. Cancel both the
refresh and cap-rebuild timers in a new load_pre handler, hold a
_file_loading gate from load_pre through the first on_pre_view tick (first
paint = GPU ready), and short-circuit on_depsgraph_update during the
window so its IFC-reload schedule_refresh + apply_clip_planes_direct
branches can't re-arm against unready regions.

Generated with the assistance of an AI coding tool.
2026-06-30 10:23:06 +02:00
Thomas Krijnen e6dc582d82 ExtractElements - copy over coordionate operation #8199 2026-06-30 10:12:45 +02:00
Thomas Krijnen 366fa67a84 Don't rely on cwd 2026-06-30 10:02:55 +02:00
Petru Conduraru 4a8b863b96 test(ifcpatch): ExtractElements regression test for georeferencing loss (#8199)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 09:59:07 +02:00
Ryan Schultz db0bbf6b84 adding vscode workspaces to .gitignore 2026-06-29 14:29:17 -05:00
Gorgious56 a757641b84 Bonsai: derive prefs PropertyGroups from EDIT_TYPES
Collapse two parallel hand-maintained lists in the addon-preferences
PropertyGroups into derivations from `tool.Parametric.EDIT_TYPES`:

- GizmoPreferences: the 10 `<name>: BoolProperty` annotations now
  generated from the full EDIT_TYPES list.

- DefaultParameters: add `has_default_parameters` flag to
  ParametricObject (set True on door/window/stair/railing/roof);
  derive the 5 `<name>: PointerProperty(type=BIM<X>Properties)`
  annotations and collapse the 5 hand-written `draw_expandable_panel`
  blocks in `draw_default_parameters` into loops driven by the flag.

Existing `test_gizmo_preferences_field_per_registry_entry` pinned the
GizmoPreferences contract; new
`test_default_parameters_field_per_registry_entry_with_defaults`
pins the DefaultParameters contract (one-directional: flag=True
implies field present, flag=False allows absence).

Generated with the assistance of an AI coding tool.
2026-06-29 16:29:44 +02:00
Thomas Krijnen 8bd22178f7 OCC_VER Compatibility 2026-06-29 11:30:37 +02:00
Thomas Krijnen b4d7780e14 Use opencascade::handle for compatibility with earlier versions 2026-06-29 11:30:37 +02:00
Frozen Forest Reality Technologies 7c092db9e6 OCCT 8 Update Part 4
Fix For : ``C:\Program Files\OCCT\inc\NCollection_Sequence.hxx(45,18): error C2280: 'CSLib_Class2d::CSLib_Class2d(const CSLib_Class2d &)': attempting to reference a deleted function``.
2026-06-29 11:30:36 +02:00
Frozen Forest Reality Technologies 38e3bb0590 Boost 1.88 Update 2026-06-29 11:30:36 +02:00
Frozen Forest Reality Technologies 7f49c945b9 OCCT 8.0 Update Part 3 2026-06-29 11:30:36 +02:00
Frozen Forest Reality Technologies 81f71e6418 OCCT 8.0 Update Part 2 2026-06-29 11:30:36 +02:00
Frozen Forest Reality Technologies 6318610bdb OCCT Update to 8.0 Part 1 2026-06-29 11:30:36 +02:00
Gorgious56 82dd1d94de Bonsai: batch host recuts in array/opening paths
Refs gh#8088. Array regen + multi-opening drops fan out N+1 wall recuts
per operator (one per child filling deletion + the final mirror recut),
making CSG opening-subtraction O(N^2) for a linear UX action.

Introduces tool.Geometry.batch_host_recut() — a context manager that
coalesces switch_representation and bpy.ops.bim.update_representation
calls per voided element within one operator transaction. The drain
re-reads the active representation so the recut reflects current IFC.

Wraps 7 entry points (regenerate_array, RegenerateArray, RemoveArray,
AddOpening, RecalculateFill, CloneOpening, regenerate_from_type) and
rewires 7 leaf call sites in opening.py, void/operator.py, and
mirror_parent_void_fillings_to_children.

An AST forward-compat guard pins the rewire contract: no direct
switch_representation or bpy.ops.bim.update_representation in the
three target files outside the helper definitions.

A 16-child array regen now recuts the wall once instead of 17 times.
The CSG cost per recut is unchanged; only the count is reduced.

21 new tests across three lanes (helper unit, entry-point coalescing,
AST guard) — all green.

Generated with the assistance of an AI coding tool.
2026-06-29 10:35:01 +02:00
Gorgious56 4004344c20 Bonsai: TAB enters wall parametric edit
Mirror the icon-click entry into wall parametric edit on the TAB key.
The dispatch in Modifier.try_applying_edit_mode had no branch for fresh
LAYER2 walls, so TAB landed in item mode instead of the parametric
draft + gizmos. Add the missing entry leg of the toggle, placed after
the generic is_object_editing branch so the finish leg still fires
when a wall is already in edit mode.

Generated with the assistance of an AI coding tool.
2026-06-29 09:48:20 +02:00
Petru Conduraru fce7cd3eb4 test(ifcpatch): add MergeProjects regression test for merging 3+ files (#7973)
Merging more than two IFC models with the MergeProjects recipe leaves
duplicated IfcGeometricRepresentationContext entities behind. All elements
are kept, but the accumulated contexts cause later disciplines to appear
"not merged" in viewers.

This test merges three projects and asserts the elements are kept, a single
IfcProject remains, and the geometric contexts are reused rather than
accumulated. It currently fails on the context assertion, reproducing #7973.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 13:43:07 +02:00
Thomas Krijnen f3dcf0b539 Attempt at less double counting of inverses #7973 2026-06-28 13:42:36 +02:00
Thomas Krijnen f5ebbd1917 Add test case for https://github.com/buildingSMART/validate/issues/305 2026-06-28 11:12:14 +02:00
Thomas Krijnen 77aa080334 Recompile rules 2026-06-28 10:37:33 +02:00
Thomas Krijnen 5afad2b179 Partial revert of 84abf5e91 2026-06-26 16:29:27 +02:00
Thomas Krijnen 98aa2c635e Rerun express-related codegen 2026-06-26 16:09:44 +02:00
Thomas Krijnen 7db1ceb07f Convert to lower() immediately after originalTextFor to retain more similar behaviour 2026-06-26 16:01:46 +02:00
Thomas Krijnen 84abf5e918 Adapt codegen for new parsing 2026-06-25 14:29:14 +02:00
Thomas Krijnen 741e8233ca originalTextFor() for on parsing express string literals 2026-06-25 14:29:14 +02:00
Thomas Krijnen 262b55630c Allow to compare simple type instance to underlying type 2026-06-25 14:29:14 +02:00
Josef Wienerroither d6218307a9 Merge branch 'v0.8.0' into ifcmax/initial-refresh 2026-06-25 11:46:22 +02:00
Gorgious56 f60a3423d0 Fix: commit pending parametric draft before extrusion-edit
EnableEditingExtrusionAxis and EnableEditingExtrusionProfile both
import a mesh from the IFC representation into obj.data as their
first real action. That mesh-import overwrites any in-memory
parametric (gizmo) draft on the object, silently discarding the
user's pending dimension edits.

Concrete reproduction: drag a wall's length gizmo (draft pending),
then click "Edit Axis" before validating the draft. The axis-edit
imports the wall axis mesh; the in-flight draft vanishes; on
cancel the wall snaps back to its pre-drag length.

Both call sites now commit the active draft via
tool.Parametric.commit_object_draft before the mesh import, gated
on tool.Parametric.is_object_editing so the guard is a no-op when
no draft is in flight.

Generated with the assistance of an AI coding tool.
2026-06-24 17:08:50 +02:00
Gorgious56 4f2c4d1633 Bonsai: promote load-warning banners to top-level UI
Multi-instance cache-lock, "Opening Cuts Skipped", and "Arrays With
Missing Children" banners now draw in BIM_PT_tabs alongside the
existing global error / outdated-model banners, so they remain
visible regardless of the active Bonsai tab. The corresponding
blocks are removed from BIM_PT_project. Dead imports
(is_cache_locked_by_other_process, draw_multiline_text) dropped
from project/ui.py.

Generated with the assistance of an AI coding tool.
2026-06-24 17:08:50 +02:00
Gorgious56 3b584ef242 Bonsai: short-circuit recreate_wall when no layer set
regenerate_wall_representation returns None for walls without an
IfcMaterialLayerSet (the only mode it knows how to rebuild). Feeding
None to switch_representation crashes deep in resolve_representation
on .Items. Document the None return on the API side and bail in
tool.Model.recreate_wall when it hits.

Generated with the assistance of an AI coding tool.
2026-06-24 17:08:50 +02:00
Gorgious56 10ee5aef4f Bonsai: refuse class-mismatched type assignment
Schema-illegal IfcDoor->IfcWallType pairings parse cleanly but propagate
into operators that fan out by type and eventually crash the wrapper.
Block the pairing at its source: API guard in ifcopenshell.api.type.
assign_type, per-object partition in BIM_OT_assign_type + DuplicateType,
new tool.Type.is_relating_type_compatible helper, AST forward-compat
guard. Files in the wild are still loaded unchanged.

Generated with the assistance of an AI coding tool.
2026-06-24 17:08:50 +02:00
Thomas Krijnen 59383f5010 constexpr more cases to prevent gcc calling non-existing template overloads 2026-06-24 11:34:04 +02:00
Thomas Krijnen a9d6776875 header.assign() helper 2026-06-24 11:12:21 +02:00
Thomas Krijnen 1057f794f6 Small conv result number tweaks 2026-06-24 11:12:11 +02:00
Josef Wienerroither 1e7affa797 Merge branch 'v0.8.0' into ifcmax/initial-refresh 2026-06-24 10:20:59 +02:00
Josef Wienerroither bb54b33594 update TODO comments 2026-06-24 10:19:42 +02:00
Thomas Krijnen b3218ced3f Mistake in parse examples include macro 2026-06-24 08:46:21 +02:00
Thomas Krijnen 49d9c416b1 Fix compilation 2026-06-23 21:51:27 +02:00
Thomas Krijnen 55e778f82e does_self_intersect() requires tri mesh 2026-06-23 20:26:28 +02:00
Thomas Krijnen 13681cdf9b Build all volumes when converting between nef and poly 2026-06-23 20:26:16 +02:00
Thomas Krijnen c592018b3f Minor changes to conversion result numbers 2026-06-23 20:25:57 +02:00
Thomas Krijnen dad4cc8a3c Merge branch 'v0.8.0' of https://github.com/IfcOpenShell/IfcOpenShell into v0.8.0 2026-06-23 20:23:13 +02:00
Gorgious56 006a24ef32 Refactor: rename rel -> subject in disconnect_rel dispatch
The "mep-pair-fitting" kind added in the previous commit carries an
IfcFlowFitting (the entity whose deletion disconnects the pair), not a
relationship entity, in the dispatch slot — but the slot was named ``rel``
across the function signature and every call site. Rename to ``subject``
so the parameter name reflects the uniform intent: "the entity whose
teardown effects the disconnect", regardless of whether that's a rel or
a fitting.

Sweep covers:

- core.connection.disconnect_rel signature + body
- tool.Connection.find_rels / find_rels_for_element / find_rel docstrings
- The cascade-on-delete call site in tool.Geometry.delete_ifc_object
- DisconnectElements operator in bim.module.model.wall
- All affected test kwargs and AST forward-compat docstring
- Error message: "Unknown rel kind" -> "Unknown kind"

No behaviour change.

Generated with the assistance of an AI coding tool.
2026-06-23 14:39:53 +02:00
Gorgious56 6fa984ce2b Fix MEP pair-disconnect crash and bend re-edit pen icon
Three user-facing fixes for the MEP-system gizmo surface:

1. MEP pair-disconnect no longer crashes Blender. The
   MEPSystemPathDecorator cached entity_instance references in
   _cached_walk; deleting a bridging fitting via the gizmo left a freed
   SWIG handle in the list, and the next _build_geometry pass segfaulted
   on .is_a. The cache now stores STEP integer ids and re-resolves via
   ifc_file.by_id on each draw, plus folds tool.Parametric.get_geom_generation
   into the cache key — ifcopenshell.api mutations invalidate before the
   next frame regardless of how the deletion was routed.

2. Bend re-edit pen icon stays reachable. The bend creation path
   tessellates the swept-disk body (upstream geometry-kernel workaround),
   so tool.System.has_parametric_body correctly returns False for a
   freshly-committed bend. _active_is_bend_fitting and
   GizmoMEPActions.is_eligible_object now fall back to the type's
   BBIM_Fitting pset — the same source bim.enable_bend_preview_from_bend
   reads parameters from — keeping the pen icon eligible.

3. MEP pair / per-port unjoin icons unified through bim.disconnect_elements.
   The MEP gizmo group's three unjoin icons (pair, start, end) now share
   the wall-disconnect surface: same VIEW3D_GT_wall_link_toggle icon, same
   bim.disconnect_elements operator. tool.Connection.find_rels learned a
   new "mep-pair-fitting" kind that returns the bridging fitting as the
   disconnect target; core.connection.disconnect_rel grew the matching
   dispatch arm. The old MEPUnjoinAtPort and MEPUnjoinPair operators are
   removed.

Also registered wall.GizmoPairDisconnect (previously declared but never
in the classes tuple, so dead code) for the wall+slab pair-disconnect
surface, and extracted MEP port-topology helpers (find_bridging_fitting,
is_disconnectable_fitting, neighbours_at_ports) onto tool.System so the
canonical walk has a single home.

Generated with the assistance of an AI coding tool.
2026-06-23 14:39:53 +02:00
Gorgious56 262f5f9a85 Merge pull request #8195 from Gorgious56/bonsai/ifc-migrate-ifc2x3-downgrade
Bonsai/ifc migrate ifc2x3 downgrade + patch preset system
2026-06-23 10:15:00 +02:00
Gorgious56 44c0c2916c Bonsai patch: lossy-downgrade popup + per-recipe preset menu
Two new UX features in the IFC Patch panel, both backed by helpers on
bonsai.tool.Patch.

Lossy-downgrade confirmation popup. When the user picks the Migrate
recipe with a target schema older than the source's (IFC4 -> IFC2X3,
IFC4X3 -> IFC2X3), ExecuteIfcPatch.invoke shows a properties dialog
listing what's preserved vs lost: IfcIndexedPolyCurve flattened with
arcs approximated, IfcPolygonalFaceSet / IfcTriangulatedFaceSet
converted to IfcFacetedBrep, IFC4-only IfcElement subclasses (IfcLamp,
IfcPipeSegment, IfcGeographicElement, ...) demoted to
IfcBuildingElementProxy with the original class + PredefinedType
encoded into ObjectType, and PredefinedType enum values absent from
IFC2X3 dropped. The user explicitly approves before the recipe runs.

The popup is gated on tool.Patch.migration_is_lossy_downgrade() which
resolves the source schema via header-only parsing
(tool.Patch._patch_source_schema reads the first ~2KB and matches a
FILE_SCHEMA regex, then normalises via ifcopenshell.util.schema.
get_fallback_schema). Avoids a full ifcopenshell.open() on every
Execute click — multi-second saving on large files. The target schema
is looked up by argument name rather than position so it survives
recipe-parameter reordering.

Per-recipe preset menu. New BIM_MT_ifc_patch_presets + AddIfcPatchPreset
wire Blender's standard preset system into the panel. Each recipe gets
its own preset subdirectory (bonsai/ifc_patch/<RecipeName>/), so a
preset saved for ExtractElements does not pollute the Migrate preset
list. The preset operator uses Attribute.get_value_name() (single
source of truth for data_type -> storage-field mapping) to build the
preset_values list dynamically per recipe.

The recipe-change callback resets
BIM_MT_ifc_patch_presets.bl_label to the canonical title — Blender's
script.execute_preset mutates the menu's bl_label to the loaded
preset's name as a "currently-selected" indicator, and without an
explicit reset the previous recipe's preset name would falsely advertise
itself in the new recipe's menu.

tool.Patch gains get_preset_subdir, migration_is_lossy_downgrade,
_patch_source_schema as cross-cutting helpers. _SCHEMA_AGE module
constant provides the ordering used by the downgrade-detection
predicate.

Test coverage: 12 bim-lane tests under test/bim/module/patch/. The
truth table for migration_is_lossy_downgrade covers IFC4/IFC4X3 source
x downgrade/upgrade/same-schema target x Migrate/non-Migrate recipe.
The schema-sniffing tests write a real IFC4X3_ADD2 file to disk and
assert the helper resolves it to IFC4X3 (regression for the original
startswith iteration-order bug). An end-to-end test drives
bpy.ops.bim.execute_ifc_patch with an in-memory IfcLamp source and
verifies the on-disk IFC2X3 file contains a single
IfcBuildingElementProxy with ObjectType "IfcLamp/COMPACTFLUORESCENT"
and the original GlobalId preserved.

Generated with the assistance of an AI coding tool.
2026-06-23 09:48:54 +02:00
Gorgious56 2ab5ca9222 ifcpatch: small recipe polish
ExtractElements: expand the `query` docstring to cover the exclusion
syntax (`!` on entity classes, `!=` on attribute / pset / material /
classification / location / group facets) and the "seed with a broad
include before subtracting" gotcha — entity-class exclusion does not
auto-seed from "all elements", so a bare `! IfcSlab` query returns
nothing.

FixArchiCADToRevitDoorSwings: guard the `IfcIndexedPolyCurve.Segments`
loop against the IFC4 case where Segments is absent (a polyline
through all coords in declared order). Previously crashed on
`None.__iter__`.

Generated with the assistance of an AI coding tool.
2026-06-23 09:46:31 +02:00
Gorgious56 f710929e9e ifcpatch Migrate: defensive IFC4/IFC4X3 -> IFC2X3 downgrade
The Migrate recipe previously crashed mid-loop with the cryptic
`RuntimeError: Entity with name '' not found in schema 'IFC2X3'` when
asked to downgrade an IFC4 or IFC4X3 file to IFC2X3 — the
class_4_to_2x3 mapping marks IFC4-only geometry / element classes with
an empty-string sentinel and the old code blindly forwarded that to
create_entity. Real files routinely contain IfcPolygonalFaceSet,
IfcTriangulatedFaceSet, IfcIndexedPolyCurve, IfcLamp, IfcPipeSegment,
IfcGeographicElement, etc.

The recipe now runs a preprocessing pipeline when the target is IFC2X3
and the source is IFC4 or IFC4X3:

- DowngradeIndexedPolyCurve flattens IfcIndexedPolyCurve to IfcPolyline
  for the whole file (arcs included — see below).
- IfcPolygonalFaceSet / IfcTriangulatedFaceSet are converted directly
  to IfcFacetedBrep at the entity level via
  ifcopenshell.util.shape_builder.polygonal_face_set_to_faceted_brep,
  preserving topology including IfcIndexedPolygonalFaceWithVoids inner
  bounds. IfcShapeRepresentation carriers have their RepresentationType
  tag updated from "Tessellation" to "Brep".
- Orphan source-only geometry instances (left over after the rewires)
  are purged iteratively via
  geometry_classes_introduced_after(target, source).

The Migrator is invoked with fallback_element_to_proxy=True so
IFC4-only IfcElement subclasses (IfcLamp, IfcPipeSegment,
IfcGeographicElement, ...) become IfcBuildingElementProxy in the
output. A post-pass encodes "<OriginalClass>/<PredefinedType>" into
ObjectType (e.g. "IfcLamp/COMPACTFLUORESCENT") when ObjectType is
empty, so the lost subclass identity survives the downgrade as
searchable text.

The migration loop now collects per-entity failures into a list rather
than crashing on the first; a summary RuntimeError fires at end if any
failed, naming up to 20 with their inverse references. Successful
migrations log a single count line via self.logger.

DowngradeIndexedPolyCurve extended:
- Arc segments (IfcArcIndex) are flattened via
  ifcopenshell.util.shape_builder.arc_to_polyline_points with
  ARC_SUBDIVISION=16 chord points per arc.
- Multi-index IfcLineIndex segments handled correctly.
- Absent Segments list (IFC4 polyline-through-all-coords case) handled.

Test coverage: 11 tests across the two recipes covering all four
preprocessing branches, the IFC4X3 source gate, the ObjectType
encoding (incl. author-supplied ObjectType preservation), the summary
RuntimeError shape, and the arc subdivision.

Generated with the assistance of an AI coding tool.
2026-06-23 09:33:03 +02:00
Gorgious56 a2dafc9ceb ifcopenshell.util: schema-aware downgrade helpers
Adds the IFC-library primitives the ifcpatch Migrate recipe needs for a
defensive IFC4 / IFC4X3 -> IFC2X3 downgrade without each caller
reinventing the wheel.

In ifcopenshell.util.schema:
- Migrator(fallback_element_to_proxy=False) opt-in: when True, IFC4-only
  IfcElement subclasses (IfcLamp, IfcPipeSegment, IfcGeographicElement,
  ...) migrate to IfcBuildingElementProxy instead of raising. Default
  preserves the strict failure-on-unmappable contract for existing
  callers (classification API, etc.).
- geometry_classes_introduced_after(target, source) derives the
  IfcRepresentationItem subclasses present in `source` but absent in
  `target` directly from the loaded schemas. Cached per pair. Replaces
  hand-curated class lists that drift with each IFC update.
  ifc4_only_geometry_classes() retained as an alias.
- generate_default_value synthesises a unit IfcAxis2Placement2D /
  IfcAxis2Placement3D when downgrading entities whose Position became
  required in the target schema (IfcIShapeProfileDef and friends in
  IFC2X3).
- Enum-mismatch detection upgraded from string-matched RuntimeError to a
  structural check via ifcopenshell.util.attribute.get_enum_items so
  upgrade paths still surface real bugs loudly.

In ifcopenshell.util.shape_builder:
- polygonal_face_set_to_faceted_brep converts IfcPolygonalFaceSet /
  IfcTriangulatedFaceSet (IFC4-only) directly to IfcFacetedBrep,
  preserving topology including IfcIndexedPolygonalFaceWithVoids inner
  bounds. Validates inputs at the boundary.
- arc_to_polyline_points approximates a circular arc through three
  points with a chord polyline of configurable subdivisions. Tolerates
  floating-point noise on planar Z. Raises on non-planar or invalid
  inputs.

Test coverage: 47 unit tests across schema + shape_builder lanes
covering each helper directly (no transitive-only coverage), including
regression pins for the IFC4X3-prefix ordering invariant in
get_fallback_schema and the strict-default Migrator contract.

Generated with the assistance of an AI coding tool.
2026-06-23 09:23:25 +02:00
Gorgious56 3c9ee4a71f Clip box: include linked IFC geometry
Add include_linked_ifc toggle on BIMSceneClipBoxProperties so the cap
pipeline can also bisect meshes inside Project > Links collection-instance
empties. Off by default - linked IFCs may carry the entire site or
structural backbone, and capping them adds per-mesh bisect cost on every
clip-box edit.

The new iterator composes instance.matrix_world @ inner.matrix_world as
the effective world placement so caps land in the active scene rather
than at the linked library's local origin. Linked-mesh cache entries are
namespaced with a 'link:' prefix to avoid collisions with top-level
scene objects.

Generated with the assistance of an AI coding tool.
2026-06-22 13:58:45 +02:00
Gorgious56 27b0b920a9 Apply black formatting and ruff isort fixes
Pre-commit checklist: black + ruff check.

Generated with the assistance of an AI coding tool.
2026-06-22 11:09:18 +02:00
Gorgious56 6367de6102 Add tool.Blender.draw_quads utility
Promotes the private _fill_quads_alpha helper from
bim/module/model/decorator.py to tool.Blender.draw_quads so any feature
decorator can reuse the same TRIS-batch path.

The new utility accepts an optional outline_color so callers can draw
fill, outline, or both in a single call. Migrates the only existing
caller (WallGizmoPreviewDecorator in model/wall.py) to the public API
and removes the local helper.

Generated with the assistance of an AI coding tool.
2026-06-22 11:09:18 +02:00
Gorgious56 3c20c27794 Extend clip box with face handles and presets
Add source-based clip box presets — a dropdown menu next to the Add
Clip Box button lets the user pre-size a clip box to the bounding box
of a chosen IFC source: a spatial element, IFC class, type, material,
profile, drawing camera frustum, status, system, group, or zone. The
picker dialog uses prop_with_search so files with hundreds of materials
or types remain browsable.

Add interactive face resize handles — six near-invisible click-target
quads render on the active clip box when its empty is the active
object. Dragging a face grows or shrinks the box one-sided on that
axis; the opposite face stays fixed. Ctrl+Click on a face aligns the
viewport to look at that face, following Blender's numpad-view
convention applied to the box's local axes so rotated boxes align
orthogonally to the screen. The gizmos honour negative-scale empties
so the visible cube and the clickable handles stay aligned.

Add settings and info menus — a gear-icon menu next to the Enable
Clipping / Show Caps toggles exposes per-file preferences (cap only
IFC products, show face handles); an info-icon menu adjacent documents
the gizmo gestures. A quick-access toggle row also appears in the
viewport Overlay popover, greyed out when no clip box exists, and
orphaned clip-box list entries now expose an X button so users can
recover from external host-empty deletions.

Plumbing: cap rebuild fires synchronously on gizmo release and
clip-box selection change, so the cross-section overlay re-forms
without waiting for the depsgraph debounce; cap eligibility honours
the "Only IFC Products" toggle. Includes 121 tests covering source
resolution, drag math, face visibility, gizmo registration, and the
view-alignment up-axis convention.

Generated with the assistance of an AI coding tool.
2026-06-22 11:09:18 +02:00
Thomas Krijnen 4f21bd1c69 Auto mem mngt in conversion result number types; more arithmetic on OpaqueCoordinate 2026-06-22 10:38:25 +02:00
Josef Wienerroither 73f31477d5 Merge branch 'v0.8.0' into ifcmax/initial-refresh 2026-06-21 08:18:19 +02:00
Thomas Krijnen 312be203c9 SYN004 test case 2026-06-20 21:30:31 +02:00
Thomas Krijnen 7c9df9f980 Don't erronously terminate on [SYN004] Non-entity type messages 2026-06-20 21:18:23 +02:00
Thomas Krijnen 669e04664d Consistent policy on normalization in halfspace eq map() 2026-06-20 13:30:17 +02:00
Thomas Krijnen c0f2fb1860 Store actual subentities in CgalShape instead of the strange degeneracies convention 2026-06-19 20:50:40 +02:00
Thomas Krijnen 9396340b13 parse examples: Include -definitions.h header as well so that preprocessor switches works 2026-06-19 20:38:37 +02:00
Gorgious56 e2a4e5692f Add network path overlay for walls and MEP
Adds a viewport overlay that traces the connected element path from
the selected wall or MEP element. Walls follow IfcRelConnectsPathElements
and draw each connected wall's reference axis with endpoint dots; MEP
elements follow IfcRelConnectsPorts and draw each segment's axis plus
a port-to-port spider for each fitting.

The new BIMModelProperties.show_paths toggle (Element Paths in the
Bonsai Decorators group of Blender's viewport overlay popover) drives
install / uninstall of both decorators on flip and on file load,
mirroring the show_slab_direction wiring. The popover row also
surfaces the pre-existing BIMSystemProperties.should_draw_decorations
toggle (System Decorations) so both connectivity overlays sit
together.

Dot colors split free endpoints (decorator_color_special, blue by
default) from junction nodes (decorator_color_selected, green by
default) so dangling chain tips read apart from interior joins. Walls
classify endpoints by IFC topology: rels expose RelatingConnectionType /
RelatedConnectionType and ATPATH dots use tool.Wall.path_connection_location_world
for the canonical T-meets join. MEP keeps the geometric classifier
because port positions coincide exactly across fitting + segment.

Generated with the assistance of an AI coding tool.
2026-06-19 09:07:11 +02:00
Gorgious56 104eeaf0cd Fix clip-box edit-mode picker + rotation margin
Edit-mode click-select rejected verts inside the clip volume
because clip_bb stayed at the first-arm view; the depsgraph and
PRE_VIEW handlers updated clip_planes but skipped the
view3d.clip_border call that refreshes clip_bb. Schedule a full
re-arm at transform-commit, IFC reload, and view drift.

The empty's wireframe was clipped by its own planes when rotated
at non-trivial scale because the 1e-6 absolute margin can't
absorb float-precision drift that scales with the box's world
half-extent. Add a 1e-5 relative expand.

Generated with the assistance of an AI coding tool.
2026-06-18 17:39:25 +02:00
Gorgious56 260a387069 Fix loading project library without IfcProject
Per IFC4+, IfcContext is the abstract supertype of IfcProject and
IfcProjectLibrary; library-only files legitimately contain only
IfcProjectLibrary as their root context. Bonsai assumed an IfcProject
was always present at three crash sites: the parent-library enum
(reported in #8183), RefreshLibrary's tree view, and AddProjectLibrary.

Introduce tool.Project.get_root_context() that prefers IfcProject and
falls back to IfcProjectLibrary, and route the three sites through it.
get_parent_library() now returns None for a root IfcProjectLibrary;
get_project_hierarchy() and the EditProjectLibrary parent-swap branch
handle that. AddProjectLibrary creates the nested sub-library via
IfcRelNests when the root is an IfcProjectLibrary, matching the
existing convention for library-under-library nesting.

For the separate "Open IFC Project" path, abort with a friendly error
pointing users to Project Setup -> Project Library -> Select Library
File instead of letting set_units() crash deep in the importer.

Closes #8183.

Partly generated with the assistance of an AI coding tool.
2026-06-18 17:17:32 +02:00
Josef Wienerroither adf2b99569 Merge branch 'v0.8.0' into ifcmax/initial-refresh 2026-06-18 10:46:27 +02:00
Bruno Perdigão 937270fc49 Fix thickness and offset calculation for rotated slabs.
Get existing `x_angle` instead of using object `rotation_euler.x`

Co-Authored-By: Ryan Schultz <ryan@openingdesign.com>
2026-06-17 22:20:52 -03:00
Bruno Perdigão 156c6183eb fix custom offset unit scale when loading from pset. 2026-06-17 22:20:51 -03:00
Bruno Perdigão 95fcf9e35c fix custom_offset scale material layers 2026-06-17 22:20:51 -03:00
Thomas Krijnen 855de34d22 Catch and log errors during initialize_settings() 2026-06-17 18:23:19 +02:00
dependabot[bot] 1e91eebf51 Bump tar from 7.5.11 to 7.5.16 in /src/ifctester/webapp
Bumps [tar](https://github.com/isaacs/node-tar) from 7.5.11 to 7.5.16.
- [Release notes](https://github.com/isaacs/node-tar/releases)
- [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/isaacs/node-tar/compare/v7.5.11...v7.5.16)

---
updated-dependencies:
- dependency-name: tar
  dependency-version: 7.5.16
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-17 14:37:12 +02:00
Thomas Krijnen d5bed316cd Option for ifcwrap cmake to run standalone #8165 2026-06-17 14:28:36 +02:00
carlopav 4d3bff4e3a feat(ifc5d): include quantity Formula in serialised Quantities
IfcQuantity* carries an optional Formula (IfcLabel) documenting how a
quantity was derived. Export it alongside each quantity so it survives
in the Quantities column.

The per-quantity entry shape grows from [name, value] to
[name, value, formula], which stays backward compatible for positional
consumers reading index 0/1. Formula is read with a schema-safe getattr
(it does not exist on IfcPhysicalComplexQuantity, nor in IFC2X3) and is
coalesced to "" when absent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-16 13:34:46 +02:00
Gorgious56 0b0e34f18d Merge pull request #8178 from Gorgious56/bonsai/clip-box
Adds a viewport clip box feature to Bonsai: a user-controllable oriented bounding box that hides everything outside its 6 faces and draws filled cross-section caps where IFC product geometry intersects the planes.

Quality and coordination > Sandbox > Clip Box
2026-06-16 13:31:04 +02:00
Gorgious56 6147a58d7a Add viewport clip-box feature
A clip box hides everything outside a user-controllable oriented
bounding box, with cross-section caps drawn where IFC product
geometry intersects the planes. The box is hosted on a Blender
empty (CUBE display); its matrix_world is the single source of
truth — G/R/S edits the empty and the viewport clip planes track.

State persists through IFC save/load via a project-level pset
(IfcProject.BBIM_ClipBoxes) so the boxes survive without binding
to any IfcRoot entity (avoids the IFC scale-lock / strip).

UI: BIM_PT_clip_box under the Sandbox tab. Prominent Enable
Clipping + Show Caps toggles at top, then Add, then a UIList with
per-row duplicate / remove icons. Scene-level enabled / show_caps
so the "hide everything outside" intent applies file-wide;
enabled is intentionally not persisted to the pset so reopening
an IFC never silently hides geometry. Adding a clip box arms
clipping so the user immediately sees the cut.

Default spawn at the 3D cursor with scale 10 (a 20 m cube) so the
volume covers a typical building storey or two rather than the
meaningless 2 m unit cube.

Modal-aware: depsgraph + draw-handler paths gate per-frame side
effects on tool.Blender.is_transform_modal_active so dragging
G/R/S on the box only writes the pset once on commit, not per
frame. Shift+D / Alt+D / Ctrl+Shift+D on a clip box gets adopted
as a first-class entry via the collection-to-list sync.

Cap eligibility is gated on IfcElement (walls, slabs, doors, …)
so spatial structure (IfcSpace, IfcBuildingStorey, IfcSite) and
annotations / grids never sprout solid fills at clip boundaries.

Cap rebuild is debounced behind a 1 s quiet window so external
gizmo drags (and any other burst of non-Bonsai depsgraph updates)
collapse to one rebuild on release. Bonsai's own G/R/S keeps the
snappy on-release feel via a modal-end fast-path. The relevance
filter compares a per-Object matrix hash against a baseline so a
plain selection click — which Blender quirkily flags as a
transform update — doesn't churn the cache or flash the caps off.
Edit mode short-circuits both the rebuild scheduler and the draw
handler entirely.

Caps use the evaluated mesh (modifier stack applied) and a
session/matrix/clip-box-hash cache so a typical scene only
re-bisects meshes whose geometry actually changed.

Performance: every per-frame poller (refresh, depsgraph handlers,
draw handlers) short-circuits on the cheapest available check
first — cap_cache emptiness for the post-view draw handler,
scene_props.enabled for the rest — so a session with clipping
disabled pays only one boolean read per tick.

Known v1 limitations documented in tests / docstrings: hollow
profiles cap as solid discs (single-ring tessellation only),
non-watertight inputs may produce degenerate caps, quad-view
untested, Cycles / EEVEE render not supported (GPU-overlay only).

Generated with the assistance of an AI coding tool.
2026-06-16 13:26:44 +02:00
Gorgious56 51eb8aece7 Add bisect_and_cap helper to tool.Geometry
Bisects a BMesh against a set of planes (clear_outer per plane),
then fills the cut edges as cap faces tagged via a BMesh int layer
so the tag survives subsequent bisects. Cut edges are grouped into
connected components before filling so a hollow profile's outer +
inner loops produce two separate cap faces instead of a single
welded outer face that hides the hole. Pre-welds T-junctions
introduced by IFC Boolean meshes so the cut closes into a fillable
loop.

Callers are responsible for input mesh quality. Non-watertight
inputs (terrain, single-shell surfaces) may produce degenerate
cap faces — that's an accepted user-supplied data limitation
which can be revisited if real-world feedback shows it matters.

Used by the clip-box feature to compute cross-section caps per IFC
product mesh.

Generated with the assistance of an AI coding tool.
2026-06-16 13:26:08 +02:00
Gorgious56 5cc9daa2f9 Add OBB clip-plane and planar tessellation to tool.Cad
Adds geometry primitives the viewport clip-box feature needs:

- obb_world_clip_planes / obb_clip_planes_from_matrix: derive the 6
  inward clip planes of an oriented bounding box (or unit cube under
  a matrix_world) in RegionView3D.clip_planes form. expand / expand_rel
  margins let callers visualising the box with overlapping geometry
  (an empty CUBE display sharing edges with the planes) keep the box's
  own wireframe inside the clip volume.
- point_is_inside_clip_planes / corners_might_cross_clip_planes: cheap
  reject tests for the per-mesh capping pass to skip the expensive
  bisect when an object's AABB is fully outside the box.
- newell_normal / plane_basis: robust planar-ring normal for thin
  near-degenerate cap rings where a two-edge cross product is unstable.
- tessellate_ring_planar: triangulate [outer, *inners] 3D rings in the
  outer ring's best-fit plane, with a shapely constrained-Delaunay
  fallback for the known failure mode of mathutils.tessellate_polygon
  on complex concave polygons-with-holes.

Tests cover unit-box, translated, rotated, and scaled cases for the
OBB-from-matrix builder + the rejection helpers.

Generated with the assistance of an AI coding tool.
2026-06-16 13:24:42 +02:00
Gorgious56 a56b5660d0 Extract transform-modal gate + viewport helpers to tool.Blender
The transform-modal active check (Bonsai keymap macros + Blender's
TRANSFORM_OT_* family) was a module-local helper in drawing/gizmos.py
used by per-gizmo poll callbacks. It needs to be shared with other
features that gate per-frame side effects on whether a drag is in
progress (clip box plane re-arming, future modal-aware decorators).

Move BONSAI_TRANSFORM_MACROS and the gate into tool.Blender as
is_transform_modal_active classmethod; widen its window scan to all
WM windows for callers without a window-bound context (depsgraph
callbacks). Leave a thin module-local alias in drawing/gizmos.py so
AST scans and existing call sites stay decoupled from the helper's
home module.

Also add generic Blender helpers needed by the clip-box feature
(reusable by any future feature):

- iter_view3d_regions: yield (area, region, region_3d) for every
  WINDOW region in every 3D viewport — for clip-plane / draw-handler
  fanout.
- get_or_create_collection: idempotent named-collection lookup +
  link to a scene.
- is_in_edit_mode: True iff the active object is in any EDIT_*
  mode — for features that need to suspend per-tick work during
  vert/edge/face manipulation.
- serialize_matrix / deserialize_matrix / hash_matrix: round-trip a
  4x4 matrix as a 16-float CSV string for IFC pset persistence + a
  matching hash for cache keys.

Generated with the assistance of an AI coding tool.
2026-06-16 13:23:46 +02:00
Gorgious56 5f1efeffaf Tolerate stale array child/parent GUIDs (#8177)
* Tolerate stale array child/parent GUIDs

A real-world IFC project (an arrayed door whose host got deleted
externally) crashed Bonsai's project load with "Instance with
GlobalId not found" inside setup_arrays.

tool.Blender.get_object_from_guid declared Optional return but let
RuntimeError propagate; callers iterating BBIM_Array child lists then
crashed instead of skipping. Honour the documented contract by
returning None on miss, matching the convention used by every other
by_guid lookup helper in tool/array.py, tool/ifc.py, tool/geometry.py.

Sweep the four user-action sites that resolve array child/parent
GUIDs without a guard - they shared the same bug class but were
reachable from different operators (regenerate_array, RegenerateArray
clear, duplicate_ifc_objects, process_arrays). An already-missing
entity is the desired terminal state for each, so the fix is
try/except RuntimeError: continue/skip.

setup_arrays now also collects each parent with at least one stale
child GUID into IfcImporter.broken_arrays, surfaced via a new Project
panel banner mirroring the existing pending_opening_recut UX. The
banner reports the count and offers "Select Elements" to navigate to
the affected array parents and a Dismiss button.

constrain_children_to_parent was being called once per layer inside
setup_arrays' for loop even though it always iterates all layers
internally - lifted out of the loop (pre-existing N x perf bug
that the stale-GUID print exposed).

Regression tests:
- test_returns_none_when_guid_not_in_file pins the get_object_from_guid
  Optional contract.
- test_remove_array_tolerates_stale_child_guid injects a fake child
  GUID into BBIM_Array.Data and asserts bim.remove_array completes
  cleanly.

Generated with the assistance of an AI coding tool.

* Black: wrap long bl_description in dismiss_pending_array_repair

Generated with the assistance of an AI coding tool.
2026-06-16 10:33:56 +02:00
carlopav 074021de70 fix(ifc5d): escape quantity names when serialising Quantities to JSON
serialise_cost_quantities built the "Quantities" JSON string by manual
concatenation, inserting quantity.Name and the related element's Name
without any escaping. A name containing a double quote, backslash or
newline produced invalid JSON, breaking any downstream parser (e.g. a
Typst json.decode consumer reporting "failed to parse JSON"). It also
crashed with a TypeError when a name was None (str += None).

Build a Python list and serialise it with json.dumps instead, keeping
the exact same [[name, value], ...] output shape, the element-name
prefix and the unsupported-type behaviour. None names are coalesced to
"" and quantity values are defensively coerced to float.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-16 06:35:06 +02:00
Gorgious56 ed7239526c Merge pull request #8173 from Gorgious56/bonsai/wall-slab-gizmos
Bonsai/wall slab gizmos
2026-06-15 16:00:56 +02:00
Gorgious56 7cd7db0c2b Tidy: black formatting + PR7a test docstrings
Wraps three over-length lines black wanted on the merge-filter +
fillet-lock commit (wall.py's ``either_is_fillet`` chain rewraps the
right-hand ``or`` operand; test_disconnect_elements.py patch-stacks
break each ``patch(`` onto its own continuation line).

Adds per-test docstrings to test_wall_props_resync_on_dim_change.py
and test_wall_split_filled_opening.py so the contract each pins is
visible on grep / on test-run failure output without scrolling to
the module-level docstring. Drops a flip_object sibling-symbol
mention from the module docstring per CLAUDE.md §4a.

Generated with the assistance of an AI coding tool.
2026-06-15 14:56:46 +02:00
Gorgious56 13c89ace83 Fix merge crash + surface/lock fillet preview connections
DumbWallJoiner.merge previously crashed on walls with a slab underside
clip because the ConnectedTo / ConnectedFrom migration loops assumed
every rel was an IfcRelConnectsPathElements. The slab's
IfcRelConnectsElements(TOP) rel has no RelatingConnectionType /
RelatedConnectionType and raised AttributeError mid-migration. Filter
on rel class; the slab rel dies with element2 via the trailing
delete_ifc_object cascade.

The fillet preview pen icon now also flips the corner's
BIMWallProperties.is_editing so the connection-disconnect gizmos
surface in parallel with the radius drag. CancelWallFilletPreview
clears the flag before tearing the preview state down so both UIs
hide together. GizmoWallUnjoinSingle.poll inlines the viewport +
array-child guards from the topology gate so the gizmo can show
during preview — its own is_editing check is the real gate.

Fillet-to-source-wall path connection icons render in a muted gray
(LOCKED_COLOR) instead of the active disconnect tone, and the
bim.disconnect_elements operator early-returns with an INFO report
("Fillet wall path connections can't be unjoined — delete the fillet
wall element to remove the corner.") when either side resolves to a
fillet corner. The slab clip rel kind stays disconnect-able since
its identity is separate from the fillet's chord-axis reference.

Drive-by /improve polish on adjacent wall.py code: 3 comment tightenings
dropping sibling-symbol names + a defensive ``if opening.ObjectPlacement:``
guard in the merge opening migration matching the pattern used elsewhere
in the same file.

Generated with the assistance of an AI coding tool.
2026-06-15 14:32:29 +02:00
Gorgious56 6119f0045e Swap merge convention to active-is-survivor
bim.merge_wall now consumes the non-active selection into the active
one — matching Blender's OBJECT_OT_join (Ctrl+J) and MESH_OT_merge
"at last" convention. The wall the user clicks last absorbs the
other; users following Blender muscle-memory get the result they
expect. DumbWallJoiner.merge is already structurally asymmetric
(wall1 = survivor); only the caller in MergeWall._perform needed
flipping. Audit confirmed the previous call site was the sole
caller of DumbWallJoiner.merge in production code.

Drive-by tidies on adjacent code: collapse two over-length comprehensions
under black's 120-char budget, and switch ``any(True for _ in gen)`` to
``any(gen)`` since the iterable yields tuples that are always truthy.

Generated with the assistance of an AI coding tool.
2026-06-15 12:37:24 +02:00
Gorgious56 bbe437adc8 Fix wall-split filled-opening classification + void copy
Two bugs in DumbWallJoiner.split's filled-opening branch:

1. Side classification read filling_obj.matrix_world.translation —
   flip-fragile because flip_object rotates the filler 180° + translates
   so the bbox stays visually in place, moving the door origin to the
   opposite bbox corner. A flipped door centred over the cut could be
   classified on the wrong side. Switch to the opening's axis-projected
   midpoint, which the unfilled-opening loop already uses.

2. When the void straddles the cut and the filling moves to element2,
   the void copy for element1 was taken from the rebound new_opening
   whose PlacementRelTo had been swapped to element2 — the new void on
   element1 then sat in element2's local frame. Reorder so the copy
   reads from the original opening (still hosted by element1) before
   remove_feature destroys it.

Generated with the assistance of an AI coding tool.
2026-06-15 11:41:01 +02:00
Gorgious56 7dcf415ef1 Resync wall props after dimension mutation
ChangeExtrusionDepth, ChangeExtrusionXAngle, and ChangeLayerLength
mutate IFC extrusion / axis but never re-prime BIMWallProperties from
the post-mutation state. Gizmo icons that position from props.height
then sit at the pre-mutation elevation even though the wall mesh shows
the new one — visible asymmetry against the workspace header H field
which redraws live. Add the existing _resync_walls_after_mutation
call to each operator's epilogue. _maybe_resync_wall_props_from_ifc
already skips non-walls and walls in edit mode, so calling on the raw
selection list is safe.

Generated with the assistance of an AI coding tool.
2026-06-15 11:06:32 +02:00
Gorgious56 3f8d7165e5 Preserve openings on wall merge
DumbWallJoiner.merge cascade-deletes element2's HasOpenings via
delete_ifc_object, which previously dropped every IfcOpeningElement
(and any IfcDoor / IfcWindow filling) hosted by the discarded wall.
Re-host each void rel onto the survivor BEFORE the delete fires, and
re-apply the opening's captured world matrix via edit_object_placement
so the void doesn't drift when the two walls have different
placements — a PlacementRelTo swap alone would fail this when origins
differ along the shared axis.

Generated with the assistance of an AI coding tool.
2026-06-15 10:57:07 +02:00
Gorgious56 ca1e2165e8 Merge pull request #8172 from Gorgious56/bonsai/railing-edit-gizmos
Bonsai/railing edit gizmos
2026-06-15 10:23:16 +02:00
Gorgious56 2f067187ee Disable snap on schematic gizmos
Schematic dimensions float in billboarded viewport space; their labels
carry the value, not the bar length. Snapping the dragged tip to scene
vertices produces nonsensical value jumps when the mouse crosses
unrelated meshes. Add an opt-out flag on the parametric gizmo group
base and override it on the schematic base — every schematic subclass
inherits no-snap behaviour, and in-place parametric gizmos (door,
window, wall, stair, roof, mep) keep the existing Ctrl-toggleable
snap because the default stays True.

GizmoDimension.invoke also forces tool_settings.use_snap = False for
schematic gizmos so the header magnet visibly switches off for the
drag's duration. The existing exit path restores the user's previous
setting on release.

Generated with the assistance of an AI coding tool.
2026-06-15 10:08:50 +02:00
Thomas Krijnen 6a6756de66 Bump binary versions in makefiles; add backwards compatibility to logger usage in python #8167 2026-06-15 09:56:36 +02:00
Thomas Krijnen 22707fa534 Bring back multiple schema includes in IfcParseExamples.cpp 2026-06-14 21:02:35 +02:00
Thomas Krijnen 3e7b739d8d Don't rely on typeid() naming in VariantArray 2026-06-14 20:27:49 +02:00
Thomas Krijnen ca99ef3af7 More changes to pass around logger to parse-related calls 2026-06-14 14:49:14 +02:00
Gorgious56 55428a0878 Add wall regen helper, fillet underside, bug sweep
Wall body rebuild + slab underside re-clip are now unified behind
tool.Model.regenerate_wall and called from split / merge / extend
operators. Fillet corner walls accept extend-to-underside (poll +
operator partition switched to is_path_connectable_wall) and surface
the wall-unjoin gizmo without the parametric-edit gate, since
fillets cannot enter that lifecycle. DumbWallJoiner.split strips the
duplicate's inherited slab-trim booleans up front so wall2 lands at
the cut point. regenerate_fillet_corner_wall re-clips after the body
rewrite so a prior extend-to-slab survives neighbour recalcs.

Drive-by bug sweep: tuple typo in hotkey_S_G's IfcSpace check,
defensive .get() in draw_regen_operations for partial AuthoringData
loads, and a try/except in get_active_representation matching the
existing convention for stale mesh ifc_definition_ids after a
representation rebuild.

Tests cover the regenerate_wall branching, the get_active_representation
stale-id contract, and the GizmoWallExtendVertically fillet acceptance.

Generated with the assistance of an AI coding tool.
2026-06-14 10:56:07 +02:00
Ryan Schultz 682bd0a4f7 closes #6235 - Add copy toggle to CAD offset (#8168)
Add a "Copy" option to bim.cad_offset. When enabled (the
default) it offsets a new copy of the selected edges as
before; when disabled it moves the existing edges to the
offset location instead. The toggle is exposed in the CAD
tool's Offset panel and the operator redo panel.

Generated with the assistance of an AI coding tool.
2026-06-12 14:08:21 -05:00
Thomas Krijnen 671217d494 Commit remainder of fixes to IfcParseExamples 2026-06-12 12:13:35 +02:00
Josef Wienerroither 815699f64b Merge branch 'v0.8.0' into ifcmax/initial-refresh 2026-06-12 11:59:40 +02:00
Thomas Krijnen dcebf23af8 Workaround for header construction order 2026-06-12 11:41:11 +02:00
Gorgious56 bb8681a954 Cascade connection cleanup on element delete
Deleting a slab that was connected to a wall via IfcRelConnectsElements(TOP)
left the wall holding orphan IfcBooleanResult items + a stale BBIM_Boolean
pset. The disconnect operator already runs the right cleanup; element delete
just never invoked it.

Extract the per-kind cleanup into core.connection.disconnect_rel so the
operator (bim.disconnect_elements) and a new cascade in
tool.Geometry.delete_ifc_object share one dispatch table. Adding a future
rel kind to tool.Connection.find_rels now flows into both call sites
automatically; an AST forward-compat guard enforces coverage.

Other adjustments:
- regenerate_wall_to_underside zero-slab branch now removes stale clip
  booleans instead of silently skipping, so disconnecting the last TOP
  slab also reverts the wall correctly.
- duplicate_ifc_objects (Shift+D) calls strip_underside_booleans on copied
  walls so the duplicate doesn't carry over the source's slab trim, then
  reloads the body representation when something was stripped so the
  viewport reflects the change without waiting on Shift+G.
- batch_being_deleted_ids threads through OverrideDelete so the cascade
  can suppress partner-side regenerate when both endpoints are queued for
  deletion in the same batch.

This file was generated with the assistance of an AI coding tool.
2026-06-12 11:11:07 +02:00
Gorgious56 c7d5d6c498 Gate slab disconnect gizmos behind parametric edit lifecycle
Wires slabs into the parametric edit framework (tool.Parametric
.EDIT_TYPES) so the wall-slab disconnect UI gets ESC handling, red
cancel icon, mutual exclusion with other parametric edits, and
per-feature gizmo prefs — all from BaseParametricGizmoGroup — without
duplicating the lifecycle.

Adds:
- ParametricObject("slab") registry entry + tool.Parametric.is_slab
  predicate (any IfcSlab).
- BIMSlabProperties with is_editing flag; PointerProperty wired by
  the framework's register_object_properties.
- bim.enable_editing_slab / bim.finish_editing_slab /
  bim.cancel_editing_slab operators on tool.Ifc.Operator so they
  flow through tool.Parametric.run_bim_op cleanly. No IFC mutation
  — slab edit is a pure UI gate; finish and cancel share the body.
- tool.Model.get_slab_props accessor.
- GizmoSlabEdition inheriting BaseParametricGizmoGroup with the
  pen / validate / cancel triad. is_element_type narrows to
  IfcSlab with at least one wall clipped to its underside.

The disconnect-icon group GizmoSlabUnjoinWalls polls behind
_slab_connection_gizmo_poll_gate(require_editing=True), which now
reads is_editing through tool.Model.get_slab_props.

Drops the standalone GizmoSlabConnectionAccess + the
setup_pen_cancel_icons helper added earlier in this branch — both
superseded by the framework integration.

Also folds in the wall + multi-slab gizmo polish requested live:
- Wall side: stack the per-slab unjoin icons vertically (up to 5)
  so multi-slab connections each get a distinct clickable icon;
  hover-highlight reveals which slab will disconnect.
- GizmoPairDisconnect activates when 2 elements with an
  IfcRelConnectsElements(TOP) rel are selected, with the icon at
  the wall-slab connection world anchor.
- Wall-slab anchor moved from slab clip Z to wall top +
  WALL_SLAB_CONNECTION_Z_CLEARANCE so the disconnect icon perches
  above the extend-vertical / slope gizmo instead of overlapping.
- Shared _resolve_active_partner_pair helper for 2-selection
  gizmos; _slab_connection_gizmo_poll_gate added to
  _REQUIRED_CALLEES + GizmoSlabEdition added to the AST
  forward-compat allowlist.

Build note: wall.py's DisconnectElements._perform imports
bonsai.core.connection.disconnect_rel — that core module is being
added in a parallel-session commit. Until that lands the addon
import will fail.

Generated with the assistance of an AI coding tool.
2026-06-12 11:05:02 +02:00
Thomas Krijnen 4d22a3fdb9 Enable retargeting of example schema 2026-06-12 11:04:18 +02:00
Gorgious56 a3593ed58b Unify wall disconnect ops via bim.disconnect_elements
Single generic dispatcher replaces UnjoinWallPathConnection +
DisconnectWallSlab. Takes two GlobalIds, looks up every supported
rel between them via tool.Connection.find_rels, dispatches the right
cleanup by rel kind:

- path (IfcRelConnectsPathElements): remove_connection on every rel
  in both orientations + recreate both walls + resync drafts.
- element-top (IfcRelConnectsElements with Description=="TOP"):
  disconnect_element + regenerate_wall_to_underside on the wall side
  via orient_element_top to recover which input is wall vs slab.
- element (other IfcRelConnectsElements): plain disconnect_element.

tool.Connection lands as a new tool module with two helpers:
- find_rels(a, b): every supported rel between two elements, walking
  both ConnectedTo + ConnectedFrom (catches both authoring
  orientations and dedups by id).
- find_rel(a, b): first-match convenience.
- orient_element_top(rel, a, b): recovers (wall, slab) from a TOP
  rel regardless of which input came first.

Updates GizmoWallUnjoinSingle to target bim.disconnect_elements with
both element_a_guid + element_b_guid pre-filled per icon. Adds the
single registration in tool/__init__.py and the classes-tuple entry
in bim/module/model/__init__.py. Drops the two retired classes.

Tests cover both cleanup branches (path + element-top), missing
endpoints, no-rel-found, and registration smoke.

Generated with the assistance of an AI coding tool.
2026-06-12 09:06:09 +02:00
Gorgious56 b0eb55cc38 Add bim.disconnect_wall_slab operator
Counterpart to UnjoinWallPathConnection on the wall-slab side: takes
a wall + slab GlobalId pair, locates the IfcRelConnectsElements(TOP)
between them via tool.Wall.find_wall_slab_rel, removes it via
ifcopenshell.api.geometry.disconnect_element, then re-runs
core.regenerate_wall_to_underside so the wall re-clips against any
remaining connected slabs (the disconnected slab is excluded
naturally because the helper walks tool.Model.get_connected_slab_objs
which filters by the rel set).

Defensive reports replace silent CANCELLED on three error paths the
UI can hit when the gizmo dispatches against stale state: unknown
GlobalIds, wall entity without a Blender object, no rel found
between the resolved pair.

Tests cover all four control flows (happy path + three error paths)
plus a registration smoke that catches a forgotten classes-tuple
update.

A follow-up commit will retrofit this + UnjoinWallPathConnection +
the MEP port disconnects through a unified bim.disconnect_elements
dispatcher with a small connection-type registry; that lands as a
separate single-concern commit so the typed operator can be
reviewed first.

Generated with the assistance of an AI coding tool.
2026-06-12 08:19:04 +02:00
Gorgious56 f6590d8be2 Add tool.Wall slab-connection helpers + tests
Four classmethods enable the new wall-slab connection gizmo work:

- iter_wall_slab_connections(wall): yields (slab, rel) tuples for
  every IfcRelConnectsElements(TOP) on wall.ConnectedFrom — the rel
  kind extend_walls_to_underside creates.
- iter_slab_wall_connections(slab): mirror, walks slab.ConnectedTo
  so a slab-side gizmo can enumerate every wall clipped to its
  underside.
- find_wall_slab_rel(wall, slab): locates the specific rel between
  a wall + slab pair so a disconnect operator knows what to remove.
- wall_slab_connection_location_world(wall_obj, slab_obj): returns
  the world-space icon anchor — wall axis midpoint X/Y lifted to
  the slab's mesh-bbox underside Z. Approximate (uses slab bbox vs
  reconstructing the slab's clip plane) but adequate for icon
  placement on a wall whose top meets the slab; returns None when
  the wall has no IFC Axis representation.

Tests (11) pin the rel-shape contract (class + Description=="TOP",
non-TOP and non-IfcRelConnectsElements rels skipped, None relating
defensively skipped) plus the icon-anchor math (axis-mid lifted to
slab-bbox bottom; None for axisless walls).

Generated with the assistance of an AI coding tool.
2026-06-12 08:02:50 +02:00
Thomas Krijnen 3136c74c2f Pass logger to proj callback 2026-06-11 21:57:42 +02:00
Thomas Krijnen 347a3c80bb More logger changes 2026-06-11 21:09:56 +02:00
Gorgious56 92aa890add Refresh railing preview on every gizmo edit
update_railing skipped the bmesh rebuild for WALL_MOUNTED_HANDRAIL
railings because the only mesh source available at the time mutated
IFC. The viewport-only preview helper that lands with the parametric
gizmo work (generate_wall_mounted_handrail_preview) sidesteps IFC
entirely, so the WALL_MOUNTED_HANDRAIL branch can join the
FRAMELESS_PANEL path and trigger update_railing_modifier_bmesh on
every property write. Gizmo drag now repaints the viewport in real
time instead of waiting for Finish Editing.

Generated with the assistance of an AI coding tool.
2026-06-11 20:49:41 +02:00
Gorgious56 05c9df74f9 Migrate railing terminal type to PickType menu
Switches the IfcRailingType terminal-type selector from cycle-on-click
to a popup menu of all terminal-type literals — 5+ values trip the
§2.8 menu-pick threshold. Updates classes registration; removes
EditRailingTerminalType in favour of PickRailingTerminalType which
inherits PickTypeMixin.

Adapts the cherry-pick from db016d881 to post-PR5 framework state:
- Imports CycleTypeMixin / PickTypeMixin / PathPreservingEditMixin
  from bim.parametric_lifecycle (PR5 moved them off gizmos.py).
- Routes is_railing through tool.Parametric (predicates moved off
  tool.Blender.Modifier between PR3-PR5).

Skips the parametric_lifecycle.py framework refactor the source
commit shipped — HEAD has the more-evolved post-PR5 framework that
already covers it.

Adds the _FakePropsBase + make_lifecycle_obj test helpers to
test/bim/conftest.py so the new test_railing_lifecycle.py can
exercise the edit triad without a real bpy.types.Object. Brings the
test_railing_schematic.py marker in line with the rest of the model
lane.

Generated with the assistance of an AI coding tool.
2026-06-11 20:49:27 +02:00
Gorgious56 0c993d3292 Guard HasShapeAspects access on IFC2X3 representation iteration
IFC2X3 representations have no HasShapeAspects inverse; opening the
Geometry & Materials subpanel on an IFC2X3 object raised AttributeError
and left the items list empty. Wrap the access with a getattr default
so pre-IFC4 schemas return an empty iterable, and pin the contract with
an AST forward-compat guard that scans bim/, tool/, and core/ for any
future direct .HasShapeAspects access.

Closes #8157

Generated with the assistance of an AI coding tool.
2026-06-11 09:28:27 +02:00
Josef Wienerroither ba629e9615 Merge branch 'v0.8.0' into ifcmax/initial-refresh 2026-06-11 07:29:19 +02:00
Richard Brice 32a601d057 fixes build problem from commit a7738eeb 2026-06-10 13:31:32 -07:00
Gorgious56 d1d1e1d4a2 Add railing parametric edit + schematic preview
Port gizmos-8088's railing gizmo block to v0.8.0:

- _RailingEditMixin (PathPreservingEditMixin specialisation) +
  EnableEditingRailing / CancelEditingRailing / FinishEditingRailing
  edit triad
- CycleRailingType (2-value type cycler) + ToggleRailingUseManualSupports
  one-shot + EditRailingTerminalType
- FlipRailingPathOrder + EnableEditingRailingPath /
  CancelEditingRailingPath / FinishEditingRailingPath path-edit
  operators (mutually exclusive with the schematic frame)
- GizmoRailingSchematic (BaseSchematicGizmoGroup specialisation) —
  axonometric schematic frame with per-attribute dimension gizmos
  for FRAMELESS_PANEL + WALL_MOUNTED_HANDRAIL railing types;
  hover-on-attr highlights the schematic edges tagged with the
  matching feature

Tests: test_railing_lifecycle.py (280 LOC) +
test_railing_schematic.py (272 LOC).

Drops the per-feature GizmoPreferences{Door,Window,Stair,Wall,Roof,
Railing} PropertyGroups that the source commit added to bim/ui.py
— that finer-grained per-attribute toggle model was deliberately
collapsed to flat per-feature bools in the PR5b prefs sweep, and
GizmoRailingSchematic gates on the flat ``prefs.gizmos.railing``
bool via ``gizmo_pref_name`` so no functionality is lost.

Generated with the assistance of an AI coding tool.
2026-06-10 20:40:33 +02:00
Thomas Krijnen a7738eeb64 Pass around non-static logger instances and programmatic access to messages in-memory 2026-06-10 18:40:17 +02:00
Thomas Krijnen a751fb956d Introduce unique error codes 2026-06-10 18:40:17 +02:00
Gorgious56 251157f8d4 Fix np_frombuffer_legacy length-vs-dtype check
The check `len(bytedata) == n * 2` was wrong: float64 is 8 bytes per
element, not 2. Legacy float64 checksums fell through to the float32
reader and produced a (2n,)-shaped array, breaking is_moved() and
is_camera_moved() with `ValueError: operands could not be broadcast`
on .blend files saved by Blender <5.0.

Adds a parametrized regression test covering both n=3 (translation)
and n=9 (rotation) for both dtypes.

Generated with the assistance of an AI coding tool.
2026-06-10 17:37:41 +02:00
Gorgious56 a213a9b848 Merge pull request #8155 from Gorgious56/bonsai/mep-edit-gizmos
Add MEP segment + bend edit gizmos
2026-06-10 17:35:33 +02:00
Gorgious56 b76bc1c1f8 Read wall extent from bbox in cursor gizmo layout
GizmoWallEdition.position_gizmos used props.anchor_x / props.length
for the in-range check (split icon visibility) and perpendicular
gizmo placement. Those props mirror IFC and are re-primed by
_maybe_resync_wall_props_from_ifc — any operator path that skips
the re-sync leaves the perpendicular gizmo clamped to the previous
wall extent, so the icon parks at the old wall end instead of the
cursor's orthogonal projection. Visible after a wall mutation as
the perpendicular icon landing way off the cursor in top-down view.

Switch to the mesh bbox along local X. recreate_wall rebuilds the
mesh to match the current IFC body on every wall mutation, so
bound_box is authoritative without an explicit props sync.

Generated with the assistance of an AI coding tool.
2026-06-10 13:17:07 +02:00
Gorgious56 4d24cef0c9 Hide MEP gizmos on non-parametric elements
MEP elements imported as tessellation / brep (no IfcExtrudedAreaSolid
or IfcSweptDiskSolid in their body representation) can't be
parametrically edited — the gizmos offer affordances the geometry
kernel has no path to honour. tool.System.has_parametric_body
inspects the Model/Body/MODEL_VIEW representation and returns True
only when at least one item resolves to one of the two
profile-sweep primitives.

The gate is wired into:
- GizmoMEPActions.is_eligible_object (the action icon group)
- _active_is_flow_segment / _active_is_bend_fitting visibility
  predicates the icon row consults per-icon
- GizmoPipeSegmentEdition / GizmoDuctSegmentEdition is_element_type

tool.Parametric.is_pipe_segment / is_duct_segment stay IFC-class-only
so their truth-table contract test keeps reading a single concern.

Generated with the assistance of an AI coding tool.
2026-06-10 13:00:01 +02:00
Gorgious56 6c9cfccc43 Move _is_multiple_of_pi to tool.Cad
Pure-math parallelism check (value ≡ 0 mod π within VTX_PRECISION)
that lived as a module-private helper in mep.py belongs next to
tool.Cad.is_x — same comparator family, no MEP-specific knowledge.
Other features with rotation-difference checks (wall fillet, roof
slope, railing terminus) now have a sanctioned spelling.

Generated with the assistance of an AI coding tool.
2026-06-10 12:35:21 +02:00
Gorgious56 0a0a5b9f04 Add MEP cache + smoke + cancel-ops forward-compat tests
Four standalone test files pinning contracts the production code
already honours:

- test_mep_actions_cache.py: GizmoMEPActions visibility-predicate
  cache evicts on selection or generation change.
- test_mep_bend_preview_cache.py: bend decorator polyline cache
  re-uses within a generation and rebuilds on generation bump.
- test_mep_distribution_fit_smoke.py: bim.fit_flow_segments
  round-trips a 3-segment polyline without raising.
- test_preview_cancel_ops_forward_compat.py: AST scan ensures every
  preview Enable* operator has a paired Cancel* operator with the
  matching prop reset.

Generated with the assistance of an AI coding tool.
2026-06-10 12:27:23 +02:00
Gorgious56 3a0abbab95 DRY transform-modal draw gate + polyline helper
Two small refactors:

- apply_transform_modal_draw_gate(group, context) replaces the
  three-line _is_transform_modal_active + _hide_all_non_modal_gizmos
  pair that BillboardingGizmoGroupMixin, BaseParametricGizmoGroup
  and BaseSchematicGizmoGroup all repeat in draw_prepare.
- decorator.py renames _stroke_lines_alpha to a public-scope
  draw_polyline_segments and drops the no-longer-private companion
  docstring reference; the function is now usable by sibling
  decorators that draw polyline overlays.

Plus a few one-liner tweaks in tool/model.py and opening.py
following the helper rename.

Generated with the assistance of an AI coding tool.
2026-06-10 12:26:48 +02:00
Gorgious56 0d703039a6 Cache array-child + wall topology by IFC generation
Two hot paths the gizmo polls fire every viewport event memoise
their result against tool.Parametric.get_geom_generation():

- tool.Blender.Modifier.any_selected_array_child caches the
  per-selection scan against the selection identity-set + the
  IFC generation token so a stable selection during a drag
  doesn't re-walk every selected object's BBIM_Array pset every
  frame.
- bim/module/model/wall.py grows a pair-predicate + connection
  cache that the wall topology gizmos hit; both keyed on
  (pair_uids, predicate_kind, generation) so a wall split or
  axis edit invalidates correctly via the generation bump.

Behavioural contract is unchanged — stale entries are evicted
on generation bump; cache miss returns the same value the
un-cached path returned.

Generated with the assistance of an AI coding tool.
2026-06-10 12:25:42 +02:00
Gorgious56 f33df52c1b Centralise model test fixtures via conftest
bim/module/model/conftest.py exposes the autouse _require_real_bpy
skip-guard, four make_* factories (obj / element / context /
ifc_file), and a patched_tool context-manager factory that wires
the half-dozen tool.* boundary patches every gizmo + decorator
test was repeating.

Existing test files in the directory drop their local copies of
_require_real_bpy and adopt the patched_tool / make_* fixtures
where the call site simplifies — test_mep_port_operators.py is
the biggest beneficiary (−89 LOC).

No production behaviour change.

Generated with the assistance of an AI coding tool.
2026-06-10 12:24:44 +02:00
Gorgious56 15a6375ea3 Extract MEP bend preview + refine port operators
Three concerns bundled by file boundary (all in mep.py):

- Extract bend preview operators + GizmoBendPreview into a focused
  mep_bend_preview.py module; preview_base.py grows the shared helper
  set both bend and other previews now consume; classes tuple in
  model/__init__.py updated to register the new module.
- Surface ERROR reports on five silent CANCELLED returns in
  MEPUnjoinAtPort / MEPRemoveTerminalFitting / MEPUnjoinPair so a
  degenerate IFC file ("fitting has no Blender object", "connected
  port leads nowhere") shows up in the popup instead of looking like
  a no-op.
- DRY: _resolve_active_mep_segment + _require_port_state factor the
  segment-id-or-active-object resolve + port-state guard out of every
  port operator's prologue; _wire_anchored_icon_targets pulls the
  GizmoMEPActions setup() body into an exercise-without-MRO helper so
  the wiring-contract tests can hit it without instantiating the
  GizmoGroup.

Drops the now-unused preview_base import that the extraction left
behind.

Generated with the assistance of an AI coding tool.
2026-06-10 12:24:05 +02:00
Gorgious56 82465a64a5 Brighten and dash opening occlusion outline
The opening preview's outline used a single-batch two-pass scheme that
dimmed the occluded back pass via alpha=0.25. The visible front pass also
inherited the source decorator color's modest alpha, so the outline read
as subtle on both sides.

Replace with a CAD hidden-line convention: solid full-alpha front pass on
the visible side, world-space dashed back pass on the occluded side. Both
passes use POLYLINE_UNIFORM_COLOR so depth and line-weight paths match.
The dashed batch is built once per object epoch by a new pure helper
tool.Blender.build_dashed_line_segments (pre-segments edges into world-
space dash chunks), then cached via the existing batch-cache mechanism
under "<uid>_dashed".

The solid front pass is rendered at a slightly wider line width than the
dashed back pass so its halo overpowers Blender's WIRE-display overlay
bias at outline pixels — without the asymmetry the wire's anti-z-fight
forward bias makes the LESS_EQUAL comparison narrowly fail and the
dashed pass wins on visible edges too.

Generated with the assistance of an AI coding tool.
2026-06-09 22:49:00 +02:00
Gorgious56 6bde619fe6 Migrate MEPConnectElements args from object names to IFC GUIDs
MEPConnectElements took obj1_name/obj2_name (Blender object names),
which break when objects are renamed or replicated by array
duplication. Switch to obj1_guid/obj2_guid resolved via
ifc_file.by_guid, with by_guid RuntimeError surfaced as an operator
error rather than a stack trace. DrawPolylineProfile (the sole
in-tree caller) updates to pass GlobalIds.

Generated with the assistance of an AI coding tool.
2026-06-09 22:47:10 +02:00
Gorgious56 ba5321fdfa Add MEP bend tessellation helper tests
Pins the geometry contracts the hand-meshed bend body relies on
while IfcSweptDiskSolid round-trip is broken upstream (#8106):

- profile cross-section sampling: circle returns 16 evenly-spaced
  points starting at (radius, 0); rectangle returns the four
  canonical corners; anything else returns None so the rep swap
  is skipped rather than meshed against the wrong section
- parallel-transport framing keeps the cross-section continuous
  around L-shaped corners — pinned via start / end ring planes
- initial_basis override seeds the first ring with the source
  segment's local +X / +Y axes, fixing the asymmetric-rectangle
  twist the world-Z seed produces

Generated with the assistance of an AI coding tool.
2026-06-09 22:18:22 +02:00
Gorgious56 49ddc97918 Add MEP port operator dispatch tests
Pins which IFC mutation each port operator commits and which
inputs each refuses with CANCELLED:
- MEPUnjoinAtPort removes the fitting + reconnects the two free
  ports; refuses if the named port is free or terminal
- MEPRemoveTerminalFitting deletes the terminal element + leaves
  the segment's port free; refuses on bridged fittings
- SelectMEPPathMembers walks IfcRelConnectsPorts in both
  directions from the active segment and selects every fitting /
  segment reachable through the port graph

Boundary mocks for tool.Ifc, tool.System and MEPGenerator stand
in for the IFC fixture; tests assert against the recorded
ifcopenshell.api.* calls.

Generated with the assistance of an AI coding tool.
2026-06-09 21:46:34 +02:00
Gorgious56 39bcd9db63 Add GizmoMEPActions wiring contract tests
Pins two regressions the live MEP gizmo group can hit:
- per-icon setup() must write `position` (and `mode` on open-lock
  icons) onto every target_set_operator result; the test stands in
  for the AttributeError on bim.mep_add_obstruction that surfaced
  when a field was dropped from the operator declaration
- each visibility_condition lambda must stay total against None /
  non-IFC inputs, since a single raising predicate silently disables
  every sibling icon in the group

Generated with the assistance of an AI coding tool.
2026-06-09 21:45:34 +02:00
Thomas Krijnen ab11ac5338 Catch decomposition errors #8149 2026-06-09 21:35:40 +02:00
Gorgious56 9346f45bba Fix decorator face-tri overlay artifacts
ProfileDecorator.draw_faces (used by the roof path-edit overlay) and
SystemDecorator.draw_faces called bmesh.ops.triangulate on the live
bmesh — both mutated the input and produced ear-clip fans that rendered
as visible streaks across n-gon roof faces at alpha 0.1. The opening
DecorationsHandler edit-mode branch had a separate bug: it computed
triangles from obj.data.calc_loop_triangles() while iterating the
edit-mode bmesh, so any topology added mid-edit desynced the indices.

Centralise the correct draw path on tool.Blender.draw_bmesh_face_tris
(wraps bm.calc_loop_triangles, non-mutating, beauty triangulator) and
route all three call-sites through it. A forward-compat AST guard walks
every *Decorator / DecorationsHandler class under bim/module/ and pins
the no-bmesh.ops.triangulate rule against future regressions.

Generated with the assistance of an AI coding tool.
2026-06-09 20:16:46 +02:00
Gorgious56 b22687891b Warn on shared-rep parametric edits
A user clicking the pen icon on a typed-product occurrence whose body
representation is mapped from its type would silently mutate every
sibling occurrence's geometry. Add a confirmation dialog at the pen-icon
dispatcher (the single chokepoint every feature routes through) showing
the sibling count, with a session-scoped suppress checkbox.

The check is read-only: tool.Model.get_sibling_occurrence_count wraps
tool.Geometry.get_elements_by_representation against the resolved body
rep and subtracts self + type. A forward-compat AST guard pins the
dispatcher monopoly so any future feature that binds pen_gizmo directly
to a feature-specific enable op fails the test before merge.

Generated with the assistance of an AI coding tool.
2026-06-09 17:32:44 +02:00
Gorgious56 784f0b1fe2 Add bend re-edit gizmo
Once a bend was created, the only way to retune start_length /
end_length / radius was to delete and recreate from scratch.
EnableBendPreviewFromBend re-opens the preview on an existing
parametric bend: it walks the bend's ports to resolve the two
connected segments, reads start / end length and radius from the
bend type's BBIM_Fitting pset, and sets editing_bend_id on the
preview props. MEPAddBend then deletes the old bend + its port
connections (single undo step) before the recreate path runs, so
finish replaces the bend in place and cancel discards the edit
without touching the original.

GizmoMEPActions surfaces a pen icon on single bend-fitting
selections via the new _active_is_bend_fitting predicate; the icon
dispatches the new operator. Mirror of the wall fillet re-edit
flow (EnableWallFilletPreviewFromCorner + editing_corner_id in
CreateWallFillet).

Test coverage: registration probe for the new operator, an attached
editing_bend_id field probe on the preview umbrella, and a
parametrized truth-table for the _is_bend_fitting predicate
(IfcFlowFitting with BEND PredefinedType, with other PredefinedType,
with no type, IfcFlowSegment, IfcWall, None).

Generated with the assistance of an AI coding tool.
2026-06-09 17:18:09 +02:00
Gorgious56 e0ceda6856 Hide wall topology gizmos on array children
Wall topology mutations (merge / join / extend-to-wall / unjoin /
fillet) applied to a Bonsai array child are silently overwritten by
the next ``regenerate_array``; merge also orphans a GUID listed in
the parent's ``BBIM_Array.Data``. Add a central
``tool.Blender.Modifier.any_selected_is_array_child`` predicate and
gate the five wall topology gizmo groups plus the six bound operators
behind it. Operator gating is defence in depth against keymap / F3
invocation paths that bypass the gizmo.

The base ``_wall_gizmo_poll_gate`` keeps its loose two-check shape
(viewport gizmos + no preview). A new
``_wall_topology_gizmo_poll_gate`` wraps it with the array-child
filter and is what the topology gizmos use. Host-opening gizmos
deliberately stay on the loose gate: openings authored on a child
are preserved through ``regenerate_array`` and track with the
replicated instance.

A forward-compat AST guard walks wall.py for ``GizmoGroup`` subclasses
and asserts each routes its poll through the tighter gate or the
central predicate, with an allow-list for the parametric-edit and
preview-owner exceptions. New wall topology gizmos inherit the
contract by construction.

Generated with the assistance of an AI coding tool.
2026-06-09 17:16:12 +02:00
Gorgious56 17951427fe Add readonly door swing arc preview
Selecting a Bonsai-parametric IfcDoor now shows the swing arc(s)
without entering edit mode. A new viewport decorator polls on the
active object, reads the door's BBIM_Door pset, and draws the same
arcs the parametric door swing gizmo would draw — matching the
hinge / panel-width / x-mirror contract minus the is_editing gate.

A forward-compat test walks every door operation type and cross-
checks the readonly decorator's arc selection against the gizmo's
swing-arc config table, so future enum additions fail in both
surfaces simultaneously.

Also disables the inherited 8-pass dark halo on GizmoArc: an open
curve has no enclosed silhouette, so the offset passes read as
ghost arcs rather than a uniform outline. The arc's own cross-
section thickness keeps it legible without the halo.

Generated with the assistance of an AI coding tool.
2026-06-09 16:13:59 +02:00
Gorgious56 734f4df84e Add MEP bend preview + bend tessellation fallback
The MEP bend feature's IfcSweptDiskSolid representation produces
geometrically correct output but fails to round-trip through the
OpenCascade geometry kernel (upstream issue #8106) — the body is
dropped on the next file load. Until upstream is fixed, MEPAddBend
captures the bend centerline in world space before the segments are
extended (otherwise the post-extension axes no longer reach the
original intersection and arc reconstruction is wrong), then after
the fitting is placed it hand-meshes the bend body and swaps the
type's swept-disk representation for an IfcTessellatedFaceSet via
tool.Geometry.export_mesh_to_tessellation + tool.Model.
replace_object_ifc_representation.

The centerline includes the straight start_length / end_length legs
in addition to the arc so the bend covers the full segment-to-
segment span. Sweep uses parallel-transport framing — each ring's
(right, up) basis is rotated by the minimum rotation that maps the
previous tangent to the current one, eliminating the twist a fixed
world-axis reference produces when the tangent crosses the
reference. Cross-section orientation seeds from the source segment's
matrix_world local +X / +Y so asymmetric IfcRectangleProfileDef
ducts land with XDim / YDim on the same axes the segment expects;
parallel transport then preserves that alignment around the arc.
Centerline radius is radius + profile_dim[lateral_axis] to match
MEPAddBend's ref_point_radius — without this offset, the bend legs
fall short of the extended segments by profile_dim * tan(angle/2).
Face winding is left to the caller to correct via
bmesh.ops.recalc_face_normals on the closed bend tube.

Two FIXME(#8106) markers (capture site + helper call site) so both
can be dropped once upstream lands a swept-disk round-trip fix.

Generated with the assistance of an AI coding tool.
2026-06-09 16:12:35 +02:00
Ryan Schultz bfa2d789f1 Error on tessellation request in IFC2X3
IfcTriangulatedFaceSet/IfcPolygonalFaceSet were introduced in
Fix #7992: IFC4 and do not exist in IFC2X3. Previously, requesting an
IfcTessellatedFaceSet representation in an IFC2X3 file silently
fell back to a faceted brep after unassigning material sets.
Add a guard in the update_representation operator (user-facing
error) and in the add_representation API (ValueError) so the
unsupported request is caught instead of failing silently.

Generated with the assistance of an AI coding tool.
2026-06-09 07:30:59 -05:00
Gorgious56 192bf00d31 Add cursor-bound perpendicular wall gizmo
GizmoWallEdition gains a fourth cursor-anchored icon that
spawns a perpendicular branch wall from the cursor's
orthogonal projection on the source wall axis. Click forms
a T-junction; shift+click forms an L-corner with the source
wall trimmed at the projection, keeping its longer portion.

The branch inherits the source's spatial container and
centerline baseline so its authored axis matches the source's
alignment rather than the type's default.

Also includes a floor-plane preview quad for the new gizmo,
a floor-Z cross line on the split preview for top-down
visibility, a small bump to QUAD_ALPHA for clearer preview
fills, and a stacking-offset helper that centralises the
cursor-row screen-up step across three call sites.

Generated with the assistance of an AI coding tool.
2026-06-09 13:54:39 +02:00
Gorgious56 ad672d0edb Add GizmoMEPActions + bend precondition + obstruction modes
The MEP one-shot operators (join, unjoin variants, terminal removal,
path-select, obstruction add/remove) had no viewport surface. This
commit adds GizmoMEPActions — the icon-action gizmo group that
surfaces them as billboarded icons around selected MEP elements.
Three anchor regions: a horizontal row above the bbox top
(selection-cardinality icons), per-port endpoints for the three-state
lock / unjoin icons (open lock for PORT_FREE, closed for
PORT_TERMINAL, unjoin for PORT_JOINED — resolved per-frame from
port_connection_state), and the predicted join location
(compute_mep_join_location, shared with the bend preview) for the
join / unjoin_pair pair. Unjoin icons render at full
DEFAULT_BILLBOARD_SCALE with warning-red hover; endpoint lock icons
shrink so the lock row stays subordinate to the row icons. The
group hides itself entirely while a bend preview is active.

MEPAddObstruction grew a position enum (CURSOR / START / END) and a
mode enum (ADD / REMOVE / TOGGLE) so the gizmo can target a specific
port without touching the cursor and dispatch ADD or REMOVE based on
the click target — the lock_open icons drive ADD with position
pinned, the lock_closed icons drive bim.mep_remove_terminal_fitting.
Without the new fields the gizmo wiring (op_props.position = ...)
crashed at setup() with AttributeError on the obstruction operator.

validate_bend_preconditions extracts the type-match and profile-kind
checks MEPAddBend enforces so EnableBendPreview surfaces the
rejection immediately — the user no longer tunes a preview only to
learn at commit time that the segments use an unsupported profile
(e.g. IfcArbitraryClosedProfileDef).

Generated with the assistance of an AI coding tool.
2026-06-09 12:44:19 +02:00
Gorgious56 0df1f0cf49 Add MEP unjoin / terminal-remove / path-select operators
Four discrete one-shot operators driven by the MEP segment's port
state. mep_unjoin_at_port deletes the IfcFlowFitting bridging a
segment's named port to a second element when the port is in the
JOINED state. mep_remove_terminal_fitting deletes the terminal
fitting at a port (closed-lock state) and dispatches by fitting
type — OBSTRUCTION fittings go through MEPGenerator.remove_obstruction
so the segment absorbs the freed length, other terminal fittings go
through the standard delete path. mep_unjoin_pair finds the single
fitting bridging two selected MEP segments and deletes it.
select_mep_path_members walks the connected MEP network from the
active element via IfcRelConnectsPorts and replaces the selection
with every reachable member. Foundation for the MEP Actions gizmo
group which surfaces these operators as icon affordances around
selected segments.

Generated with the assistance of an AI coding tool.
2026-06-09 12:16:15 +02:00
Gorgious56 d7dd8ecf57 Align extend gizmo arrow with segment axis
The extend icon used a pure screen-space billboard that always
pointed +X across the screen — the arrow ran horizontally
regardless of the pipe / duct's orientation. The new
billboarded_along_axis helper rotates the gizmo about the camera-
forward axis so its local +X aligns with the segment's local +Z
projected onto the screen, keeping the icon camera-facing but
visually following the extrusion direction. The flip-mirror branch
now reads from cursor-vs-current-end along the segment axis (not
screen-X), so the arrow points away from the current endpoint
regardless of viewport orientation. The split icon stacks
perpendicular to the rotated extend arrow in screen space so the
two don't overlap.

The decorator's green preview line no longer clamps the cursor
projection to min_projected_length — it follows the raw projection
so the line stays visible when the cursor crosses behind the
segment origin (the user still sees where they're pointing even
though the operator floors the actual commit).

Generated with the assistance of an AI coding tool.
2026-06-09 11:51:37 +02:00
Gorgious56 becbcfdfe7 Add MEP bend preview decorator + join dispatcher
The bend preview gizmo group (commit 2) populated a Scene draft but
the user saw nothing in the viewport until they hit finish — they
had to commit blindly. This commit ports the BendPreviewDecorator
(centerline arc + two leg projections on valid geometry, warning-red
axes on invalid in-segment intersections) and the interactive
GizmoBendPreview group (three dimension widgets for start_length /
end_length / radius plus validate / cancel icons). The bend axis
math lives in a pure compute_bend_preview_polylines helper, fed
into both the gizmo group's per-frame positioning and the GPU
decorator's draw path. MEPSegmentExtendPreviewDecorator lands at
the same time because it shares the decorator install / uninstall
plumbing — renders the extend-to-cursor preview line for the
GizmoPipeSegmentEdition / GizmoDuctSegmentEdition extend icons
when hovered, clamping the projected endpoint to the operator's
minimum so the preview matches where the commit lands. The
MEPJoinSegments dispatcher routes two selected MEP segments to
mep_add_transition (parallel) or enable_bend_preview (non-parallel)
— the F3 search entry point that makes the bend preview testable
before the gizmo-icon dispatch lands.

11 new tests in test_mep_bend_preview.py cover the geometry helper
truth table (parallel rejection, right-angle happy path, near-
collinear rejection, in-segment invalid_axes), the
_intersection_past_near parametrized boundary, registration probes
for the lifecycle operators / join dispatcher / gizmo group /
decorator, and the FinishBendPreview RuntimeError catch contract.
6 extend-preview-line tests (deferred from commit 3) join the
existing 35 in test_mep_segment_edition.py.

Generated with the assistance of an AI coding tool.
2026-06-08 21:18:18 +02:00
Gorgious56 5b79cefee2 Fix #8138: door/window container assignment no-op
Spatial.get_root_element walks aggregate / nest / filled-void /
voided-element chains and core.assign_container assigns the container
to whatever the walk returns. For an IfcDoor the filled-void hop
redirects to the IfcOpeningElement, then voided-element to the host
wall, so a user who selects a door and runs bim.assign_container ends
up targeting the wall — and silently no-ops on the door if the wall is
already in the target storey.

Per IFC4 / IFC4.3 (IfcDoor, IfcWindow): the spatial containment of a
filling is defined independently of the filling relationship. Major
exporters (Revit, ArchiCAD, Tekla, Allplan) emit independent
ContainedInStructure on doors / windows accordingly. Drop the
filled-void / voided-element hops from the walk; aggregate and nest
remain — those are true sub-part relationships where the parent
legitimately owns the container.

New TestGetRootElement in test/tool pins the new contract (filling
resolves to itself) plus the retained aggregate / nest / loose-element
paths so a future PR that re-adds either hop is caught. Two new
TestAssignContainer cases in test/core pin filling-to-self through the
core layer and per-element can_contain filtering.

Generated with the assistance of an AI coding tool.
2026-06-08 18:53:50 +02:00
Gorgious56 3346a59284 Add MEP pipe / duct segment edit gizmos
Pipe and duct segments had no parametric-edit affordance — the only
length edit path was a property panel value with no live preview.
This commit ports the per-segment parametric edit triad
(enable / finish / cancel) plus a cursor-anchored extend operator
and a cursor-projected split operator into one gizmo group per
segment type. The two PropertyGroups (BIMPipeSegmentProperties,
BIMDuctSegmentProperties) host the draft length plus snap fields
so cancel / no-op-finish restore the segment to its exact pre-edit
visual state including a non-identity pre-edit scale. Length
commits are written through DumbProfileJoiner.set_depth and
auto-dispatch bim.regenerate_distribution_element so adjacent
fittings track the port move. The split operator preserves
downstream port connectivity and runs through tool.Ifc.run for
single-step undo. The two segment types are now first-class
entries in tool.Parametric.EDIT_TYPES, which resolves the FIXME
on auto-commit-on-save dispatch.

35 unit tests cover predicate truth tables, segment_world_length
geometry, preview-via-scale / restore-scale helpers, gizmo class
wiring, lifecycle operator registration, dimension matrix_position
rotation respect, and lifecycle drift-handling. The 6 extend-
preview-line decorator tests stay deferred until the bend preview
decorator commit lands MEPSegmentExtendPreviewDecorator.

Generated with the assistance of an AI coding tool.
2026-06-08 15:01:03 +02:00
Gorgious56 0bf8e9283f Hide parametric gizmos during transform modal
Parametric gizmos (wall/door/window/stair/roof/array/MEP) recompute
matrix_basis every frame from obj.matrix_world. While Blender's
transform modal (G/R/S and the Bonsai macro overrides) drags the
matrix, the gizmos slide off-cursor and fight the transform overlay.

Detect via context.window.modal_operators (Blender 4.2+) — the
collection of running modal operators. Gate poll() (forward-compat)
and draw_prepare() (production path: gizmo.hide=True preserves the
GizmoGroup across the drag instead of destroying it). Cover the
Bonsai macro override for G key (and Shift/Alt/Ctrl+Shift+D) by
matching the BIM_OT_* macro idnames that surface in modal_operators.

Forward-compat test walks every parametric-edit module for GizmoGroup
subclasses and asserts poll returns False with the detector mocked,
so new gizmo groups inherit the hide automatically.

Generated with the assistance of an AI coding tool.
2026-06-08 13:33:08 +02:00
Gorgious56 db7591a867 Add clear_preview_state helper + DRY preview cleanup
Every preview operator (commit + cancel for both bend and wall
fillet) was inlining the same 3-4 line cleanup: set is_active to
False, zero every *_id IntProperty. The new clear_preview_state
helper in preview_base.py introspects bl_rna and applies that
contract generically — adopters become a single call. Two new tests
pin the contract: every *_id IntProperty zeroes, non-id fields stay.

Generated with the assistance of an AI coding tool.
2026-06-08 10:25:59 +02:00
Gorgious56 7b9af9f533 Backport pending-opening-cuts banner from gh8088
Extract the pending_opening_recut tracking, three operators (apply /
dismiss / select), Project-panel banner, and the sibling
multi-instance warning banner (its backend helpers already landed
on this branch) from commit a85ed6032 on gizmos-8088.

All tool.* dependencies (Geometry.reimport_element_representations,
Blender.set_objects_selection, Array.*) and IfcImporter.gross_elements
are already on this branch -- no other diffs from a85ed6032 are
pulled.

The source's narrow except-tuple paraphrase comments are trimmed
to keep only the durable "don't swallow programmer errors" note,
per CLAUDE.md s4a.

Tests: 5 bim-lane tests in test/bim/module/project/
test_pending_opening_cuts.py covering apply happy-path + missing
entity, dismiss, select happy-path + cancellation.

Generated with the assistance of an AI coding tool.
2026-06-08 10:18:37 +02:00
Gorgious56 704a2d36be Add MEP bend preview Scene properties + lifecycle
MEPAddBend exists on the main flow but commits bend geometry with
hardcoded defaults (start_length=0.1, end_length=0.1, radius=0.2)
with no opportunity to tune before commit. The new scene-level
BIMBendPreviewProperties hosts a draft (start_segment_id,
end_segment_id, start_length, end_length, radius); EnableBendPreview
populates it from the two selected MEP segments after asserting they
are non-parallel, FinishBendPreview dispatches MEPAddBend with the
tuned values and clears the draft, CancelBendPreview discards it.
Scene-level placement follows CLAUDE.md 2.9: a bend creates a new
fitting entity between two segments, so neither segment alone owns
the draft. Foundation for the upcoming bend preview gizmo group and
decorator.

Generated with the assistance of an AI coding tool.
2026-06-08 10:17:30 +02:00
Gorgious56 516696cd73 Add partial-state rollback on execute_ifc_operator
When an operator mutated IFC then raised mid-execute the user was left
staring at a raw traceback with the IFC graph captured by the active
transaction but the Blender side stale. Blender does not push an undo
step for a raised operator (the same gap that the CANCELLED-modal arm
patches via bpy.ops.ed.undo_push), so the WARNING the framework can
emit is only honest if it pushes that undo step too. The framework
now detects partial state via ifc_file.transaction.operations,
pushes a Recover undo step, then reports a WARNING naming Ctrl+Z so
the recovery path is discoverable. The bespoke try/except wrapper in
UnjoinWallPathConnection becomes redundant and is retired in the
same change.

Generated with the assistance of an AI coding tool.
2026-06-08 08:45:05 +02:00
Josef Wienerroither 1f102bfd83 Merge branch 'v0.8.0' into ifcmax/initial-refresh 2026-06-06 12:42:45 +02:00
Josef Wienerroither 3700e2fdce Merge branch 'v0.8.0' into ifcmax/initial-refresh 2026-06-05 08:05:26 +02:00
Josef Wienerroither a31d3623bb Merge branch 'v0.8.0' into ifcmax/initial-refresh 2026-05-31 06:44:50 +02:00
Josef Wienerroither f898d19679 Merge branch 'v0.8.0' into ifcmax/initial-refresh 2026-05-19 07:06:22 +02:00
Josef Wienerroither 3a8bc04fc0 Merge branch 'v0.8.0' into ifcmax/initial-refresh 2026-05-05 11:47:06 +02:00
Josef Wienerroither 3377cb7225 Merge branch 'v0.8.0' into ifcmax/initial-refresh 2026-05-02 10:11:34 +02:00
Josef Wienerroither 43d34e6ca3 Merge branch 'v0.8.0' into ifcmax/initial-refresh 2026-05-01 11:21:15 +02:00
Josef Wienerroither d3e7fab7d5 Merge branch 'v0.8.0' into ifcmax/initial-refresh 2026-04-25 11:16:14 +02:00
Josef Wienerroither 6eee66a3cf Merge branch 'v0.8.0' into ifcmax/initial-refresh 2026-04-12 08:22:23 +02:00
Josef Wienerroither 89933d18bc Merge branch 'v0.8.0' into ifcmax/initial-refresh 2026-04-08 10:42:06 +02:00
Josef Wienerroither 8bfb039e36 Merge branch 'v0.8.0' into ifcmax/initial-refresh 2026-04-06 10:24:07 +02:00
Josef Wienerroither cbd7b58bf6 Merge branch 'v0.8.0' into ifcmax/initial-refresh 2026-04-03 16:11:07 +02:00
Josef Wienerroither 01252d9087 Merge branch 'v0.8.0' into ifcmax/initial-refresh 2026-04-01 16:49:01 +02:00
Josef Wienerroither aab8ed4f40 Merge branch 'v0.8.0' into ifcmax/initial-refresh 2026-03-30 12:02:32 +02:00
Josef Wienerroither 059d736e89 Merge branch 'v0.8.0' into ifcmax/initial-refresh 2026-03-22 09:20:00 +01:00
Josef Wienerroither 9c791d4a68 Merge branch 'v0.8.0' into ifcmax/initial-refresh 2026-03-16 07:00:17 +01:00
Josef Wienerroither 1542ab4afc Merge branch 'v0.8.0' into ifcmax/initial-refresh 2026-03-15 10:03:47 +01:00
Josef Wienerroither 43e87090e1 Merge branch 'v0.8.0' into ifcmax/initial-refresh 2026-03-15 10:02:53 +01:00
Josef Wienerroither 225b81c37e Merge branch 'v0.8.0' into ifcmax/initial-refresh 2026-03-14 07:15:11 +01:00
Josef Wienerroither b00714132d Merge branch 'v0.8.0' into ifcmax/initial-refresh 2026-03-10 07:18:24 +01:00
Josef Wienerroither c7cde331e9 Merge branch 'v0.8.0' into ifcmax/initial-refresh 2026-03-09 09:23:03 +01:00
Josef Wienerroither 5aca9ca67e Merge branch 'v0.8.0' into ifcmax/initial-refresh 2026-03-07 08:21:43 +01:00
Josef Wienerroither 997f03fe63 Merge branch 'v0.8.0' into ifcmax/initial-refresh 2026-03-04 09:41:03 +01:00
Josef Wienerroither c5d6d535d2 Merge branch 'v0.8.0' into ifcmax/initial-refresh 2026-03-03 13:35:01 +01:00
Josef Wienerroither eb2e4a44ee Merge branch 'v0.8.0' into ifcmax/initial-refresh 2026-02-27 12:13:08 +01:00
Josef Wienerroither d5e141b773 Merge branch 'v0.8.0' into ifcmax/initial-refresh 2026-02-26 13:24:08 +01:00
Josef Wienerroither 1e8aa0b7b8 Merge branch 'v0.8.0' into ifcmax/initial-refresh 2026-02-20 09:00:52 +01:00
Josef Wienerroither feffe88613 Merge branch 'v0.8.0' into ifcmax/initial-refresh 2026-02-18 10:23:01 +01:00
Josef Wienerroither 967dee0369 Merge branch 'v0.8.0' into ifcmax/initial-refresh 2026-02-15 07:47:47 +01:00
Josef Wienerroither b4e48cf300 Merge branch 'v0.8.0' into ifcmax/initial-refresh 2026-02-13 10:24:14 +01:00
Josef Wienerroither eb842a7f44 Merge branch 'v0.8.0' into ifcmax/initial-refresh 2026-02-11 12:20:14 +01:00
Josef Wienerroither 321f9dc099 Merge branch 'v0.8.0' into ifcmax/initial-refresh 2026-02-11 11:59:12 +01:00
Josef Wienerroither b12e807571 Merge branch 'v0.8.0' into ifcmax/initial-refresh 2026-02-11 10:42:12 +01:00
dependabot[bot] e837a1b5e4 Bump aws-actions/configure-aws-credentials from 5 to 6
Bumps [aws-actions/configure-aws-credentials](https://github.com/aws-actions/configure-aws-credentials) from 5 to 6.
- [Release notes](https://github.com/aws-actions/configure-aws-credentials/releases)
- [Changelog](https://github.com/aws-actions/configure-aws-credentials/blob/main/CHANGELOG.md)
- [Commits](https://github.com/aws-actions/configure-aws-credentials/compare/v5...v6)

---
updated-dependencies:
- dependency-name: aws-actions/configure-aws-credentials
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-10 07:09:00 +01:00
dependabot[bot] 1986f4e3df Bump ruff from 0.14.14 to 0.15.0
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.14.14 to 0.15.0.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.14.14...0.15.0)

---
updated-dependencies:
- dependency-name: ruff
  dependency-version: 0.15.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-10 07:09:00 +01:00
Dion Moult 69d62a66a0 Reimplement sort / reverse / join function to format language, simplify text annotation variables, add tests
Previously, sort, reverse list, and join functionality was implemented
as special cases in Bonsai itself. Given that it has usecases
(especially in material lists, but any sort of list applies) I've moved
this function into the IOS formatting language.

The IOS formatting language previously wasn't capable of this, but the
awesome addition by @falken10vdl made the formatting language accept
queries inline, so that means it can handle lists. I also added tests
for all the new functions and expression syntax (+-*/ operators).

I simplified the code that gets the evaluated text literal - previously
it seems to call format() multiple times.
2026-02-10 07:09:00 +01:00
Josef Wienerroither 95defa5657 build: add 3ds Max 2028 build target 2026-02-08 08:21:23 +01:00
Josef Wienerroither 4559fa8466 Merge branch 'v0.8.0' into ifcmax/initial-refresh 2026-02-07 13:30:19 +01:00
Josef Wienerroither 82a58d4554 Merge branch 'v0.8.0' into ifcmax/initial-refresh 2026-01-27 17:25:44 +01:00
Josef Wienerroither 352f2bf794 import safeguarding 2026-01-27 17:22:01 +01:00
Josef Wienerroither 981d503b9d Merge branch 'v0.8.0' into ifcmax/initial-refresh 2026-01-24 09:58:11 +01:00
Josef Wienerroither 9fbd24ffb4 Merge branch 'v0.8.0' into ifcmax/initial-refresh 2026-01-23 07:24:49 +01:00
Josef Wienerroither 4aedeb71fd Merge branch 'v0.8.0' into ifcmax/initial-refresh 2026-01-17 06:50:15 +01:00
Josef Wienerroither 55c2deea37 ci: add patch for mpir build script to fix failing build when VS2026 is installed 2026-01-16 12:24:24 +01:00
Josef Wienerroither 6f871fb13b Merge branch 'v0.8.0' into ifcmax/initial-refresh 2026-01-16 07:51:51 +01:00
Josef Wienerroither 737097312e Merge branch 'v0.8.0' into ifcmax/initial-refresh 2025-12-31 12:47:47 +01:00
Josef Wienerroither 7f8ce5d44f fix: ensure uppercase tier name in cmakefile 2025-12-31 12:46:15 +01:00
Josef Wienerroither 451a9a74cf chore: add tier name to listener log 2025-12-31 12:45:37 +01:00
Josef Wienerroither 885844925e Merge branch 'v0.8.0' into ifcmax/initial-refresh 2025-12-30 09:42:42 +01:00
Josef Wienerroither 35e05a382d tweak ifcmax cmake file 2025-12-26 17:15:12 +01:00
Josef Wienerroither bf6a71cda0 Merge branch 'v0.8.0' into ifcmax/initial-refresh 2025-12-26 11:47:45 +01:00
Josef Wienerroither 4bc8efd354 Merge branch 'v0.8.0' into ifcmax/initial-refresh
# Conflicts:
#	src/ifcmax/CMakeLists.txt
2025-12-25 08:29:52 +01:00
Josef Wienerroither cf08550d7e Merge branch 'v0.8.0' into ifcmax/initial-refresh 2025-12-21 16:31:19 +01:00
Josef Wienerroither 0a45bda8e4 Merge branch 'v0.8.0' into ifcmax/initial-refresh 2025-12-17 13:01:24 +01:00
Josef Wienerroither c750bd63e9 Merge branch 'v0.8.0' into ifcmax/initial-refresh 2025-12-17 12:49:13 +01:00
Josef Wienerroither 6c4189436d Merge branch 'v0.8.0' into ifcmax/initial-refresh 2025-12-16 08:58:48 +01:00
Josef Wienerroither ae51f08c6d update and fix dependency build script 2025-12-14 15:06:29 +01:00
Josef Wienerroither b0a7273e37 Merge branch 'ifcmax/initial-refresh' into v0.8.0 2025-12-13 09:34:57 +01:00
Josef Wienerroither ba2f48e210 refactor: improve string conversions 2025-12-08 16:12:18 +01:00
Josef Wienerroither 3add345bb7 feat: add ifcmax specific build-deps.cmd 2025-12-08 15:04:18 +01:00
Josef Wienerroither 11daabc3ea feature: add ifcmax free core 2025-12-08 14:45:23 +01:00
Josef Wienerroither 7537435001 chore: add opencascade 7.9.1 patch file 2025-12-08 14:44:50 +01:00
Josef Wienerroither a9af9341b9 chore: update ifcmax cmakelists 2025-12-08 14:43:46 +01:00
533 changed files with 35781 additions and 5089 deletions
+2 -3
View File
@@ -109,7 +109,7 @@ jobs:
# Ensure Bonsai and ifcsverchok enable/disable works before uploading to extensions repo.
# Download Blender.
wget -q -O blender.tar.xz https://download.blender.org/release/Blender5.1/blender-5.1.0-linux-x64.tar.xz
wget -q -O blender.tar.xz https://download.blender.org/release/Blender5.2/blender-5.2.0-linux-x64.tar.xz
tar -xf blender.tar.xz
# Setup Blender.
@@ -179,8 +179,7 @@ jobs:
blender --online-mode --command extension install --enable --sync sun_position
cd IfcOpenShell/src/bonsai
pip install pytest-blender
pip install pytest-bdd
pip install -r requirements-dev.txt
blender --background --python scripts/setup_pytest.py
blender --python-expr "import bonsai; print(bonsai.bbim_semver); import ifcopenshell; print(ifcopenshell.version)" --background
make test
+155
View File
@@ -0,0 +1,155 @@
# This file was generated with the assistance of an AI coding tool.
name: ci-ifcwrap-standalone
on:
workflow_dispatch:
pull_request:
paths:
- ".github/workflows/ci-ifcwrap-standalone.yml"
- "cmake/**"
- "src/ifcwrap/**"
- "src/ifcparse/**"
- "src/ifcgeom/**"
- "src/serializers/**"
- "src/ifcconvert/**"
- "src/ifcopenshell-python/**"
- "src/svgfill/**"
push:
paths:
- ".github/workflows/ci-ifcwrap-standalone.yml"
- "cmake/**"
- "src/ifcwrap/**"
- "src/ifcparse/**"
- "src/ifcgeom/**"
- "src/serializers/**"
- "src/ifcconvert/**"
- "src/ifcopenshell-python/**"
- "src/svgfill/**"
env:
IFCOPENSHELL_PREFIX: ${{ github.workspace }}/ifcopenshell-install
jobs:
build-ifcopenshell:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v6
with:
submodules: recursive
- name: Install C++ dependencies
run: |
sudo apt update
sudo apt-get install --no-install-recommends -y \
cmake \
gcc \
g++ \
libboost-date-time-dev \
libboost-filesystem-dev \
libboost-iostreams-dev \
libboost-program-options-dev \
libboost-regex-dev \
libboost-system-dev \
libboost-thread-dev \
libeigen3-dev \
libocct-data-exchange-dev \
libocct-draw-dev \
libocct-foundation-dev \
libocct-modeling-algorithms-dev \
libocct-modeling-data-dev \
libocct-ocaf-dev \
libocct-visualization-dev \
libpcre3-dev \
libtbb-dev \
libxml2-dev \
libxi-dev \
occt-misc \
tcl-dev \
tk-dev \
swig
- name: Configure minimal IfcOpenShell
run: |
cmake -S cmake -B build-ifcopenshell \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX="${IFCOPENSHELL_PREFIX}" \
-DCMAKE_PREFIX_PATH=/usr \
-DCMAKE_SYSTEM_PREFIX_PATH=/usr \
-DMINIMAL_BUILD=ON \
-DBUILD_IFCPYTHON=OFF \
"-DSCHEMA_VERSIONS=4x3_add2"
- name: Build and install minimal IfcOpenShell
run: |
cmake --build build-ifcopenshell --target install -j "$(nproc)"
- name: Set up Python 3.11
uses: actions/setup-python@v6
with:
python-version: 3.11
- name: Install Python import dependencies
run: |
python -m pip install --upgrade pip
python -m pip install numpy typing_extensions
- name: Configure standalone IfcPython
run: |
PYTHON_EXECUTABLE="$(python -c 'import sys; print(sys.executable)')"
PYTHON_INCLUDE_DIR="$(python -c 'import sysconfig; print(sysconfig.get_path("include"))')"
cmake -S src/ifcwrap -B "build-ifcwrap-311" \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_PREFIX_PATH="${IFCOPENSHELL_PREFIX};/usr" \
-DPython_EXECUTABLE:FILEPATH="${PYTHON_EXECUTABLE}" \
-DPython_INCLUDE_DIR:PATH="${PYTHON_INCLUDE_DIR}" \
-DPYTHON_EXECUTABLE:FILEPATH="${PYTHON_EXECUTABLE}" \
-DPYTHON_INCLUDE_DIR:PATH="${PYTHON_INCLUDE_DIR}"
- name: Build and install standalone IfcPython
run: |
cmake --build "build-ifcwrap-311" --target install -j "$(nproc)"
- name: Import installed IfcPython
run: |
PYTHONPATH="${RUNNER_TEMP}/ifcopenshell-python" python - <<'PY'
import ifcopenshell
print("IfcOpenShell import ok:", ifcopenshell.version)
PY
- name: Set up Python 3.12
uses: actions/setup-python@v6
with:
python-version: 3.12
- name: Install Python import dependencies
run: |
python -m pip install --upgrade pip
python -m pip install numpy typing_extensions
- name: Configure standalone IfcPython
run: |
PYTHON_EXECUTABLE="$(python -c 'import sys; print(sys.executable)')"
PYTHON_INCLUDE_DIR="$(python -c 'import sysconfig; print(sysconfig.get_path("include"))')"
cmake -S src/ifcwrap -B "build-ifcwrap-312" \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_PREFIX_PATH="${IFCOPENSHELL_PREFIX};/usr" \
-DPython_EXECUTABLE:FILEPATH="${PYTHON_EXECUTABLE}" \
-DPython_INCLUDE_DIR:PATH="${PYTHON_INCLUDE_DIR}" \
-DPYTHON_EXECUTABLE:FILEPATH="${PYTHON_EXECUTABLE}" \
-DPYTHON_INCLUDE_DIR:PATH="${PYTHON_INCLUDE_DIR}"
- name: Build and install standalone IfcPython
run: |
cmake --build "build-ifcwrap-312" --target install -j "$(nproc)"
- name: Import installed IfcPython
run: |
PYTHONPATH="${RUNNER_TEMP}/ifcopenshell-python" python - <<'PY'
import ifcopenshell
print("IfcOpenShell import ok:", ifcopenshell.version)
PY
+1 -4
View File
@@ -27,10 +27,7 @@ jobs:
- name: Install dependencies
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
uv tool install ruff
uv tool install black
uv tool install poethepoet
uv tool install ty==0.0.34
cat requirements-tools.txt | xargs -L1 uv tool install
# black doesn't catch all syntax errors, so we check them explicitly.
- name: Check syntax errors
+20 -3
View File
@@ -10,9 +10,13 @@ on:
- 'src/ifcgeomserver/**'
- 'src/ifcjni/**'
- 'src/ifcmax/**'
- 'src/ifc5d/**'
- 'src/ifcedit/**'
- 'src/ifcmcp/**'
- 'src/ifcopenshell-python/**'
- '!src/ifcopenshell-python/docs/**'
- 'src/ifcparse/**'
- 'src/ifcquery/**'
- 'src/ifcwrap/**'
- 'src/qtviewer/**'
- 'src/svgfill/**'
@@ -51,7 +55,7 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install xmlschema xsdata numpy lxml pytest isodate lark networkx tabulate python-dateutil shapely pyparsing
pip install xmlschema xsdata numpy lxml pytest isodate lark networkx tabulate python-dateutil shapely pyparsing psutil
pip install src/bcf --no-deps
pip install pytest-xdist==3.8.0
@@ -252,13 +256,26 @@ jobs:
pip install deepdiff
cd ../ifcdiff && make test || ERROR=1
cd ../ifcpatch && make test || ERROR=1
pip install -e ../ifc5d --no-deps
pip install odfpy xlsxwriter
cd ../ifc5d && make test || ERROR=1
pip install -e ../ifcquery --no-deps
cd ../ifcquery && make test || ERROR=1
pip install -e ../ifcedit --no-deps
cd ../ifcedit && make test || ERROR=1
pip install mcp
pip install -e ../ifcmcp --no-deps
cd ../ifcmcp && make test || ERROR=1
pip install -e ../ifctester --no-deps
cd ../ifctester && make test || ERROR=1
make build-ids-docs || ERROR=1
# Run mathutils related tests at the end to ensure no other code is relying on mathutils.
# mathutils only has pre-built wheels for Python 3.13+; skip on older versions.
cd ../ifcopenshell-python
pip install mathutils
make test-mathutils || ERROR=1
if python -c "import sys; sys.exit(0 if sys.version_info >= (3, 13) else 1)"; then
pip install mathutils
make test-mathutils || ERROR=1
fi
if [ $ERROR -ne 0 ]; then
echo "One or more tests failed";
exit 1;
+6
View File
@@ -4,6 +4,8 @@
/_deps-vs*-x*-installed/
/_installed-vs*-x*/
/build/
/build.log
/output/
/src/examples/build/
# ifctester docs output
/src/ifctester/test/build/
@@ -22,12 +24,14 @@
__pycache__
*.py.bak
venv
uv.lock
# Visual Studio Code files
.vscode
!.vscode/launch.json
!.vscode/tasks.json
.vs
/*.code-workspace
# PyCharm files
.idea
@@ -126,5 +130,7 @@ src/ifcopenshell-python/ifcopenshell/express/*.exp.cache.dat
# temp files from AI coding tools
*.claude
CLAUDE.local.md
*.py.tmp*
*.json.tmp*
+21 -11
View File
@@ -27,13 +27,14 @@ endif()
set(CMAKE_CXX_STANDARD_REQUIRED ON) # not necessary, but encouraged
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
if(VERSION_OVERRIDE)
file(READ "../VERSION" "RELEASE_VERSION_")
string(STRIP "${RELEASE_VERSION_}" RELEASE_VERSION)
message(STATUS "Detected version '${RELEASE_VERSION}'")
else()
set(RELEASE_VERSION "0.8.0")
endif()
# The VERSION file in the repository root is the single source of truth for the
# release version. Read it unconditionally so a plain source build reports the
# real version through buildinfo.cpp instead of the stale hardcoded 0.8.0
# fallback (see #8164). VERSION_OVERRIDE still controls the branch name used
# when ADD_COMMIT_SHA embeds a commit sha.
file(READ "../VERSION" "RELEASE_VERSION_")
string(STRIP "${RELEASE_VERSION_}" RELEASE_VERSION)
message(STATUS "Detected version '${RELEASE_VERSION}'")
add_definitions(-D_DISABLE_CONSTEXPR_MUTEX_CONSTRUCTOR)
@@ -313,8 +314,12 @@ 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
@@ -558,8 +563,8 @@ if(COMPILE_SCHEMA)
# Bootstrap the parser
message(STATUS "Compiling schema, this will take a while...")
execute_process(
COMMAND ${PYTHON_EXECUTABLE} bootstrap.py express.bnf
WORKING_DIRECTORY ../src/ifcexpressparser
COMMAND ${PYTHON_EXECUTABLE} bootstrap.py
WORKING_DIRECTORY ../src/ifcopenshell-python/ifcopenshell/express
OUTPUT_FILE express_parser.py
RESULT_VARIABLE SUCCESS
)
@@ -570,7 +575,7 @@ if(COMPILE_SCHEMA)
# Generate code
execute_process(
COMMAND ${PYTHON_EXECUTABLE} ../ifcexpressparser/express_parser.py ../../${COMPILE_SCHEMA}
COMMAND ${PYTHON_EXECUTABLE} ../ifcopenshell-python/ifcopenshell/express/express_parser.py ../../${COMPILE_SCHEMA}
WORKING_DIRECTORY ../src/ifcparse
OUTPUT_VARIABLE COMPILED_SCHEMA_NAME
)
@@ -660,6 +665,11 @@ if(ADD_COMMIT_SHA)
endif()
endif(ADD_COMMIT_SHA)
# Always expose the release version (from the VERSION file) to buildinfo.cpp so
# that a build without commit-sha info reports the correct version instead of a
# stale hardcoded fallback. See #8164.
target_compile_definitions(IfcParse PRIVATE IFCOPENSHELL_VERSION_STRING=${RELEASE_VERSION})
if(MSVC)
# @todo still needs to be understood better, but the cgal and cgal-simple kernel cause multiply defined boost lambda placeholders _1 ... _3
set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} /FORCE:MULTIPLE")
+131
View File
@@ -0,0 +1,131 @@
# 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("${CMAKE_CURRENT_LIST_DIR}/utilities.cmake" OPTIONAL)
set(_IfcOpenShell_find_args)
if(IfcOpenShell_FIND_VERSION)
list(APPEND _IfcOpenShell_find_args "${IfcOpenShell_FIND_VERSION}")
if(IfcOpenShell_FIND_VERSION_EXACT)
list(APPEND _IfcOpenShell_find_args EXACT)
endif()
endif()
list(APPEND _IfcOpenShell_find_args CONFIG QUIET)
if(IfcOpenShell_FIND_COMPONENTS)
list(APPEND _IfcOpenShell_find_args COMPONENTS ${IfcOpenShell_FIND_COMPONENTS})
endif()
set(_IfcOpenShell_saved_module_path "${CMAKE_MODULE_PATH}")
list(REMOVE_ITEM CMAKE_MODULE_PATH "${CMAKE_CURRENT_LIST_DIR}")
find_package(IfcOpenShell ${_IfcOpenShell_find_args})
set(CMAKE_MODULE_PATH "${_IfcOpenShell_saved_module_path}")
if(NOT IfcOpenShell_FOUND)
set(_IfcOpenShell_error "Could not find an IfcOpenShell CMake config package. Set IfcOpenShell_DIR or CMAKE_PREFIX_PATH.")
if(IfcOpenShell_FIND_REQUIRED)
message(FATAL_ERROR "${_IfcOpenShell_error}")
elseif(NOT IfcOpenShell_FIND_QUIETLY)
message(STATUS "${_IfcOpenShell_error}")
endif()
return()
endif()
set(_IfcOpenShell_required_targets IfcOpenShell::IfcParse IfcOpenShell::IfcGeom)
set(_IfcOpenShell_missing_targets "")
foreach(_IfcOpenShell_target IN LISTS _IfcOpenShell_required_targets)
if(NOT TARGET ${_IfcOpenShell_target})
list(APPEND _IfcOpenShell_missing_targets ${_IfcOpenShell_target})
endif()
endforeach()
if(_IfcOpenShell_missing_targets)
set(IfcOpenShell_FOUND FALSE)
string(REPLACE ";" ", " _IfcOpenShell_missing_targets_text "${_IfcOpenShell_missing_targets}")
set(_IfcOpenShell_error "IfcOpenShell config was found, but required targets are missing: ${_IfcOpenShell_missing_targets_text}.")
if(IfcOpenShell_FIND_REQUIRED)
message(FATAL_ERROR "${_IfcOpenShell_error}")
elseif(NOT IfcOpenShell_FIND_QUIETLY)
message(STATUS "${_IfcOpenShell_error}")
endif()
return()
endif()
if(NOT DEFINED IFCOPENSHELL_WITH_OPENCASCADE)
set(IFCOPENSHELL_WITH_OPENCASCADE OFF)
if(TARGET IfcOpenShell::geometry_kernel_opencascade)
set(IFCOPENSHELL_WITH_OPENCASCADE ON)
endif()
endif()
if(NOT DEFINED IFCOPENSHELL_WITH_CGAL)
set(IFCOPENSHELL_WITH_CGAL OFF)
if(TARGET IfcOpenShell::IFCOPENSHELL_CGAL)
set(IFCOPENSHELL_WITH_CGAL ON)
endif()
endif()
if(NOT DEFINED IFCOPENSHELL_IFCXML)
set(IFCOPENSHELL_IFCXML OFF)
endif()
if(NOT DEFINED IFCOPENSHELL_WITH_ROCKSDB)
set(IFCOPENSHELL_WITH_ROCKSDB OFF)
endif()
set(IFCOPENSHELL_LIBRARIES IfcOpenShell::IfcParse)
foreach(_IfcOpenShell_target IN ITEMS IfcOpenShell::geometry_serializer IfcOpenShell::Serializers)
if(TARGET ${_IfcOpenShell_target})
list(APPEND IFCOPENSHELL_LIBRARIES ${_IfcOpenShell_target})
endif()
endforeach()
set(IFCOPENSHELL_KERNEL_LIBRARIES "")
foreach(_IfcOpenShell_target IN ITEMS
IfcOpenShell::geometry_kernel_opencascade
IfcOpenShell::geometry_kernel_cgal
IfcOpenShell::geometry_kernel_cgal_simple
)
if(TARGET ${_IfcOpenShell_target})
list(APPEND IFCOPENSHELL_KERNEL_LIBRARIES ${_IfcOpenShell_target})
endif()
endforeach()
set(IFCOPENSHELL_GEOMETRY_LIBRARIES IfcOpenShell::IfcGeom ${IFCOPENSHELL_KERNEL_LIBRARIES})
if(TARGET IfcOpenShell::OpenCASCADE_INTERFACE)
set(OpenCASCADE_LIBRARIES IfcOpenShell::OpenCASCADE_INTERFACE)
endif()
if(TARGET IfcOpenShell::IFCOPENSHELL_CGAL)
set(CGAL_LIBRARIES IfcOpenShell::IFCOPENSHELL_CGAL)
endif()
if(TARGET IfcOpenShell::svgfill)
set(IFCOPENSHELL_SVGFILL_LIBRARY IfcOpenShell::svgfill)
endif()
mark_as_advanced(IfcOpenShell_DIR)
unset(_IfcOpenShell_error)
unset(_IfcOpenShell_find_args)
unset(_IfcOpenShell_missing_targets)
unset(_IfcOpenShell_missing_targets_text)
unset(_IfcOpenShell_required_targets)
unset(_IfcOpenShell_target)
+38 -4
View File
@@ -7,12 +7,26 @@ set(IFCOPENSHELL_WITH_OPENCASCADE @WITH_OPENCASCADE@)
set(IFCOPENSHELL_WITH_CGAL @WITH_CGAL@)
set(IFCOPENSHELL_IFCXML @IFCXML_SUPPORT@)
set(IFCOPENSHELL_WITH_ROCKSDB @WITH_ROCKSDB@)
set(IFCOPENSHELL_COLLADA_SUPPORT @COLLADA_SUPPORT@)
set(IFCOPENSHELL_GLTF_SUPPORT @GLTF_SUPPORT@)
set(IFCOPENSHELL_HDF5_SUPPORT @HDF5_SUPPORT@)
set(IFCOPENSHELL_WITH_PROJ @WITH_PROJ@)
set(IFCOPENSHELL_USD_SUPPORT @USD_SUPPORT@)
include(CMakeFindDependencyMacro)
set(Boost_USE_STATIC_LIBS ON)
set(Boost_USE_STATIC_RUNTIME OFF)
set(Boost_USE_MULTITHREADED ON)
set(IFCOPENSHELL_BOOST_USE_STATIC_LIBS "@Boost_USE_STATIC_LIBS@")
set(IFCOPENSHELL_BOOST_USE_STATIC_RUNTIME "@Boost_USE_STATIC_RUNTIME@")
set(IFCOPENSHELL_BOOST_USE_MULTITHREADED "@Boost_USE_MULTITHREADED@")
if(NOT "${IFCOPENSHELL_BOOST_USE_STATIC_LIBS}" STREQUAL "")
set(Boost_USE_STATIC_LIBS ${IFCOPENSHELL_BOOST_USE_STATIC_LIBS})
endif()
if(NOT "${IFCOPENSHELL_BOOST_USE_STATIC_RUNTIME}" STREQUAL "")
set(Boost_USE_STATIC_RUNTIME ${IFCOPENSHELL_BOOST_USE_STATIC_RUNTIME})
endif()
if(NOT "${IFCOPENSHELL_BOOST_USE_MULTITHREADED}" STREQUAL "")
set(Boost_USE_MULTITHREADED ${IFCOPENSHELL_BOOST_USE_MULTITHREADED})
endif()
set(Boost_COMPONENTS
system
program_options
@@ -43,13 +57,33 @@ if(IFCOPENSHELL_WITH_ROCKSDB)
endif()
if(IFCOPENSHELL_IFCXML)
find_dependency(LibXml2 CONFIG)
find_dependency(LibXml2)
endif()
if(IFCOPENSHELL_WITH_CGAL)
find_dependency(CGAL CONFIG)
endif()
if(IFCOPENSHELL_COLLADA_SUPPORT)
find_dependency(OpenCOLLADA)
endif()
if(IFCOPENSHELL_GLTF_SUPPORT)
find_dependency(nlohmann_json CONFIG)
endif()
if(IFCOPENSHELL_HDF5_SUPPORT)
find_dependency(HDF5 COMPONENTS C CXX)
endif()
if(IFCOPENSHELL_WITH_PROJ)
find_dependency(PROJ)
endif()
if(IFCOPENSHELL_USD_SUPPORT)
find_dependency(USD)
endif()
if(IFCOPENSHELL_WITH_OPENCASCADE)
find_dependency(OpenCASCADE CONFIG)
if(OpenCASCADE_VERSION VERSION_LESS "7.7.0")
+3
View File
@@ -0,0 +1,3 @@
.env
*.pyc
__pycache__
+3
View File
@@ -0,0 +1,3 @@
.env
*.pyc
__pycache__
+21
View File
@@ -0,0 +1,21 @@
#!/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
@@ -0,0 +1,67 @@
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
@@ -0,0 +1,78 @@
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
@@ -0,0 +1,186 @@
---
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
@@ -0,0 +1,15 @@
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
@@ -0,0 +1,339 @@
#!/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
+15 -5
View File
@@ -50,7 +50,7 @@ Used environment variables:
- ``NO_CLEAN`` - do not clean `ifcopenshell` build directories but continue working on current build
(installed dependencies are never cleared).
By default option is disabled, to enable pass any value from `1`, `on`, `true`.
- ``IFCOS_SCHEMAS`` - schemas to be built; defaults to cmake default (IFC2X3; IFC4; IFC4X3_ADD2) - to be supplied as `2x3;4`
- ``IFCOS_SCHEMAS`` - schemas to be built; defaults to cmake default (8 schemas), to be supplied as `2x3;4;4x3_add2`
- ``USE_OCCT`` - whether to use official Open CASCADE instead of Community Edition
(`true` by default, any other value is considered `false`)
- ``WASM_PYTHON_PATH`` - path to WASM Python installation,
@@ -155,7 +155,7 @@ MPFR_VERSION = "3.1.6" # latest is 4.1.0
CGAL_VERSION = "v5.6.3"
USD_VERSION = "23.05"
TBB_VERSION = "2021.9.0"
ROCKSDB_VERSION = "9.11.2"
ROCKSDB_VERSION = "10.4.2"
ZSTD_VERSION = "1.5.7"
# binaries
cp = "cp"
@@ -627,9 +627,10 @@ def build_dependency(
build_tool_args: "list[str]",
download_url: str,
download_name: str,
*,
download_tool: Literal["py", "git"] = download_tool_default,
revision: "Union[str, None]" = None,
patch: "Union[str, list[str], None]" = None,
patch: list[str] | None = None,
shell=None,
pre_compile_subs: "Sequence[tuple[str, str, str]]" = (),
additional_files: "Union[dict[str, str], None]" = None,
@@ -714,8 +715,6 @@ def build_dependency(
urlretrieve(url, os.path.join(extract_dir, path))
if patch is not None:
if isinstance(patch, str):
patch = [patch]
for p in patch:
patch_abs = (SCRIPT_PATH / p).absolute().__str__()
if os.path.exists(patch_abs):
@@ -724,6 +723,8 @@ def build_dependency(
except Exception as e:
# Assert that the patch has already been applied
run(["patch", "-p1", "--batch", "--reverse", "--dry-run", "-i", patch_abs], cwd=extract_dir)
else:
raise FileNotFoundError(patch_abs)
if shell is not None:
sp.run(shell, shell=True, check=True, cwd=extract_dir)
@@ -1171,6 +1172,14 @@ if "cgal" in targets:
os.environ["CC"] = MAC_CROSS_COMPILE_INTEL_CC
gmp_args.extend(MAC_CROSS_COMPILE_INTEL_AUTOCONF_HOST_ARGS)
# Fixes configure failing to find a working compiler under GCC 15's default -std=gnu23.
# Issue presumably will be resolved in any next gmp version, but currently the last one is 6.3.0.
# Patch is just applying fix from upstream meantion below:
# https://gmplib.org/list-archives/gmp-bugs/2025-February/005561.html
gmp_patches = ["./patches/gmp/001-fix-std23.patch"]
if GMP_VERSION != "6.3.0":
raise Exception(f"GMP_VERSION changed to {GMP_VERSION}, check whether {gmp_patches} is still needed.")
build_dependency(
name=f"gmp-{GMP_VERSION}",
mode="autoconf",
@@ -1178,6 +1187,7 @@ if "cgal" in targets:
pre_compile_subs=(
[("build/config.h", "HAVE_OBSTACK_VPRINTF 1", "HAVE_OBSTACK_VPRINTF 0")] if "wasm" in flags else []
),
patch=gmp_patches,
# Sometimes ftp.gnu.org is very slow, use ftpmirror.gnu.org as a workaround.
download_url="https://ftpmirror.gnu.org/gnu/gmp/",
download_name=f"gmp-{GMP_VERSION}.tar.bz2",
+27
View File
@@ -0,0 +1,27 @@
Fixes configure failing to find a working compiler under GCC 15's default
-std=gnu23 (upstream fix: https://gmplib.org/repo/gmp/rev/8e7bb4ae7a18).
Upstream fix is patching `acinclude.m4`, but since in the release tarball
all macros are already expanded to `configure` script, so we're patching
all occurrences of that macro.
--- a/configure
+++ b/configure
@@ -6568,7 +6568,7 @@
#if defined (__GNUC__) && ! defined (__cplusplus)
typedef unsigned long long t1;typedef t1*t2;
-void g(){}
+void g(int,t1 const*,t1,t2,t1 const*,int){}
void h(){}
static __inline__ t1 e(t2 rp,t2 up,int n,t1 v0)
{t1 c,x,r;int i;if(v0){c=1;for(i=1;i<n;i++){x=up[i];r=x+1;rp[i]=r;}}return c;}
@@ -8187,7 +8187,7 @@
#if defined (__GNUC__) && ! defined (__cplusplus)
typedef unsigned long long t1;typedef t1*t2;
-void g(){}
+void g(int,t1 const*,t1,t2,t1 const*,int){}
void h(){}
static __inline__ t1 e(t2 rp,t2 up,int n,t1 v0)
{t1 c,x,r;int i;if(v0){c=1;for(i=1;i<n;i++){x=up[i];r=x+1;rp[i]=r;}}return c;}
-32
View File
@@ -1,32 +0,0 @@
http://git.dev.opencascade.org/gitweb/?p=occt.git;a=commitdiff;h=0ab4e621833f4eae945a3762c9a29ee12e2eec53#patch1
diff --git a/src/HLRBRep/HLRBRep_InternalAlgo.cxx b/src/HLRBRep/HLRBRep_InternalAlgo.cxx
index ca885ca..c13cb06 100644 (file)
--- a/src/HLRBRep/HLRBRep_InternalAlgo.cxx
+++ b/src/HLRBRep/HLRBRep_InternalAlgo.cxx
@@ -165,7 +165,7 @@ void HLRBRep_InternalAlgo::Update ()
SB.Bounds(v1,v2,e1,e2,f1,f2);
for (Standard_Integer e = e1; e <= e2; e++) {
- HLRBRep_EdgeData ed = aEDataArray.ChangeValue(e);
+ HLRBRep_EdgeData& ed = aEDataArray.ChangeValue(e);
HLRAlgo::DecodeMinMax(ed.MinMax(), TheMin, TheMax);
if (FirstTime) {
FirstTime = Standard_False;
@@ -307,7 +307,7 @@ void HLRBRep_InternalAlgo::InitEdgeStatus ()
Standard_Integer nf = myDS->NbFaces();
for (Standard_Integer e = 1; e <= ne; e++) {
- HLRBRep_EdgeData ed = aEDataArray.ChangeValue(e);
+ HLRBRep_EdgeData& ed = aEDataArray.ChangeValue(e);
if (ed.Selected()) ed.Status().ShowAll();
}
// for (Standard_Integer f = 1; f <= nf; f++) {
@@ -368,7 +368,7 @@ void HLRBRep_InternalAlgo::Select ()
Standard_Integer nf = myDS->NbFaces();
for (Standard_Integer e = 1; e <= ne; e++) {
- HLRBRep_EdgeData ed = aEDataArray.ChangeValue(e);
+ HLRBRep_EdgeData& ed = aEDataArray.ChangeValue(e);
ed.Selected(Standard_True);
}
-22
View File
@@ -1,22 +0,0 @@
From a0deb4ce8b43cf3c8b8c0a4225c6be5296446dbd Mon Sep 17 00:00:00 2001
From: Adam Eri <adam.eri@blackmirror.media>
Date: Tue, 3 Sep 2019 23:30:20 +0200
Subject: [PATCH] Resolves compile error on macOS
Resolves "no member named 'isnan' in namespace 'std'" on macOS
---
GeneratedSaxParser/src/GeneratedSaxParserUtils.cpp | 1 +
1 file changed, 1 insertion(+)
diff --git a/GeneratedSaxParser/src/GeneratedSaxParserUtils.cpp b/GeneratedSaxParser/src/GeneratedSaxParserUtils.cpp
index 1f9a3eef..dd6f5c59 100644
--- a/GeneratedSaxParser/src/GeneratedSaxParserUtils.cpp
+++ b/GeneratedSaxParser/src/GeneratedSaxParserUtils.cpp
@@ -10,6 +10,7 @@
#include "GeneratedSaxParserUtils.h"
#include <math.h>
+#include <cmath>
#include <memory>
#include <string.h>
#include <limits>
+42 -86
View File
@@ -1,13 +1,8 @@
[project]
name = "IfcOpenShell"
version = "0.0.0"
dependencies = [
"black==26.3.1",
"ruff==0.15.12",
"poethepoet",
"ty==0.0.32",
"gersemi==0.26.1",
]
# Don't provide requires-python explicitly
# allowing pyprojects to set their own (e.g. bonsai and general ifcopenshell version differ).
[tool.black]
line-length = 120
@@ -43,6 +38,7 @@ exclude = [
# then they will be inherited by projects' .toml files.
# This allows using assuming different Python version for different projects.
[tool.ruff]
line-length = 120
exclude = [
# Submodules.
"src/ifcopenshell-python/ifcopenshell/express",
@@ -83,92 +79,39 @@ ignore = [
]
[tool.ty.rules]
all = "ignore"
all = "error"
# Structural rules (no deep type inference needed, easier to adapt).
abstract-method-in-final-class = "error"
ambiguous-protocol-member = "error"
conflicting-declarations = "error"
conflicting-metaclass = "error"
cyclic-class-definition = "error"
cyclic-type-alias-definition = "error"
dataclass-field-order = "error"
duplicate-base = "error"
duplicate-kw-only = "error"
empty-body = "error"
escape-character-in-forward-annotation = "error"
final-on-non-method = "error"
final-without-value = "error"
ignore-comment-unknown-rule = "error"
implicit-concatenated-string-type-annotation = "error"
inconsistent-mro = "error"
ineffective-final = "error"
instance-layout-conflict = "error"
invalid-dataclass = "error"
invalid-dataclass-override = "error"
invalid-enum-member-annotation = "error"
invalid-explicit-override = "error"
invalid-frozen-dataclass-subclass = "error"
invalid-generic-class = "error"
invalid-generic-enum = "error"
invalid-ignore-comment = "error"
invalid-legacy-positional-parameter = "error"
invalid-legacy-type-variable = "error"
invalid-named-tuple = "error"
invalid-newtype = "error"
invalid-overload = "error"
invalid-paramspec = "error"
invalid-protocol = "error"
invalid-syntax-in-forward-annotation = "error"
invalid-total-ordering = "error"
invalid-type-alias-type = "error"
invalid-type-checking-constant = "error"
invalid-type-guard-definition = "error"
invalid-type-variable-bound = "error"
invalid-type-variable-constraints = "error"
invalid-typed-dict-header = "error"
invalid-typed-dict-statement = "error"
override-of-final-method = "error"
override-of-final-variable = "error"
possibly-missing-import = "error"
possibly-missing-submodule = "error"
# Has false positives due to ty walrus operator bug.
# possibly-unresolved-reference = "error"
raw-string-type-annotation = "error"
redundant-final-classvar = "error"
shadowed-type-variable = "error"
subclass-of-final-class = "error"
super-call-in-named-tuple-method = "error"
unavailable-implicit-super-arguments = "error"
unbound-type-variable = "error"
undefined-reveal = "error"
unresolved-global = "error"
unresolved-import = "error"
unresolved-reference = "error"
unused-ignore-comment = "error"
unused-type-ignore-comment = "error"
useless-overload-body = "error"
possibly-unresolved-reference = "ignore"
# Maybe later, requires to specify element types for all generics.
missing-type-argument = "ignore"
# Conflicts with `bpy` props defined using annotations.
invalid-type-form = "ignore"
# Non-structural rules:
deprecated = "error"
zero-stepsize-in-slice = "error"
possibly-missing-implicit-call = "error"
unused-awaitable = "error"
# Function argument rules:
# Conflicts with `ifcopenshell.api.geometry.add_representation` type of callables we have, confusing them with a module.
# call-non-callable = "error"
conflicting-argument-forms = "error"
call-non-callable = "ignore"
# bpy is missing some context manager implementations.
invalid-context-manager = "ignore"
# Doesn't go well with `bpy.ops.xxx.yyy`.
unresolved-attribute = "ignore"
# Too many false positives.
# invalid-argument-type = "error"
missing-argument = "error"
parameter-already-assigned = "error"
positional-only-parameter-as-kwarg = "error"
too-many-positional-arguments = "error"
unknown-argument = "error"
# Has a lot of warnings due to current ty walrus operator issues.
# index-out-of-bounds = "error"
# unresolved-attribute = "error"
invalid-argument-type = "ignore"
invalid-method-override = "ignore"
invalid-assignment = "ignore"
invalid-parameter-default = "ignore"
missing-override-decorator = "ignore"
invalid-yield = "ignore"
invalid-return-type = "ignore"
non-callable-init-subclass = "ignore"
not-iterable = "ignore"
possibly-missing-attribute = "ignore"
no-matching-overload = "ignore"
not-subscriptable = "ignore"
unsupported-dynamic-base = "ignore"
unsupported-operator = "ignore"
type-assertion-failure = "ignore"
[tool.ty.environment]
extra-paths = [
@@ -215,6 +158,18 @@ exclude = [
[tool.poe.tasks]
dev-setup.sequence = [
{cmd = "uv sync"},
{cmd = "uv pip install -e ./src/bsdd/"},
{cmd = "uv pip install -e ./src/ifcopenshell-python/[advanced,dev]"},
{cmd = "uv pip install -e ./src/ifcedit/"},
{cmd = "uv pip install -e ./src/ifcpatch/"},
{cmd = "uv pip install -e ./src/ifcquery/"},
{cmd = "uv pip install -e './src/ifcmcp/[mcp]'"},
{cmd = "uv pip install -r src/bonsai/requirements-dev.txt"},
]
dev-setup.help = "Install repo packages in editable mode"
ruff = "ruff check"
black = "black ."
@@ -243,6 +198,7 @@ cmake-format = "gersemi . --in-place"
# --ignore unresolved-reference: walrus operator false positives in ty.
cmd = """
ty check
nix/
src/bcf
src/bsdd
src/ifc2ca
+5
View File
@@ -0,0 +1,5 @@
black==26.3.1
ruff==0.15.12
poethepoet
ty==0.0.59
gersemi==0.26.1
+1 -1
View File
@@ -106,7 +106,7 @@ endif
endif # def PLATFORM
# Current build commit hash.
OLD:=1c5b825
OLD:=3e7b739
.PHONY: bump
bump:
ifndef NEW
+1
View File
@@ -90,6 +90,7 @@ modules = {
"web": None,
"light": None,
"alignment": None,
"clip_box": None,
# Uncomment this line to enable loading of the demo module. Happy hacking!
# The name "demo" must correlate to a folder name in `bim/module/`.
# "demo": None,
@@ -5,7 +5,7 @@ FILE_NAME('EPset_Drawing.ifc','2020-01-01T00:00:00',$,$,'EPset_Drawing','EPset_D
FILE_SCHEMA(('IFC4'));
ENDSEC;
DATA;
#1=IFCPROPERTYSETTEMPLATE('2JhNIvqZrFnAgxfhK0XVQX',$,'EPset_Drawing','',.PSET_OCCURRENCEDRIVEN.,'IfcAnnotation/DRAWING',(#23,#22,#27,#24,#19,#12,#26,#9,#8,#7,#6,#4,#18,#11,#5,#20,#25,#14,#10,#17,#28,#16,#3,#21,#13,#15,#2));
#1=IFCPROPERTYSETTEMPLATE('2JhNIvqZrFnAgxfhK0XVQX',$,'EPset_Drawing','',.PSET_OCCURRENCEDRIVEN.,'IfcAnnotation/DRAWING',(#23,#22,#27,#24,#29,#30,#19,#12,#26,#9,#8,#7,#6,#4,#18,#11,#5,#20,#25,#14,#10,#17,#28,#16,#3,#21,#13,#15,#2));
#2=IFCSIMPLEPROPERTYTEMPLATE('23JavTMk98ZxXhrUEnjAcf',$,'TargetView','',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
#3=IFCSIMPLEPROPERTYTEMPLATE('1yVWUt5H9DAOuu0OaMMLpe',$,'Scale','The scale of this drawing represented as a numerator and denominator, such as 1/100',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
#4=IFCSIMPLEPROPERTYTEMPLATE('3gsuPBtU93b8f0gg1pjkq6',$,'HumanScale','The scale of this drawing in human readable format, such as 1:100',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
@@ -33,5 +33,7 @@ DATA;
#26=IFCSIMPLEPROPERTYTEMPLATE('2iwERDOW55Pf4hCbuFRe1Q',$,'FillMode','Method to fill areas seen in projection',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
#27=IFCSIMPLEPROPERTYTEMPLATE('1YF$qLzBzF19Io8aB2N8cE',$,'CutMode','Method for cutting geometry',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
#28=IFCSIMPLEPROPERTYTEMPLATE('1YSnFzurrEyRNtoLdmmddP',$,'BringToFront','The objects with these SVG classes will render in front of all other objects.Ex: IfcBeam, IfcColumn',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.);
#29=IFCSIMPLEPROPERTYTEMPLATE('0lP6Y8q9v2QhDnR4sT7uVx',$,'PerspectiveShiftX','Horizontal perspective camera shift stored as drawing metadata using Blender camera shift units.',.P_SINGLEVALUE.,'IfcReal',$,$,$,$,$,.READWRITE.);
#30=IFCSIMPLEPROPERTYTEMPLATE('2mR8b1NcW5EoFyG7hJ9kLp',$,'PerspectiveShiftY','Vertical perspective camera shift stored as drawing metadata using Blender camera shift units.',.P_SINGLEVALUE.,'IfcReal',$,$,$,$,$,.READWRITE.);
ENDSEC;
END-ISO-10303-21;
+24
View File
@@ -47,10 +47,15 @@ from bonsai.bim.module.model.array import (
)
from bonsai.bim.module.model.data import AuthoringData
from bonsai.bim.module.model.decorator import (
BendPreviewDecorator,
BoundingBoxDecorator,
DoorSwingReadonlyDecorator,
MEPSegmentExtendPreviewDecorator,
MEPSystemPathDecorator,
SlabDirectionDecorator,
WallAxisDecorator,
WallFilletPreviewDecorator,
WallSystemPathDecorator,
)
from bonsai.bim.module.model.wall import WallGizmoPreviewDecorator
from bonsai.bim.module.nest.decorator import NestDecorator
@@ -315,9 +320,11 @@ 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
@@ -510,8 +517,13 @@ def _install_viewport_overlays() -> None:
NestDecorator.uninstall()
WallAxisDecorator.uninstall()
SlabDirectionDecorator.uninstall()
MEPSystemPathDecorator.uninstall()
WallSystemPathDecorator.uninstall()
WallFilletPreviewDecorator.uninstall()
BendPreviewDecorator.uninstall()
MEPSegmentExtendPreviewDecorator.uninstall()
WallGizmoPreviewDecorator.uninstall()
DoorSwingReadonlyDecorator.uninstall()
ArrayPreviewDecorator.uninstall()
ArraySelectionHighlightDecorator.uninstall()
uninstall_decorator_cache_handlers()
@@ -526,16 +538,28 @@ def _install_viewport_overlays() -> None:
WallAxisDecorator.install(bpy.context)
if model_props.show_slab_direction:
SlabDirectionDecorator.install(bpy.context)
if model_props.show_paths:
MEPSystemPathDecorator.install(bpy.context)
WallSystemPathDecorator.install(bpy.context)
if model_props.show_bounding_box:
BoundingBoxDecorator.install(bpy.context)
# Always-installed: draw() self-polls on Scene.BIMPreviewProperties.
# wall_fillet.is_active, so installation has no cost when no preview
# is open. No corresponding addon-preference toggle.
WallFilletPreviewDecorator.install(bpy.context)
# Always-installed siblings of WallFilletPreviewDecorator: each
# self-polls on its own scene.BIMPreviewProperties subgroup or on
# selection + hover gizmo state — zero cost when nothing is active.
BendPreviewDecorator.install(bpy.context)
MEPSegmentExtendPreviewDecorator.install(bpy.context)
# Always-installed: draw_lines() self-polls on selection + hover state
# for join / extend-to-wall / cursor-extend / cursor-split previews.
# Free when no preview-eligible state is active.
WallGizmoPreviewDecorator.install(bpy.context)
# Always-installed: draw() self-polls on active object + IfcDoor +
# parametric pset, so the cost is one bpy/IFC lookup per redraw when
# nothing eligible is selected.
DoorSwingReadonlyDecorator.install(bpy.context)
# Always-installed: draw() self-polls on the active object's array
# family membership, so installation has no cost when no array
# element is selected.
+14 -1
View File
@@ -46,7 +46,7 @@ IFC_CONNECTED_TYPE = Union[bpy.types.Material, bpy.types.Object]
class OperationData(TypedDict):
id: int
guid: NotRequired[str]
obj: str
obj: NotRequired[str]
class EditObjectOperationData(TypedDict):
@@ -566,6 +566,19 @@ class IfcStore:
result = getattr(operator, "_modal")(context, event)
except:
bonsai.last_error = traceback.format_exc()
# An operator that mutated IFC then raised leaves the IFC graph captured
# by the transaction but the Blender side stale. Blender does not push an
# undo step for a raised operator (mirror of the CANCELLED-modal gap
# handled below), so we push one here so Ctrl+Z actually rewinds the
# partial mutation, then surface the recovery path to the user.
ifc_file = tool.Ifc.get()
if ifc_file and ifc_file.transaction and ifc_file.transaction.operations:
bpy.ops.ed.undo_push(message=f"Recover {operator.bl_idname}")
operator.report(
{"WARNING"},
"Operation partially completed (IFC changed, Blender state may be stale). "
"Press Ctrl+Z to restore the previous state.",
)
# Try to ensure undo will work since Blender undo does work in case of errors.
# As error come unexpectedly, it's important that user might have a chance to save the file
# before they got the error and not to lose the work they've done.
+18 -2
View File
@@ -223,6 +223,7 @@ class IfcImporter:
self.elements: set[ifcopenshell.entity_instance] = set()
self.annotations: set[ifcopenshell.entity_instance] = set()
self.gross_elements: set[ifcopenshell.entity_instance] = set()
self.broken_arrays: set[ifcopenshell.entity_instance] = set()
self.element_types: set[ifcopenshell.entity_instance] = set()
self.spatial_elements: set[ifcopenshell.entity_instance] = set()
self.meshes: dict[str, OBJECT_DATA_TYPE] = {}
@@ -979,8 +980,13 @@ class IfcImporter:
if unit.Name == "METRE":
if not unit.Prefix:
bpy.context.scene.unit_settings.length_unit = "METERS"
else:
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"
else:
bpy.context.scene.unit_settings.system = "IMPERIAL"
name = unit.Name.lower()
@@ -1220,7 +1226,17 @@ class IfcImporter:
continue
for i in range(len(data)):
tool.Array.set_children_lock_state(element, i, True)
tool.Array.constrain_children_to_parent(element)
tool.Array.constrain_children_to_parent(element)
for layer in data:
for child_guid in layer.get("children", ()):
try:
self.file.by_guid(child_guid)
except RuntimeError:
print(
f"setup_arrays: array parent {element.GlobalId} references missing "
f"child GUID {child_guid!r}."
)
self.broken_arrays.add(element)
def update_linked_aggregates(self):
# TODO Remove this after a while. See commit 17d6b8a
@@ -20,7 +20,6 @@ import blf
import bpy
import gpu
import ifcopenshell.util.element
from bpy.types import SpaceView3D
from bpy_extras import view3d_utils
from gpu_extras.batch import batch_for_shader
from mathutils import Vector
@@ -28,12 +27,6 @@ from mathutils import Vector
import bonsai.tool as tool
def transparent_color(color, alpha=0.1):
color = [i for i in color]
color[3] = alpha
return color
def create_bounding_box(objs):
# Initialize the bounding box coordinates
min_x, min_y, min_z = float("inf"), float("inf"), float("inf")
@@ -79,26 +72,8 @@ def create_bounding_box(objs):
return indices, edges
class AggregateDecorator:
is_installed = False
handlers = []
@classmethod
def install(cls, context):
if cls.is_installed:
cls.uninstall()
handler = cls()
cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_aggregate, (context,), "WINDOW", "POST_VIEW"))
cls.is_installed = True
@classmethod
def uninstall(cls):
for handler in cls.handlers:
try:
SpaceView3D.draw_handler_remove(handler, "WINDOW")
except ValueError:
pass
cls.is_installed = False
class AggregateDecorator(tool.Blender.ViewportDecorator):
draw_method = "draw_aggregate"
def dotted_line_shader(self):
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface")
@@ -153,14 +128,6 @@ class AggregateDecorator:
shader.uniform_float("u_Scale", 25)
batch.draw(shader)
def draw_batch(self, shader_type, content_pos, color, indices=None):
if not tool.Blender.validate_shader_batch_data(content_pos, indices):
return
shader = self.line_shader if shader_type == "LINES" else self.shader
batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices)
shader.uniform_float("color", color)
batch.draw(shader)
def draw_aggregate(self, context):
props = tool.Aggregate.get_aggregate_props()
self.addon_prefs = tool.Blender.get_addon_preferences()
@@ -191,12 +158,13 @@ class AggregateDecorator:
aggregates.append(obj)
continue
aggregate = None
aggregates_list = tool.Aggregate.get_aggregates_recursively(element)
if props.in_aggregate_mode and props.editing_aggregate:
index = aggregates_list.index(tool.Ifc.get_entity(props.editing_aggregate))
if index > 0:
aggregate = aggregates_list[index - 1]
else:
elif aggregates_list:
aggregate = aggregates_list[-1]
if aggregate:
aggregates.append(tool.Ifc.get_object(aggregate))
@@ -225,39 +193,11 @@ class AggregateDecorator:
self.draw_custom_batch(line, decorator_color_unselected)
class AggregateModeDecorator:
is_installed = False
handlers = []
@classmethod
def install(cls, context):
if cls.is_installed:
cls.uninstall()
handler = cls()
cls.handlers.append(
SpaceView3D.draw_handler_add(handler.draw_aggregate_name, (context,), "WINDOW", "POST_PIXEL")
)
cls.handlers.append(
SpaceView3D.draw_handler_add(handler.draw_aggregate_empty, (context,), "WINDOW", "POST_VIEW")
)
cls.is_installed = True
@classmethod
def uninstall(cls):
for handler in cls.handlers:
try:
SpaceView3D.draw_handler_remove(handler, "WINDOW")
except ValueError:
pass
cls.is_installed = False
def draw_batch(self, shader_type, content_pos, color, indices=None):
if not tool.Blender.validate_shader_batch_data(content_pos, indices):
return
shader = self.line_shader if shader_type == "LINES" else self.shader
batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices)
shader.uniform_float("color", color)
batch.draw(shader)
class AggregateModeDecorator(tool.Blender.ViewportDecorator):
draw_methods = (
("draw_aggregate_name", "POST_PIXEL"),
("draw_aggregate_empty", "POST_VIEW"),
)
def draw_aggregate_name(self, context):
if context.mode == "EDIT_MESH":
@@ -56,11 +56,6 @@ class BoundaryDecorator:
unselected_elements_color = self.addon_prefs.decorator_color_unselected
special_elements_color = self.addon_prefs.decorator_color_special
def transparent_color(color, alpha=0.1):
color = [i for i in color]
color[3] = alpha
return color
gpu.state.point_size_set(6)
gpu.state.blend_set("ALPHA")
@@ -109,7 +104,11 @@ class BoundaryDecorator:
if unselected_edges:
self.draw_batch("LINES", unselected_vertices, special_elements_color, unselected_edges)
self.draw_batch("TRIS", unselected_vertices, transparent_color(special_elements_color), unselected_tris)
self.draw_batch(
"TRIS", unselected_vertices, tool.Blender.transparent_color(special_elements_color), unselected_tris
)
if selected_edges:
self.draw_batch("LINES", selected_vertices, selected_elements_color, selected_edges)
self.draw_batch("TRIS", selected_vertices, transparent_color(selected_elements_color), selected_tris)
self.draw_batch(
"TRIS", selected_vertices, tool.Blender.transparent_color(selected_elements_color), selected_tris
)
+3 -6
View File
@@ -156,16 +156,13 @@ class BrickschemaReferencesData:
for rel in getattr(tool.Ifc.get_entity(bpy.context.active_object), "HasAssociations", []):
if rel.is_a("IfcRelAssociatesLibrary"):
reference = rel.RelatingLibrary
if tool.Ifc.get_schema() == "IFC2X3" and "#" not in reference.ItemReference:
continue
if tool.Ifc.get_schema() != "IFC2X3" and "#" not in reference.Identification:
identification = tool.Document.get_external_reference_id(reference)
if not identification or "#" not in identification:
continue
results.append(
{
"id": reference.id(),
"identification": (
reference.ItemReference if tool.Ifc.get_schema() == "IFC2X3" else reference.Identification
),
"identification": identification,
"name": reference.Name or "Unnamed",
}
)
+26 -5
View File
@@ -345,9 +345,17 @@ class CadArcFrom3Points(bpy.types.Operator):
class CadOffset(bpy.types.Operator):
bl_idname = "bim.cad_offset"
bl_label = "CAD Offset"
bl_description = "Copy selected mesh geometry at provided offset. Mesh copied based on the current viewport angle."
bl_description = (
"Offset selected mesh geometry at provided distance, based on the current viewport angle. "
"Creates a copy by default, or moves the existing edges if Copy is disabled."
)
bl_options = {"REGISTER", "UNDO"}
distance: bpy.props.FloatProperty(name="Distance", default=0.1, subtype="DISTANCE")
copy: bpy.props.BoolProperty(
name="Copy",
description="Create a new offset copy of the geometry. If disabled, move the existing edges to the offset location",
default=True,
)
@classmethod
def poll(cls, context):
@@ -405,6 +413,11 @@ class CadOffset(bpy.types.Operator):
rotation = Matrix.Rotation(pi / 2, 2, "Z")
rotation_i = Matrix.Rotation(-pi / 2, 2, "Z")
# When not copying, the offset positions are gathered here and applied to
# the existing verts only after all loops are processed, so that the
# original coordinates are still available while computing offsets.
moved_verts = []
# Create loops from edges
loop_edges = set(edges)
loops = []
@@ -517,12 +530,15 @@ class CadOffset(bpy.types.Operator):
offset_length = self.distance / sqrt((1 + normals[0].dot(normals[1])) / 2)
offset = mw.inverted().to_quaternion() @ (wp.to_quaternion() @ (new_normal * offset_length).to_3d())
new_vert = v1.co + offset
new_verts.append(bm.verts.new(new_vert))
else:
normal = (normals[0] * self.distance).to_3d()
offset = mw.inverted().to_quaternion() @ (wp.to_quaternion() @ normal)
new_vert = v1.co + offset
if self.copy:
new_verts.append(bm.verts.new(new_vert))
else:
moved_verts.append((v1, new_vert))
processed_verts.add(v1.index)
@@ -531,9 +547,14 @@ class CadOffset(bpy.types.Operator):
v1 = v2
[bm.edges.new((new_verts[i], new_verts[i + 1])) for i in range(len(new_verts) - 1)]
if is_closed:
bm.edges.new((new_verts[len(new_verts) - 1], new_verts[0]))
if self.copy:
[bm.edges.new((new_verts[i], new_verts[i + 1])) for i in range(len(new_verts) - 1)]
if is_closed:
bm.edges.new((new_verts[len(new_verts) - 1], new_verts[0]))
# Move the existing edges to the offset location.
for vert, new_co in moved_verts:
vert.co = new_co
bm.verts.index_update()
bm.edges.index_update()
+6
View File
@@ -27,6 +27,11 @@ class BIMCadProperties(PropertyGroup):
resolution: bpy.props.IntProperty(name="Arc Resolution", min=1, default=1)
radius: bpy.props.FloatProperty(name="Radius", default=0.1, subtype="DISTANCE")
distance: bpy.props.FloatProperty(name="Distance", default=0.1, subtype="DISTANCE")
copy: bpy.props.BoolProperty(
name="Copy",
description="Create a new offset copy of the geometry. If disabled, move the existing edges to the offset location",
default=True,
)
x: bpy.props.FloatProperty(name="X", default=0.2, subtype="DISTANCE")
y: bpy.props.FloatProperty(name="Y", default=0.1, subtype="DISTANCE")
gable_roof_edge_angle: bpy.props.FloatProperty(
@@ -37,6 +42,7 @@ class BIMCadProperties(PropertyGroup):
resolution: int
radius: float
distance: float
copy: bool
x: float
y: float
gable_roof_edge_angle: float
@@ -256,6 +256,8 @@ class CadHotkey(bpy.types.Operator):
elif self.hotkey == "S_O":
row = self.layout.row()
row.prop(props, "distance")
row = self.layout.row()
row.prop(props, "copy")
elif self.hotkey == "S_R":
if tool.Geometry.is_profile_object_active():
@@ -291,7 +293,7 @@ class CadHotkey(bpy.types.Operator):
bpy.ops.bim.cad_fillet(resolution=self.props.resolution, radius=self.props.radius)
def hotkey_S_O(self):
bpy.ops.bim.cad_offset(distance=self.props.distance)
bpy.ops.bim.cad_offset(distance=self.props.distance, copy=self.props.copy)
def hotkey_S_Q(self):
obj = bpy.context.active_object
@@ -18,43 +18,17 @@
import blf
import gpu
from bpy.types import SpaceView3D
from bpy_extras.view3d_utils import location_3d_to_region_2d
from gpu_extras.batch import batch_for_shader
from mathutils import Vector
import bonsai.tool as tool
class ClashDecorator:
is_installed = False
handlers = []
@classmethod
def install(cls, context):
if cls.is_installed:
cls.uninstall()
handler = cls()
cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_text, (context,), "WINDOW", "POST_PIXEL"))
cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_geometry, (context,), "WINDOW", "POST_VIEW"))
cls.is_installed = True
@classmethod
def uninstall(cls):
for handler in cls.handlers:
try:
SpaceView3D.draw_handler_remove(handler, "WINDOW")
except ValueError:
pass
cls.is_installed = False
def draw_batch(self, shader_type, content_pos, color, indices=None):
if not tool.Blender.validate_shader_batch_data(content_pos, indices):
return
shader = self.line_shader if shader_type == "LINES" else self.shader
batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices)
shader.uniform_float("color", color)
batch.draw(shader)
class ClashDecorator(tool.Blender.ViewportDecorator):
draw_methods = (
("draw_text", "POST_PIXEL"),
("draw_geometry", "POST_VIEW"),
)
def draw_text(self, context):
self.addon_prefs = tool.Blender.get_addon_preferences()
@@ -0,0 +1,130 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# 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 bpy
from bpy.app.handlers import persistent
import bonsai.tool as tool
from . import face_quad, gizmos, operator, prop, ui
classes = (
operator.BIM_OT_add_clip_box,
operator.BIM_OT_add_clip_box_for_source,
operator.BIM_OT_align_view_to_clip_face,
operator.BIM_OT_duplicate_clip_box,
operator.BIM_OT_remove_clip_box,
operator.BIM_OT_set_active_clip_box,
operator.BIM_OT_toggle_clip_box_enabled,
prop.BIMClipBoxProperties,
prop.BIMSceneClipBoxProperties,
face_quad.BIM_GT_box_face_quad,
face_quad.BIM_GT_box_face_outline,
gizmos.OBJECT_GGT_bim_clip_box,
ui.BIM_MT_clip_box_add_for_source,
ui.BIM_MT_clip_box_info,
ui.BIM_MT_clip_box_settings,
ui.BIM_UL_clip_box,
ui.BIM_PT_clip_box,
)
@persistent
def _on_depsgraph_update(scene, depsgraph):
tool.ClipBox.on_depsgraph_update(scene, depsgraph)
tool.ClipBox.on_depsgraph_update_caps(scene, depsgraph)
@persistent
def _on_load_pre(filepath):
# Tear down any in-flight clip-box timers before Blender frees the
# WM / screens / areas / regions for the loading file. A refresh timer
# that survives the teardown fires against the new file's freshly-
# allocated regions before their GPU state is wired, CTD-ing inside
# GPU_matrix_ortho_set. The gate also blocks the depsgraph IFC-reload
# branch and is held closed until on_pre_view fires for the first time
# on the new file (first paint = GPU contexts wired).
tool.ClipBox._file_loading = True
tool.ClipBox._post_load_paint_pending = True
tool.ClipBox._cancel_pending_refresh()
tool.ClipBox._cancel_pending_cap_rebuild()
@persistent
def _on_load_post(filepath):
# The _file_loading gate is NOT cleared here: load_post fires before
# the new file's first paint, so GPU contexts may still be uninitialised.
# on_pre_view consumes _post_load_paint_pending to open the gate at the
# safe moment and kick the post-load re-arm.
# Restore the per-scene clip-box list from the project's BBIM_ClipBoxes
# pset. Runs after the standard load_post that creates Blender objects.
tool.ClipBox._last_seen_object_matrices.clear()
tool.ClipBox.load_from_project_pset()
_draw_handler_pre = None
_draw_handler_post = None
def register():
global _draw_handler_pre, _draw_handler_post
bpy.types.Object.BIMClipBoxProperties = bpy.props.PointerProperty(type=prop.BIMClipBoxProperties)
bpy.types.Scene.BIMSceneClipBoxProperties = bpy.props.PointerProperty(type=prop.BIMSceneClipBoxProperties)
tool.ClipBox.reset_ownership()
if _on_depsgraph_update not in bpy.app.handlers.depsgraph_update_post:
bpy.app.handlers.depsgraph_update_post.append(_on_depsgraph_update)
if _on_load_pre not in bpy.app.handlers.load_pre:
bpy.app.handlers.load_pre.append(_on_load_pre)
if _on_load_post not in bpy.app.handlers.load_post:
bpy.app.handlers.load_post.append(_on_load_post)
if _draw_handler_pre is None:
_draw_handler_pre = bpy.types.SpaceView3D.draw_handler_add(tool.ClipBox.on_pre_view, (), "WINDOW", "PRE_VIEW")
if _draw_handler_post is None:
_draw_handler_post = bpy.types.SpaceView3D.draw_handler_add(
tool.ClipBox.on_post_view_caps, (), "WINDOW", "POST_VIEW"
)
def unregister():
global _draw_handler_pre, _draw_handler_post
if _draw_handler_post is not None:
try:
bpy.types.SpaceView3D.draw_handler_remove(_draw_handler_post, "WINDOW")
except ValueError:
pass
_draw_handler_post = None
if _draw_handler_pre is not None:
try:
bpy.types.SpaceView3D.draw_handler_remove(_draw_handler_pre, "WINDOW")
except ValueError:
pass
_draw_handler_pre = None
if _on_load_post in bpy.app.handlers.load_post:
bpy.app.handlers.load_post.remove(_on_load_post)
if _on_load_pre in bpy.app.handlers.load_pre:
bpy.app.handlers.load_pre.remove(_on_load_pre)
if _on_depsgraph_update in bpy.app.handlers.depsgraph_update_post:
bpy.app.handlers.depsgraph_update_post.remove(_on_depsgraph_update)
tool.ClipBox._cancel_pending_refresh()
tool.ClipBox._cancel_pending_cap_rebuild()
tool.ClipBox._last_seen_object_matrices.clear()
tool.ClipBox.clear_clip_planes()
del bpy.types.Object.BIMClipBoxProperties
del bpy.types.Scene.BIMSceneClipBoxProperties
@@ -0,0 +1,212 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# 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.
"""EnumProperty ``items=`` callbacks for the source-based clip-box picker.
Each callback returns ``[(id_str, label, description)]`` where ``id_str`` is
an IFC entity id stringified for entity-driven kinds, an IFC class name for
``CLASS``, or a fixed status name for ``STATUS``. The clip-box operator
turns the picked id into a ``matrix_world`` via the source-preset helper.
"""
from __future__ import annotations
import bonsai.tool as tool
EnumItems = list[tuple[str, str, str]]
# Module-level cache. Blender's EnumProperty stores raw char pointers from the
# tuples a callback returns, so the Python strings must outlive the draw call.
# Stashing the latest result per kind keeps them alive across callback firings.
_items_cache: dict[str, EnumItems] = {}
# Sentinel id used for the "no options available" placeholder. The operator
# treats this as an invalid pick and surfaces an ERROR.
NO_OPTIONS_ID = "__none__"
def _cache(kind: str, items: EnumItems) -> EnumItems:
_items_cache[kind] = items
return items
def _no_options(label: str) -> EnumItems:
# Blender refuses to draw an EnumProperty with zero entries — show a
# placeholder so the dialog renders and the user sees the empty state.
return [(NO_OPTIONS_ID, label, "")]
def _label(entity, ifc_class: str | None = None) -> str:
name = (getattr(entity, "Name", None) or "Unnamed").strip() or "Unnamed"
return f"{ifc_class}: {name}" if ifc_class else name
def _build_items(kind: str, empty_label: str, build_fn) -> EnumItems:
"""Shared shape for the IFC-driven enum callbacks.
Returns the no-IFC placeholder if no file is loaded, then runs
``build_fn(ifc_file)``, sorts the result alphabetically by label, and
returns the empty-result placeholder if nothing matched. The output is
always routed through the module cache.
"""
ifc = tool.Ifc.get()
if ifc is None:
return _cache(kind, _no_options("No IFC loaded"))
items = build_fn(ifc)
items.sort(key=lambda t: t[1].lower())
if not items:
return _cache(kind, _no_options(empty_label))
return _cache(kind, items)
# Top-down spatial hierarchy so the picker reads in the order an architect
# already thinks in, rather than a flat alphabetical mix. IfcSpace is excluded
# — spaces are typically empty volumes used for room metadata, so clipping to
# one rarely matches the user intent of "show me what's in this container".
SPATIAL_CLASSES: tuple[str, ...] = (
"IfcProject",
"IfcSite",
"IfcBuilding",
"IfcBuildingStorey",
)
def spatial_items(self, context) -> EnumItems:
# Special-case: per-class sort within the hierarchy order rather than a
# flat alphabetical sort, so the dropdown reads project → site → building.
ifc = tool.Ifc.get()
if ifc is None:
return _cache("SPATIAL", _no_options("No IFC loaded"))
items: EnumItems = []
for ifc_class in SPATIAL_CLASSES:
try:
entities = ifc.by_type(ifc_class, include_subtypes=False)
except RuntimeError:
continue
for entity in sorted(entities, key=lambda e: (e.Name or "").lower()):
items.append((str(entity.id()), _label(entity, ifc_class), ""))
if not items:
return _cache("SPATIAL", _no_options("No spatial containers"))
return _cache("SPATIAL", items)
def class_items(self, context) -> EnumItems:
# Special-case: the picker value IS the IFC class name, not an entity id,
# so the build shape differs from the other entity-driven callbacks.
ifc = tool.Ifc.get()
if ifc is None:
return _cache("CLASS", _no_options("No IFC loaded"))
# List only IFC classes ACTUALLY present in the file (not the whole
# schema), so the user picks from classes that can produce a non-empty
# clip volume. ``e.is_a()`` returns the most specific class per element.
present = sorted({e.is_a() for e in ifc.by_type("IfcProduct")})
if not present:
return _cache("CLASS", _no_options("No products"))
return _cache("CLASS", [(cls, cls, "") for cls in present])
def type_items(self, context) -> EnumItems:
return _build_items(
"TYPE",
"No types defined",
lambda ifc: [(str(e.id()), _label(e, e.is_a()), "") for e in ifc.by_type("IfcTypeProduct")],
)
def material_items(self, context) -> EnumItems:
return _build_items(
"MATERIAL",
"No materials defined",
lambda ifc: [(str(e.id()), _label(e), "") for e in ifc.by_type("IfcMaterial")],
)
def profile_items(self, context) -> EnumItems:
# ProfileName is optional. Skip unnamed profiles — they can't be
# meaningfully picked from a flat list.
return _build_items(
"PROFILE",
"No named profiles",
lambda ifc: [
(str(e.id()), f"{e.is_a()}: {e.ProfileName}", "")
for e in ifc.by_type("IfcProfileDef")
if getattr(e, "ProfileName", None)
],
)
def drawing_items(self, context) -> EnumItems:
return _build_items(
"DRAWING",
"No drawings defined",
lambda ifc: [(str(e.id()), _label(e), "") for e in ifc.by_type("IfcAnnotation") if e.ObjectType == "DRAWING"],
)
# Display labels for each status value. The id strings on the left are the
# canonical Pset_*Common.Status enum values accepted by Bonsai's status query.
STATUS_LABELS: tuple[tuple[str, str], ...] = (
("No Status", "No Status"),
("NEW", "New"),
("EXISTING", "Existing"),
("DEMOLISH", "Demolish"),
("TEMPORARY", "Temporary"),
("OTHER", "Other"),
("NOTKNOWN", "Not Known"),
("UNSET", "Unset"),
)
def status_items(self, context) -> EnumItems:
# Fixed enum; no IFC needed. Still routed through the cache to share the
# same string-lifetime guarantee as the other callbacks.
return _cache("STATUS", [(value, label, "") for value, label in STATUS_LABELS])
def system_items(self, context) -> EnumItems:
# IfcStructuralAnalysisModel is a structural-grouping container, not a
# distribution system — excluded to match Bonsai's other system pickers.
return _build_items(
"SYSTEM",
"No systems defined",
lambda ifc: [
(str(e.id()), _label(e, e.is_a()), "")
for e in ifc.by_type("IfcSystem")
if not e.is_a("IfcStructuralAnalysisModel")
],
)
def group_items(self, context) -> EnumItems:
# include_subtypes=False so IfcSystem and IfcZone instances don't appear
# under Group as well — those get their own picker entries.
return _build_items(
"GROUP",
"No groups defined",
lambda ifc: [(str(e.id()), _label(e), "") for e in ifc.by_type("IfcGroup", include_subtypes=False)],
)
def zone_items(self, context) -> EnumItems:
return _build_items(
"ZONE",
"No zones defined",
lambda ifc: [(str(e.id()), _label(e), "") for e in ifc.by_type("IfcZone")],
)
@@ -0,0 +1,879 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# 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.
"""Generic face-quad resize gizmos for any axis-aligned local box.
This module contains the box-agnostic core of the interactive
face-resize gizmos: two Gizmo classes (a near-invisible click target
welded to each face, and a thin colored edge outline), a per-redraw
orchestrator that places six of each on a box, and the pure one-sided
resize arithmetic. None of it knows about IFC, clip boxes, or
``BIMSceneClipBoxProperties`` — a future camera-view-box adapter can
reuse the same classes and helpers.
Consumer contract — the adapter group must:
1. Create six ``BIM_GT_box_face_quad`` and six ``BIM_GT_box_face_outline``
instances at ``setup()`` time, in :data:`FACE_ROUTES` order, and bind
each quad's ``move_get_cb`` / ``move_set_cb`` to closures that read
and mutate the box's host (e.g. an Empty's ``location`` / ``scale``).
2. Call :func:`apply_face_quad_layout` from ``refresh()`` /
``draw_prepare()`` with the box's local-frame ``bmin`` / ``bmax``,
the host's ``matrix_world``, the OBB rotation as a 4x4
(``Matrix.Identity(4)`` when the rotation rides in ``matrix_world``),
and the current ``region`` / ``rv3d``.
3. Implement ``_lock_for(active_gz)`` / ``_unlock_all()`` on the group
for drag mutual exclusion; the quad's ``invoke`` / ``exit`` call them.
The resize arithmetic in :func:`compute_face_resize` is pure: feed it
the modal scalar plus drag-start snapshots and it returns the host's
new scale-on-axis and new origin location.
"""
from __future__ import annotations
import math
from collections.abc import Sequence
from typing import Any
import bpy
from bpy_extras.view3d_utils import location_3d_to_region_2d, region_2d_to_location_3d
from mathutils import Matrix, Vector
# ---------------------------------------------------------------------------
# Public iteration order
# ---------------------------------------------------------------------------
# (axis, is_max) pairs. The adapter group's ``setup()`` MUST create its
# six face-quad gizmos in this order so positional indexing into the
# layout helper stays correct.
FACE_ROUTES: tuple[tuple[int, bool], ...] = (
(0, False),
(0, True),
(1, False),
(1, True),
(2, False),
(2, True),
)
# ---------------------------------------------------------------------------
# Public visual constants (adapter reads these in setup())
# ---------------------------------------------------------------------------
# Standard XYZ axis colors (Blender convention).
AXIS_COLOR: dict[int, tuple[float, float, float]] = {
0: (1.0, 0.2, 0.2),
1: (0.2, 1.0, 0.2),
2: (0.2, 0.4, 1.0),
}
# Documented "selectable but unpainted" trick: the GPU still writes the
# selection buffer at this alpha so clicks register, but no visible
# pixels are produced.
FACE_QUAD_ALPHA: float = 0.001
# Very faint hover tint — just enough to confirm "you're aiming at this
# face" without painting visibly over geometry behind it.
FACE_QUAD_ALPHA_HIGHLIGHT: float = 0.04
# Setup-time default for ``select_bias``; the layout helper overwrites
# it per frame to the front-facing or halo value below. Kept below the
# canonical arrow bias so a bailed frame can't let a front quad steal
# clicks meant for a hidden control.
FACE_QUAD_SELECT_BIAS: float = 0.5
# ---------------------------------------------------------------------------
# Internal constants
# ---------------------------------------------------------------------------
# Unit quad in the local XY plane spanning [-0.5, 0.5]^2 at z=0. Two
# CCW triangles viewed from +Z. matrix_basis stretches it onto the
# face's perpendicular extents.
_QUAD_TRIS: list[tuple[float, float, float]] = [
(-0.5, -0.5, 0.0),
(0.5, -0.5, 0.0),
(0.5, 0.5, 0.0),
(-0.5, -0.5, 0.0),
(0.5, 0.5, 0.0),
(-0.5, 0.5, 0.0),
]
# Unit-quad outline as 4 line segments in the local XY plane at z=0.
_QUAD_OUTLINE_LINES: list[tuple[float, float, float]] = [
(-0.5, -0.5, 0.0),
(0.5, -0.5, 0.0),
(0.5, -0.5, 0.0),
(0.5, 0.5, 0.0),
(0.5, 0.5, 0.0),
(-0.5, 0.5, 0.0),
(-0.5, 0.5, 0.0),
(-0.5, -0.5, 0.0),
]
# Degenerate zero-area triangle for hidden back-facing quads with no
# visible-adjacent neighbours (rare orientation). Blender tolerates
# this; the gizmo is hidden anyway so nothing renders.
_EMPTY_TRIS: list[tuple[float, float, float]] = [
(0.0, 0.0, 0.0),
(0.0, 0.0, 0.0),
(0.0, 0.0, 0.0),
]
# Rotates the gizmo's local +Z onto the outward face normal in the
# box's local frame. Right-hand rotation around the named axis.
_AXIS_ORIENT: dict[tuple[int, bool], Matrix] = {
(0, False): Matrix.Rotation(-math.pi / 2, 4, "Y"),
(0, True): Matrix.Rotation(math.pi / 2, 4, "Y"),
(1, False): Matrix.Rotation(math.pi / 2, 4, "X"),
(1, True): Matrix.Rotation(-math.pi / 2, 4, "X"),
(2, False): Matrix.Rotation(math.pi, 4, "X"),
(2, True): Matrix.Identity(4),
}
# Per-face mapping from face-quad local axes to local box axes for the
# perpendicular-extent scale. ``(w_axis, h_axis)`` — the box-local axis
# indices the quad's local X and Y span after the orientation rotation.
_QUAD_PERP_AXES: dict[tuple[int, bool], tuple[int, int]] = {
(0, False): (2, 1),
(0, True): (2, 1),
(1, False): (0, 2),
(1, True): (0, 2),
(2, False): (0, 1),
(2, True): (0, 1),
}
# Front-facing quad sits ABOVE the halo strips so the cursor on the
# visible face area always grabs the visible face, never accidentally
# routes to a back-face halo strip in an adjacent screen region.
_FACE_QUAD_FRONT_FACING_SELECT_BIAS: float = 1.5
_FACE_QUAD_HALO_FRAME_SELECT_BIAS: float = 1.0
# Target halo-strip thickness in screen pixels. The world-space margin
# is recomputed per frame so the rim stays a roughly constant on-screen
# size regardless of viewport zoom.
_FACE_QUAD_HALO_TARGET_PIXELS: float = 20.0
# Minimum world half-extent a face resize may shrink to. Stops a drag
# from collapsing the host to zero or negative scale.
_MIN_HALF_EXTENT: float = 1e-4
# ---------------------------------------------------------------------------
# Pure predicates (testable without Blender)
# ---------------------------------------------------------------------------
Vec3 = tuple[float, float, float]
def face_outward_axis_local(axis: int, is_max: bool) -> Vec3:
"""Un-rotated outward face normal in the box's local AABB coords.
For ``(axis=0, is_max=True)`` returns ``(+1, 0, 0)``; for the X
face ``(-1, 0, 0)``; etc. The rotated world normal is obtained by
applying the host's rotation and the OBB rotation:
``mw_rot @ cage_rotation @ this``.
"""
sign = 1.0 if is_max else -1.0
out = [0.0, 0.0, 0.0]
out[axis] = sign
return (out[0], out[1], out[2])
def front_facing_face_mask(
face_normals_world: Sequence[Vec3],
view_dir_world: Vec3,
eps: float = 1e-6,
) -> tuple[bool, ...]:
"""Which of the 6 box faces point toward the camera.
A face is front-facing iff its outward normal points AGAINST the
view direction (``dot(normal, view_dir) < -eps``). The ``-eps``
margin prevents flicker at grazing angles.
``face_normals_world`` must be in :data:`FACE_ROUTES` order; returns
a 6-tuple of bool parallel to that order.
"""
if len(face_normals_world) != 6:
msg = f"expected 6 face normals, got {len(face_normals_world)}"
raise ValueError(msg)
vx, vy, vz = view_dir_world
return tuple((n[0] * vx + n[1] * vy + n[2] * vz) < -eps for n in face_normals_world)
def view_axis_parallel_face_mask(
face_normals_world: Sequence[Vec3],
view_dir_world: Vec3,
threshold: float = 0.95,
) -> tuple[bool, ...]:
"""Which faces have normals (anti-)parallel to the view direction.
True iff ``abs(dot(normal, view_dir)) >= threshold`` — i.e. the
face is nearly perpendicular to the screen plane. Provided as a
pure predicate for callers that want to detect degenerate-drag
conditions; the layout helper itself no longer gates on it.
"""
if len(face_normals_world) != 6:
msg = f"expected 6 face normals, got {len(face_normals_world)}"
raise ValueError(msg)
vx, vy, vz = view_dir_world
return tuple(abs(n[0] * vx + n[1] * vy + n[2] * vz) >= threshold for n in face_normals_world)
# ---------------------------------------------------------------------------
# Pure resize arithmetic
# ---------------------------------------------------------------------------
def compute_face_resize(
*,
value: float,
init_world_half: float,
init_location: tuple[float, float, float],
world_axis: tuple[float, float, float],
display_size: float,
) -> tuple[float, tuple[float, float, float]]:
"""Pure one-sided face-resize arithmetic.
Returns ``(new_scale_axis, new_location)`` — the host's new scale
on the dragged axis and its new world origin — such that the
dragged face moves by the modal's outward delta while the OPPOSITE
face stays put.
``value`` is ``init + delta``, where ``init`` is the unsigned
drag-start world half-extent and ``delta`` is the cursor projection
onto the face's OUTWARD world normal. Realized half-extent is
clamped to a small floor; the location shift uses the realized
(post-clamp) delta so the opposite face stays fixed even at the
clamp.
"""
face_delta = value - init_world_half
new_world_half = init_world_half + 0.5 * face_delta
if new_world_half < _MIN_HALF_EXTENT:
new_world_half = _MIN_HALF_EXTENT
realized_delta = 2.0 * (new_world_half - init_world_half)
ds = display_size if display_size != 0.0 else 1.0
new_scale_axis = new_world_half / ds
shift = 0.5 * realized_delta
new_location = (
init_location[0] + shift * world_axis[0],
init_location[1] + shift * world_axis[1],
init_location[2] + shift * world_axis[2],
)
return new_scale_axis, new_location
# ---------------------------------------------------------------------------
# Internal geometry helpers
# ---------------------------------------------------------------------------
def _compute_face_quad_scale(bmin: Any, bmax: Any, axis: int, is_max: bool) -> tuple[float, float]:
"""Return ``(w, h)`` for the face quad's scale matrix."""
w_axis, h_axis = _QUAD_PERP_AXES[(axis, is_max)]
w = float(bmax[w_axis] - bmin[w_axis])
h = float(bmax[h_axis] - bmin[h_axis])
return w, h
def _shared_edge_corner_keys(
axis_a: int, is_max_a: bool, axis_b: int, is_max_b: bool
) -> tuple[tuple[int, int, int], tuple[int, int, int]] | None:
"""Return the 2 corner-bit triples shared by two adjacent faces.
Corner keys are 3-tuples of bits (0 = bmin, 1 = bmax). The two
returned corners are ordered with the free-axis bit ascending.
"""
if axis_a == axis_b:
return None
free_axis = 3 - axis_a - axis_b
bit_a = 1 if is_max_a else 0
bit_b = 1 if is_max_b else 0
corner_lo = [0, 0, 0]
corner_hi = [0, 0, 0]
corner_lo[axis_a] = bit_a
corner_hi[axis_a] = bit_a
corner_lo[axis_b] = bit_b
corner_hi[axis_b] = bit_b
corner_lo[free_axis] = 0
corner_hi[free_axis] = 1
return (
(corner_lo[0], corner_lo[1], corner_lo[2]),
(corner_hi[0], corner_hi[1], corner_hi[2]),
)
def _face_corner_keys(axis: int, is_max: bool) -> tuple[
tuple[int, int, int],
tuple[int, int, int],
tuple[int, int, int],
tuple[int, int, int],
]:
"""Return the 4 corner-bit triples of a face in CCW order.
Triangulation as ``[(0,1,2), (0,2,3)]`` covers the whole face with
two non-overlapping triangles.
"""
fixed_bit = 1 if is_max else 0
free_axes = [a for a in (0, 1, 2) if a != axis]
fa0, fa1 = free_axes
corners = []
for ka, kb in ((0, 0), (1, 0), (1, 1), (0, 1)):
key = [0, 0, 0]
key[axis] = fixed_bit
key[fa0] = ka
key[fa1] = kb
corners.append((key[0], key[1], key[2]))
return (corners[0], corners[1], corners[2], corners[3])
def _build_strip_tris_relative(
edge_p0_local: tuple[float, float, float],
edge_p1_local: tuple[float, float, float],
extrusion_local: tuple[float, float, float],
) -> list[tuple[float, float, float]]:
"""Build two CCW triangles (6 vertices) for a thin halo strip.
All inputs are in coords relative to the gizmo's ``matrix_basis``
anchor. The strip runs along ``[edge_p0_local, edge_p1_local]`` and
extrudes by ``extrusion_local`` perpendicular to the edge.
"""
p0x, p0y, p0z = edge_p0_local
p1x, p1y, p1z = edge_p1_local
ex, ey, ez = extrusion_local
p0e = (p0x + ex, p0y + ey, p0z + ez)
p1e = (p1x + ex, p1y + ey, p1z + ez)
return [
(p0x, p0y, p0z),
p0e,
p1e,
(p0x, p0y, p0z),
p1e,
(p1x, p1y, p1z),
]
def _strips_geometry_changed(quad_gz, face_quad_local, all_tris) -> bool:
"""True if the back-face quad's geometry differs from the cached upload.
Pure orbit/pan doesn't change either the box pose or the cage
rotation, so the computed strip vertices are byte-identical to the
previous frame's. Hitting the cache lets the back-facing branch
skip ``new_custom_shape`` and the GPU upload.
"""
cached = getattr(quad_gz, "_strips_cache_key", None)
last_state = getattr(quad_gz, "_last_geometry_state", None)
key = (face_quad_local, all_tris)
if cached is None or last_state != "strips" or cached != key:
quad_gz._strips_cache_key = key
quad_gz._last_geometry_state = "strips"
return True
return False
def _compute_face_basis(
mw: Any,
mw_rot: Any,
cage_rotation: Any,
pivot_local: Any,
face_local: Any,
orient: Any,
) -> tuple[Any, Any]:
"""World-space (translation, outward-normal-direction) for one face."""
rotated_face_local = cage_rotation.to_3x3() @ (face_local - pivot_local) + pivot_local
face_world = mw @ rotated_face_local
world_axis = (mw_rot @ cage_rotation.to_3x3() @ (orient.to_3x3() @ Vector((0.0, 0.0, 1.0)))).normalized()
return face_world, world_axis
def _compose_face_matrix_basis(
face_world: Any,
mw_rot_scale: Any,
cage_rotation: Any,
orient: Any,
w: float,
h: float,
) -> Any:
"""Compose the 5-term ``matrix_basis`` for a face-plane gizmo.
Returns ``Translation @ mw_rot_scale @ cage_rotation @ orient @
Diagonal((w, h, 1, 1))`` — maps a unit-square local quad onto the
world-space face rectangle, including the host's scale.
"""
quad_scale = Matrix.Diagonal((w, h, 1.0, 1.0))
return Matrix.Translation(face_world) @ mw_rot_scale.to_4x4() @ cage_rotation @ orient @ quad_scale
def _compute_box_corners_world(
bmin: Any,
bmax: Any,
pivot_local: Any,
cage_rotation_3x3: Any,
mw: Any,
) -> dict[tuple[int, int, int], Any]:
"""Return the 8 OBB corners in world space, keyed by bit-triple."""
corners: dict[tuple[int, int, int], Any] = {}
for ix in (0, 1):
for iy in (0, 1):
for iz in (0, 1):
local = Vector(
(
float(bmax.x if ix else bmin.x),
float(bmax.y if iy else bmin.y),
float(bmax.z if iz else bmin.z),
)
)
rotated = cage_rotation_3x3 @ (local - pivot_local) + pivot_local
corners[(ix, iy, iz)] = mw @ rotated
return corners
def _abs_scale_matrix(mw: Any) -> Any:
"""Return a copy of ``mw`` with all scale components ``abs()``-ed.
Without this, a negative-scale host produces a visible/clickable
face inversion: ``mw @ local_vec`` flips the +axis face onto the
-axis world side, while the rotation-only normal stays pointing
in the +axis direction — so the gizmo for "the +X face" sits at
world -X but reports its outward normal as +X.
"""
loc, rot, scale = mw.decompose()
abs_scale = Vector((abs(scale.x), abs(scale.y), abs(scale.z)))
return Matrix.LocRotScale(loc, rot, abs_scale)
def _world_radius_to_screen_pixels(
region: Any,
rv3d: Any,
center_world: Vector,
world_radius: float,
*,
min_pixels: float = 0.0,
) -> float:
"""Return the on-screen pixel radius of a world-space circle.
Projects ``center_world`` and a sample point offset by
``world_radius`` along the camera's view-aligned right axis to
region pixels, and returns the screen-pixel distance between them.
Falls back to ``min_pixels`` if either projection fails.
"""
try:
view_inv = rv3d.view_matrix.inverted()
right = Vector((view_inv[0][0], view_inv[0][1], view_inv[0][2])).normalized()
except (AttributeError, ValueError):
right = Vector((1.0, 0.0, 0.0))
sample_world = center_world + right * world_radius
return _world_segment_to_screen_pixels(region, rv3d, center_world, sample_world, min_pixels=min_pixels)
def _world_segment_to_screen_pixels(
region: Any,
rv3d: Any,
p0_world: Vector,
p1_world: Vector,
*,
min_pixels: float = 0.0,
) -> float:
"""Return the on-screen pixel length of an arbitrary world segment.
Unlike :func:`_world_radius_to_screen_pixels`, this measures the
ACTUAL projected length of the segment — foreshortening included.
Use this when the segment direction is known to be oblique to the
screen plane (e.g. a back face's outward normal): a perpendicular
radius measurement overestimates the on-screen length, leaving
halo strips visually narrower than the requested pixel target.
"""
p0 = location_3d_to_region_2d(region, rv3d, p0_world)
p1 = location_3d_to_region_2d(region, rv3d, p1_world)
if not p0 or not p1:
return min_pixels
dx = float(p1[0]) - float(p0[0])
dy = float(p1[1]) - float(p0[1])
return max(min_pixels, (dx * dx + dy * dy) ** 0.5)
# ---------------------------------------------------------------------------
# Gizmo classes
# ---------------------------------------------------------------------------
class BIM_GT_box_face_quad(bpy.types.Gizmo): # noqa: N801 — Blender bl_idname convention
"""Near-invisible face-quad click target with drag-to-resize modal.
Geometry: a unit quad in the local XY plane at z=0. The adapter
group's layout helper rotates and scales it onto the face plane;
the quad is welded to the world face (``use_draw_scale = False``).
"""
bl_idname = "BIM_GT_box_face_quad"
bl_target_properties = ({"id": "offset", "type": "FLOAT", "array_length": 1},)
__slots__ = (
"custom_shape",
"custom_shape_select",
"init_value",
"move_get_cb",
"move_set_cb",
"axis",
"start_location",
"depth_point",
"callback",
"ctrl_click_cb",
"_group",
"_face_axis",
"is_max",
"_drag_snapshot",
"_last_geometry_state",
"_strips_cache_key",
)
def draw(self, context: Any) -> None:
self.draw_custom_shape(self.custom_shape)
def draw_select(self, context: Any, select_id: int) -> None:
# Back-facing quads bind ``custom_shape_select`` to the halo-strip
# TRIS so clicks OUTSIDE the box silhouette catch the back face.
# Front-facing quads leave it None and reuse ``custom_shape``.
shape = getattr(self, "custom_shape_select", None) or self.custom_shape
self.draw_custom_shape(shape, select_id=select_id)
def setup(self) -> None:
if not hasattr(self, "custom_shape_"):
self.custom_shape = self.new_custom_shape("TRIS", _QUAD_TRIS)
self.custom_shape_select = None
# Quad welded to world geometry — clicks must align with the
# visible face, not a screen-size widget. Disables Blender's
# per-frame pixel-constant autoscale.
self.use_draw_scale = False
# ---- modal -------------------------------------------------------------
def invoke(self, context: Any, event: Any) -> set[str]:
# CTRL+click handoff: dispatch a host-defined callback (e.g.
# align-view) instead of starting a drag.
if event.ctrl and getattr(self, "ctrl_click_cb", None) is not None:
self.ctrl_click_cb(context, event)
return {"FINISHED"}
region = context.region
rv3d = context.region_data
if region is None or rv3d is None:
return {"CANCELLED"}
self.init_value = self.move_get_cb()
# Freeze the projection plane at invoke — projection-plane
# drift on tilted axes causes exponential delta runaway.
self.depth_point = self.matrix_basis.translation.copy()
self.start_location = region_2d_to_location_3d(region, rv3d, (event.mouse_x, event.mouse_y), self.depth_point)
if getattr(self, "_group", None) is not None:
self._group._lock_for(self)
return {"RUNNING_MODAL"}
def exit(self, context: Any, cancel: bool) -> None:
try:
if context.area:
context.area.header_text_set(None)
if cancel:
self.move_set_cb(self.init_value)
if hasattr(self, "callback"):
self.callback(self.move_get_cb())
finally:
self._drag_snapshot = None
if getattr(self, "_group", None) is not None:
self._group._unlock_all()
def modal(self, context: Any, event: Any, tweak: set[str]) -> set[str]:
if event.type == "ESC":
return {"CANCELLED"}
region = context.region
rv3d = context.region_data
if region is None or rv3d is None:
return {"CANCELLED"}
end_location = region_2d_to_location_3d(region, rv3d, (event.mouse_x, event.mouse_y), self.depth_point)
delta = (end_location - self.start_location).dot(self.axis)
if "SNAP" in tweak:
delta = round(delta, 1)
if "PRECISE" in tweak:
delta /= 10.0
self.move_set_cb(self.init_value + delta)
if context.area:
context.area.header_text_set(f"Value: {self.move_get_cb():.3f} ({delta:.3f})")
return {"RUNNING_MODAL"}
class BIM_GT_box_face_outline(bpy.types.Gizmo): # noqa: N801 — Blender bl_idname convention
"""Thin non-interactive colored edge outline for one face.
Drawn as 4 line segments in the face plane. The layout helper
toggles its ``alpha`` between near-zero and ``1.0`` based on the
sibling face-quad's ``is_highlight`` state — so hovering the quad
lights up the matching outline. ``hide_select = True`` keeps the
outline out of the GPU selection buffer.
"""
bl_idname = "BIM_GT_box_face_outline"
bl_target_properties = ()
__slots__ = (
"custom_shape",
"_face_axis",
"is_max",
"_last_outline_state",
)
def draw(self, context: Any) -> None:
self.draw_custom_shape(self.custom_shape)
def draw_select(self, context: Any, select_id: int) -> None:
return None
def setup(self) -> None:
if not hasattr(self, "custom_shape_"):
self.custom_shape = self.new_custom_shape("LINES", _QUAD_OUTLINE_LINES)
self.use_draw_scale = False
self.hide_select = True
self._last_outline_state = "unit"
# ---------------------------------------------------------------------------
# Per-redraw orchestrator
# ---------------------------------------------------------------------------
def apply_face_quad_layout(
*,
quad_gizmos,
outline_gizmos,
bmin: Any,
bmax: Any,
matrix_world: Any,
cage_rotation: Any,
region: Any,
rv3d: Any,
locked: bool,
) -> None:
"""Lay out 6 face quads + 6 outlines on the box for this redraw.
``quad_gizmos`` / ``outline_gizmos`` are length-6 sequences in
:data:`FACE_ROUTES` order. ``bmin`` / ``bmax`` are the box corners
in the host's local frame; ``matrix_world`` is the host's world
matrix; ``cage_rotation`` is the OBB rotation as a 4x4 (use
``Matrix.Identity(4)`` when rotation rides in ``matrix_world``).
``region`` / ``rv3d`` drive the view-dependent front/back split and
the screen-constant halo margin; passing ``rv3d = None`` bails.
Negative scale on the host is normalized to positive internally so
the visible cube and the clickable face gizmos stay aligned —
callers don't need to pre-process ``matrix_world``.
When ``locked`` (a drag is active), ``hide`` / ``select_bias``
writes are skipped — the active quad's geometry is still refreshed
so it tracks the moving box.
"""
if rv3d is None or getattr(rv3d, "view_rotation", None) is None:
return
if len(quad_gizmos) != 6 or len(outline_gizmos) != 6:
return
mw = _abs_scale_matrix(matrix_world)
mw_rot = mw.to_quaternion().to_matrix()
mw_rot_scale = mw.to_3x3()
cage_rotation_3x3 = cage_rotation.to_3x3()
pivot_local = (bmin + bmax) * 0.5
box_center_local = pivot_local
face_midpoints_local = {
(0, False): Vector((float(bmin.x), box_center_local.y, box_center_local.z)),
(0, True): Vector((float(bmax.x), box_center_local.y, box_center_local.z)),
(1, False): Vector((box_center_local.x, float(bmin.y), box_center_local.z)),
(1, True): Vector((box_center_local.x, float(bmax.y), box_center_local.z)),
(2, False): Vector((box_center_local.x, box_center_local.y, float(bmin.z))),
(2, True): Vector((box_center_local.x, box_center_local.y, float(bmax.z))),
}
view_dir = (rv3d.view_rotation @ Vector((0.0, 0.0, -1.0))).normalized()
view_dir_tuple = (float(view_dir.x), float(view_dir.y), float(view_dir.z))
face_normals_world = []
for route_axis, route_is_max in FACE_ROUTES:
axis_local = Vector(face_outward_axis_local(route_axis, route_is_max))
n_world = (mw_rot @ cage_rotation_3x3 @ axis_local).normalized()
face_normals_world.append((float(n_world.x), float(n_world.y), float(n_world.z)))
front = front_facing_face_mask(tuple(face_normals_world), view_dir_tuple)
box_center_world = mw @ pivot_local
corners_world = _compute_box_corners_world(bmin, bmax, pivot_local, cage_rotation_3x3, mw)
route_to_index = {route: i for i, route in enumerate(FACE_ROUTES)}
for i, route in enumerate(FACE_ROUTES):
quad_gz = quad_gizmos[i]
is_front = front[i]
axis_b, is_max_b = route
# Place the colored OUTLINE on every face using the same composed
# face matrix the front-facing solid quad uses. Hidden/shown via
# alpha at the end of the pass.
outline_orient = _AXIS_ORIENT[route]
outline_face_world, _outline_axis = _compute_face_basis(
mw,
mw_rot,
cage_rotation,
pivot_local,
face_midpoints_local[route],
outline_orient,
)
ow, oh = _compute_face_quad_scale(bmin, bmax, axis_b, is_max_b)
outline_gizmos[i].matrix_basis = _compose_face_matrix_basis(
outline_face_world, mw_rot_scale, cage_rotation, outline_orient, ow, oh
)
if is_front:
if not locked:
quad_gz.hide = False
quad_gz.select_bias = _FACE_QUAD_FRONT_FACING_SELECT_BIAS
orient = _AXIS_ORIENT[route]
face_world, world_axis = _compute_face_basis(
mw,
mw_rot,
cage_rotation,
pivot_local,
face_midpoints_local[route],
orient,
)
w, h = _compute_face_quad_scale(bmin, bmax, axis_b, is_max_b)
quad_gz.matrix_basis = _compose_face_matrix_basis(face_world, mw_rot_scale, cage_rotation, orient, w, h)
quad_gz.axis = world_axis
if getattr(quad_gz, "_last_geometry_state", None) != "solid":
quad_gz.custom_shape = quad_gz.new_custom_shape("TRIS", _QUAD_TRIS)
quad_gz.custom_shape_select = None
quad_gz._last_geometry_state = "solid"
continue
# Back-facing: anchor at the back face centre; build halo strips
# in the planes of the adjacent FRONT faces, extruded outside
# the silhouette toward this face's outward normal.
face_world = mw @ (cage_rotation_3x3 @ (face_midpoints_local[route] - pivot_local) + pivot_local)
quad_gz.matrix_basis = Matrix.Translation(face_world)
quad_gz.axis = (mw_rot @ cage_rotation_3x3 @ Vector(face_outward_axis_local(axis_b, is_max_b))).normalized()
adjacent_front_routes = [
(axis_a, is_max_a)
for axis_a in range(3)
if axis_a != axis_b
for is_max_a in (False, True)
if front[route_to_index[(axis_a, is_max_a)]]
]
# Per-face world margin: measure the screen-projected length of
# ONE world unit along THIS face's outward normal. The world
# margin that yields ~N pixels on screen is then ``N / length``.
# Foreshortening on oblique faces shortens the projected step,
# so the world step must grow to keep the strip the same width
# on screen.
face_world_margin = 0.0
if region is not None:
sample_end = box_center_world + quad_gz.axis * 1.0
screen_step = _world_segment_to_screen_pixels(region, rv3d, box_center_world, sample_end, min_pixels=0.0)
if screen_step > 0.0:
face_world_margin = _FACE_QUAD_HALO_TARGET_PIXELS / screen_step
if face_world_margin <= 0.0 or not adjacent_front_routes:
if not locked:
quad_gz.hide = True
quad_gz.select_bias = _FACE_QUAD_HALO_FRAME_SELECT_BIAS
if getattr(quad_gz, "_last_geometry_state", None) != "empty":
quad_gz.custom_shape = quad_gz.new_custom_shape("TRIS", _EMPTY_TRIS)
quad_gz.custom_shape_select = None
quad_gz._last_geometry_state = "empty"
continue
extrusion_world = quad_gz.axis * face_world_margin
extrusion_local = (
float(extrusion_world.x),
float(extrusion_world.y),
float(extrusion_world.z),
)
all_tris: list[tuple[float, float, float]] = []
for axis_a, is_max_a in adjacent_front_routes:
edge_keys = _shared_edge_corner_keys(axis_a, is_max_a, axis_b, is_max_b)
if edge_keys is None:
continue
key0, key1 = edge_keys
wp0 = corners_world[key0]
wp1 = corners_world[key1]
local_p0 = (
float(wp0.x - face_world.x),
float(wp0.y - face_world.y),
float(wp0.z - face_world.z),
)
local_p1 = (
float(wp1.x - face_world.x),
float(wp1.y - face_world.y),
float(wp1.z - face_world.z),
)
all_tris.extend(_build_strip_tris_relative(local_p0, local_p1, extrusion_local))
if not locked:
quad_gz.hide = False
quad_gz.select_bias = _FACE_QUAD_HALO_FRAME_SELECT_BIAS
corner_keys = _face_corner_keys(axis_b, is_max_b)
wc_local = [
(
float(corners_world[k].x - face_world.x),
float(corners_world[k].y - face_world.y),
float(corners_world[k].z - face_world.z),
)
for k in corner_keys
]
face_quad_local = [
wc_local[0],
wc_local[1],
wc_local[2],
wc_local[0],
wc_local[2],
wc_local[3],
]
if _strips_geometry_changed(quad_gz, tuple(face_quad_local), tuple(all_tris)):
quad_gz.custom_shape = quad_gz.new_custom_shape("TRIS", face_quad_local)
quad_gz.custom_shape_select = quad_gz.new_custom_shape("TRIS", all_tris)
quad_gz._last_geometry_state = "strips"
# Outline alpha follows ONLY the hovered quad's own state — light
# the outline of the face under the cursor, nothing else.
if not locked:
for outline_gz, quad_gz in zip(outline_gizmos, quad_gizmos, strict=True):
lit = bool(getattr(quad_gz, "is_highlight", False))
outline_gz.alpha = 1.0 if lit else 0.0
outline_gz.alpha_highlight = 1.0 if lit else 0.0
__all__ = [
"AXIS_COLOR",
"FACE_QUAD_ALPHA",
"FACE_QUAD_ALPHA_HIGHLIGHT",
"FACE_QUAD_SELECT_BIAS",
"FACE_ROUTES",
"BIM_GT_box_face_outline",
"BIM_GT_box_face_quad",
"apply_face_quad_layout",
"compute_face_resize",
"face_outward_axis_local",
"front_facing_face_mask",
"view_axis_parallel_face_mask",
]
@@ -0,0 +1,312 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# 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.
"""Interactive face-quad resize gizmos for the active clip box.
Adapter group that binds the generic :mod:`face_quad` core to a Bonsai
clip-box Empty: six near-invisible click quads + six edge outlines on
the cube's faces. Dragging a face does a ONE-SIDED resize — the dragged
face moves along its outward world normal while the opposite face stays
put — by writing the empty's ``location`` and ``scale``. Bonsai's
depsgraph handler then re-arms the clip planes from the new matrix.
"""
from __future__ import annotations
import contextlib
from typing import Any
import bpy
from mathutils import Matrix, Vector
import bonsai.tool as tool
from . import face_quad
# Local-frame bounds of the empty's CUBE display. The display spans
# ``[-empty_display_size, +empty_display_size]^3``; Bonsai always sets
# ``empty_display_size = 1.0`` on clip-box hosts, so the local box is
# the unit cube. The empty's per-axis scale + rotation + translation
# ride in ``matrix_world``, which the layout helper applies.
_LOCAL_BMIN = Vector((-1.0, -1.0, -1.0))
_LOCAL_BMAX = Vector((1.0, 1.0, 1.0))
def _world_axis(empty: bpy.types.Object, axis: int, is_max: bool) -> Vector:
"""Outward world-space unit normal of the ``(axis, is_max)`` face.
Uses the rotation-only matrix so a negative-scale empty doesn't
flip the resulting direction — the visible "+X face" then stays
associated with world +X (transformed through rotation).
"""
rot_mat = empty.matrix_world.to_quaternion().to_matrix()
n = Vector(rot_mat.col[axis])
if n.length <= 0.0:
return Vector((0.0, 0.0, 0.0))
n.normalize()
return n if is_max else -n
def _world_half_extent(empty: bpy.types.Object, axis: int) -> float:
"""The empty's box half-extent along local ``axis`` in WORLD units.
A CUBE empty's local cube is ``±empty_display_size``; ``matrix_world``
stretches it by the column length on ``axis``. So the world
half-extent is ``|column[axis]| * empty_display_size``.
"""
col_len = empty.matrix_world.to_3x3().col[axis].length
display_size = abs(float(getattr(empty, "empty_display_size", 1.0) or 1.0))
return float(col_len) * display_size
def _make_face_get_cb(gz: Any, group: Any, axis: int, is_max: bool):
"""Closure returning the world half-extent at drag start and
snapshotting the empty's full transform on the gizmo instance.
The snapshot lives on the gizmo (not the group) so a PERSISTENT
group servicing multiple clip boxes can't bleed one drag's state
onto another. Cleared on ``exit`` by the shared face-quad hook.
"""
def getter() -> float:
empty = group._empty
if empty is None:
return 0.0
existing = getattr(gz, "_drag_snapshot", None)
if existing is not None and existing.get("empty_name") == getattr(empty, "name", None):
return float(existing["world_half"])
world_half = _world_half_extent(empty, axis)
display_size = abs(float(getattr(empty, "empty_display_size", 1.0) or 1.0))
gz._drag_snapshot = {
"empty_name": getattr(empty, "name", None),
"world_half": world_half,
"location": tuple(float(v) for v in empty.location),
"scale": tuple(float(v) for v in empty.scale),
"display_size": display_size if display_size != 0.0 else 1.0,
"world_axis": tuple(_world_axis(empty, axis, is_max)),
}
return float(world_half)
return getter
def _make_ctrl_click_cb(axis: int, is_max: bool):
"""Closure that dispatches CTRL+click on a face to the align-view operator.
Routing through an operator (rather than mutating ``rv3d`` here)
keeps the action F3-searchable and undoable.
"""
def _callback(_context: Any, _event: Any) -> None:
bpy.ops.bim.align_view_to_clip_face("INVOKE_DEFAULT", axis=axis, is_max=is_max)
return _callback
def _make_face_set_cb(gz: Any, group: Any, axis: int, is_max: bool):
"""Closure that applies a one-sided face resize by writing the
empty's ``location`` + ``scale``.
The modal calls this with ``value = init + delta`` where ``delta``
is the cursor's projection onto the face's OUTWARD world normal.
Both reads come from ``gz._drag_snapshot`` so every frame is
relative to drag start, never compounding.
"""
del is_max # snapshot's world_axis carries the direction
def setter(value: float) -> None:
empty = group._empty
if empty is None:
return
snap = getattr(gz, "_drag_snapshot", None)
if snap is None or snap.get("empty_name") != getattr(empty, "name", None):
return
new_scale_axis, new_location = face_quad.compute_face_resize(
value=value,
init_world_half=snap["world_half"],
init_location=snap["location"],
world_axis=snap["world_axis"],
display_size=snap["display_size"],
)
new_scale = list(snap["scale"])
# Preserve the sign of the original scale so a user-flipped empty
# stays flipped after the resize — compute_face_resize returns a
# positive magnitude, the sign is the user's intent to keep.
sign = -1.0 if snap["scale"][axis] < 0.0 else 1.0
new_scale[axis] = sign * new_scale_axis
empty.scale = new_scale
empty.location = Vector(new_location)
return setter
class OBJECT_GGT_bim_clip_box(bpy.types.GizmoGroup): # noqa: N801 — Blender bl_idname convention
"""Face-quad resize handles on the active clip box.
Renders six near-invisible click-target quads and six colored edge
outlines on the active clip-box empty whenever clipping is enabled.
Click-and-drag a face to resize one-sided; the opposite face stays
put. CTRL+click and plain click fall through to selection.
"""
bl_idname = "OBJECT_GGT_bim_clip_box"
bl_label = "Bonsai Clip Box Faces"
bl_space_type = "VIEW_3D"
bl_region_type = "WINDOW"
bl_options = {"3D", "PERSISTENT", "SHOW_MODAL_ALL"}
@classmethod
def poll(cls, context: Any) -> bool:
scene = getattr(context, "scene", None)
if scene is None:
return False
scene_props = tool.ClipBox.get_scene_props(scene)
if not scene_props.enabled or not scene_props.enable_gizmos:
return False
active_clip_box = tool.ClipBox.get_active_clip_box(scene)
if active_clip_box is None:
return False
# Only render when the user has the active clip box itself
# selected — otherwise the face handles would intercept clicks
# meant for the geometry behind them.
return getattr(context, "active_object", None) is active_clip_box
@classmethod
def setup_keymap(cls, keyconfig):
# Bind CLICK_DRAG so plain LEFTMOUSE PRESS passes through to
# selection — the user can still click through a near-invisible
# face quad to pick a mesh behind it.
km = keyconfig.keymaps.new(
name=cls.bl_idname,
space_type=cls.bl_space_type,
region_type=cls.bl_region_type,
)
km.keymap_items.new("gizmogroup.gizmo_tweak", type="LEFTMOUSE", value="CLICK_DRAG")
km.keymap_items.new("gizmogroup.gizmo_tweak", type="LEFTMOUSE", value="PRESS", ctrl=True)
return km
def setup(self, context: Any) -> None:
# ``_empty`` is resolved each refresh so the PERSISTENT group
# follows whichever clip box is active in the scene PG.
self._empty: bpy.types.Object | None = None
self._locked = False
self._face_routes: list[tuple[int, bool]] = []
for axis, is_max in face_quad.FACE_ROUTES:
gz = self.gizmos.new(face_quad.BIM_GT_box_face_quad.bl_idname)
gz._group = self
gz._face_axis = axis
gz.is_max = is_max
gz._drag_snapshot = None
gz._last_geometry_state = "solid"
gz._strips_cache_key = None
gz.color = face_quad.AXIS_COLOR[axis]
gz.color_highlight = tuple(min(1.0, c + 0.3) for c in face_quad.AXIS_COLOR[axis])
gz.alpha = face_quad.FACE_QUAD_ALPHA
gz.alpha_highlight = face_quad.FACE_QUAD_ALPHA_HIGHLIGHT
gz.use_draw_modal = True
gz.scale_basis = 1.0
gz.select_bias = face_quad.FACE_QUAD_SELECT_BIAS
gz.move_get_cb = _make_face_get_cb(gz, self, axis, is_max)
gz.move_set_cb = _make_face_set_cb(gz, self, axis, is_max)
# CTRL+click on a face aligns the viewport to look at it.
gz.ctrl_click_cb = _make_ctrl_click_cb(axis, is_max)
self._face_routes.append((axis, is_max))
# Outlines added last so they composite on top of the quad
# fills (Blender draws gizmos in creation order).
for axis, is_max in face_quad.FACE_ROUTES:
ol = self.gizmos.new(face_quad.BIM_GT_box_face_outline.bl_idname)
ol._face_axis = axis
ol.is_max = is_max
ol.color = face_quad.AXIS_COLOR[axis]
ol.color_highlight = face_quad.AXIS_COLOR[axis]
ol.alpha = 0.0
ol.alpha_highlight = 0.0
ol.line_width = 2.5
def _quad_gizmos(self):
return self.gizmos[: len(self._face_routes)]
def _outline_gizmos(self):
n = len(self._face_routes)
return self.gizmos[n : 2 * n]
def refresh(self, context: Any) -> None:
"""State-change path: resolve the active empty, then run the
shared face-quad layout so the quads aren't stale for a frame
after a selection or active-index change."""
empty = tool.ClipBox.get_active_clip_box(context.scene)
self._empty = empty
if empty is None:
for gz in self.gizmos:
gz.hide = True
return
self._layout(context, empty)
def draw_prepare(self, context: Any) -> None:
"""Per-redraw — fires on orbit — re-run the layout so the
front/back split, halo strips, and outline highlights track
the camera and any live G/R/S on the empty."""
empty = self._empty
if empty is None:
return
self._layout(context, empty)
def _layout(self, context: Any, empty: bpy.types.Object) -> None:
face_quad.apply_face_quad_layout(
quad_gizmos=self._quad_gizmos(),
outline_gizmos=self._outline_gizmos(),
bmin=_LOCAL_BMIN,
bmax=_LOCAL_BMAX,
matrix_world=empty.matrix_world,
# The empty's rotation rides in matrix_world, so the
# box-local OBB rotation is identity.
cage_rotation=Matrix.Identity(4),
region=getattr(context, "region", None),
rv3d=getattr(context, "region_data", None),
locked=self._locked,
)
# ---- mutual exclusion (lock siblings during a drag) ------------------
def _lock_for(self, active_gizmo) -> None:
self._locked = True
for gz in self.gizmos:
if gz is not active_gizmo:
with contextlib.suppress(ReferenceError, RuntimeError):
gz.hide = True
def _unlock_all(self) -> None:
self._locked = False
for gz in self.gizmos:
with contextlib.suppress(ReferenceError, RuntimeError):
gz.hide = False
# Rebuild caps synchronously so the cross-section overlay
# re-forms the instant the user releases the handle, rather
# than waiting for the depsgraph's debounced rebuild path.
with contextlib.suppress(RuntimeError, ReferenceError):
tool.ClipBox.rebuild_caps_now()
# Push an undo step so the user can revert a face drag with Ctrl+Z.
with contextlib.suppress(RuntimeError):
bpy.ops.ed.undo_push(message="Resize Clip Box")
@@ -0,0 +1,294 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# 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 bpy
from mathutils import Matrix, Vector
import bonsai.tool as tool
from bonsai.bim.helper import prop_with_search
from . import data
# NOTE: do NOT add ``from __future__ import annotations`` to this module.
# PEP 563 stringifies the operator's EnumProperty class annotations, which
# breaks any introspection that reads ``cls.__annotations__[name].keywords``
# — including the enum-search helper that draws the search-button icon.
CLIP_BOX_NAME = "ClipBox"
# Display labels for the source-based picker, used for the menu entries and
# the dialog title. The dict keys are the canonical source-kind identifiers.
SOURCE_KIND_LABELS: dict[str, str] = {
"SPATIAL": "Spatial Element",
"CLASS": "Class",
"TYPE": "Type",
"MATERIAL": "Material",
"PROFILE": "Profile",
"DRAWING": "Drawing",
"STATUS": "Status",
"SYSTEM": "System",
"GROUP": "Group",
"ZONE": "Zone",
}
_SOURCE_ID_DISPATCH = {
"SPATIAL": data.spatial_items,
"CLASS": data.class_items,
"TYPE": data.type_items,
"MATERIAL": data.material_items,
"PROFILE": data.profile_items,
"DRAWING": data.drawing_items,
"STATUS": data.status_items,
"SYSTEM": data.system_items,
"GROUP": data.group_items,
"ZONE": data.zone_items,
}
def _source_id_items(self, context):
"""Dispatch the ``source_id`` enum items based on the picked ``source_kind``."""
fn = _SOURCE_ID_DISPATCH.get(self.source_kind)
if fn is None:
return [(data.NO_OPTIONS_ID, "No options", "")]
return fn(self, context)
def _source_display_name(kind, source_id):
"""Human-readable name of the picked source, used in the clip-box name."""
if kind == "STATUS":
return next((label for value, label in data.STATUS_LABELS if value == source_id), source_id)
if kind == "CLASS":
# source_id IS the human-readable IFC class name.
return source_id
ifc = tool.Ifc.get()
if ifc is None:
return source_id
try:
entity = ifc.by_id(int(source_id))
except (TypeError, ValueError, RuntimeError):
return source_id
return (getattr(entity, "Name", None) or "Unnamed").strip() or "Unnamed"
class BIM_OT_align_view_to_clip_face(bpy.types.Operator):
bl_idname = "bim.align_view_to_clip_face"
bl_label = "Align View to Clip Box Face"
bl_description = "Orient the 3D viewport to look directly at the picked clip-box face"
bl_options = {"REGISTER"}
axis: bpy.props.IntProperty(default=0, options={"SKIP_SAVE"})
is_max: bpy.props.BoolProperty(default=True, options={"SKIP_SAVE"})
def execute(self, context):
rv3d = getattr(context, "region_data", None)
if rv3d is None:
return {"CANCELLED"}
clip_box = tool.ClipBox.get_active_clip_box(context.scene)
if clip_box is None:
return {"CANCELLED"}
rot_mat = clip_box.matrix_world.to_quaternion().to_matrix()
outward_local = Vector((0.0, 0.0, 0.0))
outward_local[self.axis] = 1.0 if self.is_max else -1.0
outward = (rot_mat @ outward_local).normalized()
if outward.length == 0.0:
return {"CANCELLED"}
up_world = (rot_mat @ _local_up_for_face(self.axis, self.is_max)).normalized()
rv3d.view_rotation = _view_rotation_from_forward_and_up(-outward, up_world)
return {"FINISHED"}
def _local_up_for_face(axis: int, is_max: bool) -> Vector:
"""Box-local up direction for a face, following Blender numpad conventions.
Side faces (local ±X / ±Y normal) → local +Z is up. Top face (local +Z
normal) → local +Y is up; bottom face (local -Z normal) → local -Y is
up. The caller rotates this through the empty's matrix so the
resulting world up axis tracks the box's orientation.
"""
if axis == 2:
return Vector((0.0, 1.0, 0.0)) if is_max else Vector((0.0, -1.0, 0.0))
return Vector((0.0, 0.0, 1.0))
def _view_rotation_from_forward_and_up(forward: Vector, up_hint: Vector) -> "bpy.types.Quaternion":
"""Build a camera ``view_rotation`` that looks along ``forward`` with
``up_hint`` projected to the camera's local +Y."""
back = -forward.normalized()
right = up_hint.cross(back)
if right.length < 1e-6:
right = Vector((1.0, 0.0, 0.0))
right.normalize()
up = back.cross(right).normalized()
return Matrix(
(
(right.x, up.x, back.x),
(right.y, up.y, back.y),
(right.z, up.z, back.z),
)
).to_quaternion()
class BIM_OT_add_clip_box(bpy.types.Operator):
bl_idname = "bim.add_clip_box"
bl_label = "Add Clip Box"
bl_description = (
"Create a clip box empty at the 3D cursor. The empty's location, rotation, and scale "
"drive the viewport clip planes; resize with S, move with G, rotate with R"
)
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
# Default to a 20m cube (scale 10 around [-1, +1] local cube) so
# the volume covers a typical building storey or two rather than
# the meaningless 2m unit cube. The user resizes with S.
matrix = Matrix.Translation(context.scene.cursor.location.copy()) @ Matrix.Diagonal((10.0, 10.0, 10.0, 1.0))
tool.ClipBox.create_clip_box_empty(context, matrix, name=CLIP_BOX_NAME)
return {"FINISHED"}
class BIM_OT_add_clip_box_for_source(bpy.types.Operator):
bl_idname = "bim.add_clip_box_for_source"
bl_label = "Add Clip Box From Source"
bl_description = (
"Create a clip box sized to a chosen source: a spatial container, IFC type, material, "
"profile, drawing camera frustum, element status, system, group, or zone"
)
bl_options = {"REGISTER", "UNDO"}
source_kind: bpy.props.EnumProperty(
name="Source Kind",
items=[(kind, label, "") for kind, label in SOURCE_KIND_LABELS.items()],
default="SPATIAL",
options={"SKIP_SAVE"},
)
source_id: bpy.props.EnumProperty(
name="Source",
items=_source_id_items,
options={"SKIP_SAVE"},
)
def invoke(self, context, event):
return context.window_manager.invoke_props_dialog(self)
def draw(self, context):
layout = self.layout
label = f"Clip {SOURCE_KIND_LABELS.get(self.source_kind, 'Source')}"
# Search button appears once the enum exceeds the helper's threshold,
# giving the user a popup picker instead of a plain dropdown.
prop_with_search(layout, self, "source_id", text=label)
def execute(self, context):
if not self.source_id or self.source_id == data.NO_OPTIONS_ID:
self.report({"ERROR"}, "No source selected.")
return {"CANCELLED"}
matrix = tool.ClipBox.compute_matrix_for_source(self.source_kind, self.source_id)
if matrix is None:
kind_label = SOURCE_KIND_LABELS.get(self.source_kind, self.source_kind)
self.report(
{"ERROR"},
f"No elements found for {kind_label} '{_source_display_name(self.source_kind, self.source_id)}'.",
)
return {"CANCELLED"}
name = f"ClipBox.{SOURCE_KIND_LABELS.get(self.source_kind, self.source_kind)}.{_source_display_name(self.source_kind, self.source_id)}"
tool.ClipBox.create_clip_box_empty(context, matrix, name=name)
return {"FINISHED"}
class BIM_OT_remove_clip_box(bpy.types.Operator):
bl_idname = "bim.remove_clip_box"
bl_label = "Remove Clip Box"
bl_description = "Remove this clip box and its host empty"
bl_options = {"REGISTER", "UNDO"}
index: bpy.props.IntProperty(default=-1, options={"SKIP_SAVE"})
delete_object: bpy.props.BoolProperty(default=True, name="Delete Host Object")
def execute(self, context):
scene_props = tool.ClipBox.get_scene_props(context.scene)
index = self.index if self.index >= 0 else scene_props.active_clip_box_index
if index < 0 or index >= len(scene_props.clip_boxes):
return {"CANCELLED"}
entry = scene_props.clip_boxes[index]
obj = entry.obj
scene_props.clip_boxes.remove(index)
if scene_props.active_clip_box_index >= len(scene_props.clip_boxes):
scene_props.active_clip_box_index = max(0, len(scene_props.clip_boxes) - 1)
if self.delete_object and obj is not None:
bpy.data.objects.remove(obj, do_unlink=True)
tool.ClipBox.refresh(context.scene)
tool.ClipBox.save_to_project_pset(context.scene)
return {"FINISHED"}
class BIM_OT_set_active_clip_box(bpy.types.Operator):
bl_idname = "bim.set_active_clip_box"
bl_label = "Set Active Clip Box"
bl_description = "Set this clip box as the active one driving the viewport clip"
bl_options = {"REGISTER", "UNDO"}
index: bpy.props.IntProperty(default=-1, options={"SKIP_SAVE"})
def execute(self, context):
scene_props = tool.ClipBox.get_scene_props(context.scene)
if self.index < 0 or self.index >= len(scene_props.clip_boxes):
return {"CANCELLED"}
scene_props.active_clip_box_index = self.index
return {"FINISHED"}
class BIM_OT_toggle_clip_box_enabled(bpy.types.Operator):
bl_idname = "bim.toggle_clip_box_enabled"
bl_label = "Toggle Clip Box"
bl_description = "Toggle whether the active clip box is driving the viewport clip planes"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
scene_props = tool.ClipBox.get_scene_props(context.scene)
scene_props.enabled = not scene_props.enabled
return {"FINISHED"}
class BIM_OT_duplicate_clip_box(bpy.types.Operator):
bl_idname = "bim.duplicate_clip_box"
bl_label = "Duplicate Clip Box"
bl_description = "Duplicate this clip box: copy its empty + matrix into a new entry"
bl_options = {"REGISTER", "UNDO"}
index: bpy.props.IntProperty(default=-1, options={"SKIP_SAVE"})
def execute(self, context):
scene_props = tool.ClipBox.get_scene_props(context.scene)
source_index = self.index if self.index >= 0 else scene_props.active_clip_box_index
if source_index < 0 or source_index >= len(scene_props.clip_boxes):
return {"CANCELLED"}
source = scene_props.clip_boxes[source_index].obj
if source is None:
return {"CANCELLED"}
copy = tool.ClipBox.create_clip_box_empty(context, source.matrix_world.copy(), name=source.name)
# Preserve the source's display attrs so the duplicate matches.
copy.empty_display_type = source.empty_display_type
copy.empty_display_size = source.empty_display_size
copy.show_in_front = source.show_in_front
return {"FINISHED"}
@@ -0,0 +1,165 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# 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
from typing import TYPE_CHECKING
import bpy
from bpy.types import PropertyGroup
import bonsai.tool as tool
from bonsai.bim.prop import ObjProperty
class BIMClipBoxProperties(PropertyGroup):
"""Per-object marker for a clip-box host empty.
The host empty's ``matrix_world`` is the single source of truth for
the clip box's pose and dimensions: translation = box centre,
rotation = box orientation, per-axis scale = world half-extents. The
visible cube comes from the empty's CUBE display.
Only ``is_clip_box`` lives here; visibility (``enabled``) and overlay
(``show_caps``) are global per-file and live on the Scene PG.
"""
is_clip_box: bpy.props.BoolProperty(
default=False,
description="True when this empty was created as a clip-box host. Internal flag; not user-edited.",
)
if TYPE_CHECKING:
is_clip_box: bool
def update_active_clip_box_index(self, context):
tool.ClipBox.schedule_refresh()
tool.ClipBox.select_active_clip_box(context)
# Rebuild caps for the new active box's clip volume.
tool.ClipBox.invalidate_cap_cache(immediate=True)
def update_show_caps(self, context):
tool.ClipBox.schedule_refresh()
# Off → on must trigger a rebuild so caps reappear immediately rather
# than wait for the next depsgraph tick. The rebuild is a no-op when
# show_caps is now False (it clears and returns), so this is safe in
# both directions.
tool.ClipBox.invalidate_cap_cache()
def update_enabled(self, context):
tool.ClipBox.schedule_refresh()
def update_clip_only_ifc_products(self, context):
# The eligibility set for capping changed — drop the cache and let the
# debounced rebuild pick up the new objects on the next idle tick.
tool.ClipBox.invalidate_cap_cache()
def update_include_linked_ifc(self, context):
tool.ClipBox.invalidate_cap_cache()
class BIMSceneClipBoxProperties(PropertyGroup):
"""Scene-level registry of clip boxes in this file.
Multiple boxes may exist; ``active_clip_box_index`` selects which one
drives the viewport clip at any time. ``enabled`` and ``show_caps``
are global because the user's intent ("hide everything outside the
box", "draw cap overlays") applies file-wide, not per box.
``enabled`` is intentionally not persisted to the project pset:
opening a fresh IFC should never silently hide geometry behind a
remembered toggle. Selecting any clip-box empty in the viewport
re-arms it (see :meth:`tool.ClipBox._sync_active_to_selection`).
"""
clip_boxes: bpy.props.CollectionProperty(type=ObjProperty)
active_clip_box_index: bpy.props.IntProperty(
default=0,
min=0,
update=update_active_clip_box_index,
description="Index of the clip box currently driving the viewport clip planes",
)
enabled: bpy.props.BoolProperty(
name="Enabled",
default=False,
update=update_enabled,
description="When enabled, the active clip box hides all viewport geometry outside its 6 faces",
)
show_caps: bpy.props.BoolProperty(
name="Show Caps",
default=True,
update=update_show_caps,
description=(
"Draw filled cross-section caps where IFC product geometry "
"crosses the active clip planes. Disable for performance on "
"very heavy scenes"
),
)
# Stored on the Scene PG so Blender persists it in the .blend; deliberately
# NOT written to the project pset so the IFC stays portable across users
# who may have different Blender-side reference geometry to clip.
clip_only_ifc_products: bpy.props.BoolProperty(
name="Only IFC Products",
default=True,
update=update_clip_only_ifc_products,
description=(
"When enabled, only IFC element geometry gets cross-section caps. "
"Disable to also cap Blender-side reference meshes (sketches, "
"imported obj, primitive cubes, …)"
),
)
# Opt-in inclusion of geometry sitting inside loaded Project Links
# collection-instance empties. Off by default — linked IFCs commonly
# carry the entire site / structural / MEP context, and bisecting
# them on every clip-box edit can be expensive.
include_linked_ifc: bpy.props.BoolProperty(
name="Include Linked IFC",
default=False,
update=update_include_linked_ifc,
description=(
"Also generate cross-section caps for geometry inside linked "
"IFC files (Project ▸ Links). Off by default — linked IFCs may "
"carry the entire site / structural backbone, and capping them "
"adds per-mesh bisect cost on every clip-box edit"
),
)
# Also Scene-only — gizmo visibility is a per-user editing preference,
# not a portable IFC property.
enable_gizmos: bpy.props.BoolProperty(
name="Show Face Handles",
default=True,
description=(
"Show interactive face-resize handles on the active clip box. "
"Disable to fall back to plain G/R/S transforms on the empty"
),
)
if TYPE_CHECKING:
active_clip_box_index: int
enabled: bool
show_caps: bool
clip_only_ifc_products: bool
include_linked_ifc: bool
enable_gizmos: bool
+145
View File
@@ -0,0 +1,145 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# 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
from bpy.types import Menu, Panel, UIList
import bonsai.tool as tool
# Per-kind icon for the source-picker menu. Picked from Blender's built-in
# icon set; semantically close to the kind so users can scan the menu visually.
_SOURCE_MENU_ENTRIES: tuple[tuple[str, str, str], ...] = (
("SPATIAL", "Clip Spatial Element", "OUTLINER_COLLECTION"),
("CLASS", "Clip by Class", "BLANK1"),
("TYPE", "Clip Type", "FILE_3D"),
("MATERIAL", "Clip Material", "MATERIAL"),
("PROFILE", "Clip Profile", "MESH_CIRCLE"),
("DRAWING", "Clip Drawing Extents", "CAMERA_DATA"),
("STATUS", "Clip by Status", "INFO"),
("SYSTEM", "Clip by System", "MOD_FLUID"),
("GROUP", "Clip by Group", "OUTLINER_OB_GROUP_INSTANCE"),
("ZONE", "Clip by Zone", "MOD_LATTICE"),
)
class BIM_MT_clip_box_add_for_source(Menu):
bl_idname = "BIM_MT_clip_box_add_for_source"
bl_label = "Add Clip Box From Source"
def draw(self, context):
layout = self.layout
for kind, label, icon in _SOURCE_MENU_ENTRIES:
op = layout.operator("bim.add_clip_box_for_source", text=label, icon=icon)
op.source_kind = kind
class BIM_MT_clip_box_settings(Menu):
bl_idname = "BIM_MT_clip_box_settings"
bl_label = "Clip Box Settings"
def draw(self, context):
scene_props = tool.ClipBox.get_scene_props(context.scene)
self.layout.prop(scene_props, "clip_only_ifc_products")
self.layout.prop(scene_props, "include_linked_ifc")
self.layout.prop(scene_props, "enable_gizmos")
class BIM_MT_clip_box_info(Menu):
bl_idname = "BIM_MT_clip_box_info"
bl_label = "Clip Box Face Handles"
def draw(self, context):
layout = self.layout
layout.label(text="Face Handles", icon="INFO")
layout.separator()
layout.label(text="Drag a face to resize the clip box on that axis.")
layout.label(text="The opposite face stays fixed (one-sided resize).")
layout.label(text="Ctrl+Click a face to align the viewport to it.")
layout.separator()
layout.label(text="Toggle handles from the Settings (gear) menu.")
class BIM_UL_clip_box(UIList):
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index, flt_flag):
obj = item.obj
row = layout.row(align=True)
if obj is None:
# Host empty was deleted from outliner; still expose the
# remove button so the orphan entry isn't permanent.
row.label(text="(missing)", icon="ERROR")
row.operator("bim.remove_clip_box", text="", icon="X", emboss=False).index = index
return
row.prop(obj, "name", text="", emboss=False, icon="MESH_CUBE")
row.operator("bim.duplicate_clip_box", text="", icon="DUPLICATE", emboss=False).index = index
row.operator("bim.remove_clip_box", text="", icon="X", emboss=False).index = index
class BIM_PT_clip_box(Panel):
bl_idname = "BIM_PT_clip_box"
bl_label = "Clip Box"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "scene"
bl_options = {"DEFAULT_CLOSED"}
bl_parent_id = "BIM_PT_tab_sandbox"
def draw(self, context):
layout = self.layout
scene_props = tool.ClipBox.get_scene_props(context.scene)
toggles = layout.row(align=True)
toggles.scale_y = 2.0
toggles.prop(
scene_props,
"enabled",
text="Enable Clipping",
icon="HIDE_OFF" if scene_props.enabled else "HIDE_ON",
toggle=True,
)
toggles.prop(scene_props, "show_caps", text="Show Caps", icon="MOD_SOLIDIFY", toggle=True)
toggles.menu("BIM_MT_clip_box_settings", icon="PREFERENCES", text="")
toggles.menu("BIM_MT_clip_box_info", icon="INFO", text="")
layout.separator()
row = layout.row(align=True)
row.operator("bim.add_clip_box", icon="ADD", text="Add Clip Box")
row.menu("BIM_MT_clip_box_add_for_source", icon="DOWNARROW_HLT", text="")
layout.template_list(
"BIM_UL_clip_box",
"",
scene_props,
"clip_boxes",
scene_props,
"active_clip_box_index",
rows=3,
)
obj = tool.ClipBox.get_active_clip_box(context.scene)
if obj is None:
layout.label(text="No active clip box", icon="INFO")
return
col = layout.column(align=True)
col.label(text="Edit the empty with G / R / S to move / rotate / resize")
col.prop(obj, "location")
col.prop(obj, "rotation_euler")
col.prop(obj, "scale")
+11 -22
View File
@@ -127,36 +127,25 @@ class ObjectDocumentData:
identification = None
if is_information:
if tool.Ifc.get_schema() == "IFC2X3":
identification = relating_document.DocumentId
else:
identification = relating_document.Identification
identification = tool.Document.get_document_information_id(relating_document)
location = getattr(relating_document, "Location", None)
description = getattr(relating_document, "Description", "No description")
else:
description = relating_document.Description
if tool.Ifc.get_schema() == "IFC2X3":
reference_to_document = relating_document.ReferenceToDocument
if not name and reference_to_document:
name = reference_to_document[0].Name
referenced_document = tool.Document.get_reference_document(relating_document)
identification = relating_document.ItemReference
if not identification and reference_to_document:
identification = reference_to_document[0].DocumentId
location = relating_document.Location
else:
referenced_document = relating_document.ReferencedDocument
if not name and referenced_document:
name = referenced_document.Name
if not name and referenced_document:
name = referenced_document.Name
identification = relating_document.Identification
if not identification and referenced_document:
identification = referenced_document.Identification
identification = tool.Document.get_external_reference_id(relating_document)
if not identification and referenced_document:
identification = tool.Document.get_document_information_id(referenced_document)
location = relating_document.Location
if location is None and referenced_document:
location = referenced_document.Location
location = relating_document.Location
# IFC2X3 IfcDocumentInformation has no Location to fall back to.
if location is None and referenced_document and tool.Ifc.get_schema() != "IFC2X3":
location = referenced_document.Location
location = cls.convert_to_file_uri(location) if location else None
+125 -10
View File
@@ -82,7 +82,15 @@ import math
from collections.abc import Callable, Iterator
from dataclasses import dataclass
from enum import Enum
from typing import Any, ClassVar, Literal, Optional, Protocol, runtime_checkable
from typing import (
TYPE_CHECKING,
Any,
ClassVar,
Literal,
Optional,
Protocol,
runtime_checkable,
)
import blf
import bpy
@@ -105,6 +113,9 @@ from mathutils.kdtree import KDTree
import bonsai.tool as tool
from bonsai.bim.module.drawing.shaders import ExtrusionGuidesShader
if TYPE_CHECKING:
import bmesh
SNAP_POINT_SIZE = 10.0
SNAP_POINT_COLOR = (1.0, 0.5, 0.0, 1.0)
SNAP_MAX_RADIUS = 50.0
@@ -159,6 +170,38 @@ _SPECIAL = {"=", " "} # Formula prefix, spaces
NUMERIC_INPUT_CHARS = _DIGITS | _OPERATORS | _METRIC_UNITS | _IMPERIAL_UNITS | _SPECIAL
def _is_transform_modal_active(context) -> bool:
"""Module-local alias for ``tool.Blender.is_transform_modal_active``.
Preserved as a name so AST scans and call sites in this file stay
decoupled from the helper's home module.
"""
return tool.Blender.is_transform_modal_active(context)
def _hide_all_non_modal_gizmos(group) -> None:
"""Set ``hide = True`` on every gizmo in ``group`` whose own ``is_modal``
is False. Used by parametric ``draw_prepare`` to suppress visible
re-positioning while a transform modal is dragging ``matrix_world``."""
for gz in group.gizmos:
if not getattr(gz, "is_modal", False):
gz.hide = True
def apply_transform_modal_draw_gate(group, context) -> bool:
"""Combined gate for ``draw_prepare`` overrides: hide non-modal gizmos and
return ``True`` when a Blender transform modal is dragging matrix_world.
Returns ``False`` when no transform modal is active so callers can fall
through to their normal positioning logic. ``True`` means the caller must
early-return without touching matrix_basis the hidden gizmos will be
re-shown on the next idle frame once the modal exits."""
if not _is_transform_modal_active(context):
return False
_hide_all_non_modal_gizmos(group)
return True
class GizmoColor(Enum):
"""Color identifiers for dimension gizmos.
@@ -1694,6 +1737,33 @@ def billboarded_at(world_pos: Vector, billboard_rot: Matrix, scale: float = DEFA
return Matrix.Translation(world_pos) @ billboard_rot @ Matrix.Scale(scale, 4)
def billboarded_along_axis(
world_pos: Vector,
billboard_rot: Matrix,
axis_world: Vector,
scale: float = DEFAULT_BILLBOARD_SCALE,
) -> Matrix:
"""Composed matrix_basis like ``billboarded_at`` but with local +X
rotated about the camera-forward axis to align with ``axis_world``
projected onto the screen plane.
The gizmo still faces the camera (local +Z stays along camera-forward),
only its in-plane orientation changes. Falls back to plain
``billboarded_at`` when the axis is near-parallel to the view direction
(no usable screen projection)."""
camera_forward = billboard_rot @ Vector((0.0, 0.0, 1.0))
projected = axis_world - camera_forward * axis_world.dot(camera_forward)
if projected.length < 1e-4:
return billboarded_at(world_pos, billboard_rot, scale)
projected.normalize()
y_axis = camera_forward.cross(projected).normalized()
rot = Matrix.Identity(4)
rot[0][:3] = (projected.x, y_axis.x, camera_forward.x)
rot[1][:3] = (projected.y, y_axis.y, camera_forward.y)
rot[2][:3] = (projected.z, y_axis.z, camera_forward.z)
return Matrix.Translation(world_pos) @ rot @ Matrix.Scale(scale, 4)
def get_screen_up(billboard_rot: Matrix) -> Vector:
"""Camera's screen-up direction in world space — local +Y of the billboard
rotation. Use to lift a gizmo above an anchor in a way that stays
@@ -1976,7 +2046,9 @@ class TexturedQuadGizmoMixin(StaticTrisGizmoMixin):
def setup(self) -> None:
super().setup()
from bonsai.bim.module.drawing import gizmo_textures
from bonsai.bim.module.drawing import (
gizmo_textures, # ty: ignore[unresolved-import]
)
self._quad_batch = batch_for_shader(
gizmo_textures.get_shader(),
@@ -1985,7 +2057,9 @@ class TexturedQuadGizmoMixin(StaticTrisGizmoMixin):
)
def draw(self, context: bpy.types.Context) -> None:
from bonsai.bim.module.drawing import gizmo_textures
from bonsai.bim.module.drawing import (
gizmo_textures, # ty: ignore[unresolved-import]
)
texture = gizmo_textures.get_icon_texture(self.icon_name)
if texture is None:
@@ -3255,11 +3329,16 @@ class GizmoArc(StaticTrisGizmoMixin, bpy.types.Gizmo):
"""Static quarter-arc glyph for swing visualisation.
Consumers needing the mirrored (RIGHT) visual apply a flip-X matrix to
``matrix_basis``."""
``matrix_basis``. ``outline_alpha = 0.0`` suppresses the inherited 8-pass
dark halo: an open curve has no enclosed silhouette for the dilation to
ring, so the offset passes read as ghost arcs rather than a uniform
outline. The arc's own cross-section thickness keeps it legible without
the halo."""
bl_idname = "VIEW3D_GT_arc"
__slots__ = ("custom_shape",)
tris = ARC_TRIS_DEFAULT
outline_alpha = 0.0
def _link_toggle_icon_tris(broken: bool) -> tuple[tuple[float, float, float], ...]:
@@ -4786,7 +4865,14 @@ class GizmoDimension(GizmoMovable):
self.init_value = click_distance
if self.initial_snap_state and self.active_obj:
# Schematic gizmos opt out of dimension snap. Force the header
# indicator to ``off`` for the drag's duration so the user sees the
# state matches behaviour; ``exit`` restores ``initial_snap_state``.
# Skipping the snap cache here also avoids the per-drag mesh probe.
snap_supported = getattr(self.gizmo_group, "snap_enabled_on_dimensions", True)
if not snap_supported:
context.scene.tool_settings.use_snap = False
elif self.initial_snap_state and self.active_obj:
build_snap_cache(context, self.active_obj)
self._snap_cache_built = True
@@ -4828,11 +4914,18 @@ class GizmoDimension(GizmoMovable):
if not region or not rv3d:
return {"RUNNING_MODAL"}
tool_settings.use_snap = not self.initial_snap_state if event.ctrl else self.initial_snap_state
# Group-level opt-out: schematic gizmos float in viewport space, so
# global-snap-to-scene-vertices would produce spurious value jumps.
# The fallback (``True``) covers any gizmo whose group is not a
# ``BaseParametricGizmoGroup``.
snap_supported = getattr(self.gizmo_group, "snap_enabled_on_dimensions", True)
if tool_settings.use_snap and not self._snap_cache_built and self.active_obj:
build_snap_cache(context, self.active_obj)
self._snap_cache_built = True
if snap_supported:
tool_settings.use_snap = not self.initial_snap_state if event.ctrl else self.initial_snap_state
if tool_settings.use_snap and not self._snap_cache_built and self.active_obj:
build_snap_cache(context, self.active_obj)
self._snap_cache_built = True
current_coord = (event.mouse_region_x, event.mouse_region_y)
@@ -4856,7 +4949,7 @@ class GizmoDimension(GizmoMovable):
delta = (current_3d - self.start_location).dot(axis_direction)
if tool_settings.use_snap and self.active_obj:
if snap_supported and tool_settings.use_snap and self.active_obj:
# Snap the dimension tip (not mouse position) to target
# Calculate where the dimension tip would be with current delta
# The tip is at: gizmo_origin + axis * (init_value + delta)
@@ -4998,6 +5091,8 @@ class BillboardingGizmoGroupMixin:
self.position_gizmos(context)
def draw_prepare(self, context: bpy.types.Context) -> None:
if apply_transform_modal_draw_gate(self, context):
return
self.position_gizmos(context)
def setup_icon_gizmo(
@@ -5227,6 +5322,13 @@ class BaseParametricGizmoGroup:
# Pre-computed flip matrix for negative value handling (180° rotation around Z)
FLIP_MATRIX = Matrix.Rotation(math.pi, 4, "Z")
# Default: dimension drags respect Blender's global snap (Ctrl-toggleable
# during drag). Subclasses whose dimensions float in viewport space rather
# than aligning to real-world geometry should override to ``False`` —
# snapping to scene vertices in that case produces spurious value jumps
# as the mouse crosses unrelated meshes.
snap_enabled_on_dimensions: bool = True
# === Icon Gizmo Layout (meters) ===
# Icons are positioned in a horizontal row above the element:
# [Validate] [Cancel] [Cycle]
@@ -5652,6 +5754,8 @@ class BaseParametricGizmoGroup:
if preview_base.any_preview_active(context):
return False
if _is_transform_modal_active(context):
return False
if cls.gizmo_pref_name:
prefs = tool.Blender.get_addon_preferences()
if not getattr(prefs.gizmos, cls.gizmo_pref_name, True):
@@ -6416,6 +6520,8 @@ class BaseParametricGizmoGroup:
"""
if not self.is_setup_complete():
return
if apply_transform_modal_draw_gate(self, context):
return
obj = context.active_object
if not obj:
return
@@ -6483,6 +6589,11 @@ class BaseSchematicGizmoGroup(BaseParametricGizmoGroup):
# list and become no-ops. The schematic equivalents below take their place.
dimension_gizmo_props: list[DimensionGizmoConfig] = []
# Schematic dimensions float in billboarded viewport space, not aligned to
# real-world geometry. Snapping the dragged tip to scene vertices would
# produce nonsensical value jumps as the mouse crosses unrelated meshes.
snap_enabled_on_dimensions: bool = False
# Declarative dimension configuration consumed by ``setup_schematic_dimensions``
# and ``update_schematic_dimensions``. Each config produces one
# ``BIM_GT_gizmo_dimension`` instance positioned at a schematic-local
@@ -6622,6 +6733,8 @@ class BaseSchematicGizmoGroup(BaseParametricGizmoGroup):
def draw_prepare(self, context: bpy.types.Context) -> None:
if not self.is_setup_complete():
return
if apply_transform_modal_draw_gate(self, context):
return
obj = context.active_object
if not obj:
return
@@ -7028,6 +7141,8 @@ class BaseIconActionGroup(BillboardingGizmoGroupMixin):
return False
if not tool.Blender.are_viewport_gizmos_enabled():
return False
if _is_transform_modal_active(context):
return False
return cls.is_eligible_object(obj)
def setup(self, context: bpy.types.Context) -> None:
@@ -50,6 +50,9 @@ def set_active_camera_resolution(scene: bpy.types.Scene) -> None:
if camera.type != props.camera_type:
camera.type = props.camera_type
if props.update_props and (drawing := tool.Ifc.get_entity(camera_obj)):
tool.Drawing.sync_perspective_camera_shifts(drawing, camera)
ortho_scale, aspect_ratio = props.get_scale_and_aspect_ratio()
scene_render = scene.render
if (camera.ortho_scale != ortho_scale) or not tool.Cad.is_x(
@@ -189,14 +189,20 @@ 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",
}
unit_length = unit_length_mapping[unit_length]
# 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)
# 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)
+106 -21
View File
@@ -57,7 +57,7 @@ import shapely
from bpy_extras.image_utils import load_image
from bpy_extras.io_utils import ImportHelper
from lxml import etree
from mathutils import Color, Vector
from mathutils import Color, Matrix, Vector
import bonsai.bim.export_ifc
import bonsai.bim.handler
@@ -602,6 +602,7 @@ class CreateDrawing(bpy.types.Operator):
context_type: Literal["body", "annotation"],
drawing_elements: set[ifcopenshell.entity_instance],
target_view: str,
link_matrix: Optional[Matrix] = None,
) -> None:
drawing_elements = drawing_elements.copy()
contexts_: list[list[int]] = getattr(contexts, context_type)
@@ -613,9 +614,19 @@ class CreateDrawing(bpy.types.Operator):
geom_settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS)
geom_settings.set("iterator-output", ifcopenshell.ifcopenshell_wrapper.NATIVE)
if ifc.by_id(context[0]).ContextType == "Plan" and "PLAN_VIEW" in target_view:
is_plan = ifc.by_id(context[0]).ContextType == "Plan" and "PLAN_VIEW" in target_view
z_offset = (0.002 if target_view == "PLAN_VIEW" else -0.002) if is_plan else 0.0
if link_matrix is not None:
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc)
t = link_matrix.to_translation()
offset = (t.x / unit_scale, t.y / unit_scale, t.z / unit_scale + z_offset)
geom_settings.set("model-offset", offset)
q = link_matrix.to_quaternion()
geom_settings.set("model-rotation", (q.x, q.y, q.z, q.w))
elif z_offset:
# A 2mm Z offset to combat Z-fighting in plan or RCPs
geom_settings.set("model-offset", (0.0, 0.0, 0.002 if target_view == "PLAN_VIEW" else -0.002))
geom_settings.set("model-offset", (0.0, 0.0, z_offset))
geom_settings.set("context-ids", context)
it = ifcopenshell.geom.iterator(
@@ -923,11 +934,16 @@ class CreateDrawing(bpy.types.Operator):
bim_props = tool.Blender.get_bim_props()
prefs = tool.Blender.get_addon_preferences()
files = {bim_props.ifc_file: tool.Ifc.get()}
# Map ifc_path → (ifc_file, link_matrix); main file has no link_matrix (None)
files: dict[str, tuple[ifcopenshell.file, Optional[Matrix]]] = {bim_props.ifc_file: (tool.Ifc.get(), None)}
props = tool.Project.get_project_props()
for link in props.get_loaded_links_for_drawings():
files[link.filepath] = self.get_linked_file(link)
try:
link_matrix = tool.Project.calculate_link_matrix(link)
except Exception:
link_matrix = None
files[link.filepath] = (self.get_linked_file(link), link_matrix)
target_view = ifcopenshell.util.element.get_psets(self.camera_element)["EPset_Drawing"]["TargetView"]
self.setup_serialiser(target_view)
@@ -935,7 +951,13 @@ class CreateDrawing(bpy.types.Operator):
tree = ifcopenshell.geom.tree()
tree.enable_face_styles(True)
for ifc_path, ifc in files.items():
# Accumulated across every file in the loop below (main model plus any
# linked models) so the SHAPELY fill pass after the loop covers all of
# them, not just whichever file happened to be processed last.
raycast_objs = set()
elements_with_faces = set()
for ifc_path, (ifc, link_matrix) in files.items():
# Don't use draw.main() just whilst we're prototyping and experimenting
# TODO: hash paths are never used
ifc_hash = hashlib.md5(ifc_path.encode("utf-8")).hexdigest()
@@ -944,13 +966,24 @@ class CreateDrawing(bpy.types.Operator):
self.serialiser.setFile(ifc)
drawing_elements = tool.Drawing.get_drawing_elements(self.camera_element, ifc_file=ifc)
if self.cprops.fill_mode == "SHAPELY":
for element in drawing_elements.copy():
if element.is_a("IfcAnnotation"):
continue
obj = tool.Ifc.get_object(element)
if obj and obj.type == "MESH" and len(obj.data.polygons):
elements_with_faces.add(element.GlobalId)
raycast_objs.add(obj)
# Get all representation contexts to see what we're dealing with.
# Drawings only draw bodies and annotations (and facetation, due to a Revit bug).
# A drawing prioritises a target view context first, followed by a model view context as a fallback.
# Specifically for PLAN_VIEW and REFLECTED_PLAN_VIEW, any Plan context is also prioritised.
contexts = self.get_linework_contexts(ifc, target_view)
self.serialize_contexts_elements(ifc, tree, contexts, "body", drawing_elements, target_view)
self.serialize_contexts_elements(ifc, tree, contexts, "annotation", drawing_elements, target_view)
self.serialize_contexts_elements(ifc, tree, contexts, "body", drawing_elements, target_view, link_matrix)
self.serialize_contexts_elements(
ifc, tree, contexts, "annotation", drawing_elements, target_view, link_matrix
)
if tool.Ifc.get() == ifc and self.camera_element not in drawing_elements:
with profile("Camera element"):
@@ -1017,16 +1050,6 @@ class CreateDrawing(bpy.types.Operator):
# shapely variant
group = root.find("{http://www.w3.org/2000/svg}g")
raycast_objs = set()
elements_with_faces = set()
for element in drawing_elements.copy():
if element.is_a("IfcAnnotation"):
continue
obj = tool.Ifc.get_object(element)
if obj and obj.type == "MESH" and len(obj.data.polygons):
elements_with_faces.add(element.GlobalId)
raycast_objs.add(obj)
projections = root.xpath(
".//svg:g[contains(@class, 'projection')]", namespaces={"svg": "http://www.w3.org/2000/svg"}
)
@@ -1683,6 +1706,12 @@ 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(),
),
)
@@ -2319,7 +2348,10 @@ class ActivateDrawingBase(tool.Ifc.Operator):
bl_description = (
"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"
+ "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"
)
drawing: bpy.props.IntProperty()
@@ -2335,13 +2367,32 @@ class ActivateDrawingBase(tool.Ifc.Operator):
default=False,
options={"SKIP_SAVE"},
)
load_selected_annotations: bpy.props.BoolProperty(
name="Load Selected Annotations",
description="Load the annotations of all selected drawings without switching the active view.",
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
if event.type == "LEFTMOUSE" and event.shift:
@@ -2354,6 +2405,37 @@ class ActivateDrawingBase(tool.Ifc.Operator):
if props.is_editing_drawings == False:
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
return {"FINISHED"}
drawing = tool.Ifc.get().by_id(self.drawing)
dprops = tool.Drawing.get_document_props()
@@ -2439,7 +2521,10 @@ class ActivateDrawing(bpy.types.Operator, ActivateDrawingBase):
bl_description = (
"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"
+ "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"
)
@@ -3501,7 +3586,7 @@ class EditSheet(bpy.types.Operator, tool.Ifc.Operator):
if sheet.is_a("IfcDocumentInformation"):
self.document_type = "SHEET"
self.name = sheet.Name
self.identification = sheet.DocumentId if tool.Ifc.get_schema() == "IFC2X3" else sheet.Identification
self.identification = tool.Document.get_document_information_id(sheet)
elif sheet.is_a("IfcDocumentReference") and tool.Drawing.get_reference_description(sheet) == "TITLEBLOCK":
self.document_type = "TITLEBLOCK"
else:
@@ -604,6 +604,7 @@ class BIMCameraProperties(PropertyGroup):
return tool.Blender.get_active_uilist_element(dprops.drawing_styles, self.active_drawing_style_index)
# For now, this JSON dump are all the parameters that determine a camera's "Block representation"
# Perspective camera shift is stored in EPset_Drawing and intentionally excluded here.
# By checking this, you will know whether or not the camera IFC representation needs to be refreshed
def update_representation(self, matrix_world: Matrix) -> bool:
"""Update ``representation`` based on current camera properties and the provided world matrix.
@@ -903,12 +903,8 @@ class SvgWriter:
continue
sheet = tool.Drawing.get_reference_document(sheet_reference)
if sheet:
if tool.Ifc.get_schema() == "IFC2X3":
reference_id = sheet_reference.ItemReference or "-"
sheet_id = sheet.DocumentId or "-"
else:
reference_id = sheet_reference.Identification or "-"
sheet_id = sheet.Identification or "-"
reference_id = tool.Document.get_external_reference_id(sheet_reference) or "-"
sheet_id = tool.Document.get_document_information_id(sheet) or "-"
return (reference_id, sheet_id)
break
return ("-", "-")
@@ -99,6 +99,10 @@ class BIM_PT_camera(Panel):
if props.target_view == "MODEL_VIEW":
row = self.layout.row()
row.prop(props, "camera_type")
if props.camera_type == "PERSP":
row = self.layout.row(align=True)
row.prop(camera_data, "shift_x", text="Camera Shift X/Y:")
row.prop(camera_data, "shift_y", text="")
row = self.layout.row()
row.prop(props, "linework_mode")
@@ -16,8 +16,6 @@
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
from collections.abc import Sequence
import blf
import bpy
import gpu
@@ -25,15 +23,16 @@ import ifcopenshell
import numpy as np
from bpy.types import SpaceView3D
from bpy_extras.view3d_utils import location_3d_to_region_2d
from gpu_extras.batch import batch_for_shader
from mathutils import Matrix, Vector
import bonsai.tool as tool
class ItemDecorator:
is_installed = False
handlers = []
class ItemDecorator(tool.Blender.ViewportDecorator):
draw_methods = (
("draw_text", "POST_PIXEL"),
("draw", "POST_VIEW"),
)
objs: dict[str, dict[str, list]]
obj_is_selected: dict[str, bool]
obj_is_boolean: dict[str, list[ifcopenshell.entity_instance]]
@@ -119,23 +118,6 @@ class ItemDecorator:
"special_edges": special_edges,
}
@classmethod
def uninstall(cls):
for handler in cls.handlers:
try:
SpaceView3D.draw_handler_remove(handler, "WINDOW")
except ValueError:
pass
cls.is_installed = False
def draw_batch(self, shader_type, content_pos, color, indices=None):
if not tool.Blender.validate_shader_batch_data(content_pos, indices):
return
shader = self.line_shader if shader_type == "LINES" else self.shader
batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices)
shader.uniform_float("color", color)
batch.draw(shader)
def draw_text(self, context):
self.addon_prefs = tool.Blender.get_addon_preferences()
selected_elements_color = self.addon_prefs.decorator_color_selected
@@ -163,11 +145,6 @@ class ItemDecorator:
blf.disable(font_id, blf.SHADOW)
def draw(self, context: bpy.types.Context) -> None:
def transparent_color(color: Sequence[float], alpha: float = 0.05) -> list[float]:
color = [i for i in color]
color[3] = alpha
return color
self.addon_prefs = tool.Blender.get_addon_preferences()
selected_elements_color = self.addon_prefs.decorator_color_selected
unselected_elements_color = self.addon_prefs.decorator_color_unselected
@@ -197,15 +174,33 @@ class ItemDecorator:
if context.mode != "OBJECT":
continue
self.draw_batch("LINES", data["verts"], selected_elements_color, data["edges"])
self.draw_batch("TRIS", data["verts"], transparent_color(selected_elements_color), data["tris"])
self.draw_batch(
"TRIS",
data["verts"],
tool.Blender.transparent_color(selected_elements_color, alpha=0.05),
data["tris"],
)
self.draw_batch("LINES", data["special_verts"], selected_elements_color, data["special_edges"])
elif self.obj_is_boolean[obj_name]:
self.draw_batch("LINES", data["verts"], special_elements_color, data["edges"])
self.draw_batch("TRIS", data["verts"], transparent_color(special_elements_color), data["tris"])
self.draw_batch(
"TRIS",
data["verts"],
tool.Blender.transparent_color(special_elements_color, alpha=0.05),
data["tris"],
)
self.draw_batch("LINES", data["special_verts"], special_elements_color, data["special_edges"])
else:
self.draw_batch(
"LINES", data["verts"], transparent_color(unselected_elements_color, alpha=0.2), data["edges"]
"LINES",
data["verts"],
tool.Blender.transparent_color(unselected_elements_color, alpha=0.2),
data["edges"],
)
self.draw_batch(
"TRIS",
data["verts"],
tool.Blender.transparent_color(special_elements_color, alpha=0.05),
data["tris"],
)
self.draw_batch("TRIS", data["verts"], transparent_color(special_elements_color), data["tris"])
self.draw_batch("LINES", data["special_verts"], special_elements_color, data["special_edges"])
@@ -546,6 +546,13 @@ class UpdateRepresentation(bpy.types.Operator, tool.Ifc.Operator):
objs = [bpy.data.objects[obj_name]] if obj_name else context.selected_objects
self.file = tool.Ifc.get()
# Tessellated face sets (IfcTriangulatedFaceSet/IfcPolygonalFaceSet) were
# introduced in IFC4 and do not exist in IFC2X3. Catch this early so we
# don't silently fall back to a faceted brep after stripping materials.
if self.ifc_representation_class == "IfcTessellatedFaceSet" and self.file.schema == "IFC2X3":
self.report({"ERROR"}, "Tessellated face sets are not supported in IFC2X3.")
return {"CANCELLED"}
for obj in objs:
# TODO: write unit tests to see how this bulk operation handles
# contradictory ifc_representation_class values and when
@@ -883,6 +890,16 @@ class OverrideDelete(bpy.types.Operator):
# Track aggregates before deleting their parts
aggregates_to_check = self.track_aggregates(objects_to_remove)
# Snapshot the set of IFC entity ids being deleted in this batch so the
# connection-rel cascade inside `delete_ifc_object` can suppress
# partner-side regenerate when the partner is also about to vanish.
batch_being_deleted_ids: set[int] = set()
for obj in objects_to_remove:
if not tool.Blender.is_valid_data_block(obj):
continue
if (entity := tool.Ifc.get_entity(obj)) is not None:
batch_being_deleted_ids.add(entity.id())
clear_active_object = True
for i, obj in enumerate(objects_to_remove, 1):
@@ -924,7 +941,7 @@ class OverrideDelete(bpy.types.Operator):
if tool.Drawing.is_auto_annotation(element):
self.report({"INFO"}, "References cannot be deleted. Exclude the referenced element instead.")
continue
tool.Geometry.delete_ifc_object(obj)
tool.Geometry.delete_ifc_object(obj, batch_being_deleted_ids=batch_being_deleted_ids)
elif tool.Geometry.is_representation_item(obj):
tool.Geometry.delete_ifc_item(obj)
else:
@@ -1023,7 +1040,10 @@ class OverrideDelete(bpy.types.Operator):
pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
if not pset:
continue
array_parents.add(ifc_file.by_guid(pset["Parent"]))
try:
array_parents.add(ifc_file.by_guid(pset["Parent"]))
except RuntimeError:
continue
for array_parent in array_parents:
array_parent_obj = tool.Ifc.get_object(array_parent)
@@ -3166,7 +3186,7 @@ class EnableEditingRepresentationItems(bpy.types.Operator, tool.Ifc.Operator):
product_reps = element.RepresentationMaps
item_aspect = {}
for product_rep in product_reps:
for aspect in product_rep.HasShapeAspects:
for aspect in getattr(product_rep, "HasShapeAspects", ()):
for aspect_rep in aspect.ShapeRepresentations:
if aspect_rep.ContextOfItems != representation.ContextOfItems:
continue
+1 -1
View File
@@ -17,9 +17,9 @@
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import bpy
import ifcopenshell.util.unit
from bpy.types import Menu, Panel, UIList
import ifcopenshell.util.unit
import bonsai.bim
import bonsai.tool as tool
from bonsai.bim.helper import prop_with_search
@@ -21,7 +21,6 @@ from math import radians
import blf
import gpu
import ifcopenshell.util.geolocation
from bpy.types import SpaceView3D
from bpy_extras.view3d_utils import location_3d_to_region_2d
from gpu_extras.batch import batch_for_shader
from mathutils import Matrix, Vector
@@ -30,27 +29,11 @@ import bonsai.tool as tool
from bonsai.bim.module.georeference.data import GeoreferenceData
class GeoreferenceDecorator:
is_installed = False
handlers = []
@classmethod
def install(cls, context):
if cls.is_installed:
cls.uninstall()
handler = cls()
cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_text, (context,), "WINDOW", "POST_PIXEL"))
cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_geometry, (context,), "WINDOW", "POST_VIEW"))
cls.is_installed = True
@classmethod
def uninstall(cls):
for handler in cls.handlers:
try:
SpaceView3D.draw_handler_remove(handler, "WINDOW")
except ValueError:
pass
cls.is_installed = False
class GeoreferenceDecorator(tool.Blender.ViewportDecorator):
draw_methods = (
("draw_text", "POST_PIXEL"),
("draw_geometry", "POST_VIEW"),
)
def draw_batch(self, shader_type, content_pos, color, indices=None, should_scale=True):
if not tool.Blender.validate_shader_batch_data(content_pos, indices):
@@ -197,6 +180,10 @@ class GeoreferenceDecorator:
decorator_color_error = self.addon_prefs.decorator_color_error
gpu.state.blend_set("ALPHA")
# The georef gizmo is a coordinate-system overlay: it must communicate
# orientation regardless of model contents, so depth testing is bypassed.
original_depth_test = gpu.state.depth_test_get()
gpu.state.depth_test_set("ALWAYS")
self.line_shader = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR")
self.line_shader.bind() # required to be able to change uniforms of the shader
@@ -335,6 +322,8 @@ class GeoreferenceDecorator:
self.draw_batch("LINES", verts, decorator_color_special, edges)
self.draw_dashed_line(location * 3, location * 6, decorator_color_error)
gpu.state.depth_test_set(original_depth_test)
def draw_dashed_line(self, start, end, colour, should_scale=True):
direction = (end - start).normalized()
distance = (end - start).length
+1 -3
View File
@@ -103,9 +103,7 @@ class LibraryReferencesData:
results.append(
{
"id": library.id(),
"identification": (
library.ItemReference if tool.Ifc.get_schema() == "IFC2X3" else library.Identification
),
"identification": tool.Document.get_external_reference_id(library),
"name": library.Name or "Unnamed",
}
)
@@ -20,44 +20,18 @@
import blf
import bpy
import gpu
from bpy.types import SpaceView3D
from bpy_extras.view3d_utils import location_3d_to_region_2d
from gpu_extras.batch import batch_for_shader
from mathutils import Matrix, Vector
import bonsai.tool as tool
from bonsai.bim.module.light.data import SolarData
class SolarDecorator:
is_installed = False
handlers = []
@classmethod
def install(cls, context):
if cls.is_installed:
cls.uninstall()
handler = cls()
cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_text, (context,), "WINDOW", "POST_PIXEL"))
cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_geometry, (context,), "WINDOW", "POST_VIEW"))
cls.is_installed = True
@classmethod
def uninstall(cls):
for handler in cls.handlers:
try:
SpaceView3D.draw_handler_remove(handler, "WINDOW")
except ValueError:
pass
cls.is_installed = False
def draw_batch(self, shader_type, content_pos, color, indices=None):
if not tool.Blender.validate_shader_batch_data(content_pos, indices):
return
shader = self.line_shader if shader_type == "LINES" else self.shader
batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices)
shader.uniform_float("color", color)
batch.draw(shader)
class SolarDecorator(tool.Blender.ViewportDecorator):
draw_methods = (
("draw_text", "POST_PIXEL"),
("draw_geometry", "POST_VIEW"),
)
def draw_text(self, context: bpy.types.Context) -> None:
self.addon_prefs = tool.Blender.get_addon_preferences()
@@ -418,6 +418,13 @@ class ImportQuickFavorites(bpy.types.Operator):
bl_description = "Import operators from Blender's Quick Favorites menu, including their configured properties"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
if bpy.app.version[:2] not in tool.Misc.QuickFavorites.OFFSET_USER_MENUS:
cls.poll_message_set(f"Blender version {bpy.app.version_string} is not supported.")
return False
return True
def execute(self, context) -> set["rna_enums.OperatorReturnItems"]:
props = tool.Misc.get_misc_props()
props.quick_favorites.clear()
+49 -1
View File
@@ -27,12 +27,14 @@ import bonsai.tool as tool
from . import (
array,
covering,
decorator,
door,
external,
grid,
handler,
host_add_opening_gizmo,
mep,
mep_bend_preview,
opening,
product,
profile,
@@ -84,6 +86,7 @@ classes = (
product.SetActiveType,
workspace.Hotkey,
workspace.BIM_MT_add_representation_item,
wall.AddPerpendicularWall,
wall.AddWallsFromSlab,
wall.AlignWall,
wall.CancelEditingWall,
@@ -108,6 +111,9 @@ classes = (
wall.GizmoWallFilletPreview,
wall.GizmoWallFilletReedit,
wall.GizmoWallFilletToggleOpenings,
wall.GizmoPairDisconnect,
wall.GizmoSlabEdition,
wall.GizmoSlabUnjoinWalls,
wall.GizmoWallJoinIntersection,
wall.GizmoWallLinkToggle,
wall.GizmoWallUnjoinSingle,
@@ -118,7 +124,7 @@ classes = (
wall.RotateWall90,
wall.SplitWall,
wall.SplitWallAtCursor,
wall.UnjoinWallPathConnection,
wall.DisconnectElements,
wall.UnjoinWalls,
wall.EnableWallFilletPreview,
wall.FinishWallFilletPreview,
@@ -152,11 +158,14 @@ classes = (
slab.DisableEditingExtrusionProfile,
slab.DisableEditingSketchExtrusionProfile,
slab.AddSlabFromWall,
slab.CancelEditingSlab,
slab.DrawPolylineSlab,
slab.EditExtrusionProfile,
slab.EditSketchExtrusionProfile,
slab.EnableEditingExtrusionProfile,
slab.EnableEditingSketchExtrusionProfile,
slab.EnableEditingSlab,
slab.FinishEditingSlab,
slab.RecalculateSlab,
slab.ResetVertex,
slab.SetArcIndex,
@@ -183,11 +192,16 @@ classes = (
prop.BIMDoorProperties,
prop.BIMRailingProperties,
prop.BIMRoofProperties,
prop.BIMSlabProperties,
prop.BIMWallProperties,
prop.BIMPipeSegmentProperties,
prop.BIMDuctSegmentProperties,
prop.BIMPolylineProperties,
prop.BIMExternalParametricGeometryProperties,
prop.BIMBendPreviewProperties,
prop.BIMWallFilletPreviewProperties,
prop.BIMPreviewProperties,
prop.BIMParametricEditDialogPrefs,
ui.BIM_PT_array,
ui.BIM_PT_stair,
ui.BIM_PT_wall,
@@ -240,9 +254,13 @@ classes = (
railing.CopyRailingParameters,
railing.AddRailing,
railing.CancelEditingRailing,
railing.CycleRailingType,
railing.FinishEditingRailing,
railing.PickRailingTerminalType,
railing.FlipRailingPathOrder,
railing.EnableEditingRailing,
railing.GizmoRailingSchematic,
railing.ToggleRailingUseManualSupports,
railing.CancelEditingRailingPath,
railing.FinishEditingRailingPath,
railing.EnableEditingRailingPath,
@@ -263,6 +281,27 @@ classes = (
mep.MEPAddObstruction,
mep.MEPAddTransition,
mep.MEPAddBend,
mep.MEPRemoveTerminalFitting,
mep.SelectMEPPathMembers,
mep.MEPJoinSegments,
mep_bend_preview.EnableBendPreview,
mep_bend_preview.FinishBendPreview,
mep_bend_preview.CancelBendPreview,
mep_bend_preview.EnableBendPreviewFromBend,
mep_bend_preview.GizmoBendPreview,
mep.EnableEditingPipeSegment,
mep.FinishEditingPipeSegment,
mep.CancelEditingPipeSegment,
mep.EnableEditingDuctSegment,
mep.FinishEditingDuctSegment,
mep.CancelEditingDuctSegment,
mep.ExtendPipeSegmentToCursor,
mep.ExtendDuctSegmentToCursor,
mep.SplitPipeSegmentAtCursor,
mep.SplitDuctSegmentAtCursor,
mep.GizmoPipeSegmentEdition,
mep.GizmoDuctSegmentEdition,
mep.GizmoMEPActions,
external.ApplyExternalParametricGeometry,
)
@@ -322,6 +361,9 @@ def register():
type=prop.BIMExternalParametricGeometryProperties
)
bpy.types.Scene.BIMPreviewProperties = bpy.props.PointerProperty(type=prop.BIMPreviewProperties)
bpy.types.WindowManager.BIMParametricEditDialogPrefs = bpy.props.PointerProperty(
type=prop.BIMParametricEditDialogPrefs
)
bpy.types.VIEW3D_MT_add.prepend(ui.add_menu)
bpy.app.handlers.load_post.append(handler.load_post)
@@ -336,6 +378,11 @@ def unregister():
# half-unloaded module state.
opening.DecorationsHandler.uninstall()
# Network path overlays attach SpaceView3D draw handlers on toggle;
# uninstall here so addon disable / Blender shutdown doesn't leak them.
decorator.MEPSystemPathDecorator.uninstall()
decorator.WallSystemPathDecorator.uninstall()
if not bpy.app.background:
for tool_data in reversed(tools):
bpy.utils.unregister_tool(tool_data.tool)
@@ -347,6 +394,7 @@ def unregister():
tool.Parametric.unregister_object_properties()
del bpy.types.Object.BIMExternalParametricGeometryProperties
del bpy.types.Scene.BIMPreviewProperties
del bpy.types.WindowManager.BIMParametricEditDialogPrefs
bpy.app.handlers.load_post.remove(handler.load_post)
bpy.types.VIEW3D_MT_add.remove(ui.add_menu)
+71 -27
View File
@@ -15,6 +15,8 @@
#
# 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 modified with the assistance of an AI coding tool.
import json
from typing import ClassVar
@@ -327,6 +329,7 @@ class _ArrayEditMixin(ParametricEditMixinBase):
# Unhide the (possibly newly-regenerated) children so the user sees
# the committed result. Mirrors the hide in ``_enable_one``.
cls._set_children_visibility(element, hidden=False)
tool.Array.select_only_parent(obj, context)
@classmethod
def _cancel_one(cls, obj: bpy.types.Object) -> None:
@@ -419,18 +422,28 @@ class RegenerateArray(bpy.types.Operator, tool.Ifc.Operator):
pset = ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array")
arrays = json.loads(pset["Data"])
pset = tool.Ifc.get().by_id(pset["id"])
for array in arrays:
for child in set(array["children"]):
if child_obj := tool.Ifc.get_object(tool.Ifc.get().by_guid(child)):
tool.Geometry.delete_ifc_object(child_obj)
array["children"].clear()
# Always operate on the parent — this operator can be invoked with
# either the parent OR any array child as active_object (the per-child
# gizmo group fires it from a child selection). Using ``obj`` /
# ``element`` directly would feed a child to ``regenerate_array`` and
# constrain children against a sibling, silently corrupting the array.
tool.Model.regenerate_array(parent, arrays)
tool.Array.constrain_children_to_parent(parent_element)
# Coalesce host recuts across the child-delete loop, the regenerate,
# and the per-child opening mirror: each fans out its own host body
# recut without the batch wrapper.
with tool.Geometry.batch_host_recut():
for array in arrays:
for child in set(array["children"]):
try:
child_element = tool.Ifc.get().by_guid(child)
except RuntimeError:
continue
if child_obj := tool.Ifc.get_object(child_element):
tool.Geometry.delete_ifc_object(child_obj)
array["children"].clear()
# Always operate on the parent — this operator can be invoked with
# either the parent OR any array child as active_object (the per-child
# gizmo group fires it from a child selection). Using ``obj`` /
# ``element`` directly would feed a child to ``regenerate_array`` and
# constrain children against a sibling, silently corrupting the array.
tool.Model.regenerate_array(parent, arrays)
tool.Array.constrain_children_to_parent(parent_element)
tool.Array.select_only_parent(parent, context)
class RemoveArray(bpy.types.Operator, tool.Ifc.Operator):
@@ -465,23 +478,24 @@ class RemoveArray(bpy.types.Operator, tool.Ifc.Operator):
except:
return {"FINISHED"}
if self.keep_objs:
tool.Array.bake_children_transform(element, self.item)
tool.Array.set_children_lock_state(element, self.item, False)
with tool.Geometry.batch_host_recut():
if self.keep_objs:
tool.Array.bake_children_transform(element, self.item)
tool.Array.set_children_lock_state(element, self.item, False)
if not self.keep_objs:
data[self.item]["count"] = 1
tool.Array.remove_constraints(parent_element)
tool.Model.regenerate_array(parent, data, array_layers_to_apply=[self.item] if self.keep_objs else [])
if not self.keep_objs:
data[self.item]["count"] = 1
tool.Array.remove_constraints(parent_element)
tool.Model.regenerate_array(parent, data, array_layers_to_apply=[self.item] if self.keep_objs else [])
pset = tool.Pset.get_element_pset(element, "BBIM_Array")
if len(data) == 1:
ifcopenshell.api.pset.remove_pset(tool.Ifc.get(), product=element, pset=pset)
else:
del data[self.item]
data = tool.Ifc.get().createIfcText(json.dumps(data))
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Data": data})
tool.Array.constrain_children_to_parent(element)
pset = tool.Pset.get_element_pset(element, "BBIM_Array")
if len(data) == 1:
ifcopenshell.api.pset.remove_pset(tool.Ifc.get(), product=element, pset=pset)
else:
del data[self.item]
data = tool.Ifc.get().createIfcText(json.dumps(data))
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Data": data})
tool.Array.constrain_children_to_parent(element)
class SelectArrayParent(bpy.types.Operator):
@@ -881,6 +895,36 @@ class EnableEditingParametric(bpy.types.Operator):
default="",
description="Operator bl_idname to invoke (e.g., 'bim.enable_editing_door').",
)
sibling_count: bpy.props.IntProperty(default=0, options={"HIDDEN"})
@staticmethod
def should_show_shared_rep_dialog(*, suppress: bool, has_entity: bool, sibling_count: int) -> bool:
"""Pure decision for the pre-edit warning. Returns ``True`` only when the
edit will silently mutate other elements' geometry AND the user has not
opted out of the warning for this session."""
if suppress or not has_entity:
return False
return sibling_count > 0
def invoke(self, context, event):
prefs = getattr(context.window_manager, "BIMParametricEditDialogPrefs", None)
suppress = bool(prefs and prefs.suppress_shared_rep_warning)
obj = context.active_object
element = tool.Ifc.get_entity(obj) if obj else None
self.sibling_count = tool.Model.get_sibling_occurrence_count(element) if element is not None else 0
if self.should_show_shared_rep_dialog(
suppress=suppress, has_entity=element is not None, sibling_count=self.sibling_count
):
return context.window_manager.invoke_props_dialog(self, width=400)
return self.execute(context)
def draw(self, context):
layout = self.layout
layout.label(text="Shared geometry", icon="ERROR")
layout.label(text=f"Geometry is shared with {self.sibling_count} other element(s).")
layout.label(text="Edits will affect them too.")
prefs = context.window_manager.BIMParametricEditDialogPrefs
layout.prop(prefs, "suppress_shared_rep_warning", text="Don't show this again for this session")
def execute(self, context):
# Malformed ``feature_enable_op`` (missing dot) would otherwise crash
@@ -51,6 +51,10 @@ class AuthoringData:
@classmethod
def load(cls, ifc_element_type: Optional[str] = None):
# ``is_loaded`` is set first as a recursion guard: one of the data
# computations evaluates a PropertyGroup enum's ``items`` callback,
# which re-enters this method. Without the guard, load recurses to
# RecursionError.
cls.is_loaded = True
cls.props = tool.Model.get_model_props()
cls.data["default_container"] = cls.default_container()
File diff suppressed because it is too large Load Diff
@@ -33,6 +33,7 @@ from mathutils import Vector
import bonsai.tool as tool
from bonsai.bim.module.drawing import gizmos as gizmo
from bonsai.bim.module.model.opening import is_filling_supported
from bonsai.bim.module.model.wall import (
_get_wall_geom_cached,
_wall_camera_facing_icon_y,
@@ -52,6 +53,20 @@ def is_supported_host(element) -> bool:
return tool.Parametric.is_path_connectable_wall(element) or element.is_a("IfcSlab") or element.is_a("IfcRoof")
def is_supported_filling_or_opening(element) -> bool:
"""Total predicate for the add-opening gizmo poll. ``None`` (raw Blender
mesh) is accepted because the operator converts unclassified meshes
into ``IfcOpeningElement`` instances. ``IfcOpeningElement`` is accepted
because reassigning an existing opening to a new host is a legal path
through the operator. Otherwise defer to the generator's own
supported-filling predicate."""
if element is None:
return True
if element.is_a("IfcOpeningElement"):
return True
return is_filling_supported(element)
def _resolve_active_host(context: bpy.types.Context, n_selected: int):
"""Shared poll prologue: gizmo gate + selection cardinality + active-in-
selected + IFC entity lookup + supported-host predicate. Returns the
@@ -72,12 +87,14 @@ def _resolve_active_host(context: bpy.types.Context, n_selected: int):
class GizmoHostAddOpening(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin):
"""Activates when a host element (wall / slab / roof) is the active object
and exactly one other selected object is *not* itself a host.
"""Activates when exactly two objects are selected and one is a fillable
host (wall / slab / roof) while the other is a valid filling (door /
window / existing opening, or a plain Blender mesh).
Renders a single ``VIEW3D_GT_add_opening`` icon at the void object's
projected location on the host. A click dispatches ``bim.add_opening``,
which handles any element exposing the ``HasOpenings`` inverse.
Selection-order independent: the host role is identified by class, not
by active state. The "+" icon anchors on the host's surface regardless
of which object was clicked first. The dispatched ``bim.add_opening``
operator also handles either order.
Per-frame positioning keeps the icon facing the camera as the viewport
orbits."""
@@ -90,22 +107,29 @@ class GizmoHostAddOpening(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
element = _resolve_active_host(context, n_selected=2)
if element is None:
if not _wall_gizmo_poll_gate(context):
return False
# The operator itself filters on HasOpenings, but checking here keeps
# the icon from appearing on host classes that can't accept openings
# in the active IFC schema.
if not hasattr(element, "HasOpenings"):
selected = list(tool.Blender.get_selected_objects())
if len(selected) != 2:
return False
active = context.active_object
other = next(o for o in tool.Blender.get_selected_objects() if o is not active)
# Host + host pairings are claimed by host-specific gizmos (wall-join,
# extend-vertical, …) — suppress here so the add-opening icon never
# stacks on top of them.
if is_supported_host(tool.Ifc.get_entity(other)):
if active is None or active not in selected:
return False
return True
a_element = tool.Ifc.get_entity(selected[0])
b_element = tool.Ifc.get_entity(selected[1])
return cls._is_apply_opening_pair(a_element, b_element) or cls._is_apply_opening_pair(b_element, a_element)
@staticmethod
def _is_apply_opening_pair(host_element, filling_element) -> bool:
"""``host_element`` qualifies as a fillable host AND ``filling_element``
qualifies as a filling. Used twice with the operands swapped so the
gizmo polls true regardless of which of the two selected objects is
active."""
if not is_supported_host(host_element):
return False
if not hasattr(host_element, "HasOpenings"):
return False
return is_supported_filling_or_opening(filling_element)
def setup(self, context: bpy.types.Context) -> None:
default_color, highlight_color = self.get_decoration_colors()
@@ -114,18 +138,20 @@ class GizmoHostAddOpening(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin
)
def position_gizmos(self, context: bpy.types.Context) -> None:
host_obj = context.active_object
if not host_obj:
selected = list(tool.Blender.get_selected_objects())
if len(selected) != 2:
return
selected = tool.Blender.get_selected_objects()
other = next((o for o in selected if o is not host_obj), None)
if not other:
return
element = tool.Ifc.get_entity(host_obj)
if not element:
a, b = selected[0], selected[1]
a_element = tool.Ifc.get_entity(a)
b_element = tool.Ifc.get_entity(b)
if is_supported_host(a_element):
host_obj, host_element, other = a, a_element, b
elif is_supported_host(b_element):
host_obj, host_element, other = b, b_element, a
else:
return
if tool.Parametric.is_path_connectable_wall(element):
if tool.Parametric.is_path_connectable_wall(host_element):
world_pos = wall_anchor(context, self, host_obj, other)
else:
world_pos = layer3_anchor(host_obj, other)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,444 @@
# 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.
"""Bend-preview lifecycle for MEP segment joins.
Holds the four lifecycle operators (Enable / Finish / Cancel /
EnableFromBend) and the ``GizmoBendPreview`` group that surfaces the
tunable dimensions and validate/cancel icons during preview. Draft state
lives at ``Scene.BIMPreviewProperties.bend`` per CLAUDE.md §2.9 (Scene
for cross-element previews).
The geometry math (``compute_bend_preview_polylines``,
``_bend_profile_cross_section``, ``_sweep_profile_along_polyline``)
stays in ``mep.py`` because the commit operator ``MEPAddBend`` reuses
it; this module imports the polyline helper for per-frame gizmo
positioning. The GPU lines themselves are drawn by
``decorator.BendPreviewDecorator``, kept in ``decorator.py`` with its
sibling decorators."""
from typing import ClassVar
import bpy
import ifcopenshell.util.element
import ifcopenshell.util.unit
from mathutils import Matrix, Vector
import bonsai.tool as tool
from bonsai.bim.module.drawing import gizmos as gizmo
from bonsai.bim.module.model import preview_base
from bonsai.bim.module.model.mep import (
_is_bend_fitting,
_n_mep_selected,
cached_compute_bend_preview_polylines,
segments_are_parallel,
validate_bend_preconditions,
)
class EnableBendPreview(bpy.types.Operator):
"""Enter bend-preview mode for two selected MEP segments. Populates
scene.BIMPreviewProperties.bend with segment IFC ids and default
start_length / end_length / radius; no IFC mutation until finish."""
bl_idname = "bim.enable_bend_preview"
bl_label = "Enter Bend Preview"
bl_description = "Begin tuning bend parameters before committing the bend"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
if not _n_mep_selected(2):
cls.poll_message_set("Select exactly 2 MEP segments to bend.")
return False
return True
def execute(self, context):
selected = tool.Blender.get_selected_objects()
active = context.active_object
if active is None or active not in selected:
self.report({"ERROR"}, "Active object must be one of the selected MEP segments.")
return {"CANCELLED"}
other = next((o for o in selected if o is not active), None)
if other is None:
self.report({"ERROR"}, "Two MEP segments must be selected.")
return {"CANCELLED"}
active_element = tool.Ifc.get_entity(active)
other_element = tool.Ifc.get_entity(other)
if active_element is None or other_element is None:
self.report({"ERROR"}, "Both selected objects must be IFC elements.")
return {"CANCELLED"}
if segments_are_parallel(active, other):
self.report({"ERROR"}, "Bend preview is for non-parallel segments only.")
return {"CANCELLED"}
# Pre-check the same preconditions MEPAddBend enforces so the user
# sees the rejection here rather than after tuning a doomed preview.
precondition_error = validate_bend_preconditions(active_element, other_element)
if precondition_error is not None:
self.report({"ERROR"}, precondition_error)
return {"CANCELLED"}
preview_base.sync_uncommitted_moves([active, other])
props = preview_base.get_preview_props(context, "bend")
# Auto-cancel any prior preview so re-clicking join on a different
# pair doesn't silently commit the previous tuning.
if props is not None and props.is_active:
bpy.ops.bim.cancel_bend_preview()
props.start_segment_id = active_element.id()
props.end_segment_id = other_element.id()
props.start_length = 0.1
props.end_length = 0.1
props.radius = 0.2
props.is_active = True
return {"FINISHED"}
class FinishBendPreview(bpy.types.Operator):
"""Commit the previewed bend with the tuned parameters and exit preview.
Preview state survives a failed commit so the user can re-tune without
re-selecting."""
bl_idname = "bim.finish_bend_preview"
bl_label = "Apply Bend"
bl_description = "Commit the bend with the previewed parameters"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
return preview_base.commit_preview(
self,
context,
"bend",
"mep_add_bend",
("start_segment_id", "end_segment_id", "start_length", "end_length", "radius", "editing_bend_id"),
)
class CancelBendPreview(bpy.types.Operator):
"""Exit bend preview without committing."""
bl_idname = "bim.cancel_bend_preview"
bl_label = "Cancel Bend"
bl_description = "Discard the previewed bend"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
if context.screen is None:
return {"CANCELLED"}
props = preview_base.get_preview_props(context, "bend")
if props is None or not props.is_active:
return {"CANCELLED"}
preview_base.clear_preview_state(props)
return {"FINISHED"}
class EnableBendPreviewFromBend(bpy.types.Operator):
"""Re-open the bend preview on an existing bend fitting.
Resolves the two connected segments via the bend's ports +
``IfcRelConnectsPorts``, reads parametric values back from the bend's
``BBIM_Fitting`` pset, and flags the preview so committing replaces
the existing bend in place."""
bl_idname = "bim.enable_bend_preview_from_bend"
bl_label = "Edit Bend"
bl_description = "Re-open the bend preview to retune an existing bend"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
active = context.active_object
if active is None:
cls.poll_message_set("No active object.")
return False
element = tool.Ifc.get_entity(active)
if element is None or not _is_bend_fitting(element):
cls.poll_message_set("Active object must be a bend fitting.")
return False
return True
def execute(self, context):
active = context.active_object
bend_element = tool.Ifc.get_entity(active)
if bend_element is None or not _is_bend_fitting(bend_element):
self.report({"ERROR"}, "Active object is not a bend fitting.")
return {"CANCELLED"}
connected_segments: list = []
for port in tool.System.get_ports(bend_element):
connected_port = tool.System.get_connected_port(port)
if connected_port is None:
continue
related = tool.System.get_port_relating_element(connected_port)
if related is not None and related.is_a("IfcFlowSegment") and related not in connected_segments:
connected_segments.append(related)
if len(connected_segments) != 2:
self.report(
{"ERROR"},
f"Bend has {len(connected_segments)} connected segments; need exactly 2 to re-edit.",
)
return {"CANCELLED"}
# Read parametric values from the bend type's BBIM_Fitting pset. The
# type carries the canonical parameters; querying the occurrence
# would force a get_type round-trip and miss user-edited types.
bend_type = ifcopenshell.util.element.get_type(bend_element)
if bend_type is None:
self.report({"ERROR"}, "Bend fitting has no type to read parameters from.")
return {"CANCELLED"}
bend_type_obj = tool.Ifc.get_object(bend_type)
if bend_type_obj is None:
self.report({"ERROR"}, "Bend type has no Blender object — cannot read pset.")
return {"CANCELLED"}
bbim = tool.Model.get_modeling_bbim_pset_data(bend_type_obj, "BBIM_Fitting")
if bbim is None:
self.report({"ERROR"}, "Bend fitting has no BBIM_Fitting pset — not a parametric bend.")
return {"CANCELLED"}
data = bbim.get("data_dict", {})
props = preview_base.get_preview_props(context, "bend")
if props is not None and props.is_active:
bpy.ops.bim.cancel_bend_preview()
# Segment order is load-bearing: the bend's lateral sign and z-axis
# flip are derived from which segment is "start" vs "end". Re-edit
# must reuse the same pairing as the original create so the recreate
# lands at the same orientation.
start_segment, end_segment = connected_segments
props.start_segment_id = start_segment.id()
props.end_segment_id = end_segment.id()
# Pset values are in IFC native units; scene units come from si_conversion.
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
props.start_length = float(data.get("start_length", 0.1)) * si_conversion
props.end_length = float(data.get("end_length", 0.1)) * si_conversion
props.radius = float(data.get("radius", 0.2)) * si_conversion
props.editing_bend_id = bend_element.id()
props.is_active = True
return {"FINISHED"}
def _bend_preview_segments(context):
"""Resolve the two segment objects from the scene-level preview props.
Re-resolves by IFC id each frame so undo / file reload during preview
never dangles a stale bpy reference."""
props = context.scene.BIMPreviewProperties.bend
ifc_file = tool.Ifc.get()
if ifc_file is None or not props.is_active:
return None, None
try:
start_element = ifc_file.by_id(props.start_segment_id)
end_element = ifc_file.by_id(props.end_segment_id)
except Exception:
return None, None
start_obj = tool.Ifc.get_object(start_element) if start_element else None
end_obj = tool.Ifc.get_object(end_element) if end_element else None
return start_obj, end_obj
def _gizmo_x_matrix(location: Vector, x_direction: Vector) -> Matrix:
"""Build a 4x4 matrix placing a gizmo at ``location`` with its local +X
axis aligned to ``x_direction`` in world space. ``BIM_GT_gizmo_dimension``
draws + drags along local +X by convention."""
x = x_direction.normalized()
seed = Vector((0, 0, 1)) if abs(x.z) < 0.9 else Vector((1, 0, 0))
y = (seed - x * seed.dot(x)).normalized()
z = x.cross(y)
mat = Matrix.Identity(4)
mat[0][:3] = (x.x, y.x, z.x)
mat[1][:3] = (x.y, y.y, z.y)
mat[2][:3] = (x.z, y.z, z.z)
mat.translation = location
return mat
class GizmoBendPreview(bpy.types.GizmoGroup):
"""Interactive gizmo group for the bend preview flow.
Three dimension widgets drag start_length / end_length / radius; two
icon gizmos commit or cancel. When the geometry is degenerate the
dimensions and validate hide but cancel stays visible so the user
always has an exit."""
bl_idname = "OBJECT_GGT_bim_bend_preview"
bl_label = "Bend Preview Gizmos"
bl_space_type = "VIEW_3D"
bl_region_type = "WINDOW"
bl_options = {"3D", "PERSISTENT"}
ICON_SCALE: ClassVar[float] = 0.375
ICON_SPACING_X: ClassVar[float] = 0.4
ICON_Z_OFFSET: ClassVar[float] = 1.5
@classmethod
def poll(cls, context):
preview = getattr(context.scene, "BIMPreviewProperties", None)
props = preview.bend if preview is not None else None
if props is None or not props.is_active:
return False
if not tool.Blender.are_viewport_gizmos_enabled():
return False
ifc_file = tool.Ifc.get()
if ifc_file is None:
return False
try:
ifc_file.by_id(props.start_segment_id)
ifc_file.by_id(props.end_segment_id)
except (RuntimeError, KeyError):
return False
return True
def setup(self, context):
prefs = tool.Blender.get_addon_preferences()
default_color = tuple(prefs.decorations_colour[:3])
highlight_color = tuple(prefs.decorator_color_selected[:3])
_props = preview_base.make_props_callback("bend")
def setup_dimension(attr: str, prop_name: str, invert_delta: bool = False) -> bpy.types.Gizmo:
gz = self.gizmos.new("BIM_GT_gizmo_dimension")
gz.move_get_cb = preview_base.make_dim_getter(_props, attr)
gz.move_set_cb = preview_base.make_dim_setter(_props, attr)
gz.axis = Vector((1, 0, 0))
gz.invert_delta = invert_delta
gz.delta_scale = 1.0
gz.prop_name = prop_name
gz.gizmo_group = self
gz.color = default_color
gz.color_highlight = highlight_color
gz.alpha = 1.0
gz.use_draw_modal = True
gz.use_draw_scale = False
gz.text_offset_sign = 1
gz.text_alignment = gizmo.TextAlignment.CENTER
gz.show_start_arrow = False
gz.show_end_arrow = True
gz.show_extension_lines = False
gz.text_formatter = None
return gz
self.start_dim = setup_dimension("start_length", "Start Length")
self.end_dim = setup_dimension("end_length", "End Length")
self.radius_dim = setup_dimension("radius", "Radius")
from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup
self.validate_icon = self.gizmos.new("VIEW3D_GT_validate")
self.validate_icon.use_draw_scale = False
self.validate_icon.color = BaseParametricGizmoGroup.COLOR_GREEN
self.validate_icon.color_highlight = highlight_color
self.validate_icon.target_set_operator("bim.finish_bend_preview")
self.cancel_icon = self.gizmos.new("VIEW3D_GT_cancel")
self.cancel_icon.use_draw_scale = False
self.cancel_icon.color = BaseParametricGizmoGroup.COLOR_RED
self.cancel_icon.color_highlight = highlight_color
self.cancel_icon.target_set_operator("bim.cancel_bend_preview")
def refresh(self, context):
self._position_gizmos(context)
def draw_prepare(self, context):
self._position_gizmos(context)
def _position_gizmos(self, context):
"""Place gizmos at the bend intersection using the current scene
props. Cancel stays visible on degenerate geometry so the user
always has an exit; the other widgets hide when there's no defined
tangent / arc to anchor them on."""
start_obj, end_obj = _bend_preview_segments(context)
if start_obj is None or end_obj is None:
for gz in (self.start_dim, self.end_dim, self.radius_dim, self.validate_icon, self.cancel_icon):
gz.hide = True
return
props = context.scene.BIMPreviewProperties.bend
preview = cached_compute_bend_preview_polylines(
start_obj, end_obj, props.start_length, props.end_length, props.radius
)
if not preview["valid"]:
for gz in (self.start_dim, self.end_dim, self.radius_dim, self.validate_icon):
gz.hide = True
self.cancel_icon.hide = False
axes = preview.get("invalid_axes") or []
if axes:
intersection_point = axes[0][1]
billboard_rot = gizmo.get_billboard_rotation(context)
anchor = intersection_point + Vector((0, 0, self.ICON_Z_OFFSET))
self.cancel_icon.matrix_basis = gizmo.billboarded_at(anchor, billboard_rot, scale=self.ICON_SCALE)
return
for gz in (self.start_dim, self.end_dim, self.radius_dim, self.validate_icon, self.cancel_icon):
gz.hide = False
leg_a_far, leg_a_end = preview["leg_a"]
leg_b_far, leg_b_end = preview["leg_b"]
toward_bend_a = (
(leg_a_end - leg_a_far).normalized() if (leg_a_end - leg_a_far).length > 1e-6 else Vector((0, 0, 1))
)
toward_bend_b = (
(leg_b_end - leg_b_far).normalized() if (leg_b_end - leg_b_far).length > 1e-6 else Vector((0, 0, 1))
)
leg_a_tangent = leg_a_end + toward_bend_a * props.start_length
leg_b_tangent = leg_b_end + toward_bend_b * props.end_length
# axis is set in world space every frame so the drag projection
# matches the visual regardless of either segment's matrix_world.
self.start_dim.matrix_basis = _gizmo_x_matrix(leg_a_tangent, -toward_bend_a)
self.start_dim.axis = -toward_bend_a
self.start_dim.set_dimension_length(props.start_length)
self.end_dim.matrix_basis = _gizmo_x_matrix(leg_b_tangent, -toward_bend_b)
self.end_dim.axis = -toward_bend_b
self.end_dim.set_dimension_length(props.end_length)
arc = preview["arc"]
if len(arc) >= 3:
mid = len(arc) // 2
chord_mid = (arc[0] + arc[-1]) * 0.5
toward_mid = arc[mid] - chord_mid
if toward_mid.length > 1e-6:
toward_mid = toward_mid.normalized()
half_chord = (arc[-1] - arc[0]).length * 0.5
center_dist = max(0.0, props.radius * props.radius - half_chord * half_chord) ** 0.5
arc_center = chord_mid - toward_mid * center_dist
radial_out = arc[mid] - arc_center
if radial_out.length > 1e-6:
radial_out.normalize()
inward = -radial_out
self.radius_dim.matrix_basis = _gizmo_x_matrix(arc[mid], inward)
self.radius_dim.axis = inward
self.radius_dim.set_dimension_length(props.radius)
else:
self.radius_dim.hide = True
else:
self.radius_dim.hide = True
else:
self.radius_dim.hide = True
billboard_rot = gizmo.get_billboard_rotation(context)
anchor_base = arc[len(arc) // 2] if arc else (leg_a_end + leg_b_end) * 0.5
anchor = anchor_base + Vector((0, 0, self.ICON_Z_OFFSET))
offset_x = billboard_rot @ Vector((self.ICON_SPACING_X, 0.0, 0.0))
self.validate_icon.matrix_basis = gizmo.billboarded_at(anchor, billboard_rot, scale=self.ICON_SCALE)
self.cancel_icon.matrix_basis = gizmo.billboarded_at(anchor + offset_x, billboard_rot, scale=self.ICON_SCALE)
+132 -59
View File
@@ -15,6 +15,8 @@
#
# 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 modified with the assistance of an AI coding tool.
from collections.abc import Sequence
from math import radians
@@ -207,6 +209,21 @@ def _get_cached_world_draw_data(
# handle each call), so they stay drawable across frames.
_batch_cache: dict[tuple[int, str], tuple[int, "gpu.types.GPUBatch"]] = {}
# CAD hidden-line convention for the occluded back-pass: world-space dashes so
# density stays coherent across zoom. Dash + gap = period; dash_width controls
# the "on" portion.
_DASH_PERIOD_METERS: float = 0.20
_DASH_WIDTH_METERS: float = 0.10
# Solid front pass is rendered wider than the dashed back pass so its halo
# overpowers the dashed center on visible edges even when the WIRE-display
# overlay biases the depth buffer at outline pixels.
_DASH_LINE_WIDTH: float = 1.5
_SOLID_LINE_WIDTH: float = 2.5
# Per-iteration default line width used by every non-occlusion draw call in
# this decorator's ``__call__``. Restored after each occlusion pair so the
# next draw isn't silently inheriting the wider solid-pass override.
_DEFAULT_LINE_WIDTH: float = 2.0
def _get_cached_batch_or_none(cache_key: tuple[int, str]) -> "gpu.types.GPUBatch | None":
uid = cache_key[0]
@@ -223,6 +240,15 @@ def _store_batch_in_cache(cache_key: tuple[int, str], batch: "gpu.types.GPUBatch
_batch_cache[cache_key] = (epoch, batch)
def is_filling_supported(element) -> bool:
"""True when Bonsai's opening generator can derive an opening from this
element. IFC's schema permits any IfcElement as a filling; Bonsai
currently supports only IfcDoor and IfcWindow because those are the
classes with OverallWidth/OverallHeight attributes (or their types'
ELEVATION_VIEW profiles) that the generator can consume."""
return element is not None and element.is_a() in ("IfcDoor", "IfcWindow")
class FilledOpeningGenerator:
def generate(
self,
@@ -392,18 +418,16 @@ class FilledOpeningGenerator:
representation = tool.Geometry.get_representation_by_context(voided_element, context)
assert representation
bonsai.core.geometry.switch_representation(
tool.Ifc,
tool.Geometry,
obj=voided_obj,
representation=representation,
)
tool.Geometry.recut_host(voided_obj, representation)
def regenerate_from_type(self, usecase_path: str, ifc_file: ifcopenshell.file, settings: dict[str, Any]) -> None:
relating_type = settings["relating_type"]
for related_object in settings["related_objects"]:
self._regenerate_from_type(related_object)
# Filling type-switch on an array of fillings fans out N host recuts —
# one per related object — without batching. Coalesce them.
with tool.Geometry.batch_host_recut():
for related_object in settings["related_objects"]:
self._regenerate_from_type(related_object)
def _regenerate_from_type(self, related_object: ifcopenshell.entity_instance) -> None:
filling = related_object
@@ -452,12 +476,7 @@ class FilledOpeningGenerator:
representation = tool.Geometry.get_active_representation(voided_obj)
if not representation:
continue
bonsai.core.geometry.switch_representation(
tool.Ifc,
tool.Geometry,
obj=voided_obj,
representation=representation,
)
tool.Geometry.recut_host(voided_obj, representation)
def generate_opening_from_filling(
self,
@@ -592,6 +611,31 @@ class RecalculateFill(bpy.types.Operator, tool.Ifc.Operator):
return context.selected_objects
def _execute(self, context):
# N selected fillings × M voided host parts would fire N×M host recuts
# without batching. Coalesce per host.
with tool.Geometry.batch_host_recut():
return self._recalculate_fills(context)
def _recalculate_fills(self, context):
# Refresh each selected filling's mapped opening source before
# recutting the host. Dedup by source id covers the common shared-
# source case in one rewrite while leaving unrelated sibling sources
# untouched.
seen_source_ids: set[int] = set()
for obj in context.selected_objects:
element = tool.Ifc.get_entity(obj)
if not element or not element.FillsVoids:
continue
opening = element.FillsVoids[0].RelatingOpeningElement
body = tool.Geometry.get_body_representation(opening)
if body is None:
continue
source = tool.Geometry.resolve_mapped_representation(body)
if source.id() in seen_source_ids:
continue
seen_source_ids.add(source.id())
tool.Model.regenerate_filling_opening_body(element)
for obj in context.selected_objects:
element = tool.Ifc.get_entity(obj)
if not element or not element.FillsVoids:
@@ -620,12 +664,7 @@ class RecalculateFill(bpy.types.Operator, tool.Ifc.Operator):
if building_obj and building_obj.data:
representation = tool.Geometry.get_active_representation(building_obj)
if representation:
bonsai.core.geometry.switch_representation(
tool.Ifc,
tool.Geometry,
obj=building_obj,
representation=representation,
)
tool.Geometry.recut_host(building_obj, representation)
# Refresh cut decorator
DecoratorData.cut_cache.clear()
@@ -947,27 +986,29 @@ class EditOpenings(Operator, tool.Ifc.Operator):
for opening_element in opening_elements:
opening_obj = tool.Ifc.get_object(opening_element)
similar_openings = bonsai.core.geometry.get_similar_openings(tool.Ifc, opening_element)
similar_openings_building_objs = bonsai.core.geometry.get_similar_openings_building_objs(
tool.Ifc, similar_openings
)
building_objs.update(similar_openings_building_objs)
if opening_obj:
if tool.Ifc.is_edited(opening_obj):
tool.Geometry.run_geometry_update_representation(obj=opening_obj)
bonsai.core.geometry.edit_similar_opening_placement(
tool.Geometry, opening_element, similar_openings
)
elif tool.Ifc.is_moved(opening_obj):
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=opening_obj)
opening_edited = tool.Ifc.is_edited(opening_obj)
opening_moved = tool.Ifc.is_moved(opening_obj)
# Sibling walls only need a viewport-level refresh when the
# opening's shape or placement actually changed — a pure
# show/hide toggle leaves them in their existing state.
if opening_edited or opening_moved:
similar_openings = bonsai.core.geometry.get_similar_openings(tool.Ifc, opening_element)
similar_openings_building_objs = bonsai.core.geometry.get_similar_openings_building_objs(
tool.Ifc, similar_openings
)
building_objs.update(similar_openings_building_objs)
if opening_edited:
tool.Geometry.run_geometry_update_representation(obj=opening_obj)
else:
bonsai.core.geometry.edit_object_placement(
tool.Ifc, tool.Geometry, tool.Surveyor, obj=opening_obj
)
bonsai.core.geometry.edit_similar_opening_placement(
tool.Geometry, opening_element, similar_openings
)
building_objs.update(self.get_all_building_objects_of_similar_openings(opening_element))
building_objs.update(
self.get_all_building_objects_of_similar_openings(opening_element)
) # NB this has nothing to do with clone similar_opening
tool.Ifc.unlink(element=opening_element)
if props.representation_obj == opening_obj:
props.representation_obj = None
@@ -1005,6 +1046,12 @@ class CloneOpening(Operator, tool.Ifc.Operator):
return True
def _execute(self, context):
# The voided host may be an aggregate whose parts each get recut.
# Coalesce per host so a many-parts aggregate doesn't fan out.
with tool.Geometry.batch_host_recut():
return self._clone_opening(context)
def _clone_opening(self, context):
# NOTE: Operator displayed in UI only with IfcOpeningElement being active.
ifc_file = tool.Ifc.get()
objects = bpy.context.selected_objects
@@ -1034,12 +1081,7 @@ class CloneOpening(Operator, tool.Ifc.Operator):
continue
representation = tool.Geometry.get_active_representation(obj)
assert representation
bonsai.core.geometry.switch_representation(
tool.Ifc,
tool.Geometry,
obj=obj,
representation=representation,
)
tool.Geometry.recut_host(obj, representation)
return {"FINISHED"}
@@ -1189,22 +1231,55 @@ class DecorationsHandler:
shader.uniform_float("color", color)
batch.draw(shader)
def _draw_lines_with_occlusion(self, verts, color, edges_indices, occluded_alpha: float = 0.25, cache_key=None):
# One batch, two draws: front pass at full color, occluded pass at
# `occluded_alpha`. Save/restore depth_test matches the pattern in
# bim/module/structural/decorator.py so callers' state survives.
batch = self._get_or_build_batch(self.line_shader, "LINES", verts, edges_indices, cache_key=cache_key)
if batch is None:
def _draw_lines_with_occlusion(self, verts, color, edges_indices, cache_key=None):
# Two-pass CAD hidden-line convention. Both passes use POLYLINE_UNIFORM_COLOR.
#
# The solid front pass is rendered WIDER than the dashed back pass so it
# produces a halo around the line center, beyond the depth-bias zone that
# Blender's overlay engine writes when an opening is set to WIRE display.
# Without the width difference, the wire bias makes the center-pixel
# ``LESS_EQUAL`` comparison fail (line ends up slightly behind the biased
# wire depth) so the solid pass would lose to the dashed back pass even
# on visible edges. The halo gives the solid pass enough screen-space to
# overpower the dashed pattern visually.
#
# Dashed renders first at the standard width so the solid overlay's wider
# halo cleanly hides it on visible edges; on occluded edges the solid
# ``LESS_EQUAL`` pass fails against the wall depth and the dashed remains.
front_batch = self._get_or_build_batch(self.line_shader, "LINES", verts, edges_indices, cache_key=cache_key)
if front_batch is None:
return
dashed_cache_key = (cache_key[0], cache_key[1] + "_dashed") if cache_key is not None else None
dash_batch = None
if dashed_cache_key is not None:
dash_batch = _get_cached_batch_or_none(dashed_cache_key)
if dash_batch is None:
dash_verts, dash_edges = tool.Blender.build_dashed_line_segments(
verts, edges_indices, _DASH_PERIOD_METERS, _DASH_WIDTH_METERS
)
dash_batch = self._get_or_build_batch(self.line_shader, "LINES", dash_verts, dash_edges)
if dash_batch is not None and dashed_cache_key is not None:
_store_batch_in_cache(dashed_cache_key, dash_batch)
original_depth_test = gpu.state.depth_test_get()
front_color = list(color)
front_color[3] = 1.0
self.line_shader.uniform_float("color", front_color)
if dash_batch is not None:
self.line_shader.uniform_float("lineWidth", _DASH_LINE_WIDTH)
gpu.state.depth_test_set("ALWAYS")
dash_batch.draw(self.line_shader)
self.line_shader.uniform_float("lineWidth", _SOLID_LINE_WIDTH)
gpu.state.depth_test_set("LESS_EQUAL")
self.line_shader.uniform_float("color", color)
batch.draw(self.line_shader)
gpu.state.depth_test_set("GREATER")
dimmed = list(color)
dimmed[3] = occluded_alpha
self.line_shader.uniform_float("color", dimmed)
batch.draw(self.line_shader)
front_batch.draw(self.line_shader)
# Restore the per-iteration default set at the top of __call__ so
# subsequent draws (the HalfSpaceSolid arrow, future call-sites) are
# not silently affected by the front-pass width override.
self.line_shader.uniform_float("lineWidth", _DEFAULT_LINE_WIDTH)
gpu.state.depth_test_set(original_depth_test)
def __call__(self, context):
@@ -1240,7 +1315,7 @@ class DecorationsHandler:
self.line_shader.bind() # required to be able to change uniforms of the shader
# POLYLINE_UNIFORM_COLOR specific uniforms
self.line_shader.uniform_float("viewportSize", (context.region.width, context.region.height))
self.line_shader.uniform_float("lineWidth", 2.0)
self.line_shader.uniform_float("lineWidth", _DEFAULT_LINE_WIDTH)
# general shader
self.shader = gpu.shader.from_builtin("UNIFORM_COLOR")
@@ -1278,9 +1353,7 @@ class DecorationsHandler:
self.draw_batch("LINES", verts, selected_elements_color, selected_edges)
self.draw_batch("POINTS", unselected_vertices, unselected_elements_color)
self.draw_batch("POINTS", selected_vertices, selected_elements_color)
obj.data.calc_loop_triangles()
tris = [tuple(t.vertices) for t in obj.data.loop_triangles]
self.draw_batch("TRIS", verts, transparent_color(special_elements_color), tris)
tool.Blender.draw_bmesh_face_tris(bm, verts, transparent_color(special_elements_color), self.draw_batch)
else:
line_verts, verts, edges_indices, tris = _get_cached_world_draw_data(obj)
color = selected_elements_color if obj in context.selected_objects else special_elements_color
@@ -163,6 +163,59 @@ def sync_uncommitted_moves(objects: list) -> None:
tool.Geometry.commit_placement_if_moved(obj, apply_scale=False)
def clear_preview_state(props: bpy.types.PropertyGroup) -> None:
"""Reset a preview PropertyGroup to its idle state on commit / cancel.
Sets ``is_active`` to False and zeros every ``IntProperty`` whose name
ends in ``_id`` (the entity-reference convention every preview follows).
Other fields are left at their last value defaults are re-applied on
the next enable, so leaving them alone avoids a redundant write."""
props.is_active = False
for name, rna in props.bl_rna.properties.items():
if name.endswith("_id") and rna.type == "INT":
setattr(props, name, 0)
# --- Standard Finish flow ----------------------------------------------------
def commit_preview(
operator: bpy.types.Operator,
context: bpy.types.Context,
attr: str,
target_op_name: str,
kwarg_names: tuple[str, ...],
) -> set[str]:
"""Standard Finish-Preview dispatch: validate context + active preview,
read kwargs off the draft, call ``bpy.ops.bim.<target_op_name>(**kwargs)``,
and clear the preview on success.
The dispatched operator's own ``self.report({"ERROR"})`` paths are promoted
by ``bpy.ops`` to ``RuntimeError`` catching it here surfaces the message
to the user via ``operator.report`` rather than leaving Blender's operator
state half-broken (which silently disables downstream gizmo polls).
Returns the dispatched operator's result set verbatim so callers can
pass it straight back from their own ``execute``."""
if context.screen is None:
return {"CANCELLED"}
props = get_preview_props(context, attr)
if props is None or not props.is_active:
return {"CANCELLED"}
if tool.Ifc.get() is None:
operator.report({"ERROR"}, "No IFC file loaded.")
return {"CANCELLED"}
kwargs = {name: getattr(props, name) for name in kwarg_names}
try:
result = getattr(bpy.ops.bim, target_op_name)(**kwargs)
except RuntimeError as exc:
operator.report({"ERROR"}, str(exc))
return {"CANCELLED"}
if "FINISHED" in result:
clear_preview_state(props)
return result
# --- Esc dispatch ------------------------------------------------------------
PREVIEW_CANCEL_OPS: tuple[tuple[str, str], ...] = (
+10 -1
View File
@@ -1002,6 +1002,14 @@ class EnableEditingExtrusionAxis(bpy.types.Operator, tool.Ifc.Operator):
def _execute(self, context):
self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
obj = context.active_object
# Commit any in-progress parametric (gizmo) draft on this object
# before switching to axis-edit. Otherwise the in-memory draft
# state is overwritten when the axis mesh is imported below,
# silently discarding the user's pending dimension edits.
if feature := tool.Parametric.is_object_editing(obj):
tool.Parametric.commit_object_draft(obj, feature.finish_op)
element = tool.Ifc.get_entity(obj)
axis = ifcopenshell.util.representation.get_representation(element, "Model", "Axis", "GRAPH_VIEW")
@@ -1157,7 +1165,8 @@ class DrawPolylineProfile(bpy.types.Operator, PolylineOperator, tool.Ifc.Operato
DumbProfileJoiner().join_V(profile2["obj"], profile1["obj"])
if connect_IfcFlowSegments:
bpy.ops.bim.mep_connect_elements(
obj1_name=profile1["obj"].name, obj2_name=profile2["obj"].name
obj1_guid=tool.Ifc.get_entity(profile1["obj"]).GlobalId,
obj2_guid=tool.Ifc.get_entity(profile2["obj"]).GlobalId,
)
def modal(self, context, event):
+230 -5
View File
@@ -33,8 +33,10 @@ from bonsai.bim.module.drawing.decoration import CutDecorator
from bonsai.bim.module.model.data import AuthoringData
from bonsai.bim.module.model.decorator import (
BoundingBoxDecorator,
MEPSystemPathDecorator,
SlabDirectionDecorator,
WallAxisDecorator,
WallSystemPathDecorator,
)
from bonsai.bim.module.model.door import update_door_modifier_bmesh
from bonsai.bim.module.model.window import update_window_modifier_bmesh
@@ -132,6 +134,19 @@ def update_slab_direction_decorator(self: "BIMModelProperties", context: bpy.typ
SlabDirectionDecorator.uninstall()
def update_paths_decorator(self: "BIMModelProperties", context: bpy.types.Context) -> None:
"""Unified toggle for connected-element path overlays. Drives both the
MEP and wall path decorators each decorator's ``draw`` short-circuits
when its kind of element isn't selected, so leaving both installed is
cheap and lets one toggle cover any connected-element family."""
if self.show_paths:
MEPSystemPathDecorator.install(bpy.context)
WallSystemPathDecorator.install(bpy.context)
else:
MEPSystemPathDecorator.uninstall()
WallSystemPathDecorator.uninstall()
def update_measure_xyz(self: "BIMModelProperties", context: bpy.types.Context) -> None:
if self.show_bounding_box:
BoundingBoxDecorator.install(context)
@@ -228,11 +243,7 @@ def update_wall_offset_baseline(self: "BIMWallProperties", context: bpy.types.Co
def update_railing(self: "BIMRailingProperties", context: bpy.types.Context) -> None:
"""Regenerate railing mesh when property changes."""
if self.is_editing:
# Only FRAMELESS_PANEL can update live via bmesh.
# WALL_MOUNTED_HANDRAIL geometry is generated from IFC representation,
# so it only updates on "Finish Editing" to avoid modifying IFC during preview.
if self.railing_type == "FRAMELESS_PANEL":
_get_updater("railing", "update_railing_modifier_bmesh")(context)
_get_updater("railing", "update_railing_modifier_bmesh")(context)
def update_roof(self: "BIMRoofProperties", context: bpy.types.Context) -> None:
@@ -242,6 +253,20 @@ def update_roof(self: "BIMRoofProperties", context: bpy.types.Context) -> None:
_get_updater("roof", "update_roof_modifier_bmesh")(obj)
def update_pipe_segment(self: "BIMPipeSegmentProperties", context: bpy.types.Context) -> None:
"""Regenerate pipe-segment preview mesh from props during edit. Does NOT touch IFC."""
obj = context.active_object
if obj and self.is_editing:
_get_updater("mep", "regenerate_pipe_segment_mesh_from_props")(obj)
def update_duct_segment(self: "BIMDuctSegmentProperties", context: bpy.types.Context) -> None:
"""Regenerate duct-segment preview mesh from props during edit. Does NOT touch IFC."""
obj = context.active_object
if obj and self.is_editing:
_get_updater("mep", "regenerate_duct_segment_mesh_from_props")(obj)
class BIMModelProperties(PropertyGroup):
ifc_class: bpy.props.EnumProperty(items=get_ifc_class, name="Construction Class", update=update_ifc_class)
relating_type_id: bpy.props.EnumProperty(
@@ -344,6 +369,19 @@ class BIMModelProperties(PropertyGroup):
default=False,
update=update_slab_direction_decorator,
)
show_paths: bpy.props.BoolProperty(
name="Show Paths",
default=False,
update=update_paths_decorator,
description=(
"Trace the connected element path from the selected element. For "
"walls, follows IfcRelConnectsPathElements and draws each "
"connected wall's reference axis with endpoint dots. For MEP "
"elements, follows IfcRelConnectsPorts and draws each segment's "
"axis plus a port-to-port spider for each fitting. Toggle off to "
"skip the BFS traversal entirely."
),
)
prev_transform_orientation_slot_type: bpy.props.StringProperty(name="Previous Gizmo Orientation Type")
prev_show_gizmo_object_translate: bpy.props.BoolProperty(name="Previous Gizmo Translate")
@@ -391,6 +429,7 @@ class BIMModelProperties(PropertyGroup):
offset: float
show_wall_axis: bool
show_slab_direction: bool
show_paths: bool
prev_transform_orientation_slot_type: str
prev_show_gizmo_object_translate: bool
@@ -1679,6 +1718,21 @@ class BIMRoofProperties(PropertyGroup):
setattr(target_props, prop_name, prop_value)
class BIMSlabProperties(PropertyGroup):
"""Transient state for the slab disconnect-access gizmo.
``is_editing`` flips True when the user clicks the pen icon on a slab
that has wall connections gating the per-wall disconnect icons in
``GizmoSlabUnjoinWalls`` so they're hidden until the user opts in. No
IFC draft state lives here: the disconnect operator commits directly,
so this PropertyGroup carries only the UI gate."""
is_editing: bpy.props.BoolProperty(name="Slab Edit Active", default=False, options={"SKIP_SAVE"})
if TYPE_CHECKING:
is_editing: bool
class BIMWallProperties(PropertyGroup):
"""Transient draft state for parametric wall gizmo editing.
@@ -1924,6 +1978,155 @@ class BIMExternalParametricGeometryProperties(bpy.types.PropertyGroup):
sverchok_nodes: Union[sverchok.node_tree.SverchCustomTree, None]
class BIMPipeSegmentProperties(PropertyGroup):
"""Transient draft state for parametric pipe-segment gizmo editing."""
is_editing: bpy.props.BoolProperty(
default=False,
description="True while pipe-segment parametric edit mode is active.",
)
mesh_dirty: bpy.props.BoolProperty(
default=False,
options={"HIDDEN", "SKIP_SAVE"},
description=(
"True while the visible mesh is the preview shape; cleared once the "
"real IFC-derived geometry is restored (on commit or cancel)."
),
)
length: bpy.props.FloatProperty(
name="Length",
default=1.0,
min=0.01,
subtype="DISTANCE",
update=update_pipe_segment,
description="Pipe-segment extrusion length (preview value; committed on finish).",
)
snap_length: bpy.props.FloatProperty(
description="Snapshot of length at edit-enable; commit skips no-op writes.",
)
snap_object_scale_z: bpy.props.FloatProperty(
default=1.0,
description=(
"Snapshot of obj.scale.z at edit-enable. Cancel / no-op-finish restore "
"this exact value so a user's non-identity pre-edit scale isn't silently "
"zeroed by the scale-based preview."
),
)
if TYPE_CHECKING:
is_editing: bool
mesh_dirty: bool
length: float
snap_length: float
snap_object_scale_z: float
class BIMDuctSegmentProperties(PropertyGroup):
"""Transient draft state for parametric duct-segment gizmo editing."""
is_editing: bpy.props.BoolProperty(
default=False,
description="True while duct-segment parametric edit mode is active.",
)
mesh_dirty: bpy.props.BoolProperty(
default=False,
options={"HIDDEN", "SKIP_SAVE"},
description=(
"True while the visible mesh is the preview shape; cleared once the "
"real IFC-derived geometry is restored (on commit or cancel)."
),
)
length: bpy.props.FloatProperty(
name="Length",
default=1.0,
min=0.01,
subtype="DISTANCE",
update=update_duct_segment,
description="Duct-segment extrusion length (preview value; committed on finish).",
)
snap_length: bpy.props.FloatProperty(
description="Snapshot of length at edit-enable; commit skips no-op writes.",
)
snap_object_scale_z: bpy.props.FloatProperty(
default=1.0,
description=(
"Snapshot of obj.scale.z at edit-enable. Cancel / no-op-finish restore "
"this exact value so a user's non-identity pre-edit scale isn't silently "
"zeroed by the scale-based preview."
),
)
if TYPE_CHECKING:
is_editing: bool
mesh_dirty: bool
length: float
snap_length: float
snap_object_scale_z: float
class BIMBendPreviewProperties(PropertyGroup):
"""Scene-level pending state for the bend-creation preview flow.
Scene-level (not per-object) because the bend involves two segments by
IFC id neither alone owns the draft."""
is_active: bpy.props.BoolProperty(
default=False,
options={"SKIP_SAVE"},
description="True while the bend-creation preview flow is active.",
)
start_segment_id: bpy.props.IntProperty(
default=0,
options={"SKIP_SAVE"},
description="IFC element id of the start (active) segment.",
)
end_segment_id: bpy.props.IntProperty(
default=0,
options={"SKIP_SAVE"},
description="IFC element id of the end (other selected) segment.",
)
start_length: bpy.props.FloatProperty(
name="Start Length",
default=0.1,
min=0.001,
subtype="DISTANCE",
description="Length of the bend fitting's tangent leg on the start (active) segment side",
)
end_length: bpy.props.FloatProperty(
name="End Length",
default=0.1,
min=0.001,
subtype="DISTANCE",
description="Length of the bend fitting's tangent leg on the end (other) segment side",
)
radius: bpy.props.FloatProperty(
name="Radius",
default=0.2,
min=0.001,
subtype="DISTANCE",
description="Inner radius of the bend curve",
)
editing_bend_id: bpy.props.IntProperty(
default=0,
options={"SKIP_SAVE"},
description=(
"IFC element id of an existing bend fitting being re-edited "
"(non-zero only on the pen-icon re-edit flow). The create "
"operator deletes this bend + its port connections before "
"recreating with the new parameters."
),
)
if TYPE_CHECKING:
is_active: bool
start_segment_id: int
end_segment_id: int
start_length: float
end_length: float
radius: float
editing_bend_id: int
class BIMWallFilletPreviewProperties(PropertyGroup):
"""Scene-level pending state for the wall-fillet preview flow.
@@ -1980,7 +2183,29 @@ class BIMWallFilletPreviewProperties(PropertyGroup):
class BIMPreviewProperties(PropertyGroup):
"""Umbrella for parametric-edit preview drafts attached to ``Scene``."""
bend: bpy.props.PointerProperty(type=BIMBendPreviewProperties)
wall_fillet: bpy.props.PointerProperty(type=BIMWallFilletPreviewProperties)
if TYPE_CHECKING:
bend: BIMBendPreviewProperties
wall_fillet: BIMWallFilletPreviewProperties
class BIMParametricEditDialogPrefs(PropertyGroup):
"""Session-scoped flag for the parametric-edit pen-icon dispatcher.
Attached to ``WindowManager`` so the state lives for one Blender session
and resets on restart the right scope for "don't show this again for
this session" toggles."""
suppress_shared_rep_warning: bpy.props.BoolProperty(
name="Suppress shared-representation warning",
description=(
"When true, the pen-icon dispatcher skips the shared-geometry "
"confirmation dialog. Resets on Blender restart."
),
default=False,
)
if TYPE_CHECKING:
suppress_shared_rep_warning: bool
+650 -11
View File
@@ -18,6 +18,7 @@
import json
import math
from typing import Any
import bmesh
@@ -27,14 +28,24 @@ import ifcopenshell.api.geometry
import ifcopenshell.api.pset
import ifcopenshell.util.representation
import ifcopenshell.util.unit
from mathutils import Vector
from mathutils import Matrix, Vector
import bonsai.core.geometry
import bonsai.core.root
import bonsai.tool as tool
from bonsai.bim.module.drawing import gizmos as gizmo
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
from bonsai.bim.module.model import prop
from bonsai.bim.module.model.data import RailingData, refresh
from bonsai.bim.module.model.decorator import ProfileDecorator
from bonsai.bim.parametric_lifecycle import PathPreservingEditMixin
from bonsai.bim.parametric_lifecycle import (
CycleTypeMixin,
PathPreservingEditMixin,
PickTypeMixin,
)
from bonsai.tool.cad import WELD_TOLERANCE
V_ = tool.Blender.V_
# reference:
# https://ifc43-docs.standards.buildingsmart.org/IFC/RELEASE/IFC4x3/HTML/lexical/IfcRailing.htm
@@ -125,6 +136,56 @@ def update_bbim_railing_pset(element: ifcopenshell.entity_instance, railing_data
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Data": railing_data})
def generate_wall_mounted_handrail_preview(
obj: bpy.types.Object,
props: "prop.BIMRailingProperties",
path_data: dict[str, Any],
si_conversion: float,
) -> None:
"""Viewport-only WALL_MOUNTED_HANDRAIL preview: rebuild ``obj.data`` from the same
geometry helper the IFC representation builder uses, without writing any IFC."""
railing_path = [Vector(v) * si_conversion for v in path_data["verts"]]
looped_path = path_data["edges"][-1][-1] == path_data["edges"][0][0]
geom = ifcopenshell.api.geometry.compute_wall_mounted_handrail_geometry(
railing_path=railing_path,
support_spacing=props.support_spacing,
railing_diameter=props.railing_diameter,
clear_width=props.clear_width,
height=props.height,
use_manual_supports=props.use_manual_supports,
terminal_type=props.terminal_type,
looped_path=looped_path,
unit_scale=1.0, # props are already SI; bypass the IFC project-units conversion
)
bm = tool.Blender.get_bmesh_for_mesh(obj.data, clean=True)
tool.Cad.sweep_disk_along_polyline(
bm,
[Vector(p) for p in geom.handrail_polyline],
geom.handrail_radius,
arc_indices=geom.handrail_arc_point_indices,
)
for support in geom.supports:
tool.Cad.sweep_disk_along_polyline(
bm,
[Vector(p) for p in support.arc_polyline],
support.arc_radius,
)
tool.Cad.add_disk_extrusion(
bm,
Vector(support.disk_position),
support.disk_radius,
support.disk_depth,
support.disk_z_rotation,
)
bmesh.ops.recalc_face_normals(bm, faces=bm.faces[:])
tool.Blender.apply_bmesh(obj.data, bm)
def update_railing_modifier_bmesh(context: bpy.types.Context) -> None:
"""before using should make sure that Data contains up-to-date information.
If BBIM Pset just changed should call refresh() before updating bmesh
@@ -140,6 +201,13 @@ def update_railing_modifier_bmesh(context: bpy.types.Context) -> None:
path_data = RailingData.data["path_data"]
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
# WALL_MOUNTED_HANDRAIL renders the preview from the compute helper; IFC stays
# untouched until Finish Editing rebuilds the representation.
if not props.is_editing_path and props.railing_type == "WALL_MOUNTED_HANDRAIL":
generate_wall_mounted_handrail_preview(obj, props, path_data, si_conversion)
return
# need to make sure we support edit mode
# since users will probably be in edit mode when they'll be changing railing path
bm = tool.Blender.get_bmesh_for_mesh(obj.data, clean=True)
@@ -165,8 +233,6 @@ def update_railing_modifier_bmesh(context: bpy.types.Context) -> None:
thickness = props.thickness
spacing = props.spacing
# spacing
# split each edge in 3 segments by 0.5 * spacing by x-y plane
main_edges = bm.edges[:]
for main_edge in main_edges:
bm_split_edge_at_offset(main_edge, spacing)
@@ -211,7 +277,7 @@ def update_railing_modifier_bmesh(context: bpy.types.Context) -> None:
bmesh.ops.dissolve_edges(bm, edges=edges_to_dissolve)
bmesh.ops.dissolve_verts(bm, verts=verts_to_dissolve)
# to remove unnecessary verts in 0 spacing case
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.0001)
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=WELD_TOLERANCE)
bmesh.ops.recalc_face_normals(bm, faces=bm.faces[:])
@@ -271,8 +337,8 @@ def get_path_data(obj: bpy.types.Object) -> dict[str, Any]:
segments.append((i - 1, 0))
break
# skip path verts if they just go vertical to avoid errors
if (v.co.xy - prev_v.co.xy).length <= 0.0001:
# Vertical-only segments project to a degenerate XY edge; skip to avoid divide-by-zero downstream.
if (v.co.xy - prev_v.co.xy).length <= WELD_TOLERANCE:
continue
points.append(v.co)
@@ -407,9 +473,8 @@ class CopyRailingParameters(bpy.types.Operator, tool.Ifc.Operator):
class _RailingEditMixin(PathPreservingEditMixin):
"""Type-specific hooks for railing parametric-edit operators. Single-object
(active_object). ``path_data`` is preserved through the edit; the separate
``Enable/Finish/CancelEditingRailingPath`` operators handle path editing."""
"""Single-object (active_object) railing-edit hooks; path_data is preserved
through the edit (path editing is a separate operator family)."""
pset_name = "BBIM_Railing"
@@ -436,7 +501,21 @@ class _RailingEditMixin(PathPreservingEditMixin):
update_railing_modifier_ifc_data(context)
@classmethod
def _update_modifier_bmesh(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
def _restore_viewport_after_cancel(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
"""WALL_MOUNTED_HANDRAIL reloads the committed Body; others rebuild the preview bmesh."""
props = tool.Model.get_railing_props(obj)
if props.railing_type == "WALL_MOUNTED_HANDRAIL":
element = tool.Ifc.get_entity(obj)
assert element
body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
if body:
bonsai.core.geometry.switch_representation(
tool.Ifc,
tool.Geometry,
obj=obj,
representation=body,
)
return
update_railing_modifier_bmesh(context)
@@ -467,6 +546,556 @@ class FinishEditingRailing(_RailingEditMixin, bpy.types.Operator, tool.Ifc.Opera
return self._finish_targets(context)
class CycleRailingType(bpy.types.Operator, tool.Ifc.Operator, CycleTypeMixin):
"""Cycle railing_type (FRAMELESS_PANEL ↔ WALL_MOUNTED_HANDRAIL). Shift+click reverses."""
bl_idname = "bim.cycle_railing_type"
bl_label = "Cycle Railing Type"
bl_options = {"REGISTER", "UNDO"}
element_checker = tool.Parametric.is_railing
props_getter = tool.Model.get_railing_props
type_literal = tool.Model.RailingType
type_attr = "railing_type"
def _execute(self, context: bpy.types.Context) -> set[str]:
return self._cycle_type(context)
class ToggleRailingUseManualSupports(bpy.types.Operator):
"""Flip use_manual_supports on the active WALL_MOUNTED_HANDRAIL railing.
No-op unless a parametric edit is active and the railing is wall-mounted.
"""
bl_idname = "bim.toggle_railing_use_manual_supports"
bl_label = "Toggle Railing Manual Supports"
bl_description = "Switch between automatic support spacing and manual per-vertex placement"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
resolved = tool.Model.resolve_active_props_for_edit(
context,
tool.Model.get_railing_props,
subtype=("railing_type", "WALL_MOUNTED_HANDRAIL"),
)
if resolved is None:
return {"CANCELLED"}
_obj, props = resolved
props.use_manual_supports = not props.use_manual_supports
return {"FINISHED"}
class PickRailingTerminalType(bpy.types.Operator, tool.Ifc.Operator, PickTypeMixin):
"""Pick ``terminal_type`` for the active WALL_MOUNTED_HANDRAIL railing."""
bl_idname = "bim.pick_railing_terminal_type"
bl_label = "Pick Railing Terminal Type"
bl_description = "Pick the cap geometry applied at the rail ends"
bl_options = {"REGISTER", "UNDO"}
skip_element_check = True
props_getter = tool.Model.get_railing_props
type_literal = prop.CapType
type_attr = "terminal_type"
def _execute(self, context: bpy.types.Context) -> set[str]:
if (
tool.Model.resolve_active_props_for_edit(
context,
tool.Model.get_railing_props,
subtype=("railing_type", "WALL_MOUNTED_HANDRAIL"),
)
is None
):
return {"CANCELLED"}
return self._pick_type(context)
def _format_attr_distance(attr_name: str):
"""text_formatter that renders the named property as a distance, ignoring the
dimension's visible-length argument (which is fixed for schematic gizmos)."""
return lambda p, _v: tool.Unit.format_distance(getattr(p, attr_name))
class GizmoRailingSchematic(bpy.types.GizmoGroup, gizmo.BaseSchematicGizmoGroup):
"""Schematic-frame parametric editor for railings. Mutually exclusive with path-edit mode."""
bl_idname = "OBJECT_GGT_bim_railing_edition"
bl_label = "Railing Editing Gizmo"
bl_space_type = "VIEW_3D"
bl_region_type = "WINDOW"
bl_options = {"3D", "PERSISTENT"}
enable_editing_operator = "bim.enable_editing_railing"
finish_editing_operator = "bim.finish_editing_railing"
cancel_editing_operator = "bim.cancel_editing_railing"
cycle_type_operator = "bim.cycle_railing_type"
props_getter = tool.Model.get_railing_props
gizmo_pref_name = "railing"
# Schematic-local layout. +X → screen RIGHT, +Y → screen UP, +Z → toward viewer
# (post billboard rotation). Each dimension is anchored alongside the feature it
# measures so the label, not the bar length, carries the value.
SCHEMATIC_MESH_HEIGHT_FRAC = 0.9 # Mesh top edge in schematic-local +Y
SCHEMATIC_MESH_WIDTH_FRAC = 0.7 # Mesh side edges in schematic-local ±X
SCHEMATIC_MESH_RAIL_Y_FRAC = SCHEMATIC_MESH_HEIGHT_FRAC / 2 # WALL_MOUNTED_HANDRAIL rail centreline
SCHEMATIC_MESH_DEPTH_FRAC = 0.06 # Panel depth — small so the schematic reads as slabs not boxes
# WALL_MOUNTED_HANDRAIL dimensions — fractions of schematic_box_size so they
# scale with the host group's box size.
SCHEMATIC_RAIL_RADIUS_FRAC = 0.05
SCHEMATIC_RAIL_CLEAR_FRAC = 0.5 # Stylised — wider than real-world for visible bracket arm
SCHEMATIC_RAIL_INSET_FRAC = 0.08 # Wall extends past the outermost support on both sides
@classmethod
def schematic_rail_radius(cls) -> float:
return cls.schematic_box_size * cls.SCHEMATIC_RAIL_RADIUS_FRAC
@classmethod
def schematic_rail_clear(cls) -> float:
return cls.schematic_box_size * cls.SCHEMATIC_RAIL_CLEAR_FRAC
# Axonometric 3/4 view: +Z projects down-and-left so the depth axis
# is visibly separated from the back face. Without the X tilt, panel
# thickness (schematic-local Z) collapses to a near-horizontal bar.
schematic_view_rotation = Matrix.Rotation(math.radians(20), 4, "X") @ Matrix.Rotation(math.radians(-25), 4, "Y")
# Hover a dimension → highlight the schematic edges tagged with the matching feature.
# Tags are written by the mesh builders. "spacing" is empty space (no edges) so it's
# absent from this map and gracefully no-ops on hover.
schematic_attr_to_feature = {
"height": "panel_height",
"thickness": "panel_thickness",
"railing_diameter": "rail_tube",
"clear_width": "bracket",
"support_spacing": "bracket",
}
schematic_dimension_props = [
# ── FRAMELESS_PANEL ─────────────────────────────────────────────
DimensionGizmoConfig(
attr_name="height",
axis=(0, 1, 0),
min_value=0.01,
# Gated to FRAMELESS_PANEL: in WALL_MOUNTED_HANDRAIL, height only
# feeds TO_FLOOR / TO_END_POST_AND_FLOOR terminals so dragging it
# is a no-op under the default "180" terminal.
visibility_condition=lambda p: p.railing_type == "FRAMELESS_PANEL",
matrix_position=lambda p: Vector((-GizmoRailingSchematic.SCHEMATIC_MESH_WIDTH_FRAC / 2 - 0.08, 0.0, 0.0)),
schematic_visible_length=SCHEMATIC_MESH_HEIGHT_FRAC,
text_formatter=_format_attr_distance("height"),
),
DimensionGizmoConfig(
attr_name="thickness",
axis=(0, 0, 1), # panel depth — projects to a true depth direction under the 3/4 tilt
min_value=0.005,
visibility_condition=lambda p: p.railing_type == "FRAMELESS_PANEL",
matrix_position=lambda p: Vector(
(
(
-GizmoRailingSchematic.SCHEMATIC_MESH_WIDTH_FRAC / 2
- GizmoRailingSchematic.SCHEMATIC_MESH_GAP_HALF_WIDTH
)
/ 2,
GizmoRailingSchematic.SCHEMATIC_MESH_HEIGHT_FRAC + 0.05,
-GizmoRailingSchematic.SCHEMATIC_MESH_DEPTH_FRAC / 2,
)
),
schematic_visible_length=0.4, # longer than default to survive depth foreshortening
text_formatter=_format_attr_distance("thickness"),
),
DimensionGizmoConfig(
attr_name="spacing",
axis=(1, 0, 0),
min_value=0.0, # zero-spacing collapses the picket gap into a single continuous panel
visibility_condition=lambda p: p.railing_type == "FRAMELESS_PANEL",
matrix_position=lambda p: Vector((0.0, -0.1, 0.0)),
text_formatter=_format_attr_distance("spacing"),
),
# ── WALL_MOUNTED_HANDRAIL ──────────────────────────────────────
DimensionGizmoConfig(
attr_name="railing_diameter",
axis=(0, 1, 0),
min_value=0.001,
visibility_condition=lambda p: p.railing_type == "WALL_MOUNTED_HANDRAIL",
matrix_position=lambda p: Vector(
(
-GizmoRailingSchematic.SCHEMATIC_MESH_WIDTH_FRAC / 2 - 0.05,
GizmoRailingSchematic.SCHEMATIC_MESH_RAIL_Y_FRAC - 0.09,
GizmoRailingSchematic.schematic_rail_clear(),
)
),
text_formatter=_format_attr_distance("railing_diameter"),
),
DimensionGizmoConfig(
attr_name="clear_width",
axis=(0, 0, 1), # +Z is the wall-to-rail perpendicular axis under the 3/4 tilt
min_value=0.001,
visibility_condition=lambda p: p.railing_type == "WALL_MOUNTED_HANDRAIL",
matrix_position=lambda p: Vector(
(
0.0,
GizmoRailingSchematic.SCHEMATIC_MESH_RAIL_Y_FRAC,
0.0,
)
),
schematic_visible_length=0.36, # 2× default so the call-out survives depth projection
text_formatter=_format_attr_distance("clear_width"),
),
DimensionGizmoConfig(
attr_name="support_spacing",
axis=(1, 0, 0),
min_value=0.05,
visibility_condition=lambda p: (p.railing_type == "WALL_MOUNTED_HANDRAIL" and not p.use_manual_supports),
matrix_position=lambda p: Vector(
(
-GizmoRailingSchematic.SCHEMATIC_MESH_WIDTH_FRAC / 2
+ GizmoRailingSchematic.SCHEMATIC_RAIL_INSET_FRAC,
-0.18,
0.0,
)
),
# Bare names (not Gizmo…SCHEMATIC_…) because the class is still under construction here.
schematic_visible_length=SCHEMATIC_MESH_WIDTH_FRAC - 2 * SCHEMATIC_RAIL_INSET_FRAC,
text_formatter=_format_attr_distance("support_spacing"),
),
]
@classmethod
def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool:
return tool.Parametric.is_railing(element)
@classmethod
def schematic_cache_key(cls, props) -> tuple:
"""Cache the schematic mesh by ``railing_type`` — proportions are fixed
per type, so the bmesh build runs at most twice across a session
(once for ``FRAMELESS_PANEL``, once for ``WALL_MOUNTED_HANDRAIL``)
rather than once per draw call."""
return (props.railing_type,)
def setup_element_specific_gizmos(self, context: bpy.types.Context) -> None:
"""Create the WALL_MOUNTED_HANDRAIL-only affordances on the schematic.
Two static lock glyphs (open/closed) for toggling
``use_manual_supports``: instantiate both and let the per-frame state
query pick which one to show. State-aware icons use a static pair
rather than a single dynamic gizmo to avoid ``prop_path`` resolution
in the render path.
Plus a cycle-glyph at the rail end that opens the ``terminal_type``
popup when clicked.
"""
default_color, highlight_color = self.get_decoration_colors()
self.lock_open_gizmo, self.lock_closed_gizmo = self.create_icon_gizmo_lock_pair(
"bim.toggle_railing_use_manual_supports",
open_color=default_color,
)
self.terminal_gizmo = self.gizmos.new("VIEW3D_GT_menu")
self.terminal_gizmo.color = default_color
self.terminal_gizmo.color_highlight = highlight_color
self.terminal_gizmo.use_draw_scale = False
self.terminal_gizmo.alpha = 0.8
self.terminal_gizmo.target_set_operator("bim.pick_railing_terminal_type")
def _refresh_element_specific(self, context: bpy.types.Context, mw: "Matrix", props) -> None:
"""Position and gate the WALL_MOUNTED_HANDRAIL-only gizmos.
- Lock glyphs: only WALL_MOUNTED_HANDRAIL while editing. Show
``lock_open`` when ``use_manual_supports`` is True, the closed
padlock when False ("auto-spacing is locked to support_spacing").
- Terminal gizmo: same gating, positioned just past the right rail
end so it reads as "configure the rail's end cap".
"""
super()._refresh_element_specific(context, mw, props)
# ``draw_prepare`` can fire on a freshly recreated GizmoGroup instance
# before ``setup_element_specific_gizmos`` has populated the lock /
# terminal attributes (Blender 5.x recreates per-region groups on
# reload). Bail out cheaply; the next refresh after setup completes
# will reposition them correctly.
if not hasattr(self, "lock_open_gizmo"):
return
# Single gate for all WALL_MOUNTED_HANDRAIL extras.
active = props.is_editing and not props.is_editing_path and props.railing_type == "WALL_MOUNTED_HANDRAIL"
if not active:
self.lock_open_gizmo.hide = True
self.lock_closed_gizmo.hide = True
self.terminal_gizmo.hide = True
return
billboard_rot = self._frame_billboard_rot
view_rotation = self.schematic_view_rotation
anchor = self._compute_schematic_anchor(props, mw, billboard_rot)
# ── Lock glyphs for use_manual_supports ──────────────────────────
# Sit just above the wall's bottom line, near the centre of the
# schematic — visually grouped with the dimension it controls
# (support_spacing) without overlapping the arrow tail below.
is_manual = bool(props.use_manual_supports)
self.lock_open_gizmo.hide = not is_manual
self.lock_closed_gizmo.hide = is_manual
lock_local = Vector((0.0, 0.05, 0.0))
lock_world = anchor + billboard_rot @ view_rotation @ lock_local
lock_matrix = gizmo.billboarded_at(lock_world, billboard_rot, 0.09)
self.lock_open_gizmo.matrix_basis = lock_matrix
self.lock_closed_gizmo.matrix_basis = lock_matrix
# ── Terminal-type popup gizmo at the right rail end ──────────────
# Pushed well past the right wall edge so the icon doesn't crowd
# the wall outline or the bracket attach point. At rail height and
# rail depth so it reads as "attached to the rail terminal".
self.terminal_gizmo.hide = False
terminal_local = Vector(
(
self.SCHEMATIC_MESH_WIDTH_FRAC / 2 + 0.25,
self.SCHEMATIC_MESH_RAIL_Y_FRAC,
self.schematic_rail_clear(),
)
)
terminal_world = anchor + billboard_rot @ view_rotation @ terminal_local
self.terminal_gizmo.matrix_basis = gizmo.billboarded_at(terminal_world, billboard_rot, 0.18)
def update_editing_gizmos(
self, context: bpy.types.Context, mw: "Matrix", props: "prop.BIMRailingProperties"
) -> None:
"""Hide the pen gizmo while polyline path-edit is active; reposition the cycle icon.
The base class shows the pen gizmo whenever ``is_editing`` is False,
which is the case during path-edit too. Allowing the user to click
through into parametric edit while the polyline mesh is open in EDIT
mode mixes two distinct editing states and leaves a stale draft if
they cancel out block the entry point instead. The operator itself
is intentionally not guarded (callers via scripting can still invoke
it); this is the UX-level enforcement.
The cycle icon defaults to the editing icon row (next to validate /
cancel) via the parent's positioning. We move it to just above the
schematic mesh so it reads as "cycle the railing type *shown here*"
associated with the preview the user is interacting with, not a
generic editing button at the bottom of the schematic.
"""
super().update_editing_gizmos(context, mw, props)
if props.is_editing_path:
self.pen_gizmo.hide = True
if props.is_editing and not props.is_editing_path:
billboard_rot = self._frame_billboard_rot
view_rotation = self.schematic_view_rotation
anchor = self._compute_schematic_anchor(props, mw, billboard_rot)
# Comfortably above the mesh top edge so the icon doesn't crowd
# the ``thickness`` / ``clear_width`` dimension callouts that
# already sit just above the panel/wall.
cycle_local = Vector((0.0, self.SCHEMATIC_MESH_HEIGHT_FRAC + 0.25, 0.0))
world_pos = anchor + billboard_rot @ view_rotation @ cycle_local
# 30% smaller than the editing-icon-row default (0.30 → 0.21):
# the cycle is a tertiary affordance compared to pen/validate/cancel.
self.cycle_gizmo.matrix_basis = gizmo.billboarded_at(world_pos, billboard_rot, 0.21)
@classmethod
def build_schematic_mesh(cls, props) -> "bmesh.types.BMesh":
"""Build a wireframe preview of the railing in schematic-local coordinates.
FRAMELESS_PANEL renders as a box whose proportions track the bound
properties (height / thickness / spacing); WALL_MOUNTED_HANDRAIL
renders as a horizontal tube with two L-shaped supports whose
proportions track railing_diameter / clear_width / support_spacing.
Both are scaled to fit inside ``[-schematic_box_size, +schematic_box_size]``
on each axis so the schematic reads the same regardless of absolute
property values.
The mesh is decorative clicks land on the labeled sliders, not on
the preview geometry. See ``BaseSchematicGizmoGroup`` for the
draw-handler lifecycle.
"""
bm = bmesh.new()
if props.railing_type == "FRAMELESS_PANEL":
cls._build_frameless_panel_schematic(bm, props)
else:
cls._build_wall_mounted_handrail_schematic(bm, props)
return bm
# Schematic-local half-width of the visible gap between the two panel boxes.
# Conveys the "spacing" semantic at a glance — the user sees two pickets
# separated by air, with the spacing dimension emerging from that gap.
SCHEMATIC_MESH_GAP_HALF_WIDTH = 0.05
@classmethod
def _build_frameless_panel_schematic(cls, bm: "bmesh.types.BMesh", props) -> None:
"""Stylised panel: two wireframe boxes with a visible gap between them.
The box edges sit at the ``SCHEMATIC_MESH_*_FRAC`` positions
(matching where the dimension gizmos anchor), so each dimension line
visually starts at the geometry feature it measures. Internal
proportions are stable across drags the actual values are shown
through the dimension labels, while the schematic communicates
which feature each label refers to. The gap between the two boxes
(set by ``SCHEMATIC_MESH_GAP_HALF_WIDTH``) gives the "spacing"
dimension a real visual referent.
Edges are tagged on a string layer so hover-highlight can colour
the geometric feature being measured: vertical edges height,
depth edges thickness. The X-aligned edges along the panel
width are untagged (they don't correspond to a single dimension).
"""
hw = cls.SCHEMATIC_MESH_WIDTH_FRAC / 2
hd = cls.SCHEMATIC_MESH_DEPTH_FRAC / 2
h_top = cls.SCHEMATIC_MESH_HEIGHT_FRAC
gap = cls.SCHEMATIC_MESH_GAP_HALF_WIDTH
layer_name = cls.SCHEMATIC_FEATURE_LAYER_NAME
feat_layer = bm.edges.layers.string.get(layer_name) or bm.edges.layers.string.new(layer_name)
# Edge index → feature tag for one box. Order matches the (a, b)
# tuple order below: bottom ring (4) + top ring (4) + verticals (4).
edge_tags_per_box = (
b"", # (0,1) bottom-back, X-aligned
b"panel_thickness", # (1,2) bottom-right, Z-aligned
b"", # (2,3) bottom-front, X-aligned
b"panel_thickness", # (3,0) bottom-left, Z-aligned
b"", # (4,5) top-back, X-aligned
b"panel_thickness", # (5,6) top-right, Z-aligned
b"", # (6,7) top-front, X-aligned
b"panel_thickness", # (7,4) top-left, Z-aligned
b"panel_height", # (0,4) vertical back-left
b"panel_height", # (1,5) vertical back-right
b"panel_height", # (2,6) vertical front-right
b"panel_height", # (3,7) vertical front-left
)
# Build two separate wireframe boxes — one on each side of the central
# gap. The boxes share the same Y range (0..h_top) and Z range (±hd)
# but split the X range so the gap from -gap to +gap stays empty.
for x_left, x_right in ((-hw, -gap), (gap, hw)):
corners = [
bm.verts.new((x_left, 0.0, -hd)),
bm.verts.new((x_right, 0.0, -hd)),
bm.verts.new((x_right, 0.0, hd)),
bm.verts.new((x_left, 0.0, hd)),
bm.verts.new((x_left, h_top, -hd)),
bm.verts.new((x_right, h_top, -hd)),
bm.verts.new((x_right, h_top, hd)),
bm.verts.new((x_left, h_top, hd)),
]
for tag, (a, b) in zip(
edge_tags_per_box,
(
(0, 1),
(1, 2),
(2, 3),
(3, 0), # bottom ring
(4, 5),
(5, 6),
(6, 7),
(7, 4), # top ring
(0, 4),
(1, 5),
(2, 6),
(3, 7), # vertical edges
),
):
edge = bm.edges.new((corners[a], corners[b]))
if tag:
edge[feat_layer] = tag
@classmethod
def _build_wall_mounted_handrail_schematic(cls, bm: "bmesh.types.BMesh", props) -> None:
"""Stylised wall-mounted handrail: wall outline, hex tube, two L-brackets.
Three visual elements convey "rail mounted on a wall":
- **Wall outline** a wireframe rectangle in the YZ plane at ``z=0``,
extending slightly past the rail ends so the wall reads as a
surface the rail is *attached to* rather than a coincident frame.
- **Handrail tube** a hexagonal cross-section extruded along ±X
at ``z=+clear_s`` (in front of the wall), at ``y=rail_y``.
- **L-shaped brackets** at each rail end from the rail centreline
drop a short distance, then run perpendicular back to the wall
plane. Mirrors the standard wall-mount bracket geometry: a
horizontal arm holding the rail off the wall, a vertical drop
attaching to the rail.
Like ``_build_frameless_panel_schematic``, the schematic uses fixed
proportions so the dimension gizmos' anchor points stay aligned
with the geometry features regardless of property values.
"""
half_len = cls.SCHEMATIC_MESH_WIDTH_FRAC / 2
wall_top = cls.SCHEMATIC_MESH_HEIGHT_FRAC
rail_y = cls.SCHEMATIC_MESH_RAIL_Y_FRAC # rail sits at half wall height
radius_s = cls.schematic_rail_radius()
clear_s = cls.schematic_rail_clear()
layer_name = cls.SCHEMATIC_FEATURE_LAYER_NAME
feat_layer = bm.edges.layers.string.get(layer_name) or bm.edges.layers.string.new(layer_name)
# ── Wall outline (rectangle at z=0, slightly wider than the rail) ──
# Spans the full schematic height; the rail attaches in the middle,
# so the wall reads as "continuing past the rail above and below".
# Wall edges stay untagged — they're background context, not a
# feature any dimension measures.
wall_extra = 0.08
wall_x_left = -half_len - wall_extra
wall_x_right = half_len + wall_extra
wall_corners = [
bm.verts.new((wall_x_left, 0.0, 0.0)),
bm.verts.new((wall_x_right, 0.0, 0.0)),
bm.verts.new((wall_x_right, wall_top, 0.0)),
bm.verts.new((wall_x_left, wall_top, 0.0)),
]
for a, b in ((0, 1), (1, 2), (2, 3), (3, 0)):
bm.edges.new((wall_corners[a], wall_corners[b]))
# ── Handrail tube (hex cross-section in YZ, extruded along X) ──────
# Centred on the rail centreline at (±(half_len - rail_inset),
# rail_y, +clear_s) — in front of the wall plane at z=0. The tube
# is shorter than the wall so the wall visibly extends past it on
# both sides; the L-brackets sit at the tube ends, so the leftmost
# bracket no longer coincides with the wall's left edge.
rail_inset = cls.SCHEMATIC_RAIL_INSET_FRAC
rail_x_left = -half_len + rail_inset
rail_x_right = half_len - rail_inset
segments = 6
ring_left, ring_right = [], []
for i in range(segments):
theta = 2 * math.pi * i / segments
dy = math.cos(theta) * radius_s
dz = math.sin(theta) * radius_s
ring_left.append(bm.verts.new((rail_x_left, rail_y + dy, clear_s + dz)))
ring_right.append(bm.verts.new((rail_x_right, rail_y + dy, clear_s + dz)))
# All hex-tube edges tagged "rail_tube" so they highlight together
# when the railing_diameter dimension is hovered.
for i in range(segments):
j = (i + 1) % segments
e_left = bm.edges.new((ring_left[i], ring_left[j]))
e_right = bm.edges.new((ring_right[i], ring_right[j]))
e_axial = bm.edges.new((ring_left[i], ring_right[i]))
e_left[feat_layer] = b"rail_tube"
e_right[feat_layer] = b"rail_tube"
e_axial[feat_layer] = b"rail_tube"
# ── L-brackets at each rail end (rail → drop → wall) ───────────────
# Bracket attach points follow the rail ends, so they're pulled
# inward by ``rail_inset`` from the wall edges. From the rail
# centreline, drop ``bracket_drop`` in Y, then run perpendicular
# back to the wall plane (z=0). The L shape reads as a wall-mount
# bracket under the 3/4 tilt. Both bracket segments tagged
# "bracket" so they highlight when clear_width OR support_spacing
# is hovered (both dimensions measure features of the supports).
bracket_drop = 0.06
for x in (rail_x_left, rail_x_right):
v_rail = bm.verts.new((x, rail_y, clear_s))
v_corner = bm.verts.new((x, rail_y - bracket_drop, clear_s))
v_wall = bm.verts.new((x, rail_y - bracket_drop, 0.0))
e1 = bm.edges.new((v_rail, v_corner))
e2 = bm.edges.new((v_corner, v_wall))
e1[feat_layer] = b"bracket"
e2[feat_layer] = b"bracket"
class FlipRailingPathOrder(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.flip_railing_path_order"
bl_label = "Flip Railing Path Order"
@@ -510,6 +1139,16 @@ class EnableEditingRailingPath(bpy.types.Operator, tool.Ifc.Operator):
[o.select_set(False) for o in context.selected_objects if o != obj]
assert obj
props = tool.Model.get_railing_props(obj)
# Auto-commit any in-progress parametric draft before switching to
# path-edit. ``set_props_kwargs_from_ifc_data`` a few lines below
# overwrites props with the pset's stored values — without committing
# first, anything the user dragged on a dimension gizmo (height,
# diameter, …) would be silently discarded the moment path-edit
# starts.
if props.is_editing:
tool.Parametric.commit_object_draft(obj, "bim.finish_editing_railing")
data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Railing")["data_dict"]
# required since we could load pset from .ifc and BIMRoofProperties won't be set
props.set_props_kwargs_from_ifc_data(data)
+80 -1
View File
@@ -271,7 +271,7 @@ class DumbSlabPlaner:
# For instances, a 30 degrees angled extrusion with positive direction has the same extrusion direction as a
# -150 degrees angled extrusion with negative direction. The difference lies in the object's rotation.
# This means that things can get messy if the user changes the object x angle somehow. We have to figure out an alternative approach.
existing_x_angle = obj.rotation_euler.x
existing_x_angle = tool.Model.get_existing_x_angle(extrusion)
existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 0, tolerance=0.001) else existing_x_angle
existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, pi, tolerance=0.001) else existing_x_angle
existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 2 * pi, tolerance=0.001) else existing_x_angle
@@ -620,6 +620,14 @@ class EnableEditingExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator):
def _execute(self, context):
self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
obj = context.active_object
# Commit any in-progress parametric (gizmo) draft on this object
# before switching to profile-edit. Otherwise the in-memory draft
# state is overwritten when the profile mesh is imported below,
# silently discarding the user's pending dimension edits.
if feature := tool.Parametric.is_object_editing(obj):
tool.Parametric.commit_object_draft(obj, feature.finish_op)
element = tool.Ifc.get_entity(obj)
body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
@@ -991,3 +999,74 @@ class RecalculateSlab(bpy.types.Operator, tool.Ifc.Operator):
tool.Model.recalculate_walls(walls)
return {"FINISHED"}
class EnableEditingSlab(bpy.types.Operator, tool.Ifc.Operator):
"""Open the slab disconnect-access mode. Pure UI toggle: flips
``obj.BIMSlabProperties.is_editing`` so the per-wall disconnect
gizmos surface on the slab. ``tool.Ifc.Operator`` base because the
parametric framework's universal dispatcher routes through
``tool.Parametric.run_bim_op``, which only accepts that subclass for
undo-safe lifecycle. No IFC mutation."""
bl_idname = "bim.enable_editing_slab"
bl_label = "Edit Slab Connections"
bl_description = "Show disconnect icons for every wall clipped to this slab"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
obj = context.active_object
if obj is None:
return False
element = tool.Ifc.get_entity(obj)
return element is not None and element.is_a("IfcSlab")
def _execute(self, context):
context.active_object.BIMSlabProperties.is_editing = True
return {"FINISHED"}
class CancelEditingSlab(bpy.types.Operator, tool.Ifc.Operator):
"""Close the slab disconnect-access mode."""
bl_idname = "bim.cancel_editing_slab"
bl_label = "Close Slab Edit"
bl_description = "Hide the slab disconnect icons"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
obj = context.active_object
if obj is None:
return False
element = tool.Ifc.get_entity(obj)
return element is not None and element.is_a("IfcSlab")
def _execute(self, context):
context.active_object.BIMSlabProperties.is_editing = False
return {"FINISHED"}
class FinishEditingSlab(bpy.types.Operator, tool.Ifc.Operator):
"""Close the slab disconnect-access mode. Same body as Cancel — slab
edit is a pure UI gate with no IFC draft to commit; the framework
requires both ``bim.finish_editing_<name>`` and
``bim.cancel_editing_<name>`` to exist by name convention."""
bl_idname = "bim.finish_editing_slab"
bl_label = "Finish Slab Edit"
bl_description = "Hide the slab disconnect icons"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
obj = context.active_object
if obj is None:
return False
element = tool.Ifc.get_entity(obj)
return element is not None and element.is_a("IfcSlab")
def _execute(self, context):
context.active_object.BIMSlabProperties.is_editing = False
return {"FINISHED"}
+1 -1
View File
@@ -22,9 +22,9 @@ from collections.abc import Iterable
from typing import TYPE_CHECKING, Any
import bpy
import ifcopenshell.util.unit
from bpy.types import Panel
import ifcopenshell.util.unit
import bonsai.bim
import bonsai.tool as tool
from bonsai.bim.helper import prop_with_search
File diff suppressed because it is too large Load Diff
@@ -963,7 +963,11 @@ class EditObjectUI:
@classmethod
def draw_regen_operations(cls, row, ui_context):
if AuthoringData.data["is_regenable_element"]:
# ``AuthoringData.load`` flips ``is_loaded`` at entry as a recursion
# guard, so a partial load (any computation along the way raising)
# leaves the tail keys unset. ``.get()`` keeps the header draw alive
# until the underlying failure is investigated.
if AuthoringData.data.get("is_regenable_element"):
row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row
add_layout_hotkey_operator(row, "Regen", "S_G", "Recalculate Element Geometry", ui_context)
@@ -1317,7 +1321,7 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator):
bpy.ops.bim.recalculate_profile()
elif self.active_class in ("IfcWindow", "IfcWindowStandardCase", "IfcDoor", "IfcDoorStandardCase"):
bpy.ops.bim.recalculate_fill()
elif self.active_class in ("IfcSpace"):
elif self.active_class in ("IfcSpace",):
bpy.ops.bim.generate_space()
def hotkey_S_M(self):
+7 -64
View File
@@ -20,7 +20,6 @@ import blf
import bpy
import gpu
import ifcopenshell.util.element
from bpy.types import SpaceView3D
from bpy_extras import view3d_utils
from gpu_extras.batch import batch_for_shader
from mathutils import Vector
@@ -28,12 +27,6 @@ from mathutils import Vector
import bonsai.tool as tool
def transparent_color(color, alpha=0.1):
color = [i for i in color]
color[3] = alpha
return color
def create_bounding_box(objs):
# Initialize the bounding box coordinates
min_x, min_y, min_z = float("inf"), float("inf"), float("inf")
@@ -79,26 +72,8 @@ def create_bounding_box(objs):
return indices, edges
class NestDecorator:
is_installed = False
handlers = []
@classmethod
def install(cls, context):
if cls.is_installed:
cls.uninstall()
handler = cls()
cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_nest, (context,), "WINDOW", "POST_VIEW"))
cls.is_installed = True
@classmethod
def uninstall(cls):
for handler in cls.handlers:
try:
SpaceView3D.draw_handler_remove(handler, "WINDOW")
except ValueError:
pass
cls.is_installed = False
class NestDecorator(tool.Blender.ViewportDecorator):
draw_method = "draw_nest"
def dotted_line_shader(self):
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface")
@@ -154,14 +129,6 @@ class NestDecorator:
shader.uniform_float("u_Scale", 25)
batch.draw(shader)
def draw_batch(self, shader_type, content_pos, color, indices=None):
if not tool.Blender.validate_shader_batch_data(content_pos, indices):
return
shader = self.line_shader if shader_type == "LINES" else self.shader
batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices)
shader.uniform_float("color", color)
batch.draw(shader)
def draw_nest(self, context: bpy.types.Context) -> None:
props = tool.Nest.get_nest_props()
if props.in_nest_mode:
@@ -226,35 +193,11 @@ class NestDecorator:
self.draw_custom_batch(line, decorator_color_unselected)
class NestModeDecorator:
is_installed = False
handlers = []
@classmethod
def install(cls, context):
if cls.is_installed:
cls.uninstall()
handler = cls()
cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_nest_name, (context,), "WINDOW", "POST_PIXEL"))
cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_nest_empty, (context,), "WINDOW", "POST_VIEW"))
cls.is_installed = True
@classmethod
def uninstall(cls):
for handler in cls.handlers:
try:
SpaceView3D.draw_handler_remove(handler, "WINDOW")
except ValueError:
pass
cls.is_installed = False
def draw_batch(self, shader_type, content_pos, color, indices=None):
if not tool.Blender.validate_shader_batch_data(content_pos, indices):
return
shader = self.line_shader if shader_type == "LINES" else self.shader
batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices)
shader.uniform_float("color", color)
batch.draw(shader)
class NestModeDecorator(tool.Blender.ViewportDecorator):
draw_methods = (
("draw_nest_name", "POST_PIXEL"),
("draw_nest_empty", "POST_VIEW"),
)
def draw_nest_name(self, context):
if context.mode == "EDIT_MESH":
@@ -21,6 +21,7 @@ import bpy
from . import operator, prop, ui
classes = (
operator.AddIfcPatchPreset,
operator.ExecuteIfcPatch,
operator.ExtractSelectedElements,
operator.RunMigratePatch,
@@ -28,6 +29,7 @@ classes = (
operator.SelectIfcPatchOutput,
operator.UpdateIfcPatchArguments,
prop.BIMPatchProperties,
ui.BIM_MT_ifc_patch_presets,
ui.BIM_PT_patch,
)
+60 -3
View File
@@ -18,11 +18,12 @@
import json
from pathlib import Path
from typing import TYPE_CHECKING, cast
from typing import TYPE_CHECKING
import bpy
import ifcopenshell
import ifcpatch
from bl_operators.presets import AddPresetBase
from bpy_extras.io_utils import ExportHelper, ImportHelper
import bonsai.bim.handler
@@ -77,6 +78,27 @@ class ExecuteIfcPatch(bpy.types.Operator):
return False
return True
def invoke(self, context, event):
# Migrating IFC4 → IFC2X3 is lossy (enum drops, IFC4-only classes
# become IfcBuildingElementProxy, tessellated meshes get rebuilt as
# IfcFacetedBrep). Confirm before running so the user knows.
if tool.Patch.migration_is_lossy_downgrade():
return context.window_manager.invoke_props_dialog(self, width=480)
return self.execute(context)
def draw(self, context):
layout = self.layout
layout.label(text="Downgrading to IFC2X3 is lossy.", icon="ERROR")
column = layout.column(align=True)
column.label(text="Geometry will be preserved as faithfully as possible:")
column.label(text="• IfcIndexedPolyCurve → IfcPolyline (arcs approximated by chords)")
column.label(text="• IfcPolygonalFaceSet / IfcTriangulatedFaceSet → IfcFacetedBrep")
column.separator()
column.label(text="The following information is lost:")
column.label(text="• IFC4-only classes (IfcLamp, IfcPipeSegment, …) → IfcBuildingElementProxy")
column.label(text="• PredefinedType enum values absent from IFC2X3 are dropped")
column.label(text=" (original class + enum saved as ObjectType, e.g. 'IfcLamp/COMPACTFLUORESCENT')")
def execute(self, context):
props = tool.Patch.get_patch_props()
recipe_name = props.ifc_patch_recipes
@@ -100,8 +122,8 @@ class ExecuteIfcPatch(bpy.types.Operator):
if props.should_load_from_memory and tool.Ifc.get():
args["file"] = tool.Ifc.get()
else:
args["input"] = cast(str, props.ifc_patch_input)
args["file"] = cast(ifcopenshell.file, ifcopenshell.open(props.ifc_patch_input))
args["input"] = props.ifc_patch_input
args["file"] = ifcopenshell.open(props.ifc_patch_input)
# Store this in case the patch recipe resets the Blender session, such as by loading a new project.
ifc_patch_output = props.ifc_patch_output or props.ifc_patch_input
@@ -224,3 +246,38 @@ class ExtractSelectedElements(bpy.types.Operator):
query = tool.Search.get_query_for_selected_elements()
props.ifc_patch_args_attr[0].string_value = query
return {"FINISHED"}
class AddIfcPatchPreset(AddPresetBase, bpy.types.Operator):
"""Save / remove ifc-patch argument presets, scoped per recipe.
Presets live in the standard Blender preset directory under
``bonsai/ifc_patch/<recipe>/`` so a preset created for ``ExtractElements``
does not pollute the preset list for ``Migrate``. Persistence across files
and sessions is inherited from Blender's preset system."""
bl_idname = "bim.add_ifc_patch_preset"
bl_label = "Add IFC Patch Preset"
preset_menu = "BIM_MT_ifc_patch_presets"
preset_defines = ["props = bpy.context.scene.BIMPatchProperties"]
@property
def preset_subdir(self) -> str:
return tool.Patch.get_preset_subdir()
@property
def preset_values(self) -> list[str]:
# `Attribute.get_value_name()` returns the storage field for the
# argument's data_type (string_value, bool_value, …). For file
# arguments it returns the wrapping PointerProperty (`filepath_value`)
# — the scalar path the preset needs is `.single_file` on that.
props = tool.Patch.get_patch_props()
values = []
for i, arg in enumerate(props.ifc_patch_args_attr):
field = arg.get_value_name()
if not field:
continue
if arg.data_type == "file":
field = f"{field}.single_file"
values.append(f"props.ifc_patch_args_attr[{i}].{field}")
return values
@@ -71,6 +71,15 @@ def get_ifcpatch_recipes(self: "BIMPatchProperties", context: bpy.types.Context)
def update_ifc_patch_recipe(self: "BIMPatchProperties", context: bpy.types.Context) -> None:
bpy.ops.bim.update_ifc_patch_arguments(recipe=self.ifc_patch_recipes)
# Blender's script.execute_preset mutates the menu class's bl_label to
# the loaded preset's display name (used as a "currently selected"
# indicator). The label persists across recipe changes — making the new
# recipe's menu falsely show the previous recipe's preset name. Reset
# the label to the menu's canonical title so it always matches the
# active recipe's preset list.
menu_cls = getattr(bpy.types, "BIM_MT_ifc_patch_presets", None)
if menu_cls is not None:
menu_cls.bl_label = "IFC Patch Presets"
class BIMPatchProperties(PropertyGroup):
+19
View File
@@ -29,6 +29,20 @@ if TYPE_CHECKING:
from bonsai.bim.prop import Attribute
class BIM_MT_ifc_patch_presets(bpy.types.Menu):
"""Lists ifc-patch presets for the currently selected recipe.
``preset_subdir`` is resolved per draw so switching recipes swaps the
preset list without re-registering the menu."""
bl_label = "IFC Patch Presets"
preset_operator = "script.execute_preset"
def draw(self, context: bpy.types.Context) -> None:
self.preset_subdir = tool.Patch.get_preset_subdir()
bpy.types.Menu.draw_preset(self, context)
class BIM_PT_patch(bpy.types.Panel):
bl_label = "Patch"
bl_idname = "BIM_PT_patch"
@@ -66,6 +80,11 @@ class BIM_PT_patch(bpy.types.Panel):
row.operator("bim.patch_query_from_selected", text="", icon="EYEDROPPER")
if props.ifc_patch_args_attr:
preset_row = layout.row(heading="Preset", align=True)
preset_row.menu("BIM_MT_ifc_patch_presets", text=BIM_MT_ifc_patch_presets.bl_label)
preset_row.operator("bim.add_ifc_patch_preset", text="", icon="ADD")
preset_row.operator("bim.add_ifc_patch_preset", text="", icon="REMOVE").remove_active = True
draw_callback = draw_callback_ if props.ifc_patch_recipes == "ExtractElements" else None
draw_attributes(props.ifc_patch_args_attr, layout, callback=draw_callback)
@@ -18,6 +18,8 @@
import bpy
import bonsai.tool as tool
from . import decorator, gizmo, operator, prop, ui, workspace
classes = (
@@ -28,6 +30,12 @@ classes = (
operator.AppendLibraryElementByQuery,
operator.AssignLibraryDeclaration,
operator.BIM_FH_import_ifc,
operator.BIM_OT_apply_pending_opening_cuts,
operator.BIM_OT_dismiss_multi_instance_warning,
operator.BIM_OT_dismiss_pending_array_repair,
operator.BIM_OT_dismiss_pending_opening_cuts,
operator.BIM_OT_select_pending_array_repair,
operator.BIM_OT_select_pending_opening_cuts,
operator.BIM_OT_load_clipping_planes,
operator.BIM_OT_save_clipping_planes,
operator.ChangeLibraryElement,
@@ -52,6 +60,8 @@ classes = (
operator.LinkIfc,
operator.LoadBlendMetadataAndIFC,
operator.LoadLink,
operator.AutosavePrompt,
operator.LoadAutosavedRecoveryPopup,
operator.LoadLinkedProject,
operator.LoadProject,
operator.LoadProjectElements,
@@ -82,6 +92,8 @@ classes = (
prop.FilterCategory,
prop.Link,
prop.EditedObj,
prop.PendingArrayRepair,
prop.PendingOpeningRecut,
prop.BIMProjectProperties,
prop.MeasureToolSettings,
ui.BIM_MT_new_project,
@@ -128,6 +140,7 @@ 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)
+2 -2
View File
@@ -162,8 +162,8 @@ class ProjectLibraryData:
library_file = IfcStore.library_file
if library_file is None or library_file.schema == "IFC2X3":
return results
project = library_file.by_type("IfcProject")[0]
results.append((str(project.id()), f"IfcProject {project.Name or 'Unnamed'}", project.Description or ""))
root = tool.Project.get_root_context(library_file)
results.append((str(root.id()), f"{root.is_a()} {root.Name or 'Unnamed'}", root.Description or ""))
for library_id, data in cls.data["project_libraries"].items():
results.append((str(library_id), data["Name"] or "Unnamed", data["Description"] or ""))
return results
@@ -42,12 +42,6 @@ def toggle_decorations_on_load(*args):
# as queried object is linked from separate .blend file.
def transparent_color(color, alpha=0.1):
color = [i for i in color]
color[3] = alpha
return color
class ProjectDecorator:
installed = None
@@ -80,11 +74,6 @@ class ProjectDecorator:
unselected_elements_color = self.addon_prefs.decorator_color_unselected
special_elements_color = self.addon_prefs.decorator_color_special
def transparent_color(color, alpha=0.1):
color = [i for i in color]
color[3] = alpha
return color
gpu.state.point_size_set(6)
gpu.state.blend_set("ALPHA")
@@ -110,7 +99,9 @@ class ProjectDecorator:
if geom.selected_edges:
self.draw_batch("LINES", selected_vertices, selected_elements_color, geom.selected_edges)
self.draw_batch("TRIS", selected_vertices, transparent_color(selected_elements_color), geom.selected_tris)
self.draw_batch(
"TRIS", selected_vertices, tool.Blender.transparent_color(selected_elements_color), geom.selected_tris
)
class ClippingPlaneDecorator:
@@ -145,11 +136,6 @@ class ClippingPlaneDecorator:
unselected_elements_color = self.addon_prefs.decorator_color_unselected
special_elements_color = self.addon_prefs.decorator_color_special
def transparent_color(color, alpha=0.1):
color = [i for i in color]
color[3] = alpha
return color
gpu.state.point_size_set(6)
gpu.state.blend_set("ALPHA")
@@ -210,37 +196,21 @@ class ClippingPlaneDecorator:
if unselected_edges:
self.draw_batch("LINES", unselected_vertices, special_elements_color, unselected_edges)
self.draw_batch("TRIS", unselected_vertices, transparent_color(special_elements_color), unselected_tris)
self.draw_batch(
"TRIS", unselected_vertices, tool.Blender.transparent_color(special_elements_color), unselected_tris
)
if selected_edges:
self.draw_batch("LINES", selected_vertices, selected_elements_color, selected_edges)
self.draw_batch("TRIS", selected_vertices, transparent_color(selected_elements_color), selected_tris)
self.draw_batch(
"TRIS", selected_vertices, tool.Blender.transparent_color(selected_elements_color), selected_tris
)
class MeasureDecorator:
is_installed = False
handlers = []
@classmethod
def install(cls, context):
if cls.is_installed:
cls.uninstall()
handler = cls()
cls.handlers.append(
SpaceView3D.draw_handler_add(handler.draw_measurements_text, (context,), "WINDOW", "POST_PIXEL")
)
cls.handlers.append(
SpaceView3D.draw_handler_add(handler.draw_measurements_poly, (context,), "WINDOW", "POST_VIEW")
)
cls.is_installed = True
@classmethod
def uninstall(cls):
for handler in cls.handlers:
try:
SpaceView3D.draw_handler_remove(handler, "WINDOW")
except ValueError:
pass
cls.is_installed = False
class MeasureDecorator(tool.Blender.ViewportDecorator):
draw_methods = (
("draw_measurements_text", "POST_PIXEL"),
("draw_measurements_poly", "POST_VIEW"),
)
def draw_measurements_text(self, context):
PolylineDecorator().select_and_draw_measurements_text(context)
@@ -281,9 +281,9 @@ class RefreshLibrary(bpy.types.Operator):
elements = {e for e in elements if not tool.Project.is_element_assigned_to_project_library(e, rels)}
self.props.add_library_project_library("Unassigned", len(elements), 0, False)
ifc_project = library_file.by_type("IfcProject")[0]
root_context = tool.Project.get_root_context(library_file)
hierarchy = tool.Project.get_project_hierarchy(library_file)
tool.Project.load_project_libraries_to_ui(ifc_project, hierarchy)
tool.Project.load_project_libraries_to_ui(root_context, hierarchy)
return {"FINISHED"}
@@ -763,7 +763,10 @@ class EditProjectLibrary(bpy.types.Operator):
previous_parent_library = tool.Project.get_parent_library(project_library)
new_parent_library = library_file.by_id(int(props.parent_library))
if previous_parent_library != new_parent_library:
if previous_parent_library.is_a("IfcProject"):
if previous_parent_library is None:
# Edited library was a root in a library-only file; nest it under the new parent.
ifcopenshell.api.nest.assign_object(library_file, [project_library], new_parent_library)
elif previous_parent_library.is_a("IfcProject"):
# Then new one is IfcProjectLibrary.
ifcopenshell.api.nest.assign_object(library_file, [project_library], new_parent_library)
else: # Previous is IfcProjectLibrary.
@@ -804,9 +807,12 @@ class AddProjectLibrary(bpy.types.Operator):
props = tool.Project.get_project_props()
library_file = IfcStore.library_file
assert library_file
project = library_file.by_type("IfcProject")[0]
root_context = tool.Project.get_root_context(library_file)
project_library = ifcopenshell.api.root.create_entity(library_file, "IfcProjectLibrary")
ifcopenshell.api.project.assign_declaration(library_file, [project_library], project)
if root_context.is_a("IfcProject"):
ifcopenshell.api.project.assign_declaration(library_file, [project_library], root_context)
else:
ifcopenshell.api.nest.assign_object(library_file, [project_library], root_context)
ProjectLibraryData.load() # Update enum.
props.selected_project_library = str(project_library.id())
props.is_editing_project_library = True
@@ -979,8 +985,10 @@ 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
@@ -989,6 +997,7 @@ 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
@@ -1035,7 +1044,33 @@ 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
@@ -1113,6 +1148,14 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
f"Error loading IFC file from filepath '{filepath}'. See logs above in the system console for the details.",
)
return {"CANCELLED"}
if not tool.Ifc.get().by_type("IfcProject"):
self.report(
{"ERROR"},
"This file contains no IfcProject. It is likely an IFC project library — "
"load it via Project Setup → Project Library → Select Library File instead.",
)
IfcStore.purge()
return {"CANCELLED"}
props = tool.Project.get_project_props()
props.is_loading = True
props.total_elements = len(tool.Ifc.get().by_type("IfcElement"))
@@ -1122,7 +1165,8 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
props.should_save_metadata_for_this_file = metadata_doc is not None
tool.Blender.register_toolbar()
tool.Project.add_recent_ifc_project(self.get_filepath_abs())
if not self.skip_recent:
tool.Project.add_recent_ifc_project(self.get_filepath_abs())
if self.is_advanced:
pass
@@ -1135,10 +1179,13 @@ 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)
@@ -1223,6 +1270,30 @@ class LoadProjectElements(bpy.types.Operator):
props = tool.Project.get_project_props()
props.is_loading = False
# Stash elements the kernel skipped opening cuts on (HasOpenings > void_limit).
# The Project panel banner offers the user a one-click recut.
props.pending_opening_recut.clear()
if ifc_importer.gross_elements:
for element in ifc_importer.gross_elements:
item = props.pending_opening_recut.add()
item.ifc_definition_id = element.id()
self.report(
{"WARNING"},
f"{len(ifc_importer.gross_elements)} element(s) had too many openings and were loaded without cuts. "
f"Apply manually from the Project panel.",
)
props.pending_array_repair.clear()
if ifc_importer.broken_arrays:
for element in ifc_importer.broken_arrays:
item = props.pending_array_repair.add()
item.ifc_definition_id = element.id()
self.report(
{"WARNING"},
f"{len(ifc_importer.broken_arrays)} array parent(s) reference missing child GUIDs. "
f"Inspect from the Project panel.",
)
tool.Project.load_default_thumbnails()
tool.Project.set_default_context()
tool.Project.set_default_modeling_dimensions()
@@ -1256,6 +1327,11 @@ class LoadProjectElements(bpy.types.Operator):
if element.IsDecomposedBy:
for subelement in element.IsDecomposedBy[0].RelatedObjects:
decomposed_elements.add(subelement)
# IfcSurfaceFeature (e.g. road markings) adhere to a host element
# via IfcRelAdheresToElement, a [1:1] hierarchical relationship in
# the same family as aggregation, containment and nesting (IFC4.3).
for rel in getattr(element, "HasSurfaceFeatures", ()):
decomposed_elements.update(rel.RelatedSurfaceFeatures)
if decomposed_elements:
self.append_decomposed_elements(decomposed_elements)
elements.update(decomposed_elements)
@@ -1384,6 +1460,7 @@ class LinkIfc(bpy.types.Operator, ImportHelper, tool.Ifc.Operator):
new.ifc_definition_id = reference.id()
new.name = filepath
new.filepath = filepath
new.query = self.query
bpy.ops.bim.load_link(link_index=-1, use_cache=self.use_cache, query=self.query)
@@ -1454,6 +1531,10 @@ class LoadLink(bpy.types.Operator, tool.Ifc.Operator):
def _execute(self, context):
self.link = tool.Project.get_project_props().links[self.link_index]
# Fall back to the Link's stored query so callers that omit it
# still replay the filter the link was created with.
if not self.query and self.link.query:
self.query = self.link.query
filepath = Path(tool.Ifc.resolve_uri(self.link.filepath))
if not filepath.exists():
self.report({"ERROR"}, f"File does not exist: '{filepath}'")
@@ -1621,13 +1702,36 @@ class ReloadLink(bpy.types.Operator):
bl_description = "Reload the selected file"
link_index: bpy.props.IntProperty(name="Link Index")
query: bpy.props.StringProperty(
name="Query",
description=(
"Custom selector query to use to load element from a linked model. E.g. 'IfcElement'.\n\n"
"Default query - IfcElement, but excluding IfcProxy, IfcSpatialStructureElement, IfcSpatialElement, IfcFeatureElement."
),
)
if TYPE_CHECKING:
link_index: int
query: str
def invoke(self, context, event):
link = tool.Project.get_project_props().links[self.link_index]
self.query = link.query
return context.window_manager.invoke_props_dialog(self)
def draw(self, context):
assert self.layout
self.layout.prop(self, "query", placeholder="IfcElement")
def execute(self, context):
link = tool.Project.get_project_props().links[self.link_index]
# An unset query means the operator was called without the dialog
# (e.g. from a script) - preserve the link's stored query instead
# of overwriting it with the empty default.
if self.properties.is_property_set("query"):
link.query = self.query
bpy.ops.bim.unload_link(link_index=self.link_index)
return bpy.ops.bim.load_link(link_index=self.link_index, use_cache=False) or {"FINISHED"}
return bpy.ops.bim.load_link(link_index=self.link_index, use_cache=False, query=link.query) or {"FINISHED"}
class ToggleLinkSelectability(bpy.types.Operator):
@@ -1876,6 +1980,7 @@ 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
@@ -1936,6 +2041,18 @@ 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
@@ -1998,7 +2115,8 @@ 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.
tool.Project.add_recent_ifc_project(Path(output_file))
if not self.skip_recent:
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("//"))
@@ -2032,6 +2150,7 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
)
bonsai.bim.handler.refresh_ui_data()
tool.Autosave.reset_timer()
@classmethod
def description(cls, context, properties):
@@ -2040,6 +2159,123 @@ 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"
@@ -3421,3 +3657,149 @@ class GenerateUVMap(bpy.types.Operator):
tool.Loader.load_generated_uv_map(obj.data)
self.report({"INFO"}, "Generated UV map for selected mesh.")
return {"FINISHED"}
class BIM_OT_apply_pending_opening_cuts(bpy.types.Operator, tool.Ifc.Operator):
"""Recompute the wall mesh including opening subtractions for every host
that the load-time ``void_limit`` filter skipped. Clears the deferred
list on completion so the panel banner disappears."""
bl_idname = "bim.apply_pending_opening_cuts"
bl_label = "Apply Pending Opening Cuts"
bl_description = (
"Recompute meshes for elements whose openings were skipped at load because they had too many openings"
)
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context: bpy.types.Context) -> set[str]:
pending = tool.Project.get_project_props().pending_opening_recut
applied = 0
skipped = 0
failed = 0
for item in pending:
try:
element = tool.Ifc.get().by_id(item.ifc_definition_id)
except RuntimeError:
skipped += 1
continue
obj = tool.Ifc.get_object(element)
if obj is None:
skipped += 1
continue
body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
if body is None:
skipped += 1
continue
try:
tool.Geometry.reimport_element_representations(obj, body, apply_openings=True)
applied += 1
except (RuntimeError, OSError, AttributeError) as exc:
# Programmer errors (TypeError, ValueError, etc.) must surface — don't swallow them.
failed += 1
print(f"apply_pending_opening_cuts: failed to recompute {element} ({exc})")
pending.clear()
message = f"Applied opening cuts to {applied} element(s)."
if skipped:
message += f" {skipped} entry/entries skipped (entity or object no longer available)."
if failed:
message += f" {failed} entry/entries failed (see system console)."
self.report({"WARNING"}, message)
else:
self.report({"INFO"}, message)
return {"FINISHED"}
class BIM_OT_dismiss_pending_opening_cuts(bpy.types.Operator):
bl_idname = "bim.dismiss_pending_opening_cuts"
bl_label = "Dismiss Pending Opening Cuts"
bl_description = "Clear the pending opening-cut list without applying it. Walls stay solid where openings would have been subtracted."
bl_options = {"REGISTER", "UNDO"}
def execute(self, context: bpy.types.Context) -> set[str]:
tool.Project.get_project_props().pending_opening_recut.clear()
return {"FINISHED"}
class BIM_OT_dismiss_multi_instance_warning(bpy.types.Operator):
bl_idname = "bim.dismiss_multi_instance_warning"
bl_label = "Dismiss Multi-Instance Warning"
bl_description = (
"Hide the warning that another Blender instance has this IFC file open. Sticky for the current session."
)
bl_options = {"REGISTER"}
def execute(self, context: bpy.types.Context) -> set[str]:
from bonsai.bim.ifc import dismiss_multi_instance_warning
dismiss_multi_instance_warning()
return {"FINISHED"}
class BIM_OT_select_pending_opening_cuts(bpy.types.Operator):
bl_idname = "bim.select_pending_opening_cuts"
bl_label = "Select Elements With Skipped Opening Cuts"
bl_description = "Select the Blender objects whose openings were skipped at load. Useful for locating which elements need attention."
bl_options = {"REGISTER", "UNDO"}
def execute(self, context: bpy.types.Context) -> set[str]:
ifc_file = tool.Ifc.get()
if ifc_file is None:
self.report({"INFO"}, "No IFC file loaded.")
return {"CANCELLED"}
objects: list[bpy.types.Object] = []
for item in tool.Project.get_project_props().pending_opening_recut:
try:
element = ifc_file.by_id(item.ifc_definition_id)
except RuntimeError:
continue
obj = tool.Ifc.get_object(element)
if obj is not None:
objects.append(obj)
if not objects:
self.report({"INFO"}, "No matching Blender objects found for the pending list.")
return {"CANCELLED"}
tool.Blender.set_objects_selection(context, active_object=objects[0], selected_objects=objects)
self.report({"INFO"}, f"Selected {len(objects)} element(s).")
return {"FINISHED"}
class BIM_OT_select_pending_array_repair(bpy.types.Operator):
bl_idname = "bim.select_pending_array_repair"
bl_label = "Select Array Parents With Missing Children"
bl_description = "Select the Blender objects of array parents whose BBIM_Array.Data references children that don't resolve in the file."
bl_options = {"REGISTER", "UNDO"}
def execute(self, context: bpy.types.Context) -> set[str]:
ifc_file = tool.Ifc.get()
if ifc_file is None:
self.report({"INFO"}, "No IFC file loaded.")
return {"CANCELLED"}
objects: list[bpy.types.Object] = []
for item in tool.Project.get_project_props().pending_array_repair:
try:
element = ifc_file.by_id(item.ifc_definition_id)
except RuntimeError:
continue
obj = tool.Ifc.get_object(element)
if obj is not None:
objects.append(obj)
if not objects:
self.report({"INFO"}, "No matching Blender objects found for the pending list.")
return {"CANCELLED"}
tool.Blender.set_objects_selection(context, active_object=objects[0], selected_objects=objects)
self.report({"INFO"}, f"Selected {len(objects)} array parent(s).")
return {"FINISHED"}
class BIM_OT_dismiss_pending_array_repair(bpy.types.Operator):
bl_idname = "bim.dismiss_pending_array_repair"
bl_label = "Dismiss Pending Array Repair"
bl_description = (
"Clear the pending array-repair list without acting on it. The underlying BBIM_Array.Data stays unchanged."
)
bl_options = {"REGISTER", "UNDO"}
def execute(self, context: bpy.types.Context) -> set[str]:
tool.Project.get_project_props().pending_array_repair.clear()
return {"FINISHED"}
+34 -1
View File
@@ -98,7 +98,8 @@ def is_editing_project_library_update(self: "BIMProjectProperties", context: bpy
project_library = library_file.by_id(int(self.selected_project_library))
self.project_library_attributes.clear()
bonsai.bim.helper.import_attributes(project_library, self.project_library_attributes)
self.parent_library = str(tool.Project.get_parent_library(project_library).id())
if parent_library := tool.Project.get_parent_library(project_library):
self.parent_library = str(parent_library.id())
ProjectLibraryData.load() # Show edit icon in enum.
return
@@ -259,6 +260,11 @@ class Link(PropertyGroup):
description="STEP ID of the IfcDocumentReference when linked to a parent IFC project. Zero when no parent IFC exists",
default=0,
)
query: StringProperty(
name="Query",
description="Selector query used to filter elements when loading the linked model",
default="",
)
if TYPE_CHECKING:
name: str
@@ -274,6 +280,7 @@ class Link(PropertyGroup):
include_in_drawings: bool
empty_handle: Union[bpy.types.Object, None]
ifc_definition_id: int
query: str
class EditedObj(PropertyGroup):
@@ -295,6 +302,28 @@ class LibraryBreadcrumb(PropertyGroup):
library_id: int
class PendingOpeningRecut(PropertyGroup):
"""One element whose ``HasOpenings`` exceeded ``void_limit`` at load time
and was imported without opening subtractions. The user can later apply
them on demand from the Project panel banner."""
ifc_definition_id: IntProperty(name="IFC Definition ID")
if TYPE_CHECKING:
ifc_definition_id: int
class PendingArrayRepair(PropertyGroup):
"""One array parent whose ``BBIM_Array.Data`` references at least one
child GUID that does not resolve in the current IFC file. The user can
select these parents from the Project panel banner to inspect them."""
ifc_definition_id: IntProperty(name="IFC Definition ID")
if TYPE_CHECKING:
ifc_definition_id: int
class BIMProjectProperties(PropertyGroup):
is_editing: BoolProperty(name="Is Editing", default=False)
is_loading: BoolProperty(name="Is Loading", default=False)
@@ -360,6 +389,8 @@ class BIMProjectProperties(PropertyGroup):
default=30,
description="Maxium number of openings that object can have. If object has more openings, it will be loaded without openings",
)
pending_opening_recut: CollectionProperty(name="Pending Opening Recut", type=PendingOpeningRecut)
pending_array_repair: CollectionProperty(name="Pending Array Repair", type=PendingArrayRepair)
style_limit: IntProperty(
name="Style Limit",
default=300,
@@ -525,6 +556,8 @@ class BIMProjectProperties(PropertyGroup):
deflection_tolerance: float
angular_tolerance: float
void_limit: int
pending_opening_recut: bpy.types.bpy_prop_collection_idprop[PendingOpeningRecut]
pending_array_repair: bpy.types.bpy_prop_collection_idprop[PendingArrayRepair]
style_limit: int
distance_limit: float
false_origin_mode: Literal["AUTOMATIC", "MANUAL", "DISABLED"]
@@ -18,43 +18,17 @@
import blf
import gpu
from bpy.types import SpaceView3D
from bpy_extras.view3d_utils import location_3d_to_region_2d
from gpu_extras.batch import batch_for_shader
from mathutils import Vector
import bonsai.tool as tool
class GridDecorator:
is_installed = False
handlers = []
@classmethod
def install(cls, context):
if cls.is_installed:
cls.uninstall()
handler = cls()
cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_text, (context,), "WINDOW", "POST_PIXEL"))
cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw, (context,), "WINDOW", "POST_VIEW"))
cls.is_installed = True
@classmethod
def uninstall(cls):
for handler in cls.handlers:
try:
SpaceView3D.draw_handler_remove(handler, "WINDOW")
except ValueError:
pass
cls.is_installed = False
def draw_batch(self, shader_type, content_pos, color, indices=None):
if not tool.Blender.validate_shader_batch_data(content_pos, indices):
return
shader = self.line_shader if shader_type == "LINES" else self.shader
batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices)
shader.uniform_float("color", color)
batch.draw(shader)
class GridDecorator(tool.Blender.ViewportDecorator):
draw_methods = (
("draw_text", "POST_PIXEL"),
("draw", "POST_VIEW"),
)
def draw_text(self, context):
if not tool.Blender.is_addon_enabled():
@@ -31,11 +31,17 @@ import bonsai.tool as tool
from bonsai.bim.module.structural.load_decoration_data import ShaderInfo
class LoadsDecorator:
class LoadsDecorator(tool.Blender.ViewportDecorator):
"""Decorator to show structural loads in 3D"""
is_installed = False
handlers = []
# draw_methods exists to satisfy ViewportDecorator.__init_subclass__'s
# method-existence check; the override install below is what actually
# registers handlers (the POST_VIEW binding passes no context arg, which
# the base's generic install cannot express).
draw_methods = (
("draw_load_values", "POST_PIXEL"),
("__call__", "POST_VIEW"),
)
decoration_data = None
text_info = []
shader_info = []
@@ -54,15 +60,6 @@ class LoadsDecorator:
cls.update()
cls.is_installed = True
@classmethod
def uninstall(cls) -> None:
for handler in cls.handlers:
try:
SpaceView3D.draw_handler_remove(handler, "WINDOW")
except ValueError:
pass
cls.is_installed = False
@classmethod
def update(cls) -> None:
cls.decoration_data.update()
@@ -71,7 +71,11 @@ class LoadByDirection(TypedDict):
ProcessedLoad = TypedDict(
"ProcessedLoad",
{"linear loads": LoadByDirection, "max linear load": float, "discrete loads": list[list[DiscreteConfigItem]]},
{
"linear loads": dict[str, LoadByDirection] | None,
"max linear load": float,
"discrete loads": list[list[DiscreteConfigItem]],
},
)
@@ -845,13 +849,16 @@ class ShaderInfo:
v = l1[1] + fac * (pos - l1[0])
return v
def interpolate(self, pos: float, loadinfo: list[LoadConfigItem], start: int, end: int, key: str) -> np.ndarray:
def interpolate(self, pos: float, loadinfo: list[LoadConfigItem], start: int, end: int) -> np.ndarray:
"""interpolate the result vectors between load poits"""
result = np.zeros(6)
for i in range(6):
value1 = [loadinfo[start]["pos"], loadinfo[start][key][i]] # [position, force_component]
value2 = [loadinfo[end]["pos"], loadinfo[end][key][i]] # [position, force_component]
result[i] = self.interp1d(value1, value2, pos) # interpolated [position, force_component]
# [position, force_component]
value1 = [loadinfo[start]["pos"], loadinfo[start]["load values"][i]]
# [position, force_component]
value2 = [loadinfo[end]["pos"], loadinfo[end]["load values"][i]]
# interpolated [position, force_component]
result[i] = self.interp1d(value1, value2, pos)
return result
def get_before_and_after(self, pos: float, load_config_list: list[list[LoadConfigItem]]) -> dict[str, list[float]]:
@@ -895,8 +902,8 @@ class ShaderInfo:
load_before += config[end]["load values"]
elif end - start == 1:
load_before += self.interpolate(pos, config, start, end, "load values")
load_after += self.interpolate(pos, config, start, end, "load values")
load_before += self.interpolate(pos, config, start, end)
load_after += self.interpolate(pos, config, start, end)
start += 1
end -= 1
return_value = {"before": load_before.tolist(), "after": load_after.tolist()}
+31 -24
View File
@@ -744,7 +744,7 @@ class EnableEditingSurfaceStyle(bpy.types.Operator):
if self.ifc_class == "IfcSurfaceStyleLighting":
def callback(attribute_name: str, _: object, data: dict[str, Any]) -> None:
assert attributes
assert attributes is not None
color = attributes.add()
assert isinstance(color, ColourRgb)
color.name = attribute_name
@@ -782,34 +782,40 @@ class EditSurfaceStyle(bpy.types.Operator, tool.Ifc.Operator):
def _execute(self, context):
self.props = tool.Style.get_style_props()
self.style = tool.Ifc.get().by_id(self.props.is_editing_style)
prev_update_graph = self.props.update_graph
self.props["update_graph"] = False
style_elements = tool.Style.get_style_elements(self.style)
# NOTE: currently this operator is used to edit existing (and only existing) IfcSurfaceStyles
# or new or existing IfcSurfaceStyle components (shading, etc)
# which is kind of confusing.
if self.props.is_editing_class == "IfcSurfaceStyle":
self.surface_style = self.style
else:
self.surface_style = style_elements.get(self.props.is_editing_class, None)
self.shading_style = style_elements.get("IfcSurfaceStyleShading", None)
self.rendering_style = style_elements.get("IfcSurfaceStyleRendering", None)
self.texture_style = style_elements.get("IfcSurfaceStyleWithTextures", None)
try:
style_elements = tool.Style.get_style_elements(self.style)
if self.surface_style:
result = self.edit_existing_style()
else:
result = self.add_new_style()
# NOTE: currently this operator is used to edit existing (and only existing) IfcSurfaceStyles
# or new or existing IfcSurfaceStyle components (shading, etc)
# which is kind of confusing.
if self.props.is_editing_class == "IfcSurfaceStyle":
self.surface_style = self.style
else:
self.surface_style = style_elements.get(self.props.is_editing_class, None)
self.shading_style = style_elements.get("IfcSurfaceStyleShading", None)
self.rendering_style = style_elements.get("IfcSurfaceStyleRendering", None)
self.texture_style = style_elements.get("IfcSurfaceStyleWithTextures", None)
if result:
return result
if self.surface_style:
result = self.edit_existing_style()
else:
result = self.add_new_style()
tool.Style.disable_editing()
core.load_styles(tool.Style, style_type=self.props.style_type)
if result:
return result
# restore selected style type
material = tool.Ifc.get_object(self.style)
msprops = tool.Style.get_material_style_props(material)
msprops.active_style_type = msprops.active_style_type
tool.Style.disable_editing()
core.load_styles(tool.Style, style_type=self.props.style_type)
# restore selected style type
material = tool.Ifc.get_object(self.style)
msprops = tool.Style.get_material_style_props(material)
msprops.active_style_type = msprops.active_style_type
finally:
self.props["update_graph"] = prev_update_graph
def edit_existing_style(self) -> None:
ifc_file = tool.Ifc.get()
@@ -1231,4 +1237,5 @@ class RemoveSurfaceStyle(bpy.types.Operator, tool.Ifc.Operator):
surface_style = tool.Style.get_style_elements(style)[props.is_editing_class]
ifcopenshell.api.style.remove_surface_style(ifc_file, surface_style)
core.disable_editing_style(tool.Style)
core.load_styles(tool.Style, style_type=props.style_type)
return {"FINISHED"}
@@ -185,6 +185,13 @@ class ColourRgb(PropertyGroup):
# to fit blender.bim.helper.draw_attribute
is_optional = False
special_type = ""
data_type = ""
ifc_class = ""
use_explorer_ui = False
@property
def display_name(self):
return self.name
def get_value_name(self, *args, **kwargs):
return "color_value"
+50 -3
View File
@@ -176,8 +176,30 @@ class BIM_PT_styles(Panel):
row.prop(self.props, "reflectance_method")
if self.props.reflectance_method not in ("PHYSICAL", "NOTDEFINED", "FLAT"):
self.layout.label(text="Supported reflectance methods are:")
self.layout.label(text="PHYSICAL / NOTDEFINED / FLAT")
self.layout.label(
text=f"{self.props.reflectance_method} will be skipped: only PHYSICAL / NOTDEFINED / FLAT are supported",
icon="ERROR",
)
elif self.props.reflectance_method in ("PHYSICAL", "NOTDEFINED"):
if self.props.specular_colour_class == "IfcColourRgb":
self.layout.label(
text="Metallic color is IFC-only in PHYSICAL/NOTDEFINED and does not affect Blender appearance",
icon="ERROR",
)
elif self.props.reflectance_method == "FLAT":
if self.props.diffuse_colour_class == "IfcNormalisedRatioMeasure":
self.layout.label(
text="Emissive ratio is IFC-only in FLAT Reflectance method and does not affect Blender appearance",
icon="ERROR",
)
self.layout.label(
text="Specular value is IFC-only in FLAT Reflectance method and does not affect Blender appearance",
icon="ERROR",
)
self.layout.label(
text="Highlight value is IFC-only in FLAT Reflectance method and does not affect Blender appearance",
icon="ERROR",
)
row = self.layout.row(align=True)
row.label(text="Emissive" if self.props.reflectance_method == "FLAT" else "Diffuse")
@@ -232,6 +254,8 @@ class BIM_PT_styles(Panel):
row.operator("bim.add_surface_texture", text="", icon="ADD")
if textures:
self.layout.prop(self.props, "uv_mode")
if self.props.uv_mode in ("Generated", "Camera"):
self.layout.label(text="Not available in SOLID Mode", icon="INFO")
for i, texture in enumerate(textures):
split = self.layout.split(factor=0.30, align=True)
@@ -244,6 +268,22 @@ class BIM_PT_styles(Panel):
op_clear = row.operator("bim.remove_texture_map", text="", icon="X")
op_path.texture_map_index = op_clear.texture_map_index = i
reflectance = self.props.reflectance_method
mode = texture.mode
if reflectance == "FLAT":
if mode != "EMISSIVE":
self.layout.label(
text=f"{mode} will be skipped: only EMISSIVE is supported for Render Reflectance FLAT",
icon="ERROR",
)
elif reflectance in ("PHYSICAL", "NOTDEFINED"):
_SUPPORTED = {"DIFFUSE", "NORMAL", "METALLICROUGHNESS", "EMISSIVE", "OCCLUSION"}
if mode not in _SUPPORTED:
self.layout.label(
text=f"{mode} will be skipped: not supported for Render Reflectance PHYSICAL/NOTDEFINED",
icon="ERROR",
)
def draw_externally_defined_surface_style(self):
row = self.layout.row()
op = row.operator("bim.browse_external_style", icon="APPEND_BLEND", text="Append From Blend File")
@@ -252,10 +292,17 @@ class BIM_PT_styles(Panel):
bonsai.bim.helper.draw_attributes(self.props.external_style_attributes, self.layout, enable_search=True)
def draw_refraction_surface_style(self):
self.layout.label(
text="Refraction values are IFC-only and do not affect Blender surface appearance",
icon="ERROR",
)
bonsai.bim.helper.draw_attributes(self.props.refraction_style_attributes, self.layout, enable_search=True)
row = self.layout.row(align=True)
def draw_lighting_surface_style(self):
self.layout.label(
text="Lighting values are IFC-only and do not affect Blender surface appearance",
icon="ERROR",
)
bonsai.bim.helper.draw_attributes(self.props.lighting_style_colours, self.layout)
def draw_edit_ui(self, edit_label: str):
@@ -15,9 +15,10 @@
#
# 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 modified with the assistance of an AI coding tool.
import bmesh
import bpy
import gpu
from bpy.app.handlers import persistent
@@ -30,12 +31,6 @@ ERROR_ELEMENTS_COLOR = (1, 0.2, 0.322, 1) # RED
UNSPECIAL_ELEMENT_COLOR = (0.2, 0.2, 0.2, 1) # GREY
def transparent_color(color, alpha=0.1):
color = [i for i in color]
color[3] = alpha
return color
@persistent
def toggle_decorations_on_load(*args):
props = tool.System.get_system_props()
@@ -78,15 +73,9 @@ class SystemDecorator:
batch.draw(shader)
def draw_faces(self, bm, vertices_coords):
"""mutates original bm (triangulates it)
so the triangulation edges will be shown too
"""
traingulated_bm = bm
bmesh.ops.triangulate(traingulated_bm, faces=traingulated_bm.faces)
face_indices = [[v.index for v in f.verts] for f in traingulated_bm.faces]
faces_color = transparent_color(self.addon_prefs.decorator_color_special)
self.draw_batch("TRIS", vertices_coords, faces_color, face_indices)
"""Submit a non-mutating beauty-triangulated TRIS batch over ``bm``'s faces."""
faces_color = tool.Blender.transparent_color(self.addon_prefs.decorator_color_special)
tool.Blender.draw_bmesh_face_tris(bm, vertices_coords, faces_color, self.draw_batch)
def __call__(self, context, get_custom_bmesh=None, draw_faces=False, exit_edit_mode_callback=None):
self.addon_prefs = tool.Blender.get_addon_preferences()
@@ -133,13 +122,15 @@ class SystemDecorator:
self.shader = gpu.shader.from_builtin("UNIFORM_COLOR")
self.shader.bind()
self.draw_batch("LINES", all_vertices, transparent_color(unselected_elements_color), unselected_edges)
self.draw_batch(
"LINES", all_vertices, tool.Blender.transparent_color(unselected_elements_color), unselected_edges
)
self.draw_batch("LINES", all_vertices, selected_elements_color, selected_edges)
self.draw_batch("LINES", all_vertices, UNSPECIAL_ELEMENT_COLOR, arc_edges)
self.draw_batch("LINES", all_vertices, special_elements_color, preview_edges)
self.draw_batch("LINES", all_vertices, special_elements_color, roof_angle_edges)
self.draw_batch("POINTS", unselected_vertices, transparent_color(unselected_elements_color, 0.5))
self.draw_batch("POINTS", unselected_vertices, tool.Blender.transparent_color(unselected_elements_color, 0.5))
self.draw_batch("POINTS", error_vertices, ERROR_ELEMENTS_COLOR)
self.draw_batch("POINTS", special_vertices, special_elements_color)
self.draw_batch("POINTS", selected_vertices, selected_elements_color)
@@ -317,13 +317,23 @@ class MEPConnectElements(bpy.types.Operator, tool.Ifc.Operator):
bl_label = "Connect MEP Elements"
bl_description = "Connects two selected elements by their closest located ports and adjusts them"
bl_options = {"REGISTER", "UNDO"}
obj1_name: bpy.props.StringProperty(name="Object 1")
obj2_name: bpy.props.StringProperty(name="Object 2")
obj1_guid: bpy.props.StringProperty(name="Object 1 GlobalId")
obj2_guid: bpy.props.StringProperty(name="Object 2 GlobalId")
def _execute(self, context):
if self.obj1_name and self.obj2_name:
obj1 = bpy.data.objects.get(self.obj1_name)
obj2 = bpy.data.objects.get(self.obj2_name)
if self.obj1_guid and self.obj2_guid:
ifc_file = tool.Ifc.get()
try:
el1_lookup = ifc_file.by_guid(self.obj1_guid)
el2_lookup = ifc_file.by_guid(self.obj2_guid)
except RuntimeError:
self.report({"ERROR"}, "Could not resolve MEP elements from supplied GlobalIds.")
return {"CANCELLED"}
obj1 = tool.Ifc.get_object(el1_lookup)
obj2 = tool.Ifc.get_object(el2_lookup)
if not obj1 or not obj2:
self.report({"ERROR"}, "Supplied MEP elements have no Blender object bound.")
return {"CANCELLED"}
else:
if not context.selected_objects or len(context.selected_objects) != 2:
self.report({"ERROR"}, "Need to select 2 objects.")
+32 -4
View File
@@ -65,10 +65,28 @@ class AssignType(bpy.types.Operator, tool.Ifc.Operator):
if active_drawing:
active_target_view = tool.Drawing.get_drawing_target_view(active_drawing)
compatible: list[tuple[bpy.types.Object, ifcopenshell.entity_instance]] = []
skipped_classes: set[str] = set()
for obj in related_objects:
element = tool.Ifc.get_entity(obj)
if not element or not element.is_a("IfcObject"):
continue
if not tool.Type.is_relating_type_compatible(element, relating_type):
skipped_classes.add(element.is_a())
continue
compatible.append((obj, element))
if skipped_classes:
self.report(
{"WARNING"},
f"Skipped {', '.join(sorted(skipped_classes))}: not a valid occurrence for " f"{relating_type.is_a()}.",
)
if not compatible:
self.report({"ERROR"}, f"No selected object can be typed by {relating_type.is_a()}.")
return {"CANCELLED"}
for obj, element in compatible:
core.assign_type(tool.Ifc, tool.Model, tool.Type, element=element, type=relating_type)
# Switch to the drawing's target view if available
@@ -376,12 +394,22 @@ class DuplicateType(bpy.types.Operator, tool.Ifc.Operator):
if self.assign_selected_objects:
selected_objects = tool.Blender.get_selected_objects()
prefs = tool.Blender.get_addon_preferences()
skipped_classes: set[str] = set()
for selected_obj in selected_objects:
selected_element = tool.Ifc.get_entity(selected_obj)
if selected_element and selected_element.is_a("IfcObject"):
core.assign_type(tool.Ifc, tool.Model, tool.Type, element=selected_element, type=new)
if prefs.occurrence_name_style == "TYPE":
selected_obj.name = tool.Model.generate_occurrence_name(new, selected_element.is_a())
if not selected_element or not selected_element.is_a("IfcObject"):
continue
if not tool.Type.is_relating_type_compatible(selected_element, new):
skipped_classes.add(selected_element.is_a())
continue
core.assign_type(tool.Ifc, tool.Model, tool.Type, element=selected_element, type=new)
if prefs.occurrence_name_style == "TYPE":
selected_obj.name = tool.Model.generate_occurrence_name(new, selected_element.is_a())
if skipped_classes:
self.report(
{"WARNING"},
f"Skipped {', '.join(sorted(skipped_classes))}: not a valid occurrence for " f"{new.is_a()}.",
)
if obj in context.selectable_objects:
tool.Blender.select_and_activate_single_object(context, new_obj)
+24 -20
View File
@@ -26,7 +26,7 @@ import bonsai.bim.handler
import bonsai.core.geometry
import bonsai.core.root
import bonsai.tool as tool
from bonsai.bim.module.model.opening import FilledOpeningGenerator
from bonsai.bim.module.model.opening import FilledOpeningGenerator, is_filling_supported
class AddOpening(bpy.types.Operator, tool.Ifc.Operator):
@@ -34,11 +34,13 @@ class AddOpening(bpy.types.Operator, tool.Ifc.Operator):
bl_label = "Apply Opening"
bl_options = {"REGISTER", "UNDO"}
bl_description = (
"Apply opening objects to an Element.\n\n"
"The Element and the openings to be applied should be selected. The order of selection is not important.\n"
"Opening can be just a Blender mesh object.\n\n"
"Shift+click: keep the filling at its current matrix_world — skip the wall-axis snap "
"and the rl1/rl2 Z-elevation default that the regular click applies."
"Cuts openings in a wall, slab, or roof using selected shape objects — "
"doors, windows, existing openings, or plain (non-IFC) meshes. "
"Selection order doesn't matter.\n\n"
"Doors and windows also fill the opening. Other IFC classes are currently "
"unsupported by the opening generator and get skipped with a warning.\n\n"
"Shift+click: keep each opening at its shape object's current position "
"instead of snapping to the wall."
)
# Toggled by ``invoke`` when the user holds SHIFT during a gizmo / hotkey
@@ -59,6 +61,12 @@ class AddOpening(bpy.types.Operator, tool.Ifc.Operator):
return self.execute(context)
def _execute(self, context):
# Multi-opening drops on the same host fan out N update_representation
# writes + N switch_representation recuts without batching. Coalesce.
with tool.Geometry.batch_host_recut():
return self._add_openings(context)
def _add_openings(self, context):
selected_objects = context.selected_objects
target_object = selected_objects[0]
@@ -78,8 +86,14 @@ class AddOpening(bpy.types.Operator, tool.Ifc.Operator):
self.report({"INFO"}, "You can't add an opening to another opening.")
continue
elif not element1.is_a("IfcOpeningElement") and not element2.is_a("IfcOpeningElement"):
if element1.is_a("IfcWindow") or element1.is_a("IfcDoor"): # Add a fill to an element.
if is_filling_supported(element1): # Add a fill to an element.
obj1, obj2 = obj2, obj1
elif not is_filling_supported(element2):
self.report(
{"INFO"},
f"Cannot apply {element2.is_a()} as an opening — Bonsai currently supports only IfcDoor and IfcWindow as parametric fillings.",
)
continue
FilledOpeningGenerator().generate(
obj2,
obj1,
@@ -165,7 +179,7 @@ class AddOpening(bpy.types.Operator, tool.Ifc.Operator):
voided_obj.scale = (1.0, 1.0, 1.0)
tool.Ifc.finish_edit(voided_obj)
else:
bpy.ops.bim.update_representation(obj=voided_obj.name)
tool.Geometry.update_host_representation(voided_obj)
if tool.Ifc.is_moved(voided_obj):
bonsai.core.geometry.edit_object_placement(
@@ -174,12 +188,7 @@ class AddOpening(bpy.types.Operator, tool.Ifc.Operator):
representation = tool.Geometry.get_active_representation(voided_obj)
assert representation
bonsai.core.geometry.switch_representation(
tool.Ifc,
tool.Geometry,
obj=voided_obj,
representation=representation,
)
tool.Geometry.recut_host(voided_obj, representation)
tool.Geometry.lock_scale(voided_obj)
if not has_visible_openings:
@@ -217,12 +226,7 @@ class RemoveOpening(bpy.types.Operator, tool.Ifc.Operator):
if building_obj and building_obj.data:
representation = tool.Geometry.get_active_representation(building_obj)
assert representation
bonsai.core.geometry.switch_representation(
tool.Ifc,
tool.Geometry,
obj=building_obj,
representation=representation,
)
tool.Geometry.recut_host(building_obj, representation)
tool.Geometry.unlock_scale_object_with_openings(obj)
tool.Geometry.clear_cache(element)
return {"FINISHED"}
+163 -64
View File
@@ -39,21 +39,10 @@ from natsort import natsorted
import bonsai.bim
import bonsai.bim.helper
import bonsai.tool as tool
from bonsai.bim.ifc import is_cache_locked_by_other_process
from bonsai.bim.module.bsdd.prop import BIMBSDDProperties, BSDDProperty
from bonsai.bim.module.model.prop import (
BIMDoorProperties,
BIMRailingProperties,
BIMRoofProperties,
BIMStairProperties,
BIMWindowProperties,
)
from bonsai.bim.module.model.ui import (
draw_door_properties,
draw_railing_properties,
draw_roof_properties,
draw_stair_properties,
draw_window_properties,
)
from bonsai.bim.module.model import prop as _model_prop
from bonsai.bim.module.model import ui as _model_ui
from bonsai.bim.module.pset.prop import IfcProperty
from bonsai.bim.prop import Attribute
@@ -278,30 +267,29 @@ class BIM_UL_panel_visibilities(bpy.types.UIList):
class GizmoPreferences(bpy.types.PropertyGroup):
"""Aggregator for parametric gizmo visibility settings. One flat bool per
parametric feature; controls whether that feature's gizmo group polls
visible in the viewport."""
visible in the viewport.
The per-feature ``<name>: BoolProperty`` fields are derived from
``tool.Parametric.EDIT_TYPES`` at module load adding a new parametric
type to the registry automatically surfaces its toggle here, with no
parallel hand-maintained list to keep in sync."""
draw_gizmos_in_3d_viewport: BoolProperty(
name="Draw Gizmos In 3D Viewport",
default=True,
description="Show interactive gizmos in the 3D viewport for parametric elements",
)
door: BoolProperty(name="Door", default=True)
window: BoolProperty(name="Window", default=True)
stair: BoolProperty(name="Stair", default=True)
railing: BoolProperty(name="Railing", default=True)
roof: BoolProperty(name="Roof", default=True)
array: BoolProperty(name="Array", default=True)
wall: BoolProperty(name="Wall", default=True)
if TYPE_CHECKING:
draw_gizmos_in_3d_viewport: bool
door: bool
window: bool
stair: bool
railing: bool
roof: bool
array: bool
wall: bool
for _gizmo_pref_entry in tool.Parametric.EDIT_TYPES:
GizmoPreferences.__annotations__[_gizmo_pref_entry.name] = BoolProperty(
name=_gizmo_pref_entry.name.replace("_", " ").title(),
default=True,
)
del _gizmo_pref_entry
class DocPreferences(bpy.types.PropertyGroup):
@@ -397,11 +385,22 @@ class DocPreferences(bpy.types.PropertyGroup):
class DefaultParameters(bpy.types.PropertyGroup):
door: bpy.props.PointerProperty(type=BIMDoorProperties)
window: bpy.props.PointerProperty(type=BIMWindowProperties)
railing: bpy.props.PointerProperty(type=BIMRailingProperties)
roof: bpy.props.PointerProperty(type=BIMRoofProperties)
stair: bpy.props.PointerProperty(type=BIMStairProperties)
"""Per-type preset values used to seed new parametric instances.
The ``<name>: PointerProperty`` fields are derived from the subset of
``tool.Parametric.EDIT_TYPES`` flagged ``has_default_parameters=True``,
each pointing at the matching ``BIM<Name>Properties`` class. Adding a
new entry with that flag automatically surfaces a preferences section
and gives the create operator a preset to copy from."""
for _default_params_entry in tool.Parametric.EDIT_TYPES:
if not _default_params_entry.has_default_parameters:
continue
DefaultParameters.__annotations__[_default_params_entry.name] = bpy.props.PointerProperty(
type=getattr(_model_prop, _default_params_entry.props_attr),
)
del _default_params_entry
class BIM_ADDON_preferences(bpy.types.AddonPreferences):
@@ -513,6 +512,15 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
size=4,
description="Color of not selected verts/edges (used in profile editing mode)",
)
clip_box_cap_color: bpy.props.FloatVectorProperty(
name="Clip Box Caps Color",
subtype="COLOR",
default=(0.0, 0.0, 0.0, 1.0),
min=0.0,
max=1.0,
size=4,
description="Fill color of clip-box cross-section caps",
)
decorator_color_special: bpy.props.FloatVectorProperty(
name="Special Elements Color",
subtype="COLOR",
@@ -569,6 +577,43 @@ 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",
@@ -681,6 +726,9 @@ 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"]
@@ -802,43 +850,39 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
layout.row().prop(self, "decorator_color_special")
layout.row().prop(self, "decorator_color_error")
layout.row().prop(self, "decorator_color_background")
bonsai.bim.helper.draw_expandable_panel(
layout,
context,
"Clip Box",
self.draw_clip_box_colors,
)
def draw_clip_box_colors(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
layout.row().prop(self, "clip_box_cap_color")
def draw_default_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
box = layout.box()
bonsai.bim.helper.draw_expandable_panel(
box,
context,
"Door",
lambda _layout, _context: draw_door_properties(_layout, self.default_parameters.door),
)
bonsai.bim.helper.draw_expandable_panel(
box,
context,
"Window",
lambda _layout, _context: draw_window_properties(_layout, self.default_parameters.window),
)
bonsai.bim.helper.draw_expandable_panel(
box,
context,
"Railing",
lambda _layout, _context: draw_railing_properties(_layout, self.default_parameters.railing),
)
bonsai.bim.helper.draw_expandable_panel(
box,
context,
"Roof",
lambda _layout, _context: draw_roof_properties(_layout, self.default_parameters.roof),
)
bonsai.bim.helper.draw_expandable_panel(
box,
context,
"Stair",
lambda _layout, _context: draw_stair_properties(_layout, self.default_parameters.stair),
)
for entry in tool.Parametric.EDIT_TYPES:
if not entry.has_default_parameters:
continue
props = getattr(self.default_parameters, entry.name)
draw_props = getattr(_model_ui, f"draw_{entry.name}_properties")
bonsai.bim.helper.draw_expandable_panel(
box,
context,
entry.name.replace("_", " ").title(),
lambda _layout, _context, _draw=draw_props, _props=props: _draw(_layout, _props),
)
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:")
@@ -952,6 +996,50 @@ class BIM_PT_tabs(Panel):
op.uri = "https://docs.bonsaibim.org/guides/troubleshooting.html#saving-and-loading-blend-files"
row.operator("bim.close_blend_warning", text="", icon="CANCEL")
if is_cache_locked_by_other_process():
box = self.layout.box()
box.alert = True
row = box.row(align=True)
row.label(text="IFC Already Open in Another Blender Instance", icon="ERROR")
row.operator("bim.dismiss_multi_instance_warning", text="", icon="CANCEL")
draw_multiline_text(
box.column(align=True),
"This file is open in another Blender instance. Editing the same "
"IFC from two instances at once can lose your work or display "
"outdated geometry. Close the other Blender instances to continue safely.",
context=context,
)
pprops = tool.Project.get_project_props()
if pending := pprops.pending_opening_recut:
box = self.layout.box()
box.alert = True
box.label(text="Opening Cuts Skipped", icon="ERROR")
draw_multiline_text(
box.column(align=True),
f"{len(pending)} element(s) had too many openings to cut during load. "
f"Apply to recompute their meshes, or dismiss to leave them as they are.",
context=context,
)
row = box.row(align=True)
row.operator("bim.select_pending_opening_cuts", text="Select Elements", icon="RESTRICT_SELECT_OFF")
row.operator("bim.apply_pending_opening_cuts", text="Apply Openings", icon="PLAY")
row.operator("bim.dismiss_pending_opening_cuts", text="", icon="CANCEL")
if pending := pprops.pending_array_repair:
box = self.layout.box()
box.alert = True
box.label(text="Arrays With Missing Children", icon="ERROR")
draw_multiline_text(
box.column(align=True),
f"{len(pending)} array parent(s) reference child GUIDs that don't exist in this file. "
f"The arrays loaded incomplete. Select to inspect, or dismiss.",
context=context,
)
row = box.row(align=True)
row.operator("bim.select_pending_array_repair", text="Select Elements", icon="RESTRICT_SELECT_OFF")
row.operator("bim.dismiss_pending_array_repair", text="", icon="CANCEL")
gprops = tool.Geometry.get_geometry_props()
# Check that Blender mode and IFC Mode do match.
if context.mode == "OBJECT" and gprops.mode in ("OBJECT", "ITEM"):
@@ -1903,6 +1991,7 @@ class BIM_PT_decorators_overlay(Panel):
aggregate_props = tool.Aggregate.get_aggregate_props()
nest_props = tool.Nest.get_nest_props()
model_props = tool.Model.get_model_props()
system_props = tool.System.get_system_props()
display_all = overlay.show_overlays
col = layout.column()
@@ -1920,10 +2009,20 @@ class BIM_PT_decorators_overlay(Panel):
row = col.row(align=True)
row.prop(model_props, "show_slab_direction", text="Slab Direction")
row = col.row(align=True)
row.prop(model_props, "show_paths", text="Element Paths")
row.prop(system_props, "should_draw_decorations", text="System Decorations")
row = col.row(align=True)
row.prop(model_props, "show_bounding_box", text="Bounding Box Dimensions")
row = col.row(align=True)
row.prop(model_props, "show_cut_decorator", text="Cut Decorator")
row.prop(model_props, "show_cut_decorator_fill", text="Fill Cut Decorator")
clip_box_props = tool.ClipBox.get_scene_props(context.scene)
row = col.row(align=True)
# Grey out the toggles when there is no clip box to act on, so the
# user can see the controls but can't flip a switch that does nothing.
row.enabled = bool(clip_box_props.clip_boxes)
row.prop(clip_box_props, "enabled", text="Enable Clipping")
row.prop(clip_box_props, "show_caps", text="Show Caps")
class BIM_PT_snappping(Panel):
+113
View File
@@ -0,0 +1,113 @@
# 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.
"""Shared post-disconnect cleanup dispatch.
Used by both ``bim.disconnect_elements`` (explicit user disconnect) and the
connection cascade in ``tool.Geometry.delete_ifc_object`` (implicit
disconnect-on-delete). Each kind returned by
:py:meth:`bonsai.tool.connection.Connection.find_rels` /
:py:meth:`find_rels_for_element` maps to a single arm here, so adding a new
kind means extending one dispatch table both call sites benefit
automatically and the AST forward-compat guard enforces coverage.
The ``subject`` parameter is the entity whose teardown effects the
disconnect: for ``"path"`` / ``"element"`` / ``"element-top"`` kinds it
carries an ``IfcRel*`` relationship entity (the rel that gets removed);
for ``"mep-pair-fitting"`` it carries an ``IfcFlowFitting`` (the fitting
that gets deleted). The slot is uniform on intent the dispatch decides
the teardown mechanism by kind.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import bonsai.core.geometry
from bonsai.core.model import regenerate_wall_to_underside
if TYPE_CHECKING:
import ifcopenshell
import bonsai.tool as tool
def disconnect_rel(
ifc: type[tool.Ifc],
geometry: type[tool.Geometry],
model: type[tool.Model],
connection: type[tool.Connection],
subject: ifcopenshell.entity_instance,
kind: str,
elem: ifcopenshell.entity_instance,
partner: ifcopenshell.entity_instance,
skip_elem_recreate: bool = False,
skip_partner_recreate: bool = False,
) -> None:
"""Run the post-disconnect cleanup for one connection.
``elem`` and ``partner`` are the two endpoints. The ``skip_*_recreate``
flags suppress per-side regenerate / recreate work used by the
cascade-on-delete to avoid re-extruding entities that are about to be
removed by ``remove_product``. For the disconnect operator (where neither
endpoint is being deleted), both flags stay False and the full cleanup
runs on both sides.
"""
if kind == "path":
bonsai.core.geometry.remove_connection(geometry, connection=subject)
if not skip_elem_recreate:
elem_obj = ifc.get_object(elem)
if elem_obj is not None:
model.recreate_wall(elem, elem_obj)
if not skip_partner_recreate:
partner_obj = ifc.get_object(partner)
if partner_obj is not None:
model.recreate_wall(partner, partner_obj)
elif kind == "element-top":
wall, _slab = connection.orient_element_top(subject, elem, partner)
ifc.run(
"geometry.disconnect_element",
relating_element=subject.RelatingElement,
related_element=subject.RelatedElement,
)
# Skip the wall-side regenerate when the wall is itself being deleted —
# either it's the elem of this cascade pass, or it's the partner that
# was queued earlier in the same batch.
if (wall is elem and skip_elem_recreate) or (wall is partner and skip_partner_recreate):
return
wall_obj = ifc.get_object(wall)
if wall_obj is not None:
regenerate_wall_to_underside(ifc, geometry, model, [wall_obj])
elif kind == "element":
ifc.run(
"geometry.disconnect_element",
relating_element=subject.RelatingElement,
related_element=subject.RelatedElement,
)
elif kind == "mep-pair-fitting":
if skip_elem_recreate and subject is elem:
return
if skip_partner_recreate and subject is partner:
return
fitting_obj = ifc.get_object(subject)
if fitting_obj is not None:
geometry.delete_ifc_object(fitting_obj)
else:
raise ValueError(f"Unknown kind: {kind!r}")
+17 -2
View File
@@ -302,9 +302,15 @@ def add_drawing(
context=drawing.get_body_context(),
ifc_representation_class=None,
)
drawings_parent_group = drawing.ensure_drawings_parent_group()
group = ifc.run("group.add_group")
ifc.run("group.edit_group", group=group, attributes={"Name": drawing_name, "ObjectType": "DRAWING"})
ifc.run("group.assign_group", group=group, products=[element])
ifc.run("group.assign_group", group=drawings_parent_group, products=[group])
collector.assign(camera)
pset = ifc.run("pset.add_pset", product=element, name="EPset_Drawing")
if drawing.get_unit_system() == "METRIC":
@@ -335,7 +341,10 @@ def add_drawing(
},
)
drawing.setup_shading_styles_path(shading_styles_path)
information = ifc.run("document.add_information")
drawings_parent_document = drawing.ensure_drawings_parent_document()
information = ifc.run("document.add_information", parent=drawings_parent_document)
uri = drawing.get_default_drawing_path(drawing_name)
reference = ifc.run("document.add_reference", information=information)
if ifc.get_schema() == "IFC2X3":
@@ -363,9 +372,13 @@ def duplicate_drawing(
drawing_tool.set_name(new_drawing, drawing_name)
group = drawing_tool.get_drawing_group(new_drawing)
ifc.run("group.unassign_group", group=group, products=[new_drawing])
drawings_parent_group = drawing_tool.ensure_drawings_parent_group()
new_group = ifc.run("group.add_group")
ifc.run("group.edit_group", group=new_group, attributes={"Name": drawing_name, "ObjectType": "DRAWING"})
ifc.run("group.assign_group", group=new_group, products=[new_drawing])
ifc.run("group.assign_group", group=drawings_parent_group, products=[new_group])
if should_duplicate_annotations:
new_annotations: list[ifcopenshell.entity_instance] = []
annotation_objs = [ifc.get_object(a) for a in drawing_tool.get_group_elements(group) if a != drawing]
@@ -381,7 +394,9 @@ def duplicate_drawing(
old_reference = drawing_tool.get_drawing_document(new_drawing)
ifc.run("document.unassign_document", products=[new_drawing], document=old_reference)
information = ifc.run("document.add_information")
drawings_parent_document = drawing_tool.ensure_drawings_parent_document()
information = ifc.run("document.add_information", parent=drawings_parent_document)
uri = drawing_tool.get_default_drawing_path(drawing_name)
reference = ifc.run("document.add_reference", information=information)
if ifc.get_schema() == "IFC2X3":
+14 -3
View File
@@ -167,12 +167,22 @@ def regenerate_wall_to_underside(
model: type[tool.Model],
wall_objs: list[bpy.types.Object],
) -> None:
"""Re-clip walls to their connected underside objects after the slab has moved."""
"""Re-clip walls to their connected underside objects after the slab has moved.
When a wall has no remaining slab connections the case reached after the
last TOP rel is severed (via disconnect or via cascade-on-slab-delete) the
stale trim booleans are cleaned up so the wall reverts to its pre-clip
extrusion instead of holding orphan ``IfcBooleanResult`` items and a dead
``BBIM_Boolean`` pset.
"""
clipped_objs = []
reverted_objs = []
for obj in wall_objs:
wall = ifc.get_entity(obj)
slab_objs = model.get_connected_slab_objs(wall)
if not slab_objs:
model.remove_wall_to_underside_booleans(wall)
reverted_objs.append(obj)
continue
if ifc.is_moved(obj):
geometry.run_edit_object_placement(obj=obj)
@@ -185,8 +195,9 @@ def regenerate_wall_to_underside(
if clip:
model.clip_wall_to_slab(wall, clip)
clipped_objs.append(obj)
if clipped_objs:
model.reload_body_representation(clipped_objs)
refresh_objs = clipped_objs + reverted_objs
if refresh_objs:
model.reload_body_representation(refresh_objs)
def extend_wall_to_slab(
-64
View File
@@ -1,64 +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 math
from collections.abc import Iterable
from typing import TYPE_CHECKING
import bonsai.core.geometry
if TYPE_CHECKING:
import bpy
import bonsai.tool as tool
Z_ROTATION_ALIGNMENT_TOLERANCE = 1e-9
def _z_rotation_diff(target_z: float, source_z: float) -> float:
"""Signed Z-Euler difference wrapped to [-π, π]."""
return (target_z - source_z + math.pi) % (2 * math.pi) - math.pi
def copy_z_rotation_to_selected(
ifc: type[tool.Ifc],
geometry: type[tool.Geometry],
surveyor: type[tool.Surveyor],
*,
active: bpy.types.Object,
targets: Iterable[bpy.types.Object],
flip: bool = False,
) -> int:
"""Apply ``active``'s Z-Euler rotation to each target."""
source_z = surveyor.get_z_rotation(active)
if flip:
source_z += math.pi
rotated = 0
for obj in targets:
if abs(_z_rotation_diff(surveyor.get_z_rotation(obj), source_z)) < Z_ROTATION_ALIGNMENT_TOLERANCE:
continue
surveyor.set_z_rotation(obj, source_z)
rotated += 1
if ifc.get_entity(obj) is not None:
bonsai.core.geometry.edit_object_placement(ifc, geometry, surveyor, obj=obj)
return rotated
+14 -4
View File
@@ -195,6 +195,14 @@ class Collector:
def assign(cls, obj, should_clean_users_collection=False): pass
@interface
class Connection:
def find_rel(cls, elem_a, elem_b): pass
def find_rels(cls, elem_a, elem_b): pass
def find_rels_for_element(cls, elem): pass
def orient_element_top(cls, rel, elem_a, elem_b): pass
@interface
class Context:
def clear_context(cls): pass
@@ -246,7 +254,6 @@ 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
@@ -347,6 +354,8 @@ class Drawing:
def enable_editing_schedules(cls): pass
def enable_editing_sheets(cls): pass
def enable_editing_text(cls, obj): pass
def ensure_drawings_parent_document(cls): pass
def ensure_drawings_parent_group(cls): pass
def ensure_unique_drawing_name(cls, name): pass
def ensure_unique_identification(cls, identification): pass
def export_font_size(cls, obj): pass
@@ -694,11 +703,13 @@ class Model:
def load_openings(cls, openings): pass
def purge_scene_openings(cls): pass
def recalculate_walls(cls, objs): pass
def recreate_wall(cls, element, obj): pass
def regenerate_array(cls, parent, data): pass
def regenerate_profile(cls, obj): pass
def regenerate_slab(cls, obj): pass
def reload_body_representation(cls, obj_or_objects): pass
def remove_wall_to_underside_booleans(cls, wall): pass
def strip_underside_booleans(cls, wall): pass
def replace_object_ifc_representation(cls, ifc_file, ifc_context, obj, new_representation): pass
@@ -794,7 +805,7 @@ class Profile:
@interface
class Parametric:
def get_geom_generation(cls) -> int: pass
def get_geom_generation(cls): pass
def refresh_post_commit(cls, operator) -> None: pass
@@ -1160,8 +1171,6 @@ class Style:
@interface
class Surveyor:
def get_absolute_matrix(cls, obj): pass
def get_z_rotation(cls, obj): pass
def set_z_rotation(cls, obj, z): pass
@interface
@@ -1198,6 +1207,7 @@ class Type:
def get_representation_context(cls, representation): pass
def get_type_occurrences(cls, element_type): pass
def has_material_usage(cls, element): pass
def is_relating_type_compatible(cls, occurrence, relating_type): pass
def record_material_usage_attributes(cls, element): pass
def restore_material_usage_attributes(cls, element, usage_attributes): pass
def run_geometry_add_representation(cls, obj=None, context=None, ifc_representation_class=None, profile_set_usage=None): pass
+5
View File
@@ -30,7 +30,9 @@ from bonsai.tool.bsdd import Bsdd
from bonsai.tool.cad import Cad
from bonsai.tool.clash import Clash
from bonsai.tool.classification import Classification
from bonsai.tool.clip_box import ClipBox
from bonsai.tool.collector import Collector
from bonsai.tool.connection import Connection
from bonsai.tool.context import Context
from bonsai.tool.cost import Cost
from bonsai.tool.covering import Covering
@@ -78,3 +80,6 @@ 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

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