"""Bake AO & Bevel - seit 1.6 fest in BaseMaterials integriert.

Ursprung: Stephans eigenstaendiges bake_ao_addon (Blender 5.2). Der Code ist
bewusst 1:1 uebernommen (gleiche Operator-IDs); das alte Add-on muss
deaktiviert werden, sonst kollidieren die IDs.
"""
import os
import re
import tempfile
from contextlib import contextmanager
from math import floor

import bpy
from bpy.types import Operator, Panel, PropertyGroup
from bpy.props import (IntProperty, PointerProperty, FloatProperty, EnumProperty,
                       BoolProperty, StringProperty)
from bpy_extras.io_utils import ExportHelper

# ---------- File Format to Extension ----------
format_to_ext = {'PNG': 'png', 'JPEG': 'jpg', 'OPEN_EXR': 'exr', 'TIFF': 'tiff'}

# FIX: Blender resets Image.file_format to PNG whenever generated_type or
# colorspace_settings is touched, and refuses to set it again afterwards.
# The intended format is therefore kept as a custom property on the image and
# enforced at save time. Without this a Bevel map meant to be 32-bit EXR was
# written as an 8-bit PNG - no error, just quantised normals.
FORMAT_KEY = "cgm_bake_format"


# Marks the nodes that belong to a live preview (bevel, AO or curvature), so
# they can be found again in any material - independent of names the user
# might change.
PREVIEW_KEY = "cgm_bevel_preview"
CURV_MIX_KEY = "cgm_curvature_mix"   # markiert den Lighten-Mix aus dem Curvature-Bake
AO_MIX_KEY = "cgm_ao_mix"            # markiert den Multiply-Mix aus dem AO-Bake
AO_CURVE_KEY = "cgm_ao_curve"        # markiert die Spread-Kurve (RGB Curves) zwischen AO-Textur und Multiply
AO_RANGE_KEY = "cgm_ao_range"        # 1.6.1-Zwischenstand (Map Range) - nur noch fuer die Migration
BEVEL_NM_KEY = "cgm_bevel_normalmap" # markiert den Normal-Map-Node aus dem Bevel-Bake (seit 1.6.3)

# Spread-Kurve: Combined-Kanal mit vier Punkten (0,0) - (0.1,0.1) - Arbeits-
# punkt bei x = 0.65 - (1,1). Der Spread-Slider hebt NUR den Arbeitspunkt
# senkrecht an: y = 0.65 + 0.30 * spread. Bei spread = 0 liegen alle vier
# Punkte auf der Diagonalen (exakte Identitaet, rohe AO-Map), bei spread = 1
# steht der Arbeitspunkt auf 0.95 - mittlere und helle Bereiche werden stark
# aufgehellt, die Verschattung zieht sich in Ecken und Kanten zurueck. Der
# Stuetzpunkt (0.1, 0.1) haelt die tiefen Schatten dabei stabil.
AO_SPREAD_X = 0.65
AO_SPREAD_HUB = 0.30


def preview_nodes(bl_idnames=None):
    """Every node currently placed by a live preview, optionally filtered."""
    for mat in bpy.data.materials:
        if not mat.use_nodes or not mat.node_tree:
            continue
        for node in mat.node_tree.nodes:
            if not node.get(PREVIEW_KEY):
                continue
            if bl_idnames and node.bl_idname not in bl_idnames:
                continue
            yield mat.node_tree, node


def preview_bevel_nodes():
    """Every bevel node currently placed by a live preview."""
    yield from preview_nodes(('ShaderNodeBevel',))


def _sync_preview(self, context):
    """Push radius/samples straight into the live preview while dragging."""
    for _nt, node in preview_bevel_nodes():
        node.inputs['Radius'].default_value = self.bevel_radius
        node.samples = self.bevel_samples


def _sync_curvature(self, context):
    """Push the contrast straight into a running curvature preview."""
    for _nt, node in preview_nodes(('ShaderNodeMath',)):
        if node.operation == 'MULTIPLY':
            node.inputs[1].default_value = self.curvature_contrast


def _materialien(context):
    """Der Materialkreis, auf den sich Erkennung und Slider beziehen.

    Mit Kontext: die Materialien der ausgewaehlten Meshes; ist nichts
    ausgewaehlt, die des aktiven Objekts; gibt es auch das nicht, LEER.
    Bewusst NIE der Rueckfall auf alle Materialien der Datei - ein
    vergessenes Testmaterial irgendwo wuerde sonst datei-weit die
    Bake-Buttons ausblenden und das Panel lahmlegen (Stephans Fall
    06.08.2026: "Buttons ausgegraut" nach Remove).
    Nur context=None (ausdruecklich, z. B. Wartung ueber die ganze
    Datei) liefert weiterhin alle Materialien.
    """
    if context is None:
        return bpy.data.materials
    sel = [o for o in context.selected_objects if o.type == 'MESH']
    if not sel:
        aktiv = getattr(context, 'active_object', None)
        if aktiv is not None and aktiv.type == 'MESH':
            sel = [aktiv]
    return {s.material for o in sel for s in o.material_slots if s.material}


def curvature_mix_nodes(context=None):
    """Alle Lighten-Mix-Nodes, die ein Curvature-Bake eingebaut hat.

    Reichweite: _materialien() - Auswahl, sonst aktives Objekt.
    """
    mats = _materialien(context)
    for mat in mats:
        if not (mat and mat.use_nodes):
            continue
        for node in mat.node_tree.nodes:
            if node.get(CURV_MIX_KEY):
                yield node


def _sync_curvature_mix(self, context):
    """Slider im N-Panel wirkt direkt auf die eingebauten Lighten-Mixe."""
    for node in curvature_mix_nodes(context):
        node.inputs['Factor'].default_value = self.curvature_mix_factor


def ao_nodes(key, context=None):
    """Alle Nodes mit dem gegebenen AO-Marker (AO_MIX_KEY/AO_CURVE_KEY).

    Gleiche Reichweite wie curvature_mix_nodes: _materialien(context).
    """
    for mat in _materialien(context):
        if not (mat and mat.use_nodes):
            continue
        for node in mat.node_tree.nodes:
            if node.get(key):
                yield mat.node_tree, node


def _sync_ao_strength(self, context):
    """Slider im N-Panel wirkt direkt auf den Factor der AO-Multiply-Mixe."""
    for _nt, node in ao_nodes(AO_MIX_KEY, context):
        node.inputs['Factor'].default_value = self.ao_strength


def _sync_ao_spread(self, context):
    """Slider im N-Panel hebt den Arbeitspunkt der Spread-Kurven an.

    points[2] ist der Arbeitspunkt bei x = 0.65 (siehe AO_SPREAD_X):
    y = 0.65 + 0.30 * spread. spread = 0 ist die Identitaet (alle Punkte
    kollinear auf der Diagonalen) - exakt die rohe AO-Map. mapping.update()
    plus update_tag() lassen den Viewport live nachziehen.

    Trifft der Sync noch einen 1.6.1-Map-Range an (Marker cgm_ao_range),
    wird das Material vorher automatisch auf die Kurve umgebaut.
    """
    ao_map_range_migrieren(context)
    y = AO_SPREAD_X + AO_SPREAD_HUB * self.ao_spread
    for nt, node in ao_nodes(AO_CURVE_KEY, context):
        node.mapping.curves[3].points[2].location = (AO_SPREAD_X, y)
        node.mapping.update()
        nt.update_tag()


def alte_ao_mixe(context=None):
    """AO-Multiply-Nodes aus Bakes vor 1.6.1 - ohne Marker, ohne Map Range.

    Erkennung am Node-Aufbau, den nur dieses Add-on baut: ein Multiply-Mix,
    dessen B-Eingang direkt an einer Image-Texture haengt, mit den vergebenen
    Labels ("AO multiply"/"AO") oder dem Bildnamen-Suffix "_AO".
    Liefert (node_tree, mix_node, tex_node).
    """
    for mat in _materialien(context):
        if not (mat and mat.use_nodes):
            continue
        for node in mat.node_tree.nodes:
            if node.bl_idname != 'ShaderNodeMix' or node.get(AO_MIX_KEY):
                continue
            if getattr(node, 'blend_type', '') != 'MULTIPLY':
                continue
            b = node.inputs['B']
            if not b.links or b.links[0].from_node.type != 'TEX_IMAGE':
                continue
            tex = b.links[0].from_node
            bild_name = tex.image.name if tex.image else ""
            if (node.label == "AO multiply" or tex.label == "AO"
                    or "_AO" in bild_name):
                yield mat.node_tree, node, tex


def bevel_normalmaps(context=None):
    """Angewandte Bevel-Bakes: der Normal-Map-Node aus attach_bevel_normal.

    Neue Bakes (seit 1.6.3) tragen den Marker BEVEL_NM_KEY; aeltere werden
    am Aufbau erkannt, den nur dieses Add-on baut - ein Normal-Map-Node,
    dessen Color an einer Image-Texture mit Label "Bevel Normal" oder dem
    Bildnamen-Suffix "_Bevel" haengt (gleiches Muster wie alte_ao_mixe).
    Liefert (node_tree, normalmap_node, tex_node oder None).
    """
    for mat in _materialien(context):
        if not (mat and mat.use_nodes):
            continue
        for node in mat.node_tree.nodes:
            if node.bl_idname != 'ShaderNodeNormalMap':
                continue
            links = node.inputs['Color'].links
            tex = links[0].from_node if links else None
            if tex is not None and tex.type != 'TEX_IMAGE':
                tex = None
            if node.get(BEVEL_NM_KEY):
                yield mat.node_tree, node, tex
                continue
            if tex is None:
                continue
            bild_name = tex.image.name if tex.image else ""
            if tex.label == "Bevel Normal" or "_Bevel" in bild_name:
                yield mat.node_tree, node, tex


# Sichtbarkeits-/Sperrlogik: "ist das Ergebnis schon angewendet?" - dieselben
# Fragen stellen Panel (Button ausblenden) und Bake-Operatoren (Doppel-Bake
# verhindern), deshalb einmal zentral.
def ao_angewendet(context=None):
    return (any(True for _ in ao_nodes(AO_MIX_KEY, context))
            or any(True for _ in alte_ao_mixe(context)))


def bevel_angewendet(context=None):
    return any(True for _ in bevel_normalmaps(context))


def curvature_angewendet(context=None):
    return any(True for _ in curvature_mix_nodes(context))


# ---------- UI Properties ----------
class BakeAOProperties(PropertyGroup):
    samples: IntProperty(
        name="Samples",
        description="Number of ray samples for baking. Higher values reduce noise but increase bake time.",
        default=64,
        min=1,
        max=1024
    )
    bevel_radius: FloatProperty(
        name="Bevel Radius",
        description="Radius of the bevel effect in meters for normal map baking.",
        default=0.001,
        min=0.0,
        max=1.0,
        subtype='DISTANCE',
        update=_sync_preview
    )
    bevel_samples: IntProperty(
        name="Bevel Samples",
        description="Number of samples for the bevel node. Higher values improve quality but slow down baking.",
        default=4,
        min=1,
        max=16,
        update=_sync_preview
    )
    preview_active: BoolProperty(
        name="Bevel preview running",
        description="Internal: a live bevel preview is currently set up",
        default=False
    )
    preview_objects: StringProperty(
        name="Preview objects",
        description="Internal: objects the preview was started for",
        default=""
    )
    preview_shading: StringProperty(
        name="Saved viewport shading",
        description="Internal: viewport shading modes to restore",
        default=""
    )
    preview_engine: StringProperty(
        name="Saved render engine",
        description="Internal: render engine to restore",
        default=""
    )
    preview_use_hdri: BoolProperty(
        name="HDRI light",
        description="Light the bevel preview with Blender's built-in studio "
                    "HDRI instead of the (often dark) scene world",
        default=True
    )
    curvature_contrast: FloatProperty(
        name="Contrast",
        description="Amplification of the edge mask in the curvature bake",
        default=8.0, min=1.0, max=50.0,
        update=_sync_curvature
    )
    curvature_mix_factor: FloatProperty(
        name="Curvature Strength",
        description="Blend factor of the baked curvature mask in the base "
                    "color (0 = off, 1 = full edge highlight)",
        default=1.0, min=0.0, max=1.0, subtype='FACTOR',
        update=_sync_curvature_mix
    )
    ao_strength: FloatProperty(
        name="AO Strength",
        description="How strongly the baked AO map darkens the base color "
                    "(factor of the multiply mix; 1 = full effect, matches "
                    "the look before the sliders existed)",
        default=1.0, min=0.0, max=1.0, subtype='FACTOR',
        update=_sync_ao_strength
    )
    ao_spread: FloatProperty(
        name="AO Spread",
        description="Lifts the mid and bright parts of the baked AO map "
                    "along a curve, so the shading pulls back into corners "
                    "and creases and open surfaces stay clean (0 = raw map, "
                    "1 = strongest lift)",
        default=0.0, min=0.0, max=1.0, subtype='FACTOR',
        update=_sync_ao_spread
    )
    preview_mode: StringProperty(
        name="Preview mode",
        description="Internal: which live preview is running (BEVEL/AO/CURVATURE)",
        default=""
    )
    texture_size: EnumProperty(
        name="Texture Size",
        description="Resolution of the baked textures.",
        items=[
            ('1024', '1K (1024x1024)', 'Low resolution for quick bakes'),
            ('2048', '2K (2048x2048)', 'Standard resolution for most uses'),
            ('4096', '4K (4096x4096)', 'High resolution for detailed models'),
            ('8192', '8K (8192x8192)', 'Ultra high resolution for large-scale assets'),
        ],
        default='2048'
    )
    file_format: EnumProperty(
        name="File Format",
        description="Output format for baked textures.",
        items=[
            ('PNG', 'PNG', 'Lossless format with good compression'),
            ('OPEN_EXR', 'OpenEXR', 'High dynamic range format, ideal for normal maps'),
            ('JPEG', 'JPEG', 'Compressed format, smaller file size but lossy'),
            ('TIFF', 'TIFF', 'High-quality format with large file sizes'),
        ],
        default='PNG'
    )


# ---------- Helper Functions ----------
def clean(name):
    return re.sub(r'_u\d+_v\d+', '', name).replace('-', '_').strip('_')


def remember_format(img, fmt):
    """Store the intended output format on the image (see FORMAT_KEY)."""
    img[FORMAT_KEY] = fmt
    img.file_format = fmt      # attempt it anyway; do not rely on it


def wanted_format(img):
    return img.get(FORMAT_KEY, img.file_format)


def set_non_color(img):
    """FIX: only assign the colorspace when it actually differs.

    On a GENERATED image the assignment discards the pixel buffer and rebuilds
    it from generated_color. Executed after a bake, it wipes the finished
    result and leaves a plain white map behind - while the operator still
    reports FINISHED. Measured: (0.509, 0.500, 0.993) before, (1.0, 1.0, 1.0)
    after.
    """
    if img.colorspace_settings.name != 'Non-Color':
        img.colorspace_settings.name = 'Non-Color'


def fill_white(img, w, h):
    """FIX: foreach_set instead of img.pixels[:] = [...].

    At 8K the old assignment built a Python list of 268 million floats, which
    takes minutes and a lot of RAM. The 8K option was effectively unusable.
    """
    img.pixels.foreach_set([1.0] * (4 * w * h))
    img.update()


@contextmanager
def image_editor_override(img):
    """Provides a temporary Image Editor context.

    FIX: the old code passed a context dictionary as the first argument to
    bpy.ops. Blender 4.0 removed that form ("ValueError: 1-2 args execution
    context is supported"), so every UDIM bake has been dead since then -
    the very feature the add-on advertises. Replaced by context.temp_override().
    """
    win = bpy.context.window
    if win is None or win.screen is None or not win.screen.areas:
        raise RuntimeError("No window available for an Image Editor context")

    area = next((a for a in win.screen.areas if a.type == 'IMAGE_EDITOR'), None)
    old_type = None
    if area is None:
        area = win.screen.areas[0]
        old_type, area.type = area.type, 'IMAGE_EDITOR'

    space = area.spaces.active
    old_img = space.image
    space.image = img
    region = next((r for r in area.regions if r.type == 'WINDOW'), area.regions[-1])
    try:
        with bpy.context.temp_override(window=win, area=area,
                                       region=region, space_data=space):
            yield
    finally:
        space.image = old_img
        if old_type:
            area.type = old_type


def ensure_udim_tiles(img, tile_numbers, w, h):
    """Creates missing UDIM tiles and initialises their pixel buffer.

    img.tiles.new() alone is not enough - the tile then exists in the list but
    has no buffer, and the bake aborts with "Uninitialized image". Measured:
    neither generated_*, nor scale(), nor save() fill it. Only tile_add /
    tile_fill do.
    """
    with image_editor_override(img):
        for number in tile_numbers:
            existing = next((i for i, t in enumerate(img.tiles)
                             if t.number == number), None)
            if existing is None:
                bpy.ops.image.tile_add(number=number, count=1, width=w, height=h,
                                       label="", float=False, alpha=False)
            elif existing > 0:          # tile 1001 comes with images.new()
                img.tiles.active_index = existing
                bpy.ops.image.tile_fill(width=w, height=h, color=(1, 1, 1, 1),
                                        alpha=False, float=False)
    img.update()


def new_udim_image(name, w, h, fmt, tiles, is_float=False):
    img = bpy.data.images.new(name=name, width=w, height=h,
                              alpha=False, float_buffer=is_float, tiled=True)
    ensure_udim_tiles(img, tiles, w, h)
    if is_float:
        set_non_color(img)
    remember_format(img, fmt)
    img.filepath_raw = os.path.join(tempfile.mkdtemp(),
                                    f"{name}.<UDIM>.{format_to_ext[fmt]}")
    return img


def new_single_image(name, w, h, fmt, is_float=False):
    img = bpy.data.images.new(name=name, width=w, height=h,
                              alpha=False, float_buffer=is_float, tiled=False)
    img.generated_type = 'BLANK'
    fill_white(img, w, h)
    if is_float:
        set_non_color(img)
    remember_format(img, fmt)
    img.filepath_raw = os.path.join(tempfile.mkdtemp(),
                                    f"{name}.{format_to_ext[fmt]}")
    return img


def set_best_device(scene):
    """Pick a GPU backend for baking.

    FIX: the old version wrote compute_device_type straight into the user
    preferences and left it there - a bake could permanently change the user's
    Cycles device setting. The previous state is now returned so the caller can
    restore it, and an already configured GPU is left alone.
    """
    addon = bpy.context.preferences.addons.get("cycles")
    if addon is None:
        scene.cycles.device = 'CPU'
        return None

    prefs = addon.preferences
    previous = (prefs.compute_device_type, scene.cycles.device)

    if prefs.compute_device_type != 'NONE':
        prefs.get_devices()
        if any(d.type != 'CPU' and d.use for d in prefs.devices):
            scene.cycles.device = 'GPU'
            return previous

    for backend in ("OPTIX", "CUDA", "HIP", "ONEAPI", "METAL"):
        try:
            prefs.compute_device_type = backend
        except (TypeError, AttributeError):     # FIX: was a bare except
            continue
        prefs.get_devices()
        if any(d.type != 'CPU' for d in prefs.devices):
            for d in prefs.devices:
                d.use = (d.type != 'CPU')
            scene.cycles.device = 'GPU'
            return previous

    scene.cycles.device = 'CPU'
    return previous


def restore_device(scene, previous):
    if not previous:
        return
    device_type, scene_device = previous
    addon = bpy.context.preferences.addons.get("cycles")
    if addon is not None:
        addon.preferences.compute_device_type = device_type
    scene.cycles.device = scene_device


def get_base_image_info(mat, default_size):
    if not mat.node_tree:
        return default_size, default_size, 'PNG', False
    for n in mat.node_tree.nodes:
        if n.type == 'BSDF_PRINCIPLED':
            bc = n.inputs['Base Color']
            if bc.links and bc.links[0].from_node.type == 'TEX_IMAGE':
                im = bc.links[0].from_node.image
                if im:                      # FIX: node can exist without image
                    return im.size[0], im.size[1], im.file_format, im.source == 'TILED'
    return default_size, default_size, 'PNG', False


def used_udim_tiles(obj):
    """FIX: floor() instead of int().

    int() truncates towards zero, so a UV at -0.3 landed in tile 1001 instead
    of 1000. Iterating uv_layer.data directly is also markedly faster than
    going through every loop index.
    """
    tiles = {1001}
    uv = obj.data.uv_layers.active
    if uv:
        for datum in uv.data:
            u, v = datum.uv
            tiles.add(1001 + floor(u) + 10 * floor(v))
    return sorted(t for t in tiles if 1001 <= t <= 2000)


def has_valid_uv_map(obj):
    return obj.data.uv_layers.active is not None


def has_valid_material(obj):
    return any(slot.material and slot.material.use_nodes for slot in obj.material_slots)


@contextmanager
def bake_settings(scene, normal=False):
    """FIX: set every bake setting the result depends on - and restore them.

    The old code set normal_space and inherited the rest from the scene. If the
    three channel mappings happen to sit on POS_Z, the bake produces an almost
    entirely WHITE normal map (measured: 9,683 of 16,384 pixels pure white) and
    still reports FINISHED. Such settings linger in a scene unnoticed.
    """
    bake = scene.render.bake
    keys = ['target', 'use_selected_to_active', 'use_cage', 'cage_extrusion',
            'margin', 'use_clear']
    if normal:
        keys += ['normal_space', 'normal_r', 'normal_g', 'normal_b']
    saved = {k: getattr(bake, k) for k in keys}
    try:
        bake.target = 'IMAGE_TEXTURES'
        bake.use_selected_to_active = False
        bake.use_cage = False
        bake.cage_extrusion = 0.0
        bake.margin = max(16, saved['margin'])
        bake.use_clear = True
        if normal:
            bake.normal_space = 'TANGENT'
            bake.normal_r, bake.normal_g, bake.normal_b = 'POS_X', 'POS_Y', 'POS_Z'
        yield bake
    finally:
        for k, v in saved.items():
            setattr(bake, k, v)


@contextmanager
def render_settings(scene, samples):
    """Engine, samples and denoising, restored no matter how we leave."""
    saved = (scene.render.engine, getattr(scene.cycles, 'samples', 128),
             scene.cycles.use_denoising)
    device_state = None
    try:
        scene.render.engine = 'CYCLES'
        scene.cycles.samples = samples
        scene.cycles.use_denoising = False      # denoising ruins baked data
        device_state = set_best_device(scene)
        yield
    finally:
        scene.render.engine, scene.cycles.samples, scene.cycles.use_denoising = saved
        restore_device(scene, device_state)


@contextmanager
def progress(wm, total):
    """FIX: the progress bar was never ended when a bake raised - the cursor
    stayed stuck in 'busy' until Blender was restarted."""
    wm.progress_begin(0, total)
    try:
        yield wm
    finally:
        wm.progress_end()


@contextmanager
def restored_material_links(entries):
    """Puts material links back even if the bake raises.

    FIX: this is the important one. Both bake paths rewire the user's node
    trees - the AO bake disconnects the Surface output, the Bevel bake replaces
    the Normal input. Without a finally clause, ANY error during the bake
    (cancelled by the user, out of GPU memory, missing UVs) left the material
    dismantled: no surface shader, a stray bevel node, the bake target still
    hanging in the tree. Silent damage to the user's file.

    entries: list of (node_tree, [nodes to remove], [(from_socket, to_socket)])
    """
    try:
        yield entries
    finally:
        for nt, temp_nodes, relinks in entries:
            for node in temp_nodes:
                try:
                    nt.nodes.remove(node)
                except (RuntimeError, ReferenceError):
                    pass
            for from_socket, to_socket in relinks:
                try:
                    for link in list(to_socket.links):
                        nt.links.remove(link)
                    nt.links.new(from_socket, to_socket)
                except (RuntimeError, ReferenceError):
                    pass


def is_fully_smooth(obj):
    """True if every face is smooth shaded - then a bevel bake stays flat."""
    polys = obj.data.polygons
    return bool(polys) and all(p.use_smooth for p in polys)


@contextmanager
def temporary_shading(mesh):
    """Marks UV seams as sharp edges for the duration of the bake.

    Why: a tangent space bake reads its tangents along the UV islands. Sharp
    edges that disagree with the seams produce visible ridges along the island
    borders.

    FIX 1 - the original ALSO forced every face to smooth here, and that is
    what made the whole feature pointless: a tangent space bake encodes the
    difference against the shading normal, and the bevel node has already
    replaced exactly that. Measured on a cube (256x256, radius 0.15):

        all faces smooth : 0 of 65,536 pixels differ from a flat map
        faces flat       : 11,676 of 65,536

    So the map came out empty by construction. The face shading is now left
    alone; the operator warns instead when an object is fully smooth.

    FIX 2 - the original restored sharp_face and every edge at the end of the
    loop body. If the bake raised in between, the user's mesh kept the changed
    shading - 'Sharp Edges' wiped across the entire model, which is not
    something you notice straight away.
    """
    saved_edges = [e.use_edge_sharp for e in mesh.edges]
    try:
        for edge in mesh.edges:
            if edge.use_seam:
                edge.use_edge_sharp = True
        mesh.update()
        yield
    finally:
        for i, val in enumerate(saved_edges):
            mesh.edges[i].use_edge_sharp = val
        mesh.update()


def attach_bake_target(nt, img):
    tex = nt.nodes.new('ShaderNodeTexImage')
    tex.image = img
    tex.select = True
    nt.nodes.active = tex
    return tex


def _ao_kurven_node(nt, spread=0.0):
    """Erzeugt den markierten Spread-Kurven-Node (RGB Curves, Combined-Kanal).

    Vier Punkte: (0,0) - Stuetzpunkt (0.1, 0.1) - Arbeitspunkt
    (0.65, 0.65 + 0.30 * spread) - (1,1). Die Auto-Handles des Nodes bleiben
    unangetastet, der weiche Bezier-Verlauf ist gewollt.
    """
    cv = nt.nodes.new('ShaderNodeRGBCurve')
    cv[AO_CURVE_KEY] = True
    cv.label = "AO spread"
    kurve = cv.mapping.curves[3]          # Combined-Kanal; (0,0)+(1,1) sind schon da
    kurve.points.new(0.1, 0.1)
    kurve.points.new(AO_SPREAD_X, AO_SPREAD_X + AO_SPREAD_HUB * spread)
    cv.mapping.update()
    return cv


def ao_kurve_einbauen(nt, tex, mul, spread=0.0):
    """Haengt die Spread-Kurve zwischen AO-Textur und Multiply-Mix.

    Neutralstellung (spread = 0) ist die exakte Identitaet - alle vier
    Kurvenpunkte liegen auf der Diagonalen, die rohe AO-Map kommt
    unveraendert durch. Der Spread-Slider hebt nur den Arbeitspunkt an.
    """
    cv = _ao_kurven_node(nt, spread)
    cv.location = ((tex.location.x + mul.location.x) / 2,
                   tex.location.y + 170)
    for link in list(mul.inputs['B'].links):
        nt.links.remove(link)
    nt.links.new(tex.outputs['Color'], cv.inputs['Color'])
    nt.links.new(cv.outputs['Color'], mul.inputs['B'])
    return cv


def ao_map_range_migrieren(context=None):
    """1.6.1-Aufbau -> 1.6.2: ersetzt den Map Range durch die Spread-Kurve.

    Der Map Range (Marker cgm_ao_range) war nur einen Vormittag im Umlauf;
    sein Spread-Wert wird bewusst nicht uebersetzt, die Kurve startet
    neutral (spread = 0). Der Umbau geschieht an Ort und Stelle: gleiche
    Position, gleiche Verkabelung (Textur -> Kurve -> Multiply-B).
    Rueckgabe: Anzahl ersetzter Nodes.
    """
    umgebaut = 0
    for nt, mr in list(ao_nodes(AO_RANGE_KEY, context)):
        quelle = mr.inputs['Value'].links
        tex = quelle[0].from_node if quelle else None
        ziele = [l.to_socket for l in mr.outputs['Result'].links]
        ort = tuple(mr.location)
        nt.nodes.remove(mr)
        cv = _ao_kurven_node(nt, spread=0.0)
        cv.location = ort
        if tex is not None:
            nt.links.new(tex.outputs['Color'], cv.inputs['Color'])
        for sock in ziele:
            nt.links.new(cv.outputs['Color'], sock)
        nt.update_tag()
        umgebaut += 1
    return umgebaut


def multiply_ao_into_base_color(nt, img, strength=1.0, spread=0.0):
    bsdf = next((n for n in nt.nodes if n.type == 'BSDF_PRINCIPLED'), None)
    if not bsdf:
        return
    bc = bsdf.inputs['Base Color']

    ao_tex = nt.nodes.new('ShaderNodeTexImage')
    ao_tex.image = img
    ao_tex.label = "AO"
    ao_tex.location = (bsdf.location.x - 700, bsdf.location.y - 300)

    # FIX: ShaderNodeMixRGB has been legacy since Blender 3.4 and no longer
    # appears in the Add menu, so the node the add-on inserted could not be
    # rebuilt by hand. ShaderNodeMix uses Factor/A/B instead of Fac/Color1/Color2.
    mul = nt.nodes.new('ShaderNodeMix')
    mul.data_type = 'RGBA'
    mul.blend_type = 'MULTIPLY'
    mul.label = "AO multiply"
    mul[AO_MIX_KEY] = True               # Anker fuer den Staerke-Slider im Panel
    mul.location = (bsdf.location.x - 350, bsdf.location.y - 200)
    mul.inputs['Factor'].default_value = strength
    nt.links.new(ao_tex.outputs['Color'], mul.inputs['B'])
    # Slider "AO Spread" braucht die Spread-Kurve zwischen Textur und Multiply.
    ao_kurve_einbauen(nt, ao_tex, mul, spread)

    if bc.links:
        fs = bc.links[0].from_socket
        nt.links.remove(bc.links[0])
        nt.links.new(fs, mul.inputs['A'])
    else:
        rgb = nt.nodes.new('ShaderNodeRGB')
        rgb.outputs['Color'].default_value = bc.default_value
        rgb.location = (mul.location.x - 250, mul.location.y)
        nt.links.new(rgb.outputs['Color'], mul.inputs['A'])

    nt.links.new(mul.outputs['Result'], bc)


def lighten_curvature_into_base_color(nt, img, factor=1.0):
    """Haengt die gebackene Kantenmaske als Lighten-Mix vor die Base Color.

    Gleiche Mechanik wie multiply_ao_into_base_color, nur mit LIGHTEN:
    weisse Kanten der Maske hellen die bisherige Farbe auf (Abnutzungslook).
    """
    bsdf = next((n for n in nt.nodes if n.type == 'BSDF_PRINCIPLED'), None)
    if not bsdf:
        return
    bc = bsdf.inputs['Base Color']

    cur_tex = nt.nodes.new('ShaderNodeTexImage')
    cur_tex.image = img
    cur_tex.label = "Curvature"
    cur_tex.location = (bsdf.location.x - 700, bsdf.location.y - 600)

    mix = nt.nodes.new('ShaderNodeMix')
    mix.data_type = 'RGBA'
    mix.blend_type = 'LIGHTEN'
    mix.label = "Curvature lighten"
    mix[CURV_MIX_KEY] = True             # Anker fuer den Staerke-Slider im Panel
    mix.location = (bsdf.location.x - 350, bsdf.location.y - 500)
    mix.inputs['Factor'].default_value = factor
    nt.links.new(cur_tex.outputs['Color'], mix.inputs['B'])

    if bc.links:
        fs = bc.links[0].from_socket
        nt.links.remove(bc.links[0])
        nt.links.new(fs, mix.inputs['A'])
    else:
        rgb = nt.nodes.new('ShaderNodeRGB')
        rgb.outputs['Color'].default_value = bc.default_value
        rgb.location = (mix.location.x - 250, mix.location.y)
        nt.links.new(rgb.outputs['Color'], mix.inputs['A'])

    nt.links.new(mix.outputs['Result'], bc)


def attach_bevel_normal(nt, img, bsdf):
    bevel_tex = nt.nodes.new('ShaderNodeTexImage')
    bevel_tex.image = img
    set_non_color(bevel_tex.image)
    bevel_tex.label = "Bevel Normal"
    bevel_tex.location = (bsdf.location.x - 700, bsdf.location.y - 700)

    normal_map = nt.nodes.new('ShaderNodeNormalMap')
    normal_map[BEVEL_NM_KEY] = True      # Anker fuer Erkennung/Entfernen
    normal_map.space = 'TANGENT'
    normal_map.location = (bsdf.location.x - 350, bsdf.location.y - 700)
    nt.links.new(bevel_tex.outputs['Color'], normal_map.inputs['Color'])
    for link in list(bsdf.inputs['Normal'].links):
        nt.links.remove(link)
    nt.links.new(normal_map.outputs['Normal'], bsdf.inputs['Normal'])


def collect_targets(ctx):
    return [obj for obj in ctx.selected_objects
            if obj.type == 'MESH' and has_valid_uv_map(obj) and has_valid_material(obj)]


def drop_empty_slots(obj):
    for i in reversed(range(len(obj.material_slots))):
        if obj.material_slots[i].material is None:
            obj.active_material_index = i
            bpy.ops.object.material_slot_remove()


def resolution_for(obj, props):
    default_size = int(props.texture_size)
    for m in obj.data.materials:
        if m:
            w, h, _, is_udim = get_base_image_info(m, default_size)
            return w, h, is_udim
    return default_size, default_size, False


# ---------- Live preview ----------
#
# Pressing "Bake Bevel" no longer bakes straight away. It wires a bevel node
# into every material, switches the viewport to rendered Cycles and stops
# there, so the radius can be judged by eye before anything is computed. Two
# buttons appear: bake, or cancel.
#
# The previous Normal link is not stored anywhere - it is moved INTO the bevel
# node's own Normal input. Undoing the preview therefore only means: take
# whatever feeds the bevel node, put it back on the BSDF, delete the node. That
# survives a file reload, undo steps and renamed materials, because the state
# lives in the node tree itself.

def _preview_viewport_ein(context, props):
    """Merkt Engine + Shading jeder 3D-Ansicht und schaltet auf Cycles-RENDERED.

    Optional wird die Vorschau mit Blenders mitgelieferter Studio-HDRI
    beleuchtet - die Standard-Szenenwelt ist so dunkel, dass Bevel/AO kaum
    zu beurteilen sind. Die Szene selbst bleibt unberuehrt.
    """
    props.preview_engine = context.scene.render.engine
    modes = []
    for area in context.screen.areas:
        if area.type == 'VIEW_3D':
            shading = area.spaces.active.shading
            modes.append("|".join((
                shading.type,
                str(shading.use_scene_world_render),
                shading.studio_light,
                f"{shading.studiolight_intensity:.3f}",
            )))
            shading.type = 'RENDERED'
            if props.preview_use_hdri:
                shading.use_scene_world_render = False
                try:
                    shading.studio_light = 'forest.exr'   # wird mit Blender ausgeliefert
                except TypeError:
                    pass
                shading.studiolight_intensity = 1.0
    props.preview_shading = ",".join(modes)
    context.scene.render.engine = 'CYCLES'


def _preview_viewport_aus(context, props):
    if props.preview_engine:
        context.scene.render.engine = props.preview_engine
    modes = [m for m in props.preview_shading.split(",") if m]
    views = [a for a in context.screen.areas if a.type == 'VIEW_3D']
    for area, eintrag in zip(views, modes):
        shading = area.spaces.active.shading
        teile = eintrag.split("|")
        shading.type = teile[0]
        if len(teile) == 4:      # Format mit HDRI-Zustand
            shading.use_scene_world_render = (teile[1] == "True")
            try:
                shading.studio_light = teile[2]
            except TypeError:
                pass
            shading.studiolight_intensity = float(teile[3])
    props.preview_shading = ""
    props.preview_engine = ""


def kanten_maske_kette(nt, props, ort):
    """Baut die Kantenmasken-Kette Bevel -> dot -> (1-x) -> Kontrast.

    Rueckgabe: (liste_aller_knoten, kontrast_node). Der Kontrast-Node liefert
    die Maske als Value (0 = Flaeche, 1 = Kante).
    """
    bevel = nt.nodes.new('ShaderNodeBevel')
    bevel.inputs['Radius'].default_value = props.bevel_radius
    bevel.samples = props.bevel_samples
    bevel.location = (ort[0] - 900, ort[1] - 500)
    geom = nt.nodes.new('ShaderNodeNewGeometry')
    geom.location = (ort[0] - 900, ort[1] - 700)
    dot = nt.nodes.new('ShaderNodeVectorMath')
    dot.operation = 'DOT_PRODUCT'
    dot.location = (ort[0] - 700, ort[1] - 600)
    nt.links.new(bevel.outputs['Normal'], dot.inputs[0])
    nt.links.new(geom.outputs['Normal'], dot.inputs[1])
    sub = nt.nodes.new('ShaderNodeMath')
    sub.operation = 'SUBTRACT'
    sub.inputs[0].default_value = 1.0
    sub.location = (ort[0] - 520, ort[1] - 600)
    nt.links.new(dot.outputs['Value'], sub.inputs[1])
    mul = nt.nodes.new('ShaderNodeMath')
    mul.operation = 'MULTIPLY'
    mul.use_clamp = True
    mul.inputs[1].default_value = props.curvature_contrast
    mul.location = (ort[0] - 340, ort[1] - 600)
    nt.links.new(sub.outputs['Value'], mul.inputs[0])
    return [bevel, geom, dot, sub, mul], mul


def enter_bevel_preview(context, targets, props):
    """Wires the preview in. Returns the number of materials touched."""
    touched = 0
    for obj in targets:
        for slot in obj.material_slots:
            mat = slot.material
            if not (mat and mat.use_nodes):
                continue
            nt = mat.node_tree
            bsdf = next((n for n in nt.nodes if n.type == 'BSDF_PRINCIPLED'), None)
            if not bsdf:
                continue
            if any(n.bl_idname == 'ShaderNodeBevel' and n.get(PREVIEW_KEY)
                   for n in nt.nodes):
                continue                      # already previewing this material

            normal_in = bsdf.inputs['Normal']
            bevel = nt.nodes.new('ShaderNodeBevel')
            bevel[PREVIEW_KEY] = True
            bevel.label = "Bevel Preview"
            bevel.location = (bsdf.location.x - 300, bsdf.location.y - 500)
            bevel.inputs['Radius'].default_value = props.bevel_radius
            bevel.samples = props.bevel_samples
            if normal_in.links:
                link = normal_in.links[0]
                nt.links.new(link.from_socket, bevel.inputs['Normal'])
                nt.links.remove(link)
            nt.links.new(bevel.outputs['Normal'], normal_in)
            touched += 1

    props.preview_objects = "\n".join(o.name for o in targets)
    _preview_viewport_ein(context, props)
    props.preview_active = True
    props.preview_mode = 'BEVEL'
    return touched


def enter_curvature_preview(context, targets, props):
    """Curvature-Vorschau: weisse Konturen auf Schwarz.

    Die Kantenmaske geht als Emission direkt an den Material-Output - genau
    das Bild, das der Bake nachher liefert. Der Original-Shader bleibt im
    Mix-Shader (Eingang 1) aufbewahrt und kommt beim Beenden zurueck.
    """
    touched = 0
    for obj in targets:
        for slot in obj.material_slots:
            mat = slot.material
            if not (mat and mat.use_nodes):
                continue
            nt = mat.node_tree
            out = next((n for n in nt.nodes
                        if n.type == 'OUTPUT_MATERIAL' and n.is_active_output), None)
            if not out or not out.inputs['Surface'].links:
                continue
            if any(n.bl_idname == 'ShaderNodeMixShader' and n.get(PREVIEW_KEY)
                   for n in nt.nodes):
                continue
            surf = out.inputs['Surface']
            link = surf.links[0]

            kette, mul = kanten_maske_kette(nt, props, out.location)
            emit = nt.nodes.new('ShaderNodeEmission')
            emit.location = (out.location.x - 300, out.location.y - 250)
            nt.links.new(mul.outputs['Value'], emit.inputs['Color'])
            mixsh = nt.nodes.new('ShaderNodeMixShader')
            mixsh.inputs['Fac'].default_value = 1.0
            mixsh.location = (out.location.x - 150, out.location.y)
            for node in kette + [emit, mixsh]:
                node[PREVIEW_KEY] = True
                node.label = "Curvature Preview"
            nt.links.new(link.from_socket, mixsh.inputs[1])   # Original aufbewahrt
            nt.links.remove(link)
            nt.links.new(emit.outputs['Emission'], mixsh.inputs[2])
            nt.links.new(mixsh.outputs['Shader'], surf)
            touched += 1

    props.preview_objects = "\n".join(o.name for o in targets)
    _preview_viewport_ein(context, props)
    props.preview_active = True
    props.preview_mode = 'CURVATURE'
    return touched


def leave_preview(context, props):
    """Baut JEDE laufende Vorschau (Bevel/AO/Curvature) ab und stellt
    Links und Viewport wieder her."""
    # Anschluss-Knoten: an welchem Eingang haengt das Original?
    QUELLE = {
        'ShaderNodeBevel': 'Normal',
        'ShaderNodeMixShader': 1,       # Shader-Eingang 1 = Original
        'ShaderNodeMix': 'A',
    }
    relinks, zu_entfernen = [], []
    for nt, node in list(preview_nodes()):
        zu_entfernen.append((nt, node))
        eingang = QUELLE.get(node.bl_idname)
        if eingang is None:
            continue                     # Hilfsknoten der Masken-Kette
        sock = node.inputs[eingang]
        source = sock.links[0].from_socket if sock.links else None
        if source is not None and source.node.get(PREVIEW_KEY):
            source = None
        targets = [l.to_socket for out in node.outputs for l in out.links
                   if not l.to_node.get(PREVIEW_KEY)]
        if source and targets:
            relinks.append((nt, source, targets))
    for nt, node in zu_entfernen:
        try:
            nt.nodes.remove(node)
        except (RuntimeError, ReferenceError):
            pass
    for nt, source, targets in relinks:
        for socket in targets:
            try:
                nt.links.new(source, socket)
            except (RuntimeError, ReferenceError):
                pass

    _preview_viewport_aus(context, props)
    props.preview_active = False
    props.preview_mode = ""
    props.preview_objects = ""


# Rueckwaertskompatibler Name - die Bevel-Operatoren rufen ihn weiter auf.
def leave_bevel_preview(context, props):
    leave_preview(context, props)


class BevelPreviewStartOperator(Operator):
    bl_idname = "object.bevel_preview_start"
    bl_label = "Bake Bevel"
    bl_options = {'REGISTER', 'UNDO'}
    bl_description = ("Sets up a live Cycles preview of the bevel. Adjust the "
                      "radius, then bake or cancel")

    @classmethod
    def poll(cls, context):
        return context.mode == 'OBJECT' and bool(context.selected_objects)

    def execute(self, ctx):
        props = ctx.scene.bake_ao_props
        targets = collect_targets(ctx)
        if not targets:
            self.report({'ERROR'}, "No valid mesh objects with UV maps and materials found!")
            return {'CANCELLED'}

        # Doppel-Bake-Sperre (wie im Bake selbst): gar nicht erst in die
        # Vorschau, wenn schon eine Bevel-Normal-Map angewendet ist.
        if bevel_angewendet(ctx):
            self.report({'ERROR'},
                        "A bevel normal map is already applied to this "
                        "selection - use 'Remove baked Bevel' first to "
                        "bake again.")
            return {'CANCELLED'}

        smooth_objs = [o.name for o in targets if is_fully_smooth(o)]
        if smooth_objs:
            self.report({'WARNING'},
                        f"Fully smooth shaded: {', '.join(smooth_objs[:3])}"
                        f"{' ...' if len(smooth_objs) > 3 else ''}. A bevel bake "
                        "has no effect there - the bevel normal cancels out "
                        "against the shading normal. Use Shade Flat on the hard "
                        "surface parts.")

        added = enter_bevel_preview(ctx, targets, props)
        total = sum(1 for _ in preview_bevel_nodes())
        if not total:
            # Nothing to preview at all - undo the viewport switch as well.
            leave_bevel_preview(ctx, props)
            self.report({'ERROR'}, "No Principled BSDF found to preview.")
            return {'CANCELLED'}
        if not added:
            # Pressing the button again with a preview already running must not
            # tear it down - just say it is still there.
            self.report({'INFO'}, f"Bevel preview already running on {total} material(s)")
            return {'FINISHED'}

        self.report({'INFO'},
                    f"Live preview on {total} material(s) - adjust Bevel Radius, "
                    f"then press Bake")
        return {'FINISHED'}


class BevelPreviewCancelOperator(Operator):
    bl_idname = "object.bevel_preview_cancel"
    bl_label = "Cancel"
    bl_options = {'REGISTER', 'UNDO'}
    bl_description = "Discards the preview and restores materials and viewport"

    def execute(self, ctx):
        leave_bevel_preview(ctx, ctx.scene.bake_ao_props)
        self.report({'INFO'}, "Bevel preview discarded")
        return {'FINISHED'}


class BevelPreviewConfirmOperator(Operator):
    bl_idname = "object.bevel_preview_confirm"
    bl_label = "Bake"
    bl_options = {'REGISTER', 'UNDO'}
    bl_description = "Bakes the bevel normal map with the previewed settings"

    def execute(self, ctx):
        props = ctx.scene.bake_ao_props
        names = [n for n in props.preview_objects.split("\n") if n]
        objs = [bpy.data.objects[n] for n in names if n in bpy.data.objects]

        # Tear the preview down completely first, then bake from a clean node
        # tree. The bake builds its own wiring, so leaving preview nodes in
        # place would stack two bevel nodes in series. Restoring the viewport
        # before baking also stops Cycles from rendering the preview alongside.
        leave_bevel_preview(ctx, props)

        if not objs:
            self.report({'ERROR'}, "The previewed objects are gone.")
            return {'CANCELLED'}

        bpy.ops.object.select_all(action='DESELECT')
        for o in objs:
            o.select_set(True)
        ctx.view_layer.objects.active = objs[0]
        return bpy.ops.object.bake_bevel()


class CurvaturePreviewStartOperator(Operator):
    bl_idname = "object.curvature_preview_start"
    bl_label = "Preview Curvature"
    bl_options = {'REGISTER', 'UNDO'}
    bl_description = ("Live preview of the worn-edge mask: bright edges are "
                      "mixed onto the current color (Lighten). Adjust radius "
                      "and contrast, then bake")

    @classmethod
    def poll(cls, context):
        return context.mode == 'OBJECT' and bool(context.selected_objects)

    def execute(self, ctx):
        props = ctx.scene.bake_ao_props
        if props.preview_active:
            leave_preview(ctx, props)     # anderer Modus lief noch
        targets = collect_targets(ctx)
        if not targets:
            self.report({'ERROR'}, "No valid mesh objects with UV maps and materials found!")
            return {'CANCELLED'}
        # Doppel-Bake-Sperre (wie im Bake selbst): gar nicht erst in die
        # Vorschau, wenn schon eine Curvature-Maske angewendet ist.
        if curvature_angewendet(ctx):
            self.report({'ERROR'},
                        "A curvature mask is already applied to this "
                        "selection - use 'Remove baked Curvature' first to "
                        "bake again.")
            return {'CANCELLED'}
        smooth_objs = [o.name for o in targets if is_fully_smooth(o)]
        if smooth_objs:
            self.report({'WARNING'},
                        f"Fully smooth shaded: {', '.join(smooth_objs[:3])}"
                        f"{' ...' if len(smooth_objs) > 3 else ''}. The edge mask "
                        "stays empty there - use Shade Flat on the hard surface parts.")
        touched = enter_curvature_preview(ctx, targets, props)
        if not touched:
            leave_preview(ctx, props)
            self.report({'ERROR'}, "No Principled BSDF found to preview.")
            return {'CANCELLED'}
        self.report({'INFO'},
                    f"Curvature preview on {touched} material(s) - adjust radius "
                    f"and contrast, then press Bake")
        return {'FINISHED'}


class CurvaturePreviewCancelOperator(Operator):
    bl_idname = "object.curvature_preview_cancel"
    bl_label = "Cancel"
    bl_options = {'REGISTER', 'UNDO'}
    bl_description = "Discards the curvature preview and restores materials and viewport"

    def execute(self, ctx):
        leave_preview(ctx, ctx.scene.bake_ao_props)
        self.report({'INFO'}, "Curvature preview discarded")
        return {'FINISHED'}


class CurvaturePreviewConfirmOperator(Operator):
    bl_idname = "object.curvature_preview_confirm"
    bl_label = "Bake"
    bl_options = {'REGISTER', 'UNDO'}
    bl_description = ("Bakes the curvature mask with the previewed settings "
                      "and mixes it onto the Base Color (Lighten)")

    def execute(self, ctx):
        props = ctx.scene.bake_ao_props
        names = [n for n in props.preview_objects.split("\n") if n]
        objs = [bpy.data.objects[n] for n in names if n in bpy.data.objects]
        leave_preview(ctx, props)
        if not objs:
            self.report({'ERROR'}, "The previewed objects are gone.")
            return {'CANCELLED'}
        bpy.ops.object.select_all(action='DESELECT')
        for o in objs:
            o.select_set(True)
        ctx.view_layer.objects.active = objs[0]
        return bpy.ops.object.bake_curvature()


# ---------- Smart UV Unwrap Operator ----------
class SmartUVUnwrapOperator(Operator):
    bl_idname = "object.smart_uv_unwrap"
    bl_label = "Smart UV Unwrap"
    bl_options = {'REGISTER', 'UNDO'}
    bl_description = "Unwraps the mesh using Smart UV Project with 66° angle limit and 0.01 island margin."

    @classmethod
    def poll(cls, context):
        obj = context.active_object
        return obj is not None and obj.type == 'MESH'

    def execute(self, ctx):
        if ctx.mode != 'OBJECT':
            bpy.ops.object.mode_set(mode='OBJECT')

        if not ctx.selected_objects:
            self.report({'ERROR'}, "No object selected! Please select a mesh object.")
            return {'CANCELLED'}

        obj = ctx.active_object
        if not obj or obj.type != 'MESH':
            self.report({'ERROR'}, "Active object must be a mesh! Please select a mesh object.")
            return {'CANCELLED'}

        bpy.ops.object.mode_set(mode='EDIT')
        try:
            bpy.ops.mesh.select_all(action='SELECT')
            # FIX: angle_limit is radians in the Python API. The literal 66.0
            # was silently clamped to the maximum, so the "66°" in the
            # description was never what actually ran.
            bpy.ops.uv.smart_project(
                angle_limit=1.1519,          # 66 degrees
                island_margin=0.01,
                area_weight=0.0,
                correct_aspect=True,
                scale_to_bounds=False
            )
        finally:
            bpy.ops.object.mode_set(mode='OBJECT')

        self.report({'INFO'}, "Smart UV Unwrap completed successfully")
        return {'FINISHED'}


# ---------- Preview Last Baked Image Operator ----------
class PreviewBakeTextureOperator(Operator):
    bl_idname = "object.preview_bake_texture"
    bl_label = "Preview Last Texture"
    bl_options = {'REGISTER'}
    bl_description = "Displays the last baked texture in the Image Editor."

    @classmethod
    def poll(cls, context):
        return context.scene.bake_ao_last_image is not None

    def execute(self, ctx):
        img = ctx.scene.bake_ao_last_image
        if not img:
            self.report({'ERROR'}, "No baked texture to preview! Bake a texture first.")
            return {'CANCELLED'}

        area = next((a for a in ctx.screen.areas if a.type == 'IMAGE_EDITOR'), None)
        if not area:
            # FIX: never hijack the 3D viewport - that is what the user is
            # looking at. Prefer a large, expendable area instead.
            candidates = [a for a in ctx.screen.areas
                          if a.type not in ('VIEW_3D', 'PROPERTIES', 'OUTLINER')]
            area = max(candidates or ctx.screen.areas,
                       key=lambda a: a.width * a.height)
            area.type = 'IMAGE_EDITOR'

        area.spaces.active.image = img
        self.report({'INFO'}, f"Previewing {img.name} in Image Editor")
        return {'FINISHED'}


# ---------- AO Slider Retrofit ----------
class AOSliderUpgradeOperator(Operator):
    bl_idname = "object.ao_slider_upgrade"
    bl_label = "Enable AO Sliders"
    bl_options = {'REGISTER', 'UNDO'}
    bl_description = ("Retrofits materials whose AO map was baked with an "
                      "older version: tags the multiply node and inserts the "
                      "spread curve the sliders drive. The look does not "
                      "change")

    def execute(self, ctx):
        props = ctx.scene.bake_ao_props
        umgeruestet = 0
        erster_faktor = None
        for nt, mix, tex in list(alte_ao_mixe(ctx)):
            mix[AO_MIX_KEY] = True
            if erster_faktor is None:
                erster_faktor = mix.inputs['Factor'].default_value
            ao_kurve_einbauen(nt, tex, mix, spread=0.0)
            umgeruestet += 1
        # 1.6.1-Zwischenstand (Map Range) gleich mit auf die Kurve umbauen.
        migriert = ao_map_range_migrieren(ctx)
        if not umgeruestet and not migriert:
            self.report({'INFO'}, "No old AO setup found to retrofit.")
            return {'CANCELLED'}
        # Slider auf den vorgefundenen Zustand stellen, damit der erste Griff
        # ans Panel keinen Sprung macht. (Loest die Sync-Callbacks aus - bei
        # den frisch nachgeruesteten Nodes schreibt das denselben Wert zurueck.)
        if erster_faktor is not None:
            props.ao_strength = erster_faktor
        props.ao_spread = 0.0
        self.report({'INFO'},
                    f"AO sliders enabled on {umgeruestet + migriert} material(s)")
        return {'FINISHED'}


# ---------- Remove Baked Result ----------
def _mix_einbindung_entfernen(nt, mix):
    """Loest einen eingebauten Mix (AO-Multiply/Curvature-Lighten) sauber aus.

    Entfernt den Mix, einen Zwischen-Node am B-Eingang (Spread-Kurve bzw.
    1.6.1-Map-Range) und die Textur davor. Der A-Eingang - die urspruengliche
    Farbe - kommt wieder direkt an die Base Color. Hatte der Einbau einen
    RGB-Ersatzknoten fuer eine unverkabelte Base Color angelegt, wird dessen
    Wert in den default_value zurueckgeschrieben und der Knoten mit entfernt.
    """
    entfernen = [mix]
    b = mix.inputs['B']
    node = b.links[0].from_node if b.links else None
    if node is not None and node.bl_idname in ('ShaderNodeRGBCurve',
                                               'ShaderNodeMapRange'):
        entfernen.append(node)
        eingang = node.inputs['Color'] if node.bl_idname == 'ShaderNodeRGBCurve' \
            else node.inputs['Value']
        node = eingang.links[0].from_node if eingang.links else None
    if node is not None and node.type == 'TEX_IMAGE':
        entfernen.append(node)

    a = mix.inputs['A']
    quelle = a.links[0].from_socket if a.links else None
    ziele = [l.to_socket for out in mix.outputs for l in out.links]
    if (quelle is not None and quelle.node.bl_idname == 'ShaderNodeRGB'
            and sum(len(o.links) for o in quelle.node.outputs) == 1):
        farbe = tuple(quelle.node.outputs['Color'].default_value)
        for sock in ziele:
            try:
                sock.default_value = farbe
            except (TypeError, ValueError):
                pass
        entfernen.append(quelle.node)
        quelle = None

    for n in entfernen:
        try:
            nt.nodes.remove(n)
        except (RuntimeError, ReferenceError):
            pass
    if quelle is not None:
        for sock in ziele:
            try:
                nt.links.new(quelle, sock)
            except (RuntimeError, ReferenceError):
                pass


class BakeResultRemoveOperator(Operator):
    bl_idname = "object.bake_result_remove"
    bl_label = "Remove Baked Result"
    bl_options = {'REGISTER', 'UNDO'}
    bl_description = ("Removes the applied bake nodes from the materials of "
                      "the selection, so the bake button comes back (e.g. "
                      "after the geometry changed). The baked image itself "
                      "is kept")

    art: EnumProperty(
        name="Result",
        items=[('AO', "AO", "Remove the AO multiply mix, spread curve and texture node"),
               ('BEVEL', "Bevel", "Remove the bevel normal map and texture node"),
               ('CURVATURE', "Curvature", "Remove the curvature lighten mix and texture node")],
        default='AO'
    )

    def execute(self, ctx):
        entfernt = 0
        if self.art == 'AO':
            mixe = list(ao_nodes(AO_MIX_KEY, ctx))
            mixe += [(nt, mix) for nt, mix, _tex in alte_ao_mixe(ctx)]
            for nt, mix in mixe:
                _mix_einbindung_entfernen(nt, mix)
                entfernt += 1
            # Verwaiste Spread-Kurven/Map-Ranges (sollte es nicht geben) mit weg.
            for nt, node in (list(ao_nodes(AO_CURVE_KEY, ctx))
                             + list(ao_nodes(AO_RANGE_KEY, ctx))):
                try:
                    nt.nodes.remove(node)
                except (RuntimeError, ReferenceError):
                    pass
        elif self.art == 'CURVATURE':
            for nt, mix in list(ao_nodes(CURV_MIX_KEY, ctx)):
                _mix_einbindung_entfernen(nt, mix)
                entfernt += 1
        else:  # BEVEL
            for nt, nm, tex in list(bevel_normalmaps(ctx)):
                for n in (nm, tex):
                    if n is None:
                        continue
                    try:
                        nt.nodes.remove(n)
                    except (RuntimeError, ReferenceError):
                        pass
                entfernt += 1
        name = {'AO': "AO", 'BEVEL': "Bevel", 'CURVATURE': "Curvature"}[self.art]
        if not entfernt:
            self.report({'INFO'}, f"No applied {name} result found.")
            return {'CANCELLED'}
        self.report({'INFO'}, f"Removed {name} from {entfernt} material(s)")
        return {'FINISHED'}


# ---------- AO Bake Operator ----------
class BakeAOOperator(Operator):
    bl_idname = "object.bake_ao"
    bl_label = "Bake AO to New Texture"
    bl_options = {'REGISTER', 'UNDO'}
    bl_description = "Bakes an Ambient Occlusion texture for all selected meshes and applies it to their materials."

    @classmethod
    def poll(cls, context):
        return context.mode == 'OBJECT' and bool(context.selected_objects)

    def execute(self, ctx):
        if ctx.mode != 'OBJECT':
            bpy.ops.object.mode_set(mode='OBJECT')

        if not ctx.selected_objects:
            self.report({'ERROR'}, "No objects selected! Please select one or more mesh objects.")
            return {'CANCELLED'}

        scn, props = ctx.scene, ctx.scene.bake_ao_props
        targets = collect_targets(ctx)
        if not targets:
            self.report({'ERROR'}, "No valid mesh objects with UV maps and materials found!")
            return {'CANCELLED'}

        # Doppel-Bake-Sperre: sonst stapeln sich Multiply auf Multiply.
        if ao_angewendet(ctx):
            self.report({'ERROR'},
                        "AO is already applied to this selection - use "
                        "'Remove baked AO' first to bake again.")
            return {'CANCELLED'}

        active_before = ctx.view_layer.objects.active
        baked = 0
        try:
            with render_settings(scn, props.samples), progress(ctx.window_manager, len(targets)) as wm:
                for idx, obj in enumerate(targets):
                    wm.progress_update(idx)
                    ctx.view_layer.objects.active = obj
                    drop_empty_slots(obj)

                    tiles = used_udim_tiles(obj)
                    w, h, is_udim = resolution_for(obj, props)
                    fmt = props.file_format
                    name = f"{clean(obj.name)}_AO"
                    ao_img = (new_udim_image(name, w, h, fmt, tiles) if is_udim
                              else new_single_image(name, w, h, fmt))

                    entries = []
                    for slot in obj.material_slots:
                        m = slot.material
                        if not (m and m.use_nodes):
                            continue
                        nt = m.node_tree
                        relinks = []
                        out = next((n for n in nt.nodes if n.type == 'OUTPUT_MATERIAL'), None)
                        if out and out.inputs['Surface'].links:
                            link = out.inputs['Surface'].links[0]
                            relinks.append((link.from_socket, out.inputs['Surface']))
                            nt.links.remove(link)
                        tex = attach_bake_target(nt, ao_img)
                        if out:
                            nt.links.new(tex.outputs['Color'], out.inputs['Surface'])
                        entries.append((nt, [tex], relinks))

                    with restored_material_links(entries), bake_settings(scn):
                        bpy.ops.object.bake(type='AO')

                    for nt, _, _ in entries:
                        multiply_ao_into_base_color(nt, ao_img,
                                                    props.ao_strength,
                                                    props.ao_spread)

                    ctx.scene.bake_ao_last_image = ao_img
                    baked += 1
        except RuntimeError as exc:
            self.report({'ERROR'}, f"AO bake failed: {exc}")
            return {'CANCELLED'}
        finally:
            if active_before:
                ctx.view_layer.objects.active = active_before

        self.report({'INFO'}, f"AO bake finished for {baked} object(s)")
        return {'FINISHED'}


# ---------- Bevel Bake Operator ----------
class BakeBevelOperator(Operator):
    bl_idname = "object.bake_bevel"
    bl_label = "Bake Bevel to New Texture"
    bl_options = {'REGISTER', 'UNDO'}
    bl_description = "Bakes a Bevel normal map for all selected meshes and applies it to their materials."

    @classmethod
    def poll(cls, context):
        return context.mode == 'OBJECT' and bool(context.selected_objects)

    def execute(self, ctx):
        if ctx.mode != 'OBJECT':
            bpy.ops.object.mode_set(mode='OBJECT')

        if not ctx.selected_objects:
            self.report({'ERROR'}, "No objects selected! Please select one or more mesh objects.")
            return {'CANCELLED'}

        scn, props = ctx.scene, ctx.scene.bake_ao_props
        targets = collect_targets(ctx)
        if not targets:
            self.report({'ERROR'}, "No valid mesh objects with UV maps and materials found!")
            return {'CANCELLED'}

        # Doppel-Bake-Sperre: sonst stapeln sich zwei Normal Maps.
        if bevel_angewendet(ctx):
            self.report({'ERROR'},
                        "A bevel normal map is already applied to this "
                        "selection - use 'Remove baked Bevel' first to "
                        "bake again.")
            return {'CANCELLED'}

        if props.bevel_radius <= 0.0:
            self.report({'WARNING'},
                        "Bevel Radius is 0 - the map will come out flat. "
                        "Try roughly 1/1000 of the object size.")

        # Say it up front instead of handing back an empty map that looks fine.
        smooth_objs = [o.name for o in targets if is_fully_smooth(o)]
        if smooth_objs:
            self.report({'WARNING'},
                        f"Fully smooth shaded: {', '.join(smooth_objs[:3])}"
                        f"{' ...' if len(smooth_objs) > 3 else ''}. A bevel bake "
                        "has no effect there - the bevel normal cancels out "
                        "against the shading normal. Use Shade Flat (or Shade "
                        "Auto Smooth) on the hard surface parts.")

        active_before = ctx.view_layer.objects.active
        baked = 0
        try:
            with render_settings(scn, props.samples), progress(ctx.window_manager, len(targets)) as wm:
                for idx, obj in enumerate(targets):
                    wm.progress_update(idx)
                    ctx.view_layer.objects.active = obj
                    drop_empty_slots(obj)

                    tiles = used_udim_tiles(obj)
                    w, h, is_udim = resolution_for(obj, props)
                    name = f"{clean(obj.name)}_Bevel"
                    # Normals always go to EXR - 8 bit visibly banded them.
                    bevel_img = (new_udim_image(name, w, h, 'OPEN_EXR', tiles, is_float=True)
                                 if is_udim else
                                 new_single_image(name, w, h, 'OPEN_EXR', is_float=True))

                    entries, bsdfs = [], []
                    for slot in obj.material_slots:
                        m = slot.material
                        if not (m and m.use_nodes):
                            continue
                        nt = m.node_tree
                        bsdf = next((n for n in nt.nodes if n.type == 'BSDF_PRINCIPLED'), None)
                        if not bsdf:
                            continue

                        relinks = []
                        normal_in = bsdf.inputs['Normal']
                        if normal_in.links:
                            link = normal_in.links[0]
                            relinks.append((link.from_socket, normal_in))
                            nt.links.remove(link)

                        bevel_node = nt.nodes.new('ShaderNodeBevel')
                        bevel_node.inputs['Radius'].default_value = props.bevel_radius
                        bevel_node.samples = props.bevel_samples
                        if relinks:
                            nt.links.new(relinks[0][0], bevel_node.inputs['Normal'])
                        nt.links.new(bevel_node.outputs['Normal'], normal_in)

                        tex = attach_bake_target(nt, bevel_img)
                        entries.append((nt, [tex, bevel_node], relinks))
                        bsdfs.append((nt, bsdf))

                    if not entries:
                        bpy.data.images.remove(bevel_img)
                        continue

                    with temporary_shading(obj.data), \
                         restored_material_links(entries), \
                         bake_settings(scn, normal=True):
                        bpy.ops.object.bake(type='NORMAL')

                    for nt, bsdf in bsdfs:
                        attach_bevel_normal(nt, bevel_img, bsdf)

                    ctx.scene.bake_ao_last_image = bevel_img
                    baked += 1
        except RuntimeError as exc:
            self.report({'ERROR'}, f"Bevel bake failed: {exc}")
            return {'CANCELLED'}
        finally:
            if active_before:
                ctx.view_layer.objects.active = active_before

        self.report({'INFO'}, f"Bevel bake finished for {baked} object(s)")
        return {'FINISHED'}


class BakeCurvatureOperator(Operator):
    bl_idname = "object.bake_curvature"
    bl_label = "Bake Curvature to New Texture"
    bl_options = {'REGISTER', 'UNDO'}
    bl_description = ("Bakes a worn-edge mask (white edges on black), like the "
                      "cavity ridges in solid shading. Uses the Bevel radius "
                      "and samples above")

    @classmethod
    def poll(cls, context):
        return context.mode == 'OBJECT' and bool(context.selected_objects)

    def execute(self, ctx):
        scn, props = ctx.scene, ctx.scene.bake_ao_props
        targets = collect_targets(ctx)
        if not targets:
            self.report({'ERROR'}, "No valid mesh objects with UV maps and materials found!")
            return {'CANCELLED'}
        # Doppel-Bake-Sperre: sonst stapeln sich Lighten auf Lighten.
        if curvature_angewendet(ctx):
            self.report({'ERROR'},
                        "A curvature mask is already applied to this "
                        "selection - use 'Remove baked Curvature' first to "
                        "bake again.")
            return {'CANCELLED'}
        if props.bevel_radius <= 0.0:
            self.report({'WARNING'},
                        "Bevel Radius is 0 - the curvature mask will stay black. "
                        "Try roughly 1/1000 of the object size.")
        smooth_objs = [o.name for o in targets if is_fully_smooth(o)]
        if smooth_objs:
            self.report({'WARNING'},
                        f"Fully smooth shaded: {', '.join(smooth_objs[:3])}"
                        f"{' ...' if len(smooth_objs) > 3 else ''}. The edge mask "
                        "stays empty there - use Shade Flat on the hard surface parts.")

        active_before = ctx.view_layer.objects.active
        baked = 0
        try:
            with render_settings(scn, props.samples), progress(ctx.window_manager, len(targets)) as wm:
                for idx, obj in enumerate(targets):
                    wm.progress_update(idx)
                    ctx.view_layer.objects.active = obj
                    drop_empty_slots(obj)

                    tiles = used_udim_tiles(obj)
                    w, h, is_udim = resolution_for(obj, props)
                    fmt = props.file_format
                    name = f"{clean(obj.name)}_Curvature"
                    img = (new_udim_image(name, w, h, fmt, tiles) if is_udim
                           else new_single_image(name, w, h, fmt))

                    entries = []
                    for slot in obj.material_slots:
                        m = slot.material
                        if not (m and m.use_nodes):
                            continue
                        nt = m.node_tree
                        out = next((n for n in nt.nodes
                                    if n.type == 'OUTPUT_MATERIAL' and n.is_active_output), None)
                        if not out:
                            continue
                        surf = out.inputs['Surface']
                        relinks = []
                        if surf.links:
                            link = surf.links[0]
                            relinks.append((link.from_socket, surf))
                            nt.links.remove(link)

                        # Kantenmaske: 1 - dot(BevelNormal, Shading-Normal),
                        # verstaerkt und geklemmt. An Kanten weicht die
                        # Bevel-Normale ab -> dot faellt unter 1 -> Maske hell.
                        bevel = nt.nodes.new('ShaderNodeBevel')
                        bevel.inputs['Radius'].default_value = props.bevel_radius
                        bevel.samples = props.bevel_samples
                        geom = nt.nodes.new('ShaderNodeNewGeometry')
                        dot = nt.nodes.new('ShaderNodeVectorMath')
                        dot.operation = 'DOT_PRODUCT'
                        nt.links.new(bevel.outputs['Normal'], dot.inputs[0])
                        nt.links.new(geom.outputs['Normal'], dot.inputs[1])
                        sub = nt.nodes.new('ShaderNodeMath')
                        sub.operation = 'SUBTRACT'
                        sub.inputs[0].default_value = 1.0
                        nt.links.new(dot.outputs['Value'], sub.inputs[1])
                        mul = nt.nodes.new('ShaderNodeMath')
                        mul.operation = 'MULTIPLY'
                        mul.use_clamp = True
                        mul.inputs[1].default_value = props.curvature_contrast
                        nt.links.new(sub.outputs['Value'], mul.inputs[0])
                        emit = nt.nodes.new('ShaderNodeEmission')
                        nt.links.new(mul.outputs['Value'], emit.inputs['Color'])
                        nt.links.new(emit.outputs['Emission'], surf)

                        tex = attach_bake_target(nt, img)
                        entries.append((nt, [tex, emit, mul, sub, dot, geom, bevel], relinks))

                    if not entries:
                        bpy.data.images.remove(img)
                        continue

                    with temporary_shading(obj.data), \
                         restored_material_links(entries), \
                         bake_settings(scn):
                        bpy.ops.object.bake(type='EMIT')

                    # Maske direkt nutzbar machen: Lighten-Mix vor die Base Color.
                    for nt, _nodes, _relinks in entries:
                        lighten_curvature_into_base_color(
                            nt, img, props.curvature_mix_factor)

                    ctx.scene.bake_ao_last_image = img
                    baked += 1
        except RuntimeError as exc:
            self.report({'ERROR'}, f"Curvature bake failed: {exc}")
            return {'CANCELLED'}
        finally:
            if active_before:
                ctx.view_layer.objects.active = active_before

        self.report({'INFO'}, f"Curvature bake finished for {baked} object(s)")
        return {'FINISHED'}


# ---------- Save Operators ----------
def save_image(img, base_path):
    """Writes the image; one file per tile for UDIM. Returns the file count.

    FIX: written through save_render with explicit image settings. img.save()
    depends on img.file_format, which does not hold on generated images - a
    map meant to be EXR was written as PNG. save_render also understands the
    <UDIM> marker, which img.save() requires but the old per-tile loop never
    produced ("must contain a valid UDIM marker").
    """
    fmt = wanted_format(img)
    ext = "." + format_to_ext.get(fmt, 'png')
    # FIX: was base_path.rstrip(ext) - rstrip takes a SET of characters, not a
    # suffix, so ".png" stripped every trailing p, n, g and dot from the name.
    stem = base_path[:-len(ext)] if base_path.lower().endswith(ext) else base_path
    if img.source == 'TILED' and "<UDIM>" not in stem:
        stem += ".<UDIM>"

    scene = bpy.context.scene
    settings = scene.render.image_settings
    before = (settings.file_format, settings.color_depth, settings.color_mode)
    try:
        settings.file_format = fmt
        settings.color_mode = 'RGB'
        settings.color_depth = '32' if fmt == 'OPEN_EXR' else '8'
        img.save_render(filepath=stem + ext, scene=scene)
    finally:
        settings.file_format, settings.color_depth, settings.color_mode = before

    return len(img.tiles) if img.source == 'TILED' else 1


def _mix_bild(mix):
    """Das gebackene Bild hinter einem Einbindungs-Mix (AO/Curvature).

    Folgt dem B-Eingang ueber einen etwaigen Zwischen-Node (Spread-Kurve
    bzw. 1.6.1-Map-Range) bis zur Image-Texture - gleiche Kette wie
    _mix_einbindung_entfernen.
    """
    b = mix.inputs['B']
    node = b.links[0].from_node if b.links else None
    if node is not None and node.bl_idname in ('ShaderNodeRGBCurve',
                                               'ShaderNodeMapRange'):
        eingang = node.inputs['Color'] if node.bl_idname == 'ShaderNodeRGBCurve' \
            else node.inputs['Value']
        node = eingang.links[0].from_node if eingang.links else None
    if node is not None and node.type == 'TEX_IMAGE':
        return node.image
    return None


def gebackene_bilder(context):
    """Alle Bilder, die das Add-on im Materialkreis eingebunden hat.

    AO (Marker + 1.6.0-Alt-Erkennung), Bevel-Normal-Map und Curvature-
    Maske - dedupliziert, Reihenfolge stabil (AO, Bevel, Curvature). Ist
    nichts (mehr) eingebunden, faellt die Liste aufs zuletzt gebackene
    Bild zurueck, damit "Save" auch direkt nach einem entfernten Ergebnis
    noch etwas anzubieten hat.
    """
    bilder = []

    def merken(img):
        if img is not None and img not in bilder:
            bilder.append(img)

    for _nt, mix in ao_nodes(AO_MIX_KEY, context):
        merken(_mix_bild(mix))
    for _nt, _mix, tex in alte_ao_mixe(context):
        merken(tex.image)
    for _nt, _nm, tex in bevel_normalmaps(context):
        if tex is not None:
            merken(tex.image)
    for mix in curvature_mix_nodes(context):
        merken(_mix_bild(mix))
    if not bilder:
        letzte = getattr(context.scene, "bake_ao_last_image", None)
        if letzte:
            bilder.append(letzte)
    return bilder


class SaveBakeTextureOperator(Operator):
    bl_idname = "object.save_bake_texture"
    bl_label = "Save Baked Textures"
    bl_options = {'REGISTER'}
    bl_description = ("Saves ALL textures baked by this add-on (AO, bevel "
                      "normal, curvature) next to the blend file.")

    def execute(self, ctx):
        bilder = gebackene_bilder(ctx)
        if not bilder:
            self.report({'ERROR'}, "No baked image found! Bake a texture first.")
            return {'CANCELLED'}
        # FIX: on an unsaved blend file "//" resolves to nothing and the image
        # was written to some unpredictable place.
        if not bpy.data.filepath:
            self.report({'ERROR'}, "Blend file is unsaved - use 'Save As...' instead.")
            return {'CANCELLED'}
        folder = bpy.path.abspath("//")
        n = 0
        try:
            for img in bilder:
                n += save_image(img, os.path.join(folder, img.name))
        except RuntimeError as exc:
            self.report({'ERROR'}, f"Saving failed: {exc}")
            return {'CANCELLED'}
        self.report({'INFO'},
                    f"Saved {len(bilder)} texture(s), {n} file(s) to {folder}")
        return {'FINISHED'}


class SaveBakeTextureToOperator(Operator, ExportHelper):
    bl_idname = "object.save_bake_texture_to"
    bl_label = "Save Baked Textures As..."
    bl_options = {'REGISTER'}
    bl_description = ("Saves ALL textures baked by this add-on to the chosen "
                      "directory. With more than one texture the picked name "
                      "only chooses the folder - each map keeps its own name")
    filename_ext = ""
    filepath: bpy.props.StringProperty(subtype='FILE_PATH')

    def execute(self, ctx):
        bilder = gebackene_bilder(ctx)
        if not bilder:
            self.report({'ERROR'}, "No baked image found! Bake a texture first.")
            return {'CANCELLED'}
        n = 0
        try:
            if len(bilder) == 1:
                n = save_image(bilder[0], self.filepath)
            else:
                folder = os.path.dirname(self.filepath)
                for img in bilder:
                    n += save_image(img, os.path.join(folder, img.name))
        except RuntimeError as exc:
            self.report({'ERROR'}, f"Saving failed: {exc}")
            return {'CANCELLED'}
        self.report({'INFO'}, f"Saved {len(bilder)} texture(s), {n} file(s)")
        return {'FINISHED'}

    def invoke(self, ctx, event):
        bilder = gebackene_bilder(ctx)
        if bilder:
            img = bilder[0]
            self.filename_ext = "." + format_to_ext.get(wanted_format(img), 'png')
            self.filepath = bpy.path.abspath("//") + img.name + self.filename_ext
        ctx.window_manager.fileselect_add(self)
        return {'RUNNING_MODAL'}


# ---------- UI Panel ----------
class BakeAOPanel(Panel):
    bl_label = "Bake AO & Bevel"
    bl_idname = "VIEW3D_PT_bake_ao"
    bl_parent_id = "MATERIAL_PT_material_panel"
    bl_space_type = 'VIEW_3D'
    bl_region_type = 'UI'
    bl_category = "BaseMaterials"
    bl_options = {'DEFAULT_CLOSED'}

    def draw(self, ctx):
        layout = self.layout
        p = ctx.scene.bake_ao_props

        box = layout.box()
        box.label(text="Settings", icon='SETTINGS')
        row = box.row()
        row.prop(p, "texture_size")
        row.prop(p, "samples")
        box.prop(p, "file_format")
        box.operator("object.smart_uv_unwrap", text="Smart UV Unwrap", icon='UV')

        box = layout.box()
        box.label(text="AO Bake", icon='LIGHT_AREA')
        # Ist schon eine AO-Einbindung angewendet, verschwindet der Bake-
        # Button (kein Multiply-auf-Multiply-Stapeln); es bleiben die Slider
        # bzw. der Nachruest-Knopf, plus der bewusste Weg zum Neu-Baken.
        ao_da = any(True for _ in ao_nodes(AO_MIX_KEY, ctx))
        ao_alt_da = any(True for _ in alte_ao_mixe(ctx))
        if not (ao_da or ao_alt_da):
            box.operator("object.bake_ao", text="Bake AO", icon='RENDER_STILL')
        if ao_da:
            # Regeln Multiply-Factor und Kurven-Arbeitspunkt der eingebauten
            # AO-Nodes live, ohne neu zu backen.
            col = box.column(align=True)
            col.prop(p, "ao_strength")
            col.prop(p, "ao_spread")
        elif ao_alt_da:
            # AO-Einbindung aus einer aelteren Version gefunden: ein Klick
            # ruestet Marker + Spread-Kurve nach, dann erscheinen die Slider.
            box.operator("object.ao_slider_upgrade",
                         text="Enable AO Sliders", icon='ARROW_LEFTRIGHT')
        if ao_da or ao_alt_da:
            box.operator("object.bake_result_remove",
                         text="Remove baked AO", icon='X').art = 'AO'

        box = layout.box()
        box.label(text="Bevel Bake", icon='META_BALL')
        bevel_da = bevel_angewendet(ctx)
        curv_da = curvature_angewendet(ctx)
        # Radius/Samples/HDRI wirken nur auf Preview und Bake-Vorgang, nicht
        # nachtraeglich aufs Ergebnis - nach dem Bevel-Bake haben sie hier
        # nichts mehr verloren (Stephan, 06.08.2026). Der Curvature-Bake
        # nutzt sie mit; solange der noch aussteht, zeigt SEIN Kasten sie an.
        if not bevel_da:
            row = box.row()
            row.prop(p, "bevel_radius")
            row.prop(p, "bevel_samples")
            box.prop(p, "preview_use_hdri")
        if p.preview_active and p.preview_mode == 'BEVEL':
            # Live preview running: the sliders above feed straight into the
            # viewport, so this is where the radius actually gets decided.
            info = box.column(align=True)
            info.label(text="Live preview - drag the radius", icon='SHADING_RENDERED')
            info.label(text="Bake when it looks right.")
            row = box.row(align=True)
            row.scale_y = 1.4
            row.operator("object.bevel_preview_confirm", text="Bake", icon='RENDER_STILL')
            row.operator("object.bevel_preview_cancel", text="Cancel", icon='X')
        elif bevel_da:
            # Schon eine Bevel-Normal-Map im Material: kein zweiter Bake
            # obendrauf - erst bewusst entfernen, dann ist der Button zurueck.
            box.operator("object.bake_result_remove",
                         text="Remove baked Bevel", icon='X').art = 'BEVEL'
        else:
            box.operator("object.bevel_preview_start", text="Bake Bevel", icon='SHADING_RENDERED')

        box = layout.box()
        box.label(text="Curvature Bake", icon='MOD_BEVEL')
        # Contrast wirkt nur waehrend Preview/Bake, nicht nachtraeglich aufs
        # gebackene Ergebnis - nach dem Bake verschwindet er mit dem Button
        # und kommt nach "Remove baked Curvature" wieder.
        if bevel_da and not curv_da:
            # Der Curvature-Bake braucht Radius/Samples/HDRI noch - im
            # Bevel-Kasten sind sie nach dessen Bake schon ausgeblendet.
            row = box.row()
            row.prop(p, "bevel_radius")
            row.prop(p, "bevel_samples")
            box.prop(p, "preview_use_hdri")
        if not curv_da:
            box.prop(p, "curvature_contrast")
        if p.preview_active and p.preview_mode == 'CURVATURE':
            info = box.column(align=True)
            info.label(text="White edges on black - drag radius & contrast", icon='SHADING_RENDERED')
            info.label(text="Bake mixes the edges onto Base Color.")
            row = box.row(align=True)
            row.scale_y = 1.4
            row.operator("object.curvature_preview_confirm", text="Bake", icon='RENDER_STILL')
            row.operator("object.curvature_preview_cancel", text="Cancel", icon='X')
        elif not curv_da:
            row = box.row(align=True)
            row.operator("object.curvature_preview_start", text="Preview", icon='SHADING_RENDERED')
            row.operator("object.bake_curvature", text="Bake Curvature", icon='RENDER_STILL')
        # Erst nach einem Bake sinnvoll: regelt den Lighten-Mix in der Base
        # Color; Neu-Baken nur ueber den bewussten Remove-Weg.
        if curv_da:
            box.prop(p, "curvature_mix_factor")
            box.operator("object.bake_result_remove",
                         text="Remove baked Curvature", icon='X').art = 'CURVATURE'

        box = layout.box()
        box.label(text="Save Baked Texture", icon='FILE_TICK')
        row = box.row()
        row.operator("object.save_bake_texture", text="Save Here", icon='DISK_DRIVE')
        row.operator("object.save_bake_texture_to", text="Save As...", icon='FILE_FOLDER')
        row.operator("object.preview_bake_texture", text="Preview", icon='IMAGE_DATA')
        # Small thing, but it saves a trip to the Image Editor to find out
        # which maps "Save Here" is about to write.
        bilder = gebackene_bilder(ctx)
        if bilder:
            namen = ", ".join(i.name for i in bilder[:3])
            if len(bilder) > 3:
                namen += f" +{len(bilder) - 3}"
            box.label(text=f"Baked: {namen}", icon='IMAGE_DATA')


# ---------- Registration ----------
classes = (BakeAOProperties, SmartUVUnwrapOperator, AOSliderUpgradeOperator,
           BakeResultRemoveOperator,
           BakeAOOperator, BakeBevelOperator,
           BevelPreviewStartOperator, BevelPreviewConfirmOperator,
           BevelPreviewCancelOperator, BakeCurvatureOperator,
           CurvaturePreviewStartOperator, CurvaturePreviewConfirmOperator,
           CurvaturePreviewCancelOperator,
           SaveBakeTextureOperator, SaveBakeTextureToOperator, PreviewBakeTextureOperator,
           BakeAOPanel)


def register_bake_ao():
    for c in classes:
        bpy.utils.register_class(c)
    bpy.types.Scene.bake_ao_props = PointerProperty(type=BakeAOProperties)
    bpy.types.Scene.bake_ao_last_image = PointerProperty(type=bpy.types.Image)


def unregister_bake_ao():
    del bpy.types.Scene.bake_ao_last_image
    del bpy.types.Scene.bake_ao_props
    for c in reversed(classes):
        bpy.utils.unregister_class(c)
