diff --git a/src/bonsai/bonsai/bim/module/model/preview_base.py b/src/bonsai/bonsai/bim/module/model/preview_base.py
new file mode 100644
index 0000000000..f9921dd1aa
--- /dev/null
+++ b/src/bonsai/bonsai/bim/module/model/preview_base.py
@@ -0,0 +1,211 @@
+# Bonsai - OpenBIM Blender Add-on
+# Copyright (C) 2026
+#
+# This file is part of Bonsai.
+#
+# Bonsai is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Bonsai is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Bonsai. If not, see .
+#
+# This file was generated with the assistance of an AI coding tool.
+
+"""Shared helpers for Bonsai's parametric preview flows.
+
+Multiple Bonsai features follow the same Scene-level preview pattern:
+
+ EnablePreview — validates a selection, populates draft state on
+ ``Scene.BIMPreviewProperties.``, flips ``is_active``.
+ GizmoPreview — polls on ``is_active``, surfaces tunable widgets +
+ validate/cancel icons.
+ PreviewDecorator — GPU lines drawn while ``is_active`` is True.
+ FinishPreview — direct ``bpy.ops.bim.(...)`` call with kwargs
+ read off the draft state, then clears it.
+ CancelPreview — pure state reset.
+
+The MEP bend and wall fillet flows are the two current callers. They write
+their Finish / Cancel operators directly, matching the convention used
+throughout the rest of ``bim/module/model/`` for operator-to-operator
+dispatch (explicit ``bpy.ops.bim.X(kwarg=value)`` at the call site, no
+string indirection). This module hosts the cross-cutting accessors only;
+no base class layer.
+
+The GPU draw-handler lifecycle for ``PreviewDecorator`` lives on the
+feature-neutral ``tool.Blender.ViewportDecorator`` base, which every
+viewport decorator (preview or otherwise) inherits from."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from typing import Any
+
+import bpy
+
+import bonsai.tool as tool
+
+# --- Props accessors ---------------------------------------------------------
+
+
+def get_preview_props(context: bpy.types.Context, attr: str):
+ """Resolve a child preview PropertyGroup under ``Scene.BIMPreviewProperties``.
+
+ Returns ``None`` if the umbrella isn't attached yet — true briefly
+ during addon register and during plug-out, so polls / draw callbacks
+ must defend against ``None`` rather than assuming the prop is always
+ available."""
+ preview = getattr(context.scene, "BIMPreviewProperties", None)
+ return getattr(preview, attr, None) if preview is not None else None
+
+
+def is_preview_active(context: bpy.types.Context, attr: str) -> bool:
+ """``True`` while a specific preview is open. Used by sibling gizmo
+ polls to hide themselves so the preview is the only interactive
+ surface in the viewport (the bend / fillet preview groups take over
+ the same selection's icon stack)."""
+ props = get_preview_props(context, attr)
+ return bool(props is not None and props.is_active)
+
+
+# --- Lazy closure factories --------------------------------------------------
+#
+# Used by preview gizmo groups when wiring ``BIM_GT_gizmo_dimension``'s
+# ``move_get_cb`` / ``move_set_cb`` callbacks. The closures re-resolve
+# ``bpy.context.scene`` per CALL rather than capturing it at setup() time
+# — the captured Scene's RNA struct can be freed on file open / undo, and
+# referencing a freed struct crashes Blender. Lazy lookup survives the
+# whole undo / reload lifecycle.
+
+
+def make_props_callback(attr: str) -> Callable[[], Any]:
+ """Return a zero-arg callable that lazily fetches the preview props.
+
+ Equivalent to ``getattr(bpy.context.scene.BIMPreviewProperties, attr)``
+ with full defensiveness against missing scene / missing umbrella."""
+
+ def _props():
+ scene = bpy.context.scene
+ preview = getattr(scene, "BIMPreviewProperties", None) if scene else None
+ return getattr(preview, attr, None) if preview is not None else None
+
+ return _props
+
+
+def make_dim_getter(props_callback: Callable[[], Any], field: str) -> Callable[[], float]:
+ """Factory for ``BIM_GT_gizmo_dimension.move_get_cb`` reading a single
+ FloatProperty off the live preview state. Returns ``0.0`` defensively
+ when the props are temporarily unavailable so the widget doesn't crash
+ Blender during plug-out / reload."""
+
+ def _get() -> float:
+ props = props_callback()
+ return getattr(props, field) if props is not None else 0.0
+
+ return _get
+
+
+def make_dim_setter(
+ props_callback: Callable[[], Any],
+ field: str,
+ min_value: float = 0.001,
+) -> Callable[[float], None]:
+ """Factory for ``BIM_GT_gizmo_dimension.move_set_cb`` writing a single
+ FloatProperty + tagging viewport areas for redraw so the GPU preview
+ decorator tracks the value live during drag. Clamps at ``min_value``
+ to match the FloatProperty's declared lower bound."""
+
+ def _set(value: float) -> None:
+ props = props_callback()
+ if props is None:
+ return
+ setattr(props, field, max(min_value, float(value)))
+ for area in bpy.context.screen.areas if bpy.context.screen else ():
+ if area.type == "VIEW_3D":
+ area.tag_redraw()
+
+ return _set
+
+
+# --- Shared Enable lifecycle helpers -----------------------------------------
+
+
+def sync_uncommitted_moves(objects: list) -> None:
+ """Push any Blender-side translation / rotation of ``objects`` back to
+ their IFC ``ObjectPlacement`` before a preview decorator starts reading
+ ``obj.matrix_world`` per frame.
+
+ Without this sync, a user who grabbed-moved an object but didn't commit
+ the move sees the live preview at the dragged position while the final
+ commit lands at the stale IFC position — a confusing "where did my
+ preview go?" experience. Both bend and fillet enable paths call this
+ on the relevant pair just before activating the preview."""
+ for obj in objects:
+ tool.Geometry.commit_placement_if_moved(obj, apply_scale=False)
+
+
+# --- Esc dispatch ------------------------------------------------------------
+
+PREVIEW_CANCEL_OPS: tuple[tuple[str, str], ...] = (
+ ("bend", "cancel_bend_preview"),
+ ("wall_fillet", "cancel_wall_fillet_preview"),
+)
+"""Registry of ``(child PointerProperty on Scene.BIMPreviewProperties, bim
+operator name)`` consulted by the Esc handler. Adding a new preview means
+appending one tuple; the forward-compat test pins that every preview
+PropertyGroup with ``is_active`` has an entry here."""
+
+
+def try_cancel_active_preview(context: bpy.types.Context) -> bool:
+ """Cancel every registered preview that is currently active.
+
+ Returns ``True`` iff at least one preview was cancelled. Multiple
+ previews can be simultaneously active (e.g. a stale bend preview opened
+ just before the user starts a wall fillet) — one Esc must clear them
+ all rather than forcing the user to tap Esc once per preview.
+
+ Tags 3D viewports for redraw on success — the Esc keymap entry runs
+ outside a viewport mouse event so the gizmo poll wouldn't re-evaluate
+ until the next interaction without an explicit redraw."""
+ cancelled = False
+ for attr, op_name in PREVIEW_CANCEL_OPS:
+ if is_preview_active(context, attr):
+ getattr(bpy.ops.bim, op_name)()
+ cancelled = True
+ if cancelled:
+ screen = context.screen
+ for area in screen.areas if screen else ():
+ if area.type == "VIEW_3D":
+ area.tag_redraw()
+ return cancelled
+
+
+def discard_pending_previews(scene: bpy.types.Scene) -> None:
+ """Clear every active preview under ``Scene.BIMPreviewProperties`` so
+ saved preview state never resurfaces on file load.
+
+ Mirrors ``tool.Parametric.heal_stale_edit_flags`` for the object-level
+ parametric-edit lifecycle — except previews are *discarded* rather than
+ validated. A preview's only UI cue is its in-viewport widget; reloading
+ a ``.blend`` saved mid-preview restores the flag but not the surrounding
+ user attention, and a stuck ``is_active`` silently hides every sibling
+ gizmo poll gated on it.
+
+ Iterates ``PREVIEW_CANCEL_OPS`` so any preview registered for Esc
+ cancellation is automatically covered here too. Sets ``is_active``
+ directly rather than dispatching the cancel operator: load_post may
+ fire before ``bpy.context.screen`` is reattached, and the cancel
+ operators bail on ``context.screen is None``."""
+ preview = getattr(scene, "BIMPreviewProperties", None)
+ if preview is None:
+ return
+ for attr, _op_name in PREVIEW_CANCEL_OPS:
+ child = getattr(preview, attr, None)
+ if child is not None and getattr(child, "is_active", False):
+ child.is_active = False