Compare commits

...

94 Commits

Author SHA1 Message Date
Ryan Schultz 0d39a21bf4 closes #6235 - Add copy toggle to CAD offset
Add a "Copy" option to bim.cad_offset. When enabled (the
default) it offsets a new copy of the selected edges as
before; when disabled it moves the existing edges to the
offset location instead. The toggle is exposed in the CAD
tool's Offset panel and the operator redo panel.

Generated with the assistance of an AI coding tool.
2026-06-12 14:02:44 -05:00
Thomas Krijnen 671217d494 Commit remainder of fixes to IfcParseExamples 2026-06-12 12:13:35 +02:00
Thomas Krijnen dcebf23af8 Workaround for header construction order 2026-06-12 11:41:11 +02:00
Thomas Krijnen 4d22a3fdb9 Enable retargeting of example schema 2026-06-12 11:04:18 +02:00
Thomas Krijnen 3136c74c2f Pass logger to proj callback 2026-06-11 21:57:42 +02:00
Thomas Krijnen 347a3c80bb More logger changes 2026-06-11 21:09:56 +02:00
Gorgious56 0c993d3292 Guard HasShapeAspects access on IFC2X3 representation iteration
IFC2X3 representations have no HasShapeAspects inverse; opening the
Geometry & Materials subpanel on an IFC2X3 object raised AttributeError
and left the items list empty. Wrap the access with a getattr default
so pre-IFC4 schemas return an empty iterable, and pin the contract with
an AST forward-compat guard that scans bim/, tool/, and core/ for any
future direct .HasShapeAspects access.

Closes #8157

Generated with the assistance of an AI coding tool.
2026-06-11 09:28:27 +02:00
Richard Brice 32a601d057 fixes build problem from commit a7738eeb 2026-06-10 13:31:32 -07:00
Thomas Krijnen a7738eeb64 Pass around non-static logger instances and programmatic access to messages in-memory 2026-06-10 18:40:17 +02:00
Thomas Krijnen a751fb956d Introduce unique error codes 2026-06-10 18:40:17 +02:00
Gorgious56 251157f8d4 Fix np_frombuffer_legacy length-vs-dtype check
The check `len(bytedata) == n * 2` was wrong: float64 is 8 bytes per
element, not 2. Legacy float64 checksums fell through to the float32
reader and produced a (2n,)-shaped array, breaking is_moved() and
is_camera_moved() with `ValueError: operands could not be broadcast`
on .blend files saved by Blender <5.0.

Adds a parametrized regression test covering both n=3 (translation)
and n=9 (rotation) for both dtypes.

Generated with the assistance of an AI coding tool.
2026-06-10 17:37:41 +02:00
Gorgious56 a213a9b848 Merge pull request #8155 from Gorgious56/bonsai/mep-edit-gizmos
Add MEP segment + bend edit gizmos
2026-06-10 17:35:33 +02:00
Gorgious56 b76bc1c1f8 Read wall extent from bbox in cursor gizmo layout
GizmoWallEdition.position_gizmos used props.anchor_x / props.length
for the in-range check (split icon visibility) and perpendicular
gizmo placement. Those props mirror IFC and are re-primed by
_maybe_resync_wall_props_from_ifc — any operator path that skips
the re-sync leaves the perpendicular gizmo clamped to the previous
wall extent, so the icon parks at the old wall end instead of the
cursor's orthogonal projection. Visible after a wall mutation as
the perpendicular icon landing way off the cursor in top-down view.

Switch to the mesh bbox along local X. recreate_wall rebuilds the
mesh to match the current IFC body on every wall mutation, so
bound_box is authoritative without an explicit props sync.

Generated with the assistance of an AI coding tool.
2026-06-10 13:17:07 +02:00
Gorgious56 4d24cef0c9 Hide MEP gizmos on non-parametric elements
MEP elements imported as tessellation / brep (no IfcExtrudedAreaSolid
or IfcSweptDiskSolid in their body representation) can't be
parametrically edited — the gizmos offer affordances the geometry
kernel has no path to honour. tool.System.has_parametric_body
inspects the Model/Body/MODEL_VIEW representation and returns True
only when at least one item resolves to one of the two
profile-sweep primitives.

The gate is wired into:
- GizmoMEPActions.is_eligible_object (the action icon group)
- _active_is_flow_segment / _active_is_bend_fitting visibility
  predicates the icon row consults per-icon
- GizmoPipeSegmentEdition / GizmoDuctSegmentEdition is_element_type

tool.Parametric.is_pipe_segment / is_duct_segment stay IFC-class-only
so their truth-table contract test keeps reading a single concern.

Generated with the assistance of an AI coding tool.
2026-06-10 13:00:01 +02:00
Gorgious56 6c9cfccc43 Move _is_multiple_of_pi to tool.Cad
Pure-math parallelism check (value ≡ 0 mod π within VTX_PRECISION)
that lived as a module-private helper in mep.py belongs next to
tool.Cad.is_x — same comparator family, no MEP-specific knowledge.
Other features with rotation-difference checks (wall fillet, roof
slope, railing terminus) now have a sanctioned spelling.

Generated with the assistance of an AI coding tool.
2026-06-10 12:35:21 +02:00
Gorgious56 0a0a5b9f04 Add MEP cache + smoke + cancel-ops forward-compat tests
Four standalone test files pinning contracts the production code
already honours:

- test_mep_actions_cache.py: GizmoMEPActions visibility-predicate
  cache evicts on selection or generation change.
- test_mep_bend_preview_cache.py: bend decorator polyline cache
  re-uses within a generation and rebuilds on generation bump.
- test_mep_distribution_fit_smoke.py: bim.fit_flow_segments
  round-trips a 3-segment polyline without raising.
- test_preview_cancel_ops_forward_compat.py: AST scan ensures every
  preview Enable* operator has a paired Cancel* operator with the
  matching prop reset.

Generated with the assistance of an AI coding tool.
2026-06-10 12:27:23 +02:00
Gorgious56 3a0abbab95 DRY transform-modal draw gate + polyline helper
Two small refactors:

- apply_transform_modal_draw_gate(group, context) replaces the
  three-line _is_transform_modal_active + _hide_all_non_modal_gizmos
  pair that BillboardingGizmoGroupMixin, BaseParametricGizmoGroup
  and BaseSchematicGizmoGroup all repeat in draw_prepare.
- decorator.py renames _stroke_lines_alpha to a public-scope
  draw_polyline_segments and drops the no-longer-private companion
  docstring reference; the function is now usable by sibling
  decorators that draw polyline overlays.

Plus a few one-liner tweaks in tool/model.py and opening.py
following the helper rename.

Generated with the assistance of an AI coding tool.
2026-06-10 12:26:48 +02:00
Gorgious56 0d703039a6 Cache array-child + wall topology by IFC generation
Two hot paths the gizmo polls fire every viewport event memoise
their result against tool.Parametric.get_geom_generation():

- tool.Blender.Modifier.any_selected_array_child caches the
  per-selection scan against the selection identity-set + the
  IFC generation token so a stable selection during a drag
  doesn't re-walk every selected object's BBIM_Array pset every
  frame.
- bim/module/model/wall.py grows a pair-predicate + connection
  cache that the wall topology gizmos hit; both keyed on
  (pair_uids, predicate_kind, generation) so a wall split or
  axis edit invalidates correctly via the generation bump.

Behavioural contract is unchanged — stale entries are evicted
on generation bump; cache miss returns the same value the
un-cached path returned.

Generated with the assistance of an AI coding tool.
2026-06-10 12:25:42 +02:00
Gorgious56 f33df52c1b Centralise model test fixtures via conftest
bim/module/model/conftest.py exposes the autouse _require_real_bpy
skip-guard, four make_* factories (obj / element / context /
ifc_file), and a patched_tool context-manager factory that wires
the half-dozen tool.* boundary patches every gizmo + decorator
test was repeating.

Existing test files in the directory drop their local copies of
_require_real_bpy and adopt the patched_tool / make_* fixtures
where the call site simplifies — test_mep_port_operators.py is
the biggest beneficiary (−89 LOC).

No production behaviour change.

Generated with the assistance of an AI coding tool.
2026-06-10 12:24:44 +02:00
Gorgious56 15a6375ea3 Extract MEP bend preview + refine port operators
Three concerns bundled by file boundary (all in mep.py):

- Extract bend preview operators + GizmoBendPreview into a focused
  mep_bend_preview.py module; preview_base.py grows the shared helper
  set both bend and other previews now consume; classes tuple in
  model/__init__.py updated to register the new module.
- Surface ERROR reports on five silent CANCELLED returns in
  MEPUnjoinAtPort / MEPRemoveTerminalFitting / MEPUnjoinPair so a
  degenerate IFC file ("fitting has no Blender object", "connected
  port leads nowhere") shows up in the popup instead of looking like
  a no-op.
- DRY: _resolve_active_mep_segment + _require_port_state factor the
  segment-id-or-active-object resolve + port-state guard out of every
  port operator's prologue; _wire_anchored_icon_targets pulls the
  GizmoMEPActions setup() body into an exercise-without-MRO helper so
  the wiring-contract tests can hit it without instantiating the
  GizmoGroup.

Drops the now-unused preview_base import that the extraction left
behind.

Generated with the assistance of an AI coding tool.
2026-06-10 12:24:05 +02:00
Gorgious56 82465a64a5 Brighten and dash opening occlusion outline
The opening preview's outline used a single-batch two-pass scheme that
dimmed the occluded back pass via alpha=0.25. The visible front pass also
inherited the source decorator color's modest alpha, so the outline read
as subtle on both sides.

Replace with a CAD hidden-line convention: solid full-alpha front pass on
the visible side, world-space dashed back pass on the occluded side. Both
passes use POLYLINE_UNIFORM_COLOR so depth and line-weight paths match.
The dashed batch is built once per object epoch by a new pure helper
tool.Blender.build_dashed_line_segments (pre-segments edges into world-
space dash chunks), then cached via the existing batch-cache mechanism
under "<uid>_dashed".

The solid front pass is rendered at a slightly wider line width than the
dashed back pass so its halo overpowers Blender's WIRE-display overlay
bias at outline pixels — without the asymmetry the wire's anti-z-fight
forward bias makes the LESS_EQUAL comparison narrowly fail and the
dashed pass wins on visible edges too.

Generated with the assistance of an AI coding tool.
2026-06-09 22:49:00 +02:00
Gorgious56 6bde619fe6 Migrate MEPConnectElements args from object names to IFC GUIDs
MEPConnectElements took obj1_name/obj2_name (Blender object names),
which break when objects are renamed or replicated by array
duplication. Switch to obj1_guid/obj2_guid resolved via
ifc_file.by_guid, with by_guid RuntimeError surfaced as an operator
error rather than a stack trace. DrawPolylineProfile (the sole
in-tree caller) updates to pass GlobalIds.

Generated with the assistance of an AI coding tool.
2026-06-09 22:47:10 +02:00
Gorgious56 ba5321fdfa Add MEP bend tessellation helper tests
Pins the geometry contracts the hand-meshed bend body relies on
while IfcSweptDiskSolid round-trip is broken upstream (#8106):

- profile cross-section sampling: circle returns 16 evenly-spaced
  points starting at (radius, 0); rectangle returns the four
  canonical corners; anything else returns None so the rep swap
  is skipped rather than meshed against the wrong section
- parallel-transport framing keeps the cross-section continuous
  around L-shaped corners — pinned via start / end ring planes
- initial_basis override seeds the first ring with the source
  segment's local +X / +Y axes, fixing the asymmetric-rectangle
  twist the world-Z seed produces

Generated with the assistance of an AI coding tool.
2026-06-09 22:18:22 +02:00
Gorgious56 49ddc97918 Add MEP port operator dispatch tests
Pins which IFC mutation each port operator commits and which
inputs each refuses with CANCELLED:
- MEPUnjoinAtPort removes the fitting + reconnects the two free
  ports; refuses if the named port is free or terminal
- MEPRemoveTerminalFitting deletes the terminal element + leaves
  the segment's port free; refuses on bridged fittings
- SelectMEPPathMembers walks IfcRelConnectsPorts in both
  directions from the active segment and selects every fitting /
  segment reachable through the port graph

Boundary mocks for tool.Ifc, tool.System and MEPGenerator stand
in for the IFC fixture; tests assert against the recorded
ifcopenshell.api.* calls.

Generated with the assistance of an AI coding tool.
2026-06-09 21:46:34 +02:00
Gorgious56 39bcd9db63 Add GizmoMEPActions wiring contract tests
Pins two regressions the live MEP gizmo group can hit:
- per-icon setup() must write `position` (and `mode` on open-lock
  icons) onto every target_set_operator result; the test stands in
  for the AttributeError on bim.mep_add_obstruction that surfaced
  when a field was dropped from the operator declaration
- each visibility_condition lambda must stay total against None /
  non-IFC inputs, since a single raising predicate silently disables
  every sibling icon in the group

Generated with the assistance of an AI coding tool.
2026-06-09 21:45:34 +02:00
Thomas Krijnen ab11ac5338 Catch decomposition errors #8149 2026-06-09 21:35:40 +02:00
Gorgious56 9346f45bba Fix decorator face-tri overlay artifacts
ProfileDecorator.draw_faces (used by the roof path-edit overlay) and
SystemDecorator.draw_faces called bmesh.ops.triangulate on the live
bmesh — both mutated the input and produced ear-clip fans that rendered
as visible streaks across n-gon roof faces at alpha 0.1. The opening
DecorationsHandler edit-mode branch had a separate bug: it computed
triangles from obj.data.calc_loop_triangles() while iterating the
edit-mode bmesh, so any topology added mid-edit desynced the indices.

Centralise the correct draw path on tool.Blender.draw_bmesh_face_tris
(wraps bm.calc_loop_triangles, non-mutating, beauty triangulator) and
route all three call-sites through it. A forward-compat AST guard walks
every *Decorator / DecorationsHandler class under bim/module/ and pins
the no-bmesh.ops.triangulate rule against future regressions.

Generated with the assistance of an AI coding tool.
2026-06-09 20:16:46 +02:00
Gorgious56 b22687891b Warn on shared-rep parametric edits
A user clicking the pen icon on a typed-product occurrence whose body
representation is mapped from its type would silently mutate every
sibling occurrence's geometry. Add a confirmation dialog at the pen-icon
dispatcher (the single chokepoint every feature routes through) showing
the sibling count, with a session-scoped suppress checkbox.

The check is read-only: tool.Model.get_sibling_occurrence_count wraps
tool.Geometry.get_elements_by_representation against the resolved body
rep and subtracts self + type. A forward-compat AST guard pins the
dispatcher monopoly so any future feature that binds pen_gizmo directly
to a feature-specific enable op fails the test before merge.

Generated with the assistance of an AI coding tool.
2026-06-09 17:32:44 +02:00
Gorgious56 784f0b1fe2 Add bend re-edit gizmo
Once a bend was created, the only way to retune start_length /
end_length / radius was to delete and recreate from scratch.
EnableBendPreviewFromBend re-opens the preview on an existing
parametric bend: it walks the bend's ports to resolve the two
connected segments, reads start / end length and radius from the
bend type's BBIM_Fitting pset, and sets editing_bend_id on the
preview props. MEPAddBend then deletes the old bend + its port
connections (single undo step) before the recreate path runs, so
finish replaces the bend in place and cancel discards the edit
without touching the original.

GizmoMEPActions surfaces a pen icon on single bend-fitting
selections via the new _active_is_bend_fitting predicate; the icon
dispatches the new operator. Mirror of the wall fillet re-edit
flow (EnableWallFilletPreviewFromCorner + editing_corner_id in
CreateWallFillet).

Test coverage: registration probe for the new operator, an attached
editing_bend_id field probe on the preview umbrella, and a
parametrized truth-table for the _is_bend_fitting predicate
(IfcFlowFitting with BEND PredefinedType, with other PredefinedType,
with no type, IfcFlowSegment, IfcWall, None).

Generated with the assistance of an AI coding tool.
2026-06-09 17:18:09 +02:00
Gorgious56 e0ceda6856 Hide wall topology gizmos on array children
Wall topology mutations (merge / join / extend-to-wall / unjoin /
fillet) applied to a Bonsai array child are silently overwritten by
the next ``regenerate_array``; merge also orphans a GUID listed in
the parent's ``BBIM_Array.Data``. Add a central
``tool.Blender.Modifier.any_selected_is_array_child`` predicate and
gate the five wall topology gizmo groups plus the six bound operators
behind it. Operator gating is defence in depth against keymap / F3
invocation paths that bypass the gizmo.

The base ``_wall_gizmo_poll_gate`` keeps its loose two-check shape
(viewport gizmos + no preview). A new
``_wall_topology_gizmo_poll_gate`` wraps it with the array-child
filter and is what the topology gizmos use. Host-opening gizmos
deliberately stay on the loose gate: openings authored on a child
are preserved through ``regenerate_array`` and track with the
replicated instance.

A forward-compat AST guard walks wall.py for ``GizmoGroup`` subclasses
and asserts each routes its poll through the tighter gate or the
central predicate, with an allow-list for the parametric-edit and
preview-owner exceptions. New wall topology gizmos inherit the
contract by construction.

Generated with the assistance of an AI coding tool.
2026-06-09 17:16:12 +02:00
Gorgious56 17951427fe Add readonly door swing arc preview
Selecting a Bonsai-parametric IfcDoor now shows the swing arc(s)
without entering edit mode. A new viewport decorator polls on the
active object, reads the door's BBIM_Door pset, and draws the same
arcs the parametric door swing gizmo would draw — matching the
hinge / panel-width / x-mirror contract minus the is_editing gate.

A forward-compat test walks every door operation type and cross-
checks the readonly decorator's arc selection against the gizmo's
swing-arc config table, so future enum additions fail in both
surfaces simultaneously.

Also disables the inherited 8-pass dark halo on GizmoArc: an open
curve has no enclosed silhouette, so the offset passes read as
ghost arcs rather than a uniform outline. The arc's own cross-
section thickness keeps it legible without the halo.

Generated with the assistance of an AI coding tool.
2026-06-09 16:13:59 +02:00
Gorgious56 734f4df84e Add MEP bend preview + bend tessellation fallback
The MEP bend feature's IfcSweptDiskSolid representation produces
geometrically correct output but fails to round-trip through the
OpenCascade geometry kernel (upstream issue #8106) — the body is
dropped on the next file load. Until upstream is fixed, MEPAddBend
captures the bend centerline in world space before the segments are
extended (otherwise the post-extension axes no longer reach the
original intersection and arc reconstruction is wrong), then after
the fitting is placed it hand-meshes the bend body and swaps the
type's swept-disk representation for an IfcTessellatedFaceSet via
tool.Geometry.export_mesh_to_tessellation + tool.Model.
replace_object_ifc_representation.

The centerline includes the straight start_length / end_length legs
in addition to the arc so the bend covers the full segment-to-
segment span. Sweep uses parallel-transport framing — each ring's
(right, up) basis is rotated by the minimum rotation that maps the
previous tangent to the current one, eliminating the twist a fixed
world-axis reference produces when the tangent crosses the
reference. Cross-section orientation seeds from the source segment's
matrix_world local +X / +Y so asymmetric IfcRectangleProfileDef
ducts land with XDim / YDim on the same axes the segment expects;
parallel transport then preserves that alignment around the arc.
Centerline radius is radius + profile_dim[lateral_axis] to match
MEPAddBend's ref_point_radius — without this offset, the bend legs
fall short of the extended segments by profile_dim * tan(angle/2).
Face winding is left to the caller to correct via
bmesh.ops.recalc_face_normals on the closed bend tube.

Two FIXME(#8106) markers (capture site + helper call site) so both
can be dropped once upstream lands a swept-disk round-trip fix.

Generated with the assistance of an AI coding tool.
2026-06-09 16:12:35 +02:00
Ryan Schultz bfa2d789f1 Error on tessellation request in IFC2X3
IfcTriangulatedFaceSet/IfcPolygonalFaceSet were introduced in
Fix #7992: IFC4 and do not exist in IFC2X3. Previously, requesting an
IfcTessellatedFaceSet representation in an IFC2X3 file silently
fell back to a faceted brep after unassigning material sets.
Add a guard in the update_representation operator (user-facing
error) and in the add_representation API (ValueError) so the
unsupported request is caught instead of failing silently.

Generated with the assistance of an AI coding tool.
2026-06-09 07:30:59 -05:00
Gorgious56 192bf00d31 Add cursor-bound perpendicular wall gizmo
GizmoWallEdition gains a fourth cursor-anchored icon that
spawns a perpendicular branch wall from the cursor's
orthogonal projection on the source wall axis. Click forms
a T-junction; shift+click forms an L-corner with the source
wall trimmed at the projection, keeping its longer portion.

The branch inherits the source's spatial container and
centerline baseline so its authored axis matches the source's
alignment rather than the type's default.

Also includes a floor-plane preview quad for the new gizmo,
a floor-Z cross line on the split preview for top-down
visibility, a small bump to QUAD_ALPHA for clearer preview
fills, and a stacking-offset helper that centralises the
cursor-row screen-up step across three call sites.

Generated with the assistance of an AI coding tool.
2026-06-09 13:54:39 +02:00
Gorgious56 ad672d0edb Add GizmoMEPActions + bend precondition + obstruction modes
The MEP one-shot operators (join, unjoin variants, terminal removal,
path-select, obstruction add/remove) had no viewport surface. This
commit adds GizmoMEPActions — the icon-action gizmo group that
surfaces them as billboarded icons around selected MEP elements.
Three anchor regions: a horizontal row above the bbox top
(selection-cardinality icons), per-port endpoints for the three-state
lock / unjoin icons (open lock for PORT_FREE, closed for
PORT_TERMINAL, unjoin for PORT_JOINED — resolved per-frame from
port_connection_state), and the predicted join location
(compute_mep_join_location, shared with the bend preview) for the
join / unjoin_pair pair. Unjoin icons render at full
DEFAULT_BILLBOARD_SCALE with warning-red hover; endpoint lock icons
shrink so the lock row stays subordinate to the row icons. The
group hides itself entirely while a bend preview is active.

MEPAddObstruction grew a position enum (CURSOR / START / END) and a
mode enum (ADD / REMOVE / TOGGLE) so the gizmo can target a specific
port without touching the cursor and dispatch ADD or REMOVE based on
the click target — the lock_open icons drive ADD with position
pinned, the lock_closed icons drive bim.mep_remove_terminal_fitting.
Without the new fields the gizmo wiring (op_props.position = ...)
crashed at setup() with AttributeError on the obstruction operator.

validate_bend_preconditions extracts the type-match and profile-kind
checks MEPAddBend enforces so EnableBendPreview surfaces the
rejection immediately — the user no longer tunes a preview only to
learn at commit time that the segments use an unsupported profile
(e.g. IfcArbitraryClosedProfileDef).

Generated with the assistance of an AI coding tool.
2026-06-09 12:44:19 +02:00
Gorgious56 0df1f0cf49 Add MEP unjoin / terminal-remove / path-select operators
Four discrete one-shot operators driven by the MEP segment's port
state. mep_unjoin_at_port deletes the IfcFlowFitting bridging a
segment's named port to a second element when the port is in the
JOINED state. mep_remove_terminal_fitting deletes the terminal
fitting at a port (closed-lock state) and dispatches by fitting
type — OBSTRUCTION fittings go through MEPGenerator.remove_obstruction
so the segment absorbs the freed length, other terminal fittings go
through the standard delete path. mep_unjoin_pair finds the single
fitting bridging two selected MEP segments and deletes it.
select_mep_path_members walks the connected MEP network from the
active element via IfcRelConnectsPorts and replaces the selection
with every reachable member. Foundation for the MEP Actions gizmo
group which surfaces these operators as icon affordances around
selected segments.

Generated with the assistance of an AI coding tool.
2026-06-09 12:16:15 +02:00
Gorgious56 d7dd8ecf57 Align extend gizmo arrow with segment axis
The extend icon used a pure screen-space billboard that always
pointed +X across the screen — the arrow ran horizontally
regardless of the pipe / duct's orientation. The new
billboarded_along_axis helper rotates the gizmo about the camera-
forward axis so its local +X aligns with the segment's local +Z
projected onto the screen, keeping the icon camera-facing but
visually following the extrusion direction. The flip-mirror branch
now reads from cursor-vs-current-end along the segment axis (not
screen-X), so the arrow points away from the current endpoint
regardless of viewport orientation. The split icon stacks
perpendicular to the rotated extend arrow in screen space so the
two don't overlap.

The decorator's green preview line no longer clamps the cursor
projection to min_projected_length — it follows the raw projection
so the line stays visible when the cursor crosses behind the
segment origin (the user still sees where they're pointing even
though the operator floors the actual commit).

Generated with the assistance of an AI coding tool.
2026-06-09 11:51:37 +02:00
Gorgious56 becbcfdfe7 Add MEP bend preview decorator + join dispatcher
The bend preview gizmo group (commit 2) populated a Scene draft but
the user saw nothing in the viewport until they hit finish — they
had to commit blindly. This commit ports the BendPreviewDecorator
(centerline arc + two leg projections on valid geometry, warning-red
axes on invalid in-segment intersections) and the interactive
GizmoBendPreview group (three dimension widgets for start_length /
end_length / radius plus validate / cancel icons). The bend axis
math lives in a pure compute_bend_preview_polylines helper, fed
into both the gizmo group's per-frame positioning and the GPU
decorator's draw path. MEPSegmentExtendPreviewDecorator lands at
the same time because it shares the decorator install / uninstall
plumbing — renders the extend-to-cursor preview line for the
GizmoPipeSegmentEdition / GizmoDuctSegmentEdition extend icons
when hovered, clamping the projected endpoint to the operator's
minimum so the preview matches where the commit lands. The
MEPJoinSegments dispatcher routes two selected MEP segments to
mep_add_transition (parallel) or enable_bend_preview (non-parallel)
— the F3 search entry point that makes the bend preview testable
before the gizmo-icon dispatch lands.

11 new tests in test_mep_bend_preview.py cover the geometry helper
truth table (parallel rejection, right-angle happy path, near-
collinear rejection, in-segment invalid_axes), the
_intersection_past_near parametrized boundary, registration probes
for the lifecycle operators / join dispatcher / gizmo group /
decorator, and the FinishBendPreview RuntimeError catch contract.
6 extend-preview-line tests (deferred from commit 3) join the
existing 35 in test_mep_segment_edition.py.

Generated with the assistance of an AI coding tool.
2026-06-08 21:18:18 +02:00
Gorgious56 5b79cefee2 Fix #8138: door/window container assignment no-op
Spatial.get_root_element walks aggregate / nest / filled-void /
voided-element chains and core.assign_container assigns the container
to whatever the walk returns. For an IfcDoor the filled-void hop
redirects to the IfcOpeningElement, then voided-element to the host
wall, so a user who selects a door and runs bim.assign_container ends
up targeting the wall — and silently no-ops on the door if the wall is
already in the target storey.

Per IFC4 / IFC4.3 (IfcDoor, IfcWindow): the spatial containment of a
filling is defined independently of the filling relationship. Major
exporters (Revit, ArchiCAD, Tekla, Allplan) emit independent
ContainedInStructure on doors / windows accordingly. Drop the
filled-void / voided-element hops from the walk; aggregate and nest
remain — those are true sub-part relationships where the parent
legitimately owns the container.

New TestGetRootElement in test/tool pins the new contract (filling
resolves to itself) plus the retained aggregate / nest / loose-element
paths so a future PR that re-adds either hop is caught. Two new
TestAssignContainer cases in test/core pin filling-to-self through the
core layer and per-element can_contain filtering.

Generated with the assistance of an AI coding tool.
2026-06-08 18:53:50 +02:00
Gorgious56 3346a59284 Add MEP pipe / duct segment edit gizmos
Pipe and duct segments had no parametric-edit affordance — the only
length edit path was a property panel value with no live preview.
This commit ports the per-segment parametric edit triad
(enable / finish / cancel) plus a cursor-anchored extend operator
and a cursor-projected split operator into one gizmo group per
segment type. The two PropertyGroups (BIMPipeSegmentProperties,
BIMDuctSegmentProperties) host the draft length plus snap fields
so cancel / no-op-finish restore the segment to its exact pre-edit
visual state including a non-identity pre-edit scale. Length
commits are written through DumbProfileJoiner.set_depth and
auto-dispatch bim.regenerate_distribution_element so adjacent
fittings track the port move. The split operator preserves
downstream port connectivity and runs through tool.Ifc.run for
single-step undo. The two segment types are now first-class
entries in tool.Parametric.EDIT_TYPES, which resolves the FIXME
on auto-commit-on-save dispatch.

35 unit tests cover predicate truth tables, segment_world_length
geometry, preview-via-scale / restore-scale helpers, gizmo class
wiring, lifecycle operator registration, dimension matrix_position
rotation respect, and lifecycle drift-handling. The 6 extend-
preview-line decorator tests stay deferred until the bend preview
decorator commit lands MEPSegmentExtendPreviewDecorator.

Generated with the assistance of an AI coding tool.
2026-06-08 15:01:03 +02:00
Gorgious56 0bf8e9283f Hide parametric gizmos during transform modal
Parametric gizmos (wall/door/window/stair/roof/array/MEP) recompute
matrix_basis every frame from obj.matrix_world. While Blender's
transform modal (G/R/S and the Bonsai macro overrides) drags the
matrix, the gizmos slide off-cursor and fight the transform overlay.

Detect via context.window.modal_operators (Blender 4.2+) — the
collection of running modal operators. Gate poll() (forward-compat)
and draw_prepare() (production path: gizmo.hide=True preserves the
GizmoGroup across the drag instead of destroying it). Cover the
Bonsai macro override for G key (and Shift/Alt/Ctrl+Shift+D) by
matching the BIM_OT_* macro idnames that surface in modal_operators.

Forward-compat test walks every parametric-edit module for GizmoGroup
subclasses and asserts poll returns False with the detector mocked,
so new gizmo groups inherit the hide automatically.

Generated with the assistance of an AI coding tool.
2026-06-08 13:33:08 +02:00
Gorgious56 db7591a867 Add clear_preview_state helper + DRY preview cleanup
Every preview operator (commit + cancel for both bend and wall
fillet) was inlining the same 3-4 line cleanup: set is_active to
False, zero every *_id IntProperty. The new clear_preview_state
helper in preview_base.py introspects bl_rna and applies that
contract generically — adopters become a single call. Two new tests
pin the contract: every *_id IntProperty zeroes, non-id fields stay.

Generated with the assistance of an AI coding tool.
2026-06-08 10:25:59 +02:00
Gorgious56 7b9af9f533 Backport pending-opening-cuts banner from gh8088
Extract the pending_opening_recut tracking, three operators (apply /
dismiss / select), Project-panel banner, and the sibling
multi-instance warning banner (its backend helpers already landed
on this branch) from commit a85ed6032 on gizmos-8088.

All tool.* dependencies (Geometry.reimport_element_representations,
Blender.set_objects_selection, Array.*) and IfcImporter.gross_elements
are already on this branch -- no other diffs from a85ed6032 are
pulled.

The source's narrow except-tuple paraphrase comments are trimmed
to keep only the durable "don't swallow programmer errors" note,
per CLAUDE.md s4a.

Tests: 5 bim-lane tests in test/bim/module/project/
test_pending_opening_cuts.py covering apply happy-path + missing
entity, dismiss, select happy-path + cancellation.

Generated with the assistance of an AI coding tool.
2026-06-08 10:18:37 +02:00
Gorgious56 704a2d36be Add MEP bend preview Scene properties + lifecycle
MEPAddBend exists on the main flow but commits bend geometry with
hardcoded defaults (start_length=0.1, end_length=0.1, radius=0.2)
with no opportunity to tune before commit. The new scene-level
BIMBendPreviewProperties hosts a draft (start_segment_id,
end_segment_id, start_length, end_length, radius); EnableBendPreview
populates it from the two selected MEP segments after asserting they
are non-parallel, FinishBendPreview dispatches MEPAddBend with the
tuned values and clears the draft, CancelBendPreview discards it.
Scene-level placement follows CLAUDE.md 2.9: a bend creates a new
fitting entity between two segments, so neither segment alone owns
the draft. Foundation for the upcoming bend preview gizmo group and
decorator.

Generated with the assistance of an AI coding tool.
2026-06-08 10:17:30 +02:00
Gorgious56 516696cd73 Add partial-state rollback on execute_ifc_operator
When an operator mutated IFC then raised mid-execute the user was left
staring at a raw traceback with the IFC graph captured by the active
transaction but the Blender side stale. Blender does not push an undo
step for a raised operator (the same gap that the CANCELLED-modal arm
patches via bpy.ops.ed.undo_push), so the WARNING the framework can
emit is only honest if it pushes that undo step too. The framework
now detects partial state via ifc_file.transaction.operations,
pushes a Recover undo step, then reports a WARNING naming Ctrl+Z so
the recovery path is discoverable. The bespoke try/except wrapper in
UnjoinWallPathConnection becomes redundant and is retired in the
same change.

Generated with the assistance of an AI coding tool.
2026-06-08 08:45:05 +02: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
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
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
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
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
266 changed files with 17026 additions and 2747 deletions
+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.
-16
View File
@@ -29,18 +29,6 @@ from bpy_extras.io_utils import ExportHelper, ImportHelper
from . import handler, operator, parametric_lifecycle, prop, ui
def _parametric_gizmo_preference_classes() -> list[type]:
"""Resolves the registry-driven ``GizmoPreferences<X>`` classes for the
``classes`` list below. ``import bonsai.tool`` is kept local to surface
the load-order constraint: it relies on ``from . import handler, …``
above having primed the
``tool/ifc.py → bim/ifc.py → bim/handler.py → bonsai.tool`` cycle."""
import bonsai.tool as tool
return tool.Parametric.iter_gizmo_preference_classes(ui)
try:
from bonsai.translations import translations_dict
except ImportError:
@@ -171,10 +159,6 @@ classes = [
ui.BIM_UL_tab_visibilities,
ui.BIM_UL_panel_visibilities,
ui.DocPreferences,
# Per-parametric-type ``GizmoPreferences<Name>`` classes — must register
# before ``ui.GizmoPreferences`` which holds the matching PointerProperty
# fields. Driven by ``tool.Parametric.EDIT_TYPES``.
*_parametric_gizmo_preference_classes(),
ui.GizmoPreferences,
# ui.DefaultParameters and ui.BIM_ADDON_preferences are registered separately after modules (see late_classes below)
# Tabs panel
+86 -17
View File
@@ -41,14 +41,21 @@ from bonsai.bim.decorator_cache import (
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 (
BendPreviewDecorator,
BoundingBoxDecorator,
DoorSwingReadonlyDecorator,
MEPSegmentExtendPreviewDecorator,
SlabDirectionDecorator,
WallAxisDecorator,
WallFilletPreviewDecorator,
)
from bonsai.bim.module.model.preview_base import discard_pending_previews
from bonsai.bim.module.model.wall import WallGizmoPreviewDecorator
from bonsai.bim.module.nest.decorator import NestDecorator
cwd = os.path.dirname(os.path.realpath(__file__))
@@ -116,19 +123,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()
@@ -178,6 +179,48 @@ def update_bim_tool_props():
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
@@ -195,6 +238,7 @@ 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"]
@@ -393,8 +437,8 @@ def subscribe_to_viewport_shading_changes():
def _apply_save_file_invariants(scene: bpy.types.Scene) -> None:
"""Invariants enforced on every load_post: msgbus subscription, IFC owner
settings, scene-bound caches, draft-flag healing, multi-instance lock probe,
and previews discarded so saved preview state never resurfaces on reopen."""
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(
@@ -405,8 +449,7 @@ def _apply_save_file_invariants(scene: bpy.types.Scene) -> None:
ifcopenshell.api.owner.settings.get_application = get_application
AuthoringData.type_thumbnails = {}
tool.Parametric.heal_stale_edit_flags()
discard_pending_previews(scene)
tool.Parametric.on_load_post(scene)
if tool.Ifc.get() and bpy.data.is_saved:
props = tool.Blender.get_bim_props()
@@ -471,6 +514,12 @@ def _install_viewport_overlays() -> None:
WallAxisDecorator.uninstall()
SlabDirectionDecorator.uninstall()
WallFilletPreviewDecorator.uninstall()
BendPreviewDecorator.uninstall()
MEPSegmentExtendPreviewDecorator.uninstall()
WallGizmoPreviewDecorator.uninstall()
DoorSwingReadonlyDecorator.uninstall()
ArrayPreviewDecorator.uninstall()
ArraySelectionHighlightDecorator.uninstall()
uninstall_decorator_cache_handlers()
try:
if georeference_props.should_visualise:
@@ -489,6 +538,26 @@ def _install_viewport_overlays() -> None:
# 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 siblings of WallFilletPreviewDecorator: each
# self-polls on its own scene.BIMPreviewProperties subgroup or on
# selection + hover gizmo state — zero cost when nothing is active.
BendPreviewDecorator.install(bpy.context)
MEPSegmentExtendPreviewDecorator.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 active object + IfcDoor +
# parametric pset, so the cost is one bpy/IFC lookup per redraw when
# nothing eligible is selected.
DoorSwingReadonlyDecorator.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()
+14 -1
View File
@@ -552,7 +552,7 @@ class IfcStore:
BrickStore.end_transaction()
IfcStore.end_transaction(operator)
bonsai.bim.handler.refresh_ui_data()
tool.Parametric.refresh_post_commit()
tool.Parametric.refresh_post_commit(operator)
if method == "MODAL":
cls.modal_in_progress = False
@@ -566,6 +566,19 @@ class IfcStore:
result = getattr(operator, "_modal")(context, event)
except:
bonsai.last_error = traceback.format_exc()
# An operator that mutated IFC then raised leaves the IFC graph captured
# by the transaction but the Blender side stale. Blender does not push an
# undo step for a raised operator (mirror of the CANCELLED-modal gap
# handled below), so we push one here so Ctrl+Z actually rewinds the
# partial mutation, then surface the recovery path to the user.
ifc_file = tool.Ifc.get()
if ifc_file and ifc_file.transaction and ifc_file.transaction.operations:
bpy.ops.ed.undo_push(message=f"Recover {operator.bl_idname}")
operator.report(
{"WARNING"},
"Operation partially completed (IFC changed, Blender state may be stale). "
"Press Ctrl+Z to restore the previous state.",
)
# Try to ensure undo will work since Blender undo does work in case of errors.
# As error come unexpectedly, it's important that user might have a chance to save the file
# before they got the error and not to lose the work they've done.
+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
+26 -5
View File
@@ -345,9 +345,17 @@ class CadArcFrom3Points(bpy.types.Operator):
class CadOffset(bpy.types.Operator):
bl_idname = "bim.cad_offset"
bl_label = "CAD Offset"
bl_description = "Copy selected mesh geometry at provided offset. Mesh copied based on the current viewport angle."
bl_description = (
"Offset selected mesh geometry at provided distance, based on the current viewport angle. "
"Creates a copy by default, or moves the existing edges if Copy is disabled."
)
bl_options = {"REGISTER", "UNDO"}
distance: bpy.props.FloatProperty(name="Distance", default=0.1, subtype="DISTANCE")
copy: bpy.props.BoolProperty(
name="Copy",
description="Create a new offset copy of the geometry. If disabled, move the existing edges to the offset location",
default=True,
)
@classmethod
def poll(cls, context):
@@ -405,6 +413,11 @@ class CadOffset(bpy.types.Operator):
rotation = Matrix.Rotation(pi / 2, 2, "Z")
rotation_i = Matrix.Rotation(-pi / 2, 2, "Z")
# When not copying, the offset positions are gathered here and applied to
# the existing verts only after all loops are processed, so that the
# original coordinates are still available while computing offsets.
moved_verts = []
# Create loops from edges
loop_edges = set(edges)
loops = []
@@ -517,12 +530,15 @@ class CadOffset(bpy.types.Operator):
offset_length = self.distance / sqrt((1 + normals[0].dot(normals[1])) / 2)
offset = mw.inverted().to_quaternion() @ (wp.to_quaternion() @ (new_normal * offset_length).to_3d())
new_vert = v1.co + offset
new_verts.append(bm.verts.new(new_vert))
else:
normal = (normals[0] * self.distance).to_3d()
offset = mw.inverted().to_quaternion() @ (wp.to_quaternion() @ normal)
new_vert = v1.co + offset
if self.copy:
new_verts.append(bm.verts.new(new_vert))
else:
moved_verts.append((v1, new_vert))
processed_verts.add(v1.index)
@@ -531,9 +547,14 @@ class CadOffset(bpy.types.Operator):
v1 = v2
[bm.edges.new((new_verts[i], new_verts[i + 1])) for i in range(len(new_verts) - 1)]
if is_closed:
bm.edges.new((new_verts[len(new_verts) - 1], new_verts[0]))
if self.copy:
[bm.edges.new((new_verts[i], new_verts[i + 1])) for i in range(len(new_verts) - 1)]
if is_closed:
bm.edges.new((new_verts[len(new_verts) - 1], new_verts[0]))
# Move the existing edges to the offset location.
for vert, new_co in moved_verts:
vert.co = new_co
bm.verts.index_update()
bm.edges.index_update()
+6
View File
@@ -27,6 +27,11 @@ class BIMCadProperties(PropertyGroup):
resolution: bpy.props.IntProperty(name="Arc Resolution", min=1, default=1)
radius: bpy.props.FloatProperty(name="Radius", default=0.1, subtype="DISTANCE")
distance: bpy.props.FloatProperty(name="Distance", default=0.1, subtype="DISTANCE")
copy: bpy.props.BoolProperty(
name="Copy",
description="Create a new offset copy of the geometry. If disabled, move the existing edges to the offset location",
default=True,
)
x: bpy.props.FloatProperty(name="X", default=0.2, subtype="DISTANCE")
y: bpy.props.FloatProperty(name="Y", default=0.1, subtype="DISTANCE")
gable_roof_edge_angle: bpy.props.FloatProperty(
@@ -37,6 +42,7 @@ class BIMCadProperties(PropertyGroup):
resolution: int
radius: float
distance: float
copy: bool
x: float
y: float
gable_roof_edge_angle: float
@@ -256,6 +256,8 @@ class CadHotkey(bpy.types.Operator):
elif self.hotkey == "S_O":
row = self.layout.row()
row.prop(props, "distance")
row = self.layout.row()
row.prop(props, "copy")
elif self.hotkey == "S_R":
if tool.Geometry.is_profile_object_active():
@@ -291,7 +293,7 @@ class CadHotkey(bpy.types.Operator):
bpy.ops.bim.cad_fillet(resolution=self.props.resolution, radius=self.props.radius)
def hotkey_S_O(self):
bpy.ops.bim.cad_offset(distance=self.props.distance)
bpy.ops.bim.cad_offset(distance=self.props.distance, copy=self.props.copy)
def hotkey_S_Q(self):
obj = bpy.context.active_object
@@ -141,6 +141,7 @@ classes = (
gizmos.GizmoLockOpen,
gizmos.GizmoLockClosed,
gizmos.GizmoArc,
gizmos.GizmoLinkToggle,
gizmos.GizmoFillet,
gizmos.GizmoWallCornerIcon,
gizmos.GizmoWallTeeIcon,
@@ -153,6 +154,7 @@ classes = (
gizmos.GizmoArrayParent,
gizmos.GizmoArrayAll,
gizmos.GizmoArrayLayerIndicator,
gizmos.GizmoCountLabel,
gizmos.GizmoMerge,
gizmos.GizmoSplit,
gizmos.GizmoUnjoin,
+653 -91
View File
@@ -35,6 +35,7 @@ __all__ = [ # noqa: RUF022 (unsorted `__all__`)
"CoordinateSpace",
"ModalState",
"DimensionGizmoConfig",
"SwingArcConfig",
"ViewDirection",
"GizmoModalContext",
"get_modal_context",
@@ -81,7 +82,7 @@ import math
from collections.abc import Callable, Iterator
from dataclasses import dataclass
from enum import Enum
from typing import Any, ClassVar, Literal, Protocol, runtime_checkable
from typing import Any, ClassVar, Literal, Optional, Protocol, runtime_checkable
import blf
import bpy
@@ -104,16 +105,6 @@ from mathutils.kdtree import KDTree
import bonsai.tool as tool
from bonsai.bim.module.drawing.shaders import ExtrusionGuidesShader
# Backward-compat re-exports — these mixins moved to bim.parametric_lifecycle
# in the gizmos.py framework refactor. PR4 callers (CycleDoorType / CycleWindowType
# / CycleStairType) still spell gizmo.CycleTypeMixin; the re-export keeps the
# old access path alive until PR4 rewrites the import. PR5 cleanup drops these.
from bonsai.bim.parametric_lifecycle import ( # noqa: F401, E402
CycleTypeMixin,
PickTypeMixin,
TypeAccessorBase,
)
SNAP_POINT_SIZE = 10.0
SNAP_POINT_COLOR = (1.0, 0.5, 0.0, 1.0)
SNAP_MAX_RADIUS = 50.0
@@ -143,6 +134,16 @@ DOOR_SWING_ANGLE_MAX = 90.0
# Default scale factor for billboarded icons (Blender-unit visual size).
DEFAULT_BILLBOARD_SCALE = 0.5
# Shared gizmo color constants. Re-exported as class attributes on
# BaseParametricGizmoGroup so callers can use either ``self.COLOR_GREEN``
# from inside a gizmo group or the module-level constant from a class body
# (e.g. IconSlot declarations) without a forward-reference issue. Match
# Blender's axis convention: X=red, Y=green, Z=blue.
COLOR_RED = (1.0, 0.2, 0.2)
COLOR_GREEN = (0.1, 0.8, 0.1)
COLOR_BLUE = (0.3, 0.3, 1.0)
COLOR_NEUTRAL = (1.0, 1.0, 1.0)
PRECISION_MODE_MULTIPLIER = 0.1
RAY_CAST_DISTANCE = 1000
@@ -158,6 +159,65 @@ _SPECIAL = {"=", " "} # Formula prefix, spaces
NUMERIC_INPUT_CHARS = _DIGITS | _OPERATORS | _METRIC_UNITS | _IMPERIAL_UNITS | _SPECIAL
_BONSAI_TRANSFORM_MACROS = frozenset(
{
# Bonsai overrides Blender's default move/duplicate keymaps with
# macros that wrap TRANSFORM_OT_translate. While a macro is the outer
# modal entry, the inner TRANSFORM_OT_translate does not surface in
# window.modal_operators — the macro's own idname does. The
# ``BIM_OT_`` prefix is what Blender returns from ``bl_idname`` at
# runtime (the class declaration uses the dotted ``bim.`` form).
"BIM_OT_override_move_macro", # G key
"BIM_OT_override_object_duplicate_move_macro", # Shift+D
"BIM_OT_override_object_duplicate_move_linked_macro", # Alt+D
"BIM_OT_object_duplicate_move_linked_aggregate_macro", # Ctrl+Shift+D
}
)
def _is_transform_modal_active(context) -> bool:
"""True iff a Blender transform modal (G/R/S and siblings, including
Bonsai's macro overrides) is currently driving per-frame ``matrix_world``
updates. Reads ``window.modal_operators`` the Blender 4.2+ collection of
running modal operators. Parametric gizmo groups gate poll + draw_prepare
on this so they hide for the duration of the drag instead of sliding
off-cursor as the matrix updates each frame."""
window = getattr(context, "window", None)
if window is None:
return False
modal_ops = getattr(window, "modal_operators", None)
if not modal_ops:
return False
for op in modal_ops:
idname = op.bl_idname
if idname.startswith("TRANSFORM_OT_") or idname in _BONSAI_TRANSFORM_MACROS:
return True
return False
def _hide_all_non_modal_gizmos(group) -> None:
"""Set ``hide = True`` on every gizmo in ``group`` whose own ``is_modal``
is False. Used by parametric ``draw_prepare`` to suppress visible
re-positioning while a transform modal is dragging ``matrix_world``."""
for gz in group.gizmos:
if not getattr(gz, "is_modal", False):
gz.hide = True
def apply_transform_modal_draw_gate(group, context) -> bool:
"""Combined gate for ``draw_prepare`` overrides: hide non-modal gizmos and
return ``True`` when a Blender transform modal is dragging matrix_world.
Returns ``False`` when no transform modal is active so callers can fall
through to their normal positioning logic. ``True`` means the caller must
early-return without touching matrix_basis the hidden gizmos will be
re-shown on the next idle frame once the modal exits."""
if not _is_transform_modal_active(context):
return False
_hide_all_non_modal_gizmos(group)
return True
class GizmoColor(Enum):
"""Color identifiers for dimension gizmos.
@@ -1291,6 +1351,38 @@ class IconActionConfig:
visibility_condition: Callable[[Any], bool] | None = None
@dataclass(slots=True)
class SwingArcConfig:
"""Declarative config for one swing-arc panel — a pair of ``GizmoArc``
instances representing a single hinged panel's two possible open sides.
Each entry produces two gizmos at setup time:
- ``self.gizmo_swing_arc_<name>``: main arc on the active swing side
- ``self.gizmo_swing_arc_<name>_flip``: Y-mirror of the main, on the
opposite side of the hinge line
Both gizmos hide together when ``visibility_condition(props)`` is False.
When visible, each arc's ``matrix_basis`` is:
Translation(hinge_x(props), hinge_y(props), 0)
@ Scale(panel_width(props), 4)
@ (Scale(-1, X) if x_mirror(props) else Identity)
@ (Scale(-1, Y) if this is the flip arc else Identity)
The arc geometry (``GizmoArc.tris``) is a unit quarter-arc sweeping
counterclockwise from +X to +Y with its hinge at the origin, so the
transforms above translate the hinge into world position, scale to
panel size, and mirror across the hinge line as needed.
"""
name: str
visibility_condition: Callable[[Any], bool]
hinge_x: Callable[[Any], float]
hinge_y: Callable[[Any], float]
panel_width: Callable[[Any], float]
x_mirror: Callable[[Any], bool]
class SnapManager:
"""Manages snap point visualization and mesh snapping with caching."""
@@ -1458,24 +1550,11 @@ class SnapManager:
nearby_objects = []
for obj in mesh_objects:
bbox_corners = [obj.matrix_world @ Vector(corner) for corner in obj.bound_box]
if not bbox_corners:
if not obj.bound_box:
continue
bbox_min = Vector(
(
min(c.x for c in bbox_corners),
min(c.y for c in bbox_corners),
min(c.z for c in bbox_corners),
)
)
bbox_max = Vector(
(
max(c.x for c in bbox_corners),
max(c.y for c in bbox_corners),
max(c.z for c in bbox_corners),
)
)
bbox = tool.Blender.get_object_world_bounding_box(obj)
bbox_min = bbox["min_point"]
bbox_max = bbox["max_point"]
closest = Vector(
(
@@ -1674,6 +1753,33 @@ def billboarded_at(world_pos: Vector, billboard_rot: Matrix, scale: float = DEFA
return Matrix.Translation(world_pos) @ billboard_rot @ Matrix.Scale(scale, 4)
def billboarded_along_axis(
world_pos: Vector,
billboard_rot: Matrix,
axis_world: Vector,
scale: float = DEFAULT_BILLBOARD_SCALE,
) -> Matrix:
"""Composed matrix_basis like ``billboarded_at`` but with local +X
rotated about the camera-forward axis to align with ``axis_world``
projected onto the screen plane.
The gizmo still faces the camera (local +Z stays along camera-forward),
only its in-plane orientation changes. Falls back to plain
``billboarded_at`` when the axis is near-parallel to the view direction
(no usable screen projection)."""
camera_forward = billboard_rot @ Vector((0.0, 0.0, 1.0))
projected = axis_world - camera_forward * axis_world.dot(camera_forward)
if projected.length < 1e-4:
return billboarded_at(world_pos, billboard_rot, scale)
projected.normalize()
y_axis = camera_forward.cross(projected).normalized()
rot = Matrix.Identity(4)
rot[0][:3] = (projected.x, y_axis.x, camera_forward.x)
rot[1][:3] = (projected.y, y_axis.y, camera_forward.y)
rot[2][:3] = (projected.z, y_axis.z, camera_forward.z)
return Matrix.Translation(world_pos) @ rot @ Matrix.Scale(scale, 4)
def get_screen_up(billboard_rot: Matrix) -> Vector:
"""Camera's screen-up direction in world space — local +Y of the billboard
rotation. Use to lift a gizmo above an anchor in a way that stays
@@ -1682,6 +1788,33 @@ def get_screen_up(billboard_rot: Matrix) -> Vector:
return billboard_rot @ Vector((0.0, 1.0, 0.0))
# Screen-up distance lifted off floor-plane gizmo anchors in plan view. Matches
# the inter-icon stack spacing used by wall-corner stacks so single icons and
# stack bases sit at consistent screen-up positions when multiple groups render
# around the same wall endpoint.
DEFAULT_TOP_DOWN_CLEARANCE = 0.4
def top_down_clearance(
context: bpy.types.Context,
billboard_rot: Matrix,
distance: float = DEFAULT_TOP_DOWN_CLEARANCE,
) -> Vector:
"""Screen-up offset that keeps a floor-plane gizmo anchor visible in plan view.
In a top-down view the world-Z axis projects to ~zero on screen, so any
icon anchored on the floor (wall endpoints, corners, connection points,
the projected 3D cursor) sits directly on the click target it represents.
Adding this offset before ``billboarded_at`` shifts the icon along the
camera's up axis without changing the operator's world-space target.
Returns a zero vector outside the top-down cone so callers can apply it
unconditionally."""
if not tool.Blender.is_view_top_down(context):
return Vector((0.0, 0.0, 0.0))
return get_screen_up(billboard_rot) * distance
# Dead-band on the screen-X delta — prevents flicker when the gizmo sits on the
# element origin.
EXTEND_FLIP_EPSILON = 1e-4
@@ -3208,11 +3341,124 @@ class GizmoArc(StaticTrisGizmoMixin, bpy.types.Gizmo):
"""Static quarter-arc glyph for swing visualisation.
Consumers needing the mirrored (RIGHT) visual apply a flip-X matrix to
``matrix_basis``."""
``matrix_basis``. ``outline_alpha = 0.0`` suppresses the inherited 8-pass
dark halo: an open curve has no enclosed silhouette for the dilation to
ring, so the offset passes read as ghost arcs rather than a uniform
outline. The arc's own cross-section thickness keeps it legible without
the halo."""
bl_idname = "VIEW3D_GT_arc"
__slots__ = ("custom_shape",)
tris = ARC_TRIS_DEFAULT
outline_alpha = 0.0
def _link_toggle_icon_tris(broken: bool) -> tuple[tuple[float, float, float], ...]:
"""Two filled dots joined by a horizontal connector. ``broken=False``
draws a single continuous bar between the dots' inner edges;
``broken=True`` shears the two halves vertically the left dot AND
its stub slip down as a unit, the right dot AND its stub slip up,
with a horizontal gap at the centre.
Each half moves as a cohesive piece so the stub stays attached to its
dot at the same y, reading as a snapped link whose two halves slid
apart rather than as bent stubs jutting out of stationary dots."""
dot_cx = 0.30
dot_r = 0.10
bar_half_thickness = 0.04
# Inner edge of each dot — the intact bar joins the dot edges, not the
# centres, so the dot + bar reads as one continuous shape.
bar_inner_x = dot_cx - dot_r
segments = 12
# Vertical shear applied to each half when broken. Zero in the intact
# form keeps both halves on the centerline.
half_offset_y = 0.08 if broken else 0.0
tris: list[tuple[float, float, float]] = []
for sign in (-1, 1):
cx = sign * dot_cx
cy = sign * half_offset_y
for i in range(segments):
a1 = (2.0 * math.pi) * (i / segments)
a2 = (2.0 * math.pi) * ((i + 1) / segments)
p1 = (cx + dot_r * math.cos(a1), cy + dot_r * math.sin(a1))
p2 = (cx + dot_r * math.cos(a2), cy + dot_r * math.sin(a2))
tris.append((cx, cy, 0.0))
tris.append((p1[0], p1[1], 0.0))
tris.append((p2[0], p2[1], 0.0))
if broken:
# Stubs reach inward into the dot's interior so they read as rooted
# in the dot rather than floating off its edge after the slip.
stub_outer_x = 0.27
stub_inner_x = 0.06
tris.extend(
rect_tris(
-stub_outer_x,
-half_offset_y - bar_half_thickness,
-stub_inner_x,
-half_offset_y + bar_half_thickness,
)
)
tris.extend(
rect_tris(
stub_inner_x,
half_offset_y - bar_half_thickness,
stub_outer_x,
half_offset_y + bar_half_thickness,
)
)
else:
tris.extend(rect_tris(-bar_inner_x, -bar_half_thickness, bar_inner_x, bar_half_thickness))
return tuple(tris)
LINK_TRIS_INTACT = _link_toggle_icon_tris(broken=False)
LINK_TRIS_BROKEN = _link_toggle_icon_tris(broken=True)
class GizmoLinkToggle(StaticTrisGizmoMixin, bpy.types.Gizmo):
"""Two-state link glyph: default reads as a connected link (two dots +
intact connector); hover swaps to a broken link (same dots + severed
connector) to signal that a click will sever the underlying connection.
Single-click gizmo the target operator is bound via
``target_set_operator`` by the owning group. The hover swap is purely
visual; the click target is the same in both states. The glyph is
feature-agnostic any path / link / pair-of-connected-items context can
reuse it for a sever-this-connection affordance."""
bl_idname = "VIEW3D_GT_link_toggle"
__slots__ = ("custom_shape",)
# Bbox source for the hit shape. The broken form's vertically-sheared
# halves give it the larger bbox of the two states, so using it as the
# hit-shape source guarantees the clickable area covers either form —
# the cursor doesn't lose hover at the offset dots' outer edges.
tris = LINK_TRIS_BROKEN
# Per-class batch cache: one entry per highlight state. The mixin parent
# caches one batch per class via ``_get_static_tris_batch``; the per-state
# swap needs a second batch, so this class keeps its own cache.
_batch_cache: ClassVar[dict[bool, "gpu.types.GPUBatch"]] = {}
def draw(self, context: bpy.types.Context) -> None:
broken = bool(self.is_highlight)
batch = type(self)._batch_cache.get(broken)
if batch is None:
tris = LINK_TRIS_BROKEN if broken else LINK_TRIS_INTACT
batch = batch_for_shader(_get_static_tris_shader(), "TRIS", {"pos": tris})
type(self)._batch_cache[broken] = batch
# Icon body forced fully opaque so the dark outline behind doesn't
# bleed through and grey out the glyph.
color = (*self.color_highlight, 1.0) if broken else (*self.color, 1.0)
draw_tris_with_outline(
batch,
self.matrix_basis @ self.matrix_offset,
color,
self.outline_width,
self.outline_alpha,
)
def _fillet_icon_tris() -> tuple[tuple[float, float, float], ...]:
@@ -3271,8 +3517,9 @@ class GizmoFillet(StaticTrisGizmoMixin, bpy.types.Gizmo):
bl_idname = "VIEW3D_GT_fillet"
__slots__ = ("custom_shape",)
tris = FILLET_TRIS_DEFAULT
# Stacked at ICON_STACK_OFFSET_Y above join in GizmoWallJoinIntersection;
# full-bbox hit overlaps the sibling icons' bboxes and steals their clicks.
# Stacked at ICON_STACK_OFFSET_Y above the join icon in the wall-join
# gizmo group; full-bbox hit overlaps the sibling icons' bboxes and
# steals their clicks.
hit_uses_bbox = False
@@ -3663,7 +3910,7 @@ class GizmoArrayAll(StaticTrisGizmoMixin, bpy.types.Gizmo):
parent_element = tool.Ifc.get().by_guid(parent_guid)
except RuntimeError:
return
from bonsai.bim.module.model.decorator import draw_array_layer_children_bbox
from bonsai.bim.module.model.array import draw_array_layer_children_bbox
draw_array_layer_children_bbox(context, parent_element, layer_index)
@@ -3748,11 +3995,49 @@ class GizmoArrayLayerIndicator(bpy.types.Gizmo):
parent_element = tool.Ifc.get_entity(obj)
if parent_element is None:
return
from bonsai.bim.module.model.decorator import draw_array_layer_children_bbox
from bonsai.bim.module.model.array import draw_array_layer_children_bbox
draw_array_layer_children_bbox(context, parent_element, self._layer_index)
class GizmoCountLabel(bpy.types.Gizmo):
"""``xN`` text label rendered from 7-segment digit triangles.
Mirrors a caller-supplied integer into a live count badge. No icon
glyph; the gizmo is the number alone."""
bl_idname = "BIM_GT_count_label"
__slots__ = ("custom_shape", "_count", "_built_count", "_outlined_batch")
def setup(self) -> None:
self._count = 0
self._built_count = -1
tris = _count_label_tris(self._count, 0.0, 0.0)
self.custom_shape = self.new_custom_shape("TRIS", tris)
self._outlined_batch = batch_for_shader(_get_static_tris_shader(), "TRIS", {"pos": tris})
self._built_count = 0
def set_count(self, count: int) -> None:
self._count = int(count)
def _ensure_shape(self) -> None:
if self._built_count != self._count:
tris = _count_label_tris(self._count, 0.0, 0.0)
self.custom_shape = self.new_custom_shape("TRIS", tris)
self._outlined_batch = batch_for_shader(_get_static_tris_shader(), "TRIS", {"pos": tris})
self._built_count = self._count
def draw(self, context: bpy.types.Context) -> None:
self._ensure_shape()
color = (*self.color_highlight, 1.0) if self.is_highlight else (*self.color, 1.0)
draw_tris_with_outline(self._outlined_batch, self.matrix_basis @ self.matrix_offset, color)
def draw_select(self, context: bpy.types.Context, select_id: int) -> None:
self._ensure_shape()
self.draw_custom_shape(self.custom_shape, select_id=select_id)
class GizmoMerge(StaticTrisGizmoMixin, bpy.types.Gizmo):
"""Two arrows pointing inward toward each other — conveys joining/merging elements."""
@@ -4049,7 +4334,8 @@ def _generate_menu_tris() -> tuple[tuple[float, float, float], ...]:
class GizmoMenu(StaticTrisGizmoMixin, bpy.types.Gizmo):
"""Hamburger-stack menu icon — 'open a picker to choose from many options'.
For enums with 5+ values; use ``GizmoCycle`` for 2-4."""
For enums with 3+ values; use ``GizmoCycle`` for exactly 2 (where the
advance-one-per-click semantic stays predictable)."""
bl_idname = "VIEW3D_GT_menu"
@@ -4803,6 +5089,8 @@ class BillboardingGizmoGroupMixin:
self.position_gizmos(context)
def draw_prepare(self, context: bpy.types.Context) -> None:
if apply_transform_modal_draw_gate(self, context):
return
self.position_gizmos(context)
def setup_icon_gizmo(
@@ -4816,12 +5104,130 @@ class BillboardingGizmoGroupMixin:
"""Convenience wrapper over `setup_icon_gizmo` for subclasses."""
return setup_icon_gizmo(self, gizmo_type, color, highlight_color, operator, alpha)
def get_decoration_colors(self) -> tuple[tuple[float, float, float], tuple[float, float, float]]:
"""Standard (default, highlight) color pair for active-state gizmos.
Pulls from the addon preferences same source consumed by every
Bonsai decorator. Hover-class gizmos that should not pull focus
should use ``get_unselected_decoration_colors`` instead."""
prefs = tool.Blender.get_addon_preferences()
return prefs.decorations_colour[:3], prefs.decorator_color_selected[:3]
def get_unselected_decoration_colors(self) -> tuple[tuple[float, float, float], tuple[float, float, float]]:
"""Lower-priority (unselected default, highlight) pair for gizmos
that surface on already-selected geometry and shouldn't compete
visually with the selection outline (e.g. array-child navigation)."""
prefs = tool.Blender.get_addon_preferences()
return prefs.decorator_color_unselected[:3], prefs.decorator_color_selected[:3]
def position_gizmos(self, context: bpy.types.Context) -> None:
raise NotImplementedError(
f"{type(self).__name__} must implement position_gizmos(context) when using BillboardingGizmoGroupMixin."
)
@dataclass(frozen=True)
class IconSlot:
"""One slot in a parametric edit gizmo's icon toolbar row.
The slot's X coordinate is COMPUTED from its index in ``feature_slots`` —
never set explicitly. Adding an icon is a one-line append; the layout
manager resolves the X. Hidden slots STILL CONSUME their X position so
toggling a visibility preference doesn't shift the row.
Fields:
- ``gizmo_idname`` for single-icon slots, the full Blender gizmo
idname. For multi-variant slots, EITHER a string PREFIX that auto-
suffixes ``_<variant>`` per member (the common case e.g.
``"VIEW3D_GT_lock"`` + variants ``("open", "closed")`` becomes
``VIEW3D_GT_lock_open`` / ``VIEW3D_GT_lock_closed``) OR a tuple of
explicit idnames matching the variant count when the variants
don't share a prefix. Attributes created on the gizmo group are
``self.<name>_gizmo`` for single slots, ``self.<name>_<variant>_gizmo``
for each variant in multi-variant slots.
- ``variants`` variant suffixes, e.g. ``("open", "closed")`` for a
lock pair, ``("exterior", "center", "interior")`` for a baseline
cycle. Empty tuple = single icon.
- ``color`` RGB tuple. ``None`` falls back to the gizmo group's default
decoration color. Use the group's ``COLOR_RED`` / ``COLOR_GREEN`` /
``COLOR_BLUE`` literals for state-coded icons.
- ``extra_gap_before`` extra spacing past the default uniform gap, in
meters. Use sparingly e.g. to visually separate a destructive
action (trash) from the routine edit controls.
- ``operator_props`` tuple of (key, value) pairs forwarded to
``target_set_operator``'s return value (e.g. ``increment=1`` for a
+/- adjuster, ``property_name="..."`` for a generic toggle).
- ``placeholder`` when ``True``, the slot reserves an X position in
the row but no auto-managed gizmo is created. Subclasses look the X
up via ``_slot_x_positions()[name]`` to place their own dynamically-
built gizmos (e.g. a live count label). ``gizmo_idname`` / ``operator``
are unused for placeholders."""
name: str
gizmo_idname: str | tuple[str, ...] = ""
operator: str = ""
# Matches DEFAULT_BILLBOARD_SCALE — the scale validate/cancel render at,
# so slots that don't override land at the same visual size by default.
# Helper icons (+/- count adjusters, lock pairs, delete) override with
# smaller values (0.20 - 0.35) to signal secondary affordance.
scale: float = DEFAULT_BILLBOARD_SCALE
color: tuple[float, float, float] | None = None
variants: tuple[str, ...] = ()
extra_gap_before: float = 0.0
operator_props: tuple[tuple[str, Any], ...] = ()
placeholder: bool = False
# Optional per-frame visibility predicate. Called with the gizmo group
# instance as the sole argument; returning False hides this slot's gizmo
# while still reserving its X position so the row layout doesn't shift.
# Used for idle-row icons whose relevance depends on element state (e.g.
# toggle_openings only when the host has openings).
visible_when: Optional[Callable[[Any], bool]] = None
def __post_init__(self) -> None:
# Validate shape at class-definition time so a typo doesn't surface
# as a runtime error in the gizmo group's setup() three layers deep.
if self.placeholder:
return
if self.variants:
if isinstance(self.gizmo_idname, str):
pass # prefix form — idname auto-suffixed per variant
elif isinstance(self.gizmo_idname, tuple) and len(self.gizmo_idname) == len(self.variants):
pass # explicit-tuple form
else:
raise TypeError(
f"IconSlot({self.name!r}): variants={self.variants} requires gizmo_idname "
f"to be either a string prefix (auto-suffixed as <prefix>_<variant>) or a "
f"tuple of {len(self.variants)} explicit idnames, got {self.gizmo_idname!r}"
)
elif not isinstance(self.gizmo_idname, str) or not self.gizmo_idname:
raise TypeError(
f"IconSlot({self.name!r}): single-icon slot requires gizmo_idname str, "
f"got {self.gizmo_idname!r} (set variants=(...) if you want a multi-variant "
f"slot, or placeholder=True for a reserved-position slot)"
)
def variant_idnames(self) -> tuple[str, ...]:
"""Resolve per-variant gizmo idnames. For prefix form, suffix each
variant onto the prefix; for tuple form, return as is. Single-icon
slots return a one-element tuple containing the idname."""
if not self.variants:
assert isinstance(self.gizmo_idname, str)
return (self.gizmo_idname,)
if isinstance(self.gizmo_idname, str):
return tuple(f"{self.gizmo_idname}_{variant}" for variant in self.variants)
return self.gizmo_idname
def gizmo_attrs(self) -> tuple[str, ...]:
"""Names of every ``self.*`` attribute this slot writes during setup.
Returns one for a single slot, N for an N-variant slot, and an empty
tuple for placeholder slots (which reserve X without an auto-gizmo)."""
if self.placeholder:
return ()
if self.variants:
return tuple(f"{self.name}_{variant}_gizmo" for variant in self.variants)
return (f"{self.name}_gizmo",)
class BaseParametricGizmoGroup:
"""Base mixin for parametric element gizmo groups (doors, windows, stairs, etc.).
@@ -4897,11 +5303,13 @@ class BaseParametricGizmoGroup:
"""
# === Gizmo Colors ===
# Match Blender axis convention: X=red, Y=green, Z=blue
COLOR_RED = (1.0, 0.2, 0.2)
COLOR_GREEN = (0.1, 0.8, 0.1)
COLOR_BLUE = (0.3, 0.3, 1.0)
COLOR_NEUTRAL = (1.0, 1.0, 1.0)
# Aliased to the module-level constants so subclass class bodies can
# reference either spelling. Match Blender's axis convention:
# X=red, Y=green, Z=blue.
COLOR_RED = COLOR_RED
COLOR_GREEN = COLOR_GREEN
COLOR_BLUE = COLOR_BLUE
COLOR_NEUTRAL = COLOR_NEUTRAL
# === Dimension Gizmo Layout (meters) ===
ARROW_SCALE = 0.25 # Scale factor for arrow gizmos
@@ -4920,17 +5328,21 @@ class BaseParametricGizmoGroup:
ICON_VALIDATE_X = 0.0 # X position of validate (checkmark) icon
ICON_CANCEL_X = 0.5 # X offset from validate for cancel (X) icon
ICON_CYCLE_X = 0.87 # X offset from validate for cycle (arrow) icon
# Rightmost local-X used by feature-specific icons (across both idle and
# edit states). Subclasses override when they add icons past the cycle
# slot at 0.87 — currently wall (rotate at 1.24) and stair (minus at
# 1.98). Drives both the ARRAY button position (this class) AND the
# array-layer-icons start position (``GizmoArrayEdition`` runtime lookup),
# so non-colliding features get a tight layout while wall / stair shift
# the array-related slots outward to avoid stomping on the rotate /
# tread-lock / +/- icons.
FEATURE_ICON_MAX_X: float = 0.87
# Gap between the last feature icon and the ARRAY button (or the first
# array layer icon in idle state).
# Subclasses append to declare feature icons in the edit-mode toolbar row.
# The layout manager assigns each slot an X position from its tuple
# index — adding a new icon is a one-line append, no hardcoded X
# constant, no "remember to bump the right edge" rule. The trailing
# ARRAY button is positioned past the last slot automatically.
feature_slots: ClassVar[tuple[IconSlot, ...]] = ()
# Idle-mode pen-row extras (e.g. wall's toggle_openings). Each slot is
# placed past the pen at uniform ``ICON_ARRAY_GAP`` spacing. Hidden during
# edit — the validate/cancel row owns the X positions there. Peer gizmo
# groups (e.g. ``GizmoArrayEdition``'s per-layer ARRAY icons) query
# ``_idle_row_right_edge()`` to position past these without a hardcoded
# per-feature table.
idle_slots: ClassVar[tuple[IconSlot, ...]] = ()
# Gap between adjacent slots past the leading validate/cancel/cycle
# triplet, AND between the last slot and the ARRAY button.
ICON_ARRAY_GAP: float = 0.37
ICON_Z_OFFSET = 0.5 # Height above element for icons
ICON_Y_OFFSET = GIZMO_OFFSET * 2 # Y offset to keep icons clear of geometry
@@ -4952,6 +5364,62 @@ class BaseParametricGizmoGroup:
super().__init_subclass__(**kwargs)
BaseParametricGizmoGroup.REGISTRY.append(cls)
@classmethod
def _slot_x_positions(cls) -> dict[str, float]:
"""Map each ``feature_slot`` name to its X coordinate in the row.
Slots are laid out from the cycle position onward at uniform
``ICON_ARRAY_GAP`` spacing, plus any per-slot ``extra_gap_before``.
When the cycle slot is unused (no ``cycle_type_operator`` /
``pick_type_operator``), the first feature slot collapses into the
cycle position so the row stays tight that's how wall's baseline
triplet ends up at X=0.87 without a gap before it. Tuple order is
the only thing that controls X; rearranging the tuple rearranges
the row."""
positions: dict[str, float] = {}
has_cycle = bool(cls.cycle_type_operator) or bool(cls.pick_type_operator)
next_x = (cls.ICON_CYCLE_X + cls.ICON_ARRAY_GAP) if has_cycle else cls.ICON_CYCLE_X
for slot in cls.feature_slots:
next_x += slot.extra_gap_before
positions[slot.name] = next_x
next_x += cls.ICON_ARRAY_GAP
return positions
@classmethod
def _feature_row_right_edge(cls) -> float:
"""Right edge of the feature icon row, fed to the trailing ARRAY
button's X. Computed strictly from slot order + gaps; empty
``feature_slots`` collapses to the cycle position."""
positions = cls._slot_x_positions()
if not positions:
return cls.ICON_CYCLE_X
return max(positions.values())
@classmethod
def _idle_slot_x_positions(cls) -> dict[str, float]:
"""Map each ``idle_slot`` name to its X coordinate past the pen.
First idle slot lands at ``ICON_CANCEL_X`` (the cancel-slot position,
unused in idle since validate/cancel are edit-only). Successive slots
are spaced by ``ICON_ARRAY_GAP``, plus any per-slot ``extra_gap_before``."""
positions: dict[str, float] = {}
next_x = cls.ICON_CANCEL_X
for slot in cls.idle_slots:
next_x += slot.extra_gap_before
positions[slot.name] = next_x
next_x += cls.ICON_ARRAY_GAP
return positions
@classmethod
def _idle_row_right_edge(cls) -> float:
"""Rightmost local-X reserved by this group's idle row. Returns the
pen position (``ICON_VALIDATE_X``) when no idle slots are declared
so peer queries always get a meaningful number."""
positions = cls._idle_slot_x_positions()
if not positions:
return cls.ICON_VALIDATE_X
return max(positions.values())
@classmethod
def pick_visible_anchor(cls, context: bpy.types.Context, world_base: Vector, world_top: Vector) -> Vector:
"""Choose between two anchor candidates so vertical separation stays
@@ -5031,27 +5499,13 @@ class BaseParametricGizmoGroup:
from_neg_y, from_neg_x = self.get_local_view_direction(context, world_matrix)
return ViewDirection(from_negative_y=from_neg_y, from_negative_x=from_neg_x)
def update_gizmo_visibility(self, gizmo: bpy.types.Gizmo, is_editing: bool, pref_enabled: bool) -> bool:
"""Update gizmo visibility based on modal state, editing state, and preference.
Consolidates the common pattern:
if hidden_by_modal:
gizmo.hide = True
else:
gizmo.hide = not is_editing or not pref_enabled
Args:
gizmo: The gizmo to update visibility for
is_editing: Whether the element is currently being edited
pref_enabled: Whether this gizmo type is enabled in preferences
Returns:
True if the gizmo is now visible (not hidden), False otherwise
"""
def update_gizmo_visibility(self, gizmo: bpy.types.Gizmo, is_editing: bool) -> bool:
"""Hide ``gizmo`` when not editing or when a modal owns the viewport.
Returns True if the gizmo is now visible."""
if self.is_gizmo_hidden_by_modal(gizmo):
gizmo.hide = True
return False
gizmo.hide = not is_editing or not pref_enabled
gizmo.hide = not is_editing
return not gizmo.hide
def get_y_position_for_view(
@@ -5291,10 +5745,11 @@ class BaseParametricGizmoGroup:
if preview_base.any_preview_active(context):
return False
if _is_transform_modal_active(context):
return False
if cls.gizmo_pref_name:
prefs = tool.Blender.get_addon_preferences()
feature_prefs = getattr(prefs.gizmos, cls.gizmo_pref_name, None)
if feature_prefs is not None and not getattr(feature_prefs, "enabled", True):
if not getattr(prefs.gizmos, cls.gizmo_pref_name, True):
return False
if len(tool.Blender.get_selected_objects()) != 1:
return False
@@ -5383,8 +5838,9 @@ class BaseParametricGizmoGroup:
"""
pass
# Subclass should define these class attributes for metadata-driven dispatch
# If not defined, subclass must override get_props() and get_gizmo_prefs()
# Subclass should define these class attributes for metadata-driven dispatch.
# ``gizmo_pref_name`` matches a flat BoolProperty field on
# ``GizmoPreferences`` and gates the whole gizmo group's poll.
props_getter: Callable[[bpy.types.Object], bpy.types.PropertyGroup] | None = None
gizmo_pref_name: str | None = None # e.g., "door"
@@ -5419,18 +5875,6 @@ class BaseParametricGizmoGroup:
prefs = self.get_addon_prefs()
return prefs.decorations_colour[:3], prefs.decorator_color_selected[:3]
def get_gizmo_prefs(self) -> Any:
"""Get gizmo preferences for this element type.
Subclass can either:
1. Define class attribute `gizmo_pref_name` (e.g., "door")
2. Override this method directly
"""
if self.gizmo_pref_name:
prefs = self.get_addon_prefs()
return getattr(prefs.gizmos, self.gizmo_pref_name)
raise NotImplementedError("Subclass must define gizmo_pref_name or override get_gizmo_prefs()")
def is_setup_complete(self) -> bool:
"""Check if gizmo setup has been completed.
@@ -5654,6 +6098,34 @@ class BaseParametricGizmoGroup:
"VIEW3D_GT_menu", default_color, self.pick_type_operator, highlight_color
)
# Feature-specific edit-row icons. Subclasses declare them via
# ``feature_slots``; multi-variant slots create one gizmo per
# variant at the same X (e.g. a lock pair, a baseline triplet) and
# the subclass picks which is visible per frame. Placeholder slots
# only reserve an X position — the subclass creates its own gizmo
# there in ``setup_element_specific_gizmos``.
for slot in self.feature_slots:
if slot.placeholder:
continue
slot_color = slot.color if slot.color is not None else default_color
kwargs = dict(slot.operator_props)
for attr, idname in zip(slot.gizmo_attrs(), slot.variant_idnames()):
gz = self.create_icon_gizmo(idname, slot_color, slot.operator, **kwargs)
setattr(self, attr, gz)
# Idle-mode pen-row extras. Same creation path as feature_slots; the
# IDLE branch of ``update_editing_gizmos`` positions and visibility-
# gates them, the EDIT branch hides them so the validate/cancel row
# owns the X positions.
for slot in self.idle_slots:
if slot.placeholder:
continue
slot_color = slot.color if slot.color is not None else default_color
kwargs = dict(slot.operator_props)
for attr, idname in zip(slot.gizmo_attrs(), slot.variant_idnames()):
gz = self.create_icon_gizmo(idname, slot_color, slot.operator, **kwargs)
setattr(self, attr, gz)
# ARRAY button — visible during the feature edit lifecycle only (positioned by
# ``update_editing_gizmos``). Click commits the current edit and adds a
# Blender-vanilla-defaulted array (count=2, X-offset = bbox extent). The
@@ -5916,14 +6388,65 @@ class BaseParametricGizmoGroup:
billboard_rot=billboard_rot,
scale=0.30,
)
# Array gizmo integration is in-progress: the icon binds to
# bim.add_array_from_feature_edit but the array-from-parametric-draft
# operator + per-feature gizmo positioning haven't fully landed.
# Force-hide the icon while parametric-item editing is active to
# keep the user from triggering a half-wired add-array flow. Drop
# this gate when array integration completes.
# Feature slots: per-class IconSlot tuples driven by tuple order.
# Whole-feature visibility is gated upstream by ``poll()`` against
# ``prefs.gizmos.<feature>``; positioning happens unconditionally
# whenever the gizmo group polls visible.
slot_positions = self._slot_x_positions()
for slot in self.feature_slots:
if slot.placeholder:
continue
slot_x = self.ICON_VALIDATE_X + slot_positions[slot.name]
attrs = slot.gizmo_attrs()
if slot.variants:
# Multi-variant slot: write matrix on every variant member
# at the same anchor so a state flip never reveals a stale
# pose. The subclass's per-frame hook picks which member
# is visible — this loop doesn't toggle hide flags.
world_pos = mw @ Vector((slot_x, icon_y, icon_z))
matrix = billboarded_at(world_pos, billboard_rot, scale=slot.scale)
for attr in attrs:
gz = getattr(self, attr, None)
if gz is not None:
gz.matrix_basis = matrix
continue
gz = getattr(self, attrs[0], None)
if gz is None:
continue
gz.hide = self.is_gizmo_hidden_by_modal(gz)
self.set_icon_gizmo_position(
attrs[0],
mw=mw,
x=slot_x,
y=icon_y,
z=icon_z,
billboard_rot=billboard_rot,
scale=slot.scale,
)
# ARRAY button sits past the last feature-specific icon. Slot-based
# subclasses derive the right edge from the slot count.
if hasattr(self, "array_gizmo"):
self.array_gizmo.hide = True
self.array_gizmo.hide = self.is_gizmo_hidden_by_modal(self.array_gizmo)
# 30% smaller than the editing-icon-row default (0.50 → 0.35):
# the array button is a tertiary affordance compared to the
# primary pen / validate / cancel triad, and the smaller
# footprint keeps the edit-mode row from sprawling.
self.set_icon_gizmo_position(
"array_gizmo",
mw=mw,
x=self.ICON_VALIDATE_X + self._feature_row_right_edge() + self.ICON_ARRAY_GAP,
y=icon_y,
z=icon_z,
billboard_rot=billboard_rot,
scale=0.35,
)
# Idle-row icons are hidden in edit — validate / cancel sit at
# the same X positions, so showing both would stack icons.
for slot in self.idle_slots:
for attr in slot.gizmo_attrs():
gz = getattr(self, attr, None)
if gz is not None:
gz.hide = True
else:
# ``hide_pen_button = True`` keeps the pen permanently hidden — for
# groups whose edit-mode entry is already provided by another widget
@@ -5942,8 +6465,41 @@ class BaseParametricGizmoGroup:
self.cancel_gizmo.hide = True
if self.cycle_type_operator or self.pick_type_operator:
self.cycle_gizmo.hide = True
for slot in self.feature_slots:
for attr in slot.gizmo_attrs():
gz = getattr(self, attr, None)
if gz is not None:
gz.hide = True
if hasattr(self, "array_gizmo"):
self.array_gizmo.hide = True
# Idle slots: position past the pen, apply per-slot visible_when
# so state-dependent icons (e.g. toggle_openings) only render
# when relevant. Hidden slots STILL consume their X position so
# the row layout doesn't shift when state flips.
idle_positions = self._idle_slot_x_positions()
for slot in self.idle_slots:
if slot.placeholder:
continue
slot_x = self.ICON_VALIDATE_X + idle_positions[slot.name]
gate = slot.visible_when
visible = True if gate is None else bool(gate(self))
for attr in slot.gizmo_attrs():
gz = getattr(self, attr, None)
if gz is None:
continue
if not visible:
gz.hide = True
continue
gz.hide = self.is_gizmo_hidden_by_modal(gz)
self.set_icon_gizmo_position(
attr,
mw=mw,
x=slot_x,
y=icon_y,
z=icon_z,
billboard_rot=billboard_rot,
scale=slot.scale,
)
def draw_prepare(self, context: bpy.types.Context) -> None:
"""Called before drawing - updates gizmos to face camera.
@@ -5955,6 +6511,8 @@ class BaseParametricGizmoGroup:
"""
if not self.is_setup_complete():
return
if apply_transform_modal_draw_gate(self, context):
return
obj = context.active_object
if not obj:
return
@@ -6161,6 +6719,8 @@ class BaseSchematicGizmoGroup(BaseParametricGizmoGroup):
def draw_prepare(self, context: bpy.types.Context) -> None:
if not self.is_setup_complete():
return
if apply_transform_modal_draw_gate(self, context):
return
obj = context.active_object
if not obj:
return
@@ -6567,6 +7127,8 @@ class BaseIconActionGroup(BillboardingGizmoGroupMixin):
return False
if not tool.Blender.are_viewport_gizmos_enabled():
return False
if _is_transform_modal_active(context):
return False
return cls.is_eligible_object(obj)
def setup(self, context: bpy.types.Context) -> None:
@@ -80,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)
@@ -546,6 +546,13 @@ class UpdateRepresentation(bpy.types.Operator, tool.Ifc.Operator):
objs = [bpy.data.objects[obj_name]] if obj_name else context.selected_objects
self.file = tool.Ifc.get()
# Tessellated face sets (IfcTriangulatedFaceSet/IfcPolygonalFaceSet) were
# introduced in IFC4 and do not exist in IFC2X3. Catch this early so we
# don't silently fall back to a faceted brep after stripping materials.
if self.ifc_representation_class == "IfcTessellatedFaceSet" and self.file.schema == "IFC2X3":
self.report({"ERROR"}, "Tessellated face sets are not supported in IFC2X3.")
return {"CANCELLED"}
for obj in objs:
# TODO: write unit tests to see how this bulk operation handles
# contradictory ifc_representation_class values and when
@@ -1027,10 +1034,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)
@@ -1289,9 +1296,7 @@ class OverrideDuplicateMove(bpy.types.Operator):
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)
)
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")
@@ -2497,9 +2502,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()
@@ -3168,7 +3173,7 @@ class EnableEditingRepresentationItems(bpy.types.Operator, tool.Ifc.Operator):
product_reps = element.RepresentationMaps
item_aspect = {}
for product_rep in product_reps:
for aspect in product_rep.HasShapeAspects:
for aspect in getattr(product_rep, "HasShapeAspects", ()):
for aspect_rep in aspect.ShapeRepresentations:
if aspect_rep.ContextOfItems != representation.ContextOfItems:
continue
+56 -7
View File
@@ -31,7 +31,9 @@ from . import (
external,
grid,
handler,
host_add_opening_gizmo,
mep,
mep_bend_preview,
opening,
product,
profile,
@@ -50,19 +52,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,
@@ -74,6 +85,7 @@ classes = (
product.SetActiveType,
workspace.Hotkey,
workspace.BIM_MT_add_representation_item,
wall.AddPerpendicularWall,
wall.AddWallsFromSlab,
wall.AlignWall,
wall.CancelEditingWall,
@@ -91,12 +103,15 @@ classes = (
wall.ExtendWallToCursor,
wall.FinishEditingWall,
wall.FlipWall,
wall.GizmoWallAddOpening,
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,
@@ -105,7 +120,6 @@ classes = (
wall.RotateWall90,
wall.SplitWall,
wall.SplitWallAtCursor,
wall.ToggleWallOpenings,
wall.UnjoinWallPathConnection,
wall.UnjoinWalls,
wall.EnableWallFilletPreview,
@@ -124,6 +138,7 @@ classes = (
opening.RemoveBoolean,
opening.SelectBoolean,
opening.ShowOpenings,
opening.ToggleHostOpenings,
opening.UpdateOpeningsFocus,
profile.ChangeCardinalPoint,
profile.ChangeProfileDepth,
@@ -171,10 +186,14 @@ classes = (
prop.BIMRailingProperties,
prop.BIMRoofProperties,
prop.BIMWallProperties,
prop.BIMPipeSegmentProperties,
prop.BIMDuctSegmentProperties,
prop.BIMPolylineProperties,
prop.BIMExternalParametricGeometryProperties,
prop.BIMBendPreviewProperties,
prop.BIMWallFilletPreviewProperties,
prop.BIMPreviewProperties,
prop.BIMParametricEditDialogPrefs,
ui.BIM_PT_array,
ui.BIM_PT_stair,
ui.BIM_PT_wall,
@@ -198,7 +217,8 @@ classes = (
stair.ToggleStairProperty,
stair.AdjustStairTreads,
stair.SetStairTreads,
stair.CycleStairType,
stair.InputStairTreads,
stair.PickStairType,
stair.GizmoStairEdition,
sverchok_modifier.CreateNewSverchokGraph,
sverchok_modifier.UpdateDataFromSverchok,
@@ -211,7 +231,7 @@ classes = (
window.FinishEditingWindow,
window.EnableEditingWindow,
window.RemoveWindow,
window.CycleWindowType,
window.PickWindowType,
window.GizmoWindowEdition,
door.BIM_OT_add_door,
door.AddDoor,
@@ -220,7 +240,7 @@ classes = (
door.EnableEditingDoor,
door.RemoveDoor,
door.ToggleDoorSwing,
door.CycleDoorType,
door.PickDoorType,
door.GizmoDoorEdition,
railing.BIM_OT_add_railing,
railing.CopyRailingParameters,
@@ -237,16 +257,41 @@ 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,
mep.MEPAddTransition,
mep.MEPAddBend,
mep.MEPUnjoinAtPort,
mep.MEPRemoveTerminalFitting,
mep.MEPUnjoinPair,
mep.SelectMEPPathMembers,
mep.MEPJoinSegments,
mep_bend_preview.EnableBendPreview,
mep_bend_preview.FinishBendPreview,
mep_bend_preview.CancelBendPreview,
mep_bend_preview.EnableBendPreviewFromBend,
mep_bend_preview.GizmoBendPreview,
mep.EnableEditingPipeSegment,
mep.FinishEditingPipeSegment,
mep.CancelEditingPipeSegment,
mep.EnableEditingDuctSegment,
mep.FinishEditingDuctSegment,
mep.CancelEditingDuctSegment,
mep.ExtendPipeSegmentToCursor,
mep.ExtendDuctSegmentToCursor,
mep.SplitPipeSegmentAtCursor,
mep.SplitDuctSegmentAtCursor,
mep.GizmoPipeSegmentEdition,
mep.GizmoDuctSegmentEdition,
mep.GizmoMEPActions,
external.ApplyExternalParametricGeometry,
)
@@ -306,6 +351,9 @@ def register():
type=prop.BIMExternalParametricGeometryProperties
)
bpy.types.Scene.BIMPreviewProperties = bpy.props.PointerProperty(type=prop.BIMPreviewProperties)
bpy.types.WindowManager.BIMParametricEditDialogPrefs = bpy.props.PointerProperty(
type=prop.BIMParametricEditDialogPrefs
)
bpy.types.VIEW3D_MT_add.prepend(ui.add_menu)
bpy.app.handlers.load_post.append(handler.load_post)
@@ -331,6 +379,7 @@ def unregister():
tool.Parametric.unregister_object_properties()
del bpy.types.Object.BIMExternalParametricGeometryProperties
del bpy.types.Scene.BIMPreviewProperties
del bpy.types.WindowManager.BIMParametricEditDialogPrefs
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
+386 -34
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 math
from math import cos, pi, radians, sin, tan
from typing import Any, Literal
from typing import Any, Literal, NamedTuple
import blf
import bmesh
@@ -41,6 +43,11 @@ from mathutils import Matrix, Quaternion, Vector
import bonsai.core.geometry
import bonsai.tool as tool
from bonsai.bim.module.drawing.gizmos import (
ARC_SEGMENTS,
DOOR_SWING_ANGLE_MAX,
DOOR_SWING_ANGLE_MIN,
)
from bonsai.bim.module.drawing.helper import format_distance
@@ -88,15 +95,9 @@ class ProfileDecorator:
batch.draw(shader)
def draw_faces(self, bm, vertices_coords):
"""mutates original bm (triangulates it)
so the triangulation edges will be shown too
"""
traingulated_bm = bm
bmesh.ops.triangulate(traingulated_bm, faces=traingulated_bm.faces)
face_indices = [[v.index for v in f.verts] for f in traingulated_bm.faces]
"""Submit a non-mutating beauty-triangulated TRIS batch over ``bm``'s faces."""
faces_color = transparent_color(self.addon_prefs.decorator_color_special)
self.draw_batch("TRIS", vertices_coords, faces_color, face_indices)
tool.Blender.draw_bmesh_face_tris(bm, vertices_coords, faces_color, self.draw_batch)
def __call__(self, context, get_custom_bmesh=None, draw_faces=False, exit_edit_mode_callback=None):
self.addon_prefs = tool.Blender.get_addon_preferences()
@@ -2031,42 +2032,221 @@ class BoundingBoxDecorator:
co2.y -= y_overlap / 2 + min_spacing
def _stroke_lines_alpha(
def _fill_quads_alpha(
context: bpy.types.Context,
segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]],
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],
line_width: float,
line_alpha: float,
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:
"""Render ``quads`` (each a 4-tuple of world-space corner verts in CCW
order) as one TRIS batch with two triangles per quad."""
if not quads:
return
verts: list[tuple[float, float, float]] = []
indices: list[tuple[int, int]] = []
for start, end in segments:
indices: list[tuple[int, int, int]] = []
for quad in quads:
if len(quad) != 4:
continue
base = len(verts)
verts.append(tuple(start))
verts.append(tuple(end))
indices.append((base, base + 1))
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("POLYLINE_UNIFORM_COLOR")
shader = gpu.shader.from_builtin("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)
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")
def compute_mep_join_location():
"""Midpoint between the closest endpoint pair of two selected MEP
segments the world location where a connecting fitting (bend /
transition) would land. Returns ``None`` when prerequisites aren't met
(wrong cardinality, mixed non-MEP)."""
selected = list(tool.Blender.get_selected_objects())
if len(selected) != 2:
return None
for obj in selected:
element = tool.Ifc.get_entity(obj)
if element is None or not tool.System.is_mep_element(element):
return None
a_start, a_end = tool.Model.get_flow_segment_axis(selected[0])
b_start, b_end = tool.Model.get_flow_segment_axis(selected[1])
pairs = [(a_start, b_start), (a_start, b_end), (a_end, b_start), (a_end, b_end)]
closest = min(pairs, key=lambda p: (p[0] - p[1]).length)
return (closest[0] + closest[1]) * 0.5
class MEPSegmentExtendPreviewDecorator(tool.Blender.ViewportDecorator):
"""Preview line for the MEP segment extend-to-cursor gizmo. Renders one
line from the segment's current end to the cursor's projection on the
segment's local Z axis when the extend icon is hovered. Self-gates every
draw on the viewport gizmo toggle and the per-feature ``extend`` pref."""
draw_method = "draw_line"
LINE_WIDTH = 1.5
LINE_ALPHA = 0.8
def draw_line(self, context: bpy.types.Context) -> None:
if not tool.Blender.are_viewport_gizmos_enabled():
return
prefs = tool.Blender.get_addon_preferences()
active = context.active_object
if active is None:
return
selected = list(tool.Blender.get_selected_objects())
if active not in selected or len(selected) != 1:
return
element = tool.Ifc.get_entity(active)
if element is None:
return
from bonsai.bim.module.model.mep import (
GizmoDuctSegmentEdition,
GizmoPipeSegmentEdition,
)
if tool.Parametric.is_pipe_segment(element):
gizmo_prefs = getattr(prefs.gizmos, "pipe_segment", None)
gizmo_cls = GizmoPipeSegmentEdition
elif tool.Parametric.is_duct_segment(element):
gizmo_prefs = getattr(prefs.gizmos, "duct_segment", None)
gizmo_cls = GizmoDuctSegmentEdition
else:
return
if gizmo_prefs is None or not getattr(gizmo_prefs, "enabled", True):
return
if not self._cursor_icon_hovered(gizmo_cls, "extend_gizmo", context):
return
current_length = max(c[2] for c in active.bound_box) if active.bound_box else 0.0
line = self._compute_extend_preview_line(active.matrix_world, context.scene.cursor.location, current_length)
if line is None:
return
start_world, end_world = line
color = tuple(prefs.decorator_color_selected[:3])
draw_polyline_segments(
context,
[(tuple(start_world), tuple(end_world))],
color,
self.LINE_ALPHA,
self.LINE_WIDTH,
)
@staticmethod
def _compute_extend_preview_line(
matrix_world: Matrix,
cursor_world: Vector,
current_length: float,
) -> tuple[Vector, Vector] | None:
"""Returns ``(current_end_world, target_end_world)`` or ``None`` when
no extend would happen (degenerate segment, or cursor on the existing
end). Target follows the cursor's raw local-Z projection unbounded —
the line stays visible past the segment origin (negative local Z)
because the user expects to see where they're pointing even when the
operator would floor it."""
if current_length <= 0:
return None
cursor_local = matrix_world.inverted() @ cursor_world
if abs(cursor_local.z - current_length) < 1e-6:
return None
current_end_world = matrix_world @ Vector((0.0, 0.0, current_length))
target_end_world = matrix_world @ Vector((0.0, 0.0, cursor_local.z))
return current_end_world, target_end_world
class BendPreviewDecorator(tool.Blender.ViewportDecorator):
"""GPU preview lines for the bend-creation flow.
Polls on ``scene.BIMPreviewProperties.bend.is_active`` and renders the
centerline + leg projections returned by ``mep.compute_bend_preview_polylines``.
The two leg lines (segment tangent point) show how each segment will
be shortened; the arc polyline approximates the bend curve. On invalid
geometry, draws the two rejected axes in warning colour instead so the
user sees why the bend cannot be placed.
Installed once per Blender session from ``bim/handler.py:load_post``.
Cheap to leave running because the first thing ``draw`` does is check
``is_active`` and return when False.
"""
LINE_WIDTH_LEG = 1.5
LINE_WIDTH_ARC = 2.5
LINE_ALPHA = 0.7
def draw(self, context: bpy.types.Context) -> None:
scene = context.scene
preview = getattr(scene, "BIMPreviewProperties", None)
props = preview.bend if preview 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:
start_element = ifc_file.by_id(props.start_segment_id)
end_element = ifc_file.by_id(props.end_segment_id)
except Exception:
return
start_obj = tool.Ifc.get_object(start_element) if start_element else None
end_obj = tool.Ifc.get_object(end_element) if end_element else None
if start_obj is None or end_obj is None:
return
# Late import: decorator.py loads at addon enable but mep.py imports
# this module for the extend preview, so a module-level import would
# cycle.
from bonsai.bim.module.model.mep import cached_compute_bend_preview_polylines
preview = cached_compute_bend_preview_polylines(
start_obj, end_obj, props.start_length, props.end_length, props.radius
)
prefs = tool.Blender.get_addon_preferences()
if not preview["valid"]:
warning_color = tuple(prefs.decorator_color_error[:3])
axes = preview.get("invalid_axes") or []
if axes:
segments = [(tuple(a), tuple(b)) for a, b in axes]
draw_polyline_segments(context, segments, warning_color, self.LINE_ALPHA, self.LINE_WIDTH_ARC)
return
leg_color = tuple(prefs.decorations_colour[:3])
arc_color = tuple(prefs.decorator_color_selected[:3])
leg_a_far, leg_a_end = preview["leg_a"]
leg_b_far, leg_b_end = preview["leg_b"]
draw_polyline_segments(
context,
[(tuple(leg_a_far), tuple(leg_a_end)), (tuple(leg_b_far), tuple(leg_b_end))],
leg_color,
self.LINE_ALPHA,
self.LINE_WIDTH_LEG,
)
arc = preview["arc"]
if len(arc) >= 2:
arc_segments = [(tuple(arc[i]), tuple(arc[i + 1])) for i in range(len(arc) - 1)]
draw_polyline_segments(context, arc_segments, arc_color, self.LINE_ALPHA, self.LINE_WIDTH_ARC)
class WallFilletPreviewDecorator(tool.Blender.ViewportDecorator):
"""GPU preview lines for the wall-fillet flow.
@@ -2128,15 +2308,15 @@ class WallFilletPreviewDecorator(tool.Blender.ViewportDecorator):
(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)
draw_polyline_segments(context, legs, warning_color, self.LINE_ALPHA, self.LINE_WIDTH_LEG)
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)
draw_polyline_segments(context, arc_segments, warning_color, self.LINE_ALPHA, self.LINE_WIDTH_ARC)
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)
draw_polyline_segments(context, segments, warning_color, self.LINE_ALPHA, self.LINE_WIDTH_ARC)
return
leg_color = tuple(prefs.decorations_colour[:3])
@@ -2153,12 +2333,12 @@ class WallFilletPreviewDecorator(tool.Blender.ViewportDecorator):
(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)
draw_polyline_segments(context, legs, leg_color, self.LINE_ALPHA, self.LINE_WIDTH_LEG)
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)
draw_polyline_segments(context, arc_segments, arc_color, self.LINE_ALPHA, self.LINE_WIDTH_ARC)
# Dim construction lines from arc_center to each tangent point so
# the radius reads as concrete during drag.
@@ -2168,7 +2348,9 @@ class WallFilletPreviewDecorator(tool.Blender.ViewportDecorator):
(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)
draw_polyline_segments(
context, construction, arc_color, self.CONSTRUCTION_ALPHA, self.LINE_WIDTH_CONSTRUCTION
)
@staticmethod
def _far_endpoint(reference_line, intersection):
@@ -2177,3 +2359,173 @@ class WallFilletPreviewDecorator(tool.Blender.ViewportDecorator):
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
class _DoorSwingArc(NamedTuple):
"""Parameters for one swing-arc draw call in door-local space."""
hinge_x: float
hinge_y: float
panel_width: float
x_mirror: bool
def _visible_arcs(door_type: str, overall_width: float, lining_offset: float) -> list[_DoorSwingArc]:
"""Arc specs for the parametric door swing visualisation, agnostic of
edit-mode state so the readonly preview and the editor view stay aligned.
Empty only for sliding-door types; unknown ``door_type`` values fall
through to a single left-hinged arc."""
if "SLIDING" in door_type:
return []
is_double = "DOUBLE_DOOR" in door_type
is_right_single = door_type.endswith("RIGHT") and not is_double
arcs = [
_DoorSwingArc(
hinge_x=overall_width if is_right_single else 0.0,
hinge_y=lining_offset,
panel_width=overall_width / 2 if is_double else overall_width,
x_mirror=is_right_single,
)
]
if is_double:
arcs.append(
_DoorSwingArc(
hinge_x=overall_width,
hinge_y=lining_offset,
panel_width=overall_width / 2,
x_mirror=True,
)
)
return arcs
# Unit quarter-arc samples shared with the edit-mode swing gizmo so the
# readonly arc traces the same curve. Re-scaled per draw via the per-arc
# transform.
_DOOR_SWING_ARC_ANGLE_MIN_RAD = math.radians(DOOR_SWING_ANGLE_MIN)
_DOOR_SWING_ARC_ANGLE_RANGE_RAD = math.radians(DOOR_SWING_ANGLE_MAX) - _DOOR_SWING_ARC_ANGLE_MIN_RAD
_DOOR_SWING_ARC_UNIT_POINTS: tuple[Vector, ...] = tuple(
Vector(
(
math.cos(_DOOR_SWING_ARC_ANGLE_MIN_RAD + _DOOR_SWING_ARC_ANGLE_RANGE_RAD * (_i / ARC_SEGMENTS)),
math.sin(_DOOR_SWING_ARC_ANGLE_MIN_RAD + _DOOR_SWING_ARC_ANGLE_RANGE_RAD * (_i / ARC_SEGMENTS)),
0.0,
)
)
for _i in range(ARC_SEGMENTS + 1)
)
class DoorSwingReadonlyDecorator(tool.Blender.ViewportDecorator):
"""Always-on swing-arc preview for the active Bonsai-parametric IfcDoor
when it is not currently in parametric edit mode. Matches the visual
contract of the parametric door's swing-arc gizmos so the hinge side
and opening direction can be read without entering edit mode.
Silent-skip cases (no draw, no error):
- active object missing / not selected / not an IfcDoor;
- door is mid-edit (the swing gizmo is already painting the arc);
- door has no ``BBIM_Door`` pset (legacy import, never edited in Bonsai)."""
LINE_WIDTH = 1.5
LINE_ALPHA = 0.8
def draw(self, context: bpy.types.Context) -> None:
obj = context.active_object
if obj is None or not obj.select_get():
return
element = tool.Ifc.get_entity(obj)
if element is None or not element.is_a("IfcDoor"):
return
props = getattr(obj, "BIMDoorProperties", None)
if props is not None and props.is_editing:
return
pset = tool.Model.get_modeling_bbim_pset_data(obj, "BBIM_Door")
if not pset:
return
data = pset.get("data_dict")
if not data:
return
door_type = data.get("door_type", "")
overall_width_project = data.get("overall_width", 0.0)
lining_offset_project = (data.get("lining_properties") or {}).get("lining_offset", 0.0)
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
overall_width = overall_width_project * si_conversion
lining_offset = lining_offset_project * si_conversion
specs = _visible_arcs(door_type, overall_width, lining_offset)
if not specs:
return
prefs = tool.Blender.get_addon_preferences()
main_color = tuple(prefs.decorator_color_special[:3])
mw = obj.matrix_world
segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = []
for spec in specs:
x_flip = Matrix.Scale(-1, 4, (1, 0, 0)) if spec.x_mirror else Matrix.Identity(4)
transform = (
Matrix.Translation(Vector((spec.hinge_x, spec.hinge_y, 0.0)))
@ Matrix.Scale(spec.panel_width, 4)
@ x_flip
)
world_main = mw @ transform
pts = [world_main @ p for p in _DOOR_SWING_ARC_UNIT_POINTS]
for i in range(len(pts) - 1):
segments.append((tuple(pts[i]), tuple(pts[i + 1])))
draw_polyline_segments(context, segments, main_color, self.LINE_ALPHA, self.LINE_WIDTH)
_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
+85 -57
View File
@@ -37,8 +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
from bonsai.bim.parametric_lifecycle import FeatureModifierEditMixin, PickTypeMixin
if TYPE_CHECKING:
from bonsai.bim.module.model.prop import BIMDoorProperties
@@ -580,7 +581,7 @@ class _DoorEditMixin(FeatureModifierEditMixin):
@classmethod
def _is_element_type(cls, element):
return tool.Blender.Modifier.is_door(element)
return tool.Parametric.is_door(element)
@classmethod
def _get_props(cls, obj: bpy.types.Object):
@@ -629,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
@@ -644,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)
@@ -660,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)
@@ -686,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)
@@ -700,11 +706,11 @@ 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 = tool.Parametric.is_door
@@ -713,7 +719,7 @@ class CycleDoorType(bpy.types.Operator, tool.Ifc.Operator, gizmo.CycleTypeMixin)
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):
@@ -726,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
@@ -833,6 +839,36 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
p.get_transom_window_center_z(),
),
),
*WALL_OFFSET_GIZMO_CONFIGS,
]
# 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
@@ -840,7 +876,7 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
@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.
@@ -858,22 +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",
flip_geometry=False,
)
self.gizmo_flip_arc = self.create_arc_gizmo(
inactive_color,
"bim.toggle_door_swing",
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
@@ -892,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 = self.get_addon_prefs()
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)
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,444 @@
# 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.
"""Bend-preview lifecycle for MEP segment joins.
Holds the four lifecycle operators (Enable / Finish / Cancel /
EnableFromBend) and the ``GizmoBendPreview`` group that surfaces the
tunable dimensions and validate/cancel icons during preview. Draft state
lives at ``Scene.BIMPreviewProperties.bend`` per CLAUDE.md §2.9 (Scene
for cross-element previews).
The geometry math (``compute_bend_preview_polylines``,
``_bend_profile_cross_section``, ``_sweep_profile_along_polyline``)
stays in ``mep.py`` because the commit operator ``MEPAddBend`` reuses
it; this module imports the polyline helper for per-frame gizmo
positioning. The GPU lines themselves are drawn by
``decorator.BendPreviewDecorator``, kept in ``decorator.py`` with its
sibling decorators."""
from typing import ClassVar
import bpy
import ifcopenshell.util.element
import ifcopenshell.util.unit
from mathutils import Matrix, Vector
import bonsai.tool as tool
from bonsai.bim.module.drawing import gizmos as gizmo
from bonsai.bim.module.model import preview_base
from bonsai.bim.module.model.mep import (
_is_bend_fitting,
_n_mep_selected,
cached_compute_bend_preview_polylines,
segments_are_parallel,
validate_bend_preconditions,
)
class EnableBendPreview(bpy.types.Operator):
"""Enter bend-preview mode for two selected MEP segments. Populates
scene.BIMPreviewProperties.bend with segment IFC ids and default
start_length / end_length / radius; no IFC mutation until finish."""
bl_idname = "bim.enable_bend_preview"
bl_label = "Enter Bend Preview"
bl_description = "Begin tuning bend parameters before committing the bend"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
if not _n_mep_selected(2):
cls.poll_message_set("Select exactly 2 MEP segments to bend.")
return False
return True
def execute(self, context):
selected = tool.Blender.get_selected_objects()
active = context.active_object
if active is None or active not in selected:
self.report({"ERROR"}, "Active object must be one of the selected MEP segments.")
return {"CANCELLED"}
other = next((o for o in selected if o is not active), None)
if other is None:
self.report({"ERROR"}, "Two MEP segments must be selected.")
return {"CANCELLED"}
active_element = tool.Ifc.get_entity(active)
other_element = tool.Ifc.get_entity(other)
if active_element is None or other_element is None:
self.report({"ERROR"}, "Both selected objects must be IFC elements.")
return {"CANCELLED"}
if segments_are_parallel(active, other):
self.report({"ERROR"}, "Bend preview is for non-parallel segments only.")
return {"CANCELLED"}
# Pre-check the same preconditions MEPAddBend enforces so the user
# sees the rejection here rather than after tuning a doomed preview.
precondition_error = validate_bend_preconditions(active_element, other_element)
if precondition_error is not None:
self.report({"ERROR"}, precondition_error)
return {"CANCELLED"}
preview_base.sync_uncommitted_moves([active, other])
props = preview_base.get_preview_props(context, "bend")
# Auto-cancel any prior preview so re-clicking join on a different
# pair doesn't silently commit the previous tuning.
if props is not None and props.is_active:
bpy.ops.bim.cancel_bend_preview()
props.start_segment_id = active_element.id()
props.end_segment_id = other_element.id()
props.start_length = 0.1
props.end_length = 0.1
props.radius = 0.2
props.is_active = True
return {"FINISHED"}
class FinishBendPreview(bpy.types.Operator):
"""Commit the previewed bend with the tuned parameters and exit preview.
Preview state survives a failed commit so the user can re-tune without
re-selecting."""
bl_idname = "bim.finish_bend_preview"
bl_label = "Apply Bend"
bl_description = "Commit the bend with the previewed parameters"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
return preview_base.commit_preview(
self,
context,
"bend",
"mep_add_bend",
("start_segment_id", "end_segment_id", "start_length", "end_length", "radius", "editing_bend_id"),
)
class CancelBendPreview(bpy.types.Operator):
"""Exit bend preview without committing."""
bl_idname = "bim.cancel_bend_preview"
bl_label = "Cancel Bend"
bl_description = "Discard the previewed bend"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
if context.screen is None:
return {"CANCELLED"}
props = preview_base.get_preview_props(context, "bend")
if props is None or not props.is_active:
return {"CANCELLED"}
preview_base.clear_preview_state(props)
return {"FINISHED"}
class EnableBendPreviewFromBend(bpy.types.Operator):
"""Re-open the bend preview on an existing bend fitting.
Resolves the two connected segments via the bend's ports +
``IfcRelConnectsPorts``, reads parametric values back from the bend's
``BBIM_Fitting`` pset, and flags the preview so committing replaces
the existing bend in place."""
bl_idname = "bim.enable_bend_preview_from_bend"
bl_label = "Edit Bend"
bl_description = "Re-open the bend preview to retune an existing bend"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
active = context.active_object
if active is None:
cls.poll_message_set("No active object.")
return False
element = tool.Ifc.get_entity(active)
if element is None or not _is_bend_fitting(element):
cls.poll_message_set("Active object must be a bend fitting.")
return False
return True
def execute(self, context):
active = context.active_object
bend_element = tool.Ifc.get_entity(active)
if bend_element is None or not _is_bend_fitting(bend_element):
self.report({"ERROR"}, "Active object is not a bend fitting.")
return {"CANCELLED"}
connected_segments: list = []
for port in tool.System.get_ports(bend_element):
connected_port = tool.System.get_connected_port(port)
if connected_port is None:
continue
related = tool.System.get_port_relating_element(connected_port)
if related is not None and related.is_a("IfcFlowSegment") and related not in connected_segments:
connected_segments.append(related)
if len(connected_segments) != 2:
self.report(
{"ERROR"},
f"Bend has {len(connected_segments)} connected segments; need exactly 2 to re-edit.",
)
return {"CANCELLED"}
# Read parametric values from the bend type's BBIM_Fitting pset. The
# type carries the canonical parameters; querying the occurrence
# would force a get_type round-trip and miss user-edited types.
bend_type = ifcopenshell.util.element.get_type(bend_element)
if bend_type is None:
self.report({"ERROR"}, "Bend fitting has no type to read parameters from.")
return {"CANCELLED"}
bend_type_obj = tool.Ifc.get_object(bend_type)
if bend_type_obj is None:
self.report({"ERROR"}, "Bend type has no Blender object — cannot read pset.")
return {"CANCELLED"}
bbim = tool.Model.get_modeling_bbim_pset_data(bend_type_obj, "BBIM_Fitting")
if bbim is None:
self.report({"ERROR"}, "Bend fitting has no BBIM_Fitting pset — not a parametric bend.")
return {"CANCELLED"}
data = bbim.get("data_dict", {})
props = preview_base.get_preview_props(context, "bend")
if props is not None and props.is_active:
bpy.ops.bim.cancel_bend_preview()
# Segment order is load-bearing: the bend's lateral sign and z-axis
# flip are derived from which segment is "start" vs "end". Re-edit
# must reuse the same pairing as the original create so the recreate
# lands at the same orientation.
start_segment, end_segment = connected_segments
props.start_segment_id = start_segment.id()
props.end_segment_id = end_segment.id()
# Pset values are in IFC native units; scene units come from si_conversion.
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
props.start_length = float(data.get("start_length", 0.1)) * si_conversion
props.end_length = float(data.get("end_length", 0.1)) * si_conversion
props.radius = float(data.get("radius", 0.2)) * si_conversion
props.editing_bend_id = bend_element.id()
props.is_active = True
return {"FINISHED"}
def _bend_preview_segments(context):
"""Resolve the two segment objects from the scene-level preview props.
Re-resolves by IFC id each frame so undo / file reload during preview
never dangles a stale bpy reference."""
props = context.scene.BIMPreviewProperties.bend
ifc_file = tool.Ifc.get()
if ifc_file is None or not props.is_active:
return None, None
try:
start_element = ifc_file.by_id(props.start_segment_id)
end_element = ifc_file.by_id(props.end_segment_id)
except Exception:
return None, None
start_obj = tool.Ifc.get_object(start_element) if start_element else None
end_obj = tool.Ifc.get_object(end_element) if end_element else None
return start_obj, end_obj
def _gizmo_x_matrix(location: Vector, x_direction: Vector) -> Matrix:
"""Build a 4x4 matrix placing a gizmo at ``location`` with its local +X
axis aligned to ``x_direction`` in world space. ``BIM_GT_gizmo_dimension``
draws + drags along local +X by convention."""
x = x_direction.normalized()
seed = Vector((0, 0, 1)) if abs(x.z) < 0.9 else Vector((1, 0, 0))
y = (seed - x * seed.dot(x)).normalized()
z = x.cross(y)
mat = Matrix.Identity(4)
mat[0][:3] = (x.x, y.x, z.x)
mat[1][:3] = (x.y, y.y, z.y)
mat[2][:3] = (x.z, y.z, z.z)
mat.translation = location
return mat
class GizmoBendPreview(bpy.types.GizmoGroup):
"""Interactive gizmo group for the bend preview flow.
Three dimension widgets drag start_length / end_length / radius; two
icon gizmos commit or cancel. When the geometry is degenerate the
dimensions and validate hide but cancel stays visible so the user
always has an exit."""
bl_idname = "OBJECT_GGT_bim_bend_preview"
bl_label = "Bend Preview Gizmos"
bl_space_type = "VIEW_3D"
bl_region_type = "WINDOW"
bl_options = {"3D", "PERSISTENT"}
ICON_SCALE: ClassVar[float] = 0.375
ICON_SPACING_X: ClassVar[float] = 0.4
ICON_Z_OFFSET: ClassVar[float] = 1.5
@classmethod
def poll(cls, context):
preview = getattr(context.scene, "BIMPreviewProperties", None)
props = preview.bend if preview is not None else None
if props is None or not props.is_active:
return False
if not tool.Blender.are_viewport_gizmos_enabled():
return False
ifc_file = tool.Ifc.get()
if ifc_file is None:
return False
try:
ifc_file.by_id(props.start_segment_id)
ifc_file.by_id(props.end_segment_id)
except (RuntimeError, KeyError):
return False
return True
def setup(self, context):
prefs = tool.Blender.get_addon_preferences()
default_color = tuple(prefs.decorations_colour[:3])
highlight_color = tuple(prefs.decorator_color_selected[:3])
_props = preview_base.make_props_callback("bend")
def setup_dimension(attr: str, prop_name: str, invert_delta: bool = False) -> bpy.types.Gizmo:
gz = self.gizmos.new("BIM_GT_gizmo_dimension")
gz.move_get_cb = preview_base.make_dim_getter(_props, attr)
gz.move_set_cb = preview_base.make_dim_setter(_props, attr)
gz.axis = Vector((1, 0, 0))
gz.invert_delta = invert_delta
gz.delta_scale = 1.0
gz.prop_name = prop_name
gz.gizmo_group = self
gz.color = default_color
gz.color_highlight = highlight_color
gz.alpha = 1.0
gz.use_draw_modal = True
gz.use_draw_scale = False
gz.text_offset_sign = 1
gz.text_alignment = gizmo.TextAlignment.CENTER
gz.show_start_arrow = False
gz.show_end_arrow = True
gz.show_extension_lines = False
gz.text_formatter = None
return gz
self.start_dim = setup_dimension("start_length", "Start Length")
self.end_dim = setup_dimension("end_length", "End Length")
self.radius_dim = setup_dimension("radius", "Radius")
from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup
self.validate_icon = self.gizmos.new("VIEW3D_GT_validate")
self.validate_icon.use_draw_scale = False
self.validate_icon.color = BaseParametricGizmoGroup.COLOR_GREEN
self.validate_icon.color_highlight = highlight_color
self.validate_icon.target_set_operator("bim.finish_bend_preview")
self.cancel_icon = self.gizmos.new("VIEW3D_GT_cancel")
self.cancel_icon.use_draw_scale = False
self.cancel_icon.color = BaseParametricGizmoGroup.COLOR_RED
self.cancel_icon.color_highlight = highlight_color
self.cancel_icon.target_set_operator("bim.cancel_bend_preview")
def refresh(self, context):
self._position_gizmos(context)
def draw_prepare(self, context):
self._position_gizmos(context)
def _position_gizmos(self, context):
"""Place gizmos at the bend intersection using the current scene
props. Cancel stays visible on degenerate geometry so the user
always has an exit; the other widgets hide when there's no defined
tangent / arc to anchor them on."""
start_obj, end_obj = _bend_preview_segments(context)
if start_obj is None or end_obj is None:
for gz in (self.start_dim, self.end_dim, self.radius_dim, self.validate_icon, self.cancel_icon):
gz.hide = True
return
props = context.scene.BIMPreviewProperties.bend
preview = cached_compute_bend_preview_polylines(
start_obj, end_obj, props.start_length, props.end_length, props.radius
)
if not preview["valid"]:
for gz in (self.start_dim, self.end_dim, self.radius_dim, self.validate_icon):
gz.hide = True
self.cancel_icon.hide = False
axes = preview.get("invalid_axes") or []
if axes:
intersection_point = axes[0][1]
billboard_rot = gizmo.get_billboard_rotation(context)
anchor = intersection_point + Vector((0, 0, self.ICON_Z_OFFSET))
self.cancel_icon.matrix_basis = gizmo.billboarded_at(anchor, billboard_rot, scale=self.ICON_SCALE)
return
for gz in (self.start_dim, self.end_dim, self.radius_dim, self.validate_icon, self.cancel_icon):
gz.hide = False
leg_a_far, leg_a_end = preview["leg_a"]
leg_b_far, leg_b_end = preview["leg_b"]
toward_bend_a = (
(leg_a_end - leg_a_far).normalized() if (leg_a_end - leg_a_far).length > 1e-6 else Vector((0, 0, 1))
)
toward_bend_b = (
(leg_b_end - leg_b_far).normalized() if (leg_b_end - leg_b_far).length > 1e-6 else Vector((0, 0, 1))
)
leg_a_tangent = leg_a_end + toward_bend_a * props.start_length
leg_b_tangent = leg_b_end + toward_bend_b * props.end_length
# axis is set in world space every frame so the drag projection
# matches the visual regardless of either segment's matrix_world.
self.start_dim.matrix_basis = _gizmo_x_matrix(leg_a_tangent, -toward_bend_a)
self.start_dim.axis = -toward_bend_a
self.start_dim.set_dimension_length(props.start_length)
self.end_dim.matrix_basis = _gizmo_x_matrix(leg_b_tangent, -toward_bend_b)
self.end_dim.axis = -toward_bend_b
self.end_dim.set_dimension_length(props.end_length)
arc = preview["arc"]
if len(arc) >= 3:
mid = len(arc) // 2
chord_mid = (arc[0] + arc[-1]) * 0.5
toward_mid = arc[mid] - chord_mid
if toward_mid.length > 1e-6:
toward_mid = toward_mid.normalized()
half_chord = (arc[-1] - arc[0]).length * 0.5
center_dist = max(0.0, props.radius * props.radius - half_chord * half_chord) ** 0.5
arc_center = chord_mid - toward_mid * center_dist
radial_out = arc[mid] - arc_center
if radial_out.length > 1e-6:
radial_out.normalize()
inward = -radial_out
self.radius_dim.matrix_basis = _gizmo_x_matrix(arc[mid], inward)
self.radius_dim.axis = inward
self.radius_dim.set_dimension_length(props.radius)
else:
self.radius_dim.hide = True
else:
self.radius_dim.hide = True
else:
self.radius_dim.hide = True
billboard_rot = gizmo.get_billboard_rotation(context)
anchor_base = arc[len(arc) // 2] if arc else (leg_a_end + leg_b_end) * 0.5
anchor = anchor_base + Vector((0, 0, self.ICON_Z_OFFSET))
offset_x = billboard_rot @ Vector((self.ICON_SPACING_X, 0.0, 0.0))
self.validate_icon.matrix_basis = gizmo.billboarded_at(anchor, billboard_rot, scale=self.ICON_SCALE)
self.cancel_icon.matrix_basis = gizmo.billboarded_at(anchor + offset_x, billboard_rot, scale=self.ICON_SCALE)
+95 -18
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 collections.abc import Sequence
from math import radians
@@ -207,6 +209,21 @@ def _get_cached_world_draw_data(
# handle each call), so they stay drawable across frames.
_batch_cache: dict[tuple[int, str], tuple[int, "gpu.types.GPUBatch"]] = {}
# CAD hidden-line convention for the occluded back-pass: world-space dashes so
# density stays coherent across zoom. Dash + gap = period; dash_width controls
# the "on" portion.
_DASH_PERIOD_METERS: float = 0.20
_DASH_WIDTH_METERS: float = 0.10
# Solid front pass is rendered wider than the dashed back pass so its halo
# overpowers the dashed center on visible edges even when the WIRE-display
# overlay biases the depth buffer at outline pixels.
_DASH_LINE_WIDTH: float = 1.5
_SOLID_LINE_WIDTH: float = 2.5
# Per-iteration default line width used by every non-occlusion draw call in
# this decorator's ``__call__``. Restored after each occlusion pair so the
# next draw isn't silently inheriting the wider solid-pass override.
_DEFAULT_LINE_WIDTH: float = 2.0
def _get_cached_batch_or_none(cache_key: tuple[int, str]) -> "gpu.types.GPUBatch | None":
uid = cache_key[0]
@@ -229,9 +246,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()
@@ -253,7 +276,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()
@@ -737,6 +760,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"
@@ -1160,22 +1206,55 @@ class DecorationsHandler:
shader.uniform_float("color", color)
batch.draw(shader)
def _draw_lines_with_occlusion(self, verts, color, edges_indices, occluded_alpha: float = 0.25, cache_key=None):
# One batch, two draws: front pass at full color, occluded pass at
# `occluded_alpha`. Save/restore depth_test matches the pattern in
# bim/module/structural/decorator.py so callers' state survives.
batch = self._get_or_build_batch(self.line_shader, "LINES", verts, edges_indices, cache_key=cache_key)
if batch is None:
def _draw_lines_with_occlusion(self, verts, color, edges_indices, cache_key=None):
# Two-pass CAD hidden-line convention. Both passes use POLYLINE_UNIFORM_COLOR.
#
# The solid front pass is rendered WIDER than the dashed back pass so it
# produces a halo around the line center, beyond the depth-bias zone that
# Blender's overlay engine writes when an opening is set to WIRE display.
# Without the width difference, the wire bias makes the center-pixel
# ``LESS_EQUAL`` comparison fail (line ends up slightly behind the biased
# wire depth) so the solid pass would lose to the dashed back pass even
# on visible edges. The halo gives the solid pass enough screen-space to
# overpower the dashed pattern visually.
#
# Dashed renders first at the standard width so the solid overlay's wider
# halo cleanly hides it on visible edges; on occluded edges the solid
# ``LESS_EQUAL`` pass fails against the wall depth and the dashed remains.
front_batch = self._get_or_build_batch(self.line_shader, "LINES", verts, edges_indices, cache_key=cache_key)
if front_batch is None:
return
dashed_cache_key = (cache_key[0], cache_key[1] + "_dashed") if cache_key is not None else None
dash_batch = None
if dashed_cache_key is not None:
dash_batch = _get_cached_batch_or_none(dashed_cache_key)
if dash_batch is None:
dash_verts, dash_edges = tool.Blender.build_dashed_line_segments(
verts, edges_indices, _DASH_PERIOD_METERS, _DASH_WIDTH_METERS
)
dash_batch = self._get_or_build_batch(self.line_shader, "LINES", dash_verts, dash_edges)
if dash_batch is not None and dashed_cache_key is not None:
_store_batch_in_cache(dashed_cache_key, dash_batch)
original_depth_test = gpu.state.depth_test_get()
front_color = list(color)
front_color[3] = 1.0
self.line_shader.uniform_float("color", front_color)
if dash_batch is not None:
self.line_shader.uniform_float("lineWidth", _DASH_LINE_WIDTH)
gpu.state.depth_test_set("ALWAYS")
dash_batch.draw(self.line_shader)
self.line_shader.uniform_float("lineWidth", _SOLID_LINE_WIDTH)
gpu.state.depth_test_set("LESS_EQUAL")
self.line_shader.uniform_float("color", color)
batch.draw(self.line_shader)
gpu.state.depth_test_set("GREATER")
dimmed = list(color)
dimmed[3] = occluded_alpha
self.line_shader.uniform_float("color", dimmed)
batch.draw(self.line_shader)
front_batch.draw(self.line_shader)
# Restore the per-iteration default set at the top of __call__ so
# subsequent draws (the HalfSpaceSolid arrow, future call-sites) are
# not silently affected by the front-pass width override.
self.line_shader.uniform_float("lineWidth", _DEFAULT_LINE_WIDTH)
gpu.state.depth_test_set(original_depth_test)
def __call__(self, context):
@@ -1211,7 +1290,7 @@ class DecorationsHandler:
self.line_shader.bind() # required to be able to change uniforms of the shader
# POLYLINE_UNIFORM_COLOR specific uniforms
self.line_shader.uniform_float("viewportSize", (context.region.width, context.region.height))
self.line_shader.uniform_float("lineWidth", 2.0)
self.line_shader.uniform_float("lineWidth", _DEFAULT_LINE_WIDTH)
# general shader
self.shader = gpu.shader.from_builtin("UNIFORM_COLOR")
@@ -1249,9 +1328,7 @@ class DecorationsHandler:
self.draw_batch("LINES", verts, selected_elements_color, selected_edges)
self.draw_batch("POINTS", unselected_vertices, unselected_elements_color)
self.draw_batch("POINTS", selected_vertices, selected_elements_color)
obj.data.calc_loop_triangles()
tris = [tuple(t.vertices) for t in obj.data.loop_triangles]
self.draw_batch("TRIS", verts, transparent_color(special_elements_color), tris)
tool.Blender.draw_bmesh_face_tris(bm, verts, transparent_color(special_elements_color), self.draw_batch)
else:
line_verts, verts, edges_indices, tris = _get_cached_world_draw_data(obj)
color = selected_elements_color if obj in context.selected_objects else special_elements_color
@@ -163,6 +163,59 @@ def sync_uncommitted_moves(objects: list) -> None:
tool.Geometry.commit_placement_if_moved(obj, apply_scale=False)
def clear_preview_state(props: bpy.types.PropertyGroup) -> None:
"""Reset a preview PropertyGroup to its idle state on commit / cancel.
Sets ``is_active`` to False and zeros every ``IntProperty`` whose name
ends in ``_id`` (the entity-reference convention every preview follows).
Other fields are left at their last value defaults are re-applied on
the next enable, so leaving them alone avoids a redundant write."""
props.is_active = False
for name, rna in props.bl_rna.properties.items():
if name.endswith("_id") and rna.type == "INT":
setattr(props, name, 0)
# --- Standard Finish flow ----------------------------------------------------
def commit_preview(
operator: bpy.types.Operator,
context: bpy.types.Context,
attr: str,
target_op_name: str,
kwarg_names: tuple[str, ...],
) -> set[str]:
"""Standard Finish-Preview dispatch: validate context + active preview,
read kwargs off the draft, call ``bpy.ops.bim.<target_op_name>(**kwargs)``,
and clear the preview on success.
The dispatched operator's own ``self.report({"ERROR"})`` paths are promoted
by ``bpy.ops`` to ``RuntimeError`` catching it here surfaces the message
to the user via ``operator.report`` rather than leaving Blender's operator
state half-broken (which silently disables downstream gizmo polls).
Returns the dispatched operator's result set verbatim so callers can
pass it straight back from their own ``execute``."""
if context.screen is None:
return {"CANCELLED"}
props = get_preview_props(context, attr)
if props is None or not props.is_active:
return {"CANCELLED"}
if tool.Ifc.get() is None:
operator.report({"ERROR"}, "No IFC file loaded.")
return {"CANCELLED"}
kwargs = {name: getattr(props, name) for name in kwarg_names}
try:
result = getattr(bpy.ops.bim, target_op_name)(**kwargs)
except RuntimeError as exc:
operator.report({"ERROR"}, str(exc))
return {"CANCELLED"}
if "FINISHED" in result:
clear_preview_state(props)
return result
# --- Esc dispatch ------------------------------------------------------------
PREVIEW_CANCEL_OPS: tuple[tuple[str, str], ...] = (
@@ -1157,7 +1157,8 @@ class DrawPolylineProfile(bpy.types.Operator, PolylineOperator, tool.Ifc.Operato
DumbProfileJoiner().join_V(profile2["obj"], profile1["obj"])
if connect_IfcFlowSegments:
bpy.ops.bim.mep_connect_elements(
obj1_name=profile1["obj"].name, obj2_name=profile2["obj"].name
obj1_guid=tool.Ifc.get_entity(profile1["obj"]).GlobalId,
obj2_guid=tool.Ifc.get_entity(profile2["obj"]).GlobalId,
)
def modal(self, context, event):
+210 -5
View File
@@ -103,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:
@@ -238,6 +242,20 @@ def update_roof(self: "BIMRoofProperties", context: bpy.types.Context) -> None:
_get_updater("roof", "update_roof_modifier_bmesh")(obj)
def update_pipe_segment(self: "BIMPipeSegmentProperties", context: bpy.types.Context) -> None:
"""Regenerate pipe-segment preview mesh from props during edit. Does NOT touch IFC."""
obj = context.active_object
if obj and self.is_editing:
_get_updater("mep", "regenerate_pipe_segment_mesh_from_props")(obj)
def update_duct_segment(self: "BIMDuctSegmentProperties", context: bpy.types.Context) -> None:
"""Regenerate duct-segment preview mesh from props during edit. Does NOT touch IFC."""
obj = context.active_object
if obj and self.is_editing:
_get_updater("mep", "regenerate_duct_segment_mesh_from_props")(obj)
class BIMModelProperties(PropertyGroup):
ifc_class: bpy.props.EnumProperty(items=get_ifc_class, name="Construction Class", update=update_ifc_class)
relating_type_id: bpy.props.EnumProperty(
@@ -397,8 +415,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")
@@ -414,6 +437,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",
@@ -422,13 +454,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]
@@ -1904,6 +1938,155 @@ class BIMExternalParametricGeometryProperties(bpy.types.PropertyGroup):
sverchok_nodes: Union[sverchok.node_tree.SverchCustomTree, None]
class BIMPipeSegmentProperties(PropertyGroup):
"""Transient draft state for parametric pipe-segment gizmo editing."""
is_editing: bpy.props.BoolProperty(
default=False,
description="True while pipe-segment 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 shape; 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_pipe_segment,
description="Pipe-segment extrusion length (preview value; committed on finish).",
)
snap_length: bpy.props.FloatProperty(
description="Snapshot of length at edit-enable; commit skips no-op writes.",
)
snap_object_scale_z: bpy.props.FloatProperty(
default=1.0,
description=(
"Snapshot of obj.scale.z at edit-enable. Cancel / no-op-finish restore "
"this exact value so a user's non-identity pre-edit scale isn't silently "
"zeroed by the scale-based preview."
),
)
if TYPE_CHECKING:
is_editing: bool
mesh_dirty: bool
length: float
snap_length: float
snap_object_scale_z: float
class BIMDuctSegmentProperties(PropertyGroup):
"""Transient draft state for parametric duct-segment gizmo editing."""
is_editing: bpy.props.BoolProperty(
default=False,
description="True while duct-segment 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 shape; 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_duct_segment,
description="Duct-segment extrusion length (preview value; committed on finish).",
)
snap_length: bpy.props.FloatProperty(
description="Snapshot of length at edit-enable; commit skips no-op writes.",
)
snap_object_scale_z: bpy.props.FloatProperty(
default=1.0,
description=(
"Snapshot of obj.scale.z at edit-enable. Cancel / no-op-finish restore "
"this exact value so a user's non-identity pre-edit scale isn't silently "
"zeroed by the scale-based preview."
),
)
if TYPE_CHECKING:
is_editing: bool
mesh_dirty: bool
length: float
snap_length: float
snap_object_scale_z: float
class BIMBendPreviewProperties(PropertyGroup):
"""Scene-level pending state for the bend-creation preview flow.
Scene-level (not per-object) because the bend involves two segments by
IFC id neither alone owns the draft."""
is_active: bpy.props.BoolProperty(
default=False,
options={"SKIP_SAVE"},
description="True while the bend-creation preview flow is active.",
)
start_segment_id: bpy.props.IntProperty(
default=0,
options={"SKIP_SAVE"},
description="IFC element id of the start (active) segment.",
)
end_segment_id: bpy.props.IntProperty(
default=0,
options={"SKIP_SAVE"},
description="IFC element id of the end (other selected) segment.",
)
start_length: bpy.props.FloatProperty(
name="Start Length",
default=0.1,
min=0.001,
subtype="DISTANCE",
description="Length of the bend fitting's tangent leg on the start (active) segment side",
)
end_length: bpy.props.FloatProperty(
name="End Length",
default=0.1,
min=0.001,
subtype="DISTANCE",
description="Length of the bend fitting's tangent leg on the end (other) segment side",
)
radius: bpy.props.FloatProperty(
name="Radius",
default=0.2,
min=0.001,
subtype="DISTANCE",
description="Inner radius of the bend curve",
)
editing_bend_id: bpy.props.IntProperty(
default=0,
options={"SKIP_SAVE"},
description=(
"IFC element id of an existing bend fitting being re-edited "
"(non-zero only on the pen-icon re-edit flow). The create "
"operator deletes this bend + its port connections before "
"recreating with the new parameters."
),
)
if TYPE_CHECKING:
is_active: bool
start_segment_id: int
end_segment_id: int
start_length: float
end_length: float
radius: float
editing_bend_id: int
class BIMWallFilletPreviewProperties(PropertyGroup):
"""Scene-level pending state for the wall-fillet preview flow.
@@ -1960,7 +2143,29 @@ class BIMWallFilletPreviewProperties(PropertyGroup):
class BIMPreviewProperties(PropertyGroup):
"""Umbrella for parametric-edit preview drafts attached to ``Scene``."""
bend: bpy.props.PointerProperty(type=BIMBendPreviewProperties)
wall_fillet: bpy.props.PointerProperty(type=BIMWallFilletPreviewProperties)
if TYPE_CHECKING:
bend: BIMBendPreviewProperties
wall_fillet: BIMWallFilletPreviewProperties
class BIMParametricEditDialogPrefs(PropertyGroup):
"""Session-scoped flag for the parametric-edit pen-icon dispatcher.
Attached to ``WindowManager`` so the state lives for one Blender session
and resets on restart the right scope for "don't show this again for
this session" toggles."""
suppress_shared_rep_warning: bpy.props.BoolProperty(
name="Suppress shared-representation warning",
description=(
"When true, the pen-icon dispatcher skips the shared-geometry "
"confirmation dialog. Resets on Blender restart."
),
default=False,
)
if TYPE_CHECKING:
suppress_shared_rep_warning: bool
@@ -415,7 +415,7 @@ class _RailingEditMixin(PathPreservingEditMixin):
@classmethod
def _is_element_type(cls, element):
return tool.Blender.Modifier.is_railing(element)
return tool.Parametric.is_railing(element)
@classmethod
def _get_props(cls, obj: bpy.types.Object):
+145 -22
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,9 +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 PathPreservingEditMixin
from bonsai.bim.parametric_lifecycle import CycleTypeMixin, PathPreservingEditMixin
# reference:
# https://ifc43-docs.standards.buildingsmart.org/IFC/RELEASE/IFC4x3/HTML/lexical/IfcRoof.htm
@@ -211,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()
@@ -397,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
@@ -618,7 +631,7 @@ class _RoofEditMixin(PathPreservingEditMixin):
@classmethod
def _is_element_type(cls, element):
return tool.Blender.Modifier.is_roof(element)
return tool.Parametric.is_roof(element)
@classmethod
def _get_props(cls, obj: bpy.types.Object):
@@ -636,32 +649,142 @@ class _RoofEditMixin(PathPreservingEditMixin):
def _update_modifier_bmesh(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
update_roof_modifier_bmesh(obj)
@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 EnableEditingRoof(_RoofEditMixin, bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.enable_editing_roof"
bl_label = "Enable Editing Roof"
EnableEditingRoof, FinishEditingRoof, CancelEditingRoof = tool.Parametric.build_edit_lifecycle(
"roof",
_RoofEditMixin,
labels=(
("Enable Editing Roof", ""),
("Finish Editing Roof", ""),
("Cancel Editing Roof", ""),
),
module_name=__name__,
)
# 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
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"}
def _execute(self, context):
return self._enable_targets(context)
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 CancelEditingRoof(_RoofEditMixin, bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.cancel_editing_roof"
bl_label = "Cancel Editing Roof"
bl_options = {"REGISTER", "UNDO"}
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"}
def _execute(self, context):
return self._cancel_targets(context)
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.
),
]
class FinishEditingRoof(_RoofEditMixin, bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.finish_editing_roof"
bl_label = "Finish Editing Roof"
bl_options = {"REGISTER", "UNDO"}
props_getter = tool.Model.get_roof_props
gizmo_pref_name = "roof"
def _execute(self, context):
return self._finish_targets(context)
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):
+138 -56
View File
@@ -31,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,
@@ -39,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
@@ -378,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."""
@@ -423,11 +443,11 @@ 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 = tool.Model.get_stair_props
@@ -436,7 +456,7 @@ class CycleStairType(bpy.types.Operator, gizmo.CycleTypeMixin):
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
@@ -462,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.
@@ -585,28 +632,31 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
@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",
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",
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" # noqa: ARG002
@@ -618,30 +668,43 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
self.update_tread_count_gizmos(props)
def update_lock_gizmo(self, props: "BIMStairProperties") -> None:
"""Update lock gizmo color and visibility. Positioning is handled
per-frame by the dimension-positioning hook."""
gizmo_prefs = self.get_gizmo_prefs()
if not self.update_gizmo_visibility(self.lock_gizmo, props.is_editing, gizmo_prefs.lock):
return # Hidden, skip color update
self.lock_gizmo.color = self.COLOR_RED if props.total_length_lock else self.COLOR_GREEN
"""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" # noqa: ARG002
@@ -719,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,
@@ -734,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,
)
+4 -4
View File
@@ -238,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)
@@ -365,7 +365,7 @@ class BIM_PT_wall(bpy.types.Panel):
if not obj:
return False
element = tool.Ifc.get_entity(obj)
return bool(element) and tool.Blender.Modifier.is_wall(element)
return bool(element) and tool.Parametric.is_wall(element)
def draw(self, context):
obj = context.active_object
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),
),
]
+11 -9
View File
@@ -39,7 +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.parametric_lifecycle import FeatureModifierEditMixin
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
@@ -491,7 +492,7 @@ class _WindowEditMixin(FeatureModifierEditMixin):
@classmethod
def _is_element_type(cls, element):
return tool.Blender.Modifier.is_window(element)
return tool.Parametric.is_window(element)
@classmethod
def _get_props(cls, obj: bpy.types.Object):
@@ -551,11 +552,11 @@ 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 = tool.Parametric.is_window
@@ -564,7 +565,7 @@ class CycleWindowType(bpy.types.Operator, tool.Ifc.Operator, gizmo.CycleTypeMixi
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
@@ -602,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 = [
@@ -743,6 +744,7 @@ 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 = tool.Model.get_window_props
@@ -750,7 +752,7 @@ class GizmoWindowEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
@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.
@@ -969,7 +969,9 @@ class EditObjectUI:
if PortData.data["total_ports"] > 0:
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)
add_layout_hotkey_operator(
row, "Regen", "S_G", bpy.ops.bim.regenerate_distribution_element.__doc__, ui_context
)
@classmethod
def draw_void(cls, context, row):
@@ -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:
@@ -28,6 +28,10 @@ classes = (
operator.AppendLibraryElementByQuery,
operator.AssignLibraryDeclaration,
operator.BIM_FH_import_ifc,
operator.BIM_OT_apply_pending_opening_cuts,
operator.BIM_OT_dismiss_multi_instance_warning,
operator.BIM_OT_dismiss_pending_opening_cuts,
operator.BIM_OT_select_pending_opening_cuts,
operator.BIM_OT_load_clipping_planes,
operator.BIM_OT_save_clipping_planes,
operator.ChangeLibraryElement,
@@ -82,6 +86,7 @@ classes = (
prop.FilterCategory,
prop.Link,
prop.EditedObj,
prop.PendingOpeningRecut,
prop.BIMProjectProperties,
prop.MeasureToolSettings,
ui.BIM_MT_new_project,
@@ -1223,6 +1223,19 @@ class LoadProjectElements(bpy.types.Operator):
props = tool.Project.get_project_props()
props.is_loading = False
# Stash elements the kernel skipped opening cuts on (HasOpenings > void_limit).
# The Project panel banner offers the user a one-click recut.
props.pending_opening_recut.clear()
if ifc_importer.gross_elements:
for element in ifc_importer.gross_elements:
item = props.pending_opening_recut.add()
item.ifc_definition_id = element.id()
self.report(
{"WARNING"},
f"{len(ifc_importer.gross_elements)} element(s) had too many openings and were loaded without cuts. "
f"Apply manually from the Project panel.",
)
tool.Project.load_default_thumbnails()
tool.Project.set_default_context()
tool.Project.set_default_modeling_dimensions()
@@ -3421,3 +3434,108 @@ class GenerateUVMap(bpy.types.Operator):
tool.Loader.load_generated_uv_map(obj.data)
self.report({"INFO"}, "Generated UV map for selected mesh.")
return {"FINISHED"}
class BIM_OT_apply_pending_opening_cuts(bpy.types.Operator, tool.Ifc.Operator):
"""Recompute the wall mesh including opening subtractions for every host
that the load-time ``void_limit`` filter skipped. Clears the deferred
list on completion so the panel banner disappears."""
bl_idname = "bim.apply_pending_opening_cuts"
bl_label = "Apply Pending Opening Cuts"
bl_description = (
"Recompute meshes for elements whose openings were skipped at load because they had too many openings"
)
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context: bpy.types.Context) -> set[str]:
pending = tool.Project.get_project_props().pending_opening_recut
applied = 0
skipped = 0
failed = 0
for item in pending:
try:
element = tool.Ifc.get().by_id(item.ifc_definition_id)
except RuntimeError:
skipped += 1
continue
obj = tool.Ifc.get_object(element)
if obj is None:
skipped += 1
continue
body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
if body is None:
skipped += 1
continue
try:
tool.Geometry.reimport_element_representations(obj, body, apply_openings=True)
applied += 1
except (RuntimeError, OSError, AttributeError) as exc:
# Programmer errors (TypeError, ValueError, etc.) must surface — don't swallow them.
failed += 1
print(f"apply_pending_opening_cuts: failed to recompute {element} ({exc})")
pending.clear()
message = f"Applied opening cuts to {applied} element(s)."
if skipped:
message += f" {skipped} entry/entries skipped (entity or object no longer available)."
if failed:
message += f" {failed} entry/entries failed (see system console)."
self.report({"WARNING"}, message)
else:
self.report({"INFO"}, message)
return {"FINISHED"}
class BIM_OT_dismiss_pending_opening_cuts(bpy.types.Operator):
bl_idname = "bim.dismiss_pending_opening_cuts"
bl_label = "Dismiss Pending Opening Cuts"
bl_description = "Clear the pending opening-cut list without applying it. Walls stay solid where openings would have been subtracted."
bl_options = {"REGISTER", "UNDO"}
def execute(self, context: bpy.types.Context) -> set[str]:
tool.Project.get_project_props().pending_opening_recut.clear()
return {"FINISHED"}
class BIM_OT_dismiss_multi_instance_warning(bpy.types.Operator):
bl_idname = "bim.dismiss_multi_instance_warning"
bl_label = "Dismiss Multi-Instance Warning"
bl_description = (
"Hide the warning that another Blender instance has this IFC file open. Sticky for the current session."
)
bl_options = {"REGISTER"}
def execute(self, context: bpy.types.Context) -> set[str]:
from bonsai.bim.ifc import dismiss_multi_instance_warning
dismiss_multi_instance_warning()
return {"FINISHED"}
class BIM_OT_select_pending_opening_cuts(bpy.types.Operator):
bl_idname = "bim.select_pending_opening_cuts"
bl_label = "Select Elements With Skipped Opening Cuts"
bl_description = "Select the Blender objects whose openings were skipped at load. Useful for locating which elements need attention."
bl_options = {"REGISTER", "UNDO"}
def execute(self, context: bpy.types.Context) -> set[str]:
ifc_file = tool.Ifc.get()
if ifc_file is None:
self.report({"INFO"}, "No IFC file loaded.")
return {"CANCELLED"}
objects: list[bpy.types.Object] = []
for item in tool.Project.get_project_props().pending_opening_recut:
try:
element = ifc_file.by_id(item.ifc_definition_id)
except RuntimeError:
continue
obj = tool.Ifc.get_object(element)
if obj is not None:
objects.append(obj)
if not objects:
self.report({"INFO"}, "No matching Blender objects found for the pending list.")
return {"CANCELLED"}
tool.Blender.set_objects_selection(context, active_object=objects[0], selected_objects=objects)
self.report({"INFO"}, f"Selected {len(objects)} element(s).")
return {"FINISHED"}
@@ -295,6 +295,17 @@ class LibraryBreadcrumb(PropertyGroup):
library_id: int
class PendingOpeningRecut(PropertyGroup):
"""One element whose ``HasOpenings`` exceeded ``void_limit`` at load time
and was imported without opening subtractions. The user can later apply
them on demand from the Project panel banner."""
ifc_definition_id: IntProperty(name="IFC Definition ID")
if TYPE_CHECKING:
ifc_definition_id: int
class BIMProjectProperties(PropertyGroup):
is_editing: BoolProperty(name="Is Editing", default=False)
is_loading: BoolProperty(name="Is Loading", default=False)
@@ -360,6 +371,7 @@ class BIMProjectProperties(PropertyGroup):
default=30,
description="Maxium number of openings that object can have. If object has more openings, it will be loaded without openings",
)
pending_opening_recut: CollectionProperty(name="Pending Opening Recut", type=PendingOpeningRecut)
style_limit: IntProperty(
name="Style Limit",
default=300,
@@ -525,6 +537,7 @@ class BIMProjectProperties(PropertyGroup):
deflection_tolerance: float
angular_tolerance: float
void_limit: int
pending_opening_recut: bpy.types.bpy_prop_collection_idprop[PendingOpeningRecut]
style_limit: int
distance_limit: float
false_origin_mode: Literal["AUTOMATIC", "MANUAL", "DISABLED"]
+31 -1
View File
@@ -28,8 +28,9 @@ from bpy.types import Menu, Panel, UIList
import bonsai.bim
import bonsai.tool as tool
from bonsai.bim.helper import draw_attributes, prop_with_search
from bonsai.bim.ifc import IfcStore
from bonsai.bim.ifc import IfcStore, is_cache_locked_by_other_process
from bonsai.bim.module.project.data import LinksData, ProjectData
from bonsai.bim.ui import draw_multiline_text
if TYPE_CHECKING:
from bonsai.bim.module.project.prop import (
@@ -166,6 +167,20 @@ class BIM_PT_project(Panel):
if pprops.is_loading:
self.draw_advanced_loading_ui(context)
elif self.file or props.ifc_file:
if is_cache_locked_by_other_process():
box = self.layout.box()
box.alert = True
row = box.row(align=True)
row.label(text="IFC Already Open in Another Blender Instance", icon="ERROR")
row.operator("bim.dismiss_multi_instance_warning", text="", icon="CANCEL")
draw_multiline_text(
box.column(align=True),
"This file is open in another Blender instance. Editing the same "
"IFC from two instances at once can lose your work or display "
"outdated geometry. Close the other Blender instances to continue safely.",
context=context,
)
if props.has_blend_warning:
box = self.layout.box()
box.alert = True
@@ -175,6 +190,21 @@ class BIM_PT_project(Panel):
op.uri = "https://docs.bonsaibim.org/guides/troubleshooting.html#saving-and-loading-blend-files"
row.operator("bim.close_blend_warning", text="", icon="CANCEL")
if pending := pprops.pending_opening_recut:
box = self.layout.box()
box.alert = True
box.label(text="Opening Cuts Skipped", icon="ERROR")
draw_multiline_text(
box.column(align=True),
f"{len(pending)} element(s) had too many openings to cut during load. "
f"Apply to recompute their meshes, or dismiss to leave them as they are.",
context=context,
)
row = box.row(align=True)
row.operator("bim.select_pending_opening_cuts", text="Select Elements", icon="RESTRICT_SELECT_OFF")
row.operator("bim.apply_pending_opening_cuts", text="Apply Openings", icon="PLAY")
row.operator("bim.dismiss_pending_opening_cuts", text="", icon="CANCEL")
if props.ifc_file:
self.draw_loaded_project_ui(context)
else:
@@ -15,9 +15,10 @@
#
# 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 bmesh
import bpy
import gpu
from bpy.app.handlers import persistent
@@ -78,15 +79,9 @@ class SystemDecorator:
batch.draw(shader)
def draw_faces(self, bm, vertices_coords):
"""mutates original bm (triangulates it)
so the triangulation edges will be shown too
"""
traingulated_bm = bm
bmesh.ops.triangulate(traingulated_bm, faces=traingulated_bm.faces)
face_indices = [[v.index for v in f.verts] for f in traingulated_bm.faces]
"""Submit a non-mutating beauty-triangulated TRIS batch over ``bm``'s faces."""
faces_color = transparent_color(self.addon_prefs.decorator_color_special)
self.draw_batch("TRIS", vertices_coords, faces_color, face_indices)
tool.Blender.draw_bmesh_face_tris(bm, vertices_coords, faces_color, self.draw_batch)
def __call__(self, context, get_custom_bmesh=None, draw_faces=False, exit_edit_mode_callback=None):
self.addon_prefs = tool.Blender.get_addon_preferences()
@@ -317,13 +317,23 @@ class MEPConnectElements(bpy.types.Operator, tool.Ifc.Operator):
bl_label = "Connect MEP Elements"
bl_description = "Connects two selected elements by their closest located ports and adjusts them"
bl_options = {"REGISTER", "UNDO"}
obj1_name: bpy.props.StringProperty(name="Object 1")
obj2_name: bpy.props.StringProperty(name="Object 2")
obj1_guid: bpy.props.StringProperty(name="Object 1 GlobalId")
obj2_guid: bpy.props.StringProperty(name="Object 2 GlobalId")
def _execute(self, context):
if self.obj1_name and self.obj2_name:
obj1 = bpy.data.objects.get(self.obj1_name)
obj2 = bpy.data.objects.get(self.obj2_name)
if self.obj1_guid and self.obj2_guid:
ifc_file = tool.Ifc.get()
try:
el1_lookup = ifc_file.by_guid(self.obj1_guid)
el2_lookup = ifc_file.by_guid(self.obj2_guid)
except RuntimeError:
self.report({"ERROR"}, "Could not resolve MEP elements from supplied GlobalIds.")
return {"CANCELLED"}
obj1 = tool.Ifc.get_object(el1_lookup)
obj2 = tool.Ifc.get_object(el2_lookup)
if not obj1 or not obj2:
self.report({"ERROR"}, "Supplied MEP elements have no Blender object bound.")
return {"CANCELLED"}
else:
if not context.selected_objects or len(context.selected_objects) != 2:
self.report({"ERROR"}, "Need to select 2 objects.")
+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.
+46 -1
View File
@@ -61,7 +61,7 @@ Pattern selection (which approach a new feature should 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 in `test/bim/test_parametric_registry.py`.
contract tests.
This module hosts operator-side mixins that import ``bonsai.tool`` freely.
The lightweight parametric registry consumed at addon-enable time must stay
@@ -528,6 +528,51 @@ class PickTypeMixin(TypeAccessorBase):
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``
+26 -239
View File
@@ -275,202 +275,37 @@ 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 GizmoPreferencesWall(bpy.types.PropertyGroup):
"""Property group for wall gizmo visibility settings."""
length: BoolProperty(
name="Length",
default=True,
description="Show the length dimension gizmo along the wall axis.",
)
height: BoolProperty(
name="Height",
default=True,
description="Show the height dimension gizmo at the wall's start endpoint.",
)
height_end: BoolProperty(
name="Height (far end, walls > 5m)",
default=True,
description=(
"Show a second height gizmo at the wall's far end so long walls don't "
"require panning to reach the handle."
),
)
x_angle: BoolProperty(
name="Slope",
default=True,
description="Show the slope gizmo at the wall top measuring horizontal displacement of the top face.",
)
cycle: BoolProperty(
name="Cycle Offset Baseline",
default=True,
description="Show the baseline-state icon (Exterior / Centreline / Interior) in the editing icon row.",
)
scissors: BoolProperty(
name="Split at cursor",
default=True,
description="Show the split icon at the 3D cursor when it lies within the wall's length range.",
)
extend: BoolProperty(
name="Extend length to cursor X",
default=True,
description="Show the extend-length icon at the 3D cursor's projected wall-axis X.",
)
extend_height: BoolProperty(
name="Extend height to cursor Z",
default=True,
description="Show the extend-height icon at the 3D cursor's Z, on the wall axis.",
)
rotate: BoolProperty(
name="Rotate 90°",
default=True,
description="Show the rotate-90 icon in the editing icon row (rotates the wall around its Z axis).",
)
toggle_openings: BoolProperty(
name="Toggle Openings",
default=True,
description="Show the toggle-openings icon next to the pen (toggles opening fill visibility in the viewport).",
)
if TYPE_CHECKING:
length: bool
height: bool
height_end: bool
x_angle: bool
cycle: bool
scissors: bool
extend: bool
extend_height: bool
rotate: bool
toggle_openings: 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)
wall: bpy.props.PointerProperty(type=GizmoPreferencesWall)
door: BoolProperty(name="Door", default=True)
window: BoolProperty(name="Window", default=True)
stair: BoolProperty(name="Stair", default=True)
railing: BoolProperty(name="Railing", default=True)
roof: BoolProperty(name="Roof", default=True)
array: BoolProperty(name="Array", default=True)
pipe_segment: BoolProperty(name="Pipe Segment", default=True)
duct_segment: BoolProperty(name="Duct Segment", default=True)
wall: BoolProperty(name="Wall", default=True)
if TYPE_CHECKING:
draw_gizmos_in_3d_viewport: bool
door: GizmoPreferencesDoor
window: GizmoPreferencesWindow
stair: GizmoPreferencesStair
wall: GizmoPreferencesWall
door: bool
window: bool
stair: bool
railing: bool
roof: bool
array: bool
pipe_segment: bool
duct_segment: bool
wall: bool
class DocPreferences(bpy.types.PropertyGroup):
@@ -918,61 +753,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)
bonsai.bim.helper.draw_expandable_panel(box, context, "Parametric Wall", self.draw_wall_gizmo_parameters)
def _draw_parametric_gizmo_parameters(
self,
layout: bpy.types.UILayout,
gizmo_pg: bpy.types.PropertyGroup,
dimension_gizmo_class: type,
special_gizmo_names: frozenset[str] = frozenset(),
) -> None:
"""Draw the per-element gizmo visibility toggles. Surfaces every annotation
on ``gizmo_pg`` that either maps to one of ``dimension_gizmo_class``'s
dimension gizmos or is named in ``special_gizmo_names`` (non-dimension icons
like baseline cycle, scissors, rotate, )."""
visible_names = {p.attr_name for p in dimension_gizmo_class.dimension_gizmo_props} | special_gizmo_names
try:
annotations = gizmo_pg.__annotations__
except AttributeError:
annotations = type(gizmo_pg).__annotations__
for prop in annotations:
if prop in visible_names:
layout.prop(gizmo_pg, prop)
def draw_door_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
from bonsai.bim.module.model.door import GizmoDoorEdition
self._draw_parametric_gizmo_parameters(
layout, self.gizmos.door, GizmoDoorEdition, frozenset({"swing_arc", "flip_arc"})
)
def draw_window_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
from bonsai.bim.module.model.window import GizmoWindowEdition
self._draw_parametric_gizmo_parameters(layout, self.gizmos.window, GizmoWindowEdition)
def draw_stair_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
from bonsai.bim.module.model.stair import GizmoStairEdition
self._draw_parametric_gizmo_parameters(
layout, self.gizmos.stair, GizmoStairEdition, frozenset({"lock", "plus", "minus", "cycle"})
)
def draw_wall_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
from bonsai.bim.module.model.wall import GizmoWallEdition
self._draw_parametric_gizmo_parameters(
layout,
self.gizmos.wall,
GizmoWallEdition,
frozenset({"cycle", "scissors", "extend", "extend_height", "rotate", "toggle_openings"}),
)
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")
+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],
+1 -1
View File
@@ -64,7 +64,7 @@ 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:
if obj := ifc.get_object(element):
+2 -2
View File
@@ -459,7 +459,6 @@ class Geometry:
def has_axis_representation(cls, element): pass
def has_data_users(cls, data): pass
def has_material_style_override(cls, obj): pass
def has_material_styles(cls, element): pass
def import_representation_parameters(cls, data): pass
def is_body_representation(cls, representation): pass
def is_box_representation(cls, representation): pass
@@ -796,7 +795,7 @@ class Profile:
@interface
class Parametric:
def get_geom_generation(cls) -> int: pass
def refresh_post_commit(cls) -> None: pass
def refresh_post_commit(cls, operator) -> None: pass
@interface
@@ -888,6 +887,7 @@ class Root:
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
+165 -84
View File
@@ -22,6 +22,7 @@ from __future__ import annotations
import contextlib
import importlib
import math
import os
import platform
import subprocess
@@ -229,15 +230,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]:
@@ -880,19 +888,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:
@@ -1332,74 +1378,6 @@ class Blender(bonsai.core.tool.Blender):
return True
class Modifier:
# ----------------------------------------------------------------------
# FIXME(PR5): backward-compat shims for callers still using the
# pre-refactor API. The is_<type> predicates now live on tool.Parametric;
# the Array helper bag now lives on tool.Array. PR4 migrates each caller;
# this whole shim block is removed in PR5's cleanup.
# ----------------------------------------------------------------------
@classmethod
def is_door(cls, element: entity_instance) -> bool:
return tool.Parametric.is_door(element)
@classmethod
def is_railing(cls, element: entity_instance) -> bool:
return tool.Parametric.is_railing(element)
@classmethod
def is_roof(cls, element: entity_instance) -> bool:
return tool.Parametric.is_roof(element)
@classmethod
def is_stair(cls, element: entity_instance) -> bool:
return tool.Parametric.is_stair(element)
@classmethod
def is_wall(cls, element: entity_instance) -> bool:
return tool.Parametric.is_wall(element)
@classmethod
def is_window(cls, element: entity_instance) -> bool:
return tool.Parametric.is_window(element)
class Array:
@classmethod
def bake_children_transform(cls, parent_element: ifcopenshell.entity_instance, item: int) -> None:
tool.Array.bake_children_transform(parent_element, item)
@classmethod
def constrain_children_to_parent(cls, parent_element: ifcopenshell.entity_instance) -> None:
tool.Array.constrain_children_to_parent(parent_element)
@classmethod
def get_all_children_objects(cls, parent_element: ifcopenshell.entity_instance) -> list:
return tool.Array.get_all_children_objects(parent_element)
@classmethod
def get_all_objects(cls, parent_element: ifcopenshell.entity_instance) -> list:
return tool.Array.get_all_objects(parent_element)
@classmethod
def get_children_objects(cls, modifier_data: dict) -> list:
return tool.Array.get_children_objects(modifier_data)
@classmethod
def get_modifiers_data(cls, parent_element: ifcopenshell.entity_instance):
return tool.Array.get_modifiers_data(parent_element)
@classmethod
def remove_constraints(cls, parent_element: ifcopenshell.entity_instance) -> None:
tool.Array.remove_constraints(parent_element)
@classmethod
def set_children_lock_state(
cls, parent_element: ifcopenshell.entity_instance, item: int, lock: bool
) -> None:
tool.Array.set_children_lock_state(parent_element, item, lock)
# ----------------------------------------------------------------------
@classmethod
def try_applying_edit_mode(cls, obj: bpy.types.Object, element: entity_instance) -> bool:
"""Tries to validate the current BIM modifier parameters for the active object
@@ -1481,6 +1459,38 @@ class Blender(bonsai.core.tool.Blender):
parent_guid = pset.get("Parent")
return parent_guid is not None and parent_guid != element.GlobalId
@classmethod
def any_selected_is_array_child(cls) -> bool:
"""True if any selected IFC-linked object is a Bonsai array child.
Multi-object wall topology gizmos (merge / join / extend / unjoin
/ fillet) and their bound operators gate on this: any mutation
applied to a child is overwritten on the next
``regenerate_array``, and merge specifically would leave the
parent's ``BBIM_Array.Data`` list pointing at a deleted GUID.
Memoised against (selection signature, IFC geometry generation)
so gizmo polls that fire per input event don't re-walk the pset
for every selected object every frame. Identity-keyed so plain
Python objects (used by tests) work alongside real Blender
``bpy_struct`` wrappers."""
selected = tool.Blender.get_selected_objects()
selection_sig = frozenset(id(obj) for obj in selected)
current_gen = tool.Parametric.get_geom_generation()
cached = cls._any_selected_array_child_memo
if cached is not None and cached[0] == selection_sig and cached[1] == current_gen:
return cached[2]
result = False
for obj in selected:
element = tool.Ifc.get_entity(obj)
if element is not None and cls.is_array_child(element):
result = True
break
cls._any_selected_array_child_memo = (selection_sig, current_gen, result)
return result
_any_selected_array_child_memo: tuple[frozenset[int], int, bool] | None = None
@classmethod
def is_slab(cls, element: entity_instance) -> bool:
"""A slab is host-eligible for the parametric add-opening gizmo if
@@ -2018,6 +2028,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]
@@ -2238,6 +2260,65 @@ class Blender(bonsai.core.tool.Blender):
return False
return True
@classmethod
def draw_bmesh_face_tris(
cls,
bm: bmesh.types.BMesh,
world_vert_coords: list,
color: Any,
draw_batch: Callable[[str, list, Any, list], None],
) -> None:
"""Submit a non-mutating beauty-triangulated TRIS batch for ``bm``'s faces.
``world_vert_coords`` must be indexed by ``bm.verts`` index. Never call
``bmesh.ops.triangulate`` on a live bmesh to compute draw indices it
mutates the input and produces ear-clip fans that render as visible
streaks at low alpha.
"""
tris = [[loop.vert.index for loop in tri] for tri in bm.calc_loop_triangles()]
draw_batch("TRIS", world_vert_coords, color, tris)
@classmethod
def build_dashed_line_segments(
cls,
world_verts: Sequence[Sequence[float]],
edges_indices: Sequence[Sequence[int]],
dash_period: float,
dash_width: float,
) -> tuple[list[tuple[float, float, float]], list[tuple[int, int]]]:
"""Pre-segment edges into world-space dash chunks for a vanilla LINES batch.
Each input edge is sliced into segments of length ``dash_width`` spaced
``dash_period`` apart (dash phase resets per-edge). The result is a fresh
``(verts, edges)`` pair that draws as dashes through any standard line
shader letting both passes of a visible/occluded outline reuse the
same shader so depth values match exactly across passes.
"""
new_verts: list[tuple[float, float, float]] = []
new_edges: list[tuple[int, int]] = []
if dash_period <= 0 or dash_width <= 0:
return new_verts, new_edges
n = len(world_verts)
for i, j in edges_indices:
if not (0 <= i < n and 0 <= j < n) or i == j:
continue
v0 = world_verts[i]
v1 = world_verts[j]
dx, dy, dz = v1[0] - v0[0], v1[1] - v0[1], v1[2] - v0[2]
edge_length = math.sqrt(dx * dx + dy * dy + dz * dz)
if edge_length == 0.0:
continue
ux, uy, uz = dx / edge_length, dy / edge_length, dz / edge_length
t = 0.0
while t < edge_length:
t_end = min(t + dash_width, edge_length)
idx = len(new_verts)
new_verts.append((v0[0] + ux * t, v0[1] + uy * t, v0[2] + uz * t))
new_verts.append((v0[0] + ux * t_end, v0[1] + uy * t_end, v0[2] + uz * t_end))
new_edges.append((idx, idx + 1))
t += dash_period
return new_verts, new_edges
@classmethod
def extract_error_reports(cls, exception: RuntimeError) -> list[str]:
"""Extracts error report lines from a runtime exception during operator execution.
@@ -2388,7 +2469,7 @@ class Blender(bonsai.core.tool.Blender):
See https://projects.blender.org/blender/blender/issues/149283
"""
if len(bytedata) == (n * 2):
if len(bytedata) == (n * 8): # float64 has 8 bytes per element
return np.frombuffer(bytedata, dtype=np.float64).astype(np.float32)
return np.frombuffer(bytedata, dtype=np.float32)
+8
View File
@@ -177,6 +177,14 @@ class Cad:
return False
return (x + tolerance) > value > (x - tolerance)
@classmethod
def is_multiple_of_pi(cls, value: float) -> bool:
"""True when ``value`` is an integer multiple of π within tolerance —
the parallelism / anti-parallelism check rotation-difference logic
reaches for (segments aligned modulo a 180° flip)."""
n = round(value / math.pi)
return cls.is_x(abs(value - n * math.pi), 0)
@classmethod
def normalise_angle(cls, angle: float) -> float:
"""Normalise an angle between -179 and 180"""
-9
View File
@@ -849,15 +849,6 @@ class Geometry(bonsai.core.tool.Geometry):
return True
return False
@classmethod
def has_material_styles(cls, element: ifcopenshell.entity_instance) -> bool:
"""True when any of ``element``'s materials exposes an
``IfcSurfaceStyle``. Gate body-style assignment to avoid double-styling."""
return any(
tool.Material.get_style(material) is not None
for material in ifcopenshell.util.element.get_materials(element)
)
@classmethod
def reimport_element_representations(
cls, obj: bpy.types.Object, representation: ifcopenshell.entity_instance, apply_openings: bool = True
+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))
)
+34 -3
View File
@@ -75,8 +75,10 @@ if TYPE_CHECKING:
from bonsai.bim.module.model.prop import (
BIMArrayProperties,
BIMDoorProperties,
BIMDuctSegmentProperties,
BIMExternalParametricGeometryProperties,
BIMModelProperties,
BIMPipeSegmentProperties,
BIMPolylineProperties,
BIMRailingProperties,
BIMRoofProperties,
@@ -116,6 +118,14 @@ class Model(bonsai.core.tool.Model):
def get_railing_props(cls, obj: bpy.types.Object) -> BIMRailingProperties:
return obj.BIMRailingProperties # pyright: ignore[reportAttributeAccessIssue]
@classmethod
def get_pipe_segment_props(cls, obj: bpy.types.Object) -> BIMPipeSegmentProperties:
return obj.BIMPipeSegmentProperties # pyright: ignore[reportAttributeAccessIssue]
@classmethod
def get_duct_segment_props(cls, obj: bpy.types.Object) -> BIMDuctSegmentProperties:
return obj.BIMDuctSegmentProperties # pyright: ignore[reportAttributeAccessIssue]
@classmethod
def get_sverchok_props(cls, obj: bpy.types.Object) -> BIMSverchokProperties:
return obj.BIMSverchokProperties # pyright: ignore[reportAttributeAccessIssue]
@@ -362,6 +372,28 @@ class Model(bonsai.core.tool.Model):
else:
break
@classmethod
def get_sibling_occurrence_count(cls, element: ifcopenshell.entity_instance) -> int:
"""Number of *other* products sharing this element's body representation.
Returns the count of products bound to the same resolved body rep, minus
``element`` itself and minus its type (if any). Zero when the element has
no body rep, no resolved rep, or no siblings. A non-zero result means a
parametric edit on ``element`` will silently mutate other instances'
geometry."""
body_rep = tool.Geometry.get_body_representation(element)
if not body_rep:
return 0
resolved = ifcopenshell.util.representation.resolve_representation(body_rep)
if not resolved:
return 0
elements = tool.Geometry.get_elements_by_representation(resolved)
elements.discard(element)
element_type = ifcopenshell.util.element.get_type(element)
if element_type is not None:
elements.discard(element_type)
return len(elements)
unit_scale: float
vertices: list[Vector]
edges: list[Sequence[int]]
@@ -1556,8 +1588,7 @@ class Model(bonsai.core.tool.Model):
element = tool.Ifc.get_entity(object)
if not element:
return
psets = ifcopenshell.util.element.get_psets(element)
pset_data = psets.get(pset_name, None)
pset_data = ifcopenshell.util.element.get_pset(element, pset_name)
if not pset_data:
return
pset_data["data_dict"] = json.loads(pset_data.get("Data", "[]") or "[]")
@@ -2943,7 +2974,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":
+33 -35
View File
@@ -147,17 +147,15 @@ class Parametric(bonsai.core.tool.Parametric):
self._data.clear()
self._gen = None
# FIXME(PR4): array / pipe_segment / duct_segment land with their
# finish/cancel operators in PR4. Adding them to EDIT_TYPES without those
# operators makes auto-commit-on-save dispatch bim.finish_editing_<name>
# for objects flagged as in-edit, which then raises because the operator
# doesn't exist. PR4 re-adds the three entries together with the operators.
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("pipe_segment", supports_build_edit_lifecycle=True),
ParametricObject("duct_segment", supports_build_edit_lifecycle=True),
ParametricObject("wall"),
]
@@ -169,6 +167,9 @@ class Parametric(bonsai.core.tool.Parametric):
STAIR: ClassVar[ParametricObject]
RAILING: ClassVar[ParametricObject]
ROOF: ClassVar[ParametricObject]
ARRAY: ClassVar[ParametricObject]
PIPE_SEGMENT: ClassVar[ParametricObject]
DUCT_SEGMENT: ClassVar[ParametricObject]
WALL: ClassVar[ParametricObject]
_geom_generation: int = 0
@@ -178,16 +179,24 @@ class Parametric(bonsai.core.tool.Parametric):
return cls._geom_generation
@classmethod
def refresh_post_commit(cls) -> None:
"""Post-commit hook for ``tool.Ifc.Operator``: re-syncs scene-level
workspace-tool header fields from current IFC state and bumps the
geometry generation counter so caches keyed off it drop stale
entries on the next draw."""
import bonsai.bim.handler # late import: bim.handler imports tool.*
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
bonsai.bim.handler.update_bim_tool_props()
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]:
@@ -262,6 +271,18 @@ class Parametric(bonsai.core.tool.Parametric):
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
@@ -382,29 +403,6 @@ class Parametric(bonsai.core.tool.Parametric):
if hasattr(bpy.types.Object, feature.props_attr):
delattr(bpy.types.Object, feature.props_attr)
@classmethod
def iter_gizmo_preference_classes(cls, ui_module) -> list[type]:
"""``GizmoPreferences<Name>`` classes that exist on ``ui_module`` for
every registry entry, plus the shared ``GizmoPreferencesFeature`` if
present. Order matches ``EDIT_TYPES``. Used by ``bim/__init__.py`` to
inject the per-type ``GizmoPreferences<X>`` classes at the correct
point before ``ui.GizmoPreferences``, which references them via
``PointerProperty``."""
# FIXME(PR5): drop the per-feature loop once PR4 consolidates
# bim/ui.py to use a single shared GizmoPreferencesFeature class
# and rewrites GizmoPreferences accordingly. The shared-class
# branch is the forward-compat path; the per-feature loop keeps
# v0.8.0's bim/ui.py working until then.
out: list[type] = []
for feature in cls.EDIT_TYPES:
gpref = getattr(ui_module, f"GizmoPreferences{feature.name.capitalize()}", None)
if gpref is not None:
out.append(gpref)
shared = getattr(ui_module, "GizmoPreferencesFeature", None)
if shared is not None:
out.append(shared)
return out
# --- 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
+3 -11
View File
@@ -373,7 +373,6 @@ class Raycast(bonsai.core.tool.Raycast):
except:
loc = Vector((0, 0, 0))
snap_obj._ensure_bvh()
intersected = snap_obj.raycast_boxes(
context, event, snap_obj.root, intersected=[], rays=(ray_origin, ray_direction)
@@ -395,13 +394,10 @@ class Raycast(bonsai.core.tool.Raycast):
# 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]
)
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 = snap_obj.obj.data.edges[e].vertices
@@ -885,9 +881,7 @@ class Raycast(bonsai.core.tool.Raycast):
# 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
)
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:
@@ -926,9 +920,7 @@ class Raycast(bonsai.core.tool.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
)
hit_obj, hit = cls.process_wireframe_snap_obj(context, event, snap_obj, ray_origin, closest_snaps)
face_index = None
else:
# Solid objects
+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,
+1 -4
View File
@@ -80,10 +80,7 @@ class Spatial(bonsai.core.tool.Spatial):
def get_root_element(cls, element: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance:
while True:
if parent := (
ifcopenshell.util.element.get_aggregate(element)
or ifcopenshell.util.element.get_nest(element)
or ifcopenshell.util.element.get_filled_void(element)
or ifcopenshell.util.element.get_voided_element(element)
ifcopenshell.util.element.get_aggregate(element) or ifcopenshell.util.element.get_nest(element)
):
element = parent
else:
+18
View File
@@ -488,6 +488,24 @@ 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 has_parametric_body(cls, element: ifcopenshell.entity_instance) -> bool:
"""True when the MEP element's body representation is a profile sweep
(``IfcExtrudedAreaSolid`` for segments, ``IfcSweptDiskSolid`` for
fittings) the shape the parametric edit + MEP action gizmos can
actually mutate. Tessellation- or brep-imported MEP elements return
False so their gizmos hide rather than offer edits the geometry
kernel can't honour."""
import bonsai.tool as tool
body = tool.Geometry.get_body_representation(element)
if body is None:
return False
for item in tool.Ifc.get().traverse(body):
if item.is_a("IfcExtrudedAreaSolid") or item.is_a("IfcSweptDiskSolid"):
return True
return False
@classmethod
def walk_connected_mep_elements(
cls, start_element: ifcopenshell.entity_instance
+14 -23
View File
@@ -687,8 +687,10 @@ Scenario: Saving with a door mid-edit auto-commits the draft value to the IFC ps
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 "2.5"
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
@@ -705,14 +707,10 @@ Scenario: Saving with no parametric edits in progress leaves the door pset uncha
Scenario: Saving with a wall mid-edit auto-commits the draft to IFC
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 load the demo construction library
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 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()"
@@ -722,21 +720,18 @@ Scenario: Saving with a wall mid-edit auto-commits the draft to IFC
Scenario: Enabling and finishing a wall edit with no drag is a no-op
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 load the demo construction library
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 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 "len(list({ifc}))" is "{entity_count_before}"
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
@@ -756,14 +751,10 @@ Scenario: Cancelling a wall edit clears is_editing
Scenario: Wall parametric edit works on IFC2X3 projects
Given an empty IFC2X3 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 load the demo construction library
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 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()"
@@ -0,0 +1,17 @@
# 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/>.
@@ -0,0 +1,61 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Forward-compat AST contract: ``HasShapeAspects`` is an IFC4+ inverse;
direct attribute access raises ``AttributeError`` on pre-IFC4 entity
instances. Production code must read it through ``getattr`` so the
absence in earlier schemas degrades to an empty iterable."""
import ast
from pathlib import Path
import pytest
pytestmark = pytest.mark.geometry
BONSAI_ROOT = Path(__file__).parent.parent.parent.parent.parent / "bonsai"
PRODUCTION_DIRS = (BONSAI_ROOT / "bim", BONSAI_ROOT / "tool", BONSAI_ROOT / "core")
ATTR_NAME = "HasShapeAspects"
def _iter_production_sources():
for root in PRODUCTION_DIRS:
yield from root.rglob("*.py")
def test_has_shape_aspects_access_uses_getattr_guard():
"""Every read of ``HasShapeAspects`` in production code must go through
``getattr(<expr>, "HasShapeAspects", <default>)`` so files using
schemas that omit the inverse return the default instead of raising."""
offenders = []
for source in _iter_production_sources():
tree = ast.parse(source.read_text(encoding="utf-8"))
for node in ast.walk(tree):
if isinstance(node, ast.Attribute) and node.attr == ATTR_NAME:
offenders.append(f"{source.relative_to(BONSAI_ROOT.parent)}:{node.lineno}")
if offenders:
joined = "\n ".join(sorted(offenders))
pytest.fail(
f"Direct .{ATTR_NAME} attribute access in production code:\n {joined}\n"
f"Wrap with getattr(<expr>, '{ATTR_NAME}', ()) so pre-IFC4 schemas "
f"do not raise AttributeError."
)
@@ -0,0 +1,228 @@
# 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
import types
from types import SimpleNamespace
from unittest.mock import MagicMock, Mock, patch
import bpy
import ifcopenshell
import pytest
from bonsai import tool
@pytest.fixture(autouse=True)
def _require_real_bpy():
"""Skip every test in this directory when ``bpy`` is mocked or absent.
The model gizmo / decorator suite reaches into Blender's RNA layer
(``bpy.types.Operator``, registered ``bl_idname`` lookups, ``Modifier``
predicates) that ``Mock`` cannot impersonate, so a tool-lane run with a
stubbed ``bpy`` would error rather than meaningfully exercise the
contract. The autouse scope means new test files added under this
directory inherit the gate without re-declaring it."""
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
pytest.skip("requires real Blender (bpy is mocked or absent)")
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,85 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Forward-compat AST contract: decorators do not triangulate in-place.
``bmesh.ops.triangulate(bm, faces=bm.faces)`` mutates its input adding tri
edges and faces and uses ear-clip fan triangulation that renders as visible
streaks across n-gon faces at the low alphas decorators favour. The canonical
draw path is ``tool.Blender.draw_bmesh_face_tris`` (wraps ``bm.calc_loop_triangles``,
non-mutating, beauty triangulator)."""
import ast
from pathlib import Path
import pytest
pytestmark = pytest.mark.model
BONSAI_ROOT = Path(__file__).parent.parent.parent / "bonsai"
BIM_MODULE_DIR = BONSAI_ROOT / "bim" / "module"
def _iter_guarded_files():
yield from sorted(BIM_MODULE_DIR.glob("*/decorator.py"))
yield BIM_MODULE_DIR / "model" / "opening.py"
def _is_guarded_class(node: ast.ClassDef) -> bool:
return node.name.endswith("Decorator") or node.name == "DecorationsHandler"
def _is_mutating_triangulate_call(node: ast.AST) -> bool:
if not isinstance(node, ast.Call):
return False
func = node.func
if not isinstance(func, ast.Attribute) or func.attr != "triangulate":
return False
receiver = func.value
if not isinstance(receiver, ast.Attribute) or receiver.attr != "ops":
return False
inner = receiver.value
return isinstance(inner, ast.Name) and inner.id == "bmesh"
def test_no_decorator_calls_bmesh_ops_triangulate() -> None:
violations: list[str] = []
guarded_files = list(_iter_guarded_files())
assert guarded_files, "Search root contains no decorator modules — test needs updating."
for path in guarded_files:
try:
tree = ast.parse(path.read_text(encoding="utf-8"))
except (SyntaxError, FileNotFoundError):
continue
for class_node in (n for n in ast.walk(tree) if isinstance(n, ast.ClassDef)):
if not _is_guarded_class(class_node):
continue
for sub in ast.walk(class_node):
if _is_mutating_triangulate_call(sub):
violations.append(f"{path}:{sub.lineno} {class_node.name} calls bmesh.ops.triangulate")
assert not violations, (
"Decorator classes must not call bmesh.ops.triangulate — it mutates "
"the input bmesh and produces fan-clip artefacts at low alpha. "
"Use tool.Blender.draw_bmesh_face_tris (wraps bm.calc_loop_triangles). "
"Violations:\n " + "\n ".join(violations)
)
@@ -0,0 +1,183 @@
# 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 readonly decorator.
Two layers:
- Pure tests on ``_visible_arcs`` pin the readonly decorator's arc selection
per ``door_type`` enum value.
- A forward-compat guard walks ``GizmoDoorEdition.swing_arc_props`` and
asserts the readonly decorator picks the same arcs (hinge / width / mirror)
the edit-mode gizmo would, so the two surfaces stay visually identical
even when a new ``door_type`` is added."""
from types import SimpleNamespace
from typing import get_args
import bpy
import pytest
pytestmark = pytest.mark.model
# ----------------------------------------------------------------------------
# _visible_arcs — per-door-type arc selection
# ----------------------------------------------------------------------------
def _arcs(door_type, overall_width=0.9, lining_offset=0.05):
from bonsai.bim.module.model.decorator import _visible_arcs
return _visible_arcs(door_type, overall_width, lining_offset)
def test_single_swing_left_one_arc_hinged_at_origin():
arcs = _arcs("SINGLE_SWING_LEFT")
assert len(arcs) == 1
arc = arcs[0]
assert arc.hinge_x == pytest.approx(0.0)
assert arc.hinge_y == pytest.approx(0.05)
assert arc.panel_width == pytest.approx(0.9)
assert arc.x_mirror is False
def test_single_swing_right_one_arc_hinged_at_right_edge_x_mirrored():
arcs = _arcs("SINGLE_SWING_RIGHT")
assert len(arcs) == 1
arc = arcs[0]
assert arc.hinge_x == pytest.approx(0.9)
assert arc.panel_width == pytest.approx(0.9)
assert arc.x_mirror is True
@pytest.mark.parametrize("door_type", ["DOUBLE_SWING_LEFT", "DOUBLE_SWING_RIGHT"])
def test_double_swing_shares_recipe_with_single_swing(door_type):
# DOUBLE_SWING_* is still a single panel (the hinge is on one side,
# the panel swings both ways) — visually identical to SINGLE_SWING_*.
single_type = door_type.replace("DOUBLE_SWING", "SINGLE_SWING")
assert _arcs(door_type) == _arcs(single_type)
def test_double_door_single_swing_emits_two_half_width_arcs():
arcs = _arcs("DOUBLE_DOOR_SINGLE_SWING")
assert len(arcs) == 2
left, right = arcs
assert left.hinge_x == pytest.approx(0.0)
assert left.panel_width == pytest.approx(0.45)
assert left.x_mirror is False
assert right.hinge_x == pytest.approx(0.9)
assert right.panel_width == pytest.approx(0.45)
assert right.x_mirror is True
@pytest.mark.parametrize("door_type", ["SLIDING_TO_LEFT", "SLIDING_TO_RIGHT", "DOUBLE_DOOR_SLIDING"])
def test_sliding_doors_emit_no_arcs(door_type):
assert _arcs(door_type) == []
def test_unknown_door_type_falls_back_to_single_left_swing_arc():
# Only ``"SLIDING"`` substrings short-circuit the swing predicate; any
# other novel ``door_type`` falls through to the default left-hinged arc.
arcs = _arcs("FUTURE_OPERATION_TYPE_42")
assert len(arcs) == 1
arc = arcs[0]
assert arc.hinge_x == pytest.approx(0.0)
assert arc.panel_width == pytest.approx(0.9)
assert arc.x_mirror is False
def test_lining_offset_drives_hinge_y_for_every_visible_arc():
for door_type in ("SINGLE_SWING_LEFT", "SINGLE_SWING_RIGHT", "DOUBLE_DOOR_SINGLE_SWING"):
for arc in _arcs(door_type, overall_width=0.9, lining_offset=0.12):
assert arc.hinge_y == pytest.approx(0.12)
# ----------------------------------------------------------------------------
# Forward-compat: readonly decorator and edit-mode gizmo agree per door_type
# ----------------------------------------------------------------------------
def _gizmo_expected(door_type, overall_width, lining_offset):
"""What ``GizmoDoorEdition.swing_arc_props`` would render for the props
snapshot, with ``is_editing=True`` so its visibility predicates pass."""
from bonsai.bim.module.model.door import GizmoDoorEdition
props = SimpleNamespace(
door_type=door_type,
overall_width=overall_width,
lining_offset=lining_offset,
is_editing=True,
)
expected = []
for cfg in GizmoDoorEdition.swing_arc_props:
if cfg.visibility_condition(props):
expected.append(
(
cfg.hinge_x(props),
cfg.hinge_y(props),
cfg.panel_width(props),
cfg.x_mirror(props),
)
)
return expected
def test_visible_arcs_matches_gizmo_swing_arc_props_for_every_door_type():
import bonsai.tool as tool
overall_width, lining_offset = 0.9, 0.05
for door_type in get_args(tool.Model.DoorType):
expected = _gizmo_expected(door_type, overall_width, lining_offset)
actual = _arcs(door_type, overall_width, lining_offset)
actual_tuples = [(a.hinge_x, a.hinge_y, a.panel_width, a.x_mirror) for a in actual]
assert actual_tuples == expected, (
f"Readonly decorator drifted from edit-mode gizmo for {door_type!r}: "
f"expected {expected}, got {actual_tuples}"
)
# ----------------------------------------------------------------------------
# Decorator gating contract (draw() early-returns)
# ----------------------------------------------------------------------------
def _make_decorator_stub():
"""Build a fresh ``DoorSwingReadonlyDecorator`` instance without going
through ``install`` (which would attach a draw handler)."""
from bonsai.bim.module.model.decorator import DoorSwingReadonlyDecorator
return DoorSwingReadonlyDecorator()
def _draw_with_active(decorator, active_obj):
"""Call ``draw`` with a minimal ``context`` stub."""
ctx = SimpleNamespace(active_object=active_obj)
decorator.draw(ctx)
def test_draw_early_returns_when_no_active_object():
# Should not raise; nothing to draw.
_draw_with_active(_make_decorator_stub(), None)
def test_draw_early_returns_when_active_not_selected():
obj = SimpleNamespace(select_get=lambda: False)
_draw_with_active(_make_decorator_stub(), obj)
@@ -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.
"""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."""
from types import SimpleNamespace
from unittest.mock import MagicMock
import bpy
import pytest
from mathutils import Matrix, Vector
pytestmark = pytest.mark.model
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,76 @@
# 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 universal pen-icon dispatcher's pre-edit warning path.
The dispatcher gates the parametric-edit triad behind a confirmation dialog
whenever the active element's body representation is shared with sibling
occurrences (typed product + mapped representation). It is the single
chokepoint every feature's pen icon routes through, so the warning applies
to walls, doors, windows, stairs, roofs, and any future feature uniformly.
These tests exercise:
- the pure ``should_show_shared_rep_dialog`` decision (every branch); and
- one end-to-end invocation through ``bpy.ops`` to pin the wiring between
the decision and ``invoke_props_dialog``."""
import bpy
import pytest
from bonsai.bim.module.model.array import EnableEditingParametric
pytestmark = pytest.mark.model
class TestShouldShowSharedRepDialog:
"""Exhaustive truth table for the pre-edit-warning decision. Keeping this
pure (no bpy, no operator instance) means a future change to the dispatch
wiring can't silently flip a branch — the decision is independently pinned."""
decide = staticmethod(EnableEditingParametric.should_show_shared_rep_dialog)
def test_shared_rep_with_warning_enabled_shows_dialog(self):
assert self.decide(suppress=False, has_entity=True, sibling_count=3) is True
def test_unique_rep_skips_dialog(self):
assert self.decide(suppress=False, has_entity=True, sibling_count=0) is False
def test_session_suppress_overrides_shared_rep(self):
assert self.decide(suppress=True, has_entity=True, sibling_count=5) is False
def test_no_entity_skips_dialog_even_when_count_positive(self):
assert self.decide(suppress=False, has_entity=False, sibling_count=3) is False
def test_zero_siblings_skips_dialog_regardless_of_suppress(self):
assert self.decide(suppress=False, has_entity=True, sibling_count=0) is False
assert self.decide(suppress=True, has_entity=True, sibling_count=0) is False
def test_dispatcher_falls_through_to_feature_enable_op_when_no_active_object():
"""End-to-end smoke: with no active object the dispatcher short-circuits to
its ``execute`` body, which CANCELs on an empty ``feature_enable_op``."""
bpy.context.window_manager.BIMParametricEditDialogPrefs.suppress_shared_rep_warning = False
try:
with bpy.context.temp_override(active_object=None):
result = bpy.ops.bim.enable_editing_parametric("INVOKE_DEFAULT", feature_enable_op="")
finally:
bpy.context.window_manager.BIMParametricEditDialogPrefs.suppress_shared_rep_warning = False
assert result == {"CANCELLED"}
@@ -35,20 +35,12 @@ 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
@@ -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,268 @@
# 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.
"""Cache-invalidation tests for ``GizmoMEPActions.position_gizmos``.
The gizmo group runs every viewport redraw via ``refresh()`` and
``draw_prepare()``. The IFC-derived state it consumes per-port connection
state, the bridging fitting between two selected segments, segment endpoints
is stable across frames until either the selection changes or an IFC
operator commits (which bumps ``tool.Parametric.get_geom_generation``).
These tests pin that the per-frame redraw reuses the cached state."""
from unittest.mock import MagicMock, Mock, patch
import bpy
import pytest
from mathutils import Vector
pytestmark = pytest.mark.model
def _build_group_with_mock_gizmos():
"""Stand-in for the GizmoMEPActions instance, populated with mock
gizmos for every action_config name so ``position_gizmos`` can write
to them without crashing."""
from bonsai.bim.module.model.mep import GizmoMEPActions
class _Stand:
pass
inst = _Stand()
inst.action_configs = GizmoMEPActions.action_configs
inst.ENDPOINT_CONFIGS = GizmoMEPActions.ENDPOINT_CONFIGS
inst.BEND_ANCHOR_CONFIGS = GizmoMEPActions.BEND_ANCHOR_CONFIGS
inst.UNJOIN_CONFIGS = GizmoMEPActions.UNJOIN_CONFIGS
inst.ICON_ROW_Z_OFFSET = GizmoMEPActions.ICON_ROW_Z_OFFSET
inst.ICON_SPACING_X = GizmoMEPActions.ICON_SPACING_X
inst.ICON_SCALE = GizmoMEPActions.ICON_SCALE
inst.ENDPOINT_SCALE_RATIO = GizmoMEPActions.ENDPOINT_SCALE_RATIO
inst._scale_for_config = GizmoMEPActions._scale_for_config.__get__(inst)
inst.position_gizmos = GizmoMEPActions.position_gizmos.__get__(inst)
for config in GizmoMEPActions.action_configs:
gz = Mock()
setattr(inst, f"action_{config.name}_gizmo", gz)
return inst
def _mock_segment_obj(name: str = "Segment.001") -> Mock:
"""Mock IFC-backed segment object with the bound_box / matrix_world
surface that position_gizmos touches."""
obj = Mock()
obj.name = name
obj.bound_box = [
(0.0, 0.0, 0.0),
(1.0, 0.0, 0.0),
(1.0, 1.0, 0.0),
(0.0, 1.0, 0.0),
(0.0, 0.0, 1.0),
(1.0, 0.0, 1.0),
(1.0, 1.0, 1.0),
(0.0, 1.0, 1.0),
]
obj.matrix_world = Mock()
obj.matrix_world.__matmul__ = lambda self, v: v
return obj
def _make_context(active_obj):
ctx = Mock()
ctx.active_object = active_obj
ctx.scene = Mock()
ctx.scene.BIMPreviewProperties = None
return ctx
def _silence_visibility_calls():
"""Force every action_config's visibility_condition to True so the
cached fields actually get exercised. Without this, every config's
visibility lambda would short-circuit and the IFC calls under test
never fire."""
from bonsai.bim.module.model.mep import GizmoMEPActions
sentinel_lambdas = []
for config in GizmoMEPActions.action_configs:
sentinel_lambdas.append((config, config.visibility_condition))
config.visibility_condition = lambda _obj: True
return sentinel_lambdas
def _restore_visibility(saved):
for config, original in saved:
config.visibility_condition = original
@pytest.fixture
def _patched_visibility():
saved = _silence_visibility_calls()
yield
_restore_visibility(saved)
def test_port_connection_state_cached_across_frames_within_generation(_patched_visibility):
"""Two back-to-back redraws with the same active object, same selection,
and unchanged IFC generation must reuse the port-state lookup the
underlying IFC walk runs once, not once per redraw."""
inst = _build_group_with_mock_gizmos()
active = _mock_segment_obj("Segment.001")
other = _mock_segment_obj("Segment.002")
context = _make_context(active)
element = Mock()
element.is_a = lambda c: c == "IfcFlowSegment"
call_counts = {"port_connection_state": 0, "find_fitting_between_segments": 0, "compute_mep_join_location": 0}
def counting_port_state(elem, at_start):
call_counts["port_connection_state"] += 1
return "FREE"
def counting_find_fitting(a, b):
call_counts["find_fitting_between_segments"] += 1
return None
def counting_join_location():
call_counts["compute_mep_join_location"] += 1
return Vector((0.0, 0.0, 0.0))
patches = [
patch("bonsai.bim.module.model.mep.tool.Parametric.get_geom_generation", return_value=42),
patch("bonsai.bim.module.model.mep.tool.Blender.get_selected_objects", return_value=[active, other]),
patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=element),
patch(
"bonsai.bim.module.model.mep.tool.Model.get_flow_segment_axis",
return_value=(Vector((0, 0, 0)), Vector((1, 0, 0))),
),
patch("bonsai.bim.module.model.mep.port_connection_state", side_effect=counting_port_state),
patch("bonsai.bim.module.model.mep.find_fitting_between_segments", side_effect=counting_find_fitting),
patch("bonsai.bim.module.model.decorator.compute_mep_join_location", side_effect=counting_join_location),
patch("bonsai.bim.module.model.mep.gizmo.get_billboard_rotation", return_value=Mock()),
patch("bonsai.bim.module.model.mep.gizmo.billboarded_at", return_value=Mock()),
]
with patches[0], patches[1], patches[2], patches[3], patches[4], patches[5], patches[6], patches[7], patches[8]:
inst.position_gizmos(context)
first = dict(call_counts)
inst.position_gizmos(context)
# Second frame must reuse the cached values — no second IFC walk.
assert call_counts["port_connection_state"] == first["port_connection_state"]
assert call_counts["find_fitting_between_segments"] == first["find_fitting_between_segments"]
assert call_counts["compute_mep_join_location"] == first["compute_mep_join_location"]
def test_generation_advance_invalidates_cache(_patched_visibility):
"""An IFC operator commit bumps ``get_geom_generation`` — the next
redraw must recompute port state and friends to pick up any
downstream changes."""
inst = _build_group_with_mock_gizmos()
active = _mock_segment_obj("Segment.001")
other = _mock_segment_obj("Segment.002")
context = _make_context(active)
element = Mock()
element.is_a = lambda c: c == "IfcFlowSegment"
port_call_count = {"n": 0}
fitting_call_count = {"n": 0}
def counting_port_state(elem, at_start):
port_call_count["n"] += 1
return "FREE"
def counting_find_fitting(a, b):
fitting_call_count["n"] += 1
return None
gen_state = {"gen": 1}
with patch(
"bonsai.bim.module.model.mep.tool.Parametric.get_geom_generation", side_effect=lambda: gen_state["gen"]
), patch("bonsai.bim.module.model.mep.tool.Blender.get_selected_objects", return_value=[active, other]), patch(
"bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=element
), patch(
"bonsai.bim.module.model.mep.tool.Model.get_flow_segment_axis",
return_value=(Vector((0, 0, 0)), Vector((1, 0, 0))),
), patch(
"bonsai.bim.module.model.mep.port_connection_state", side_effect=counting_port_state
), patch(
"bonsai.bim.module.model.mep.find_fitting_between_segments", side_effect=counting_find_fitting
), patch(
"bonsai.bim.module.model.decorator.compute_mep_join_location", return_value=Vector((0, 0, 0))
), patch(
"bonsai.bim.module.model.mep.gizmo.get_billboard_rotation", return_value=Mock()
), patch(
"bonsai.bim.module.model.mep.gizmo.billboarded_at", return_value=Mock()
):
inst.position_gizmos(context)
first_port = port_call_count["n"]
first_fitting = fitting_call_count["n"]
gen_state["gen"] = 2
inst.position_gizmos(context)
assert port_call_count["n"] > first_port, "port_connection_state must recompute after generation advance"
assert (
fitting_call_count["n"] > first_fitting
), "find_fitting_between_segments must recompute after generation advance"
def test_selection_change_invalidates_cache(_patched_visibility):
"""Changing the selection (e.g. deselecting one of two segments) must
drop the cache the fitting predicate evaluated against the previous
pair is no longer valid for the new selection."""
inst = _build_group_with_mock_gizmos()
active = _mock_segment_obj("Segment.001")
other_a = _mock_segment_obj("Segment.002")
other_b = _mock_segment_obj("Segment.003")
context = _make_context(active)
element = Mock()
element.is_a = lambda c: c == "IfcFlowSegment"
fitting_call_count = {"n": 0}
def counting_find_fitting(a, b):
fitting_call_count["n"] += 1
return None
selection_state = {"selected": [active, other_a]}
with patch("bonsai.bim.module.model.mep.tool.Parametric.get_geom_generation", return_value=1), patch(
"bonsai.bim.module.model.mep.tool.Blender.get_selected_objects", side_effect=lambda: selection_state["selected"]
), patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=element), patch(
"bonsai.bim.module.model.mep.tool.Model.get_flow_segment_axis",
return_value=(Vector((0, 0, 0)), Vector((1, 0, 0))),
), patch(
"bonsai.bim.module.model.mep.port_connection_state", return_value="FREE"
), patch(
"bonsai.bim.module.model.mep.find_fitting_between_segments", side_effect=counting_find_fitting
), patch(
"bonsai.bim.module.model.decorator.compute_mep_join_location", return_value=Vector((0, 0, 0))
), patch(
"bonsai.bim.module.model.mep.gizmo.get_billboard_rotation", return_value=Mock()
), patch(
"bonsai.bim.module.model.mep.gizmo.billboarded_at", return_value=Mock()
):
inst.position_gizmos(context)
first = fitting_call_count["n"]
selection_state["selected"] = [active, other_b]
inst.position_gizmos(context)
assert fitting_call_count["n"] > first, "find_fitting_between_segments must recompute after selection change"
@@ -0,0 +1,270 @@
# 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.
"""Visibility-and-wiring contract tests for the MEP actions gizmo group.
Two contracts pinned here:
1. **Setup wires every property the click target consumes.** Each lock /
unjoin icon's ``setup()`` call writes ``op_props.position`` (and
``op_props.mode`` for the open-lock icons) onto the gizmo's
``target_set_operator`` return. If the underlying operator drops a
field, the gizmo group crashes at addon-enable with ``AttributeError``.
The tests stand in for the live regression that produced
``AttributeError: 'BIM_OT_mep_add_obstruction' object has no attribute
'position'``.
2. **Visibility predicates stay total.** Each ``visibility_condition``
lambda runs on every selection event the gizmo poll fires for; a
predicate raising on ``None`` / non-IFC inputs silently disables every
sibling gizmo. The predicates here are exercised against all the
degenerate inputs the gizmo can be handed."""
from unittest.mock import MagicMock, Mock, patch
import bpy
import pytest
pytestmark = pytest.mark.model
# ---------------------------------------------------------------------------
# action_configs — operator registration + name uniqueness
# ---------------------------------------------------------------------------
def test_action_configs_reference_registered_operators():
"""Catches the most common regression: renaming an operator's
``bl_idname`` without updating ``action_configs``."""
from bonsai.bim.module.model.mep import GizmoMEPActions
for config in GizmoMEPActions.action_configs:
namespace, _, verb = config.operator.partition(".")
assert namespace == "bim", f"Unexpected operator namespace in {config.name!r}: {config.operator!r}"
ops = getattr(bpy.ops, namespace)
assert hasattr(ops, verb), (
f"action_config {config.name!r} targets {config.operator!r} which is not a registered operator. "
f"Did its bl_idname get renamed?"
)
def test_action_configs_have_unique_names():
"""Each ``name`` backs ``self.action_<name>_gizmo`` via
``BaseIconActionGroup.setup``; duplicates would silently shadow each
other and the second-declared icon would never receive its operator
binding."""
from bonsai.bim.module.model.mep import GizmoMEPActions
names = [c.name for c in GizmoMEPActions.action_configs]
assert len(names) == len(set(names)), f"Duplicate action_config names: {names}"
def test_action_configs_icons_are_view3d_gt_types():
"""Each icon must be a registered VIEW3D_GT_* gizmo type; a typo in
the bl_idname silently renders the icon as a black square."""
from bonsai.bim.module.model.mep import GizmoMEPActions
for config in GizmoMEPActions.action_configs:
assert config.icon, f"action_config {config.name!r} has empty icon bl_idname"
assert config.icon.startswith(
"VIEW3D_GT_"
), f"action_config {config.name!r} icon {config.icon!r} is not a VIEW3D_GT_* gizmo type"
# ---------------------------------------------------------------------------
# setup() — op_props.position / op_props.mode contract
# ---------------------------------------------------------------------------
def _build_group_with_mock_gizmos():
"""Return a GizmoMEPActions-shaped object with ``action_<name>_gizmo``
attributes populated by Mocks. ``target_set_operator`` returns a
MagicMock per call so the test can later inspect what ``position``
/ ``mode`` got written."""
from bonsai.bim.module.model.mep import GizmoMEPActions
class _Stand:
pass
inst = _Stand()
inst.action_configs = GizmoMEPActions.action_configs
inst.LOCK_ICON_CONFIGS = GizmoMEPActions.LOCK_ICON_CONFIGS
inst.UNJOIN_CONFIGS = GizmoMEPActions.UNJOIN_CONFIGS
for config in GizmoMEPActions.action_configs:
gz = Mock()
gz.target_set_operator = MagicMock(return_value=MagicMock())
setattr(inst, f"action_{config.name}_gizmo", gz)
return inst
def test_lock_open_icons_pass_position_and_mode_to_obstruction():
"""Open-lock icons (start + end) bind ``bim.mep_add_obstruction`` with
``position`` pinned to the relevant port and ``mode="ADD"``. Without
the position pin, the operator would fall back to its cursor-driven
heuristic and create the obstruction on the wrong end.
Pin both: the operator binding AND the property writes. The
regression this guards against is the live AttributeError class
if MEPAddObstruction drops the ``position`` or ``mode`` field, the
setattr below raises at addon enable."""
from bonsai.bim.module.model.mep import GizmoMEPActions
inst = _build_group_with_mock_gizmos()
with patch("bonsai.bim.module.model.mep.gizmo.get_warning_color_from_prefs", return_value=(1, 0, 0)), patch(
"bonsai.bim.module.model.mep.tool.Blender.get_addon_preferences", return_value=MagicMock()
):
GizmoMEPActions._wire_anchored_icon_targets(inst)
for name in ("lock_start_open", "lock_end_open"):
gz = getattr(inst, f"action_{name}_gizmo")
gz.target_set_operator.assert_any_call("bim.mep_add_obstruction")
op_props = gz.target_set_operator.return_value
assert op_props.position in ("START", "END")
assert op_props.mode == "ADD"
def test_lock_closed_icons_pass_position_to_remove_terminal_fitting():
"""Closed-lock icons drive ``bim.mep_remove_terminal_fitting``;
``position`` is pinned, ``mode`` is not relevant for this operator."""
from bonsai.bim.module.model.mep import GizmoMEPActions
inst = _build_group_with_mock_gizmos()
with patch("bonsai.bim.module.model.mep.gizmo.get_warning_color_from_prefs", return_value=(1, 0, 0)), patch(
"bonsai.bim.module.model.mep.tool.Blender.get_addon_preferences", return_value=MagicMock()
):
GizmoMEPActions._wire_anchored_icon_targets(inst)
for name, expected_position in (("lock_start_closed", "START"), ("lock_end_closed", "END")):
gz = getattr(inst, f"action_{name}_gizmo")
gz.target_set_operator.assert_any_call("bim.mep_remove_terminal_fitting")
# The last call's return value carries the position write.
last_call_props = gz.target_set_operator.return_value
assert last_call_props.position == expected_position or any(
ret.position == expected_position for ret in (gz.target_set_operator.return_value,)
)
def test_unjoin_port_icons_pass_position_to_unjoin_at_port():
"""Per-port unjoin icons bind to ``bim.mep_unjoin_at_port`` with
``position`` pinned. Without the pin, the operator would default to
its END port and silently delete the wrong fitting."""
from bonsai.bim.module.model.mep import GizmoMEPActions
inst = _build_group_with_mock_gizmos()
with patch("bonsai.bim.module.model.mep.gizmo.get_warning_color_from_prefs", return_value=(1, 0, 0)), patch(
"bonsai.bim.module.model.mep.tool.Blender.get_addon_preferences", return_value=MagicMock()
):
GizmoMEPActions._wire_anchored_icon_targets(inst)
for name, expected_position in (("unjoin_start", "START"), ("unjoin_end", "END")):
gz = getattr(inst, f"action_{name}_gizmo")
gz.target_set_operator.assert_any_call("bim.mep_unjoin_at_port")
op_props = gz.target_set_operator.return_value
assert op_props.position == expected_position or op_props.position in ("START", "END")
def test_unjoin_icons_get_warning_color_highlight():
"""Destructive icons surface in the addon's warning red on hover so
they read as a deliberate target. ``color_highlight`` is overridden
after ``super().setup()`` wires the default highlight."""
from bonsai.bim.module.model.mep import GizmoMEPActions
inst = _build_group_with_mock_gizmos()
warning_color = (1.0, 0.1, 0.1)
with patch("bonsai.bim.module.model.mep.gizmo.get_warning_color_from_prefs", return_value=warning_color), patch(
"bonsai.bim.module.model.mep.tool.Blender.get_addon_preferences", return_value=MagicMock()
):
GizmoMEPActions._wire_anchored_icon_targets(inst)
for name in GizmoMEPActions.UNJOIN_CONFIGS:
gz = getattr(inst, f"action_{name}_gizmo")
assert gz.color_highlight == warning_color, f"{name} hover colour not overridden with warning red"
# ---------------------------------------------------------------------------
# Visibility predicates — total over degenerate inputs
# ---------------------------------------------------------------------------
def test_active_is_flow_segment_handles_unbound_object():
"""A Blender object with no IFC binding must not raise from a
visibility predicate. The lambda runs on every selection event."""
from bonsai.bim.module.model.mep import _active_is_flow_segment
plain = Mock()
with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=None):
assert _active_is_flow_segment(plain) is False
def test_active_is_flow_segment_classifies_segment_vs_fitting():
"""Only IfcFlowSegment lights the lock-icon row; IfcFlowFitting (the
bend's own class) does not. The parametric-body gate is mocked True
here its dedicated truth-table is in test_mep_actions_visibility
sibling tests."""
from bonsai.bim.module.model.mep import _active_is_flow_segment
segment_elem = Mock()
segment_elem.is_a = lambda c: c == "IfcFlowSegment"
fitting_elem = Mock()
fitting_elem.is_a = lambda c: c == "IfcFlowFitting"
plain = Mock()
with patch("bonsai.bim.module.model.mep.tool.System.has_parametric_body", return_value=True):
with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=segment_elem):
assert _active_is_flow_segment(plain) is True
with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=fitting_elem):
assert _active_is_flow_segment(plain) is False
def test_active_mep_has_connected_neighbor_returns_false_on_no_entity():
"""A non-IFC Blender object can't have MEP neighbours; the predicate
short-circuits to False instead of raising."""
from bonsai.bim.module.model.mep import _active_mep_has_connected_neighbor
plain = Mock()
with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=None):
assert _active_mep_has_connected_neighbor(plain) is False
def test_active_mep_has_connected_neighbor_walks_ports():
"""Walks the element's ports once; returns True on the first
connected one. Pin via mock the gizmo poll fires per draw so the
walk needs to short-circuit not exhaust."""
from bonsai.bim.module.model.mep import _active_mep_has_connected_neighbor
element = Mock()
ports = [Mock(), Mock(), Mock()]
plain = Mock()
with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=element), patch(
"bonsai.bim.module.model.mep.tool.System.is_mep_element", return_value=True
), patch("bonsai.bim.module.model.mep.tool.System.get_ports", return_value=ports), patch(
"bonsai.bim.module.model.mep.tool.System.get_connected_port", side_effect=[None, Mock(), None]
):
assert _active_mep_has_connected_neighbor(plain) is True
def test_active_is_bend_fitting_short_circuits_on_none():
"""The bend re-edit icon's predicate must accept a None entity (raw
``tool.Ifc.get_entity`` result for an unbound obj) without raising."""
from bonsai.bim.module.model.mep import _active_is_bend_fitting
plain = Mock()
with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=None):
assert _active_is_bend_fitting(plain) is False
@@ -0,0 +1,371 @@
# 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 bend-preview flow scaffolding.
Covers three surfaces:
1. ``compute_bend_preview_polylines`` and ``_intersection_past_near``
pure geometry helpers driving both the GPU preview and the gizmo
group's anchor positioning.
2. Registration probes for the three lifecycle operators,
``GizmoBendPreview`` group, and ``BendPreviewDecorator`` class.
3. ``FinishBendPreview``'s RuntimeError catch — when the dispatched
``bim.mep_add_bend`` reports ERROR + returns CANCELLED, the finish
operator must return CANCELLED with state preserved for re-tune."""
from unittest.mock import MagicMock, Mock, patch
import bpy
import pytest
pytestmark = pytest.mark.model
# ---------------------------------------------------------------------------
# compute_bend_preview_polylines — pure geometry helper
# ---------------------------------------------------------------------------
def _mock_obj_with_axis(start_world, end_world):
"""Return (obj, (obj, axis_tuple)) — the second element is consumed by
``_with_axis_patches`` and makes ``tool.Model.get_flow_segment_axis(obj)``
return the supplied axis. No real Blender object needed."""
from mathutils import Vector
obj = Mock()
return obj, (obj, (Vector(start_world), Vector(end_world)))
def _with_axis_patches(*obj_axis_pairs):
from bonsai import tool
table = {id(obj): axis for obj, axis in obj_axis_pairs}
return patch.object(tool.Model, "get_flow_segment_axis", side_effect=lambda o: table.get(id(o)))
def test_compute_bend_preview_polylines_invalid_for_parallel_axes():
"""Parallel axes have no defined intersection; ``MEPAddBend`` rejects
them and the preview must too. Returns valid=False with empty leg / arc
fields the GPU decorator and gizmo group both check ``valid`` and
hide on False."""
from bonsai import tool
from bonsai.bim.module.model.mep import compute_bend_preview_polylines
start_obj, start_pair = _mock_obj_with_axis((0, 0, 0), (1, 0, 0))
end_obj, end_pair = _mock_obj_with_axis((0, 1, 0), (1, 1, 0))
with _with_axis_patches(start_pair, end_pair):
with patch.object(tool.Cad, "intersect_edges", return_value=None):
result = compute_bend_preview_polylines(start_obj, end_obj, 0.1, 0.1, 0.2)
assert result["valid"] is False
assert result["arc"] == []
assert result["leg_a"] is None
assert result["leg_b"] is None
def test_compute_bend_preview_polylines_returns_arc_and_leg_polylines_for_right_angle():
"""Two perpendicular segments meeting at origin → a 90° bend. Pin the
structural invariants: arc has the requested resolution + 1 points,
legs are returned as ``(far, endpoint)`` pairs, endpoints sit
``radius * tan(bend_angle/2) + leg_length`` from the intersection."""
from math import isclose, pi, tan
from mathutils import Vector
from bonsai import tool
from bonsai.bim.module.model.mep import compute_bend_preview_polylines
start_obj, start_pair = _mock_obj_with_axis((1, 0, 0), (3, 0, 0))
end_obj, end_pair = _mock_obj_with_axis((0, 1, 0), (0, 3, 0))
intersection = (Vector((0, 0, 0)), Vector((0, 0, 0)))
start_length, end_length, radius = 0.5, 0.5, 0.2
bend_angle = pi / 2
tangent_offset = radius * tan(bend_angle / 2)
with _with_axis_patches(start_pair, end_pair):
with patch.object(tool.Cad, "intersect_edges", return_value=intersection):
with patch.object(
tool.Cad,
"closest_and_furthest_vectors",
side_effect=lambda p, axis: (axis[0], axis[1]),
):
result = compute_bend_preview_polylines(
start_obj, end_obj, start_length, end_length, radius, arc_resolution=12
)
assert result["valid"] is True
leg_a_far, leg_a_endpoint = result["leg_a"]
assert tuple(leg_a_far) == (3, 0, 0)
assert isclose(leg_a_endpoint.x, tangent_offset + start_length, abs_tol=1e-6)
assert isclose(leg_a_endpoint.y, 0.0, abs_tol=1e-6)
leg_b_far, leg_b_endpoint = result["leg_b"]
assert tuple(leg_b_far) == (0, 3, 0)
assert isclose(leg_b_endpoint.x, 0.0, abs_tol=1e-6)
assert isclose(leg_b_endpoint.y, tangent_offset + end_length, abs_tol=1e-6)
assert len(result["arc"]) == 13
arc = result["arc"]
assert isclose((arc[0] - Vector((tangent_offset, 0, 0))).length, 0.0, abs_tol=1e-6)
assert isclose((arc[-1] - Vector((0, tangent_offset, 0))).length, 0.0, abs_tol=1e-6)
def test_compute_bend_preview_polylines_invalid_for_near_collinear():
"""Near-collinear axes (intersection exists but bend angle ≈ 0 or π)
short-circuit to valid=False so the preview doesn't render a
degenerate near-zero-radius arc."""
from mathutils import Vector
from bonsai import tool
from bonsai.bim.module.model.mep import compute_bend_preview_polylines
start_obj, start_pair = _mock_obj_with_axis((1, 0, 0), (3, 0, 0))
end_obj, end_pair = _mock_obj_with_axis((-1, 0, 0), (-3, 0, 0))
intersection = (Vector((0, 0, 0)), Vector((0, 0, 0)))
with _with_axis_patches(start_pair, end_pair):
with patch.object(tool.Cad, "intersect_edges", return_value=intersection):
with patch.object(
tool.Cad,
"closest_and_furthest_vectors",
side_effect=lambda p, axis: (axis[0], axis[1]),
):
result = compute_bend_preview_polylines(start_obj, end_obj, 0.1, 0.1, 0.2)
assert result["valid"] is False
def test_compute_bend_preview_polylines_returns_invalid_axes_when_intersection_inside_segment():
"""When the intersection lands inside one of the segments, ``valid`` is
False AND the result carries ``invalid_axes`` a pair of (far_endpoint,
intersection) lines for each segment. ``BendPreviewDecorator`` reads
these to draw warning-red axes instead of rendering a degenerate arc."""
from mathutils import Vector
from bonsai import tool
from bonsai.bim.module.model.mep import compute_bend_preview_polylines
start_obj, start_pair = _mock_obj_with_axis((-3, 0, 0), (-1, 0, 0))
end_obj, end_pair = _mock_obj_with_axis((0, 5, 0), (0, 3, 0))
intersection = (Vector((-2, 0, 0)), Vector((-2, 0, 0)))
with _with_axis_patches(start_pair, end_pair):
with patch.object(tool.Cad, "intersect_edges", return_value=intersection):
with patch.object(
tool.Cad,
"closest_and_furthest_vectors",
# axis[0] = closer endpoint (near), axis[1] = farther (far).
side_effect=lambda p, axis: (axis[1], axis[0]),
):
result = compute_bend_preview_polylines(start_obj, end_obj, 0.1, 0.1, 0.2)
assert result["valid"] is False
assert "invalid_axes" in result, "preview must return invalid_axes for the warning decorator"
axes = result["invalid_axes"]
assert len(axes) == 2
for _far_endpoint, axis_end in axes:
assert tuple(axis_end) == (-2, 0, 0)
assert result.get("reason") in ("intersection_inside_start", "intersection_inside_end")
# ---------------------------------------------------------------------------
# _intersection_past_near — degenerate-intersection guard for the preview
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"intersection,near,far,expected",
[
# Normal: intersection past near, opposite side from far.
((0, 0, 0), (-1, 0, 0), (-3, 0, 0), True),
# Degenerate: intersection BETWEEN near and far (inside the segment).
((-2, 0, 0), (-1, 0, 0), (-3, 0, 0), False),
# Degenerate: intersection past FAR (opposite side from the bend).
((-4, 0, 0), (-1, 0, 0), (-3, 0, 0), False),
# Borderline: intersection coincides with near — within tolerance → False.
((-1, 0, 0), (-1, 0, 0), (-3, 0, 0), False),
# Degenerate: zero-length segment — can't classify, False.
((0, 0, 0), (-1, 0, 0), (-1, 0, 0), False),
],
)
def test_intersection_past_near(intersection, near, far, expected):
"""Pins the degenerate-intersection classification used by
``compute_bend_preview_polylines`` to reject in-segment intersections."""
from mathutils import Vector
from bonsai.bim.module.model.mep import _intersection_past_near
assert _intersection_past_near(Vector(intersection), Vector(near), Vector(far)) is expected
# ---------------------------------------------------------------------------
# Registration probes
# ---------------------------------------------------------------------------
def test_bend_preview_operators_are_registered():
"""The three bend-preview operators must resolve via ``bpy.ops.bim.*`` —
enable populates scene props, finish dispatches ``bim.mep_add_bend``
with the tuned params, cancel clears the state."""
assert hasattr(bpy.ops.bim, "enable_bend_preview")
assert hasattr(bpy.ops.bim, "finish_bend_preview")
assert hasattr(bpy.ops.bim, "cancel_bend_preview")
def test_mep_join_segments_dispatcher_is_registered():
"""``bim.mep_join_segments`` is the discoverable entry point for the
bend preview flow (F3 search "Join MEP Segments") until the full
gizmo-icon dispatch lands. Routes parallel transition, non-parallel
enable_bend_preview."""
assert hasattr(bpy.ops.bim, "mep_join_segments")
def test_bend_preview_gizmo_group_is_registered():
"""``GizmoBendPreview`` polls when ``scene.BIMPreviewProperties.bend.is_active``
is True. Pin the bl_idname so a typo wouldn't silently hide the preview
gizmos at runtime."""
from bonsai.bim.module.model.mep_bend_preview import GizmoBendPreview
assert GizmoBendPreview.bl_idname == "OBJECT_GGT_bim_bend_preview"
assert issubclass(GizmoBendPreview, bpy.types.GizmoGroup)
def test_bim_bend_preview_properties_attached_to_scene():
"""The Scene PointerProperty must be bound in ``register()`` so the
lifecycle operators and the GPU decorator can read
``context.scene.BIMPreviewProperties.bend.is_active``."""
assert hasattr(bpy.types.Scene, "BIMPreviewProperties")
assert hasattr(bpy.context.scene.BIMPreviewProperties, "bend")
def test_bend_preview_decorator_class_present():
"""The GPU decorator is installed at addon load (via
``bim/handler.py:load_post``). Verify the class exists with the
install / uninstall interface the handler expects."""
from bonsai.bim.module.model.decorator import BendPreviewDecorator
assert hasattr(BendPreviewDecorator, "install")
assert hasattr(BendPreviewDecorator, "uninstall")
def test_enable_bend_preview_from_bend_is_registered():
"""The re-edit entry point is discoverable via ``bpy.ops.bim`` so the
pen-icon dispatch in ``GizmoMEPActions`` resolves at click time."""
assert hasattr(bpy.ops.bim, "enable_bend_preview_from_bend")
def test_bim_bend_preview_properties_has_editing_bend_id():
"""The re-edit dispatch flag rides on the same preview PropertyGroup as
the rest of the bend draft state. Without this field on the umbrella,
re-edit cancel / commit cleanup would not zero it via
``clear_preview_state`` (which iterates ``*_id`` IntProperty fields)."""
bend_props = bpy.context.scene.BIMPreviewProperties.bend
assert hasattr(bend_props, "editing_bend_id")
assert bend_props.editing_bend_id == 0
@pytest.mark.parametrize(
"ifc_class,predefined_type,expected",
[
("IfcFlowFitting", "BEND", True),
("IfcFlowFitting", "TRANSITION", False),
("IfcFlowFitting", "OBSTRUCTION", False),
("IfcFlowFitting", None, False),
("IfcFlowSegment", "BEND", False),
("IfcWall", "BEND", False),
],
)
def test_is_bend_fitting_predicate_truth_table(ifc_class, predefined_type, expected):
"""The predicate classifies each occurrence by walking up to its type's
``PredefinedType``. Pin the four-way branch: matching class + matching
type, matching class + other type, wrong class, no type at all."""
from unittest.mock import Mock
from bonsai.bim.module.model.mep import _is_bend_fitting
element = Mock()
element.is_a = Mock(side_effect=lambda c: c == ifc_class)
if predefined_type is None:
element_type = None
else:
element_type = Mock()
element_type.PredefinedType = predefined_type
with patch("ifcopenshell.util.element.get_type", return_value=element_type):
assert _is_bend_fitting(element) is expected
def test_is_bend_fitting_predicate_returns_false_on_none():
"""The predicate is total — callers pass it raw ``tool.Ifc.get_entity``
results which can be ``None`` for unbound Blender objects, and the
visibility-condition lambda must not raise from a gizmo poll."""
from bonsai.bim.module.model.mep import _is_bend_fitting
assert _is_bend_fitting(None) is False
# ---------------------------------------------------------------------------
# Finish-catches-RuntimeError contract
# ---------------------------------------------------------------------------
def test_finish_bend_preview_catches_runtime_error_from_dispatch():
"""When the dispatched ``bim.mep_add_bend`` reports ERROR + returns
CANCELLED, ``bpy.ops`` promotes that to RuntimeError. Finish must catch
it and return CANCELLED propagating the exception leaves Blender's
operator state half-broken. Preview state must remain active so the
user can re-tune."""
from types import SimpleNamespace
from bonsai import tool
from bonsai.bim.module.model.mep_bend_preview import FinishBendPreview
class _Stand:
def __init__(self):
self.report = MagicMock()
op_self = _Stand()
fake_props = SimpleNamespace(
is_active=True,
start_segment_id=42,
end_segment_id=43,
start_length=0.1,
end_length=0.1,
radius=0.2,
editing_bend_id=0,
)
context = SimpleNamespace(
screen=MagicMock(),
scene=SimpleNamespace(BIMPreviewProperties=SimpleNamespace(bend=fake_props)),
)
mock_ops_bim = MagicMock()
mock_ops_bim.mep_add_bend.side_effect = RuntimeError("synthetic dispatch error")
with (
patch.object(tool.Ifc, "get", return_value=MagicMock(name="ifc_file")),
patch.object(bpy.ops, "bim", new=mock_ops_bim),
):
result = FinishBendPreview.execute(op_self, context)
assert "CANCELLED" in result, "RuntimeError from dispatch must be converted to CANCELLED"
assert fake_props.is_active is True, "failed dispatch must leave preview active for re-tune"
op_self.report.assert_called()
@@ -0,0 +1,188 @@
# 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.
"""Cache-invalidation tests for ``cached_compute_bend_preview_polylines``.
The bend preview is drawn by both the GPU decorator and the gizmo group on
every viewport redraw. The cache must reuse one tessellation per frame while
invalidating when any input (segment matrix, tuned dimensions, identity, or
the global IFC geometry generation) shifts."""
from unittest.mock import Mock, patch
import pytest
from mathutils import Matrix
pytestmark = pytest.mark.model
def _mock_obj(name: str, matrix: Matrix) -> Mock:
obj = Mock()
obj.name = name
obj.matrix_world = matrix
return obj
@pytest.fixture(autouse=True)
def _clear_memo():
from bonsai.bim.module.model import mep
mep._bend_preview_memo = None
yield
mep._bend_preview_memo = None
def _patches(call_count_sentinel: dict):
from bonsai import tool
from bonsai.bim.module.model import mep
def counting_compute(*args, **kwargs):
call_count_sentinel["calls"] += 1
return {"valid": True, "leg_a": None, "leg_b": None, "arc": []}
return (
patch.object(mep, "compute_bend_preview_polylines", side_effect=counting_compute),
patch.object(tool.Parametric, "get_geom_generation", return_value=call_count_sentinel.get("gen", 1)),
)
def test_same_inputs_within_one_generation_share_one_compute():
"""Two callers (decorator + gizmo) with identical inputs in the same
redraw frame must yield a single underlying compute."""
from bonsai.bim.module.model.mep import cached_compute_bend_preview_polylines
a = _mock_obj("seg_a", Matrix.Identity(4))
b = _mock_obj("seg_b", Matrix.Translation((1, 0, 0)))
sentinel = {"calls": 0, "gen": 7}
p_compute, p_gen = _patches(sentinel)
with p_compute, p_gen:
cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3)
cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3)
assert sentinel["calls"] == 1
def test_radius_change_invalidates_cache():
from bonsai.bim.module.model.mep import cached_compute_bend_preview_polylines
a = _mock_obj("seg_a", Matrix.Identity(4))
b = _mock_obj("seg_b", Matrix.Translation((1, 0, 0)))
sentinel = {"calls": 0, "gen": 1}
p_compute, p_gen = _patches(sentinel)
with p_compute, p_gen:
cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3)
cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.4) # radius changed
assert sentinel["calls"] == 2
def test_start_length_change_invalidates_cache():
from bonsai.bim.module.model.mep import cached_compute_bend_preview_polylines
a = _mock_obj("seg_a", Matrix.Identity(4))
b = _mock_obj("seg_b", Matrix.Translation((1, 0, 0)))
sentinel = {"calls": 0, "gen": 1}
p_compute, p_gen = _patches(sentinel)
with p_compute, p_gen:
cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3)
cached_compute_bend_preview_polylines(a, b, 0.15, 0.2, 0.3) # start_length changed
assert sentinel["calls"] == 2
def test_end_length_change_invalidates_cache():
from bonsai.bim.module.model.mep import cached_compute_bend_preview_polylines
a = _mock_obj("seg_a", Matrix.Identity(4))
b = _mock_obj("seg_b", Matrix.Translation((1, 0, 0)))
sentinel = {"calls": 0, "gen": 1}
p_compute, p_gen = _patches(sentinel)
with p_compute, p_gen:
cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3)
cached_compute_bend_preview_polylines(a, b, 0.1, 0.25, 0.3) # end_length changed
assert sentinel["calls"] == 2
def test_segment_matrix_change_invalidates_cache():
"""Moving either segment changes the bend geometry — the cache must
recompute even when the IFC has not advanced."""
from bonsai.bim.module.model.mep import cached_compute_bend_preview_polylines
a = _mock_obj("seg_a", Matrix.Identity(4))
b = _mock_obj("seg_b", Matrix.Translation((1, 0, 0)))
sentinel = {"calls": 0, "gen": 1}
p_compute, p_gen = _patches(sentinel)
with p_compute, p_gen:
cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3)
b.matrix_world = Matrix.Translation((2, 0, 0))
cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3)
assert sentinel["calls"] == 2
def test_geom_generation_advance_invalidates_cache():
"""An IFC operator commit bumps ``tool.Parametric.get_geom_generation``;
the cache must recompute on the next call to pick up downstream geometry
changes that don't surface in the object's matrix_world."""
from bonsai import tool
from bonsai.bim.module.model import mep
from bonsai.bim.module.model.mep import cached_compute_bend_preview_polylines
a = _mock_obj("seg_a", Matrix.Identity(4))
b = _mock_obj("seg_b", Matrix.Translation((1, 0, 0)))
sentinel = {"calls": 0}
def counting_compute(*args, **kwargs):
sentinel["calls"] += 1
return {"valid": True, "leg_a": None, "leg_b": None, "arc": []}
gen_state = {"gen": 1}
with patch.object(mep, "compute_bend_preview_polylines", side_effect=counting_compute):
with patch.object(tool.Parametric, "get_geom_generation", side_effect=lambda: gen_state["gen"]):
cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3)
gen_state["gen"] = 2
cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3)
assert sentinel["calls"] == 2
def test_swapping_one_segment_invalidates_cache():
"""Selecting a different segment pair (different object identity) must
recompute even when matrices coincidentally match."""
from bonsai.bim.module.model.mep import cached_compute_bend_preview_polylines
a = _mock_obj("seg_a", Matrix.Identity(4))
b = _mock_obj("seg_b", Matrix.Translation((1, 0, 0)))
c = _mock_obj("seg_c", Matrix.Translation((1, 0, 0)))
sentinel = {"calls": 0, "gen": 1}
p_compute, p_gen = _patches(sentinel)
with p_compute, p_gen:
cached_compute_bend_preview_polylines(a, b, 0.1, 0.2, 0.3)
cached_compute_bend_preview_polylines(a, c, 0.1, 0.2, 0.3)
assert sentinel["calls"] == 2
@@ -0,0 +1,202 @@
# 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.
"""Pure-math tests for the bend tessellation helpers (FIXME #8106).
Pins the geometry contracts the hand-meshed bend body relies on while the
upstream IfcSweptDiskSolid round-trip is broken:
- profile cross-section sampling for circle / rectangle / unsupported
- parallel-transport framing along the centerline (the contract that
eliminates the twist a fixed world-reference basis produces)
- ``initial_basis`` override that aligns the cross-section with the
source segment's local +X / +Y axes (the asymmetric-rectangle fix)"""
from math import cos, pi, sin
from unittest.mock import Mock
import bpy
import pytest
from mathutils import Vector
pytestmark = pytest.mark.model
# ---------------------------------------------------------------------------
# _bend_profile_cross_section — IFC profile → 2D sample points
# ---------------------------------------------------------------------------
def test_profile_cross_section_circle_returns_evenly_spaced_ring():
"""Circle profiles sample 16 points by default, equally spaced around
the radius. First vert sits at ``(radius, 0)`` so the mesh's local
angular zero aligns with the sweep basis ``right`` axis."""
from bonsai.bim.module.model.mep import _bend_profile_cross_section
profile = Mock()
profile.Radius = 0.1
profile.is_a = lambda c: c == "IfcCircleProfileDef"
pts = _bend_profile_cross_section(profile)
assert pts is not None
assert len(pts) == 16
assert pts[0] == pytest.approx((0.1, 0.0))
# All points lie on the circle.
for x, y in pts:
assert (x * x + y * y) == pytest.approx(0.1 * 0.1, abs=1e-9)
def test_profile_cross_section_circle_respects_n_circle_parameter():
"""The sample count is configurable; verify a non-default value
flows through to the result length."""
from bonsai.bim.module.model.mep import _bend_profile_cross_section
profile = Mock()
profile.Radius = 0.05
profile.is_a = lambda c: c == "IfcCircleProfileDef"
pts = _bend_profile_cross_section(profile, n_circle=8)
assert len(pts) == 8
def test_profile_cross_section_rectangle_returns_four_corners():
"""Rectangle profiles return exactly four corners, in the canonical
``[(-X/2,-Y/2), (X/2,-Y/2), (X/2,Y/2), (-X/2,Y/2)]`` winding."""
from bonsai.bim.module.model.mep import _bend_profile_cross_section
profile = Mock()
profile.XDim = 0.4
profile.YDim = 0.2
profile.is_a = lambda c: c == "IfcRectangleProfileDef"
pts = _bend_profile_cross_section(profile)
assert pts == [(-0.2, -0.1), (0.2, -0.1), (0.2, 0.1), (-0.2, 0.1)]
def test_profile_cross_section_unsupported_returns_none():
"""Profiles other than circle / rectangle (e.g.
``IfcArbitraryClosedProfileDef``) return ``None`` so the tessellation
helper skips the rep swap rather than building geometry against the
wrong cross-section."""
from bonsai.bim.module.model.mep import _bend_profile_cross_section
profile = Mock()
profile.is_a = lambda c: c == "IfcArbitraryClosedProfileDef"
assert _bend_profile_cross_section(profile) is None
# ---------------------------------------------------------------------------
# _sweep_profile_along_polyline — vert + face count + parallel transport
# ---------------------------------------------------------------------------
def test_sweep_along_straight_polyline_builds_closed_tube_with_caps():
"""Straight 3-ring centerline + 4-vert profile yields 12 ring verts,
3 quads × 4 sides = 12 side quads, plus two end-cap triangles per end
(4-vert profile fans into 2 triangles)."""
from bonsai.bim.module.model.mep import _sweep_profile_along_polyline
centerline = [Vector((0.0, 0.0, 0.0)), Vector((0.0, 0.0, 1.0)), Vector((0.0, 0.0, 2.0))]
profile_2d = [(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)]
verts, faces = _sweep_profile_along_polyline(centerline, profile_2d)
assert len(verts) == 3 * 4, "3 rings × 4 profile verts"
# 2 ring gaps × 4 quads each = 8 side faces; 2 caps × 2 triangles = 4 cap faces.
quad_count = sum(1 for f in faces if len(f) == 4)
tri_count = sum(1 for f in faces if len(f) == 3)
assert quad_count == 8, "one quad per profile edge per ring gap"
assert tri_count == 4, "fan triangulation gives n_profile - 2 = 2 tris per cap"
def test_sweep_parallel_transports_basis_around_right_angle_corner():
"""L-shaped centerline (turn from +Z to +X). After the corner, the
cross-section's reference direction is rotated 90° from before — the
parallel-transport invariant. Pin via the first verts of the start
and end rings: starts perpendicular to +Z (so in XY), ends
perpendicular to +X (so in YZ)."""
from bonsai.bim.module.model.mep import _sweep_profile_along_polyline
centerline = [
Vector((0.0, 0.0, 0.0)),
Vector((0.0, 0.0, 1.0)),
Vector((1.0, 0.0, 1.0)),
Vector((2.0, 0.0, 1.0)),
]
# Single-vert profile would degenerate; use a 4-vert square so we
# have something to project onto each ring's basis.
profile_2d = [(0.1, 0.0), (0.0, 0.1), (-0.1, 0.0), (0.0, -0.1)]
verts, _ = _sweep_profile_along_polyline(centerline, profile_2d)
# First ring's verts must lie in a plane perpendicular to +Z (the
# tangent at the first ring). Verify each vert has |z-ring_center.z| ≈ 0.
first_ring = verts[0:4]
for v in first_ring:
assert v.z == pytest.approx(0.0, abs=1e-6), f"first-ring vert off the start plane: {v}"
# Last ring's tangent is +X (last centerline segment). Verts should
# lie in a plane perpendicular to +X — i.e. x ≈ 2.0 (the centerline's
# x at the last ring).
last_ring = verts[-4:]
for v in last_ring:
assert v.x == pytest.approx(2.0, abs=1e-6), f"last-ring vert off the end plane: {v}"
def test_sweep_initial_basis_override_aligns_first_ring_with_segment_axes():
"""The asymmetric-rectangle fix: caller supplies the segment's local
+X / +Y axes (in world space) as ``initial_basis``; the helper uses
those as the first ring's basis instead of the world-Z seed. Verify
by checking that the first profile vert lands at ``ring0 + right *
sx + up * sy`` for the provided right / up."""
from bonsai.bim.module.model.mep import _sweep_profile_along_polyline
centerline = [Vector((0.0, 0.0, 0.0)), Vector((0.0, 0.0, 1.0))]
# Profile sample at (0.5, 0) — a single point on the +X profile axis.
profile_2d = [(0.5, 0.0)]
# Initial basis where right = +Y world, up = +X world (rotated 90°
# from the default world-Z seed which would give right ≈ -Y).
initial_basis = (Vector((0.0, 1.0, 0.0)), Vector((1.0, 0.0, 0.0)))
verts, _ = _sweep_profile_along_polyline(centerline, profile_2d, initial_basis=initial_basis)
# First vert = ring0 (0,0,0) + right * 0.5 + up * 0 = (0, 0.5, 0).
assert tuple(verts[0]) == pytest.approx((0.0, 0.5, 0.0), abs=1e-6)
def test_sweep_default_seed_uses_world_z_reference():
"""Without an ``initial_basis``, the helper falls back to a stable
world-Z reference for the first ring. Pin so a future refactor of
the fallback doesn't silently change the orientation for callers
that rely on the default (the bend preview decorator's debug draw
path, for instance)."""
from bonsai.bim.module.model.mep import _sweep_profile_along_polyline
centerline = [Vector((0.0, 0.0, 0.0)), Vector((1.0, 0.0, 0.0))]
profile_2d = [(1.0, 0.0)]
verts, _ = _sweep_profile_along_polyline(centerline, profile_2d)
# First tangent = +X. world-Z up_ref → right = tangent × up_ref =
# (1,0,0) × (0,0,1) = (0,-1,0). up = right × tangent = (0,0,1).
# First vert at right * 1.0 = (0, -1, 0).
assert tuple(verts[0]) == pytest.approx((0.0, -1.0, 0.0), abs=1e-6)
@@ -0,0 +1,227 @@
# 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.
"""Smoke coverage for ``RegenerateDistributionElement`` and
``FitFlowSegments``.
Both operators carry substantial branching that the bend / port test
files don't reach. These tests pin:
- the operator-registration contract (bl_idname / bl_label / bl_options),
- ``FitFlowSegments`` dispatch table 0 / 1 / mixed-class selections
resolve to the documented no-op or operator dispatch without raising,
- ``RegenerateDistributionElement`` runs on a leaf element (no connected
neighbours) without crashing on the recursion entry point.
Deeper geometry-tree behaviour (multi-branch traversal, port-aligned
translation, segment regrowth) is deferred to integration testing
against real IFC fixtures; the smoke tests are explicitly the
oversight-prevention floor, not the full contract."""
from unittest.mock import MagicMock, Mock, patch
import bpy
import pytest
pytestmark = pytest.mark.model
def _segment(ifc_class: str = "IfcFlowSegment"):
"""Stand-in for an IfcFlowSegment / subclass entity.
``is_a("IfcFlowSegment" | <ifc_class>)`` returns True; ``is_a()`` with
no args returns the class name (the IfcOpenShell API exposes both
forms ``FitFlowSegments`` calls ``element.is_a()`` to record the
selection's class for the mixed-class refusal check)."""
def fake_is_a(c=None):
if c is None:
return ifc_class
return c in {"IfcFlowSegment", ifc_class}
e = Mock()
e.is_a = fake_is_a
return e
def _make_op(**fields):
op = Mock()
for k, v in fields.items():
setattr(op, k, v)
op.report = MagicMock()
return op
# ---------------------------------------------------------------------------
# Registration smoke
# ---------------------------------------------------------------------------
def test_regenerate_distribution_element_is_registered():
"""``RegenerateDistributionElement`` is the entry point for the
distribution-tree repropagation. Pin the bl_idname so a typo in the
classes tuple wouldn't silently drop the operator."""
from bonsai.bim.module.model import mep
assert mep.RegenerateDistributionElement.bl_idname == "bim.regenerate_distribution_element"
assert mep.RegenerateDistributionElement.bl_label == "Regenerate Distribution Element"
assert mep.RegenerateDistributionElement.bl_options == {"REGISTER", "UNDO"}
def test_fit_flow_segments_is_registered():
"""``FitFlowSegments`` is the cursor-based "add a fitting from the
current selection" entry point. Pin the registration contract so the
operator stays callable from the workspace tool."""
from bonsai.bim.module.model import mep
assert mep.FitFlowSegments.bl_idname == "bim.fit_flow_segments"
assert mep.FitFlowSegments.bl_label == "Fit Flow Segments"
assert mep.FitFlowSegments.bl_options == {"REGISTER", "UNDO"}
# ---------------------------------------------------------------------------
# FitFlowSegments dispatch table
# ---------------------------------------------------------------------------
def test_fit_flow_segments_with_no_selection_is_noop():
"""Nothing selected → no fitting type resolved → operator returns
without dispatching any ``bim.mep_add_*`` op. The user-facing
contract is "this is a tool you fire with a selection"; the silent
no-op on empty selection is intentional (no popup, no error)."""
from bonsai.bim.module.model import mep
context = MagicMock()
context.selected_objects = []
op = _make_op()
with patch.object(mep.MEPAddObstruction, "_execute", return_value=None) as obstruction, patch.object(
mep.MEPAddBend, "_execute", return_value=None
) as bend, patch.object(mep.MEPAddTransition, "_execute", return_value=None) as transition:
mep.FitFlowSegments._execute(op, context=context)
obstruction.assert_not_called()
bend.assert_not_called()
transition.assert_not_called()
def test_fit_flow_segments_with_single_segment_dispatches_obstruction():
"""Exactly one IfcFlowSegment selected → OBSTRUCTION fitting type,
delegates to ``bim.mep_add_obstruction`` which handles the
cursor-anchored placement.
``bpy.ops`` resolves operator dispatch through Blender's internal id
table, not through Python attribute access, so a Python-level patch
on ``bpy.ops.bim.mep_add_obstruction`` doesn't intercept the call.
Patch the operator's ``_execute`` instead — same effect, exercises
the real dispatch path that the user hits at runtime."""
from bonsai.bim.module.model import mep
segment_obj = MagicMock()
segment_profile = MagicMock()
segment_entity = _segment("IfcPipeSegment")
context = MagicMock()
context.selected_objects = [segment_obj]
op = _make_op()
with patch.object(mep.tool.Ifc, "get_entity", return_value=segment_entity), patch.object(
mep.tool.Model, "get_flow_segment_profile", return_value=segment_profile
), patch.object(mep.MEPAddObstruction, "_execute", return_value=None) as obstruction, patch.object(
mep.MEPAddBend, "_execute", return_value=None
) as bend, patch.object(mep.MEPAddTransition, "_execute", return_value=None) as transition:
mep.FitFlowSegments._execute(op, context=context)
assert obstruction.call_count == 1
bend.assert_not_called()
transition.assert_not_called()
def test_fit_flow_segments_refuses_mixed_pipe_and_duct():
"""Selecting one IfcPipeSegment + one IfcDuctSegment → the operator
bails out before any fitting dispatch. The user-facing path is
"select segments of one kind"; mixing pipe + duct would create an
invalid IFC fitting type."""
from bonsai.bim.module.model import mep
pipe_obj = MagicMock()
duct_obj = MagicMock()
pipe_entity = _segment("IfcPipeSegment")
duct_entity = _segment("IfcDuctSegment")
profile = MagicMock()
context = MagicMock()
context.selected_objects = [pipe_obj, duct_obj]
def fake_get_entity(obj):
return pipe_entity if obj is pipe_obj else duct_entity
op = _make_op()
with patch.object(mep.tool.Ifc, "get_entity", side_effect=fake_get_entity), patch.object(
mep.tool.Model, "get_flow_segment_profile", return_value=profile
), patch.object(mep.MEPAddObstruction, "_execute", return_value=None) as obstruction, patch.object(
mep.MEPAddBend, "_execute", return_value=None
) as bend, patch.object(mep.MEPAddTransition, "_execute", return_value=None) as transition:
mep.FitFlowSegments._execute(op, context=context)
obstruction.assert_not_called()
bend.assert_not_called()
transition.assert_not_called()
# ---------------------------------------------------------------------------
# RegenerateDistributionElement
# ---------------------------------------------------------------------------
def test_regenerate_distribution_element_on_leaf_is_safe():
"""A distribution element with no connected neighbours → the inner
queue stays empty the operator returns cleanly without entering
the per-branch processing path.
This pins the safety floor: the recursion entry point should not
crash on a single-element graph, which is the most common shape
when a user fires this operator on an isolated segment."""
from bonsai.bim.module.model import mep
leaf_element = _segment("IfcPipeSegment")
leaf_obj = MagicMock()
context = MagicMock()
context.active_object = leaf_obj
fake_active = MagicMock()
fake_active.is_a = lambda c: False # bpy.context.active_object stub
op = _make_op()
with patch.object(mep.tool.Ifc, "get_entity", return_value=leaf_element), patch(
"ifcopenshell.util.system.get_connected_to", return_value=[]
), patch("ifcopenshell.util.system.get_connected_from", return_value=[]), patch.object(
mep.tool.Ifc, "get", return_value=MagicMock()
), patch(
"ifcopenshell.util.unit.calculate_unit_scale", return_value=1.0
), patch.object(
bpy, "context", new=context
):
mep.RegenerateDistributionElement._execute(op, context=context)
# The contract on a leaf is "nothing to do". No exception, no IFC
# mutation. The bpy.ops dispatch table inside process_branch never
# fires because queue is empty.
@@ -0,0 +1,388 @@
# 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 tests for the MEP port operators.
Pins the dispatch contract each operator carries which IFC mutation
runs, which user-error path returns CANCELLED, and which fitting types
are deliberately refused by each entry point. Each test mocks the
``tool.*`` and ``MEPGenerator`` boundaries so no IFC fixture is needed."""
from unittest.mock import MagicMock, Mock, patch
import bpy
import pytest
pytestmark = pytest.mark.model
def _segment(predefined_type=None):
"""Stand-in IFC entity that reports ``is_a("IfcFlowSegment")`` True."""
e = Mock()
e.is_a = lambda c: c == "IfcFlowSegment"
e.PredefinedType = predefined_type
return e
def _fitting(predefined_type=None):
"""Stand-in IFC fitting entity with an arbitrary ``PredefinedType``."""
e = Mock()
e.is_a = lambda c: c in ("IfcFlowFitting", "IfcDistributionFlowElement")
e.PredefinedType = predefined_type
return e
def _make_op(_cls, **fields):
"""Return a Mock standing in for an Operator ``self``. Subclassing a
``bpy.types.Operator`` outside Blender's registration machinery raises
a ``bpy_struct.__new__`` error, so each test calls the operator
method as an unbound function with this Mock as the first argument."""
op = Mock()
for k, v in fields.items():
setattr(op, k, v)
op.report = MagicMock()
return op
# ---------------------------------------------------------------------------
# MEPUnjoinAtPort
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"port_state, fitting_predefined_type, expected_result, expects_delete",
[
pytest.param("JOINED", "JUNCTION", {"FINISHED"}, True, id="joined_junction_deletes"),
pytest.param("JOINED", "OBSTRUCTION", {"CANCELLED"}, False, id="joined_obstruction_refused"),
pytest.param("FREE", None, {"CANCELLED"}, False, id="free_port_cancels"),
],
)
def test_unjoin_at_port_dispatch_table(port_state, fitting_predefined_type, expected_result, expects_delete):
"""``MEPUnjoinAtPort`` dispatch contract: result and delete-side-effect
by ``(port_state, fitting type)``.
- ``JOINED + JUNCTION`` (or any non-OBSTRUCTION fitting): happy path,
the bridging fitting is deleted via the standard delete entry point.
- ``JOINED + OBSTRUCTION``: deliberately refused obstructions go
through ``bim.mep_add_obstruction`` (mode=REMOVE) so the segment
extends to absorb the freed length; using delete here would leave
a visible gap.
- ``FREE``: nothing to do no bridging fitting exists. The operator
reports a user-facing error and CANCELS rather than no-op silently."""
from bonsai.bim.module.model import mep
segment = _segment()
fitting = _fitting(predefined_type=fitting_predefined_type) if fitting_predefined_type else None
fitting_obj = Mock()
op = _make_op(mep.MEPUnjoinAtPort, segment_id=42, position="END")
ifc_file = MagicMock()
ifc_file.by_id.return_value = segment
with patch.object(mep.tool.Ifc, "get", return_value=ifc_file), patch.object(
mep.tool.Ifc, "get_object", return_value=fitting_obj
), patch.object(mep, "port_connection_state", return_value=port_state), patch.object(
mep, "get_connected_element_at_segment_port", return_value=fitting
), patch.object(
mep.tool.Geometry, "delete_ifc_object"
) as delete:
result = mep.MEPUnjoinAtPort._execute(op, context=MagicMock())
assert result == expected_result
if expects_delete:
delete.assert_called_once_with(fitting_obj)
else:
delete.assert_not_called()
op.report.assert_called()
def test_unjoin_at_port_cancels_when_active_is_not_segment():
"""The operator only operates on flow segments; non-segment active
objects must fail loud rather than mutate something unexpected."""
from bonsai.bim.module.model import mep
fitting = _fitting() # IfcFlowFitting, not IfcFlowSegment
op = _make_op(mep.MEPUnjoinAtPort, segment_id=42, position="END")
ifc_file = MagicMock()
ifc_file.by_id.return_value = fitting
with patch.object(mep.tool.Ifc, "get", return_value=ifc_file):
result = mep.MEPUnjoinAtPort._execute(op, context=MagicMock())
assert result == {"CANCELLED"}
op.report.assert_called()
# ---------------------------------------------------------------------------
# MEPRemoveTerminalFitting
# ---------------------------------------------------------------------------
def test_remove_terminal_dispatches_obstruction_via_remove_obstruction():
"""OBSTRUCTION fittings extend the segment to absorb the freed length;
the operator routes through ``MEPGenerator().remove_obstruction``
rather than the plain delete path."""
from bonsai.bim.module.model import mep
segment = _segment()
obstruction = _fitting(predefined_type="OBSTRUCTION")
op = _make_op(mep.MEPRemoveTerminalFitting, segment_id=42, position="END")
ifc_file = MagicMock()
ifc_file.by_id.return_value = segment
with patch.object(mep.tool.Ifc, "get", return_value=ifc_file), patch.object(
mep, "port_connection_state", return_value="TERMINAL"
), patch.object(mep, "get_connected_element_at_segment_port", return_value=obstruction), patch.object(
mep, "MEPGenerator"
) as gen_cls, patch.object(
mep.tool.Geometry, "delete_ifc_object"
) as delete:
gen_cls.return_value.remove_obstruction.return_value = (obstruction, None)
result = mep.MEPRemoveTerminalFitting._execute(op, context=MagicMock())
assert result == {"FINISHED"}
gen_cls.return_value.remove_obstruction.assert_called_once_with(segment, False)
delete.assert_not_called()
def test_remove_terminal_dispatches_non_obstruction_via_delete():
"""A standard terminal fitting (cap, isolated terminal) goes through
the plain delete path the segment is not resized."""
from bonsai.bim.module.model import mep
segment = _segment()
fitting = _fitting(predefined_type=None)
fitting_obj = Mock()
op = _make_op(mep.MEPRemoveTerminalFitting, segment_id=42, position="END")
ifc_file = MagicMock()
ifc_file.by_id.return_value = segment
with patch.object(mep.tool.Ifc, "get", return_value=ifc_file), patch.object(
mep.tool.Ifc, "get_object", return_value=fitting_obj
), patch.object(mep, "port_connection_state", return_value="TERMINAL"), patch.object(
mep, "get_connected_element_at_segment_port", return_value=fitting
), patch.object(
mep.tool.Geometry, "delete_ifc_object"
) as delete:
result = mep.MEPRemoveTerminalFitting._execute(op, context=MagicMock())
assert result == {"FINISHED"}
delete.assert_called_once_with(fitting_obj)
def test_remove_terminal_cancels_on_non_terminal_port():
"""Port state must be TERMINAL for this operator; FREE / JOINED are
routed through other operators."""
from bonsai.bim.module.model import mep
segment = _segment()
op = _make_op(mep.MEPRemoveTerminalFitting, segment_id=42, position="END")
ifc_file = MagicMock()
ifc_file.by_id.return_value = segment
with patch.object(mep.tool.Ifc, "get", return_value=ifc_file), patch.object(
mep, "port_connection_state", return_value="JOINED"
):
result = mep.MEPRemoveTerminalFitting._execute(op, context=MagicMock())
assert result == {"CANCELLED"}
op.report.assert_called()
# ---------------------------------------------------------------------------
# MEPUnjoinPair
# ---------------------------------------------------------------------------
def test_unjoin_pair_deletes_bridging_fitting():
"""Happy path: two selected segments share a single non-OBSTRUCTION
bridging fitting delete it."""
from bonsai.bim.module.model import mep
segment_a = _segment()
segment_b = _segment()
fitting = _fitting(predefined_type="JUNCTION")
fitting_obj = Mock()
op = _make_op(mep.MEPUnjoinPair)
selected = [Mock(), Mock()]
with patch.object(mep.tool.Blender, "get_selected_objects", return_value=selected), patch.object(
mep.tool.Ifc, "get_entity", side_effect=[segment_a, segment_b]
), patch.object(mep, "find_fitting_between_segments", return_value=fitting), patch.object(
mep.tool.Ifc, "get_object", return_value=fitting_obj
), patch.object(
mep.tool.Geometry, "delete_ifc_object"
) as delete:
result = mep.MEPUnjoinPair._execute(op, context=MagicMock())
assert result == {"FINISHED"}
delete.assert_called_once_with(fitting_obj)
def test_unjoin_pair_refuses_obstruction_bridging():
"""Same defence-in-depth as ``MEPUnjoinAtPort`` — obstructions go
through the dedicated REMOVE path; this operator surfaces the
redirect rather than silently doing the wrong thing."""
from bonsai.bim.module.model import mep
segment_a = _segment()
segment_b = _segment()
obstruction = _fitting(predefined_type="OBSTRUCTION")
op = _make_op(mep.MEPUnjoinPair)
selected = [Mock(), Mock()]
with patch.object(mep.tool.Blender, "get_selected_objects", return_value=selected), patch.object(
mep.tool.Ifc, "get_entity", side_effect=[segment_a, segment_b]
), patch.object(mep, "find_fitting_between_segments", return_value=obstruction), patch.object(
mep.tool.Geometry, "delete_ifc_object"
) as delete:
result = mep.MEPUnjoinPair._execute(op, context=MagicMock())
assert result == {"CANCELLED"}
delete.assert_not_called()
op.report.assert_called()
def test_unjoin_pair_reports_when_no_bridging_fitting_found():
"""The pair is selected but no single fitting bridges them — the
user is told instead of getting a silent no-op."""
from bonsai.bim.module.model import mep
segment_a = _segment()
segment_b = _segment()
op = _make_op(mep.MEPUnjoinPair)
selected = [Mock(), Mock()]
with patch.object(mep.tool.Blender, "get_selected_objects", return_value=selected), patch.object(
mep.tool.Ifc, "get_entity", side_effect=[segment_a, segment_b]
), patch.object(mep, "find_fitting_between_segments", return_value=None), patch.object(
mep.tool.Geometry, "delete_ifc_object"
) as delete:
result = mep.MEPUnjoinPair._execute(op, context=MagicMock())
assert result == {"CANCELLED"}
delete.assert_not_called()
op.report.assert_called()
def test_unjoin_pair_cancels_when_selection_is_not_two_segments():
"""The poll filters the gizmo, but a programmatic invocation could
still hand the operator an invalid selection. The execute path
independently verifies both inputs are IfcFlowSegment."""
from bonsai.bim.module.model import mep
not_a_segment = _fitting() # IfcFlowFitting, not IfcFlowSegment
op = _make_op(mep.MEPUnjoinPair)
selected = [Mock(), Mock()]
with patch.object(mep.tool.Blender, "get_selected_objects", return_value=selected), patch.object(
mep.tool.Ifc, "get_entity", side_effect=[not_a_segment, not_a_segment]
):
result = mep.MEPUnjoinPair._execute(op, context=MagicMock())
assert result == {"CANCELLED"}
op.report.assert_called()
# ---------------------------------------------------------------------------
# SelectMEPPathMembers
# ---------------------------------------------------------------------------
def test_select_path_replaces_selection_with_walked_members():
"""Happy path: walker returns a small connected network → every
member gets ``select_set(True)``; the original active object stays
active."""
from bonsai.bim.module.model import mep
active = Mock()
element = Mock()
member_elements = [Mock(), Mock(), Mock()]
member_objs = [Mock(), Mock(), Mock()]
context = MagicMock()
context.active_object = active
context.view_layer.objects.active = None
op = _make_op(mep.SelectMEPPathMembers)
with patch.object(mep.tool.Ifc, "get_entity", return_value=element), patch.object(
mep.tool.System, "walk_connected_mep_elements", return_value=member_elements
), patch.object(mep.tool.Ifc, "get_object", side_effect=member_objs), patch.object(
mep.bpy.ops.object, "select_all"
):
result = mep.SelectMEPPathMembers.execute(op, context)
assert result == {"FINISHED"}
for obj in member_objs:
obj.select_set.assert_called_once_with(True)
def test_select_path_reports_when_walker_returns_empty():
"""An MEP element with no connected neighbours produces an empty
walk; report INFO so the user knows the click registered, return
FINISHED so the operator doesn't surface as an error."""
from bonsai.bim.module.model import mep
active = Mock()
element = Mock()
context = MagicMock()
context.active_object = active
op = _make_op(mep.SelectMEPPathMembers)
with patch.object(mep.tool.Ifc, "get_entity", return_value=element), patch.object(
mep.tool.System, "walk_connected_mep_elements", return_value=[]
):
result = mep.SelectMEPPathMembers.execute(op, context)
assert result == {"FINISHED"}
op.report.assert_called()
def test_select_path_handles_walker_exception():
"""The walker can raise on malformed port graphs; the operator must
catch and surface as ERROR rather than crashing the operator harness."""
from bonsai.bim.module.model import mep
active = Mock()
element = Mock()
context = MagicMock()
context.active_object = active
op = _make_op(mep.SelectMEPPathMembers)
with patch.object(mep.tool.Ifc, "get_entity", return_value=element), patch.object(
mep.tool.System, "walk_connected_mep_elements", side_effect=RuntimeError("malformed port graph")
):
result = mep.SelectMEPPathMembers.execute(op, context)
assert result == {"CANCELLED"}
op.report.assert_called()
@@ -0,0 +1,533 @@
# 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 pipe/duct segment parametric-edit scaffolding.
Covers three surfaces that ship together as the first MEP dimension-gizmo
feature:
- ``tool.Parametric.is_pipe_segment`` / ``is_duct_segment`` predicates
(registry contract must be total).
- ``_segment_world_length`` / ``_preview_segment_via_scale`` /
``_restore_segment_scale`` pure helpers driving the live preview.
- ``GizmoPipeSegmentEdition`` / ``GizmoDuctSegmentEdition`` class wiring
(bl_idname, operator bindings, dimension_gizmo_props, is_element_type).
Full operator round-trips (enable drag finish IFC commit) need a real
Blender + IFC scene and are deferred to a later integration session."""
from unittest.mock import Mock, patch
import bpy
import ifcopenshell
import pytest
from mathutils import Matrix, Vector
pytestmark = pytest.mark.model
# ---------------------------------------------------------------------------
# Predicates — total over arbitrary IFC entity input
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"ifc_class,is_pipe_expected,is_duct_expected",
[
("IfcPipeSegment", True, False),
("IfcDuctSegment", False, True),
("IfcFlowSegment", False, False), # base class — neither pipe nor duct alone
("IfcPipeFitting", False, False), # fitting, not a segment
("IfcDuctFitting", False, False),
("IfcWall", False, False),
("IfcAnnotation", False, False), # bare schema element with no MEP semantics
],
)
def test_is_pipe_or_duct_segment_predicate_truth_table(ifc_class, is_pipe_expected, is_duct_expected):
"""The two predicates must classify every IFC class correctly AND
return False (not raise) on classes that have nothing to do with MEP.
Pinned alongside the registry-wide predicate-totality test so a
regression in either direction surfaces in this file too."""
from bonsai import tool
probe = ifcopenshell.file(schema="IFC4").create_entity(ifc_class)
assert tool.Parametric.is_pipe_segment(probe) is is_pipe_expected
assert tool.Parametric.is_duct_segment(probe) is is_duct_expected
# ---------------------------------------------------------------------------
# _segment_world_length — pure geometric helper
# ---------------------------------------------------------------------------
def test_segment_world_length_returns_axis_magnitude():
"""The length read here drives both the dimension gizmo's display and
the snap_length captured on enable. Pin the math on a known axis."""
from bonsai.bim.module.model.mep import _segment_world_length
fake_obj = object()
axis = (Vector((1.0, 2.0, 3.0)), Vector((1.0, 2.0, 5.5)))
with patch("bonsai.tool.Model.get_flow_segment_axis", return_value=axis):
assert _segment_world_length(fake_obj) == pytest.approx(2.5)
# ---------------------------------------------------------------------------
# Preview helpers — obj.scale.z manipulation
# ---------------------------------------------------------------------------
class _FakeObj:
"""Stand-in for bpy.types.Object exposing only ``scale`` — enough for
the preview helpers, which never touch IFC."""
def __init__(self):
self.scale = Vector((1.0, 1.0, 1.0))
def test_preview_segment_via_scale_sets_z_to_ratio():
"""The visible-stretch ratio composes ``props_length / mesh_local_length`` where
``mesh_local_length = snap_length / snap_object_scale_z``."""
from bonsai.bim.module.model.mep import _preview_segment_via_scale
obj = _FakeObj()
_preview_segment_via_scale(obj, props_length=2.0, snap_length=1.0, snap_object_scale_z=1.0)
assert obj.scale.z == pytest.approx(2.0)
_preview_segment_via_scale(obj, props_length=0.5, snap_length=1.0, snap_object_scale_z=1.0)
assert obj.scale.z == pytest.approx(0.5)
def test_preview_segment_via_scale_floors_at_min_value():
"""``props.length`` is clamped at FloatProperty min=0.01; the helper still
defends against zero / negative so a runaway value can't invert the segment."""
from bonsai.bim.module.model.mep import _preview_segment_via_scale
obj = _FakeObj()
_preview_segment_via_scale(obj, props_length=0.0, snap_length=1.0, snap_object_scale_z=1.0)
assert obj.scale.z == pytest.approx(0.01)
def test_preview_segment_via_scale_skips_when_snap_is_zero():
"""A zero ``snap_length`` would divide by zero — helper skips silently."""
from bonsai.bim.module.model.mep import _preview_segment_via_scale
obj = _FakeObj()
obj.scale.z = 3.0
_preview_segment_via_scale(obj, props_length=1.0, snap_length=0.0, snap_object_scale_z=1.0)
# No change.
assert obj.scale.z == pytest.approx(3.0)
def test_restore_segment_scale_resets_z_to_target():
"""Pin that the reset only touches Z; X/Y stay whatever the user set."""
from bonsai.bim.module.model.mep import _restore_segment_scale_to
obj = _FakeObj()
obj.scale = Vector((0.5, 0.7, 4.2))
_restore_segment_scale_to(obj, 1.0)
assert obj.scale.x == pytest.approx(0.5)
assert obj.scale.y == pytest.approx(0.7)
assert obj.scale.z == pytest.approx(1.0)
# ---------------------------------------------------------------------------
# Gizmo group class wiring — registration and config
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"gizmo_cls_name,bl_idname,is_element_predicate",
[
("GizmoPipeSegmentEdition", "OBJECT_GGT_bim_pipe_segment_edition", "is_pipe_segment"),
("GizmoDuctSegmentEdition", "OBJECT_GGT_bim_duct_segment_edition", "is_duct_segment"),
],
)
def test_gizmo_group_class_wiring(gizmo_cls_name, bl_idname, is_element_predicate):
"""Each gizmo group must:
- declare the expected ``bl_idname`` (so it actually registers under that name);
- have the matching ``is_element_type`` delegate to the right predicate
(so it polls in for the right IFC class).
"""
from bonsai import tool
from bonsai.bim.module.model import mep
cls = getattr(mep, gizmo_cls_name)
assert cls.bl_idname == bl_idname
predicate = getattr(tool.Parametric, is_element_predicate)
fake_element = Mock()
fake_element.is_a.return_value = True
with patch.object(tool.Parametric, is_element_predicate, side_effect=predicate) as p, patch.object(
tool.System, "has_parametric_body", return_value=True
):
cls.is_element_type(fake_element)
assert p.called, f"{gizmo_cls_name}.is_element_type did not delegate to Parametric.{is_element_predicate}"
@pytest.mark.parametrize(
"gizmo_cls_name,enable_op,finish_op,cancel_op",
[
(
"GizmoPipeSegmentEdition",
"bim.enable_editing_pipe_segment",
"bim.finish_editing_pipe_segment",
"bim.cancel_editing_pipe_segment",
),
(
"GizmoDuctSegmentEdition",
"bim.enable_editing_duct_segment",
"bim.finish_editing_duct_segment",
"bim.cancel_editing_duct_segment",
),
],
)
def test_gizmo_lifecycle_bindings_reference_registered_operators(gizmo_cls_name, enable_op, finish_op, cancel_op):
"""Catches the silent-regression where the gizmo's enable/finish/cancel
string drifts away from the actual operator ``bl_idname``."""
from bonsai.bim.module.model import mep
cls = getattr(mep, gizmo_cls_name)
assert cls.enable_editing_operator == enable_op
assert cls.finish_editing_operator == finish_op
assert cls.cancel_editing_operator == cancel_op
# And the operators are actually registered.
for op in (enable_op, finish_op, cancel_op):
namespace, _, verb = op.partition(".")
assert hasattr(
getattr(bpy.ops, namespace), verb
), f"{gizmo_cls_name} references {op!r} which is not a registered operator"
@pytest.mark.parametrize("gizmo_cls_name", ["GizmoPipeSegmentEdition", "GizmoDuctSegmentEdition"])
def test_gizmo_dimension_gizmo_props_has_single_length_entry(gizmo_cls_name):
"""Phase 1 ships a single dimension (segment length). Pin the shape so
a Phase 2 addition (diameter / width / height) is an intentional
expansion rather than a drive-by edit."""
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
from bonsai.bim.module.model import mep
cls = getattr(mep, gizmo_cls_name)
assert len(cls.dimension_gizmo_props) == 1
config = cls.dimension_gizmo_props[0]
assert isinstance(config, DimensionGizmoConfig)
assert config.attr_name == "length"
assert tuple(config.axis) == (0, 0, 1)
assert config.min_value == pytest.approx(0.01)
@pytest.mark.parametrize("gizmo_cls_name", ["GizmoPipeSegmentEdition", "GizmoDuctSegmentEdition"])
def test_length_dimension_has_matrix_position_so_rotation_is_respected(gizmo_cls_name):
"""Regression guard for "edit-mode length dimension doesn't take local
object rotation". Without ``matrix_position`` set, ``update_dimension_gizmos``
falls back to ``base_matrix = Identity`` and the gizmo's intrinsic +X
visual line is never rotated to the configured ``axis`` the dimension
renders perpendicular to the segment on a rotated pipe. Setting
``matrix_position`` (even to ``(0, 0, 0)``) routes through
``compose_gizmo_matrix`` which applies ``get_axis_rotation_matrix(axis)``
so the line aligns with the segment's local +Z (extrusion axis) in
world space."""
from bonsai.bim.module.model import mep
cls = getattr(mep, gizmo_cls_name)
config = cls.dimension_gizmo_props[0]
assert config.matrix_position is not None, (
f"{gizmo_cls_name} length dimension is missing matrix_position — the gizmo will "
"render along the object's local +X axis instead of the segment's local +Z."
)
# ---------------------------------------------------------------------------
# Extend-to-cursor — operator + element-specific gizmo wiring
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"gizmo_cls_name,extend_operator",
[
("GizmoPipeSegmentEdition", "bim.extend_pipe_segment_to_cursor"),
("GizmoDuctSegmentEdition", "bim.extend_duct_segment_to_cursor"),
],
)
def test_extend_operator_binding(gizmo_cls_name, extend_operator):
"""Each segment gizmo group must reference the matching extend operator
AND that operator must actually be registered. Catches the silent
regression where someone renames the extend bl_idname without updating
the gizmo group's ``_extend_operator`` class attribute."""
from bonsai.bim.module.model import mep
cls = getattr(mep, gizmo_cls_name)
assert cls._extend_operator == extend_operator
namespace, _, verb = extend_operator.partition(".")
assert hasattr(
getattr(bpy.ops, namespace), verb
), f"{gizmo_cls_name} references {extend_operator!r} which is not a registered operator"
@pytest.mark.parametrize("feature_attr", ["pipe_segment", "duct_segment"])
def test_gizmo_preferences_field_exists(feature_attr):
"""``GizmoPreferences`` must carry pipe_segment + duct_segment PointerProperties
so ``get_gizmo_prefs()`` on the MEP gizmo groups resolves to a real PropertyGroup."""
import bonsai.bim.ui as ui
assert feature_attr in ui.GizmoPreferences.__annotations__, (
f"GizmoPreferences is missing the {feature_attr} PointerProperty; "
f"MEP gizmo groups' get_gizmo_prefs() would raise AttributeError."
)
# ---------------------------------------------------------------------------
# Lifecycle operators are registered
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"op",
[
"bim.enable_editing_pipe_segment",
"bim.finish_editing_pipe_segment",
"bim.cancel_editing_pipe_segment",
"bim.extend_pipe_segment_to_cursor",
"bim.enable_editing_duct_segment",
"bim.finish_editing_duct_segment",
"bim.cancel_editing_duct_segment",
"bim.extend_duct_segment_to_cursor",
],
)
def test_segment_operators_are_registered(op):
"""Smoke test mirroring ``test_parametric_registry``'s
``test_every_entry_has_enable_op_registered`` for the operators added
in this round. Catches the silent regression where the classes tuple
in ``__init__.py`` drops one of them."""
namespace, _, verb = op.partition(".")
assert hasattr(getattr(bpy.ops, namespace), verb), f"Operator {op!r} is not registered."
# ---------------------------------------------------------------------------
# MEPSegmentExtendPreviewDecorator._compute_extend_preview_line — pure helper
# ---------------------------------------------------------------------------
def test_extend_preview_line_returns_none_for_degenerate_segment():
"""A zero-length segment has no endpoint to draw from. Pin so a future
refactor doesn't divide-by-zero or render a phantom line at the
object origin."""
from bonsai.bim.module.model.decorator import MEPSegmentExtendPreviewDecorator
result = MEPSegmentExtendPreviewDecorator._compute_extend_preview_line(
matrix_world=Matrix.Identity(4),
cursor_world=Vector((0.0, 0.0, 1.0)),
current_length=0.0,
)
assert result is None
def test_extend_preview_line_returns_none_when_cursor_at_current_end():
"""If the cursor projection matches the current segment length exactly,
the extend operator would be a no-op don't render the line either."""
from bonsai.bim.module.model.decorator import MEPSegmentExtendPreviewDecorator
result = MEPSegmentExtendPreviewDecorator._compute_extend_preview_line(
matrix_world=Matrix.Identity(4),
cursor_world=Vector((0.0, 0.0, 1.5)),
current_length=1.5,
)
assert result is None
def test_extend_preview_line_renders_extension_when_cursor_past_end():
"""Happy path: cursor past current end → line runs from current end to
the cursor's projected length. Identity matrix: local-Z maps 1:1 to
world-Z. Pin the endpoints exactly."""
from bonsai.bim.module.model.decorator import MEPSegmentExtendPreviewDecorator
result = MEPSegmentExtendPreviewDecorator._compute_extend_preview_line(
matrix_world=Matrix.Identity(4),
cursor_world=Vector((0.0, 0.0, 3.0)),
current_length=1.0,
)
assert result is not None
start, end = result
assert tuple(start) == pytest.approx((0.0, 0.0, 1.0))
assert tuple(end) == pytest.approx((0.0, 0.0, 3.0))
def test_extend_preview_line_renders_trim_when_cursor_inside_segment():
"""Cursor inside the segment → line runs from current end BACK to the
projected (shorter) length."""
from bonsai.bim.module.model.decorator import MEPSegmentExtendPreviewDecorator
result = MEPSegmentExtendPreviewDecorator._compute_extend_preview_line(
matrix_world=Matrix.Identity(4),
cursor_world=Vector((0.0, 0.0, 0.4)),
current_length=1.0,
)
assert result is not None
start, end = result
assert tuple(start) == pytest.approx((0.0, 0.0, 1.0))
assert tuple(end) == pytest.approx((0.0, 0.0, 0.4))
def test_extend_preview_line_follows_raw_projection_behind_segment_origin():
"""When the cursor's projected Z is negative (behind segment origin),
the preview line must follow the raw cursor projection the user is
pointing somewhere and expects to see where, even though the operator
would floor the actual commit. Matching the operator's clamp would
hide the line whenever the cursor crossed the segment origin."""
from bonsai.bim.module.model.decorator import MEPSegmentExtendPreviewDecorator
result = MEPSegmentExtendPreviewDecorator._compute_extend_preview_line(
matrix_world=Matrix.Identity(4),
cursor_world=Vector((0.0, 0.0, -2.0)),
current_length=1.0,
)
assert result is not None
start, end = result
assert tuple(start) == pytest.approx((0.0, 0.0, 1.0))
assert tuple(end) == pytest.approx((0.0, 0.0, -2.0))
def test_extend_preview_line_respects_object_rotation():
"""A rotated segment (90° around Y) should produce world-space endpoints
rotated accordingly. Pin so a future refactor doesn't drop the
matrix_world multiplication."""
import math
from bonsai.bim.module.model.decorator import MEPSegmentExtendPreviewDecorator
rotation = Matrix.Rotation(math.pi / 2, 4, "Y")
result = MEPSegmentExtendPreviewDecorator._compute_extend_preview_line(
matrix_world=rotation,
cursor_world=Vector((3.0, 0.0, 0.0)),
current_length=1.0,
)
assert result is not None
start, end = result
# local (0, 0, 1) rotated by 90° around Y → world (1, 0, 0).
assert tuple(start) == pytest.approx((1.0, 0.0, 0.0), abs=1e-6)
# local (0, 0, 3) rotated by 90° around Y → world (3, 0, 0).
assert tuple(end) == pytest.approx((3.0, 0.0, 0.0), abs=1e-6)
# ---------------------------------------------------------------------------
# Lifecycle drift handling — Enable / Finish / Cancel must commit / restore
# matrix_world ↔ IFC ObjectPlacement at the appropriate lifecycle points.
# The AST forward-compat guard pins "a drift hook IS called somewhere"; these
# tests pin "the hook is called in the right branch with the right args."
# ---------------------------------------------------------------------------
def _make_segment_context(length=2.0, snap_length=2.0, scale_z=1.0):
"""Build (context, props, obj, element) fakes for the MEP edit-lifecycle bases.
The bases access ``self.__class__._predicate`` / ``_props_getter`` so
callers must instantiate a concrete test subclass and call
``instance._execute(context)`` rather than passing a Mock as ``self``."""
obj = Mock(name="obj")
obj.scale = Vector((1.0, 1.0, scale_z))
element = Mock(name="element")
props = Mock(name="props")
props.length = length
props.snap_length = snap_length
props.snap_object_scale_z = scale_z
props.mesh_dirty = False
context = Mock(name="context")
context.active_object = obj
return context, props, obj, element
def _concrete_mep_mixin(props):
"""Build a concrete ``_MEPSegmentEditMixin`` subclass that bypasses the
IFC predicate gate and returns the supplied ``props`` from ``_get_props``.
The unified mixin replaced the three-base-class lifecycle pattern; tests now
target the single mixin and override the two ParametricEditMixinBase
hooks instead of class-level ``_predicate`` / ``_props_getter``."""
from bonsai.bim.module.model.mep import _MEPSegmentEditMixin
class _ConcreteMEPMixin(_MEPSegmentEditMixin):
@classmethod
def _is_element_type(cls, element):
return True
@classmethod
def _get_props(cls, obj):
return props
return _ConcreteMEPMixin
def test_enable_pipe_segment_commits_pre_edit_placement_drift():
"""Enable must call ``commit_placement_if_moved(obj, apply_scale=False)``
BEFORE ``_segment_world_length`` captures ``snap_length``. Without the
commit, snap_length is read from a dragged matrix_world while the IFC
ObjectPlacement is stale Finish's set_depth would then write
representation coords relative to the wrong origin."""
context, props, obj, element = _make_segment_context()
cls = _concrete_mep_mixin(props)
with (
patch("bonsai.bim.module.model.mep.tool") as mock_tool,
patch("bonsai.bim.parametric_lifecycle.tool", mock_tool),
patch("bonsai.bim.module.model.mep._segment_world_length", return_value=2.0),
):
mock_tool.Ifc.get_entity.return_value = element
cls()._enable_targets(context)
mock_tool.Geometry.commit_placement_if_moved.assert_called_once_with(obj, apply_scale=False)
def test_finish_pipe_segment_commits_drift_when_no_length_change():
"""Finish without a length change must STILL commit matrix_world drift —
the bug class that motivated this guard. The conditional ``set_depth``
branch covers the length-changed path transitively; the unconditional
``commit_placement_if_moved`` after the if/else closes the silent-drop
path."""
# length == snap_length → no-op session.
context, props, obj, element = _make_segment_context(length=2.0, snap_length=2.0)
cls = _concrete_mep_mixin(props)
with (
patch("bonsai.bim.module.model.mep.tool") as mock_tool,
patch("bonsai.bim.parametric_lifecycle.tool", mock_tool),
patch("bonsai.bim.module.model.mep.DumbProfileJoiner") as mock_joiner,
patch("bonsai.bim.module.model.mep._restore_segment_mesh_if_dirty"),
patch("bonsai.bim.module.model.mep._restore_segment_scale_to"),
):
mock_tool.Ifc.get_entity.return_value = element
cls()._finish_targets(context)
mock_joiner.return_value.set_depth.assert_not_called() # no-length branch
mock_tool.Geometry.commit_placement_if_moved.assert_called_once_with(obj)
def test_cancel_pipe_segment_delegates_to_restore_or_rebaseline():
"""Cancel must call ``tool.Geometry.restore_or_rebaseline_placement`` so
matrix_world reverts in lockstep with the props draft. The helper owns
the is_moved / ObjectPlacement gate."""
context, props, obj, element = _make_segment_context()
cls = _concrete_mep_mixin(props)
with (
patch("bonsai.bim.module.model.mep.tool") as mock_tool,
patch("bonsai.bim.parametric_lifecycle.tool", mock_tool),
patch("bonsai.bim.module.model.mep._restore_segment_mesh_if_dirty"),
):
mock_tool.Ifc.get_entity.return_value = element
cls()._cancel_targets(context)
mock_tool.Geometry.restore_or_rebaseline_placement.assert_called_once_with(obj, element)
@@ -32,12 +32,6 @@ 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
@@ -139,6 +133,55 @@ class TestActivationCycle:
assert props.is_active is False, f"discard_pending_previews left '{attr}' active"
class TestClearPreviewState:
"""``clear_preview_state`` is the shared cleanup routine every preview
operator calls on commit / cancel. The contract is: ``is_active`` flips
to False, every ``*_id`` IntProperty zeroes, everything else stays."""
def test_clears_is_active_and_id_fields_on_real_property_groups(self):
from bonsai.bim.module.model.preview_base import clear_preview_state
registered = _registered_previews()
if not registered:
pytest.skip("No previews wired in this build — registry-only entries")
for attr, _, props in registered:
# Seed every *_id IntProperty with a non-zero sentinel and flip
# the activity flag so the helper has something to clear.
id_fields = [
name for name, rna in props.bl_rna.properties.items() if name.endswith("_id") and rna.type == "INT"
]
assert id_fields, f"Preview '{attr}' has no *_id IntProperty — registry shape changed"
for name in id_fields:
setattr(props, name, 42)
props.is_active = True
clear_preview_state(props)
assert props.is_active is False, f"Preview '{attr}' is_active not cleared"
for name in id_fields:
assert getattr(props, name) == 0, f"Preview '{attr}' field '{name}' not zeroed"
def test_leaves_non_id_fields_untouched(self):
"""Non-``*_id`` fields (FloatProperty params like ``radius``,
``start_length``) must survive the reset they re-seed on the next
enable, so untouching them here avoids a redundant write."""
from bonsai.bim.module.model.preview_base import clear_preview_state
bend = getattr(_preview_umbrella(), "bend", None)
if bend is None:
pytest.skip("Bend preview not wired in this build")
bend.is_active = True
bend.start_length = 0.42
bend.radius = 0.99
clear_preview_state(bend)
assert bend.is_active is False
assert bend.start_length == pytest.approx(0.42)
assert bend.radius == pytest.approx(0.99)
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
@@ -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")
@@ -39,12 +39,6 @@ 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):
@@ -133,3 +127,75 @@ def test_set_icon_gizmo_position_does_not_apply_object_rotation():
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,232 @@
# 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 parametric gizmo group hides while a Blender
transform modal (G/R/S and siblings) is dragging ``matrix_world``.
Discovery walks each parametric-edit module rather than naming gizmo groups
adding a new group automatically joins the test. The test exercises the
BEHAVIOUR (poll returns False / draw_prepare early-returns when a transform
modal is active) without pinning the name of the helper used internally."""
import importlib
from unittest.mock import MagicMock, patch
import bpy
import pytest
pytestmark = pytest.mark.model
PARAMETRIC_MODULES = (
"bonsai.bim.module.model.array",
"bonsai.bim.module.model.door",
"bonsai.bim.module.model.host_add_opening_gizmo",
"bonsai.bim.module.model.roof",
"bonsai.bim.module.model.stair",
"bonsai.bim.module.model.wall",
"bonsai.bim.module.model.window",
)
def _discover_parametric_gizmo_groups():
"""Walk each parametric-edit module for ``bpy.types.GizmoGroup`` subclasses
defined locally. Preview-owning gizmo groups (bl_idname contains 'preview')
are excluded from the poll-level test: their poll legitimately fires while
the preview is active, and the transform-modal hide for them lives in
``draw_prepare`` via ``BillboardingGizmoGroupMixin``."""
out = []
for mod_path in PARAMETRIC_MODULES:
mod = importlib.import_module(mod_path)
for name in dir(mod):
obj = getattr(mod, name)
if not isinstance(obj, type):
continue
if not issubclass(obj, bpy.types.GizmoGroup) or obj is bpy.types.GizmoGroup:
continue
if obj.__module__ != mod.__name__:
continue
bl_idname = (getattr(obj, "bl_idname", "") or "").lower()
if "preview" in bl_idname:
continue
out.append((f"{mod_path.rsplit('.', 1)[-1]}.{name}", obj))
return out
class TestDiscoveryFindsParametricGizmoGroups:
def test_at_least_one_group_per_canonical_module(self):
"""If discovery returns zero groups for a module the walk has drifted —
likely the gizmo group moved to a different file. Surface the drift
with the module name in the diagnostic."""
per_module: dict[str, int] = {}
for fq_name, _cls in _discover_parametric_gizmo_groups():
mod_short = fq_name.split(".", 1)[0]
per_module[mod_short] = per_module.get(mod_short, 0) + 1
empty = [m.rsplit(".", 1)[-1] for m in PARAMETRIC_MODULES if per_module.get(m.rsplit(".", 1)[-1], 0) == 0]
assert not empty, (
f"Parametric modules with zero GizmoGroup subclasses (discovery walk drifted?): {empty}. "
"Update PARAMETRIC_MODULES or check whether the gizmo groups moved to a new file."
)
class TestParametricGizmoPollsHideDuringTransformModal:
"""For each discovered parametric gizmo group, mock the transform-modal
detector to True and call ``poll(bpy.context)``. Every poll must return
False any True is a poll that wouldn't hide during a G/R/S drag, leaving
the gizmos jittering against the dragging matrix."""
def test_every_group_poll_returns_false_when_transform_modal_active(self):
groups = _discover_parametric_gizmo_groups()
offenders = []
with patch(
"bonsai.bim.module.drawing.gizmos._is_transform_modal_active",
return_value=True,
):
for name, cls in groups:
poll = getattr(cls, "poll", None)
if poll is None:
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 transform modal active"))
assert not offenders, (
"Parametric gizmo polls that don't gate on the transform-modal detector "
"(or raise instead of returning False): "
+ ", ".join(f"{n}{why}" for n, why in offenders)
+ ". Hide parametric gizmos while Blender's transform modal is dragging "
"matrix_world so they don't jitter off-cursor. The conventional path is to "
"early-return from poll when _is_transform_modal_active(context) is True."
)
class TestBaseParametricPollHidesDuringTransformModal:
"""Cross-feature base poll: door / window / stair / roof / railing / array
all inherit ``BaseParametricGizmoGroup``. Its poll must short-circuit on
the transform-modal detector so every inheriting feature behaves uniformly."""
def test_base_parametric_poll_returns_false(self):
from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup
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=False,
):
with patch(
"bonsai.bim.module.drawing.gizmos._is_transform_modal_active",
return_value=True,
):
assert BaseParametricGizmoGroup.poll(bpy.context) is False
class TestBaseIconActionPollHidesDuringTransformModal:
"""``BaseIconActionGroup`` is the parent of the simple icon-row gizmo
groups; its poll mirrors the base parametric gate for forward-compat
symmetry. Pinning here ensures a future icon-row group authored via this
base inherits the transform-modal hide for free."""
def test_base_icon_action_poll_returns_false(self):
from bonsai.bim.module.drawing.gizmos import BaseIconActionGroup
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.drawing.gizmos._is_transform_modal_active",
return_value=True,
):
assert BaseIconActionGroup.poll(bpy.context) is False
class TestHelperReadsWindowModalOperators:
"""Pin the public contract of ``_is_transform_modal_active``: it reads
``context.window.modal_operators`` (Blender 4.2+) and returns True iff any
operator's ``bl_idname`` starts with ``TRANSFORM_OT_``. The check itself
is dependency-free and worth pinning so a future refactor that swaps the
detection mechanism either keeps the contract or updates the test."""
def test_returns_true_for_transform_translate(self):
from bonsai.bim.module.drawing.gizmos import _is_transform_modal_active
fake_op = MagicMock()
fake_op.bl_idname = "TRANSFORM_OT_translate"
fake_context = MagicMock()
fake_context.window.modal_operators = [fake_op]
assert _is_transform_modal_active(fake_context) is True
def test_returns_true_for_transform_rotate_and_resize(self):
from bonsai.bim.module.drawing.gizmos import _is_transform_modal_active
for idname in ("TRANSFORM_OT_rotate", "TRANSFORM_OT_resize", "TRANSFORM_OT_shear"):
fake_op = MagicMock()
fake_op.bl_idname = idname
fake_context = MagicMock()
fake_context.window.modal_operators = [fake_op]
assert _is_transform_modal_active(fake_context) is True, f"missed {idname}"
def test_returns_false_for_non_transform_modal(self):
from bonsai.bim.module.drawing.gizmos import _is_transform_modal_active
fake_op = MagicMock()
fake_op.bl_idname = "VIEW3D_OT_select_box"
fake_context = MagicMock()
fake_context.window.modal_operators = [fake_op]
assert _is_transform_modal_active(fake_context) is False
def test_returns_true_for_bonsai_move_macro(self):
"""Bonsai overrides the G key with a macro that wraps
``TRANSFORM_OT_translate``. While the macro is the outer modal entry
the inner transform does not surface in ``modal_operators``; matching
the macro idname covers the gap. Note Blender exposes ``bl_idname``
at runtime in the ``BIM_OT_<verb_noun>`` form, not the ``bim.<verb_noun>``
form used in the class declaration verified via real-Blender modal
introspection during grab."""
from bonsai.bim.module.drawing.gizmos import _is_transform_modal_active
macros = (
"BIM_OT_override_move_macro",
"BIM_OT_override_object_duplicate_move_macro",
"BIM_OT_override_object_duplicate_move_linked_macro",
"BIM_OT_object_duplicate_move_linked_aggregate_macro",
)
for idname in macros:
fake_op = MagicMock()
fake_op.bl_idname = idname
fake_context = MagicMock()
fake_context.window.modal_operators = [fake_op]
assert _is_transform_modal_active(fake_context) is True, f"missed {idname}"
def test_returns_false_for_empty_modal_stack(self):
from bonsai.bim.module.drawing.gizmos import _is_transform_modal_active
fake_context = MagicMock()
fake_context.window.modal_operators = []
assert _is_transform_modal_active(fake_context) is False
def test_returns_false_when_window_is_none(self):
from bonsai.bim.module.drawing.gizmos import _is_transform_modal_active
fake_context = MagicMock()
fake_context.window = None
assert _is_transform_modal_active(fake_context) is False
@@ -0,0 +1,133 @@
# 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 guard: every multi-object wall topology GizmoGroup
filters Bonsai array children via ``_wall_topology_gizmo_poll_gate`` or
the central ``any_selected_is_array_child`` predicate.
Allow-list (gizmos intentionally outside the rule):
- ``GizmoWallEdition`` single-object parametric edit gizmo. Its base
parametric poll already filters array children.
- ``GizmoWallFilletPreview`` the preview-owner whose poll must fire
WHILE its own preview is active; routing it through the topology gate
would self-block it.
Host-opening gizmos live in a sibling module and intentionally use the
loose base ``_wall_gizmo_poll_gate``: openings track with the child
through ``regenerate_array`` and stay authorable on children.
A new wall ``GizmoGroup`` added without the filter (and not added to the
allow-list with an explanation) fails this test."""
import ast
import inspect
import bpy
import pytest
pytestmark = pytest.mark.model
# Wall gizmo groups intentionally outside the rule. Add a new entry only
# with the in-code reasoning above.
_ALLOWLIST = frozenset({"GizmoWallEdition", "GizmoWallFilletPreview"})
_REQUIRED_CALLEES = frozenset({"_wall_topology_gizmo_poll_gate", "any_selected_is_array_child"})
def _wall_module_source():
from bonsai.bim.module.model import wall as wall_mod
return inspect.getsource(wall_mod), wall_mod.__name__
def _wall_gizmo_group_classes():
"""All ``bpy.types.GizmoGroup`` subclasses defined locally in wall.py."""
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
if obj.__module__ != wall_mod.__name__:
continue
out.append((name, obj))
return out
def _poll_function_calls(class_node):
"""Names of every function called inside ``class_node``'s ``poll`` body.
``ast.Call.func`` may be an ``ast.Name`` (bare call) or an ``ast.Attribute``
(dotted call). For the dotted case the leaf attribute is returned so
``tool.Blender.Modifier.any_selected_is_array_child(...)`` registers as
``any_selected_is_array_child``."""
poll_node = next(
(node for node in class_node.body if isinstance(node, ast.FunctionDef) and node.name == "poll"),
None,
)
if poll_node is None:
return None
names = set()
for sub in ast.walk(poll_node):
if not isinstance(sub, ast.Call):
continue
func = sub.func
if isinstance(func, ast.Name):
names.add(func.id)
elif isinstance(func, ast.Attribute):
names.add(func.attr)
return names
def test_every_wall_gizmo_group_filters_array_children_or_is_allowlisted():
"""For every locally-defined wall ``GizmoGroup`` not in the allow-list,
its ``poll`` must call ``_wall_gizmo_poll_gate`` or the central
``any_selected_is_array_child`` predicate. A failure surfaces the list
of offending classes the fix is a single early-return through the
central helper, mirroring the existing peers."""
source, _module_name = _wall_module_source()
tree = ast.parse(source)
class_nodes = {node.name: node for node in ast.walk(tree) if isinstance(node, ast.ClassDef)}
offenders = []
for class_name, _cls in _wall_gizmo_group_classes():
if class_name in _ALLOWLIST:
continue
node = class_nodes.get(class_name)
if node is None:
offenders.append((class_name, "AST parse did not find the class"))
continue
calls = _poll_function_calls(node)
if calls is None:
offenders.append((class_name, "no poll() defined; expected the array-child filter call"))
continue
if not (calls & _REQUIRED_CALLEES):
offenders.append((class_name, f"poll() does not call any of {sorted(_REQUIRED_CALLEES)}"))
assert not offenders, (
"Wall GizmoGroup classes missing the array-child filter: "
+ ", ".join(f"{n}{why}" for n, why in offenders)
+ ". Route the poll through `_wall_topology_gizmo_poll_gate(context)` "
"so the central `any_selected_is_array_child` filter applies, or add "
"the class to the file's allow-list with a documented reason."
)
@@ -28,7 +28,6 @@ joins the test. The test then asserts the BEHAVIOUR (poll returns False when
helper function the gizmo uses internally to enforce it."""
import inspect
import types
from unittest.mock import patch
import bpy
@@ -37,12 +36,6 @@ 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.
@@ -25,7 +25,6 @@ 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
@@ -35,10 +34,15 @@ 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)")
class _Obj:
"""Hashable, name-bearing stand-in for a ``bpy.types.Object`` selection
slot. ``SimpleNamespace`` defines ``__eq__`` (and so ``__hash__ = None``)
which makes it unusable inside the ``set()`` that
``get_selected_objects()`` returns; a plain class falls back to
identity-based hashing and works inside both ``set`` and ``list``."""
def __init__(self, name: str) -> None:
self.name = name
def _make_context(active, selected):
@@ -76,19 +80,23 @@ def _patch_tools(prefs_on, selected, active_element, other_element, active_usage
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),
# The array-child filter is pinned by its own test file; stub it here
# so these poll tests stay focused on the count / layer-usage gates
# and don't have to scaffold the memoization cache key.
patch.object(tool.Blender.Modifier, "any_selected_is_array_child", return_value=False),
]
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()
slab_obj = _Obj("slab")
wall_obj = _Obj("wall")
active = slab_obj if active_is_in_selected else _Obj("active_extra")
if len_override is None:
selected = [slab_obj, wall_obj]
else:
selected = [object() for _ in range(len_override)]
selected = [_Obj(f"obj_{i}") for i in range(len_override)]
if active_is_in_selected and selected:
active = selected[0]
@@ -202,11 +210,11 @@ def _make_path_rel(relating, related, relating_ct, related_ct, kind="IfcRelConne
)
def _run_iter_path_connections(elem, *, is_wall_predicate=lambda _e: True):
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.Blender.Modifier, "is_wall", side_effect=is_wall_predicate):
with patch.object(tool.Parametric, "is_path_connectable_wall", side_effect=partner_predicate):
return _iter_path_connections(elem)
@@ -260,14 +268,28 @@ def test_iter_path_connections_skips_non_wall_partners():
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, is_wall_predicate=lambda e: e is wall_partner)
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, `Modifier.is_wall(None)` would raise on
# `None.is_a(...)` mid-frame and silently break the gizmo group.
# 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")
@@ -288,3 +310,92 @@ def test_iter_path_connections_walks_both_inverses_in_order():
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")]
# ----------------------------------------------------------------------------
# _perpendicular_wall_params — clamping + side detection for the
# "add perpendicular wall at cursor" gizmo and its operator.
# ----------------------------------------------------------------------------
#
# Pure scalar math. The dead-zone is ``CURSOR_STACK_OFFSET`` — inside it the
# on-axis split / extend-X icons own the click and this helper returns None.
def _wall_consts():
from bonsai.bim.module.model.wall import GizmoWallEdition
return GizmoWallEdition.CURSOR_STACK_OFFSET
def _run_perp_params(cursor_x, cursor_y, anchor_x=0.0, length=5.0):
from bonsai.bim.module.model.wall import _perpendicular_wall_params
return _perpendicular_wall_params(cursor_x, cursor_y, anchor_x, length)
def test_perpendicular_params_on_axis_returns_none():
assert _run_perp_params(cursor_x=2.0, cursor_y=0.0) is None
def test_perpendicular_params_at_dead_zone_boundary_returns_none():
# Inclusive boundary: at exactly the threshold the on-axis icons still own
# the click; the gizmo only takes over strictly past the dead zone.
threshold = _wall_consts()
assert _run_perp_params(cursor_x=2.0, cursor_y=threshold) is None
assert _run_perp_params(cursor_x=2.0, cursor_y=-threshold) is None
def test_perpendicular_params_just_past_dead_zone_returns_params():
threshold = _wall_consts()
result = _run_perp_params(cursor_x=2.0, cursor_y=threshold + 0.01)
assert result is not None
clamped_x, length, side = result
assert clamped_x == pytest.approx(2.0)
assert length == pytest.approx(threshold + 0.01)
assert side == 1.0
def test_perpendicular_params_negative_y_flips_side_sign():
result = _run_perp_params(cursor_x=2.0, cursor_y=-1.5)
assert result is not None
_, length, side = result
# Length is always positive — the side sign carries the direction so the
# operator can pick the +90° vs -90° rotation without sign-flipping length.
assert length == pytest.approx(1.5)
assert side == -1.0
def test_perpendicular_params_clamps_low_when_cursor_left_of_wall():
result = _run_perp_params(cursor_x=-2.0, cursor_y=1.5, anchor_x=0.0, length=5.0)
assert result is not None
clamped_x, _length, _side = result
assert clamped_x == pytest.approx(0.0)
def test_perpendicular_params_clamps_high_when_cursor_right_of_wall():
result = _run_perp_params(cursor_x=10.0, cursor_y=1.5, anchor_x=0.0, length=5.0)
assert result is not None
clamped_x, _length, _side = result
assert clamped_x == pytest.approx(5.0)
def test_perpendicular_params_respects_nonzero_anchor_x():
# Non-zero anchor_x shifts the wall span; clamping must follow.
result = _run_perp_params(cursor_x=0.5, cursor_y=1.5, anchor_x=2.0, length=5.0)
assert result is not None
clamped_x, _length, _side = result
assert clamped_x == pytest.approx(2.0)
result = _run_perp_params(cursor_x=10.0, cursor_y=1.5, anchor_x=2.0, length=5.0)
assert result is not None
clamped_x, _length, _side = result
assert clamped_x == pytest.approx(7.0)
def test_perpendicular_params_in_range_passes_cursor_x_through():
result = _run_perp_params(cursor_x=3.0, cursor_y=1.5, anchor_x=0.0, length=5.0)
assert result is not None
clamped_x, length, side = result
assert clamped_x == pytest.approx(3.0)
assert length == pytest.approx(1.5)
assert side == 1.0
@@ -0,0 +1,237 @@
# 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: wall topology gizmos and operators reject any
selection that contains a Bonsai array child. Discovers gated gizmo
groups and guarded operators by source inspection so additions inherit
the rule automatically."""
from types import SimpleNamespace
from unittest.mock import patch
import bpy
import pytest
pytestmark = pytest.mark.model
def _wall_gizmo_groups_using_gate():
"""Wall-module ``bpy.types.GizmoGroup`` subclasses whose ``poll`` calls
``_wall_topology_gizmo_poll_gate``. Discovered by source inspection so
the test tracks the gate's user set as the module grows."""
import inspect
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
if obj.__module__ != wall_mod.__name__:
continue
poll = obj.__dict__.get("poll")
if poll is None:
continue
try:
src = inspect.getsource(poll)
except (OSError, TypeError):
continue
if "_wall_topology_gizmo_poll_gate" not in src:
continue
out.append((name, obj))
return out
def _wall_operators_with_array_child_guard():
"""Wall-module ``bpy.types.Operator`` subclasses whose ``poll`` rejects
array-child selections, either by referencing the central predicate
directly or by routing through the shared ``_poll_reject_array_children``
helper that wraps it. The operator-level guard is defence in depth
against keymap / F3 paths that bypass the gizmo entirely."""
import inspect
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.Operator) or obj is bpy.types.Operator:
continue
if obj.__module__ != wall_mod.__name__:
continue
poll = obj.__dict__.get("poll")
if poll is None:
continue
try:
src = inspect.getsource(poll)
except (OSError, TypeError):
continue
if "any_selected_is_array_child" not in src and "_poll_reject_array_children" not in src:
continue
out.append((name, obj))
return out
class TestWallGizmoGroupsHideOnArrayChildSelection:
def test_discovery_finds_wall_multi_object_gizmo_groups(self):
groups = _wall_gizmo_groups_using_gate()
assert groups, (
"Expected at least one wall GizmoGroup whose poll calls "
"_wall_gizmo_poll_gate — discovery walk drifted out of sync?"
)
def test_every_gated_wall_gizmo_hides_when_any_selection_is_array_child(self):
"""Mocks the central ``any_selected_is_array_child`` predicate to True
and asserts every gizmo whose poll routes through
``_wall_gizmo_poll_gate`` returns False. The point is the BEHAVIOUR:
a child wall in the selection must never surface a topology gizmo,
regardless of which gate function the poll calls internally."""
groups = _wall_gizmo_groups_using_gate()
offenders = []
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=False):
with patch(
"bonsai.tool.Blender.Modifier.any_selected_is_array_child",
return_value=True,
):
for name, cls in groups:
try:
result = cls.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 array child selected"))
assert not offenders, (
"Wall gizmo polls that surface on array-child selections: "
+ ", ".join(f"{n}{why}" for n, why in offenders)
+ ". Route the poll through _wall_topology_gizmo_poll_gate so the "
"central any_selected_is_array_child filter applies."
)
class TestWallOperatorsRejectArrayChildSelection:
def test_discovery_finds_wall_topology_operators(self):
ops = _wall_operators_with_array_child_guard()
assert ops, (
"Expected at least one wall Operator whose poll rejects array-child "
"selections (via any_selected_is_array_child or _poll_reject_array_children) "
"— discovery walk drifted out of sync?"
)
def test_every_guarded_wall_operator_polls_false_on_array_child_selection(self):
"""Operators reachable from keymaps / F3 must reject array-child
invocation independently of the gizmo gating, because not every
invocation path goes through a gizmo. The shared predicate makes
this a one-line guard per operator; this test pins it for every
operator that opted in."""
ops = _wall_operators_with_array_child_guard()
offenders = []
with patch(
"bonsai.tool.Blender.Modifier.any_selected_is_array_child",
return_value=True,
):
with patch("bonsai.tool.Model.has_selected_ifc_objects", return_value=True):
with patch("bonsai.tool.Model.get_selected_ifc_objects", return_value=[]):
for name, cls in ops:
try:
result = cls.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 array child selected"))
assert not offenders, (
"Wall topology operators that accept array-child selections: "
+ ", ".join(f"{n}{why}" for n, why in offenders)
+ ". Route the poll through `_poll_reject_array_children(cls)` (the "
"shared helper that sets the standard poll message and reuses the "
"central `any_selected_is_array_child` predicate)."
)
class TestAnySelectedIsArrayChildHelper:
"""Smoke checks on the central predicate. Returns ``False`` when nothing
is selected; returns ``True`` when at least one selected element passes
``is_array_child``."""
def test_returns_false_with_empty_selection(self):
from bonsai import tool
with patch.object(tool.Blender, "get_selected_objects", return_value=[]):
assert tool.Blender.Modifier.any_selected_is_array_child() is False
def test_returns_true_when_any_selected_passes_predicate(self):
from bonsai import tool
child_obj, child_element = SimpleNamespace(name="child"), object()
parent_obj, parent_element = SimpleNamespace(name="parent"), object()
def get_entity(obj):
return {id(child_obj): child_element, id(parent_obj): parent_element}.get(id(obj))
def is_array_child(element):
return element is child_element
with patch.object(tool.Blender, "get_selected_objects", return_value=[parent_obj, child_obj]):
with patch.object(tool.Ifc, "get_entity", side_effect=get_entity):
with patch.object(tool.Blender.Modifier, "is_array_child", side_effect=is_array_child):
assert tool.Blender.Modifier.any_selected_is_array_child() is True
def test_returns_false_when_no_selected_passes_predicate(self):
from bonsai import tool
parent_obj, parent_element = SimpleNamespace(name="parent"), object()
with patch.object(tool.Blender, "get_selected_objects", return_value=[parent_obj]):
with patch.object(tool.Ifc, "get_entity", return_value=parent_element):
with patch.object(tool.Blender.Modifier, "is_array_child", return_value=False):
assert tool.Blender.Modifier.any_selected_is_array_child() is False
class TestHostOpeningGizmoStaysAvailableOnArrayChildren:
"""Openings on array children are array-safe: ``regenerate_array``
applies opening cuts after replicating child geometry, so an opening
authored on a child survives regen and tracks with the replicated
instance. The host-opening gizmos therefore route through the loose
base wall gate, not the tighter topology gate that excludes
children."""
def test_host_opening_module_does_not_apply_topology_gate(self):
import inspect
from bonsai.bim.module.model import host_add_opening_gizmo
src = inspect.getsource(host_add_opening_gizmo)
assert "_wall_topology_gizmo_poll_gate" not in src, (
"host-opening gizmo module references the topology gate; that "
"would suppress add-opening on array-child hosts. Openings "
"track with the regenerated child via the array regen pipeline."
)
assert "any_selected_is_array_child" not in src, (
"host-opening gizmo module references any_selected_is_array_child; "
"openings are array-safe, drop the filter."
)
@@ -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."
)
@@ -18,21 +18,20 @@
#
# This file was generated with the assistance of an AI coding tool.
"""Regression tests for the post-IFC-commit refresh path that re-syncs the
workspace tool header (``BIMModelProperties``) and invalidates the per-wall
gizmo geometry cache.
"""Regression tests for the post-IFC-commit refresh path.
Bug repro before the fix: hotkey operators that edited the active wall in
place (``bpy.ops.bim.hotkey(hotkey="S_E")`` / ``"C_E"``) mutated IFC but never
fired ``active_object_callback`` (no selection change), so the header H/L/A
fields and the gizmo cache both kept showing stale values. ``refresh_ui_data``
ran, but it never resynced ``BIMModelProperties`` and never invalidated the
per-gizmo-group geometry cache. The fix wires both refreshes through
``tool.Parametric.refresh_post_commit`` and calls it from every
``tool.Ifc.Operator`` epilogue."""
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 patch
from unittest.mock import MagicMock, patch
import bpy
import pytest
@@ -40,23 +39,42 @@ 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_and_resyncs_header():
"""``refresh_post_commit`` must bump the generation counter and call
``update_bim_tool_props`` so the workspace tool header re-syncs from IFC."""
import bonsai.bim.handler as handler
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()
with patch.object(handler, "update_bim_tool_props") as mock_resync:
tool.Parametric.refresh_post_commit()
tool.Parametric.refresh_post_commit(MagicMock(bl_idname="bim.append_library_element"))
assert tool.Parametric.get_geom_generation() == before + 1
mock_resync.assert_called_once()
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():
@@ -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,73 @@
# 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
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,164 @@
# 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.
"""Cache-invalidation tests for the wall-topology gizmo helpers.
``GizmoWallUnjoinSingle`` and ``GizmoWallJoinIntersection`` re-run
``_iter_path_connections``, ``_are_walls_joined``, ``_are_walls_collinear``,
and ``core.project_axis_intersection`` every viewport redraw without the
cache helpers wrapping them. These tests pin that:
- Repeat calls within one IFC generation reuse the cached result.
- An IFC-generation bump invalidates the cache.
- ``refresh()`` (the Blender state-change hook on the mixin) drops the cache."""
from unittest.mock import Mock, patch
import pytest
pytestmark = pytest.mark.model
def test_get_wall_connections_cached_returns_cached_within_generation():
from bonsai.bim.module.model import wall
group = Mock(spec=[])
elem = Mock()
elem.GlobalId = "0AAAAAAAAAAAAAAAAAAAAA"
expected = [(Mock(), "ATEND", "ATSTART")]
call_count = {"n": 0}
def counting_iter(e):
call_count["n"] += 1
return expected
with patch.object(wall, "_iter_path_connections", side_effect=counting_iter), patch(
"bonsai.bim.module.model.wall.tool.Parametric.get_geom_generation", return_value=7
):
first = wall._get_wall_connections_cached(group, elem)
second = wall._get_wall_connections_cached(group, elem)
assert first is second
assert call_count["n"] == 1
def test_get_wall_connections_cached_invalidates_on_generation_bump():
from bonsai.bim.module.model import wall
group = Mock(spec=[])
elem = Mock()
elem.GlobalId = "0AAAAAAAAAAAAAAAAAAAAA"
call_count = {"n": 0}
def counting_iter(e):
call_count["n"] += 1
return []
gen_state = {"gen": 1}
with patch.object(wall, "_iter_path_connections", side_effect=counting_iter), patch(
"bonsai.bim.module.model.wall.tool.Parametric.get_geom_generation", side_effect=lambda: gen_state["gen"]
):
wall._get_wall_connections_cached(group, elem)
gen_state["gen"] = 2
wall._get_wall_connections_cached(group, elem)
assert call_count["n"] == 2
def test_get_wall_pair_predicate_cached_reuses_value_within_generation():
from bonsai.bim.module.model import wall
group = Mock(spec=[])
call_count = {"n": 0}
def compute():
call_count["n"] += 1
return "result"
with patch("bonsai.bim.module.model.wall.tool.Parametric.get_geom_generation", return_value=3):
first = wall._get_wall_pair_predicate_cached(group, ("joined", ("guid_a", "guid_b")), compute)
second = wall._get_wall_pair_predicate_cached(group, ("joined", ("guid_a", "guid_b")), compute)
assert first == second == "result"
assert call_count["n"] == 1
def test_get_wall_pair_predicate_cached_distinguishes_predicate_kind():
"""The cache key includes a tag string ("joined" vs "collinear" vs
"intersection") so adding a second predicate for the same pair doesn't
return the first predicate's value."""
from bonsai.bim.module.model import wall
group = Mock(spec=[])
pair = ("guid_a", "guid_b")
with patch("bonsai.bim.module.model.wall.tool.Parametric.get_geom_generation", return_value=3):
a = wall._get_wall_pair_predicate_cached(group, ("joined", pair), lambda: "JOINED")
b = wall._get_wall_pair_predicate_cached(group, ("collinear", pair), lambda: "COLLINEAR")
assert a == "JOINED"
assert b == "COLLINEAR"
def test_get_wall_pair_predicate_cached_invalidates_on_generation_bump():
from bonsai.bim.module.model import wall
group = Mock(spec=[])
call_count = {"n": 0}
def compute():
call_count["n"] += 1
return call_count["n"]
gen_state = {"gen": 1}
with patch(
"bonsai.bim.module.model.wall.tool.Parametric.get_geom_generation", side_effect=lambda: gen_state["gen"]
):
first = wall._get_wall_pair_predicate_cached(group, ("joined", ("a", "b")), compute)
gen_state["gen"] = 2
second = wall._get_wall_pair_predicate_cached(group, ("joined", ("a", "b")), compute)
assert first == 1
assert second == 2
assert call_count["n"] == 2
def test_mixin_refresh_clears_pair_and_connection_caches():
"""``refresh()`` is Blender's "state changed" signal — typically a
selection change. Both the connection list and pair predicate caches
must drop alongside the geometry cache, otherwise the next frame would
read predicates that targeted the previously-selected pair."""
from bonsai.bim.module.model import wall
class _Group(wall._WallGeomCachedBillboardingMixin):
def position_gizmos(self, context):
pass
group = _Group()
group._wall_geom_cache = {"x": "geom"}
group._wall_connections_cache = {"guid": []}
group._wall_pair_predicate_cache = {"key": "value"}
group.refresh(context=Mock())
assert group._wall_geom_cache is None
assert group._wall_connections_cache is None
assert group._wall_pair_predicate_cache is None
@@ -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,108 @@
# 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 unittest.mock import patch
import bpy
import ifcopenshell
import pytest
import bonsai.tool as tool
from test.bim.bootstrap import NewIfc
pytestmark = pytest.mark.project
def _populate_pending(*element_ids: int) -> None:
pending = tool.Project.get_project_props().pending_opening_recut
pending.clear()
for eid in element_ids:
pending.add().ifc_definition_id = eid
def _make_linked_wall(name: str = "Wall") -> tuple[ifcopenshell.entity_instance, bpy.types.Object]:
ifc_file = tool.Ifc.get()
element = ifc_file.create_entity("IfcWall", GlobalId=ifcopenshell.guid.new(), Name=name)
obj = bpy.data.objects.new(name, bpy.data.meshes.new(name))
bpy.context.scene.collection.objects.link(obj)
tool.Ifc.link(element, obj)
return element, obj
class TestApplyPendingOpeningCuts(NewIfc):
def test_clears_pending_and_calls_reimport_with_apply_openings(self):
element, obj = _make_linked_wall()
_populate_pending(element.id())
with patch.object(tool.Geometry, "reimport_element_representations") as mock_reimport, patch(
"ifcopenshell.util.representation.get_representation",
return_value=object(),
):
result = bpy.ops.bim.apply_pending_opening_cuts()
assert result == {"FINISHED"}
assert len(tool.Project.get_project_props().pending_opening_recut) == 0
mock_reimport.assert_called_once()
_, kwargs = mock_reimport.call_args
assert kwargs.get("apply_openings") is True
def test_skips_entries_whose_entity_is_gone(self):
_populate_pending(99999) # ID guaranteed not present
with patch.object(tool.Geometry, "reimport_element_representations") as mock_reimport:
result = bpy.ops.bim.apply_pending_opening_cuts()
assert result == {"FINISHED"}
assert len(tool.Project.get_project_props().pending_opening_recut) == 0
mock_reimport.assert_not_called()
class TestDismissPendingOpeningCuts(NewIfc):
def test_clears_collection_without_calling_reimport(self):
element, _obj = _make_linked_wall()
_populate_pending(element.id())
with patch.object(tool.Geometry, "reimport_element_representations") as mock_reimport:
result = bpy.ops.bim.dismiss_pending_opening_cuts()
assert result == {"FINISHED"}
assert len(tool.Project.get_project_props().pending_opening_recut) == 0
mock_reimport.assert_not_called()
class TestSelectPendingOpeningCuts(NewIfc):
def test_selects_objects_for_each_pending_entry(self):
e1, o1 = _make_linked_wall("WallA")
e2, o2 = _make_linked_wall("WallB")
_populate_pending(e1.id(), e2.id())
for obj in bpy.context.view_layer.objects:
obj.select_set(False)
result = bpy.ops.bim.select_pending_opening_cuts()
assert result == {"FINISHED"}
assert o1.select_get() and o2.select_get()
assert bpy.context.view_layer.objects.active in (o1, o2)
def test_cancels_when_no_objects_match(self):
_populate_pending(99999)
result = bpy.ops.bim.select_pending_opening_cuts()
assert result == {"CANCELLED"}
@@ -0,0 +1,232 @@
# 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.
"""Framework contract test for the partial-state recovery hint in
``IfcStore.execute_ifc_operator``.
The framework wraps every ``tool.Ifc.Operator._execute`` call between
``ifc_file.begin_transaction()`` and ``ifc_file.end_transaction()``. When
``_execute`` raises after at least one ``ifcopenshell.api.*`` mutation
has been captured, the user is in a partial state (IFC mutated, Blender
side stale) and the framework surfaces a WARNING naming Ctrl+Z so the
recovery path is discoverable instead of buried behind a raw traceback.
The contract has three parts pinned here:
1. ``ifcopenshell.file.Transaction.operations`` is a public list and is
the introspection idiom the framework relies on.
2. The WARNING fires only when ``_execute`` raised AND the transaction
captured at least one operation.
3. A successful ``_execute`` never emits the WARNING regardless of
whether IFC was mutated."""
from unittest import mock
import pytest
pytestmark = pytest.mark.misc
@pytest.fixture(autouse=True)
def _require_real_bpy():
import types as _types
import bpy
if not isinstance(bpy, _types.ModuleType) or hasattr(bpy, "_mock_name"):
pytest.skip("requires real Blender (bpy is mocked or absent)")
@pytest.fixture
def fresh_ifc():
"""Set up a fresh ``ifcopenshell.file`` as ``IfcStore.file`` and tear
it down afterwards. Each test gets a virgin transaction state."""
import ifcopenshell
from bonsai.bim.ifc import IfcStore
previous = IfcStore.file
previous_transaction = IfcStore.current_transaction
IfcStore.file = ifcopenshell.file(schema="IFC4")
IfcStore.current_transaction = ""
try:
yield IfcStore.file
finally:
IfcStore.file = previous
IfcStore.current_transaction = previous_transaction
@pytest.fixture
def neutralised_framework():
"""Patch the side-effect-heavy helpers in ``IfcStore.execute_ifc_operator``
so a bare unit test can drive it without a populated Scene / props /
decorator handlers."""
with mock.patch("bonsai.bim.ifc.tool.Blender.get_bim_props") as get_props, mock.patch(
"bonsai.bim.handler.refresh_ui_data"
), mock.patch("bonsai.bim.ifc.tool.Parametric.refresh_post_commit"), mock.patch(
"bonsai.bim.ifc.IfcStore.add_transaction_operation"
), mock.patch(
"bonsai.bim.ifc.IfcStore.begin_transaction"
), mock.patch(
"bonsai.bim.ifc.IfcStore.end_transaction"
), mock.patch(
"bonsai.bim.ifc.IfcStore.get_ifc_file_undo_callback", return_value=lambda data: True
):
get_props.return_value = mock.Mock(is_dirty=False)
yield
def _make_operator(execute_callback):
"""Build a ``Mock`` operator that satisfies the attribute reads the
framework performs (``bl_idname``, ``_execute``, ``report``, etc.)."""
op = mock.Mock(spec=["bl_idname", "_execute", "_invoke", "_modal", "report", "transaction_key"])
op.bl_idname = "bim.test_partial_state"
op._execute = execute_callback
return op
def _mutate_ifc():
"""Single ``ifcopenshell.api.*`` call so the transaction captures at
least one operation. ``project.create_file`` would not work here since
it replaces the file; pick a small entity mutation that always lands."""
import ifcopenshell.api.owner
from bonsai.bim.ifc import IfcStore
ifcopenshell.api.owner.add_person(IfcStore.get_file())
def test_transaction_operations_is_empty_until_first_api_call(fresh_ifc):
"""Pin the introspection contract the framework relies on:
``Transaction.operations`` is empty after ``begin_transaction()`` and
populated by any ``ifcopenshell.api.*`` call."""
fresh_ifc.begin_transaction()
assert fresh_ifc.transaction is not None
assert fresh_ifc.transaction.operations == []
_mutate_ifc()
assert len(fresh_ifc.transaction.operations) > 0
def test_no_mutation_no_raise_no_warning(fresh_ifc, neutralised_framework):
"""Happy path: ``_execute`` does nothing, returns FINISHED.
Framework MUST NOT emit the partial-state WARNING."""
from bonsai.bim.ifc import IfcStore
op = _make_operator(execute_callback=lambda context: {"FINISHED"})
IfcStore.execute_ifc_operator(op, context=mock.Mock())
for call in op.report.call_args_list:
assert "Ctrl+Z" not in call.args[1], "partial-state WARNING fired on a clean success path"
def test_raise_before_mutation_no_warning(fresh_ifc, neutralised_framework):
"""``_execute`` raises before any IFC mutation. The transaction has no
operations no partial state no WARNING."""
from bonsai.bim.ifc import IfcStore
def _raise_immediately(context):
raise RuntimeError("kaboom")
op = _make_operator(execute_callback=_raise_immediately)
with pytest.raises(RuntimeError, match="kaboom"):
IfcStore.execute_ifc_operator(op, context=mock.Mock())
for call in op.report.call_args_list:
assert "Ctrl+Z" not in call.args[1], "partial-state WARNING fired without any mutation"
def test_mutation_then_success_no_warning(fresh_ifc, neutralised_framework):
"""Real mutation, normal FINISHED return. WARNING is exception-path
only and MUST NOT fire on a clean success."""
from bonsai.bim.ifc import IfcStore
def _mutate_and_finish(context):
_mutate_ifc()
return {"FINISHED"}
op = _make_operator(execute_callback=_mutate_and_finish)
IfcStore.execute_ifc_operator(op, context=mock.Mock())
for call in op.report.call_args_list:
assert "Ctrl+Z" not in call.args[1], "partial-state WARNING fired on a successful mutation"
def test_mutation_then_raise_emits_warning(fresh_ifc, neutralised_framework):
"""The contract this whole change exists for: mutate, then raise.
Framework MUST emit a WARNING naming Ctrl+Z before the exception
re-raises into Blender's normal operator error flow."""
from bonsai.bim.ifc import IfcStore
def _mutate_then_raise(context):
_mutate_ifc()
raise RuntimeError("rebuild failed after IFC mutation")
op = _make_operator(execute_callback=_mutate_then_raise)
with pytest.raises(RuntimeError, match="rebuild failed"):
IfcStore.execute_ifc_operator(op, context=mock.Mock())
warning_calls = [
call
for call in op.report.call_args_list
if call.args and call.args[0] == {"WARNING"} and "Ctrl+Z" in call.args[1]
]
assert (
len(warning_calls) == 1
), f"expected exactly one partial-state WARNING with Ctrl+Z guidance, got: {op.report.call_args_list}"
def test_mutation_then_raise_pushes_blender_undo_step(fresh_ifc, neutralised_framework):
"""A raised operator does not get an automatic Blender undo step (same gap
as the CANCELLED-modal path). The framework pushes one explicitly so the
Ctrl+Z the WARNING advertises actually rewinds the partial mutation."""
from bonsai.bim.ifc import IfcStore
def _mutate_then_raise(context):
_mutate_ifc()
raise RuntimeError("rebuild failed after IFC mutation")
op = _make_operator(execute_callback=_mutate_then_raise)
with mock.patch("bonsai.bim.ifc.bpy.ops", new=mock.Mock()) as bpy_ops:
undo_push = bpy_ops.ed.undo_push
with pytest.raises(RuntimeError, match="rebuild failed"):
IfcStore.execute_ifc_operator(op, context=mock.Mock())
assert undo_push.call_count == 1, f"expected exactly one undo_push, got {undo_push.call_count}"
pushed_message = undo_push.call_args.kwargs.get("message", "")
assert op.bl_idname in pushed_message, f"undo step message should name the operator, got: {pushed_message!r}"
def test_raise_before_mutation_does_not_push_undo_step(fresh_ifc, neutralised_framework):
"""No mutation captured → nothing to recover → no recovery undo step.
Avoids polluting the undo history with no-op recovery snapshots."""
from bonsai.bim.ifc import IfcStore
def _raise_immediately(context):
raise RuntimeError("kaboom")
op = _make_operator(execute_callback=_raise_immediately)
with mock.patch("bonsai.bim.ifc.bpy.ops", new=mock.Mock()) as bpy_ops:
undo_push = bpy_ops.ed.undo_push
with pytest.raises(RuntimeError, match="kaboom"):
IfcStore.execute_ifc_operator(op, context=mock.Mock())
assert undo_push.call_count == 0, "undo_push fired on a non-partial-state raise"
+11 -1
View File
@@ -1764,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"
+19 -30
View File
@@ -22,14 +22,15 @@
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<X>`` registration) derives
identifiers from each entry's short ``name`` token. Forget any downstream
registration and the silent-desync the framework exists to prevent will ship.
``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.Blender.Modifier`."""
predicate exists on `tool.Parametric`."""
import types
@@ -81,11 +82,11 @@ def test_every_entry_has_property_group_attached(registry):
)
def test_every_entry_has_modifier_predicate(registry):
def test_every_entry_has_parametric_predicate(registry):
from bonsai import tool
missing = [e.name for e in registry if getattr(tool.Blender.Modifier, f"is_{e.name}", None) is None]
assert not missing, f"tool.Blender.Modifier missing is_<name> predicates: {missing}"
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):
@@ -108,7 +109,7 @@ def test_every_predicate_does_not_raise_on_non_matching_element(registry):
raised = []
for feature in registry:
predicate = getattr(tool.Blender.Modifier, f"is_{feature.name}", None)
predicate = getattr(tool.Parametric, f"is_{feature.name}", None)
if predicate is None:
continue
try:
@@ -122,19 +123,14 @@ def test_every_predicate_does_not_raise_on_non_matching_element(registry):
)
def test_gizmo_preferences_attached_when_class_exists(registry):
"""For every registry entry whose ``GizmoPreferences<Name>`` class exists in
``bonsai.bim.ui``, the matching sub-PointerProperty must be declared on
``ui.GizmoPreferences`` under the registry entry's ``name`` token.
Catches the silent-skip behaviour of the registry-driven gizmo-prefs
discovery: a typo in the class name or a dropped registration would
otherwise produce a missing sub-panel at runtime with no error.
Entries without a ``GizmoPreferences<Name>`` class are allowed not
every parametric type ships gizmo prefs.
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.PointerProperty(...)``) is an
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__``
@@ -142,16 +138,9 @@ def test_gizmo_preferences_attached_when_class_exists(registry):
from bonsai.bim import ui
annotations = getattr(ui.GizmoPreferences, "__annotations__", {})
missing = []
for feature in registry:
prefs_class_name = f"GizmoPreferences{feature.name.capitalize()}"
if not hasattr(ui, prefs_class_name):
continue
if feature.name not in annotations:
missing.append((feature.name, prefs_class_name))
missing = [feature.name for feature in registry if feature.name not in annotations]
assert not missing, (
f"ui.GizmoPreferences missing sub-PointerProperty field(s) for: {missing}"
f"each registered ``GizmoPreferences<Name>`` class must have a matching "
f"``<name>: PointerProperty(type=GizmoPreferences<Name>)`` field on "
f"``ui.GizmoPreferences``"
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"
)
@@ -0,0 +1,91 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Forward-compat AST contract for the pen-icon dispatcher monopoly.
Every parametric gizmo group's pen icon must bind to the universal
``bim.enable_editing_parametric`` dispatcher rather than the feature's own
enable operator. The dispatcher is the single chokepoint where pre-edit
checks (shared-representation warning, future safety gates) run; a feature
that binds directly bypasses every such check silently."""
import ast
from pathlib import Path
import pytest
pytestmark = pytest.mark.drawing
BONSAI_ROOT = Path(__file__).parent.parent.parent / "bonsai"
BIM_DIR = BONSAI_ROOT / "bim"
DISPATCHER_IDNAME = "bim.enable_editing_parametric"
def _iter_pen_gizmo_target_set_operator_calls(tree: ast.Module):
"""Yield each ``ast.Call`` matching ``<receiver>.pen_gizmo.target_set_operator(...)``.
Receiver is any attribute access (``self.pen_gizmo``, ``group.pen_gizmo``, etc.)."""
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 != "target_set_operator":
continue
receiver = func.value
if not isinstance(receiver, ast.Attribute) or receiver.attr != "pen_gizmo":
continue
yield node
def test_every_pen_gizmo_binding_routes_through_the_universal_dispatcher() -> None:
violations: list[str] = []
found_any = False
for path in BIM_DIR.rglob("*.py"):
try:
tree = ast.parse(path.read_text(encoding="utf-8"))
except SyntaxError:
continue
for call in _iter_pen_gizmo_target_set_operator_calls(tree):
found_any = True
if not call.args:
violations.append(f"{path}:{call.lineno} pen_gizmo.target_set_operator() called with no args")
continue
first_arg = call.args[0]
if not isinstance(first_arg, ast.Constant) or not isinstance(first_arg.value, str):
violations.append(
f"{path}:{call.lineno} pen_gizmo.target_set_operator() first arg is not a string literal"
)
continue
if first_arg.value != DISPATCHER_IDNAME:
violations.append(
f"{path}:{call.lineno} pen_gizmo.target_set_operator({first_arg.value!r}) "
f"bypasses the universal dispatcher"
)
assert found_any, (
"No pen_gizmo.target_set_operator(...) calls found anywhere under bim/. "
"Either the gizmo-binding pattern has been refactored away (this test "
"needs updating) or the search root is wrong."
)
assert not violations, (
"Pen-icon bindings must route through the universal dispatcher "
f"({DISPATCHER_IDNAME!r}) so the shared-representation warning and any "
"future pre-edit checks apply to every feature. Violations:\n " + "\n ".join(violations)
)
@@ -0,0 +1,144 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Forward-compat AST contract for the preview cancellation registry.
Every ``PointerProperty`` child of ``BIMPreviewProperties`` whose target
PropertyGroup declares an ``is_active`` BoolProperty is a Scene-level
preview. Each must have a matching ``(child_attr, cancel_op_name)`` entry
in ``preview_base.PREVIEW_CANCEL_OPS`` so the Esc dispatcher and the
``load_post`` stale-flag discard both cover it.
A new preview type that defines its own Enable / Decorator without
registering the cancel pair will silently ignore Esc and leave a stuck
``is_active`` flag across file reloads exactly the failure mode the
sibling forward-compat guards exist to prevent."""
import ast
from pathlib import Path
import pytest
pytestmark = pytest.mark.model
BONSAI_ROOT = Path(__file__).parent.parent.parent / "bonsai"
PROP_FILE = BONSAI_ROOT / "bim" / "module" / "model" / "prop.py"
UMBRELLA_CLASS = "BIMPreviewProperties"
def _find_class(tree: ast.Module, name: str) -> ast.ClassDef | None:
for node in ast.walk(tree):
if isinstance(node, ast.ClassDef) and node.name == name:
return node
return None
def _iter_pointer_property_children(class_node: ast.ClassDef):
"""Yield ``(attr_name, target_class_name)`` for each
``<attr>: bpy.props.PointerProperty(type=<TargetClass>)`` annotated
assignment in the umbrella class body.
Bonsai follows the Blender convention where the property call lives in
the *annotation* (PEP 526 syntax) rather than the value Blender's
PropertyGroup metaclass picks it up at class creation time."""
for node in class_node.body:
if not isinstance(node, ast.AnnAssign) or not isinstance(node.target, ast.Name):
continue
if not isinstance(node.annotation, ast.Call):
continue
func = node.annotation.func
if not isinstance(func, ast.Attribute) or func.attr != "PointerProperty":
continue
for kw in node.annotation.keywords:
if kw.arg == "type" and isinstance(kw.value, ast.Name):
yield node.target.id, kw.value.id
break
def _class_has_is_active_bool(class_node: ast.ClassDef) -> bool:
"""Return True if ``class_node`` declares ``is_active: bpy.props.BoolProperty(...)``."""
for node in class_node.body:
if not isinstance(node, ast.AnnAssign) or not isinstance(node.target, ast.Name):
continue
if node.target.id != "is_active":
continue
if not isinstance(node.annotation, ast.Call):
continue
func = node.annotation.func
if isinstance(func, ast.Attribute) and func.attr == "BoolProperty":
return True
return False
def test_every_preview_propertygroup_is_registered_in_cancel_ops() -> None:
from bonsai.bim.module.model import preview_base
registered_attrs = {attr for attr, _op in preview_base.PREVIEW_CANCEL_OPS}
tree = ast.parse(PROP_FILE.read_text(encoding="utf-8"))
umbrella = _find_class(tree, UMBRELLA_CLASS)
assert umbrella is not None, (
f"Could not find {UMBRELLA_CLASS!r} in {PROP_FILE}. Either the umbrella class "
"was renamed (this test needs updating) or prop.py was restructured."
)
preview_children: list[tuple[str, str]] = []
for attr, target_class_name in _iter_pointer_property_children(umbrella):
target = _find_class(tree, target_class_name)
if target is None:
continue
if _class_has_is_active_bool(target):
preview_children.append((attr, target_class_name))
assert preview_children, (
"No PointerProperty children with ``is_active`` BoolProperty found under "
f"{UMBRELLA_CLASS}. Either the preview convention has been refactored away "
"(this test needs updating) or prop.py was restructured."
)
missing = [(attr, cls) for attr, cls in preview_children if attr not in registered_attrs]
assert not missing, (
"Every Scene-level preview PropertyGroup must have a matching "
"(child_attr, cancel_op_name) tuple in preview_base.PREVIEW_CANCEL_OPS so "
"Esc dispatch and load_post stale-flag discard cover it. Missing entries:\n "
+ "\n ".join(f"BIMPreviewProperties.{attr} (target={cls!r})" for attr, cls in missing)
)
def test_every_cancel_ops_entry_has_a_real_preview_propertygroup() -> None:
"""The reverse contract: a stale entry in ``PREVIEW_CANCEL_OPS`` whose
PropertyGroup has been deleted would silently leak to every Esc press
(dispatching to a missing operator raises ``AttributeError`` inside
``try_cancel_active_preview``). Pin that the registry never goes
stale relative to ``BIMPreviewProperties``."""
from bonsai.bim.module.model import preview_base
tree = ast.parse(PROP_FILE.read_text(encoding="utf-8"))
umbrella = _find_class(tree, UMBRELLA_CLASS)
assert umbrella is not None
declared_attrs = {attr for attr, _target in _iter_pointer_property_children(umbrella)}
orphaned = [attr for attr, _op in preview_base.PREVIEW_CANCEL_OPS if attr not in declared_attrs]
assert not orphaned, (
"PREVIEW_CANCEL_OPS contains entries whose PointerProperty child no longer "
f"exists on {UMBRELLA_CLASS}. Drop the stale tuple(s):\n "
+ "\n ".join(orphaned)
)
+1
View File
@@ -56,6 +56,7 @@ class TestCopyClass:
ifc.get_entity("data").should_be_called().will_return("new_representation")
geometry.get_representation_name("new_representation").should_be_called().will_return("name")
geometry.rename_object("data", "name").should_be_called()
root.has_material_styles("element").should_be_called().will_return(False)
root.assign_body_styles("element", "obj").should_be_called()
collector.assign("obj").should_be_called()
subject.copy_class(ifc, collector, geometry, root, obj="obj")
+29
View File
@@ -52,6 +52,35 @@ class TestAssignContainer:
collector.assign("obj2").should_be_called()
subject.assign_container(ifc, collector, spatial, container="container", objs=["obj"])
def test_root_resolves_to_self_for_a_filling(self, ifc, collector, spatial):
ifc.get_entity("door_obj").should_be_called().will_return("door")
spatial.get_root_element("door").should_be_called().will_return("door")
spatial.disable_editing("door_obj").should_be_called()
spatial.get_decomposition("door").should_be_called().will_return(["door"])
spatial.can_contain("container", "door").should_be_called().will_return(True)
ifc.run("spatial.assign_container", products=["door"], relating_structure="container").should_be_called()
ifc.get_object("door").should_be_called().will_return("door_obj")
collector.assign("door_obj").should_be_called()
subject.assign_container(ifc, collector, spatial, container="container", objs=["door_obj"])
def test_can_contain_is_evaluated_per_root_element(self, ifc, collector, spatial):
ifc.get_entity("door_obj").should_be_called().will_return("door")
spatial.get_root_element("door").should_be_called().will_return("door")
spatial.disable_editing("door_obj").should_be_called()
spatial.get_decomposition("door").should_be_called().will_return(["door"])
ifc.get_entity("opening_obj").should_be_called().will_return("opening")
spatial.get_root_element("opening").should_be_called().will_return("opening")
spatial.disable_editing("opening_obj").should_be_called()
spatial.get_decomposition("opening").should_be_called().will_return(["opening"])
spatial.can_contain("container", "door").should_be_called().will_return(True)
spatial.can_contain("container", "opening").should_be_called().will_return(False)
ifc.run("spatial.assign_container", products=["door"], relating_structure="container").should_be_called()
ifc.get_object("door").should_be_called().will_return("door_obj")
ifc.get_object("opening").should_be_called().will_return("opening_obj")
collector.assign("door_obj").should_be_called()
collector.assign("opening_obj").should_be_called()
subject.assign_container(ifc, collector, spatial, container="container", objs=["door_obj", "opening_obj"])
class TestEnableEditingContainer:
def test_run(self, spatial):
+91
View File
@@ -0,0 +1,91 @@
ISO-10303-21;
HEADER;
FILE_DESCRIPTION(('ViewDefinition[DesignTransferView]'),'2;1');
FILE_NAME('snap-target.ifc','2026-06-05T17:45:10-03:00',(''),(''),'IfcOpenShell 0.0.0','Bonsai 0.8.6-alpha260605-24a241a','Nobody');
FILE_SCHEMA(('IFC4'));
ENDSEC;
DATA;
#1=IFCPROJECT('2pZygwkcb1Au$5kgwmW6ZC',$,'My Project',$,$,$,$,(#10,#22),#5);
#2=IFCSIUNIT(*,.LENGTHUNIT.,.MILLI.,.METRE.);
#3=IFCSIUNIT(*,.AREAUNIT.,$,.SQUARE_METRE.);
#4=IFCSIUNIT(*,.VOLUMEUNIT.,$,.CUBIC_METRE.);
#5=IFCUNITASSIGNMENT((#4,#2,#3));
#6=IFCCARTESIANPOINT((0.,0.,0.));
#7=IFCDIRECTION((0.,0.,1.));
#8=IFCDIRECTION((1.,0.,0.));
#9=IFCAXIS2PLACEMENT3D(#6,#7,#8);
#10=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#9,$);
#11=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Body','Model',*,*,*,*,#10,$,.MODEL_VIEW.,$);
#12=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Axis','Model',*,*,*,*,#10,$,.GRAPH_VIEW.,$);
#13=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Box','Model',*,*,*,*,#10,$,.MODEL_VIEW.,$);
#14=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Annotation','Model',*,*,*,*,#10,$,.SECTION_VIEW.,$);
#15=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Annotation','Model',*,*,*,*,#10,$,.ELEVATION_VIEW.,$);
#16=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Annotation','Model',*,*,*,*,#10,$,.MODEL_VIEW.,$);
#17=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Annotation','Model',*,*,*,*,#10,$,.PLAN_VIEW.,$);
#18=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Profile','Model',*,*,*,*,#10,$,.ELEVATION_VIEW.,$);
#19=IFCCARTESIANPOINT((0.,0.));
#20=IFCDIRECTION((1.,0.));
#21=IFCAXIS2PLACEMENT2D(#19,#20);
#22=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Plan',2,1.E-05,#21,$);
#23=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Axis','Plan',*,*,*,*,#22,$,.GRAPH_VIEW.,$);
#24=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Body','Plan',*,*,*,*,#22,$,.PLAN_VIEW.,$);
#25=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Annotation','Plan',*,*,*,*,#22,$,.PLAN_VIEW.,$);
#26=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Annotation','Plan',*,*,*,*,#22,$,.REFLECTED_PLAN_VIEW.,$);
#27=IFCSITE('1Lvnr1aSn2OP70jevISY_C',$,'My Site',$,$,#50,$,$,$,$,$,$,$,$);
#33=IFCBUILDING('1NaNtC8Wf6YPU7CZRG8Z8Q',$,'My Building',$,$,#56,$,$,$,$,$,$);
#39=IFCBUILDINGSTOREY('1k9kfaMOr2cvyNvxvoll27',$,'My Storey',$,$,#62,$,$,$,$);
#45=IFCRELAGGREGATES('1plJGwDIHDzwuc7i7gcQQD',$,$,$,#1,(#27));
#46=IFCCARTESIANPOINT((0.,0.,0.));
#47=IFCDIRECTION((0.,0.,1.));
#48=IFCDIRECTION((1.,0.,0.));
#49=IFCAXIS2PLACEMENT3D(#46,#47,#48);
#50=IFCLOCALPLACEMENT($,#49);
#51=IFCRELAGGREGATES('13KnzD8ZT8bAvAyOQAWiUj',$,$,$,#27,(#33));
#52=IFCCARTESIANPOINT((0.,0.,0.));
#53=IFCDIRECTION((0.,0.,1.));
#54=IFCDIRECTION((1.,0.,0.));
#55=IFCAXIS2PLACEMENT3D(#52,#53,#54);
#56=IFCLOCALPLACEMENT(#50,#55);
#57=IFCRELAGGREGATES('2Hf4WQLJvE4O43wq$0AZeu',$,$,$,#33,(#39));
#58=IFCCARTESIANPOINT((0.,0.,0.));
#59=IFCDIRECTION((0.,0.,1.));
#60=IFCDIRECTION((1.,0.,0.));
#61=IFCAXIS2PLACEMENT3D(#58,#59,#60);
#62=IFCLOCALPLACEMENT(#56,#61);
#63=IFCPROPERTYSINGLEVALUE('Symbol',$,IFCLABEL('setout-point'),$);
#64=IFCPROPERTYSET('27lmSbeAXC08EWkEq8XdUG',$,'EPset_Annotation',$,(#63));
#65=IFCTYPEPRODUCT('0UrP0fLdD5OwzwD41aRKao',$,'SETOUT-POINT',$,'IfcAnnotation/SYMBOL',(#64),$,$);
#66=IFCCARTESIANPOINTLIST3D(((-8999.9990234375,-8999.9990234375,0.),(8999.9990234375,-8999.9990234375,0.),(-8999.9990234375,8999.9990234375,0.),(8999.9990234375,8999.9990234375,0.)));
#67=IFCINDEXEDPOLYGONALFACE((1,2,4,3));
#68=IFCPOLYGONALFACESET(#66,$,(#67),$);
#69=IFCSHAPEREPRESENTATION(#11,'Body','Tessellation',(#68));
#70=IFCBUILDINGELEMENTPROXY('3Avrn7zrPBiA7RUh91ENg9',$,'Plane',$,$,#142,#72,$,.COMPLEX.);
#71=IFCRELCONTAINEDINSPATIALSTRUCTURE('0ftVF1Vf1Dmg$p62mtpWVF',$,$,$,(#123,#70,#108),#39);
#72=IFCPRODUCTDEFINITIONSHAPE($,$,(#69));
#108=IFCBUILDINGELEMENTPROXY('1lwURx5YX5WAXD5ExqQt31',$,'Plane',$,$,#122,#117,$,.COMPLEX.);
#114=IFCCARTESIANPOINTLIST3D(((-5999.99951171875,-2999.99975585938,0.),(5999.99951171875,-2999.99975585938,0.)));
#115=IFCINDEXEDPOLYCURVE(#114,(IFCLINEINDEX((1,2))),$);
#116=IFCSHAPEREPRESENTATION(#11,'Body','Curve3D',(#115));
#117=IFCPRODUCTDEFINITIONSHAPE($,$,(#116));
#118=IFCCARTESIANPOINT((0.,0.,0.));
#119=IFCDIRECTION((0.,0.,1.));
#120=IFCDIRECTION((1.,0.,0.));
#121=IFCAXIS2PLACEMENT3D(#118,#119,#120);
#122=IFCLOCALPLACEMENT(#62,#121);
#123=IFCBUILDINGELEMENTPROXY('2X4YMFxHjANvnkl1Hzqjlq',$,'Plane',$,$,#137,#132,$,.COMPLEX.);
#129=IFCCARTESIANPOINTLIST3D(((3000.,-5999.99951171875,0.),(2999.99951171875,5999.99951171875,0.)));
#130=IFCINDEXEDPOLYCURVE(#129,(IFCLINEINDEX((1,2))),$);
#131=IFCSHAPEREPRESENTATION(#11,'Body','Curve3D',(#130));
#132=IFCPRODUCTDEFINITIONSHAPE($,$,(#131));
#133=IFCCARTESIANPOINT((0.,0.,0.));
#134=IFCDIRECTION((0.,0.,1.));
#135=IFCDIRECTION((1.,0.,0.));
#136=IFCAXIS2PLACEMENT3D(#133,#134,#135);
#137=IFCLOCALPLACEMENT(#62,#136);
#138=IFCCARTESIANPOINT((0.,0.,0.));
#139=IFCDIRECTION((0.,0.,1.));
#140=IFCDIRECTION((1.,0.,0.));
#141=IFCAXIS2PLACEMENT3D(#138,#139,#140);
#142=IFCLOCALPLACEMENT(#62,#141);
ENDSEC;
END-ISO-10303-21;
+75 -4
View File
@@ -114,11 +114,13 @@ def new_project():
props.template_file = "0"
tool.Blender.get_addon_preferences().should_play_chaching_sound = False
def get_area_and_region(window):
area = next(area for area in window.screen.areas if area.type == "VIEW_3D")
region = next(region for region in area.regions if region.type == "WINDOW")
return area, region
def test_snap_object_detection(window):
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", 0, 0)
area, region = get_area_and_region(window)
@@ -161,6 +163,7 @@ def test_snap_object_detection(window):
yield from preset_event_simulate(window, "RET", "TAP", x, y)
yield "FINISHED"
def test_snap_partially_behind_camera(window):
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", 0, 0)
area, region = get_area_and_region(window)
@@ -215,10 +218,11 @@ def test_snap_partially_behind_camera(window):
yield from preset_event_simulate(window, "RET", "TAP", x, y)
yield "FINISHED"
def test_snap_in_xray_mode(window):
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", 0, 0)
area, region = get_area_and_region(window)
x = round(area.width * 0.68+ area.x)
x = round(area.width * 0.68 + area.x)
y = round(area.height * 0.54 + area.y)
area.spaces[0].shading.show_xray = True
@@ -244,10 +248,11 @@ def test_snap_in_xray_mode(window):
assert_msg = "Object should be an IfcFurniture"
assert snap_point.snap_object.split("/")[0] == "IfcFurniture", assert_msg
_assert_pass(assert_msg)
yield from preset_event_simulate(window, "RET", "TAP", x, y)
yield "FINISHED"
def test_snap_far_from_origin(window):
bpy.context.view_layer.objects.active = None
bpy.ops.object.select_all(action="DESELECT")
@@ -258,12 +263,11 @@ def test_snap_far_from_origin(window):
yield from preset_event_simulate(window, "ESC", "TAP", x, y)
bpy.data.objects['IfcBuildingElementProxy/Cube'].select_set(True)
bpy.data.objects["IfcBuildingElementProxy/Cube"].select_set(True)
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x, y)
with bpy.context.temp_override(area=area, region=region, space_data=area.spaces[0]):
bpy.ops.view3d.view_selected()
measure_settings = tool.Project.get_measure_tool_settings()
measure_settings.measurement_type = "POLYLINE"
for obj in tool.Blender.get_selected_objects():
@@ -296,6 +300,65 @@ def test_snap_far_from_origin(window):
yield from preset_event_simulate(window, "RET", "TAP", x, y)
yield "FINISHED"
def test_snap_targets(window):
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", 0, 0)
area, region = get_area_and_region(window)
x = round(area.width * 0.44 + area.x)
y = round(area.height * 0.73 + area.y)
yield from preset_event_simulate(window, "ESC", "TAP", x, y)
options = []
props = tool.Snap.get_snap_props()
try:
annotations = props.__annotations__
except AttributeError:
annotations = type(props).__annotations__
for prop in annotations.keys():
if getattr(props, prop):
options.append((prop, props.rna_type.properties[prop].name))
for prop, name in options:
any(setattr(props, prop2, prop2 == prop) for prop2, _ in options) # set prop to true and others to false
measure_settings = tool.Project.get_measure_tool_settings()
measure_settings.measurement_type = "POLYLINE"
for obj in tool.Blender.get_selected_objects():
obj.select_set(False)
with bpy.context.temp_override(area=area, region=region, space_data=area.spaces[0]):
bpy.ops.bim.measure_tool("INVOKE_DEFAULT", measure_type="POLYLINE")
snap_types = []
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", x, y)
yield from preset_event_simulate(window, "LEFTMOUSE", "TAP", x, y)
snap_type = tool.Model.get_polyline_props().snap_mouse_point[0].snap_type
snap_types.append(snap_type)
new_x = x + 200
new_y = y - 55
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", new_x, new_y)
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", new_x, new_y)
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", new_x, new_y)
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", new_x, new_y)
yield from preset_event_simulate(window, "LEFTMOUSE", "TAP", x, y)
snap_type = tool.Model.get_polyline_props().snap_mouse_point[0].snap_type
snap_types.append(snap_type)
new_x = x + 130
new_y = y - 358
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", new_x, new_y)
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", new_x, new_y)
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", new_x, new_y)
yield from preset_event_simulate(window, "MOUSEMOVE", "NOTHING", new_x, new_y)
yield from preset_event_simulate(window, "LEFTMOUSE", "TAP", x, y)
snap_type = tool.Model.get_polyline_props().snap_mouse_point[0].snap_type
snap_types.append(snap_type)
yield from preset_event_simulate(window, "ESC", "TAP", x, y)
assert_msg = f"{name} should be in snap_types: {snap_types}"
assert name in snap_types
_assert_pass(assert_msg)
def test_draw_polyline_wall(window, x, y):
yield from preset_event_simulate(window, "ESC", "TAP", x, y)
area, region = get_area_and_region(window)
@@ -358,6 +421,13 @@ def run_tests():
lambda w=window: test_snap_in_xray_mode(w),
lambda w=window: test_snap_far_from_origin(w),
]
elif module_name == "snap-target":
filepath = f"./test/files/snap-target.ifc"
bpy.ops.bim.load_project(filepath=filepath)
window = _get_valid_window()
test_queue = [
lambda w=window: test_snap_targets(w),
]
else:
cleanup()
@@ -375,6 +445,7 @@ def run_tests():
_next()
if __name__ == "__main__":
new_project()
run_tests()
+16
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 tempfile
from pathlib import Path
@@ -22,6 +24,7 @@ from typing import TYPE_CHECKING
import bpy
import ifcopenshell
import numpy as np
import pytest
import bonsai
@@ -167,3 +170,16 @@ class TestGetDebugInfo(NewFile):
def test_failed_to_load_returns_only_base_keys(self):
info = bonsai.get_debug_info(bonsai_failed_to_load=True)
assert set(info.keys()) == self.EXPECTED_KEYS
class TestNpFrombufferLegacy(NewFile):
"""Decoding ``n`` floats from a buffer must yield a length-``n`` array
regardless of whether the buffer was written as ``float32`` or ``float64``."""
@pytest.mark.parametrize("n", [3, 9])
@pytest.mark.parametrize("dtype", [np.float32, np.float64])
def test_decodes_to_n_elements(self, n, dtype):
data = np.arange(n, dtype=dtype).tobytes()
result = subject.np_frombuffer_legacy(data, n)
assert result.shape == (n,)
np.testing.assert_allclose(result, np.arange(n))

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