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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
Three test files covering PR4's new surfaces — preview registry,
wall-gizmo poll behaviour, fillet operator registration. Every test
walks the live registry or class hierarchy instead of hard-coding
preview keys, operator names, or helper function names, so adding a
new preview / wall gizmo group / fillet operator exercises the same
invariants without test edits.
test_preview_base.py (6 tests):
* RegistryContract: every PREVIEW_CANCEL_OPS entry resolves to a
callable cancel operator on bpy.ops.bim.
* GetPreviewPropsTolerance: get_preview_props returns None for
contexts without a scene (regression guard for the SimpleNamespace
bug fixed in commit ee63137c6).
* ActivationCycle (registry-driven loop): any_preview_active toggles
with each registered preview's is_active flag;
discard_pending_previews clears every active flag across every
registered preview.
* SaveOnDiscardWired: locates the bim.save_project operator
dynamically and verifies its execute path references the discard
helper by its actual __name__.
test_wall_gizmo_poll_gate.py (4 tests):
* WallGizmoGroupsHideDuringPreview: walks the wall module for
bpy.types.GizmoGroup subclasses (skips preview-owner exceptions
whose bl_idname contains 'preview'), mocks any_preview_active to
True, and asserts every discovered gizmo's poll returns False.
* BaseParametricGizmoPollHidesDuringPreview: mirrors the test for
the cross-feature parametric framework base class.
test_fillet_operators.py (3 tests):
* FilletOperatorsRegistered: at-least-four-ops + every-discovered-op-
is-callable. Catches accidental deregistration.
* EnableRejectsIneligibleSelection: poll returns False without a
selection so the operator is greyed-out in menus.
State-clearing tests via bpy.ops.bim.cancel_wall_fillet_preview() are
deliberately omitted — the operator early-returns when context.screen
is unattached and prior tests in the model lane can leave the screen
in that state, making the dispatch path inherently flaky. Live testing
covers the behaviour.
Net: 13 tests pass cleanly in both single-file and full model lane.
Generated with the assistance of an AI coding tool.
Save-path:
* SaveProject._execute (project/operator.py) now calls
preview_base.discard_pending_previews(context.scene) right after
tool.Parametric.commit_pending_edits(). Previews are session-
transient — discard rather than commit. Sibling gizmo polls gate
on each preview's is_active flag; a stuck flag persisted through
the save would silently hide them on reload. Mirrors the pattern
already in gizmos-8088.
Preview-active gate hardening:
* preview_base.get_preview_props tolerates contexts without a
``scene`` attribute. Pre-existing tests use SimpleNamespace mocks
for the context; the previous getattr(context.scene, ...) raised
AttributeError before the inner default kicked in.
Test update:
* test_wall_header_refresh.test_geom_generation_invalidates_wall_geom_cache
patches tool.Wall.read_geometry instead of the now-deleted local
wall._read_wall_geometry (commit 7e5e7b8d6 migrated the call site).
Generated with the assistance of an AI coding tool.
DecorationsHandler now caches dissolved edges (mesh-keyed), world-space
draw payload, and GPUBatch objects with per-object epoch invalidation —
moving one wall doesn't wipe 50 opening caches. Object-mode dissolve
removes triangulation noise; 2-pass depth-test split dims occluded lines
instead of hiding them. Edit-mode behavior unchanged.
Also: disable viewport shadows for IfcFeatureElementSubtraction objects,
and wire DecorationsHandler.uninstall() into the model module's
unregister() so the new persistent handlers don't leak on addon disable.
Generated with the assistance of an AI coding tool.
The single-wall unjoin gizmo needs to enumerate every
IfcRelConnectsPathElements a wall participates in, regardless of which
side of the rel the wall was authored on, and place an icon at each
join's physical location. Two helpers carry that work:
_path_connection_location_world wraps core.compute_path_connection_location
at the Vector boundary. _iter_path_connections walks ConnectedTo +
ConnectedFrom, normalises orientation to (other, self_ct, other_ct),
and filters non-wall partners + None refs so per-frame gizmo positioning
survives malformed IFC.
Generated with the assistance of an AI coding tool.
Two paired test files for the framework infrastructure landed
earlier in this PR.
test_decorator_cache.py (11 tests):
* The 4-hook invalidation list (depsgraph_update_post + undo_post +
redo_post + load_post) is symmetrically managed by
install_decorator_cache_handlers / uninstall_decorator_cache_handlers.
A future edit that drops a hook from one side without the other
would land as a Blender segfault when a cached bpy.types.Object
ref outlives its underlying ID block — the regression must surface
as a test failure first.
* install is idempotent (calling twice doesn't double-register).
* uninstall when not installed doesn't raise.
* The bump handler accepts Blender's variadic args.
* The depsgraph predicate gates correctly: bumps on Object geometry
or transform updates, silently skips on Material / NodeTree / Image
updates (which would otherwise rebuild every cache on every node
edit).
* TokenCache.get_or_compute short-circuits on key+token match and
recomputes when the token bumps.
test_undo_resync_parametric_drafts.py (3 tests):
* UNDO_REGENERATORS keys must all be in tool.Parametric.EDIT_TYPES.
A typo would silently no-op on Ctrl+Z, restoring the desync the
helper is meant to prevent.
* The dispatcher skips objects with no active parametric edit
(undo_post fires for every undo, most of which touch zero drafts).
* The dispatcher silently skips parametric types that have no
UNDO_REGENERATORS entry (door / window / array are IFC-derived
with no draft preview mesh — they don't need a regenerator).
Mocks use spec=bpy.types.Depsgraph / spec=bpy.types.DepsgraphUpdate
/ spec=tool.parametric.ParametricObject so typos in mocked-attribute
access fail loudly (CLAUDE.md test discipline).
Generated with the assistance of an AI coding tool.
Splitting a wall through a door orphaned the door (door.FillsVoids
became empty). The fill rel was being reassigned by setting its
RelatedBuildingElement slot — schema-wise that's the filling slot, not
the wall slot — so when remove_feature deleted the old opening it
also cascade-removed the rel. Transferring via RelatingOpeningElement
keeps the rel pointing at the new opening so the door stays
associated. Pre-existing bug from 5a6476a57, surfaced by ef144dce2.
Generated with the assistance of an AI coding tool.
DumbWallJoiner.split assigned openings by projecting the opening's
centre-point onto the wall axis, so any opening whose footprint
straddled the cut was silently dropped from whichever wall its centre
missed. Now the full axis-projected extent (via ifcopenshell.geom.
create_shape) drives the assignment; for filled openings whose void
straddles the cut, a pure-void copy is added back to the neighbour
wall so its body is also cut.
Generated with the assistance of an AI coding tool.
Ctrl+Z / Ctrl+Shift+Z on an in-progress parametric draft (wall /
stair / roof) used to leave the preview mesh frozen in its
pre-undo shape — the IFC mutation rolls back but the bmesh built
from draft props doesn't repaint.
Add a registry of per-type regenerator functions
(``UNDO_REGENERATORS``) that re-build each type's preview mesh
from its current props. The dispatcher
``resync_parametric_drafts_after_undo`` walks all objects, skips
any without an active parametric edit, looks up the regenerator
by feature name, and calls it. Tagged 3D viewports for redraw.
Types without an entry (door / window / railing / etc.) are
intentionally absent — they're IFC-derived, so the undo's
representation rollback + next-frame refresh already repaints
correctly without a draft-side regenerator.
Undo/redo wiring is self-installed by
``bonsai.bim.parametric_lifecycle``: a ``@persistent``
``_resync_on_undo`` callback dispatches into the registry, and
``install_parametric_lifecycle_handlers()`` /
``uninstall_parametric_lifecycle_handlers()`` append/remove it
from ``bpy.app.handlers.undo_post`` and ``redo_post``.
``bim/__init__.py``'s ``register()`` calls the install function
*after* the central ``handler.undo_post`` / ``redo_post`` appends
so the regenerators see restored IFC state — ``bpy.app.handlers``
fire in append order. ``handler.py`` itself stays ignorant of the
parametric subsystem. The lazy function-local imports in each
regenerator break the addon-load cycle —
``bonsai.bim.parametric_lifecycle`` loads before
``bim/module/model/*``.
Generated with the assistance of an AI coding tool.
Three changes to the shared Enable/Finish/Cancel mixins:
1. Always-on drift triad on ParametricEditMixinBase. The base now
provides ``_handle_drift_on_enable`` / ``_handle_drift_on_finish``
/ ``_handle_drift_on_cancel`` classmethods, called from the
per-mixin ``_enable_one`` / ``_finish_one`` / ``_cancel_one``.
Pre-edit Blender-side translations commit to IFC on Enable
(apply_scale=False — only translation/rotation, not the user's
accidental scale), in-edit drag commits on Finish (apply_scale=True),
and Cancel restores the committed IFC placement via
``restore_or_rebaseline_placement``. Prevents the
"uncommitted drag disappears on Finish" and "preview snaps back
on Cancel" UX bugs.
2. ``_ParametricEditMixinBase`` renamed to ``ParametricEditMixinBase``
(public). Per-feature mixins that need to subclass directly
(e.g., when neither FeatureModifier nor PathPreserving fits)
can do so without reaching into a private name.
3. ``_update_modifier_bmesh`` (PathPreserving) renamed to
``_restore_viewport_after_cancel``. The old name was inaccurate
for subclasses that load a different IFC representation on
Cancel rather than rebuilding a bmesh preview from props.
Plus two polish changes:
* ``_mark_type_thumbnail_dirty`` helper on the base centralises the
``ifcopenshell.util.element.get_type`` + thumbnail-mark pattern
that both mixins repeated inline.
* ``FeatureModifierEditMixin._cancel_one`` and
``PathPreservingEditMixin._cancel_one`` wrap the restore in
``try/finally`` so ``props.is_editing = False`` flips even on
partial restore failure. Without this, a Cancel that raised
mid-restore would leave the user locked out of the edit lifecycle.
* ``PathPreservingEditMixin._finish_one`` / ``_cancel_one`` skip the
pset commit + viewport rebuild when the draft equals the stored
pset (no-op Enable→Finish round-trip should not pollute the
representation list or burn an undo entry).
``FeatureModifierEditMixin._finish_one`` now routes the pset commit
through ``tool.Pset.write_bbim_data`` instead of inlining the
``createIfcText(json.dumps(...))`` + ``ifcopenshell.api.pset.edit_pset``
dance. Two test assertions updated to match.
Generated with the assistance of an AI coding tool.
Surfaces any regression in:
* the modules dict in bim/__init__.py (added a folder, forgot the entry)
* PointerProperty wiring on bpy.types.{Scene,Object,...}
* registry-driven GizmoPreferences<Name> auto-registration in
tool.Parametric.iter_gizmo_preference_classes
* bpy.app.handlers append/remove balance
* every register()/unregister() across the 45+ feature modules
as a single PASSED/FAILED test instead of the silent "addon failed to
enable" users encounter in a fresh Blender. Paired with the existing
test_parametric_registry.py contract tests, this catches both the
registry-shape regressions (operators/PropertyGroups/predicates) and
the registration-mechanics regressions (PointerProperty types not
registered before their owners).
Generated with the assistance of an AI coding tool.
test_parametric_lifecycle.py covers the door/window/railing/roof
state-transition contracts (enable/finish/cancel; no-op on
non-matching elements; draft preserved on finish-time failure)
that the registry smoke test never exercised.
test_parametric_registry.py gains a check that every is_<name>
predicate stays total (never raises on a non-matching IFC entity)
— a raising predicate would break the save path for unrelated
types. Also rewrites the gizmo-prefs check to read __annotations__
instead of hasattr, which depended on Blender registration timing.
Generated with the assistance of an AI coding tool.
Aligns with the test/bim/ convention: heavy imports go inside test
functions so the autouse _require_real_bpy fixture skips cleanly
when bpy is mocked, rather than module-level imports failing at
collection time and erroring out the whole file.
Generated with the assistance of an AI coding tool.
When two GizmoDimension hit regions overlap (a short dimension
nested inside a longer one along the same axis), the larger one
used to win because hit boxes are scaled by world-space length —
the long box fully contains the short one, leaving the short
gizmo unreachable. The larger gizmo stays clickable at its
exposed ends, so smaller-wins is the right UX default.
Sets self.select_bias = -self._dimension_length inside
GizmoDimension.set_dimension_length. The smaller gizmo writes a
less-negative depth value in the GPU select buffer and wins the
tie-break. select_bias is unused elsewhere in the codebase, so
icon and arrow gizmos keep bias=0 and are unaffected (icons
correctly still win against dimensions, since 0 > -length).
Adds test/bim/module/drawing/test_dimension_gizmo_priority.py
with 5 cases: direct ordering, monotonicity across length ranges,
abs() handling for signed dimensions, and NaN/Inf safety.
Generated with the assistance of an AI coding tool.
set_icon_gizmo_position computed
``mw @ (Translation @ billboard_rot @ Scale)`` — the object's world
matrix was applied AFTER the billboard rotation, so any non-trivial
object rotation (e.g. a wall rotated in plan, a stair rotated to
match a corridor) carried over into the icon's transform and tilted
it edge-on to the camera instead of facing it.
Switch to ``billboarded_at(world_pos, billboard_rot, scale)`` where
``world_pos = mw @ local_pos``: translate to world space first, then
apply the billboard rotation independently of the object's rotation.
This matches the manual pattern the base class's
``update_editing_gizmos`` already uses for validate/cancel/cycle for
exactly this reason.
Drops the now-stale workaround docstring on
``GizmoWallEdition._update_icon_row_extras`` that documented why it
bypassed ``set_icon_gizmo_position`` — the helper does the right
thing now.
Adds ``test/bim/module/model/test_stair_gizmos.py`` as the regression
guard: parametrised over six rotation angles, asserts that the rotation
part of the resulting matrix equals ``billboard_rot`` (no contribution
from ``mw``'s rotation) and that the translation lands at
``world_pos``. Also exercises ``set_icon_gizmo_position`` end-to-end via
a stub gizmo to catch the exact shape of the previously-broken call
site.
Generated with the assistance of an AI coding tool.
Walls gain in-viewport parametric editing matching the door/window/stair
UX: drag handles for length, height, slope (x-angle), layer baseline
cycle, plus cursor-anchored quality-of-life operators (split at cursor,
extend to cursor, extend height, rotate 90, toggle openings) and
two-object state-machine gizmos (unjoin / merge / join-corner /
extend-to-wall / extend-vertically / add-opening).
Wall enters tool.Parametric.EDIT_TYPES, so save-time auto-commit,
GizmoPreferencesWall registration, and the in-progress-edit predicates
all light up automatically through the registry plumbing landed two
commits back.
The three-layer commit model (drag -> BIMWallProperties -> bmesh
preview -> Finish -> single ifc.run) means dragging a handle through
hundreds of intermediate values produces zero extra IFC entities. A
no-op enable->finish round-trip is byte-identical. The snapshot diff
in FinishEditingWall skips unchanged params.
_commit_active_wall_edit_if_any ensures cursor-anchored operators see
committed geometry, not the draft preview box.
Also lands the `prompt_auto_commit_parametric_edits` BoolProperty on
BIM_ADDON_preferences (consumed by the auto-commit dialog landed in
the framework commit) and refactors
`draw_{door,window,stair}_gizmo_parameters` into a shared
`_draw_parametric_gizmo_parameters` helper that the new
`draw_wall_gizmo_parameters` reuses. This commit and the framework
commit are stacked - the framework commit references the BoolProperty
defined here, so they must land together.
Tests cover pure math (core/test_model.py), DimensionGizmoConfig text
formatter, GizmoWallExtendVertically.poll() preconditions, and the
refresh_post_commit cache-invalidation regression. BDD scenarios in
model.feature cover the edit triad, auto-commit on save, and the
two-object gizmos. Documentation added to creating_walls.rst.
Generated with the assistance of an AI coding tool.
Establish a single source of truth for parametric element types (door,
window, stair, railing, roof). tool.Parametric.EDIT_TYPES drives:
- BIM<Name>Properties PointerProperty attachment via the registry
- GizmoPreferences<Name> class registration in bim/__init__.py
- save-time auto-commit of pending draft edits
- the refresh_post_commit epilogue called from IfcStore after every IFC
mutation, which fixes the stale-header bug where in-place hotkey
mutations (S_E / C_E) left BIMModelProperties and the gizmo cache
pointing at obsolete values.
Refactors door/window/railing/roof onto shared mixins from
bim/parametric_lifecycle.py (FeatureModifierEditMixin and
PathPreservingEditMixin); stair gets the lock-gizmo refactor and
frame-cache integration. Behavior preserved.
Adds BaseParametricGizmoGroup._prime_frame_caches so the parametric
gizmos stop re-deriving preferences, view direction, and billboard
rotation per frame; reorders poll() to short-circuit on the cheapest
predicate first. Adds the icon library + BillboardingGizmoGroupMixin
that the wall feature in the next commit will consume.
Generated with the assistance of an AI coding tool.
When a project has a ifc file associated, selecting non-ifc objects and duplicating them with SHIFT + D now correctly both duplicate them, keep the new objects selected and starts the transform modal. IFC objects behaviour is unaffected.
Parse ifcmerge JSON output and display a per-conflict breakdown in the
panel when merge fails. Ctrl+click on the Merge button previews
conflicts without committing. Add SelectConflictEntity operator to
select and frame the conflicting object in the 3D viewport.
Generated with the assistance of an AI coding tool.
Move all business logic into bonsai core and tool. Performance fixes to
minimise file IO, various minor bug fixes and tests.
Generated with the assistance of an AI coding tool.
Fix#7034
bpy.ops.bim.extend_to_underside doesn't exist - the correct operator
name is bim.extend_walls_to_underside. The AttributeError killed the
entire panel draw, hiding mirror, align, aggregation, and QTO buttons.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add tests for all four covering generation operators: flooring/ceiling
from walls and flooring/ceiling from cursor. Previously only flooring
from walls was tested.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Walk up the full hierarchy via get_parent() in can_nest() and
can_aggregate() to reject assignments that would create a cycle.
Also reject self-assignment.
Fix#7248
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Tests using custom first/last tread runs were not setting
custom_tread_lock=False, so the custom values were silently ignored
since 8f7cf76d9 introduced the lock gate in the calculation.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>