# SPDX-License-Identifier: GPL-3.0-or-later
#
# Material Dropper – Blender Add-on
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.


"""
Material Dropper – Blender 5.x Add-on

Click a material to lock it, then left-click objects/faces to assign.
ESC cancels.

Assign modes (panel toggle):
    Slot  – slot of the face under cursor
    Mesh  – replace material in every slot, preserving slot structure
    Face  – single face
    Fill  – flood-fill from face, stopped by any active boundary type

Fill boundaries (multi-selectable with CTRL+click):
    Sharp  – sharp edges
    Seam   – seam edges
    Angle  – edges whose dihedral angle exceeds Edge Angle threshold
    (none) – fills entire connected mesh island
"""

bl_info = {
    "name": "Material Dropper",
    "author": "Laserschwert",
    "version": (1, 0, 0),
    "blender": (5, 0, 0),
    "location": "3D Viewport > Sidebar > Materials",
    "description": "Material list with constant assignment and face highlighting",
    "license":     "GPL-3.0-or-later",
    "category":    "Material",
}

import bpy
import blf
import gpu
import math
from bpy.types import Panel, Operator, PropertyGroup
from bpy.props import (StringProperty, BoolProperty, PointerProperty,
                        EnumProperty, FloatProperty)
from gpu_extras.batch import batch_for_shader
from collections import deque
from mathutils import Vector


# ---------------------------------------------------------------------------
# view3d_utils – defensive import
# ---------------------------------------------------------------------------

def _build_v3u():
    import types
    ns = types.SimpleNamespace()
    try:
        from bpy_extras import view3d_utils as _v
        if hasattr(_v, 'region_2d_to_origin_3d'):
            ns.ray_origin    = _v.region_2d_to_origin_3d
            ns.ray_direction = _v.region_2d_to_vector_3d
            ns.loc_to_2d     = _v.location_3d_to_region_2d
            return ns
    except Exception:
        pass
    def _origin(region, rv3d, coord):
        return rv3d.view_matrix.inverted().translation
    def _direction(region, rv3d, coord):
        x, y = coord
        nx = (2.0*x/region.width) - 1.0
        ny = (2.0*y/region.height) - 1.0
        v  = rv3d.perspective_matrix.inverted() @ Vector((nx, ny, -1.0, 1.0))
        v /= v.w
        return (Vector(v.to_3d()) - rv3d.view_matrix.inverted().translation).normalized()
    def _loc_to_2d(region, rv3d, co3d):
        prj = rv3d.perspective_matrix @ Vector((*co3d, 1.0))
        if abs(prj.w) < 1e-8: return None
        return Vector(((prj.x/prj.w+1)*0.5*region.width,
                        (prj.y/prj.w+1)*0.5*region.height))
    ns.ray_origin    = _origin
    ns.ray_direction = _direction
    ns.loc_to_2d     = _loc_to_2d
    return ns

_V3U = None
def get_v3u():
    global _V3U
    if _V3U is None:
        _V3U = _build_v3u()
    return _V3U


# ---------------------------------------------------------------------------
# Ray-cast
# ---------------------------------------------------------------------------

def ray_cast_object(context, event):
    region = context.region
    rv3d   = context.region_data
    if not region or not rv3d:
        return None, None, None
    v3u = get_v3u()
    try:
        coord  = (event.mouse_region_x, event.mouse_region_y)
        origin = v3u.ray_origin(region, rv3d, coord)
        direc  = v3u.ray_direction(region, rv3d, coord)

        # For orthographic views (including camera views with ortho camera),
        # push the origin back along the view direction by the clip_end distance
        # so the ray reliably starts in front of all geometry.
        is_ortho_cam = (rv3d.view_perspective == 'CAMERA' and
                        context.scene.camera and
                        context.scene.camera.data and
                        context.scene.camera.data.type == 'ORTHO')
        is_ortho = (rv3d.view_perspective == 'ORTHO' or
                    rv3d.is_orthographic_side_view or
                    is_ortho_cam)

        if is_ortho:
            # Use clip_end from the space data if available, else fallback
            try:
                clip_end = context.space_data.clip_end
            except Exception:
                clip_end = 1000.0
            origin = Vector(origin) - Vector(direc) * clip_end

        ok, loc, _, fi, obj, _ = context.scene.ray_cast(
            context.evaluated_depsgraph_get(), origin, direc)
        if ok and obj and obj.type == 'MESH':
            n = len(obj.data.polygons)
            if n == 0:
                return obj, None, loc
            fi_safe = fi if fi < n else fi % n
            return obj, fi_safe, loc
    except Exception as e:
        print(f"[MatDropper] ray_cast: {e}")
    return None, None, None


# ---------------------------------------------------------------------------
# Flood-fill with multi-boundary support
# ---------------------------------------------------------------------------

def flood_fill_faces(obj, start, use_sharp, use_seam, use_angle, angle_limit_rad):
    mesh  = obj.data
    e2f = {}
    for p in mesh.polygons:
        for ek in p.edge_keys:
            e2f.setdefault(ek, []).append(p.index)
    face_normals = [p.normal.copy() for p in mesh.polygons]
    edge_by_key  = {e.key: e for e in mesh.edges}

    def is_boundary(ek, edge):
        if use_sharp and edge.use_edge_sharp: return True
        if use_seam  and edge.use_seam:       return True
        if use_angle:
            faces = e2f.get(ek, [])
            if len(faces) == 2:
                dot = max(-1.0, min(1.0, face_normals[faces[0]].dot(face_normals[faces[1]])))
                if math.acos(dot) > angle_limit_rad: return True
        return False

    visited = {start}
    q       = deque([start])
    n_polys = len(mesh.polygons)
    while q:
        fi = q.popleft()
        if fi >= n_polys:
            continue
        for ek in mesh.polygons[fi].edge_keys:
            edge = edge_by_key.get(ek)
            if edge and is_boundary(ek, edge): continue
            for nb in e2f.get(ek, []):
                if nb not in visited and nb < n_polys:
                    visited.add(nb); q.append(nb)
    return visited


# ---------------------------------------------------------------------------
# Assignment helpers
# ---------------------------------------------------------------------------

def slot_at_face(obj, fi):
    if fi is not None and fi < len(obj.data.polygons):
        return obj.data.polygons[fi].material_index
    return 0

def faces_for_slot(obj, slot):
    return [p.index for p in obj.data.polygons if p.material_index == slot]

def assign_slot(obj, mat, slot):
    while len(obj.material_slots) <= slot:
        bpy.ops.object.material_slot_add({'object': obj})
    obj.material_slots[slot].material = mat

def assign_all_slots(obj, mat):
    if not obj.material_slots:
        obj.data.materials.append(mat)
    else:
        for slot in obj.material_slots:
            slot.material = mat

def assign_faces(obj, indices, mat):
    slot = next((i for i, s in enumerate(obj.material_slots) if s.material == mat), None)
    if slot is None:
        obj.data.materials.append(mat)
        slot = len(obj.material_slots) - 1
    for fi in indices:
        if fi < len(obj.data.polygons):
            obj.data.polygons[fi].material_index = slot
    obj.data.update()

def do_assign(context, obj, fi, mat, mode, props):
    prev_active = context.view_layer.objects.active
    prev_sel    = list(context.selected_objects)
    for o in prev_sel: o.select_set(False)
    obj.select_set(True)
    context.view_layer.objects.active = obj

    if mode == 'MESH':
        assign_all_slots(obj, mat)
    elif mode == 'FACE' and fi is not None:
        assign_faces(obj, [fi], mat)
    if mode == 'FILL' and fi is not None:
        if fi < len(obj.data.polygons):
            filled = flood_fill_faces(
                obj, fi,
                props.fill_use_sharp, props.fill_use_seam, props.fill_use_angle,
                props.fill_angle)
            assign_faces(obj, filled, mat)
        # else: fi is out of range for the base mesh (e.g. modifier added faces) – skip silently
    else:
        assign_slot(obj, mat, slot_at_face(obj, fi))

    obj.select_set(False)
    for o in prev_sel:
        try: o.select_set(True)
        except: pass
    try: context.view_layer.objects.active = prev_active
    except: pass


# ---------------------------------------------------------------------------
# Scene materials
# ---------------------------------------------------------------------------

def get_all_scene_materials():
    mats, seen = [], set()
    try:
        for obj in bpy.context.scene.objects:
            if obj.type == 'MESH':
                for s in obj.material_slots:
                    if s.material and s.material.name not in seen:
                        seen.add(s.material.name); mats.append(s.material)
    except: pass
    try:
        for mat in bpy.data.materials:
            if mat.name not in seen:
                seen.add(mat.name); mats.append(mat)
    except: pass
    return mats


# ---------------------------------------------------------------------------
# Face highlight
# ---------------------------------------------------------------------------

def faces_to_2d(region, rv3d, obj, indices):
    v3u  = get_v3u()
    mesh = obj.data
    mx   = obj.matrix_world
    tris, loops = [], []
    for fi in indices:
        if fi >= len(mesh.polygons): continue
        verts = []; ok = True
        for vi in mesh.polygons[fi].vertices:
            c = v3u.loc_to_2d(region, rv3d, mx @ mesh.vertices[vi].co)
            if c is None: ok = False; break
            verts.append((c.x, c.y))
        if not ok or len(verts) < 3: continue
        p0 = verts[0]
        for i in range(1, len(verts)-1):
            tris.extend([p0, verts[i], verts[i+1]])
        loops.append(verts + [verts[0]])
    return tris, loops


# ---------------------------------------------------------------------------
# Properties
# ---------------------------------------------------------------------------

def _filter_update(self, context):
    pass


class MATDROPPER_Properties(PropertyGroup):
    filter_text:    StringProperty(name="Filter", default="", update=_filter_update)
    show_thumbnails:BoolProperty(name="Show Thumbnails", default=False)
    show_overlays:  BoolProperty(name="Show Overlays",   default=False)
    filter_object:  PointerProperty(
        name="Object",
        description="Show only materials assigned to this object. Leave empty for all scene materials.",
        type=bpy.types.Object,
        poll=lambda self, obj: obj.type == 'MESH',
    )
    assign_target:  EnumProperty(
        name="Assign to",
        items=[
            ('SLOT', "Slot", "Material slot of the face under cursor"),
            ('MESH', "Mesh", "Replace material in all slots, keeping slot structure"),
            ('FACE', "Face", "Single face under cursor"),
            ('FILL', "Fill", "Flood-fill from face"),
        ],
        default='SLOT',
    )
    fill_use_sharp: BoolProperty(name="Sharp", default=False,
        description="Stop fill at sharp edges")
    fill_use_seam:  BoolProperty(name="Seam",  default=False,
        description="Stop fill at seam edges")
    fill_use_angle: BoolProperty(name="Angle", default=False,
        description="Stop fill at edges exceeding the angle threshold")
    fill_angle:     FloatProperty(name="Edge Angle", default=math.radians(30.0),
        min=0.0, max=math.radians(180.0), step=10, precision=1,
        description="Dihedral angle limit – fill stops above this value",
        subtype='ANGLE', unit='ROTATION')
    constant_material:   StringProperty(default="")
    last_material:       StringProperty(default="")
    # Comma-separated material names marked as favourites
    favourites:          StringProperty(default="")
    non_fav_collapsed:   BoolProperty(default=False, description="Collapse non-favourite materials")


# ---------------------------------------------------------------------------
# Favourites helpers
# ---------------------------------------------------------------------------

def get_favourites(props):
    """Return set of favourite material names."""
    raw = props.favourites.strip()
    return set(raw.split(",")) - {""} if raw else set()

def set_favourites(props, fav_set):
    props.favourites = ",".join(sorted(fav_set))


# ---------------------------------------------------------------------------
# Operator: toggle favourite
# ---------------------------------------------------------------------------

class MATDROPPER_OT_ToggleFavourite(Operator):
    """Toggle favourite status for this material."""
    bl_idname  = "matdropper.toggle_favourite"
    bl_label   = "Toggle Favourite"
    bl_options = {'INTERNAL'}

    material_name: StringProperty()

    def execute(self, context):
        props = context.window_manager.matdropper_props
        favs  = get_favourites(props)
        if self.material_name in favs:
            favs.discard(self.material_name)
        else:
            favs.add(self.material_name)
        set_favourites(props, favs)
        return {'FINISHED'}



# ---------------------------------------------------------------------------
# Operator: cancel constant assign
# ---------------------------------------------------------------------------

class MATDROPPER_OT_CancelAssign(Operator):
    """Cancel the current material assignment."""
    bl_idname  = "matdropper.cancel_assign"
    bl_label   = "Cancel Assignment"
    bl_options = {'INTERNAL'}

    def execute(self, context):
        context.window_manager.matdropper_props.constant_material = ""
        return {'FINISHED'}



# ---------------------------------------------------------------------------
# Operator: toggle collapse of non-favourite materials
# ---------------------------------------------------------------------------

class MATDROPPER_OT_ToggleNonFav(Operator):
    """Show/hide non-favourite materials"""
    bl_idname  = "matdropper.toggle_non_fav"
    bl_label   = "Toggle Non-Favourites"
    bl_options = {'INTERNAL'}

    def execute(self, context):
        props = context.window_manager.matdropper_props
        props.non_fav_collapsed = not props.non_fav_collapsed
        return {'FINISHED'}


# ---------------------------------------------------------------------------
# Operator: toggle fill boundary flag (CTRL = multi-select)
# ---------------------------------------------------------------------------

class MATDROPPER_OT_ToggleBoundary(Operator):
    """Toggle this fill boundary. CTRL keeps other selections."""
    bl_idname  = "matdropper.toggle_boundary"
    bl_label   = "Toggle Fill Boundary"
    bl_options = {'INTERNAL'}

    flag: EnumProperty(items=[
        ('SHARP', "Sharp", ""),
        ('SEAM',  "Seam",  ""),
        ('ANGLE', "Angle", ""),
    ])

    def invoke(self, context, event):
        props = context.window_manager.matdropper_props
        if not event.ctrl:
            currently_only = (
                (self.flag == 'SHARP' and     props.fill_use_sharp and not props.fill_use_seam  and not props.fill_use_angle) or
                (self.flag == 'SEAM'  and not props.fill_use_sharp and     props.fill_use_seam  and not props.fill_use_angle) or
                (self.flag == 'ANGLE' and not props.fill_use_sharp and not props.fill_use_seam  and     props.fill_use_angle)
            )
            if currently_only:
                props.fill_use_sharp = False
                props.fill_use_seam  = False
                props.fill_use_angle = False
            else:
                props.fill_use_sharp = (self.flag == 'SHARP')
                props.fill_use_seam  = (self.flag == 'SEAM')
                props.fill_use_angle = (self.flag == 'ANGLE')
        else:
            if self.flag == 'SHARP': props.fill_use_sharp = not props.fill_use_sharp
            elif self.flag == 'SEAM': props.fill_use_seam = not props.fill_use_seam
            else:                     props.fill_use_angle = not props.fill_use_angle
        return {'FINISHED'}


# ---------------------------------------------------------------------------
# GPU draw callback
# ---------------------------------------------------------------------------

def _rect(sh, x, y, w, h, c):
    b = batch_for_shader(sh, 'TRI_FAN', {"pos":[(x,y),(x+w,y),(x+w,y+h),(x,y+h)]})
    sh.bind(); sh.uniform_float("color", c); b.draw(sh)

def draw_overlay(op, context):
    sh = gpu.shader.from_builtin('UNIFORM_COLOR')
    gpu.state.blend_set('ALPHA')
    gpu.state.depth_test_set('NONE')

    if op._hl_tris:
        b = batch_for_shader(sh, 'TRIS', {"pos": op._hl_tris})
        sh.bind(); sh.uniform_float("color", op._fill_col); b.draw(sh)

    gpu.state.blend_set('NONE')
    gpu.state.depth_test_set('NONE')  # keep depth test OFF for HUD
    if not op._mat: return

    mx, my = op._mx, op._my
    mode   = op._target

    if   mode == 'MESH': bg=(0.07,0.20,0.44,0.94); ac=(0.30,0.70,1.00,1.00)
    elif mode == 'FACE': bg=(0.07,0.36,0.16,0.94); ac=(0.20,1.00,0.45,1.00)
    elif mode == 'FILL': bg=(0.30,0.10,0.40,0.94); ac=(0.85,0.35,1.00,1.00)
    else:                bg=(0.38,0.26,0.04,0.94); ac=(1.00,0.70,0.20,1.00)

    if   mode == 'SLOT': line2 = "Fill Material Slot"
    elif mode == 'MESH': line2 = "Fill Entire Mesh"
    elif mode == 'FACE': line2 = "Fill Face"
    else:                line2 = f"Flood Fill Faces (Boundaries: {op._boundary_label})"

    nm    = op._mat.name
    box_w = min(max(180, max(len(nm), len(line2)) * 7), 380)

    gpu.state.blend_set('ALPHA')
    _rect(sh, mx+13, my-46, box_w+4, 48, (0,0,0,0.38))
    _rect(sh, mx+15, my-44, box_w,   44, bg)
    _rect(sh, mx+15, my- 3, box_w,    3, ac)
    gpu.state.blend_set('NONE')

    fid = 0
    blf.enable(fid, blf.SHADOW); blf.shadow(fid,3,0,0,0,0.85); blf.shadow_offset(fid,1,-1)
    blf.color(fid, 1,1,1,1); blf.size(fid, 11)
    blf.position(fid, mx+21, my-20, 0); blf.draw(fid, nm)
    blf.color(fid, ac[0],ac[1],ac[2],1); blf.size(fid, 9)
    blf.position(fid, mx+21, my-37, 0); blf.draw(fid, line2)
    blf.disable(fid, blf.SHADOW)
    gpu.state.depth_test_set('LESS_EQUAL')  # restore Blender default


# ---------------------------------------------------------------------------
# Operator: Constant assign  (modal, INTERNAL – no undo entry of its own)
# ---------------------------------------------------------------------------

class MATDROPPER_OT_ConstantAssign(Operator):
    """Click to lock material; left-click objects to assign. ESC cancels."""
    bl_idname  = "matdropper.constant_assign"
    bl_label   = "Assign Material"
    bl_options = {'INTERNAL'}  # no REGISTER – modal wrapper must not pollute undo

    material_name: StringProperty()

    _draw_handle    = None
    _mat            = None
    _mx = _my       = 0
    _show_ovl       = False
    _target         = 'SLOT'
    _boundary_label = ""
    _hl_tris        = []
    _hl_loops       = []
    _fill_col       = (1.0, 0.55, 0.10, 0.45)
    _line_col       = (1.0, 0.70, 0.20, 0.95)
    _last           = (None, None)

    _MODE_COLS = {
        'SLOT': ((1.00,0.55,0.10,0.45),(1.00,0.70,0.20,0.95)),
        'MESH': ((0.15,0.55,1.00,0.40),(0.30,0.70,1.00,0.95)),
        'FACE': ((0.10,1.00,0.35,0.50),(0.20,1.00,0.45,0.95)),
        'FILL': ((0.65,0.15,1.00,0.45),(0.85,0.35,1.00,0.95)),
    }

    def _recompute_hl(self, context, obj, fi):
        self._hl_tris = []; self._hl_loops = []
        if obj is None or not self._show_ovl: return
        props = context.window_manager.matdropper_props
        mode  = self._target
        if   mode == 'MESH': idxs = list(range(len(obj.data.polygons)))
        elif mode == 'FACE': idxs = [fi] if fi is not None else []
        elif mode == 'FILL' and fi is not None:
            idxs = list(flood_fill_faces(
                obj, fi,
                props.fill_use_sharp, props.fill_use_seam, props.fill_use_angle,
                props.fill_angle))
        else: idxs = faces_for_slot(obj, slot_at_face(obj, fi))
        t, l = faces_to_2d(context.region, context.region_data, obj, idxs)
        self._hl_tris = t; self._hl_loops = l

    def modal(self, context, event):
        if context.area: context.area.tag_redraw()
        self._mx = event.mouse_region_x
        self._my = event.mouse_region_y

        props = context.window_manager.matdropper_props
        new_mat = bpy.data.materials.get(props.constant_material)
        if new_mat:
            self._mat = new_mat
        elif not props.constant_material:
            # Cancelled externally (e.g. by clicking the status box button)
            self._cleanup(context)
            return {'CANCELLED'}

        self._target   = props.assign_target
        self._show_ovl = props.show_overlays
        fc, lc = self._MODE_COLS.get(self._target, self._MODE_COLS['SLOT'])
        self._fill_col = fc; self._line_col = lc

        if self._target == 'FILL':
            parts = []
            if props.fill_use_sharp: parts.append("Sharp")
            if props.fill_use_seam:  parts.append("Seam")
            if props.fill_use_angle:
                deg = round(math.degrees(props.fill_angle), 1)
                parts.append(f"Angle {deg:.4g}\u00b0")
            self._boundary_label = ", ".join(parts) if parts else "Mesh Island"

        # Region bounds check
        mouse_in_viewport = False
        if context.area:
            ax = event.mouse_x; ay = event.mouse_y
            in_window = False; in_panel = False
            for region in context.area.regions:
                rx = region.x; ry = region.y
                if rx <= ax < rx+region.width and ry <= ay < ry+region.height:
                    if region.type == 'WINDOW':
                        in_window = True
                    elif region.type in {'UI','TOOLS','HEADER','TOOL_HEADER','FOOTER'}:
                        in_panel = True
            mouse_in_viewport = in_window and not in_panel

        if event.type == 'MOUSEMOVE' and mouse_in_viewport:
            obj, fi, _ = ray_cast_object(context, event)
            if (obj, fi) != self._last:
                self._last = (obj, fi)
                self._recompute_hl(context, obj, fi)

        if event.type == 'LEFTMOUSE' and event.value == 'PRESS':
            if mouse_in_viewport:
                obj, fi, _ = ray_cast_object(context, event)
                if obj:
                    do_assign(context, obj, fi, self._mat, self._target, props)
                    # Tag the mesh as changed so Blender's undo system picks it up.
                    # undo_push must come AFTER the data change.
                    try:
                        bpy.ops.ed.undo_push(message="Assign Material")
                    except Exception:
                        pass
                    self._last = (None, None)   # force highlight refresh next move
                    return {'RUNNING_MODAL'}
            return {'PASS_THROUGH'}

        if event.type in {'RIGHTMOUSE', 'ESC'}:
            props.constant_material = ""
            self._cleanup(context)
            return {'CANCELLED'}

        return {'PASS_THROUGH'}

    def invoke(self, context, event):
        mat = bpy.data.materials.get(self.material_name)
        if not mat:
            self.report({'WARNING'}, "Material not found")
            return {'CANCELLED'}

        props = context.window_manager.matdropper_props
        props.constant_material = mat.name
        props.last_material    = mat.name
        self._mat            = mat
        self._mx             = event.mouse_region_x
        self._my             = event.mouse_region_y
        self._show_ovl       = props.show_overlays
        self._target         = props.assign_target
        self._boundary_label = ""
        self._hl_tris        = []; self._hl_loops = []
        self._last           = (None, None)
        fc, lc = self._MODE_COLS.get(self._target, self._MODE_COLS['SLOT'])
        self._fill_col = fc; self._line_col = lc

        if self._draw_handle is not None:
            bpy.types.SpaceView3D.draw_handler_remove(self._draw_handle, 'WINDOW')
            self._draw_handle = None

        self._draw_handle = bpy.types.SpaceView3D.draw_handler_add(
            draw_overlay, (self, context), 'WINDOW', 'POST_PIXEL')
        context.window_manager.modal_handler_add(self)
        return {'RUNNING_MODAL'}

    def _cleanup(self, context):
        if self._draw_handle:
            bpy.types.SpaceView3D.draw_handler_remove(self._draw_handle, 'WINDOW')
            self._draw_handle = None
        self._hl_tris = []; self._hl_loops = []
        if context.area: context.area.tag_redraw()


# ---------------------------------------------------------------------------
# Clear filter
# ---------------------------------------------------------------------------

class MATDROPPER_OT_ClearFilter(Operator):
    """Clear the search filter"""
    bl_idname = "matdropper.clear_filter"
    bl_label  = "Clear Filter"
    def execute(self, context):
        context.window_manager.matdropper_props.filter_text = ""
        return {'FINISHED'}


# ---------------------------------------------------------------------------
# Refresh
# ---------------------------------------------------------------------------

class MATDROPPER_OT_Refresh(Operator):
    """Refresh material list and previews"""
    bl_idname = "matdropper.refresh"
    bl_label  = "Refresh"
    def execute(self, context):
        global _V3U; _V3U = None
        try:
            for mat in bpy.data.materials: mat.preview_ensure()
        except: pass
        context.area.tag_redraw()
        return {'FINISHED'}


# ---------------------------------------------------------------------------
# Panel
# ---------------------------------------------------------------------------

class MATDROPPER_PT_Panel(Panel):
    bl_label       = "Material Dropper"
    bl_idname      = "MATDROPPER_PT_Panel"
    bl_space_type  = 'VIEW_3D'
    bl_region_type = 'UI'
    bl_category    = "MatDrop"

    def draw(self, context):
        layout = self.layout
        props  = context.window_manager.matdropper_props
        locked = props.constant_material

        # ── Object filter ────────────────────────────────────────
        layout.prop(props, "filter_object", text="", icon='OBJECT_DATA')

        # ── Search + clear ───────────────────────────────────────
        row = layout.row(align=True)
        row.prop(props, "filter_text", text="", icon='VIEWZOOM', placeholder="Search...")
        if props.filter_text:
            row.operator("matdropper.clear_filter", text="", icon='X')

        layout.separator(factor=0.4)

        # ── Assign target ────────────────────────────────────────
        col = layout.column(align=True)
        col.label(text="Assign to:")
        row = col.row(align=True)
        row.scale_y = 1.15
        row.prop(props, "assign_target", expand=True)

        if props.assign_target == 'FILL':
            box = col.box()
            box.label(text="Fill Boundary Types", icon='EDGESEL')
            row2 = box.row(align=True)
            def _btn(flag, active):
                op = row2.operator("matdropper.toggle_boundary",
                                    text=flag.capitalize(), depress=active, emboss=True)
                op.flag = flag
            _btn('SHARP', props.fill_use_sharp)
            _btn('SEAM',  props.fill_use_seam)
            _btn('ANGLE', props.fill_use_angle)
            if props.fill_use_angle:
                box.prop(props, "fill_angle")

        layout.separator(factor=0.4)

        # ── Options ──────────────────────────────────────────────
        col2 = layout.column(align=True)
        col2.prop(props, "show_thumbnails")
        col2.prop(props, "show_overlays")

        layout.separator(factor=0.4)

        # ── Active material status (always visible) ──────────────
        sb = layout.box()
        if locked:
            row = sb.row(align=True)
            row.alert = True
            op = row.operator("matdropper.cancel_assign",
                               text=locked + "  –  ESC to cancel",
                               icon='BRUSH_DATA',
                               emboss=True)
        else:
            row = sb.row(align=True)
            last = props.last_material
            if last and bpy.data.materials.get(last):
                op = row.operator("matdropper.constant_assign",
                                   text=f"Last material: {last}",
                                   icon='MATERIAL',
                                   emboss=True)
                op.material_name = last
            else:
                row.alignment = 'CENTER'
                row.label(text="Last material: none")

        # ── Material list ─────────────────────────────────────────
        filter_obj = props.filter_object
        if filter_obj:
            materials = sorted(
                [s.material for s in filter_obj.material_slots if s.material is not None],
                key=lambda m: m.name.lower()
            )
            if not materials:
                layout.label(text="The object has no assigned materials.", icon='INFO')
                return
        else:
            materials = sorted(get_all_scene_materials(), key=lambda m: m.name.lower())

        try:
            for m in materials: m.preview_ensure()
        except: pass

        ft = props.filter_text.lower()
        if ft:
            materials = [m for m in materials if ft in m.name.lower()]

        if not materials:
            layout.label(text="No materials found", icon='ERROR')
            return

        # Sort: favourites first (alphabetical), then rest (alphabetical)
        favs = get_favourites(props)
        fav_mats  = [m for m in materials if m.name in favs]
        rest_mats = [m for m in materials if m.name not in favs]
        materials = fav_mats + rest_mats

        show_thumb = props.show_thumbnails

        non_fav_collapsed = props.non_fav_collapsed

        for idx, mat in enumerate(materials):
            # Clickable separator between favourites and the rest
            if idx == len(fav_mats) and fav_mats and rest_mats:
                sep_tria = "\u25b2" if not non_fav_collapsed else "\u25bc"
                sep_box = layout.box()
                sep_row = sep_box.row()
                sep_row.scale_y = 0.75
                sep_row.operator("matdropper.toggle_non_fav",
                                  text=sep_tria, emboss=True)

            # Skip non-favourites when collapsed
            if idx >= len(fav_mats) and non_fav_collapsed:
                continue

            is_locked = mat.name == locked
            is_fav    = mat.name in favs

            row = layout.row(align=True)
            row.scale_y = 0.85
            if is_locked:
                row.alert = True

            # Material icon / thumbnail
            icon_val = (mat.preview.icon_id
                        if show_thumb and mat.preview and mat.preview.icon_id
                        else 0)

            # Both buttons in the same row with NORMAL emboss so hover
            # highlights the entire row regardless of which button is hovered
            row.emboss = 'NORMAL'

            # Name button (expands, left-aligned, flat)
            name_sub = row.row(align=True)
            name_sub.emboss = 'NONE'
            name_sub.alignment = 'LEFT'
            if icon_val:
                op = name_sub.operator(
                    "matdropper.constant_assign",
                    text=mat.name,
                    icon_value=icon_val,
                    depress=is_locked,
                )
            else:
                op = name_sub.operator(
                    "matdropper.constant_assign",
                    text=mat.name,
                    icon='MATERIAL',
                    depress=is_locked,
                )
            op.material_name = mat.name

            # Pin + Fake User icons together, right-aligned as a group
            icons_sub = row.row(align=True)
            icons_sub.emboss = 'NONE'
            icons_sub.alignment = 'RIGHT'
            fav_op = icons_sub.operator(
                "matdropper.toggle_favourite",
                text="",
                icon='PINNED' if is_fav else 'UNPINNED',
                depress=is_fav,
            )
            fav_op.material_name = mat.name
            fake_op = icons_sub.operator(
                "matdropper.toggle_fake_user",
                text="",
                icon='FAKE_USER_ON' if mat.use_fake_user else 'FAKE_USER_OFF',
                depress=mat.use_fake_user,
            )
            fake_op.material_name = mat.name



# ---------------------------------------------------------------------------
# Operator: toggle fake user
# ---------------------------------------------------------------------------

class MATDROPPER_OT_ToggleFakeUser(Operator):
    """Toggle Fake User for this material."""
    bl_idname  = "matdropper.toggle_fake_user"
    bl_label   = "Toggle Fake User"
    bl_options = {'REGISTER', 'UNDO'}

    material_name: StringProperty()

    def execute(self, context):
        mat = bpy.data.materials.get(self.material_name)
        if mat:
            mat.use_fake_user = not mat.use_fake_user
        return {'FINISHED'}


# ---------------------------------------------------------------------------
# Add-on Preferences (shown in Edit > Preferences > Add-ons)
# ---------------------------------------------------------------------------

class MATDROPPER_AddonPreferences(bpy.types.AddonPreferences):
    bl_idname = __name__

    def draw(self, context):
        layout = self.layout

        layout.label(text="Click a material to lock it, then left-click objects/faces to assign. ESC cancels.")

        layout.separator()
        layout.label(text="Assign Modes:", icon='MATERIAL')
        col = layout.column(align=True)
        col.scale_y = 0.85
        col.label(text="  Slot  –  Material slot of the face under cursor")
        col.label(text="  Mesh  –  Replace material in every slot, preserving slot structure")
        col.label(text="  Face  –  Single face, adds new slot if necessary")
        col.label(text="  Fill   –  Flood-fill from face, stopped by active boundary type, adds new slot if necessary")

        layout.separator()
        layout.label(text="Fill Boundaries  (CTRL+click for multi-select):", icon='EDGESEL')
        col2 = layout.column(align=True)
        col2.scale_y = 0.85
        col2.label(text="  Sharp  –  Sharp edges")
        col2.label(text="  Seam   –  Seam edges")
        col2.label(text="  Angle  –  Edges whose dihedral angle exceeds the threshold")
        col2.label(text="  (none) –  Fills entire connected mesh island")

        layout.separator()
        layout.label(text="Note:", icon='INFO')
        layout.label(text="  Element highlighting (Show Overlays) can be slow with larger face counts.")




classes = (
    MATDROPPER_AddonPreferences,
    MATDROPPER_Properties,
    MATDROPPER_OT_CancelAssign,
    MATDROPPER_OT_ToggleNonFav,
    MATDROPPER_OT_ToggleFakeUser,
    MATDROPPER_OT_ToggleFavourite,
    MATDROPPER_OT_ToggleBoundary,
    MATDROPPER_OT_ClearFilter,
    MATDROPPER_OT_ConstantAssign,
    MATDROPPER_OT_Refresh,
    MATDROPPER_PT_Panel,
)

def register():
    global _V3U; _V3U = None
    for cls in classes:
        bpy.utils.register_class(cls)
    # WindowManager properties are never written to the undo stack,
    # so changing them (picking a material, switching mode, etc.) will
    # never create or consume undo steps.
    bpy.types.WindowManager.matdropper_props = PointerProperty(type=MATDROPPER_Properties)

def unregister():
    for cls in reversed(classes):
        bpy.utils.unregister_class(cls)
    if hasattr(bpy.types.WindowManager, 'matdropper_props'):
        del bpy.types.WindowManager.matdropper_props

if __name__ == "__main__":
    register()
