Compare commits

...

157 Commits

Author SHA1 Message Date
Ryan Schultz 6b9dbebccc Decouple should_start_fresh_session from use_relative_path
so IFC files always open in a clean session
2026-06-08 20:16:45 -05:00
Ryan Schultz 7bcbe1b908 Fix relative path error when IFC not under blend dir
When use_relative_path is enabled, Path.relative_to() raises
ValueError if the IFC file is on a different path than the
.blend file. Fall back to the absolute path in that case.

Generated with the assistance of an AI coding tool.
2026-06-08 20:16:45 -05:00
Ryan Schultz 1ccd74e78a Closes #7765: Default IFC file path to relative and auto-convert on blend save
Previously, saving an IFC file stored an absolute path in `bim_props.ifc_file` by default. Users had to manually check "Use Relative Path" each time. This made projects less portable — moving or sharing a project folder broke the stored path, requiring manual relinking.

What changed:

-   `use_relative_project_path` now defaults to `True` in `BIMProjectProperties`, `LoadProject`, `ExportIFC`, and `SelectIfcFile`. Relative paths are opt-out rather than opt-in.

-   Added a `save_post` handler in `handler.py` (registered in `__init__.py`) that fires whenever the user saves the `.blend` file. If `use_relative_project_path` is enabled and the stored `ifc_file` path is still absolute, it converts it to a path relative to the blend file's directory. `IfcStore.path` is kept absolute internally so file loading continues to work correctly.

Generated with the assistance of an AI coding tool.
2026-06-08 20:16:44 -05:00
Gorgious56 93c6350e0f Merge pull request #8148 from Gorgious56/bonsai/parametric-framework-features-pt2
Add parametric edit framework features (pt2): gizmo + UX polish
2026-06-07 02:09:00 +02:00
Gorgious56 a139adaa2c Apply black formatting to satisfy lint-formatting CI
Three files flagged by black --check on the lint-formatting job:

* bim/module/geometry/operator.py — single-arg `.update(...)` rejoined
  onto one line under the 120-char budget.
* test/bim/module/model/test_wall_gizmos.py — same join on a
  _make_path_rel call.
* test/modal/test_modal.py — pre-existing baseline noise picked up
  via the upstream merge: PEP-8 blank-line separators between top-
  level functions, `0.68+` → `0.68 +`, double quotes, trailing
  whitespace stripped.

No behavioural change; pure whitespace.

Generated with the assistance of an AI coding tool.
2026-06-07 02:05:53 +02:00
Gorgious56 a4a806147d Merge remote-tracking branch 'ifcopenshell/v0.8.0' into bonsai/parametric-framework-features-pt2 2026-06-06 21:47:24 +02:00
Gorgious56 4962e3256d Promote idle-row icons into the slot system
The toggle_openings icon lived outside the IconSlot layout — each
host (wall, roof) declared an ad-hoc setup_pen_row_toggle_openings_icon
+ update_pen_row_toggle_openings_icon pair, and GizmoArrayEdition
queried a hardcoded _FEATURE_IDLE_MAX_X dict to position past it.
On an arrayed wall the dict was shadowed: find_for_element returns
"array" before "wall" in EDIT_TYPES order, the wall reservation was
never consulted, and the first per-layer ARRAY icon (local X=0.37)
landed 13cm from the wall's toggle_openings (X=0.50) — visually on
top of each other.

Promote idle-row icons into the slot system instead of patching the
dict:

* IconSlot gains an Optional visible_when predicate for state-driven
  visibility (toggle_openings only when the host carries openings).
* BaseParametricGizmoGroup gains idle_slots: ClassVar[tuple[IconSlot]]
  + _idle_slot_x_positions() + _idle_row_right_edge() helpers; the
  setup + idle-branch positioning loops mirror the existing
  feature_slots path.
* Wall and roof declare toggle_openings as an idle_slot and drop
  their ad-hoc setup/update calls.
* GizmoArrayEdition's _resolve_feature_idle_max_x walks
  BaseParametricGizmoGroup.REGISTRY and takes the max
  _idle_row_right_edge() across peers whose poll passes — no more
  hardcoded dict, no more find_for_element-order shadowing.
* setup_pen_row_toggle_openings_icon + update_pen_row_toggle_openings_icon
  helpers deleted from drawing/gizmos.py.
* 3 forward-compat AST guards pin the new contract.

Also bundles an unrelated array-test fix: TestUsingArrays in
test/tool/test_model.py was asserting against bpy.context.selected_objects
which is a fragile signal after remove_array / apply_array. A new
_array_objects() helper filters bpy.data.objects via the BIM_Array
pset's IfcActuator type instead.

Layout on an arrayed wall after the fix:
  pen        X = 0.00
  toggle     X = 0.50 (idle_slot 0)
  array[0]   X = 0.87 (one ICON_ARRAY_GAP past idle row)
  array[1]   X = 1.27
All separated by the standard inter-icon spacing.

Generated with the assistance of an AI coding tool.
2026-06-06 18:22:27 +02:00
Bruno Perdigão 06d99feeea Add no headless test for Bonsai Snap Target. 2026-06-05 18:29:19 -03:00
Gorgious56 f584a50fbb Clear wall-edit gizmos off click targets in plan view
In plan view world-Z collapses to zero on screen, so every wall-edit
icon anchored on the floor — the projected 3D cursor, wall endpoints,
wall-to-wall corners, IfcRelConnectsPathElements connection points —
projects onto the click target it represents. The result on a typical
extend / split / unjoin action: the icon sits on top of the cursor
crosshair (or the corner the user wants to click), defeating precise
positioning.

Add shared ``gizmo.top_down_clearance(context, billboard_rot)`` to
bim/module/drawing/gizmos.py: returns a screen-up Vector in top-down
view (cosine cone around world Z, matching ``is_view_top_down``) and a
zero Vector elsewhere, so call sites apply it unconditionally before
``billboarded_at``. Default distance 0.4 m aligns with the inter-icon
stack spacing already used by GizmoWallJoinIntersection so single
icons and stack bases land at consistent screen-up positions when
multiple groups render around the same wall endpoint.

Apply at the seven wall-edit anchor sites:

* GizmoWallEdition cursor stack (top-down branch only — non-top-down
  already stacks along world-Z at structural points clear of the
  cursor).
* GizmoWallExtendVertically (single icon at wall origin endpoint,
  active-object Z elevation).
* GizmoWallJoinIntersection corner stack base + merge midpoint.
* GizmoWallUnjoinSingle link-toggle pool (one icon per IFC path
  connection, previously sitting exactly on the connection point).
* GizmoWallFilletReedit pen icon at fillet corner.
* GizmoWallFilletToggleOpenings.

The clearance is a pure visual offset — bound operators still read
the world-space anchor (cursor / endpoint / connection point) at
execute time, so the action's target is unaffected.

Also tighten GizmoWallUnjoinSingle: gate poll on ``props.is_editing``
so the link-toggle icons only surface during the wall edit lifecycle
(matching every other edit-row icon), and downsize them via a new
``ICON_SCALE = 0.35`` constant since 16 of them at default scale
cluttered the viewport on path-heavy walls.

ruff + black clean. Wall gizmos test lane 14/14 pass.

Generated with the assistance of an AI coding tool.
2026-06-05 16:02:47 +02:00
Bruno Postle 24a241addc Use version preprocessor guards for RocksDB unique_ptr API, retain unique_ptr internally 2026-06-05 14:22:07 +02:00
Bruno Postle 365be8fb52 Support RocksDB shared library and new unique_ptr DB::Open API
Some distributions (e.g. Fedora) ship only a shared RocksDB that exports
RocksDB::rocksdb-shared rather than RocksDB::rocksdb. The CMake target
selection now falls back to the shared target when the static one is absent.

Newer RocksDB also changed DB::Open and DB::OpenForReadOnly to take
std::unique_ptr<DB>* instead of DB**. IfcFile.cpp uses SFINAE tag dispatch
to build against both old and new APIs without version detection.
2026-06-05 14:22:07 +02:00
Bruno Postle eacff93945 Use std::lexicographical_compare in Point_d_4d_Less 2026-06-05 13:56:04 +02:00
Bruno Postle 9d956f18b7 Fix CGAL 6.x build: add Point_d_4d_Less comparator for std::map
CGAL 6.x deleted operator< from Point_d, so std::map<Point_d, ...>
no longer compiles. Adds a custom lexicographic comparator and updates
the three affected maps in snap_halfspaces and snap_halfspaces_2.
2026-06-05 13:56:04 +02:00
Gorgious56 8faf9ff43d Consolidate load_post parametric drains
bim/handler.py was importing two feature-module internals
(wall_offset_gizmos.clear_caches, preview_base.discard_pending_previews)
to drain load-transient parametric state alongside the existing
tool.Parametric.heal_stale_edit_flags() call inside
_apply_save_file_invariants. Each new parametric drain added one
top-level import and one inline call — every load_post drain leaked
into handler.py's namespace.

Hide all three drains behind tool.Parametric.on_load_post(scene),
sited adjacent to heal_stale_edit_flags. The two feature-module
imports become late imports inside on_load_post — same pattern as
refresh_post_commit's existing `import bonsai.bim.handler` — which
sidesteps the tool.parametric -> bim.module.model.preview_base ->
bonsai.tool registration-time cycle.

The forward-compat AST contract that pinned "every module-scope
GenerationKeyedCache + clear_caches MUST be drained on load_post"
follows the call site to its new home — the test now walks
tool.Parametric.on_load_post instead of _apply_save_file_invariants.

No behaviour change. 45/45 affected bim tests pass
(test_handler_forward_compat, test_preview_base,
test_wall_offset_gizmos, test_parametric_registry).
ruff + black clean on all touched files.

Generated with the assistance of an AI coding tool.
2026-06-05 13:20:49 +02:00
Gorgious56 87bca20df7 Relocate feature decorators to their owning modules
Three feature-specific decorators previously lived in
bim/module/model/decorator.py despite owning state only their
home module reads:

* ArrayPreviewDecorator + ArraySelectionHighlightDecorator +
  draw_array_layer_children_bbox -> array.py (read array
  edit-state props and walk BBIM_Array psets)
* WallGizmoPreviewDecorator + draw_wall_partner_bbox -> wall.py
  (dereference wall.py-private classes and helpers via lazy
  imports)

decorator.py keeps cross-cutting infrastructure
(BoundingBoxDecorator, SlabDirectionDecorator, WallAxisDecorator,
WallFilletPreviewDecorator, PolylineDecorator, ProductDecorator)
and the shared bbox primitives (bbox_world_edges,
draw_polyline_segments, _BBOX_EDGES, _stroke_lines_alpha,
_fill_quads_alpha) that several feature files now import.

handler.py and gizmos.py update their import paths; the
wall-feature lazy imports inside WallGizmoPreviewDecorator
methods collapse to direct references now that the decorator
lives in wall.py.

No behaviour change. Wall lane 37/37, array lane 15/15, wall
forward-compat 6/6, parametric-registry 8/8 still pass.

Generated with the assistance of an AI coding tool.
2026-06-05 12:45:51 +02:00
Gorgious56 a30546f1f2 Bbox dimensions key, DRY array operators, drop dead code
Three concerns sharing the same architectural theme (collapse inline
bbox / edit-state lookups, drop overrides that re-do base-class work):

== Bbox helpers and array operator DRY ==

* tool/blender.py: add a "dimensions" tuple key to both
  get_object_bounding_box and get_object_world_bounding_box return
  dicts. The (max - min) per-axis extent — which callers previously
  computed via local helpers — is now a key alongside min_x / max_x
  / min_point / max_point / center. Distinct from Blender's built-in
  obj.dimensions (which folds object-level scale): the local variant
  is the intrinsic mesh bbox extent; the world variant is the
  matrix_world-applied AABB.

* bim/module/model/array.py: drop the local _bbox_dims helper; the
  two callers now read tool.Blender.get_object_bounding_box["dimensions"]
  directly.

* Rename _parent_geometry_changed -> _array_children_need_rebuild.
  The old name suggested "did the parent change just now", implying
  the function was a parent-edit-finish trigger. It actually runs
  only inside the array-edit-finish path as a drift safety net (the
  upstream-deliberate design — see commit 83d97d7e9 "Fix #7616. Make
  regenerate array an operator instead of an array preference" —
  means the array doesn't auto-regen when its parent geometry edits
  finish). New name matches the call-site phrasing
  ``if X: _wipe_array_children(layers)`` and clarifies that this is
  a children-state check, not a parent-edit trigger.

* Extract _resolve_array_edit_props(context) — returns the active
  object's array props during an active edit lifecycle, or None.
  Collapses the obj-active-then-is-editing prologue (3 lines + return)
  to one resolver call across 4 sites: ToggleArrayMethod.execute,
  AdjustArrayCount.execute, RemoveArrayLayerFromEdit._execute and
  .poll. Each call site shrinks from 7 lines to 3.

* Migrate two inline bbox reads inside GizmoArrayEdition to the new
  dict keys: get_axis_world_face_center collapses the manual
  xs/ys/zs min/max + center math to bbox["center"] + bbox["max_x"] /
  ["max_y"] / ["max_z"]; get_element_height collapses
  ``max(corner[2] for corner in obj.bound_box)`` to
  tool.Blender.get_object_bounding_box(obj)["max_z"].

The _BBOX_EQUALITY_EPS = 1e-5 tolerance stays inline as a single-
consumer constant — no other call site needs tolerance-equality on
dimension tuples, so extracting it to a shared util would be
speculative abstraction.

== Drop dead code ==

* GizmoArrayEdition.update_editing_gizmos override + its
  _has_other_parametric_type helper: redundant with
  hide_pen_button = True at line 1024. The base class already hides
  the pen in every idle case (when hide_pen_button is truthy) AND in
  every editing case (unconditionally). The override's conditional
  hide-when-parametric only re-hid a pen that was already hidden in
  both branches. Removes the only remaining path that could re-show
  the array's pen icon; array-edit entry is now uniformly via the
  per-layer ARRAY icons (which is the documented preferred
  affordance, see the hide_pen_button comment).

* _wall_fillet_preview_active in wall.py: defined but never called.
  _wall_fillet_props (the sibling thin-wrapper around
  preview_base.get_preview_props) is heavily used; the
  is_preview_active wrapper was added speculatively and never picked
  up a consumer.

Generated with the assistance of an AI coding tool.
2026-06-05 12:03:26 +02:00
Gorgious56 fbe6fe5384 Fix wall edit lifecycle + drain wall_offset_gizmos cache on load
Bundled bug fixes + the forward-compat AST guard that prevents the
underlying class of bug from coming back.

* bim/module/model/wall.py: FinishEditingWall._execute early-returns
  CANCELLED when props.is_editing is False. Without this guard, a
  failed enable (e.g. on a wall without IfcMaterialLayerSetUsage)
  leaves is_editing False but a press on finish still walked the
  sub-ops below, which dereferenced layer-set-dependent state and
  crashed.

* tool/model.py: Model.offset_wall now guards against
  ifcopenshell.util.element.get_material returning None before
  calling .is_a("IfcMaterialLayerSetUsage"). Fixes the pre-existing
  test/bim/module/model/test_wall_header_refresh.py crash that has
  been the only failing test in the wall lane since this branch
  started.

* bim/handler.py: _apply_save_file_invariants drains
  wall_offset_gizmos.clear_caches() on load_post. The module-scope
  GenerationKeyedCache instance survives the .blend reload; without
  the drain the cache may serve entries whose bpy_struct references
  point into the freed bpy.data of the previous file.

* test/bim/test_handler_forward_compat.py: AST-walk test that
  enumerates every bim/module/model/*.py source declaring both a
  module-scope GenerationKeyedCache assignment AND a top-level
  clear_caches function, and asserts each module appears as a
  <module>.clear_caches() call in _apply_save_file_invariants. Pins
  the contract: any future module-scope geom cache that exposes
  clear_caches must wire into the load_post drain.

* test/bim/feature/model.feature + test/bim/test_feature.py: wall
  edit-lifecycle scenarios switch from "add cube + assign as
  IfcWallType" to "load the demo construction library + add an
  occurrence of the WAL100 wall type", so the parametric edit runs
  against a real LAYER2 wall with IfcMaterialLayerSetUsage rather
  than a vanilla-mesh promotion that lacks one. The demo-library
  step also picks the schema-matching library file (IFC2X3 /
  IFC4 / IFC4X3) so the appended types remain valid across schemas.
  Door saved-height assertion updates from 2.5 → 2500 to reflect
  that BBIM_Door pset stores project units (METRIC_MM in the
  empty-project fixture).

Generated with the assistance of an AI coding tool.
2026-06-05 10:27:01 +02:00
Bruno Postle bd264f1d85 Add missing standard library includes for self-sufficient headers
Fixes builds with newer GCC/libstdc++ that no longer provide <cstdint>,
<cstring>, <cfloat>, <memory>, <algorithm> etc. transitively. Also
disambiguates visit<> calls in taxonomy.h with the full namespace and
casts the character value in IfcCharacterDecoder to uint32_t to silence
ambiguous overload warnings.
2026-06-05 08:54:27 +02:00
Bruno Postle 674ed36e41 Fix HDF5 config-mode detection to use shared library when static is absent
When HDF5 is found via its CMake config file, the code previously hardcoded
the hdf5_cpp-static target. On distributions that ship only shared HDF5
(e.g. Fedora rawhide where the config file was added in a newer package),
this caused a link failure. Now checks for hdf5_cpp-static, hdf5_cpp-shared,
and hdf5::hdf5_cpp-shared in order, falling back to module-mode discovery.
2026-06-05 08:53:10 +02:00
Thomas Krijnen 1f2b20fd86 Fix --convert-back-units on transformation object #8137 2026-06-04 22:15:35 +02:00
Thomas Krijnen 94fab271cd Check for empty result after BOPAlgo_MakerVolume and reset manifoldness state #8140 2026-06-04 21:45:27 +02:00
Thomas Krijnen 8583d0963f Make faceset duplicate loop detection respect inner/outer #8140 2026-06-04 21:45:27 +02:00
Thomas Krijnen 77a2284f8a Re-sew non-manifold operands; interior loop re-orientations affect edge identity #8140 2026-06-04 21:45:26 +02:00
Thomas Krijnen 4520a72152 Sane error messages for unsupported items in geometry libs #8106 2026-06-04 21:45:26 +02:00
Gorgious56 25651a1507 Fix demo preset crash + scope header refresh
bpy.ops.bim.new_project(preset='demo') crashed in
refresh_bim_tool_headers: the post-commit hook fired for every
nested bpy.ops.bim.append_library_element during template
loading, and the operator context Blender hands to
programmatically-invoked nested operators is stripped of the
view-layer attributes the refresh reads.

Two changes resolve it.

Gate the header refresh in tool.Parametric.refresh_post_commit
on operator.bl_idname being one of the EDIT_TYPES finish_op
idnames. Only validate-gizmo commits (bim.finish_editing_<name>)
now trigger the refresh; demo-loader and other non-edit
operators skip it. Querying the registry directly is the
canonical signal — string-prefix matching would silently drift
if ParametricObject.finish_op changes derivation.

Harden tool.Blender.get_active_object so its view_layer fallback
also uses getattr; the 150+ callers routed through it now
tolerate stripped contexts. _resolve_bim_tool_context applies
the same defensive pattern to mode / workspace.

Tests:
- test_handler_restricted_context covers get_active_object's
  defensive path and the BimTool-family whitelist (excludes
  annotation, spatial, structural).
- test_handler_forward_compat AST-pins that the gate consults
  EDIT_TYPES (not a string prefix).
- test_wall_header_refresh rewritten — three tests cover the
  gated-by-registry contract: counter bumps for every commit,
  finish_op operators refresh headers, others don't.

Hotkey-driven in-place edits (S_E / C_E) no longer trigger the
refresh — they were caught by the pre-refactor "every commit"
design. Left out of scope; the new skip-non-finish test pins
this as intentional.

Generated with the assistance of an AI coding tool.
2026-06-04 10:42:58 +02:00
Gorgious56 94faaa3160 Drop dead Geometry.has_material_styles + sanitation sweep
Two related cleanups bundled because each was too small on its own.

== Drop dead Geometry.has_material_styles duplicate ==

Two parallel has_material_styles implementations existed on HEAD:

* Geometry.has_material_styles (tool/geometry.py:853, added by
  3483683cb "Add tool.Geometry helpers for body representation +
  placement"): checks each material via tool.Material.get_style
  for an IfcSurfaceStyle. This is the implementation gizmos-8088
  uses — its core/root.py:58 calls geometry.has_material_styles.

* Root.has_material_styles (tool/root.py:75, added by e76455913
  "Route _has_material_styles through tool.Root.has_material_styles"):
  checks each material for a HasRepresentation inverse. Added to
  fix the test/core/test_root.py::TestCopyClass::test_AAAAAAAAAAAA
  failure by routing the check through a Prophecy-mockable seam.

HEAD's core/root.py:59 calls root.has_material_styles. The Geometry
version became orphaned by that migration — zero callers historically
(git log -S "Geometry.has_material_styles" returns nothing). The
Root placement is the right architectural home: has_material_styles
pairs with assign_body_styles in the copy_class flow as "is there
material-defined styling? if not, apply body styling" — both
decisions live on the same interface, called in sequence from the
same caller.

The semantic delta (HasRepresentation vs IfcSurfaceStyle) is a close
approximation in real IFC files where HasRepresentation almost always
indicates a styled material; if precision becomes necessary, the
Root impl can be tightened independently of this cleanup.

Drop the Geometry method + its abstract declaration in core/tool.py.

== Sanitation sweep per CLAUDE.md §4a ==

Eight rot-prone references in code we authored on this branch get
their first-draft mistakes cleaned up. The §4a rule (no sibling
symbol names, no test paths, no motivation history in docstrings)
got added during this branch, so older commits sometimes named their
siblings in prose; this is a focused cleanup of the worst offenders.

* bim/module/model/wall.py:201 — _CommitWallDraftsFirstMixin
  docstring carried motivation history ("...that every multi-wall
  operator … used to repeat at the top of _execute"). Rewrite to
  describe only the current contract.

* bim/module/model/wall.py:1910 — cycle_type_operator comment named
  two sibling methods. Rephrase to describe what happens at the slot.

* bim/module/model/wall.py:2025 — _active_instances ClassVar comment
  named WallGizmoPreviewDecorator. Rephrase to "the wall-gizmo
  preview decorator" (role, not class).

* bim/module/drawing/gizmos.py:3402 — GizmoFillet hit_uses_bbox
  comment named GizmoWallJoinIntersection. Rephrase to "the wall-join
  gizmo group".

* bim/module/drawing/gizmos.py:3887 — GizmoCountLabel docstring had
  a :meth:`set_count` cross-reference. Drop — reader sees the method
  next to the class.

* bim/module/model/host_add_opening_gizmo.py:201 — poll-exclusion
  comment named GizmoWallEdition + GizmoRoofEdition. Rephrase to
  describe why we skip ("walls and parametric roofs both render
  their own toggle in the pen row").

* bim/module/void/operator.py:45 — preserve_placement comment named
  FilledOpeningGenerator.generate. Rephrase to "the filling-opening
  generator gates its snap-to-wall-axis block on this flag".

* bim/parametric_lifecycle.py:64 — module docstring named the test
  file path (test/bim/test_parametric_registry.py). Rewrite to
  "enforced by the registry contract tests".

Sweep otherwise clean: no third-party software names in this-branch-
authored comments (upstream Revit / Tekla / ArchiCAD references are
legitimate external-constraint workarounds, §4a-allowed). No
PR/issue numbers we authored except the FIXME(PR5) in
tool/parametric.py:150, deliberately preserved until PR6's MEP slice
resolves it.

Generated with the assistance of an AI coding tool.
2026-06-04 09:08:47 +02:00
Gorgious56 0e922074b9 Adopt _CommitWallDraftsFirstMixin on 7 wall operators
The 7 multi-wall operators (UnjoinWalls, UnjoinWallPathConnection,
ExtendWallsToUnderside, ExtendWallsToWall, SplitWall, MergeWall,
JoinWallsIntersection) each opened their _execute with an identical
prologue:

    _commit_pending_wall_edits_for_selection(context)
    # ... operator-specific logic

— flushing any in-progress wall parametric drafts so the operator
acts on committed IFC state rather than the draft preview box.

Extract that prologue into _CommitWallDraftsFirstMixin: its _execute
calls the commit helper, then delegates to a subclass-supplied
_perform. Subclasses inherit the mixin first in their bases tuple so
the mixin's _execute resolves first via the MRO. The IFC transaction
opened by tool.Ifc.Operator.execute still wraps both the commit and
the perform.

Behaviour-equivalent — same call, same order, same selection scope.
Architectural cleanup only: a future multi-wall operator can no
longer forget the commit step. The named helper
_commit_pending_wall_edits_for_selection stays as the single
encapsulation of the names=("wall",) filter; its docstring loses
the stale "every multi-wall operator calls it at the top of
_execute" sentence and now just describes the filter contract.

Matches gizmos-8088's _CommitWallDraftsFirstMixin pattern.

Generated with the assistance of an AI coding tool.
2026-06-03 17:15:45 +02:00
Gorgious56 90ea256cc3 Shift-click add-opening preserves filling placement
The regular bim.add_opening click on the host-add-opening gizmo
(wall + door/window co-selected) routes through
FilledOpeningGenerator.generate, which snaps the filling to the
wall's reference-line axis, optionally rotates 180° when the
filling sits on the opposite side, and re-applies an rl1 / rl2
Z-elevation default. That is the right default for "drag a fresh
door onto a wall and let the model place it for me", but defeats
the workflow where the user has already positioned the filling
precisely (e.g. snapped to a window in an adjacent wall, copy-
pasted at an exact Z, aligned to a reference object).

Holding SHIFT while clicking the gizmo now opts into a
"preserve placement" mode: the filling stays at its current
matrix_world and the opening is created at the filling's existing
position. The opening / filling rels and representation work are
unchanged — only the snap-to-axis branch is skipped, so the IFC
graph is identical to the regular click; only the spatial
position of the filling differs (user-chosen vs auto-snapped).

Implementation:

* bim/module/void/operator.py: AddOpening gains a hidden
  preserve_placement BoolProperty + an invoke() that sets it from
  event.shift. The call into FilledOpeningGenerator.generate
  forwards the flag. bl_description documents the SHIFT modifier
  so it surfaces in F3 search / hover tooltip.

* bim/module/model/opening.py: FilledOpeningGenerator.generate
  accepts preserve_placement (default False — backwards-compatible
  with the other caller, tool.Model.add_filled_opening). The
  voided_obj.data-gated snap block (raycast + axis projection +
  rl-Z default + filling_obj.matrix_world write) skips entirely
  when the flag is True. The opening's matrix_world reads from
  filling_obj.matrix_world below the gate, so the opening lands
  at the filling's preserved position automatically.

Generated with the assistance of an AI coding tool.
2026-06-03 16:44:38 +02:00
Gorgious56 387bd51b4a Use menu pick gizmo for door / window / stair type
The door / window / stair edit-row's type-cycle icon advanced one
type per click (CycleDoorType / CycleWindowType / CycleStairType
bound to cycle_type_operator). DoorType has 8 IFC variants,
WindowType 9, StairType 3 — so cycling past the target was the norm.

Threshold rule for cycle-vs-menu: cycle is appropriate for exactly 2
values (advance-one-per-click stays predictable). Three or more
values warrants a popup menu. Door / window / stair all qualify;
roof (RoofGenerationMethod has 2 values) keeps cycle. Wall has no
type cycle. Array is unaffected.

Swap to the popup-menu pattern (PickTypeMixin already on HEAD at
bim/parametric_lifecycle.py:442): clicking the icon opens a menu
listing all type_literal values; selecting one applies it in a
single undo step. The hamburger icon (VIEW3D_GT_menu) is wired into
BaseParametricGizmoGroup.setup_editing_gizmos whenever
pick_type_operator is set (mutually exclusive with
cycle_type_operator). Matches gizmos-8088's pattern exactly.

Per-feature shape:

* door.py: PickDoorType replaces CycleDoorType.
  GizmoDoorEdition.cycle_type_operator → pick_type_operator.
* window.py: PickWindowType replaces CycleWindowType. Same swap.
* stair.py: PickStairType replaces CycleStairType (no
  tool.Ifc.Operator inheritance — stair-type changes
  BIMStairProperties only, no IFC mutation). Same swap.
* bim/module/model/__init__.py: registration entries renamed
  Cycle* → Pick*.
* bim/module/drawing/gizmos.py: drop the
  CycleTypeMixin / PickTypeMixin / TypeAccessorBase shim re-export —
  its own docstring already noted "PR5 cleanup drops these" and the
  three callers (door / window / stair Cycle*Type) it served are
  gone. Roof's CycleTypeMixin import was already direct from
  bim.parametric_lifecycle. Also update GizmoMenu docstring to
  reflect the 2-vs-3+ threshold.

Generated with the assistance of an AI coding tool.
2026-06-03 16:07:13 +02:00
Gorgious56 de4c394b50 Add host-wall offset gizmos for door/window edit
When entering parametric edit on a door or window that fills a
wall opening, four dimension gizmos now measure the distances
from the wall edges to the filling's jambs and from the wall's
base/top to the sill/header. Dragging any gizmo translates the
filling along the wall's local axis; 180°-flipped fillings and
slanted LAYER2 walls round-trip correctly. The has_host_wall
predicate hides all four when the filling → opening → wall
chain cannot be resolved.

Generated with the assistance of an AI coding tool.
2026-06-03 15:34:48 +02:00
Gorgious56 ab64b652ff Show wall cursor gizmos outside edit mode + axis previews
Four concerns that together make the cursor-anchored gizmos
(extend_x_gizmo, extend_z_gizmo, split_gizmo on GizmoWallEdition)
fully functional and visually informative without entering parametric
edit mode first:

* Drop the props.is_editing gate in _update_cursor_gizmos. The three
  bound operators (bim.extend_wall_to_cursor,
  bim.extend_wall_height_to_cursor, bim.split_wall_at_cursor) already
  poll on wall-selected and commit any pending wall edit before
  acting, so single-click without entering edit mode is now the
  canonical flow. Matches gizmos-8088's always-on behaviour.

* Register GizmoWallEdition instances in a per-region weakref map
  (_active_instances) populated at setup_element_specific_gizmos
  time. The WallGizmoPreviewDecorator dereferences this map to read
  live is_highlight state off the cursor icons. Without the
  registration its _cursor_icon_hovered always returned False and
  the hover-gated GPU previews silently never drew. Mirrors the
  same pattern already in place on GizmoWallJoinIntersection.

* Add post-operator resync to all three cursor operators
  (_maybe_resync_wall_props_from_ifc for the single-wall split /
  extend-height paths, _resync_walls_after_mutation for the
  selection-wide extend-X path). Without this, props.length /
  props.height stayed stale after the operator ran, so the
  orientation flips _apply_wall_extend_flips computes from
  cursor_local vs wall dimensions kept using the pre-extend values
  until the next selection change. Matches gizmos-8088's pattern.

* Hover-gated GPU previews per icon:

  - extend-X: filled Z=0 floor quads spanning the wall's offset to
    offset+thickness Y band, visible from plan view without side-
    view clutter. Grow case (cursor beyond either endpoint): one
    green decorator_color_selected quad over the extension. Shrink
    case (cursor inside extent): green quad for the portion that
    REMAINS + red decorator_color_error quad for the portion the
    operator REMOVES.

  - extend-Z: vertical lines at the cursor's projected X in the
    wall's y=0 reference-line plane. Grow case (cursor above wall
    top): one green segment from z=height to z=cursor.z. Shrink
    case: green from z=0 to z=cursor.z (REMAINS) + red from
    z=cursor.z to z=height (REMOVES).

  - split: one red vertical line at the cursor's projected X from
    base to wall top — the cut plane.

  Quads use QUAD_ALPHA=0.25 so the underlying wall body stays
  visible.

* New module-level _fill_quads_alpha helper next to
  _stroke_lines_alpha, plus a per-decorator _fill convenience method
  and a _wall_floor_quad corner builder.

Modal-active gizmo hiding (is_gizmo_hidden_by_modal) is preserved.

Generated with the assistance of an AI coding tool.
2026-06-03 13:57:39 +02:00
Gorgious56 99bb1e30ad Generalise opening gizmos + DRY toolbar plumbing
Add openings — GizmoWallAddOpening only fired when a wall was active +
co-selected with a non-host; slabs and roofs got no in-viewport handle.
GizmoHostAddOpening covers all three host types via is_supported_host,
dispatching walls to the axis-projection anchor and slabs/roofs to a
world-Z anchor lifted just above the host's top face (predictable
height regardless of the void's vertical position).

Show openings on hosts with their own parametric-edit toolbar —
GizmoRoofEdition gains an idle-row toggle_openings_gizmo parallel to
the wall's, parked at the cancel-slot X next to the pen. Visible only
when the host carries HasOpenings and the edit triad is idle. Roof
overrides get_element_height to return the mesh's world-AABB top in
object-local Z, so the WHOLE pen-row anchors visibly above sloped or
stepped roof bodies. The wall's idle-row toggle now also hides when
HasOpenings is empty.

Show openings on hosts WITHOUT a parametric-edit toolbar —
GizmoHostToggleOpenings scoped strictly to the fallback case: a single
host selected, HasOpenings non-empty, NOT a path-connectable wall, NOT
a parametric roof. Covers slabs today plus any foreign-authored IfcRoof
without BBIM_Roof. Anchored at object origin XY + world-AABB top Z.
When slab parametric-edit eventually lands, the slab predicate joins
the exclusion list and this gizmo's poll narrows automatically.

Operator move — ToggleWallOpenings was already host-agnostic; renamed
to ToggleHostOpenings in opening.py (bl_idname bim.toggle_host_openings).
Three callers (the wall idle-row binding, GizmoWallFilletToggleOpenings,
and workspace.py's hotkey_A_O for Alt+O) now route through the renamed
operator. The Alt+O binding is surfaced in the operator's
bl_description so it appears in F3 search and hover tooltips.

DRY refactors —
* GizmoWallAddOpening deleted (subsumed by GizmoHostAddOpening)
* tool.Blender.get_object_world_bounding_box added as the world-AABB
  sibling of the existing local helper; 3 inline call sites in
  tool/misc.py (set_object_origin_to_bottom, scale_object_to_height)
  and gizmos.py adopt it (2 other sites in drawing/operator.py and
  project/operator.py inherently need raw transformed corners for
  per-corner plane / NDC tests — not AABB candidates)
* BaseParametricGizmoGroup gains setup_pen_row_toggle_openings_icon +
  update_pen_row_toggle_openings_icon; wall + roof + any future host
  gizmo wire up the idle-row toggle with two one-line calls
* _resolve_active_host shared poll prologue between the two host
  gizmos (gate + selection count + active-in-selected + entity lookup
  + supported-host check)
* HasOpenings non-empty checks at 3 sites route through
  tool.Geometry.has_openings
* hotkey_A_O body collapsed to bpy.ops.bim.toggle_host_openings()

The forward-compat AST guard pinning "must accept fillet-corner walls"
retargets from GizmoWallAddOpening.poll to is_supported_host.

Generated with the assistance of an AI coding tool.
2026-06-03 12:39:17 +02:00
Gorgious56 1707c36bd8 Stack cursor-anchored wall gizmos along screen-up in top view
The extend-X / extend-Z / split icons share the cursor's projected X
on the wall axis, separated only by world Z (floor / cursor / wall
top). World Z collapses to a single screen point in plan view, so
every icon piled onto extend-X's hit target and only the topmost was
clickable.

Two refinements ported from gizmos-8088:

* When ``tool.Blender.is_view_top_down(context)`` reports the camera
  is near plan-view, swap world-Z stacking for screen-up stacking:
  anchor all icons at the floor world position and offset each by
  ``index * CURSOR_STACK_OFFSET`` along ``tool.Blender.get_screen_up_world(context)``.
  Each icon lands in its own screen-space slot regardless of view
  rotation.
* In the same top-down branch, drop ``extend_z_gizmo`` entirely. A
  vertical-intent gizmo has no readable cue when looking down +Z —
  clicking it would mutate the wall in a direction the user can't
  see change.
* Bonus: split's local Z now goes through
  ``core.extrusion_depth_from_vertical_height(props.height, props.x_angle)``
  so the icon lands on the slanted top edge of sloped walls (x_angle
  != 0) instead of the vertical-height target the wall isn't at.

All three helpers (``is_view_top_down``, ``get_screen_up_world``,
``extrusion_depth_from_vertical_height``) already on HEAD from PR2/PR3.
Non-top views unchanged — same world-Z stacking + cascading bumps as
before.

Generated with the assistance of an AI coding tool.
2026-06-02 19:22:38 +02:00
Gorgious56 44f5ee028f Port WallGizmoPreviewDecorator from gizmos-8088
Hover-gated viewport preview lines that show where a wall-join /
extend / split operator would land before the user clicks. Four
preview paths, each gated on a specific icon's ``is_highlight`` state:

* **Join intersection** — two LAYER2 walls selected in the ``intersect``
  state (non-joined, non-collinear, non-parallel). Draws four lines:
  each wall's axis at both base and top Z, extending from the wall's
  nearer endpoint to the projected XY intersection. The pair of lines
  per wall communicates the full plane the join welds at, not just
  the floor edge.
* **Cursor extend** — single LAYER2 wall, hover on ``extend_x_gizmo``.
  One line from the wall's nearer X endpoint to the cursor's projected
  X on the wall axis.
* **Cursor extend-Z** — hover on ``extend_z_gizmo``. Vertical line at
  the cursor's projected X from wall base to cursor Z (the new total
  height).
* **Cursor split** — hover on ``split_gizmo``. Vertical line at the
  cursor's projected X from wall base to wall top — the cut plane.
  Warning-red colour matches the icon's destructive-action signal.

Hover colour rules for the join preview:

* **Join or Fillet hover** → all four lines highlight in
  ``decorator_color_selected``. Both icons commit a symmetric corner
  meet, so every line is part of the operation.
* **Extend-to-Wall hover** → only the non-active wall's two lines
  (base + top) highlight. The default-direction extend operator
  moves the non-active wall into the active one's axis; only that
  wall's preview should signal motion.
* No hover → all four lines in ``decorations_colour``.

Three coordinated changes:

* ``bim/module/model/wall.py`` gains the ``_classify_wall_join_state``
  wrapper over ``core.classify_wall_join_state`` (feeds the
  ``_are_walls_joined`` flag the core helper expects) AND a
  ``_active_instances`` per-region weakref ClassVar on
  ``GizmoWallJoinIntersection`` populated in ``setup()``. Without the
  weakref registration, the decorator's ``_lookup_active_instance``
  call returns None every frame and the hover gates silently
  evaluate False — the symptom would be preview lines that never
  switch colour. Both pieces ported from gizmos-8088.
* ``bim/module/model/decorator.py`` gains
  ``WallGizmoPreviewDecorator`` (~280 LOC across the four preview
  paths + shared helpers ``_stroke`` /
  ``_active_layer2_wall_for_gizmo_preview`` /
  ``_join_group_hover_state`` / ``_extended_wall_index``). All
  cross-file dependencies (``core.classify_wall_join_state``,
  ``core.wall_join_preview_lines``, ``_stroke_lines_alpha``,
  ``_cursor_icon_hovered``, ``_lookup_active_instance``,
  ``tool.Parametric.is_path_connectable_wall``,
  ``_wall_axis_world_segment_from_geom``) already on HEAD.
* ``bim/handler.py`` wires ``WallGizmoPreviewDecorator.install()`` /
  ``.uninstall()`` alongside the other always-on preview decorators.
  The decorator self-polls every frame; cost is one selection-count
  check + one ``is_highlight`` read when no eligible state is active.

Verified: headless smoke green, ruff + black clean. Live testing
confirms the four preview paths fire correctly when hovering each
icon.

Generated with the assistance of an AI coding tool.
2026-06-02 17:30:13 +02:00
Gorgious56 ed7b2fc233 Stack wall-join trio along screen-up + L/T glyphs
GizmoWallJoinIntersection used to place its icons at state-specific
world points: join at floor Z, extend-to-wall at the active wall's
top Z, fillet stacked screen-up above join. Same XY at different Z
collapses to a single screen pixel in plan / top view, so two icons
became one hit target — invisible from above.

* position_gizmos now always-stacks along screen-up at a wall-top
  anchor in both the joined (unjoin + fillet) and the intersecting
  (extend + join + fillet) states. Order bottom-up is
  extend / L / fillet. Collinear-merge keeps its single boundary
  icon (no stack needed).
* New _stack_anchor_z picks the active wall's top Z (or the taller
  of the two on mid-selection-transition frames). New _stack_at
  lays a tuple of icons along screen-up at the resolved anchor.
* Glyph swap: join_icon -> VIEW3D_GT_wall_corner (L), extend_to_wall_icon
  -> VIEW3D_GT_wall_tee (T). Both classes already existed in
  bim/module/drawing/gizmos.py from an earlier commit; only the
  setup() bl_idname strings changed. The previous arrow-merge /
  arrow-extend pair read as the same direction once stacked.

Forward-compat AST contracts in test_wall_gizmos_forward_compat.py
pin the new invariants: the L and T bl_idnames must appear in
setup(), and position_gizmos must route through _stack_at so a
regression that reintroduces a direct billboarded_at write for any
state-specific icon fails CI before it flattens the stack again.

Also folds in a one-line typo fix in core/spatial.py:
assign_container's per-element can_contain check iterated `e` but
predicate-tested `root_element` (the outer for-loop variable), so
every element in the comprehension was tested against the same
container/element pair. Switch the argument to `e`.

Generated with the assistance of an AI coding tool.
2026-06-02 15:35:31 +02:00
Gorgious56 2c18155d98 Merge ifcopenshell/v0.8.0 into parametric-framework-pt2
Bring in 13 commits from upstream v0.8.0 (tip f158ae737):

- Add regenerate_wall_to_underside operator + has_underside_connection
  Model interface (closes #7943)
- Extend/regenerate walls to multiple undersides
- Fix duplicate booleans in extend_walls_to_underside
- Fix extend_walls_to_underside ridge artifact
- Regenerate connected walls when recalculating a slab
- Fix validate_type corruption; remove debug prints
- Lazy BVH tree construction in SnapObj + early-terminate solid raycasts
  in non-xray mode + optimize 2D projection in ray_cast_by_proximity_2d
- Fix crash in update_bim_tool_props when selected type isn't a valid
  ifc_class
- Fix assign_container in spatial.py (#8079)
- Fix sign of temporary offset restore in sweep_along_curve

Auto-merge resolved all overlap files cleanly:
- bim/handler.py: work branch's update_bim_tool_props refactor and
  upstream's try/except hardening converged on identical try/except
  around props.ifc_class assignment (no net change).
- bim/module/model/__init__.py: upstream's wall.RegenerateWallToUnderside
  entry and work branch's roof gizmo entries occupy disjoint sections.
- bim/module/model/wall.py: upstream's RegenerateWallToUnderside operator
  and work branch's GizmoWallEdition/IconSlot refactors occupy disjoint
  sections.
- core/tool.py: upstream's four new Model stubs and work branch's
  Root.has_material_styles stub occupy different classes.

Partly generated with the assistance of an AI coding tool.
2026-06-02 13:31:53 +02:00
Gorgious56 1e8c0b86a0 Migrate Modifier shim callers + drop the shim block
Completes the PR4/PR5 cleanup the FIXME at tool/blender.py
flagged: every is_<type> / Array.<helper> shim on
tool.Blender.Modifier delegated one-for-one to tool.Parametric /
tool.Array. Callers now reach the canonical home directly, and the
shim block — seven is_<type> classmethods plus the inner class Array
— comes out.

Renames (no semantic change):

* tool.Blender.Modifier.is_<door|railing|roof|stair|wall|window>
  → tool.Parametric.is_<x>
  13 sites across tool/loader.py, bim/import_ifc.py,
  bim/module/geometry/{data,operator}.py, bim/module/model/{door,
  railing,roof,stair,ui,wall,window}.py.

* tool.Blender.Modifier.Array.<helper> → tool.Array.<helper>
  4 sites across tool/root.py, bim/import_ifc.py,
  bim/module/geometry/operator.py.

* test_parametric_registry.py: the two getattr probes that hunt
  predicates by name now look on tool.Parametric. Docstring + the
  test function name (test_every_entry_has_modifier_predicate →
  test_every_entry_has_parametric_predicate) follow the move.

Kept on tool.Blender.Modifier (non-shim, no equivalent on
tool.Parametric): try_applying_edit_mode,
try_canceling_editing_modifier_parameters_or_path,
is_eligible_for_<x>_modifier (×5), is_array_child, is_slab.

Verified: 109 model-lane tests + 8 parametric-registry tests pass
(the one pre-existing failure in test_wall_header_refresh.py is
unrelated — it patches handler.update_bim_tool_props which has been
renamed). git grep for tool\.Blender\.Modifier\.(is_<type>|Array\.)
returns empty. black + ruff clean on every touched file.

Generated with the assistance of an AI coding tool.
2026-06-02 12:59:40 +02:00
Gorgious56 ac11044261 Add GizmoRoofEdition + fix low-slope normals + cancel restore
Ports roof parametric edit gizmo group from gizmos-8088 and folds in
three roof-mesh bug fixes surfaced during live testing.

Port:

* ``CycleRoofGenerationMethod`` operator (bim.cycle_roof_generation_method)
  cycles props.generation_method between "HEIGHT" and "ANGLE". Shift+click
  cycles in reverse via the ``CycleTypeMixin`` contract.
* ``GizmoRoofEdition`` gizmo group: 3 dimension gizmos for height
  (visible in HEIGHT mode) / slope angle with tan/atan2 rise round-trip
  + degree formatter (ANGLE mode) / roof_thickness. All three handles
  anchor at the object's local origin and separate visually via their
  declared axes (height/slope +Z, thickness -Z) — height + slope are
  mutually exclusive via ``visibility_condition`` so they never paint
  at the same time. Anchoring at the origin sidesteps the first-click
  default-identity-matrix symptom that footprint-derived anchoring
  would have hit on a stale ``RoofData`` cache.
* Lifecycle factory swap: explicit ``EnableEditingRoof / CancelEditingRoof
  / FinishEditingRoof`` classes replaced by ``tool.Parametric.build_edit_lifecycle("roof", _RoofEditMixin, ...)``.
  Same bl_idnames out, no external caller changes.
* Registration: ``CycleRoofGenerationMethod`` + ``GizmoRoofEdition``
  added to ``bim/module/model/__init__.py`` classes tuple.
* Tests: ``test_roof_gizmos.py`` covering slope round-trip, visibility
  gates, cycle operator metadata, and origin-anchored positioning.

Bug fixes:

* ``generate_hipped_roof_bmesh`` flipped the bottom slab face's normal
  at low slope angles. The kernel's outward-inference becomes
  ambiguous on near-flat geometry once ``remove_doubles`` and
  internal-face deletion run, and the early ``recalc_face_normals``
  pass at line 389 ran BEFORE the topology was final. A second pass
  on the final closed mesh fixes the eave plane (now reliably points
  down regardless of slope).
* ``bpypolyskel.polygonize`` can emit a face whose vertex list
  contains the same index twice on certain footprint/slope
  combinations (a straight-skeleton ridge collapse). ``bm.faces.new``
  rejects those with ``found the same (BMVert) used multiple times``,
  aborting the whole rebuild. Filter the degenerate faces out so the
  rest of the roof renders.
* ``_RoofEditMixin._restore_viewport_after_cancel`` now rebuilds the
  bmesh from the just-restored draft via ``update_roof_modifier_bmesh``.
  The hook was abstract on ``PathPreservingEditMixin`` and raised
  ``NotImplementedError`` on cancel-after-edit, leaving the user
  stranded.

Also folds in a parallel ``tool/loader.py`` swap from
``tool.Blender.Modifier.is_railing`` to ``tool.Parametric.is_railing``
(consistent with the rest of the loader using ``tool.Parametric.*``).

Verified: headless smoke green, test_parametric_registry.py 8/8,
test_roof_gizmos.py 15/15. ruff + black clean on the touched files.

Generated with the assistance of an AI coding tool.
2026-06-02 12:20:52 +02:00
Gorgious56 ab9152e32d Fix fillet preview crash + surface openings on fillet walls
Three wall-gizmo fixes:

* GizmoWallFilletPreview crashed on every draw_prepare after the
  DRY-colors refactor moved decoration lookups onto
  self.get_decoration_colors() — that method lives on
  BillboardingGizmoGroupMixin / BaseParametricGizmoGroup, but
  GizmoWallFilletPreview inherited only from bpy.types.GizmoGroup.
  setup() AttributeError'd silently, leaving radius_dim and friends
  unset. Add the mixin to the bases; rename _position_gizmos to
  position_gizmos so the mixin's refresh/draw_prepare dispatch lands
  correctly and drop the now-redundant overrides.

* GizmoWallAddOpening's poll gated on the strict is_wall predicate,
  which rejects fillet-corner walls (no LAYER2 usage by IFC spec).
  Switch to is_path_connectable_wall on both the active and the
  partner-exclusion checks so the add-opening icon surfaces over
  curved corners — matching every other wall-state gizmo's host gate.

* Show / hide openings was only available on LAYER2 walls because
  GizmoWallEdition's parametric edit pipeline (which carries the
  toggle) refuses fillet bodies. Add GizmoWallFilletToggleOpenings,
  a dedicated single-icon group that polls on is_fillet_corner_wall
  and reuses bim.toggle_wall_openings — the body stays untouched.

Forward-compat AST guards in test_wall_gizmos_forward_compat.py pin
both invariants: every wall GizmoGroup that calls
self.get_decoration_colors() must inherit a mixin that provides it,
and GizmoWallAddOpening.poll must keep using the looser predicate.

Generated with the assistance of an AI coding tool.
2026-06-02 11:39:12 +02:00
Gorgious56 18dc7abb06 Split update_bim_tool_props commit vs selection
tool.Parametric.refresh_post_commit was calling update_bim_tool_props
after every IFC mutation. The function does two things — refresh
read-only header values (extrusion_depth/length/x_angle) and re-target
user-intent enums (ifc_class, relating_type_id) from the active object.
Doing both on the commit path crashed on IfcAnnotation actives (the
type isn't in the bim_tool ifc_class enum) and silently overwrote the
user's "what to build next" choice on every other element.

Split the function: update_bim_tool_props remains selection-driven and
does both halves; new refresh_bim_tool_headers is header-only and is
what refresh_post_commit now calls. Behaviour on selection change is
preserved. Also ports the upstream PR #8136 try/except guard onto the
props.ifc_class write for the selection-driven path. Adds
test_handler_forward_compat.py to pin both contracts via AST.

Generated with the assistance of an AI coding tool.
2026-06-02 10:57:16 +02:00
Gorgious56 b7549f2476 Wire array panel buttons to triad lifecycle
Two bugs in BIM_PT_array:

1. The "is this layer in edit mode" predicate compared a BoolProperty
   against an int (props.is_editing == i). Python evaluates False == 0
   as True, so layer 0 always rendered the per-layer edit form even
   when no edit was active — clicking validate/cancel then dispatched
   against a phantom edit state. Switched to
   props.editing_item_index == i, which defaults to -1 and matches
   exactly one layer when an edit is active.

2. The panel's CHECKMARK and CANCEL buttons called bim.edit_array /
   bim.disable_editing_array, a parallel lifecycle that only cleared
   editing_item_index. Entering edit mode via the viewport gizmo
   (bim.enable_editing_array, the triad enter) sets is_editing=True
   and hides array children; the legacy panel exit unwound neither —
   so committing or cancelling from the panel left is_editing=True
   with children hidden, and the viewport gizmo thought the edit was
   still in progress. Re-bound both panel buttons to the canonical
   triad operators (bim.finish_editing_array /
   bim.cancel_editing_array), which _ArrayEditMixin already owns and
   which the viewport gizmo group already uses. Panel and gizmo now
   share one exit path.

The three now-unreachable operators are deleted with their
registration entries: EditArray (bim.edit_array), DisableEditingArray
(bim.disable_editing_array), and EnableEditingArrayItem
(bim.enable_editing_array_item, never called from any UI). The two
test/tool/test_model.py sites that drove bim.edit_array as a commit
step are switched to bim.finish_editing_array.

External scripts or user keymaps bound to bim.edit_array /
bim.disable_editing_array will need to update — the replacements are
bim.finish_editing_array and bim.cancel_editing_array, both taking no
parameters (the layer is read from props.editing_item_index).

Partly generated with the assistance of an AI coding tool.
2026-06-02 10:37:01 +02:00
Gorgious56 ba6cfe9c24 Fix door swing arcs + declarative SwingArcConfig
The recent per-gizmo-prefs cleanup left ``update_swing_gizmos`` with a
stale ``prefs`` reference that raised NameError mid-refresh, so the flip
arc's ``matrix_basis`` was never reassigned and the gizmo drifted to the
world origin. SINGLE_SWING_RIGHT also lacked an X-mirror on the primary
arc, so the swing extended past the door's right edge instead of
sweeping back over the panel.

Five related fixes / additions:

* Drop the leftover ``prefs.decorations_colour[:3]`` per-frame colour
  override (the setup-time ``decorator_color_special`` is the durable
  contract — there's no reason to overwrite it every refresh).
* Add X-mirror to RIGHT-hinged single-panel transforms so the arc
  sweeps back over the door rather than past the right edge.
* Treat DOUBLE_DOOR_SINGLE_SWING as a two-panel layout: 4 arcs total
  (left + right panels, each with its own Y-mirrored flip) scaled to
  ``overall_width / 2``.
* Hide all swing arcs for SLIDING_TO_LEFT / SLIDING_TO_RIGHT /
  DOUBLE_DOOR_SLIDING — sliding doors don't swing. A slide-direction
  indicator is deferred to a separate change.
* Pin ``select_bias = -1000.0`` on every arc gizmo so the big
  quarter-arc hit shapes don't steal clicks from the smaller dimension
  and edit gizmos drawn on top.

Architectural cleanup driven by the same diff: the imperative
4-create + 50-line update block is replaced by a declarative
``swing_arc_props`` list of ``SwingArcConfig`` entries (mirrors the
existing ``dimension_gizmo_props`` pattern). Setup iterates the list
and creates one (main, flip) pair per entry under
``gizmo_swing_arc_<name>`` / ``gizmo_swing_arc_<name>_flip``; update
iterates the same list and positions each pair via the lambdas. Adding
a hypothetical multi-panel variant becomes a config entry rather than
two more attribute names plus a transform branch.

``ToggleDoorSwing`` gets a ``description`` classmethod that returns
user-facing wording per ``flip_geometry`` branch so the tooltip on
hover stops reading like operator internals.

``test/bim/module/model/test_door_gizmos.py`` (new) pins the
per-door-type contract: 11 cases covering LEFT / RIGHT hinge positions,
DOUBLE_SWING parity with SINGLE_SWING, DOUBLE_DOOR 4-arc layout, the
sliding-types hide invariant, ``is_editing=False`` hide invariant,
flip-arc matrix re-assignment, and world-matrix pre-multiplication.

Verified: ``pytest test/bim/module/model/test_door_gizmos.py`` 11/11
green; combined wall + stair + door gizmo lanes 37/37 green; ruff +
black clean on the three touched files.

Generated with the assistance of an AI coding tool.
2026-06-02 10:15:09 +02:00
Ryan Schultz f158ae7377 Fix crash in update_bim_tool_props when selected type isn't a valid ifc_class
props.ifc_class is an EnumProperty whose items list only the element/space
types present in the model. Assigning element_type.is_a() crashed with
`enum "<class>" not found` when the selected element's type wasn't a member
(e.g. a raw IfcTypeProduct, or a stale item list mid-rebuild), aborting the
post-commit refresh.

Wrap the assignment in the same try/except TypeError guard already used for
the sibling relating_type_id assignments (added in 233cc344fa).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 08:44:21 +02:00
Gorgious56 b154cadf3b Add IconSlot placeholders + stair xN tread label
Add a clickable "xN" badge to GizmoStairEdition's edit row, mirroring
the array's popup-input UX: click opens a number dialog (no more
shift+click-into-modal). Text-only — no 2x2 grid glyph.

Structural changes that enable this cleanly:

* IconSlot.placeholder=True: slots reserve an X position in the row
  without auto-creating a gizmo. Subclasses resolve the reserved X via
  _slot_x_positions()[name] to place their own dynamic gizmos. Drops
  the brittle "remember to add extra_gap_before" workaround that would
  silently rot on slot reorders.

* Array bug fix: the count badge collided with the "-" icon because
  the slot manager placed count_minus at the cycle position (X=0.87)
  where ICON_NUMBER_X also lives. Migrating the badge to a placeholder
  slot lets the manager allocate the X naturally and the "-" no longer
  overlaps. ICON_NUMBER_X constant removed.

* IntegerInputDialogMixin in parametric_lifecycle.py: extracts the
  popup-dialog plumbing shared between InputArrayCount and the new
  InputStairTreads. Subclasses declare an IntProperty + attr_name +
  props_getter; the mixin owns invoke/execute. _resolve_props helper
  factors the common obj/props/requires_editing prologue.

Tests: BIM_GT_count_label registration; IconSlot placeholder contract
(no gizmo_idname required; gizmo_attrs() returns empty); the stair
edit-row slot layout reserves the label position between tread_lock
and plus at one ICON_ARRAY_GAP each; visibility propagates from
props.is_editing.

Partly generated with the assistance of an AI coding tool.
2026-06-02 08:38:01 +02:00
Bruno Perdigão 5dde402f8b Optimize 2D projection in ray_cast_by_proximity_2d 2026-06-01 22:27:58 -03:00
Bruno Perdigão 1daee04d9c Early-terminate solid raycasts in non-xray mode 2026-06-01 22:18:13 -03:00
Bruno Perdigão 0d7c378db5 Lazy BVH tree construction in SnapObj 2026-06-01 20:47:27 -03:00
Gorgious56 1ba9341201 Drop per-gizmo preferences + fix dynamic-wall face normals + DRY colors
Three related cleanups in one pass:

* **Per-gizmo preferences removed.** The ``visibility_pref`` field on
  IconSlot, the ``prefs.gizmos.<feature>.<icon>`` PropertyGroups, and
  the dispatcher that surfaced them in the addon preferences UI are
  all gone. ``update_gizmo_visibility`` loses its ``pref_enabled``
  parameter — visibility is now driven purely by editing state and
  modal gating. bim/ui.py drops ~257 lines of dead PropertyGroup
  definitions; bim/__init__.py and tool/parametric.py shed their
  matching wiring; door / wall slot declarations stop referencing
  the now-nonexistent prefs.

* **Dynamic-wall face normals fixed.** ``regenerate_wall_mesh_from_props``
  in wall.py now calls ``bmesh.ops.recalc_face_normals`` before writing
  the mesh. Without it, walls regenerated from the parametric edit
  draft could ship with inward-facing normals on some faces, which
  rendered as visual holes under any backface-cull or normal-aware
  shading. ``test/bim/module/model/test_wall_preview_mesh.py`` pins
  the invariant (every face's normal points away from the wall centre).

* **Color constants DRY.** ``COLOR_RED`` / ``COLOR_GREEN`` /
  ``COLOR_BLUE`` / ``COLOR_NEUTRAL`` now live at module scope in
  gizmos.py; the BaseParametricGizmoGroup class attributes alias the
  same tuples so ``self.COLOR_GREEN`` keeps working. IconSlot
  declarations in stair.py (plus / minus) and array.py (count_minus /
  count_plus / delete) now reference the named constants instead of
  duplicating the RGB tuples inline.

Verified: headless smoke green at 1267 BIM_OT_ classes,
test_parametric_registry.py 8/8, wall lane 31/31 (includes the new
preview-mesh test). ruff + black clean on the touched files.

Generated with the assistance of an AI coding tool.
2026-06-01 18:32:54 +02:00
Gorgious56 f0aec7b38e Highlight partner wall on link-toggle hover
Hovering a wall-junction link-toggle icon today only swaps the icon
shape — the user doesn't see which wall the click will disconnect from
until after they click. ATPATH (T-junction) configurations especially
make the partner ambiguous when multiple connections sit close together.

On hover, paint a wireframe bbox around the partner wall using the same
shader, constants and color the array module already established for
its layer-children highlight (POLYLINE_UNIFORM_COLOR, decorator_color_special,
line width 1.8, alpha 0.8). The line-width / alpha constants in decorator.py
are renamed from _ARRAY_LAYER_BBOX_LINE_* to _BBOX_HIGHLIGHT_LINE_* and
shared between draw_array_layer_children_bbox and the new
draw_wall_partner_bbox so the two highlights stay in lockstep.

The trigger lives in a new GizmoWallLinkToggle subclass in wall.py
which keeps the base gizmos.GizmoLinkToggle generic (per the
generic-naming convention for shared widgets). The subclass's draw()
calls super().draw(context) then on self.is_highlight outlines its
partner_obj via the shared decorator helper. Same trigger pattern as
GizmoArrayLayerIndicator.

Blender's Gizmo API exposes target_set_operator but no symmetric
getter, so the partner reference can't be read back from the bound
operator handle. Instead GizmoWallUnjoinSingle.position_gizmos
mirrors the resolved partner_obj onto each visible icon every frame
next to the existing other_wall_guid write — the icon's draw() reads
from its own __slots__-declared attribute.

A forward-compat AST test pins the contract: GizmoWallLinkToggle.draw
must reference is_highlight and call draw_wall_partner_bbox. Catches
the regression where someone tidies the draw() override into super()
or replaces the shared helper with an ad-hoc draw call.

Generated with the assistance of an AI coding tool.
2026-06-01 16:40:18 +02:00
Gorgious56 4cf34b69d2 Replace hardcoded icon-X constants with IconSlot layout manager
The parametric edit toolbar row used to assign each feature icon its
own ICON_<NAME>_X constant, with a separate FEATURE_ICON_MAX_X override
each subclass had to bump whenever a new icon was added. Forgetting the
bump silently collided icons — wall's rotate icon and the array button
both landed at X=1.24 in edit mode.

The new IconSlot dataclass + feature_slots tuple replace the
constants-and-override pattern with order-driven positioning: the
layout manager assigns each slot an X from its tuple index plus a
uniform ICON_ARRAY_GAP. Adding an icon is now a one-line append; the
"forget to bump" failure mode is structurally impossible.

Slot capabilities cover every existing icon-row shape:
* Single icon (wall rotate, array delete).
* N-variant slots — N gizmos at the same X with one visible per frame
  via a subclass picker (stair tread-lock open/closed, wall baseline
  exterior/center/interior). Pair becomes the N=2 case; triplet the
  N=3 case. Variant idnames can be authored either as a tuple of
  explicit names or as a string prefix that auto-suffixes _<variant>.
* Visibility prefs gate slot rendering without reflowing the row —
  hidden slots still consume their X position.
* Extra per-slot gap before for visual separation (array's delete
  trails the routine controls by an extra 0.2 m).
* Operator props forwarded to target_set_operator so adjusters
  (+/-, increment) and generic toggles (property_name=...) work.

When the cycle slot is unused, feature slots collapse into the cycle
position so the row stays tight — that's how wall's baseline triplet
sits at X=0.87 without a gap before it.

Three subclasses migrate to the new system:
* wall.py — rotate icon + baseline triplet variants. Drops
  ICON_ROTATE_X, _BASELINE_GIZMO_ATTRS, the manual triplet creation
  loop, and the matching positioning block in _update_icon_row_extras
  (it now just picks variant visibility).
* stair.py — tread_lock pair (open/closed) + plus + minus.
  _update_editing_icon_positions reads slot X via _slot_x_positions
  instead of three hardcoded constants. Also fixes the standalone
  total_length_lock gizmo, which was broken since PR4 split
  VIEW3D_GT_lock into open/closed pair (caller wasn't updated).
* array.py — count_minus + count_plus + method + delete (with
  extra_gap_before=0.20 to separate the destructive action).
  Drops the manual edit-row positioning loop entirely; the base
  loop handles it. GizmoArrayChild now inherits BillboardingGizmoGroupMixin
  and uses the shared setup_icon_gizmo helper, dropping its
  duplicated _make_icon wrapper.

Two helpers added on BillboardingGizmoGroupMixin to fold the duplicated
prefs/color preamble that appeared at the top of six wall gizmo setups
plus the array-child setup:
* get_decoration_colors() — (decorations_colour, decorator_color_selected),
  the active-state pair.
* get_unselected_decoration_colors() — (decorator_color_unselected,
  decorator_color_selected) for gizmos surfaced on already-selected
  geometry that should not pull focus.

Verified: headless smoke green at 1267 BIM_OT_ classes,
test_parametric_registry.py 8/8 pass, wall lane 29/29 pass,
model lane unchanged at 135 pass + 7 pre-existing v0.8.0 failures
(no regressions). ruff + black clean.

Generated with the assistance of an AI coding tool.
2026-06-01 16:19:42 +02:00
Gorgious56 44723e83b6 Add link-toggle hover gizmo for wall junctions
The previous single-wall unjoin gizmo used a bracket-pair icon
(VIEW3D_GT_unjoin) that reads as "unjoin" only after you know what
it is, with no clear "linked" inverse — closing the brackets to
suggest the connected state collapses to a hollow square that
doesn't read as a link at all.

Add GizmoLinkToggle (VIEW3D_GT_link_toggle): two filled dots joined
by a horizontal connector in the default state. On hover the two
halves shear vertically apart — left dot+stub slip down as a unit,
right dot+stub slip up — with a horizontal gap at the centre,
signalling that a click will sever the underlying connection. The
glyph lives next to the generic icon classes (GizmoLockOpen/Closed,
GizmoArc) so any path / link / pair-of-connected-items context can
reuse it; it isn't wall-specific despite the first caller.

The class keeps its own per-state GPUBatch cache so the shape swap
on hover doesn't allocate per frame. The hit-shape is sourced from
the broken form (the larger bbox of the two states) so the cursor
doesn't lose hover at the offset dots' outer edges and flicker
between states.

GizmoWallUnjoinSingle.setup() now requests VIEW3D_GT_link_toggle.
The operator binding (bim.unjoin_wall_path_connection), the
POOL_SIZE, and the per-frame partner-GUID write are unchanged.

Generated with the assistance of an AI coding tool.
2026-06-01 14:51:31 +02:00
Gorgious56 2272c35e9b Fix spurious X/Y rotation on fillet corner wall
When the two source walls were placed at different elevations, the
fillet corner wall ended up with sub-degree X and Y Euler rotations
even though both source walls had only a Z rotation.

Cause: _apply_fillet_corner_geometry derived the corner's local X
axis from `chord = tangent_b - tangent_a` (a 3D vector). With walls
at different Z, `chord.z` was non-zero, so `x_dir = chord.normalized()`
inherited that Z component. The Z axis was already hardcoded to world
Z, so x_dir and z_dir were no longer orthogonal — the resulting
matrix_world was non-orthonormal, and Blender's Euler decomposition
surfaced the skew as the visible X/Y rotation drift.

Project the chord to the XY plane before normalising so x_dir is
strictly XY-aligned and orthogonal to z_dir. The corner wall is now
placed at wall A's elevation with a pure Z rotation, which matches
the user's expectation when both inputs are Z-aligned regardless of
their relative elevation.

Generated with the assistance of an AI coding tool.
2026-06-01 14:49:33 +02:00
falken10vdl 1c128a2d6a Add has_underside_connection method to Model class and update wall regeneration logic 2026-06-01 07:44:49 -05:00
Ryan Schultz 36372627db Fix validate_type corruption; remove debug prints
When validate_type selected a preferred_item from remaining_items
(e.g. the sole IfcBooleanResult in a representation), it left that
item in the list. The subsequent Items filter removed every item,
leaving Items=[] and causing guess_type to return
"MappedRepresentation" — silently corrupting the representation.

Also removes temporary debug print statements added during
investigation of the wall-to-slab extension workflow.

Generated with the assistance of an AI coding tool.
2026-06-01 07:44:49 -05:00
Ryan Schultz a9b6f02f02 Fix duplicate booleans in extend_walls_to_underside
Re-running the operator on the same wall/slab pair created
additional IfcPolygonalFaceSet booleans each time. Now each
wall's existing booleans are removed before re-clipping, and
previously connected slabs are merged with the new selection
so no earlier clips are silently discarded.

Generated with the assistance of an AI coding tool.
2026-06-01 07:44:49 -05:00
Ryan Schultz 9e7d97e298 Regenerate connected walls when recalculating a slab
When Shift+G is pressed on a LAYER3 element, any LAYER2 walls
connected via IfcRelConnectsElements(TOP) are now re-clipped
to the slab's updated geometry after recalculate_slab runs.

Generated with the assistance of an AI coding tool.
2026-06-01 07:44:49 -05:00
Ryan Schultz 187b8e7167 Add extend/regenerate walls to multiple undersides
extend_walls_to_underside now accepts multiple slab/roof
objects in a single operation — all selected non-LAYER2 IFC
elements are treated as clip targets, all LAYER2 elements as
walls. Placement sync is done once upfront; each wall is then
clipped against every selected slab before reloading.

Also adds bim.regenerate_wall_to_underside (Shift+G): after
moving a slab, re-clips connected walls using the existing
IfcRelConnectsElements(TOP) relationship. Old booleans are
removed via remove_representation_item before re-clipping.

Generated with the assistance of an AI coding tool.
2026-06-01 07:44:49 -05:00
Ryan Schultz 08e33fe572 Closes #7943: Add regenerate_wall_to_underside operator
When extend_walls_to_underside is applied to a wall and the
roof/slab is later moved, pressing Shift+G now re-clips the
wall to the slab's new position.

The IFC relationship created by connect_wall_to_slab
(IfcRelConnectsElements, Description="TOP") is used to look
up which slabs a wall is clipped to. On regeneration, the
existing manual booleans (IfcPolygonalFaceSet operands) are
cleanly removed via remove_representation_item, then
clip_wall_to_slab is re-applied for each connected slab.

Shift+G on a LAYER2 wall that has a TOP connection now calls
bim.regenerate_wall_to_underside; walls without a connection
continue to call bim.recalculate_wall as before.

Generated with the assistance of an AI coding tool.
2026-06-01 07:44:49 -05:00
Ryan Schultz e142b9d7b4 Fix extend_walls_to_underside ridge artifact
When the operator was called twice on the same wall for a
ridge roof, the two IfcPolygonalFaceSet clip solids shared
an exact ridge edge (kissing-solid). OCCT produced spurious
extra vertices at the coincident boundary.

Fix by building the clip solid from a rectangle on the slope
plane that extends slightly past the face edge (1 project
unit margin) rather than the exact face footprint. Adjacent
slope solids now volumetrically overlap at the ridge instead
of sharing a boundary face, which OCCT handles correctly.

Generated with the assistance of an AI coding tool.
2026-06-01 07:44:49 -05:00
Gorgious56 de29d12b00 Fix fillet partner missing from wall unjoin gizmo
GizmoWallUnjoinSingle.poll accepts fillet-corner walls via the looser
tool.Parametric.is_path_connectable_wall predicate (fillet corners
have no LAYER2 usage by IFC spec, but they still participate in
IfcRelConnectsPathElements). The partner filter inside
_iter_path_connections used the stricter tool.Blender.Modifier.is_wall
(LAYER2-only), so adjacent LAYER2 walls silently dropped their
fillet-corner partners from the connection list — the unjoin icon
appeared when the fillet wall itself was selected but not on either
of its LAYER2 neighbours.

Switch the partner filter to is_path_connectable_wall so host and
partner predicates match. Add a regression test for the fillet case
and an AST forward-compat guard pinning the predicate symbol so a
future "tidy the imports" can't silently re-introduce the asymmetry.

Generated with the assistance of an AI coding tool.
2026-06-01 14:42:57 +02:00
Gorgious56 9440bafc32 Add array parametric edit lifecycle + GizmoArrayEdition / Child
Ports the array parametric-edit lifecycle, gizmo group, child guard,
per-layer ARRAY entry icons, and the array bbox decorators
(preview + selection highlight + layer-children) from gizmos-8088.
Restores the array_gizmo icon's positioning + visibility in the
framework's parametric edit row.

Registry (tool/parametric.py):
* EDIT_TYPES adds ParametricObject("array", supports_build_edit_lifecycle=True).
  _ArrayEditMixin in array.py feeds build_edit_lifecycle which auto-
  generates EnableEditingArray / FinishEditingArray / CancelEditingArray
  with the conventional bl_idnames the gizmo references.

tool/blender.py:
* Adds is_array predicate wrapper around tool.Parametric.is_array.
  The registry contract test test_every_entry_has_modifier_predicate
  enforces every EDIT_TYPES entry has a matching is_<name> wrapper on
  tool.Blender.Modifier.

array.py (+1130 LOC port from gizmos-8088):
* _ArrayEditMixin(ParametricEditMixinBase) drives the auto-generated
  enable / finish / cancel lifecycle.
* GizmoArrayEdition: validate + cancel + count display + +/- adjusters
  + method toggle + delete button + per-layer ARRAY entry icons
  (preallocated pool of MAX_LAYER_GIZMOS=8).
* GizmoArrayChild: child-array gizmo for the array-replica case.
* EditArrayFromChild: resolves the spawning layer via
  tool.Array.get_child_layer_index so clicking a child's array gizmo
  opens the layer that produced that child rather than always layer 0
  (the gizmos-8088 source itself hardcoded item=0; HEAD has the helper
  to do it right).
* New operators: EnableEditingArrayItem, ArrayParentGizmoClick,
  ArrayGizmoClick, ToggleArrayMethod, RemoveArrayLayerFromEdit,
  InputArrayCount, AdjustArrayCount.

prop.py: BIMArrayProperties gets per_child_opening BoolProperty
(when the array parent fills a host, give each child its own
opening + filling pair).

Bug fix: guard update_relating_array_from_object against the
cleanup-time None set. _finish_one writes relating_array_object = None
to clear the source-array reference; that fired the update callback,
which dispatched bpy.ops.bim.enable_editing_array(item=self.is_editing).
With is_editing just flipped to False, the bool coerced to 0 and
re-opened layer-0 edit immediately after every validate. The guard
short-circuits on None; item is also fixed to 0 (the bool-as-layer-
index was always meaningless for the legitimate user-pick path).

decorator.py (+312 LOC, all ports from gizmos-8088):
* bbox_world_edges / draw_polyline_segments / _BBOX_EDGES - shared
  geometry helpers usable across array decorators.
* draw_array_layer_children_bbox - green wireframe bbox per child of
  one array layer, drawn inline from a gizmo's draw() so the highlight
  tracks the hover cursor without POST_VIEW lag.
* ArrayPreviewDecorator - faint cyan ghost bboxes at each future
  array instance during the edit lifecycle (offset math mirrors
  Model.regenerate_array, gated on props.is_editing).
* ArraySelectionHighlightDecorator - bounding-box overlay surfacing
  the array family of the selected object. Child selected -> parent
  in special color + siblings in unselected color; parent selected
  (idle) -> all children in unselected color. TokenCache-backed.

handler.py: imports + uninstall/install the 2 always-on decorators in
_install_viewport_overlays. Both self-poll, so installation has no
cost when no array is selected / in edit mode.

Registration (bim/module/model/__init__.py):
* Adds the 3 lifecycle classes generated by build_edit_lifecycle
  (CancelEditingArray, EnableEditingArray, FinishEditingArray) -
  they exist as module-level names but are only visible to Blender's
  operator registry when included in the classes tuple.
* Adds the 8 new operators + 2 new gizmo groups in alphabetical order.

gizmos.py: restores the array_gizmo icon position + visibility block
in BaseParametricGizmoGroup.update_editing_gizmos. Was force-hidden
in c250b2c1a because no array gizmo existed; the icon's plumbing
comes back online now that GizmoArrayEdition is registered.

Verified by test/bim/test_parametric_registry.py: all 8 tests pass -
enable/finish/cancel ops resolve, PropertyGroup attached, is_array
predicate present, predicate is total on non-matching elements.

Generated with the assistance of an AI coding tool.
2026-06-01 14:39:58 +02:00
falken10vdl 431cf435ef Fix assign_container in spatial.py (#8079)
ifc.get_object(element) can return None for IFC elements that aren't loaded as Blender objects (e.g., decomposed sub-elements). 
The loop now skips those instead of passing None into collector.assign().

Cheers!
2026-06-01 07:02:28 -05:00
Tiago Azevedo a433f56337 Fix sign of temporary offset restore in sweep_along_curve
The temporary-offset workaround (#7408, commit bd57cc8735) subtracts the
directrix centroid (`mean`) from the curve points before building the
sweep near the origin, then must add it back to restore the original
location. The restore negated the sign — `Move(-mean)` instead of
`Move(+mean)` — placing the swept solid at -mean (mirrored through the
origin) rather than its true position.

Only triggers for polyline directrixes (`is_polyhedron()`) whose centroid
is more than 100 m from the origin (`mean.norm() > 1e2`), so models
centered near the origin are unaffected. Models that keep absolute site
coordinates (e.g. many Revit/ODA IFC exports) render affected swept
solids — reinforcing bars, pipes — at a mirrored phantom location far
from the rest of the model.
2026-06-01 11:42:39 +02:00
Gorgious56 0d3543fa31 Drop duplicate _path_connection_location_world in wall.py
PR3 shipped tool.Wall.path_connection_location_world; the local
_path_connection_location_world added in PR4 commit 70845e4dd
duplicated the same logic. The only caller in wall.py already uses
the tool method (line 3687 area), so the local helper has been
dead code since the migration in 7e5e7b8d6 routed _get_wall_geom_cached
to tool.Wall.read_geometry. Drop it.

Generated with the assistance of an AI coding tool.
2026-06-01 10:48:09 +02:00
Gorgious56 e764559133 Route _has_material_styles through tool.Root.has_material_styles
Pre-existing architectural smell on v0.8.0: core/root.py.copy_class
called a module-level _has_material_styles helper that did
ifcopenshell.util.element.get_materials() directly, bypassing the
Prophecy mock seam that every other branch in copy_class flowed
through. Symptom: test/core/test_root.py::TestCopyClass::
test_AAAAAAAAAAAA passed mock strings into copy_class, the helper
called .is_a() on the string, AttributeError.

Move the check to tool.Root.has_material_styles (paired with
assign_body_styles — they're called in sequence as "is there a
material style? if not, assign body style"). core/root.py now
calls root.has_material_styles(new) like every other dependency,
fixing the test failure and dropping the ifcopenshell.util.element
import that was the only consumer of the ifcopenshell import at
module load in core/root.py.

* core/tool.py: add abstract has_material_styles to Root interface.
* tool/root.py: add concrete classmethod near assign_body_styles.
* core/root.py: replace _has_material_styles helper call site with
  root.has_material_styles; drop the local helper and its import.
* test/core/test_root.py: add the new mock expectation
  root.has_material_styles("element").will_return(False) before the
  existing assign_body_styles expectation.

Generated with the assistance of an AI coding tool.
2026-06-01 10:47:57 +02:00
Gorgious56 a3f92eb427 Merge pull request #8133 from Gorgious56/bonsai/parametric-framework-features
Bonsai/parametric framework features
2026-06-01 09:20:51 +02:00
Gorgious56 453e6dc1cc Add behaviour-contract tests for PR4 surfaces
Three test files covering PR4's new surfaces — preview registry,
wall-gizmo poll behaviour, fillet operator registration. Every test
walks the live registry or class hierarchy instead of hard-coding
preview keys, operator names, or helper function names, so adding a
new preview / wall gizmo group / fillet operator exercises the same
invariants without test edits.

test_preview_base.py (6 tests):
* RegistryContract: every PREVIEW_CANCEL_OPS entry resolves to a
  callable cancel operator on bpy.ops.bim.
* GetPreviewPropsTolerance: get_preview_props returns None for
  contexts without a scene (regression guard for the SimpleNamespace
  bug fixed in commit ee63137c6).
* ActivationCycle (registry-driven loop): any_preview_active toggles
  with each registered preview's is_active flag;
  discard_pending_previews clears every active flag across every
  registered preview.
* SaveOnDiscardWired: locates the bim.save_project operator
  dynamically and verifies its execute path references the discard
  helper by its actual __name__.

test_wall_gizmo_poll_gate.py (4 tests):
* WallGizmoGroupsHideDuringPreview: walks the wall module for
  bpy.types.GizmoGroup subclasses (skips preview-owner exceptions
  whose bl_idname contains 'preview'), mocks any_preview_active to
  True, and asserts every discovered gizmo's poll returns False.
* BaseParametricGizmoPollHidesDuringPreview: mirrors the test for
  the cross-feature parametric framework base class.

test_fillet_operators.py (3 tests):
* FilletOperatorsRegistered: at-least-four-ops + every-discovered-op-
  is-callable. Catches accidental deregistration.
* EnableRejectsIneligibleSelection: poll returns False without a
  selection so the operator is greyed-out in menus.

State-clearing tests via bpy.ops.bim.cancel_wall_fillet_preview() are
deliberately omitted — the operator early-returns when context.screen
is unattached and prior tests in the model lane can leave the screen
in that state, making the dispatch path inherently flaky. Live testing
covers the behaviour.

Net: 13 tests pass cleanly in both single-file and full model lane.

Generated with the assistance of an AI coding tool.
2026-06-01 08:35:26 +02:00
Bruno Perdigão 728026d3f0 Remove debug print 2026-05-31 22:30:56 -03:00
Bruno Perdigão 3f270df11e Add more no headless test for snap 2026-05-31 22:26:16 -03:00
Bruno Perdigão 792a0c7da1 Merge tests into a single file 2026-05-31 22:26:16 -03:00
Bruno Perdigão 5856d29fbe Add test files and scripts 2026-05-31 22:26:16 -03:00
Bruno Perdigão cd482a7874 Initial implementation of tests for modal operators 2026-05-31 22:26:16 -03:00
Gorgious56 f6e95c8e8e Bonsai Makefile - pin deepdiff<9.1
deepdiff 9.1.0 added cachebox<6,>=5.2 as a direct runtime dep.
cachebox 5.2.3 only publishes macOS x86_64 wheels for macosx_10_12+,
incompatible with the macos py311 build's --platform macosx_10_10_x86_64.
The daily build's linux-wheel safeguard fires when the resulting
cachebox-*-manylinux_*.whl leaks into the macOS / windows wheels folder
(builds run on ubuntu-latest and cross-build via pip download --platform).

Pin deepdiff to <9.1 (resolves to 9.0.0, no cachebox transitive dep) as
the minimal hotfix. Long-term cleanup: bump the macos py311 platform tag
from 10_10 to 10_13 (matching py312/py313) and re-flag this line with the
standard \$(PYPI_PLATFORM) --only-binary=:all: pattern used by brickschema
and python-socketio.

Partly generated with the assistance of an AI coding tool.
2026-05-31 21:33:28 +02:00
Gorgious56 ee63137c6c Discard previews on IFC save + harden preview-active gate
Save-path:
* SaveProject._execute (project/operator.py) now calls
  preview_base.discard_pending_previews(context.scene) right after
  tool.Parametric.commit_pending_edits(). Previews are session-
  transient — discard rather than commit. Sibling gizmo polls gate
  on each preview's is_active flag; a stuck flag persisted through
  the save would silently hide them on reload. Mirrors the pattern
  already in gizmos-8088.

Preview-active gate hardening:
* preview_base.get_preview_props tolerates contexts without a
  ``scene`` attribute. Pre-existing tests use SimpleNamespace mocks
  for the context; the previous getattr(context.scene, ...) raised
  AttributeError before the inner default kicked in.

Test update:
* test_wall_header_refresh.test_geom_generation_invalidates_wall_geom_cache
  patches tool.Wall.read_geometry instead of the now-deleted local
  wall._read_wall_geometry (commit 7e5e7b8d6 migrated the call site).

Generated with the assistance of an AI coding tool.
2026-05-31 19:10:48 +02:00
Ryan Schultz c83b4eb69f Restore pre-aggregate selection on exit; deselect on unsupported profile
When override_mode_set_edit encounters an unsupported profile (Couldn't
import profile), deselect the object so Tab continues to cycle cleanly.

Also restores the selection that existed before entering aggregate mode
when finally tabbing out, via save/restore_previous_selection().
2026-05-31 07:41:03 -05:00
Ryan Schultz 5eef433abf Deselect geometry after exiting item mode in aggregate context
Following the pattern from 586f9be077, deselect the active object after
exiting item mode so Tab continues to cycle cleanly. Also deselects
parametric LAYER1/LAYER2 items that cannot be edited directly, avoiding
the need to manually deselect before Tab-cycling out of aggregate mode.
2026-05-31 07:25:58 -05:00
Gorgious56 7e5e7b8d6a Drop wall.py local read_geometry + validate dupes + relax gates
Two cohesive cleanups in one commit.

A. Migrate wall.py to PR3-absorbed tool methods (fixes bug 4: pen icon
missing on fillet corner walls):

PR3 shipped tool.Wall.read_geometry + tool.Wall.validate_for_parametric_edit
but wall.py kept local duplicates predating that work. The local
_read_wall_geometry guards on tool.Blender.Modifier.is_wall (LAYER2-only)
while the tool method guards on tool.Parametric.is_path_connectable_wall
(LAYER2 OR fillet corner). Consequence: _get_wall_geom_cached → local
_read_wall_geometry returned None for every fillet corner →
GizmoWallFilletReedit.position_gizmos hit `if geom is None: hide` →
pen icon was unreachable for every fillet corner the user created.

Three _read_wall_geometry callers migrated to tool.Wall.read_geometry
(_read_wall_state_into_props, _get_wall_geom_cached,
GizmoWallJoinIntersection.position_gizmos). Two
_validate_wall_for_parametric_edit callers migrated to
tool.Wall.validate_for_parametric_edit (_maybe_resync_wall_props_from_ifc,
EnableEditingWall._execute). Local helpers deleted; docstring references
updated.

B. Drop over-restrictive gizmo gates (fixes bug 1: join icons missing
when walls intersect away from endpoints):

GizmoWallJoinIntersection.position_gizmos no longer hides itself when
the projected intersection lands further than MAX_DISTANCE_TO_ENDPOINT_
FACTOR (0.75 wall lengths) from any endpoint. The remaining
PARALLEL_DOT_THRESHOLD (cos 2°) gate via project_axis_intersection
returns None for near-parallel walls and is the only correctness bound;
distance from endpoints is a UI concern, not a geometric one.

GizmoWallFilletReedit.poll drops the has_a / has_b ConnectedFrom +
ConnectedTo guard — the IsFilletCorner pset is the authoritative signal.
EnableWallFilletPreviewFromCorner.execute already separately validates
both neighbour connections and reports a user-facing error if either
side is disconnected.

Generated with the assistance of an AI coding tool.
2026-05-31 12:15:39 +02:00
Gorgious56 788d4fe8e8 Hide sister gizmos during preview + ESC cancels + DRY wall polls
Three live-session regressions surfaced after the fillet feature
landed.

Sister gizmos competed with the active preview:
* preview_base.any_preview_active(context): new helper iterates the
  PREVIEW_CANCEL_OPS registry and returns True if any preview is open.
  Future previews registered there automatically gate sister gizmos.
* BaseParametricGizmoGroup.poll (gizmos.py): short-circuits on
  any_preview_active so every parametric gizmo (door/window/stair/
  roof/railing/wall edition) hides during ANY preview.
* The 4 wall gizmo groups with explicit polls (GizmoWallAddOpening,
  GizmoWallExtendVertically, GizmoWallJoinIntersection,
  GizmoWallUnjoinSingle) + GizmoWallFilletReedit gain the same gate.

DRY: extract _wall_gizmo_poll_gate(context):
* 5 wall gizmo polls each duplicated the 2 pre-flight checks
  (viewport-gizmos enabled + no preview active). The helper centralises
  them — each poll becomes a single short-circuit line followed by its
  per-feature selection inspection.

ESC cancels the active preview:
* try_cancel_active_preview already existed in preview_base since PR3
  but had no caller. Hooked into OverrideEscape.execute (geometry/
  operator.py) as a new elif branch — same keymap that already cancels
  pen gizmo edit mode + item mode + edit mode + aggregate mode. Order
  in the branch chain matters: try preview cancel before falling back
  to try_canceling_editing_modifier_parameters_or_path so the in-
  flight preview wins over a stale modifier-edit cancel attempt.

Generated with the assistance of an AI coding tool.
2026-05-31 10:38:30 +02:00
Ryan Schultz a1c2aecf1b Add select_similar to type attribute panels
In BIM_PT_type_attributes and BIM_PT_object_attributes (when
the active object is a type), attribute value buttons now use
"type.<Attr>" as the selector key so the operator finds
matching occurrences via their relating type rather than the
occurrence's own (often unset) attributes.

Generated with the assistance of an AI coding tool.
2026-05-30 21:53:13 -05:00
Ryan Schultz d501970352 Add clipboard copy to SelectSimilarContainer operator
After selecting objects in the same container, copy a `location="Name"`
filter query to the clipboard and report it — consistent with the same
behaviour in SelectSimilarType, SelectSimilarAggregate, SelectIfcClass,
and SelectSimilarMaterial.

Generated with the assistance of an AI coding tool.
2026-05-30 17:41:50 -05:00
Ryan Schultz fd96e6a4d2 Fix #8128: Fix filter_elements skipping groups after a zero-result facet_list
When a `+`-separated filter group returns no results, `FacetTransformer.facet_list`
was skipping the reset of `has_additive_facet_in_current_list` because the reset
was inside the `if self.elements:` guard. The stale flag caused the next group's
`add_default_elements()` to bail out early, leaving its element set empty and
silently dropping every subsequent group from the result.

Move the flag reset outside the guard so it always fires regardless of whether
the group produced any results.
2026-05-30 16:28:14 -05:00
Ryan Schultz 3dd3a0d70c Closes #8127: Add imperial location display to Placement panel
In the Placement panel, show Location and Rotation X/Y/Z
each on their own row beneath a header label. When the IFC
file uses imperial units, display a read-only feet-and-inches
label alongside each Location input field.

Generated with the assistance of an AI coding tool.
2026-05-30 14:08:53 -05:00
Ryan Schultz 2e5995176a Format stair lengths using IFC length unit
Display general and calculated stair parameters (Width,
Height, Tread Run, Tread Rise, Length, etc.) formatted
to the IFC file's configured length unit rather than
raw numeric values.

Generated with the assistance of an AI coding tool.
2026-05-30 12:03:50 -05:00
Gorgious56 2114c1d5d0 Add wall-fillet feature: operators, gizmos, decorator
End-to-end fillet flow on top of the helpers + recreate_wall hook
(landed in the previous commit). Users select two LAYER2 walls, click
the fillet entry icon, drag the live radius widget, and validate to
replace the corner with a curved LAYER2 corner wall (banana body).

Operators (5):
* EnableWallFilletPreview: 2-wall selection → validates LAYER2 +
  straight axis + zero-slope + intersect-or-joined state → seeds the
  preview props with a default radius computed from the shorter
  available leg.
* FinishWallFilletPreview: dispatches CreateWallFillet with the tuned
  radius; clears preview state on FINISHED, preserves it on failure so
  the user can re-tune without re-selecting.
* CancelWallFilletPreview: clears preview state, no IFC mutation.
* EnableWallFilletPreviewFromCorner: pen-icon re-edit on an existing
  fillet corner — pre-fills the preview from the corner's BBIM_Wall
  pset + walks the inverse graph to recover wall A and wall B.
* CreateWallFillet: deletes any prior corner + A↔B path connection,
  shortens A and B to the tangent points, instantiates a corner wall
  from A's type, unassigns the swept-layer material/type (the explicit
  banana body MUST own its geometry), assigns the dominant material,
  rebuilds the body, sets a straight 2-point chord axis, stores
  BBIM_Wall.IsFilletCorner+FilletRadius, reconnects A and B to the
  corner with NOTDEFINED on the corner's side.

Gizmo groups (2 new + entry icon on existing):
* GizmoWallFilletPreview: visible while a preview is active. Bundles
  a radius_dim widget at the arc apex, a trim_dim widget along wall A
  expressing the same DOF via the leg setback distance
  (trim = |radius| * tan(sweep/2)), and validate / cancel icons
  anchored above the apex in screen-up.
* GizmoWallFilletReedit: pen-icon entry on an existing fillet corner
  wall (single-selection, BBIM_Wall.IsFilletCorner set, both neighbour
  connections present). Mutually exclusive with an active preview.
* GizmoWallJoinIntersection now stacks a fillet entry icon
  (VIEW3D_GT_fillet → bim.enable_wall_fillet_preview) above the
  existing join/unjoin icon in the joined and intersect state branches.

Property + decorator infrastructure:
* prop.py: BIMWallFilletPreviewProperties (Scene-level draft) +
  BIMPreviewProperties umbrella with only the wall_fillet pointer.
  The umbrella is the seam preview_base.py (landed in PR3) already
  reads via getattr(scene, "BIMPreviewProperties", None).
* decorator.py: _stroke_lines_alpha helper + WallFilletPreviewDecorator.
  Polls is_active; renders leg projections + arc + arc-center
  construction lines from tool.Wall.compute_wall_fillet_geometry.
* __init__.py: registers operators + gizmo groups + property groups +
  wires Scene.BIMPreviewProperties.
* handler.py: WallFilletPreviewDecorator.install/uninstall in
  _install_decorators — always installed, self-polls on is_active.

Drive-by: extract gizmo.get_screen_up(billboard_rot) helper —
the local +Y of a billboard rotation is the camera's screen-up world
direction. Replaces 4 inline `billboard_rot @ Vector((0.0, 1.0, 0.0))`
sites added across the fillet feature's gizmo groups.

Generated with the assistance of an AI coding tool.
2026-05-30 13:04:49 +02:00
carlopav 3f680f5c21 IfcCostSchedule PDF export with typst: fix bugs
Fixed a bug when a summary cost has no sum applied.
Added Currency in table header.
Cleanup.
Added guards for end summary.
2026-05-29 18:38:16 +02:00
Gorgious56 97e8deb069 Cache opening previews + dissolve fill
DecorationsHandler now caches dissolved edges (mesh-keyed), world-space
draw payload, and GPUBatch objects with per-object epoch invalidation —
moving one wall doesn't wipe 50 opening caches. Object-mode dissolve
removes triangulation noise; 2-pass depth-test split dims occluded lines
instead of hiding them. Edit-mode behavior unchanged.

Also: disable viewport shadows for IfcFeatureElementSubtraction objects,
and wire DecorationsHandler.uninstall() into the model module's
unregister() so the new persistent handlers don't leak on addon disable.

Generated with the assistance of an AI coding tool.
2026-05-29 12:16:49 +02:00
Ryan Schultz 6ba5f5af3d Fix CardinalPoint not applied to all selected objects
EditAssignedMaterial propagated layer set usage attributes
to all selected objects but skipped this loop for profile
set usage. Add the same loop so CardinalPoint and
ReferenceExtent are copied to each selected object's
IfcMaterialProfileSetUsage on save.

Generated with the assistance of an AI coding tool.
2026-05-28 21:21:16 -05:00
Ryan Schultz 335ee1a1bb Fix negative zero in imperial feet-inches parser
When the user enters `-0' - 10"`, Python parses feet as -0.0.
The check `feet < 0` is False for negative zero, so the sign was
silently dropped. Use math.copysign to detect it correctly.

Generated with the assistance of an AI coding tool.
2026-05-28 12:14:23 -05:00
Gorgious56 49348908e6 Add wall-fillet helper functions + recreate_wall hook
Eleven module-level helpers in wall.py that the upcoming wall-fillet
operators + gizmo groups depend on. Each is self-contained or
references only helpers earlier in the file; the operators and
gizmos themselves land in follow-up commits.

* _wall_fillet_props / _wall_fillet_preview_active /
  _wall_fillet_preview_walls: thin read-side accessors over the
  BIMPreviewProperties.wall_fillet pointer (added with the
  operators commit). Safe today: get_preview_props returns None
  until the pointer is attached.
* _walls_have_zero_slope_for_fillet: validates that input walls
  are vertical (x_angle ~ 0); slanted-extrusion fillets require
  swept-along-curve geometry the banana profile builder doesn't
  support.
* _build_curved_corner_body_representation: builds the banana
  (annular sector) IfcExtrudedAreaSolid as a polyline-tessellated
  IfcIndexedPolyCurve.
* _apply_fillet_corner_geometry: positions the corner wall at
  tangent_a and rebuilds its body. Shared by the creation operator
  and the regenerate path.
* _resolve_two_walls: pulls (active, other) from a 2-wall
  selection, validates both as LAYER2 + straight-axis + not-already-
  a-fillet-corner.
* _pick_dominant_wall_material: returns the thickest layer's
  material from an element's IfcMaterialLayerSet / Usage.
* regenerate_fillet_corner_wall: re-runs the geometry build from
  BBIM_Wall.FilletRadius + current neighbour layer parameters.
  Called by tool.Model.recreate_wall when the IsFilletCorner pset
  is set; the FIXME(PR4) placeholder in recreate_wall is dropped.
* _wall_fillet_gizmo_x_matrix: 4x4 placement matrix with local +X
  aligned to a world-space direction; used by the fillet preview
  gizmo group.

Centralises the IsFilletCorner pset read as
tool.Parametric.is_fillet_corner_wall — replaces 3 inline
get_pset(element, "BBIM_Wall", "IsFilletCorner") sites
(tool.Model.recreate_wall, tool.Model.recalculate_walls,
tool.Parametric.is_path_connectable_wall) plus the new
_resolve_two_walls call.

Generated with the assistance of an AI coding tool.
2026-05-28 16:42:42 +02:00
Gorgious56 c250b2c1a7 Gate parametric-edit array gizmo until integration completes
The framework's parametric-edit icon row currently binds an array
icon to bim.add_array_from_feature_edit, but the supporting per-
feature add-array flow and gizmo positioning haven't fully landed.
Showing the icon today lets the user click it and trigger a half-
wired flow.

Force the icon hidden inside the props.is_editing branch of
BaseParametricGizmoGroup.update_editing_gizmos. The else-branch
(not editing) already hides it, so this just mirrors that behavior
during edit mode. Drop this gate when array integration completes
to re-enable the icon position + visibility plumbing.

Generated with the assistance of an AI coding tool.
2026-05-28 15:30:46 +02:00
Gorgious56 6874d52100 Add cursor-aware extend-arrow flip on wall edit gizmos
The extend-X / extend-Z icons in GizmoWallEdition's cursor row are
billboarded toward the camera; without orientation polish they
always point in the same screen-space direction regardless of which
wall endpoint the click will move (or whether the cursor sits above
or below the wall top). New helper mirrors the icon's local-X (extend-X)
or local-Y (extend-Z) axis so each arrow points toward the end it
will move:

* Extend-X: walk wall midpoint to figure out which endpoint stays
  fixed (cursor past midpoint → ATSTART stays; cursor before midpoint
  → ATEND stays). Project the fixed endpoint into screen-space and
  flip the arrow when the gizmo's anchor sits on the same side.
* Extend-Z: flip when the cursor is below the wall top (within
  EXTEND_FLIP_EPSILON tolerance).

Called once per resolved cursor gizmo from
``GizmoWallEdition._update_cursor_gizmos``, after the gizmo's
``matrix_basis`` is set by ``gizmo.billboarded_at``. Reuses
``gizmo.should_flip_extend_arrow`` + ``EXTEND_FLIP_MIRROR_X/Y`` +
``EXTEND_FLIP_EPSILON`` already on tool.

Generated with the assistance of an AI coding tool.
2026-05-28 15:14:51 +02:00
Gorgious56 6c21e2b6f4 Add single-wall unjoin operator + gizmo group
GizmoWallJoinIntersection's unjoin only fires when exactly two walls
are selected and surfaces one icon at their shared corner — useless
when the wall has 3+ joins and the user wants to disconnect just one.

* UnjoinWallPathConnection: surgical counterpart to UnjoinWalls.
  Disconnects the active wall from a single partner wall identified
  by IFC GlobalId (invariant under Blender-object renames + file
  save/reload + undo). Walks both inverse arrays of the active wall
  for the specific IfcRelConnectsPathElements joining the pair —
  matches DumbWallJoiner.split's pattern and avoids disconnect_path's
  direction-sensitivity. Resyncs both walls' draft props after the
  recreate_wall pass.
* GizmoWallUnjoinSingle: activates on exactly-one selected
  LAYER2 wall. Preallocates a pool of 16 unjoin icons (Blender forbids
  gizmo allocation outside setup(); ATSTART + ATEND + ATPATH rels are
  rarely more than a handful). Per-frame, iterates _iter_path_connections,
  positions one billboarded icon at each join via
  tool.Wall.path_connection_location_world, and hides the rest. Each
  visible icon's bound operator carries the partner GlobalId, so a
  click removes only that one rel.
* model/__init__.py: register both classes alphabetically.

Mutually exclusive with GizmoWallJoinIntersection via poll() — that
group requires len(selected) == 2; this one requires 1.

Generated with the assistance of an AI coding tool.
2026-05-28 15:05:23 +02:00
Gorgious56 70845e4dd4 Add wall path-connection inverse-walk helpers
The single-wall unjoin gizmo needs to enumerate every
IfcRelConnectsPathElements a wall participates in, regardless of which
side of the rel the wall was authored on, and place an icon at each
join's physical location. Two helpers carry that work:

_path_connection_location_world wraps core.compute_path_connection_location
at the Vector boundary. _iter_path_connections walks ConnectedTo +
ConnectedFrom, normalises orientation to (other, self_ct, other_ct),
and filters non-wall partners + None refs so per-frame gizmo positioning
survives malformed IFC.

Generated with the assistance of an AI coding tool.
2026-05-28 14:23:35 +02:00
Gorgious56 d7b5ac1453 Add wall draft-resync helper + wire 6 mutation operators
After a one-shot wall IFC mutation (unjoin / split / merge / extend /
join-at-corner …) the always-visible gizmos on the OTHER side of the
join can be left reading stale ``BIMWallProperties`` — the IFC
geometry moved but the draft props that drive the gizmo handles still
point at the pre-mutation numbers, so a subsequent edit-mode enter
shows the wall at its old length / position.

* New ``_maybe_resync_wall_props_from_ifc(obj)``: re-primes a single
  wall's draft props from current IFC, with guards for non-walls,
  non-parametric walls, and walls in an active draft session (the
  draft is then the source of truth, not IFC). Must run from an
  operator ``_execute`` — ID writes from gizmo refresh raise.
* New ``_resync_walls_after_mutation(objs)``: iterates the above
  across a selection.
* Six existing mutation operators gain a resync call after their
  ``core.*`` / ``DumbWallJoiner`` mutation completes:
  UnjoinWalls, ExtendWallsToUnderside, ExtendWallsToWall, SplitWall,
  MergeWall, JoinWallsIntersection. MergeWall resyncs only the
  surviving wall — the active wall is the deletion target.

Generated with the assistance of an AI coding tool.
2026-05-28 14:06:11 +02:00
Gorgious56 1961cd905e Fix parametric framework live-session regressions
Bundle of bugs surfaced when exercising the new gizmo framework
end-to-end in a live Blender session after the
bim/module/drawing/gizmos.py refactor + TypeAccessor/CycleType/PickType
mixins landed.

Register / annotation resolution
* parametric_lifecycle.py: hoist `entity_instance` import out of
  TYPE_CHECKING so typing.get_type_hints resolves the
  Callable[[entity_instance], bool] annotation at operator registration
  (CycleDoorType, CycleWindowType, CycleStairType failed with NameError).
  Clarify the INTERFACE return contract on the picker entry-point so
  readers see why the gizmo step stays off the undo stack.

Framework callable contracts
* model/wall.py, door.py, window.py, stair.py: migrate `props_getter`
  and `element_checker` from bl_idname strings to bound classmethods
  on tool.Model / tool.Parametric. BaseParametricGizmoGroup.get_props
  expects a callable; the string form raised TypeError on first
  gizmo poll.
* model/door.py, model/stair.py: drop the dead `prop_path=` operator
  kwarg from create_arc_gizmo / create_icon_gizmo call sites. The
  framework helper blindly setattrs every kwarg onto the operator's
  OperatorProperties, but ToggleDoorSwing / ToggleStairProperty don't
  declare prop_path — the setattr raised mid-setup_element_specific_gizmos,
  so self.gizmo_door_type / self.lock_gizmo never got assigned and
  every subsequent draw_prepare tornadoed AttributeError. Nothing
  reads op.prop_path anywhere; the kwarg was dead data.

Dispatcher operators
* model/array.py: add EnableEditingParametric (the framework pen-icon
  dispatcher that routes to a per-feature edit operator by bl_idname
  string) and AddArrayFromFeatureEdit (binds the framework's array
  icon to bim.add_array on the current parametric draft).
* model/__init__.py: register both new operators.

Per-frame robustness
* drawing/gizmos.py: guard BaseParametricGizmoGroup.draw_prepare with
  is_setup_complete() — matches the existing guard in refresh() and
  in BaseSchematicGizmoGroup.draw_prepare(). Defense-in-depth: when
  any subclass's setup raises mid-way, draw_prepare now no-ops cleanly
  instead of per-frame AttributeError-tornadoing on whatever attribute
  the failed setup phase was meant to populate.
* model/decorator.py: guard ProfileDecorator.__call__ against
  context.active_object is None. The decorator is a per-frame
  viewport draw handler; deselecting or deleting the active object
  while it's installed crashed on obj.mode access. Treat None the
  same as "no longer in edit mode" — uninstall + fire the exit
  callback if present.
* geometry/data.py: ViewportData.load() populates `data` before
  flipping `is_loaded`, so a raise from cls.mode() no longer leaves
  the class flag-set but data-empty for subsequent reads.

Generated with the assistance of an AI coding tool.
2026-05-28 13:48:15 +02:00
Gorgious56 f1cf757ba2 Refactor bim/module/drawing/gizmos — framework + icon infra
Three concerns bundled into one cohesive refactor of gizmos.py
(splitting them surgically requires intermediate commits with
duplicate same-named classes that Python can't parse):

1. Framework primitives — StaticTrisGizmoMixin + TexturedQuadGizmoMixin
   replace the older TrisGizmoMixin. New module-level helpers:
   _get_static_tris_shader / _get_static_tris_batch / clear_static_
   tris_cache for cached GPU batch reuse, _draw_outline_and_body for
   the shared outline-then-body render path, draw_tris_with_outline
   as the public wrapper. billboarded_at(world_pos, billboard_rot,
   scale) is the canonical billboard-matrix helper; should_flip_extend_
   arrow encapsulates the view-aware mirror decision for extend
   gizmos; get_warning_color_from_prefs reads the user's warning
   color.

2. Config classes — BaseValueGizmoConfig (shared visibility + dimension-
   text contract), CountGizmoConfig (array N indicator),
   DimensionGizmoConfig (length / height / depth labels), IconActionConfig
   (icon-only gizmos that invoke an operator on click). DimensionRenderer
   draws the actual numeric label using BLF.

3. Icon classes — each rewritten on StaticTrisGizmoMixin so they share
   the cached GPU batch + outline-then-body render path:
   GizmoLockOpen / GizmoLockClosed (replacing the single-state
   GizmoLock), GizmoArc, GizmoFillet, GizmoWallCornerIcon,
   GizmoWallTeeIcon, GizmoPen / GizmoValidate / GizmoCancel (the
   parametric-edit triad), GizmoPlus / GizmoMinus / GizmoTrash,
   GizmoArrayParent / GizmoArrayAll / GizmoArrayLayerIndicator (array
   context indicators with a small digit-rendering helper for the "xN"
   count label), GizmoMerge / GizmoSplit / GizmoUnjoin (wall-join
   icons), and GizmoMenu (textured-quad icon-action menu trigger).

The legacy TrisGizmoMixin, GizmoLock, and DimensionDrawConfig are
removed; downstream callers in subsequent PR4 commits swap to the
new mixin and config classes when their feature operators land.

CycleTypeMixin / PickTypeMixin / TypeAccessorBase live in
bim.parametric_lifecycle (previous commit). The three mixins are
re-exported from gizmos.py here so feature-module access via
``gizmo.<MixinName>`` keeps working until PR5 cleanup drops the
re-exports.

bim/module/drawing/__init__.py is updated in the same commit to
register the 11 new gizmo classes (GizmoLockOpen / GizmoLockClosed /
GizmoFillet / GizmoWallCornerIcon / GizmoWallTeeIcon / GizmoTrash /
GizmoArrayParent / GizmoArrayAll / GizmoArrayLayerIndicator /
GizmoUnjoin / GizmoMenu) — without that, the new classes exist in
gizmos.py but aren't usable as bpy gizmo types.

Generated with the assistance of an AI coding tool.
2026-05-27 23:56:28 +02:00
Gorgious56 b039e12623 Add TypeAccessorBase + CycleTypeMixin + PickTypeMixin
Three operator mixins for type-selection ops on parametric features
(door type-cycle, window type-pick, stair type-cycle, railing
type-pick, roof type-cycle, etc.). Each shares the same contract:

* ``element_checker`` validates the active object is the expected
  IFC type
* ``props_getter`` resolves the BIM<Name>Properties group
* ``type_literal`` is the Literal type whose args drive the enum
* ``type_attr`` is the PropertyGroup field to read/write
* ``skip_element_check=True`` bypasses element validation (for
  operators that target a non-IFC context)

CycleTypeMixin shift-click reverses direction (forward by default).
PickTypeMixin opens a popup menu and routes the picked value
through execute() so F6 redo / EXEC_DEFAULT reach the apply path.
The PickType modal-handler dance waits for LEFTMOUSE release before
opening the menu when invoked mid-click (e.g. from a gizmo's
target_set_operator) so Blender's drag-through-pick gesture doesn't
commit an accidental item.

Ships standalone — the next commit's gizmos.py framework refactor
re-exports these names from bonsai.bim.parametric_lifecycle so
gizmo modules can spell ``gizmo.CycleTypeMixin`` / ``gizmo.PickTypeMixin``.
Concrete operator subclasses land in subsequent PR4 commits per
feature (door / window / stair / railing / roof).

Generated with the assistance of an AI coding tool.
2026-05-27 23:06:42 +02:00
Gorgious56 1325705d8e Merge pull request #8112 from Gorgious56/bonsai/parametric-framework-infra
Decorator cache + parametric lifecycle drift triad + wall split fixes
2026-05-27 21:43:51 +02:00
Gorgious56 cb2f20b2b6 Add tests for decorator_cache + undo-resync dispatch
Two paired test files for the framework infrastructure landed
earlier in this PR.

test_decorator_cache.py (11 tests):
* The 4-hook invalidation list (depsgraph_update_post + undo_post +
  redo_post + load_post) is symmetrically managed by
  install_decorator_cache_handlers / uninstall_decorator_cache_handlers.
  A future edit that drops a hook from one side without the other
  would land as a Blender segfault when a cached bpy.types.Object
  ref outlives its underlying ID block — the regression must surface
  as a test failure first.
* install is idempotent (calling twice doesn't double-register).
* uninstall when not installed doesn't raise.
* The bump handler accepts Blender's variadic args.
* The depsgraph predicate gates correctly: bumps on Object geometry
  or transform updates, silently skips on Material / NodeTree / Image
  updates (which would otherwise rebuild every cache on every node
  edit).
* TokenCache.get_or_compute short-circuits on key+token match and
  recomputes when the token bumps.

test_undo_resync_parametric_drafts.py (3 tests):
* UNDO_REGENERATORS keys must all be in tool.Parametric.EDIT_TYPES.
  A typo would silently no-op on Ctrl+Z, restoring the desync the
  helper is meant to prevent.
* The dispatcher skips objects with no active parametric edit
  (undo_post fires for every undo, most of which touch zero drafts).
* The dispatcher silently skips parametric types that have no
  UNDO_REGENERATORS entry (door / window / array are IFC-derived
  with no draft preview mesh — they don't need a regenerator).

Mocks use spec=bpy.types.Depsgraph / spec=bpy.types.DepsgraphUpdate
/ spec=tool.parametric.ParametricObject so typos in mocked-attribute
access fail loudly (CLAUDE.md test discipline).

Generated with the assistance of an AI coding tool.
2026-05-27 21:28:41 +02:00
Gorgious56 f41f5dfdd8 Fix wall split: preserve door/window fill rel
Splitting a wall through a door orphaned the door (door.FillsVoids
became empty). The fill rel was being reassigned by setting its
RelatedBuildingElement slot — schema-wise that's the filling slot, not
the wall slot — so when remove_feature deleted the old opening it
also cascade-removed the rel. Transferring via RelatingOpeningElement
keeps the rel pointing at the new opening so the door stays
associated. Pre-existing bug from 5a6476a57, surfaced by ef144dce2.

Generated with the assistance of an AI coding tool.
2026-05-27 21:28:41 +02:00
Gorgious56 1855e4c019 Fix wall split: keep straddling openings on both walls
DumbWallJoiner.split assigned openings by projecting the opening's
centre-point onto the wall axis, so any opening whose footprint
straddled the cut was silently dropped from whichever wall its centre
missed. Now the full axis-projected extent (via ifcopenshell.geom.
create_shape) drives the assignment; for filled openings whose void
straddles the cut, a pure-void copy is added back to the neighbour
wall so its body is also cut.

Generated with the assistance of an AI coding tool.
2026-05-27 21:28:41 +02:00
Gorgious56 2feade01cb DRY tag-redraw-3D-viewports loops via tool.Blender.update_all_viewports
Five inline copies of the same defensive pattern lived across
``tool/parametric.py``, ``bim/parametric_lifecycle.py``,
``bim/module/model/preview_base.py`` (twice), and as a near-twin
in ``tool/blender.py:update_all_viewports`` itself.

``tool.Blender.update_all_viewports`` already covered the
``tag_redraw`` job but used an ``assert context.screen`` that would
raise during background-mode operators or early-load_post calls
where ``screen`` legitimately is None. Relax to a defensive
``getattr(context, "screen", None)`` + silent return so the helper
fits every caller's needs, then collapse the 4 inline copies to
single calls.

Net -9 LOC. The helper now describes its contract ("silent no-op
when no screen attached") rather than naming specific callers, so
moving a caller doesn't rot the docstring.

Generated with the assistance of an AI coding tool.
2026-05-27 21:28:41 +02:00
Gorgious56 ff4c642db1 Add parametric-draft undo-resync registry
Ctrl+Z / Ctrl+Shift+Z on an in-progress parametric draft (wall /
stair / roof) used to leave the preview mesh frozen in its
pre-undo shape — the IFC mutation rolls back but the bmesh built
from draft props doesn't repaint.

Add a registry of per-type regenerator functions
(``UNDO_REGENERATORS``) that re-build each type's preview mesh
from its current props. The dispatcher
``resync_parametric_drafts_after_undo`` walks all objects, skips
any without an active parametric edit, looks up the regenerator
by feature name, and calls it. Tagged 3D viewports for redraw.

Types without an entry (door / window / railing / etc.) are
intentionally absent — they're IFC-derived, so the undo's
representation rollback + next-frame refresh already repaints
correctly without a draft-side regenerator.

Undo/redo wiring is self-installed by
``bonsai.bim.parametric_lifecycle``: a ``@persistent``
``_resync_on_undo`` callback dispatches into the registry, and
``install_parametric_lifecycle_handlers()`` /
``uninstall_parametric_lifecycle_handlers()`` append/remove it
from ``bpy.app.handlers.undo_post`` and ``redo_post``.
``bim/__init__.py``'s ``register()`` calls the install function
*after* the central ``handler.undo_post`` / ``redo_post`` appends
so the regenerators see restored IFC state — ``bpy.app.handlers``
fire in append order. ``handler.py`` itself stays ignorant of the
parametric subsystem. The lazy function-local imports in each
regenerator break the addon-load cycle —
``bonsai.bim.parametric_lifecycle`` loads before
``bim/module/model/*``.

Generated with the assistance of an AI coding tool.
2026-05-27 21:28:20 +02:00
Gorgious56 e7e489e390 Refactor bim/parametric_lifecycle — drift triad + Cancel polish
Three changes to the shared Enable/Finish/Cancel mixins:

1. Always-on drift triad on ParametricEditMixinBase. The base now
   provides ``_handle_drift_on_enable`` / ``_handle_drift_on_finish``
   / ``_handle_drift_on_cancel`` classmethods, called from the
   per-mixin ``_enable_one`` / ``_finish_one`` / ``_cancel_one``.
   Pre-edit Blender-side translations commit to IFC on Enable
   (apply_scale=False — only translation/rotation, not the user's
   accidental scale), in-edit drag commits on Finish (apply_scale=True),
   and Cancel restores the committed IFC placement via
   ``restore_or_rebaseline_placement``. Prevents the
   "uncommitted drag disappears on Finish" and "preview snaps back
   on Cancel" UX bugs.

2. ``_ParametricEditMixinBase`` renamed to ``ParametricEditMixinBase``
   (public). Per-feature mixins that need to subclass directly
   (e.g., when neither FeatureModifier nor PathPreserving fits)
   can do so without reaching into a private name.

3. ``_update_modifier_bmesh`` (PathPreserving) renamed to
   ``_restore_viewport_after_cancel``. The old name was inaccurate
   for subclasses that load a different IFC representation on
   Cancel rather than rebuilding a bmesh preview from props.

Plus two polish changes:

* ``_mark_type_thumbnail_dirty`` helper on the base centralises the
  ``ifcopenshell.util.element.get_type`` + thumbnail-mark pattern
  that both mixins repeated inline.
* ``FeatureModifierEditMixin._cancel_one`` and
  ``PathPreservingEditMixin._cancel_one`` wrap the restore in
  ``try/finally`` so ``props.is_editing = False`` flips even on
  partial restore failure. Without this, a Cancel that raised
  mid-restore would leave the user locked out of the edit lifecycle.
* ``PathPreservingEditMixin._finish_one`` / ``_cancel_one`` skip the
  pset commit + viewport rebuild when the draft equals the stored
  pset (no-op Enable→Finish round-trip should not pollute the
  representation list or burn an undo entry).

``FeatureModifierEditMixin._finish_one`` now routes the pset commit
through ``tool.Pset.write_bbim_data`` instead of inlining the
``createIfcText(json.dumps(...))`` + ``ifcopenshell.api.pset.edit_pset``
dance. Two test assertions updated to match.

Generated with the assistance of an AI coding tool.
2026-05-27 15:51:37 +02:00
Gorgious56 5e23030a0f Decompose bim/handler.py load_post + install cache + discard hooks
Three concerns folded into ``load_post`` argue for separation:

1. Save-file invariants every load must re-establish (msgbus
   subscription, owner-settings, thumbnail cache, draft-flag healing,
   blend-warning flag, H5 lock probe).
2. User-preference-driven UI setup (toolbar, workspace, viewport
   shading, panel hijack, snap defaults).
3. Viewport overlay sync (every decorator's install/uninstall).

Pull each into its own function (``_apply_save_file_invariants`` /
``_apply_user_preferences`` / ``_install_viewport_overlays``). The
``load_post`` callback becomes a 3-line orchestrator. Each phase
is independently call-able from tests and from PR4 features that
need to re-trigger one phase without the others.

Two new hooks land with the decompose:

* ``tool.Parametric.heal_stale_edit_flags()`` + ``discard_pending_previews(scene)``
  fire in ``_apply_save_file_invariants``. The first clears
  object-level ``BIM<Name>Properties.is_editing`` flags that lost
  their backing IFC element across a load; the second clears
  scene-level ``BIMPreviewProperties.<x>.is_active`` so saved
  preview state never resurfaces with no UI to interact with it.

* ``install_decorator_cache_handlers`` / ``uninstall_decorator_cache_handlers``
  wrap the decorator install/install pass in
  ``_install_viewport_overlays``. The bump handlers append to
  ``depsgraph_update_post`` + ``undo_post`` + ``redo_post`` +
  ``load_post`` so the previous commit's ``TokenCache`` in
  ``tool.System.get_decoration_data`` finally invalidates on
  structural scene changes.

Generated with the assistance of an AI coding tool.
2026-05-27 15:28:18 +02:00
Gorgious56 c9f12dd441 Add bim/module/model/preview_base module
Shared helpers for Bonsai's Scene-level parametric preview flows.
Two PR4 features will consume this — MEP bend preview and wall
fillet preview — both following the same shape:

    Enable<X>Preview   — populates draft on Scene.BIMPreviewProperties.<x>
    Gizmo<X>Preview    — polls on is_active, surfaces tunable widgets
    <X>PreviewDecorator — GPU lines while is_active is True
    Finish<X>Preview   — bpy.ops.bim.<verb>(...) with draft kwargs
    Cancel<X>Preview   — pure state reset

The module hosts the cross-cutting accessors (``get_preview_props``,
``is_preview_active``), lazy-closure factories for gizmo dimension
callbacks (``make_props_callback`` / ``make_dim_getter`` /
``make_dim_setter`` — defensive against missing scene / freed RNA
struct on file open / undo), the Enable-time IFC-placement sync
(``sync_uncommitted_moves``), and the Esc + load_post discard
machinery (``PREVIEW_CANCEL_OPS`` registry, ``try_cancel_active_preview``,
``discard_pending_previews``).

Ships standalone — the consumer features land in PR4 (preview
PropertyGroups, Enable/Finish/Cancel operators, gizmo groups,
decorators, Esc keymap binding). All accessors are defensive
against missing PropertyGroups / operators on v0.8.0 — calling
``discard_pending_previews(scene)`` from the next commit's
load_post hook is a no-op until PR4 attaches BIMPreviewProperties.

Generated with the assistance of an AI coding tool.
2026-05-27 15:25:07 +02:00
Gorgious56 4b9ad66c95 Wrap tool.System.get_decoration_data with TokenCache lookup
System decoration draws on every viewport refresh — the
``_build_decoration_data`` body walks every distribution element,
resolves connected ports, builds the vert/edge arrays for the GPU
batch. A bare call per frame burns time on an unchanged scene.

Add a single-entry cache keyed on ``(decorator_cache_token,
id(decorated_elements_set))``. Reads short-circuit when neither
component moved:

* ``decorator_cache_token`` from ``bim.decorator_cache`` invalidates
  on depsgraph / undo / redo / load via the bump handler.
* ``id(decorated_elements_set)`` invalidates when
  ``SystemDecorationData.load()`` reassigns the set (e.g. when the
  user changes the set of decorated systems via the panel).

The handler that bumps the token is installed in the next commit
(bim/handler.py decompose). Until then the token stays at 0, so
the cache only hits when ``id()`` also matches — degraded behaviour
during the bisect window but not incorrect.

Generated with the assistance of an AI coding tool.
2026-05-27 14:55:44 +02:00
Gorgious56 d43a1353e0 Add bim/decorator_cache module — TokenCache + handler primitives
New helper module for POST_VIEW decorators. Exports:

* ``get_decorator_cache_token()`` — global int counter consumers
  include in their cache key so the value invalidates on structural
  scene changes.
* ``_bump_decorator_cache_token()`` — ``@bpy.app.handlers.persistent``
  callback that increments the token. Gates on the depsgraph payload
  so animation playback / driver evaluation doesn't churn the token.
* ``install_decorator_cache_handlers`` / ``uninstall_…`` — idempotent
  append / remove against depsgraph_update_post + undo_post + redo_post
  + load_post. Called once from ``bim.register`` / ``unregister``.
* ``TokenCache[T]`` — single-entry memoiser keyed on ``(caller_key,
  token)``. Cached ``bpy.types.Object`` references can't outlive the
  underlying ID blocks because any depsgraph / undo / load bumps the
  token and forces a recompute.

This commit ships the module standalone. The next commits in this
PR wire it: tool/system.py adds the cache wrap on get_decoration_data
and bim/handler.py installs the bump callbacks. Until both land,
the module is intentionally dead code — keeps the diff narrow and
the commit history bisectable.

Generated with the assistance of an AI coding tool.
2026-05-27 14:53:06 +02:00
Gorgious56 b1fa2407a9 Merge pull request #8109 from Gorgious56/bonsai/parametric-framework-slim
Extract parametric framework foundation into tool/ and core/
2026-05-27 14:46:59 +02:00
Gorgious56 786d3c8a89 Fix latent runtime bugs + ty annotations surfaced by CI
Five code paths in slim PR2 referenced symbols that don't exist in
v0.8.0's bim layer, raising at first call. Plus three type
annotations that ty flagged as unresolved.

1. tool/system.py:get_decoration_data — drop the cache layer that
   keyed on a token from a bim/decorator_cache.py module. The cache
   is dead-or-broken in slim: the depsgraph bump handler that would
   invalidate the token lives in PR3's bim/handler.py decompose, so
   the token stays at 0 forever. Either the cache never hits
   (decorated_elements rebuilt → new id() per call) or returns
   stale data (list reused). Revert to direct
   `_build_decoration_data()` calls. PR3 reintroduces the cache
   atomically: decorator_cache module + handler install + cache
   wrap + tests. Keeps `_build_decoration_data` extraction
   (cleaner than v0.8.0's monolithic version regardless of cache).

2. tool/spatial.py — add `get_host_element` + `get_host_wall`.
   The interface stubs in `core/tool.py:1037-1038` were declared
   but never implemented. `tool/duplicate.py:99` (object duplication
   with fills) and `tool/model.py:1260` (array per-child opening
   mirror) call these and would raise AttributeError.

3. tool/model.py:recreate_wall — drop the fillet-corner branch
   that function-locally imports `regenerate_fillet_corner_wall`
   from `bim/module/model/wall`. The function lands with PR4; fall
   through to the straight-extrusion path preserves v0.8.0
   behaviour for fillet walls until then. Tag FIXME(PR4).

4. tool/model.py — drop `get_pipe_segment_props` /
   `get_duct_segment_props` accessors. Their return types reference
   `BIMPipeSegmentProperties` / `BIMDuctSegmentProperties` which
   land with PR4's prop.py; calling either accessor on v0.8.0 would
   AttributeError on `obj.BIM<X>SegmentProperties`. Zero callers in
   slim — PR4 reintroduces both accessors together with the
   PropertyGroups they wrap. Also drops the matching TYPE_CHECKING
   imports.

5. tool/blender.py:557 — `Mapping[type[ViewportDecorator], bool]`
   needs the qualified `Blender.ViewportDecorator` because the
   annotation is on a method INSIDE the same nested class; the
   bare name doesn't resolve at type-check time.

6. core/tool.py Surveyor — drop the `obj: "bpy.types.Object"` /
   `z: float` / `-> float` / `-> None` annotations on
   `get_z_rotation` / `set_z_rotation`. The `@interface` decorator
   wraps each method as `classmethod(abstractmethod(...))` at
   import time, but ty doesn't track the wrap and flags every
   call site as `missing-argument` plus the `pass` body as
   `empty-body` against the declared return type, plus the
   `bpy.types.Object` forward-ref as `unresolved-reference`.
   Reverting to v0.8.0's untyped style (matching the sibling
   `get_absolute_matrix(cls, obj)` stub) clears six ty errors at
   the cost of zero runtime semantics — the abstract stubs only
   serve as registry markers, concrete `tool.Surveyor.*` carries
   the real signatures.

Generated with the assistance of an AI coding tool.
2026-05-27 14:38:37 +02:00
Gorgious56 89b7eff03e Add addon-load smoke test pinning register/unregister cycle
Surfaces any regression in:

* the modules dict in bim/__init__.py (added a folder, forgot the entry)
* PointerProperty wiring on bpy.types.{Scene,Object,...}
* registry-driven GizmoPreferences<Name> auto-registration in
  tool.Parametric.iter_gizmo_preference_classes
* bpy.app.handlers append/remove balance
* every register()/unregister() across the 45+ feature modules

as a single PASSED/FAILED test instead of the silent "addon failed to
enable" users encounter in a fresh Blender. Paired with the existing
test_parametric_registry.py contract tests, this catches both the
registry-shape regressions (operators/PropertyGroups/predicates) and
the registration-mechanics regressions (PointerProperty types not
registered before their owners).

Generated with the assistance of an AI coding tool.
2026-05-27 13:26:38 +02:00
Gorgious56 1c8fad3c13 Fix tool.Parametric to ship safely on v0.8.0 bim layer
Three corrective fixes folded into one commit. All surface as
addon-load / save-time exceptions on v0.8.0's bim layer because
PR2's tool.Parametric refactor over-committed to the PR4 contract.

1. iter_gizmo_preference_classes — the previous implementation
   returned only the shared GizmoPreferencesFeature class. v0.8.0's
   bim/ui.py declares PointerProperty fields ('door', 'window', ...)
   on GizmoPreferences that point at per-feature
   GizmoPreferences<Name> classes; those must be registered BEFORE
   GizmoPreferences itself. The shared-class-only return broke
   addon registration with:
      'door' PointerProperty could not register (see previous error)
   Restore the v0.8.0 per-feature lookup (iterate EDIT_TYPES, look
   up each GizmoPreferences<Capitalize(name)> on ui_module) and
   keep the shared-class lookup as forward-compat. Tag FIXME(PR5).

2. EDIT_TYPES — drop the array / pipe_segment / duct_segment
   entries from the registry. Their bim.finish_editing_<name>
   operators land with PR4. Registering them in PR2's EDIT_TYPES
   without the operators makes auto-commit-on-save dispatch a
   non-existent finish_op for any object whose
   BIM<Name>Properties.is_editing flag is True, raising:
      RuntimeError: 'bim.finish_editing_array' must be a registered
      tool.Ifc.Operator subclass for undo-safe IFC mutation
   PR4 re-adds the three entries together with their operators.
   Tag FIXME(PR4).

3. tool.Blender.Modifier shim block — upgrade the prose comment to
   a formal FIXME(PR5) marker so the PR5 cleanup sweep finds it via
   grep alongside every other tagged shim site.

Generated with the assistance of an AI coding tool.
2026-05-27 13:26:21 +02:00
Gorgious56 6ec8372378 Extract bim/ifc + tool/cad helpers referenced by PR2
Fixes addon-load ImportError that surfaces when tool/geometry.py
and tool/model.py (extracted in C8 / C9) reference symbols that
don't exist on v0.8.0:

* bim/ifc.py: get_cache_or_detect_lock — IfcStore.get_cache
  variant that tracks the multi-instance-cache-locked-by-other-
  process flag, sets it on PermissionError, clears it (along with
  the dismiss flag) on subsequent success. Used by
  tool.Geometry.* to gate IFC cache reads without crashing when
  another Blender instance holds the cache lock.
* tool/cad.py: WELD_TOLERANCE constant + paired CAD helpers
  (auto-detect-curves vertex precision, polyline normal helpers,
  etc.) used by tool.Model.* + by the parametric model operators
  that land in PR4.

Both modules had zero upstream commits since the gizmos-8088 fork
point — safe bulk extraction. PR4 has no caller-line work for
either file (the additions are pure additions, no existing API
removed); the v0.8.0 callers of get_cache_or_detect_lock and
WELD_TOLERANCE are the PR2-scope files that needed them.

Generated with the assistance of an AI coding tool.
2026-05-27 11:44:01 +02:00
Gorgious56 5dc7513de0 Add tool.Blender.Modifier backward-compat shims
The previous commit moved is_<type> predicates off tool.Blender.Modifier
onto tool.Parametric, and earlier C4 moved the Array helper bag off
tool.Blender.Modifier.Array onto tool.Array. PR4 will migrate every
caller; this commit keeps the OLD entry points alive as thin delegates
so PR2 ships without breaking ~30 caller sites that still spell the
old API in v0.8.0:

* tool.Blender.Modifier.is_door / is_railing / is_roof / is_stair /
  is_wall / is_window — delegate to tool.Parametric.is_<type>.
* tool.Blender.Modifier.Array.bake_children_transform / constrain_
  children_to_parent / get_all_children_objects / get_all_objects /
  get_children_objects / get_modifiers_data / remove_constraints /
  set_children_lock_state — delegate to tool.Array.<same name>.

These shims are removed in PR5's cleanup commit once PR4 has rewritten
the call sites in bim/import_ifc.py, bim/module/geometry/operator.py,
bim/module/geometry/data.py, bim/module/model/array.py + the per-feature
operators (door, wall, window, railing, roof, stair, ui).

Generated with the assistance of an AI coding tool.
2026-05-27 09:23:29 +02:00
Gorgious56 f37c77e80c Refactor tool.Parametric — feature registry + lifecycle hooks
tool.Parametric becomes the central registry for Bonsai's parametric
features (wall, slab, door, window, railing, roof, stair, plus
mep-segment variants). Each feature registers a ParametricObject spec
declaring its enable/finish/cancel op names, props accessor, regen
callback, and is_element_type predicate.

Public surface:

* tool.Parametric.WALL / SLAB / DOOR / WINDOW / RAILING / ROOF /
  STAIR / PIPE_SEGMENT / DUCT_SEGMENT — typed accessors per feature.
* tool.Parametric.is_wall / is_door / is_window / is_railing /
  is_roof / is_stair — element-type predicates that move off
  tool.Blender.Modifier into the parametric registry. The next
  commit adds backward-compat shims on tool.Blender.Modifier so
  v0.8.0 callers keep working.
* tool.Parametric.is_object_editing(obj) — returns the registered
  feature an object is currently editing, or None.
* tool.Parametric.run_bim_op(op_name) — invoke a parametric op by
  bl_idname.
* tool.Parametric.heal_stale_edit_flags — clear is_editing flags
  on file load so a saved-mid-edit project doesn't leave gizmos
  poll-locked.
* supports_build_edit_lifecycle field on ParametricObject — declares
  whether the feature implements the build/edit/cancel triad.

The previous bare `print(f"Bonsai: commit of {obj.name!r} via
{finish_op} failed: {e}")` exception-handler is replaced with
logger.warning(..., exc_info=True). Same channel (Bonsai configures
logging to the Blender console at WARNING level), strictly more
information (full traceback), correct idiom for an error-path
message. A second logger.warning is added for parametric predicate
failures, also exception-handler scope.

Generated with the assistance of an AI coding tool.
2026-05-27 09:21:39 +02:00
Gorgious56 db9d903650 Polish tool.Model + tool.Pset + add tool.Slab service
tool.Model gains:

* get_pipe_segment_props / get_duct_segment_props — typed prop accessors
  for the MEP-segment edit lifecycle.
* resolve_active_props_for_edit — picks the right BIM*Properties to
  drive a parametric edit triad based on the active object's IFC class.
* mirror_parent_void_fillings_to_children — when an array parent has
  hosted fillings (door/window in a wall), replicate the same fill
  rels onto each array child. Uses tool.Array.get_parametric_propagation_
  targets so the propagation stays within the array family (the old
  get_all_element_occurrences over-propagated to standalone occurrences
  of the same type, which silently mutated unrelated arrays).
* unshare_opening_representation — fork a shared IfcShapeRepresentation
  so editing one opening doesn't mutate its array sibling.
* duplicate_ifc_objects gains a post-condition select-restore on the
  array parent so callers don't get a deselected parent for N>=2 arrays.

sync_object_ifc_position is kept as a thin delegate to
tool.Geometry.commit_placement_if_moved (the new home, added in C8) so
the 6 v0.8.0 callers in mep / product / system don't AttributeError;
PR4 migrates each caller and removes the delegate.

tool.Pset gains:

* upsert_pset — get-or-add-or-edit in one call.
* write_bbim_data — JSON-encode + write BBIM_* metadata in one call.

tool.Slab is new — slab-specific reads (active extrusion, axis
direction) used by the slab gizmos, pure-IFC, no PropertyGroup mutation.

Generated with the assistance of an AI coding tool.
2026-05-27 00:14:51 +02:00
Gorgious56 3483683cb4 Add tool.Geometry helpers for body representation + placement
Adds:

* get_body_representation(element) — DRY of the repeated
  ifcopenshell.util.representation.get_representation(element, "Model",
  "Body", "MODEL_VIEW") call across slab / wall / opening / stair /
  roof / door / window / mep. One central place to read the body rep;
  every caller stops re-spelling the four magic strings.
* has_axis_representation(element) — predicate for elements with a
  GRAPH_VIEW Axis representation. Used by the wall/MEP path decorators
  to skip elements without an unambiguous 1D path.
* has_material_styles(element) — predicate for whether the element
  carries IfcStyledItem material assignments.
* restore_placement_from_ifc(obj, element) — snap obj.matrix_world back
  to element's committed IFC placement + rebaseline the drift checksum.
* restore_or_rebaseline_placement(obj, element) — Cancel-flow helper:
  restores if ObjectPlacement exists, just rebaselines the checksum if
  not.
* detach_representation(product) — remove the active representation
  from a product without deleting the entity (used by parametric
  rebuilds that wipe + re-add).

commit_placement_if_moved docstring expanded with a "drop-in scope"
note so callers don't redundantly wrap it in an is_moved check that
the helper already does.

Switches the duplicate-aware helper calls (formerly tool.Root.*) to
tool.Duplicate.* now that the service exists (C6).

Generated with the assistance of an AI coding tool.
2026-05-27 00:04:03 +02:00
Gorgious56 a0c6f6f9a6 Extend tool.Blender for parametric framework + decorators
Adds:

* ViewportDecorator base class — install/uninstall/draw lifecycle for
  3D viewport gpu overlays, with handler-rollback-on-failure so a
  partial install can't leave dangling draw handlers.
* sync_all classmethod — drive each listed ViewportDecorator subclass
  to its desired install state in one call.
* is_view_top_down + top_down_factor — viewport-camera orientation
  predicates used by gizmo billboarding and decorator layout.
* get_screen_up_world — screen-up vector in world space for gizmo
  text orientation.
* are_viewport_gizmos_enabled — central gate for the global
  draw_gizmos_in_3d_viewport pref, replacing duplicated prefs reads.
* DecoratorColors NamedTuple + get_decorator_colors — single source
  for the colour palette every viewport decorator binds.

Preserves Ryan Schultz's add_layout_hotkey_operator polish (719309571,
2026-05-25): the row-position move + separator(factor=1) between the
modifier and key icons stay intact in this extraction.

Generated with the assistance of an AI coding tool.
2026-05-27 00:00:53 +02:00
Gorgious56 49ddda6281 Add tool.Duplicate service
Extract the duplicate-aware relationship-walk + restoration logic
(get_decomposition_relationships, get_connection_relationships,
get_port_connection_relationships, recreate_decompositions,
recreate_connections, recreate_port_connections, consume_warnings)
out of tool.Root into its own service.

tool.Root's responsibility is identity and addressing of IFC roots;
the duplicate-aware bookkeeping of "before duplication, what relations
did this graph have, and how do I restore them on the new copies?"
deserves its own home. The split was already declared on core/tool.py
(C2); this commit lands the concrete tool.Duplicate implementation.

tool.Root keeps its own copies of the methods on v0.8.0's tool/root.py
during this PR so callers in bim/module/spatial/operator.py keep
working at runtime; the Root cleanup lands in PR4 alongside the
caller updates.

Generated with the assistance of an AI coding tool.
2026-05-26 23:48:11 +02:00
Gorgious56 96b6985960 Extend tool.System with port + path helpers
Adds:

* direction_from_port_pair(port_a, port_b) — derive the connect_port
  direction kwarg from each port's FlowDirection (NOTDEFINED for
  non-canonical pairs). Centralises a pattern that callers were
  inlining inconsistently.
* tool.System.walk_connected_mep_elements — BFS over connected MEP
  flow elements via IfcRelConnectsPorts.
* tool.System.get_port_world_position — port placement → world-space
  Vector, used by the MEP path decorator.
* tool.System._build_decoration_data — cached decoration metadata
  for the MEP system-path overlay.

Plus a get_port_relating_element return-type tightening (Union with
None) and a partial-init cycle workaround on bim.module.system.data
imports (now function-local — top-level import triggered the cycle
through tool.Ifc.Operator).

Generated with the assistance of an AI coding tool.
2026-05-26 23:45:14 +02:00
Gorgious56 b19b2ac7cd Add tool.Array service
Top-level array-domain service extracted out of tool.Blender.Modifier.Array.
Owns the BBIM_Array pset graph navigation (constrain_children_to_parent,
remove_constraints, get_modifiers_data, get_children_objects,
get_all_children_objects, get_child_layer_index, bake_children_transform),
plus the Blender-side CHILD_OF constraint lifecycle that ties each child
replica to its parent's transform.

Array's own module gives the parent/child semantics a clean home — array
behaviour was previously scattered between tool.Blender.Modifier and ad-hoc
helpers in bim/module/model/array.py. The relocation eliminates the inline
duplication and gives Bonsai callers a single import surface.

Generated with the assistance of an AI coding tool.
2026-05-26 23:41:56 +02:00
Gorgious56 fdf4b82371 Add tool.Wall service
Bpy-permitted wall reads — get_axis_local_extent, get_length_and_height,
get_x_angle, get_path_connection_location, walk_connected_walls — used
by gizmo lambdas that need wall dimensions and join topology without
the side effect of loading the wall's draft BIMWallProperties (the
loader mutates PropertyGroup state and would clobber the wall's own
gizmo state when both the wall and a hosted filling are selected).

All reads go through ifcopenshell.util.representation / .util.element
so the IFC graph stays the source of truth. tool.Wall consumes
core.model's PARALLEL_DOT_THRESHOLD + collinearity helpers (no inline
magic numbers).

Generated with the assistance of an AI coding tool.
2026-05-26 23:40:19 +02:00
Gorgious56 2f40441f1c Add tool.* interface stubs to core.tool
Declares the bpy-free contract for tool services landing in subsequent
commits — tool.Wall, tool.Array, tool.System, tool.Duplicate (extracted
from tool.Root), tool.Parametric, plus minor additions on existing
interfaces (tool.Spatial.get_host_element / get_host_wall,
tool.Geometry.has_axis_representation / has_material_styles,
tool.Surveyor.get_z_rotation / set_z_rotation).

The @interface declarations are empty-bodied; concrete implementations
land in the per-service tool/* commits below. Keeping the contract in
core lets core/* helpers and tests reference the surface without
importing the concrete tool modules.

Moves get_decomposition_relationships + recreate_decompositions off
tool.Root onto the new tool.Duplicate (extraction of duplicate-aware
behaviour into its own service).

Generated with the assistance of an AI coding tool.
2026-05-26 23:31:28 +02:00
Gorgious56 230cbe1fd8 Add core/model.py constants + core/product.py helpers
core/model.py gains:

* Three calibrated dot-product / distance thresholds — PARALLEL_DOT_THRESHOLD
  (~2° from parallel, cos(2°) ≈ 0.9994), COLLINEAR_LINE_TOLERANCE (50mm
  perpendicular distance for two parallel wall axes to share a line),
  BASELINE_OFFSET_TOLERANCE — replacing inline magic numbers that the
  wall-join classifier, fillet-state machine, and gizmo preview decorator
  all read from.
* Pure wall-join geometry helpers (project_axis_intersection,
  are_axes_collinear, classify_wall_join_state, wall_join_preview_lines,
  resolve_extend_walls_target, extrusion_depth_from_vertical_height,
  length_and_height_from_extrusion). They take primitive tuples + floats,
  no bpy, no ifcopenshell — testable in the core lane.

core/product.py is new — pure-Python aggregate-walk helpers (resolve_host_
of_product, collect_decomposed_products) that downstream tool/spatial and
tool/aggregate consumers can call without importing ifcopenshell at module
load.

Generated with the assistance of an AI coding tool.
2026-05-26 23:28:19 +02:00
Gorgious56 4d4c5b4d51 Split railing representation into pure-compute + IFC wrapper
add_railing_representation now factors into two parts:

* compute_wall_mounted_handrail_geometry returns a pure-geometry
  WallMountedHandrailGeometry dataclass (handrail polyline + support
  list + terminal caps), no IFC mutation.
* add_railing_representation wraps that dataclass into an
  IfcShapeRepresentation as before.

Downstream consumers that want the same math without round-tripping
through an IFC file (Blender gizmo previews, viewport drafts) now
drive compute_X directly. Future add_X_representation work in the
geometry API is encouraged to follow the same shape — a sibling
compute_X function + thin IFC wrapper.

The railing_type parameter is dropped from the signature — only
WALL_MOUNTED_HANDRAIL was ever supported, so the kwarg was dead.
The Bonsai railing-modifier caller is updated in the same commit
to stop passing it; without that update Bonsai's
finish_editing_railing_path raises TypeError on the first edit.

RailingSupport and WallMountedHandrailGeometry use @dataclass(slots=True)
— they're constructed N-per-cap during arc sampling, so the per-instance
overhead matters.

Public symbols (RailingSupport, TERMINAL_TYPE,
WallMountedHandrailGeometry, compute_wall_mounted_handrail_geometry,
add_railing_representation) re-exported from ifcopenshell.api.geometry.
New test/api/geometry/test_add_railing_representation.py covers the
compute/wrap contract.

Generated with the assistance of an AI coding tool.
2026-05-26 23:22:19 +02:00
Gorgious56 3d81660dad Use util.unit.mm_to_m in add_window_representation
Drops the module-local ``mm()`` helper in favour of the centralised
``ifcopenshell.util.unit.mm_to_m`` (added earlier in this PR). The
``as mm`` import alias preserves the existing call sites' readability.

Generated with the assistance of an AI coding tool.
2026-05-26 23:22:19 +02:00
Gorgious56 b4abd999b6 Use util.unit.mm_to_m in add_door_representation
Drops the module-local ``mm()`` helper in favour of the centralised
``ifcopenshell.util.unit.mm_to_m`` (added earlier in this PR). The
``as mm`` import alias preserves the existing call sites' readability.

Generated with the assistance of an AI coding tool.
2026-05-26 23:22:19 +02:00
Gorgious56 1e6db764d4 Add numpy axis-index constants + silence MEP-transition prints
ShapeBuilder gains module-level NP_X / NP_Y / NP_Z / NP_XY / NP_XZ /
NP_YZ / NP_YX axis-index constants. Downstream geometry builders had
been redefining local copies for indexing np.ndarray vectors of shape
(3,) or (N, 3); centralising removes the duplication.

mep_transition_length and mep_transition_calculate verbose default
flipped from True to False. The prints are diagnostic-only output;
True-by-default spammed the console on every transition computation,
which fires per-fitting on IFC load.

Generated with the assistance of an AI coding tool.
2026-05-26 23:22:19 +02:00
Gorgious56 936526b41b Add ifcopenshell.util.unit.mm_to_m helper
Centralises the millimetre-to-metre conversion shortcut that
add_door_representation and add_window_representation each defined
locally. Subsequent commits in this PR switch both call sites to
import this from util.unit, removing the duplicate definitions.

Generated with the assistance of an AI coding tool.
2026-05-26 23:22:19 +02:00
Richard Brice 45ea5eb07a Updates alignment api. Fixes bugs authoring semantic-only alignment 2026-05-25 10:34:29 -07:00
Richard Brice 42ed398169 Simplifies line and circle parent curves and parent curve normalization 2026-05-25 10:34:29 -07:00
Richard Brice f70044d373 Fixes bug computing cross slope 2026-05-25 10:34:29 -07:00
Ryan Schultz 719309571e Improve active tool panel hotkey button display
Use add_layout_hotkey_operator for draw_regen_operations so the Regen
button shows text and shortcut icons in the sidebar like all other
panel buttons. Add a separator between modifier and key icons for
readability.
2026-05-25 09:35:12 -05:00
Bruno Postle d3f0ad03fb Quote {id} placeholders in examples (issue #8101)
Shell {} expressions require quoting
2026-05-24 20:25:36 +01:00
Gorgious56 7e96692764 Merge pull request #8089 from Gorgious56/gizmos
Parametric gizmos : Support wall and wall operations
2026-05-21 11:58:54 +02:00
Gorgious56 3e0978062f Add lifecycle-mixin tests + predicate-total registry guard
test_parametric_lifecycle.py covers the door/window/railing/roof
state-transition contracts (enable/finish/cancel; no-op on
non-matching elements; draft preserved on finish-time failure)
that the registry smoke test never exercised.

test_parametric_registry.py gains a check that every is_<name>
predicate stays total (never raises on a non-matching IFC entity)
— a raising predicate would break the save path for unrelated
types. Also rewrites the gizmo-prefs check to read __annotations__
instead of hasattr, which depended on Blender registration timing.

Generated with the assistance of an AI coding tool.
2026-05-21 11:40:38 +02:00
Gorgious56 b3f482e0fa Defer mathutils imports in stair gizmo tests
Aligns with the test/bim/ convention: heavy imports go inside test
functions so the autouse _require_real_bpy fixture skips cleanly
when bpy is mocked, rather than module-level imports failing at
collection time and erroring out the whole file.

Generated with the assistance of an AI coding tool.
2026-05-21 11:18:00 +02:00
Gorgious56 4943c77c5e Add BONSAI_TEST_ARGS env-var fallback to runpytest.py
PowerShell and some wrapper scripts on Windows occasionally strip
or reorder the `--` separator before Blender sees it, dropping the
pytest args into Blender's positional file-load slot ("File format
is not supported"). The env var carries the same args via a
shell-evaluation-free channel. Default `--` path is byte-identical
to the pre-change behaviour.

Generated with the assistance of an AI coding tool.
2026-05-21 11:17:31 +02:00
Gorgious56 6caf94f1d3 Sweep docstrings for rot-prone references
Docstrings naming sibling methods, private helpers, test files, or
historical symbols silently go wrong on rename. Strip Sphinx :meth:
/ :class: / :func: / :attr: markup that mostly added noise (no
Sphinx in this project), and rewrite five docstrings that cited
specific test paths or private hooks to describe the behaviour
instead.

Generated with the assistance of an AI coding tool.
2026-05-21 11:09:29 +02:00
Gorgious56 1e36cc318e Drop save-time parametric-edit confirm dialog
The dialog's only outcomes were "Apply & Save" (same as silent save)
or "Cancel" (same as not saving) — net friction with no actual choice.
Auto-commit stays as the safety net; the count now suffixes the
existing save-success report so it isn't immediately overwritten.

Generated with the assistance of an AI coding tool.
2026-05-21 11:00:19 +02:00
Gorgious56 46381ec08b Prioritize smaller distance gizmos in selection
When two GizmoDimension hit regions overlap (a short dimension
nested inside a longer one along the same axis), the larger one
used to win because hit boxes are scaled by world-space length —
the long box fully contains the short one, leaving the short
gizmo unreachable. The larger gizmo stays clickable at its
exposed ends, so smaller-wins is the right UX default.

Sets self.select_bias = -self._dimension_length inside
GizmoDimension.set_dimension_length. The smaller gizmo writes a
less-negative depth value in the GPU select buffer and wins the
tie-break. select_bias is unused elsewhere in the codebase, so
icon and arrow gizmos keep bias=0 and are unaffected (icons
correctly still win against dimensions, since 0 > -length).

Adds test/bim/module/drawing/test_dimension_gizmo_priority.py
with 5 cases: direct ordering, monotonicity across length ranges,
abs() handling for signed dimensions, and NaN/Inf safety.

Generated with the assistance of an AI coding tool.
2026-05-21 10:30:32 +02:00
Gorgious56 47af955dd1 Simplify pending edit popup text 2026-05-21 09:48:00 +02:00
Gorgious56 f582d0230c Fix set_icon_gizmo_position so billboard ignores object rotation
set_icon_gizmo_position computed
``mw @ (Translation @ billboard_rot @ Scale)`` — the object's world
matrix was applied AFTER the billboard rotation, so any non-trivial
object rotation (e.g. a wall rotated in plan, a stair rotated to
match a corridor) carried over into the icon's transform and tilted
it edge-on to the camera instead of facing it.

Switch to ``billboarded_at(world_pos, billboard_rot, scale)`` where
``world_pos = mw @ local_pos``: translate to world space first, then
apply the billboard rotation independently of the object's rotation.
This matches the manual pattern the base class's
``update_editing_gizmos`` already uses for validate/cancel/cycle for
exactly this reason.

Drops the now-stale workaround docstring on
``GizmoWallEdition._update_icon_row_extras`` that documented why it
bypassed ``set_icon_gizmo_position`` — the helper does the right
thing now.

Adds ``test/bim/module/model/test_stair_gizmos.py`` as the regression
guard: parametrised over six rotation angles, asserts that the rotation
part of the resulting matrix equals ``billboard_rot`` (no contribution
from ``mw``'s rotation) and that the translation lands at
``world_pos``. Also exercises ``set_icon_gizmo_position`` end-to-end via
a stub gizmo to catch the exact shape of the previously-broken call
site.

Generated with the assistance of an AI coding tool.
2026-05-20 17:28:18 +02:00
Gorgious56 26eef20eb5 Add wall parametric editing and gizmos
Walls gain in-viewport parametric editing matching the door/window/stair
UX: drag handles for length, height, slope (x-angle), layer baseline
cycle, plus cursor-anchored quality-of-life operators (split at cursor,
extend to cursor, extend height, rotate 90, toggle openings) and
two-object state-machine gizmos (unjoin / merge / join-corner /
extend-to-wall / extend-vertically / add-opening).

Wall enters tool.Parametric.EDIT_TYPES, so save-time auto-commit,
GizmoPreferencesWall registration, and the in-progress-edit predicates
all light up automatically through the registry plumbing landed two
commits back.

The three-layer commit model (drag -> BIMWallProperties -> bmesh
preview -> Finish -> single ifc.run) means dragging a handle through
hundreds of intermediate values produces zero extra IFC entities. A
no-op enable->finish round-trip is byte-identical. The snapshot diff
in FinishEditingWall skips unchanged params.
_commit_active_wall_edit_if_any ensures cursor-anchored operators see
committed geometry, not the draft preview box.

Also lands the `prompt_auto_commit_parametric_edits` BoolProperty on
BIM_ADDON_preferences (consumed by the auto-commit dialog landed in
the framework commit) and refactors
`draw_{door,window,stair}_gizmo_parameters` into a shared
`_draw_parametric_gizmo_parameters` helper that the new
`draw_wall_gizmo_parameters` reuses. This commit and the framework
commit are stacked - the framework commit references the BoolProperty
defined here, so they must land together.

Tests cover pure math (core/test_model.py), DimensionGizmoConfig text
formatter, GizmoWallExtendVertically.poll() preconditions, and the
refresh_post_commit cache-invalidation regression. BDD scenarios in
model.feature cover the edit triad, auto-commit on save, and the
two-object gizmos. Documentation added to creating_walls.rst.

Generated with the assistance of an AI coding tool.
2026-05-20 16:58:39 +02:00
Gorgious56 2143262883 Fix dead duplicates and misleading import comments
Three small post-landing cleanups against the parametric framework commit:

* core/model.py had `are_axes_collinear` and `closest_endpoint_midpoint`
  each defined twice — Python silently kept the second copy, the first
  was dead code. Removed the dead copies; runtime behavior unchanged
  (the live versions were already the kept ones).
* bim/__init__.py's `_parametric_gizmo_preference_classes` docstring
  named the wrong link in the import chain (`tool.blender → bim.ifc`).
  The real chain is `tool/ifc.py` (and ~6 other tool/* modules) which
  import `from bonsai.bim.ifc import IfcStore` at module load. Updated
  docstring to cite that root cause and the architectural fix (move
  `IfcStore` out of `bim/`).
* tool/blender.py's `from bonsai.bim.ifc import IFC_CONNECTED_TYPE`
  carried a 5-line comment claiming it was "lazy" to avoid a circular
  load. The import sits inside an `if TYPE_CHECKING:` block with
  `from __future__ import annotations` — it never runs at runtime
  regardless. Comment removed; the TYPE_CHECKING guard is
  self-explanatory.

Generated with the assistance of an AI coding tool.
2026-05-20 16:25:49 +02:00
Gorgious56 233cc344fa Add tool.Parametric registry and lifecycle mixins
Establish a single source of truth for parametric element types (door,
window, stair, railing, roof). tool.Parametric.EDIT_TYPES drives:
- BIM<Name>Properties PointerProperty attachment via the registry
- GizmoPreferences<Name> class registration in bim/__init__.py
- save-time auto-commit of pending draft edits
- the refresh_post_commit epilogue called from IfcStore after every IFC
  mutation, which fixes the stale-header bug where in-place hotkey
  mutations (S_E / C_E) left BIMModelProperties and the gizmo cache
  pointing at obsolete values.

Refactors door/window/railing/roof onto shared mixins from
bim/parametric_lifecycle.py (FeatureModifierEditMixin and
PathPreservingEditMixin); stair gets the lock-gizmo refactor and
frame-cache integration. Behavior preserved.

Adds BaseParametricGizmoGroup._prime_frame_caches so the parametric
gizmos stop re-deriving preferences, view direction, and billboard
rotation per frame; reorders poll() to short-circuit on the cheapest
predicate first. Adds the icon library + BillboardingGizmoGroupMixin
that the wall feature in the next commit will consume.

Generated with the assistance of an AI coding tool.
2026-05-20 15:18:44 +02:00
Gorgious56 a64e737d9c Merge pull request #8078 from Gorgious56/v0.8.0
Fix 8077 : Fix SHIFT + D with non-ifc object selection
2026-05-19 13:03:21 +02:00
Gorgious56 1b2507e143 Fix 8077 : Fix SHIFT + D with non-ifc object selection
When a project has a ifc file associated, selecting non-ifc objects and duplicating them with SHIFT + D now correctly both duplicate them, keep the new objects selected and starts the transform modal. IFC objects behaviour is unaffected.
2026-05-19 12:29:18 +02:00
Geert Hesselink 508b99cb73 Fix lint failures and add missing pyparsing dependency (#8048)
* unblock voxel schema loading, add test for express

* Apply black formatting

* Fix lint failures and add missing pyparsing dependency

* align ty -> 0.0.34
2026-05-18 22:17:45 +02:00
Thomas Krijnen 4e406ab1ce Change default value of assume_asset_uniqueness_by_name #8045 2026-05-18 13:29:39 +02:00
Thomas Krijnen 227d85d81f arrange polygons: limit width ratio when merging boxes 2026-05-15 21:12:43 +02:00
Thomas Krijnen a24cdf4958 Merge branch 'v0.8.0' of https://github.com/IfcOpenShell/IfcOpenShell into v0.8.0 2026-05-15 21:12:01 +02:00
Ryan Schultz e78ef865b8 Fix #8056 - Dimensions with CustomUnit" = "Inches - Fractional" should not show 0. 2026-05-15 07:28:29 -05:00
Thomas Krijnen 9345b9ce3f arrange polies: don't allow snapped point paths to cross non-containing other rect axes 2026-05-14 21:45:59 +02:00
Thomas Krijnen 0b5dded3b3 Fix temporary solution storage in arrange polygons 2026-05-14 14:37:44 +02:00
Thomas Krijnen 97218b1fdb Calculate box-width as orthogonal distance; aabb code for segment intersection (disabled) 2026-05-14 14:17:10 +02:00
Thomas Krijnen 1b637c6499 Arrange polies: reorder segment to exterior insertion based on length 2026-05-12 20:52:30 +02:00
180 changed files with 25569 additions and 2790 deletions
+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
uv tool install ruff
uv tool install black
uv tool install poethepoet
uv tool install ty
uv tool install ty==0.0.34
# black doesn't catch all syntax errors, so we check them explicitly.
- name: Check syntax errors
+1 -1
View File
@@ -51,7 +51,7 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install xmlschema xsdata numpy lxml pytest isodate lark networkx tabulate python-dateutil shapely
pip install xmlschema xsdata numpy lxml pytest isodate lark networkx tabulate python-dateutil shapely pyparsing
pip install src/bcf --no-deps
pip install pytest-xdist==3.8.0
+7 -3
View File
@@ -258,10 +258,14 @@ if(WITH_ROCKSDB)
set(ROCKSDB_LIBRARIES "IFCOPENSHELL_RocksDB")
target_compile_definitions(IFCOPENSHELL_RocksDB INTERFACE IFOPSH_WITH_ROCKSDB)
set(SWIG_DEFINES ${SWIG_DEFINES} -DIFOPSH_WITH_ROCKSDB)
# Shared binaries for `rocksdb` only support limited API (only `c.h`), but we use `db.h` API.
# So rocksdb supported only as a static library.
# See https://github.com/facebook/rocksdb/issues/981.
target_link_libraries(IFCOPENSHELL_RocksDB INTERFACE RocksDB::rocksdb)
if(TARGET RocksDB::rocksdb)
target_link_libraries(IFCOPENSHELL_RocksDB INTERFACE RocksDB::rocksdb)
elseif(TARGET RocksDB::rocksdb-shared)
target_link_libraries(IFCOPENSHELL_RocksDB INTERFACE RocksDB::rocksdb-shared)
else()
message(FATAL_ERROR "RocksDB found but neither RocksDB::rocksdb nor RocksDB::rocksdb-shared target exists")
endif()
if(WITH_ZSTD)
# @todo do we actually need the zstd include dir or rather just pass
+9 -1
View File
@@ -88,7 +88,15 @@ if(NOT HDF5_INCLUDE_DIR OR NOT HDF5_LIBRARY_DIR)
mark_as_advanced(HDF5_DIR)
if(HDF5_DIR)
message(STATUS "HDF5: found config at '${HDF5_DIR}'.")
set(HDF5_LIBRARIES hdf5_cpp-static)
if(TARGET hdf5_cpp-static)
set(HDF5_LIBRARIES hdf5_cpp-static)
elseif(TARGET hdf5_cpp-shared)
set(HDF5_LIBRARIES hdf5_cpp-shared)
elseif(TARGET hdf5::hdf5_cpp-shared)
set(HDF5_LIBRARIES hdf5::hdf5_cpp-shared)
else()
find_package(HDF5 REQUIRED COMPONENTS CXX)
endif()
else()
# If it failed, still try to find as a module.
# E.g. on Ubuntu `libhdf5-dev` doesn't provie hdf5-config.cmake.
+1 -2
View File
@@ -126,9 +126,8 @@ ssl._create_default_https_context = ssl._create_unverified_context
import time
from collections.abc import Generator, Sequence
from pathlib import Path
from urllib.request import urlretrieve
from typing import Literal, Union
from urllib.request import urlretrieve
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
+9 -1
View File
@@ -192,7 +192,11 @@ endif
# Provides networkx graph analysis for project dependency calculations
cd build && . env/$(VENV_ACTIVATE) && $(PIP) download networkx --dest=./wheels
# Required by IFCDiff
cd build && . env/$(VENV_ACTIVATE) && $(PIP) download deepdiff --dest=./wheels
# Pinned <9.1: deepdiff 9.1.0 adds cachebox<6,>=5.2 which only ships macOS x86_64
# wheels for macosx_10_12+ and is incompatible with our macos py311 --platform
# macosx_10_10_x86_64 target. Revisit once the macos py311 platform tag is bumped
# to 10_13 (matching py312/py313).
cd build && . env/$(VENV_ACTIVATE) && $(PIP) download "deepdiff<9.1" --dest=./wheels
# Required by IFCCSV and ifcopenshell.util.selector
cd build && . env/$(VENV_ACTIVATE) && $(PIP) download lark --dest=./wheels
# Required by IFC4D
@@ -356,6 +360,10 @@ else
pytest test/tool/test_$(MODULE).py --maxfail=1
endif
.PHONY: test-modal
test-modal:
blender --enable-event-simulate --python test/modal/test_modal.py --window-maximized
# Reregistering test is not added to the standard test suite because during unregister
# Blender removes all Bonsai dependencies breaking dev-environment symlinks.
.PHONY: test-reregister
+8 -4
View File
@@ -15,6 +15,8 @@
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was modified with the assistance of an AI coding tool.
import importlib
import os
@@ -25,7 +27,7 @@ import bpy
import bpy.utils.previews
from bpy_extras.io_utils import ExportHelper, ImportHelper
from . import handler, operator, prop, ui
from . import handler, operator, parametric_lifecycle, prop, ui
try:
from bonsai.translations import translations_dict
@@ -157,9 +159,6 @@ classes = [
ui.BIM_UL_tab_visibilities,
ui.BIM_UL_panel_visibilities,
ui.DocPreferences,
ui.GizmoPreferencesDoor, # Register before GizmoPreferences
ui.GizmoPreferencesWindow, # Register before GizmoPreferences
ui.GizmoPreferencesStair, # Register before GizmoPreferences
ui.GizmoPreferences,
# ui.DefaultParameters and ui.BIM_ADDON_preferences are registered separately after modules (see late_classes below)
# Tabs panel
@@ -268,8 +267,11 @@ def register():
bpy.app.handlers.depsgraph_update_post.append(on_register)
bpy.app.handlers.undo_post.append(handler.undo_post)
bpy.app.handlers.redo_post.append(handler.redo_post)
# Must follow the two appends above so regenerators see restored IFC state.
parametric_lifecycle.install_parametric_lifecycle_handlers()
bpy.app.handlers.load_post.append(handler.load_post)
bpy.app.handlers.load_post.append(handler.loadIfcStore)
bpy.app.handlers.save_post.append(handler.save_post)
bpy.types.Scene.BIMProperties = bpy.props.PointerProperty(type=prop.BIMProperties)
bpy.types.Scene.BIMSnapProperties = bpy.props.PointerProperty(type=prop.BIMSnapProperties)
bpy.types.Scene.BIMSnapGroups = bpy.props.PointerProperty(type=prop.BIMSnapGroups)
@@ -325,8 +327,10 @@ def unregister():
unregister_classes(classes)
parametric_lifecycle.uninstall_parametric_lifecycle_handlers()
bpy.app.handlers.load_post.remove(handler.load_post)
bpy.app.handlers.load_post.remove(handler.loadIfcStore)
bpy.app.handlers.save_post.remove(handler.save_post)
del bpy.types.Scene.BIMProperties
del bpy.types.Collection.BIMCollectionProperties
del bpy.types.Object.BIMObjectProperties
+119
View File
@@ -0,0 +1,119 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Shared structural-change cache token for POST_VIEW decorators.
Decorators include the token in their cache key and rebuild on bump."""
from __future__ import annotations
from collections.abc import Callable
from typing import Any, Generic, TypeVar
import bpy
T = TypeVar("T")
_DECORATOR_CACHE_TOKEN = 0
def get_decorator_cache_token() -> int:
return _DECORATOR_CACHE_TOKEN
def reset_for_test() -> None:
"""Test-only: reset the cache token to 0 so bump-count assertions are stable."""
global _DECORATOR_CACHE_TOKEN
_DECORATOR_CACHE_TOKEN = 0
@bpy.app.handlers.persistent
def _bump_decorator_cache_token(*args: Any) -> None:
"""depsgraph_update_post fires every animation frame and every driver
evaluation, even when no IFC-relevant ID block changed. Unconditional
bumping defeats the cache: an animated scene rebuilds every decorator
every viewport tick. Gate the depsgraph path on Object geometry or
transform updates; undo / redo / load have no depsgraph and always
invalidate.
Coverage assumption: ``TokenCache`` consumers key on Object identity
(depsgraph updates whose ``id`` is a ``bpy.types.Object``). Mesh /
Material / NodeTree updates that don't surface as an Object change
do NOT invalidate the token — a decorator that caches material- or
mesh-data-derived state must gate on a separate signal."""
global _DECORATOR_CACHE_TOKEN
if len(args) >= 2:
depsgraph = args[1]
if depsgraph is not None and hasattr(depsgraph, "updates"):
if not any(
(getattr(u, "is_updated_geometry", False) or getattr(u, "is_updated_transform", False))
and hasattr(u, "id")
and isinstance(u.id, bpy.types.Object)
for u in depsgraph.updates
):
return
_DECORATOR_CACHE_TOKEN += 1
def _hooks() -> tuple[Any, ...]:
return (
bpy.app.handlers.depsgraph_update_post,
bpy.app.handlers.undo_post,
bpy.app.handlers.redo_post,
bpy.app.handlers.load_post,
)
def install_decorator_cache_handlers() -> None:
"""Append the bump handler to each hook; idempotent."""
for hook in _hooks():
if _bump_decorator_cache_token not in hook:
hook.append(_bump_decorator_cache_token)
def uninstall_decorator_cache_handlers() -> None:
for hook in _hooks():
try:
hook.remove(_bump_decorator_cache_token)
except ValueError:
pass
class TokenCache(Generic[T]):
"""Memoise a single value keyed on ``(caller_key, get_decorator_cache_token())``.
The token component invalidates the cache on depsgraph / undo / redo / load,
so cached ``bpy.types.Object`` references can't outlive the underlying ID
blocks. Holds exactly one entry — last key wins."""
__slots__ = ("_key", "_value")
def __init__(self) -> None:
self._key: tuple[Any, int] | None = None
self._value: T | None = None
def get_or_compute(self, key: Any, compute: Callable[[], T]) -> T:
token_key = (key, _DECORATOR_CACHE_TOKEN)
if token_key == self._key:
return self._value # type: ignore[return-value]
value = compute()
self._key = token_key
self._value = value
return value
+192 -42
View File
@@ -15,11 +15,12 @@
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was modified with the assistance of an AI coding tool.
import os
import weakref
from collections.abc import Callable
from math import cos
from typing import Union
import bpy
@@ -31,16 +32,27 @@ from bpy.app.handlers import persistent
from mathutils import Vector
import bonsai.bim
import bonsai.core.model as core_model
import bonsai.tool as tool
from bonsai.bim.ifc import IfcStore
from bonsai.bim.decorator_cache import (
install_decorator_cache_handlers,
uninstall_decorator_cache_handlers,
)
from bonsai.bim.ifc import IfcStore, get_cache_or_detect_lock
from bonsai.bim.module.aggregate.decorator import AggregateDecorator
from bonsai.bim.module.georeference.decorator import GeoreferenceDecorator
from bonsai.bim.module.model.array import (
ArrayPreviewDecorator,
ArraySelectionHighlightDecorator,
)
from bonsai.bim.module.model.data import AuthoringData
from bonsai.bim.module.model.decorator import (
BoundingBoxDecorator,
SlabDirectionDecorator,
WallAxisDecorator,
WallFilletPreviewDecorator,
)
from bonsai.bim.module.model.wall import WallGizmoPreviewDecorator
from bonsai.bim.module.nest.decorator import NestDecorator
cwd = os.path.dirname(os.path.realpath(__file__))
@@ -108,19 +120,13 @@ def active_object_callback():
def update_bim_tool_props():
"""update BIM Tools props (such as extrusion_depth, length and x_angle) when active object changes"""
obj = bpy.context.active_object
# bunch of checks to see if we're in a valid state
if not obj:
return
mode = bpy.context.mode
current_tool = bpy.context.workspace.tools.from_space_view3d_mode(mode)
if not current_tool or current_tool.idname not in tool.Blender.get_list_of_tools():
return
element = tool.Ifc.get_entity(obj)
if not element:
"""Selection-driven BIM Tool sync: re-target user-intent enums
(ifc_class, relating_type_id) AND refresh header values
(extrusion_depth, length, x_angle) for the new active object."""
ctx = _resolve_bim_tool_context()
if ctx is None:
return
obj, current_tool, element = ctx
props = tool.Model.get_model_props()
aprops = tool.Drawing.get_annotation_props()
@@ -133,18 +139,85 @@ def update_bim_tool_props():
if is_annotation_tool and (object_type := tool.Drawing.get_annotation_type_object_type(element_type)):
aprops.object_type = object_type
aprops.relating_type_id = str(element_type.id())
try:
aprops.relating_type_id = str(element_type.id())
except TypeError:
# EnumProperty items are rebuilt asynchronously when ifc_class changes;
# this assignment can race a stale item list. Skipping is harmless —
# the UI will resync on the next active_object_callback.
pass
return
if is_bim_tool:
props.ifc_class = element_type.is_a()
try:
props.ifc_class = element_type.is_a()
except TypeError:
# ifc_class only lists element/space types present in the model, so an
# unsupported type (e.g. a raw IfcTypeProduct) or a stale item list mid-
# rebuild raises `enum "<class>" not found`. Skip rather than crash the
# handler — it re-fires on the next selection and the panel resyncs.
pass
if is_bim_tool or TOOLS_TO_CLASSES_MAP.get(current_tool.idname) == element_type.is_a():
props.relating_type_id = str(element_type.id())
# Only assign when the target enum is the one that lists this type — otherwise
# we hit `enum "<id>" not found in (...)` if the user selects an element of a
# different class than the workspace tool was built for (e.g. selecting a wall
# while the door tool is active).
tool_class_match = TOOLS_TO_CLASSES_MAP.get(current_tool.idname) == element_type.is_a()
bim_tool_class_match = is_bim_tool and props.ifc_class == element_type.is_a()
if bim_tool_class_match or tool_class_match:
try:
props.relating_type_id = str(element_type.id())
except TypeError:
# Defensive: the enum item list can lag behind ifc_class assignment
# above. Skipping leaves the panel briefly out of sync rather than
# crashing the handler (which Blender re-fires on every selection).
pass
if is_annotation_tool:
return
_read_headers_into_props(obj, element)
def refresh_bim_tool_headers():
"""Push the active IFC entity's current header float values
(extrusion_depth, length, x_angle) into ``BIMModelProperties``.
Enum-safe: never writes user-intent enum slots, which are owned by
the selection callback."""
ctx = _resolve_bim_tool_context()
if ctx is None:
return
obj, current_tool, element = ctx
if current_tool.idname not in tool.Blender.get_property_header_tools():
return
_read_headers_into_props(obj, element)
def _resolve_bim_tool_context():
"""Return ``(obj, current_tool, element)`` when an active BIM workspace
tool sees a resolvable IFC element; ``None`` otherwise. Defensive
against stripped operator contexts — a missing ``active_object`` /
``mode`` / ``workspace`` short-circuits to ``None`` instead of raising."""
obj = tool.Blender.get_active_object()
if not obj:
return None
mode = getattr(bpy.context, "mode", None)
workspace = getattr(bpy.context, "workspace", None)
if mode is None or workspace is None:
return None
current_tool = workspace.tools.from_space_view3d_mode(mode)
if not current_tool or current_tool.idname not in tool.Blender.get_list_of_tools():
return None
element = tool.Ifc.get_entity(obj)
if not element:
return None
return obj, current_tool, element
def _read_headers_into_props(obj, element):
"""Populate ``BIMModelProperties`` header values from the active
object's IFC extrusion. Enum-safe: writes only header floats, never
user-intent enum slots, so it is safe to call on the post-commit hook."""
representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
if not representation:
return
@@ -162,10 +235,13 @@ def update_bim_tool_props():
if not AuthoringData.is_loaded:
AuthoringData.load()
props = tool.Model.get_model_props()
if AuthoringData.data["active_material_usage"] == "LAYER2":
x_angle = get_x_angle(extrusion)
axis = tool.Model.get_wall_axis(obj)["reference"]
props.extrusion_depth = abs(extrusion.Depth * si_conversion * cos(x_angle))
props.extrusion_depth = core_model.vertical_height_from_extrusion_depth(
extrusion.Depth * si_conversion, x_angle
)
props.length = (axis[1] - axis[0]).length
props.x_angle = x_angle
@@ -357,7 +433,32 @@ def subscribe_to_viewport_shading_changes():
@persistent
def load_post(scene):
def save_post(scene) -> None:
"""After saving the .blend file, convert the stored IFC path to relative if enabled."""
pprops = tool.Project.get_project_props()
if not pprops.use_relative_project_path:
return
bim_props = tool.Blender.get_bim_props()
ifc_path = bim_props.ifc_file
if not ifc_path or not os.path.isabs(ifc_path):
return
blend_dir = bpy.path.abspath("//")
if not blend_dir:
return
from pathlib import Path
from bonsai.bim.ifc import IfcStore
try:
rel_path = str(Path(ifc_path).relative_to(blend_dir))
except ValueError:
return # IFC file is not under the blend directory; keep absolute path
bim_props.ifc_file = rel_path
IfcStore.set_path(ifc_path) # keep IfcStore.path absolute for loading
def _apply_save_file_invariants(scene: bpy.types.Scene) -> None:
"""Invariants enforced on every load_post: msgbus subscription, IFC owner
settings, scene-bound caches, load-transient parametric state, and the
multi-instance lock probe."""
global global_subscription_owner
active_object_key = bpy.types.LayerObjects, "active"
bpy.msgbus.subscribe_rna(
@@ -368,6 +469,23 @@ def load_post(scene):
ifcopenshell.api.owner.settings.get_application = get_application
AuthoringData.type_thumbnails = {}
tool.Parametric.on_load_post(scene)
if tool.Ifc.get() and bpy.data.is_saved:
props = tool.Blender.get_bim_props()
props.has_blend_warning = True
# Probe the H5 cooked-geometry cache so the multi-instance warning surfaces
# right after .blend load. Without this, the lock is only detected when a
# mutation triggers ``clear_cache`` — by which time the user has already
# made changes that may now conflict with the other Blender instance.
if tool.Ifc.get():
get_cache_or_detect_lock()
def _apply_user_preferences() -> None:
"""User-preference-driven UI setup: toolbar, BIM workspace, viewport shading
subscription, scene-panel hijack, tab layout, snap defaults."""
preferences = tool.Blender.get_addon_preferences()
if not preferences.should_setup_toolbar:
tool.Blender.unregister_toolbar()
@@ -391,11 +509,21 @@ def load_post(scene):
tool.Blender.override_scene_panel(panel)
tool.Blender.setup_tabs()
if tool.Ifc.get() and bpy.data.is_saved:
props = tool.Blender.get_bim_props()
props.has_blend_warning = True
if preferences.should_use_snap and (scene := bpy.context.scene):
# Snapping is off by default in Blender, but in BIM, it's more useful to be on
scene.tool_settings.use_snap = True
# Match default Bonsai snaps
scene.tool_settings.snap_elements_base = {"EDGE", "EDGE_PERPENDICULAR", "VERTEX", "EDGE_MIDPOINT", "FACE"}
# Bonsai overlays
tool.Blender.sync_old_preferences()
def _install_viewport_overlays() -> None:
"""Sync every Bonsai viewport decorator to its enabled state.
Wrapped in uninstall/install of the decorator-cache bump handlers so a
decorator's own install path doesn't double-bind to depsgraph_update_post
via ``TokenCache`` instances created during their own ``install()``."""
georeference_props = tool.Georeference.get_georeference_props()
aggregate_props = tool.Aggregate.get_aggregate_props()
nest_props = tool.Nest.get_nest_props()
@@ -405,23 +533,45 @@ def load_post(scene):
NestDecorator.uninstall()
WallAxisDecorator.uninstall()
SlabDirectionDecorator.uninstall()
if georeference_props.should_visualise:
GeoreferenceDecorator.install(bpy.context)
if aggregate_props.aggregate_decorator:
AggregateDecorator.install(bpy.context)
if nest_props.nest_decorator:
NestDecorator.install(bpy.context)
if model_props.show_wall_axis:
WallAxisDecorator.install(bpy.context)
if model_props.show_slab_direction:
SlabDirectionDecorator.install(bpy.context)
if model_props.show_bounding_box:
BoundingBoxDecorator.install(bpy.context)
WallFilletPreviewDecorator.uninstall()
WallGizmoPreviewDecorator.uninstall()
ArrayPreviewDecorator.uninstall()
ArraySelectionHighlightDecorator.uninstall()
uninstall_decorator_cache_handlers()
try:
if georeference_props.should_visualise:
GeoreferenceDecorator.install(bpy.context)
if aggregate_props.aggregate_decorator:
AggregateDecorator.install(bpy.context)
if nest_props.nest_decorator:
NestDecorator.install(bpy.context)
if model_props.show_wall_axis:
WallAxisDecorator.install(bpy.context)
if model_props.show_slab_direction:
SlabDirectionDecorator.install(bpy.context)
if model_props.show_bounding_box:
BoundingBoxDecorator.install(bpy.context)
# Always-installed: draw() self-polls on Scene.BIMPreviewProperties.
# wall_fillet.is_active, so installation has no cost when no preview
# is open. No corresponding addon-preference toggle.
WallFilletPreviewDecorator.install(bpy.context)
# Always-installed: draw_lines() self-polls on selection + hover state
# for join / extend-to-wall / cursor-extend / cursor-split previews.
# Free when no preview-eligible state is active.
WallGizmoPreviewDecorator.install(bpy.context)
# Always-installed: draw() self-polls on the active object's array
# family membership, so installation has no cost when no array
# element is selected.
ArraySelectionHighlightDecorator.install(bpy.context)
# Always-installed: draw() self-polls on props.is_editing — only
# paints during an active array edit lifecycle.
ArrayPreviewDecorator.install(bpy.context)
finally:
install_decorator_cache_handlers()
if preferences.should_use_snap and (scene := bpy.context.scene):
# Snapping is off by default in Blender, but in BIM, it's more useful to be on
scene.tool_settings.use_snap = True
# Match default Bonsai snaps
scene.tool_settings.snap_elements_base = {"EDGE", "EDGE_PERPENDICULAR", "VERTEX", "EDGE_MIDPOINT", "FACE"}
tool.Blender.sync_old_preferences()
@persistent
def load_post(scene):
_apply_save_file_invariants(scene)
_apply_user_preferences()
_install_viewport_overlays()
+40 -1
View File
@@ -64,6 +64,44 @@ class TransactionStep(TypedDict):
operations: list[Operation]
# Set when ``IfcStore.get_cache`` observes an external lock on the HDF5 cache —
# signal that another Blender process has the same IFC file open. Project panel
# polls ``is_cache_locked_by_other_process`` to warn the user. The dismissed
# flag is sticky per-session so the warning doesn't re-nag once the user has
# acknowledged it.
_cache_locked_by_other_process: bool = False
_multi_instance_warning_dismissed: bool = False
def is_cache_locked_by_other_process() -> bool:
return _cache_locked_by_other_process and not _multi_instance_warning_dismissed
def dismiss_multi_instance_warning() -> None:
global _multi_instance_warning_dismissed
_multi_instance_warning_dismissed = True
def get_cache_or_detect_lock() -> ifcopenshell.geom.serializers.hdf5 | None:
"""Like ``IfcStore.get_cache`` but tracks the multi-instance lock flag — sets
it on ``PermissionError``, clears it (along with the dismiss flag) when a
subsequent call succeeds. Returns ``None`` on lock; other exceptions
propagate. Callers that don't need the warning side effect can use
``IfcStore.get_cache`` directly."""
global _cache_locked_by_other_process, _multi_instance_warning_dismissed
try:
cache = IfcStore.get_cache()
except PermissionError:
_cache_locked_by_other_process = True
return None
if _cache_locked_by_other_process:
# Lock released — clear both flags so a future re-locking re-surfaces
# the warning rather than staying suppressed by the previous dismiss.
_cache_locked_by_other_process = False
_multi_instance_warning_dismissed = False
return cache
class IfcStore:
path: str = ""
"""Should be set only using ``tool.Ifc.set_path``."""
@@ -196,7 +234,7 @@ class IfcStore:
shutil.copy2(IfcStore.cache_path, new_cache_path)
except PermissionError:
pass # Well we tried. No cache for you!
IfcStore.get_cache()
get_cache_or_detect_lock()
@staticmethod
def load_file(path: str) -> None:
@@ -514,6 +552,7 @@ class IfcStore:
BrickStore.end_transaction()
IfcStore.end_transaction(operator)
bonsai.bim.handler.refresh_ui_data()
tool.Parametric.refresh_post_commit(operator)
if method == "MODAL":
cls.modal_in_progress = False
+2 -2
View File
@@ -1219,8 +1219,8 @@ class IfcImporter:
if element not in elements_to_import:
continue
for i in range(len(data)):
tool.Blender.Modifier.Array.set_children_lock_state(element, i, True)
tool.Blender.Modifier.Array.constrain_children_to_parent(element)
tool.Array.set_children_lock_state(element, i, True)
tool.Array.constrain_children_to_parent(element)
def update_linked_aggregates(self):
# TODO Remove this after a while. See commit 17d6b8a
@@ -139,6 +139,7 @@ class BIMAggregateProperties(PropertyGroup):
previous_editing_aggregate: PointerProperty(name="Editing Aggregate", type=bpy.types.Object)
editing_objects: CollectionProperty(type=Objects)
not_editing_objects: CollectionProperty(type=Objects)
previously_selected_objects: CollectionProperty(type=Objects)
aggregate_decorator: BoolProperty(
name="Display Aggregate",
default=False,
@@ -155,5 +156,6 @@ class BIMAggregateProperties(PropertyGroup):
previous_editing_aggregate: Union[bpy.types.Object, None]
editing_objects: bpy.types.bpy_prop_collection_idprop[Objects]
not_editing_objects: bpy.types.bpy_prop_collection_idprop[Objects]
previously_selected_objects: bpy.types.bpy_prop_collection_idprop[Objects]
aggregate_decorator: bool
previous_state: bool
+3 -1
View File
@@ -48,12 +48,14 @@ def draw_ui(context: bpy.types.Context, layout: bpy.types.UILayout, attributes)
row = layout.row()
op = row.operator("bim.enable_editing_attributes", icon="GREASEPENCIL", text="Edit")
element = tool.Ifc.get_entity(obj)
key_prefix = "type." if (element and element.is_a("IfcTypeObject")) else ""
for attribute in attributes:
row = layout.row(align=True)
row.label(text=attribute["name"])
value = bonsai.bim.helper.get_display_value(attribute["value"])
op = row.operator("bim.select_similar", text=value, icon="NONE", emboss=False)
op.key = attribute["name"]
op.key = key_prefix + attribute["name"]
# TODO: reimplement, see #1222
# if "IfcSite/" in context.active_object.name or "IfcBuilding/" in context.active_object.name:
@@ -15,6 +15,8 @@
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was modified with the assistance of an AI coding tool.
import bpy
@@ -136,14 +138,34 @@ classes = (
gizmos.GizmoArrow2D,
gizmos.GizmoCone,
gizmos.GizmoDimension,
gizmos.GizmoLock,
gizmos.GizmoLockOpen,
gizmos.GizmoLockClosed,
gizmos.GizmoArc,
gizmos.GizmoLinkToggle,
gizmos.GizmoFillet,
gizmos.GizmoWallCornerIcon,
gizmos.GizmoWallTeeIcon,
gizmos.GizmoPen,
gizmos.GizmoValidate,
gizmos.GizmoCancel,
gizmos.GizmoPlus,
gizmos.GizmoMinus,
gizmos.GizmoTrash,
gizmos.GizmoArrayParent,
gizmos.GizmoArrayAll,
gizmos.GizmoArrayLayerIndicator,
gizmos.GizmoCountLabel,
gizmos.GizmoMerge,
gizmos.GizmoSplit,
gizmos.GizmoUnjoin,
gizmos.GizmoExtend,
gizmos.GizmoExtendVertical,
gizmos.GizmoOffsetExterior,
gizmos.GizmoOffsetCenter,
gizmos.GizmoOffsetInterior,
gizmos.GizmoAddOpening,
gizmos.GizmoCycle,
gizmos.GizmoMenu,
# Drawing-specific gizmos
gizmos.UglyDotGizmo,
gizmos.ExtrusionGuidesGizmo,
File diff suppressed because it is too large Load Diff
@@ -313,7 +313,7 @@ def format_distance(
if not feet and not add_inches:
tx_dist += str(feet) + "'"
if not feet and add_inches:
if not feet and add_inches and unit_length != "INCHES":
if value < 0:
tx_dist += "-0' - "
else:
@@ -44,8 +44,12 @@ class ViewportData:
@classmethod
def load(cls):
cls.is_loaded = True
# Populate data BEFORE flipping is_loaded so a raising ``mode()``
# call doesn't leave the class half-loaded (flag set, dict empty).
# Subsequent items-callback invocations skip load() on a True flag
# and would hit ``cls.data["mode"]`` → KeyError.
cls.data = {"mode": cls.mode()}
cls.is_loaded = True
@classmethod
def mode(cls) -> tool.Blender.BLENDER_ENUM_ITEMS:
@@ -76,9 +80,9 @@ class ViewportData:
modes.append(edit_mode)
elif element.is_a("IfcGridAxis"):
modes.append(edit_mode)
elif tool.Blender.Modifier.is_roof(element):
elif tool.Parametric.is_roof(element):
modes.append(edit_mode)
elif tool.Blender.Modifier.is_railing(element):
elif tool.Parametric.is_railing(element):
modes.append(edit_mode)
elif item_mode not in modes:
modes.append(item_mode)
@@ -60,6 +60,7 @@ import bonsai.core.root
import bonsai.core.spatial
import bonsai.tool as tool
from bonsai.bim.ifc import IfcStore
from bonsai.bim.module.model import preview_base
from bonsai.bim.module.model.decorator import ProfileDecorator
if TYPE_CHECKING:
@@ -1026,10 +1027,10 @@ class OverrideDelete(bpy.types.Operator):
for array_parent in array_parents:
array_parent_obj = tool.Ifc.get_object(array_parent)
data = [(i, data) for i, data in enumerate(tool.Blender.Modifier.Array.get_modifiers_data(array_parent))]
data = [(i, data) for i, data in enumerate(tool.Array.get_modifiers_data(array_parent))]
# NOTE: there is a way to remove arrays more precisely but it's more complex
for i, modifier_data in reversed(data):
children = set(tool.Blender.Modifier.Array.get_children_objects(modifier_data))
children = set(tool.Array.get_children_objects(modifier_data))
if children.issubset(selected_objects):
with context.temp_override(active_object=array_parent_obj):
bpy.ops.bim.remove_array(item=i)
@@ -1183,7 +1184,7 @@ class OverrideDuplicateMove(bpy.types.Operator):
operator: bpy.types.Operator, context: bpy.types.Context, linked: bool = False
) -> set["rna_enums.OperatorReturnItems"]:
# Deep magick from the dawn of time
if tool.Ifc.get():
if tool.Ifc.get() and tool.Model.has_selected_ifc_objects(include_active=False):
IfcStore.execute_ifc_operator(operator, context)
return {"FINISHED"}
@@ -1287,6 +1288,9 @@ class OverrideDuplicateMove(bpy.types.Operator):
if part_obj:
all_objects_to_select.add(part_obj)
# Non-IFC duplicates aren't tracked in old_to_new but are left selected by duplicate_ifc_objects
all_objects_to_select.update(obj for obj in context.selected_objects if not tool.Ifc.get_entity(obj))
# Deselect everything first
bpy.ops.object.select_all(action="DESELECT")
@@ -2223,6 +2227,8 @@ class OverrideEscape(bpy.types.Operator):
bpy.ops.bim.hide_all_openings()
elif tool.Aggregate.get_aggregate_props().in_aggregate_mode:
bpy.ops.bim.disable_aggregate_mode()
elif preview_base.try_cancel_active_preview(context):
pass
elif active_object := context.active_object:
if tool.Blender.Modifier.try_canceling_editing_modifier_parameters_or_path(active_object):
pass
@@ -2264,6 +2270,8 @@ class OverrideModeSetEdit(bpy.types.Operator, tool.Ifc.Operator):
gprops = tool.Geometry.get_geometry_props()
if gprops.representation_obj:
tool.Geometry.disable_item_mode()
if active_obj := bpy.context.active_object:
active_obj.select_set(False)
else:
bonsai.core.aggregate.exit_aggregate_mode(tool.Aggregate)
return {"FINISHED"}
@@ -2350,6 +2358,7 @@ class OverrideModeSetEdit(bpy.types.Operator, tool.Ifc.Operator):
and usage in ("LAYER1", "LAYER2")
):
self.report({"INFO"}, f"Parametric {usage} elements cannot be edited directly")
obj.select_set(False)
elif item.is_a("IfcSweptAreaSolid"):
tool.Geometry.sync_item_positions()
res = tool.Model.import_profile((profile := item.SweptArea), obj=obj)
@@ -2358,6 +2367,7 @@ class OverrideModeSetEdit(bpy.types.Operator, tool.Ifc.Operator):
{"INFO"},
f"Couldn't import profile, editing it directly is not yet supported. Failing profile: {profile}.",
)
obj.select_set(False)
return
tool.Ifc.link(item, obj.data)
self.enable_edit_mode(context)
@@ -2485,9 +2495,9 @@ class OverrideModeSetObject(bpy.types.Operator, tool.Ifc.Operator):
profile = tool.Ifc.get().by_id(profile_id)
if tool.Ifc.get_object(profile): # We are editing an arbitrary profile
bpy.ops.bim.edit_arbitrary_profile()
elif tool.Blender.Modifier.is_railing(element):
elif tool.Parametric.is_railing(element):
bpy.ops.bim.finish_editing_railing_path()
elif tool.Blender.Modifier.is_roof(element):
elif tool.Parametric.is_roof(element):
bpy.ops.bim.finish_editing_roof_path()
elif tool.Model.get_usage_type(element) == "PROFILE":
bpy.ops.bim.edit_extrusion_axis()
+25 -2
View File
@@ -19,6 +19,7 @@
import bpy
from bpy.types import Menu, Panel, UIList
import ifcopenshell.util.unit
import bonsai.bim
import bonsai.tool as tool
from bonsai.bim.helper import prop_with_search
@@ -483,10 +484,32 @@ class BIM_PT_placement(Panel):
row.label(text="No Object Placement Found")
return
is_imperial = False
if tool.Ifc.get():
length_unit = ifcopenshell.util.unit.get_project_unit(tool.Ifc.get(), "LENGTHUNIT")
if length_unit and length_unit.Name != "METRE":
is_imperial = True
row = self.layout.row()
row.prop(context.active_object, "location", text="Location")
row.label(text="Location:")
if is_imperial:
loc = context.active_object.location
for i, (axis, comp) in enumerate(zip("XYZ", (loc.x, loc.y, loc.z))):
split = self.layout.split(factor=0.6)
split.prop(context.active_object, "location", index=i, text=axis)
sub = split.row()
sub.enabled = False
sub.alignment = "LEFT"
sub.label(text=tool.Unit.format_distance(comp))
else:
for i, axis in enumerate("XYZ"):
self.layout.prop(context.active_object, "location", index=i, text=axis)
row = self.layout.row()
row.prop(context.active_object, "rotation_euler", text="Rotation")
row.label(text="Rotation:")
for i, axis in enumerate("XYZ"):
self.layout.prop(context.active_object, "rotation_euler", index=i, text=axis)
if props.blender_offset_type != "NONE":
row = self.layout.row(align=True)
@@ -637,6 +637,16 @@ class EditAssignedMaterial(bpy.types.Operator, tool.Ifc.Operator):
usage=material_set_usage,
attributes=attributes,
)
for obj in objects:
obj_element = tool.Ifc.get_entity(obj)
if not obj_element:
continue
obj_material_usage = ifcopenshell.util.element.get_material(obj_element)
if obj_material_usage and obj_material_usage.is_a("IfcMaterialProfileSetUsage"):
obj_material_usage.CardinalPoint = material_set_usage.CardinalPoint
obj_material_usage.ReferenceExtent = material_set_usage.ReferenceExtent
model_profile.DumbProfileRecalculator().recalculate(objects)
bpy.ops.bim.disable_editing_assigned_material(obj=active_obj.name)
+67 -15
View File
@@ -15,11 +15,15 @@
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was modified with the assistance of an AI coding tool.
from typing import NamedTuple
import bpy
import bonsai.tool as tool
from . import (
array,
covering,
@@ -27,6 +31,7 @@ from . import (
external,
grid,
handler,
host_add_opening_gizmo,
mep,
opening,
product,
@@ -46,17 +51,28 @@ from . import (
classes = (
array.AddArray,
array.DisableEditingArray,
array.EditArray,
array.CancelEditingArray,
array.EnableEditingArray,
array.FinishEditingArray,
array.ApplyArray,
array.RegenerateArray,
array.RemoveArray,
array.SelectAllArrayObjects,
array.SelectArrayParent,
array.ArrayParentGizmoClick,
array.EditArrayFromChild,
array.Input3DCursorXArray,
array.Input3DCursorYArray,
array.Input3DCursorZArray,
array.EnableEditingParametric,
array.AddArrayFromFeatureEdit,
array.ArrayGizmoClick,
array.ToggleArrayMethod,
array.RemoveArrayLayerFromEdit,
array.InputArrayCount,
array.AdjustArrayCount,
array.GizmoArrayEdition,
array.GizmoArrayChild,
product.AddDefaultType,
product.AddEmptyType,
product.AddOccurrence,
@@ -70,19 +86,45 @@ classes = (
workspace.BIM_MT_add_representation_item,
wall.AddWallsFromSlab,
wall.AlignWall,
wall.CancelEditingWall,
wall.ChangeExtrusionDepth,
wall.ChangeExtrusionXAngle,
wall.ChangeLayerLength,
wall.CycleWallOffset,
wall.DrawPolylineWall,
wall.EnableEditingWall,
wall.ExtendWallHeightToCursor,
wall.ExtendWallsToUnderside,
wall.RegenerateWallToUnderside,
wall.ExtendWallsToWall,
wall.ExtendWallsToPolylinePoint,
wall.ExtendWallToCursor,
wall.FinishEditingWall,
wall.FlipWall,
host_add_opening_gizmo.GizmoHostAddOpening,
host_add_opening_gizmo.GizmoHostToggleOpenings,
wall.GizmoWallEdition,
wall.GizmoWallExtendVertically,
wall.GizmoWallFilletPreview,
wall.GizmoWallFilletReedit,
wall.GizmoWallFilletToggleOpenings,
wall.GizmoWallJoinIntersection,
wall.GizmoWallLinkToggle,
wall.GizmoWallUnjoinSingle,
wall.JoinWallsIntersection,
wall.MergeWall,
wall.OffsetWalls,
wall.RecalculateWall,
wall.RotateWall90,
wall.SplitWall,
wall.SplitWallAtCursor,
wall.UnjoinWallPathConnection,
wall.UnjoinWalls,
wall.EnableWallFilletPreview,
wall.FinishWallFilletPreview,
wall.CancelWallFilletPreview,
wall.EnableWallFilletPreviewFromCorner,
wall.CreateWallFillet,
opening.AddBoolean,
opening.CloneOpening,
opening.EditOpenings,
@@ -94,6 +136,7 @@ classes = (
opening.RemoveBoolean,
opening.SelectBoolean,
opening.ShowOpenings,
opening.ToggleHostOpenings,
opening.UpdateOpeningsFocus,
profile.ChangeCardinalPoint,
profile.ChangeProfileDepth,
@@ -140,10 +183,14 @@ classes = (
prop.BIMDoorProperties,
prop.BIMRailingProperties,
prop.BIMRoofProperties,
prop.BIMWallProperties,
prop.BIMPolylineProperties,
prop.BIMExternalParametricGeometryProperties,
prop.BIMWallFilletPreviewProperties,
prop.BIMPreviewProperties,
ui.BIM_PT_array,
ui.BIM_PT_stair,
ui.BIM_PT_wall,
ui.BIM_PT_sverchok,
ui.BIM_PT_window,
ui.BIM_PT_door,
@@ -164,7 +211,8 @@ classes = (
stair.ToggleStairProperty,
stair.AdjustStairTreads,
stair.SetStairTreads,
stair.CycleStairType,
stair.InputStairTreads,
stair.PickStairType,
stair.GizmoStairEdition,
sverchok_modifier.CreateNewSverchokGraph,
sverchok_modifier.UpdateDataFromSverchok,
@@ -177,7 +225,7 @@ classes = (
window.FinishEditingWindow,
window.EnableEditingWindow,
window.RemoveWindow,
window.CycleWindowType,
window.PickWindowType,
window.GizmoWindowEdition,
door.BIM_OT_add_door,
door.AddDoor,
@@ -186,7 +234,7 @@ classes = (
door.EnableEditingDoor,
door.RemoveDoor,
door.ToggleDoorSwing,
door.CycleDoorType,
door.PickDoorType,
door.GizmoDoorEdition,
railing.BIM_OT_add_railing,
railing.CopyRailingParameters,
@@ -203,11 +251,13 @@ classes = (
roof.AddRoof,
roof.CancelEditingRoof,
roof.CopyRoofParameters,
roof.CycleRoofGenerationMethod,
roof.FinishEditingRoof,
roof.EnableEditingRoof,
roof.CancelEditingRoofPath,
roof.FinishEditingRoofPath,
roof.EnableEditingRoofPath,
roof.GizmoRoofEdition,
roof.RemoveRoof,
roof.SetGableRoofEdgeAngle,
mep.MEPAddObstruction,
@@ -264,15 +314,14 @@ def register():
bpy.types.Scene.BIMModelProperties = bpy.props.PointerProperty(type=prop.BIMModelProperties)
bpy.types.Scene.BIMPolylineProperties = bpy.props.PointerProperty(type=prop.BIMPolylineProperties)
bpy.types.Object.BIMArrayProperties = bpy.props.PointerProperty(type=prop.BIMArrayProperties)
bpy.types.Object.BIMStairProperties = bpy.props.PointerProperty(type=prop.BIMStairProperties)
bpy.types.Object.BIMSverchokProperties = bpy.props.PointerProperty(type=prop.BIMSverchokProperties)
bpy.types.Object.BIMWindowProperties = bpy.props.PointerProperty(type=prop.BIMWindowProperties)
bpy.types.Object.BIMDoorProperties = bpy.props.PointerProperty(type=prop.BIMDoorProperties)
bpy.types.Object.BIMRailingProperties = bpy.props.PointerProperty(type=prop.BIMRailingProperties)
bpy.types.Object.BIMRoofProperties = bpy.props.PointerProperty(type=prop.BIMRoofProperties)
# Per-parametric-type ``BIM<Name>Properties`` PointerProperties — driven by
# ``tool.Parametric.EDIT_TYPES``; adding a registry entry is the single touchpoint.
tool.Parametric.register_object_properties(prop)
bpy.types.Object.BIMExternalParametricGeometryProperties = bpy.props.PointerProperty(
type=prop.BIMExternalParametricGeometryProperties
)
bpy.types.Scene.BIMPreviewProperties = bpy.props.PointerProperty(type=prop.BIMPreviewProperties)
bpy.types.VIEW3D_MT_add.prepend(ui.add_menu)
bpy.app.handlers.load_post.append(handler.load_post)
@@ -281,6 +330,12 @@ def register():
def unregister():
# DecorationsHandler is installed lazily by bim.show_openings; tear it down
# (along with its persistent depsgraph / undo / redo / load cache handlers)
# before the rest of unregister so those handlers can't fire against
# half-unloaded module state.
opening.DecorationsHandler.uninstall()
if not bpy.app.background:
for tool_data in reversed(tools):
bpy.utils.unregister_tool(tool_data.tool)
@@ -288,13 +343,10 @@ def unregister():
del bpy.types.Scene.BIMModelProperties
del bpy.types.Scene.BIMPolylineProperties
del bpy.types.Object.BIMArrayProperties
del bpy.types.Object.BIMStairProperties
del bpy.types.Object.BIMSverchokProperties
del bpy.types.Object.BIMWindowProperties
del bpy.types.Object.BIMDoorProperties
del bpy.types.Object.BIMRailingProperties
del bpy.types.Object.BIMRoofProperties
tool.Parametric.unregister_object_properties()
del bpy.types.Object.BIMExternalParametricGeometryProperties
del bpy.types.Scene.BIMPreviewProperties
bpy.app.handlers.load_post.remove(handler.load_post)
bpy.types.VIEW3D_MT_add.remove(ui.add_menu)
File diff suppressed because it is too large Load Diff
+246 -1
View File
@@ -108,7 +108,7 @@ class ProfileDecorator:
obj = context.active_object
if obj.mode != "EDIT":
if obj is None or obj.mode != "EDIT":
if exit_edit_mode_callback:
ProfileDecorator.uninstall()
exit_edit_mode_callback()
@@ -2029,3 +2029,248 @@ class BoundingBoxDecorator:
else:
co1.y += y_overlap / 2 + min_spacing
co2.y -= y_overlap / 2 + min_spacing
def _stroke_lines_alpha(
context: bpy.types.Context,
segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]],
color_rgb: tuple[float, float, float],
line_width: float,
line_alpha: float,
) -> None:
"""Render ``segments`` (a list of ``(start, end)`` tuples) as one
anti-aliased LINES batch in world space. Early-returns when
``context.region`` is unavailable (e.g. when called from a
``_RestrictContext``)."""
if not segments:
return
verts: list[tuple[float, float, float]] = []
indices: list[tuple[int, int]] = []
for start, end in segments:
base = len(verts)
verts.append(tuple(start))
verts.append(tuple(end))
indices.append((base, base + 1))
if not tool.Blender.validate_shader_batch_data(verts, indices):
return
region = getattr(context, "region", None)
if region is None:
return
shader = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR")
shader.bind()
shader.uniform_float("viewportSize", (region.width, region.height))
shader.uniform_float("lineWidth", line_width)
shader.uniform_float("color", (*color_rgb, line_alpha))
batch = batch_for_shader(shader, "LINES", {"pos": verts}, indices=indices)
gpu.state.blend_set("ALPHA")
batch.draw(shader)
gpu.state.blend_set("NONE")
def _fill_quads_alpha(
context: bpy.types.Context,
quads: list[
tuple[
tuple[float, float, float],
tuple[float, float, float],
tuple[float, float, float],
tuple[float, float, float],
]
],
color_rgb: tuple[float, float, float],
alpha: float,
) -> None:
"""Render ``quads`` (each a 4-tuple of world-space corner verts in CCW
order) as one TRIS batch with two triangles per quad. Companion to
``_stroke_lines_alpha`` for filled previews."""
if not quads:
return
verts: list[tuple[float, float, float]] = []
indices: list[tuple[int, int, int]] = []
for quad in quads:
if len(quad) != 4:
continue
base = len(verts)
verts.extend(tuple(v) for v in quad)
indices.append((base, base + 1, base + 2))
indices.append((base, base + 2, base + 3))
if not tool.Blender.validate_shader_batch_data(verts, indices):
return
region = getattr(context, "region", None)
if region is None:
return
shader = gpu.shader.from_builtin("UNIFORM_COLOR")
shader.bind()
shader.uniform_float("color", (*color_rgb, alpha))
batch = batch_for_shader(shader, "TRIS", {"pos": verts}, indices=indices)
gpu.state.blend_set("ALPHA")
batch.draw(shader)
gpu.state.blend_set("NONE")
class WallFilletPreviewDecorator(tool.Blender.ViewportDecorator):
"""GPU preview lines for the wall-fillet flow.
Polls on ``scene.BIMPreviewProperties.wall_fillet.is_active`` and renders
the leg projections + arc + radial construction lines returned by
``tool.Wall.compute_wall_fillet_geometry``. The two leg lines show how
each wall will be shortened to its tangent point; the arc approximates
the rounded corner; the two construction lines (arc center to each
tangent point) visually pin the radius.
Installed once per Blender session from ``bim/handler.py:load_post``
and uninstalled in ``bim/module/model/__init__.py:unregister``."""
LINE_WIDTH_LEG = 1.5
LINE_WIDTH_ARC = 2.5
LINE_WIDTH_CONSTRUCTION = 1.0
LINE_ALPHA = 0.7
CONSTRUCTION_ALPHA = 0.4
def draw(self, context: bpy.types.Context) -> None:
scene = context.scene
preview_props = getattr(scene, "BIMPreviewProperties", None)
props = preview_props.wall_fillet if preview_props is not None else None
if props is None or not props.is_active:
return
ifc_file = tool.Ifc.get()
if ifc_file is None:
return
try:
wall_a = ifc_file.by_id(props.wall_a_id)
wall_b = ifc_file.by_id(props.wall_b_id)
except Exception:
return
wall_a_obj = tool.Ifc.get_object(wall_a) if wall_a else None
wall_b_obj = tool.Ifc.get_object(wall_b) if wall_b else None
if wall_a_obj is None or wall_b_obj is None:
return
geom = tool.Wall.compute_wall_fillet_geometry(wall_a_obj, wall_b_obj, props.radius)
if geom is None:
return
prefs = tool.Blender.get_addon_preferences()
warning_color = tuple(prefs.decorator_color_error[:3])
if not geom["valid"]:
# Degenerate geometry paints red: invalid_radius shows legs+arc
# past the wall ends; invalid_axes shows the parallel/collinear
# axes.
if geom.get("invalid_radius"):
tangent_a = geom.get("tangent_a")
tangent_b = geom.get("tangent_b")
ref_a = tool.Wall.get_world_reference_line(wall_a_obj)
ref_b = tool.Wall.get_world_reference_line(wall_b_obj)
if tangent_a is not None and tangent_b is not None and ref_a is not None and ref_b is not None:
far_a = self._far_endpoint(ref_a, geom["intersection"])
far_b = self._far_endpoint(ref_b, geom["intersection"])
legs = [
(tuple(far_a), tuple(tangent_a)),
(tuple(far_b), tuple(tangent_b)),
]
_stroke_lines_alpha(context, legs, warning_color, self.LINE_WIDTH_LEG, self.LINE_ALPHA)
arc = geom.get("arc") or []
if len(arc) >= 2:
arc_segments = [(tuple(arc[i]), tuple(arc[i + 1])) for i in range(len(arc) - 1)]
_stroke_lines_alpha(context, arc_segments, warning_color, self.LINE_WIDTH_ARC, self.LINE_ALPHA)
elif geom.get("invalid_axes"):
axes = geom["invalid_axes"]
segments = [(tuple(a), tuple(b)) for a, b in axes]
_stroke_lines_alpha(context, segments, warning_color, self.LINE_WIDTH_ARC, self.LINE_ALPHA)
return
leg_color = tuple(prefs.decorations_colour[:3])
arc_color = tuple(prefs.decorator_color_selected[:3])
# Resolved against the IFC reference line, not mesh bounds, so trimmed
# walls and openings don't shift the leg endpoints.
ref_a = tool.Wall.get_world_reference_line(wall_a_obj)
ref_b = tool.Wall.get_world_reference_line(wall_b_obj)
if ref_a is not None and ref_b is not None and geom["intersection"] is not None:
far_a = self._far_endpoint(ref_a, geom["intersection"])
far_b = self._far_endpoint(ref_b, geom["intersection"])
legs = [
(tuple(far_a), tuple(geom["tangent_a"])),
(tuple(far_b), tuple(geom["tangent_b"])),
]
_stroke_lines_alpha(context, legs, leg_color, self.LINE_WIDTH_LEG, self.LINE_ALPHA)
arc = geom["arc"]
if len(arc) >= 2:
arc_segments = [(tuple(arc[i]), tuple(arc[i + 1])) for i in range(len(arc) - 1)]
_stroke_lines_alpha(context, arc_segments, arc_color, self.LINE_WIDTH_ARC, self.LINE_ALPHA)
# Dim construction lines from arc_center to each tangent point so
# the radius reads as concrete during drag.
arc_center = geom.get("arc_center")
if arc_center is not None:
construction = [
(tuple(arc_center), tuple(geom["tangent_a"])),
(tuple(arc_center), tuple(geom["tangent_b"])),
]
_stroke_lines_alpha(context, construction, arc_color, self.LINE_WIDTH_CONSTRUCTION, self.CONSTRUCTION_ALPHA)
@staticmethod
def _far_endpoint(reference_line, intersection):
"""Endpoint of ``reference_line`` furthest from ``intersection``."""
p1, p2 = reference_line
d1 = (p1.x - intersection[0]) ** 2 + (p1.y - intersection[1]) ** 2 + (p1.z - intersection[2]) ** 2
d2 = (p2.x - intersection[0]) ** 2 + (p2.y - intersection[1]) ** 2 + (p2.z - intersection[2]) ** 2
return p2 if d2 >= d1 else p1
_BBOX_EDGES = (
(0, 1), (1, 2), (2, 3), (3, 0),
(4, 5), (5, 6), (6, 7), (7, 4),
(0, 4), (1, 5), (2, 6), (3, 7),
) # fmt: skip
def bbox_world_edges(
obj: bpy.types.Object,
) -> list[tuple[tuple[float, float, float], tuple[float, float, float]]]:
"""Return world-space (start, end) tuples for the 12 edges of ``obj``'s
bounding box. Empty list if the object has no bound_box (e.g. Empties)."""
if not obj.bound_box:
return []
mw = obj.matrix_world
corners = [mw @ Vector(c) for c in obj.bound_box]
return [(tuple(corners[a]), tuple(corners[b])) for a, b in _BBOX_EDGES]
def draw_polyline_segments(
context: bpy.types.Context,
segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]],
color_rgb: tuple[float, float, float],
alpha: float,
line_width: float,
) -> None:
"""Render ``segments`` as one anti-aliased LINES batch in world space."""
if not segments:
return
verts: list[tuple[float, float, float]] = []
indices: list[tuple[int, int]] = []
for start, end in segments:
base = len(verts)
verts.append(start)
verts.append(end)
indices.append((base, base + 1))
if not tool.Blender.validate_shader_batch_data(verts, indices):
return
region = getattr(context, "region", None)
if region is None:
return
shader = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR")
shader.bind()
shader.uniform_float("viewportSize", (region.width, region.height))
shader.uniform_float("lineWidth", line_width)
shader.uniform_float("color", (*color_rgb, alpha))
batch = batch_for_shader(shader, "LINES", {"pos": verts}, indices=indices)
gpu.state.blend_set("ALPHA")
batch.draw(shader)
gpu.state.blend_set("NONE")
_BBOX_HIGHLIGHT_LINE_WIDTH = 1.8
_BBOX_HIGHLIGHT_LINE_ALPHA = 0.8
+120 -138
View File
@@ -37,7 +37,9 @@ import bonsai.core.root
import bonsai.tool as tool
from bonsai.bim.module.drawing import gizmos as gizmo
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
from bonsai.bim.module.model.wall_offset_gizmos import WALL_OFFSET_GIZMO_CONFIGS
from bonsai.bim.module.model.window import create_bm_box, create_bm_window
from bonsai.bim.parametric_lifecycle import FeatureModifierEditMixin, PickTypeMixin
if TYPE_CHECKING:
from bonsai.bim.module.model.prop import BIMDoorProperties
@@ -566,103 +568,58 @@ class AddDoor(bpy.types.Operator, tool.Ifc.Operator):
return {"FINISHED"}
class CancelEditingDoor(bpy.types.Operator, tool.Ifc.Operator):
class _DoorEditMixin(FeatureModifierEditMixin):
"""Type-specific hooks for door parametric-edit operators. Multi-object —
iterates ``tool.Blender.get_selected_objects()`` so a finish/cancel applies
to every selected door at once."""
pset_name = "BBIM_Door"
@classmethod
def _iter_targets(cls, context: bpy.types.Context) -> list[bpy.types.Object]:
return tool.Blender.get_selected_objects()
@classmethod
def _is_element_type(cls, element):
return tool.Parametric.is_door(element)
@classmethod
def _get_props(cls, obj: bpy.types.Object):
return tool.Model.get_door_props(obj)
@classmethod
def _update_modifier_representation(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
update_door_modifier_representation(obj)
class CancelEditingDoor(_DoorEditMixin, bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.cancel_editing_door"
bl_label = "Cancel Editing Door on Selected Objects"
bl_description = "Cancel editing and revert door parameters to their previous values"
bl_options = {"REGISTER", "UNDO"}
def cancel_editing_door_on_object(self, obj: bpy.types.Object) -> None:
element = tool.Ifc.get_entity(obj)
assert element
if not tool.Blender.Modifier.is_door(element):
return
props = tool.Model.get_door_props(obj)
data = json.loads(ifcopenshell.util.element.get_pset(element, "BBIM_Door", "Data"))
data.update(data.pop("lining_properties"))
data.update(data.pop("panel_properties"))
# restore previous settings since editing was canceled
props.set_props_kwargs_from_ifc_data(data)
body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
core.switch_representation(
tool.Ifc,
tool.Geometry,
obj=obj,
representation=body,
)
props.is_editing = False
def _execute(self, context: bpy.types.Context) -> set[str]: # noqa: ARG002
for obj in tool.Blender.get_selected_objects():
self.cancel_editing_door_on_object(obj)
return {"FINISHED"}
def _execute(self, context: bpy.types.Context) -> set[str]:
return self._cancel_targets(context)
class FinishEditingDoor(bpy.types.Operator, tool.Ifc.Operator):
class FinishEditingDoor(_DoorEditMixin, bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.finish_editing_door"
bl_label = "Finish Editing Door on Selected Objects"
bl_description = "Apply changes and finish editing door parameters"
bl_options = {"REGISTER", "UNDO"}
def finish_editing_door_on_object(self, obj: bpy.types.Object) -> None:
element = tool.Ifc.get_entity(obj)
assert element
if not tool.Blender.Modifier.is_door(element):
return
props = tool.Model.get_door_props(obj)
door_data = props.get_general_kwargs(convert_to_project_units=True)
lining_props = props.get_lining_kwargs(convert_to_project_units=True)
panel_props = props.get_panel_kwargs(convert_to_project_units=True)
door_data["lining_properties"] = lining_props
door_data["panel_properties"] = panel_props
props.is_editing = False
update_door_modifier_representation(obj)
element_type = ifcopenshell.util.element.get_type(element)
if element_type:
tool.Model.mark_thumbnail_for_update(element_type)
pset = tool.Pset.get_element_pset(element, "BBIM_Door")
door_data = tool.Ifc.get().createIfcText(json.dumps(door_data, default=list))
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Data": door_data})
def _execute(self, context: bpy.types.Context) -> set[str]: # noqa: ARG002
for obj in tool.Blender.get_selected_objects():
self.finish_editing_door_on_object(obj)
return {"FINISHED"}
def _execute(self, context: bpy.types.Context) -> set[str]:
return self._finish_targets(context)
class EnableEditingDoor(bpy.types.Operator, tool.Ifc.Operator):
class EnableEditingDoor(_DoorEditMixin, bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.enable_editing_door"
bl_label = "Enable Editing Door on Selected Objects"
bl_description = "Enter edit mode to modify door parameters interactively"
bl_options = {"REGISTER", "UNDO"}
def edit_door_on_obj(self, obj: bpy.types.Object) -> None:
element = tool.Ifc.get_entity(obj)
assert element
if not tool.Blender.Modifier.is_door(element):
return
props = tool.Model.get_door_props(obj)
data = json.loads(ifcopenshell.util.element.get_pset(element, "BBIM_Door", "Data"))
data.update(data.pop("lining_properties"))
data.update(data.pop("panel_properties"))
data.update(tool.Model.get_constituents_props_data(element))
# required since we could load pset from .ifc and BIMDoorProperties won't be set
props.set_props_kwargs_from_ifc_data(data)
props.is_editing = True
def _execute(self, context: bpy.types.Context) -> set[str]: # noqa: ARG002
for obj in tool.Blender.get_selected_objects():
self.edit_door_on_obj(obj)
return {"FINISHED"}
def _execute(self, context: bpy.types.Context) -> set[str]:
return self._enable_targets(context)
class RemoveDoor(bpy.types.Operator, tool.Ifc.Operator):
@@ -673,7 +630,7 @@ class RemoveDoor(bpy.types.Operator, tool.Ifc.Operator):
def remove_door_on_object(self, obj: bpy.types.Object) -> None:
element = tool.Ifc.get_entity(obj)
assert element
if not tool.Blender.Modifier.is_door(element):
if not tool.Parametric.is_door(element):
return
props = tool.Model.get_door_props(obj)
props.is_editing = False
@@ -688,12 +645,8 @@ class RemoveDoor(bpy.types.Operator, tool.Ifc.Operator):
class ToggleDoorSwing(bpy.types.Operator, tool.Ifc.Operator):
"""Toggle door swing direction and optionally flip door geometry.
Shift+Click (when flip_geometry=True): Flip geometry only without changing door direction"""
bl_idname = "bim.toggle_door_swing"
bl_label = "Toggle Door Swing"
bl_label = "Change Door Swing"
bl_options = {"REGISTER", "UNDO"}
flip_geometry: bpy.props.BoolProperty(name="Flip Geometry", default=False)
@@ -704,6 +657,15 @@ class ToggleDoorSwing(bpy.types.Operator, tool.Ifc.Operator):
name="Skip Direction Change", default=False, options={"HIDDEN", "SKIP_SAVE"}
)
@classmethod
def description(cls, context: bpy.types.Context, properties: bpy.types.OperatorProperties) -> str:
if properties.flip_geometry:
return (
"Swing the door from the opposite side of the wall. "
"Shift+click: mirror the door without changing which side it opens to"
)
return "Move the door hinge to the opposite side"
def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]:
self.skip_direction_change = event.shift
return self.execute(context)
@@ -730,7 +692,7 @@ class ToggleDoorSwing(bpy.types.Operator, tool.Ifc.Operator):
if not element:
return {"CANCELLED"}
is_door = tool.Blender.Modifier.is_door(element)
is_door = tool.Parametric.is_door(element)
if self.flip_geometry:
tool.Geometry.flip_object(obj, self.flip_local_axes)
@@ -744,20 +706,20 @@ class ToggleDoorSwing(bpy.types.Operator, tool.Ifc.Operator):
return {"FINISHED"}
class CycleDoorType(bpy.types.Operator, tool.Ifc.Operator, gizmo.CycleTypeMixin):
"""Cycle through available door types. Shift+click to cycle in reverse."""
class PickDoorType(bpy.types.Operator, tool.Ifc.Operator, PickTypeMixin):
"""Pick a door type from a popup menu."""
bl_idname = "bim.cycle_door_type"
bl_label = "Cycle Door Type"
bl_idname = "bim.pick_door_type"
bl_label = "Pick Door Type"
bl_options = {"REGISTER", "UNDO"}
element_checker = "is_door"
props_getter = "get_door_props"
element_checker = tool.Parametric.is_door
props_getter = tool.Model.get_door_props
type_literal = tool.Model.DoorType
type_attr = "door_type"
def _execute(self, context: bpy.types.Context) -> set[str]:
return self._cycle_type(context)
return self._pick_type(context)
class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
@@ -770,7 +732,7 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
enable_editing_operator = "bim.enable_editing_door"
finish_editing_operator = "bim.finish_editing_door"
cancel_editing_operator = "bim.cancel_editing_door"
cycle_type_operator = "bim.cycle_door_type"
pick_type_operator = "bim.pick_door_type"
# Declarative dimension gizmo configuration with visibility and position
# matrix_position lambdas replace the get_dimension_matrix_* methods
@@ -877,14 +839,44 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
p.get_transom_window_center_z(),
),
),
*WALL_OFFSET_GIZMO_CONFIGS,
]
props_getter = "get_door_props"
# Big quarter-arc hit shapes cover much of the door face — without a
# negative select_bias they would steal clicks from the small dimension
# and edit gizmos drawn on top of them.
SWING_ARC_SELECT_BIAS = -1000.0
swing_arc_operator = "bim.toggle_door_swing"
swing_arc_props = [
gizmo.SwingArcConfig(
name="primary",
visibility_condition=lambda p: p.is_editing and "SLIDING" not in p.door_type,
hinge_x=lambda p: (
p.overall_width if p.door_type.endswith("RIGHT") and "DOUBLE_DOOR" not in p.door_type else 0.0
),
hinge_y=lambda p: p.lining_offset,
panel_width=lambda p: p.overall_width / 2 if "DOUBLE_DOOR" in p.door_type else p.overall_width,
x_mirror=lambda p: p.door_type.endswith("RIGHT") and "DOUBLE_DOOR" not in p.door_type,
),
gizmo.SwingArcConfig(
name="secondary",
visibility_condition=lambda p: p.is_editing
and "DOUBLE_DOOR" in p.door_type
and "SLIDING" not in p.door_type,
hinge_x=lambda p: p.overall_width,
hinge_y=lambda p: p.lining_offset,
panel_width=lambda p: p.overall_width / 2,
x_mirror=lambda _p: True,
),
]
props_getter = tool.Model.get_door_props
gizmo_pref_name = "door"
@classmethod
def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool:
return tool.Blender.Modifier.is_door(element)
return tool.Parametric.is_door(element)
def get_icon_y_extent(self, props: "BIMDoorProperties") -> tuple[float, float]:
"""Get Y extents for door icon positioning.
@@ -902,24 +894,20 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
return (furthest_y, furthest_y)
def setup_element_specific_gizmos(self, context: bpy.types.Context) -> None:
"""Create door-specific swing arc gizmos."""
prefs = tool.Blender.get_addon_preferences()
inactive_color = prefs.decorator_color_background[:3]
special_color = prefs.decorator_color_special[:3]
"""Create one (main, flip) swing-arc pair per ``swing_arc_props`` entry.
self.gizmo_door_type = self.create_arc_gizmo(
special_color,
"bim.toggle_door_swing",
prop_path="BIMDoorProperties.door_type",
flip_geometry=False,
)
self.gizmo_flip_arc = self.create_arc_gizmo(
inactive_color,
"bim.toggle_door_swing",
prop_path="BIMDoorProperties.door_type",
flip_geometry=True,
flip_local_axes="XY",
)
Stored as ``self.gizmo_swing_arc_<name>`` and ``self.gizmo_swing_arc_<name>_flip``
and pinned to ``SWING_ARC_SELECT_BIAS`` so other door gizmos win selection."""
prefs = tool.Blender.get_addon_preferences()
main_color = prefs.decorator_color_special[:3]
flip_color = prefs.decorator_color_background[:3]
for cfg in self.swing_arc_props:
main = self.create_arc_gizmo(main_color, self.swing_arc_operator, flip_geometry=False)
flip = self.create_arc_gizmo(flip_color, self.swing_arc_operator, flip_geometry=True)
for gz in (main, flip):
gz.select_bias = self.SWING_ARC_SELECT_BIAS
setattr(self, f"gizmo_swing_arc_{cfg.name}", main)
setattr(self, f"gizmo_swing_arc_{cfg.name}_flip", flip)
def _refresh_element_specific(
self, context: bpy.types.Context, mw: Matrix, props: "BIMDoorProperties" # noqa: ARG002
@@ -938,29 +926,23 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
self._update_view_dependent_dimensions(context, mw, props)
def update_swing_gizmos(self, mw: Matrix, props: "BIMDoorProperties") -> None:
"""Update swing gizmo position and color based on editing state."""
prefs = tool.Blender.get_addon_preferences()
door_gizmo_prefs = prefs.gizmos.door
door_type_visible = self.update_gizmo_visibility(
self.gizmo_door_type, props.is_editing, door_gizmo_prefs.swing_arc
)
flip_arc_visible = self.update_gizmo_visibility(
self.gizmo_flip_arc, props.is_editing, door_gizmo_prefs.flip_arc
)
if not door_type_visible and not flip_arc_visible:
return
swing_x_offset = props.overall_width if "RIGHT" in props.door_type else 0.0
base_swing_transform = Matrix.Translation(V_(swing_x_offset, props.lining_offset, 0)) @ Matrix.Scale(
props.overall_width, 4
)
if door_type_visible:
self.gizmo_door_type.matrix_basis = mw @ base_swing_transform
self.gizmo_door_type.color = prefs.decorations_colour[:3]
if flip_arc_visible:
mirror_y = Matrix.Scale(-1, 4, (0, 1, 0))
self.gizmo_flip_arc.matrix_basis = mw @ base_swing_transform @ mirror_y
"""Position each declared swing-arc pair per its config + props state."""
mirror_y = Matrix.Scale(-1, 4, (0, 1, 0))
for cfg in self.swing_arc_props:
main = getattr(self, f"gizmo_swing_arc_{cfg.name}")
flip = getattr(self, f"gizmo_swing_arc_{cfg.name}_flip")
show = cfg.visibility_condition(props)
main_visible = self.update_gizmo_visibility(main, show)
flip_visible = self.update_gizmo_visibility(flip, show)
if not (main_visible or flip_visible):
continue
x_flip = Matrix.Scale(-1, 4, (1, 0, 0)) if cfg.x_mirror(props) else Matrix.Identity(4)
transform = (
Matrix.Translation(V_(cfg.hinge_x(props), cfg.hinge_y(props), 0))
@ Matrix.Scale(cfg.panel_width(props), 4)
@ x_flip
)
if main_visible:
main.matrix_basis = mw @ transform
if flip_visible:
flip.matrix_basis = mw @ transform @ mirror_y
@@ -0,0 +1,221 @@
# 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.
"""Generic single-click "Add Opening" gizmo for hosts (walls, slabs, roofs).
One GizmoGroup serves every IFC host type that exposes ``HasOpenings``:
parametric LAYER2 walls, any ``IfcSlab``, and any ``IfcRoof``. The poll
guards host-host pairings so this gizmo never overlaps with the existing
wall-join / extend-vertically gizmos. The positioner dispatches on element
type walls use axis-projection + camera-facing-Y math (which requires the
parametric layer-set); slabs and roofs use a world-Z face bias driven by
the void object's elevation against the host's bounding box."""
import bpy
from mathutils import Vector
import bonsai.tool as tool
from bonsai.bim.module.drawing import gizmos as gizmo
from bonsai.bim.module.model.wall import (
_get_wall_geom_cached,
_wall_camera_facing_icon_y,
_wall_gizmo_poll_gate,
_WallGeomCachedBillboardingMixin,
)
def is_supported_host(element) -> bool:
"""Total predicate (None → False). Walls accept either a parametric
LAYER2 wall OR a fillet-corner wall (both expose a usable axis +
layer-set for the anchor math); slabs and roofs only need the bound
box so any IfcSlab / IfcRoof qualifies regardless of parametric
modifier state."""
if element is None:
return False
return tool.Parametric.is_path_connectable_wall(element) or element.is_a("IfcSlab") or element.is_a("IfcRoof")
def _resolve_active_host(context: bpy.types.Context, n_selected: int):
"""Shared poll prologue: gizmo gate + selection cardinality + active-in-
selected + IFC entity lookup + supported-host predicate. Returns the
active element on success, ``None`` on any failure callers chain their
feature-specific checks past the early-return."""
if not _wall_gizmo_poll_gate(context):
return None
selected = tool.Blender.get_selected_objects()
if len(selected) != n_selected:
return None
active = context.active_object
if active is None or active not in selected:
return None
element = tool.Ifc.get_entity(active)
if not element or not is_supported_host(element):
return None
return element
class GizmoHostAddOpening(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin):
"""Activates when a host element (wall / slab / roof) is the active object
and exactly one other selected object is *not* itself a host.
Renders a single ``VIEW3D_GT_add_opening`` icon at the void object's
projected location on the host. A click dispatches ``bim.add_opening``,
which handles any element exposing the ``HasOpenings`` inverse.
Per-frame positioning keeps the icon facing the camera as the viewport
orbits."""
bl_idname = "OBJECT_GGT_bim_host_add_opening"
bl_label = "Host Add Opening Gizmo"
bl_space_type = "VIEW_3D"
bl_region_type = "WINDOW"
bl_options = {"3D", "PERSISTENT"}
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
element = _resolve_active_host(context, n_selected=2)
if element is None:
return False
# The operator itself filters on HasOpenings, but checking here keeps
# the icon from appearing on host classes that can't accept openings
# in the active IFC schema.
if not hasattr(element, "HasOpenings"):
return False
active = context.active_object
other = next(o for o in tool.Blender.get_selected_objects() if o is not active)
# Host + host pairings are claimed by host-specific gizmos (wall-join,
# extend-vertical, …) — suppress here so the add-opening icon never
# stacks on top of them.
if is_supported_host(tool.Ifc.get_entity(other)):
return False
return True
def setup(self, context: bpy.types.Context) -> None:
default_color, highlight_color = self.get_decoration_colors()
self.add_opening_icon = self.setup_icon_gizmo(
"VIEW3D_GT_add_opening", default_color, highlight_color, "bim.add_opening"
)
def position_gizmos(self, context: bpy.types.Context) -> None:
host_obj = context.active_object
if not host_obj:
return
selected = tool.Blender.get_selected_objects()
other = next((o for o in selected if o is not host_obj), None)
if not other:
return
element = tool.Ifc.get_entity(host_obj)
if not element:
return
if tool.Parametric.is_path_connectable_wall(element):
world_pos = wall_anchor(context, self, host_obj, other)
else:
world_pos = layer3_anchor(host_obj, other)
if world_pos is None:
return
self.add_opening_icon.matrix_basis = gizmo.billboarded_at(world_pos, gizmo.get_billboard_rotation(context))
def wall_anchor(
context: bpy.types.Context, group: bpy.types.GizmoGroup, wall_obj: bpy.types.Object, other: bpy.types.Object
) -> Vector | None:
"""World-space anchor for the add-opening icon on a wall host: void origin
projected onto the wall reference-line X (clamped to wall extents), lifted to
the camera-facing wall-local Y."""
geom = _get_wall_geom_cached(group, wall_obj)
if not geom:
return None
mw = wall_obj.matrix_world
wall_local = mw.inverted() @ other.matrix_world.translation
local_x = max(geom["anchor_x"], min(wall_local.x, geom["anchor_x"] + geom["length"]))
icon_y = _wall_camera_facing_icon_y(context, mw, geom)
base_world = mw @ Vector((local_x, icon_y, 0.0))
top_world = mw @ Vector((local_x, icon_y, geom["height"] + gizmo.BaseParametricGizmoGroup.ICON_Z_OFFSET))
return gizmo.BaseParametricGizmoGroup.pick_visible_anchor(context, base_world, top_world)
def layer3_anchor(host_obj: bpy.types.Object, other: bpy.types.Object) -> Vector:
"""World-space anchor for the add-opening icon on a LAYER3 host (slab / roof):
void's world XY, lifted just above the host's top face. Predictable height
regardless of where the void sits vertically clicking the icon places the
opening at the void's XY, and the operator handles the actual cut depth."""
bbox = tool.Blender.get_object_world_bounding_box(host_obj)
anchor_xy = other.matrix_world.translation.xy
top_z = bbox["max_z"] + gizmo.BaseParametricGizmoGroup.ICON_Z_OFFSET
return Vector((anchor_xy.x, anchor_xy.y, top_z))
def host_toggle_anchor(host_obj: bpy.types.Object) -> Vector:
"""Object origin XY, lifted just above the topmost mesh vertex. Tracks
the parametric origin (useful reference even when the mesh extends
asymmetrically) and the visible top face (stays clear of sloped or
stepped bodies)."""
origin = host_obj.matrix_world.translation
top_z = tool.Blender.get_object_world_bounding_box(host_obj)["max_z"] + gizmo.BaseParametricGizmoGroup.ICON_Z_OFFSET
return Vector((origin.x, origin.y, top_z))
class GizmoHostToggleOpenings(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin):
"""Fallback toggle-openings icon for hosts that lack their own
parametric-edit toolbar slabs today, plus any foreign-authored
IfcRoof that carries no BBIM_Roof pset (so ``GizmoRoofEdition`` doesn't
poll for it). Walls and parametric roofs already render an idle-row
toggle next to the pen and are excluded from this poll.
When slab parametric-edit lands the slab branch will pen-row-handle
its own toggle; updating the exclusion predicate here is the only
migration step needed."""
bl_idname = "OBJECT_GGT_bim_host_toggle_openings"
bl_label = "Host Toggle Openings Gizmo"
bl_space_type = "VIEW_3D"
bl_region_type = "WINDOW"
bl_options = {"3D", "PERSISTENT"}
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
element = _resolve_active_host(context, n_selected=1)
if element is None:
return False
if not tool.Geometry.has_openings(element):
return False
# Skip when a per-feature parametric-edit gizmo already surfaces
# an idle-row toggle for this element — walls and parametric roofs
# both render their own toggle in the pen row.
if tool.Parametric.is_path_connectable_wall(element):
return False
if tool.Parametric.is_roof(element):
return False
return True
def setup(self, context: bpy.types.Context) -> None:
default_color, highlight_color = self.get_decoration_colors()
self.toggle_openings_icon = self.setup_icon_gizmo(
"VIEW3D_GT_add_opening", default_color, highlight_color, "bim.toggle_host_openings"
)
def position_gizmos(self, context: bpy.types.Context) -> None:
host_obj = context.active_object
if not host_obj:
return
self.toggle_openings_icon.matrix_basis = gizmo.billboarded_at(
host_toggle_anchor(host_obj), gizmo.get_billboard_rotation(context)
)
+257 -24
View File
@@ -41,8 +41,187 @@ from mathutils import Matrix, Vector
import bonsai.core.geometry
import bonsai.tool as tool
from bonsai.bim import decorator_cache
from bonsai.bim.module.drawing.decoration import DecoratorData
# Multi-entry cache for the opening preview's dissolved-edges fallback.
# Single-entry wouldn't fit: the draw handler iterates every active opening
# per frame, each with its own mesh. Bumped wholesale on the shared
# decorator-cache token (depsgraph / undo / redo / load), one slot per
# (mesh.session_uid, angle_limit). Outlier vs. the per-object caches below —
# consulted only on world-draw-data miss, so the global wipe rarely fires in
# steady state and the simpler invalidation is enough.
_dissolved_edges_cache: dict[
tuple[int, float],
tuple[list[Vector], list[tuple[int, int]]],
] = {}
_dissolved_edges_cache_token: int = -1
def _get_cached_dissolved_edges(
mesh: bpy.types.Mesh,
angle_limit: float = radians(1.0),
) -> tuple[list[Vector], list[tuple[int, int]]]:
global _dissolved_edges_cache_token
token = decorator_cache.get_decorator_cache_token()
if token != _dissolved_edges_cache_token:
_dissolved_edges_cache.clear()
_dissolved_edges_cache_token = token
key = (mesh.session_uid, angle_limit)
cached = _dissolved_edges_cache.get(key)
if cached is not None:
return cached
result = tool.Geometry.get_dissolved_edges(mesh, angle_limit=angle_limit)
_dissolved_edges_cache[key] = result
return result
# Per-object epoch: bumped only when this specific object's transform or geometry
# updates land in the depsgraph delta. Invalidation work scales with the number
# of changed objects, not total scene size — moving one object leaves every
# other entry valid. Bumped by the depsgraph handler below; cleared on
# undo/redo/load alongside the cache dicts.
_object_epochs: dict[int, int] = {}
@bpy.app.handlers.persistent
def _bump_object_epochs_for_decoration(*args) -> None:
# depsgraph_update_post is called as (scene, depsgraph) in 4.x but the
# *args signature follows decorator_cache's defensive idiom.
depsgraph = args[1] if len(args) >= 2 else None
if depsgraph is None or not hasattr(depsgraph, "updates"):
return
for u in depsgraph.updates:
if not isinstance(u.id, bpy.types.Object):
continue
if not (u.is_updated_geometry or u.is_updated_transform):
continue
# u.id is the evaluated COW copy; the cache keys are written from the
# original Object (read by the draw handler), and session_uid can
# differ across the COW boundary. Resolve to the original before keying.
original = getattr(u.id, "original", u.id)
if original is None:
continue
uid = original.session_uid
_object_epochs[uid] = _object_epochs.get(uid, 0) + 1
@bpy.app.handlers.persistent
def _clear_decoration_caches_globally(*args) -> None:
# Undo/redo/load: depsgraph deltas can't be trusted to describe the
# transition, so wipe every per-object cache state.
_object_epochs.clear()
_world_draw_data_cache.clear()
_batch_cache.clear()
def _decoration_invalidation_hooks() -> tuple:
return (
bpy.app.handlers.undo_post,
bpy.app.handlers.redo_post,
bpy.app.handlers.load_post,
)
def install_decoration_cache_handlers() -> None:
if _bump_object_epochs_for_decoration not in bpy.app.handlers.depsgraph_update_post:
bpy.app.handlers.depsgraph_update_post.append(_bump_object_epochs_for_decoration)
for hook in _decoration_invalidation_hooks():
if _clear_decoration_caches_globally not in hook:
hook.append(_clear_decoration_caches_globally)
def uninstall_decoration_cache_handlers() -> None:
try:
bpy.app.handlers.depsgraph_update_post.remove(_bump_object_epochs_for_decoration)
except ValueError:
pass
for hook in _decoration_invalidation_hooks():
try:
hook.remove(_clear_decoration_caches_globally)
except ValueError:
pass
# Per-object world-space draw payload: line_verts (dissolved or ios_edges-filtered),
# verts (full mesh, indexed by loop_triangles), edges_indices, tris. Entries are
# (epoch, payload) tuples; lookup compares epoch to _object_epochs[uid], so a
# stale entry for an object that didn't change since the last build still hits.
_world_draw_data_cache: dict[
int,
tuple[
int,
tuple[
list[tuple[float, float, float]],
list[tuple[float, float, float]],
list[tuple[int, int]],
list[tuple[int, ...]],
],
],
] = {}
def _get_cached_world_draw_data(
obj: bpy.types.Object,
) -> tuple[
list[tuple[float, float, float]],
list[tuple[float, float, float]],
list[tuple[int, int]],
list[tuple[int, ...]],
]:
uid = obj.session_uid
epoch = _object_epochs.get(uid, 0)
entry = _world_draw_data_cache.get(uid)
if entry is not None and entry[0] == epoch:
return entry[1]
mw = obj.matrix_world
verts = [tuple(mw @ v.co) for v in obj.data.vertices]
obj.data.calc_loop_triangles()
tris = [tuple(t.vertices) for t in obj.data.loop_triangles]
ios_edges_attribute = obj.data.attributes.get("ios_edges")
if ios_edges_attribute:
# Loader-curated edges: read the attribute aligned with bm.edges order.
bm = bmesh.new()
bm.from_mesh(obj.data)
edges_indices = [
tuple(v.index for v in e.verts) for i, e in enumerate(bm.edges) if ios_edges_attribute.data[i].value
]
bm.free()
line_verts = verts
else:
dissolved, edges_indices = _get_cached_dissolved_edges(obj.data)
line_verts = [tuple(mw @ v) for v in dissolved]
result = (line_verts, verts, edges_indices, tris)
_world_draw_data_cache[uid] = (epoch, result)
return result
# GPUBatch cache: skip per-frame batch_for_shader. Entries are (epoch, batch);
# lookup compares epoch to _object_epochs[uid] so other objects' batches stay
# alive when one object's depsgraph delta bumps only its own epoch. The cached
# batches reference GPU-side buffers tied to Blender's built-in shaders, which
# are themselves cached by name (gpu.shader.from_builtin returns the same
# handle each call), so they stay drawable across frames.
_batch_cache: dict[tuple[int, str], tuple[int, "gpu.types.GPUBatch"]] = {}
def _get_cached_batch_or_none(cache_key: tuple[int, str]) -> "gpu.types.GPUBatch | None":
uid = cache_key[0]
epoch = _object_epochs.get(uid, 0)
entry = _batch_cache.get(cache_key)
if entry is not None and entry[0] == epoch:
return entry[1]
return None
def _store_batch_in_cache(cache_key: tuple[int, str], batch: "gpu.types.GPUBatch") -> None:
uid = cache_key[0]
epoch = _object_epochs.get(uid, 0)
_batch_cache[cache_key] = (epoch, batch)
class FilledOpeningGenerator:
def generate(
@@ -50,9 +229,15 @@ class FilledOpeningGenerator:
filling_obj: bpy.types.Object,
voided_obj: bpy.types.Object,
target: Optional[Vector] = None,
preserve_placement: bool = False,
) -> Union[None, str]:
"""
:param target: Target opening position. If ommited, cursor position is used.
:param preserve_placement: If True, keep ``filling_obj.matrix_world`` as-is
and skip the snap-to-wall-axis / rl1-rl2 Z-default logic. The opening
is still created at the filling's current world position. Useful
when the caller (e.g. the SHIFT-add-opening gizmo flow) has
already positioned the filling intentionally.
:return: None if there was no errors, otherwise returns a string with error message.
"""
props = tool.Model.get_model_props()
@@ -74,7 +259,7 @@ class FilledOpeningGenerator:
should_set_z_level = False
# Sometimes, the voided_obj may be an aggregate, which won't have any representation.
if voided_obj.data:
if not preserve_placement and voided_obj.data:
raycast = voided_obj.closest_point_on_mesh(voided_obj.matrix_world.inverted() @ target, distance=0.01)
if not raycast[0]:
target = filling_obj.matrix_world.translation.copy()
@@ -558,6 +743,29 @@ class AddBoolean(Operator, tool.Ifc.Operator):
tool.Root.reload_item_decorator()
class ToggleHostOpenings(Operator, tool.Ifc.Operator):
bl_idname = "bim.toggle_host_openings"
bl_label = "Toggle Openings"
bl_description = "Show or hide opening fills (doors and windows) in the viewport\n\nHotkey: Alt+O"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
if not tool.Model.has_selected_ifc_objects():
cls.poll_message_set("No IFC objects selected.")
return False
return True
def _execute(self, context: bpy.types.Context) -> set[str]:
# Opening visibility is independent of host geometry — don't commit any
# active parametric edit; the user can keep editing the host.
if tool.Model.get_model_props().openings:
bpy.ops.bim.edit_openings(apply_all=True)
else:
bpy.ops.bim.show_openings()
return {"FINISHED"}
class ShowOpenings(Operator, tool.Ifc.Operator):
bl_idname = "bim.show_openings"
bl_label = "Show Openings"
@@ -941,7 +1149,6 @@ class SelectBoolean(Operator):
return {"FINISHED"}
# TODO: merge with ProfileDecorator?
class DecorationsHandler:
installed = None
@@ -951,6 +1158,7 @@ class DecorationsHandler:
cls.uninstall()
handler = cls()
cls.installed = SpaceView3D.draw_handler_add(handler, (context,), "WINDOW", "POST_VIEW")
install_decoration_cache_handlers()
@classmethod
def uninstall(cls):
@@ -959,15 +1167,46 @@ class DecorationsHandler:
except ValueError:
pass
cls.installed = None
uninstall_decoration_cache_handlers()
def draw_batch(self, shader_type, content_pos, color, indices=None):
def _get_or_build_batch(self, shader, shader_type, content_pos, indices=None, cache_key=None):
if cache_key is not None:
cached = _get_cached_batch_or_none(cache_key)
if cached is not None:
return cached
if not tool.Blender.validate_shader_batch_data(content_pos, indices):
return
shader = self.line_shader if shader_type == "LINES" else self.shader
return None
batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices)
if cache_key is not None:
_store_batch_in_cache(cache_key, batch)
return batch
def draw_batch(self, shader_type, content_pos, color, indices=None, cache_key=None):
shader = self.line_shader if shader_type == "LINES" else self.shader
batch = self._get_or_build_batch(shader, shader_type, content_pos, indices, cache_key=cache_key)
if batch is None:
return
shader.uniform_float("color", color)
batch.draw(shader)
def _draw_lines_with_occlusion(self, verts, color, edges_indices, occluded_alpha: float = 0.25, cache_key=None):
# One batch, two draws: front pass at full color, occluded pass at
# `occluded_alpha`. Save/restore depth_test matches the pattern in
# bim/module/structural/decorator.py so callers' state survives.
batch = self._get_or_build_batch(self.line_shader, "LINES", verts, edges_indices, cache_key=cache_key)
if batch is None:
return
original_depth_test = gpu.state.depth_test_get()
gpu.state.depth_test_set("LESS_EQUAL")
self.line_shader.uniform_float("color", color)
batch.draw(self.line_shader)
gpu.state.depth_test_set("GREATER")
dimmed = list(color)
dimmed[3] = occluded_alpha
self.line_shader.uniform_float("color", dimmed)
batch.draw(self.line_shader)
gpu.state.depth_test_set(original_depth_test)
def __call__(self, context):
props = tool.Model.get_model_props()
if not props.openings:
@@ -1039,23 +1278,20 @@ class DecorationsHandler:
self.draw_batch("LINES", verts, selected_elements_color, selected_edges)
self.draw_batch("POINTS", unselected_vertices, unselected_elements_color)
self.draw_batch("POINTS", selected_vertices, selected_elements_color)
obj.data.calc_loop_triangles()
tris = [tuple(t.vertices) for t in obj.data.loop_triangles]
self.draw_batch("TRIS", verts, transparent_color(special_elements_color), tris)
else:
bm = bmesh.new()
bm.from_mesh(obj.data)
verts = [tuple(obj.matrix_world @ v.co) for v in bm.verts]
if ios_edges_attribute := obj.data.attributes.get("ios_edges"):
edges = [e for i, e in enumerate(bm.edges) if ios_edges_attribute.data[i].value]
else:
edges = bm.edges
edges_indices = [tuple([v.index for v in e.verts]) for e in edges]
line_verts, verts, edges_indices, tris = _get_cached_world_draw_data(obj)
color = selected_elements_color if obj in context.selected_objects else special_elements_color
self.draw_batch("LINES", verts, color, edges_indices)
obj.data.calc_loop_triangles()
tris = [tuple(t.vertices) for t in obj.data.loop_triangles]
self.draw_batch("TRIS", verts, transparent_color(special_elements_color), tris)
self._draw_lines_with_occlusion(line_verts, color, edges_indices, cache_key=(obj.session_uid, "lines"))
self.draw_batch(
"TRIS",
verts,
transparent_color(special_elements_color),
tris,
cache_key=(obj.session_uid, "tris"),
)
if "HalfSpaceSolid" in obj.name:
# Arrow shape
@@ -1069,7 +1305,4 @@ class DecorationsHandler:
]
edges = [(0, 1), (1, 2), (1, 3), (1, 4), (1, 5)]
color = selected_elements_color if obj in context.selected_objects else special_elements_color
self.draw_batch("LINES", verts, color, edges)
if obj.mode != "EDIT":
bm.free()
self._draw_lines_with_occlusion(verts, color, edges, cache_key=(obj.session_uid, "arrow"))
@@ -75,6 +75,7 @@ class PolylineOperator:
self.is_typing = False
self.snap_angle = None
self.snapping_points = []
self.unit_scale = 1.0
self.instructions = {
"Cycle Input": {"icons": True, "keys": ["EVENT_TAB"]},
"Distance Input": {"icons": True, "keys": ["EVENT_D"]},
@@ -0,0 +1,221 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Shared helpers for Bonsai's parametric preview flows.
Multiple Bonsai features follow the same Scene-level preview pattern:
Enable<X>Preview validates a selection, populates draft state on
``Scene.BIMPreviewProperties.<x>``, flips ``is_active``.
Gizmo<X>Preview polls on ``is_active``, surfaces tunable widgets +
validate/cancel icons.
<X>PreviewDecorator GPU lines drawn while ``is_active`` is True.
Finish<X>Preview direct ``bpy.ops.bim.<verb>(...)`` call with kwargs
read off the draft state, then clears it.
Cancel<X>Preview pure state reset.
The MEP bend and wall fillet flows are the two current callers. They write
their Finish / Cancel operators directly, matching the convention used
throughout the rest of ``bim/module/model/`` for operator-to-operator
dispatch (explicit ``bpy.ops.bim.X(kwarg=value)`` at the call site, no
string indirection). This module hosts the cross-cutting accessors only;
no base class layer.
The GPU draw-handler lifecycle for ``<X>PreviewDecorator`` lives on the
feature-neutral ``tool.Blender.ViewportDecorator`` base, which every
viewport decorator (preview or otherwise) inherits from."""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
import bpy
import bonsai.tool as tool
# --- Props accessors ---------------------------------------------------------
def get_preview_props(context: bpy.types.Context, attr: str):
"""Resolve a child preview PropertyGroup under ``Scene.BIMPreviewProperties``.
Returns ``None`` if the umbrella isn't attached yet — true briefly
during addon register and during plug-out, so polls / draw callbacks
must defend against ``None`` rather than assuming the prop is always
available. Also tolerates contexts without a ``scene`` attribute
(test mocks built from ``SimpleNamespace``)."""
scene = getattr(context, "scene", None)
if scene is None:
return None
preview = getattr(scene, "BIMPreviewProperties", None)
return getattr(preview, attr, None) if preview is not None else None
def is_preview_active(context: bpy.types.Context, attr: str) -> bool:
"""``True`` while a specific preview is open. Used by sibling gizmo
polls to hide themselves so the preview is the only interactive
surface in the viewport (the bend / fillet preview groups take over
the same selection's icon stack)."""
props = get_preview_props(context, attr)
return bool(props is not None and props.is_active)
def any_preview_active(context: bpy.types.Context) -> bool:
"""``True`` if any registered preview is currently open. Sister gizmo
polls call this to hide themselves uniformly during ANY preview, so a
new preview registered in ``PREVIEW_CANCEL_OPS`` automatically gates
every parametric gizmo without each one growing a specific check."""
for attr, _op_name in PREVIEW_CANCEL_OPS:
if is_preview_active(context, attr):
return True
return False
# --- Lazy closure factories --------------------------------------------------
#
# Used by preview gizmo groups when wiring ``BIM_GT_gizmo_dimension``'s
# ``move_get_cb`` / ``move_set_cb`` callbacks. The closures re-resolve
# ``bpy.context.scene`` per CALL rather than capturing it at setup() time
# — the captured Scene's RNA struct can be freed on file open / undo, and
# referencing a freed struct crashes Blender. Lazy lookup survives the
# whole undo / reload lifecycle.
def make_props_callback(attr: str) -> Callable[[], Any]:
"""Return a zero-arg callable that lazily fetches the preview props.
Equivalent to ``getattr(bpy.context.scene.BIMPreviewProperties, attr)``
with full defensiveness against missing scene / missing umbrella."""
def _props():
scene = bpy.context.scene
preview = getattr(scene, "BIMPreviewProperties", None) if scene else None
return getattr(preview, attr, None) if preview is not None else None
return _props
def make_dim_getter(props_callback: Callable[[], Any], field: str) -> Callable[[], float]:
"""Factory for ``BIM_GT_gizmo_dimension.move_get_cb`` reading a single
FloatProperty off the live preview state. Returns ``0.0`` defensively
when the props are temporarily unavailable so the widget doesn't crash
Blender during plug-out / reload."""
def _get() -> float:
props = props_callback()
return getattr(props, field) if props is not None else 0.0
return _get
def make_dim_setter(
props_callback: Callable[[], Any],
field: str,
min_value: float = 0.001,
) -> Callable[[float], None]:
"""Factory for ``BIM_GT_gizmo_dimension.move_set_cb`` writing a single
FloatProperty + tagging viewport areas for redraw so the GPU preview
decorator tracks the value live during drag. Clamps at ``min_value``
to match the FloatProperty's declared lower bound."""
def _set(value: float) -> None:
props = props_callback()
if props is None:
return
setattr(props, field, max(min_value, float(value)))
tool.Blender.update_all_viewports()
return _set
# --- Shared Enable lifecycle helpers -----------------------------------------
def sync_uncommitted_moves(objects: list) -> None:
"""Push any Blender-side translation / rotation of ``objects`` back to
their IFC ``ObjectPlacement`` before a preview decorator starts reading
``obj.matrix_world`` per frame.
Without this sync, a user who grabbed-moved an object but didn't commit
the move sees the live preview at the dragged position while the final
commit lands at the stale IFC position a confusing "where did my
preview go?" experience. Both bend and fillet enable paths call this
on the relevant pair just before activating the preview."""
for obj in objects:
tool.Geometry.commit_placement_if_moved(obj, apply_scale=False)
# --- Esc dispatch ------------------------------------------------------------
PREVIEW_CANCEL_OPS: tuple[tuple[str, str], ...] = (
("bend", "cancel_bend_preview"),
("wall_fillet", "cancel_wall_fillet_preview"),
)
"""Registry of ``(child PointerProperty on Scene.BIMPreviewProperties, bim
operator name)`` consulted by the Esc handler. Adding a new preview means
appending one tuple; the forward-compat test pins that every preview
PropertyGroup with ``is_active`` has an entry here."""
def try_cancel_active_preview(context: bpy.types.Context) -> bool:
"""Cancel every registered preview that is currently active.
Returns ``True`` iff at least one preview was cancelled. Multiple
previews can be simultaneously active (e.g. a stale bend preview opened
just before the user starts a wall fillet) one Esc must clear them
all rather than forcing the user to tap Esc once per preview.
Tags 3D viewports for redraw on success the Esc keymap entry runs
outside a viewport mouse event so the gizmo poll wouldn't re-evaluate
until the next interaction without an explicit redraw."""
cancelled = False
for attr, op_name in PREVIEW_CANCEL_OPS:
if is_preview_active(context, attr):
getattr(bpy.ops.bim, op_name)()
cancelled = True
if cancelled:
tool.Blender.update_all_viewports(context)
return cancelled
def discard_pending_previews(scene: bpy.types.Scene) -> None:
"""Clear every active preview under ``Scene.BIMPreviewProperties`` so
saved preview state never resurfaces on file load.
Mirrors ``tool.Parametric.heal_stale_edit_flags`` for the object-level
parametric-edit lifecycle except previews are *discarded* rather than
validated. A preview's only UI cue is its in-viewport widget; reloading
a ``.blend`` saved mid-preview restores the flag but not the surrounding
user attention, and a stuck ``is_active`` silently hides every sibling
gizmo poll gated on it.
Iterates ``PREVIEW_CANCEL_OPS`` so any preview registered for Esc
cancellation is automatically covered here too. Sets ``is_active``
directly rather than dispatching the cancel operator: load_post may
fire before ``bpy.context.screen`` is reattached, and the cancel
operators bail on ``context.screen is None``."""
preview = getattr(scene, "BIMPreviewProperties", None)
if preview is None:
return
for attr, _op_name in PREVIEW_CANCEL_OPS:
child = getattr(preview, attr, None)
if child is not None and getattr(child, "is_active", False):
child.is_active = False
+227 -5
View File
@@ -15,6 +15,8 @@
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was modified with the assistance of an AI coding tool.
import math
from collections.abc import Callable
@@ -101,8 +103,12 @@ def update_type_page(self: "BIMModelProperties", context: bpy.types.Context) ->
def update_relating_array_from_object(self: "BIMArrayProperties", context: bpy.types.Context) -> None:
bpy.ops.bim.enable_editing_array(item=self.is_editing)
return
# Skip the cleanup-time clear: Finish/Cancel sets relating_array_object back to None,
# which has no source to hydrate from. Only the user-driven pick (None → some array)
# should auto-enter edit on the picked source's layer 0.
if self.relating_array_object is None:
return
bpy.ops.bim.enable_editing_array(item=0)
def is_object_array_applicable(self: "BIMArrayProperties", obj: bpy.types.Object) -> bool:
@@ -193,6 +199,32 @@ def update_stair(self: "BIMStairProperties", context: bpy.types.Context) -> None
_get_updater("stair", "regenerate_stair_mesh")(obj)
def update_wall(self: "BIMWallProperties", context: bpy.types.Context) -> None:
"""Regenerate wall mesh preview when property changes. Does NOT touch IFC."""
obj = context.active_object
if obj and self.is_editing:
_get_updater("wall", "regenerate_wall_mesh_from_props")(obj)
def update_wall_offset_baseline(self: "BIMWallProperties", context: bpy.types.Context) -> None:
"""Recompute the preview-only ``offset`` when the draft baseline cycles. Does not touch IFC.
``offset`` itself has no ``update`` callback on purpose adding one would make
every baseline cycle rebuild the bmesh twice (once via offset's callback, once
explicitly below)."""
obj = context.active_object
if not (obj and self.is_editing):
return
t = self.thickness
if self.desired_offset_baseline == "CENTER":
self.offset = -t / 2
elif self.desired_offset_baseline == "INTERIOR":
self.offset = -t
else: # EXTERIOR
self.offset = 0.0
_get_updater("wall", "regenerate_wall_mesh_from_props")(obj)
def update_railing(self: "BIMRailingProperties", context: bpy.types.Context) -> None:
"""Regenerate railing mesh when property changes."""
if self.is_editing:
@@ -369,8 +401,13 @@ class BIMModelProperties(PropertyGroup):
class BIMArrayProperties(PropertyGroup):
is_editing: bpy.props.IntProperty(
default=-1, description="Currently edited array index. -1 if not in array editing mode."
is_editing: bpy.props.BoolProperty(
default=False,
description="True while an array layer is in parametric edit mode. The specific layer is in editing_item_index.",
)
editing_item_index: bpy.props.IntProperty(
default=-1,
description="Index of the array layer currently being edited; -1 when not in edit mode.",
)
count: bpy.props.IntProperty(name="Count", default=0, min=0)
x: bpy.props.FloatProperty(name="X", default=0, subtype="DISTANCE")
@@ -386,6 +423,15 @@ class BIMArrayProperties(PropertyGroup):
name="Method",
default="OFFSET",
)
per_child_opening: bpy.props.BoolProperty(
name="Per-Child Opening",
description=(
"When the array parent fills a wall (or any voidable host), give each array child its own opening + "
"filling pair so the host is cut once per child. Disable to leave the host uncut by the children — "
"only the parent's original opening remains"
),
default=True,
)
relating_array_object: bpy.props.PointerProperty(
type=bpy.types.Object,
name="Copy Array Properties",
@@ -394,13 +440,15 @@ class BIMArrayProperties(PropertyGroup):
)
if TYPE_CHECKING:
is_editing: int
is_editing: bool
editing_item_index: int
count: int
x: float
y: float
z: float
use_local_space: bool
method: Literal["OFFSET", "DISTRIBUTE"]
per_child_opening: bool
sync_children: bool
relating_array_object: Union[bpy.types.Object, None]
@@ -1631,6 +1679,118 @@ class BIMRoofProperties(PropertyGroup):
setattr(target_props, prop_name, prop_value)
class BIMWallProperties(PropertyGroup):
"""Transient draft state for parametric wall gizmo editing.
Populated from IFC on `bim.enable_editing_wall`, mutated by gizmo drags during edit
(preview only no IFC writes), and either committed by `bim.finish_editing_wall`
or discarded by `bim.cancel_editing_wall`.
The `snap_*` fields are the values captured on enable; `finish_editing_wall` compares
current vs snap to skip unchanged params and guarantee a no-op session leaves the
IFC file byte-identical.
"""
is_editing: bpy.props.BoolProperty(
default=False,
description="True while wall parametric edit mode is active.",
)
mesh_dirty: bpy.props.BoolProperty(
default=False,
options={"HIDDEN", "SKIP_SAVE"},
description=(
"True while the visible mesh is the preview box; cleared once the real "
"IFC-derived geometry is restored (on commit or cancel)."
),
)
length: bpy.props.FloatProperty(
name="Length",
default=1.0,
min=0.01,
subtype="DISTANCE",
update=update_wall,
description="Wall length along its reference axis (preview value; committed on finish).",
)
height: bpy.props.FloatProperty(
name="Height",
default=3.0,
min=0.01,
subtype="DISTANCE",
update=update_wall,
description="Wall vertical height (preview value; committed on finish).",
)
x_angle: bpy.props.FloatProperty(
name="Slope (X Angle)",
default=0.0,
soft_min=-math.pi / 3,
soft_max=math.pi / 3,
subtype="ANGLE",
update=update_wall,
description="Slope angle: tilt of the wall's top face along +Y (preview value; committed on finish).",
)
thickness: bpy.props.FloatProperty(
name="Thickness",
default=0.2,
min=0.001,
subtype="DISTANCE",
description="Wall thickness captured from IFC at edit-enable; not gizmo-bound.",
)
offset: bpy.props.FloatProperty(
name="Offset",
default=0.0,
subtype="DISTANCE",
description="Layer-set offset captured from IFC at edit-enable; driven by desired_offset_baseline.",
)
desired_offset_baseline: bpy.props.EnumProperty(
items=[
("EXTERIOR", "Exterior", "Reference axis at the exterior face"),
("CENTER", "Center", "Reference axis at the wall centreline"),
("INTERIOR", "Interior", "Reference axis at the interior face"),
],
name="Desired Offset Baseline",
default="CENTER",
update=update_wall_offset_baseline,
description="Which face of the wall the reference axis aligns to (preview value; committed on finish).",
)
anchor_x: bpy.props.FloatProperty(
default=0.0,
subtype="DISTANCE",
description="Local-X of the wall's axis polyline start, so the preview box lands where the IFC mesh does.",
)
snap_length: bpy.props.FloatProperty(description="Snapshot of length at edit-enable; commit skips no-op writes.")
snap_height: bpy.props.FloatProperty(description="Snapshot of height at edit-enable; commit skips no-op writes.")
snap_thickness: bpy.props.FloatProperty(
description="Snapshot of thickness at edit-enable; commit skips no-op writes."
)
snap_offset: bpy.props.FloatProperty(description="Snapshot of offset at edit-enable; commit skips no-op writes.")
snap_x_angle: bpy.props.FloatProperty(
subtype="ANGLE",
description="Snapshot of x_angle at edit-enable; commit skips no-op writes.",
)
snap_offset_baseline: bpy.props.StringProperty(
default="",
description="Snapshot of desired_offset_baseline at edit-enable; commit skips no-op writes.",
)
if TYPE_CHECKING:
is_editing: bool
mesh_dirty: bool
length: float
height: float
x_angle: float
thickness: float
offset: float
desired_offset_baseline: Literal["EXTERIOR", "CENTER", "INTERIOR"]
anchor_x: float
snap_length: float
snap_height: float
snap_thickness: float
snap_offset: float
snap_x_angle: float
snap_offset_baseline: str
class SnapMousePoint(PropertyGroup):
x: bpy.props.FloatProperty(name="X")
y: bpy.props.FloatProperty(name="Y")
@@ -1762,3 +1922,65 @@ class BIMExternalParametricGeometryProperties(bpy.types.PropertyGroup):
geometry_source: Literal["GEONODES", "IFCSVERCHOK"]
geo_nodes: Union[bpy.types.GeometryNodeTree, None]
sverchok_nodes: Union[sverchok.node_tree.SverchCustomTree, None]
class BIMWallFilletPreviewProperties(PropertyGroup):
"""Scene-level pending state for the wall-fillet preview flow.
Scene-level because the fillet spans two walls and commits a third
(corner) wall between them. ``SKIP_SAVE`` fields throughout."""
is_active: bpy.props.BoolProperty(
default=False,
options={"SKIP_SAVE"},
description="True while the wall-fillet preview flow is active.",
)
wall_a_id: bpy.props.IntProperty(
default=0,
options={"SKIP_SAVE"},
description=(
"IFC element id of the active wall — the corner wall inherits its "
"material layer set, height, x_angle, and type."
),
)
wall_b_id: bpy.props.IntProperty(
default=0,
options={"SKIP_SAVE"},
description="IFC element id of the other selected wall.",
)
radius: bpy.props.FloatProperty(
name="Radius",
default=0.5,
soft_min=-10.0,
soft_max=10.0,
subtype="DISTANCE",
unit="LENGTH",
options={"SKIP_SAVE"},
description="Radius of the circular arc connecting the two walls.",
)
editing_corner_id: bpy.props.IntProperty(
default=0,
options={"SKIP_SAVE"},
description=(
"IFC element id of an existing fillet corner being re-edited "
"(non-zero only on the pen-icon re-edit flow). The create "
"operator deletes this corner + its connections before recreating "
"with the new radius."
),
)
if TYPE_CHECKING:
is_active: bool
wall_a_id: int
wall_b_id: int
radius: float
editing_corner_id: int
class BIMPreviewProperties(PropertyGroup):
"""Umbrella for parametric-edit preview drafts attached to ``Scene``."""
wall_fillet: bpy.props.PointerProperty(type=BIMWallFilletPreviewProperties)
if TYPE_CHECKING:
wall_fillet: BIMWallFilletPreviewProperties
+47 -48
View File
@@ -34,6 +34,7 @@ import bonsai.core.root
import bonsai.tool as tool
from bonsai.bim.module.model.data import RailingData, refresh
from bonsai.bim.module.model.decorator import ProfileDecorator
from bonsai.bim.parametric_lifecycle import PathPreservingEditMixin
# reference:
# https://ifc43-docs.standards.buildingsmart.org/IFC/RELEASE/IFC4x3/HTML/lexical/IfcRailing.htm
@@ -92,7 +93,6 @@ def update_railing_modifier_ifc_data(context: bpy.types.Context) -> None:
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
representation_data = {
"railing_type": props.railing_type,
"context": body,
"railing_path": railing_path,
"use_manual_supports": props.use_manual_supports,
@@ -406,66 +406,65 @@ class CopyRailingParameters(bpy.types.Operator, tool.Ifc.Operator):
return {"FINISHED"}
class EnableEditingRailing(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.enable_editing_railing"
bl_label = "Enable Editing Railing"
bl_options = {"REGISTER"}
class _RailingEditMixin(PathPreservingEditMixin):
"""Type-specific hooks for railing parametric-edit operators. Single-object
(active_object). ``path_data`` is preserved through the edit; the separate
``Enable/Finish/CancelEditingRailingPath`` operators handle path editing."""
def _execute(self, context):
obj = context.active_object
assert obj
props = tool.Model.get_railing_props(obj)
data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Railing")["data_dict"]
pset_name = "BBIM_Railing"
@classmethod
def _is_element_type(cls, element):
return tool.Parametric.is_railing(element)
@classmethod
def _get_props(cls, obj: bpy.types.Object):
return tool.Model.get_railing_props(obj)
@classmethod
def _post_load_data(cls, data: dict) -> dict:
# BIMRailingProperties.path_data is a StringProperty holding JSON.
data["path_data"] = json.dumps(data["path_data"])
return data
# required since we could load pset from .ifc and BIMRailingProperties won't be set
props.set_props_kwargs_from_ifc_data(data)
@classmethod
def _update_pset(cls, element, data: dict) -> None:
update_bbim_railing_pset(element, data)
props.is_editing = True
return {"FINISHED"}
@classmethod
def _update_modifier_ifc_data(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
update_railing_modifier_ifc_data(context)
class CancelEditingRailing(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.cancel_editing_railing"
bl_label = "Cancel Editing Railing"
bl_options = {"REGISTER"}
def _execute(self, context):
obj = context.active_object
assert obj
data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Railing")["data_dict"]
props = tool.Model.get_railing_props(obj)
# restore previous settings since editing was canceled
props.set_props_kwargs_from_ifc_data(data)
@classmethod
def _update_modifier_bmesh(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
update_railing_modifier_bmesh(context)
props.is_editing = False
return {"FINISHED"}
class FinishEditingRailing(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.finish_editing_railing"
bl_label = "Finish Editing Railing"
bl_options = {"REGISTER"}
class EnableEditingRailing(_RailingEditMixin, bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.enable_editing_railing"
bl_label = "Enable Editing Railing"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
obj = context.active_object
assert obj
element = tool.Ifc.get_entity(obj)
assert element
props = tool.Model.get_railing_props(obj)
return self._enable_targets(context)
pset_data = tool.Model.get_modeling_bbim_pset_data(bpy.context.active_object, "BBIM_Railing")
path_data = pset_data["data_dict"]["path_data"]
railing_data = props.get_general_kwargs(convert_to_project_units=True)
railing_data["path_data"] = path_data
props.is_editing = False
class CancelEditingRailing(_RailingEditMixin, bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.cancel_editing_railing"
bl_label = "Cancel Editing Railing"
bl_options = {"REGISTER", "UNDO"}
update_bbim_railing_pset(element, railing_data)
update_railing_modifier_ifc_data(context)
return {"FINISHED"}
def _execute(self, context):
return self._cancel_targets(context)
class FinishEditingRailing(_RailingEditMixin, bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.finish_editing_railing"
bl_label = "Finish Editing Railing"
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
return self._finish_targets(context)
class FlipRailingPathOrder(bpy.types.Operator, tool.Ifc.Operator):
+167 -45
View File
@@ -17,8 +17,8 @@
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import json
from math import cos, pi, radians, tan
from typing import Any, Literal, Union
from math import atan2, cos, degrees, pi, radians, tan
from typing import Any, ClassVar, Literal, Union
import bmesh
import bpy
@@ -32,8 +32,11 @@ from mathutils import Quaternion, Vector
import bonsai.core.root
import bonsai.tool as tool
from bonsai.bim.module.drawing import gizmos as gizmo
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig, IconSlot
from bonsai.bim.module.model.data import RoofData, refresh
from bonsai.bim.module.model.decorator import ProfileDecorator
from bonsai.bim.parametric_lifecycle import CycleTypeMixin, PathPreservingEditMixin
# reference:
# https://ifc43-docs.standards.buildingsmart.org/IFC/RELEASE/IFC4x3/HTML/lexical/IfcRoof.htm
@@ -210,7 +213,13 @@ def generate_hipped_roof_bmesh(
new_verts = [bm.verts.new(v) for v in verts]
new_edges = [bm.edges.new([new_verts[vi] for vi in edge]) for edge in edges]
new_faces = [bm.faces.new([new_verts[vi] for vi in face]) for face in faces]
# Skip degenerate faces. ``bpypolyskel.polygonize`` can emit a face whose
# vertex list contains the same index twice on certain footprint /
# slope combinations (the straight-skeleton collapses two ridge events
# onto the same vertex). ``bm.faces.new`` rejects those with
# ``found the same (BMVert) used multiple times``; dropping them keeps
# the rest of the roof intact instead of aborting the whole rebuild.
new_faces = [bm.faces.new([new_verts[vi] for vi in face]) for face in faces if len(set(face)) == len(face)]
if mode == "HEIGHT": # Calculate the angle we ended up with.
new_faces[0].normal_update()
@@ -396,6 +405,11 @@ def generate_hipped_roof_bmesh(
if is_internal:
faces_to_delete.add(face)
bmesh.ops.delete(bm, geom=list(faces_to_delete), context="FACES")
# Final pass: ``remove_doubles`` + internal-face deletion above can leave
# the bottom slab faces flipped at low slopes, where the kernel's
# "outward" inference becomes ambiguous on near-flat geometry. Recompute
# once more on the final topology so the eave plane points down.
bmesh.ops.recalc_face_normals(bm, faces=bm.faces[:])
return bm
@@ -608,61 +622,169 @@ class AddRoof(bpy.types.Operator, tool.Ifc.Operator):
tool.Model.add_body_representation(obj)
class EnableEditingRoof(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.enable_editing_roof"
bl_label = "Enable Editing Roof"
bl_options = {"REGISTER"}
class _RoofEditMixin(PathPreservingEditMixin):
"""Type-specific hooks for roof parametric-edit operators. Single-object
(active_object). ``path_data`` is preserved through the edit; the separate
``Enable/Finish/CancelEditingRoofPath`` operators handle path editing."""
def _execute(self, context):
obj = context.active_object
assert obj
props = tool.Model.get_roof_props(obj)
data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Roof")["data_dict"]
# required since we could load pset from .ifc and BIMRoofProperties won't be set
props.set_props_kwargs_from_ifc_data(data)
props.is_editing = True
return {"FINISHED"}
pset_name = "BBIM_Roof"
@classmethod
def _is_element_type(cls, element):
return tool.Parametric.is_roof(element)
class CancelEditingRoof(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.cancel_editing_roof"
bl_label = "Cancel Editing Roof"
bl_options = {"REGISTER"}
@classmethod
def _get_props(cls, obj: bpy.types.Object):
return tool.Model.get_roof_props(obj)
def _execute(self, context):
obj = context.active_object
assert obj
data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Roof")["data_dict"]
props = tool.Model.get_roof_props(obj)
@classmethod
def _update_pset(cls, element, data: dict) -> None:
update_bbim_roof_pset(element, data)
# restore previous settings since editing was canceled
props.set_props_kwargs_from_ifc_data(data)
@classmethod
def _update_modifier_ifc_data(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
update_roof_modifier_ifc_data(context)
@classmethod
def _update_modifier_bmesh(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
update_roof_modifier_bmesh(obj)
props.is_editing = False
return {"FINISHED"}
@classmethod
def _restore_viewport_after_cancel(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
"""Rebuild the roof bmesh from the just-restored draft props so the
viewport reverts to the pre-edit geometry. Same helper the modal
edits use, just driven by the cancelled props instead of in-flight
drag values."""
update_roof_modifier_bmesh(obj)
class FinishEditingRoof(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.finish_editing_roof"
bl_label = "Finish Editing Roof"
bl_options = {"REGISTER"}
EnableEditingRoof, FinishEditingRoof, CancelEditingRoof = tool.Parametric.build_edit_lifecycle(
"roof",
_RoofEditMixin,
labels=(
("Enable Editing Roof", ""),
("Finish Editing Roof", ""),
("Cancel Editing Roof", ""),
),
module_name=__name__,
)
def _execute(self, context):
obj = context.active_object
element = tool.Ifc.get_entity(obj)
props = tool.Model.get_roof_props(obj)
pset_data = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Roof")
path_data = pset_data["data_dict"]["path_data"]
# Fixed horizontal run for the slope gizmo: the draggable value is the
# vertical rise at this distance from the anchor, in the rise/run convention.
_ROOF_SLOPE_REFERENCE_RUN = 1.0
# One degree shy of vertical; avoids tan() blow-up when the user drags the
# rise handle past the gizmo's anchor.
_ROOF_MAX_SLOPE_ANGLE = pi / 2 - 0.001
roof_data = props.get_general_kwargs(convert_to_project_units=True)
roof_data["path_data"] = path_data
props.is_editing = False
update_bbim_roof_pset(element, roof_data)
update_roof_modifier_ifc_data(context)
return {"FINISHED"}
def _roof_has_openings() -> bool:
"""``visible_when`` predicate for the toggle_openings idle slot. True iff
the active object's IFC element exposes a non-empty HasOpenings inverse."""
obj = bpy.context.active_object
if obj is None:
return False
element = tool.Ifc.get_entity(obj)
if element is None:
return False
return tool.Geometry.has_openings(element)
class CycleRoofGenerationMethod(bpy.types.Operator, tool.Ifc.Operator, CycleTypeMixin):
"""Cycle the roof generation method (HEIGHT ↔ ANGLE). Shift+click cycles in reverse."""
bl_idname = "bim.cycle_roof_generation_method"
bl_label = "Cycle Roof Generation Method"
bl_options = {"REGISTER", "UNDO"}
element_checker = tool.Parametric.is_roof
props_getter = tool.Model.get_roof_props
type_literal = tool.Model.RoofGenerationMethod
type_attr = "generation_method"
def _execute(self, context: bpy.types.Context) -> set[str]:
return self._cycle_type(context)
class GizmoRoofEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
bl_idname = "OBJECT_GGT_bim_roof_edition"
bl_label = "Roof Editing Gizmo"
bl_space_type = "VIEW_3D"
bl_region_type = "WINDOW"
bl_options = {"3D", "PERSISTENT"}
enable_editing_operator = "bim.enable_editing_roof"
finish_editing_operator = "bim.finish_editing_roof"
cancel_editing_operator = "bim.cancel_editing_roof"
cycle_type_operator = "bim.cycle_roof_generation_method"
# Positions for all three dimensions are set per-frame by the position
# override below; no static ``matrix_position`` is needed.
dimension_gizmo_props = [
DimensionGizmoConfig(
attr_name="height",
axis=(0, 0, 1),
min_value=0.01,
visibility_condition=lambda p: p.generation_method == "HEIGHT",
),
DimensionGizmoConfig(
attr_name="angle",
axis=(0, 0, 1),
prop_name="Slope",
min_value=0.0,
visibility_condition=lambda p: p.generation_method == "ANGLE",
compute_value=lambda p: tan(p.angle) * _ROOF_SLOPE_REFERENCE_RUN,
apply_value=lambda p, rise: setattr(
p, "angle", min(_ROOF_MAX_SLOPE_ANGLE, max(0.0, atan2(rise, _ROOF_SLOPE_REFERENCE_RUN)))
),
text_formatter=lambda p, rise: (f"{tool.Unit.format_distance(rise)} ({degrees(p.angle):.1f}°)"),
),
DimensionGizmoConfig(
attr_name="roof_thickness",
axis=(0, 0, -1),
min_value=0.001,
# The line shows the perpendicular slab thickness (matching the
# pset value and the drag delta); the true vertical span is
# ``roof_thickness / cos(angle)``, longer than what is drawn.
),
]
props_getter = tool.Model.get_roof_props
gizmo_pref_name = "roof"
idle_slots: ClassVar[tuple[IconSlot, ...]] = (
IconSlot(
name="toggle_openings",
gizmo_idname="VIEW3D_GT_add_opening",
operator="bim.toggle_host_openings",
visible_when=lambda gg: _roof_has_openings(),
),
)
@classmethod
def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool:
return tool.Parametric.is_roof(element)
def _update_dimension_gizmo_positions(self, context: bpy.types.Context, mw, props) -> None: # noqa: ARG002
"""Anchor every dimension gizmo at the object origin. Each gizmo's
declared axis (height/slope along +Z, thickness along -Z) separates
them in 3D so they don't visually collide despite sharing a
position; the height + slope gizmos themselves are mutually
exclusive via ``visibility_condition`` on ``generation_method``."""
origin = Vector((0.0, 0.0, 0.0))
self.set_dimension_gizmo_position("height", mw, origin, (0, 0, 1))
self.set_dimension_gizmo_position("angle", mw, origin, (0, 0, 1))
self.set_dimension_gizmo_position("roof_thickness", mw, origin, (0, 0, -1))
def get_element_height(self, props) -> float: # noqa: ARG002
"""Object-local Z of the mesh's topmost vertex, so the pen / validate /
cancel / cycle row anchors visibly above sloped or stepped roof
bodies rather than at the parametric ``props.height`` which may not
match the rendered apex on ANGLE-generation roofs."""
obj = bpy.context.active_object
if obj is None or not getattr(obj, "bound_box", None):
return 1.0
return max(c[2] for c in obj.bound_box)
class EnableEditingRoofPath(bpy.types.Operator, tool.Ifc.Operator):
+153 -77
View File
@@ -15,6 +15,8 @@
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was modified with the assistance of an AI coding tool.
import json
@@ -29,7 +31,13 @@ from mathutils import Matrix, Vector
import bonsai.core.root
import bonsai.tool as tool
from bonsai.bim.module.drawing import gizmos as gizmo
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
from bonsai.bim.module.drawing.gizmos import (
COLOR_GREEN,
COLOR_RED,
DimensionGizmoConfig,
IconSlot,
)
from bonsai.bim.parametric_lifecycle import IntegerInputDialogMixin, PickTypeMixin
from bonsai.tool.numeric_input import (
IntegerInputState,
run_integer_input_modal,
@@ -37,7 +45,7 @@ from bonsai.tool.numeric_input import (
)
V_ = tool.Blender.V_
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, ClassVar
from bmesh.types import BMVert
from bpy.props import IntProperty
@@ -262,7 +270,6 @@ class FinishEditingStair(bpy.types.Operator, tool.Ifc.Operator):
# Use the special method that includes custom_tread_lock for IFC storage
data = props.get_props_kwargs_for_ifc_export(convert_to_project_units=True)
props.is_editing = False
regenerate_stair_mesh(obj)
tool.Model.add_body_representation(obj)
@@ -272,6 +279,7 @@ class FinishEditingStair(bpy.types.Operator, tool.Ifc.Operator):
# update IfcStairFlight properties
update_ifc_stair_props(obj)
props.is_editing = False
return {"FINISHED"}
@@ -376,6 +384,20 @@ class AdjustStairTreads(bpy.types.Operator):
return {"FINISHED"}
class InputStairTreads(IntegerInputDialogMixin, bpy.types.Operator):
"""Popup-dialog entry point for typing a new ``number_of_treads`` value.
Bound to the world-space ``xN`` count label in the stair edit row."""
bl_idname = "bim.input_stair_treads"
bl_label = "Set Number of Treads"
bl_description = "Type the number of treads for this stair"
bl_options = {"REGISTER", "UNDO"}
number_of_treads: IntProperty(name="Number of Treads", default=1, min=1)
attr_name = "number_of_treads"
props_getter = staticmethod(tool.Model.get_stair_props)
class SetStairTreads(bpy.types.Operator):
"""Set the number of treads to a specific value."""
@@ -421,20 +443,20 @@ class SetStairTreads(bpy.types.Operator):
return f"Number of Treads: {input_str}_{validity} | Enter to confirm, Esc to cancel"
class CycleStairType(bpy.types.Operator, gizmo.CycleTypeMixin):
"""Cycle through stair types. Shift+click to cycle in reverse."""
class PickStairType(bpy.types.Operator, PickTypeMixin):
"""Pick a stair type from a popup menu."""
bl_idname = "bim.cycle_stair_type"
bl_label = "Cycle Stair Type"
bl_idname = "bim.pick_stair_type"
bl_label = "Pick Stair Type"
bl_options = {"REGISTER", "UNDO"}
props_getter = "get_stair_props"
props_getter = tool.Model.get_stair_props
type_literal = tool.Model.StairType
type_attr = "stair_type"
skip_element_check = True
def execute(self, context: bpy.types.Context) -> set[str]:
return self._cycle_type(context)
return self._pick_type(context)
# Tread run accessors - callbacks that delegate to BIMStairProperties methods
@@ -460,20 +482,47 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
bl_region_type = "WINDOW"
bl_options = {"3D", "PERSISTENT"}
# === Stair-Specific Icon Layout (meters) ===
# Additional icons for stair editing, positioned after standard icons:
# [Validate] [Cancel] [Cycle] [TreadLock] [Plus] [Minus]
ICON_TREAD_LOCK_X = 1.24 # X position for tread lock toggle icon
ICON_PLUS_X = 1.61 # X position for add tread (+) icon
ICON_MINUS_X = 1.98 # X position for remove tread (-) icon
# === Stair-Specific Icon Layout ===
# Row order: [Validate] [Cancel] [Cycle] [TreadLock] [xN] [Plus] [Minus]
# The base class assigns X positions from ``feature_slots`` tuple order —
# adding an icon is a one-line append, no hardcoded X constant.
ICON_PLUS_MINUS_SCALE = 0.24 # Scale for plus/minus icons (slightly larger)
ICON_CYCLE_SCALE = 0.3 # Scale for cycle type icon
ICON_COUNT_LABEL_SCALE = 0.36 # Scale for the xN tread-count label
ICON_Z_OFFSET = 0.5 # Z offset above geometry for editing icons
feature_slots: ClassVar[tuple[IconSlot, ...]] = (
IconSlot(
name="tread_lock",
gizmo_idname="VIEW3D_GT_lock",
variants=("open", "closed"),
operator="bim.toggle_stair_property",
color=(1.0, 1.0, 1.0),
operator_props=(("property_name", "custom_tread_lock"),),
),
IconSlot(name="tread_count_label", placeholder=True),
IconSlot(
name="plus",
gizmo_idname="VIEW3D_GT_plus",
operator="bim.adjust_stair_treads",
scale=ICON_PLUS_MINUS_SCALE,
color=COLOR_GREEN,
operator_props=(("increment", 1),),
),
IconSlot(
name="minus",
gizmo_idname="VIEW3D_GT_minus",
operator="bim.adjust_stair_treads",
scale=ICON_PLUS_MINUS_SCALE,
color=COLOR_RED,
operator_props=(("increment", -1),),
),
)
enable_editing_operator = "bim.enable_editing_stair"
finish_editing_operator = "bim.finish_editing_stair"
cancel_editing_operator = "bim.cancel_editing_stair"
cycle_type_operator = "bim.cycle_stair_type"
pick_type_operator = "bim.pick_stair_type"
def get_icon_y_extent(self, props: "BIMStairProperties") -> tuple[float, float]:
"""Get Y extents for stair icon positioning.
@@ -578,83 +627,91 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
]
# Metadata-driven dispatch for props and preferences
props_getter = "get_stair_props"
props_getter = tool.Model.get_stair_props
gizmo_pref_name = "stair"
@classmethod
def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool:
return tool.Blender.Modifier.is_stair(element)
return tool.Parametric.is_stair(element)
def setup_element_specific_gizmos(self, context: bpy.types.Context) -> None:
"""Create stair-specific icon gizmos (lock, plus, minus)."""
self.lock_gizmo = self.create_icon_gizmo(
"VIEW3D_GT_lock",
self.COLOR_BLUE,
"""Create the total-length lock as an open/closed pair plus the
``xN`` tread-count label. Lock click toggles
``props.total_length_lock``; the per-frame update hook picks which
member is visible. Anchored to the stair's far X end (not the edit
row) so it's positioned by ``_update_lock_gizmo_position`` rather
than the toolbar slot system.
The count label binds to ``bim.input_stair_treads`` (popup dialog)
for click-to-type input and sits at the X reserved by the
``tread_count_label`` placeholder slot in ``feature_slots``."""
self.total_length_lock_open_gizmo, self.total_length_lock_closed_gizmo = self.create_icon_gizmo_lock_pair(
"bim.toggle_stair_property",
prop_path="BIMStairProperties.total_length_lock",
self.COLOR_BLUE,
property_name="total_length_lock",
)
self.tread_lock_gizmo = self.create_icon_gizmo(
"VIEW3D_GT_lock",
(1.0, 1.0, 1.0),
"bim.toggle_stair_property",
prop_path="BIMStairProperties.custom_tread_lock",
property_name="custom_tread_lock",
)
self.plus_gizmo = self.create_icon_gizmo(
"VIEW3D_GT_plus", self.COLOR_GREEN, "bim.adjust_stair_treads", increment=1
)
self.minus_gizmo = self.create_icon_gizmo(
"VIEW3D_GT_minus", self.COLOR_RED, "bim.adjust_stair_treads", increment=-1
)
default_color, highlight_color = self.get_decoration_colors()
self.tread_count_label_gizmo = self.gizmos.new("BIM_GT_count_label")
self.tread_count_label_gizmo.use_draw_scale = False
self.tread_count_label_gizmo.color = default_color
self.tread_count_label_gizmo.color_highlight = highlight_color
self.tread_count_label_gizmo.alpha = 0.8
self.tread_count_label_gizmo.target_set_operator("bim.input_stair_treads")
def _refresh_element_specific(self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties") -> None:
"""Update stair-specific lock and tread count gizmos."""
billboard_rot = gizmo.get_billboard_rotation(context)
self.update_lock_gizmo(mw, props, billboard_rot)
def _refresh_element_specific(
self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties" # noqa: ARG002
) -> None:
"""Update stair-specific lock and tread count gizmos. Lock positioning is
handled per-frame in the dimension-positioning hook."""
self.update_lock_gizmo(props)
self.update_tread_lock_gizmo(props)
self.update_tread_count_gizmos(props)
def update_lock_gizmo(self, mw: Matrix, props: "BIMStairProperties", billboard_rot: Matrix) -> None:
"""Update lock gizmo visibility, color, and position."""
gizmo_prefs = self.get_gizmo_prefs()
if not self.update_gizmo_visibility(self.lock_gizmo, props.is_editing, gizmo_prefs.lock):
return # Hidden, skip positioning
self.lock_gizmo.color = self.COLOR_RED if props.total_length_lock else self.COLOR_GREEN
total_run = props.get_total_run()
local_transform = (
Matrix.Translation(Vector((total_run + self.ICON_Z_OFFSET, -self.GIZMO_OFFSET, -self.GIZMO_OFFSET)))
@ billboard_rot
@ Matrix.Scale(self.EDITING_ICON_SCALE, 4)
)
self.lock_gizmo.matrix_basis = mw @ local_transform
def update_lock_gizmo(self, props: "BIMStairProperties") -> None:
"""Show the open/closed total-length lock variant matching
``props.total_length_lock``. Positioning is handled per-frame by
the dimension-positioning hook."""
if not hasattr(self, "total_length_lock_open_gizmo"):
return
if not props.is_editing:
self.total_length_lock_open_gizmo.hide = True
self.total_length_lock_closed_gizmo.hide = True
return
self.total_length_lock_open_gizmo.hide = props.total_length_lock
self.total_length_lock_closed_gizmo.hide = not props.total_length_lock
def update_tread_lock_gizmo(self, props: "BIMStairProperties") -> None:
"""Update visibility of tread lock gizmo. Positioning is handled in _update_editing_icon_positions."""
if not hasattr(self, "tread_lock_gizmo"):
"""Show the open/closed lock variant matching ``props.custom_tread_lock``.
Both pair members share an X position (set by the base's slot
positioning); this picks which one is visible per frame so a state
flip can't reveal both at once."""
if not hasattr(self, "tread_lock_open_gizmo"):
return
gizmo_prefs = self.get_gizmo_prefs()
self.update_gizmo_visibility(self.tread_lock_gizmo, props.is_editing, gizmo_prefs.lock)
if not props.is_editing:
self.tread_lock_open_gizmo.hide = True
self.tread_lock_closed_gizmo.hide = True
return
self.tread_lock_open_gizmo.hide = props.custom_tread_lock
self.tread_lock_closed_gizmo.hide = not props.custom_tread_lock
def update_tread_count_gizmos(self, props: "BIMStairProperties") -> None:
"""Update visibility of +/- tread count gizmos. Positioning is handled in _update_editing_icon_positions."""
"""Update visibility of the +/- tread count gizmos and the ``xN``
label. Positioning is handled in ``_update_editing_icon_positions``."""
if not hasattr(self, "plus_gizmo") or not hasattr(self, "minus_gizmo"):
return
gizmo_prefs = self.get_gizmo_prefs()
self.update_gizmo_visibility(self.plus_gizmo, props.is_editing, gizmo_prefs.plus)
self.update_gizmo_visibility(self.plus_gizmo, props.is_editing)
# Minus has additional condition: number_of_treads > 1
self.update_gizmo_visibility(
self.minus_gizmo, props.is_editing and props.number_of_treads > 1, gizmo_prefs.minus
)
self.update_gizmo_visibility(self.minus_gizmo, props.is_editing and props.number_of_treads > 1)
if hasattr(self, "tread_count_label_gizmo"):
self.update_gizmo_visibility(self.tread_count_label_gizmo, props.is_editing)
def _update_dimension_gizmo_positions(
self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties"
self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties" # noqa: ARG002
) -> None:
"""Update dimension gizmo positions based on camera view direction."""
viewing_from_negative_y, viewing_from_negative_x = self.get_local_view_direction(context, mw)
billboard_rot = gizmo.get_billboard_rotation(context)
viewing_from_negative_y, viewing_from_negative_x = self._frame_view_dir
billboard_rot = self._frame_billboard_rot
total_run = props.get_total_run()
riser_height = props.get_riser_height()
@@ -725,10 +782,12 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
billboard_rot: Matrix,
total_run: float,
) -> None:
"""Update lock gizmo position based on Y view direction."""
"""Update lock gizmo pair position based on Y view direction. Writes
the matrix on both members so a state flip can't reveal a stale pose."""
y_pos = self.get_y_position_for_view(props, viewing_from_negative_y, use_offset=True)
self.set_icon_gizmo_position(
"lock_gizmo",
self.set_icon_gizmo_pair_position(
"total_length_lock_open_gizmo",
"total_length_lock_closed_gizmo",
mw,
total_run + self.ICON_Z_OFFSET,
y_pos,
@@ -740,30 +799,47 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
def _update_editing_icon_positions(
self, mw: Matrix, props: "BIMStairProperties", viewing_from_negative_y: bool, billboard_rot: Matrix
) -> None:
"""Update editing icon positions, flipping Y based on viewing angle."""
"""Reposition the editing icons at stair's view-dependent Y. The base
class's update_editing_gizmos already placed them at the default
``get_icon_y_offset`` Y this overrides with the stair-specific
``get_icon_y_for_view`` flip so the icons land on the side the
camera is looking from."""
if not props.is_editing:
return
icon_z = props.height + self.ICON_Z_OFFSET
y_pos = self.get_icon_y_for_view(props, viewing_from_negative_y)
slot_x = self._slot_x_positions()
self.set_icon_gizmo_position("validate_gizmo", mw, 0, y_pos, icon_z, billboard_rot)
self.set_icon_gizmo_position("cancel_gizmo", mw, self.ICON_CANCEL_X, y_pos, icon_z, billboard_rot)
self.set_icon_gizmo_position(
"cycle_gizmo", mw, self.ICON_CYCLE_X, y_pos, icon_z, billboard_rot, scale=self.ICON_CYCLE_SCALE
)
self.set_icon_gizmo_position(
"tread_lock_gizmo",
self.set_icon_gizmo_pair_position(
"tread_lock_open_gizmo",
"tread_lock_closed_gizmo",
mw,
self.ICON_TREAD_LOCK_X,
slot_x["tread_lock"],
y_pos,
icon_z - self.EDITING_ICON_SCALE / 2,
billboard_rot,
scale=self.EDITING_ICON_SCALE,
)
self.set_icon_gizmo_position(
"plus_gizmo", mw, self.ICON_PLUS_X, y_pos, icon_z, billboard_rot, scale=self.ICON_PLUS_MINUS_SCALE
"plus_gizmo", mw, slot_x["plus"], y_pos, icon_z, billboard_rot, scale=self.ICON_PLUS_MINUS_SCALE
)
self.set_icon_gizmo_position(
"minus_gizmo", mw, self.ICON_MINUS_X, y_pos, icon_z, billboard_rot, scale=self.ICON_PLUS_MINUS_SCALE
"minus_gizmo", mw, slot_x["minus"], y_pos, icon_z, billboard_rot, scale=self.ICON_PLUS_MINUS_SCALE
)
if hasattr(self, "tread_count_label_gizmo"):
self.tread_count_label_gizmo.set_count(int(props.number_of_treads))
self.set_icon_gizmo_position(
"tread_count_label_gizmo",
mw,
slot_x["tread_count_label"],
y_pos,
icon_z,
billboard_rot,
scale=self.ICON_COUNT_LABEL_SCALE,
)
+48 -6
View File
@@ -24,6 +24,7 @@ from typing import TYPE_CHECKING, Any
import bpy
from bpy.types import Panel
import ifcopenshell.util.unit
import bonsai.bim
import bonsai.tool as tool
from bonsai.bim.helper import prop_with_search
@@ -237,11 +238,11 @@ class BIM_PT_array(bpy.types.Panel):
for i, array in enumerate(ArrayData.data["parameters"]["data_dict"]):
box = self.layout.box()
if props.is_editing == i:
if props.editing_item_index == i:
row = box.row(align=True)
row.prop(props, "count", icon="MOD_ARRAY")
row.operator("bim.edit_array", icon="CHECKMARK", text="").item = i
row.operator("bim.disable_editing_array", icon="CANCEL", text="")
row.operator("bim.finish_editing_array", icon="CHECKMARK", text="")
row.operator("bim.cancel_editing_array", icon="CANCEL", text="")
row = box.row(align=True)
row.prop(props, "method")
row = box.row(align=True)
@@ -303,6 +304,8 @@ class BIM_PT_stair(bpy.types.Panel):
row = self.layout.row(align=True)
row.label(text="Stair parameters", icon="IPO_CONSTANT")
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
if props.is_editing:
calculated_params = tool.Model.get_active_stair_calculated_params()
row = self.layout.row(align=True)
@@ -322,22 +325,61 @@ class BIM_PT_stair(bpy.types.Panel):
row.label(text=f"{prop_name}:")
row = self.layout.row(align=True)
for prop_value_item in prop_value:
row.label(text=str(prop_value_item))
if isinstance(prop_value_item, float):
row.label(text=tool.Unit.format_distance(prop_value_item * si_conversion))
else:
row.label(text=str(prop_value_item))
else:
row.label(text=prop_name)
row.label(text=str(prop_value))
if isinstance(prop_value, float):
row.label(text=tool.Unit.format_distance(prop_value * si_conversion))
else:
row.label(text=str(prop_value))
# calculated properties
for prop_name, prop_value in calculated_params.items():
row = self.layout.row(align=True)
row.label(text=prop_name)
row.label(text=str(prop_value))
if isinstance(prop_value, float):
row.label(text=tool.Unit.format_distance(prop_value * si_conversion))
else:
row.label(text=str(prop_value))
else:
row = self.layout.row()
row.label(text="No Stair Found")
row.operator("bim.add_stair", icon="ADD", text="")
class BIM_PT_wall(bpy.types.Panel):
bl_label = "Wall"
bl_idname = "BIM_PT_wall"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "scene"
bl_options = {"DEFAULT_CLOSED"}
bl_parent_id = "BIM_PT_tab_parametric_geometry"
@classmethod
def poll(cls, context):
obj = context.active_object
if not obj:
return False
element = tool.Ifc.get_entity(obj)
return bool(element) and tool.Parametric.is_wall(element)
def draw(self, context):
obj = context.active_object
if obj is None:
return
props = tool.Model.get_wall_props(obj)
row = self.layout.row(align=True)
if props.is_editing:
row.operator("bim.finish_editing_wall", icon="CHECKMARK", text="Finish Editing")
row.operator("bim.cancel_editing_wall", icon="CANCEL", text="")
else:
row.operator("bim.enable_editing_wall", icon="GREASEPENCIL", text="Edit Wall")
class BIM_PT_sverchok(bpy.types.Panel):
bl_label = "Sverchok"
bl_idname = "BIM_PT_sverchok"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,279 @@
# 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.
"""Four wall-offset dimension gizmos (left / right / top / bottom) shared by door and
window edit gizmo groups both fillings sit in a LAYER2 wall and the offset math is
identical.
The compute side returns a *signed* value on the X axis (negative when the filling
is 180°-flipped onto the wall's opposite face) so the gizmo framework auto-flips
the rendered arrow; the apply side takes ``abs(value)`` because the user-facing
offset is always positive. Z-axis values are unsigned in both directions.
Fillings are assumed to align with the wall's local X axis to within ±90° — the
parametric door/window construction path enforces this, and the X-sign math
falls back to +1 if ``col[0].x`` lands on the ambiguous zero (filling rotated
exactly 90° in the wall plane).
Every public entry point falls back to a safe no-op when the host-wall chain
cannot be resolved: reads return 0.0, writes do nothing, and gizmo anchors
return a filling-relative position. This keeps the gizmos non-crashing when a
filling momentarily loses its host (e.g. mid-edit, partially-loaded files).
``_GEOM_CACHE`` is module-scoped and persists across tests tests must call
``clear_caches()`` between cases."""
from __future__ import annotations
from typing import TYPE_CHECKING, NamedTuple, Protocol
from mathutils import Vector
import bonsai.tool as tool
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
if TYPE_CHECKING:
import bpy
class FillingProps(Protocol):
"""Structural subset of door/window props this module touches."""
id_data: bpy.types.Object
overall_width: float
overall_height: float
# Wall-local frame axis indices. Y (depth) is unused — fillings sit on the wall's centreline.
_AXIS_X = 0
_AXIS_Z = 2
class _HostWallGeom(NamedTuple):
"""Cached host-wall geometry in SI metres, wall-local frame. ``height`` is
the vertical projection (already accounts for slanted extrusions)."""
wall_obj: bpy.types.Object
height: float
axis_min_x: float
axis_max_x: float
class _AxisExtent(NamedTuple):
"""``[low, high]`` interval on one wall-local axis; low = near end.
``x_sign`` is +1 / -1 for an X-axis filling extent only (carries the
180° auto-flip); always 1.0 elsewhere."""
low: float
high: float
x_sign: float = 1.0
class _Edge(NamedTuple):
"""Wall edge a gizmo measures to. ``is_max_end=True`` picks right/top, else left/bottom."""
axis_index: int
is_max_end: bool
_LEFT = _Edge(axis_index=_AXIS_X, is_max_end=False)
_RIGHT = _Edge(axis_index=_AXIS_X, is_max_end=True)
_BOTTOM = _Edge(axis_index=_AXIS_Z, is_max_end=False)
_TOP = _Edge(axis_index=_AXIS_Z, is_max_end=True)
# Avoids repeating the host-wall chain walk + LAYER2 geometry read per gizmo per frame.
_GEOM_CACHE = tool.Parametric.GenerationKeyedCache()
def clear_caches() -> None:
_GEOM_CACHE.clear()
def _host_wall_geom(filling_obj: bpy.types.Object) -> _HostWallGeom | None:
"""Cached host-wall geometry for a filling, or ``None`` if any link in
filling opening wall LAYER2 extrusion scene-object resolution breaks."""
return _GEOM_CACHE.get_or_compute(filling_obj.name, lambda: _compute_host_wall_geom(filling_obj))
def _compute_host_wall_geom(filling_obj: bpy.types.Object) -> _HostWallGeom | None:
element = tool.Ifc.get_entity(filling_obj)
if not element:
return None
host_wall = tool.Spatial.get_host_wall(element)
if not host_wall:
return None
wall_obj = tool.Ifc.get_object(host_wall)
length_height = tool.Wall.get_length_and_height(host_wall)
axis_extent = tool.Wall.get_axis_local_extent(host_wall)
# x_angle is None for non-LAYER2 walls — gates entry; the value itself is unused.
if not (wall_obj and length_height and axis_extent and tool.Wall.get_x_angle(host_wall) is not None):
return None
_, height = length_height
axis_min_x, axis_max_x = axis_extent
return _HostWallGeom(wall_obj=wall_obj, height=height, axis_min_x=axis_min_x, axis_max_x=axis_max_x)
def _filling_axis_extent(props: FillingProps, host_wall_obj: bpy.types.Object, axis_index: int) -> _AxisExtent:
"""Filling footprint on the wall's local axis.
X-axis extent carries the filling's orientation sign (180° flip onto
the opposite face) in ``x_sign``."""
filling_in_wall = host_wall_obj.matrix_world.inverted() @ props.id_data.matrix_world
origin = filling_in_wall.translation[axis_index]
if axis_index == _AXIS_X:
# col[0].x is the X-component of the filling's local X axis in the wall-local frame:
# +1 when filling's +X aligns with wall's +X, -1 after a 180° Z-flip.
x_sign = 1.0 if filling_in_wall.col[0].x >= 0.0 else -1.0
signed_width = x_sign * props.overall_width
return _AxisExtent(origin + min(0.0, signed_width), origin + max(0.0, signed_width), x_sign)
return _AxisExtent(origin, origin + props.overall_height)
def _wall_axis_extent(geom: _HostWallGeom, axis_index: int) -> _AxisExtent:
"""Wall span on one local axis: X = IFC axis-line endpoints (not mesh bound-box,
which drifts on trimmed walls); Z = 0 wall height."""
if axis_index == _AXIS_X:
return _AxisExtent(geom.axis_min_x, geom.axis_max_x)
return _AxisExtent(0.0, geom.height)
def _offset_from_extents(filling: _AxisExtent, wall: _AxisExtent, is_max_end: bool) -> float:
"""Distance from the wall edge to the filling's matching edge on the same axis."""
if is_max_end:
return wall.high - filling.high
return filling.low - wall.low
def _translate_along_wall_axis(
props: FillingProps, host_wall_obj: bpy.types.Object, delta: float, axis_index: int
) -> None:
"""Shift the filling by ``delta`` SI metres along the wall's local axis. Drag
operates in the filling's intent frame, not Blender's world frame, so a rotated
host wall still tracks correctly."""
if delta == 0.0:
return
direction_world = host_wall_obj.matrix_world.to_3x3().col[axis_index].normalized()
props.id_data.matrix_world.translation = props.id_data.matrix_world.translation + direction_world * delta
def _get_offset(props: FillingProps, edge: _Edge) -> float:
"""SI distance from the wall edge to the filling's matching edge on the same axis."""
geom = _host_wall_geom(props.id_data)
if not geom:
return 0.0
filling = _filling_axis_extent(props, geom.wall_obj, edge.axis_index)
wall = _wall_axis_extent(geom, edge.axis_index)
return _offset_from_extents(filling, wall, edge.is_max_end)
def _set_offset(props: FillingProps, edge: _Edge, value: float) -> None:
"""Translate the filling so its offset to ``edge`` becomes ``max(0, value)`` SI metres.
Max-end edges (right/top) translate in the opposite direction of near-end edges."""
geom = _host_wall_geom(props.id_data)
if not geom:
return
current = _get_offset(props, edge)
target = max(0.0, value)
delta = (current - target) if edge.is_max_end else (target - current)
_translate_along_wall_axis(props, geom.wall_obj, delta, edge.axis_index)
def has_host_wall(props: FillingProps) -> bool:
"""True when the filling resolves to a LAYER2 host wall present in the scene."""
return _host_wall_geom(props.id_data) is not None
def _edge_position(props: FillingProps, edge: _Edge) -> Vector:
"""Gizmo anchor in filling-local space, at the wall edge, pointing toward the filling."""
geom = _host_wall_geom(props.id_data)
if not geom:
if edge.axis_index == _AXIS_X:
return Vector((0.0, 0.0, props.overall_height / 2))
return Vector((props.overall_width / 2, 0.0, props.overall_height if edge.is_max_end else 0.0))
wall = _wall_axis_extent(geom, edge.axis_index)
edge_value = wall.high if edge.is_max_end else wall.low
if edge.axis_index == _AXIS_X:
wall_edge_world = geom.wall_obj.matrix_world @ Vector((edge_value, 0.0, 0.0))
pos = props.id_data.matrix_world.inverted() @ wall_edge_world
return Vector((pos.x, 0.0, props.overall_height / 2))
# LAYER2 wall matrix_world is upright, so wall-local Z and filling-local Z differ
# only by the filling's Z origin in the wall frame.
filling_z_in_wall = _filling_axis_extent(props, geom.wall_obj, axis_index=_AXIS_Z).low
return Vector((props.overall_width / 2, 0.0, edge_value - filling_z_in_wall))
def _compute_value(props: FillingProps, edge: _Edge) -> float:
"""Renderer-side value. X-axis edges return a signed value so the gizmo's
auto-flip kicks in for fillings on the wall's opposite face; Z-axis returns unsigned."""
geom = _host_wall_geom(props.id_data)
if not geom:
return 0.0
filling = _filling_axis_extent(props, geom.wall_obj, edge.axis_index)
wall = _wall_axis_extent(geom, edge.axis_index)
return filling.x_sign * _offset_from_extents(filling, wall, edge.is_max_end)
def _apply_value(props: FillingProps, edge: _Edge, value: float) -> None:
"""Drag-end commit; X-axis takes ``abs(value)`` since the negative sign in compute
is a rendering hint only (user-facing offset is always positive)."""
if edge.axis_index == _AXIS_X:
_set_offset(props, edge, abs(value))
else:
_set_offset(props, edge, value)
# attr_name identifies the gizmo within its group; values flow through
# compute/apply, not via a registered property.
WALL_OFFSET_GIZMO_CONFIGS: list[DimensionGizmoConfig] = [
DimensionGizmoConfig(
attr_name="host_wall_offset_left",
axis=(1, 0, 0),
visibility_condition=has_host_wall,
compute_value=lambda p: _compute_value(p, _LEFT),
apply_value=lambda p, v: _apply_value(p, _LEFT, v),
matrix_position=lambda p: _edge_position(p, _LEFT),
),
DimensionGizmoConfig(
attr_name="host_wall_offset_right",
axis=(-1, 0, 0),
visibility_condition=has_host_wall,
compute_value=lambda p: _compute_value(p, _RIGHT),
apply_value=lambda p, v: _apply_value(p, _RIGHT, v),
matrix_position=lambda p: _edge_position(p, _RIGHT),
),
DimensionGizmoConfig(
attr_name="host_wall_offset_bottom",
axis=(0, 0, 1),
visibility_condition=has_host_wall,
compute_value=lambda p: _compute_value(p, _BOTTOM),
apply_value=lambda p, v: _apply_value(p, _BOTTOM, v),
matrix_position=lambda p: _edge_position(p, _BOTTOM),
),
DimensionGizmoConfig(
attr_name="host_wall_offset_top",
axis=(0, 0, -1),
visibility_condition=has_host_wall,
compute_value=lambda p: _compute_value(p, _TOP),
apply_value=lambda p, v: _apply_value(p, _TOP, v),
matrix_position=lambda p: _edge_position(p, _TOP),
),
]
+41 -75
View File
@@ -39,6 +39,8 @@ import bonsai.core.root
import bonsai.tool as tool
from bonsai.bim.module.drawing import gizmos as gizmo
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
from bonsai.bim.module.model.wall_offset_gizmos import WALL_OFFSET_GIZMO_CONFIGS
from bonsai.bim.parametric_lifecycle import FeatureModifierEditMixin, PickTypeMixin
if TYPE_CHECKING:
from bonsai.bim.module.model.prop import BIMWindowProperties
@@ -482,90 +484,53 @@ class AddWindow(bpy.types.Operator, tool.Ifc.Operator):
return {"FINISHED"}
class CancelEditingWindow(bpy.types.Operator, tool.Ifc.Operator):
class _WindowEditMixin(FeatureModifierEditMixin):
"""Type-specific hooks for window parametric-edit operators. Single-object
by design (window edits target the active object only)."""
pset_name = "BBIM_Window"
@classmethod
def _is_element_type(cls, element):
return tool.Parametric.is_window(element)
@classmethod
def _get_props(cls, obj: bpy.types.Object):
return tool.Model.get_window_props(obj)
@classmethod
def _update_modifier_representation(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
update_window_modifier_representation(context)
class CancelEditingWindow(_WindowEditMixin, bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.cancel_editing_window"
bl_label = "Cancel Editing Window"
bl_description = "Cancel editing and revert window parameters to their previous values"
bl_options = {"REGISTER"}
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context: bpy.types.Context) -> set[str]:
obj = context.active_object
assert obj
element = tool.Ifc.get_entity(obj)
assert element
data = json.loads(ifcopenshell.util.element.get_pset(element, "BBIM_Window", "Data"))
data.update(data.pop("lining_properties"))
data.update(data.pop("panel_properties"))
props = tool.Model.get_window_props(obj)
props.set_props_kwargs_from_ifc_data(data)
body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
bonsai.core.geometry.switch_representation(
tool.Ifc,
tool.Geometry,
obj=obj,
representation=body,
)
props.is_editing = False
return {"FINISHED"}
return self._cancel_targets(context)
class FinishEditingWindow(bpy.types.Operator, tool.Ifc.Operator):
class FinishEditingWindow(_WindowEditMixin, bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.finish_editing_window"
bl_label = "Finish Editing Window"
bl_description = "Apply changes and finish editing window parameters"
bl_options = {"REGISTER"}
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context: bpy.types.Context) -> set[str]:
obj = context.active_object
assert obj
element = tool.Ifc.get_entity(obj)
assert element
props = tool.Model.get_window_props(obj)
window_data = props.get_general_kwargs(convert_to_project_units=True)
lining_props = props.get_lining_kwargs(convert_to_project_units=True)
panel_props = props.get_panel_kwargs(convert_to_project_units=True)
window_data["lining_properties"] = lining_props
window_data["panel_properties"] = panel_props
props.is_editing = False
update_window_modifier_representation(context)
element_type = ifcopenshell.util.element.get_type(element)
if element_type:
tool.Model.mark_thumbnail_for_update(element_type)
pset = tool.Pset.get_element_pset(element, "BBIM_Window")
window_data = tool.Ifc.get().createIfcText(json.dumps(window_data, default=list))
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Data": window_data})
return {"FINISHED"}
return self._finish_targets(context)
class EnableEditingWindow(bpy.types.Operator, tool.Ifc.Operator):
class EnableEditingWindow(_WindowEditMixin, bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.enable_editing_window"
bl_label = "Enable Editing Window"
bl_description = "Enter edit mode to modify window parameters interactively"
bl_options = {"REGISTER"}
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context: bpy.types.Context) -> set[str]:
obj = context.active_object
assert obj
props = tool.Model.get_window_props(obj)
element = tool.Ifc.get_entity(obj)
assert element
data = json.loads(ifcopenshell.util.element.get_pset(element, "BBIM_Window", "Data"))
data.update(data.pop("lining_properties"))
data.update(data.pop("panel_properties"))
data.update(tool.Model.get_constituents_props_data(element))
# required since we could load pset from .ifc and BIMWindowProperties won't be set
props.set_props_kwargs_from_ifc_data(data)
props.is_editing = True
return {"FINISHED"}
return self._enable_targets(context)
class RemoveWindow(bpy.types.Operator, tool.Ifc.Operator):
@@ -587,20 +552,20 @@ class RemoveWindow(bpy.types.Operator, tool.Ifc.Operator):
return {"FINISHED"}
class CycleWindowType(bpy.types.Operator, tool.Ifc.Operator, gizmo.CycleTypeMixin):
"""Cycle through available window types. Shift+click to cycle in reverse."""
class PickWindowType(bpy.types.Operator, tool.Ifc.Operator, PickTypeMixin):
"""Pick a window type from a popup menu."""
bl_idname = "bim.cycle_window_type"
bl_label = "Cycle Window Type"
bl_idname = "bim.pick_window_type"
bl_label = "Pick Window Type"
bl_options = {"REGISTER", "UNDO"}
element_checker = "is_window"
props_getter = "get_window_props"
element_checker = tool.Parametric.is_window
props_getter = tool.Model.get_window_props
type_literal = tool.Model.WindowType
type_attr = "window_type"
def _execute(self, context: bpy.types.Context) -> set[str]:
return self._cycle_type(context)
return self._pick_type(context)
# Frame accessor factory - creates callbacks that delegate to BIMWindowProperties methods
@@ -638,7 +603,7 @@ class GizmoWindowEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
enable_editing_operator = "bim.enable_editing_window"
finish_editing_operator = "bim.finish_editing_window"
cancel_editing_operator = "bim.cancel_editing_window"
cycle_type_operator = "bim.cycle_window_type"
pick_type_operator = "bim.pick_window_type"
# matrix_position lambdas replace the get_dimension_matrix_* methods
dimension_gizmo_props = [
@@ -779,14 +744,15 @@ class GizmoWindowEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
),
# lining_offset is handled specially in _update_dimension_gizmo_positions due to negative value support
DimensionGizmoConfig(attr_name="lining_offset", axis=(0, 1, 0), min_value=-10.0),
*WALL_OFFSET_GIZMO_CONFIGS,
]
props_getter = "get_window_props"
props_getter = tool.Model.get_window_props
gizmo_pref_name = "window"
@classmethod
def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool:
return tool.Blender.Modifier.is_window(element)
return tool.Parametric.is_window(element)
def get_icon_y_extent(self, props: "BIMWindowProperties") -> tuple[float, float]:
"""Get Y extents for window icon positioning.
+16 -17
View File
@@ -841,7 +841,7 @@ class EditObjectUI:
row = cls.layout.row(align=True)
row.separator()
row.label(text="Operations") if ui_context != "TOOL_HEADER" else row
cls.draw_regen_operations(row)
cls.draw_regen_operations(row, ui_context)
if AuthoringData.data["active_material_usage"] == "LAYER2":
row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row
@@ -962,20 +962,16 @@ class EditObjectUI:
return row
@classmethod
def draw_regen_operations(cls, row):
custom_icon = custom_icon_previews.get("REGEN", custom_icon_previews["IFC"]).icon_id
def draw_regen_operations(cls, row, ui_context):
if AuthoringData.data["is_regenable_element"]:
op = row.operator("bim.hotkey", text="", icon_value=custom_icon)
description = "Recalculate Element Geometry\nHotkey: S G"
op.hotkey = "S_G"
op.description = description.strip()
row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row
add_layout_hotkey_operator(row, "Regen", "S_G", "Recalculate Element Geometry", ui_context)
if PortData.data["total_ports"] > 0:
op = row.operator("bim.hotkey", text="", icon_value=custom_icon)
description = f"{bpy.ops.bim.regenerate_distribution_element.__doc__}\n\nHotkey: S G"
op.hotkey = "S_G"
op.description = description.strip()
row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row
add_layout_hotkey_operator(
row, "Regen", "S_G", bpy.ops.bim.regenerate_distribution_element.__doc__, ui_context
)
@classmethod
def draw_void(cls, context, row):
@@ -1300,9 +1296,15 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator):
bpy.ops.bim.generate_space()
return
if self.active_material_usage == "LAYER2":
bpy.ops.bim.recalculate_wall()
if element and tool.Model.has_underside_connection(element):
bpy.ops.bim.regenerate_wall_to_underside()
else:
bpy.ops.bim.recalculate_wall()
elif self.active_material_usage == "LAYER3":
bpy.ops.bim.recalculate_slab()
wall_objs = tool.Model.get_connected_wall_objs(element)
if wall_objs:
core.regenerate_wall_to_underside(tool.Ifc, tool.Geometry, tool.Model, wall_objs)
elif tool.System.get_ports(element):
bpy.ops.bim.regenerate_distribution_element()
elif self.active_material_usage == "PROFILE":
@@ -1442,10 +1444,7 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator):
bpy.ops.bim.enable_editing_extrusion_axis()
def hotkey_A_O(self):
if tool.Model.get_model_props().openings:
bpy.ops.bim.edit_openings(apply_all=True)
else:
bpy.ops.bim.show_openings()
bpy.ops.bim.toggle_host_openings()
def hotkey_C_E(self):
if not bpy.context.selected_objects:
@@ -15,6 +15,8 @@
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was modified with the assistance of an AI coding tool.
import datetime
import json
@@ -61,6 +63,7 @@ import bonsai.core.project as core
import bonsai.tool as tool
from bonsai.bim import export_ifc, import_ifc
from bonsai.bim.ifc import IfcStore
from bonsai.bim.module.model import preview_base
from bonsai.bim.module.model.decorator import FaceAreaDecorator, PolylineDecorator
from bonsai.bim.module.model.polyline import PolylineOperator
from bonsai.bim.module.project.data import LinksData, ProjectLibraryData
@@ -961,11 +964,11 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
use_relative_path: bpy.props.BoolProperty(
name="Use Relative Path",
description="Store the IFC project path relative to the .blend file. Requires .blend file to be saved",
default=False,
default=True,
)
should_start_fresh_session: bpy.props.BoolProperty(
name="Should Start Fresh Session",
description="Clear current Blender session before loading IFC. Not supported with 'Use Relative Path' option",
description="Clear current Blender session before loading IFC",
default=True,
)
import_without_ifc_data: bpy.props.BoolProperty(
@@ -1073,9 +1076,6 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
bpy.app.handlers.load_post.remove(load_handler)
self.finish_loading_project(context)
if self.use_relative_path:
self.should_start_fresh_session = False
if self.should_start_fresh_session:
# WARNING: wm.read_homefile clears context which could lead to some
# operators to fail:
@@ -1140,8 +1140,6 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
return ImportHelper.invoke(self, context, event)
def draw(self, context):
if self.use_relative_path:
self.should_start_fresh_session = False
self.layout.prop(self, "is_advanced")
self.layout.prop(self, "should_start_fresh_session")
self.layout.prop(self, "import_without_ifc_data")
@@ -1872,7 +1870,7 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
json_version: bpy.props.EnumProperty(items=[("4", "4", ""), ("5a", "5a", "")], name="IFC JSON Version")
json_compact: bpy.props.BoolProperty(name="Export Compact IFCJSON", default=False)
should_save_as: bpy.props.BoolProperty(name="Should Save As", default=False, options={"HIDDEN"})
use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=False)
use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=True)
if TYPE_CHECKING:
filter_glob: str
@@ -1903,11 +1901,11 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
self.use_relative_path = tool.Project.get_project_props().use_relative_project_path
props = tool.Blender.get_bim_props()
if (filepath := props.ifc_file) and not self.should_save_as:
self.filepath = str(tool.Blender.ensure_blender_path_is_abs(Path(filepath)))
return self.execute(context)
return ExportHelper.invoke(self, context, event)
filepath = props.ifc_file
if not filepath or self.should_save_as:
return ExportHelper.invoke(self, context, event)
self.filepath = str(tool.Blender.ensure_blender_path_is_abs(Path(filepath)))
return self.execute(context)
def check(self, context):
# ExportHelper is automatically adjusting suffix to `filename_ext`.
@@ -1933,6 +1931,20 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
return {"FINISHED"}
def _execute(self, context):
committed, failed_commits = tool.Parametric.commit_pending_edits()
# Previews are session-transient — discard rather than commit. Sibling
# gizmo polls gate on each preview's is_active flag, and a stuck flag
# persisted through the save would silently hide them on reload.
preview_base.discard_pending_previews(context.scene)
# Suffix is appended to the IFC save-success report below so the auto-commit
# info isn't immediately overwritten by the success message in Blender's
# status bar (only the latest self.report({"INFO"}, ...) sticks).
commit_suffix = f" (auto-committed {committed} pending parametric edit(s))" if committed else ""
if failed_commits:
names = ", ".join(o.name for o in failed_commits)
msg = f"Auto-commit failed for {len(failed_commits)} object(s): {names}"
print(f"Bonsai: {msg} (their drafts are NOT saved to the IFC file).")
self.report({"ERROR"}, msg)
start = time.time()
logger = logging.getLogger("ExportIFC")
path_log = tool.Blender.get_data_dir_path("process.log")
@@ -2001,7 +2013,7 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
blendmetadata_path = output_file + suffix
self.report(
{"INFO"},
f'IFC Project "{os.path.basename(output_file)}" And Metadata File Saved to: {os.path.basename(blendmetadata_path)}',
f'IFC Project "{os.path.basename(output_file)}" And Metadata File Saved to: {os.path.basename(blendmetadata_path)}{commit_suffix}',
)
except Exception as e:
self.report({"ERROR"}, f"Failed to save blend metadata file: {e}")
@@ -2011,7 +2023,7 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
bpy.ops.wm.save_mainfile(filepath=bpy.data.filepath)
self.report(
{"INFO"},
f'IFC Project "{os.path.basename(output_file)}" {"" if not save_blend_file else "And Current Blend File Are"} Saved',
f'IFC Project "{os.path.basename(output_file)}" {"" if not save_blend_file else "And Current Blend File Are"} Saved{commit_suffix}',
)
bonsai.bim.handler.refresh_ui_data()
+1 -1
View File
@@ -444,7 +444,7 @@ class BIMProjectProperties(PropertyGroup):
items=get_parent_libaries,
)
use_relative_project_path: BoolProperty(name="Use Relative Project Path", default=False)
use_relative_project_path: BoolProperty(name="Use Relative Project Path", default=True)
should_save_metadata_for_this_file: BoolProperty(
name="Save Session Data for This File",
description="Enable saving session data (window layout, settings) to a metadata blend file for this specific IFC file",
@@ -302,6 +302,15 @@ class SelectSimilarContainer(bpy.types.Operator):
is_recursive=self.is_recursive,
)
self.is_recursive = True # <-- forcibly reset
element = tool.Ifc.get_entity(context.active_object)
if element:
container = tool.Spatial.get_container(element)
if container:
result = f'location="{container.Name}"'
bpy.context.window_manager.clipboard = result
self.report({"INFO"}, f"({result}) was copied to the clipboard.")
return {"FINISHED"}
+2 -1
View File
@@ -151,7 +151,8 @@ class BIM_PT_type_attributes(Panel):
row = layout.row(align=True)
row.label(text=attribute["name"])
value = get_display_value(attribute["value"])
row.label(text=value)
op = row.operator("bim.select_similar", text=value, icon="NONE", emboss=False)
op.key = "type." + attribute["name"]
def add_object_button(self, context):
+19 -2
View File
@@ -36,9 +36,17 @@ class AddOpening(bpy.types.Operator, tool.Ifc.Operator):
bl_description = (
"Apply opening objects to an Element.\n\n"
"The Element and the openings to be applied should be selected. The order of selection is not important.\n"
"Opening can be just a Blender mesh object."
"Opening can be just a Blender mesh object.\n\n"
"Shift+click: keep the filling at its current matrix_world — skip the wall-axis snap "
"and the rl1/rl2 Z-elevation default that the regular click applies."
)
# Toggled by ``invoke`` when the user holds SHIFT during a gizmo / hotkey
# click. The filling-opening generator gates its snap-to-wall-axis block
# on this flag. HIDDEN + SKIP_SAVE so the flag doesn't surface in the F6
# redo panel or persist into saved keymaps.
preserve_placement: bpy.props.BoolProperty(default=False, options={"HIDDEN", "SKIP_SAVE"})
@classmethod
def poll(cls, context):
if len(context.selected_objects) < 2:
@@ -46,6 +54,10 @@ class AddOpening(bpy.types.Operator, tool.Ifc.Operator):
return False
return True
def invoke(self, context, event):
self.preserve_placement = bool(event.shift)
return self.execute(context)
def _execute(self, context):
selected_objects = context.selected_objects
target_object = selected_objects[0]
@@ -68,7 +80,12 @@ class AddOpening(bpy.types.Operator, tool.Ifc.Operator):
elif not element1.is_a("IfcOpeningElement") and not element2.is_a("IfcOpeningElement"):
if element1.is_a("IfcWindow") or element1.is_a("IfcDoor"): # Add a fill to an element.
obj1, obj2 = obj2, obj1
FilledOpeningGenerator().generate(obj2, obj1, target=obj2.matrix_world.translation)
FilledOpeningGenerator().generate(
obj2,
obj1,
target=obj2.matrix_world.translation,
preserve_placement=self.preserve_placement,
)
continue
elif element1.is_a("IfcOpeningElement") or element2.is_a("IfcOpeningElement"):
if element1.is_a("IfcOpeningElement"): # Reassign an opening to another element.
+1 -1
View File
@@ -219,7 +219,7 @@ class SelectIfcFile(bpy.types.Operator, IFCFileSelector, ImportHelper):
bl_options = {"REGISTER", "UNDO"}
bl_description = f"Select a different IFC file.\n{tool.Blender.operator_invoke_filepath_hotkeys_description}"
filter_glob: bpy.props.StringProperty(default="*.ifc;*.ifczip;*.ifcxml", options={"HIDDEN"})
use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=False)
use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=True)
filename_ext = ".ifc"
def execute(self, context):
@@ -0,0 +1,656 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Shared operator mixins for parametric-edit operators.
Edit-lifecycle mixins (Enable / Finish / Cancel):
`FeatureModifierEditMixin` door, window (BBIM_<Type> pset; nested
lining/panel properties; Finish + Cancel route through
``ifcopenshell.api.feature``).
`PathPreservingEditMixin` railing, roof (path_data preserved across
edit; only general kwargs are user-editable).
Pattern selection (which approach a new feature should adopt):
Every parametric edit lifecycle commits to one of three patterns. Pick by
answering "does the feature share the Enable→Finish→Cancel shape that
one of the existing mixins already encodes?":
A. Inherit one of the shared mixins below and route through
`tool.Parametric.build_edit_lifecycle`:
- `FeatureModifierEditMixin` when the feature stores its pset as
`{general fields} + {lining_properties: {...}} + {panel_properties: {...}}`
and Finish must call a per-type `update_<type>_modifier_representation`.
- `PathPreservingEditMixin` when the feature's pset carries a
`path_data` field that survives general-kwarg edits untouched, with
a separate Enable/Finish/Cancel lifecycle for path editing itself.
B. Write a per-feature mixin that subclasses `ParametricEditMixinBase`
and provides `_enable_targets` / `_finish_targets` / `_cancel_targets`,
then route through `build_edit_lifecycle`. Pick this when the
feature's pset roundtrip or representation handling diverges from the
shared mixins but the EnableFinishCancel shape still fits.
C. Declare standalone Enable/Finish/Cancel Operator subclasses (no
factory) when the feature's parameter-change logic is sufficiently
unique that even a per-feature mixin would force optional hooks or
dead branches. Such operators MUST call the matrix_world drift
helpers (`tool.Geometry.commit_placement_if_moved` on Enable/Finish,
`tool.Geometry.restore_or_rebaseline_placement` on Cancel) the
drift contract is enforced uniformly regardless of which pattern the
operators adopt.
The authoritative list of registered parametric types and which use
`build_edit_lifecycle` vs. standalone operators lives in
`tool/parametric.py`'s `EDIT_TYPES` and is enforced by the registry
contract tests.
This module hosts operator-side mixins that import ``bonsai.tool`` freely.
The lightweight parametric registry consumed at addon-enable time must stay
free of such imports and lives separately in ``tool/parametric.py``."""
from __future__ import annotations
import json
from collections.abc import Callable
from typing import ClassVar, get_args
import bpy
import ifcopenshell.util.element
from bpy.app.handlers import persistent
from ifcopenshell import entity_instance
import bonsai.core.geometry
import bonsai.tool as tool
class ParametricEditMixinBase:
"""Common scaffolding for parametric edit-lifecycle mixins.
Each per-type subclass provides four hooks:
``pset_name``: BBIM_<Type> pset identifier
``_is_element_type(element)``: IFC element predicate
``_get_props(obj)``: PropertyGroup accessor
``_iter_targets(context)``: list of objects to act on (default: ``[active_object]``)
Drift handling is built in: pre-edit matrix_world drift commits to IFC on
Enable, in-edit drag commits on Finish, and Cancel restores the committed
IFC placement. This prevents an uncommitted drag from disappearing on
Finish or snapping back on Cancel.
Operator subclasses call one of ``_enable_targets`` / ``_finish_targets`` /
``_cancel_targets`` from their ``_execute`` method."""
pset_name: ClassVar[str]
@classmethod
def _iter_targets(cls, context: bpy.types.Context) -> list[bpy.types.Object]:
obj = context.active_object
return [obj] if obj else []
@classmethod
def _is_element_type(cls, element: entity_instance) -> bool:
raise NotImplementedError
@classmethod
def _get_props(cls, obj: bpy.types.Object):
raise NotImplementedError
@classmethod
def _resolve(cls, obj: bpy.types.Object):
"""Look up ``(element, props)`` for ``obj`` if it matches this type, else None.
Common predicate guard for every lifecycle method collapses the
``element = tool.Ifc.get_entity(obj); assert element; if not is_<type>(element): return``
triplet into one call."""
element = tool.Ifc.get_entity(obj)
if not element or not cls._is_element_type(element):
return None
return element, cls._get_props(obj)
@classmethod
def _handle_drift_on_enable(cls, obj: bpy.types.Object) -> None:
tool.Geometry.commit_placement_if_moved(obj, apply_scale=False)
@classmethod
def _handle_drift_on_finish(cls, obj: bpy.types.Object) -> None:
tool.Geometry.commit_placement_if_moved(obj)
@classmethod
def _handle_drift_on_cancel(cls, obj: bpy.types.Object, element: entity_instance) -> None:
tool.Geometry.restore_or_rebaseline_placement(obj, element)
@classmethod
def _mark_type_thumbnail_dirty(cls, element: entity_instance) -> None:
"""Mark the element's type's preview thumbnail for refresh so the
property-panel preview reflects post-edit geometry. No-op for
occurrences without a backing type."""
element_type = ifcopenshell.util.element.get_type(element)
if element_type:
tool.Model.mark_thumbnail_for_update(element_type)
class FeatureModifierEditMixin(ParametricEditMixinBase):
"""Lifecycle for door- and window-style parametric modifier operators.
Enable:
Read BBIM_<Type> pset JSON unwrap ``lining_properties`` and
``panel_properties`` merge constituents data set draft props
``is_editing = True``.
Finish:
Gather ``general / lining / panel`` kwargs (project units) nest
``is_editing = False`` call ``_update_modifier_representation``
mark thumbnail write back to BBIM_<Type> pset via
``ifcopenshell.api.pset.edit_pset``.
Cancel:
Read BBIM_<Type> pset JSON unwrap restore draft props
``switch_representation`` to the Body representation
``is_editing = False``."""
@classmethod
def _update_modifier_representation(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
"""Hook: call the per-type ``update_<type>_modifier_representation``."""
raise NotImplementedError
@classmethod
def _enable_one(cls, obj: bpy.types.Object) -> None:
resolved = cls._resolve(obj)
if resolved is None:
return
element, props = resolved
cls._handle_drift_on_enable(obj)
data = json.loads(ifcopenshell.util.element.get_pset(element, cls.pset_name, "Data"))
data.update(data.pop("lining_properties"))
data.update(data.pop("panel_properties"))
data.update(tool.Model.get_constituents_props_data(element))
# required since the pset can be loaded from .ifc and the PropertyGroup
# would otherwise still hold its default values
props.set_props_kwargs_from_ifc_data(data)
props.is_editing = True
@classmethod
def _finish_one(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
resolved = cls._resolve(obj)
if resolved is None:
return
element, props = resolved
data = props.get_general_kwargs(convert_to_project_units=True)
data["lining_properties"] = props.get_lining_kwargs(convert_to_project_units=True)
data["panel_properties"] = props.get_panel_kwargs(convert_to_project_units=True)
cls._update_modifier_representation(obj, context)
cls._mark_type_thumbnail_dirty(element)
tool.Pset.write_bbim_data(element, cls.pset_name, data)
cls._handle_drift_on_finish(obj)
# Set only on success: if any IFC op above raised, the user's draft survives for retry.
props.is_editing = False
@classmethod
def _cancel_one(cls, obj: bpy.types.Object) -> None:
resolved = cls._resolve(obj)
if resolved is None:
return
element, props = resolved
# Cancel must always clear is_editing — leaving it True after a
# restore-failure would block the user from re-entering edit mode and
# the next save's stale-flag heal would silently roll back the
# cancellation. Wrap the restore in try/finally so the flag flips
# even on partial failure.
try:
data = json.loads(ifcopenshell.util.element.get_pset(element, cls.pset_name, "Data"))
data.update(data.pop("lining_properties"))
data.update(data.pop("panel_properties"))
props.set_props_kwargs_from_ifc_data(data)
body = tool.Geometry.get_body_representation(element)
bonsai.core.geometry.switch_representation(tool.Ifc, tool.Geometry, obj=obj, representation=body)
cls._handle_drift_on_cancel(obj, element)
finally:
props.is_editing = False
def _enable_targets(self, context: bpy.types.Context) -> set[str]:
for obj in self._iter_targets(context):
self._enable_one(obj)
return {"FINISHED"}
def _finish_targets(self, context: bpy.types.Context) -> set[str]:
for obj in self._iter_targets(context):
self._finish_one(obj, context)
return {"FINISHED"}
def _cancel_targets(self, context: bpy.types.Context) -> set[str]:
for obj in self._iter_targets(context):
self._cancel_one(obj)
return {"FINISHED"}
class PathPreservingEditMixin(ParametricEditMixinBase):
"""Lifecycle for railing- and roof-style parametric modifier operators.
Distinctive: ``path_data`` is part of the BBIM_<Type> pset but is **not**
user-editable through this lifecycle it survives the edit untouched, only
general kwargs are diffed. (Path editing has its own separate operator
pair, ``Enable/Finish/CancelEditing<Type>Path``, out of scope here.)
Enable:
Fetch pset data via ``tool.Model.get_modeling_bbim_pset_data`` set
draft props ``is_editing = True``. The subclass post-load hook
can reshape the dict to fit the PropertyGroup's storage layout
(e.g., pre-serialise a structured pset value to JSON for a
``StringProperty`` field).
Finish:
Read fresh pset keep ``path_data`` gather ``general`` kwargs
(project units) reassemble ``is_editing = False`` call
``_update_pset`` (per-type pset writer) call ``_update_modifier_ifc_data``
(per-type geometry commit).
Cancel:
Read fresh pset restore draft props call
``_restore_viewport_after_cancel`` (per-type viewport restore typically
rebuilds the bmesh preview, but subclasses may load a different
representation entirely) ``is_editing = False``."""
@classmethod
def _post_load_data(cls, data: dict) -> dict:
"""Hook: optionally transform the pset data dict after loading and before
passing to ``set_props_kwargs_from_ifc_data``. Default: pass-through.
Override when the PropertyGroup stores a structured pset field as a
serialised primitive e.g., a list/dict value mapped onto a
``StringProperty`` requires JSON-encoding here."""
return data
@classmethod
def _update_pset(cls, element: entity_instance, data: dict) -> None:
"""Hook: per-type pset writer (``update_bbim_<type>_pset``)."""
raise NotImplementedError
@classmethod
def _update_modifier_ifc_data(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
"""Hook: per-type ``update_<type>_modifier_ifc_data`` — commits the
modified geometry to IFC. Signature accepts ``(obj, context)`` so
subclasses can forward either argument to their existing helper."""
raise NotImplementedError
@classmethod
def _restore_viewport_after_cancel(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
"""Hook: restore the viewport mesh to match the just-restored draft props.
Most subclasses rebuild a bmesh preview from props. Subclasses whose
committed IFC representation diverges from the preview may switch
the mesh back to the committed representation instead."""
raise NotImplementedError
@classmethod
def _enable_one(cls, obj: bpy.types.Object) -> None:
resolved = cls._resolve(obj)
if resolved is None:
return
_element, props = resolved
cls._handle_drift_on_enable(obj)
data = tool.Model.get_modeling_bbim_pset_data(obj, cls.pset_name)["data_dict"]
data = cls._post_load_data(data)
props.set_props_kwargs_from_ifc_data(data)
props.is_editing = True
@classmethod
def _finish_one(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
resolved = cls._resolve(obj)
if resolved is None:
return
element, props = resolved
pset_data = tool.Model.get_modeling_bbim_pset_data(obj, cls.pset_name)
stored = pset_data["data_dict"]
data = props.get_general_kwargs(convert_to_project_units=True)
data["path_data"] = stored["path_data"]
# Skip the pset commit when the draft is identical to the stored pset:
# an Enable → Finish-without-changes cycle should not pollute the
# representation list or burn an undo entry. Drift commit still runs
# unconditionally — matrix_world drift is independent of pset content.
if data != stored:
cls._update_pset(element, data)
cls._update_modifier_ifc_data(obj, context)
cls._mark_type_thumbnail_dirty(element)
cls._handle_drift_on_finish(obj)
# Set only on success: if any IFC op above raised, the user's draft survives for retry.
props.is_editing = False
@classmethod
def _cancel_one(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
resolved = cls._resolve(obj)
if resolved is None:
return
element, props = resolved
try:
pset_data = tool.Model.get_modeling_bbim_pset_data(obj, cls.pset_name)
stored = pset_data["data_dict"]
draft = props.get_general_kwargs(convert_to_project_units=True)
draft["path_data"] = stored["path_data"]
nothing_changed = draft == stored
data = cls._post_load_data(stored)
props.set_props_kwargs_from_ifc_data(data)
# Skip the viewport rebuild on a no-op cancel: the mesh on screen is
# still the committed representation, and the per-type viewport-restore
# hook may be expensive (some subclasses reload a high-poly IFC
# representation rather than rebuild a preview mesh).
if not nothing_changed:
cls._restore_viewport_after_cancel(obj, context)
cls._handle_drift_on_cancel(obj, element)
finally:
# Always clear the flag — see ``FeatureModifierEditMixin._cancel_one``
# for the rationale.
props.is_editing = False
def _enable_targets(self, context: bpy.types.Context) -> set[str]:
for obj in self._iter_targets(context):
self._enable_one(obj)
return {"FINISHED"}
def _finish_targets(self, context: bpy.types.Context) -> set[str]:
for obj in self._iter_targets(context):
self._finish_one(obj, context)
return {"FINISHED"}
def _cancel_targets(self, context: bpy.types.Context) -> set[str]:
for obj in self._iter_targets(context):
self._cancel_one(obj, context)
return {"FINISHED"}
# --- Type-selection mixins (Cycle / Pick) ------------------------------------
class TypeAccessorBase:
"""Shared contract for operators that resolve and write a Literal type
attribute on a Bonsai PropertyGroup.
Subclasses define ``element_checker``, ``props_getter``, ``type_literal``,
``type_attr``; ``skip_element_check`` bypasses element validation. Concrete
subclasses (``CycleTypeMixin``, ``PickTypeMixin``) add the interaction
shape on top.
Test doubles must be set on the operator instance the predicates are
bound at class-definition time, so patching the underlying tool module
has no effect."""
element_checker: Callable[[entity_instance], bool]
props_getter: Callable[[bpy.types.Object], bpy.types.PropertyGroup]
type_literal: type
type_attr: str
skip_element_check: bool = False
def _resolve_target(self, context: bpy.types.Context) -> bpy.types.Object | None:
"""Return the active object iff it passes ``element_checker`` (or the
check is skipped). ``None`` signals the operator should bail with
``{'CANCELLED'}``."""
obj = context.active_object
if not obj:
return None
if not self.skip_element_check:
element = tool.Ifc.get_entity(obj)
if not element or not self.element_checker(element):
return None
return obj
class CycleTypeMixin(TypeAccessorBase):
"""Operator mixin that cycles through ``type_literal``'s values.
Shift-click reverses direction."""
reverse: bpy.props.BoolProperty(name="Reverse", default=False, options={"HIDDEN", "SKIP_SAVE"})
def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]:
self.reverse = event.shift
return self.execute(context)
def _cycle_type(self, context: bpy.types.Context) -> set[str]:
obj = self._resolve_target(context)
if obj is None:
return {"CANCELLED"}
props = self.props_getter(obj)
types = get_args(self.type_literal)
current = getattr(props, self.type_attr)
idx = types.index(current) if current in types else 0
direction = -1 if self.reverse else 1
setattr(props, self.type_attr, types[(idx + direction) % len(types)])
return {"FINISHED"}
class PickTypeMixin(TypeAccessorBase):
"""Operator mixin that opens a popup menu listing ``type_literal``'s values.
Empty ``value`` ``invoke`` opens the popup; non-empty the user picked
an item and ``_pick_type`` applies it.
When invoked mid-click (e.g. from a gizmo's ``target_set_operator``), the
menu opens only after the originating ``LEFTMOUSE`` releases. Otherwise
the still-pressed click flows straight into Blender's drag-through-pick
gesture and the menu commits whichever item the cursor drifts over on
release. Other invocation paths (command-palette / F3, EXEC_DEFAULT, F6
redo) bypass the wait and open the menu immediately.
The ``value`` StringProperty is declared on this mixin but registered via
the concrete Operator subclass's MRO scan — do not instantiate the mixin
standalone."""
# Carries the picked value through invoke→execute; empty default
# distinguishes "open popup" from "apply".
value: bpy.props.StringProperty(default="", options={"HIDDEN", "SKIP_SAVE"})
def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]:
"""Open the picker menu, or apply a value that was preset by a
menu-item click.
Routing through ``execute()`` keeps subclass IFC-transaction wrapping
in the loop and means F6 redo / ``EXEC_DEFAULT`` reach the apply path."""
if self.value:
return self.execute(context)
if self._resolve_target(context) is None:
return {"CANCELLED"}
if event.value == "PRESS":
context.window_manager.modal_handler_add(self)
return {"RUNNING_MODAL"}
return self._open_picker(context)
def modal(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]:
if event.type == "LEFTMOUSE" and event.value == "RELEASE":
self._open_picker(context)
# INTERFACE does not remove a modal handler; only FINISHED /
# CANCELLED do.
return {"CANCELLED"}
if event.type in {"RIGHTMOUSE", "ESC"}:
return {"CANCELLED"}
return {"RUNNING_MODAL"}
def _open_picker(self, context: bpy.types.Context) -> set[str]:
bl_idname = self.bl_idname
values = list(get_args(self.type_literal))
def draw(menu_self, _menu_context):
layout = menu_self.layout
for v in values:
op = layout.operator(bl_idname, text=v)
op.value = v
context.window_manager.popup_menu(draw, title=self.bl_label, icon="MENU_PANEL")
# The type change is a two-step interaction: this invocation just OPENS
# the menu (no state change yet); a SECOND invocation fires when the
# user clicks a menu item — that one writes ``props.<type_attr>`` and
# returns FINISHED. By returning INTERFACE here (and not FINISHED), the
# menu-open step is excluded from Blender's undo stack so the user
# gets exactly ONE undo entry per type change. If we returned FINISHED
# here too, the stack would gain a no-op "opened the menu" entry that
# Ctrl+Z would dismiss before reverting the actual type change —
# confusing UX where the first Ctrl+Z appears to do nothing.
return {"INTERFACE"}
def _pick_type(self, context: bpy.types.Context) -> set[str]:
if not self.value:
# No-op rather than re-open the menu, so command-palette misuse
# doesn't infinite-loop.
return {"CANCELLED"}
obj = self._resolve_target(context)
if obj is None:
return {"CANCELLED"}
if self.value not in get_args(self.type_literal):
self.report({"WARNING"}, f"Unknown {self.type_attr}: {self.value!r}")
return {"CANCELLED"}
props = self.props_getter(obj)
setattr(props, self.type_attr, self.value)
return {"FINISHED"}
class IntegerInputDialogMixin:
"""Operator mixin that mirrors a per-feature ``IntProperty`` on the
operator into a draft attribute on the active object's parametric props,
via Blender's ``invoke_props_dialog`` popup.
Subclasses declare:
- ``attr_name`` name of the IntProperty on the subclass AND of the
attribute on the resolved props (same name on both sides).
- ``props_getter`` ``staticmethod(tool.Model.get_<feature>_props)``.
- ``requires_editing`` True iff the operator must no-op outside an
active edit lifecycle. Default False.
- ``value_min`` minimum value to clamp to. Default 1."""
attr_name: ClassVar[str] = ""
props_getter: ClassVar[Callable[[bpy.types.Object], bpy.types.PropertyGroup]]
requires_editing: ClassVar[bool] = False
value_min: ClassVar[int] = 1
def _resolve_props(self, context: bpy.types.Context) -> bpy.types.PropertyGroup | None:
"""Return the active object's parametric props if the operator is
allowed to fire, ``None`` otherwise (caller bails with ``CANCELLED``)."""
obj = context.active_object
if not obj:
return None
props = self.props_getter(obj)
if self.requires_editing and not props.is_editing:
return None
return props
def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]: # noqa: ARG002
props = self._resolve_props(context)
if props is None:
return {"CANCELLED"}
setattr(self, self.attr_name, max(self.value_min, getattr(props, self.attr_name)))
return context.window_manager.invoke_props_dialog(self)
def execute(self, context: bpy.types.Context) -> set[str]:
props = self._resolve_props(context)
if props is None:
return {"CANCELLED"}
setattr(props, self.attr_name, max(self.value_min, getattr(self, self.attr_name)))
return {"FINISHED"}
# --- Undo-resync registry ----------------------------------------------------
#
# Per-type regenerators called from ``resync_parametric_drafts_after_undo``
# (wired into ``bim/handler.py:undo_post`` and ``redo_post``) so the preview
# mesh of an in-progress parametric draft repaints after Ctrl+Z / Ctrl+Shift+Z.
#
# Each regenerator is a one-line lazy-import + call. Lazy imports because
# ``bonsai.bim.parametric_lifecycle`` loads before ``bim/module/model/*``
# at addon enable; a module-level import would cycle. Each function-local
# import lands at first call, after the feature module has registered.
#
# Types with no entry — door, window, railing, etc. — are IFC-derived: undo
# of an IFC mutation already restores the entity, and ``switch_representation``
# repaints the mesh as a side effect of the next refresh. They don't need a
# bespoke preview regenerator.
def _wall_undo_regenerator(obj: bpy.types.Object) -> None:
from bonsai.bim.module.model.wall import regenerate_wall_mesh_from_props
regenerate_wall_mesh_from_props(obj)
def _stair_undo_regenerator(obj: bpy.types.Object) -> None:
from bonsai.bim.module.model.stair import regenerate_stair_mesh
regenerate_stair_mesh(obj)
def _roof_undo_regenerator(obj: bpy.types.Object) -> None:
from bonsai.bim.module.model.roof import update_roof_modifier_bmesh
update_roof_modifier_bmesh(obj)
UNDO_REGENERATORS: dict[str, Callable[[bpy.types.Object], None]] = {
"wall": _wall_undo_regenerator,
"stair": _stair_undo_regenerator,
"roof": _roof_undo_regenerator,
}
def resync_parametric_drafts_after_undo() -> None:
"""Re-render preview meshes for every parametric draft currently active.
Walks all objects, skips any not in a registered parametric edit,
dispatches to the per-type regenerator in ``UNDO_REGENERATORS``. A type
without an entry is left alone its preview is either already correct
(IFC-derived) or has no draft preview mesh."""
for obj in bpy.data.objects:
feature = tool.Parametric.is_object_editing(obj)
if feature is None:
continue
regenerator = UNDO_REGENERATORS.get(feature.name)
if regenerator is None:
continue
regenerator(obj)
tool.Blender.update_all_viewports()
@persistent
def _resync_on_undo(scene: bpy.types.Scene) -> None:
resync_parametric_drafts_after_undo()
def install_parametric_lifecycle_handlers() -> None:
"""Append the undo-resync callback to undo_post and redo_post; idempotent.
Caller must invoke this AFTER appending the central undo/redo handlers so
regenerators see restored IFC state bpy.app.handlers fire in append order."""
for hook in (bpy.app.handlers.undo_post, bpy.app.handlers.redo_post):
if _resync_on_undo not in hook:
hook.append(_resync_on_undo)
def uninstall_parametric_lifecycle_handlers() -> None:
for hook in (bpy.app.handlers.undo_post, bpy.app.handlers.redo_post):
try:
hook.remove(_resync_on_undo)
except ValueError:
pass
+28 -161
View File
@@ -15,6 +15,8 @@
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was modified with the assistance of an AI coding tool.
import os
import platform
@@ -92,7 +94,10 @@ class IFCFileSelector:
filepath = self.get_filepath_abs()
if self.use_relative_path:
filepath = filepath.relative_to(bpy.path.abspath("//"))
try:
filepath = filepath.relative_to(bpy.path.abspath("//"))
except ValueError:
pass # IFC file is not under the blend directory; keep absolute path
return filepath.as_posix().replace("\\", "/")
def draw(self, context: bpy.types.Context) -> None:
@@ -273,130 +278,33 @@ class BIM_UL_panel_visibilities(bpy.types.UIList):
row.prop(item, "is_bookmarked", text="", icon="SOLO_ON" if item.is_bookmarked else "SOLO_OFF", emboss=False)
class GizmoPreferencesDoor(bpy.types.PropertyGroup):
"""Property group for door gizmo visibility settings."""
overall_height: BoolProperty(name="Overall Height", default=True)
overall_width: BoolProperty(name="Overall Width", default=True)
threshold_thickness: BoolProperty(name="Threshold Thickness", default=True)
threshold_depth: BoolProperty(name="Threshold Depth", default=True)
threshold_offset: BoolProperty(name="Threshold Offset", default=True)
lining_offset: BoolProperty(name="Lining Offset", default=True)
lining_depth: BoolProperty(name="Lining Depth", default=True)
lining_thickness: BoolProperty(name="Lining Thickness", default=True)
transom_offset: BoolProperty(name="Transom Offset", default=True)
transom_thickness: BoolProperty(name="Transom Thickness", default=True)
casing_thickness: BoolProperty(name="Casing Thickness", default=True)
casing_depth: BoolProperty(name="Casing Depth", default=True)
swing_arc: BoolProperty(name="Swing Arc", default=True, description="Show door swing direction arc")
flip_arc: BoolProperty(name="Flip Arc", default=True, description="Show flip door orientation arc")
if TYPE_CHECKING:
overall_height: bool
overall_width: bool
threshold_thickness: bool
threshold_depth: bool
threshold_offset: bool
lining_offset: bool
lining_depth: bool
lining_thickness: bool
transom_offset: bool
transom_thickness: bool
casing_thickness: bool
casing_depth: bool
swing_arc: bool
flip_arc: bool
class GizmoPreferencesWindow(bpy.types.PropertyGroup):
"""Property group for window gizmo visibility settings."""
overall_height: BoolProperty(name="Overall Height", default=True)
overall_width: BoolProperty(name="Overall Width", default=True)
lining_offset: BoolProperty(name="Lining Offset", default=True)
lining_depth: BoolProperty(name="Lining Depth", default=True)
lining_thickness: BoolProperty(name="Lining Thickness", default=True)
lining_to_panel_offset_x: BoolProperty(name="Lining to Panel Offset X", default=True)
lining_to_panel_offset_y: BoolProperty(name="Lining to Panel Offset Y", default=True)
frame_depth: BoolProperty(name="Frame Depth", default=True)
frame_thickness: BoolProperty(name="Frame Thickness", default=True)
mullion_thickness: BoolProperty(name="Mullion Thickness", default=True)
first_mullion_offset: BoolProperty(name="First Mullion Offset", default=True)
second_mullion_offset: BoolProperty(name="Second Mullion Offset", default=True)
transom_thickness: BoolProperty(name="Transom Thickness", default=True)
first_transom_offset: BoolProperty(name="First Transom Offset", default=True)
second_transom_offset: BoolProperty(name="Second Transom Offset", default=True)
if TYPE_CHECKING:
overall_height: bool
overall_width: bool
lining_offset: bool
lining_depth: bool
lining_thickness: bool
lining_to_panel_offset_x: bool
lining_to_panel_offset_y: bool
frame_depth: bool
frame_thickness: bool
mullion_thickness: bool
first_mullion_offset: bool
second_mullion_offset: bool
transom_thickness: bool
first_transom_offset: bool
second_transom_offset: bool
class GizmoPreferencesStair(bpy.types.PropertyGroup):
"""Property group for stair gizmo visibility settings."""
width: BoolProperty(name="Width", default=True)
height: BoolProperty(name="Height", default=True)
tread_run: BoolProperty(name="Tread Run", default=True)
tread_depth: BoolProperty(name="Tread Depth", default=True)
riser_height: BoolProperty(name="Riser Height", default=True)
nosing_length: BoolProperty(name="Nosing Length", default=True)
nosing_depth: BoolProperty(name="Nosing Depth", default=True)
total_length_target: BoolProperty(name="Total Length Target", default=True)
base_slab_depth: BoolProperty(name="Base Slab Depth", default=True)
top_slab_depth: BoolProperty(name="Top Slab Depth", default=True)
lock: BoolProperty(name="Total Length Lock", default=True)
plus: BoolProperty(name="Add Tread (+)", default=True)
minus: BoolProperty(name="Remove Tread (-)", default=True)
cycle: BoolProperty(name="Cycle Stair Type", default=True)
if TYPE_CHECKING:
width: bool
height: bool
tread_run: bool
tread_depth: bool
riser_height: bool
nosing_length: bool
nosing_depth: bool
total_length_target: bool
base_slab_depth: bool
top_slab_depth: bool
lock: bool
plus: bool
minus: bool
cycle: bool
class GizmoPreferences(bpy.types.PropertyGroup):
"""Property group for all gizmo visibility settings."""
"""Aggregator for parametric gizmo visibility settings. One flat bool per
parametric feature; controls whether that feature's gizmo group polls
visible in the viewport."""
draw_gizmos_in_3d_viewport: BoolProperty(
name="Draw Gizmos In 3D Viewport",
default=True,
description="Show interactive gizmos in the 3D viewport for parametric elements",
)
door: bpy.props.PointerProperty(type=GizmoPreferencesDoor)
window: bpy.props.PointerProperty(type=GizmoPreferencesWindow)
stair: bpy.props.PointerProperty(type=GizmoPreferencesStair)
door: BoolProperty(name="Door", default=True)
window: BoolProperty(name="Window", default=True)
stair: BoolProperty(name="Stair", default=True)
railing: BoolProperty(name="Railing", default=True)
roof: BoolProperty(name="Roof", default=True)
array: BoolProperty(name="Array", default=True)
wall: BoolProperty(name="Wall", default=True)
if TYPE_CHECKING:
draw_gizmos_in_3d_viewport: bool
door: GizmoPreferencesDoor
window: GizmoPreferencesWindow
stair: GizmoPreferencesStair
door: bool
window: bool
stair: bool
railing: bool
roof: bool
array: bool
wall: bool
class DocPreferences(bpy.types.PropertyGroup):
@@ -844,54 +752,13 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
)
def draw_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
"""Render one enabled-toggle per parametric feature."""
layout.label(text="Toggle visibility of gizmos in editing mode")
box = layout.box()
bonsai.bim.helper.draw_expandable_panel(box, context, "Parametric Door", self.draw_door_gizmo_parameters)
bonsai.bim.helper.draw_expandable_panel(box, context, "Parametric Window", self.draw_window_gizmo_parameters)
bonsai.bim.helper.draw_expandable_panel(box, context, "Parametric Stair", self.draw_stair_gizmo_parameters)
def draw_door_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
from bonsai.bim.module.model.door import GizmoDoorEdition
door_gizmos = self.gizmos.door
gizmo_prop_names = {p.attr_name for p in GizmoDoorEdition.dimension_gizmo_props}
# Add special gizmos not in dimension_gizmo_props
gizmo_prop_names.update(("swing_arc", "flip_arc"))
try:
annotations = door_gizmos.__annotations__
except AttributeError:
annotations = type(door_gizmos).__annotations__
for prop in annotations:
if prop in gizmo_prop_names:
layout.prop(door_gizmos, prop)
def draw_window_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
from bonsai.bim.module.model.window import GizmoWindowEdition
window_gizmos = self.gizmos.window
gizmo_prop_names = {p.attr_name for p in GizmoWindowEdition.dimension_gizmo_props}
try:
annotations = window_gizmos.__annotations__
except AttributeError:
annotations = type(window_gizmos).__annotations__
for prop in annotations:
if prop in gizmo_prop_names:
layout.prop(window_gizmos, prop)
def draw_stair_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
from bonsai.bim.module.model.stair import GizmoStairEdition
stair_gizmos = self.gizmos.stair
gizmo_prop_names = {p.attr_name for p in GizmoStairEdition.dimension_gizmo_props}
# Add special gizmos not in dimension_gizmo_props
special_gizmo_names = {"lock", "plus", "minus", "cycle"}
try:
annotations = stair_gizmos.__annotations__
except AttributeError:
annotations = type(stair_gizmos).__annotations__
for prop in annotations:
if prop in gizmo_prop_names or prop in special_gizmo_names:
layout.prop(stair_gizmos, prop)
annotations = type(self.gizmos).__annotations__
for feature in tool.Parametric.EDIT_TYPES:
if feature.name in annotations:
box.prop(self.gizmos, feature.name)
def draw_model_settings(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
layout.prop(self, "occurrence_name_style")
+3
View File
@@ -93,6 +93,8 @@ def enter_aggregate_mode(
aggregator: type[tool.Aggregate],
obj: bpy.types.Object,
):
if not aggregator.get_aggregate_props().in_aggregate_mode:
aggregator.save_previous_selection()
aggregator.update_previous_aggregate_mode_state()
if aggregator.get_higher_aggregate():
aggregator.disable_aggregate_mode()
@@ -107,6 +109,7 @@ def exit_aggregate_mode(aggregator: type[tool.Aggregate]):
aggregator.enable_aggregate_mode(new_obj)
else:
aggregator.disable_aggregate_mode()
aggregator.restore_previous_selection()
class IncompatibleAggregateError(Exception):
+514 -8
View File
@@ -15,10 +15,13 @@
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was modified with the assistance of an AI coding tool.
from __future__ import annotations
from typing import TYPE_CHECKING, Literal, Optional
import math
from typing import TYPE_CHECKING, Any, Literal, Optional
if TYPE_CHECKING:
import bpy
@@ -31,6 +34,24 @@ if TYPE_CHECKING:
OffsetType = Literal["CENTER", "EXTERIOR", "INTERIOR"]
# Arc sample count for fillet preview polylines. 24 samples produces a visually
# smooth arc at common viewport scales without bloating the GPU batch.
FILLET_DEFAULT_ARC_RESOLUTION = 24
# Dot-product floor for treating two wall-axis segments as parallel — below
# this the projected intersection is too sensitive to floating-point noise
# to be useful as a junction apex. Calibrated to ~2° from parallel.
PARALLEL_DOT_THRESHOLD = 0.9994
# Perpendicular distance (SI metres) under which two parallel wall axes are
# considered to share the same infinite line. Calibrated to absorb sub-50mm
# placement drift between authored-joined walls without merging genuinely
# offset parallel walls.
COLLINEAR_LINE_TOLERANCE = 0.05
# Default proximity (SI metres) for classifying a layer offset against the
# canonical EXTERIOR / CENTER / INTERIOR baselines. Tight enough that ordinary
# millimetre-scale modelling intent always falls into the nearest baseline.
BASELINE_OFFSET_TOLERANCE = 0.001
def unjoin_walls(
ifc: type[tool.Ifc],
blender: type[tool.Blender],
@@ -140,23 +161,73 @@ def align_objects(
model.align_objects(reference_obj, objs, align_type)
def regenerate_wall_to_underside(
ifc: type[tool.Ifc],
geometry: type[tool.Geometry],
model: type[tool.Model],
wall_objs: list[bpy.types.Object],
) -> None:
"""Re-clip walls to their connected underside objects after the slab has moved."""
clipped_objs = []
for obj in wall_objs:
wall = ifc.get_entity(obj)
slab_objs = model.get_connected_slab_objs(wall)
if not slab_objs:
continue
if ifc.is_moved(obj):
geometry.run_edit_object_placement(obj=obj)
# Sync each slab's Blender mesh to its current IFC representation before
# reading face geometry, so a changed profile is picked up correctly.
model.reload_body_representation(slab_objs)
model.remove_wall_to_underside_booleans(wall)
for slab_obj in slab_objs:
clip = model.get_slab_clipping_bmesh(slab_obj)
if clip:
model.clip_wall_to_slab(wall, clip)
clipped_objs.append(obj)
if clipped_objs:
model.reload_body_representation(clipped_objs)
def extend_wall_to_slab(
ifc: type[tool.Ifc],
geometry: type[tool.Geometry],
model: type[tool.Model],
slab_obj: bpy.types.Object,
slab_objs: list[bpy.types.Object],
wall_objs: list[bpy.types.Object],
) -> None:
if not (clip := model.get_slab_clipping_bmesh(slab_obj)):
return # Nothing to clip?
slab = ifc.get_entity(slab_obj)
# If any wall is currently in item mode, exit it before modifying the
# representation. Leaving stale item objects around causes delete_ifc_item
# to later remove the extrusion (or other pre-boolean items) from inside
# the boolean chain, corrupting the IFC model.
geom_props = geometry.get_geometry_props()
if geom_props.representation_obj in wall_objs:
geometry.disable_item_mode()
clipped_walls = []
for obj in wall_objs:
if ifc.is_moved(obj):
geometry.run_edit_object_placement(obj=obj)
wall = ifc.get_entity(obj)
model.clip_wall_to_slab(wall, clip)
model.connect_wall_to_slab(wall, slab)
model.reload_body_representation(wall_objs)
# Merge previously connected slabs with newly requested ones so that
# re-running the operator never produces duplicate booleans and never
# silently discards clips that were applied in an earlier call.
existing = model.get_connected_slab_objs(wall)
seen = {id(s) for s in existing}
all_slab_objs = list(existing) + [s for s in slab_objs if id(s) not in seen]
# Remove stale booleans once, then re-clip against the full set.
model.remove_wall_to_underside_booleans(wall)
did_clip = False
for slab_obj in all_slab_objs:
clip = model.get_slab_clipping_bmesh(slab_obj)
if not clip:
continue
model.clip_wall_to_slab(wall, clip)
model.connect_wall_to_slab(wall, ifc.get_entity(slab_obj))
did_clip = True
if did_clip:
clipped_walls.append(obj)
if clipped_walls:
model.reload_body_representation(clipped_walls)
class RequireTwoWallsError(Exception):
@@ -173,3 +244,438 @@ class RequireAtLeastTwoElements(Exception):
class RequireLayeredElement(Exception):
pass
# --- Wall geometry math (pure) ------------------------------------------------
# Tuple in / tuple out so these helpers run without ``bpy`` or ``mathutils``.
# Callers convert ``mathutils.Vector`` at the boundary.
def baseline_from_offset(offset: float, thickness: float, tolerance: float = BASELINE_OFFSET_TOLERANCE) -> str:
"""Classify a numeric layer offset as EXTERIOR / CENTER / INTERIOR.
Handles both POSITIVE and NEGATIVE direction_sense walls. Returns the
closest canonical baseline; falls back to ``"CENTER"`` when nothing is
within ``tolerance``."""
candidates = (
("EXTERIOR", 0.0),
("CENTER", -thickness / 2),
("INTERIOR", -thickness),
("EXTERIOR", thickness),
("CENTER", thickness / 2),
("INTERIOR", 0.0),
)
best = min(candidates, key=lambda c: abs(offset - c[1]))
return best[0] if abs(offset - best[1]) < tolerance else "CENTER"
def project_axis_intersection(
seg_a: tuple[tuple[float, float, float], tuple[float, float, float]],
seg_b: tuple[tuple[float, float, float], tuple[float, float, float]],
parallel_threshold: float,
) -> Optional[tuple[float, float, float]]:
"""Compute the 2D (X,Y plane) intersection of two world-space axis segments.
Each segment is a pair of 3-tuples. Returns the intersection as a 3-tuple
(Z is the average of the four input Zs, for visual placement) or ``None`` if
the segments are parallel within ``parallel_threshold`` (a dot-product magnitude
threshold see ``PARALLEL_DOT_THRESHOLD`` for the calibrated value)."""
p1, p2 = seg_a
p3, p4 = seg_b
d1x, d1y = p2[0] - p1[0], p2[1] - p1[1]
d2x, d2y = p4[0] - p3[0], p4[1] - p3[1]
d1_len = (d1x * d1x + d1y * d1y) ** 0.5
d2_len = (d2x * d2x + d2y * d2y) ** 0.5
if d1_len < 1e-9 or d2_len < 1e-9:
return None
dot = (d1x * d2x + d1y * d2y) / (d1_len * d2_len)
if abs(dot) >= parallel_threshold:
return None
denom = d1x * d2y - d1y * d2x
if abs(denom) < 1e-9:
return None
t = ((p3[0] - p1[0]) * d2y - (p3[1] - p1[1]) * d2x) / denom
ix = p1[0] + t * d1x
iy = p1[1] + t * d1y
iz = (p1[2] + p2[2] + p3[2] + p4[2]) / 4
return (ix, iy, iz)
def opening_is_past_cut(min_t: float, cut_percentage: float) -> bool:
"""True when the opening's near edge sits past the cut on the t axis.
Strict inequality is load-bearing: a boundary touch or NaN keeps the
opening on both walls the safe default when extent resolution fails."""
return min_t > cut_percentage
def opening_is_before_cut(max_t: float, cut_percentage: float) -> bool:
"""True when the opening's far edge sits before the cut on the t axis."""
return max_t < cut_percentage
def opening_straddles_cut(min_t: float, max_t: float, cut_percentage: float) -> bool:
"""True when the opening's extent crosses the cut on the t axis."""
return min_t < cut_percentage < max_t
WallJoinState = Literal["joined", "collinear", "intersect", "none"]
def classify_wall_join_state(
seg_a: tuple[tuple[float, float, float], tuple[float, float, float]],
seg_b: tuple[tuple[float, float, float], tuple[float, float, float]],
are_joined: bool,
parallel_threshold: float,
collinear_tolerance: float,
) -> tuple[WallJoinState, Optional[tuple[float, float, float]]]:
"""Classify a wall pair's geometric state — ``(state, intersection)``.
Priority: ``"joined"`` (caller-supplied flag) ``"collinear"``
``"intersect"`` (projected point returned) ``"none"`` (parallel,
non-collinear)."""
if are_joined:
return "joined", None
if are_axes_collinear(seg_a, seg_b, parallel_threshold, collinear_tolerance):
return "collinear", None
intersection = project_axis_intersection(seg_a, seg_b, parallel_threshold)
if intersection is None:
return "none", None
return "intersect", intersection
def wall_join_preview_lines(
seg_a: tuple[tuple[float, float, float], tuple[float, float, float]],
seg_b: tuple[tuple[float, float, float], tuple[float, float, float]],
intersection: tuple[float, float, float],
) -> list[tuple[tuple[float, float, float], tuple[float, float, float]]]:
"""Two segments showing each wall axis extending to ``intersection``.
Each segment runs from the input axis's nearest endpoint to the
intersection, held at that wall's own Z. Returned in input order
``[floor_a, floor_b]``."""
ix, iy, _ = intersection
def _nearest(seg: tuple[tuple[float, float, float], tuple[float, float, float]]) -> tuple[float, float, float]:
return min(seg, key=lambda p: (p[0] - ix) ** 2 + (p[1] - iy) ** 2)
near_a = _nearest(seg_a)
near_b = _nearest(seg_b)
return [
(near_a, (ix, iy, near_a[2])),
(near_b, (ix, iy, near_b[2])),
]
def resolve_extend_walls_target(
target_obj: Any,
objs: list[Any],
reverse: bool,
) -> tuple[Any, list[Any]]:
"""Pick which object is the extend-target and which are extended.
Default direction: ``objs`` are extended to meet ``target_obj``.
Reversed direction (``reverse=True``) swaps the pair equivalent to
having passed them in the opposite order. The swap is well-defined only
for the 1+1 case (one target + one other); for ``n>1`` it would be
ambiguous, so the default direction is preserved instead."""
if reverse and target_obj is not None and len(objs) == 1:
return objs[0], [target_obj]
return target_obj, objs
def displacement_from_x_angle(height: float, x_angle: float) -> float:
"""Top-edge horizontal displacement for a wall of given vertical ``height``
and slope ``x_angle`` (radians). Inverse of ``x_angle_from_displacement``."""
return height * math.tan(x_angle)
def x_angle_from_displacement(height: float, displacement: float) -> float:
"""Recover slope ``x_angle`` (radians) from a top-edge horizontal displacement.
``height`` is clamped to ``max(height, 1e-6)`` so zero-height walls map
cleanly to ``±π/2`` instead of dividing by zero."""
return math.atan2(displacement, max(height, 1e-6))
def vertical_height_from_extrusion_depth(extrusion_depth: float, x_angle: float) -> float:
"""Vertical height of a wall given its slanted extrusion depth and slope.
``IfcExtrudedAreaSolid.Depth`` measures along the (possibly slanted) extrusion
direction. The vertical height the user thinks of is ``depth * cos(x_angle)``.
Unit-agnostic: the result is in the same units as ``extrusion_depth``."""
return extrusion_depth * abs(math.cos(x_angle))
def extrusion_depth_from_vertical_height(vertical_height: float, x_angle: float) -> float:
"""``vertical_height / cos(x_angle)`` with ``cos`` clamped at ``1e-6`` to
stay finite near ``±π/2``."""
return vertical_height / max(abs(math.cos(x_angle)), 1e-6)
def length_and_height_from_extrusion(
extrusion_depth: float,
x_angle: float,
reference_line_x_extent: float,
unit_scale: float,
) -> tuple[float, float]:
"""SI ``(length, vertical_height)`` of a LAYER2 wall.
Height is the *vertical* projection of the slanted depth, not the
slanted depth itself."""
length = reference_line_x_extent * unit_scale
height = vertical_height_from_extrusion_depth(extrusion_depth * unit_scale, x_angle)
return length, height
def are_axes_collinear(
seg_a: tuple[tuple[float, float, float], tuple[float, float, float]],
seg_b: tuple[tuple[float, float, float], tuple[float, float, float]],
parallel_threshold: float = PARALLEL_DOT_THRESHOLD,
line_tolerance: float = COLLINEAR_LINE_TOLERANCE,
) -> bool:
"""True if both axis segments lie on the same infinite line in plan.
Two conditions: directions must be (anti-)parallel within ``parallel_threshold``,
AND any endpoint of B must lie on A's infinite line within ``line_tolerance``.
Plan-only (Z ignored)."""
d1x, d1y = seg_a[1][0] - seg_a[0][0], seg_a[1][1] - seg_a[0][1]
d2x, d2y = seg_b[1][0] - seg_b[0][0], seg_b[1][1] - seg_b[0][1]
d1_len = (d1x * d1x + d1y * d1y) ** 0.5
d2_len = (d2x * d2x + d2y * d2y) ** 0.5
if d1_len < 1e-9 or d2_len < 1e-9:
return False
if abs((d1x * d2x + d1y * d2y) / (d1_len * d2_len)) < parallel_threshold:
return False
# Project seg_b[0] onto the infinite line through seg_a; the perpendicular
# distance to the original point tells us how far off the line B sits.
nx, ny = d1x / d1_len, d1y / d1_len
dx, dy = seg_b[0][0] - seg_a[0][0], seg_b[0][1] - seg_a[0][1]
t = dx * nx + dy * ny
proj_x = seg_a[0][0] + nx * t
proj_y = seg_a[0][1] + ny * t
perp_x = seg_b[0][0] - proj_x
perp_y = seg_b[0][1] - proj_y
return (perp_x * perp_x + perp_y * perp_y) ** 0.5 < line_tolerance
def closest_endpoint_midpoint(
seg_a: tuple[tuple[float, float, float], tuple[float, float, float]],
seg_b: tuple[tuple[float, float, float], tuple[float, float, float]],
) -> tuple[float, float, float]:
"""Midpoint of the closest endpoint pair between two segments."""
endpoints_a = (seg_a[0], seg_a[1])
endpoints_b = (seg_b[0], seg_b[1])
def _distance_sq(p: tuple[float, float, float], q: tuple[float, float, float]) -> float:
return (p[0] - q[0]) ** 2 + (p[1] - q[1]) ** 2 + (p[2] - q[2]) ** 2
closest_pair = min(((a, b) for a in endpoints_a for b in endpoints_b), key=lambda pair: _distance_sq(*pair))
a, b = closest_pair
return ((a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2)
def compute_path_connection_location(
seg_self: tuple[tuple[float, float, float], tuple[float, float, float]],
self_conn_type: str,
seg_other: tuple[tuple[float, float, float], tuple[float, float, float]],
other_conn_type: str,
parallel_threshold: float = PARALLEL_DOT_THRESHOLD,
) -> tuple[float, float, float]:
"""World-space location of a single ``IfcRelConnectsPathElements`` between
two wall axes.
Priority: ``self``'s ATSTART/ATEND endpoint → ``other``'s ATSTART/ATEND
endpoint axis intersection closest-endpoint midpoint fallback."""
if self_conn_type == "ATSTART":
return seg_self[0]
if self_conn_type == "ATEND":
return seg_self[1]
if other_conn_type == "ATSTART":
return seg_other[0]
if other_conn_type == "ATEND":
return seg_other[1]
intersection = project_axis_intersection(seg_self, seg_other, parallel_threshold)
if intersection is not None:
return intersection
return closest_endpoint_midpoint(seg_self, seg_other)
def _vec_sub(a: tuple[float, float, float], b: tuple[float, float, float]) -> tuple[float, float, float]:
return (a[0] - b[0], a[1] - b[1], a[2] - b[2])
def _vec_dot(a: tuple[float, float, float], b: tuple[float, float, float]) -> float:
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
def _vec_cross(a: tuple[float, float, float], b: tuple[float, float, float]) -> tuple[float, float, float]:
return (a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0])
def _vec_length(v: tuple[float, float, float]) -> float:
return (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]) ** 0.5
def _rotate_around_axis(
v: tuple[float, float, float],
axis: tuple[float, float, float],
angle: float,
) -> tuple[float, float, float]:
"""Rotate ``v`` around unit-length ``axis`` by ``angle`` radians."""
cos_a = math.cos(angle)
sin_a = math.sin(angle)
dot = _vec_dot(axis, v)
cross = _vec_cross(axis, v)
k = 1.0 - cos_a
return (
v[0] * cos_a + cross[0] * sin_a + axis[0] * dot * k,
v[1] * cos_a + cross[1] * sin_a + axis[1] * dot * k,
v[2] * cos_a + cross[2] * sin_a + axis[2] * dot * k,
)
def compute_fillet_polylines(
seg_a: tuple[tuple[float, float, float], tuple[float, float, float]],
seg_b: tuple[tuple[float, float, float], tuple[float, float, float]],
radius: float,
arc_resolution: int = FILLET_DEFAULT_ARC_RESOLUTION,
parallel_threshold: float = PARALLEL_DOT_THRESHOLD,
) -> dict:
"""Preview polylines for a circular fillet at the junction of two axes.
Returns a dict with ``valid``, ``reason``, ``intersection``, ``tangent_a``
/ ``tangent_b``, ``arc`` (``arc_resolution + 1`` samples), ``arc_center``,
``arc_radius``, ``sweep_angle``, ``sweep_axis``, ``tangent_offset``,
``wall_a_join_side`` / ``wall_b_join_side`` (ATSTART/ATEND/None),
``invalid_radius`` (tangent overshoots arc + tangents still populated
for warning rendering), and ``invalid_axes`` (set on parallel)."""
blank: dict = {
"valid": False,
"reason": None,
"intersection": None,
"tangent_a": None,
"tangent_b": None,
"arc": [],
"arc_center": None,
"arc_radius": radius,
"sweep_angle": 0.0,
"sweep_axis": None,
"tangent_offset": 0.0,
"wall_a_join_side": None,
"wall_b_join_side": None,
"invalid_radius": False,
"invalid_axes": None,
}
intersection = project_axis_intersection(seg_a, seg_b, parallel_threshold)
if intersection is None:
return {**blank, "reason": "parallel", "invalid_axes": [seg_a, seg_b]}
def _classify(seg, ipt):
d0 = (seg[0][0] - ipt[0]) ** 2 + (seg[0][1] - ipt[1]) ** 2 + (seg[0][2] - ipt[2]) ** 2
d1 = (seg[1][0] - ipt[0]) ** 2 + (seg[1][1] - ipt[1]) ** 2 + (seg[1][2] - ipt[2]) ** 2
if d0 <= d1:
return seg[0], seg[1], "ATSTART"
return seg[1], seg[0], "ATEND"
near_a, far_a, side_a = _classify(seg_a, intersection)
near_b, far_b, side_b = _classify(seg_b, intersection)
# Direction along each segment AWAY from the corner. ``far - intersection``
# handles both the shared-corner and extended-axes cases uniformly.
dir_a_raw = _vec_sub(far_a, intersection)
dir_b_raw = _vec_sub(far_b, intersection)
far_len_a = _vec_length(dir_a_raw)
far_len_b = _vec_length(dir_b_raw)
if far_len_a < 1e-9 or far_len_b < 1e-9:
return {**blank, "reason": "near_collinear", "intersection": intersection}
dir_a = (dir_a_raw[0] / far_len_a, dir_a_raw[1] / far_len_a, dir_a_raw[2] / far_len_a)
dir_b = (dir_b_raw[0] / far_len_b, dir_b_raw[1] / far_len_b, dir_b_raw[2] / far_len_b)
cos_angle = max(-1.0, min(1.0, _vec_dot(dir_a, dir_b)))
angle = math.acos(cos_angle)
sweep_angle = math.pi - angle
if sweep_angle < 1e-3 or sweep_angle > math.pi - 1e-3:
return {
**blank,
"reason": "near_collinear",
"intersection": intersection,
"sweep_angle": sweep_angle,
"wall_a_join_side": side_a,
"wall_b_join_side": side_b,
}
tangent_offset = radius * math.tan(sweep_angle / 2)
tangent_a = (
intersection[0] + dir_a[0] * tangent_offset,
intersection[1] + dir_a[1] * tangent_offset,
intersection[2] + dir_a[2] * tangent_offset,
)
tangent_b = (
intersection[0] + dir_b[0] * tangent_offset,
intersection[1] + dir_b[1] * tangent_offset,
intersection[2] + dir_b[2] * tangent_offset,
)
plane_normal_raw = _vec_cross(dir_a, dir_b)
pn_len = _vec_length(plane_normal_raw)
if pn_len < 1e-9:
return {**blank, "reason": "near_collinear", "intersection": intersection}
plane_normal = (
plane_normal_raw[0] / pn_len,
plane_normal_raw[1] / pn_len,
plane_normal_raw[2] / pn_len,
)
perp_a = _vec_cross(plane_normal, dir_a)
if _vec_dot(perp_a, dir_b) < 0:
perp_a = (-perp_a[0], -perp_a[1], -perp_a[2])
arc_center = (
tangent_a[0] + perp_a[0] * radius,
tangent_a[1] + perp_a[1] * radius,
tangent_a[2] + perp_a[2] * radius,
)
v_a = _vec_sub(tangent_a, arc_center)
v_b = _vec_sub(tangent_b, arc_center)
sweep_axis = plane_normal
if _vec_dot(_vec_cross(v_a, v_b), plane_normal) < 0:
sweep_axis = (-plane_normal[0], -plane_normal[1], -plane_normal[2])
arc_points: list[tuple[float, float, float]] = []
for i in range(arc_resolution + 1):
t = i / arc_resolution
rotated = _rotate_around_axis(v_a, sweep_axis, sweep_angle * t)
arc_points.append(
(
arc_center[0] + rotated[0],
arc_center[1] + rotated[1],
arc_center[2] + rotated[2],
)
)
# Overshoot check only for convex fillets (positive ``tangent_offset``);
# the inverted-fillet case puts tangents past the intersection.
invalid_radius = tangent_offset > 0 and (tangent_offset > far_len_a or tangent_offset > far_len_b)
return {
"valid": not invalid_radius,
"reason": "invalid_radius" if invalid_radius else None,
"intersection": intersection,
"tangent_a": tangent_a,
"tangent_b": tangent_b,
"arc": arc_points,
"arc_center": arc_center,
"arc_radius": radius,
"sweep_angle": sweep_angle,
"sweep_axis": sweep_axis,
"tangent_offset": tangent_offset,
"wall_a_join_side": side_a,
"wall_b_join_side": side_b,
"leg_a_available": far_len_a,
"leg_b_available": far_len_b,
"invalid_radius": invalid_radius,
"invalid_axes": None,
}
+64
View File
@@ -0,0 +1,64 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
from __future__ import annotations
import math
from collections.abc import Iterable
from typing import TYPE_CHECKING
import bonsai.core.geometry
if TYPE_CHECKING:
import bpy
import bonsai.tool as tool
Z_ROTATION_ALIGNMENT_TOLERANCE = 1e-9
def _z_rotation_diff(target_z: float, source_z: float) -> float:
"""Signed Z-Euler difference wrapped to [-π, π]."""
return (target_z - source_z + math.pi) % (2 * math.pi) - math.pi
def copy_z_rotation_to_selected(
ifc: type[tool.Ifc],
geometry: type[tool.Geometry],
surveyor: type[tool.Surveyor],
*,
active: bpy.types.Object,
targets: Iterable[bpy.types.Object],
flip: bool = False,
) -> int:
"""Apply ``active``'s Z-Euler rotation to each target."""
source_z = surveyor.get_z_rotation(active)
if flip:
source_z += math.pi
rotated = 0
for obj in targets:
if abs(_z_rotation_diff(surveyor.get_z_rotation(obj), source_z)) < Z_ROTATION_ALIGNMENT_TOLERANCE:
continue
surveyor.set_z_rotation(obj, source_z)
rotated += 1
if ifc.get_entity(obj) is not None:
bonsai.core.geometry.edit_object_placement(ifc, geometry, surveyor, obj=obj)
return rotated
+1 -22
View File
@@ -20,8 +20,6 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Optional
import ifcopenshell.util.element
if TYPE_CHECKING:
import bpy
import ifcopenshell
@@ -58,31 +56,12 @@ def copy_class(
geometry.change_object_data(obj, data, is_global=True)
geometry.rename_object(data, geometry.get_representation_name(ifc.get_entity(data)))
# Only assign styles if element doesn't get them from material
if not _has_material_styles(ifc, new):
if not root.has_material_styles(new):
root.assign_body_styles(new, obj)
collector.assign(obj)
return new
def _has_material_styles(ifc: type[tool.Ifc], element: ifcopenshell.entity_instance) -> bool:
"""Check if element has styles defined through its material.
Returns True if any constituent material has a style representation,
which means styles should NOT be applied directly to the geometry.
"""
materials = ifcopenshell.util.element.get_materials(element)
if not materials:
return False
# Check if any of the constituent materials have styles
for material in materials:
if hasattr(material, "HasRepresentation") and material.HasRepresentation:
return True
return False
def assign_class(
ifc: type[tool.Ifc],
collector: type[tool.Collector],
+3 -2
View File
@@ -64,10 +64,11 @@ def assign_container(
spatial.disable_editing(obj)
all_elements.add(root_element)
all_elements.update(spatial.get_decomposition(root_element))
if products := [e for e in root_elements if spatial.can_contain(container, root_element)]:
if products := [e for e in root_elements if spatial.can_contain(container, e)]:
ifc.run("spatial.assign_container", products=products, relating_structure=container)
for element in all_elements:
collector.assign(ifc.get_object(element))
if obj := ifc.get_object(element):
collector.assign(obj)
def enable_editing_container(spatial: type[tool.Spatial], obj: bpy.types.Object) -> None:
+63 -2
View File
@@ -415,6 +415,17 @@ class Drawing:
def update_embedded_svg_location(cls, uri, old_location, new_location): pass
@interface
class Duplicate:
def get_decomposition_relationships(cls, objs): pass
def get_connection_relationships(cls, objs): pass
def get_port_connection_relationships(cls, objs): pass
def recreate_decompositions(cls, relationships, old_to_new): pass
def recreate_connections(cls, relationship, old_to_new): pass
def recreate_port_connections(cls, snapshot, old_to_new): pass
def consume_warnings(cls): pass
@interface
class Feature:
def add_feature(cls, featured_obj, featured_objs): pass
@@ -445,6 +456,7 @@ class Geometry:
def get_representation_name(cls, representation): pass
def get_styles(cls, obj): pass
def get_total_representation_items(cls, obj): pass
def has_axis_representation(cls, element): pass
def has_data_users(cls, data): pass
def has_material_style_override(cls, obj): pass
def import_representation_parameters(cls, data): pass
@@ -668,6 +680,9 @@ class Model:
def export_profile(cls, obj, position=None): pass
def generate_occurrence_name(cls, element_type, ifc_class): pass
def get_extrusion(cls, representation): pass
def get_connected_slab_objs(cls, wall): pass
def get_connected_wall_objs(cls, slab): pass
def has_underside_connection(cls, element): pass
def get_manual_booleans(cls, element): pass
def get_material_layer_parameters(cls, element): pass
def get_slab_clipping_bmesh(cls, obj): pass
@@ -683,6 +698,7 @@ class Model:
def regenerate_profile(cls, obj): pass
def regenerate_slab(cls, obj): pass
def reload_body_representation(cls, obj_or_objects): pass
def remove_wall_to_underside_booleans(cls, wall): pass
def replace_object_ifc_representation(cls, ifc_file, ifc_context, obj, new_representation): pass
@@ -776,6 +792,12 @@ class Profile:
def get_profile(cls, element): pass
@interface
class Parametric:
def get_geom_generation(cls) -> int: pass
def refresh_post_commit(cls, operator) -> None: pass
@interface
class Pset:
def add_proposed_property(cls, name, value, props): pass
@@ -859,13 +881,13 @@ class Root:
def assign_body_styles(cls, element, obj): pass
def copy_representation(cls, source, dest): pass
def does_type_have_representations(cls, element): pass
def get_decomposition_relationships(cls, objs): pass
def get_default_container(cls): pass
def get_element_representation(cls, element, context): pass
def get_element_type(cls, element): pass
def get_object_name(cls, obj): pass
def get_object_representation(cls, obj): pass
def get_representation_context(cls, representation): pass
def has_material_styles(cls, element): pass
def is_containable(cls, element): pass
def is_drawing_annotation(cls, element): pass
def is_element_a(cls, element, ifc_class): pass
@@ -873,7 +895,6 @@ class Root:
def is_in_nest_mode(cls, element): pass
def is_spatial_element(cls, element): pass
def link_object_data(cls, source_obj, destination_obj): pass
def recreate_decompositions(cls, relationships, old_to_new): pass
def run_geometry_add_representation(cls, obj=None, context=None, ifc_representation_class=None, profile_set_usage=None): pass
def set_object_name(cls, obj, element): pass
@@ -1017,6 +1038,8 @@ class Spatial:
def get_container(cls, element): pass
def get_decomposed_elements(cls, container, recursive): pass
def get_decomposition(cls, element): pass
def get_host_element(cls, filling): pass
def get_host_wall(cls, filling): pass
def get_object_matrix(cls, obj): pass
def get_relative_object_matrix(cls, target_obj, relative_to_obj): pass
def get_root_element(cls, element): pass
@@ -1137,6 +1160,8 @@ class Style:
@interface
class Surveyor:
def get_absolute_matrix(cls, obj): pass
def get_z_rotation(cls, obj): pass
def set_z_rotation(cls, obj, z): pass
@interface
@@ -1203,6 +1228,42 @@ class Voider:
def void(cls, opening_obj, building_obj): pass
@interface
class Array:
def bake_children_transform(cls, parent_element, item): pass
def constrain_children_to_parent(cls, parent_element): pass
def get_all_children_objects(cls, parent_element): pass
def get_all_objects(cls, parent_element): pass
def get_child_layer_index(cls, child_element): pass
def get_children_objects(cls, modifier_data): pass
def get_modifiers_data(cls, parent_element): pass
def get_parent_element(cls, element): pass
def get_parent_object(cls, element): pass
def remove_constraints(cls, parent_element): pass
def set_children_lock_state(cls, parent_element, item, lock_state): pass
@interface
class Slab:
def read_geometry(cls, obj): pass
@interface
class Wall:
def collinear_boundary_world(cls, seg_a, seg_b): pass
def compute_wall_fillet_geometry(cls, wall_a_obj, wall_b_obj, radius, arc_resolution): pass
def get_axis_local_extent(cls, wall): pass
def get_length_and_height(cls, wall): pass
def get_world_reference_line(cls, obj): pass
def get_x_angle(cls, wall): pass
def has_layer2_usage(cls, wall): pass
def is_straight_axis(cls, wall): pass
def path_connection_location_world(cls, seg_self, self_conn_type, seg_other, other_conn_type, parallel_threshold): pass
def read_geometry(cls, obj): pass
def validate_for_parametric_edit(cls, obj): pass
def walk_connected_walls(cls, start_element, node_cap): pass
@interface
class Web:
pass
+5
View File
@@ -20,6 +20,7 @@
# ruff: noqa: F401
from bonsai.tool.aggregate import Aggregate
from bonsai.tool.array import Array
from bonsai.tool.attribute import Attribute
from bonsai.tool.bcf import Bcf
from bonsai.tool.blender import Blender
@@ -37,6 +38,7 @@ from bonsai.tool.debug import Debug
from bonsai.tool.demo import Demo
from bonsai.tool.document import Document
from bonsai.tool.drawing import Drawing
from bonsai.tool.duplicate import Duplicate
from bonsai.tool.feature import Feature
from bonsai.tool.geometry import Geometry
from bonsai.tool.georeference import Georeference
@@ -51,6 +53,7 @@ from bonsai.tool.misc import Misc
from bonsai.tool.model import Model
from bonsai.tool.nest import Nest
from bonsai.tool.owner import Owner
from bonsai.tool.parametric import Parametric
from bonsai.tool.patch import Patch
from bonsai.tool.polyline import Polyline
from bonsai.tool.profile import Profile
@@ -63,6 +66,7 @@ from bonsai.tool.resource import Resource
from bonsai.tool.root import Root
from bonsai.tool.search import Search
from bonsai.tool.sequence import Sequence
from bonsai.tool.slab import Slab
from bonsai.tool.snap import Snap
from bonsai.tool.spatial import Spatial
from bonsai.tool.structural import Structural
@@ -72,4 +76,5 @@ from bonsai.tool.system import System
from bonsai.tool.tester import Tester
from bonsai.tool.type import Type
from bonsai.tool.unit import Unit
from bonsai.tool.wall import Wall
from bonsai.tool.web import Web
+21
View File
@@ -205,6 +205,27 @@ class Aggregate(bonsai.core.tool.Aggregate):
props.in_aggregate_mode = True
return {"FINISHED"}
@classmethod
def save_previous_selection(cls) -> None:
props = cls.get_aggregate_props()
props.previously_selected_objects.clear()
for obj in bpy.context.selected_objects:
entry = props.previously_selected_objects.add()
entry.obj = obj
@classmethod
def restore_previous_selection(cls) -> None:
props = cls.get_aggregate_props()
for obj in bpy.context.selected_objects:
obj.select_set(False)
for entry in props.previously_selected_objects:
if entry.obj:
try:
entry.obj.select_set(True)
except Exception:
pass
props.previously_selected_objects.clear()
@classmethod
def disable_aggregate_mode(cls):
context = bpy.context
+207
View File
@@ -0,0 +1,207 @@
# 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.
"""Bonsai parametric array service.
Top-level array-domain helpers. The ``BBIM_Array`` pset on a parent ``IfcElement``
holds the list of layers; each layer holds the GUIDs of its child replicas. These
helpers navigate that graph and manage the Blender-side CHILD_OF constraint that
pins children to the parent's matrix_world."""
from __future__ import annotations
import json
from collections.abc import Generator
from typing import TYPE_CHECKING, Any
import bpy
import ifcopenshell
import ifcopenshell.util.element
import bonsai.core.tool
import bonsai.tool as tool
if TYPE_CHECKING:
from ifcopenshell import entity_instance
class Array(bonsai.core.tool.Array):
@classmethod
def bake_children_transform(cls, parent_element: entity_instance, item: int) -> None:
modifier_data = list(cls.get_modifiers_data(parent_element))[item]
children = cls.get_children_objects(modifier_data)
for child in children:
constraint = next((c for c in child.constraints if c.type == "CHILD_OF"), None)
if constraint:
with bpy.context.temp_override(object=child):
bpy.ops.constraint.apply(constraint=constraint.name, owner="OBJECT")
@classmethod
def constrain_children_to_parent(cls, parent_element: ifcopenshell.entity_instance) -> None:
if not (parent_obj := tool.Ifc.get_object(parent_element)):
return # Filtered out, arrayed void, etc
assert isinstance(parent_obj, bpy.types.Object)
children = cls.get_all_children_objects(parent_element)
for child in children:
constraint = next((c for c in child.constraints if c.type == "CHILD_OF"), None)
if constraint:
child.constraints.remove(constraint)
constraint = child.constraints.new("CHILD_OF")
constraint.name = "BBIM_Array_CHILD_OF"
assert isinstance(constraint, bpy.types.ChildOfConstraint)
constraint.target = parent_obj
@classmethod
def set_children_lock_state(
cls, parent_element: ifcopenshell.entity_instance, item: int, lock_state: bool = True
) -> None:
modifier_data = list(cls.get_modifiers_data(parent_element))[item]
children = cls.get_children_objects(modifier_data)
for child_obj in children:
tool.Blender.lock_transform(child_obj, lock_state)
@classmethod
def remove_constraints(cls, parent_element: ifcopenshell.entity_instance) -> None:
children = cls.get_all_children_objects(parent_element)
for child in children:
constraint = next((c for c in child.constraints if c.type == "CHILD_OF"), None)
if constraint:
child.constraints.remove(constraint)
@classmethod
def get_all_objects(cls, parent_element: ifcopenshell.entity_instance) -> list[bpy.types.Object]:
parent_obj = tool.Ifc.get_object(parent_element)
assert isinstance(parent_obj, bpy.types.Object)
children_objects = list(cls.get_all_children_objects(parent_element))
array_objects = [parent_obj] + children_objects # We ensure the parent is at index 0
return array_objects
@classmethod
def get_all_children_objects(
cls, parent_element: ifcopenshell.entity_instance
) -> Generator[bpy.types.Object, None, None]:
for array_modifier in cls.get_modifiers_data(parent_element):
yield from cls.get_children_objects(array_modifier)
@classmethod
def get_parent_element(cls, element: entity_instance) -> entity_instance | None:
"""Inverse of ``get_all_children_objects``: resolve an array element
back to its parent entity. Returns ``None`` when the element isn't
part of a Bonsai parametric array, or the stored Parent GUID does
not resolve in the current file (this is a data-integrity warning
and is logged to the console)."""
pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
if not pset:
return None
parent_guid = pset["Parent"]
try:
return tool.Ifc.get().by_guid(parent_guid)
except RuntimeError:
print(
f"BBIM_Array.Parent GUID {parent_guid!r} on {element} does not resolve "
f"in the current file — array integrity may be broken."
)
return None
@classmethod
def get_parent_object(cls, element: entity_instance) -> bpy.types.Object | None:
parent_element = cls.get_parent_element(element)
if parent_element is None:
return None
return tool.Ifc.get_object(parent_element)
@classmethod
def get_modifiers_data(cls, parent_element: ifcopenshell.entity_instance) -> Generator[dict[str, Any], None, None]:
array_pset = ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array")
yield from json.loads(array_pset["Data"])
@classmethod
def get_children_objects(cls, modifier_data: dict[str, Any]) -> Generator[bpy.types.Object, None, None]:
child_guid: str
for child_guid in modifier_data["children"]:
child_obj = tool.Blender.get_object_from_guid(child_guid)
if child_obj:
yield child_obj
@classmethod
def get_array_root_guid(cls, element: entity_instance) -> str:
"""Walk ``BBIM_Array.Parent`` upwards and return the topmost ancestor's
GlobalId. For an element with no ``BBIM_Array`` pset (independent
window, never arrayed, or former-child after the apply path), returns
the element's own GlobalId — its "family" is just itself."""
current = element
seen: set[str] = set()
while True:
pset = ifcopenshell.util.element.get_pset(current, "BBIM_Array")
parent_guid = pset.get("Parent") if pset else None
if not parent_guid or parent_guid == current.GlobalId or parent_guid in seen:
return current.GlobalId
seen.add(parent_guid)
try:
current = tool.Ifc.get().by_guid(parent_guid)
except RuntimeError:
return current.GlobalId
@classmethod
def get_parametric_propagation_targets(cls, element: entity_instance) -> list[entity_instance]:
"""Type-occurrences that should receive parametric updates when
``element`` is edited.
Returns occurrences in ``element``'s Bonsai array family. When
``element`` is not part of any array, returns the type-occurrence
peers that are likewise free of ``BBIM_Array`` (preserving the
bulk-edit-by-type UX for standalone parametric elements). An
occurrence whose ``BBIM_Array`` root differs from ``element``'s root
is excluded that is the "independent former child" case the array
apply path produces."""
occurrences = tool.Ifc.get_all_element_occurrences(element)
element_pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
if not element_pset:
return [o for o in occurrences if not ifcopenshell.util.element.get_pset(o, "BBIM_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 get_child_layer_index(cls, child_element: entity_instance) -> int | None:
"""Index of the layer that produced ``child_element``, or ``None``
if the child is unparented, missing from the parent's data, or the
parent's pset is unreadable. Total: never raises."""
pset = ifcopenshell.util.element.get_pset(child_element, "BBIM_Array")
if not pset:
return None
parent_guid = pset.get("Parent")
if not parent_guid or parent_guid == child_element.GlobalId:
return None
try:
parent_element = tool.Ifc.get().by_guid(parent_guid)
except RuntimeError:
return None
data_text = ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array", "Data")
if not data_text:
return None
try:
layers = json.loads(data_text)
except (ValueError, TypeError):
return None
child_guid = child_element.GlobalId
for i, layer in enumerate(layers):
if child_guid in layer.get("children", []):
return i
return None
+335 -163
View File
@@ -15,12 +15,13 @@
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was modified with the assistance of an AI coding tool.
from __future__ import annotations
import contextlib
import importlib
import json
import os
import platform
import subprocess
@@ -28,7 +29,7 @@ import sys
import tempfile
import traceback
import types
from collections.abc import Callable, Generator, Iterable, Sequence, Sized
from collections.abc import Callable, Generator, Iterable, Mapping, Sequence, Sized
from datetime import datetime
from functools import cache, lru_cache
from pathlib import Path
@@ -45,7 +46,6 @@ from typing import (
import bmesh
import bpy
import ifcopenshell.api
import ifcopenshell.util.element
import numpy as np
import numpy.typing as npt
@@ -55,12 +55,12 @@ from mathutils import Matrix, Vector
import bonsai.bim
import bonsai.core.tool
import bonsai.tool as tool
from bonsai.bim.ifc import IFC_CONNECTED_TYPE
if TYPE_CHECKING:
import bpy.stub_internal.rna_enums as rna_enums
from sun_position.properties import SunPosProperties
from bonsai.bim.ifc import IFC_CONNECTED_TYPE
from bonsai.bim.module.attribute.prop import BIMAttributeProperties
from bonsai.bim.module.constraint.prop import (
BIMConstraintProperties,
@@ -97,6 +97,19 @@ VIEWPORT_ATTRIBUTES = [
OBJECT_DATA_TYPE = Union[bpy.types.Mesh, bpy.types.Curve, bpy.types.Camera]
_RAILING_MODIFIER_IFC_CLASSES = ("IfcRailing", "IfcRailingType")
_STAIR_MODIFIER_IFC_CLASSES = (
"IfcStairFlight",
"IfcStairFlightType",
"IfcMember",
"IfcMemberType",
"IfcStair",
"IfcStairType",
)
_WINDOW_MODIFIER_IFC_CLASSES = ("IfcWindow", "IfcWindowType", "IfcWindowStyle")
_DOOR_MODIFIER_IFC_CLASSES = ("IfcDoor", "IfcDoorType", "IfcDoorStyle")
_ROOF_MODIFIER_IFC_CLASSES = ("IfcRoof", "IfcRoofType")
class Blender(bonsai.core.tool.Blender):
OBJECT_TYPES_THAT_SUPPORT_EDIT_MODE = ("MESH", "CURVE", "SURFACE", "META", "FONT", "LATTICE", "ARMATURE")
@@ -216,15 +229,22 @@ class Blender(bonsai.core.tool.Blender):
@classmethod
def get_active_object(cls, is_selected: bool = False) -> Union[bpy.types.Object, None]:
"""Gets the active object
"""Return the active object, or ``None`` when the current context
exposes neither ``active_object`` nor a ``view_layer`` (stripped
operator contexts).
:param is_selected: If true, the active object also needs to be selected.
"""
if obj := (getattr(bpy.context, "active_object", None) or bpy.context.view_layer.objects.active):
if not is_selected:
return obj
if obj.select_get():
return obj
obj = getattr(bpy.context, "active_object", None)
if obj is None:
view_layer = getattr(bpy.context, "view_layer", None)
if view_layer is not None:
obj = view_layer.objects.active
if obj is None:
return None
if is_selected and not obj.select_get():
return None
return obj
@classmethod
def get_selected_objects(cls, include_active: bool = True) -> set[bpy.types.Object]:
@@ -415,6 +435,189 @@ class Blender(bonsai.core.tool.Blender):
with bpy.context.temp_override(**cls.get_viewport_context()):
bpy.ops.wm.tool_set_by_id(name=tool_name)
@classmethod
def are_viewport_gizmos_enabled(cls) -> bool:
"""Central gate every Bonsai gizmo poll / decorator draw checks before
rendering. Centralises the read of
``gizmos.draw_gizmos_in_3d_viewport`` from addon preferences."""
return cls.get_addon_preferences().gizmos.draw_gizmos_in_3d_viewport
class DecoratorColors(NamedTuple):
selected: tuple
unselected: tuple
special: tuple
error: tuple
background: tuple
@classmethod
def get_decorator_colors(cls) -> Blender.DecoratorColors:
"""The five ``decorator_color_*`` fields read together so each viewport
decorator's draw callback resolves them in one call instead of five."""
prefs = cls.get_addon_preferences()
return cls.DecoratorColors(
selected=prefs.decorator_color_selected,
unselected=prefs.decorator_color_unselected,
special=prefs.decorator_color_special,
error=prefs.decorator_color_error,
background=prefs.decorator_color_background,
)
class ViewportDecorator:
"""Shared ``SpaceView3D.draw_handler_add`` lifecycle for feature decorators.
Single-handler subclasses set ``draw_method`` (default ``"draw"``); the
handler binds at ``POST_VIEW``. Multi-handler subclasses set
``draw_methods`` to a tuple of ``(method_name, phase)`` pairs; when it
is non-``None`` it supersedes ``draw_method``.
Decorators whose ``install`` must accept extra arguments (e.g. a callback
or a precomputed bmesh) override ``install`` themselves."""
draw_method: str = "draw"
draw_methods: tuple[tuple[str, str], ...] | None = None
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
cls.handlers = []
cls.is_installed = False
# Fail loudly at class-definition time if draw_method / draw_methods
# names an attribute the class doesn't expose. Without this, a typo
# only surfaces on the first redraw — as a silent missing-attribute
# handler — which may be far from the offending declaration.
method_names = (
tuple(name for name, _phase in cls.draw_methods) if cls.draw_methods is not None else (cls.draw_method,)
)
for name in method_names:
if getattr(cls, name, None) is None:
raise TypeError(f"{cls.__name__}: draw method {name!r} is declared but not defined on the class")
@classmethod
def install(cls, context: bpy.types.Context) -> None:
if cls.is_installed:
cls.uninstall()
handler = cls()
bindings = cls.draw_methods if cls.draw_methods is not None else ((cls.draw_method, "POST_VIEW"),)
# Rollback partial registrations on any draw_handler_add failure, so
# cls.handlers never ends up holding a half-installed set.
added: list = []
try:
for method_name, phase in bindings:
added.append(
bpy.types.SpaceView3D.draw_handler_add(
getattr(handler, method_name), (context,), "WINDOW", phase
)
)
except Exception:
for h in added:
try:
bpy.types.SpaceView3D.draw_handler_remove(h, "WINDOW")
except ValueError:
pass
raise
cls.handlers = added
cls.is_installed = True
@classmethod
def uninstall(cls) -> None:
for h in cls.handlers:
try:
bpy.types.SpaceView3D.draw_handler_remove(h, "WINDOW")
except ValueError:
pass
cls.handlers.clear()
cls.is_installed = False
@staticmethod
def _lookup_active_instance(gizmo_cls: type, context: bpy.types.Context) -> Optional[Any]:
"""Return the live ``GizmoGroup`` instance registered under
``context.region``, or ``None`` if there isn't one. The per-region
weakref dict on the gizmo class is populated by ``setup()``; multi-
viewport setups put one entry per region in it so each region's
decorator sees only its own region's hover state."""
instances = getattr(gizmo_cls, "_active_instances", None)
if not instances:
return None
region = getattr(context, "region", None)
if region is None:
return None
ref = instances.get(region.as_pointer())
if ref is None:
return None
return ref()
def _cursor_icon_hovered(self, gizmo_cls: type, attr_name: str, context: bpy.types.Context) -> bool:
"""True iff the gizmo group instance in the current region exposes a gizmo
under ``attr_name`` that reports as highlighted. Any access exception is
swallowed so a transient bpy-state hiccup never breaks the draw loop."""
inst = self._lookup_active_instance(gizmo_cls, context)
if inst is None:
return False
try:
return bool(getattr(inst, attr_name).is_highlight)
except (AttributeError, ReferenceError):
return False
@classmethod
def sync_all(
cls,
context: bpy.types.Context,
enabled: Mapping[type[Blender.ViewportDecorator], bool],
) -> None:
"""Drive each listed decorator to its desired install state in one call.
Each entry whose value is ``True`` ends up installed; each entry whose
value is ``False`` ends up uninstalled. Pass ``True`` for always-on
overlays so they survive subsequent file loads."""
for decorator_cls, should_install in enabled.items():
if should_install:
decorator_cls.install(context)
else:
decorator_cls.uninstall()
@classmethod
def is_view_top_down(cls, context: bpy.types.Context, threshold: float = 0.9659) -> bool:
"""True when the viewport camera is looking ~straight down (or up) the world Z axis.
Default threshold of 0.9659 = cos(15°) a 15° tilt cone around ±world Z.
Above the threshold the world-Z axis projects to a small fraction of its
true length on screen, so callers that lay icons or markers out along
world Z should switch to a screen-space offset and any gizmo whose intent
is specifically "vertical" loses its visual cue. The cone is kept narrow
so vertical-intent gizmos stay visible across the typical orbit range of
3D viewport work and drop out only near genuine plan view."""
rv3d = context.region_data
if rv3d is None:
return False
view_forward = Vector(rv3d.view_matrix.inverted().col[2][:3]).normalized()
return abs(view_forward.z) > threshold
@classmethod
def top_down_factor(cls, context: bpy.types.Context, threshold: float = 0.9659) -> float:
"""Continuous 01 ramp matching ``is_view_top_down``'s cone: 0 outside the
cone, ramping linearly to 1 at strict alignment with world Z. Callers that
want a proportional effect (an icon-stack lift growing as the view
approaches plan) use this in place of the boolean to avoid a one-frame
visual jump as the camera crosses the threshold."""
rv3d = context.region_data
if rv3d is None:
return 0.0
view_forward = Vector(rv3d.view_matrix.inverted().col[2][:3]).normalized()
alignment = abs(view_forward.z)
if alignment <= threshold:
return 0.0
return (alignment - threshold) / (1.0 - threshold)
@classmethod
def get_screen_up_world(cls, context: bpy.types.Context) -> Vector:
"""World-space direction corresponding to the camera's up axis (screen-vertical).
Returns ``+Y`` when region data is unavailable so callers can compute an
offset without a guard branch."""
rv3d = context.region_data
if rv3d is None:
return Vector((0.0, 1.0, 0.0))
return Vector(rv3d.view_matrix.inverted().col[1][:3]).normalized()
@classmethod
def get_shader_editor_context(cls) -> Union[dict[str, Any], None]:
for screen in bpy.data.screens:
@@ -484,9 +687,13 @@ class Blender(bonsai.core.tool.Blender):
@classmethod
def update_all_viewports(cls, context: bpy.types.Context | None = None) -> None:
"""Tag every visible 3D viewport for redraw. Silent no-op when no
screen attached (background mode, plug-out, mid-load_post)."""
context = context or bpy.context
assert context.screen
for area in context.screen.areas:
screen = getattr(context, "screen", None)
if screen is None:
return
for area in screen.areas:
if area.type == "VIEW_3D":
area.tag_redraw()
@@ -635,10 +842,11 @@ class Blender(bonsai.core.tool.Blender):
op_text = "" if ui_context == "TOOL_HEADER" else text
modifier_icon, modifier_str = cls.KEY_MODIFIERS.get(modifier, ("NONE", ""))
row = layout if ui_context == "TOOL_HEADER" else layout.row(align=True)
module = sys.modules[module_name]
icon_previews: Union[bpy.utils.previews.ImagePreviewCollection, None]
icon_previews = getattr(module, "custom_icon_previews", None)
row = layout if ui_context == "TOOL_HEADER" else layout.row(align=True)
if icon_previews:
custom_icon = icon_previews.get(text.upper().replace(" ", "_"), icon_previews["IFC"]).icon_id
op = row.operator(operator_to_use, text=op_text, icon_value=custom_icon)
@@ -646,6 +854,7 @@ class Blender(bonsai.core.tool.Blender):
op = row.operator(operator_to_use, text=op_text)
if ui_context != "TOOL_HEADER":
row.label(text="", icon=modifier_icon)
row.separator(factor=1)
row.label(text="", icon=f"EVENT_{key}")
if operator_to_use == hotkey_operator:
@@ -678,19 +887,57 @@ class Blender(bonsai.core.tool.Blender):
# ( 1.0, 1.0, -1.0), # 7
# ]
bound_box = obj.bound_box
min_pt = Vector(bound_box[0])
max_pt = Vector(bound_box[6])
bbox_dict = {
"min_x": bound_box[0][0],
"max_x": bound_box[6][0],
"min_y": bound_box[0][1],
"max_y": bound_box[6][1],
"min_z": bound_box[0][2],
"max_z": bound_box[6][2],
"min_point": Vector(bound_box[0]),
"max_point": Vector(bound_box[6]),
"center": (Vector(bound_box[6]) + Vector(bound_box[0])) / 2,
"min_x": min_pt.x,
"max_x": max_pt.x,
"min_y": min_pt.y,
"max_y": max_pt.y,
"min_z": min_pt.z,
"max_z": max_pt.z,
"min_point": min_pt,
"max_point": max_pt,
"center": (max_pt + min_pt) / 2,
# Intrinsic per-axis size in object-local space. Distinct from
# ``obj.dimensions``, which folds object-level scale into its
# output; this is the raw mesh bbox extent.
"dimensions": (max_pt.x - min_pt.x, max_pt.y - min_pt.y, max_pt.z - min_pt.z),
}
return bbox_dict
@classmethod
def get_object_world_bounding_box(cls, obj: bpy.types.Object) -> dict[str, Union[float, Vector]]:
"""Same shape as ``get_object_bounding_box`` but with ``matrix_world``
applied extents are computed across the 8 transformed corners, so
a rotated or scaled object reports its actual world-axis AABB rather
than the misleading transform of the local-space corners.
``bound_box[0]`` / ``bound_box[6]`` are the local min/max corners but
do NOT correspond to the world AABB extremes once the object is
rotated, so min/max must be taken per-axis across all 8 corners."""
corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
xs = [c.x for c in corners]
ys = [c.y for c in corners]
zs = [c.z for c in corners]
min_point = Vector((min(xs), min(ys), min(zs)))
max_point = Vector((max(xs), max(ys), max(zs)))
return {
"min_x": min_point.x,
"max_x": max_point.x,
"min_y": min_point.y,
"max_y": max_point.y,
"min_z": min_point.z,
"max_z": max_point.z,
"min_point": min_point,
"max_point": max_point,
"center": (min_point + max_point) / 2,
# World-axis-aligned per-axis size. For rotated objects this is
# the AABB extent, not the intrinsic mesh size (use the local
# variant for that).
"dimensions": (max_point.x - min_point.x, max_point.y - min_point.y, max_point.z - min_point.z),
}
@classmethod
def select_and_activate_single_object(cls, context: bpy.types.Context, active_object: bpy.types.Object) -> None:
for obj in context.selected_objects:
@@ -1137,20 +1384,18 @@ class Blender(bonsai.core.tool.Blender):
:return: True if an action was taken, False otherwise
"""
if cls.is_roof(element):
if cls.is_editing_roof_parameters(obj):
bpy.ops.bim.finish_editing_roof()
# roof and railing both finalize then drop into path-edit mode — handle
# them before the generic finish dispatch so the path transition runs.
if tool.Parametric.is_roof(element):
if tool.Parametric.ROOF.is_editing(obj):
tool.Parametric.run_bim_op(tool.Parametric.ROOF.finish_op)
bpy.ops.bim.enable_editing_roof_path()
elif cls.is_railing(element):
if cls.is_editing_railing_parameters(obj):
bpy.ops.bim.finish_editing_railing()
elif tool.Parametric.is_railing(element):
if tool.Parametric.RAILING.is_editing(obj):
tool.Parametric.run_bim_op(tool.Parametric.RAILING.finish_op)
bpy.ops.bim.enable_editing_railing_path()
elif cls.is_editing_stair_parameters(obj):
bpy.ops.bim.finish_editing_stair()
elif cls.is_editing_door_parameters(obj):
bpy.ops.bim.finish_editing_door()
elif cls.is_editing_window_parameters(obj):
bpy.ops.bim.finish_editing_window()
elif feature := tool.Parametric.is_object_editing(obj):
tool.Parametric.run_bim_op(feature.finish_op)
else:
return False
return True
@@ -1161,68 +1406,80 @@ class Blender(bonsai.core.tool.Blender):
:return: True if an action was taken, False otherwise
"""
# Path-edit modes are distinct from parametric draft modes; handle them first.
if cls.is_editing_railing_path(obj):
bpy.ops.bim.cancel_editing_railing_path()
elif cls.is_editing_roof_path(obj):
bpy.ops.bim.cancel_editing_roof_path()
elif cls.is_editing_railing_parameters(obj):
bpy.ops.bim.cancel_editing_railing()
elif cls.is_editing_door_parameters(obj):
bpy.ops.bim.cancel_editing_door()
elif cls.is_editing_window_parameters(obj):
bpy.ops.bim.cancel_editing_window()
elif cls.is_editing_roof_parameters(obj):
bpy.ops.bim.cancel_editing_roof()
elif cls.is_editing_stair_parameters(obj):
bpy.ops.bim.cancel_editing_stair()
elif feature := tool.Parametric.is_object_editing(obj):
tool.Parametric.run_bim_op(feature.cancel_op)
else:
return False
return True
@classmethod
def is_eligible_for_railing_modifier(cls, obj: bpy.types.Object) -> bool:
return tool.Blender.is_object_an_ifc_class(obj, ("IfcRailing", "IfcRailingType"))
return tool.Blender.is_object_an_ifc_class(obj, _RAILING_MODIFIER_IFC_CLASSES)
@classmethod
def is_eligible_for_stair_modifier(cls, obj: bpy.types.Object) -> bool:
return tool.Blender.is_object_an_ifc_class(
obj, ("IfcStairFlight", "IfcStairFlightType", "IfcMember", "IfcMemberType", "IfcStair", "IfcStairType")
)
return tool.Blender.is_object_an_ifc_class(obj, _STAIR_MODIFIER_IFC_CLASSES)
@classmethod
def is_eligible_for_window_modifier(cls, obj: bpy.types.Object) -> bool:
return tool.Blender.is_object_an_ifc_class(obj, ("IfcWindow", "IfcWindowType", "IfcWindowStyle"))
return tool.Blender.is_object_an_ifc_class(obj, _WINDOW_MODIFIER_IFC_CLASSES)
@classmethod
def is_eligible_for_door_modifier(cls, obj: bpy.types.Object) -> bool:
return tool.Blender.is_object_an_ifc_class(obj, ("IfcDoor", "IfcDoorType", "IfcDoorStyle"))
return tool.Blender.is_object_an_ifc_class(obj, _DOOR_MODIFIER_IFC_CLASSES)
@classmethod
def is_eligible_for_roof_modifier(cls, obj: bpy.types.Object) -> bool:
return tool.Blender.is_object_an_ifc_class(obj, ("IfcRoof", "IfcRoofType"))
return tool.Blender.is_object_an_ifc_class(obj, _ROOF_MODIFIER_IFC_CLASSES)
@classmethod
def is_railing(cls, element: entity_instance) -> bool:
return tool.Pset.get_element_pset(element, "BBIM_Railing")
def is_array_child(cls, element: entity_instance) -> bool:
"""True if element is a CHILD of a Bonsai parametric array.
Children are managed replicas regenerated from the parent's pset —
their parametric attributes (door dimensions, wall lengths, ) are
overwritten on the next ``regenerate_array``. Parametric gizmo
groups skip children via this predicate in ``poll``.
This sits on a different axis from ``tool.Parametric.is_array``:
cardinality (parent vs child) is orthogonal to feature kind, and
an arrayed wall fires both ``is_wall`` and ``is_array`` on the
same element."""
if element is None:
return False
pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
if not pset:
return False
parent_guid = pset.get("Parent")
return parent_guid is not None and parent_guid != element.GlobalId
@classmethod
def is_roof(cls, element: entity_instance) -> bool:
return tool.Pset.get_element_pset(element, "BBIM_Roof")
def is_slab(cls, element: entity_instance) -> bool:
"""A slab is host-eligible for the parametric add-opening gizmo if
it is an IfcSlab with LAYER3 usage.
Slabs carry no proprietary BBIM_Slab pset their parametric state
lives in standard IFC (extrusion depth, IfcMaterialLayerSetUsage
with LayerSetDirection AXIS3). Any LAYER3 slab qualifies."""
if element is None or not element.is_a("IfcSlab"):
return False
return tool.Model.get_usage_type(element) == "LAYER3"
@classmethod
def is_window(cls, element: entity_instance) -> bool:
return tool.Pset.get_element_pset(element, "BBIM_Window")
def is_pipe_segment(cls, element: entity_instance) -> bool:
return element is not None and element.is_a("IfcPipeSegment")
@classmethod
def is_door(cls, element: entity_instance) -> bool:
return tool.Pset.get_element_pset(element, "BBIM_Door")
def is_duct_segment(cls, element: entity_instance) -> bool:
return element is not None and element.is_a("IfcDuctSegment")
@classmethod
def is_stair(cls, element: entity_instance) -> bool:
return tool.Pset.get_element_pset(element, "BBIM_Stair")
@classmethod
def is_editing_railing_path(cls, obj: bpy.types.Object):
def is_editing_railing_path(cls, obj: bpy.types.Object) -> bool:
props = tool.Model.get_railing_props(obj)
return props.is_editing_path
@@ -1231,107 +1488,10 @@ class Blender(bonsai.core.tool.Blender):
props = tool.Model.get_roof_props(obj)
return props.is_editing_path
@classmethod
def is_editing_railing_parameters(cls, obj: bpy.types.Object) -> bool:
props = tool.Model.get_railing_props(obj)
return props.is_editing
@classmethod
def is_editing_roof_parameters(cls, obj: bpy.types.Object) -> bool:
props = tool.Model.get_roof_props(obj)
return props.is_editing
@classmethod
def is_editing_window_parameters(cls, obj: bpy.types.Object) -> bool:
props = tool.Model.get_window_props(obj)
return props.is_editing
@classmethod
def is_editing_door_parameters(cls, obj: bpy.types.Object) -> bool:
props = tool.Model.get_door_props(obj)
return props.is_editing
@classmethod
def is_editing_stair_parameters(cls, obj: bpy.types.Object) -> bool:
props = tool.Model.get_stair_props(obj)
return props.is_editing
@classmethod
def is_modifier_with_non_editable_path(cls, element: entity_instance) -> bool:
return cls.is_stair(element) or cls.is_door(element) or cls.is_window(element)
class Array:
@classmethod
def bake_children_transform(cls, parent_element: entity_instance, item: int) -> None:
modifier_data = list(cls.get_modifiers_data(parent_element))[item]
children = cls.get_children_objects(modifier_data)
for child in children:
constraint = next((c for c in child.constraints if c.type == "CHILD_OF"), None)
if constraint:
with bpy.context.temp_override(object=child):
bpy.ops.constraint.apply(constraint=constraint.name, owner="OBJECT")
@classmethod
def constrain_children_to_parent(cls, parent_element: ifcopenshell.entity_instance) -> None:
if not (parent_obj := tool.Ifc.get_object(parent_element)):
return # Filtered out, arrayed void, etc
assert isinstance(parent_obj, bpy.types.Object)
children = cls.get_all_children_objects(parent_element)
for child in children:
constraint = next((c for c in child.constraints if c.type == "CHILD_OF"), None)
if constraint:
child.constraints.remove(constraint)
constraint = child.constraints.new("CHILD_OF")
constraint.name = "BBIM_Array_CHILD_OF"
assert isinstance(constraint, bpy.types.ChildOfConstraint)
constraint.target = parent_obj
@classmethod
def set_children_lock_state(
cls, parent_element: ifcopenshell.entity_instance, item: int, lock_state: bool = True
) -> None:
modifier_data = list(cls.get_modifiers_data(parent_element))[item]
children = cls.get_children_objects(modifier_data)
for child_obj in children:
Blender.lock_transform(child_obj, lock_state)
@classmethod
def remove_constraints(cls, parent_element: ifcopenshell.entity_instance) -> None:
children = cls.get_all_children_objects(parent_element)
for child in children:
constraint = next((c for c in child.constraints if c.type == "CHILD_OF"), None)
if constraint:
child.constraints.remove(constraint)
@classmethod
def get_all_objects(cls, parent_element: ifcopenshell.entity_instance) -> list[bpy.types.Object]:
parent_obj = tool.Ifc.get_object(parent_element)
assert isinstance(parent_obj, bpy.types.Object)
children_objects = list(cls.get_all_children_objects(parent_element))
array_objects = [parent_obj] + children_objects # We ensure the parent is at index 0
return array_objects
@classmethod
def get_all_children_objects(
cls, parent_element: ifcopenshell.entity_instance
) -> Generator[bpy.types.Object, None, None]:
for array_modifier in cls.get_modifiers_data(parent_element):
yield from cls.get_children_objects(array_modifier)
@classmethod
def get_modifiers_data(
cls, parent_element: ifcopenshell.entity_instance
) -> Generator[dict[str, Any], None, None]:
array_pset = ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array")
yield from json.loads(array_pset["Data"])
@classmethod
def get_children_objects(cls, modifier_data: dict[str, Any]) -> Generator[bpy.types.Object, None, None]:
child_guid: str
for child_guid in modifier_data["children"]:
child_obj = tool.Blender.get_object_from_guid(child_guid)
if child_obj:
yield child_obj
feature = tool.Parametric.find_for_element(element)
return bool(feature and feature.has_non_editable_path)
class Attribute:
@classmethod
@@ -1835,6 +1995,18 @@ class Blender(bonsai.core.tool.Blender):
dct = {cls.bl_idname: cls.ifc_element_type for cls in (BimTool.__subclasses__())}
return types.MappingProxyType(dct)
@classmethod
@lru_cache
def get_property_header_tools(cls) -> frozenset[str]:
"""``BimTool`` plus its parametric subclasses — the workspace
tools whose 3D-view / N-panel header surfaces BIM Tool property
floats (extrusion_depth, length, x_angle). ``AnnotationTool``
and the non-``BimTool`` workspace tools (spatial / structural /
cad / covering) are excluded by construction."""
from bonsai.bim.module.model.workspace import BimTool
return frozenset(cls.bl_idname for cls in (BimTool.__subclasses__() + [BimTool]))
@classmethod
def get_object_constraint_props(cls, obj: bpy.types.Object) -> BIMObjectConstraintProperties:
return obj.BIMObjectConstraintProperties # pyright: ignore[reportAttributeAccessIssue]
+111
View File
@@ -32,6 +32,7 @@ from __future__ import annotations
import math
import sys
from collections.abc import Sequence
from typing import TYPE_CHECKING, Union
import bmesh
@@ -45,6 +46,13 @@ if TYPE_CHECKING:
VTX_PRECISION = 1.0e-5
# Tolerances below are in Blender units (SI metres).
# Looser than VTX_PRECISION because regen-time numeric drift exceeds CAD snap precision.
WELD_TOLERANCE = 1.0e-4
# How close a vertex must be to the cut plane to count as on it.
BISECT_TOLERANCE = 1.0e-4
# Strict weld for cleaning up exactly-coincident vertices.
WELD_EPSILON = 1.0e-6
class Cad:
@@ -996,3 +1004,106 @@ class Cad:
y = height_half + height_half * (prj[1] / w)
return Vector((float(x), float(y)))
return default
@classmethod
def sweep_disk_along_polyline(
cls,
bm: bmesh.types.BMesh,
points: Sequence[Vector],
radius: float,
arc_indices: Sequence[int] = (),
profile_segments: int = 8,
) -> None:
"""Append a tube of ``radius`` along the polyline ``points`` to ``bm``.
Viewport-quality approximation of an IFC ``IfcSweptDiskSolid``: each
consecutive pair of points becomes a capped cylinder. The cylinders
overlap at joints rather than being mitered the visual artifact is
negligible at typical handrail radii (~25mm) and acceptable for
live parametric-edit preview.
``arc_indices`` is accepted for API symmetry with the IFC builder
(which receives the same data structure), but is currently unused
arcs are visualised as polyline kinks. Tessellating each arc with a
Lagrange or circular interpolation would smooth the joints; deferred
until profile fidelity becomes a concern.
:param bm: target bmesh, mutated in place.
:param points: polyline vertices.
:param radius: tube radius (project units).
:param arc_indices: indices of arc midpoints (currently ignored).
:param profile_segments: sides on each cylinder cross-section.
"""
del arc_indices # accepted for forward compatibility; see docstring
if len(points) < 2:
return
for p0, p1 in zip(points, points[1:]):
cls._add_capped_cylinder(bm, Vector(p0), Vector(p1), radius, profile_segments)
@classmethod
def add_disk_extrusion(
cls,
bm: bmesh.types.BMesh,
position: Vector,
radius: float,
depth: float,
axis_rotation_z: float,
profile_segments: int = 12,
) -> None:
"""Append a flat cylinder (disk extrusion) to ``bm``.
A disk of ``radius`` extruded by ``depth`` along the +Y axis rotated
by ``axis_rotation_z`` radians around Z. ``position`` is the disk's
base, not its centre.
:param bm: target bmesh, mutated in place.
:param position: base of the extrusion in object-local coordinates.
:param radius: disk radius.
:param depth: extrusion depth along the (rotated) Y axis.
:param axis_rotation_z: rotation around Z applied to the +Y axis to
obtain the extrusion direction.
:param profile_segments: sides on the disk's edge.
"""
# The +Y axis rotated by axis_rotation_z around Z gives the extrusion
# direction: (-sin(θ), cos(θ), 0). The disk axis points along it.
axis = Vector((-math.sin(axis_rotation_z), math.cos(axis_rotation_z), 0.0))
end = position + axis * depth
cls._add_capped_cylinder(bm, position, end, radius, profile_segments)
@classmethod
def _add_capped_cylinder(
cls,
bm: bmesh.types.BMesh,
p0: Vector,
p1: Vector,
radius: float,
segments: int,
) -> None:
"""Append one capped cylinder of ``radius`` from ``p0`` to ``p1`` to ``bm``."""
direction = p1 - p0
length = direction.length
if length < 1e-9:
return
direction = direction / length
z_axis = Vector((0.0, 0.0, 1.0))
dot = direction.dot(z_axis)
if dot > 1.0 - 1e-6:
rotation = Matrix.Identity(4)
elif dot < -1.0 + 1e-6:
# Anti-parallel: rotate 180° around X so the cone flips bottom-to-top.
rotation = Matrix.Rotation(math.pi, 4, "X")
else:
rotation = z_axis.rotation_difference(direction).to_matrix().to_4x4()
matrix = Matrix.Translation((p0 + p1) * 0.5) @ rotation
bmesh.ops.create_cone(
bm,
cap_ends=True,
cap_tris=False,
segments=segments,
radius1=radius,
radius2=radius,
depth=length,
matrix=matrix,
)
+1
View File
@@ -135,6 +135,7 @@ class Collector(bonsai.core.tool.Collector):
if element.is_a("IfcFeatureElementSubtraction"):
obj.display_type = "WIRE"
obj.display.show_shadows = False
@classmethod
def _create_project_child_collection(cls, name: str) -> bpy.types.Collection:
+328
View File
@@ -0,0 +1,328 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021 Dion Moult <dion@thinkmoult.com>
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
# This file was generated with the assistance of an AI coding tool.
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Literal
import bpy
import ifcopenshell
import ifcopenshell.util.element
import ifcopenshell.util.placement
import ifcopenshell.util.representation
import bonsai.core.geometry
import bonsai.core.tool
import bonsai.tool as tool
@dataclass
class DecompositionRecord:
type: Literal["fill"]
element: ifcopenshell.entity_instance
@dataclass
class ConnectionRecord:
type: Literal["path"]
relating_element: ifcopenshell.entity_instance
related_element: ifcopenshell.entity_instance
relating_connection_type: str
related_connection_type: str
relating_priorities: list[int]
related_priorities: list[int]
@dataclass
class PortConnectionRecord:
relating_port_index: int
related_element: ifcopenshell.entity_instance
related_port_index: int
direction: str
@dataclass
class PortConnectionSnapshot:
"""Port-to-port connections and per-element port counts captured before duplication."""
by_element: dict[ifcopenshell.entity_instance, list[PortConnectionRecord]] = field(default_factory=dict)
port_counts: dict[ifcopenshell.entity_instance, int] = field(default_factory=dict)
class Duplicate(bonsai.core.tool.Duplicate):
_pending_warnings: list[str] = []
@classmethod
def _emit_warning(cls, message: str) -> None:
"""Buffer a warning for later retrieval by an operator. Falling through
to a print keeps the message in the Blender console for the headless /
no-operator code path."""
cls._pending_warnings.append(message)
print(f"Bonsai: WARNING — {message}")
@classmethod
def consume_warnings(cls) -> list[str]:
"""Return and clear the buffered warnings — operators call this after
``tool.Geometry.duplicate_ifc_objects`` to forward each to ``self.report``."""
warnings = cls._pending_warnings
cls._pending_warnings = []
return warnings
@classmethod
def get_decomposition_relationships(
cls, objs: list[bpy.types.Object]
) -> dict[ifcopenshell.entity_instance, DecompositionRecord]:
relationships: dict[ifcopenshell.entity_instance, DecompositionRecord] = {}
for obj in objs:
element = tool.Ifc.get_entity(obj)
if not element:
continue
if building := tool.Spatial.get_host_element(element):
relationships[element] = DecompositionRecord(type="fill", element=building)
return relationships
@classmethod
def get_connection_relationships(
cls, objs: list[bpy.types.Object]
) -> dict[ifcopenshell.entity_instance, ConnectionRecord]:
relationships: dict[ifcopenshell.entity_instance, ConnectionRecord] = {}
for obj in objs:
element = tool.Ifc.get_entity(obj)
if not element:
continue
if hasattr(element, "ConnectedTo") and element.ConnectedTo:
paths = [
connection for connection in element.ConnectedTo if connection.is_a("IfcRelConnectsPathElements")
]
for path in paths:
relationships[element] = ConnectionRecord(
type="path",
relating_element=path.RelatingElement,
related_element=path.RelatedElement,
relating_connection_type=path.RelatingConnectionType,
related_connection_type=path.RelatedConnectionType,
relating_priorities=list(path.RelatingPriorities or []),
related_priorities=list(path.RelatedPriorities or []),
)
return relationships
@classmethod
def get_port_connection_relationships(cls, objs: list[bpy.types.Object]) -> PortConnectionSnapshot:
"""Snapshot ``IfcRelConnectsPorts`` among MEP elements in ``objs``, indexed for positional-port replay onto duplicates."""
# Function-local: top-level import would trigger a partial-init cycle.
from bonsai.tool.system import direction_from_port_pair
snapshot = PortConnectionSnapshot()
elements_in_set: set[ifcopenshell.entity_instance] = set()
for obj in objs:
element = tool.Ifc.get_entity(obj)
if element is not None and tool.System.is_mep_element(element):
elements_in_set.add(element)
if not elements_in_set:
return snapshot
ordered_elements = sorted(elements_in_set, key=lambda e: e.id())
for element in ordered_elements:
snapshot.port_counts[element] = len(tool.System.get_ports(element))
seen: set[tuple[tuple[int, int], tuple[int, int]]] = set()
for element in ordered_elements:
ports = tool.System.get_ports(element)
for port_index, port in enumerate(ports):
connected_port = tool.System.get_connected_port(port)
if connected_port is None:
continue
other_element = tool.System.get_port_relating_element(connected_port)
if other_element is None or other_element not in elements_in_set:
continue
other_ports = tool.System.get_ports(other_element)
try:
other_port_index = other_ports.index(connected_port)
except ValueError:
continue
pair_key = tuple(
sorted(
[
(element.id(), port_index),
(other_element.id(), other_port_index),
]
)
)
if pair_key in seen:
continue
seen.add(pair_key)
snapshot.by_element.setdefault(element, []).append(
PortConnectionRecord(
relating_port_index=port_index,
related_element=other_element,
related_port_index=other_port_index,
direction=direction_from_port_pair(port, connected_port),
)
)
return snapshot
@classmethod
def recreate_decompositions(
cls,
relationships: dict[ifcopenshell.entity_instance, DecompositionRecord],
old_to_new: dict[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]],
) -> None:
for subelement, data in relationships.items():
new_subelements = old_to_new.get(subelement)
new_elements = old_to_new.get(data.element)
if not new_subelements or not new_elements:
continue
for i, new_subelement in enumerate(new_subelements):
new_element = new_elements[i]
if data.type == "fill":
element = new_element
filling = new_subelement
voided_obj = tool.Ifc.get_object(new_element)
filling_obj = tool.Ifc.get_object(new_subelement)
existing_opening_occurrence = subelement.FillsVoids[0].RelatingOpeningElement
opening = tool.Ifc.run("root.copy_class", product=existing_opening_occurrence)
tool.Ifc.run(
"geometry.edit_object_placement",
product=opening,
matrix=ifcopenshell.util.placement.get_local_placement(opening.ObjectPlacement),
is_si=False,
)
representation = ifcopenshell.util.representation.get_representation(
existing_opening_occurrence, "Model", "Body", "MODEL_VIEW"
)
representation = ifcopenshell.util.representation.resolve_representation(representation)
mapped_representation = tool.Ifc.run("geometry.map_representation", representation=representation)
tool.Ifc.run(
"geometry.assign_representation",
product=opening,
representation=mapped_representation,
)
tool.Ifc.run("feature.add_feature", feature=opening, element=element)
tool.Ifc.run("feature.add_filling", opening=opening, element=filling)
voided_objs = [voided_obj]
# Openings affect all subelements of an aggregate
for child_subelement in ifcopenshell.util.element.get_decomposition(element):
subobj = tool.Ifc.get_object(child_subelement)
if subobj:
voided_objs.append(subobj)
for voided_obj in voided_objs:
if mesh_data := voided_obj.data:
representation = tool.Ifc.get().by_id(
tool.Geometry.get_mesh_props(mesh_data).ifc_definition_id
)
bonsai.core.geometry.switch_representation(
tool.Ifc,
tool.Geometry,
obj=voided_obj,
representation=representation,
)
@classmethod
def recreate_connections(
cls,
relationship: dict[ifcopenshell.entity_instance, ConnectionRecord],
old_to_new: dict[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]],
) -> None:
for element, data in relationship.items():
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
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(
cls,
snapshot: PortConnectionSnapshot,
old_to_new: dict[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]],
) -> 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():
for record in records:
related_element = record.related_element
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)
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}")
+149 -19
View File
@@ -73,7 +73,7 @@ import bonsai.core.style
import bonsai.core.system
import bonsai.core.tool
import bonsai.tool as tool
from bonsai.bim.ifc import IfcStore
from bonsai.bim.ifc import IfcStore, get_cache_or_detect_lock
if TYPE_CHECKING:
from bonsai.bim.module.geometry.prop import (
@@ -115,10 +115,42 @@ class Geometry(bonsai.core.tool.Geometry):
@classmethod
def clear_cache(cls, element: ifcopenshell.entity_instance) -> None:
cache = IfcStore.get_cache()
# Cache acquisition can fail if the HDF5 file is locked by another
# process — degrade gracefully rather than aborting the caller's
# reimport flow. A stale cache entry is harmless; a raised exception
# prevents the actual mesh swap. The wrapper sets the project-panel
# warning flag on lock so the user sees one prominent notice instead
# of per-element log spam.
try:
cache = get_cache_or_detect_lock()
except Exception as exc:
print(f"clear_cache: skipping cache invalidation for {element} ({exc})")
return
if cache and hasattr(element, "GlobalId"):
cache.remove(element.GlobalId)
@classmethod
def has_axis_representation(cls, element: ifcopenshell.entity_instance) -> bool:
"""True if the element carries a shape representation whose
RepresentationIdentifier is 'Axis'. Elements without one cannot be
projected to an unambiguous 1D path; callers that draw schematic axis
overlays must skip them rather than fall back to mesh-derived geometry."""
product_rep = getattr(element, "Representation", None)
if product_rep is None:
return False
for rep in product_rep.Representations:
if getattr(rep, "RepresentationIdentifier", None) == "Axis":
return True
return False
@classmethod
def get_body_representation(cls, element: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance | None:
"""The element's ``Model/Body/MODEL_VIEW`` representation, or ``None``.
Single source for the ``(context, identifier, target_view)`` triple used
by every body-geometry reader across walls, slabs, doors, openings, and
feature decorators."""
return ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
@classmethod
def clear_modifiers(cls, obj: bpy.types.Object) -> None:
for modifier in obj.modifiers:
@@ -225,7 +257,13 @@ class Geometry(bonsai.core.tool.Geometry):
break
mesh = obj.data
assert isinstance(mesh, bpy.types.Mesh)
item = tool.Ifc.get().by_id(tool.Geometry.get_mesh_props(mesh).ifc_definition_id)
item_id = tool.Geometry.get_mesh_props(mesh).ifc_definition_id
try:
item = tool.Ifc.get().by_id(item_id)
except RuntimeError:
# Entity already deleted (e.g. removed as part of a sibling boolean collapse).
bpy.data.objects.remove(obj)
return
rep_obj = props.representation_obj
assert (rep_obj := props.representation_obj) and (rep_element := tool.Ifc.get_entity(rep_obj))
cls.remove_representation_item(item, rep_element)
@@ -389,6 +427,29 @@ class Geometry(bonsai.core.tool.Geometry):
bm.free()
del mesh["ios_edges"]
@classmethod
def get_dissolved_edges(
cls,
mesh: bpy.types.Mesh,
angle_limit: float = radians(1.0),
) -> tuple[list[Vector], list[tuple[int, int]]]:
# Read-only on `mesh`: builds a throwaway bmesh, dissolves coplanar
# edges while preserving material seams, returns wire-overlay data.
bm = bmesh.new()
bm.from_mesh(mesh)
bmesh.ops.dissolve_limit(
bm,
angle_limit=angle_limit,
verts=bm.verts,
edges=bm.edges,
delimit={"MATERIAL"},
)
bm.verts.index_update()
verts = [v.co.copy() for v in bm.verts]
edges = [(e.verts[0].index, e.verts[1].index) for e in bm.edges]
bm.free()
return verts, edges
@classmethod
def apply_item_ids_as_vertex_groups(cls, obj: bpy.types.Object) -> None:
"""Save mesh-object item_ids as vertex groups in format 'ios_item_id_xxxx'.
@@ -1093,11 +1154,16 @@ class Geometry(bonsai.core.tool.Geometry):
@classmethod
def get_representation_item(cls, obj: bpy.types.Object) -> Union[ifcopenshell.entity_instance, None]:
data = obj.data
if (
isinstance(data, Geometry.TYPES_WITH_MESH_PROPERTIES)
and (ifc_id := tool.Geometry.get_mesh_props(data).ifc_definition_id)
and ((item := tool.Ifc.get().by_id(ifc_id)).is_a("IfcRepresentationItem"))
):
if not isinstance(data, Geometry.TYPES_WITH_MESH_PROPERTIES):
return None
ifc_id = tool.Geometry.get_mesh_props(data).ifc_definition_id
if not ifc_id:
return None
try:
item = tool.Ifc.get().by_id(ifc_id)
except RuntimeError:
return None
if item.is_a("IfcRepresentationItem"):
return item
return None
@@ -1154,6 +1220,53 @@ class Geometry(bonsai.core.tool.Geometry):
props.location_checksum = repr(tool.Blender.np_array_legacy(obj.matrix_world.translation).tobytes())
props.rotation_checksum = repr(tool.Blender.np_array_legacy(obj.matrix_world.to_3x3()).tobytes())
@classmethod
def commit_placement_if_moved(cls, obj: bpy.types.Object, *, apply_scale: bool = True) -> None:
"""Write ``obj.matrix_world`` back to its IFC ``ObjectPlacement`` when the
object has drifted since its last placement commit.
Scope: drop-in only when the gate is exactly ``is_moved(obj)``. Call sites
whose gate is wider (e.g. ``is_moved OR is_scaled``) or already enforced
upstream (inside an ``if is_moved:`` block) should call
``edit_object_placement`` directly to avoid the redundant inner check."""
if not tool.Ifc.is_moved(obj):
return
bonsai.core.geometry.edit_object_placement(
tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj, apply_scale=apply_scale
)
@classmethod
def restore_placement_from_ifc(cls, obj: bpy.types.Object, element: ifcopenshell.entity_instance) -> None:
"""Snap ``obj.matrix_world`` back to ``element``'s committed IFC placement,
then re-baseline the drift checksum so ``tool.Ifc.is_moved(obj)`` returns
False afterwards.
Precondition: ``element.ObjectPlacement`` must not be None. Callers in a
cancel-style flow that want a "restore-or-clear-drift" semantic must gate
on ObjectPlacement themselves and call ``record_object_position`` directly
in the no-placement branch."""
assert element.ObjectPlacement is not None, (
"restore_placement_from_ifc requires ObjectPlacement — gate the caller "
"or use restore_or_rebaseline_placement for the restore-or-clear-drift semantic"
)
matrix_np = ifcopenshell.util.placement.get_local_placement(element.ObjectPlacement).copy()
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
matrix_np[:3, 3] *= unit_scale
obj.matrix_world = tool.Loader.apply_blender_offset_to_matrix_world(obj, matrix_np)
cls.record_object_position(obj)
@classmethod
def restore_or_rebaseline_placement(cls, obj: bpy.types.Object, element: ifcopenshell.entity_instance) -> None:
"""Cancel-flow placement restore: revert ``obj.matrix_world`` to the committed
IFC placement; when the element has no ObjectPlacement, re-baseline the drift
checksum instead so a subsequent edit does not silently commit the discarded drag."""
if not tool.Ifc.is_moved(obj):
return
if element.ObjectPlacement is None:
cls.record_object_position(obj)
return
cls.restore_placement_from_ifc(obj, element)
@classmethod
def remove_connection(cls, connection: ifcopenshell.entity_instance) -> None:
tool.Ifc.get().remove(connection)
@@ -1205,11 +1318,27 @@ class Geometry(bonsai.core.tool.Geometry):
bpy.data.objects.remove(obj)
return new_obj
@classmethod
def detach_representation(cls, product: ifcopenshell.entity_instance) -> None:
"""Replace ``product.Representation`` with a deep copy so the product
no longer shares its representation tree (mapped or direct) with any
other entity. The ``IfcGeometricRepresentationContext`` is excluded
from the copy so contexts stay file-singletons. No-op when the
product has no ``Representation`` attribute or it is unset."""
rep = getattr(product, "Representation", None)
if rep is None:
return
product.Representation = ifcopenshell.util.element.copy_deep(
tool.Ifc.get(), rep, exclude=["IfcGeometricRepresentationContext"]
)
@classmethod
def resolve_mapped_representation(
cls, representation: ifcopenshell.entity_instance
) -> ifcopenshell.entity_instance:
if representation.RepresentationType == "MappedRepresentation":
if not representation.Items:
return representation
return cls.resolve_mapped_representation(representation.Items[0].MappingSource.MappedRepresentation)
return representation
@@ -2132,8 +2261,11 @@ class Geometry(bonsai.core.tool.Geometry):
new_active_obj = None
# Track decompositions so they can be recreated after the operation
decomposition_relationships = tool.Root.get_decomposition_relationships(objects_to_duplicate)
connection_relationships = tool.Root.get_connection_relationships(objects_to_duplicate)
decomposition_relationships = tool.Duplicate.get_decomposition_relationships(objects_to_duplicate)
connection_relationships = tool.Duplicate.get_connection_relationships(objects_to_duplicate)
# Snapshot port-to-port connections — copy_class disconnects new ports
# by default, leaving Shift+D duplicates unconnected.
port_connection_snapshot = tool.Duplicate.get_port_connection_relationships(objects_to_duplicate)
old_to_new: dict[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]] = {}
old_obj_name_to_new_obj_name: dict[str, str] = {}
@@ -2155,10 +2287,7 @@ class Geometry(bonsai.core.tool.Geometry):
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.
if tool.Ifc.is_moved(obj):
bonsai.core.geometry.edit_object_placement(
tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj, apply_scale=False
)
cls.commit_placement_if_moved(obj, apply_scale=False)
new_obj = obj.copy()
temp_data = None
@@ -2212,7 +2341,7 @@ class Geometry(bonsai.core.tool.Geometry):
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.Blender.Modifier.Array.get_all_children_objects(new):
for child in tool.Array.get_all_children_objects(new):
child.select_set(True)
# TODO: add new array children to recreate their decomposition too
@@ -2240,10 +2369,11 @@ class Geometry(bonsai.core.tool.Geometry):
# Remove connections with old objects and recreates paths
cls.remove_old_connections(old_to_new)
tool.Root.recreate_connections(connection_relationships, old_to_new)
tool.Duplicate.recreate_connections(connection_relationships, old_to_new)
tool.Duplicate.recreate_port_connections(port_connection_snapshot, old_to_new)
# Recreate decompositions
tool.Root.recreate_decompositions(decomposition_relationships, old_to_new)
tool.Duplicate.recreate_decompositions(decomposition_relationships, old_to_new)
cls.remove_linked_aggregate_data(old_to_new)
bonsai.bim.handler.refresh_ui_data()
tool.Root.reload_grid_decorator()
@@ -2308,8 +2438,8 @@ class Geometry(bonsai.core.tool.Geometry):
continue
array_data = []
for modifier_data in tool.Blender.Modifier.Array.get_modifiers_data(array_parent):
children = set(tool.Blender.Modifier.Array.get_children_objects(modifier_data))
for modifier_data in tool.Array.get_modifiers_data(array_parent):
children = set(tool.Array.get_children_objects(modifier_data))
if children.issubset(selected_objects):
modifier_data["children"] = []
array_data.append(modifier_data)
+2 -2
View File
@@ -1216,7 +1216,7 @@ class Loader(bonsai.core.tool.Loader):
) -> bool:
items = [i["item"] for i in ifcopenshell.util.representation.resolve_items(representation)]
if len(items) == 1 and items[0].is_a("IfcSweptDiskSolid"):
if tool.Blender.Modifier.is_railing(element):
if tool.Parametric.is_railing(element):
return False
return True
elif len(items) and ( # See #2508 why we accommodate for invalid IFCs here
@@ -1224,7 +1224,7 @@ class Loader(bonsai.core.tool.Loader):
and len({i.is_a() for i in items}) == 1
and len({i.Radius for i in items}) == 1
):
if tool.Blender.Modifier.is_railing(element):
if tool.Parametric.is_railing(element):
return False
return True
return False
+3 -8
View File
@@ -227,10 +227,8 @@ class Misc(bonsai.core.tool.Misc):
@classmethod
def set_object_origin_to_bottom(cls, obj: bpy.types.Object) -> None:
absolute_bound_box = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
min_z = min([c[2] for c in absolute_bound_box])
new_origin = obj.matrix_world.translation.copy()
new_origin[2] = min_z
new_origin[2] = tool.Blender.get_object_world_bounding_box(obj)["min_z"]
assert isinstance(obj.data, bpy.types.Mesh)
obj.data.transform(
Matrix.Translation(
@@ -249,11 +247,8 @@ class Misc(bonsai.core.tool.Misc):
@classmethod
def scale_object_to_height(cls, obj: bpy.types.Object, height: float) -> None:
absolute_bound_box = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
max_z = max([c[2] for c in absolute_bound_box])
min_z = min([c[2] for c in absolute_bound_box])
current_absolute_height = max_z - min_z
scale_factor = height / current_absolute_height
bbox = tool.Blender.get_object_world_bounding_box(obj)
scale_factor = height / (bbox["max_z"] - bbox["min_z"])
obj.matrix_world @= Matrix.Scale(
scale_factor, 4, obj.matrix_world.inverted().to_quaternion() @ Vector((0, 0, 1))
)
+328 -42
View File
@@ -15,12 +15,14 @@
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was modified with the assistance of an AI coding tool.
from __future__ import annotations
import collections.abc
import json
from collections.abc import Iterable, Sequence
from collections.abc import Callable, Iterable, Sequence
from copy import deepcopy
from math import atan, cos, degrees, pi, radians
from typing import (
@@ -37,9 +39,11 @@ from typing import (
import bmesh
import bpy
import ifcopenshell
import ifcopenshell.api.feature
import ifcopenshell.api.geometry
import ifcopenshell.api.grid
import ifcopenshell.api.pset
import ifcopenshell.api.root
import ifcopenshell.geom
import ifcopenshell.ifcopenshell_wrapper as W
import ifcopenshell.util.element
@@ -58,6 +62,7 @@ import bonsai.core.geometry
import bonsai.core.tool
import bonsai.tool as tool
from bonsai.bim import import_ifc
from bonsai.tool.cad import VTX_PRECISION, WELD_TOLERANCE
T = TypeVar("T")
V_ = tool.Blender.V_
@@ -77,6 +82,7 @@ if TYPE_CHECKING:
BIMRoofProperties,
BIMStairProperties,
BIMSverchokProperties,
BIMWallProperties,
BIMWindowProperties,
)
@@ -98,6 +104,10 @@ class Model(bonsai.core.tool.Model):
def get_stair_props(cls, obj: bpy.types.Object) -> BIMStairProperties:
return obj.BIMStairProperties # pyright: ignore[reportAttributeAccessIssue]
@classmethod
def get_wall_props(cls, obj: bpy.types.Object) -> BIMWallProperties:
return obj.BIMWallProperties # pyright: ignore[reportAttributeAccessIssue]
@classmethod
def get_roof_props(cls, obj: bpy.types.Object) -> BIMRoofProperties:
return obj.BIMRoofProperties # pyright: ignore[reportAttributeAccessIssue]
@@ -123,6 +133,35 @@ class Model(bonsai.core.tool.Model):
assert (scene := bpy.context.scene)
return scene.BIMPolylineProperties # pyright: ignore[reportAttributeAccessIssue]
@classmethod
def resolve_active_props_for_edit(
cls,
context: bpy.types.Context,
props_getter: Callable[[bpy.types.Object], Any],
*,
subtype: Optional[tuple[str, Any]] = None,
) -> Optional[tuple[bpy.types.Object, Any]]:
"""Resolve ``(obj, props)`` for an operator that acts on the active
object only while a parametric edit is active.
Returns ``None`` (the operator should ``return {"CANCELLED"}``) when
any of these fail:
- no active object,
- ``props.is_editing`` is False,
- ``subtype`` is given as ``(attr, value)`` and ``props.<attr> != value``.
"""
obj = context.active_object
if not obj:
return None
props = props_getter(obj)
if not getattr(props, "is_editing", False):
return None
if subtype is not None:
attr, value = subtype
if getattr(props, attr, None) != value:
return None
return obj, props
@classmethod
def convert_si_to_unit(cls, value: T) -> T:
if isinstance(value, (tuple, list)):
@@ -312,6 +351,8 @@ class Model(bonsai.core.tool.Model):
@classmethod
def get_extrusion(cls, representation: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]:
"""Return first found IfcExtrudedAreaSolid"""
if not representation.Items:
return None
item = representation.Items[0]
while True:
if item.is_a("IfcExtrudedAreaSolid"):
@@ -792,7 +833,7 @@ class Model(bonsai.core.tool.Model):
assert element or representation, "Either element or representation must be provided."
if representation is None:
assert element
representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
representation = tool.Geometry.get_body_representation(element)
if not representation:
return []
booleans = []
@@ -804,6 +845,57 @@ class Model(bonsai.core.tool.Model):
items.append(item.FirstOperand)
return booleans
@classmethod
def get_connected_slab_objs(cls, wall: ifcopenshell.entity_instance) -> list[bpy.types.Object]:
"""Return Blender objects for slabs connected to wall via IfcRelConnectsElements(TOP)."""
result = []
for rel in wall.ConnectedFrom:
if rel.is_a("IfcRelConnectsElements") and rel.Description == "TOP":
slab_obj = tool.Ifc.get_object(rel.RelatingElement)
if slab_obj:
result.append(slab_obj)
return result
@classmethod
def get_connected_wall_objs(cls, slab: ifcopenshell.entity_instance) -> list[bpy.types.Object]:
"""Return Blender objects for LAYER2 walls connected to slab via IfcRelConnectsElements(TOP)."""
result = []
for rel in slab.ConnectedTo:
if rel.is_a("IfcRelConnectsElements") and rel.Description == "TOP":
wall_obj = tool.Ifc.get_object(rel.RelatedElement)
if wall_obj:
result.append(wall_obj)
return result
@classmethod
def has_underside_connection(cls, element: ifcopenshell.entity_instance) -> bool:
"""Return True if element has an IfcRelConnectsElements(TOP) relationship."""
return any(rel.is_a("IfcRelConnectsElements") and rel.Description == "TOP" for rel in element.ConnectedFrom)
@classmethod
def remove_wall_to_underside_booleans(cls, wall: ifcopenshell.entity_instance) -> None:
"""Remove all IfcBooleanResult items previously added by extend_walls_to_underside."""
manual_booleans = cls.get_manual_booleans(wall)
if not manual_booleans:
return
ifc_file = tool.Ifc.get()
for b in manual_booleans:
sec = b.SecondOperand
if sec is None:
# The IfcPolygonalFaceSet was already deleted externally. Splice the
# orphaned IfcBooleanResult out of the chain so the representation stays valid.
parents = list(ifc_file.get_inverse(b))
for parent in parents:
if parent.is_a("IfcBooleanResult") and parent.FirstOperand == b:
parent.FirstOperand = b.FirstOperand
elif parent.is_a("IfcShapeRepresentation"):
new_items = tuple((set(parent.Items) - {b}) | {b.FirstOperand})
parent.Items = new_items
cls.unmark_manual_booleans(wall, [b.id()])
ifc_file.remove(b)
elif sec.is_a("IfcTessellatedFaceSet"):
tool.Geometry.remove_representation_item(sec, wall)
@classmethod
def get_manual_booleans(
cls, element: ifcopenshell.entity_instance, representation: Optional[ifcopenshell.entity_instance] = None
@@ -813,10 +905,11 @@ class Model(bonsai.core.tool.Model):
return []
boolean_ids = json.loads(pset["Data"])
if representation is None:
representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
representation = tool.Geometry.get_body_representation(element)
if not representation:
return []
booleans = [b for b in cls.get_booleans(element, representation) if b.id() in boolean_ids]
all_chain_booleans = cls.get_booleans(element, representation)
booleans = [b for b in all_chain_booleans if b.id() in boolean_ids]
return booleans
@classmethod
@@ -902,7 +995,7 @@ class Model(bonsai.core.tool.Model):
# Revolved area check should happen inside bim.enable_editing_extrusion_axis
# but keep it here to trigger import_representation_items,
# so users will be able to at least move IfcRevolvedAreaSolid, until there will be a full support.
body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
body = tool.Geometry.get_body_representation(element)
if body and any(
i.is_a("IfcRevolvedAreaSolid") for i in ifcopenshell.util.representation.resolve_base_items(body)
):
@@ -1015,7 +1108,14 @@ class Model(bonsai.core.tool.Model):
def handle_array_on_copied_element(
cls, element: ifcopenshell.entity_instance, array_data: Optional[dict[str, Any]] = None
) -> None:
"""if no `array_data` is provided then an array will be removed from the element"""
"""Post-copy hook: decide what to do with the BBIM_Array pset a copy
inherits from its source.
- ``array_data=None`` detach the copy from any array. Removes the
inherited BBIM_Array pset and any CHILD_OF constraint.
- ``array_data`` provided promote the copy to a fresh array parent
with an empty children list, using the provided layer config.
"""
if array_data is None:
array_pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
@@ -1059,8 +1159,8 @@ class Model(bonsai.core.tool.Model):
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=array_pset, properties={"Data": json_data})
for i in range(len(array_data)):
tool.Blender.Modifier.Array.set_children_lock_state(element, i, True)
tool.Blender.Modifier.Array.constrain_children_to_parent(element)
tool.Array.set_children_lock_state(element, i, True)
tool.Array.constrain_children_to_parent(element)
@classmethod
def regenerate_array(
@@ -1097,12 +1197,17 @@ class Model(bonsai.core.tool.Model):
offset = base_offset * i
for obj in obj_stack:
# IndexError when child_i is past the recorded children list
# (count grew); RuntimeError when by_guid finds no entity (the
# child was deleted outside the array op); AssertionError when
# the IFC entity exists but its Blender object was unlinked.
# All three fall through to duplication.
try:
global_id = array["children"][child_i]
child_element = tool.Ifc.get().by_guid(global_id)
child_obj = tool.Ifc.get_object(child_element)
assert child_obj
except:
except (IndexError, RuntimeError, AssertionError):
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)
@@ -1139,14 +1244,24 @@ class Model(bonsai.core.tool.Model):
removed_children = set(existing_children) - set(array["children"])
for removed_child in removed_children:
element = tool.Ifc.get().by_guid(removed_child)
# Strip any wall/slab opening cut by this child before deletion,
# so the host's HasOpenings shrinks symmetrically with count.
if getattr(element, "FillsVoids", None):
ifcopenshell.api.feature.remove_feature(
tool.Ifc.get(), feature=element.FillsVoids[0].RelatingOpeningElement
)
obj = tool.Ifc.get_object(element)
if obj:
tool.Geometry.delete_ifc_object(obj)
if array.get("per_child_opening", array.get("mirror_to_host", True)) and children_elements:
cls.mirror_parent_void_fillings_to_children(parent_element, children_elements)
if array_i in array_layers_to_apply:
for child_element in children_elements:
pset = tool.Pset.get_element_pset(child_element, "BBIM_Array")
ifcopenshell.api.pset.remove_pset(tool.Ifc.get(), product=child_element, pset=pset)
cls.unshare_opening_representation(child_element)
array["children"] = []
array["count"] = 1
@@ -1159,6 +1274,112 @@ class Model(bonsai.core.tool.Model):
tool.Ifc.get(), pset=pset, properties={"Data": json_data, "Parent": parent_element.GlobalId}
)
# 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(
cls,
parent_element: ifcopenshell.entity_instance,
children_elements: Sequence[ifcopenshell.entity_instance],
) -> None:
"""Replicate the parent's FillsVoids → host chain onto each array child.
For each child, tears down any stale opening, creates a new
IfcOpeningElement at the child's current placement, reuses the parent's
opening representation as a MappedRepresentation, and adds the
void + filling pair so the host element is cut once per child.
No-op when the parent is not a filling, when the host element cannot
be resolved, or when the children list is empty. Opt out via the
per-layer ``per_child_opening`` flag on ``BBIM_Array.Data`` (legacy
key ``mirror_to_host`` still honoured for round-trip with older files).
"""
host = tool.Spatial.get_host_element(parent_element)
if host is None or not children_elements:
return
ifc_file = tool.Ifc.get()
parent_opening = parent_element.FillsVoids[0].RelatingOpeningElement
parent_opening_rep = ifcopenshell.util.representation.get_representation(
parent_opening, "Model", "Body", "MODEL_VIEW"
)
if parent_opening_rep is None:
return
parent_opening_rep = ifcopenshell.util.representation.resolve_representation(parent_opening_rep)
for child in children_elements:
if getattr(child, "FillsVoids", None):
ifcopenshell.api.feature.remove_feature(ifc_file, feature=child.FillsVoids[0].RelatingOpeningElement)
child_obj = tool.Ifc.get_object(child)
if child_obj is None:
continue
new_opening = ifcopenshell.api.root.create_entity(
ifc_file,
ifc_class="IfcOpeningElement",
predefined_type="OPENING",
name="Opening",
)
ifcopenshell.api.geometry.edit_object_placement(
ifc_file,
product=new_opening,
matrix=np.array(child_obj.matrix_world),
is_si=True,
)
mapped_representation = ifcopenshell.api.geometry.map_representation(
ifc_file, representation=parent_opening_rep
)
ifcopenshell.api.geometry.assign_representation(
ifc_file, product=new_opening, representation=mapped_representation
)
ifcopenshell.api.feature.add_feature(ifc_file, feature=new_opening, element=host)
ifcopenshell.api.feature.add_filling(ifc_file, opening=new_opening, element=child)
# Openings affect every sub-element of an aggregate, not just the named host.
voided_objs: list[bpy.types.Object] = []
host_obj = tool.Ifc.get_object(host)
if host_obj is not None:
voided_objs.append(host_obj)
for subelement in tool.Aggregate.get_parts_recursively(host):
subobj = tool.Ifc.get_object(subelement)
if subobj is not None:
voided_objs.append(subobj)
for voided_obj in voided_objs:
if not voided_obj.data:
continue
voided_element = tool.Ifc.get_entity(voided_obj)
if voided_element is None:
continue
context = tool.Geometry.get_active_representation_context(voided_obj)
representation = tool.Geometry.get_representation_by_context(voided_element, context)
if representation is None:
continue
bonsai.core.geometry.switch_representation(
tool.Ifc, tool.Geometry, obj=voided_obj, representation=representation
)
@classmethod
def unshare_opening_representation(cls, filling: ifcopenshell.entity_instance) -> None:
"""Detach a filling's opening representation from any shared mapped body.
Required when a Bonsai array child is promoted to an independent
object: the array's per-child opening mirror builds each child's
opening representation as an ``IfcMappedRepresentation`` over the
parent opening's body. Without this detach, a later edit replacing
the parent body rewrites the shared ``IfcRepresentationMap`` and
reshapes the former-child's opening too."""
if not getattr(filling, "FillsVoids", None):
return
tool.Geometry.detach_representation(filling.FillsVoids[0].RelatingOpeningElement)
@classmethod
def replace_object_ifc_representation(
cls,
@@ -1305,8 +1526,8 @@ class Model(bonsai.core.tool.Model):
return [obj for obj in tool.Blender.get_selected_objects() if tool.Ifc.get_entity(obj)]
@classmethod
def has_selected_ifc_objects(cls) -> bool:
return any(tool.Ifc.get_entity(obj) for obj in tool.Blender.get_selected_objects())
def has_selected_ifc_objects(cls, include_active: bool = True) -> bool:
return any(tool.Ifc.get_entity(obj) for obj in tool.Blender.get_selected_objects(include_active=include_active))
@classmethod
def get_selected_mesh_objects(cls) -> list[bpy.types.Object]:
@@ -1355,8 +1576,7 @@ class Model(bonsai.core.tool.Model):
@classmethod
def sync_object_ifc_position(cls, obj: bpy.types.Object) -> None:
"""make sure IFC position will be in sync with the Blender object position, if object was moved in Blender"""
if tool.Ifc.is_moved(obj):
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj)
tool.Geometry.commit_placement_if_moved(obj)
@classmethod
def get_element_matrix(cls, element: ifcopenshell.entity_instance, keep_local: bool = False) -> Matrix:
@@ -1388,7 +1608,7 @@ class Model(bonsai.core.tool.Model):
if not obj.data:
continue
element = tool.Ifc.get_entity(obj)
body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
body = tool.Geometry.get_body_representation(element)
bonsai.core.geometry.switch_representation(
tool.Ifc,
tool.Geometry,
@@ -1505,6 +1725,10 @@ class Model(bonsai.core.tool.Model):
"TRIPLE_PANEL_VERTICAL",
]
RoofGenerationMethod = Literal["HEIGHT", "ANGLE"]
RailingType = Literal["FRAMELESS_PANEL", "WALL_MOUNTED_HANDRAIL"]
@classmethod
def generate_stair_2d_profile(
cls,
@@ -1756,7 +1980,7 @@ class Model(bonsai.core.tool.Model):
from bonsai.bim.module.model.opening import FilledOpeningGenerator
ifc_file = tool.Ifc.get()
fillings = {e: tool.Ifc.get_object(e) for e in tool.Ifc.get_all_element_occurrences(element)}
fillings = {e: tool.Ifc.get_object(e) for e in tool.Array.get_parametric_propagation_targets(element)}
voided_objs = set()
has_replaced_opening_representation = False
@@ -1898,7 +2122,9 @@ class Model(bonsai.core.tool.Model):
bm = bmesh.new()
bm.from_mesh(mesh)
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-4)
# Looser than auto_detect_curves' VTX_PRECISION: profiles must close into
# a single loop, so nearly-coincident endpoints should snap together.
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=WELD_TOLERANCE)
bmesh.ops.delete(bm, geom=bm.faces, context="FACES_ONLY")
# https://docs.blender.org/api/blender_python_api_2_63_8/bmesh.html#CustomDataAccess
@@ -2126,7 +2352,7 @@ class Model(bonsai.core.tool.Model):
bm = bmesh.new()
bm.from_mesh(mesh)
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-5)
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=VTX_PRECISION)
bmesh.ops.delete(bm, geom=bm.faces, context="FACES_ONLY")
# https://docs.blender.org/api/blender_python_api_2_63_8/bmesh.html#CustomDataAccess
@@ -2345,6 +2571,12 @@ class Model(bonsai.core.tool.Model):
@classmethod
def get_existing_x_angle(cls, extrusion: ifcopenshell.entity_instance) -> float:
"""Signed slope of the extrusion's direction in the y-z plane (radians).
Assumes extrusion directions lie in the y-z plane (LAYER2 wall and
LAYER3 slab convention). For inverted extrusions (z 0), adds π to
preserve angular continuity for callers consuming the angle via
cos/sin."""
x, y, z = extrusion.ExtrudedDirection.DirectionRatios
vector = Vector((0, 1))
x_angle = vector.angle_signed(Vector((y, z)))
@@ -2379,12 +2611,15 @@ class Model(bonsai.core.tool.Model):
clipping_bm = bmesh.new()
vertex_map = {}
kept = 0
for face in bm.faces:
face.normal_update()
normal = face.normal.to_4d()
normal.w = 0
if (obj.matrix_world @ normal).z >= -0.5:
world_normal_z = (obj.matrix_world @ normal).z
if world_normal_z >= -0.5:
continue
kept += 1
new_verts = []
for vert in face.verts:
if not (new_vert := vertex_map.get(vert.index, None)):
@@ -2397,6 +2632,7 @@ class Model(bonsai.core.tool.Model):
return
bmesh.ops.recalc_face_normals(clipping_bm, faces=clipping_bm.faces)
clipping_bm.faces.ensure_lookup_table()
return clipping_bm # clipping_bm is in project units
@classmethod
@@ -2410,17 +2646,53 @@ class Model(bonsai.core.tool.Model):
min_z = min(zs)
max_z = max(zs)
operand = None
if (z := max_z - min_z) and not np.isclose(z, 0.0):
builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get())
ifc_file = tool.Ifc.get()
builder = ifcopenshell.util.shape_builder.ShapeBuilder(ifc_file)
result = bmesh.ops.extrude_face_region(bm, geom=bm.faces)
extruded_verts = [elem for elem in result["geom"] if isinstance(elem, bmesh.types.BMVert)]
bmesh.ops.translate(bm, verts=extruded_verts, vec=(0, 0, z))
# Build one IfcPolygonalFaceSet clip solid per clipping face.
# Each solid uses a rectangle on the slope plane rather than the exact face
# footprint. The original approach (exact footprint) caused a kissing-solid /
# boundary-coincidence bug when the operator is called twice for a ridge roof: the
# two slope solids share an exact ridge edge, and OCCT produces spurious extra
# vertices. Extending each solid slightly past the ridge (by margin) creates a
# volumetric overlap instead of a kissing boundary — OCCT handles overlapping
# DIFFERENCE operands correctly.
margin = 1.0 # project units past the face edge — enough to ensure overlap at ridge
operands = []
for face in bm.faces:
face.normal_update()
normal = Vector(face.normal).normalized()
verts = [v.co for v in bm.verts]
faces = [[v.index for v in p.verts] for p in bm.faces]
operand = builder.mesh(verts, faces)
# Orthonormal basis spanning the slope plane.
ref = Vector((0, 0, 1)) if abs(normal.z) < 0.9 else Vector((1, 0, 0))
tangent1 = normal.cross(ref).normalized()
tangent2 = normal.cross(tangent1).normalized()
centroid = sum((v.co for v in face.verts), Vector()) / len(face.verts)
# Tight bounding rectangle in slope-plane coords, plus a small margin.
t1_coords = [(v.co - centroid).dot(tangent1) for v in face.verts]
t2_coords = [(v.co - centroid).dot(tangent2) for v in face.verts]
half1 = max(abs(c) for c in t1_coords) + margin
half2 = max(abs(c) for c in t2_coords) + margin
# Rectangle on the slope plane, extruded upward in wall-local Z.
clip_bm = bmesh.new()
v0 = clip_bm.verts.new(centroid + half1 * tangent1 + half2 * tangent2)
v1 = clip_bm.verts.new(centroid - half1 * tangent1 + half2 * tangent2)
v2 = clip_bm.verts.new(centroid - half1 * tangent1 - half2 * tangent2)
v3 = clip_bm.verts.new(centroid + half1 * tangent1 - half2 * tangent2)
bottom_face = clip_bm.faces.new([v0, v1, v2, v3])
result = bmesh.ops.extrude_face_region(clip_bm, geom=[bottom_face])
top_verts = [e for e in result["geom"] if isinstance(e, bmesh.types.BMVert)]
bmesh.ops.translate(clip_bm, verts=top_verts, vec=Vector((0, 0, max_z - min_z)))
clip_bm.verts.ensure_lookup_table()
clip_verts = [v.co for v in clip_bm.verts]
clip_faces = [[v.index for v in f.verts] for f in clip_bm.faces]
operand = builder.mesh(clip_verts, clip_faces)
clip_bm.free()
operands.append(operand)
for extrusion in ifcopenshell.util.shape.get_base_extrusions(wall) or []:
if extrusion.Position:
@@ -2437,10 +2709,9 @@ class Model(bonsai.core.tool.Model):
extrusion.Depth = max_z / direction[2]
if operand:
booleans = ifcopenshell.api.geometry.add_boolean(
tool.Ifc.get(), first_item=extrusion, second_items=[operand]
)
if operands:
body_repr = ifcopenshell.util.representation.get_representation(wall, "Model", "Body", "MODEL_VIEW")
booleans = ifcopenshell.api.geometry.add_boolean(ifc_file, first_item=extrusion, second_items=operands)
tool.Model.mark_manual_booleans(wall, booleans)
@classmethod
@@ -2672,7 +2943,7 @@ class Model(bonsai.core.tool.Model):
def offset_wall(cls, wall: bpy.types.Object, baseline: Literal["EXTERIOR", "INTERIOR", "CENTER"]) -> None:
element = tool.Ifc.get_entity(wall)
usage = ifcopenshell.util.element.get_material(element)
if not usage.is_a("IfcMaterialLayerSetUsage"):
if usage is None or not usage.is_a("IfcMaterialLayerSetUsage"):
return
layer_set = usage.ForLayerSet
if baseline == "CENTER":
@@ -2693,6 +2964,20 @@ class Model(bonsai.core.tool.Model):
@classmethod
def recreate_wall(cls, element: ifcopenshell.entity_instance, obj: bpy.types.Object) -> None:
# Curved fillet-corner walls own a hand-built banana body that
# ``regenerate_wall_representation`` would flatten — it reads the axis
# as a 2-point reference line and builds a straight extrusion. Rebuild
# the curve in place instead: ``regenerate_fillet_corner_wall`` keeps
# radius + placement from the pset / current ``ObjectPlacement`` while
# picking up new thickness / height from the wall type, which is what
# we want when a type-property edit triggered this call.
if tool.Parametric.is_fillet_corner_wall(element):
# Lazy import: ``tool.Model`` loads before ``bim/module/model`` at
# addon enable; a module-level import would cycle.
from bonsai.bim.module.model.wall import regenerate_fillet_corner_wall
regenerate_fillet_corner_wall(element, obj)
return
rep = ifcopenshell.api.geometry.regenerate_wall_representation(tool.Ifc.get(), element)
bonsai.core.geometry.switch_representation(
tool.Ifc,
@@ -2713,28 +2998,29 @@ class Model(bonsai.core.tool.Model):
queue: set[tuple[ifcopenshell.entity_instance, bpy.types.Object]] = set()
for wall in walls:
element = tool.Ifc.get_entity(wall)
if tool.Ifc.is_moved(wall):
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=wall)
tool.Geometry.commit_placement_if_moved(wall)
queue.add((element, wall))
for rel in getattr(element, "ConnectedTo", []):
obj = tool.Ifc.get_object(rel.RelatedElement)
if tool.Ifc.is_moved(obj):
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj)
tool.Geometry.commit_placement_if_moved(obj)
queue.add((rel.RelatedElement, obj))
for rel in getattr(element, "ConnectedFrom", []):
obj = tool.Ifc.get_object(rel.RelatingElement)
if tool.Ifc.is_moved(obj):
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj)
tool.Geometry.commit_placement_if_moved(obj)
queue.add((rel.RelatingElement, obj))
for element, wall in queue:
if tool.Model.get_usage_type(element) == "LAYER2" and wall:
# Use layer custom offset
if not wall:
continue
is_layer2_usage = tool.Model.get_usage_type(element) == "LAYER2"
is_fillet_corner = tool.Parametric.is_fillet_corner_wall(element)
if not (is_layer2_usage or is_fillet_corner):
continue
if is_layer2_usage:
custom_offset = tool.Model.get_material_layer_custom_offset(element, wall)
material = ifcopenshell.util.element.get_material(element)
if material.is_a("IfcMaterialLayerSetUsage") and custom_offset is not None:
material.OffsetFromReferenceLine = custom_offset
cls.recreate_wall(element, wall)
cls.recreate_wall(element, wall)
@classmethod
def regenerate_slab(cls, obj: bpy.types.Object) -> None:
+621
View File
@@ -0,0 +1,621 @@
# 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.
"""Registry and save-time auto-commit for parametric draft edits.
The registry is consumed along two orthogonal axes:
- **Predicate axis**: every entry carries an ``is_<name>`` total predicate. Used
by ``find_for_element``, save-flow auto-commit, and per-feature gizmo polls.
- **Lifecycle axis**: a subset of entries flagged ``supports_build_edit_lifecycle=True``
share the ``Enable/Finish/CancelEditing<Type>`` operator shape and are wired
through ``build_edit_lifecycle``. The remainder declare their edit operators
directly because their lifecycle (per-attribute diff dispatch, layer-stack
editing, mid-spline gizmo drag, ) does not fit the shared mixin contract.
Adding a new parametric element type is a single entry in ``EDIT_TYPES``;
flag ``supports_build_edit_lifecycle`` only if the type's edit lifecycle matches
one of the shared mixins in ``bim/parametric_lifecycle.py``."""
from __future__ import annotations
import logging
import re
from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, ClassVar, Optional
import bpy
import bonsai.core.tool
import bonsai.tool as tool
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from ifcopenshell import entity_instance
# Lowercase ASCII snake_case token; each segment a non-empty letter/digit
# sequence starting with a letter. ``"pipe_segment"`` → ``"BIMPipeSegmentProperties"``.
_VALID_NAME_RE = re.compile(r"^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$")
def _camel_case(name: str) -> str:
return "".join(part.capitalize() for part in name.split("_"))
@dataclass(frozen=True)
class ParametricObject:
"""One parametric element type's draft + enable + finish + cancel edit lifecycle.
The ``name`` token drives every derived identifier: the
``BIM<Name>Properties`` attribute on ``bpy.types.Object``, the
``bim.enable_editing_<name>`` / ``bim.finish_editing_<name>`` /
``bim.cancel_editing_<name>`` operator ``bl_idname``s, and the
``tool.Parametric.is_<name>`` runtime predicate.
The predicate is part of the contract and MUST be total accept any IFC
entity, return a bool, never raise. A raising predicate breaks the save
path for every parametric type, not just its own.
``supports_build_edit_lifecycle`` marks entries whose edit lifecycle fits the
shared mixin contract (``_enable_targets`` / ``_finish_targets`` /
``_cancel_targets``) and that therefore wire their operators through
``build_edit_lifecycle``. Entries with bespoke edit lifecycles (per-attribute
diff dispatch, layer-stack editing, mid-spline gizmo drag) leave this
False and declare their operator classes directly."""
name: str
has_non_editable_path: bool = False
supports_build_edit_lifecycle: bool = False
def __post_init__(self) -> None:
if not _VALID_NAME_RE.match(self.name):
raise ValueError(
f"ParametricObject name {self.name!r} must match "
f"{_VALID_NAME_RE.pattern!r} — lowercase letters / digits, "
f"optionally split by single underscores (e.g. ``door`` or "
f"``pipe_segment``). Leading / trailing underscores and "
f"consecutive underscores are rejected because they produce "
f"empty CamelCase segments in derived class names."
)
@property
def props_attr(self) -> str:
return f"BIM{_camel_case(self.name)}Properties"
@property
def enable_op(self) -> str:
return f"bim.enable_editing_{self.name}"
@property
def finish_op(self) -> str:
return f"bim.finish_editing_{self.name}"
@property
def cancel_op(self) -> str:
return f"bim.cancel_editing_{self.name}"
def is_editing(self, obj: bpy.types.Object) -> bool:
props = getattr(obj, self.props_attr, None)
return bool(props and getattr(props, "is_editing", False))
class Parametric(bonsai.core.tool.Parametric):
class GenerationKeyedCache:
"""A dict-keyed cache stamped with the parametric generation counter
at fill time. Reads at a later generation drop the whole dict and
re-run the loader. Any IFC commit bumps the generation, invalidating
all entries en bloc.
``None`` values are stored verbatim; only "key not in dict" counts as
a miss."""
def __init__(self) -> None:
self._gen: int | None = None
self._data: dict = {}
def get_or_compute(self, key, loader):
current = Parametric.get_geom_generation()
if self._gen != current:
self._data.clear()
self._gen = current
if key not in self._data:
self._data[key] = loader()
return self._data[key]
def clear(self) -> None:
"""Explicit drop. Use from ``load_post`` so a fresh file starts clean."""
self._data.clear()
self._gen = None
# FIXME(PR5): pipe_segment / duct_segment land with their finish/cancel
# operators in the MEP slice of PR5 (PR5d). Until then they stay out of
# EDIT_TYPES so auto-commit-on-save doesn't try to dispatch a
# non-existent operator.
EDIT_TYPES: list[ParametricObject] = [
ParametricObject("door", has_non_editable_path=True, supports_build_edit_lifecycle=True),
ParametricObject("window", has_non_editable_path=True, supports_build_edit_lifecycle=True),
ParametricObject("stair", has_non_editable_path=True, supports_build_edit_lifecycle=True),
ParametricObject("railing", supports_build_edit_lifecycle=True),
ParametricObject("roof", supports_build_edit_lifecycle=True),
ParametricObject("array", supports_build_edit_lifecycle=True),
ParametricObject("wall"),
]
# Annotations for the uppercase constants populated from ``EDIT_TYPES`` by
# the binding loop at module bottom. Declared here so IDEs and type
# checkers see the attributes without running the loop.
DOOR: ClassVar[ParametricObject]
WINDOW: ClassVar[ParametricObject]
STAIR: ClassVar[ParametricObject]
RAILING: ClassVar[ParametricObject]
ROOF: ClassVar[ParametricObject]
ARRAY: ClassVar[ParametricObject]
WALL: ClassVar[ParametricObject]
_geom_generation: int = 0
@classmethod
def get_geom_generation(cls) -> int:
return cls._geom_generation
@classmethod
def refresh_post_commit(cls, operator: bpy.types.Operator) -> None:
"""Post-commit hook for ``tool.Ifc.Operator``: bumps the geometry
generation counter so caches keyed off it drop stale entries on
the next draw, and tags viewports for redraw.
Additionally refreshes the BIM Tool header floats for the
validate-gizmo path operators whose ``bl_idname`` is the
``finish_op`` of an entry in ``EDIT_TYPES``. That is the only
commit class where selection didn't change but the header
values displayed did. Other operators skip the refresh: they
don't target an active-object header edit, and their commit
context may lack the view-layer attributes the refresh reads."""
cls._geom_generation += 1
tool.Blender.update_all_viewports()
if operator.bl_idname in {feature.finish_op for feature in cls.EDIT_TYPES}:
import bonsai.bim.handler # late import: bim.handler imports tool.*
bonsai.bim.handler.refresh_bim_tool_headers()
@classmethod
def find_by_name(cls, name: str) -> Optional[ParametricObject]:
return next((f for f in cls.EDIT_TYPES if f.name == name), None)
@classmethod
def _safe_predicate(cls, feature: ParametricObject, element: entity_instance) -> bool:
"""Resolve and invoke ``is_<feature.name>`` defensively. The contract is
that predicates are total (see ``ParametricObject`` docstring); a
regression that turns one predicate raising would otherwise break the
save path for every parametric type, not just its own."""
predicate = getattr(cls, f"is_{feature.name}", None)
if predicate is None:
return False
try:
return bool(predicate(element))
except Exception:
logger.warning(
"parametric predicate is_%s raised on %r",
feature.name,
element,
exc_info=True,
)
return False
@classmethod
def find_for_element(cls, element: entity_instance) -> Optional[ParametricObject]:
"""Return the registry entry whose IFC type predicate matches ``element``."""
for feature in cls.EDIT_TYPES:
if cls._safe_predicate(feature, element):
return feature
return None
@classmethod
def is_object_editing(cls, obj: bpy.types.Object, skip_name: Optional[str] = None) -> Optional[ParametricObject]:
"""Return the registry entry whose edit lifecycle is active on ``obj``, or None.
``skip_name`` excludes one entry from the scan, for callers that want
to know if a *different* type is editing."""
for feature in cls.EDIT_TYPES:
if feature.name == skip_name:
continue
if feature.is_editing(obj):
return feature
return None
@classmethod
def _validated_editing_feature(cls, obj: bpy.types.Object) -> Optional[ParametricObject]:
"""Return the active registry entry on ``obj``, validated against the
per-type predicate. Returns None when no ``is_editing`` flag is set
or when the flag is stale.
Self-heals: a predicate mismatch clears the flag in place so the
finish dispatch never re-picks up a phantom edit."""
feature = cls.is_object_editing(obj)
if feature is None:
return None
element = tool.Ifc.get_entity(obj)
if element is None or not cls._safe_predicate(feature, element):
getattr(obj, feature.props_attr).is_editing = False
return None
return feature
@classmethod
def heal_stale_edit_flags(cls) -> None:
"""Validate every scene object's ``is_editing`` flag against the
per-type predicate, clearing stale flags in place.
Run from ``load_post`` so a ``.blend`` saved with phantom flags
(e.g. a save that bypassed the auto-commit flush) is consistent the
moment it opens."""
for obj in bpy.data.objects:
cls._validated_editing_feature(obj)
@classmethod
def on_load_post(cls, scene: bpy.types.Scene) -> None:
"""Drain load-transient parametric state on a freshly opened scene
so no draft edit flag, preview flag, or cache entry persists from
the saved file."""
from bonsai.bim.module.model import wall_offset_gizmos
from bonsai.bim.module.model.preview_base import discard_pending_previews
cls.heal_stale_edit_flags()
discard_pending_previews(scene)
wall_offset_gizmos.clear_caches()
@classmethod
def get_pending_edits(cls) -> list[tuple[bpy.types.Object, str]]:
"""``(object, finish_operator_bl_idname)`` pairs for every object
with an in-progress parametric draft. Stale flags are cleared in
place and excluded."""
pending: list[tuple[bpy.types.Object, str]] = []
for obj in bpy.data.objects:
feature = cls._validated_editing_feature(obj)
if feature is not None:
pending.append((obj, feature.finish_op))
return pending
@classmethod
def run_bim_op(cls, bl_idname: str) -> None:
"""Invoke a ``bim.*`` operator by ``bl_idname``.
Asserts the operator is a ``tool.Ifc.Operator`` subclass bypassing
that wrap would mutate IFC outside Bonsai's transaction system."""
verb = bl_idname.removeprefix("bim.")
op_cls = getattr(bpy.types, f"BIM_OT_{verb}", None)
if op_cls is None or not issubclass(op_cls, tool.Ifc.Operator):
raise RuntimeError(
f"{bl_idname!r} must be a registered tool.Ifc.Operator subclass for undo-safe IFC mutation"
)
getattr(bpy.ops.bim, verb)()
@classmethod
def commit_object_draft(cls, obj: bpy.types.Object, finish_op: str) -> bool:
"""Run ``finish_op`` scoped to ``obj`` alone. Returns False (with
traceback printed) if the operator raised.
Both ``temp_override`` and ``view_layer.objects.active`` are set:
``temp_override`` does not rebind ``objects.active``, and some finish
operators read it directly."""
view_layer = bpy.context.view_layer
original_active = view_layer.objects.active
try:
with bpy.context.temp_override(active_object=obj, selected_objects=[obj]):
view_layer.objects.active = obj
try:
cls.run_bim_op(finish_op)
return True
except Exception:
logger.warning(
"commit of %r via %s failed",
obj.name,
finish_op,
exc_info=True,
)
return False
finally:
view_layer.objects.active = original_active
@classmethod
def commit_pending_edits(cls) -> tuple[int, list[bpy.types.Object]]:
"""Run each pending draft's finish operator scoped to its object.
A per-object failure does not abort the loop remaining drafts
still flush, otherwise the auto-commit would ship the exact silent
desync it exists to prevent."""
committed = 0
failed: list[bpy.types.Object] = []
for obj, finish_op in cls.get_pending_edits():
if cls.commit_object_draft(obj, finish_op):
committed += 1
else:
failed.append(obj)
return committed, failed
@classmethod
def commit_pending_edits_for_selection(
cls, names: Optional[tuple[str, ...]] = None
) -> tuple[int, list[bpy.types.Object]]:
"""Selection-scoped variant. ``names`` filters which registry entries
to consider; ``None`` considers every type."""
committed = 0
failed: list[bpy.types.Object] = []
for obj in tool.Blender.get_selected_objects():
feature = cls._validated_editing_feature(obj)
if feature is None:
continue
if names is not None and feature.name not in names:
continue
if cls.commit_object_draft(obj, feature.finish_op):
committed += 1
else:
failed.append(obj)
return committed, failed
@classmethod
def _assert_predicates_registered(cls) -> None:
"""Loud at addon-enable if any ``EDIT_TYPES`` entry has no matching
``is_<name>`` classmethod. Without this, a typo in the registry entry
produces a silent-False predicate that never matches every
parametric draft of that type bypasses save-flow auto-commit."""
missing = [feature.name for feature in cls.EDIT_TYPES if not callable(getattr(cls, f"is_{feature.name}", None))]
if missing:
raise RuntimeError(
f"tool.Parametric.EDIT_TYPES has entries with no is_<name> predicate: {missing}. "
f"Add `is_<name>(cls, element) -> bool` classmethods on tool.Parametric, "
f"or remove the entries from EDIT_TYPES."
)
@classmethod
def register_object_properties(cls, prop_module) -> None:
"""Attach ``bpy.types.Object.BIM<Name>Properties`` for every registered
parametric type. Skips entries whose ``PropertyGroup`` is absent."""
cls._assert_predicates_registered()
for feature in cls.EDIT_TYPES:
prop_cls = getattr(prop_module, feature.props_attr, None)
if prop_cls is None:
continue
setattr(bpy.types.Object, feature.props_attr, bpy.props.PointerProperty(type=prop_cls))
@classmethod
def unregister_object_properties(cls) -> None:
for feature in cls.EDIT_TYPES:
if hasattr(bpy.types.Object, feature.props_attr):
delattr(bpy.types.Object, feature.props_attr)
# --- Feature-kind predicates ------------------------------------------------
# One predicate per registered parametric type. Each is total: accepts any
# IFC entity (or None), returns a bool, never raises. Predicates live with
# the registry rather than ``tool.Blender.Modifier`` because they ARE the
# registry contract — ``find_for_element`` and ``_validated_editing_feature``
# resolve them by name. Coupling them on the same class makes a typo at
# registration time an immediate AttributeError instead of a silent None
# predicate that never matches.
@classmethod
def is_array(cls, element: entity_instance) -> bool:
"""True if element is the PARENT of a Bonsai parametric array.
Array children also carry a ``BBIM_Array`` pset (their ``Parent``
field points back to the original), so checking pset presence alone
would falsely match them. The parent is distinguished by
``pset.Parent == element.GlobalId``."""
import ifcopenshell.util.element
if element is None:
return False
pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
if not pset:
return False
return pset.get("Parent") == element.GlobalId
@classmethod
def is_railing(cls, element: entity_instance) -> bool:
if element is None:
return False
return tool.Pset.get_element_pset(element, "BBIM_Railing") is not None
@classmethod
def is_roof(cls, element: entity_instance) -> bool:
if element is None:
return False
return tool.Pset.get_element_pset(element, "BBIM_Roof") is not None
@classmethod
def is_window(cls, element: entity_instance) -> bool:
if element is None:
return False
return tool.Pset.get_element_pset(element, "BBIM_Window") is not None
@classmethod
def is_door(cls, element: entity_instance) -> bool:
if element is None:
return False
return tool.Pset.get_element_pset(element, "BBIM_Door") is not None
@classmethod
def is_stair(cls, element: entity_instance) -> bool:
if element is None:
return False
return tool.Pset.get_element_pset(element, "BBIM_Stair") is not None
@classmethod
def is_wall(cls, element: entity_instance) -> bool:
"""A wall is editable by the parametric gizmo if it is an IfcWall with LAYER2 usage.
Unlike doors/windows/stairs, walls do not carry a proprietary BBIM_Wall pset
their parametric state lives in standard IFC (axis polyline, IfcMaterialLayerSetUsage,
IfcExtrudedAreaSolid). Any LAYER2 wall qualifies."""
if element is None or not element.is_a("IfcWall"):
return False
return tool.Model.get_usage_type(element) == "LAYER2"
@classmethod
def is_path_connectable_wall(cls, element: entity_instance) -> bool:
"""An IfcWall that may participate in IfcRelConnectsPathElements joins —
either a LAYER2 parametric wall, or a fillet-corner wall whose body is
hand-built but whose axis still drives path connections.
Distinct from ``is_wall``: that predicate gates parametric edits that
would regenerate the body and flatten a curved fillet. Unjoin / join
gizmo polls and path-connection partner enumeration use this looser
predicate so fillet corners (which have no LAYER2 usage by spec) still
surface their join icons."""
if element is None or not element.is_a("IfcWall"):
return False
if tool.Model.get_usage_type(element) == "LAYER2":
return True
return cls.is_fillet_corner_wall(element)
@classmethod
def is_fillet_corner_wall(cls, element: entity_instance) -> bool:
"""``True`` if the wall carries the ``BBIM_Wall.IsFilletCorner`` flag,
marking it as a curved corner whose banana body is hand-built rather
than regenerated from the wall's axis + layer set."""
import ifcopenshell.util.element
return bool(ifcopenshell.util.element.get_pset(element, "BBIM_Wall", "IsFilletCorner"))
@classmethod
def is_pipe_segment(cls, element: entity_instance) -> bool:
return element is not None and element.is_a("IfcPipeSegment")
@classmethod
def is_duct_segment(cls, element: entity_instance) -> bool:
return element is not None and element.is_a("IfcDuctSegment")
@classmethod
def build_edit_lifecycle(
cls,
feature_name: str,
mixin: type,
labels: tuple[tuple[str, str], tuple[str, str], tuple[str, str]],
bl_options: Optional[set[str]] = None,
enable_extra_props: Optional[dict[str, Any]] = None,
enable_extra_kwargs: Optional[Callable[[Any], dict[str, Any]]] = None,
module_name: Optional[str] = None,
) -> tuple[type, type, type]:
"""Generate (Enable, Finish, Cancel) operator classes for a parametric type.
``mixin`` provides ``_enable_targets`` / ``_finish_targets`` /
``_cancel_targets`` (i.e. inherits from ``ParametricEditMixinBase`` or
a sibling). ``labels`` is ``((enable_label, enable_desc), )`` in
Enable / Finish / Cancel order.
``bl_idname`` and the Python class name come from the registry entry
``feature_name`` MUST already be in ``EDIT_TYPES``, otherwise a typo
produces an unregistered operator. Anchoring bl_idnames to the registry
eliminates the silent-mismatch failure mode where a hand-typed
``bl_idname = "bim.enable_editing_dor"`` produces a class that
``find_for_element`` never resolves to.
``enable_extra_props`` declares extra ``bpy.props.*`` descriptors to
attach to the Enable class only (e.g. array's ``item: IntProperty``
carrying the target layer index across redo). When set,
``enable_extra_kwargs`` must also be supplied: it receives the Enable
operator instance and returns a kwargs dict forwarded to
``_enable_targets`` so the mixin's enable phase sees the extras.
``module_name`` sets ``__module__`` on the generated classes pass
``__name__`` from the calling feature module so Blender's right-click
Edit Source resolves to the feature module rather than the factory
site. Defaults to the factory's module, which is sub-optimal for
debugging but harmless."""
import bonsai.tool as _tool # late import: tool/__init__.py wires this module last
feature = cls.find_by_name(feature_name)
if feature is None:
raise RuntimeError(
f"build_edit_lifecycle: {feature_name!r} not in EDIT_TYPES — add a "
f"ParametricObject entry before declaring its operators"
)
if not feature.supports_build_edit_lifecycle:
raise RuntimeError(
f"build_edit_lifecycle: {feature_name!r} has supports_build_edit_lifecycle=False — "
f"its edit lifecycle is bespoke. Either declare "
f"Enable/Finish/CancelEditing{_camel_case(feature_name)} as direct Operator "
f"subclasses, or flip the flag on the EDIT_TYPES entry if the type does fit "
f"the shared mixin contract."
)
if (enable_extra_props is None) != (enable_extra_kwargs is None):
raise RuntimeError(
f"build_edit_lifecycle({feature_name!r}): enable_extra_props and "
f"enable_extra_kwargs must be supplied together — extras with no "
f"kwargs builder are unreachable, kwargs with no extras have nothing to forward"
)
options = bl_options if bl_options is not None else {"REGISTER", "UNDO"}
base_classes = (mixin, bpy.types.Operator, _tool.Ifc.Operator)
capitalised = _camel_case(feature_name)
def _build(
action: str, bl_idname: str, label: str, desc: str, target_method: str, extras: Optional[dict]
) -> type:
if extras and target_method == "_enable_targets":
assert enable_extra_kwargs is not None
kwargs_builder = enable_extra_kwargs
def _execute(self, context: bpy.types.Context) -> set[str]:
return getattr(self, target_method)(context, **kwargs_builder(self))
else:
def _execute(self, context: bpy.types.Context) -> set[str]:
return getattr(self, target_method)(context)
attrs: dict[str, Any] = {
"bl_idname": bl_idname,
"bl_label": label,
"bl_description": desc,
"bl_options": options,
"_execute": _execute,
}
if module_name is not None:
attrs["__module__"] = module_name
if extras:
# Blender's PropertyGroup machinery reads __annotations__ for bpy.props descriptors.
attrs["__annotations__"] = dict(extras)
return type(f"{action}Editing{capitalised}", base_classes, attrs)
return (
_build("Enable", feature.enable_op, labels[0][0], labels[0][1], "_enable_targets", enable_extra_props),
_build("Finish", feature.finish_op, labels[1][0], labels[1][1], "_finish_targets", None),
_build("Cancel", feature.cancel_op, labels[2][0], labels[2][1], "_cancel_targets", None),
)
_edit_type_names = [entry.name for entry in Parametric.EDIT_TYPES]
if len(set(_edit_type_names)) != len(_edit_type_names):
raise RuntimeError(
f"EDIT_TYPES name collision: {_edit_type_names}. Each name is the primary key "
f"for derived bl_idnames, BIM<Name>Properties attributes, is_<name> predicates, "
f"and the uppercase constant — a duplicate silently shadows the first entry."
)
del _edit_type_names
# Bind every registered ParametricObject as an uppercase class attribute so
# call sites can reference ``tool.Parametric.ROOF`` directly. Renaming a
# registry entry renames the constant; a typo at the call site surfaces as
# AttributeError at module load.
for _entry in Parametric.EDIT_TYPES:
setattr(Parametric, _entry.name.upper(), _entry)
del _entry
+30
View File
@@ -18,10 +18,12 @@
from __future__ import annotations
import json
from typing import TYPE_CHECKING, Any, Literal, Union, assert_never
import bpy
import ifcopenshell
import ifcopenshell.api.pset
import ifcopenshell.util.attribute
import ifcopenshell.util.element
@@ -74,6 +76,34 @@ class Pset(bonsai.core.tool.Pset):
if pset:
return tool.Ifc.get().by_id(pset["id"])
@classmethod
def upsert_pset(
cls,
element: ifcopenshell.entity_instance,
pset_name: str,
properties: dict[str, Any],
) -> ifcopenshell.entity_instance:
"""Get or create ``pset_name`` on ``element``, write ``properties``, return the pset.
Centralises the get-element-pset add-pset-if-missing edit-pset idiom."""
ifc_file = tool.Ifc.get()
pset = cls.get_element_pset(element, pset_name)
if not pset:
pset = ifcopenshell.api.pset.add_pset(ifc_file, product=element, name=pset_name)
ifcopenshell.api.pset.edit_pset(ifc_file, pset=pset, properties=properties)
return pset
@classmethod
def write_bbim_data(
cls,
element: ifcopenshell.entity_instance,
pset_name: str,
data: dict[str, Any],
) -> ifcopenshell.entity_instance:
"""Get or create the BBIM_<Type> pset and write ``data`` as the IfcText-serialised
JSON ``Data`` property. Canonical writer for parametric-modifier pset state."""
data_text = tool.Ifc.get().createIfcText(json.dumps(data, default=list))
return cls.upsert_pset(element, pset_name, {"Data": data_text})
@classmethod
def get_pset_props(cls, obj: str, obj_type: tool.Ifc.OBJECT_TYPE) -> PsetProperties:
if obj_type == "Object":
+138 -50
View File
@@ -373,26 +373,38 @@ class Raycast(bonsai.core.tool.Raycast):
except:
loc = Vector((0, 0, 0))
verts_2d = [
view3d_utils.location_3d_to_region_2d(region, rv3d, v) for v in snap_obj.verts_3d
] # Numpy version is worst in performance
snap_obj._ensure_bvh()
intersected = snap_obj.raycast_boxes(
context, event, snap_obj.root, intersected=[], rays=(ray_origin, ray_direction)
)
# Collect edges from intersected BVH boxes
edges = []
for it in intersected:
edges.extend(it.edges)
edges = set(edges)
# Build only the vertices indices that belong to these edges
verts_idx: set[int] = set()
for e in edges:
ev = snap_obj.obj.data.edges[e].vertices
verts_idx.add(ev[0])
verts_idx.add(ev[1])
# Lazily project only the needed vertices to 2D screen space
verts_2d: dict[int, Vector] = {}
for idx in verts_idx:
v2d = view3d_utils.location_3d_to_region_2d(region, rv3d, snap_obj.verts_3d[idx])
if v2d is not None:
verts_2d[idx] = v2d
edge_verts = {}
for e in edges:
verts_idx = tuple(snap_obj.obj.data.edges[e].vertices)
verts = snap_obj.obj.data.vertices
v1 = snap_obj.obj.matrix_world @ verts[verts_idx[0]].co
v1_2d = verts_2d[verts_idx[0]]
v2 = snap_obj.obj.matrix_world @ verts[verts_idx[1]].co
v2_2d = verts_2d[verts_idx[1]]
verts_idx = snap_obj.obj.data.edges[e].vertices
v1 = snap_obj.verts_3d[verts_idx[0]]
v2 = snap_obj.verts_3d[verts_idx[1]]
v1_2d = verts_2d.get(verts_idx[0])
v2_2d = verts_2d.get(verts_idx[1])
if (v1_2d is None) ^ (v2_2d is None):
point, _ = cls.intersect_edge_region_border(region, context.space_data, rv3d, v1, v2)
if v1_2d is None:
@@ -404,10 +416,16 @@ class Raycast(bonsai.core.tool.Raycast):
snap_threshold = 10.0
for i, point in enumerate(verts_2d):
if not point:
continue
distance = (Vector(mouse_pos) - point).length
# Check all vertices for proximity to mouse position.
# Re-use the 2D projections already computed for edge endpoints.
for i, v3d in enumerate(snap_obj.verts_3d):
if i in verts_2d:
v2d = verts_2d[i]
else:
v2d = view3d_utils.location_3d_to_region_2d(region, rv3d, v3d)
if v2d is None:
continue
distance = (Vector(mouse_pos) - v2d).length
if distance <= snap_threshold:
snap_point = {
"object": snap_obj.obj,
@@ -799,6 +817,30 @@ class Raycast(bonsai.core.tool.Raycast):
else:
return None, None, None
@classmethod
def process_wireframe_snap_obj(
cls,
context: bpy.types.Context,
event: bpy.types.Event,
snap_obj,
ray_origin: Vector,
closest_snaps: list,
):
snap_points = tool.Raycast.ray_cast_by_proximity_2d(context, event, snap_obj)
hit_obj = None
hit = None
if snap_points:
closest_length_squared = float("inf")
for point in snap_points:
point["group"] = "Wireframe"
closest_snaps.append(point)
length = (point["point"] - ray_origin).length_squared
if length < closest_length_squared:
closest_length_squared = length
hit = point["point"]
hit_obj = point["object"]
return hit_obj, hit
@classmethod
def ray_cast_and_get_closest_to_camera_snaps(
cls,
@@ -813,35 +855,43 @@ class Raycast(bonsai.core.tool.Raycast):
ray_origin, ray_target, ray_direction = cls.get_viewport_ray_data(context, event)
space = context.space_data
xray_mode = (space.shading.type == "SOLID" and space.shading.show_xray) or (
space.shading.type == "WIREFRAME" and space.shading.show_xray_wireframe
)
closest_snaps = []
hit = None
for snap_obj in objs_to_raycast:
if snap_obj.obj.type in {"EMPTY", "CURVE"} or (
hasattr(snap_obj.obj.data, "polygons") and len(snap_obj.obj.data.polygons) == 0
):
# For wireframe objects we have to test all the snaps to see which is closer
snap_points = tool.Raycast.ray_cast_by_proximity_2d(context, event, snap_obj)
closest_wf_hit = None
closest_wf_length_squared = 1.0
closest_wf_point = None
if snap_points:
for point in snap_points:
point["group"] = "Wireframe"
closest_snaps.append(point)
length = (point["point"] - ray_origin).length_squared
if closest_wf_hit is None or length < closest_wf_length_squared:
closest_wf_length_squared = length
closest_wf_hit = point["point"]
closest_wf_point = point
if not xray_mode and objs_to_raycast:
# Non-xray - only the closest solid object's Face snap is kept by
# the caller (detect_snapping_points). Process solids in distance
# order and stop at the first hit to minimise raycasts.
wireframe_objs = []
solid_objs = []
for snap_obj in objs_to_raycast:
if snap_obj.obj.type in {"EMPTY", "CURVE"} or (
hasattr(snap_obj.obj.data, "polygons") and len(snap_obj.obj.data.polygons) == 0
):
wireframe_objs.append(snap_obj)
else:
solid_objs.append(snap_obj)
if closest_wf_point:
hit_obj = closest_wf_point["object"]
hit = closest_wf_point["point"]
face_index = None
# Rough distance - object origin to ray origin
solid_objs.sort(key=lambda so: (so.obj.matrix_world.translation - ray_origin).length_squared)
else:
# Solid objects
# Process wireframe objects first (all of them, always collected)
for snap_obj in wireframe_objs:
hit_obj, hit = cls.process_wireframe_snap_obj(context, event, snap_obj, ray_origin, closest_snaps)
if hit is not None:
length_squared = (hit - ray_origin).length_squared
if closest_obj is None or length_squared < closest_length_squared:
closest_length_squared = length_squared
closest_obj = hit_obj
closest_hit = hit
closest_face_index = None
# Process solid objects in distance order, stop at first hit
for snap_obj in solid_objs:
hit_obj, hit, face_index = cls.cast_rays_to_single_object(context, event, snap_obj.obj)
if hit:
@@ -855,14 +905,45 @@ class Raycast(bonsai.core.tool.Raycast):
}
closest_snaps.append(snap_point)
# Here we test which is closer, including wireframe and solid objects
if hit is not None:
length_squared = (hit - ray_origin).length_squared
if closest_obj is None or length_squared < closest_length_squared:
closest_length_squared = length_squared
closest_obj = hit_obj
closest_hit = hit
closest_face_index = face_index
length_squared = (hit - ray_origin).length_squared
if closest_obj is None or length_squared < closest_length_squared:
closest_length_squared = length_squared
closest_obj = hit_obj
closest_hit = hit
closest_face_index = face_index
break
else:
# Xray mode - process all objects (all snaps are kept by the caller)
for snap_obj in objs_to_raycast:
if snap_obj.obj.type in {"EMPTY", "CURVE"} or (
hasattr(snap_obj.obj.data, "polygons") and len(snap_obj.obj.data.polygons) == 0
):
hit_obj, hit = cls.process_wireframe_snap_obj(context, event, snap_obj, ray_origin, closest_snaps)
face_index = None
else:
# Solid objects
hit_obj, hit, face_index = cls.cast_rays_to_single_object(context, event, snap_obj.obj)
if hit:
snap_point = {
"point": hit,
"type": "Face",
"group": "Object",
"object": hit_obj,
"face_index": face_index,
"distance": 9, # High value so it has low priority
}
closest_snaps.append(snap_point)
if hit is not None:
length_squared = (hit - ray_origin).length_squared
if closest_obj is None or length_squared < closest_length_squared:
closest_length_squared = length_squared
closest_obj = hit_obj
closest_hit = hit
closest_face_index = face_index
# Label snaps from the closest object
if closest_obj is not None:
@@ -936,12 +1017,19 @@ class SnapObj:
def __init__(self, obj: bpy.types.Object):
self.__class__.all.append(self)
self.obj = obj
self.root = self._create_root_node()
self.root.edges = [e.index for e in obj.data.edges]
self.split_box(self.root, 0)
self.root = None
self._bvh_built = False
self.verts_3d = [obj.matrix_world @ v.co for v in obj.data.vertices]
self.snap_points = []
def _ensure_bvh(self):
if self._bvh_built:
return
self.root = self._create_root_node()
self.root.edges = [e.index for e in self.obj.data.edges]
self.split_box(self.root, 0)
self._bvh_built = True
def __clear_all__():
for instance in SnapObj.all:
del instance
+13 -1
View File
@@ -71,6 +71,18 @@ class Root(bonsai.core.tool.Root):
should_use_presentation_style_assignment=props.should_use_presentation_style_assignment,
)
@classmethod
def has_material_styles(cls, element: ifcopenshell.entity_instance) -> bool:
"""``True`` if any constituent material on ``element`` carries a style
representation. Body styles should NOT be applied directly when this
is True the material-inherited style is the authoritative source.
Paired with ``assign_body_styles``: callers check this first and only
call ``assign_body_styles`` when it returns False."""
materials = ifcopenshell.util.element.get_materials(element)
if not materials:
return False
return any(getattr(m, "HasRepresentation", None) for m in materials)
@classmethod
def copy_representation(
cls, source: ifcopenshell.entity_instance, dest: ifcopenshell.entity_instance
@@ -381,7 +393,7 @@ class Root(bonsai.core.tool.Root):
# 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.Blender.Modifier.Array.get_all_children_objects(new[0])
array_children = tool.Array.get_all_children_objects(new[0])
for obj in array_children:
bonsai.core.aggregate.assign_object(
tool.Ifc,
+74
View File
@@ -0,0 +1,74 @@
# 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.
"""Side-effect-free slab helpers — IFC reads for LAYER3 extrusions.
Exposes ``read_geometry``: a single live read of the parametric attributes
(extrusion depth and slope) that drive icon placement and dimension display
on a LAYER3 slab. Lives in ``tool/`` so bim-layer callers can stay
declarative they get a dict, not an IFC walk."""
from __future__ import annotations
from typing import TYPE_CHECKING, TypedDict
import ifcopenshell.util.unit
import bonsai.core.tool
import bonsai.tool as tool
if TYPE_CHECKING:
import bpy
class SlabGeometry(TypedDict):
depth: float
x_angle: float
class Slab(bonsai.core.tool.Slab):
@classmethod
def read_geometry(cls, obj: bpy.types.Object) -> SlabGeometry | None:
"""Live-read slab parametric geometry as a dict, or ``None`` if the
object is not a LAYER3 extruded slab.
Returned keys (all SI units): ``depth`` (extrusion thickness along the
slab's local Z), ``x_angle`` (slope in radians; zero for level slabs).
The slope is encoded in ``obj.matrix_world`` as a post-rotation, so
callers projecting world points into slab-local space via
``mw.inverted()`` will see a level frame whose Z runs along the slab
thickness ``x_angle`` is reported for callers that need the slope
as a scalar but is already applied by the placement."""
element = tool.Ifc.get_entity(obj)
if not element or not tool.Blender.Modifier.is_slab(element):
return None
representation = tool.Geometry.get_body_representation(element)
if not representation:
return None
extrusion = tool.Model.get_extrusion(representation)
if not extrusion:
return None
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
x_angle = tool.Model.get_existing_x_angle(extrusion)
return {
"depth": extrusion.Depth * unit_scale,
"x_angle": x_angle,
}
+26
View File
@@ -90,6 +90,32 @@ class Spatial(bonsai.core.tool.Spatial):
break
return element
@classmethod
def get_host_element(cls, filling: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance | None:
"""The building element that hosts a filling (door/window) via the
standard ``FillsVoids RelatingOpeningElement VoidsElements
RelatingBuildingElement`` chain, with safety guards at each hop.
Returns ``None`` if any link is missing, or if the given entity is
not a fillable type (no ``FillsVoids`` inverse).
For the wall-only case (gizmos that only make sense on walls), use
`get_host_wall` which adds an ``IfcWall`` type filter on top of this."""
if not getattr(filling, "FillsVoids", None):
return None
opening = filling.FillsVoids[0].RelatingOpeningElement
if not opening.VoidsElements:
return None
return opening.VoidsElements[0].RelatingBuildingElement
@classmethod
def get_host_wall(cls, filling: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance | None:
"""The ``IfcWall`` that hosts a filling (door/window), or ``None``.
Walls only fillings hosted in slabs / roofs / arbitrary elements
produce ``None`` so wall-offset callers stay opted out cleanly."""
host = cls.get_host_element(filling)
return host if host and host.is_a("IfcWall") else None
@classmethod
def can_contain(cls, container: ifcopenshell.entity_instance, element: ifcopenshell.entity_instance) -> bool:
if tool.Ifc.get_schema() == "IFC2X3":
+118 -22
View File
@@ -19,6 +19,7 @@
from __future__ import annotations
import re
from collections import deque
from enum import Enum
from typing import TYPE_CHECKING, Any, Optional, Union
@@ -26,6 +27,7 @@ import bpy
import ifcopenshell.api.geometry
import ifcopenshell.api.system
import ifcopenshell.util.element
import ifcopenshell.util.placement
import ifcopenshell.util.system
from mathutils import Matrix, Vector
@@ -35,12 +37,29 @@ import bonsai.core.root
import bonsai.core.tool
import bonsai.tool as tool
from bonsai.bim import import_ifc
from bonsai.bim.module.system.data import ObjectSystemData, SystemDecorationData
# Data-class imports from ``bonsai.bim.module.system.data`` are function-local:
# a top-level import would trigger a partial-init cycle through tool.Ifc.Operator.
if TYPE_CHECKING:
from bonsai.bim.module.system.prop import BIMSystemProperties, BIMZoneProperties
_DIRECTION_FROM_FLOW_PAIR: dict[tuple[str, str], str] = {
("SOURCE", "SINK"): "SOURCE",
("SINK", "SOURCE"): "SINK",
("SOURCEANDSINK", "SOURCEANDSINK"): "SOURCEANDSINK",
}
def direction_from_port_pair(port_a: ifcopenshell.entity_instance, port_b: ifcopenshell.entity_instance) -> str:
"""Derive the ``direction`` arg for ``ifcopenshell.api.system.connect_port``
from each port's ``FlowDirection``. Returns ``NOTDEFINED`` for non-canonical pairs."""
a = getattr(port_a, "FlowDirection", None) or "NOTDEFINED"
b = getattr(port_b, "FlowDirection", None) or "NOTDEFINED"
return _DIRECTION_FROM_FLOW_PAIR.get((a, b), "NOTDEFINED")
class System(bonsai.core.tool.System):
@classmethod
def get_system_props(cls) -> BIMSystemProperties:
@@ -81,7 +100,7 @@ class System(bonsai.core.tool.System):
# make sure obj.dimensions and .matrix_world has valid data
bpy.context.view_layer.update()
# need to make sure .ObjectPlacement is also updated when we're going to add ports
tool.Model.sync_object_ifc_position(obj)
tool.Geometry.commit_placement_if_moved(obj)
mep_element = tool.Ifc.get_entity(obj)
bbox = tool.Blender.get_object_bounding_box(obj)
@@ -162,12 +181,12 @@ class System(bonsai.core.tool.System):
return ifcopenshell.util.system.get_ports(element)
@classmethod
def get_port_relating_element(cls, port: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance:
def get_port_relating_element(cls, port: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]:
if tool.Ifc.get_schema() == "IFC2X3":
element = port.ContainedIn[0].RelatedElement
else:
element = port.Nests[0].RelatingObject
return element
rel = port.ContainedIn[0] if port.ContainedIn else None
return rel.RelatedElement if rel else None
rel = port.Nests[0] if port.Nests else None
return rel.RelatingObject if rel else None
@classmethod
def get_port_predefined_type(cls, mep_element: ifcopenshell.entity_instance) -> str:
@@ -280,31 +299,42 @@ class System(bonsai.core.tool.System):
system_props = cls.get_system_props()
return tool.Ifc.get_entity_by_id(system_props.active_system_id)
# Decoration-data cache, keyed on (decorator_cache_token, id(decorated_elements_set)).
_decoration_data_cache_key: tuple | None = None
_decoration_data_cache: dict[str, Any] | None = None
@classmethod
def get_decoration_data(cls) -> dict[str, Any]:
from bonsai.bim.decorator_cache import get_decorator_cache_token
from bonsai.bim.module.system.data import ObjectSystemData, SystemDecorationData
if not ObjectSystemData.is_loaded:
ObjectSystemData.load()
if not SystemDecorationData.is_loaded:
SystemDecorationData.load()
token = get_decorator_cache_token()
key = (token, id(SystemDecorationData.data["decorated_elements"]))
if key == cls._decoration_data_cache_key and cls._decoration_data_cache is not None:
return cls._decoration_data_cache
result = cls._build_decoration_data()
cls._decoration_data_cache_key = key
cls._decoration_data_cache = result
return result
@classmethod
def _build_decoration_data(cls) -> dict[str, Any]:
from bonsai.bim.module.system.data import ObjectSystemData, SystemDecorationData
all_vertices = []
preview_edges = []
special_vertices = []
selected_edges = []
selected_vertices = []
view3d_space = tool.Blender.get_viewport_context()["space_data"].region_3d
viewport_matrix = view3d_space.view_matrix.inverted()
viewport_y_axis = viewport_matrix.col[1].to_3d().normalized()
camera_pos = viewport_matrix.translation
dir_to_camera = lambda x: (camera_pos - x).normalized()
def most_aligned_vector(a, vectors):
return max(vectors, key=lambda v: abs(a.dot(v)))
start_vert_i = 0
if not ObjectSystemData.is_loaded:
ObjectSystemData.load()
if not SystemDecorationData.is_loaded:
SystemDecorationData.load()
class FlowDirection(Enum):
BACKWARD = -1
FORWARD = 1
@@ -458,6 +488,72 @@ class System(bonsai.core.tool.System):
def is_mep_element(cls, element: ifcopenshell.entity_instance) -> bool:
return element.is_a("IfcFlowSegment") or element.is_a("IfcFlowFitting")
@classmethod
def walk_connected_mep_elements(
cls, start_element: ifcopenshell.entity_instance
) -> list[ifcopenshell.entity_instance]:
"""Return all MEP elements reachable from ``start_element`` via
``IfcRelConnectsPorts`` in either direction, in BFS order with
``start_element`` first.
Only ``IfcFlowSegment`` and ``IfcFlowFitting`` instances are
returned; non-MEP neighbours reached via a fitting's port are
traversed but not collected.
"""
if not cls.is_mep_element(start_element):
return []
result: list[ifcopenshell.entity_instance] = []
visited: set[int] = set()
queue: deque[ifcopenshell.entity_instance] = deque([start_element])
while queue:
element = queue.popleft()
if element.id() in visited:
continue
visited.add(element.id())
if not cls.is_mep_element(element):
continue
result.append(element)
for port in cls.get_ports(element):
connected_port = cls.get_connected_port(port)
if connected_port is None:
continue
neighbor = cls.get_port_relating_element(connected_port)
if neighbor is None or neighbor.id() in visited:
continue
queue.append(neighbor)
return result
@classmethod
def get_port_world_position(cls, port: ifcopenshell.entity_instance) -> Vector:
"""World-space position of an ``IfcDistributionPort``.
Follows the parent element's live ``matrix_world`` when available so
an uncommitted rotation doesn't drift from its ports; falls back to
the raw IFC placement otherwise."""
placement = getattr(port, "ObjectPlacement", None)
if placement is None:
return Vector((0.0, 0.0, 0.0))
port_ifc_matrix = Matrix(ifcopenshell.util.placement.get_local_placement(placement).tolist())
parent_element = cls.get_port_relating_element(port)
if parent_element is None:
return Vector(port_ifc_matrix.translation)
parent_obj = tool.Ifc.get_object(parent_element)
if parent_obj is None:
return Vector(port_ifc_matrix.translation)
parent_placement = getattr(parent_element, "ObjectPlacement", None)
if parent_placement is None:
return Vector(port_ifc_matrix.translation)
parent_ifc_matrix = Matrix(ifcopenshell.util.placement.get_local_placement(parent_placement).tolist())
try:
port_local_to_parent = parent_ifc_matrix.inverted() @ port_ifc_matrix
except ValueError:
return Vector(port_ifc_matrix.translation)
return (parent_obj.matrix_world @ port_local_to_parent).translation
@classmethod
def get_flow_element_controls(cls, element: ifcopenshell.entity_instance) -> list[ifcopenshell.entity_instance]:
if not element.HasControlElements:
+2 -2
View File
@@ -199,8 +199,8 @@ class Unit(bonsai.core.tool.Unit):
if inches is None:
inches = 0
# If feet is negative, inches should also be negative (subtractive)
if feet < 0:
# If feet is negative (including -0), inches should also be negative (subtractive)
if math.copysign(1, feet) < 0:
inches = -inches
# Convert to meters
+327
View File
@@ -0,0 +1,327 @@
# 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.
"""Side-effect-free wall helpers — IFC reads and wall-axis geometry, callable from
gizmo lambdas without loading the wall's draft props. The world-space geometry helpers
are pure-math wrappers over ``bonsai.core.model``."""
from __future__ import annotations
from collections import deque
from typing import TYPE_CHECKING, TypedDict
import ifcopenshell
import ifcopenshell.util.element
import ifcopenshell.util.representation
import ifcopenshell.util.unit
from mathutils import Vector
import bonsai.core.model
import bonsai.core.tool
import bonsai.tool as tool
if TYPE_CHECKING:
import bpy
class WallGeometry(TypedDict):
anchor_x: float
length: float
height: float
x_angle: float
thickness: float
offset: float
class Wall(bonsai.core.tool.Wall):
@classmethod
def get_length_and_height(cls, wall: ifcopenshell.entity_instance) -> tuple[float, float] | None:
"""SI length and vertical height of a LAYER2 extruded wall, or ``None`` for
non-parametric bodies (sweeps, brep, non-extrusion booleans)."""
representation = tool.Geometry.get_body_representation(wall)
if not representation:
return None
extrusion = tool.Model.get_extrusion(representation)
if not extrusion:
return None
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
p1, p2 = ifcopenshell.util.representation.get_reference_line(wall)
x_angle = tool.Model.get_existing_x_angle(extrusion)
return bonsai.core.model.length_and_height_from_extrusion(
extrusion_depth=extrusion.Depth,
x_angle=x_angle,
reference_line_x_extent=p2[0] - p1[0],
unit_scale=unit_scale,
)
@classmethod
def get_axis_local_extent(cls, wall: ifcopenshell.entity_instance) -> tuple[float, float] | None:
"""``(min_x, max_x)`` of the wall's IFC reference line in wall-local SI metres,
or ``None``. Anchors wall-edge gizmos at IFC-authoritative ends ``obj.bound_box``
would drift on trimmed walls or walls with end openings."""
representation = tool.Geometry.get_body_representation(wall)
if not representation:
return None
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
p1, p2 = ifcopenshell.util.representation.get_reference_line(wall)
x1, x2 = p1[0] * unit_scale, p2[0] * unit_scale
return (min(x1, x2), max(x1, x2))
@classmethod
def get_x_angle(cls, wall: ifcopenshell.entity_instance) -> float | None:
"""Slanted-extrusion angle (radians) of a LAYER2 wall, zero for vertical walls,
``None`` for non-parametric bodies. Callers that assume wall-local Z == world Z
must gate on this being zero."""
representation = tool.Geometry.get_body_representation(wall)
if not representation:
return None
extrusion = tool.Model.get_extrusion(representation)
if not extrusion:
return None
return tool.Model.get_existing_x_angle(extrusion)
@classmethod
def read_geometry(cls, obj: bpy.types.Object) -> WallGeometry | None:
"""Live wall geometry from IFC in SI metres/radians, or ``None`` for
non-path-connectable walls. Shared by gizmo positioning and draft
initialisation. Fillet-corner walls carry their chord axis as the
reference line and report zero thickness / offset (material was
unassigned at construction); callers that need a layer-driven thickness
must gate on ``tool.Parametric.is_wall`` upstream."""
element = tool.Ifc.get_entity(obj)
if not element or not tool.Parametric.is_path_connectable_wall(element):
return None
representation = tool.Geometry.get_body_representation(element)
if not representation:
return None
extrusion = tool.Model.get_extrusion(representation)
if not extrusion:
return None
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
p1, p2 = ifcopenshell.util.representation.get_reference_line(element)
layer_params = tool.Model.get_material_layer_parameters(element)
x_angle = tool.Model.get_existing_x_angle(extrusion)
return {
"anchor_x": p1[0] * unit_scale,
"length": (p2[0] - p1[0]) * unit_scale,
"height": bonsai.core.model.vertical_height_from_extrusion_depth(extrusion.Depth * unit_scale, x_angle),
"x_angle": x_angle,
"thickness": layer_params["thickness"],
"offset": layer_params["offset"],
}
@classmethod
def collinear_boundary_world(cls, seg_a: tuple[Vector, Vector], seg_b: tuple[Vector, Vector]) -> Vector:
"""World-space midpoint of the closest endpoint pair across two wall axis segments —
the anchor for Merge/Unjoin gizmos on collinear or already-joined walls."""
return Vector(
bonsai.core.model.closest_endpoint_midpoint(
(tuple(seg_a[0]), tuple(seg_a[1])),
(tuple(seg_b[0]), tuple(seg_b[1])),
)
)
@classmethod
def path_connection_location_world(
cls,
seg_self: tuple[Vector, Vector],
self_conn_type: str,
seg_other: tuple[Vector, Vector],
other_conn_type: str,
parallel_threshold: float = bonsai.core.model.PARALLEL_DOT_THRESHOLD,
) -> Vector:
"""World-space physical join point of an ``IfcRelConnectsPathElements`` — an
endpoint for end-connected walls, the axis intersection for ATPATH junctions."""
return Vector(
bonsai.core.model.compute_path_connection_location(
(tuple(seg_self[0]), tuple(seg_self[1])),
self_conn_type,
(tuple(seg_other[0]), tuple(seg_other[1])),
other_conn_type,
parallel_threshold,
)
)
@classmethod
def validate_for_parametric_edit(cls, obj: bpy.types.Object) -> str | None:
"""``None`` if the wall is parametrically editable, else a user-facing string naming
the specific gap so the user can fix the precise blocker."""
element = tool.Ifc.get_entity(obj)
if not element:
return "Object is not an IFC element."
if not element.is_a("IfcWall"):
return f"Object is an {element.is_a()}, not an IfcWall."
if tool.Model.get_usage_type(element) != "LAYER2":
return (
"Wall has no IfcMaterialLayerSetUsage with LayerSetDirection AXIS2 (required for parametric editing)."
)
representation = tool.Geometry.get_body_representation(element)
if not representation:
return "Wall has no Model/Body/MODEL_VIEW representation to drive parametric dimensions."
if not tool.Model.get_extrusion(representation):
return (
"Wall body is not an IfcExtrudedAreaSolid "
"(e.g. a brep mesh or boolean result without a base extrusion)."
)
return None
@classmethod
def has_layer2_usage(cls, wall: ifcopenshell.entity_instance) -> bool:
"""True iff ``wall`` is a LAYER2 parametric wall (has ``IfcMaterialLayerSetUsage``
with ``LayerSetDirection == AXIS2``). Required by every parametric wall edit
non-LAYER2 walls (brep / freeform bodies) cannot be driven by axis + thickness."""
return tool.Model.get_usage_type(wall) == "LAYER2"
@classmethod
def is_straight_axis(cls, wall: ifcopenshell.entity_instance) -> bool:
"""True iff the wall's Axis representation is a single straight line segment.
Curved-axis walls (e.g. a fillet corner inserted between two straight walls)
report ``False`` so callers gate them out of operations that assume a straight
reference line. The check inspects the ``Plan/Axis/GRAPH_VIEW`` representation
when present; falls back to True when no Axis representation exists (the
``Body`` extrusion alone is implicitly straight)."""
axis_rep = ifcopenshell.util.representation.get_representation(wall, "Plan", "Axis", "GRAPH_VIEW")
if axis_rep is None or not axis_rep.Items:
return True
for item in axis_rep.Items:
if item.is_a("IfcPolyline"):
if len(item.Points) != 2:
return False
elif item.is_a("IfcIndexedPolyCurve"):
# An ``IfcIndexedPolyCurve`` is straight only when (a) its
# ``Points`` list holds exactly two points and (b) it has no
# ``Segments`` or only ``IfcLineIndex`` segments. Any ``IfcArcIndex``
# makes it curved.
segments = getattr(item, "Segments", None)
if segments:
for seg in segments:
if seg.is_a("IfcArcIndex"):
return False
point_list = item.Points
point_coords = getattr(point_list, "CoordList", None) if point_list else None
if point_coords and len(point_coords) > 2:
return False
else:
# Trimmed curve, composite curve, B-spline — definitely curved.
return False
return True
@classmethod
def get_world_reference_line(cls, obj: bpy.types.Object) -> tuple[Vector, Vector] | None:
"""World-space endpoints of the wall's IFC reference line, in Blender units.
Returns ``(p1, p2)`` as 3D vectors with the wall's local Z preserved.
Returns ``None`` when the wall has no IFC element or no IFC Axis
representation. Anchors to the IFC reference line, not the mesh bound
box, so it stays correct when the mesh is stale or trimmed past the
IFC axis endpoints."""
element = tool.Ifc.get_entity(obj)
if element is None or not tool.Geometry.has_axis_representation(element):
return None
p1, p2 = ifcopenshell.util.representation.get_reference_line(element)
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
local_p1 = Vector((p1[0] * unit_scale, p1[1] * unit_scale, 0.0))
local_p2 = Vector((p2[0] * unit_scale, p2[1] * unit_scale, 0.0))
return obj.matrix_world @ local_p1, obj.matrix_world @ local_p2
@classmethod
def walk_connected_walls(
cls,
start_element: ifcopenshell.entity_instance,
node_cap: int = 5000,
) -> list[ifcopenshell.entity_instance]:
"""BFS over ``IfcRelConnectsPathElements`` from ``start_element``.
Returns every ``IfcWall`` reachable in either direction (relating /
related side of the relation) in BFS order with ``start_element``
first. Stops when ``node_cap`` walls have been visited so a corrupt
or massive network can't lock up a draw callback. Non-wall path
elements (e.g. ``IfcRoof``, ``IfcSlab``) are traversed but not
collected they may bridge two disjoint wall runs.
Mirror of ``tool.System.walk_connected_mep_elements``."""
if not start_element.is_a("IfcWall"):
return []
result: list[ifcopenshell.entity_instance] = []
visited: set[int] = set()
queue: deque[ifcopenshell.entity_instance] = deque([start_element])
while queue and len(visited) < node_cap:
element = queue.popleft()
if element.id() in visited:
continue
visited.add(element.id())
if element.is_a("IfcWall"):
result.append(element)
# ``ConnectedTo`` / ``ConnectedFrom`` are the IFC inverse
# attributes that expose the relations where this element
# is the relating / related side respectively.
for rel in getattr(element, "ConnectedTo", []) or ():
if rel.is_a("IfcRelConnectsPathElements"):
neighbor = rel.RelatedElement
if neighbor is not None and neighbor.id() not in visited:
queue.append(neighbor)
for rel in getattr(element, "ConnectedFrom", []) or ():
if rel.is_a("IfcRelConnectsPathElements"):
neighbor = rel.RelatingElement
if neighbor is not None and neighbor.id() not in visited:
queue.append(neighbor)
return result
@classmethod
def compute_wall_fillet_geometry(
cls,
wall_a_obj: bpy.types.Object,
wall_b_obj: bpy.types.Object,
radius: float,
arc_resolution: int = bonsai.core.model.FILLET_DEFAULT_ARC_RESOLUTION,
) -> dict | None:
"""Compute fillet geometry between two walls in world space.
Returns a dict augmented with ``profile_thickness`` and ``height`` from
the active (A) wall's LAYER2 parameters, plus ``wall_type_id`` and
``x_angle``. Returns ``None`` when either wall lacks a reference line
or LAYER2 usage."""
axis_a = cls.get_world_reference_line(wall_a_obj)
axis_b = cls.get_world_reference_line(wall_b_obj)
if axis_a is None or axis_b is None:
return None
wall_a = tool.Ifc.get_entity(wall_a_obj)
if wall_a is None or not cls.has_layer2_usage(wall_a):
return None
seg_a = ((axis_a[0].x, axis_a[0].y, axis_a[0].z), (axis_a[1].x, axis_a[1].y, axis_a[1].z))
seg_b = ((axis_b[0].x, axis_b[0].y, axis_b[0].z), (axis_b[1].x, axis_b[1].y, axis_b[1].z))
result = bonsai.core.model.compute_fillet_polylines(seg_a, seg_b, radius, arc_resolution)
layers = tool.Model.get_material_layer_parameters(wall_a)
length_height = cls.get_length_and_height(wall_a)
wall_type = ifcopenshell.util.element.get_type(wall_a)
result.update(
{
"profile_thickness": layers["thickness"],
"profile_offset": layers["offset"],
"height": length_height[1] if length_height else None,
"x_angle": cls.get_x_angle(wall_a) or 0.0,
"wall_type_id": wall_type.id() if wall_type else None,
}
)
return result
@@ -51,6 +51,62 @@ To use these tools:
2. Use the appropriate shortcut or select the tool from the top bar.
3. Follow the on-screen prompts or adjust parameters as needed.
Interactive Parametric Editing
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Selected walls expose an in-viewport parametric edit mode that mirrors the door /
window / stair pen-icon UI:
1. Select a single wall. A pen (Edit Wall) icon appears next to the wall in the
3D viewport, and a matching ``Edit Wall`` button is available in the
``Parametric Geometry`` tab of the N panel.
2. Click the pen icon (or the panel button) to enter edit mode. Dimension
gizmos for length, height, slope (x-angle) and the layer offset baseline
appear around the wall.
3. Drag any handle to update the value. Dragging only modifies the in-progress
draft — the IFC file is not touched until you commit, so dragging a length
handle through many intermediate values produces zero extra IFC entities.
4. Click the green ✓ icon to commit; click the red ✗ to discard. Pressing the
✓ icon on a wall that hasn't been dragged is a true byte-identical no-op —
the IFC file is unchanged.
While editing, additional gizmos surface based on context:
- **Cycle Baseline**: cycles the layer offset baseline (Exterior → Centreline →
Interior). Shift+click cycles in reverse.
- **3D-cursor scissors**: appears when the 3D cursor sits on the wall axis;
clicking splits the wall at the cursor's projected X.
- **3D-cursor extend (horizontal)**: appears when the 3D cursor sits beyond the
wall axis; clicking extends the wall to the cursor's projected X.
- **3D-cursor extend (vertical)**: appears when the 3D cursor sits above /
below the wall; clicking extends the wall's height to the cursor's Z.
- **Rotate 90°**: rotates the wall around its Z axis.
- **Show / hide openings**: toggles opening fill visibility (doors and windows).
When two walls are selected, the gizmo switches to a state-aware icon at their
common point:
- Already joined → an Unjoin icon at the shared corner.
- Collinear (same axis line) → a Merge icon at the boundary midpoint.
- Joinable corner → a Join icon at the floor + an Extend-To-Wall icon at the
active wall's top.
When a wall and a slab (LAYER3 element) are selected, an Extend-Vertically icon
appears at the wall's origin / slab elevation; clicking dispatches
``bim.extend_walls_to_underside``.
When a wall and a non-wall, non-slab object are selected, an Add-Opening icon
appears above the wall at the other object's projected X.
Auto-commit on save
~~~~~~~~~~~~~~~~~~~
Pressing Ctrl+S (or running ``bim.save_project``) while any wall is mid-edit
flushes every pending parametric draft first — the same Apply-Wall-Edits the ✓
icon performs, scoped per wall. The IFC saved on disk reflects the values the
user dragged, not the snapshot taken when edit mode was entered. Each commit
produces its own undo entry, so Ctrl+Z walks back through commits individually.
Aligning Walls
^^^^^^^^^^^^^^
+31 -3
View File
@@ -17,18 +17,46 @@
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
"""
Requires pytest installed under blender
Requires pytest installed under blender.
Usage: `blender -b -P runpytest.py -- ARGS`
Usage:
blender -b -P runpytest.py -- ARGS
Alternative (when the calling shell strips or reorders the ``--`` separator
before it reaches Blender observed with some PowerShell / wrapper-script
invocations on Windows): pass the same pytest args via the
``BONSAI_TEST_ARGS`` environment variable as a single shell-quoted string
and invoke without ``--``::
$env:BONSAI_TEST_ARGS = "test/bim/ -x -q"
blender -b -P runpytest.py
"""
import os
import shlex
import sys
import pytest
argv = [__file__]
if "--" in sys.argv:
env_args = os.environ.get("BONSAI_TEST_ARGS", "")
if env_args:
# POSIX-style quoting works on all three OSes — env var values are
# literal strings (no shell evaluation when Python reads them), and
# POSIX quoting (``'foo "bar baz" qux'`` → three tokens, quotes stripped)
# matches what most docs and examples use.
argv += shlex.split(env_args)
# On the env-var path the args never appear in Blender's argv at all,
# so any pytest plugin that reads ``sys.argv`` directly (instead of
# going through pytest's API) would otherwise see only Blender's own
# ``-b -P runpytest.py`` and miss the test args entirely. Shadow argv
# so those plugins see the pytest-shaped view they expect.
sys.argv = list(argv)
elif "--" in sys.argv:
# The traditional path: Blender forwards everything after ``--`` to the
# script via ``sys.argv``. ``sys.argv`` is deliberately left as Blender
# set it — pre-existing behavior, preserved.
i = sys.argv.index("--")
argv += sys.argv[i + 1 :]
@@ -285,6 +285,32 @@ Scenario: Override duplicate move - without active IFC data
Then the object "Cube" exists
And the object "Cube.001" exists
Scenario: Override duplicate move - non-IFC objects inside an IFC project
Given an empty IFC project
And I add a cube
And the object "Cube" is selected
When I duplicate the selected objects
Then the object "Cube" exists
And the object "Cube.001" exists
And the object "Cube.001" is selected
Scenario: Override duplicate move - mixed IFC and non-IFC selection
Given an empty IFC project
And I add a cube
And the object "Cube" is selected
And I look at the "Class" panel
And I set the "Products" property to "IfcElement"
And I set the "Class" property to "IfcWall"
And I click "Assign IFC Class"
And I add a cube
And the object "IfcWall/Cube" is selected
And additionally the object "Cube" is selected
When I duplicate the selected objects
Then the object "IfcWall/Cube.001" exists
And the object "IfcWall/Cube.001" is selected
And the object "Cube.001" exists
And the object "Cube.001" is selected
Scenario: Override duplicate move - with active IFC data
Given an empty IFC project
And I add a cube
+115
View File
@@ -285,6 +285,7 @@ Scenario: Split a wall which has a flipped door
And the object "IfcWall/Wall" is selected
And I press "bim.hotkey(hotkey='S_K')"
Then the object "IfcDoor/Door" is at "8.01,0.1,0"
And the object "IfcWall/Wall.001" is filled by "IfcDoor/Door"
Scenario: Offset walls
Given an empty IFC project
@@ -673,6 +674,120 @@ Scenario: Create door type based on door modifier, add an occurrence of it and e
And I press "bim.finish_editing_door()"
Then nothing happens
Scenario: Saving with a door mid-edit auto-commits the draft value to the IFC pset
Given an empty IFC project
And I trigger "Add Element"
And I set the "Class" property to "IfcDoorType"
And I set the "Predefined Type" property to "DOOR"
And I set the "Representation" property to "Door"
When I click "OK"
And I press "bim.add_occurrence"
And I press "bim.enable_editing_door()"
And I set "active_object.BIMDoorProperties.overall_height" to "2.5"
Then "active_object.BIMDoorProperties.is_editing" is "True"
When I press "bim.save_project(filepath='{temp_project_path}', should_save_as=True)"
Then "active_object.BIMDoorProperties.is_editing" is "False"
# BBIM_<Type> psets store project units, not raw Blender SI. The empty project
# used in an_empty_blender_session is METRIC_MM, so 2.5 m → 2500 mm in the pset.
And the variable "saved_height" is "__import__('json').loads(ifcopenshell.util.element.get_pset({ifc}.by_type('IfcDoor')[0], 'BBIM_Door', 'Data'))['overall_height']"
And the variable "saved_height" equals "2500.0"
Scenario: Saving with no parametric edits in progress leaves the door pset unchanged
Given an empty IFC project
And I trigger "Add Element"
And I set the "Class" property to "IfcDoorType"
And I set the "Predefined Type" property to "DOOR"
And I set the "Representation" property to "Door"
When I click "OK"
And I press "bim.add_occurrence"
And the variable "pre_save_height" is "__import__('json').loads(ifcopenshell.util.element.get_pset({ifc}.by_type('IfcDoor')[0], 'BBIM_Door', 'Data'))['overall_height']"
When I press "bim.save_project(filepath='{temp_project_path}', should_save_as=True)"
Then the variable "post_save_height" is "__import__('json').loads(ifcopenshell.util.element.get_pset({ifc}.by_type('IfcDoor')[0], 'BBIM_Door', 'Data'))['overall_height']"
And the variable "post_save_height" equals "{pre_save_height}"
Scenario: Saving with a wall mid-edit auto-commits the draft to IFC
Given an empty IFC project
And I load the demo construction library
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
And I press "bim.add_occurrence"
And the object "IfcWall/Wall" is selected
And I press "bim.enable_editing_wall()"
Then "active_object.BIMWallProperties.is_editing" is "True"
When I press "bim.save_project(filepath='{temp_project_path}', should_save_as=True)"
Then "active_object.BIMWallProperties.is_editing" is "False"
Scenario: Enabling and finishing a wall edit with no drag is a no-op
Given an empty IFC project
And I load the demo construction library
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
And I press "bim.add_occurrence"
And the object "IfcWall/Wall" is selected
And the variable "entity_count_before" is "len(list({ifc}))"
When I press "bim.enable_editing_wall()"
And I press "bim.finish_editing_wall()"
Then "active_object.BIMWallProperties.is_editing" is "False"
And the variable "entity_count_after" is "len(list({ifc}))"
And the variable "entity_count_after" equals "{entity_count_before}"
Scenario: Cancelling a wall edit clears is_editing
Given an empty IFC project
And I add a cube
And the object "Cube" is selected
And I set "scene.BIMRootProperties.ifc_product" to "IfcElementType"
And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType"
And I press "bim.assign_class"
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
And the variable "cube" is "{ifc}.by_type('IfcWallType')[0].id()"
And I set "scene.BIMModelProperties.relating_type_id" to "{cube}"
And I press "bim.add_occurrence"
And the object "IfcWall/Wall" is selected
And I press "bim.enable_editing_wall()"
When I press "bim.cancel_editing_wall()"
Then "active_object.BIMWallProperties.is_editing" is "False"
Scenario: Wall parametric edit works on IFC2X3 projects
Given an empty IFC2X3 project
And I load the demo construction library
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
And I press "bim.add_occurrence"
And the object "IfcWall/Wall" is selected
When I press "bim.enable_editing_wall()"
Then "active_object.BIMWallProperties.is_editing" is "True"
When I press "bim.finish_editing_wall()"
Then "active_object.BIMWallProperties.is_editing" is "False"
Scenario: Rotate a wall 90° via bim.rotate_wall_90
Given an empty IFC project
And I load the demo construction library
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
And I press "bim.add_occurrence"
And the object "IfcWall/Wall" is selected
When I press "bim.rotate_wall_90()"
Then the object "IfcWall/Wall" dimensions are "1,0.1,3"
And the object "IfcWall/Wall" bottom left corner is at "0,0,0"
And the object "IfcWall/Wall" top right corner is at "-0.1,1,3"
Scenario: Splitting a wall with another wall mid-edit commits the pending edit first
Given an empty IFC project
And I load the demo construction library
And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType"
And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()"
And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}"
And I press "bim.add_occurrence"
And the object "IfcWall/Wall" is selected
And I press "bim.enable_editing_wall()"
Then "active_object.BIMWallProperties.is_editing" is "True"
When I press "bim.split_wall()"
Then "active_object.BIMWallProperties.is_editing" is "False"
Scenario: Create a door, undo and create a new door
Given an empty IFC project
And I prepare to undo
@@ -0,0 +1,100 @@
# 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.
"""Regression guard: overlapping distance gizmos must let the smaller one win.
When two ``GizmoDimension`` instances overlap on screen (e.g. a short dimension
nested inside a longer one along the same axis), the longer one's hit box fully
contains the shorter one's. Without a depth bias the longer one wins the GPU
select tie-break and the shorter one becomes unreachable.
``GizmoDimension.set_dimension_length`` writes ``select_bias = -dimension_length``
so the smaller one writes a higher (less-negative) bias and wins. The longer one
stays clickable at its exposed ends regardless of bias.
We call ``set_dimension_length`` as an unbound method on a ``SimpleNamespace``
fake ``self``. Its body only *writes* attributes (``_display_value``,
``_dimension_length``, ``select_bias``), so it doesn't need a real
``bpy.types.Gizmo`` instance those only exist inside a registered
``GizmoGroup`` and aren't constructible in a headless test."""
import types
from types import SimpleNamespace
import bpy
import pytest
from bonsai.bim.module.drawing.gizmos import GizmoDimension
pytestmark = pytest.mark.drawing
@pytest.fixture(autouse=True)
def _require_real_bpy():
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
pytest.skip("requires real Blender (bpy is mocked or absent)")
def test_smaller_dimension_wins_select_bias():
small = SimpleNamespace()
large = SimpleNamespace()
GizmoDimension.set_dimension_length(small, 0.077)
GizmoDimension.set_dimension_length(large, 0.109)
assert small.select_bias > large.select_bias
@pytest.mark.parametrize(
"lengths",
[
[0.0, 0.05, 0.077, 0.109, 1.0, 5.0, 10.0],
[0.001, 0.5, 2.5, 100.0, 9999.0],
],
)
def test_select_bias_is_non_increasing_in_length(lengths):
"""A monotonic mapping is all Blender's GPU select needs to break the tie."""
biases = []
for length in lengths:
gizmo = SimpleNamespace()
GizmoDimension.set_dimension_length(gizmo, length)
biases.append(gizmo.select_bias)
for prev, curr in zip(biases, biases[1:]):
assert prev >= curr, f"select_bias must be non-increasing in length, got {biases}"
def test_negative_length_uses_absolute_value_for_bias():
"""Negative dimension values (e.g. inverted angles) clamp to abs() for hit-box scaling;
select_bias follows the same clamped magnitude so signed-direction gizmos still
obey the smaller-wins rule against their positive-sided peers."""
positive = SimpleNamespace()
negative = SimpleNamespace()
GizmoDimension.set_dimension_length(positive, 0.5)
GizmoDimension.set_dimension_length(negative, -0.5)
assert positive.select_bias == negative.select_bias
def test_nan_and_inf_length_falls_back_to_zero_bias():
"""Invalid inputs are coerced to 0.0 before the bias is written, so a malformed
update can't push a gizmo arbitrarily far forward or backward in the select buffer."""
import math
for bad in (math.nan, math.inf, -math.inf, "not a number"):
gizmo = SimpleNamespace()
GizmoDimension.set_dimension_length(gizmo, bad)
assert gizmo.select_bias == 0.0
@@ -0,0 +1,54 @@
# 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.
import types
from types import SimpleNamespace
import bpy
import pytest
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
pytestmark = pytest.mark.drawing
@pytest.fixture(autouse=True)
def _require_real_bpy():
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
pytest.skip("requires real Blender (bpy is mocked or absent)")
def test_text_formatter_defaults_to_none():
config = DimensionGizmoConfig(attr_name="length", axis=(1, 0, 0))
assert config.text_formatter is None
def test_text_formatter_field_stores_callable():
formatter = lambda props, value: f"{value:.2f}m" # noqa: E731
config = DimensionGizmoConfig(attr_name="length", axis=(1, 0, 0), text_formatter=formatter)
assert config.text_formatter is not None
assert callable(config.text_formatter)
def test_text_formatter_receives_props_and_value():
formatter = lambda props, value: f"{props.label}={value}" # noqa: E731
config = DimensionGizmoConfig(attr_name="length", axis=(1, 0, 0), text_formatter=formatter)
props = SimpleNamespace(label="L")
assert config.text_formatter(props, 3.14) == "L=3.14"
@@ -0,0 +1,19 @@
# 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.
@@ -0,0 +1,212 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Shared fixtures and factories for ``test/bim/module/model/`` gizmo and
decorator tests.
The boundary between Blender / IFC / Bonsai's ``tool.*`` layer is patched
identically across many model-test files (viewport-state, selection, IFC
entity lookup, modifier predicates, view-camera state). The ``patched_tool``
fixture below centralises that patch stack so each test names only the
boundary methods it cares about; everything else is left to production.
Factory helpers (``make_obj``, ``make_element``, ``make_context``,
``make_ifc_file``) replace near-identical local helpers that previously
lived in each file.
When to use these fixtures in a new test file:
- Adding a gizmo / decorator test that patches ``tool.Blender`` or
``tool.Ifc`` boundary methods? Request the ``patched_tool`` fixture
as a test parameter and call it as a context-manager factory.
- Need a stub ``bpy.types.Object`` / ``ifcopenshell.entity_instance`` /
``poll()`` context / ``ifcopenshell.file``? Import the matching factory
from this module rather than re-rolling locally.
- Need to reset module-level state (e.g. a decorator cache token) between
tests? Define an ``@pytest.fixture(autouse=True)`` reset in the test
file itself these stay file-local because they target state specific
to one decorator/module and globalising the reset would surprise
unrelated tests.
Layout note: pure helpers (``make_*``) live alongside the fixture in this
file rather than a sibling ``test_utils.py``. pytest's documented role for
``conftest.py`` is fixtures, so this is a mild convention bend kept here
because the helper count is small and the dependencies (``tool``, ``Mock``)
already need to be imported for the fixture itself. Split into a separate
module if the helper count grows past ~6 or any helper picks up its own
non-trivial dependencies."""
import contextlib
from types import SimpleNamespace
from unittest.mock import MagicMock, Mock, patch
import ifcopenshell
import pytest
from bonsai import tool
def make_obj(*, session_uid=None, selected=True, **attrs):
"""Mock a ``bpy.types.Object`` with attributes commonly read by gizmos.
``session_uid`` is set only when provided so tests that don't care about
object identity (most poll() tests use ``object()`` sentinels) can use
``make_obj()`` without a spurious uid. ``selected`` wires ``select_get()``
to return the given boolean. Extra attrs are set as plain attributes.
A bare ``Mock()`` is required because ``Mock(spec=bpy.types.Object)``
rejects ``select_get`` Blender's C-registered methods aren't exposed
to Python introspection."""
obj = Mock()
if session_uid is not None:
obj.session_uid = session_uid
obj.select_get.return_value = selected
for name, value in attrs.items():
setattr(obj, name, value)
return obj
def make_element(step_id=None, *, ifc_class=None, **attrs):
"""Mock an ``ifcopenshell.entity_instance`` with the surfaces gizmos read.
``step_id`` populates ``element.id()``. ``ifc_class`` wires ``is_a(name)``
to return True only when ``name == ifc_class``. Extra kwargs become plain
attributes (e.g. ``HasOpenings=()``)."""
element = Mock()
if step_id is not None:
element.id.return_value = step_id
if ifc_class is not None:
element.is_a.side_effect = lambda type_name: type_name == ifc_class
for name, value in attrs.items():
setattr(element, name, value)
return element
def make_context(*, active=None, selected=(), scene=None):
"""``SimpleNamespace`` stub with the ``poll()`` reads tests exercise:
``active_object``, ``selected_objects``, and ``scene``. ``selected`` is
materialised to a list so tests can iterate without re-walking a generator.
``scene`` defaults to an empty namespace so guards that walk
``context.scene.BIMPreviewProperties`` (via ``getattr(..., default=None)``)
treat the preview as inactive pass a custom namespace to activate."""
return SimpleNamespace(
active_object=active,
selected_objects=list(selected),
scene=scene if scene is not None else SimpleNamespace(),
)
def make_ifc_file(elements_by_guid: dict | None = None) -> MagicMock:
"""Mock ``ifcopenshell.file`` with ``spec=`` so attribute typos surface as
``AttributeError`` instead of silently auto-creating a child mock.
When ``elements_by_guid`` is given, ``by_guid`` is wired to look up the
mapping and raise ``RuntimeError`` on a missing guid same shape as the
real ifcopenshell.file behaviour, so a test that depends on orphan handling
sees an exception rather than a silent ``None``."""
f = MagicMock(spec=ifcopenshell.file, name="ifc_file")
if elements_by_guid is not None:
def _by_guid(guid):
try:
return elements_by_guid[guid]
except KeyError:
raise RuntimeError(f"no entity with guid {guid}")
f.by_guid.side_effect = _by_guid
return f
@pytest.fixture
def patched_tool():
"""Context-manager factory for the ``tool.*`` boundary patches that nearly
every gizmo / decorator test repeats. Use as::
with patched_tool(viewport_gizmos=True, selected=[obj_a, obj_b],
modifier_predicates={"is_wall": True}):
GizmoFoo.poll(context)
Only the kwargs you pass are patched anything left as ``None`` (or
omitted) keeps production behaviour. Values can be:
- ``viewport_gizmos`` / ``view_top_down`` / ``addon_prefs``: passed to
``return_value=`` of the corresponding patch.
- ``selected``: wrapped in ``set(...)`` for ``get_selected_objects``
(matches the production return type for ``poll()``-side reads).
- ``selected_list``: as-is for ``get_selected_objects`` when order
matters (some operators iterate it). Mutually exclusive with
``selected`` if both are passed, ``selected`` wins and
``selected_list`` is ignored. Pass only one.
- ``entity``: either a callable (used as ``side_effect``) or a single
value (used as ``return_value``).
- ``modifier_predicates``: dict ``{predicate_name: bool_or_callable}``.
Callables are wired as ``side_effect``, bools as ``return_value``.
- ``screen_up``: ``return_value`` for ``get_screen_up_world``.
Patches close on context-manager exit via an ``ExitStack`` no
``try/finally`` bookkeeping in the test body."""
@contextlib.contextmanager
def _factory(
*,
viewport_gizmos=None,
addon_prefs=None,
selected=None,
selected_list=None,
entity=None,
modifier_predicates=None,
view_top_down=None,
screen_up=None,
):
with contextlib.ExitStack() as stack:
if viewport_gizmos is not None:
stack.enter_context(
patch.object(tool.Blender, "are_viewport_gizmos_enabled", return_value=viewport_gizmos)
)
if addon_prefs is not None:
stack.enter_context(patch.object(tool.Blender, "get_addon_preferences", return_value=addon_prefs))
if selected is not None:
stack.enter_context(patch.object(tool.Blender, "get_selected_objects", return_value=set(selected)))
elif selected_list is not None:
stack.enter_context(
patch.object(tool.Blender, "get_selected_objects", return_value=list(selected_list))
)
if entity is not None:
if callable(entity):
stack.enter_context(patch.object(tool.Ifc, "get_entity", side_effect=entity))
else:
stack.enter_context(patch.object(tool.Ifc, "get_entity", return_value=entity))
if modifier_predicates:
for name, value in modifier_predicates.items():
# Parametric feature-kind predicates live on tool.Parametric; the
# remaining cardinality / non-parametric predicates (is_array_child,
# is_slab, is_eligible_for_*) stay on tool.Blender.Modifier.
target = tool.Parametric if hasattr(tool.Parametric, name) else tool.Blender.Modifier
if callable(value):
stack.enter_context(patch.object(target, name, side_effect=value))
else:
stack.enter_context(patch.object(target, name, return_value=value))
if view_top_down is not None:
stack.enter_context(patch.object(tool.Blender, "is_view_top_down", return_value=view_top_down))
if screen_up is not None:
stack.enter_context(patch.object(tool.Blender, "get_screen_up_world", return_value=screen_up))
yield
return _factory
@@ -0,0 +1,176 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Contract tests for the shared decorator cache module.
The cache token + persistent handler are the only thing protecting cached
``bpy.types.Object`` refs in dependent decorators from being dereferenced
after the underlying object is freed. These tests pin that contract:
- The 4-hook invalidation list (depsgraph/undo/redo/load) is symmetrically
managed by install/uninstall. A future edit that drops a hook from one
side without the other lands as a Blender segfault the regression must
surface as a test failure first.
- The handler increments the token and accepts Blender's variadic args."""
import bpy
import pytest
from bonsai.bim import decorator_cache
pytestmark = pytest.mark.model
@pytest.fixture(autouse=True)
def _reset_cache_token():
"""Fresh token between tests so the bump-count assertions are stable."""
decorator_cache.reset_for_test()
yield
def test_install_and_uninstall_manage_all_invalidation_hooks():
"""install_decorator_cache_handlers() must register the bump handler in
every hook the dependent decorators rely on; uninstall must remove it
from every hook install touched. Catches the regression class where
a hook is dropped from one side and not the other."""
expected_hooks = (
bpy.app.handlers.depsgraph_update_post,
bpy.app.handlers.undo_post,
bpy.app.handlers.redo_post,
bpy.app.handlers.load_post,
)
# Defensive cleanup in case a previous addon-init run left the handler
# registered — the test must observe a clean slate before install().
for hook in expected_hooks:
while decorator_cache._bump_decorator_cache_token in hook:
hook.remove(decorator_cache._bump_decorator_cache_token)
try:
decorator_cache.install_decorator_cache_handlers()
for hook in expected_hooks:
assert decorator_cache._bump_decorator_cache_token in hook, (
"install_decorator_cache_handlers() must register the bump "
"handler in every hook a dependent cache relies on"
)
decorator_cache.uninstall_decorator_cache_handlers()
for hook in expected_hooks:
assert decorator_cache._bump_decorator_cache_token not in hook, (
"uninstall_decorator_cache_handlers() must remove the bump " "handler from every hook install touched"
)
finally:
# Make sure the test never leaves the handler dangling.
for hook in expected_hooks:
while decorator_cache._bump_decorator_cache_token in hook:
hook.remove(decorator_cache._bump_decorator_cache_token)
def test_install_is_idempotent():
"""Calling install twice must not double-register the bump handler —
the addon-init path may run on script reload and we don't want to
invalidate the cache twice per event."""
hook = bpy.app.handlers.depsgraph_update_post
while decorator_cache._bump_decorator_cache_token in hook:
hook.remove(decorator_cache._bump_decorator_cache_token)
try:
decorator_cache.install_decorator_cache_handlers()
decorator_cache.install_decorator_cache_handlers()
appearances = sum(1 for h in hook if h is decorator_cache._bump_decorator_cache_token)
assert appearances == 1, "install must not double-register"
finally:
decorator_cache.uninstall_decorator_cache_handlers()
def test_bump_handler_increments_token():
"""undo / redo / load_post invoke the handler with at most one positional
argument (the scene or filepath). Every such call must bump the token
those events legitimately invalidate every cached Object reference."""
decorator_cache._bump_decorator_cache_token()
assert decorator_cache.get_decorator_cache_token() == 1
decorator_cache._bump_decorator_cache_token("scene")
assert decorator_cache.get_decorator_cache_token() == 2
def test_get_decorator_cache_token_reads_current_value():
"""``get_decorator_cache_token()`` is the public read interface — it must
reflect the current token, not a captured-at-import-time value."""
initial = decorator_cache.get_decorator_cache_token()
decorator_cache._bump_decorator_cache_token()
assert decorator_cache.get_decorator_cache_token() == initial + 1
def test_depsgraph_update_with_no_object_changes_does_not_bump():
"""depsgraph_update_post fires every animation frame, every driver
evaluation, and every UI-only state shift. None of those invalidate a
decorator's cached IFC-derived geometry — gating the bump is what makes
the ``TokenCache`` worth more than a per-frame recompute."""
from unittest.mock import MagicMock
initial = decorator_cache.get_decorator_cache_token()
depsgraph = MagicMock(spec=bpy.types.Depsgraph, name="depsgraph")
depsgraph.updates = [] # empty updates list — animation tick with no real changes
decorator_cache._bump_decorator_cache_token("scene", depsgraph)
assert (
decorator_cache.get_decorator_cache_token() == initial
), "depsgraph_update_post with no Object changes must not bump the token"
def test_depsgraph_update_with_object_geometry_change_bumps():
"""When the depsgraph reports an Object geometry or transform change,
cached references may now point at a renamed / freed ID block. The token
must advance so dependent caches re-fetch on the next read."""
from unittest.mock import MagicMock
initial = decorator_cache.get_decorator_cache_token()
update = MagicMock(spec=bpy.types.DepsgraphUpdate, name="update")
update.is_updated_geometry = True
update.is_updated_transform = False
update.id = bpy.data.objects.new("dep_cache_probe", None)
try:
depsgraph = MagicMock(spec=bpy.types.Depsgraph, name="depsgraph")
depsgraph.updates = [update]
decorator_cache._bump_decorator_cache_token("scene", depsgraph)
assert decorator_cache.get_decorator_cache_token() == initial + 1
finally:
bpy.data.objects.remove(update.id, do_unlink=True)
def test_depsgraph_update_with_non_object_change_does_not_bump():
"""Material / NodeTree / Image updates fire depsgraph_update_post too
but never invalidate the decorator's Object-keyed caches. Filter them
out so a node-graph edit doesn't trigger a global cache rebuild."""
from unittest.mock import MagicMock
initial = decorator_cache.get_decorator_cache_token()
update = MagicMock(spec=bpy.types.DepsgraphUpdate, name="update")
update.is_updated_geometry = True
update.is_updated_transform = True
update.id = bpy.data.materials.new("dep_cache_probe_mat")
try:
depsgraph = MagicMock(spec=bpy.types.Depsgraph, name="depsgraph")
depsgraph.updates = [update]
decorator_cache._bump_decorator_cache_token("scene", depsgraph)
assert (
decorator_cache.get_decorator_cache_token() == initial
), "Non-Object ID updates must not bump the decorator cache token"
finally:
bpy.data.materials.remove(update.id, do_unlink=True)
@@ -0,0 +1,219 @@
# 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.
"""Contract tests for the door swing-arc gizmo positioning.
Each test calls ``GizmoDoorEdition.update_swing_gizmos`` as an unbound method
against a SimpleNamespace stand-in that records ``matrix_basis`` assignments and
``hide`` flags. The expected matrices are recomputed from first principles so
the tests describe the geometric contract directly rather than echoing the
implementation."""
import types
from types import SimpleNamespace
from unittest.mock import MagicMock
import bpy
import pytest
from mathutils import Matrix, Vector
pytestmark = pytest.mark.model
@pytest.fixture(autouse=True)
def _require_real_bpy():
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
pytest.skip("requires real Blender (bpy is mocked or absent)")
def _make_props(door_type, overall_width=0.9, lining_offset=0.0, is_editing=True):
return SimpleNamespace(
door_type=door_type,
overall_width=overall_width,
lining_offset=lining_offset,
is_editing=is_editing,
)
def _make_fake_group():
"""Stand-in for ``GizmoDoorEdition``: one MagicMock per declared arc gizmo
plus a stub ``update_gizmo_visibility`` that records the visibility flag on
each mock's ``hide`` attribute."""
from bonsai.bim.module.model.door import GizmoDoorEdition
fake = SimpleNamespace()
fake.swing_arc_props = GizmoDoorEdition.swing_arc_props
def update_gizmo_visibility(gizmo, is_visible):
gizmo.hide = not is_visible
return is_visible
fake.update_gizmo_visibility = update_gizmo_visibility
for cfg in fake.swing_arc_props:
setattr(fake, f"gizmo_swing_arc_{cfg.name}", MagicMock(spec=["matrix_basis", "hide"]))
setattr(fake, f"gizmo_swing_arc_{cfg.name}_flip", MagicMock(spec=["matrix_basis", "hide"]))
return fake
def _call_update(fake, props, mw=None):
from bonsai.bim.module.model.door import GizmoDoorEdition
GizmoDoorEdition.update_swing_gizmos(fake, mw or Matrix.Identity(4), props)
def _matrix_approx(actual, expected, abs_tol=1e-6):
assert isinstance(actual, Matrix), f"matrix_basis was never assigned (got {type(actual).__name__})"
for i in range(4):
for j in range(4):
assert actual[i][j] == pytest.approx(expected[i][j], abs=abs_tol), (
f"Mismatch at [{i}][{j}]: got {actual[i][j]}, expected {expected[i][j]}\n"
f"actual=\n{actual}\nexpected=\n{expected}"
)
_MIRROR_X = Matrix.Scale(-1, 4, (1, 0, 0))
_MIRROR_Y = Matrix.Scale(-1, 4, (0, 1, 0))
def test_single_swing_left_primary_arc_hinges_at_left_edge():
"""Left-hinged single-swing: primary arc at (0, lining_offset), scaled to
overall_width, no X-mirror. Flip arc same transform composed with Y-mirror.
Secondary panel hidden."""
fake = _make_fake_group()
props = _make_props(door_type="SINGLE_SWING_LEFT", overall_width=0.9, lining_offset=0.05)
_call_update(fake, props)
expected = Matrix.Translation(Vector((0.0, 0.05, 0.0))) @ Matrix.Scale(0.9, 4)
_matrix_approx(fake.gizmo_swing_arc_primary.matrix_basis, expected)
_matrix_approx(fake.gizmo_swing_arc_primary_flip.matrix_basis, expected @ _MIRROR_Y)
assert fake.gizmo_swing_arc_secondary.hide is True
assert fake.gizmo_swing_arc_secondary_flip.hide is True
def test_single_swing_right_primary_arc_hinges_at_right_edge_with_x_mirror():
"""Right-hinged single-swing: primary arc anchored at (overall_width, lining_offset)
with an X-mirror applied so the arc sweeps back over the door panel rather
than extending past the right edge."""
fake = _make_fake_group()
props = _make_props(door_type="SINGLE_SWING_RIGHT", overall_width=0.9, lining_offset=0.05)
_call_update(fake, props)
expected = Matrix.Translation(Vector((0.9, 0.05, 0.0))) @ Matrix.Scale(0.9, 4) @ _MIRROR_X
_matrix_approx(fake.gizmo_swing_arc_primary.matrix_basis, expected)
_matrix_approx(fake.gizmo_swing_arc_primary_flip.matrix_basis, expected @ _MIRROR_Y)
assert fake.gizmo_swing_arc_secondary.hide is True
assert fake.gizmo_swing_arc_secondary_flip.hide is True
@pytest.mark.parametrize(
("double_type", "single_type"),
[
("DOUBLE_SWING_LEFT", "SINGLE_SWING_LEFT"),
("DOUBLE_SWING_RIGHT", "SINGLE_SWING_RIGHT"),
],
)
def test_double_swing_uses_same_recipe_as_single_swing(double_type, single_type):
"""DOUBLE_SWING_* (one panel that can open both ways) shares the
single-panel positioning recipe with its SINGLE_SWING_* counterpart."""
fake_a = _make_fake_group()
fake_b = _make_fake_group()
props_a = _make_props(door_type=double_type, overall_width=0.9, lining_offset=0.05)
props_b = _make_props(door_type=single_type, overall_width=0.9, lining_offset=0.05)
_call_update(fake_a, props_a)
_call_update(fake_b, props_b)
_matrix_approx(
fake_a.gizmo_swing_arc_primary.matrix_basis,
fake_b.gizmo_swing_arc_primary.matrix_basis,
)
_matrix_approx(
fake_a.gizmo_swing_arc_primary_flip.matrix_basis,
fake_b.gizmo_swing_arc_primary_flip.matrix_basis,
)
def test_double_door_shows_four_arcs_each_scaled_to_half_door_width():
"""DOUBLE_DOOR_SINGLE_SWING: left panel hinged at x=0, right panel hinged
at x=overall_width with X-mirror, both scaled to overall_width/2. Each
panel also gets a Y-mirrored flip arc 4 arcs total."""
fake = _make_fake_group()
props = _make_props(door_type="DOUBLE_DOOR_SINGLE_SWING", overall_width=1.6, lining_offset=0.0)
_call_update(fake, props)
half = 1.6 / 2
expected_primary = Matrix.Translation(Vector((0.0, 0.0, 0.0))) @ Matrix.Scale(half, 4)
expected_secondary = Matrix.Translation(Vector((1.6, 0.0, 0.0))) @ Matrix.Scale(half, 4) @ _MIRROR_X
_matrix_approx(fake.gizmo_swing_arc_primary.matrix_basis, expected_primary)
_matrix_approx(fake.gizmo_swing_arc_primary_flip.matrix_basis, expected_primary @ _MIRROR_Y)
_matrix_approx(fake.gizmo_swing_arc_secondary.matrix_basis, expected_secondary)
_matrix_approx(fake.gizmo_swing_arc_secondary_flip.matrix_basis, expected_secondary @ _MIRROR_Y)
for cfg in fake.swing_arc_props:
assert getattr(fake, f"gizmo_swing_arc_{cfg.name}").hide is False
assert getattr(fake, f"gizmo_swing_arc_{cfg.name}_flip").hide is False
@pytest.mark.parametrize("door_type", ["SLIDING_TO_LEFT", "SLIDING_TO_RIGHT", "DOUBLE_DOOR_SLIDING"])
def test_sliding_door_types_hide_all_arcs(door_type):
"""Sliding doors don't swing — every arc in ``swing_arc_props`` is hidden."""
fake = _make_fake_group()
props = _make_props(door_type=door_type, overall_width=0.9, lining_offset=0.0)
_call_update(fake, props)
for cfg in fake.swing_arc_props:
assert getattr(fake, f"gizmo_swing_arc_{cfg.name}").hide is True
assert getattr(fake, f"gizmo_swing_arc_{cfg.name}_flip").hide is True
def test_not_editing_hides_all_arcs():
"""``is_editing=False`` collapses every arc's visibility, regardless of door type."""
fake = _make_fake_group()
props = _make_props(door_type="SINGLE_SWING_LEFT", overall_width=0.9, is_editing=False)
_call_update(fake, props)
for cfg in fake.swing_arc_props:
assert getattr(fake, f"gizmo_swing_arc_{cfg.name}").hide is True
assert getattr(fake, f"gizmo_swing_arc_{cfg.name}_flip").hide is True
def test_flip_arc_matrix_is_reassigned_each_refresh():
"""The flip arc's ``matrix_basis`` must be (re-)assigned on every refresh
so a stale identity matrix can never appear at the world origin."""
fake = _make_fake_group()
props = _make_props(door_type="SINGLE_SWING_LEFT", overall_width=0.9, lining_offset=0.1)
_call_update(fake, props)
assert isinstance(fake.gizmo_swing_arc_primary_flip.matrix_basis, Matrix)
assert fake.gizmo_swing_arc_primary_flip.matrix_basis != Matrix.Identity(4)
def test_world_matrix_pre_multiplies_into_arc_transform():
"""The caller's world matrix ``mw`` left-multiplies the per-panel transform:
a translated ``mw`` shifts every arc by the same offset."""
fake = _make_fake_group()
props = _make_props(door_type="SINGLE_SWING_LEFT", overall_width=0.9, lining_offset=0.0)
mw = Matrix.Translation(Vector((10.0, 20.0, 30.0)))
_call_update(fake, props, mw=mw)
expected = mw @ Matrix.Translation(Vector((0.0, 0.0, 0.0))) @ Matrix.Scale(0.9, 4)
_matrix_approx(fake.gizmo_swing_arc_primary.matrix_basis, expected)
@@ -0,0 +1,96 @@
# 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.
"""Behaviour contracts for the wall-fillet operator chain.
Each fillet operator's geometry path requires real Blender + IFC fixtures
(walls with IfcMaterialLayerSetUsage, neighbour rels, etc.). End-to-end
fillet round-trips belong in the bim feature suite (model.feature) where
that scaffolding already exists. This file pins the surface-level invariants
that don't depend on the geometry path:
* the lifecycle operators are registered under their conventional bl_idnames,
* the enable poll rejects ineligible selections.
State-clearing tests via ``bpy.ops.bim.cancel_wall_fillet_preview()`` were
removed because the dispatch is flaky in full-suite ordering the operator
early-returns when ``context.screen`` is unattached and prior tests can leave
the screen in that state. The behaviour is covered by the user-visible live
test loop instead."""
import types
import bpy
import pytest
pytestmark = pytest.mark.model
@pytest.fixture(autouse=True)
def _require_real_bpy():
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
pytest.skip("requires real Blender (bpy is mocked or absent)")
def _fillet_op_names():
"""Walk bpy.ops.bim for operators whose name contains ``wall_fillet`` —
avoids hard-coding the five lifecycle bl_idnames so adding / renaming
one updates discovery automatically. Each name maps to a callable
operator."""
return sorted(name for name in dir(bpy.ops.bim) if "wall_fillet" in name)
class TestFilletOperatorsRegistered:
"""Catches accidental deregistration of any fillet lifecycle operator —
drops in the classes tuple of bim/module/model/__init__.py would otherwise
leave the gizmo group's target_set_operator binding pointing at a missing
op and crash the first time a user clicked the icon."""
def test_at_least_the_expected_lifecycle_set_is_registered(self):
names = _fillet_op_names()
# The lifecycle has enable + finish + cancel as a minimum; a healthy
# build also includes the from-corner re-edit entry and the create
# operator the finish dispatches to. The test asserts at least four —
# below that the feature can't function — without enumerating each
# by name, so the test stays meaningful if one is renamed or merged.
assert len(names) >= 4, (
f"Only {len(names)} fillet operators found on bpy.ops.bim: {names}. "
"The fillet lifecycle needs enable + finish + cancel + create at "
"minimum; check bim/module/model/__init__.py classes tuple."
)
def test_every_discovered_fillet_op_is_callable(self):
for name in _fillet_op_names():
op = getattr(bpy.ops.bim, name)
assert callable(op), f"bpy.ops.bim.{name} is not callable — registration broke?"
class TestEnableRejectsIneligibleSelection:
"""The preview enable operator requires a specific 2-wall selection
(LAYER2 walls with straight axes). With no selection at all, poll
must return False so the operator is greyed-out in menus instead of
crashing on dispatch."""
def test_enable_poll_returns_false_with_no_selection(self):
# Deselect everything in the default scene; no IfcWall is present
# in a fresh bpy_extras context anyway, so poll() must short-circuit.
bpy.ops.object.select_all(action="DESELECT")
bpy.context.view_layer.update()
assert bpy.ops.bim.enable_wall_fillet_preview.poll() is False
@@ -0,0 +1,463 @@
# 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.
"""Poll + positioning tests for ``GizmoHostAddOpening``.
The gizmo dispatches on element type: walls keep the existing axis-projection
math, while LAYER3 hosts (slabs, roofs) use a world-Z face bias derived from
the void object's elevation. Each branch is exercised independently with
mocks so the per-type contract is pinned without launching a full Blender
modelling session."""
import contextlib
from types import SimpleNamespace
from unittest.mock import patch
import bpy
import pytest
from mathutils import Matrix, Vector
import bonsai.tool as tool
from test.bim.bootstrap import NewFile
from test.bim.module.model.conftest import make_context
pytestmark = pytest.mark.model
# ---------------------------------------------------------------------------
# poll() — entry gate per host type and per co-selection shape
# ---------------------------------------------------------------------------
_IFC_CLASS_BY_KIND = {
"wall": "IfcWall",
"slab": "IfcSlab",
"roof": "IfcRoof",
"plain": "IfcDiscreteAccessory",
}
class _FakeIfcEntity:
"""Minimal stand-in for an ``ifcopenshell.entity_instance`` in poll tests.
Provides the two surfaces the gizmo's poll consults: ``is_a(type_name)``
(used directly by ``is_supported_host`` for slab/roof) and an optional
``HasOpenings`` attribute (probed by the poll's ``hasattr`` guard)."""
def __init__(self, ifc_class: str, has_openings: bool = True):
self._ifc_class = ifc_class
if has_openings:
self.HasOpenings = ()
def is_a(self, type_name: str) -> bool:
return self._ifc_class == type_name
def _build_poll_callbacks(selected, active_kind, other_kind):
"""Build the ``(get_entity, is_path_connectable_wall)`` side-effect
callables that simulate one poll() invocation. ``active_kind`` /
``other_kind`` accept ``"wall"``, ``"slab"``, ``"roof"``, ``"plain"``
(non-host IFC element), ``"mesh"`` (no IFC entity), or ``None``
(object outside the selection set).
Wall recognition goes through ``tool.Parametric.is_path_connectable_wall``
so fillet-corner walls (which have no LAYER2 usage) also surface the
add-opening icon; slab/roof use ``is_a`` on the fake entity so the
broadened class-based predicate is exercised."""
sentinels = {kind: _FakeIfcEntity(_IFC_CLASS_BY_KIND[kind]) for kind in _IFC_CLASS_BY_KIND}
# The "plain" sentinel lacks HasOpenings so the hasattr guard branch
# is reachable from the corresponding poll test.
sentinels["plain"] = _FakeIfcEntity(_IFC_CLASS_BY_KIND["plain"], has_openings=False)
def entity_for(kind):
if kind in (None, "mesh"):
return None
return sentinels[kind]
entity_map = {}
if len(selected) >= 1:
entity_map[id(selected[0])] = entity_for(active_kind)
if len(selected) >= 2:
entity_map[id(selected[1])] = entity_for(other_kind)
def get_entity(obj):
return entity_map.get(id(obj))
def is_path_connectable_wall(element):
return element is sentinels["wall"]
return get_entity, is_path_connectable_wall
def _run_poll(
patched_tool, prefs_on=True, n_selected=2, active_in_selected=True, active_kind="wall", other_kind="mesh"
):
from bonsai.bim.module.model.host_add_opening_gizmo import GizmoHostAddOpening
selected = [object() for _ in range(n_selected)]
active = selected[0] if (active_in_selected and selected) else object()
get_entity, is_path_connectable_wall = _build_poll_callbacks(selected, active_kind, other_kind)
with patched_tool(
viewport_gizmos=prefs_on,
selected=selected,
entity=get_entity,
modifier_predicates={"is_path_connectable_wall": is_path_connectable_wall},
):
return GizmoHostAddOpening.poll(make_context(active=active, selected=selected))
@pytest.mark.parametrize("host_kind", ["wall", "slab", "roof"])
def test_poll_accepts_each_host_with_a_plain_mesh_void(host_kind, patched_tool):
assert _run_poll(patched_tool, active_kind=host_kind, other_kind="mesh") is True
def test_poll_rejects_when_gizmo_toggle_off(patched_tool):
assert _run_poll(patched_tool, prefs_on=False) is False
def test_poll_rejects_when_selection_count_is_not_two(patched_tool):
assert _run_poll(patched_tool, n_selected=1) is False
assert _run_poll(patched_tool, n_selected=3) is False
def test_poll_rejects_when_active_is_not_in_selection(patched_tool):
assert _run_poll(patched_tool, active_in_selected=False) is False
def test_poll_rejects_when_active_has_no_ifc_entity(patched_tool):
assert _run_poll(patched_tool, active_kind="mesh") is False
def test_poll_rejects_when_active_is_not_a_host(patched_tool):
# "plain" sentinel is recognised as an IFC entity but is none of wall/slab/roof.
assert _run_poll(patched_tool, active_kind="plain") is False
@pytest.mark.parametrize(
"active_kind,other_kind",
[
("wall", "wall"), # wall-join gizmo owns this
("slab", "slab"), # future slab-edit gizmo
("roof", "roof"),
("wall", "slab"), # extend-vertically gizmo overlaps with this
("slab", "wall"),
("roof", "wall"),
],
)
def test_poll_rejects_host_host_pairs(active_kind, other_kind, patched_tool):
"""Host + host pairings must be suppressed so the icon never stacks with
the wall-join / extend-vertical / future slab-edit gizmos."""
assert _run_poll(patched_tool, active_kind=active_kind, other_kind=other_kind) is False
def test_poll_rejects_active_host_without_has_openings(patched_tool):
# Real-world equivalent: an IFC class that the active schema strips
# ``HasOpenings`` from (e.g., a non-element subtype). The active sentinel
# is set up as a connectable wall but with no HasOpenings attribute.
from bonsai.bim.module.model.host_add_opening_gizmo import GizmoHostAddOpening
selected = [object(), object()]
active = selected[0]
host_sentinel = object() # No HasOpenings attribute
other_sentinel = None
with patched_tool(
viewport_gizmos=True,
selected=selected,
entity=lambda o: host_sentinel if o is selected[0] else other_sentinel,
modifier_predicates={"is_path_connectable_wall": lambda e: e is host_sentinel},
):
assert GizmoHostAddOpening.poll(make_context(active=active, selected=selected)) is False
# ---------------------------------------------------------------------------
# position_gizmos() — branch dispatch and per-branch anchor math
# ---------------------------------------------------------------------------
def _run_position_wall_branch(patched_tool, *, other_translation=(0.5, 0.0, 0.0), top_down=True):
"""Drive the wall branch with stub IFC reads, returning the icon's
matrix_basis translation."""
from bonsai.bim.module.drawing import gizmos as gizmo_module
from bonsai.bim.module.model import host_add_opening_gizmo as host_mod
from bonsai.bim.module.model.host_add_opening_gizmo import GizmoHostAddOpening
geom = {"anchor_x": 0.0, "length": 2.0, "height": 3.0, "offset": 0.0, "thickness": 0.2}
wall_element = object()
active = SimpleNamespace(matrix_world=Matrix.Identity(4))
other = SimpleNamespace(matrix_world=Matrix.Translation(Vector(other_translation)))
selected = [active, other]
context = SimpleNamespace(active_object=active)
icon = SimpleNamespace(matrix_basis=None, hide=True)
self_stub = SimpleNamespace(add_opening_icon=icon)
with contextlib.ExitStack() as stack:
stack.enter_context(
patched_tool(
selected_list=selected,
entity=wall_element,
modifier_predicates={"is_path_connectable_wall": True},
view_top_down=top_down,
screen_up=Vector((0.0, 1.0, 0.0)),
)
)
stack.enter_context(patch.object(host_mod, "_get_wall_geom_cached", return_value=geom))
stack.enter_context(patch.object(host_mod, "_wall_camera_facing_icon_y", return_value=0.0))
stack.enter_context(patch.object(gizmo_module, "get_billboard_rotation", return_value=Matrix.Identity(4)))
stack.enter_context(
patch.object(
gizmo_module, "billboarded_at", side_effect=lambda pos, rot, scale=0.5: Matrix.Translation(pos)
)
)
GizmoHostAddOpening.position_gizmos(self_stub, context)
return icon.matrix_basis.translation
def test_wall_branch_drops_height_lift_in_top_down_view(patched_tool):
"""In plan view the wall-top Z lift must collapse to zero and the icon
must instead offset along screen-up otherwise the icon stacks on top
of the wall outline and the user can't see it."""
from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup
pos = _run_position_wall_branch(patched_tool, top_down=True)
assert pos.z == pytest.approx(0.0)
assert pos.y == pytest.approx(BaseParametricGizmoGroup.SCREEN_STACK_OFFSET)
def _run_position_layer3_branch(
patched_tool, *, host_world_z_range=(0.0, 0.2), other_z=1.0, other_xy=(0.7, 0.4), is_path_connectable_wall=False
):
"""Drive the LAYER3 (slab/roof) branch and return the icon translation.
``host_world_z_range`` sets the world-Z extents of the host's bounding box
(the gizmo picks top vs bottom by comparing the void's Z to the box
midpoint). ``is_path_connectable_wall`` keeps a single helper for both
branches by flipping the dispatch predicate."""
from bonsai.bim.module.drawing import gizmos as gizmo_module
from bonsai.bim.module.model.host_add_opening_gizmo import GizmoHostAddOpening
z_min, z_max = host_world_z_range
# bound_box returns 8 corners in local space; we only need their world-Z
# range to drive the branch, so fix XY at zero and vary Z.
local_corners = [(0.0, 0.0, z_min), (0.0, 0.0, z_max)] * 4
host_obj = SimpleNamespace(matrix_world=Matrix.Identity(4), bound_box=local_corners)
other = SimpleNamespace(matrix_world=Matrix.Translation(Vector((other_xy[0], other_xy[1], other_z))))
selected = [host_obj, other]
context = SimpleNamespace(active_object=host_obj)
icon = SimpleNamespace(matrix_basis=None, hide=True)
self_stub = SimpleNamespace(add_opening_icon=icon)
host_element = object()
with contextlib.ExitStack() as stack:
stack.enter_context(
patched_tool(
selected_list=selected,
entity=host_element,
modifier_predicates={"is_path_connectable_wall": is_path_connectable_wall},
)
)
stack.enter_context(patch.object(gizmo_module, "get_billboard_rotation", return_value=Matrix.Identity(4)))
stack.enter_context(
patch.object(
gizmo_module, "billboarded_at", side_effect=lambda pos, rot, scale=0.5: Matrix.Translation(pos)
)
)
GizmoHostAddOpening.position_gizmos(self_stub, context)
return icon.matrix_basis.translation
@pytest.mark.parametrize("other_z", [1.0, 0.1, -1.0])
def test_layer3_branch_always_parks_above_top_face(patched_tool, other_z):
"""Icon parks above the host's top face regardless of the void's Z —
predictable height every time. Void's XY is preserved so clicking the
icon dispatches the operator at the intended XY position."""
from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup
pos = _run_position_layer3_branch(patched_tool, host_world_z_range=(0.0, 0.2), other_z=other_z, other_xy=(0.7, 0.4))
assert pos.x == pytest.approx(0.7)
assert pos.y == pytest.approx(0.4)
assert pos.z == pytest.approx(0.2 + BaseParametricGizmoGroup.ICON_Z_OFFSET)
# ---------------------------------------------------------------------------
# is_supported_host() — predicate totality
# ---------------------------------------------------------------------------
def test_is_supported_host_returns_false_for_none():
"""Total predicate: ``None`` short-circuits to False without raising."""
from bonsai.bim.module.model.host_add_opening_gizmo import is_supported_host
assert is_supported_host(None) is False
def test_is_supported_host_accepts_bare_ifc_slab():
"""The slab branch is class-based — any ``IfcSlab`` qualifies, even
without LAYER3 parametric usage. The positioner reads ``obj.bound_box``,
which works for both parametric and imported geometry."""
from bonsai.bim.module.model.host_add_opening_gizmo import is_supported_host
assert is_supported_host(_FakeIfcEntity("IfcSlab")) is True
def test_is_supported_host_accepts_bare_ifc_roof():
"""The roof branch is class-based, not pset-based — a bare ``IfcRoof``
imported from another IFC tool qualifies even without the Bonsai
BBIM_Roof parametric marker that ``tool.Parametric.is_roof``
would require."""
from bonsai.bim.module.model.host_add_opening_gizmo import is_supported_host
assert is_supported_host(_FakeIfcEntity("IfcRoof")) is True
def test_is_supported_host_rejects_non_host_ifc_class():
"""Non-host IFC classes are filtered — covers ``IfcCovering`` (which has
HasOpenings but is not a wall/slab/roof) and prevents the gizmo from
surfacing on arbitrary building elements."""
from bonsai.bim.module.model.host_add_opening_gizmo import is_supported_host
assert is_supported_host(_FakeIfcEntity("IfcCovering")) is False
assert is_supported_host(_FakeIfcEntity("IfcDiscreteAccessory")) is False
# ---------------------------------------------------------------------------
# End-to-end smoke: gizmo's target operator handles host + mesh-void selection
# ---------------------------------------------------------------------------
#
# The gizmo binds ``bim.add_opening`` via ``setup_icon_gizmo`` — clicking the
# icon dispatches that operator with the current selection set. The operator
# has its own target/opening detection that swaps based on which selected
# object carries an IFC entity. This smoke test pins that handoff: with a
# host as the active object and a non-IFC mesh as the "void", the operator
# creates an ``IfcOpeningElement`` linked to the host via the standard
# ``HasOpenings`` inverse.
class TestAddOpeningIntegrationOnSlab(NewFile):
def test_creates_opening_when_slab_is_active_with_mesh_void(self):
tool.Project.get_project_props().template_file = "IFC4 Demo Template.ifc"
bpy.ops.bim.create_project()
ifc_file = tool.Ifc.get()
slab_type = ifc_file.by_type("IfcSlabType")[0]
bpy.ops.bim.add_occurrence(relating_type_id=slab_type.id())
slab = ifc_file.by_type("IfcSlab")[0]
slab_obj = tool.Ifc.get_object(slab)
assert isinstance(slab_obj, bpy.types.Object)
assert len(slab.HasOpenings) == 0
void_obj = bpy.data.objects.new("VoidMesh", bpy.data.meshes.new("VoidMesh"))
bpy.context.scene.collection.objects.link(void_obj)
void_obj.matrix_world = void_obj.matrix_world.copy()
void_obj.matrix_world.translation = (
slab_obj.matrix_world.translation.x,
slab_obj.matrix_world.translation.y,
slab_obj.matrix_world.translation.z + 1.0,
)
tool.Blender.set_objects_selection(bpy.context, slab_obj, (slab_obj, void_obj))
bpy.ops.bim.add_opening()
assert len(slab.HasOpenings) == 1
opening = slab.HasOpenings[0].RelatedOpeningElement
assert opening.is_a("IfcOpeningElement")
class TestAddOpeningPollOnForeignAuthoredSlab(NewFile):
def test_poll_resolves_true_for_slab_without_layer3_usage(self):
"""An ``IfcSlab`` loaded from a non-Bonsai IFC carries no
``IfcMaterialLayerSetUsage``, so ``tool.Blender.Modifier.is_slab``
rejects it yet the gizmo's widened predicate accepts any
``IfcSlab`` because the positioner only reads the bound box.
This pins the bare-class branch through the full ``poll`` path
with real bpy + ifcopenshell state."""
import ifcopenshell.api.material
from bonsai.bim.module.model.host_add_opening_gizmo import (
GizmoHostAddOpening,
is_supported_host,
)
tool.Project.get_project_props().template_file = "IFC4 Demo Template.ifc"
bpy.ops.bim.create_project()
ifc_file = tool.Ifc.get()
slab_type = ifc_file.by_type("IfcSlabType")[0]
bpy.ops.bim.add_occurrence(relating_type_id=slab_type.id())
slab = ifc_file.by_type("IfcSlab")[0]
slab_obj = tool.Ifc.get_object(slab)
assert isinstance(slab_obj, bpy.types.Object)
# Strip every material association so the slab has no direct
# LayerSetUsage and nothing to inherit from the type. The slab is
# now a foreign-authored IFC class in everything but provenance.
ifcopenshell.api.material.unassign_material(ifc_file, products=[slab, slab_type])
assert tool.Blender.Modifier.is_slab(slab) is False
assert is_supported_host(slab) is True
void_obj = bpy.data.objects.new("VoidMesh", bpy.data.meshes.new("VoidMesh"))
bpy.context.scene.collection.objects.link(void_obj)
void_obj.matrix_world = void_obj.matrix_world.copy()
void_obj.matrix_world.translation = (
slab_obj.matrix_world.translation.x,
slab_obj.matrix_world.translation.y,
slab_obj.matrix_world.translation.z + 1.0,
)
tool.Blender.set_objects_selection(bpy.context, slab_obj, (slab_obj, void_obj))
assert GizmoHostAddOpening.poll(bpy.context) is True
class TestAddOpeningPollOnForeignAuthoredRoof(NewFile):
def test_poll_resolves_true_for_roof_without_bbim_pset(self):
"""A mesh-bodied ``IfcRoof`` promoted from a raw Blender mesh
carries no ``BBIM_Roof`` pset, so ``tool.Parametric.is_roof``
rejects it yet the gizmo's widened predicate accepts any
``IfcRoof`` because the positioner only reads the bound box. This
fixture mirrors how a foreign IFC roof loads (geometry + IFC
identity, no parametric markers)."""
from bonsai.bim.module.model.host_add_opening_gizmo import (
GizmoHostAddOpening,
is_supported_host,
)
tool.Project.get_project_props().template_file = "IFC4 Demo Template.ifc"
bpy.ops.bim.create_project()
bpy.ops.mesh.primitive_cube_add(size=2, location=(0, 0, 0))
roof_obj = bpy.context.active_object
assert roof_obj is not None
tool.Root.get_root_props().ifc_product = "IfcElement"
bpy.ops.bim.assign_class(ifc_class="IfcRoof")
roof = tool.Ifc.get_entity(roof_obj)
assert roof is not None and roof.is_a("IfcRoof")
assert tool.Parametric.is_roof(roof) is False
assert is_supported_host(roof) is True
void_obj = bpy.data.objects.new("VoidMesh", bpy.data.meshes.new("VoidMesh"))
bpy.context.scene.collection.objects.link(void_obj)
void_obj.matrix_world = void_obj.matrix_world.copy()
void_obj.matrix_world.translation = (
roof_obj.matrix_world.translation.x,
roof_obj.matrix_world.translation.y,
roof_obj.matrix_world.translation.z + 1.0,
)
tool.Blender.set_objects_selection(bpy.context, roof_obj, (roof_obj, void_obj))
assert GizmoHostAddOpening.poll(bpy.context) is True
@@ -0,0 +1,521 @@
# 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 tool.Geometry.get_dissolved_edges and the opening-decoration cache
layers. The dissolve helper's contract:
- Read-only on the input mesh.
- Returns (verts_local, edge_indices) indexed into the dissolved bmesh.
- Material seams survive (delimit=MATERIAL).
- Default angle threshold is 1°."""
from math import radians
import bmesh
import bpy
import pytest
from mathutils import Matrix, Vector
import bonsai.tool as tool
from bonsai.bim import decorator_cache
from bonsai.bim.module.model import opening as opening_module
pytestmark = pytest.mark.model
@pytest.fixture(autouse=True)
def _reset_decoration_caches():
# Tests share module-global state (dissolve cache + token, world-draw-data
# cache, batch cache, per-object epochs). Reset every layer so a previous
# test can't poison hit/miss assertions.
decorator_cache.reset_for_test()
opening_module._dissolved_edges_cache.clear()
opening_module._dissolved_edges_cache_token = -1
opening_module._world_draw_data_cache.clear()
opening_module._batch_cache.clear()
opening_module._object_epochs.clear()
yield
decorator_cache.reset_for_test()
opening_module._dissolved_edges_cache.clear()
opening_module._world_draw_data_cache.clear()
opening_module._batch_cache.clear()
opening_module._object_epochs.clear()
def _make_mesh(name: str, verts: list[tuple[float, float, float]], faces: list[tuple[int, ...]]) -> bpy.types.Mesh:
mesh = bpy.data.meshes.new(name)
mesh.from_pydata(verts, [], faces)
mesh.update()
return mesh
def _edge_count(mesh: bpy.types.Mesh) -> int:
bm = bmesh.new()
bm.from_mesh(mesh)
n = len(bm.edges)
bm.free()
return n
def test_collapses_coplanar_diagonal_on_triangulated_quad():
# Triangulated unit quad in the XY plane: 4 verts, 2 tris share a diagonal.
# Raw bmesh has 5 edges (4 quad sides + 1 diagonal). Dissolve must drop the
# diagonal because both triangles are perfectly coplanar.
mesh = _make_mesh(
"quad_tri",
verts=[(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)],
faces=[(0, 1, 2), (0, 2, 3)],
)
assert _edge_count(mesh) == 5
verts, edges = tool.Geometry.get_dissolved_edges(mesh)
assert len(verts) == 4
assert len(edges) == 4
# Every returned edge index must point into the returned verts list.
for a, b in edges:
assert 0 <= a < len(verts)
assert 0 <= b < len(verts)
assert a != b
def test_preserves_real_edges_on_cube():
# Default cube has 8 verts / 12 edges / 6 quad faces. There are no coplanar
# internal splits to dissolve, so the helper must return the cube intact.
mesh = bpy.data.meshes.new("cube")
bm = bmesh.new()
bmesh.ops.create_cube(bm, size=1.0)
bm.to_mesh(mesh)
bm.free()
verts, edges = tool.Geometry.get_dissolved_edges(mesh)
assert len(verts) == 8
assert len(edges) == 12
def test_preserves_material_seam_on_coplanar_split():
# Two coplanar triangles sharing an edge but each with a different
# material_index. delimit=MATERIAL must keep the shared edge alive.
mesh = _make_mesh(
"split_mat",
verts=[(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)],
faces=[(0, 1, 2), (0, 2, 3)],
)
mat_a = bpy.data.materials.new("mat_a")
mat_b = bpy.data.materials.new("mat_b")
mesh.materials.append(mat_a)
mesh.materials.append(mat_b)
mesh.polygons[0].material_index = 0
mesh.polygons[1].material_index = 1
mesh.update()
verts, edges = tool.Geometry.get_dissolved_edges(mesh)
# The 4 perimeter edges plus the shared diagonal: 5 total survive.
assert len(verts) == 4
assert len(edges) == 5
bpy.data.materials.remove(mat_a)
bpy.data.materials.remove(mat_b)
def test_does_not_mutate_input_mesh():
# The helper must be read-only: viewport draw handlers call it every frame
# and any obj.data mutation would race the depsgraph and trigger redraws.
mesh = _make_mesh(
"ro_quad",
verts=[(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)],
faces=[(0, 1, 2), (0, 2, 3)],
)
edges_before = _edge_count(mesh)
verts_before = len(mesh.vertices)
tool.Geometry.get_dissolved_edges(mesh)
assert _edge_count(mesh) == edges_before
assert len(mesh.vertices) == verts_before
def test_accepts_explicit_angle_limit():
# Smoke: the angle_limit kwarg must be honored end-to-end (not silently
# ignored). With a near-zero threshold, even sub-degree coplanar splits
# survive; with a generous threshold, they collapse.
mesh = _make_mesh(
"quad_tri",
verts=[(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)],
faces=[(0, 1, 2), (0, 2, 3)],
)
_, edges_zero = tool.Geometry.get_dissolved_edges(mesh, angle_limit=0.0)
_, edges_default = tool.Geometry.get_dissolved_edges(mesh)
assert len(edges_zero) > len(edges_default), "angle_limit=0 must preserve more edges than the default 1° dissolve"
def test_cache_serves_identical_object_on_repeat_call():
# Without caching, the helper rebuilds verts/edges every viewport redraw.
# Identity (`is`) — not equality — proves the second call hit the cache
# rather than recomputing identical content.
mesh = _make_mesh(
"cached",
verts=[(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)],
faces=[(0, 1, 2), (0, 2, 3)],
)
first = opening_module._get_cached_dissolved_edges(mesh)
second = opening_module._get_cached_dissolved_edges(mesh)
assert first is second
def test_cache_invalidates_on_decorator_token_bump():
# depsgraph_update_post / undo / redo / load all bump the shared decorator
# token; this cache must clear when the token changes so a downstream
# depsgraph edit (mesh content changed) is reflected on the next call.
mesh = _make_mesh(
"bumped",
verts=[(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)],
faces=[(0, 1, 2), (0, 2, 3)],
)
first = opening_module._get_cached_dissolved_edges(mesh)
decorator_cache._DECORATOR_CACHE_TOKEN += 1
second = opening_module._get_cached_dissolved_edges(mesh)
assert first is not second, "token bump must invalidate the cache entry"
assert len(first[0]) == len(second[0])
assert len(first[1]) == len(second[1])
def test_cache_partitions_entries_by_mesh_identity():
# Two distinct meshes share the same epoch; both must coexist in the cache
# so multi-opening frames don't thrash.
mesh_a = _make_mesh(
"a",
verts=[(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)],
faces=[(0, 1, 2), (0, 2, 3)],
)
mesh_b = _make_mesh(
"b",
verts=[(0, 0, 0), (2, 0, 0), (2, 2, 0), (0, 2, 0)],
faces=[(0, 1, 2), (0, 2, 3)],
)
a_first = opening_module._get_cached_dissolved_edges(mesh_a)
b_first = opening_module._get_cached_dissolved_edges(mesh_b)
a_second = opening_module._get_cached_dissolved_edges(mesh_a)
assert a_first is a_second, "mesh_a entry must survive an interleaved mesh_b call"
assert a_first is not b_first
def test_cache_partitions_entries_by_angle_limit():
# Same mesh, different angle_limit → different cached results. Hardens
# against a future caller introducing a per-opening threshold override.
mesh = _make_mesh(
"partitioned",
verts=[(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)],
faces=[(0, 1, 2), (0, 2, 3)],
)
tight = opening_module._get_cached_dissolved_edges(mesh, angle_limit=0.0)
loose = opening_module._get_cached_dissolved_edges(mesh, angle_limit=radians(1.0))
tight_again = opening_module._get_cached_dissolved_edges(mesh, angle_limit=0.0)
assert tight is tight_again
assert tight is not loose
# --- world-data cache (_get_cached_world_draw_data) ---------------------------
def _make_object(name: str, mesh: bpy.types.Mesh) -> bpy.types.Object:
obj = bpy.data.objects.new(name, mesh)
bpy.context.scene.collection.objects.link(obj)
return obj
def _make_triangulated_quad_obj(name: str) -> bpy.types.Object:
mesh = _make_mesh(
name,
verts=[(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)],
faces=[(0, 1, 2), (0, 2, 3)],
)
return _make_object(name, mesh)
def test_world_data_cache_returns_four_tuple_with_expected_shapes():
obj = _make_triangulated_quad_obj("shape")
line_verts, verts, edges_indices, tris = opening_module._get_cached_world_draw_data(obj)
assert len(verts) == 4 # full mesh vert count
assert len(line_verts) == 4 # dissolved (diagonal collapsed → 4 surviving verts)
assert len(edges_indices) == 4 # quad outline, no diagonal
assert len(tris) == 2 # two triangles
assert all(len(t) == 3 for t in tris)
def test_world_data_cache_hit_returns_identical_tuple_on_repeat_call():
obj = _make_triangulated_quad_obj("hit")
first = opening_module._get_cached_world_draw_data(obj)
second = opening_module._get_cached_world_draw_data(obj)
assert first is second
def test_world_data_cache_invalidates_on_object_epoch_bump():
# depsgraph_update_post bumps per-object epochs (one per Object whose
# transform or geometry changed). After bumping this object's epoch the
# next lookup must miss and recompute.
obj = _make_triangulated_quad_obj("bumped")
first = opening_module._get_cached_world_draw_data(obj)
opening_module._object_epochs[obj.session_uid] = opening_module._object_epochs.get(obj.session_uid, 0) + 1
second = opening_module._get_cached_world_draw_data(obj)
assert first is not second
def test_world_data_cache_partitions_entries_by_object_identity():
a = _make_triangulated_quad_obj("a")
b = _make_triangulated_quad_obj("b")
a_first = opening_module._get_cached_world_draw_data(a)
b_first = opening_module._get_cached_world_draw_data(b)
a_second = opening_module._get_cached_world_draw_data(a)
assert a_first is a_second
assert a_first is not b_first
def test_world_data_cache_reflects_new_matrix_after_epoch_bump():
# The cache stores world-space verts. A transform without an epoch bump
# would serve stale coordinates — but transform updates bump the object's
# epoch via the depsgraph handler, so after bump + recompute the new
# matrix must be reflected.
obj = _make_triangulated_quad_obj("moved")
before = opening_module._get_cached_world_draw_data(obj)
obj.matrix_world = obj.matrix_world @ Matrix.Translation((5.0, 0.0, 0.0))
opening_module._object_epochs[obj.session_uid] = opening_module._object_epochs.get(obj.session_uid, 0) + 1
after = opening_module._get_cached_world_draw_data(obj)
# Each vert in `after` is 5 units shifted on X relative to `before`.
for a_co, b_co in zip(after[1], before[1]):
assert a_co[0] - b_co[0] == pytest.approx(5.0)
assert a_co[1] == pytest.approx(b_co[1])
assert a_co[2] == pytest.approx(b_co[2])
def test_world_data_cache_ios_edges_path_returns_curated_edges():
# When the mesh has an ios_edges attribute, line_verts must equal the full
# verts (no dissolve), and edges_indices must include only entries where
# the attribute is True.
obj = _make_triangulated_quad_obj("curated")
attr = obj.data.attributes.new(name="ios_edges", type="BOOLEAN", domain="EDGE")
# 5 edges total (quad + diagonal). Mark only the 4 quad sides as real.
bm = bmesh.new()
bm.from_mesh(obj.data)
real_edges_count = 0
for i, edge in enumerate(bm.edges):
is_diagonal = (
abs(edge.verts[0].co[0] - edge.verts[1].co[0]) > 0 and abs(edge.verts[0].co[1] - edge.verts[1].co[1]) > 0
)
attr.data[i].value = not is_diagonal
if not is_diagonal:
real_edges_count += 1
bm.free()
obj.data.update()
line_verts, verts, edges_indices, _ = opening_module._get_cached_world_draw_data(obj)
assert line_verts is verts, "ios_edges path must reuse the full-verts list as line_verts"
assert len(edges_indices) == real_edges_count
def test_world_data_cache_dissolve_path_drops_diagonal():
# Without ios_edges, the cache falls through to dissolve. The 5th edge
# (diagonal) must be gone from edges_indices.
obj = _make_triangulated_quad_obj("dissolved")
line_verts, verts, edges_indices, _ = opening_module._get_cached_world_draw_data(obj)
assert len(edges_indices) == 4
assert len(line_verts) == 4
assert len(verts) == 4
# --- batch cache (_get_cached_batch_or_none / _store_batch_in_cache) ---------
def test_batch_cache_returns_none_on_cold_lookup():
assert opening_module._get_cached_batch_or_none((123, "lines")) is None
def test_batch_cache_returns_stored_batch_on_hit():
# Sentinel stands in for a GPUBatch — the cache treats it opaquely, so
# this test pins lookup/store correctness without needing a real shader.
sentinel = object()
opening_module._store_batch_in_cache((42, "lines"), sentinel)
assert opening_module._get_cached_batch_or_none((42, "lines")) is sentinel
def test_batch_cache_invalidates_on_object_epoch_bump():
sentinel = object()
opening_module._store_batch_in_cache((42, "lines"), sentinel)
opening_module._object_epochs[42] = opening_module._object_epochs.get(42, 0) + 1
assert opening_module._get_cached_batch_or_none((42, "lines")) is None
def test_batch_cache_partitions_entries_by_kind():
# Same object, different batch kinds (LINES vs TRIS vs arrow) coexist —
# required so the same opening's three batches don't evict each other.
lines_batch = object()
tris_batch = object()
opening_module._store_batch_in_cache((42, "lines"), lines_batch)
opening_module._store_batch_in_cache((42, "tris"), tris_batch)
assert opening_module._get_cached_batch_or_none((42, "lines")) is lines_batch
assert opening_module._get_cached_batch_or_none((42, "tris")) is tris_batch
def test_batch_cache_partitions_entries_by_object_uid():
a_batch = object()
b_batch = object()
opening_module._store_batch_in_cache((1, "lines"), a_batch)
opening_module._store_batch_in_cache((2, "lines"), b_batch)
assert opening_module._get_cached_batch_or_none((1, "lines")) is a_batch
assert opening_module._get_cached_batch_or_none((2, "lines")) is b_batch
# --- per-object epoch invalidation (granularity contract) --------------------
def test_world_data_cache_per_object_epoch_invalidates_only_target():
# Core contract for the granular-invalidation feature: bumping one object's
# epoch must not evict another object's cached payload. This is what makes
# dragging a single object in a 50-opening scene affordable.
a = _make_triangulated_quad_obj("granular_a")
b = _make_triangulated_quad_obj("granular_b")
a_first = opening_module._get_cached_world_draw_data(a)
b_first = opening_module._get_cached_world_draw_data(b)
opening_module._object_epochs[a.session_uid] = opening_module._object_epochs.get(a.session_uid, 0) + 1
a_second = opening_module._get_cached_world_draw_data(a)
b_second = opening_module._get_cached_world_draw_data(b)
assert a_first is not a_second, "a's epoch bump must invalidate a's entry"
assert b_first is b_second, "a's epoch bump must NOT touch b's entry"
def test_batch_cache_per_object_epoch_invalidates_only_target():
a_lines = object()
b_lines = object()
opening_module._store_batch_in_cache((1, "lines"), a_lines)
opening_module._store_batch_in_cache((2, "lines"), b_lines)
opening_module._object_epochs[1] = opening_module._object_epochs.get(1, 0) + 1
assert opening_module._get_cached_batch_or_none((1, "lines")) is None
assert opening_module._get_cached_batch_or_none((2, "lines")) is b_lines
def test_global_clear_handler_wipes_everything():
# undo/redo/load can't be modeled as per-object deltas — the global handler
# must wipe every layer (epochs + both caches) so we can never serve state
# that pre-dates the undo/load.
a = _make_triangulated_quad_obj("wipe_a")
opening_module._get_cached_world_draw_data(a)
opening_module._store_batch_in_cache((a.session_uid, "lines"), object())
assert a.session_uid in opening_module._world_draw_data_cache
assert (a.session_uid, "lines") in opening_module._batch_cache
opening_module._clear_decoration_caches_globally()
assert opening_module._world_draw_data_cache == {}
assert opening_module._batch_cache == {}
assert opening_module._object_epochs == {}
class _FakeDepsgraphUpdate:
def __init__(self, id_, transform: bool = False, geometry: bool = False):
self.id = id_
self.is_updated_transform = transform
self.is_updated_geometry = geometry
class _FakeDepsgraph:
def __init__(self, updates):
self.updates = updates
def test_depsgraph_handler_bumps_epoch_for_updated_object():
# Synthesised depsgraph delta: one Object with a transform update. The
# handler must increment that object's epoch.
obj = _make_triangulated_quad_obj("bumped_via_handler")
before = opening_module._object_epochs.get(obj.session_uid, 0)
deps = _FakeDepsgraph([_FakeDepsgraphUpdate(obj, transform=True)])
opening_module._bump_object_epochs_for_decoration(None, deps)
assert opening_module._object_epochs[obj.session_uid] == before + 1
def test_depsgraph_handler_ignores_non_object_updates():
# Updates whose .id isn't a bpy.types.Object (Mesh, Material, NodeTree…)
# must not affect any object's epoch.
obj = _make_triangulated_quad_obj("untouched")
deps = _FakeDepsgraph([_FakeDepsgraphUpdate(obj.data, geometry=True)])
opening_module._bump_object_epochs_for_decoration(None, deps)
assert obj.session_uid not in opening_module._object_epochs
def test_depsgraph_handler_ignores_updates_without_transform_or_geometry():
# An Object update flagged only for shading must not bump the epoch —
# shading changes don't move the wire overlay.
obj = _make_triangulated_quad_obj("shading_only")
deps = _FakeDepsgraph([_FakeDepsgraphUpdate(obj)])
opening_module._bump_object_epochs_for_decoration(None, deps)
assert obj.session_uid not in opening_module._object_epochs
def test_depsgraph_handler_resolves_cow_original():
# For non-evaluated Blender objects, obj.original returns obj itself, so
# the .original-resolution path keys the SAME uid the draw handler reads.
# Pinning this prevents a future refactor that drops the .original lookup
# from silently regressing the COW-boundary case (the decorator failing to
# follow a moved object).
obj = _make_triangulated_quad_obj("cow")
deps = _FakeDepsgraph([_FakeDepsgraphUpdate(obj, transform=True)])
opening_module._bump_object_epochs_for_decoration(None, deps)
assert obj.original.session_uid in opening_module._object_epochs
def test_depsgraph_handler_tolerates_missing_depsgraph():
# Some Blender event paths may call the handler without a depsgraph; the
# handler must short-circuit instead of raising AttributeError.
opening_module._bump_object_epochs_for_decoration()
opening_module._bump_object_epochs_for_decoration(None)
opening_module._bump_object_epochs_for_decoration(None, None)
assert opening_module._object_epochs == {}
@@ -0,0 +1,178 @@
# 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 parametric-edit preview registry contract.
Every test reads the live ``PREVIEW_CANCEL_OPS`` registry rather than hard-
coding preview keys or cancel-operator names, so adding a new preview to the
registry automatically exercises the same invariants without test changes."""
import types
import bpy
import pytest
pytestmark = pytest.mark.model
@pytest.fixture(autouse=True)
def _require_real_bpy():
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
pytest.skip("requires real Blender (bpy is mocked or absent)")
def _registry():
from bonsai.bim.module.model.preview_base import PREVIEW_CANCEL_OPS
return PREVIEW_CANCEL_OPS
def _preview_umbrella():
return getattr(bpy.context.scene, "BIMPreviewProperties", None)
def _registered_previews():
"""``[(attr, op_name, props)]`` for every registry entry that has a real
child PropertyGroup on the umbrella in the current addon build."""
umbrella = _preview_umbrella()
if umbrella is None:
return []
out = []
for attr, op_name in _registry():
props = getattr(umbrella, attr, None)
if props is not None:
out.append((attr, op_name, props))
return out
class TestRegistryContract:
"""Pins the invariant that every entry in PREVIEW_CANCEL_OPS resolves to
a real cancel operator the addon registers. A new preview added to the
registry without its matching cancel operator would otherwise crash
``try_cancel_active_preview`` on the first Esc."""
def test_every_registered_cancel_op_is_callable(self):
for attr, op_name in _registry():
op = getattr(bpy.ops.bim, op_name, None)
assert op is not None and callable(op), (
f"Preview '{attr}' in PREVIEW_CANCEL_OPS points to bim.{op_name} "
f"but no such operator is registered."
)
class TestGetPreviewPropsTolerance:
"""The bug-class fixed in commit ee63137c6: ``get_preview_props`` is called
from gizmo polls during addon init and from test mocks built on
``SimpleNamespace`` neither has a fully-formed Blender context. The
helper must return None rather than raise."""
def test_returns_none_when_context_has_no_scene(self):
from bonsai.bim.module.model.preview_base import get_preview_props
# Pass an arbitrary attr name — the contract is the same for every
# preview key, so picking one literally would be a maintenance trap.
for attr, _ in _registry():
assert get_preview_props(types.SimpleNamespace(), attr) is None
break
def test_returns_none_when_scene_lacks_umbrella(self):
from bonsai.bim.module.model.preview_base import get_preview_props
ctx = types.SimpleNamespace(scene=types.SimpleNamespace())
for attr, _ in _registry():
assert get_preview_props(ctx, attr) is None
break
class TestActivationCycle:
"""End-to-end contract on the real addon: each registered preview can be
activated and then cancelled to inactive. Runs for every preview that
has a wired PropertyGroup, so a new preview added to the registry +
umbrella is covered without test edits."""
def test_any_preview_active_reflects_each_preview_state(self):
from bonsai.bim.module.model.preview_base import any_preview_active
registered = _registered_previews()
if not registered:
pytest.skip("No previews wired in this build — registry-only entries")
# All inactive baseline.
for _, _, props in registered:
props.is_active = False
assert any_preview_active(bpy.context) is False
# Flip each one independently — the helper must report True.
for _, _, props in registered:
props.is_active = True
assert any_preview_active(bpy.context) is True
props.is_active = False
def test_discard_pending_previews_clears_every_active_flag(self):
from bonsai.bim.module.model.preview_base import discard_pending_previews
registered = _registered_previews()
if not registered:
pytest.skip("No previews wired in this build — registry-only entries")
for _, _, props in registered:
props.is_active = True
discard_pending_previews(bpy.context.scene)
for attr, _, props in registered:
assert props.is_active is False, f"discard_pending_previews left '{attr}' active"
class TestSaveOnDiscardWired:
"""Pins that the SaveProject operator clears preview state before writing
the IFC file a stuck is_active flag persisted through the save would
silently hide sister gizmos on the next file load.
Structural check: the SaveProject operator class must reference the
discard helper somewhere in its execute path. Behavioural integration
(actually saving a .blend with an active preview and reloading) belongs
in the bim feature suite; this is the small guard against accidental
removal of the call site."""
def test_save_project_dispatches_discard_pending_previews(self):
import inspect
from bonsai.bim.module.model import preview_base
from bonsai.bim.module.project import operator as project_operator
# Find the project save operator dynamically — looking for any
# Operator class whose bl_idname is "bim.save_project". Avoids
# hard-coding the class identifier.
save_op = None
for name in dir(project_operator):
obj = getattr(project_operator, name)
if isinstance(obj, type) and getattr(obj, "bl_idname", None) == "bim.save_project":
save_op = obj
break
assert save_op is not None, "Expected an operator with bl_idname='bim.save_project' in project/operator.py"
# Walk the class's methods for the discard call. Avoids pinning a
# specific method name (_execute vs execute vs an inner helper) so
# the test survives operator refactors.
source = inspect.getsource(save_op)
assert preview_base.discard_pending_previews.__name__ in source, (
f"{save_op.__name__} does not reference discard_pending_previews. "
"Saving with a preview open would persist its is_active flag to the "
".blend file and silently hide sister gizmos on reopen."
)
@@ -0,0 +1,306 @@
# 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.
"""Unit tests for the roof parametric gizmo group.
Covers the parts of ``GizmoRoofEdition`` that don't need a live Blender
viewport: the mode-conditional ``visibility_condition`` lambdas, the
slope ``compute_value`` / ``apply_value`` roundtrip, the
``CycleRoofGenerationMethod`` operator metadata + cycle behaviour, and
the ``_update_dimension_gizmo_positions`` override that anchors all three
dimension gizmos at the object's local origin."""
import math
from types import SimpleNamespace
from unittest.mock import patch
import bpy
import pytest
pytestmark = pytest.mark.model
def _get_config(attr_name):
"""Return the ``DimensionGizmoConfig`` for ``attr_name`` from the roof gizmo."""
from bonsai.bim.module.model.roof import GizmoRoofEdition
for cfg in GizmoRoofEdition.dimension_gizmo_props:
if cfg.attr_name == attr_name:
return cfg
raise AssertionError(f"no DimensionGizmoConfig with attr_name={attr_name!r}")
# ----------------------------------------------------------------------------
# Mode-conditional visibility
# ----------------------------------------------------------------------------
#
# ``height`` and ``angle`` are mutually exclusive — exactly one is shown
# depending on ``generation_method``. ``roof_thickness`` applies regardless
# of the generation mode.
def test_height_gizmo_visible_only_in_height_mode():
cfg = _get_config("height")
assert cfg.visibility_condition(SimpleNamespace(generation_method="HEIGHT")) is True
assert cfg.visibility_condition(SimpleNamespace(generation_method="ANGLE")) is False
def test_angle_gizmo_visible_only_in_angle_mode():
cfg = _get_config("angle")
assert cfg.visibility_condition(SimpleNamespace(generation_method="ANGLE")) is True
assert cfg.visibility_condition(SimpleNamespace(generation_method="HEIGHT")) is False
def test_thickness_has_no_mode_gate():
"""Slab thickness applies to both generation modes — pinning
``visibility_condition is None`` guards against an accidental mode-gate
being added later that would silently hide it when toggling modes."""
assert _get_config("roof_thickness").visibility_condition is None
# ----------------------------------------------------------------------------
# Slope (angle) ↔ rise roundtrip
# ----------------------------------------------------------------------------
#
# The slope handle displays vertical rise at a fixed 1m run; dragging it
# updates ``props.angle`` via ``atan2(rise, run)``. Roundtrip preservation
# is the contract — feeding ``compute_value`` into ``apply_value`` must
# leave the angle unchanged (within float tolerance).
def test_slope_compute_value_returns_rise_at_reference_run():
from bonsai.bim.module.model.roof import _ROOF_SLOPE_REFERENCE_RUN
cfg = _get_config("angle")
# 30° slope → rise = tan(30°) * 1m ≈ 0.5774 m
props = SimpleNamespace(angle=math.radians(30))
assert cfg.compute_value(props) == pytest.approx(math.tan(math.radians(30)) * _ROOF_SLOPE_REFERENCE_RUN)
def test_slope_apply_value_sets_angle_from_rise():
from bonsai.bim.module.model.roof import _ROOF_SLOPE_REFERENCE_RUN
cfg = _get_config("angle")
props = SimpleNamespace(angle=0.0)
cfg.apply_value(props, 0.5)
assert props.angle == pytest.approx(math.atan2(0.5, _ROOF_SLOPE_REFERENCE_RUN))
def test_slope_roundtrip_preserves_angle():
cfg = _get_config("angle")
for deg in (5, 15, 30, 45, 60, 80):
props = SimpleNamespace(angle=math.radians(deg))
rise = cfg.compute_value(props)
cfg.apply_value(props, rise)
assert math.degrees(props.angle) == pytest.approx(deg, abs=1e-6)
def test_slope_apply_value_clamps_negative_to_zero():
"""A negative drag (rise < 0) must not produce a negative angle —
``atan2(-x, run)`` would yield a negative result, but ``apply_value``
clamps to ``[0, pi/2 - 1e-3]`` so the roof never inverts."""
cfg = _get_config("angle")
props = SimpleNamespace(angle=math.radians(30))
cfg.apply_value(props, -1.0)
assert props.angle == 0.0
def test_slope_apply_value_clamps_at_near_vertical():
"""Slopes approaching 90° are clamped just below to avoid a vertical
extrusion that would degenerate the bisect step in
``generate_hipped_roof_bmesh``."""
from bonsai.bim.module.model.roof import _ROOF_MAX_SLOPE_ANGLE
cfg = _get_config("angle")
props = SimpleNamespace(angle=0.0)
cfg.apply_value(props, 1e9) # absurdly steep
assert props.angle == pytest.approx(_ROOF_MAX_SLOPE_ANGLE)
# ----------------------------------------------------------------------------
# Cycle operator metadata
# ----------------------------------------------------------------------------
#
# ``CycleRoofGenerationMethod`` plugs into ``CycleTypeMixin`` so the
# HEIGHT ↔ ANGLE icon cycles through the two values. The mixin reads four
# class attributes to do its work; if any drift, the cycle no-ops or
# CANCELLED-loops in subtle ways. Pin them here.
def test_cycle_operator_class_metadata():
from typing import get_args
from bonsai import tool
from bonsai.bim.module.model.roof import CycleRoofGenerationMethod
assert CycleRoofGenerationMethod.bl_idname == "bim.cycle_roof_generation_method"
assert CycleRoofGenerationMethod.element_checker == tool.Parametric.is_roof
assert CycleRoofGenerationMethod.props_getter == tool.Model.get_roof_props
assert CycleRoofGenerationMethod.type_attr == "generation_method"
# The Literal resolves to ("HEIGHT", "ANGLE") — the mixin calls
# ``get_args(type_literal)`` to enumerate the cycle.
assert get_args(CycleRoofGenerationMethod.type_literal) == ("HEIGHT", "ANGLE")
assert CycleRoofGenerationMethod.type_literal is tool.Model.RoofGenerationMethod
def test_cycle_operator_wired_on_gizmo_group():
"""The gizmo group's ``cycle_type_operator`` must match the bl_idname or
the base class skips the cycle icon entirely (see gizmos.py:4987)."""
from bonsai.bim.module.model.roof import CycleRoofGenerationMethod, GizmoRoofEdition
assert GizmoRoofEdition.cycle_type_operator == CycleRoofGenerationMethod.bl_idname
def _cycle_stub_self(*, reverse: bool, props, element_is_target: bool = True):
"""Build a stub ``self`` for ``CycleTypeMixin._cycle_type``.
``bpy.types.Operator`` subclasses can't be ``__init__``-ed outside of
Blender's registration path (``bpy_struct.__new__`` rejects a bare
call). Calling the unbound mixin method with a stub ``self`` that
mirrors the class attributes the method reads is the cleanest way to
exercise the cycle logic without launching a registered operator
instance.
``element_checker`` and ``props_getter`` are captured by the cycle
operator at class-definition time, so global ``tool.*`` patches at
test time can't intercept them — the stub injects callables directly
instead. ``_resolve_target`` is bound from ``TypeAccessorBase`` so
the cycle method's call into it dispatches against the stub
attributes."""
from types import MethodType
from bonsai.bim.module.model.roof import CycleRoofGenerationMethod
from bonsai.bim.parametric_lifecycle import TypeAccessorBase
stub = SimpleNamespace(
reverse=reverse,
skip_element_check=False,
element_checker=lambda _elem: element_is_target,
props_getter=lambda _obj: props,
type_literal=CycleRoofGenerationMethod.type_literal,
type_attr=CycleRoofGenerationMethod.type_attr,
)
stub._resolve_target = MethodType(TypeAccessorBase._resolve_target, stub)
return stub
def test_cycle_type_advances_forward():
"""``_cycle_type`` advances the prop value to the next item in the
Literal. The stub injects ``element_checker`` / ``props_getter``
directly so the method runs without a live IFC fixture."""
from bonsai import tool
from bonsai.bim import parametric_lifecycle as gizmo_module
props = SimpleNamespace(generation_method="HEIGHT")
context = SimpleNamespace(active_object=object())
with patch.object(tool.Ifc, "get_entity", return_value=object()):
result = gizmo_module.CycleTypeMixin._cycle_type(_cycle_stub_self(reverse=False, props=props), context)
assert result == {"FINISHED"}
assert props.generation_method == "ANGLE"
def test_cycle_type_reverse_walks_backward():
"""Shift+click sets ``reverse=True`` and walks the cycle in the other
direction from HEIGHT that means wrapping to ANGLE (the last item)."""
from bonsai import tool
from bonsai.bim import parametric_lifecycle as gizmo_module
props = SimpleNamespace(generation_method="HEIGHT")
context = SimpleNamespace(active_object=object())
with patch.object(tool.Ifc, "get_entity", return_value=object()):
gizmo_module.CycleTypeMixin._cycle_type(_cycle_stub_self(reverse=True, props=props), context)
assert props.generation_method == "ANGLE" # wrapped from HEIGHT backward
def test_cycle_type_cancels_when_active_is_not_a_roof():
"""Non-roof active object → CANCELLED, props untouched. Guards against
a stray cycle click on a wall mutating ``wall.generation_method`` (a
non-existent attr) and silently no-oping or AttributeError-ing later."""
from bonsai import tool
from bonsai.bim import parametric_lifecycle as gizmo_module
props = SimpleNamespace(generation_method="HEIGHT")
context = SimpleNamespace(active_object=object())
with patch.object(tool.Ifc, "get_entity", return_value=object()):
result = gizmo_module.CycleTypeMixin._cycle_type(
_cycle_stub_self(reverse=False, props=props, element_is_target=False),
context,
)
assert result == {"CANCELLED"}
assert props.generation_method == "HEIGHT"
# ----------------------------------------------------------------------------
# _update_dimension_gizmo_positions — origin anchoring
# ----------------------------------------------------------------------------
#
# All three dimension gizmos anchor at the object's local origin. Their
# declared axes (height/slope +Z, thickness -Z) separate them in 3D so
# they don't visually collide despite sharing a position; height + slope
# are themselves mutually exclusive via visibility_condition on
# generation_method.
def test_override_positions_all_dimensions_at_object_origin():
"""The override calls ``set_dimension_gizmo_position`` with the
object-local origin (0, 0, 0) for every dimension gizmo. Anchoring at
the object origin keeps the gizmos tied to the object's matrix_world
rather than to footprint geometry that may not be cached yet fixes
the first-click default-identity-matrix bug structurally."""
from bonsai.bim.module.model.roof import GizmoRoofEdition
calls: dict[str, tuple] = {}
def record(attr_name, _mw, position, axis, _value=None):
calls[attr_name] = (position, axis)
stub = SimpleNamespace(set_dimension_gizmo_position=record)
GizmoRoofEdition._update_dimension_gizmo_positions(stub, context=None, mw=None, props=None)
assert set(calls) == {"height", "angle", "roof_thickness"}
for name in ("height", "angle", "roof_thickness"):
position, _axis = calls[name]
assert position.xyz[:] == pytest.approx(
(0.0, 0.0, 0.0)
), f"{name} anchored at {position.xyz[:]} instead of object origin"
# Axes split the three handles along Z+ (height/slope) vs Z- (thickness)
# so they don't visually collide despite sharing the anchor point.
assert calls["height"][1] == (0, 0, 1)
assert calls["angle"][1] == (0, 0, 1)
assert calls["roof_thickness"][1] == (0, 0, -1)
# ----------------------------------------------------------------------------
# Registration smoke test
# ----------------------------------------------------------------------------
#
# Pattern 4 from _shared/bonsai-test-patterns.md: assert the operator is
# actually registered as ``bim.cycle_roof_generation_method``. Catches
# ``bl_idname`` typos and missing-from-``classes``-tuple regressions at
# test time rather than at user-click time (the failure mode otherwise is
# a silent no-op on the cycle icon, because the gizmo base class skips the
# icon entirely if its ``cycle_type_operator`` resolves to nothing).
def test_cycle_operator_is_registered_under_bim_namespace():
assert hasattr(bpy.ops.bim, "cycle_roof_generation_method")
@@ -0,0 +1,207 @@
# 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.
"""Regression guard for the stair icon billboard fix.
Before the fix, ``set_icon_gizmo_position`` in ``bim.module.drawing.gizmos``
composed ``mw @ (Translation @ billboard_rot @ Scale)``, which applied the
stair's world rotation on top of the billboard rotation. The result was
icons (validate / cancel / lock / +/- / cycle / tread_lock) drawn edge-on
to the camera for any stair rotated in plan effectively unclickable.
The fix routes through ``billboarded_at(world_pos, billboard_rot, scale)``,
which computes ``Translation(world_pos) @ billboard_rot @ Scale`` the
object's rotation is folded into the translation only, never the rotation."""
import math
import types
import bpy
import pytest
pytestmark = pytest.mark.model
@pytest.fixture(autouse=True)
def _require_real_bpy():
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
pytest.skip("requires real Blender (bpy is mocked or absent)")
def _rotation_close(a, b, tol: float = 1e-6) -> bool:
for row_a, row_b in zip(a, b):
for va, vb in zip(row_a, row_b):
if abs(va - vb) > tol:
return False
return True
@pytest.mark.parametrize("angle_deg", [0, 30, 45, 90, 135, 217])
def test_billboarded_at_rotation_is_pure_billboard(angle_deg):
"""Object rotation must not leak into the gizmo's rotation part."""
from mathutils import Matrix, Vector
from bonsai.bim.module.drawing.gizmos import billboarded_at
mw = Matrix.Rotation(math.radians(angle_deg), 4, "Z") @ Matrix.Translation((3, 4, 5))
billboard_rot = Matrix.Rotation(math.radians(30), 4, "X")
world_pos = mw @ Vector((1, 0, 2))
result = billboarded_at(world_pos, billboard_rot, scale=0.5)
# The rotation part of result, after stripping the 0.5 uniform scale,
# must equal billboard_rot — no contribution from mw's rotation.
rotation_part = result.to_3x3() * 2.0
assert _rotation_close(rotation_part.to_4x4(), billboard_rot)
def test_billboarded_at_translation_is_world_pos():
"""Translation lands exactly at the world-space target."""
from mathutils import Matrix, Vector
from bonsai.bim.module.drawing.gizmos import billboarded_at
world_pos = Vector((1.23, 4.56, 7.89))
result = billboarded_at(world_pos, Matrix.Identity(4), scale=0.5)
assert (result.translation - world_pos).length < 1e-6
def test_set_icon_gizmo_position_does_not_apply_object_rotation():
"""End-to-end: the helper used by every stair icon (and shared with all
parametric gizmo groups) must produce a matrix whose rotation part is
billboard_rot, not mw_rotation @ billboard_rot. This is the exact bug
that left stair icons edge-on to the camera."""
from mathutils import Matrix, Vector
from bonsai.bim.module.drawing.gizmos import (
BaseParametricGizmoGroup,
billboarded_at,
)
# Same inputs as the real call site (stair.py:747-765), but we drive the
# helper directly so we don't need a registered GizmoGroup. We bind a
# stand-in `get_gizmo_if_visible` that returns a tiny mock; the helper's
# observable output is the matrix_basis it assigns.
captured = {}
class _GizmoStub:
matrix_basis = Matrix.Identity(4)
stub = _GizmoStub()
def _fake_get(name):
captured["name"] = name
return stub
# Bind the helper to a throwaway instance so `self.get_gizmo_if_visible`
# resolves to our stub without registering a real GizmoGroup with Blender.
fake_self = types.SimpleNamespace(get_gizmo_if_visible=_fake_get)
mw = Matrix.Rotation(math.radians(45), 4, "Z") @ Matrix.Translation((3, 4, 5))
billboard_rot = Matrix.Rotation(math.radians(30), 4, "X")
local_pos = Vector((1, 0, 2))
BaseParametricGizmoGroup.set_icon_gizmo_position(
fake_self,
"validate_gizmo",
mw=mw,
x=local_pos.x,
y=local_pos.y,
z=local_pos.z,
billboard_rot=billboard_rot,
scale=0.5,
)
expected = billboarded_at(mw @ local_pos, billboard_rot, 0.5)
assert captured["name"] == "validate_gizmo"
for row_a, row_b in zip(stub.matrix_basis, expected):
for va, vb in zip(row_a, row_b):
assert abs(va - vb) < 1e-6
def test_icon_slot_placeholder_skips_validation_and_returns_no_attrs():
"""Placeholder slots reserve an X position without an auto-created gizmo:
construction must not require ``gizmo_idname`` / ``operator``, and
``gizmo_attrs()`` must return an empty tuple so the base class's
setup/positioning loops naturally skip the slot."""
from bonsai.bim.module.drawing.gizmos import IconSlot
slot = IconSlot(name="my_label", placeholder=True)
assert slot.placeholder is True
assert slot.gizmo_attrs() == ()
with pytest.raises(TypeError, match="gizmo_idname"):
IconSlot(name="broken")
def test_count_label_gizmo_is_registered():
"""The shared text-only ``xN`` gizmo must register so the stair group's
``gizmos.new("BIM_GT_count_label")`` resolves."""
from bonsai.bim.module.drawing.gizmos import GizmoCountLabel
assert GizmoCountLabel.bl_idname == "BIM_GT_count_label"
assert bpy.types.Gizmo.bl_rna_get_subclass_py("BIM_GT_count_label") is GizmoCountLabel
def test_stair_edit_row_reserves_label_slot_between_tread_lock_and_plus():
"""The ``tread_count_label`` placeholder slot must sit one
``ICON_ARRAY_GAP`` past the tread-lock and one gap before the plus
icon, so the layout naturally allocates the count label's X without
any subclass-side gap math."""
from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup
from bonsai.bim.module.model.stair import GizmoStairEdition
slot_x = GizmoStairEdition._slot_x_positions()
gap = BaseParametricGizmoGroup.ICON_ARRAY_GAP
assert "tread_count_label" in slot_x
assert slot_x["tread_count_label"] - slot_x["tread_lock"] == pytest.approx(gap)
assert slot_x["plus"] - slot_x["tread_count_label"] == pytest.approx(gap)
assert slot_x["minus"] - slot_x["plus"] == pytest.approx(gap)
def test_update_tread_count_gizmos_toggles_label_with_editing():
"""``update_tread_count_gizmos`` must propagate ``props.is_editing``
to the label's hide state so the badge appears only inside edit mode."""
from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup
from bonsai.bim.module.model.stair import GizmoStairEdition
class _GizmoStub:
def __init__(self):
self.hide = False
plus_gz = _GizmoStub()
minus_gz = _GizmoStub()
label_gz = _GizmoStub()
fake_self = types.SimpleNamespace(
plus_gizmo=plus_gz,
minus_gizmo=minus_gz,
tread_count_label_gizmo=label_gz,
update_gizmo_visibility=lambda g, v: BaseParametricGizmoGroup.update_gizmo_visibility(fake_self, g, v),
is_gizmo_hidden_by_modal=lambda g: False,
)
props_editing = types.SimpleNamespace(is_editing=True, number_of_treads=5)
GizmoStairEdition.update_tread_count_gizmos(fake_self, props_editing)
assert label_gz.hide is False
props_idle = types.SimpleNamespace(is_editing=False, number_of_treads=5)
GizmoStairEdition.update_tread_count_gizmos(fake_self, props_idle)
assert label_gz.hide is True
@@ -0,0 +1,106 @@
# 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 ``parametric_lifecycle.resync_parametric_drafts_after_undo``.
Blender's undo restores PropertyGroup field values but does not refire
their ``update`` callbacks, so the preview mesh of an in-progress
parametric draft desyncs from the gizmo dimension widget after Ctrl+Z.
The resync helper walks active drafts and re-runs the per-type
regenerator to bring preview back in line with the (restored) draft
state. This file pins the dispatch contract."""
from unittest.mock import MagicMock, patch
import bpy
import pytest
import bonsai.tool as tool
from bonsai.bim import parametric_lifecycle
pytestmark = pytest.mark.model
def test_undo_regenerators_target_registered_parametric_types():
"""Every entry in ``UNDO_REGENERATORS`` must name a real parametric
type. A typo would silently no-op on Ctrl+Z, restoring the desync
this helper is meant to prevent."""
registered_names = {f.name for f in tool.Parametric.EDIT_TYPES}
unknown = set(parametric_lifecycle.UNDO_REGENERATORS) - registered_names
assert not unknown, f"UNDO_REGENERATORS keys {unknown} are not in tool.Parametric.EDIT_TYPES"
def test_resync_skips_objects_not_in_parametric_edit():
"""Objects with no active parametric edit must not trigger any
regenerator the helper is called from undo_post which fires on
every undo, including undos that touch zero parametric drafts."""
captured = []
def fake_dispatch(obj):
captured.append(obj)
with patch.dict(parametric_lifecycle.UNDO_REGENERATORS, {"wall": fake_dispatch}, clear=False), patch.object(
tool.Parametric, "is_object_editing", return_value=None
):
parametric_lifecycle.resync_parametric_drafts_after_undo()
assert captured == []
def test_resync_dispatches_to_registered_regenerator_for_editing_object():
"""When an object is in parametric edit and its type has a registered
regenerator, the regenerator must run with that object as the sole
arg. This is the load-bearing branch: preview mesh re-renders from
current props, so the gizmo and preview re-sync."""
captured = []
def fake_wall_regenerator(obj):
captured.append(obj)
fake_feature = MagicMock(spec=tool.parametric.ParametricObject)
fake_feature.name = "wall"
obj = bpy.data.objects.new("test_wall_obj", bpy.data.meshes.new("test_wall_mesh"))
try:
with patch.dict(
parametric_lifecycle.UNDO_REGENERATORS, {"wall": fake_wall_regenerator}, clear=False
), patch.object(tool.Parametric, "is_object_editing", side_effect=lambda o: fake_feature if o is obj else None):
parametric_lifecycle.resync_parametric_drafts_after_undo()
finally:
bpy.data.objects.remove(obj, do_unlink=True)
assert captured == [obj]
def test_resync_skips_editing_object_whose_type_has_no_regenerator():
"""A parametric type without an ``UNDO_REGENERATORS`` entry (door /
window / array IFC-derived preview, no desync) must not raise; the
helper silently skips it."""
fake_feature = MagicMock(spec=tool.parametric.ParametricObject)
fake_feature.name = "door" # door has no entry in UNDO_REGENERATORS
obj = bpy.data.objects.new("test_door_obj", bpy.data.meshes.new("test_door_mesh"))
try:
with patch.object(
tool.Parametric, "is_object_editing", side_effect=lambda o: fake_feature if o is obj else None
):
parametric_lifecycle.resync_parametric_drafts_after_undo()
finally:
bpy.data.objects.remove(obj, do_unlink=True)
@@ -0,0 +1,154 @@
# 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.
"""Behaviour contract: every wall gizmo group hides while a parametric-edit
preview is active.
Enumerates wall gizmo groups by walking the wall module for ``bpy.types.GizmoGroup``
subclasses rather than naming them adding a new wall gizmo group automatically
joins the test. The test then asserts the BEHAVIOUR (poll returns False when
``preview_base.any_preview_active`` is True) without pinning the name of the
helper function the gizmo uses internally to enforce it."""
import inspect
import types
from unittest.mock import patch
import bpy
import pytest
pytestmark = pytest.mark.model
@pytest.fixture(autouse=True)
def _require_real_bpy():
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
pytest.skip("requires real Blender (bpy is mocked or absent)")
def _wall_gizmo_groups():
"""Walk the wall module for ``bpy.types.GizmoGroup`` subclasses defined
locally (skip imported references). Returns a list of (name, cls) tuples.
A gizmo group whose ``poll`` legitimately needs to fire WHILE a preview
is active i.e. it IS the preview's own gizmo group — is excluded by
convention: classes whose bl_idname references the preview surface
(``preview`` in the idname) are the preview-owner exception."""
from bonsai.bim.module.model import wall as wall_mod
out = []
for name in dir(wall_mod):
obj = getattr(wall_mod, name)
if not isinstance(obj, type):
continue
if not issubclass(obj, bpy.types.GizmoGroup) or obj is bpy.types.GizmoGroup:
continue
# Local definitions only — skip re-exports / aliases.
if obj.__module__ != wall_mod.__name__:
continue
# Preview-owner exception: the gizmo group that drives a preview
# itself must remain visible while its preview is active, so a
# "no preview active" gate would self-block it. The bl_idname
# contains the substring 'preview' for these groups by Bonsai
# convention (e.g. OBJECT_GGT_bim_wall_fillet_preview).
bl_idname = getattr(obj, "bl_idname", "") or ""
if "preview" in bl_idname.lower():
continue
out.append((name, obj))
return out
class TestWallGizmoGroupsHideDuringPreview:
"""Behaviour contract: a parametric-edit preview is the only interactive
surface in the viewport, so every sister wall gizmo must self-hide via
its poll. The test exercises this BEHAVIOUR when ``any_preview_active``
reports True, every wall gizmo's poll returns False — without pinning
the helper function name each poll uses internally."""
def test_discovery_finds_wall_gizmo_groups(self):
"""Sanity check: at least one wall gizmo group is found. If this fails,
the discovery walk drifted out of sync with the module structure (e.g.
wall gizmo groups got moved to a separate file)."""
groups = _wall_gizmo_groups()
assert groups, "Expected at least one wall GizmoGroup subclass in wall.py — discovery walk broke?"
def test_every_wall_gizmo_hides_when_a_preview_is_active(self):
"""For each discovered wall gizmo group, mock ``any_preview_active`` to
True and call ``poll(bpy.context)``. Every poll must return False
any True is a poll that wouldn't hide during a fillet/bend preview,
leaving the user with two competing icon stacks on the same selection."""
groups = _wall_gizmo_groups()
offenders = []
with patch("bonsai.bim.module.model.preview_base.any_preview_active", return_value=True):
for name, cls in groups:
poll = getattr(cls, "poll", None)
if poll is None:
# Inherits poll from a mixin / base — the base poll's gating
# is covered separately. Skip rather than crash.
continue
try:
result = poll(bpy.context)
except Exception as exc: # noqa: BLE001
offenders.append((name, f"poll raised: {type(exc).__name__}: {exc}"))
continue
if result:
offenders.append((name, "poll returned True with preview active"))
assert not offenders, (
"Wall gizmo polls that don't gate on any_preview_active "
"(or raise instead of returning False): "
+ ", ".join(f"{n}{why}" for n, why in offenders)
+ ". Hide sister gizmos during previews so the preview is the only "
"interactive surface in the viewport. The conventional path is to "
"early-return from poll when preview_base.any_preview_active(context) "
"is True."
)
class TestBaseParametricGizmoPollHidesDuringPreview:
"""Mirror of the wall-specific test for the cross-feature parametric
framework: door / window / stair / roof / railing / array all inherit
``BaseParametricGizmoGroup``. Its poll must also short-circuit on
``any_preview_active`` so sister features behave consistently with walls."""
def test_base_parametric_poll_returns_false_when_a_preview_is_active(self):
from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup
# The base poll requires an active selected object before checking the
# preview gate. Mock both the selected-object check (return a sentinel)
# AND the gate so the test exercises ONLY the preview short-circuit.
with patch("bonsai.tool.Blender.get_active_object", return_value=object()):
with patch("bonsai.tool.Blender.are_viewport_gizmos_enabled", return_value=True):
with patch(
"bonsai.bim.module.model.preview_base.any_preview_active",
return_value=True,
):
assert BaseParametricGizmoGroup.poll(bpy.context) is False
class TestModulePathIsFindable:
"""If wall.py is split across multiple modules (e.g. wall_gizmos.py),
update ``_wall_gizmo_groups`` to walk each. This sanity check fails first
so the diagnostic message is obvious."""
def test_wall_module_resolves(self):
from bonsai.bim.module.model import wall as wall_mod
assert inspect.ismodule(wall_mod)
@@ -0,0 +1,304 @@
# 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.
"""Unit tests for the poll() preconditions of wall billboarding gizmo groups.
These tests patch ``tool.Blender`` / ``tool.Ifc`` / ``tool.Model`` so the poll
logic can be exercised without a real IFC fixture. Each test pins one of the
gates ``poll()`` walks, so any silent regression in the gate order or in the
LAYER3-active / LAYER2-other contract is caught by a dedicated assertion."""
import types
from types import SimpleNamespace
from unittest.mock import patch
import bpy
import pytest
pytestmark = pytest.mark.wall
@pytest.fixture(autouse=True)
def _require_real_bpy():
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
pytest.skip("requires real Blender (bpy is mocked or absent)")
def _make_context(active, selected):
"""Build a minimal ``context`` stub with the two attributes ``poll()`` reads."""
return SimpleNamespace(active_object=active, selected_objects=list(selected))
def _patch_tools(prefs_on, selected, active_element, other_element, active_usage, other_usage):
"""Return a stack of patches that simulate one selection / IFC state for poll().
``prefs.gizmos.draw_gizmos_in_3d_viewport`` is the top-level toggle. The
selection set, the IFC entity lookup, and the usage-type lookup are stubbed
so the test only depends on the predicate ordering in poll()."""
prefs = SimpleNamespace(gizmos=SimpleNamespace(draw_gizmos_in_3d_viewport=prefs_on))
entity_map = {}
usage_map = {}
# active_element/other_element are matched by object identity from the selected set
if len(selected) == 2:
entity_map[id(selected[0])] = active_element
entity_map[id(selected[1])] = other_element
usage_map[id(active_element)] = active_usage
usage_map[id(other_element)] = other_usage
def get_entity(obj):
return entity_map.get(id(obj))
def get_usage_type(element):
return usage_map.get(id(element))
from bonsai import tool
return [
patch.object(tool.Blender, "get_addon_preferences", return_value=prefs),
patch.object(tool.Blender, "get_selected_objects", return_value=set(selected)),
patch.object(tool.Ifc, "get_entity", side_effect=get_entity),
patch.object(tool.Model, "get_usage_type", side_effect=get_usage_type),
]
def _run_poll(prefs_on, active_is_in_selected, len_override, active_usage, other_usage, active_has_entity=True):
from bonsai.bim.module.model.wall import GizmoWallExtendVertically
slab_obj = object()
wall_obj = object()
active = slab_obj if active_is_in_selected else object()
if len_override is None:
selected = [slab_obj, wall_obj]
else:
selected = [object() for _ in range(len_override)]
if active_is_in_selected and selected:
active = selected[0]
slab_element = object() if active_has_entity else None
wall_element = object()
patches = _patch_tools(prefs_on, selected, slab_element, wall_element, active_usage, other_usage)
for p in patches:
p.start()
try:
return GizmoWallExtendVertically.poll(_make_context(active, selected))
finally:
for p in patches:
p.stop()
def test_poll_accepts_layer3_active_with_layer2_other():
assert (
_run_poll(
prefs_on=True, active_is_in_selected=True, len_override=None, active_usage="LAYER3", other_usage="LAYER2"
)
is True
)
def test_poll_rejects_when_gizmo_toggle_off():
assert (
_run_poll(
prefs_on=False, active_is_in_selected=True, len_override=None, active_usage="LAYER3", other_usage="LAYER2"
)
is False
)
def test_poll_rejects_when_selection_count_is_not_two():
assert (
_run_poll(
prefs_on=True, active_is_in_selected=True, len_override=3, active_usage="LAYER3", other_usage="LAYER2"
)
is False
)
assert (
_run_poll(
prefs_on=True, active_is_in_selected=True, len_override=1, active_usage="LAYER3", other_usage="LAYER2"
)
is False
)
def test_poll_rejects_when_active_has_no_ifc_entity():
assert (
_run_poll(
prefs_on=True,
active_is_in_selected=True,
len_override=None,
active_usage="LAYER3",
other_usage="LAYER2",
active_has_entity=False,
)
is False
)
def test_poll_rejects_when_active_is_not_layer3():
# A LAYER2 active (wall) must NOT trigger this gizmo — the wall-join gizmo
# owns that case, and extend_walls_to_underside expects the slab to be active.
assert (
_run_poll(
prefs_on=True, active_is_in_selected=True, len_override=None, active_usage="LAYER2", other_usage="LAYER2"
)
is False
)
# Active with no usage at all (generic mesh, e.g. an opening blocker) is also rejected.
assert (
_run_poll(prefs_on=True, active_is_in_selected=True, len_override=None, active_usage=None, other_usage="LAYER2")
is False
)
def test_poll_rejects_when_other_is_not_layer2_wall():
assert (
_run_poll(
prefs_on=True, active_is_in_selected=True, len_override=None, active_usage="LAYER3", other_usage="LAYER3"
)
is False
)
assert (
_run_poll(prefs_on=True, active_is_in_selected=True, len_override=None, active_usage="LAYER3", other_usage=None)
is False
)
# ----------------------------------------------------------------------------
# _iter_path_connections — IfcRelConnectsPathElements inverse-graph walk
# ----------------------------------------------------------------------------
#
# Normalises both ConnectedTo and ConnectedFrom orientations to (other, self_ct,
# other_ct) so callers always read "self first" regardless of which side of the
# rel this wall was authored on. Non-wall partners and malformed (None) refs are
# filtered out so per-frame gizmo positioning survives partial IFC state.
def _make_path_rel(relating, related, relating_ct, related_ct, kind="IfcRelConnectsPathElements"):
"""Build a stub IfcRelConnectsPathElements for inverse-walk tests."""
return SimpleNamespace(
is_a=lambda name, _k=kind: name == _k,
RelatingElement=relating,
RelatedElement=related,
RelatingConnectionType=relating_ct,
RelatedConnectionType=related_ct,
)
def _run_iter_path_connections(elem, *, partner_predicate=lambda _e: True):
from bonsai import tool
from bonsai.bim.module.model.wall import _iter_path_connections
with patch.object(tool.Parametric, "is_path_connectable_wall", side_effect=partner_predicate):
return _iter_path_connections(elem)
def test_iter_path_connections_empty_inverses_yields_nothing():
elem = SimpleNamespace(ConnectedTo=[], ConnectedFrom=[])
assert _run_iter_path_connections(elem) == []
def test_iter_path_connections_connected_to_orientation_is_self_first():
# Self is the rel's RelatingElement → its connection type is RelatingConnectionType.
self_elem = object()
other = object()
rel = _make_path_rel(relating=self_elem, related=other, relating_ct="ATEND", related_ct="ATSTART")
elem = SimpleNamespace(ConnectedTo=[rel], ConnectedFrom=[])
assert _run_iter_path_connections(elem) == [(other, "ATEND", "ATSTART")]
def test_iter_path_connections_connected_from_orientation_is_self_first():
# Self is the rel's RelatedElement → its connection type is RelatedConnectionType.
# The helper must FLIP the tuple so callers still see (other, self_ct, other_ct).
self_elem = object()
other = object()
rel = _make_path_rel(relating=other, related=self_elem, relating_ct="ATSTART", related_ct="ATEND")
elem = SimpleNamespace(ConnectedTo=[], ConnectedFrom=[rel])
assert _run_iter_path_connections(elem) == [(other, "ATEND", "ATSTART")]
def test_iter_path_connections_skips_non_path_rels():
# IfcRelAggregates, IfcRelContainedInSpatialStructure, etc. share the
# ConnectedTo/ConnectedFrom inverse arrays — only IfcRelConnectsPathElements
# carries the per-end connection-type semantics we care about.
self_elem = object()
other = object()
non_path = _make_path_rel(
relating=self_elem, related=other, relating_ct="ATSTART", related_ct="ATEND", kind="IfcRelAggregates"
)
path = _make_path_rel(relating=self_elem, related=other, relating_ct="ATEND", related_ct="ATSTART")
elem = SimpleNamespace(ConnectedTo=[non_path, path], ConnectedFrom=[])
assert _run_iter_path_connections(elem) == [(other, "ATEND", "ATSTART")]
def test_iter_path_connections_skips_non_wall_partners():
# Walls may path-connect to non-wall elements (columns, beams). The single-
# wall unjoin gizmo only surfaces wall-to-wall joins to match the existing
# two-wall gizmo's scope.
self_elem = object()
wall_partner = object()
non_wall_partner = object()
rel_wall = _make_path_rel(relating=self_elem, related=wall_partner, relating_ct="ATEND", related_ct="ATSTART")
rel_non_wall = _make_path_rel(
relating=self_elem, related=non_wall_partner, relating_ct="ATEND", related_ct="ATSTART"
)
elem = SimpleNamespace(ConnectedTo=[rel_wall, rel_non_wall], ConnectedFrom=[])
result = _run_iter_path_connections(elem, partner_predicate=lambda e: e is wall_partner)
assert result == [(wall_partner, "ATEND", "ATSTART")]
def test_iter_path_connections_includes_fillet_corner_partner():
# Fillet-corner walls carry no LAYER2 usage but are still valid path
# partners. The enumeration must use the same predicate the gizmo group's
# poll uses for the host wall — otherwise the corner is silently dropped
# from the neighbour's connection list and looks unconnected from the
# LAYER2 wall's perspective.
self_elem = object()
fillet_partner = object()
rel = _make_path_rel(relating=self_elem, related=fillet_partner, relating_ct="ATEND", related_ct="ATSTART")
elem = SimpleNamespace(ConnectedTo=[rel], ConnectedFrom=[])
result = _run_iter_path_connections(elem, partner_predicate=lambda e: e is fillet_partner)
assert result == [(fillet_partner, "ATEND", "ATSTART")]
def test_iter_path_connections_tolerates_none_partner_refs():
# Malformed / partial IFC files can leave a rel's element ref unset.
# Without a None guard, the partner predicate would receive None and
# raise on `.is_a(...)` mid-frame, silently breaking the gizmo group.
self_elem = object()
other = object()
rel_none = _make_path_rel(relating=self_elem, related=None, relating_ct="ATEND", related_ct="ATSTART")
rel_ok = _make_path_rel(relating=self_elem, related=other, relating_ct="ATSTART", related_ct="ATEND")
elem = SimpleNamespace(ConnectedTo=[rel_none, rel_ok], ConnectedFrom=[])
assert _run_iter_path_connections(elem) == [(other, "ATSTART", "ATEND")]
def test_iter_path_connections_walks_both_inverses_in_order():
# A wall can sit on both sides of different path rels (e.g. authored once
# as the RelatingElement, once as the RelatedElement). The helper walks
# ConnectedTo first, then ConnectedFrom — pinning the order so callers can
# depend on it for icon-slot allocation.
self_elem = object()
p1 = object()
p2 = object()
rel_to = _make_path_rel(relating=self_elem, related=p1, relating_ct="ATSTART", related_ct="ATSTART")
rel_from = _make_path_rel(relating=p2, related=self_elem, relating_ct="ATEND", related_ct="ATEND")
elem = SimpleNamespace(ConnectedTo=[rel_to], ConnectedFrom=[rel_from])
assert _run_iter_path_connections(elem) == [(p1, "ATSTART", "ATSTART"), (p2, "ATEND", "ATEND")]
@@ -0,0 +1,386 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Forward-compat AST contracts for wall gizmo internals.
Pins structural invariants that no per-call-site behavioural test can catch
on its own: the kind of "someone tidied the imports" regression that leaves
tests green but silently changes runtime semantics. Each contract names the
invariant it pins so a future revert tells the contributor exactly what the
rule is."""
import ast
import inspect
import textwrap
import pytest
pytestmark = pytest.mark.wall
def test_iter_path_connections_uses_path_connectable_predicate():
"""The partner filter must consult the looser ``is_path_connectable_wall``
predicate, matching the host-side predicate used by the gizmo group's
poll. Strict ``is_wall`` rejects fillet-corner walls (which have no
LAYER2 usage by IFC spec), so a regression to ``is_wall`` would silently
drop fillet partners from the connection list visible to the user as
"the corner looks unconnected from the adjacent wall's selection.\" """
from bonsai.bim.module.model.wall import _iter_path_connections
source = inspect.getsource(_iter_path_connections)
tree = ast.parse(source)
attr_names = {node.attr for node in ast.walk(tree) if isinstance(node, ast.Attribute)}
assert "is_path_connectable_wall" in attr_names, (
"_iter_path_connections must filter partners with is_path_connectable_wall — "
"the same predicate the gizmo group's poll uses on the host wall. "
"Symmetry between host and partner predicates is required for fillet "
"corners (no LAYER2 usage) to surface as connected from their LAYER2 "
"neighbours' perspective."
)
assert "is_wall" not in attr_names, (
"_iter_path_connections must NOT call .is_wall on partner elements — "
"that strict predicate drops fillet-corner walls. Use "
"is_path_connectable_wall instead."
)
def test_gizmo_wall_link_toggle_invokes_partner_bbox_helper():
"""The wall subclass must call draw_wall_partner_bbox when its hover
state is active. Without this contract the partner-wall highlight
silently regresses if someone "tidies" the draw() override away."""
from bonsai.bim.module.model import wall as wall_module
source = textwrap.dedent(inspect.getsource(wall_module.GizmoWallLinkToggle.draw))
tree = ast.parse(source)
attr_names = {node.attr for node in ast.walk(tree) if isinstance(node, ast.Attribute)}
call_names: set[str] = set()
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
if isinstance(node.func, ast.Attribute):
call_names.add(node.func.attr)
elif isinstance(node.func, ast.Name):
call_names.add(node.func.id)
assert "is_highlight" in attr_names, (
"GizmoWallLinkToggle.draw must gate its highlight call on self.is_highlight — "
"without it the partner outline would draw every frame, not just on hover."
)
assert "draw_wall_partner_bbox" in call_names, (
"GizmoWallLinkToggle.draw must call draw_wall_partner_bbox to render the "
"partner outline. The shared composite in decorator.py is the canonical "
"trigger for this feature; replacing it with an ad-hoc draw call would "
"drift from the array-children bbox styling."
)
def test_every_wall_gizmo_group_resolves_get_decoration_colors():
"""Any wall ``GizmoGroup`` whose ``setup()`` reads decoration colours via
``self.get_decoration_colors()`` must inherit from a mixin that supplies
it (``gizmo.BaseParametricGizmoGroup`` or ``gizmo.BillboardingGizmoGroupMixin``).
Without the mixin the call AttributeErrors inside ``setup()``, Blender
logs the failure and skips the rest of ``setup()``, and every later
``draw_prepare()`` blows up on whichever attribute the truncated setup
failed to assign a silent, runtime-only regression that no other test
catches."""
import bpy
from bonsai.bim.module.model import wall as wall_module
offenders: list[str] = []
for name in dir(wall_module):
cls = getattr(wall_module, name)
if not inspect.isclass(cls):
continue
if inspect.getmodule(cls) is not wall_module:
continue
if not issubclass(cls, bpy.types.GizmoGroup):
continue
setup = cls.__dict__.get("setup")
if setup is None:
continue
try:
src = inspect.getsource(setup)
except (OSError, TypeError):
continue
if "self.get_decoration_colors()" not in src:
continue
if not hasattr(cls, "get_decoration_colors"):
offenders.append(cls.__name__)
assert not offenders, (
f"GizmoGroup subclasses {offenders} call self.get_decoration_colors() in "
"setup() but inherit from no class that provides it. Add "
"gizmo.BillboardingGizmoGroupMixin (or gizmo.BaseParametricGizmoGroup) to "
"the class bases — both define get_decoration_colors and are the canonical "
"wall-gizmo mixins."
)
def test_host_add_opening_accepts_fillet_corner_active():
"""``is_supported_host`` (the gate ``GizmoHostAddOpening.poll`` dispatches
through) must classify walls via ``is_path_connectable_wall``, not the
strict ``is_wall`` predicate. Fillet-corner walls carry no LAYER2 usage
by IFC spec, so the strict predicate rejects them and the add-opening
icon never surfaces over a curved corner symmetry with the join /
unjoin / extend wall gizmos (all of which already poll on the looser
predicate) is required for the user to drop openings into fillet
corners at all."""
from bonsai.bim.module.model.host_add_opening_gizmo import is_supported_host
source = textwrap.dedent(inspect.getsource(is_supported_host))
tree = ast.parse(source)
attr_names = {node.attr for node in ast.walk(tree) if isinstance(node, ast.Attribute)}
assert "is_path_connectable_wall" in attr_names, (
"is_supported_host must gate walls on tool.Parametric.is_path_connectable_wall. "
"The strict is_wall predicate hides the add-opening gizmo over every "
"fillet-corner wall."
)
assert "is_wall" not in attr_names, (
"is_supported_host must NOT call .is_wall — that strict predicate drops "
"fillet-corner walls. Use is_path_connectable_wall instead, matching the "
"host gate every other wall-state gizmo group uses."
)
def test_join_intersection_uses_l_and_t_glyphs():
"""``GizmoWallJoinIntersection.setup`` must bind the join icon to the L
glyph (``VIEW3D_GT_wall_corner``) and the extend-to icon to the T glyph
(``VIEW3D_GT_wall_tee``). The L / T pair makes the corner-join vs
extend-into-side distinction read at a glance a regression to the
arrow-merge glyph for both icons makes them visually indistinguishable
once they're stacked at the same XY."""
from bonsai.bim.module.model.wall import GizmoWallJoinIntersection
source = textwrap.dedent(inspect.getsource(GizmoWallJoinIntersection.setup))
assert '"VIEW3D_GT_wall_corner"' in source, (
"GizmoWallJoinIntersection.setup must bind join_icon to VIEW3D_GT_wall_corner "
"(the L glyph). The arrow-merge glyph (VIEW3D_GT_merge) is the collinear-merge "
"case and was visually ambiguous with the extend-to icon when both were stacked."
)
assert '"VIEW3D_GT_wall_tee"' in source, (
"GizmoWallJoinIntersection.setup must bind extend_to_wall_icon to "
"VIEW3D_GT_wall_tee (the T glyph). The arrow-extend glyph was visually "
"ambiguous with the join icon when both were stacked."
)
def test_join_intersection_stacks_along_screen_up_in_both_states():
"""``GizmoWallJoinIntersection.position_gizmos`` must route both the
joined (unjoin + fillet) and the intersecting (join + extend + fillet)
states through ``_stack_at`` so the icons stay individually clickable
in any view, including top / plan view where world-Z separation
collapses to zero on screen. A regression that re-introduces a
per-state ``billboarded_at(corner, ...)`` write outside ``_stack_at``
silently flattens the stack back onto one screen pixel."""
from bonsai.bim.module.model.wall import GizmoWallJoinIntersection
source = textwrap.dedent(inspect.getsource(GizmoWallJoinIntersection.position_gizmos))
tree = ast.parse(source)
call_names: set[str] = set()
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
if isinstance(node.func, ast.Attribute):
call_names.add(node.func.attr)
elif isinstance(node.func, ast.Name):
call_names.add(node.func.id)
assert "_stack_at" in call_names, (
"GizmoWallJoinIntersection.position_gizmos must call self._stack_at to "
"lay icons along screen-up at the wall-top anchor. Direct "
"billboarded_at writes for the join/unjoin/extend/fillet icons bypass "
"the stacking contract and re-introduce the top-view collapse bug."
)
def _get_wall_axis_callers_in(method) -> set[str]:
"""Return the set of attribute chains in ``method``'s source that resolve
to ``tool.Model.get_wall_axis``. Empty set means the method does not read
from the mesh-bound-box axis source."""
source = textwrap.dedent(inspect.getsource(method))
tree = ast.parse(source)
offenders: set[str] = set()
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
func = node.func
if not isinstance(func, ast.Attribute) or func.attr != "get_wall_axis":
continue
# Reconstruct the receiver chain to surface it in the assertion message.
chain: list[str] = [func.attr]
receiver = func.value
while isinstance(receiver, ast.Attribute):
chain.append(receiver.attr)
receiver = receiver.value
if isinstance(receiver, ast.Name):
chain.append(receiver.id)
offenders.add(".".join(reversed(chain)))
return offenders
def _method_writes_ifc_axis(method) -> bool:
"""True iff ``method``'s body calls ``self.set_axis(...)`` — the only
path that writes a wall's IFC reference line via
``ifcopenshell.api.geometry.assign_representation``. Methods that only
read ``axis["base"]`` / ``axis["side"]`` for layer-polygon work (slab
clipping, opening snap) never call ``set_axis`` and are not under this
rule."""
source = textwrap.dedent(inspect.getsource(method))
tree = ast.parse(source)
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
func = node.func
if isinstance(func, ast.Attribute) and func.attr == "set_axis":
return True
return False
def test_dumb_wall_joiner_axis_writers_read_ifc_reference_line():
"""Any ``DumbWallJoiner`` method that writes the IFC reference line
(via ``self.set_axis`` ``ifcopenshell.api.geometry.assign_representation``)
must read its input axis from the IFC reference line too not from
``tool.Model.get_wall_axis``, whose X-extent comes from ``obj.bound_box``
(the Body mesh AABB). The bound-box axis drifts past or short of the
IFC reference line at mitred / butt-jointed walls and at walls with end
openings; mixing it on input with the IFC axis on output produces
non-colinear sub-axes that compound through chained extend/split/join
edits.
The IFC-anchored helper is ``tool.Wall.get_world_reference_line`` for
world-space endpoints, or ``ifcopenshell.util.representation.get_reference_line``
for local-SI endpoints.
Joiner methods that only read layer-polygon base/side (e.g. ``clip``
for slab intersection) are exempt they need the body footprint, not
the axis, and never call ``set_axis``."""
from bonsai.bim.module.model.wall import DumbWallJoiner
offenders: dict[str, set[str]] = {}
for name, method in inspect.getmembers(DumbWallJoiner, predicate=inspect.isfunction):
if not _method_writes_ifc_axis(method):
continue
bad_calls = _get_wall_axis_callers_in(method)
if bad_calls:
offenders[name] = bad_calls
assert not offenders, (
f"DumbWallJoiner methods that call self.set_axis must not read the "
f"bound-box-derived axis: {offenders}. Use "
"tool.Wall.get_world_reference_line for world-space endpoints, or "
"ifcopenshell.util.representation.get_reference_line for local-SI "
"endpoints. Mixing bound_box on input with IFC axis on output "
"produces non-colinear sub-axes that compound through chained "
"extend/split/join edits."
)
def test_extend_walls_to_polyline_set_origin_uses_ifc_reference_line():
"""``ExtendWallsToPolylinePoint.set_origin`` seeds the polyline preview
anchor at one of the wall's axis endpoints. The downstream operator
(``DumbWallJoiner.extend``) projects the user's chosen target onto the
IFC reference line; if the preview anchor comes from
``tool.Model.get_wall_axis`` (bound_box) the user sees the preview at
one endpoint and the wall lands at a different one the visible
"extend falls short by a few cm/m" symptom."""
from bonsai.bim.module.model.wall import ExtendWallsToPolylinePoint
offenders = _get_wall_axis_callers_in(ExtendWallsToPolylinePoint.set_origin)
assert not offenders, (
f"ExtendWallsToPolylinePoint.set_origin must not read the bound-box-derived "
f"axis: {offenders}. Use tool.Wall.get_world_reference_line so the preview "
"anchor lands on the same IFC reference line the downstream extend operator "
"projects onto."
)
def test_wall_toggle_openings_uses_idle_slots():
"""The wall's toggle_openings icon must be declared in
``GizmoWallEdition.idle_slots`` so the base class lays it out at the
standard pen-row position. Routing it through ad-hoc setup helpers
instead would re-introduce the X-collision with the array's first
per-layer icon the bug this contract was added to prevent."""
from bonsai.bim.module.model.wall import GizmoWallEdition
slot_names = {s.name for s in GizmoWallEdition.idle_slots}
assert "toggle_openings" in slot_names, (
"GizmoWallEdition.idle_slots must contain a slot named 'toggle_openings'. "
"The base class derives its X position from the slot's tuple index so peer "
"groups (GizmoArrayEdition's per-layer icons) can query a real layout edge "
"via _idle_row_right_edge() instead of a hardcoded per-feature table."
)
def test_no_pen_row_toggle_openings_helpers_remain():
"""The legacy ``setup_pen_row_toggle_openings_icon`` and
``update_pen_row_toggle_openings_icon`` helpers were removed once
toggle_openings migrated into the ``idle_slots`` system. A re-introduced
helper would shadow the slot-driven layout features calling it would
set up a second gizmo at a different X and the collision-prevention
contract would silently regress.
Walks the wall and roof modules (the historical callers) plus
drawing/gizmos.py (the historical home) for any reference to either
name."""
import bonsai.bim.module.drawing.gizmos as gizmos_mod
import bonsai.bim.module.model.roof as roof_mod
import bonsai.bim.module.model.wall as wall_mod
forbidden = ("setup_pen_row_toggle_openings_icon", "update_pen_row_toggle_openings_icon")
for mod in (gizmos_mod, roof_mod, wall_mod):
source = inspect.getsource(mod)
for name in forbidden:
assert name not in source, (
f"{mod.__name__} still references {name!r}. The toggle_openings icon "
f"is now declared via idle_slots; the ad-hoc helpers were removed to "
f"prevent layout drift between feature groups."
)
def test_array_idle_max_x_walks_registry_not_hardcoded_dict():
"""``GizmoArrayEdition._resolve_feature_idle_max_x`` must query peer
parametric gizmo groups' ``_idle_row_right_edge`` rather than indexing
a hardcoded per-feature ``_FEATURE_IDLE_MAX_X`` dict. The dict approach
was the source of the toggle_openings array-layer-icon collision bug
on arrayed walls (find_for_element returns 'array' first, shadowing the
wall reservation)."""
from bonsai.bim.module.model.array import GizmoArrayEdition
assert not hasattr(GizmoArrayEdition, "_FEATURE_IDLE_MAX_X"), (
"GizmoArrayEdition._FEATURE_IDLE_MAX_X was a hardcoded per-feature dict "
"that shadowed peer groups' real idle rows for compound elements (arrayed "
"walls). It was replaced by a registry walk via REGISTRY + "
"_idle_row_right_edge() — re-introducing the dict would re-create the bug."
)
source = inspect.getsource(GizmoArrayEdition._resolve_feature_idle_max_x)
assert "_idle_row_right_edge" in source, (
"_resolve_feature_idle_max_x must call peer_cls._idle_row_right_edge() so "
"the X position derives from each peer's actual declared idle_slots."
)
assert "REGISTRY" in source, (
"_resolve_feature_idle_max_x must iterate BaseParametricGizmoGroup.REGISTRY "
"to discover peer groups; find_for_element returns ONE entry and shadows "
"compound-element memberships."
)
@@ -0,0 +1,111 @@
# 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.
"""Regression tests for the post-IFC-commit refresh path.
Two invariants:
* Every commit bumps ``_geom_generation`` so caches keyed off it drop
stale entries on the next read.
* The BIM Tool header float refresh (``refresh_bim_tool_headers``) fires
only for commits whose operator is a parametric ``finish_op`` from
``tool.Parametric.EDIT_TYPES`` the validate-gizmo path. Other
operators skip it; their commit context may lack the view-layer
attributes the refresh reads."""
import types
from unittest.mock import MagicMock, patch
import bpy
import pytest
pytestmark = pytest.mark.wall
@pytest.fixture(autouse=True)
def _require_real_bpy():
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
pytest.skip("requires real Blender (bpy is mocked or absent)")
def test_refresh_post_commit_bumps_generation_for_every_operator():
"""The generation counter advances on every commit, regardless of
operator class it's the cache-invalidation signal for any code
keyed off ``tool.Parametric.get_geom_generation()``."""
from bonsai import tool
before = tool.Parametric.get_geom_generation()
tool.Parametric.refresh_post_commit(MagicMock(bl_idname="bim.append_library_element"))
assert tool.Parametric.get_geom_generation() == before + 1
def test_refresh_post_commit_refreshes_headers_for_validate_gizmo_operators():
"""Operators whose ``bl_idname`` matches a ``ParametricObject.finish_op``
in ``EDIT_TYPES`` are the validate-gizmo path: selection didn't
change, but the IFC values backing the BIM Tool header did. The
commit hook must push the new IFC state into the header floats."""
import bonsai.bim.handler as handler
from bonsai import tool
finish_op_idname = tool.Parametric.EDIT_TYPES[0].finish_op
with patch.object(handler, "refresh_bim_tool_headers") as mock_refresh:
tool.Parametric.refresh_post_commit(MagicMock(bl_idname=finish_op_idname))
mock_refresh.assert_called_once()
def test_refresh_post_commit_skips_header_refresh_for_non_finish_operators():
"""Other operators must not trigger the header refresh. The refresh
reads ``bpy.context``; for commits invoked from a stripped operator
context (e.g. nested ``bpy.ops`` calls during project setup) this
would raise ``AttributeError`` and break the outer operator chain."""
import bonsai.bim.handler as handler
from bonsai import tool
with patch.object(handler, "refresh_bim_tool_headers") as mock_refresh:
tool.Parametric.refresh_post_commit(MagicMock(bl_idname="bim.append_library_element"))
mock_refresh.assert_not_called()
def test_geom_generation_invalidates_wall_geom_cache():
"""Bumping the generation must cause ``_get_wall_geom_cached`` to drop its
stored entries on the next read, even when the same gizmo group instance
and the same wall object are reused (the case Blender's
``GizmoGroup.refresh()`` does not cover)."""
from bonsai import tool
from bonsai.bim.module.model import wall as wall_mod
class _FakeGroup:
pass
group = _FakeGroup()
fake_obj = types.SimpleNamespace(name="Wall/W001")
sentinel_a = {"length": 1.0, "height": 2.0, "x_angle": 0.0}
sentinel_b = {"length": 1.5, "height": 2.5, "x_angle": 0.0}
with patch.object(tool.Wall, "read_geometry", side_effect=[sentinel_a, sentinel_b]):
first = wall_mod._get_wall_geom_cached(group, fake_obj)
assert first is sentinel_a
# Same call without a generation bump must hit the cache (no extra read).
assert wall_mod._get_wall_geom_cached(group, fake_obj) is sentinel_a
# Simulate an IFC commit: generation advances, cache must drop.
tool.Parametric._geom_generation += 1
second = wall_mod._get_wall_geom_cached(group, fake_obj)
assert second is sentinel_b
assert second is not first
@@ -0,0 +1,453 @@
# 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.
"""Offset arithmetic for the filling (Door / Window) wall-offset dimension gizmos.
The apply path (``_set_offset``) must translate ``obj.matrix_world`` so the
corresponding read path (``_get_offset``) reads back the new value i.e.
drag a left-offset to 2.0 m, then reading the left offset must return ~2.0 m.
The tests below pin that round-trip, the rotated/flipped filling case (the
add-opening flow may 180° a filling onto the wall's opposite face), and the
visibility predicate."""
from math import pi
from unittest import mock
import pytest
from mathutils import Matrix
pytestmark = pytest.mark.model
# Module under test, plus its cache dict so each test starts from a clean slate.
import bonsai.bim.module.model.wall_offset_gizmos as subject
@pytest.fixture(autouse=True)
def _clear_geom_cache():
"""Per-test isolation — the module-level cache would otherwise carry mocks
across tests and surface as flaky-looking failures."""
subject._GEOM_CACHE.clear()
yield
subject._GEOM_CACHE.clear()
def _make_props(filling_matrix, overall_width=1.0, overall_height=2.0, name="FillingObj"):
"""Filling PropertyGroup stand-in (``BIMDoorProperties`` /
``BIMWindowProperties`` share the relevant shape). The wall-offset helpers
only touch ``id_data`` (the filling obj), ``overall_width``, and
``overall_height``; nothing else from the real PropertyGroup matters here."""
filling_obj = mock.Mock(name=name)
filling_obj.name = name
filling_obj.matrix_world = filling_matrix
props = mock.Mock(spec=["id_data", "overall_width", "overall_height"])
props.id_data = filling_obj
props.overall_width = overall_width
props.overall_height = overall_height
return props, filling_obj
def _patch_host_wall(wall_matrix, length, height, geom_gen=1, host_present=True, x_angle=0.0):
"""Mock the chain ``Ifc.get_entity → Spatial.get_host_wall → Ifc.get_object``
and the ``tool.Wall.*`` IFC reads so the helpers see a host wall
positioned at ``wall_matrix`` with the supplied length, height, and
extrusion angle. The IFC axis is taken to start at wall-local X=0 and
extend to X=``length``. ``host_present=False`` makes the chain return
None partway through."""
wall_obj = mock.Mock(name="WallObj")
wall_obj.matrix_world = wall_matrix
host_wall = mock.Mock(name="IfcWall") if host_present else None
return mock.patch.multiple(
subject.tool,
Ifc=mock.MagicMock(
spec=subject.tool.Ifc,
get_entity=mock.Mock(return_value=mock.Mock(name="IfcDoor")),
get_object=mock.Mock(return_value=wall_obj if host_present else None),
),
Spatial=mock.MagicMock(
spec=subject.tool.Spatial,
get_host_wall=mock.Mock(return_value=host_wall),
),
Wall=mock.MagicMock(
spec=subject.tool.Wall,
get_length_and_height=mock.Mock(return_value=(length, height) if host_present else None),
get_axis_local_extent=mock.Mock(return_value=(0.0, length) if host_present else None),
get_x_angle=mock.Mock(return_value=x_angle if host_present else None),
),
Parametric=mock.MagicMock(
spec=subject.tool.Parametric,
get_geom_generation=mock.Mock(return_value=geom_gen),
),
)
# ----------------------------------------------------------------------
# Visibility predicate
# ----------------------------------------------------------------------
def test_has_host_wall_returns_true_when_chain_resolves():
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
assert subject.has_host_wall(props) is True
def test_has_host_wall_returns_false_when_no_host():
props, _ = _make_props(Matrix.Identity(4))
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0, host_present=False):
assert subject.has_host_wall(props) is False
def test_has_host_wall_returns_true_for_slanted_wall():
"""Slanted LAYER2 walls keep ``matrix_world`` upright — the slope lives in
the IFC extrusion direction and in the wall mesh vertices, not in the
object transform. So wall-local Z still equals world Z, the offset math
round-trips, and the gizmos must remain visible."""
import math
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0, x_angle=math.radians(15)):
assert subject.has_host_wall(props) is True
# ----------------------------------------------------------------------
# Compute helpers (un-flipped filling, wall at world origin)
# ----------------------------------------------------------------------
def test_get_wall_offset_left_for_filling_at_known_x():
"""Wall span 0→5 on X; filling origin at wall-local X=1.5 with +X aligned.
Filling's left edge is at wall-X 1.5 → offset_left = 1.5."""
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
assert subject._get_offset(props, subject._LEFT) == pytest.approx(1.5)
def test_get_wall_offset_right_complements_left_plus_width():
"""offset_left + overall_width + offset_right == wall length."""
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
left = subject._get_offset(props, subject._LEFT)
right = subject._get_offset(props, subject._RIGHT)
assert left + props.overall_width + right == pytest.approx(5.0)
def test_get_wall_offset_bottom_for_filling_at_sill_height():
"""Wall base at world Z=0; filling origin at wall-local Z=0.5 → sill at 0.5 m."""
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
assert subject._get_offset(props, subject._BOTTOM) == pytest.approx(0.5)
def test_get_wall_offset_top_complements_bottom_plus_height():
"""offset_bottom + overall_height + offset_top == wall height."""
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
bottom = subject._get_offset(props, subject._BOTTOM)
top = subject._get_offset(props, subject._TOP)
assert bottom + props.overall_height + top == pytest.approx(3.0)
# ----------------------------------------------------------------------
# Slanted wall (LAYER2): wall ``matrix_world`` stays upright, so the
# offset math is the same as for a vertical wall. Pinning this guards
# against the visibility gate being re-added or the math diverging.
# ----------------------------------------------------------------------
def test_get_wall_offset_bottom_for_slanted_wall():
"""For a LAYER2 slanted wall the wall matrix is identity rotation — sill
height read in the wall's local Z is still the world Z above the wall
base."""
import math
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.9)))
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0, x_angle=math.radians(15)):
assert subject._get_offset(props, subject._BOTTOM) == pytest.approx(0.9)
def test_set_wall_offset_bottom_round_trips_for_slanted_wall():
"""Round-trip on a slanted wall: setting then reading the bottom offset
yields the input. The apply path translates along the wall's local Z
direction in world space (``matrix_world.to_3x3().col[2]``), which equals
world Z for an upright wall matrix regardless of IFC slope."""
import math
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0, x_angle=math.radians(15)):
subject._set_offset(props, subject._BOTTOM, 1.2)
assert subject._get_offset(props, subject._BOTTOM) == pytest.approx(1.2)
# ----------------------------------------------------------------------
# Flipped filling (180° around Z) — add-opening flow flips a filling that
# lands on the wall's opposite face. Offset arithmetic must still report
# the leftmost/rightmost edges in wall coordinates, not in filling coordinates.
# ----------------------------------------------------------------------
def test_get_wall_offset_left_handles_flipped_filling():
"""Flipped filling at wall-local X=1.5: filling extends from X=1.5 in filling's +X
direction, which is wall's -X. So filling's leftmost edge in wall coords is
at wall-X 0.5, not wall-X 1.5."""
filling_matrix = Matrix.Translation((1.5, 0.0, 0.5)) @ Matrix.Rotation(pi, 4, "Z")
props, _ = _make_props(filling_matrix, overall_width=1.0)
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
assert subject._get_offset(props, subject._LEFT) == pytest.approx(0.5)
def test_get_wall_offset_right_handles_flipped_filling():
"""Same flipped filling — filling's rightmost edge in wall coords is at the
filling origin (wall-X 1.5), so offset_right = wall_length - 1.5 = 3.5."""
filling_matrix = Matrix.Translation((1.5, 0.0, 0.5)) @ Matrix.Rotation(pi, 4, "Z")
props, _ = _make_props(filling_matrix, overall_width=1.0)
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
assert subject._get_offset(props, subject._RIGHT) == pytest.approx(3.5)
# ----------------------------------------------------------------------
# Apply helpers — must round-trip with the compute helpers.
# ----------------------------------------------------------------------
def test_set_wall_offset_left_translates_filling_along_wall_x():
"""Setting left-offset to 2.0 (from 1.5) shifts the filling origin by +0.5
along the wall's local X axis."""
props, filling_obj = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
subject._set_offset(props, subject._LEFT, 2.0)
assert filling_obj.matrix_world.translation.x == pytest.approx(2.0)
assert filling_obj.matrix_world.translation.z == pytest.approx(0.5)
def test_set_wall_offset_bottom_translates_filling_along_wall_z():
"""Setting bottom-offset to 1.0 (from 0.5) shifts the filling origin by +0.5
along the wall's local Z axis."""
props, filling_obj = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
subject._set_offset(props, subject._BOTTOM, 1.0)
assert filling_obj.matrix_world.translation.z == pytest.approx(1.0)
assert filling_obj.matrix_world.translation.x == pytest.approx(1.5)
def test_set_wall_offset_right_round_trips_with_get():
"""The right-edge setter is the symmetric pair of the left-edge setter —
they must produce mutually consistent geometry, otherwise pulling the
right edge would silently desync the left."""
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
subject._set_offset(props, subject._RIGHT, 1.0)
result = subject._get_offset(props, subject._RIGHT)
assert result == pytest.approx(1.0)
def test_set_wall_offset_top_round_trips_with_get():
"""Same round-trip invariant for the top edge."""
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
subject._set_offset(props, subject._TOP, 0.25)
result = subject._get_offset(props, subject._TOP)
assert result == pytest.approx(0.25)
# ----------------------------------------------------------------------
# Cache invalidation
# ----------------------------------------------------------------------
def test_geom_cache_invalidates_when_generation_bumps():
"""The cache must reset when ``tool.Parametric.get_geom_generation()``
advances so IFC mutations don't leave stale host-wall reads in memory."""
props, _ = _make_props(Matrix.Translation((1.0, 0.0, 0.0)))
# Patch get_geom_generation on the real class — the cache binds to the class
# at definition time, so a mock.patch.multiple on subject.tool.Parametric
# would be invisible to it.
from bonsai.tool.parametric import Parametric
with mock.patch.object(Parametric, "get_geom_generation", return_value=1):
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
first = subject._get_offset(props, subject._LEFT)
with mock.patch.object(Parametric, "get_geom_generation", return_value=2):
with _patch_host_wall(Matrix.Identity(4), length=8.0, height=3.0):
right_after_bump = subject._get_offset(props, subject._RIGHT)
assert first == pytest.approx(1.0)
# 8 m wall, filling at x=1, width 1 → right offset = 6. Reads 6 only if the
# cache dropped on the generation bump.
assert right_after_bump == pytest.approx(6.0)
# ----------------------------------------------------------------------
# Signed value the gizmo reports for left/right (sign-flip on filling's 180° Z rotation)
#
# Without the sign flip the dim arrow renders in the wrong world direction
# for a filling whose local +X is opposite the wall's local +X. The gizmo
# system flips its rendered dim arrow 180° around Z whenever the reported
# value is negative, so the unflipped/flipped cases produce mirrored signs
# and the arrow ends up pointing the right way visually in both orientations.
# ----------------------------------------------------------------------
def test_left_signed_value_positive_for_unflipped_filling():
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
assert subject._compute_value(props, subject._LEFT) == pytest.approx(1.5)
def test_left_signed_value_negative_for_flipped_filling():
"""Filling rotated 180° around Z: its leftmost edge (in wall coords) sits
at wall-X 0.5, so the user-facing left offset is 0.5 but the signed
value must be -0.5 so the gizmo flips its rendered arrow 180° around Z."""
from math import pi
filling = Matrix.Translation((1.5, 0.0, 0.5)) @ Matrix.Rotation(pi, 4, "Z")
props, _ = _make_props(filling, overall_width=1.0)
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
assert subject._compute_value(props, subject._LEFT) == pytest.approx(-0.5)
def test_right_signed_value_positive_for_unflipped_filling():
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
assert subject._compute_value(props, subject._RIGHT) == pytest.approx(2.5)
def test_right_signed_value_negative_for_flipped_filling():
from math import pi
filling = Matrix.Translation((1.5, 0.0, 0.5)) @ Matrix.Rotation(pi, 4, "Z")
props, _ = _make_props(filling, overall_width=1.0)
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
assert subject._compute_value(props, subject._RIGHT) == pytest.approx(-3.5)
# ----------------------------------------------------------------------
# Matrix-position anchors at the wall edge (visual: arrow tail at wall,
# head at filling). The position is in filling-local frame so that mw @ pos
# lands at the wall edge in world.
# ----------------------------------------------------------------------
def test_left_offset_position_lands_at_wall_start_in_world():
"""For an unflipped filling at wall-local (1.5, 0, 0.5) with the wall at the
world origin (bound_box.min_x=0), the matrix_position transformed by the
filling's world matrix must land at world (0, 0, mid_height)."""
props, filling_obj = _make_props(Matrix.Translation((1.5, 0.0, 0.5)), overall_height=2.0)
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
pos_filling_local = subject._edge_position(props, subject._LEFT)
pos_world = filling_obj.matrix_world @ pos_filling_local
# Wall's start at world X=0 (bound_box.min_x=0 in the patched wall).
assert pos_world.x == pytest.approx(0.0)
def test_top_offset_position_lands_at_wall_top_in_world():
"""The top arrow anchors at wall-top — filling-local Z must equal
``overall_height + top_offset`` so the mw-multiplied point lands on the
wall's top edge at world height."""
props, filling_obj = _make_props(Matrix.Translation((1.5, 0.0, 0.5)), overall_height=2.0)
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
pos_filling_local = subject._edge_position(props, subject._TOP)
pos_world = filling_obj.matrix_world @ pos_filling_local
# Wall height 3.0, wall base at world Z=0 → wall top at world Z=3.
assert pos_world.z == pytest.approx(3.0)
def test_bottom_offset_position_lands_at_wall_base_in_world():
"""Same idea for the bottom anchor — filling-local Z = ``-bottom_offset``
so the point lands at world Z=0 (the wall's base)."""
props, filling_obj = _make_props(Matrix.Translation((1.5, 0.0, 0.5)), overall_height=2.0)
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
pos_filling_local = subject._edge_position(props, subject._BOTTOM)
pos_world = filling_obj.matrix_world @ pos_filling_local
assert pos_world.z == pytest.approx(0.0)
def test_apply_value_takes_absolute_value():
"""Apply lambdas use ``abs(v)`` so the apply path stays correct even when
the compute side returned a negative signed value (flipped filling)."""
props, filling_obj = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
# Simulate the gizmo handing back a negative value (flipped-filling scenario).
# The user-facing offset is +2.0, the filling must end up at wall-X=2.0.
left_cfg = next(c for c in subject.WALL_OFFSET_GIZMO_CONFIGS if c.attr_name == "host_wall_offset_left")
left_cfg.apply_value(props, -2.0)
assert filling_obj.matrix_world.translation.x == pytest.approx(2.0)
def test_clear_caches_drops_all_entries():
"""The ``load_post`` handler calls ``clear_caches`` so a fresh file
doesn't inherit stale entries from the previous one. Pin the contract."""
props, _ = _make_props(Matrix.Translation((1.0, 0.0, 0.0)))
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
subject._get_offset(props, subject._LEFT)
assert subject._GEOM_CACHE._data
subject.clear_caches()
assert not subject._GEOM_CACHE._data
# ----------------------------------------------------------------------
# Stale-cache edge cases — cache keys are Blender object names, invalidated
# only by parametric generation bumps and ``load_post``. Anything that
# changes scene state without bumping the generation (Blender rename,
# external Python script deleting a wall) leaves the cache holding stale
# entries until the next IFC mutation. These tests pin that behavior.
# ----------------------------------------------------------------------
def test_filling_rename_within_session_reads_correctly_under_new_name():
"""Reading offsets after a Blender rename hits a cache miss under the
new name and recomputes the old-name entry is leaked but harmless,
and the new-name read returns correct geometry."""
props, filling_obj = _make_props(Matrix.Translation((1.5, 0.0, 0.5)), name="Door1")
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
first = subject._get_offset(props, subject._LEFT)
assert "Door1" in subject._GEOM_CACHE._data
filling_obj.name = "Door1_renamed"
second = subject._get_offset(props, subject._LEFT)
assert first == pytest.approx(1.5)
assert second == pytest.approx(1.5)
assert "Door1_renamed" in subject._GEOM_CACHE._data
def test_host_wall_deletion_serves_stale_cache_until_invalidation():
"""If the host wall is removed without bumping the generation counter
(e.g. external script), the cache keeps returning the pre-deletion
geometry only an IFC mutation or ``clear_caches()`` drops the stale
entry."""
props, _ = _make_props(Matrix.Translation((1.5, 0.0, 0.5)))
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
assert subject.has_host_wall(props) is True
# Even after the patch exits and the chain would now return None, the
# cached entry under the filling's name is still served.
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0, host_present=False):
assert subject.has_host_wall(props) is True # stale
subject.clear_caches()
assert subject.has_host_wall(props) is False # recomputed
def test_filling_rotated_90_degrees_in_wall_plane_falls_back_to_positive_sign():
"""A filling rotated exactly 90° around Z has ``col[0].x == 0.0``, which
is the ambiguous boundary for the X-sign. The implementation falls back
to +1 (the ``>= 0.0`` branch), so the renderer-side value reads as
positive same sign as an unflipped filling."""
filling_matrix = Matrix.Translation((1.5, 0.0, 0.5)) @ Matrix.Rotation(pi / 2, 4, "Z")
props, _ = _make_props(filling_matrix, overall_width=1.0)
with _patch_host_wall(Matrix.Identity(4), length=5.0, height=3.0):
signed = subject._compute_value(props, subject._LEFT)
# +1 fallback × unflipped-equivalent left offset = +1.5 (filling origin in wall coords).
assert signed == pytest.approx(1.5)
@@ -0,0 +1,79 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Pins the outward-normals invariant of the parametric-wall draft preview mesh.
``regenerate_wall_mesh_from_props`` rebuilds ``obj.data`` as a fresh bmesh
box from ``BIMWallProperties`` every time a gizmo handle moves. The hand
authored face windings carry no guarantee of outward orientation, so the
function must normalise face windings before writing the mesh back
otherwise the viewport renders the draft with inverted shading and
back-face culling hides faces the user expects to see."""
import types
from unittest.mock import patch
import bpy
import pytest
from mathutils import Vector
pytestmark = pytest.mark.wall
@pytest.fixture(autouse=True)
def _require_real_bpy():
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
pytest.skip("requires real Blender (bpy is mocked or absent)")
def test_regenerate_wall_mesh_from_props_outward_normals():
"""Every face of the preview box must have its normal pointing away
from the box centroid the contract every other preview-mesh builder
in ``bim/module/model`` (door / window / roof / railing) holds."""
from bonsai.bim.module.model.wall import regenerate_wall_mesh_from_props
mesh = bpy.data.meshes.new("preview_mesh")
obj = bpy.data.objects.new("preview_wall", mesh)
fake_props = types.SimpleNamespace(
length=2.0,
height=3.0,
thickness=0.2,
offset=0.0,
x_angle=0.0,
anchor_x=0.0,
mesh_dirty=False,
)
try:
with patch("bonsai.tool.Model.get_wall_props", return_value=fake_props):
regenerate_wall_mesh_from_props(obj)
assert len(mesh.polygons) == 6, f"expected 6 faces, got {len(mesh.polygons)}"
centroid = sum((v.co for v in mesh.vertices), Vector()) / len(mesh.vertices)
for face in mesh.polygons:
outward = (face.center - centroid).normalized()
dot = face.normal.dot(outward)
assert dot > 0.5, (
f"face {face.index} normal {tuple(face.normal)} points inward "
f"(outward direction {tuple(outward)}, dot={dot:.3f})"
)
finally:
bpy.data.objects.remove(obj)
bpy.data.meshes.remove(mesh)
@@ -0,0 +1,377 @@
# 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.
"""Regression tests for wall-split opening assignment when the cut passes
through an opening.
Bug repro before the fix: when ``bpy.ops.bim.split_wall`` (Shift+K) cut a wall
through an opening, ``DumbWallJoiner.split`` decided opening assignment using
the opening's centre-point projected onto the wall axis. Any opening whose
extent straddled the cut was therefore assigned to whichever side its centre
sat on, leaving the neighbour wall with no void where the opening overlapped.
The fix replaces the single-point test with an axis-projected extent
(``_opening_axis_extent``): an opening is removed from a wall only when its
extent lies *entirely* outside that wall's portion of the axis. Straddling
openings stay on both walls."""
from unittest.mock import MagicMock, patch
import bpy
import pytest
pytestmark = pytest.mark.wall
def _fake_shape(verts_local, matrix_world_4x4):
"""Build a stand-in for the ``shape`` object returned by
``ifcopenshell.geom.create_shape``. ``get_vertices`` and
``get_shape_matrix`` are mocked separately to read off this stand-in."""
import numpy as np
shape = MagicMock(name="shape")
shape.geometry = MagicMock(name="geometry")
shape._verts = np.asarray(verts_local, dtype=np.float64)
shape._matrix = np.asarray(matrix_world_4x4, dtype=np.float64)
return shape
def test_opening_axis_extent_uses_geometry_kernel_vertices():
"""``_opening_axis_extent`` drives ``ifcopenshell.geom.create_shape`` to
get the opening's real geometry vertices and ``get_shape_matrix`` to get
its world placement, then projects the world-space corners onto the wall
axis. This is the production path works for every representation type
Bonsai may produce (mapped representation, swept area, brep, boolean).
A unit cube centred at world X=5 on a 10m wall axis projects to
t [0.45, 0.55] (the cube spans 0.5m on each axis around the centre)."""
from bonsai.bim.module.model.wall import _opening_axis_extent
# Unit cube in local coords, centred at (0,0,0), extent ±0.5.
verts_local = [
(-0.5, -0.5, -0.5),
(0.5, -0.5, -0.5),
(0.5, 0.5, -0.5),
(-0.5, 0.5, -0.5),
(-0.5, -0.5, 0.5),
(0.5, -0.5, 0.5),
(0.5, 0.5, 0.5),
(-0.5, 0.5, 0.5),
]
matrix_world = [
[1.0, 0.0, 0.0, 5.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[0.0, 0.0, 0.0, 1.0],
]
fake_shape = _fake_shape(verts_local, matrix_world)
opening = MagicMock(name="opening")
axis_reference = (
__import__("mathutils").Vector((0.0, 0.0)),
__import__("mathutils").Vector((10.0, 0.0)),
)
with (
patch("ifcopenshell.geom.create_shape", return_value=fake_shape),
patch("ifcopenshell.util.shape.get_vertices", return_value=fake_shape._verts),
patch("ifcopenshell.util.shape.get_shape_matrix", return_value=fake_shape._matrix),
):
min_t, max_t = _opening_axis_extent(opening, axis_reference, unit_scale=1.0)
# Cube spans world X ∈ [4.5, 5.5] → t ∈ [0.45, 0.55].
assert min_t == pytest.approx(0.45)
assert max_t == pytest.approx(0.55)
def test_opening_axis_extent_falls_back_to_placement_when_geometry_kernel_fails():
"""When ``ifcopenshell.geom.create_shape`` raises (representation it
can't process), the helper falls back to a degenerate single-point range
at the opening's composed placement origin. This is the safety net — it
matches the pre-fix center-only semantics rather than dropping the
opening entirely."""
from bonsai.bim.module.model.wall import _opening_axis_extent
opening = MagicMock(name="opening")
placement_matrix = [
[1.0, 0.0, 0.0, 5.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[0.0, 0.0, 0.0, 1.0],
]
axis_reference = (
__import__("mathutils").Vector((0.0, 0.0)),
__import__("mathutils").Vector((10.0, 0.0)),
)
with (
patch("ifcopenshell.geom.create_shape", side_effect=RuntimeError("kernel failure")),
patch("ifcopenshell.util.placement.get_local_placement") as mock_get_placement,
):
mock_get_placement.return_value = type("FakeArr", (), {"tolist": lambda self: placement_matrix})()
min_t, max_t = _opening_axis_extent(opening, axis_reference, unit_scale=1.0)
assert min_t == max_t == pytest.approx(0.5)
def test_opening_axis_extent_offset_cursor_inside_extent_returns_straddling_range():
"""The regression guard for the user-reported bug across two fix attempts:
when the cursor is placed *inside* the opening but not at its exact
centre, the helper must still return a range that straddles the cursor
position so the side test keeps the opening on both walls.
Pre-fix v2/v3 collapsed to a degenerate range whenever the production
representation type wasn't recognised (Blender bound_box absent in v2;
mapped representation not walked in v3). The current implementation uses
``ifcopenshell.geom.create_shape``, which handles every representation
Bonsai may produce."""
from bonsai.bim.module.model.wall import _opening_axis_extent
# 2m-wide opening centred at world X=5 → world X ∈ [4.0, 6.0] → t ∈ [0.4, 0.6].
verts_local = [(-1.0, -0.5, -0.5), (1.0, 0.5, 0.5)]
matrix_world = [
[1.0, 0.0, 0.0, 5.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[0.0, 0.0, 0.0, 1.0],
]
fake_shape = _fake_shape(verts_local, matrix_world)
opening = MagicMock(name="opening")
axis_reference = (
__import__("mathutils").Vector((0.0, 0.0)),
__import__("mathutils").Vector((10.0, 0.0)),
)
with (
patch("ifcopenshell.geom.create_shape", return_value=fake_shape),
patch("ifcopenshell.util.shape.get_vertices", return_value=fake_shape._verts),
patch("ifcopenshell.util.shape.get_shape_matrix", return_value=fake_shape._matrix),
):
min_t, max_t = _opening_axis_extent(opening, axis_reference, unit_scale=1.0)
# Cursor at world X=4.7 → t=0.47 (inside the opening, not centred on it).
cut_percentage = 0.47
assert (
min_t < cut_percentage < max_t
), f"opening [t={min_t}, t={max_t}] must straddle off-centre cursor at t={cut_percentage}"
def test_straddling_opening_is_kept_on_both_sides():
"""The pruning logic must keep an opening whose extent straddles the cut
on *both* element1 and element2.
Before the fix, an opening with centre at t=0.5 and cut_percentage=0.6
would be removed from element2 (centre < cut) but kept on element1; the
opening's right half — which physically overlaps element2 — would be
silently dropped. After the fix, the opening overlaps both portions of
the axis (min_t=0.3 < 0.6 < max_t=0.7) so both walls keep it.
Replays the boolean comparisons that ``DumbWallJoiner.split`` performs on
the helper's return value; does not call the helper itself."""
min_t, max_t = 0.3, 0.7 # straddles any cut_percentage in (0.3, 0.7)
cut_percentage = 0.6
removed_from_element1 = min_t > cut_percentage
removed_from_element2 = max_t < cut_percentage
assert removed_from_element1 is False, "straddling opening must remain on element1"
assert removed_from_element2 is False, "straddling opening must remain on element2"
def test_opening_entirely_past_cut_is_removed_from_element1_only():
"""Opening lies wholly on element2's side (min_t > cut_percentage).
Pre-fix and post-fix both remove it from element1; post-fix additionally
guarantees it stays on element2 because max_t > cut_percentage."""
min_t, max_t = 0.7, 0.9
cut_percentage = 0.5
assert (min_t > cut_percentage) is True # removed from element1
assert (max_t < cut_percentage) is False # kept on element2
def test_opening_entirely_before_cut_is_removed_from_element2_only():
"""Mirror of the above: opening wholly on element1's side."""
min_t, max_t = 0.1, 0.3
cut_percentage = 0.5
assert (min_t > cut_percentage) is False # kept on element1
assert (max_t < cut_percentage) is True # removed from element2
def test_opening_touching_cut_at_boundary_stays_on_both_walls():
"""Boundary touch: an opening's ``max_t`` lands exactly on the cut. Strict
inequalities keep the opening on both walls the safer default. (Non-
strict ``<=`` would have removed from element2 instead.)"""
min_t, max_t = 0.2, 0.5
cut_percentage = 0.5
assert (min_t > cut_percentage) is False # kept on element1
assert (max_t < cut_percentage) is False # kept on element2 (boundary == cut)
def test_degenerate_range_at_cut_keeps_opening_on_both_walls():
"""Regression guard for the **post-fix-v1 regression**: when the helper
falls back to a degenerate range ``(t, t)`` (geometry kernel failed, or
the pre-create_shape fix attempts that produced only the placement
centre), placing the 3D cursor *on* the opening's centre makes
``cut_percentage == t``.
With non-strict ``>=`` / ``<=`` tests, the degenerate range matched both
removal conditions and both walls dropped the opening leaving the user
with two walls and no hole anywhere. Strict ``>`` / ``<`` tests keep the
opening on both walls in this case, which matches the visible geometry."""
min_t, max_t = 0.5, 0.5 # degenerate range — both bounds at the centre
cut_percentage = 0.5 # cursor placed exactly on the opening centre
removed_from_element1 = min_t > cut_percentage
removed_from_element2 = max_t < cut_percentage
assert removed_from_element1 is False, "must not remove from element1 when cursor sits on opening centre"
assert removed_from_element2 is False, "must not remove from element2 when cursor sits on opening centre"
# ---------------------------------------------------------------------------
# Filled-opening void-straddle behaviour
#
# When a wall split passes through a door/window, the filling (the door
# element itself) belongs to whichever wall contains its centre — but the
# void cut by the IfcOpeningElement may still straddle the cut, in which
# case the neighbour wall's body must also be cut. The helper that adds the
# pure-void copy is ``_add_void_copy``; the decision is taken in
# ``DumbWallJoiner.split``'s filled-opening loop.
# ---------------------------------------------------------------------------
def _make_void_copy_mock(has_filling_rel=True):
"""Build the ``void_copy`` MagicMock returned by ``copy_class`` so its
``HasFillings`` / ``VoidsElements`` / ``ObjectPlacement`` shape matches
what ``_add_void_copy`` mutates."""
copy_placement = MagicMock(name="copy_placement")
copy_placement.is_a = lambda klass: klass == "IfcLocalPlacement"
void_relation = MagicMock(name="VoidsRelation")
void_copy = MagicMock(name="void_copy")
void_copy.HasFillings = (MagicMock(name="copy_filling_rel"),) if has_filling_rel else ()
void_copy.VoidsElements = (void_relation,)
void_copy.ObjectPlacement = copy_placement
return void_copy, void_relation, copy_placement
def test_add_void_copy_strips_fillings_and_reparents_to_target_wall():
"""``_add_void_copy`` must create a pure-void IfcOpeningElement attached
to the target wall: the filling relationship copied along with the source
must be removed, ``VoidsElements[0].RelatingBuildingElement`` must point
at the target wall, and the representation must be a deep copy (not a
shared reference with the source)."""
from bonsai.bim.module.model.wall import _add_void_copy
source_representation = MagicMock(name="source_representation")
source_opening = MagicMock(name="source_opening")
source_opening.Representation = source_representation
void_copy, void_relation, copy_placement = _make_void_copy_mock()
carried_filling_rel = void_copy.HasFillings[0]
target_placement = MagicMock(name="target_placement")
target_wall = MagicMock(name="target_wall")
target_wall.ObjectPlacement = target_placement
ifc_file = MagicMock(name="ifc_file")
deep_copy_result = MagicMock(name="copied_representation")
with (
patch("bonsai.tool.Ifc.get", return_value=ifc_file),
patch("ifcopenshell.api.root.copy_class", return_value=void_copy) as mock_copy_class,
patch("ifcopenshell.util.element.copy_deep", return_value=deep_copy_result),
):
_add_void_copy(target_wall, source_opening)
# The carried-over filling relationship must be removed — the copy is a pure void.
ifc_file.remove.assert_called_once_with(carried_filling_rel)
# The void now points at the target wall, not the source's wall.
assert void_relation.RelatingBuildingElement is target_wall
# The placement is reparented under the target wall's local placement.
assert copy_placement.PlacementRelTo is target_placement
# The representation is deep-copied so future edits don't ripple back to source.
assert void_copy.Representation is deep_copy_result
mock_copy_class.assert_called_once_with(ifc_file, product=source_opening)
def test_add_void_copy_handles_source_with_no_fillings():
"""If the source opening has no ``HasFillings`` (the copy_class result
inherits that), the loop over ``void_copy.HasFillings or ()`` must run
zero times no spurious ``ifc_file.remove`` call."""
from bonsai.bim.module.model.wall import _add_void_copy
source_opening = MagicMock(name="source_opening")
source_opening.Representation = MagicMock(name="rep")
void_copy, _void_relation, _copy_placement = _make_void_copy_mock(has_filling_rel=False)
target_wall = MagicMock(name="target_wall")
ifc_file = MagicMock(name="ifc_file")
with (
patch("bonsai.tool.Ifc.get", return_value=ifc_file),
patch("ifcopenshell.api.root.copy_class", return_value=void_copy),
patch("ifcopenshell.util.element.copy_deep", return_value=MagicMock()),
):
_add_void_copy(target_wall, source_opening)
ifc_file.remove.assert_not_called()
def test_filled_opening_void_straddle_decision_keeps_void_on_neighbour():
"""Replays the decision logic in ``DumbWallJoiner.split``'s filled-opening
loop for the case ``filling_position <= cut_percentage and void_straddles``:
filling stays on element1 (its centre is before the cut), but the void
extent crosses the cut, so the neighbour wall (element2) must receive a
pure-void copy via ``_add_void_copy``.
Mirrors the unfilled-opening decision tests exercises the boolean
branching rather than full ``split()`` integration."""
cut_percentage = 0.5
filling_position = 0.4 # filling centre on element1's side
min_t, max_t = 0.3, 0.7 # void extent straddles cut at 0.5
void_straddles = min_t < cut_percentage < max_t
filling_on_element2 = filling_position > cut_percentage
# Expected branch: filling stays, but void straddles → add copy to element2.
assert void_straddles is True
assert filling_on_element2 is False
# Equivalent to the ``elif void_straddles:`` path adding a void copy to element2.
def test_filled_opening_void_straddle_with_filling_on_far_side_keeps_void_on_origin():
"""The symmetric case: ``filling_position > cut_percentage and void_straddles``.
Filling moves to element2 with the original void; element1 needs a
pure-void copy back (the void's element1 portion would otherwise be
orphaned). Documents the boolean state of the inner branch."""
cut_percentage = 0.5
filling_position = 0.6 # filling centre on element2's side
min_t, max_t = 0.3, 0.7
void_straddles = min_t < cut_percentage < max_t
filling_on_element2 = filling_position > cut_percentage
assert void_straddles is True
assert filling_on_element2 is True
# Equivalent to the outer ``if filling_position > cut_percentage`` path
# taking its inner ``if void_straddles`` branch and adding a void copy
# back to element1.
+24 -1
View File
@@ -15,6 +15,8 @@
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was modified with the assistance of an AI coding tool.
from __future__ import annotations
@@ -1131,6 +1133,17 @@ def the_variable_key_is_value(key, value):
variables[key] = eval(replace_variables(value))
@then(parsers.parse('the variable "{key}" equals "{value}"'))
def the_variable_key_equals_value(key, value):
assert key in variables, f'Variable "{key}" was never set'
expected = eval(replace_variables(value))
actual = variables[key]
if isinstance(actual, float) and isinstance(expected, float):
assert abs(actual - expected) < 1e-5, f'Variable "{key}" is {actual!r}, expected {expected!r}'
else:
assert actual == expected, f'Variable "{key}" is {actual!r}, expected {expected!r}'
@then("nothing happens")
def nothing_happens():
pass
@@ -1751,7 +1764,17 @@ def i_load_the_ifc_test_file(filepath):
@given("I load the demo construction library")
@when("I load the demo construction library")
def i_add_a_construction_library():
lib_path = "./bonsai/bim/data/libraries/IFC4 Demo Library.ifc"
# Pick the library file whose schema matches the current project so the
# appended types are valid (IFC2X3-vs-IFC4 entity attributes differ).
schema_to_library = {
"IFC2X3": "IFC2X3 Demo Library.ifc",
"IFC4": "IFC4 Demo Library.ifc",
"IFC4X3": "IFC4X3 Demo Library.ifc",
"IFC4X3_ADD2": "IFC4X3 Demo Library.ifc",
}
schema = tool.Ifc.get().schema
lib_name = schema_to_library.get(schema, "IFC4 Demo Library.ifc")
lib_path = f"./bonsai/bim/data/libraries/{lib_name}"
bpy.ops.bim.select_library_file(filepath=lib_path, append_all=True)
@@ -0,0 +1,177 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Forward-compat AST contracts for the BIM Tool refresh path.
Pins structural invariants that no behavioural test can catch on its own:
the commit-driven header refresh fires only for the parametric validate-
gizmo operators (``bim.finish_editing_<name>``), never universally and
the header writer never drifts into user-intent enum writes."""
import ast
from pathlib import Path
import pytest
pytestmark = pytest.mark.model
HANDLER_PATH = Path(__file__).parent.parent.parent / "bonsai" / "bim" / "handler.py"
PARAMETRIC_PATH = HANDLER_PATH.parent.parent / "tool" / "parametric.py"
MODEL_MODULE_DIR = HANDLER_PATH.parent / "module" / "model"
# User-intent enums encode the user's "what to build next" choice on the
# BIM Tool panel. The header-only writer must never drift into enum writes;
# user-intent enums are owned by the selection-change path.
USER_INTENT_ENUM_ATTRS = frozenset({"ifc_class", "relating_type_id"})
def _function_node(tree: ast.Module, name: str) -> ast.FunctionDef:
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef) and node.name == name:
return node
raise AssertionError(f"{name!r} not found in {HANDLER_PATH.name}")
@pytest.fixture(scope="module")
def handler_tree() -> ast.Module:
return ast.parse(HANDLER_PATH.read_text(encoding="utf-8"))
def test_read_headers_into_props_writes_only_header_floats(handler_tree: ast.Module) -> None:
"""``_read_headers_into_props`` is the header-only writer called from
the selection-driven refresh. It must not assign to user-intent enum
slots (``ifc_class``, ``relating_type_id``); those are the
'what to build next' choice and have their own targeted writes
earlier in ``update_bim_tool_props``."""
fn = _function_node(handler_tree, "_read_headers_into_props")
offenders = []
for node in ast.walk(fn):
if not isinstance(node, ast.Assign):
continue
for target in node.targets:
if isinstance(target, ast.Attribute) and target.attr in USER_INTENT_ENUM_ATTRS:
offenders.append((target.attr, node.lineno))
if offenders:
msgs = ", ".join(f"{attr} at line {line}" for attr, line in offenders)
pytest.fail(
f"_read_headers_into_props assigns to user-intent enum slot(s): {msgs}. "
f"Header refresh must not re-target the user's BIM Tool panel selection."
)
def test_refresh_post_commit_gates_header_refresh_on_edit_types_registry() -> None:
"""``tool.Parametric.refresh_post_commit`` fires for every IFC
operator commit. Only operators whose ``bl_idname`` matches a
``ParametricObject.finish_op`` in ``EDIT_TYPES`` (the validate-
gizmo path) must trigger a BIM Tool header refresh selection
didn't change but the header values did. Other operators must
skip the refresh: they don't target an active-object header edit,
and their commit context may lack the view-layer attributes the
refresh reads.
The gate must consult the registry, not match a string prefix
``EDIT_TYPES`` is the canonical list of parametric features, and
querying it stays correct even if ``ParametricObject.finish_op``
changes its derivation rule."""
parametric_tree = ast.parse(PARAMETRIC_PATH.read_text(encoding="utf-8"))
fn = _function_node(parametric_tree, "refresh_post_commit")
found_gated_call = False
for node in ast.walk(fn):
if not isinstance(node, ast.If):
continue
references_registry = any(
isinstance(sub, ast.Attribute) and sub.attr == "EDIT_TYPES" for sub in ast.walk(node.test)
)
if not references_registry:
continue
for body_node in ast.walk(node):
if (
isinstance(body_node, ast.Call)
and isinstance(body_node.func, ast.Attribute)
and body_node.func.attr == "refresh_bim_tool_headers"
):
found_gated_call = True
break
if found_gated_call:
break
assert found_gated_call, (
"tool.Parametric.refresh_post_commit must gate refresh_bim_tool_headers on an "
"If whose test references EDIT_TYPES (the parametric registry). An ungated call "
"fires the refresh for commits in contexts that strip view-layer attributes; "
"a missing call silently drops the validate-gizmo header refresh."
)
def _modules_with_module_scope_cache_and_clear():
"""Yield ``module_name`` for every ``bim/module/model/*.py`` source that
declares a module-scope ``GenerationKeyedCache()`` assignment AND a
top-level ``def clear_caches``. These are the modules whose cache state
survives file loads and must be drained from ``_apply_save_file_invariants``."""
for path in MODEL_MODULE_DIR.glob("*.py"):
tree = ast.parse(path.read_text(encoding="utf-8"))
has_cache = False
has_clear = False
for node in tree.body:
if isinstance(node, ast.FunctionDef) and node.name == "clear_caches":
has_clear = True
continue
if isinstance(node, ast.Assign):
for sub in ast.walk(node.value):
if (
isinstance(sub, ast.Call)
and isinstance(sub.func, ast.Attribute)
and sub.func.attr == "GenerationKeyedCache"
):
has_cache = True
break
if has_cache and has_clear:
yield path.stem
def test_on_load_post_drains_every_module_scope_geom_cache() -> None:
"""Module-scope ``GenerationKeyedCache`` instances persist across file
loads the counter they invalidate against is class-level and survives
a ``.blend`` reload. Without a ``load_post`` drain the cache may serve
entries whose ``bpy_struct`` references point into the previous file's
freed ``bpy.data``, raising ``ReferenceError`` on the next attribute read.
Pin: every model module that exposes both a module-scope cache and a
top-level ``clear_caches`` is called from ``tool.Parametric.on_load_post``,
the central post-load drain."""
parametric_tree = ast.parse(PARAMETRIC_PATH.read_text(encoding="utf-8"))
fn = _function_node(parametric_tree, "on_load_post")
drained: set[str] = set()
for node in ast.walk(fn):
if (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and node.func.attr == "clear_caches"
and isinstance(node.func.value, ast.Name)
):
drained.add(node.func.value.id)
missing = [name for name in _modules_with_module_scope_cache_and_clear() if name not in drained]
if missing:
pytest.fail(
"Module(s) expose a module-scope GenerationKeyedCache + clear_caches() but "
f"tool.Parametric.on_load_post does not drain them on load_post: {sorted(missing)}. "
"Add a `<module>.clear_caches()` call so freshly-loaded files cannot serve "
"entries holding freed bpy.data references from the previous file."
)
@@ -0,0 +1,74 @@
# 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.
"""Restricted-context regression test for ``tool.Blender.get_active_object``.
Some Blender contexts (e.g. the C-side operator context handed to
programmatically-invoked nested ``bpy.ops`` calls) lack the view-layer
attributes a normal UI context exposes. The canonical accessor must
return ``None`` in that case rather than ``AttributeError`` otherwise
every caller routed through it inherits the same crash class that
originally broke ``bpy.ops.bim.new_project(preset='demo')``."""
from unittest.mock import patch
import pytest
pytestmark = pytest.mark.model
class _RestrictedContext:
"""Stand-in for a ``bpy.context`` stripped of view-layer attributes."""
def __getattr__(self, name):
raise AttributeError(name)
def test_get_active_object_returns_none_in_restricted_context():
"""``tool.Blender.get_active_object`` is the canonical defensive
accessor. Both the primary read (``bpy.context.active_object``) and
the fallback (``bpy.context.view_layer.objects.active``) must
tolerate a stripped context otherwise the 150+ callers in the
codebase that route through this helper inherit the crash."""
import bonsai.tool.blender as blender_tool
with patch.object(blender_tool, "bpy") as bpy_patch:
bpy_patch.context = _RestrictedContext()
assert blender_tool.Blender.get_active_object() is None
def test_property_header_tools_whitelists_bim_tool_family_only():
"""``tool.Blender.get_property_header_tools`` gates the validate-
gizmo header refresh. Parametric ``BimTool`` subclasses and the
base ``BimTool`` itself must be included; ``AnnotationTool`` and
workspace tools outside the ``BimTool`` family (spatial, structural,
etc.) must not they don't surface these header floats."""
import bonsai.tool as tool_
# Ensure the lru_cache picks up subclasses registered by the
# current Blender session (idempotent if already populated).
tool_.Blender.get_property_header_tools.cache_clear()
headers = tool_.Blender.get_property_header_tools()
assert "bim.bim_tool" in headers, "base BimTool must surface property headers"
assert "bim.wall_tool" in headers, "parametric BimTool subclass must surface property headers"
assert "bim.annotation_tool" not in headers, "AnnotationTool is not a BimTool subclass — no header surface"
assert "bim.spatial_tool" not in headers, "SpatialTool is not BimTool-derived"
assert "bim.structural_tool" not in headers, "StructuralTool is not BimTool-derived"
@@ -0,0 +1,411 @@
# 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.
"""Unit coverage for the shared parametric-edit lifecycle mixins.
``bonsai.bim.parametric_lifecycle`` is the load-bearing path for 4 of 6
parametric features (door, window, railing, roof). The registry smoke test
elsewhere verifies operators are wired up; the mixins' own state-transition
contracts are tested here.
The mixins are exercised through minimal in-test subclasses that supply the
abstract hooks (``_is_element_type``, ``_get_props``, etc.). All ``tool.*`` and
``ifcopenshell.*`` references at the module top of ``parametric_lifecycle`` are
patched at the module attribute (not the source module) so each test sees
isolated mock state."""
import json
from typing import ClassVar
from unittest import mock
import pytest
pytestmark = pytest.mark.model
@pytest.fixture(autouse=True)
def _require_real_bpy():
import types as _types
import bpy
if not isinstance(bpy, _types.ModuleType) or hasattr(bpy, "_mock_name"):
pytest.skip("requires real Blender (bpy is mocked or absent)")
class _FakeProps:
"""Stand-in for ``BIM<Name>Properties`` — records what was set so tests can
assert state transitions without instantiating real PropertyGroups."""
def __init__(self):
self.is_editing = False
self.last_kwargs = None
self.general = {"width": 1000}
self.lining = {"thickness": 50}
self.panel = {"material": "wood"}
def set_props_kwargs_from_ifc_data(self, data):
self.last_kwargs = dict(data)
def get_general_kwargs(self, convert_to_project_units=True):
return dict(self.general)
def get_lining_kwargs(self, convert_to_project_units=True):
return dict(self.lining)
def get_panel_kwargs(self, convert_to_project_units=True):
return dict(self.panel)
def _make_obj(props):
obj = mock.Mock()
obj.props = props
obj.name = "TestObj"
return obj
def _make_pset_text(general, lining, panel):
payload = {"lining_properties": lining, "panel_properties": panel, **general}
return json.dumps(payload)
# ----------------------------------------------------------------------
# FeatureModifierEditMixin (door/window pattern)
# ----------------------------------------------------------------------
def _door_mixin_cls(match=True, raise_on_update=False):
from bonsai.bim.parametric_lifecycle import FeatureModifierEditMixin
raised = raise_on_update
class _TestDoorMixin(FeatureModifierEditMixin):
pset_name: ClassVar[str] = "BBIM_Door"
representations_called: ClassVar[list] = []
@classmethod
def _is_element_type(cls, element):
return match
@classmethod
def _get_props(cls, obj):
return obj.props
@classmethod
def _update_modifier_representation(cls, obj, context):
cls.representations_called.append(obj)
if raised:
raise RuntimeError("simulated representation failure")
return _TestDoorMixin
@pytest.fixture
def patched_tool_and_ifc():
"""Patch ``tool`` and ``ifcopenshell.*`` references on the lifecycle module.
Yields ``(mock_tool, mock_ifc_util_element, mock_ifc_api_pset,
mock_ifc_util_rep, mock_core_geometry)`` so tests can configure return
values and assert call args."""
target = "bonsai.bim.parametric_lifecycle"
with mock.patch(f"{target}.tool") as mock_tool, mock.patch(f"{target}.ifcopenshell") as mock_ifc, mock.patch(
f"{target}.bonsai"
) as mock_bonsai:
# Element returned by tool.Ifc.get_entity is reused across mocks.
element = mock.Mock(name="entity")
mock_tool.Ifc.get_entity.return_value = element
mock_tool.Ifc.get.return_value = mock.Mock(name="ifc_file")
mock_tool.Model.get_constituents_props_data.return_value = {"materials": []}
mock_tool.Pset.get_element_pset.return_value = mock.Mock(name="pset")
mock_ifc.util.element.get_type.return_value = None # skip thumbnail mark
yield {
"tool": mock_tool,
"ifc": mock_ifc,
"bonsai": mock_bonsai,
"element": element,
}
def test_feature_modifier_enable_one_sets_is_editing_and_loads_kwargs(patched_tool_and_ifc):
props = _FakeProps()
obj = _make_obj(props)
patched_tool_and_ifc["ifc"].util.element.get_pset.return_value = _make_pset_text(
{"width": 1234}, {"thickness": 50}, {"material": "wood"}
)
cls = _door_mixin_cls(match=True)
cls._enable_one(obj)
assert props.is_editing is True
assert props.last_kwargs is not None
assert props.last_kwargs["width"] == 1234
assert props.last_kwargs["thickness"] == 50
assert props.last_kwargs["material"] == "wood"
assert "materials" in props.last_kwargs # from get_constituents_props_data
def test_feature_modifier_enable_one_noop_when_element_not_match(patched_tool_and_ifc):
props = _FakeProps()
obj = _make_obj(props)
cls = _door_mixin_cls(match=False)
cls._enable_one(obj)
assert props.is_editing is False
assert props.last_kwargs is None
# get_pset must not be called when _is_element_type returns False — the
# _resolve guard short-circuits before reading pset data.
patched_tool_and_ifc["ifc"].util.element.get_pset.assert_not_called()
def test_feature_modifier_enable_one_noop_when_no_entity(patched_tool_and_ifc):
"""tool.Ifc.get_entity returning None must short-circuit before predicate runs."""
props = _FakeProps()
obj = _make_obj(props)
patched_tool_and_ifc["tool"].Ifc.get_entity.return_value = None
cls = _door_mixin_cls(match=True)
cls._enable_one(obj)
assert props.is_editing is False
def test_feature_modifier_finish_one_clears_is_editing_and_writes_pset(patched_tool_and_ifc):
props = _FakeProps()
props.is_editing = True
obj = _make_obj(props)
ctx = mock.Mock(name="context")
cls = _door_mixin_cls(match=True)
cls._finish_one(obj, ctx)
assert props.is_editing is False
assert obj in cls.representations_called
# tool.Pset.write_bbim_data is called exactly once with the merged dict.
patched_tool_and_ifc["tool"].Pset.write_bbim_data.assert_called_once()
call_args = patched_tool_and_ifc["tool"].Pset.write_bbim_data.call_args
assert call_args.args[1] == "BBIM_Door" # pset_name positional arg
written_data = call_args.args[2]
assert "lining_properties" in written_data and "panel_properties" in written_data
def test_feature_modifier_finish_one_exception_leaves_draft_in_progress(patched_tool_and_ifc):
"""If _update_modifier_representation raises, is_editing must stay True
so the user's draft survives for retry. This is the contract called out
in parametric_lifecycle.py:161 set is_editing=False only on success."""
props = _FakeProps()
props.is_editing = True
obj = _make_obj(props)
ctx = mock.Mock(name="context")
cls = _door_mixin_cls(match=True, raise_on_update=True)
with pytest.raises(RuntimeError, match="simulated representation failure"):
cls._finish_one(obj, ctx)
assert props.is_editing is True # draft survives
def test_feature_modifier_cancel_one_restores_and_clears_is_editing(patched_tool_and_ifc):
props = _FakeProps()
props.is_editing = True
obj = _make_obj(props)
patched_tool_and_ifc["ifc"].util.element.get_pset.return_value = _make_pset_text(
{"width": 900}, {"thickness": 60}, {"material": "steel"}
)
cls = _door_mixin_cls(match=True)
cls._cancel_one(obj)
assert props.is_editing is False
assert props.last_kwargs is not None and props.last_kwargs["width"] == 900
# switch_representation must be called via bonsai.core.geometry.
patched_tool_and_ifc["bonsai"].core.geometry.switch_representation.assert_called_once()
def test_feature_modifier_targets_loop_uses_iter_targets(patched_tool_and_ifc):
"""_enable_targets / _finish_targets / _cancel_targets iterate
_iter_targets default is [active_object]; subclasses can override."""
props_a, props_b = _FakeProps(), _FakeProps()
obj_a, obj_b = _make_obj(props_a), _make_obj(props_b)
patched_tool_and_ifc["ifc"].util.element.get_pset.return_value = _make_pset_text(
{"width": 1000}, {"thickness": 50}, {"material": "wood"}
)
cls = _door_mixin_cls(match=True)
cls._iter_targets = classmethod(lambda c, ctx: [obj_a, obj_b])
result = cls()._enable_targets(mock.Mock())
assert result == {"FINISHED"}
assert props_a.is_editing is True
assert props_b.is_editing is True
# ----------------------------------------------------------------------
# PathPreservingEditMixin (railing/roof pattern)
# ----------------------------------------------------------------------
class _FakePathProps:
"""Stand-in for railing/roof properties — get_general_kwargs only (no lining/panel)."""
def __init__(self):
self.is_editing = False
self.last_kwargs = None
self.general = {"width": 200, "thickness": 10}
def set_props_kwargs_from_ifc_data(self, data):
self.last_kwargs = dict(data)
def get_general_kwargs(self, convert_to_project_units=True):
return dict(self.general)
def _path_mixin_cls(match=True):
from bonsai.bim.parametric_lifecycle import PathPreservingEditMixin
class _TestPathMixin(PathPreservingEditMixin):
pset_name: ClassVar[str] = "BBIM_Railing"
pset_updates: ClassVar[list] = []
ifc_data_updates: ClassVar[list] = []
bmesh_updates: ClassVar[list] = []
@classmethod
def _is_element_type(cls, element):
return match
@classmethod
def _get_props(cls, obj):
return obj.props
@classmethod
def _update_pset(cls, element, data):
cls.pset_updates.append((element, data))
@classmethod
def _update_modifier_ifc_data(cls, obj, context):
cls.ifc_data_updates.append(obj)
@classmethod
def _restore_viewport_after_cancel(cls, obj, context):
cls.bmesh_updates.append(obj)
return _TestPathMixin
def test_path_preserving_enable_one_sets_is_editing(patched_tool_and_ifc):
props = _FakePathProps()
obj = _make_obj(props)
patched_tool_and_ifc["tool"].Model.get_modeling_bbim_pset_data.return_value = {
"data_dict": {"width": 250, "path_data": {"points": [[0, 0], [1, 0]]}}
}
cls = _path_mixin_cls(match=True)
cls._enable_one(obj)
assert props.is_editing is True
assert props.last_kwargs is not None
assert props.last_kwargs["width"] == 250
# path_data passes through (default _post_load_data is pass-through)
assert props.last_kwargs["path_data"] == {"points": [[0, 0], [1, 0]]}
def test_path_preserving_finish_one_preserves_path_data_and_clears_is_editing(patched_tool_and_ifc):
props = _FakePathProps()
props.is_editing = True
obj = _make_obj(props)
ctx = mock.Mock(name="context")
sentinel_path = {"points": [[5, 5], [9, 9]], "edges": [[0, 1]]}
patched_tool_and_ifc["tool"].Model.get_modeling_bbim_pset_data.return_value = {
"data_dict": {"path_data": sentinel_path}
}
cls = _path_mixin_cls(match=True)
cls._finish_one(obj, ctx)
assert props.is_editing is False
assert cls.pset_updates, "_update_pset must be called on Finish"
assert cls.pset_updates[-1][1]["path_data"] is sentinel_path # preserved by reference
assert obj in cls.ifc_data_updates
def test_path_preserving_cancel_one_calls_restore_viewport_after_cancel(patched_tool_and_ifc):
props = _FakePathProps()
props.is_editing = True
obj = _make_obj(props)
ctx = mock.Mock(name="context")
patched_tool_and_ifc["tool"].Model.get_modeling_bbim_pset_data.return_value = {
"data_dict": {"width": 250, "path_data": {"points": []}}
}
cls = _path_mixin_cls(match=True)
cls._cancel_one(obj, ctx)
assert props.is_editing is False
assert obj in cls.bmesh_updates
def test_path_preserving_enable_one_post_load_data_hook_runs(patched_tool_and_ifc):
"""Railing overrides _post_load_data to JSON-serialise path_data —
confirm the hook is honoured (here we drop a sentinel key)."""
props = _FakePathProps()
obj = _make_obj(props)
patched_tool_and_ifc["tool"].Model.get_modeling_bbim_pset_data.return_value = {
"data_dict": {"width": 250, "extra": "drop_me"}
}
cls = _path_mixin_cls(match=True)
cls._post_load_data = classmethod(lambda c, data: {k: v for k, v in data.items() if k != "extra"})
cls._enable_one(obj)
assert "extra" not in props.last_kwargs
# ----------------------------------------------------------------------
# _ParametricEditMixinBase._resolve guard
# ----------------------------------------------------------------------
def test_resolve_returns_none_when_obj_has_no_entity(patched_tool_and_ifc):
cls = _door_mixin_cls(match=True)
patched_tool_and_ifc["tool"].Ifc.get_entity.return_value = None
obj = _make_obj(_FakeProps())
assert cls._resolve(obj) is None
def test_resolve_returns_none_when_element_type_mismatch(patched_tool_and_ifc):
cls = _door_mixin_cls(match=False)
obj = _make_obj(_FakeProps())
assert cls._resolve(obj) is None
def test_resolve_returns_tuple_when_match(patched_tool_and_ifc):
cls = _door_mixin_cls(match=True)
props = _FakeProps()
obj = _make_obj(props)
resolved = cls._resolve(obj)
assert resolved is not None
element, returned_props = resolved
assert element is patched_tool_and_ifc["element"]
assert returned_props is props
@@ -0,0 +1,146 @@
# 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.
"""Registration smoke test for `tool.Parametric.EDIT_TYPES`.
The registry is the single source of truth for which parametric element types
exist. Every consumer (auto-commit on save, finish/cancel chains, the
``PointerProperty`` attachment, the ``GizmoPreferences`` per-feature toggle)
derives identifiers from each entry's short ``name`` token. Forget any
downstream registration and the silent-desync the framework exists to prevent
will ship.
These tests pin the registry-to-runtime contract: for every entry the operator
``bl_idname``s resolve to registered ``bpy.ops.bim.*`` callables, the
``PropertyGroup`` class is attached to ``bpy.types.Object``, and the per-type
predicate exists on `tool.Parametric`."""
import types
import bpy
import pytest
pytestmark = pytest.mark.model
@pytest.fixture(autouse=True)
def _require_real_bpy():
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
pytest.skip("requires real Blender (bpy is mocked or absent)")
@pytest.fixture
def registry():
from bonsai import tool
return tool.Parametric.EDIT_TYPES
def test_registry_is_non_empty(registry):
assert len(registry) >= 1
def test_every_entry_has_enable_op_registered(registry):
missing = [e.enable_op for e in registry if not hasattr(bpy.ops.bim, e.enable_op.removeprefix("bim."))]
assert not missing, f"Missing enable operators: {missing}"
def test_every_entry_has_finish_op_registered(registry):
missing = [e.finish_op for e in registry if not hasattr(bpy.ops.bim, e.finish_op.removeprefix("bim."))]
assert not missing, f"Missing finish operators: {missing}"
def test_every_entry_has_cancel_op_registered(registry):
missing = [e.cancel_op for e in registry if not hasattr(bpy.ops.bim, e.cancel_op.removeprefix("bim."))]
assert not missing, f"Missing cancel operators: {missing}"
def test_every_entry_has_property_group_attached(registry):
# ``register_object_properties`` runs at addon enable; if any entry's
# PropertyGroup class is missing on prop module the attribute is skipped.
missing = [e.props_attr for e in registry if not hasattr(bpy.types.Object, e.props_attr)]
assert not missing, (
f"bpy.types.Object missing attributes: {missing}"
f"verify the matching PropertyGroup classes exist in bim.module.model.prop"
)
def test_every_entry_has_parametric_predicate(registry):
from bonsai import tool
missing = [e.name for e in registry if getattr(tool.Parametric, f"is_{e.name}", None) is None]
assert not missing, f"tool.Parametric missing is_<name> predicates: {missing}"
def test_every_predicate_does_not_raise_on_non_matching_element(registry):
"""Each ``is_<name>`` predicate must be **total**: accept any IFC entity
and return a truthy/falsy value, never raise.
The registry iterates every predicate against the active IFC element on
save; a raising predicate (e.g. ``AttributeError`` from a missing pset
accessor when handed a non-matching element type) propagates upward and
breaks the save path for *all* parametric types, not just its own.
This test probes each predicate with an ``IfcAnnotation`` (an element
that carries none of the BBIM_<Type> psets the predicates look up) and
asserts the call does not raise. Falsy returns are acceptable the
registry treats them as 'no match'. What's forbidden is raising."""
import ifcopenshell
from bonsai import tool
probe = ifcopenshell.file(schema="IFC4").create_entity("IfcAnnotation")
raised = []
for feature in registry:
predicate = getattr(tool.Parametric, f"is_{feature.name}", None)
if predicate is None:
continue
try:
predicate(probe)
except Exception as e:
raised.append((feature.name, type(e).__name__, str(e)))
assert not raised, (
f"is_<name> predicates raised on a non-matching IfcAnnotation: {raised}. "
f"Predicates must be total — return bool, never raise. Add an "
f"`if not element.is_a('IfcXxx'): return False` short-circuit or guard the pset lookup."
)
def test_gizmo_preferences_field_per_registry_entry(registry):
"""Every registry entry must have a matching ``<name>: BoolProperty`` field
on ``ui.GizmoPreferences`` so the addon-preferences UI auto-renders a
toggle for it and ``BaseParametricGizmoGroup.poll`` can gate the whole
gizmo group on ``prefs.gizmos.<name>``.
Checks ``__annotations__`` rather than ``hasattr`` because Blender's
PropertyGroup syntax (``field: bpy.props.BoolProperty(...)``) is an
annotation-only assignment the attribute only materialises on the
class after Blender's metaclass installs the bpy_struct descriptor,
which depends on registration timing. Reading ``__annotations__``
pins the source-level contract independently of when register() ran."""
from bonsai.bim import ui
annotations = getattr(ui.GizmoPreferences, "__annotations__", {})
missing = [feature.name for feature in registry if feature.name not in annotations]
assert not missing, (
f"ui.GizmoPreferences missing BoolProperty field(s) for: {missing}"
f"each registry entry must have a matching ``<name>: BoolProperty(...)`` "
f"field on ``ui.GizmoPreferences`` so the preferences UI surfaces a toggle"
)

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