// Editor-only collision-fidelity tools (Phase 1.5 stabilize #2 — DR: Iteration_2026-07_CoopHades). // The Environment (layer 8) colliders live as invisible primitives in the Gameplay SUBSCENE while every // visual mesh is a classic GameObject in Game.unity — two disconnected trees, so fidelity drifts. This // tool re-fits the subscene colliders to the Game.unity render meshes: // - boundary rings: REBUILT at ~3.5-4 u arcs around the DESIGN radius (base 30 / expedition 52 — NEVER // the current segments' average, which ratchets inward on every re-run), each segment's inner face // sampled from cliff vertices in the ANKLE band (0.45..2.5) so the wall stops the player BEFORE any // visibly rising skirt they could phase into (operator height-pass directive 07-09) // - room cover: convex MeshCollider from the actual CoverRock_* render mesh (exact fit) // - landmarks: oriented box fit from the hint-matched prop's walk-band vertices (claim-once) // - adds: named kinds (tree trunks / turbine poles / solar rigs) PLUS a GENERIC solid sweep — every // non-soft visual >= 0.5 u tall with walk-band geometry inside the LOCAL fitted wall gets a walk-band // OBB collider, top pinned to the visual's top (capped 3): nothing solid-looking is walk-through, and // nothing that reads as a mound/pile is walk-over. Craters become FULL blocks (set-piece obstacles). // Audit = dry run (report only). Apply = mutate + save the SUBSCENE only (Game.unity is never written). // Re-run contract: idempotent — ring segments and prop adds are regenerated from scratch each Apply. // Gotcha (CLAUDE.md): a MeshCollider bakes ONLY if the mesh asset has Read/Write enabled. #if UNITY_EDITOR using System.Collections.Generic; using System.Text; using UnityEditor; using UnityEditor.SceneManagement; using UnityEngine; using UnityEngine.SceneManagement; namespace ProjectM.EditorTools { public static class ColliderFitTools { const string SubscenePath = "Assets/_Project/Subscenes/Gameplay.unity"; const int EnvLayer = 8; // "Environment" const float RingBandMin = 0.45f, RingBandMax = 2.5f; // ankle band: stop before any visible rise const float PropBandMin = 0.1f, PropBandMax = 2.5f; // landmark/prop footprint band const float WallThickness = 1.5f, WallHeight = 5f; const float SolidMinHeight = 0.5f, SolidMinFoot = 0.7f, SolidMaxFoot = 15f; // Walk-through-acceptable flora/detail — everything ELSE that clears the solid thresholds gets a collider. static readonly System.Text.RegularExpressions.Regex SoftName = new System.Text.RegularExpressions.Regex( "grass|flower|fern|leaf|leaves|branch|vine|ivy|moss|mushroom|bush|patch|pebble|groundcover|ground_cover|clover|weed|reed|lily|cliff|ground|river|water|coverrock", System.Text.RegularExpressions.RegexOptions.IgnoreCase); struct Region { public string Name; public Vector3 Center; } static readonly Region[] Regions = { new Region { Name = "base", Center = new Vector3(0f, 0f, 0f) }, new Region { Name = "exp", Center = new Vector3(1000f, 0f, 0f) }, new Region { Name = "slot1", Center = new Vector3(1500f, 0f, 0f) }, }; // LM-name-substring -> visual-name-substring, both compared normalized (lowercase, underscores // stripped). Ordered specific-first ("waterwheel" must win before "well"). static readonly string[][] LandmarkHints = { new[] { "waterwheel", "waterwheel" }, new[] { "warpgate", "warpgate" }, new[] { "windmill", "windmill" }, new[] { "surveytent", "proptent" }, new[] { "artefact", "artefact" }, new[] { "cabin", "stonecabin" }, new[] { "well", "propwell" }, new[] { "sign", "propsign" }, }; [MenuItem("ProjectM/World/Collider Fit — Audit (dry run)")] public static void AuditMenu() { Debug.Log(Audit()); } [MenuItem("ProjectM/World/Collider Fit — APPLY + save subscene")] public static void ApplyMenu() { Debug.Log(Apply()); } public static string Audit() { return Run(false); } public static string Apply() { return Run(true); } static string Run(bool apply) { var rep = new StringBuilder(); rep.AppendLine(apply ? "[ColliderFit] APPLY" : "[ColliderFit] AUDIT (dry run)"); var game = SceneManager.GetSceneByName("Game"); if (!game.isLoaded) return "[ColliderFit] ABORT: Game.unity must be loaded (visual source)."; var sub = SceneManager.GetSceneByPath(SubscenePath); if (!sub.isLoaded) sub = EditorSceneManager.OpenScene(SubscenePath, OpenSceneMode.Additive); var visuals = CollectVisuals(game); rep.AppendLine("visual LOD0 renderers: " + visuals.Count); var groups = FindGroups(sub); var profiles = new Dictionary(); profiles["base"] = RebuildRing(groups, "WorldColliders_BaseRing", 3.5f, 30f, visuals, apply, rep); profiles["exp"] = RebuildRing(groups, "WorldColliders_ExpeditionRing", 4f, 52f, visuals, apply, rep); profiles["slot1"] = RebuildRing(groups, "WorldColliders_ExpeditionRing_Slot1", 4f, 52f, visuals, apply, rep); var solids = new List(); FitCover(groups, visuals, apply, rep, solids); FitLandmarks(groups, visuals, apply, rep, solids); AddPropColliders(sub, groups, visuals, solids, profiles, apply, rep); if (apply) { EditorSceneManager.MarkSceneDirty(sub); EditorSceneManager.SaveScene(sub); rep.AppendLine("[ColliderFit] SAVED " + SubscenePath); } return rep.ToString(); } // ---------- visual collection ---------- struct Vis { public Renderer R; public Mesh Mesh; public string Name; } static List CollectVisuals(Scene game) { var list = new List(); foreach (var root in game.GetRootGameObjects()) foreach (var mf in root.GetComponentsInChildren(true)) { var r = mf.GetComponent(); if (r == null || mf.sharedMesh == null) continue; string n = mf.gameObject.name; if (n.Contains("LOD1") || n.Contains("LOD2") || n.Contains("LOD3")) continue; // LOD0/no-suffix only list.Add(new Vis { R = r, Mesh = mf.sharedMesh, Name = n }); } return list; } static Dictionary FindGroups(Scene sub) { var groups = new Dictionary(); foreach (var root in sub.GetRootGameObjects()) foreach (var t in root.GetComponentsInChildren(true)) if (t.name.StartsWith("WorldColliders_")) groups[t.name] = t; return groups; } static void GatherWorldVerts(Vis v, float yMin, float yMax, List into) { var verts = v.Mesh.vertices; var m = v.R.transform.localToWorldMatrix; for (int i = 0; i < verts.Length; i++) { var w = m.MultiplyPoint3x4(verts[i]); if (w.y >= yMin && w.y <= yMax) into.Add(w); } } static string Normalize(string s) { return s.Replace("_", "").ToLowerInvariant(); } // ---------- boundary rings (rebuild at fine resolution around the DESIGN radius) ---------- /// Per-angle fitted wall profile: ThetaDeg/Inner are parallel, one entry per new segment. /// Used by the adds pass to gate props by the LOCAL wall radius at their angle. class RingProfile { public Vector3 Center; public float[] ThetaDeg; public float[] Inner; public float InnerAt(Vector3 pos) { var d = pos - Center; d.y = 0f; float theta = Mathf.Atan2(d.z, d.x) * Mathf.Rad2Deg; float bestD = float.MaxValue; float inner = 0f; for (int i = 0; i < ThetaDeg.Length; i++) { float dd = Mathf.Abs(Mathf.DeltaAngle(theta, ThetaDeg[i])); if (dd < bestD) { bestD = dd; inner = Inner[i]; } } return inner; } } static RingProfile RebuildRing(Dictionary groups, string name, float targetArc, float designR, List visuals, bool apply, StringBuilder rep) { if (!groups.TryGetValue(name, out var group)) { rep.AppendLine("MISSING group " + name); return null; } Vector3 c = group.position; int nOld = group.childCount; float r0 = designR; // NEVER the current average — a fitted ring re-read would ratchet the window inward var verts = new List(); foreach (var v in visuals) { if (!v.Name.Contains("Cliff")) continue; var d = v.R.bounds.center - c; d.y = 0f; if (d.magnitude > 130f) continue; GatherWorldVerts(v, RingBandMin, RingBandMax, verts); } int segCount = Mathf.Max(8, Mathf.RoundToInt(2f * Mathf.PI * r0 / targetArc)); float halfSpanDeg = 180f / segCount; var thetas = new float[segCount]; var inners = new float[segCount]; int clamped = 0, noSample = 0; float minI = float.MaxValue, maxI = float.MinValue, sumI = 0f; for (int s = 0; s < segCount; s++) { float theta = -180f + (s + 0.5f) * (360f / segCount); float best = float.MaxValue; for (int i = 0; i < verts.Count; i++) { var dv = verts[i] - c; dv.y = 0f; float vr = dv.magnitude; if (vr < r0 - 8f || vr > r0 + 15f) continue; float dth = Mathf.Abs(Mathf.DeltaAngle(theta, Mathf.Atan2(dv.z, dv.x) * Mathf.Rad2Deg)); if (dth > halfSpanDeg * 1.2f) continue; if (vr < best) best = vr; } float inner; if (best == float.MaxValue) { inner = r0 - WallThickness * 0.5f; noSample++; } else { inner = Mathf.Clamp(best, r0 - 8f, r0 + 12f); if (best < r0 - 8f || best > r0 + 12f) clamped++; } thetas[s] = theta; inners[s] = inner; minI = Mathf.Min(minI, inner); maxI = Mathf.Max(maxI, inner); sumI += inner; } rep.AppendLine("== " + name + ": designR=" + r0.ToString("F0") + " -> " + segCount + " segs (arc ~" + targetArc + "u), inner min/avg/max = " + minI.ToString("F1") + "/" + (sumI / segCount).ToString("F1") + "/" + maxI.ToString("F1") + ", clamped=" + clamped + ", noSample=" + noSample); if (apply) { for (int i = group.childCount - 1; i >= 0; i--) Object.DestroyImmediate(group.GetChild(i).gameObject); for (int s = 0; s < segCount; s++) { float rad = thetas[s] * Mathf.Deg2Rad; var dir = new Vector3(Mathf.Cos(rad), 0f, Mathf.Sin(rad)); float centerR = inners[s] + WallThickness * 0.5f; var go = new GameObject("Seg_" + s); go.layer = EnvLayer; go.transform.SetParent(group, false); go.transform.position = c + dir * centerR; go.transform.rotation = Quaternion.LookRotation(dir, Vector3.up); // +z radial, +x tangent var bc = go.AddComponent(); float len = 2f * centerR * Mathf.Tan(halfSpanDeg * Mathf.Deg2Rad) * 1.10f; bc.size = new Vector3(len, WallHeight, WallThickness); bc.center = new Vector3(0f, 2f, 0f); // spans y -0.5..4.5 } rep.AppendLine(" rebuilt " + segCount + " segments (was " + nOld + ")"); } return new RingProfile { Center = c, ThetaDeg = thetas, Inner = inners }; } // ---------- room cover rocks ---------- static void FitCover(Dictionary groups, List visuals, bool apply, StringBuilder rep, List solids) { if (!groups.TryGetValue("WorldColliders_RoomCover", out var group)) { rep.AppendLine("MISSING WorldColliders_RoomCover"); return; } rep.AppendLine("== WorldColliders_RoomCover"); foreach (Transform t in group) { if (!t.name.StartsWith("Cover_")) continue; string want = "CoverRock_" + t.name.Substring("Cover_".Length); Vis vis = default; bool found = false; foreach (var v in visuals) if (v.Name == want) { vis = v; found = true; break; } if (!found) { rep.AppendLine(" " + t.name + ": no visual '" + want + "' — KEEP box"); continue; } var b = vis.R.bounds; rep.AppendLine(" " + t.name + " -> convex mesh of " + want + " (visual XZ " + b.size.x.ToString("F1") + "x" + b.size.z.ToString("F1") + ")"); if (apply) { var bc = t.GetComponent(); if (bc != null) Object.DestroyImmediate(bc); var mc = t.GetComponent(); if (mc == null) mc = t.gameObject.AddComponent(); mc.sharedMesh = vis.Mesh; mc.convex = true; t.SetPositionAndRotation(vis.R.transform.position, vis.R.transform.rotation); t.localScale = vis.R.transform.lossyScale; // group root sits at identity } solids.Add(new SolidBox { Center = b.center, HalfXZ = 0.5f * Mathf.Max(b.size.x, b.size.z) }); } } // ---------- landmarks ---------- struct SolidBox { public Vector3 Center; public float HalfXZ; } static void FitLandmarks(Dictionary groups, List visuals, bool apply, StringBuilder rep, List solids) { if (!groups.TryGetValue("WorldColliders_Landmarks", out var group)) { rep.AppendLine("MISSING WorldColliders_Landmarks"); return; } rep.AppendLine("== WorldColliders_Landmarks"); var claimed = new HashSet(); foreach (Transform t in group) { var box = t.GetComponent(); if (box == null) continue; // 1) hint match: LM name -> expected visual-name substring, nearest unclaimed within 12 u Vis best = default; bool found = false; string lmNorm = Normalize(t.name); foreach (var hint in LandmarkHints) { if (!lmNorm.Contains(hint[0])) continue; float bestDist = float.MaxValue; foreach (var v in visuals) { if (claimed.Contains(v.R) || !Normalize(v.Name).Contains(hint[1])) continue; float dx = v.R.bounds.center.x - t.position.x, dz = v.R.bounds.center.z - t.position.z; float dist = Mathf.Sqrt(dx * dx + dz * dz); if (dist < 12f && dist < bestDist) { bestDist = dist; best = v; found = true; } } break; // first matching hint decides the target name (specific-first order) } // 2) fallback: nearest-largest unclaimed solid prop within 8 u if (!found) { float bestScore = 0f; foreach (var v in visuals) { if (claimed.Contains(v.R)) continue; if (!(v.Name.StartsWith("SM_Prop_") || v.Name.StartsWith("SM_Bld_"))) continue; var b = v.R.bounds; float dx = b.center.x - t.position.x, dz = b.center.z - t.position.z; float dist = Mathf.Sqrt(dx * dx + dz * dz); if (dist > 8f) continue; float score = (b.size.x * b.size.z) / (1f + dist); if (score > bestScore) { bestScore = score; best = v; found = true; } } } if (!found) { rep.AppendLine(" " + t.name + ": no prop match — KEEP"); continue; } claimed.Add(best.R); var verts = new List(); GatherWorldVerts(best, PropBandMin, PropBandMax, verts); var vt = best.R.transform; Vector3 mn, mx; if (verts.Count == 0) { var b = best.R.bounds; // fallback: world AABB in visual frame approximation mn = Quaternion.Inverse(vt.rotation) * (b.min - vt.position); mx = Quaternion.Inverse(vt.rotation) * (b.max - vt.position); } else { var inv = Quaternion.Inverse(vt.rotation); mn = new Vector3(float.MaxValue, float.MaxValue, float.MaxValue); mx = new Vector3(float.MinValue, float.MinValue, float.MinValue); for (int i = 0; i < verts.Count; i++) { var l = inv * (verts[i] - vt.position); mn = Vector3.Min(mn, l); mx = Vector3.Max(mx, l); } } var size = mx - mn; size.x = Mathf.Max(0.5f, size.x); size.z = Mathf.Max(0.5f, size.z); float sizeY = Mathf.Max(2.5f, mx.y); var center = (mn + mx) * 0.5f; center.y = sizeY * 0.5f; rep.AppendLine(" " + t.name + " -> " + best.Name + " oldXZ " + box.size.x.ToString("F1") + "x" + box.size.z.ToString("F1") + " newXZ " + size.x.ToString("F1") + "x" + size.z.ToString("F1")); if (apply) { t.SetPositionAndRotation(vt.position, vt.rotation); box.center = center; box.size = new Vector3(size.x, sizeY, size.z); } solids.Add(new SolidBox { Center = vt.position, HalfXZ = 0.5f * Mathf.Max(size.x, size.z) }); } } // ---------- missing-collider adds (named kinds + GENERIC solid sweep) ---------- static void AddPropColliders(Scene sub, Dictionary groups, List visuals, List solids, Dictionary profiles, bool apply, StringBuilder rep) { rep.AppendLine("== WorldColliders_Props (adds)"); Transform propsRoot = null; if (groups.TryGetValue("WorldColliders_Props", out var existing)) { propsRoot = existing; if (apply) // idempotent re-run: rebuild adds from scratch for (int i = propsRoot.childCount - 1; i >= 0; i--) Object.DestroyImmediate(propsRoot.GetChild(i).gameObject); } else if (apply) { var go = new GameObject("WorldColliders_Props"); SceneManager.MoveGameObjectToScene(go, sub); go.layer = EnvLayer; propsRoot = go.transform; } var placed = new List(); var seenSolarRigs = new HashSet(); int added = 0, skippedRegion = 0, skippedOverlap = 0, skippedSoft = 0, skippedSmall = 0, tooBig = 0; foreach (var v in visuals) { string kind = null; if (v.Name.Contains("_Tree_") && v.Name.EndsWith("_LOD0") && !v.Name.Contains("Branches")) kind = "trunk"; else if (v.Name.Contains("Wind_Turbine") && !v.Name.Contains("Propeller")) kind = "pole"; else if (v.Name.Contains("Solar_Panels_01_Swivel")) kind = "solar"; else if (v.Name.Contains("Solar_Panels")) continue; // rig handled once via the Swivel cluster else if (v.Name.StartsWith("SM_") && !SoftName.IsMatch(v.Name)) kind = "generic"; if (kind == null) { if (v.Name.StartsWith("SM_") && SoftName.IsMatch(v.Name)) skippedSoft++; continue; } var wb = v.R.bounds; if (kind == "generic" && (wb.size.y < SolidMinHeight || Mathf.Max(wb.size.x, wb.size.z) < SolidMinFoot)) { skippedSmall++; continue; } // inside the LOCAL fitted wall at this prop's angle? (checked BEFORE too-big so flags = reachable only) bool inPlay = false; foreach (var reg in Regions) { if (!profiles.TryGetValue(reg.Name, out var prof) || prof == null) continue; var d = wb.center - reg.Center; d.y = 0f; if (d.magnitude > 90f) continue; // not this region if (d.magnitude < prof.InnerAt(wb.center) - 1.0f) { inPlay = true; break; } } if (!inPlay) { skippedRegion++; continue; } if (kind == "generic" && Mathf.Max(wb.size.x, wb.size.z) > SolidMaxFoot) { tooBig++; rep.AppendLine(" TOO-BIG IN-PLAY (manual review) " + v.Name + " @(" + wb.center.x.ToString("F0") + "," + wb.center.z.ToString("F0") + ") " + wb.size.x.ToString("F0") + "x" + wb.size.z.ToString("F0")); continue; } // fit Vector3 pos; string shape; float capR = 0f; Vector3 boxSize = Vector3.zero, boxCenter = Vector3.zero; Quaternion boxRot = Quaternion.identity; if (kind == "trunk" || kind == "pole") { float bandMax = kind == "pole" ? 2.5f : 1.6f; var verts = new List(); GatherWorldVerts(v, 0.25f, bandMax, verts); if (verts.Count < 3) { skippedSmall++; continue; } var centroid = Vector3.zero; foreach (var w in verts) centroid += w; centroid /= verts.Count; var dists = new List(); foreach (var w in verts) { float dx = w.x - centroid.x, dz = w.z - centroid.z; dists.Add(Mathf.Sqrt(dx * dx + dz * dz)); } dists.Sort(); float p90 = dists[Mathf.Clamp(Mathf.FloorToInt(dists.Count * 0.9f), 0, dists.Count - 1)]; capR = Mathf.Clamp(p90, 0.25f, 1.0f); pos = new Vector3(centroid.x, 0f, centroid.z); shape = "capsule r=" + capR.ToString("F2"); } else if (kind == "solar") { var rig = v.R.transform.parent != null ? v.R.transform.parent : v.R.transform; if (!seenSolarRigs.Add(rig)) continue; var b = v.R.bounds; foreach (var r2 in rig.GetComponentsInChildren(true)) b.Encapsulate(r2.bounds); pos = new Vector3(b.center.x, 0f, b.center.z); boxSize = new Vector3(Mathf.Max(0.5f, b.size.x), Mathf.Clamp(b.max.y, 0.8f, 2.5f), Mathf.Max(0.5f, b.size.z)); boxCenter = new Vector3(0f, boxSize.y * 0.5f, 0f); shape = "box " + boxSize.x.ToString("F1") + "x" + boxSize.z.ToString("F1") + " h=" + boxSize.y.ToString("F1"); } else // generic: walk-band OBB in the visual's frame, top pinned to the visual's top { var verts = new List(); GatherWorldVerts(v, PropBandMin, PropBandMax, verts); if (verts.Count == 0) { skippedSmall++; continue; } // elevated geometry: nothing at walk height var vt = v.R.transform; var inv = Quaternion.Inverse(vt.rotation); var mn = new Vector3(float.MaxValue, float.MaxValue, float.MaxValue); var mx = new Vector3(float.MinValue, float.MinValue, float.MinValue); for (int i = 0; i < verts.Count; i++) { var l = inv * (verts[i] - vt.position); mn = Vector3.Min(mn, l); mx = Vector3.Max(mx, l); } var size = mx - mn; size.x = Mathf.Max(0.4f, size.x); size.z = Mathf.Max(0.4f, size.z); float sizeY = Mathf.Clamp(Mathf.Max(mx.y, wb.max.y), 0.8f, 3.0f); // top = visual TOP (capped 3), nothing looks climbable boxSize = new Vector3(size.x, sizeY, size.z); boxCenter = (mn + mx) * 0.5f; boxCenter.y = sizeY * 0.5f; boxRot = vt.rotation; pos = vt.position; pos.y = 0f; shape = "obb " + size.x.ToString("F1") + "x" + size.z.ToString("F1") + " h=" + sizeY.ToString("F1"); } // dedupe vs covers/landmarks + already-placed adds bool overlap = false; var posFlat = new Vector3(pos.x, 0f, pos.z); foreach (var s in solids) if (Vector3.Distance(new Vector3(s.Center.x, 0f, s.Center.z), posFlat) < s.HalfXZ + 0.2f) { overlap = true; break; } if (!overlap) foreach (var p in placed) if (Vector3.Distance(new Vector3(p.Center.x, 0f, p.Center.z), posFlat) < Mathf.Max(0.9f, p.HalfXZ * 0.8f)) { overlap = true; break; } if (overlap) { skippedOverlap++; continue; } float halfXZ = kind == "trunk" || kind == "pole" ? capR : 0.5f * Mathf.Max(boxSize.x, boxSize.z); placed.Add(new SolidBox { Center = posFlat, HalfXZ = halfXZ }); added++; rep.AppendLine(" ADD " + v.Name + " @(" + pos.x.ToString("F1") + "," + pos.z.ToString("F1") + ") " + shape); if (apply) { var go = new GameObject("P_" + v.Name + "_" + added); go.transform.SetParent(propsRoot, false); go.transform.SetPositionAndRotation(pos, boxRot); go.layer = EnvLayer; if (kind == "trunk" || kind == "pole") { var cc = go.AddComponent(); cc.radius = capR; cc.height = 3.5f; cc.center = new Vector3(0f, 1.5f, 0f); cc.direction = 1; } else { var bc = go.AddComponent(); bc.size = boxSize; bc.center = boxCenter; } } } rep.AppendLine("adds: " + added + " placed | skipped: " + skippedRegion + " outside walls, " + skippedOverlap + " covered/overlap, " + skippedSoft + " soft flora, " + skippedSmall + " sub-threshold/elevated, " + tooBig + " too-big flagged IN-PLAY"); } } } #endif