diff --git a/Assets/Scripts/ConcealmentColliderBaker.cs b/Assets/Scripts/ConcealmentColliderBaker.cs
new file mode 100644
index 0000000..828eb87
--- /dev/null
+++ b/Assets/Scripts/ConcealmentColliderBaker.cs
@@ -0,0 +1,608 @@
+using System;
+using System.Collections.Generic;
+using UnityEngine;
+#if UNITY_EDITOR
+using UnityEditor;
+using UnityEditor.SceneManagement;
+#endif
+
+///
+/// Editor tool that decomposes an environment mesh (typically an open-shell / non-watertight
+/// furniture mesh) into a small set of CONVEX MeshColliders via surface clustering, so
+/// can treat the furniture as "embeddable" through its
+/// convex-collider ClosestPoint path WITHOUT a single convex hull filling the open compartments.
+///
+/// Approach (no voxels, no native plugin — "approximate convex decomposition" on the surface):
+/// 1. Weld coincident vertices so connectivity is detected across split-normal/UV seams.
+/// 2. Grow connected triangle regions into SLABS: a region keeps absorbing edge-adjacent
+/// triangles while it stays thinner than along its flattest
+/// axis (PCA minor axis). This spans both faces of one board (they are within a board's
+/// thickness) but refuses to jump the gap to the next board (that would thicken the region).
+/// 3. Convex-hull each slab into its own convex collider. Boards become thin convex slabs;
+/// the empty space between boards is covered by no collider, so it stays hollow.
+///
+/// The colliders are real child GameObjects referencing SAVED hull-mesh assets, so they survive
+/// domain reload / play-mode / scene save (unlike an in-memory-only bake).
+///
+/// Runtime-inert: the component exists only so can detect it in a
+/// collider's parent and skip the furniture's own concave MeshCollider (exactly like a baked
+/// ConcealmentVolume does) — the generated convex slabs are the authoritative embedding shape.
+///
+[DisallowMultipleComponent]
+public class ConcealmentColliderBaker : MonoBehaviour
+{
+ [Tooltip("Optional. If empty, the shared mesh of a MeshFilter on this object (or a child) is used.")]
+ [SerializeField] private Mesh sourceMesh;
+
+ [Header("Clustering")]
+ [Tooltip("Vertices closer than this (local units) are welded, so shared edges are found across split-normal/UV seams. ~ a hair below the smallest real feature.")]
+ [Min(1e-4f)] [SerializeField] private float weldEpsilon = 1e-3f;
+
+ [Tooltip("A connected region keeps growing while it stays thinner than this (local units) along its flattest axis. ~ board thickness. Larger => fewer, chunkier colliders (risk of bridging compartments); smaller => more, thinner slabs.")]
+ [Min(1e-3f)] [SerializeField] private float maxSlabThickness = 0.12f;
+
+ [Tooltip("Flat/thin clusters are inflated to at least this thickness (local units) so the convex collider has usable volume for ClosestPoint tests.")]
+ [Min(1e-3f)] [SerializeField] private float minThickness = 0.04f;
+
+ [Tooltip("Clusters with fewer triangles than this are discarded as noise.")]
+ [Min(1)] [SerializeField] private int minClusterTriangles = 1;
+
+ [Tooltip("Hard cap on generated colliders. The meter's broadphase is capped at 64 colliders TOTAL across all environment in range, so keep this modest. Largest clusters win.")]
+ [Min(1)] [SerializeField] private int maxColliders = 24;
+
+ [Header("Output")]
+ [Tooltip("If true, generated colliders inherit the source object's layer (so they land on the meter's environment mask). Otherwise 'Collider Layer' is used.")]
+ [SerializeField] private bool useSourceLayer = true;
+ [SerializeField] private int colliderLayer;
+
+ [SerializeField, HideInInspector] private List generatedAssetPaths = new List();
+
+ private const string ContainerName = "ConcealmentColliders (generated)";
+ private const string AssetFolder = "Assets/Generated/ConcealmentColliders";
+
+#if UNITY_EDITOR
+ [ContextMenu("Bake Convex Colliders")]
+ public void Bake()
+ {
+ Mesh mesh = sourceMesh;
+ Transform meshSpace = transform;
+ if (!mesh)
+ {
+ MeshFilter mf = GetComponentInChildren();
+ if (mf && mf.sharedMesh) { mesh = mf.sharedMesh; meshSpace = mf.transform; }
+ }
+ if (!mesh)
+ {
+ Debug.LogError($"[ConcealmentColliderBaker] '{name}': no source mesh (assign one or add a MeshFilter).", this);
+ return;
+ }
+
+ Clear(); // remove previous container + assets first
+
+ string baseName = ResolveAssetBaseName();
+
+ Vector3[] rawVerts = mesh.vertices;
+ int[] rawTris = mesh.triangles;
+
+ // 1. Weld coincident vertices.
+ WeldVertices(rawVerts, weldEpsilon, out List wv, out int[] map);
+
+ // Welded triangles (drop degenerate).
+ var triA = new List(); var triB = new List(); var triC = new List();
+ for (int i = 0; i + 2 < rawTris.Length; i += 3)
+ {
+ int a = map[rawTris[i]], b = map[rawTris[i + 1]], c = map[rawTris[i + 2]];
+ if (a == b || b == c || a == c) continue;
+ triA.Add(a); triB.Add(b); triC.Add(c);
+ }
+ int triCount = triA.Count;
+ if (triCount == 0)
+ {
+ Debug.LogWarning($"[ConcealmentColliderBaker] '{name}': mesh has no valid triangles after welding.", this);
+ return;
+ }
+
+ // 2. Edge adjacency (welded edges shared by triangles).
+ var edgeToTri = new Dictionary(triCount * 3);
+ var neighbors = new List[triCount];
+ for (int t = 0; t < triCount; t++) neighbors[t] = new List();
+ for (int t = 0; t < triCount; t++)
+ {
+ LinkEdge(edgeToTri, neighbors, triA[t], triB[t], t);
+ LinkEdge(edgeToTri, neighbors, triB[t], triC[t], t);
+ LinkEdge(edgeToTri, neighbors, triC[t], triA[t], t);
+ }
+
+ // 3. Grow connected triangles into thickness-bounded slabs.
+ // Disconnected boards never link (separate components); connected assemblies split
+ // wherever a region would exceed maxSlabThickness.
+ var clusters = new List>();
+ bool[] assigned = new bool[triCount];
+ var queue = new Queue();
+ var vset = new HashSet();
+ var vpos = new List();
+ var added = new List();
+
+ for (int s = 0; s < triCount; s++)
+ {
+ if (assigned[s]) continue;
+
+ var cluster = new List { s };
+ assigned[s] = true;
+ vset.Clear(); vpos.Clear();
+ AddTriVerts(vset, vpos, wv, triA[s], triB[s], triC[s], null);
+
+ queue.Clear();
+ for (int k = 0; k < neighbors[s].Count; k++) queue.Enqueue(neighbors[s][k]);
+
+ while (queue.Count > 0)
+ {
+ int t = queue.Dequeue();
+ if (assigned[t]) continue;
+
+ int before = vpos.Count;
+ AddTriVerts(vset, vpos, wv, triA[t], triB[t], triC[t], added);
+
+ float th = SlabThickness(vpos, out _);
+ if (th <= maxSlabThickness)
+ {
+ assigned[t] = true;
+ cluster.Add(t);
+ for (int k = 0; k < neighbors[t].Count; k++)
+ if (!assigned[neighbors[t][k]]) queue.Enqueue(neighbors[t][k]);
+ }
+ else
+ {
+ // Revert the tentatively added verts.
+ for (int k = 0; k < added.Count; k++) vset.Remove(added[k]);
+ if (vpos.Count > before) vpos.RemoveRange(before, vpos.Count - before);
+ }
+ }
+
+ clusters.Add(cluster);
+ }
+
+ // 4. Filter noise + cap count (keep largest slabs).
+ clusters.RemoveAll(c => c.Count < minClusterTriangles);
+ clusters.Sort((x, y) => y.Count.CompareTo(x.Count));
+ int dropped = 0;
+ if (clusters.Count > maxColliders)
+ {
+ dropped = clusters.Count - maxColliders;
+ clusters.RemoveRange(maxColliders, dropped);
+ }
+
+ // 5. Build a convex collider per slab.
+ EnsureFolder();
+ Transform container = new GameObject(ContainerName).transform;
+ container.SetParent(meshSpace, false);
+ container.localPosition = Vector3.zero;
+ container.localRotation = Quaternion.identity;
+ container.localScale = Vector3.one;
+ int outLayer = useSourceLayer ? gameObject.layer : colliderLayer;
+
+ int made = 0;
+ for (int ci = 0; ci < clusters.Count; ci++)
+ {
+ // Unique verts of this cluster.
+ var pset = new HashSet();
+ var pts = new List();
+ List cl = clusters[ci];
+ for (int j = 0; j < cl.Count; j++)
+ {
+ int t = cl[j];
+ if (pset.Add(triA[t])) pts.Add(wv[triA[t]]);
+ if (pset.Add(triB[t])) pts.Add(wv[triB[t]]);
+ if (pset.Add(triC[t])) pts.Add(wv[triC[t]]);
+ }
+
+ float thick = SlabThickness(pts, out Vector3 normal);
+ List hullPts;
+ if (thick < minThickness)
+ {
+ // Inflate a (near-)flat slab into a thin prism so the hull has volume.
+ float half = minThickness * 0.5f;
+ hullPts = new List(pts.Count * 2);
+ for (int k = 0; k < pts.Count; k++)
+ {
+ hullPts.Add(pts[k] + normal * half);
+ hullPts.Add(pts[k] - normal * half);
+ }
+ }
+ else hullPts = pts;
+
+ if (!ComputeConvexHull(hullPts.ToArray(), out List hv, out List ht) || hv.Count < 4)
+ continue;
+
+ Mesh cm = new Mesh { name = $"{baseName}_{made}" };
+ cm.SetVertices(hv);
+ cm.SetTriangles(ht, 0);
+ cm.RecalculateBounds();
+
+ string path = $"{AssetFolder}/{baseName}_{made}.asset";
+ AssetDatabase.CreateAsset(cm, path);
+ generatedAssetPaths.Add(path);
+
+ GameObject go = new GameObject($"hull_{made}");
+ go.transform.SetParent(container, false);
+ go.layer = outLayer;
+ MeshCollider col = go.AddComponent();
+ col.sharedMesh = cm;
+ col.convex = true;
+ col.isTrigger = false;
+
+ made++;
+ }
+
+ AssetDatabase.SaveAssets();
+ EditorUtility.SetDirty(this);
+ UnityEditor.SceneManagement.EditorSceneManager.MarkSceneDirty(gameObject.scene);
+
+ Debug.Log($"[ConcealmentColliderBaker] '{name}': baked {made} convex collider(s)" +
+ (dropped > 0 ? $" (+{dropped} smaller slab(s) dropped by Max Colliders={maxColliders})" : "") +
+ ". Save the scene to persist.", this);
+ }
+
+ [ContextMenu("Clear Baked Colliders")]
+ public void Clear()
+ {
+ for (int i = 0; i < generatedAssetPaths.Count; i++)
+ if (!string.IsNullOrEmpty(generatedAssetPaths[i]))
+ AssetDatabase.DeleteAsset(generatedAssetPaths[i]);
+ generatedAssetPaths.Clear();
+
+ var all = GetComponentsInChildren(true);
+ for (int i = 0; i < all.Length; i++)
+ if (all[i] && all[i] != transform && all[i].name == ContainerName)
+ DestroyImmediate(all[i].gameObject);
+
+ EditorUtility.SetDirty(this);
+ if (gameObject.scene.IsValid())
+ UnityEditor.SceneManagement.EditorSceneManager.MarkSceneDirty(gameObject.scene);
+ }
+
+ private static void EnsureFolder()
+ {
+ if (!AssetDatabase.IsValidFolder("Assets/Generated"))
+ AssetDatabase.CreateFolder("Assets", "Generated");
+ if (!AssetDatabase.IsValidFolder(AssetFolder))
+ AssetDatabase.CreateFolder("Assets/Generated", "ConcealmentColliders");
+ }
+
+ ///
+ /// Readable, deterministic base name for the generated collider-mesh assets (replaces the old GUID):
+ /// - Prefab stage: "{assetPath without 'Assets/' and '.prefab'}/{path below the prefab root}"
+ /// - Scene: "{sceneName}/{full hierarchy path from the scene root}"
+ /// Sanitized to a filesystem-safe token; on collision with a DIFFERENT baker's assets, "_2", "_3", … is appended.
+ ///
+ private string ResolveAssetBaseName()
+ {
+ string raw;
+ PrefabStage stage = PrefabStageUtility.GetPrefabStage(gameObject);
+ if (stage != null)
+ {
+ string ap = stage.assetPath;
+ if (ap.StartsWith("Assets/")) ap = ap.Substring("Assets/".Length);
+ if (ap.EndsWith(".prefab")) ap = ap.Substring(0, ap.Length - ".prefab".Length);
+ // Path below the prefab root — the root's name already lives in the asset path.
+ string hier = GetHierarchyPath(transform, stage.prefabContentsRoot.transform);
+ raw = hier.Length == 0 ? ap : ap + "/" + hier;
+ }
+ else
+ {
+ string scene = string.IsNullOrEmpty(gameObject.scene.name) ? "Untitled" : gameObject.scene.name;
+ raw = scene + "/" + GetHierarchyPath(transform, null);
+ }
+
+ string baseName = SanitizeFileName(raw);
+ if (baseName.Length == 0) baseName = "Bake";
+
+ // Clear() already removed THIS baker's assets, so a hit here means another baker resolved to the
+ // same name -> disambiguate. The per-collider "_0" file is the presence marker of a base name.
+ string candidate = baseName;
+ for (int dup = 2; System.IO.File.Exists($"{AssetFolder}/{candidate}_0.asset"); dup++)
+ candidate = $"{baseName}_{dup}";
+ return candidate;
+ }
+
+ /// Slash-joined transform names from up to (but excluding) (null = up to the root).
+ private static string GetHierarchyPath(Transform t, Transform stopAt)
+ {
+ var names = new List();
+ for (Transform cur = t; cur && cur != stopAt; cur = cur.parent)
+ names.Add(cur.name);
+ names.Reverse();
+ return string.Join("/", names);
+ }
+
+ /// Collapses every run of non-[A-Za-z0-9-] characters to a single '_' and trims leading/trailing '_'.
+ private static string SanitizeFileName(string s)
+ {
+ var sb = new System.Text.StringBuilder(s.Length);
+ bool pendingSep = false;
+ for (int i = 0; i < s.Length; i++)
+ {
+ char c = s[i];
+ bool keep = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-';
+ if (keep)
+ {
+ if (pendingSep && sb.Length > 0) sb.Append('_');
+ pendingSep = false;
+ sb.Append(c);
+ }
+ else pendingSep = true;
+ }
+ return sb.ToString();
+ }
+#endif
+
+ // ---- Welding & clustering helpers (runtime-safe: no editor API) ----
+
+ private static void WeldVertices(Vector3[] src, float eps, out List welded, out int[] map)
+ {
+ welded = new List(src.Length);
+ map = new int[src.Length];
+ float inv = 1f / Mathf.Max(eps, 1e-6f);
+ var lut = new Dictionary(src.Length);
+ for (int i = 0; i < src.Length; i++)
+ {
+ Vector3 p = src[i];
+ int qx = Mathf.RoundToInt(p.x * inv);
+ int qy = Mathf.RoundToInt(p.y * inv);
+ int qz = Mathf.RoundToInt(p.z * inv);
+ long key = ((long)(qx & 0x1FFFFF) << 42) | ((long)(qy & 0x1FFFFF) << 21) | (long)(qz & 0x1FFFFF);
+ if (lut.TryGetValue(key, out int idx)) { map[i] = idx; }
+ else { idx = welded.Count; lut[key] = idx; welded.Add(p); map[i] = idx; }
+ }
+ }
+
+ private static void LinkEdge(Dictionary edgeToTri, List[] neighbors, int u, int v, int tri)
+ {
+ long key = EdgeKey(u, v);
+ if (edgeToTri.TryGetValue(key, out int other))
+ {
+ neighbors[tri].Add(other);
+ neighbors[other].Add(tri);
+ edgeToTri[key] = tri; // chain non-manifold edges (>2 tris) so all get linked
+ }
+ else edgeToTri[key] = tri;
+ }
+
+ private static void AddTriVerts(HashSet set, List pos, List wv, int a, int b, int c, List addedOut)
+ {
+ addedOut?.Clear();
+ if (set.Add(a)) { pos.Add(wv[a]); addedOut?.Add(a); }
+ if (set.Add(b)) { pos.Add(wv[b]); addedOut?.Add(b); }
+ if (set.Add(c)) { pos.Add(wv[c]); addedOut?.Add(c); }
+ }
+
+ /// Extent of the point cloud along its PCA minor axis (= slab thickness), plus that axis.
+ private static float SlabThickness(List pts, out Vector3 normal)
+ {
+ normal = Vector3.up;
+ int n = pts.Count;
+ if (n < 2) return 0f;
+
+ Vector3 mean = Vector3.zero;
+ for (int i = 0; i < n; i++) mean += pts[i];
+ mean /= n;
+
+ double c00 = 0, c01 = 0, c02 = 0, c11 = 0, c12 = 0, c22 = 0;
+ for (int i = 0; i < n; i++)
+ {
+ double x = pts[i].x - mean.x, y = pts[i].y - mean.y, z = pts[i].z - mean.z;
+ c00 += x * x; c01 += x * y; c02 += x * z;
+ c11 += y * y; c12 += y * z; c22 += z * z;
+ }
+
+ var a = new double[3, 3] { { c00, c01, c02 }, { c01, c11, c12 }, { c02, c12, c22 } };
+ Jacobi3(a, out double[,] v, out double[] d);
+
+ int mi = 0;
+ if (d[1] < d[mi]) mi = 1;
+ if (d[2] < d[mi]) mi = 2;
+ normal = new Vector3((float)v[0, mi], (float)v[1, mi], (float)v[2, mi]);
+ if (normal.sqrMagnitude < 1e-12f) { normal = Vector3.up; return 0f; }
+ normal.Normalize();
+
+ float mn = float.MaxValue, mx = float.MinValue;
+ for (int i = 0; i < n; i++)
+ {
+ float dp = Vector3.Dot(pts[i], normal);
+ if (dp < mn) mn = dp;
+ if (dp > mx) mx = dp;
+ }
+ return mx - mn;
+ }
+
+ /// Cyclic Jacobi eigendecomposition of a symmetric 3x3 matrix (eigenvectors in columns of v).
+ private static void Jacobi3(double[,] a, out double[,] v, out double[] d)
+ {
+ v = new double[3, 3] { { 1, 0, 0 }, { 0, 1, 0 }, { 0, 0, 1 } };
+ for (int sweep = 0; sweep < 24; sweep++)
+ {
+ double off = Math.Abs(a[0, 1]) + Math.Abs(a[0, 2]) + Math.Abs(a[1, 2]);
+ if (off < 1e-18) break;
+ JacobiRotate(a, v, 0, 1);
+ JacobiRotate(a, v, 0, 2);
+ JacobiRotate(a, v, 1, 2);
+ }
+ d = new double[] { a[0, 0], a[1, 1], a[2, 2] };
+ }
+
+ private static void JacobiRotate(double[,] a, double[,] v, int p, int q)
+ {
+ double apq = a[p, q];
+ if (Math.Abs(apq) < 1e-20) return;
+
+ double app = a[p, p], aqq = a[q, q];
+ double phi = 0.5 * Math.Atan2(2.0 * apq, aqq - app);
+ double c = Math.Cos(phi), s = Math.Sin(phi);
+
+ a[p, p] = c * c * app - 2.0 * s * c * apq + s * s * aqq;
+ a[q, q] = s * s * app + 2.0 * s * c * apq + c * c * aqq;
+ a[p, q] = 0; a[q, p] = 0;
+
+ int r = 3 - p - q;
+ double arp = a[r, p], arq = a[r, q];
+ double nrp = c * arp - s * arq;
+ double nrq = s * arp + c * arq;
+ a[r, p] = nrp; a[p, r] = nrp;
+ a[r, q] = nrq; a[q, r] = nrq;
+
+ for (int k = 0; k < 3; k++)
+ {
+ double vkp = v[k, p], vkq = v[k, q];
+ v[k, p] = c * vkp - s * vkq;
+ v[k, q] = s * vkp + c * vkq;
+ }
+ }
+
+ // ---- Convex hull (incremental; outputs a triangulated hull mesh) ----
+
+ private struct HFace { public int a, b, c; public Vector3 n; }
+
+ private static bool ComputeConvexHull(Vector3[] pts, out List hullVertsOut, out List hullTrisOut)
+ {
+ hullVertsOut = new List();
+ hullTrisOut = new List();
+
+ int n = pts.Length;
+ if (n < 4) return false;
+
+ Vector3 bmn = pts[0], bmx = pts[0];
+ for (int i = 1; i < n; i++) { bmn = Vector3.Min(bmn, pts[i]); bmx = Vector3.Max(bmx, pts[i]); }
+ float diag = (bmx - bmn).magnitude;
+ if (diag < 1e-6f) return false;
+ float eps = diag * 1e-6f;
+
+ int minX = 0, maxX = 0;
+ for (int i = 1; i < n; i++) { if (pts[i].x < pts[minX].x) minX = i; if (pts[i].x > pts[maxX].x) maxX = i; }
+ int i0 = minX, i1 = maxX;
+ if (i0 == i1) return false;
+
+ Vector3 lineDir = pts[i1] - pts[i0];
+ float lineLen = lineDir.magnitude;
+ if (lineLen < eps) return false;
+ lineDir /= lineLen;
+
+ int i2 = -1; float best = eps;
+ for (int i = 0; i < n; i++)
+ {
+ float dd = Vector3.Cross(pts[i] - pts[i0], lineDir).magnitude;
+ if (dd > best) { best = dd; i2 = i; }
+ }
+ if (i2 < 0) return false;
+
+ Vector3 nrm0 = Vector3.Cross(pts[i1] - pts[i0], pts[i2] - pts[i0]);
+ float nl = nrm0.magnitude;
+ if (nl < eps) return false;
+ nrm0 /= nl;
+
+ int i3 = -1; best = eps;
+ for (int i = 0; i < n; i++)
+ {
+ float dd = Mathf.Abs(Vector3.Dot(pts[i] - pts[i0], nrm0));
+ if (dd > best) { best = dd; i3 = i; }
+ }
+ if (i3 < 0) return false;
+
+ Vector3 interior = (pts[i0] + pts[i1] + pts[i2] + pts[i3]) * 0.25f;
+
+ var faces = new List(4 + n);
+ AddHullFace(faces, pts, interior, i0, i1, i2);
+ AddHullFace(faces, pts, interior, i0, i1, i3);
+ AddHullFace(faces, pts, interior, i0, i2, i3);
+ AddHullFace(faces, pts, interior, i1, i2, i3);
+
+ var visible = new List();
+ var edgeCount = new Dictionary();
+ var horizonA = new List();
+ var horizonB = new List();
+
+ for (int pi = 0; pi < n; pi++)
+ {
+ if (pi == i0 || pi == i1 || pi == i2 || pi == i3) continue;
+ Vector3 p = pts[pi];
+
+ visible.Clear();
+ for (int f = 0; f < faces.Count; f++)
+ {
+ HFace hf = faces[f];
+ if (Vector3.Dot(hf.n, p - pts[hf.a]) > eps) visible.Add(f);
+ }
+ if (visible.Count == 0) continue;
+
+ edgeCount.Clear();
+ for (int vi = 0; vi < visible.Count; vi++)
+ {
+ HFace hf = faces[visible[vi]];
+ AccEdge(edgeCount, hf.a, hf.b);
+ AccEdge(edgeCount, hf.b, hf.c);
+ AccEdge(edgeCount, hf.c, hf.a);
+ }
+ horizonA.Clear(); horizonB.Clear();
+ for (int vi = 0; vi < visible.Count; vi++)
+ {
+ HFace hf = faces[visible[vi]];
+ if (EdgeIsHorizon(edgeCount, hf.a, hf.b)) { horizonA.Add(hf.a); horizonB.Add(hf.b); }
+ if (EdgeIsHorizon(edgeCount, hf.b, hf.c)) { horizonA.Add(hf.b); horizonB.Add(hf.c); }
+ if (EdgeIsHorizon(edgeCount, hf.c, hf.a)) { horizonA.Add(hf.c); horizonB.Add(hf.a); }
+ }
+
+ visible.Sort();
+ for (int vi = visible.Count - 1; vi >= 0; vi--) faces.RemoveAt(visible[vi]);
+
+ for (int h = 0; h < horizonA.Count; h++)
+ AddHullFace(faces, pts, interior, horizonA[h], horizonB[h], pi);
+ }
+
+ if (faces.Count < 4) return false;
+
+ var remap = new Dictionary();
+ for (int f = 0; f < faces.Count; f++)
+ {
+ HFace hf = faces[f];
+ hullTrisOut.Add(RemapVert(remap, hullVertsOut, pts, hf.a));
+ hullTrisOut.Add(RemapVert(remap, hullVertsOut, pts, hf.b));
+ hullTrisOut.Add(RemapVert(remap, hullVertsOut, pts, hf.c));
+ }
+ return true;
+ }
+
+ private static void AddHullFace(List faces, Vector3[] pts, Vector3 interior, int a, int b, int c)
+ {
+ Vector3 nn = Vector3.Cross(pts[b] - pts[a], pts[c] - pts[a]);
+ float l = nn.magnitude;
+ if (l < 1e-20f) return;
+ nn /= l;
+ if (Vector3.Dot(nn, pts[a] - interior) < 0f) nn = -nn;
+ faces.Add(new HFace { a = a, b = b, c = c, n = nn });
+ }
+
+ private static long EdgeKey(int u, int v)
+ {
+ int lo = u < v ? u : v, hi = u < v ? v : u;
+ return ((long)lo << 32) | (uint)hi;
+ }
+
+ private static void AccEdge(Dictionary ec, int u, int v)
+ {
+ long k = EdgeKey(u, v);
+ ec.TryGetValue(k, out int c);
+ ec[k] = c + 1;
+ }
+
+ private static bool EdgeIsHorizon(Dictionary ec, int u, int v)
+ {
+ ec.TryGetValue(EdgeKey(u, v), out int c);
+ return c == 1;
+ }
+
+ private static int RemapVert(Dictionary remap, List outV, Vector3[] pts, int idx)
+ {
+ if (remap.TryGetValue(idx, out int r)) return r;
+ r = outV.Count; remap[idx] = r; outV.Add(pts[idx]);
+ return r;
+ }
+}
diff --git a/Assets/Scripts/ConcealmentColliderBaker.cs.meta b/Assets/Scripts/ConcealmentColliderBaker.cs.meta
new file mode 100644
index 0000000..d0f74f6
--- /dev/null
+++ b/Assets/Scripts/ConcealmentColliderBaker.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 80f683e75cf2f2a41bb32d8e9de0a2f0
\ No newline at end of file
diff --git a/Assets/Scripts/ConcealmentMeter.cs b/Assets/Scripts/ConcealmentMeter.cs
index 0efd26f..70f53e4 100644
--- a/Assets/Scripts/ConcealmentMeter.cs
+++ b/Assets/Scripts/ConcealmentMeter.cs
@@ -323,8 +323,9 @@ public class ConcealmentMeter : MonoBehaviour
if (col is MeshCollider mc && !mc.convex)
{
- // A baked volume supersedes raycast parity for this object.
- if (includeConcaveMeshColliders && !mc.GetComponentInParent())
+ // A baked ConcealmentVolume or ConcealmentColliderBaker supersedes raycast parity for this object.
+ if (includeConcaveMeshColliders && !mc.GetComponentInParent()
+ && !mc.GetComponentInParent())
_concave.Add(mc);
continue; // concave can't use ClosestPoint
}