#nullable enable using Cysharp.Threading.Tasks; using GLTFast; using System; using System.Collections.Generic; using System.Threading; using UnityEngine; using UnityEngine.UI; using UVC.UI.List.Tree; using UVC.Util; namespace SHI.modal { public class ModelDetailView : MonoBehaviour { public Action? OnItemSelected; [Header("View Output (UI)")] [SerializeField] private RawImage outputImage; [SerializeField] private Color backgroundColor = new Color(0.2f, 0.2f, 0.22f, 1f); [Tooltip("전용 카메라가 렌더링할 레이어. 모델 노드에 재귀적으로 적용됩니다.")] [SerializeField] private int modelLayer = 6; [SerializeField] private bool createDefaultLight = true; [Header("Mouse Controls")] [SerializeField] private float panSpeed = 1.0f; // 픽셀→월드 스케일 보정 계수 [SerializeField] private float rotateDegPerPixel = 0.2f; // RMB 회전(도/픽셀) [SerializeField] private float zoomSpeed = 5f; // 휠 전/후 모델 이동 속도 [Header("Output Image Setup")] [SerializeField] private bool autoCreateOutputImage = true; [SerializeField] private bool autoApplyRectMask = true; [Header("Wireframe")] [SerializeField] private bool wireframeMode = false; // 켜면 라인만 렌더 private UnityEngine.Camera _viewCamera; private RenderTexture _rt; private Material _wireframeMat; // Resources에서 로드한 메터리얼 private bool _wireframeApplied; // Orbit controls state (카메라 위치 계산용) private Vector3 _orbitTarget; private float _orbitDistance = 5f; private float _yaw = 0f; private float _pitch = 20f; // Drag state private bool _mmbDragging; private bool _rmbDragging; private Vector3 _mmbLastPos; private Vector3 _rmbStartPos; private float _yawStart; private float _pitchStart; private Quaternion _modelStartRot; private Vector3 _modelStartPos; // RMB 드래그 시작 시 모델 위치 private Vector3 _rmbPivot; // RMB 회전의 피벗(모델 중심) private readonly Dictionary _idToObject = new Dictionary(); private readonly Dictionary _matCache = new Dictionary(); private GameObject? _root; private Guid? _focusedId; public async UniTask> LoadModelAsync(string path, CancellationToken ct) { Debug.Log($"ModelDetailView.LoadModelAsync: {path}"); Dispose(); await UniTask.DelayFrame(1); // Dispose 후1프레임 대기하여 리소스 해제 안정화 EnsureCameraAndTargetTexture(); var items = new List(); var gltf = new GltfImport(); var success = await gltf.Load(path, new ImportSettings(), ct); if (!success) { Debug.LogError($"glTFast Load failed: {path}"); return items; } _root = new GameObject("ModelDetailViewRoot"); _root.layer = modelLayer; var sceneOk = await gltf.InstantiateMainSceneAsync(_root.transform); if (!sceneOk) { Debug.LogError("InstantiateMainSceneAsync failed"); return items; } SetLayerRecursive(_root, modelLayer); // Build hierarchical item data preserving parent-child relationships if (_root != null) { for (int i = 0; i < _root.transform.childCount; i++) { var child = _root.transform.GetChild(i); var topItem = BuildItemRecursive(child, _root.transform); if (topItem != null) { items.Add(topItem); } } } var bounds = CalculateBounds(_root); FrameToBounds(bounds); // 요청: Resources/SHI/Shader/BasicWireframe 메터리얼 적용 TryLoadWireframeMaterial(); if (wireframeMode && _wireframeMat != null) { ApplyWireframeMaterialToRoot(); } return items; } private ModelDetailListItemData BuildItemRecursive(Transform node, Transform root) { var data = new ModelDetailListItemData { Name = node.name, ExternalKey = BuildFullPath(node, root) }; // map id -> object for selection/highlight and cache materials at this subtree root _idToObject[data.Id] = node.gameObject; CacheOriginalMaterials(node.gameObject); // children for (int i = 0; i < node.childCount; i++) { var child = node.GetChild(i); var childData = BuildItemRecursive(child, root); data.AddChild(childData); } return data; } private void TryLoadWireframeMaterial() { if (_wireframeMat == null) { _wireframeMat = Resources.Load("SHI/Shader/BasicWireframe"); if (_wireframeMat == null) { Debug.LogWarning("BasicWireframe material not found at Resources/SHI/Shader/BasicWireframe. Wireframe will be disabled."); } } } private void ApplyWireframeMaterialToRoot() { if (_root == null || _wireframeMat == null) return; var rends = _root.GetComponentsInChildren(true); foreach (var r in rends) { if (r == null) continue; var count = Mathf.Max(1, r.sharedMaterials.Length); var arr = new Material[count]; for (int i = 0; i < count; i++) arr[i] = _wireframeMat; r.sharedMaterials = arr; } _wireframeApplied = true; } private void RestoreAllOriginalMaterials() { foreach (var kv in _matCache) { var (rends, originals) = kv.Value; for (int i = 0; i < rends.Length; i++) { if (rends[i] == null) continue; rends[i].sharedMaterials = originals[i]; } } _wireframeApplied = false; } private void EnsureCameraAndTargetTexture() { EnsureOutputImageUI(); if (_viewCamera == null) { // Create a world-space rig not parented to UI to avoid layout side effects var rig = new GameObject("ModelDetailViewRig"); rig.layer = modelLayer; rig.transform.SetParent(null, false); var camGo = new GameObject("ModelDetailViewCamera"); camGo.layer = modelLayer; camGo.transform.SetParent(rig.transform, false); _viewCamera = camGo.AddComponent(); _viewCamera.clearFlags = CameraClearFlags.SolidColor; _viewCamera.backgroundColor = backgroundColor; _viewCamera.nearClipPlane = 0.01f; _viewCamera.farClipPlane = 5000f; _viewCamera.cullingMask = (modelLayer >= 0 && modelLayer <= 31) ? (1 << modelLayer) : ~0; // Prevent drawing to main display when no RT assigned _viewCamera.targetDisplay = 1; // secondary display _viewCamera.enabled = false; // enable only when RT is bound } // optional default light (independent of UI) if (createDefaultLight && _viewCamera.transform.parent != null && _viewCamera.transform.parent.Find("ModelDetailViewLight") == null) { var lightGo = new GameObject("ModelDetailViewLight"); lightGo.layer = modelLayer; lightGo.transform.SetParent(_viewCamera.transform.parent, false); lightGo.transform.localPosition = Vector3.zero; lightGo.transform.localRotation = Quaternion.Euler(50f, -30f, 0f); var light = lightGo.AddComponent(); light.type = LightType.Directional; light.intensity = 1.1f; light.shadows = LightShadows.Soft; } EnsureRenderTargetSize(); } private void EnsureOutputImageUI() { var selfRt = GetComponent(); if (autoApplyRectMask && selfRt != null && selfRt.GetComponent() == null) { selfRt.gameObject.AddComponent(); } if (outputImage == null && autoCreateOutputImage) { var go = new GameObject("OutputImage", typeof(RectTransform), typeof(RawImage)); go.transform.SetParent(transform, false); outputImage = go.GetComponent(); } if (outputImage != null) { var rt = outputImage.rectTransform; rt.anchorMin = Vector2.zero; rt.anchorMax = Vector2.one; rt.offsetMin = Vector2.zero; rt.offsetMax = Vector2.zero; } } private void EnsureRenderTargetSize() { if (_viewCamera == null) { return; } if (outputImage == null) { // No output target; keep camera disabled to avoid drawing to display _viewCamera.enabled = false; return; } var rtf = outputImage.rectTransform; int w = Mathf.Max(64, Mathf.RoundToInt(rtf.rect.width)); int h = Mathf.Max(64, Mathf.RoundToInt(rtf.rect.height)); if (_rt == null || _rt.width != w || _rt.height != h) { // release old if (_rt != null) { if (_viewCamera.targetTexture == _rt) _viewCamera.targetTexture = null; _rt.Release(); Destroy(_rt); } // create new _rt = new RenderTexture(w, h, 24, RenderTextureFormat.ARGB32) { name = "ModelDetailViewRT", antiAliasing = 2 }; _viewCamera.targetTexture = _rt; outputImage.texture = _rt; // enable camera only when RT is assigned _viewCamera.enabled = true; } else { // ensure binding and enable if (_viewCamera.targetTexture != _rt) { _viewCamera.targetTexture = _rt; } if (!_viewCamera.enabled) _viewCamera.enabled = true; } } protected void OnRectTransformDimensionsChange() { // Keep RT in sync with UI size changes (e.g., parent resized) EnsureRenderTargetSize(); } // Allow external toggle public void SetWireframe(bool on) { wireframeMode = on; TryLoadWireframeMaterial(); if (_wireframeMat != null) { if (on) ApplyWireframeMaterialToRoot(); else RestoreAllOriginalMaterials(); } else { Debug.LogWarning("Wireframe material not found. Wireframe toggle ignored."); } } private static void SetLayerRecursive(GameObject go, int layer) { if (layer < 0 || layer > 31) return; go.layer = layer; foreach (Transform c in go.transform) SetLayerRecursive(c.gameObject, layer); } private static Bounds CalculateBounds(GameObject root) { var rends = root.GetComponentsInChildren(true); var has = false; var bounds = new Bounds(root.transform.position, Vector3.zero); foreach (var r in rends) { if (r == null) continue; if (!has) { bounds = r.bounds; has = true; } else bounds.Encapsulate(r.bounds); } if (!has) bounds = new Bounds(root.transform.position, Vector3.one); return bounds; } private void FrameToBounds(Bounds b) { if (_viewCamera == null) return; var center = b.center; var extents = b.extents; float radius = Mathf.Max(extents.x, Mathf.Max(extents.y, Mathf.Max(extents.z, 0.001f))); float fovRad = _viewCamera.fieldOfView * Mathf.Deg2Rad; float dist = radius / Mathf.Sin(fovRad * 0.5f); dist = Mathf.Clamp(dist, 1f, 1e4f); _viewCamera.transform.position = center + new Vector3(1, 0.5f, 1).normalized * dist; _viewCamera.transform.LookAt(center); //_viewCamera.nearClipPlane = Mathf.Max(0.01f, dist - radius * 2f); //_viewCamera.farClipPlane = dist + radius * 4f; _orbitTarget = center; _orbitDistance = Vector3.Distance(_viewCamera.transform.position, _orbitTarget); var dir = (_viewCamera.transform.position - _orbitTarget).normalized; _yaw = Mathf.Atan2(dir.x, dir.z) * Mathf.Rad2Deg; _pitch = Mathf.Asin(dir.y) * Mathf.Rad2Deg; } private string BuildFullPath(Transform node, Transform root) { var stack = new Stack(); var current = node; while (current != null && current != root) { stack.Push(current.name); current = current.parent; } return "/" + string.Join('/', stack); } private void CacheOriginalMaterials(GameObject go) { var rends = go.GetComponentsInChildren(true); if (rends.Length == 0) return; var originals = new UnityEngine.Material[rends.Length][]; for (int i = 0; i < rends.Length; i++) originals[i] = rends[i].sharedMaterials; _matCache[go] = (rends, originals); } public void FocusItem(TreeListItemData data) { if (data == null) return; FocusItemById(data.Id); } public void FocusItemById(Guid id) { _focusedId = id; if (_idToObject.TryGetValue(id, out var go)) { Highlight(go, true); Debug.Log($"ModelDetailView.FocusItemById: {go.name}"); _orbitTarget = go.transform.position; } } public void UnfocusItem() { if (_focusedId.HasValue && _idToObject.TryGetValue(_focusedId.Value, out var go)) { Highlight(go, false); } _focusedId = null; } public void SetVisibility(Guid id, bool on) { Debug.Log($"ModelDetailView.SetVisibility: id={id} on={on}"); if (_idToObject.TryGetValue(id, out var go)) { go.SetActive(on); } } // Common color property names across pipelines/shaders private static readonly string[] ColorProps = new[] { "_WireframeColor", "_WireColor", // wireframe shader variants "_BaseColor", "_Color", "_LineColor", "_TintColor" }; private bool TrySetColor(Material mat, Color c) { for (int i = 0; i < ColorProps.Length; i++) { var prop = ColorProps[i]; if (mat != null && mat.HasProperty(prop)) { mat.SetColor(prop, c); return true; } } // fallback to material.color if supported try { mat.color = c; return true; } catch { /* ignore */ } return false; } private bool TryGetColor(Material mat, out Color c) { for (int i = 0; i < ColorProps.Length; i++) { var prop = ColorProps[i]; if (mat != null && mat.HasProperty(prop)) { c = mat.GetColor(prop); return true; } } try { c = mat.color; return true; } catch { /* ignore */ } c = ColorUtil.FromHex("#888888"); return false; } private bool TryGetDefaultWireColor(out Color c) { if (_wireframeMat != null && TryGetColor(_wireframeMat, out c)) return true; c = ColorUtil.FromHex("#888888"); return false; } private void Highlight(GameObject go, bool on) { if (!_matCache.TryGetValue(go, out var tuple)) return; var (rends, originals) = tuple; for (int i = 0; i < rends.Length; i++) { var r = rends[i]; if (r == null) continue; // Work on instantiated materials to avoid touching shared assets var mats = r.materials; for (int m = 0; m < mats.Length; m++) { if (on) { // set to highlight color regardless of shader TrySetColor(mats[m], ColorUtil.FromHex("#888814")); } else { // When in wireframe mode (material replaced) restore to default wireframe color. bool matIsWire = mats[m] != null && (mats[m].HasProperty("_WireframeColor") || mats[m].HasProperty("_WireColor")); if (_wireframeApplied || matIsWire) { Color baseWire; if (TryGetDefaultWireColor(out baseWire)) TrySetColor(mats[m], baseWire); else TrySetColor(mats[m], ColorUtil.FromHex("#888888")); } else { // restore to original color if known; otherwise white Color orig; if (i < originals.Length && m < originals[i].Length && originals[i][m] != null && TryGetColor(originals[i][m], out orig)) TrySetColor(mats[m], orig); else TrySetColor(mats[m], ColorUtil.FromHex("#888888")); } } } r.materials = mats; } } public void RaiseSelected(TreeListItemData data) { OnItemSelected?.Invoke(data); } private void Update() { if (_viewCamera == null) return; // 영역 히트테스트 if (outputImage != null) { var cam = outputImage.canvas != null && outputImage.canvas.renderMode != RenderMode.ScreenSpaceOverlay ? outputImage.canvas.worldCamera : null; if (!RectTransformUtility.RectangleContainsScreenPoint(outputImage.rectTransform, Input.mousePosition, cam)) { return; } } else { var selfRt = GetComponent(); if (selfRt != null) { var parentCanvas = selfRt.GetComponentInParent(); UnityEngine.Camera uiCam = null; if (parentCanvas != null && parentCanvas.renderMode != RenderMode.ScreenSpaceOverlay) uiCam = parentCanvas.worldCamera; if (!RectTransformUtility.RectangleContainsScreenPoint(selfRt, Input.mousePosition, uiCam)) return; } } // 드래그 상태 전이 처리 (점프 방지) if (Input.GetMouseButtonDown(2)) { _mmbDragging = true; _mmbLastPos = Input.mousePosition; } if (Input.GetMouseButtonUp(2)) { _mmbDragging = false; } if (Input.GetMouseButtonDown(1)) { _rmbDragging = true; _rmbStartPos = Input.mousePosition; _yawStart = _yaw; _pitchStart = _pitch; if (_root != null) { _modelStartRot = _root.transform.rotation; _modelStartPos = _root.transform.position; } _rmbPivot = _orbitTarget; } if (Input.GetMouseButtonUp(1)) { _rmbDragging = false; } // 가운데 버튼: 모델 이동 (좌/우/상/하) - 카메라 기준, 픽셀 델타 기반 if (_mmbDragging && _root != null) { Vector3 cur = Input.mousePosition; Vector2 dp = (Vector2)(cur - _mmbLastPos); _mmbLastPos = cur; // 뷰 사각 픽셀 크기 구하기 float wPix, hPix; if (outputImage != null) { var r = outputImage.rectTransform.rect; wPix = Mathf.Max(1f, r.width); hPix = Mathf.Max(1f, r.height); } else { var rt = GetComponent(); var r = (rt != null) ? rt.rect : new Rect(0, 0, Screen.width, Screen.height); wPix = Mathf.Max(1f, r.width); hPix = Mathf.Max(1f, r.height); } // 카메라에서의 월드/픽셀 스케일(거리 기반) float halfV = Mathf.Tan(_viewCamera.fieldOfView * Mathf.Deg2Rad * 0.5f) * _orbitDistance; float halfH = halfV * _viewCamera.aspect; float worldPerPixelX = (halfH * 2f) / wPix * panSpeed; float worldPerPixelY = (halfV * 2f) / hPix * panSpeed; Vector3 deltaModel = _viewCamera.transform.right * (dp.x * worldPerPixelX) + _viewCamera.transform.up * (dp.y * worldPerPixelY); _root.transform.position += deltaModel; _orbitTarget += deltaModel; } // 마우스 휠: 모델 전/후 이동(카메라 기준) float scroll = Input.GetAxis("Mouse ScrollWheel"); if (Mathf.Abs(scroll) > 1e-5f && _root != null) { var forward = _viewCamera.transform.forward; Vector3 deltaZ = forward * (-scroll * zoomSpeed); _root.transform.position += deltaZ; _orbitTarget += deltaZ; } // 오른쪽 버튼: 모델 회전 (카메라 기준 yaw/pitch) - 모델 중심(_orbitTarget) 기준으로 회전 if (_rmbDragging && _root != null) { Vector3 cur = Input.mousePosition; Vector2 dpAbs = (Vector2)(cur - _rmbStartPos); // 반전: 좌우/상하 모두 반대 방향으로 회전 float yaw = -dpAbs.x * rotateDegPerPixel; // 좌우 반전 float pitch = dpAbs.y * rotateDegPerPixel; // 위아래 반전 // 카메라 기준 축으로 회전 행렬 구성 Quaternion yawQ = Quaternion.AngleAxis(yaw, _viewCamera.transform.up); Quaternion pitchQ = Quaternion.AngleAxis(pitch, _viewCamera.transform.right); Quaternion r = yawQ * pitchQ; // 피벗(_rmbPivot, 보통 모델 중심) 기준으로 위치+회전 동시 적용 Vector3 startVec = _modelStartPos - _rmbPivot; _root.transform.position = _rmbPivot + r * startVec; _root.transform.rotation = r * _modelStartRot; } } private void UpdateOrbitPose() { if (_viewCamera == null) return; _orbitDistance = Mathf.Max(0.01f, _orbitDistance); var rot = Quaternion.Euler(_pitch, _yaw, 0f); var offset = rot * new Vector3(0, 0, -_orbitDistance); _viewCamera.transform.position = _orbitTarget + offset; _viewCamera.transform.LookAt(_orbitTarget); } public void Dispose() { foreach (var kv in _matCache) { var (rends, originals) = kv.Value; for (int i = 0; i < rends.Length; i++) { if (rends[i] == null) continue; var mats = rends[i].materials; for (int m = 0; m < mats.Length; m++) { if (mats[m] != null) UnityEngine.Object.Destroy(mats[m]); } rends[i].materials = originals[i]; } } _matCache.Clear(); _idToObject.Clear(); if (_root != null) UnityEngine.Object.Destroy(_root); _root = null; _focusedId = null; _wireframeApplied = false; if (_viewCamera != null) { // detach RT and keep camera disabled to avoid replacing main display if (_rt != null && _viewCamera.targetTexture == _rt) { _viewCamera.targetTexture = null; } _viewCamera.enabled = false; } if (_rt != null) { _rt.Release(); Destroy(_rt); _rt = null; } } } }