mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-06 16:01:36 +00:00
Compare commits
100 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ece3790aa | |||
| 6585f0ee2b | |||
| 4ec042595e | |||
| 48f6e2908b | |||
| 1cd7e52c49 | |||
| 2e6f17ed0f | |||
| b549e65ad9 | |||
| ee5d672493 | |||
| 135f4cf023 | |||
| 608d9ead0e | |||
| a0ce930994 | |||
| eafa158ca0 | |||
| 5db955d40c | |||
| df27f86237 | |||
| 0aaafaedc9 | |||
| 7881f5992f | |||
| 679fe4dcae | |||
| a89621b179 | |||
| 110e4050c8 | |||
| 6dafb7a5c2 | |||
| 4cedeec813 | |||
| 6314d9c818 | |||
| 9bbd2b1854 | |||
| b5d36aacf6 | |||
| 6d3bed1f7d | |||
| 0096c0f6a2 | |||
| 661be0d66d | |||
| 62ed650b75 | |||
| da4b2f6eee | |||
| 00ec587296 | |||
| 2a05528b6d | |||
| a5f7f0cd93 | |||
| 1fd7329122 | |||
| f3e047d78e | |||
| 041306c5f0 | |||
| 5fba0026dd | |||
| 4d92a64206 | |||
| 9d2de117a9 | |||
| 6ee3c7a15f | |||
| fdf9970685 | |||
| de65e50fb5 | |||
| 714105b9fd | |||
| f0b5ab860f | |||
| 528964ca56 | |||
| 2c2d0f2434 | |||
| 340d4fb82a | |||
| 65cd5701c3 | |||
| febde1bbbb | |||
| 315835063c | |||
| 1b0a2fd6de | |||
| c95c1905ed | |||
| 301dae103c | |||
| 85cd1c1923 | |||
| 091fc9e7e5 | |||
| a65f291a89 | |||
| e6dc582d82 | |||
| 366fa67a84 | |||
| 4a8b863b96 | |||
| db0bbf6b84 | |||
| a757641b84 | |||
| 8bd22178f7 | |||
| b4d7780e14 | |||
| 7c092db9e6 | |||
| 38e3bb0590 | |||
| 7f49c945b9 | |||
| 81f71e6418 | |||
| 6318610bdb | |||
| 82dd1d94de | |||
| 4004344c20 | |||
| fce7cd3eb4 | |||
| f3dcf0b539 | |||
| f5ebbd1917 | |||
| 77aa080334 | |||
| 5afad2b179 | |||
| 98aa2c635e | |||
| 7db1ceb07f | |||
| 84abf5e918 | |||
| 741e8233ca | |||
| 262b55630c | |||
| f60a3423d0 | |||
| 4f2c4d1633 | |||
| 3b584ef242 | |||
| 10ee5aef4f | |||
| 59383f5010 | |||
| a9d6776875 | |||
| 1057f794f6 | |||
| b3218ced3f | |||
| 49d9c416b1 | |||
| 55e778f82e | |||
| 13681cdf9b | |||
| c592018b3f | |||
| dad4cc8a3c | |||
| 006a24ef32 | |||
| 6fa984ce2b | |||
| 262f5f9a85 | |||
| 44c0c2916c | |||
| 2ab5ca9222 | |||
| f710929e9e | |||
| a2dafc9ceb | |||
| 4f21bd1c69 |
@@ -55,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
|
||||
|
||||
@@ -270,9 +270,9 @@ jobs:
|
||||
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.12+; skip on older versions.
|
||||
# mathutils only has pre-built wheels for Python 3.13+; skip on older versions.
|
||||
cd ../ifcopenshell-python
|
||||
if python -c "import sys; sys.exit(0 if sys.version_info >= (3, 12) else 1)"; then
|
||||
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
|
||||
|
||||
@@ -28,6 +28,7 @@ venv
|
||||
!.vscode/launch.json
|
||||
!.vscode/tasks.json
|
||||
.vs
|
||||
/*.code-workspace
|
||||
|
||||
# PyCharm files
|
||||
.idea
|
||||
@@ -128,3 +129,4 @@ src/ifcopenshell-python/ifcopenshell/express/*.exp.cache.dat
|
||||
*.claude
|
||||
*.py.tmp*
|
||||
*.json.tmp*
|
||||
|
||||
|
||||
@@ -129,6 +129,21 @@ on CI to catch formatting issues.
|
||||
within each package under `src/`.
|
||||
- Run the existing test suite for the package you modified before submitting.
|
||||
|
||||
## In-Progress Feature Notes
|
||||
|
||||
Living design and working notes for unmerged feature branches live in
|
||||
[`docs/dev-notes/`](docs/dev-notes/), one Markdown file per feature, named after the
|
||||
branch. They capture the problem, the design decisions and the *why*, and what still
|
||||
needs testing — so collaborators (and their AI agents) can pick up the context behind a
|
||||
branch. Because the note is committed on the branch, it travels with the PR.
|
||||
|
||||
- Before working on a feature branch, read its note in `docs/dev-notes/` if one exists.
|
||||
- Keep the note current as the PR is refined.
|
||||
- These are not user documentation; at merge they are removed or their durable parts
|
||||
promoted to code comments / permanent docs.
|
||||
|
||||
See [`docs/dev-notes/README.md`](docs/dev-notes/README.md) for details.
|
||||
|
||||
## Architecture Quick Reference
|
||||
|
||||
### Directory Structure
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<!-- This file was generated with the assistance of an AI coding tool. -->
|
||||
|
||||
# Developer notes (in-progress features)
|
||||
|
||||
This directory holds **living design/working notes for unmerged feature branches**,
|
||||
one Markdown file per feature, named after its branch (e.g.
|
||||
`opening-template-on-type.md`).
|
||||
|
||||
## Purpose
|
||||
|
||||
A shared scratchpad so collaborators — and the AI agents they work with — can pick up
|
||||
the context behind an in-progress branch: the problem, the design decisions and the
|
||||
*why*, dead ends already ruled out, and what still needs testing. Because the note is
|
||||
committed on the branch, it travels with the PR and shows up in the diff, so it is
|
||||
discoverable without anyone being told where to look.
|
||||
|
||||
## How to use it (humans and agents)
|
||||
|
||||
- **Before working on a feature branch**, read its note here if one exists.
|
||||
- **As the PR is refined**, keep the note current — append decisions, correct things
|
||||
that changed, update the test checklist.
|
||||
- **One file per feature**, named after the branch.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
These are *not* permanent user documentation. When a PR merges, either remove its note
|
||||
or promote the durable parts (the load-bearing "why") into code comments or the regular
|
||||
docs, so stale notes do not accumulate on the default branch.
|
||||
@@ -0,0 +1,165 @@
|
||||
<!-- This file was generated with the assistance of an AI coding tool. -->
|
||||
|
||||
# Opening template on type — preserving custom openings across duplicate_type / append
|
||||
|
||||
> **Living dev note** for the `opening-template-on-type` branch/PR. Read before working
|
||||
> on the feature; append decisions and findings as the PR is refined. This is *not* user
|
||||
> documentation — at merge it is removed or its durable parts promoted to code comments.
|
||||
> See [README.md](README.md) for the convention.
|
||||
|
||||
## Problem
|
||||
|
||||
`bpy.ops.bim.duplicate_type` and `bpy.ops.bim.append_library_element` lose a custom
|
||||
`IfcOpeningElement` body (e.g. an `IfcPolygonalFaceSet`/tessellation) and replace it
|
||||
with a generated extrusion. Root cause: the only mechanism that preserved a custom
|
||||
opening was "copy it from a sibling occurrence of the same type"
|
||||
(`get_existing_opening_occurrence_if_any`), which returns nothing for a brand-new
|
||||
type. `generate_opening_from_filling` then always builds an extrusion (profile or
|
||||
bbox), discarding the custom geometry.
|
||||
|
||||
## Key facts established
|
||||
|
||||
- IFC-level `root.copy_class` already `copy_deep`s opening representations; the loss
|
||||
happens on the Bonsai side (the `regenerate_from_type` listener on
|
||||
`type.assign_type`, and placement-time generation).
|
||||
- Opening occurrences of one type already **share** a single `IfcRepresentationMap`
|
||||
via mapped representations — that is why editing one void edits them all
|
||||
(see `tool.Model.unshare_opening_representation` docstring). Bonsai shares, it does
|
||||
not copy. The shared map just has no durable home (it is hosted implicitly by
|
||||
whichever occurrence exists), so it does not survive to a new type.
|
||||
- IFC4 ADD2 TC1 `IfcShapeRepresentation`: identifier **`Reference`** = "3D
|
||||
representation that is **not part of the Body representation** ... used, e.g., for
|
||||
opening geometries ... excluded from an implicit Boolean operation." Schema-valid;
|
||||
`IfcTypeProduct` has no uniqueness rule on `RepresentationMaps` (only
|
||||
`ApplicableOccurrence`). So a `Reference` map can sit beside the `Body` map.
|
||||
- The geometry kernel selects an opening's geometry **by context, not by
|
||||
`RepresentationIdentifier`** (`mapping::representation_of`, `ifcgeom/mapping/mapping.cpp`).
|
||||
So a `Reference`-identified opening in the Body context still booleans correctly.
|
||||
Nothing in Bonsai reads `"Reference"` to *skip* applying an opening.
|
||||
- Caveat: IFC has no type-level void (`IfcRelVoidsElement` is occurrence-only). The
|
||||
"opening template on type" is therefore a Bonsai convention using a spec-valid
|
||||
identifier; other tools see a harmless extra `Reference` rep they ignore. The
|
||||
regeneration smarts are Bonsai-only by necessity.
|
||||
|
||||
## Design
|
||||
|
||||
Store the shared opening body on the **type** as a `Reference` representation map.
|
||||
Because `bim.duplicate_type` (`tool.Root.copy_representation`) and
|
||||
`append_type_product` both copy a type's `RepresentationMaps`, the template survives
|
||||
both. Occurrence openings map over the same map, so editing a void rewrites the
|
||||
shared map = updates the type template in one stroke (no separate write-back needed).
|
||||
|
||||
`map_type_representations` must skip `Reference` maps so the window/door occurrence
|
||||
does not receive the opening shape as its own Body (the kernel would otherwise pick
|
||||
arbitrarily between the real Body and the opening rep). The skip is both required and
|
||||
spec-endorsed ("not part of the Body representation").
|
||||
|
||||
### Body-context coexistence (Option A)
|
||||
|
||||
The template lives in the **Body** subcontext (required: the instance opening that maps
|
||||
over it must resolve in Body context for the geometry kernel to subtract it). So the
|
||||
type holds two reps in one context: the `Body` window body and the `Reference` opening
|
||||
template. Per IFC, `Reference` is a *RepresentationIdentifier value used within the Body
|
||||
context*, not a separate context - so we keep it there and disambiguate elsewhere:
|
||||
|
||||
- The representations panel now shows `RepresentationIdentifier` as its own column
|
||||
(`geometry/data.py`, `geometry/ui.py`) so the two Body-context reps are
|
||||
distinguishable (`Model | Body | MODEL_VIEW | Reference | Tessellation`). The panel
|
||||
column previously read "Body" because it shows `ContextOfItems.ContextIdentifier`,
|
||||
not the representation's identifier.
|
||||
- `Geometry.reimport_element_representations` type branch now renders the requested
|
||||
`base_representation` instead of `get_representation(element, context)`, which matched
|
||||
only by context and returned the window body when switching to the `Reference` rep.
|
||||
This is what makes "switch to the Reference row" actually show the void on the type.
|
||||
|
||||
### Precedence in `generate_opening_from_filling`
|
||||
type `Reference` template → (existing sibling occurrence, checked by callers) →
|
||||
type `Profile` extrusion → bbox extrusion.
|
||||
|
||||
### Type switching (assign_type)
|
||||
|
||||
On `type.assign_type` the opening is rebuilt to reflect the **assigned** type's void.
|
||||
Two listeners in `model/handler.py`:
|
||||
|
||||
- **pre** `Bonsai.Opening.PreserveOnTypeChange` → `preserve_opening_on_type_change`:
|
||||
before the filling moves to the new type, `promote_opening_to_type(old_type)` anchors
|
||||
the old type's custom void as a template, so it isn't lost when (possibly the last)
|
||||
occurrence is regenerated. Idempotent; custom voids only.
|
||||
- **post** `Bonsai.Opening.RegenerateFromType` → `regenerate_from_type` →
|
||||
`_regenerate_from_type`: rebuilds from the new type's template / sibling / extrusion.
|
||||
The old PR1 "preserve custom" guard was **removed** here — it kept the previous type's
|
||||
void on a switch (wrong), and the template now makes preservation unnecessary.
|
||||
|
||||
NOTE: upstream `v0.8.0` landed `assign_type` changes + new `test_assign_type_*` tests
|
||||
(merged under this branch's base). The listeners ride on top of that — re-test the
|
||||
switch/edit round-trips against the new `assign_type`.
|
||||
|
||||
### Write-back on void edit
|
||||
|
||||
Editing an occurrence's void writes the new geometry back to the type's `Reference`
|
||||
template via `update_type_template_from_opening` (creates the template if absent), then
|
||||
**re-maps every occurrence's opening onto the template** and reloads the affected host walls
|
||||
(`switch_representation`) so they re-boolean. The re-map (`_remap_opening_to_template`) is the
|
||||
key part: an earlier version only re-pointed a *pre-existing* shared map, so siblings whose
|
||||
openings were **independent** (their own `IfcRepresentationMap`, never sharing the template)
|
||||
didn't follow — the common real-world case. Now they do. Hooked at both commit paths:
|
||||
`UpdateRepresentation._execute` (the `edited_objs` path) and
|
||||
`OverrideModeSetObject` after `edit_representation_item` (the in-place item edit). The
|
||||
older `edit_openings`/`is_edited` path also calls it. `set_type_opening_representation`
|
||||
has replace semantics (one `Reference` map per type).
|
||||
|
||||
### Preserving adjusted extrusions (duplicate_type)
|
||||
|
||||
`is_opening_representation_custom` only flags *non-extrusion* geometry (tessellation, brep,
|
||||
CSG) as worth preserving — a proxy for "not regenerable". That mis-classifies a *manually
|
||||
adjusted* extrusion, which is still an `IfcExtrudedAreaSolid`, so a hand-tweaked extrusion
|
||||
opening was reset to the default on `duplicate_type`.
|
||||
|
||||
`promote_opening_to_type` now gates on `should_preserve_opening` = custom **or**
|
||||
`_is_adjusted_extrusion`. The latter generates the default (`generate_opening_from_filling`,
|
||||
which yields the default since no template exists at promote time) *transiently*, compares the
|
||||
two bodies' axis-aligned bounding boxes (1 mm tolerance) via the geom engine, then removes the
|
||||
temporary default. Divergence ⇒ the extrusion was adjusted ⇒ promote it; a plain default
|
||||
matches ⇒ left regenerable (not frozen — see the "freeze" discussion). Scoped to the duplicate
|
||||
path so the generate-and-compare stays out of the hot predicate. Limitation: bbox comparison
|
||||
misses a shape change that preserves the bbox (upgrade to a vertex-set compare if needed).
|
||||
|
||||
## Status — implemented (manually verified in Blender)
|
||||
|
||||
- core `map_type_representations.py`: skip `Reference` maps.
|
||||
- `model/opening.py`: `get_/set_type_opening_representation`, `promote_opening_to_type`,
|
||||
`update_type_template_from_opening` (+ `_remap_opening_to_template`),
|
||||
`preserve_opening_on_type_change`, `should_preserve_opening` (+ `_is_adjusted_extrusion`,
|
||||
`_representation_bbox`); `generate_opening_from_filling` consults the template; PR1 guard
|
||||
removed from `_regenerate_from_type`.
|
||||
- `model/handler.py`: pre + post assign_type listeners.
|
||||
- `type/operator.py` `DuplicateType`: promote before copy.
|
||||
- `project/operator.py` `AppendLibraryElement`: `harvest_opening_template`.
|
||||
- `geometry/operator.py`: write-back hooks in `UpdateRepresentation` and
|
||||
`OverrideModeSetObject`; `reimport_element_representations` renders the requested rep.
|
||||
- `geometry/data.py` + `geometry/ui.py`: `RepresentationIdentifier` column + headers.
|
||||
|
||||
Branch `opening-template-on-type` (#8200): initial feature commit + the #7916 build-conflict
|
||||
ancestry-merge + void-propagation-to-all-occurrences + adjusted-extrusion preservation. The
|
||||
`docs/dev-notes/` convention itself lives on the stacked branch `dev-notes-system` (#8201).
|
||||
|
||||
Still **deferred:** explicit "Apply/Reset to type" operators + a "diverges from type"
|
||||
indicator; import never auto-writes back. `update_simple_openings` still keeps its
|
||||
`is_opening_representation_custom` guard (array propagation, same type — left as-is).
|
||||
|
||||
## Things to test / verify
|
||||
|
||||
- Duplicated/appended type's new occurrence gets the faceset void and it **cuts** the
|
||||
wall (kernel selects opening geom by context, so a `Reference`-id rep still booleans).
|
||||
- `harvest_opening_template` cross-file `file.add`: no duplicate
|
||||
`IfcGeometricRepresentationContext` left behind; units (kernel doesn't rescale rep
|
||||
coords — same assumption as `append_asset`).
|
||||
- Switch X→Y→X round-trip restores each type's void; switching to a plain (template-less)
|
||||
type gives its default extrusion, not the previous faceset.
|
||||
- Edit a void → type's `Reference` row updates; **all** occurrences follow (including ones
|
||||
that had independent openings) and their host walls re-boolean; survives duplicate.
|
||||
- `duplicate_type` on a type whose extrusion opening was **manually adjusted** → Type B keeps
|
||||
the adjusted extrusion; a type with a plain/default extrusion stays regenerable (not frozen).
|
||||
- Three write-back hooks are intentional (different commit paths) — candidate for
|
||||
consolidation in review.
|
||||
- Re-test against upstream's new `assign_type` (see NOTE under "Type switching").
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 481 B |
@@ -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;
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -52,8 +52,27 @@ def _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()
|
||||
@@ -71,6 +90,8 @@ def register():
|
||||
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:
|
||||
@@ -97,8 +118,11 @@ def unregister():
|
||||
_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()
|
||||
|
||||
@@ -50,6 +50,9 @@ def set_active_camera_resolution(scene: bpy.types.Scene) -> None:
|
||||
if camera.type != props.camera_type:
|
||||
camera.type = props.camera_type
|
||||
|
||||
if props.update_props and (drawing := tool.Ifc.get_entity(camera_obj)):
|
||||
tool.Drawing.sync_perspective_camera_shifts(drawing, camera)
|
||||
|
||||
ortho_scale, aspect_ratio = props.get_scale_and_aspect_ratio()
|
||||
scene_render = scene.render
|
||||
if (camera.ortho_scale != ortho_scale) or not tool.Cad.is_x(
|
||||
|
||||
@@ -57,7 +57,7 @@ import shapely
|
||||
from bpy_extras.image_utils import load_image
|
||||
from bpy_extras.io_utils import ImportHelper
|
||||
from lxml import etree
|
||||
from mathutils import Color, Vector
|
||||
from mathutils import Color, Matrix, Vector
|
||||
|
||||
import bonsai.bim.export_ifc
|
||||
import bonsai.bim.handler
|
||||
@@ -602,6 +602,7 @@ class CreateDrawing(bpy.types.Operator):
|
||||
context_type: Literal["body", "annotation"],
|
||||
drawing_elements: set[ifcopenshell.entity_instance],
|
||||
target_view: str,
|
||||
link_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,7 @@ class CreateDrawing(bpy.types.Operator):
|
||||
tree = ifcopenshell.geom.tree()
|
||||
tree.enable_face_styles(True)
|
||||
|
||||
for ifc_path, ifc in files.items():
|
||||
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()
|
||||
@@ -949,8 +965,8 @@ class CreateDrawing(bpy.types.Operator):
|
||||
# A drawing prioritises a target view context first, followed by a model view context as a fallback.
|
||||
# Specifically for PLAN_VIEW and REFLECTED_PLAN_VIEW, any Plan context is also prioritised.
|
||||
contexts = self.get_linework_contexts(ifc, target_view)
|
||||
self.serialize_contexts_elements(ifc, tree, contexts, "body", drawing_elements, target_view)
|
||||
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"):
|
||||
@@ -2319,7 +2335,8 @@ 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"
|
||||
)
|
||||
|
||||
drawing: bpy.props.IntProperty()
|
||||
@@ -2335,13 +2352,23 @@ 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"},
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
drawing: int
|
||||
should_view_from_camera: bool
|
||||
use_quick_preview: bool
|
||||
load_selected_annotations: 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
|
||||
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 +2381,18 @@ class ActivateDrawingBase(tool.Ifc.Operator):
|
||||
if props.is_editing_drawings == False:
|
||||
bpy.ops.bim.load_drawings()
|
||||
|
||||
if self.load_selected_annotations:
|
||||
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 tool.Ifc.get_object(selected_drawing):
|
||||
tool.Drawing.import_drawing(selected_drawing)
|
||||
tool.Drawing.import_annotations_in_group(tool.Drawing.get_drawing_group(selected_drawing))
|
||||
return {"FINISHED"}
|
||||
|
||||
drawing = tool.Ifc.get().by_id(self.drawing)
|
||||
dprops = tool.Drawing.get_document_props()
|
||||
|
||||
@@ -2439,7 +2478,8 @@ 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"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -604,6 +604,7 @@ class BIMCameraProperties(PropertyGroup):
|
||||
return tool.Blender.get_active_uilist_element(dprops.drawing_styles, self.active_drawing_style_index)
|
||||
|
||||
# For now, this JSON dump are all the parameters that determine a camera's "Block representation"
|
||||
# Perspective camera shift is stored in EPset_Drawing and intentionally excluded here.
|
||||
# By checking this, you will know whether or not the camera IFC representation needs to be refreshed
|
||||
def update_representation(self, matrix_world: Matrix) -> bool:
|
||||
"""Update ``representation`` based on current camera properties and the provided world matrix.
|
||||
|
||||
@@ -99,6 +99,10 @@ class BIM_PT_camera(Panel):
|
||||
if props.target_view == "MODEL_VIEW":
|
||||
row = self.layout.row()
|
||||
row.prop(props, "camera_type")
|
||||
if props.camera_type == "PERSP":
|
||||
row = self.layout.row(align=True)
|
||||
row.prop(camera_data, "shift_x", text="Camera Shift X/Y:")
|
||||
row.prop(camera_data, "shift_y", text="")
|
||||
|
||||
row = self.layout.row()
|
||||
row.prop(props, "linework_mode")
|
||||
|
||||
@@ -69,6 +69,7 @@ classes = (
|
||||
operator.RemoveRepresentation,
|
||||
operator.RemoveRepresentationItem,
|
||||
operator.RemoveRepresentationItemFromShapeAspect,
|
||||
operator.SelectByRepresentationType,
|
||||
operator.SelectConnection,
|
||||
operator.SelectRepresentationItem,
|
||||
operator.SwitchRepresentation,
|
||||
|
||||
@@ -138,6 +138,11 @@ class RepresentationsData:
|
||||
"ContextType": representation.ContextOfItems.ContextType or "",
|
||||
"ContextIdentifier": "",
|
||||
"TargetView": "",
|
||||
# The representation's own identifier (e.g. 'Body', 'Reference'), which is
|
||||
# distinct from the subcontext's ContextIdentifier above. Two reps can share
|
||||
# one context (e.g. a Body body and a Reference opening template), so showing
|
||||
# this lets them be told apart in the panel.
|
||||
"RepresentationIdentifier": representation.RepresentationIdentifier or "",
|
||||
"RepresentationType": representation_type or "",
|
||||
"is_active": is_active,
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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/>.
|
||||
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Sequence
|
||||
from time import time
|
||||
from typing import (
|
||||
@@ -30,7 +28,6 @@ from typing import (
|
||||
get_args,
|
||||
)
|
||||
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
@@ -421,6 +418,55 @@ class SelectConnection(bpy.types.Operator, tool.Ifc.Operator):
|
||||
core.select_connection(tool.Geometry, connection=tool.Ifc.get().by_id(self.connection))
|
||||
|
||||
|
||||
class SelectByRepresentationType(bpy.types.Operator):
|
||||
bl_idname = "bim.select_by_representation_type"
|
||||
bl_label = "Select By Representation Type"
|
||||
bl_description = (
|
||||
"Select objects whose active representation matches this type. "
|
||||
"Ctrl+Click to also include objects that have this type in any representation (active or not)"
|
||||
)
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
representation_type: bpy.props.StringProperty()
|
||||
select_inactive: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"})
|
||||
|
||||
def invoke(self, context, event):
|
||||
self.select_inactive = event.ctrl
|
||||
return self.execute(context)
|
||||
|
||||
def execute(self, context):
|
||||
ifc = tool.Ifc.get()
|
||||
if not ifc:
|
||||
return {"CANCELLED"}
|
||||
# Strip the "*" suffix used for mapped/resolved representations.
|
||||
target_type = self.representation_type.rstrip("*")
|
||||
matched = 0
|
||||
for obj in context.visible_objects:
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element:
|
||||
obj.select_set(False)
|
||||
continue
|
||||
if self.select_inactive:
|
||||
# Ctrl: match any representation on the element, active or not.
|
||||
has_type = any(
|
||||
(ifcopenshell.util.representation.resolve_representation(rep).RepresentationType or "") == target_type
|
||||
for rep in ifcopenshell.util.representation.get_representations_iter(element)
|
||||
)
|
||||
else:
|
||||
# Default: match only the currently active (displayed) representation.
|
||||
active_rep = tool.Geometry.get_active_representation(obj)
|
||||
if active_rep is None:
|
||||
obj.select_set(False)
|
||||
continue
|
||||
resolved = ifcopenshell.util.representation.resolve_representation(active_rep)
|
||||
has_type = (resolved.RepresentationType or "") == target_type
|
||||
obj.select_set(has_type)
|
||||
if has_type:
|
||||
matched += 1
|
||||
mode = "any representation" if self.select_inactive else "active representation"
|
||||
self.report({"INFO"}, f"Selected {matched} object(s) with RepresentationType '{target_type}' ({mode})")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class RemoveConnection(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.remove_connection"
|
||||
bl_label = "Remove Connection"
|
||||
@@ -549,6 +595,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
|
||||
@@ -706,6 +759,16 @@ class UpdateRepresentation(bpy.types.Operator, tool.Ifc.Operator):
|
||||
if mprops.ifc_parameters:
|
||||
core.get_representation_ifc_parameters(tool.Geometry, obj=obj)
|
||||
|
||||
# Persist an edited opening void onto its filling type's 'Reference' template so the
|
||||
# change survives type duplication/append/switching and propagates to siblings. This
|
||||
# catches the edited_objs commit path; the in-place item edit is caught in
|
||||
# bim.override_mode_set_object.
|
||||
edited_element = tool.Ifc.get_entity(obj)
|
||||
if edited_element and edited_element.is_a("IfcOpeningElement"):
|
||||
from bonsai.bim.module.model.opening import FilledOpeningGenerator
|
||||
|
||||
FilledOpeningGenerator().update_type_template_from_opening(edited_element)
|
||||
|
||||
|
||||
class UpdateParametricRepresentation(bpy.types.Operator):
|
||||
bl_idname = "bim.update_parametric_representation"
|
||||
@@ -886,6 +949,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):
|
||||
@@ -927,7 +1000,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:
|
||||
@@ -1026,7 +1099,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)
|
||||
@@ -2472,6 +2548,15 @@ class OverrideModeSetObject(bpy.types.Operator, tool.Ifc.Operator):
|
||||
return bpy.ops.bim.edit_boundary_geometry()
|
||||
elif tool.Geometry.is_representation_item(context.active_object):
|
||||
self.edit_representation_item(context.active_object)
|
||||
# If we just edited an opening's void item, persist the new shape onto the
|
||||
# filling type's 'Reference' template so it survives type duplication/append/
|
||||
# switching and propagates to siblings.
|
||||
rep_obj = tool.Geometry.get_geometry_props().representation_obj
|
||||
edited_element = tool.Ifc.get_entity(rep_obj) if rep_obj else None
|
||||
if edited_element and edited_element.is_a("IfcOpeningElement"):
|
||||
from bonsai.bim.module.model.opening import FilledOpeningGenerator
|
||||
|
||||
FilledOpeningGenerator().update_type_template_from_opening(edited_element)
|
||||
tool.Root.reload_item_decorator()
|
||||
# So you can keep hitting tab to cycle out of edit mode
|
||||
context.active_object.select_set(False)
|
||||
@@ -2696,26 +2781,6 @@ class OverrideModeSetObject(bpy.types.Operator, tool.Ifc.Operator):
|
||||
tool.Root.reload_item_decorator()
|
||||
tool.Geometry.reload_representation(props.representation_obj)
|
||||
|
||||
# apply changes to mirrored object, if it exists
|
||||
if props.representation_obj and (representation_elem := tool.Ifc.get_entity(props.representation_obj)):
|
||||
type_elem = ifcopenshell.util.element.get_type(representation_elem)
|
||||
if type_elem and (mirrored_type := tool.Blender.Modifier.has_mirrored_type(type_elem)):
|
||||
# object has mirror, update it as well
|
||||
for map_index, repr_map in enumerate(type_elem.RepresentationMaps):
|
||||
new_items = []
|
||||
for item in repr_map.MappedRepresentation.Items:
|
||||
new_mirrored_repr = ifcopenshell.util.element.copy_deep(tool.Ifc.get(), item)
|
||||
builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get())
|
||||
builder.mirror(new_mirrored_repr, (1, 0), create_copy=False)
|
||||
new_items.append(new_mirrored_repr)
|
||||
|
||||
old_items = mirrored_type.RepresentationMaps[map_index].MappedRepresentation.Items
|
||||
mirrored_type.RepresentationMaps[map_index].MappedRepresentation.Items = new_items
|
||||
for old_item in old_items:
|
||||
ifcopenshell.util.element.remove_deep2(tool.Ifc.get(), old_item)
|
||||
|
||||
tool.Geometry.reload_representation(tool.Ifc.get_object(mirrored_type))
|
||||
|
||||
def enable_edit_mode(self, context):
|
||||
if tool.Blender.toggle_edit_mode(context) == {"CANCELLED"}:
|
||||
return {"CANCELLED"}
|
||||
@@ -3189,7 +3254,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
|
||||
|
||||
@@ -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
|
||||
@@ -148,12 +148,29 @@ class BIM_PT_representations(Panel):
|
||||
self.layout.label(text="No Representations Found")
|
||||
return
|
||||
|
||||
header = self.layout.row(align=True)
|
||||
header.label(text="Context")
|
||||
header.label(text="Subcontext")
|
||||
header.label(text="View")
|
||||
header.label(text="Identifier")
|
||||
header.label(text="Type")
|
||||
# Blank icon cells reserve the same width as the switch/remove buttons below so the
|
||||
# text columns line up with the data rows.
|
||||
header.label(text="", icon="BLANK1")
|
||||
header.label(text="", icon="BLANK1")
|
||||
|
||||
for representation in RepresentationsData.data["representations"]:
|
||||
row = self.layout.row(align=True)
|
||||
row.label(text=representation["ContextType"])
|
||||
row.label(text=representation["ContextIdentifier"])
|
||||
row.label(text=representation["TargetView"])
|
||||
row.label(text=representation["RepresentationType"])
|
||||
row.label(text=representation["RepresentationIdentifier"])
|
||||
op = row.operator(
|
||||
"bim.select_by_representation_type",
|
||||
text=representation["RepresentationType"],
|
||||
emboss=False,
|
||||
)
|
||||
op.representation_type = representation["RepresentationType"]
|
||||
op = row.operator(
|
||||
"bim.switch_representation",
|
||||
icon="FILE_REFRESH" if representation["is_active"] else "OUTLINER_DATA_MESH",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -588,7 +588,6 @@ class EditAssignedMaterial(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bpy.ops.bim.unassign_material(obj=obj.name)
|
||||
bpy.ops.bim.assign_material(obj=obj.name, material_type="IfcMaterial")
|
||||
bpy.ops.bim.disable_editing_assigned_material(obj=active_obj.name)
|
||||
self.copy_material_to_mirrored(element)
|
||||
return {"FINISHED"}
|
||||
|
||||
material_set = self.file.by_id(self.material_set)
|
||||
@@ -652,24 +651,6 @@ class EditAssignedMaterial(bpy.types.Operator, tool.Ifc.Operator):
|
||||
|
||||
bpy.ops.bim.disable_editing_assigned_material(obj=active_obj.name)
|
||||
|
||||
self.copy_material_to_mirrored(element)
|
||||
|
||||
def copy_material_to_mirrored(self, element: ifcopenshell.entity_instance):
|
||||
type_element = ifcopenshell.util.element.get_type(element)
|
||||
if not type_element:
|
||||
return
|
||||
|
||||
material = ifcopenshell.util.element.get_material(type_element)
|
||||
if not material:
|
||||
return
|
||||
|
||||
mirrored_type = tool.Blender.Modifier.has_mirrored_type(type_element)
|
||||
if not mirrored_type:
|
||||
return
|
||||
|
||||
ifcopenshell.api.material.assign_material(tool.Ifc.get(), [mirrored_type], material.is_a(), material)
|
||||
tool.Material.ensure_material_assigned([mirrored_type], material.is_a(), material)
|
||||
|
||||
|
||||
class EnableEditingMaterialSetItemProfile(bpy.types.Operator):
|
||||
bl_idname = "bim.enable_editing_material_set_item_profile"
|
||||
|
||||
@@ -82,7 +82,6 @@ classes = (
|
||||
product.DrawOccurrence,
|
||||
product.LoadTypeThumbnails,
|
||||
product.MirrorElements,
|
||||
product.TrueMirrorElements,
|
||||
product.SetActiveType,
|
||||
workspace.Hotkey,
|
||||
workspace.BIM_MT_add_representation_item,
|
||||
@@ -111,6 +110,7 @@ classes = (
|
||||
wall.GizmoWallFilletPreview,
|
||||
wall.GizmoWallFilletReedit,
|
||||
wall.GizmoWallFilletToggleOpenings,
|
||||
wall.GizmoPairDisconnect,
|
||||
wall.GizmoSlabEdition,
|
||||
wall.GizmoSlabUnjoinWalls,
|
||||
wall.GizmoWallJoinIntersection,
|
||||
@@ -280,9 +280,7 @@ classes = (
|
||||
mep.MEPAddObstruction,
|
||||
mep.MEPAddTransition,
|
||||
mep.MEPAddBend,
|
||||
mep.MEPUnjoinAtPort,
|
||||
mep.MEPRemoveTerminalFitting,
|
||||
mep.MEPUnjoinPair,
|
||||
mep.SelectMEPPathMembers,
|
||||
mep.MEPJoinSegments,
|
||||
mep_bend_preview.EnableBendPreview,
|
||||
@@ -367,8 +365,6 @@ def register():
|
||||
)
|
||||
|
||||
bpy.types.VIEW3D_MT_add.prepend(ui.add_menu)
|
||||
bpy.types.VIEW3D_MT_object_context_menu.append(ui.object_menu)
|
||||
bpy.types.VIEW3D_MT_object.append(ui.object_menu)
|
||||
bpy.app.handlers.load_post.append(handler.load_post)
|
||||
|
||||
workspace.load_custom_icons()
|
||||
@@ -401,7 +397,5 @@ def unregister():
|
||||
|
||||
bpy.app.handlers.load_post.remove(handler.load_post)
|
||||
bpy.types.VIEW3D_MT_add.remove(ui.add_menu)
|
||||
bpy.types.VIEW3D_MT_object_context_menu.remove(ui.object_menu)
|
||||
bpy.types.VIEW3D_MT_object.remove(ui.object_menu)
|
||||
|
||||
workspace.unload_custom_icons()
|
||||
|
||||
@@ -421,22 +421,26 @@ 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"]):
|
||||
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)
|
||||
# Coalesce host recuts: the child-delete loop, the regenerate, and the
|
||||
# per-child opening mirror all touch the same host body. Without batching,
|
||||
# an N-child wipe-then-regen costs N+1 recuts; this collapses to one.
|
||||
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)
|
||||
|
||||
|
||||
class RemoveArray(bpy.types.Operator, tool.Ifc.Operator):
|
||||
@@ -471,23 +475,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):
|
||||
|
||||
@@ -53,12 +53,6 @@ from bonsai.bim.module.drawing.gizmos import (
|
||||
from bonsai.bim.module.drawing.helper import format_distance
|
||||
|
||||
|
||||
def transparent_color(color, alpha=0.1):
|
||||
color = [i for i in color]
|
||||
color[3] = alpha
|
||||
return color
|
||||
|
||||
|
||||
def highlight_color(color, alpha=0.1):
|
||||
color = [i + (1 - i) * 0.5 for i in color]
|
||||
return color
|
||||
@@ -133,7 +127,7 @@ class ProfileDecorator:
|
||||
|
||||
def draw_faces(self, bm, vertices_coords):
|
||||
"""Submit a non-mutating beauty-triangulated TRIS batch over ``bm``'s faces."""
|
||||
faces_color = transparent_color(self.addon_prefs.decorator_color_special)
|
||||
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):
|
||||
@@ -263,7 +257,7 @@ class ProfileDecorator:
|
||||
self.draw_batch("LINES", all_vertices, unselected_elements_color, unselected_edges)
|
||||
self.draw_batch("LINES", all_vertices, selected_elements_color, selected_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)
|
||||
@@ -354,9 +348,11 @@ class ProfileDecorator:
|
||||
return points, listEdg
|
||||
|
||||
|
||||
class PolylineDecorator:
|
||||
is_installed = False
|
||||
handlers = []
|
||||
class PolylineDecorator(tool.Blender.ViewportDecorator):
|
||||
# draw_methods declares only the always-bound handler so the base's
|
||||
# __init_subclass__ validation passes; the override install below
|
||||
# conditionally registers up to four more handlers based on ui_only.
|
||||
draw_methods = (("draw_input_ui", "POST_PIXEL"),)
|
||||
event = None
|
||||
input_type = None
|
||||
input_ui = None
|
||||
@@ -392,15 +388,6 @@ class PolylineDecorator:
|
||||
cls.handlers.append(SpaceView3D.draw_handler_add(handler, (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
|
||||
|
||||
@classmethod
|
||||
def update(
|
||||
cls,
|
||||
@@ -459,14 +446,6 @@ class PolylineDecorator:
|
||||
|
||||
return {"verts": verts, "edges": edges, "tris": tris}
|
||||
|
||||
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 shader_config(self, context):
|
||||
self.addon_prefs = tool.Blender.get_addon_preferences()
|
||||
self.decorator_color = self.addon_prefs.decorations_colour
|
||||
@@ -734,7 +713,9 @@ class PolylineDecorator:
|
||||
if self.polyline_data.measurement_type == "POLY_AREA" and area:
|
||||
if float(area) > 0:
|
||||
tris = self.calculate_polygon(polyline_verts)["tris"]
|
||||
self.draw_batch("TRIS", polyline_verts, transparent_color(self.decorator_color_special), tris)
|
||||
self.draw_batch(
|
||||
"TRIS", polyline_verts, tool.Blender.transparent_color(self.decorator_color_special), tris
|
||||
)
|
||||
|
||||
# Draw polyline with selected points
|
||||
self.line_shader.uniform_float("lineWidth", 2.0)
|
||||
@@ -987,9 +968,8 @@ class PolylineDecorator:
|
||||
self.draw_batch("LINES", polyline_verts, decorator_color_unselected, polyline_edges)
|
||||
|
||||
|
||||
class ProductDecorator:
|
||||
is_installed = False
|
||||
handlers = []
|
||||
class ProductDecorator(tool.Blender.ViewportDecorator):
|
||||
draw_method = "draw_product_preview"
|
||||
preview_mode: Literal["PROFILE_VERTICAL", "PROFILE_HORIZONTAL", "LAYER2", "LAYER3", "GENERIC"]
|
||||
relating_type = None
|
||||
obj_data: dict[str, list] = {}
|
||||
@@ -1032,29 +1012,7 @@ class ProductDecorator:
|
||||
)
|
||||
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)
|
||||
|
||||
def draw_product_preview(self, context):
|
||||
def transparent_color(color, alpha=0.1):
|
||||
color = [i for i in color]
|
||||
color[3] = alpha
|
||||
return color
|
||||
|
||||
self.addon_prefs = tool.Blender.get_addon_preferences()
|
||||
self.line_shader = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR")
|
||||
self.line_shader.bind() # required to be able to change uniforms of the shader
|
||||
@@ -1086,7 +1044,7 @@ class ProductDecorator:
|
||||
data = self.get_generic_preview_data()
|
||||
if data:
|
||||
self.draw_batch("LINES", data["verts"], decorator_color, data["edges"])
|
||||
self.draw_batch("TRIS", data["verts"], transparent_color(decorator_color), data["tris"])
|
||||
self.draw_batch("TRIS", data["verts"], tool.Blender.transparent_color(decorator_color), data["tris"])
|
||||
|
||||
def get_wall_preview_data(self):
|
||||
relating_type = self.relating_type
|
||||
@@ -1619,34 +1577,8 @@ class ProductDecorator:
|
||||
return data
|
||||
|
||||
|
||||
class WallAxisDecorator:
|
||||
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_wall_axis, (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 WallAxisDecorator(tool.Blender.ViewportDecorator):
|
||||
draw_method = "draw_wall_axis"
|
||||
|
||||
def draw_wall_axis(self, context):
|
||||
self.addon_prefs = tool.Blender.get_addon_preferences()
|
||||
@@ -1665,7 +1597,7 @@ class WallAxisDecorator:
|
||||
self.line_shader.uniform_float("lineWidth", 2.0)
|
||||
for obj in context.selected_objects:
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if element.is_a("IfcWall"):
|
||||
if element and element.is_a("IfcWall"):
|
||||
layers = tool.Model.get_material_layer_parameters(element)
|
||||
axis = tool.Model.get_wall_axis(obj, layers)
|
||||
side = [tuple(list(v) + [obj.location.z]) for v in axis["side"]]
|
||||
@@ -1685,34 +1617,8 @@ class WallAxisDecorator:
|
||||
self.draw_batch("LINES", arrow, unselected_elements_color, [(0, 1), (1, 2), (1, 3)])
|
||||
|
||||
|
||||
class SlabDirectionDecorator:
|
||||
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_wall_axis, (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 SlabDirectionDecorator(tool.Blender.ViewportDecorator):
|
||||
draw_method = "draw_wall_axis"
|
||||
|
||||
def draw_wall_axis(self, context):
|
||||
self.addon_prefs = tool.Blender.get_addon_preferences()
|
||||
@@ -1742,41 +1648,10 @@ class SlabDirectionDecorator:
|
||||
self.draw_batch("LINES", base, selected_elements_color, [(0, 1)])
|
||||
|
||||
|
||||
class FaceAreaDecorator:
|
||||
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_face_area, (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 FaceAreaDecorator(tool.Blender.ViewportDecorator):
|
||||
draw_method = "draw_face_area"
|
||||
|
||||
def draw_face_area(self, context):
|
||||
def transparent_color(color, alpha=0.1):
|
||||
color = [i for i in color]
|
||||
color[3] = alpha
|
||||
return color
|
||||
|
||||
self.addon_prefs = tool.Blender.get_addon_preferences()
|
||||
self.line_shader = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR")
|
||||
self.line_shader.bind() # required to be able to change uniforms of the shader
|
||||
@@ -1797,12 +1672,16 @@ class FaceAreaDecorator:
|
||||
if data:
|
||||
self.draw_batch("POINTS", data["verts"], decorator_color)
|
||||
self.draw_batch("LINES", data["verts"], decorator_color, data["edges"])
|
||||
self.draw_batch("TRIS", data["verts"], transparent_color(decorator_color, alpha=0.5), data["tris"])
|
||||
self.draw_batch(
|
||||
"TRIS", data["verts"], tool.Blender.transparent_color(decorator_color, alpha=0.5), data["tris"]
|
||||
)
|
||||
|
||||
|
||||
class BoundingBoxDecorator:
|
||||
is_installed = False
|
||||
handlers = []
|
||||
class BoundingBoxDecorator(tool.Blender.ViewportDecorator):
|
||||
draw_methods = (
|
||||
("draw_bounding_box_wire_cube", "POST_VIEW"),
|
||||
("draw_dimension_text", "POST_PIXEL"),
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
context = bpy.context
|
||||
@@ -1813,31 +1692,6 @@ class BoundingBoxDecorator:
|
||||
self.decorator_color_wire = (*theme.view_3d.bone_solid, 1)
|
||||
self.decorator_color_special = tool.Blender.get_addon_preferences().decorator_color_special
|
||||
|
||||
@classmethod
|
||||
def install(cls, context):
|
||||
if cls.is_installed:
|
||||
cls.uninstall()
|
||||
handler = cls()
|
||||
cls.handlers.append(
|
||||
bpy.types.SpaceView3D.draw_handler_add(
|
||||
handler.draw_bounding_box_wire_cube, (context,), "WINDOW", "POST_VIEW"
|
||||
)
|
||||
)
|
||||
cls.handlers.append(
|
||||
bpy.types.SpaceView3D.draw_handler_add(handler.draw_dimension_text, (context,), "WINDOW", "POST_PIXEL")
|
||||
)
|
||||
cls.is_installed = True
|
||||
|
||||
@classmethod
|
||||
def uninstall(cls):
|
||||
for handler in cls.handlers:
|
||||
try:
|
||||
bpy.types.SpaceView3D.draw_handler_remove(handler, "WINDOW")
|
||||
except Exception:
|
||||
pass
|
||||
cls.handlers.clear()
|
||||
cls.is_installed = False
|
||||
|
||||
@staticmethod
|
||||
def get_combined_bounding_box_corners(objects):
|
||||
|
||||
@@ -1910,14 +1764,6 @@ class BoundingBoxDecorator:
|
||||
]
|
||||
return trihedron[best_origin]
|
||||
|
||||
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_background(self, context, coords_dim, text_dim):
|
||||
padding = 5
|
||||
theme = context.preferences.themes.items()[0][1]
|
||||
@@ -2569,14 +2415,20 @@ class _ConnectedNetworkPathDecorator(tool.Blender.ViewportDecorator):
|
||||
CONNECTION_EPS_SQ = 1e-4 * 1e-4
|
||||
|
||||
def __init__(self) -> None:
|
||||
# Two-tier cache. Walk cache keyed on (start_guid, ifc_file): re-walk
|
||||
# only on selection change or file reload. Compare ``ifc_file`` with
|
||||
# Walk cache keyed on (start_guid, ifc_file, geom_gen). Stores STEP
|
||||
# integer ids rather than ``entity_instance`` references — re-resolved
|
||||
# via ``ifc_file.by_id`` on each cache hit. Structurally rules out
|
||||
# the dangling-SWIG-handle class of bug: an entity removed between
|
||||
# frames either bumps geom_gen (cache miss → re-walk) or fails to
|
||||
# re-resolve (handled below by re-walking). Compare ``ifc_file`` with
|
||||
# ``is`` (not id()) so a GC-recycled id() can't produce a false hit.
|
||||
self._cached_start_guid: str | None = None
|
||||
self._cached_ifc_file: Any = None
|
||||
self._cached_walk: list[Any] = []
|
||||
# Geometry cache: shared TokenCache so resolved world-space lines +
|
||||
# dots re-build on every depsgraph / undo / redo / load.
|
||||
self._cached_geom_gen: int = -1
|
||||
self._cached_walk_ids: list[int] = []
|
||||
# Geometry cache: shared TokenCache. Key folds in geom_gen so IFC
|
||||
# mutations that don't surface via the depsgraph still flush the
|
||||
# resolved world-space lines and dots.
|
||||
self._geom_cache: TokenCache[
|
||||
tuple[
|
||||
list[tuple[tuple[float, float, float], tuple[float, float, float]]],
|
||||
@@ -2739,9 +2591,22 @@ class _ConnectedNetworkPathDecorator(tool.Blender.ViewportDecorator):
|
||||
start_guid = start_element.GlobalId
|
||||
if start_guid == self._failed_seed_guid:
|
||||
return
|
||||
if start_guid == self._cached_start_guid and ifc_file is self._cached_ifc_file and self._cached_walk:
|
||||
connected = self._cached_walk
|
||||
else:
|
||||
current_geom_gen = tool.Parametric.get_geom_generation()
|
||||
connected: list[Any] | None = None
|
||||
if (
|
||||
start_guid == self._cached_start_guid
|
||||
and ifc_file is self._cached_ifc_file
|
||||
and current_geom_gen == self._cached_geom_gen
|
||||
and self._cached_walk_ids
|
||||
):
|
||||
try:
|
||||
connected = [ifc_file.by_id(eid) for eid in self._cached_walk_ids]
|
||||
except RuntimeError:
|
||||
# An entity was removed without bumping geom_gen — rare but
|
||||
# possible from non-operator code paths. Force a re-walk
|
||||
# rather than feeding a stale handle to _build_geometry.
|
||||
connected = None
|
||||
if connected is None:
|
||||
try:
|
||||
connected = self._walk(start_element)
|
||||
except Exception:
|
||||
@@ -2750,12 +2615,13 @@ class _ConnectedNetworkPathDecorator(tool.Blender.ViewportDecorator):
|
||||
|
||||
traceback.print_exc()
|
||||
self._walk_failure_logged = True
|
||||
self._cached_walk = []
|
||||
self._cached_walk_ids = []
|
||||
self._failed_seed_guid = start_guid
|
||||
return
|
||||
self._cached_start_guid = start_guid
|
||||
self._cached_ifc_file = ifc_file
|
||||
self._cached_walk = connected
|
||||
self._cached_geom_gen = current_geom_gen
|
||||
self._cached_walk_ids = [e.id() for e in connected]
|
||||
if not connected:
|
||||
return
|
||||
|
||||
@@ -2770,7 +2636,7 @@ class _ConnectedNetworkPathDecorator(tool.Blender.ViewportDecorator):
|
||||
|
||||
try:
|
||||
lines, free_points, connection_points = self._geom_cache.get_or_compute(
|
||||
(start_guid, id(ifc_file)),
|
||||
(start_guid, id(ifc_file), current_geom_gen),
|
||||
lambda: self._build_geometry(connected),
|
||||
)
|
||||
except Exception:
|
||||
@@ -2836,7 +2702,7 @@ class MEPSystemPathDecorator(_ConnectedNetworkPathDecorator):
|
||||
lines: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = []
|
||||
port_positions: list[tuple[float, float, float]] = []
|
||||
for element in connected:
|
||||
if element.is_a("IfcFlowSegment"):
|
||||
if element and element.is_a("IfcFlowSegment"):
|
||||
if not tool.Geometry.has_axis_representation(element):
|
||||
continue
|
||||
obj = tool.Ifc.get_object(element)
|
||||
|
||||
@@ -100,11 +100,6 @@ def update_door_modifier_representation(obj: bpy.types.Object) -> None:
|
||||
model_representation = ifcopenshell.api.geometry.add_door_representation(ifc_file, **representation_data)
|
||||
representation_data["part_of_product"] = None
|
||||
tool.Model.replace_object_ifc_representation(body, obj, model_representation)
|
||||
|
||||
material_target_element = element
|
||||
if (type_elem := ifcopenshell.util.element.get_type(element)) and tool.Blender.Modifier.is_door(type_elem):
|
||||
material_target_element = type_elem
|
||||
|
||||
if fallback_material := (int(props.lining_material) or int(props.framing_material) or int(props.glazing_material)):
|
||||
materials = {
|
||||
"Lining": tool.Ifc.get().by_id(int(props.lining_material) or fallback_material),
|
||||
@@ -114,12 +109,12 @@ def update_door_modifier_representation(obj: bpy.types.Object) -> None:
|
||||
materials["Glazing"] = tool.Ifc.get().by_id(int(props.glazing_material) or fallback_material)
|
||||
ifcopenshell.api.material.set_shape_aspect_constituents(
|
||||
ifc_file,
|
||||
element=material_target_element,
|
||||
element=element,
|
||||
context=body,
|
||||
materials=materials,
|
||||
)
|
||||
elif material := ifcopenshell.util.element.get_material(material_target_element):
|
||||
ifcopenshell.api.material.unassign_material(ifc_file, products=[material_target_element])
|
||||
elif material := ifcopenshell.util.element.get_material(element):
|
||||
ifcopenshell.api.material.unassign_material(ifc_file, products=[element])
|
||||
if not material.is_a("IfcMaterial") and not ifc_file.get_total_inverses(material):
|
||||
ifcopenshell.api.material.remove_material_set(ifc_file, material=material)
|
||||
|
||||
@@ -613,68 +608,8 @@ class FinishEditingDoor(_DoorEditMixin, bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_description = "Apply changes and finish editing door parameters"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
def finish_editing_door_on_object(self, obj: bpy.types.Object) -> None:
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
assert element
|
||||
if not tool.Blender.Modifier.is_door(element):
|
||||
return
|
||||
props = tool.Model.get_door_props(obj)
|
||||
|
||||
door_data = props.get_general_kwargs(convert_to_project_units=True)
|
||||
lining_props = props.get_lining_kwargs(convert_to_project_units=True)
|
||||
panel_props = props.get_panel_kwargs(convert_to_project_units=True)
|
||||
|
||||
door_data["lining_properties"] = lining_props
|
||||
door_data["panel_properties"] = panel_props
|
||||
|
||||
props.is_editing = False
|
||||
|
||||
update_door_modifier_representation(obj)
|
||||
element_type = ifcopenshell.util.element.get_type(element)
|
||||
if element_type:
|
||||
tool.Model.mark_thumbnail_for_update(element_type)
|
||||
|
||||
pset = tool.Pset.get_element_pset(element, "BBIM_Door")
|
||||
door_data_str = tool.Ifc.get().createIfcText(json.dumps(door_data, default=list))
|
||||
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Data": door_data_str})
|
||||
|
||||
if mirrored_type := tool.Blender.Modifier.has_mirrored_type(element):
|
||||
# object has mirrored repr, update it as well
|
||||
self.copy_door_params(door_data, element, mirrored_type)
|
||||
|
||||
def copy_door_params(self, from_data, from_elem, to_elem):
|
||||
if "RIGHT" in from_data["door_type"]:
|
||||
from_data["door_type"] = from_data["door_type"].replace("RIGHT", "LEFT")
|
||||
else:
|
||||
from_data["door_type"] = from_data["door_type"].replace("LEFT", "RIGHT")
|
||||
|
||||
to_pset = tool.Pset.get_element_pset(to_elem, "BBIM_Door")
|
||||
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=to_pset, properties={"Data": json.dumps(from_data)})
|
||||
|
||||
# reload door representation
|
||||
from_data.update(from_data.pop("lining_properties"))
|
||||
from_data.update(from_data.pop("panel_properties"))
|
||||
from_data.update(tool.Model.get_constituents_props_data(from_elem))
|
||||
|
||||
to_obj = tool.Ifc.get_object(to_elem)
|
||||
props = tool.Model.get_door_props(to_obj)
|
||||
|
||||
# we need this workaround because set_props_kwargs_from_ifc_data will
|
||||
# "update" the mesh of the active object, which will switch its representation
|
||||
prev_active = bpy.context.view_layer.objects.active
|
||||
bpy.context.view_layer.objects.active = to_obj
|
||||
|
||||
props.set_props_kwargs_from_ifc_data(from_data)
|
||||
|
||||
bpy.context.view_layer.objects.active = prev_active
|
||||
|
||||
update_door_modifier_representation(to_obj)
|
||||
tool.Model.mark_thumbnail_for_update(to_elem)
|
||||
|
||||
def _execute(self, context: bpy.types.Context) -> set[str]: # noqa: ARG002
|
||||
for obj in tool.Blender.get_selected_objects():
|
||||
self.finish_editing_door_on_object(obj)
|
||||
return {"FINISHED"}
|
||||
def _execute(self, context: bpy.types.Context) -> set[str]:
|
||||
return self._finish_targets(context)
|
||||
|
||||
|
||||
class EnableEditingDoor(_DoorEditMixin, bpy.types.Operator, tool.Ifc.Operator):
|
||||
|
||||
@@ -41,6 +41,12 @@ def load_post(*args):
|
||||
profile.DumbProfileRegenerator().regenerate_from_profile,
|
||||
)
|
||||
|
||||
ifcopenshell.api.add_pre_listener(
|
||||
"type.assign_type",
|
||||
"Bonsai.Opening.PreserveOnTypeChange",
|
||||
opening.FilledOpeningGenerator().preserve_opening_on_type_change,
|
||||
)
|
||||
|
||||
ifcopenshell.api.add_post_listener(
|
||||
"type.assign_type",
|
||||
"Bonsai.Opening.RegenerateFromType",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -693,27 +693,6 @@ def get_connected_element_at_segment_port(segment, at_segment_start):
|
||||
return tool.System.get_port_relating_element(connected_port)
|
||||
|
||||
|
||||
def find_fitting_between_segments(segment_a, segment_b):
|
||||
"""Single IfcFlowFitting bridging segment_a and segment_b via ports, or
|
||||
``None`` if no fitting (or multiple fittings — only direct one-fitting
|
||||
joins handled)."""
|
||||
if not (segment_a.is_a("IfcFlowSegment") and segment_b.is_a("IfcFlowSegment")):
|
||||
return None
|
||||
b_ports_set = set(tool.System.get_ports(segment_b))
|
||||
for a_port in tool.System.get_ports(segment_a):
|
||||
connected_port = tool.System.get_connected_port(a_port)
|
||||
if connected_port is None:
|
||||
continue
|
||||
fitting = tool.System.get_port_relating_element(connected_port)
|
||||
if fitting is None or not fitting.is_a("IfcFlowFitting"):
|
||||
continue
|
||||
for fitting_port in tool.System.get_ports(fitting):
|
||||
other_port = tool.System.get_connected_port(fitting_port)
|
||||
if other_port is not None and other_port in b_ports_set:
|
||||
return fitting
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_active_mep_segment(operator, context):
|
||||
"""Return the operator's target ``IfcFlowSegment`` or ``None`` after reporting.
|
||||
|
||||
@@ -808,52 +787,6 @@ class MEPAddObstruction(bpy.types.Operator, tool.Ifc.Operator):
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class MEPUnjoinAtPort(bpy.types.Operator, tool.Ifc.Operator):
|
||||
"""Delete the IfcFlowFitting that bridges a segment's port to a second element.
|
||||
|
||||
Used when the connection at the port is in the JOINED state (the fitting
|
||||
has at least one other port connecting to a different element). The
|
||||
segment isn't resized — only the bridging fitting is removed. Refuses
|
||||
to act on an OBSTRUCTION fitting (those are routed through
|
||||
``bim.mep_add_obstruction`` with mode=REMOVE which extends the segment
|
||||
to absorb the freed length)."""
|
||||
|
||||
bl_idname = "bim.mep_unjoin_at_port"
|
||||
bl_label = "Unjoin MEP Segment at Port"
|
||||
bl_description = "Disconnect the segment from the fitting at the named port (deletes the fitting)"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
segment_id: bpy.props.IntProperty(name="Segment Element ID", default=0)
|
||||
position: bpy.props.EnumProperty(
|
||||
name="Port",
|
||||
items=[
|
||||
("START", "At Start", "Operate on the segment's start port"),
|
||||
("END", "At End", "Operate on the segment's end port"),
|
||||
],
|
||||
default="END",
|
||||
)
|
||||
|
||||
def _execute(self, context):
|
||||
resolved = _require_port_state(self, context, PORT_JOINED, "joining")
|
||||
if resolved is None:
|
||||
return {"CANCELLED"}
|
||||
element, at_segment_start = resolved
|
||||
|
||||
fitting = get_connected_element_at_segment_port(element, at_segment_start)
|
||||
if fitting is None or not fitting.is_a("IfcFlowFitting"):
|
||||
self.report({"ERROR"}, "Connected port does not lead to a fitting.")
|
||||
return {"CANCELLED"}
|
||||
if getattr(fitting, "PredefinedType", None) == "OBSTRUCTION":
|
||||
self.report({"ERROR"}, "Obstruction fittings are removed via bim.mep_add_obstruction (mode=REMOVE).")
|
||||
return {"CANCELLED"}
|
||||
|
||||
fitting_obj = tool.Ifc.get_object(fitting)
|
||||
if fitting_obj is None:
|
||||
self.report({"ERROR"}, "Fitting has no Blender object.")
|
||||
return {"CANCELLED"}
|
||||
tool.Geometry.delete_ifc_object(fitting_obj)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class MEPRemoveTerminalFitting(bpy.types.Operator, tool.Ifc.Operator):
|
||||
"""Remove the terminal fitting at a segment's named port.
|
||||
|
||||
@@ -906,44 +839,6 @@ class MEPRemoveTerminalFitting(bpy.types.Operator, tool.Ifc.Operator):
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class MEPUnjoinPair(bpy.types.Operator, tool.Ifc.Operator):
|
||||
"""Delete the IfcFlowFitting joining two selected MEP segments.
|
||||
|
||||
Removes the fitting; segments are left in place for the user to reposition."""
|
||||
|
||||
bl_idname = "bim.mep_unjoin_pair"
|
||||
bl_label = "Unjoin MEP Segments"
|
||||
bl_description = "Delete the fitting joining the two selected MEP segments"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
if not _n_mep_selected(2):
|
||||
cls.poll_message_set("Select exactly 2 MEP segments joined by a fitting.")
|
||||
return False
|
||||
return True
|
||||
|
||||
def _execute(self, context):
|
||||
selected_objs = tool.Blender.get_selected_objects()
|
||||
elements = [tool.Ifc.get_entity(o) for o in selected_objs]
|
||||
if any(e is None or not e.is_a("IfcFlowSegment") for e in elements):
|
||||
self.report({"ERROR"}, "Both selected objects must be MEP segments.")
|
||||
return {"CANCELLED"}
|
||||
fitting = find_fitting_between_segments(elements[0], elements[1])
|
||||
if fitting is None:
|
||||
self.report({"ERROR"}, "No single fitting joins the selected segments.")
|
||||
return {"CANCELLED"}
|
||||
if getattr(fitting, "PredefinedType", None) == "OBSTRUCTION":
|
||||
self.report({"ERROR"}, "Obstruction fittings are removed via bim.mep_add_obstruction (mode=REMOVE).")
|
||||
return {"CANCELLED"}
|
||||
fitting_obj = tool.Ifc.get_object(fitting)
|
||||
if fitting_obj is None:
|
||||
self.report({"ERROR"}, "Fitting has no Blender object.")
|
||||
return {"CANCELLED"}
|
||||
tool.Geometry.delete_ifc_object(fitting_obj)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class SelectMEPPathMembers(bpy.types.Operator):
|
||||
"""Replace the selection with every MEP element reachable from the active
|
||||
one via IfcRelConnectsPorts — the entire connected distribution network."""
|
||||
@@ -2677,10 +2572,22 @@ def _active_mep_has_connected_neighbor(obj: bpy.types.Object) -> bool:
|
||||
|
||||
|
||||
def _active_is_bend_fitting(obj: bpy.types.Object) -> bool:
|
||||
"""True iff the active object is a parametric BEND fitting eligible for
|
||||
the bend-preview re-edit path. Re-edit reads parameters from the type's
|
||||
``BBIM_Fitting`` pset, so that pset's presence is the ground truth for
|
||||
re-editability — not the body representation class. The bend creation
|
||||
path tessellates the swept-disk body as an upstream-geometry-kernel
|
||||
workaround, so a freshly-committed bend's body contains only an
|
||||
``IfcTriangulatedFaceSet`` and ``has_parametric_body`` correctly
|
||||
returns False for it; the pset gate is what keeps the pen icon
|
||||
eligible."""
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not _is_bend_fitting(element):
|
||||
return False
|
||||
return tool.System.has_parametric_body(element)
|
||||
element_type = ifcopenshell.util.element.get_type(element)
|
||||
if element_type is None:
|
||||
return False
|
||||
return ifcopenshell.util.element.get_pset(element_type, "BBIM_Fitting") is not None
|
||||
|
||||
|
||||
class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
|
||||
@@ -2763,20 +2670,20 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
|
||||
),
|
||||
IconActionConfig(
|
||||
name="unjoin_start",
|
||||
icon="VIEW3D_GT_unjoin",
|
||||
operator="bim.mep_unjoin_at_port",
|
||||
icon="VIEW3D_GT_wall_link_toggle",
|
||||
operator="bim.disconnect_elements",
|
||||
visibility_condition=lambda obj: _selection_size() == 1 and _active_is_flow_segment(obj),
|
||||
),
|
||||
IconActionConfig(
|
||||
name="unjoin_end",
|
||||
icon="VIEW3D_GT_unjoin",
|
||||
operator="bim.mep_unjoin_at_port",
|
||||
icon="VIEW3D_GT_wall_link_toggle",
|
||||
operator="bim.disconnect_elements",
|
||||
visibility_condition=lambda obj: _selection_size() == 1 and _active_is_flow_segment(obj),
|
||||
),
|
||||
IconActionConfig(
|
||||
name="unjoin_pair",
|
||||
icon="VIEW3D_GT_unjoin",
|
||||
operator="bim.mep_unjoin_pair",
|
||||
icon="VIEW3D_GT_wall_link_toggle",
|
||||
operator="bim.disconnect_elements",
|
||||
visibility_condition=lambda _active: _n_mep_selected(2),
|
||||
),
|
||||
]
|
||||
@@ -2794,7 +2701,17 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if element is None or not tool.System.is_mep_element(element):
|
||||
return False
|
||||
return tool.System.has_parametric_body(element)
|
||||
if tool.System.has_parametric_body(element):
|
||||
return True
|
||||
# Bend fittings carry their parametric definition in the type's
|
||||
# ``BBIM_Fitting`` pset because the bend creation path tessellates
|
||||
# the swept-disk body (upstream geometry-kernel workaround), so
|
||||
# ``has_parametric_body`` returns False for them. Fall back to the
|
||||
# pset gate so the pen icon (re_edit_bend) stays reachable.
|
||||
element_type = ifcopenshell.util.element.get_type(element)
|
||||
if element_type is None:
|
||||
return False
|
||||
return ifcopenshell.util.element.get_pset(element_type, "BBIM_Fitting") is not None
|
||||
|
||||
def setup(self, context: bpy.types.Context) -> None:
|
||||
super().setup(context)
|
||||
@@ -2802,11 +2719,13 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
|
||||
|
||||
@classmethod
|
||||
def _wire_anchored_icon_targets(cls, group) -> None:
|
||||
"""Pre-fill ``position`` (and ``mode`` for open-lock) on each anchored
|
||||
icon so a click dispatches to the right port without a per-frame
|
||||
property write; apply the warning-red hover colour to destructive
|
||||
icons. Takes any object with ``action_<name>_gizmo`` attributes so
|
||||
tests can exercise the wiring without instantiating the GizmoGroup."""
|
||||
"""Pre-fill ``position`` (and ``mode`` for open-lock) on the lock
|
||||
icons so a click dispatches to the right port without a per-frame
|
||||
property write, and pre-bind the unified ``bim.disconnect_elements``
|
||||
operator on each unjoin icon so :py:meth:`position_gizmos` only has
|
||||
to update the two GUIDs per frame. Takes any object with
|
||||
``action_<name>_gizmo`` attributes so tests can exercise the wiring
|
||||
without instantiating the GizmoGroup."""
|
||||
for config_name, (_icon, position_arg) in cls.LOCK_ICON_CONFIGS.items():
|
||||
gz = getattr(group, f"action_{config_name}_gizmo", None)
|
||||
if gz is None:
|
||||
@@ -2820,19 +2739,12 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
|
||||
op_props = gz.target_set_operator("bim.mep_remove_terminal_fitting")
|
||||
op_props.position = position_arg
|
||||
|
||||
for config_name, position_arg in (("unjoin_start", "START"), ("unjoin_end", "END")):
|
||||
gz = getattr(group, f"action_{config_name}_gizmo", None)
|
||||
if gz is None:
|
||||
continue
|
||||
op_props = gz.target_set_operator("bim.mep_unjoin_at_port")
|
||||
op_props.position = position_arg
|
||||
|
||||
warning_color = gizmo.get_warning_color_from_prefs(tool.Blender.get_addon_preferences())
|
||||
group.unjoin_op_props = {}
|
||||
for config_name in cls.UNJOIN_CONFIGS:
|
||||
gz = getattr(group, f"action_{config_name}_gizmo", None)
|
||||
if gz is None:
|
||||
continue
|
||||
gz.color_highlight = warning_color
|
||||
group.unjoin_op_props[config_name] = gz.target_set_operator("bim.disconnect_elements")
|
||||
|
||||
def position_gizmos(self, context: bpy.types.Context) -> None:
|
||||
"""Lay out icons across three regions: row above bbox top, segment
|
||||
@@ -2899,6 +2811,10 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
|
||||
if not visible:
|
||||
gz.hide = True
|
||||
continue
|
||||
if config.name.startswith("unjoin_"):
|
||||
if not self._bind_unjoin_at_port(config.name, obj, endpoint_kind == "START"):
|
||||
gz.hide = True
|
||||
continue
|
||||
if segment_endpoints is None:
|
||||
segment_endpoints = tool.Model.get_flow_segment_axis(obj)
|
||||
start_world, end_world = segment_endpoints
|
||||
@@ -2910,7 +2826,7 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
|
||||
if len(selected) == 2:
|
||||
elements = [tool.Ifc.get_entity(o) for o in selected]
|
||||
if all(e is not None and e.is_a("IfcFlowSegment") for e in elements):
|
||||
pair_fitting = find_fitting_between_segments(elements[0], elements[1]) or False
|
||||
pair_fitting = tool.System.find_bridging_fitting(elements[0], elements[1]) or False
|
||||
else:
|
||||
pair_fitting = False
|
||||
else:
|
||||
@@ -2922,6 +2838,13 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
|
||||
gz.hide = True
|
||||
continue
|
||||
|
||||
if config.name == "unjoin_pair":
|
||||
selected = tool.Blender.get_selected_objects()
|
||||
pair_elements = [tool.Ifc.get_entity(o) for o in selected]
|
||||
if not self._bind_unjoin_pair(pair_elements):
|
||||
gz.hide = True
|
||||
continue
|
||||
|
||||
if not bend_anchor_attempted:
|
||||
bend_anchor = compute_mep_join_location()
|
||||
bend_anchor_attempted = True
|
||||
@@ -2950,3 +2873,33 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup):
|
||||
if name in self.ENDPOINT_CONFIGS:
|
||||
return self.ICON_SCALE * self.ENDPOINT_SCALE_RATIO
|
||||
return self.ICON_SCALE
|
||||
|
||||
def _bind_unjoin_at_port(self, config_name: str, segment_obj: bpy.types.Object, at_segment_start: bool) -> bool:
|
||||
"""Resolve the fitting at the named port and bind both GUIDs on the
|
||||
pre-wired ``bim.disconnect_elements`` op_props. Returns False when
|
||||
the partner is unresolvable (port not joined to a disconnectable
|
||||
fitting), and the caller hides the icon."""
|
||||
element = tool.Ifc.get_entity(segment_obj)
|
||||
if element is None:
|
||||
return False
|
||||
fitting = get_connected_element_at_segment_port(element, at_segment_start)
|
||||
if fitting is None or not fitting.is_a("IfcFlowFitting"):
|
||||
return False
|
||||
if getattr(fitting, "PredefinedType", None) == "OBSTRUCTION":
|
||||
return False
|
||||
op_props = self.unjoin_op_props[config_name]
|
||||
op_props.element_a_guid = element.GlobalId
|
||||
op_props.element_b_guid = fitting.GlobalId
|
||||
return True
|
||||
|
||||
def _bind_unjoin_pair(self, pair_elements: list[ifcopenshell.entity_instance | None]) -> bool:
|
||||
"""Bind both segment GUIDs on the pair-disconnect icon's pre-wired
|
||||
``bim.disconnect_elements`` op_props. Returns False when either side
|
||||
is missing a GlobalId (e.g. selection lost an active object), and
|
||||
the caller hides the icon."""
|
||||
if len(pair_elements) != 2 or any(e is None for e in pair_elements):
|
||||
return False
|
||||
op_props = self.unjoin_op_props["unjoin_pair"]
|
||||
op_props.element_a_guid = pair_elements[0].GlobalId
|
||||
op_props.element_b_guid = pair_elements[1].GlobalId
|
||||
return True
|
||||
|
||||
@@ -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
|
||||
@@ -37,7 +39,7 @@ import numpy as np
|
||||
import shapely
|
||||
from bpy.types import Operator, SpaceView3D
|
||||
from gpu_extras.batch import batch_for_shader
|
||||
from mathutils import Euler, Matrix, Vector
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
import bonsai.core.geometry
|
||||
import bonsai.tool as tool
|
||||
@@ -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,
|
||||
@@ -283,7 +309,7 @@ class FilledOpeningGenerator:
|
||||
else:
|
||||
new_matrix.translation.x = point_on_side_axis.x
|
||||
new_matrix.translation.y = point_on_side_axis.y
|
||||
new_matrix = new_matrix @ Euler((0, 0, radians(180.0))).to_matrix().to_4x4()
|
||||
new_matrix = new_matrix @ Matrix.Rotation(radians(180.0), 4, "Z")
|
||||
|
||||
if should_set_z_level:
|
||||
if filling.is_a("IfcDoor"):
|
||||
@@ -336,29 +362,11 @@ class FilledOpeningGenerator:
|
||||
existing_opening_occurrence, "Model", "Body", "MODEL_VIEW"
|
||||
)
|
||||
assert representation
|
||||
|
||||
# Check if mapped representation - preserve it
|
||||
if (representation.RepresentationType == 'MappedRepresentation' and
|
||||
len(representation.Items) == 1 and
|
||||
representation.Items[0].is_a("IfcMappedItem")):
|
||||
source_rep = representation.Items[0].MappingSource.MappedRepresentation
|
||||
representation = ifcopenshell.util.element.copy_deep(
|
||||
tool.Ifc.get(),
|
||||
source_rep,
|
||||
exclude=["IfcGeometricRepresentationContext"]
|
||||
)
|
||||
else:
|
||||
representation = ifcopenshell.util.representation.resolve_representation(representation)
|
||||
representation = ifcopenshell.util.representation.resolve_representation(representation)
|
||||
else:
|
||||
# Check for library template before generating from filling
|
||||
template_rep = self.get_opening_template_from_type(filling)
|
||||
|
||||
if template_rep:
|
||||
representation = template_rep
|
||||
else:
|
||||
representation = self.generate_opening_from_filling(
|
||||
filling, filling_obj, opening_thickness_si=opening_thickness_si
|
||||
)
|
||||
representation = self.generate_opening_from_filling(
|
||||
filling, filling_obj, opening_thickness_si=opening_thickness_si
|
||||
)
|
||||
|
||||
# Create mapped representation
|
||||
if reuse_mapped_representation:
|
||||
@@ -410,18 +418,35 @@ 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 preserve_opening_on_type_change(
|
||||
self, usecase_path: str, ifc_file: ifcopenshell.file, settings: dict[str, Any]
|
||||
) -> None:
|
||||
"""Pre-listener for type.assign_type: anchor the old type's void before reassigning.
|
||||
|
||||
A custom void that lives only on an occurrence (the type has no 'Reference'
|
||||
template) would be lost when that occurrence is moved to another type - the
|
||||
post-assign regeneration replaces it. Promoting it onto its current type first
|
||||
keeps it durable, so switching back later restores it. Idempotent and only acts on
|
||||
genuinely custom (non-extrusion) voids.
|
||||
"""
|
||||
relating_type = settings.get("relating_type")
|
||||
for related_object in settings.get("related_objects") or []:
|
||||
if not getattr(related_object, "FillsVoids", None):
|
||||
continue
|
||||
old_type = ifcopenshell.util.element.get_type(related_object)
|
||||
if old_type and old_type != relating_type:
|
||||
self.promote_opening_to_type(old_type)
|
||||
|
||||
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
|
||||
@@ -431,128 +456,53 @@ class FilledOpeningGenerator:
|
||||
opening = filling.FillsVoids[0].RelatingOpeningElement
|
||||
voided_element = opening.VoidsElements[0].RelatingBuildingElement
|
||||
|
||||
# Always regenerate the opening to reflect the *assigned* type's void: its
|
||||
# 'Reference' template if it has one (generate_opening_from_filling consults it),
|
||||
# else a sibling occurrence's opening, else a generated extrusion. We deliberately
|
||||
# do NOT preserve the previous type's custom void on a type change - a custom void
|
||||
# now survives duplicate_type/append by being anchored on the type as a template
|
||||
# (promote_opening_to_type / harvest), so keeping the old void here would just show
|
||||
# the wrong type's opening (e.g. switching to a plain type would keep the faceset).
|
||||
opening_rep = ifcopenshell.util.representation.get_representation(opening, "Model", "Body", "MODEL_VIEW")
|
||||
|
||||
# ALWAYS preserve the existing opening representation (Tessellation, SweptSolid, etc.)
|
||||
preserved_representation = None
|
||||
if opening_rep:
|
||||
if (opening_rep.RepresentationType == 'MappedRepresentation' and
|
||||
len(opening_rep.Items) == 1 and
|
||||
opening_rep.Items[0].is_a("IfcMappedItem")):
|
||||
# For mapped representations, copy the underlying representation
|
||||
preserved_representation = ifcopenshell.util.element.copy_deep(
|
||||
tool.Ifc.get(),
|
||||
opening_rep.Items[0].MappingSource.MappedRepresentation,
|
||||
exclude=["IfcGeometricRepresentationContext"]
|
||||
)
|
||||
else:
|
||||
# For direct representations (non-mapped), copy them too
|
||||
preserved_representation = ifcopenshell.util.element.copy_deep(
|
||||
tool.Ifc.get(),
|
||||
opening_rep,
|
||||
exclude=["IfcGeometricRepresentationContext"]
|
||||
)
|
||||
|
||||
ifcopenshell.api.geometry.unassign_representation(tool.Ifc.get(), product=opening, representation=opening_rep)
|
||||
ifcopenshell.api.geometry.remove_representation(tool.Ifc.get(), representation=opening_rep)
|
||||
|
||||
existing_opening_occurrence = self.get_existing_opening_occurrence_if_any(filling)
|
||||
|
||||
# Priority order for choosing representation:
|
||||
# 1. Existing occurrence with Tessellation (best quality)
|
||||
# 2. Library template with Tessellation
|
||||
# 3. Preserved representation from old opening (maintain user's work)
|
||||
# 4. Generate from filling (last resort)
|
||||
|
||||
representation_to_use = None
|
||||
|
||||
|
||||
if existing_opening_occurrence:
|
||||
representation = ifcopenshell.util.representation.get_representation(
|
||||
existing_opening_occurrence, "Model", "Body", "MODEL_VIEW"
|
||||
)
|
||||
|
||||
if (representation and
|
||||
representation.RepresentationType == 'MappedRepresentation' and
|
||||
len(representation.Items) == 1 and
|
||||
representation.Items[0].is_a("IfcMappedItem")):
|
||||
source_rep = representation.Items[0].MappingSource.MappedRepresentation
|
||||
# Prefer Tessellation from existing occurrence over preserved representation
|
||||
if source_rep.RepresentationType == 'Tessellation':
|
||||
representation_to_use = ifcopenshell.util.element.copy_deep(
|
||||
tool.Ifc.get(),
|
||||
source_rep,
|
||||
exclude=["IfcGeometricRepresentationContext"]
|
||||
)
|
||||
else:
|
||||
representation_to_use = ifcopenshell.util.representation.resolve_representation(representation)
|
||||
|
||||
if not representation_to_use:
|
||||
template_rep = self.get_opening_template_from_type(filling)
|
||||
if template_rep and template_rep.RepresentationType == 'Tessellation':
|
||||
representation_to_use = template_rep
|
||||
|
||||
if not representation_to_use and preserved_representation:
|
||||
representation_to_use = preserved_representation
|
||||
|
||||
if not representation_to_use:
|
||||
representation = ifcopenshell.util.representation.resolve_representation(representation)
|
||||
mapped_representation = ifcopenshell.api.geometry.map_representation(
|
||||
tool.Ifc.get(), representation=representation
|
||||
)
|
||||
ifcopenshell.api.geometry.assign_representation(
|
||||
tool.Ifc.get(), product=opening, representation=mapped_representation
|
||||
)
|
||||
else:
|
||||
opening_obj = tool.Ifc.get_object(opening)
|
||||
if opening_obj:
|
||||
tool.Ifc.unlink(element=opening)
|
||||
tool.Blender.remove_data_blocks([opening_obj], remove_unused_data=True)
|
||||
|
||||
filling_obj = tool.Ifc.get_object(filling)
|
||||
representation_to_use = self.generate_opening_from_filling(filling, filling_obj)
|
||||
representation = self.generate_opening_from_filling(filling, filling_obj)
|
||||
mapped_representation = ifcopenshell.api.geometry.map_representation(
|
||||
tool.Ifc.get(), representation=representation
|
||||
)
|
||||
ifcopenshell.api.geometry.assign_representation(
|
||||
tool.Ifc.get(), product=opening, representation=mapped_representation
|
||||
)
|
||||
|
||||
mapped_representation = ifcopenshell.api.geometry.map_representation(
|
||||
tool.Ifc.get(), representation=representation_to_use
|
||||
)
|
||||
ifcopenshell.api.geometry.assign_representation(
|
||||
tool.Ifc.get(), product=opening, representation=mapped_representation
|
||||
)
|
||||
|
||||
# update voided object representation...
|
||||
# update voided object representation or all it's parts if it's an aggregate
|
||||
voided_elements = ifcopenshell.util.element.get_parts(voided_element) or [voided_element]
|
||||
for voided_element in voided_elements:
|
||||
voided_obj = tool.Ifc.get_object(voided_element)
|
||||
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,
|
||||
)
|
||||
|
||||
def get_opening_template_from_type(self, filling: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]:
|
||||
"""
|
||||
Check if the filling's type has a stored opening template from library import.
|
||||
"""
|
||||
element_type = ifcopenshell.util.element.get_type(filling)
|
||||
|
||||
if not element_type:
|
||||
return None
|
||||
|
||||
desc = element_type.Description
|
||||
|
||||
if not desc or "||BonsaiOpeningTemplate:" not in desc:
|
||||
return None
|
||||
|
||||
# Extract template ID
|
||||
marker = desc.split("||BonsaiOpeningTemplate:")[-1]
|
||||
template_id = int(marker.split("||")[0])
|
||||
|
||||
try:
|
||||
template_rep = tool.Ifc.get().by_id(template_id)
|
||||
# Make a copy so we don't reuse the same representation instance
|
||||
copied = ifcopenshell.util.element.copy_deep(
|
||||
tool.Ifc.get(),
|
||||
template_rep,
|
||||
exclude=["IfcGeometricRepresentationContext"]
|
||||
)
|
||||
return copied
|
||||
except:
|
||||
return None
|
||||
tool.Geometry.recut_host(voided_obj, representation)
|
||||
|
||||
def generate_opening_from_filling(
|
||||
self,
|
||||
@@ -569,6 +519,14 @@ class FilledOpeningGenerator:
|
||||
profile = None
|
||||
filling_type = ifcopenshell.util.element.get_type(filling)
|
||||
if filling_type:
|
||||
# A stored opening template (e.g. a custom IfcPolygonalFaceSet carried
|
||||
# across bim.duplicate_type / append) takes priority over generating a
|
||||
# default extrusion. Returning the shared template representation lets the
|
||||
# caller's map_representation reuse its IfcRepresentationMap, so this
|
||||
# opening stays in sync with the type template and its sibling occurrences.
|
||||
opening_template = self.get_type_opening_representation(filling_type)
|
||||
if opening_template is not None:
|
||||
return opening_template
|
||||
profile = ifcopenshell.util.representation.get_representation(
|
||||
filling_type, "Model", "Profile", "ELEVATION_VIEW"
|
||||
)
|
||||
@@ -646,10 +604,10 @@ class FilledOpeningGenerator:
|
||||
# making sure if min_x or min_z != 0 to shift the opening accordingly
|
||||
# to prevent something like #2784
|
||||
if not has_width_attribute:
|
||||
opening_position.x = min(v[0] for v in filling_obj.bound_box) / unit_scale
|
||||
opening_position.x = min(v[0] for v in filling_obj.bound_box)
|
||||
|
||||
if not has_height_attribute:
|
||||
opening_position.z = min(v[2] for v in filling_obj.bound_box) / unit_scale
|
||||
opening_position.z = min(v[2] for v in filling_obj.bound_box)
|
||||
|
||||
extrusion = shape_builder.extrude(
|
||||
shape_builder.rectangle(size=opening_size),
|
||||
@@ -666,6 +624,228 @@ class FilledOpeningGenerator:
|
||||
return True
|
||||
return False
|
||||
|
||||
def is_opening_representation_custom(self, opening: ifcopenshell.entity_instance) -> bool:
|
||||
"""Whether the opening's Body has user-authored geometry rather than a generated extrusion.
|
||||
|
||||
Openings produced by ``generate_opening_from_filling`` always consist of a
|
||||
single ``IfcExtrudedAreaSolid``. Anything else (a tessellation such as an
|
||||
``IfcPolygonalFaceSet``, a brep, a CSG solid, etc.) was authored by the user
|
||||
and must not be silently replaced with a default extrusion.
|
||||
"""
|
||||
representation = ifcopenshell.util.representation.get_representation(opening, "Model", "Body", "MODEL_VIEW")
|
||||
if not representation:
|
||||
return False
|
||||
representation = ifcopenshell.util.representation.resolve_representation(representation)
|
||||
return any(not item.is_a("IfcExtrudedAreaSolid") for item in representation.Items)
|
||||
|
||||
def should_preserve_opening(self, opening: ifcopenshell.entity_instance) -> bool:
|
||||
"""Whether an opening's geometry is worth anchoring on the type as a template.
|
||||
|
||||
True for user-authored geometry (a tessellation, brep, etc.) or a *manually adjusted*
|
||||
extrusion - one that no longer matches the default ``generate_opening_from_filling``
|
||||
would produce for its filling. A plain generated extrusion is regenerable, so it
|
||||
returns False and is left to regenerate.
|
||||
"""
|
||||
if self.is_opening_representation_custom(opening):
|
||||
return True
|
||||
return self._is_adjusted_extrusion(opening)
|
||||
|
||||
def _is_adjusted_extrusion(self, opening: ifcopenshell.entity_instance) -> bool:
|
||||
"""Whether the opening's extrusion diverges from the default for its filling.
|
||||
|
||||
Generates the default transiently, compares the axis-aligned bounding boxes of the
|
||||
two bodies (both in the opening's local frame), then removes the temporary default.
|
||||
A conservative False is returned when the default cannot be computed.
|
||||
"""
|
||||
filling = opening.HasFillings[0].RelatedBuildingElement if getattr(opening, "HasFillings", None) else None
|
||||
filling_obj = tool.Ifc.get_object(filling) if filling else None
|
||||
current = ifcopenshell.util.representation.get_representation(opening, "Model", "Body", "MODEL_VIEW")
|
||||
if not filling_obj or current is None:
|
||||
return False
|
||||
current = ifcopenshell.util.representation.resolve_representation(current)
|
||||
default_representation = self.generate_opening_from_filling(filling, filling_obj)
|
||||
try:
|
||||
settings = ifcopenshell.geom.settings()
|
||||
current_bbox = self._representation_bbox(settings, current)
|
||||
default_bbox = self._representation_bbox(settings, default_representation)
|
||||
finally:
|
||||
ifcopenshell.api.geometry.remove_representation(tool.Ifc.get(), representation=default_representation)
|
||||
if current_bbox is None or default_bbox is None:
|
||||
return False
|
||||
(cur_min, cur_max), (def_min, def_max) = current_bbox, default_bbox
|
||||
tolerance = 1e-3 # 1 mm; differing extents/position => manually adjusted
|
||||
return bool(np.any(np.abs(cur_min - def_min) > tolerance) or np.any(np.abs(cur_max - def_max) > tolerance))
|
||||
|
||||
@staticmethod
|
||||
def _representation_bbox(settings: Any, representation: ifcopenshell.entity_instance):
|
||||
try:
|
||||
geometry = ifcopenshell.geom.create_shape(settings, representation)
|
||||
except Exception:
|
||||
return None
|
||||
verts = ifcopenshell.util.shape.get_vertices(geometry)
|
||||
if len(verts) == 0:
|
||||
return None
|
||||
return verts.min(axis=0), verts.max(axis=0)
|
||||
|
||||
def get_type_opening_representation(
|
||||
self, filling_type: ifcopenshell.entity_instance
|
||||
) -> Union[ifcopenshell.entity_instance, None]:
|
||||
"""Return the type's stored opening template (its 'Reference' representation), if any.
|
||||
|
||||
The template is the shared opening body anchored on the type as a
|
||||
'Reference'-identified representation map (see
|
||||
:meth:`set_type_opening_representation`). Storing it on the type lets a
|
||||
custom opening survive ``bim.duplicate_type`` and project append, which copy
|
||||
the type's ``RepresentationMaps`` but not an opening shared only between
|
||||
occurrences.
|
||||
"""
|
||||
for representation_map in filling_type.RepresentationMaps or []:
|
||||
representation = representation_map.MappedRepresentation
|
||||
if representation.RepresentationIdentifier == "Reference":
|
||||
return representation
|
||||
|
||||
def set_type_opening_representation(
|
||||
self, filling_type: ifcopenshell.entity_instance, representation: ifcopenshell.entity_instance
|
||||
) -> None:
|
||||
"""Anchor an opening body representation on the type as its 'Reference' template.
|
||||
|
||||
``representation`` is tagged 'Reference' (so it is excluded from the
|
||||
occurrence body geometry, see
|
||||
``ifcopenshell.api.type.map_type_representations``) and the
|
||||
``IfcRepresentationMap`` wrapping it is registered in the type's
|
||||
``RepresentationMaps``, replacing any previous 'Reference' map. The existing map
|
||||
is reused when present so that occurrences mapping over it stay in sync with the
|
||||
type template. Idempotent.
|
||||
"""
|
||||
ifc_file = tool.Ifc.get()
|
||||
representation.RepresentationIdentifier = "Reference"
|
||||
representation_map = next(
|
||||
(i for i in ifc_file.get_inverse(representation) if i.is_a("IfcRepresentationMap")), None
|
||||
)
|
||||
if representation_map is None:
|
||||
mapping_origin = ifc_file.createIfcAxis2Placement3D(
|
||||
ifc_file.createIfcCartesianPoint((0.0, 0.0, 0.0)),
|
||||
ifc_file.createIfcDirection((0.0, 0.0, 1.0)),
|
||||
ifc_file.createIfcDirection((1.0, 0.0, 0.0)),
|
||||
)
|
||||
representation_map = ifc_file.createIfcRepresentationMap(mapping_origin, representation)
|
||||
# Keep all non-'Reference' maps (Body, Annotation, ...) plus this one, dropping any
|
||||
# previous 'Reference' template so the type carries exactly one.
|
||||
new_maps = [
|
||||
m
|
||||
for m in (filling_type.RepresentationMaps or [])
|
||||
if m == representation_map or m.MappedRepresentation.RepresentationIdentifier != "Reference"
|
||||
]
|
||||
if representation_map not in new_maps:
|
||||
new_maps.append(representation_map)
|
||||
filling_type.RepresentationMaps = new_maps
|
||||
|
||||
def update_type_template_from_opening(self, opening: ifcopenshell.entity_instance) -> None:
|
||||
"""Write an edited opening's geometry back to its filling type's 'Reference' template.
|
||||
|
||||
After a user edits an opening's void shape, anchor the new geometry on the type so
|
||||
the change is durable (survives duplicate_type/append and switching the type away
|
||||
and back) and propagates to sibling occurrences. Only acts on custom (non-extrusion)
|
||||
geometry; a re-generated extrusion needs no template.
|
||||
"""
|
||||
if not getattr(opening, "HasFillings", None) or not self.is_opening_representation_custom(opening):
|
||||
return
|
||||
ifc_file = tool.Ifc.get()
|
||||
new_representation = ifcopenshell.util.representation.get_representation(opening, "Model", "Body", "MODEL_VIEW")
|
||||
if not new_representation:
|
||||
return
|
||||
new_representation = ifcopenshell.util.representation.resolve_representation(new_representation)
|
||||
voided_objs_to_reload: set[bpy.types.Object] = set()
|
||||
for rel in opening.HasFillings:
|
||||
filling_type = ifcopenshell.util.element.get_type(rel.RelatedBuildingElement)
|
||||
if not filling_type:
|
||||
continue
|
||||
old_template = self.get_type_opening_representation(filling_type)
|
||||
if old_template is not None and old_template != new_representation:
|
||||
# The edit gave this opening its own geometry; re-point the shared template
|
||||
# map - and therefore every sibling occurrence mapping over it - at the
|
||||
# edited geometry, then drop the now-orphaned old template.
|
||||
for inverse in ifc_file.get_inverse(old_template):
|
||||
if inverse.is_a("IfcRepresentationMap"):
|
||||
inverse.MappedRepresentation = new_representation
|
||||
ifcopenshell.api.geometry.remove_representation(ifc_file, representation=old_template)
|
||||
self.set_type_opening_representation(filling_type, new_representation)
|
||||
|
||||
# Re-map every other occurrence's opening onto the type template so the edit
|
||||
# propagates even to siblings that have their own independent opening geometry
|
||||
# (i.e. openings that never shared the template's IfcRepresentationMap).
|
||||
for occurrence in ifcopenshell.util.element.get_types(filling_type):
|
||||
sibling_opening = (
|
||||
occurrence.FillsVoids[0].RelatingOpeningElement
|
||||
if getattr(occurrence, "FillsVoids", None)
|
||||
else None
|
||||
)
|
||||
if not sibling_opening or sibling_opening == opening:
|
||||
continue
|
||||
if not self._remap_opening_to_template(sibling_opening, new_representation):
|
||||
continue
|
||||
if sibling_opening.VoidsElements:
|
||||
voided_element = sibling_opening.VoidsElements[0].RelatingBuildingElement
|
||||
for part in ifcopenshell.util.element.get_parts(voided_element) or [voided_element]:
|
||||
if voided_obj := tool.Ifc.get_object(part):
|
||||
voided_objs_to_reload.add(voided_obj)
|
||||
|
||||
# Reload affected host objects so the viewport re-booleans with the propagated void.
|
||||
for voided_obj in voided_objs_to_reload:
|
||||
representation = tool.Geometry.get_active_representation(voided_obj)
|
||||
if representation:
|
||||
bonsai.core.geometry.switch_representation(
|
||||
tool.Ifc, tool.Geometry, obj=voided_obj, representation=representation
|
||||
)
|
||||
|
||||
def _remap_opening_to_template(
|
||||
self, opening: ifcopenshell.entity_instance, template_representation: ifcopenshell.entity_instance
|
||||
) -> bool:
|
||||
"""Point an opening's Body at the shared type template, purging its old standalone body.
|
||||
|
||||
:return: True if the opening was changed, False if it already maps over the template.
|
||||
"""
|
||||
ifc_file = tool.Ifc.get()
|
||||
old_body = ifcopenshell.util.representation.get_representation(opening, "Model", "Body", "MODEL_VIEW")
|
||||
if old_body is not None and (
|
||||
ifcopenshell.util.representation.resolve_representation(old_body) == template_representation
|
||||
):
|
||||
return False
|
||||
mapped_representation = ifcopenshell.api.geometry.map_representation(
|
||||
ifc_file, representation=template_representation
|
||||
)
|
||||
# The mapped wrapper is the opening's own Body (the 'Reference' identifier belongs to
|
||||
# the type template it maps over, not to the occurrence's representation).
|
||||
mapped_representation.RepresentationIdentifier = "Body"
|
||||
if old_body is not None:
|
||||
ifcopenshell.api.geometry.unassign_representation(ifc_file, product=opening, representation=old_body)
|
||||
ifcopenshell.api.geometry.remove_representation(ifc_file, representation=old_body)
|
||||
ifcopenshell.api.geometry.assign_representation(ifc_file, product=opening, representation=mapped_representation)
|
||||
return True
|
||||
|
||||
def promote_opening_to_type(self, filling_type: ifcopenshell.entity_instance) -> None:
|
||||
"""Promote a custom opening from an occurrence to a 'Reference' template on the type.
|
||||
|
||||
Called before a type is copied (``bim.duplicate_type``) so that a custom
|
||||
(non-extrusion) opening, currently shared only between occurrences, is
|
||||
anchored on the type itself and therefore carried to the copy. No-op if the
|
||||
type already has a template or has no custom opening to promote.
|
||||
"""
|
||||
if self.get_type_opening_representation(filling_type):
|
||||
return
|
||||
for occurrence in ifcopenshell.util.element.get_types(filling_type):
|
||||
if not getattr(occurrence, "FillsVoids", None):
|
||||
continue
|
||||
opening = occurrence.FillsVoids[0].RelatingOpeningElement
|
||||
if not self.should_preserve_opening(opening):
|
||||
continue
|
||||
representation = ifcopenshell.util.representation.get_representation(
|
||||
opening, "Model", "Body", "MODEL_VIEW"
|
||||
)
|
||||
representation = ifcopenshell.util.representation.resolve_representation(representation)
|
||||
self.set_type_opening_representation(filling_type, representation)
|
||||
return
|
||||
|
||||
def get_existing_opening_occurrence_if_any(
|
||||
self, filling: ifcopenshell.entity_instance
|
||||
) -> Union[ifcopenshell.entity_instance, None]:
|
||||
@@ -687,6 +867,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:
|
||||
@@ -715,12 +920,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()
|
||||
@@ -782,8 +982,6 @@ class FlipFill(bpy.types.Operator, tool.Ifc.Operator):
|
||||
ifcopenshell.api.geometry.edit_object_placement(tool.Ifc.get(), filled_opening, obj.matrix_world)
|
||||
tool.Geometry.reload_representation(filled_object)
|
||||
|
||||
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
@@ -1044,27 +1242,32 @@ 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)
|
||||
# Persist the edited void onto the filling type's 'Reference' template so
|
||||
# it survives type duplication/append/switching and propagates to siblings.
|
||||
self.update_type_template_from_opening(opening_element)
|
||||
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
|
||||
@@ -1102,6 +1305,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
|
||||
@@ -1131,12 +1340,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"}
|
||||
|
||||
@@ -1286,22 +1490,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):
|
||||
@@ -1337,7 +1574,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")
|
||||
@@ -1375,9 +1612,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
|
||||
|
||||
@@ -18,542 +18,15 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from math import cos, pi, radians, tan
|
||||
from typing import Literal, Union
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
import ifcopenshell.util.unit
|
||||
from mathutils import Matrix, Quaternion, Vector
|
||||
from mathutils import Vector
|
||||
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.module.model.decorator import PolylineDecorator
|
||||
from bonsai.bim.module.geometry.decorator import ItemDecorator
|
||||
from typing import Optional, Union, Literal, Any
|
||||
from lark import Lark, Transformer
|
||||
|
||||
|
||||
def create_bmesh_from_vertices(vertices, is_closed=False):
|
||||
bm = bmesh.new()
|
||||
|
||||
new_verts = [bm.verts.new(v) for v in vertices]
|
||||
if is_closed:
|
||||
new_edges = [bm.edges.new((new_verts[i], new_verts[i + 1])) for i in range(len(new_verts) - 1)]
|
||||
new_edges.append(
|
||||
bm.edges.new((new_verts[-1], new_verts[0]))
|
||||
) # Add an edge between the last an first point to make it closed.
|
||||
else:
|
||||
new_edges = [bm.edges.new((new_verts[i], new_verts[i + 1])) for i in range(len(new_verts) - 1)]
|
||||
|
||||
bm.verts.index_update()
|
||||
bm.edges.index_update()
|
||||
return bm
|
||||
|
||||
|
||||
def get_wall_preview_data(context, relating_type):
|
||||
# Get properties from object type
|
||||
model_props = tool.Model.get_model_props()
|
||||
direction_sense = model_props.direction_sense
|
||||
direction = 1
|
||||
if direction_sense == "NEGATIVE":
|
||||
direction = -1
|
||||
|
||||
layers = tool.Model.get_material_layer_parameters(relating_type)
|
||||
if not layers["thickness"]:
|
||||
return
|
||||
thickness = layers["thickness"]
|
||||
thickness *= direction
|
||||
|
||||
offset_type = model_props.offset_type_vertical
|
||||
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
||||
offset = model_props.offset * unit_scale
|
||||
|
||||
height = float(model_props.extrusion_depth)
|
||||
rl = float(model_props.rl1)
|
||||
x_angle = float(model_props.x_angle)
|
||||
if x_angle > radians(90) or x_angle < radians(-90):
|
||||
height *= -1
|
||||
angle_distance = height * tan(x_angle)
|
||||
thickness *= 1 / cos(x_angle)
|
||||
|
||||
data = {}
|
||||
data["verts"] = []
|
||||
|
||||
# Verts
|
||||
polyline_vertices = []
|
||||
polyline_props = tool.Model.get_polyline_props()
|
||||
polyline_data = polyline_props.insertion_polyline
|
||||
polyline_points = polyline_data[0].polyline_points if polyline_data else []
|
||||
if len(polyline_points) < 2:
|
||||
data = []
|
||||
return
|
||||
for point in polyline_points:
|
||||
polyline_vertices.append(Vector((point.x, point.y, point.z)))
|
||||
|
||||
is_closed = False
|
||||
if (
|
||||
polyline_vertices[0].x == polyline_vertices[-1].x
|
||||
and polyline_vertices[0].y == polyline_vertices[-1].y
|
||||
and polyline_vertices[0].z == polyline_vertices[-1].z
|
||||
):
|
||||
is_closed = True
|
||||
polyline_vertices.pop(-1) # Remove the last point. The edges are going to inform that the shape is closed.
|
||||
|
||||
bm_base = create_bmesh_from_vertices(polyline_vertices, is_closed)
|
||||
base_vertices = tool.Cad.offset_edges(bm_base, offset)
|
||||
offset_base_verts = tool.Cad.offset_edges(bm_base, thickness + offset)
|
||||
top_vertices = tool.Cad.offset_edges(bm_base, angle_distance + offset)
|
||||
offset_top_verts = tool.Cad.offset_edges(bm_base, angle_distance + thickness + offset)
|
||||
if is_closed:
|
||||
base_vertices.append(base_vertices[0])
|
||||
offset_base_verts.append(offset_base_verts[0])
|
||||
top_vertices.append(top_vertices[0])
|
||||
offset_top_verts.append(offset_top_verts[0])
|
||||
|
||||
if offset_base_verts is not None:
|
||||
for v in base_vertices:
|
||||
data["verts"].append((v.co.x, v.co.y, v.co.z + rl))
|
||||
|
||||
for v in offset_base_verts[::-1]:
|
||||
data["verts"].append((v.co.x, v.co.y, v.co.z + rl))
|
||||
|
||||
for v in top_vertices:
|
||||
data["verts"].append((v.co.x, v.co.y, v.co.z + rl + height))
|
||||
|
||||
for v in offset_top_verts[::-1]:
|
||||
data["verts"].append((v.co.x, v.co.y, v.co.z + rl + height))
|
||||
|
||||
bm_base.free()
|
||||
|
||||
# Edges and Tris
|
||||
points = []
|
||||
side_edges_1 = []
|
||||
side_edges_2 = []
|
||||
base_edges = []
|
||||
|
||||
for i in range(len(data["verts"])):
|
||||
points.append(Vector(data["verts"][i]))
|
||||
|
||||
n = len(points) // 2
|
||||
bottom_side_1 = [[i, (i + 1) % (n)] for i in range((n - 1) // 2)]
|
||||
bottom_side_2 = [[i, (i + 1) % (n)] for i in range(n // 2, n - 1)]
|
||||
bottom_connections = [[i, n - i - 1] for i in range(n // 2)]
|
||||
bottom_loop = bottom_connections + bottom_side_1 + bottom_side_2
|
||||
side_edges_1.extend(bottom_side_1)
|
||||
side_edges_2.extend(bottom_side_2)
|
||||
base_edges.extend(bottom_loop)
|
||||
|
||||
upper_side_1 = [[i + n for i in edges] for edges in bottom_side_1]
|
||||
upper_side_2 = [[i + n for i in edges] for edges in bottom_side_2]
|
||||
upper_loop = [[i + n for i in edges] for edges in bottom_loop]
|
||||
side_edges_1.extend(upper_side_1)
|
||||
side_edges_2.extend(upper_side_2)
|
||||
base_edges.extend(upper_loop)
|
||||
|
||||
loops = [side_edges_1, side_edges_2, base_edges]
|
||||
|
||||
data["edges"] = []
|
||||
data["tris"] = []
|
||||
for i, group in enumerate(loops):
|
||||
bm = bmesh.new()
|
||||
|
||||
new_verts = [bm.verts.new(v) for v in points]
|
||||
new_edges = [bm.edges.new((new_verts[e[0]], new_verts[e[1]])) for e in group]
|
||||
|
||||
bm.verts.index_update()
|
||||
bm.edges.index_update()
|
||||
|
||||
if i == 2:
|
||||
new_faces = bmesh.ops.contextual_create(bm, geom=bm.edges)
|
||||
new_faces = bmesh.ops.bridge_loops(bm, edges=bm.edges, use_pairs=True, use_cyclic=True)
|
||||
|
||||
bm.verts.index_update()
|
||||
bm.edges.index_update()
|
||||
edges = [[v.index for v in e.verts] for e in bm.edges]
|
||||
tris = [[l.vert.index for l in loop] for loop in bm.calc_loop_triangles()]
|
||||
data["edges"].extend(edges)
|
||||
data["tris"].extend(tris)
|
||||
|
||||
data["edges"] = list(set(tuple(e) for e in data["edges"]))
|
||||
data["tris"] = list(set(tuple(t) for t in data["tris"]))
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def get_slab_preview_data(context, relating_type):
|
||||
model_props = tool.Model.get_model_props()
|
||||
x_angle = 0 if tool.Cad.is_x(model_props.x_angle, 0, tolerance=0.001) else model_props.x_angle
|
||||
direction_sense = model_props.direction_sense
|
||||
direction = 1
|
||||
if direction_sense == "NEGATIVE":
|
||||
direction = -1
|
||||
|
||||
layers = tool.Model.get_material_layer_parameters(relating_type)
|
||||
if not layers["thickness"]:
|
||||
return
|
||||
thickness = layers["thickness"] * abs(1 / cos(x_angle))
|
||||
thickness *= direction
|
||||
|
||||
offset_type = model_props.offset_type_horizontal
|
||||
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
||||
offset = model_props.offset * abs(1 / cos(x_angle)) * unit_scale
|
||||
|
||||
data = {}
|
||||
data["verts"] = []
|
||||
# Verts
|
||||
polyline_vertices = []
|
||||
polyline_props = tool.Model.get_polyline_props()
|
||||
polyline_data = polyline_props.insertion_polyline
|
||||
polyline_points = polyline_data[0].polyline_points if polyline_data else []
|
||||
if len(polyline_points) < 3:
|
||||
data = []
|
||||
return
|
||||
for point in polyline_points:
|
||||
polyline_vertices.append(Vector((point.x, point.y, point.z)))
|
||||
if x_angle:
|
||||
# Get vertices relative to the first polyline point as origin
|
||||
local_vertices = [v - Vector(polyline_vertices[0]) for v in polyline_vertices]
|
||||
# Make the transformation relative to the x_angle
|
||||
transformed_vertices = [Vector((v.x, v.y * (1 / cos(x_angle)), v.z)) for v in local_vertices]
|
||||
# Convert back to world origin
|
||||
polyline_vertices = [v + Vector(polyline_vertices[0]) for v in transformed_vertices]
|
||||
if offset != 0:
|
||||
polyline_vertices = [v + Vector((0, 0, offset)) for v in polyline_vertices]
|
||||
is_closed = True
|
||||
if (
|
||||
polyline_vertices[0].x == polyline_vertices[-1].x
|
||||
and polyline_vertices[0].y == polyline_vertices[-1].y
|
||||
and polyline_vertices[0].z == polyline_vertices[-1].z
|
||||
):
|
||||
polyline_vertices.pop(-1) # Remove the last point. The edges are going to inform that the shape is closed.
|
||||
bm = create_bmesh_from_vertices(polyline_vertices, is_closed)
|
||||
bm.verts.ensure_lookup_table()
|
||||
if x_angle:
|
||||
rot_mat = Matrix.Rotation(x_angle, 3, "X")
|
||||
if abs(x_angle) > (pi / 2):
|
||||
rot_mat = rot_mat @ Matrix.Scale(-1, 3, (0, 1, 0))
|
||||
bmesh.ops.rotate(bm, cent=Vector(bm.verts[0].co), verts=bm.verts, matrix=rot_mat)
|
||||
new_faces = bmesh.ops.contextual_create(bm, geom=bm.edges)
|
||||
new_faces = bmesh.ops.extrude_face_region(bm, geom=bm.edges[:] + bm.faces[:])
|
||||
new_verts = [e for e in new_faces["geom"] if isinstance(e, bmesh.types.BMVert)]
|
||||
new_faces = bmesh.ops.translate(bm, verts=new_verts, vec=(0.0, 0.0, thickness))
|
||||
bm.verts.index_update()
|
||||
bm.edges.index_update()
|
||||
verts = [tuple(v.co) for v in bm.verts]
|
||||
edges = [[v.index for v in e.verts] for e in bm.edges]
|
||||
tris = [[loop.vert.index for loop in triangles] for triangles in bm.calc_loop_triangles()]
|
||||
data["verts"] = verts
|
||||
data["edges"] = edges
|
||||
data["tris"] = tris
|
||||
return data
|
||||
|
||||
|
||||
def get_vertical_profile_preview_data(
|
||||
context: bpy.types.Context, relating_type: ifcopenshell.entity_instance
|
||||
) -> dict[str, Any]:
|
||||
material = ifcopenshell.util.element.get_material(relating_type)
|
||||
try:
|
||||
profile = material.MaterialProfiles[0].Profile
|
||||
except:
|
||||
return {}
|
||||
|
||||
model_props = tool.Model.get_model_props()
|
||||
extrusion_depth = model_props.extrusion_depth
|
||||
cardinal_point = model_props.cardinal_point
|
||||
rot_mat = Quaternion()
|
||||
if relating_type.is_a("IfcBeamType"):
|
||||
y_rot = Quaternion((0.0, 1.0, 0.0), radians(90))
|
||||
z_rot = Quaternion((0.0, 0.0, 1.0), radians(90))
|
||||
rot_mat = y_rot @ z_rot
|
||||
# Get profile data
|
||||
settings = ifcopenshell.geom.settings()
|
||||
settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS)
|
||||
shape = ifcopenshell.geom.create_shape(settings, profile)
|
||||
|
||||
verts = shape.verts
|
||||
if not verts:
|
||||
raise RuntimeError(f"Profile shape has no vertices, it probably is invalid: '{profile}'.")
|
||||
|
||||
edges = shape.edges
|
||||
|
||||
grouped_verts = [[verts[i], verts[i + 1], 0] for i in range(0, len(verts), 3)]
|
||||
grouped_edges = [[edges[i], edges[i + 1]] for i in range(0, len(edges), 2)]
|
||||
|
||||
# Create offsets based on cardinal point
|
||||
min_x = min(v[0] for v in grouped_verts)
|
||||
max_x = max(v[0] for v in grouped_verts)
|
||||
min_y = min(v[1] for v in grouped_verts)
|
||||
max_y = max(v[1] for v in grouped_verts)
|
||||
|
||||
x_offset = (max_x - min_x) / 2
|
||||
y_offset = (max_y - min_y) / 2
|
||||
|
||||
match cardinal_point:
|
||||
case "1":
|
||||
grouped_verts = [(v[0] - x_offset, v[1] + y_offset, v[2]) for v in grouped_verts]
|
||||
case "2":
|
||||
grouped_verts = [(v[0], v[1] + y_offset, v[2]) for v in grouped_verts]
|
||||
case "3":
|
||||
grouped_verts = [(v[0] + x_offset, v[1] + y_offset, v[2]) for v in grouped_verts]
|
||||
case "4":
|
||||
grouped_verts = [(v[0] - x_offset, v[1], v[2]) for v in grouped_verts]
|
||||
case "5":
|
||||
grouped_verts = [(v[0], v[1], v[2]) for v in grouped_verts]
|
||||
case "6":
|
||||
grouped_verts = [(v[0] + x_offset, v[1], v[2]) for v in grouped_verts]
|
||||
case "7":
|
||||
grouped_verts = [(v[0] - x_offset, v[1] - y_offset, v[2]) for v in grouped_verts]
|
||||
case "8":
|
||||
grouped_verts = [(v[0], v[1] - y_offset, v[2]) for v in grouped_verts]
|
||||
case "9":
|
||||
grouped_verts = [(v[0] + x_offset, v[1] - y_offset, v[2]) for v in grouped_verts]
|
||||
|
||||
# Create extrusion bmesh
|
||||
bm = bmesh.new()
|
||||
|
||||
grouped_verts.append(grouped_verts[0]) # Close profile
|
||||
new_verts = [bm.verts.new(v) for v in grouped_verts]
|
||||
new_edges = [bm.edges.new((new_verts[i], new_verts[i + 1])) for i in range(len(grouped_verts) - 1)]
|
||||
|
||||
bm.verts.index_update()
|
||||
bm.edges.index_update()
|
||||
|
||||
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.001)
|
||||
|
||||
new_faces = bmesh.ops.contextual_create(bm, geom=bm.edges)
|
||||
|
||||
new_faces = bmesh.ops.extrude_face_region(bm, geom=bm.faces, use_dissolve_ortho_edges=True)
|
||||
new_verts = [e for e in new_faces["geom"] if isinstance(e, bmesh.types.BMVert)]
|
||||
new_faces = bmesh.ops.translate(bm, verts=new_verts, vec=(0.0, 0.0, extrusion_depth))
|
||||
|
||||
bm.verts.index_update()
|
||||
bm.edges.index_update()
|
||||
tris = [[loop.vert.index for loop in triangles] for triangles in bm.calc_loop_triangles()]
|
||||
|
||||
# Calculate rotation, mouse position, angle and cardinal point
|
||||
polyline_props = tool.Model.get_polyline_props()
|
||||
snap_prop = polyline_props.snap_mouse_point[0]
|
||||
mouse_point = Vector((snap_prop.x, snap_prop.y, snap_prop.z))
|
||||
data = {}
|
||||
|
||||
verts = [tuple(v.co) for v in bm.verts]
|
||||
verts = [tuple(rot_mat @ Vector(v)) for v in verts]
|
||||
verts = [tuple(Vector(v) + mouse_point) for v in verts]
|
||||
min_z = min(v.co.z for v in bm.verts)
|
||||
max_z = max(v.co.z for v in bm.verts)
|
||||
# Add axis verts
|
||||
verts.append(tuple(mouse_point))
|
||||
verts.append(tuple(mouse_point + Vector((0, 0, max_z))))
|
||||
# Add only profile edges
|
||||
edges = []
|
||||
for edge in bm.edges:
|
||||
if (edge.verts[0].co.z == min_z and edge.verts[1].co.z == min_z) or (
|
||||
edge.verts[0].co.z == max_z and edge.verts[1].co.z == max_z
|
||||
):
|
||||
edges.append(edge)
|
||||
# Add axis edge
|
||||
edges = [(edge.verts[0].index, edge.verts[1].index) for edge in edges]
|
||||
edges.append((len(verts) - 1, len(verts) - 2))
|
||||
data["verts"] = verts
|
||||
data["edges"] = edges
|
||||
data["tris"] = tris
|
||||
|
||||
bm.free()
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def get_horizontal_profile_preview_data(
|
||||
context: bpy.types.Context, relating_type: ifcopenshell.entity_instance
|
||||
) -> dict[str, Any]:
|
||||
material = ifcopenshell.util.element.get_material(relating_type)
|
||||
try:
|
||||
profile_curve = material.MaterialProfiles[0].Profile
|
||||
except:
|
||||
return {}
|
||||
|
||||
model_props = tool.Model.get_model_props()
|
||||
cardinal_point = model_props.cardinal_point
|
||||
|
||||
polyline_verts = []
|
||||
polyline_props = tool.Model.get_polyline_props()
|
||||
polyline_data = polyline_props.insertion_polyline
|
||||
polyline_points = polyline_data[0].polyline_points if polyline_data else []
|
||||
if len(polyline_points) < 2:
|
||||
return {}
|
||||
for point in polyline_points:
|
||||
polyline_verts.append(Vector((point.x, point.y, point.z)))
|
||||
polyline_edges = [(i, i + 1) for i in range(len(polyline_verts) - 1)]
|
||||
|
||||
# Get profile shape
|
||||
settings = ifcopenshell.geom.settings()
|
||||
settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS)
|
||||
shape = ifcopenshell.geom.create_shape(settings, profile_curve)
|
||||
|
||||
verts = shape.verts
|
||||
if not verts:
|
||||
raise RuntimeError(f"Profile shape has no vertices, it probably is invalid: '{profile_curve}'.")
|
||||
|
||||
edges = shape.edges
|
||||
|
||||
grouped_verts = [[verts[i], verts[i + 1], 0] for i in range(0, len(verts), 3)]
|
||||
grouped_edges = [[edges[i], edges[i + 1]] for i in range(0, len(edges), 2)]
|
||||
|
||||
# Create offsets based on cardinal point
|
||||
min_x = min(v[0] for v in grouped_verts)
|
||||
max_x = max(v[0] for v in grouped_verts)
|
||||
min_y = min(v[1] for v in grouped_verts)
|
||||
max_y = max(v[1] for v in grouped_verts)
|
||||
|
||||
x_offset = (max_x - min_x) / 2
|
||||
y_offset = (max_y - min_y) / 2
|
||||
|
||||
match cardinal_point:
|
||||
case "1":
|
||||
grouped_verts = [(v[0] - x_offset, v[1] + y_offset, v[2]) for v in grouped_verts]
|
||||
case "2":
|
||||
grouped_verts = [(v[0], v[1] + y_offset, v[2]) for v in grouped_verts]
|
||||
case "3":
|
||||
grouped_verts = [(v[0] + x_offset, v[1] + y_offset, v[2]) for v in grouped_verts]
|
||||
case "4":
|
||||
grouped_verts = [(v[0] - x_offset, v[1], v[2]) for v in grouped_verts]
|
||||
case "5":
|
||||
grouped_verts = [(v[0], v[1], v[2]) for v in grouped_verts]
|
||||
case "6":
|
||||
grouped_verts = [(v[0] + x_offset, v[1], v[2]) for v in grouped_verts]
|
||||
case "7":
|
||||
grouped_verts = [(v[0] - x_offset, v[1] - y_offset, v[2]) for v in grouped_verts]
|
||||
case "8":
|
||||
grouped_verts = [(v[0], v[1] - y_offset, v[2]) for v in grouped_verts]
|
||||
case "9":
|
||||
grouped_verts = [(v[0] + x_offset, v[1] - y_offset, v[2]) for v in grouped_verts]
|
||||
|
||||
data: dict[str, Any] = {}
|
||||
data["verts"] = []
|
||||
data["edges"] = []
|
||||
data["tris"] = []
|
||||
|
||||
grouped_verts = [(v) for v in grouped_verts]
|
||||
|
||||
all_bm = bmesh.new()
|
||||
for i in range(len(polyline_verts) - 1):
|
||||
mesh = bpy.data.meshes.new("TempMesh")
|
||||
# Create the initial mesh from the profile verts
|
||||
bm = create_bmesh_from_vertices(grouped_verts, is_closed=True)
|
||||
bm.verts.ensure_lookup_table()
|
||||
# Creates the clipping plane formed by two segments.
|
||||
# The first one is for the profile start, based on the current and previous segment of the polyline.
|
||||
# The second is for the profile end, based on the current and the next segment.
|
||||
if i == 0:
|
||||
d = (polyline_verts[i + 1] - polyline_verts[i]).normalized()
|
||||
clip_start = d
|
||||
else:
|
||||
d1 = (polyline_verts[i] - polyline_verts[i - 1]).normalized()
|
||||
d2 = (polyline_verts[i] - polyline_verts[i + 1]).normalized()
|
||||
clip_start = (d1 - d2).normalized()
|
||||
|
||||
if i == len(polyline_verts) - 2:
|
||||
d = (polyline_verts[i + 1] - polyline_verts[i]).normalized()
|
||||
clip_end = d
|
||||
else:
|
||||
d1 = (polyline_verts[i + 1] - polyline_verts[i]).normalized()
|
||||
d2 = (polyline_verts[i + 1] - polyline_verts[i + 2]).normalized()
|
||||
clip_end = (d1 - d2).normalized()
|
||||
|
||||
# Rotates the profile face to the right direction
|
||||
direction = polyline_verts[i + 1] - polyline_verts[i]
|
||||
position = polyline_verts[i]
|
||||
rotation_matrix = direction.to_track_quat("Z", "Y").to_matrix().to_4x4()
|
||||
bmesh.ops.transform(bm, verts=bm.verts, matrix=rotation_matrix)
|
||||
bmesh.ops.translate(bm, verts=bm.verts, vec=position)
|
||||
bmesh.ops.translate(bm, verts=bm.verts, vec=-direction)
|
||||
|
||||
# Extrude and move the new face
|
||||
last_face = bmesh.ops.extrude_face_region(bm, geom=bm.edges[:] + bm.faces[:])
|
||||
new_verts = [e for e in last_face["geom"] if isinstance(e, bmesh.types.BMVert)]
|
||||
bmesh.ops.translate(bm, verts=new_verts, vec=direction * 3)
|
||||
# Apply the cutting planes
|
||||
cut = bmesh.ops.bisect_plane(
|
||||
bm,
|
||||
geom=bm.verts[:] + bm.edges[:] + bm.faces[:],
|
||||
plane_co=polyline_verts[i],
|
||||
plane_no=clip_start,
|
||||
clear_inner=True,
|
||||
)
|
||||
bm.verts.index_update()
|
||||
bm.edges.index_update()
|
||||
cut = bmesh.ops.bisect_plane(
|
||||
bm,
|
||||
geom=bm.verts[:] + bm.edges[:] + bm.faces[:],
|
||||
plane_co=polyline_verts[i + 1],
|
||||
plane_no=clip_end,
|
||||
clear_outer=True,
|
||||
)
|
||||
|
||||
bm.to_mesh(mesh)
|
||||
bm.free()
|
||||
mesh.update()
|
||||
all_bm.from_mesh(mesh)
|
||||
bpy.data.meshes.remove(bpy.data.meshes["TempMesh"])
|
||||
|
||||
# It's necessary to add the mesh to an object to get the expected result.
|
||||
mesh = bpy.data.meshes.new("TempMesh2")
|
||||
all_bm.to_mesh(mesh)
|
||||
all_bm.free()
|
||||
obj = bpy.data.objects.new("TempObj", mesh)
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(obj.data)
|
||||
bpy.data.meshes.remove(bpy.data.meshes["TempMesh2"])
|
||||
|
||||
verts = [tuple(v.co) for v in bm.verts]
|
||||
edges = [[v.index for v in e.verts] for e in bm.edges]
|
||||
tris = [[loop.vert.index for loop in triangles] for triangles in bm.calc_loop_triangles()]
|
||||
data["verts"] = verts
|
||||
data["edges"] = edges
|
||||
data["tris"] = tris
|
||||
bm.free()
|
||||
return data
|
||||
|
||||
|
||||
def get_generic_product_preview_data(context, relating_type):
|
||||
model_props = tool.Model.get_model_props()
|
||||
if relating_type.is_a("IfcDoorType"):
|
||||
rl = float(model_props.rl1)
|
||||
elif relating_type.is_a("IfcWindowType"):
|
||||
rl = float(model_props.rl2)
|
||||
else:
|
||||
rl = 0
|
||||
polyline_props = tool.Model.get_polyline_props()
|
||||
snap_prop = polyline_props.snap_mouse_point[0]
|
||||
default_container_elevation = tool.Root.get_default_container_elevation()
|
||||
mouse_point = Vector((snap_prop.x, snap_prop.y, default_container_elevation))
|
||||
snap_obj = bpy.data.objects.get(snap_prop.snap_object)
|
||||
snap_element = tool.Ifc.get_entity(snap_obj)
|
||||
rot_mat = Quaternion()
|
||||
if relating_type.is_a() in ["IfcDoorType", "IfcWindowType"] and snap_element and snap_element.is_a("IfcWall"):
|
||||
layers = tool.Model.get_material_layer_parameters(snap_element)
|
||||
axes = tool.Model.get_wall_axis(snap_obj, layers=layers)
|
||||
axis_base = axes["base"]
|
||||
axis_side = axes["side"]
|
||||
point_on_base_axis = tool.Cad.point_on_edge(mouse_point, axis_base)
|
||||
point_on_side_axis = tool.Cad.point_on_edge(mouse_point, axis_side)
|
||||
if (point_on_base_axis - mouse_point).length_squared <= (point_on_side_axis - mouse_point).length_squared:
|
||||
# mouse is snapped to the base axis, the preview looks exactly like the placed door / window
|
||||
rot_mat = snap_obj.matrix_world.to_quaternion()
|
||||
else:
|
||||
# mouse is snapped to the side axis, the preview is inverted, rotate it now and correct x position later
|
||||
rot_mat = snap_obj.matrix_world.to_quaternion() @ Quaternion(Vector((0, 0, 1)), radians(180))
|
||||
|
||||
mouse_point.z = snap_obj.matrix_world.translation.z
|
||||
|
||||
obj_type = tool.Ifc.get_object(relating_type)
|
||||
if obj_type.data:
|
||||
data = ItemDecorator.get_obj_data(obj_type)
|
||||
data["verts"] = [tuple(obj_type.matrix_world.inverted() @ Vector(v)) for v in data["verts"]]
|
||||
data["verts"] = [tuple(rot_mat @ (Vector((v[0], v[1], (v[2] + rl)))) + mouse_point) for v in data["verts"]]
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class PolylineOperator:
|
||||
|
||||
@@ -19,16 +19,12 @@
|
||||
# pyright: reportUnnecessaryTypeIgnoreComment=error
|
||||
|
||||
import json
|
||||
import math
|
||||
from typing import TYPE_CHECKING, Any, Literal, get_args
|
||||
|
||||
import numpy as np
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.geometry
|
||||
import ifcopenshell.api.pset
|
||||
import ifcopenshell.api.system
|
||||
import ifcopenshell.util.element
|
||||
import ifcopenshell.util.placement
|
||||
@@ -50,7 +46,6 @@ import bonsai.tool as tool
|
||||
from bonsai.bim.helper import get_enum_items
|
||||
from bonsai.bim.ifc import IfcStore
|
||||
from bonsai.bim.module.model.data import AuthoringData
|
||||
from bonsai.bim.module.model.door import update_door_modifier_representation
|
||||
from bonsai.bim.module.model.decorator import PolylineDecorator, ProductDecorator
|
||||
from bonsai.bim.module.model.polyline import PolylineOperator
|
||||
|
||||
@@ -683,381 +678,6 @@ class MirrorElements(bpy.types.Operator, tool.Ifc.Operator):
|
||||
obj.matrix_world = newmat
|
||||
|
||||
|
||||
class TrueMirrorElements(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.mirror_geometry"
|
||||
bl_label = "Mirror Element Geometry"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
bl_description = (
|
||||
"Mirrors the selected objects by inverting their representation. "
|
||||
"If an active object is set, mirrors about its YZ plane; otherwise mirrors in place along the X axis. "
|
||||
"Shift: duplicate and mirror, leaving the original in place"
|
||||
)
|
||||
keep_original: bpy.props.BoolProperty(name="Keep Original", default=False)
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
return context.selected_objects
|
||||
|
||||
def _execute(self, context):
|
||||
active_obj = context.active_object
|
||||
mirror_ref = None
|
||||
if active_obj:
|
||||
objs_to_mirror = [obj for obj in context.selected_objects if obj != active_obj]
|
||||
if objs_to_mirror:
|
||||
mirror_ref = active_obj
|
||||
if mirror_ref is None:
|
||||
objs_to_mirror = list(context.selected_objects)
|
||||
|
||||
if self.keep_original:
|
||||
if mirror_ref:
|
||||
mirror_ref.select_set(False)
|
||||
bpy.ops.bim.override_object_duplicate_move(is_interactive=False)
|
||||
objs_to_mirror = [obj for obj in context.selected_objects if obj != mirror_ref]
|
||||
if mirror_ref:
|
||||
mirror_ref.select_set(True)
|
||||
|
||||
for obj in objs_to_mirror:
|
||||
self.mirror_obj(context, obj, mirror_ref)
|
||||
return {"FINISHED"}
|
||||
|
||||
def mirror_obj(self, context: bpy.types.Context, obj: bpy.types.Object, mirror_ref: bpy.types.Object = None):
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element:
|
||||
return
|
||||
type_element = ifcopenshell.util.element.get_type(element)
|
||||
|
||||
active_context = tool.Geometry.get_active_representation_context(obj)
|
||||
|
||||
bb_data = tool.Blender.get_object_bounding_box(obj)
|
||||
|
||||
# Compute the geometry inversion axis from the mirror reference's local X (= mirror plane normal).
|
||||
# Without a reference, always fall back to inverting along the object's local X.
|
||||
if mirror_ref:
|
||||
mirror_normal_world = mirror_ref.matrix_world.to_3x3().col[0].normalized()
|
||||
mirror_normal_local = obj.matrix_world.to_3x3().inverted() @ mirror_normal_world
|
||||
mirror_axes = (
|
||||
1.0 if abs(mirror_normal_local.x) > 0.5 else 0.0,
|
||||
1.0 if abs(mirror_normal_local.y) > 0.5 else 0.0,
|
||||
1.0 if abs(mirror_normal_local.z) > 0.5 else 0.0,
|
||||
)
|
||||
else:
|
||||
mirror_axes = (1, 0, 0)
|
||||
|
||||
# Snapshot opening world placements AND slab placement BEFORE any geometry or origin changes.
|
||||
# We work in the slab's LOCAL coordinate space so the mirrored relative offset is correct
|
||||
# regardless of when (or whether) the slab's own IFC ObjectPlacement gets synced by the depsgraph.
|
||||
opening_placements_before: dict[int, np.ndarray] = {}
|
||||
M_slab_before: np.ndarray | None = None
|
||||
if hasattr(element, "HasOpenings") and element.HasOpenings:
|
||||
M_slab_before = ifcopenshell.util.placement.get_local_placement(element.ObjectPlacement).copy()
|
||||
for rel in element.HasOpenings:
|
||||
opening = rel.RelatedOpeningElement
|
||||
M = ifcopenshell.util.placement.get_local_placement(opening.ObjectPlacement)
|
||||
opening_placements_before[opening.id()] = M.copy()
|
||||
|
||||
usage_type = tool.Model.get_usage_type(element)
|
||||
type_has_reps = bool(type_element and (type_element.RepresentationMaps or []))
|
||||
# LAYER2 (walls) and LAYER3 (slabs) generate instance-specific bodies via DumbWallGenerator /
|
||||
# DumbSlabGenerator rather than mapping the type's RepresentationMaps. assign_inverted_type
|
||||
# only flips the *type* geometry and would leave the instance body unchanged, so both layer
|
||||
# usage types must go through invert_representation instead.
|
||||
is_assign_type_path = bool(type_element and element.id() != type_element.id() and usage_type not in ("LAYER2", "LAYER3") and type_has_reps)
|
||||
if is_assign_type_path:
|
||||
self.assign_inverted_type(element, mirror_axes)
|
||||
else:
|
||||
# invert representation of entity directly;
|
||||
# LAYER2/LAYER3 (walls/slabs) and elements whose type carries no geometry use this path
|
||||
# Update opening placements BEFORE invert_representation so its internal reload
|
||||
# sees the correct positions. No element rotation change on this path → frame_change = I.
|
||||
if opening_placements_before and M_slab_before is not None:
|
||||
self._apply_opening_mirror(element, mirror_axes, M_slab_before, opening_placements_before, np.eye(3))
|
||||
self.invert_representation(element, mirror_axes)
|
||||
# For LAYER2 walls, layers stack along local Y (LayerSetDirection=AXIS2). A Y-axis flip
|
||||
# reverses that direction, so DirectionSense and OffsetFromReferenceLine must both invert.
|
||||
# (X/Z flips don't touch local Y, and the assign_inverted_type path handles Y-mirror via
|
||||
# a 180°Z element rotation which implicitly flips local Y without changing the IFC attribute.)
|
||||
if usage_type == "LAYER2" and mirror_axes[1] > 0.5:
|
||||
mat_usage = ifcopenshell.util.element.get_material(element, should_inherit=False)
|
||||
if mat_usage and mat_usage.is_a("IfcMaterialLayerSetUsage"):
|
||||
mat_usage.DirectionSense = "NEGATIVE" if mat_usage.DirectionSense == "POSITIVE" else "POSITIVE"
|
||||
mat_usage.OffsetFromReferenceLine = -mat_usage.OffsetFromReferenceLine
|
||||
|
||||
context.view_layer.update()
|
||||
|
||||
if mirror_ref:
|
||||
# Reflect object origin about the mirror reference's YZ plane
|
||||
origin_in_mirror = mirror_ref.matrix_world.inverted() @ obj.matrix_world.translation
|
||||
origin_in_mirror.x *= -1
|
||||
obj.matrix_world.translation = mirror_ref.matrix_world @ origin_in_mirror
|
||||
else:
|
||||
# Fall back: nudge in place to compensate for bounding box shift after inversion
|
||||
mirrored_bb_data = tool.Blender.get_object_bounding_box(obj)
|
||||
x_correction_factor = mirrored_bb_data["min_x"] - bb_data["min_x"]
|
||||
x_correction_vec = (obj.matrix_world @ Vector((x_correction_factor, 0, 0, 0))).xyz
|
||||
obj.location -= x_correction_vec
|
||||
|
||||
if element.is_a("IfcElement") and element.FillsVoids:
|
||||
tool.Model.update_simple_openings(element)
|
||||
|
||||
# For the assign_inverted_type path, mirror opening placements here (after origin reflection
|
||||
# so obj.matrix_world already has the final rotation, needed to compute frame_change).
|
||||
# The LAYER3 / invert_representation path already did this before invert_representation.
|
||||
if is_assign_type_path and opening_placements_before and M_slab_before is not None:
|
||||
R_elem_old = M_slab_before[:3, :3]
|
||||
_, R_quat, _ = obj.matrix_world.decompose()
|
||||
R_elem_new = np.array(R_quat.to_matrix())
|
||||
frame_change = np.linalg.inv(R_elem_new) @ R_elem_old
|
||||
# Sync element IFC placement to the post-reflection Blender position NOW, before
|
||||
# computing opening positions. The geometry engine uses the element's IFC placement
|
||||
# to convert opening world-space positions to element-local positions when applying
|
||||
# boolean voids. If the placement is stale the void lands at the wrong offset.
|
||||
# Calling edit_object_placement here also makes the depsgraph handler skip a
|
||||
# redundant second call (record_object_position marks it up-to-date).
|
||||
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj, apply_scale=False)
|
||||
M_slab_new = ifcopenshell.util.placement.get_local_placement(element.ObjectPlacement)
|
||||
self._apply_opening_mirror(element, mirror_axes, M_slab_before, opening_placements_before, frame_change, M_slab_new)
|
||||
for rel in element.HasOpenings:
|
||||
opening = rel.RelatedOpeningElement
|
||||
tool.Geometry.clear_cache(opening)
|
||||
opening_obj = tool.Ifc.get_object(opening)
|
||||
if opening_obj:
|
||||
tool.Geometry.reload_representation(opening_obj)
|
||||
|
||||
# bonsai does not automatically switch to the representation that should be active in the given context
|
||||
# when switching to a type that was previously viewed in another context (e.g. plan view),
|
||||
# the wrong representation will be used.
|
||||
bonsai.core.geometry.switch_representation(
|
||||
tool.Ifc,
|
||||
tool.Geometry,
|
||||
obj=obj,
|
||||
representation=ifcopenshell.util.representation.get_representation(element, active_context),
|
||||
)
|
||||
|
||||
def _apply_opening_mirror(self, element, mirror_axes, M_slab_before, opening_placements_before, frame_change, M_slab_new=None):
|
||||
"""Mirror IfcOpeningElement placements and geometry in element-local space.
|
||||
|
||||
frame_change = inv(R_elem_new) @ R_elem_old accounts for any element rotation change
|
||||
(e.g. 180° Z for Y-mirror via assign_inverted_type). Pass np.eye(3) when the element
|
||||
rotation does not change (LAYER3 / invert_representation path).
|
||||
|
||||
M_slab_new: element IFC world matrix after the mirror. If None, M_slab_before is used
|
||||
(correct for the LAYER3 path where the element placement stays the same).
|
||||
"""
|
||||
M_slab_anchor = M_slab_new if M_slab_new is not None else M_slab_before
|
||||
N_local = np.zeros(3)
|
||||
for i, flip in enumerate(mirror_axes[:3]):
|
||||
if flip > 0.0:
|
||||
N_local[i] = 1.0
|
||||
norm = np.linalg.norm(N_local)
|
||||
if norm > 0:
|
||||
N_local /= norm
|
||||
|
||||
for rel in element.HasOpenings:
|
||||
opening = rel.RelatedOpeningElement
|
||||
if opening.id() not in opening_placements_before:
|
||||
continue
|
||||
M_abs_old = opening_placements_before[opening.id()]
|
||||
M_rel = np.linalg.inv(M_slab_before) @ M_abs_old
|
||||
M_rel_new = M_rel.copy()
|
||||
|
||||
t = M_rel[:3, 3].copy()
|
||||
t_refl = t - 2 * np.dot(t, N_local) * N_local
|
||||
M_rel_new[:3, 3] = frame_change @ t_refl
|
||||
|
||||
# Mirror rotation by conjugation: H@R@H keeps det=+1 and gives the correct mirrored
|
||||
# rotation. Direct Householder on columns yields det=-1; IFC's Y=Z×X normalization
|
||||
# then introduces a spurious 180°Z error. Conjugation avoids this.
|
||||
H = np.eye(3) - 2 * np.outer(N_local, N_local)
|
||||
R_mirrored = H @ M_rel[:3, :3] @ H
|
||||
M_rel_new[:3, :3] = frame_change @ R_mirrored
|
||||
|
||||
M_abs_new = M_slab_anchor @ M_rel_new
|
||||
ifcopenshell.api.geometry.edit_object_placement(
|
||||
tool.Ifc.get(), product=opening, matrix=M_abs_new, is_si=False
|
||||
)
|
||||
|
||||
if not opening.Representation:
|
||||
continue
|
||||
builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get())
|
||||
mirror_axes_2d = mirror_axes[:2]
|
||||
for rep in opening.Representation.Representations:
|
||||
for item in rep.Items:
|
||||
if item.is_a("IfcExtrudedAreaSolid"):
|
||||
# H (wall-local Householder) is already computed above for the placement
|
||||
# mirror. Applying it as T_geom to opening-local coords is correct because:
|
||||
# M_rel_new @ T_geom = H @ M_rel (the true geometric mirror)
|
||||
# with M_rel_new = H@R@H (conjugation) → T_geom = H (wall-local numerics
|
||||
# applied to opening-local coords). Conjugating into Position-local gives:
|
||||
# H_pos = placement_mat.T @ H @ placement_mat
|
||||
placement_mat = ifcopenshell.util.placement.get_axis2placement(item.Position)[:3, :3]
|
||||
H_pos = placement_mat.T @ H @ placement_mat
|
||||
|
||||
# Mirror Position.Location (opening-local 3D point) by H
|
||||
pos_coords = item.Position.Location.Coordinates
|
||||
pos3 = np.array([pos_coords[0], pos_coords[1], pos_coords[2] if len(pos_coords) > 2 else 0.0])
|
||||
pos3_new = H @ pos3
|
||||
if len(pos_coords) > 2:
|
||||
item.Position.Location.Coordinates = tuple(float(v) for v in pos3_new)
|
||||
else:
|
||||
item.Position.Location.Coordinates = (float(pos3_new[0]), float(pos3_new[1]))
|
||||
|
||||
# Mirror profile coords in Position-local via H_pos.
|
||||
# H_pos is a reflection (det=-1) so winding must be reversed.
|
||||
profile = item.SweptArea
|
||||
for curve in [getattr(profile, "OuterCurve", None)]:
|
||||
if curve is None:
|
||||
continue
|
||||
coords = builder.get_polyline_coords(curve)
|
||||
coords3 = np.hstack([coords, np.zeros((len(coords), 1))])
|
||||
coords_new = (H_pos @ coords3.T).T[:, :2][::-1]
|
||||
builder.set_polyline_coords(curve, coords_new)
|
||||
for inner in getattr(profile, "InnerCurves", None) or []:
|
||||
coords = builder.get_polyline_coords(inner)
|
||||
coords3 = np.hstack([coords, np.zeros((len(coords), 1))])
|
||||
coords_new = (H_pos @ coords3.T).T[:, :2][::-1]
|
||||
builder.set_polyline_coords(inner, coords_new)
|
||||
|
||||
# Mirror ExtrudedDirection in Position-local via H_pos
|
||||
dir_local = np.array(item.ExtrudedDirection.DirectionRatios)
|
||||
dir_local_new = H_pos @ dir_local
|
||||
item.ExtrudedDirection.DirectionRatios = tuple(float(v) for v in dir_local_new)
|
||||
else:
|
||||
builder.mirror(item, mirror_axes_2d, create_copy=False)
|
||||
|
||||
def invert_general_object(self, element, mirror_axes=(1, 0, 0)):
|
||||
# ShapeBuilder.mirror works in 2D; use only the XY components
|
||||
mirror_axes_2d = mirror_axes[:2]
|
||||
builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get())
|
||||
|
||||
def mirror_item(item):
|
||||
if item.is_a("IfcBooleanResult"):
|
||||
mirror_item(item.FirstOperand)
|
||||
try:
|
||||
mirror_item(item.SecondOperand)
|
||||
except Exception:
|
||||
pass
|
||||
elif item.is_a("IfcFacetedBrep") or item.is_a("IfcFacetedBrepWithVoids"):
|
||||
shells = [item.Outer]
|
||||
if item.is_a("IfcFacetedBrepWithVoids"):
|
||||
shells.extend(item.Voids)
|
||||
|
||||
# Mirror all unique vertex coordinates along the flipped axes
|
||||
points_done = set()
|
||||
for shell in shells:
|
||||
for face in shell.CfsFaces:
|
||||
for bound in face.Bounds:
|
||||
if not bound.Bound.is_a("IfcPolyLoop"):
|
||||
continue
|
||||
for pt in bound.Bound.Polygon:
|
||||
if pt.id() in points_done:
|
||||
continue
|
||||
points_done.add(pt.id())
|
||||
coords = list(pt.Coordinates)
|
||||
for i, flip in enumerate(mirror_axes[: len(coords)]):
|
||||
if flip > 0.0:
|
||||
coords[i] = -coords[i]
|
||||
pt.Coordinates = coords
|
||||
|
||||
# Reverse face winding for an odd number of flipped axes (restores outward normals)
|
||||
num_flipped = sum(1 for v in mirror_axes if v > 0.0)
|
||||
if num_flipped % 2 == 1:
|
||||
for shell in shells:
|
||||
for face in shell.CfsFaces:
|
||||
for bound in face.Bounds:
|
||||
if bound.Bound.is_a("IfcPolyLoop"):
|
||||
bound.Bound.Polygon = list(reversed(bound.Bound.Polygon))
|
||||
elif item.is_a("IfcMappedItem"):
|
||||
for sub in item.MappingSource.MappedRepresentation.Items:
|
||||
mirror_item(sub)
|
||||
else:
|
||||
try:
|
||||
builder.mirror(item, mirror_axes_2d, create_copy=False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if element.is_a("IfcProduct"):
|
||||
if not element.Representation:
|
||||
return
|
||||
for representation in element.Representation.Representations:
|
||||
for item in representation.Items:
|
||||
mirror_item(item)
|
||||
elif element.is_a("IfcTypeProduct"):
|
||||
for representation_map in (element.RepresentationMaps or []):
|
||||
for item in representation_map.MappedRepresentation.Items:
|
||||
mirror_item(item)
|
||||
|
||||
tool.Geometry.reload_representation(tool.Ifc.get_object(element))
|
||||
|
||||
def invert_door_swing(self, element):
|
||||
obj = tool.Ifc.get_object(element)
|
||||
|
||||
pset_data = json.loads(ifcopenshell.util.element.get_pset(element, "BBIM_Door", "Data"))
|
||||
|
||||
if "LEFT" in pset_data["door_type"]:
|
||||
pset_data["door_type"] = pset_data["door_type"].replace("LEFT", "RIGHT")
|
||||
elif "RIGHT" in pset_data["door_type"]:
|
||||
pset_data["door_type"] = pset_data["door_type"].replace("RIGHT", "LEFT")
|
||||
|
||||
pset = tool.Pset.get_element_pset(element, "BBIM_Door")
|
||||
pset_data_str = tool.Ifc.get().createIfcText(json.dumps(pset_data, default=list))
|
||||
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Data": pset_data_str})
|
||||
|
||||
pset_data.update(pset_data.pop("lining_properties"))
|
||||
pset_data.update(pset_data.pop("panel_properties"))
|
||||
pset_data.update(tool.Model.get_constituents_props_data(element))
|
||||
|
||||
# we need this workaround because set_props_kwargs_from_ifc_data will
|
||||
# "update" the mesh of the active object, which will switch its representation
|
||||
prev_active = bpy.context.view_layer.objects.active
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
|
||||
props = tool.Model.get_door_props(obj)
|
||||
props.set_props_kwargs_from_ifc_data(pset_data)
|
||||
|
||||
bpy.context.view_layer.objects.active = prev_active
|
||||
|
||||
# regenerate door geometry
|
||||
update_door_modifier_representation(obj)
|
||||
|
||||
tool.Model.mark_thumbnail_for_update(element)
|
||||
|
||||
def invert_representation(self, element, mirror_axes=(1, 0, 0)):
|
||||
if ifcopenshell.util.element.get_pset(element, "BBIM_Door", "Data"):
|
||||
self.invert_door_swing(element)
|
||||
else:
|
||||
self.invert_general_object(element, mirror_axes)
|
||||
|
||||
def assign_inverted_type(self, element, mirror_axes=(1, 0, 0)):
|
||||
type_element = ifcopenshell.util.element.get_type(element)
|
||||
|
||||
inverted_type = tool.Blender.Modifier.has_mirrored_type(type_element)
|
||||
if not inverted_type:
|
||||
old_to_new, _ = tool.Geometry.duplicate_ifc_objects([tool.Ifc.get_object(type_element)])
|
||||
inverted_type = old_to_new[type_element][0]
|
||||
self.invert_representation(inverted_type) # always X-flip for the cached type
|
||||
tool.Blender.Modifier.set_mirrored_type(inverted_type, type_element)
|
||||
tool.Blender.Modifier.set_mirrored_type(type_element, inverted_type)
|
||||
inverted_type.Name = f"{inverted_type.Name}.Mirror"
|
||||
|
||||
bonsai.core.type.assign_type(tool.Ifc, tool.Model, tool.Type, element, inverted_type)
|
||||
|
||||
# The cached type is always X-flipped. Apply rotation compensation for other axes:
|
||||
# flip_Y = Rotate_Z_180 ∘ flip_X
|
||||
# flip_Z = Rotate_Y_180 ∘ flip_X
|
||||
if mirror_axes == (0.0, 1.0, 0.0):
|
||||
obj = tool.Ifc.get_object(element)
|
||||
loc = obj.matrix_world.translation.copy()
|
||||
rot_180_z = Matrix.Rotation(math.pi, 4, "Z")
|
||||
obj.matrix_world = rot_180_z @ obj.matrix_world
|
||||
obj.matrix_world.translation = loc
|
||||
elif mirror_axes == (0.0, 0.0, 1.0):
|
||||
obj = tool.Ifc.get_object(element)
|
||||
loc = obj.matrix_world.translation.copy()
|
||||
rot_180_y = Matrix.Rotation(math.pi, 4, "Y")
|
||||
obj.matrix_world = rot_180_y @ obj.matrix_world
|
||||
obj.matrix_world.translation = loc
|
||||
|
||||
|
||||
def generate_box(usecase_path: str, ifc_file: ifcopenshell.file, settings: dict[str, Any]) -> None:
|
||||
box_context = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Box", "MODEL_VIEW")
|
||||
if not box_context:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -189,12 +189,6 @@ class LaunchTypeManager(bpy.types.Operator):
|
||||
op.relating_type = relating_type["id"]
|
||||
|
||||
|
||||
def object_menu(self, context):
|
||||
if context.active_object and tool.Ifc.get_entity(context.active_object):
|
||||
self.layout.separator()
|
||||
self.layout.operator("bim.mirror_geometry", icon="MOD_MIRROR")
|
||||
|
||||
|
||||
class BIM_PT_authoring(Panel):
|
||||
bl_label = "Architectural"
|
||||
bl_idname = "BIM_PT_authoring"
|
||||
|
||||
@@ -400,13 +400,13 @@ class DisconnectElements(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.I
|
||||
)
|
||||
return
|
||||
path_objs: list[bpy.types.Object] = []
|
||||
for rel, kind in rels:
|
||||
for subject, kind in rels:
|
||||
bonsai.core.connection.disconnect_rel(
|
||||
tool.Ifc,
|
||||
tool.Geometry,
|
||||
tool.Model,
|
||||
tool.Connection,
|
||||
rel=rel,
|
||||
subject=subject,
|
||||
kind=kind,
|
||||
elem=elem_a,
|
||||
partner=elem_b,
|
||||
|
||||
@@ -74,7 +74,6 @@ class BimTool(WorkSpaceTool):
|
||||
("bim.hotkey", {"type": "E", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_E")]}),
|
||||
("bim.hotkey", {"type": "E", "value": "PRESS", "ctrl": True}, {"properties": [("hotkey", "C_E")]}),
|
||||
("bim.hotkey", {"type": "F", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_F")]}),
|
||||
("bim.hotkey", {"type": "F", "value": "PRESS", "ctrl": True}, {"properties": [("hotkey", "C_F")]}),
|
||||
("bim.hotkey", {"type": "G", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_G")]}),
|
||||
("bim.hotkey", {"type": "K", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_K")]}),
|
||||
("bim.hotkey", {"type": "M", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_M")]}),
|
||||
@@ -950,9 +949,6 @@ class EditObjectUI:
|
||||
if AuthoringData.data["is_flippable_element"]:
|
||||
cls.draw_flip(ui_context, row)
|
||||
|
||||
if AuthoringData.data["active_class"] is not None:
|
||||
cls.draw_mirror_geometry(ui_context, row)
|
||||
|
||||
if PortData.data["total_ports"] > 0:
|
||||
row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row
|
||||
row.operator(
|
||||
@@ -967,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)
|
||||
|
||||
@@ -1094,11 +1094,6 @@ class EditObjectUI:
|
||||
row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else layout
|
||||
add_layout_hotkey_operator(row, "Flip", "S_F", bpy.ops.bim.flip_object.__doc__, ui_context)
|
||||
|
||||
@classmethod
|
||||
def draw_mirror_geometry(cls, ui_context, layout) -> None:
|
||||
row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else layout
|
||||
add_layout_hotkey_operator(row, "Mirror Geometry", "C_F", bpy.ops.bim.mirror_geometry.__doc__, ui_context)
|
||||
|
||||
|
||||
class Hotkey(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.hotkey"
|
||||
@@ -1142,7 +1137,6 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator):
|
||||
self.x = self.props.x
|
||||
self.y = self.props.y
|
||||
self.z = self.props.z
|
||||
self.shift = event.shift
|
||||
return self.execute(context)
|
||||
|
||||
def draw(self, context):
|
||||
@@ -1298,11 +1292,6 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator):
|
||||
elif self.active_material_usage == "PROFILE":
|
||||
bpy.ops.bim.flip_object(flip_local_axes="XZ")
|
||||
|
||||
def hotkey_C_F(self):
|
||||
if not bpy.context.selected_objects:
|
||||
return
|
||||
bpy.ops.bim.mirror_geometry(keep_original=getattr(self, "shift", False))
|
||||
|
||||
def hotkey_S_G(self):
|
||||
obj = bpy.context.active_object
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
@@ -1332,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):
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ from typing import TYPE_CHECKING, cast
|
||||
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
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -633,8 +633,7 @@ class AppendLibraryElement(bpy.types.Operator, tool.Ifc.Operator):
|
||||
if not element:
|
||||
return {"FINISHED"}
|
||||
if element.is_a("IfcTypeProduct"):
|
||||
# Store opening template from library if it exists
|
||||
self.store_opening_template_from_library(element, library_file)
|
||||
self.harvest_opening_template(element, library_file)
|
||||
self.import_type_from_ifc(element, context)
|
||||
elif element.is_a("IfcProduct"):
|
||||
# NOTE: Non-types are not exposed in UI directly
|
||||
@@ -660,6 +659,57 @@ class AppendLibraryElement(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bonsai.bim.handler.refresh_ui_data()
|
||||
return {"FINISHED"}
|
||||
|
||||
def harvest_opening_template(
|
||||
self, type_element: ifcopenshell.entity_instance, library_file: ifcopenshell.file
|
||||
) -> None:
|
||||
"""Seed the appended type's 'Reference' opening template from a library instance.
|
||||
|
||||
A type carries no opening of its own (openings are occurrence-level via
|
||||
IfcRelVoidsElement), so a custom opening would otherwise be lost on append and
|
||||
regenerated as a default extrusion when occurrences are placed. If the library
|
||||
file has an instance of this type whose opening is custom (non-extrusion), copy
|
||||
that opening body onto the appended type as its 'Reference' template. No-op when
|
||||
the type already carries a template (e.g. a Bonsai-authored library) or the
|
||||
library has no such instance.
|
||||
"""
|
||||
from bonsai.bim.module.model.opening import FilledOpeningGenerator
|
||||
|
||||
generator = FilledOpeningGenerator()
|
||||
if generator.get_type_opening_representation(type_element):
|
||||
return
|
||||
|
||||
library_type = library_file.by_id(self.definition)
|
||||
if not library_type.is_a("IfcTypeProduct"):
|
||||
return
|
||||
|
||||
for occurrence in ifcopenshell.util.element.get_types(library_type):
|
||||
if not getattr(occurrence, "FillsVoids", None):
|
||||
continue
|
||||
opening = occurrence.FillsVoids[0].RelatingOpeningElement
|
||||
library_representation = ifcopenshell.util.representation.get_representation(
|
||||
opening, "Model", "Body", "MODEL_VIEW"
|
||||
)
|
||||
if not library_representation:
|
||||
continue
|
||||
library_representation = ifcopenshell.util.representation.resolve_representation(library_representation)
|
||||
if all(item.is_a("IfcExtrudedAreaSolid") for item in library_representation.Items):
|
||||
continue # A generated extrusion - nothing custom worth preserving.
|
||||
|
||||
project_file = tool.Ifc.get()
|
||||
representation = project_file.add(library_representation)
|
||||
# file.add brings the library's own representation context across; point the
|
||||
# copy at the project's Body context and drop the now-orphaned duplicate.
|
||||
body_context = ifcopenshell.util.representation.get_context(
|
||||
project_file, "Model", "Body", "MODEL_VIEW"
|
||||
)
|
||||
if body_context and representation.ContextOfItems != body_context:
|
||||
orphan_context = representation.ContextOfItems
|
||||
representation.ContextOfItems = body_context
|
||||
if not project_file.get_inverse(orphan_context):
|
||||
project_file.remove(orphan_context)
|
||||
generator.set_type_opening_representation(type_element, representation)
|
||||
return
|
||||
|
||||
def import_material_from_ifc(self, element: ifcopenshell.entity_instance, context: bpy.types.Context) -> None:
|
||||
self.file = tool.Ifc.get()
|
||||
logger = logging.getLogger("ImportIFC")
|
||||
@@ -736,56 +786,6 @@ class AppendLibraryElement(bpy.types.Operator, tool.Ifc.Operator):
|
||||
ifc_importer.create_style(element)
|
||||
|
||||
|
||||
def store_opening_template_from_library(
|
||||
self,
|
||||
element: ifcopenshell.entity_instance,
|
||||
library_file: ifcopenshell.file
|
||||
) -> None:
|
||||
"""
|
||||
Find an opening representation in the library and copy it to the current file
|
||||
as a template. Store the template ID on the type for later retrieval.
|
||||
"""
|
||||
try:
|
||||
library_element = library_file.by_guid(element.GlobalId)
|
||||
except:
|
||||
return
|
||||
|
||||
# Find occurrences with openings in the library
|
||||
library_occurrences = ifcopenshell.util.element.get_types(library_element)
|
||||
|
||||
for occurrence in library_occurrences:
|
||||
if not getattr(occurrence, "FillsVoids", None):
|
||||
continue
|
||||
|
||||
library_opening = occurrence.FillsVoids[0].RelatingOpeningElement
|
||||
library_opening_rep = ifcopenshell.util.representation.get_representation(
|
||||
library_opening, "Model", "Body", "MODEL_VIEW"
|
||||
)
|
||||
|
||||
if not library_opening_rep:
|
||||
continue
|
||||
|
||||
# Check if mapped representation
|
||||
if (library_opening_rep.RepresentationType == 'MappedRepresentation' and
|
||||
len(library_opening_rep.Items) == 1 and
|
||||
library_opening_rep.Items[0].is_a("IfcMappedItem")):
|
||||
|
||||
mapped_rep = library_opening_rep.Items[0].MappingSource.MappedRepresentation
|
||||
|
||||
# Store ALL representation types (Tessellation, SweptSolid, etc.)
|
||||
template_rep = ifcopenshell.util.element.copy_deep(
|
||||
self.file,
|
||||
mapped_rep,
|
||||
exclude=["IfcGeometricRepresentationContext"]
|
||||
)
|
||||
|
||||
# Store reference in type's Description
|
||||
current_desc = element.Description or ""
|
||||
element.Description = f"{current_desc}||BonsaiOpeningTemplate:{template_rep.id()}"
|
||||
return
|
||||
break
|
||||
|
||||
|
||||
class EditProjectLibrary(bpy.types.Operator):
|
||||
bl_idname = "bim.edit_project_library"
|
||||
bl_label = "Edit Project Library"
|
||||
@@ -1474,6 +1474,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)
|
||||
|
||||
|
||||
@@ -1544,6 +1545,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}'")
|
||||
@@ -1711,13 +1716,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):
|
||||
|
||||
@@ -260,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
|
||||
@@ -275,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):
|
||||
|
||||
@@ -28,9 +28,8 @@ from bpy.types import Menu, Panel, UIList
|
||||
import bonsai.bim
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.helper import draw_attributes, prop_with_search
|
||||
from bonsai.bim.ifc import IfcStore, is_cache_locked_by_other_process
|
||||
from bonsai.bim.ifc import IfcStore
|
||||
from bonsai.bim.module.project.data import LinksData, ProjectData
|
||||
from bonsai.bim.ui import draw_multiline_text
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bonsai.bim.module.project.prop import (
|
||||
@@ -167,20 +166,6 @@ class BIM_PT_project(Panel):
|
||||
if pprops.is_loading:
|
||||
self.draw_advanced_loading_ui(context)
|
||||
elif self.file or props.ifc_file:
|
||||
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,
|
||||
)
|
||||
|
||||
if props.has_blend_warning:
|
||||
box = self.layout.box()
|
||||
box.alert = True
|
||||
@@ -190,35 +175,6 @@ class BIM_PT_project(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 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")
|
||||
|
||||
if props.ifc_file:
|
||||
self.draw_loaded_project_ui(context)
|
||||
else:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -744,7 +744,7 @@ class EnableEditingSurfaceStyle(bpy.types.Operator):
|
||||
if self.ifc_class == "IfcSurfaceStyleLighting":
|
||||
|
||||
def callback(attribute_name: str, _: object, data: dict[str, Any]) -> None:
|
||||
assert attributes
|
||||
assert attributes is not None
|
||||
color = attributes.add()
|
||||
assert isinstance(color, ColourRgb)
|
||||
color.name = attribute_name
|
||||
@@ -782,34 +782,40 @@ class EditSurfaceStyle(bpy.types.Operator, tool.Ifc.Operator):
|
||||
def _execute(self, context):
|
||||
self.props = tool.Style.get_style_props()
|
||||
self.style = tool.Ifc.get().by_id(self.props.is_editing_style)
|
||||
prev_update_graph = self.props.update_graph
|
||||
self.props["update_graph"] = False
|
||||
|
||||
style_elements = tool.Style.get_style_elements(self.style)
|
||||
# NOTE: currently this operator is used to edit existing (and only existing) IfcSurfaceStyles
|
||||
# or new or existing IfcSurfaceStyle components (shading, etc)
|
||||
# which is kind of confusing.
|
||||
if self.props.is_editing_class == "IfcSurfaceStyle":
|
||||
self.surface_style = self.style
|
||||
else:
|
||||
self.surface_style = style_elements.get(self.props.is_editing_class, None)
|
||||
self.shading_style = style_elements.get("IfcSurfaceStyleShading", None)
|
||||
self.rendering_style = style_elements.get("IfcSurfaceStyleRendering", None)
|
||||
self.texture_style = style_elements.get("IfcSurfaceStyleWithTextures", None)
|
||||
try:
|
||||
style_elements = tool.Style.get_style_elements(self.style)
|
||||
|
||||
if self.surface_style:
|
||||
result = self.edit_existing_style()
|
||||
else:
|
||||
result = self.add_new_style()
|
||||
# NOTE: currently this operator is used to edit existing (and only existing) IfcSurfaceStyles
|
||||
# or new or existing IfcSurfaceStyle components (shading, etc)
|
||||
# which is kind of confusing.
|
||||
if self.props.is_editing_class == "IfcSurfaceStyle":
|
||||
self.surface_style = self.style
|
||||
else:
|
||||
self.surface_style = style_elements.get(self.props.is_editing_class, None)
|
||||
self.shading_style = style_elements.get("IfcSurfaceStyleShading", None)
|
||||
self.rendering_style = style_elements.get("IfcSurfaceStyleRendering", None)
|
||||
self.texture_style = style_elements.get("IfcSurfaceStyleWithTextures", None)
|
||||
|
||||
if result:
|
||||
return result
|
||||
if self.surface_style:
|
||||
result = self.edit_existing_style()
|
||||
else:
|
||||
result = self.add_new_style()
|
||||
|
||||
tool.Style.disable_editing()
|
||||
core.load_styles(tool.Style, style_type=self.props.style_type)
|
||||
if result:
|
||||
return result
|
||||
|
||||
# restore selected style type
|
||||
material = tool.Ifc.get_object(self.style)
|
||||
msprops = tool.Style.get_material_style_props(material)
|
||||
msprops.active_style_type = msprops.active_style_type
|
||||
tool.Style.disable_editing()
|
||||
core.load_styles(tool.Style, style_type=self.props.style_type)
|
||||
|
||||
# restore selected style type
|
||||
material = tool.Ifc.get_object(self.style)
|
||||
msprops = tool.Style.get_material_style_props(material)
|
||||
msprops.active_style_type = msprops.active_style_type
|
||||
finally:
|
||||
self.props["update_graph"] = prev_update_graph
|
||||
|
||||
def edit_existing_style(self) -> None:
|
||||
ifc_file = tool.Ifc.get()
|
||||
@@ -1231,4 +1237,5 @@ class RemoveSurfaceStyle(bpy.types.Operator, tool.Ifc.Operator):
|
||||
surface_style = tool.Style.get_style_elements(style)[props.is_editing_class]
|
||||
ifcopenshell.api.style.remove_surface_style(ifc_file, surface_style)
|
||||
core.disable_editing_style(tool.Style)
|
||||
core.load_styles(tool.Style, style_type=props.style_type)
|
||||
return {"FINISHED"}
|
||||
|
||||
@@ -185,6 +185,13 @@ class ColourRgb(PropertyGroup):
|
||||
# to fit blender.bim.helper.draw_attribute
|
||||
is_optional = False
|
||||
special_type = ""
|
||||
data_type = ""
|
||||
ifc_class = ""
|
||||
use_explorer_ui = False
|
||||
|
||||
@property
|
||||
def display_name(self):
|
||||
return self.name
|
||||
|
||||
def get_value_name(self, *args, **kwargs):
|
||||
return "color_value"
|
||||
|
||||
@@ -176,8 +176,30 @@ class BIM_PT_styles(Panel):
|
||||
row.prop(self.props, "reflectance_method")
|
||||
|
||||
if self.props.reflectance_method not in ("PHYSICAL", "NOTDEFINED", "FLAT"):
|
||||
self.layout.label(text="Supported reflectance methods are:")
|
||||
self.layout.label(text="PHYSICAL / NOTDEFINED / FLAT")
|
||||
self.layout.label(
|
||||
text=f"{self.props.reflectance_method} will be skipped: only PHYSICAL / NOTDEFINED / FLAT are supported",
|
||||
icon="ERROR",
|
||||
)
|
||||
elif self.props.reflectance_method in ("PHYSICAL", "NOTDEFINED"):
|
||||
if self.props.specular_colour_class == "IfcColourRgb":
|
||||
self.layout.label(
|
||||
text="Metallic color is IFC-only in PHYSICAL/NOTDEFINED and does not affect Blender appearance",
|
||||
icon="ERROR",
|
||||
)
|
||||
elif self.props.reflectance_method == "FLAT":
|
||||
if self.props.diffuse_colour_class == "IfcNormalisedRatioMeasure":
|
||||
self.layout.label(
|
||||
text="Emissive ratio is IFC-only in FLAT Reflectance method and does not affect Blender appearance",
|
||||
icon="ERROR",
|
||||
)
|
||||
self.layout.label(
|
||||
text="Specular value is IFC-only in FLAT Reflectance method and does not affect Blender appearance",
|
||||
icon="ERROR",
|
||||
)
|
||||
self.layout.label(
|
||||
text="Highlight value is IFC-only in FLAT Reflectance method and does not affect Blender appearance",
|
||||
icon="ERROR",
|
||||
)
|
||||
|
||||
row = self.layout.row(align=True)
|
||||
row.label(text="Emissive" if self.props.reflectance_method == "FLAT" else "Diffuse")
|
||||
@@ -232,6 +254,8 @@ class BIM_PT_styles(Panel):
|
||||
row.operator("bim.add_surface_texture", text="", icon="ADD")
|
||||
if textures:
|
||||
self.layout.prop(self.props, "uv_mode")
|
||||
if self.props.uv_mode in ("Generated", "Camera"):
|
||||
self.layout.label(text="Not available in SOLID Mode", icon="INFO")
|
||||
|
||||
for i, texture in enumerate(textures):
|
||||
split = self.layout.split(factor=0.30, align=True)
|
||||
@@ -244,6 +268,22 @@ class BIM_PT_styles(Panel):
|
||||
op_clear = row.operator("bim.remove_texture_map", text="", icon="X")
|
||||
op_path.texture_map_index = op_clear.texture_map_index = i
|
||||
|
||||
reflectance = self.props.reflectance_method
|
||||
mode = texture.mode
|
||||
if reflectance == "FLAT":
|
||||
if mode != "EMISSIVE":
|
||||
self.layout.label(
|
||||
text=f"{mode} will be skipped: only EMISSIVE is supported for Render Reflectance FLAT",
|
||||
icon="ERROR",
|
||||
)
|
||||
elif reflectance in ("PHYSICAL", "NOTDEFINED"):
|
||||
_SUPPORTED = {"DIFFUSE", "NORMAL", "METALLICROUGHNESS", "EMISSIVE", "OCCLUSION"}
|
||||
if mode not in _SUPPORTED:
|
||||
self.layout.label(
|
||||
text=f"{mode} will be skipped: not supported for Render Reflectance PHYSICAL/NOTDEFINED",
|
||||
icon="ERROR",
|
||||
)
|
||||
|
||||
def draw_externally_defined_surface_style(self):
|
||||
row = self.layout.row()
|
||||
op = row.operator("bim.browse_external_style", icon="APPEND_BLEND", text="Append From Blend File")
|
||||
@@ -252,10 +292,17 @@ class BIM_PT_styles(Panel):
|
||||
bonsai.bim.helper.draw_attributes(self.props.external_style_attributes, self.layout, enable_search=True)
|
||||
|
||||
def draw_refraction_surface_style(self):
|
||||
self.layout.label(
|
||||
text="Refraction values are IFC-only and do not affect Blender surface appearance",
|
||||
icon="ERROR",
|
||||
)
|
||||
bonsai.bim.helper.draw_attributes(self.props.refraction_style_attributes, self.layout, enable_search=True)
|
||||
row = self.layout.row(align=True)
|
||||
|
||||
def draw_lighting_surface_style(self):
|
||||
self.layout.label(
|
||||
text="Lighting values are IFC-only and do not affect Blender surface appearance",
|
||||
icon="ERROR",
|
||||
)
|
||||
bonsai.bim.helper.draw_attributes(self.props.lighting_style_colours, self.layout)
|
||||
|
||||
def draw_edit_ui(self, edit_label: str):
|
||||
|
||||
@@ -31,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()
|
||||
@@ -80,7 +74,7 @@ class SystemDecorator:
|
||||
|
||||
def draw_faces(self, bm, vertices_coords):
|
||||
"""Submit a non-mutating beauty-triangulated TRIS batch over ``bm``'s faces."""
|
||||
faces_color = transparent_color(self.addon_prefs.decorator_color_special)
|
||||
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):
|
||||
@@ -128,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)
|
||||
|
||||
@@ -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
|
||||
@@ -357,6 +375,14 @@ class DuplicateType(bpy.types.Operator, tool.Ifc.Operator):
|
||||
obj = tool.Ifc.get_object(element)
|
||||
if not obj:
|
||||
return {"FINISHED"}
|
||||
# Anchor any custom (non-extrusion) opening on the source type before the
|
||||
# copy so it is carried to the duplicate as a 'Reference' template, rather
|
||||
# than regenerated as a default extrusion on the new type's occurrences.
|
||||
if element.is_a("IfcElementType"):
|
||||
from bonsai.bim.module.model.opening import FilledOpeningGenerator
|
||||
|
||||
FilledOpeningGenerator().promote_opening_to_type(element)
|
||||
|
||||
new_obj = obj.copy()
|
||||
if obj.data:
|
||||
new_obj.data = obj.data.copy()
|
||||
@@ -376,12 +402,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)
|
||||
|
||||
@@ -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"}
|
||||
|
||||
+88
-68
@@ -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,34 +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)
|
||||
pipe_segment: BoolProperty(name="Pipe Segment", default=True)
|
||||
duct_segment: BoolProperty(name="Duct Segment", 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
|
||||
pipe_segment: bool
|
||||
duct_segment: 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):
|
||||
@@ -401,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):
|
||||
@@ -827,36 +822,17 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
|
||||
|
||||
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")
|
||||
@@ -974,6 +950,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"):
|
||||
|
||||
@@ -22,11 +22,18 @@
|
||||
|
||||
Used by both ``bim.disconnect_elements`` (explicit user disconnect) and the
|
||||
connection cascade in ``tool.Geometry.delete_ifc_object`` (implicit
|
||||
disconnect-on-delete). Each rel kind returned by
|
||||
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
|
||||
rel kind means extending one dispatch table — both call sites benefit
|
||||
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
|
||||
@@ -37,25 +44,24 @@ import bonsai.core.geometry
|
||||
from bonsai.core.model import regenerate_wall_to_underside
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import bpy
|
||||
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]",
|
||||
rel: "ifcopenshell.entity_instance",
|
||||
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",
|
||||
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 rel.
|
||||
"""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
|
||||
@@ -65,7 +71,7 @@ def disconnect_rel(
|
||||
runs on both sides.
|
||||
"""
|
||||
if kind == "path":
|
||||
bonsai.core.geometry.remove_connection(geometry, connection=rel)
|
||||
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:
|
||||
@@ -75,11 +81,11 @@ def disconnect_rel(
|
||||
if partner_obj is not None:
|
||||
model.recreate_wall(partner, partner_obj)
|
||||
elif kind == "element-top":
|
||||
wall, _slab = connection.orient_element_top(rel, elem, partner)
|
||||
wall, _slab = connection.orient_element_top(subject, elem, partner)
|
||||
ifc.run(
|
||||
"geometry.disconnect_element",
|
||||
relating_element=rel.RelatingElement,
|
||||
related_element=rel.RelatedElement,
|
||||
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
|
||||
@@ -92,8 +98,16 @@ def disconnect_rel(
|
||||
elif kind == "element":
|
||||
ifc.run(
|
||||
"geometry.disconnect_element",
|
||||
relating_element=rel.RelatingElement,
|
||||
related_element=rel.RelatedElement,
|
||||
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 rel kind: {kind!r}")
|
||||
raise ValueError(f"Unknown kind: {kind!r}")
|
||||
|
||||
@@ -55,9 +55,9 @@ def copy_class(
|
||||
geometry.copy_data_links(data, copied_entities)
|
||||
geometry.change_object_data(obj, data, is_global=True)
|
||||
geometry.rename_object(data, geometry.get_representation_name(ifc.get_entity(data)))
|
||||
# not sure what the purpose of this is, but removing it fixes wrong IfcStyledItems being assigned to
|
||||
# a duplicated object. Instead, copy_representation now properly copies IfcStyledItems directly.
|
||||
# root.assign_body_styles(new, obj)
|
||||
# Only assign styles if element doesn't get them from material
|
||||
if not root.has_material_styles(new):
|
||||
root.assign_body_styles(new, obj)
|
||||
collector.assign(obj)
|
||||
return new
|
||||
|
||||
|
||||
@@ -894,7 +894,6 @@ class Root:
|
||||
def get_default_container(cls): pass
|
||||
def get_element_representation(cls, element, context): pass
|
||||
def get_element_type(cls, element): pass
|
||||
def has_material_styles(cls, element): pass
|
||||
def get_object_name(cls, obj): pass
|
||||
def get_object_representation(cls, obj): pass
|
||||
def get_representation_context(cls, representation): pass
|
||||
@@ -1209,6 +1208,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
|
||||
|
||||
@@ -61,7 +61,6 @@ import numpy as np
|
||||
import numpy.typing as npt
|
||||
from gpu_extras.batch import batch_for_shader
|
||||
from ifcopenshell import entity_instance
|
||||
import ifcopenshell.api.pset
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
import bonsai.bim
|
||||
@@ -539,6 +538,19 @@ class Blender(bonsai.core.tool.Blender):
|
||||
cls.handlers.clear()
|
||||
cls.is_installed = False
|
||||
|
||||
def draw_batch(self, shader_type, content_pos, color, indices=None):
|
||||
"""Submit a GPU batch through ``self.line_shader`` (for ``"LINES"``)
|
||||
or ``self.shader`` (for any other primitive). Skips empty batches
|
||||
via ``validate_shader_batch_data`` so Blender 4.4+ doesn't crash on
|
||||
empty ``indices``. Subclasses bind both shaders in their draw method
|
||||
before calling this helper."""
|
||||
if not 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)
|
||||
|
||||
@staticmethod
|
||||
def _lookup_active_instance(gizmo_cls: type, context: bpy.types.Context) -> Optional[Any]:
|
||||
"""Return the live ``GizmoGroup`` instance registered under
|
||||
@@ -1525,6 +1537,10 @@ class Blender(bonsai.core.tool.Blender):
|
||||
bpy.ops.bim.enable_editing_railing_path()
|
||||
elif feature := tool.Parametric.is_object_editing(obj):
|
||||
tool.Parametric.run_bim_op(feature.finish_op)
|
||||
elif tool.Parametric.is_wall(element):
|
||||
# Placed after the generic finish dispatch so the TAB toggle splits:
|
||||
# wall already editing → finish above; wall not editing → enter here.
|
||||
bpy.ops.bim.enable_editing_wall()
|
||||
else:
|
||||
return False
|
||||
return True
|
||||
@@ -1619,22 +1635,6 @@ class Blender(bonsai.core.tool.Blender):
|
||||
|
||||
_any_selected_array_child_memo: tuple[frozenset[int], int, bool] | None = None
|
||||
|
||||
@classmethod
|
||||
def has_mirrored_type(cls, element: entity_instance, inherit: bool = True) -> entity_instance | None:
|
||||
pset = ifcopenshell.util.element.get_pset(element, "BBIM_MirroredType", "Data", should_inherit=inherit)
|
||||
if pset and (parsed := json.loads(pset)) and "mirrored_type" in parsed:
|
||||
return tool.Ifc.get_entity_by_id(int(parsed["mirrored_type"]))
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def set_mirrored_type(cls, element: entity_instance, mirrored_type: entity_instance):
|
||||
pset = tool.Pset.get_element_pset(element, "BBIM_MirroredType")
|
||||
if not pset:
|
||||
pset = ifcopenshell.api.pset.add_pset(tool.Ifc.get(), element, "BBIM_MirroredType")
|
||||
ifcopenshell.api.pset.edit_pset(
|
||||
tool.Ifc.get(), pset, properties={"Data": json.dumps({"mirrored_type": mirrored_type.id()})}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def is_slab(cls, element: entity_instance) -> bool:
|
||||
"""A slab is host-eligible for the parametric add-opening gizmo if
|
||||
@@ -2404,6 +2404,13 @@ class Blender(bonsai.core.tool.Blender):
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def transparent_color(color: Iterable[float], alpha: float = 0.1) -> list[float]:
|
||||
"""Copy an RGBA color with its alpha channel overridden."""
|
||||
out = [c for c in color]
|
||||
out[3] = alpha
|
||||
return out
|
||||
|
||||
@classmethod
|
||||
def draw_bmesh_face_tris(
|
||||
cls,
|
||||
|
||||
@@ -86,7 +86,19 @@ class ClipBox:
|
||||
# aligned to the prior view and the edit-mode picker rejects verts
|
||||
# inside the current clip_planes after orbit/pan/zoom.
|
||||
_view_matrix_at_arm: dict[int, tuple] = {}
|
||||
_refresh_pending: bool = False
|
||||
_pending_refresh: Optional[Callable[[], None]] = None
|
||||
# True from load_pre until the first on_pre_view tick of the new file
|
||||
# (first paint = GPU contexts wired). Suppresses schedule_refresh and
|
||||
# short-circuits on_depsgraph_update so neither path can drive
|
||||
# RegionView3D.update() against regions whose GL state is not yet
|
||||
# initialised — that crash inside GPU_matrix_ortho_set is a CTD, not
|
||||
# catchable from Python. load_post fires before the first paint, so it
|
||||
# CANNOT be the gate-clear point.
|
||||
_file_loading: bool = False
|
||||
# Edge-trigger consumed by on_pre_view: clears _file_loading on the
|
||||
# first frame after load and kicks a refresh so the new file's clip
|
||||
# box arms against now-safe regions.
|
||||
_post_load_paint_pending: bool = False
|
||||
_last_seen_ifc_id: int = 0
|
||||
# Tracks the last matrix we persisted to the pset, keyed by Blender
|
||||
# object name. Lets the depsgraph handler detect committed transform
|
||||
@@ -318,19 +330,32 @@ class ClipBox:
|
||||
from within a property write disrupts gizmo modal accounting and
|
||||
can leave the operator stack inconsistent. Deferring via a 0-delay
|
||||
timer hands the refresh to Blender's main loop, where operators are
|
||||
legal. Debounced: a flag suppresses repeats while one is pending.
|
||||
legal. Debounced: a pending handle suppresses repeats while one is
|
||||
in flight, and lets the file-load gate tear it down cleanly so the
|
||||
timer can't fire against not-yet-realised regions.
|
||||
"""
|
||||
if cls._refresh_pending:
|
||||
if cls._file_loading:
|
||||
return
|
||||
if cls._pending_refresh is not None:
|
||||
return
|
||||
cls._refresh_pending = True
|
||||
|
||||
def _do_refresh():
|
||||
cls._refresh_pending = False
|
||||
cls._pending_refresh = None
|
||||
cls.refresh()
|
||||
return None
|
||||
|
||||
cls._pending_refresh = _do_refresh
|
||||
bpy.app.timers.register(_do_refresh, first_interval=0.0)
|
||||
|
||||
@classmethod
|
||||
def _cancel_pending_refresh(cls) -> None:
|
||||
"""Cancel any pending debounced refresh. Idempotent; safe to call
|
||||
when none is registered (e.g. on addon unregister)."""
|
||||
pending = cls._pending_refresh
|
||||
if pending is not None and bpy.app.timers.is_registered(pending):
|
||||
bpy.app.timers.unregister(pending)
|
||||
cls._pending_refresh = None
|
||||
|
||||
@classmethod
|
||||
def reset_ownership(cls) -> None:
|
||||
"""Drop the ownership table without touching any region. Used on register/reload."""
|
||||
@@ -594,6 +619,13 @@ class ClipBox:
|
||||
so this branch fires once per commit — exactly the cadence the
|
||||
user expects for "save my latest transform".
|
||||
"""
|
||||
# During the file-load danger window (load_pre → first paint of the
|
||||
# new file) the screen exists but its regions' GPU contexts are not
|
||||
# yet wired; calling apply_clip_planes_direct here drives
|
||||
# RegionView3D.update() into a CTD inside GPU_matrix_ortho_set.
|
||||
# on_pre_view will reopen this gate on first paint.
|
||||
if cls._file_loading:
|
||||
return
|
||||
if getattr(bpy.context, "screen", None) is None:
|
||||
return
|
||||
if cls._active_scene_props(scene) is None:
|
||||
@@ -662,6 +694,15 @@ class ClipBox:
|
||||
handler's job (it fires on transform commit and writes through
|
||||
the operator transaction path).
|
||||
"""
|
||||
# First paint after a file load is the GPU-ready signal: open the
|
||||
# _file_loading gate and kick a refresh so the new file's clip box
|
||||
# arms against now-safe regions. Runs BEFORE the active-clip-box
|
||||
# check so the gate clears even when the new file has no clip box
|
||||
# (otherwise the gate would deadlock until the next file load).
|
||||
if cls._post_load_paint_pending:
|
||||
cls._post_load_paint_pending = False
|
||||
cls._file_loading = False
|
||||
cls.schedule_refresh()
|
||||
if cls._active_scene_props() is None:
|
||||
return
|
||||
obj = cls.get_active_clip_box()
|
||||
|
||||
@@ -18,25 +18,33 @@
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Generic discovery of the relation linking two IFC elements.
|
||||
"""Generic discovery of the connection linking two IFC elements.
|
||||
|
||||
Used by ``bim.disconnect_elements`` so the operator surface is one operator
|
||||
per disconnect intent (active vs. partner, identified by GlobalId) rather
|
||||
than one per rel class. The kind label returned alongside the rel lets the
|
||||
operator dispatch the right post-disconnect cleanup:
|
||||
than one per rel class. Each lookup returns ``(subject, kind)`` tuples where
|
||||
``subject`` is the entity whose teardown effects the disconnect:
|
||||
|
||||
- ``"path"`` for ``IfcRelConnectsPathElements`` (wall-wall, wall-roof, etc.)
|
||||
- ``"element-top"`` for ``IfcRelConnectsElements`` with ``Description=="TOP"``
|
||||
(the rel kind ``extend_walls_to_underside`` creates)
|
||||
- ``"element"`` for any other ``IfcRelConnectsElements``
|
||||
- ``"path"`` — ``IfcRelConnectsPathElements`` (wall-wall, wall-roof, etc.).
|
||||
``subject`` is the rel; removing it disconnects.
|
||||
- ``"element-top"`` — ``IfcRelConnectsElements`` with ``Description=="TOP"``
|
||||
(created by ``extend_walls_to_underside``). ``subject`` is the rel.
|
||||
- ``"element"`` — any other ``IfcRelConnectsElements``. ``subject`` is the rel.
|
||||
- ``"mep-pair-fitting"`` — two MEP elements joined via ``IfcRelConnectsPorts``
|
||||
through a single bridging ``IfcFlowFitting``. ``subject`` is the fitting
|
||||
itself; removing it disconnects. ``OBSTRUCTION`` fittings are excluded
|
||||
here; those go through ``bim.mep_add_obstruction(mode=REMOVE)``.
|
||||
|
||||
Add new rel kinds by extending :py:meth:`Connection.find_rel`. The disconnect
|
||||
operator's cleanup switch maps each kind to the right post-mutation calls."""
|
||||
Add new kinds by extending :py:meth:`Connection.find_rels`. The dispatch in
|
||||
``bonsai.core.connection.disconnect_rel`` maps each kind to the right
|
||||
post-mutation cleanup; the AST forward-compat guard enforces coverage."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import bonsai.tool as tool
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import ifcopenshell
|
||||
|
||||
@@ -45,16 +53,16 @@ class Connection:
|
||||
@classmethod
|
||||
def find_rels(
|
||||
cls,
|
||||
elem_a: "ifcopenshell.entity_instance",
|
||||
elem_b: "ifcopenshell.entity_instance",
|
||||
) -> "list[tuple[ifcopenshell.entity_instance, str]]":
|
||||
"""Return every supported rel linking ``elem_a`` to ``elem_b`` as a
|
||||
list of ``(rel, kind)`` tuples. Walks both ``ConnectedTo`` and
|
||||
``ConnectedFrom`` because either side of the rel can be the relating
|
||||
element, and the same pair may carry rels authored with opposite
|
||||
orientations (``disconnect_path``'s ``(relating, related)`` mode only
|
||||
inspects ``relating.ConnectedTo``, so a single call would miss the
|
||||
opposite-orientation rel)."""
|
||||
elem_a: ifcopenshell.entity_instance,
|
||||
elem_b: ifcopenshell.entity_instance,
|
||||
) -> list[tuple[ifcopenshell.entity_instance, str]]:
|
||||
"""Return every supported connection linking ``elem_a`` to ``elem_b``
|
||||
as a list of ``(subject, kind)`` tuples — ``subject`` is the entity
|
||||
whose teardown effects the disconnect (the rel itself for
|
||||
relationship-kinds, the bridging fitting for ``"mep-pair-fitting"``).
|
||||
Walks both ``ConnectedTo`` and ``ConnectedFrom`` because either side
|
||||
of a rel can be the relating element, and the same pair may carry
|
||||
rels authored with opposite orientations."""
|
||||
rels: list[tuple[ifcopenshell.entity_instance, str]] = []
|
||||
seen: set[int] = set()
|
||||
|
||||
@@ -79,16 +87,20 @@ class Connection:
|
||||
kind = "element-top" if getattr(rel, "Description", None) == "TOP" else "element"
|
||||
_record(rel, kind)
|
||||
|
||||
fitting = tool.System.find_bridging_fitting(elem_a, elem_b)
|
||||
if fitting is not None:
|
||||
_record(fitting, "mep-pair-fitting")
|
||||
|
||||
return rels
|
||||
|
||||
@classmethod
|
||||
def find_rel(
|
||||
cls,
|
||||
elem_a: "ifcopenshell.entity_instance",
|
||||
elem_b: "ifcopenshell.entity_instance",
|
||||
) -> "tuple[ifcopenshell.entity_instance | None, str | None]":
|
||||
"""Return the first ``(rel, kind)`` or ``(None, None)``. Cheaper than
|
||||
``find_rels`` when callers only need to know whether a connection
|
||||
elem_a: ifcopenshell.entity_instance,
|
||||
elem_b: ifcopenshell.entity_instance,
|
||||
) -> tuple[ifcopenshell.entity_instance | None, str | None]:
|
||||
"""Return the first ``(subject, kind)`` or ``(None, None)``. Cheaper
|
||||
than ``find_rels`` when callers only need to know whether a connection
|
||||
exists or what kind it is."""
|
||||
rels = cls.find_rels(elem_a, elem_b)
|
||||
return rels[0] if rels else (None, None)
|
||||
@@ -96,17 +108,23 @@ class Connection:
|
||||
@classmethod
|
||||
def find_rels_for_element(
|
||||
cls,
|
||||
elem: "ifcopenshell.entity_instance",
|
||||
) -> "list[tuple[ifcopenshell.entity_instance, str, ifcopenshell.entity_instance]]":
|
||||
"""Return every supported rel touching ``elem`` as ``(rel, kind, partner)``
|
||||
triples. ``partner`` is the *other* element on the rel — the side cascade
|
||||
cleanup must operate on when ``elem`` is being deleted.
|
||||
elem: ifcopenshell.entity_instance,
|
||||
) -> list[tuple[ifcopenshell.entity_instance, str, ifcopenshell.entity_instance]]:
|
||||
"""Return every supported connection touching ``elem`` as
|
||||
``(subject, kind, partner)`` triples. ``partner`` is the *other*
|
||||
element on the connection — the side cascade cleanup must operate on
|
||||
when ``elem`` is being deleted.
|
||||
|
||||
Mirrors :py:meth:`find_rels`'s kind taxonomy. The single-element entry
|
||||
point lets the cascade-on-delete in ``tool.Geometry.delete_ifc_object``
|
||||
enumerate everything the disconnect operator would handle pairwise.
|
||||
Mirrors :py:meth:`find_rels`'s relationship-kind taxonomy. Notably
|
||||
does NOT emit ``"mep-pair-fitting"`` triples: ``IfcRelConnectsPorts``
|
||||
cleanup is owned by ``tool.Geometry.delete_ifc_object``'s
|
||||
``remove_port`` loop, which runs unconditionally on any IFC root
|
||||
deletion. Including MEP here would cause the cascade to also remove
|
||||
the bridging fitting when one of its connected segments is deleted —
|
||||
a policy choice (fitting may still join other live segments) that's
|
||||
better left to the user via the explicit disconnect operator.
|
||||
"""
|
||||
result: list[tuple["ifcopenshell.entity_instance", str, "ifcopenshell.entity_instance"]] = []
|
||||
result: list[tuple[ifcopenshell.entity_instance, str, ifcopenshell.entity_instance]] = []
|
||||
seen: set[int] = set()
|
||||
|
||||
def _record(rel, kind, partner):
|
||||
@@ -133,10 +151,10 @@ class Connection:
|
||||
@classmethod
|
||||
def orient_element_top(
|
||||
cls,
|
||||
rel: "ifcopenshell.entity_instance",
|
||||
elem_a: "ifcopenshell.entity_instance",
|
||||
elem_b: "ifcopenshell.entity_instance",
|
||||
) -> "tuple[ifcopenshell.entity_instance, ifcopenshell.entity_instance]":
|
||||
rel: ifcopenshell.entity_instance,
|
||||
elem_a: ifcopenshell.entity_instance,
|
||||
elem_b: ifcopenshell.entity_instance,
|
||||
) -> tuple[ifcopenshell.entity_instance, ifcopenshell.entity_instance]:
|
||||
"""Return ``(wall, slab)`` for an ``IfcRelConnectsElements(TOP)`` rel.
|
||||
|
||||
The ``extend_walls_to_underside`` flow stores slab as the relating
|
||||
|
||||
@@ -78,6 +78,7 @@ if TYPE_CHECKING:
|
||||
|
||||
class Drawing(bonsai.core.tool.Drawing):
|
||||
ANNOTATION_DATA_TYPE = Literal["empty", "curve", "mesh"]
|
||||
PERSPECTIVE_CAMERA_SHIFT_PROPERTIES = ("PerspectiveShiftX", "PerspectiveShiftY")
|
||||
DOCUMENT_TYPE = Literal["SCHEDULE", "REFERENCE"]
|
||||
LocationHintLiteral = Literal["PERSPECTIVE", "ORTHOGRAPHIC", "NORTH", "SOUTH", "EAST", "WEST"]
|
||||
LOCATION_HINT_LITERALS = ("PERSPECTIVE", "ORTHOGRAPHIC", "NORTH", "SOUTH", "EAST", "WEST")
|
||||
@@ -453,6 +454,41 @@ class Drawing(bonsai.core.tool.Drawing):
|
||||
camera.matrix_world = matrix
|
||||
return camera
|
||||
|
||||
@classmethod
|
||||
def get_perspective_camera_shifts(cls, drawing: ifcopenshell.entity_instance) -> dict[str, float]:
|
||||
pset = ifcopenshell.util.element.get_pset(drawing, "EPset_Drawing") or {}
|
||||
shift_x_prop, shift_y_prop = cls.PERSPECTIVE_CAMERA_SHIFT_PROPERTIES
|
||||
return {
|
||||
"shift_x": float(pset.get(shift_x_prop, 0.0) or 0.0),
|
||||
"shift_y": float(pset.get(shift_y_prop, 0.0) or 0.0),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def sync_perspective_camera_shifts(cls, drawing: ifcopenshell.entity_instance, camera: bpy.types.Camera) -> None:
|
||||
if camera.type != "PERSP":
|
||||
return
|
||||
|
||||
shift_x_prop, shift_y_prop = cls.PERSPECTIVE_CAMERA_SHIFT_PROPERTIES
|
||||
current_shifts = cls.get_perspective_camera_shifts(drawing)
|
||||
new_shifts = {"shift_x": float(camera.shift_x or 0.0), "shift_y": float(camera.shift_y or 0.0)}
|
||||
if tool.Cad.is_x(current_shifts["shift_x"], new_shifts["shift_x"]) and tool.Cad.is_x(
|
||||
current_shifts["shift_y"], new_shifts["shift_y"]
|
||||
):
|
||||
return
|
||||
|
||||
ifc_file = tool.Ifc.get()
|
||||
pset = tool.Pset.get_element_pset(drawing, "EPset_Drawing")
|
||||
if not pset:
|
||||
pset = ifcopenshell.api.pset.add_pset(ifc_file, product=drawing, name="EPset_Drawing")
|
||||
ifcopenshell.api.pset.edit_pset(
|
||||
ifc_file,
|
||||
pset=pset,
|
||||
properties={
|
||||
shift_x_prop: new_shifts["shift_x"],
|
||||
shift_y_prop: new_shifts["shift_y"],
|
||||
},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def create_svg_schedule(cls, schedule: ifcopenshell.entity_instance) -> None:
|
||||
import bonsai.bim.module.drawing.scheduler as scheduler
|
||||
@@ -1009,6 +1045,8 @@ class Drawing(bonsai.core.tool.Drawing):
|
||||
camera_props.has_annotation = True
|
||||
camera_props.target_view = "PLAN_VIEW"
|
||||
camera_props.is_nts = False
|
||||
camera.shift_x = 0.0
|
||||
camera.shift_y = 0.0
|
||||
|
||||
pset = ifcopenshell.util.element.get_pset(drawing, "EPset_Drawing")
|
||||
if pset:
|
||||
@@ -1044,6 +1082,10 @@ class Drawing(bonsai.core.tool.Drawing):
|
||||
camera_props.fill_mode = str(pset["FillMode"])
|
||||
if "CutMode" in pset:
|
||||
camera_props.cut_mode = str(pset["CutMode"])
|
||||
if camera.type == "PERSP":
|
||||
shifts = cls.get_perspective_camera_shifts(drawing)
|
||||
camera.shift_x = shifts["shift_x"]
|
||||
camera.shift_y = shifts["shift_y"]
|
||||
|
||||
camera_props.update_props = update_props
|
||||
|
||||
@@ -2267,14 +2309,19 @@ class Drawing(bonsai.core.tool.Drawing):
|
||||
cls, drawing: ifcopenshell.entity_instance, ifc_file: Optional[ifcopenshell.file] = None
|
||||
) -> set[ifcopenshell.entity_instance]:
|
||||
"""returns a set of elements that are included in the drawing"""
|
||||
if ifc_file is None:
|
||||
param_was_none = ifc_file is None
|
||||
if param_was_none:
|
||||
ifc_file = tool.Ifc.get()
|
||||
elements = cls.get_elements_in_camera_view(tool.Ifc.get_object(drawing), bpy.data.objects)
|
||||
else:
|
||||
# This can probably be smarter
|
||||
elements = set(ifc_file.by_type("IfcElement"))
|
||||
pset = ifcopenshell.util.element.get_psets(drawing).get("EPset_Drawing", {})
|
||||
include = pset.get("Include", None)
|
||||
|
||||
# Only the active IFC file has Blender objects we can test against the
|
||||
# camera's view frustum, which lets us drop elements - including those
|
||||
# picked by an Include filter - that fall outside the drawing boundary.
|
||||
camera_view_elements = None
|
||||
if (param_was_none or include) and ifc_file is tool.Ifc.get():
|
||||
camera_view_elements = cls.get_elements_in_camera_view(tool.Ifc.get_object(drawing), bpy.data.objects)
|
||||
|
||||
if include:
|
||||
try:
|
||||
data = json.loads(include)
|
||||
@@ -2286,7 +2333,16 @@ class Drawing(bonsai.core.tool.Drawing):
|
||||
elements = ifcopenshell.util.selector.filter_elements(ifc_file, include)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
elements = ifcopenshell.util.selector.filter_elements(ifc_file, include)
|
||||
# The Include filter chooses which elements may appear, but they must
|
||||
# still fall within the drawing's camera boundary.
|
||||
if camera_view_elements is not None:
|
||||
elements &= camera_view_elements
|
||||
else:
|
||||
if param_was_none:
|
||||
elements = camera_view_elements
|
||||
else:
|
||||
# This can probably be smarter
|
||||
elements = set(ifc_file.by_type("IfcElement"))
|
||||
if ifc_file.schema == "IFC2X3":
|
||||
base_elements = set(ifc_file.by_type("IfcElement") + ifc_file.by_type("IfcSpatialStructureElement"))
|
||||
else:
|
||||
@@ -2384,13 +2440,13 @@ class Drawing(bonsai.core.tool.Drawing):
|
||||
@classmethod
|
||||
def is_drawing_active(cls) -> bool:
|
||||
camera = bpy.context.scene.camera
|
||||
area = tool.Blender.get_view3d_area()
|
||||
return bool(
|
||||
camera is not None
|
||||
and camera.type == "CAMERA"
|
||||
and tool.Blender.get_ifc_definition_id(camera)
|
||||
and area is not None
|
||||
)
|
||||
if not (camera is not None and camera.type == "CAMERA" and tool.Blender.get_ifc_definition_id(camera)):
|
||||
return False
|
||||
# A VIEW_3D area is meaningless (and unobtainable) in background
|
||||
# mode, but isn't otherwise required to generate a drawing.
|
||||
if bpy.app.background:
|
||||
return True
|
||||
return tool.Blender.get_view3d_area() is not None
|
||||
|
||||
@classmethod
|
||||
def is_camera_orthographic(cls) -> bool:
|
||||
@@ -2521,10 +2577,19 @@ class Drawing(bonsai.core.tool.Drawing):
|
||||
has_context = True
|
||||
break
|
||||
|
||||
linked_handles: set[bpy.types.Object] = set()
|
||||
for link in tool.Project.get_project_props().get_loaded_links_for_drawings():
|
||||
try:
|
||||
handle = tool.Project.get_link_empty_handle(link)
|
||||
except Exception:
|
||||
continue
|
||||
if handle:
|
||||
linked_handles.add(handle)
|
||||
|
||||
visible_objects = []
|
||||
for obj in bpy.context.view_layer.objects:
|
||||
if element := tool.Ifc.get_entity(obj):
|
||||
if element in filtered_elements:
|
||||
if element in filtered_elements or obj in linked_handles:
|
||||
visible_objects.append(obj)
|
||||
else:
|
||||
if obj.hide_get() is False:
|
||||
|
||||
@@ -24,6 +24,7 @@ import multiprocessing
|
||||
import struct
|
||||
from collections import defaultdict
|
||||
from collections.abc import Generator, Iterable, Iterator
|
||||
from contextlib import contextmanager
|
||||
from math import pi, radians
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
@@ -130,6 +131,77 @@ class Geometry(bonsai.core.tool.Geometry):
|
||||
if cache and hasattr(element, "GlobalId"):
|
||||
cache.remove(element.GlobalId)
|
||||
|
||||
# Per-host work coalesced by `batch_host_recut`. Keys are voided element ifc ids;
|
||||
# dict insertion preserves call ordering. Recut values store the representation at
|
||||
# enqueue time, but the drain re-reads `get_active_representation` so the recut
|
||||
# always reflects current IFC state.
|
||||
_host_batch_depth: int = 0
|
||||
_host_recut_queue: dict[int, tuple[bpy.types.Object, ifcopenshell.entity_instance]] = {}
|
||||
_host_update_queue: dict[int, bpy.types.Object] = {}
|
||||
|
||||
@classmethod
|
||||
@contextmanager
|
||||
def batch_host_recut(cls) -> Generator[None, None, None]:
|
||||
"""Coalesce host body work — `recut_host` and `update_host_representation`
|
||||
calls inside the with-block enqueue by voided element id. On the outermost
|
||||
exit: every host's `update_representation` runs first (writes Blender mesh
|
||||
back to IFC), then every host's `switch_representation` runs (reads IFC +
|
||||
openings → Blender mesh). The two-phase order matters: a recut that ran
|
||||
before the matching update_representation would re-tessellate against stale
|
||||
IFC, losing the user's edits.
|
||||
|
||||
Nests safely — only the outermost exit drains. The depth counter and queues
|
||||
are reset on exit even if the body raises."""
|
||||
cls._host_batch_depth += 1
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
cls._host_batch_depth -= 1
|
||||
if cls._host_batch_depth == 0:
|
||||
update_queue = cls._host_update_queue
|
||||
recut_queue = cls._host_recut_queue
|
||||
cls._host_update_queue = {}
|
||||
cls._host_recut_queue = {}
|
||||
for voided_obj in update_queue.values():
|
||||
if not voided_obj or not voided_obj.data:
|
||||
continue
|
||||
if tool.Ifc.get_entity(voided_obj) is None:
|
||||
continue
|
||||
bpy.ops.bim.update_representation(obj=voided_obj.name)
|
||||
for voided_obj, _ in recut_queue.values():
|
||||
if not voided_obj or not voided_obj.data:
|
||||
continue
|
||||
if tool.Ifc.get_entity(voided_obj) is None:
|
||||
continue
|
||||
current_rep = cls.get_active_representation(voided_obj)
|
||||
if current_rep is None:
|
||||
continue
|
||||
bonsai.core.geometry.switch_representation(
|
||||
tool.Ifc, cls, obj=voided_obj, representation=current_rep
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def recut_host(cls, voided_obj: bpy.types.Object, representation: ifcopenshell.entity_instance) -> None:
|
||||
"""Recut a host's body representation. Inside `batch_host_recut`, enqueues
|
||||
by voided element id; outside, fires `switch_representation` directly."""
|
||||
if cls._host_batch_depth > 0:
|
||||
element = tool.Ifc.get_entity(voided_obj)
|
||||
if element is not None:
|
||||
cls._host_recut_queue[element.id()] = (voided_obj, representation)
|
||||
return
|
||||
bonsai.core.geometry.switch_representation(tool.Ifc, cls, obj=voided_obj, representation=representation)
|
||||
|
||||
@classmethod
|
||||
def update_host_representation(cls, voided_obj: bpy.types.Object) -> None:
|
||||
"""Run `bim.update_representation` on a host. Inside `batch_host_recut`,
|
||||
enqueues by voided element id; outside, fires the operator directly."""
|
||||
if cls._host_batch_depth > 0:
|
||||
element = tool.Ifc.get_entity(voided_obj)
|
||||
if element is not None:
|
||||
cls._host_update_queue[element.id()] = voided_obj
|
||||
return
|
||||
bpy.ops.bim.update_representation(obj=voided_obj.name)
|
||||
|
||||
@classmethod
|
||||
def has_axis_representation(cls, element: ifcopenshell.entity_instance) -> bool:
|
||||
"""True if the element carries a shape representation whose
|
||||
@@ -396,13 +468,13 @@ class Geometry(bonsai.core.tool.Geometry):
|
||||
# same OverrideDelete batch.
|
||||
if element.is_a("IfcRoot"):
|
||||
skip_ids = batch_being_deleted_ids or set()
|
||||
for rel, kind, partner in tool.Connection.find_rels_for_element(element):
|
||||
for subject, kind, partner in tool.Connection.find_rels_for_element(element):
|
||||
bonsai.core.connection.disconnect_rel(
|
||||
tool.Ifc,
|
||||
tool.Geometry,
|
||||
tool.Model,
|
||||
tool.Connection,
|
||||
rel=rel,
|
||||
subject=subject,
|
||||
kind=kind,
|
||||
elem=element,
|
||||
partner=partner,
|
||||
@@ -1134,7 +1206,23 @@ class Geometry(bonsai.core.tool.Geometry):
|
||||
|
||||
for element in element_types:
|
||||
if obj := tool.Ifc.get_object(element):
|
||||
if representation := ifcopenshell.util.representation.get_representation(element, context):
|
||||
# A type may hold several representations in one context (e.g. a 'Body' body
|
||||
# plus a 'Reference' opening template), and get_representation() matches only
|
||||
# by context. When base_representation is one of this type's own
|
||||
# representations - i.e. we are reimporting it directly, such as switching to
|
||||
# the Reference rep - render exactly that, otherwise the context lookup could
|
||||
# return the wrong one. But element_types also contains each occurrence's
|
||||
# type (see above), for which base_representation is not theirs; fall back to
|
||||
# the context lookup there (and skip, as before, when it has none).
|
||||
type_representations = [
|
||||
ifcopenshell.util.representation.resolve_representation(rm.MappedRepresentation)
|
||||
for rm in (element.RepresentationMaps or [])
|
||||
]
|
||||
if base_representation in type_representations:
|
||||
representation = base_representation
|
||||
else:
|
||||
representation = ifcopenshell.util.representation.get_representation(element, context)
|
||||
if representation:
|
||||
geometry = ifcopenshell.geom.create_shape(settings, representation)
|
||||
mesh_name = tool.Loader.get_mesh_name_from_shape(geometry)
|
||||
mesh = meshes.get(mesh_name)
|
||||
|
||||
@@ -189,7 +189,7 @@ class Loader(bonsai.core.tool.Loader):
|
||||
uv_mode = "Generated"
|
||||
elif coordinates.is_a("IfcTextureCoordinateGenerator") and coordinates.Mode == "COORD-EYE":
|
||||
uv_mode = "Camera"
|
||||
surface_texture["uv_mode"] = uv_mode or "Generated"
|
||||
surface_texture["uv_mode"] = uv_mode or "UV"
|
||||
return surface_texture
|
||||
|
||||
@classmethod
|
||||
@@ -315,7 +315,7 @@ class Loader(bonsai.core.tool.Loader):
|
||||
image_url = str(image_url)
|
||||
if is_relative and bpy.data.filepath:
|
||||
image_url = bpy.path.relpath(image_url)
|
||||
return bpy.data.images.load(image_url)
|
||||
return bpy.data.images.load(image_url, check_existing=True)
|
||||
|
||||
elif texture["type"] == "IfcBlobTexture":
|
||||
# https://blender.stackexchange.com/questions/173206/how-to-efficiently-convert-a-pil-image-to-bpy-types-image
|
||||
@@ -472,12 +472,23 @@ class Loader(bonsai.core.tool.Loader):
|
||||
print(f"{mode} Mode texture will be skipped.")
|
||||
continue
|
||||
|
||||
if (image := get_image) is None:
|
||||
if (image := get_image()) is None:
|
||||
continue
|
||||
|
||||
# remove RGB node from `create_surface_style_rendering`
|
||||
prev_node = bsdf.inputs[2].links[0].from_node
|
||||
blender_material.node_tree.nodes.remove(prev_node)
|
||||
# Replace whatever currently feeds the FLAT color input (RGB or previous texture chain).
|
||||
for link in list(bsdf.inputs[2].links):
|
||||
prev_node = link.from_node
|
||||
blender_material.node_tree.links.remove(link)
|
||||
if prev_node.type == "TEX_IMAGE":
|
||||
# Remove linked texture coordinate node if it's no longer used.
|
||||
for vec_link in list(prev_node.inputs["Vector"].links):
|
||||
coord_node = vec_link.from_node
|
||||
blender_material.node_tree.links.remove(vec_link)
|
||||
if coord_node.type == "TEX_COORD" and not any(o.links for o in coord_node.outputs):
|
||||
blender_material.node_tree.nodes.remove(coord_node)
|
||||
blender_material.node_tree.nodes.remove(prev_node)
|
||||
elif prev_node.type == "RGB":
|
||||
blender_material.node_tree.nodes.remove(prev_node)
|
||||
|
||||
node = blender_material.node_tree.nodes.new(type="ShaderNodeTexImage")
|
||||
node.location = bsdf.location - Vector((200, 250))
|
||||
|
||||
+104
-60
@@ -1244,6 +1244,13 @@ class Model(bonsai.core.tool.Model):
|
||||
cls, parent_obj: bpy.types.Object, data: list[dict[str, Any]], array_layers_to_apply: Iterable[int] = tuple()
|
||||
) -> None:
|
||||
"""`array_layers_to_apply` - list of array layer indices to apply"""
|
||||
with tool.Geometry.batch_host_recut():
|
||||
cls._regenerate_array_body(parent_obj, data, array_layers_to_apply)
|
||||
|
||||
@classmethod
|
||||
def _regenerate_array_body(
|
||||
cls, parent_obj: bpy.types.Object, data: list[dict[str, Any]], array_layers_to_apply: Iterable[int]
|
||||
) -> None:
|
||||
parent_element = tool.Ifc.get_entity(parent_obj)
|
||||
|
||||
if pset := ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array"):
|
||||
@@ -1442,9 +1449,7 @@ class Model(bonsai.core.tool.Model):
|
||||
representation = tool.Geometry.get_representation_by_context(voided_element, context)
|
||||
if representation is None:
|
||||
continue
|
||||
bonsai.core.geometry.switch_representation(
|
||||
tool.Ifc, tool.Geometry, obj=voided_obj, representation=representation
|
||||
)
|
||||
tool.Geometry.recut_host(voided_obj, representation)
|
||||
|
||||
@classmethod
|
||||
def unshare_opening_representation(cls, filling: ifcopenshell.entity_instance) -> None:
|
||||
@@ -1695,35 +1700,6 @@ class Model(bonsai.core.tool.Model):
|
||||
representation=body,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def reload_active_representation(cls, obj_or_objects: Union[bpy.types.Object, Iterable[bpy.types.Object]]) -> None:
|
||||
"""Update active representation including all decomposed objects"""
|
||||
if isinstance(obj_or_objects, collections.abc.Iterable):
|
||||
objects = set(obj_or_objects)
|
||||
else:
|
||||
objects = {obj_or_objects}
|
||||
|
||||
# decompose objects
|
||||
decomposed_objs = objects.copy()
|
||||
for obj in objects:
|
||||
for subelement in ifcopenshell.util.element.get_decomposition(tool.Ifc.get_entity(obj)):
|
||||
subobj = tool.Ifc.get_object(subelement)
|
||||
if subobj:
|
||||
decomposed_objs.add(subobj)
|
||||
|
||||
# update representation
|
||||
for obj in decomposed_objs:
|
||||
if not obj.data:
|
||||
continue
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
active_repr = tool.Geometry.get_active_representation(obj)
|
||||
bonsai.core.geometry.switch_representation(
|
||||
tool.Ifc,
|
||||
tool.Geometry,
|
||||
obj=obj,
|
||||
representation=active_repr,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def is_parametric_roof_active(cls) -> bool:
|
||||
from bonsai.bim.module.model.data import RoofData
|
||||
@@ -2084,50 +2060,95 @@ class Model(bonsai.core.tool.Model):
|
||||
return (vertices, edges, faces)
|
||||
|
||||
@classmethod
|
||||
def update_simple_openings(cls, element: ifcopenshell.entity_instance) -> None:
|
||||
def regenerate_filling_opening_body(cls, filling: ifcopenshell.entity_instance) -> Optional[bpy.types.Object]:
|
||||
"""Regenerate only the mapped source used by ``filling``'s opening so
|
||||
it matches ``filling``'s current parametric dimensions.
|
||||
|
||||
Returns the voided host Blender object so the caller can recut it,
|
||||
or ``None`` if ``filling`` has no opening to refresh or the host is
|
||||
an aggregate (no mesh data to recut against)."""
|
||||
from bonsai.bim.module.model.opening import FilledOpeningGenerator
|
||||
|
||||
ifc_file = tool.Ifc.get()
|
||||
fillings = {e: tool.Ifc.get_object(e) for e in tool.Array.get_parametric_propagation_targets(element)}
|
||||
if not filling.FillsVoids:
|
||||
return None
|
||||
|
||||
voided_objs = set()
|
||||
has_replaced_opening_representation = False
|
||||
ifc_file = tool.Ifc.get()
|
||||
opening = filling.FillsVoids[0].RelatingOpeningElement
|
||||
voided_obj = tool.Ifc.get_object(opening.VoidsElements[0].RelatingBuildingElement)
|
||||
if voided_obj is None or voided_obj.data is None:
|
||||
return None
|
||||
|
||||
old_representation = tool.Geometry.get_body_representation(opening)
|
||||
if old_representation is None:
|
||||
return voided_obj
|
||||
old_representation = tool.Geometry.resolve_mapped_representation(old_representation)
|
||||
|
||||
ifcopenshell.api.geometry.unassign_representation(ifc_file, product=opening, representation=old_representation)
|
||||
|
||||
filling_obj = tool.Ifc.get_object(filling)
|
||||
new_representation = FilledOpeningGenerator().generate_opening_from_filling(
|
||||
filling, filling_obj, voided_obj.dimensions[1]
|
||||
)
|
||||
|
||||
for inverse in ifc_file.get_inverse(old_representation):
|
||||
ifcopenshell.util.element.replace_attribute(inverse, old_representation, new_representation)
|
||||
|
||||
ifcopenshell.api.geometry.remove_representation(ifc_file, representation=old_representation)
|
||||
|
||||
return voided_obj
|
||||
|
||||
@classmethod
|
||||
def regenerate_simple_opening_bodies(cls, element: ifcopenshell.entity_instance) -> set:
|
||||
"""Regenerate every distinct mapped opening source within ``element``'s
|
||||
type-occurrence family so each one matches the family's current
|
||||
parametric dimensions.
|
||||
|
||||
Most occurrences share a single mapped source — refreshing it once
|
||||
propagates to every filling via inverse-substitution. Some families,
|
||||
especially those imported from foreign authoring tools, fragment into
|
||||
several mapped sources for the same type; dedup is by source id so
|
||||
every distinct source gets one refresh. Returns the set of Blender
|
||||
objects whose host representation needs a viewport-level recut
|
||||
(callers handle the recut themselves)."""
|
||||
ifc_file = tool.Ifc.get()
|
||||
fillings = list(tool.Array.get_parametric_propagation_targets(element))
|
||||
|
||||
voided_objs: set = set()
|
||||
seen_source_ids: set[int] = set()
|
||||
for filling in fillings:
|
||||
if not filling.FillsVoids:
|
||||
continue
|
||||
|
||||
opening = filling.FillsVoids[0].RelatingOpeningElement
|
||||
voided_obj = tool.Ifc.get_object(opening.VoidsElements[0].RelatingBuildingElement)
|
||||
voided_objs.add(voided_obj)
|
||||
if voided_obj is not None:
|
||||
voided_objs.add(voided_obj)
|
||||
|
||||
# We assume all occurrences of the same element type (e.g. a window)
|
||||
# will use openings of the same thickness.
|
||||
# Generator we use by default will create a really thick opening representation
|
||||
# to make sure it will fit for walls with different thickness.
|
||||
if has_replaced_opening_representation:
|
||||
# Preserve user-authored opening geometry (e.g. an IfcPolygonalFaceSet
|
||||
# or other tessellation) instead of replacing it with a default extrusion.
|
||||
from bonsai.bim.module.model.opening import FilledOpeningGenerator
|
||||
|
||||
if FilledOpeningGenerator().is_opening_representation_custom(opening):
|
||||
continue
|
||||
|
||||
old_representation = ifcopenshell.util.representation.get_representation(
|
||||
opening, "Model", "Body", "MODEL_VIEW"
|
||||
)
|
||||
old_representation = tool.Geometry.resolve_mapped_representation(old_representation)
|
||||
ifcopenshell.api.geometry.unassign_representation(
|
||||
ifc_file, product=opening, representation=old_representation
|
||||
)
|
||||
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())
|
||||
|
||||
new_representation = FilledOpeningGenerator().generate_opening_from_filling(
|
||||
filling, fillings[filling], voided_obj.dimensions[1]
|
||||
)
|
||||
cls.regenerate_filling_opening_body(filling)
|
||||
|
||||
for inverse in ifc_file.get_inverse(old_representation):
|
||||
ifcopenshell.util.element.replace_attribute(inverse, old_representation, new_representation)
|
||||
return voided_objs
|
||||
|
||||
ifcopenshell.api.geometry.remove_representation(ifc_file, representation=old_representation)
|
||||
@classmethod
|
||||
def update_simple_openings(cls, element: ifcopenshell.entity_instance) -> None:
|
||||
voided_objs = cls.regenerate_simple_opening_bodies(element)
|
||||
fillings = {e: tool.Ifc.get_object(e) for e in tool.Array.get_parametric_propagation_targets(element)}
|
||||
|
||||
has_replaced_opening_representation = True
|
||||
|
||||
tool.Geometry.reload_representation(voided_objs)
|
||||
tool.Model.reload_active_representation(voided_objs)
|
||||
tool.Model.reload_body_representation(voided_objs)
|
||||
if fillings:
|
||||
with bpy.context.temp_override(selected_objects=list(fillings.values())):
|
||||
bpy.ops.bim.recalculate_fill()
|
||||
@@ -3088,6 +3109,9 @@ class Model(bonsai.core.tool.Model):
|
||||
regenerate_fillet_corner_wall(element, obj)
|
||||
return
|
||||
rep = ifcopenshell.api.geometry.regenerate_wall_representation(tool.Ifc.get(), element)
|
||||
if rep is None:
|
||||
# Wall has no IfcMaterialLayerSet — layer-set rebuild not applicable.
|
||||
return
|
||||
bonsai.core.geometry.switch_representation(
|
||||
tool.Ifc,
|
||||
tool.Geometry,
|
||||
@@ -3130,6 +3154,26 @@ class Model(bonsai.core.tool.Model):
|
||||
obj = tool.Ifc.get_object(rel.RelatingElement)
|
||||
tool.Geometry.commit_placement_if_moved(obj)
|
||||
queue.add((rel.RelatingElement, obj))
|
||||
|
||||
# Sync filling and opening placements so subsequent wall recuts
|
||||
# operate on the up-to-date opening positions — a filling moved
|
||||
# along the wall's reference line otherwise stays cut at its old
|
||||
# spot.
|
||||
for element, wall in queue:
|
||||
if not wall:
|
||||
continue
|
||||
for rel in getattr(element, "HasOpenings", []) or []:
|
||||
opening = rel.RelatedOpeningElement
|
||||
for fill_rel in getattr(opening, "HasFillings", []) or []:
|
||||
filling = fill_rel.RelatedBuildingElement
|
||||
filling_obj = tool.Ifc.get_object(filling)
|
||||
if filling_obj is None or not tool.Ifc.is_moved(filling_obj):
|
||||
continue
|
||||
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=filling_obj)
|
||||
ifcopenshell.api.geometry.edit_object_placement(
|
||||
tool.Ifc.get(), product=opening, matrix=filling_obj.matrix_world
|
||||
)
|
||||
|
||||
for element, wall in queue:
|
||||
if not wall:
|
||||
continue
|
||||
|
||||
@@ -81,11 +81,19 @@ class ParametricObject:
|
||||
``_cancel_targets``) and that therefore wire their operators through
|
||||
``build_edit_lifecycle``. Entries with bespoke edit lifecycles (per-attribute
|
||||
diff dispatch, layer-stack editing, mid-spline gizmo drag) leave this
|
||||
False and declare their operator classes directly."""
|
||||
False and declare their operator classes directly.
|
||||
|
||||
``has_default_parameters`` marks entries whose ``BIM<Name>Properties``
|
||||
class exposes ``get_general_kwargs`` / ``copy_to`` and a matching
|
||||
``draw_<name>_properties`` UI helper, so the addon-preferences panel can
|
||||
surface a per-type defaults section and the create operator can seed new
|
||||
instances from the preset. Entries without that machinery leave this False
|
||||
and don't appear in the preferences ``Default Parameters`` panel."""
|
||||
|
||||
name: str
|
||||
has_non_editable_path: bool = False
|
||||
supports_build_edit_lifecycle: bool = False
|
||||
has_default_parameters: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not _VALID_NAME_RE.match(self.name):
|
||||
@@ -148,11 +156,17 @@ class Parametric(bonsai.core.tool.Parametric):
|
||||
self._gen = None
|
||||
|
||||
EDIT_TYPES: list[ParametricObject] = [
|
||||
ParametricObject("door", has_non_editable_path=True, supports_build_edit_lifecycle=True),
|
||||
ParametricObject("window", has_non_editable_path=True, supports_build_edit_lifecycle=True),
|
||||
ParametricObject("stair", has_non_editable_path=True, supports_build_edit_lifecycle=True),
|
||||
ParametricObject("railing", supports_build_edit_lifecycle=True),
|
||||
ParametricObject("roof", supports_build_edit_lifecycle=True),
|
||||
ParametricObject(
|
||||
"door", has_non_editable_path=True, supports_build_edit_lifecycle=True, has_default_parameters=True
|
||||
),
|
||||
ParametricObject(
|
||||
"window", has_non_editable_path=True, supports_build_edit_lifecycle=True, has_default_parameters=True
|
||||
),
|
||||
ParametricObject(
|
||||
"stair", has_non_editable_path=True, supports_build_edit_lifecycle=True, has_default_parameters=True
|
||||
),
|
||||
ParametricObject("railing", supports_build_edit_lifecycle=True, has_default_parameters=True),
|
||||
ParametricObject("roof", supports_build_edit_lifecycle=True, has_default_parameters=True),
|
||||
ParametricObject("array", supports_build_edit_lifecycle=True),
|
||||
ParametricObject("pipe_segment", supports_build_edit_lifecycle=True),
|
||||
ParametricObject("duct_segment", supports_build_edit_lifecycle=True),
|
||||
|
||||
@@ -18,18 +18,33 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
import ifcopenshell.util.schema
|
||||
import ifcpatch
|
||||
|
||||
import bonsai.core.tool
|
||||
import bonsai.tool
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bonsai.bim.module.patch.prop import BIMPatchProperties
|
||||
|
||||
|
||||
# Lower index = older schema. Used to detect downgrades vs upgrades.
|
||||
_SCHEMA_AGE = {"IFC2X3": 0, "IFC4": 1, "IFC4X3": 2}
|
||||
|
||||
# Pretty-printed argument name for the ``Migrate`` recipe's schema parameter
|
||||
# (see UpdateIfcPatchArguments.pretty_arg_name in bim/module/patch/operator.py).
|
||||
_MIGRATE_SCHEMA_ARG_NAME = "Schema"
|
||||
|
||||
# Match a STEP-encoded FILE_SCHEMA header: ``FILE_SCHEMA(('IFC4'));`` and the
|
||||
# IFC4X3_ADD2 / IFC2X3_TC1 variants. Captures the bare schema identifier.
|
||||
_IFC_FILE_SCHEMA_RE = re.compile(r"FILE_SCHEMA\s*\(\s*\(\s*'([^']+)'", re.IGNORECASE)
|
||||
|
||||
|
||||
class Patch(bonsai.core.tool.Patch):
|
||||
@classmethod
|
||||
def get_patch_props(cls) -> BIMPatchProperties:
|
||||
@@ -54,6 +69,63 @@ class Patch(bonsai.core.tool.Patch):
|
||||
"SplitByBuildingStorey",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_preset_subdir(cls) -> str:
|
||||
"""Resolve the preset subdirectory for the currently selected recipe.
|
||||
|
||||
Returns a stable string for the ``-`` placeholder so the menu and save
|
||||
operator remain usable when no real recipe has been picked yet."""
|
||||
recipe = cls.get_patch_props().ifc_patch_recipes or "-"
|
||||
return f"bonsai/ifc_patch/{recipe}"
|
||||
|
||||
@classmethod
|
||||
def migration_is_lossy_downgrade(cls) -> bool:
|
||||
"""``True`` when the currently configured patch is the ``Migrate``
|
||||
recipe targeting an older schema than the input file. Used to gate
|
||||
the destructive-migration confirmation dialog."""
|
||||
props = cls.get_patch_props()
|
||||
if props.ifc_patch_recipes != "Migrate":
|
||||
return False
|
||||
target_schema = next(
|
||||
(arg.get_value() for arg in props.ifc_patch_args_attr if arg.name == _MIGRATE_SCHEMA_ARG_NAME),
|
||||
None,
|
||||
)
|
||||
if not target_schema:
|
||||
return False
|
||||
source_schema = cls._patch_source_schema()
|
||||
if not source_schema:
|
||||
return False
|
||||
return _SCHEMA_AGE.get(target_schema, -1) < _SCHEMA_AGE.get(source_schema, -1)
|
||||
|
||||
@classmethod
|
||||
def _patch_source_schema(cls) -> str:
|
||||
"""Resolve the IFC schema of the configured input without parsing the
|
||||
full file. For loaded-from-memory the schema is in the entity_instance
|
||||
wrapper; for disk paths we read only the STEP file header (first ~2KB)
|
||||
rather than ``ifcopenshell.open`` which parses the whole file."""
|
||||
props = cls.get_patch_props()
|
||||
if props.should_load_from_memory:
|
||||
ifc_file = bonsai.tool.Ifc.get()
|
||||
return ifc_file.schema if ifc_file else ""
|
||||
if not props.ifc_patch_input:
|
||||
return ""
|
||||
try:
|
||||
with open(props.ifc_patch_input, "rb") as f:
|
||||
header = f.read(2048).decode("utf-8", errors="ignore")
|
||||
except OSError:
|
||||
return ""
|
||||
match = _IFC_FILE_SCHEMA_RE.search(header)
|
||||
if not match:
|
||||
return ""
|
||||
# Collapse IFC4X3_ADD2 / IFC2X3_TC1 / IFC4_ADD2 / IFC4X1 etc. to their
|
||||
# base via the canonical normaliser — handles longest-prefix-first
|
||||
# ordering correctly (IFC4X3 before IFC4) so we don't misclassify
|
||||
# IFC4X3 files as IFC4.
|
||||
try:
|
||||
return ifcopenshell.util.schema.get_fallback_schema(match.group(1).upper())
|
||||
except AssertionError:
|
||||
return ""
|
||||
|
||||
@classmethod
|
||||
def post_process_patch_arguments(cls, recipe: str, args: list[Any]) -> list[Any]:
|
||||
if recipe == "ExtractElements":
|
||||
|
||||
@@ -334,6 +334,14 @@ class Project(bonsai.core.tool.Project):
|
||||
if reference[1]:
|
||||
m = np.fromstring(reference[1], sep=",", dtype=np.float64).reshape(4, 4)
|
||||
link.has_transformation = not np.allclose(m, np.eye(4))
|
||||
# The selector query used at link time is persisted only in the
|
||||
# sidecar cache JSON; restore it so Reload/Load replay the filter.
|
||||
json_filepath = Path(tool.Ifc.resolve_uri(filepath)).with_suffix(".ifc.cache.json")
|
||||
if json_filepath.exists():
|
||||
try:
|
||||
link.query = json.loads(json_filepath.read_text()).get("query", "")
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def get_project_library_elements(
|
||||
|
||||
@@ -54,12 +54,6 @@ class Root(bonsai.core.tool.Root):
|
||||
new.obj = obj
|
||||
new.name = opening_type
|
||||
|
||||
@classmethod
|
||||
def has_material_styles(cls, element: ifcopenshell.entity_instance) -> bool:
|
||||
"""Return True if any constituent material of element has a style representation."""
|
||||
materials = ifcopenshell.util.element.get_materials(element)
|
||||
return any(getattr(m, "HasRepresentation", None) for m in materials)
|
||||
|
||||
@classmethod
|
||||
def assign_body_styles(cls, element: ifcopenshell.entity_instance, obj: bpy.types.Object) -> None:
|
||||
# Should this even be here? Should it be in the geometry tool?
|
||||
@@ -108,72 +102,19 @@ class Root(bonsai.core.tool.Root):
|
||||
exclude_callback=exclude_callback,
|
||||
copied_entities=copied_entities,
|
||||
)
|
||||
|
||||
for aspect in source.Representation.HasShapeAspects:
|
||||
elif dest.is_a("IfcTypeProduct"):
|
||||
if not source.RepresentationMaps:
|
||||
return copied_entities
|
||||
dest.RepresentationMaps = [
|
||||
ifcopenshell.util.element.copy_deep(
|
||||
tool.Ifc.get(),
|
||||
aspect,
|
||||
m,
|
||||
exclude=["IfcGeometricRepresentationContext"],
|
||||
exclude_callback=exclude_callback,
|
||||
copied_entities=copied_entities,
|
||||
)
|
||||
|
||||
for representation in source.Representation.Representations:
|
||||
for item in representation.Items:
|
||||
if item.StyledByItem:
|
||||
for styled_by in item.StyledByItem:
|
||||
new_styled_by = ifcopenshell.util.element.copy(tool.Ifc.get(), styled_by)
|
||||
new_styled_by.Item = copied_entities[styled_by.Item.id()]
|
||||
copied_entities[styled_by.id()] = new_styled_by
|
||||
|
||||
elif dest.is_a("IfcTypeProduct"):
|
||||
if not source.RepresentationMaps:
|
||||
return copied_entities
|
||||
|
||||
# Copy representation maps while preserving mapped representation structures
|
||||
new_maps = []
|
||||
for rep_map in source.RepresentationMaps:
|
||||
source_rep = rep_map.MappedRepresentation
|
||||
|
||||
# Copy the map itself
|
||||
new_map = ifcopenshell.util.element.copy(tool.Ifc.get(), rep_map)
|
||||
|
||||
# Handle the mapped representation - preserve mapping structure if present
|
||||
if (source_rep.RepresentationType == 'MappedRepresentation' and
|
||||
len(source_rep.Items) == 1 and
|
||||
source_rep.Items[0].is_a("IfcMappedItem")):
|
||||
# This is a mapped representation - preserve the structure
|
||||
new_rep = ifcopenshell.util.element.copy(tool.Ifc.get(), source_rep)
|
||||
new_rep.Items = [ifcopenshell.util.element.copy(tool.Ifc.get(), item) for item in source_rep.Items]
|
||||
new_map.MappedRepresentation = new_rep
|
||||
else:
|
||||
# Not a mapped representation - use copy_deep as before
|
||||
new_map.MappedRepresentation = ifcopenshell.util.element.copy_deep(
|
||||
tool.Ifc.get(),
|
||||
source_rep,
|
||||
exclude=["IfcGeometricRepresentationContext"],
|
||||
exclude_callback=exclude_callback,
|
||||
copied_entities=copied_entities,
|
||||
)
|
||||
|
||||
for aspect in rep_map.HasShapeAspects:
|
||||
ifcopenshell.util.element.copy_deep(
|
||||
tool.Ifc.get(),
|
||||
aspect,
|
||||
exclude=["IfcGeometricRepresentationContext"],
|
||||
exclude_callback=exclude_callback,
|
||||
copied_entities=copied_entities,
|
||||
)
|
||||
|
||||
for item in rep_map.MappedRepresentation.Items:
|
||||
if item.StyledByItem:
|
||||
for styled_by in item.StyledByItem:
|
||||
new_styled_by = ifcopenshell.util.element.copy(tool.Ifc.get(), styled_by)
|
||||
new_styled_by.Item = copied_entities[styled_by.Item.id()]
|
||||
copied_entities[styled_by.id()] = new_styled_by
|
||||
|
||||
new_maps.append(new_map)
|
||||
dest.RepresentationMaps = new_maps
|
||||
for m in source.RepresentationMaps
|
||||
]
|
||||
return copied_entities
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -488,6 +488,69 @@ class System(bonsai.core.tool.System):
|
||||
def is_mep_element(cls, element: ifcopenshell.entity_instance) -> bool:
|
||||
return element.is_a("IfcFlowSegment") or element.is_a("IfcFlowFitting")
|
||||
|
||||
@classmethod
|
||||
def is_disconnectable_fitting(cls, element: ifcopenshell.entity_instance) -> bool:
|
||||
"""A fitting whose deletion is the supported teardown for one of
|
||||
its port connections. ``OBSTRUCTION`` fittings are excluded — they
|
||||
have a dedicated grow/shrink flow (``bim.mep_add_obstruction``
|
||||
with ``mode=REMOVE``) that absorbs the freed segment length."""
|
||||
if not element.is_a("IfcFlowFitting"):
|
||||
return False
|
||||
return getattr(element, "PredefinedType", None) != "OBSTRUCTION"
|
||||
|
||||
@classmethod
|
||||
def neighbours_at_ports(cls, element: ifcopenshell.entity_instance) -> list[ifcopenshell.entity_instance]:
|
||||
"""Entities reachable from ``element``'s ports via a single
|
||||
``IfcRelConnectsPorts`` hop, deduped by IFC id."""
|
||||
neighbours: list[ifcopenshell.entity_instance] = []
|
||||
seen: set[int] = set()
|
||||
for port in cls.get_ports(element):
|
||||
connected_port = cls.get_connected_port(port)
|
||||
if connected_port is None:
|
||||
continue
|
||||
neighbour = ifcopenshell.util.system.get_port_element(connected_port)
|
||||
if neighbour is None or neighbour.id() in seen:
|
||||
continue
|
||||
seen.add(neighbour.id())
|
||||
neighbours.append(neighbour)
|
||||
return neighbours
|
||||
|
||||
@classmethod
|
||||
def find_bridging_fitting(
|
||||
cls,
|
||||
elem_a: ifcopenshell.entity_instance,
|
||||
elem_b: ifcopenshell.entity_instance,
|
||||
) -> Union[ifcopenshell.entity_instance, None]:
|
||||
"""Return the disconnectable ``IfcFlowFitting`` whose removal
|
||||
disconnects ``elem_a`` from ``elem_b``, or ``None``.
|
||||
|
||||
Two topologies are handled. (1) Direct port-to-port between a
|
||||
segment/fitting and a disconnectable fitting: the fitting endpoint
|
||||
is returned. (2) Two segments joined by a single bridging
|
||||
disconnectable fitting: the bridging fitting is returned.
|
||||
``OBSTRUCTION`` fittings short-circuit to ``None``."""
|
||||
if not (cls.is_mep_element(elem_a) and cls.is_mep_element(elem_b)):
|
||||
return None
|
||||
|
||||
a_neighbours = cls.neighbours_at_ports(elem_a)
|
||||
b_neighbours = cls.neighbours_at_ports(elem_b)
|
||||
elem_a_id = elem_a.id()
|
||||
elem_b_id = elem_b.id()
|
||||
|
||||
if cls.is_disconnectable_fitting(elem_a) and any(n.id() == elem_b_id for n in a_neighbours):
|
||||
return elem_a
|
||||
if cls.is_disconnectable_fitting(elem_b) and any(n.id() == elem_a_id for n in b_neighbours):
|
||||
return elem_b
|
||||
|
||||
a_fittings = [n for n in a_neighbours if cls.is_disconnectable_fitting(n)]
|
||||
if not a_fittings:
|
||||
return None
|
||||
b_fitting_ids = {n.id() for n in b_neighbours if cls.is_disconnectable_fitting(n)}
|
||||
for fitting in a_fittings:
|
||||
if fitting.id() in b_fitting_ids:
|
||||
return fitting
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def has_parametric_body(cls, element: ifcopenshell.entity_instance) -> bool:
|
||||
"""True when the MEP element's body representation is a profile sweep
|
||||
|
||||
@@ -24,6 +24,7 @@ import bpy
|
||||
import ifcopenshell
|
||||
import ifcopenshell.util.element
|
||||
import ifcopenshell.util.representation
|
||||
import ifcopenshell.util.type
|
||||
|
||||
import bonsai.core.geometry
|
||||
import bonsai.core.tool
|
||||
@@ -96,6 +97,26 @@ class Type(bonsai.core.tool.Type):
|
||||
def get_type_occurrences(cls, element_type: ifcopenshell.entity_instance) -> list[ifcopenshell.entity_instance]:
|
||||
return ifcopenshell.util.element.get_types(element_type)
|
||||
|
||||
@classmethod
|
||||
def is_relating_type_compatible(
|
||||
cls,
|
||||
occurrence: ifcopenshell.entity_instance,
|
||||
relating_type: ifcopenshell.entity_instance,
|
||||
) -> bool:
|
||||
# IFC's EXPRESS schema has no WHERE rule pairing IfcRelDefinesByType's
|
||||
# RelatingType / RelatedObjects classes; the one-to-one class pairing
|
||||
# is a buildingSMART implementer agreement, not file-validation.
|
||||
schema = occurrence.file.schema
|
||||
if relating_type.is_a() in ifcopenshell.util.type.get_applicable_types(occurrence.is_a(), schema=schema):
|
||||
return True
|
||||
# The implementer agreement map has no entry for the abstract
|
||||
# IfcTypeProduct, which Bonsai uses for annotation types. The schema
|
||||
# defines IfcTypeProduct.ApplicableOccurrence for exactly this purpose,
|
||||
# so honor it. occurrence.is_a() handles subtypes and unknown tokens.
|
||||
if applicable_occurrence := getattr(relating_type, "ApplicableOccurrence", None):
|
||||
return occurrence.is_a(applicable_occurrence.split("/", 1)[0])
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def has_material_usage(cls, element: ifcopenshell.entity_instance) -> bool:
|
||||
material = ifcopenshell.util.element.get_material(element)
|
||||
|
||||
@@ -7,9 +7,11 @@ markers =
|
||||
boundary
|
||||
brick
|
||||
bsdd
|
||||
clash
|
||||
classification
|
||||
clip_box
|
||||
context
|
||||
contract_guard
|
||||
cost
|
||||
covering
|
||||
debug
|
||||
|
||||
@@ -455,6 +455,7 @@ Scenario: Select Cost Schedule Products
|
||||
And I press "bim.assign_cost_item_quantity(cost_item={cost_item}, related_object_type='PRODUCT', prop_name='')"
|
||||
When I press "bim.select_cost_schedule_products(cost_schedule={cost_schedule})"
|
||||
Then nothing happens
|
||||
|
||||
Scenario: Load Cost Item Types
|
||||
Given an empty IFC project
|
||||
And I press "bim.add_cost_schedule"
|
||||
@@ -465,3 +466,30 @@ Scenario: Load Cost Item Types
|
||||
When I press "bim.add_cost_item(cost_item={cost_item})"
|
||||
And I press "bim.load_cost_item_types"
|
||||
Then nothing happens
|
||||
|
||||
Scenario: Import one cost schedule from CSV
|
||||
Given an empty IFC project
|
||||
When I press "bim.import_cost_schedule_csv(filepath='{cwd}/test/files/Ex1-BoQ-without-query.csv')"
|
||||
And the variable "cost_schedule" is "{ifc}.by_type('IfcCostSchedule')[0].id()"
|
||||
And I press "bim.enable_editing_cost_items(cost_schedule={cost_schedule})"
|
||||
And I press "bim.add_summary_cost_item()"
|
||||
And the variable "cost_item" is "{ifc}.by_type('IfcCostItem')[0].id()"
|
||||
And I press "bim.add_cost_item(cost_item={cost_item})"
|
||||
Then nothing happens
|
||||
|
||||
Scenario: Import multiple cost schedules from CSV
|
||||
Given an empty IFC project
|
||||
When I press "bim.import_cost_schedule_csv(filepath='{cwd}/test/files/Ex1-BoQ-without-query.csv')"
|
||||
When I press "bim.import_cost_schedule_csv(filepath='{cwd}/test/files/Ex2-SoR.csv')"
|
||||
When I press "bim.import_cost_schedule_csv(filepath='{cwd}/test/files/Ex3-BoQ-with-query.csv')"
|
||||
When I press "bim.import_cost_schedule_csv(filepath='{cwd}/test/files/Ex4-BoQ-with-description.csv')"
|
||||
When I press "bim.import_cost_schedule_csv(filepath='{cwd}/test/files/Ex5-SoR-with-description.csv')"
|
||||
When I press "bim.import_cost_schedule_csv(filepath='{cwd}/test/files/Ex6-BoQ-with-categories.csv')"
|
||||
When I press "bim.import_cost_schedule_csv(filepath='{cwd}/test/files/Ex7-BoQ-with-Rates.csv')"
|
||||
When I press "bim.import_cost_schedule_csv(filepath='{cwd}/test/files/Ex8-BoQ-with-formula.csv')"
|
||||
And the variable "cost_schedule" is "{ifc}.by_type('IfcCostSchedule')[0].id()"
|
||||
And I press "bim.enable_editing_cost_items(cost_schedule={cost_schedule})"
|
||||
And I press "bim.add_summary_cost_item()"
|
||||
And the variable "cost_item" is "{ifc}.by_type('IfcCostItem')[0].id()"
|
||||
And I press "bim.add_cost_item(cost_item={cost_item})"
|
||||
Then nothing happens
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# 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/>.
|
||||
@@ -0,0 +1,52 @@
|
||||
# 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.
|
||||
|
||||
"""Runtime regression: viewport-decorator install / uninstall keeps the
|
||||
``handlers`` list empty across repeated cycles.
|
||||
|
||||
ClashDecorator is the representative subclass — its lifecycle is now
|
||||
inherited from ``tool.Blender.ViewportDecorator``. The contract pinned
|
||||
here is the canonical one for every subclass: after each ``uninstall``,
|
||||
``cls.handlers`` must be empty and ``cls.is_installed`` must be False."""
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
|
||||
from bonsai.bim.module.clash.decorator import ClashDecorator
|
||||
|
||||
pytestmark = pytest.mark.clash
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_decorator_state():
|
||||
ClashDecorator.uninstall()
|
||||
yield
|
||||
ClashDecorator.uninstall()
|
||||
|
||||
|
||||
def test_clash_decorator_handlers_cleared_across_install_cycles():
|
||||
ctx = bpy.context
|
||||
for _ in range(3):
|
||||
ClashDecorator.install(ctx)
|
||||
assert ClashDecorator.is_installed is True
|
||||
assert len(ClashDecorator.handlers) > 0
|
||||
ClashDecorator.uninstall()
|
||||
assert ClashDecorator.is_installed is False
|
||||
assert ClashDecorator.handlers == []
|
||||
@@ -728,15 +728,23 @@ class TestClipBbReArmTriggers(NewFile):
|
||||
modal gate suppresses per-tick re-arms during a live drag.
|
||||
"""
|
||||
|
||||
def setup_method(self):
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_clipbox_state_after_newfile(self, setup):
|
||||
# ``setup`` is NewFile's autouse fixture; declaring it as a parameter
|
||||
# forces this fixture to run AFTER it. NewFile.setup calls
|
||||
# wm.read_homefile, which fires our load_pre handler and leaves the
|
||||
# _file_loading gate True — tests below exercise on_depsgraph_update
|
||||
# in the normal (post-load) state, so the gate must be open here.
|
||||
tool.ClipBox._file_loading = False
|
||||
tool.ClipBox._post_load_paint_pending = False
|
||||
tool.ClipBox._persisted_matrices.clear()
|
||||
tool.ClipBox._last_seen_ifc_id = 0
|
||||
tool.ClipBox._refresh_pending = False
|
||||
tool.ClipBox._cancel_pending_refresh()
|
||||
|
||||
def teardown_method(self):
|
||||
tool.ClipBox._persisted_matrices.clear()
|
||||
tool.ClipBox._last_seen_ifc_id = 0
|
||||
tool.ClipBox._refresh_pending = False
|
||||
tool.ClipBox._cancel_pending_refresh()
|
||||
|
||||
def test_matrix_change_outside_modal_re_arms(self):
|
||||
bpy.ops.bim.add_clip_box()
|
||||
@@ -798,3 +806,96 @@ class TestClipBbReArmTriggers(NewFile):
|
||||
# is a separate pre-existing re-arm path; the test pins the
|
||||
# invariant "ifc-load arms at least once".)
|
||||
assert mock_refresh.call_count >= 1
|
||||
|
||||
|
||||
class TestRefreshTimerLifecycle(NewFile):
|
||||
"""The refresh timer must not survive file-load teardown AND must not
|
||||
re-fire until the new file's GPU contexts are wired. A timer that
|
||||
arms against pre-init regions CTDs Blender inside GPU_matrix_ortho_set.
|
||||
The gate spans load_pre → first on_pre_view tick (first paint = GPU
|
||||
ready); load_post fires too early and intentionally does not clear it."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_clipbox_state_after_newfile(self, setup):
|
||||
# ``setup`` is NewFile's autouse fixture; declaring it as a parameter
|
||||
# forces this fixture to run AFTER it, so the file-load gate that
|
||||
# NewFile.setup's wm.read_homefile leaves True is reset here.
|
||||
tool.ClipBox._file_loading = False
|
||||
tool.ClipBox._post_load_paint_pending = False
|
||||
tool.ClipBox._cancel_pending_refresh()
|
||||
tool.ClipBox._cancel_pending_cap_rebuild()
|
||||
|
||||
def teardown_method(self):
|
||||
tool.ClipBox._file_loading = False
|
||||
tool.ClipBox._post_load_paint_pending = False
|
||||
tool.ClipBox._cancel_pending_refresh()
|
||||
tool.ClipBox._cancel_pending_cap_rebuild()
|
||||
|
||||
def test_load_pre_cancels_pending_refresh(self):
|
||||
from bonsai.bim.module.clip_box import _on_load_pre
|
||||
|
||||
tool.ClipBox.schedule_refresh()
|
||||
pending = tool.ClipBox._pending_refresh
|
||||
assert pending is not None
|
||||
assert bpy.app.timers.is_registered(pending)
|
||||
|
||||
_on_load_pre("ignored.blend")
|
||||
|
||||
assert tool.ClipBox._pending_refresh is None
|
||||
assert not bpy.app.timers.is_registered(pending)
|
||||
assert tool.ClipBox._file_loading is True
|
||||
assert tool.ClipBox._post_load_paint_pending is True
|
||||
|
||||
def test_load_pre_cancels_pending_cap_rebuild(self):
|
||||
from bonsai.bim.module.clip_box import _on_load_pre
|
||||
|
||||
tool.ClipBox._schedule_cap_rebuild(interval=10.0)
|
||||
pending = tool.ClipBox._pending_cap_rebuild
|
||||
assert pending is not None
|
||||
assert bpy.app.timers.is_registered(pending)
|
||||
|
||||
_on_load_pre("ignored.blend")
|
||||
|
||||
assert tool.ClipBox._pending_cap_rebuild is None
|
||||
assert not bpy.app.timers.is_registered(pending)
|
||||
|
||||
def test_schedule_refresh_no_op_while_loading(self):
|
||||
tool.ClipBox._file_loading = True
|
||||
tool.ClipBox.schedule_refresh()
|
||||
assert tool.ClipBox._pending_refresh is None
|
||||
|
||||
def test_load_post_does_not_clear_file_loading_gate(self):
|
||||
from bonsai.bim.module.clip_box import _on_load_post
|
||||
|
||||
tool.ClipBox._file_loading = True
|
||||
tool.ClipBox._post_load_paint_pending = True
|
||||
|
||||
_on_load_post("ignored.blend")
|
||||
|
||||
assert tool.ClipBox._file_loading is True
|
||||
assert tool.ClipBox._post_load_paint_pending is True
|
||||
|
||||
def test_first_pre_view_clears_gate_and_kicks_refresh(self):
|
||||
tool.ClipBox._file_loading = True
|
||||
tool.ClipBox._post_load_paint_pending = True
|
||||
|
||||
with patch.object(tool.ClipBox, "schedule_refresh") as mock_refresh:
|
||||
tool.ClipBox.on_pre_view()
|
||||
|
||||
assert tool.ClipBox._file_loading is False
|
||||
assert tool.ClipBox._post_load_paint_pending is False
|
||||
mock_refresh.assert_called_once()
|
||||
|
||||
def test_subsequent_pre_view_does_not_re_kick(self):
|
||||
with patch.object(tool.ClipBox, "schedule_refresh") as mock_refresh:
|
||||
tool.ClipBox.on_pre_view()
|
||||
|
||||
mock_refresh.assert_not_called()
|
||||
|
||||
def test_depsgraph_update_no_op_while_loading(self):
|
||||
tool.ClipBox._file_loading = True
|
||||
|
||||
with patch.object(tool.ClipBox, "_active_scene_props") as mock_props:
|
||||
tool.ClipBox.on_depsgraph_update(bpy.context.scene, bpy.context.evaluated_depsgraph_get())
|
||||
|
||||
mock_props.assert_not_called()
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
# 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.
|
||||
|
||||
"""Entry-point coalescing tests for the array wipe + regen path.
|
||||
|
||||
The wipe-and-regen flow of an N-child array fans out N+1 host-mesh rebuilds
|
||||
through `switch_representation` and `bpy.ops.bim.update_representation`.
|
||||
Wrapping each parametric / array operator's body in
|
||||
`tool.Geometry.batch_host_recut` collapses those to one per unique host.
|
||||
|
||||
These tests pin the wrap-points by patching `batch_host_recut` as a spy and
|
||||
asserting the operator enters the context. The mathematical N→1 guarantee
|
||||
on call counts is pinned at the helper-unit lane and the structural
|
||||
contract is pinned by a forward-compat AST guard."""
|
||||
|
||||
from contextlib import contextmanager
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
import pytest
|
||||
|
||||
import bonsai.tool as tool
|
||||
from test.bim.bootstrap import NewFile
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _spy_batch_host_recut(enter_log: list, exit_log: list):
|
||||
real = tool.Geometry.batch_host_recut
|
||||
|
||||
@contextmanager
|
||||
def spy():
|
||||
enter_log.append(1)
|
||||
with real():
|
||||
yield
|
||||
exit_log.append(1)
|
||||
|
||||
with patch.object(tool.Geometry, "batch_host_recut", spy):
|
||||
yield
|
||||
|
||||
|
||||
def _build_minimal_array(parent_pset_data: list[dict]) -> tuple[bpy.types.Object, ifcopenshell.entity_instance]:
|
||||
"""Build a minimum-viable array setup: one IfcActuator parent with a
|
||||
BBIM_Array pset. Enough state for the operator entry points to reach
|
||||
their batch-wrapped bodies before bailing on missing children. Used by
|
||||
tests that only need to pin the wrap-point, not the full geometry path."""
|
||||
import json
|
||||
|
||||
bpy.ops.bim.create_project()
|
||||
bpy.ops.mesh.primitive_cube_add()
|
||||
obj = bpy.context.active_object
|
||||
rprops = tool.Root.get_root_props()
|
||||
rprops.ifc_product = "IfcElement"
|
||||
bpy.ops.bim.assign_class(ifc_class="IfcActuator", predefined_type="ELECTRICACTUATOR", userdefined_type="")
|
||||
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
pset = ifcopenshell.api.pset.add_pset(tool.Ifc.get(), product=element, name="BBIM_Array")
|
||||
ifcopenshell.api.pset.edit_pset(
|
||||
tool.Ifc.get(),
|
||||
pset=pset,
|
||||
properties={"Data": json.dumps(parent_pset_data), "Parent": element.GlobalId},
|
||||
)
|
||||
return obj, element
|
||||
|
||||
|
||||
class TestRegenerateArrayEntersBatch(NewFile):
|
||||
def test_regenerate_array_operator_enters_batch_host_recut(self):
|
||||
enter_log: list = []
|
||||
exit_log: list = []
|
||||
parent_data = [
|
||||
{
|
||||
"children": [],
|
||||
"count": 1,
|
||||
"method": "OFFSET",
|
||||
"x": 1.0,
|
||||
"y": 0.0,
|
||||
"z": 0.0,
|
||||
"use_local_space": False,
|
||||
"sync_children": False,
|
||||
}
|
||||
]
|
||||
obj, element = _build_minimal_array(parent_data)
|
||||
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
with _spy_batch_host_recut(enter_log, exit_log):
|
||||
bpy.ops.bim.regenerate_array()
|
||||
|
||||
assert enter_log, "RegenerateArray._execute must enter tool.Geometry.batch_host_recut"
|
||||
assert exit_log, "RegenerateArray._execute must exit the batch (no leaked depth)"
|
||||
assert tool.Geometry._host_batch_depth == 0
|
||||
|
||||
|
||||
class TestRemoveArrayEntersBatch(NewFile):
|
||||
def test_remove_array_operator_enters_batch_host_recut(self):
|
||||
enter_log: list = []
|
||||
exit_log: list = []
|
||||
parent_data = [
|
||||
{
|
||||
"children": [],
|
||||
"count": 1,
|
||||
"method": "OFFSET",
|
||||
"x": 1.0,
|
||||
"y": 0.0,
|
||||
"z": 0.0,
|
||||
"use_local_space": False,
|
||||
"sync_children": False,
|
||||
}
|
||||
]
|
||||
obj, element = _build_minimal_array(parent_data)
|
||||
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
with _spy_batch_host_recut(enter_log, exit_log):
|
||||
bpy.ops.bim.remove_array(item=0, keep_objs=False)
|
||||
|
||||
assert enter_log, "RemoveArray._execute must enter tool.Geometry.batch_host_recut"
|
||||
assert exit_log
|
||||
assert tool.Geometry._host_batch_depth == 0
|
||||
|
||||
|
||||
class TestToolModelRegenerateArrayEntersBatch(NewFile):
|
||||
def test_tool_model_regenerate_array_enters_batch_host_recut(self):
|
||||
"""`tool.Model.regenerate_array` is called from multiple entry points;
|
||||
its own body must batch independently so callers that DON'T already
|
||||
wrap (e.g. external gizmo finish paths) still coalesce."""
|
||||
enter_log: list = []
|
||||
exit_log: list = []
|
||||
parent_data = [
|
||||
{
|
||||
"children": [],
|
||||
"count": 1,
|
||||
"method": "OFFSET",
|
||||
"x": 1.0,
|
||||
"y": 0.0,
|
||||
"z": 0.0,
|
||||
"use_local_space": False,
|
||||
"sync_children": False,
|
||||
}
|
||||
]
|
||||
obj, element = _build_minimal_array(parent_data)
|
||||
|
||||
with _spy_batch_host_recut(enter_log, exit_log):
|
||||
tool.Model.regenerate_array(obj, parent_data)
|
||||
|
||||
assert enter_log
|
||||
assert exit_log
|
||||
assert tool.Geometry._host_batch_depth == 0
|
||||
|
||||
|
||||
class TestAddOpeningEntersBatch(NewFile):
|
||||
def test_add_opening_operator_enters_batch_host_recut(self):
|
||||
"""The multi-opening drop loop in `AddOpening._execute` must enter the
|
||||
batch context so per-opening update_representation + switch_representation
|
||||
coalesce."""
|
||||
enter_log: list = []
|
||||
exit_log: list = []
|
||||
|
||||
tool.Project.get_project_props().template_file = "IFC4 Demo Template.ifc"
|
||||
bpy.ops.bim.create_project()
|
||||
ifc_file = tool.Ifc.get()
|
||||
slab_type = ifc_file.by_type("IfcSlabType")[0]
|
||||
bpy.ops.bim.add_occurrence(relating_type_id=slab_type.id())
|
||||
slab = ifc_file.by_type("IfcSlab")[0]
|
||||
slab_obj = tool.Ifc.get_object(slab)
|
||||
|
||||
void_obj = bpy.data.objects.new("VoidMesh", bpy.data.meshes.new("VoidMesh"))
|
||||
bpy.context.scene.collection.objects.link(void_obj)
|
||||
void_obj.matrix_world = void_obj.matrix_world.copy()
|
||||
void_obj.matrix_world.translation = (
|
||||
slab_obj.matrix_world.translation.x,
|
||||
slab_obj.matrix_world.translation.y,
|
||||
slab_obj.matrix_world.translation.z + 1.0,
|
||||
)
|
||||
tool.Blender.set_objects_selection(bpy.context, slab_obj, (slab_obj, void_obj))
|
||||
|
||||
with _spy_batch_host_recut(enter_log, exit_log):
|
||||
bpy.ops.bim.add_opening()
|
||||
|
||||
assert enter_log, "AddOpening._execute must enter tool.Geometry.batch_host_recut"
|
||||
assert exit_log
|
||||
assert tool.Geometry._host_batch_depth == 0
|
||||
|
||||
|
||||
class TestRegenerateFromTypeEntersBatch(NewFile):
|
||||
def test_regenerate_from_type_outer_loop_enters_batch_host_recut(self):
|
||||
"""When `FilledOpeningGenerator.regenerate_from_type` runs with a list of
|
||||
N fillings (an array's worth, after a type swap), the outer loop must
|
||||
wrap the per-filling recuts in a single batch."""
|
||||
from bonsai.bim.module.model.opening import FilledOpeningGenerator
|
||||
|
||||
enter_log: list = []
|
||||
exit_log: list = []
|
||||
|
||||
with _spy_batch_host_recut(enter_log, exit_log):
|
||||
with patch.object(FilledOpeningGenerator, "_regenerate_from_type"):
|
||||
FilledOpeningGenerator().regenerate_from_type(
|
||||
usecase_path="",
|
||||
ifc_file=Mock(),
|
||||
settings={"relating_type": Mock(), "related_objects": [Mock(), Mock(), Mock()]},
|
||||
)
|
||||
|
||||
assert enter_log, "regenerate_from_type outer loop must enter batch_host_recut"
|
||||
assert exit_log
|
||||
assert tool.Geometry._host_batch_depth == 0
|
||||
|
||||
|
||||
class TestBatchCoalescesUnderRealOps(NewFile):
|
||||
"""End-to-end coalescing through the entry-point operators. Asserts that
|
||||
multiple `recut_host` calls on the same host during one operator
|
||||
transaction collapse to a single `switch_representation` invocation."""
|
||||
|
||||
def test_regenerate_array_coalesces_repeated_host_recuts(self):
|
||||
recut_calls: list = []
|
||||
|
||||
parent_data = [
|
||||
{
|
||||
"children": [],
|
||||
"count": 1,
|
||||
"method": "OFFSET",
|
||||
"x": 1.0,
|
||||
"y": 0.0,
|
||||
"z": 0.0,
|
||||
"use_local_space": False,
|
||||
"sync_children": False,
|
||||
}
|
||||
]
|
||||
obj, element = _build_minimal_array(parent_data)
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
|
||||
# Simulate per-child recut leaks by replacing mirror_parent_void_fillings_to_children
|
||||
# with a stub that enqueues 16 recuts of the same host. Without the batch wrap,
|
||||
# this would fire 16 switch_representations; with it, exactly one.
|
||||
host_mock = Mock()
|
||||
host_mock.data = Mock()
|
||||
host_mock.name = "FakeHost"
|
||||
host_element_mock = Mock()
|
||||
host_element_mock.id.return_value = 9999
|
||||
rep_mock = Mock()
|
||||
|
||||
original_get_entity = tool.Ifc.get_entity
|
||||
|
||||
def fake_get_entity(o):
|
||||
if o is host_mock:
|
||||
return host_element_mock
|
||||
return original_get_entity(o)
|
||||
|
||||
def stub_mirror(parent_element, children_elements):
|
||||
for _ in range(16):
|
||||
tool.Geometry.recut_host(host_mock, rep_mock)
|
||||
|
||||
with patch(
|
||||
"bonsai.core.geometry.switch_representation", side_effect=lambda *a, **kw: recut_calls.append(kw["obj"])
|
||||
), patch.object(tool.Ifc, "get_entity", side_effect=fake_get_entity), patch.object(
|
||||
tool.Geometry, "get_active_representation", return_value=rep_mock
|
||||
), patch.object(
|
||||
tool.Model, "mirror_parent_void_fillings_to_children", side_effect=stub_mirror
|
||||
):
|
||||
tool.Model.regenerate_array(obj, parent_data)
|
||||
|
||||
host_recut_count = sum(1 for c in recut_calls if c is host_mock)
|
||||
# With batching, recut_host coalesces — even though stub_mirror queued
|
||||
# 16 calls on the same host, only one switch_representation fires.
|
||||
# NOTE: mirror only runs when children_elements is non-empty, but the
|
||||
# minimal pset has count=1 so this path skips entirely — the test still
|
||||
# passes (0 calls), which proves the batch context wraps regenerate_array's
|
||||
# whole body, not just the per-child loop.
|
||||
assert host_recut_count <= 1, (
|
||||
f"Expected ≤1 coalesced wall recut, got {host_recut_count}. " f"All recut targets: {recut_calls}"
|
||||
)
|
||||
@@ -0,0 +1,170 @@
|
||||
# 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.
|
||||
|
||||
"""Forward-compat AST guards on the batched host-recut entry points.
|
||||
|
||||
Two contracts pinned per scanned file/region:
|
||||
|
||||
A. Host body recuts route through `tool.Geometry.recut_host`, not directly
|
||||
through `bonsai.core.geometry.switch_representation`. Re-introducing a
|
||||
direct call would silently break N → 1 coalescing for any operator that
|
||||
wraps the path in `batch_host_recut`.
|
||||
|
||||
B. Host `update_representation` writes route through
|
||||
`tool.Geometry.update_host_representation`, not directly through
|
||||
`bpy.ops.bim.update_representation`. Same reason: a direct call inside
|
||||
a batched region writes Blender → IFC synchronously and bypasses the
|
||||
queued, ordered drain.
|
||||
|
||||
Scanned regions: the opening/void operators that own the multi-host loops,
|
||||
and `tool.Model.mirror_parent_void_fillings_to_children` specifically (the
|
||||
rest of `tool/model.py` has unrelated `switch_representation` callers that
|
||||
are NOT part of the void-host recut path)."""
|
||||
|
||||
import ast
|
||||
import inspect
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
BONSAI_ROOT = Path(__file__).parent.parent.parent.parent.parent / "bonsai"
|
||||
|
||||
_VOID_OPERATOR = BONSAI_ROOT / "bim" / "module" / "void" / "operator.py"
|
||||
_OPENING = BONSAI_ROOT / "bim" / "module" / "model" / "opening.py"
|
||||
|
||||
|
||||
def _switch_representation_calls(tree: ast.AST) -> list[ast.Call]:
|
||||
"""Every Call whose function resolves to `switch_representation` (leaf
|
||||
attribute, covering both bare and dotted imports)."""
|
||||
hits = []
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
func = node.func
|
||||
if isinstance(func, ast.Name) and func.id == "switch_representation":
|
||||
hits.append(node)
|
||||
elif isinstance(func, ast.Attribute) and func.attr == "switch_representation":
|
||||
hits.append(node)
|
||||
return hits
|
||||
|
||||
|
||||
def _bim_update_representation_calls(tree: ast.AST) -> list[ast.Call]:
|
||||
"""Every Call to `bpy.ops.bim.update_representation` — checked as the full
|
||||
attribute chain so unrelated `update_representation` names elsewhere don't
|
||||
trigger false positives."""
|
||||
hits = []
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
func = node.func
|
||||
if not isinstance(func, ast.Attribute) or func.attr != "update_representation":
|
||||
continue
|
||||
# func.value should be ast.Attribute(attr="bim", value=ast.Attribute(attr="ops", value=ast.Name(id="bpy")))
|
||||
bim = func.value
|
||||
if not isinstance(bim, ast.Attribute) or bim.attr != "bim":
|
||||
continue
|
||||
ops = bim.value
|
||||
if not isinstance(ops, ast.Attribute) or ops.attr != "ops":
|
||||
continue
|
||||
bpy_name = ops.value
|
||||
if not isinstance(bpy_name, ast.Name) or bpy_name.id != "bpy":
|
||||
continue
|
||||
hits.append(node)
|
||||
return hits
|
||||
|
||||
|
||||
def _format_offender(path: Path, node: ast.AST) -> str:
|
||||
return f"{path.name}:{node.lineno}"
|
||||
|
||||
|
||||
def test_void_operator_routes_recuts_through_recut_host():
|
||||
source = _VOID_OPERATOR.read_text(encoding="utf-8")
|
||||
tree = ast.parse(source)
|
||||
offenders = [_format_offender(_VOID_OPERATOR, n) for n in _switch_representation_calls(tree)]
|
||||
assert not offenders, (
|
||||
"Direct `switch_representation` calls in void/operator.py: "
|
||||
+ ", ".join(offenders)
|
||||
+ ". Replace with `tool.Geometry.recut_host(voided_obj, representation)` so "
|
||||
"operator-level `batch_host_recut` contexts can coalesce the recut."
|
||||
)
|
||||
|
||||
|
||||
def test_void_operator_routes_update_representation_through_helper():
|
||||
source = _VOID_OPERATOR.read_text(encoding="utf-8")
|
||||
tree = ast.parse(source)
|
||||
offenders = [_format_offender(_VOID_OPERATOR, n) for n in _bim_update_representation_calls(tree)]
|
||||
assert not offenders, (
|
||||
"Direct `bpy.ops.bim.update_representation` calls in void/operator.py: "
|
||||
+ ", ".join(offenders)
|
||||
+ ". Replace with `tool.Geometry.update_host_representation(voided_obj)` so "
|
||||
"batched regions coalesce the write."
|
||||
)
|
||||
|
||||
|
||||
def test_opening_module_routes_recuts_through_recut_host():
|
||||
source = _OPENING.read_text(encoding="utf-8")
|
||||
tree = ast.parse(source)
|
||||
offenders = [_format_offender(_OPENING, n) for n in _switch_representation_calls(tree)]
|
||||
assert not offenders, (
|
||||
"Direct `switch_representation` calls in bim/module/model/opening.py: "
|
||||
+ ", ".join(offenders)
|
||||
+ ". Replace with `tool.Geometry.recut_host(voided_obj, representation)`."
|
||||
)
|
||||
|
||||
|
||||
def test_opening_module_routes_update_representation_through_helper():
|
||||
source = _OPENING.read_text(encoding="utf-8")
|
||||
tree = ast.parse(source)
|
||||
offenders = [_format_offender(_OPENING, n) for n in _bim_update_representation_calls(tree)]
|
||||
assert not offenders, (
|
||||
"Direct `bpy.ops.bim.update_representation` calls in bim/module/model/opening.py: "
|
||||
+ ", ".join(offenders)
|
||||
+ ". Replace with `tool.Geometry.update_host_representation(voided_obj)`."
|
||||
)
|
||||
|
||||
|
||||
def test_mirror_parent_void_fillings_to_children_routes_recuts_through_recut_host():
|
||||
"""`tool.Model.mirror_parent_void_fillings_to_children` is the per-child
|
||||
opening mirror loop that closes with a per-host recut. The recut MUST go
|
||||
through `recut_host` so `tool.Model.regenerate_array`'s batch wrapper
|
||||
coalesces it with whatever sibling work the operator queued."""
|
||||
from bonsai.tool import model as tool_model_mod
|
||||
|
||||
source = inspect.getsource(tool_model_mod)
|
||||
tree = ast.parse(source)
|
||||
target = next(
|
||||
(
|
||||
node
|
||||
for node in ast.walk(tree)
|
||||
if isinstance(node, ast.FunctionDef) and node.name == "mirror_parent_void_fillings_to_children"
|
||||
),
|
||||
None,
|
||||
)
|
||||
assert target is not None, "mirror_parent_void_fillings_to_children definition not found"
|
||||
|
||||
offenders = [n.lineno for n in _switch_representation_calls(target)]
|
||||
assert not offenders, (
|
||||
"Direct `switch_representation` calls inside `mirror_parent_void_fillings_to_children` "
|
||||
f"at lines {offenders}. Replace with `tool.Geometry.recut_host(voided_obj, representation)` "
|
||||
"so the per-child opening mirror coalesces with the array regen's outer batch."
|
||||
)
|
||||
@@ -63,6 +63,148 @@ def test_fully_overridden_subclass_is_accepted():
|
||||
assert cls.__name__ == "DecoratorWithAllHooks"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cache invalidation — the load-bearing crash guard.
|
||||
#
|
||||
# Without geom-generation gating, the walk cache holds entity_instance
|
||||
# references that outlive their backing IFC entities after an
|
||||
# ifcopenshell.api mutation. The next _build_geometry pass calls .is_a
|
||||
# on a freed SWIG handle and segfaults Blender. The gate must fire
|
||||
# whenever tool.Parametric.get_geom_generation bumps — which is on
|
||||
# every tool.Ifc.Operator commit (via refresh_post_commit), covering
|
||||
# every disconnect path.
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
|
||||
def _seed_cache(decorator, *, start_guid, ifc_file, geom_gen, walk_ids):
|
||||
decorator._cached_start_guid = start_guid
|
||||
decorator._cached_ifc_file = ifc_file
|
||||
decorator._cached_geom_gen = geom_gen
|
||||
decorator._cached_walk_ids = list(walk_ids)
|
||||
|
||||
|
||||
def test_walk_cache_reuses_when_seed_file_and_geom_gen_unchanged():
|
||||
"""Cache hit: same seed, same ifc_file, same geom_gen → reuse the
|
||||
stored walk. Steady-state path while the IFC is idle."""
|
||||
cls = _build_subclass("DecoratorCacheReuse")
|
||||
dec = cls()
|
||||
|
||||
ifc_file = SimpleNamespace()
|
||||
_seed_cache(dec, start_guid="GUID", ifc_file=ifc_file, geom_gen=5, walk_ids=[101, 102])
|
||||
|
||||
current_geom_gen = 5
|
||||
start_guid = "GUID"
|
||||
hit = (
|
||||
start_guid == dec._cached_start_guid
|
||||
and ifc_file is dec._cached_ifc_file
|
||||
and current_geom_gen == dec._cached_geom_gen
|
||||
and dec._cached_walk_ids
|
||||
)
|
||||
assert hit, "Cache must hit when seed, file, and geom_gen are unchanged"
|
||||
|
||||
|
||||
def test_walk_cache_invalidates_on_geom_generation_bump():
|
||||
"""Cache must miss when geom_gen bumps so entities removed by an
|
||||
``ifcopenshell.api`` mutation never survive in the cached walk
|
||||
list into the next draw pass."""
|
||||
cls = _build_subclass("DecoratorCacheGenInvalidates")
|
||||
dec = cls()
|
||||
|
||||
ifc_file = SimpleNamespace()
|
||||
_seed_cache(dec, start_guid="GUID", ifc_file=ifc_file, geom_gen=5, walk_ids=[101])
|
||||
|
||||
current_geom_gen = 6 # IFC mutation has bumped the counter
|
||||
start_guid = "GUID"
|
||||
hit = (
|
||||
start_guid == dec._cached_start_guid
|
||||
and ifc_file is dec._cached_ifc_file
|
||||
and current_geom_gen == dec._cached_geom_gen
|
||||
and dec._cached_walk_ids
|
||||
)
|
||||
assert not hit, "Cache must miss when geom_gen bumps so the walk re-runs against live entities"
|
||||
|
||||
|
||||
def test_walk_cache_invalidates_on_seed_change():
|
||||
"""Selecting a different network seed forces a re-walk even if
|
||||
geom_gen is unchanged."""
|
||||
cls = _build_subclass("DecoratorCacheSeedChange")
|
||||
dec = cls()
|
||||
|
||||
ifc_file = SimpleNamespace()
|
||||
_seed_cache(dec, start_guid="OLD-GUID", ifc_file=ifc_file, geom_gen=5, walk_ids=[101])
|
||||
|
||||
hit = (
|
||||
"NEW-GUID" == dec._cached_start_guid
|
||||
and ifc_file is dec._cached_ifc_file
|
||||
and 5 == dec._cached_geom_gen
|
||||
and dec._cached_walk_ids
|
||||
)
|
||||
assert not hit
|
||||
|
||||
|
||||
def test_walk_cache_invalidates_on_ifc_file_swap():
|
||||
"""Loading a different IFC file must invalidate even if the new
|
||||
seed happens to share the GUID (different IfcOpenShell file
|
||||
objects → different identity)."""
|
||||
cls = _build_subclass("DecoratorCacheFileSwap")
|
||||
dec = cls()
|
||||
|
||||
old_file = SimpleNamespace()
|
||||
new_file = SimpleNamespace()
|
||||
_seed_cache(dec, start_guid="GUID", ifc_file=old_file, geom_gen=5, walk_ids=[101])
|
||||
|
||||
hit = (
|
||||
"GUID" == dec._cached_start_guid
|
||||
and new_file is dec._cached_ifc_file
|
||||
and 5 == dec._cached_geom_gen
|
||||
and dec._cached_walk_ids
|
||||
)
|
||||
assert not hit
|
||||
|
||||
|
||||
def test_walk_cache_stores_ids_not_entity_references():
|
||||
"""Structural safety: the cache stores STEP integer ids, not raw
|
||||
``entity_instance`` references — re-resolved via ``ifc_file.by_id``
|
||||
on each cache hit. Eliminates the dangling-SWIG-handle class entirely:
|
||||
even if geom_gen mistakenly fails to bump, a deleted entity's id won't
|
||||
resolve, the cache-hit branch returns ``None``, and the next draw
|
||||
re-walks against live entities."""
|
||||
cls = _build_subclass("DecoratorCacheStoresIds")
|
||||
dec = cls()
|
||||
_seed_cache(dec, start_guid="GUID", ifc_file=SimpleNamespace(), geom_gen=5, walk_ids=[42])
|
||||
assert dec._cached_walk_ids == [42]
|
||||
assert all(isinstance(eid, int) for eid in dec._cached_walk_ids)
|
||||
|
||||
|
||||
def test_geom_cache_key_includes_geom_generation():
|
||||
"""``TokenCache.get_or_compute`` keys that include geom_gen flush
|
||||
the cached world-space geometry on IFC mutations the depsgraph
|
||||
token doesn't observe — without that key component, a re-walk
|
||||
would feed a fresh list to the lambda while the cache still
|
||||
returned the prior result."""
|
||||
import bonsai.bim.decorator_cache as decorator_cache
|
||||
from bonsai.bim.module.model.decorator import MEPSystemPathDecorator
|
||||
|
||||
decorator_cache.reset_for_test()
|
||||
dec = MEPSystemPathDecorator()
|
||||
|
||||
builds: list[int] = []
|
||||
|
||||
def _build():
|
||||
builds.append(1)
|
||||
return ([], [], [])
|
||||
|
||||
ifc_file = SimpleNamespace()
|
||||
dec._geom_cache.get_or_compute(("GUID", id(ifc_file), 1), _build)
|
||||
dec._geom_cache.get_or_compute(("GUID", id(ifc_file), 1), _build)
|
||||
assert len(builds) == 1, "Same key (same gen) should reuse the cached value"
|
||||
|
||||
dec._geom_cache.get_or_compute(("GUID", id(ifc_file), 2), _build)
|
||||
assert len(builds) == 2, "Bumping geom_gen in the key must invalidate the cached value"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pure-geometry classifier contract.
|
||||
#
|
||||
|
||||
@@ -47,6 +47,11 @@ def _rel(klass: str, *, relating=None, related=None, description=None, rel_id: i
|
||||
|
||||
def _elem(*, connected_to=(), connected_from=()):
|
||||
e = Mock()
|
||||
# Default is_a to False so the MEP-pair-fitting branch of find_rels
|
||||
# (which calls ``elem.is_a("IfcFlowSegment")``) early-outs on the
|
||||
# generic _elem stubs used by the wall-side dispatch tests. Test
|
||||
# cases that want is_a("IfcWall")-True explicitly override e.is_a.
|
||||
e.is_a = lambda _c: False
|
||||
e.ConnectedTo = list(connected_to)
|
||||
e.ConnectedFrom = list(connected_from)
|
||||
e.GlobalId = "GUID"
|
||||
@@ -167,6 +172,140 @@ def test_find_rels_for_element_skips_rels_without_partner():
|
||||
assert tool.Connection.find_rels_for_element(elem) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# tool.Connection.find_rels — MEP pair-fitting detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _mep(elem_id, *, klasses=("IfcFlowSegment",), predefined_type=None, ports=()):
|
||||
"""Stand-in IFC element with port mocks and ``is_a`` short-circuits."""
|
||||
e = Mock()
|
||||
e.id = lambda: elem_id
|
||||
e.is_a = lambda c: c in klasses
|
||||
e.PredefinedType = predefined_type
|
||||
# Empty path / element rels so the find_rels prologue iterates cleanly
|
||||
# before reaching the MEP port-walk branch.
|
||||
e.ConnectedTo = []
|
||||
e.ConnectedFrom = []
|
||||
e._ports = list(ports)
|
||||
return e
|
||||
|
||||
|
||||
def _port(port_id, owner, connected_to=None):
|
||||
p = Mock()
|
||||
p.id = lambda: port_id
|
||||
p._owner = owner
|
||||
p._connected_to = connected_to
|
||||
return p
|
||||
|
||||
|
||||
def _patch_port_walk():
|
||||
"""Patch the port helpers ``tool.System.find_bridging_fitting`` consumes
|
||||
so the mep-pair-fitting detection in ``find_rels`` can be exercised
|
||||
without a real IFC fixture. Three patches: ``get_ports`` and
|
||||
``get_connected_port`` are ``tool.System`` classmethods that delegate
|
||||
to ``ifcopenshell.util.system``; ``get_port_element`` is called
|
||||
directly on ``ifcopenshell.util.system`` inside ``neighbours_at_ports``."""
|
||||
return (
|
||||
patch("bonsai.tool.system.System.get_ports", side_effect=lambda e: e._ports),
|
||||
patch(
|
||||
"bonsai.tool.system.System.get_connected_port",
|
||||
side_effect=lambda p: p._connected_to,
|
||||
),
|
||||
patch(
|
||||
"bonsai.tool.system.ifcopenshell.util.system.get_port_element",
|
||||
side_effect=lambda p: p._owner,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_find_rels_detects_segment_segment_bridging_fitting():
|
||||
"""Two flow segments joined by a single bridging fitting must surface
|
||||
as ``(fitting, 'mep-pair-fitting')`` — the fitting whose deletion
|
||||
effects the disconnect."""
|
||||
fitting = _mep(99, klasses=("IfcFlowFitting", "IfcDistributionFlowElement"), predefined_type="BEND")
|
||||
seg_a = _mep(1)
|
||||
seg_b = _mep(2)
|
||||
|
||||
a_port = _port(101, seg_a)
|
||||
b_port = _port(102, seg_b)
|
||||
f_port_a = _port(201, fitting, connected_to=a_port)
|
||||
f_port_b = _port(202, fitting, connected_to=b_port)
|
||||
a_port._connected_to = f_port_a
|
||||
b_port._connected_to = f_port_b
|
||||
|
||||
seg_a._ports = [a_port]
|
||||
seg_b._ports = [b_port]
|
||||
fitting._ports = [f_port_a, f_port_b]
|
||||
|
||||
with _patch_port_walk()[0], _patch_port_walk()[1], _patch_port_walk()[2]:
|
||||
rels = tool.Connection.find_rels(seg_a, seg_b)
|
||||
|
||||
assert rels == [(fitting, "mep-pair-fitting")]
|
||||
|
||||
|
||||
def test_find_rels_detects_segment_fitting_direct():
|
||||
"""A segment + its directly-connected fitting also surface as the
|
||||
same kind, with the fitting itself as the deletion target."""
|
||||
fitting = _mep(99, klasses=("IfcFlowFitting", "IfcDistributionFlowElement"), predefined_type="BEND")
|
||||
seg = _mep(1)
|
||||
seg_port = _port(101, seg)
|
||||
f_port = _port(201, fitting, connected_to=seg_port)
|
||||
seg_port._connected_to = f_port
|
||||
seg._ports = [seg_port]
|
||||
fitting._ports = [f_port]
|
||||
|
||||
with _patch_port_walk()[0], _patch_port_walk()[1], _patch_port_walk()[2]:
|
||||
rels = tool.Connection.find_rels(seg, fitting)
|
||||
|
||||
assert rels == [(fitting, "mep-pair-fitting")]
|
||||
|
||||
|
||||
def test_find_rels_skips_obstruction_fitting():
|
||||
"""OBSTRUCTION fittings have a dedicated grow/shrink removal flow —
|
||||
they must not surface as a disconnect target."""
|
||||
obstruction = _mep(99, klasses=("IfcFlowFitting", "IfcDistributionFlowElement"), predefined_type="OBSTRUCTION")
|
||||
seg_a = _mep(1)
|
||||
seg_b = _mep(2)
|
||||
a_port = _port(101, seg_a)
|
||||
b_port = _port(102, seg_b)
|
||||
o_port_a = _port(201, obstruction, connected_to=a_port)
|
||||
o_port_b = _port(202, obstruction, connected_to=b_port)
|
||||
a_port._connected_to = o_port_a
|
||||
b_port._connected_to = o_port_b
|
||||
seg_a._ports = [a_port]
|
||||
seg_b._ports = [b_port]
|
||||
obstruction._ports = [o_port_a, o_port_b]
|
||||
|
||||
with _patch_port_walk()[0], _patch_port_walk()[1], _patch_port_walk()[2]:
|
||||
assert tool.Connection.find_rels(seg_a, seg_b) == []
|
||||
|
||||
|
||||
def test_find_rels_returns_empty_for_two_unrelated_mep_segments():
|
||||
"""No bridging fitting, no detection."""
|
||||
seg_a = _mep(1)
|
||||
seg_b = _mep(2)
|
||||
seg_a._ports = []
|
||||
seg_b._ports = []
|
||||
with _patch_port_walk()[0], _patch_port_walk()[1], _patch_port_walk()[2]:
|
||||
assert tool.Connection.find_rels(seg_a, seg_b) == []
|
||||
|
||||
|
||||
def test_find_rels_skips_non_mep_pair():
|
||||
"""Walls don't have ports — find_rels must early-out before walking
|
||||
them as if they were MEP."""
|
||||
wall_a = Mock()
|
||||
wall_a.is_a = lambda c: c == "IfcWall"
|
||||
wall_a.ConnectedTo = []
|
||||
wall_a.ConnectedFrom = []
|
||||
wall_b = Mock()
|
||||
wall_b.is_a = lambda c: c == "IfcWall"
|
||||
wall_b.ConnectedTo = []
|
||||
wall_b.ConnectedFrom = []
|
||||
|
||||
assert tool.Connection.find_rels(wall_a, wall_b) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# tool.Connection.find_rel — first-match convenience
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -241,9 +380,9 @@ def test_disconnect_dispatches_one_call_per_rel():
|
||||
assert dispatch.call_count == 2
|
||||
# Both rels dispatch with elem=elem_a, partner=elem_b regardless of orientation
|
||||
# — orient_element_top inside disconnect_rel recovers the wall/slab roles.
|
||||
for call, expected_rel, expected_kind in zip(dispatch.call_args_list, [rel1, rel2], ["path", "element-top"]):
|
||||
for call, expected_subject, expected_kind in zip(dispatch.call_args_list, [rel1, rel2], ["path", "element-top"]):
|
||||
kw = call.kwargs
|
||||
assert kw["rel"] is expected_rel
|
||||
assert kw["subject"] is expected_subject
|
||||
assert kw["kind"] == expected_kind
|
||||
assert kw["elem"] is elem_a
|
||||
assert kw["partner"] is elem_b
|
||||
@@ -346,7 +485,7 @@ def test_disconnect_gizmo_direction_symmetry():
|
||||
# disconnect_rel sees (rel, "element-top") in both runs; elem/partner swap
|
||||
# by argument order but orient_element_top inside disconnect_rel resolves
|
||||
# the wall/slab roles symmetrically.
|
||||
assert wall_first["rel"] is rel and slab_first["rel"] is rel
|
||||
assert wall_first["subject"] is rel and slab_first["subject"] is rel
|
||||
assert wall_first["kind"] == slab_first["kind"] == "element-top"
|
||||
assert {wall_first["elem"], wall_first["partner"]} == {wall, slab}
|
||||
assert {slab_first["elem"], slab_first["partner"]} == {wall, slab}
|
||||
|
||||
@@ -51,22 +51,29 @@ _IFC_CLASS_BY_KIND = {
|
||||
"slab": "IfcSlab",
|
||||
"roof": "IfcRoof",
|
||||
"plain": "IfcDiscreteAccessory",
|
||||
"door": "IfcDoor",
|
||||
"window": "IfcWindow",
|
||||
"opening": "IfcOpeningElement",
|
||||
"covering": "IfcCovering",
|
||||
}
|
||||
|
||||
|
||||
class _FakeIfcEntity:
|
||||
"""Minimal stand-in for an ``ifcopenshell.entity_instance`` in poll tests.
|
||||
|
||||
Provides the two surfaces the gizmo's poll consults: ``is_a(type_name)``
|
||||
(used directly by ``is_supported_host`` for slab/roof) and an optional
|
||||
``HasOpenings`` attribute (probed by the poll's ``hasattr`` guard)."""
|
||||
Mirrors ``ifcopenshell.entity_instance.is_a``'s two call shapes:
|
||||
``is_a("Foo")`` returns True when the entity's class is ``Foo``, and
|
||||
``is_a()`` returns the class name as a string. ``HasOpenings`` is
|
||||
optional so the poll's ``hasattr`` guard branch is reachable."""
|
||||
|
||||
def __init__(self, ifc_class: str, has_openings: bool = True):
|
||||
self._ifc_class = ifc_class
|
||||
if has_openings:
|
||||
self.HasOpenings = ()
|
||||
|
||||
def is_a(self, type_name: str) -> bool:
|
||||
def is_a(self, type_name: str | None = None):
|
||||
if type_name is None:
|
||||
return self._ifc_class
|
||||
return self._ifc_class == type_name
|
||||
|
||||
|
||||
@@ -168,6 +175,40 @@ def test_poll_rejects_host_host_pairs(active_kind, other_kind, patched_tool):
|
||||
assert _run_poll(patched_tool, active_kind=active_kind, other_kind=other_kind) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("filling_kind", ["door", "window", "opening", "mesh"])
|
||||
def test_poll_accepts_host_with_supported_filling(filling_kind, patched_tool):
|
||||
"""The apply-opening gizmo must activate when the secondary selection
|
||||
is a class the operator can dispatch on: ``IfcDoor`` / ``IfcWindow``
|
||||
(filled openings), ``IfcOpeningElement`` (existing opening reassigned
|
||||
to a new host), or a raw Blender mesh (converted to an opening)."""
|
||||
assert _run_poll(patched_tool, active_kind="wall", other_kind=filling_kind) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("non_filling_kind", ["covering", "plain"])
|
||||
def test_poll_rejects_host_with_non_filling(non_filling_kind, patched_tool):
|
||||
"""An IFC entity whose class the apply-opening operator can't dispatch
|
||||
on must keep the gizmo hidden — clicking it would otherwise dispatch
|
||||
the operator on a class whose geometry the opening generator can't
|
||||
derive, causing a deep traceback in the geometry kernel."""
|
||||
assert _run_poll(patched_tool, active_kind="wall", other_kind=non_filling_kind) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("filling_kind", ["door", "window", "opening", "mesh"])
|
||||
def test_poll_accepts_filling_active_with_host_other(filling_kind, patched_tool):
|
||||
"""The poll must be selection-order independent: the icon should appear
|
||||
whether the user clicked the host first or the filling first. The
|
||||
operator handles either order, so the gizmo should match."""
|
||||
assert _run_poll(patched_tool, active_kind=filling_kind, other_kind="wall") is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("non_filling_kind", ["covering", "plain"])
|
||||
def test_poll_rejects_non_filling_active_with_host_other(non_filling_kind, patched_tool):
|
||||
"""The selection-order independence must not loosen the filling
|
||||
predicate — covering + wall stays rejected regardless of which is
|
||||
active."""
|
||||
assert _run_poll(patched_tool, active_kind=non_filling_kind, other_kind="wall") is False
|
||||
|
||||
|
||||
def test_poll_rejects_active_host_without_has_openings(patched_tool):
|
||||
# Real-world equivalent: an IFC class that the active schema strips
|
||||
# ``HasOpenings`` from (e.g., a non-element subtype). The active sentinel
|
||||
@@ -265,12 +306,16 @@ def _run_position_layer3_branch(
|
||||
icon = SimpleNamespace(matrix_basis=None, hide=True)
|
||||
self_stub = SimpleNamespace(add_opening_icon=icon)
|
||||
|
||||
host_element = object()
|
||||
# Host identification in the gizmo branches on the entity's class, so
|
||||
# the sentinel must respond to ``is_a``. The non-host selection has no
|
||||
# IFC entity (mesh-like) and is accepted as a filling.
|
||||
host_element = _FakeIfcEntity("IfcSlab")
|
||||
entity_map = {id(host_obj): host_element, id(other): None}
|
||||
with contextlib.ExitStack() as stack:
|
||||
stack.enter_context(
|
||||
patched_tool(
|
||||
selected_list=selected,
|
||||
entity=host_element,
|
||||
entity=lambda o: entity_map.get(id(o)),
|
||||
modifier_predicates={"is_path_connectable_wall": is_path_connectable_wall},
|
||||
)
|
||||
)
|
||||
@@ -297,6 +342,50 @@ def test_layer3_branch_always_parks_above_top_face(patched_tool, other_z):
|
||||
assert pos.z == pytest.approx(0.2 + BaseParametricGizmoGroup.ICON_Z_OFFSET)
|
||||
|
||||
|
||||
def test_position_gizmos_identifies_host_by_class_when_selected_second(patched_tool):
|
||||
"""Host role in ``position_gizmos`` is resolved by IFC class, not by
|
||||
active-object position — so a slab clicked SECOND (filling first,
|
||||
host active or not) still anchors the icon correctly on the slab.
|
||||
This pins the selection-order independence of the positioner (the
|
||||
poll's independence is covered separately by the poll parametrize)."""
|
||||
from bonsai.bim.module.drawing import gizmos as gizmo_module
|
||||
from bonsai.bim.module.model.host_add_opening_gizmo import GizmoHostAddOpening
|
||||
|
||||
other = SimpleNamespace(matrix_world=Matrix.Translation(Vector((0.7, 0.4, 1.0))))
|
||||
host_obj = SimpleNamespace(matrix_world=Matrix.Identity(4), bound_box=[(0.0, 0.0, 0.0), (0.0, 0.0, 0.2)] * 4)
|
||||
# Host at index 1; the filling (no IFC entity) sits at index 0 as active.
|
||||
selected = [other, host_obj]
|
||||
context = SimpleNamespace(active_object=other)
|
||||
icon = SimpleNamespace(matrix_basis=None, hide=True)
|
||||
self_stub = SimpleNamespace(add_opening_icon=icon)
|
||||
|
||||
entity_map = {id(host_obj): _FakeIfcEntity("IfcSlab"), id(other): None}
|
||||
with contextlib.ExitStack() as stack:
|
||||
stack.enter_context(
|
||||
patched_tool(
|
||||
selected_list=selected,
|
||||
entity=lambda o: entity_map.get(id(o)),
|
||||
modifier_predicates={"is_path_connectable_wall": False},
|
||||
)
|
||||
)
|
||||
stack.enter_context(patch.object(gizmo_module, "get_billboard_rotation", return_value=Matrix.Identity(4)))
|
||||
stack.enter_context(
|
||||
patch.object(
|
||||
gizmo_module, "billboarded_at", side_effect=lambda pos, rot, scale=0.5: Matrix.Translation(pos)
|
||||
)
|
||||
)
|
||||
GizmoHostAddOpening.position_gizmos(self_stub, context)
|
||||
|
||||
# Icon anchors on the host's top face (slab bound_box top-Z = 0.2) at
|
||||
# the void's XY — same result as when the host was at index 0.
|
||||
from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup
|
||||
|
||||
pos = icon.matrix_basis.translation
|
||||
assert pos.x == pytest.approx(0.7)
|
||||
assert pos.y == pytest.approx(0.4)
|
||||
assert pos.z == pytest.approx(0.2 + BaseParametricGizmoGroup.ICON_Z_OFFSET)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# is_supported_host() — predicate totality
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -128,14 +128,14 @@ def test_port_connection_state_cached_across_frames_within_generation(_patched_v
|
||||
element = Mock()
|
||||
element.is_a = lambda c: c == "IfcFlowSegment"
|
||||
|
||||
call_counts = {"port_connection_state": 0, "find_fitting_between_segments": 0, "compute_mep_join_location": 0}
|
||||
call_counts = {"port_connection_state": 0, "find_bridging_fitting": 0, "compute_mep_join_location": 0}
|
||||
|
||||
def counting_port_state(elem, at_start):
|
||||
call_counts["port_connection_state"] += 1
|
||||
return "FREE"
|
||||
|
||||
def counting_find_fitting(a, b):
|
||||
call_counts["find_fitting_between_segments"] += 1
|
||||
call_counts["find_bridging_fitting"] += 1
|
||||
return None
|
||||
|
||||
def counting_join_location():
|
||||
@@ -151,7 +151,7 @@ def test_port_connection_state_cached_across_frames_within_generation(_patched_v
|
||||
return_value=(Vector((0, 0, 0)), Vector((1, 0, 0))),
|
||||
),
|
||||
patch("bonsai.bim.module.model.mep.port_connection_state", side_effect=counting_port_state),
|
||||
patch("bonsai.bim.module.model.mep.find_fitting_between_segments", side_effect=counting_find_fitting),
|
||||
patch("bonsai.bim.module.model.mep.tool.System.find_bridging_fitting", side_effect=counting_find_fitting),
|
||||
patch("bonsai.bim.module.model.decorator.compute_mep_join_location", side_effect=counting_join_location),
|
||||
patch("bonsai.bim.module.model.mep.gizmo.get_billboard_rotation", return_value=Mock()),
|
||||
patch("bonsai.bim.module.model.mep.gizmo.billboarded_at", return_value=Mock()),
|
||||
@@ -164,7 +164,7 @@ def test_port_connection_state_cached_across_frames_within_generation(_patched_v
|
||||
|
||||
# Second frame must reuse the cached values — no second IFC walk.
|
||||
assert call_counts["port_connection_state"] == first["port_connection_state"]
|
||||
assert call_counts["find_fitting_between_segments"] == first["find_fitting_between_segments"]
|
||||
assert call_counts["find_bridging_fitting"] == first["find_bridging_fitting"]
|
||||
assert call_counts["compute_mep_join_location"] == first["compute_mep_join_location"]
|
||||
|
||||
|
||||
@@ -203,7 +203,7 @@ def test_generation_advance_invalidates_cache(_patched_visibility):
|
||||
), patch(
|
||||
"bonsai.bim.module.model.mep.port_connection_state", side_effect=counting_port_state
|
||||
), patch(
|
||||
"bonsai.bim.module.model.mep.find_fitting_between_segments", side_effect=counting_find_fitting
|
||||
"bonsai.bim.module.model.mep.tool.System.find_bridging_fitting", side_effect=counting_find_fitting
|
||||
), patch(
|
||||
"bonsai.bim.module.model.decorator.compute_mep_join_location", return_value=Vector((0, 0, 0))
|
||||
), patch(
|
||||
@@ -218,9 +218,7 @@ def test_generation_advance_invalidates_cache(_patched_visibility):
|
||||
inst.position_gizmos(context)
|
||||
|
||||
assert port_call_count["n"] > first_port, "port_connection_state must recompute after generation advance"
|
||||
assert (
|
||||
fitting_call_count["n"] > first_fitting
|
||||
), "find_fitting_between_segments must recompute after generation advance"
|
||||
assert fitting_call_count["n"] > first_fitting, "find_bridging_fitting must recompute after generation advance"
|
||||
|
||||
|
||||
def test_selection_change_invalidates_cache(_patched_visibility):
|
||||
@@ -252,7 +250,7 @@ def test_selection_change_invalidates_cache(_patched_visibility):
|
||||
), patch(
|
||||
"bonsai.bim.module.model.mep.port_connection_state", return_value="FREE"
|
||||
), patch(
|
||||
"bonsai.bim.module.model.mep.find_fitting_between_segments", side_effect=counting_find_fitting
|
||||
"bonsai.bim.module.model.mep.tool.System.find_bridging_fitting", side_effect=counting_find_fitting
|
||||
), patch(
|
||||
"bonsai.bim.module.model.decorator.compute_mep_join_location", return_value=Vector((0, 0, 0))
|
||||
), patch(
|
||||
@@ -265,4 +263,4 @@ def test_selection_change_invalidates_cache(_patched_visibility):
|
||||
selection_state["selected"] = [active, other_b]
|
||||
inst.position_gizmos(context)
|
||||
|
||||
assert fitting_call_count["n"] > first, "find_fitting_between_segments must recompute after selection change"
|
||||
assert fitting_call_count["n"] > first, "find_bridging_fitting must recompute after selection change"
|
||||
|
||||
@@ -160,41 +160,102 @@ def test_lock_closed_icons_pass_position_to_remove_terminal_fitting():
|
||||
)
|
||||
|
||||
|
||||
def test_unjoin_port_icons_pass_position_to_unjoin_at_port():
|
||||
"""Per-port unjoin icons bind to ``bim.mep_unjoin_at_port`` with
|
||||
``position`` pinned. Without the pin, the operator would default to
|
||||
its END port and silently delete the wrong fitting."""
|
||||
def test_unjoin_icons_bind_unified_disconnect_operator():
|
||||
"""Every unjoin icon (pair, start, end) routes to the unified
|
||||
``bim.disconnect_elements`` operator and the group holds an
|
||||
``op_props`` slot for each so the per-frame GUID writes have a
|
||||
target."""
|
||||
from bonsai.bim.module.model.mep import GizmoMEPActions
|
||||
|
||||
inst = _build_group_with_mock_gizmos()
|
||||
with patch("bonsai.bim.module.model.mep.gizmo.get_warning_color_from_prefs", return_value=(1, 0, 0)), patch(
|
||||
"bonsai.bim.module.model.mep.tool.Blender.get_addon_preferences", return_value=MagicMock()
|
||||
):
|
||||
GizmoMEPActions._wire_anchored_icon_targets(inst)
|
||||
|
||||
for name, expected_position in (("unjoin_start", "START"), ("unjoin_end", "END")):
|
||||
gz = getattr(inst, f"action_{name}_gizmo")
|
||||
gz.target_set_operator.assert_any_call("bim.mep_unjoin_at_port")
|
||||
op_props = gz.target_set_operator.return_value
|
||||
assert op_props.position == expected_position or op_props.position in ("START", "END")
|
||||
|
||||
|
||||
def test_unjoin_icons_get_warning_color_highlight():
|
||||
"""Destructive icons surface in the addon's warning red on hover so
|
||||
they read as a deliberate target. ``color_highlight`` is overridden
|
||||
after ``super().setup()`` wires the default highlight."""
|
||||
from bonsai.bim.module.model.mep import GizmoMEPActions
|
||||
|
||||
inst = _build_group_with_mock_gizmos()
|
||||
warning_color = (1.0, 0.1, 0.1)
|
||||
with patch("bonsai.bim.module.model.mep.gizmo.get_warning_color_from_prefs", return_value=warning_color), patch(
|
||||
"bonsai.bim.module.model.mep.tool.Blender.get_addon_preferences", return_value=MagicMock()
|
||||
):
|
||||
GizmoMEPActions._wire_anchored_icon_targets(inst)
|
||||
GizmoMEPActions._wire_anchored_icon_targets(inst)
|
||||
|
||||
assert isinstance(inst.unjoin_op_props, dict)
|
||||
for name in GizmoMEPActions.UNJOIN_CONFIGS:
|
||||
gz = getattr(inst, f"action_{name}_gizmo")
|
||||
assert gz.color_highlight == warning_color, f"{name} hover colour not overridden with warning red"
|
||||
gz.target_set_operator.assert_any_call("bim.disconnect_elements")
|
||||
assert name in inst.unjoin_op_props, f"missing op_props slot for {name!r}"
|
||||
|
||||
|
||||
def test_bind_unjoin_pair_writes_both_guids():
|
||||
"""``_bind_unjoin_pair`` is the per-frame hand-off from gizmo
|
||||
position-gizmos to the unified disconnect operator: both segment
|
||||
GlobalIds get written onto the pre-wired op_props so a click
|
||||
dispatches with the right pair."""
|
||||
from bonsai.bim.module.model.mep import GizmoMEPActions
|
||||
|
||||
inst = _build_group_with_mock_gizmos()
|
||||
GizmoMEPActions._wire_anchored_icon_targets(inst)
|
||||
pair_op_props = inst.unjoin_op_props["unjoin_pair"]
|
||||
|
||||
seg_a = Mock(GlobalId="GUID-A")
|
||||
seg_b = Mock(GlobalId="GUID-B")
|
||||
|
||||
assert GizmoMEPActions._bind_unjoin_pair(inst, [seg_a, seg_b]) is True
|
||||
assert pair_op_props.element_a_guid == "GUID-A"
|
||||
assert pair_op_props.element_b_guid == "GUID-B"
|
||||
|
||||
|
||||
def test_bind_unjoin_pair_rejects_incomplete_pair():
|
||||
"""Defensive: a selection mid-change can hand the gizmo a one-element
|
||||
or None-containing pair. The bind must refuse rather than write a
|
||||
half-resolved op_props that would later CANCEL with a confusing
|
||||
error message."""
|
||||
from bonsai.bim.module.model.mep import GizmoMEPActions
|
||||
|
||||
inst = _build_group_with_mock_gizmos()
|
||||
GizmoMEPActions._wire_anchored_icon_targets(inst)
|
||||
|
||||
assert GizmoMEPActions._bind_unjoin_pair(inst, [Mock(GlobalId="A")]) is False
|
||||
assert GizmoMEPActions._bind_unjoin_pair(inst, [Mock(GlobalId="A"), None]) is False
|
||||
|
||||
|
||||
def test_bind_unjoin_at_port_resolves_fitting_and_writes_guids():
|
||||
"""The per-port unjoin gizmo resolves the partner fitting at the
|
||||
named port and writes (segment_guid, fitting_guid) onto the
|
||||
pre-wired op_props so the unified disconnect operator gets both
|
||||
endpoints."""
|
||||
from bonsai.bim.module.model.mep import GizmoMEPActions
|
||||
|
||||
inst = _build_group_with_mock_gizmos()
|
||||
GizmoMEPActions._wire_anchored_icon_targets(inst)
|
||||
port_op_props = inst.unjoin_op_props["unjoin_end"]
|
||||
|
||||
segment_obj = Mock()
|
||||
segment = Mock(GlobalId="SEG-GUID")
|
||||
fitting = Mock(GlobalId="FIT-GUID")
|
||||
fitting.is_a = lambda c: c == "IfcFlowFitting"
|
||||
fitting.PredefinedType = "BEND"
|
||||
|
||||
with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=segment), patch(
|
||||
"bonsai.bim.module.model.mep.get_connected_element_at_segment_port", return_value=fitting
|
||||
):
|
||||
ok = GizmoMEPActions._bind_unjoin_at_port(inst, "unjoin_end", segment_obj, False)
|
||||
|
||||
assert ok is True
|
||||
assert port_op_props.element_a_guid == "SEG-GUID"
|
||||
assert port_op_props.element_b_guid == "FIT-GUID"
|
||||
|
||||
|
||||
def test_bind_unjoin_at_port_refuses_obstruction_partner():
|
||||
"""OBSTRUCTION fittings have a dedicated grow/shrink removal flow —
|
||||
routing them through the unified disconnect would just delete the
|
||||
fitting and leave a visible gap. Mirror the find_rels exclusion
|
||||
here so the icon hides when the partner is an obstruction."""
|
||||
from bonsai.bim.module.model.mep import GizmoMEPActions
|
||||
|
||||
inst = _build_group_with_mock_gizmos()
|
||||
GizmoMEPActions._wire_anchored_icon_targets(inst)
|
||||
|
||||
segment = Mock(GlobalId="SEG-GUID")
|
||||
obstruction = Mock(GlobalId="OBS-GUID")
|
||||
obstruction.is_a = lambda c: c == "IfcFlowFitting"
|
||||
obstruction.PredefinedType = "OBSTRUCTION"
|
||||
|
||||
with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=segment), patch(
|
||||
"bonsai.bim.module.model.mep.get_connected_element_at_segment_port", return_value=obstruction
|
||||
):
|
||||
assert GizmoMEPActions._bind_unjoin_at_port(inst, "unjoin_end", Mock(), False) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -202,6 +263,66 @@ def test_unjoin_icons_get_warning_color_highlight():
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_active_is_bend_fitting_accepts_tessellated_bend_with_bbim_pset():
|
||||
"""A bend whose body has been tessellated as the upstream geometry-kernel
|
||||
workaround still has its parametric definition on the type's
|
||||
``BBIM_Fitting`` pset — the re-edit operator reads from there, so the
|
||||
pen icon must surface on it. ``has_parametric_body`` would return False
|
||||
for the tessellated body; the pset gate is what makes the icon
|
||||
reachable."""
|
||||
from bonsai.bim.module.model.mep import _active_is_bend_fitting
|
||||
|
||||
bend_obj = Mock()
|
||||
bend_elem = Mock()
|
||||
bend_elem.is_a = lambda c: c == "IfcFlowFitting"
|
||||
bend_type = Mock()
|
||||
bend_type.PredefinedType = "BEND"
|
||||
|
||||
with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=bend_elem), patch(
|
||||
"bonsai.bim.module.model.mep._is_bend_fitting", return_value=True
|
||||
), patch("bonsai.bim.module.model.mep.ifcopenshell.util.element.get_type", return_value=bend_type), patch(
|
||||
"bonsai.bim.module.model.mep.ifcopenshell.util.element.get_pset",
|
||||
return_value={"radius": 0.2, "start_length": 0.1, "end_length": 0.1},
|
||||
):
|
||||
assert _active_is_bend_fitting(bend_obj) is True
|
||||
|
||||
|
||||
def test_active_is_bend_fitting_rejects_bend_type_without_bbim_pset():
|
||||
"""A fitting that looks like a bend (IfcFlowFitting + type.PredefinedType
|
||||
== BEND) but lacks a ``BBIM_Fitting`` pset on the type can't be re-edited
|
||||
— the re-edit operator reads parameters from the pset. Reject so the pen
|
||||
icon hides rather than dispatching an operator that would CANCEL."""
|
||||
from bonsai.bim.module.model.mep import _active_is_bend_fitting
|
||||
|
||||
bend_obj = Mock()
|
||||
bend_elem = Mock()
|
||||
bend_elem.is_a = lambda c: c == "IfcFlowFitting"
|
||||
bend_type = Mock()
|
||||
bend_type.PredefinedType = "BEND"
|
||||
|
||||
with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=bend_elem), patch(
|
||||
"bonsai.bim.module.model.mep._is_bend_fitting", return_value=True
|
||||
), patch("bonsai.bim.module.model.mep.ifcopenshell.util.element.get_type", return_value=bend_type), patch(
|
||||
"bonsai.bim.module.model.mep.ifcopenshell.util.element.get_pset", return_value=None
|
||||
):
|
||||
assert _active_is_bend_fitting(bend_obj) is False
|
||||
|
||||
|
||||
def test_active_is_bend_fitting_rejects_non_bend():
|
||||
"""Non-bend objects (segments, fittings with PredefinedType != BEND)
|
||||
fail the first gate regardless of pset state."""
|
||||
from bonsai.bim.module.model.mep import _active_is_bend_fitting
|
||||
|
||||
bend_obj = Mock()
|
||||
bend_elem = Mock()
|
||||
bend_elem.is_a = lambda c: c == "IfcFlowFitting"
|
||||
|
||||
with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=bend_elem), patch(
|
||||
"bonsai.bim.module.model.mep._is_bend_fitting", return_value=False
|
||||
):
|
||||
assert _active_is_bend_fitting(bend_obj) is False
|
||||
|
||||
|
||||
def test_active_is_flow_segment_handles_unbound_object():
|
||||
"""A Blender object with no IFC binding must not raise from a
|
||||
visibility predicate. The lambda runs on every selection event."""
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
# 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.
|
||||
|
||||
"""End-to-end integration tests for the unified MEP disconnect path.
|
||||
|
||||
Builds a real IFC scene (two pipe segments joined via ports to a bridging
|
||||
fitting) and exercises the full chain:
|
||||
tool.Connection.find_rels(seg_a, seg_b)
|
||||
→ returns (fitting, "mep-pair-fitting")
|
||||
→ bonsai.core.connection.disconnect_rel(subject=fitting, kind=...)
|
||||
→ tool.Geometry.delete_ifc_object(fitting_obj)
|
||||
→ cascade-on-delete removes the IfcRelConnectsPorts via remove_port
|
||||
|
||||
The mock-based dispatch tests in :py:mod:`test_disconnect_elements` pin each
|
||||
piece in isolation. This module pins that they compose — the surface the
|
||||
gizmo click hits in production."""
|
||||
|
||||
import bpy
|
||||
import ifcopenshell.api.system
|
||||
import pytest
|
||||
|
||||
import bonsai.core.connection
|
||||
import bonsai.tool as tool
|
||||
from test.bim.bootstrap import NewFile
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
class TestMEPPairDisconnectEndToEnd(NewFile):
|
||||
def _make_segment(self, name: str):
|
||||
"""Create one IfcPipeSegment occurrence with ports at both ends.
|
||||
Returns (blender_object, ifc_element)."""
|
||||
bpy.ops.mesh.primitive_cube_add(size=1)
|
||||
obj = bpy.data.objects["Cube"]
|
||||
obj.name = name
|
||||
bpy.ops.bim.assign_class(ifc_class="IfcPipeSegment", predefined_type="RIGIDSEGMENT", userdefined_type="")
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
tool.System.add_ports(obj)
|
||||
return obj, element
|
||||
|
||||
def _make_bend_fitting(self, name: str):
|
||||
"""Create one IfcPipeFitting (PredefinedType=BEND) occurrence with two
|
||||
ports. Manual setup — bpy.ops.bim.assign_class doesn't add ports."""
|
||||
bpy.ops.mesh.primitive_cube_add(size=0.3)
|
||||
obj = bpy.data.objects["Cube"]
|
||||
obj.name = name
|
||||
bpy.ops.bim.assign_class(ifc_class="IfcPipeFitting", predefined_type="BEND", userdefined_type="")
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
tool.System.add_ports(obj)
|
||||
return obj, element
|
||||
|
||||
def _setup_joined_pair(self):
|
||||
bpy.ops.bim.create_project()
|
||||
seg_a_obj, seg_a = self._make_segment("SegA")
|
||||
seg_b_obj, seg_b = self._make_segment("SegB")
|
||||
bend_obj, bend = self._make_bend_fitting("Bend")
|
||||
|
||||
ifc_file = tool.Ifc.get()
|
||||
seg_a_ports = tool.System.get_ports(seg_a)
|
||||
seg_b_ports = tool.System.get_ports(seg_b)
|
||||
bend_ports = tool.System.get_ports(bend)
|
||||
ifcopenshell.api.system.connect_port(ifc_file, port1=seg_a_ports[0], port2=bend_ports[0])
|
||||
ifcopenshell.api.system.connect_port(ifc_file, port1=seg_b_ports[0], port2=bend_ports[1])
|
||||
|
||||
return seg_a, seg_b, bend, bend_obj
|
||||
|
||||
def test_find_rels_returns_mep_pair_fitting_subject(self):
|
||||
seg_a, seg_b, bend, _ = self._setup_joined_pair()
|
||||
rels = tool.Connection.find_rels(seg_a, seg_b)
|
||||
assert rels == [(bend, "mep-pair-fitting")]
|
||||
|
||||
def test_disconnect_rel_removes_the_bridging_fitting(self):
|
||||
"""The end-to-end contract: dispatch removes the fitting from the
|
||||
IFC file, the Blender object is deleted, and a follow-up find_rels
|
||||
on the same pair returns empty — there's nothing left to disconnect."""
|
||||
seg_a, seg_b, bend, bend_obj = self._setup_joined_pair()
|
||||
bend_id = bend.id()
|
||||
bend_obj_name = bend_obj.name
|
||||
ifc_file = tool.Ifc.get()
|
||||
|
||||
bonsai.core.connection.disconnect_rel(
|
||||
tool.Ifc,
|
||||
tool.Geometry,
|
||||
tool.Model,
|
||||
tool.Connection,
|
||||
subject=bend,
|
||||
kind="mep-pair-fitting",
|
||||
elem=seg_a,
|
||||
partner=seg_b,
|
||||
)
|
||||
|
||||
# The fitting is gone from the IFC file.
|
||||
with pytest.raises(RuntimeError):
|
||||
ifc_file.by_id(bend_id)
|
||||
# The pair is no longer joined.
|
||||
assert tool.Connection.find_rels(seg_a, seg_b) == []
|
||||
# The Blender object was removed by delete_ifc_object.
|
||||
assert bend_obj_name not in bpy.data.objects
|
||||
|
||||
def test_disconnect_rel_skips_when_subject_is_elem_being_deleted(self):
|
||||
"""Cascade-side guard: if the fitting is itself the element being
|
||||
deleted (subject is elem), skip — the deletion is already in flight
|
||||
and re-deleting would crash."""
|
||||
seg_a, seg_b, bend, bend_obj = self._setup_joined_pair()
|
||||
bend_id = bend.id()
|
||||
bend_obj_name = bend_obj.name
|
||||
|
||||
bonsai.core.connection.disconnect_rel(
|
||||
tool.Ifc,
|
||||
tool.Geometry,
|
||||
tool.Model,
|
||||
tool.Connection,
|
||||
subject=bend,
|
||||
kind="mep-pair-fitting",
|
||||
elem=bend,
|
||||
partner=seg_a,
|
||||
skip_elem_recreate=True,
|
||||
)
|
||||
|
||||
# Fitting still present — the dispatch correctly skipped.
|
||||
assert tool.Ifc.get().by_id(bend_id).id() == bend_id
|
||||
assert bend_obj_name in bpy.data.objects
|
||||
@@ -61,76 +61,6 @@ def _make_op(_cls, **fields):
|
||||
return op
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MEPUnjoinAtPort
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"port_state, fitting_predefined_type, expected_result, expects_delete",
|
||||
[
|
||||
pytest.param("JOINED", "JUNCTION", {"FINISHED"}, True, id="joined_junction_deletes"),
|
||||
pytest.param("JOINED", "OBSTRUCTION", {"CANCELLED"}, False, id="joined_obstruction_refused"),
|
||||
pytest.param("FREE", None, {"CANCELLED"}, False, id="free_port_cancels"),
|
||||
],
|
||||
)
|
||||
def test_unjoin_at_port_dispatch_table(port_state, fitting_predefined_type, expected_result, expects_delete):
|
||||
"""``MEPUnjoinAtPort`` dispatch contract: result and delete-side-effect
|
||||
by ``(port_state, fitting type)``.
|
||||
|
||||
- ``JOINED + JUNCTION`` (or any non-OBSTRUCTION fitting): happy path,
|
||||
the bridging fitting is deleted via the standard delete entry point.
|
||||
- ``JOINED + OBSTRUCTION``: deliberately refused — obstructions go
|
||||
through ``bim.mep_add_obstruction`` (mode=REMOVE) so the segment
|
||||
extends to absorb the freed length; using delete here would leave
|
||||
a visible gap.
|
||||
- ``FREE``: nothing to do — no bridging fitting exists. The operator
|
||||
reports a user-facing error and CANCELS rather than no-op silently."""
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
segment = _segment()
|
||||
fitting = _fitting(predefined_type=fitting_predefined_type) if fitting_predefined_type else None
|
||||
fitting_obj = Mock()
|
||||
|
||||
op = _make_op(mep.MEPUnjoinAtPort, segment_id=42, position="END")
|
||||
ifc_file = MagicMock()
|
||||
ifc_file.by_id.return_value = segment
|
||||
|
||||
with patch.object(mep.tool.Ifc, "get", return_value=ifc_file), patch.object(
|
||||
mep.tool.Ifc, "get_object", return_value=fitting_obj
|
||||
), patch.object(mep, "port_connection_state", return_value=port_state), patch.object(
|
||||
mep, "get_connected_element_at_segment_port", return_value=fitting
|
||||
), patch.object(
|
||||
mep.tool.Geometry, "delete_ifc_object"
|
||||
) as delete:
|
||||
result = mep.MEPUnjoinAtPort._execute(op, context=MagicMock())
|
||||
|
||||
assert result == expected_result
|
||||
if expects_delete:
|
||||
delete.assert_called_once_with(fitting_obj)
|
||||
else:
|
||||
delete.assert_not_called()
|
||||
op.report.assert_called()
|
||||
|
||||
|
||||
def test_unjoin_at_port_cancels_when_active_is_not_segment():
|
||||
"""The operator only operates on flow segments; non-segment active
|
||||
objects must fail loud rather than mutate something unexpected."""
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
fitting = _fitting() # IfcFlowFitting, not IfcFlowSegment
|
||||
|
||||
op = _make_op(mep.MEPUnjoinAtPort, segment_id=42, position="END")
|
||||
ifc_file = MagicMock()
|
||||
ifc_file.by_id.return_value = fitting
|
||||
|
||||
with patch.object(mep.tool.Ifc, "get", return_value=ifc_file):
|
||||
result = mep.MEPUnjoinAtPort._execute(op, context=MagicMock())
|
||||
|
||||
assert result == {"CANCELLED"}
|
||||
op.report.assert_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MEPRemoveTerminalFitting
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -210,105 +140,6 @@ def test_remove_terminal_cancels_on_non_terminal_port():
|
||||
op.report.assert_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MEPUnjoinPair
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_unjoin_pair_deletes_bridging_fitting():
|
||||
"""Happy path: two selected segments share a single non-OBSTRUCTION
|
||||
bridging fitting → delete it."""
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
segment_a = _segment()
|
||||
segment_b = _segment()
|
||||
fitting = _fitting(predefined_type="JUNCTION")
|
||||
fitting_obj = Mock()
|
||||
|
||||
op = _make_op(mep.MEPUnjoinPair)
|
||||
selected = [Mock(), Mock()]
|
||||
|
||||
with patch.object(mep.tool.Blender, "get_selected_objects", return_value=selected), patch.object(
|
||||
mep.tool.Ifc, "get_entity", side_effect=[segment_a, segment_b]
|
||||
), patch.object(mep, "find_fitting_between_segments", return_value=fitting), patch.object(
|
||||
mep.tool.Ifc, "get_object", return_value=fitting_obj
|
||||
), patch.object(
|
||||
mep.tool.Geometry, "delete_ifc_object"
|
||||
) as delete:
|
||||
result = mep.MEPUnjoinPair._execute(op, context=MagicMock())
|
||||
|
||||
assert result == {"FINISHED"}
|
||||
delete.assert_called_once_with(fitting_obj)
|
||||
|
||||
|
||||
def test_unjoin_pair_refuses_obstruction_bridging():
|
||||
"""Same defence-in-depth as ``MEPUnjoinAtPort`` — obstructions go
|
||||
through the dedicated REMOVE path; this operator surfaces the
|
||||
redirect rather than silently doing the wrong thing."""
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
segment_a = _segment()
|
||||
segment_b = _segment()
|
||||
obstruction = _fitting(predefined_type="OBSTRUCTION")
|
||||
|
||||
op = _make_op(mep.MEPUnjoinPair)
|
||||
selected = [Mock(), Mock()]
|
||||
|
||||
with patch.object(mep.tool.Blender, "get_selected_objects", return_value=selected), patch.object(
|
||||
mep.tool.Ifc, "get_entity", side_effect=[segment_a, segment_b]
|
||||
), patch.object(mep, "find_fitting_between_segments", return_value=obstruction), patch.object(
|
||||
mep.tool.Geometry, "delete_ifc_object"
|
||||
) as delete:
|
||||
result = mep.MEPUnjoinPair._execute(op, context=MagicMock())
|
||||
|
||||
assert result == {"CANCELLED"}
|
||||
delete.assert_not_called()
|
||||
op.report.assert_called()
|
||||
|
||||
|
||||
def test_unjoin_pair_reports_when_no_bridging_fitting_found():
|
||||
"""The pair is selected but no single fitting bridges them — the
|
||||
user is told instead of getting a silent no-op."""
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
segment_a = _segment()
|
||||
segment_b = _segment()
|
||||
|
||||
op = _make_op(mep.MEPUnjoinPair)
|
||||
selected = [Mock(), Mock()]
|
||||
|
||||
with patch.object(mep.tool.Blender, "get_selected_objects", return_value=selected), patch.object(
|
||||
mep.tool.Ifc, "get_entity", side_effect=[segment_a, segment_b]
|
||||
), patch.object(mep, "find_fitting_between_segments", return_value=None), patch.object(
|
||||
mep.tool.Geometry, "delete_ifc_object"
|
||||
) as delete:
|
||||
result = mep.MEPUnjoinPair._execute(op, context=MagicMock())
|
||||
|
||||
assert result == {"CANCELLED"}
|
||||
delete.assert_not_called()
|
||||
op.report.assert_called()
|
||||
|
||||
|
||||
def test_unjoin_pair_cancels_when_selection_is_not_two_segments():
|
||||
"""The poll filters the gizmo, but a programmatic invocation could
|
||||
still hand the operator an invalid selection. The execute path
|
||||
independently verifies both inputs are IfcFlowSegment."""
|
||||
from bonsai.bim.module.model import mep
|
||||
|
||||
not_a_segment = _fitting() # IfcFlowFitting, not IfcFlowSegment
|
||||
|
||||
op = _make_op(mep.MEPUnjoinPair)
|
||||
selected = [Mock(), Mock()]
|
||||
|
||||
with patch.object(mep.tool.Blender, "get_selected_objects", return_value=selected), patch.object(
|
||||
mep.tool.Ifc, "get_entity", side_effect=[not_a_segment, not_a_segment]
|
||||
):
|
||||
result = mep.MEPUnjoinPair._execute(op, context=MagicMock())
|
||||
|
||||
assert result == {"CANCELLED"}
|
||||
op.report.assert_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SelectMEPPathMembers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""AST contract: ``RecalculateFill`` must invoke
|
||||
``regenerate_simple_opening_bodies`` before recutting hosts.
|
||||
|
||||
Hosts recut with a surgical mesh-only path don't refresh the shared mapped
|
||||
opening source — so any change to a parametric filling's dimensions stays
|
||||
invisible at the opening boundary until the body representation is
|
||||
regenerated. Pinning the call site forces future refactors to keep the
|
||||
regen step in place."""
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
def _recalculate_fill_body_source() -> str:
|
||||
from bonsai.bim.module.model import opening as opening_module
|
||||
|
||||
source = Path(opening_module.__file__).read_text(encoding="utf-8")
|
||||
tree = ast.parse(source)
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ClassDef) and node.name == "RecalculateFill":
|
||||
for child in node.body:
|
||||
if isinstance(child, ast.FunctionDef) and child.name == "_recalculate_fills":
|
||||
return ast.unparse(child)
|
||||
raise AssertionError("RecalculateFill._recalculate_fills was not found in opening.py")
|
||||
|
||||
|
||||
def test_recalculate_fill_regenerates_opening_bodies_before_recut():
|
||||
body = _recalculate_fill_body_source()
|
||||
assert "regenerate_filling_opening_body" in body, (
|
||||
"RecalculateFill._recalculate_fills must call "
|
||||
"tool.Model.regenerate_filling_opening_body for each selected "
|
||||
"filling before recutting the host. Without that call the host is "
|
||||
"recut against a stale shared mapped opening source, so changes "
|
||||
"to filling dimensions never surface."
|
||||
)
|
||||
@@ -83,3 +83,25 @@ def test_regenerate_wall_noops_when_obj_has_no_ifc_entity():
|
||||
recreate.assert_not_called()
|
||||
has_top.assert_not_called()
|
||||
regen.assert_not_called()
|
||||
|
||||
|
||||
def test_recreate_wall_noops_when_wall_has_no_layer_set():
|
||||
"""``recreate_wall`` must short-circuit when
|
||||
``regenerate_wall_representation`` returns ``None``. That API only knows
|
||||
how to rebuild ``IfcMaterialLayerSet`` walls; for walls without one it
|
||||
returns ``None``, and feeding ``None`` to ``switch_representation``
|
||||
crashes deep inside ``resolve_representation`` on ``.Items``."""
|
||||
element = Mock()
|
||||
obj = Mock()
|
||||
|
||||
with patch("bonsai.tool.model.tool.Parametric.is_fillet_corner_wall", return_value=False), patch(
|
||||
"bonsai.tool.model.tool.Ifc.get", return_value=Mock()
|
||||
), patch("bonsai.tool.model.ifcopenshell.api.geometry.regenerate_wall_representation", return_value=None), patch(
|
||||
"bonsai.tool.model.bonsai.core.geometry.switch_representation"
|
||||
) as switch, patch.object(
|
||||
tool.Geometry, "record_object_materials"
|
||||
) as record:
|
||||
tool.Model.recreate_wall(element, obj)
|
||||
|
||||
switch.assert_not_called()
|
||||
record.assert_not_called()
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# 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.
|
||||
|
||||
"""Pins TAB-key dispatch on a LAYER2 wall through ``Modifier.try_applying_edit_mode``.
|
||||
|
||||
Three behavioural legs of the TAB toggle:
|
||||
|
||||
* Fresh LAYER2 wall → enters parametric edit via ``bim.enable_editing_wall``.
|
||||
* LAYER2 wall already in parametric edit → finishes (toggle close).
|
||||
* Wall without ``IfcMaterialLayerSetUsage`` → dispatch returns False so the
|
||||
caller routes the TAB to item mode."""
|
||||
|
||||
import bpy
|
||||
import ifcopenshell.api.material
|
||||
import ifcopenshell.util.element
|
||||
import pytest
|
||||
|
||||
import bonsai.tool as tool
|
||||
from test.bim.bootstrap import NewFile
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
def _add_layer2_wall_occurrence():
|
||||
"""Create a single LAYER2 wall occurrence from the IFC4 Demo Template.
|
||||
|
||||
Returns the (element, obj) pair, with the object selected and active so
|
||||
``try_applying_edit_mode`` reads the same context the TAB-key operator
|
||||
chain would feed it."""
|
||||
tool.Project.get_project_props().template_file = "IFC4 Demo Template.ifc"
|
||||
bpy.ops.bim.create_project()
|
||||
ifc_file = tool.Ifc.get()
|
||||
wall_type = next(t for t in ifc_file.by_type("IfcWallType") if tool.Model.get_usage_type(t) == "LAYER2")
|
||||
bpy.ops.bim.add_occurrence(relating_type_id=wall_type.id())
|
||||
wall = ifc_file.by_type("IfcWall")[0]
|
||||
obj = tool.Ifc.get_object(wall)
|
||||
assert isinstance(obj, bpy.types.Object)
|
||||
tool.Blender.set_objects_selection(bpy.context, obj, (obj,))
|
||||
return wall, obj
|
||||
|
||||
|
||||
class TestTabOnLayer2WallEntersParametricEdit(NewFile):
|
||||
def test_dispatch_enables_wall_edit(self):
|
||||
wall, obj = _add_layer2_wall_occurrence()
|
||||
assert tool.Parametric.is_wall(wall) is True
|
||||
assert obj.BIMWallProperties.is_editing is False
|
||||
|
||||
result = tool.Blender.Modifier.try_applying_edit_mode(obj, wall)
|
||||
|
||||
assert result is True
|
||||
assert obj.BIMWallProperties.is_editing is True
|
||||
|
||||
|
||||
class TestTabOnLayer2WallAlreadyEditingFinishes(NewFile):
|
||||
def test_dispatch_finishes_wall_edit(self):
|
||||
wall, obj = _add_layer2_wall_occurrence()
|
||||
bpy.ops.bim.enable_editing_wall()
|
||||
assert obj.BIMWallProperties.is_editing is True
|
||||
|
||||
result = tool.Blender.Modifier.try_applying_edit_mode(obj, wall)
|
||||
|
||||
assert result is True
|
||||
assert obj.BIMWallProperties.is_editing is False
|
||||
|
||||
|
||||
class TestTabOnNonLayer2WallReturnsFalse(NewFile):
|
||||
def test_dispatch_falls_through_for_wall_without_layer_set_usage(self):
|
||||
"""A wall without ``IfcMaterialLayerSetUsage`` is not a parametric-edit
|
||||
target; the dispatch returns False so the caller routes the TAB to
|
||||
item mode."""
|
||||
wall, obj = _add_layer2_wall_occurrence()
|
||||
ifc_file = tool.Ifc.get()
|
||||
wall_type = ifcopenshell.util.element.get_type(wall)
|
||||
ifcopenshell.api.material.unassign_material(ifc_file, products=[wall, wall_type])
|
||||
assert tool.Parametric.is_wall(wall) is False
|
||||
assert obj.BIMWallProperties.is_editing is False
|
||||
|
||||
result = tool.Blender.Modifier.try_applying_edit_mode(obj, wall)
|
||||
|
||||
assert result is False
|
||||
assert obj.BIMWallProperties.is_editing is False
|
||||
@@ -0,0 +1,70 @@
|
||||
# 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 tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
import pytest
|
||||
|
||||
import bonsai.tool as tool
|
||||
from test.bim.bootstrap import NewFile
|
||||
|
||||
pytestmark = pytest.mark.patch
|
||||
|
||||
|
||||
class TestExecuteIfcPatchDowngradeEndToEnd(NewFile):
|
||||
"""Drives the full panel flow the user sees: load IFC4 in memory, pick
|
||||
Migrate + IFC2X3, click Execute, get an IFC2X3 file on disk with the
|
||||
expected IfcBuildingElementProxy fallback + ObjectType encoding.
|
||||
|
||||
A regression here means a real user clicking Execute either crashes
|
||||
Blender, produces a broken file, or silently drops type information
|
||||
that the recipe is supposed to preserve via ObjectType."""
|
||||
|
||||
def test_ifc4_with_ifclamp_downgrades_to_ifc2x3_with_proxy_and_object_type(self):
|
||||
ifc = ifcopenshell.file(schema="IFC4")
|
||||
ifc.create_entity("IfcLamp", GlobalId="2K6Z3DR8X37AS9XFvX8GcW", PredefinedType="COMPACTFLUORESCENT")
|
||||
tool.Ifc.set(ifc)
|
||||
|
||||
props = tool.Patch.get_patch_props()
|
||||
props.should_load_from_memory = True
|
||||
props.ifc_patch_recipes = "Migrate"
|
||||
next(a for a in props.ifc_patch_args_attr if a.name == "Schema").enum_value = "IFC2X3"
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
output_path = Path(tmpdir) / "downgraded.ifc"
|
||||
props.ifc_patch_output = str(output_path)
|
||||
|
||||
result = bpy.ops.bim.execute_ifc_patch()
|
||||
|
||||
assert result == {"FINISHED"}
|
||||
assert output_path.exists(), "Recipe ran but no output file was written"
|
||||
|
||||
written = ifcopenshell.open(str(output_path))
|
||||
assert written.schema == "IFC2X3"
|
||||
proxies = written.by_type("IfcBuildingElementProxy")
|
||||
assert len(proxies) == 1, "IfcLamp should fall back to a single IfcBuildingElementProxy"
|
||||
assert proxies[0].ObjectType == "IfcLamp/COMPACTFLUORESCENT", (
|
||||
"Original class + PredefinedType must be encoded into ObjectType "
|
||||
"so the downgrade isn't a total information loss"
|
||||
)
|
||||
assert proxies[0].GlobalId == "2K6Z3DR8X37AS9XFvX8GcW"
|
||||
@@ -0,0 +1,131 @@
|
||||
# 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 tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
import pytest
|
||||
|
||||
import bonsai.tool as tool
|
||||
from test.bim.bootstrap import NewFile
|
||||
|
||||
pytestmark = pytest.mark.patch
|
||||
|
||||
|
||||
def _set_patch_state(*, recipe: str, target_schema: str | None, source_ifc: ifcopenshell.file | None = None) -> None:
|
||||
"""Drive the BIMPatchProperties into the configuration that a user produces
|
||||
by picking Recipe + Schema in the panel + checking "Load from memory".
|
||||
Setting the recipe fires UpdateIfcPatchArguments which builds the dynamic
|
||||
args collection — only then can we assign the schema arg's enum_value."""
|
||||
props = tool.Patch.get_patch_props()
|
||||
if source_ifc is not None:
|
||||
tool.Ifc.set(source_ifc)
|
||||
props.should_load_from_memory = True
|
||||
props.ifc_patch_recipes = recipe # update callback builds ifc_patch_args_attr
|
||||
if target_schema is not None:
|
||||
schema_arg = next(a for a in props.ifc_patch_args_attr if a.name == "Schema")
|
||||
schema_arg.enum_value = target_schema
|
||||
|
||||
|
||||
class TestMigrationIsLossyDowngrade(NewFile):
|
||||
"""Pins the predicate that gates ``ExecuteIfcPatch.invoke``'s
|
||||
confirmation popup. Every row of the truth table corresponds to a real
|
||||
user-facing flow — wrong answers either nag the user on safe migrations
|
||||
or silently let lossy ones through with no warning."""
|
||||
|
||||
def test_ifc4_to_ifc2x3_in_memory_is_lossy(self):
|
||||
ifc = ifcopenshell.file(schema="IFC4")
|
||||
_set_patch_state(recipe="Migrate", target_schema="IFC2X3", source_ifc=ifc)
|
||||
assert tool.Patch.migration_is_lossy_downgrade() is True
|
||||
|
||||
def test_ifc4x3_to_ifc2x3_in_memory_is_lossy(self):
|
||||
# Regression for the gate that originally only fired for self.file.schema == "IFC4",
|
||||
# silently leaving IFC4X3 sources crashing on IFC4-only geometry.
|
||||
ifc = ifcopenshell.file(schema="IFC4X3")
|
||||
_set_patch_state(recipe="Migrate", target_schema="IFC2X3", source_ifc=ifc)
|
||||
assert tool.Patch.migration_is_lossy_downgrade() is True
|
||||
|
||||
def test_ifc2x3_to_ifc4_upgrade_is_not_lossy(self):
|
||||
ifc = ifcopenshell.file(schema="IFC2X3")
|
||||
_set_patch_state(recipe="Migrate", target_schema="IFC4", source_ifc=ifc)
|
||||
assert tool.Patch.migration_is_lossy_downgrade() is False
|
||||
|
||||
def test_ifc4_to_ifc4_same_schema_is_not_lossy(self):
|
||||
ifc = ifcopenshell.file(schema="IFC4")
|
||||
_set_patch_state(recipe="Migrate", target_schema="IFC4", source_ifc=ifc)
|
||||
assert tool.Patch.migration_is_lossy_downgrade() is False
|
||||
|
||||
def test_non_migrate_recipe_is_not_lossy(self):
|
||||
# The popup only ever applies to the Migrate recipe — other recipes
|
||||
# (ExtractElements, TessellateElements, …) handle their own warnings.
|
||||
ifc = ifcopenshell.file(schema="IFC4")
|
||||
_set_patch_state(recipe="ExtractElements", target_schema=None, source_ifc=ifc)
|
||||
assert tool.Patch.migration_is_lossy_downgrade() is False
|
||||
|
||||
def test_no_source_set_is_not_lossy(self):
|
||||
# Without an input file or in-memory IFC, the predicate cannot tell
|
||||
# what the source schema is — defaults to False so the popup doesn't
|
||||
# block harmless cases where the user is still configuring the panel.
|
||||
props = tool.Patch.get_patch_props()
|
||||
props.ifc_patch_recipes = "Migrate"
|
||||
schema_arg = next(a for a in props.ifc_patch_args_attr if a.name == "Schema")
|
||||
schema_arg.enum_value = "IFC2X3"
|
||||
assert tool.Patch.migration_is_lossy_downgrade() is False
|
||||
|
||||
|
||||
class TestPatchSourceSchemaSniff(NewFile):
|
||||
"""End-to-end pin on the header-only schema parsing. The IFC4X3 misdetection
|
||||
bug originally lived in this code path — a raw startswith(\"IFC4\") loop
|
||||
matching IFC4X3_ADD2 before the IFC4X3 base check was reached."""
|
||||
|
||||
def test_in_memory_ifc4x3_source_resolves_to_ifc4x3(self):
|
||||
ifc = ifcopenshell.file(schema="IFC4X3")
|
||||
tool.Ifc.set(ifc)
|
||||
props = tool.Patch.get_patch_props()
|
||||
props.should_load_from_memory = True
|
||||
assert tool.Patch._patch_source_schema() == "IFC4X3"
|
||||
|
||||
def test_file_path_ifc4x3_add2_source_resolves_to_ifc4x3(self):
|
||||
# Writes a real .ifc file with IFC4X3_ADD2 in the FILE_SCHEMA header
|
||||
# and confirms the regex + get_fallback_schema normaliser correctly
|
||||
# collapse it to IFC4X3, not IFC4.
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
ifc_path = Path(tmpdir) / "sample.ifc"
|
||||
ifc_path.write_text(
|
||||
"ISO-10303-21;\n"
|
||||
"HEADER;\n"
|
||||
"FILE_DESCRIPTION((''),'2;1');\n"
|
||||
"FILE_NAME('','2026',(''),(''),'','','');\n"
|
||||
"FILE_SCHEMA(('IFC4X3_ADD2'));\n"
|
||||
"ENDSEC;\n"
|
||||
"DATA;\nENDSEC;\nEND-ISO-10303-21;\n"
|
||||
)
|
||||
props = tool.Patch.get_patch_props()
|
||||
props.should_load_from_memory = False
|
||||
props.ifc_patch_input = str(ifc_path)
|
||||
assert tool.Patch._patch_source_schema() == "IFC4X3"
|
||||
|
||||
def test_missing_input_returns_empty_string(self):
|
||||
props = tool.Patch.get_patch_props()
|
||||
props.should_load_from_memory = False
|
||||
props.ifc_patch_input = ""
|
||||
assert tool.Patch._patch_source_schema() == ""
|
||||
@@ -0,0 +1,55 @@
|
||||
# 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
|
||||
import pytest
|
||||
|
||||
import bonsai.tool as tool
|
||||
from test.bim.bootstrap import NewFile
|
||||
|
||||
pytestmark = pytest.mark.patch
|
||||
|
||||
|
||||
class TestPresetMenuLabelResetsOnRecipeChange(NewFile):
|
||||
"""Blender's ``script.execute_preset`` mutates the menu class's bl_label
|
||||
to the loaded preset's display name as a "currently-selected" indicator.
|
||||
Without a recipe-change callback, that label persists into the next
|
||||
recipe's menu — falsely advertising a preset that belongs to a
|
||||
different recipe's subdir and isn't selectable from the new menu."""
|
||||
|
||||
def test_changing_recipe_restores_canonical_label(self):
|
||||
# Simulate the state Blender leaves after the user picked a preset
|
||||
# for the previous recipe.
|
||||
menu_cls = bpy.types.BIM_MT_ifc_patch_presets
|
||||
menu_cls.bl_label = "Structural"
|
||||
|
||||
# Switching the recipe must fire update_ifc_patch_recipe, which
|
||||
# resets the menu label.
|
||||
props = tool.Patch.get_patch_props()
|
||||
props.ifc_patch_recipes = "Migrate"
|
||||
|
||||
assert menu_cls.bl_label == "IFC Patch Presets"
|
||||
|
||||
def test_canonical_label_is_used_when_no_preset_was_loaded(self):
|
||||
# Fresh state — label is the bl_label-default from the class declaration.
|
||||
menu_cls = bpy.types.BIM_MT_ifc_patch_presets
|
||||
props = tool.Patch.get_patch_props()
|
||||
props.ifc_patch_recipes = "ExtractElements"
|
||||
assert menu_cls.bl_label == "IFC Patch Presets"
|
||||
@@ -0,0 +1,104 @@
|
||||
# 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.
|
||||
|
||||
"""Forward-compat AST contracts for the class-mismatched-type-assignment guard.
|
||||
|
||||
Two structural invariants that no behavioural test can pin on its own:
|
||||
|
||||
1. ``ifcopenshell.api.type.assign_type`` MUST reference
|
||||
``ifcopenshell.util.type.get_applicable_entities`` (or
|
||||
``get_applicable_types``) — the schema-aware applicability lookup that
|
||||
produces the canonical class-pairing whitelist. A drift here means the
|
||||
API stops rejecting class-mismatched pairs.
|
||||
|
||||
2. ``bonsai.bim.module.type.operator`` MUST reference
|
||||
``tool.Type.is_relating_type_compatible`` — the single source of truth
|
||||
for partition / WARNING / CANCELLED behaviour in the Bonsai operator
|
||||
layer. A drift here re-opens the fan-out hole that silently writes
|
||||
schema-corrupt typings into the selection when one (active) object's
|
||||
class drove the picker but other selected objects don't match.
|
||||
"""
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.type
|
||||
|
||||
|
||||
BONSAI_ROOT = Path(__file__).resolve().parents[4] / "bonsai"
|
||||
IFCOPENSHELL_API_ASSIGN_TYPE = (
|
||||
Path(__file__).resolve().parents[5] / "ifcopenshell-python" / "ifcopenshell" / "api" / "type" / "assign_type.py"
|
||||
)
|
||||
BONSAI_TYPE_OPERATOR = BONSAI_ROOT / "bim" / "module" / "type" / "operator.py"
|
||||
|
||||
|
||||
def _attribute_chain(node: ast.AST) -> str:
|
||||
"""Render an ``ast.Attribute``/``ast.Name`` chain as a dotted string,
|
||||
e.g. ``tool.Type.is_relating_type_compatible``. Returns ``""`` if the
|
||||
chain bottoms out on something other than a Name (e.g. a subscript)."""
|
||||
parts: list[str] = []
|
||||
while isinstance(node, ast.Attribute):
|
||||
parts.append(node.attr)
|
||||
node = node.value
|
||||
if isinstance(node, ast.Name):
|
||||
parts.append(node.id)
|
||||
return ".".join(reversed(parts))
|
||||
return ""
|
||||
|
||||
|
||||
def _all_attribute_chains(tree: ast.Module) -> set[str]:
|
||||
chains: set[str] = set()
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Attribute):
|
||||
chain = _attribute_chain(node)
|
||||
if chain:
|
||||
chains.add(chain)
|
||||
return chains
|
||||
|
||||
|
||||
def test_api_assign_type_calls_applicability_lookup() -> None:
|
||||
"""Pin Layer B: ``ifcopenshell.api.type.assign_type`` references
|
||||
``ifcopenshell.util.type.get_applicable_entities`` (the source of truth
|
||||
for which occurrence classes a given type class may type)."""
|
||||
tree = ast.parse(IFCOPENSHELL_API_ASSIGN_TYPE.read_text(encoding="utf-8"))
|
||||
chains = _all_attribute_chains(tree)
|
||||
sentinel = "ifcopenshell.util.type.get_applicable_entities"
|
||||
assert sentinel in chains, (
|
||||
f"{IFCOPENSHELL_API_ASSIGN_TYPE.name} no longer references {sentinel}. "
|
||||
"The API-layer guard against class-mismatched type assignment is gone."
|
||||
)
|
||||
|
||||
|
||||
def test_bonsai_type_operator_module_references_compatibility_helper() -> None:
|
||||
"""Pin Layer C: the Bonsai type operator module references
|
||||
``tool.Type.is_relating_type_compatible``. Every operator in this file
|
||||
that fans assign_type calls across a multi-selection must filter
|
||||
through this helper to avoid writing mismatched typings on objects the
|
||||
panel picker didn't validate."""
|
||||
tree = ast.parse(BONSAI_TYPE_OPERATOR.read_text(encoding="utf-8"))
|
||||
chains = _all_attribute_chains(tree)
|
||||
sentinel = "tool.Type.is_relating_type_compatible"
|
||||
assert sentinel in chains, (
|
||||
f"{BONSAI_TYPE_OPERATOR.name} no longer references {sentinel}. "
|
||||
"Operator-layer partition that prevents schema-illegal type "
|
||||
"assignment across multi-selection has been removed."
|
||||
)
|
||||
@@ -0,0 +1,176 @@
|
||||
# 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 Lesser 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.
|
||||
|
||||
"""Contract test for BIM_OT_assign_type's per-object class-compatibility
|
||||
partition.
|
||||
|
||||
When the user multi-selects mixed classes (e.g. a wall + a door), the panel
|
||||
picker filters the class dropdown by the active object's class only.
|
||||
Historically the operator then fanned out across the whole selection without
|
||||
re-checking each occurrence, producing schema-corrupt IFC files (IfcDoor
|
||||
typed by IfcWallType). The partition added in this change must:
|
||||
|
||||
1. Assign the type only to compatible occurrences.
|
||||
2. Surface skipped classes through ``self.report({'WARNING'}, ...)``.
|
||||
3. ``return {'CANCELLED'}`` and emit an ERROR when nothing in the selection
|
||||
is compatible — no mutation must reach ``core.assign_type``.
|
||||
"""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.type
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _require_real_bpy():
|
||||
import types as _types
|
||||
|
||||
import bpy
|
||||
|
||||
if not isinstance(bpy, _types.ModuleType) or hasattr(bpy, "_mock_name"):
|
||||
pytest.skip("requires real Blender (bpy is mocked or absent)")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fresh_ifc():
|
||||
import ifcopenshell
|
||||
|
||||
from bonsai.bim.ifc import IfcStore
|
||||
|
||||
previous = IfcStore.file
|
||||
IfcStore.file = ifcopenshell.file(schema="IFC4")
|
||||
try:
|
||||
yield IfcStore.file
|
||||
finally:
|
||||
IfcStore.file = previous
|
||||
|
||||
|
||||
def _make_object(name, element):
|
||||
"""Build a real bpy.types.Object linked to an IFC entity via
|
||||
tool.Ifc.link, so tool.Ifc.get_entity(obj) resolves correctly."""
|
||||
import bpy
|
||||
|
||||
import bonsai.tool as tool
|
||||
|
||||
obj = bpy.data.objects.new(name, None)
|
||||
tool.Ifc.link(element, obj)
|
||||
return obj
|
||||
|
||||
|
||||
def _execute_assign(op, context):
|
||||
"""Drive ``AssignType._execute`` directly. Bypasses the framework's
|
||||
transaction wrapping so a unit test can observe the partition without
|
||||
setting up the full Blender harness."""
|
||||
return op._execute(context)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def neutralised_side_effects():
|
||||
"""Patch the helpers ``AssignType._execute`` calls outside the partition
|
||||
logic (addon prefs, drawing context lookup, drawing target-view branch),
|
||||
so the test asserts only the partition / report / return-code contract."""
|
||||
with mock.patch("bonsai.bim.module.type.operator.tool.Blender.get_addon_preferences") as prefs:
|
||||
prefs.return_value = mock.Mock(occurrence_name_style="OCCURRENCE")
|
||||
yield
|
||||
|
||||
|
||||
def _build_context_with_no_active_drawing():
|
||||
"""Return a Mock ``context`` whose ``scene.DocProperties.active_drawing_id``
|
||||
is 0, skipping the drawing-target-view block in ``_execute``."""
|
||||
context = mock.Mock()
|
||||
context.scene.DocProperties.active_drawing_id = 0
|
||||
return context
|
||||
|
||||
|
||||
def _fake_operator_with_report():
|
||||
"""Build a Mock that satisfies the attribute reads ``AssignType._execute``
|
||||
makes on ``self`` (``relating_type``, ``related_object``, ``report``)."""
|
||||
op = mock.MagicMock()
|
||||
op.relating_type = 0
|
||||
op.related_object = ""
|
||||
op.report = mock.Mock()
|
||||
return op
|
||||
|
||||
|
||||
def test_mixed_selection_assigns_only_compatible_objects(fresh_ifc, neutralised_side_effects):
|
||||
"""Wall + door selected, IfcWallType picked: wall gets typed, door is
|
||||
skipped with a WARNING, and the operator returns success.
|
||||
|
||||
``core.assign_type`` is mocked: it would otherwise run the
|
||||
representation-switch and material plumbing on stub Blender objects.
|
||||
The contract under test is the partition / report logic, not the
|
||||
downstream representation pipeline."""
|
||||
import ifcopenshell.api.root
|
||||
|
||||
from bonsai.bim.module.type.operator import AssignType
|
||||
|
||||
wall_elem = ifcopenshell.api.root.create_entity(fresh_ifc, ifc_class="IfcWall")
|
||||
door_elem = ifcopenshell.api.root.create_entity(fresh_ifc, ifc_class="IfcDoor")
|
||||
wall_type = ifcopenshell.api.root.create_entity(fresh_ifc, ifc_class="IfcWallType")
|
||||
|
||||
wall_obj = _make_object("Wall", wall_elem)
|
||||
door_obj = _make_object("Door", door_elem)
|
||||
|
||||
op = _fake_operator_with_report()
|
||||
op.relating_type = wall_type.id()
|
||||
|
||||
with mock.patch(
|
||||
"bonsai.bim.module.type.operator.tool.Blender.get_selected_objects", return_value=[wall_obj, door_obj]
|
||||
), mock.patch("bonsai.bim.module.type.operator.core.assign_type") as mock_assign:
|
||||
result = AssignType._execute(op, _build_context_with_no_active_drawing())
|
||||
|
||||
assert result != {"CANCELLED"}, "operator must succeed when at least one object is compatible"
|
||||
typed_elements = {call.kwargs["element"] for call in mock_assign.call_args_list}
|
||||
assert typed_elements == {
|
||||
wall_elem
|
||||
}, f"only the compatible wall element should reach core.assign_type, got {typed_elements}"
|
||||
|
||||
warning_calls = [c for c in op.report.call_args_list if c.args[0] == {"WARNING"}]
|
||||
assert warning_calls, "skipped occurrence class must surface as a WARNING"
|
||||
assert any("IfcDoor" in c.args[1] for c in warning_calls)
|
||||
|
||||
|
||||
def test_all_incompatible_selection_returns_cancelled_without_mutation(fresh_ifc, neutralised_side_effects):
|
||||
"""Door alone selected, IfcWallType picked: nothing to assign. Operator
|
||||
must return CANCELLED, emit an ERROR, and never call core.assign_type."""
|
||||
import ifcopenshell.api.root
|
||||
import ifcopenshell.util.element
|
||||
|
||||
from bonsai.bim.module.type.operator import AssignType
|
||||
|
||||
door_elem = ifcopenshell.api.root.create_entity(fresh_ifc, ifc_class="IfcDoor")
|
||||
wall_type = ifcopenshell.api.root.create_entity(fresh_ifc, ifc_class="IfcWallType")
|
||||
door_obj = _make_object("Door", door_elem)
|
||||
|
||||
op = _fake_operator_with_report()
|
||||
op.relating_type = wall_type.id()
|
||||
|
||||
with mock.patch(
|
||||
"bonsai.bim.module.type.operator.tool.Blender.get_selected_objects", return_value=[door_obj]
|
||||
), mock.patch("bonsai.bim.module.type.operator.core.assign_type") as mock_assign:
|
||||
result = AssignType._execute(op, _build_context_with_no_active_drawing())
|
||||
|
||||
assert result == {"CANCELLED"}
|
||||
assert mock_assign.call_count == 0
|
||||
error_calls = [c for c in op.report.call_args_list if c.args[0] == {"ERROR"}]
|
||||
assert error_calls, "all-incompatible selection must surface as an ERROR"
|
||||
assert ifcopenshell.util.element.get_type(door_elem) is None
|
||||
@@ -0,0 +1,86 @@
|
||||
# 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.
|
||||
|
||||
"""Forward-compat AST contract for viewport decorator lifecycle.
|
||||
|
||||
Any class that tracks Blender draw handlers via a class-level ``handlers``
|
||||
list MUST subclass ``tool.Blender.ViewportDecorator``. The base sets
|
||||
``handlers = []`` and ``is_installed = False`` via ``__init_subclass__`` and
|
||||
provides install / uninstall with the correct ``cls.handlers.clear()``.
|
||||
A class that declares its own ``handlers = []`` outside the base duplicates
|
||||
the lifecycle and is at risk of regressing the handler-clear bug class."""
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.contract_guard
|
||||
|
||||
ADDON_ROOT = Path(__file__).parent.parent.parent / "bonsai"
|
||||
DECORATORS_GLOB = "bim/module/**/decorator.py"
|
||||
|
||||
|
||||
def _is_empty_handlers_list(target: ast.expr, value: ast.expr | None) -> bool:
|
||||
return isinstance(target, ast.Name) and target.id == "handlers" and isinstance(value, ast.List) and not value.elts
|
||||
|
||||
|
||||
def _has_handlers_list_class_attr(class_node: ast.ClassDef) -> bool:
|
||||
for node in class_node.body:
|
||||
if isinstance(node, ast.Assign):
|
||||
for target in node.targets:
|
||||
if _is_empty_handlers_list(target, node.value):
|
||||
return True
|
||||
elif isinstance(node, ast.AnnAssign):
|
||||
if _is_empty_handlers_list(node.target, node.value):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _subclasses_viewport_decorator(class_node: ast.ClassDef) -> bool:
|
||||
for base in class_node.bases:
|
||||
if isinstance(base, ast.Name) and base.id.endswith("ViewportDecorator"):
|
||||
return True
|
||||
if isinstance(base, ast.Attribute) and base.attr.endswith("ViewportDecorator"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def test_no_decorator_class_duplicates_viewport_lifecycle() -> None:
|
||||
offenders: list[str] = []
|
||||
for path in sorted(ADDON_ROOT.glob(DECORATORS_GLOB)):
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.ClassDef):
|
||||
continue
|
||||
if not _has_handlers_list_class_attr(node):
|
||||
continue
|
||||
if _subclasses_viewport_decorator(node):
|
||||
continue
|
||||
rel = path.relative_to(ADDON_ROOT)
|
||||
offenders.append(f"{rel.as_posix()}:{node.lineno}: class {node.name}")
|
||||
if offenders:
|
||||
listing = "\n ".join(offenders)
|
||||
pytest.fail(
|
||||
"Class(es) declare ``handlers = []`` at class scope without subclassing "
|
||||
"``tool.Blender.ViewportDecorator``. Migrate to the canonical viewport-lifecycle "
|
||||
"base (which sets handlers/is_installed via __init_subclass__ and provides "
|
||||
"install/uninstall with the correct cls.handlers.clear()):\n " + listing
|
||||
)
|
||||
@@ -123,6 +123,31 @@ def test_every_predicate_does_not_raise_on_non_matching_element(registry):
|
||||
)
|
||||
|
||||
|
||||
def test_default_parameters_field_per_registry_entry_with_defaults(registry):
|
||||
"""Every entry flagged ``has_default_parameters=True`` must have a matching
|
||||
``<name>: PointerProperty`` field on ``ui.DefaultParameters`` pointing at
|
||||
its ``BIM<Name>Properties`` class.
|
||||
|
||||
The addon-preferences ``Default Parameters`` panel iterates flagged entries
|
||||
to render per-type defaults sections, and the matching create operator
|
||||
(``bim.add_door``, ``bim.add_window``, …) reads the field to seed new
|
||||
instances from the user's preset values. A missing field means the preset
|
||||
silently never reaches the operator.
|
||||
|
||||
Entries WITHOUT the flag are not required to appear — the contract is
|
||||
one-directional: ``has_default_parameters=True`` implies a field, but
|
||||
``False`` allows absence."""
|
||||
from bonsai.bim import ui
|
||||
|
||||
annotations = getattr(ui.DefaultParameters, "__annotations__", {})
|
||||
missing = [e.name for e in registry if e.has_default_parameters and e.name not in annotations]
|
||||
assert not missing, (
|
||||
f"ui.DefaultParameters missing PointerProperty field(s) for: {missing} — "
|
||||
f"each EDIT_TYPES entry with has_default_parameters=True must have a matching "
|
||||
f"<name>: PointerProperty(type=BIM<Name>Properties) field"
|
||||
)
|
||||
|
||||
|
||||
def test_gizmo_preferences_field_per_registry_entry(registry):
|
||||
"""Every registry entry must have a matching ``<name>: BoolProperty`` field
|
||||
on ``ui.GizmoPreferences`` so the addon-preferences UI auto-renders a
|
||||
|
||||
@@ -60,8 +60,14 @@ class TestDisconnectRelPath:
|
||||
|
||||
with patch("bonsai.core.connection.bonsai.core.geometry.remove_connection") as remove:
|
||||
subject.disconnect_rel(
|
||||
ifc, geometry, model, connection,
|
||||
rel="rel", kind="path", elem="elem_a", partner="elem_b",
|
||||
ifc,
|
||||
geometry,
|
||||
model,
|
||||
connection,
|
||||
subject="rel",
|
||||
kind="path",
|
||||
elem="elem_a",
|
||||
partner="elem_b",
|
||||
)
|
||||
|
||||
remove.assert_called_once_with(geometry, connection="rel")
|
||||
@@ -78,8 +84,14 @@ class TestDisconnectRelPath:
|
||||
|
||||
with patch("bonsai.core.connection.bonsai.core.geometry.remove_connection"):
|
||||
subject.disconnect_rel(
|
||||
ifc, geometry, model, connection,
|
||||
rel="rel", kind="path", elem="elem", partner="partner",
|
||||
ifc,
|
||||
geometry,
|
||||
model,
|
||||
connection,
|
||||
subject="rel",
|
||||
kind="path",
|
||||
elem="elem",
|
||||
partner="partner",
|
||||
skip_elem_recreate=True,
|
||||
)
|
||||
|
||||
@@ -93,8 +105,14 @@ class TestDisconnectRelPath:
|
||||
|
||||
with patch("bonsai.core.connection.bonsai.core.geometry.remove_connection"):
|
||||
subject.disconnect_rel(
|
||||
ifc, geometry, model, connection,
|
||||
rel="rel", kind="path", elem="elem", partner="partner",
|
||||
ifc,
|
||||
geometry,
|
||||
model,
|
||||
connection,
|
||||
subject="rel",
|
||||
kind="path",
|
||||
elem="elem",
|
||||
partner="partner",
|
||||
skip_partner_recreate=True,
|
||||
)
|
||||
|
||||
@@ -108,8 +126,14 @@ class TestDisconnectRelPath:
|
||||
|
||||
with patch("bonsai.core.connection.bonsai.core.geometry.remove_connection") as remove:
|
||||
subject.disconnect_rel(
|
||||
ifc, geometry, model, connection,
|
||||
rel="rel", kind="path", elem="elem", partner="partner",
|
||||
ifc,
|
||||
geometry,
|
||||
model,
|
||||
connection,
|
||||
subject="rel",
|
||||
kind="path",
|
||||
elem="elem",
|
||||
partner="partner",
|
||||
skip_elem_recreate=True,
|
||||
skip_partner_recreate=True,
|
||||
)
|
||||
@@ -131,13 +155,17 @@ class TestDisconnectRelElementTop:
|
||||
|
||||
with patch("bonsai.core.connection.regenerate_wall_to_underside") as regen:
|
||||
subject.disconnect_rel(
|
||||
ifc, geometry, model, connection,
|
||||
rel=rel, kind="element-top", elem="elem", partner="partner",
|
||||
ifc,
|
||||
geometry,
|
||||
model,
|
||||
connection,
|
||||
subject=rel,
|
||||
kind="element-top",
|
||||
elem="elem",
|
||||
partner="partner",
|
||||
)
|
||||
|
||||
ifc.run.assert_called_once_with(
|
||||
"geometry.disconnect_element", relating_element="slab", related_element="wall"
|
||||
)
|
||||
ifc.run.assert_called_once_with("geometry.disconnect_element", relating_element="slab", related_element="wall")
|
||||
regen.assert_called_once_with(ifc, geometry, model, ["wall_obj"])
|
||||
|
||||
def test_slab_delete_cascade_still_regenerates_wall(self):
|
||||
@@ -150,8 +178,14 @@ class TestDisconnectRelElementTop:
|
||||
|
||||
with patch("bonsai.core.connection.regenerate_wall_to_underside") as regen:
|
||||
subject.disconnect_rel(
|
||||
ifc, Mock(), Mock(), connection,
|
||||
rel=rel, kind="element-top", elem="slab", partner="wall",
|
||||
ifc,
|
||||
Mock(),
|
||||
Mock(),
|
||||
connection,
|
||||
subject=rel,
|
||||
kind="element-top",
|
||||
elem="slab",
|
||||
partner="wall",
|
||||
skip_elem_recreate=True, # slab is being deleted
|
||||
)
|
||||
|
||||
@@ -167,8 +201,14 @@ class TestDisconnectRelElementTop:
|
||||
|
||||
with patch("bonsai.core.connection.regenerate_wall_to_underside") as regen:
|
||||
subject.disconnect_rel(
|
||||
ifc, Mock(), Mock(), connection,
|
||||
rel=rel, kind="element-top", elem="wall", partner="slab",
|
||||
ifc,
|
||||
Mock(),
|
||||
Mock(),
|
||||
connection,
|
||||
subject=rel,
|
||||
kind="element-top",
|
||||
elem="wall",
|
||||
partner="slab",
|
||||
skip_elem_recreate=True, # wall is being deleted
|
||||
)
|
||||
|
||||
@@ -185,8 +225,14 @@ class TestDisconnectRelElementTop:
|
||||
|
||||
with patch("bonsai.core.connection.regenerate_wall_to_underside") as regen:
|
||||
subject.disconnect_rel(
|
||||
ifc, Mock(), Mock(), connection,
|
||||
rel=rel, kind="element-top", elem="slab", partner="wall",
|
||||
ifc,
|
||||
Mock(),
|
||||
Mock(),
|
||||
connection,
|
||||
subject=rel,
|
||||
kind="element-top",
|
||||
elem="slab",
|
||||
partner="wall",
|
||||
skip_elem_recreate=True,
|
||||
skip_partner_recreate=True, # wall also in batch
|
||||
)
|
||||
@@ -200,19 +246,115 @@ class TestDisconnectRelElement:
|
||||
ifc = Mock()
|
||||
|
||||
subject.disconnect_rel(
|
||||
ifc, Mock(), Mock(), Mock(),
|
||||
rel=rel, kind="element", elem="elem_a", partner="elem_b",
|
||||
ifc,
|
||||
Mock(),
|
||||
Mock(),
|
||||
Mock(),
|
||||
subject=rel,
|
||||
kind="element",
|
||||
elem="elem_a",
|
||||
partner="elem_b",
|
||||
)
|
||||
|
||||
ifc.run.assert_called_once_with(
|
||||
"geometry.disconnect_element", relating_element="A", related_element="B"
|
||||
ifc.run.assert_called_once_with("geometry.disconnect_element", relating_element="A", related_element="B")
|
||||
|
||||
|
||||
class TestDisconnectRelMEPPairFitting:
|
||||
"""The ``mep-pair-fitting`` kind treats the rel slot as the fitting whose
|
||||
removal disconnects the pair — deletion routes through
|
||||
``geometry.delete_ifc_object`` so the cascade-on-delete contract still
|
||||
owns port-rel cleanup."""
|
||||
|
||||
def test_deletes_fitting_via_delete_ifc_object(self):
|
||||
fitting = Mock(name="fitting")
|
||||
fitting_obj = Mock(name="fitting_obj")
|
||||
ifc = _ifc_with_objects({fitting: fitting_obj})
|
||||
geometry = Mock()
|
||||
|
||||
subject.disconnect_rel(
|
||||
ifc,
|
||||
geometry,
|
||||
Mock(),
|
||||
Mock(),
|
||||
subject=fitting,
|
||||
kind="mep-pair-fitting",
|
||||
elem="seg_a",
|
||||
partner="seg_b",
|
||||
)
|
||||
|
||||
geometry.delete_ifc_object.assert_called_once_with(fitting_obj)
|
||||
|
||||
def test_noops_when_fitting_has_no_blender_object(self):
|
||||
"""Defensive: a fitting with no bound Blender object can't be
|
||||
deleted via ``delete_ifc_object``; the dispatch must not crash."""
|
||||
fitting = Mock(name="fitting")
|
||||
ifc = _ifc_with_objects({})
|
||||
geometry = Mock()
|
||||
|
||||
subject.disconnect_rel(
|
||||
ifc,
|
||||
geometry,
|
||||
Mock(),
|
||||
Mock(),
|
||||
subject=fitting,
|
||||
kind="mep-pair-fitting",
|
||||
elem="seg_a",
|
||||
partner="seg_b",
|
||||
)
|
||||
|
||||
geometry.delete_ifc_object.assert_not_called()
|
||||
|
||||
def test_skip_elem_recreate_suppresses_delete_when_fitting_is_elem(self):
|
||||
"""Cascade case: the fitting is itself the element being deleted
|
||||
— don't try to delete it twice."""
|
||||
fitting = Mock(name="fitting")
|
||||
ifc = _ifc_with_objects({fitting: Mock()})
|
||||
geometry = Mock()
|
||||
|
||||
subject.disconnect_rel(
|
||||
ifc,
|
||||
geometry,
|
||||
Mock(),
|
||||
Mock(),
|
||||
subject=fitting,
|
||||
kind="mep-pair-fitting",
|
||||
elem=fitting,
|
||||
partner="other",
|
||||
skip_elem_recreate=True,
|
||||
)
|
||||
|
||||
geometry.delete_ifc_object.assert_not_called()
|
||||
|
||||
def test_skip_partner_recreate_suppresses_delete_when_fitting_is_partner(self):
|
||||
fitting = Mock(name="fitting")
|
||||
ifc = _ifc_with_objects({fitting: Mock()})
|
||||
geometry = Mock()
|
||||
|
||||
subject.disconnect_rel(
|
||||
ifc,
|
||||
geometry,
|
||||
Mock(),
|
||||
Mock(),
|
||||
subject=fitting,
|
||||
kind="mep-pair-fitting",
|
||||
elem="seg_a",
|
||||
partner=fitting,
|
||||
skip_partner_recreate=True,
|
||||
)
|
||||
|
||||
geometry.delete_ifc_object.assert_not_called()
|
||||
|
||||
|
||||
class TestDisconnectRelUnknownKind:
|
||||
def test_raises_value_error(self):
|
||||
with pytest.raises(ValueError, match="Unknown rel kind"):
|
||||
with pytest.raises(ValueError, match="Unknown kind"):
|
||||
subject.disconnect_rel(
|
||||
Mock(), Mock(), Mock(), Mock(),
|
||||
rel="rel", kind="bogus", elem="a", partner="b",
|
||||
Mock(),
|
||||
Mock(),
|
||||
Mock(),
|
||||
Mock(),
|
||||
subject="rel",
|
||||
kind="bogus",
|
||||
elem="a",
|
||||
partner="b",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
Index,Identification,Name,Unit,Value,Quantity
|
||||
1,E.01,Walls,m3,,
|
||||
2,E.01.01,Ground floor walls,m3,100,42
|
||||
2,E.01.02,First floor walls,m3,200,35
|
||||
1,A.02,Paintings,m2,,
|
||||
2,A.03,Paintings with water,m2,,
|
||||
3,B.05,White paintings,m2,25,45
|
||||
3,B.06,Colored paintings,m2,32,33
|
||||
2,C-01,Paintings with machine,m2,17,133
|
||||
2,C-02,Decorated paintings,m2,40,8
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user