add tile key selection for addressables builds

This commit is contained in:
2026-01-23 16:07:24 +01:00
parent bd1e6f4f4d
commit 04ef2c68cc
16 changed files with 1383 additions and 185 deletions

View File

@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEditor.AddressableAssets;
using UnityEditor.AddressableAssets.Settings;
@@ -15,99 +16,240 @@ public static class GeoTileAddressablesBuilder
private const string GroupName = "TilePrefabs";
private const string TileLabel = "tile";
private const string ManifestFileName = "TileManifest.json";
private const float TileSizeMeters = 1000f;
private const float DefaultTileSizeMeters = 1000f;
private const float DefaultTileSizeX = 1000f;
private const float DefaultTileSizeY = 1000f;
private const float DefaultOverlapX = 0.5f;
private const float DefaultOverlapY = 0.5f;
private const string BuildPathVariable = "TileBuildPath";
private const string LoadPathVariable = "TileLoadPath";
private const string BuildPathValue = "ServerData/TileBundles/[BuildTarget]";
private const string LoadPathValue = "file://{UnityEngine.Application.persistentDataPath}/TileBundles/[BuildTarget]";
[MenuItem("Tools/Geo Tiles/Build Tile Addressables (Android)")]
internal struct TileKeyConfig
{
public float TileSizeX;
public float TileSizeY;
public float OverlapX;
public float OverlapY;
public static TileKeyConfig Default => new TileKeyConfig
{
TileSizeX = DefaultTileSizeX,
TileSizeY = DefaultTileSizeY,
OverlapX = DefaultOverlapX,
OverlapY = DefaultOverlapY
};
}
internal struct TileRecord
{
public string TileId;
public string TileKey;
public int XKey;
public int YKey;
public double Xmin;
public double Ymin;
public double GlobalMin;
public double GlobalMax;
}
internal struct BuildRequest
{
public string TileIndexCsvPath;
public string TilePrefabsDir;
public string OutputRoot;
public BuildTarget Target;
public BuildTargetGroup TargetGroup;
public float TileSizeMeters;
public TileKeyConfig TileKeyConfig;
public List<TileRecord> SelectedTiles;
public bool OverwriteExisting;
public bool Verbose;
}
[MenuItem("Tools/Geo Tiles/Build (Android)")]
public static void BuildAndroid()
{
Debug.LogWarning("[GeoTileAddressablesBuilder] Legacy build. Use Tools/Geo Tiles/Addressables Build Window for selection builds.");
BuildForTarget(BuildTarget.Android, BuildTargetGroup.Android);
}
[MenuItem("Tools/Geo Tiles/Build (Linux)")]
public static void BuildLinux()
{
Debug.LogWarning("[GeoTileAddressablesBuilder] Legacy build. Use Tools/Geo Tiles/Addressables Build Window for selection builds.");
BuildForTarget(BuildTarget.StandaloneLinux64, BuildTargetGroup.Standalone);
}
private static void BuildForTarget(BuildTarget target, BuildTargetGroup group)
{
if (!Directory.Exists(TilePrefabsDir))
BuildSelectedTiles(new BuildRequest
{
Debug.LogError($"[GeoTileAddressablesBuilder] Prefab directory missing: {TilePrefabsDir}");
return;
TileIndexCsvPath = TileIndexCsvPath,
TilePrefabsDir = TilePrefabsDir,
OutputRoot = "ServerData/TileBundles",
Target = target,
TargetGroup = group,
TileSizeMeters = DefaultTileSizeMeters,
TileKeyConfig = TileKeyConfig.Default,
SelectedTiles = null,
OverwriteExisting = false,
Verbose = false
});
}
internal static bool BuildSelectedTiles(BuildRequest request)
{
var tileIndexCsvPath = string.IsNullOrWhiteSpace(request.TileIndexCsvPath) ? TileIndexCsvPath : request.TileIndexCsvPath;
var tilePrefabsDir = string.IsNullOrWhiteSpace(request.TilePrefabsDir) ? TilePrefabsDir : request.TilePrefabsDir;
var outputRoot = string.IsNullOrWhiteSpace(request.OutputRoot) ? "ServerData/TileBundles" : request.OutputRoot;
var tileSizeMeters = request.TileSizeMeters > 0f ? request.TileSizeMeters : DefaultTileSizeMeters;
var tileKeyConfig = request.TileKeyConfig;
if (tileKeyConfig.TileSizeX <= 0f && tileKeyConfig.TileSizeY <= 0f &&
tileKeyConfig.OverlapX == 0f && tileKeyConfig.OverlapY == 0f)
tileKeyConfig = TileKeyConfig.Default;
if (tileKeyConfig.TileSizeX <= 0f) tileKeyConfig.TileSizeX = DefaultTileSizeX;
if (tileKeyConfig.TileSizeY <= 0f) tileKeyConfig.TileSizeY = DefaultTileSizeY;
if (!Directory.Exists(tilePrefabsDir))
{
Debug.LogError($"[GeoTileAddressablesBuilder] Prefab directory missing: {tilePrefabsDir}");
return false;
}
if (!File.Exists(TileIndexCsvPath))
if (!File.Exists(tileIndexCsvPath))
{
Debug.LogError($"[GeoTileAddressablesBuilder] CSV missing: {TileIndexCsvPath}");
return;
Debug.LogError($"[GeoTileAddressablesBuilder] CSV missing: {tileIndexCsvPath}");
return false;
}
var tiles = request.SelectedTiles ?? ParseTilesCsv(tileIndexCsvPath, tileKeyConfig);
if (tiles.Count == 0)
{
Debug.LogError("[GeoTileAddressablesBuilder] No tiles selected for build.");
return false;
}
var settings = AddressableAssetSettingsDefaultObject.GetSettings(true);
if (settings == null)
{
Debug.LogError("[GeoTileAddressablesBuilder] Addressables settings not found.");
return;
return false;
}
EnsureProfileVariable(settings, BuildPathVariable, BuildPathValue);
var buildPathValue = BuildPathFromOutputRoot(outputRoot);
EnsureProfileVariable(settings, BuildPathVariable, buildPathValue);
EnsureProfileVariable(settings, LoadPathVariable, LoadPathValue);
EnsureRemoteCatalogPaths(settings);
var groupAsset = GetOrCreateGroup(settings);
ConfigureGroup(settings, groupAsset);
AssignPrefabs(settings, groupAsset);
var assignedTileIds = AssignPrefabs(settings, groupAsset, tilePrefabsDir, tiles, true);
var filteredTiles = tiles
.Where(tile => !string.IsNullOrWhiteSpace(tile.TileId) && assignedTileIds.Contains(tile.TileId))
.ToList();
if (filteredTiles.Count == 0)
{
Debug.LogError("[GeoTileAddressablesBuilder] No matching prefabs found for selected tiles.");
return false;
}
settings.SetDirty(AddressableAssetSettings.ModificationEvent.BatchModification, null, true);
AssetDatabase.SaveAssets();
var previousTarget = EditorUserBuildSettings.activeBuildTarget;
if (previousTarget != target)
if (previousTarget != request.Target)
{
if (!EditorUserBuildSettings.SwitchActiveBuildTarget(group, target))
if (!EditorUserBuildSettings.SwitchActiveBuildTarget(request.TargetGroup, request.Target))
{
Debug.LogError($"[GeoTileAddressablesBuilder] Failed to switch build target to {target}.");
return;
Debug.LogError($"[GeoTileAddressablesBuilder] Failed to switch build target to {request.Target}.");
return false;
}
}
var outputPath = GetRemoteCatalogBuildPath(settings, request.Target);
if (request.OverwriteExisting && Directory.Exists(outputPath))
Directory.Delete(outputPath, true);
AddressableAssetSettings.BuildPlayerContent();
var outputPath = GetRemoteCatalogBuildPath(settings, target);
outputPath = GetRemoteCatalogBuildPath(settings, request.Target);
if (!Directory.Exists(outputPath))
{
Debug.LogError($"[GeoTileAddressablesBuilder] Build output not found: {outputPath}");
return;
return false;
}
var (catalogFile, catalogHashFile) = FindCatalogFiles(outputPath);
if (string.IsNullOrEmpty(catalogFile))
{
Debug.LogError($"[GeoTileAddressablesBuilder] Catalog file not found in: {outputPath}");
return;
return false;
}
var manifest = BuildManifest(target.ToString(), catalogFile, catalogHashFile);
var manifest = BuildManifest(request.Target.ToString(), catalogFile, catalogHashFile, filteredTiles, tileSizeMeters);
var manifestPath = Path.Combine(outputPath, ManifestFileName);
File.WriteAllText(manifestPath, JsonUtility.ToJson(manifest, true));
AssetDatabase.Refresh();
Debug.Log($"[GeoTileAddressablesBuilder] DONE. Output={outputPath}");
if (request.Verbose)
Debug.Log($"[GeoTileAddressablesBuilder] Built {filteredTiles.Count} tiles (from {tiles.Count} selected). Output={outputPath}");
return true;
}
private static void AssignPrefabs(AddressableAssetSettings settings, AddressableAssetGroup group)
private static HashSet<string> AssignPrefabs(AddressableAssetSettings settings, AddressableAssetGroup group, string prefabsDir, List<TileRecord> tiles, bool removeUnselected)
{
var prefabGuids = AssetDatabase.FindAssets("t:Prefab", new[] { TilePrefabsDir });
var tileById = new Dictionary<string, TileRecord>(StringComparer.OrdinalIgnoreCase);
foreach (var tile in tiles)
{
if (!string.IsNullOrWhiteSpace(tile.TileId))
tileById[tile.TileId] = tile;
}
var assignedTileIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var selectedGuids = new HashSet<string>();
var prefabGuids = AssetDatabase.FindAssets("t:Prefab", new[] { prefabsDir });
foreach (var guid in prefabGuids)
{
var path = AssetDatabase.GUIDToAssetPath(guid).Replace("\\", "/");
var parentDir = Path.GetDirectoryName(path)?.Replace("\\", "/");
if (!string.Equals(parentDir, TilePrefabsDir, StringComparison.OrdinalIgnoreCase))
if (!string.Equals(parentDir, prefabsDir, StringComparison.OrdinalIgnoreCase))
continue;
var tileId = Path.GetFileNameWithoutExtension(path);
if (!tileById.TryGetValue(tileId, out var tile))
continue;
var entry = settings.CreateOrMoveEntry(guid, group, false, false);
entry.address = Path.GetFileNameWithoutExtension(path);
entry.address = tile.TileKey;
entry.SetLabel(TileLabel, true, true);
selectedGuids.Add(guid);
assignedTileIds.Add(tileId);
}
if (!removeUnselected)
return assignedTileIds;
var entries = group.entries.ToList();
foreach (var entry in entries)
{
if (entry == null || string.IsNullOrWhiteSpace(entry.AssetPath))
continue;
var entryPath = entry.AssetPath.Replace("\\", "/");
var entryDir = Path.GetDirectoryName(entryPath)?.Replace("\\", "/");
if (!string.Equals(entryDir, prefabsDir, StringComparison.OrdinalIgnoreCase))
continue;
if (!selectedGuids.Contains(entry.guid))
group.RemoveAssetEntry(entry);
}
return assignedTileIds;
}
private static AddressableAssetGroup GetOrCreateGroup(AddressableAssetSettings settings)
@@ -182,11 +324,10 @@ public static class GeoTileAddressablesBuilder
return (catalogFile, catalogHash);
}
private static TileManifest BuildManifest(string buildTarget, string catalogFile, string catalogHashFile)
private static TileManifest BuildManifest(string buildTarget, string catalogFile, string catalogHashFile, List<TileRecord> tiles, float tileSizeMeters)
{
var tiles = ParseTilesCsv();
if (tiles.Count == 0)
throw new InvalidOperationException("No tiles parsed from tile_index.csv");
throw new InvalidOperationException("No tiles selected for TileManifest.");
double minX = double.PositiveInfinity;
double minY = double.PositiveInfinity;
@@ -203,6 +344,7 @@ public static class GeoTileAddressablesBuilder
var tile = tiles[i];
entries[i] = new TileEntry
{
tileKey = tile.TileKey,
tileId = tile.TileId,
offsetX = (float)(tile.Xmin - minX),
offsetZ = (float)(tile.Ymin - minY),
@@ -215,7 +357,7 @@ public static class GeoTileAddressablesBuilder
buildTarget = buildTarget,
catalogFile = catalogFile,
catalogHashFile = catalogHashFile,
tileSizeMeters = TileSizeMeters,
tileSizeMeters = tileSizeMeters,
tiles = entries
};
}
@@ -229,20 +371,23 @@ public static class GeoTileAddressablesBuilder
return Path.GetFullPath(rawPath.Replace("[BuildTarget]", target.ToString()));
}
private struct TileRecord
private static string BuildPathFromOutputRoot(string outputRoot)
{
public string TileId;
public double Xmin;
public double Ymin;
public double GlobalMin;
public double GlobalMax;
if (string.IsNullOrWhiteSpace(outputRoot))
return BuildPathValue;
var normalized = outputRoot.Replace("\\", "/").TrimEnd('/');
if (normalized.IndexOf("[BuildTarget]", StringComparison.OrdinalIgnoreCase) >= 0)
return normalized;
return $"{normalized}/[BuildTarget]";
}
private static List<TileRecord> ParseTilesCsv()
internal static List<TileRecord> ParseTilesCsv(string csvPath, TileKeyConfig tileKeyConfig)
{
var tiles = new List<TileRecord>();
var ci = CultureInfo.InvariantCulture;
var lines = File.ReadAllLines(TileIndexCsvPath);
var lines = File.ReadAllLines(csvPath);
if (lines.Length < 2)
return tiles;
@@ -260,6 +405,7 @@ public static class GeoTileAddressablesBuilder
int IDX_YMIN = headerMap["ymin"];
int IDX_GMIN = headerMap["global_min"];
int IDX_GMAX = headerMap["global_max"];
int IDX_TILE_KEY = headerMap.TryGetValue("tile_key", out var idxTileKey) ? idxTileKey : -1;
for (int i = 1; i < lines.Length; i++)
{
@@ -269,16 +415,26 @@ public static class GeoTileAddressablesBuilder
var parts = line.Split(',');
int maxIdx = Math.Max(IDX_TILE, Math.Max(IDX_XMIN, Math.Max(IDX_YMIN, Math.Max(IDX_GMIN, IDX_GMAX))));
if (IDX_TILE_KEY >= 0)
maxIdx = Math.Max(maxIdx, IDX_TILE_KEY);
if (parts.Length <= maxIdx)
continue;
try
{
var xmin = double.Parse(parts[IDX_XMIN], ci);
var ymin = double.Parse(parts[IDX_YMIN], ci);
var tileKeyRaw = IDX_TILE_KEY >= 0 ? parts[IDX_TILE_KEY].Trim() : "";
var tileKey = ResolveTileKey(tileKeyRaw, xmin, ymin, tileKeyConfig, out var xKey, out var yKey);
tiles.Add(new TileRecord
{
TileId = parts[IDX_TILE].Trim(),
Xmin = double.Parse(parts[IDX_XMIN], ci),
Ymin = double.Parse(parts[IDX_YMIN], ci),
TileKey = tileKey,
XKey = xKey,
YKey = yKey,
Xmin = xmin,
Ymin = ymin,
GlobalMin = double.Parse(parts[IDX_GMIN], ci),
GlobalMax = double.Parse(parts[IDX_GMAX], ci)
});
@@ -292,6 +448,33 @@ public static class GeoTileAddressablesBuilder
return tiles;
}
private static string ResolveTileKey(string tileKeyRaw, double xmin, double ymin, TileKeyConfig tileKeyConfig, out int xKey, out int yKey)
{
if (!string.IsNullOrWhiteSpace(tileKeyRaw) && TryParseTileKey(tileKeyRaw, out xKey, out yKey))
return tileKeyRaw;
float sizeX = tileKeyConfig.TileSizeX <= 0f ? 1f : tileKeyConfig.TileSizeX;
float sizeY = tileKeyConfig.TileSizeY <= 0f ? 1f : tileKeyConfig.TileSizeY;
xKey = (int)Math.Floor((xmin + tileKeyConfig.OverlapX) / sizeX);
yKey = (int)Math.Floor((ymin + tileKeyConfig.OverlapY) / sizeY);
return $"{xKey}_{yKey}";
}
private static bool TryParseTileKey(string tileKey, out int xKey, out int yKey)
{
xKey = 0;
yKey = 0;
if (string.IsNullOrWhiteSpace(tileKey))
return false;
var parts = tileKey.Split('_');
if (parts.Length != 2)
return false;
return int.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out xKey)
&& int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out yKey);
}
private static Dictionary<string, int> BuildHeaderMap(string headerLine)
{
var map = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);

View File

@@ -0,0 +1,375 @@
using System;
using System.Collections.Generic;
using System.IO;
using UnityEditor;
using UnityEngine;
public class GeoTileAddressablesWindow : EditorWindow
{
private string tileIndexCsvPath = "Assets/GeoData/tile_index.csv";
private string tilePrefabsDir = "Assets/TilePrefabs";
private string outputRoot = "ServerData/TileBundles";
private BuildTarget buildTarget = BuildTarget.StandaloneLinux64;
private float tileSizeX = 1000f;
private float tileSizeY = 1000f;
private float overlapX = 0.5f;
private float overlapY = 0.5f;
private string bottomLeftKey = "";
private string topRightKey = "";
private bool overwriteExisting = true;
private bool verboseLogging = false;
private Vector2 scrollPosition;
private enum BuildSource
{
Prefabs,
SceneTerrains
}
private BuildSource buildSource = BuildSource.Prefabs;
private readonly List<GeoTileAddressablesBuilder.TileRecord> tileRecords = new List<GeoTileAddressablesBuilder.TileRecord>();
private readonly List<TileKeyOption> tileKeyOptions = new List<TileKeyOption>();
private readonly Dictionary<string, TileKeyOption> tileKeyLookup = new Dictionary<string, TileKeyOption>(StringComparer.OrdinalIgnoreCase);
private string[] tileKeyStrings = Array.Empty<string>();
private string cachedCsvPath = "";
private DateTime cachedCsvWriteTimeUtc = DateTime.MinValue;
private float cachedTileSizeX = 0f;
private float cachedTileSizeY = 0f;
private float cachedOverlapX = 0f;
private float cachedOverlapY = 0f;
private struct TileKeyOption
{
public string Key;
public int X;
public int Y;
}
[MenuItem("Tools/Geo Tiles/Addressables Build Window")]
public static void ShowWindow()
{
var win = GetWindow<GeoTileAddressablesWindow>("Geo Tile Addressables Build");
win.minSize = new Vector2(620, 420);
}
private void OnGUI()
{
scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition);
GUILayout.Label("Geo Tile Addressables Build", EditorStyles.boldLabel);
GUILayout.Space(8);
GUILayout.Label("Paths", EditorStyles.boldLabel);
tileIndexCsvPath = EditorGUILayout.TextField("Tile Index CSV", tileIndexCsvPath);
tilePrefabsDir = EditorGUILayout.TextField("Tile Prefabs Dir", tilePrefabsDir);
outputRoot = EditorGUILayout.TextField("Output Root", outputRoot);
buildTarget = (BuildTarget)EditorGUILayout.EnumPopup("Build Target", buildTarget);
GUILayout.Space(10);
GUILayout.Label("Tile Key Config", EditorStyles.boldLabel);
tileSizeX = EditorGUILayout.FloatField("Tile Size X (m)", tileSizeX);
tileSizeY = EditorGUILayout.FloatField("Tile Size Y (m)", tileSizeY);
overlapX = EditorGUILayout.FloatField("Overlap X (m)", overlapX);
overlapY = EditorGUILayout.FloatField("Overlap Y (m)", overlapY);
RefreshTileCache();
GUILayout.Space(10);
GUILayout.Label("Tile Selection", EditorStyles.boldLabel);
DrawTileSelection();
GUILayout.Space(10);
GUILayout.Label("Build Options", EditorStyles.boldLabel);
buildSource = (BuildSource)EditorGUILayout.EnumPopup("Source", buildSource);
overwriteExisting = EditorGUILayout.ToggleLeft("Overwrite existing bundles", overwriteExisting);
verboseLogging = EditorGUILayout.ToggleLeft("Verbose logging", verboseLogging);
if (buildSource == BuildSource.SceneTerrains)
EditorGUILayout.HelpBox("Scene terrain source is not implemented yet.", MessageType.Info);
GUILayout.Space(12);
if (GUILayout.Button("Build Addressables (Selected Tiles)"))
BuildSelectedTiles();
if (GUILayout.Button("Open Output Folder"))
OpenOutputFolder();
EditorGUILayout.EndScrollView();
}
private void RefreshTileCache()
{
if (!File.Exists(tileIndexCsvPath))
{
tileRecords.Clear();
tileKeyOptions.Clear();
tileKeyLookup.Clear();
tileKeyStrings = Array.Empty<string>();
return;
}
var writeTime = File.GetLastWriteTimeUtc(tileIndexCsvPath);
if (tileIndexCsvPath == cachedCsvPath &&
writeTime == cachedCsvWriteTimeUtc &&
Mathf.Approximately(tileSizeX, cachedTileSizeX) &&
Mathf.Approximately(tileSizeY, cachedTileSizeY) &&
Mathf.Approximately(overlapX, cachedOverlapX) &&
Mathf.Approximately(overlapY, cachedOverlapY))
{
return;
}
var config = new GeoTileAddressablesBuilder.TileKeyConfig
{
TileSizeX = tileSizeX,
TileSizeY = tileSizeY,
OverlapX = overlapX,
OverlapY = overlapY
};
tileRecords.Clear();
tileRecords.AddRange(GeoTileAddressablesBuilder.ParseTilesCsv(tileIndexCsvPath, config));
BuildTileKeyOptions();
cachedCsvPath = tileIndexCsvPath;
cachedCsvWriteTimeUtc = writeTime;
cachedTileSizeX = tileSizeX;
cachedTileSizeY = tileSizeY;
cachedOverlapX = overlapX;
cachedOverlapY = overlapY;
}
private void BuildTileKeyOptions()
{
tileKeyOptions.Clear();
tileKeyLookup.Clear();
for (int i = 0; i < tileRecords.Count; i++)
{
var tile = tileRecords[i];
if (string.IsNullOrWhiteSpace(tile.TileKey))
continue;
if (tileKeyLookup.ContainsKey(tile.TileKey))
continue;
var option = new TileKeyOption
{
Key = tile.TileKey,
X = tile.XKey,
Y = tile.YKey
};
tileKeyOptions.Add(option);
tileKeyLookup[tile.TileKey] = option;
}
tileKeyOptions.Sort((a, b) =>
{
int cmp = a.X.CompareTo(b.X);
return cmp != 0 ? cmp : a.Y.CompareTo(b.Y);
});
tileKeyStrings = new string[tileKeyOptions.Count];
for (int i = 0; i < tileKeyOptions.Count; i++)
tileKeyStrings[i] = tileKeyOptions[i].Key;
}
private void DrawTileSelection()
{
if (tileKeyOptions.Count == 0)
{
EditorGUILayout.HelpBox("No tile keys available. Check the tile index CSV and key config.", MessageType.Info);
EditorGUILayout.LabelField("Tiles in selection", "0");
return;
}
if (string.IsNullOrEmpty(bottomLeftKey) || !tileKeyLookup.ContainsKey(bottomLeftKey))
bottomLeftKey = tileKeyOptions[0].Key;
var bottomLeft = tileKeyLookup[bottomLeftKey];
var topRightOptions = GetTopRightOptions(bottomLeft);
if (topRightOptions.Count == 0)
topRightOptions.Add(bottomLeft);
if (string.IsNullOrEmpty(topRightKey) || !ContainsKey(topRightOptions, topRightKey))
topRightKey = topRightOptions[topRightOptions.Count - 1].Key;
int bottomLeftIndex = Array.IndexOf(tileKeyStrings, bottomLeftKey);
int newBottomLeftIndex = EditorGUILayout.Popup("Bottom-Left Key", bottomLeftIndex, tileKeyStrings);
if (newBottomLeftIndex != bottomLeftIndex)
{
bottomLeftKey = tileKeyStrings[newBottomLeftIndex];
bottomLeft = tileKeyLookup[bottomLeftKey];
topRightOptions = GetTopRightOptions(bottomLeft);
if (topRightOptions.Count == 0)
topRightOptions.Add(bottomLeft);
if (!ContainsKey(topRightOptions, topRightKey))
topRightKey = topRightOptions[topRightOptions.Count - 1].Key;
}
var topRightKeys = BuildKeyArray(topRightOptions);
int topRightIndex = Array.IndexOf(topRightKeys, topRightKey);
if (topRightIndex < 0)
topRightIndex = topRightKeys.Length - 1;
int newTopRightIndex = EditorGUILayout.Popup("Top-Right Key", topRightIndex, topRightKeys);
if (newTopRightIndex != topRightIndex)
topRightKey = topRightKeys[newTopRightIndex];
EditorGUILayout.LabelField("Tiles in selection", CountSelectedTiles().ToString());
}
private List<TileKeyOption> GetTopRightOptions(TileKeyOption bottomLeft)
{
var options = new List<TileKeyOption>();
for (int i = 0; i < tileKeyOptions.Count; i++)
{
var option = tileKeyOptions[i];
if (option.X >= bottomLeft.X && option.Y >= bottomLeft.Y)
options.Add(option);
}
return options;
}
private int CountSelectedTiles()
{
if (!tileKeyLookup.TryGetValue(bottomLeftKey, out var bottomLeft) ||
!tileKeyLookup.TryGetValue(topRightKey, out var topRight))
return 0;
int count = 0;
for (int i = 0; i < tileRecords.Count; i++)
{
var tile = tileRecords[i];
if (tile.XKey >= bottomLeft.X && tile.XKey <= topRight.X &&
tile.YKey >= bottomLeft.Y && tile.YKey <= topRight.Y)
count++;
}
return count;
}
private List<GeoTileAddressablesBuilder.TileRecord> GetSelectedTiles()
{
var selected = new List<GeoTileAddressablesBuilder.TileRecord>();
if (!tileKeyLookup.TryGetValue(bottomLeftKey, out var bottomLeft) ||
!tileKeyLookup.TryGetValue(topRightKey, out var topRight))
return selected;
for (int i = 0; i < tileRecords.Count; i++)
{
var tile = tileRecords[i];
if (tile.XKey >= bottomLeft.X && tile.XKey <= topRight.X &&
tile.YKey >= bottomLeft.Y && tile.YKey <= topRight.Y)
selected.Add(tile);
}
return selected;
}
private void BuildSelectedTiles()
{
if (buildSource != BuildSource.Prefabs)
{
Debug.LogError("[GeoTileAddressablesWindow] Scene terrain source is not implemented.");
return;
}
RefreshTileCache();
if (tileRecords.Count == 0)
{
Debug.LogError("[GeoTileAddressablesWindow] No tiles loaded from CSV.");
return;
}
var selectedTiles = GetSelectedTiles();
if (selectedTiles.Count == 0)
{
Debug.LogError("[GeoTileAddressablesWindow] Selection is empty.");
return;
}
var request = new GeoTileAddressablesBuilder.BuildRequest
{
TileIndexCsvPath = tileIndexCsvPath,
TilePrefabsDir = tilePrefabsDir,
OutputRoot = outputRoot,
Target = buildTarget,
TargetGroup = GetBuildTargetGroup(buildTarget),
TileSizeMeters = tileSizeX,
TileKeyConfig = new GeoTileAddressablesBuilder.TileKeyConfig
{
TileSizeX = tileSizeX,
TileSizeY = tileSizeY,
OverlapX = overlapX,
OverlapY = overlapY
},
SelectedTiles = selectedTiles,
OverwriteExisting = overwriteExisting,
Verbose = verboseLogging
};
GeoTileAddressablesBuilder.BuildSelectedTiles(request);
}
private void OpenOutputFolder()
{
if (string.IsNullOrWhiteSpace(outputRoot))
return;
var normalized = outputRoot.Replace("\\", "/").TrimEnd('/');
var resolved = normalized.IndexOf("[BuildTarget]", StringComparison.OrdinalIgnoreCase) >= 0
? normalized.Replace("[BuildTarget]", buildTarget.ToString())
: $"{normalized}/{buildTarget}";
var fullPath = Path.GetFullPath(resolved);
if (Directory.Exists(fullPath))
EditorUtility.RevealInFinder(fullPath);
else
Debug.LogWarning($"[GeoTileAddressablesWindow] Output folder not found: {fullPath}");
}
private static bool ContainsKey(List<TileKeyOption> options, string key)
{
for (int i = 0; i < options.Count; i++)
{
if (string.Equals(options[i].Key, key, StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
private static string[] BuildKeyArray(List<TileKeyOption> options)
{
var keys = new string[options.Count];
for (int i = 0; i < options.Count; i++)
keys[i] = options[i].Key;
return keys;
}
private static BuildTargetGroup GetBuildTargetGroup(BuildTarget target)
{
switch (target)
{
case BuildTarget.Android:
return BuildTargetGroup.Android;
case BuildTarget.StandaloneLinux64:
case BuildTarget.StandaloneWindows:
case BuildTarget.StandaloneWindows64:
case BuildTarget.StandaloneOSX:
return BuildTargetGroup.Standalone;
case BuildTarget.iOS:
return BuildTargetGroup.iOS;
case BuildTarget.WebGL:
return BuildTargetGroup.WebGL;
default:
return BuildTargetGroup.Standalone;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f9927ed702ab46b49721a4695f84efb7

View File

@@ -43,6 +43,24 @@ public class GeoTileImporter : EditorWindow
private bool deleteExistingEnhancedTrees = false;
private string enhancedTreesParentName = "Geo_Trees_Enhanced";
private Vector2 scrollPosition;
private float tileKeySizeX = 1000f;
private float tileKeySizeY = 1000f;
private float tileKeyOverlapX = 0.5f;
private float tileKeyOverlapY = 0.5f;
private string bottomLeftKey = "";
private string topRightKey = "";
private readonly List<TileRecord> tileIndexCache = new List<TileRecord>();
private readonly List<TileKeyOption> tileKeyOptions = new List<TileKeyOption>();
private readonly Dictionary<string, TileKeyOption> tileKeyLookup = new Dictionary<string, TileKeyOption>(StringComparer.OrdinalIgnoreCase);
private string[] tileKeyStrings = Array.Empty<string>();
private string cachedTileCsvPath = "";
private DateTime cachedTileCsvTimeUtc = DateTime.MinValue;
private float cachedTileKeySizeX = 0f;
private float cachedTileKeySizeY = 0f;
private float cachedTileOverlapX = 0f;
private float cachedTileOverlapY = 0f;
// Prefabs for trees and furniture (assign in editor)
private GameObject treePrefab;
private GameObject lampPrefab;
@@ -60,6 +78,8 @@ public class GeoTileImporter : EditorWindow
private void OnGUI()
{
scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition);
GUILayout.Label("Inputs", EditorStyles.boldLabel);
tilesCsvPath = EditorGUILayout.TextField("tile_index.csv", tilesCsvPath);
heightmapsDir = EditorGUILayout.TextField("height_png16 dir", heightmapsDir);
@@ -110,6 +130,19 @@ public class GeoTileImporter : EditorWindow
bollardPrefab = (GameObject)EditorGUILayout.ObjectField("Bollard Prefab", bollardPrefab, typeof(GameObject), false);
defaultFurniturePrefab = (GameObject)EditorGUILayout.ObjectField("Default Furniture Prefab", defaultFurniturePrefab, typeof(GameObject), false);
GUILayout.Space(12);
GUILayout.Label("Tile Key Config", EditorStyles.boldLabel);
tileKeySizeX = EditorGUILayout.FloatField("Tile Size X (m)", tileKeySizeX);
tileKeySizeY = EditorGUILayout.FloatField("Tile Size Y (m)", tileKeySizeY);
tileKeyOverlapX = EditorGUILayout.FloatField("Overlap X (m)", tileKeyOverlapX);
tileKeyOverlapY = EditorGUILayout.FloatField("Overlap Y (m)", tileKeyOverlapY);
RefreshTileIndexCache();
GUILayout.Space(10);
GUILayout.Label("Tile Selection", EditorStyles.boldLabel);
DrawTileSelectionUI();
GUILayout.Space(12);
if (GUILayout.Button("Import / Rebuild"))
ImportTiles();
@@ -119,6 +152,8 @@ public class GeoTileImporter : EditorWindow
"Absolute elevation mapping: Terrain Y = global_min, Terrain height = (global_max - global_min).\n" +
"CSV is header-driven (order-independent). Optionally applies ortho JPGs and instantiates buildings/trees GLBs.",
MessageType.Info);
EditorGUILayout.EndScrollView();
}
private static void EnsureHeightmapImportSettings(string assetPath)
@@ -258,6 +293,8 @@ public class GeoTileImporter : EditorWindow
applyOrthoTextures = false;
}
RefreshTileIndexCache();
var parent = GameObject.Find(parentName);
if (parent == null) parent = new GameObject(parentName);
@@ -267,118 +304,51 @@ public class GeoTileImporter : EditorWindow
DestroyImmediate(parent.transform.GetChild(i).gameObject);
}
var ci = CultureInfo.InvariantCulture;
var lines = File.ReadAllLines(tilesCsvPath);
Debug.Log($"[GeoTileImporter] Read {lines.Length} lines.");
if (lines.Length < 2)
var tiles = ParseTilesCsv();
if (tiles == null || tiles.Count == 0)
{
Debug.LogError("[GeoTileImporter] CSV has no data rows (need header + at least 1 row).");
Debug.LogError("[GeoTileImporter] No valid tiles found in CSV.");
return;
}
var headerLine = lines[0].Trim();
var headerMap = BuildHeaderMap(headerLine);
Debug.Log($"[GeoTileImporter] Header: {headerLine}");
Debug.Log($"[GeoTileImporter] Header columns mapped: {string.Join(", ", headerMap.Keys)}");
// Required columns (order-independent)
string[] required = { "tile_id", "xmin", "ymin", "global_min", "global_max", "out_res" };
if (!HasAll(headerMap, required))
var selectedTiles = ApplySelection(tiles);
if (selectedTiles.Count == 0)
{
Debug.LogError("[GeoTileImporter] CSV missing required columns. Required: " +
string.Join(", ", required) +
"\nFound: " + string.Join(", ", headerMap.Keys));
Debug.LogError("[GeoTileImporter] Selection is empty. Adjust Tile Selection and try again.");
return;
}
int IDX_TILE = headerMap["tile_id"];
int IDX_XMIN = headerMap["xmin"];
int IDX_YMIN = headerMap["ymin"];
int IDX_GMIN = headerMap["global_min"];
int IDX_GMAX = headerMap["global_max"];
int IDX_RES = headerMap["out_res"];
// Compute origin from min xmin/ymin
double originX = double.PositiveInfinity;
double originY = double.PositiveInfinity;
int validRowsForOrigin = 0;
for (int i = 1; i < lines.Length; i++)
for (int i = 0; i < selectedTiles.Count; i++)
{
var line = lines[i].Trim();
if (string.IsNullOrWhiteSpace(line)) continue;
var parts = line.Split(',');
// Robust: just ensure indices exist in this row
int needMaxIndex = Math.Max(Math.Max(Math.Max(Math.Max(IDX_TILE, IDX_XMIN), IDX_YMIN), IDX_GMIN), Math.Max(IDX_GMAX, IDX_RES));
if (parts.Length <= needMaxIndex)
{
Debug.LogWarning($"[GeoTileImporter] Origin scan: skipping line {i + 1} (too few columns: {parts.Length}). Line: '{line}'");
continue;
}
try
{
double xmin = double.Parse(parts[IDX_XMIN], ci);
double ymin = double.Parse(parts[IDX_YMIN], ci);
originX = Math.Min(originX, xmin);
originY = Math.Min(originY, ymin);
validRowsForOrigin++;
}
catch (Exception e)
{
Debug.LogWarning($"[GeoTileImporter] Origin scan parse failed line {i + 1}: '{line}'\n{e.Message}");
}
originX = Math.Min(originX, selectedTiles[i].Xmin);
originY = Math.Min(originY, selectedTiles[i].Ymin);
}
if (validRowsForOrigin == 0 || double.IsInfinity(originX) || double.IsInfinity(originY))
if (double.IsInfinity(originX) || double.IsInfinity(originY))
{
Debug.LogError("[GeoTileImporter] Could not compute origin (no valid rows parsed). Check CSV numeric format.");
return;
}
Debug.Log($"[GeoTileImporter] Origin: ({originX}, {originY}) from {validRowsForOrigin} valid rows.");
Debug.Log($"[GeoTileImporter] Origin: ({originX}, {originY}) from {selectedTiles.Count} selected tiles.");
int imported = 0, skipped = 0;
int importedTextures = 0;
var placements = new List<(string tileId, float ux, float uz, float gmin)>();
for (int i = 1; i < lines.Length; i++)
for (int i = 0; i < selectedTiles.Count; i++)
{
var line = lines[i].Trim();
if (string.IsNullOrWhiteSpace(line)) continue;
var tile = selectedTiles[i];
var tileId = tile.TileId;
double xmin = tile.Xmin;
double ymin = tile.Ymin;
double gmin = tile.GlobalMin;
double gmax = tile.GlobalMax;
var parts = line.Split(',');
int needMaxIndex = Math.Max(Math.Max(Math.Max(Math.Max(IDX_TILE, IDX_XMIN), IDX_YMIN), IDX_GMIN), Math.Max(IDX_GMAX, IDX_RES));
if (parts.Length <= needMaxIndex)
{
skipped++;
Debug.LogWarning($"[GeoTileImporter] Skipping line {i + 1} (too few columns: {parts.Length}). Line: '{line}'");
continue;
}
string tileId = parts[IDX_TILE].Trim();
double xmin, ymin, gmin, gmax;
int outRes;
try
{
xmin = double.Parse(parts[IDX_XMIN], ci);
ymin = double.Parse(parts[IDX_YMIN], ci);
gmin = double.Parse(parts[IDX_GMIN], ci);
gmax = double.Parse(parts[IDX_GMAX], ci);
outRes = int.Parse(parts[IDX_RES], ci);
}
catch (Exception e)
{
skipped++;
Debug.LogWarning($"[GeoTileImporter] Parse failed line {i + 1} tile '{tileId}': {e.Message}\nLine: '{line}'");
continue;
}
if (outRes != heightmapResolution)
Debug.LogWarning($"[GeoTileImporter] Tile {tileId}: out_res={outRes} but importer expects {heightmapResolution}.");
if (tile.OutRes != heightmapResolution)
Debug.LogWarning($"[GeoTileImporter] Tile {tileId}: out_res={tile.OutRes} but importer expects {heightmapResolution}.");
float heightRange = (float)(gmax - gmin);
if (heightRange <= 0.0001f)
@@ -493,7 +463,7 @@ public class GeoTileImporter : EditorWindow
}
}
Debug.Log($"[GeoTileImporter] Imported {tileId} @ XZ=({ux},{uz}) Y={gmin} heightRange={heightRange} usedU16={usedU16}");
Debug.Log($"[GeoTileImporter] Imported {tileId} ({tile.TileKey}) @ XZ=({ux},{uz}) Y={gmin} heightRange={heightRange} usedU16={usedU16}");
imported++;
placements.Add((tileId, ux, uz, (float)gmin));
}
@@ -509,6 +479,313 @@ public class GeoTileImporter : EditorWindow
ImportEnhancedTrees(placements);
}
private void RefreshTileIndexCache()
{
if (!File.Exists(tilesCsvPath))
{
tileIndexCache.Clear();
tileKeyOptions.Clear();
tileKeyLookup.Clear();
tileKeyStrings = Array.Empty<string>();
return;
}
var writeTime = File.GetLastWriteTimeUtc(tilesCsvPath);
if (tilesCsvPath == cachedTileCsvPath &&
writeTime == cachedTileCsvTimeUtc &&
Mathf.Approximately(tileKeySizeX, cachedTileKeySizeX) &&
Mathf.Approximately(tileKeySizeY, cachedTileKeySizeY) &&
Mathf.Approximately(tileKeyOverlapX, cachedTileOverlapX) &&
Mathf.Approximately(tileKeyOverlapY, cachedTileOverlapY))
{
return;
}
tileIndexCache.Clear();
tileIndexCache.AddRange(ParseTilesCsv());
BuildTileKeyOptions();
cachedTileCsvPath = tilesCsvPath;
cachedTileCsvTimeUtc = writeTime;
cachedTileKeySizeX = tileKeySizeX;
cachedTileKeySizeY = tileKeySizeY;
cachedTileOverlapX = tileKeyOverlapX;
cachedTileOverlapY = tileKeyOverlapY;
}
private void BuildTileKeyOptions()
{
tileKeyOptions.Clear();
tileKeyLookup.Clear();
for (int i = 0; i < tileIndexCache.Count; i++)
{
var tile = tileIndexCache[i];
if (string.IsNullOrWhiteSpace(tile.TileKey))
continue;
if (tileKeyLookup.ContainsKey(tile.TileKey))
continue;
var option = new TileKeyOption
{
Key = tile.TileKey,
X = tile.XKey,
Y = tile.YKey
};
tileKeyOptions.Add(option);
tileKeyLookup[tile.TileKey] = option;
}
tileKeyOptions.Sort((a, b) =>
{
int cmp = a.X.CompareTo(b.X);
return cmp != 0 ? cmp : a.Y.CompareTo(b.Y);
});
tileKeyStrings = new string[tileKeyOptions.Count];
for (int i = 0; i < tileKeyOptions.Count; i++)
tileKeyStrings[i] = tileKeyOptions[i].Key;
}
private void DrawTileSelectionUI()
{
if (tileKeyOptions.Count == 0)
{
EditorGUILayout.HelpBox("No tile keys available. Check the tile index CSV and key config.", MessageType.Info);
EditorGUILayout.LabelField("Tiles in selection", "0");
return;
}
if (string.IsNullOrEmpty(bottomLeftKey) || !tileKeyLookup.ContainsKey(bottomLeftKey))
bottomLeftKey = tileKeyOptions[0].Key;
var bottomLeft = tileKeyLookup[bottomLeftKey];
var topRightOptions = GetTopRightOptions(bottomLeft);
if (topRightOptions.Count == 0)
topRightOptions.Add(bottomLeft);
if (string.IsNullOrEmpty(topRightKey) || !ContainsKey(topRightOptions, topRightKey))
topRightKey = topRightOptions[topRightOptions.Count - 1].Key;
int bottomLeftIndex = Array.IndexOf(tileKeyStrings, bottomLeftKey);
int newBottomLeftIndex = EditorGUILayout.Popup("Bottom-Left Key", bottomLeftIndex, tileKeyStrings);
if (newBottomLeftIndex != bottomLeftIndex)
{
bottomLeftKey = tileKeyStrings[newBottomLeftIndex];
bottomLeft = tileKeyLookup[bottomLeftKey];
topRightOptions = GetTopRightOptions(bottomLeft);
if (topRightOptions.Count == 0)
topRightOptions.Add(bottomLeft);
if (!ContainsKey(topRightOptions, topRightKey))
topRightKey = topRightOptions[topRightOptions.Count - 1].Key;
}
var topRightKeys = BuildKeyArray(topRightOptions);
int topRightIndex = Array.IndexOf(topRightKeys, topRightKey);
if (topRightIndex < 0)
topRightIndex = topRightKeys.Length - 1;
int newTopRightIndex = EditorGUILayout.Popup("Top-Right Key", topRightIndex, topRightKeys);
if (newTopRightIndex != topRightIndex)
topRightKey = topRightKeys[newTopRightIndex];
EditorGUILayout.LabelField("Tiles in selection", CountSelectedTiles().ToString());
}
private List<TileKeyOption> GetTopRightOptions(TileKeyOption bottomLeft)
{
var options = new List<TileKeyOption>();
for (int i = 0; i < tileKeyOptions.Count; i++)
{
var option = tileKeyOptions[i];
if (option.X >= bottomLeft.X && option.Y >= bottomLeft.Y)
options.Add(option);
}
return options;
}
private int CountSelectedTiles()
{
if (!tileKeyLookup.TryGetValue(bottomLeftKey, out var bottomLeft) ||
!tileKeyLookup.TryGetValue(topRightKey, out var topRight))
return 0;
int count = 0;
for (int i = 0; i < tileIndexCache.Count; i++)
{
var tile = tileIndexCache[i];
if (tile.XKey >= bottomLeft.X && tile.XKey <= topRight.X &&
tile.YKey >= bottomLeft.Y && tile.YKey <= topRight.Y)
count++;
}
return count;
}
private List<TileRecord> ApplySelection(List<TileRecord> tiles)
{
if (!tileKeyLookup.TryGetValue(bottomLeftKey, out var bottomLeft) ||
!tileKeyLookup.TryGetValue(topRightKey, out var topRight))
return tiles;
var selected = new List<TileRecord>();
for (int i = 0; i < tiles.Count; i++)
{
var tile = tiles[i];
if (tile.XKey >= bottomLeft.X && tile.XKey <= topRight.X &&
tile.YKey >= bottomLeft.Y && tile.YKey <= topRight.Y)
selected.Add(tile);
}
return selected;
}
private List<TileRecord> ParseTilesCsv()
{
var tiles = new List<TileRecord>();
var ci = CultureInfo.InvariantCulture;
var lines = File.ReadAllLines(tilesCsvPath);
if (lines.Length < 2)
{
Debug.LogError("[GeoTileImporter] CSV has no data rows (need header + at least 1 row).");
return tiles;
}
var headerLine = lines[0].Trim();
var headerMap = BuildHeaderMap(headerLine);
Debug.Log($"[GeoTileImporter] Header columns mapped: {string.Join(", ", headerMap.Keys)}");
string[] required = { "tile_id", "xmin", "ymin", "global_min", "global_max", "out_res" };
if (!HasAll(headerMap, required))
{
Debug.LogError("[GeoTileImporter] CSV missing required columns. Required: " +
string.Join(", ", required) +
"\nFound: " + string.Join(", ", headerMap.Keys));
return tiles;
}
int IDX_TILE = headerMap["tile_id"];
int IDX_XMIN = headerMap["xmin"];
int IDX_YMIN = headerMap["ymin"];
int IDX_GMIN = headerMap["global_min"];
int IDX_GMAX = headerMap["global_max"];
int IDX_RES = headerMap["out_res"];
int IDX_TILE_KEY = headerMap.ContainsKey("tile_key") ? headerMap["tile_key"] : -1;
for (int i = 1; i < lines.Length; i++)
{
var line = lines[i].Trim();
if (string.IsNullOrWhiteSpace(line))
continue;
var parts = line.Split(',');
int needMaxIndex = Math.Max(Math.Max(Math.Max(Math.Max(IDX_TILE, IDX_XMIN), IDX_YMIN), IDX_GMIN), Math.Max(IDX_GMAX, IDX_RES));
if (IDX_TILE_KEY >= 0)
needMaxIndex = Math.Max(needMaxIndex, IDX_TILE_KEY);
if (parts.Length <= needMaxIndex)
{
Debug.LogWarning($"[GeoTileImporter] Skipping line {i + 1} (too few columns: {parts.Length}).");
continue;
}
try
{
var xmin = double.Parse(parts[IDX_XMIN], ci);
var ymin = double.Parse(parts[IDX_YMIN], ci);
var tileKeyRaw = IDX_TILE_KEY >= 0 ? parts[IDX_TILE_KEY].Trim() : "";
var tileKey = ResolveTileKey(tileKeyRaw, xmin, ymin, out var xKey, out var yKey);
tiles.Add(new TileRecord
{
TileId = parts[IDX_TILE].Trim(),
TileKey = tileKey,
XKey = xKey,
YKey = yKey,
Xmin = xmin,
Ymin = ymin,
GlobalMin = double.Parse(parts[IDX_GMIN], ci),
GlobalMax = double.Parse(parts[IDX_GMAX], ci),
OutRes = int.Parse(parts[IDX_RES], ci)
});
}
catch (Exception e)
{
Debug.LogWarning($"[GeoTileImporter] Parse error line {i + 1}: {e.Message}");
}
}
return tiles;
}
private string ResolveTileKey(string tileKeyRaw, double xmin, double ymin, out int xKey, out int yKey)
{
if (!string.IsNullOrWhiteSpace(tileKeyRaw) && TryParseTileKey(tileKeyRaw, out xKey, out yKey))
return tileKeyRaw;
float sizeX = tileKeySizeX <= 0f ? 1f : tileKeySizeX;
float sizeY = tileKeySizeY <= 0f ? 1f : tileKeySizeY;
xKey = (int)Math.Floor((xmin + tileKeyOverlapX) / sizeX);
yKey = (int)Math.Floor((ymin + tileKeyOverlapY) / sizeY);
return $"{xKey}_{yKey}";
}
private static bool TryParseTileKey(string tileKey, out int xKey, out int yKey)
{
xKey = 0;
yKey = 0;
if (string.IsNullOrWhiteSpace(tileKey))
return false;
var parts = tileKey.Split('_');
if (parts.Length != 2)
return false;
return int.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out xKey)
&& int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out yKey);
}
private static bool ContainsKey(List<TileKeyOption> options, string key)
{
for (int i = 0; i < options.Count; i++)
{
if (string.Equals(options[i].Key, key, StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
private static string[] BuildKeyArray(List<TileKeyOption> options)
{
var keys = new string[options.Count];
for (int i = 0; i < options.Count; i++)
keys[i] = options[i].Key;
return keys;
}
private struct TileRecord
{
public string TileId;
public string TileKey;
public int XKey;
public int YKey;
public double Xmin;
public double Ymin;
public double GlobalMin;
public double GlobalMax;
public int OutRes;
}
private struct TileKeyOption
{
public string Key;
public int X;
public int Y;
}
private void ImportBuildings(List<(string tileId, float ux, float uz, float gmin)> placements)
{
if (!importBuildings)

View File

@@ -35,6 +35,24 @@ public class GeoTilePrefabImporter : EditorWindow
private bool includeFurniture = false;
private bool includeEnhancedTrees = false;
private Vector2 scrollPosition;
private float tileKeySizeX = 1000f;
private float tileKeySizeY = 1000f;
private float tileKeyOverlapX = 0.5f;
private float tileKeyOverlapY = 0.5f;
private string bottomLeftKey = "";
private string topRightKey = "";
private readonly List<TileMetadata> tileIndexCache = new List<TileMetadata>();
private readonly List<TileKeyOption> tileKeyOptions = new List<TileKeyOption>();
private readonly Dictionary<string, TileKeyOption> tileKeyLookup = new Dictionary<string, TileKeyOption>(StringComparer.OrdinalIgnoreCase);
private string[] tileKeyStrings = Array.Empty<string>();
private string cachedTileCsvPath = "";
private DateTime cachedTileCsvTimeUtc = DateTime.MinValue;
private float cachedTileKeySizeX = 0f;
private float cachedTileKeySizeY = 0f;
private float cachedTileOverlapX = 0f;
private float cachedTileOverlapY = 0f;
// Prefabs for furniture (optional)
private GameObject lampPrefab;
private GameObject benchPrefab;
@@ -45,7 +63,10 @@ public class GeoTilePrefabImporter : EditorWindow
private struct TileMetadata
{
public string TileKey;
public string TileId;
public int XKey;
public int YKey;
public double Xmin, Ymin, Xmax, Ymax;
public double GlobalMin, GlobalMax;
public int OutRes;
@@ -60,6 +81,8 @@ public class GeoTilePrefabImporter : EditorWindow
private void OnGUI()
{
scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition);
GUILayout.Label("Input Paths", EditorStyles.boldLabel);
tilesCsvPath = EditorGUILayout.TextField("tile_index.csv", tilesCsvPath);
heightmapsDir = EditorGUILayout.TextField("height_png16 dir", heightmapsDir);
@@ -96,6 +119,19 @@ public class GeoTilePrefabImporter : EditorWindow
bollardPrefab = (GameObject)EditorGUILayout.ObjectField("Bollard Prefab", bollardPrefab, typeof(GameObject), false);
defaultFurniturePrefab = (GameObject)EditorGUILayout.ObjectField("Default Furniture", defaultFurniturePrefab, typeof(GameObject), false);
GUILayout.Space(12);
GUILayout.Label("Tile Key Config", EditorStyles.boldLabel);
tileKeySizeX = EditorGUILayout.FloatField("Tile Size X (m)", tileKeySizeX);
tileKeySizeY = EditorGUILayout.FloatField("Tile Size Y (m)", tileKeySizeY);
tileKeyOverlapX = EditorGUILayout.FloatField("Overlap X (m)", tileKeyOverlapX);
tileKeyOverlapY = EditorGUILayout.FloatField("Overlap Y (m)", tileKeyOverlapY);
RefreshTileIndexCache();
GUILayout.Space(10);
GUILayout.Label("Tile Selection", EditorStyles.boldLabel);
DrawTileSelectionUI();
GUILayout.Space(15);
if (GUILayout.Button("Generate Prefabs"))
ImportTilesAsPrefabs();
@@ -110,6 +146,8 @@ public class GeoTilePrefabImporter : EditorWindow
"TerrainData and TerrainLayers are saved as separate .asset files.\n" +
"IMPORTANT: Use 'Place All Prefabs in Scene' to position tiles correctly.",
MessageType.Info);
EditorGUILayout.EndScrollView();
}
private void PlaceAllPrefabsInScene()
@@ -211,6 +249,7 @@ public class GeoTilePrefabImporter : EditorWindow
EnsureDirectoryExists($"{prefabOutputDir}/TerrainLayers");
// Parse CSV
RefreshTileIndexCache();
var tiles = ParseTilesCsv();
if (tiles == null || tiles.Count == 0)
{
@@ -218,17 +257,24 @@ public class GeoTilePrefabImporter : EditorWindow
return;
}
Debug.Log($"[GeoTilePrefabImporter] Found {tiles.Count} tiles to process.");
var selectedTiles = ApplySelection(tiles);
if (selectedTiles.Count == 0)
{
Debug.LogError("[GeoTilePrefabImporter] Selection is empty. Adjust Tile Selection and try again.");
return;
}
Debug.Log($"[GeoTilePrefabImporter] Found {selectedTiles.Count} tiles to process.");
int created = 0, skipped = 0, failed = 0;
for (int i = 0; i < tiles.Count; i++)
for (int i = 0; i < selectedTiles.Count; i++)
{
var tile = tiles[i];
var tile = selectedTiles[i];
EditorUtility.DisplayProgressBar(
"Creating Tile Prefabs",
$"Processing {tile.TileId} ({i + 1}/{tiles.Count})",
(float)i / tiles.Count);
$"Processing {tile.TileId} ({i + 1}/{selectedTiles.Count})",
(float)i / selectedTiles.Count);
string prefabPath = $"{prefabOutputDir}/{tile.TileId}.prefab";
if (File.Exists(prefabPath) && !overwriteExisting)
@@ -259,6 +305,168 @@ public class GeoTilePrefabImporter : EditorWindow
Debug.Log($"[GeoTilePrefabImporter] DONE. Created={created}, Skipped={skipped}, Failed={failed}");
}
private void RefreshTileIndexCache()
{
if (!File.Exists(tilesCsvPath))
{
tileIndexCache.Clear();
tileKeyOptions.Clear();
tileKeyLookup.Clear();
tileKeyStrings = Array.Empty<string>();
return;
}
var writeTime = File.GetLastWriteTimeUtc(tilesCsvPath);
if (tilesCsvPath == cachedTileCsvPath &&
writeTime == cachedTileCsvTimeUtc &&
Mathf.Approximately(tileKeySizeX, cachedTileKeySizeX) &&
Mathf.Approximately(tileKeySizeY, cachedTileKeySizeY) &&
Mathf.Approximately(tileKeyOverlapX, cachedTileOverlapX) &&
Mathf.Approximately(tileKeyOverlapY, cachedTileOverlapY))
{
return;
}
tileIndexCache.Clear();
tileIndexCache.AddRange(ParseTilesCsv());
BuildTileKeyOptions();
cachedTileCsvPath = tilesCsvPath;
cachedTileCsvTimeUtc = writeTime;
cachedTileKeySizeX = tileKeySizeX;
cachedTileKeySizeY = tileKeySizeY;
cachedTileOverlapX = tileKeyOverlapX;
cachedTileOverlapY = tileKeyOverlapY;
}
private void BuildTileKeyOptions()
{
tileKeyOptions.Clear();
tileKeyLookup.Clear();
for (int i = 0; i < tileIndexCache.Count; i++)
{
var tile = tileIndexCache[i];
if (string.IsNullOrWhiteSpace(tile.TileKey))
continue;
if (tileKeyLookup.ContainsKey(tile.TileKey))
continue;
var option = new TileKeyOption
{
Key = tile.TileKey,
X = tile.XKey,
Y = tile.YKey
};
tileKeyOptions.Add(option);
tileKeyLookup[tile.TileKey] = option;
}
tileKeyOptions.Sort((a, b) =>
{
int cmp = a.X.CompareTo(b.X);
return cmp != 0 ? cmp : a.Y.CompareTo(b.Y);
});
tileKeyStrings = new string[tileKeyOptions.Count];
for (int i = 0; i < tileKeyOptions.Count; i++)
tileKeyStrings[i] = tileKeyOptions[i].Key;
}
private void DrawTileSelectionUI()
{
if (tileKeyOptions.Count == 0)
{
EditorGUILayout.HelpBox("No tile keys available. Check the tile index CSV and key config.", MessageType.Info);
EditorGUILayout.LabelField("Tiles in selection", "0");
return;
}
if (string.IsNullOrEmpty(bottomLeftKey) || !tileKeyLookup.ContainsKey(bottomLeftKey))
bottomLeftKey = tileKeyOptions[0].Key;
var bottomLeft = tileKeyLookup[bottomLeftKey];
var topRightOptions = GetTopRightOptions(bottomLeft);
if (topRightOptions.Count == 0)
topRightOptions.Add(bottomLeft);
if (string.IsNullOrEmpty(topRightKey) || !ContainsKey(topRightOptions, topRightKey))
topRightKey = topRightOptions[topRightOptions.Count - 1].Key;
int bottomLeftIndex = Array.IndexOf(tileKeyStrings, bottomLeftKey);
int newBottomLeftIndex = EditorGUILayout.Popup("Bottom-Left Key", bottomLeftIndex, tileKeyStrings);
if (newBottomLeftIndex != bottomLeftIndex)
{
bottomLeftKey = tileKeyStrings[newBottomLeftIndex];
bottomLeft = tileKeyLookup[bottomLeftKey];
topRightOptions = GetTopRightOptions(bottomLeft);
if (topRightOptions.Count == 0)
topRightOptions.Add(bottomLeft);
if (!ContainsKey(topRightOptions, topRightKey))
topRightKey = topRightOptions[topRightOptions.Count - 1].Key;
}
var topRightKeys = BuildKeyArray(topRightOptions);
int topRightIndex = Array.IndexOf(topRightKeys, topRightKey);
if (topRightIndex < 0)
topRightIndex = topRightKeys.Length - 1;
int newTopRightIndex = EditorGUILayout.Popup("Top-Right Key", topRightIndex, topRightKeys);
if (newTopRightIndex != topRightIndex)
topRightKey = topRightKeys[newTopRightIndex];
EditorGUILayout.LabelField("Tiles in selection", CountSelectedTiles().ToString());
}
private List<TileKeyOption> GetTopRightOptions(TileKeyOption bottomLeft)
{
var options = new List<TileKeyOption>();
for (int i = 0; i < tileKeyOptions.Count; i++)
{
var option = tileKeyOptions[i];
if (option.X >= bottomLeft.X && option.Y >= bottomLeft.Y)
options.Add(option);
}
return options;
}
private int CountSelectedTiles()
{
if (!tileKeyLookup.TryGetValue(bottomLeftKey, out var bottomLeft) ||
!tileKeyLookup.TryGetValue(topRightKey, out var topRight))
return 0;
int count = 0;
for (int i = 0; i < tileIndexCache.Count; i++)
{
var tile = tileIndexCache[i];
if (tile.XKey >= bottomLeft.X && tile.XKey <= topRight.X &&
tile.YKey >= bottomLeft.Y && tile.YKey <= topRight.Y)
count++;
}
return count;
}
private List<TileMetadata> ApplySelection(List<TileMetadata> tiles)
{
if (!tileKeyLookup.TryGetValue(bottomLeftKey, out var bottomLeft) ||
!tileKeyLookup.TryGetValue(topRightKey, out var topRight))
return tiles;
var selected = new List<TileMetadata>();
for (int i = 0; i < tiles.Count; i++)
{
var tile = tiles[i];
if (tile.XKey >= bottomLeft.X && tile.XKey <= topRight.X &&
tile.YKey >= bottomLeft.Y && tile.YKey <= topRight.Y)
selected.Add(tile);
}
return selected;
}
private List<TileMetadata> ParseTilesCsv()
{
var tiles = new List<TileMetadata>();
@@ -288,6 +496,7 @@ public class GeoTilePrefabImporter : EditorWindow
int IDX_GMIN = headerMap["global_min"];
int IDX_GMAX = headerMap["global_max"];
int IDX_RES = headerMap["out_res"];
int IDX_TILE_KEY = headerMap.ContainsKey("tile_key") ? headerMap["tile_key"] : -1;
for (int i = 1; i < lines.Length; i++)
{
@@ -295,7 +504,11 @@ public class GeoTilePrefabImporter : EditorWindow
if (string.IsNullOrWhiteSpace(line)) continue;
var parts = line.Split(',');
int maxIdx = Math.Max(Math.Max(Math.Max(IDX_TILE, IDX_XMAX), IDX_YMAX), Math.Max(IDX_GMAX, IDX_RES));
int maxIdx = Math.Max(
Math.Max(Math.Max(IDX_TILE, IDX_XMIN), Math.Max(IDX_YMIN, IDX_XMAX)),
Math.Max(Math.Max(IDX_YMAX, IDX_GMIN), Math.Max(IDX_GMAX, IDX_RES)));
if (IDX_TILE_KEY >= 0)
maxIdx = Math.Max(maxIdx, IDX_TILE_KEY);
if (parts.Length <= maxIdx)
{
Debug.LogWarning($"[GeoTilePrefabImporter] Skipping line {i + 1}: too few columns.");
@@ -304,11 +517,19 @@ public class GeoTilePrefabImporter : EditorWindow
try
{
var xmin = double.Parse(parts[IDX_XMIN], ci);
var ymin = double.Parse(parts[IDX_YMIN], ci);
var tileKeyRaw = IDX_TILE_KEY >= 0 ? parts[IDX_TILE_KEY].Trim() : "";
var tileKey = ResolveTileKey(tileKeyRaw, xmin, ymin, out var xKey, out var yKey);
tiles.Add(new TileMetadata
{
TileKey = tileKey,
TileId = parts[IDX_TILE].Trim(),
Xmin = double.Parse(parts[IDX_XMIN], ci),
Ymin = double.Parse(parts[IDX_YMIN], ci),
XKey = xKey,
YKey = yKey,
Xmin = xmin,
Ymin = ymin,
Xmax = double.Parse(parts[IDX_XMAX], ci),
Ymax = double.Parse(parts[IDX_YMAX], ci),
GlobalMin = double.Parse(parts[IDX_GMIN], ci),
@@ -325,6 +546,58 @@ public class GeoTilePrefabImporter : EditorWindow
return tiles;
}
private string ResolveTileKey(string tileKeyRaw, double xmin, double ymin, out int xKey, out int yKey)
{
if (!string.IsNullOrWhiteSpace(tileKeyRaw) && TryParseTileKey(tileKeyRaw, out xKey, out yKey))
return tileKeyRaw;
float sizeX = tileKeySizeX <= 0f ? 1f : tileKeySizeX;
float sizeY = tileKeySizeY <= 0f ? 1f : tileKeySizeY;
xKey = (int)Math.Floor((xmin + tileKeyOverlapX) / sizeX);
yKey = (int)Math.Floor((ymin + tileKeyOverlapY) / sizeY);
return $"{xKey}_{yKey}";
}
private static bool TryParseTileKey(string tileKey, out int xKey, out int yKey)
{
xKey = 0;
yKey = 0;
if (string.IsNullOrWhiteSpace(tileKey))
return false;
var parts = tileKey.Split('_');
if (parts.Length != 2)
return false;
return int.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out xKey)
&& int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out yKey);
}
private static bool ContainsKey(List<TileKeyOption> options, string key)
{
for (int i = 0; i < options.Count; i++)
{
if (string.Equals(options[i].Key, key, StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
private static string[] BuildKeyArray(List<TileKeyOption> options)
{
var keys = new string[options.Count];
for (int i = 0; i < options.Count; i++)
keys[i] = options[i].Key;
return keys;
}
private struct TileKeyOption
{
public string Key;
public int X;
public int Y;
}
private bool CreateTilePrefab(TileMetadata tile)
{
// Validate height range
@@ -407,6 +680,7 @@ public class GeoTilePrefabImporter : EditorWindow
// Store metadata as component for later use
var metadata = root.AddComponent<GeoTileMetadata>();
metadata.tileKey = tile.TileKey;
metadata.tileId = tile.TileId;
metadata.xmin = tile.Xmin;
metadata.ymin = tile.Ymin;

View File

@@ -1,5 +1,7 @@
#if UNITY_EDITOR
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using UnityEditor;
using UnityEditor.SceneManagement;
@@ -46,6 +48,44 @@ public static class MissingScriptUtility
Debug.Log($"[MissingScriptUtility] Missing scripts found in prefabs: {missingCount}");
}
[MenuItem("Tools/Diagnostics/Find Missing Scripts in ScriptableObjects")]
public static void FindMissingScriptsInScriptableObjects()
{
var assetGuids = AssetDatabase.FindAssets("t:ScriptableObject");
int missingCount = 0;
for (int i = 0; i < assetGuids.Length; i++)
{
var path = AssetDatabase.GUIDToAssetPath(assetGuids[i]);
EditorUtility.DisplayProgressBar("Scanning ScriptableObjects", path, (float)i / assetGuids.Length);
if (string.IsNullOrWhiteSpace(path) || !path.EndsWith(".asset", StringComparison.OrdinalIgnoreCase))
continue;
if (!File.Exists(path))
continue;
string contents;
try
{
contents = File.ReadAllText(path);
}
catch
{
continue;
}
if (contents.Contains("m_Script: {fileID: 0}"))
{
missingCount++;
Debug.LogWarning($"[MissingScriptUtility] Missing script reference in asset: {path}");
}
}
EditorUtility.ClearProgressBar();
Debug.Log($"[MissingScriptUtility] Missing scripts found in scriptable objects: {missingCount}");
}
[MenuItem("Tools/Diagnostics/Remove Missing Scripts in Open Scenes")]
public static void RemoveMissingScriptsInOpenScenes()
{