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.
The narrow `<AppKit/NSView.h>` header only forward-declares NSWindow,
so `view.window.backingScaleFactor` fails to compile on macOS with:
error: property 'backingScaleFactor' cannot be found in forward
class object 'NSWindow'
Use the AppKit umbrella header so NSWindow's interface (including
backingScaleFactor) is in scope. Same idiomatic include any non-trivial
Cocoa code reaches for; we're already linking -framework Cocoa so
there's no compile-time cost beyond a slightly heavier translation
unit (the .mm is ~30 lines anyway).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Without this, BonsaiViewer.app launched on macOS would show a white
viewport for the same reason Windows did before d4d0c934b — the
`createSurface()` else branch fell through to "wgpu surface creation
not yet wired for this platform", `init()` returned false, and the
Qt window background was all the user saw.
Pieces:
- `WgpuMetalSurface_mac.{h,mm}`: tiny Objective-C++ bridge. Takes the
NSView pointer that Qt's `winId()` returns on macOS, attaches a
CAMetalLayer (using Qt's existing one when surfaceType is
MetalSurface, attaching one ourselves as a defensive fallback),
sets `contentsScale` from the window's backing scale factor so
retina drawables come out at native resolution, and returns the
layer as `void*`. The .mm keeps the Objective-C namespace pollution
out of WgpuViewportWindow.cpp.
- `WgpuViewportWindow` ctor: `setSurfaceType(QSurface::MetalSurface)`
on macOS so Qt backs the NSView with a CAMetalLayer at window
creation; OpenGLSurface elsewhere as before.
- `WgpuViewportWindow::createSurface()`: new `#elif defined(Q_OS_MAC)`
branch that fills a `WGPUSurfaceSourceMetalLayer` with the layer
pointer from the bridge and hands it to `wgpuInstanceCreateSurface`.
- `CMakeLists.txt`: `enable_language(OBJCXX)` + the .mm file added to
the source list on Apple, and links `-framework Cocoa` (NSView) +
`-framework QuartzCore` (CAMetalLayer).
`winId()` on macOS returns the backing NSView*, not the NSWindow* —
that's the layer-bearing host wgpu-native expects.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Without a Windows branch in `WgpuViewportWindow::createSurface()` we
were falling through to the `qWarning() << "wgpu surface creation not
yet wired for this platform"` else clause on Windows runs, causing
`init() -> createSurface()` to return false and the viewport to render
nothing (the user sees the Qt window's background fill — a white
viewport — and the log says "wgpu init failed; viewport will not
render in DebugView").
Add a `#elif defined(Q_OS_WIN)` branch that fills a
`WGPUSurfaceSourceWindowsHWND` chained-struct from
`GetModuleHandleW(nullptr)` (HINSTANCE) and `winId()` (HWND, as a Win32
window handle on the Qt Windows platform plugin), then passes it as
`surface_desc.nextInChain` to `wgpuInstanceCreateSurface`.
`<windows.h>` is pulled in inside the gated block with NOMINMAX and
WIN32_LEAN_AND_MEAN defined first so the preprocessor pollution
(`min`, `max`, etc.) doesn't leak into Eigen / `std::min`,`std::max`
elsewhere in the TU.
Note on the unrelated DXC log line the user also sees:
[wgpu err] DxcCreateInstance failed:
No such interface supported (0x80004002)
That is wgpu-native's DX12 backend probing for a modern
`dxcompiler.dll`. `E_NOINTERFACE` means a *too-old* dxcompiler.dll was
found on the system DLL search path (typical: a stale copy in
System32 / Visual Studio install). wgpu-native then falls back to its
Vulkan backend, so this log line is recoverable on its own — the
fatal failure was the missing surface branch above. If we hit shader
compilation issues after this lands, we can ship a known-good
dxcompiler.dll + dxil.dll alongside wgpu_native.dll separately.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The Windows branch of the wgpu-native FetchContent block was
hardcoding the x86_64 archive name regardless of host arch — the
macOS and Linux branches already switch on
\`CMAKE_SYSTEM_PROCESSOR MATCHES "arm64|aarch64"\`, but Windows
didn't get the same treatment because the wgpu work was done on
x86_64 hosts.
Result on \`windows-11-arm\`: CMake downloaded
\`wgpu-windows-x86_64-msvc-release.zip\`, IfcViewerWgpu linked against
the x86_64 import library, and the final link of
IfcViewerWgpuMinimal/BonsaiViewer emitted ~60 unresolved \`wgpu*\`
externs because the import-lib symbols are x86_64-only.
Upstream wgpu-native v29.0.0.0 already publishes
\`wgpu-windows-aarch64-msvc-release.zip\` — switching on
\`CMAKE_SYSTEM_PROCESSOR\` so the right archive gets fetched is
sufficient.
Also restores the ARM64 row in \`.github/workflows/build_win.yml\`
that the prior commit dropped (the comment there was wrong; upstream
does ship the binary, our CMake just wasn't asking for it).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Linux (Rocky manylinux, GCC 11):
- WgpuAreaMeasurement.h: include <cstddef> directly. GCC 11 does not
transitively pull `size_t` through <vector>, so triangleCount()'s
return type fails to parse.
- WgpuViewportWindow.cpp:meshLocalToGlobal: use static_cast<double>(...)
instead of double(mesh_local[N]) when constructing the Eigen::Vector4d.
The latter triggers GCC 11's most-vexing-parse: it reads
`Vector4d local(double(mesh_local[0]), double(mesh_local[1]), ...)`
as a function declaration of `local` taking parameters
`double mesh_local[0]` etc., colliding with the outer `mesh_local`
parameter and failing with "redefinition of double* mesh_local".
macOS arm64:
- IfcViewerWgpuMinimal + BonsaiViewer install rules: change
`BUNDLE DESTINATION bin` → `BUNDLE DESTINATION .`. Qt's deploy
generator emits `macdeployqt <Target>.app` with no path prefix, which
only resolves when the bundle sits at the install-prefix root.
`BUNDLE DESTINATION bin` put it at `<prefix>/bin/Target.app` and the
install/strip step failed with "Could not find app bundle".
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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.
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.
Bundles four portability fixes uncovered by manually firing the platform
workflows against this branch:
- WgpuOverlayRenderer.cpp: GCC 11 (Rocky manylinux runner) does not parse
a multi-line raw string inside `#define`. Converted
THICK_LINE_HELPERS_WGSL from a `#define` to a `static const char*` and
switched AXIS_WGSL / SECTION_WGSL / MARQUEE_WGSL to `std::string` so
they can concatenate at static-init time. Three call sites now pass
`.c_str()` to svFromCStr.
- bonsaiviewer/CMakeLists.txt: added BUNDLE DESTINATION to the install
rule (same fix already applied to IfcViewerWgpuMinimal). MACOSX_BUNDLE
targets fail at configure on macOS without it even when nobody runs
`make install`.
- build_osx.yml: dropped the x64 (Intel cross-compile) matrix row. The
runner is arm64 so `brew --prefix qt` returns the arm64 prefix; we'd
need a separate x86_64 Qt install under /usr/local to cross-build
BonsaiViewer. Revisit if Intel-Mac demand resurfaces.
- build_win.yml: dropped the ARM64 matrix row. wgpu-native does not ship
a Windows-ARM64 binary, so IfcViewerWgpu's link step fails with ~60
unresolved wgpu* externs. Re-enable when upstream publishes that
target.
Cherry-pick this commit to v0.8.0 so the workflow_dispatch buttons see
the dropped rows.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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.
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.
When validate_type selected a preferred_item from remaining_items
(e.g. the sole IfcBooleanResult in a representation), it left that
item in the list. The subsequent Items filter removed every item,
leaving Items=[] and causing guess_type to return
"MappedRepresentation" — silently corrupting the representation.
Also removes temporary debug print statements added during
investigation of the wall-to-slab extension workflow.
Generated with the assistance of an AI coding tool.
Re-running the operator on the same wall/slab pair created
additional IfcPolygonalFaceSet booleans each time. Now each
wall's existing booleans are removed before re-clipping, and
previously connected slabs are merged with the new selection
so no earlier clips are silently discarded.
Generated with the assistance of an AI coding tool.
When Shift+G is pressed on a LAYER3 element, any LAYER2 walls
connected via IfcRelConnectsElements(TOP) are now re-clipped
to the slab's updated geometry after recalculate_slab runs.
Generated with the assistance of an AI coding tool.
extend_walls_to_underside now accepts multiple slab/roof
objects in a single operation — all selected non-LAYER2 IFC
elements are treated as clip targets, all LAYER2 elements as
walls. Placement sync is done once upfront; each wall is then
clipped against every selected slab before reloading.
Also adds bim.regenerate_wall_to_underside (Shift+G): after
moving a slab, re-clips connected walls using the existing
IfcRelConnectsElements(TOP) relationship. Old booleans are
removed via remove_representation_item before re-clipping.
Generated with the assistance of an AI coding tool.
When extend_walls_to_underside is applied to a wall and the
roof/slab is later moved, pressing Shift+G now re-clips the
wall to the slab's new position.
The IFC relationship created by connect_wall_to_slab
(IfcRelConnectsElements, Description="TOP") is used to look
up which slabs a wall is clipped to. On regeneration, the
existing manual booleans (IfcPolygonalFaceSet operands) are
cleanly removed via remove_representation_item, then
clip_wall_to_slab is re-applied for each connected slab.
Shift+G on a LAYER2 wall that has a TOP connection now calls
bim.regenerate_wall_to_underside; walls without a connection
continue to call bim.recalculate_wall as before.
Generated with the assistance of an AI coding tool.
When the operator was called twice on the same wall for a
ridge roof, the two IfcPolygonalFaceSet clip solids shared
an exact ridge edge (kissing-solid). OCCT produced spurious
extra vertices at the coincident boundary.
Fix by building the clip solid from a rectangle on the slope
plane that extends slightly past the face edge (1 project
unit margin) rather than the exact face footprint. Adjacent
slope solids now volumetrically overlap at the ridge instead
of sharing a boundary face, which OCCT handles correctly.
Generated with the assistance of an AI coding tool.
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.
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.
ifc.get_object(element) can return None for IFC elements that aren't loaded as Blender objects (e.g., decomposed sub-elements).
The loop now skips those instead of passing None into collector.assign().
Cheers!
The temporary-offset workaround (#7408, commit bd57cc8735) subtracts the
directrix centroid (`mean`) from the curve points before building the
sweep near the origin, then must add it back to restore the original
location. The restore negated the sign — `Move(-mean)` instead of
`Move(+mean)` — placing the swept solid at -mean (mirrored through the
origin) rather than its true position.
Only triggers for polyline directrixes (`is_polyhedron()`) whose centroid
is more than 100 m from the origin (`mean.norm() > 1e2`), so models
centered near the origin are unaffected. Models that keep absolute site
coordinates (e.g. many Revit/ODA IFC exports) render affected swept
solids — reinforcing bars, pipes — at a mirrored phantom location far
from the rest of the model.
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.
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.
The target has MACOSX_BUNDLE ON, which on Darwin requires
install(TARGETS) to specify a BUNDLE DESTINATION — CMake validates
that at configure time, even when nobody runs `make install`. The
macOS CI configure step failed with:
install TARGETS given no BUNDLE DESTINATION for MACOSX_BUNDLE
executable target "IfcViewerWgpuMinimal".
Set BUNDLE DESTINATION bin alongside RUNTIME DESTINATION bin so both
platforms install into the same spot. No behavioural change on Linux
(no MACOSX_BUNDLE) — verified locally.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
LodBuilder.cpp guards its real body behind #ifdef WITH_MESH_OPTIMIZER
(the stub is `return;`). The IfcViewer static lib propagates the
define via target_compile_definitions, but test_lod_builder compiles
LodBuilder.cpp standalone (it doesn't link IfcViewer), so the test
silently exercised the no-op path. summariseLods and buildLods cases
asserted on the post-build state and saw zero LOD1 output.
Pre-existing regression since 884e7ba32 ("Make meshoptim optional");
adds the define to the test target directly so the real build path
runs. 5/5 LOD cases pass after.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two Tier-1 unit binaries under src/ifcviewer-wgpu/tests/ — same Catch2
+ CTest harness as the surviving GL-side tests, gated by
BUILD_BONSAIVIEWER_TESTS.
- test_wgpu_selection: 17 cases / 71 assertions covering replace, add,
remove, toggle, clear, contains, count, selectionIds, fillFlagsArray,
active-id semantics, dirty-bit, and id == 0 sentinel handling.
- test_wgpu_visibility: 8 cases / 27 assertions covering hide, show,
clear, isHidden, hiddenIds, idempotence, and the 0 sentinel.
The wgpu state classes have a deliberately simpler shape than the GL
ones (no Q_OBJECT, no signals — replaced by a dirty bit; no bulk
set/add/remove methods — bulk behaviour lives in the viewport verbs).
One *intentional* behavioural difference is documented in the test:
add(id) steals active in the wgpu API, where GL's addToSelection kept
the prior active. Each pick should drive the properties panel to the
most recently touched object.
Bulk hide/isolate/show-all semantics live in WgpuViewportWindow, which
composes WgpuVisibilityState + the model instance lists; those are
integration-level, not Tier-1, so they're not covered here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bonsai now drives the wgpu viewport for both sidecar and direct-IFC
loads. The GL viewer and its supporting state classes are gone.
SceneLoader rewire:
- Takes WgpuViewportWindow* instead of ViewportWindow*.
- Sidecar path reads metadata only (readSidecarMetadataOnly) and hands
the StreamingSidecar off to the new applyCachedModel. Field accesses
inside applySidecarData go through .meta.
- Direct-IFC path uses the wgpu A-path (upload{Mesh,Instance}Chunk +
finalizeModel). The applyLodExtension call is dropped — wgpu has no
live LOD1 splice; LOD1 still lands in the on-disk sidecar for the
next open.
Bonsai migration:
- ViewportWindow → WgpuViewportWindow across MainWindow, Measurement,
SessionState, and every modules/*/{Commands,Panel,View}.{h,cpp} —
116 sites total. Same s/OverlayRenderer::/WgpuOverlayRenderer::/
rename, 12 sites.
- Includes flipped from ../ifcviewer/ViewportWindow.h to
../ifcviewer-wgpu/WgpuViewportWindow.h. OverlayRenderer.h include
dropped (transitively reached via the viewport header).
- BonsaiViewer links IfcViewerWgpu in addition to IfcViewer for the
duration of the migration; the GL-side IfcViewer also publicly links
IfcViewerWgpu so SceneLoader can resolve WgpuViewportWindow.
GL backend deletion:
- src/ifcviewer/ViewportWindow.{cpp,h}, BvhAccel.*, OverlayRenderer.*,
Selection.*, Visibility.* all gone.
- src/ifcviewer-minimal/ removed entirely (MinimalWindow drove the GL
viewport).
- src/ifcviewer/tests: test_bvh_accel, test_selection, test_visibility
removed. The first has no replacement (wgpu doesn't use a per-instance
BVH); the latter two are ported separately. test_lod_builder,
test_sidecar_cache, test_instanced_geometry, test_federation remain
(backend-agnostic).
- IfcViewer's CMakeLists drops OpenGL, Qt::OpenGL, Qt::Widgets — none
of the surviving translation units reach for them.
Build flag plumbing:
- BUILD_BONSAIVIEWER now auto-enables BUILD_BONSAIVIEWER_WGPU since
SceneLoader requires the wgpu lib for its WgpuViewportWindow* arg.
- The wgpu subprojects add_subdirectory ahead of the GL one so
IfcViewerWgpu exists when IfcViewer's link evaluates.
- src/ifcviewer-minimal subdir reference removed from cmake/CMakeLists.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Make the wgpu viewport ready for bonsai's verb actions, federation
refresh, and tool routing — i.e. callable from an outside host, not
just from the minimal viewer's own hotkeys.
Surface additions on WgpuViewportWindow:
- Qt signals: objectPicked, frameStatsUpdated, surfacePickedInTool,
toolModeChanged, toolBackspacePressed.
- FrameStats struct + rolling 60-sample frame-time window for the
fps field; emit at end of render() so external listeners see fresh
numbers in the same tick.
- InstanceLookup struct + findInstance(object_id, ...) const for the
measurement tools' O(1) object → (model, mesh, placement) resolve.
- Federation hooks (setFederatedFalseOrigin / setModelCoordinateOperation
/ setModelTransformation) + per-model coordinate_operation_meters /
model_transformation_meters fields on WgpuModelGpuData. Implement
composeInstanceFromPlacement + recomposeAndUploadModel so each setter
actually applies — model recompose runs in double, casts to float for
the GPU upload, and refreshes per-chunk world AABBs. meshLocalToGlobal
now composes coordinate_operation · placement properly.
- showModel / hideModel for per-model visibility, plus element-level
verbs (hideSelectedElements / isolateSelectedElements / showAllElements
/ invertElementVisibility) and setSelectedObjectId / cameraState() /
projectionOrtho() / toggle{Area,Length,Volume}Tool wrappers.
- Section-cutting methods (toggleSectionTool / clearSectionPlanes /
sectionToolActive) moved to public so bonsai's Commands.cpp can call.
- QVector3D overload of computeObjectAabb to match the GL signature.
- ToolMode::None → ToolMode::NoTool (X11 macro collision avoidance).
Direct-IFC ingestion (A-path), mirrors the GL streaming push API:
- uploadMeshChunk / uploadInstanceChunk stage into pending_direct_loads_
using the same vertex quantisation as SidecarBuilder so direct-load
and sidecar-load produce byte-identical buffers.
- finalizeModel wraps the staged data in a file-less StreamingSidecar,
routes through the existing applyCachedModel chunk planner, then
gathers per-chunk vertex+index bytes from memory and feeds
applyStreamedChunk synchronously. Every chunk lands is_resident=true
immediately (no disk I/O to defer).
Streaming collapse:
- Delete the applyCachedModel(SidecarData) full-load path entirely.
- Rename applyCachedModelStreaming → applyCachedModel; loadSidecar
always uses the metadata-only reader. Drop the --streaming CLI flag
from IfcViewerWgpuMinimal and the streaming_enabled_ field.
WgpuSelectionState::ids() → selectionIds() so bonsai's
`viewport_->selection().selectionIds()` compiles unchanged.
Eigen3 added as a public dep of IfcViewerWgpu for the federation
matrices.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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.
Fixes two coupled streaming pathologies on working-set > pool scenes:
1) The candidate loop used `break` when a candidate couldn't fit even
after eviction. Comment justified it with "sorted by priority, lower
candidates can't beat it either" — true for *priority* eviction, but
the failure is *size-based fitting*. A 31 MB candidate that doesn't
fit in 24 MB largest-free was starving the entire per-frame budget,
including smaller candidates that would have fit happily. Replaced
with `continue`.
2) Same blocked candidate re-entered the candidate list every frame
forever, spamming `[blocked]` and (worse, on web) paying for the same
byte-range fetch over and over when apply-time OOM happened. Added
`blocked_cooldown_until_frame_idx` on the chunk: when OOM strikes at
enqueue *or* apply, the chunk is skipped from candidate gathering for
~3s. Web-friendly cap of one wasted fetch per 3s per chronic chunk
instead of per-frame. Cooldown expires naturally; if the pool layout
changes within the window (other chunks evicted, fragmentation
coalesces) the chunk re-enters automatically.
Also added eviction-attribution + chunk thrash detection (gated behind
WGPU_STREAM_EVICT_LOG=1) to confirm A→B→A 2-cycles vs simple
sacrificial-victim cycles. Quietened the steady-state stream debug
dump — moved the verbose multi-line "missing/resident/bottom" snapshot
behind WGPU_STREAM_DEEP_DEBUG=1 with a wider 300-frame interval, and
added a single-line `[stream]` health summary every ~5s in interactive
mode. Removed the hardcoded one-off "brace.ifc bracing all
chunks" dump that was investigation scaffolding for a now-closed bug.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Ports Bonsai's LengthMeasurement onto WgpuViewportWindow as a new
WgpuLengthMeasurement class. Each LMB appends a world-space pick point
and the readout adapts to the running count:
1 pt → laser-measure: coplanar-patch BFS on the click's surface
projects every patch vertex into the surface's own tangent
basis to get face extents (X/Y/Z bars dashed in world space),
plus ENH coords for the picked point, plus a vertical
raycast for floor/ceiling distance on horizontal surfaces.
2 pts → distance A→B + axis-coloured ΔX/ΔY/ΔZ stair-step + dashed
perpendicular projection when both picks landed on
near-parallel surfaces.
3 pts → angle at middle vertex + triangle area + perimeter.
4+ pts → polygon area via best-fit-plane shoelace (Jacobi-3x3
eigendecomp inline; no Eigen dep) or fan-triangulated
fallback for non-planar loops, plus closed-loop perimeter.
Backspace / Del removes the last point; Esc / L again exits.
Dependencies layered in:
- pickMeshLocalAt now refines the AABB-coarse pickSurfaceAt hit into a
real triangle hit via Möller-Trumbore against the picked instance's
CPU mesh shadow. Without this the BFS seeds with whatever triangle
is closest to the bounding-box corner — producing patches and
extents shaped like the AABB instead of the surface.
- meshLocalToGlobal: applies the instance's placement_transformation
only (no per-model CoordinateOperation in wgpu yet). ENH equals
IFC-world for non-federated loads, which is what the minimal viewer
handles.
- raycast: brute-force world-AABB cull + Möller-Trumbore over the CPU
mesh shadow. Used by the laser-measure ceiling/floor distance.
- ToolMode gains Length; click handler routes plain/Alt LMB through
onLengthPick, Backspace through onLengthBackspace. Marquee-arm is
gated off in Length mode.
Volume HUD now shows "Volume: 0.0000 m³ (0 objects)" the moment V is
pressed, matching how A primes "Area: 0.0000 m²" — gives the user a
visible cue the tool is active before any selection.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
deepdiff 9.1.0 added cachebox<6,>=5.2 as a direct runtime dep.
cachebox 5.2.3 only publishes macOS x86_64 wheels for macosx_10_12+,
incompatible with the macos py311 build's --platform macosx_10_10_x86_64.
The daily build's linux-wheel safeguard fires when the resulting
cachebox-*-manylinux_*.whl leaks into the macOS / windows wheels folder
(builds run on ubuntu-latest and cross-build via pip download --platform).
Pin deepdiff to <9.1 (resolves to 9.0.0, no cachebox transitive dep) as
the minimal hotfix. Long-term cleanup: bump the macos py311 platform tag
from 10_10 to 10_13 (matching py312/py313) and re-flag this line with the
standard \$(PYPI_PLATFORM) --only-binary=:all: pattern used by brickschema
and python-socketio.
Partly generated with the assistance of an AI coding tool.
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.
When override_mode_set_edit encounters an unsupported profile (Couldn't
import profile), deselect the object so Tab continues to cycle cleanly.
Also restores the selection that existed before entering aggregate mode
when finally tabbing out, via save/restore_previous_selection().
Following the pattern from 586f9be077, deselect the active object after
exiting item mode so Tab continues to cycle cleanly. Also deselects
parametric LAYER1/LAYER2 items that cannot be edited directly, avoiding
the need to manually deselect before Tab-cycling out of aggregate mode.
Ports Bonsai's AreaMeasurement onto WgpuViewportWindow as a new
WgpuAreaMeasurement class. Each LMB pick resolves to (instance,
triangle), BFS-expands the coplanar patch (dot(normal, seed_normal)
> 0.9999, ~0.81° tolerance), and toggles it in/out of the running set.
Alt+LMB skips BFS for single-triangle accumulate. Connected-components
sweep over the selected set produces one "X.XXXX m²" label per patch
at its area-weighted centroid in world space; HUD shows the running
total + triangle count.
Dependencies layered in:
- WgpuOverlayRenderer.setHighlightTriangles / encodeHighlightTriangles:
translucent world-space triangle list (cyan @ 0.45 alpha), depth-
tested but depth-write off so the corner gizmo + labels still sit
on top.
- WgpuViewportWindow.pickMeshLocalAt: reuses pickSurfaceAt for the
world hit, then inverts the instance's composed transform to express
it in mesh-local space — what the BFS needs. Uses the live map key
(`mid`) rather than InstanceCpu.model_id, which is whatever the GL
streamer wrote at sidecar-write time and goes stale across sessions.
- WgpuViewportWindow.readbackMeshTriangles: CPU mesh shadow lookup.
The shadow itself is populated during the same dequant pass that
computes mesh-local volume — applyCachedModel for full loads and
applyStreamedChunk for streaming, so the BFS has data the moment
the user can pick it.
WgpuModelGpuData gains a MeshTriangles vector indexed by mesh_id;
doubles per-vertex CPU memory (12 B/vert) but skips wgpu mapAsync
plumbing for now. Bounds-check at pick time gracefully no-ops when a
stale sidecar field is out of range.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Ports Bonsai's volumeOfObjects + volumesPerObject onto WgpuViewportWindow.
Mesh-local volumes are precomputed at applyCachedModel via signed-
tetrahedra-from-origin (dequantising positions from the 12 B/vertex GPU
layout); per-instance volume is just the cached local × |det(placement)|.
No GPU readback — measurement is O(K) in the selection size.
Streaming path computes volumes per-chunk as they arrive — fills any
mesh whose chunk just delivered, then re-runs updateVolumeReadout if
the user is staring at a Volume readout while the geometry pages in.
UX matches GL: V toggles, Esc exits, selection-driven (LMB pick / marquee
/ Shift/Ctrl set ops all funnel into updateVolumeReadout). HUD shows
total + count; one overlay label per object at its AABB centre, capped
at 200 to keep the label-texture cache bounded on large marquees.
Side fixes layered on the label overlay:
- O(1) AABB lookup via object_id_to_instance instead of linear-scanning
every model's instance list per selected object.
- Label texture cache evicts entries not touched this frame, so churning
through "X.XXXX m³" strings doesn't pin GPU memory.
- DrawRec stores the WGPUBindGroup handle by value rather than a
LabelTexture* pointer into the QHash — getOrCreateLabelTexture can
rehash the table and invalidate every captured pointer, which crashed
large marquee selections with BindGroup-no-longer-alive.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two cohesive cleanups in one commit.
A. Migrate wall.py to PR3-absorbed tool methods (fixes bug 4: pen icon
missing on fillet corner walls):
PR3 shipped tool.Wall.read_geometry + tool.Wall.validate_for_parametric_edit
but wall.py kept local duplicates predating that work. The local
_read_wall_geometry guards on tool.Blender.Modifier.is_wall (LAYER2-only)
while the tool method guards on tool.Parametric.is_path_connectable_wall
(LAYER2 OR fillet corner). Consequence: _get_wall_geom_cached → local
_read_wall_geometry returned None for every fillet corner →
GizmoWallFilletReedit.position_gizmos hit `if geom is None: hide` →
pen icon was unreachable for every fillet corner the user created.
Three _read_wall_geometry callers migrated to tool.Wall.read_geometry
(_read_wall_state_into_props, _get_wall_geom_cached,
GizmoWallJoinIntersection.position_gizmos). Two
_validate_wall_for_parametric_edit callers migrated to
tool.Wall.validate_for_parametric_edit (_maybe_resync_wall_props_from_ifc,
EnableEditingWall._execute). Local helpers deleted; docstring references
updated.
B. Drop over-restrictive gizmo gates (fixes bug 1: join icons missing
when walls intersect away from endpoints):
GizmoWallJoinIntersection.position_gizmos no longer hides itself when
the projected intersection lands further than MAX_DISTANCE_TO_ENDPOINT_
FACTOR (0.75 wall lengths) from any endpoint. The remaining
PARALLEL_DOT_THRESHOLD (cos 2°) gate via project_axis_intersection
returns None for near-parallel walls and is the only correctness bound;
distance from endpoints is a UI concern, not a geometric one.
GizmoWallFilletReedit.poll drops the has_a / has_b ConnectedFrom +
ConnectedTo guard — the IsFilletCorner pset is the authoritative signal.
EnableWallFilletPreviewFromCorner.execute already separately validates
both neighbour connections and reports a user-facing error if either
side is disconnected.
Generated with the assistance of an AI coding tool.
Three live-session regressions surfaced after the fillet feature
landed.
Sister gizmos competed with the active preview:
* preview_base.any_preview_active(context): new helper iterates the
PREVIEW_CANCEL_OPS registry and returns True if any preview is open.
Future previews registered there automatically gate sister gizmos.
* BaseParametricGizmoGroup.poll (gizmos.py): short-circuits on
any_preview_active so every parametric gizmo (door/window/stair/
roof/railing/wall edition) hides during ANY preview.
* The 4 wall gizmo groups with explicit polls (GizmoWallAddOpening,
GizmoWallExtendVertically, GizmoWallJoinIntersection,
GizmoWallUnjoinSingle) + GizmoWallFilletReedit gain the same gate.
DRY: extract _wall_gizmo_poll_gate(context):
* 5 wall gizmo polls each duplicated the 2 pre-flight checks
(viewport-gizmos enabled + no preview active). The helper centralises
them — each poll becomes a single short-circuit line followed by its
per-feature selection inspection.
ESC cancels the active preview:
* try_cancel_active_preview already existed in preview_base since PR3
but had no caller. Hooked into OverrideEscape.execute (geometry/
operator.py) as a new elif branch — same keymap that already cancels
pen gizmo edit mode + item mode + edit mode + aggregate mode. Order
in the branch chain matters: try preview cancel before falling back
to try_canceling_editing_modifier_parameters_or_path so the in-
flight preview wins over a stale modifier-edit cancel attempt.
Generated with the assistance of an AI coding tool.