From f1587ae47d0bc5cb94cb4c76dffb946654f987ae Mon Sep 17 00:00:00 2001 From: SullyunShin Date: Mon, 24 Feb 2025 15:55:44 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B0=9D=EC=B2=B4=20=EC=8A=A4=EB=83=85=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 고정된 큐브 꼭짓점이 아닌 Convecx Hull 포인트로 스냅하도록 수정 --- .../Resources/Prefabs/PRF_RenderObject.prefab | 2 +- .../XED/AssetTool/CustomAssetConnector.cs | 8 +- .../XED/AssetTool/CustomAssetRenderObject.cs | 26 +- .../XED/AssetTool/RenderObjectHandler.cs | 96 +- .../XED/Common/ConvexHullCalculator.cs | 1221 +++++++++++++++++ .../XED/Common/ConvexHullCalculator.cs.meta | 2 + Assets/Scripts/XED/Common/KDTree.cs | 111 ++ Assets/Scripts/XED/Common/KDTree.cs.meta | 2 + .../Scripts/XED/UI/Panel/Panel_DragHandler.cs | 1 + .../Scripts/XED/UI/Panel/Panel_ObjectInfo.cs | 9 +- 10 files changed, 1468 insertions(+), 10 deletions(-) create mode 100644 Assets/Scripts/XED/Common/ConvexHullCalculator.cs create mode 100644 Assets/Scripts/XED/Common/ConvexHullCalculator.cs.meta create mode 100644 Assets/Scripts/XED/Common/KDTree.cs create mode 100644 Assets/Scripts/XED/Common/KDTree.cs.meta diff --git a/Assets/Resources/Prefabs/PRF_RenderObject.prefab b/Assets/Resources/Prefabs/PRF_RenderObject.prefab index ca7529f4..9a760ff8 100644 --- a/Assets/Resources/Prefabs/PRF_RenderObject.prefab +++ b/Assets/Resources/Prefabs/PRF_RenderObject.prefab @@ -15,7 +15,7 @@ GameObject: m_Icon: {fileID: 0} m_NavMeshLayer: 0 m_StaticEditorFlags: 0 - m_IsActive: 1 + m_IsActive: 0 --- !u!4 &4257723628152675382 Transform: m_ObjectHideFlags: 0 diff --git a/Assets/Scripts/XED/AssetTool/CustomAssetConnector.cs b/Assets/Scripts/XED/AssetTool/CustomAssetConnector.cs index 5bdf836f..f2a4331b 100644 --- a/Assets/Scripts/XED/AssetTool/CustomAssetConnector.cs +++ b/Assets/Scripts/XED/AssetTool/CustomAssetConnector.cs @@ -221,8 +221,8 @@ namespace XED.Asset return; } string name = ""; - List selectedObjects = new List(); List selectedItems = new List(); + List selectedRenderObjects = new List(); renderObjectHandler.RemoveAllSelections(); for (int i = 0; i < activeAssets.Count; i++) { @@ -231,12 +231,12 @@ namespace XED.Asset { name = i == 0 ? activeAsset.hierarchyItem.name : name.Equals(activeAsset.hierarchyItem.name) ? activeAsset.hierarchyItem.name : "-"; selectedItems.Add(activeAsset.hierarchyItem); - selectedObjects.Add(activeAsset.renderObject.gameObject); - renderObjectHandler.AddSelection(activeAsset.renderObject); + selectedRenderObjects.Add(activeAsset.renderObject); } } + renderObjectHandler.AddSelection(selectedRenderObjects); componentScrollView.OnSelect(selectedItems); - onSelectObjects?.Invoke(name, selectedObjects); + onSelectObjects?.Invoke(name, selectedRenderObjects.Select(x => x.gameObject).ToList()); } public void OnRemoveObject(List objects) { diff --git a/Assets/Scripts/XED/AssetTool/CustomAssetRenderObject.cs b/Assets/Scripts/XED/AssetTool/CustomAssetRenderObject.cs index a4decd16..4a8ecd13 100644 --- a/Assets/Scripts/XED/AssetTool/CustomAssetRenderObject.cs +++ b/Assets/Scripts/XED/AssetTool/CustomAssetRenderObject.cs @@ -204,6 +204,30 @@ namespace XED.Util Destroy(renderObject); renderObject = null; } - } + } + public Bounds GetBounds() + { + return boxCollider.bounds; + } + public List GetRenderObjectVertices() + { + MeshFilter[] meshFilters = renderObject.GetComponentsInChildren(); + List points = new List(); + for (int i = 0; i < meshFilters.Length; i++) + { + Vector3[] vertices = meshFilters[i].mesh.vertices; + for (int k = 0; k < vertices.Length; k++) + { + Vector3 pt = renderObject.transform.TransformPoint(vertices[k]); + points.Add(pt); + } + } + return points; + } + public KDTree GetVertexPointKDTree() + { + if (renderObject == null) return null; + return new KDTree(renderObject); + } } } diff --git a/Assets/Scripts/XED/AssetTool/RenderObjectHandler.cs b/Assets/Scripts/XED/AssetTool/RenderObjectHandler.cs index 166d8db1..773213ac 100644 --- a/Assets/Scripts/XED/AssetTool/RenderObjectHandler.cs +++ b/Assets/Scripts/XED/AssetTool/RenderObjectHandler.cs @@ -20,6 +20,7 @@ namespace XED.Util List selectedGameObjects = new List(); CustomAssetRenderObject prevSelectedObject; RTGController rtgController; + ConvexHullCalculator convexHullCalculator = new ConvexHullCalculator(); Vector3 clickBeginPos; float clickLengthThreshold = 5.0f; int uiLayer; @@ -72,8 +73,7 @@ namespace XED.Util //rtg Ʈѷ Ŀ ٲ ϱ 뵵 if (lockHandler == true) { - lockHandler = false; - onDragForceEnd?.Invoke(); + lockHandler = false; return; } //ui 쿡 Ʈ . @@ -170,6 +170,21 @@ namespace XED.Util selectedGameObjects.Add(item.gameObject); rtgController.SetGizmoTargetObjects(selectedGameObjects); } + public void AddSelection(List items) + { + for (int i = 0; i < items.Count; i++) + { + CustomAssetRenderObject item = items[i]; + if (selectedRenderObjects.Any(x => x == item)) + { + return; + } + item.Select(); + selectedRenderObjects.Add(item); + selectedGameObjects.Add(item.gameObject); + } + rtgController.SetGizmoTargetObjects(selectedGameObjects); + } public void RemoveAllSelections() { for (int i = 0; i < selectedRenderObjects.Count; i++) @@ -215,6 +230,7 @@ namespace XED.Util { OnTransformChanged(transformObjects); onTransformChanged?.Invoke(selectedGameObjects); + onDragForceEnd?.Invoke(); } public void OnTransformChanged(List transformObjects) { @@ -307,6 +323,81 @@ namespace XED.Util }); CommandManager.I.ExecuteCommand(command); } + public void VertexSnap() + { + if (selectedRenderObjects.Count != 1) + { + return; + } + // Ʈ ٿش. + //Transform.position ٰ Ʈ ⸮ ƴϱ + // ڽ 浹Ǵ Ʈ ؽ KDTree ϴٰ ξ. + float minDist = float.MaxValue; + CustomAssetRenderObject fromObject = selectedRenderObjects[0]; + CustomAssetRenderObject toObject = null; + Bounds fromBounds = fromObject.GetBounds(); + Collider[] colliders = Physics.OverlapBox(fromBounds.center, fromBounds.size * 2.0f); + foreach (Collider collider in colliders) + { + if (collider.gameObject == fromObject.gameObject) continue; + CustomAssetRenderObject otherObject = collider.gameObject.GetComponent(); + if (otherObject != null) + { + float dist = Vector3.Magnitude(fromObject.transform.position - otherObject.transform.position); + if (dist < minDist) + { + minDist = dist; + toObject = otherObject; + } + } + } + if (toObject != null) + { + //õ Ʈ ConvexHull ؽ ش. + //̴ iterationϴ ؽ ̱ ָ, + // õ Ʈ TDTree Nearest Ʈ Ž toObject Ϳ Ʈ ŽDZ ؽ ʴ 찡 ִ. + List verts = new List(); + List tris = new List(); + List normals = new List(); + convexHullCalculator.GenerateHull(fromObject.GetRenderObjectVertices(), false, ref verts, ref tris, ref normals); + KDTree toTree = toObject.GetVertexPointKDTree(); + Vector3 nearestPoint = Vector3.zero; + Vector3 snapPoint = Vector3.zero; + float minDistance = float.MaxValue; + for (int i = 0; i < verts.Count; i++) + { + Vector3 ptNearest = verts[i]; + Vector3 ptSnap = toTree.FindNearest(ptNearest); + float tempDist = Vector3.Magnitude(ptSnap - ptNearest); + if (tempDist < minDistance) + { + minDistance = tempDist; + nearestPoint = ptNearest; + snapPoint = ptSnap; + } + } + //Vector3 direction = snapPoint - nearestPoint; + //fromObject.transform.position += direction; + List dir = new List() { snapPoint - nearestPoint }; + if (dir[0].magnitude < 0.5f) + { + fromObject.transform.position += dir[0]; + } + else + { + ActionCommand command = new ActionCommand( + () => + { + fromObject.transform.position += dir[0]; + }, + () => + { + fromObject.transform.position -= dir[0]; + }); + CommandManager.I.ExecuteCommand(command); + } + } + } InputHandler myHandler; @@ -333,6 +424,7 @@ namespace XED.Util getKeyActions.Add(KeyCode.W, SetRTGRotate); getKeyActions.Add(KeyCode.E, SetRTGScale); getKeyActions.Add(KeyCode.R, ResetRTG); + getKeyActions.Add(KeyCode.V, VertexSnap); var shortcutTable = new Dictionary>(); #if UNITY_EDITOR diff --git a/Assets/Scripts/XED/Common/ConvexHullCalculator.cs b/Assets/Scripts/XED/Common/ConvexHullCalculator.cs new file mode 100644 index 00000000..bfcecf00 --- /dev/null +++ b/Assets/Scripts/XED/Common/ConvexHullCalculator.cs @@ -0,0 +1,1221 @@ +/** +* Copyright 2019 Oskar Sigvardsson +* +* Permission is hereby granted, free of charge, to any person obtaining a copy +* of this software and associated documentation files (the "Software"), to deal +* in the Software without restriction, including without limitation the rights +* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +* copies of the Software, and to permit persons to whom the Software is +* furnished to do so, subject to the following conditions: +* +* The above copyright notice and this permission notice shall be included in +* all copies or substantial portions of the Software. +* +* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +* SOFTWARE. +*/ + +//#define DEBUG_QUICKHULL + +using System.Diagnostics; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using UnityEngine; +/// +/// An implementation of the quickhull algorithm for generating 3d convex +/// hulls. +/// +/// The algorithm works like this: you start with an initial "seed" hull, +/// that is just a simple tetrahedron made up of four points in the point +/// cloud. This seed hull is then grown until it all the points in the +/// point cloud is inside of it, at which point it will be the convex hull +/// for the entire set. +/// +/// All of the points in the point cloud is divided into two parts, the +/// "open set" and the "closed set". The open set consists of all the +/// points outside of the tetrahedron, and the closed set is all of the +/// points inside the tetrahedron. After each iteration of the algorithm, +/// the closed set gets bigger and the open set get smaller. When the open +/// set is empty, the algorithm is finished. +/// +/// Each point in the open set is assigned to a face that it lies outside +/// of. To grow the hull, the point in the open set which is farthest from +/// it's face is chosen. All faces which are facing that point (I call +/// them "lit faces" in the code, because if you imagine the point as a +/// point light, it's the set of points which would be lit by that point +/// light) are removed, and a "horizon" of edges is found from where the +/// faces were removed. From this horizon, new faces are constructed in a +/// "cone" like fashion connecting the point to the edges. +/// +/// To keep track of the faces, I use a struct for each face which +/// contains the three vertices of the face in CCW order, as well as the +/// three triangles which share an edge. I was considering doing a +/// half-edge structure to store the mesh, but it's not needed. Using a +/// struct for each face and neighbors simplify the algorithm and makes it +/// easy to export it as a mesh. +/// +/// The most subtle part of the algorithm is finding the horizon. In order +/// to properly construct the cone so that all neighbors are kept +/// consistent, you can do a depth-first search from the first lit face. +/// If the depth-first search always proceeeds in a counter-clockwise +/// fashion, it guarantees that the horizon will be found in a +/// counter-clockwise order, which makes it easy to construct the cone of +/// new faces. +/// +/// A note: the code uses a right-handed coordinate system, where the +/// cross-product uses the right-hand rule and the faces are in CCW order. +/// At the end of the algorithm, the hull is exported in a Unity-friendly +/// fashion, with a left-handed mesh. +/// +public class ConvexHullCalculator +{ + + /// + /// Constant representing a point that has yet to be assigned to a + /// face. It's only used immediately after constructing the seed hull. + /// + const int UNASSIGNED = -2; + + /// + /// Constant representing a point that is inside the convex hull, and + /// thus is behind all faces. In the openSet array, all points with + /// INSIDE are at the end of the array, with indexes larger + /// openSetTail. + /// + const int INSIDE = -1; + + /// + /// Epsilon value. If the coordinates of the point space are + /// exceptionally close to each other, this value might need to be + /// adjusted. + /// + const float EPSILON = 0.0001f; + + /// + /// Struct representing a single face. + /// + /// Vertex0, Vertex1 and Vertex2 are the vertices in CCW order. They + /// acutal points are stored in the points array, these are just + /// indexes into that array. + /// + /// Opposite0, Opposite1 and Opposite2 are the keys to the faces which + /// share an edge with this face. Opposite0 is the face opposite + /// Vertex0 (so it has an edge with Vertex2 and Vertex1), etc. + /// + /// Normal is (unsurprisingly) the normal of the triangle. + /// + struct Face + { + public int Vertex0; + public int Vertex1; + public int Vertex2; + + public int Opposite0; + public int Opposite1; + public int Opposite2; + + public Vector3 Normal; + + public Face(int v0, int v1, int v2, int o0, int o1, int o2, Vector3 normal) + { + Vertex0 = v0; + Vertex1 = v1; + Vertex2 = v2; + Opposite0 = o0; + Opposite1 = o1; + Opposite2 = o2; + Normal = normal; + } + + public bool Equals(Face other) + { + return (this.Vertex0 == other.Vertex0) + && (this.Vertex1 == other.Vertex1) + && (this.Vertex2 == other.Vertex2) + && (this.Opposite0 == other.Opposite0) + && (this.Opposite1 == other.Opposite1) + && (this.Opposite2 == other.Opposite2) + && (this.Normal == other.Normal); + } + } + + /// + /// Struct representing a mapping between a point and a face. These + /// are used in the openSet array. + /// + /// Point is the index of the point in the points array, Face is the + /// key of the face in the Key dictionary, Distance is the distance + /// from the face to the point. + /// + struct PointFace + { + public int Point; + public int Face; + public float Distance; + + public PointFace(int p, int f, float d) + { + Point = p; + Face = f; + Distance = d; + } + } + + /// + /// Struct representing a single edge in the horizon. + /// + /// Edge0 and Edge1 are the vertexes of edge in CCW order, Face is the + /// face on the other side of the horizon. + /// + /// TODO Edge1 isn't actually needed, you can just index the next item + /// in the horizon array. + /// + struct HorizonEdge + { + public int Face; + public int Edge0; + public int Edge1; + } + + /// + /// A dictionary storing the faces of the currently generated convex + /// hull. The key is the id of the face, used in the Face, PointFace + /// and HorizonEdge struct. + /// + /// This is a Dictionary, because we need both random access to it, + /// the ability to loop through it, and ability to quickly delete + /// faces (in the ConstructCone method), and Dictionary is the obvious + /// candidate that can do all of those things. + /// + /// I'm wondering if using a Dictionary is best idea, though. It might + /// be better to just have them in a List and mark a face as + /// deleted by adding a field to the Face struct. The downside is that + /// we would need an extra field in the Face struct, and when we're + /// looping through the points in openSet, we would have to loop + /// through all the Faces EVER created in the algorithm, and skip the + /// ones that have been marked as deleted. However, looping through a + /// list is fairly fast, and it might be worth it to avoid Dictionary + /// overhead. + /// + /// TODO test converting to a List instead. + /// + Dictionary faces; + + /// + /// The set of points to be processed. "openSet" is a misleading name, + /// because it's both the open set (points which are still outside the + /// convex hull) and the closed set (points that are inside the convex + /// hull). The first part of the array (with indexes <= openSetTail) + /// is the openSet, the last part of the array (with indexes > + /// openSetTail) are the closed set, with Face set to INSIDE. The + /// closed set is largely irrelevant to the algorithm, the open set is + /// what matters. + /// + /// Storing the entire open set in one big list has a downside: when + /// we're reassigning points after ConstructCone, we only need to + /// reassign points that belong to the faces that have been removed, + /// but storing it in one array, we have to loop through the entire + /// list, and checking litFaces to determine which we can skip and + /// which need to be reassigned. + /// + /// The alternative here is to give each face in Face array it's own + /// openSet. I don't like that solution, because then you have to + /// juggle so many more heap-allocated List's, we'd have to use + /// object pools and such. It would do a lot more allocation, and it + /// would have worse locality. I should maybe test that solution, but + /// it probably wont be faster enough (if at all) to justify the extra + /// allocations. + /// + List openSet; + + /// + /// Set of faces which are "lit" by the current point in the set. This + /// is used in the FindHorizon() DFS search to keep track of which + /// faces we've already visited, and in the ReassignPoints() method to + /// know which points need to be reassigned. + /// + HashSet litFaces; + + /// + /// The current horizon. Generated by the FindHorizon() DFS search, + /// and used in ConstructCone to construct new faces. The list of + /// edges are in CCW order. + /// + List horizon; + + /// + /// If SplitVerts is false, this Dictionary is used to keep track of + /// which points we've added to the final mesh. + /// + Dictionary hullVerts; + + /// + /// The "tail" of the openSet, the last index of a vertex that has + /// been assigned to a face. + /// + int openSetTail = -1; + + /// + /// When adding a new face to the faces Dictionary, use this for the + /// key and then increment it. + /// + int faceCount = 0; + + /// + /// Generate a convex hull from points in points array, and store the + /// mesh in Unity-friendly format in verts and tris. If splitVerts is + /// true, the the verts will be split, if false, the same vert will be + /// used for more than one triangle. + /// + public void GenerateHull( + List points, + bool splitVerts, + ref List verts, + ref List tris, + ref List normals) + { + if (points.Count < 4) + { + throw new System.ArgumentException("Need at least 4 points to generate a convex hull"); + } + + Initialize(points, splitVerts); + + GenerateInitialHull(points); + + while (openSetTail >= 0) + { + GrowHull(points); + } + + ExportMesh(points, splitVerts, ref verts, ref tris, ref normals); + VerifyMesh(points, ref verts, ref tris); + } + + /// + /// Make sure all the buffers and variables needed for the algorithm + /// are initialized. + /// + void Initialize(List points, bool splitVerts) + { + faceCount = 0; + openSetTail = -1; + + if (faces == null) + { + faces = new Dictionary(); + litFaces = new HashSet(); + horizon = new List(); + openSet = new List(points.Count); + } + else + { + faces.Clear(); + litFaces.Clear(); + horizon.Clear(); + openSet.Clear(); + + if (openSet.Capacity < points.Count) + { + // i wonder if this is a good idea... if you call + // GenerateHull over and over with slightly increasing + // points counts, it's going to reallocate every time. Maybe + // i should just use .Add(), and let the List manage the + // capacity, increasing it geometrically every time we need + // to reallocate. + + // maybe do + // openSet.Capacity = Mathf.NextPowerOfTwo(points.Count) + // instead? + + openSet.Capacity = points.Count; + } + } + + if (!splitVerts) + { + if (hullVerts == null) + { + hullVerts = new Dictionary(); + } + else + { + hullVerts.Clear(); + } + } + } + + /// + /// Create initial seed hull. + /// + void GenerateInitialHull(List points) + { + // Find points suitable for use as the seed hull. Some varieties of + // this algorithm pick extreme points here, but I'm not convinced + // you gain all that much from that. Currently what it does is just + // find the first four points that are not coplanar. + int b0, b1, b2, b3; + FindInitialHullIndices(points, out b0, out b1, out b2, out b3); + + var v0 = points[b0]; + var v1 = points[b1]; + var v2 = points[b2]; + var v3 = points[b3]; + + var above = Dot(v3 - v1, Cross(v1 - v0, v2 - v0)) > 0.0f; + + // Create the faces of the seed hull. You need to draw a diagram + // here, otherwise it's impossible to know what's going on :) + + // Basically: there are two different possible start-tetrahedrons, + // depending on whether the fourth point is above or below the base + // triangle. If you draw a tetrahedron with these coordinates (in a + // right-handed coordinate-system): + + // b0 = (0,0,0) + // b1 = (1,0,0) + // b2 = (0,1,0) + // b3 = (0,0,1) + + // you can see the first case (set b3 = (0,0,-1) for the second + // case). The faces are added with the proper references to the + // faces opposite each vertex + + faceCount = 0; + if (above) + { + faces[faceCount++] = new Face(b0, b2, b1, 3, 1, 2, Normal(points[b0], points[b2], points[b1])); + faces[faceCount++] = new Face(b0, b1, b3, 3, 2, 0, Normal(points[b0], points[b1], points[b3])); + faces[faceCount++] = new Face(b0, b3, b2, 3, 0, 1, Normal(points[b0], points[b3], points[b2])); + faces[faceCount++] = new Face(b1, b2, b3, 2, 1, 0, Normal(points[b1], points[b2], points[b3])); + } + else + { + faces[faceCount++] = new Face(b0, b1, b2, 3, 2, 1, Normal(points[b0], points[b1], points[b2])); + faces[faceCount++] = new Face(b0, b3, b1, 3, 0, 2, Normal(points[b0], points[b3], points[b1])); + faces[faceCount++] = new Face(b0, b2, b3, 3, 1, 0, Normal(points[b0], points[b2], points[b3])); + faces[faceCount++] = new Face(b1, b3, b2, 2, 0, 1, Normal(points[b1], points[b3], points[b2])); + } + + VerifyFaces(points); + + // Create the openSet. Add all points except the points of the seed + // hull. + for (int i = 0; i < points.Count; i++) + { + if (i == b0 || i == b1 || i == b2 || i == b3) continue; + + openSet.Add(new PointFace(i, UNASSIGNED, 0.0f)); + } + + // Add the seed hull verts to the tail of the list. + openSet.Add(new PointFace(b0, INSIDE, float.NaN)); + openSet.Add(new PointFace(b1, INSIDE, float.NaN)); + openSet.Add(new PointFace(b2, INSIDE, float.NaN)); + openSet.Add(new PointFace(b3, INSIDE, float.NaN)); + + // Set the openSetTail value. Last item in the array is + // openSet.Count - 1, but four of the points (the verts of the seed + // hull) are part of the closed set, so move openSetTail to just + // before those. + openSetTail = openSet.Count - 5; + + Assert(openSet.Count == points.Count); + + // Assign all points of the open set. This does basically the same + // thing as ReassignPoints() + for (int i = 0; i <= openSetTail; i++) + { + Assert(openSet[i].Face == UNASSIGNED); + Assert(openSet[openSetTail].Face == UNASSIGNED); + Assert(openSet[openSetTail + 1].Face == INSIDE); + + var assigned = false; + var fp = openSet[i]; + + Assert(faces.Count == 4); + Assert(faces.Count == faceCount); + for (int j = 0; j < 4; j++) + { + Assert(faces.ContainsKey(j)); + + var face = faces[j]; + + var dist = PointFaceDistance(points[fp.Point], points[face.Vertex0], face); + + if (dist > 0) + { + fp.Face = j; + fp.Distance = dist; + openSet[i] = fp; + + assigned = true; + break; + } + } + + if (!assigned) + { + // Point is inside + fp.Face = INSIDE; + fp.Distance = float.NaN; + + // Point is inside seed hull: swap point with tail, and move + // openSetTail back. We also have to decrement i, because + // there's a new item at openSet[i], and we need to process + // it next iteration + openSet[i] = openSet[openSetTail]; + openSet[openSetTail] = fp; + + openSetTail -= 1; + i -= 1; + } + } + + VerifyOpenSet(points); + } + + /// + /// Find four points in the point cloud that are not coplanar for the + /// seed hull + /// + void FindInitialHullIndices(List points, out int b0, out int b1, out int b2, out int b3) + { + var count = points.Count; + + for (int i0 = 0; i0 < count - 3; i0++) + { + for (int i1 = i0 + 1; i1 < count - 2; i1++) + { + var p0 = points[i0]; + var p1 = points[i1]; + + if (AreCoincident(p0, p1)) continue; + + for (int i2 = i1 + 1; i2 < count - 1; i2++) + { + var p2 = points[i2]; + + if (AreCollinear(p0, p1, p2)) continue; + + for (int i3 = i2 + 1; i3 < count - 0; i3++) + { + var p3 = points[i3]; + + if (AreCoplanar(p0, p1, p2, p3)) continue; + + b0 = i0; + b1 = i1; + b2 = i2; + b3 = i3; + return; + } + } + } + } + + throw new System.ArgumentException("Can't generate hull, points are coplanar"); + } + + /// + /// Grow the hull. This method takes the current hull, and expands it + /// to encompass the point in openSet with the point furthest away + /// from its face. + /// + void GrowHull(List points) + { + Assert(openSetTail >= 0); + Assert(openSet[0].Face != INSIDE); + + // Find farthest point and first lit face. + var farthestPoint = 0; + var dist = openSet[0].Distance; + + for (int i = 1; i <= openSetTail; i++) + { + if (openSet[i].Distance > dist) + { + farthestPoint = i; + dist = openSet[i].Distance; + } + } + + // Use lit face to find horizon and the rest of the lit + // faces. + FindHorizon( + points, + points[openSet[farthestPoint].Point], + openSet[farthestPoint].Face, + faces[openSet[farthestPoint].Face]); + + VerifyHorizon(); + + // Construct new cone from horizon + ConstructCone(points, openSet[farthestPoint].Point); + + VerifyFaces(points); + + // Reassign points + ReassignPoints(points); + } + + /// + /// Start the search for the horizon. + /// + /// The search is a DFS search that searches neighboring triangles in + /// a counter-clockwise fashion. When it find a neighbor which is not + /// lit, that edge will be a line on the horizon. If the search always + /// proceeds counter-clockwise, the edges of the horizon will be found + /// in counter-clockwise order. + /// + /// The heart of the search can be found in the recursive + /// SearchHorizon() method, but the the first iteration of the search + /// is special, because it has to visit three neighbors (all the + /// neighbors of the initial triangle), while the rest of the search + /// only has to visit two (because one of them has already been + /// visited, the one you came from). + /// + void FindHorizon(List points, Vector3 point, int fi, Face face) + { + // TODO should I use epsilon in the PointFaceDistance comparisons? + + litFaces.Clear(); + horizon.Clear(); + + litFaces.Add(fi); + + Assert(PointFaceDistance(point, points[face.Vertex0], face) > 0.0f); + + // For the rest of the recursive search calls, we first check if the + // triangle has already been visited and is part of litFaces. + // However, in this first call we can skip that because we know it + // can't possibly have been visited yet, since the only thing in + // litFaces is the current triangle. + { + var oppositeFace = faces[face.Opposite0]; + + var dist = PointFaceDistance( + point, + points[oppositeFace.Vertex0], + oppositeFace); + + if (dist <= 0.0f) + { + horizon.Add(new HorizonEdge + { + Face = face.Opposite0, + Edge0 = face.Vertex1, + Edge1 = face.Vertex2, + }); + } + else + { + SearchHorizon(points, point, fi, face.Opposite0, oppositeFace); + } + } + + if (!litFaces.Contains(face.Opposite1)) + { + var oppositeFace = faces[face.Opposite1]; + + var dist = PointFaceDistance( + point, + points[oppositeFace.Vertex0], + oppositeFace); + + if (dist <= 0.0f) + { + horizon.Add(new HorizonEdge + { + Face = face.Opposite1, + Edge0 = face.Vertex2, + Edge1 = face.Vertex0, + }); + } + else + { + SearchHorizon(points, point, fi, face.Opposite1, oppositeFace); + } + } + + if (!litFaces.Contains(face.Opposite2)) + { + var oppositeFace = faces[face.Opposite2]; + + var dist = PointFaceDistance( + point, + points[oppositeFace.Vertex0], + oppositeFace); + + if (dist <= 0.0f) + { + horizon.Add(new HorizonEdge + { + Face = face.Opposite2, + Edge0 = face.Vertex0, + Edge1 = face.Vertex1, + }); + } + else + { + SearchHorizon(points, point, fi, face.Opposite2, oppositeFace); + } + } + } + + /// + /// Recursively search to find the horizon or lit set. + /// + void SearchHorizon(List points, Vector3 point, int prevFaceIndex, int faceCount, Face face) + { + Assert(prevFaceIndex >= 0); + Assert(litFaces.Contains(prevFaceIndex)); + Assert(!litFaces.Contains(faceCount)); + Assert(faces[faceCount].Equals(face)); + + litFaces.Add(faceCount); + + // Use prevFaceIndex to determine what the next face to search will + // be, and what edges we need to cross to get there. It's important + // that the search proceeds in counter-clockwise order from the + // previous face. + int nextFaceIndex0; + int nextFaceIndex1; + int edge0; + int edge1; + int edge2; + + if (prevFaceIndex == face.Opposite0) + { + nextFaceIndex0 = face.Opposite1; + nextFaceIndex1 = face.Opposite2; + + edge0 = face.Vertex2; + edge1 = face.Vertex0; + edge2 = face.Vertex1; + } + else if (prevFaceIndex == face.Opposite1) + { + nextFaceIndex0 = face.Opposite2; + nextFaceIndex1 = face.Opposite0; + + edge0 = face.Vertex0; + edge1 = face.Vertex1; + edge2 = face.Vertex2; + } + else + { + Assert(prevFaceIndex == face.Opposite2); + + nextFaceIndex0 = face.Opposite0; + nextFaceIndex1 = face.Opposite1; + + edge0 = face.Vertex1; + edge1 = face.Vertex2; + edge2 = face.Vertex0; + } + + if (!litFaces.Contains(nextFaceIndex0)) + { + var oppositeFace = faces[nextFaceIndex0]; + + var dist = PointFaceDistance( + point, + points[oppositeFace.Vertex0], + oppositeFace); + + if (dist <= 0.0f) + { + horizon.Add(new HorizonEdge + { + Face = nextFaceIndex0, + Edge0 = edge0, + Edge1 = edge1, + }); + } + else + { + SearchHorizon(points, point, faceCount, nextFaceIndex0, oppositeFace); + } + } + + if (!litFaces.Contains(nextFaceIndex1)) + { + var oppositeFace = faces[nextFaceIndex1]; + + var dist = PointFaceDistance( + point, + points[oppositeFace.Vertex0], + oppositeFace); + + if (dist <= 0.0f) + { + horizon.Add(new HorizonEdge + { + Face = nextFaceIndex1, + Edge0 = edge1, + Edge1 = edge2, + }); + } + else + { + SearchHorizon(points, point, faceCount, nextFaceIndex1, oppositeFace); + } + } + } + + /// + /// Remove all lit faces and construct new faces from the horizon in a + /// "cone-like" fashion. + /// + /// This is a relatively straight-forward procedure, given that the + /// horizon is handed to it in already sorted counter-clockwise. The + /// neighbors of the new faces are easy to find: they're the previous + /// and next faces to be constructed in the cone, as well as the face + /// on the other side of the horizon. We also have to update the face + /// on the other side of the horizon to reflect it's new neighbor from + /// the cone. + /// + void ConstructCone(List points, int farthestPoint) + { + foreach (var fi in litFaces) + { + Assert(faces.ContainsKey(fi)); + faces.Remove(fi); + } + + var firstNewFace = faceCount; + + for (int i = 0; i < horizon.Count; i++) + { + // Vertices of the new face, the farthest point as well as the + // edge on the horizon. Horizon edge is CCW, so the triangle + // should be as well. + var v0 = farthestPoint; + var v1 = horizon[i].Edge0; + var v2 = horizon[i].Edge1; + + // Opposite faces of the triangle. First, the edge on the other + // side of the horizon, then the next/prev faces on the new cone + var o0 = horizon[i].Face; + var o1 = (i == horizon.Count - 1) ? firstNewFace : firstNewFace + i + 1; + var o2 = (i == 0) ? (firstNewFace + horizon.Count - 1) : firstNewFace + i - 1; + + var fi = faceCount++; + + faces[fi] = new Face( + v0, v1, v2, + o0, o1, o2, + Normal(points[v0], points[v1], points[v2])); + + var horizonFace = faces[horizon[i].Face]; + + if (horizonFace.Vertex0 == v1) + { + Assert(v2 == horizonFace.Vertex2); + horizonFace.Opposite1 = fi; + } + else if (horizonFace.Vertex1 == v1) + { + Assert(v2 == horizonFace.Vertex0); + horizonFace.Opposite2 = fi; + } + else + { + Assert(v1 == horizonFace.Vertex2); + Assert(v2 == horizonFace.Vertex1); + horizonFace.Opposite0 = fi; + } + + faces[horizon[i].Face] = horizonFace; + } + } + + /// + /// Reassign points based on the new faces added by ConstructCone(). + /// + /// Only points that were previous assigned to a removed face need to + /// be updated, so check litFaces while looping through the open set. + /// + /// There is a potential optimization here: there's no reason to loop + /// through the entire openSet here. If each face had it's own + /// openSet, we could just loop through the openSets in the removed + /// faces. That would make the loop here shorter. + /// + /// However, to do that, we would have to juggle A LOT more List's, + /// and we would need an object pool to manage them all without + /// generating a whole bunch of garbage. I don't think it's worth + /// doing that to make this loop shorter, a straight for-loop through + /// a list is pretty darn fast. Still, it might be worth trying + /// + void ReassignPoints(List points) + { + for (int i = 0; i <= openSetTail; i++) + { + var fp = openSet[i]; + + if (litFaces.Contains(fp.Face)) + { + var assigned = false; + var point = points[fp.Point]; + + foreach (var kvp in faces) + { + var fi = kvp.Key; + var face = kvp.Value; + + var dist = PointFaceDistance( + point, + points[face.Vertex0], + face); + + if (dist > EPSILON) + { + assigned = true; + + fp.Face = fi; + fp.Distance = dist; + + openSet[i] = fp; + break; + } + } + + if (!assigned) + { + // If point hasn't been assigned, then it's inside the + // convex hull. Swap it with openSetTail, and decrement + // openSetTail. We also have to decrement i, because + // there's now a new thing in openSet[i], so we need i + // to remain the same the next iteration of the loop. + fp.Face = INSIDE; + fp.Distance = float.NaN; + + openSet[i] = openSet[openSetTail]; + openSet[openSetTail] = fp; + + i--; + openSetTail--; + } + } + } + } + + /// + /// Final step in algorithm, export the faces of the convex hull in a + /// mesh-friendly format. + /// + void ExportMesh( + List points, + bool splitVerts, + ref List verts, + ref List tris, + ref List normals) + { + if (verts == null) + { + verts = new List(); + } + else + { + verts.Clear(); + } + + if (tris == null) + { + tris = new List(); + } + else + { + tris.Clear(); + } + + if (normals == null) + { + normals = new List(); + } + else + { + normals.Clear(); + } + + foreach (var face in faces.Values) + { + int vi0, vi1, vi2; + + if (splitVerts) + { + vi0 = verts.Count; verts.Add(points[face.Vertex0]); + vi1 = verts.Count; verts.Add(points[face.Vertex1]); + vi2 = verts.Count; verts.Add(points[face.Vertex2]); + + normals.Add(face.Normal); + normals.Add(face.Normal); + normals.Add(face.Normal); + } + else + { + if (!hullVerts.TryGetValue(face.Vertex0, out vi0)) + { + vi0 = verts.Count; + hullVerts[face.Vertex0] = vi0; + verts.Add(points[face.Vertex0]); + } + + if (!hullVerts.TryGetValue(face.Vertex1, out vi1)) + { + vi1 = verts.Count; + hullVerts[face.Vertex1] = vi1; + verts.Add(points[face.Vertex1]); + } + + if (!hullVerts.TryGetValue(face.Vertex2, out vi2)) + { + vi2 = verts.Count; + hullVerts[face.Vertex2] = vi2; + verts.Add(points[face.Vertex2]); + } + } + + tris.Add(vi0); + tris.Add(vi1); + tris.Add(vi2); + } + + if (splitVerts) + return; + + for (int i = 0; i < verts.Count; i++) + { + normals.Add(Vector3.zero); + } + + foreach (var face in faces.Values) + { + normals[hullVerts[face.Vertex0]] += face.Normal; + normals[hullVerts[face.Vertex1]] += face.Normal; + normals[hullVerts[face.Vertex2]] += face.Normal; + } + + for (int i = 0; i < normals.Count; i++) + { + normals[i] = normals[i].normalized; + } + } + + /// + /// Signed distance from face to point (a positive number means that + /// the point is above the face) + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + float PointFaceDistance(Vector3 point, Vector3 pointOnFace, Face face) + { + return Dot(face.Normal, point - pointOnFace); + } + + /// + /// Calculate normal for triangle + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + Vector3 Normal(Vector3 v0, Vector3 v1, Vector3 v2) + { + return Cross(v1 - v0, v2 - v0).normalized; + } + + /// + /// Dot product, for convenience. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static float Dot(Vector3 a, Vector3 b) + { + return a.x * b.x + a.y * b.y + a.z * b.z; + } + + /// + /// Vector3.Cross i left-handed, the algorithm is right-handed. Also, + /// i wanna test to see if using aggressive inlining makes any + /// difference here. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static Vector3 Cross(Vector3 a, Vector3 b) + { + return new Vector3( + a.y * b.z - a.z * b.y, + a.z * b.x - a.x * b.z, + a.x * b.y - a.y * b.x); + } + + /// + /// Check if two points are coincident + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + bool AreCoincident(Vector3 a, Vector3 b) + { + return (a - b).magnitude <= EPSILON; + } + + /// + /// Check if three points are collinear + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + bool AreCollinear(Vector3 a, Vector3 b, Vector3 c) + { + return Cross(c - a, c - b).magnitude <= EPSILON; + } + + /// + /// Check if four points are coplanar + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + bool AreCoplanar(Vector3 a, Vector3 b, Vector3 c, Vector3 d) + { + var n1 = Cross(c - a, c - b); + var n2 = Cross(d - a, d - b); + + var m1 = n1.magnitude; + var m2 = n2.magnitude; + + return m1 <= EPSILON + || m2 <= EPSILON + || AreCollinear(Vector3.zero, + (1.0f / m1) * n1, + (1.0f / m2) * n2); + } + + /// + /// Method used for debugging, verifies that the openSet is in a + /// sensible state. Conditionally compiled if DEBUG_QUICKHULL if + /// defined. + /// + [Conditional("DEBUG_QUICKHULL")] + void VerifyOpenSet(List points) + { + for (int i = 0; i < openSet.Count; i++) + { + if (i > openSetTail) + { + Assert(openSet[i].Face == INSIDE); + } + else + { + Assert(openSet[i].Face != INSIDE); + Assert(openSet[i].Face != UNASSIGNED); + + Assert(PointFaceDistance( + points[openSet[i].Point], + points[faces[openSet[i].Face].Vertex0], + faces[openSet[i].Face]) > 0.0f); + } + } + } + + /// + /// Method used for debugging, verifies that the horizon is in a + /// sensible state. Conditionally compiled if DEBUG_QUICKHULL if + /// defined. + /// + [Conditional("DEBUG_QUICKHULL")] + void VerifyHorizon() + { + for (int i = 0; i < horizon.Count; i++) + { + var prev = i == 0 ? horizon.Count - 1 : i - 1; + + Assert(horizon[prev].Edge1 == horizon[i].Edge0); + Assert(HasEdge(faces[horizon[i].Face], horizon[i].Edge1, horizon[i].Edge0)); + } + } + + /// + /// Method used for debugging, verifies that the faces array is in a + /// sensible state. Conditionally compiled if DEBUG_QUICKHULL if + /// defined. + /// + [Conditional("DEBUG_QUICKHULL")] + void VerifyFaces(List points) + { + foreach (var kvp in faces) + { + var fi = kvp.Key; + var face = kvp.Value; + + Assert(faces.ContainsKey(face.Opposite0)); + Assert(faces.ContainsKey(face.Opposite1)); + Assert(faces.ContainsKey(face.Opposite2)); + + Assert(face.Opposite0 != fi); + Assert(face.Opposite1 != fi); + Assert(face.Opposite2 != fi); + + Assert(face.Vertex0 != face.Vertex1); + Assert(face.Vertex0 != face.Vertex2); + Assert(face.Vertex1 != face.Vertex2); + + Assert(HasEdge(faces[face.Opposite0], face.Vertex2, face.Vertex1)); + Assert(HasEdge(faces[face.Opposite1], face.Vertex0, face.Vertex2)); + Assert(HasEdge(faces[face.Opposite2], face.Vertex1, face.Vertex0)); + + Assert((face.Normal - Normal( + points[face.Vertex0], + points[face.Vertex1], + points[face.Vertex2])).magnitude < EPSILON); + } + } + + /// + /// Method used for debugging, verifies that the final mesh is + /// actually a convex hull of all the points. Conditionally compiled + /// if DEBUG_QUICKHULL if defined. + /// + [Conditional("DEBUG_QUICKHULL")] + void VerifyMesh(List points, ref List verts, ref List tris) + { + Assert(tris.Count % 3 == 0); + + for (int i = 0; i < points.Count; i++) + { + for (int j = 0; j < tris.Count; j += 3) + { + var t0 = verts[tris[j]]; + var t1 = verts[tris[j + 1]]; + var t2 = verts[tris[j + 2]]; + + Assert(Dot(points[i] - t0, Vector3.Cross(t1 - t0, t2 - t0)) <= EPSILON); + } + + } + } + + /// + /// Does face f have a face with vertexes e0 and e1? Used only for + /// debugging. + /// + bool HasEdge(Face f, int e0, int e1) + { + return (f.Vertex0 == e0 && f.Vertex1 == e1) + || (f.Vertex1 == e0 && f.Vertex2 == e1) + || (f.Vertex2 == e0 && f.Vertex0 == e1); + } + + /// + /// Assert method, conditionally compiled with DEBUG_QUICKHULL. + /// + /// I could just use Debug.Assert or the Assertions class, but I like + /// the idea of just writing Assert(something), and I also want it to + /// be conditionally compiled out with the same #define as the other + /// debug methods. + /// + [Conditional("DEBUG_QUICKHULL")] + static void Assert(bool condition) + { + if (!condition) + { + throw new UnityEngine.Assertions.AssertionException("Assertion failed", ""); + } + } +} diff --git a/Assets/Scripts/XED/Common/ConvexHullCalculator.cs.meta b/Assets/Scripts/XED/Common/ConvexHullCalculator.cs.meta new file mode 100644 index 00000000..080bd705 --- /dev/null +++ b/Assets/Scripts/XED/Common/ConvexHullCalculator.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c4a665cb7acfb0a48b480078f6e450b0 \ No newline at end of file diff --git a/Assets/Scripts/XED/Common/KDTree.cs b/Assets/Scripts/XED/Common/KDTree.cs new file mode 100644 index 00000000..c4534840 --- /dev/null +++ b/Assets/Scripts/XED/Common/KDTree.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using UnityEngine; + +namespace XED.Util +{ + public class KDTree + { + private class KDNode + { + public Vector3 point; + public KDNode left, right; + public int axis; + } + + private KDNode root; + + public KDTree(Vector3[] points) + { + root = Build(points, 0); + } + public KDTree(GameObject gb) + { + MeshFilter[] meshFilters = gb.GetComponentsInChildren(); + HashSet listPoints = new HashSet(); + for (int i = 0; i < meshFilters.Length; i++) + { + Vector3[] vertices = meshFilters[i].mesh.vertices; + for (int k = 0; k < vertices.Length; k++) + { + Vector3 pt = gb.transform.TransformPoint(vertices[k]); + listPoints.Add(pt); + } + } + root = Build(listPoints.ToArray(), 0); + } + + private KDNode Build(Vector3[] points, int depth) + { + if (points.Length == 0) return null; + + int axis = depth % 3; + System.Array.Sort(points, (a, b) => (a[axis] < b[axis]) ? -1 : (a[axis] > b[axis]) ? 1 : 0); + //System.Array.Sort(points, (a, b) => a[axis].CompareTo(b[axis])); + int median = points.Length / 2; + + return new KDNode + { + point = points[median], + axis = axis, + left = Build(points[..median], depth + 1), + right = Build(points[(median + 1)..], depth + 1) + }; + } + private Vector3[] RemoveDuplicates(Vector3[] points, float epsilon = 1e-5f) + { + if (points.Length == 0) return points; + + List uniquePoints = new List { points[0] }; + + for (int i = 1; i < points.Length; i++) + { + if (!ApproximatelyEqual(points[i], points[i - 1], epsilon)) + { + uniquePoints.Add(points[i]); + } + } + + return uniquePoints.ToArray(); + } + + private bool ApproximatelyEqual(Vector3 a, Vector3 b, float epsilon = 1e-5f) + { + return Mathf.Abs(a.x - b.x) < epsilon && + Mathf.Abs(a.y - b.y) < epsilon && + Mathf.Abs(a.z - b.z) < epsilon; + } + public Vector3 FindNearest(Vector3 target) + { + return FindNearest(root, target, root.point, float.MaxValue); + } + + private Vector3 FindNearest(KDNode node, Vector3 target, Vector3 best, float bestDist) + { + if (node == null) return best; + + float dist = Vector3.Distance(target, node.point); + if (dist < bestDist) + { + bestDist = dist; + best = node.point; + } + + bool searchLeft = target[node.axis] < node.point[node.axis]; + + KDNode first = searchLeft ? node.left : node.right; + KDNode second = searchLeft ? node.right : node.left; + + best = FindNearest(first, target, best, bestDist); + bestDist = Vector3.Distance(target, best); + + if (Mathf.Abs(target[node.axis] - node.point[node.axis]) < bestDist) + { + best = FindNearest(second, target, best, bestDist); + } + + return best; + } + } +} diff --git a/Assets/Scripts/XED/Common/KDTree.cs.meta b/Assets/Scripts/XED/Common/KDTree.cs.meta new file mode 100644 index 00000000..29dba091 --- /dev/null +++ b/Assets/Scripts/XED/Common/KDTree.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d2c4266c7916a7f488400174b07261d8 \ No newline at end of file diff --git a/Assets/Scripts/XED/UI/Panel/Panel_DragHandler.cs b/Assets/Scripts/XED/UI/Panel/Panel_DragHandler.cs index e47f0342..3196a37d 100644 --- a/Assets/Scripts/XED/UI/Panel/Panel_DragHandler.cs +++ b/Assets/Scripts/XED/UI/Panel/Panel_DragHandler.cs @@ -50,6 +50,7 @@ namespace XED.UI } public void OnEndDrag(Vector3 position) { + if (!selectionBoxImage.gameObject.activeSelf) return; selectionBoxImage.gameObject.SetActive(false); StopAllCoroutines(); SetSelectionBoxSize(position); diff --git a/Assets/Scripts/XED/UI/Panel/Panel_ObjectInfo.cs b/Assets/Scripts/XED/UI/Panel/Panel_ObjectInfo.cs index f5623978..0153cec7 100644 --- a/Assets/Scripts/XED/UI/Panel/Panel_ObjectInfo.cs +++ b/Assets/Scripts/XED/UI/Panel/Panel_ObjectInfo.cs @@ -223,9 +223,14 @@ namespace XED.UI } if (selectedObjects.Count == 0) { - return; + return; } - Text_Name.text = name; + string append = ""; + if (!name.Equals("-")) + { + append = string.Format(" ({0})", selectedObjects.Count); + } + Text_Name.text = name + append; OnTransformChanged(selectedObjects); if (lastSelectedInputField.type != InputFieldType.none) {