chore: Project Restructuring & Theatre
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
using UnityEngine;
|
||||
using UnityEditor;
|
||||
using Unheimlich.Core.Audio;
|
||||
|
||||
namespace Unheimlich.Editor
|
||||
{
|
||||
[CustomEditor(typeof(AudioListenable))]
|
||||
public class AudioListenableEditor : UnityEditor.Editor
|
||||
{
|
||||
public override void OnInspectorGUI()
|
||||
{
|
||||
DrawDefaultInspector();
|
||||
|
||||
AudioListenable listenable = (AudioListenable)target;
|
||||
|
||||
EditorGUILayout.Space(10);
|
||||
EditorGUILayout.LabelField("Quick Setup", EditorStyles.boldLabel);
|
||||
|
||||
if (GUILayout.Button("Setup as Wall"))
|
||||
SetupAsType(listenable, AudioEmitterType.Wall, 200f, 20f, true);
|
||||
|
||||
if (GUILayout.Button("Setup as Metal Object"))
|
||||
SetupAsType(listenable, AudioEmitterType.Metal, 800f, 100f, false);
|
||||
|
||||
if (GUILayout.Button("Setup as Wood Object"))
|
||||
SetupAsType(listenable, AudioEmitterType.Wood, 300f, 50f, false);
|
||||
|
||||
if (GUILayout.Button("Setup as Glass"))
|
||||
SetupAsType(listenable, AudioEmitterType.Glass, 1200f, 150f, false);
|
||||
|
||||
if (GUILayout.Button("Setup as Mechanical"))
|
||||
SetupAsType(listenable, AudioEmitterType.Mechanical, 500f, 80f, false);
|
||||
|
||||
EditorGUILayout.Space(5);
|
||||
|
||||
if (GUILayout.Button("Test Sound (Play Mode Only)", GUILayout.Height(30)))
|
||||
{
|
||||
if (Application.isPlaying)
|
||||
{
|
||||
listenable.StartListening(Camera.main != null ? Camera.main.transform.position : Vector3.zero);
|
||||
}
|
||||
else
|
||||
{
|
||||
EditorGUILayout.HelpBox("Enter Play Mode to test sounds", MessageType.Info);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void SetupAsType(AudioListenable listenable, AudioEmitterType type, float baseFreq, float freqVar, bool isGeneric)
|
||||
{
|
||||
SerializedObject so = new SerializedObject(listenable);
|
||||
|
||||
so.FindProperty("_emitterType").enumValueIndex = (int)type;
|
||||
so.FindProperty("_useProceduralAudio").boolValue = true;
|
||||
so.FindProperty("_baseFrequency").floatValue = baseFreq;
|
||||
so.FindProperty("_frequencyVariation").floatValue = freqVar;
|
||||
|
||||
if (isGeneric)
|
||||
{
|
||||
so.FindProperty("_volumeMin").floatValue = 0.2f;
|
||||
so.FindProperty("_volumeMax").floatValue = 0.4f;
|
||||
}
|
||||
|
||||
so.ApplyModifiedProperties();
|
||||
EditorUtility.SetDirty(listenable);
|
||||
}
|
||||
}
|
||||
|
||||
[CanEditMultipleObjects]
|
||||
[CustomEditor(typeof(Transform))]
|
||||
public class TransformEditorExtension : UnityEditor.Editor
|
||||
{
|
||||
private UnityEditor.Editor defaultEditor;
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
defaultEditor = CreateEditor(targets, System.Type.GetType("UnityEditor.TransformInspector, UnityEditor"));
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (defaultEditor != null)
|
||||
DestroyImmediate(defaultEditor);
|
||||
}
|
||||
|
||||
public override void OnInspectorGUI()
|
||||
{
|
||||
if (defaultEditor != null)
|
||||
defaultEditor.OnInspectorGUI();
|
||||
|
||||
EditorGUILayout.Space(10);
|
||||
|
||||
if (targets.Length == 1)
|
||||
{
|
||||
Transform t = (Transform)target;
|
||||
if (t.GetComponent<AudioListenable>() == null)
|
||||
{
|
||||
if (GUILayout.Button("Add Audio Listenable", GUILayout.Height(25)))
|
||||
{
|
||||
Undo.AddComponent<AudioListenable>(t.gameObject);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b606ceac9567f9746b716e63b0e644ee
|
||||
@@ -0,0 +1,63 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unheimlich.Audio
|
||||
{
|
||||
public class AudioSourceInspector : MonoBehaviour
|
||||
{
|
||||
[Header("Debug Settings")]
|
||||
[SerializeField] private float _logInterval = 1f;
|
||||
|
||||
private AudioSource _source;
|
||||
private float _lastLogTime;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_source = GetComponent<AudioSource>();
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (_source == null) return;
|
||||
|
||||
if (Time.time - _lastLogTime >= _logInterval)
|
||||
{
|
||||
LogAudioSourceState();
|
||||
_lastLogTime = Time.time;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
Debug.Log($"<color=cyan>[AudioInspector] OnEnable called on {gameObject.name}</color>");
|
||||
if (_source != null)
|
||||
LogAudioSourceState();
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
Debug.Log($"<color=magenta>[AudioInspector] OnDisable called on {gameObject.name}</color>");
|
||||
if (_source != null)
|
||||
LogAudioSourceState();
|
||||
}
|
||||
|
||||
private void LogAudioSourceState()
|
||||
{
|
||||
if (_source == null)
|
||||
{
|
||||
Debug.LogWarning($"[AudioInspector] No AudioSource on {gameObject.name}");
|
||||
return;
|
||||
}
|
||||
|
||||
string clipName = _source.clip != null ? _source.clip.name : "NULL";
|
||||
Debug.Log($"<color=yellow>[AudioInspector] {gameObject.name} | " +
|
||||
$"Clip: {clipName} | " +
|
||||
$"Time: {_source.time:F2}s | " +
|
||||
$"isPlaying: {_source.isPlaying} | " +
|
||||
$"enabled: {_source.enabled} | " +
|
||||
$"volume: {_source.volume:F2} | " +
|
||||
$"mute: {_source.mute} | " +
|
||||
$"loop: {_source.loop} | " +
|
||||
$"Active: {gameObject.activeInHierarchy}</color>");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 65fea0a39ddb811419ec546ea98e5917
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 51d3ce25787badd429148924ba0fdaca
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6712b96ee1ccf81428cd47e78893587a
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,84 @@
|
||||
using UnityEngine;
|
||||
using UnityEditor;
|
||||
using System.IO;
|
||||
|
||||
namespace FBXImporter
|
||||
{
|
||||
/// <summary>
|
||||
/// Extracts animation clips embedded in a FBX into individual .anim files
|
||||
/// in the asset's Animations/ folder.
|
||||
/// </summary>
|
||||
public static class AnimationExtractor
|
||||
{
|
||||
/// <summary>
|
||||
/// Reads all animation clips from the FBX and saves each as a .anim file
|
||||
/// in animationsDir. Skips clips that already exist (idempotent).
|
||||
/// </summary>
|
||||
public static void ExtractClips(string fbxAssetPath, string animationsDir)
|
||||
{
|
||||
if (!AssetDatabase.IsValidFolder(animationsDir))
|
||||
{
|
||||
Debug.LogWarning($"[FBXImporter] Animations folder not found: {animationsDir}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Load all assets embedded in the FBX
|
||||
var allAssets = AssetDatabase.LoadAllAssetsAtPath(fbxAssetPath);
|
||||
int count = 0;
|
||||
|
||||
foreach (var asset in allAssets)
|
||||
{
|
||||
if (!(asset is AnimationClip clip)) continue;
|
||||
|
||||
// Skip the "preview" take Unity adds internally
|
||||
if (clip.name.StartsWith("__preview__")) continue;
|
||||
|
||||
string clipName = SanitizeClipName(clip.name);
|
||||
string clipPath = $"{animationsDir}/{clipName}.anim";
|
||||
|
||||
// Skip if already extracted
|
||||
if (AssetDatabase.LoadAssetAtPath<AnimationClip>(clipPath) != null)
|
||||
{
|
||||
Debug.Log($"[FBXImporter] Clip already exists, skipped: {clipName}");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Duplicate the clip as a standalone asset
|
||||
var extracted = Object.Instantiate(clip);
|
||||
extracted.name = clipName;
|
||||
AssetDatabase.CreateAsset(extracted, clipPath);
|
||||
count++;
|
||||
|
||||
Debug.Log($"[FBXImporter] Extracted clip → {clipPath}");
|
||||
}
|
||||
|
||||
if (count > 0)
|
||||
{
|
||||
AssetDatabase.SaveAssets();
|
||||
Debug.Log($"[FBXImporter] {count} clip(s) extracted to {animationsDir}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.Log($"[FBXImporter] No new animation clips found in {Path.GetFileName(fbxAssetPath)}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sanitizes a clip name for use as a filename.
|
||||
/// "Take 001" → "Take_001"
|
||||
/// </summary>
|
||||
static string SanitizeClipName(string name)
|
||||
{
|
||||
return name.Replace(" ", "_")
|
||||
.Replace("/", "_")
|
||||
.Replace("\\", "_")
|
||||
.Replace(":", "_")
|
||||
.Replace("|", "_")
|
||||
.Replace("*", "_")
|
||||
.Replace("?", "_")
|
||||
.Replace("\"", "_")
|
||||
.Replace("<", "_")
|
||||
.Replace(">", "_");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0ec504778d5233d40a1b99a372f7eb3e
|
||||
@@ -0,0 +1,94 @@
|
||||
using UnityEngine;
|
||||
using UnityEditor;
|
||||
using UnityEditor.Animations;
|
||||
using System.IO;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace FBXImporter
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates an AnimatorController with one state per extracted animation clip,
|
||||
/// then assigns it to the Animator component on the prefab.
|
||||
/// This allows previewing animations in the editor without manual setup.
|
||||
/// </summary>
|
||||
public static class AnimatorControllerBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates an AnimatorController in animationsDir, adds one state per .anim clip found,
|
||||
/// then assigns the controller to the Animator on the prefab at prefabPath.
|
||||
/// </summary>
|
||||
public static void CreateAndAssign(string animationsDir, string prefabPath, string assetName)
|
||||
{
|
||||
if (!AssetDatabase.IsValidFolder(animationsDir))
|
||||
{
|
||||
Debug.LogWarning($"[FBXImporter] Animations folder not found: {animationsDir}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Find all extracted .anim clips in Animations/
|
||||
var clipGuids = AssetDatabase.FindAssets("t:AnimationClip", new[] { animationsDir });
|
||||
if (clipGuids.Length == 0)
|
||||
{
|
||||
Debug.Log($"[FBXImporter] No clips found in {animationsDir} — skipping controller creation");
|
||||
return;
|
||||
}
|
||||
|
||||
var clips = new List<AnimationClip>();
|
||||
foreach (var guid in clipGuids)
|
||||
{
|
||||
var clip = AssetDatabase.LoadAssetAtPath<AnimationClip>(
|
||||
AssetDatabase.GUIDToAssetPath(guid));
|
||||
if (clip != null) clips.Add(clip);
|
||||
}
|
||||
|
||||
// Create the controller
|
||||
string controllerPath = $"{animationsDir}/{assetName}.controller";
|
||||
var controller = AnimatorController.CreateAnimatorControllerAtPath(controllerPath);
|
||||
|
||||
// Add one state per clip
|
||||
var rootStateMachine = controller.layers[0].stateMachine;
|
||||
foreach (var clip in clips)
|
||||
{
|
||||
var state = rootStateMachine.AddState(clip.name);
|
||||
state.motion = clip;
|
||||
Debug.Log($"[FBXImporter] Controller state added: {clip.name}");
|
||||
}
|
||||
|
||||
// Set the first clip as default state
|
||||
if (clips.Count > 0)
|
||||
rootStateMachine.defaultState = rootStateMachine.states[0].state;
|
||||
|
||||
AssetDatabase.SaveAssets();
|
||||
Debug.Log($"[FBXImporter] AnimatorController created → {controllerPath}");
|
||||
|
||||
// Assign controller to the prefab
|
||||
AssignToPrefab(prefabPath, controller);
|
||||
}
|
||||
|
||||
static void AssignToPrefab(string prefabPath, AnimatorController controller)
|
||||
{
|
||||
var prefabAsset = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);
|
||||
if (prefabAsset == null)
|
||||
{
|
||||
Debug.LogWarning($"[FBXImporter] Prefab not found at: {prefabPath}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Edit the prefab
|
||||
var prefabInstance = PrefabUtility.LoadPrefabContents(prefabPath);
|
||||
|
||||
// Get or add Animator component
|
||||
var animator = prefabInstance.GetComponent<Animator>();
|
||||
if (animator == null)
|
||||
animator = prefabInstance.AddComponent<Animator>();
|
||||
|
||||
animator.runtimeAnimatorController = controller;
|
||||
|
||||
// Save prefab changes
|
||||
PrefabUtility.SaveAsPrefabAsset(prefabInstance, prefabPath);
|
||||
PrefabUtility.UnloadPrefabContents(prefabInstance);
|
||||
|
||||
Debug.Log($"[FBXImporter] AnimatorController assigned to prefab: {Path.GetFileName(prefabPath)}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5d86102882208454999c396da82b6886
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace FBXImporter
|
||||
{
|
||||
public class AssetFolders
|
||||
{
|
||||
public string Root { get; set; }
|
||||
public string Sources { get; set; }
|
||||
public string Textures { get; set; }
|
||||
public string Materials { get; set; }
|
||||
public string Prefabs { get; set; }
|
||||
public string Animations { get; set; }
|
||||
public string Audio { get; set; }
|
||||
public string VFX { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d94b3203f22e53f4ebcd1f0817c5987a
|
||||
@@ -0,0 +1,35 @@
|
||||
using UnityEditor;
|
||||
|
||||
namespace FBXImporter
|
||||
{
|
||||
/// <summary>
|
||||
/// Automatically removes a FBX from the registry when it's deleted from the project.
|
||||
///
|
||||
/// IMPORTANT: We resolve the GUID *before* returning DidNotDelete so Unity hasn't
|
||||
/// touched the file yet — AssetPathToGUID is still valid at this point.
|
||||
/// We use UnregisterByGuid to bypass a second GUID lookup after deletion starts,
|
||||
/// which would return an empty string and silently fail.
|
||||
/// </summary>
|
||||
public class FBXDeletionWatcher : UnityEditor.AssetModificationProcessor
|
||||
{
|
||||
static AssetDeleteResult OnWillDeleteAsset(string path, RemoveAssetOptions options)
|
||||
{
|
||||
if (!path.EndsWith(".fbx", System.StringComparison.OrdinalIgnoreCase) &&
|
||||
!path.EndsWith(".obj", System.StringComparison.OrdinalIgnoreCase))
|
||||
return AssetDeleteResult.DidNotDelete;
|
||||
|
||||
// Resolve GUID now, while the file still exists
|
||||
string guid = AssetDatabase.AssetPathToGUID(path);
|
||||
|
||||
if (!string.IsNullOrEmpty(guid))
|
||||
{
|
||||
// Use UnregisterByGuid — avoids a second AssetPathToGUID call
|
||||
// inside Unregister() which may fail once deletion is in progress
|
||||
ProcessedFBXRegistry.UnregisterByGuid(guid, System.IO.Path.GetFileName(path));
|
||||
}
|
||||
|
||||
// Always let Unity handle the actual deletion
|
||||
return AssetDeleteResult.DidNotDelete;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 689b81497b9e3a444965cbdf9a0260a2
|
||||
@@ -0,0 +1,186 @@
|
||||
using UnityEngine;
|
||||
using UnityEditor;
|
||||
using System.IO;
|
||||
|
||||
namespace FBXImporter
|
||||
{
|
||||
public static class FBXProcessor
|
||||
{
|
||||
/// <summary>
|
||||
/// Full pipeline for a single FBX:
|
||||
/// 1. Detect source (Blender/Maya)
|
||||
/// 2. Create folder structure
|
||||
/// 3. Configure ModelImporter (rig type, materials)
|
||||
/// 4. Force reimport
|
||||
/// 5. Connect PBR textures → create .mat files
|
||||
/// 6. Extract animation clips → Animations/
|
||||
/// 7. Generate prefab(s) — one per mesh if multi-mesh FBX
|
||||
/// 8. Create AnimatorController + assign to prefab
|
||||
/// </summary>
|
||||
public static void Process(string fbxAssetPath,
|
||||
ModelImporterAnimationType animationType = ModelImporterAnimationType.Generic,
|
||||
string rootNode = "",
|
||||
string originalSourceFolder = "")
|
||||
{
|
||||
if ((!fbxAssetPath.EndsWith(".fbx", System.StringComparison.OrdinalIgnoreCase) &&
|
||||
!fbxAssetPath.EndsWith(".obj", System.StringComparison.OrdinalIgnoreCase)) ||
|
||||
!fbxAssetPath.StartsWith("Assets/"))
|
||||
{
|
||||
Debug.LogWarning($"[FBXImporter] Skipped: {fbxAssetPath}");
|
||||
return;
|
||||
}
|
||||
|
||||
Debug.Log($"[FBXImporter] ── Processing {Path.GetFileName(fbxAssetPath)} ──");
|
||||
|
||||
var source = FBXSourceDetector.Detect(fbxAssetPath);
|
||||
var folders = FolderSetup.Resolve(fbxAssetPath);
|
||||
bool withAnims = animationType != ModelImporterAnimationType.None;
|
||||
|
||||
FolderSetup.Create(
|
||||
Path.GetDirectoryName(folders.Root),
|
||||
Path.GetFileName(folders.Root),
|
||||
withAnims
|
||||
);
|
||||
folders = FolderSetup.Resolve(fbxAssetPath);
|
||||
|
||||
// Configure importer
|
||||
var importer = AssetImporter.GetAtPath(fbxAssetPath) as ModelImporter;
|
||||
if (importer != null)
|
||||
{
|
||||
ConfigureImporter(importer, source, animationType, rootNode);
|
||||
importer.SaveAndReimport();
|
||||
}
|
||||
|
||||
// Load model
|
||||
var fbxAsset = AssetDatabase.LoadAssetAtPath<GameObject>(fbxAssetPath);
|
||||
if (fbxAsset == null)
|
||||
{
|
||||
Debug.LogError($"[FBXImporter] Could not load: {fbxAssetPath}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Connect PBR textures
|
||||
ProcessMaterials(fbxAsset, folders, source);
|
||||
|
||||
// Extract animation clips
|
||||
if (withAnims && !string.IsNullOrEmpty(folders.Animations))
|
||||
AnimationExtractor.ExtractClips(fbxAssetPath, folders.Animations);
|
||||
|
||||
// Generate prefab(s)
|
||||
string assetName = Path.GetFileNameWithoutExtension(fbxAssetPath);
|
||||
string prefabPath = $"{folders.Prefabs}/{assetName}.prefab";
|
||||
PrefabBuilder.CreatePrefabs(fbxAsset, folders, assetName);
|
||||
|
||||
AssetDatabase.SaveAssets();
|
||||
AssetDatabase.Refresh();
|
||||
|
||||
// Create AnimatorController with clips and assign to prefab
|
||||
if (withAnims && !string.IsNullOrEmpty(folders.Animations))
|
||||
AnimatorControllerBuilder.CreateAndAssign(folders.Animations, prefabPath, assetName);
|
||||
|
||||
AssetDatabase.SaveAssets();
|
||||
AssetDatabase.Refresh();
|
||||
|
||||
Debug.Log($"[FBXImporter] ── Done: {assetName} ──");
|
||||
}
|
||||
|
||||
// ── Importer settings ─────────────────────────────────────────────────────
|
||||
|
||||
static void ConfigureImporter(ModelImporter importer, FBXSource source,
|
||||
ModelImporterAnimationType animType, string rootNode)
|
||||
{
|
||||
importer.importCameras = false;
|
||||
importer.importLights = false;
|
||||
importer.importNormals = ModelImporterNormals.Import;
|
||||
importer.importTangents = ModelImporterTangents.CalculateMikk;
|
||||
importer.importBlendShapes = true;
|
||||
importer.importVisibility = true;
|
||||
importer.useFileScale = true;
|
||||
importer.globalScale = 1f;
|
||||
|
||||
importer.materialImportMode = ModelImporterMaterialImportMode.ImportViaMaterialDescription;
|
||||
importer.materialLocation = ModelImporterMaterialLocation.External;
|
||||
importer.materialName = ModelImporterMaterialName.BasedOnMaterialName;
|
||||
importer.materialSearch = ModelImporterMaterialSearch.Local;
|
||||
|
||||
importer.animationType = animType;
|
||||
if (!string.IsNullOrWhiteSpace(rootNode))
|
||||
importer.motionNodeName = rootNode;
|
||||
}
|
||||
|
||||
// ── Materials ─────────────────────────────────────────────────────────────
|
||||
|
||||
static void ProcessMaterials(GameObject root, AssetFolders folders, FBXSource source)
|
||||
{
|
||||
foreach (var renderer in root.GetComponentsInChildren<Renderer>(true))
|
||||
{
|
||||
var mats = renderer.sharedMaterials;
|
||||
bool changed = false;
|
||||
|
||||
for (int i = 0; i < mats.Length; i++)
|
||||
{
|
||||
var processed = ProcessMaterial(mats[i], folders, source);
|
||||
if (processed != null && processed != mats[i])
|
||||
{
|
||||
mats[i] = processed;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) renderer.sharedMaterials = mats;
|
||||
}
|
||||
}
|
||||
|
||||
static Material ProcessMaterial(Material mat, AssetFolders folders, FBXSource source)
|
||||
{
|
||||
if (mat == null) return null;
|
||||
|
||||
string cleanName = TextureMatcher.CleanMaterialName(mat.name, source);
|
||||
string matPath = $"{folders.Materials}/{cleanName}.mat";
|
||||
|
||||
Material existing = AssetDatabase.LoadAssetAtPath<Material>(matPath);
|
||||
Material target = existing ?? new Material(ShaderDetector.GetLitShader());
|
||||
|
||||
ConnectPBRTextures(target, folders.Textures, cleanName);
|
||||
|
||||
if (existing == null)
|
||||
{
|
||||
AssetDatabase.CreateAsset(target, matPath);
|
||||
Debug.Log($"[FBXImporter] Material created → {matPath}");
|
||||
}
|
||||
else
|
||||
{
|
||||
EditorUtility.SetDirty(target);
|
||||
}
|
||||
|
||||
return target;
|
||||
}
|
||||
|
||||
static void ConnectPBRTextures(Material mat, string textureDir, string baseName)
|
||||
{
|
||||
foreach (var slot in TextureMatcher.SlotKeywords.Keys)
|
||||
{
|
||||
var tex = TextureMatcher.FindForSlot(textureDir, baseName, slot);
|
||||
if (tex == null) continue;
|
||||
|
||||
string pipelineSlot = ShaderDetector.GetSlotName(slot);
|
||||
mat.SetTexture(pipelineSlot, tex);
|
||||
|
||||
switch (slot)
|
||||
{
|
||||
case "_BumpMap":
|
||||
mat.EnableKeyword("_NORMALMAP");
|
||||
break;
|
||||
case "_EmissionMap":
|
||||
mat.EnableKeyword("_EMISSION");
|
||||
mat.SetColor("_EmissionColor", Color.white);
|
||||
mat.globalIlluminationFlags = MaterialGlobalIlluminationFlags.BakedEmissive;
|
||||
break;
|
||||
case "_ParallaxMap":
|
||||
mat.EnableKeyword("_PARALLAXMAP");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: eea47f2dacccac441aa4a6806412a43a
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace FBXImporter
|
||||
{
|
||||
public enum FBXSource { Unknown, Blender, Maya }
|
||||
|
||||
public static class FBXSourceDetector
|
||||
{
|
||||
public static FBXSource Detect(string assetPath)
|
||||
{
|
||||
if (assetPath.Contains("/BlenderAssets/")) return FBXSource.Blender;
|
||||
if (assetPath.Contains("/MayaAssets/")) return FBXSource.Maya;
|
||||
|
||||
string fullPath = Path.GetFullPath(assetPath);
|
||||
if (!File.Exists(fullPath)) return FBXSource.Unknown;
|
||||
|
||||
try
|
||||
{
|
||||
byte[] buffer = new byte[2000];
|
||||
using (var fs = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read))
|
||||
fs.Read(buffer, 0, buffer.Length);
|
||||
|
||||
string header = Encoding.ASCII.GetString(buffer);
|
||||
if (header.Contains("Blender")) return FBXSource.Blender;
|
||||
if (header.Contains("Maya")) return FBXSource.Maya;
|
||||
if (header.Contains("FBX SDK")) return FBXSource.Maya;
|
||||
if (header.Contains("Autodesk")) return FBXSource.Maya;
|
||||
}
|
||||
catch { }
|
||||
|
||||
return FBXSource.Unknown;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 330be20566a6f0249bea3963ca032e4f
|
||||
@@ -0,0 +1,54 @@
|
||||
using UnityEditor;
|
||||
using System.IO;
|
||||
|
||||
namespace FBXImporter
|
||||
{
|
||||
public static class FolderSetup
|
||||
{
|
||||
static readonly string[] RequiredFolders = { "Sources", "Textures", "Materials", "Prefabs", "Audio", "VFX" };
|
||||
|
||||
public static AssetFolders Create(string parentPath, string assetName, bool withAnimations = false)
|
||||
{
|
||||
string root = $"{parentPath}/{assetName}";
|
||||
EnsureFolder(parentPath, assetName);
|
||||
foreach (var folder in RequiredFolders)
|
||||
EnsureFolder(root, folder);
|
||||
|
||||
string animPath = $"{root}/Animations";
|
||||
if (withAnimations) EnsureFolder(root, "Animations");
|
||||
|
||||
AssetDatabase.Refresh();
|
||||
return BuildFolders(root, withAnimations ? animPath : null);
|
||||
}
|
||||
|
||||
public static AssetFolders Resolve(string fbxAssetPath)
|
||||
{
|
||||
string dir = Path.GetDirectoryName(fbxAssetPath).Replace("\\", "/");
|
||||
string root = dir.EndsWith("/Sources")
|
||||
? Path.GetDirectoryName(dir).Replace("\\", "/")
|
||||
: dir;
|
||||
|
||||
string animPath = $"{root}/Animations";
|
||||
return BuildFolders(root, AssetDatabase.IsValidFolder(animPath) ? animPath : null);
|
||||
}
|
||||
|
||||
static AssetFolders BuildFolders(string root, string animPath) => new AssetFolders
|
||||
{
|
||||
Root = root,
|
||||
Sources = $"{root}/Sources",
|
||||
Textures = $"{root}/Textures",
|
||||
Materials = $"{root}/Materials",
|
||||
Prefabs = $"{root}/Prefabs",
|
||||
Animations = animPath,
|
||||
Audio = $"{root}/Audio",
|
||||
VFX = $"{root}/VFX",
|
||||
};
|
||||
|
||||
static void EnsureFolder(string parent, string child)
|
||||
{
|
||||
string path = $"{parent}/{child}";
|
||||
if (!AssetDatabase.IsValidFolder(path))
|
||||
AssetDatabase.CreateFolder(parent, child);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b2256f5b9c45ebc48bbfc702f7b7177e
|
||||
@@ -0,0 +1,90 @@
|
||||
using UnityEngine;
|
||||
using UnityEditor;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
|
||||
namespace FBXImporter
|
||||
{
|
||||
public static class PrefabBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a single prefab from the imported FBX root GameObject.
|
||||
/// Adds a LODGroup automatically if _LOD0, _LOD1... meshes are found.
|
||||
/// </summary>
|
||||
public static void CreatePrefabs(GameObject fbxRoot, AssetFolders folders, string assetName)
|
||||
{
|
||||
CreateSinglePrefab(fbxRoot, folders.Prefabs, assetName);
|
||||
}
|
||||
|
||||
public static GameObject CreatePrefab(GameObject fbxRoot, string prefabDir, string assetName)
|
||||
{
|
||||
return CreateSinglePrefab(fbxRoot, prefabDir, assetName);
|
||||
}
|
||||
|
||||
static GameObject CreateSinglePrefab(GameObject fbxRoot, string prefabDir, string assetName)
|
||||
{
|
||||
EnsureFolder(prefabDir);
|
||||
|
||||
var instance = Object.Instantiate(fbxRoot);
|
||||
instance.name = assetName;
|
||||
|
||||
if (TrySetupLODGroup(instance))
|
||||
Debug.Log($"[FBXImporter] LODGroup configured for '{assetName}'");
|
||||
|
||||
string prefabPath = $"{prefabDir}/{assetName}.prefab";
|
||||
var prefab = PrefabUtility.SaveAsPrefabAsset(instance, prefabPath);
|
||||
Object.DestroyImmediate(instance);
|
||||
|
||||
if (prefab != null) Debug.Log($"[FBXImporter] Prefab → {prefabPath}");
|
||||
else Debug.LogError($"[FBXImporter] Failed to save prefab at {prefabPath}");
|
||||
|
||||
return prefab;
|
||||
}
|
||||
|
||||
static bool TrySetupLODGroup(GameObject root)
|
||||
{
|
||||
var lodMap = new SortedDictionary<int, List<Renderer>>();
|
||||
foreach (var r in root.GetComponentsInChildren<Renderer>(true))
|
||||
{
|
||||
int idx = ExtractLODIndex(r.gameObject.name.ToUpper());
|
||||
if (idx < 0) continue;
|
||||
if (!lodMap.ContainsKey(idx)) lodMap[idx] = new List<Renderer>();
|
||||
lodMap[idx].Add(r);
|
||||
}
|
||||
|
||||
if (lodMap.Count < 2) return false;
|
||||
|
||||
var existing = root.GetComponent<LODGroup>();
|
||||
if (existing != null) Object.DestroyImmediate(existing);
|
||||
|
||||
var group = root.AddComponent<LODGroup>();
|
||||
float[] thresh = { 0.6f, 0.3f, 0.1f, 0.05f, 0.01f };
|
||||
var lods = new LOD[lodMap.Count];
|
||||
int i = 0;
|
||||
foreach (var kvp in lodMap)
|
||||
{
|
||||
lods[i] = new LOD(i < thresh.Length ? thresh[i] : 0.01f, kvp.Value.ToArray());
|
||||
i++;
|
||||
}
|
||||
group.SetLODs(lods);
|
||||
group.RecalculateBounds();
|
||||
return true;
|
||||
}
|
||||
|
||||
static int ExtractLODIndex(string name)
|
||||
{
|
||||
int idx = name.LastIndexOf("_LOD");
|
||||
if (idx < 0) return -1;
|
||||
return int.TryParse(name.Substring(idx + 4), out int level) ? level : -1;
|
||||
}
|
||||
|
||||
static void EnsureFolder(string path)
|
||||
{
|
||||
if (AssetDatabase.IsValidFolder(path)) return;
|
||||
string parent = Path.GetDirectoryName(path).Replace("\\", "/");
|
||||
string child = Path.GetFileName(path);
|
||||
if (!AssetDatabase.IsValidFolder(parent)) EnsureFolder(parent);
|
||||
AssetDatabase.CreateFolder(parent, child);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c3b17b0a218bf80458531ba1c789cfed
|
||||
@@ -0,0 +1,126 @@
|
||||
using UnityEditor;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
namespace FBXImporter
|
||||
{
|
||||
/// <summary>
|
||||
/// Persistent registry of FBX files seen by the importer.
|
||||
/// Stores GUID + filename so the file is human-readable.
|
||||
/// Format per line: "guid TAB filename.fbx"
|
||||
/// Registry file is stored next to this script, wherever the plugin is installed.
|
||||
/// </summary>
|
||||
public static class ProcessedFBXRegistry
|
||||
{
|
||||
// Resolved dynamically so the registry follows the plugin wherever it's installed
|
||||
static string _registryPath;
|
||||
static string RegistryPath
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_registryPath != null) return _registryPath;
|
||||
|
||||
var guids = AssetDatabase.FindAssets("ProcessedFBXRegistry t:Script");
|
||||
foreach (var guid in guids)
|
||||
{
|
||||
string path = AssetDatabase.GUIDToAssetPath(guid);
|
||||
if (path.Contains("FBXImporter"))
|
||||
{
|
||||
_registryPath = Path.GetDirectoryName(path).Replace("\\", "/") + "/processed_fbx.txt";
|
||||
return _registryPath;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback — should never happen if plugin is properly installed
|
||||
_registryPath = "Assets/processed_fbx.txt";
|
||||
return _registryPath;
|
||||
}
|
||||
}
|
||||
|
||||
// guid → filename (for display)
|
||||
static Dictionary<string, string> _registry;
|
||||
|
||||
static Dictionary<string, string> Registry
|
||||
{
|
||||
get { if (_registry == null) Load(); return _registry; }
|
||||
}
|
||||
|
||||
public static bool IsRegistered(string assetPath)
|
||||
{
|
||||
string guid = AssetDatabase.AssetPathToGUID(assetPath);
|
||||
return !string.IsNullOrEmpty(guid) && Registry.ContainsKey(guid);
|
||||
}
|
||||
|
||||
public static void Register(System.Collections.Generic.IEnumerable<string> assetPaths)
|
||||
{
|
||||
bool changed = false;
|
||||
foreach (var path in assetPaths)
|
||||
{
|
||||
string guid = AssetDatabase.AssetPathToGUID(path);
|
||||
if (string.IsNullOrEmpty(guid)) continue;
|
||||
if (!Registry.ContainsKey(guid))
|
||||
{
|
||||
Registry[guid] = Path.GetFileName(path);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) Save();
|
||||
}
|
||||
|
||||
/// <summary>Removes a FBX from the registry by asset path.</summary>
|
||||
public static void Unregister(string assetPath)
|
||||
{
|
||||
string guid = AssetDatabase.AssetPathToGUID(assetPath);
|
||||
if (!string.IsNullOrEmpty(guid))
|
||||
UnregisterByGuid(guid, Path.GetFileName(assetPath));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a FBX from the registry directly by GUID.
|
||||
/// Used by FBXDeletionWatcher where the GUID is resolved BEFORE
|
||||
/// Unity deletes the file — avoids the race condition where
|
||||
/// AssetPathToGUID returns empty after deletion starts.
|
||||
/// </summary>
|
||||
public static void UnregisterByGuid(string guid, string displayName = "")
|
||||
{
|
||||
if (string.IsNullOrEmpty(guid)) return;
|
||||
if (Registry.Remove(guid))
|
||||
{
|
||||
Save();
|
||||
string name = string.IsNullOrEmpty(displayName) ? guid : displayName;
|
||||
UnityEngine.Debug.Log($"[FBXImporter] Removed from registry: {name}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Persistence ───────────────────────────────────────────────────────────
|
||||
|
||||
static void Load()
|
||||
{
|
||||
_registry = new Dictionary<string, string>();
|
||||
if (!File.Exists(RegistryPath)) return;
|
||||
|
||||
foreach (var line in File.ReadAllLines(RegistryPath))
|
||||
{
|
||||
string trimmed = line.Trim();
|
||||
if (string.IsNullOrEmpty(trimmed)) continue;
|
||||
|
||||
var parts = trimmed.Split(new[] { '\t', ' ' }, 2, System.StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length >= 1)
|
||||
_registry[parts[0]] = parts.Length >= 2 ? parts[1] : "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
static void Save()
|
||||
{
|
||||
string dir = Path.GetDirectoryName(RegistryPath);
|
||||
if (!Directory.Exists(dir)) Directory.CreateDirectory(dir);
|
||||
|
||||
var lines = Registry.Select(kvp => $"{kvp.Key}\t{kvp.Value}").ToArray();
|
||||
File.WriteAllLines(RegistryPath, lines);
|
||||
|
||||
// Nullify cache so the next access reloads from disk
|
||||
_registry = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 85c39d9a08dc3d04a974255db5d713ed
|
||||
@@ -0,0 +1,48 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.Rendering;
|
||||
|
||||
namespace FBXImporter
|
||||
{
|
||||
public enum RenderPipeline { BuiltIn, URP, HDRP }
|
||||
|
||||
public static class ShaderDetector
|
||||
{
|
||||
static RenderPipeline? _cached;
|
||||
|
||||
public static RenderPipeline GetPipeline()
|
||||
{
|
||||
if (_cached.HasValue) return _cached.Value;
|
||||
var rp = GraphicsSettings.currentRenderPipeline;
|
||||
if (rp == null) _cached = RenderPipeline.BuiltIn;
|
||||
else if (rp.GetType().Name.Contains("Universal")) _cached = RenderPipeline.URP;
|
||||
else if (rp.GetType().Name.Contains("HD")) _cached = RenderPipeline.HDRP;
|
||||
else _cached = RenderPipeline.BuiltIn;
|
||||
return _cached.Value;
|
||||
}
|
||||
|
||||
public static Shader GetLitShader()
|
||||
{
|
||||
switch (GetPipeline())
|
||||
{
|
||||
case RenderPipeline.URP: return Shader.Find("Universal Render Pipeline/Lit") ?? Shader.Find("Standard");
|
||||
case RenderPipeline.HDRP: return Shader.Find("HDRP/Lit") ?? Shader.Find("Standard");
|
||||
default: return Shader.Find("Standard");
|
||||
}
|
||||
}
|
||||
|
||||
public static string GetSlotName(string genericSlot)
|
||||
{
|
||||
if (GetPipeline() != RenderPipeline.HDRP) return genericSlot;
|
||||
switch (genericSlot)
|
||||
{
|
||||
case "_BaseMap": return "_BaseColorMap";
|
||||
case "_BumpMap": return "_NormalMap";
|
||||
case "_MetallicGlossMap": return "_MaskMap";
|
||||
case "_SpecGlossMap": return "_MaskMap";
|
||||
case "_OcclusionMap": return "_MaskMap";
|
||||
case "_EmissionMap": return "_EmissiveColorMap";
|
||||
default: return genericSlot;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: dc40db3111674c04f94d8fdb63aed0ab
|
||||
@@ -0,0 +1,142 @@
|
||||
using System.IO;
|
||||
using System.Collections.Generic;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace FBXImporter
|
||||
{
|
||||
public static class TextureMatcher
|
||||
{
|
||||
static readonly string[] SupportedExtensions = { ".png", ".tga", ".jpg", ".jpeg", ".tiff", ".exr" };
|
||||
|
||||
/// <summary>
|
||||
/// Maps shader slot -> keywords matched anywhere in filename (case-insensitive).
|
||||
/// More specific keywords listed first to avoid false positives.
|
||||
/// </summary>
|
||||
public static readonly Dictionary<string, string[]> SlotKeywords = new Dictionary<string, string[]>
|
||||
{
|
||||
{ "_BaseMap", new[] { "albedo", "diffuse", "basecolor", "base_color", "_diff", "_dif", "_D", "_d" } },
|
||||
{ "_BumpMap", new[] { "normal", "nrm", "_N", "_n" } },
|
||||
{ "_MetallicGlossMap", new[] { "metallic", "metal", "gloss", "_M", "_m" } },
|
||||
{ "_SpecGlossMap", new[] { "roughness", "rough", "spec", "_R", "_r" } },
|
||||
{ "_OcclusionMap", new[] { "occlusion", "ambientocclusion", "_AO", "_ao" } },
|
||||
{ "_EmissionMap", new[] { "emissive", "emission", "_E", "_e" } },
|
||||
{ "_ParallaxMap", new[] { "height", "parallax", "opacity", "alpha", "_H", "_h" } },
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Finds a texture for the given material and shader slot.
|
||||
///
|
||||
/// PASS 1 - Prefix match (strict):
|
||||
/// filename starts with baseName AND contains a slot keyword
|
||||
/// e.g. baseName="Robot", slot="_BaseMap" -> matches "Robot_D.jpg", "Robot_diffuse.png"
|
||||
///
|
||||
/// PASS 2 - Fallback (keyword only, no prefix required):
|
||||
/// If pass 1 finds nothing, scan ALL textures in the folder for the keyword.
|
||||
/// Useful when textures use a different prefix than the material name.
|
||||
/// e.g. material="Robot", textures="Drone_diff.jpg" -> connected via "diff" keyword
|
||||
/// </summary>
|
||||
public static Texture2D FindForSlot(string textureDir, string baseName, string shaderSlot)
|
||||
{
|
||||
if (!SlotKeywords.TryGetValue(shaderSlot, out string[] keywords)) return null;
|
||||
if (!AssetDatabase.IsValidFolder(textureDir)) return null;
|
||||
|
||||
string[] guids = AssetDatabase.FindAssets("t:Texture2D", new[] { textureDir });
|
||||
|
||||
// Pass 1: prefix + keyword
|
||||
foreach (var guid in guids)
|
||||
{
|
||||
string assetPath = AssetDatabase.GUIDToAssetPath(guid);
|
||||
if (!IsSupportedExtension(assetPath)) continue;
|
||||
|
||||
string filename = Path.GetFileNameWithoutExtension(assetPath).ToLower();
|
||||
if (!filename.StartsWith(baseName.ToLower())) continue;
|
||||
|
||||
foreach (var keyword in keywords)
|
||||
{
|
||||
if (filename.Contains(keyword.ToLower()))
|
||||
{
|
||||
var tex = AssetDatabase.LoadAssetAtPath<Texture2D>(assetPath);
|
||||
if (tex == null) continue;
|
||||
if (shaderSlot == "_BumpMap") EnsureNormalMapType(assetPath);
|
||||
return tex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: keyword only fallback (mismatched prefix)
|
||||
foreach (var guid in guids)
|
||||
{
|
||||
string assetPath = AssetDatabase.GUIDToAssetPath(guid);
|
||||
if (!IsSupportedExtension(assetPath)) continue;
|
||||
|
||||
string filename = Path.GetFileNameWithoutExtension(assetPath).ToLower();
|
||||
|
||||
foreach (var keyword in keywords)
|
||||
{
|
||||
if (filename.Contains(keyword.ToLower()))
|
||||
{
|
||||
var tex = AssetDatabase.LoadAssetAtPath<Texture2D>(assetPath);
|
||||
if (tex == null) continue;
|
||||
if (shaderSlot == "_BumpMap") EnsureNormalMapType(assetPath);
|
||||
Debug.Log($"[FBXImporter] Fallback match: {shaderSlot} <- {Path.GetFileName(assetPath)} (mat: {baseName})");
|
||||
return tex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Strips DCC-specific suffixes to recover the clean base name.
|
||||
/// Maya : "hero_blinn1" -> "hero" / "standardSurface1" -> "Material"
|
||||
/// Both : "tree_MAT" -> "tree"
|
||||
/// </summary>
|
||||
public static string CleanMaterialName(string rawName, FBXSource source)
|
||||
{
|
||||
string name = rawName;
|
||||
|
||||
if (source == FBXSource.Maya)
|
||||
{
|
||||
string[] mayaSuffixes = { "_blinn", "_phong", "_lambert", "_aiStandardSurface", "_standardSurface", "_SG" };
|
||||
foreach (var s in mayaSuffixes)
|
||||
{
|
||||
int idx = name.ToLower().IndexOf(s.ToLower());
|
||||
if (idx > 0) { name = name.Substring(0, idx); break; }
|
||||
}
|
||||
|
||||
if (name.ToLower().StartsWith("standardsurface") ||
|
||||
name.ToLower().StartsWith("lambert") ||
|
||||
name.ToLower().StartsWith("phong") ||
|
||||
name.ToLower().StartsWith("blinn"))
|
||||
name = "Material";
|
||||
|
||||
name = System.Text.RegularExpressions.Regex.Replace(name, @"\d+$", "").TrimEnd('_');
|
||||
}
|
||||
|
||||
foreach (var s in new[] { "_MAT", "_Mat", "_material", "_Material" })
|
||||
if (name.EndsWith(s)) name = name.Substring(0, name.Length - s.Length);
|
||||
|
||||
return string.IsNullOrWhiteSpace(name) ? rawName : name;
|
||||
}
|
||||
|
||||
static bool IsSupportedExtension(string assetPath)
|
||||
{
|
||||
string ext = Path.GetExtension(assetPath).ToLower();
|
||||
foreach (var e in SupportedExtensions)
|
||||
if (ext == e) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
static void EnsureNormalMapType(string assetPath)
|
||||
{
|
||||
var ti = AssetImporter.GetAtPath(assetPath) as TextureImporter;
|
||||
if (ti != null && ti.textureType != TextureImporterType.NormalMap)
|
||||
{
|
||||
ti.textureType = TextureImporterType.NormalMap;
|
||||
ti.SaveAndReimport();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 675fd374b2a03994c8ac820bee5fafa2
|
||||
@@ -0,0 +1,106 @@
|
||||
using UnityEngine;
|
||||
using UnityEditor;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
|
||||
namespace FBXImporter
|
||||
{
|
||||
public class FBXBatchDialog : EditorWindow
|
||||
{
|
||||
private class FBXEntry
|
||||
{
|
||||
public string Path;
|
||||
public string Name;
|
||||
public FBXSource Source;
|
||||
public bool ShouldProcess = true;
|
||||
}
|
||||
|
||||
private List<FBXEntry> _entries = new List<FBXEntry>();
|
||||
private Vector2 _scroll;
|
||||
|
||||
public static void Show(List<string> fbxPaths)
|
||||
{
|
||||
var window = GetWindow<FBXBatchDialog>(true, "FBX Importer — New Assets", true);
|
||||
window.minSize = new Vector2(520, 320);
|
||||
|
||||
foreach (var path in fbxPaths)
|
||||
window._entries.Add(new FBXEntry {
|
||||
Path = path,
|
||||
Name = Path.GetFileName(path),
|
||||
Source = FBXSourceDetector.Detect(path),
|
||||
ShouldProcess = true,
|
||||
});
|
||||
|
||||
window.ShowModal();
|
||||
}
|
||||
|
||||
void OnGUI()
|
||||
{
|
||||
GUILayout.Space(10);
|
||||
GUILayout.Label($"{_entries.Count} new FBX detected", EditorStyles.boldLabel);
|
||||
GUILayout.Label("All files are registered. Check which ones to process.", EditorStyles.miniLabel);
|
||||
GUILayout.Space(8);
|
||||
|
||||
// Header
|
||||
using (new EditorGUILayout.HorizontalScope(EditorStyles.toolbar))
|
||||
{
|
||||
GUILayout.Label("Process", GUILayout.Width(55));
|
||||
GUILayout.Label("File", GUILayout.Width(200));
|
||||
GUILayout.Label("Source", GUILayout.Width(70));
|
||||
GUILayout.Label("Path", GUILayout.ExpandWidth(true));
|
||||
}
|
||||
|
||||
// List
|
||||
_scroll = EditorGUILayout.BeginScrollView(_scroll, GUILayout.ExpandHeight(true));
|
||||
foreach (var entry in _entries)
|
||||
{
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
entry.ShouldProcess = EditorGUILayout.Toggle(entry.ShouldProcess, GUILayout.Width(55));
|
||||
var style = new GUIStyle(EditorStyles.label)
|
||||
{ normal = { textColor = entry.ShouldProcess ? Color.white : Color.gray } };
|
||||
GUILayout.Label(entry.Name, style, GUILayout.Width(200));
|
||||
GUILayout.Label(entry.Source.ToString(), EditorStyles.miniLabel, GUILayout.Width(70));
|
||||
GUILayout.Label(entry.Path, EditorStyles.miniLabel, GUILayout.ExpandWidth(true));
|
||||
}
|
||||
}
|
||||
EditorGUILayout.EndScrollView();
|
||||
|
||||
// Select all / none
|
||||
GUILayout.Space(4);
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
if (GUILayout.Button("All", GUILayout.Width(60))) _entries.ForEach(e => e.ShouldProcess = true);
|
||||
if (GUILayout.Button("None", GUILayout.Width(60))) _entries.ForEach(e => e.ShouldProcess = false);
|
||||
}
|
||||
|
||||
GUILayout.Space(8);
|
||||
|
||||
// Actions
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
GUILayout.FlexibleSpace();
|
||||
|
||||
if (GUILayout.Button("Skip all", GUILayout.Width(80)))
|
||||
{ Close(); return; }
|
||||
|
||||
int count = _entries.FindAll(e => e.ShouldProcess).Count;
|
||||
using (new EditorGUI.DisabledScope(count == 0))
|
||||
{
|
||||
if (GUILayout.Button($"Process {count} FBX", GUILayout.Width(130)))
|
||||
{ ProcessSelected(); Close(); }
|
||||
}
|
||||
}
|
||||
GUILayout.Space(8);
|
||||
}
|
||||
|
||||
void ProcessSelected()
|
||||
{
|
||||
foreach (var entry in _entries)
|
||||
{
|
||||
if (!entry.ShouldProcess) continue;
|
||||
FBXImportDialog.Show(entry.Path, entry.Source);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 96093adb2a120a6468377c6dc73e1607
|
||||
@@ -0,0 +1,65 @@
|
||||
using UnityEditor;
|
||||
using System.Linq;
|
||||
|
||||
namespace FBXImporter
|
||||
{
|
||||
public static class FBXContextMenu
|
||||
{
|
||||
[MenuItem("Assets/FBX Importer/Process this FBX", false, 1000)]
|
||||
static void ProcessSingle()
|
||||
{
|
||||
foreach (var obj in Selection.objects)
|
||||
{
|
||||
string path = AssetDatabase.GetAssetPath(obj);
|
||||
if (!path.EndsWith(".fbx", System.StringComparison.OrdinalIgnoreCase) &&
|
||||
!path.EndsWith(".obj", System.StringComparison.OrdinalIgnoreCase)) continue;
|
||||
var source = FBXSourceDetector.Detect(path);
|
||||
FBXImportDialog.Show(path, source);
|
||||
}
|
||||
}
|
||||
|
||||
[MenuItem("Assets/FBX Importer/Process this FBX", true)]
|
||||
static bool ProcessSingleValidate() =>
|
||||
Selection.objects.Any(o => AssetDatabase.GetAssetPath(o)
|
||||
.EndsWith(".fbx", System.StringComparison.OrdinalIgnoreCase) ||
|
||||
AssetDatabase.GetAssetPath(o).EndsWith(".obj", System.StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
[MenuItem("Assets/FBX Importer/Remove from registry", false, 1001)]
|
||||
static void RemoveFromRegistry()
|
||||
{
|
||||
foreach (var obj in Selection.objects)
|
||||
{
|
||||
string path = AssetDatabase.GetAssetPath(obj);
|
||||
if (path.EndsWith(".fbx", System.StringComparison.OrdinalIgnoreCase) ||
|
||||
path.EndsWith(".obj", System.StringComparison.OrdinalIgnoreCase))
|
||||
ProcessedFBXRegistry.Unregister(path);
|
||||
}
|
||||
}
|
||||
|
||||
[MenuItem("Assets/FBX Importer/Remove from registry", true)]
|
||||
static bool RemoveFromRegistryValidate() =>
|
||||
Selection.objects.Any(o => AssetDatabase.GetAssetPath(o)
|
||||
.EndsWith(".fbx", System.StringComparison.OrdinalIgnoreCase) ||
|
||||
AssetDatabase.GetAssetPath(o).EndsWith(".obj", System.StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
[MenuItem("Assets/FBX Importer/Create Asset Folder Structure", false, 1020)]
|
||||
static void CreateFolderStructure()
|
||||
{
|
||||
string selectedPath = GetSelectedFolderPath();
|
||||
if (string.IsNullOrEmpty(selectedPath)) return;
|
||||
|
||||
string parent = System.IO.Path.GetDirectoryName(selectedPath).Replace("\\", "/");
|
||||
FolderSetup.Create(parent, System.IO.Path.GetFileName(selectedPath), false);
|
||||
}
|
||||
|
||||
[MenuItem("Assets/FBX Importer/Create Asset Folder Structure", true)]
|
||||
static bool CreateFolderStructureValidate() => !string.IsNullOrEmpty(GetSelectedFolderPath());
|
||||
|
||||
static string GetSelectedFolderPath()
|
||||
{
|
||||
if (Selection.activeObject == null) return null;
|
||||
string path = AssetDatabase.GetAssetPath(Selection.activeObject);
|
||||
return AssetDatabase.IsValidFolder(path) ? path : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 35883f87017729a4da4aa2723ca60654
|
||||
@@ -0,0 +1,243 @@
|
||||
using UnityEngine;
|
||||
using UnityEditor;
|
||||
using System.IO;
|
||||
|
||||
namespace FBXImporter
|
||||
{
|
||||
/// <summary>
|
||||
/// Per-asset config dialog.
|
||||
/// Default destination: Assets/[ProjectName]/Content
|
||||
/// Deletes the original source folder after successful import.
|
||||
/// </summary>
|
||||
public class FBXImportDialog : EditorWindow
|
||||
{
|
||||
private string _fbxSourcePath;
|
||||
private string _originalFolder; // folder to delete after import
|
||||
private FBXSource _detectedSource;
|
||||
private string _assetName = "";
|
||||
private string _destinationPath = "";
|
||||
private bool _createAnims = false;
|
||||
private string _errorMessage = "";
|
||||
|
||||
// Rig
|
||||
private ModelImporterAnimationType _animType = ModelImporterAnimationType.Generic;
|
||||
private string _rootNode = "";
|
||||
|
||||
static readonly string[] AnimTypeLabels = { "None", "Legacy", "Generic", "Humanoid" };
|
||||
static readonly ModelImporterAnimationType[] AnimTypeValues =
|
||||
{
|
||||
ModelImporterAnimationType.None,
|
||||
ModelImporterAnimationType.Legacy,
|
||||
ModelImporterAnimationType.Generic,
|
||||
ModelImporterAnimationType.Human,
|
||||
};
|
||||
|
||||
public static void Show(string fbxAssetPath, FBXSource source)
|
||||
{
|
||||
var window = GetWindow<FBXImportDialog>(true, $"Configure — {Path.GetFileName(fbxAssetPath)}", true);
|
||||
window.minSize = new Vector2(460, 360);
|
||||
window.maxSize = new Vector2(460, 360);
|
||||
window._fbxSourcePath = fbxAssetPath;
|
||||
window._detectedSource = source;
|
||||
window._assetName = Path.GetFileNameWithoutExtension(fbxAssetPath);
|
||||
|
||||
// Store the original parent folder for cleanup after import
|
||||
window._originalFolder = Path.GetDirectoryName(
|
||||
Path.GetDirectoryName(fbxAssetPath)).Replace("\\", "/");
|
||||
|
||||
// Default destination: Assets/[ProjectFolder]/Content
|
||||
// Falls back to Assets/ if not found
|
||||
window._destinationPath = FindDefaultContentFolder();
|
||||
|
||||
window.ShowModal();
|
||||
}
|
||||
|
||||
// ── Default content folder detection ─────────────────────────────────────
|
||||
|
||||
static string FindDefaultContentFolder()
|
||||
{
|
||||
// Try to find a "Content" folder one level deep under Assets/
|
||||
string[] candidates = AssetDatabase.GetSubFolders("Assets");
|
||||
foreach (var folder in candidates)
|
||||
{
|
||||
string content = $"{folder}/Content";
|
||||
if (AssetDatabase.IsValidFolder(content))
|
||||
return content;
|
||||
}
|
||||
|
||||
// Fallback to Assets/
|
||||
return "Assets";
|
||||
}
|
||||
|
||||
// ── GUI ───────────────────────────────────────────────────────────────────
|
||||
|
||||
void OnGUI()
|
||||
{
|
||||
GUILayout.Space(10);
|
||||
GUILayout.Label("Configure asset", EditorStyles.boldLabel);
|
||||
GUILayout.Space(6);
|
||||
|
||||
DrawRow("File", Path.GetFileName(_fbxSourcePath));
|
||||
DrawRow("Source", _detectedSource.ToString());
|
||||
GUILayout.Space(10);
|
||||
|
||||
// Asset name
|
||||
GUILayout.Label("Asset Name", EditorStyles.miniBoldLabel);
|
||||
_assetName = EditorGUILayout.TextField(_assetName);
|
||||
GUILayout.Space(6);
|
||||
|
||||
// Destination
|
||||
GUILayout.Label("Destination Folder", EditorStyles.miniBoldLabel);
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
EditorGUILayout.LabelField(_destinationPath, EditorStyles.helpBox);
|
||||
if (GUILayout.Button("Browse…", GUILayout.Width(70)))
|
||||
{
|
||||
string chosen = EditorUtility.OpenFolderPanel("Select destination", Application.dataPath, "");
|
||||
if (!string.IsNullOrEmpty(chosen))
|
||||
{
|
||||
if (chosen.StartsWith(Application.dataPath))
|
||||
_destinationPath = "Assets" + chosen.Substring(Application.dataPath.Length);
|
||||
else
|
||||
_errorMessage = "Please select a folder inside Assets/";
|
||||
}
|
||||
}
|
||||
}
|
||||
EditorGUILayout.LabelField($"→ {_destinationPath}/{_assetName}", EditorStyles.miniLabel);
|
||||
GUILayout.Space(8);
|
||||
|
||||
// Rig
|
||||
GUILayout.Label("Rig & Animation", EditorStyles.miniBoldLabel);
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
GUILayout.Label("Animation Type", GUILayout.Width(120));
|
||||
int current = System.Array.IndexOf(AnimTypeValues, _animType);
|
||||
int selected = GUILayout.SelectionGrid(current, AnimTypeLabels, 4);
|
||||
_animType = AnimTypeValues[selected];
|
||||
}
|
||||
|
||||
if (_animType != ModelImporterAnimationType.None)
|
||||
{
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
GUILayout.Label("Root Node (opt.)", GUILayout.Width(120));
|
||||
_rootNode = EditorGUILayout.TextField(_rootNode);
|
||||
}
|
||||
_createAnims = true;
|
||||
}
|
||||
|
||||
GUILayout.Space(4);
|
||||
_createAnims = EditorGUILayout.ToggleLeft("Create Animations/ folder", _createAnims);
|
||||
GUILayout.Space(8);
|
||||
|
||||
if (!string.IsNullOrEmpty(_errorMessage))
|
||||
{
|
||||
EditorGUILayout.HelpBox(_errorMessage, MessageType.Error);
|
||||
GUILayout.Space(4);
|
||||
}
|
||||
|
||||
// Buttons — always enabled as long as name is not empty
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
GUILayout.FlexibleSpace();
|
||||
|
||||
if (GUILayout.Button("Skip", GUILayout.Width(70)))
|
||||
{ Close(); return; }
|
||||
|
||||
bool valid = !string.IsNullOrWhiteSpace(_assetName)
|
||||
&& !string.IsNullOrWhiteSpace(_destinationPath);
|
||||
|
||||
using (new EditorGUI.DisabledScope(!valid))
|
||||
{
|
||||
if (GUILayout.Button("Create & Import", GUILayout.Width(120)))
|
||||
{ Confirm(); Close(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Confirm ───────────────────────────────────────────────────────────────
|
||||
|
||||
void Confirm()
|
||||
{
|
||||
var folders = FolderSetup.Create(_destinationPath, _assetName, _createAnims);
|
||||
|
||||
// Move FBX into Sources/
|
||||
string targetFBX = $"{folders.Sources}/{Path.GetFileName(_fbxSourcePath)}";
|
||||
if (_fbxSourcePath != targetFBX)
|
||||
{
|
||||
string moveError = AssetDatabase.MoveAsset(_fbxSourcePath, targetFBX);
|
||||
if (!string.IsNullOrEmpty(moveError))
|
||||
{
|
||||
Debug.LogError($"[FBXImporter] Could not move FBX: {moveError}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Move textures from original folder into Textures/
|
||||
MoveTexturesFromOriginal(folders);
|
||||
|
||||
// Run full pipeline — pass original folder for cleanup
|
||||
FBXProcessor.Process(targetFBX, _animType, _rootNode, _originalFolder);
|
||||
|
||||
var rootObj = AssetDatabase.LoadAssetAtPath<Object>(folders.Root);
|
||||
if (rootObj != null) EditorGUIUtility.PingObject(rootObj);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Moves texture files from the original dropped folder into the asset's Textures/.
|
||||
/// Handles the case where the user dropped a folder containing both source/ and textures/.
|
||||
/// </summary>
|
||||
void MoveTexturesFromOriginal(AssetFolders folders)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_originalFolder)) return;
|
||||
|
||||
// Look for a textures subfolder in the original dropped folder
|
||||
string[] textureSources = {
|
||||
$"{_originalFolder}/textures",
|
||||
$"{_originalFolder}/Textures",
|
||||
$"{_originalFolder}/texture",
|
||||
_originalFolder // textures may be directly in the root
|
||||
};
|
||||
|
||||
string[] supportedExts = { ".png", ".tga", ".jpg", ".jpeg", ".tiff", ".exr" };
|
||||
|
||||
foreach (var texSource in textureSources)
|
||||
{
|
||||
if (!AssetDatabase.IsValidFolder(texSource)) continue;
|
||||
|
||||
string[] guids = AssetDatabase.FindAssets("t:Texture2D", new[] { texSource });
|
||||
foreach (var guid in guids)
|
||||
{
|
||||
string srcPath = AssetDatabase.GUIDToAssetPath(guid);
|
||||
string ext = Path.GetExtension(srcPath).ToLower();
|
||||
|
||||
bool valid = false;
|
||||
foreach (var e in supportedExts) if (ext == e) { valid = true; break; }
|
||||
if (!valid) continue;
|
||||
|
||||
string dstPath = $"{folders.Textures}/{Path.GetFileName(srcPath)}";
|
||||
if (srcPath == dstPath) continue;
|
||||
|
||||
string err = AssetDatabase.MoveAsset(srcPath, dstPath);
|
||||
if (!string.IsNullOrEmpty(err))
|
||||
Debug.LogWarning($"[FBXImporter] Could not move texture {Path.GetFileName(srcPath)}: {err}");
|
||||
else
|
||||
Debug.Log($"[FBXImporter] Texture moved → {dstPath}");
|
||||
}
|
||||
|
||||
break; // Only process first valid source
|
||||
}
|
||||
|
||||
AssetDatabase.Refresh();
|
||||
}
|
||||
|
||||
static void DrawRow(string label, string value)
|
||||
{
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
EditorGUILayout.LabelField(label, GUILayout.Width(60));
|
||||
EditorGUILayout.LabelField(value, EditorStyles.miniLabel);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0f982faee0b3f5142a962f73ba411924
|
||||
@@ -0,0 +1,194 @@
|
||||
using UnityEngine;
|
||||
using UnityEditor;
|
||||
using System.IO;
|
||||
|
||||
namespace FBXImporter
|
||||
{
|
||||
/// <summary>
|
||||
/// FBX Importer Inspector Window — Window > FBX Importer > Inspector
|
||||
///
|
||||
/// Shows for the selected FBX:
|
||||
/// - Detected source (Blender/Maya)
|
||||
/// - Registry status
|
||||
/// - Folder structure status
|
||||
/// - Texture slot matching preview
|
||||
/// - Animation clips found
|
||||
/// - Animator Controller status
|
||||
/// - Actions: process, remove from registry, create folders, ping
|
||||
/// </summary>
|
||||
public class FBXImporterWindow : EditorWindow
|
||||
{
|
||||
private Object _selectedFBX;
|
||||
private Vector2 _scrollTextures;
|
||||
private Vector2 _scrollClips;
|
||||
|
||||
[MenuItem("Window/FBX Importer/Inspector")]
|
||||
public static void Open() => GetWindow<FBXImporterWindow>("FBX Importer").Show();
|
||||
|
||||
void OnGUI()
|
||||
{
|
||||
GUILayout.Label("FBX Importer", EditorStyles.boldLabel);
|
||||
EditorGUILayout.HelpBox(
|
||||
"Select a .fbx in the Project panel.\n" +
|
||||
"Drop → batch dialog appears automatically for new files.",
|
||||
MessageType.Info);
|
||||
GUILayout.Space(6);
|
||||
|
||||
_selectedFBX = EditorGUILayout.ObjectField("FBX Asset", _selectedFBX, typeof(Object), false);
|
||||
|
||||
// Auto-fill from Project panel selection
|
||||
if (_selectedFBX == null && Selection.activeObject != null)
|
||||
{
|
||||
string sel = AssetDatabase.GetAssetPath(Selection.activeObject);
|
||||
if (sel.EndsWith(".fbx", System.StringComparison.OrdinalIgnoreCase))
|
||||
_selectedFBX = Selection.activeObject;
|
||||
}
|
||||
|
||||
if (_selectedFBX == null) return;
|
||||
|
||||
string path = AssetDatabase.GetAssetPath(_selectedFBX);
|
||||
if (!path.EndsWith(".fbx", System.StringComparison.OrdinalIgnoreCase) &&
|
||||
!path.EndsWith(".obj", System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
EditorGUILayout.HelpBox("Not a .fbx file.", MessageType.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
DrawInspector(path);
|
||||
}
|
||||
|
||||
void DrawInspector(string assetPath)
|
||||
{
|
||||
EditorGUILayout.Space(6);
|
||||
|
||||
var source = FBXSourceDetector.Detect(assetPath);
|
||||
var folders = FolderSetup.Resolve(assetPath);
|
||||
bool registered = ProcessedFBXRegistry.IsRegistered(assetPath);
|
||||
string clean = TextureMatcher.CleanMaterialName(
|
||||
Path.GetFileNameWithoutExtension(assetPath), source);
|
||||
|
||||
// ── Detection ─────────────────────────────────────────────────────────
|
||||
GUILayout.Label("Detection", EditorStyles.boldLabel);
|
||||
DrawRow("Source", source.ToString(),
|
||||
source != FBXSource.Unknown ? Color.green : Color.yellow);
|
||||
DrawRow("Registered", registered ? "Yes" : "No — will appear on next drop",
|
||||
registered ? Color.green : Color.yellow);
|
||||
|
||||
// ── Folder structure ──────────────────────────────────────────────────
|
||||
EditorGUILayout.Space(6);
|
||||
GUILayout.Label("Folder Structure", EditorStyles.boldLabel);
|
||||
DrawFolderRow("Sources/", folders.Sources);
|
||||
DrawFolderRow("Textures/", folders.Textures);
|
||||
DrawFolderRow("Materials/", folders.Materials);
|
||||
DrawFolderRow("Prefabs/", folders.Prefabs);
|
||||
DrawFolderRow("Animations/", folders.Animations);
|
||||
DrawFolderRow("Audio/", folders.Audio);
|
||||
DrawFolderRow("VFX/", folders.VFX);
|
||||
|
||||
// ── Texture slot preview ──────────────────────────────────────────────
|
||||
EditorGUILayout.Space(6);
|
||||
GUILayout.Label("Texture Slots", EditorStyles.boldLabel);
|
||||
_scrollTextures = EditorGUILayout.BeginScrollView(_scrollTextures, GUILayout.Height(130));
|
||||
foreach (var kvp in TextureMatcher.SlotKeywords)
|
||||
{
|
||||
var tex = TextureMatcher.FindForSlot(folders.Textures, clean, kvp.Key);
|
||||
bool found = tex != null;
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
var style = new GUIStyle(EditorStyles.label)
|
||||
{ normal = { textColor = found ? new Color(0.4f, 1f, 0.4f) : new Color(0.5f, 0.5f, 0.5f) } };
|
||||
GUILayout.Label(found ? "✓" : "○", style, GUILayout.Width(16));
|
||||
GUILayout.Label(kvp.Key, GUILayout.Width(160));
|
||||
GUILayout.Label(found ? tex.name : "–", EditorStyles.miniLabel);
|
||||
}
|
||||
}
|
||||
EditorGUILayout.EndScrollView();
|
||||
|
||||
// ── Animation clips ───────────────────────────────────────────────────
|
||||
EditorGUILayout.Space(6);
|
||||
GUILayout.Label("Animation Clips", EditorStyles.boldLabel);
|
||||
|
||||
var allAssets = AssetDatabase.LoadAllAssetsAtPath(assetPath);
|
||||
int clipCount = 0;
|
||||
_scrollClips = EditorGUILayout.BeginScrollView(_scrollClips, GUILayout.Height(60));
|
||||
foreach (var asset in allAssets)
|
||||
{
|
||||
if (!(asset is AnimationClip clip)) continue;
|
||||
if (clip.name.StartsWith("__preview__")) continue;
|
||||
clipCount++;
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
GUILayout.Label("♦", GUILayout.Width(16));
|
||||
GUILayout.Label(clip.name, EditorStyles.miniLabel);
|
||||
GUILayout.Label($"{clip.length:0.00}s", EditorStyles.miniLabel, GUILayout.Width(50));
|
||||
}
|
||||
}
|
||||
if (clipCount == 0)
|
||||
GUILayout.Label("No clips found in this FBX", EditorStyles.miniLabel);
|
||||
EditorGUILayout.EndScrollView();
|
||||
|
||||
// Animator controller status
|
||||
if (!string.IsNullOrEmpty(folders.Animations))
|
||||
{
|
||||
string controllerPath = $"{folders.Animations}/{Path.GetFileNameWithoutExtension(assetPath)}.controller";
|
||||
bool hasController = AssetDatabase.LoadAssetAtPath<UnityEditor.Animations.AnimatorController>(controllerPath) != null;
|
||||
DrawRow("Controller", hasController ? "Created ✓" : "Not created yet",
|
||||
hasController ? Color.green : Color.gray);
|
||||
}
|
||||
|
||||
// ── Actions ───────────────────────────────────────────────────────────
|
||||
EditorGUILayout.Space(8);
|
||||
GUILayout.Label("Actions", EditorStyles.boldLabel);
|
||||
|
||||
if (GUILayout.Button("▶ Process this FBX (full pipeline)", GUILayout.Height(28)))
|
||||
FBXImportDialog.Show(assetPath, source);
|
||||
|
||||
EditorGUILayout.Space(2);
|
||||
|
||||
if (GUILayout.Button("🔄 Reconnect textures only"))
|
||||
{
|
||||
var fbxAsset = AssetDatabase.LoadAssetAtPath<GameObject>(assetPath);
|
||||
if (fbxAsset != null)
|
||||
{
|
||||
// Re-run texture connection without reimport
|
||||
FBXProcessor.Process(assetPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (GUILayout.Button("🗑 Remove from registry"))
|
||||
ProcessedFBXRegistry.Unregister(assetPath);
|
||||
|
||||
if (GUILayout.Button("📁 Create missing folders"))
|
||||
FolderSetup.Create(Path.GetDirectoryName(folders.Root), Path.GetFileName(folders.Root), false);
|
||||
|
||||
if (GUILayout.Button("🔍 Ping in Project panel"))
|
||||
EditorGUIUtility.PingObject(_selectedFBX);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
static void DrawRow(string label, string value, Color valueColor)
|
||||
{
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
EditorGUILayout.LabelField(label, GUILayout.Width(90));
|
||||
var style = new GUIStyle(EditorStyles.label) { normal = { textColor = valueColor } };
|
||||
EditorGUILayout.LabelField(value, style);
|
||||
}
|
||||
}
|
||||
|
||||
static void DrawFolderRow(string label, string folderPath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(folderPath)) return;
|
||||
bool exists = AssetDatabase.IsValidFolder(folderPath);
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
var style = new GUIStyle(EditorStyles.label)
|
||||
{ normal = { textColor = exists ? new Color(0.4f, 1f, 0.4f) : new Color(0.6f, 0.6f, 0.6f) } };
|
||||
GUILayout.Label(exists ? "✓" : "○", style, GUILayout.Width(16));
|
||||
GUILayout.Label(label, GUILayout.Width(90));
|
||||
GUILayout.Label(folderPath, EditorStyles.miniLabel);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 896d2ee0a3a30ef42baf3b73fc486171
|
||||
@@ -0,0 +1,31 @@
|
||||
#if UNITY_EDITOR
|
||||
|
||||
using UnityEngine;
|
||||
using UnityEditor;
|
||||
|
||||
[InitializeOnLoad]
|
||||
public static class HierarchySectionHeader
|
||||
{
|
||||
static HierarchySectionHeader()
|
||||
{
|
||||
EditorApplication.hierarchyWindowItemOnGUI += HierarchyWindowItemOnGUI;
|
||||
}
|
||||
|
||||
static void HierarchyWindowItemOnGUI(int instanceID, Rect selectionRect)
|
||||
{
|
||||
var gameObject = EditorUtility.InstanceIDToObject(instanceID) as GameObject;
|
||||
|
||||
if (gameObject != null && gameObject.name.StartsWith("//", System.StringComparison.Ordinal))
|
||||
{
|
||||
EditorGUI.DrawRect(selectionRect, Color.grey);
|
||||
|
||||
EditorGUI.LabelField(selectionRect, gameObject.name.Replace("/", "").ToUpperInvariant(), new GUIStyle(EditorStyles.boldLabel)
|
||||
{
|
||||
fontSize = 12,
|
||||
alignment = TextAnchor.MiddleCenter,
|
||||
normal = { textColor = Color.black }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a8e4f480645562e48bfd2214400c5d2d
|
||||
@@ -0,0 +1,127 @@
|
||||
using UnityEngine;
|
||||
using UnityEditor;
|
||||
using OctoCore.Localization;
|
||||
using System.IO;
|
||||
|
||||
namespace Unheimlich.Editor
|
||||
{
|
||||
[CustomPropertyDrawer(typeof(OctoLocalizedTextData))]
|
||||
public class LocalizedTextFieldDrawer : PropertyDrawer
|
||||
{
|
||||
private const float LINE_HEIGHT = 20f;
|
||||
private const float SPACING = 2f;
|
||||
private const float LABEL_WIDTH = 80f;
|
||||
|
||||
public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
|
||||
{
|
||||
// Height for label + EN field + FR field
|
||||
return LINE_HEIGHT * 3 + SPACING * 2;
|
||||
}
|
||||
|
||||
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
|
||||
{
|
||||
EditorGUI.BeginProperty(position, label, property);
|
||||
|
||||
OctoLocalizedTextData textData = property.objectReferenceValue as OctoLocalizedTextData;
|
||||
|
||||
// If null, create new OctoLocalizedTextData
|
||||
if (textData == null)
|
||||
{
|
||||
textData = CreateLocalizedTextData(property);
|
||||
property.objectReferenceValue = textData;
|
||||
property.serializedObject.ApplyModifiedProperties();
|
||||
}
|
||||
|
||||
// Draw label
|
||||
Rect labelRect = new Rect(position.x, position.y, position.width, LINE_HEIGHT);
|
||||
EditorGUI.LabelField(labelRect, label, EditorStyles.boldLabel);
|
||||
|
||||
// Draw EN field
|
||||
Rect enRect = new Rect(position.x, position.y + LINE_HEIGHT + SPACING, position.width, LINE_HEIGHT);
|
||||
DrawLanguageField(enRect, textData, OctoLanguages.English, "EN");
|
||||
|
||||
// Draw FR field
|
||||
Rect frRect = new Rect(position.x, position.y + (LINE_HEIGHT + SPACING) * 2, position.width, LINE_HEIGHT);
|
||||
DrawLanguageField(frRect, textData, OctoLanguages.French, "FR");
|
||||
|
||||
EditorGUI.EndProperty();
|
||||
}
|
||||
|
||||
private void DrawLanguageField(Rect position, OctoLocalizedTextData textData, OctoLanguages language, string labelText)
|
||||
{
|
||||
if (textData == null) return;
|
||||
|
||||
// Ensure dictionary is initialized
|
||||
if (textData.Languages.Dictionary == null || textData.Languages.Dictionary.Count == 0)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
textData.Init();
|
||||
#endif
|
||||
}
|
||||
|
||||
// Get language key
|
||||
string langKey = language.ToString();
|
||||
|
||||
// Get current value
|
||||
string currentValue = "";
|
||||
if (textData.Languages.Dictionary.TryGetValue(langKey, out string value))
|
||||
{
|
||||
currentValue = value;
|
||||
}
|
||||
|
||||
// Draw label
|
||||
Rect labelRect = new Rect(position.x, position.y, LABEL_WIDTH, position.height);
|
||||
EditorGUI.LabelField(labelRect, labelText + ":");
|
||||
|
||||
// Draw text field
|
||||
Rect fieldRect = new Rect(position.x + LABEL_WIDTH, position.y, position.width - LABEL_WIDTH, position.height);
|
||||
|
||||
EditorGUI.BeginChangeCheck();
|
||||
string newValue = EditorGUI.TextField(fieldRect, currentValue);
|
||||
|
||||
if (EditorGUI.EndChangeCheck())
|
||||
{
|
||||
// Update via indexer (Dictionary is read-only property but supports indexer)
|
||||
textData.Languages.Dictionary[langKey] = newValue;
|
||||
EditorUtility.SetDirty(textData);
|
||||
AssetDatabase.SaveAssets();
|
||||
}
|
||||
}
|
||||
|
||||
private OctoLocalizedTextData CreateLocalizedTextData(SerializedProperty property)
|
||||
{
|
||||
// Get the object being edited
|
||||
Object targetObject = property.serializedObject.targetObject;
|
||||
string assetPath = AssetDatabase.GetAssetPath(targetObject);
|
||||
|
||||
if (string.IsNullOrEmpty(assetPath))
|
||||
return null;
|
||||
|
||||
string directory = Path.GetDirectoryName(assetPath);
|
||||
string fileName = Path.GetFileNameWithoutExtension(assetPath);
|
||||
string propertyPath = property.propertyPath.Replace(".", "_");
|
||||
|
||||
// Create unique name
|
||||
string textDataName = $"text_{fileName}_{propertyPath}";
|
||||
string textDataPath = $"{directory}/{textDataName}.asset";
|
||||
|
||||
// Check if already exists
|
||||
OctoLocalizedTextData existingData = AssetDatabase.LoadAssetAtPath<OctoLocalizedTextData>(textDataPath);
|
||||
if (existingData != null)
|
||||
return existingData;
|
||||
|
||||
// Create new OctoLocalizedTextData
|
||||
OctoLocalizedTextData newTextData = ScriptableObject.CreateInstance<OctoLocalizedTextData>();
|
||||
|
||||
// Initialize via Init() method
|
||||
newTextData.Init();
|
||||
|
||||
// Save asset
|
||||
AssetDatabase.CreateAsset(newTextData, textDataPath);
|
||||
AssetDatabase.SaveAssets();
|
||||
AssetDatabase.Refresh();
|
||||
|
||||
return newTextData;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6566d44f45287f943b77aa92aaefeeb0
|
||||
@@ -0,0 +1,642 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using UnityEditor;
|
||||
using UnityEditor.SceneManagement;
|
||||
using UnityEngine;
|
||||
using UnityEngine.SceneManagement;
|
||||
|
||||
[InitializeOnLoad]
|
||||
public static class SceneEditPresenceTool
|
||||
{
|
||||
private const string ServerUrl = "http://unityapi.coiriergraphics.fr/scene_presence/";
|
||||
private const double HeartbeatInterval = 180;
|
||||
|
||||
private static string _userId;
|
||||
private static string _projectName;
|
||||
private static string _scenePath;
|
||||
private static double _nextHeartbeat;
|
||||
private static bool _offline;
|
||||
private static string _currentEditor = "none";
|
||||
private static bool _requestInFlight;
|
||||
|
||||
private const float OverlayHeight = 30f;
|
||||
private const float OverlayMarginTop = 3f;
|
||||
private const float OverlayOffsetX = 48f;
|
||||
private const float OverlayBaseWidth = 420f;
|
||||
private const float OverlayWidthScale = .70f;
|
||||
private const float ButtonWidth = 78f;
|
||||
|
||||
private static string ConfigPath =>
|
||||
Path.Combine(
|
||||
Directory.GetParent(Application.dataPath).FullName,
|
||||
"UserSettings",
|
||||
"ScenePresenceUser.json"
|
||||
);
|
||||
|
||||
static SceneEditPresenceTool()
|
||||
{
|
||||
_projectName = Directory.GetParent(Application.dataPath).Name;
|
||||
_userId = LoadOrAskUserId();
|
||||
|
||||
EditorApplication.delayCall += OnStartup;
|
||||
EditorApplication.update += OnUpdate;
|
||||
EditorSceneManager.activeSceneChangedInEditMode += OnSceneChanged;
|
||||
SceneView.duringSceneGui += OnSceneGui;
|
||||
}
|
||||
|
||||
private static async void OnStartup()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_userId))
|
||||
return;
|
||||
|
||||
var scene = SceneManager.GetActiveScene();
|
||||
if (!string.IsNullOrEmpty(scene.path))
|
||||
{
|
||||
_scenePath = scene.path;
|
||||
await OpenScene();
|
||||
}
|
||||
}
|
||||
|
||||
private static async void OnSceneChanged(Scene oldScene, Scene newScene)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_userId))
|
||||
return;
|
||||
|
||||
if (!string.IsNullOrEmpty(newScene.path))
|
||||
{
|
||||
_scenePath = newScene.path;
|
||||
await OpenScene();
|
||||
}
|
||||
}
|
||||
|
||||
private static async void OnUpdate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_userId))
|
||||
return;
|
||||
|
||||
if (string.IsNullOrEmpty(_scenePath))
|
||||
return;
|
||||
|
||||
if (_requestInFlight)
|
||||
return;
|
||||
|
||||
if (EditorApplication.timeSinceStartup < _nextHeartbeat)
|
||||
return;
|
||||
|
||||
_nextHeartbeat = EditorApplication.timeSinceStartup + HeartbeatInterval;
|
||||
await Heartbeat();
|
||||
RepaintSceneView();
|
||||
}
|
||||
|
||||
private static void OnSceneGui(SceneView sceneView)
|
||||
{
|
||||
Handles.BeginGUI();
|
||||
|
||||
float width = Mathf.Min(sceneView.position.width - (OverlayOffsetX + 16f), OverlayBaseWidth) * OverlayWidthScale;
|
||||
|
||||
Rect rect = new Rect(
|
||||
OverlayOffsetX,
|
||||
OverlayMarginTop,
|
||||
width,
|
||||
OverlayHeight
|
||||
);
|
||||
|
||||
DrawTopBar(rect);
|
||||
|
||||
Handles.EndGUI();
|
||||
}
|
||||
|
||||
private static void DrawTopBar(Rect rect)
|
||||
{
|
||||
UiState state = GetUiState();
|
||||
string buttonLabel = state == UiState.OwnedByMe ? "Release" : "Edit";
|
||||
bool buttonEnabled = state != UiState.Offline && !_requestInFlight;
|
||||
|
||||
Rect buttonRect = new Rect(
|
||||
rect.xMax - ButtonWidth - 6f,
|
||||
rect.y + 4f,
|
||||
ButtonWidth,
|
||||
rect.height - 8f
|
||||
);
|
||||
|
||||
Rect textRect = new Rect(
|
||||
rect.x + 10f,
|
||||
rect.y,
|
||||
rect.width - ButtonWidth - 22f,
|
||||
rect.height
|
||||
);
|
||||
|
||||
EditorGUI.DrawRect(rect, GetBackgroundColor(state));
|
||||
|
||||
GUIStyle textStyle = new GUIStyle(EditorStyles.label)
|
||||
{
|
||||
alignment = TextAnchor.MiddleLeft,
|
||||
fontStyle = FontStyle.Bold,
|
||||
fontSize = 11,
|
||||
normal = { textColor = Color.white },
|
||||
clipping = TextClipping.Clip
|
||||
};
|
||||
|
||||
GUI.Label(textRect, GetMessage(state), textStyle);
|
||||
|
||||
Color oldColor = GUI.backgroundColor;
|
||||
GUI.backgroundColor = GetButtonColor(state);
|
||||
|
||||
EditorGUI.BeginDisabledGroup(!buttonEnabled);
|
||||
if (GUI.Button(buttonRect, buttonLabel))
|
||||
{
|
||||
if (state == UiState.MissingUserId)
|
||||
{
|
||||
AskUserIdWindow.ShowWindow(SaveUserIdAndRefresh);
|
||||
}
|
||||
else if (state == UiState.OwnedByMe)
|
||||
{
|
||||
ReleaseEditMode();
|
||||
}
|
||||
else
|
||||
{
|
||||
TakeEditMode();
|
||||
}
|
||||
}
|
||||
EditorGUI.EndDisabledGroup();
|
||||
|
||||
GUI.backgroundColor = oldColor;
|
||||
}
|
||||
|
||||
private static UiState GetUiState()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_userId))
|
||||
return UiState.MissingUserId;
|
||||
|
||||
if (_offline)
|
||||
return UiState.Offline;
|
||||
|
||||
if (_currentEditor == _userId)
|
||||
return UiState.OwnedByMe;
|
||||
|
||||
if (!string.IsNullOrEmpty(_currentEditor) && _currentEditor != "none")
|
||||
return UiState.OwnedByOther;
|
||||
|
||||
return UiState.Free;
|
||||
}
|
||||
|
||||
private static string GetMessage(UiState state)
|
||||
{
|
||||
switch (state)
|
||||
{
|
||||
case UiState.MissingUserId:
|
||||
return "User ID required";
|
||||
case UiState.OwnedByMe:
|
||||
return "You are editing this scene";
|
||||
case UiState.OwnedByOther:
|
||||
return $"{_currentEditor} is editing this scene";
|
||||
case UiState.Offline:
|
||||
return "Offline";
|
||||
default:
|
||||
return "Scene available";
|
||||
}
|
||||
}
|
||||
|
||||
private static Color GetBackgroundColor(UiState state)
|
||||
{
|
||||
const float alpha = 0.70f;
|
||||
|
||||
switch (state)
|
||||
{
|
||||
case UiState.MissingUserId:
|
||||
case UiState.Offline:
|
||||
return new Color(1.00f, 0.10f, 0.10f, alpha);
|
||||
|
||||
case UiState.OwnedByMe:
|
||||
return new Color(0.22f, 0.55f, 0.28f, alpha);
|
||||
|
||||
case UiState.OwnedByOther:
|
||||
return new Color(0.82f, 0.48f, 0.10f, alpha);
|
||||
|
||||
default:
|
||||
return new Color(0.18f, 0.18f, 0.18f, alpha);
|
||||
}
|
||||
}
|
||||
|
||||
private static Color GetButtonColor(UiState state)
|
||||
{
|
||||
switch (state)
|
||||
{
|
||||
case UiState.Free:
|
||||
return new Color(0.38f, 0.66f, 1.00f, 1f);
|
||||
|
||||
case UiState.MissingUserId:
|
||||
case UiState.OwnedByOther:
|
||||
case UiState.OwnedByMe:
|
||||
case UiState.Offline:
|
||||
default:
|
||||
return new Color(0.22f, 0.22f, 0.22f, 1f);
|
||||
}
|
||||
}
|
||||
|
||||
private static async void TakeEditMode()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_userId))
|
||||
{
|
||||
AskUserIdWindow.ShowWindow(SaveUserIdAndRefresh);
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(_scenePath))
|
||||
return;
|
||||
|
||||
EditModeRequest payload = new EditModeRequest
|
||||
{
|
||||
projectName = _projectName,
|
||||
scenePath = _scenePath,
|
||||
userId = _userId,
|
||||
dateTime = DateTime.UtcNow.ToString("o"),
|
||||
force = false
|
||||
};
|
||||
|
||||
SetEditModeResponse result = await Request<SetEditModeResponse>("set_edit_mode.php", payload);
|
||||
if (result == null)
|
||||
{
|
||||
SetOffline();
|
||||
return;
|
||||
}
|
||||
|
||||
_offline = false;
|
||||
|
||||
if (!result.success)
|
||||
{
|
||||
Debug.LogError("[ScenePresence] set_edit_mode failed.");
|
||||
RepaintSceneView();
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.granted)
|
||||
{
|
||||
_currentEditor = _userId;
|
||||
RepaintSceneView();
|
||||
return;
|
||||
}
|
||||
|
||||
string ownerName = !string.IsNullOrWhiteSpace(result.occupiedBy)
|
||||
? result.occupiedBy
|
||||
: (!string.IsNullOrWhiteSpace(_currentEditor) && _currentEditor != "none" ? _currentEditor : "another user");
|
||||
|
||||
bool forceTake = EditorUtility.DisplayDialog(
|
||||
"Scene Already In Edit Mode",
|
||||
$"This scene is already locked by {ownerName}.\nDo you want to take control anyway?",
|
||||
"Take Control",
|
||||
"Cancel"
|
||||
);
|
||||
|
||||
if (!forceTake)
|
||||
return;
|
||||
|
||||
payload.force = true;
|
||||
|
||||
SetEditModeResponse forced = await Request<SetEditModeResponse>("set_edit_mode.php", payload);
|
||||
if (forced == null)
|
||||
{
|
||||
SetOffline();
|
||||
return;
|
||||
}
|
||||
|
||||
_offline = false;
|
||||
|
||||
if (forced.success && forced.granted)
|
||||
{
|
||||
_currentEditor = _userId;
|
||||
RepaintSceneView();
|
||||
}
|
||||
}
|
||||
|
||||
private static async void ReleaseEditMode()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_userId))
|
||||
return;
|
||||
|
||||
if (string.IsNullOrEmpty(_scenePath))
|
||||
return;
|
||||
|
||||
ReleaseResponse result = await Request<ReleaseResponse>("release_edit_mode.php", new BaseRequest
|
||||
{
|
||||
projectName = _projectName,
|
||||
scenePath = _scenePath,
|
||||
userId = _userId,
|
||||
dateTime = DateTime.UtcNow.ToString("o")
|
||||
});
|
||||
|
||||
if (result == null)
|
||||
{
|
||||
SetOffline();
|
||||
return;
|
||||
}
|
||||
|
||||
_offline = false;
|
||||
_currentEditor = "none";
|
||||
RepaintSceneView();
|
||||
await OpenScene();
|
||||
}
|
||||
|
||||
private static async Task OpenScene()
|
||||
{
|
||||
OpenSceneResponse result = await Request<OpenSceneResponse>("open_scene.php", new BaseRequest
|
||||
{
|
||||
projectName = _projectName,
|
||||
scenePath = _scenePath,
|
||||
userId = _userId,
|
||||
dateTime = DateTime.UtcNow.ToString("o")
|
||||
});
|
||||
|
||||
if (result == null)
|
||||
{
|
||||
SetOffline();
|
||||
return;
|
||||
}
|
||||
|
||||
_offline = false;
|
||||
|
||||
if (result.hasEditor)
|
||||
{
|
||||
_currentEditor = string.IsNullOrWhiteSpace(result.editorUserId) ? "none" : result.editorUserId;
|
||||
}
|
||||
else
|
||||
{
|
||||
_currentEditor = "none";
|
||||
}
|
||||
|
||||
RepaintSceneView();
|
||||
}
|
||||
|
||||
private static async Task Heartbeat()
|
||||
{
|
||||
HeartbeatResponse result = await Request<HeartbeatResponse>("heartbeat.php", new BaseRequest
|
||||
{
|
||||
projectName = _projectName,
|
||||
scenePath = _scenePath,
|
||||
userId = _userId,
|
||||
dateTime = DateTime.UtcNow.ToString("o")
|
||||
});
|
||||
|
||||
if (result == null)
|
||||
{
|
||||
SetOffline();
|
||||
return;
|
||||
}
|
||||
|
||||
_offline = false;
|
||||
|
||||
if (result.hasEditor)
|
||||
_currentEditor = string.IsNullOrWhiteSpace(result.editorUserId) ? "none" : result.editorUserId;
|
||||
else
|
||||
_currentEditor = "none";
|
||||
|
||||
RepaintSceneView();
|
||||
}
|
||||
|
||||
private static async Task<T> Request<T>(string endpoint, object payload) where T : class
|
||||
{
|
||||
_requestInFlight = true;
|
||||
|
||||
try
|
||||
{
|
||||
string json = JsonUtility.ToJson(payload);
|
||||
byte[] bytes = Encoding.UTF8.GetBytes(json);
|
||||
|
||||
HttpWebRequest request = (HttpWebRequest)WebRequest.Create(ServerUrl + endpoint);
|
||||
request.Method = "POST";
|
||||
request.ContentType = "application/json";
|
||||
request.Timeout = 5000;
|
||||
request.ReadWriteTimeout = 5000;
|
||||
request.ContentLength = bytes.Length;
|
||||
|
||||
using (var requestStream = await request.GetRequestStreamAsync())
|
||||
{
|
||||
await requestStream.WriteAsync(bytes, 0, bytes.Length);
|
||||
}
|
||||
|
||||
using var response = await request.GetResponseAsync();
|
||||
using var responseStream = response.GetResponseStream();
|
||||
using var reader = new StreamReader(responseStream);
|
||||
string responseJson = await reader.ReadToEndAsync();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(responseJson))
|
||||
return null;
|
||||
|
||||
if (!responseJson.TrimStart().StartsWith("{"))
|
||||
{
|
||||
Debug.LogError($"[ScenePresence] Non-JSON response from {endpoint}: {responseJson}");
|
||||
return null;
|
||||
}
|
||||
|
||||
//Debug.Log($"[ScenePresence] {endpoint} => {responseJson}");
|
||||
return JsonUtility.FromJson<T>(responseJson);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"[ScenePresence] Request failed ({endpoint}): {ex}");
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_requestInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void SetOffline()
|
||||
{
|
||||
_offline = true;
|
||||
RepaintSceneView();
|
||||
}
|
||||
|
||||
private static void RepaintSceneView()
|
||||
{
|
||||
if (SceneView.lastActiveSceneView != null)
|
||||
SceneView.lastActiveSceneView.Repaint();
|
||||
}
|
||||
|
||||
private static string LoadOrAskUserId()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(ConfigPath))
|
||||
{
|
||||
UserConfig saved = JsonUtility.FromJson<UserConfig>(File.ReadAllText(ConfigPath));
|
||||
if (saved != null && !string.IsNullOrWhiteSpace(saved.userId))
|
||||
return saved.userId.Trim();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"[ScenePresence] Failed to read config: {ex.Message}");
|
||||
}
|
||||
|
||||
EditorApplication.delayCall += () =>
|
||||
{
|
||||
AskUserIdWindow.ShowWindow(SaveUserIdAndRefresh);
|
||||
};
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void SaveUserIdAndRefresh(string userId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
return;
|
||||
|
||||
_userId = userId.Trim();
|
||||
SaveUserId(_userId);
|
||||
|
||||
EditorApplication.delayCall += async () =>
|
||||
{
|
||||
Scene scene = SceneManager.GetActiveScene();
|
||||
if (!string.IsNullOrEmpty(scene.path))
|
||||
{
|
||||
_scenePath = scene.path;
|
||||
await OpenScene();
|
||||
}
|
||||
|
||||
RepaintSceneView();
|
||||
};
|
||||
}
|
||||
|
||||
private static void SaveUserId(string userId)
|
||||
{
|
||||
try
|
||||
{
|
||||
string directory = Path.GetDirectoryName(ConfigPath);
|
||||
if (!Directory.Exists(directory))
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
UserConfig config = new UserConfig { userId = userId };
|
||||
File.WriteAllText(ConfigPath, JsonUtility.ToJson(config, true));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"[ScenePresence] Failed to save config: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private class UserConfig
|
||||
{
|
||||
public string userId;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private class BaseRequest
|
||||
{
|
||||
public string projectName;
|
||||
public string scenePath;
|
||||
public string userId;
|
||||
public string dateTime;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private class EditModeRequest : BaseRequest
|
||||
{
|
||||
public bool force;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private class OpenSceneResponse
|
||||
{
|
||||
public bool success;
|
||||
public bool hasEditor;
|
||||
public string editorUserId;
|
||||
public string editorDateTime;
|
||||
public string editorServerTakenAt;
|
||||
public bool editorOnline;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private class HeartbeatResponse
|
||||
{
|
||||
public bool success;
|
||||
public bool hasEditor;
|
||||
public string editorUserId;
|
||||
public bool editorOnline;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private class SetEditModeResponse
|
||||
{
|
||||
public bool success;
|
||||
public bool granted;
|
||||
public bool forced;
|
||||
public string occupiedBy;
|
||||
public string editorUserId;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private class ReleaseResponse
|
||||
{
|
||||
public bool success;
|
||||
public bool released;
|
||||
}
|
||||
|
||||
private enum UiState
|
||||
{
|
||||
MissingUserId,
|
||||
Free,
|
||||
OwnedByOther,
|
||||
OwnedByMe,
|
||||
Offline
|
||||
}
|
||||
}
|
||||
|
||||
public class AskUserIdWindow : EditorWindow
|
||||
{
|
||||
private static Action<string> _onSave;
|
||||
private static AskUserIdWindow _instance;
|
||||
|
||||
private string _userId = "";
|
||||
|
||||
public static void ShowWindow(Action<string> onSave)
|
||||
{
|
||||
if (_instance != null)
|
||||
{
|
||||
_instance.Focus();
|
||||
return;
|
||||
}
|
||||
|
||||
_onSave = onSave;
|
||||
|
||||
_instance = CreateInstance<AskUserIdWindow>();
|
||||
_instance.titleContent = new GUIContent("Scene Presence Setup");
|
||||
_instance.minSize = new Vector2(240, 60);
|
||||
_instance.maxSize = new Vector2(240, 60);
|
||||
_instance.ShowUtility();
|
||||
_instance.Focus();
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (_instance == this)
|
||||
_instance = null;
|
||||
}
|
||||
|
||||
private void OnGUI()
|
||||
{
|
||||
GUILayout.Space(10);
|
||||
EditorGUIUtility.labelWidth = 60;
|
||||
_userId = EditorGUILayout.TextField("User ID", _userId);
|
||||
GUILayout.FlexibleSpace();
|
||||
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
GUILayout.FlexibleSpace();
|
||||
|
||||
GUI.enabled = !string.IsNullOrWhiteSpace(_userId);
|
||||
if (GUILayout.Button("Save", GUILayout.Width(80)))
|
||||
{
|
||||
_onSave?.Invoke(_userId.Trim());
|
||||
Close();
|
||||
}
|
||||
GUI.enabled = true;
|
||||
}
|
||||
|
||||
GUILayout.Space(10);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d9c85ae0c5f70cc4da07f387065c0015
|
||||
Reference in New Issue
Block a user