5382cbf341
Phase 1.5 stabilize #2 (gate-approved incl. missing-collider adds + the 07-09 height/solid-sweep directive). New ColliderFitTools (Editor, audit/apply, idempotent) refits the subscene Environment colliders to the Game.unity render meshes: - Boundary rings rebuilt 16/24/24 -> 54/82/82 segments (~3.5-4 u arcs) around the DESIGN radii (30/52 - fitting from the current segments ratchets inward on re-runs), inner faces fitted to ankle-band (0.45-2.5) cliff vertices so the wall stops the player before any visibly rising skirt. - 8 room cover boxes -> convex MeshColliders of the actual CoverRock_* meshes (needed Read/Write on 2 PNB rock meshes: a MeshCollider bakes ONLY if the mesh is readable - InvalidOperationException per bake, classic scene view still shows the collider). - 11 landmarks: hint-matched claim-once OBB fits (cabin/tent walk-into fixed; well/sign/artefact invisible walls removed). - 85 adds: generic solid sweep (>=0.5 u tall, non-flora, walk-band geometry, gated by the LOCAL fitted wall radius) - cacti, succulents, boulders, bone piles, small rocks, craters (full set-piece blocks), crates, tree trunk, turbine poles, dish, solar rigs. Collider tops pinned to the visual top (cap 3): nothing solid-looking is walk-through or walk-over. Elevated-only geometry (rotors, panels) excluded - no phantom ground walls. Verified: baked-CollisionWorld probes (ring closure 32/32/region, 10/10 solidity samples across base + both room slots, open ground free, server==client parity at 323 static bodies), enemy backstop live smoke (enemy embedded in the new crater block depenetrated out and re-engaged across ~12 u; 4 others converged normally through ~40 obstacles), 466/466 EditMode, console clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
534 lines
28 KiB
C#
534 lines
28 KiB
C#
// 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<string, RingProfile>();
|
|
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<SolidBox>();
|
|
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<Vis> CollectVisuals(Scene game)
|
|
{
|
|
var list = new List<Vis>();
|
|
foreach (var root in game.GetRootGameObjects())
|
|
foreach (var mf in root.GetComponentsInChildren<MeshFilter>(true))
|
|
{
|
|
var r = mf.GetComponent<MeshRenderer>();
|
|
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<string, Transform> FindGroups(Scene sub)
|
|
{
|
|
var groups = new Dictionary<string, Transform>();
|
|
foreach (var root in sub.GetRootGameObjects())
|
|
foreach (var t in root.GetComponentsInChildren<Transform>(true))
|
|
if (t.name.StartsWith("WorldColliders_"))
|
|
groups[t.name] = t;
|
|
return groups;
|
|
}
|
|
|
|
static void GatherWorldVerts(Vis v, float yMin, float yMax, List<Vector3> 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) ----------
|
|
|
|
/// <summary>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.</summary>
|
|
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<string, Transform> groups, string name, float targetArc,
|
|
float designR, List<Vis> 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<Vector3>();
|
|
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<BoxCollider>();
|
|
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<string, Transform> groups, List<Vis> visuals, bool apply, StringBuilder rep,
|
|
List<SolidBox> 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<BoxCollider>();
|
|
if (bc != null) Object.DestroyImmediate(bc);
|
|
var mc = t.GetComponent<MeshCollider>();
|
|
if (mc == null) mc = t.gameObject.AddComponent<MeshCollider>();
|
|
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<string, Transform> groups, List<Vis> visuals, bool apply, StringBuilder rep,
|
|
List<SolidBox> solids)
|
|
{
|
|
if (!groups.TryGetValue("WorldColliders_Landmarks", out var group)) { rep.AppendLine("MISSING WorldColliders_Landmarks"); return; }
|
|
rep.AppendLine("== WorldColliders_Landmarks");
|
|
var claimed = new HashSet<Renderer>();
|
|
foreach (Transform t in group)
|
|
{
|
|
var box = t.GetComponent<BoxCollider>();
|
|
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<Vector3>();
|
|
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<string, Transform> groups, List<Vis> visuals,
|
|
List<SolidBox> solids, Dictionary<string, RingProfile> 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<SolidBox>();
|
|
var seenSolarRigs = new HashSet<Transform>();
|
|
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<Vector3>();
|
|
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<float>();
|
|
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<MeshRenderer>(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<Vector3>();
|
|
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<CapsuleCollider>();
|
|
cc.radius = capR;
|
|
cc.height = 3.5f;
|
|
cc.center = new Vector3(0f, 1.5f, 0f);
|
|
cc.direction = 1;
|
|
}
|
|
else
|
|
{
|
|
var bc = go.AddComponent<BoxCollider>();
|
|
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
|