World: collision-fidelity pass — fit walls/cover/landmarks to visuals + solid sweep

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>
This commit is contained in:
2026-07-09 19:45:40 -07:00
parent 0574e74e18
commit 5382cbf341
5 changed files with 14736 additions and 1223 deletions
@@ -0,0 +1,533 @@
// 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
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4d2462ed4721eb745984cb5d91e32f7a
File diff suppressed because it is too large Load Diff