#if UNITY_EDITOR
using System.Collections.Generic;
using System.Linq;
using Unity.Mathematics;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
namespace jeanf.scenemanagement
{
///
/// Edit-mode audit of play-mode zone detection, grouped per scenario. Every
/// in the open scenes is resolved once against the open
/// volumes with the bridge's exact failsafe chain (), then
/// classified under every whose scene or dependencies include the scene
/// the object lives in (the same object can be OK for one scenario and broken for another):
/// - BROKEN — resolves to no zone at all (pending at runtime), or to a zone the scenario's
/// listOfZonesNeededForThisScenario does not include;
/// - OK — resolves to a scenario-consistent zone; how it resolved (edge/lifted/fallback) is
/// informational detail, not a status.
/// Objects living in scenes no scenario owns are grouped separately and only checked for
/// volume coverage. 'Load scenario scenes + scan' opens every scenario's scene and
/// dependencies (only the override's when one is set) additively and audits all of them at
/// once. Row actions: Select (ping + frame), Fix (minimal per-axis snap into the correct
/// volume, undoable), Add zone (append the detected zone to that scenario's
/// listOfZonesNeededForThisScenario when the object is correctly placed and the scenario list
/// is what's incomplete).
///
public class ZoneValidationWindow : EditorWindow
{
private enum Status { Broken, Ok }
private struct Row
{
public Transform Target;
public Status Status;
public string ZoneLabel;
public string Detail;
public Zone DetectedZone;
/// Volume to snap the object into with Fix; null when no fix applies.
public VolumeAuthoring FixVolume;
/// Scenario 'Add zone' would modify; null outside scenario groups.
public Scenario TargetScenario;
}
private class Group
{
public Scenario Scenario;
public string Name;
public string Note;
public readonly List Rows = new List();
public int Broken;
}
private readonly List _groups = new List();
private Scenario _scenarioOverride;
private bool _showOk = true;
private int _volumeCount;
private int _objectCount;
private bool _scanned;
private Vector2 _scroll;
[MenuItem("Tools/Jeanf/SceneManagement/Zone Detection Validation")]
private static void Open() => GetWindow("Zone Validation");
private void OnHierarchyChange() => _scanned = false;
private void OnGUI()
{
using (new EditorGUILayout.HorizontalScope())
{
if (GUILayout.Button("Scan open scenes", GUILayout.Height(24))) Scan();
if (_scenarioOverride != null &&
GUILayout.Button(new GUIContent($"Load '{_scenarioOverride.scenarioName}' + scan",
"Opens this scenario's scene and dependencies additively, then scans."),
GUILayout.Height(24), GUILayout.Width(200)))
LoadScenarioScenesAndScan(allScenarios: false);
if (GUILayout.Button(new GUIContent("Load ALL scenarios + scan",
"Opens every scenario's scene and dependencies additively, clears the override, and audits " +
"the whole project at once — one group per scenario."), GUILayout.Height(24), GUILayout.Width(180)))
LoadScenarioScenesAndScan(allScenarios: true);
_showOk = GUILayout.Toggle(_showOk, "show OK", GUILayout.Width(80));
}
_scenarioOverride = (Scenario)EditorGUILayout.ObjectField(
new GUIContent("Scenario override", "Leave empty to audit every scenario; set to restrict the audit to one."),
_scenarioOverride, typeof(Scenario), false);
if (Application.isPlaying)
{
EditorGUILayout.HelpBox("Play mode: inspect the ObjectZoneTrackingBridge component for live assignments instead.", MessageType.Info);
return;
}
if (!_scanned) { EditorGUILayout.HelpBox("Scan to audit the open scenes.", MessageType.Info); return; }
if (_volumeCount == 0)
{
EditorGUILayout.HelpBox("No VolumeAuthoring found in the open scenes. Zone volumes usually live in a " +
"SubScene — open it for edit (check the SubScene's 'Edit' toggle) and re-scan.", MessageType.Warning);
return;
}
var broken = _groups.Sum(g => g.Broken);
var total = _groups.Sum(g => g.Rows.Count);
EditorGUILayout.LabelField($"Volumes: {_volumeCount} Objects: {_objectCount} Scenarios: {_groups.Count(g => g.Scenario != null)}");
using (new EditorGUILayout.HorizontalScope())
{
EditorGUILayout.LabelField($"Broken: {broken} OK: {total - broken}", EditorStyles.boldLabel);
using (new EditorGUI.DisabledScope(broken == 0))
if (GUILayout.Button($"Select broken ({broken})", GUILayout.Width(140)))
Selection.objects = _groups.SelectMany(g => g.Rows)
.Where(r => r.Status == Status.Broken && r.Target != null)
.Select(r => (Object)r.Target.gameObject).Distinct().ToArray();
}
EditorGUILayout.Space(2);
var rescanNeeded = false;
_scroll = EditorGUILayout.BeginScrollView(_scroll);
foreach (var group in _groups)
{
var key = $"ZoneVal_group_{group.Name}";
var expanded = SessionState.GetBool(key, group.Broken > 0);
var header = $"{group.Name} — Broken: {group.Broken} OK: {group.Rows.Count - group.Broken}";
bool next;
using (new EditorGUILayout.HorizontalScope())
{
next = EditorGUILayout.Foldout(expanded, header, true,
group.Broken > 0 ? EditorStyles.foldoutHeader : EditorStyles.foldout);
if (next != expanded) SessionState.SetBool(key, next);
var dirtyScenes = group.Rows.Where(r => r.Target != null)
.Select(r => r.Target.gameObject.scene).Distinct().Where(s => s.isDirty).ToList();
var scenarioDirty = group.Scenario != null && EditorUtility.IsDirty(group.Scenario);
using (new EditorGUI.DisabledScope(dirtyScenes.Count == 0 && !scenarioDirty))
if (GUILayout.Button(new GUIContent(
dirtyScenes.Count > 1 ? $"Save {dirtyScenes.Count} scenes" : "Save",
"Saves this group's modified scene(s), and the scenario asset when 'Add zone' changed it."),
GUILayout.Width(100)))
{
foreach (var scene in dirtyScenes) EditorSceneManager.SaveScene(scene);
if (scenarioDirty) AssetDatabase.SaveAssetIfDirty(group.Scenario);
}
}
if (!next) continue;
using (new EditorGUI.IndentLevelScope())
{
if (!string.IsNullOrEmpty(group.Note))
EditorGUILayout.HelpBox(group.Note, MessageType.Info);
foreach (var row in group.Rows)
{
if (row.Status == Status.Ok && !_showOk) continue;
if (row.Target == null) continue;
if (DrawRow(row)) rescanNeeded = true;
}
}
}
EditorGUILayout.EndScrollView();
if (rescanNeeded)
EditorApplication.delayCall += () => { Scan(); Repaint(); };
EditorGUILayout.Space(2);
EditorGUILayout.HelpBox("Proximity: select the ObjectZoneTrackingBridge (Core prefab) and enable " +
"'Simulate proximity' in its inspector to tune thresholds in the Scene view without entering play mode.", MessageType.None);
}
/// Draws one result row; returns true when an action changed the scene/assets.
private bool DrawRow(in Row row)
{
var changed = false;
using (new EditorGUILayout.HorizontalScope())
{
var icon = row.Status == Status.Broken ? "console.erroricon.sml" : "TestPassed";
GUILayout.Label(EditorGUIUtility.IconContent(icon), GUILayout.Width(20), GUILayout.Height(18));
using (new EditorGUI.DisabledScope(true))
EditorGUILayout.ObjectField(row.Target.gameObject, typeof(GameObject), true, GUILayout.MinWidth(140));
if (GUILayout.Button("Select", GUILayout.Width(50)))
{
Selection.activeGameObject = row.Target.gameObject;
EditorGUIUtility.PingObject(row.Target.gameObject);
SceneView.lastActiveSceneView?.Frame(new Bounds(row.Target.position, Vector3.one * 2f), false);
}
using (new EditorGUI.DisabledScope(row.FixVolume == null))
if (GUILayout.Button(new GUIContent("Fix",
row.FixVolume != null
? $"Snap into '{row.FixVolume.zone.zoneName}' with the minimal per-axis move (undoable)."
: "No target volume to snap into."), GUILayout.Width(36)))
{
SnapIntoVolume(row.Target, row.FixVolume);
changed = true;
}
if (row.Status == Status.Broken && row.DetectedZone != null && row.TargetScenario != null)
{
if (GUILayout.Button(new GUIContent("Add zone",
$"The object is correctly placed — add '{row.DetectedZone.zoneName}' to " +
$"'{row.TargetScenario.name}'.listOfZonesNeededForThisScenario instead of moving it (undoable)."),
GUILayout.Width(70)))
{
AddZoneToScenario(row.TargetScenario, row.DetectedZone);
changed = true;
}
}
EditorGUILayout.LabelField(row.Target.gameObject.scene.name, GUILayout.Width(120));
EditorGUILayout.LabelField(row.ZoneLabel, GUILayout.Width(170));
EditorGUILayout.LabelField(row.Detail);
}
return changed;
}
private void Scan()
{
_groups.Clear();
_scanned = true;
var volumes = EditorZoneResolver.GatherVolumes();
_volumeCount = volumes.Count;
if (_volumeCount == 0) return;
var chain = EditorZoneResolver.SceneChainParams();
var proximityByZone = CollectProximityThresholds();
var scenarios = CollectScenarios();
// Resolve every object once; classification varies per scenario.
var resolved = EditorZoneResolver.GatherTrackedObjects()
.Select(t => (Target: t, Res: EditorZoneResolver.Resolve(t.position, volumes, chain)))
.ToList();
_objectCount = resolved.Count;
var openPaths = new HashSet();
for (var i = 0; i < SceneManager.sceneCount; i++) openPaths.Add(SceneManager.GetSceneAt(i).path);
var claimed = new HashSet();
foreach (var scenario in scenarios)
{
// Match by asset path AND by scene name — SceneReference assets can go stale while
// the name still identifies the open scene unambiguously.
var paths = new HashSet(ScenarioScenePaths(scenario));
var names = new HashSet(paths.Select(System.IO.Path.GetFileNameWithoutExtension));
if (!string.IsNullOrEmpty(scenario.scene?.Name)) names.Add(scenario.scene.Name);
var members = resolved.Where(r => paths.Contains(r.Target.gameObject.scene.path)
|| names.Contains(r.Target.gameObject.scene.name)).ToList();
// Skip scenarios with no presence at all; keep the override and any scenario whose
// scenes are open, so an empty result is visible instead of silently vanishing.
if (members.Count == 0 && scenario != _scenarioOverride && !paths.Overlaps(openPaths)) continue;
var group = new Group { Scenario = scenario, Name = scenario.scenarioName ?? scenario.name };
if (members.Count == 0)
{
var perScene = resolved.GroupBy(r => r.Target.gameObject.scene.name)
.Select(g => $"{g.Key}:{g.Count()}");
group.Note = $"No tracked objects matched this scenario's scenes [{string.Join(", ", names)}]. " +
$"Tracked objects live in: {string.Join(", ", perScene)}.";
Debug.Log($"[ZoneValidation] '{group.Name}': 0 objects matched. Scenario scene paths: " +
$"[{string.Join(" | ", paths)}]. Open scenes: [{string.Join(" | ", openPaths)}]. " +
$"Objects per scene: {string.Join(", ", perScene)}");
}
var expectedZoneIds = new HashSet();
if (scenario.listOfZonesNeededForThisScenario != null)
foreach (var zone in scenario.listOfZonesNeededForThisScenario)
if (zone != null) expectedZoneIds.Add($"{zone.id}");
foreach (var (target, res) in members)
{
claimed.Add(target);
group.Rows.Add(Classify(target, res, expectedZoneIds, scenario, volumes, chain, proximityByZone));
}
Finish(group);
_groups.Add(group);
}
// Objects in scenes no audited scenario owns (persistent scene, tools…): coverage check only.
var orphans = resolved.Where(r => !claimed.Contains(r.Target)).ToList();
if (orphans.Count > 0)
{
var group = new Group { Scenario = null, Name = "No scenario (persistent/unowned scenes)" };
var none = new HashSet();
foreach (var (target, res) in orphans)
group.Rows.Add(Classify(target, res, none, null, volumes, chain, proximityByZone));
Finish(group);
_groups.Add(group);
}
_groups.Sort((a, b) => (b.Broken - a.Broken) != 0 ? b.Broken.CompareTo(a.Broken)
: string.Compare(a.Name, b.Name, System.StringComparison.OrdinalIgnoreCase));
}
private static void Finish(Group group)
{
group.Rows.Sort((a, b) => a.Status.CompareTo(b.Status));
group.Broken = group.Rows.Count(r => r.Status == Status.Broken);
}
private Row Classify(Transform target, in EditorZoneResolver.Resolution res, HashSet expectedZoneIds,
Scenario scenario, List volumes, in EditorZoneResolver.ChainParams chain,
Dictionary proximityByZone)
{
var row = new Row { Target = target, DetectedZone = res.Zone, TargetScenario = scenario };
if (!res.HasZone)
{
row.Status = Status.Broken;
row.ZoneLabel = "— no volume —";
row.Detail = "outside every volume (all failsafes miss): pending forever at runtime.";
row.FixVolume = NearestVolume(target.position, volumes, expectedZoneIds);
return row;
}
row.ZoneLabel = $"{res.Zone.zoneName} ({res.Zone.id})";
var kindNote = res.Kind switch
{
ObjectZoneTrackingBridge.MatchKind.Soft => $"pivot on the volume edge (±{chain.Tolerance}m). ",
ObjectZoneTrackingBridge.MatchKind.Lifted => $"pivot {res.Distance:F2}m above the volume (ceiling-lift). ",
ObjectZoneTrackingBridge.MatchKind.Fallback => $"pivot outside, nearest volume at {res.Distance:F2}m. ",
_ => "",
};
if (expectedZoneIds.Count > 0 && !expectedZoneIds.Contains($"{res.Zone.id}"))
{
row.Status = Status.Broken;
row.Detail = $"zone not required by the scenario — wrong room, or add it to the scenario. {kindNote}";
row.FixVolume = NearestVolume(target.position, volumes, expectedZoneIds);
}
else
{
row.Status = Status.Ok;
if (proximityByZone.TryGetValue($"{res.Zone.id}", out var threshold))
kindNote += $"proximity-gated, threshold {threshold}m.";
row.Detail = kindNote;
// Not exactly inside its own volume: works at runtime, but offer the snap.
if (res.Kind != ObjectZoneTrackingBridge.MatchKind.Exact) row.FixVolume = res.Volume;
}
return row;
}
/// The override scenario alone, or every Scenario asset in the project.
private List CollectScenarios()
{
var scenarios = new List();
if (_scenarioOverride != null) { scenarios.Add(_scenarioOverride); return scenarios; }
foreach (var guid in AssetDatabase.FindAssets("t:Scenario"))
{
var scenario = AssetDatabase.LoadAssetAtPath(AssetDatabase.GUIDToAssetPath(guid));
if (scenario != null) scenarios.Add(scenario);
}
return scenarios;
}
private static IEnumerable ScenarioScenePaths(Scenario scenario)
{
var main = ScenePathOf(scenario.scene);
if (main != null) yield return main;
if (scenario.dependenciesInThisScenario == null) yield break;
foreach (var dependency in scenario.dependenciesInThisScenario)
{
var path = ScenePathOf(dependency);
if (path != null) yield return path;
}
}
/// Asset path of the referenced scene; falls back to the addressable address when
/// it is itself an asset path (the common case in this project).
private static string ScenePathOf(SceneReference reference)
{
if (reference == null) return null;
var asset = reference.EditorSceneAsset;
if (asset != null)
{
var path = AssetDatabase.GetAssetPath(asset);
if (!string.IsNullOrEmpty(path)) return path;
}
var address = reference.Address;
return !string.IsNullOrEmpty(address) && address.EndsWith(".unity") ? address : null;
}
/// Opens the audited scenarios' scenes additively, then scans all of them at once.
private void LoadScenarioScenesAndScan(bool allScenarios)
{
if (allScenarios) _scenarioOverride = null; // group per scenario, audit everything
var toOpen = CollectScenarios().SelectMany(ScenarioScenePaths).Distinct()
.Where(path => !string.IsNullOrEmpty(path))
.Where(path => { var s = SceneManager.GetSceneByPath(path); return !s.IsValid() || !s.isLoaded; })
.ToList();
if (toOpen.Count > 10 && !EditorUtility.DisplayDialog("Zone Validation",
$"This will open {toOpen.Count} scenario scenes additively. Continue?", "Open + scan", "Cancel"))
return;
foreach (var path in toOpen) EditorSceneManager.OpenScene(path, OpenSceneMode.Additive);
Debug.Log($"[ZoneValidation] Opened {toOpen.Count} scenario scene(s) additively.");
Scan();
}
private static void AddZoneToScenario(Scenario scenario, Zone zone)
{
Undo.RecordObject(scenario, "Add zone to scenario");
scenario.listOfZonesNeededForThisScenario ??= new List();
if (!scenario.listOfZonesNeededForThisScenario.Contains(zone))
scenario.listOfZonesNeededForThisScenario.Add(zone);
EditorUtility.SetDirty(scenario);
}
/// Nearest volume the object SHOULD be in: nearest among the scenario's expected
/// zones when any are known, nearest overall otherwise.
private static VolumeAuthoring NearestVolume(Vector3 position, List volumes, HashSet expectedZoneIds)
{
VolumeAuthoring best = null;
var bestDistSq = float.MaxValue;
foreach (var volume in volumes)
{
if (expectedZoneIds.Count > 0 && !expectedZoneIds.Contains($"{volume.zone.id}")) continue;
var t = volume.transform;
var distSq = VolumeMath.DistanceSq(float4x4.TRS(t.position, t.rotation, new float3(1f)), t.localScale, position);
if (distSq >= bestDistSq) continue;
bestDistSq = distSq;
best = volume;
}
return best;
}
///
/// Moves the object into the volume with the minimal per-axis correction: the pivot is
/// expressed in the volume's rotation-only local frame and clamped (with a small inset)
/// into the box — axes already inside don't move, so the common case is a straight move
/// along one axis toward the volume. Undoable.
///
private static void SnapIntoVolume(Transform target, VolumeAuthoring volume)
{
var t = volume.transform;
var frame = float4x4.TRS(t.position, t.rotation, new float3(1f));
var local = VolumeMath.ToLocal(frame, target.position);
var extents = (float3)t.localScale * 0.5f;
var inset = math.min(new float3(0.1f), extents * 0.25f);
var limit = math.max(extents - inset, new float3(0f));
var clamped = math.clamp(local, -limit, limit);
if (math.all(clamped == local)) return; // already inside
Undo.RecordObject(target, "Snap into zone volume");
target.position = math.transform(frame, clamped);
EditorUtility.SetDirty(target);
}
private Dictionary CollectProximityThresholds()
{
var result = new Dictionary();
var bridge = FindAnyObjectByType(FindObjectsInactive.Include);
if (bridge == null) return result;
foreach (var config in bridge.ProximityZones)
if (config.zone != null && config.threshold > 0f) result[$"{config.zone.id}"] = config.threshold;
return result;
}
}
}
#endif