Compare commits

..

6 Commits

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

Generated with the assistance of an AI coding tool.

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

Generated with the assistance of an AI coding tool.

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

Generated with the assistance of an AI coding tool.

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

Generated with the assistance of an AI coding tool.

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 15:21:49 -05:00
88 changed files with 1097 additions and 2117 deletions
+15
View File
@@ -129,6 +129,21 @@ on CI to catch formatting issues.
within each package under `src/`.
- Run the existing test suite for the package you modified before submitting.
## In-Progress Feature Notes
Living design and working notes for unmerged feature branches live in
[`docs/dev-notes/`](docs/dev-notes/), one Markdown file per feature, named after the
branch. They capture the problem, the design decisions and the *why*, and what still
needs testing — so collaborators (and their AI agents) can pick up the context behind a
branch. Because the note is committed on the branch, it travels with the PR.
- Before working on a feature branch, read its note in `docs/dev-notes/` if one exists.
- Keep the note current as the PR is refined.
- These are not user documentation; at merge they are removed or their durable parts
promoted to code comments / permanent docs.
See [`docs/dev-notes/README.md`](docs/dev-notes/README.md) for details.
## Architecture Quick Reference
### Directory Structure
+7 -13
View File
@@ -27,14 +27,13 @@ endif()
set(CMAKE_CXX_STANDARD_REQUIRED ON) # not necessary, but encouraged
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
# The VERSION file in the repository root is the single source of truth for the
# release version. Read it unconditionally so a plain source build reports the
# real version through buildinfo.cpp instead of the stale hardcoded 0.8.0
# fallback (see #8164). VERSION_OVERRIDE still controls the branch name used
# when ADD_COMMIT_SHA embeds a commit sha.
file(READ "../VERSION" "RELEASE_VERSION_")
string(STRIP "${RELEASE_VERSION_}" RELEASE_VERSION)
message(STATUS "Detected version '${RELEASE_VERSION}'")
if(VERSION_OVERRIDE)
file(READ "../VERSION" "RELEASE_VERSION_")
string(STRIP "${RELEASE_VERSION_}" RELEASE_VERSION)
message(STATUS "Detected version '${RELEASE_VERSION}'")
else()
set(RELEASE_VERSION "0.8.0")
endif()
add_definitions(-D_DISABLE_CONSTEXPR_MUTEX_CONSTRUCTOR)
@@ -661,11 +660,6 @@ if(ADD_COMMIT_SHA)
endif()
endif(ADD_COMMIT_SHA)
# Always expose the release version (from the VERSION file) to buildinfo.cpp so
# that a build without commit-sha info reports the correct version instead of a
# stale hardcoded fallback. See #8164.
target_compile_definitions(IfcParse PRIVATE IFCOPENSHELL_VERSION_STRING=${RELEASE_VERSION})
if(MSVC)
# @todo still needs to be understood better, but the cgal and cgal-simple kernel cause multiply defined boost lambda placeholders _1 ... _3
set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} /FORCE:MULTIPLE")
+28
View File
@@ -0,0 +1,28 @@
<!-- This file was generated with the assistance of an AI coding tool. -->
# Developer notes (in-progress features)
This directory holds **living design/working notes for unmerged feature branches**,
one Markdown file per feature, named after its branch (e.g.
`opening-template-on-type.md`).
## Purpose
A shared scratchpad so collaborators — and the AI agents they work with — can pick up
the context behind an in-progress branch: the problem, the design decisions and the
*why*, dead ends already ruled out, and what still needs testing. Because the note is
committed on the branch, it travels with the PR and shows up in the diff, so it is
discoverable without anyone being told where to look.
## How to use it (humans and agents)
- **Before working on a feature branch**, read its note here if one exists.
- **As the PR is refined**, keep the note current — append decisions, correct things
that changed, update the test checklist.
- **One file per feature**, named after the branch.
## Lifecycle
These are *not* permanent user documentation. When a PR merges, either remove its note
or promote the durable parts (the load-bearing "why") into code comments or the regular
docs, so stale notes do not accumulate on the default branch.
+165
View File
@@ -0,0 +1,165 @@
<!-- This file was generated with the assistance of an AI coding tool. -->
# Opening template on type — preserving custom openings across duplicate_type / append
> **Living dev note** for the `opening-template-on-type` branch/PR. Read before working
> on the feature; append decisions and findings as the PR is refined. This is *not* user
> documentation — at merge it is removed or its durable parts promoted to code comments.
> See [README.md](README.md) for the convention.
## Problem
`bpy.ops.bim.duplicate_type` and `bpy.ops.bim.append_library_element` lose a custom
`IfcOpeningElement` body (e.g. an `IfcPolygonalFaceSet`/tessellation) and replace it
with a generated extrusion. Root cause: the only mechanism that preserved a custom
opening was "copy it from a sibling occurrence of the same type"
(`get_existing_opening_occurrence_if_any`), which returns nothing for a brand-new
type. `generate_opening_from_filling` then always builds an extrusion (profile or
bbox), discarding the custom geometry.
## Key facts established
- IFC-level `root.copy_class` already `copy_deep`s opening representations; the loss
happens on the Bonsai side (the `regenerate_from_type` listener on
`type.assign_type`, and placement-time generation).
- Opening occurrences of one type already **share** a single `IfcRepresentationMap`
via mapped representations — that is why editing one void edits them all
(see `tool.Model.unshare_opening_representation` docstring). Bonsai shares, it does
not copy. The shared map just has no durable home (it is hosted implicitly by
whichever occurrence exists), so it does not survive to a new type.
- IFC4 ADD2 TC1 `IfcShapeRepresentation`: identifier **`Reference`** = "3D
representation that is **not part of the Body representation** ... used, e.g., for
opening geometries ... excluded from an implicit Boolean operation." Schema-valid;
`IfcTypeProduct` has no uniqueness rule on `RepresentationMaps` (only
`ApplicableOccurrence`). So a `Reference` map can sit beside the `Body` map.
- The geometry kernel selects an opening's geometry **by context, not by
`RepresentationIdentifier`** (`mapping::representation_of`, `ifcgeom/mapping/mapping.cpp`).
So a `Reference`-identified opening in the Body context still booleans correctly.
Nothing in Bonsai reads `"Reference"` to *skip* applying an opening.
- Caveat: IFC has no type-level void (`IfcRelVoidsElement` is occurrence-only). The
"opening template on type" is therefore a Bonsai convention using a spec-valid
identifier; other tools see a harmless extra `Reference` rep they ignore. The
regeneration smarts are Bonsai-only by necessity.
## Design
Store the shared opening body on the **type** as a `Reference` representation map.
Because `bim.duplicate_type` (`tool.Root.copy_representation`) and
`append_type_product` both copy a type's `RepresentationMaps`, the template survives
both. Occurrence openings map over the same map, so editing a void rewrites the
shared map = updates the type template in one stroke (no separate write-back needed).
`map_type_representations` must skip `Reference` maps so the window/door occurrence
does not receive the opening shape as its own Body (the kernel would otherwise pick
arbitrarily between the real Body and the opening rep). The skip is both required and
spec-endorsed ("not part of the Body representation").
### Body-context coexistence (Option A)
The template lives in the **Body** subcontext (required: the instance opening that maps
over it must resolve in Body context for the geometry kernel to subtract it). So the
type holds two reps in one context: the `Body` window body and the `Reference` opening
template. Per IFC, `Reference` is a *RepresentationIdentifier value used within the Body
context*, not a separate context - so we keep it there and disambiguate elsewhere:
- The representations panel now shows `RepresentationIdentifier` as its own column
(`geometry/data.py`, `geometry/ui.py`) so the two Body-context reps are
distinguishable (`Model | Body | MODEL_VIEW | Reference | Tessellation`). The panel
column previously read "Body" because it shows `ContextOfItems.ContextIdentifier`,
not the representation's identifier.
- `Geometry.reimport_element_representations` type branch now renders the requested
`base_representation` instead of `get_representation(element, context)`, which matched
only by context and returned the window body when switching to the `Reference` rep.
This is what makes "switch to the Reference row" actually show the void on the type.
### Precedence in `generate_opening_from_filling`
type `Reference` template → (existing sibling occurrence, checked by callers) →
type `Profile` extrusion → bbox extrusion.
### Type switching (assign_type)
On `type.assign_type` the opening is rebuilt to reflect the **assigned** type's void.
Two listeners in `model/handler.py`:
- **pre** `Bonsai.Opening.PreserveOnTypeChange``preserve_opening_on_type_change`:
before the filling moves to the new type, `promote_opening_to_type(old_type)` anchors
the old type's custom void as a template, so it isn't lost when (possibly the last)
occurrence is regenerated. Idempotent; custom voids only.
- **post** `Bonsai.Opening.RegenerateFromType``regenerate_from_type`
`_regenerate_from_type`: rebuilds from the new type's template / sibling / extrusion.
The old PR1 "preserve custom" guard was **removed** here — it kept the previous type's
void on a switch (wrong), and the template now makes preservation unnecessary.
NOTE: upstream `v0.8.0` landed `assign_type` changes + new `test_assign_type_*` tests
(merged under this branch's base). The listeners ride on top of that — re-test the
switch/edit round-trips against the new `assign_type`.
### Write-back on void edit
Editing an occurrence's void writes the new geometry back to the type's `Reference`
template via `update_type_template_from_opening` (creates the template if absent), then
**re-maps every occurrence's opening onto the template** and reloads the affected host walls
(`switch_representation`) so they re-boolean. The re-map (`_remap_opening_to_template`) is the
key part: an earlier version only re-pointed a *pre-existing* shared map, so siblings whose
openings were **independent** (their own `IfcRepresentationMap`, never sharing the template)
didn't follow — the common real-world case. Now they do. Hooked at both commit paths:
`UpdateRepresentation._execute` (the `edited_objs` path) and
`OverrideModeSetObject` after `edit_representation_item` (the in-place item edit). The
older `edit_openings`/`is_edited` path also calls it. `set_type_opening_representation`
has replace semantics (one `Reference` map per type).
### Preserving adjusted extrusions (duplicate_type)
`is_opening_representation_custom` only flags *non-extrusion* geometry (tessellation, brep,
CSG) as worth preserving — a proxy for "not regenerable". That mis-classifies a *manually
adjusted* extrusion, which is still an `IfcExtrudedAreaSolid`, so a hand-tweaked extrusion
opening was reset to the default on `duplicate_type`.
`promote_opening_to_type` now gates on `should_preserve_opening` = custom **or**
`_is_adjusted_extrusion`. The latter generates the default (`generate_opening_from_filling`,
which yields the default since no template exists at promote time) *transiently*, compares the
two bodies' axis-aligned bounding boxes (1 mm tolerance) via the geom engine, then removes the
temporary default. Divergence ⇒ the extrusion was adjusted ⇒ promote it; a plain default
matches ⇒ left regenerable (not frozen — see the "freeze" discussion). Scoped to the duplicate
path so the generate-and-compare stays out of the hot predicate. Limitation: bbox comparison
misses a shape change that preserves the bbox (upgrade to a vertex-set compare if needed).
## Status — implemented (manually verified in Blender)
- core `map_type_representations.py`: skip `Reference` maps.
- `model/opening.py`: `get_/set_type_opening_representation`, `promote_opening_to_type`,
`update_type_template_from_opening` (+ `_remap_opening_to_template`),
`preserve_opening_on_type_change`, `should_preserve_opening` (+ `_is_adjusted_extrusion`,
`_representation_bbox`); `generate_opening_from_filling` consults the template; PR1 guard
removed from `_regenerate_from_type`.
- `model/handler.py`: pre + post assign_type listeners.
- `type/operator.py` `DuplicateType`: promote before copy.
- `project/operator.py` `AppendLibraryElement`: `harvest_opening_template`.
- `geometry/operator.py`: write-back hooks in `UpdateRepresentation` and
`OverrideModeSetObject`; `reimport_element_representations` renders the requested rep.
- `geometry/data.py` + `geometry/ui.py`: `RepresentationIdentifier` column + headers.
Branch `opening-template-on-type` (#8200): initial feature commit + the #7916 build-conflict
ancestry-merge + void-propagation-to-all-occurrences + adjusted-extrusion preservation. The
`docs/dev-notes/` convention itself lives on the stacked branch `dev-notes-system` (#8201).
Still **deferred:** explicit "Apply/Reset to type" operators + a "diverges from type"
indicator; import never auto-writes back. `update_simple_openings` still keeps its
`is_opening_representation_custom` guard (array propagation, same type — left as-is).
## Things to test / verify
- Duplicated/appended type's new occurrence gets the faceset void and it **cuts** the
wall (kernel selects opening geom by context, so a `Reference`-id rep still booleans).
- `harvest_opening_template` cross-file `file.add`: no duplicate
`IfcGeometricRepresentationContext` left behind; units (kernel doesn't rescale rep
coords — same assumption as `append_asset`).
- Switch X→Y→X round-trip restores each type's void; switching to a plain (template-less)
type gives its default extrusion, not the previous faceset.
- Edit a void → type's `Reference` row updates; **all** occurrences follow (including ones
that had independent openings) and their host walls re-boolean; survives duplicate.
- `duplicate_type` on a type whose extrusion opening was **manually adjusted** → Type B keeps
the adjusted extrusion; a type with a plain/default extrusion stays regenerable (not frozen).
- Three write-back hooks are intentional (different commit paths) — candidate for
consolidation in review.
- Re-test against upstream's new `assign_type` (see NOTE under "Type switching").
+3 -18
View File
@@ -82,15 +82,7 @@ import math
from collections.abc import Callable, Iterator
from dataclasses import dataclass
from enum import Enum
from typing import (
TYPE_CHECKING,
Any,
ClassVar,
Literal,
Optional,
Protocol,
runtime_checkable,
)
from typing import Any, ClassVar, Literal, Optional, Protocol, runtime_checkable
import blf
import bpy
@@ -113,9 +105,6 @@ from mathutils.kdtree import KDTree
import bonsai.tool as tool
from bonsai.bim.module.drawing.shaders import ExtrusionGuidesShader
if TYPE_CHECKING:
import bmesh
SNAP_POINT_SIZE = 10.0
SNAP_POINT_COLOR = (1.0, 0.5, 0.0, 1.0)
SNAP_MAX_RADIUS = 50.0
@@ -2046,9 +2035,7 @@ class TexturedQuadGizmoMixin(StaticTrisGizmoMixin):
def setup(self) -> None:
super().setup()
from bonsai.bim.module.drawing import (
gizmo_textures, # ty: ignore[unresolved-import]
)
from bonsai.bim.module.drawing import gizmo_textures
self._quad_batch = batch_for_shader(
gizmo_textures.get_shader(),
@@ -2057,9 +2044,7 @@ class TexturedQuadGizmoMixin(StaticTrisGizmoMixin):
)
def draw(self, context: bpy.types.Context) -> None:
from bonsai.bim.module.drawing import (
gizmo_textures, # ty: ignore[unresolved-import]
)
from bonsai.bim.module.drawing import gizmo_textures
texture = gizmo_textures.get_icon_texture(self.icon_name)
if texture is None:
@@ -951,12 +951,6 @@ class CreateDrawing(bpy.types.Operator):
tree = ifcopenshell.geom.tree()
tree.enable_face_styles(True)
# Accumulated across every file in the loop below (main model plus any
# linked models) so the SHAPELY fill pass after the loop covers all of
# them, not just whichever file happened to be processed last.
raycast_objs = set()
elements_with_faces = set()
for ifc_path, (ifc, link_matrix) in files.items():
# Don't use draw.main() just whilst we're prototyping and experimenting
# TODO: hash paths are never used
@@ -966,24 +960,13 @@ class CreateDrawing(bpy.types.Operator):
self.serialiser.setFile(ifc)
drawing_elements = tool.Drawing.get_drawing_elements(self.camera_element, ifc_file=ifc)
if self.cprops.fill_mode == "SHAPELY":
for element in drawing_elements.copy():
if element.is_a("IfcAnnotation"):
continue
obj = tool.Ifc.get_object(element)
if obj and obj.type == "MESH" and len(obj.data.polygons):
elements_with_faces.add(element.GlobalId)
raycast_objs.add(obj)
# Get all representation contexts to see what we're dealing with.
# Drawings only draw bodies and annotations (and facetation, due to a Revit bug).
# A drawing prioritises a target view context first, followed by a model view context as a fallback.
# Specifically for PLAN_VIEW and REFLECTED_PLAN_VIEW, any Plan context is also prioritised.
contexts = self.get_linework_contexts(ifc, target_view)
self.serialize_contexts_elements(ifc, tree, contexts, "body", drawing_elements, target_view, link_matrix)
self.serialize_contexts_elements(
ifc, tree, contexts, "annotation", 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"):
@@ -1050,6 +1033,16 @@ class CreateDrawing(bpy.types.Operator):
# shapely variant
group = root.find("{http://www.w3.org/2000/svg}g")
raycast_objs = set()
elements_with_faces = set()
for element in drawing_elements.copy():
if element.is_a("IfcAnnotation"):
continue
obj = tool.Ifc.get_object(element)
if obj and obj.type == "MESH" and len(obj.data.polygons):
elements_with_faces.add(element.GlobalId)
raycast_objs.add(obj)
projections = root.xpath(
".//svg:g[contains(@class, 'projection')]", namespaces={"svg": "http://www.w3.org/2000/svg"}
)
@@ -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)
+18 -1
View File
@@ -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",
@@ -27,7 +27,6 @@ import bonsai.tool as tool
from . import (
array,
covering,
decorator,
door,
external,
grid,
+3 -6
View File
@@ -329,7 +329,6 @@ class _ArrayEditMixin(ParametricEditMixinBase):
# Unhide the (possibly newly-regenerated) children so the user sees
# the committed result. Mirrors the hide in ``_enable_one``.
cls._set_children_visibility(element, hidden=False)
tool.Array.select_only_parent(obj, context)
@classmethod
def _cancel_one(cls, obj: bpy.types.Object) -> None:
@@ -422,9 +421,9 @@ class RegenerateArray(bpy.types.Operator, tool.Ifc.Operator):
pset = ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array")
arrays = json.loads(pset["Data"])
pset = tool.Ifc.get().by_id(pset["id"])
# Coalesce host recuts across the child-delete loop, the regenerate,
# and the per-child opening mirror: each fans out its own host body
# recut without the batch wrapper.
# Coalesce host recuts: the child-delete loop, the regenerate, and the
# per-child opening mirror all touch the same host body. Without batching,
# an N-child wipe-then-regen costs N+1 recuts; this collapses to one.
with tool.Geometry.batch_host_recut():
for array in arrays:
for child in set(array["children"]):
@@ -443,8 +442,6 @@ class RegenerateArray(bpy.types.Operator, tool.Ifc.Operator):
tool.Model.regenerate_array(parent, arrays)
tool.Array.constrain_children_to_parent(parent_element)
tool.Array.select_only_parent(parent, context)
class RemoveArray(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.remove_array"
@@ -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",
-10
View File
@@ -38,7 +38,6 @@ import numpy as np
from ifcopenshell.util.shape_builder import ShapeBuilder
from mathutils import Matrix, Vector
import bonsai.core.geometry
import bonsai.core.root
import bonsai.tool as tool
from bonsai.bim.module.drawing import gizmos as gizmo
@@ -1678,11 +1677,6 @@ def _n_mep_selected(n: int) -> bool:
element = tool.Ifc.get_entity(selected_obj)
if element is None or not tool.System.is_mep_element(element):
return False
# Array children mirror their parent's port topology. Writable MEP
# actions on a child get wiped by the next array regen, so gate the
# icons out at the visibility layer.
if tool.Array.is_array_child(element):
return False
return True
@@ -2561,8 +2555,6 @@ def _active_is_flow_segment(obj: bpy.types.Object) -> bool:
element = tool.Ifc.get_entity(obj)
if element is None or not element.is_a("IfcFlowSegment"):
return False
if tool.Array.is_array_child(element):
return False
return tool.System.has_parametric_body(element)
@@ -2592,8 +2584,6 @@ def _active_is_bend_fitting(obj: bpy.types.Object) -> bool:
element = tool.Ifc.get_entity(obj)
if not _is_bend_fitting(element):
return False
if tool.Array.is_array_child(element):
return False
element_type = ifcopenshell.util.element.get_type(element)
if element_type is None:
return False
@@ -420,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"]
@@ -437,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)
@@ -493,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"
)
@@ -590,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]:
@@ -1000,6 +1256,9 @@ class EditOpenings(Operator, tool.Ifc.Operator):
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
@@ -138,7 +138,7 @@ def update_bbim_railing_pset(element: ifcopenshell.entity_instance, railing_data
def generate_wall_mounted_handrail_preview(
obj: bpy.types.Object,
props: "prop.BIMRailingProperties",
props: "BIMRailingProperties",
path_data: dict[str, Any],
si_conversion: float,
) -> None:
@@ -860,9 +860,7 @@ class GizmoRailingSchematic(bpy.types.GizmoGroup, gizmo.BaseSchematicGizmoGroup)
terminal_world = anchor + billboard_rot @ view_rotation @ terminal_local
self.terminal_gizmo.matrix_basis = gizmo.billboarded_at(terminal_world, billboard_rot, 0.18)
def update_editing_gizmos(
self, context: bpy.types.Context, mw: "Matrix", props: "prop.BIMRailingProperties"
) -> None:
def update_editing_gizmos(self, context: bpy.types.Context, mw: "Matrix", props: "BIMRailingProperties") -> None:
"""Hide the pen gizmo while polyline path-edit is active; reposition the cycle icon.
The base class shows the pen gizmo whenever ``is_editing`` is False,
@@ -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")
@@ -1294,11 +1346,6 @@ class LoadProjectElements(bpy.types.Operator):
if element.IsDecomposedBy:
for subelement in element.IsDecomposedBy[0].RelatedObjects:
decomposed_elements.add(subelement)
# IfcSurfaceFeature (e.g. road markings) adhere to a host element
# via IfcRelAdheresToElement, a [1:1] hierarchical relationship in
# the same family as aggregation, containment and nesting (IFC4.3).
for rel in getattr(element, "HasSurfaceFeatures", ()):
decomposed_elements.update(rel.RelatedSurfaceFeatures)
if decomposed_elements:
self.append_decomposed_elements(decomposed_elements)
elements.update(decomposed_elements)
@@ -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()
+2 -61
View File
@@ -302,25 +302,9 @@ def add_drawing(
context=drawing.get_body_context(),
ifc_representation_class=None,
)
drawings_parent_group = None
for group in ifc.get().by_type("IfcGroup"):
if group.Name == "DRAWINGS" and group.ObjectType == "DRAWINGS":
drawings_parent_group = group
break
if not drawings_parent_group:
drawings_parent_group = ifc.run("group.add_group")
ifc.run(
"group.edit_group", group=drawings_parent_group, attributes={"Name": "DRAWINGS", "ObjectType": "DRAWINGS"}
)
group = ifc.run("group.add_group")
ifc.run("group.edit_group", group=group, attributes={"Name": drawing_name, "ObjectType": "DRAWING"})
ifc.run("group.assign_group", group=group, products=[element])
ifc.run("group.assign_group", group=drawings_parent_group, products=[group])
collector.assign(camera)
pset = ifc.run("pset.add_pset", product=element, name="EPset_Drawing")
if drawing.get_unit_system() == "METRIC":
@@ -351,22 +335,7 @@ def add_drawing(
},
)
drawing.setup_shading_styles_path(shading_styles_path)
drawings_parent_document = None
for document in ifc.get().by_type("IfcDocumentInformation"):
if document.Name == "DRAWINGS" and document.Scope == "DRAWINGS":
drawings_parent_document = document
break
if not drawings_parent_document:
drawings_parent_document = ifc.run("document.add_information")
if ifc.get_schema() == "IFC2X3":
attributes = {"DocumentId": "DRAWINGS", "Name": "DRAWINGS", "Scope": "DRAWINGS"}
else:
attributes = {"Identification": "DRAWINGS", "Name": "DRAWINGS", "Scope": "DRAWINGS"}
ifc.run("document.edit_information", information=drawings_parent_document, attributes=attributes)
information = ifc.run("document.add_information", parent=drawings_parent_document)
information = ifc.run("document.add_information")
uri = drawing.get_default_drawing_path(drawing_name)
reference = ifc.run("document.add_reference", information=information)
if ifc.get_schema() == "IFC2X3":
@@ -394,23 +363,9 @@ def duplicate_drawing(
drawing_tool.set_name(new_drawing, drawing_name)
group = drawing_tool.get_drawing_group(new_drawing)
ifc.run("group.unassign_group", group=group, products=[new_drawing])
drawings_parent_group = None
for parent_group in ifc.get().by_type("IfcGroup"):
if parent_group.Name == "DRAWINGS" and parent_group.ObjectType == "DRAWINGS":
drawings_parent_group = parent_group
break
if not drawings_parent_group:
drawings_parent_group = ifc.run("group.add_group")
ifc.run(
"group.edit_group", group=drawings_parent_group, attributes={"Name": "DRAWINGS", "ObjectType": "DRAWINGS"}
)
new_group = ifc.run("group.add_group")
ifc.run("group.edit_group", group=new_group, attributes={"Name": drawing_name, "ObjectType": "DRAWING"})
ifc.run("group.assign_group", group=new_group, products=[new_drawing])
ifc.run("group.assign_group", group=drawings_parent_group, products=[new_group])
if should_duplicate_annotations:
new_annotations: list[ifcopenshell.entity_instance] = []
annotation_objs = [ifc.get_object(a) for a in drawing_tool.get_group_elements(group) if a != drawing]
@@ -426,21 +381,7 @@ def duplicate_drawing(
old_reference = drawing_tool.get_drawing_document(new_drawing)
ifc.run("document.unassign_document", products=[new_drawing], document=old_reference)
drawings_parent_document = None
for document in ifc.get().by_type("IfcDocumentInformation"):
if document.Name == "DRAWINGS" and document.Scope == "DRAWINGS":
drawings_parent_document = document
break
if not drawings_parent_document:
drawings_parent_document = ifc.run("document.add_information")
if ifc.get_schema() == "IFC2X3":
attributes = {"DocumentId": "DRAWINGS", "Name": "DRAWINGS", "Scope": "DRAWINGS"}
else:
attributes = {"Identification": "DRAWINGS", "Name": "DRAWINGS", "Scope": "DRAWINGS"}
ifc.run("document.edit_information", information=drawings_parent_document, attributes=attributes)
information = ifc.run("document.add_information", parent=drawings_parent_document)
information = ifc.run("document.add_information")
uri = drawing_tool.get_default_drawing_path(drawing_name)
reference = ifc.run("document.add_reference", information=information)
if ifc.get_schema() == "IFC2X3":
+3 -4
View File
@@ -50,15 +50,14 @@ def copy_z_rotation_to_selected(
flip: bool = False,
) -> int:
"""Apply ``active``'s Z-Euler rotation to each target."""
source_z = surveyor.get_z_rotation(active) # ty: ignore[missing-argument]
source_z = surveyor.get_z_rotation(active)
if flip:
source_z += math.pi
rotated = 0
for obj in targets:
target_z = surveyor.get_z_rotation(obj) # ty: ignore[missing-argument]
if abs(_z_rotation_diff(target_z, source_z)) < Z_ROTATION_ALIGNMENT_TOLERANCE:
if abs(_z_rotation_diff(surveyor.get_z_rotation(obj), source_z)) < Z_ROTATION_ALIGNMENT_TOLERANCE:
continue
surveyor.set_z_rotation(obj, source_z) # ty: ignore[missing-argument]
surveyor.set_z_rotation(obj, source_z)
rotated += 1
if ifc.get_entity(obj) is not None:
bonsai.core.geometry.edit_object_placement(ifc, geometry, surveyor, obj=obj)
+1 -1
View File
@@ -804,7 +804,7 @@ class Profile:
@interface
class Parametric:
def get_geom_generation(cls): pass
def get_geom_generation(cls) -> int: pass
def refresh_post_commit(cls, operator) -> None: pass
-19
View File
@@ -178,25 +178,6 @@ class Array(bonsai.core.tool.Array):
element_root = cls.get_array_root_guid(element)
return [o for o in occurrences if cls.get_array_root_guid(o) == element_root]
@classmethod
def select_only_parent(cls, parent_obj: bpy.types.Object, context: bpy.types.Context) -> None:
"""Post-condition for the user-facing regenerate and finish-edit paths:
only ``parent_obj`` is selected + active. Grow and shrink otherwise
diverge on which objects stay selected, surfacing an inconsistency."""
tool.Blender.select_and_activate_single_object(context, parent_obj)
@classmethod
def is_array_child(cls, element: entity_instance) -> bool:
"""True when ``element`` is a child of a parametric array — has a
BBIM_Array pset whose Parent GUID points to a different element.
Lighter than ``get_child_layer_index`` (no ``by_guid`` lookup, no
Data parse); suitable for per-element checks in draw handlers."""
pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
if not pset:
return False
parent_guid = pset.get("Parent")
return bool(parent_guid) and parent_guid != element.GlobalId
@classmethod
def get_child_layer_index(cls, child_element: entity_instance) -> int | None:
"""Index of the layer that produced ``child_element``, or ``None``
+59 -54
View File
@@ -248,30 +248,32 @@ class Duplicate(bonsai.core.tool.Duplicate):
old_to_new: dict[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]],
) -> None:
for element, data in relationship.items():
new_relating_elements = old_to_new.get(data.relating_element) or []
new_related_elements = old_to_new.get(data.related_element) or []
try:
new_relating_element = old_to_new.get(data.relating_element)[0]
new_related_element = old_to_new.get(data.related_element)[0]
except (KeyError, IndexError, TypeError):
continue
new_rel = tool.Ifc.run(
"geometry.connect_path",
relating_element=new_relating_element,
related_element=new_related_element,
relating_connection=data.relating_connection_type,
related_connection=data.related_connection_type,
)
# connect_path hardcodes priorities to []; restore them post-hoc.
priority_attrs: dict[str, Any] = {}
if data.relating_priorities:
priority_attrs["RelatingPriorities"] = data.relating_priorities
if data.related_priorities:
priority_attrs["RelatedPriorities"] = data.related_priorities
for new_relating_element, new_related_element in zip(new_relating_elements, new_related_elements):
new_rel = tool.Ifc.run(
"geometry.connect_path",
relating_element=new_relating_element,
related_element=new_related_element,
relating_connection=data.relating_connection_type,
related_connection=data.related_connection_type,
)
if new_rel is not None and priority_attrs:
try:
tool.Ifc.run("attribute.edit_attributes", product=new_rel, attributes=priority_attrs)
except (RuntimeError, ifcopenshell.Error) as e:
cls._emit_warning(
f"connection priority restore failed for {new_rel}; "
f"duplicate has empty RelatingPriorities/RelatedPriorities: {e}"
)
if new_rel is not None and priority_attrs:
try:
tool.Ifc.run("attribute.edit_attributes", product=new_rel, attributes=priority_attrs)
except (RuntimeError, ifcopenshell.Error) as e:
cls._emit_warning(
f"connection priority restore failed for {new_rel}; "
f"duplicate has empty RelatingPriorities/RelatedPriorities: {e}"
)
@classmethod
def recreate_port_connections(
@@ -281,43 +283,46 @@ class Duplicate(bonsai.core.tool.Duplicate):
) -> None:
"""Recreate ``IfcRelConnectsPorts`` between duplicates; skip records whose duplicate's port count diverges from the snapshot."""
for relating_element, records in snapshot.by_element.items():
new_relatings = old_to_new.get(relating_element) or []
expected_relating = snapshot.port_counts.get(relating_element)
for record in records:
related_element = record.related_element
new_relateds = old_to_new.get(related_element) or []
try:
new_relating = old_to_new[relating_element][0]
new_related = old_to_new[related_element][0]
except (KeyError, IndexError):
continue
new_relating_ports = tool.System.get_ports(new_relating)
new_related_ports = tool.System.get_ports(new_related)
expected_relating = snapshot.port_counts.get(relating_element)
if expected_relating is not None and len(new_relating_ports) != expected_relating:
cls._emit_warning(
f"port reconnect skipped — duplicate has {len(new_relating_ports)} ports, "
f"snapshot had {expected_relating}"
)
continue
expected_related = snapshot.port_counts.get(related_element)
for new_relating, new_related in zip(new_relatings, new_relateds):
new_relating_ports = tool.System.get_ports(new_relating)
new_related_ports = tool.System.get_ports(new_related)
if expected_related is not None and len(new_related_ports) != expected_related:
cls._emit_warning(
f"port reconnect skipped — duplicate has {len(new_related_ports)} ports, "
f"snapshot had {expected_related}"
)
continue
if expected_relating is not None and len(new_relating_ports) != expected_relating:
cls._emit_warning(
f"port reconnect skipped — duplicate has {len(new_relating_ports)} ports, "
f"snapshot had {expected_relating}"
)
continue
if expected_related is not None and len(new_related_ports) != expected_related:
cls._emit_warning(
f"port reconnect skipped — duplicate has {len(new_related_ports)} ports, "
f"snapshot had {expected_related}"
)
continue
try:
new_port_a = new_relating_ports[record.relating_port_index]
new_port_b = new_related_ports[record.related_port_index]
except IndexError:
cls._emit_warning(
f"port reconnect skipped — record references port index past the duplicate's port list"
)
continue
try:
tool.Ifc.run(
"system.connect_port",
port1=new_port_a,
port2=new_port_b,
direction=record.direction or "NOTDEFINED",
)
except (RuntimeError, ifcopenshell.Error) as e:
cls._emit_warning(f"port reconnect failed between duplicates: {e}")
try:
new_port_a = new_relating_ports[record.relating_port_index]
new_port_b = new_related_ports[record.related_port_index]
except IndexError:
cls._emit_warning(
f"port reconnect skipped — record references port index past the duplicate's port list"
)
continue
try:
tool.Ifc.run(
"system.connect_port",
port1=new_port_a,
port2=new_port_b,
direction=record.direction or "NOTDEFINED",
)
except (RuntimeError, ifcopenshell.Error) as e:
cls._emit_warning(f"port reconnect failed between duplicates: {e}")
+112 -222
View File
@@ -163,21 +163,13 @@ class Geometry(bonsai.core.tool.Geometry):
cls._host_update_queue = {}
cls._host_recut_queue = {}
for voided_obj in update_queue.values():
try:
if not voided_obj or not voided_obj.data:
continue
except ReferenceError:
# Blender object was deleted while the batch was open
# (e.g. user removed it via the outliner mid-op).
if not voided_obj or not voided_obj.data:
continue
if tool.Ifc.get_entity(voided_obj) is None:
continue
bpy.ops.bim.update_representation(obj=voided_obj.name)
for voided_obj, _ in recut_queue.values():
try:
if not voided_obj or not voided_obj.data:
continue
except ReferenceError:
if not voided_obj or not voided_obj.data:
continue
if tool.Ifc.get_entity(voided_obj) is None:
continue
@@ -1214,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)
@@ -2489,16 +2497,99 @@ class Geometry(bonsai.core.tool.Geometry):
old_obj_name_to_new_obj_name: dict[str, str] = {}
for obj in objects_to_duplicate:
new_active = cls._duplicate_ifc_object_once(
obj,
active_object,
linked,
arrays_to_duplicate,
old_to_new,
old_obj_name_to_new_obj_name,
)
if new_active is not None:
new_active_obj = new_active
element = tool.Ifc.get_entity(obj)
if element:
if element.is_a("IfcAnnotation") and element.ObjectType == "DRAWING":
tool.Blender.deselect_object(obj)
continue # For now, don't copy drawings until we stabilise a bit more. It's tricky.
elif tool.Geometry.is_locked(element):
tool.Blender.deselect_object(obj)
continue
elif tool.Geometry.is_representation_item(obj):
cls.duplicate_ifc_item(obj)
continue
tracked_opening_type = tool.Model.get_tracked_opening_type(obj)
is_tracked_opening = bool(tracked_opening_type)
keep_data_linked = linked and not element and not is_tracked_opening
# Prior to duplicating, sync the object placement to make decomposition recreation more stable.
cls.commit_placement_if_moved(obj, apply_scale=False)
new_obj = obj.copy()
temp_data = None
# Currently for optimization we do not apply pending changes (scale or changed .data)
# to the original and duplicated objects.
# Keep new object edited if original is.
if tool.Ifc.is_edited(obj, ignore_scale=True):
tool.Ifc.edit(new_obj)
if obj.data and not keep_data_linked:
# assure root.copy_class won't replace the previous mesh globally
temp_data = obj.data.copy()
new_obj.data = temp_data
# Unlink from previous boolean element
# and keep object tracked for decorations.
if is_tracked_opening:
mprops = tool.Geometry.get_mesh_props(new_obj.data)
mprops.ifc_boolean_id = 0
tool.Root.add_tracked_opening(new_obj, tracked_opening_type)
if obj == active_object:
new_active_obj = new_obj
for collection in obj.users_collection:
collection.objects.link(new_obj)
obj.select_set(False)
new_obj.select_set(True)
old_obj_name_to_new_obj_name[obj.name] = new_obj.name
if not element:
continue
# clear object's collection so it will be able to have it's own
tool.Blender.get_object_bim_props(new_obj).collection = None
# copy the actual class
new = bonsai.core.root.copy_class(tool.Ifc, tool.Collector, tool.Geometry, tool.Root, obj=new_obj)
# clean up the orphaned mesh with ifc id of the original object to avoid confusion
# IfcGridAxis keeps the same mesh data (it's pointing to ifc id 0, so it's not a problem)
if new and temp_data and not new.is_a("IfcGridAxis"):
if new.is_a("IfcRelSpaceBoundary"):
surface = new.ConnectionGeometry.SurfaceOnRelatingElement
temp_data.name = f"0/{surface.id()}"
tool.Ifc.link(surface, temp_data)
else:
tool.Blender.remove_data_block(temp_data)
if new:
# TODO: handle array data for other cases of duplication
array_data = arrays_to_duplicate.get(obj, None)
tool.Model.handle_array_on_copied_element(new, array_data)
if array_data:
for child in tool.Array.get_all_children_objects(new):
child.select_set(True)
# TODO: add new array children to recreate their decomposition too
old_to_new[element] = [new]
if new.is_a("IfcRelSpaceBoundary"):
tool.Boundary.decorate_boundary(new_obj)
# Slab-trim booleans (from extend_walls_to_underside) belong to
# the source wall's connection, not the copy. Strip them so the
# duplicate reverts to its pre-clip extrusion — mirrors the way
# filling rels are dropped while manual booleans persist on copy.
# Reload the body when something was stripped so the viewport
# immediately shows the unclipped geometry; otherwise the user
# sees a stale mesh until they Shift+G, which is easy to miss.
if new.is_a("IfcWall"):
if tool.Model.strip_underside_booleans(new):
tool.Model.reload_body_representation(new_obj)
# HasOpenings rels don't follow object duplication, so
# the duplicate's body must rebuild to match its current
# opening set.
else:
tool.Model.regenerate_wall(new_obj)
# Remap Blender parent relationships for duplicated objects
for old_obj_name, new_obj_name in old_obj_name_to_new_obj_name.items():
@@ -2526,211 +2617,10 @@ class Geometry(bonsai.core.tool.Geometry):
# Recreate decompositions
tool.Duplicate.recreate_decompositions(decomposition_relationships, old_to_new)
cls.remove_linked_aggregate_data(old_to_new)
# In-loop regenerate_wall runs before recreate_connections, so any new
# walls that just received an IfcRelConnectsPathElements have stale
# junction geometry — recalculate them now that their connection graph
# is complete.
cls._recalculate_walls_with_new_connections(old_to_new)
bonsai.bim.handler.refresh_ui_data()
tool.Root.reload_grid_decorator()
return old_to_new, new_active_obj or active_object
@classmethod
def duplicate_ifc_object_n_times(
cls, source: bpy.types.Object, count: int
) -> dict[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]]:
"""N-way duplicate of a single source.
Same per-copy semantics as duplicate_ifc_objects (IFC class copy,
decomposition + connection recreation, body regen for walls), but
bypasses the set() dedupe and the arrays_to_duplicate pre-scan so
callers building a fresh array don't pay per-call overhead N times.
Returns the same old_to_new dict shape, with the source element
mapping to the N new entities."""
if count <= 0:
return {}
sources = {source}
decomposition_relationships = tool.Duplicate.get_decomposition_relationships(sources)
connection_relationships = tool.Duplicate.get_connection_relationships(sources)
port_connection_snapshot = tool.Duplicate.get_port_connection_relationships(sources)
old_to_new: dict[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]] = {}
old_obj_name_to_new_obj_name: dict[str, str] = {}
for _ in range(count):
cls._duplicate_ifc_object_once(
source,
None,
False,
{},
old_to_new,
old_obj_name_to_new_obj_name,
keep_source_selected=True,
)
for old_obj_name, new_obj_name in old_obj_name_to_new_obj_name.items():
new_obj = bpy.data.objects.get(new_obj_name)
if new_obj and new_obj.parent and new_obj.parent.name in old_obj_name_to_new_obj_name:
world_matrix = new_obj.matrix_world.copy()
new_parent_name = old_obj_name_to_new_obj_name[new_obj.parent.name]
new_parent = bpy.data.objects.get(new_parent_name)
if new_parent:
new_obj.parent = new_parent
new_obj.matrix_world = world_matrix
for old in old_to_new.keys():
if old.is_a("IfcElementAssembly"):
tool.Root.recreate_aggregate(old_to_new)
cls.remove_old_connections(old_to_new)
tool.Duplicate.recreate_connections(connection_relationships, old_to_new)
tool.Duplicate.recreate_port_connections(port_connection_snapshot, old_to_new)
tool.Duplicate.recreate_decompositions(decomposition_relationships, old_to_new)
cls.remove_linked_aggregate_data(old_to_new)
cls._recalculate_walls_with_new_connections(old_to_new)
bonsai.bim.handler.refresh_ui_data()
tool.Root.reload_grid_decorator()
return old_to_new
@classmethod
def _duplicate_ifc_object_once(
cls,
obj: bpy.types.Object,
active_object: Optional[bpy.types.Object],
linked: bool,
arrays_to_duplicate: dict[bpy.types.Object, Any],
old_to_new: dict[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]],
old_obj_name_to_new_obj_name: dict[str, str],
keep_source_selected: bool = False,
) -> Optional[bpy.types.Object]:
"""Per-source body of the duplicate flow. Mutates old_to_new and
old_obj_name_to_new_obj_name in place. Returns new_obj when obj is
the active_object, else None.
keep_source_selected: when True, skip the source deselect so batched
callers can run N iterations without N×2 select flips and without
needing a post-loop restore on the source."""
new_active_obj: Optional[bpy.types.Object] = None
element = tool.Ifc.get_entity(obj)
if element:
if element.is_a("IfcAnnotation") and element.ObjectType == "DRAWING":
tool.Blender.deselect_object(obj)
return None # For now, don't copy drawings until we stabilise a bit more. It's tricky.
elif tool.Geometry.is_locked(element):
tool.Blender.deselect_object(obj)
return None
elif tool.Geometry.is_representation_item(obj):
cls.duplicate_ifc_item(obj)
return None
tracked_opening_type = tool.Model.get_tracked_opening_type(obj)
is_tracked_opening = bool(tracked_opening_type)
keep_data_linked = linked and not element and not is_tracked_opening
# Prior to duplicating, sync the object placement to make decomposition recreation more stable.
cls.commit_placement_if_moved(obj, apply_scale=False)
new_obj = obj.copy()
temp_data = None
# Currently for optimization we do not apply pending changes (scale or changed .data)
# to the original and duplicated objects.
# Keep new object edited if original is.
if tool.Ifc.is_edited(obj, ignore_scale=True):
tool.Ifc.edit(new_obj)
if obj.data and not keep_data_linked:
# assure root.copy_class won't replace the previous mesh globally
temp_data = obj.data.copy()
new_obj.data = temp_data
# Unlink from previous boolean element
# and keep object tracked for decorations.
if is_tracked_opening:
mprops = tool.Geometry.get_mesh_props(new_obj.data)
mprops.ifc_boolean_id = 0
tool.Root.add_tracked_opening(new_obj, tracked_opening_type)
if obj == active_object:
new_active_obj = new_obj
for collection in obj.users_collection:
collection.objects.link(new_obj)
if not keep_source_selected:
obj.select_set(False)
new_obj.select_set(True)
old_obj_name_to_new_obj_name[obj.name] = new_obj.name
if not element:
return new_active_obj
# clear object's collection so it will be able to have it's own
tool.Blender.get_object_bim_props(new_obj).collection = None
# copy the actual class
new = bonsai.core.root.copy_class(tool.Ifc, tool.Collector, tool.Geometry, tool.Root, obj=new_obj)
# clean up the orphaned mesh with ifc id of the original object to avoid confusion
# IfcGridAxis keeps the same mesh data (it's pointing to ifc id 0, so it's not a problem)
if new and temp_data and not new.is_a("IfcGridAxis"):
if new.is_a("IfcRelSpaceBoundary"):
surface = new.ConnectionGeometry.SurfaceOnRelatingElement
temp_data.name = f"0/{surface.id()}"
tool.Ifc.link(surface, temp_data)
else:
tool.Blender.remove_data_block(temp_data)
if new:
# TODO: handle array data for other cases of duplication
array_data = arrays_to_duplicate.get(obj, None)
tool.Model.handle_array_on_copied_element(new, array_data)
if array_data:
for child in tool.Array.get_all_children_objects(new):
child.select_set(True)
# TODO: add new array children to recreate their decomposition too
old_to_new.setdefault(element, []).append(new)
if new.is_a("IfcRelSpaceBoundary"):
tool.Boundary.decorate_boundary(new_obj)
# Slab-trim booleans (from extend_walls_to_underside) belong to
# the source wall's connection, not the copy. Strip them so the
# duplicate reverts to its pre-clip extrusion — mirrors the way
# filling rels are dropped while manual booleans persist on copy.
# Reload the body when something was stripped so the viewport
# immediately shows the unclipped geometry; otherwise the user
# sees a stale mesh until they Shift+G, which is easy to miss.
if new.is_a("IfcWall"):
if tool.Model.strip_underside_booleans(new):
tool.Model.reload_body_representation(new_obj)
# HasOpenings rels don't follow object duplication, so
# the duplicate's body must rebuild to match its current
# opening set.
else:
tool.Model.regenerate_wall(new_obj)
return new_active_obj
@classmethod
def _recalculate_walls_with_new_connections(
cls, old_to_new: dict[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]]
) -> None:
"""Recalculate new IfcWall duplicates that just received an
``IfcRelConnectsPathElements``. The in-loop ``regenerate_wall`` runs
before ``recreate_connections``, so wall body geometry doesn't reflect
the junction until this second pass."""
walls_to_recalc: list[bpy.types.Object] = []
for new_list in old_to_new.values():
for new_entity in new_list:
if not new_entity.is_a("IfcWall"):
continue
if not (getattr(new_entity, "ConnectedTo", None) or getattr(new_entity, "ConnectedFrom", None)):
continue
new_obj = tool.Ifc.get_object(new_entity)
if new_obj is not None:
walls_to_recalc.append(new_obj)
if walls_to_recalc:
tool.Model.recalculate_walls(walls_to_recalc)
@classmethod
def duplicate_ifc_item(cls, obj: bpy.types.Object) -> None:
props = tool.Geometry.get_geometry_props()
+17 -47
View File
@@ -59,7 +59,6 @@ from ifcopenshell.util.shape_builder import ShapeBuilder, np_to_3d
from mathutils import Matrix, Vector
import bonsai.core.geometry
import bonsai.core.model
import bonsai.core.tool
import bonsai.tool as tool
from bonsai.bim import import_ifc
@@ -1248,35 +1247,6 @@ class Model(bonsai.core.tool.Model):
with tool.Geometry.batch_host_recut():
cls._regenerate_array_body(parent_obj, data, array_layers_to_apply)
@classmethod
def _prune_orphan_array_children(cls, array: dict[str, Any]) -> None:
"""Drop GUIDs from ``array['children']`` whose IFC entity or Blender
object is no longer alive, and cascade-remove the orphan IFC entity
if it still exists. Outliner / keyboard delete of a Bonsai-managed
object bypasses ``bim.delete``'s cascade, leaving dangling opening
and filling references that later confuse regen and crash the
``batch_host_recut`` drain."""
live_guids: list[str] = []
ifc_file = tool.Ifc.get()
for guid in array["children"]:
try:
element = ifc_file.by_guid(guid)
except RuntimeError:
continue
obj = tool.Ifc.get_object(element)
try:
is_live = obj is not None and obj.data is not None
except ReferenceError:
is_live = False
if is_live:
live_guids.append(guid)
continue
try:
ifcopenshell.api.root.remove_product(ifc_file, product=element)
except (RuntimeError, ifcopenshell.Error):
pass
array["children"] = live_guids
@classmethod
def _regenerate_array_body(
cls, parent_obj: bpy.types.Object, data: list[dict[str, Any]], array_layers_to_apply: Iterable[int]
@@ -1292,7 +1262,6 @@ class Model(bonsai.core.tool.Model):
obj_stack = [parent_obj]
for array_i, array in enumerate(data):
cls._prune_orphan_array_children(array)
child_i = 0
existing_children = set(array["children"])
total_existing_children = len(array["children"])
@@ -1306,14 +1275,6 @@ class Model(bonsai.core.tool.Model):
else:
base_offset = Vector([array["x"], array["y"], array["z"]]) * unit_scale
target_new_in_this_layer = (array["count"] - 1) * len(obj_stack)
missing_count = max(0, target_new_in_this_layer - total_existing_children)
new_entities_pool: list[ifcopenshell.entity_instance] = []
if missing_count > 0:
batch_old_to_new = tool.Geometry.duplicate_ifc_object_n_times(parent_obj, missing_count)
new_entities_pool = batch_old_to_new.get(parent_element, [])
new_entities_iter = iter(new_entities_pool)
for i in range(array["count"]):
if i == 0:
continue
@@ -1331,13 +1292,8 @@ class Model(bonsai.core.tool.Model):
child_obj = tool.Ifc.get_object(child_element)
assert child_obj
except (IndexError, RuntimeError, AssertionError):
try:
child_element = next(new_entities_iter)
except StopIteration:
# Stale-GUID mid-list left the pool exhausted; fall back
# to a one-off duplicate so the layer can still complete.
old_to_new, _ = tool.Geometry.duplicate_ifc_objects([parent_obj])
child_element = next(iter(old_to_new.values()))[0]
old_to_new, _ = tool.Geometry.duplicate_ifc_objects([parent_obj])
child_element = next(iter(old_to_new.values()))[0]
child_obj = tool.Ifc.get_object(child_element)
# add child pset
@@ -1405,7 +1361,14 @@ class Model(bonsai.core.tool.Model):
tool.Ifc.get(), pset=pset, properties={"Data": json_data, "Parent": parent_element.GlobalId}
)
tool.Blender.set_object_selection(parent_obj, True)
# Post-condition: parent is selected on return. duplicate_ifc_objects
# deselects the source on every call inside the regen loop; without
# this restore, callers get a deselected parent for arrays with N >= 2.
# TODO: batch the per-child duplicate_ifc_objects([parent]) calls into
# a single N-way duplicate — N depsgraph churns + N select/deselect
# flips is wasteful, and a batched duplicate would also remove the
# need for this restore.
parent_obj.select_set(True)
@classmethod
def mirror_parent_void_fillings_to_children(
@@ -2161,6 +2124,13 @@ class Model(bonsai.core.tool.Model):
if voided_obj is not None:
voided_objs.add(voided_obj)
# 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
body = tool.Geometry.get_body_representation(opening)
if body is None:
continue
+24 -26
View File
@@ -373,37 +373,35 @@ class Root(bonsai.core.tool.Root):
try:
new_aggregate = old_to_new[old_aggregate]
except:
for new_entity in new:
bonsai.core.aggregate.unassign_object(
tool.Ifc,
tool.Aggregate,
tool.Collector,
relating_obj=tool.Ifc.get_object(old_aggregate),
related_obj=tool.Ifc.get_object(new_entity),
)
continue
for new_entity in new:
bonsai.core.aggregate.assign_object(
bonsai.core.aggregate.unassign_object(
tool.Ifc,
tool.Aggregate,
tool.Collector,
relating_obj=tool.Ifc.get_object(new_aggregate[0]),
related_obj=tool.Ifc.get_object(new_entity),
relating_obj=tool.Ifc.get_object(old_aggregate),
related_obj=tool.Ifc.get_object(new[0]),
)
continue
# Make sure that the array children also get reassigned to the correct aggregate
pset = ifcopenshell.util.element.get_pset(new_entity, "BBIM_Array")
if pset:
array_children = tool.Array.get_all_children_objects(new_entity)
for obj in array_children:
bonsai.core.aggregate.assign_object(
tool.Ifc,
tool.Aggregate,
tool.Collector,
relating_obj=tool.Ifc.get_object(new_aggregate[0]),
related_obj=tool.Ifc.get_object(tool.Ifc.get_entity(obj)),
)
bonsai.core.aggregate.assign_object(
tool.Ifc,
tool.Aggregate,
tool.Collector,
relating_obj=tool.Ifc.get_object(new_aggregate[0]),
related_obj=tool.Ifc.get_object(new[0]),
)
# Make sure that the array children also get reassigned to the correct aggregate
pset = ifcopenshell.util.element.get_pset(new[0], "BBIM_Array")
if pset:
array_children = tool.Array.get_all_children_objects(new[0])
for obj in array_children:
bonsai.core.aggregate.assign_object(
tool.Ifc,
tool.Aggregate,
tool.Collector,
relating_obj=tool.Ifc.get_object(new_aggregate[0]),
related_obj=tool.Ifc.get_object(tool.Ifc.get_entity(obj)),
)
if new_aggregate is None:
return
-7
View File
@@ -357,13 +357,6 @@ class System(bonsai.core.tool.System):
if not cls.is_mep_element(element):
continue
# Array children inherit port topology from their parent's IFC
# entity, but their positions are derived — drawing ports on every
# copy of an arrayed segment doubles up markers and misleads the
# user into thinking each copy has its own port network.
if tool.Array.is_array_child(element):
continue
selected_element = element in connected_elements
verts_pos = []
@@ -35,7 +35,6 @@ from unittest.mock import Mock, patch
import bpy
import ifcopenshell
import ifcopenshell.api.pset
import pytest
import bonsai.tool as tool
@@ -1,716 +0,0 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Tests for the batched array-duplicate path.
`tool.Geometry.duplicate_ifc_object_n_times` lifts the per-call overhead of
`duplicate_ifc_objects` (snapshot, UI refresh, decorator reload, select
flips) out of the per-child loop in `_regenerate_array_body`. These tests
pin three contracts:
1. N-way batched duplicate produces N distinct entities mapped from the
source under `old_to_new[source_element]`, and the source object stays
selected throughout (no per-iteration deselect).
2. Per-layer batching collapses the N independent UI refreshes into one.
3. End-to-end array regen still yields the same number and shape of
children as the per-call baseline."""
import json
from unittest.mock import patch
import bpy
import ifcopenshell
import ifcopenshell.api.pset
import ifcopenshell.util.element
import pytest
import bonsai.tool as tool
from test.bim.bootstrap import NewFile
pytestmark = pytest.mark.model
def _build_actuator(name: str = "Actuator") -> tuple[bpy.types.Object, ifcopenshell.entity_instance]:
"""Minimal IfcActuator + cube — matches the test_array_batch_recut.py shape."""
bpy.ops.bim.create_project()
bpy.ops.mesh.primitive_cube_add()
obj = bpy.context.active_object
obj.name = name
rprops = tool.Root.get_root_props()
rprops.ifc_product = "IfcElement"
bpy.ops.bim.assign_class(ifc_class="IfcActuator", predefined_type="ELECTRICACTUATOR", userdefined_type="")
element = tool.Ifc.get_entity(obj)
return obj, element
def _build_actuator_with_array_pset(
count: int, x: float = 1.0
) -> tuple[bpy.types.Object, ifcopenshell.entity_instance, list[dict]]:
obj, element = _build_actuator()
parent_data = [
{
"children": [],
"count": count,
"method": "OFFSET",
"x": x,
"y": 0.0,
"z": 0.0,
"use_local_space": False,
"sync_children": False,
}
]
pset = ifcopenshell.api.pset.add_pset(tool.Ifc.get(), product=element, name="BBIM_Array")
ifcopenshell.api.pset.edit_pset(
tool.Ifc.get(),
pset=pset,
properties={"Data": json.dumps(parent_data), "Parent": element.GlobalId},
)
return obj, element, parent_data
class TestDuplicateIfcObjectNTimes(NewFile):
def test_returns_empty_dict_for_zero_count(self):
obj, _ = _build_actuator()
result = tool.Geometry.duplicate_ifc_object_n_times(obj, 0)
assert result == {}
def test_returns_empty_dict_for_negative_count(self):
obj, _ = _build_actuator()
result = tool.Geometry.duplicate_ifc_object_n_times(obj, -3)
assert result == {}
def test_produces_n_distinct_entities(self):
obj, element = _build_actuator()
result = tool.Geometry.duplicate_ifc_object_n_times(obj, 5)
new_entities = result.get(element)
assert new_entities is not None
assert len(new_entities) == 5
assert len({e.id() for e in new_entities}) == 5
for new_entity in new_entities:
assert new_entity.is_a("IfcActuator")
assert new_entity.GlobalId != element.GlobalId
def test_source_stays_selected_after_batch(self):
obj, _ = _build_actuator()
obj.select_set(True)
tool.Geometry.duplicate_ifc_object_n_times(obj, 4)
assert obj in bpy.context.selected_objects, "source object must remain selected across batched duplicates"
def test_each_new_entity_has_blender_object(self):
obj, element = _build_actuator()
result = tool.Geometry.duplicate_ifc_object_n_times(obj, 3)
for new_entity in result[element]:
new_obj = tool.Ifc.get_object(new_entity)
assert new_obj is not None
assert new_obj is not obj
class TestBatchedRefreshUIDataCallCount(NewFile):
def test_n_times_calls_refresh_ui_data_once(self):
obj, _ = _build_actuator()
with patch("bonsai.bim.handler.refresh_ui_data") as refresh_mock:
tool.Geometry.duplicate_ifc_object_n_times(obj, 8)
assert (
refresh_mock.call_count == 1
), f"batched 8-way duplicate must call refresh_ui_data once, got {refresh_mock.call_count}"
def test_n_times_calls_reload_grid_decorator_once(self):
obj, _ = _build_actuator()
with patch.object(tool.Root, "reload_grid_decorator") as reload_mock:
tool.Geometry.duplicate_ifc_object_n_times(obj, 8)
assert reload_mock.call_count == 1
class TestRegenerateArrayEndToEnd(NewFile):
def test_regenerate_array_creates_expected_children(self):
obj, element, parent_data = _build_actuator_with_array_pset(count=8)
bpy.context.view_layer.objects.active = obj
tool.Model.regenerate_array(obj, parent_data)
layer = parent_data[0]
assert len(layer["children"]) == 7, "8-element array means 7 new children (parent + 7)"
for child_guid in layer["children"]:
child_element = tool.Ifc.get().by_guid(child_guid)
assert child_element is not None
assert child_element.is_a("IfcActuator")
child_pset = ifcopenshell.util.element.get_pset(child_element, "BBIM_Array")
assert child_pset is not None
assert child_pset["Parent"] == element.GlobalId
def test_regenerate_array_parent_stays_selected(self):
obj, element, parent_data = _build_actuator_with_array_pset(count=4)
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
tool.Model.regenerate_array(obj, parent_data)
assert (
obj in bpy.context.selected_objects
), "regenerate_array must leave parent_obj selected on return (post-condition)"
def test_regen_operator_leaves_only_parent_selected_and_active(self):
"""Post-condition parity between grow and shrink for the user-facing
``bim.regenerate_array`` operator: only the parent is selected + active;
every child is deselected. Pre-fix the grow path left new children
selected, creating inconsistency with the shrink path.
Scoped to the operator, not the tool method ``remove_array`` and
``apply_array`` also invoke ``tool.Model.regenerate_array`` internally
but expect a different post-selection state (children stay selected
for user follow-up work)."""
obj, element, parent_data = _build_actuator_with_array_pset(count=6)
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
bpy.ops.bim.regenerate_array()
assert obj in bpy.context.selected_objects
assert bpy.context.view_layer.objects.active is obj
parent_pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
parent_data_after = json.loads(parent_pset["Data"])
for child_guid in parent_data_after[0]["children"]:
child_element = tool.Ifc.get().by_guid(child_guid)
child_obj = tool.Ifc.get_object(child_element)
assert (
child_obj not in bpy.context.selected_objects
), f"child {child_obj.name} must be deselected on regenerate_array return"
def test_regen_operator_after_shrink_still_leaves_only_parent_selected(self):
obj, element, parent_data = _build_actuator_with_array_pset(count=6)
bpy.context.view_layer.objects.active = obj
bpy.ops.bim.regenerate_array()
parent_pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
arrays = json.loads(parent_pset["Data"])
arrays[0]["count"] = 3
pset_entity = tool.Ifc.get().by_id(parent_pset["id"])
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset_entity, properties={"Data": json.dumps(arrays)})
bpy.ops.bim.regenerate_array()
assert obj in bpy.context.selected_objects
assert bpy.context.view_layer.objects.active is obj
parent_pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
arrays_after = json.loads(parent_pset["Data"])
for child_guid in arrays_after[0]["children"]:
child_element = tool.Ifc.get().by_guid(child_guid)
child_obj = tool.Ifc.get_object(child_element)
assert child_obj not in bpy.context.selected_objects
def test_regenerate_array_child_positions_match_offset(self):
obj, element, parent_data = _build_actuator_with_array_pset(count=4, x=2.5)
bpy.context.view_layer.objects.active = obj
parent_x = obj.matrix_world.translation.x
tool.Model.regenerate_array(obj, parent_data)
layer = parent_data[0]
for i, child_guid in enumerate(layer["children"], start=1):
child_element = tool.Ifc.get().by_guid(child_guid)
child_obj = tool.Ifc.get_object(child_element)
expected_x = parent_x + 2.5 * i
assert child_obj.matrix_world.translation.x == pytest.approx(
expected_x
), f"child {i}: expected x≈{expected_x}, got {child_obj.matrix_world.translation.x}"
class TestRegenerateArrayUIRefreshCoalesces(NewFile):
def test_n_children_grow_calls_refresh_ui_data_once_per_layer(self):
obj, element, parent_data = _build_actuator_with_array_pset(count=8)
bpy.context.view_layer.objects.active = obj
with patch("bonsai.bim.handler.refresh_ui_data") as refresh_mock:
tool.Model.regenerate_array(obj, parent_data)
assert refresh_mock.call_count == 1, (
"growing an array layer from 0 to 7 children must call refresh_ui_data once, "
f"got {refresh_mock.call_count}"
)
def test_n_children_grow_calls_reload_grid_decorator_once_per_layer(self):
obj, element, parent_data = _build_actuator_with_array_pset(count=8)
bpy.context.view_layer.objects.active = obj
with patch.object(tool.Root, "reload_grid_decorator") as reload_mock:
tool.Model.regenerate_array(obj, parent_data)
assert reload_mock.call_count == 1
class TestRecreateAggregateIteratesAllNew(NewFile):
"""Pins the [0]-indexing sweep in tool/root.py recreate_aggregate. When the
new-list has N>1 entries (the batched-duplicate shape), every entry must be
aggregate-assigned, not just new[0]."""
def test_iterates_assign_object_per_new_entity_when_old_has_aggregate(self):
from unittest.mock import Mock
old_assembly = Mock()
old_assembly.is_a = lambda c: c == "IfcElementAssembly"
old_parent_aggregate = Mock()
old_parent_aggregate.is_a = lambda c: False
new_assemblies = [Mock(), Mock(), Mock()]
new_parent_aggregate = [Mock()]
old_to_new = {old_assembly: new_assemblies, old_parent_aggregate: new_parent_aggregate}
with patch(
"ifcopenshell.util.element.get_aggregate",
side_effect=lambda e: old_parent_aggregate if e is old_assembly else None,
), patch("bonsai.core.aggregate.assign_object") as assign_mock, patch(
"ifcopenshell.util.element.get_pset", return_value=None
), patch.object(
tool.Ifc, "get_object", side_effect=lambda e: Mock(spec=bpy.types.Object)
), patch.object(
tool.Blender, "select_and_activate_single_object"
):
tool.Root.recreate_aggregate(old_to_new)
assert (
assign_mock.call_count == 3
), f"recreate_aggregate must assign each of N new entities (not just new[0]); got {assign_mock.call_count}"
def test_iterates_unassign_object_per_new_entity_when_aggregate_missing(self):
from unittest.mock import Mock
old_assembly = Mock()
old_assembly.is_a = lambda c: c == "IfcElementAssembly"
old_parent_aggregate = Mock()
new_assemblies = [Mock(), Mock(), Mock()]
old_to_new = {old_assembly: new_assemblies} # parent aggregate NOT in old_to_new
with patch(
"ifcopenshell.util.element.get_aggregate",
side_effect=lambda e: old_parent_aggregate if e is old_assembly else None,
), patch("bonsai.core.aggregate.unassign_object") as unassign_mock, patch.object(
tool.Ifc, "get_object", side_effect=lambda e: Mock(spec=bpy.types.Object)
):
tool.Root.recreate_aggregate(old_to_new)
assert unassign_mock.call_count == 3, (
f"recreate_aggregate must unassign each of N new entities when parent aggregate is missing; "
f"got {unassign_mock.call_count}"
)
class TestRecreateConnectionsZipsPairs(NewFile):
"""Pins the [0]-indexing sweep in tool/duplicate.py recreate_connections. When
both sides of a connection are duplicated N times, zip-pair the N new
relating with N new related; when only one side is duplicated, skip."""
def _make_connection_data(self):
from unittest.mock import Mock
from bonsai.tool.duplicate import ConnectionRecord
return ConnectionRecord(
type="path",
relating_element=Mock(),
related_element=Mock(),
relating_connection_type="ATSTART",
related_connection_type="ATEND",
relating_priorities=[],
related_priorities=[],
)
def test_zips_n_pairs_when_both_sides_duplicated(self):
from unittest.mock import Mock
data = self._make_connection_data()
old_to_new = {
data.relating_element: [Mock(), Mock(), Mock()],
data.related_element: [Mock(), Mock(), Mock()],
}
relationship = {Mock(): data}
with patch.object(tool.Ifc, "run", return_value=None) as run_mock:
tool.Duplicate.recreate_connections(relationship, old_to_new)
connect_calls = [c for c in run_mock.call_args_list if c.args and c.args[0] == "geometry.connect_path"]
assert (
len(connect_calls) == 3
), f"zip-pair must create 3 connect_path calls for 3-vs-3 batched duplicate; got {len(connect_calls)}"
def test_skips_when_other_side_not_duplicated(self):
from unittest.mock import Mock
data = self._make_connection_data()
# Only relating side is in old_to_new; related side was NOT duplicated.
old_to_new = {data.relating_element: [Mock(), Mock(), Mock()]}
relationship = {Mock(): data}
with patch.object(tool.Ifc, "run", return_value=None) as run_mock:
tool.Duplicate.recreate_connections(relationship, old_to_new)
connect_calls = [c for c in run_mock.call_args_list if c.args and c.args[0] == "geometry.connect_path"]
assert (
connect_calls == []
), "when only one side of a connection is in old_to_new, no connections should be recreated"
def test_single_pair_case_unchanged(self):
"""Pre-sweep behavior (1 source -> 1 new) must still work — zip with two 1-element lists."""
from unittest.mock import Mock
data = self._make_connection_data()
old_to_new = {
data.relating_element: [Mock()],
data.related_element: [Mock()],
}
relationship = {Mock(): data}
with patch.object(tool.Ifc, "run", return_value=None) as run_mock:
tool.Duplicate.recreate_connections(relationship, old_to_new)
connect_calls = [c for c in run_mock.call_args_list if c.args and c.args[0] == "geometry.connect_path"]
assert len(connect_calls) == 1
class TestRecalculateWallsWithNewConnections(NewFile):
"""Pins the post-connection wall recalc: after ``recreate_connections``
wires new IfcRelConnectsPathElements onto duplicated walls, the wall
bodies must be re-recalculated because the in-loop ``regenerate_wall``
fired before the connections existed. Otherwise the junction geometry
stays stale and the user has to manually regen."""
def test_walls_with_new_connections_are_recalculated(self):
from unittest.mock import Mock
wall_new = Mock()
wall_new.is_a = lambda c: c == "IfcWall"
wall_new.ConnectedTo = [Mock()]
wall_new.ConnectedFrom = []
wall_obj = Mock(spec=bpy.types.Object)
old_to_new = {Mock(): [wall_new]}
with patch.object(tool.Ifc, "get_object", return_value=wall_obj), patch.object(
tool.Model, "recalculate_walls"
) as recalc_mock:
tool.Geometry._recalculate_walls_with_new_connections(old_to_new)
assert recalc_mock.call_count == 1
assert recalc_mock.call_args.args[0] == [wall_obj]
def test_walls_without_connections_are_skipped(self):
from unittest.mock import Mock
wall_new = Mock()
wall_new.is_a = lambda c: c == "IfcWall"
wall_new.ConnectedTo = []
wall_new.ConnectedFrom = []
old_to_new = {Mock(): [wall_new]}
with patch.object(tool.Ifc, "get_object", return_value=Mock(spec=bpy.types.Object)), patch.object(
tool.Model, "recalculate_walls"
) as recalc_mock:
tool.Geometry._recalculate_walls_with_new_connections(old_to_new)
assert recalc_mock.call_count == 0, "walls with no new connections must not trigger a recalc pass"
def test_non_wall_entities_are_skipped(self):
from unittest.mock import Mock
actuator_new = Mock()
actuator_new.is_a = lambda c: c == "IfcActuator"
actuator_new.ConnectedTo = [Mock()]
old_to_new = {Mock(): [actuator_new]}
with patch.object(tool.Ifc, "get_object", return_value=Mock(spec=bpy.types.Object)), patch.object(
tool.Model, "recalculate_walls"
) as recalc_mock:
tool.Geometry._recalculate_walls_with_new_connections(old_to_new)
assert recalc_mock.call_count == 0
def test_multiple_new_walls_collected_into_one_call(self):
from unittest.mock import Mock
wall_a_new = Mock()
wall_a_new.is_a = lambda c: c == "IfcWall"
wall_a_new.ConnectedTo = [Mock()]
wall_a_new.ConnectedFrom = []
wall_b_new = Mock()
wall_b_new.is_a = lambda c: c == "IfcWall"
wall_b_new.ConnectedTo = []
wall_b_new.ConnectedFrom = [Mock()]
objs = {wall_a_new: Mock(spec=bpy.types.Object), wall_b_new: Mock(spec=bpy.types.Object)}
old_to_new = {Mock(): [wall_a_new], Mock(): [wall_b_new]}
with patch.object(tool.Ifc, "get_object", side_effect=lambda e: objs.get(e)), patch.object(
tool.Model, "recalculate_walls"
) as recalc_mock:
tool.Geometry._recalculate_walls_with_new_connections(old_to_new)
assert recalc_mock.call_count == 1
assert set(recalc_mock.call_args.args[0]) == {objs[wall_a_new], objs[wall_b_new]}
class TestMEPActionGuardsAgainstArrayChildren(NewFile):
"""Pins the array-child guards on the three MEP-action visibility helpers.
Writable MEP actions (add fitting, remove terminal, join, re-edit bend)
applied to an array child get wiped by the next regen gating the icons
at the visibility layer prevents that footgun."""
def test_active_is_flow_segment_returns_false_for_array_child(self):
from unittest.mock import Mock
from bonsai.bim.module.model.mep import _active_is_flow_segment
obj = Mock(spec=bpy.types.Object)
element = Mock()
element.is_a = lambda c: c == "IfcFlowSegment"
with patch.object(tool.Ifc, "get_entity", return_value=element), patch.object(
tool.Array, "is_array_child", return_value=True
), patch.object(tool.System, "has_parametric_body", return_value=True):
assert _active_is_flow_segment(obj) is False
def test_active_is_flow_segment_true_for_non_array_parent(self):
from unittest.mock import Mock
from bonsai.bim.module.model.mep import _active_is_flow_segment
obj = Mock(spec=bpy.types.Object)
element = Mock()
element.is_a = lambda c: c == "IfcFlowSegment"
with patch.object(tool.Ifc, "get_entity", return_value=element), patch.object(
tool.Array, "is_array_child", return_value=False
), patch.object(tool.System, "has_parametric_body", return_value=True):
assert _active_is_flow_segment(obj) is True
def test_active_is_bend_fitting_returns_false_for_array_child(self):
from unittest.mock import Mock
from bonsai.bim.module.model.mep import _active_is_bend_fitting
obj = Mock(spec=bpy.types.Object)
element = Mock()
with patch.object(tool.Ifc, "get_entity", return_value=element), patch(
"bonsai.bim.module.model.mep._is_bend_fitting", return_value=True
), patch.object(tool.Array, "is_array_child", return_value=True):
assert _active_is_bend_fitting(obj) is False
def test_n_mep_selected_returns_false_when_any_selected_is_array_child(self):
from unittest.mock import Mock
from bonsai.bim.module.model.mep import _n_mep_selected
obj_a = Mock(spec=bpy.types.Object)
obj_b = Mock(spec=bpy.types.Object)
element_a = Mock()
element_b = Mock()
def is_array_child(el):
return el is element_b
with patch.object(tool.Blender, "get_selected_objects", return_value=[obj_a, obj_b]), patch.object(
tool.Ifc, "get_entity", side_effect=lambda o: element_a if o is obj_a else element_b
), patch.object(tool.System, "is_mep_element", return_value=True), patch.object(
tool.Array, "is_array_child", side_effect=is_array_child
):
assert _n_mep_selected(2) is False
class TestSelectOnlyParent(NewFile):
"""Pins ``tool.Array.select_only_parent`` — the shared helper wired into
both ``bim.regenerate_array`` and ``bim.finish_editing_array`` so the
grow / shrink / edit-commit paths converge on the same post-condition:
only the parent is selected + active."""
def test_deselects_children_selects_and_activates_parent(self):
obj, element, parent_data = _build_actuator_with_array_pset(count=4)
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
tool.Model.regenerate_array(obj, parent_data)
for child_guid in parent_data[0]["children"]:
child_element = tool.Ifc.get().by_guid(child_guid)
child_obj = tool.Ifc.get_object(child_element)
child_obj.select_set(True)
tool.Array.select_only_parent(obj, bpy.context)
assert obj in bpy.context.selected_objects
assert bpy.context.view_layer.objects.active is obj
for child_guid in parent_data[0]["children"]:
child_element = tool.Ifc.get().by_guid(child_guid)
child_obj = tool.Ifc.get_object(child_element)
assert child_obj not in bpy.context.selected_objects
class TestIsArrayChild(NewFile):
"""Pins ``tool.Array.is_array_child`` — the light helper used by the port
decorator (and any future per-element guard) to skip array children."""
def test_returns_false_when_no_bbim_array_pset(self):
from unittest.mock import Mock
element = Mock()
with patch("ifcopenshell.util.element.get_pset", return_value=None):
assert tool.Array.is_array_child(element) is False
def test_returns_false_on_the_array_parent_itself(self):
from unittest.mock import Mock
element = Mock()
element.GlobalId = "PARENT_GUID"
with patch("ifcopenshell.util.element.get_pset", return_value={"Parent": "PARENT_GUID"}):
assert tool.Array.is_array_child(element) is False
def test_returns_true_when_parent_guid_points_elsewhere(self):
from unittest.mock import Mock
element = Mock()
element.GlobalId = "CHILD_GUID"
with patch("ifcopenshell.util.element.get_pset", return_value={"Parent": "PARENT_GUID"}):
assert tool.Array.is_array_child(element) is True
class TestOrphanArrayChildPrune(NewFile):
"""Outliner / keyboard delete of a Bonsai-managed array child bypasses
``bim.delete``'s cascade, leaving the IFC entity and its opening / filling
refs behind. Regen must prune these orphans before the main loop or the
stale registry entry corrupts the ``batch_host_recut`` drain."""
def test_orphan_ifc_entity_pruned_from_children_list(self):
obj, element, parent_data = _build_actuator_with_array_pset(count=4)
bpy.context.view_layer.objects.active = obj
tool.Model.regenerate_array(obj, parent_data)
assert len(parent_data[0]["children"]) == 3
orphan_guid = parent_data[0]["children"][1]
orphan_element = tool.Ifc.get().by_guid(orphan_guid)
orphan_obj = tool.Ifc.get_object(orphan_element)
assert orphan_obj is not None
bpy.data.objects.remove(orphan_obj, do_unlink=True)
tool.Model.regenerate_array(obj, parent_data)
assert (
orphan_guid not in parent_data[0]["children"]
), "orphan GUID must be pruned from array['children'] once its Blender object is dead"
try:
still_there = tool.Ifc.get().by_guid(orphan_guid)
except RuntimeError:
still_there = None
assert still_there is None, "orphan IFC entity must be cascade-removed, not left as a leak"
def test_regen_completes_when_child_deleted_outside_bim_cascade(self):
obj, element, parent_data = _build_actuator_with_array_pset(count=6)
bpy.context.view_layer.objects.active = obj
tool.Model.regenerate_array(obj, parent_data)
victim_guid = parent_data[0]["children"][2]
victim_element = tool.Ifc.get().by_guid(victim_guid)
victim_obj = tool.Ifc.get_object(victim_element)
bpy.data.objects.remove(victim_obj, do_unlink=True)
tool.Model.regenerate_array(obj, parent_data)
assert len(parent_data[0]["children"]) == 5, "regen must rebuild to the target count after pruning the orphan"
for guid in parent_data[0]["children"]:
child = tool.Ifc.get().by_guid(guid)
child_obj = tool.Ifc.get_object(child)
assert child_obj is not None, "every surviving child must have a live Blender object"
class TestRecreatePortConnectionsZipsPairs(NewFile):
"""Pins the [0]-indexing sweep in tool/duplicate.py recreate_port_connections.
When both sides of a port-to-port connection are duplicated N times, the
connection must be recreated on every pair of new siblings not just the
first. Matters for arrayed MEP segments (pipes / ducts / cables) where each
child in the array should stay connected to its neighbour after regen."""
def _make_snapshot(self, relating_element, records, port_counts):
from bonsai.tool.duplicate import PortConnectionSnapshot
return PortConnectionSnapshot(
by_element={relating_element: records},
port_counts=port_counts,
)
def _make_record(self, related_element, relating_port_index=0, related_port_index=0, direction="SOURCE"):
from bonsai.tool.duplicate import PortConnectionRecord
return PortConnectionRecord(
relating_port_index=relating_port_index,
related_element=related_element,
related_port_index=related_port_index,
direction=direction,
)
def test_zips_n_pairs_when_both_sides_duplicated(self):
from unittest.mock import Mock
relating_old = Mock()
related_old = Mock()
record = self._make_record(related_old)
snapshot = self._make_snapshot(relating_old, [record], port_counts={})
old_to_new = {
relating_old: [Mock(), Mock(), Mock()],
related_old: [Mock(), Mock(), Mock()],
}
fake_ports = [Mock(), Mock()]
with patch.object(tool.System, "get_ports", return_value=fake_ports), patch.object(
tool.Ifc, "run", return_value=None
) as run_mock:
tool.Duplicate.recreate_port_connections(snapshot, old_to_new)
connect_calls = [c for c in run_mock.call_args_list if c.args and c.args[0] == "system.connect_port"]
assert (
len(connect_calls) == 3
), f"zip-pair must create 3 connect_port calls for 3-vs-3 batched MEP duplicate; got {len(connect_calls)}"
def test_skips_when_other_side_not_duplicated(self):
from unittest.mock import Mock
relating_old = Mock()
related_old = Mock()
record = self._make_record(related_old)
snapshot = self._make_snapshot(relating_old, [record], port_counts={})
# Only relating side is in old_to_new.
old_to_new = {relating_old: [Mock(), Mock(), Mock()]}
with patch.object(tool.System, "get_ports", return_value=[Mock()]), patch.object(
tool.Ifc, "run", return_value=None
) as run_mock:
tool.Duplicate.recreate_port_connections(snapshot, old_to_new)
connect_calls = [c for c in run_mock.call_args_list if c.args and c.args[0] == "system.connect_port"]
assert connect_calls == [], "when only one side is in old_to_new, no port connections should be recreated"
def test_single_pair_case_unchanged(self):
"""Pre-sweep behavior (1 source -> 1 new) must still work — zip with two 1-element lists."""
from unittest.mock import Mock
relating_old = Mock()
related_old = Mock()
record = self._make_record(related_old)
snapshot = self._make_snapshot(relating_old, [record], port_counts={})
old_to_new = {relating_old: [Mock()], related_old: [Mock()]}
with patch.object(tool.System, "get_ports", return_value=[Mock()]), patch.object(
tool.Ifc, "run", return_value=None
) as run_mock:
tool.Duplicate.recreate_port_connections(snapshot, old_to_new)
connect_calls = [c for c in run_mock.call_args_list if c.args and c.args[0] == "system.connect_port"]
assert len(connect_calls) == 1
@@ -346,9 +346,7 @@ def test_active_is_flow_segment_classifies_segment_vs_fitting():
fitting_elem.is_a = lambda c: c == "IfcFlowFitting"
plain = Mock()
with patch("bonsai.bim.module.model.mep.tool.System.has_parametric_body", return_value=True), patch(
"bonsai.bim.module.model.mep.tool.Array.is_array_child", return_value=False
):
with patch("bonsai.bim.module.model.mep.tool.System.has_parametric_body", return_value=True):
with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=segment_elem):
assert _active_is_flow_segment(plain) is True
with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=fitting_elem):
@@ -146,9 +146,7 @@ def test_fit_flow_segments_with_single_segment_dispatches_obstruction():
mep.tool.Model, "get_flow_segment_profile", return_value=segment_profile
), patch.object(mep.MEPAddObstruction, "_execute", return_value=None) as obstruction, patch.object(
mep.MEPAddBend, "_execute", return_value=None
) as bend, patch.object(
mep.MEPAddTransition, "_execute", return_value=None
) as transition:
) as bend, patch.object(mep.MEPAddTransition, "_execute", return_value=None) as transition:
mep.FitFlowSegments._execute(op, context=context)
assert obstruction.call_count == 1
@@ -180,9 +178,7 @@ def test_fit_flow_segments_refuses_mixed_pipe_and_duct():
mep.tool.Model, "get_flow_segment_profile", return_value=profile
), patch.object(mep.MEPAddObstruction, "_execute", return_value=None) as obstruction, patch.object(
mep.MEPAddBend, "_execute", return_value=None
) as bend, patch.object(
mep.MEPAddTransition, "_execute", return_value=None
) as transition:
) as bend, patch.object(mep.MEPAddTransition, "_execute", return_value=None) as transition:
mep.FitFlowSegments._execute(op, context=context)
obstruction.assert_not_called()
@@ -173,9 +173,8 @@ def test_gizmo_group_class_wiring(gizmo_cls_name, bl_idname, is_element_predicat
predicate = getattr(tool.Parametric, is_element_predicate)
fake_element = Mock()
fake_element.is_a.return_value = True
with (
patch.object(tool.Parametric, is_element_predicate, side_effect=predicate) as p,
patch.object(tool.System, "has_parametric_body", return_value=True),
with patch.object(tool.Parametric, is_element_predicate, side_effect=predicate) as p, patch.object(
tool.System, "has_parametric_body", return_value=True
):
cls.is_element_type(fake_element)
assert p.called, f"{gizmo_cls_name}.is_element_type did not delegate to Parametric.{is_element_predicate}"
@@ -139,5 +139,6 @@ def test_every_cancel_ops_entry_has_a_real_preview_propertygroup() -> None:
orphaned = [attr for attr, _op in preview_base.PREVIEW_CANCEL_OPS if attr not in declared_attrs]
assert not orphaned, (
"PREVIEW_CANCEL_OPS contains entries whose PointerProperty child no longer "
f"exists on {UMBRELLA_CLASS}. Drop the stale tuple(s):\n " + "\n ".join(orphaned)
f"exists on {UMBRELLA_CLASS}. Drop the stale tuple(s):\n "
+ "\n ".join(orphaned)
)
-1
View File
@@ -24,7 +24,6 @@ import time
import bpy
import ifcopenshell
import ifcopenshell.util.element
import pytest
from bonsai import tool as tool
@@ -164,62 +164,6 @@ def test_stale_element_skipped_at_drain():
assert recut.call_count == 0
class _DeadStructRNA:
"""Simulates a Blender object whose StructRNA has been removed — every
attribute access raises ReferenceError. Enqueue this as voided_obj to
reproduce the outliner-mid-batch-delete crash."""
def __getattr__(self, name):
raise ReferenceError("StructRNA of type Object has been removed")
def __bool__(self):
raise ReferenceError("StructRNA of type Object has been removed")
def test_dead_structrna_recut_skipped_at_drain():
"""Blender object is deleted while the batch is open (outliner delete +
manual DEL bypass the bim.delete cascade). The drain must skip it silently
not raise so unrelated hosts in the same batch still get their recut."""
from bonsai import tool
dead_obj = _DeadStructRNA()
live_obj = _mock_voided_obj("LiveWall")
rep = Mock()
def get_entity(obj):
# Called only when the guard clears — for the dead ref, guard short-circuits first.
return _mock_element(2)
with patch("bonsai.core.geometry.switch_representation") as recut, patch.object(
tool.Ifc, "get_entity", side_effect=get_entity
), patch.object(tool.Geometry, "get_active_representation", return_value=rep):
with tool.Geometry.batch_host_recut():
tool.Geometry._host_recut_queue[999] = (dead_obj, rep)
tool.Geometry.recut_host(live_obj, rep)
assert recut.call_count == 1, "live host must still get its recut despite a dead sibling in the queue"
drained_obj = recut.call_args.kwargs["obj"]
assert drained_obj is live_obj
def test_dead_structrna_update_skipped_at_drain():
"""Same guarantee for update_representation drain path."""
from bonsai import tool
dead_obj = _DeadStructRNA()
live_obj = _mock_voided_obj("LiveWall")
bpy_ops_mock = Mock()
with patch("bonsai.tool.geometry.bpy.ops", new=bpy_ops_mock), patch.object(
tool.Ifc, "get_entity", return_value=_mock_element(42)
), patch.object(tool.Geometry, "get_active_representation", return_value=Mock()):
with tool.Geometry.batch_host_recut():
tool.Geometry._host_update_queue[999] = dead_obj
tool.Geometry.update_host_representation(live_obj)
assert bpy_ops_mock.bim.update_representation.call_count == 1
def test_exception_inside_batch_still_resets_state():
from bonsai import tool
+4 -5
View File
@@ -23,7 +23,6 @@ import bpy
import ifcopenshell
import ifcopenshell.api.geometry
import ifcopenshell.api.material
import ifcopenshell.api.pset
import ifcopenshell.api.root
import ifcopenshell.api.style
import ifcopenshell.api.type
@@ -631,15 +630,15 @@ class TestUsingArrays(NewFile):
def test_remove_array_first_to_last(self):
self.setup_array(add_second_layer=True)
bpy.ops.bim.remove_array(item=0)
assert len(self._array_objects()) == 3
assert len(bpy.context.selected_objects) == 3
bpy.ops.bim.remove_array(item=0)
assert len(self._array_objects()) == 1
assert len(bpy.context.selected_objects) == 1
def test_apply_array_1_layer(self):
self.setup_array()
bpy.ops.bim.apply_array()
objs = self._array_objects()
objs = bpy.context.selected_objects
assert len(objs) == 4
# check BBIM_Array psets are removed
for obj in objs:
@@ -665,7 +664,7 @@ class TestUsingArrays(NewFile):
self.setup_array(sync_children=True)
bpy.ops.bim.apply_array()
objs = self._array_objects()
objs = bpy.context.selected_objects
assert len(objs) == 4
# check BBIM_Array psets are removed
for obj in objs:
+3 -5
View File
@@ -57,8 +57,7 @@ class CsvHeader(TypedDict):
# Formula
Formula: NotRequired[str]
# QuantityClass: 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
@@ -98,8 +97,7 @@ class CostItem(TypedDict):
Query: Union[str, None]
Formula: Union[str, None]
# QuantityClass: Union[str, None]
#QuantityClass: Union[str, None]
class Csv2Ifc:
# Inputs.
@@ -422,7 +420,7 @@ class Csv2Ifc:
products=results,
formula=cost_item["Formula"],
ifc_class=ifc_quantity_class,
)
)
self.create_cost_items(cost_item["children"], cost_item["ifc"])
@@ -231,7 +231,7 @@ def open(
kwargs = {"mmap": mmap}
if logger is not None:
kwargs["logger"] = logger
f = ifcopenshell_wrapper.open(str(path.absolute()), **kwargs)
f = ifcopenshell_wrapper.open(str(path.absolute()), **kwargs) # ty: ignore[unknown-argument]
else:
f = ifcopenshell_wrapper.open(str(path.absolute()), False, *((logger,) if logger is not None else ()))
return file(f)
@@ -49,7 +49,6 @@ Future versions of this API may support:
from ._get_segment_start_point_label import register_referent_name_callback
from .add_stationing_referent import add_stationing_referent
from .add_positioning_referent import add_positioning_referent
from .add_vertical_layout import add_vertical_layout
from .add_zero_length_segment import add_zero_length_segment
from .create import create
@@ -95,7 +94,6 @@ from .util import *
__all__ = [
"add_stationing_referent",
"add_positioning_referent",
"add_vertical_layout",
"add_zero_length_segment",
"create",
@@ -22,6 +22,8 @@ import numpy as np
import ifcopenshell
import ifcopenshell.api.alignment
import ifcopenshell.geom
import ifcopenshell.ifcopenshell_wrapper as ifcopenshell_wrapper
import ifcopenshell.util.unit
from ifcopenshell import entity_instance
from ifcopenshell.api.alignment._get_segment_endpoint import _get_segment_endpoint
from ifcopenshell.api.alignment._update_zero_length_segment_placement import _update_zero_length_segment_placement
@@ -22,11 +22,28 @@ import numpy as np
import ifcopenshell
import ifcopenshell.api.alignment
from ifcopenshell.api.alignment import _map_alignment_cant_segment
from ifcopenshell.api.alignment._update_zero_length_segment_placement import _update_zero_length_segment_placement
import ifcopenshell.api.nest
from ifcopenshell import entity_instance
import ifcopenshell.api.pset
import ifcopenshell.geom
import ifcopenshell.util.alignment
import ifcopenshell.util.unit
from ifcopenshell import entity_instance, ifcopenshell_wrapper
from ifcopenshell.api.alignment._add_segment_to_curve import _add_segment_to_curve
from ifcopenshell.api.alignment._get_segment_endpoint import _get_segment_endpoint
from ifcopenshell.api.alignment._get_segment_start_point_label import (
_get_segment_start_point_label,
)
from ifcopenshell.api.alignment._map_alignment_cant_segment import (
_map_alignment_cant_segment,
)
from ifcopenshell.api.alignment._map_alignment_horizontal_segment import (
_map_alignment_horizontal_segment,
)
from ifcopenshell.api.alignment._map_alignment_vertical_segment import (
_map_alignment_vertical_segment,
)
def _add_segment_to_layout(
@@ -18,7 +18,11 @@
import ifcopenshell
import ifcopenshell.api.alignment
import ifcopenshell.util.alignment
from ifcopenshell import entity_instance
from ifcopenshell.api.alignment._get_segment_start_point_label import (
_get_segment_start_point_label,
)
def _add_zero_length_segment(file: ifcopenshell.file, layout: entity_instance) -> None:
@@ -18,7 +18,6 @@
import ifcopenshell.api.alignment
import ifcopenshell.geom
from ifcopenshell import entity_instance, ifcopenshell_wrapper
from ifcopenshell.api.alignment._map_alignment_segment import _map_alignment_segment
from typing import Union
@@ -1,113 +0,0 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import ifcopenshell
import ifcopenshell.api.alignment
from ifcopenshell.api.alignment.update_fallback_position import update_fallback_position
import ifcopenshell.api.pset
import ifcopenshell.guid
from ifcopenshell import entity_instance
def add_positioning_referent(
file: ifcopenshell.file,
name: str,
alignment: entity_instance,
distance_along: float,
station: float,
positioned_product: entity_instance,
) -> entity_instance:
"""
Semantically defines the position of a product along an alignment by adding an IfcReferent to the alignment that defines the stationing system.
:param alignment: the alignment to receive the referent
:param distance_along: distance along the alignment basis curve
:param station: station value
:param name: name to assign to IfcReferent.Name, typically a stringized version of the station value
:param positioned_product: the product whose position is informed by the referent
:return: referent
Example:
.. code:: python
alignment = model.by_type("IfcAlignment")[0]
pier = model.by_type("IfcBridgePart")[0]
ifcopenshell.api.alignment.add_positioning_referent(model,name="Pier 1 Sta 1+00",alignment=alignment,distance_along=0.0,station=100.0,positioned_product=pier)
"""
curve = ifcopenshell.api.alignment.get_curve(alignment)
object_placement = None
representation = None
if curve and curve.is_a("IfcCompositeCurve") and 0 < len(curve.Segments):
object_placement = file.createIfcLinearPlacement(
RelativePlacement=file.createIfcAxis2PlacementLinear(
Location=file.createIfcPointByDistanceExpression(
DistanceAlong=file.createIfcLengthMeasure(distance_along),
OffsetLateral=None,
OffsetVertical=None,
OffsetLongitudinal=None,
BasisCurve=curve,
)
),
)
update_fallback_position(file, object_placement)
else:
object_placement = file.createIfcLocalPlacement(
PlacementRelTo=None,
RelativePlacement=file.createIfcAxis2Placement2D(
Location=file.createIfcCartesianPoint(alignment.ObjectPlacement.RelativePlacement.Location.Coordinates)
),
)
# this commented out code is what you would do to add a geometric representation of the referent
# the example is a circle. a better way would be to pass a representation into the function
# representation = file.create_entity(
# name="IfcCircle",
# position=file.createIfcAxis2Placement2D(Location=file.createIfcCartesianPoint(Coordinates=(0.0, 0.0)),
# radius=1.0)
# )
# create referent for the station
referent = file.createIfcReferent(
GlobalId=ifcopenshell.guid.new(),
OwnerHistory=None,
Name=name,
Description=None,
ObjectType=None,
ObjectPlacement=object_placement,
Representation=representation,
PredefinedType="POSITION",
)
pset_stationing = ifcopenshell.api.pset.add_pset(file, product=referent, name="Pset_Stationing")
ifcopenshell.api.pset.edit_pset(file, pset=pset_stationing, properties={"Station": station})
if len(referent.Positions) == 0:
rel_positions = file.createIfcRelPositions(
GlobalId=ifcopenshell.guid.new(),
RelatingPositioningElement=referent,
RelatedProducts=[
positioned_product,
],
)
else:
referent.Positions[0].RelatedProducts += (positioned_product,)
return referent
@@ -16,35 +16,35 @@
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
from typing import Optional
import numpy as np
import ifcopenshell
import ifcopenshell.api.alignment
from ifcopenshell.api.alignment.update_fallback_position import update_fallback_position
import ifcopenshell.api.pset
import ifcopenshell.geom
import ifcopenshell.guid
import ifcopenshell.util.element
from ifcopenshell import entity_instance
import ifcopenshell.util.unit
from ifcopenshell import entity_instance, ifcopenshell_wrapper
def add_stationing_referent(
file: ifcopenshell.file,
name: str,
alignment: entity_instance,
distance_along: float,
station: float,
incoming_station: Optional[float] = None,
on_basis_curve: Optional[bool] = None,
name: str,
positioned_product: entity_instance,
) -> entity_instance:
"""
Adds an IfcReferent to the alignment that defines the stationing system.
Adds an IfcReferent to the alignment with the Pset_Stationing property set.
:param name: name to assign to IfcReferent.Name, typically a stringized version of the station value
:param alignment: the alignment to receive the referent
:param distance_along: distance along the alignment basis curve
:param station: station value
:param incoming_station: station value of the incoming segment, only set to specify a station equation
:param on_basis_curve: whether the referent is positioned on the basis curve or the alignment curve, if None the function will default to the basis curve
:param name: name to assign to IfcReferent.Name, typically a stringized version of the station value
:param positioned_product: the product whose position is informed by the referent
:return: referent
Example:
@@ -52,21 +52,14 @@ def add_stationing_referent(
.. code:: python
alignment = model.by_type("IfcAlignment")[0]
ifcopenshell.api.alignment.add_stationing_referent(model,name="1+00.0",alignment=alignment,distance_along=0.0,station=100.0)
ifcopenshell.api.alignment.add_stationing_referent(model,alignment=alignment,distance_along=0.0,station=100.0)
"""
if on_basis_curve is None:
on_basis_curve = True
curve = (
ifcopenshell.api.alignment.get_basis_curve(alignment)
if on_basis_curve
else ifcopenshell.api.alignment.get_curve(alignment)
)
basis_curve = ifcopenshell.api.alignment.get_basis_curve(alignment)
object_placement = None
representation = None
if curve and curve.is_a("IfcCompositeCurve") and 0 < len(curve.Segments):
if basis_curve and basis_curve.is_a("IfcCompositeCurve") and 0 < len(basis_curve.Segments):
object_placement = file.createIfcLinearPlacement(
RelativePlacement=file.createIfcAxis2PlacementLinear(
Location=file.createIfcPointByDistanceExpression(
@@ -74,7 +67,7 @@ def add_stationing_referent(
OffsetLateral=None,
OffsetVertical=None,
OffsetLongitudinal=None,
BasisCurve=curve,
BasisCurve=basis_curve,
)
),
)
@@ -107,12 +100,8 @@ def add_stationing_referent(
Representation=representation,
PredefinedType="STATION",
)
properties = {"Station": station}
if incoming_station is not None:
properties["IncomingStation"] = incoming_station
pset_stationing = ifcopenshell.api.pset.add_pset(file, product=referent, name="Pset_Stationing")
ifcopenshell.api.pset.edit_pset(file, pset=pset_stationing, properties=properties)
ifcopenshell.api.pset.edit_pset(file, pset=pset_stationing, properties={"Station": station})
nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
if nest is None:
@@ -126,4 +115,15 @@ def add_stationing_referent(
nest.RelatedObjects, key=lambda x: ifcopenshell.util.element.get_pset(x, name="Pset_Stationing", prop="Station")
)
if len(referent.Positions) == 0:
rel_positions = file.createIfcRelPositions(
GlobalId=ifcopenshell.guid.new(),
RelatingPositioningElement=referent,
RelatedProducts=[
positioned_product,
],
)
else:
referent.Positions[0].RelatedProducts += (positioned_product,)
return referent
@@ -51,6 +51,18 @@ def _move_vertical_layout_to_child_alignment(
# aggregate the child alignment to the parent alignment
ifcopenshell.api.aggregate.assign_object(file, products=[child_alignment], relating_object=parent_alignment)
# move all referents positioning segments of the vertical layout to the referent nest of the child alignment
child_referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, child_alignment)
parent_referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, parent_alignment)
for referent in parent_referent_nest.RelatedObjects:
for product in referent.Positions[0].RelatedProducts:
if product.is_a("IfcAlignmentSegment") and product.Nests[0].RelatingObject == vertical_layout:
# ifcopenshell.api.nest.change_nest(file,referent,child_alignment) - this doesn't work because referent is assigned to child_alignment.IsNestedBy[0].RelatedObjects
# and it needs to be assigned to child_alignment.IsNestedBy[1].RelatedObjects
# move the referent manually - unassign it and add it to the child alignment's referent nest
ifcopenshell.api.nest.unassign_object(file, [referent])
child_referent_nest.RelatedObjects += (referent,)
# if the parent alignment has a representation, move the Axis/Curve3D represention to the child alignment
base_curve = ifcopenshell.api.alignment.get_basis_curve(parent_alignment)
if base_curve:
@@ -23,8 +23,18 @@ import ifcopenshell.api.alignment
from ifcopenshell.api.alignment._get_segment_endpoint import _get_segment_endpoint
from ifcopenshell.api.alignment._update_zero_length_segment_placement import _update_zero_length_segment_placement
import ifcopenshell.api.nest
import ifcopenshell.ifcopenshell_wrapper as wrapper
import ifcopenshell.util.unit
from ifcopenshell import entity_instance
from ifcopenshell.api.alignment._get_segment_start_point_label import (
_get_segment_start_point_label,
)
from ifcopenshell.api.alignment._map_alignment_horizontal_segment import (
_map_alignment_horizontal_segment,
)
from ifcopenshell.api.alignment._map_alignment_vertical_segment import (
_map_alignment_vertical_segment,
)
from ifcopenshell.api.alignment._update_curve_segment_transition_code import (
_update_curve_segment_transition_code,
)
@@ -87,7 +87,9 @@ def create(
_create_geometric_representation(file, alignment)
referent_name = ifcopenshell.util.alignment.station_as_string(file, start_station)
referent = ifcopenshell.api.alignment.add_stationing_referent(file, referent_name, alignment, 0.0, start_station)
referent = ifcopenshell.api.alignment.add_stationing_referent(
file, alignment, 0.0, start_station, referent_name, alignment
)
for layout in alignment_layouts:
_add_zero_length_segment(file, layout)
@@ -141,7 +141,7 @@ def create_as_polyline(
# define stationing
name = ifcopenshell.util.alignment.station_as_string(file, start_station)
referent = ifcopenshell.api.alignment.add_stationing_referent(file, name, alignment, 0.0, start_station)
referent = ifcopenshell.api.alignment.add_stationing_referent(file, alignment, 0.0, start_station, name, alignment)
# IFC 4.1.4.1.1 Alignment Aggregation To Project
project = file.by_type("IfcProject")[0]
@@ -21,7 +21,9 @@ from typing import Union
import numpy as np
import ifcopenshell
from ifcopenshell import entity_instance
import ifcopenshell.api.alignment
import ifcopenshell.geom
from ifcopenshell import entity_instance, ifcopenshell_wrapper
from ifcopenshell.api.alignment._add_segment_to_layout import _add_segment_to_layout
@@ -16,47 +16,23 @@
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
from typing import Optional
import ifcopenshell
import ifcopenshell.api.alignment
import ifcopenshell.util.element
from ifcopenshell import entity_instance
def _distance_along_of_referent(referent: entity_instance) -> float:
placement = referent.ObjectPlacement
if placement.is_a("IfcLinearPlacement"):
return placement.RelativePlacement.Location.DistanceAlong.wrappedValue
# IfcLocalPlacement fallback (e.g. semantic-only alignment, or the placement could not yet
# be expressed relative to a basis curve) carries no DistanceAlong; it is only ever used for
# the starting referent, at distance 0.0.
return 0.0
def distance_along_from_station(file: ifcopenshell.file, alignment: entity_instance, station: float) -> Optional[float]:
def distance_along_from_station(file: ifcopenshell.file, alignment: entity_instance, station: float) -> float:
"""
Given a station, returns the distance along the horizontal alignment.
If the alignment does not have stationing defined with an IfcReferent, the start of the alignment is assumed
to be at station 0.0. That is, the station is the distance along.
Station equations (where Pset_Stationing.IncomingStation is set on a referent) are taken into account.
For each STATION referent nested to the alignment, DistanceAlong (D) and the outgoing station (S, i.e.
Pset_Stationing.Station) are read off, sorted by DistanceAlong. The requested station is located within
the segment defined by the last referent whose outgoing station is less than or equal to it, and the
distance along is computed as D + (station - S) for that referent.
If the station falls within a gap introduced by a forward (gap) station equation - that is, it was skipped
over by the equation - there is no distance along that corresponds to it, and None is returned.
Note that an overlap (backward) station equation causes a range of stations to correspond to two distinct
distances along the alignment, one on either side of the equation. This implementation returns the distance
along in the segment following the equation (i.e. the outgoing side).
.. note:: The current implementation does not account for station equations and assumes stationing is increasing along the alignment.
:param alignment: the alignment
:param station: station value
:return: distance along the horizontal alignment, or None if the station falls inside a station equation gap
:return: distance along the horizontal alignment
Example:
@@ -67,36 +43,6 @@ def distance_along_from_station(file: ifcopenshell.file, alignment: entity_insta
print(dist_along) # 100.00
"""
referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
if referent_nest is None:
start_station = ifcopenshell.api.alignment.get_alignment_start_station(file, alignment)
return station - start_station
stations = [
(
_distance_along_of_referent(referent),
ifcopenshell.util.element.get_pset(referent, name="Pset_Stationing", prop="Station"),
)
for referent in referent_nest.RelatedObjects
]
stations.sort(key=lambda entry: entry[0])
index = None
for i, (distance_along, outgoing_station) in enumerate(stations):
if outgoing_station <= station:
index = i
if index is None:
# station precedes the alignment's starting station; extrapolate from the first referent
distance_along, outgoing_station = stations[0]
return distance_along + (station - outgoing_station)
distance_along, outgoing_station = stations[index]
if index + 1 < len(stations):
next_distance_along, _ = stations[index + 1]
if station - outgoing_station > next_distance_along - distance_along:
# the station was skipped over by a forward (gap) station equation
return None
return distance_along + (station - outgoing_station)
start_station = ifcopenshell.api.alignment.get_alignment_start_station(file, alignment)
dist_along = station - start_station
return dist_along
@@ -16,6 +16,7 @@
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
from collections.abc import Sequence
from ifcopenshell import entity_instance
@@ -19,7 +19,6 @@
import numpy as np
import ifcopenshell
import ifcopenshell.api.alignment
import ifcopenshell.util.placement
from ifcopenshell import entity_instance
@@ -16,6 +16,8 @@
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import numpy as np
import ifcopenshell
import ifcopenshell.util.placement
from ifcopenshell import entity_instance
@@ -34,7 +36,7 @@ def update_fallback_position(file: ifcopenshell.file, lp: entity_instance):
if not lp.CartesianPosition:
lp.CartesianPosition = file.createIfcAxis2Placement3D(Location=file.createIfcCartesianPoint((0.0, 0.0, 0.0)))
p = ifcopenshell.util.placement.get_local_placement(lp)
p = np.array(ifcopenshell.util.placement.get_axis2placement(lp.RelativePlacement))
x = float(p[0, 3])
y = float(p[1, 3])
@@ -117,7 +117,7 @@ def assign_cost_item_quantity(
"products": products or [],
"prop_name": prop_name,
"formula": formula,
"ifc_class": ifc_class,
"ifc_class" : ifc_class
}
return usecase.execute()
@@ -134,7 +134,7 @@ class Usecase:
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")
tree = ast.parse(self.settings["formula"], mode = "eval")
collector = VariableExtractor()
collector.visit(tree)
variables = collector.variables
@@ -144,10 +144,10 @@ class Usecase:
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."
)
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}' "
@@ -159,9 +159,7 @@ class Usecase:
new_quantity = None
for quantity in self.quantities:
if (
quantity.Formula == self.settings["formula"] and len(self.settings["products"]) == 1
): # Todo improve it
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
@@ -186,23 +184,23 @@ class Usecase:
self.update_cost_item_count()
def get_value_from_pset(
self,
product: ifcopenshell.entity_instance,
v: str,
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)
return (pset or {}).get(pset_property_name,None)
def get_value_from_qset(
self,
product: ifcopenshell.entity_instance,
v: str,
self,
product:ifcopenshell.entity_instance,
v: str,
) -> float:
qtos = ifcopenshell.util.element.get_psets(product, qtos_only=True)
qtos = ifcopenshell.util.element.get_psets(product, qtos_only = True)
quantities = next(iter(qtos.values()), {})
return (quantities or {}).get(v, None)
return (quantities or {}).get(v,None)
def assign_cost_control(
self, related_object: ifcopenshell.entity_instance, cost_item: ifcopenshell.entity_instance
@@ -245,7 +243,6 @@ class Usecase:
count += 1
quantity[3] = count
OPERATORS = {
ast.Add: operator.add,
ast.Sub: operator.sub,
@@ -255,20 +252,18 @@ OPERATORS = {
ast.USub: operator.neg,
}
def build_full_name(node):
# used for variables with dots
#used for variables with dots
parts = []
while isinstance(node, ast.Attribute):
parts.append(node.attr)
node = node.value
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()
@@ -279,7 +274,6 @@ class VariableExtractor(ast.NodeVisitor):
def visit_Attribute(self, node):
self.variables.add(build_full_name(node))
class FormulaEvaluator(ast.NodeVisitor):
def __init__(self, values):
self.values = values
@@ -287,7 +281,7 @@ class FormulaEvaluator(ast.NodeVisitor):
def visit_BinOp(self, node):
left = self.visit(node.left)
right = self.visit(node.right)
return OPERATORS[type(node.op)](left, right) # ty: ignore[too-many-positional-arguments]
return OPERATORS[type(node.op)](left, right)
def visit_Name(self, node):
return self.values[node.id]
@@ -775,10 +775,7 @@ class Usecase:
# Utils method for the loop.
def get_tuple_type(tuple_: tuple) -> type:
# Guard against empty (possibly nested) tuples, e.g. an aggregate
# attribute set to `()` or `((),)`, which would otherwise index
# into an empty tuple and raise IndexError (see #7261).
while isinstance(tuple_, tuple) and tuple_:
while isinstance(tuple_, tuple):
tuple_ = tuple_[0]
return type(tuple_)
@@ -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,
@@ -642,16 +642,16 @@ class entity_instance:
return_type: type[dict] = dict,
ignore: Sequence[str] = (),
) -> dict[str, Any]:
"""More perfomant version of `.get_info()`.\n
Method has exactly the same signature as `.get_info()`, but the fast C++
path only implements ``recursive=True``, ``return_type=dict`` and
``ignore=()``. Any other combination falls back to the pure Python
`.get_info()`, where no meaningful performance gain is possible anyway
as the cost is dominated by the recursive traversal.
"""More perfomant version of `.get_info()` but with limited arguments values.\n
Method has exactly the same signature as `.get_info()` but it doesn't support getting information non-recursively.
Currently supported arguments values:
* recursive: `True` (will fail with default `False` value from `.get_info()`)
* return_type: `dict`
* ignore: `()` (empty tuple)
"""
if recursive and return_type is dict and not ignore:
return ifcopenshell_wrapper.get_info_cpp(self.wrapped_data, include_identifier)
return self.get_info(
include_identifier=include_identifier, recursive=recursive, return_type=return_type, ignore=ignore
)
assert recursive
assert return_type is dict
assert len(ignore) == 0
return ifcopenshell_wrapper.get_info_cpp(self.wrapped_data, include_identifier)
@@ -221,7 +221,8 @@ for id in to_emit:
statements.append("%s << %s" % (id, stmt))
if __name__ == "__main__":
print(r"""
print(
r"""
# This file is generated by IfcOpenShell ifcexpressparser bootstrap.py
from __future__ import annotations
@@ -260,4 +261,6 @@ if __name__ == "__main__":
mdl = importlib.import_module(output)
mdl.Generator(m).emit()
sys.stdout.write(m.schema.name)
""" % ("\n ".join(statements)))
"""
% ("\n ".join(statements))
)
@@ -695,7 +695,6 @@ codegen_rule("MOD", lambda context: "%")
codegen_rule("TRUE", lambda context: "True")
codegen_rule("FALSE", lambda context: "False")
def _dotted_name(node: ast.AST):
"""Return dotted name for Name/Attribute chains, else None."""
if isinstance(node, ast.Name):
@@ -705,7 +704,6 @@ def _dotted_name(node: ast.AST):
return f"{base}.{node.attr}" if base else node.attr
return None
class AttributeGetattrTransformer(ast.NodeTransformer):
def visit_Attribute(self, node):
parents = []
@@ -722,7 +720,7 @@ class AttributeGetattrTransformer(ast.NodeTransformer):
if isinstance(node.ctx, ast.Store):
return node
if _dotted_name(node) in ("ifcopenshell.create_entity", "str.lower"):
if _dotted_name(node) in ('ifcopenshell.create_entity', 'str.lower'):
return node
if node.attr.startswith("__"):
@@ -363,18 +363,24 @@ class EarlyBoundCodeWriter:
)
)
self.statements[self.statements.index("{factory_placeholder}")] = """
self.statements[self.statements.index("{factory_placeholder}")] = (
"""
class %(schema_name)s_instance_factory : public IfcParse::instance_factory {
virtual IfcUtil::IfcBaseClass* operator()(const IfcParse::declaration* decl, IfcEntityInstanceData&& data) const {
%(instance_mapping)s
}
};
""" % locals()
"""
% locals()
)
""
self.statements[self.statements.index("{string_pool_placeholder}")] = """
self.statements[self.statements.index("{string_pool_placeholder}")] = (
"""
const std::string strings[] = {%s};
""" % ",".join(map(lambda s: '"%s"s' % s, self.strings))
"""
% ",".join(map(lambda s: '"%s"s' % s, self.strings))
)
def __str__(self):
return "\n".join(self.statements)
@@ -145,7 +145,8 @@ class configuration:
config.set(
"snippets",
"print all wall ids",
self.config_encode("""
self.config_encode(
"""
###########################################################################
# A simple script that iterates over all walls in the current model #
# and prints their Globally unique IDs (GUIDS) to the console window #
@@ -153,13 +154,15 @@ class configuration:
for wall in model.by_type("IfcWall"):
print ("wall with global id: "+str(wall.GlobalId))
""".lstrip()),
""".lstrip()
),
)
config.set(
"snippets",
"print properties of current selection",
self.config_encode("""
self.config_encode(
"""
###########################################################################
# A simple script that iterates over all IfcPropertySets of the currently #
# selected object and prints them to the console #
@@ -177,7 +180,8 @@ if selection:
for prop in relDefinesByProperties.RelatingPropertyDefinition.HasProperties:
print ("{:<20} :{}".format(prop.Name,prop.NominalValue.wrappedValue))
print ("\\n")
""".lstrip()),
""".lstrip()
),
)
with open(conf_file, "w") as configfile:
config.write(configfile)
@@ -1697,16 +1697,10 @@ class uninitialized_tag: ...
def arrange_polygons(settings, polygons): ...
def clear_schemas(): ...
def construct_iterator(geometry_library, settings, file, num_threads, logger=...): ...
def construct_iterator_with_include_exclude(
geometry_library, settings, file, elems, include, num_threads, logger=...
): ...
def construct_iterator_with_include_exclude_globalid(
geometry_library, settings, file, elems, include, num_threads, logger=...
): ...
def construct_iterator_with_include_exclude_id(
geometry_library, settings, file, elems, include, num_threads, logger=...
): ...
def construct_iterator(geometry_library, settings, file, num_threads): ...
def construct_iterator_with_include_exclude(geometry_library, settings, file, elems, include, num_threads): ...
def construct_iterator_with_include_exclude_globalid(geometry_library, settings, file, elems, include, num_threads): ...
def construct_iterator_with_include_exclude_id(geometry_library, settings, file, elems, include, num_threads): ...
def convert_loop_to_function_item(loop): ...
def create_box(*args): ...
def create_epeck(*args): ...
@@ -1723,8 +1717,8 @@ def line_segments_to_polygons(s, eps, segments): ...
def map_shape(settings, instance): ...
def nary_union(sequence): ...
def new_IfcBaseClass(schema_identifier: str, name: str) -> entity_instance: ...
def open(fn: str, readonly: bool = False, logger=...) -> file: ...
def parse_ifcxml(filename, logger=...): ...
def open(fn: str, readonly: bool = False) -> file: ...
def parse_ifcxml(filename): ...
def polygons_to_svg(*args): ...
def read(data): ...
def register_schema(arg1): ...
@@ -56,7 +56,7 @@ def append_zero_length_segments(file: ifcopenshell.file) -> ifcopenshell.file:
for alignment in alignments:
layouts = ifcopenshell.api.alignment.get_alignment_layouts(alignment)
for layout in layouts:
ifcopenshell.api.alignment.add_zero_length_segment(patched_file, layout)
ifcopenshell.api.alignment.add_zero_length_segment(patched_file, layout, include_referent=False)
curve = ifcopenshell.api.alignment.get_layout_curve(layout)
if curve:
ifcopenshell.api.alignment.add_zero_length_segment(patched_file, curve)
@@ -355,7 +355,8 @@ def get_cost_rate(
class CostValueUnserialiser:
def parse(self, formula: str):
l = lark.Lark("""start: formula
l = lark.Lark(
"""start: formula
formula: operand (operator operand)*
operand: value | category "(" formula ")"
value: NUMBER?
@@ -392,7 +393,8 @@ class CostValueUnserialiser:
NEWLINE: (CR? LF)+
%ignore WS // Disregard spaces in text
""")
"""
)
start = l.parse(formula)
return self.get_formula(start.children[0])
@@ -1125,8 +1125,7 @@ def get_decomposition(element: ifcopenshell.entity_instance, is_recursive=True)
"""
Retrieves all subelements of an element based on the spatial decomposition
hierarchy. This includes all subspaces and elements contained in subspaces,
parts of an aggregate, all openings, all fills of any openings, and any
surface features adhering to an element (IFC4.3 and above).
parts of an aggregate, all openings, and all fills of any openings.
:param element: The IFC element
:return: The decomposition of the element
@@ -1162,10 +1161,6 @@ def get_decomposition(element: ifcopenshell.entity_instance, is_recursive=True)
related = rel.RelatedObjects
queue.extend(related)
results.update(related)
for rel in getattr(element, "HasSurfaceFeatures", []):
related = rel.RelatedSurfaceFeatures
queue.extend(related)
results.update(related)
if not is_recursive:
break
return results
@@ -1256,8 +1251,6 @@ def get_parent(
- Nesting: components are attached to a host parent
- Filling: the physical element fills an opening, such as a window filling a hole
- Voiding: the opening voids another physical element, such as a hole in a wall
- Adherence: a surface feature adheres to a host element, such as a road
marking adhering to a road course (IFC4.3 and above)
:param element: Any physical or spatial element in the tree
:param ifc_class: Optionally filter the type of parent you're after. For
@@ -1277,7 +1270,6 @@ def get_parent(
or get_nest(element)
or get_filled_void(element)
or get_voided_element(element)
or get_adhered_element(element)
)
if not ifc_class:
@@ -1329,28 +1321,6 @@ def get_voided_element(element: ifcopenshell.entity_instance) -> Union[ifcopensh
return rel[0].RelatingBuildingElement
def get_adhered_element(element: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]:
"""If the element is a surface feature, get the element it adheres to
In IFC4.3 an IfcSurfaceFeature (such as a road marking) adheres to a host
element through the IfcRelAdheresToElement relationship. This is a [1:1]
cardinality hierarchical relationship, in the same family as aggregation,
containment and nesting.
:param element: The IfcSurfaceFeature
:return: The host element that the surface feature adheres to
Example:
.. code:: python
marking = file.by_type("IfcSurfaceFeature")[0]
host = ifcopenshell.util.element.get_adhered_element(marking)
"""
if rel := getattr(element, "AdheresToElement", None):
return rel[0].RelatingElement
def get_aggregate(element: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]:
"""
Retrieves the aggregate parent of an element.
@@ -1445,29 +1415,6 @@ def get_contained(element: ifcopenshell.entity_instance) -> list[ifcopenshell.en
return objects
def get_surface_features(element: ifcopenshell.entity_instance) -> list[ifcopenshell.entity_instance]:
"""Retrieves the surface features that adhere to an element.
In IFC4.3 an IfcSurfaceFeature (such as a road marking) adheres to a host
element through the IfcRelAdheresToElement relationship.
:param element: The IFC element
:return: The surface features adhering to the element
Example:
.. code:: python
element = file.by_type("IfcCourse")[0]
markings = ifcopenshell.util.element.get_surface_features(element)
"""
objects: list[ifcopenshell.entity_instance] = []
if has_surface_features := getattr(element, "HasSurfaceFeatures", ()):
for rel in has_surface_features:
objects.extend(rel.RelatedSurfaceFeatures)
return objects
def get_components(
element: ifcopenshell.entity_instance, include_ports: bool = False
) -> list[ifcopenshell.entity_instance]:
@@ -39,7 +39,8 @@ import ifcopenshell.util.shape
import ifcopenshell.util.system
import ifcopenshell.util.unit
filter_elements_grammar = lark.Lark("""start: filter_group
filter_elements_grammar = lark.Lark(
"""start: filter_group
filter_group: facet_list ("+" facet_list)*
facet_list: facet ("," facet)*
@@ -110,9 +111,11 @@ filter_elements_grammar = lark.Lark("""start: filter_group
NEWLINE: (CR? LF)+
%ignore WS // Disregard spaces in text
""")
"""
)
get_element_grammar = lark.Lark("""start: keys
get_element_grammar = lark.Lark(
"""start: keys
keys: key ("." key)*
key: quoted_string | regex_string | unquoted_string
@@ -127,9 +130,11 @@ get_element_grammar = lark.Lark("""start: keys
WS: /[ \\t\\f\\r\\n]/+
%ignore WS // Disregard spaces in text
""")
"""
)
format_grammar = lark.Lark("""start: expression
format_grammar = lark.Lark(
"""start: expression
?expression: add_sub
?add_sub: mul_div
@@ -188,7 +193,8 @@ format_grammar = lark.Lark("""start: expression
NEWLINE: (CR? LF)+
%ignore WS // Disregard spaces in text
""")
"""
)
class FormatTransformer(lark.Transformer):
@@ -912,13 +912,6 @@ def convert_file_length_units(ifc_file: ifcopenshell.file, target_units: str = "
new_value = convert_value(val)
setattr(element, attr.name(), new_value)
# IfcGeometricRepresentationContext.Precision is typed as a plain IfcReal
# but is interpreted in the project length unit, so it must be scaled too.
# Subcontexts derive Precision from their parent and cannot be set.
for context in file_patched.by_type("IfcGeometricRepresentationContext", include_subtypes=False):
if context.Precision is not None:
context.Precision = convert_unit(context.Precision, old_length, new_length)
has_map_unit = False
if (
ifc_file.schema == "IFC2X3"
@@ -1,100 +0,0 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import ifcopenshell.api.alignment
import ifcopenshell.api.context
import ifcopenshell.api.unit
import ifcopenshell.util.element
def test_add_positioning_referent():
file = ifcopenshell.file(schema="IFC4X3")
project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
length = ifcopenshell.api.unit.add_si_unit(file, unit_type="LENGTHUNIT")
ifcopenshell.api.unit.assign_unit(file, units=[length])
geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model")
axis_model_representation_subcontext = ifcopenshell.api.context.add_context(
file,
context_type="Model",
context_identifier="Axis",
target_view="MODEL_VIEW",
parent=geometric_representation_context,
)
alignment = ifcopenshell.api.alignment.create(file, "TestAlignment", start_station=2000.0)
horizontal_layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
segment = ifcopenshell.api.alignment.get_layout_segments(horizontal_layout)[0]
referent = ifcopenshell.api.alignment.add_positioning_referent(
file, "P.C.", alignment, distance_along=0.0, station=2000.0, positioned_product=segment
)
assert referent.is_a("IfcReferent")
assert referent.PredefinedType == "POSITION"
assert referent.Name == "P.C."
assert ifcopenshell.util.element.get_pset(element=referent, name="Pset_Stationing")
assert ifcopenshell.util.element.get_pset(element=referent, name="Pset_Stationing", prop="Station") == 2000.0
assert referent.ObjectPlacement != None
assert len(referent.Positions) == 1
rel_positions = referent.Positions[0]
assert rel_positions.is_a("IfcRelPositions")
assert rel_positions.RelatingPositioningElement == referent
assert rel_positions.RelatedProducts == (segment,)
def test_add_positioning_referent_creates_separate_referent_per_call():
file = ifcopenshell.file(schema="IFC4X3")
project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
length = ifcopenshell.api.unit.add_si_unit(file, unit_type="LENGTHUNIT")
ifcopenshell.api.unit.assign_unit(file, units=[length])
geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model")
axis_model_representation_subcontext = ifcopenshell.api.context.add_context(
file,
context_type="Model",
context_identifier="Axis",
target_view="MODEL_VIEW",
parent=geometric_representation_context,
)
alignment = ifcopenshell.api.alignment.create(file, "TestAlignment", start_station=2000.0)
horizontal_layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
segment = ifcopenshell.api.alignment.get_layout_segments(horizontal_layout)[0]
first_referent = ifcopenshell.api.alignment.add_positioning_referent(
file, "P.C.", alignment, distance_along=0.0, station=2000.0, positioned_product=segment
)
other_product = file.createIfcBuildingElementProxy(GlobalId=ifcopenshell.guid.new(), Name="Sign")
second_referent = ifcopenshell.api.alignment.add_positioning_referent(
file, "P.C.", alignment, distance_along=0.0, station=2000.0, positioned_product=other_product
)
# each call creates its own IfcReferent, each with its own IfcRelPositions to the product passed in
assert first_referent != second_referent
assert len(first_referent.Positions) == 1
assert first_referent.Positions[0].RelatedProducts == (segment,)
assert len(second_referent.Positions) == 1
assert second_referent.Positions[0].RelatedProducts == (other_product,)
test_add_positioning_referent()
test_add_positioning_referent_creates_separate_referent_per_call()
@@ -1,115 +0,0 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import ifcopenshell.api.alignment
import ifcopenshell.api.context
import ifcopenshell.api.unit
import ifcopenshell.util.element
def _create_test_file():
file = ifcopenshell.file(schema="IFC4X3")
project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
length = ifcopenshell.api.unit.add_si_unit(file, unit_type="LENGTHUNIT")
ifcopenshell.api.unit.assign_unit(file, units=[length])
geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model")
axis_model_representation_subcontext = ifcopenshell.api.context.add_context(
file,
context_type="Model",
context_identifier="Axis",
target_view="MODEL_VIEW",
parent=geometric_representation_context,
)
return file
def _create_test_alignment_with_vertical(file):
# include_vertical=True so that get_curve() (IfcGradientCurve, on the "Axis" representation)
# and get_basis_curve() (IfcCompositeCurve, on the "FootPrint" representation) are different
# entities, letting the on_basis_curve option be observed.
alignment = ifcopenshell.api.alignment.create(file, "TestAlignment", include_vertical=True, start_station=0.0)
assert ifcopenshell.api.alignment.get_basis_curve(alignment).is_a("IfcCompositeCurve")
assert ifcopenshell.api.alignment.get_curve(alignment).is_a("IfcGradientCurve")
assert ifcopenshell.api.alignment.get_basis_curve(alignment) != ifcopenshell.api.alignment.get_curve(alignment)
return alignment
def _assert_common_referent_asserts(referent, name, station):
assert referent.is_a("IfcReferent")
assert referent.PredefinedType == "STATION"
assert referent.Name == name
assert ifcopenshell.util.element.get_pset(element=referent, name="Pset_Stationing")
assert ifcopenshell.util.element.get_pset(element=referent, name="Pset_Stationing", prop="Station") == station
assert referent.ObjectPlacement != None
def test_add_stationing_referent_on_basis_curve_none_defaults_to_basis_curve():
# on_basis_curve=None should behave the same as on_basis_curve=True
file = _create_test_file()
alignment = _create_test_alignment_with_vertical(file)
referent = ifcopenshell.api.alignment.add_stationing_referent(
file, "1+00.000", alignment, distance_along=100.0, station=100.0, on_basis_curve=None
)
_assert_common_referent_asserts(referent, "1+00.000", 100.0)
assert referent.ObjectPlacement.is_a("IfcLinearPlacement")
assert referent.ObjectPlacement.RelativePlacement.Location.BasisCurve == ifcopenshell.api.alignment.get_basis_curve(
alignment
)
def test_add_stationing_referent_on_basis_curve_true():
file = _create_test_file()
alignment = _create_test_alignment_with_vertical(file)
referent = ifcopenshell.api.alignment.add_stationing_referent(
file, "1+00.000", alignment, distance_along=100.0, station=100.0, on_basis_curve=True
)
_assert_common_referent_asserts(referent, "1+00.000", 100.0)
assert referent.ObjectPlacement.is_a("IfcLinearPlacement")
assert referent.ObjectPlacement.RelativePlacement.Location.BasisCurve == ifcopenshell.api.alignment.get_basis_curve(
alignment
)
def test_add_stationing_referent_on_basis_curve_false():
# with a vertical layout present, on_basis_curve=False positions the referent on the
# alignment curve (IfcGradientCurve) rather than on the basis curve (IfcCompositeCurve).
file = _create_test_file()
alignment = _create_test_alignment_with_vertical(file)
referent = ifcopenshell.api.alignment.add_stationing_referent(
file, "1+00.000", alignment, distance_along=100.0, station=100.0, on_basis_curve=False
)
_assert_common_referent_asserts(referent, "1+00.000", 100.0)
assert referent.ObjectPlacement.is_a("IfcLinearPlacement")
basis_curve = referent.ObjectPlacement.RelativePlacement.Location.BasisCurve
assert basis_curve == ifcopenshell.api.alignment.get_curve(alignment)
assert basis_curve != ifcopenshell.api.alignment.get_basis_curve(alignment)
test_add_stationing_referent_on_basis_curve_none_defaults_to_basis_curve()
test_add_stationing_referent_on_basis_curve_true()
test_add_stationing_referent_on_basis_curve_false()
@@ -48,26 +48,5 @@ def test_add_stationing_to_alignment():
assert ifcopenshell.util.element.get_pset(element=referent, name="Pset_Stationing", prop="Station") == 2000.0
assert referent.ObjectPlacement != None
# add a station equation at 1000 distance along. this is station 3+000 in coming and 4+000 outgoing.
# this is a gap equation.
second_referent = ifcopenshell.api.alignment.add_stationing_referent(
file, "4+000.000", alignment, distance_along=1000.0, station=4000.0, incoming_station=3000.0
)
referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
assert len(referent_nest.RelatedObjects) == 2
assert second_referent == referent_nest.RelatedObjects[1]
assert second_referent.PredefinedType == "STATION"
assert second_referent.Name == "4+000.000"
assert ifcopenshell.util.element.get_pset(element=second_referent, name="Pset_Stationing")
assert ifcopenshell.util.element.get_pset(element=second_referent, name="Pset_Stationing", prop="Station") == 4000.0
assert (
ifcopenshell.util.element.get_pset(element=second_referent, name="Pset_Stationing", prop="IncomingStation")
== 3000.0
)
assert second_referent.ObjectPlacement != None
test_add_stationing_to_alignment()
@@ -21,12 +21,9 @@ import math
import pytest
import ifcopenshell
import ifcopenshell.api.aggregate
import ifcopenshell.api.alignment
import ifcopenshell.api.context
import ifcopenshell.api.spatial
import ifcopenshell.api.unit
import ifcopenshell.util.unit
import numpy as np
def test_create_representation():
@@ -53,56 +53,4 @@ def test_distance_along_from_station():
assert ifcopenshell.api.alignment.distance_along_from_station(file, alignment, 17525.36) == pytest.approx(7525.36)
def test_distance_along_from_station_with_station_equations():
# Reproduces the worked example from the IFC Alignment Geometry Implementation Guide, chapter 9.2.6:
# a gap equation (P3: incoming 14+00.00, outgoing 17+00.00) and an overlap equation
# (P4: incoming 19+00.00, outgoing 18+50.00).
file = ifcopenshell.file(schema="IFC4X3")
project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
length = ifcopenshell.api.unit.add_conversion_based_unit(file, name="foot")
ifcopenshell.api.unit.assign_unit(file, units=[length])
geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model")
axis_model_representation_subcontext = ifcopenshell.api.context.add_context(
file,
context_type="Model",
context_identifier="Axis",
target_view="MODEL_VIEW",
parent=geometric_representation_context,
)
coordinates = [(500.0, 2500.0), (3340.0, 660.0), (4340.0, 5000.0), (7600.0, 4560.0), (8480.0, 2010.0)]
radii = [(1000.0), (1250.0), (950.0)]
vpoints = [(0.0, 100.0), (2000.0, 135.0), (5000.0, 105.0), (7400.0, 153.0), (9800.0, 105.0), (12800.0, 90.0)]
lengths = [(1600.0), (1200.0), (2000.0), (800.0)]
alignment = ifcopenshell.api.alignment.create_by_pi_method(
file, "TestAlignment", coordinates, radii, vpoints, lengths, start_station=1000.0
)
ifcopenshell.api.alignment.add_stationing_referent(
file, "P3", alignment, distance_along=400.0, station=1700.0, incoming_station=1400.0
)
ifcopenshell.api.alignment.add_stationing_referent(
file, "P4", alignment, distance_along=600.0, station=1850.0, incoming_station=1900.0
)
distance_along_from_station = ifcopenshell.api.alignment.distance_along_from_station
# between P2 and P3: Sta. 13+00.00
assert distance_along_from_station(file, alignment, 1300.0) == pytest.approx(300.0)
# between P3 and P4: Sta. 18+00.00
assert distance_along_from_station(file, alignment, 1800.0) == pytest.approx(500.0)
# between P4 and P5: Sta. 19+25.00
assert distance_along_from_station(file, alignment, 1925.0) == pytest.approx(675.0)
# Sta. 15+00.00 falls inside the gap opened by the equation at P3 and has no corresponding distance along
assert distance_along_from_station(file, alignment, 1500.0) is None
# Sta. 18+75.00 falls inside the overlap zone at P4; the post-equation (outgoing) match is returned
assert distance_along_from_station(file, alignment, 1875.0) == pytest.approx(625.0)
test_distance_along_from_station()
test_distance_along_from_station_with_station_equations()
@@ -64,14 +64,3 @@ class TestGetInfo2(test.bootstrap.IFC4):
"Outer": {"CfsFaces": None, "type": "IfcClosedShell"},
"type": "IfcFacetedBrep",
}
def test_unsupported_arguments_fall_back_to_get_info(self):
# Regression test for #4270: get_info_2 raised a bare AssertionError
# when called with its own default arguments (recursive=False) or any
# other combination the C++ fast path does not implement. It must
# delegate to get_info instead of crashing.
brep = self.file.create_entity("IfcFacetedBrep")
shell = self.file.create_entity("IfcClosedShell")
brep.Outer = shell
assert brep.get_info_2() == brep.get_info()
assert brep.get_info_2(recursive=True, ignore=("Outer",)) == brep.get_info(recursive=True, ignore=("Outer",))
@@ -1,6 +1,5 @@
import ifcopenshell
def test_skip_over_non_entity_instance():
data = """
ISO-10303-21;
+1 -1
View File
@@ -46,4 +46,4 @@ def test_file(filename):
if __name__ == "__main__":
pytest.main(["-sx", __file__, "--import-mode=importlib"])
pytest.main(["-sx", __file__, '--import-mode=importlib'])
@@ -19,7 +19,6 @@
from math import pi
import numpy as np
import pytest
import ifcopenshell.api.context
import ifcopenshell.api.georeference
@@ -259,23 +258,6 @@ class TestConvertFileLengthUnits(test.bootstrap.IFC2X3):
assert max(i.id() for i in output) == len(output.wrapped_data.entity_names()) + 1
assert subject.get_full_unit_name(subject.get_project_unit(output, "LENGTHUNIT")) == "METRE"
def test_precision_conversion(self):
# Regression test for #6127: IfcGeometricRepresentationContext.Precision
# is typed IfcReal but interpreted in the project length unit, so it must
# be scaled along with the length measures.
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
unit = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT", prefix="MILLI")
ifcopenshell.api.unit.assign_unit(self.file, units=[unit])
context = ifcopenshell.api.context.add_context(self.file, context_type="Model")
context.Precision = 0.01
# Subcontexts derive Precision from the parent and must be left alone.
ifcopenshell.api.context.add_context(
self.file, context_type="Model", context_identifier="Body", target_view="MODEL_VIEW", parent=context
)
output = subject.convert_file_length_units(self.file, target_units="METER")
new_context = output.by_type("IfcGeometricRepresentationContext", include_subtypes=False)[0]
assert new_context.Precision == pytest.approx(0.00001)
def test_attribute_conversion(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
unit = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT", prefix="MILLI")
+13 -25
View File
@@ -39,7 +39,6 @@
#include <stdlib.h>
#include <string>
#include <iomanip>
#include <charconv>
#ifdef USE_MMAP
#include <boost/filesystem/path.hpp>
@@ -450,14 +449,7 @@ const std::string& TokenFunc::asStringRef(const Token& token) {
}
std::string& str = token.lexer->GetTempString();
token.lexer->TokenString(token.startPos, str);
// A well-formed string/enumeration/binary token has both delimiters (e.g.
// '...', .XXX., "...."), so at least two characters. Malformed input from a
// fuzzer can produce a single-character token (e.g. a bare '.' left by
// ".)" instead of ".PHYSICAL."); stripping both ends would then erase past
// the end of an already-empty string, which is undefined behaviour and
// aborts under hardened standard libraries (_GLIBCXX_ASSERTIONS). Require
// two characters before stripping. See #5683.
if ((isString(token) || isEnumeration(token) || isBinary(token)) && str.size() >= 2) {
if ((isString(token) || isEnumeration(token) || isBinary(token)) && !str.empty()) {
//remove start+end characters in-place
str.erase(str.end() - 1);
str.erase(str.begin());
@@ -754,29 +746,25 @@ namespace {
// the output of the C++ ostream formatting operation.
// REAL = [ SIGN ] DIGIT { DIGIT } "." { DIGIT } [ "E" [ SIGN ] DIGIT { DIGIT } ] .
static std::string format_double(const double& d) {
// Use the shortest representation that round-trips exactly (like
// Python's repr) instead of max_digits10. max_digits10 padded clean
// values with noise digits (0.0174532925199433 -> 0.017453292519943299),
// which rewrote every REAL and produced huge diffs when a file was
// re-saved. See #7696.
// std::to_chars is locale-independent, so no ostringstream/imbue is
// needed here.
char buf[64];
const auto res = std::to_chars(buf, buf + sizeof(buf), d);
const std::string str(buf, res.ptr);
std::ostringstream oss;
oss.imbue(std::locale::classic());
oss << std::setprecision(std::numeric_limits<double>::max_digits10) << d;
const std::string str = oss.str();
oss.str("");
std::string::size_type e = str.find('e');
if (e == std::string::npos) {
e = str.find('E');
}
std::string result = str.substr(0, e);
if (result.find('.') == std::string::npos) {
result += '.';
const std::string mantissa = str.substr(0, e);
oss << mantissa;
if (mantissa.find('.') == std::string::npos) {
oss << ".";
}
if (e != std::string::npos) {
result += 'E';
result += str.substr(e + 1);
oss << "E";
oss << str.substr(e + 1);
}
return result;
return oss.str();
}
static std::string format_binary(const boost::dynamic_bitset<>& b) {
-17
View File
@@ -353,17 +353,6 @@ IFC_PARSE_API bool IfcUtil::path::rename_file(const std::string& old_filename, c
return success;
}
IFC_PARSE_API bool IfcUtil::path::atomic_rename_file(const std::string& old_filename, const std::string& new_filename) {
std::wstring old_filename_w = from_utf8(old_filename);
std::wstring new_filename_w = from_utf8(new_filename);
// MOVEFILE_REPLACE_EXISTING makes the replace atomic on NTFS (no unlink
// of the destination first). MOVEFILE_WRITE_THROUGH waits until the move
// is flushed to disk before returning.
const bool success = !!MoveFileExW(old_filename_w.c_str(), new_filename_w.c_str(),
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH);
return success;
}
IFC_PARSE_API bool IfcUtil::path::delete_file(const std::string& filename) {
std::wstring filename_w = from_utf8(filename);
const bool success = !!DeleteFileW(filename_w.c_str());
@@ -379,12 +368,6 @@ IFC_PARSE_API bool IfcUtil::path::rename_file(const std::string& old_filename, c
return std::rename(old_filename.c_str(), new_filename.c_str()) == 0;
}
IFC_PARSE_API bool IfcUtil::path::atomic_rename_file(const std::string& old_filename, const std::string& new_filename) {
// POSIX rename() atomically replaces an existing destination on the same
// filesystem, so there is no window in which new_filename is missing.
return std::rename(old_filename.c_str(), new_filename.c_str()) == 0;
}
IFC_PARSE_API bool IfcUtil::path::delete_file(const std::string& filename) {
return std::remove(filename.c_str()) != 0;
}
-4
View File
@@ -30,10 +30,6 @@
#if defined(IFCOPENSHELL_BRANCH) && defined(IFCOPENSHELL_COMMIT)
const char *IFCOPENSHELL_VERSION = STRINGIFY(IFCOPENSHELL_BRANCH) "-" STRINGIFY(IFCOPENSHELL_COMMIT);
#elif defined(IFCOPENSHELL_VERSION_STRING)
// Set from CMake's RELEASE_VERSION (the repository VERSION file) so a release
// build without commit-sha info still reports the correct version. See #8164.
const char *IFCOPENSHELL_VERSION = STRINGIFY(IFCOPENSHELL_VERSION_STRING);
#else
const char *IFCOPENSHELL_VERSION = "0.8.0";
#endif
-7
View File
@@ -37,13 +37,6 @@ namespace path {
IFC_PARSE_API bool delete_file(const std::string& filename);
IFC_PARSE_API bool rename_file(const std::string& old_filename, const std::string& new_filename);
/// Atomically renames old_filename onto new_filename, replacing an existing
/// destination in a single filesystem operation. Unlike rename_file(), the
/// destination is never unlinked before the rename, so an interruption can
/// never leave the destination missing. This requires both paths to live on
/// the same filesystem. Returns true on success.
IFC_PARSE_API bool atomic_rename_file(const std::string& old_filename, const std::string& new_filename);
#if defined(_MSC_VER) && defined(_UNICODE)
/// Uses windows.h string conversion functions
@@ -110,8 +110,8 @@ class Patcher(ifcpatch.BasePatcher):
pass
if element.is_a("IfcProject"):
proj = self.new.add(element)
for ctx in element.RepresentationContexts or ():
for coop in getattr(ctx, "HasCoordinateOperation", ()):
for ctx in element.RepresentationContexts:
for coop in getattr(ctx, 'HasCoordinateOperation', ()):
self.new.add(coop)
return proj
return ifcopenshell.api.project.append_asset(
@@ -33,7 +33,9 @@ class TestDowngradeIndexedPolyCurve(test.bootstrap.IFC4):
Points=point_list,
Segments=segments,
)
self.file.create_entity("IfcArbitraryClosedProfileDef", ProfileType="AREA", OuterCurve=curve)
self.file.create_entity(
"IfcArbitraryClosedProfileDef", ProfileType="AREA", OuterCurve=curve
)
return curve
def test_run_without_segments(self):
@@ -78,7 +80,9 @@ class TestDowngradeIndexedPolyCurve(test.bootstrap.IFC4):
Points=point_list,
Segments=[self.file.createIfcLineIndex((1, 2, 3, 4, 1))],
)
self.file.create_entity("IfcArbitraryClosedProfileDef", ProfileType="AREA", OuterCurve=curve)
self.file.create_entity(
"IfcArbitraryClosedProfileDef", ProfileType="AREA", OuterCurve=curve
)
ifcpatch.execute(
{"input": "input.ifc", "file": self.file, "recipe": "DowngradeIndexedPolyCurve", "arguments": []}
)
@@ -106,7 +110,9 @@ class TestDowngradeIndexedPolyCurve(test.bootstrap.IFC4):
self.file.createIfcLineIndex((3, 4)),
],
)
self.file.create_entity("IfcArbitraryClosedProfileDef", ProfileType="AREA", OuterCurve=curve)
self.file.create_entity(
"IfcArbitraryClosedProfileDef", ProfileType="AREA", OuterCurve=curve
)
ifcpatch.execute(
{"input": "input.ifc", "file": self.file, "recipe": "DowngradeIndexedPolyCurve", "arguments": []}
)
+1 -11
View File
@@ -21,12 +21,10 @@ import os
import ifcopenshell
import ifcopenshell.api.aggregate
import ifcopenshell.api.context
import ifcopenshell.api.geometry
import ifcopenshell.api.georeference
import ifcopenshell.api.root
import ifcopenshell.api.spatial
import ifcopenshell.util.element
import numpy
import pytest
import ifcpatch
@@ -98,10 +96,7 @@ class TestExtractElements(test.bootstrap.IFC4):
self.file,
coordinate_operation={"Eastings": 100000.0, "Northings": 200000.0},
)
wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
matrix = numpy.eye(4)
matrix[:3, 3] = [5.0, 10.0, 2.0]
ifcopenshell.api.geometry.edit_object_placement(self.file, product=wall, matrix=matrix)
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
output = ifcpatch.execute({"file": self.file, "recipe": "ExtractElements", "arguments": ["IfcWall"]})
@@ -110,11 +105,6 @@ class TestExtractElements(test.bootstrap.IFC4):
conversion = output.by_type("IfcMapConversion")[0]
assert conversion.Eastings == 100000.0
assert conversion.Northings == 200000.0
# Placements must be copied verbatim: extraction must not bake map
# coordinates (or any other georeferencing transform) into the local
# placements of the extracted elements.
wall_new = output.by_type("IfcWall")[0]
assert wall_new.ObjectPlacement.RelativePlacement.Location.Coordinates == (5.0, 10.0, 2.0)
@pytest.mark.skipif(
"IFC4X3" not in ifcopenshell.ifcopenshell_wrapper.schema_names(),
-2
View File
@@ -977,14 +977,12 @@ struct ShapeRTTI : public boost::static_visitor<PyObject*>
if (item == nullptr) {
throw IfcParse::IfcException("Failed to convert placement");
}
/*
if (st.get<ifcopenshell::geometry::settings::ConvertBackUnits>().get()) {
// we pass the settings to the Transformation object, but access the data just offloads to the
// generic cartesian_base<Matrix4> so there's no time to apply the settings to the translation part.
item = ifcopenshell::geometry::taxonomy::matrix4::ptr(item->clone_());
item->components().col(3).head<3>() /= kernel.settings().get<ifcopenshell::geometry::settings::LengthUnit>().get();
}
*/
return new IfcGeom::Transformation(kernel.settings(), item);
} else {
if (!representation) {
+5 -45
View File
@@ -117,51 +117,10 @@ PyObject* get_feature(const std::string& x) {
%{
#include <fstream>
#include <random>
static const std::string& helper_fn_declaration_get_name(const IfcParse::declaration* decl) {
return decl->name();
}
// Atomic IFC/STEP write (issue #4797): serialize to a temporary file next to
// the destination, then atomically rename it onto the destination. If the
// process is interrupted mid-write, the destination is never truncated or
// left with dangling STEP references; at most a stray temp file remains, which
// the caller can safely ignore. Keeping the temp in the same directory means
// the rename stays on a single filesystem and is therefore atomic. The temp
// path never leaks into the FILE_NAME header, which is derived from the model
// header, not the output path.
template <typename T>
static void helper_fn_atomic_write(T& file_obj, const std::string& fn) {
std::random_device rd;
const std::string temp_fn = fn + "." + std::to_string(rd()) + ".tmp";
{
// Same open mode as a plain write so the bytes are identical.
std::ofstream f(IfcUtil::path::from_utf8(temp_fn).c_str());
if (!f.good()) {
// The temp file could not be created (e.g. directory not
// writable). Nothing was touched; report as a normal write error.
throw std::runtime_error("Failed to write to path: '" + fn + "', check folder and file permissions.");
}
f << file_obj;
f.flush();
if (!f.good()) {
// Serialization failed (e.g. disk full). Clean up the partial temp
// and abort. The existing destination is left intact.
f.close();
IfcUtil::path::delete_file(temp_fn);
throw std::runtime_error("Failed to write to path: '" + fn + "', the file may be incomplete.");
}
// The ofstream destructor at the end of this scope closes the stream.
// On Windows the file must be closed before it can be renamed.
}
if (!IfcUtil::path::atomic_rename_file(temp_fn, fn)) {
IfcUtil::path::delete_file(temp_fn);
throw std::runtime_error("Failed to write to path: '" + fn + "', could not replace the existing file.");
}
}
static IfcUtil::ArgumentType helper_fn_attribute_type(const IfcUtil::IfcBaseClass* inst, unsigned i) {
const IfcParse::parameter_type* pt = 0;
if (inst->declaration().as_entity()) {
@@ -260,10 +219,11 @@ private:
}
void write(const std::string& fn) {
// Atomic write: serialize to a temp file next to the target, then
// atomically rename it into place, so an interrupted write can never
// corrupt the destination (issue #4797).
helper_fn_atomic_write(*$self, fn);
std::ofstream f(IfcUtil::path::from_utf8(fn).c_str());
if (!f.good()) {
throw std::runtime_error("Failed to write to path: '" + fn + "', check folder and file permissions.");
}
f << (*$self);
}
std::string to_string() {
+3 -13
View File
@@ -34,19 +34,9 @@
if (PySequence_Size(aggregate) == -1) return false;
for(Py_ssize_t i = 0; i < PySequence_Size(aggregate); ++i) {
PyObject* element = PySequence_GetItem(aggregate, i);
// Accept the exact type or, for the numeric types, a subclass such
// as a numpy scalar (numpy.float64 subclasses float), so that numpy
// arrays can be assigned. The REAL vs INTEGER distinction is kept: a
// float is not accepted where an int is expected and vice versa, and
// bool (a subclass of int) is still rejected for INTEGER. See #5873.
bool b;
if (type_obj == static_cast<void*>(&PyFloat_Type)) {
b = PyFloat_Check(element);
} else if (type_obj == static_cast<void*>(&PyLong_Type)) {
b = PyLong_Check(element) && !PyBool_Check(element);
} else {
b = element->ob_type == type_obj;
}
// This is equivalent to the PyFloat_CheckExact macro. This means
// that direct instances of int, float, str, etc. need to be used.
bool b = element->ob_type == type_obj;
Py_DECREF(element);
if (!b) {
return false;
+3 -7
View File
@@ -108,13 +108,9 @@ int GltfSerializer::writeMaterial(const ifcopenshell::geometry::taxonomy::style:
base[3] = 1. - style->transparency;
}
if (style->has_specularity()) {
// glTF requires roughnessFactor in [0, 1]. A specular exponent of 0
// previously produced 1/0 = inf, which nlohmann::json serialises as
// null and makes the file invalid; exponents below 1 exceeded 1. #8073
const double roughness = style->specularity > 1.0 ? 1.0 / style->specularity : 1.0;
json_["materials"].push_back({ {"name", style->name}, {"doubleSided", true}, {"pbrMetallicRoughness", {{"baseColorFactor", base}, {"metallicFactor", 0}, {"roughnessFactor", roughness}}}});
} else
if (style->has_specularity())
json_["materials"].push_back({ {"name", style->name}, {"doubleSided", true}, {"pbrMetallicRoughness", {{"baseColorFactor", base}, {"metallicFactor", 0}, {"roughnessFactor", 1.0 / style->specularity}}}});
else
json_["materials"].push_back({ {"name", style->name}, {"doubleSided", true}, {"pbrMetallicRoughness", {{"baseColorFactor", base}, {"metallicFactor", 0}}}});
if (style->transparency == style->transparency && style->transparency > 1.e-9) {