playbar 추가, 모델 여러개 로드

This commit is contained in:
logonkhi
2025-12-02 21:09:37 +09:00
parent bc4056b474
commit 5704b2d109
46 changed files with 3009 additions and 640 deletions

View File

@@ -42,8 +42,12 @@ namespace SHI.Modal.ISOP
/// </list>
/// </summary>
[UxmlElement]
public partial class ISOPChart : VisualElement
public partial class ISOPChart : VisualElement, IDisposable
{
#region IDisposable
private bool _disposed = false;
#endregion
#region (Constants)
/// <summary>메인 UXML 파일 경로 (Resources 폴더 기준)</summary>
private const string UXML_PATH = "SHI/Modal/ISOP/ISOPChart";
@@ -525,5 +529,47 @@ namespace SHI.Modal.ISOP
return 0;
}
#endregion
#region IDisposable
/// <summary>
/// 리소스를 해제하고 이벤트 핸들러를 정리합니다.
/// </summary>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
// 스크롤 이벤트 해제
if (contentScroll != null)
{
contentScroll.horizontalScroller.valueChanged -= OnHorizontalScroll;
}
// 외부 이벤트 정리
OnExpand = null;
// 동적으로 생성된 UI 요소 정리
monthsLayer?.Clear();
weeksLayer?.Clear();
daysLayer?.Clear();
timelineContent?.Clear();
// 데이터 정리
tasks?.Clear();
tasks = null;
// UI 참조 정리
_expandBtn = null;
taskRowTemplate = null;
root = null;
headerTitle = null;
headerTimeline = null;
monthsLayer = null;
weeksLayer = null;
daysLayer = null;
timelineContent = null;
contentScroll = null;
}
#endregion
}
}

View File

@@ -48,15 +48,15 @@ namespace SHI.Modal.ISOP
[SerializeField]
public UIDocument uiDocument;
private VisualElement content;
private VisualElement? content;
private TreeList listView;
private ISOPModelView modelView;
private ISOPChart chartView;
private TreeList? listView;
private ISOPModelView? modelView;
private ISOPChart? chartView;
private Button closeBtn;
private Button showTreeBtn;
private Button dragBtn;
private Button? closeBtn;
private Button? showTreeBtn;
private Button? dragBtn;
private CancellationTokenSource? _cts;
private bool _suppressSelection = false;
@@ -79,6 +79,14 @@ namespace SHI.Modal.ISOP
private float _lastModelFlexGrow = 1f;
private float _lastChartFlexGrow = 1f;
// GeometryChangedEvent 콜백 (해제용)
private EventCallback<GeometryChangedEvent>? _contentGeometryChangedCallback;
// 로딩 UI
private VisualElement? _loadingOverlay;
private VisualElement? _loadingSpinner;
private IVisualElementScheduledItem? _spinnerAnimation;
private void OnEnable()
{
@@ -140,8 +148,12 @@ namespace SHI.Modal.ISOP
}
initDrag(root);
_expanded = ExpandedSide.None;
// 로딩 UI 참조
_loadingOverlay = root.Q<VisualElement>("loading-overlay");
_loadingSpinner = root.Q<VisualElement>("loading-spinner");
}
private void initDrag(VisualElement root)
@@ -158,103 +170,107 @@ namespace SHI.Modal.ISOP
//dragBtn.AddManipulator(new HorizontalDragManipulator());
// 드래그 시작
dragBtn.RegisterCallback<PointerDownEvent>((evt) =>
{
// 좌클릭만 처리 (0)
if (evt.button != 0) return;
Debug.Log("Drag Started (PointerDown) - captured");
// 포인터 캡처
_isDragging = true;
_activePointerId = evt.pointerId;
dragBtn.CapturePointer(_activePointerId);
// 포인터가 drag 버튼의 어느 위치를 눌렀는지 계산 (center 기준)
// evt.position은 content 기준 좌표
var dragCenterX = dragBtn.layout.x + dragBtn.layout.width * 0.5f;
_dragOffset = evt.position.x - dragCenterX;
evt.StopImmediatePropagation();
}, TrickleDown.TrickleDown);
// 전역 포인터 무브로 위치 추적 (root에 등록)
dragBtn.RegisterCallback<PointerMoveEvent>((evt) =>
{
if (!_isDragging) return;
if (evt.pointerId != _activePointerId) return;
//Debug.Log($"Dragging... evt.pointerId:{evt.pointerId}, _activePointerId:{_activePointerId}");
// evt.position은 content 기준 좌표
float pointerX = evt.position.x;
float centerX = pointerX - _dragOffset;
ApplyDragPosition(content, dragBtn, centerX);
evt.StopImmediatePropagation();
}, TrickleDown.TrickleDown);
// 드래그 종료 (마우스 업 또는 포인터 캔슬)
dragBtn.RegisterCallback<PointerUpEvent>((evt) =>
{
if (!_isDragging) return;
if (evt.pointerId != _activePointerId) return;
Debug.Log("Drag Ended");
_isDragging = false;
if (_activePointerId != -1)
{
try { dragBtn.ReleasePointer(_activePointerId); } catch { }
}
_activePointerId = -1;
evt.StopImmediatePropagation();
}, TrickleDown.TrickleDown);
dragBtn.RegisterCallback<PointerCancelEvent>((evt) =>
{
if (!_isDragging) return;
if (evt.pointerId != _activePointerId) return;
_isDragging = false;
if (_activePointerId != -1)
{
try { dragBtn.ReleasePointer(_activePointerId); } catch { }
}
_activePointerId = -1;
evt.StopImmediatePropagation();
}, TrickleDown.TrickleDown);
// 드래그 이벤트 등록
dragBtn.RegisterCallback<PointerDownEvent>(OnDragPointerDown, TrickleDown.TrickleDown);
dragBtn.RegisterCallback<PointerMoveEvent>(OnDragPointerMove, TrickleDown.TrickleDown);
dragBtn.RegisterCallback<PointerUpEvent>(OnDragPointerUp, TrickleDown.TrickleDown);
dragBtn.RegisterCallback<PointerCancelEvent>(OnDragPointerCancel, TrickleDown.TrickleDown);
// 초기화 및 레이아웃 변경 시 재계산
bool initialized = false;
content.RegisterCallback<GeometryChangedEvent>((evt) =>
{
// 드래그 중에는 GeometryChanged 이벤트 무시
if (_isDragging) return;
// 초기화: treeList의 레이아웃이 계산될 때까지 대기
if (!initialized)
{
if (listView == null || listView.layout.width <= 0)
{
return; // 아직 레이아웃이 계산되지 않음
}
initialized = true;
}
UpdateDragAndPanels(content, dragBtn);
});
_contentGeometryChangedCallback = OnContentGeometryChanged;
content.RegisterCallback(_contentGeometryChangedCallback);
}
}
}
private void OnDragPointerDown(PointerDownEvent evt)
{
// 좌클릭만 처리 (0)
if (evt.button != 0) return;
Debug.Log("Drag Started (PointerDown) - captured");
// 포인터 캡처
_isDragging = true;
_activePointerId = evt.pointerId;
dragBtn.CapturePointer(_activePointerId);
// 포인터가 drag 버튼의 어느 위치를 눌렀는지 계산 (center 기준)
// evt.position은 content 기준 좌표
var dragCenterX = dragBtn.layout.x + dragBtn.layout.width * 0.5f;
_dragOffset = evt.position.x - dragCenterX;
evt.StopImmediatePropagation();
}
private void OnDragPointerMove(PointerMoveEvent evt)
{
if (!_isDragging) return;
if (evt.pointerId != _activePointerId) return;
// evt.position은 content 기준 좌표
float pointerX = evt.position.x;
float centerX = pointerX - _dragOffset;
ApplyDragPosition(content, dragBtn, centerX);
evt.StopImmediatePropagation();
}
private void OnDragPointerUp(PointerUpEvent evt)
{
if (!_isDragging) return;
if (evt.pointerId != _activePointerId) return;
Debug.Log("Drag Ended");
_isDragging = false;
if (_activePointerId != -1)
{
try { dragBtn.ReleasePointer(_activePointerId); } catch { }
}
_activePointerId = -1;
evt.StopImmediatePropagation();
}
private void OnDragPointerCancel(PointerCancelEvent evt)
{
if (!_isDragging) return;
if (evt.pointerId != _activePointerId) return;
_isDragging = false;
if (_activePointerId != -1)
{
try { dragBtn.ReleasePointer(_activePointerId); } catch { }
}
_activePointerId = -1;
evt.StopImmediatePropagation();
}
private bool _geometryInitialized = false;
private void OnContentGeometryChanged(GeometryChangedEvent evt)
{
// 드래그 중에는 GeometryChanged 이벤트 무시
if (_isDragging) return;
// 초기화: treeList의 레이아웃이 계산될 때까지 대기
if (!_geometryInitialized)
{
if (listView == null || listView.layout.width <= 0)
{
return; // 아직 레이아웃이 계산되지 않음
}
_geometryInitialized = true;
}
UpdateDragAndPanels(content, dragBtn);
}
/// <summary>
/// glTF 모델과 간트 데이터를 하위 뷰에 로드하고, 선택 동기화를 위한 매핑을 구축합니다.
/// </summary>
/// <param name="gltfPath">glTF/glb 파일 경로.</param>
/// <param name="ganttPath">간트 데이터셋 경로.</param>
/// <param name="externalCt">외부 취소 토큰.</param>
public async UniTask LoadData(string gltfPath, string ganttPath, CancellationToken externalCt = default)
public async UniTask LoadData(List<string> gltfPaths, string ganttPath, CancellationToken externalCt = default)
{
if(modelView == null)
{
@@ -263,38 +279,103 @@ namespace SHI.Modal.ISOP
{
await UniTask.Yield();
}
}
Debug.Log($"ISOPModal: LoadData {gltfPath}");
Debug.Log($"ISOPModal: LoadData {string.Join(", ", gltfPaths)}, {ganttPath}");
// 이전 작업 취소
if (_cts != null)
{
try { _cts.Cancel(); } catch { }
_cts.Dispose();
}
_cts = CancellationTokenSource.CreateLinkedTokenSource(externalCt);
var ct = _cts.Token;
// 로딩 표시
ShowLoading(true);
// 모델/리스트 로드
IEnumerable<TreeListItemData> items = Array.Empty<TreeListItemData>();
if (modelView != null)
try
{
try
// 이전 작업 취소
if (_cts != null)
{
items = await modelView.LoadModelAsync(gltfPath, ct);
try { _cts.Cancel(); } catch { }
_cts.Dispose();
}
catch (OperationCanceledException) { }
_cts = CancellationTokenSource.CreateLinkedTokenSource(externalCt);
var ct = _cts.Token;
// 모델/리스트 로드
IEnumerable<TreeListItemData> items = Array.Empty<TreeListItemData>();
if (modelView != null)
{
try
{
items = await modelView.LoadModelAsync(gltfPaths, ct);
}
catch (OperationCanceledException) { }
}
BuildKeyMaps(items);
if (listView != null) listView.SetData(items.ToList());
if (chartView != null) chartView.Load(ganttPath);
}
finally
{
// 로딩 숨기기
ShowLoading(false);
}
}
#region UI (Loading UI)
/// <summary>
/// 로딩 오버레이와 스피너 애니메이션을 표시하거나 숨깁니다.
/// </summary>
private void ShowLoading(bool show)
{
if (_loadingOverlay == null) return;
if (show)
{
_loadingOverlay.AddToClassList("visible");
StartSpinnerAnimation();
}
else
{
_loadingOverlay.RemoveFromClassList("visible");
StopSpinnerAnimation();
}
}
/// <summary>
/// 스피너 회전 애니메이션 시작
/// </summary>
private void StartSpinnerAnimation()
{
if (_loadingSpinner == null) return;
StopSpinnerAnimation();
float angle = 0f;
_spinnerAnimation = _loadingSpinner.schedule.Execute(() =>
{
angle = (angle + 10f) % 360f;
_loadingSpinner.style.rotate = new Rotate(Angle.Degrees(angle));
}).Every(16); // ~60fps
}
/// <summary>
/// 스피너 회전 애니메이션 중지
/// </summary>
private void StopSpinnerAnimation()
{
if (_spinnerAnimation != null)
{
_spinnerAnimation.Pause();
_spinnerAnimation = null;
}
BuildKeyMaps(items);
if (listView != null) listView.SetData(items.ToList());
if (chartView != null) chartView.Load(ganttPath);
if (_loadingSpinner != null)
{
_loadingSpinner.style.rotate = new Rotate(Angle.Degrees(0));
}
}
#endregion
private void BuildKeyMaps(IEnumerable<TreeListItemData> items)
{
@@ -595,13 +676,24 @@ namespace SHI.Modal.ISOP
private void OnDestroy()
{
// CancellationTokenSource 정리
if (_cts != null)
{
try { _cts.Cancel(); } catch { }
_cts.Dispose();
_cts = null;
}
// listView 이벤트 해제 및 Dispose
if (listView != null)
{
listView.OnSelectionChanged -= OnListItemSelectionChanged;
listView.OnClosed -= OnListClosed;
listView.OnVisibilityChanged -= OnListVisibilityChanged;
listView.Dispose();
}
// modelView 이벤트 해제 및 Dispose
if (modelView != null)
{
modelView.OnItemSelected -= OnModelItemSelected;
@@ -609,13 +701,56 @@ namespace SHI.Modal.ISOP
modelView.Dispose();
}
// chartView 이벤트 해제 및 Dispose
if (chartView != null)
{
chartView.OnExpand -= ToggleExpandChart;
chartView.Dispose();
}
// 버튼 이벤트 해제
if (showTreeBtn != null) showTreeBtn.clicked -= OnClickShowTree;
if (closeBtn != null) closeBtn.clicked -= OnClickClose;
// dragBtn 포인터 이벤트 해제
if (dragBtn != null)
{
dragBtn.UnregisterCallback<PointerDownEvent>(OnDragPointerDown, TrickleDown.TrickleDown);
dragBtn.UnregisterCallback<PointerMoveEvent>(OnDragPointerMove, TrickleDown.TrickleDown);
dragBtn.UnregisterCallback<PointerUpEvent>(OnDragPointerUp, TrickleDown.TrickleDown);
dragBtn.UnregisterCallback<PointerCancelEvent>(OnDragPointerCancel, TrickleDown.TrickleDown);
}
// content GeometryChangedEvent 해제
if (content != null && _contentGeometryChangedCallback != null)
{
content.UnregisterCallback(_contentGeometryChangedCallback);
_contentGeometryChangedCallback = null;
}
// 딕셔너리 정리
_keyToId.Clear();
_idToKey.Clear();
// 드래그 상태 초기화
_isDragging = false;
_activePointerId = -1;
_dragOffset = 0f;
_geometryInitialized = false;
// 로딩 UI 정리
StopSpinnerAnimation();
_loadingOverlay = null;
_loadingSpinner = null;
// UI 참조 정리
content = null;
listView = null;
modelView = null;
chartView = null;
closeBtn = null;
showTreeBtn = null;
dragBtn = null;
}
}

View File

@@ -46,8 +46,12 @@ namespace SHI.Modal.ISOP
/// </list>
/// </summary>
[UxmlElement]
public partial class ISOPModelView : VisualElement
public partial class ISOPModelView : VisualElement, IDisposable
{
#region IDisposable
private bool _disposed = false;
#endregion
#region (Public Events)
/// <summary>
/// 뷰 내부에서 항목이 선택될 때 발생합니다.
@@ -156,35 +160,55 @@ namespace SHI.Modal.ISOP
}
/// <summary>
/// 주어진 경로의 glTF 모델을 비동기로 로드하고, UI 트리에 사용할 계층 항목을 생성합니다.
/// 주어진 경로의 glTF 모델을 비동기로 로드하고, UI 트리에 사용할 계층 항목을 생성합니다.
/// </summary>
public async UniTask<IEnumerable<TreeListItemData>> LoadModelAsync(string path, CancellationToken ct)
/// <param name="paths">로드할 glTF/glb 파일 경로 목록</param>
/// <param name="ct">취소 토큰</param>
/// <returns>로드된 모델들의 계층 항목 목록</returns>
public async UniTask<IEnumerable<TreeListItemData>> LoadModelAsync(List<string> paths, CancellationToken ct)
{
Debug.Log($"ISOPModelView.LoadModelAsync: {path}");
Dispose();
Debug.Log($"ISOPModelView.LoadModelAsync: {paths?.Count ?? 0} files");
CleanupForReload();
await UniTask.DelayFrame(1);
EnsureCameraAndTargetTexture();
var items = new List<TreeListItemData>();
var gltf = new GltfImport();
var success = await gltf.Load(path, new ImportSettings(), ct);
if (!success)
if (paths == null || paths.Count == 0)
{
Debug.LogError($"glTFast Load failed: {path}");
Debug.LogWarning("ISOPModelView.LoadModelAsync: No paths provided");
return items;
}
if(_root == null) _root = new GameObject("ISOPModelViewRoot");
if (_root == null) _root = new GameObject("ISOPModelViewRoot");
_root.layer = modelLayer;
var sceneOk = await gltf.InstantiateMainSceneAsync(_root.transform);
if (!sceneOk)
// 각 파일을 순차적으로 로드
foreach (var path in paths)
{
Debug.LogError("InstantiateMainSceneAsync failed");
return items;
if (string.IsNullOrEmpty(path)) continue;
Debug.Log($"ISOPModelView.LoadModelAsync: Loading {path}");
var gltf = new GltfImport();
var success = await gltf.Load(path, new ImportSettings(), ct);
if (!success)
{
Debug.LogError($"glTFast Load failed: {path}");
continue;
}
var sceneOk = await gltf.InstantiateMainSceneAsync(_root.transform);
if (!sceneOk)
{
Debug.LogError($"InstantiateMainSceneAsync failed: {path}");
continue;
}
}
SetLayerRecursive(_root, modelLayer);
// 로드된 모든 자식에서 TreeListItemData 생성
if (_root != null)
{
for (int i = 0; i < _root.transform.childCount; i++)
@@ -600,8 +624,13 @@ namespace SHI.Modal.ISOP
}
}
public void Dispose()
/// <summary>
/// 모델 재로드를 위한 리소스 정리.
/// Dispose()와 달리 UI 참조(_renderContainer, _expandBtn)는 유지합니다.
/// </summary>
private void CleanupForReload()
{
// 머티리얼 정리 (인스턴스 머티리얼 삭제)
foreach (var kv in _originalSharedByRenderer)
{
var r = kv.Key;
@@ -616,11 +645,14 @@ namespace SHI.Modal.ISOP
}
_originalSharedByRenderer.Clear();
_idToObject.Clear();
// 모델 루트 삭제
if (_root != null) UnityEngine.Object.Destroy(_root);
_root = null;
_focusedId = null;
_wireframeApplied = false;
// 카메라 및 렌더텍스처 정리
if (_viewCamera != null)
{
if (_rt != null && _viewCamera.targetTexture == _rt)
@@ -629,6 +661,7 @@ namespace SHI.Modal.ISOP
}
_viewCamera.enabled = false;
}
if (_rt != null)
{
_rt.Release();
@@ -636,13 +669,95 @@ namespace SHI.Modal.ISOP
_rt = null;
}
//ISOPModelViewRig란 이름의 게임오브젝트도 같이 삭제
// 드래그 상태 초기화
_mmbDragging = false;
_rmbDragging = false;
// ID 시드 초기화
itemIdSeed = 1;
// 게임오브젝트 정리
var rigGo = GameObject.Find("ISOPModelViewRig");
if (rigGo != null) UnityEngine.Object.Destroy(rigGo);
_viewCamera = null;
var rootGo = GameObject.Find("ISOPModelViewRoot");
if (rootGo != null) UnityEngine.Object.Destroy(rootGo);
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
// 마우스 이벤트 해제
UnregisterCallback<MouseDownEvent>(OnMouseDown);
UnregisterCallback<MouseUpEvent>(OnMouseUp);
UnregisterCallback<MouseMoveEvent>(OnMouseMove);
UnregisterCallback<WheelEvent>(OnWheel);
UnregisterCallback<GeometryChangedEvent>(OnGeometryChanged);
// 외부 이벤트 정리
OnItemSelected = null;
OnExpand = null;
// 머티리얼 정리 (인스턴스 머티리얼 삭제)
foreach (var kv in _originalSharedByRenderer)
{
var r = kv.Key;
if (r == null) continue;
var originals = kv.Value;
var mats = r.materials;
for (int m = 0; m < mats.Length; m++)
{
if (mats[m] != null) UnityEngine.Object.Destroy(mats[m]);
}
r.materials = originals;
}
_originalSharedByRenderer.Clear();
_idToObject.Clear();
// 모델 루트 삭제
if (_root != null) UnityEngine.Object.Destroy(_root);
_root = null;
_focusedId = null;
_wireframeApplied = false;
_wireframeMat = null; // Resources에서 로드한 것은 Destroy하지 않음
// 카메라 및 렌더텍스처 정리
if (_viewCamera != null)
{
if (_rt != null && _viewCamera.targetTexture == _rt)
{
_viewCamera.targetTexture = null;
}
_viewCamera.enabled = false;
}
_viewCamera = null;
if (_rt != null)
{
_rt.Release();
UnityEngine.Object.Destroy(_rt);
_rt = null;
}
// 드래그 상태 초기화
_mmbDragging = false;
_rmbDragging = false;
// ID 시드 초기화
itemIdSeed = 1;
// UI 참조 정리
_renderContainer = null;
_expandBtn = null;
// 게임오브젝트 정리
var rigGo = GameObject.Find("ISOPModelViewRig");
if (rigGo != null) UnityEngine.Object.Destroy(rigGo);
var rootGo = GameObject.Find("ISOPModelViewRoot");
if (rootGo != null) UnityEngine.Object.Destroy(rootGo);
}
// 유틸리티 메서드

View File

@@ -64,8 +64,12 @@ namespace SHI.Modal.NW
/// </list>
/// </summary>
[UxmlElement]
public partial class NWChart : VisualElement
public partial class NWChart : VisualElement, IDisposable
{
#region IDisposable
private bool _disposed = false;
#endregion
private const string UXML_PATH = "SHI/Modal/NW/NWChart";
public Action? OnExpand;
@@ -123,7 +127,9 @@ namespace SHI.Modal.NW
private bool isDragging;
private DateTime projectStartDate;
public DateTime ProjectStartDate => projectStartDate;
private DateTime projectEndDate;
public DateTime ProjectEndDate => projectEndDate;
private int totalDays;
private float canvasHeight;
@@ -142,10 +148,15 @@ namespace SHI.Modal.NW
_expandBtn = this.Q<Button>("expand-btn");
if (_expandBtn != null)
{
_expandBtn.clicked += () => OnExpand?.Invoke();
_expandBtn.clicked += OnExpandBtnClicked;
}
}
private void OnExpandBtnClicked()
{
OnExpand?.Invoke();
}
public void Load(string jsonFileName)
{
root = this;
@@ -157,6 +168,45 @@ namespace SHI.Modal.NW
RenderNetwork();
}
/// <summary>
/// 지정한 날짜가 STDT~FNDT 범위에 포함되는 작업들의 BLK_NO 목록을 반환합니다.
/// </summary>
/// <param name="yyyymmdd">조회할 날짜 (yyyyMMdd 형식, 예: "20250818")</param>
/// <returns>해당 날짜에 진행 중인 블록 번호 목록</returns>
public List<string> GetModelNamesByDate(string yyyymmdd)
{
var result = new List<string>();
if (tasks == null || string.IsNullOrEmpty(yyyymmdd)) return result;
foreach (var task in tasks)
{
// BLK_NO가 없으면 스킵
if (string.IsNullOrEmpty(task.BLK_NO)) continue;
// STDT, FNDT 가져오기
string stdt = task.STDT;
string fndt = task.FNDT;
// STDT가 없으면 스킵
if (string.IsNullOrEmpty(stdt) || stdt == "null") continue;
// FNDT가 없으면 STDT와 동일하게 처리
if (string.IsNullOrEmpty(fndt) || fndt == "null") fndt = stdt;
// 문자열 비교로 범위 체크 (yyyyMMdd 형식은 문자열 비교로 날짜 비교 가능)
if (string.CompareOrdinal(stdt, yyyymmdd) <= 0 && string.CompareOrdinal(yyyymmdd, fndt) <= 0)
{
// 중복 방지
if (!result.Contains(task.BLK_NO))
{
result.Add(task.BLK_NO);
}
}
}
return result;
}
void LoadData(string jsonFileName)
{
var json = File.ReadAllText(jsonFileName);
@@ -428,7 +478,7 @@ namespace SHI.Modal.NW
var task = tasks!.Find( (item) => item.STDT == date.ToString("yyyyMMdd"));
if(task != null)
{
Debug.Log($"Found task for date {date:yyyyMMdd}: REL_TP={task.REL_TP}, PROJ_TP={task.PROJ_TP}");
// Debug.Log($"Found task for date {date:yyyyMMdd}: REL_TP={task.REL_TP}, PROJ_TP={task.PROJ_TP}");
var lab = new Label($"{task.REL_TP}\n{task.PROJ_TP}");
lab.style.unityTextAlign = TextAnchor.MiddleRight;
lab.style.width = Length.Percent(100);
@@ -940,5 +990,69 @@ namespace SHI.Modal.NW
if (draggingActivityCode == null) return;
UpdateConnectionsForNode(draggingActivityCode);
}
#region IDisposable
/// <summary>
/// 리소스를 해제하고 이벤트 핸들러를 정리합니다.
/// </summary>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
// 버튼 이벤트 해제
if (_expandBtn != null)
{
_expandBtn.clicked -= OnExpandBtnClicked;
}
// 스크롤 이벤트 해제
if (contentScroll != null)
{
contentScroll.horizontalScroller.valueChanged -= OnHorizontalScroll;
}
// 노드 드래그 이벤트 해제
foreach (var node in nodeElements.Values)
{
node.UnregisterCallback<PointerDownEvent>(OnNodePointerDown);
node.UnregisterCallback<PointerMoveEvent>(OnNodePointerMove);
node.UnregisterCallback<PointerUpEvent>(OnNodePointerUp);
}
// 외부 이벤트 정리
OnExpand = null;
// 동적으로 생성된 UI 요소 정리
monthsLayer?.Clear();
daysLayer?.Clear();
nodesLayer?.Clear();
linesLayer?.Clear();
// 데이터 정리
tasks?.Clear();
tasks = null;
tasksByActivityCode.Clear();
nodeElements.Clear();
nodePositions.Clear();
connectionCache.Clear();
// 드래그 상태 정리
draggingNode = null;
draggingActivityCode = null;
isDragging = false;
// UI 참조 정리
_expandBtn = null;
root = null;
headerTimeline = null;
monthsLayer = null;
daysLayer = null;
networkCanvas = null;
nodesLayer = null;
linesLayer = null;
contentScroll = null;
}
#endregion
}
}

View File

@@ -50,13 +50,14 @@ namespace SHI.Modal.NW
[SerializeField]
public UIDocument uiDocument;
private TreeList listView;
private NWModelView modelView;
private NWChart chartView;
private TreeList? listView;
private NWModelView? modelView;
private NWChart? chartView;
private PlayBar? _playBar;
private Button closeBtn;
private Button showTreeBtn;
private Button dragBtn;
private Button? closeBtn;
private Button? showTreeBtn;
private Button? dragBtn;
private VisualElement? rightPanel;
@@ -81,6 +82,13 @@ namespace SHI.Modal.NW
private float _lastModelFlexGrow = 1f;
private float _lastChartFlexGrow = 1f;
// GeometryChangedEvent 콜백 (해제용)
private EventCallback<GeometryChangedEvent>? _rightPanelGeometryChangedCallback;
// 로딩 UI
private VisualElement? _loadingOverlay;
private VisualElement? _loadingSpinner;
private IVisualElementScheduledItem? _spinnerAnimation;
private void OnEnable()
{
@@ -141,11 +149,45 @@ namespace SHI.Modal.NW
closeBtn.clicked += OnClickClose;
}
_playBar = root.Q<PlayBar>("play-bar");
if (_playBar != null)
{
_playBar.SetTimeRange(DateTime.Now, DateTime.Now.AddHours(1));
_playBar.OnPlayProgress += OnPlayProgressHandler;
_playBar.OnPositionChanged += OnPlayPositionChangedHandler;
}
initDrag(root);
_expanded = ExpandedSide.None;
// 로딩 UI 참조
_loadingOverlay = root.Q<VisualElement>("loading-overlay");
_loadingSpinner = root.Q<VisualElement>("loading-spinner");
}
private void OnPlayProgressHandler(DateTime time)
{
if(chartView != null && listView != null)
{
List<string> models = chartView.GetModelNamesByDate(time.ToString("yyyyMMdd"));
Debug.Log($"Models at {time:yyyyMMdd}: {string.Join(", ", models)}");
if(models.Count > 0) listView.ShowItems(models);
}
}
private void OnPlayPositionChangedHandler(DateTime time)
{
if(chartView != null && listView != null)
{
List<string> models = chartView.GetModelNamesByDate(time.ToString("yyyyMMdd"));
Debug.Log($"Models at {time:yyyyMMdd}: {string.Join(", ", models)}");
if(models.Count > 0) listView.ShowItems(models);
}
}
private void initDrag(VisualElement root)
{
dragBtn = root.Q<Button>("drag-btn");
@@ -157,100 +199,114 @@ namespace SHI.Modal.NW
dragBtn.style.position = Position.Absolute;
dragBtn.pickingMode = PickingMode.Position;
// 드래그 시작
dragBtn.RegisterCallback<PointerDownEvent>((evt) =>
{
// 좌클릭만 처리 (0)
if (evt.button != 0) return;
Debug.Log("Drag Started (PointerDown) - captured");
// 포인터 캡처
_isDragging = true;
_activePointerId = evt.pointerId;
dragBtn.CapturePointer(_activePointerId);
// 포인터가 drag 버튼의 어느 위치를 눌렀는지 계산 (center 기준, Y축)
var dragCenterY = dragBtn.layout.y + dragBtn.layout.height * 0.5f;
_dragOffset = evt.position.y - dragCenterY;
evt.StopImmediatePropagation();
}, TrickleDown.TrickleDown);
// 전역 포인터 무브로 위치 추적
dragBtn.RegisterCallback<PointerMoveEvent>((evt) =>
{
if (!_isDragging) return;
if (evt.pointerId != _activePointerId) return;
// evt.position은 rightPanel 기준 좌표
float pointerY = evt.position.y;
float centerY = pointerY - _dragOffset;
ApplyDragPosition(rightPanel, dragBtn, centerY);
evt.StopImmediatePropagation();
}, TrickleDown.TrickleDown);
// 드래그 종료 (마우스 업 또는 포인터 캔슬)
dragBtn.RegisterCallback<PointerUpEvent>((evt) =>
{
if (!_isDragging) return;
if (evt.pointerId != _activePointerId) return;
Debug.Log("Drag Ended");
_isDragging = false;
if (_activePointerId != -1)
{
try { dragBtn.ReleasePointer(_activePointerId); } catch { }
}
_activePointerId = -1;
evt.StopImmediatePropagation();
}, TrickleDown.TrickleDown);
dragBtn.RegisterCallback<PointerCancelEvent>((evt) =>
{
if (!_isDragging) return;
if (evt.pointerId != _activePointerId) return;
_isDragging = false;
if (_activePointerId != -1)
{
try { dragBtn.ReleasePointer(_activePointerId); } catch { }
}
_activePointerId = -1;
evt.StopImmediatePropagation();
}, TrickleDown.TrickleDown);
// 드래그 이벤트 등록
dragBtn.RegisterCallback<PointerDownEvent>(OnDragPointerDown, TrickleDown.TrickleDown);
dragBtn.RegisterCallback<PointerMoveEvent>(OnDragPointerMove, TrickleDown.TrickleDown);
dragBtn.RegisterCallback<PointerUpEvent>(OnDragPointerUp, TrickleDown.TrickleDown);
dragBtn.RegisterCallback<PointerCancelEvent>(OnDragPointerCancel, TrickleDown.TrickleDown);
// 초기화 및 레이아웃 변경 시 재계산
bool initialized = false;
rightPanel.RegisterCallback<GeometryChangedEvent>((evt) =>
{
// 드래그 중에는 GeometryChanged 이벤트 무시
if (_isDragging) return;
// 초기화: rightPanel의 레이아웃이 계산될 때까지 대기
if (!initialized)
{
if (rightPanel.layout.height <= 0)
{
return; // 아직 레이아웃이 계산되지 않음
}
initialized = true;
}
UpdateDragAndPanels(rightPanel, dragBtn);
});
_rightPanelGeometryChangedCallback = OnRightPanelGeometryChanged;
rightPanel.RegisterCallback(_rightPanelGeometryChangedCallback);
}
}
}
private void OnDragPointerDown(PointerDownEvent evt)
{
// 좌클릭만 처리 (0)
if (evt.button != 0) return;
Debug.Log("Drag Started (PointerDown) - captured");
// 포인터 캡처
_isDragging = true;
_activePointerId = evt.pointerId;
dragBtn?.CapturePointer(_activePointerId);
// 포인터가 drag 버튼의 어느 위치를 눌렀는지 계산 (center 기준, Y축)
if (dragBtn != null)
{
var dragCenterY = dragBtn.layout.y + dragBtn.layout.height * 0.5f;
_dragOffset = evt.position.y - dragCenterY;
}
evt.StopImmediatePropagation();
}
private void OnDragPointerMove(PointerMoveEvent evt)
{
if (!_isDragging) return;
if (evt.pointerId != _activePointerId) return;
// evt.position은 rightPanel 기준 좌표
float pointerY = evt.position.y;
float centerY = pointerY - _dragOffset;
if (rightPanel != null && dragBtn != null)
{
ApplyDragPosition(rightPanel, dragBtn, centerY);
}
evt.StopImmediatePropagation();
}
private void OnDragPointerUp(PointerUpEvent evt)
{
if (!_isDragging) return;
if (evt.pointerId != _activePointerId) return;
Debug.Log("Drag Ended");
_isDragging = false;
if (_activePointerId != -1)
{
try { dragBtn?.ReleasePointer(_activePointerId); } catch { }
}
_activePointerId = -1;
evt.StopImmediatePropagation();
}
private void OnDragPointerCancel(PointerCancelEvent evt)
{
if (!_isDragging) return;
if (evt.pointerId != _activePointerId) return;
_isDragging = false;
if (_activePointerId != -1)
{
try { dragBtn?.ReleasePointer(_activePointerId); } catch { }
}
_activePointerId = -1;
evt.StopImmediatePropagation();
}
private bool _geometryInitialized = false;
private void OnRightPanelGeometryChanged(GeometryChangedEvent evt)
{
// 드래그 중에는 GeometryChanged 이벤트 무시
if (_isDragging) return;
// 초기화: rightPanel의 레이아웃이 계산될 때까지 대기
if (!_geometryInitialized)
{
if (rightPanel == null || rightPanel.layout.height <= 0)
{
return; // 아직 레이아웃이 계산되지 않음
}
_geometryInitialized = true;
}
if (rightPanel != null && dragBtn != null)
{
UpdateDragAndPanels(rightPanel, dragBtn);
}
}
/// <summary>
/// glTF 모델과 간트 데이터를 하위 뷰에 로드하고, 선택 동기화를 위한 매핑을 구축합니다.
/// </summary>
/// <param name="gltfPath">glTF/glb 파일 경로.</param>
/// <param name="gltfPaths">glTF/glb 파일 경로 목록.</param>
/// <param name="ganttPath">간트 데이터셋 경로.</param>
/// <param name="externalCt">외부 취소 토큰.</param>
public async UniTask LoadData(string gltfPath, string ganttPath, CancellationToken externalCt = default)
public async UniTask LoadData(List<string> gltfPaths, string ganttPath, CancellationToken externalCt = default)
{
if(modelView == null)
{
@@ -259,38 +315,107 @@ namespace SHI.Modal.NW
{
await UniTask.Yield();
}
}
Debug.Log($"NWModal: LoadData {gltfPath}");
Debug.Log($"NWModal.LoadData: gltfPath:{string.Join(", ", gltfPaths)}, ganttPath={ganttPath}");
// 이전 작업 취소
if (_cts != null)
{
try { _cts.Cancel(); } catch { }
_cts.Dispose();
}
_cts = CancellationTokenSource.CreateLinkedTokenSource(externalCt);
var ct = _cts.Token;
// 로딩 표시
ShowLoading(true);
// 모델/리스트 로드
IEnumerable<TreeListItemData> items = Array.Empty<TreeListItemData>();
if (modelView != null)
try
{
try
// 이전 작업 취소
if (_cts != null)
{
items = await modelView.LoadModelAsync(gltfPath, ct);
try { _cts.Cancel(); } catch { }
_cts.Dispose();
}
catch (OperationCanceledException) { }
_cts = CancellationTokenSource.CreateLinkedTokenSource(externalCt);
var ct = _cts.Token;
// 모델/리스트 로드
IEnumerable<TreeListItemData> items = Array.Empty<TreeListItemData>();
if (modelView != null)
{
try
{
items = await modelView.LoadModelAsync(gltfPaths, ct);
}
catch (OperationCanceledException) { }
}
BuildKeyMaps(items);
if (listView != null) listView.SetData(items.ToList());
if (chartView != null) chartView.Load(ganttPath);
if (_playBar != null && chartView != null)
{
_playBar.SetTimeRange(chartView.ProjectStartDate, chartView.ProjectEndDate);
}
}
finally
{
// 로딩 숨기기
ShowLoading(false);
}
}
#region UI (Loading UI)
/// <summary>
/// 로딩 오버레이와 스피너 애니메이션을 표시하거나 숨깁니다.
/// </summary>
private void ShowLoading(bool show)
{
if (_loadingOverlay == null) return;
if (show)
{
_loadingOverlay.AddToClassList("visible");
StartSpinnerAnimation();
}
else
{
_loadingOverlay.RemoveFromClassList("visible");
StopSpinnerAnimation();
}
}
/// <summary>
/// 스피너 회전 애니메이션 시작
/// </summary>
private void StartSpinnerAnimation()
{
if (_loadingSpinner == null) return;
StopSpinnerAnimation();
float angle = 0f;
_spinnerAnimation = _loadingSpinner.schedule.Execute(() =>
{
angle = (angle + 10f) % 360f;
_loadingSpinner.style.rotate = new Rotate(Angle.Degrees(angle));
}).Every(16); // ~60fps
}
/// <summary>
/// 스피너 회전 애니메이션 중지
/// </summary>
private void StopSpinnerAnimation()
{
if (_spinnerAnimation != null)
{
_spinnerAnimation.Pause();
_spinnerAnimation = null;
}
BuildKeyMaps(items);
if (listView != null) listView.SetData(items.ToList());
if (chartView != null) chartView.Load(ganttPath);
if (_loadingSpinner != null)
{
_loadingSpinner.style.rotate = new Rotate(Angle.Degrees(0));
}
}
#endregion
private void BuildKeyMaps(IEnumerable<TreeListItemData> items)
{
@@ -566,13 +691,24 @@ namespace SHI.Modal.NW
private void OnDestroy()
{
// CancellationTokenSource 정리
if (_cts != null)
{
try { _cts.Cancel(); } catch { }
_cts.Dispose();
_cts = null;
}
// listView 이벤트 해제 및 Dispose
if (listView != null)
{
listView.OnSelectionChanged -= OnListItemSelectionChanged;
listView.OnClosed -= OnListClosed;
listView.OnVisibilityChanged -= OnListVisibilityChanged;
listView.Dispose();
}
// modelView 이벤트 해제 및 Dispose
if (modelView != null)
{
modelView.OnItemSelected -= OnModelItemSelected;
@@ -580,13 +716,63 @@ namespace SHI.Modal.NW
modelView.Dispose();
}
// chartView 이벤트 해제 및 Dispose
if (chartView != null)
{
chartView.OnExpand -= ToggleExpandChart;
chartView.Dispose();
}
// 버튼 이벤트 해제
if (showTreeBtn != null) showTreeBtn.clicked -= OnClickShowTree;
if (closeBtn != null) closeBtn.clicked -= OnClickClose;
// dragBtn 포인터 이벤트 해제
if (dragBtn != null)
{
dragBtn.UnregisterCallback<PointerDownEvent>(OnDragPointerDown, TrickleDown.TrickleDown);
dragBtn.UnregisterCallback<PointerMoveEvent>(OnDragPointerMove, TrickleDown.TrickleDown);
dragBtn.UnregisterCallback<PointerUpEvent>(OnDragPointerUp, TrickleDown.TrickleDown);
dragBtn.UnregisterCallback<PointerCancelEvent>(OnDragPointerCancel, TrickleDown.TrickleDown);
}
// rightPanel GeometryChangedEvent 해제
if (rightPanel != null && _rightPanelGeometryChangedCallback != null)
{
rightPanel.UnregisterCallback(_rightPanelGeometryChangedCallback);
_rightPanelGeometryChangedCallback = null;
}
if( _playBar != null)
{
_playBar.OnPlayProgress -= OnPlayProgressHandler;
_playBar.OnPositionChanged -= OnPlayPositionChangedHandler;
_playBar.Dispose();
}
// 딕셔너리 정리
_keyToId.Clear();
_idToKey.Clear();
// 드래그 상태 초기화
_isDragging = false;
_activePointerId = -1;
_dragOffset = 0f;
_geometryInitialized = false;
// 로딩 UI 정리
StopSpinnerAnimation();
_loadingOverlay = null;
_loadingSpinner = null;
// UI 참조 정리
rightPanel = null;
listView = null;
modelView = null;
chartView = null;
closeBtn = null;
showTreeBtn = null;
dragBtn = null;
}
}

View File

@@ -46,8 +46,12 @@ namespace SHI.Modal.NW
/// </list>
/// </summary>
[UxmlElement]
public partial class NWModelView : VisualElement
public partial class NWModelView : VisualElement, IDisposable
{
#region IDisposable
private bool _disposed = false;
#endregion
#region (Public Events)
/// <summary>
/// 뷰 내부에서 항목이 선택될 때 발생합니다.
@@ -141,10 +145,7 @@ namespace SHI.Modal.NW
_expandBtn = this.Q<Button>("expand-btn");
if(_expandBtn != null)
{
_expandBtn.clicked += () =>
{
OnExpand?.Invoke();
};
_expandBtn.clicked += OnExpandBtnClicked;
}
// 마우스 이벤트 등록
@@ -155,36 +156,60 @@ namespace SHI.Modal.NW
RegisterCallback<GeometryChangedEvent>(OnGeometryChanged);
}
/// <summary>
/// 주어진 경로의 glTF 모델을 비동기로 로드하고, UI 트리에 사용할 계층 항목을 생성합니다.
/// </summary>
public async UniTask<IEnumerable<TreeListItemData>> LoadModelAsync(string path, CancellationToken ct)
private void OnExpandBtnClicked()
{
Debug.Log($"NWModelView.LoadModelAsync: {path}");
Dispose();
OnExpand?.Invoke();
}
/// <summary>
/// 주어진 경로들의 glTF 모델을 비동기로 로드하고, UI 트리에 사용할 계층 항목을 생성합니다.
/// </summary>
/// <param name="paths">로드할 glTF/glb 파일 경로 목록</param>
/// <param name="ct">취소 토큰</param>
/// <returns>로드된 모델들의 계층 항목 목록</returns>
public async UniTask<IEnumerable<TreeListItemData>> LoadModelAsync(List<string> paths, CancellationToken ct)
{
Debug.Log($"NWModelView.LoadModelAsync: {paths?.Count ?? 0} files");
CleanupForReload();
await UniTask.DelayFrame(1);
EnsureCameraAndTargetTexture();
var items = new List<TreeListItemData>();
var gltf = new GltfImport();
var success = await gltf.Load(path, new ImportSettings(), ct);
if (!success)
if (paths == null || paths.Count == 0)
{
Debug.LogError($"glTFast Load failed: {path}");
Debug.LogWarning("NWModelView.LoadModelAsync: No paths provided");
return items;
}
if(_root == null) _root = new GameObject("NWModelViewRoot");
if (_root == null) _root = new GameObject("NWModelViewRoot");
_root.layer = modelLayer;
var sceneOk = await gltf.InstantiateMainSceneAsync(_root.transform);
if (!sceneOk)
// 각 파일을 순차적으로 로드
foreach (var path in paths)
{
Debug.LogError("InstantiateMainSceneAsync failed");
return items;
if (string.IsNullOrEmpty(path)) continue;
Debug.Log($"NWModelView.LoadModelAsync: Loading {path}");
var gltf = new GltfImport();
var success = await gltf.Load(path, new ImportSettings(), ct);
if (!success)
{
Debug.LogError($"glTFast Load failed: {path}");
continue;
}
var sceneOk = await gltf.InstantiateMainSceneAsync(_root.transform);
if (!sceneOk)
{
Debug.LogError($"InstantiateMainSceneAsync failed: {path}");
continue;
}
}
SetLayerRecursive(_root, modelLayer);
// 로드된 모든 자식에서 TreeListItemData 생성
if (_root != null)
{
for (int i = 0; i < _root.transform.childCount; i++)
@@ -313,7 +338,11 @@ namespace SHI.Modal.NW
private void EnsureRenderTargetSize()
{
if (_viewCamera == null || _renderContainer == null) return;
if (_viewCamera == null || _renderContainer == null)
{
Debug.LogWarning($"[NWModelView] EnsureRenderTargetSize: camera={_viewCamera != null}, container={_renderContainer != null}");
return;
}
// 고정 크기 사용
int w = FIXED_RT_WIDTH;
@@ -377,12 +406,10 @@ namespace SHI.Modal.NW
public void FocusItemById(int id)
{
Debug.Log($"NWModelView.FocusItemById: id={id}");
_focusedId = id;
if (_idToObject.TryGetValue(id, out var go))
{
Highlight(go, true);
Debug.Log($"NWModelView.FocusItemById: {go.name}");
_orbitTarget = go.transform.position;
}
}
@@ -398,17 +425,14 @@ namespace SHI.Modal.NW
public void Export(int id)
{
Debug.Log($"NWModelView.Export: id={id}");
if (_idToObject.TryGetValue(id, out var go))
{
Debug.Log($"Exporting object: {go.name}");
UVC.GLTF.GLTFExporter.ExportNodeByExplorer(go);
}
}
public void SetVisibility(int id, bool on)
{
Debug.Log($"NWModelView.SetVisibility: id={id} on={on}");
{
if (_idToObject.TryGetValue(id, out var go))
{
go.SetActive(on);
@@ -601,8 +625,12 @@ namespace SHI.Modal.NW
}
}
public void Dispose()
/// <summary>
/// 모델 재로드를 위한 정리 (UI 참조는 유지)
/// </summary>
private void CleanupForReload()
{
// 머티리얼 정리 (인스턴스 머티리얼 삭제)
foreach (var kv in _originalSharedByRenderer)
{
var r = kv.Key;
@@ -617,11 +645,14 @@ namespace SHI.Modal.NW
}
_originalSharedByRenderer.Clear();
_idToObject.Clear();
// 모델 루트 삭제
if (_root != null) UnityEngine.Object.Destroy(_root);
_root = null;
_focusedId = null;
_wireframeApplied = false;
// 카메라 및 렌더텍스처 정리
if (_viewCamera != null)
{
if (_rt != null && _viewCamera.targetTexture == _rt)
@@ -630,6 +661,7 @@ namespace SHI.Modal.NW
}
_viewCamera.enabled = false;
}
if (_rt != null)
{
_rt.Release();
@@ -637,13 +669,101 @@ namespace SHI.Modal.NW
_rt = null;
}
//NWModelViewRig란 이름의 게임오브젝트도 같이 삭제
// 드래그 상태 초기화
_mmbDragging = false;
_rmbDragging = false;
// ID 시드 초기화
itemIdSeed = 1;
// 게임오브젝트 정리
var rigGo = GameObject.Find("NWModelViewRig");
if (rigGo != null) UnityEngine.Object.Destroy(rigGo);
_viewCamera = null;
var rootGo = GameObject.Find("NWModelViewRoot");
if (rootGo != null) UnityEngine.Object.Destroy(rootGo);
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
// 버튼 이벤트 해제
if (_expandBtn != null)
{
_expandBtn.clicked -= OnExpandBtnClicked;
}
// 마우스 이벤트 해제
UnregisterCallback<MouseDownEvent>(OnMouseDown);
UnregisterCallback<MouseUpEvent>(OnMouseUp);
UnregisterCallback<MouseMoveEvent>(OnMouseMove);
UnregisterCallback<WheelEvent>(OnWheel);
UnregisterCallback<GeometryChangedEvent>(OnGeometryChanged);
// 외부 이벤트 정리
OnItemSelected = null;
OnExpand = null;
// 머티리얼 정리 (인스턴스 머티리얼 삭제)
foreach (var kv in _originalSharedByRenderer)
{
var r = kv.Key;
if (r == null) continue;
var originals = kv.Value;
var mats = r.materials;
for (int m = 0; m < mats.Length; m++)
{
if (mats[m] != null) UnityEngine.Object.Destroy(mats[m]);
}
r.materials = originals;
}
_originalSharedByRenderer.Clear();
_idToObject.Clear();
// 모델 루트 삭제
if (_root != null) UnityEngine.Object.Destroy(_root);
_root = null;
_focusedId = null;
_wireframeApplied = false;
_wireframeMat = null; // Resources에서 로드한 것은 Destroy하지 않음
// 카메라 및 렌더텍스처 정리
if (_viewCamera != null)
{
if (_rt != null && _viewCamera.targetTexture == _rt)
{
_viewCamera.targetTexture = null;
}
_viewCamera.enabled = false;
}
_viewCamera = null;
if (_rt != null)
{
_rt.Release();
UnityEngine.Object.Destroy(_rt);
_rt = null;
}
// 드래그 상태 초기화
_mmbDragging = false;
_rmbDragging = false;
// ID 시드 초기화
itemIdSeed = 1;
// UI 참조 정리
_renderContainer = null;
_expandBtn = null;
// 게임오브젝트 정리
var rigGo = GameObject.Find("NWModelViewRig");
if (rigGo != null) UnityEngine.Object.Destroy(rigGo);
var rootGo = GameObject.Find("NWModelViewRoot");
if (rootGo != null) UnityEngine.Object.Destroy(rootGo);
}
// 유틸리티 메서드

View File

@@ -0,0 +1,472 @@
#nullable enable
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UIElements;
namespace SHI.Modal
{
/// <summary>
/// 시간 기반 재생을 제어하는 UI Toolkit 컴포넌트입니다.
/// </summary>
[UxmlElement]
public partial class PlayBar : VisualElement, IDisposable
{
#region (Constants)
private const string UXML_PATH = "SHI/Modal/PlayBar";
private const int DEFAULT_INTERVAL_SECONDS = 2;
#endregion
#region UI
private Label? _startLabel;
private Label? _endLabel;
private Label? _currentTimeLabel;
private VisualElement? _progressTrack;
private VisualElement? _progressFill;
private Button? _playBtn;
private Button? _firstBtn;
private Button? _lastBtn;
private Button? _stopBtn;
private DropdownField? _intervalDropdown;
#endregion
#region (State)
private DateTime _startTime;
private DateTime _endTime;
private DateTime _currentTime;
private bool _isPlaying;
private int _intervalSeconds = DEFAULT_INTERVAL_SECONDS;
private IVisualElementScheduledItem? _playSchedule;
private ProgressDragManipulator? _dragManipulator;
#endregion
#region (Public Events)
/// <summary>재생이 시작될 때 발생</summary>
public event Action? OnPlayStarted;
/// <summary>재생이 정지될 때 발생</summary>
public event Action? OnPlayStopped;
/// <summary>재생 중 시간이 변경될 때 발생 (자동 재생 시)</summary>
public event Action<DateTime>? OnPlayProgress;
/// <summary>사용자가 진행바를 드래그/클릭하여 위치를 변경할 때 발생</summary>
public event Action<DateTime>? OnPositionChanged;
#endregion
#region UxmlAttribute
[UxmlAttribute]
public bool IsVisible
{
get => style.display == DisplayStyle.Flex;
set => style.display = value ? DisplayStyle.Flex : DisplayStyle.None;
}
#endregion
#region (Constructor)
public PlayBar()
{
var visualTree = Resources.Load<VisualTreeAsset>(UXML_PATH);
Debug.Log("[PlayBar] UXML loaded and cloned." + (visualTree == null));
if (visualTree == null)
{
Debug.LogError($"[PlayBar] UXML not found at: {UXML_PATH}");
return;
}
visualTree.CloneTree(this);
InitializeUIReferences();
InitializeEventHandlers();
// 패널에 연결된 후 드롭다운 초기화
RegisterCallback<AttachToPanelEvent>(OnAttachToPanel);
}
private void OnAttachToPanel(AttachToPanelEvent evt)
{
UnregisterCallback<AttachToPanelEvent>(OnAttachToPanel);
InitializeDropdown();
}
#endregion
#region (Initialization)
private void InitializeUIReferences()
{
_startLabel = this.Q<Label>("start-label");
_endLabel = this.Q<Label>("end-label");
_currentTimeLabel = this.Q<Label>("current-time-label");
_progressTrack = this.Q<VisualElement>("progress-track");
_progressFill = this.Q<VisualElement>("progress-fill");
_playBtn = this.Q<Button>("play-btn");
_firstBtn = this.Q<Button>("first-btn");
_lastBtn = this.Q<Button>("last-btn");
_stopBtn = this.Q<Button>("stop-btn");
_intervalDropdown = this.Q<DropdownField>("interval-dropdown");
}
private void InitializeEventHandlers()
{
_playBtn?.RegisterCallback<ClickEvent>(OnPlayClicked);
_firstBtn?.RegisterCallback<ClickEvent>(OnFirstClicked);
_lastBtn?.RegisterCallback<ClickEvent>(OnLastClicked);
_stopBtn?.RegisterCallback<ClickEvent>(OnStopClicked);
// 진행바 드래그/클릭 설정
if (_progressTrack != null)
{
_dragManipulator = new ProgressDragManipulator(this);
_progressTrack.AddManipulator(_dragManipulator);
}
}
private void InitializeDropdown()
{
if (_intervalDropdown == null) return;
var choices = new List<string>();
for (int i = 0; i <= 10; i++)
{
choices.Add($"{i}초");
}
_intervalDropdown.choices = choices;
_intervalDropdown.value = $"{DEFAULT_INTERVAL_SECONDS}초";
_intervalDropdown.RegisterValueChangedCallback(OnIntervalChanged);
}
#endregion
#region (Public Methods)
/// <summary>
/// 시간 범위를 설정합니다.
/// </summary>
public void SetTimeRange(DateTime start, DateTime end)
{
_startTime = start;
_endTime = end;
_currentTime = start;
UpdateLabels();
UpdateProgressBar();
}
/// <summary>
/// 현재 시간을 설정합니다.
/// </summary>
public void SetCurrentTime(DateTime time)
{
_currentTime = ClampTime(time);
UpdateLabels();
UpdateProgressBar();
}
/// <summary>
/// 간격 선택 옵션을 설정합니다.
/// </summary>
public void SetIntervalChoices(List<int> seconds)
{
if (_intervalDropdown == null) return;
var choices = new List<string>();
foreach (var sec in seconds)
{
choices.Add($"{sec}초");
}
_intervalDropdown.choices = choices;
_intervalDropdown.index = 0;
_intervalSeconds = seconds.Count > 0 ? seconds[0] : DEFAULT_INTERVAL_SECONDS;
}
/// <summary>
/// 재생을 시작합니다.
/// </summary>
public void Play()
{
if (_isPlaying) return;
_isPlaying = true;
UpdatePlayButtonState();
StartPlayTimer();
OnPlayStarted?.Invoke();
}
/// <summary>
/// 재생을 일시정지합니다.
/// </summary>
public void Pause()
{
if (!_isPlaying) return;
_isPlaying = false;
StopPlayTimer();
UpdatePlayButtonState();
}
/// <summary>
/// 재생을 정지하고 처음으로 돌아갑니다.
/// </summary>
public void Stop()
{
_isPlaying = false;
_currentTime = _startTime;
StopPlayTimer();
UpdatePlayButtonState();
UpdateLabels();
UpdateProgressBar();
OnPlayStopped?.Invoke();
}
/// <summary>
/// 리소스를 정리합니다.
/// </summary>
public void Dispose()
{
StopPlayTimer();
UnregisterEventHandlers();
OnPlayStarted = null;
OnPlayStopped = null;
OnPlayProgress = null;
OnPositionChanged = null;
}
#endregion
#region (Event Handlers)
private void OnPlayClicked(ClickEvent evt)
{
if (_isPlaying)
Pause();
else
Play();
}
private void OnFirstClicked(ClickEvent evt)
{
SetCurrentTime(_startTime);
OnPositionChanged?.Invoke(_currentTime);
}
private void OnLastClicked(ClickEvent evt)
{
SetCurrentTime(_endTime);
OnPositionChanged?.Invoke(_currentTime);
}
private void OnStopClicked(ClickEvent evt)
{
Stop();
}
private void OnIntervalChanged(ChangeEvent<string> evt)
{
// "2초" -> 2 파싱
if (int.TryParse(evt.newValue.Replace("초", ""), out int seconds))
{
_intervalSeconds = seconds;
// 재생 중이면 타이머 재시작
if (_isPlaying)
{
StopPlayTimer();
StartPlayTimer();
}
}
}
#endregion
#region (Timer)
private void StartPlayTimer()
{
_playSchedule = schedule.Execute(OnPlayTick).Every(_intervalSeconds * 1000);
}
private void StopPlayTimer()
{
_playSchedule?.Pause();
_playSchedule = null;
}
private void OnPlayTick()
{
if (!_isPlaying) return;
_currentTime = _currentTime.AddDays(1);
if (_currentTime >= _endTime)
{
_currentTime = _endTime;
Pause();
}
UpdateLabels();
UpdateProgressBar();
OnPlayProgress?.Invoke(_currentTime);
}
#endregion
#region UI (UI Updates)
private void UpdateLabels()
{
if (_startLabel != null)
_startLabel.text = _startTime.ToString("yyyy-MM-dd");
if (_endLabel != null)
_endLabel.text = _endTime.ToString("yyyy-MM-dd");
if (_currentTimeLabel != null)
_currentTimeLabel.text = _currentTime.ToString("yyyy-MM-dd HH:mm");
}
private void UpdateProgressBar()
{
if (_progressFill == null) return;
float progress = CalculateProgress();
_progressFill.style.width = Length.Percent(progress * 100);
}
private void UpdatePlayButtonState()
{
if (_playBtn == null) return;
if (_isPlaying)
_playBtn.AddToClassList("playing");
else
_playBtn.RemoveFromClassList("playing");
}
#endregion
#region (Utilities)
private float CalculateProgress()
{
double total = (_endTime - _startTime).TotalDays;
if (total <= 0) return 0;
double current = (_currentTime - _startTime).TotalDays;
return Mathf.Clamp01((float)(current / total));
}
private DateTime ClampTime(DateTime time)
{
if (time < _startTime) return _startTime;
if (time > _endTime) return _endTime;
return time;
}
/// <summary>
/// 정규화된 위치(0~1)로부터 시간을 설정합니다. (드래그용)
/// </summary>
internal void SetProgressFromPosition(float normalizedPosition)
{
double totalDays = (_endTime - _startTime).TotalDays;
double targetDays = totalDays * normalizedPosition;
_currentTime = _startTime.AddDays(targetDays);
_currentTime = ClampTime(_currentTime);
UpdateLabels();
UpdateProgressBar();
}
/// <summary>
/// 드래그 완료 시 이벤트를 발생시킵니다.
/// </summary>
internal void NotifyPositionChanged()
{
OnPositionChanged?.Invoke(_currentTime);
}
internal float GetTrackWidth()
{
return _progressTrack?.layout.width ?? 0;
}
#endregion
#region (Unregister)
private void UnregisterEventHandlers()
{
_playBtn?.UnregisterCallback<ClickEvent>(OnPlayClicked);
_firstBtn?.UnregisterCallback<ClickEvent>(OnFirstClicked);
_lastBtn?.UnregisterCallback<ClickEvent>(OnLastClicked);
_stopBtn?.UnregisterCallback<ClickEvent>(OnStopClicked);
if (_progressTrack != null && _dragManipulator != null)
{
_progressTrack.RemoveManipulator(_dragManipulator);
}
}
#endregion
}
/// <summary>
/// 진행바의 드래그/클릭을 처리하는 Manipulator입니다.
/// </summary>
public class ProgressDragManipulator : PointerManipulator
{
private readonly PlayBar _playBar;
private bool _isActive;
public ProgressDragManipulator(PlayBar playBar)
{
_playBar = playBar;
activators.Add(new ManipulatorActivationFilter { button = MouseButton.LeftMouse });
}
protected override void RegisterCallbacksOnTarget()
{
target.RegisterCallback<PointerDownEvent>(OnPointerDown);
target.RegisterCallback<PointerMoveEvent>(OnPointerMove);
target.RegisterCallback<PointerUpEvent>(OnPointerUp);
target.RegisterCallback<PointerCaptureOutEvent>(OnPointerCaptureOut);
}
protected override void UnregisterCallbacksFromTarget()
{
target.UnregisterCallback<PointerDownEvent>(OnPointerDown);
target.UnregisterCallback<PointerMoveEvent>(OnPointerMove);
target.UnregisterCallback<PointerUpEvent>(OnPointerUp);
target.UnregisterCallback<PointerCaptureOutEvent>(OnPointerCaptureOut);
}
private void OnPointerDown(PointerDownEvent evt)
{
if (!CanStartManipulation(evt)) return;
_isActive = true;
target.CapturePointer(evt.pointerId);
UpdatePosition(evt.localPosition);
evt.StopPropagation();
}
private void OnPointerMove(PointerMoveEvent evt)
{
if (!_isActive || !target.HasPointerCapture(evt.pointerId)) return;
UpdatePosition(evt.localPosition);
}
private void OnPointerUp(PointerUpEvent evt)
{
if (!_isActive) return;
_isActive = false;
target.ReleasePointer(evt.pointerId);
// 드래그 종료 시에만 이벤트 발생
_playBar.NotifyPositionChanged();
evt.StopPropagation();
}
private void OnPointerCaptureOut(PointerCaptureOutEvent evt)
{
_isActive = false;
}
private void UpdatePosition(Vector2 localPosition)
{
float trackWidth = _playBar.GetTrackWidth();
if (trackWidth <= 0) return;
float normalizedPosition = Mathf.Clamp01(localPosition.x / trackWidth);
_playBar.SetProgressFromPosition(normalizedPosition);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ea6077d0bdca20a4e954f6ab8a93e349

View File

@@ -45,14 +45,15 @@ namespace SHI.Modal
/// </list>
/// </summary>
[UxmlElement]
public partial class TreeList : VisualElement
public partial class TreeList : VisualElement, IDisposable
{
#region IDisposable
private bool _disposed = false;
#endregion
#region (Constants)
/// <summary>메인 UXML 파일 경로 (Resources 폴더 기준)</summary>
private const string UXML_PATH = "SHI/Modal/TreeList";
/// <summary>개별 항목 템플릿 UXML 파일 경로 (Resources 폴더 기준)</summary>
private const string ITEM_UXML_PATH = "SHI/Modal/TreeListItem";
#endregion
#region UI (UI Component References)
@@ -70,11 +71,7 @@ namespace SHI.Modal
#endregion
#region (Internal Data)
/// <summary>
/// 개별 항목 UI를 생성할 때 사용하는 UXML 템플릿입니다.
/// 생성자에서 한 번만 로드하여 재사용합니다.
/// </summary>
private VisualTreeAsset _itemTemplate;
/// <summary>
/// 원본 루트 데이터입니다.
@@ -106,19 +103,19 @@ namespace SHI.Modal
/// 항목의 가시성(눈 아이콘)이 변경될 때 발생합니다.
/// 3D 모델의 GameObject 활성화/비활성화에 연동합니다.
/// </summary>
public event Action<TreeListItemData> OnVisibilityChanged;
public event Action<TreeListItemData>? OnVisibilityChanged;
/// <summary>
/// 항목 선택 상태가 변경될 때 발생합니다.
/// 선택 및 선택 해제 모두에서 발생하며, item.isSelected로 구분합니다.
/// </summary>
public event Action<TreeListItemData> OnSelectionChanged;
public event Action<TreeListItemData>? OnSelectionChanged;
/// <summary>
/// 트리 리스트가 닫힐 때(숨겨질 때) 발생합니다.
/// 닫기 버튼 클릭 시 트리거됩니다.
/// </summary>
public event Action OnClosed;
public event Action? OnClosed;
#endregion
#region (Constructor)
@@ -138,21 +135,14 @@ namespace SHI.Modal
}
visualTree!.CloneTree(this);
// 2. 항목 템플릿 UXML 로드 (성능을 위해 미리 로드)
_itemTemplate = Resources.Load<VisualTreeAsset>(ITEM_UXML_PATH);
if (_itemTemplate == null)
{
Debug.LogError($"[TreeMenu] Item UXML not found at: {ITEM_UXML_PATH}");
return;
}
// 3. 자식 요소 참조 획득 (UXML의 name 속성으로 찾음)
// 2. 자식 요소 참조 획득 (UXML의 name 속성으로 찾음)
_searchField = this.Q<TextField>("search-field");
_treeView = this.Q<TreeView>("main-tree-view");
_closeButton = this.Q<Button>("hide-btn");
_clearButton = this.Q<Button>("clear-btn");
// 4. 이벤트 연결 및 로직 초기화
// 3. 이벤트 연결 및 로직 초기화
InitializeLogic();
}
#endregion
@@ -166,7 +156,7 @@ namespace SHI.Modal
// 검색창 이벤트: 입력 값이 변경될 때마다 필터링 실행
if (_searchField != null)
{
_searchField.RegisterValueChangedCallback(evt => OnSearch(evt.newValue));
_searchField.RegisterValueChangedCallback(OnSearchValueChanged);
}
// TreeView 설정
@@ -175,7 +165,6 @@ namespace SHI.Modal
// selectionChanged: 선택 변경 시 호출
if (_treeView != null)
{
_treeView.makeItem = MakeTreeItem;
_treeView.bindItem = BindTreeItem;
_treeView.selectionChanged += OnTreeViewSelectionChanged;
}
@@ -249,6 +238,59 @@ namespace SHI.Modal
{
_treeView.SetSelection(new List<int> { itemId });
}
/// <summary>
/// 지정된 이름 목록에 해당하는 항목만 표시하고 나머지는 숨깁니다.
/// </summary>
/// <param name="items">표시할 항목들의 이름 목록</param>
/// <param name="depth">검색 깊이 (1=1뎁스 자식만, 2=2뎁스까지, 0이하=전체)</param>
public void ShowItems(List<string> items, int depth = 1)
{
if (_originalRoots == null || _originalRoots.Count == 0) return;
var visibleNames = new HashSet<string>(items ?? new List<string>());
foreach (var root in _originalRoots)
{
SetVisibilityByNames(root, visibleNames, depth, 0);
}
// UI 갱신
_treeView?.RefreshItems();
}
/// <summary>
/// 재귀적으로 항목의 가시성을 이름 목록에 따라 설정합니다.
/// </summary>
/// <param name="node">현재 노드</param>
/// <param name="visibleNames">표시할 이름 목록</param>
/// <param name="maxDepth">최대 검색 깊이 (0이하=무제한)</param>
/// <param name="currentDepth">현재 깊이</param>
private void SetVisibilityByNames(TreeListItemData node, HashSet<string> visibleNames, int maxDepth, int currentDepth)
{
if (node == null) return;
// maxDepth <= 0 이면 전체 검색, 아니면 깊이 제한 체크
bool isWithinDepth = maxDepth <= 0 || currentDepth < maxDepth;
if (isWithinDepth)
{
// 이름이 목록에 있으면 visible, 없으면 hidden
node.IsVisible = visibleNames.Contains(node.name);
// 가시성 변경 이벤트 발송
OnVisibilityChanged?.Invoke(node);
}
// 자식들도 재귀적으로 처리
if (node.children != null)
{
foreach (var child in node.children)
{
SetVisibilityByNames(child, visibleNames, maxDepth, currentDepth + 1);
}
}
}
#endregion
#region (Selection Handling)
@@ -320,20 +362,8 @@ namespace SHI.Modal
}
#endregion
#region TreeView / (TreeView Item Creation/Binding)
/// <summary>
/// TreeView의 새 행(Row) UI를 생성합니다.
/// TreeView의 makeItem 콜백으로 사용됩니다.
/// 스크롤 시 재사용되는 풀링 시스템에서 호출됩니다.
/// </summary>
/// <returns>항목 템플릿에서 생성된 VisualElement</returns>
private VisualElement MakeTreeItem()
{
// UXML 템플릿을 복제하여 새 항목 생성
var templateContainer = _itemTemplate.Instantiate();
return templateContainer;
}
#region TreeView (TreeView Item Creation/Binding)
/// <summary>
/// 데이터를 UI 요소에 바인딩합니다.
/// TreeView의 bindItem 콜백으로 사용됩니다.
@@ -419,6 +449,15 @@ namespace SHI.Modal
#endregion
#region (Search Functionality)
/// <summary>
/// 검색 필드 값 변경 콜백입니다.
/// </summary>
/// <param name="evt">값 변경 이벤트</param>
private void OnSearchValueChanged(ChangeEvent<string> evt)
{
OnSearch(evt.newValue);
}
/// <summary>
/// 검색어에 따라 트리를 필터링합니다.
/// 검색어가 비어있으면 원본 데이터로 복원됩니다.
@@ -536,5 +575,50 @@ namespace SHI.Modal
}
}
#endregion
#region IDisposable
/// <summary>
/// 리소스를 해제하고 이벤트 핸들러를 정리합니다.
/// </summary>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
// 검색 필드 이벤트 해제
if (_searchField != null)
{
_searchField.UnregisterValueChangedCallback(OnSearchValueChanged);
}
// TreeView 이벤트 핸들러 해제
if (_treeView != null)
{
_treeView.selectionChanged -= OnTreeViewSelectionChanged;
_treeView.bindItem = null;
_treeView.makeItem = null;
}
// 외부 이벤트 구독자 정리
OnVisibilityChanged = null;
OnSelectionChanged = null;
OnClosed = null;
// 데이터 정리
_originalRoots.Clear();
_rootData?.Clear();
_rootData = null;
_previouslySelectedItem = null;
// ID 시드 초기화
_idSeed = 1;
// UI 참조 정리
_searchField = null;
_treeView = null;
_closeButton = null;
_clearButton = null;
}
#endregion
}
}