chart 완료
This commit is contained in:
@@ -21,7 +21,7 @@ namespace SHI.modal
|
||||
[SerializeField] private Button closeButton;
|
||||
[SerializeField] private ModelDetailListView listView;
|
||||
[SerializeField] private ModelDetailView modelView;
|
||||
[SerializeField] private ModelDetailChartView chartView;
|
||||
[SerializeField] private ModelDetailISOPChartView chartView;
|
||||
|
||||
[Header("UI Controls")]
|
||||
[SerializeField] private Button modelViewExpandButton;
|
||||
@@ -48,6 +48,8 @@ namespace SHI.modal
|
||||
private readonly Dictionary<string, Guid> _keyToId = new Dictionary<string, Guid>();
|
||||
private readonly Dictionary<Guid, string> _idToKey = new Dictionary<Guid, string>();
|
||||
|
||||
private Guid selectedItemId = Guid.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// UI 이벤트를 연결하고 스플리터를 준비합니다.
|
||||
/// </summary>
|
||||
@@ -147,6 +149,14 @@ namespace SHI.modal
|
||||
HandleSelection(id, "ChartView");
|
||||
}
|
||||
|
||||
public void OnClickExport()
|
||||
{
|
||||
if(selectedItemId != Guid.Empty && modelView != null)
|
||||
{
|
||||
modelView.Export(selectedItemId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// LoadData 호출 시 리스트/모델/차트를 활성화하고 분할 비율을0.5/0.5로 초기화합니다.
|
||||
/// </summary>
|
||||
@@ -294,12 +304,13 @@ namespace SHI.modal
|
||||
_suppressSelection = true;
|
||||
try
|
||||
{
|
||||
selectedItemId = itemId;
|
||||
if (source != "ListView" && listView != null) listView.SelectByItemId(itemId);
|
||||
if (source != "ModelView" && modelView != null) modelView.FocusItemById(itemId);
|
||||
if (source != "ChartView" && chartView != null)
|
||||
{
|
||||
if (_idToKey.TryGetValue(itemId, out var key)) chartView.SelectByItemKey(key);
|
||||
else chartView.SelectByItemId(itemId);
|
||||
//if (_idToKey.TryGetValue(itemId, out var key)) chartView.SelectByItemKey(key);
|
||||
//else chartView.SelectByItemId(itemId);
|
||||
}
|
||||
}
|
||||
finally
|
||||
|
||||
8
Assets/Scripts/SHI/modal/ISOP.meta
Normal file
8
Assets/Scripts/SHI/modal/ISOP.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 180c1309a0dc48c489dd21fad655cf7b
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
81
Assets/Scripts/SHI/modal/ISOP/HorizontalDragManipulator.cs
Normal file
81
Assets/Scripts/SHI/modal/ISOP/HorizontalDragManipulator.cs
Normal file
@@ -0,0 +1,81 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
|
||||
namespace SHI.Modal.ISOP
|
||||
{
|
||||
public class HorizontalDragManipulator : PointerManipulator
|
||||
{
|
||||
private bool _isActive;
|
||||
private Vector3 _startPointerPosition;
|
||||
private float _startLeftStyle; // 시작할 때의 left 값
|
||||
|
||||
public HorizontalDragManipulator()
|
||||
{
|
||||
_isActive = false;
|
||||
activators.Add(new ManipulatorActivationFilter { button = MouseButton.LeftMouse });
|
||||
}
|
||||
|
||||
protected override void RegisterCallbacksOnTarget()
|
||||
{
|
||||
target.RegisterCallback<PointerDownEvent>(OnPointerDown, TrickleDown.TrickleDown);
|
||||
target.RegisterCallback<PointerMoveEvent>(OnPointerMove, TrickleDown.TrickleDown);
|
||||
target.RegisterCallback<PointerUpEvent>(OnPointerUp, TrickleDown.TrickleDown);
|
||||
}
|
||||
|
||||
protected override void UnregisterCallbacksFromTarget()
|
||||
{
|
||||
target.UnregisterCallback<PointerDownEvent>(OnPointerDown);
|
||||
target.UnregisterCallback<PointerMoveEvent>(OnPointerMove);
|
||||
target.UnregisterCallback<PointerUpEvent>(OnPointerUp);
|
||||
}
|
||||
|
||||
private void OnPointerDown(PointerDownEvent evt)
|
||||
{
|
||||
if (_isActive) return;
|
||||
|
||||
// 1. 클릭 시 Absolute 포지션으로 변경하여 레이아웃 간섭 제거
|
||||
// (주의: 원래 레이아웃 흐름에서 빠져나오므로 디자인이 살짝 튈 수 있음)
|
||||
if (target.style.position != Position.Absolute)
|
||||
{
|
||||
// 현재 위치를 기준으로 Absolute 좌표 설정 (좌표 튀는 것 방지)
|
||||
target.style.left = target.layout.x;
|
||||
target.style.top = target.layout.y;
|
||||
target.style.position = Position.Absolute;
|
||||
}
|
||||
|
||||
_isActive = true;
|
||||
target.CapturePointer(evt.pointerId); // 마우스 캡처
|
||||
|
||||
_startPointerPosition = evt.position;
|
||||
_startLeftStyle = target.layout.x; // 현재 x 좌표 저장
|
||||
|
||||
evt.StopPropagation();
|
||||
}
|
||||
|
||||
private void OnPointerMove(PointerMoveEvent evt)
|
||||
{
|
||||
if (!_isActive || !target.HasPointerCapture(evt.pointerId)) return;
|
||||
|
||||
// 2. 이동 거리 계산 (전체 화면 기준)
|
||||
Vector3 delta = evt.position - _startPointerPosition;
|
||||
|
||||
// 3. 부모의 스케일 등을 고려한 좌표 보정 (안전장치)
|
||||
// 보통 UI Toolkit은 픽셀 단위라 1:1이지만, 복잡한 계층에선 필요할 수 있음
|
||||
float newX = _startLeftStyle + delta.x;
|
||||
|
||||
// 4. 스타일 적용 (X축만 이동)
|
||||
target.style.left = newX;
|
||||
// Y축은 건드리지 않음 (Absolute 변경 시점의 Top 유지)
|
||||
}
|
||||
|
||||
private void OnPointerUp(PointerUpEvent evt)
|
||||
{
|
||||
if (_isActive)
|
||||
{
|
||||
_isActive = false;
|
||||
target.ReleasePointer(evt.pointerId);
|
||||
evt.StopPropagation();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 08f2e48e1010a794a93bb6ff0d2b6ab4
|
||||
357
Assets/Scripts/SHI/modal/ISOP/ISOPChart.cs
Normal file
357
Assets/Scripts/SHI/modal/ISOP/ISOPChart.cs
Normal file
@@ -0,0 +1,357 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Unity.VisualScripting;
|
||||
using UnityEngine;
|
||||
using UnityEngine.TextCore.Text;
|
||||
using UnityEngine.UIElements;
|
||||
using UVC.Util;
|
||||
|
||||
namespace SHI.Modal.ISOP
|
||||
{
|
||||
[UxmlElement]
|
||||
public partial class ISOPChart : VisualElement
|
||||
{
|
||||
|
||||
// 리소스 경로 상수
|
||||
private const string UXML_PATH = "SHI/Modal/ISOP/ISOPChart"; // Resources 폴더 기준 경로
|
||||
private const string ROW_UXML_PATH = "SHI/Modal/ISOP/ISOPChartRow"; // Resources 폴더 기준 경로
|
||||
|
||||
/// <summary>
|
||||
/// 모델 뷰가 확장 요청될 때 발생합니다.
|
||||
/// </summary>
|
||||
public Action? OnExpand;
|
||||
|
||||
private Button? _expandBtn;
|
||||
|
||||
private VisualTreeAsset taskRowTemplate; // row with 8 cells
|
||||
private Color planColor = new Color(0.2f, 0.4f, 0.8f, 0.8f);
|
||||
private Color actualColor = new Color(0.2f, 0.9f, 0.3f, 0.9f);
|
||||
private float pixelsPerDay = 18f; // day cell width
|
||||
|
||||
private VisualElement root;
|
||||
|
||||
private VisualElement headerTitle;
|
||||
private VisualElement headerTimeline;
|
||||
private VisualElement monthsLayer;
|
||||
private VisualElement weeksLayer;
|
||||
private VisualElement daysLayer;
|
||||
private VisualElement timelineContent;
|
||||
private ScrollView? contentScroll;
|
||||
private List<ISOPChartDataTask> tasks;
|
||||
private int maxL;
|
||||
private DateTime projectStartDate;
|
||||
private DateTime projectEndDate;
|
||||
private int totalDays;
|
||||
|
||||
|
||||
public ISOPChart()
|
||||
{
|
||||
// UXML 로드
|
||||
var visualTree = Resources.Load<VisualTreeAsset>(UXML_PATH);
|
||||
if (visualTree != null)
|
||||
{
|
||||
visualTree.CloneTree(this);
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogError($"Failed to load UXML at path: {UXML_PATH}");
|
||||
}
|
||||
|
||||
taskRowTemplate = Resources.Load<VisualTreeAsset>(ROW_UXML_PATH);
|
||||
if (taskRowTemplate == null)
|
||||
{
|
||||
Debug.LogError($"[TreeMenu] Item UXML not found at: {ROW_UXML_PATH}");
|
||||
return;
|
||||
}
|
||||
|
||||
_expandBtn = this.Q<Button>("expand-btn");
|
||||
if (_expandBtn != null)
|
||||
{
|
||||
_expandBtn.clicked += () =>
|
||||
{
|
||||
OnExpand?.Invoke();
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public void Load(string jsonFileName)
|
||||
{
|
||||
root = this;
|
||||
|
||||
LoadData(jsonFileName);
|
||||
CalculateProjectRange();
|
||||
InitializeUI();
|
||||
SetupThreeTierHeader();
|
||||
RenderTasks();
|
||||
}
|
||||
|
||||
void LoadData(string jsonFileName)
|
||||
{
|
||||
var json = File.ReadAllText(jsonFileName);
|
||||
var wrapper = JsonUtility.FromJson<ISOPChartDataWrapper>("{\"items\":" + json + "}");
|
||||
tasks = wrapper.items.Where(t => !string.IsNullOrEmpty(t.STDT21) && t.STDT21 != "null").ToList();
|
||||
foreach (var task in tasks)
|
||||
task.CalculatedProgress = CalculateProgress(task);
|
||||
|
||||
maxL = CalculateMaxLevel();
|
||||
}
|
||||
|
||||
int CalculateMaxLevel()
|
||||
{
|
||||
if (tasks.Any(t => !string.IsNullOrEmpty(t.L8))) return 8;
|
||||
if (tasks.Any(t => !string.IsNullOrEmpty(t.L7))) return 7;
|
||||
if (tasks.Any(t => !string.IsNullOrEmpty(t.L6))) return 6;
|
||||
if (tasks.Any(t => !string.IsNullOrEmpty(t.L5))) return 5;
|
||||
if (tasks.Any(t => !string.IsNullOrEmpty(t.L4))) return 4;
|
||||
if (tasks.Any(t => !string.IsNullOrEmpty(t.L3))) return 3;
|
||||
if (tasks.Any(t => !string.IsNullOrEmpty(t.L2))) return 2;
|
||||
if (tasks.Any(t => !string.IsNullOrEmpty(t.L1))) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
void CalculateProjectRange()
|
||||
{
|
||||
var dates = new List<DateTime>();
|
||||
foreach (var task in tasks)
|
||||
{
|
||||
if (task.GetPlanStart().HasValue) dates.Add(task.GetPlanStart().Value);
|
||||
if (task.GetPlanEnd().HasValue) dates.Add(task.GetPlanEnd().Value);
|
||||
if (task.GetActualStart().HasValue) dates.Add(task.GetActualStart().Value);
|
||||
if (task.GetActualEnd().HasValue) dates.Add(task.GetActualEnd().Value);
|
||||
}
|
||||
projectStartDate = dates.Any() ? dates.Min().AddDays(-3) : new DateTime(2025, 1, 6);
|
||||
projectEndDate = dates.Any() ? dates.Max().AddDays(3) : new DateTime(2025, 2, 28);
|
||||
totalDays = (int)(projectEndDate - projectStartDate).TotalDays + 1;
|
||||
}
|
||||
|
||||
void InitializeUI()
|
||||
{
|
||||
|
||||
headerTitle = root.Q<VisualElement>("header-title");
|
||||
headerTitle.style.width = maxL * 40;
|
||||
|
||||
timelineContent = root.Q<VisualElement>("content");
|
||||
|
||||
headerTimeline = root.Q<VisualElement>("header-timeline");
|
||||
headerTimeline.style.width = totalDays * pixelsPerDay;
|
||||
|
||||
monthsLayer = headerTimeline.Q<VisualElement>("timeline-months");
|
||||
weeksLayer = headerTimeline.Q<VisualElement>("timeline-weeks");
|
||||
daysLayer = headerTimeline.Q<VisualElement>("timeline-days");
|
||||
|
||||
// ScrollView 가로 스크롤 시 header-timeline 동기화
|
||||
contentScroll = root.Q<ScrollView>("content-scroll");
|
||||
if (contentScroll != null)
|
||||
{
|
||||
contentScroll.horizontalScroller.valueChanged += OnHorizontalScroll;
|
||||
}
|
||||
}
|
||||
|
||||
void OnHorizontalScroll(float scrollValue)
|
||||
{
|
||||
// header 전체를 스크롤 (header-title + header-timeline)
|
||||
var header = root.Q<VisualElement>("header");
|
||||
if (header != null)
|
||||
{
|
||||
header.style.left = -scrollValue;
|
||||
}
|
||||
}
|
||||
|
||||
void SetupThreeTierHeader()
|
||||
{
|
||||
// Background base grid style color
|
||||
Color lineColor = ColorUtil.FromHex("#CACACA"); // light blue lines
|
||||
|
||||
monthsLayer.Clear();
|
||||
weeksLayer.Clear();
|
||||
daysLayer.Clear();
|
||||
|
||||
foreach (var layer in new[] { monthsLayer, weeksLayer, daysLayer })
|
||||
{
|
||||
layer.style.width = totalDays * pixelsPerDay;
|
||||
}
|
||||
|
||||
DateTime cursor = projectStartDate;
|
||||
while (cursor <= projectEndDate)
|
||||
{
|
||||
DateTime monthStart = new DateTime(cursor.Year, cursor.Month, 1);
|
||||
DateTime nextMonthStart = monthStart.AddMonths(1);
|
||||
DateTime segmentStart = cursor; // first day we actually display within month
|
||||
DateTime segmentEnd = nextMonthStart.AddDays(-1) < projectEndDate ? nextMonthStart.AddDays(-1) : projectEndDate;
|
||||
int startIndex = (int)(segmentStart - projectStartDate).TotalDays;
|
||||
int spanDays = (int)(segmentEnd - segmentStart).TotalDays + 1;
|
||||
AddMonthLabel(monthsLayer, segmentStart.Year, segmentStart.Month, startIndex, spanDays, lineColor);
|
||||
|
||||
// Weeks inside month
|
||||
DateTime weekCursor = segmentStart;
|
||||
while (weekCursor <= segmentEnd)
|
||||
{
|
||||
DateTime weekStart = weekCursor;
|
||||
// 1일이 속한 주를 1주차로, 일요일 기준으로 주차 계산
|
||||
DateTime monthFirstDay = new DateTime(weekStart.Year, weekStart.Month, 1);
|
||||
int firstDayOfWeek = (int)monthFirstDay.DayOfWeek; // 0=일요일, 1=월요일...
|
||||
int weekOfMonth = ((weekStart.Day + firstDayOfWeek - 1) / 7) + 1;
|
||||
// Keep the existing segmentation logic (end at Sunday or month end)
|
||||
DateTime tentativeEnd = weekStart.AddDays(7 - (int)weekStart.DayOfWeek); // until Sunday
|
||||
DateTime weekEnd = tentativeEnd > segmentEnd ? segmentEnd : tentativeEnd;
|
||||
int weekStartIndex = (int)(weekStart - projectStartDate).TotalDays;
|
||||
int weekSpanDays = (int)(weekEnd - weekStart).TotalDays + 1;
|
||||
AddWeekLabel(weeksLayer, weekOfMonth, weekStartIndex, weekSpanDays, lineColor);
|
||||
weekCursor = weekEnd.AddDays(1);
|
||||
}
|
||||
cursor = segmentEnd.AddDays(1);
|
||||
}
|
||||
|
||||
// Days line
|
||||
for (int i = 0; i < totalDays; i++)
|
||||
{
|
||||
DateTime date = projectStartDate.AddDays(i);
|
||||
AddDayCell(daysLayer, date.Day, i, lineColor);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void AddMonthLabel(VisualElement parent, int year, int month, int startIndex, int spanDays, Color lineColor)
|
||||
{
|
||||
var ve = new VisualElement();
|
||||
ve.style.width = spanDays * pixelsPerDay;
|
||||
ve.style.height = pixelsPerDay;
|
||||
ve.style.borderRightColor = lineColor;
|
||||
ve.style.borderRightWidth = 1;
|
||||
var lab = new Label($"{year}년 {month}월");
|
||||
lab.style.unityTextAlign = TextAnchor.MiddleCenter;
|
||||
lab.style.unityFontStyleAndWeight = FontStyle.Normal;
|
||||
lab.style.width = Length.Percent(100);
|
||||
lab.style.height = Length.Percent(100);
|
||||
lab.style.fontSize = 10;
|
||||
lab.style.marginBottom = 0;
|
||||
lab.style.marginLeft = 0;
|
||||
lab.style.marginRight = 0;
|
||||
lab.style.marginTop = 0;
|
||||
lab.style.paddingBottom = 0;
|
||||
lab.style.paddingLeft = 0;
|
||||
lab.style.paddingRight = 0;
|
||||
lab.style.paddingTop = 0;
|
||||
ve.Add(lab);
|
||||
parent.Add(ve);
|
||||
}
|
||||
|
||||
void AddWeekLabel(VisualElement parent, int weekOfMonth, int startIndex, int spanDays, Color lineColor)
|
||||
{
|
||||
var ve = new VisualElement();
|
||||
ve.style.width = spanDays * pixelsPerDay;
|
||||
ve.style.height = pixelsPerDay;
|
||||
ve.style.borderRightColor = lineColor;
|
||||
ve.style.borderRightWidth = 1;
|
||||
var lab = new Label($"{weekOfMonth}주");
|
||||
lab.style.unityTextAlign = TextAnchor.MiddleCenter;
|
||||
lab.style.width = Length.Percent(100);
|
||||
lab.style.height = Length.Percent(100);
|
||||
lab.style.fontSize = 10;
|
||||
lab.style.marginBottom = 0;
|
||||
lab.style.marginLeft = 0;
|
||||
lab.style.marginRight = 0;
|
||||
lab.style.marginTop = 0;
|
||||
lab.style.paddingBottom = 0;
|
||||
lab.style.paddingLeft = 0;
|
||||
lab.style.paddingRight = 0;
|
||||
lab.style.paddingTop = 0;
|
||||
ve.Add(lab);
|
||||
parent.Add(ve);
|
||||
}
|
||||
|
||||
void AddDayCell(VisualElement parent, int day, int dayIndex, Color lineColor)
|
||||
{
|
||||
var ve = new VisualElement();
|
||||
ve.style.width = pixelsPerDay;
|
||||
ve.style.height = pixelsPerDay;
|
||||
ve.style.borderRightColor = lineColor;
|
||||
ve.style.borderRightWidth = 1;
|
||||
var lab = new Label(day.ToString("00"));
|
||||
lab.style.unityTextAlign = TextAnchor.MiddleCenter;
|
||||
lab.style.width = Length.Percent(100);
|
||||
lab.style.height = Length.Percent(100);
|
||||
lab.style.fontSize = 10;
|
||||
lab.style.marginBottom = 0;
|
||||
lab.style.marginLeft = 0;
|
||||
lab.style.marginRight = 0;
|
||||
lab.style.marginTop = 0;
|
||||
lab.style.paddingBottom = 0;
|
||||
lab.style.paddingLeft = 0;
|
||||
lab.style.paddingRight = 0;
|
||||
lab.style.paddingTop = 0;
|
||||
ve.Add(lab);
|
||||
parent.Add(ve);
|
||||
}
|
||||
|
||||
void RenderTasks()
|
||||
{
|
||||
if (timelineContent == null) return;
|
||||
timelineContent.Clear();
|
||||
for (int i = 0; i < tasks.Count; i++)
|
||||
{
|
||||
var task = tasks[i];
|
||||
|
||||
var row = taskRowTemplate.CloneTree();
|
||||
row.style.height = 40; // TemplateContainer 높이 설정
|
||||
row.style.flexShrink = 0;
|
||||
row.style.flexGrow = 0;
|
||||
row.style.flexBasis = new StyleLength(StyleKeyword.Auto);
|
||||
VisualElement lContainer = row.Q<VisualElement>("task-l");
|
||||
Label txt = row.Q<Label>("task-txt");
|
||||
txt.text = task.L1;
|
||||
for (int j = 2; j <= maxL; j++)
|
||||
{
|
||||
string text = j == 2 ? task.L2 : j == 3 ? task.L3 : j == 4 ? task.L4 : j == 5 ? task.L5 : j == 6 ? task.L6 : j == 7 ? task.L7 : task.L8;
|
||||
if (string.IsNullOrEmpty(text)) text = "";
|
||||
var label = new Label($"{text}");
|
||||
label.name = $"c{j}";
|
||||
label.AddToClassList("task-txt");
|
||||
lContainer.Add(label);
|
||||
}
|
||||
|
||||
VisualElement taskCell = row.Q<VisualElement>("task-cell");
|
||||
taskCell.style.width = totalDays * pixelsPerDay;
|
||||
|
||||
if (task.GetPlanStart().HasValue && task.GetPlanEnd().HasValue)
|
||||
taskCell.Add(CreateBar(task, task.GetPlanStart().Value, task.GetPlanEnd().Value, planColor, 4));
|
||||
if (task.GetActualStart().HasValue && task.GetActualEnd().HasValue)
|
||||
taskCell.Add(CreateBar(task, task.GetActualStart().Value, task.GetActualEnd().Value, actualColor, 10));
|
||||
|
||||
timelineContent.Add(row);
|
||||
}
|
||||
}
|
||||
|
||||
VisualElement CreateBar(ISOPChartDataTask task, DateTime start, DateTime end, Color color, int top)
|
||||
{
|
||||
var bar = new VisualElement();
|
||||
bar.style.position = Position.Absolute;
|
||||
bar.style.backgroundColor = color;
|
||||
bar.style.height = 6;
|
||||
bar.style.top = top;
|
||||
float startX = (float)(start - projectStartDate).TotalDays * pixelsPerDay;
|
||||
float width = Mathf.Max((float)(end - start).TotalDays * pixelsPerDay, 6);
|
||||
bar.style.left = startX;
|
||||
bar.style.width = width;
|
||||
bar.tooltip = $"{task.GetDisplayName()}\n{start:MM/dd} ~ {end:MM/dd}";
|
||||
return bar;
|
||||
}
|
||||
|
||||
float CalculateProgress(ISOPChartDataTask task)
|
||||
{
|
||||
var planStart = task.GetPlanStart(); var planEnd = task.GetPlanEnd(); var actualEnd = task.GetActualEnd();
|
||||
if (!planStart.HasValue || !planEnd.HasValue) return 0;
|
||||
float total = (float)(planEnd.Value - planStart.Value).TotalDays; if (total <= 0) return 0;
|
||||
if (actualEnd.HasValue)
|
||||
{ float actualDays = (float)(actualEnd.Value - planStart.Value).TotalDays; return Mathf.Clamp(actualDays / total * 100, 0, 100); }
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/SHI/modal/ISOP/ISOPChart.cs.meta
Normal file
2
Assets/Scripts/SHI/modal/ISOP/ISOPChart.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ac010f8de2957a142b8b9b6cd432523a
|
||||
76
Assets/Scripts/SHI/modal/ISOP/ISOPChartData.cs
Normal file
76
Assets/Scripts/SHI/modal/ISOP/ISOPChartData.cs
Normal file
@@ -0,0 +1,76 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace SHI.Modal.ISOP
|
||||
{
|
||||
|
||||
[Serializable]
|
||||
public class ISOPChartDataTask
|
||||
{
|
||||
// 기본 정보
|
||||
public string PROJ_NO;
|
||||
public string BLK_NO;
|
||||
public string L1;
|
||||
public string L2;
|
||||
public string L3;
|
||||
public string L4;
|
||||
public string L5;
|
||||
public string L6;
|
||||
public string L7;
|
||||
public string L8;
|
||||
public string SHIP_TYPE;
|
||||
|
||||
// 계획 일정 (파란색 막대)
|
||||
public string STDT21;
|
||||
public string FNDT21;
|
||||
public int DUR21;
|
||||
|
||||
// 실적 일정 (연두색 막대)
|
||||
public string STDT23;
|
||||
public string FNDT23;
|
||||
public int DUR23;
|
||||
|
||||
// 추가 작업 코드들...
|
||||
public string STDT43, FNDT43; public int DUR43;
|
||||
public string STDT44, FNDT44; public int DUR44;
|
||||
public string STDT46, FNDT46; public int DUR46;
|
||||
public string STDT49, FNDT49; public int DUR49;
|
||||
public string STDT4A, FNDT4A; public int DUR4A;
|
||||
public string STDT4B, FNDT4B; public int DUR4B;
|
||||
public string STDT62, FNDT62; public int DUR62;
|
||||
|
||||
[NonSerialized] public float CalculatedProgress;
|
||||
|
||||
public DateTime? GetPlanStart() => ParseDate(STDT21);
|
||||
public DateTime? GetPlanEnd() => ParseDate(FNDT21);
|
||||
public DateTime? GetActualStart() => ParseDate(STDT23);
|
||||
public DateTime? GetActualEnd() => ParseDate(FNDT23);
|
||||
|
||||
private DateTime? ParseDate(string dateStr)
|
||||
{
|
||||
if (string.IsNullOrEmpty(dateStr) || dateStr == "null") return null;
|
||||
if (DateTime.TryParseExact(dateStr, "yyyyMMdd", null, System.Globalization.DateTimeStyles.None, out DateTime date))
|
||||
return date;
|
||||
return null;
|
||||
}
|
||||
|
||||
public string GetDisplayName()
|
||||
{
|
||||
// L1~L8 중 가장 하위 유효값
|
||||
if (!string.IsNullOrEmpty(L8)) return L8;
|
||||
if (!string.IsNullOrEmpty(L7)) return L7;
|
||||
if (!string.IsNullOrEmpty(L6)) return L6;
|
||||
if (!string.IsNullOrEmpty(L5)) return L5;
|
||||
if (!string.IsNullOrEmpty(L4)) return L4;
|
||||
if (!string.IsNullOrEmpty(L3)) return L3;
|
||||
if (!string.IsNullOrEmpty(L2)) return L2;
|
||||
return L1 ?? BLK_NO;
|
||||
}
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class ISOPChartDataWrapper
|
||||
{
|
||||
public List<ISOPChartDataTask> items;
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/SHI/modal/ISOP/ISOPChartData.cs.meta
Normal file
2
Assets/Scripts/SHI/modal/ISOP/ISOPChartData.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2467fbb597fa4704598d2600880aaa78
|
||||
586
Assets/Scripts/SHI/modal/ISOP/ISOPModal.cs
Normal file
586
Assets/Scripts/SHI/modal/ISOP/ISOPModal.cs
Normal file
@@ -0,0 +1,586 @@
|
||||
#nullable enable
|
||||
using Cysharp.Threading.Tasks;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
|
||||
namespace SHI.Modal.ISOP
|
||||
{
|
||||
public class ISOPModal : MonoBehaviour
|
||||
{
|
||||
[SerializeField]
|
||||
public UIDocument uiDocument;
|
||||
|
||||
private VisualElement content;
|
||||
|
||||
private TreeList listView;
|
||||
private ISOPModelView modelView;
|
||||
private ISOPChart chartView;
|
||||
|
||||
private Button closeBtn;
|
||||
private Button showTreeBtn;
|
||||
private Button dragBtn;
|
||||
|
||||
private CancellationTokenSource? _cts;
|
||||
private bool _suppressSelection = false;
|
||||
|
||||
// key<->id 매핑(차트-리스트/모델 동기화)
|
||||
private readonly Dictionary<string, int> _keyToId = new Dictionary<string, int>();
|
||||
private readonly Dictionary<int, string> _idToKey = new Dictionary<int, string>();
|
||||
|
||||
private int selectedItemId = -1;
|
||||
|
||||
private enum ExpandedSide { None, Model, Chart }
|
||||
private ExpandedSide _expanded = ExpandedSide.None;
|
||||
|
||||
// 드래그 상태 저장
|
||||
private bool _isDragging = false;
|
||||
private int _activePointerId = -1;
|
||||
private float _dragOffset = 0f; // 포인터와 drag 버튼 중심 간 오프셋
|
||||
|
||||
// 확장 전 비율 저장
|
||||
private float _lastModelFlexGrow = 1f;
|
||||
private float _lastChartFlexGrow = 1f;
|
||||
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
var root = uiDocument.rootVisualElement;
|
||||
|
||||
content = root.Q<VisualElement>("content");
|
||||
|
||||
// UXML에서 <TreeList> 태그로 추가했다면 Query로 찾음
|
||||
listView = root.Q<TreeList>();
|
||||
|
||||
if (listView != null)
|
||||
{
|
||||
listView.OnSelectionChanged += OnListItemSelectionChanged;
|
||||
listView.OnClosed += OnListClosed;
|
||||
listView.OnVisibilityChanged += OnListVisibilityChanged;
|
||||
|
||||
// listView가 다른 요소 위에 표시되도록 설정
|
||||
listView.style.unityOverflowClipBox = OverflowClipBox.ContentBox;
|
||||
|
||||
// 더미 데이터 생성
|
||||
var data = GenerateDummyData();
|
||||
listView.SetData(data);
|
||||
}
|
||||
|
||||
modelView = root.Q<ISOPModelView>();
|
||||
if (modelView != null)
|
||||
{
|
||||
// 선택 동기화: 모델 -> 리스트/차트
|
||||
modelView.OnItemSelected += OnModelItemSelected;
|
||||
modelView.OnExpand += ToggleExpandModel;
|
||||
|
||||
// modelView의 내용이 범위를 벗어나지 않도록 overflow 설정
|
||||
modelView.style.overflow = Overflow.Hidden;
|
||||
}
|
||||
|
||||
|
||||
chartView = root.Q<ISOPChart>();
|
||||
if (chartView != null)
|
||||
{
|
||||
chartView.OnExpand += ToggleExpandChart;
|
||||
}
|
||||
|
||||
showTreeBtn = root.Q<Button>("show-tree-btn");
|
||||
|
||||
if (showTreeBtn != null)
|
||||
{
|
||||
|
||||
showTreeBtn.clicked += OnClickShowTree;
|
||||
|
||||
showTreeBtn.style.display = DisplayStyle.None;
|
||||
}
|
||||
|
||||
closeBtn = root.Q<Button>("closeButton");
|
||||
if (closeBtn != null)
|
||||
{
|
||||
closeBtn.clicked += OnClickClose;
|
||||
}
|
||||
|
||||
initDrag(root);
|
||||
|
||||
_expanded = ExpandedSide.None;
|
||||
}
|
||||
|
||||
private void initDrag(VisualElement root)
|
||||
{
|
||||
dragBtn = root.Q<Button>("drag-btn");
|
||||
if (dragBtn != null)
|
||||
{
|
||||
// modelView의 위치와 크기가 결정된 후에 dragBtn의 left를 계산해서 설정
|
||||
if (modelView != null)
|
||||
{
|
||||
// dragBtn이 절대 위치로 움직일 수 있도록 설정
|
||||
dragBtn.style.position = Position.Absolute;
|
||||
dragBtn.pickingMode = PickingMode.Position;
|
||||
|
||||
//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);
|
||||
|
||||
|
||||
// 초기화 및 레이아웃 변경 시 재계산
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
if(modelView == null)
|
||||
{
|
||||
//대기
|
||||
while(modelView == null)
|
||||
{
|
||||
await UniTask.Yield();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
Debug.Log($"ISOPModal: LoadData {gltfPath}");
|
||||
|
||||
|
||||
// 이전 작업 취소
|
||||
if (_cts != null)
|
||||
{
|
||||
try { _cts.Cancel(); } catch { }
|
||||
_cts.Dispose();
|
||||
}
|
||||
_cts = CancellationTokenSource.CreateLinkedTokenSource(externalCt);
|
||||
var ct = _cts.Token;
|
||||
|
||||
// 모델/리스트 로드
|
||||
IEnumerable<TreeListItemData> items = Array.Empty<TreeListItemData>();
|
||||
if (modelView != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
items = await modelView.LoadModelAsync(gltfPath, ct);
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
}
|
||||
|
||||
BuildKeyMaps(items);
|
||||
|
||||
if (listView != null) listView.SetData(items.ToList());
|
||||
if (chartView != null) chartView.Load(ganttPath);
|
||||
}
|
||||
|
||||
private void BuildKeyMaps(IEnumerable<TreeListItemData> items)
|
||||
{
|
||||
_keyToId.Clear();
|
||||
_idToKey.Clear();
|
||||
if (items == null) return;
|
||||
|
||||
var stack = new Stack<TreeListItemData>();
|
||||
foreach (var it in items)
|
||||
{
|
||||
if (it == null) continue;
|
||||
stack.Push(it);
|
||||
while (stack.Count > 0)
|
||||
{
|
||||
var cur = stack.Pop();
|
||||
if (!string.IsNullOrEmpty(cur.ExternalKey))
|
||||
{
|
||||
_keyToId[cur.ExternalKey] = cur.id;
|
||||
_idToKey[cur.id] = cur.ExternalKey;
|
||||
}
|
||||
if (cur.children != null)
|
||||
{
|
||||
for (int i = 0; i < cur.children.Count; i++)
|
||||
{
|
||||
var child = cur.children[i] as TreeListItemData;
|
||||
if (child != null) stack.Push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnClickClose()
|
||||
{
|
||||
this.gameObject.SetActive(false);
|
||||
}
|
||||
|
||||
private void OnClickShowTree()
|
||||
{
|
||||
if (listView != null)
|
||||
{
|
||||
listView.Show();
|
||||
showTreeBtn.style.display = DisplayStyle.None;
|
||||
//1frame 후에 위치 갱신
|
||||
content.schedule.Execute(() =>
|
||||
{
|
||||
UpdateDragAndPanels(content, dragBtn);
|
||||
}).ExecuteLater(1);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnListItemSelectionChanged(TreeListItemData data)
|
||||
{
|
||||
if (data == null) return;
|
||||
Debug.Log($"ISOPModal.OnListItemSelectionChanged: id={data.id}, isSelected={data.isSelected}");
|
||||
if (data.isSelected)
|
||||
{
|
||||
HandleSelection(data.id, "ListView");
|
||||
}
|
||||
else
|
||||
{
|
||||
HandleDeselection(data.id, "ListView");
|
||||
}
|
||||
}
|
||||
|
||||
private void OnListClosed()
|
||||
{
|
||||
showTreeBtn.style.display = DisplayStyle.Flex;
|
||||
UpdateDragAndPanels(content, dragBtn);
|
||||
}
|
||||
|
||||
private void OnListVisibilityChanged(TreeListItemData itemData)
|
||||
{
|
||||
if (modelView != null) modelView.SetVisibility(itemData.id, itemData.IsVisible);
|
||||
}
|
||||
|
||||
private void OnModelItemSelected(TreeListItemData data)
|
||||
{
|
||||
if (data == null) return;
|
||||
HandleSelection(data.id, "ModelView");
|
||||
}
|
||||
|
||||
private void HandleSelection(int itemId, string source)
|
||||
{
|
||||
if (_suppressSelection) return;
|
||||
_suppressSelection = true;
|
||||
try
|
||||
{
|
||||
selectedItemId = itemId;
|
||||
if (source != "ListView" && listView != null) listView.SelectByItemId(itemId);
|
||||
if (source != "ModelView" && modelView != null) modelView.FocusItemById(itemId);
|
||||
if (source != "ChartView" && chartView != null)
|
||||
{
|
||||
//if (_idToKey.TryGetValue(itemId, out var key)) chartView.SelectByItemKey(key);
|
||||
//else chartView.SelectByItemId(itemId);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_suppressSelection = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleDeselection(int itemId, string source)
|
||||
{
|
||||
if (_suppressSelection) return;
|
||||
if (source != "ModelView" && modelView != null) modelView.UnfocusItem();
|
||||
}
|
||||
|
||||
private void ToggleExpandModel()
|
||||
{
|
||||
if (_expanded == ExpandedSide.Model)
|
||||
{
|
||||
// 확장 해제: 저장된 비율로 복원
|
||||
_expanded = ExpandedSide.None;
|
||||
modelView.style.display = DisplayStyle.Flex;
|
||||
chartView.style.display = DisplayStyle.Flex;
|
||||
dragBtn.style.display = DisplayStyle.Flex;
|
||||
// 저장된 비율로 복원
|
||||
modelView.style.flexGrow = _lastModelFlexGrow;
|
||||
chartView.style.flexGrow = _lastChartFlexGrow;
|
||||
// 1프레임 후 dragBtn 위치 업데이트
|
||||
content.schedule.Execute(() =>
|
||||
{
|
||||
UpdateDragAndPanels(content, dragBtn);
|
||||
}).ExecuteLater(1);
|
||||
return;
|
||||
}
|
||||
|
||||
// 확장 전 비율 저장
|
||||
_lastModelFlexGrow = modelView.resolvedStyle.flexGrow;
|
||||
_lastChartFlexGrow = chartView.resolvedStyle.flexGrow;
|
||||
|
||||
_expanded = ExpandedSide.Model;
|
||||
//modelView 확장
|
||||
modelView.style.display = DisplayStyle.Flex;
|
||||
modelView.style.flexGrow = 1;
|
||||
chartView.style.display = DisplayStyle.None;
|
||||
dragBtn.style.display = DisplayStyle.None;
|
||||
}
|
||||
|
||||
private void ToggleExpandChart()
|
||||
{
|
||||
if (_expanded == ExpandedSide.Chart)
|
||||
{
|
||||
// 확장 해제: 저장된 비율로 복원
|
||||
_expanded = ExpandedSide.None;
|
||||
modelView.style.display = DisplayStyle.Flex;
|
||||
chartView.style.display = DisplayStyle.Flex;
|
||||
dragBtn.style.display = DisplayStyle.Flex;
|
||||
// 저장된 비율로 복원
|
||||
modelView.style.flexGrow = _lastModelFlexGrow;
|
||||
chartView.style.flexGrow = _lastChartFlexGrow;
|
||||
// 1프레임 후 dragBtn 위치 업데이트
|
||||
content.schedule.Execute(() =>
|
||||
{
|
||||
UpdateDragAndPanels(content, dragBtn);
|
||||
}).ExecuteLater(1);
|
||||
return;
|
||||
}
|
||||
|
||||
// 확장 전 비율 저장
|
||||
_lastModelFlexGrow = modelView.resolvedStyle.flexGrow;
|
||||
_lastChartFlexGrow = chartView.resolvedStyle.flexGrow;
|
||||
|
||||
_expanded = ExpandedSide.Chart;
|
||||
// chartView 확장
|
||||
modelView.style.display = DisplayStyle.None;
|
||||
chartView.style.display = DisplayStyle.Flex;
|
||||
chartView.style.flexGrow = 1;
|
||||
dragBtn.style.display = DisplayStyle.None;
|
||||
}
|
||||
|
||||
|
||||
// dragBtn 중심 X를 주면 flex-grow를 조절하여 modelView와 chartView 비율 조정
|
||||
private void ApplyDragPosition(VisualElement root, VisualElement drag, float centerX)
|
||||
{
|
||||
if (modelView == null || chartView == null) return;
|
||||
|
||||
// 확장 모드일 때는 비율 조정하지 않음
|
||||
if (_expanded != ExpandedSide.None) return;
|
||||
|
||||
float dragHalf = Mathf.Max(1f, drag.layout.width * 0.5f);
|
||||
|
||||
// 드래그 가능 범위 계산
|
||||
float leftBound = GetLeftBound();
|
||||
float rightBound = root.layout.width;
|
||||
|
||||
// centerX를 범위 내로 제한
|
||||
centerX = Mathf.Clamp(centerX, leftBound, rightBound);
|
||||
|
||||
// 사용 가능한 너비 (treeList 제외)
|
||||
float availableWidth = rightBound - leftBound;
|
||||
|
||||
// centerX를 leftBound 기준 상대 좌표로 변환
|
||||
float relativeX = centerX - leftBound;
|
||||
|
||||
// flex 비율 계산 (relativeX : (availableWidth - relativeX))
|
||||
float modelFlexGrow = relativeX;
|
||||
float chartFlexGrow = availableWidth - relativeX;
|
||||
|
||||
// 양 끝 판정을 위한 threshold
|
||||
float threshold = 2f;
|
||||
bool isAtLeftEdge = relativeX < threshold;
|
||||
bool isAtRightEdge = relativeX > (availableWidth - threshold);
|
||||
|
||||
// 가시성 및 flex 처리
|
||||
if (isAtLeftEdge)
|
||||
{
|
||||
// 왼쪽 끝: modelView 숨김, chartView만 표시
|
||||
modelView.style.display = DisplayStyle.None;
|
||||
chartView.style.display = DisplayStyle.Flex;
|
||||
chartView.style.flexGrow = 1;
|
||||
// 비율 저장 (양 끝에서는 저장하지 않음)
|
||||
}
|
||||
else if (isAtRightEdge)
|
||||
{
|
||||
// 오른쪽 끝: chartView 숨김, modelView만 표시
|
||||
modelView.style.display = DisplayStyle.Flex;
|
||||
chartView.style.display = DisplayStyle.None;
|
||||
modelView.style.flexGrow = 1;
|
||||
// 비율 저장 (양 끝에서는 저장하지 않음)
|
||||
}
|
||||
else
|
||||
{
|
||||
// 중간: 둘 다 표시, flex 비율로 조정
|
||||
modelView.style.display = DisplayStyle.Flex;
|
||||
chartView.style.display = DisplayStyle.Flex;
|
||||
modelView.style.flexGrow = modelFlexGrow;
|
||||
chartView.style.flexGrow = chartFlexGrow;
|
||||
|
||||
// 비율 저장 (중간 위치에서만)
|
||||
_lastModelFlexGrow = modelFlexGrow;
|
||||
_lastChartFlexGrow = chartFlexGrow;
|
||||
}
|
||||
|
||||
// dragBtn 위치 조정
|
||||
float newDragLeft = centerX - dragHalf;
|
||||
drag.style.left = new Length(newDragLeft, LengthUnit.Pixel);
|
||||
}
|
||||
|
||||
// 왼쪽 경계 계산 (treeList 보이는 여부에 따라)
|
||||
private float GetLeftBound()
|
||||
{
|
||||
// treeList가 null이 아니고, DisplayStyle.None이 아니면 보이는 것으로 간주
|
||||
if (listView != null && listView.style.display != DisplayStyle.None)
|
||||
{
|
||||
// treeList가 보이는 경우: treeList의 오른쪽 끝
|
||||
float treeWidth = listView.layout.width;
|
||||
// layout이 아직 계산되지 않았으면 0이므로 체크
|
||||
if (treeWidth > 0)
|
||||
{
|
||||
return listView.layout.x + treeWidth;
|
||||
}
|
||||
// layout이 아직 없으면 resolvedStyle에서 가져오기 시도
|
||||
else if (listView.resolvedStyle.width > 0)
|
||||
{
|
||||
return listView.resolvedStyle.width;
|
||||
}
|
||||
}
|
||||
|
||||
// treeList가 안 보이거나 레이아웃 계산 안 된 경우: 화면 시작
|
||||
return 0f;
|
||||
}
|
||||
|
||||
private void UpdateDragAndPanels(VisualElement root, VisualElement drag)
|
||||
{
|
||||
if (modelView == null || chartView == null) return;
|
||||
|
||||
// modelView의 현재 layout을 사용해서 dragBtn을 배치
|
||||
// treeList가 보이는 경우를 고려하여 leftBound부터 시작
|
||||
float leftBound = GetLeftBound();
|
||||
var mv = modelView.layout;
|
||||
|
||||
// modelView의 실제 위치 계산 (treeList 포함)
|
||||
float centerX = leftBound + mv.width; // leftBound 기준 + modelView width
|
||||
|
||||
// 적용
|
||||
ApplyDragPosition(root, drag, centerX);
|
||||
}
|
||||
|
||||
// 테스트용 데이터 생성
|
||||
List<TreeListItemData> GenerateDummyData()
|
||||
{
|
||||
var root1 = new TreeListItemData { id = 1, name = "모델", isExpanded = true };
|
||||
|
||||
root1.Add(new TreeListItemData { id = 2, name = "모델1", parent = root1 });
|
||||
root1.Add(new TreeListItemData { id = 3, name = "모델2", parent = root1, IsVisible = false });
|
||||
|
||||
var child3 = new TreeListItemData { id = 4, name = "모델3", parent = root1 };
|
||||
child3.Add(new TreeListItemData { id = 5, name = "메쉬 A", parent = child3 });
|
||||
root1.Add(child3);
|
||||
|
||||
return new List<TreeListItemData> { root1 };
|
||||
}
|
||||
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (listView != null)
|
||||
{
|
||||
listView.OnSelectionChanged -= OnListItemSelectionChanged;
|
||||
listView.OnClosed -= OnListClosed;
|
||||
listView.OnVisibilityChanged -= OnListVisibilityChanged;
|
||||
}
|
||||
|
||||
if (modelView != null)
|
||||
{
|
||||
modelView.OnItemSelected -= OnModelItemSelected;
|
||||
modelView.OnExpand -= ToggleExpandModel;
|
||||
}
|
||||
|
||||
if (chartView != null)
|
||||
{
|
||||
chartView.OnExpand -= ToggleExpandChart;
|
||||
}
|
||||
|
||||
if (showTreeBtn != null) showTreeBtn.clicked -= OnClickShowTree;
|
||||
if (closeBtn != null) closeBtn.clicked -= OnClickClose;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
2
Assets/Scripts/SHI/modal/ISOP/ISOPModal.cs.meta
Normal file
2
Assets/Scripts/SHI/modal/ISOP/ISOPModal.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a070d33d51211a84ea9e6c6e6133735f
|
||||
663
Assets/Scripts/SHI/modal/ISOP/ISOPModelView.cs
Normal file
663
Assets/Scripts/SHI/modal/ISOP/ISOPModelView.cs
Normal file
@@ -0,0 +1,663 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using GLTFast;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
using UVC.UI.List.Tree;
|
||||
using UVC.Util;
|
||||
|
||||
namespace SHI.Modal.ISOP
|
||||
{
|
||||
/// <summary>
|
||||
/// glTF 모델을 비동기로 로드해 전용 카메라로 오프스크린 렌더링하고, 결과를 UIElements의 VisualElement에 출력하는 뷰입니다.
|
||||
/// 마우스 조작(이동/확대/회전), 항목 하이라이트, 와이어프레임 토글 등을 제공합니다.
|
||||
/// </summary>
|
||||
[UxmlElement]
|
||||
public partial class ISOPModelView : VisualElement
|
||||
{
|
||||
/// <summary>
|
||||
/// 뷰 내부에서 항목이 선택될 때 발생합니다.
|
||||
/// </summary>
|
||||
public Action<TreeListItemData>? OnItemSelected;
|
||||
|
||||
/// <summary>
|
||||
/// 모델 뷰가 확장 요청될 때 발생합니다.
|
||||
/// </summary>
|
||||
public Action? OnExpand;
|
||||
|
||||
// 리소스 경로 상수
|
||||
private const string UXML_PATH = "SHI/Modal/ISOP/ISOPModelView"; // Resources 폴더 기준 경로
|
||||
|
||||
// 설정
|
||||
private Color cameraBackgroundColor = new Color(1f, 1f, 1f, 1f);
|
||||
private int modelLayer = 6;
|
||||
private bool createDefaultLight = true;
|
||||
|
||||
// 마우스 조작 설정
|
||||
private float panSpeed = 1.0f;
|
||||
private float rotateDegPerPixel = 0.2f;
|
||||
private float zoomSpeed = 5f;
|
||||
|
||||
// 와이어프레임
|
||||
private bool wireframeMode = true;
|
||||
|
||||
private Camera? _viewCamera;
|
||||
private RenderTexture? _rt;
|
||||
private Material? _wireframeMat;
|
||||
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;
|
||||
private Vector3 _rmbPivot;
|
||||
|
||||
private readonly Dictionary<int, GameObject> _idToObject = new Dictionary<int, GameObject>();
|
||||
private readonly Dictionary<Renderer, UnityEngine.Material[]> _originalSharedByRenderer = new Dictionary<Renderer, UnityEngine.Material[]>();
|
||||
private GameObject? _root;
|
||||
private int? _focusedId;
|
||||
|
||||
// UI Element for rendering
|
||||
private VisualElement? _renderContainer;
|
||||
|
||||
private Button? _expandBtn;
|
||||
|
||||
private int itemIdSeed = 1;
|
||||
|
||||
public ISOPModelView()
|
||||
{
|
||||
// UXML 로드
|
||||
var visualTree = Resources.Load<VisualTreeAsset>(UXML_PATH);
|
||||
if (visualTree != null)
|
||||
{
|
||||
visualTree.CloneTree(this);
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogError($"Failed to load UXML at path: {UXML_PATH}");
|
||||
}
|
||||
|
||||
// 렌더링 컨테이너 생성
|
||||
_renderContainer = this.Q<VisualElement>("render-container");
|
||||
|
||||
_expandBtn = this.Q<Button>("expand-btn");
|
||||
if(_expandBtn != null)
|
||||
{
|
||||
_expandBtn.clicked += () =>
|
||||
{
|
||||
OnExpand?.Invoke();
|
||||
};
|
||||
}
|
||||
|
||||
// 마우스 이벤트 등록
|
||||
RegisterCallback<MouseDownEvent>(OnMouseDown);
|
||||
RegisterCallback<MouseUpEvent>(OnMouseUp);
|
||||
RegisterCallback<MouseMoveEvent>(OnMouseMove);
|
||||
RegisterCallback<WheelEvent>(OnWheel);
|
||||
RegisterCallback<GeometryChangedEvent>(OnGeometryChanged);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 주어진 경로의 glTF 모델을 비동기로 로드하고, UI 트리에 사용할 계층 항목을 생성합니다.
|
||||
/// </summary>
|
||||
public async UniTask<IEnumerable<TreeListItemData>> LoadModelAsync(string path, CancellationToken ct)
|
||||
{
|
||||
Debug.Log($"ISOPModelView.LoadModelAsync: {path}");
|
||||
Dispose();
|
||||
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)
|
||||
{
|
||||
Debug.LogError($"glTFast Load failed: {path}");
|
||||
return items;
|
||||
}
|
||||
|
||||
_root = new GameObject("ISOPModelViewRoot");
|
||||
_root.layer = modelLayer;
|
||||
var sceneOk = await gltf.InstantiateMainSceneAsync(_root.transform);
|
||||
if (!sceneOk)
|
||||
{
|
||||
Debug.LogError("InstantiateMainSceneAsync failed");
|
||||
return items;
|
||||
}
|
||||
|
||||
SetLayerRecursive(_root, modelLayer);
|
||||
|
||||
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);
|
||||
|
||||
TryLoadWireframeMaterial();
|
||||
if (wireframeMode && _wireframeMat != null)
|
||||
{
|
||||
ApplyWireframeMaterialToRoot();
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
private TreeListItemData BuildItemRecursive(Transform node, Transform root)
|
||||
{
|
||||
var data = new TreeListItemData
|
||||
{
|
||||
id = itemIdSeed++,
|
||||
name = node.name,
|
||||
isExpanded = true
|
||||
};
|
||||
|
||||
_idToObject[data.id] = node.gameObject;
|
||||
CacheOriginalMaterials(node.gameObject);
|
||||
|
||||
for (int i = 0; i < node.childCount; i++)
|
||||
{
|
||||
var child = node.GetChild(i);
|
||||
var childData = BuildItemRecursive(child, root);
|
||||
data.Add(childData);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
private void TryLoadWireframeMaterial()
|
||||
{
|
||||
if (_wireframeMat == null)
|
||||
{
|
||||
_wireframeMat = Resources.Load<Material>("SHI/Shader/BasicWireframe");
|
||||
if (_wireframeMat == null)
|
||||
{
|
||||
Debug.LogWarning("BasicWireframe material not found at Resources/SHI/Shader/BasicWireframe.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyWireframeMaterialToRoot()
|
||||
{
|
||||
if (_root == null || _wireframeMat == null) return;
|
||||
var rends = _root.GetComponentsInChildren<Renderer>(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 _originalSharedByRenderer)
|
||||
{
|
||||
var r = kv.Key;
|
||||
if (r == null) continue;
|
||||
var originals = kv.Value;
|
||||
r.sharedMaterials = originals;
|
||||
}
|
||||
_wireframeApplied = false;
|
||||
}
|
||||
|
||||
private void EnsureCameraAndTargetTexture()
|
||||
{
|
||||
if (_viewCamera == null)
|
||||
{
|
||||
var rig = new GameObject("ISOPModelViewRig");
|
||||
rig.layer = modelLayer;
|
||||
rig.transform.SetParent(null, false);
|
||||
|
||||
var camGo = new GameObject("ISOPModelViewCamera");
|
||||
camGo.layer = modelLayer;
|
||||
camGo.transform.SetParent(rig.transform, false);
|
||||
_viewCamera = camGo.AddComponent<UnityEngine.Camera>();
|
||||
_viewCamera.clearFlags = CameraClearFlags.SolidColor;
|
||||
_viewCamera.backgroundColor = cameraBackgroundColor;
|
||||
_viewCamera.nearClipPlane = 0.01f;
|
||||
_viewCamera.farClipPlane = 5000f;
|
||||
_viewCamera.cullingMask = (modelLayer >= 0 && modelLayer <= 31) ? (1 << modelLayer) : ~0;
|
||||
_viewCamera.targetDisplay = 1;
|
||||
_viewCamera.enabled = false;
|
||||
}
|
||||
|
||||
if (createDefaultLight && _viewCamera.transform.parent != null && _viewCamera.transform.parent.Find("ISOPModelViewLight") == null)
|
||||
{
|
||||
var lightGo = new GameObject("ISOPModelViewLight");
|
||||
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>();
|
||||
light.type = LightType.Directional;
|
||||
light.intensity = 1.1f;
|
||||
light.shadows = LightShadows.Soft;
|
||||
}
|
||||
|
||||
EnsureRenderTargetSize();
|
||||
}
|
||||
|
||||
private void EnsureRenderTargetSize()
|
||||
{
|
||||
if (_viewCamera == null || _renderContainer == null) return;
|
||||
|
||||
int w = Mathf.Max(64, Mathf.RoundToInt(_renderContainer.resolvedStyle.width));
|
||||
int h = Mathf.Max(64, Mathf.RoundToInt(_renderContainer.resolvedStyle.height));
|
||||
|
||||
if (_rt == null || _rt.width != w || _rt.height != h)
|
||||
{
|
||||
if (_rt != null)
|
||||
{
|
||||
if (_viewCamera.targetTexture == _rt) _viewCamera.targetTexture = null;
|
||||
_rt.Release();
|
||||
UnityEngine.Object.Destroy(_rt);
|
||||
}
|
||||
|
||||
_rt = new RenderTexture(w, h, 24, RenderTextureFormat.ARGB32)
|
||||
{
|
||||
name = "ISOPModelViewRT",
|
||||
antiAliasing = 2
|
||||
};
|
||||
_viewCamera.targetTexture = _rt;
|
||||
_viewCamera.enabled = true;
|
||||
|
||||
// UIElements에 렌더텍스처 적용
|
||||
if (_renderContainer != null)
|
||||
{
|
||||
_renderContainer.style.backgroundImage = new StyleBackground(Background.FromRenderTexture(_rt));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (_viewCamera.targetTexture != _rt)
|
||||
{
|
||||
_viewCamera.targetTexture = _rt;
|
||||
}
|
||||
if (!_viewCamera.enabled) _viewCamera.enabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnGeometryChanged(GeometryChangedEvent evt)
|
||||
{
|
||||
EnsureRenderTargetSize();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 와이어프레임 모드를 토글합니다.
|
||||
/// </summary>
|
||||
public void SetWireframe(bool on)
|
||||
{
|
||||
wireframeMode = on;
|
||||
TryLoadWireframeMaterial();
|
||||
if (_wireframeMat != null)
|
||||
{
|
||||
if (on) ApplyWireframeMaterialToRoot(); else RestoreAllOriginalMaterials();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 지정한 항목을 포커스(하이라이트)합니다.
|
||||
/// </summary>
|
||||
public void FocusItem(TreeListItemData data)
|
||||
{
|
||||
if (data == null) return;
|
||||
FocusItemById(data.id);
|
||||
}
|
||||
|
||||
public void FocusItemById(int id)
|
||||
{
|
||||
Debug.Log($"ISOPModelView.FocusItemById: id={id}");
|
||||
_focusedId = id;
|
||||
if (_idToObject.TryGetValue(id, out var go))
|
||||
{
|
||||
Highlight(go, true);
|
||||
Debug.Log($"ISOPModelView.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 Export(int id)
|
||||
{
|
||||
Debug.Log($"ISOPModelView.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($"ISOPModelView.SetVisibility: id={id} on={on}");
|
||||
if (_idToObject.TryGetValue(id, out var go))
|
||||
{
|
||||
go.SetActive(on);
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly string[] ColorProps = new[] {
|
||||
"_WireframeColor", "_WireColor",
|
||||
"_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; }
|
||||
}
|
||||
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)
|
||||
{
|
||||
var rends = go.GetComponentsInChildren<Renderer>(true);
|
||||
if (rends == null || rends.Length == 0) return;
|
||||
|
||||
for (int i = 0; i < rends.Length; i++)
|
||||
{
|
||||
var r = rends[i];
|
||||
if (r == null) continue;
|
||||
|
||||
var mats = r.materials;
|
||||
for (int m = 0; m < mats.Length; m++)
|
||||
{
|
||||
if (on)
|
||||
{
|
||||
TrySetColor(mats[m], ColorUtil.FromHex("#888814"));
|
||||
}
|
||||
else
|
||||
{
|
||||
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
|
||||
{
|
||||
Color orig;
|
||||
UnityEngine.Material[] originals;
|
||||
if (_originalSharedByRenderer.TryGetValue(r, out originals)
|
||||
&& m < originals.Length && originals[m] != null
|
||||
&& TryGetColor(originals[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 OnMouseDown(MouseDownEvent evt)
|
||||
{
|
||||
if (evt.button == 2) // Middle mouse button
|
||||
{
|
||||
_mmbDragging = true;
|
||||
_mmbLastPos = evt.mousePosition;
|
||||
evt.StopPropagation();
|
||||
}
|
||||
else if (evt.button == 1) // Right mouse button
|
||||
{
|
||||
_rmbDragging = true;
|
||||
_rmbStartPos = evt.mousePosition;
|
||||
_yawStart = _yaw;
|
||||
_pitchStart = _pitch;
|
||||
if (_root != null)
|
||||
{
|
||||
_modelStartRot = _root.transform.rotation;
|
||||
_modelStartPos = _root.transform.position;
|
||||
}
|
||||
_rmbPivot = _orbitTarget;
|
||||
evt.StopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnMouseUp(MouseUpEvent evt)
|
||||
{
|
||||
if (evt.button == 2)
|
||||
{
|
||||
_mmbDragging = false;
|
||||
evt.StopPropagation();
|
||||
}
|
||||
else if (evt.button == 1)
|
||||
{
|
||||
_rmbDragging = false;
|
||||
evt.StopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnMouseMove(MouseMoveEvent evt)
|
||||
{
|
||||
if (_viewCamera == null) return;
|
||||
|
||||
// 가운데 버튼: 모델 이동
|
||||
if (_mmbDragging && _root != null)
|
||||
{
|
||||
Vector3 cur = evt.mousePosition;
|
||||
Vector2 dp = (Vector2)(cur - _mmbLastPos);
|
||||
_mmbLastPos = cur;
|
||||
|
||||
float wPix = Mathf.Max(1f, _renderContainer?.resolvedStyle.width ?? 1f);
|
||||
float hPix = Mathf.Max(1f, _renderContainer?.resolvedStyle.height ?? 1f);
|
||||
|
||||
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;
|
||||
|
||||
evt.StopPropagation();
|
||||
}
|
||||
|
||||
// 오른쪽 버튼: 모델 회전
|
||||
if (_rmbDragging && _root != null)
|
||||
{
|
||||
Vector3 cur = evt.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;
|
||||
|
||||
Vector3 startVec = _modelStartPos - _rmbPivot;
|
||||
_root.transform.position = _rmbPivot + r * startVec;
|
||||
_root.transform.rotation = r * _modelStartRot;
|
||||
|
||||
evt.StopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnWheel(WheelEvent evt)
|
||||
{
|
||||
if (_viewCamera == null || _root == null) return;
|
||||
|
||||
float scroll = -evt.delta.y * 0.001f;
|
||||
if (Mathf.Abs(scroll) > 1e-5f)
|
||||
{
|
||||
var forward = _viewCamera.transform.forward;
|
||||
Vector3 deltaZ = forward * (-scroll * zoomSpeed);
|
||||
_root.transform.position += deltaZ;
|
||||
_orbitTarget += deltaZ;
|
||||
evt.StopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
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;
|
||||
|
||||
if (_viewCamera != null)
|
||||
{
|
||||
if (_rt != null && _viewCamera.targetTexture == _rt)
|
||||
{
|
||||
_viewCamera.targetTexture = null;
|
||||
}
|
||||
_viewCamera.enabled = false;
|
||||
}
|
||||
if (_rt != null)
|
||||
{
|
||||
_rt.Release();
|
||||
UnityEngine.Object.Destroy(_rt);
|
||||
_rt = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 유틸리티 메서드
|
||||
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<Renderer>(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);
|
||||
|
||||
_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<string>();
|
||||
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<Renderer>(true);
|
||||
if (rends.Length == 0) return;
|
||||
for (int i = 0; i < rends.Length; i++)
|
||||
{
|
||||
var r = rends[i];
|
||||
if (r == null) continue;
|
||||
if (_originalSharedByRenderer.ContainsKey(r)) continue;
|
||||
_originalSharedByRenderer[r] = r.sharedMaterials;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/SHI/modal/ISOP/ISOPModelView.cs.meta
Normal file
2
Assets/Scripts/SHI/modal/ISOP/ISOPModelView.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ed790f40299c67e4fb4dd1cfb5930562
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1af5017c83fc5cf4d8fbd1d2a801a095
|
||||
@@ -8,10 +8,7 @@ using UnityEngine.UIElements;
|
||||
|
||||
namespace SHI.modal
|
||||
{
|
||||
/// <summary>
|
||||
/// 간트 데이터 로드 및(임시) UI Toolkit 렌더 스텁.
|
||||
/// </summary>
|
||||
public class ModelDetailChartView : MonoBehaviour
|
||||
public class ModelDetailChartView2 : MonoBehaviour
|
||||
{
|
||||
public Action<string>? OnRowClickedByKey;
|
||||
public Action<Guid>? OnRowClicked;
|
||||
@@ -505,7 +502,7 @@ namespace SHI.modal
|
||||
|
||||
// React to RectTransform size changes
|
||||
private void OnRectTransformDimensionsChange()
|
||||
{
|
||||
{
|
||||
SyncContainerSize();
|
||||
SyncContainerPosition();
|
||||
}
|
||||
2
Assets/Scripts/SHI/modal/ModelDetailChartView2.cs.meta
Normal file
2
Assets/Scripts/SHI/modal/ModelDetailChartView2.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 78e6249d32aa43149ab7f5e12a71967c
|
||||
52
Assets/Scripts/SHI/modal/ModelDetailISOPChartView.cs
Normal file
52
Assets/Scripts/SHI/modal/ModelDetailISOPChartView.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
|
||||
namespace SHI.modal
|
||||
{
|
||||
/// <summary>
|
||||
/// 간트 데이터 로드 및(임시) UI Toolkit 렌더 스텁.
|
||||
/// </summary>
|
||||
public class ModelDetailISOPChartView : MonoBehaviour
|
||||
{
|
||||
public Action<string>? OnRowClickedByKey;
|
||||
public Action<Guid>? OnRowClicked;
|
||||
|
||||
[SerializeField] private ShipblockGanttController? ganttController;
|
||||
|
||||
|
||||
public void Start()
|
||||
{
|
||||
LoadFromStreamingAssets();
|
||||
}
|
||||
|
||||
|
||||
public void LoadFromStreamingAssets(string fileName = "isop_chart.json")
|
||||
{
|
||||
try
|
||||
{
|
||||
|
||||
var path = Path.Combine(Application.streamingAssetsPath, fileName);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
Debug.LogError($"File not found: {path}");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
ganttController?.Load(path);
|
||||
Debug.LogWarning("Unsupported chart JSON format.");
|
||||
}
|
||||
catch (Exception ex) { Debug.LogError($"LoadFromStreamingAssets failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1064f370279bf304dac7b13c8b4e6577
|
||||
@@ -366,6 +366,16 @@ namespace SHI.modal
|
||||
_focusedId = null;
|
||||
}
|
||||
|
||||
public void Export(Guid id)
|
||||
{
|
||||
Debug.Log($"ModelDetailView.Export: id={id}");
|
||||
if (_idToObject.TryGetValue(id, out var go))
|
||||
{
|
||||
Debug.Log($"Exporting object: {go.name}");
|
||||
UVC.GLTF.GLTFExporter.ExportNodeByExplorer(go);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 인스턴스화된 노드의 활성 상태(가시성)를 변경합니다.
|
||||
/// </summary>
|
||||
|
||||
8
Assets/Scripts/SHI/modal/NW.meta
Normal file
8
Assets/Scripts/SHI/modal/NW.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 00360ee1871ba714799ed7b0f99cc56f
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
368
Assets/Scripts/SHI/modal/TreeList.cs
Normal file
368
Assets/Scripts/SHI/modal/TreeList.cs
Normal file
@@ -0,0 +1,368 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
|
||||
namespace SHI.Modal
|
||||
{
|
||||
[UxmlElement]
|
||||
public partial class TreeList : VisualElement
|
||||
{
|
||||
// 리소스 경로 상수
|
||||
private const string UXML_PATH = "SHI/Modal/TreeList"; // Resources 폴더 기준 경로
|
||||
private const string ITEM_UXML_PATH = "SHI/Modal/TreeListItem"; // Resources 폴더 기준 경로
|
||||
|
||||
|
||||
// 내부 컴포넌트 참조
|
||||
private TextField _searchField;
|
||||
private TreeView _treeView;
|
||||
|
||||
private Button _closeButton;
|
||||
private Button _clearButton;
|
||||
|
||||
// 아이템용 UXML 에셋을 미리 로드해두는 것이 성능상 좋음
|
||||
private VisualTreeAsset _itemTemplate;
|
||||
|
||||
// 원본 루트 데이터(필터 복원용)
|
||||
private List<TreeListItemData> _originalRoots = new();
|
||||
|
||||
// 데이터 소스
|
||||
private List<TreeViewItemData<TreeListItemData>> _rootData;
|
||||
|
||||
// 고유 ID 생성용 시드
|
||||
private int _idSeed = 1;
|
||||
|
||||
// 이전 선택 상태 추적용
|
||||
private TreeListItemData? _previouslySelectedItem;
|
||||
|
||||
|
||||
// [1] 외부에 노출할 이벤트 정의
|
||||
// Action<T>: T 데이터를 함께 전달합니다.
|
||||
public event Action<TreeListItemData> OnVisibilityChanged; // 눈 아이콘 클릭 시
|
||||
public event Action<TreeListItemData> OnSelectionChanged; // 리스트 아이템 선택 시
|
||||
public event Action OnClosed; // hide 시켰을 때
|
||||
|
||||
public TreeList()
|
||||
{
|
||||
// 1. UXML 로드 및 복제 (Instantiate)
|
||||
var visualTree = Resources.Load<VisualTreeAsset>(UXML_PATH);
|
||||
if (visualTree == null)
|
||||
{
|
||||
Debug.LogError($"[TreeMenu] UXML not found at: {UXML_PATH}");
|
||||
return;
|
||||
}
|
||||
|
||||
// CloneTree(this)를 하면 UXML의 내용이 이 클래스(TreeMenu)의 자식으로 들어옴
|
||||
visualTree.CloneTree(this);
|
||||
|
||||
_itemTemplate = Resources.Load<VisualTreeAsset>(ITEM_UXML_PATH);
|
||||
if (_itemTemplate == null)
|
||||
{
|
||||
Debug.LogError($"[TreeMenu] Item UXML not found at: {ITEM_UXML_PATH}");
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 요소 찾기 (Query) - 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");
|
||||
|
||||
|
||||
// 3. 로직 초기화
|
||||
InitializeLogic();
|
||||
|
||||
}
|
||||
|
||||
private void InitializeLogic()
|
||||
{
|
||||
// 검색창 이벤트 연결
|
||||
if (_searchField != null)
|
||||
{
|
||||
_searchField.RegisterValueChangedCallback(evt => OnSearch(evt.newValue));
|
||||
}
|
||||
|
||||
// 트리뷰 설정
|
||||
if (_treeView != null)
|
||||
{
|
||||
_treeView.makeItem = MakeTreeItem;
|
||||
_treeView.bindItem = BindTreeItem;
|
||||
_treeView.selectionChanged += OnTreeViewSelectionChanged;
|
||||
}
|
||||
|
||||
if (_closeButton != null)
|
||||
{
|
||||
_closeButton.clicked += () =>
|
||||
{
|
||||
this.style.display = DisplayStyle.None;
|
||||
OnClosed?.Invoke();
|
||||
};
|
||||
}
|
||||
|
||||
if(_clearButton != null)
|
||||
{
|
||||
_clearButton.clicked += () =>
|
||||
{
|
||||
if (_searchField.value.Length > 0)
|
||||
{
|
||||
_searchField.value = string.Empty;
|
||||
OnSearch(string.Empty);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public void Show()
|
||||
{
|
||||
this.style.display = DisplayStyle.Flex;
|
||||
}
|
||||
|
||||
// 내부적인 선택 변경 핸들러 -> 외부 이벤트 호출
|
||||
private void OnTreeViewSelectionChanged(System.Collections.Generic.IEnumerable<object> selectedItems)
|
||||
{
|
||||
// 단일 선택 기준 (여러 개 선택이면 로직 변경 필요)
|
||||
var currentItem = selectedItems.FirstOrDefault() as TreeListItemData;
|
||||
|
||||
// 이전 선택 항목이 있었고, 현재 선택이 다르다면 이전 항목 해제 이벤트 발송
|
||||
if (_previouslySelectedItem != null && _previouslySelectedItem != currentItem)
|
||||
{
|
||||
_previouslySelectedItem.isSelected = false;
|
||||
OnSelectionChanged?.Invoke(_previouslySelectedItem);
|
||||
}
|
||||
|
||||
// 현재 선택 항목이 있으면 선택 이벤트 발송
|
||||
if (currentItem != null)
|
||||
{
|
||||
currentItem.isSelected = true;
|
||||
OnSelectionChanged?.Invoke(currentItem);
|
||||
}
|
||||
|
||||
// 현재 선택 상태 저장
|
||||
_previouslySelectedItem = currentItem;
|
||||
}
|
||||
|
||||
|
||||
// 외부에서 데이터를 주입하는 메서드
|
||||
public void SetData(List<TreeListItemData> roots)
|
||||
{
|
||||
|
||||
_originalRoots = roots ?? new List<TreeListItemData>();
|
||||
|
||||
_idSeed = 1;
|
||||
|
||||
var visited = new HashSet<TreeListItemData>();
|
||||
|
||||
_rootData = ConvertToTreeViewData(_originalRoots, visited, 0);
|
||||
|
||||
|
||||
// TreeView에 루트 데이터 설정 (SetRootItems 사용)
|
||||
_treeView.SetRootItems<TreeListItemData>(_rootData);
|
||||
|
||||
// 트리 뷰가 자식을 찾는 방법 정의
|
||||
_treeView.Rebuild();
|
||||
}
|
||||
|
||||
// 사이클 방지 + 고유 ID 부여
|
||||
private List<TreeViewItemData<TreeListItemData>> ConvertToTreeViewData(List<TreeListItemData> items, HashSet<TreeListItemData> visited, int depth)
|
||||
{
|
||||
var list = new List<TreeViewItemData<TreeListItemData>>(items.Count);
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (item == null) continue;
|
||||
|
||||
// 방문 체크 (사이클 차단)
|
||||
if (!visited.Add(item))
|
||||
{
|
||||
Debug.LogWarning($"[TreeList] Cycle detected at item '{item.name}' → children 무시");
|
||||
item.children = null;
|
||||
}
|
||||
|
||||
// 고유 ID 자동 할당
|
||||
if (item.id == 0) item.id = _idSeed++;
|
||||
|
||||
List<TreeViewItemData<TreeListItemData>>? childData = null;
|
||||
if (item.children != null && item.children.Count > 0)
|
||||
childData = ConvertToTreeViewData(item.children, visited, depth + 1);
|
||||
|
||||
var treeItem = new TreeViewItemData<TreeListItemData>(item.id, item, childData);
|
||||
list.Add(treeItem);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
// --- [핵심] 각 행(Row)의 UI 생성 (MakeItem) ---
|
||||
private VisualElement MakeTreeItem()
|
||||
{
|
||||
// UXML을 복제하여 새 아이템 생성
|
||||
// Instantiate()는 TemplateContainer를 반환함
|
||||
var templateContainer = _itemTemplate.Instantiate();
|
||||
|
||||
return templateContainer;
|
||||
}
|
||||
|
||||
|
||||
// --- [핵심] 데이터와 UI 연결 (BindItem) ---
|
||||
private void BindTreeItem(VisualElement element, int index)
|
||||
{
|
||||
|
||||
// TreeView 내부 로직으로 아이템 데이터 가져오기
|
||||
var item = _treeView.GetItemDataForIndex<TreeListItemData>(index);
|
||||
if (item == null) return;
|
||||
|
||||
// 1. 이름 설정
|
||||
var label = element.Q<Label>("item-label");
|
||||
|
||||
if (label != null) label.text = item.name;
|
||||
|
||||
// 2. 가시성 아이콘 설정
|
||||
var toggleBtn = element.Q<Button>("visibility-btn");
|
||||
if (toggleBtn != null) UpdateVisibilityIcon(toggleBtn, item.IsVisible);
|
||||
|
||||
// 3. 버튼 클릭 이벤트 연결 (기존 이벤트 제거 후 재등록)
|
||||
// 주의: bindItem은 스크롤 시 재사용되므로 이벤트를 매번 새로 연결하는 방식은 주의해야 함.
|
||||
// 여기서는 간편한 구현을 위해 clicked 대리자를 교체합니다.
|
||||
if (toggleBtn.userData is Action oldAction) toggleBtn.clicked -= oldAction;
|
||||
System.Action clickAction = () =>
|
||||
{
|
||||
item.IsVisible = !item.IsVisible;
|
||||
UpdateVisibilityIcon(toggleBtn, item.IsVisible);
|
||||
|
||||
// 자식들에게 동일 상태 전파
|
||||
SetChildrenVisibility(item, item.IsVisible);
|
||||
|
||||
// 화면에 그려진 자식 아이콘도 갱신되도록 목록 새로고침
|
||||
// (바인딩 시 UpdateVisibilityIcon이 호출되어 반영됨)
|
||||
_treeView.RefreshItems(); // Unity 2022+에서 제공
|
||||
// 필요한 경우 아래로 대체 가능:
|
||||
// _treeView.Rebuild();
|
||||
|
||||
// 3. [핵심] 외부로 이벤트 발송 (데이터 전달)
|
||||
OnVisibilityChanged?.Invoke(item);
|
||||
};
|
||||
toggleBtn.userData = clickAction;
|
||||
toggleBtn.clicked += clickAction;
|
||||
}
|
||||
|
||||
// 자식들 재귀적으로 IsVisible 동기화
|
||||
private void SetChildrenVisibility(TreeListItemData node, bool isVisible)
|
||||
{
|
||||
if (node.children == null || node.children.Count == 0) return;
|
||||
foreach (var child in node.children)
|
||||
{
|
||||
if (child == null) continue;
|
||||
child.IsVisible = isVisible;
|
||||
// 하위까지 재귀 적용
|
||||
SetChildrenVisibility(child, isVisible);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private void UpdateVisibilityIcon(Button btn, bool isVisible)
|
||||
{
|
||||
if (isVisible)
|
||||
{
|
||||
btn.RemoveFromClassList("visibility-off");
|
||||
btn.AddToClassList("visibility-on");
|
||||
}
|
||||
else
|
||||
{
|
||||
btn.RemoveFromClassList("visibility-on");
|
||||
btn.AddToClassList("visibility-off");
|
||||
}
|
||||
}
|
||||
|
||||
// 검색 기능
|
||||
private void OnSearch(string query)
|
||||
{
|
||||
if (string.IsNullOrEmpty(query))
|
||||
{
|
||||
_treeView.SetRootItems<TreeListItemData>(_rootData);
|
||||
_treeView.Rebuild();
|
||||
return;
|
||||
}
|
||||
|
||||
string qLower = query.Trim().ToLowerInvariant();
|
||||
var filteredWrappers = FilterTree(qLower);
|
||||
|
||||
_treeView.SetRootItems<TreeListItemData>(filteredWrappers);
|
||||
_treeView.Rebuild();
|
||||
|
||||
// 매치 결과 펼치기
|
||||
ExpandAll(filteredWrappers);
|
||||
|
||||
}
|
||||
|
||||
// 루트들에 대해 필터 수행
|
||||
private List<TreeViewItemData<TreeListItemData>> FilterTree(string qLower)
|
||||
{
|
||||
var result = new List<TreeViewItemData<TreeListItemData>>();
|
||||
foreach (var root in _originalRoots)
|
||||
{
|
||||
TreeViewItemData<TreeListItemData>? filtered = FilterNode(root, qLower);
|
||||
if (filtered != null) result.Add(filtered.Value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// 단일 노드 필터링 (매치 또는 자식 매치 시 포함)
|
||||
private TreeViewItemData<TreeListItemData>? FilterNode(TreeListItemData node, string qLower)
|
||||
{
|
||||
bool selfMatch = NodeMatches(node, qLower);
|
||||
List<TreeViewItemData<TreeListItemData>>? childFiltered = null;
|
||||
|
||||
if (node.children != null && node.children.Count > 0)
|
||||
{
|
||||
foreach (var child in node.children)
|
||||
{
|
||||
TreeViewItemData<TreeListItemData>? f = FilterNode(child, qLower);
|
||||
if (f != null)
|
||||
{
|
||||
childFiltered ??= new List<TreeViewItemData<TreeListItemData>>();
|
||||
childFiltered.Add(f.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (selfMatch || (childFiltered != null && childFiltered.Count > 0))
|
||||
{
|
||||
return new TreeViewItemData<TreeListItemData>(node.id, node, childFiltered);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private bool NodeMatches(TreeListItemData item, string qLower)
|
||||
{
|
||||
if (item.name != null && item.name.ToLowerInvariant().Contains(qLower)) return true;
|
||||
if (!string.IsNullOrEmpty(item.option) && item.option.ToLowerInvariant().Contains(qLower)) return true;
|
||||
if (!string.IsNullOrEmpty(item.ExternalKey) && item.ExternalKey.ToLowerInvariant().Contains(qLower)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// 필터된 결과 전체 펼치기 (필요 시 조건 변경 가능)
|
||||
private void ExpandAll(List<TreeViewItemData<TreeListItemData>> roots)
|
||||
{
|
||||
foreach (var r in roots)
|
||||
ExpandRecursive(r);
|
||||
}
|
||||
|
||||
private void ExpandRecursive(TreeViewItemData<TreeListItemData> wrapper)
|
||||
{
|
||||
_treeView.ExpandItem(wrapper.id);
|
||||
if (wrapper.children != null)
|
||||
{
|
||||
foreach (var c in wrapper.children)
|
||||
ExpandRecursive(c);
|
||||
}
|
||||
}
|
||||
|
||||
public void SelectByItemId(int itemId)
|
||||
{
|
||||
//itemId에 해당하는 아이템을 찾아 선택 상태로 만듭니다.
|
||||
_treeView.SetSelection(new List<int> { itemId });
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
2
Assets/Scripts/SHI/modal/TreeList.cs.meta
Normal file
2
Assets/Scripts/SHI/modal/TreeList.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 59616237ceaa21f43af235d5e80fe97a
|
||||
29
Assets/Scripts/SHI/modal/TreeListItemData.cs
Normal file
29
Assets/Scripts/SHI/modal/TreeListItemData.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace SHI.Modal
|
||||
{
|
||||
// 클래스 이름을 통일하고, 자식 리스트 타입을 수정했습니다.
|
||||
public class TreeListItemData
|
||||
{
|
||||
public int id = 0;
|
||||
public string name = string.Empty;
|
||||
public string option = string.Empty;
|
||||
public bool isExpanded = false;
|
||||
public bool isSelected = false;
|
||||
|
||||
// 재귀적 구조를 위해 타입 통일
|
||||
public TreeListItemData parent;
|
||||
public List<TreeListItemData> children = new List<TreeListItemData>();
|
||||
|
||||
public bool IsVisible = true;
|
||||
public string ExternalKey = string.Empty;
|
||||
|
||||
public void Add(TreeListItemData child)
|
||||
{
|
||||
child.parent = this;
|
||||
children.Add(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/SHI/modal/TreeListItemData.cs.meta
Normal file
2
Assets/Scripts/SHI/modal/TreeListItemData.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c8fcef8452856cc48aa71c0292261a42
|
||||
Reference in New Issue
Block a user