cancel
Showing results for 
Show  only  | Search instead for 
Did you mean: 

Tool to create buttons based on Variants in Autodesk VRED for VRED GO

Tool to create buttons based on Variants in Autodesk VRED for VRED GO

Idea: Tool to create buttons in VRED Go interface to interact with variants sets.

Compelling UI/UX is important to clients.

2 Comments
seiferp
Community Manager
Status changed to: Archived

You can use Python to automatically create an HTML overlay menu with a button for every Variant Set. The menu stays in sync with the Variant Sets. With Cursor or Claude, this is straightforward to implement.

seiferp
Community Manager

Here is an example. Copy the Python Code to your Script Editor and the HTML Code to a new Sceneplate WebEngine:

from __future__ import print_function

import json
import re

from PySide6.QtCore import QByteArray, QBuffer, QIODevice, Qt

# Optional WebEngine name; leave empty to auto-detect the sceneplate WebEngine.
SCENEPLATE_WEB_ENGINE_NAME = ""

THUMBNAIL_BATCH_SIZE = 20
THUMBNAIL_EXPORT_SIZE = 72
THUMBNAIL_SIZE = 96
ICON_PREVIEW_BG_ALPHA = 64

MIN_VRED_VERSION_YEAR = 2027
REQUIRED_API_VERSION = "2027.1"
REQUIRED_VARIANT_API = "v2"

AUTO_REFRESH_ON_VARIANTS_CHANGED = True

EVENT_THUMBNAIL_BATCH = "variantMenuThumbnailBatch"
EVENT_HIERARCHY_LOADED = "variantMenuHierarchyLoaded"
EVENT_HIERARCHY_ERROR = "variantMenuHierarchyError"
EVENT_VARIANT_EXECUTED = "variantMenuVariantExecuted"
EVENT_SELECTION_CHANGED = "variantMenuSelectionChanged"


def _log(message, level="INFO"):
    prefix = "[VariantMenu]"
    if level != "INFO":
        prefix = "%s[%s]" % (prefix, level)
    print("%s %s" % (prefix, message))


def _warn(message):
    _log(message, "WARN")


def _error(message):
    _log(message, "ERROR")


def _json_response(ok, **fields):
    payload = {"ok": ok}
    payload.update(fields)
    return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))


def _parse_version_year(version_text):
    match = re.search(r"(20\d{2})", str(version_text))
    return int(match.group(1)) if match else None


def _get_vred_version_info():
    info = {
        "apiVersion": REQUIRED_API_VERSION,
        "variantApi": REQUIRED_VARIANT_API,
        "vredVersion": None,
        "vredVersionYear": None,
        "vredDisplayVersionYear": None,
    }

    try:
        info["vredVersion"] = str(getVredVersion())
    except Exception:
        pass

    try:
        info["vredVersionYear"] = int(getVredVersionYear())
    except Exception:
        info["vredVersionYear"] = _parse_version_year(info["vredVersion"])

    try:
        info["vredDisplayVersionYear"] = int(getVredDisplayVersionYear())
    except Exception:
        pass

    return info


def _is_supported_vred_version(version_info):
    year = version_info.get("vredVersionYear")
    if year is not None and year >= MIN_VRED_VERSION_YEAR:
        return True

    display_year = version_info.get("vredDisplayVersionYear")
    return display_year is not None and display_year >= MIN_VRED_VERSION_YEAR


def verify_api():
    required_globals = (
        "vrVariantService",
        "vrVariantTypes",
        "vrdVariantSetNode",
        "vrdVariantSetGroupNode",
        "vrdVariantSetReferenceNode",
        "vrWebEngineService",
        "getVredVersionYear",
    )
    missing = [name for name in required_globals if name not in globals()]
    if missing:
        raise RuntimeError(
            "VRED 2027.1+ Python API v2 is required. Missing symbols: %s"
            % ", ".join(missing)
        )

    for method_name in (
        "getVariantSetRoot",
        "getAllVariantSets",
        "getSelectedVariantSets",
        "execute",
        "getDefaultThumbnailSize",
    ):
        if not callable(getattr(vrVariantService, method_name, None)):
            raise RuntimeError(
                "vrVariantService.%s() is unavailable in this VRED build."
                % method_name
            )

    version_info = _get_vred_version_info()
    if not _is_supported_vred_version(version_info):
        raise RuntimeError(
            "This variant menu requires VRED %s or newer (detected year: %s)."
            % (
                REQUIRED_API_VERSION,
                version_info.get("vredVersionYear")
                or version_info.get("vredDisplayVersionYear")
                or "unknown",
            )
        )


def _filter_valid_nodes(nodes):
    if nodes is None:
        return []
    return [node for node in nodes if node is not None and not node.isNull()]


def _as_variant_group(node):
    if node.isNull() or not node.isType(vrdVariantSetGroupNode):
        return None
    return vrdVariantSetGroupNode(node)


def _as_variant_set(node):
    if node.isNull() or not node.isType(vrdVariantSetNode):
        return None
    return vrdVariantSetNode(node)


def _as_variant_reference(node):
    if node.isNull() or not node.isType(vrdVariantSetReferenceNode):
        return None
    return vrdVariantSetReferenceNode(node)


def _is_show_in_vr_menu(node):
    if node is None or node.isNull():
        return False

    group = _as_variant_group(node)
    if group is not None:
        try:
            return bool(group.getShowInVRMenu())
        except Exception:
            return False

    vset = _as_variant_set(node)
    if vset is not None:
        try:
            if callable(getattr(vset, "isShowInVRMenuEnabled", None)):
                return bool(vset.isShowInVRMenuEnabled())
            return bool(vset.getShowInVRMenu())
        except Exception:
            return False

    reference = _as_variant_reference(node)
    if reference is not None:
        vset = reference.getVariantSet()
        if vset.isNull() or not vset.isValid():
            return False
        return _is_show_in_vr_menu(vset)

    return False


def _variant_identifier(vset):
    path = vset.getPath()
    if path:
        return str(path)
    return str(vset.getObjectId())


def _find_variant_set_by_id(variant_id):
    target = str(variant_id)
    for vset in _filter_valid_nodes(vrVariantService.getAllVariantSets()):
        if _variant_identifier(vset) == target:
            return vset
    return None


def _get_primary_selected_variant_id():
    for node in _filter_valid_nodes(vrVariantService.getSelectedVariantSets()):
        vset = _as_variant_set(node)
        if vset is not None:
            return _variant_identifier(vset)
    return None


def _variant_payload(vset):
    return {"id": _variant_identifier(vset), "name": vset.getName()}


def _resolve_thumbnail_size():
    try:
        size = vrVariantService.getDefaultThumbnailSize()
        edge = max(int(size.width()), int(size.height()))
        if edge > 0:
            return edge
    except Exception:
        pass
    return THUMBNAIL_SIZE


def _qimage_to_data_url(image, edge_size, use_png=False):
    from PySide6.QtGui import QImage

    if image is None or image.isNull():
        return None

    scaled = image.scaled(
        edge_size,
        edge_size,
        Qt.AspectRatioMode.KeepAspectRatioByExpanding,
        Qt.TransformationMode.SmoothTransformation,
    )

    byte_array = QByteArray()
    buffer = QBuffer(byte_array)
    buffer.open(QIODevice.OpenModeFlag.WriteOnly)

    if use_png:
        if not scaled.save(buffer, "PNG"):
            return None
        mime = "image/png"
    else:
        mime = "image/jpeg"
        if not scaled.save(buffer, "JPEG", 82):
            buffer.close()
            byte_array = QByteArray()
            buffer = QBuffer(byte_array)
            buffer.open(QIODevice.OpenModeFlag.WriteOnly)
            mime = "image/png"
            if not scaled.save(buffer, "PNG"):
                return None

    encoded = bytes(byte_array.toBase64()).decode("ascii")
    return "data:%s;base64,%s" % (mime, encoded)


def _preview_from_icon(icon, edge_size):
    if icon is None or icon.isNull():
        return None

    try:
        pixmap = icon.pixmap(edge_size, edge_size)
    except Exception:
        return None

    if pixmap.isNull():
        return None

    return pixmap.toImage()


def _is_icon_preview(vset):
    try:
        return vset.getPreviewType() == vrVariantTypes.PreviewType.Icon
    except Exception:
        return False


def _preview_type_name(vset):
    try:
        preview_type = vset.getPreviewType()
        if preview_type == vrVariantTypes.PreviewType.Icon:
            return "icon"
        if preview_type == vrVariantTypes.PreviewType.ThumbnailWithIcon:
            return "thumbnailWithIcon"
    except Exception:
        pass
    return "thumbnail"


def _clear_near_black_pixels(image, threshold=24):
    from PySide6.QtGui import QImage

    if image is None or image.isNull():
        return image

    result = image.convertToFormat(QImage.Format.Format_ARGB32)
    width = result.width()
    height = result.height()

    for y in range(height):
        for x in range(width):
            color = result.pixelColor(x, y)
            if (
                color.red() <= threshold
                and color.green() <= threshold
                and color.blue() <= threshold
            ):
                color.setAlpha(0)
                result.setPixelColor(x, y, color)

    return result


def _composite_on_icon_background(image, edge_size):
    from PySide6.QtGui import QColor, QImage, QPainter

    canvas = QImage(edge_size, edge_size, QImage.Format.Format_ARGB32_Premultiplied)
    canvas.fill(QColor(0, 0, 0, ICON_PREVIEW_BG_ALPHA))

    cleaned = _clear_near_black_pixels(image)
    scaled = cleaned.scaled(
        edge_size,
        edge_size,
        Qt.AspectRatioMode.KeepAspectRatio,
        Qt.TransformationMode.SmoothTransformation,
    )

    x = (edge_size - scaled.width()) // 2
    y = (edge_size - scaled.height()) // 2

    painter = QPainter(canvas)
    painter.drawImage(x, y, scaled)
    painter.end()
    return canvas


def _first_valid_preview(getters):
    for getter in getters:
        try:
            preview = getter()
        except Exception:
            preview = None
        if preview is not None and not preview.isNull():
            return preview
    return None


def _normalize_preview_image(image):
    from PySide6.QtGui import QImage

    if image is None or image.isNull():
        return None

    if image.format() != QImage.Format.Format_ARGB32:
        converted = image.convertToFormat(QImage.Format.Format_ARGB32)
        if not converted.isNull():
            return converted

    return image


def _preview_getters(vset, edge_size, preview_type_name):
    getters = []

    if preview_type_name == "icon":
        getters.extend([
            lambda: _preview_from_icon(vset.getCustomPreviewIcon(), edge_size),
            lambda: vset.getPreviewImage(),
            lambda: vset.getComposedPreview(True),
            lambda: vset.getComposedPreview(False),
        ])
    elif preview_type_name == "thumbnailWithIcon":
        getters.extend([
            lambda: vset.getComposedPreview(True),
            lambda: vset.getComposedPreview(False),
            lambda: vset.getPreviewImage(),
            lambda: _preview_from_icon(vset.getCustomPreviewIcon(), edge_size),
        ])
    else:
        getters.extend([
            lambda: vset.getComposedPreview(True),
            lambda: vset.getComposedPreview(False),
            lambda: vset.getPreviewImage(),
            lambda: _preview_from_icon(vset.getCustomPreviewIcon(), edge_size),
        ])

    return tuple(getters)


def _collect_thumbnail(vset):
    edge_size = min(_resolve_thumbnail_size(), THUMBNAIL_EXPORT_SIZE)
    preview_type_name = _preview_type_name(vset)
    is_icon = preview_type_name == "icon"
    preview = _first_valid_preview(_preview_getters(vset, edge_size, preview_type_name))

    if preview is None:
        return None

    preview = _normalize_preview_image(preview)

    if is_icon:
        preview = _composite_on_icon_background(preview, edge_size)
        return _qimage_to_data_url(preview, edge_size, use_png=True)

    return _qimage_to_data_url(preview, edge_size)


def _serialize_variant_set(vset, display_name=None):
    return {
        "name": display_name or vset.getName(),
        "id": _variant_identifier(vset),
        "previewType": _preview_type_name(vset),
        "thumbnail": None,
        "children": [],
    }


def _track_variant_set(vset, variant_sets):
    if variant_sets is not None:
        variant_sets.append(vset)


def _is_favorite_group(group):
    try:
        if group.isFavorite():
            return True
    except Exception:
        pass

    try:
        return group.getGroupType() == vrVariantTypes.GroupType.Favorites
    except Exception:
        return False


def _is_reference_group(group):
    try:
        return group.getGroupType() == vrVariantTypes.GroupType.SmartReference
    except Exception:
        pass

    try:
        if callable(getattr(group, "isSmartReference", None)):
            return bool(group.isSmartReference())
    except Exception:
        pass

    return False


def _sort_favorites_first(nodes):
    if not nodes:
        return nodes

    favorites = []
    others = []
    for node in nodes:
        if isinstance(node, dict) and node.get("isFavorite"):
            favorites.append(node)
        else:
            others.append(node)
    return favorites + others


def _serialize_tree_node(node, variant_sets=None):
    if not _is_show_in_vr_menu(node):
        return None

    group = _as_variant_group(node)
    if group is not None:
        children = []
        for child in _filter_valid_nodes(group.getChildren()):
            serialized = _serialize_tree_node(child, variant_sets)
            if serialized is not None:
                children.append(serialized)

        if not children:
            return None

        return {
            "name": group.getName(),
            "isFavorite": _is_favorite_group(group),
            "isReferenceGroup": _is_reference_group(group),
            "children": _sort_favorites_first(children),
        }

    reference = _as_variant_reference(node)
    if reference is not None:
        vset = reference.getVariantSet()
        if vset.isNull() or not vset.isValid():
            return None
        _track_variant_set(vset, variant_sets)
        return _serialize_variant_set(vset, display_name=reference.getName())

    vset = _as_variant_set(node)
    if vset is not None:
        _track_variant_set(vset, variant_sets)
        return _serialize_variant_set(vset)

    return None


def build_variant_hierarchy():
    root = vrVariantService.getVariantSetRoot()
    if root.isNull():
        return [], []

    hierarchy = []
    variant_sets = []

    for child in _filter_valid_nodes(root.getChildren()):
        item = _serialize_tree_node(child, variant_sets)
        if item is not None:
            hierarchy.append(item)

    return _sort_favorites_first(hierarchy), variant_sets


def _execute_variant_set(vset):
    try:
        vrVariantService.execute(vset, vrVariantTypes.ExecuteFlag.None_)
        return True
    except Exception as error:
        _error("Failed to execute '%s': %s" % (vset.getName(), error))
        return False


def _get_web_engine():
    if SCENEPLATE_WEB_ENGINE_NAME:
        web_engine = vrWebEngineService.getWebEngine(SCENEPLATE_WEB_ENGINE_NAME)
        if web_engine.isValid():
            return web_engine

    for candidate in vrWebEngineService.getWebEngines():
        if candidate.isValid() and candidate.getUsedInSceneplate():
            return candidate

    return None


def _send_web_event(event_name, payload):
    web_engine = _get_web_engine()
    if web_engine is None:
        _warn("Sceneplate WebEngine not found; event '%s' not sent." % event_name)
        return False

    if isinstance(payload, str):
        data = payload
    else:
        try:
            data = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
        except Exception as error:
            _error("JSON encode failed for '%s': %s" % (event_name, error))
            return False

    try:
        web_engine.sendEvent(event_name, data)
        return True
    except Exception as error:
        _warn("sendEvent failed (%s): %s" % (event_name, error))
        return False


def _enable_web_interaction():
    try:
        vrWebEngineService.setInteractionEnabled(True)
    except Exception as error:
        _warn("Could not enable WebEngine interaction: %s" % error)


def _send_hierarchy_error(message):
    return _send_web_event(EVENT_HIERARCHY_ERROR, {"message": message})


def _push_thumbnail_batches(variant_sets):
    batch = {}
    sent = 0
    seen_ids = set()

    for vset in variant_sets:
        variant_id = _variant_identifier(vset)
        if variant_id in seen_ids:
            continue
        seen_ids.add(variant_id)

        thumbnail = _collect_thumbnail(vset)
        if not thumbnail:
            continue

        batch[variant_id] = thumbnail
        if len(batch) >= THUMBNAIL_BATCH_SIZE:
            if _send_web_event(EVENT_THUMBNAIL_BATCH, {"thumbnails": batch}):
                sent += len(batch)
            batch = {}

    if batch and _send_web_event(EVENT_THUMBNAIL_BATCH, {"thumbnails": batch}):
        sent += len(batch)

    _log("Streamed %d / %d variant thumbnails." % (sent, len(seen_ids)))
    return sent


def _build_hierarchy_payload():
    hierarchy, variant_sets = build_variant_hierarchy()
    payload = {
        "ok": True,
        "apiVersion": REQUIRED_API_VERSION,
        "variantApi": REQUIRED_VARIANT_API,
        "hierarchy": hierarchy,
        "selectedId": _get_primary_selected_variant_id(),
        "variantCount": len(variant_sets),
    }
    return payload, variant_sets


def _push_hierarchy_to_html():
    payload, variant_sets = _build_hierarchy_payload()

    _log("Sending hierarchy (%d root groups, %d variant sets)." % (
        len(payload["hierarchy"]),
        payload["variantCount"],
    ))

    if not _send_web_event(EVENT_HIERARCHY_LOADED, payload):
        return False

    _push_thumbnail_batches(variant_sets)
    return True


def variant_menu_get_hierarchy_json():
    """Return hierarchy JSON for vred.executePythonCommand (HTML refresh button)."""
    try:
        verify_api()
    except Exception as error:
        return _json_response(False, message=str(error))

    payload, variant_sets = _build_hierarchy_payload()
    _push_thumbnail_batches(variant_sets)
    return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))


def variant_menu_execute_variant(variant_id):
    """Execute a variant set; returns JSON for vred.executePythonCommand."""
    try:
        verify_api()
    except Exception as error:
        return _json_response(False, message=str(error))

    vset = _find_variant_set_by_id(variant_id)
    if vset is None:
        return _json_response(False, message="Variant set not found: %s" % variant_id)

    if not _execute_variant_set(vset):
        return _json_response(False, message="Failed to execute variant set: %s" % vset.getName())

    vrVariantService.setSelectedNodes([vset])
    result = _variant_payload(vset)
    result["ok"] = True
    return json.dumps(result, ensure_ascii=False, separators=(",", ":"))


def variant_menu_refresh():
    """Push hierarchy + thumbnails via sendEvent (auto-refresh on scene changes)."""
    try:
        verify_api()
    except Exception as error:
        _send_hierarchy_error(str(error))
        return

    if not _push_hierarchy_to_html():
        _send_hierarchy_error("Variant menu WebEngine is not available.")


def _on_variants_changed():
    if AUTO_REFRESH_ON_VARIANTS_CHANGED:
        variant_menu_refresh()


def _on_selection_changed(selected_nodes):
    for node in _filter_valid_nodes(selected_nodes):
        vset = _as_variant_set(node)
        if vset is not None:
            _send_web_event(
                EVENT_SELECTION_CHANGED,
                {"selectedId": _variant_identifier(vset)},
            )
            return


def _on_variant_set_executed(vset):
    cast = _as_variant_set(vset)
    if cast is not None:
        _send_web_event(EVENT_VARIANT_EXECUTED, _variant_payload(cast))


def _connect_variant_signals():
    try:
        vrVariantService.variantsChanged.connect(_on_variants_changed)
        vrVariantService.selectionChanged.connect(_on_selection_changed)
        vrVariantService.variantSetExecuted.connect(_on_variant_set_executed)
    except Exception as error:
        _warn("Could not connect variant service signals: %s" % error)


def variant_menu_init():
    """Run once from Script Editor to connect signals and enable WebEngine interaction."""
    try:
        verify_api()
    except Exception as error:
        _error(str(error))
        return False

    _enable_web_interaction()
    _connect_variant_signals()
    _log("Script Editor helpers ready (VRED %s+, API v2)." % REQUIRED_API_VERSION)
    _log("Paste variant_menu.html into your Sceneplate Web content editor.")
    return True


variant_menu_init()

 

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>VRED 2027.1+ Variant Menu</title>
  <style>
    /* ------------------------------------------------------------------ */
    /* Design tokens — VRED dark grey UI */
    /* ------------------------------------------------------------------ */
    :root {
      --bg-app: transparent;
      --bg-sidebar: #1a1d24;
      --bg-card: #242830;
      --bg-card-hover: #2c313b;
      --bg-thumb: #151820;
      --bg-thumb-icon: rgba(0, 0, 0, 0.25);
      --bg-toggle: #1a1d24;
      --text-primary: #eef0f4;
      --text-secondary: #9aa3b2;
      --text-muted: #6b7380;
      --accent: #0696d7;
      --accent-soft: rgba(6, 150, 215, 0.18);
      --border: rgba(255, 255, 255, 0.08);
      --shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.45);
      --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.25);
      --sidebar-width: 400px;
      --toggle-size: 46px;
      --toggle-gutter: 18px;
      --thumb-size: 48px;
      --radius-sm: 6px;
      --radius-md: 10px;
      --radius-lg: 14px;
      --transition-fast: 180ms cubic-bezier(0.4, 0, 0.2, 1);
      --transition-med: 320ms cubic-bezier(0.4, 0, 0.2, 1);
      --font-ui: "Segoe UI", system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
    }

    *, *::before, *::after {
      box-sizing: border-box;
    }

    html, body {
      margin: 0;
      width: 100%;
      height: 100%;
      overflow: hidden;
      font-family: var(--font-ui);
      color: var(--text-primary);
      background: transparent;
      -webkit-font-smoothing: antialiased;
      user-select: none;
    }

    body:focus,
    body:focus-visible {
      outline: none;
      box-shadow: none;
    }

    /* ------------------------------------------------------------------ */
    /* UI — layout shell                                                  */
    /* ------------------------------------------------------------------ */
    .app {
      position: relative;
      width: 100%;
      height: 100%;
      /* Do not use pointer-events:none — Qt WebEngine in VRED blocks child clicks. */
    }

    /* Toggle — fixed left when collapsed, slides to sidebar edge when open. */
    .hamburger {
      position: fixed;
      top: var(--toggle-gutter);
      left: var(--toggle-gutter);
      z-index: 1200;
      width: var(--toggle-size);
      height: var(--toggle-size);
      border: 1px solid var(--border);
      border-radius: var(--radius-md);
      background: var(--bg-card);
      box-shadow: var(--shadow-sm);
      cursor: pointer;
      display: flex;
      align-items: center;
      justify-content: center;
      transition:
        left var(--transition-med),
        background var(--transition-fast),
        transform var(--transition-fast);
      -webkit-tap-highlight-color: transparent;
      touch-action: manipulation;
    }

    .app.is-open .hamburger {
      /* Avoid min() inside calc — not supported in VRED Qt WebEngine (Chromium ~69). */
      left: calc(var(--sidebar-width) - var(--toggle-size) - 14px);
    }

    .hamburger:hover {
      background: var(--bg-card-hover);
    }

    .hamburger:focus-visible {
      outline: 2px solid var(--accent);
      outline-offset: 2px;
    }

    .hamburger:active {
      transform: scale(0.96);
    }

    .hamburger__bars {
      position: relative;
      width: 20px;
      height: 14px;
    }

    .hamburger__bar {
      position: absolute;
      left: 0;
      width: 100%;
      height: 2px;
      border-radius: 2px;
      background: var(--text-primary);
      transition: transform var(--transition-med), opacity var(--transition-fast), top var(--transition-med);
    }

    .hamburger__bar:nth-child(1) { top: 0; }
    .hamburger__bar:nth-child(2) { top: 6px; }
    .hamburger__bar:nth-child(3) { top: 12px; }

    .app.is-open .hamburger__bar:nth-child(1) {
      top: 6px;
      transform: rotate(45deg);
    }

    .app.is-open .hamburger__bar:nth-child(2) {
      opacity: 0;
      transform: scaleX(0.4);
    }

    .app.is-open .hamburger__bar:nth-child(3) {
      top: 6px;
      transform: rotate(-45deg);
    }

    .sidebar {
      position: fixed;
      top: 0;
      left: 0;
      z-index: 1150;
      width: var(--sidebar-width);
      height: 100%;
      background: var(--bg-sidebar);
      border-right: 1px solid var(--border);
      box-shadow: var(--shadow-lg);
      transform: translateX(-100%);
      visibility: hidden;
      transition:
        transform var(--transition-med),
        visibility var(--transition-med);
      display: flex;
      flex-direction: column;
      text-align: left;
    }

    .app.is-open .sidebar {
      transform: translateX(0);
      visibility: visible;
    }

    .sidebar__header {
      padding: 22px 20px 16px;
      border-bottom: 1px solid var(--border);
      flex-shrink: 0;
      text-align: left;
    }

    .sidebar__title {
      margin: 0;
      font-size: 15px;
      font-weight: 600;
      letter-spacing: 0.02em;
    }

    .sidebar__toolbar {
      display: flex;
      gap: 8px;
      margin-top: 14px;
    }

    .btn-ghost {
      appearance: none;
      border: 1px solid var(--border);
      background: var(--bg-card);
      color: var(--text-primary);
      font: inherit;
      font-size: 11px;
      padding: 6px 10px;
      border-radius: var(--radius-sm);
      cursor: pointer;
      transition: background var(--transition-fast);
    }

    .btn-ghost.btn-icon {
      width: 34px;
      height: 34px;
      padding: 0;
      display: inline-flex;
      align-items: center;
      justify-content: center;
    }

    .btn-icon__glyph {
      display: inline-flex;
      align-items: center;
      justify-content: center;
      width: 16px;
      height: 16px;
      line-height: 0;
    }

    .btn-icon__glyph svg {
      width: 16px;
      height: 16px;
      fill: currentColor;
    }

    .btn-icon__glyph svg.icon-refresh {
      fill: none;
      stroke: currentColor;
      stroke-width: 2.25;
      stroke-linecap: round;
      stroke-linejoin: round;
    }

    .btn-icon--toggle .btn-icon__glyph {
      transition: transform var(--transition-med);
    }

    .btn-icon--toggle.is-expanded .btn-icon__glyph {
      transform: rotate(180deg);
    }

    .btn-ghost:hover {
      background: var(--bg-card-hover);
    }

    .btn-ghost:focus-visible {
      outline: 2px solid var(--accent);
      outline-offset: 1px;
    }

    .sidebar__body {
      flex: 1 1 auto;
      height: 0;
      min-height: 0;
      overflow-x: hidden;
      overflow-y: scroll;
      padding: 10px 10px 24px;
      scrollbar-width: thin;
      scrollbar-color: rgba(58, 65, 80, 0.45) transparent;
      overscroll-behavior: contain;
      -webkit-overflow-scrolling: touch;
      touch-action: pan-y;
    }

    .sidebar__body:focus,
    .sidebar__body:focus-visible {
      outline: none;
      box-shadow: none;
    }

    .app.is-autoscrolling,
    .app.is-autoscrolling .sidebar__body {
      cursor: none;
    }

    .autoscroll-indicator {
      position: fixed;
      z-index: 1300;
      width: 26px;
      height: 26px;
      margin: -13px 0 0 -13px;
      border: 2px solid var(--accent);
      border-radius: 50%;
      background: rgba(6, 150, 215, 0.18);
      box-shadow: 0 0 0 4px rgba(6, 150, 215, 0.1);
      pointer-events: none;
      opacity: 0;
      visibility: hidden;
      transition: opacity var(--transition-fast);
    }

    .app.is-autoscrolling .autoscroll-indicator {
      opacity: 1;
      visibility: visible;
    }

    .autoscroll-indicator::before,
    .autoscroll-indicator::after {
      content: "";
      position: absolute;
      left: 50%;
      width: 0;
      height: 0;
      border-left: 4px solid transparent;
      border-right: 4px solid transparent;
      transform: translateX(-50%);
    }

    .autoscroll-indicator::before {
      top: 4px;
      border-bottom: 5px solid var(--accent);
    }

    .autoscroll-indicator::after {
      bottom: 4px;
      border-top: 5px solid var(--accent);
    }

    .sidebar__body::-webkit-scrollbar {
      width: 8px;
    }

    .sidebar__body::-webkit-scrollbar-track {
      background: transparent;
    }

    .sidebar__body::-webkit-scrollbar-thumb {
      background: rgba(58, 65, 80, 0.45);
      border-radius: 8px;
      transition: background var(--transition-fast);
    }

    .sidebar__body::-webkit-scrollbar-thumb:hover {
      background: #6b758a;
    }

    .sidebar__body::-webkit-scrollbar-thumb:active {
      background: #7a8499;
    }

    .status {
      padding: 24px 16px;
      text-align: center;
      color: var(--text-secondary);
      font-size: 13px;
    }

    .status--error {
      color: #ff8a8a;
    }

    .spinner {
      width: 28px;
      height: 28px;
      margin: 0 auto 12px;
      border: 2px solid rgba(255, 255, 255, 0.12);
      border-top-color: var(--accent);
      border-radius: 50%;
      animation: spin 0.8s linear infinite;
    }

    @keyframes spin {
      to { transform: rotate(360deg); }
    }

    /* ------------------------------------------------------------------ */
    /* Tree Rendering                                                     */
    /* ------------------------------------------------------------------ */
    .menu-tree {
      list-style: none;
      margin: 0;
      padding: 0;
    }

    .menu-tree .menu-tree {
      margin-left: 0;
      padding-left: 14px;
      border-left: 1px solid rgba(255, 255, 255, 0.05);
    }

    .menu-item {
      margin: 2px 0;
    }

    .menu-item[data-type='variant'] {
      margin: 4px 0;
    }

    .folder-toggle {
      width: 100%;
      display: flex;
      align-items: center;
      gap: 8px;
      padding: 8px 10px;
      border: none;
      border-radius: var(--radius-sm);
      background: transparent;
      color: var(--text-primary);
      font: inherit;
      font-size: 13px;
      font-weight: 500;
      text-align: left;
      cursor: pointer;
      transition: background var(--transition-fast);
    }

    .folder-toggle:hover {
      background: var(--bg-card-hover);
    }

    .folder-toggle:focus-visible {
      outline: 2px solid var(--accent);
      outline-offset: 1px;
    }

    .folder-toggle__folder {
      width: 14px;
      height: 14px;
      flex-shrink: 0;
      display: inline-flex;
      align-items: center;
      justify-content: center;
    }

    .folder-toggle__folder svg,
    .folder-toggle__star svg,
    .folder-toggle__reference svg {
      width: 12px;
      height: 12px;
    }

    .folder-toggle__folder svg path,
    .folder-toggle__star svg path,
    .folder-toggle__reference svg path {
      fill: currentColor;
    }

    .folder-toggle__folder-closed,
    .folder-toggle__folder-open {
      align-items: center;
      justify-content: center;
    }

    .folder-toggle__folder-closed {
      display: inline-flex;
    }

    .folder-toggle__folder-open {
      display: none;
    }

    .menu-item.is-expanded > .folder-toggle .folder-toggle__folder-closed {
      display: none;
    }

    .menu-item.is-expanded > .folder-toggle .folder-toggle__folder-open {
      display: inline-flex;
    }

    .folder-toggle__star,
    .folder-toggle__reference {
      width: 14px;
      height: 14px;
      flex-shrink: 0;
      display: inline-flex;
      align-items: center;
      justify-content: center;
    }

    .folder-toggle__label {
      flex: 1;
      min-width: 0;
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
    }

    /* max-height animation — compatible with VRED Qt WebEngine (Chromium ~69). */
    .submenu-wrap {
      max-height: 0;
      overflow: hidden;
      transition: max-height var(--transition-med);
    }

    .menu-item.is-expanded > .submenu-wrap {
      max-height: 8000px;
    }

    .submenu-inner {
      overflow: hidden;
    }

    .variant-entry {
      width: 100%;
      display: flex;
      align-items: center;
      gap: 12px;
      padding: 8px 10px;
      border: 1px solid transparent;
      border-radius: var(--radius-md);
      background: var(--bg-card);
      color: var(--text-primary);
      font: inherit;
      font-size: 13px;
      text-align: left;
      cursor: pointer;
      box-shadow: var(--shadow-sm);
      transition:
        background var(--transition-fast),
        border-color var(--transition-fast),
        transform var(--transition-fast),
        box-shadow var(--transition-fast);
    }

    .variant-entry:hover {
      background: var(--bg-card-hover);
      border-color: rgba(255, 255, 255, 0.06);
    }

    .variant-entry:focus-visible {
      outline: 2px solid var(--accent);
      outline-offset: 1px;
    }

    .variant-entry.is-selected {
      background: var(--accent-soft);
      border-color: rgba(6, 150, 215, 0.45);
      box-shadow: 0 0 0 1px rgba(6, 150, 215, 0.15);
    }

    .variant-entry.is-clicked {
      transform: scale(0.98);
    }

    .variant-entry__thumb {
      width: var(--thumb-size);
      height: var(--thumb-size);
      flex-shrink: 0;
      border-radius: var(--radius-sm);
      background: var(--bg-thumb);
      object-fit: cover;
      border: 1px solid var(--border);
    }

    .variant-entry__thumb--icon {
      background: var(--bg-thumb-icon);
    }

    .variant-entry__thumb--placeholder {
      display: inline-flex;
      align-items: center;
      justify-content: center;
      color: var(--text-muted);
      font-size: 11px;
      font-weight: 600;
      letter-spacing: 0.04em;
    }

    .variant-entry__name {
      flex: 1;
      min-width: 0;
      line-height: 1.35;
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
    }

    @media (prefers-reduced-motion: reduce) {
      *, *::before, *::after {
        animation-duration: 0.01ms !important;
        animation-iteration-count: 1 !important;
        transition-duration: 0.01ms !important;
      }
    }
  </style>
</head>
<body tabindex="0">
  <div class="app" id="app">
    <div class="autoscroll-indicator" id="autoscroll-indicator" aria-hidden="true"></div>
    <button
      class="hamburger"
      id="hamburger"
      type="button"
      aria-label="Open variant menu"
      aria-controls="sidebar"
      aria-expanded="false"
    >
      <span class="hamburger__bars" aria-hidden="true">
        <span class="hamburger__bar"></span>
        <span class="hamburger__bar"></span>
        <span class="hamburger__bar"></span>
      </span>
    </button>

    <aside class="sidebar" id="sidebar" aria-label="Variant Sets">
      <div class="sidebar__header">
        <h1 class="sidebar__title">Variant Sets</h1>
        <div class="sidebar__toolbar">
          <button
            class="btn-ghost btn-icon"
            id="refresh-btn"
            type="button"
            aria-label="Refresh variant sets"
            title="Refresh"
          >
            <span class="btn-icon__glyph" aria-hidden="true">
              <svg class="icon-refresh" viewBox="0 0 24 24">
                <path d="M23 4v6h-6"/>
                <path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
              </svg>
            </span>
          </button>
          <button
            class="btn-ghost btn-icon btn-icon--toggle is-expanded"
            id="expand-collapse-btn"
            type="button"
            aria-expanded="true"
            aria-label="Collapse all groups"
            title="Collapse all groups"
          >
            <span class="btn-icon__glyph" aria-hidden="true">
              <svg viewBox="0 0 16 16">
                <path d="M4.47 5.97a.75.75 0 0 1 1.06 0L8 8.44l2.47-2.47a.75.75 0 1 1 1.06 1.06l-3 3a.75.75 0 0 1-1.06 0l-3-3a.75.75 0 0 1 0-1.06Z"/>
              </svg>
            </span>
          </button>
        </div>
      </div>
      <div class="sidebar__body" id="sidebar-body" tabindex="0">
        <div class="status" id="status-panel">
          <div class="spinner" aria-hidden="true"></div>
          <div>Loading variant sets…</div>
        </div>
        <ul class="menu-tree" id="menu-tree" hidden></ul>
      </div>
    </aside>
  </div>

  <script>
    "use strict";

    /* ================================================================== */
    /* UI                                                                 */
    /* ================================================================== */

    const AppState = {
      isOpen: false,
      selectedId: null,
      expandedKeys: new Set(),
      hierarchy: [],
      isLoading: false,
      isSupported: false,
      isPointerOverMenu: false,
      treeFullyExpanded: true,
      thumbnails: {}
    };

    // Minimum host requirements — must match variant_menu_helpers.py.
    const REQUIRED_API_VERSION = "2027.1";
    const REQUIRED_VARIANT_API = "v2";

    const DOM = {
      app: document.getElementById("app"),
      hamburger: document.getElementById("hamburger"),
      sidebar: document.getElementById("sidebar"),
      sidebarBody: document.getElementById("sidebar-body"),
      menuTree: document.getElementById("menu-tree"),
      statusPanel: document.getElementById("status-panel"),
      refreshBtn: document.getElementById("refresh-btn"),
      expandCollapseBtn: document.getElementById("expand-collapse-btn"),
      autoscrollIndicator: document.getElementById("autoscroll-indicator")
    };

    function setSidebarOpen(isOpen) {
      AppState.isOpen = isOpen;
      DOM.app.classList.toggle("is-open", isOpen);
      DOM.hamburger.setAttribute("aria-expanded", String(isOpen));
      DOM.hamburger.setAttribute("aria-label", isOpen ? "Close variant menu" : "Open variant menu");

      if (isOpen) {
        focusMenuSurface();
      }

      if (isOpen && AppState.hierarchy.length === 0 && !AppState.isLoading) {
        loadVariantSets();
      }
    }

    function toggleSidebar() {
      setSidebarOpen(!AppState.isOpen);
    }

    function showStatus(message, isError) {
      DOM.statusPanel.hidden = false;
      DOM.menuTree.hidden = true;
      DOM.statusPanel.classList.toggle("status--error", Boolean(isError));
      DOM.statusPanel.innerHTML = isError
        ? `<div>${escapeHtml(message)}</div>`
        : `<div class="spinner" aria-hidden="true"></div><div>${escapeHtml(message)}</div>`;
    }

    function showEmpty(message) {
      DOM.statusPanel.hidden = false;
      DOM.menuTree.hidden = true;
      DOM.statusPanel.classList.remove("status--error");
      DOM.statusPanel.innerHTML = `<div>${escapeHtml(message)}</div>`;
    }

    function hideStatus() {
      DOM.statusPanel.hidden = true;
      DOM.menuTree.hidden = false;
    }

    function setMenuBlocked(message) {
      AppState.isSupported = false;
      AppState.isLoading = false;
      DOM.refreshBtn.disabled = true;
      showStatus(message, true);
    }

    function markMenuSupported() {
      AppState.isSupported = true;
      DOM.refreshBtn.disabled = false;
    }

    function escapeHtml(value) {
      return String(value)
        .replace(/&/g, "&amp;")
        .replace(/</g, "&lt;")
        .replace(/>/g, "&gt;")
        .replace(/"/g, "&quot;")
        .replace(/'/g, "&#39;");
    }

    function resolveThumbnailSrc(thumbnail) {
      if (!thumbnail) {
        return null;
      }

      const value = String(thumbnail).trim();
      if (!value) {
        return null;
      }

      if (
        value.startsWith("data:") ||
        value.startsWith("http://") ||
        value.startsWith("https://") ||
        value.startsWith("file://")
      ) {
        return value;
      }

      // Treat plain paths as file URLs for local preview images from VRED.
      const normalized = value.replace(/\\/g, "/");
      return normalized.startsWith("/") ? `file://${normalized}` : `file:///${normalized}`;
    }

    /* ================================================================== */
    /* Tree Rendering                                                     */
    /* ================================================================== */

    function nodeKey(node, parentKey, index) {
      if (node.id) {
        return `vset:${node.id}`;
      }
      return `group:${parentKey}/${node.name}:${index}`;
    }

    function isVariantNode(node) {
      return Boolean(node && node.id);
    }

    function isGroupNode(node) {
      return Boolean(node && !node.id && Array.isArray(node.children));
    }

    function getFolderIconMarkup() {
      return `<span class="folder-toggle__folder" aria-hidden="true">` +
        `<span class="folder-toggle__folder-closed">` +
        `<svg viewBox="0 0 20 20" fill="none">` +
        `<path d="M1 3V6.375H8.1325L9.2575 5.25L7.75 3H1ZM1 7.5V16.5H19V5.25H11.125L8.875 7.5H1Z" fill="currentColor"/>` +
        `</svg>` +
        `</span>` +
        `<span class="folder-toggle__folder-open">` +
        `<svg viewBox="0 0 20 20" fill="none">` +
        `<path d="M14.5 6.375V5.25H10L7.75 3H1V5.1825V5.25V13.98L4.8025 6.375H14.5Z" fill="currentColor"/>` +
        `<path d="M5.5 7.5L1 16.5H14.5L19 7.5H5.5Z" fill="currentColor"/>` +
        `</svg>` +
        `</span>` +
        `</span>`;
    }

    function getReferenceGroupIconMarkup() {
      return `<span class="folder-toggle__reference" aria-hidden="true">` +
        `<svg viewBox="0 0 20 20" fill="none">` +
        `<path d="M10.5637 9.43611C10.7573 10.1716 10.5756 10.9863 10 11.5619L6.87581 14.6861C6.01429 15.5476 4.61275 15.5472 3.75162 14.6861C2.8901 13.8246 2.8901 12.4234 3.75162 11.5619L6.87581 8.43772L8.61086 6.70267C7.70587 6.65548 6.786 6.96543 6.09476 7.65667L2.97057 10.7809C1.67648 12.0749 1.67648 14.1731 2.97057 15.4671C4.26466 16.7612 6.36277 16.7612 7.65686 15.4671L10.781 12.343C11.7998 11.3242 12.0038 9.81199 11.4186 8.58121L10.5637 9.43611Z" fill="currentColor"/>` +
        `<path d="M12.3432 4.53258L9.21904 7.65678C8.2003 8.67552 7.99627 10.1877 8.58148 11.4185L9.43628 10.5637C9.24254 9.82816 9.42436 9.01355 10.0001 8.43782L13.1243 5.31363C13.9858 4.45211 15.387 4.45211 16.2485 5.31363C17.1096 6.17477 17.11 7.57631 16.2485 8.43782L11.3892 13.2971C12.2942 13.3442 13.2141 13.0343 13.9053 12.3431L17.0295 9.21887C18.3236 7.92478 18.3236 5.82667 17.0295 4.53258C15.7354 3.23849 13.6373 3.23849 12.3432 4.53258Z" fill="currentColor"/>` +
        `</svg>` +
        `</span>`;
    }

    function getFavoriteGroupIconMarkup() {
      return `<span class="folder-toggle__star" aria-hidden="true">` +
        `<svg viewBox="0 0 32 32" fill="none">` +
        `<path d="M16 1.43359C16.4612 1.43359 19.4122 12.0523 19.7853 12.3234C20.1584 12.5945 31.1693 12.1197 31.3118 12.5583C31.4543 12.9969 22.2672 19.0849 22.1247 19.5235C21.9822 19.962 25.8363 30.2874 25.4632 30.5584C25.0901 30.8295 16.4612 23.9733 16 23.9733C15.5388 23.9733 6.90985 30.8295 6.53676 30.5584C6.16368 30.2874 10.0178 19.962 9.87526 19.5235C9.73276 19.0849 0.545659 12.9969 0.688164 12.5583C0.83067 12.1197 11.8416 12.5945 12.2147 12.3234C12.5878 12.0523 15.5388 1.43359 16 1.43359Z" fill="currentColor"/>` +
        `</svg>` +
        `</span>`;
    }

    function createFolderElement(node, key) {
      const li = document.createElement("li");
      li.className = "menu-item";
      li.dataset.key = key;
      li.dataset.type = "group";

      if (node.isFavorite) {
        li.classList.add("is-favorite");
      }

      if (node.isReferenceGroup) {
        li.classList.add("is-reference-group");
      }

      if (AppState.expandedKeys.has(key)) {
        li.classList.add("is-expanded");
      }

      const starMarkup = node.isFavorite ? getFavoriteGroupIconMarkup() : "";
      const referenceMarkup = node.isReferenceGroup ? getReferenceGroupIconMarkup() : "";
      const folderMarkup = !node.isFavorite && !node.isReferenceGroup ? getFolderIconMarkup() : "";

      const button = document.createElement("button");
      button.type = "button";
      button.className = "folder-toggle";
      button.setAttribute("aria-expanded", String(AppState.expandedKeys.has(key)));
      button.innerHTML =
        folderMarkup +
        starMarkup +
        referenceMarkup +
        `<span class="folder-toggle__label">${escapeHtml(node.name)}</span>`;

      const wrap = document.createElement("div");
      wrap.className = "submenu-wrap";

      const inner = document.createElement("div");
      inner.className = "submenu-inner";

      const childList = document.createElement("ul");
      childList.className = "menu-tree";
      renderNodes(node.children, key, childList);

      inner.appendChild(childList);
      wrap.appendChild(inner);
      li.appendChild(button);
      li.appendChild(wrap);
      return li;
    }

    function getThumbClassName(previewType, isPlaceholder) {
      const classes = ["variant-entry__thumb"];
      if (previewType === "icon") {
        classes.push("variant-entry__thumb--icon");
      }
      if (isPlaceholder) {
        classes.push("variant-entry__thumb--placeholder");
      }
      return classes.join(" ");
    }

    function findHierarchyNode(nodes, variantId) {
      if (!Array.isArray(nodes)) {
        return null;
      }

      for (let i = 0; i < nodes.length; i += 1) {
        const node = nodes[i];
        if (!node) {
          continue;
        }

        if (node.id === variantId) {
          return node;
        }

        const childMatch = findHierarchyNode(node.children, variantId);
        if (childMatch) {
          return childMatch;
        }
      }

      return null;
    }

    function createVariantElement(node) {
      const li = document.createElement("li");
      li.className = "menu-item";
      li.dataset.type = "variant";
      li.dataset.variantId = node.id;

      const button = document.createElement("button");
      button.type = "button";
      button.className = "variant-entry";
      button.dataset.variantId = node.id;
      button.dataset.previewType = node.previewType || "thumbnail";

      if (AppState.selectedId === node.id) {
        button.classList.add("is-selected");
      }

      const thumbnail = AppState.thumbnails[node.id] || node.thumbnail;
      const thumbsrc=resolveThumbnailSrc(thumbnail);
      const previewType = node.previewType || "thumbnail";
      let thumbMarkup;

      if (thumbSrc) {
        thumbMarkup =
          `<img class="${getThumbClassName(previewType, false)}" alt="" src="${escapeHtml(thumbSrc)}" decoding="async">`;
      } else {
        const initials = escapeHtml((node.name || "?").slice(0, 2).toUpperCase());
        thumbMarkup = `<span class="${getThumbClassName(previewType, true)}">${initials}</span>`;
      }

      button.innerHTML =
        thumbMarkup +
        `<span class="variant-entry__name">${escapeHtml(node.name)}</span>`;

      li.appendChild(button);
      return li;
    }

    function renderNodes(nodes, parentKey, container) {
      if (!Array.isArray(nodes)) {
        return;
      }

      nodes.forEach(function (node, index) {
        if (!node || !node.name) {
          return;
        }

        const key = nodeKey(node, parentKey, index);

        if (isVariantNode(node)) {
          container.appendChild(createVariantElement(node));
          return;
        }

        if (isGroupNode(node)) {
          container.appendChild(createFolderElement(node, key));
        }
      });
    }

    function renderMenuTree(hierarchy) {
      const fragment = document.createDocumentFragment();
      renderNodes(hierarchy, "root", fragment);

      DOM.menuTree.replaceChildren(fragment);

      if (DOM.menuTree.childElementCount === 0) {
        showEmpty("No variant sets found in this scene.");
        return;
      }

      hideStatus();
    }

    function findVariantEntries(variantId) {
      const entries = [];
      DOM.menuTree.querySelectorAll(".variant-entry[data-variant-id]").forEach(function (entry) {
        if (entry.dataset.variantId === variantId) {
          entries.push(entry);
        }
      });
      return entries;
    }

    function applyThumbnailToEntry(entry, src, previewType) {
      const thumbClass = getThumbClassName(entry.dataset.previewType || previewType, false);
      const existing = entry.querySelector(".variant-entry__thumb");

      if (existing && existing.tagName === "IMG") {
        existing.className = thumbClass;
        existing.src=src;
        return;
      }

      const img = document.createElement("img");
      img.className = thumbClass;
      img.alt = "";
      img.src=src;
      img.decoding = "async";

      if (existing) {
        existing.replaceWith(img);
      } else {
        entry.insertBefore(img, entry.firstChild);
      }
    }

    function updateVariantThumbnailDom(variantId, src) {
      const node = findHierarchyNode(AppState.hierarchy, variantId);
      const previewType = (node && node.previewType) || "thumbnail";
      const entries = findVariantEntries(variantId);
      if (entries.length === 0) {
        return;
      }

      entries.forEach(function (entry) {
        applyThumbnailToEntry(entry, src, previewType);
      });
    }

    function updateVariantThumbnail(variantId, thumbnailSrc) {
      if (!variantId || !thumbnailSrc) {
        return;
      }

      const src=resolveThumbnailSrc(thumbnailSrc);
      if (!src) {
        return;
      }

      AppState.thumbnails[variantId] = thumbnailSrc;
      const node = findHierarchyNode(AppState.hierarchy, variantId);
      if (node) {
        node.thumbnail = thumbnailSrc;
      }
      updateVariantThumbnailDom(variantId, src);
    }

    function applyThumbnailBatch(payload) {
      if (!payload || !payload.thumbnails) {
        return;
      }

      Object.keys(payload.thumbnails).forEach(function (variantId) {
        updateVariantThumbnail(variantId, payload.thumbnails[variantId]);
      });
    }

    function walkGroups(nodes, parentKey, onGroupKey) {
      if (!Array.isArray(nodes)) {
        return;
      }

      nodes.forEach(function (node, index) {
        if (!node || !node.name) {
          return;
        }

        const key = nodeKey(node, parentKey, index);
        if (isGroupNode(node)) {
          onGroupKey(key);
          walkGroups(node.children, key, onGroupKey);
        }
      });
    }

    function setAllGroupsExpanded(expanded) {
      if (expanded) {
        walkGroups(AppState.hierarchy, "root", function (key) {
          AppState.expandedKeys.add(key);
        });
      } else {
        AppState.expandedKeys.clear();
      }

      renderMenuTree(AppState.hierarchy);
      updateExpandCollapseToggle(expanded);
    }

    function ensureDefaultExpansion(hierarchy) {
      walkGroups(hierarchy, "root", function (key) {
        AppState.expandedKeys.add(key);
      });
    }

    function updateExpandCollapseToggle(isExpanded) {
      AppState.treeFullyExpanded = Boolean(isExpanded);

      if (!DOM.expandCollapseBtn) {
        return;
      }

      DOM.expandCollapseBtn.classList.toggle("is-expanded", AppState.treeFullyExpanded);
      DOM.expandCollapseBtn.setAttribute("aria-expanded", String(AppState.treeFullyExpanded));

      if (AppState.treeFullyExpanded) {
        DOM.expandCollapseBtn.setAttribute("aria-label", "Collapse all groups");
        DOM.expandCollapseBtn.setAttribute("title", "Collapse all groups");
      } else {
        DOM.expandCollapseBtn.setAttribute("aria-label", "Expand all groups");
        DOM.expandCollapseBtn.setAttribute("title", "Expand all groups");
      }
    }

    function syncExpandCollapseToggle() {
      const groupItems = DOM.menuTree.querySelectorAll(".menu-item[data-type='group']");
      if (groupItems.length === 0) {
        updateExpandCollapseToggle(false);
        return;
      }

      let expandedCount = 0;
      groupItems.forEach(function (item) {
        if (item.classList.contains("is-expanded")) {
          expandedCount += 1;
        }
      });

      updateExpandCollapseToggle(expandedCount === groupItems.length);
    }

    /* ================================================================== */
    /* Event Handling                                                     */
    /* ================================================================== */

    function onRefreshClick() {
      loadVariantSets();
    }

    function onExpandCollapseToggleClick() {
      setAllGroupsExpanded(!AppState.treeFullyExpanded);
    }

    function onMenuTreeClick(event) {
      if (event.button !== 0) {
        return;
      }

      const folderToggle = event.target.closest(".folder-toggle");
      if (folderToggle) {
        const item = folderToggle.closest(".menu-item[data-type='group']");
        if (!item) {
          return;
        }

        const key = item.dataset.key;
        const willExpand = !item.classList.contains("is-expanded");

        if (willExpand) {
          AppState.expandedKeys.add(key);
          item.classList.add("is-expanded");
        } else {
          AppState.expandedKeys.delete(key);
          item.classList.remove("is-expanded");
        }

        folderToggle.setAttribute("aria-expanded", String(willExpand));
        syncExpandCollapseToggle();
        return;
      }

      const variantButton = event.target.closest(".variant-entry");
      if (!variantButton) {
        return;
      }

      const variantId = variantButton.dataset.variantId;
      if (!variantId) {
        return;
      }

      variantButton.classList.add("is-clicked");
      window.setTimeout(function () {
        variantButton.classList.remove("is-clicked");
      }, 140);

      updateSelectedVariant(variantId);
      executeVariant(variantId);
    }

    function onDocumentKeyDown(event) {
      if (event.key === "Escape") {
        if (DOM.app.classList.contains("is-autoscrolling")) {
          stopAutoscroll();
          return;
        }

        if (AppState.isOpen) {
          setSidebarOpen(false);
        }
      }
    }

    let stopAutoscroll = null;

    function bindPressHandler(element, handler) {
      if (!element) {
        return;
      }

      element.addEventListener("mousedown", function (event) {
        if (event.button !== 0) {
          return;
        }

        event.preventDefault();
        event.stopPropagation();
        handler(event);
      });
    }

    function focusMenuSurface() {
      if (DOM.sidebarBody && typeof DOM.sidebarBody.focus === "function") {
        DOM.sidebarBody.focus();
        return;
      }

      if (document.body && typeof document.body.focus === "function") {
        document.body.focus();
      }
    }

    function bindSidebarScroll() {
      const scrollContainer = DOM.sidebarBody;
      const indicator = DOM.autoscrollIndicator;
      if (!scrollContainer) {
        return;
      }

      const autoscroll = {
        active: false,
        anchorX: 0,
        anchorY: 0,
        pointerX: 0,
        pointerY: 0,
        rafId: null
      };

      const captureOptions = { capture: true, passive: false };

      function getMaxScroll() {
        return scrollContainer.scrollHeight - scrollContainer.clientHeight;
      }

      function isAutoscrollTarget(event) {
        const target = event.target;
        if (!target || !DOM.app.contains(target)) {
          return false;
        }

        return !DOM.hamburger.contains(target);
      }

      function positionIndicator() {
        if (!indicator) {
          return;
        }

        indicator.style.left = autoscroll.anchorX + "px";
        indicator.style.top = autoscroll.anchorY + "px";
      }

      function onAutoscrollMove(event) {
        if (!autoscroll.active) {
          return;
        }

        if ((event.buttons & 4) === 0) {
          stopAutoscroll();
          return;
        }

        autoscroll.pointerX = event.clientX;
        autoscroll.pointerY = event.clientY;
      }

      function autoscrollStep() {
        if (!autoscroll.active) {
          return;
        }

        const maxScroll = getMaxScroll();
        const dx = autoscroll.pointerX - autoscroll.anchorX;
        const dy = autoscroll.pointerY - autoscroll.anchorY;
        const distance = Math.sqrt(dx * dx + dy * dy);

        if (distance > 8 && maxScroll > 0) {
          const speed = Math.min(distance * 0.35, 48);
          const scrollDelta = (dy / distance) * speed;
          scrollContainer.scrollTop = Math.max(
            0,
            Math.min(maxScroll, scrollContainer.scrollTop + scrollDelta)
          );
        }

        autoscroll.rafId = window.requestAnimationFrame(autoscrollStep);
      }

      function onMiddleButtonUp(event) {
        if (event.button === 1) {
          stopAutoscroll();
        }
      }

      function onWindowBlur() {
        stopAutoscroll();
      }

      const autoscrollReleaseTargets = [document, window];
      const autoscrollReleaseEvents = ["mouseup", "pointerup"];

      function addAutoscrollListeners() {
        document.addEventListener("mousemove", onAutoscrollMove, captureOptions);
        autoscrollReleaseEvents.forEach(function (type) {
          autoscrollReleaseTargets.forEach(function (target) {
            target.addEventListener(type, onMiddleButtonUp, captureOptions);
          });
        });
        window.addEventListener("blur", onWindowBlur);
      }

      function removeAutoscrollListeners() {
        document.removeEventListener("mousemove", onAutoscrollMove, captureOptions);
        autoscrollReleaseEvents.forEach(function (type) {
          autoscrollReleaseTargets.forEach(function (target) {
            target.removeEventListener(type, onMiddleButtonUp, captureOptions);
          });
        });
        window.removeEventListener("blur", onWindowBlur);
      }

      stopAutoscroll = function () {
        if (!autoscroll.active) {
          return;
        }

        autoscroll.active = false;

        if (autoscroll.rafId !== null) {
          window.cancelAnimationFrame(autoscroll.rafId);
          autoscroll.rafId = null;
        }

        removeAutoscrollListeners();
        DOM.app.classList.remove("is-autoscrolling");
      };

      function startAutoscroll(event) {
        if (autoscroll.active) {
          return;
        }

        autoscroll.active = true;
        autoscroll.anchorX = event.clientX;
        autoscroll.anchorY = event.clientY;
        autoscroll.pointerX = event.clientX;
        autoscroll.pointerY = event.clientY;

        positionIndicator();
        DOM.app.classList.add("is-autoscrolling");
        addAutoscrollListeners();
        autoscroll.rafId = window.requestAnimationFrame(autoscrollStep);
      }

      function onMiddleMouseDown(event) {
        if (event.button !== 1 || !isAutoscrollTarget(event)) {
          return;
        }

        if (getMaxScroll() <= 0) {
          return;
        }

        event.preventDefault();
        event.stopPropagation();
        startAutoscroll(event);
      }

      function applyWheelScroll(event) {
        if (!AppState.isPointerOverMenu) {
          return;
        }

        const maxScroll = getMaxScroll();
        if (maxScroll <= 0) {
          return;
        }

        let delta = typeof event.deltaY === "number" ? event.deltaY : 0;
        if (!delta && typeof event.wheelDelta === "number") {
          delta = -event.wheelDelta / 3;
        } else if (!delta && typeof event.detail === "number") {
          delta = event.detail * 40;
        }
        if (!delta) {
          return;
        }

        if (event.cancelable) {
          event.preventDefault();
        }
        event.stopPropagation();
        scrollContainer.scrollTop = Math.max(
          0,
          Math.min(maxScroll, scrollContainer.scrollTop + delta)
        );
      }

      document.addEventListener("wheel", applyWheelScroll, captureOptions);
      document.addEventListener("mousewheel", applyWheelScroll, captureOptions);

      DOM.app.addEventListener("mousedown", onMiddleMouseDown, captureOptions);

      DOM.app.addEventListener("mouseenter", function () {
        AppState.isPointerOverMenu = true;
        focusMenuSurface();
      });
      DOM.app.addEventListener("mouseleave", function () {
        AppState.isPointerOverMenu = false;
      });
      scrollContainer.addEventListener("mousedown", function (event) {
        if (event.button === 0) {
          focusMenuSurface();
        }
      }, captureOptions);
    }

    function bindUiEvents() {
      bindPressHandler(DOM.hamburger, toggleSidebar);
      bindPressHandler(DOM.refreshBtn, onRefreshClick);
      bindPressHandler(DOM.expandCollapseBtn, onExpandCollapseToggleClick);
      DOM.menuTree.addEventListener("mousedown", onMenuTreeClick);
      bindSidebarScroll();
      document.addEventListener("keydown", onDocumentKeyDown);
    }

    /* ================================================================== */
    /* VRED Bridge (Script Editor helpers + sendEvent push updates)       */
    /* ================================================================== */

    const VRED_EVENTS = {
      hierarchyLoaded: "variantMenuHierarchyLoaded",
      hierarchyError: "variantMenuHierarchyError",
      thumbnailBatch: "variantMenuThumbnailBatch",
      variantExecuted: "variantMenuVariantExecuted",
      selectionChanged: "variantMenuSelectionChanged"
    };

    const VRED_PYTHON = {
      getHierarchy: "variant_menu_get_hierarchy_json()",
      executeVariant: function (id) {
        const safeId = String(id).replace(/\\/g, "\\\\").replace(/'/g, "\\'");
        return "variant_menu_execute_variant('" + safeId + "')";
      }
    };

    const VredBridge = {
      isAvailable: function () {
        return typeof window.vred !== "undefined";
      },

      hasCommand: function () {
        return this.isAvailable() && typeof window.vred.executePythonCommand === "function";
      },

      executePythonCommand: function (command, onSuccess, onError) {
        if (!this.hasCommand()) {
          if (onError) {
            onError();
          }
          return false;
        }

        try {
          window.vred.executePythonCommand(command, function (value) {
            if (onSuccess) {
              onSuccess(value);
            }
          });
          return true;
        } catch (error) {
          console.error("[VariantMenu] executePythonCommand failed:", error);
          if (onError) {
            onError(error);
          }
          return false;
        }
      }
    };

    function parsePythonJson(response, fallbackMessage) {
      if (response == null || response === "") {
        return { ok: false, message: fallbackMessage };
      }

      try {
        return JSON.parse(response);
      } catch (error) {
        console.warn("[VariantMenu] Could not parse Python JSON response.", response);
        return { ok: false, message: fallbackMessage };
      }
    }

    function parseEventDetail(detail) {
      if (detail == null || detail === "") {
        return null;
      }

      if (typeof detail === "object") {
        return detail;
      }

      try {
        return JSON.parse(detail);
      } catch (error) {
        console.warn("[VariantMenu] Could not parse event detail as JSON.", detail);
        return null;
      }
    }

    function applyHierarchyPayload(payload) {
      if (payload.variantApi && payload.variantApi !== REQUIRED_VARIANT_API) {
        setMenuBlocked("Unsupported variant API. Expected Python API v2 (VRED 2027.1+).");
        return false;
      }

      const hierarchy = Array.isArray(payload.hierarchy) ? payload.hierarchy : null;
      if (!hierarchy) {
        showStatus("Received invalid variant hierarchy from VRED.", true);
        return false;
      }

      AppState.hierarchy = hierarchy;

      if (payload.selectedId) {
        AppState.selectedId = payload.selectedId;
      }

      if (AppState.expandedKeys.size === 0) {
        ensureDefaultExpansion(hierarchy);
      }

      renderMenuTree(hierarchy);
      syncExpandCollapseToggle();
      return true;
    }

    function executeVariant(id) {
      if (!id || !AppState.isSupported) {
        return;
      }

      VredBridge.executePythonCommand(
        VRED_PYTHON.executeVariant(id),
        function (response) {
          const payload = parsePythonJson(response, "Failed to execute variant set.");
          if (!payload.ok) {
            showStatus(payload.message || "Failed to execute variant set.", true);
            return;
          }

          updateSelectedVariant(payload.id || id);
        },
        function () {
          showStatus("Failed to call VRED Python helpers.", true);
        }
      );
    }

    function loadVariantSets() {
      if (!VredBridge.hasCommand()) {
        showStatus(
          "VRED Python helpers unavailable. Paste variant_menu_helpers.py into Script Editor and run it once.",
          true
        );
        return;
      }

      AppState.isLoading = true;
      showStatus("Loading variant sets…", false);

      VredBridge.executePythonCommand(
        VRED_PYTHON.getHierarchy,
        function (response) {
          AppState.isLoading = false;
          const payload = parsePythonJson(response, "Failed to load variant sets from VRED.");

          if (!payload.ok) {
            setMenuBlocked(
              payload.message ||
                "This overlay requires VRED " + REQUIRED_API_VERSION + "+ with the Variant Sets module (Python API v2)."
            );
            return;
          }

          markMenuSupported();
          if (!applyHierarchyPayload(payload)) {
            AppState.isLoading = false;
          }
        },
        function () {
          AppState.isLoading = false;
          showStatus("Failed to call VRED Python helpers.", true);
        }
      );
    }

    function onHierarchyLoaded(event) {
      AppState.isLoading = false;
      const payload = parseEventDetail(event.detail);
      if (!payload) {
        return;
      }

      markMenuSupported();
      applyHierarchyPayload(payload);
    }

    function onVariantOrSelectionEvent(event) {
      const payload = parseEventDetail(event.detail);
      const selectedId = payload && (payload.selectedId || payload.id);
      if (selectedId) {
        updateSelectedVariant(selectedId);
      }
    }

    function onThumbnailBatch(event) {
      applyThumbnailBatch(parseEventDetail(event.detail));
    }

    function updateSelectedVariant(selectedId) {
      AppState.selectedId = selectedId;

      DOM.menuTree.querySelectorAll(".variant-entry").forEach(function (entry) {
        entry.classList.toggle("is-selected", entry.dataset.variantId === selectedId);
      });
    }

    function onHierarchyError(event) {
      AppState.isLoading = false;
      const payload = parseEventDetail(event.detail);
      const message = payload && payload.message
        ? payload.message
        : "Failed to load variant sets from VRED.";
      showStatus(message, true);
    }

    function bindVredEvents() {
      [
        [VRED_EVENTS.hierarchyLoaded, onHierarchyLoaded],
        [VRED_EVENTS.hierarchyError, onHierarchyError],
        [VRED_EVENTS.thumbnailBatch, onThumbnailBatch],
        [VRED_EVENTS.variantExecuted, onVariantOrSelectionEvent],
        [VRED_EVENTS.selectionChanged, onVariantOrSelectionEvent]
      ].forEach(function (entry) {
        document.addEventListener(entry[0], entry[1]);
      });
    }

    /* ================================================================== */
    /* Initialization                                                     */
    /* ================================================================== */

    function initialize() {
      bindUiEvents();
      bindVredEvents();
      focusMenuSurface();

      if (!VredBridge.hasCommand()) {
        showStatus(
          "Paste variant_menu_helpers.py into Script Editor (Edit > Script Editor), run it once, then refresh.",
          true
        );
        return;
      }

      loadVariantSets();
    }

    if (document.readyState === "loading") {
      document.addEventListener("DOMContentLoaded", initialize);
    } else {
      initialize();
    }
  </script>
</body>
</html>

Can't find what you're looking for? Ask the community or share your knowledge.

Submit Idea