diff --git a/Packages/luticalab.core/Languages/English.json b/Packages/luticalab.core/Languages/English.json
index f95c0ca..ad8089c 100644
--- a/Packages/luticalab.core/Languages/English.json
+++ b/Packages/luticalab.core/Languages/English.json
@@ -156,7 +156,56 @@
"normal_map_settings": "Normal Map Settings",
"normal_strength": "Normal Strength",
"height_scale": "Height Scale",
- "normal_map_info": "Converts height map (grayscale) to normal map for 3D surface details."
+ "normal_map_info": "Converts height map (grayscale) to normal map for 3D surface details.",
+
+ "plugin_browser_title": "TextureCocktail Plugin Browser",
+ "plugin_browser_desc": "All TextureCocktailContent subclasses found in loaded assemblies are listed here.",
+ "plugin_browser_howto": "To create a plugin: inherit from TextureCocktailContent and create a matching shader.",
+ "plugin_refresh": "Refresh",
+ "plugin_count": "Registered Plugins ({0})",
+ "plugin_class": "Class",
+ "plugin_assembly": "Assembly",
+ "plugin_author": "Author",
+ "plugin_version": "Version",
+ "plugin_description": "Description",
+
+ "texture_optimizer_title": "Texture Optimizer",
+ "texture_optimizer_scan_settings": "Scan Settings",
+ "texture_optimizer_folder": "Folder to Scan",
+ "texture_optimizer_platform": "Target Platform",
+ "texture_optimizer_max_size": "Max Allowed Size (px)",
+ "texture_optimizer_show_issues": "Show Only Textures With Issues",
+ "texture_optimizer_scan_btn": "Scan Textures",
+ "texture_optimizer_found": "Found {0} texture(s). {1} selected.",
+ "texture_optimizer_select_all": "Select All",
+ "texture_optimizer_deselect_all": "Deselect All",
+ "texture_optimizer_apply_fixes": "Apply Recommended Fixes to Selected",
+ "texture_optimizer_applied": "Applied fixes to {0} texture(s). Re-scan to verify.",
+ "texture_optimizer_issues": "Issues",
+ "texture_optimizer_ping": "Ping Asset",
+ "texture_optimizer_fix": "Fix This Texture",
+
+ "ollama_title": "Ollama Local AI Connector",
+ "ollama_desc": "Connects to a local Ollama server. Input: text prompt (+ optional image). Output: AI-generated text.",
+ "ollama_server": "Server Configuration",
+ "ollama_url": "Ollama URL",
+ "ollama_list_models": "List Models",
+ "ollama_model": "Model",
+ "ollama_model_manual": "Model (manual)",
+ "ollama_prompt_section": "Prompt",
+ "ollama_prompt_label": "Text Prompt:",
+ "ollama_attach_texture": "Attach Texture (vision models)",
+ "ollama_send": "Send Prompt",
+ "ollama_cancel": "Cancel",
+ "ollama_response_section": "Response",
+ "ollama_no_response": "(no response yet)",
+ "ollama_input_context": "Input Image Context:",
+ "ollama_response_image": "Response Image:",
+ "ollama_save_image": "Save Response Image...",
+ "ollama_clear": "Clear",
+ "ollama_ready": "Ready. Configure server URL and click 'List Models'.",
+ "ollama_waiting": "Waiting for Ollama response...",
+ "ollama_vision_help": "Requires a vision model (e.g. llava). The texture is converted to PNG and sent as base64."
}
}
\ No newline at end of file
diff --git a/Packages/luticalab.core/Languages/Japanese.json b/Packages/luticalab.core/Languages/Japanese.json
index 55c257b..3cd456c 100644
--- a/Packages/luticalab.core/Languages/Japanese.json
+++ b/Packages/luticalab.core/Languages/Japanese.json
@@ -159,7 +159,56 @@
"normal_map_settings": "ノーマルマップ設定",
"normal_strength": "ノーマル強度",
"height_scale": "高さスケール",
- "normal_map_info": "ハイトマップ(グレースケール)を3Dサーフェスディテール用のノーマルマップに変換します。"
+ "normal_map_info": "ハイトマップ(グレースケール)を3Dサーフェスディテール用のノーマルマップに変換します。",
+
+ "plugin_browser_title": "TextureCocktail プラグインブラウザー",
+ "plugin_browser_desc": "ロードされたアセンブリで見つかったすべてのTextureCocktailContentサブクラスがここに表示されます。",
+ "plugin_browser_howto": "プラグイン作成方法: TextureCocktailContentを継承し、同じ名前のシェーダーを作成してください。",
+ "plugin_refresh": "更新",
+ "plugin_count": "登録プラグイン ({0})",
+ "plugin_class": "クラス",
+ "plugin_assembly": "アセンブリ",
+ "plugin_author": "作者",
+ "plugin_version": "バージョン",
+ "plugin_description": "説明",
+
+ "texture_optimizer_title": "テクスチャ最適化ツール",
+ "texture_optimizer_scan_settings": "スキャン設定",
+ "texture_optimizer_folder": "スキャンするフォルダー",
+ "texture_optimizer_platform": "対象プラットフォーム",
+ "texture_optimizer_max_size": "最大許容サイズ (px)",
+ "texture_optimizer_show_issues": "問題のあるテクスチャのみ表示",
+ "texture_optimizer_scan_btn": "テクスチャをスキャン",
+ "texture_optimizer_found": "テクスチャ {0} 個を検出。{1} 個選択中。",
+ "texture_optimizer_select_all": "すべて選択",
+ "texture_optimizer_deselect_all": "すべて選択解除",
+ "texture_optimizer_apply_fixes": "選択したテクスチャに推奨修正を適用",
+ "texture_optimizer_applied": "{0} 個のテクスチャに修正を適用しました。再スキャンして確認してください。",
+ "texture_optimizer_issues": "問題点",
+ "texture_optimizer_ping": "アセットを強調表示",
+ "texture_optimizer_fix": "このテクスチャを修正",
+
+ "ollama_title": "Ollama ローカルAI連携",
+ "ollama_desc": "ローカルOllamaサーバーに接続します。入力: テキストプロンプト(+オプション画像)。出力: AI生成テキスト。",
+ "ollama_server": "サーバー設定",
+ "ollama_url": "Ollama URL",
+ "ollama_list_models": "モデル一覧",
+ "ollama_model": "モデル",
+ "ollama_model_manual": "モデル (手動入力)",
+ "ollama_prompt_section": "プロンプト",
+ "ollama_prompt_label": "テキストプロンプト:",
+ "ollama_attach_texture": "テクスチャを添付 (ビジョンモデル用)",
+ "ollama_send": "プロンプトを送信",
+ "ollama_cancel": "キャンセル",
+ "ollama_response_section": "応答",
+ "ollama_no_response": "(まだ応答なし)",
+ "ollama_input_context": "入力画像コンテキスト:",
+ "ollama_response_image": "応答画像:",
+ "ollama_save_image": "応答画像を保存...",
+ "ollama_clear": "クリア",
+ "ollama_ready": "準備完了。サーバーURLを設定して「モデル一覧」をクリックしてください。",
+ "ollama_waiting": "Ollamaの応答を待っています...",
+ "ollama_vision_help": "ビジョンモデル(例: llava)が必要です。テクスチャはPNGに変換されbase64で送信されます。"
}
}
\ No newline at end of file
diff --git a/Packages/luticalab.core/Languages/Korean.json b/Packages/luticalab.core/Languages/Korean.json
index 5d59751..727391f 100644
--- a/Packages/luticalab.core/Languages/Korean.json
+++ b/Packages/luticalab.core/Languages/Korean.json
@@ -155,6 +155,55 @@
"normal_map_settings": "노말 맵 설정",
"normal_strength": "노말 강도",
"height_scale": "높이 스케일",
- "normal_map_info": "하이트 맵(그레이스케일)을 3D 표면 디테일용 노말 맵으로 변환합니다."
+ "normal_map_info": "하이트 맵(그레이스케일)을 3D 표면 디테일용 노말 맵으로 변환합니다.",
+
+ "plugin_browser_title": "텍스처 칵테일 플러그인 브라우저",
+ "plugin_browser_desc": "로드된 어셈블리에서 발견된 모든 TextureCocktailContent 서브클래스가 여기에 나열됩니다.",
+ "plugin_browser_howto": "플러그인 생성 방법: TextureCocktailContent를 상속하고 동일한 이름의 셰이더를 생성하세요.",
+ "plugin_refresh": "새로 고침",
+ "plugin_count": "등록된 플러그인 ({0})",
+ "plugin_class": "클래스",
+ "plugin_assembly": "어셈블리",
+ "plugin_author": "제작자",
+ "plugin_version": "버전",
+ "plugin_description": "설명",
+
+ "texture_optimizer_title": "텍스처 최적화 도구",
+ "texture_optimizer_scan_settings": "스캔 설정",
+ "texture_optimizer_folder": "스캔할 폴더",
+ "texture_optimizer_platform": "대상 플랫폼",
+ "texture_optimizer_max_size": "최대 허용 크기 (px)",
+ "texture_optimizer_show_issues": "문제가 있는 텍스처만 표시",
+ "texture_optimizer_scan_btn": "텍스처 스캔",
+ "texture_optimizer_found": "텍스처 {0}개 발견. {1}개 선택됨.",
+ "texture_optimizer_select_all": "모두 선택",
+ "texture_optimizer_deselect_all": "모두 해제",
+ "texture_optimizer_apply_fixes": "선택된 항목에 권장 수정 사항 적용",
+ "texture_optimizer_applied": "{0}개 텍스처에 수정 사항이 적용되었습니다. 재스캔하여 확인하세요.",
+ "texture_optimizer_issues": "문제점",
+ "texture_optimizer_ping": "에셋 강조 표시",
+ "texture_optimizer_fix": "이 텍스처 수정",
+
+ "ollama_title": "Ollama 로컬 AI 연동",
+ "ollama_desc": "로컬 Ollama 서버에 연결합니다. 입력: 텍스트 프롬프트 (+ 선택적 이미지). 출력: AI 생성 텍스트.",
+ "ollama_server": "서버 설정",
+ "ollama_url": "Ollama URL",
+ "ollama_list_models": "모델 목록",
+ "ollama_model": "모델",
+ "ollama_model_manual": "모델 (직접 입력)",
+ "ollama_prompt_section": "프롬프트",
+ "ollama_prompt_label": "텍스트 프롬프트:",
+ "ollama_attach_texture": "텍스처 첨부 (비전 모델용)",
+ "ollama_send": "프롬프트 전송",
+ "ollama_cancel": "취소",
+ "ollama_response_section": "응답",
+ "ollama_no_response": "(아직 응답 없음)",
+ "ollama_input_context": "입력 이미지 컨텍스트:",
+ "ollama_response_image": "응답 이미지:",
+ "ollama_save_image": "응답 이미지 저장...",
+ "ollama_clear": "지우기",
+ "ollama_ready": "준비됨. 서버 URL을 설정하고 '모델 목록'을 클릭하세요.",
+ "ollama_waiting": "Ollama 응답을 기다리는 중...",
+ "ollama_vision_help": "비전 모델(예: llava)이 필요합니다. 텍스처가 PNG로 변환되어 base64로 전송됩니다."
}
}
\ No newline at end of file
diff --git a/Packages/luticalab.texturecocktail/Editor/AI.meta b/Packages/luticalab.texturecocktail/Editor/AI.meta
new file mode 100644
index 0000000..6730982
--- /dev/null
+++ b/Packages/luticalab.texturecocktail/Editor/AI.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: c528b4bd8d294f11ad9ab0e530e11f24
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/luticalab.texturecocktail/Editor/AI/AiBackendBase.cs b/Packages/luticalab.texturecocktail/Editor/AI/AiBackendBase.cs
new file mode 100644
index 0000000..d087299
--- /dev/null
+++ b/Packages/luticalab.texturecocktail/Editor/AI/AiBackendBase.cs
@@ -0,0 +1,80 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using UnityEngine;
+
+namespace LuticaLab.TextureCocktail
+{
+ ///
+ /// Payload sent to an AI backend.
+ ///
+ public struct AiRequest
+ {
+ /// Text prompt to send.
+ public string Prompt;
+
+ /// Optional image attachment for vision models. May be null.
+ public Texture2D AttachedImage;
+ }
+
+ ///
+ /// Response received from an AI backend.
+ ///
+ public struct AiResponse
+ {
+ /// Whether the call succeeded.
+ public bool Success;
+
+ /// The text portion of the response.
+ public string Text;
+
+ /// Optional decoded image returned in the response. May be null.
+ public Texture2D Image;
+
+ /// Error message when is false.
+ public string Error;
+ }
+
+ ///
+ /// Abstract base class for local / remote AI backends.
+ ///
+ /// Implement this to add a new AI provider. The
+ /// discovers all concrete subclasses at runtime and presents them in a dropdown.
+ ///
+ /// Implementations live in Editor/AI/ — see and
+ /// for reference examples.
+ ///
+ public abstract class AiBackendBase
+ {
+ /// Human-readable name shown in the backend selector.
+ public abstract string DisplayName { get; }
+
+ /// Default server URL pre-filled in the UI.
+ public abstract string DefaultServerUrl { get; }
+
+ ///
+ /// Whether this backend can accept an image alongside the text prompt.
+ /// When false the image attachment UI is hidden for this backend.
+ ///
+ public abstract bool SupportsImageInput { get; }
+
+ ///
+ /// Returns the list of model names available on the server.
+ /// Throw on network failure so the caller can surface the error.
+ ///
+ public abstract Task> FetchModelsAsync(
+ string serverUrl,
+ CancellationToken ct = default);
+
+ ///
+ /// Sends a prompt and returns the response.
+ /// The implementation must not throw — return = false
+ /// with a populated instead.
+ ///
+ public abstract Task SendPromptAsync(
+ string serverUrl,
+ string model,
+ AiRequest request,
+ CancellationToken ct = default);
+ }
+}
diff --git a/Packages/luticalab.texturecocktail/Editor/AI/AiBackendBase.cs.meta b/Packages/luticalab.texturecocktail/Editor/AI/AiBackendBase.cs.meta
new file mode 100644
index 0000000..4e5419f
--- /dev/null
+++ b/Packages/luticalab.texturecocktail/Editor/AI/AiBackendBase.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 2b9eb61f63254aa29168080e396e34a9
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/luticalab.texturecocktail/Editor/AI/AiTextureUtils.cs b/Packages/luticalab.texturecocktail/Editor/AI/AiTextureUtils.cs
new file mode 100644
index 0000000..6ba1d21
--- /dev/null
+++ b/Packages/luticalab.texturecocktail/Editor/AI/AiTextureUtils.cs
@@ -0,0 +1,68 @@
+using System;
+using UnityEngine;
+
+namespace LuticaLab.TextureCocktail
+{
+ ///
+ /// Shared texture encoding/decoding utilities used by all AI backend implementations.
+ ///
+ public static class AiTextureUtils
+ {
+ ///
+ /// Encodes a to a base64 PNG string.
+ /// Handles non-readable textures by blitting through a temporary RenderTexture.
+ /// Returns null on failure.
+ ///
+ public static string TextureToBase64(Texture2D tex)
+ {
+ if (tex == null) return null;
+ try
+ {
+ byte[] pngBytes;
+ if (tex.isReadable)
+ {
+ pngBytes = tex.EncodeToPNG();
+ }
+ else
+ {
+ var rt = new RenderTexture(tex.width, tex.height, 0, RenderTextureFormat.ARGB32);
+ Graphics.Blit(tex, rt);
+ RenderTexture.active = rt;
+ var readable = new Texture2D(tex.width, tex.height, TextureFormat.RGBA32, false);
+ readable.ReadPixels(new Rect(0, 0, rt.width, rt.height), 0, 0);
+ readable.Apply();
+ RenderTexture.active = null;
+ rt.Release();
+ pngBytes = readable.EncodeToPNG();
+ UnityEngine.Object.DestroyImmediate(readable);
+ }
+ return Convert.ToBase64String(pngBytes);
+ }
+ catch (Exception ex)
+ {
+ Debug.LogWarning($"[AiTextureUtils] Could not encode texture to base64: {ex.Message}");
+ return null;
+ }
+ }
+
+ ///
+ /// Decodes a base64-encoded image (PNG/JPEG) into a .
+ /// Returns null on failure.
+ ///
+ public static Texture2D Base64ToTexture(string base64)
+ {
+ if (string.IsNullOrEmpty(base64)) return null;
+ try
+ {
+ byte[] bytes = Convert.FromBase64String(base64);
+ var tex = new Texture2D(2, 2);
+ tex.LoadImage(bytes);
+ return tex;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+ }
+}
diff --git a/Packages/luticalab.texturecocktail/Editor/AI/AiTextureUtils.cs.meta b/Packages/luticalab.texturecocktail/Editor/AI/AiTextureUtils.cs.meta
new file mode 100644
index 0000000..c176488
--- /dev/null
+++ b/Packages/luticalab.texturecocktail/Editor/AI/AiTextureUtils.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: da0e05e879e14c7f94ee6b02e5494150
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/luticalab.texturecocktail/Editor/AI/OllamaBackend.cs b/Packages/luticalab.texturecocktail/Editor/AI/OllamaBackend.cs
new file mode 100644
index 0000000..7c8701f
--- /dev/null
+++ b/Packages/luticalab.texturecocktail/Editor/AI/OllamaBackend.cs
@@ -0,0 +1,124 @@
+using System;
+using System.Collections.Generic;
+using System.Net.Http;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+using UnityEngine;
+
+namespace LuticaLab.TextureCocktail
+{
+ ///
+ /// AI backend for a local Ollama server.
+ ///
+ /// Endpoints used:
+ /// GET /api/tags — list available models
+ /// POST /api/generate — generate a response (non-streaming)
+ ///
+ /// Supports vision models (llava, bakllava, etc.) when an image is attached.
+ ///
+ public class OllamaBackend : AiBackendBase
+ {
+ private static readonly HttpClient _http = new HttpClient { Timeout = TimeSpan.FromSeconds(120) };
+
+ public override string DisplayName => "Ollama";
+ public override string DefaultServerUrl => "http://localhost:11434";
+ public override bool SupportsImageInput => true;
+
+ ///
+ public override async Task> FetchModelsAsync(string serverUrl, CancellationToken ct = default)
+ {
+ string url = serverUrl.TrimEnd('/') + "/api/tags";
+ string json = await _http.GetStringAsync(url);
+ return ParseModelList(json);
+ }
+
+ ///
+ public override async Task SendPromptAsync(
+ string serverUrl,
+ string model,
+ AiRequest request,
+ CancellationToken ct = default)
+ {
+ try
+ {
+ string body = BuildRequestBody(model, request);
+ string url = serverUrl.TrimEnd('/') + "/api/generate";
+
+ var content = new StringContent(body, Encoding.UTF8, "application/json");
+ var response = await _http.PostAsync(url, content, ct);
+ response.EnsureSuccessStatusCode();
+
+ string responseJson = await response.Content.ReadAsStringAsync();
+ return ParseGenerateResponse(responseJson);
+ }
+ catch (OperationCanceledException)
+ {
+ return new AiResponse { Success = false, Error = "Request cancelled." };
+ }
+ catch (Exception ex)
+ {
+ return new AiResponse { Success = false, Error = ex.Message };
+ }
+ }
+
+ // ── Request builder ──────────────────────────────────────────────────
+
+ private static string BuildRequestBody(string model, AiRequest request)
+ {
+ var obj = new JObject
+ {
+ ["model"] = model,
+ ["prompt"] = request.Prompt,
+ ["stream"] = false,
+ };
+
+ if (request.AttachedImage != null)
+ {
+ string b64 = AiTextureUtils.TextureToBase64(request.AttachedImage);
+ if (!string.IsNullOrEmpty(b64))
+ obj["images"] = new JArray(b64);
+ }
+
+ return obj.ToString(Formatting.None);
+ }
+
+ // ── Response parsers ─────────────────────────────────────────────────
+
+ private static List ParseModelList(string json)
+ {
+ var models = new List();
+ var root = JObject.Parse(json);
+ var arr = root["models"] as JArray;
+ if (arr == null) return models;
+
+ foreach (var item in arr)
+ {
+ string name = item["name"]?.ToString();
+ if (!string.IsNullOrEmpty(name))
+ models.Add(name);
+ }
+ return models;
+ }
+
+ private static AiResponse ParseGenerateResponse(string json)
+ {
+ var root = JObject.Parse(json);
+ string text = root["response"]?.ToString() ?? "";
+
+ // Some experimental endpoints include an "images" array in the response
+ Texture2D image = null;
+ var images = root["images"] as JArray;
+ if (images != null && images.Count > 0)
+ {
+ string b64 = images[0]?.ToString();
+ if (!string.IsNullOrEmpty(b64))
+ image = AiTextureUtils.Base64ToTexture(b64);
+ }
+
+ return new AiResponse { Success = true, Text = text, Image = image };
+ }
+ }
+}
diff --git a/Packages/luticalab.texturecocktail/Editor/AI/OllamaBackend.cs.meta b/Packages/luticalab.texturecocktail/Editor/AI/OllamaBackend.cs.meta
new file mode 100644
index 0000000..d520284
--- /dev/null
+++ b/Packages/luticalab.texturecocktail/Editor/AI/OllamaBackend.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 7e4bb5863e1740ba980664455edaaa0f
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/luticalab.texturecocktail/Editor/AI/OpenAiCompatibleBackend.cs b/Packages/luticalab.texturecocktail/Editor/AI/OpenAiCompatibleBackend.cs
new file mode 100644
index 0000000..d37f726
--- /dev/null
+++ b/Packages/luticalab.texturecocktail/Editor/AI/OpenAiCompatibleBackend.cs
@@ -0,0 +1,167 @@
+using System;
+using System.Collections.Generic;
+using System.Net.Http;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+using UnityEngine;
+
+namespace LuticaLab.TextureCocktail
+{
+ ///
+ /// AI backend for any server that implements the OpenAI Chat Completions API.
+ ///
+ /// Compatible products include:
+ /// • LocalAI (https://localai.io)
+ /// • LM Studio (https://lmstudio.ai)
+ /// • Jan (https://jan.ai)
+ /// • Kobold.cpp (https://github.com/LostRuins/koboldcpp)
+ /// • llama.cpp server with --api-prefix /v1
+ /// • text-generation-webui with the OpenAI extension
+ ///
+ /// Endpoints used:
+ /// GET /v1/models — list available models
+ /// POST /v1/chat/completions — generate a response
+ ///
+ /// Vision input follows the OpenAI vision format (base64 data-URI).
+ ///
+ public class OpenAiCompatibleBackend : AiBackendBase
+ {
+ private static readonly HttpClient _http = new HttpClient { Timeout = TimeSpan.FromSeconds(120) };
+
+ public override string DisplayName => "OpenAI-Compatible (LocalAI / LM Studio / Jan …)";
+ public override string DefaultServerUrl => "http://localhost:8080";
+ public override bool SupportsImageInput => true;
+
+ ///
+ public override async Task> FetchModelsAsync(string serverUrl, CancellationToken ct = default)
+ {
+ string url = serverUrl.TrimEnd('/') + "/v1/models";
+ string json = await _http.GetStringAsync(url);
+ return ParseModelList(json);
+ }
+
+ ///
+ public override async Task SendPromptAsync(
+ string serverUrl,
+ string model,
+ AiRequest request,
+ CancellationToken ct = default)
+ {
+ try
+ {
+ string body = BuildRequestBody(model, request);
+ string url = serverUrl.TrimEnd('/') + "/v1/chat/completions";
+
+ var content = new StringContent(body, Encoding.UTF8, "application/json");
+ var response = await _http.PostAsync(url, content, ct);
+ response.EnsureSuccessStatusCode();
+
+ string responseJson = await response.Content.ReadAsStringAsync();
+ return ParseChatResponse(responseJson);
+ }
+ catch (OperationCanceledException)
+ {
+ return new AiResponse { Success = false, Error = "Request cancelled." };
+ }
+ catch (Exception ex)
+ {
+ return new AiResponse { Success = false, Error = ex.Message };
+ }
+ }
+
+ // ── Request builder ──────────────────────────────────────────────────
+
+ private static string BuildRequestBody(string model, AiRequest request)
+ {
+ // Build the message content — either plain string or mixed array for vision
+ JToken messageContent;
+ if (request.AttachedImage != null)
+ {
+ string b64 = AiTextureUtils.TextureToBase64(request.AttachedImage);
+ if (!string.IsNullOrEmpty(b64))
+ {
+ // OpenAI vision format: content is an array of text + image_url parts
+ messageContent = new JArray(
+ new JObject { ["type"] = "text", ["text"] = request.Prompt },
+ new JObject
+ {
+ ["type"] = "image_url",
+ ["image_url"] = new JObject
+ {
+ ["url"] = $"data:image/png;base64,{b64}"
+ }
+ }
+ );
+ }
+ else
+ {
+ messageContent = request.Prompt;
+ }
+ }
+ else
+ {
+ messageContent = request.Prompt;
+ }
+
+ var obj = new JObject
+ {
+ ["model"] = model,
+ ["messages"] = new JArray(
+ new JObject
+ {
+ ["role"] = "user",
+ ["content"] = messageContent,
+ }
+ ),
+ };
+
+ return obj.ToString(Formatting.None);
+ }
+
+ // ── Response parsers ─────────────────────────────────────────────────
+
+ private static List ParseModelList(string json)
+ {
+ var models = new List();
+ var root = JObject.Parse(json);
+ var arr = root["data"] as JArray;
+ if (arr == null) return models;
+
+ foreach (var item in arr)
+ {
+ string id = item["id"]?.ToString();
+ if (!string.IsNullOrEmpty(id))
+ models.Add(id);
+ }
+ return models;
+ }
+
+ private static AiResponse ParseChatResponse(string json)
+ {
+ var root = JObject.Parse(json);
+
+ // Standard OpenAI response: choices[0].message.content
+ var choices = root["choices"] as JArray;
+ if (choices == null || choices.Count == 0)
+ return new AiResponse { Success = false, Error = "No choices in response." };
+
+ var message = choices[0]?["message"];
+ string text = message?["content"]?.ToString() ?? "";
+
+ // Some custom endpoints return an images array at the top level
+ Texture2D image = null;
+ var images = root["images"] as JArray;
+ if (images != null && images.Count > 0)
+ {
+ string b64 = images[0]?.ToString();
+ if (!string.IsNullOrEmpty(b64))
+ image = AiTextureUtils.Base64ToTexture(b64);
+ }
+
+ return new AiResponse { Success = true, Text = text, Image = image };
+ }
+ }
+}
diff --git a/Packages/luticalab.texturecocktail/Editor/AI/OpenAiCompatibleBackend.cs.meta b/Packages/luticalab.texturecocktail/Editor/AI/OpenAiCompatibleBackend.cs.meta
new file mode 100644
index 0000000..a5aafef
--- /dev/null
+++ b/Packages/luticalab.texturecocktail/Editor/AI/OpenAiCompatibleBackend.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 2fb6cd46fd1046ddab64e13822740bb5
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/luticalab.texturecocktail/Editor/OllamaConnector.cs b/Packages/luticalab.texturecocktail/Editor/OllamaConnector.cs
new file mode 100644
index 0000000..8826576
--- /dev/null
+++ b/Packages/luticalab.texturecocktail/Editor/OllamaConnector.cs
@@ -0,0 +1,352 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading;
+using UnityEditor;
+using UnityEngine;
+
+namespace LuticaLab.TextureCocktail
+{
+ ///
+ /// Multi-backend AI connector editor window.
+ ///
+ /// Open via: LuticaLab → AI Connector
+ ///
+ /// INPUT : text prompt + (optional) Texture2D for vision models
+ /// OUTPUT : AI-generated text (and an image preview when the response contains one)
+ ///
+ /// Supported backends:
+ /// • Ollama — http://localhost:11434
+ /// • OpenAI-compatible APIs — LocalAI, LM Studio, Jan, Kobold.cpp, llama.cpp, etc.
+ ///
+ /// Add more backends by creating a class that inherits
+ /// anywhere in any loaded assembly — the window discovers them automatically.
+ ///
+ public class OllamaConnector : EditorWindow
+ {
+ // ── Menu item ────────────────────────────────────────────────────────
+ [MenuItem("LuticaLab/AI Connector")]
+ public static void ShowWindow()
+ {
+ GetWindow("AI Connector");
+ }
+
+ // ── Known backends ───────────────────────────────────────────────────
+ private static readonly AiBackendBase[] Backends = new AiBackendBase[]
+ {
+ new OllamaBackend(),
+ new OpenAiCompatibleBackend(),
+ };
+
+ // ── EditorPrefs keys ─────────────────────────────────────────────────
+ private const string PrefsKeyBackend = "TC_AI_Backend";
+ private const string PrefsKeyUrl = "TC_AI_Url";
+ private const string PrefsKeyModel = "TC_AI_Model";
+
+ // ── State ────────────────────────────────────────────────────────────
+ private int _backendIndex = 0;
+ private string _serverUrl = "";
+ private string _selectedModel = "";
+ private List _availableModels = new List();
+ private int _selectedModelIndex = 0;
+
+ private string _promptText = "";
+ private Texture2D _inputTexture;
+ private bool _attachTexture;
+
+ private string _responseText = "";
+ private Texture2D _responseImage;
+ private Vector2 _responseScroll;
+
+ private bool _busy;
+ private string _statusMessage = "";
+ private CancellationTokenSource _cts;
+
+ private AiBackendBase ActiveBackend => Backends[_backendIndex];
+
+ // ── Lifecycle ────────────────────────────────────────────────────────
+ private void OnEnable()
+ {
+ _backendIndex = Mathf.Clamp(EditorPrefs.GetInt(PrefsKeyBackend, 0), 0, Backends.Length - 1);
+ _serverUrl = EditorPrefs.GetString(PrefsKeyUrl, ActiveBackend.DefaultServerUrl);
+ _selectedModel = EditorPrefs.GetString(PrefsKeyModel, "");
+ if (string.IsNullOrEmpty(_statusMessage))
+ _statusMessage = "Ready. Select a backend, configure the server URL, and click 'List Models'.";
+ }
+
+ private void OnDisable()
+ {
+ _cts?.Cancel();
+ EditorPrefs.SetInt(PrefsKeyBackend, _backendIndex);
+ EditorPrefs.SetString(PrefsKeyUrl, _serverUrl);
+ EditorPrefs.SetString(PrefsKeyModel, _selectedModel);
+ }
+
+ // ── GUI ──────────────────────────────────────────────────────────────
+ private void OnGUI()
+ {
+ GUILayout.Label("AI Connector", EditorStyles.boldLabel);
+ EditorGUILayout.HelpBox(
+ "Local AI pipeline: text prompt (+ optional image) → AI-generated text response.\n" +
+ "Supports Ollama and any OpenAI-compatible API (LocalAI, LM Studio, Jan, Kobold…).",
+ MessageType.Info);
+
+ EditorGUILayout.Space(4);
+ DrawBackendSection();
+ EditorGUILayout.Space(4);
+ DrawPromptSection();
+ EditorGUILayout.Space(4);
+ DrawResponseSection();
+ EditorGUILayout.Space(4);
+ DrawStatusBar();
+ }
+
+ // ── Backend / server section ─────────────────────────────────────────
+ private void DrawBackendSection()
+ {
+ EditorGUILayout.BeginVertical(EditorStyles.helpBox);
+ GUILayout.Label("Backend & Server", EditorStyles.boldLabel);
+
+ // Backend selector
+ string[] backendNames = new string[Backends.Length];
+ for (int i = 0; i < Backends.Length; i++)
+ backendNames[i] = Backends[i].DisplayName;
+
+ int newBackendIdx = EditorGUILayout.Popup("Backend", _backendIndex, backendNames);
+ if (newBackendIdx != _backendIndex)
+ {
+ _backendIndex = newBackendIdx;
+ // Pre-fill the default URL for the newly selected backend
+ _serverUrl = ActiveBackend.DefaultServerUrl;
+ _availableModels.Clear();
+ _selectedModel = "";
+ }
+
+ // Server URL + List Models
+ EditorGUILayout.BeginHorizontal();
+ _serverUrl = EditorGUILayout.TextField("Server URL", _serverUrl);
+ GUI.enabled = !_busy;
+ if (GUILayout.Button("List Models", GUILayout.Width(100)))
+ _ = FetchModelsAsync();
+ GUI.enabled = true;
+ EditorGUILayout.EndHorizontal();
+
+ // Model selector
+ if (_availableModels.Count > 0)
+ {
+ _selectedModelIndex = Mathf.Clamp(_selectedModelIndex, 0, _availableModels.Count - 1);
+ int newIdx = EditorGUILayout.Popup("Model", _selectedModelIndex, _availableModels.ToArray());
+ if (newIdx != _selectedModelIndex)
+ {
+ _selectedModelIndex = newIdx;
+ _selectedModel = _availableModels[newIdx];
+ }
+ }
+ else
+ {
+ _selectedModel = EditorGUILayout.TextField("Model (manual)", _selectedModel);
+ }
+
+ EditorGUILayout.EndVertical();
+ }
+
+ // ── Prompt section ───────────────────────────────────────────────────
+ private void DrawPromptSection()
+ {
+ EditorGUILayout.BeginVertical(EditorStyles.helpBox);
+ GUILayout.Label("Prompt", EditorStyles.boldLabel);
+
+ GUILayout.Label("Text Prompt:");
+ _promptText = EditorGUILayout.TextArea(_promptText, GUILayout.MinHeight(80));
+
+ EditorGUILayout.Space(4);
+
+ // Image attachment — only shown for backends that support it
+ if (ActiveBackend.SupportsImageInput)
+ {
+ _attachTexture = EditorGUILayout.Toggle("Attach Texture (vision models)", _attachTexture);
+ if (_attachTexture)
+ {
+ _inputTexture = (Texture2D)EditorGUILayout.ObjectField(
+ "Input Texture", _inputTexture, typeof(Texture2D), false);
+
+ if (_inputTexture != null)
+ {
+ Rect thumbRect = GUILayoutUtility.GetRect(80, 80);
+ GUI.DrawTexture(thumbRect, _inputTexture, ScaleMode.ScaleToFit);
+ }
+
+ EditorGUILayout.HelpBox(
+ "Requires a vision model (e.g. llava for Ollama, or a multimodal model for OpenAI-compatible backends). " +
+ "The texture is converted to PNG and sent as base64.",
+ MessageType.Info);
+ }
+ }
+
+ EditorGUILayout.Space(4);
+
+ EditorGUILayout.BeginHorizontal();
+ GUI.enabled = !_busy && !string.IsNullOrWhiteSpace(_promptText) && !string.IsNullOrEmpty(_selectedModel);
+ if (GUILayout.Button("Send Prompt", GUILayout.Height(32)))
+ _ = SendPromptAsync();
+ GUI.enabled = _busy;
+ if (GUILayout.Button("Cancel", GUILayout.Width(80), GUILayout.Height(32)))
+ _cts?.Cancel();
+ GUI.enabled = true;
+ EditorGUILayout.EndHorizontal();
+
+ EditorGUILayout.EndVertical();
+ }
+
+ // ── Response section ─────────────────────────────────────────────────
+ private void DrawResponseSection()
+ {
+ EditorGUILayout.BeginVertical(EditorStyles.helpBox);
+ GUILayout.Label("Response", EditorStyles.boldLabel);
+
+ _responseScroll = EditorGUILayout.BeginScrollView(_responseScroll, GUILayout.MinHeight(120));
+ if (!string.IsNullOrEmpty(_responseText))
+ {
+ EditorGUILayout.SelectableLabel(_responseText,
+ EditorStyles.wordWrappedLabel,
+ GUILayout.ExpandHeight(true));
+ }
+ else
+ {
+ GUILayout.Label("(no response yet)", EditorStyles.centeredGreyMiniLabel);
+ }
+ EditorGUILayout.EndScrollView();
+
+ // Input texture context
+ if (_attachTexture && _inputTexture != null && !string.IsNullOrEmpty(_responseText))
+ {
+ EditorGUILayout.Space(4);
+ GUILayout.Label("Input Image Context:", EditorStyles.boldLabel);
+ Rect imgRect = GUILayoutUtility.GetRect(160, 120);
+ GUI.DrawTexture(imgRect, _inputTexture, ScaleMode.ScaleToFit);
+ }
+
+ // Response image (if the backend returned one)
+ if (_responseImage != null)
+ {
+ EditorGUILayout.Space(4);
+ GUILayout.Label("Response Image:", EditorStyles.boldLabel);
+ Rect imgRect = GUILayoutUtility.GetRect(200, 200);
+ GUI.DrawTexture(imgRect, _responseImage, ScaleMode.ScaleToFit);
+
+ if (GUILayout.Button("Save Response Image…", GUILayout.Width(180)))
+ SaveResponseImage();
+ }
+
+ if (!string.IsNullOrEmpty(_responseText))
+ {
+ EditorGUILayout.BeginHorizontal();
+ GUILayout.FlexibleSpace();
+ if (GUILayout.Button("Clear", GUILayout.Width(70)))
+ {
+ _responseText = "";
+ _responseImage = null;
+ }
+ EditorGUILayout.EndHorizontal();
+ }
+
+ EditorGUILayout.EndVertical();
+ }
+
+ private void DrawStatusBar()
+ {
+ bool isError = _statusMessage.StartsWith("Error");
+ MessageType msgType = isError ? MessageType.Error : MessageType.Info;
+ EditorGUILayout.HelpBox(_statusMessage, msgType);
+ if (_busy)
+ EditorGUILayout.HelpBox("⏳ Waiting for response…", MessageType.Info);
+ }
+
+ // ── Async operations ─────────────────────────────────────────────────
+
+ private async System.Threading.Tasks.Task FetchModelsAsync()
+ {
+ SetBusy(true, "Fetching model list…");
+ try
+ {
+ var models = await ActiveBackend.FetchModelsAsync(_serverUrl);
+ _availableModels = models;
+
+ int idx = _availableModels.IndexOf(_selectedModel);
+ _selectedModelIndex = idx >= 0 ? idx : 0;
+ if (_availableModels.Count > 0)
+ _selectedModel = _availableModels[_selectedModelIndex];
+
+ SetStatus($"Found {_availableModels.Count} model(s).");
+ }
+ catch (Exception ex)
+ {
+ SetStatus($"Error fetching models: {ex.Message}");
+ }
+ finally
+ {
+ SetBusy(false);
+ }
+ }
+
+ private async System.Threading.Tasks.Task SendPromptAsync()
+ {
+ if (string.IsNullOrWhiteSpace(_promptText) || string.IsNullOrEmpty(_selectedModel))
+ return;
+
+ _cts = new CancellationTokenSource();
+ SetBusy(true, "Sending prompt…");
+ _responseText = "";
+ _responseImage = null;
+
+ var request = new AiRequest
+ {
+ Prompt = _promptText,
+ AttachedImage = (_attachTexture && ActiveBackend.SupportsImageInput) ? _inputTexture : null,
+ };
+
+ AiResponse result = await ActiveBackend.SendPromptAsync(_serverUrl, _selectedModel, request, _cts.Token);
+
+ if (result.Success)
+ {
+ _responseText = result.Text;
+ _responseImage = result.Image;
+ SetStatus("Response received.");
+ }
+ else
+ {
+ SetStatus($"Error: {result.Error}");
+ }
+
+ SetBusy(false);
+ EditorApplication.delayCall += Repaint;
+ }
+
+ // ── Helpers ──────────────────────────────────────────────────────────
+
+ private void SaveResponseImage()
+ {
+ if (_responseImage == null) return;
+ string path = EditorUtility.SaveFilePanel("Save Response Image", "Assets", "ai_response.png", "png");
+ if (!string.IsNullOrEmpty(path))
+ {
+ File.WriteAllBytes(path, _responseImage.EncodeToPNG());
+ AssetDatabase.Refresh();
+ SetStatus($"Image saved to {path}");
+ }
+ }
+
+ private void SetBusy(bool busy, string msg = null)
+ {
+ _busy = busy;
+ if (msg != null) _statusMessage = msg;
+ EditorApplication.delayCall += Repaint;
+ }
+
+ private void SetStatus(string msg)
+ {
+ _statusMessage = msg;
+ EditorApplication.delayCall += Repaint;
+ }
+ }
+}
diff --git a/Packages/luticalab.texturecocktail/Editor/OllamaConnector.cs.meta b/Packages/luticalab.texturecocktail/Editor/OllamaConnector.cs.meta
new file mode 100644
index 0000000..91f4291
--- /dev/null
+++ b/Packages/luticalab.texturecocktail/Editor/OllamaConnector.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: cc023274fdc54f038c4ca5b80e4c04ae
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/luticalab.texturecocktail/Editor/Plugin.meta b/Packages/luticalab.texturecocktail/Editor/Plugin.meta
new file mode 100644
index 0000000..c9a0a4f
--- /dev/null
+++ b/Packages/luticalab.texturecocktail/Editor/Plugin.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 8dacce2b48b1485f92b5cd4fd54b59a2
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/luticalab.texturecocktail/Editor/Plugin/PluginBrowserWindow.cs b/Packages/luticalab.texturecocktail/Editor/Plugin/PluginBrowserWindow.cs
new file mode 100644
index 0000000..bb200a8
--- /dev/null
+++ b/Packages/luticalab.texturecocktail/Editor/Plugin/PluginBrowserWindow.cs
@@ -0,0 +1,91 @@
+using System.Collections.Generic;
+using UnityEditor;
+using UnityEngine;
+
+namespace LuticaLab.TextureCocktail
+{
+ ///
+ /// Editor window that lists all TextureCocktail plugins discovered in loaded assemblies.
+ /// Open via: LuticaLab → TextureCocktail Plugin Browser
+ ///
+ public class PluginBrowserWindow : EditorWindow
+ {
+ [MenuItem("LuticaLab/TextureCocktail Plugin Browser")]
+ public static void ShowWindow()
+ {
+ GetWindow("TC Plugin Browser");
+ }
+
+ private Vector2 _scroll;
+ private string _searchFilter = "";
+
+ private void OnEnable()
+ {
+ TextureCocktailPluginRegistry.Refresh();
+ }
+
+ private void OnGUI()
+ {
+ GUILayout.Label("TextureCocktail Plugin Browser", EditorStyles.boldLabel);
+ EditorGUILayout.HelpBox(
+ "All TextureCocktailContent subclasses found in loaded assemblies are listed here.\n" +
+ "To create a plugin: inherit from TextureCocktailContent and create a matching shader.",
+ MessageType.Info);
+
+ EditorGUILayout.Space(4);
+
+ // Search bar
+ EditorGUILayout.BeginHorizontal();
+ GUILayout.Label("Search:", GUILayout.Width(55));
+ _searchFilter = EditorGUILayout.TextField(_searchFilter);
+ if (GUILayout.Button("Refresh", GUILayout.Width(70)))
+ TextureCocktailPluginRegistry.Refresh();
+ EditorGUILayout.EndHorizontal();
+
+ EditorGUILayout.Space(4);
+
+ IReadOnlyList plugins = TextureCocktailPluginRegistry.AllPlugins;
+ GUILayout.Label($"Registered Plugins ({plugins.Count})", EditorStyles.boldLabel);
+
+ _scroll = EditorGUILayout.BeginScrollView(_scroll);
+ foreach (var info in plugins)
+ {
+ if (!string.IsNullOrEmpty(_searchFilter) &&
+ !info.DisplayName.ToLowerInvariant().Contains(_searchFilter.ToLowerInvariant()) &&
+ !info.TypeName.ToLowerInvariant().Contains(_searchFilter.ToLowerInvariant()))
+ continue;
+
+ EditorGUILayout.BeginVertical(EditorStyles.helpBox);
+
+ EditorGUILayout.BeginHorizontal();
+ GUILayout.Label(info.DisplayName, EditorStyles.boldLabel);
+ GUILayout.FlexibleSpace();
+ if (!string.IsNullOrEmpty(info.Version))
+ GUILayout.Label($"v{info.Version}", EditorStyles.miniLabel);
+ EditorGUILayout.EndHorizontal();
+
+ EditorGUILayout.LabelField("Class:", info.TypeName, EditorStyles.miniLabel);
+ EditorGUILayout.LabelField("Assembly:", info.AssemblyName, EditorStyles.miniLabel);
+
+ if (!string.IsNullOrEmpty(info.Author))
+ EditorGUILayout.LabelField("Author:", info.Author, EditorStyles.miniLabel);
+
+ if (!string.IsNullOrEmpty(info.Description))
+ EditorGUILayout.LabelField("Description:", info.Description, EditorStyles.miniLabel);
+
+ EditorGUILayout.EndVertical();
+ EditorGUILayout.Space(2);
+ }
+ EditorGUILayout.EndScrollView();
+
+ EditorGUILayout.Space(4);
+ EditorGUILayout.HelpBox(
+ "Plugin How-to:\n" +
+ "1. Create a class inheriting TextureCocktailContent\n" +
+ "2. (Optional) Add [TextureCocktailPlugin(\"Name\", \"Desc\", \"Author\")] attribute\n" +
+ "3. Create a shader with the same name (last path segment)\n" +
+ "4. TextureCocktail auto-loads your UI when the shader is selected",
+ MessageType.None);
+ }
+ }
+}
diff --git a/Packages/luticalab.texturecocktail/Editor/Plugin/PluginBrowserWindow.cs.meta b/Packages/luticalab.texturecocktail/Editor/Plugin/PluginBrowserWindow.cs.meta
new file mode 100644
index 0000000..0bfb04b
--- /dev/null
+++ b/Packages/luticalab.texturecocktail/Editor/Plugin/PluginBrowserWindow.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 072d931791d64cd9ad7dae291fd123df
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/luticalab.texturecocktail/Editor/Plugin/TextureCocktailPluginAttribute.cs b/Packages/luticalab.texturecocktail/Editor/Plugin/TextureCocktailPluginAttribute.cs
new file mode 100644
index 0000000..1892aac
--- /dev/null
+++ b/Packages/luticalab.texturecocktail/Editor/Plugin/TextureCocktailPluginAttribute.cs
@@ -0,0 +1,50 @@
+using System;
+
+namespace LuticaLab.TextureCocktail
+{
+ ///
+ /// Optional attribute that provides metadata for a TextureCocktail plugin.
+ /// Apply this to classes that inherit from .
+ ///
+ /// Usage:
+ ///
+ /// [TextureCocktailPlugin("My Effect", "Applies a custom effect", "YourName", "1.0.0")]
+ /// public class MyEffect : TextureCocktailContent { ... }
+ ///
+ ///
+ /// The plugin will be automatically discovered by
+ /// and associated with a shader whose last path segment matches the class name (e.g.
+ /// "Hidden/MyEffect" → class "MyEffect").
+ ///
+ [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
+ public sealed class TextureCocktailPluginAttribute : Attribute
+ {
+ /// Human-readable name shown in the plugin browser.
+ public string DisplayName { get; }
+
+ /// Short description of what the plugin does.
+ public string Description { get; }
+
+ /// Plugin author name.
+ public string Author { get; }
+
+ /// Plugin version string.
+ public string Version { get; }
+
+ /// Human-readable plugin name.
+ /// Short description.
+ /// Author name.
+ /// Version string (e.g. "1.0.0").
+ public TextureCocktailPluginAttribute(
+ string displayName,
+ string description = "",
+ string author = "",
+ string version = "1.0.0")
+ {
+ DisplayName = displayName;
+ Description = description;
+ Author = author;
+ Version = version;
+ }
+ }
+}
diff --git a/Packages/luticalab.texturecocktail/Editor/Plugin/TextureCocktailPluginAttribute.cs.meta b/Packages/luticalab.texturecocktail/Editor/Plugin/TextureCocktailPluginAttribute.cs.meta
new file mode 100644
index 0000000..60d3ded
--- /dev/null
+++ b/Packages/luticalab.texturecocktail/Editor/Plugin/TextureCocktailPluginAttribute.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 98ddf82eeead4f239febb09ea0f0db72
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/luticalab.texturecocktail/Editor/Plugin/TextureCocktailPluginRegistry.cs b/Packages/luticalab.texturecocktail/Editor/Plugin/TextureCocktailPluginRegistry.cs
new file mode 100644
index 0000000..d4fdc13
--- /dev/null
+++ b/Packages/luticalab.texturecocktail/Editor/Plugin/TextureCocktailPluginRegistry.cs
@@ -0,0 +1,157 @@
+using System;
+using System.Collections.Generic;
+using System.Reflection;
+using UnityEditor;
+using UnityEngine;
+
+namespace LuticaLab.TextureCocktail
+{
+ ///
+ /// Information record for a discovered TextureCocktail plugin.
+ ///
+ public sealed class TextureCocktailPluginInfo
+ {
+ /// C# class name (used to match the shader's last path segment).
+ public string TypeName { get; internal set; }
+
+ /// The actual of the plugin.
+ public Type PluginType { get; internal set; }
+
+ /// Human-readable display name (from attribute, or falls back to TypeName).
+ public string DisplayName { get; internal set; }
+
+ /// Plugin description (from attribute).
+ public string Description { get; internal set; }
+
+ /// Plugin author (from attribute).
+ public string Author { get; internal set; }
+
+ /// Plugin version string (from attribute).
+ public string Version { get; internal set; }
+
+ /// Assembly that defines the plugin.
+ public string AssemblyName { get; internal set; }
+ }
+
+ ///
+ /// Discovers and caches every subclass found in all
+ /// assemblies that are currently loaded in the AppDomain.
+ ///
+ /// Third-party plugins are picked up automatically — no manual registration is needed.
+ /// Optionally decorate your class with to
+ /// supply display metadata shown in the plugin browser.
+ ///
+ /// HOW TO CREATE A PLUGIN
+ /// ───────────────────────
+ /// 1. Create a shader whose last path segment is your class name, e.g.:
+ /// Shader "YourNamespace/MyEffect" { ... }
+ /// 2. Create a C# class in any assembly:
+ /// [TextureCocktailPlugin("My Effect", "Does something cool", "YourName")]
+ /// public class MyEffect : TextureCocktailContent { ... }
+ /// 3. TextureCocktail will load your UI automatically when the user selects the shader.
+ ///
+ public static class TextureCocktailPluginRegistry
+ {
+ private static Dictionary _typesByName;
+ private static List _infos;
+
+ /// Mapping from class name (case-insensitive) → plugin type.
+ public static IReadOnlyDictionary TypesByName
+ {
+ get
+ {
+ EnsureLoaded();
+ return _typesByName;
+ }
+ }
+
+ /// All discovered plugin info records.
+ public static IReadOnlyList AllPlugins
+ {
+ get
+ {
+ EnsureLoaded();
+ return _infos;
+ }
+ }
+
+ ///
+ /// Forces a full re-scan of all loaded assemblies.
+ /// Called automatically the first time the registry is accessed and on domain reload.
+ ///
+ [InitializeOnLoadMethod]
+ public static void Refresh()
+ {
+ _typesByName = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ _infos = new List();
+
+ foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies())
+ {
+ TryScanAssembly(assembly);
+ }
+
+ Debug.Log($"[TextureCocktail] Plugin registry refreshed — {_infos.Count} plugin(s) found.");
+ }
+
+ ///
+ /// Creates an instance of the plugin whose class name matches .
+ /// Returns null if no matching plugin is registered.
+ ///
+ public static TextureCocktailContent CreatePlugin(string shaderLastName)
+ {
+ EnsureLoaded();
+ if (_typesByName.TryGetValue(shaderLastName, out Type type))
+ {
+ return (TextureCocktailContent)ScriptableObject.CreateInstance(type);
+ }
+ return null;
+ }
+
+ // ── private ─────────────────────────────────────────────────────────────
+
+ private static void EnsureLoaded()
+ {
+ if (_typesByName == null)
+ Refresh();
+ }
+
+ private static void TryScanAssembly(Assembly assembly)
+ {
+ Type[] types;
+ try
+ {
+ types = assembly.GetTypes();
+ }
+ catch (ReflectionTypeLoadException ex)
+ {
+ // Partial results — still process what we got
+ types = ex.Types;
+ }
+ catch
+ {
+ return;
+ }
+
+ foreach (Type type in types)
+ {
+ if (type == null || type.IsAbstract || !type.IsSubclassOf(typeof(TextureCocktailContent)))
+ continue;
+
+ var attr = type.GetCustomAttribute();
+ var info = new TextureCocktailPluginInfo
+ {
+ TypeName = type.Name,
+ PluginType = type,
+ DisplayName = attr?.DisplayName ?? type.Name,
+ Description = attr?.Description ?? string.Empty,
+ Author = attr?.Author ?? string.Empty,
+ Version = attr?.Version ?? string.Empty,
+ AssemblyName = assembly.GetName().Name,
+ };
+
+ _typesByName[type.Name] = type;
+ _infos.Add(info);
+ }
+ }
+ }
+}
diff --git a/Packages/luticalab.texturecocktail/Editor/Plugin/TextureCocktailPluginRegistry.cs.meta b/Packages/luticalab.texturecocktail/Editor/Plugin/TextureCocktailPluginRegistry.cs.meta
new file mode 100644
index 0000000..ef9354f
--- /dev/null
+++ b/Packages/luticalab.texturecocktail/Editor/Plugin/TextureCocktailPluginRegistry.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 0ef796a036664d51955b07dd5ed6aa99
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/luticalab.texturecocktail/Editor/TextureCocktail.cs b/Packages/luticalab.texturecocktail/Editor/TextureCocktail.cs
index bd4c019..27865d6 100644
--- a/Packages/luticalab.texturecocktail/Editor/TextureCocktail.cs
+++ b/Packages/luticalab.texturecocktail/Editor/TextureCocktail.cs
@@ -453,31 +453,20 @@ private void OnShaderChange(Shader changeTo)
_valueChanged = true;
}
///
- /// Found shader window by name.
+ /// Finds and instantiates a shader window by the shader's last path segment.
+ /// Uses so that third-party plugins defined
+ /// in any loaded assembly are discovered automatically — no manual registration needed.
///
- ///
- /// shader name with namespace prefix, for example "ImageSync"
- /// window script most be in LuticaLab.TextureCocktail namespace
- ///
- ///
+ /// Last segment of the shader path, e.g. "ImageSync".
private TextureCocktailContent LoadShaderWindow(string shaderName)
{
- var foundType = Type.GetType("LuticaLab.TextureCocktail." + shaderName);
- if (foundType == null)
+ var plugin = TextureCocktailPluginRegistry.CreatePlugin(shaderName);
+ if (plugin == null)
{
- Debug.LogWarning($"Shader window type '{shaderName}' not found. Ensure it is in the correct namespace and assembly.");
- return null;
- }
- if (foundType.IsSubclassOf(typeof(TextureCocktailContent)))
- {
- var shaderWindow = (TextureCocktailContent)CreateInstance(foundType);
- return shaderWindow;
- }
- else
- {
- Debug.LogWarning($"Shader window type '{shaderName}' is not a subclass of TextureCocktailContent.");
- return null;
+ Debug.LogWarning($"[TextureCocktail] No plugin found for shader '{shaderName}'. " +
+ $"Create a class named '{shaderName}' that inherits TextureCocktailContent.");
}
+ return plugin;
}
private void OnTextureChanged(Texture2D newTexture)
{
diff --git a/Packages/luticalab.texturecocktail/Editor/TextureOptimizer.cs b/Packages/luticalab.texturecocktail/Editor/TextureOptimizer.cs
new file mode 100644
index 0000000..0663d27
--- /dev/null
+++ b/Packages/luticalab.texturecocktail/Editor/TextureOptimizer.cs
@@ -0,0 +1,413 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using UnityEditor;
+using UnityEngine;
+
+namespace LuticaLab.TextureCocktail
+{
+ ///
+ /// Unity texture optimization analysis and batch-fix tool.
+ /// Open via: LuticaLab → Texture Optimizer
+ ///
+ public class TextureOptimizer : EditorWindow
+ {
+ // ── Menu item ────────────────────────────────────────────────────────
+ [MenuItem("LuticaLab/Texture Optimizer")]
+ public static void ShowWindow()
+ {
+ GetWindow("Texture Optimizer");
+ }
+
+ // ── Enums ────────────────────────────────────────────────────────────
+ public enum TargetPlatform { Desktop, Mobile, VR }
+
+ // ── Inner data class ─────────────────────────────────────────────────
+ private class TextureReport
+ {
+ public Texture2D Texture;
+ public string AssetPath;
+ public TextureImporter Importer;
+
+ // Current state
+ public int Width;
+ public int Height;
+ public bool IsPOT;
+ public bool HasMipmaps;
+ public TextureImporterCompression CurrentCompression;
+ public int CurrentMaxSize;
+ public long EstimatedSizeBytes;
+
+ // Issues / recommendations
+ public List Warnings = new List();
+ public List SuggestedActions = new List();
+
+ // UI state
+ public bool IsSelected;
+ public bool FoldoutOpen;
+ }
+
+ public enum TextureOptimizationAction
+ {
+ EnableMipmaps,
+ ResizeToPOT,
+ ReduceMaxSize,
+ EnableCompression,
+ EnableCrunchCompression,
+ }
+
+ // ── Window state ─────────────────────────────────────────────────────
+ private string _scanFolder = "Assets";
+ private TargetPlatform _targetPlatform = TargetPlatform.Desktop;
+ private List _reports = new List();
+ private Vector2 _scroll;
+ private bool _scanning;
+ private int _maxSizeThreshold = 2048;
+ private bool _showOnlyWithIssues = true;
+
+ // ── Action tracking ──────────────────────────────────────────────────
+ private int _appliedCount;
+
+ // ── GUI ──────────────────────────────────────────────────────────────
+ private void OnGUI()
+ {
+ GUILayout.Label("Texture Optimizer", EditorStyles.boldLabel);
+ EditorGUILayout.Space(4);
+
+ // Scan settings
+ EditorGUILayout.BeginVertical(EditorStyles.helpBox);
+ GUILayout.Label("Scan Settings", EditorStyles.boldLabel);
+
+ EditorGUILayout.BeginHorizontal();
+ _scanFolder = EditorGUILayout.TextField("Folder to Scan", _scanFolder);
+ if (GUILayout.Button("Browse", GUILayout.Width(70)))
+ {
+ string path = EditorUtility.OpenFolderPanel("Select Folder", _scanFolder, "");
+ if (!string.IsNullOrEmpty(path))
+ {
+ if (path.StartsWith(Application.dataPath))
+ _scanFolder = "Assets" + path.Substring(Application.dataPath.Length);
+ else
+ _scanFolder = path;
+ }
+ }
+ EditorGUILayout.EndHorizontal();
+
+ _targetPlatform = (TargetPlatform)EditorGUILayout.EnumPopup("Target Platform", _targetPlatform);
+ _maxSizeThreshold = EditorGUILayout.IntField("Max Allowed Size (px)", _maxSizeThreshold);
+ _showOnlyWithIssues = EditorGUILayout.Toggle("Show Only Textures With Issues", _showOnlyWithIssues);
+
+ EditorGUILayout.EndVertical();
+
+ EditorGUILayout.Space(4);
+
+ if (GUILayout.Button("Scan Textures", GUILayout.Height(35)))
+ ScanTextures();
+
+ if (_reports.Count > 0)
+ {
+ EditorGUILayout.Space(4);
+ DrawActionBar();
+ EditorGUILayout.Space(4);
+ DrawReportList();
+ }
+ }
+
+ // ── Scan ─────────────────────────────────────────────────────────────
+ private void ScanTextures()
+ {
+ _reports.Clear();
+
+ string[] guids = AssetDatabase.FindAssets("t:Texture2D", new[] { _scanFolder });
+ int total = guids.Length;
+ int processed = 0;
+
+ foreach (string guid in guids)
+ {
+ string path = AssetDatabase.GUIDToAssetPath(guid);
+ if (EditorUtility.DisplayCancelableProgressBar(
+ "Scanning Textures",
+ $"Analyzing: {Path.GetFileName(path)}",
+ (float)processed / Mathf.Max(total, 1)))
+ {
+ break;
+ }
+
+ var tex = AssetDatabase.LoadAssetAtPath(path);
+ var importer = AssetImporter.GetAtPath(path) as TextureImporter;
+ if (tex == null || importer == null)
+ {
+ processed++;
+ continue;
+ }
+
+ var report = BuildReport(tex, path, importer);
+ if (!_showOnlyWithIssues || report.Warnings.Count > 0)
+ _reports.Add(report);
+
+ processed++;
+ }
+
+ EditorUtility.ClearProgressBar();
+ Repaint();
+ }
+
+ private TextureReport BuildReport(Texture2D tex, string path, TextureImporter importer)
+ {
+ var r = new TextureReport
+ {
+ Texture = tex,
+ AssetPath = path,
+ Importer = importer,
+ Width = tex.width,
+ Height = tex.height,
+ IsPOT = IsPowerOfTwo(tex.width) && IsPowerOfTwo(tex.height),
+ HasMipmaps = tex.mipmapCount > 1,
+ CurrentCompression = importer.textureCompression,
+ CurrentMaxSize = importer.maxTextureSize,
+ };
+
+ r.EstimatedSizeBytes = EstimateVRAM(tex, r.HasMipmaps);
+
+ // --- Warnings & actions ---
+ if (!r.IsPOT)
+ {
+ r.Warnings.Add("Texture dimensions are not power-of-two. GPU cannot generate mipmaps efficiently.");
+ r.SuggestedActions.Add(TextureOptimizationAction.ResizeToPOT);
+ }
+
+ bool is3DTexture = importer.textureType != TextureImporterType.Sprite &&
+ importer.textureType != TextureImporterType.GUI;
+
+ if (is3DTexture && !r.HasMipmaps)
+ {
+ r.Warnings.Add("Mipmaps are disabled on a non-UI texture. Enable mipmaps to reduce aliasing and improve performance.");
+ r.SuggestedActions.Add(TextureOptimizationAction.EnableMipmaps);
+ }
+
+ if (r.CurrentCompression == TextureImporterCompression.Uncompressed)
+ {
+ r.Warnings.Add("Texture is uncompressed. Compression can reduce memory usage significantly.");
+ r.SuggestedActions.Add(TextureOptimizationAction.EnableCompression);
+ }
+
+ if (r.Width > _maxSizeThreshold || r.Height > _maxSizeThreshold)
+ {
+ r.Warnings.Add($"Texture exceeds {_maxSizeThreshold}px threshold ({r.Width}×{r.Height}). Consider reducing max size.");
+ r.SuggestedActions.Add(TextureOptimizationAction.ReduceMaxSize);
+ }
+
+ if (r.CurrentCompression != TextureImporterCompression.Uncompressed &&
+ !importer.crunchedCompression &&
+ r.EstimatedSizeBytes > 1024 * 1024)
+ {
+ r.Warnings.Add("Large compressed texture. Crunch compression can further reduce disk size.");
+ r.SuggestedActions.Add(TextureOptimizationAction.EnableCrunchCompression);
+ }
+
+ return r;
+ }
+
+ // ── Action bar ───────────────────────────────────────────────────────
+ private void DrawActionBar()
+ {
+ int selectedCount = 0;
+ foreach (var r in _reports)
+ if (r.IsSelected) selectedCount++;
+
+ EditorGUILayout.BeginHorizontal();
+ GUILayout.Label($"Found {_reports.Count} texture(s). {selectedCount} selected.", EditorStyles.boldLabel);
+ GUILayout.FlexibleSpace();
+ if (GUILayout.Button("Select All", GUILayout.Width(90)))
+ foreach (var r in _reports) r.IsSelected = true;
+ if (GUILayout.Button("Deselect All", GUILayout.Width(95)))
+ foreach (var r in _reports) r.IsSelected = false;
+ EditorGUILayout.EndHorizontal();
+
+ EditorGUILayout.Space(2);
+
+ GUI.enabled = selectedCount > 0;
+ if (GUILayout.Button("Apply Recommended Fixes to Selected", GUILayout.Height(30)))
+ ApplySelectedFixes();
+ GUI.enabled = true;
+
+ if (_appliedCount > 0)
+ {
+ EditorGUILayout.HelpBox($"Applied fixes to {_appliedCount} texture(s). Re-scan to verify.", MessageType.Info);
+ }
+ }
+
+ // ── Report list ──────────────────────────────────────────────────────
+ private void DrawReportList()
+ {
+ _scroll = EditorGUILayout.BeginScrollView(_scroll);
+ foreach (var r in _reports)
+ {
+ DrawReportEntry(r);
+ }
+ EditorGUILayout.EndScrollView();
+ }
+
+ private void DrawReportEntry(TextureReport r)
+ {
+ bool hasIssues = r.Warnings.Count > 0;
+ Color rowColor = hasIssues ? new Color(1f, 0.95f, 0.8f) : new Color(0.85f, 1f, 0.85f);
+
+ var prevBg = GUI.backgroundColor;
+ GUI.backgroundColor = rowColor;
+ EditorGUILayout.BeginVertical(EditorStyles.helpBox);
+ GUI.backgroundColor = prevBg;
+
+ // Header row
+ EditorGUILayout.BeginHorizontal();
+ r.IsSelected = EditorGUILayout.Toggle(r.IsSelected, GUILayout.Width(18));
+ r.FoldoutOpen = EditorGUILayout.Foldout(r.FoldoutOpen,
+ $"{r.Texture.name} ({r.Width}×{r.Height}) {FormatBytes(r.EstimatedSizeBytes)}",
+ true);
+ GUILayout.FlexibleSpace();
+ if (hasIssues)
+ GUILayout.Label($"⚠ {r.Warnings.Count} issue(s)", EditorStyles.miniLabel);
+ else
+ GUILayout.Label("✓ OK", EditorStyles.miniLabel);
+ EditorGUILayout.EndHorizontal();
+
+ if (r.FoldoutOpen)
+ {
+ EditorGUI.indentLevel++;
+
+ EditorGUILayout.LabelField("Path:", r.AssetPath, EditorStyles.miniLabel);
+ EditorGUILayout.LabelField("Compression:", r.CurrentCompression.ToString(), EditorStyles.miniLabel);
+ EditorGUILayout.LabelField("Max Size:", r.CurrentMaxSize.ToString(), EditorStyles.miniLabel);
+ EditorGUILayout.LabelField("Mipmaps:", r.HasMipmaps ? "Enabled" : "Disabled", EditorStyles.miniLabel);
+ EditorGUILayout.LabelField("Power of Two:", r.IsPOT ? "Yes" : "No", EditorStyles.miniLabel);
+
+ if (r.Warnings.Count > 0)
+ {
+ GUILayout.Label("Issues:", EditorStyles.boldLabel);
+ foreach (var w in r.Warnings)
+ EditorGUILayout.HelpBox(w, MessageType.Warning);
+ }
+
+ // Quick-fix button
+ EditorGUILayout.BeginHorizontal();
+ GUILayout.FlexibleSpace();
+ if (GUILayout.Button("Ping Asset", GUILayout.Width(90)))
+ EditorGUIUtility.PingObject(r.Texture);
+ if (r.Warnings.Count > 0 && GUILayout.Button("Fix This Texture", GUILayout.Width(110)))
+ {
+ ApplyFix(r);
+ _appliedCount++;
+ Repaint();
+ }
+ EditorGUILayout.EndHorizontal();
+
+ EditorGUI.indentLevel--;
+ }
+
+ EditorGUILayout.EndVertical();
+ EditorGUILayout.Space(2);
+ }
+
+ // ── Fix logic ────────────────────────────────────────────────────────
+ private void ApplySelectedFixes()
+ {
+ _appliedCount = 0;
+ foreach (var r in _reports)
+ {
+ if (!r.IsSelected) continue;
+ ApplyFix(r);
+ _appliedCount++;
+ }
+ AssetDatabase.SaveAssets();
+ AssetDatabase.Refresh();
+ ScanTextures(); // Refresh reports
+ }
+
+ private void ApplyFix(TextureReport r)
+ {
+ bool dirty = false;
+
+ foreach (var action in r.SuggestedActions)
+ {
+ switch (action)
+ {
+ case TextureOptimizationAction.EnableMipmaps:
+ r.Importer.mipmapEnabled = true;
+ dirty = true;
+ break;
+
+ case TextureOptimizationAction.EnableCompression:
+ r.Importer.textureCompression = GetRecommendedCompression();
+ dirty = true;
+ break;
+
+ case TextureOptimizationAction.EnableCrunchCompression:
+ r.Importer.crunchedCompression = true;
+ r.Importer.compressionQuality = 50;
+ dirty = true;
+ break;
+
+ case TextureOptimizationAction.ReduceMaxSize:
+ int newMax = _maxSizeThreshold;
+ // Clamp to nearest valid value
+ int[] validSizes = { 32, 64, 128, 256, 512, 1024, 2048, 4096, 8192 };
+ foreach (int s in validSizes)
+ {
+ if (s >= newMax) { newMax = s; break; }
+ }
+ r.Importer.maxTextureSize = newMax;
+ dirty = true;
+ break;
+
+ case TextureOptimizationAction.ResizeToPOT:
+ r.Importer.npotScale = TextureImporterNPOTScale.ToNearest;
+ dirty = true;
+ break;
+ }
+ }
+
+ if (dirty)
+ r.Importer.SaveAndReimport();
+ }
+
+ private TextureImporterCompression GetRecommendedCompression()
+ {
+ return _targetPlatform switch
+ {
+ TargetPlatform.Mobile => TextureImporterCompression.CompressedHQ,
+ TargetPlatform.VR => TextureImporterCompression.CompressedHQ,
+ _ => TextureImporterCompression.Compressed,
+ };
+ }
+
+ // ── Helpers ──────────────────────────────────────────────────────────
+ private static bool IsPowerOfTwo(int n) => n > 0 && (n & (n - 1)) == 0;
+
+ private static long EstimateVRAM(Texture2D tex, bool hasMips)
+ {
+ // Rough estimate: width * height * bytesPerPixel (compressed ~0.5 BPP, uncompressed ~4 BPP)
+ long base_ = (long)tex.width * tex.height;
+ long bpp;
+ switch (tex.format)
+ {
+ case TextureFormat.DXT1: bpp = 1; break;
+ case TextureFormat.DXT5: bpp = 1; break;
+ case TextureFormat.ETC_RGB4: bpp = 1; break;
+ case TextureFormat.ETC2_RGBA8: bpp = 1; break;
+ case TextureFormat.ASTC_4x4: bpp = 1; break;
+ case TextureFormat.RGB24: bpp = 3; break;
+ case TextureFormat.RGBA32: bpp = 4; break;
+ default: bpp = 4; break;
+ }
+ long size = base_ * bpp;
+ return hasMips ? size * 4 / 3 : size; // mips add ~33%
+ }
+
+ private static string FormatBytes(long bytes)
+ {
+ if (bytes < 1024) return $"{bytes} B";
+ if (bytes < 1024 * 1024) return $"{bytes / 1024} KB";
+ return $"{bytes / (1024 * 1024)} MB";
+ }
+ }
+}
diff --git a/Packages/luticalab.texturecocktail/Editor/TextureOptimizer.cs.meta b/Packages/luticalab.texturecocktail/Editor/TextureOptimizer.cs.meta
new file mode 100644
index 0000000..d01aff8
--- /dev/null
+++ b/Packages/luticalab.texturecocktail/Editor/TextureOptimizer.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: bbbf4bb6c853440cb6d0db9e0b88bc8e
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/luticalab.texturecocktail/PLUGIN_GUIDE.md b/Packages/luticalab.texturecocktail/PLUGIN_GUIDE.md
new file mode 100644
index 0000000..1d3dc57
--- /dev/null
+++ b/Packages/luticalab.texturecocktail/PLUGIN_GUIDE.md
@@ -0,0 +1,168 @@
+# TextureCocktail Plugin Development Guide
+
+This guide explains how to create custom plugins (content editors) for TextureCocktail
+so that **users, modders, and AI agents** can extend the tool with new texture effects.
+
+---
+
+## Quick Start
+
+### Step 1 — Create a Shader
+
+Create a Unity shader with any path you like. The **last segment** of the path becomes the
+plugin identifier:
+
+```hlsl
+Shader "YourName/GrayscaleEffect"
+{
+ Properties
+ {
+ _MainTex ("Texture", 2D) = "white" {}
+ _Strength ("Effect Strength", Range(0,1)) = 0.5
+ }
+ SubShader
+ {
+ Pass
+ {
+ CGPROGRAM
+ #pragma vertex vert_img
+ #pragma fragment frag
+ #include "UnityCG.cginc"
+
+ sampler2D _MainTex;
+ float _Strength;
+
+ fixed4 frag(v2f_img i) : SV_Target
+ {
+ fixed4 col = tex2D(_MainTex, i.uv);
+ float gray = dot(col.rgb, fixed3(0.299, 0.587, 0.114));
+ col.rgb = lerp(col.rgb, fixed3(gray, gray, gray), _Strength);
+ return col;
+ }
+ ENDCG
+ }
+ }
+}
+```
+
+---
+
+### Step 2 — Create the Plugin Class
+
+Create a C# class **whose name exactly matches the last path segment of the shader**
+(e.g. `GrayscaleEffect`). It must:
+
+- Be in **any assembly** (no specific namespace required)
+- Inherit from `LuticaLab.TextureCocktail.TextureCocktailContent`
+- Optionally carry `[TextureCocktailPlugin]` metadata
+
+```csharp
+using LuticaLab.TextureCocktail;
+using UnityEditor;
+using UnityEngine;
+
+// Optional metadata for the Plugin Browser window
+[TextureCocktailPlugin(
+ displayName : "Grayscale Effect",
+ description : "Converts the image to grayscale with adjustable strength",
+ author : "YourName",
+ version : "1.0.0")]
+public class GrayscaleEffect : TextureCocktailContent
+{
+ public override bool UseDefaultLayout => false;
+
+ public override void OnGUI()
+ {
+ GUILayout.Label("Grayscale Effect", EditorStyles.boldLabel);
+
+ var mat = GetMaterial();
+ if (mat == null) { baseWindow.ShowShaderInfo(); return; }
+
+ EditorGUI.BeginChangeCheck();
+ float strength = mat.GetFloat("_Strength");
+ strength = EditorGUILayout.Slider("Effect Strength", strength, 0f, 1f);
+ if (EditorGUI.EndChangeCheck())
+ {
+ mat.SetFloat("_Strength", strength);
+ baseWindow.OnShaderValueChange();
+ }
+
+ baseWindow.DisplayPassedIamge();
+
+ if (GUILayout.Button("Save")) baseWindow.SaveTexture();
+ }
+
+ public override void OnShaderValueChanged() { }
+
+ // Helper — access the material through the public API
+ private Material GetMaterial()
+ {
+ var field = baseWindow.GetType().GetField("_calcMaterial",
+ System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+ return field?.GetValue(baseWindow) as Material;
+ }
+}
+```
+
+---
+
+## Plugin Discovery
+
+TextureCocktail uses `TextureCocktailPluginRegistry` which scans **all assemblies** loaded
+in the AppDomain on editor startup. Your class does **not** need to be in the
+`LuticaLab.TextureCocktail` namespace — any namespace works.
+
+Open **LuticaLab → TextureCocktail Plugin Browser** to see all discovered plugins.
+
+---
+
+## TextureCocktailContent API Reference
+
+| Member | Description |
+|--------|-------------|
+| `baseWindow` | Reference to the main `TextureCocktail` editor window |
+| `scrollPosition` | Shared scroll position for the content area |
+| `UseDefaultLayout` | Return `false` to take full control of the GUI |
+| `DontWantDisplayPropertyName` | Property names to hide from the default shader inspector |
+| `ShaderUpdateDefaultAction` | Return `false` to handle shader updates yourself |
+| `PassOrder` | GPU pass index to compile (default 0) |
+| `Initialize(window)` | Called once when the shader is selected |
+| `OnGUI()` | Draw your custom Unity IMGUI here |
+| `OnShaderValueChanged()` | Called when shader parameters change |
+| `OnValuepdate()` | Called every editor update |
+
+### TextureCocktail Window API
+
+| Method | Description |
+|--------|-------------|
+| `DisplayPassedIamge()` | Renders the preview RenderTexture inline |
+| `ShowShaderInfo()` | Renders the auto-generated shader property inspector |
+| `DisplayShaderOptions()` | Renders keyword toggle UI |
+| `CompileShader()` | Re-blits the texture through the material |
+| `SaveTexture()` | Opens save dialog and writes the result PNG |
+| `SetMaterialKeyword(name, on)` | Enable/disable a shader keyword |
+| `OnShaderValueChange()` | Triggers a full shader re-compile |
+
+---
+
+## AI Agent Usage
+
+AI agents can generate plugin code programmatically by following the same pattern:
+
+1. Generate an HLSL shader string and write it to `Packages//Shader/Image/.shader`
+2. Generate a C# class string inheriting `TextureCocktailContent` and write it to
+ `Packages//Editor/Content/.cs`
+3. Call `TextureCocktailPluginRegistry.Refresh()` to pick up the new class
+4. The user can now select the shader in the TextureCocktail window
+
+---
+
+## Example Plugin with [TextureCocktailPlugin] Attribute
+
+```csharp
+[TextureCocktailPlugin("Vignette", "Adds a vignette darkening effect", "LuticaLab", "1.0.0")]
+public class VignetteEffect : TextureCocktailContent { ... }
+```
+
+The metadata is visible in the Plugin Browser and can be inspected programmatically via
+`TextureCocktailPluginRegistry.AllPlugins`.
diff --git a/Packages/luticalab.texturecocktail/PLUGIN_GUIDE.md.meta b/Packages/luticalab.texturecocktail/PLUGIN_GUIDE.md.meta
new file mode 100644
index 0000000..bf0b5f5
--- /dev/null
+++ b/Packages/luticalab.texturecocktail/PLUGIN_GUIDE.md.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 5f38214559be46dea385b117333731a7
+TextScriptImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant: