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:
@@ -1,7 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d68b1794e4e664d45925e6fe3eedfae2
|
||||
ModelImporter:
|
||||
serializedVersion: 20300
|
||||
serializedVersion: 24501
|
||||
internalIDToNameTable: []
|
||||
externalObjects: {}
|
||||
materials:
|
||||
@@ -9,14 +9,14 @@ ModelImporter:
|
||||
materialName: 0
|
||||
materialSearch: 1
|
||||
materialLocation: 1
|
||||
searchTexturesGlobally: 1
|
||||
animations:
|
||||
legacyGenerateAnimations: 0
|
||||
bakeSimulation: 0
|
||||
resampleCurves: 1
|
||||
optimizeGameObjects: 0
|
||||
removeConstantScaleCurves: 0
|
||||
motionNodeName:
|
||||
rigImportErrors:
|
||||
rigImportWarnings:
|
||||
animationImportErrors:
|
||||
animationImportWarnings:
|
||||
animationRetargetingWarnings:
|
||||
@@ -31,7 +31,7 @@ ModelImporter:
|
||||
extraExposedTransformPaths: []
|
||||
extraUserProperties: []
|
||||
clipAnimations: []
|
||||
isReadable: 0
|
||||
isReadable: 1
|
||||
meshes:
|
||||
lODScreenPercentages: []
|
||||
globalScale: 1
|
||||
@@ -39,10 +39,12 @@ ModelImporter:
|
||||
addColliders: 0
|
||||
useSRGBMaterialColor: 1
|
||||
sortHierarchyByName: 1
|
||||
importPhysicalCameras: 1
|
||||
importVisibility: 0
|
||||
importBlendShapes: 0
|
||||
importCameras: 0
|
||||
importLights: 0
|
||||
nodeNameCollisionStrategy: 0
|
||||
fileIdsGeneration: 2
|
||||
swapUVChannels: 0
|
||||
generateSecondaryUV: 0
|
||||
@@ -54,6 +56,12 @@ ModelImporter:
|
||||
skinWeightsMode: 0
|
||||
maxBonesPerVertex: 4
|
||||
minBoneWeight: 0.001
|
||||
optimizeBones: 1
|
||||
generateMeshLods: 0
|
||||
meshLodGenerationFlags: 0
|
||||
maximumMeshLod: -1
|
||||
importUVs: -1
|
||||
importVertexColors: 1
|
||||
meshOptimizationFlags: -3
|
||||
indexFormat: 0
|
||||
secondaryUVAngleDistortion: 8
|
||||
@@ -64,6 +72,7 @@ ModelImporter:
|
||||
secondaryUVMinObjectScale: 1
|
||||
secondaryUVPackMargin: 4
|
||||
useFileScale: 1
|
||||
strictVertexDataChecks: 0
|
||||
tangentSpace:
|
||||
normalSmoothAngle: 3
|
||||
normalImportMode: 1
|
||||
@@ -72,6 +81,7 @@ ModelImporter:
|
||||
legacyComputeAllNormalsFromSmoothingGroupsWhenMeshHasBlendShapes: 0
|
||||
blendShapeNormalImportMode: 1
|
||||
normalSmoothingSource: 2
|
||||
calculateBlendshapeNormalsDeltaFromImportedNormals: 1
|
||||
referencedClips: []
|
||||
importAnimation: 0
|
||||
humanDescription:
|
||||
@@ -96,6 +106,7 @@ ModelImporter:
|
||||
humanoidOversampling: 1
|
||||
avatarSetup: 0
|
||||
addHumanoidExtraRootOnlyWhenUsingAvatar: 1
|
||||
importBlendShapeDeformPercent: 0
|
||||
remapMaterialsIfMaterialImportModeIsNone: 0
|
||||
additionalBone: 0
|
||||
userData:
|
||||
|
||||
+15
-4
@@ -1,7 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b26ded2dffadca04da4b267610d057ab
|
||||
ModelImporter:
|
||||
serializedVersion: 20300
|
||||
serializedVersion: 24501
|
||||
internalIDToNameTable: []
|
||||
externalObjects: {}
|
||||
materials:
|
||||
@@ -9,14 +9,14 @@ ModelImporter:
|
||||
materialName: 0
|
||||
materialSearch: 1
|
||||
materialLocation: 1
|
||||
searchTexturesGlobally: 1
|
||||
animations:
|
||||
legacyGenerateAnimations: 0
|
||||
bakeSimulation: 0
|
||||
resampleCurves: 1
|
||||
optimizeGameObjects: 0
|
||||
removeConstantScaleCurves: 0
|
||||
motionNodeName:
|
||||
rigImportErrors:
|
||||
rigImportWarnings:
|
||||
animationImportErrors:
|
||||
animationImportWarnings:
|
||||
animationRetargetingWarnings:
|
||||
@@ -31,7 +31,7 @@ ModelImporter:
|
||||
extraExposedTransformPaths: []
|
||||
extraUserProperties: []
|
||||
clipAnimations: []
|
||||
isReadable: 0
|
||||
isReadable: 1
|
||||
meshes:
|
||||
lODScreenPercentages: []
|
||||
globalScale: 1
|
||||
@@ -39,10 +39,12 @@ ModelImporter:
|
||||
addColliders: 0
|
||||
useSRGBMaterialColor: 1
|
||||
sortHierarchyByName: 1
|
||||
importPhysicalCameras: 1
|
||||
importVisibility: 0
|
||||
importBlendShapes: 0
|
||||
importCameras: 0
|
||||
importLights: 0
|
||||
nodeNameCollisionStrategy: 0
|
||||
fileIdsGeneration: 2
|
||||
swapUVChannels: 0
|
||||
generateSecondaryUV: 0
|
||||
@@ -54,6 +56,12 @@ ModelImporter:
|
||||
skinWeightsMode: 0
|
||||
maxBonesPerVertex: 4
|
||||
minBoneWeight: 0.001
|
||||
optimizeBones: 1
|
||||
generateMeshLods: 0
|
||||
meshLodGenerationFlags: 0
|
||||
maximumMeshLod: -1
|
||||
importUVs: -1
|
||||
importVertexColors: 1
|
||||
meshOptimizationFlags: -3
|
||||
indexFormat: 0
|
||||
secondaryUVAngleDistortion: 8
|
||||
@@ -64,6 +72,7 @@ ModelImporter:
|
||||
secondaryUVMinObjectScale: 1
|
||||
secondaryUVPackMargin: 4
|
||||
useFileScale: 1
|
||||
strictVertexDataChecks: 0
|
||||
tangentSpace:
|
||||
normalSmoothAngle: 3
|
||||
normalImportMode: 1
|
||||
@@ -72,6 +81,7 @@ ModelImporter:
|
||||
legacyComputeAllNormalsFromSmoothingGroupsWhenMeshHasBlendShapes: 0
|
||||
blendShapeNormalImportMode: 1
|
||||
normalSmoothingSource: 2
|
||||
calculateBlendshapeNormalsDeltaFromImportedNormals: 1
|
||||
referencedClips: []
|
||||
importAnimation: 0
|
||||
humanDescription:
|
||||
@@ -96,6 +106,7 @@ ModelImporter:
|
||||
humanoidOversampling: 1
|
||||
avatarSetup: 0
|
||||
addHumanoidExtraRootOnlyWhenUsingAvatar: 1
|
||||
importBlendShapeDeformPercent: 0
|
||||
remapMaterialsIfMaterialImportModeIsNone: 0
|
||||
additionalBone: 0
|
||||
userData:
|
||||
|
||||
@@ -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
|
||||
+14171
-1215
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user