Compare commits

..

45 Commits

Author SHA1 Message Date
Ryan Schultz 8ece3790aa Update opening-template dev-note
Reflect void-propagation-to-all-occurrences and adjusted-extrusion
preservation added to #8200 since the note was seeded.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 18:56:26 -05:00
Ryan Schultz 6585f0ee2b Add dev-notes convention for feature branches
Introduce docs/dev-notes/ for living design notes on unmerged feature
branches (one Markdown file per branch), so collaborators and the AI agents
they work with can pick up a branch's context from the diff. Documented in
AGENTS.md and a directory README; seeded with the opening-template-on-type
note.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 18:56:26 -05:00
Ryan Schultz 4ec042595e Preserve adjusted extrusion openings on duplicate
promote_opening_to_type now preserves an extrusion opening that was manually
adjusted away from the default - detected by comparing its bounding box to a
freshly generated default - not only non-extrusion geometry. The generate-and-
compare check is scoped to the duplicate path via should_preserve_opening.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 15:22:33 -05:00
Ryan Schultz 48f6e2908b Propagate edited void to all type occurrences
update_type_template_from_opening now re-maps every occurrence's opening onto
the type's Reference template (not only ones already sharing its map) and
reloads the affected host walls, so editing one void updates all instances
even when their openings were independent.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 15:22:33 -05:00
Ryan Schultz 1cd7e52c49 Helps with #7853: Select objects by RepresentationType from panel
Clicking the RepresentationType label in the Representations
panel selects all visible objects whose active representation
matches that type. Ctrl+Click broadens the selection to any
object that has the type in any of its representations,
whether currently active or not.

Generated with the assistance of an AI coding tool.
2026-07-05 15:22:33 -05:00
Ryan Schultz 2e6f17ed0f Preserve custom opening geometry via a type-level Reference template
Custom IfcOpeningElement voids (e.g. an IfcPolygonalFaceSet / tessellation)
were lost - reset to a default extrusion - on bim.duplicate_type, project
append, and type switching, because the void lived only on occurrences and
nothing carried it to a new type.

Anchor the shared opening body on the filling type as a 'Reference'
representation map (per IfcShapeRepresentation, 'Reference' is geometry "not
part of the Body representation", used for opening geometries excluded from an
implicit Boolean operation). bim.duplicate_type and append copy a type's
RepresentationMaps, so the template survives; generate_opening_from_filling
consults it before falling back to a generated extrusion.

- map_type_representations: skip 'Reference' maps so occurrences don't receive
  the opening shape as their own Body geometry.
- opening.py: get_/set_type_opening_representation, promote_opening_to_type,
  update_type_template_from_opening; pre/post type.assign_type listeners
  (anchor the old type's void before a switch; regenerate to the assigned
  type's void afterwards, replacing the previous "preserve custom" guard).
- DuplicateType promotes the void before copy; AppendLibraryElement harvests
  the template cross-file from a library instance.
- Write-back on void edit, hooked at both commit paths (UpdateRepresentation
  and OverrideModeSetObject).
- reimport_element_representations renders the requested representation, so
  switching a type to its Reference row shows the void rather than the body.
- Representations panel shows RepresentationIdentifier plus column headers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 15:21:49 -05:00
Bruno Postle b549e65ad9 bonsai: restore descriptive name for test_copy_with_new_geometry_copied_from_the_old
The underlying bug (has_material_styles bypassing the tool layer) was
already fixed by e76455913, which added the required mock expectation
here, but left the test under its quarantine placeholder name
test_AAAAAAAAAAAA. Restore the real name now that it genuinely passes.
2026-07-04 17:38:13 +01:00
Bruno Postle ee5d672493 tests: fix test_memusage_partial_open and add psutil to CI
test_memusage_partial_open was silently skipped in CI (psutil was
never installed there). Add psutil so it actually runs, and run the
RSS measurement in a subprocess so the fixture file isn't already in
the page cache from earlier tests, which was making both deltas read
as zero.

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

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

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

Generated with the assistance of an AI coding tool.
2026-07-04 09:34:05 +01:00
Bruno Postle 5db955d40c Apply link matrix when serialising linked drawings
Linked IFC files were included in SVG output but without their
world transform, causing geometry to appear at wrong coordinates.
Falls back to no transform if the link cache is unavailable.

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

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

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

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

Closes #8219

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes #8215.

Generated with the assistance of an AI coding tool.
2026-07-02 09:02:02 +02:00
Massimo Fabbro de65e50fb5 See #6570. Formula column other improvements 2026-07-02 08:40:58 +02:00
Massimo Fabbro 714105b9fd See #6570. Tests for import cost schedule from csv and minor fix 2026-07-02 08:40:58 +02:00
Massimo Fabbro f0b5ab860f See #6570. Formula column minor improvements and documentation 2026-07-02 08:40:58 +02:00
Massimo Fabbro 528964ca56 See #6570. Formula column for ifc5d import from csv
Now it's possible to specify the Formula column in the csv in order to calculate cost item quantities
2026-07-02 08:40:58 +02:00
Massimo Fabbro 2c2d0f2434 See #6570. Now it's possible to specify the formula in cost item quantity assignment 2026-07-02 08:40:58 +02:00
54 changed files with 1810 additions and 257 deletions
+20 -3
View File
@@ -10,9 +10,13 @@ on:
- 'src/ifcgeomserver/**'
- 'src/ifcjni/**'
- 'src/ifcmax/**'
- 'src/ifc5d/**'
- 'src/ifcedit/**'
- 'src/ifcmcp/**'
- 'src/ifcopenshell-python/**'
- '!src/ifcopenshell-python/docs/**'
- 'src/ifcparse/**'
- 'src/ifcquery/**'
- 'src/ifcwrap/**'
- 'src/qtviewer/**'
- 'src/svgfill/**'
@@ -51,7 +55,7 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install xmlschema xsdata numpy lxml pytest isodate lark networkx tabulate python-dateutil shapely pyparsing
pip install xmlschema xsdata numpy lxml pytest isodate lark networkx tabulate python-dateutil shapely pyparsing psutil
pip install src/bcf --no-deps
pip install pytest-xdist==3.8.0
@@ -252,13 +256,26 @@ jobs:
pip install deepdiff
cd ../ifcdiff && make test || ERROR=1
cd ../ifcpatch && make test || ERROR=1
pip install -e ../ifc5d --no-deps
pip install odfpy xlsxwriter
cd ../ifc5d && make test || ERROR=1
pip install -e ../ifcquery --no-deps
cd ../ifcquery && make test || ERROR=1
pip install -e ../ifcedit --no-deps
cd ../ifcedit && make test || ERROR=1
pip install mcp
pip install -e ../ifcmcp --no-deps
cd ../ifcmcp && make test || ERROR=1
pip install -e ../ifctester --no-deps
cd ../ifctester && make test || ERROR=1
make build-ids-docs || ERROR=1
# Run mathutils related tests at the end to ensure no other code is relying on mathutils.
# mathutils only has pre-built wheels for Python 3.13+; skip on older versions.
cd ../ifcopenshell-python
pip install mathutils
make test-mathutils || ERROR=1
if python -c "import sys; sys.exit(0 if sys.version_info >= (3, 13) else 1)"; then
pip install mathutils
make test-mathutils || ERROR=1
fi
if [ $ERROR -ne 0 ]; then
echo "One or more tests failed";
exit 1;
+15
View File
@@ -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
+28
View File
@@ -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.
+165
View File
@@ -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").
@@ -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;
@@ -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"):
@@ -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,
}
@@ -418,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"
@@ -710,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"
@@ -2489,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)
+19 -2
View File
@@ -17,9 +17,9 @@
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import bpy
import ifcopenshell.util.unit
from bpy.types import Menu, Panel, UIList
import ifcopenshell.util.unit
import bonsai.bim
import bonsai.tool as tool
from bonsai.bim.helper import prop_with_search
@@ -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",
@@ -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)
+305 -16
View File
@@ -240,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,
@@ -411,6 +420,25 @@ class FilledOpeningGenerator:
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"]
@@ -428,6 +456,13 @@ 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")
ifcopenshell.api.geometry.unassign_representation(tool.Ifc.get(), product=opening, representation=opening_rep)
ifcopenshell.api.geometry.remove_representation(tool.Ifc.get(), representation=opening_rep)
@@ -484,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"
)
@@ -581,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]:
@@ -608,6 +873,25 @@ class RecalculateFill(bpy.types.Operator, tool.Ifc.Operator):
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:
@@ -958,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
+1 -1
View File
@@ -22,9 +22,9 @@ from collections.abc import Iterable
from typing import TYPE_CHECKING, Any
import bpy
import ifcopenshell.util.unit
from bpy.types import Panel
import ifcopenshell.util.unit
import bonsai.bim
import bonsai.tool as tool
from bonsai.bim.helper import prop_with_search
+14 -24
View File
@@ -442,8 +442,6 @@ class ExtendWallsToUnderside(_CommitWallDraftsFirstMixin, bpy.types.Operator, to
element = tool.Ifc.get_entity(obj)
if not element:
continue
# IFC-class agnostic: any LAYER2 body (wall, covering, …) is
# extendable; everything else is treated as an underside target.
if tool.Parametric.is_path_connectable_wall(element):
walls.append(obj)
else:
@@ -3158,12 +3156,7 @@ def _apply_fillet_corner_geometry(
def _resolve_two_walls(context: bpy.types.Context) -> tuple[bpy.types.Object, bpy.types.Object] | None:
"""``(active, other)`` from a 2-element selection, both LAYER2 with straight axes.
IFC-class agnostic: the fillet only needs two straight LAYER2 bodies (the
corner it creates inherits the active element's type), so coverings / siding
fillet the same way walls do. The ``has_layer2_usage`` check below is the
real gate, not entity type."""
"""``(active, other)`` from a 2-wall selection, both LAYER2 with straight axes."""
selected = list(tool.Blender.get_selected_objects())
if len(selected) != 2:
return None
@@ -3175,7 +3168,7 @@ def _resolve_two_walls(context: bpy.types.Context) -> tuple[bpy.types.Object, bp
return None
for obj in (active, other):
element = tool.Ifc.get_entity(obj)
if element is None:
if element is None or not element.is_a("IfcWall"):
return None
if not tool.Wall.has_layer2_usage(element):
return None
@@ -3215,12 +3208,12 @@ def _pick_dominant_wall_material(
def regenerate_fillet_corner_wall(element: ifcopenshell.entity_instance, obj: bpy.types.Object) -> None:
"""Rebuild a fillet corner wall's banana body from
``EPset_Parametric.FilletRadius`` and its neighbours' current layer parameters."""
"""Rebuild a fillet corner wall's banana body from ``BBIM_Wall.FilletRadius``
and its neighbours' current layer parameters."""
ifc_file = tool.Ifc.get()
if ifc_file is None:
return
radius_si = tool.Parametric.get_parametric_prop(element, "FilletRadius")
radius_si = ifcopenshell.util.element.get_pset(element, "BBIM_Wall", "FilletRadius")
if not radius_si:
return
@@ -3424,7 +3417,7 @@ class EnableWallFilletPreviewFromCorner(bpy.types.Operator):
self.report({"ERROR"}, "Selection is not a fillet corner wall.")
return {"CANCELLED"}
radius = tool.Parametric.get_parametric_prop(corner_elem, "FilletRadius")
radius = ifcopenshell.util.element.get_pset(corner_elem, "BBIM_Wall", "FilletRadius")
if not radius:
self.report({"ERROR"}, "Corner wall has no FilletRadius pset to re-edit.")
return {"CANCELLED"}
@@ -3640,13 +3633,12 @@ class CreateWallFillet(bpy.types.Operator, tool.Ifc.Operator):
Vector((chord_length_si / unit_scale, 0.0)),
)
# Mark the corner BEFORE the downstream recalculate so
# Mark the corner wall BEFORE the downstream recalculate so
# tool.Model.recreate_wall short-circuits and preserves the curved
# geometry. The pset also gates the enable poll. FilletRadius is
# stored alongside IsFilletCorner so the corner can be rebuilt later
# (neighbour move, layer-thickness edit, pen-icon re-edit). Stored on
# the class-agnostic EPset_Parametric so LAYER2 siding corners work too.
pset = ifcopenshell.api.pset.add_pset(ifc_file, product=corner_elem, name=tool.Parametric.PARAMETRIC_PSET)
# (neighbour move, layer-thickness edit, pen-icon re-edit).
pset = ifcopenshell.api.pset.add_pset(ifc_file, product=corner_elem, name="BBIM_Wall")
ifcopenshell.api.pset.edit_pset(
ifc_file,
pset=pset,
@@ -4348,12 +4340,10 @@ class GizmoPairDisconnect(bpy.types.GizmoGroup, gizmo.BillboardingGizmoGroupMixi
if pair is None:
return
active, partner_obj, active_elem, partner_elem = pair
# Helper expects the LAYER2 element + its underside target regardless of
# which the user marked active. Class-agnostic: the LAYER2 side may be a
# wall or a covering (siding), the target a slab/roof/etc.
if tool.Parametric.is_path_connectable_wall(active_elem):
# Helper expects wall + slab regardless of which the user marked active.
if active_elem.is_a("IfcWall"):
wall_obj, slab_obj = active, partner_obj
elif tool.Parametric.is_path_connectable_wall(partner_elem):
elif partner_elem.is_a("IfcWall"):
wall_obj, slab_obj = partner_obj, active
else:
return
@@ -4659,7 +4649,7 @@ class GizmoWallFilletReedit(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix
if len(selected) != 1:
return False
element = tool.Ifc.get_entity(active)
if element is None:
if element is None or not element.is_a("IfcWall"):
return False
# IsFilletCorner pset is the authoritative signal — the re-edit
# operator separately verifies both neighbour connections exist and
@@ -4726,7 +4716,7 @@ class GizmoWallFilletToggleOpenings(bpy.types.GizmoGroup, _WallGeomCachedBillboa
if len(list(tool.Blender.get_selected_objects())) != 1:
return False
element = tool.Ifc.get_entity(active)
if element is None:
if element is None or not element.is_a("IfcWall"):
return False
return tool.Parametric.is_fillet_corner_wall(element)
@@ -633,6 +633,7 @@ class AppendLibraryElement(bpy.types.Operator, tool.Ifc.Operator):
if not element:
return {"FINISHED"}
if element.is_a("IfcTypeProduct"):
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
@@ -658,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")
@@ -1422,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)
@@ -1492,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}'")
@@ -1659,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):
+31 -24
View File
@@ -744,7 +744,7 @@ class EnableEditingSurfaceStyle(bpy.types.Operator):
if self.ifc_class == "IfcSurfaceStyleLighting":
def callback(attribute_name: str, _: object, data: dict[str, Any]) -> None:
assert attributes
assert attributes is not None
color = attributes.add()
assert isinstance(color, ColourRgb)
color.name = attribute_name
@@ -782,34 +782,40 @@ class EditSurfaceStyle(bpy.types.Operator, tool.Ifc.Operator):
def _execute(self, context):
self.props = tool.Style.get_style_props()
self.style = tool.Ifc.get().by_id(self.props.is_editing_style)
prev_update_graph = self.props.update_graph
self.props["update_graph"] = False
style_elements = tool.Style.get_style_elements(self.style)
# NOTE: currently this operator is used to edit existing (and only existing) IfcSurfaceStyles
# or new or existing IfcSurfaceStyle components (shading, etc)
# which is kind of confusing.
if self.props.is_editing_class == "IfcSurfaceStyle":
self.surface_style = self.style
else:
self.surface_style = style_elements.get(self.props.is_editing_class, None)
self.shading_style = style_elements.get("IfcSurfaceStyleShading", None)
self.rendering_style = style_elements.get("IfcSurfaceStyleRendering", None)
self.texture_style = style_elements.get("IfcSurfaceStyleWithTextures", None)
try:
style_elements = tool.Style.get_style_elements(self.style)
if self.surface_style:
result = self.edit_existing_style()
else:
result = self.add_new_style()
# NOTE: currently this operator is used to edit existing (and only existing) IfcSurfaceStyles
# or new or existing IfcSurfaceStyle components (shading, etc)
# which is kind of confusing.
if self.props.is_editing_class == "IfcSurfaceStyle":
self.surface_style = self.style
else:
self.surface_style = style_elements.get(self.props.is_editing_class, None)
self.shading_style = style_elements.get("IfcSurfaceStyleShading", None)
self.rendering_style = style_elements.get("IfcSurfaceStyleRendering", None)
self.texture_style = style_elements.get("IfcSurfaceStyleWithTextures", None)
if result:
return result
if self.surface_style:
result = self.edit_existing_style()
else:
result = self.add_new_style()
tool.Style.disable_editing()
core.load_styles(tool.Style, style_type=self.props.style_type)
if result:
return result
# restore selected style type
material = tool.Ifc.get_object(self.style)
msprops = tool.Style.get_material_style_props(material)
msprops.active_style_type = msprops.active_style_type
tool.Style.disable_editing()
core.load_styles(tool.Style, style_type=self.props.style_type)
# restore selected style type
material = tool.Ifc.get_object(self.style)
msprops = tool.Style.get_material_style_props(material)
msprops.active_style_type = msprops.active_style_type
finally:
self.props["update_graph"] = prev_update_graph
def edit_existing_style(self) -> None:
ifc_file = tool.Ifc.get()
@@ -1231,4 +1237,5 @@ class RemoveSurfaceStyle(bpy.types.Operator, tool.Ifc.Operator):
surface_style = tool.Style.get_style_elements(style)[props.is_editing_class]
ifcopenshell.api.style.remove_surface_style(ifc_file, surface_style)
core.disable_editing_style(tool.Style)
core.load_styles(tool.Style, style_type=props.style_type)
return {"FINISHED"}
@@ -185,6 +185,13 @@ class ColourRgb(PropertyGroup):
# to fit blender.bim.helper.draw_attribute
is_optional = False
special_type = ""
data_type = ""
ifc_class = ""
use_explorer_ui = False
@property
def display_name(self):
return self.name
def get_value_name(self, *args, **kwargs):
return "color_value"
+50 -3
View File
@@ -176,8 +176,30 @@ class BIM_PT_styles(Panel):
row.prop(self.props, "reflectance_method")
if self.props.reflectance_method not in ("PHYSICAL", "NOTDEFINED", "FLAT"):
self.layout.label(text="Supported reflectance methods are:")
self.layout.label(text="PHYSICAL / NOTDEFINED / FLAT")
self.layout.label(
text=f"{self.props.reflectance_method} will be skipped: only PHYSICAL / NOTDEFINED / FLAT are supported",
icon="ERROR",
)
elif self.props.reflectance_method in ("PHYSICAL", "NOTDEFINED"):
if self.props.specular_colour_class == "IfcColourRgb":
self.layout.label(
text="Metallic color is IFC-only in PHYSICAL/NOTDEFINED and does not affect Blender appearance",
icon="ERROR",
)
elif self.props.reflectance_method == "FLAT":
if self.props.diffuse_colour_class == "IfcNormalisedRatioMeasure":
self.layout.label(
text="Emissive ratio is IFC-only in FLAT Reflectance method and does not affect Blender appearance",
icon="ERROR",
)
self.layout.label(
text="Specular value is IFC-only in FLAT Reflectance method and does not affect Blender appearance",
icon="ERROR",
)
self.layout.label(
text="Highlight value is IFC-only in FLAT Reflectance method and does not affect Blender appearance",
icon="ERROR",
)
row = self.layout.row(align=True)
row.label(text="Emissive" if self.props.reflectance_method == "FLAT" else "Diffuse")
@@ -232,6 +254,8 @@ class BIM_PT_styles(Panel):
row.operator("bim.add_surface_texture", text="", icon="ADD")
if textures:
self.layout.prop(self.props, "uv_mode")
if self.props.uv_mode in ("Generated", "Camera"):
self.layout.label(text="Not available in SOLID Mode", icon="INFO")
for i, texture in enumerate(textures):
split = self.layout.split(factor=0.30, align=True)
@@ -244,6 +268,22 @@ class BIM_PT_styles(Panel):
op_clear = row.operator("bim.remove_texture_map", text="", icon="X")
op_path.texture_map_index = op_clear.texture_map_index = i
reflectance = self.props.reflectance_method
mode = texture.mode
if reflectance == "FLAT":
if mode != "EMISSIVE":
self.layout.label(
text=f"{mode} will be skipped: only EMISSIVE is supported for Render Reflectance FLAT",
icon="ERROR",
)
elif reflectance in ("PHYSICAL", "NOTDEFINED"):
_SUPPORTED = {"DIFFUSE", "NORMAL", "METALLICROUGHNESS", "EMISSIVE", "OCCLUSION"}
if mode not in _SUPPORTED:
self.layout.label(
text=f"{mode} will be skipped: not supported for Render Reflectance PHYSICAL/NOTDEFINED",
icon="ERROR",
)
def draw_externally_defined_surface_style(self):
row = self.layout.row()
op = row.operator("bim.browse_external_style", icon="APPEND_BLEND", text="Append From Blend File")
@@ -252,10 +292,17 @@ class BIM_PT_styles(Panel):
bonsai.bim.helper.draw_attributes(self.props.external_style_attributes, self.layout, enable_search=True)
def draw_refraction_surface_style(self):
self.layout.label(
text="Refraction values are IFC-only and do not affect Blender surface appearance",
icon="ERROR",
)
bonsai.bim.helper.draw_attributes(self.props.refraction_style_attributes, self.layout, enable_search=True)
row = self.layout.row(align=True)
def draw_lighting_surface_style(self):
self.layout.label(
text="Lighting values are IFC-only and do not affect Blender surface appearance",
icon="ERROR",
)
bonsai.bim.helper.draw_attributes(self.props.lighting_style_colours, self.layout)
def draw_edit_ui(self, edit_label: str):
@@ -375,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()
+15 -7
View File
@@ -26,7 +26,7 @@ import bonsai.bim.handler
import bonsai.core.geometry
import bonsai.core.root
import bonsai.tool as tool
from bonsai.bim.module.model.opening import FilledOpeningGenerator
from bonsai.bim.module.model.opening import FilledOpeningGenerator, is_filling_supported
class AddOpening(bpy.types.Operator, tool.Ifc.Operator):
@@ -34,11 +34,13 @@ class AddOpening(bpy.types.Operator, tool.Ifc.Operator):
bl_label = "Apply Opening"
bl_options = {"REGISTER", "UNDO"}
bl_description = (
"Apply opening objects to an Element.\n\n"
"The Element and the openings to be applied should be selected. The order of selection is not important.\n"
"Opening can be just a Blender mesh object.\n\n"
"Shift+click: keep the filling at its current matrix_world — skip the wall-axis snap "
"and the rl1/rl2 Z-elevation default that the regular click applies."
"Cuts openings in a wall, slab, or roof using selected shape objects — "
"doors, windows, existing openings, or plain (non-IFC) meshes. "
"Selection order doesn't matter.\n\n"
"Doors and windows also fill the opening. Other IFC classes are currently "
"unsupported by the opening generator and get skipped with a warning.\n\n"
"Shift+click: keep each opening at its shape object's current position "
"instead of snapping to the wall."
)
# Toggled by ``invoke`` when the user holds SHIFT during a gizmo / hotkey
@@ -84,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,
+59 -8
View File
@@ -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
@@ -2398,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:
@@ -2535,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:
+17 -1
View File
@@ -1206,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)
+17 -6
View File
@@ -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))
+92 -26
View File
@@ -2060,47 +2060,93 @@ 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)
has_replaced_opening_representation = True
@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)}
tool.Model.reload_body_representation(voided_objs)
if fillings:
@@ -3108,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
+20 -50
View File
@@ -486,70 +486,40 @@ class Parametric(bonsai.core.tool.Parametric):
@classmethod
def is_wall(cls, element: entity_instance) -> bool:
"""A LAYER2 axis-driven element editable by the parametric wall gizmo.
"""A wall is editable by the parametric gizmo if it is an IfcWall with LAYER2 usage.
IFC-class agnostic by design. The gizmos, edit mode and property panel
all operate on the standard-IFC parametric state axis polyline,
``IfcMaterialLayerSetUsage`` (LAYER2), ``IfcExtrudedAreaSolid`` none of
which is exclusive to ``IfcWall``. So any element modelled this way
qualifies: typically an ``IfcWall``, but also vertical claddings / siding
(``IfcCovering``) that are drawn, edited and joined the same way. Unlike
doors / windows / stairs these carry no proprietary pset their
parametric state lives entirely in standard IFC.
Gated on LAYER2 usage rather than entity type; LAYER3 elements (slabs)
and anything without vertical layered usage are excluded."""
if element is None:
Unlike doors/windows/stairs, walls do not carry a proprietary BBIM_Wall pset
their parametric state lives in standard IFC (axis polyline, IfcMaterialLayerSetUsage,
IfcExtrudedAreaSolid). Any LAYER2 wall qualifies."""
if element is None or not element.is_a("IfcWall"):
return False
return tool.Model.get_usage_type(element) == "LAYER2"
@classmethod
def is_path_connectable_wall(cls, element: entity_instance) -> bool:
"""A LAYER2 axis-driven element (or fillet corner) whose axis can drive
``IfcRelConnectsPathElements`` joins and underside clips.
"""An IfcWall that may participate in IfcRelConnectsPathElements joins —
either a LAYER2 parametric wall, or a fillet-corner wall whose body is
hand-built but whose axis still drives path connections.
IFC-class agnostic, like :meth:`is_wall` path connection, unjoin,
extend-to-wall and extend-to-underside all read placement + axis +
layer set, never the entity type so LAYER2 claddings / siding
(``IfcCovering``) join and clip the same way an ``IfcWall`` does.
Looser than :meth:`is_wall` in one respect: it also admits fillet-corner
walls, whose hand-built body carries no LAYER2 usage by spec but whose
axis still drives path connections. :meth:`is_wall` excludes them because
regenerating their body from the axis would flatten the curve."""
if element is None:
Distinct from ``is_wall``: that predicate gates parametric edits that
would regenerate the body and flatten a curved fillet. Unjoin / join
gizmo polls and path-connection partner enumeration use this looser
predicate so fillet corners (which have no LAYER2 usage by spec) still
surface their join icons."""
if element is None or not element.is_a("IfcWall"):
return False
if tool.Model.get_usage_type(element) == "LAYER2":
return True
return cls.is_fillet_corner_wall(element)
# Bonsai's class-agnostic parametric metadata (currently the fillet-corner
# flags IsFilletCorner / FilletRadius). Deliberately not on the entity-
# specific BBIM_Wall pset, so the same state can live on any LAYER2 element
# — a wall or a vertical cladding / siding (IfcCovering).
PARAMETRIC_PSET = "EPset_Parametric"
# Files authored before the move stored these flags on the wall-only pset.
LEGACY_PARAMETRIC_PSET = "BBIM_Wall"
@classmethod
def get_parametric_prop(cls, element: entity_instance, name: str) -> Any:
"""Read a Bonsai parametric flag from ``EPset_Parametric``, falling back
to the legacy ``BBIM_Wall`` pset for files authored before the fillet-
corner state was generalized off the wall-specific pset."""
import ifcopenshell.util.element
value = ifcopenshell.util.element.get_pset(element, cls.PARAMETRIC_PSET, name)
if value is None:
value = ifcopenshell.util.element.get_pset(element, cls.LEGACY_PARAMETRIC_PSET, name)
return value
@classmethod
def is_fillet_corner_wall(cls, element: entity_instance) -> bool:
"""``True`` if the element carries the ``EPset_Parametric.IsFilletCorner``
flag (legacy: ``BBIM_Wall.IsFilletCorner``), marking it as a curved
corner whose banana body is hand-built rather than regenerated from the
axis + layer set. Class-agnostic walls and LAYER2 siding alike."""
return bool(cls.get_parametric_prop(element, "IsFilletCorner"))
"""``True`` if the wall carries the ``BBIM_Wall.IsFilletCorner`` flag,
marking it as a curved corner whose banana body is hand-built rather
than regenerated from the wall's axis + layer set."""
import ifcopenshell.util.element
return bool(ifcopenshell.util.element.get_pset(element, "BBIM_Wall", "IsFilletCorner"))
@classmethod
def is_pipe_segment(cls, element: entity_instance) -> bool:
+8
View File
@@ -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(
+7 -10
View File
@@ -161,26 +161,23 @@ class Wall(bonsai.core.tool.Wall):
@classmethod
def validate_for_parametric_edit(cls, obj: bpy.types.Object) -> str | None:
"""``None`` if the element is parametrically editable, else a user-facing string
naming the specific gap so the user can fix the precise blocker.
IFC-class agnostic: the real requirement is AXIS2 layer usage + an extrusion
body, not ``IfcWall`` per se, so LAYER2 coverings/siding and other vertical
layered elements are editable the same way a wall is."""
"""``None`` if the wall is parametrically editable, else a user-facing string naming
the specific gap so the user can fix the precise blocker."""
element = tool.Ifc.get_entity(obj)
if not element:
return "Object is not an IFC element."
if not element.is_a("IfcWall"):
return f"Object is an {element.is_a()}, not an IfcWall."
if tool.Model.get_usage_type(element) != "LAYER2":
return (
"Element has no IfcMaterialLayerSetUsage with LayerSetDirection AXIS2 "
"(required for parametric editing)."
"Wall has no IfcMaterialLayerSetUsage with LayerSetDirection AXIS2 (required for parametric editing)."
)
representation = tool.Geometry.get_body_representation(element)
if not representation:
return "Element has no Model/Body/MODEL_VIEW representation to drive parametric dimensions."
return "Wall has no Model/Body/MODEL_VIEW representation to drive parametric dimensions."
if not tool.Model.get_extrusion(representation):
return (
"Element body is not an IfcExtrudedAreaSolid "
"Wall body is not an IfcExtrudedAreaSolid "
"(e.g. a brep mesh or boolean result without a base extrusion)."
)
return None
+28
View File
@@ -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
@@ -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
# ---------------------------------------------------------------------------
@@ -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."
)
+1 -2
View File
@@ -40,8 +40,7 @@ class TestCopyClass:
collector.assign("obj").should_be_called()
subject.copy_class(ifc, collector, geometry, root, obj="obj")
# def test_copy_with_new_geometry_copied_from_the_old(self, ifc, collector, geometry, root):
def test_AAAAAAAAAAAA(self, ifc, collector, geometry, root):
def test_copy_with_new_geometry_copied_from_the_old(self, ifc, collector, geometry, root):
ifc.get_entity("obj").should_be_called().will_return("original_element")
root.is_element_a("original_element", "IfcRelSpaceBoundary").should_be_called().will_return(False)
root.get_object_representation("obj").should_be_called().will_return("representation")
@@ -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
1 Index Identification Name Unit Value Quantity
2 1 E.01 Walls m3
3 2 E.01.01 Ground floor walls m3 100 42
4 2 E.01.02 First floor walls m3 200 35
5 1 A.02 Paintings m2
6 2 A.03 Paintings with water m2
7 3 B.05 White paintings m2 25 45
8 3 B.06 Colored paintings m2 32 33
9 2 C-01 Paintings with machine m2 17 133
10 2 C-02 Decorated paintings m2 40 8
+7
View File
@@ -0,0 +1,7 @@
Index,Identification,Name,Unit,Value,Quantity
1,A,Group A,,,
2,A.02,Paintings,m2,20,1
2,A.03,Paintings with water,m2,23,1
1,C,Group C,,,
2,C-01,Paintings with machine,m2,32,1
2,C-02,Decorated paintings,m2,40,2
1 Index Identification Name Unit Value Quantity
2 1 A Group A
3 2 A.02 Paintings m2 20 1
4 2 A.03 Paintings with water m2 23 1
5 1 C Group C
6 2 C-01 Paintings with machine m2 32 1
7 2 C-02 Decorated paintings m2 40 2
@@ -0,0 +1,10 @@
Index,Identification,Name,Unit,Value,Quantity,Query,Property
1,E.01,Walls,m3,,,,
2,E.01.01,Ground floor walls,m3,100,,"IfcWall, location=""Ground Floor""",GrossVolume
2,E.01.02,First floor walls,m3,200,,"IfcWall, location=""First Floor""",GrossVolume
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,,
1 Index Identification Name Unit Value Quantity Query Property
2 1 E.01 Walls m3
3 2 E.01.01 Ground floor walls m3 100 IfcWall, location="Ground Floor" GrossVolume
4 2 E.01.02 First floor walls m3 200 IfcWall, location="First Floor" GrossVolume
5 1 A.02 Paintings m2
6 2 A.03 Paintings with water m2
7 3 B.05 White paintings m2 25 45
8 3 B.06 Colored paintings m2 32 33
9 2 C-01 Paintings with machine m2 17 133
10 2 C-02 Decorated paintings m2 40 8
@@ -0,0 +1,10 @@
Index,Identification,Name,Unit,Value,Quantity,Description
1,E.01,Walls,m3,,,
2,E.01.01,Ground floor walls,m3,100,42,"Semi-solid blocks of plain-faced common brick, with an apparent density (excluding holes) of 800 kg/m³; minor drilling 45%; apparent thermal conductivity 0.21 W/mK; characteristic mechanical strength parallel to the holes greater than or equal to 10 N/mm2, perpendicular to the holes greater than or equal to 2N/mm2"
2,E.01.02,First floor walls,m3,200,35,"Semi-solid blocks of plain-faced common brick, with an apparent density (excluding holes) of 800 kg/m³; minor drilling 45%; apparent thermal conductivity 0.21 W/mK; characteristic mechanical strength parallel to the holes greater than or equal to 10 N/mm2, perpendicular to the holes greater than or equal to 2N/mm2"
1,A.02,Painting,m2,,,"Painting with washable water-based wall paint for indoor/outdoor. The price includes and compensates the costs for the supply of paint, any scaffolding up to a maximum height of 4 m from the support surface, the costs for the protection of furniture, fixed systems or the protection of floors, the cleaning of the surfaces to be treated through the use of rags or net purposes in order to remove residues that can be easily removed. The cost of occasional and partial grouting of surfaces, in order to eliminate any small scratches, including sanding of the grouted parts, is also to be considered included and compensated. For 2 coats with brush or roller."
2,A.03,Washable painting,m2,,,"Supply and installation of washable tempera paint for interiors and exteriors. The price includes and compensates for the costs of supplying the paint, any scaffolding up to a maximum height of 4 meters from the support surface, the costs of protecting furnishings, fixed installations, or floors, and cleaning the surfaces to be treated using rags or clean brushes to remove easily removable residues. On previously prepared plaster. Apply two coats with a brush or roller. (Tempera colors from the color chart)."
3,B.05,White paintings,m2,25,45,
3,B.06,Colored paintings,m2,32,33,
2,C-01,External painting,m2,17,133,"Painting with plastic coating. The price includes and compensates for the costs of supplying the paint, any scaffolding up to a maximum height of 4 meters from the support surface, the costs of protecting furnishings, fixed systems, or floors, and cleaning the surfaces to be treated using rags or clean brushes to remove easily removable residues. On already prepared plaster. For 2 coats (interior textured finish)."
2,C-02,Decorated paintings,m2,40,8,
1 Index Identification Name Unit Value Quantity Description
2 1 E.01 Walls m3
3 2 E.01.01 Ground floor walls m3 100 42 Semi-solid blocks of plain-faced common brick, with an apparent density (excluding holes) of 800 kg/m³; minor drilling 45%; apparent thermal conductivity 0.21 W/mK; characteristic mechanical strength parallel to the holes greater than or equal to 10 N/mm2, perpendicular to the holes greater than or equal to 2N/mm2
4 2 E.01.02 First floor walls m3 200 35 Semi-solid blocks of plain-faced common brick, with an apparent density (excluding holes) of 800 kg/m³; minor drilling 45%; apparent thermal conductivity 0.21 W/mK; characteristic mechanical strength parallel to the holes greater than or equal to 10 N/mm2, perpendicular to the holes greater than or equal to 2N/mm2
5 1 A.02 Painting m2 Painting with washable water-based wall paint for indoor/outdoor. The price includes and compensates the costs for the supply of paint, any scaffolding up to a maximum height of 4 m from the support surface, the costs for the protection of furniture, fixed systems or the protection of floors, the cleaning of the surfaces to be treated through the use of rags or net purposes in order to remove residues that can be easily removed. The cost of occasional and partial grouting of surfaces, in order to eliminate any small scratches, including sanding of the grouted parts, is also to be considered included and compensated. For 2 coats with brush or roller.
6 2 A.03 Washable painting m2 Supply and installation of washable tempera paint for interiors and exteriors. The price includes and compensates for the costs of supplying the paint, any scaffolding up to a maximum height of 4 meters from the support surface, the costs of protecting furnishings, fixed installations, or floors, and cleaning the surfaces to be treated using rags or clean brushes to remove easily removable residues. On previously prepared plaster. Apply two coats with a brush or roller. (Tempera colors from the color chart).
7 3 B.05 White paintings m2 25 45
8 3 B.06 Colored paintings m2 32 33
9 2 C-01 External painting m2 17 133 Painting with plastic coating. The price includes and compensates for the costs of supplying the paint, any scaffolding up to a maximum height of 4 meters from the support surface, the costs of protecting furnishings, fixed systems, or floors, and cleaning the surfaces to be treated using rags or clean brushes to remove easily removable residues. On already prepared plaster. For 2 coats (interior textured finish).
10 2 C-02 Decorated paintings m2 40 8
@@ -0,0 +1,7 @@
Index,Identification,Name,Unit,Value,Quantity,Description
1,A,Group A,,,,
2,A.02,Paintings,m2,20,1,Paint made by the best painter in the world
2,A.03,Paintings with water,m2,23,1,
1,C,Group C,,,,
2,C-01,Paintings with machine,m2,32,1,Best painting in the world painted with the best painted machine accordingly with ISO9001
2,C-02,Decorated paintings,m2,40,2,
1 Index Identification Name Unit Value Quantity Description
2 1 A Group A
3 2 A.02 Paintings m2 20 1 Paint made by the best painter in the world
4 2 A.03 Paintings with water m2 23 1
5 1 C Group C
6 2 C-01 Paintings with machine m2 32 1 Best painting in the world painted with the best painted machine accordingly with ISO9001
7 2 C-02 Decorated paintings m2 40 2
@@ -0,0 +1,10 @@
Index,Identification,Name,Unit,Material,Labor,Quantity
1,E.01,Walls,m3,,,
2,E.01.01,Ground floor walls,m3,55,45,42
2,E.01.02,First floor walls,m3,120,80,35
1,A.02,Painting,m2,,,
2,A.03,Washable painting,m2,,,
3,B.05,White paintings,m2,,,45
3,B.06,Colored paintings,m2,,,33
2,C-01,External painting,m2,,,133
2,C-02,Decorated paintings,m2,,,8
1 Index Identification Name Unit Material Labor Quantity
2 1 E.01 Walls m3
3 2 E.01.01 Ground floor walls m3 55 45 42
4 2 E.01.02 First floor walls m3 120 80 35
5 1 A.02 Painting m2
6 2 A.03 Washable painting m2
7 3 B.05 White paintings m2 45
8 3 B.06 Colored paintings m2 33
9 2 C-01 External painting m2 133
10 2 C-02 Decorated paintings m2 8
@@ -0,0 +1,10 @@
Index,Identification,Name,Unit,Value,Quantity,RateSchedule,RateID
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,,45,Ex2-SoR,A.03
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,,
1 Index Identification Name Unit Value Quantity RateSchedule RateID
2 1 E.01 Walls m3
3 2 E.01.01 Ground floor walls m3 100 42
4 2 E.01.02 First floor walls m3 200 35
5 1 A.02 Paintings m2
6 2 A.03 Paintings with water m2
7 3 B.05 White paintings m2 45 Ex2-SoR A.03
8 3 B.06 Colored paintings m2 32 33
9 2 C-01 Paintings with machine m2 17 133
10 2 C-02 Decorated paintings m2 40 8
@@ -0,0 +1,14 @@
Index,Identification,Name,Unit,Value,Quantity,Query,Property,Formula
1,E.01,Walls,m3,,,,,
2,E.01.01,Ground floor walls,m3,100,,"IfcWall, location=""Ground Floor""",GrossVolume,
2,E.01.02,First floor walls,m3,200,,"IfcWall, location=""First Floor""",GrossVolume,
1,A.02,Paintings,m2,,,,,
2,A.03,Paintings with water,m2,,,,,
3,B.05,White paintings,m2,25,,IfcWall,GrossVolume,
3,B.06,Colored paintings,m2,32,33,,,
3,B.07,Double paintings,m,,,IfcWall,,NetSideArea*2
2,C-01,Paintings with machine,m2,17,133,,,
2,C-02,Decorated paintings,m2,40,8,,,
1,D,Reinforcements,,,,,,
2,D.1,Walls reinforcements weight,kg,,,IfcWall,,Pset_ConcreteElementGeneral.ReinforcementVolumeRatio * GrossVolume
2,D.2,Beams reinforcements weight,kg,,,,,
1 Index Identification Name Unit Value Quantity Query Property Formula
2 1 E.01 Walls m3
3 2 E.01.01 Ground floor walls m3 100 IfcWall, location="Ground Floor" GrossVolume
4 2 E.01.02 First floor walls m3 200 IfcWall, location="First Floor" GrossVolume
5 1 A.02 Paintings m2
6 2 A.03 Paintings with water m2
7 3 B.05 White paintings m2 25 IfcWall GrossVolume
8 3 B.06 Colored paintings m2 32 33
9 3 B.07 Double paintings m IfcWall NetSideArea*2
10 2 C-01 Paintings with machine m2 17 133
11 2 C-02 Decorated paintings m2 40 8
12 1 D Reinforcements
13 2 D.1 Walls reinforcements weight kg IfcWall Pset_ConcreteElementGeneral.ReinforcementVolumeRatio * GrossVolume
14 2 D.2 Beams reinforcements weight kg
+101
View File
@@ -73,6 +73,83 @@ class TestCreateCamera(NewFile):
assert obj.users_collection == tuple()
class TestImportCameraProps(NewFile):
def test_imports_perspective_camera_shifts_from_drawing_pset(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
drawing = ifc.createIfcAnnotation(ObjectType="DRAWING")
pset = ifcopenshell.api.pset.add_pset(ifc, product=drawing, name="EPset_Drawing")
ifcopenshell.api.pset.edit_pset(
ifc,
pset=pset,
properties={"PerspectiveShiftX": 0.125, "PerspectiveShiftY": -0.375},
)
camera = bpy.data.cameras.new("Camera")
camera.type = "PERSP"
subject.import_camera_props(drawing, camera)
assert camera.shift_x == pytest.approx(0.125)
assert camera.shift_y == pytest.approx(-0.375)
def test_non_perspective_import_defaults_camera_shifts_to_zero(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
drawing = ifc.createIfcAnnotation(ObjectType="DRAWING")
pset = ifcopenshell.api.pset.add_pset(ifc, product=drawing, name="EPset_Drawing")
ifcopenshell.api.pset.edit_pset(
ifc,
pset=pset,
properties={"PerspectiveShiftX": 0.125, "PerspectiveShiftY": -0.375},
)
camera = bpy.data.cameras.new("Camera")
camera.type = "ORTHO"
camera.shift_x = 1.0
camera.shift_y = -1.0
subject.import_camera_props(drawing, camera)
assert camera.shift_x == 0.0
assert camera.shift_y == 0.0
class TestSyncPerspectiveCameraShifts(NewFile):
def test_round_trips_perspective_camera_shifts_through_drawing_pset(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
drawing = ifc.createIfcAnnotation(ObjectType="DRAWING")
camera = bpy.data.cameras.new("Camera")
camera.type = "PERSP"
camera.shift_x = 0.25
camera.shift_y = -0.5
subject.sync_perspective_camera_shifts(drawing, camera)
pset = ifcopenshell.util.element.get_pset(drawing, "EPset_Drawing")
assert pset["PerspectiveShiftX"] == pytest.approx(0.25)
assert pset["PerspectiveShiftY"] == pytest.approx(-0.5)
reloaded_camera = bpy.data.cameras.new("ReloadedCamera")
reloaded_camera.type = "PERSP"
subject.import_camera_props(drawing, reloaded_camera)
assert reloaded_camera.shift_x == pytest.approx(0.25)
assert reloaded_camera.shift_y == pytest.approx(-0.5)
def test_ignores_non_perspective_camera_shifts(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
drawing = ifc.createIfcAnnotation(ObjectType="DRAWING")
camera = bpy.data.cameras.new("Camera")
camera.type = "ORTHO"
camera.shift_x = 0.25
camera.shift_y = -0.5
subject.sync_perspective_camera_shifts(drawing, camera)
assert ifcopenshell.util.element.get_pset(drawing, "EPset_Drawing") is None
class TestCreateSvgSheet(NewFile):
def test_run(self):
ifc = ifcopenshell.file()
@@ -961,3 +1038,27 @@ class TestAddReferenceImage(NewFile):
uv_node = material_nodes["Texture Coordinate"]
assert len(uv_node.outputs["Generated"].links[:]) == 1
class TestIsDrawingActive(NewFile):
def test_no_active_camera(self):
bpy.context.scene.camera = None
assert subject.is_drawing_active() is False
def test_active_camera_without_ifc_definition(self):
bpy.context.scene.camera = subject.create_camera("Camera", mathutils.Matrix(), "PERSPECTIVE", "PLAN_VIEW")
assert subject.is_drawing_active() is False
def test_ifc_linked_camera_in_background_mode(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
camera_obj = subject.create_camera("Camera", mathutils.Matrix(), "PERSPECTIVE", "PLAN_VIEW")
drawing = ifc.createIfcAnnotation(ObjectType="DRAWING")
tool.Ifc.link(drawing, camera_obj)
bpy.context.scene.camera = camera_obj
# The test suite itself runs Blender in background mode, where no
# VIEW_3D area can ever exist -- this is exactly the case the fix
# addresses, so this assertion documents that assumption.
assert bpy.app.background is True
assert subject.is_drawing_active() is True
+77 -16
View File
@@ -294,64 +294,125 @@ class TestLoadLinkedModels(NewFile):
assert props.links[1].ifc_definition_id == reference2.id()
assert props.links[1].has_transformation is True
def test_load_linked_models_restores_query_from_cache_json(self):
"""The selector query used at link time is persisted only in the
sidecar cache JSON. Reopening the host IFC must restore it onto the
Link PropertyGroup so subsequent Reload/Load replay the same filter."""
ifc = ifcopenshell.file()
props = tool.Project.get_project_props()
ifcopenshell.api.root.create_entity(ifc, "IfcProject")
document = ifcopenshell.api.document.add_information(ifc)
document.Scope = "LINKED_MODEL"
with NamedTemporaryFile(suffix=".ifc.cache.json", mode="w", delete=False) as tmp:
json.dump({"query": "IfcElement, ! IfcOpeningElement"}, tmp)
json_path = Path(tmp.name)
try:
ifc_filepath = tmp.name.replace(".ifc.cache.json", ".ifc")
reference = ifcopenshell.api.document.add_reference(ifc, document)
reference.Location = Path(ifc_filepath).as_posix()
reference.Identification = ""
tool.Ifc.set(ifc)
subject.load_linked_models_from_ifc()
assert len(props.links) == 1
assert props.links[0].query == "IfcElement, ! IfcOpeningElement"
finally:
json_path.unlink(missing_ok=True)
def test_load_linked_models_query_defaults_empty_without_cache_json(self):
"""When no sidecar cache JSON exists, the restored Link's query field
must default to the empty string. Empty query is the documented signal
for the load path to apply no selector filter."""
ifc = ifcopenshell.file()
props = tool.Project.get_project_props()
ifcopenshell.api.root.create_entity(ifc, "IfcProject")
document = ifcopenshell.api.document.add_information(ifc)
document.Scope = "LINKED_MODEL"
with tempfile.TemporaryDirectory() as tmpdir:
ifc_path = Path(tmpdir) / "no-cache.ifc"
reference = ifcopenshell.api.document.add_reference(ifc, document)
reference.Location = ifc_path.as_posix()
reference.Identification = ""
tool.Ifc.set(ifc)
subject.load_linked_models_from_ifc()
assert len(props.links) == 1
assert props.links[0].query == ""
class TestCalculateLinkMatrix(NewFile):
def _write_cache_json(self, payload: dict) -> Path:
"""Write ``payload`` to a fresh sidecar cache JSON path and return it.
On Windows, ``NamedTemporaryFile(delete=True)`` holds an exclusive
handle for the ``with`` block's duration, so the code-under-test
cannot open the same path hence the manual write + unlink pattern.
"""
tmp = NamedTemporaryFile(suffix=".ifc.cache.json", mode="w", delete=False)
try:
json.dump(payload, tmp)
finally:
tmp.close()
return Path(tmp.name)
def test_linking_a_model_without_an_offset_to_our_session_with_no_offset(self):
props = tool.Project.get_project_props()
gprops = tool.Georeference.get_georeference_props()
with NamedTemporaryFile(suffix=".ifc.cache.json", mode="w", delete=True) as tmp:
json_path = self._write_cache_json({"model_project_north": "0", "model_origin_si": "0,0,0"})
try:
link = props.links.add()
link.filepath = tmp.name.replace(".ifc.cache.json", ".ifc")
json.dump({"model_project_north": "0", "model_origin_si": "0,0,0"}, tmp)
tmp.flush()
link.filepath = str(json_path).replace(".ifc.cache.json", ".ifc")
gprops.model_project_north = "0"
gprops.model_origin_si = "0,0,0"
assert np.allclose(subject.calculate_link_matrix(link), np.eye(4))
finally:
json_path.unlink(missing_ok=True)
def test_linking_an_offset_model_to_our_session_with_no_offset(self):
props = tool.Project.get_project_props()
gprops = tool.Georeference.get_georeference_props()
with NamedTemporaryFile(suffix=".ifc.cache.json", mode="w", delete=True) as tmp:
json_path = self._write_cache_json({"model_project_north": "0", "model_origin_si": "5,0,0"})
try:
link = props.links.add()
link.filepath = tmp.name.replace(".ifc.cache.json", ".ifc")
json.dump({"model_project_north": "0", "model_origin_si": "5,0,0"}, tmp)
tmp.flush()
link.filepath = str(json_path).replace(".ifc.cache.json", ".ifc")
gprops.model_project_north = "0"
gprops.model_origin_si = "0,0,0"
m = np.eye(4)
m[0][3] = 5
assert np.allclose(subject.calculate_link_matrix(link), m)
finally:
json_path.unlink(missing_ok=True)
def test_linking_an_offset_model_to_our_session_with_offset(self):
props = tool.Project.get_project_props()
gprops = tool.Georeference.get_georeference_props()
with NamedTemporaryFile(suffix=".ifc.cache.json", mode="w", delete=True) as tmp:
json_path = self._write_cache_json({"model_project_north": "0", "model_origin_si": "5,0,0"})
try:
link = props.links.add()
link.filepath = tmp.name.replace(".ifc.cache.json", ".ifc")
json.dump({"model_project_north": "0", "model_origin_si": "5,0,0"}, tmp)
tmp.flush()
link.filepath = str(json_path).replace(".ifc.cache.json", ".ifc")
gprops.model_project_north = "0"
gprops.model_origin_si = "2,0,0"
m = np.eye(4)
m[0][3] = 3
assert np.allclose(subject.calculate_link_matrix(link), m)
finally:
json_path.unlink(missing_ok=True)
def test_linking_an_offset_model_to_our_session_with_offset_and_transformation(self):
props = tool.Project.get_project_props()
gprops = tool.Georeference.get_georeference_props()
with NamedTemporaryFile(suffix=".ifc.cache.json", mode="w", delete=True) as tmp:
json_path = self._write_cache_json({"model_project_north": "0", "model_origin_si": "5,0,0"})
try:
link = props.links.add()
link.filepath = tmp.name.replace(".ifc.cache.json", ".ifc")
link.filepath = str(json_path).replace(".ifc.cache.json", ".ifc")
transformation = np.eye(4)
transformation[0][3] = 4
link.transformation = ",".join(map(str, transformation.reshape(-1)))
json.dump({"model_project_north": "0", "model_origin_si": "5,0,0"}, tmp)
tmp.flush()
gprops.model_project_north = "0"
gprops.model_origin_si = "2,0,0"
m = np.eye(4)
m[0][3] = 7
assert np.allclose(subject.calculate_link_matrix(link), m)
finally:
json_path.unlink(missing_ok=True)
class TestLoadingIfcSqlite(NewFile):
+14
View File
@@ -0,0 +1,14 @@
Index,Identification,Name,Unit,Value,Quantity,Query,Property,Formula
1,E.01,Walls,m3,,,,,
2,E.01.01,Ground floor walls,m3,100,,"IfcWall, location=""Ground Floor""",GrossVolume,
2,E.01.02,First floor walls,m3,200,,"IfcWall, location=""First Floor""",GrossVolume,
1,A.02,Paintings,m2,,,,,
2,A.03,Paintings with water,m2,,,,,
3,B.05,White paintings,m2,25,,IfcWall,GrossVolume,
3,B.06,Colored paintings,m2,32,33,,,
3,B.07,Double paintings,m,,,IfcWall,,NetSideArea*2
2,C-01,Paintings with machine,m2,17,133,,,
2,C-02,Decorated paintings,m2,40,8,,,
1,D,Reinforcements,,,,,,
2,D.1,Walls reinforcements weight,kg,,,IfcWall,,Pset_ConcreteElementGeneral.ReinforcementVolumeRatio * GrossVolume
2,D.2,Beams reinforcements weight,kg,,,,,
1 Index Identification Name Unit Value Quantity Query Property Formula
2 1 E.01 Walls m3
3 2 E.01.01 Ground floor walls m3 100 IfcWall, location="Ground Floor" GrossVolume
4 2 E.01.02 First floor walls m3 200 IfcWall, location="First Floor" GrossVolume
5 1 A.02 Paintings m2
6 2 A.03 Paintings with water m2
7 3 B.05 White paintings m2 25 IfcWall GrossVolume
8 3 B.06 Colored paintings m2 32 33
9 3 B.07 Double paintings m IfcWall NetSideArea*2
10 2 C-01 Paintings with machine m2 17 133
11 2 C-02 Decorated paintings m2 40 8
12 1 D Reinforcements
13 2 D.1 Walls reinforcements weight kg IfcWall Pset_ConcreteElementGeneral.ReinforcementVolumeRatio * GrossVolume
14 2 D.2 Beams reinforcements weight kg
+1
View File
@@ -39,6 +39,7 @@ See example files as a CSV file format reference:
- Ex5 - SoR_with_description.csv (a simple SoR with description column)
- Ex6 - BoQ with categories.csv (a simple BoQ with categories columns)
- Ex7 - BoQ with Rates.csv (a simple BoQ that connect to an existing SoR. It needs an already loaded SoR.)
- Ex8 - Boq with formula.csv (a simple BoQ with formula field used to calculate quantities when specified)
- `sample_cost_schedule_house_FR.csv` / `.ods`
- `schedule.csv`, `rates.csv` (schedule of rates example)
+31 -2
View File
@@ -55,6 +55,9 @@ class CsvHeader(TypedDict):
RateSchedule: NotRequired[str]
RateID: NotRequired[str]
# Formula
Formula: NotRequired[str]
#QuantityClass: NotRequired[str]
# Currently we assume that if column is not part of the main header,
# then it is a cost value category. So here we list any additional column
@@ -65,6 +68,8 @@ MAIN_CSV_HEADER_COLUMNS.extend(
# Not sure what this for but it's present in sample .csv.
"Subtotal",
# Columns from exporter.
"ItemIsASum",
"Quantities",
"RateSubtotal",
"TotalPrice",
# Deprecated columns from exporter, shouldn't be exported any longer.
@@ -91,6 +96,8 @@ class CostItem(TypedDict):
Property: Union[str, None]
Query: Union[str, None]
Formula: Union[str, None]
#QuantityClass: Union[str, None]
class Csv2Ifc:
# Inputs.
@@ -108,6 +115,7 @@ class Csv2Ifc:
categories: dict[str, int]
has_categories: bool
has_rates: bool
has_formula: bool
def __init__(
self,
@@ -163,9 +171,12 @@ class Csv2Ifc:
if not self.headers:
self.has_categories = True
self.has_rates = False
self.has_formula = False
self.headers = {col: i for i, col in enumerate(row) if col}
if "RateSchedule" in self.headers and "RateID" in self.headers:
self.has_rates = True
if "Formula" in self.headers:
self.has_formula = True
if "Value" in self.headers:
self.has_categories = False
else:
@@ -233,6 +244,11 @@ class Csv2Ifc:
else:
cost_rate = None
if self.has_formula:
cost_formula = row[(self.headers["Formula"])] if "Formula" in self.headers else None
else:
cost_formula = None
return {
"Identification": str(identification) if identification else None,
"Name": str(name) if name else None,
@@ -244,6 +260,7 @@ class Csv2Ifc:
"Query": query,
"children": [],
"CostRate": cost_rate,
"Formula": cost_formula,
}
def create_ifc(self) -> None:
@@ -320,6 +337,7 @@ class Csv2Ifc:
if cost_rate.get("Schedule") and cost_rate.get("RateID"):
# if cost_rate["Schedule"] is not "":
rate_cost_schedule = None
schedules = self.file.by_type("IfcCostSchedule")
for schedule in schedules:
if schedule.Name == cost_rate["Schedule"]:
@@ -381,17 +399,28 @@ class Csv2Ifc:
# and some query in "Query" column.
# If query is provided it will override the defined value
# due current behaviour in cost.assign_cost_item_quantity.
if results:
if results and not cost_item["Formula"]:
ifcopenshell.api.cost.assign_cost_item_quantity(
self.file,
cost_item=cost_item["ifc"],
products=results,
prop_name=prop_name,
)
elif not quantity:
elif not quantity and not cost_item["Formula"]:
quantity = ifcopenshell.api.cost.add_cost_item_quantity(
self.file, cost_item=cost_item["ifc"], ifc_class=quantity_class
)
if cost_item["Formula"]:
results = ifcopenshell.util.selector.filter_elements(self.file, cost_item["Query"])
results = [r for r in results]
ifc_quantity_class = ifcopenshell.util.unit.get_symbol_quantity_class(cost_item["Unit"])
quantity = ifcopenshell.api.cost.assign_cost_item_quantity(
self.file,
cost_item=cost_item["ifc"],
products=results,
formula=cost_item["Formula"],
ifc_class=ifc_quantity_class,
)
self.create_cost_items(cost_item["children"], cost_item["ifc"])
@@ -67,7 +67,7 @@ Begin learning IFC
------------------
IFC has three versions published by ISO: **IFC2X3** from 2007, **IFC4** from
2017, and **IFC4X3** in draft form. Each version improves on the previous
2017, and **IFC4X3** from 2024. Each version improves on the previous
version, and will have different **IFC Classes** with different attributes and
different **IFC Concepts**.
@@ -82,8 +82,9 @@ You can access the official documentation here:
.. tip::
It is recommended to use IFC4. However, the IFC4X3 documentation is a lot
more friendly to newcomers.
For most buildings, IFC4 is recommended. For infrastructure projects (road,
railway, bridge, and other civil elements), use IFC4X3. The IFC4X3 documentation
is also generally more newcomer-friendly.
The official ISO documentation is written for a technical audience and may be
overwhelming. This guide will take you slowly through the core concepts, and
@@ -16,10 +16,15 @@
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import ifcopenshell.api.cost
import ifcopenshell.api.control
import ast
import operator
from typing import Any
import ifcopenshell.api.control
import ifcopenshell.api.cost
import ifcopenshell.util.element
def assign_cost_item_quantity(
@@ -27,6 +32,8 @@ def assign_cost_item_quantity(
cost_item: ifcopenshell.entity_instance,
products: list[ifcopenshell.entity_instance],
prop_name: str = "",
formula: str = "",
ifc_class: str = "IfcQuantityLength",
) -> None:
"""Adds a cost item quantity that is parametrically connected to a product
@@ -57,6 +64,12 @@ def assign_cost_item_quantity(
:param prop_name: The name of the quantity. If this is not specified,
then it is assumed that there is no calculated quantity, and the
number of objects are counted instead.
:param formula: The string that contains the formula
:param ifc_class: The quantity class of the calculated value if the formula is
specified. Can be ['IfcQuantityCount', 'IfcQuantityNumber',
'IfcQuantityLength', 'IfcQuantityArea', 'IfcQuantityVolume',
'IfcQuantityWeight', 'IfcQuantityTime']. Check
ifcopenshell.util.unit.QUANTITY_CLASS for more info.
:return: None
Example:
@@ -84,6 +97,18 @@ def assign_cost_item_quantity(
# item.
ifcopenshell.api.cost.assign_cost_item_quantity(model,
cost_item=item, products=[slab], prop_name="NetVolume")
# Now let's use the formula in order to calculate the quantity value.
# For example, let's say that a IfcWall has the reinfocement volume ratio
# stored in the Pset_ConcreteElementGeneral.ReinforcementVolumeRatio
# and of course it has also the gross volume stored in the
# Qto_WallBaseQuantities.GrossVolume. So we can add an IfcQuantity that stores the
# reinforcement volume calculated with reinfocement volume ratio * gross volume.
ifcopenshell.api.cost.assign_cost_item_quantity(model,
cost_item=item, products=[wall],
formula="Pset_ConcreteElementGeneral.ReinforcementVolumeRatio * NetVolume"
ifc_class="IfcQuantityVolume")
"""
usecase = Usecase()
usecase.file = file
@@ -91,6 +116,8 @@ def assign_cost_item_quantity(
"cost_item": cost_item,
"products": products or [],
"prop_name": prop_name,
"formula": formula,
"ifc_class" : ifc_class
}
return usecase.execute()
@@ -100,12 +127,50 @@ class Usecase:
settings: dict[str, Any]
def execute(self):
if self.settings["prop_name"]:
if self.settings["prop_name"] or self.settings["formula"]:
self.quantities = set(self.settings["cost_item"].CostQuantities or [])
for product in self.settings["products"]:
if product.is_a("IfcSpatialElement"):
continue
self.assign_cost_control(related_object=product, cost_item=self.settings["cost_item"])
if self.settings["formula"]:
tree = ast.parse(self.settings["formula"], mode = "eval")
collector = VariableExtractor()
collector.visit(tree)
variables = collector.variables
for variable in variables:
getter = self.get_value_from_pset if "." in variable else self.get_value_from_qset
value = getter(product, variable)
if value is None:
print(
f"WARNING: Variable '{variable}' in product '{product.Name}' "
f"is missing (None). Check Pset/Qset or property name."
)
elif value == 0:
print(
f"WARNING: Variable '{variable}' in product '{product.Name}' "
f"has value 0. Verify if this is correct."
)
evaluator = FormulaEvaluator(values)
result = evaluator.visit(tree.body)
new_quantity = None
for quantity in self.quantities:
if quantity.Formula == self.settings["formula"] and len(self.settings["products"]) == 1: #Todo improve it
new_quantity = quantity
self.settings["ifc_class"] = quantity.is_a()
continue
if new_quantity is None:
new_quantity = self.file.create_entity(self.settings["ifc_class"], Name="Unnamed")
new_quantity.Formula = self.settings["formula"]
self.quantities.add(new_quantity)
new_quantity[3] = result
continue
if self.settings["prop_name"]:
if (
self.settings["cost_item"].CostQuantities
@@ -113,11 +178,30 @@ class Usecase:
):
continue
self.add_quantity_from_related_object(product)
if self.settings["prop_name"]:
if self.settings["prop_name"] or self.settings["formula"]:
self.settings["cost_item"].CostQuantities = list(self.quantities)
else:
self.update_cost_item_count()
def get_value_from_pset(
self,
product:ifcopenshell.entity_instance,
v: str,
) -> float:
pset_name = v.split(".")[0]
pset = ifcopenshell.util.element.get_pset(product, pset_name)
pset_property_name = v.split(".")[1]
return (pset or {}).get(pset_property_name,None)
def get_value_from_qset(
self,
product:ifcopenshell.entity_instance,
v: str,
) -> float:
qtos = ifcopenshell.util.element.get_psets(product, qtos_only = True)
quantities = next(iter(qtos.values()), {})
return (quantities or {}).get(v,None)
def assign_cost_control(
self, related_object: ifcopenshell.entity_instance, cost_item: ifcopenshell.entity_instance
) -> ifcopenshell.entity_instance:
@@ -158,3 +242,55 @@ class Usecase:
if not obj.is_a("IfcConstructionResource"):
count += 1
quantity[3] = count
OPERATORS = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
ast.Pow: operator.pow,
ast.USub: operator.neg,
}
def build_full_name(node):
#used for variables with dots
parts = []
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))
class VariableExtractor(ast.NodeVisitor):
def __init__(self):
self.variables = set()
def visit_Name(self, node):
self.variables.add(node.id)
def visit_Attribute(self, node):
self.variables.add(build_full_name(node))
class FormulaEvaluator(ast.NodeVisitor):
def __init__(self, values):
self.values = values
def visit_BinOp(self, node):
left = self.visit(node.left)
right = self.visit(node.right)
return OPERATORS[type(node.op)](left, right)
def visit_Name(self, node):
return self.values[node.id]
def visit_Attribute(self, node):
return self.values[build_full_name(node)]
def visit_Constant(self, node):
return node.value
def generic_visit(self, node):
raise ValueError(f"Operation not permitted: {type(node).__name__}")
@@ -94,6 +94,13 @@ def map_type_representations(
ifcopenshell.api.geometry.remove_representation(file, representation=representation)
for representation_map in relating_type.RepresentationMaps:
representation = representation_map.MappedRepresentation
# 'Reference' representations are, per IfcShapeRepresentation, "not part of
# the Body representation" (used e.g. for opening geometries excluded from an
# implicit Boolean operation). They may be carried on a type purely as a
# template (e.g. a shared opening body) and must not be mapped onto
# occurrences as their own geometry.
if representation.RepresentationIdentifier == "Reference":
continue
mapped_representation = ifcopenshell.api.geometry.map_representation(file, representation=representation)
ifcopenshell.api.geometry.assign_representation(
file,
@@ -74,14 +74,29 @@ def test_opening_unicode():
@pytest.mark.skipif(psutil is None, reason="psutil not installed")
def test_memusage_partial_open():
m0 = psutil.Process().memory_info().rss
f = ifcopenshell.open(fn)
m1 = psutil.Process().memory_info().rss
g = ifcopenshell.open(fn, bypass_types=("IfcRepresentationItem",))
m2 = psutil.Process().memory_info().rss
# arbitrary...
expected_ratio = 0.75
assert (m2 - m1) < (m1 - m0) * expected_ratio
# Run in a subprocess to ensure the file is not already in the process page
# cache from earlier tests, which would make both RSS deltas read as zero.
import subprocess
import sys
script = f"""
import psutil
import ifcopenshell
fn = {repr(fn)}
m0 = psutil.Process().memory_info().rss
f = ifcopenshell.open(fn)
m1 = psutil.Process().memory_info().rss
g = ifcopenshell.open(fn, bypass_types=("IfcRepresentationItem",))
m2 = psutil.Process().memory_info().rss
expected_ratio = 0.75
assert (m2 - m1) < (m1 - m0) * expected_ratio, (
f"bypass_types did not reduce memory: normal open added {{m1 - m0}} bytes, "
f"bypass open added {{m2 - m1}} bytes (expected < {{(m1 - m0) * expected_ratio:.0f}})"
)
"""
result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr or result.stdout
def test_rocks():
@@ -111,7 +111,7 @@ class Patcher(ifcpatch.BasePatcher):
if element.is_a("IfcProject"):
proj = self.new.add(element)
for ctx in element.RepresentationContexts:
for coop in ctx.HasCoordinateOperation:
for coop in getattr(ctx, 'HasCoordinateOperation', ()):
self.new.add(coop)
return proj
return ifcopenshell.api.project.append_asset(