mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-06 16:01:36 +00:00
Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ece3790aa | |||
| 6585f0ee2b | |||
| 4ec042595e | |||
| 48f6e2908b | |||
| 1cd7e52c49 | |||
| 2e6f17ed0f | |||
| b549e65ad9 | |||
| ee5d672493 | |||
| 135f4cf023 | |||
| 608d9ead0e | |||
| a0ce930994 | |||
| eafa158ca0 | |||
| 5db955d40c | |||
| df27f86237 | |||
| 0aaafaedc9 | |||
| 7881f5992f | |||
| 679fe4dcae | |||
| a89621b179 | |||
| 110e4050c8 | |||
| 6dafb7a5c2 | |||
| 4cedeec813 | |||
| 6314d9c818 | |||
| 9bbd2b1854 | |||
| b5d36aacf6 | |||
| 6d3bed1f7d | |||
| 0096c0f6a2 | |||
| 661be0d66d | |||
| 62ed650b75 | |||
| da4b2f6eee | |||
| 00ec587296 | |||
| 2a05528b6d | |||
| a5f7f0cd93 | |||
| 1fd7329122 | |||
| f3e047d78e | |||
| 041306c5f0 | |||
| 5fba0026dd | |||
| 4d92a64206 | |||
| 9d2de117a9 | |||
| 6ee3c7a15f | |||
| fdf9970685 | |||
| de65e50fb5 | |||
| 714105b9fd | |||
| f0b5ab860f | |||
| 528964ca56 | |||
| 2c2d0f2434 |
@@ -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;
|
||||
|
||||
@@ -129,6 +129,21 @@ on CI to catch formatting issues.
|
||||
within each package under `src/`.
|
||||
- Run the existing test suite for the package you modified before submitting.
|
||||
|
||||
## In-Progress Feature Notes
|
||||
|
||||
Living design and working notes for unmerged feature branches live in
|
||||
[`docs/dev-notes/`](docs/dev-notes/), one Markdown file per feature, named after the
|
||||
branch. They capture the problem, the design decisions and the *why*, and what still
|
||||
needs testing — so collaborators (and their AI agents) can pick up the context behind a
|
||||
branch. Because the note is committed on the branch, it travels with the PR.
|
||||
|
||||
- Before working on a feature branch, read its note in `docs/dev-notes/` if one exists.
|
||||
- Keep the note current as the PR is refined.
|
||||
- These are not user documentation; at merge they are removed or their durable parts
|
||||
promoted to code comments / permanent docs.
|
||||
|
||||
See [`docs/dev-notes/README.md`](docs/dev-notes/README.md) for details.
|
||||
|
||||
## Architecture Quick Reference
|
||||
|
||||
### Directory Structure
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<!-- This file was generated with the assistance of an AI coding tool. -->
|
||||
|
||||
# Developer notes (in-progress features)
|
||||
|
||||
This directory holds **living design/working notes for unmerged feature branches**,
|
||||
one Markdown file per feature, named after its branch (e.g.
|
||||
`opening-template-on-type.md`).
|
||||
|
||||
## Purpose
|
||||
|
||||
A shared scratchpad so collaborators — and the AI agents they work with — can pick up
|
||||
the context behind an in-progress branch: the problem, the design decisions and the
|
||||
*why*, dead ends already ruled out, and what still needs testing. Because the note is
|
||||
committed on the branch, it travels with the PR and shows up in the diff, so it is
|
||||
discoverable without anyone being told where to look.
|
||||
|
||||
## How to use it (humans and agents)
|
||||
|
||||
- **Before working on a feature branch**, read its note here if one exists.
|
||||
- **As the PR is refined**, keep the note current — append decisions, correct things
|
||||
that changed, update the test checklist.
|
||||
- **One file per feature**, named after the branch.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
These are *not* permanent user documentation. When a PR merges, either remove its note
|
||||
or promote the durable parts (the load-bearing "why") into code comments or the regular
|
||||
docs, so stale notes do not accumulate on the default branch.
|
||||
@@ -0,0 +1,165 @@
|
||||
<!-- This file was generated with the assistance of an AI coding tool. -->
|
||||
|
||||
# Opening template on type — preserving custom openings across duplicate_type / append
|
||||
|
||||
> **Living dev note** for the `opening-template-on-type` branch/PR. Read before working
|
||||
> on the feature; append decisions and findings as the PR is refined. This is *not* user
|
||||
> documentation — at merge it is removed or its durable parts promoted to code comments.
|
||||
> See [README.md](README.md) for the convention.
|
||||
|
||||
## Problem
|
||||
|
||||
`bpy.ops.bim.duplicate_type` and `bpy.ops.bim.append_library_element` lose a custom
|
||||
`IfcOpeningElement` body (e.g. an `IfcPolygonalFaceSet`/tessellation) and replace it
|
||||
with a generated extrusion. Root cause: the only mechanism that preserved a custom
|
||||
opening was "copy it from a sibling occurrence of the same type"
|
||||
(`get_existing_opening_occurrence_if_any`), which returns nothing for a brand-new
|
||||
type. `generate_opening_from_filling` then always builds an extrusion (profile or
|
||||
bbox), discarding the custom geometry.
|
||||
|
||||
## Key facts established
|
||||
|
||||
- IFC-level `root.copy_class` already `copy_deep`s opening representations; the loss
|
||||
happens on the Bonsai side (the `regenerate_from_type` listener on
|
||||
`type.assign_type`, and placement-time generation).
|
||||
- Opening occurrences of one type already **share** a single `IfcRepresentationMap`
|
||||
via mapped representations — that is why editing one void edits them all
|
||||
(see `tool.Model.unshare_opening_representation` docstring). Bonsai shares, it does
|
||||
not copy. The shared map just has no durable home (it is hosted implicitly by
|
||||
whichever occurrence exists), so it does not survive to a new type.
|
||||
- IFC4 ADD2 TC1 `IfcShapeRepresentation`: identifier **`Reference`** = "3D
|
||||
representation that is **not part of the Body representation** ... used, e.g., for
|
||||
opening geometries ... excluded from an implicit Boolean operation." Schema-valid;
|
||||
`IfcTypeProduct` has no uniqueness rule on `RepresentationMaps` (only
|
||||
`ApplicableOccurrence`). So a `Reference` map can sit beside the `Body` map.
|
||||
- The geometry kernel selects an opening's geometry **by context, not by
|
||||
`RepresentationIdentifier`** (`mapping::representation_of`, `ifcgeom/mapping/mapping.cpp`).
|
||||
So a `Reference`-identified opening in the Body context still booleans correctly.
|
||||
Nothing in Bonsai reads `"Reference"` to *skip* applying an opening.
|
||||
- Caveat: IFC has no type-level void (`IfcRelVoidsElement` is occurrence-only). The
|
||||
"opening template on type" is therefore a Bonsai convention using a spec-valid
|
||||
identifier; other tools see a harmless extra `Reference` rep they ignore. The
|
||||
regeneration smarts are Bonsai-only by necessity.
|
||||
|
||||
## Design
|
||||
|
||||
Store the shared opening body on the **type** as a `Reference` representation map.
|
||||
Because `bim.duplicate_type` (`tool.Root.copy_representation`) and
|
||||
`append_type_product` both copy a type's `RepresentationMaps`, the template survives
|
||||
both. Occurrence openings map over the same map, so editing a void rewrites the
|
||||
shared map = updates the type template in one stroke (no separate write-back needed).
|
||||
|
||||
`map_type_representations` must skip `Reference` maps so the window/door occurrence
|
||||
does not receive the opening shape as its own Body (the kernel would otherwise pick
|
||||
arbitrarily between the real Body and the opening rep). The skip is both required and
|
||||
spec-endorsed ("not part of the Body representation").
|
||||
|
||||
### Body-context coexistence (Option A)
|
||||
|
||||
The template lives in the **Body** subcontext (required: the instance opening that maps
|
||||
over it must resolve in Body context for the geometry kernel to subtract it). So the
|
||||
type holds two reps in one context: the `Body` window body and the `Reference` opening
|
||||
template. Per IFC, `Reference` is a *RepresentationIdentifier value used within the Body
|
||||
context*, not a separate context - so we keep it there and disambiguate elsewhere:
|
||||
|
||||
- The representations panel now shows `RepresentationIdentifier` as its own column
|
||||
(`geometry/data.py`, `geometry/ui.py`) so the two Body-context reps are
|
||||
distinguishable (`Model | Body | MODEL_VIEW | Reference | Tessellation`). The panel
|
||||
column previously read "Body" because it shows `ContextOfItems.ContextIdentifier`,
|
||||
not the representation's identifier.
|
||||
- `Geometry.reimport_element_representations` type branch now renders the requested
|
||||
`base_representation` instead of `get_representation(element, context)`, which matched
|
||||
only by context and returned the window body when switching to the `Reference` rep.
|
||||
This is what makes "switch to the Reference row" actually show the void on the type.
|
||||
|
||||
### Precedence in `generate_opening_from_filling`
|
||||
type `Reference` template → (existing sibling occurrence, checked by callers) →
|
||||
type `Profile` extrusion → bbox extrusion.
|
||||
|
||||
### Type switching (assign_type)
|
||||
|
||||
On `type.assign_type` the opening is rebuilt to reflect the **assigned** type's void.
|
||||
Two listeners in `model/handler.py`:
|
||||
|
||||
- **pre** `Bonsai.Opening.PreserveOnTypeChange` → `preserve_opening_on_type_change`:
|
||||
before the filling moves to the new type, `promote_opening_to_type(old_type)` anchors
|
||||
the old type's custom void as a template, so it isn't lost when (possibly the last)
|
||||
occurrence is regenerated. Idempotent; custom voids only.
|
||||
- **post** `Bonsai.Opening.RegenerateFromType` → `regenerate_from_type` →
|
||||
`_regenerate_from_type`: rebuilds from the new type's template / sibling / extrusion.
|
||||
The old PR1 "preserve custom" guard was **removed** here — it kept the previous type's
|
||||
void on a switch (wrong), and the template now makes preservation unnecessary.
|
||||
|
||||
NOTE: upstream `v0.8.0` landed `assign_type` changes + new `test_assign_type_*` tests
|
||||
(merged under this branch's base). The listeners ride on top of that — re-test the
|
||||
switch/edit round-trips against the new `assign_type`.
|
||||
|
||||
### Write-back on void edit
|
||||
|
||||
Editing an occurrence's void writes the new geometry back to the type's `Reference`
|
||||
template via `update_type_template_from_opening` (creates the template if absent), then
|
||||
**re-maps every occurrence's opening onto the template** and reloads the affected host walls
|
||||
(`switch_representation`) so they re-boolean. The re-map (`_remap_opening_to_template`) is the
|
||||
key part: an earlier version only re-pointed a *pre-existing* shared map, so siblings whose
|
||||
openings were **independent** (their own `IfcRepresentationMap`, never sharing the template)
|
||||
didn't follow — the common real-world case. Now they do. Hooked at both commit paths:
|
||||
`UpdateRepresentation._execute` (the `edited_objs` path) and
|
||||
`OverrideModeSetObject` after `edit_representation_item` (the in-place item edit). The
|
||||
older `edit_openings`/`is_edited` path also calls it. `set_type_opening_representation`
|
||||
has replace semantics (one `Reference` map per type).
|
||||
|
||||
### Preserving adjusted extrusions (duplicate_type)
|
||||
|
||||
`is_opening_representation_custom` only flags *non-extrusion* geometry (tessellation, brep,
|
||||
CSG) as worth preserving — a proxy for "not regenerable". That mis-classifies a *manually
|
||||
adjusted* extrusion, which is still an `IfcExtrudedAreaSolid`, so a hand-tweaked extrusion
|
||||
opening was reset to the default on `duplicate_type`.
|
||||
|
||||
`promote_opening_to_type` now gates on `should_preserve_opening` = custom **or**
|
||||
`_is_adjusted_extrusion`. The latter generates the default (`generate_opening_from_filling`,
|
||||
which yields the default since no template exists at promote time) *transiently*, compares the
|
||||
two bodies' axis-aligned bounding boxes (1 mm tolerance) via the geom engine, then removes the
|
||||
temporary default. Divergence ⇒ the extrusion was adjusted ⇒ promote it; a plain default
|
||||
matches ⇒ left regenerable (not frozen — see the "freeze" discussion). Scoped to the duplicate
|
||||
path so the generate-and-compare stays out of the hot predicate. Limitation: bbox comparison
|
||||
misses a shape change that preserves the bbox (upgrade to a vertex-set compare if needed).
|
||||
|
||||
## Status — implemented (manually verified in Blender)
|
||||
|
||||
- core `map_type_representations.py`: skip `Reference` maps.
|
||||
- `model/opening.py`: `get_/set_type_opening_representation`, `promote_opening_to_type`,
|
||||
`update_type_template_from_opening` (+ `_remap_opening_to_template`),
|
||||
`preserve_opening_on_type_change`, `should_preserve_opening` (+ `_is_adjusted_extrusion`,
|
||||
`_representation_bbox`); `generate_opening_from_filling` consults the template; PR1 guard
|
||||
removed from `_regenerate_from_type`.
|
||||
- `model/handler.py`: pre + post assign_type listeners.
|
||||
- `type/operator.py` `DuplicateType`: promote before copy.
|
||||
- `project/operator.py` `AppendLibraryElement`: `harvest_opening_template`.
|
||||
- `geometry/operator.py`: write-back hooks in `UpdateRepresentation` and
|
||||
`OverrideModeSetObject`; `reimport_element_representations` renders the requested rep.
|
||||
- `geometry/data.py` + `geometry/ui.py`: `RepresentationIdentifier` column + headers.
|
||||
|
||||
Branch `opening-template-on-type` (#8200): initial feature commit + the #7916 build-conflict
|
||||
ancestry-merge + void-propagation-to-all-occurrences + adjusted-extrusion preservation. The
|
||||
`docs/dev-notes/` convention itself lives on the stacked branch `dev-notes-system` (#8201).
|
||||
|
||||
Still **deferred:** explicit "Apply/Reset to type" operators + a "diverges from type"
|
||||
indicator; import never auto-writes back. `update_simple_openings` still keeps its
|
||||
`is_opening_representation_custom` guard (array propagation, same type — left as-is).
|
||||
|
||||
## Things to test / verify
|
||||
|
||||
- Duplicated/appended type's new occurrence gets the faceset void and it **cuts** the
|
||||
wall (kernel selects opening geom by context, so a `Reference`-id rep still booleans).
|
||||
- `harvest_opening_template` cross-file `file.add`: no duplicate
|
||||
`IfcGeometricRepresentationContext` left behind; units (kernel doesn't rescale rep
|
||||
coords — same assumption as `append_asset`).
|
||||
- Switch X→Y→X round-trip restores each type's void; switching to a plain (template-less)
|
||||
type gives its default extrusion, not the previous faceset.
|
||||
- Edit a void → type's `Reference` row updates; **all** occurrences follow (including ones
|
||||
that had independent openings) and their host walls re-boolean; survives duplicate.
|
||||
- `duplicate_type` on a type whose extrusion opening was **manually adjusted** → Type B keeps
|
||||
the adjusted extrusion; a type with a plain/default extrusion stays regenerable (not frozen).
|
||||
- Three write-back hooks are intentional (different commit paths) — candidate for
|
||||
consolidation in review.
|
||||
- Re-test against upstream's new `assign_type` (see NOTE under "Type switching").
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -744,7 +744,7 @@ class EnableEditingSurfaceStyle(bpy.types.Operator):
|
||||
if self.ifc_class == "IfcSurfaceStyleLighting":
|
||||
|
||||
def callback(attribute_name: str, _: object, data: dict[str, Any]) -> None:
|
||||
assert attributes
|
||||
assert attributes is not None
|
||||
color = attributes.add()
|
||||
assert isinstance(color, ColourRgb)
|
||||
color.name = attribute_name
|
||||
@@ -782,34 +782,40 @@ class EditSurfaceStyle(bpy.types.Operator, tool.Ifc.Operator):
|
||||
def _execute(self, context):
|
||||
self.props = tool.Style.get_style_props()
|
||||
self.style = tool.Ifc.get().by_id(self.props.is_editing_style)
|
||||
prev_update_graph = self.props.update_graph
|
||||
self.props["update_graph"] = False
|
||||
|
||||
style_elements = tool.Style.get_style_elements(self.style)
|
||||
# NOTE: currently this operator is used to edit existing (and only existing) IfcSurfaceStyles
|
||||
# or new or existing IfcSurfaceStyle components (shading, etc)
|
||||
# which is kind of confusing.
|
||||
if self.props.is_editing_class == "IfcSurfaceStyle":
|
||||
self.surface_style = self.style
|
||||
else:
|
||||
self.surface_style = style_elements.get(self.props.is_editing_class, None)
|
||||
self.shading_style = style_elements.get("IfcSurfaceStyleShading", None)
|
||||
self.rendering_style = style_elements.get("IfcSurfaceStyleRendering", None)
|
||||
self.texture_style = style_elements.get("IfcSurfaceStyleWithTextures", None)
|
||||
try:
|
||||
style_elements = tool.Style.get_style_elements(self.style)
|
||||
|
||||
if self.surface_style:
|
||||
result = self.edit_existing_style()
|
||||
else:
|
||||
result = self.add_new_style()
|
||||
# NOTE: currently this operator is used to edit existing (and only existing) IfcSurfaceStyles
|
||||
# or new or existing IfcSurfaceStyle components (shading, etc)
|
||||
# which is kind of confusing.
|
||||
if self.props.is_editing_class == "IfcSurfaceStyle":
|
||||
self.surface_style = self.style
|
||||
else:
|
||||
self.surface_style = style_elements.get(self.props.is_editing_class, None)
|
||||
self.shading_style = style_elements.get("IfcSurfaceStyleShading", None)
|
||||
self.rendering_style = style_elements.get("IfcSurfaceStyleRendering", None)
|
||||
self.texture_style = style_elements.get("IfcSurfaceStyleWithTextures", None)
|
||||
|
||||
if result:
|
||||
return result
|
||||
if self.surface_style:
|
||||
result = self.edit_existing_style()
|
||||
else:
|
||||
result = self.add_new_style()
|
||||
|
||||
tool.Style.disable_editing()
|
||||
core.load_styles(tool.Style, style_type=self.props.style_type)
|
||||
if result:
|
||||
return result
|
||||
|
||||
# restore selected style type
|
||||
material = tool.Ifc.get_object(self.style)
|
||||
msprops = tool.Style.get_material_style_props(material)
|
||||
msprops.active_style_type = msprops.active_style_type
|
||||
tool.Style.disable_editing()
|
||||
core.load_styles(tool.Style, style_type=self.props.style_type)
|
||||
|
||||
# restore selected style type
|
||||
material = tool.Ifc.get_object(self.style)
|
||||
msprops = tool.Style.get_material_style_props(material)
|
||||
msprops.active_style_type = msprops.active_style_type
|
||||
finally:
|
||||
self.props["update_graph"] = prev_update_graph
|
||||
|
||||
def edit_existing_style(self) -> None:
|
||||
ifc_file = tool.Ifc.get()
|
||||
@@ -1231,4 +1237,5 @@ class RemoveSurfaceStyle(bpy.types.Operator, tool.Ifc.Operator):
|
||||
surface_style = tool.Style.get_style_elements(style)[props.is_editing_class]
|
||||
ifcopenshell.api.style.remove_surface_style(ifc_file, surface_style)
|
||||
core.disable_editing_style(tool.Style)
|
||||
core.load_styles(tool.Style, style_type=props.style_type)
|
||||
return {"FINISHED"}
|
||||
|
||||
@@ -185,6 +185,13 @@ class ColourRgb(PropertyGroup):
|
||||
# to fit blender.bim.helper.draw_attribute
|
||||
is_optional = False
|
||||
special_type = ""
|
||||
data_type = ""
|
||||
ifc_class = ""
|
||||
use_explorer_ui = False
|
||||
|
||||
@property
|
||||
def display_name(self):
|
||||
return self.name
|
||||
|
||||
def get_value_name(self, *args, **kwargs):
|
||||
return "color_value"
|
||||
|
||||
@@ -176,8 +176,30 @@ class BIM_PT_styles(Panel):
|
||||
row.prop(self.props, "reflectance_method")
|
||||
|
||||
if self.props.reflectance_method not in ("PHYSICAL", "NOTDEFINED", "FLAT"):
|
||||
self.layout.label(text="Supported reflectance methods are:")
|
||||
self.layout.label(text="PHYSICAL / NOTDEFINED / FLAT")
|
||||
self.layout.label(
|
||||
text=f"{self.props.reflectance_method} will be skipped: only PHYSICAL / NOTDEFINED / FLAT are supported",
|
||||
icon="ERROR",
|
||||
)
|
||||
elif self.props.reflectance_method in ("PHYSICAL", "NOTDEFINED"):
|
||||
if self.props.specular_colour_class == "IfcColourRgb":
|
||||
self.layout.label(
|
||||
text="Metallic color is IFC-only in PHYSICAL/NOTDEFINED and does not affect Blender appearance",
|
||||
icon="ERROR",
|
||||
)
|
||||
elif self.props.reflectance_method == "FLAT":
|
||||
if self.props.diffuse_colour_class == "IfcNormalisedRatioMeasure":
|
||||
self.layout.label(
|
||||
text="Emissive ratio is IFC-only in FLAT Reflectance method and does not affect Blender appearance",
|
||||
icon="ERROR",
|
||||
)
|
||||
self.layout.label(
|
||||
text="Specular value is IFC-only in FLAT Reflectance method and does not affect Blender appearance",
|
||||
icon="ERROR",
|
||||
)
|
||||
self.layout.label(
|
||||
text="Highlight value is IFC-only in FLAT Reflectance method and does not affect Blender appearance",
|
||||
icon="ERROR",
|
||||
)
|
||||
|
||||
row = self.layout.row(align=True)
|
||||
row.label(text="Emissive" if self.props.reflectance_method == "FLAT" else "Diffuse")
|
||||
@@ -232,6 +254,8 @@ class BIM_PT_styles(Panel):
|
||||
row.operator("bim.add_surface_texture", text="", icon="ADD")
|
||||
if textures:
|
||||
self.layout.prop(self.props, "uv_mode")
|
||||
if self.props.uv_mode in ("Generated", "Camera"):
|
||||
self.layout.label(text="Not available in SOLID Mode", icon="INFO")
|
||||
|
||||
for i, texture in enumerate(textures):
|
||||
split = self.layout.split(factor=0.30, align=True)
|
||||
@@ -244,6 +268,22 @@ class BIM_PT_styles(Panel):
|
||||
op_clear = row.operator("bim.remove_texture_map", text="", icon="X")
|
||||
op_path.texture_map_index = op_clear.texture_map_index = i
|
||||
|
||||
reflectance = self.props.reflectance_method
|
||||
mode = texture.mode
|
||||
if reflectance == "FLAT":
|
||||
if mode != "EMISSIVE":
|
||||
self.layout.label(
|
||||
text=f"{mode} will be skipped: only EMISSIVE is supported for Render Reflectance FLAT",
|
||||
icon="ERROR",
|
||||
)
|
||||
elif reflectance in ("PHYSICAL", "NOTDEFINED"):
|
||||
_SUPPORTED = {"DIFFUSE", "NORMAL", "METALLICROUGHNESS", "EMISSIVE", "OCCLUSION"}
|
||||
if mode not in _SUPPORTED:
|
||||
self.layout.label(
|
||||
text=f"{mode} will be skipped: not supported for Render Reflectance PHYSICAL/NOTDEFINED",
|
||||
icon="ERROR",
|
||||
)
|
||||
|
||||
def draw_externally_defined_surface_style(self):
|
||||
row = self.layout.row()
|
||||
op = row.operator("bim.browse_external_style", icon="APPEND_BLEND", text="Append From Blend File")
|
||||
@@ -252,10 +292,17 @@ class BIM_PT_styles(Panel):
|
||||
bonsai.bim.helper.draw_attributes(self.props.external_style_attributes, self.layout, enable_search=True)
|
||||
|
||||
def draw_refraction_surface_style(self):
|
||||
self.layout.label(
|
||||
text="Refraction values are IFC-only and do not affect Blender surface appearance",
|
||||
icon="ERROR",
|
||||
)
|
||||
bonsai.bim.helper.draw_attributes(self.props.refraction_style_attributes, self.layout, enable_search=True)
|
||||
row = self.layout.row(align=True)
|
||||
|
||||
def draw_lighting_surface_style(self):
|
||||
self.layout.label(
|
||||
text="Lighting values are IFC-only and do not affect Blender surface appearance",
|
||||
icon="ERROR",
|
||||
)
|
||||
bonsai.bim.helper.draw_attributes(self.props.lighting_style_colours, self.layout)
|
||||
|
||||
def draw_edit_ui(self, edit_label: str):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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,,
|
||||
|
@@ -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,
|
||||
|
@@ -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,
|
||||
|
@@ -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
|
||||
|
@@ -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,,
|
||||
|
@@ -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,,,,,
|
||||
|
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,,,,,
|
||||
|
@@ -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)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user