diff --git a/.gitignore b/.gitignore
index e92b785..83f718c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -100,3 +100,6 @@ crashlytics-build.properties
/Packages/com.mibdev.fullscreen-editor/
/Assets/Textures/PSD~/
+
+# DeepBump CLI (local ML tool: cloned repo + machine-local venv, see TextureNormalMapGenerator.cs)
+/Tools/DeepBump/
diff --git a/Assets/Scripts/Editor/TextureNormalMapGenerator.cs b/Assets/Scripts/Editor/TextureNormalMapGenerator.cs
new file mode 100644
index 0000000..bdafdbe
--- /dev/null
+++ b/Assets/Scripts/Editor/TextureNormalMapGenerator.cs
@@ -0,0 +1,241 @@
+using System;
+using System.Diagnostics;
+using System.IO;
+using System.Text;
+using System.Threading;
+using UnityEditor;
+using UnityEngine;
+using Debug = UnityEngine.Debug;
+
+namespace Chameleon.Editor
+{
+ ///
+ /// Right-click a texture asset -> Process -> Generate Normal + Height Map (DeepBump).
+ /// Runs the DeepBump ML CLI (Tools/DeepBump, own venv) twice: color_to_normals estimates a
+ /// normal map from the color/albedo image, normals_to_height then derives a height/displacement
+ /// map from that normal map (DeepBump has no direct color->height path). Both land next to the
+ /// original as _normal.png (Texture Type = Normal Map) and _height.png (linear, sRGB off).
+ /// DeepBump outputs OpenGL convention, which matches Unity.
+ ///
+ public static class TextureNormalMapGenerator
+ {
+ const string MenuPath = "Assets/Process/Generate Normal + Height Map (DeepBump)";
+ const string DeepBumpDir = "Tools/DeepBump";
+ // Inputs above this get Lanczos-downscaled first: DeepBump cost is quadratic in resolution
+ // (256px tiles) and fewer tiles also means fewer visible tile seams. Normal-map detail is
+ // low-frequency anyway; strength is tuned via _BumpScale.
+ const int MaxInputSize = 2048;
+
+ static string ProjectRoot => Path.GetDirectoryName(Application.dataPath);
+
+ [MenuItem(MenuPath, true)]
+ static bool Validate()
+ => Selection.GetFiltered(SelectionMode.Assets).Length > 0;
+
+ [MenuItem(MenuPath, false, 2001)]
+ static void Process()
+ {
+ string deepBump = Path.Combine(ProjectRoot, DeepBumpDir);
+ string python = Path.Combine(deepBump, "venv/Scripts/python.exe");
+ if (!File.Exists(python))
+ {
+ Debug.LogError($"[DeepBump] venv python not found at '{python}'. " +
+ "Set up Tools/DeepBump (clone + venv with numpy/onnxruntime/imageio) first.");
+ return;
+ }
+
+ var mainThread = SynchronizationContext.Current;
+ foreach (var texture in Selection.GetFiltered(SelectionMode.Assets))
+ ProcessTexture(texture, deepBump, python, mainThread);
+ }
+
+ static void ProcessTexture(Texture2D texture, string deepBump, string python, SynchronizationContext mainThread)
+ {
+ string assetPath = AssetDatabase.GetAssetPath(texture);
+ if (string.IsNullOrEmpty(assetPath) || !File.Exists(assetPath))
+ {
+ Debug.LogError($"[DeepBump] No source file for '{texture.name}'.");
+ return;
+ }
+
+ string directory = Path.GetDirectoryName(assetPath)?.Replace('\\', '/');
+ string baseName = Path.GetFileNameWithoutExtension(assetPath);
+ string normalAssetPath = AssetDatabase.GenerateUniqueAssetPath($"{directory}/{baseName}_normal.png");
+ string heightAssetPath = AssetDatabase.GenerateUniqueAssetPath($"{directory}/{baseName}_height.png");
+
+ string inputFull = Path.GetFullPath(assetPath);
+ string normalFull = Path.GetFullPath(normalAssetPath);
+ string heightFull = Path.GetFullPath(heightAssetPath);
+ string tempInput = DownscaleIfNeeded(assetPath, inputFull, python, deepBump);
+ string itemName = Path.GetFileName(assetPath);
+
+ RunCli(python, deepBump, tempInput ?? inputFull, normalFull, "color_to_normals",
+ "DeepBump Normal Map", itemName, mainThread, (normalOk, normalLog) =>
+ {
+ DeleteTemp(tempInput);
+ if (!normalOk)
+ {
+ if (File.Exists(normalFull)) File.Delete(normalFull);
+ Debug.LogError($"[DeepBump] Normal map generation failed for '{normalAssetPath}':\n{normalLog}");
+ return;
+ }
+ ImportNormalMap(normalAssetPath);
+
+ // Height is derived from the just-generated normal map (full-res PNG on disk, before
+ // Unity's import size cap can shrink it).
+ RunCli(python, deepBump, normalFull, heightFull, "normals_to_height",
+ "DeepBump Height Map", itemName, mainThread, (heightOk, heightLog) =>
+ {
+ if (!heightOk)
+ {
+ if (File.Exists(heightFull)) File.Delete(heightFull);
+ Debug.LogError($"[DeepBump] Height map generation failed for '{heightAssetPath}' " +
+ $"(the normal map was created fine):\n{heightLog}");
+ return;
+ }
+ ImportHeightMap(heightAssetPath);
+ });
+ });
+ }
+
+ ///
+ /// Runs one DeepBump CLI module as a background process with its own progress item;
+ /// onDone(success, stderr) is invoked on the main thread.
+ ///
+ static void RunCli(string python, string deepBump, string inputFull, string outputFull, string module,
+ string progressTitle, string itemName, SynchronizationContext mainThread, Action onDone)
+ {
+ int progressId = Progress.Start(progressTitle, itemName);
+
+ var startInfo = new ProcessStartInfo
+ {
+ FileName = python,
+ Arguments = $"cli.py \"{inputFull}\" \"{outputFull}\" {module} --verbose",
+ WorkingDirectory = deepBump, // cli.py imports its sibling modules relative to cwd
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ };
+ // cli.py prints a Unicode arrow; the default cp1252 console encoding would crash it.
+ startInfo.EnvironmentVariables["PYTHONIOENCODING"] = "utf-8";
+
+ var log = new StringBuilder();
+
+ try
+ {
+ var process = new Process { StartInfo = startInfo, EnableRaisingEvents = true };
+ process.OutputDataReceived += (_, e) =>
+ {
+ if (e.Data == null) return;
+ // Verbose progress lines are "current/total" per tile.
+ int slash = e.Data.IndexOf('/');
+ if (slash > 0
+ && float.TryParse(e.Data.Substring(0, slash), out float current)
+ && float.TryParse(e.Data.Substring(slash + 1), out float total) && total > 0)
+ Progress.Report(progressId, current / total);
+ };
+ process.ErrorDataReceived += (_, e) => { if (e.Data != null) log.AppendLine(e.Data); };
+ process.Exited += (_, _) =>
+ {
+ bool success = process.ExitCode == 0;
+ process.Dispose();
+ mainThread.Post(_ =>
+ {
+ Progress.Remove(progressId);
+ onDone(success, log.ToString());
+ }, null);
+ };
+ process.Start();
+ process.BeginOutputReadLine();
+ process.BeginErrorReadLine();
+ }
+ catch (Exception e)
+ {
+ Progress.Remove(progressId);
+ onDone(false, $"Failed to start python: {e.Message}");
+ }
+ }
+
+ ///
+ /// Returns a temp-file path with the downscaled image if the source exceeds
+ /// , otherwise null (use the original). Runs synchronously
+ /// (sub-second even for large photos).
+ ///
+ static string DownscaleIfNeeded(string assetPath, string inputFull, string python, string deepBump)
+ {
+ int width = 0, height = 0;
+ if (AssetImporter.GetAtPath(assetPath) is TextureImporter importer)
+ importer.GetSourceTextureWidthAndHeight(out width, out height);
+ if (Mathf.Max(width, height) <= MaxInputSize)
+ return null;
+
+ string temp = Path.Combine(Path.GetTempPath(), $"deepbump_{Guid.NewGuid():N}.png");
+ string code = "from PIL import Image; im = Image.open(r'" + inputFull + "').convert('RGB'); " +
+ $"im.thumbnail(({MaxInputSize}, {MaxInputSize}), Image.LANCZOS); " +
+ "im.save(r'" + temp + "')";
+ var startInfo = new ProcessStartInfo
+ {
+ FileName = python,
+ Arguments = $"-c \"{code}\"",
+ WorkingDirectory = deepBump,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ RedirectStandardError = true,
+ };
+ try
+ {
+ // Fully qualified: bare "Process" resolves to this class's Process() menu method.
+ using var process = System.Diagnostics.Process.Start(startInfo);
+ string error = process.StandardError.ReadToEnd();
+ process.WaitForExit();
+ if (process.ExitCode == 0 && File.Exists(temp))
+ return temp;
+ Debug.LogWarning($"[DeepBump] Pre-resize failed, falling back to full resolution:\n{error}");
+ }
+ catch (Exception e)
+ {
+ Debug.LogWarning($"[DeepBump] Pre-resize failed, falling back to full resolution: {e.Message}");
+ }
+ DeleteTemp(temp);
+ return null;
+ }
+
+ static void DeleteTemp(string tempInput)
+ {
+ if (tempInput != null && File.Exists(tempInput))
+ File.Delete(tempInput);
+ }
+
+ static void ImportNormalMap(string assetPath)
+ {
+ AssetDatabase.ImportAsset(assetPath);
+ if (AssetImporter.GetAtPath(assetPath) is TextureImporter importer
+ && importer.textureType != TextureImporterType.NormalMap)
+ {
+ importer.textureType = TextureImporterType.NormalMap;
+ importer.SaveAndReimport();
+ }
+ Ping(assetPath);
+ }
+
+ static void ImportHeightMap(string assetPath)
+ {
+ AssetDatabase.ImportAsset(assetPath);
+ // Grayscale displacement data, not color: linear sampling.
+ if (AssetImporter.GetAtPath(assetPath) is TextureImporter importer && importer.sRGBTexture)
+ {
+ importer.sRGBTexture = false;
+ importer.SaveAndReimport();
+ }
+ Ping(assetPath);
+ }
+
+ static void Ping(string assetPath)
+ {
+ var imported = AssetDatabase.LoadAssetAtPath(assetPath);
+ EditorGUIUtility.PingObject(imported);
+ Debug.Log($"[DeepBump] Created '{assetPath}'.", imported);
+ }
+ }
+}
diff --git a/Assets/Scripts/Editor/TextureNormalMapGenerator.cs.meta b/Assets/Scripts/Editor/TextureNormalMapGenerator.cs.meta
new file mode 100644
index 0000000..bd21155
--- /dev/null
+++ b/Assets/Scripts/Editor/TextureNormalMapGenerator.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: fbe40bb187a4ccc45b2c74267cbc4259
\ No newline at end of file