updated the build pipeline

This commit is contained in:
2026-01-14 21:17:51 +01:00
parent 09dcd64ef5
commit da213b4475
74 changed files with 3320 additions and 45 deletions

View File

@@ -0,0 +1,35 @@
using UnityEditor;
using UnityEngine;
public static class GeoBoundsDebug
{
[MenuItem("Tools/Geo/Print Selected Mesh Bounds")]
public static void PrintSelectedMeshBounds()
{
foreach (var go in Selection.gameObjects)
{
var mf = go.GetComponent<MeshFilter>();
if (mf == null || mf.sharedMesh == null)
{
Debug.Log($"{go.name}: no MeshFilter/mesh");
continue;
}
var mesh = mf.sharedMesh;
var b = mesh.bounds;
// Mesh bounds are in local mesh space
var centerWorld = go.transform.TransformPoint(b.center);
// Approximate size in world space (ignores rotation, good enough for diagnosis)
var lossy = go.transform.lossyScale;
var sizeWorld = new Vector3(
Mathf.Abs(b.size.x * lossy.x),
Mathf.Abs(b.size.y * lossy.y),
Mathf.Abs(b.size.z * lossy.z)
);
Debug.Log($"{go.name}: worldCenter={centerWorld}, worldSize={sizeWorld}");
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 55ca6d0883f09584a888802b543d4540

View File

@@ -0,0 +1,957 @@
// Assets/Editor/GeoTileImporter.cs
// Robust terrain tile importer for Unity (URP or Built-in).
// - Parses CSV header -> column name mapping (order-independent)
// - Validates required columns exist
// - Imports 16-bit PNG heightmaps (tries Single Channel R16 + ushort pixel read; falls back safely)
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using UnityEditor;
using UnityEngine;
public class GeoTileImporter : EditorWindow
{
private string tilesCsvPath = "Assets/GeoData/tile_index.csv";
private string heightmapsDir = "Assets/GeoData/height_png16";
private string orthoDir = "Assets/GeoData/ortho_jpg";
private string buildingsDir = "Assets/GeoData/buildings_tiles";
private string buildingsEnhancedDir = "Assets/GeoData/buildings_enhanced";
private string treesDir = "Assets/GeoData/trees_tiles";
private string treeProxyPath = "Assets/GeoData/tree_proxies.glb";
private string furnitureDir = "Assets/GeoData/street_furniture";
private string enhancedTreesDir = "Assets/GeoData/trees_enhanced";
private float tileSizeMeters = 1000f;
private int heightmapResolution = 1025;
private string parentName = "Geo_Terrain_Tiles";
private string buildingsParentName = "Geo_Buildings";
private string treesParentName = "Geo_Trees";
private string furnitureParentName = "Geo_Furniture";
private bool deleteExisting = false;
private bool applyOrthoTextures = true;
private bool importBuildings = true;
private bool useEnhancedBuildings = false;
private bool importTrees = true;
private bool importFurniture = false;
private bool deleteExistingBuildings = false;
private bool deleteExistingTrees = false;
private bool deleteExistingFurniture = false;
private bool importEnhancedTrees = false;
private bool deleteExistingEnhancedTrees = false;
private string enhancedTreesParentName = "Geo_Trees_Enhanced";
// Prefabs for trees and furniture (assign in editor)
private GameObject treePrefab;
private GameObject lampPrefab;
private GameObject benchPrefab;
private GameObject signPrefab;
private GameObject bollardPrefab;
private GameObject defaultFurniturePrefab; // Fallback for unknown types
[MenuItem("Tools/Geo Tiles/Import Terrain Tiles (PNG16)")]
public static void ShowWindow()
{
var win = GetWindow<GeoTileImporter>("Geo Tile Importer");
win.minSize = new Vector2(620, 300);
}
private void OnGUI()
{
GUILayout.Label("Inputs", EditorStyles.boldLabel);
tilesCsvPath = EditorGUILayout.TextField("tile_index.csv", tilesCsvPath);
heightmapsDir = EditorGUILayout.TextField("height_png16 dir", heightmapsDir);
orthoDir = EditorGUILayout.TextField("ortho_jpg dir", orthoDir);
buildingsDir = EditorGUILayout.TextField("buildings_glb dir", buildingsDir);
treesDir = EditorGUILayout.TextField("trees_glb dir", treesDir);
treeProxyPath = EditorGUILayout.TextField("tree_proxies.glb", treeProxyPath);
GUILayout.Space(10);
GUILayout.Label("Terrain", EditorStyles.boldLabel);
tileSizeMeters = EditorGUILayout.FloatField("Tile size (m)", tileSizeMeters);
heightmapResolution = EditorGUILayout.IntField("Heightmap resolution", heightmapResolution);
GUILayout.Space(10);
GUILayout.Label("Scene", EditorStyles.boldLabel);
parentName = EditorGUILayout.TextField("Parent object name", parentName);
deleteExisting = EditorGUILayout.ToggleLeft("Delete existing terrains under parent", deleteExisting);
applyOrthoTextures = EditorGUILayout.ToggleLeft("Apply ortho texture per tile", applyOrthoTextures);
buildingsParentName = EditorGUILayout.TextField("Buildings parent name", buildingsParentName);
buildingsEnhancedDir = EditorGUILayout.TextField("Buildings enhanced dir", buildingsEnhancedDir);
deleteExistingBuildings = EditorGUILayout.ToggleLeft("Delete existing buildings under parent", deleteExistingBuildings);
importBuildings = EditorGUILayout.ToggleLeft("Import buildings (GLB per tile)", importBuildings);
useEnhancedBuildings = EditorGUILayout.ToggleLeft("Use enhanced buildings (from buildings_enhanced/)", useEnhancedBuildings);
GUILayout.Space(5);
treesParentName = EditorGUILayout.TextField("Trees parent name", treesParentName);
deleteExistingTrees = EditorGUILayout.ToggleLeft("Delete existing trees under parent", deleteExistingTrees);
importTrees = EditorGUILayout.ToggleLeft("Import trees (GLB chunks per tile)", importTrees);
GUILayout.Space(5);
furnitureParentName = EditorGUILayout.TextField("Furniture parent name", furnitureParentName);
furnitureDir = EditorGUILayout.TextField("Furniture CSV dir", furnitureDir);
deleteExistingFurniture = EditorGUILayout.ToggleLeft("Delete existing furniture under parent", deleteExistingFurniture);
importFurniture = EditorGUILayout.ToggleLeft("Import street furniture (from CSV)", importFurniture);
GUILayout.Space(5);
enhancedTreesParentName = EditorGUILayout.TextField("Enhanced trees parent", enhancedTreesParentName);
enhancedTreesDir = EditorGUILayout.TextField("Enhanced trees CSV dir", enhancedTreesDir);
deleteExistingEnhancedTrees = EditorGUILayout.ToggleLeft("Delete existing enhanced trees", deleteExistingEnhancedTrees);
importEnhancedTrees = EditorGUILayout.ToggleLeft("Import enhanced trees (CSV with canopy colors)", importEnhancedTrees);
GUILayout.Space(10);
GUILayout.Label("Prefabs (optional)", EditorStyles.boldLabel);
treePrefab = (GameObject)EditorGUILayout.ObjectField("Tree Prefab", treePrefab, typeof(GameObject), false);
lampPrefab = (GameObject)EditorGUILayout.ObjectField("Lamp Prefab", lampPrefab, typeof(GameObject), false);
benchPrefab = (GameObject)EditorGUILayout.ObjectField("Bench Prefab", benchPrefab, typeof(GameObject), false);
signPrefab = (GameObject)EditorGUILayout.ObjectField("Sign Prefab", signPrefab, typeof(GameObject), false);
bollardPrefab = (GameObject)EditorGUILayout.ObjectField("Bollard Prefab", bollardPrefab, typeof(GameObject), false);
defaultFurniturePrefab = (GameObject)EditorGUILayout.ObjectField("Default Furniture Prefab", defaultFurniturePrefab, typeof(GameObject), false);
GUILayout.Space(12);
if (GUILayout.Button("Import / Rebuild"))
ImportTiles();
EditorGUILayout.HelpBox(
"Creates one Unity Terrain per CSV row and positions tiles on a meter grid.\n" +
"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);
}
private static void EnsureHeightmapImportSettings(string assetPath)
{
var ti = (TextureImporter)AssetImporter.GetAtPath(assetPath);
if (ti == null) return;
bool changed = false;
if (!ti.isReadable) { ti.isReadable = true; changed = true; }
if (ti.sRGBTexture) { ti.sRGBTexture = false; changed = true; }
if (ti.textureCompression != TextureImporterCompression.Uncompressed)
{
ti.textureCompression = TextureImporterCompression.Uncompressed;
changed = true;
}
if (ti.npotScale != TextureImporterNPOTScale.None)
{
ti.npotScale = TextureImporterNPOTScale.None; // keep 1025x1025
changed = true;
}
if (ti.textureType != TextureImporterType.SingleChannel)
{
ti.textureType = TextureImporterType.SingleChannel;
changed = true;
}
var ps = ti.GetDefaultPlatformTextureSettings();
if (ps.format != TextureImporterFormat.R16)
{
ps.format = TextureImporterFormat.R16;
ti.SetPlatformTextureSettings(ps);
changed = true;
}
changed |= EnsurePlatformR16(ti, "Standalone");
changed |= EnsurePlatformR16(ti, "Android");
changed |= EnsurePlatformR16(ti, "iPhone");
if (changed) ti.SaveAndReimport();
}
private static bool EnsurePlatformR16(TextureImporter ti, string platform)
{
var ps = ti.GetPlatformTextureSettings(platform);
bool changed = false;
if (ps.name != platform)
{
ps = new TextureImporterPlatformSettings
{
name = platform,
overridden = true,
format = TextureImporterFormat.R16,
textureCompression = TextureImporterCompression.Uncompressed,
};
ti.SetPlatformTextureSettings(ps);
return true;
}
if (!ps.overridden) { ps.overridden = true; changed = true; }
if (ps.format != TextureImporterFormat.R16) { ps.format = TextureImporterFormat.R16; changed = true; }
if (ps.textureCompression != TextureImporterCompression.Uncompressed)
{
ps.textureCompression = TextureImporterCompression.Uncompressed;
changed = true;
}
if (changed) ti.SetPlatformTextureSettings(ps);
return changed;
}
private static string NormalizeHeader(string s)
=> (s ?? "").Trim().ToLowerInvariant();
private static void EnsureOrthoImportSettings(string assetPath)
{
var ti = (TextureImporter)AssetImporter.GetAtPath(assetPath);
if (ti == null) return;
bool changed = false;
if (!ti.isReadable) { ti.isReadable = true; changed = true; }
if (!ti.sRGBTexture) { ti.sRGBTexture = true; changed = true; }
if (ti.npotScale != TextureImporterNPOTScale.None) { ti.npotScale = TextureImporterNPOTScale.None; changed = true; }
if (ti.wrapMode != TextureWrapMode.Clamp) { ti.wrapMode = TextureWrapMode.Clamp; changed = true; }
if (ti.textureCompression != TextureImporterCompression.Uncompressed)
{
ti.textureCompression = TextureImporterCompression.Uncompressed;
changed = true;
}
if (changed) ti.SaveAndReimport();
}
private static Dictionary<string, int> BuildHeaderMap(string headerLine)
{
var map = new Dictionary<string, int>();
var cols = headerLine.Split(',');
for (int i = 0; i < cols.Length; i++)
{
var key = NormalizeHeader(cols[i]);
if (string.IsNullOrEmpty(key)) continue;
if (!map.ContainsKey(key))
map[key] = i;
}
return map;
}
private static bool HasAll(Dictionary<string, int> map, params string[] required)
{
foreach (var r in required)
if (!map.ContainsKey(NormalizeHeader(r)))
return false;
return true;
}
private void ImportTiles()
{
Debug.Log($"[GeoTileImporter] START\n csv={tilesCsvPath}\n pngDir={heightmapsDir}");
if (!File.Exists(tilesCsvPath))
{
Debug.LogError($"[GeoTileImporter] CSV not found: {tilesCsvPath}");
return;
}
if (!Directory.Exists(heightmapsDir))
{
Debug.LogError($"[GeoTileImporter] Heightmap dir not found: {heightmapsDir}");
return;
}
if (applyOrthoTextures && !Directory.Exists(orthoDir))
{
Debug.LogWarning($"[GeoTileImporter] Ortho dir not found: {orthoDir} (textures will be skipped).");
applyOrthoTextures = false;
}
var parent = GameObject.Find(parentName);
if (parent == null) parent = new GameObject(parentName);
if (deleteExisting)
{
for (int i = parent.transform.childCount - 1; i >= 0; i--)
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)
{
Debug.LogError("[GeoTileImporter] CSV has no data rows (need header + at least 1 row).");
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))
{
Debug.LogError("[GeoTileImporter] CSV missing required columns. Required: " +
string.Join(", ", required) +
"\nFound: " + string.Join(", ", headerMap.Keys));
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++)
{
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}");
}
}
if (validRowsForOrigin == 0 || 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.");
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++)
{
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 (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}.");
float heightRange = (float)(gmax - gmin);
if (heightRange <= 0.0001f)
{
skipped++;
Debug.LogWarning($"[GeoTileImporter] Tile {tileId}: invalid height range (global_max <= global_min). Skipping.");
continue;
}
string pngPath = Path.Combine(heightmapsDir, $"{tileId}.png").Replace("\\", "/");
if (!File.Exists(pngPath))
{
skipped++;
Debug.LogError($"[GeoTileImporter] Missing PNG for {tileId}: {pngPath}");
continue;
}
EnsureHeightmapImportSettings(pngPath);
var tex = AssetDatabase.LoadAssetAtPath<Texture2D>(pngPath);
if (tex == null)
{
skipped++;
Debug.LogError($"[GeoTileImporter] Could not load Texture2D asset: {pngPath}");
continue;
}
if (tex.width != heightmapResolution || tex.height != heightmapResolution)
Debug.LogWarning($"[GeoTileImporter] Tile {tileId}: PNG {tex.width}x{tex.height}, expected {heightmapResolution}x{heightmapResolution}.");
var terrainData = new TerrainData
{
heightmapResolution = heightmapResolution,
size = new Vector3(tileSizeMeters, heightRange, tileSizeMeters),
};
int w = tex.width;
int h = tex.height;
var heights = new float[h, w];
bool usedU16 = false;
try
{
var raw = tex.GetPixelData<ushort>(0);
if (raw.Length == w * h)
{
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++)
heights[y, x] = raw[y * w + x] / 65535f;
usedU16 = true;
}
}
catch
{
// fallback below
}
if (!usedU16)
{
var pixels = tex.GetPixels();
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++)
heights[y, x] = pixels[y * w + x].r;
}
terrainData.SetHeights(0, 0, heights);
var go = Terrain.CreateTerrainGameObject(terrainData);
go.name = tileId;
go.transform.parent = parent.transform;
float ux = (float)(xmin - originX);
float uz = (float)(ymin - originY);
go.transform.position = new Vector3(ux, (float)gmin, uz);
var terrain = go.GetComponent<Terrain>();
terrain.drawInstanced = true;
// Optional ortho texture application
if (applyOrthoTextures)
{
string orthoPath = Path.Combine(orthoDir, $"{tileId}.jpg").Replace("\\", "/");
if (File.Exists(orthoPath))
{
EnsureOrthoImportSettings(orthoPath);
var orthoTex = AssetDatabase.LoadAssetAtPath<Texture2D>(orthoPath);
if (orthoTex == null)
{
Debug.LogWarning($"[GeoTileImporter] Could not load ortho texture for {tileId}: {orthoPath}");
}
else
{
var layer = new TerrainLayer
{
diffuseTexture = orthoTex,
tileSize = new Vector2(tileSizeMeters, tileSizeMeters),
tileOffset = Vector2.zero
};
terrainData.terrainLayers = new[] { layer };
terrainData.alphamapResolution = 16;
var alpha = new float[terrainData.alphamapHeight, terrainData.alphamapWidth, 1];
for (int ay = 0; ay < terrainData.alphamapHeight; ay++)
for (int ax = 0; ax < terrainData.alphamapWidth; ax++)
alpha[ay, ax, 0] = 1f;
terrainData.SetAlphamaps(0, 0, alpha);
importedTextures++;
}
}
else
{
Debug.LogWarning($"[GeoTileImporter] Ortho texture missing for {tileId}: {orthoPath}");
}
}
Debug.Log($"[GeoTileImporter] Imported {tileId} @ XZ=({ux},{uz}) Y={gmin} heightRange={heightRange} usedU16={usedU16}");
imported++;
placements.Add((tileId, ux, uz, (float)gmin));
}
Debug.Log($"[GeoTileImporter] DONE. Imported={imported}, Skipped={skipped}, OrthoApplied={importedTextures} under '{parentName}'.");
if (imported == 0)
Debug.LogError("[GeoTileImporter] Imported 0 tiles. Scroll up for warnings/errors (missing columns, parse issues, missing PNGs).");
ImportBuildings(placements);
ImportTrees(placements);
ImportFurniture(placements);
ImportEnhancedTrees(placements);
}
private void ImportBuildings(List<(string tileId, float ux, float uz, float gmin)> placements)
{
if (!importBuildings)
return;
// Choose directory based on enhanced toggle
string activeDir = useEnhancedBuildings ? buildingsEnhancedDir : buildingsDir;
string sourceLabel = useEnhancedBuildings ? "enhanced" : "standard";
if (!Directory.Exists(activeDir))
{
Debug.LogWarning($"[GeoTileImporter] Buildings dir ({sourceLabel}) not found: {activeDir} (skipping buildings).");
return;
}
var parent = GameObject.Find(buildingsParentName);
if (parent == null) parent = new GameObject(buildingsParentName);
if (deleteExistingBuildings)
{
for (int i = parent.transform.childCount - 1; i >= 0; i--)
DestroyImmediate(parent.transform.GetChild(i).gameObject);
}
int imported = 0, missing = 0;
foreach (var (tileId, ux, uz, gmin) in placements)
{
string glbPath = Path.Combine(activeDir, $"{tileId}.glb").Replace("\\", "/");
if (!File.Exists(glbPath))
{
missing++;
Debug.LogWarning($"[GeoTileImporter] Building GLB ({sourceLabel}) missing for {tileId}: {glbPath}");
continue;
}
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(glbPath);
if (prefab == null)
{
missing++;
Debug.LogWarning($"[GeoTileImporter] Could not load building GLB for {tileId}: {glbPath}");
continue;
}
var inst = PrefabUtility.InstantiatePrefab(prefab) as GameObject ?? Instantiate(prefab);
inst.name = tileId;
inst.transform.SetParent(parent.transform, false);
inst.transform.position = new Vector3(ux, gmin, uz);
inst.transform.localRotation = Quaternion.Euler(0f, 180f, 0f);
inst.isStatic = true;
imported++;
}
Debug.Log($"[GeoTileImporter] Buildings ({sourceLabel}) imported={imported}, missing/failed={missing} under '{buildingsParentName}'.");
}
private void ImportTrees(List<(string tileId, float ux, float uz, float gmin)> placements)
{
if (!importTrees)
return;
if (!Directory.Exists(treesDir))
{
Debug.LogWarning($"[GeoTileImporter] Trees dir not found: {treesDir} (skipping trees).");
return;
}
var parent = GameObject.Find(treesParentName);
if (parent == null) parent = new GameObject(treesParentName);
if (deleteExistingTrees)
{
for (int i = parent.transform.childCount - 1; i >= 0; i--)
DestroyImmediate(parent.transform.GetChild(i).gameObject);
}
if (!File.Exists(treeProxyPath))
{
Debug.LogWarning($"[GeoTileImporter] Tree proxy GLB not found (for reference/materials): {treeProxyPath}");
}
int importedTiles = 0, importedChunks = 0, missingTiles = 0;
foreach (var (tileId, ux, uz, gmin) in placements)
{
// Look for chunk files: {tileId}_0_0.glb, {tileId}_0_1.glb, etc.
// Standard tree export creates 4x4 chunks per tile
var chunkFiles = Directory.GetFiles(treesDir, $"{tileId}_*.glb");
if (chunkFiles.Length == 0)
{
// Try single file as fallback
string singlePath = Path.Combine(treesDir, $"{tileId}.glb").Replace("\\", "/");
if (File.Exists(singlePath))
{
chunkFiles = new[] { singlePath };
}
else
{
missingTiles++;
continue;
}
}
// Create container for this tile's tree chunks
var tileContainer = new GameObject($"Trees_{tileId}");
tileContainer.transform.SetParent(parent.transform, false);
tileContainer.transform.position = new Vector3(ux, gmin, uz);
tileContainer.transform.localRotation = Quaternion.Euler(0f, 180f, 0f);
tileContainer.isStatic = true;
foreach (var chunkPath in chunkFiles)
{
string assetPath = chunkPath.Replace("\\", "/");
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(assetPath);
if (prefab == null)
{
Debug.LogWarning($"[GeoTileImporter] Could not load tree chunk: {assetPath}");
continue;
}
var inst = PrefabUtility.InstantiatePrefab(prefab) as GameObject ?? Instantiate(prefab);
inst.name = Path.GetFileNameWithoutExtension(chunkPath);
inst.transform.SetParent(tileContainer.transform, false);
inst.transform.localPosition = Vector3.zero; // Chunks already have correct local positions
inst.isStatic = true;
importedChunks++;
}
importedTiles++;
}
Debug.Log($"[GeoTileImporter] Trees imported: {importedTiles} tiles, {importedChunks} chunks, {missingTiles} missing under '{treesParentName}'.");
}
private void ImportFurniture(List<(string tileId, float ux, float uz, float gmin)> placements)
{
if (!importFurniture)
return;
if (!Directory.Exists(furnitureDir))
{
Debug.LogWarning($"[GeoTileImporter] Furniture dir not found: {furnitureDir} (skipping furniture).");
return;
}
var parent = GameObject.Find(furnitureParentName);
if (parent == null) parent = new GameObject(furnitureParentName);
if (deleteExistingFurniture)
{
for (int i = parent.transform.childCount - 1; i >= 0; i--)
DestroyImmediate(parent.transform.GetChild(i).gameObject);
}
int imported = 0, skipped = 0;
var ci = CultureInfo.InvariantCulture;
foreach (var (tileId, ux, uz, gmin) in placements)
{
string csvPath = Path.Combine(furnitureDir, $"{tileId}.csv").Replace("\\", "/");
if (!File.Exists(csvPath))
{
continue; // No furniture for this tile
}
var lines = File.ReadAllLines(csvPath);
if (lines.Length < 2)
continue;
// Parse header
var header = lines[0].Split(',');
int idxXLocal = -1, idxYLocal = -1, idxZGround = -1, idxHeight = -1, idxType = -1;
for (int i = 0; i < header.Length; i++)
{
var col = header[i].Trim().ToLowerInvariant();
if (col == "x_local") idxXLocal = i;
else if (col == "y_local") idxYLocal = i;
else if (col == "z_ground") idxZGround = i;
else if (col == "height") idxHeight = i;
else if (col == "type") idxType = i;
}
if (idxXLocal < 0 || idxYLocal < 0 || idxType < 0)
{
Debug.LogWarning($"[GeoTileImporter] Furniture CSV missing required columns: {csvPath}");
continue;
}
// Create tile container
var tileContainer = new GameObject($"Furniture_{tileId}");
tileContainer.transform.SetParent(parent.transform, false);
tileContainer.transform.position = new Vector3(ux, gmin, uz);
tileContainer.isStatic = true;
for (int i = 1; i < lines.Length; i++)
{
var parts = lines[i].Split(',');
if (parts.Length <= Math.Max(Math.Max(idxXLocal, idxYLocal), idxType))
{
skipped++;
continue;
}
try
{
float xLocal = float.Parse(parts[idxXLocal].Trim(), ci);
float yLocal = float.Parse(parts[idxYLocal].Trim(), ci);
float zGround = idxZGround >= 0 && idxZGround < parts.Length
? float.Parse(parts[idxZGround].Trim(), ci)
: 0f;
float height = idxHeight >= 0 && idxHeight < parts.Length
? float.Parse(parts[idxHeight].Trim(), ci)
: 1f;
string furnitureType = parts[idxType].Trim().ToLowerInvariant();
// Select prefab based on type, or create placeholder
GameObject obj = null;
GameObject prefabToUse = null;
Vector3 fallbackScale = Vector3.one;
switch (furnitureType)
{
case "lamp":
prefabToUse = lampPrefab;
fallbackScale = new Vector3(0.3f, height, 0.3f);
break;
case "bench":
prefabToUse = benchPrefab;
fallbackScale = new Vector3(1.5f, height, 0.5f);
break;
case "sign":
prefabToUse = signPrefab;
fallbackScale = new Vector3(0.1f, height, 0.5f);
break;
case "bollard":
prefabToUse = bollardPrefab;
fallbackScale = new Vector3(0.2f, height, 0.2f);
break;
default:
prefabToUse = defaultFurniturePrefab;
fallbackScale = new Vector3(0.5f, height, 0.5f);
break;
}
if (prefabToUse != null)
{
// Use prefab
obj = PrefabUtility.InstantiatePrefab(prefabToUse) as GameObject ?? Instantiate(prefabToUse);
obj.name = $"{furnitureType}_{i}";
// Scale prefab to match height (assuming prefab is normalized to ~1m)
float scaleMultiplier = height;
obj.transform.localScale = Vector3.one * scaleMultiplier;
}
else
{
// Create placeholder cube
obj = GameObject.CreatePrimitive(PrimitiveType.Cube);
obj.name = $"{furnitureType}_{i}";
obj.transform.localScale = fallbackScale;
}
obj.transform.SetParent(tileContainer.transform, false);
obj.transform.localPosition = new Vector3(xLocal, zGround - gmin, yLocal);
obj.isStatic = true;
imported++;
}
catch (Exception e)
{
Debug.LogWarning($"[GeoTileImporter] Failed to parse furniture line {i} in {csvPath}: {e.Message}");
skipped++;
}
}
}
Debug.Log($"[GeoTileImporter] Furniture imported={imported}, skipped={skipped} under '{furnitureParentName}'.");
}
private void ImportEnhancedTrees(List<(string tileId, float ux, float uz, float gmin)> placements)
{
if (!importEnhancedTrees)
return;
if (!Directory.Exists(enhancedTreesDir))
{
Debug.LogWarning($"[GeoTileImporter] Enhanced trees dir not found: {enhancedTreesDir} (skipping enhanced trees).");
return;
}
var parent = GameObject.Find(enhancedTreesParentName);
if (parent == null) parent = new GameObject(enhancedTreesParentName);
if (deleteExistingEnhancedTrees)
{
for (int i = parent.transform.childCount - 1; i >= 0; i--)
DestroyImmediate(parent.transform.GetChild(i).gameObject);
}
int imported = 0, skipped = 0;
var ci = CultureInfo.InvariantCulture;
foreach (var (tileId, ux, uz, gmin) in placements)
{
string csvPath = Path.Combine(enhancedTreesDir, $"{tileId}.csv").Replace("\\", "/");
if (!File.Exists(csvPath))
{
continue; // No enhanced trees for this tile
}
var lines = File.ReadAllLines(csvPath);
if (lines.Length < 2)
continue;
// Parse header
var header = lines[0].Split(',');
int idxXLocal = -1, idxYLocal = -1, idxZGround = -1, idxHeight = -1, idxRadius = -1;
int idxCanopyR = -1, idxCanopyG = -1, idxCanopyB = -1;
for (int i = 0; i < header.Length; i++)
{
var col = header[i].Trim().ToLowerInvariant();
if (col == "x_local") idxXLocal = i;
else if (col == "y_local") idxYLocal = i;
else if (col == "z_ground") idxZGround = i;
else if (col == "height") idxHeight = i;
else if (col == "radius") idxRadius = i;
else if (col == "canopy_r") idxCanopyR = i;
else if (col == "canopy_g") idxCanopyG = i;
else if (col == "canopy_b") idxCanopyB = i;
}
if (idxXLocal < 0 || idxYLocal < 0 || idxHeight < 0)
{
Debug.LogWarning($"[GeoTileImporter] Enhanced trees CSV missing required columns: {csvPath}");
continue;
}
// Create tile container
var tileContainer = new GameObject($"Trees_{tileId}");
tileContainer.transform.SetParent(parent.transform, false);
tileContainer.transform.position = new Vector3(ux, gmin, uz);
tileContainer.isStatic = true;
for (int i = 1; i < lines.Length; i++)
{
var parts = lines[i].Split(',');
if (parts.Length <= Math.Max(Math.Max(idxXLocal, idxYLocal), idxHeight))
{
skipped++;
continue;
}
try
{
float xLocal = float.Parse(parts[idxXLocal].Trim(), ci);
float yLocal = float.Parse(parts[idxYLocal].Trim(), ci);
float zGround = idxZGround >= 0 && idxZGround < parts.Length
? float.Parse(parts[idxZGround].Trim(), ci)
: 0f;
float height = float.Parse(parts[idxHeight].Trim(), ci);
float radius = idxRadius >= 0 && idxRadius < parts.Length
? float.Parse(parts[idxRadius].Trim(), ci)
: height * 0.25f;
// Parse canopy color (default to green if missing)
int canopyR = 128, canopyG = 160, canopyB = 80;
if (idxCanopyR >= 0 && idxCanopyR < parts.Length)
canopyR = Mathf.Clamp(int.Parse(parts[idxCanopyR].Trim()), 0, 255);
if (idxCanopyG >= 0 && idxCanopyG < parts.Length)
canopyG = Mathf.Clamp(int.Parse(parts[idxCanopyG].Trim()), 0, 255);
if (idxCanopyB >= 0 && idxCanopyB < parts.Length)
canopyB = Mathf.Clamp(int.Parse(parts[idxCanopyB].Trim()), 0, 255);
Color canopyColor = new Color(canopyR / 255f, canopyG / 255f, canopyB / 255f);
GameObject treeObj;
if (treePrefab != null)
{
// Use prefab instance
treeObj = PrefabUtility.InstantiatePrefab(treePrefab) as GameObject ?? Instantiate(treePrefab);
treeObj.name = $"tree_{i}";
// Scale prefab based on height/radius
float scaleY = height / 10f; // Assuming prefab is ~10m tall
float scaleXZ = radius * 2f / 5f; // Assuming prefab canopy is ~5m wide
treeObj.transform.localScale = new Vector3(scaleXZ, scaleY, scaleXZ);
// Apply canopy color to materials
var renderers = treeObj.GetComponentsInChildren<Renderer>();
foreach (var rend in renderers)
{
foreach (var mat in rend.sharedMaterials)
{
if (mat != null)
{
// Create material instance to avoid modifying shared material
var matInst = new Material(mat);
matInst.color = canopyColor;
rend.material = matInst;
}
}
}
}
else
{
// Create procedural tree placeholder (cylinder trunk + sphere canopy)
treeObj = new GameObject($"tree_{i}");
// Trunk (cylinder)
var trunk = GameObject.CreatePrimitive(PrimitiveType.Cylinder);
trunk.name = "trunk";
trunk.transform.SetParent(treeObj.transform, false);
trunk.transform.localPosition = new Vector3(0, height * 0.25f, 0);
trunk.transform.localScale = new Vector3(0.3f, height * 0.5f, 0.3f);
var trunkRend = trunk.GetComponent<Renderer>();
if (trunkRend != null)
{
var trunkMat = new Material(Shader.Find("Universal Render Pipeline/Lit") ?? Shader.Find("Standard"));
trunkMat.color = new Color(0.4f, 0.25f, 0.15f); // Brown
trunkRend.material = trunkMat;
}
// Canopy (sphere)
var canopy = GameObject.CreatePrimitive(PrimitiveType.Sphere);
canopy.name = "canopy";
canopy.transform.SetParent(treeObj.transform, false);
canopy.transform.localPosition = new Vector3(0, height * 0.7f, 0);
canopy.transform.localScale = new Vector3(radius * 2f, height * 0.5f, radius * 2f);
var canopyRend = canopy.GetComponent<Renderer>();
if (canopyRend != null)
{
var canopyMat = new Material(Shader.Find("Universal Render Pipeline/Lit") ?? Shader.Find("Standard"));
canopyMat.color = canopyColor;
canopyRend.material = canopyMat;
}
}
treeObj.transform.SetParent(tileContainer.transform, false);
treeObj.transform.localPosition = new Vector3(xLocal, zGround - gmin, yLocal);
treeObj.isStatic = true;
imported++;
}
catch (Exception e)
{
Debug.LogWarning($"[GeoTileImporter] Failed to parse enhanced tree line {i} in {csvPath}: {e.Message}");
skipped++;
}
}
}
Debug.Log($"[GeoTileImporter] Enhanced trees imported={imported}, skipped={skipped} under '{enhancedTreesParentName}'.");
}
}

View File

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

View File

@@ -0,0 +1,933 @@
// Assets/Editor/GeoTilePrefabImporter.cs
// Creates prefab assets from geo tiles. Each prefab contains terrain + buildings + trees + furniture.
// Unlike GeoTileImporter (which creates scene objects), this importer produces reusable .prefab assets.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using UnityEditor;
using UnityEngine;
public class GeoTilePrefabImporter : EditorWindow
{
// Input paths (same as GeoTileImporter)
private string tilesCsvPath = "Assets/GeoData/tile_index.csv";
private string heightmapsDir = "Assets/GeoData/height_png16";
private string orthoDir = "Assets/GeoData/ortho_jpg";
private string buildingsDir = "Assets/GeoData/buildings_tiles";
private string treesDir = "Assets/GeoData/trees_tiles";
private string furnitureDir = "Assets/GeoData/street_furniture";
private string enhancedTreesDir = "Assets/GeoData/trees_enhanced";
// Output settings
private string prefabOutputDir = "Assets/GeoData/TilePrefabs";
private bool overwriteExisting = false;
// Terrain settings
private float tileSizeMeters = 1000f;
private int heightmapResolution = 1025;
// Component toggles
private bool applyOrthoTextures = true;
private bool includeBuildings = true;
private bool includeTrees = true;
private bool includeFurniture = false;
private bool includeEnhancedTrees = false;
// Prefabs for furniture (optional)
private GameObject lampPrefab;
private GameObject benchPrefab;
private GameObject signPrefab;
private GameObject bollardPrefab;
private GameObject defaultFurniturePrefab;
private GameObject treePrefab;
private struct TileMetadata
{
public string TileId;
public double Xmin, Ymin, Xmax, Ymax;
public double GlobalMin, GlobalMax;
public int OutRes;
}
[MenuItem("Tools/Geo Tiles/Import Tiles as Prefabs")]
public static void ShowWindow()
{
var win = GetWindow<GeoTilePrefabImporter>("Geo Tile Prefab Importer");
win.minSize = new Vector2(620, 480);
}
private void OnGUI()
{
GUILayout.Label("Input Paths", EditorStyles.boldLabel);
tilesCsvPath = EditorGUILayout.TextField("tile_index.csv", tilesCsvPath);
heightmapsDir = EditorGUILayout.TextField("height_png16 dir", heightmapsDir);
orthoDir = EditorGUILayout.TextField("ortho_jpg dir", orthoDir);
buildingsDir = EditorGUILayout.TextField("buildings_tiles dir", buildingsDir);
treesDir = EditorGUILayout.TextField("trees_tiles dir", treesDir);
furnitureDir = EditorGUILayout.TextField("street_furniture dir", furnitureDir);
enhancedTreesDir = EditorGUILayout.TextField("trees_enhanced dir", enhancedTreesDir);
GUILayout.Space(10);
GUILayout.Label("Output Settings", EditorStyles.boldLabel);
prefabOutputDir = EditorGUILayout.TextField("Prefab output dir", prefabOutputDir);
overwriteExisting = EditorGUILayout.ToggleLeft("Overwrite existing prefabs", overwriteExisting);
GUILayout.Space(10);
GUILayout.Label("Terrain Settings", EditorStyles.boldLabel);
tileSizeMeters = EditorGUILayout.FloatField("Tile size (m)", tileSizeMeters);
heightmapResolution = EditorGUILayout.IntField("Heightmap resolution", heightmapResolution);
GUILayout.Space(10);
GUILayout.Label("Include Components", EditorStyles.boldLabel);
applyOrthoTextures = EditorGUILayout.ToggleLeft("Apply ortho textures", applyOrthoTextures);
includeBuildings = EditorGUILayout.ToggleLeft("Include buildings (GLB)", includeBuildings);
includeTrees = EditorGUILayout.ToggleLeft("Include trees (GLB chunks)", includeTrees);
includeFurniture = EditorGUILayout.ToggleLeft("Include street furniture (CSV)", includeFurniture);
includeEnhancedTrees = EditorGUILayout.ToggleLeft("Include enhanced trees (CSV)", includeEnhancedTrees);
GUILayout.Space(10);
GUILayout.Label("Prefabs (optional, for furniture/trees)", EditorStyles.boldLabel);
treePrefab = (GameObject)EditorGUILayout.ObjectField("Tree Prefab", treePrefab, typeof(GameObject), false);
lampPrefab = (GameObject)EditorGUILayout.ObjectField("Lamp Prefab", lampPrefab, typeof(GameObject), false);
benchPrefab = (GameObject)EditorGUILayout.ObjectField("Bench Prefab", benchPrefab, typeof(GameObject), false);
signPrefab = (GameObject)EditorGUILayout.ObjectField("Sign Prefab", signPrefab, typeof(GameObject), false);
bollardPrefab = (GameObject)EditorGUILayout.ObjectField("Bollard Prefab", bollardPrefab, typeof(GameObject), false);
defaultFurniturePrefab = (GameObject)EditorGUILayout.ObjectField("Default Furniture", defaultFurniturePrefab, typeof(GameObject), false);
GUILayout.Space(15);
if (GUILayout.Button("Generate Prefabs"))
ImportTilesAsPrefabs();
GUILayout.Space(10);
if (GUILayout.Button("Place All Prefabs in Scene"))
PlaceAllPrefabsInScene();
EditorGUILayout.HelpBox(
"Creates one .prefab asset per tile in the manifest.\n" +
"Each prefab contains: Terrain (with TerrainData), Buildings, Trees, Furniture.\n" +
"TerrainData and TerrainLayers are saved as separate .asset files.\n" +
"IMPORTANT: Use 'Place All Prefabs in Scene' to position tiles correctly.",
MessageType.Info);
}
private void PlaceAllPrefabsInScene()
{
if (!Directory.Exists(prefabOutputDir))
{
Debug.LogError($"[GeoTilePrefabImporter] Prefab directory not found: {prefabOutputDir}");
return;
}
var prefabFiles = Directory.GetFiles(prefabOutputDir, "*.prefab");
if (prefabFiles.Length == 0)
{
Debug.LogError("[GeoTilePrefabImporter] No prefabs found. Generate prefabs first.");
return;
}
// Load all prefabs and collect metadata to compute origin
var prefabsWithMeta = new List<(GameObject prefab, GeoTileMetadata meta)>();
double originX = double.PositiveInfinity;
double originY = double.PositiveInfinity;
foreach (var path in prefabFiles)
{
string assetPath = path.Replace("\\", "/");
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(assetPath);
if (prefab == null) continue;
var meta = prefab.GetComponent<GeoTileMetadata>();
if (meta == null)
{
Debug.LogWarning($"[GeoTilePrefabImporter] Prefab missing GeoTileMetadata: {assetPath}");
continue;
}
prefabsWithMeta.Add((prefab, meta));
originX = Math.Min(originX, meta.xmin);
originY = Math.Min(originY, meta.ymin);
}
if (prefabsWithMeta.Count == 0)
{
Debug.LogError("[GeoTilePrefabImporter] No valid prefabs with metadata found.");
return;
}
// Create parent container
var parent = GameObject.Find("Geo_Tile_Prefabs");
if (parent == null) parent = new GameObject("Geo_Tile_Prefabs");
// Instantiate each prefab at correct position
int placed = 0;
foreach (var (prefab, meta) in prefabsWithMeta)
{
// Check if already placed
var existing = parent.transform.Find(meta.tileId);
if (existing != null)
{
Debug.Log($"[GeoTilePrefabImporter] Tile already in scene: {meta.tileId}");
continue;
}
var instance = PrefabUtility.InstantiatePrefab(prefab) as GameObject;
if (instance == null)
{
instance = Instantiate(prefab);
instance.name = meta.tileId;
}
instance.transform.SetParent(parent.transform, false);
instance.transform.position = meta.GetWorldPosition(originX, originY);
placed++;
Debug.Log($"[GeoTilePrefabImporter] Placed {meta.tileId} at {instance.transform.position}");
}
Debug.Log($"[GeoTilePrefabImporter] Placed {placed} tile prefabs under 'Geo_Tile_Prefabs'. Origin: ({originX}, {originY})");
}
private void ImportTilesAsPrefabs()
{
Debug.Log($"[GeoTilePrefabImporter] START\n csv={tilesCsvPath}\n output={prefabOutputDir}");
// Validate inputs
if (!File.Exists(tilesCsvPath))
{
Debug.LogError($"[GeoTilePrefabImporter] CSV not found: {tilesCsvPath}");
return;
}
if (!Directory.Exists(heightmapsDir))
{
Debug.LogError($"[GeoTilePrefabImporter] Heightmap dir not found: {heightmapsDir}");
return;
}
// Create output directories
EnsureDirectoryExists(prefabOutputDir);
EnsureDirectoryExists($"{prefabOutputDir}/TerrainData");
EnsureDirectoryExists($"{prefabOutputDir}/TerrainLayers");
// Parse CSV
var tiles = ParseTilesCsv();
if (tiles == null || tiles.Count == 0)
{
Debug.LogError("[GeoTilePrefabImporter] No valid tiles found in CSV.");
return;
}
Debug.Log($"[GeoTilePrefabImporter] Found {tiles.Count} tiles to process.");
int created = 0, skipped = 0, failed = 0;
for (int i = 0; i < tiles.Count; i++)
{
var tile = tiles[i];
EditorUtility.DisplayProgressBar(
"Creating Tile Prefabs",
$"Processing {tile.TileId} ({i + 1}/{tiles.Count})",
(float)i / tiles.Count);
string prefabPath = $"{prefabOutputDir}/{tile.TileId}.prefab";
if (File.Exists(prefabPath) && !overwriteExisting)
{
Debug.Log($"[GeoTilePrefabImporter] Skipping existing: {tile.TileId}");
skipped++;
continue;
}
try
{
if (CreateTilePrefab(tile))
created++;
else
failed++;
}
catch (Exception e)
{
Debug.LogError($"[GeoTilePrefabImporter] Failed to create prefab for {tile.TileId}: {e.Message}\n{e.StackTrace}");
failed++;
}
}
EditorUtility.ClearProgressBar();
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
Debug.Log($"[GeoTilePrefabImporter] DONE. Created={created}, Skipped={skipped}, Failed={failed}");
}
private List<TileMetadata> ParseTilesCsv()
{
var tiles = new List<TileMetadata>();
var ci = CultureInfo.InvariantCulture;
var lines = File.ReadAllLines(tilesCsvPath);
if (lines.Length < 2)
{
Debug.LogError("[GeoTilePrefabImporter] CSV has no data rows.");
return null;
}
var headerMap = BuildHeaderMap(lines[0]);
string[] required = { "tile_id", "xmin", "ymin", "xmax", "ymax", "global_min", "global_max", "out_res" };
if (!HasAll(headerMap, required))
{
Debug.LogError("[GeoTilePrefabImporter] CSV missing required columns. Required: " +
string.Join(", ", required));
return null;
}
int IDX_TILE = headerMap["tile_id"];
int IDX_XMIN = headerMap["xmin"];
int IDX_YMIN = headerMap["ymin"];
int IDX_XMAX = headerMap["xmax"];
int IDX_YMAX = headerMap["ymax"];
int IDX_GMIN = headerMap["global_min"];
int IDX_GMAX = headerMap["global_max"];
int IDX_RES = headerMap["out_res"];
for (int i = 1; i < lines.Length; i++)
{
var line = lines[i].Trim();
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));
if (parts.Length <= maxIdx)
{
Debug.LogWarning($"[GeoTilePrefabImporter] Skipping line {i + 1}: too few columns.");
continue;
}
try
{
tiles.Add(new TileMetadata
{
TileId = parts[IDX_TILE].Trim(),
Xmin = double.Parse(parts[IDX_XMIN], ci),
Ymin = double.Parse(parts[IDX_YMIN], ci),
Xmax = double.Parse(parts[IDX_XMAX], ci),
Ymax = double.Parse(parts[IDX_YMAX], ci),
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($"[GeoTilePrefabImporter] Parse error line {i + 1}: {e.Message}");
}
}
return tiles;
}
private bool CreateTilePrefab(TileMetadata tile)
{
// Validate height range
float heightRange = (float)(tile.GlobalMax - tile.GlobalMin);
if (heightRange <= 0.0001f)
{
Debug.LogWarning($"[GeoTilePrefabImporter] Tile {tile.TileId}: invalid height range. Skipping.");
return false;
}
// Load heightmap
string pngPath = Path.Combine(heightmapsDir, $"{tile.TileId}.png").Replace("\\", "/");
if (!File.Exists(pngPath))
{
Debug.LogError($"[GeoTilePrefabImporter] Missing heightmap for {tile.TileId}: {pngPath}");
return false;
}
EnsureHeightmapImportSettings(pngPath);
var heightmapTex = AssetDatabase.LoadAssetAtPath<Texture2D>(pngPath);
if (heightmapTex == null)
{
Debug.LogError($"[GeoTilePrefabImporter] Could not load heightmap: {pngPath}");
return false;
}
// Create TerrainData
var terrainData = new TerrainData
{
heightmapResolution = heightmapResolution,
size = new Vector3(tileSizeMeters, heightRange, tileSizeMeters)
};
// Read heightmap pixels
int w = heightmapTex.width;
int h = heightmapTex.height;
var heights = new float[h, w];
bool usedU16 = false;
try
{
var raw = heightmapTex.GetPixelData<ushort>(0);
if (raw.Length == w * h)
{
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++)
heights[y, x] = raw[y * w + x] / 65535f;
usedU16 = true;
}
}
catch { }
if (!usedU16)
{
var pixels = heightmapTex.GetPixels();
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++)
heights[y, x] = pixels[y * w + x].r;
}
terrainData.SetHeights(0, 0, heights);
// Save TerrainData as asset (MUST be done before creating prefab)
string terrainDataPath = $"{prefabOutputDir}/TerrainData/{tile.TileId}_TerrainData.asset";
if (File.Exists(terrainDataPath))
AssetDatabase.DeleteAsset(terrainDataPath);
AssetDatabase.CreateAsset(terrainData, terrainDataPath);
// Apply ortho texture if enabled
if (applyOrthoTextures)
{
ApplyOrthoTexture(terrainData, tile.TileId);
}
// Create root GameObject with Terrain (ensures prefab contains the terrain asset)
var root = Terrain.CreateTerrainGameObject(terrainData);
root.name = tile.TileId;
var terrain = root.GetComponent<Terrain>();
terrain.drawInstanced = true;
// Store metadata as component for later use
var metadata = root.AddComponent<GeoTileMetadata>();
metadata.tileId = tile.TileId;
metadata.xmin = tile.Xmin;
metadata.ymin = tile.Ymin;
metadata.globalMin = tile.GlobalMin;
metadata.globalMax = tile.GlobalMax;
// Add child components
if (includeBuildings)
AddBuildings(root, tile);
if (includeTrees)
AddTrees(root, tile);
if (includeFurniture)
AddFurniture(root, tile);
if (includeEnhancedTrees)
AddEnhancedTrees(root, tile);
// Save as prefab
string prefabPath = $"{prefabOutputDir}/{tile.TileId}.prefab";
if (File.Exists(prefabPath))
AssetDatabase.DeleteAsset(prefabPath);
PrefabUtility.SaveAsPrefabAsset(root, prefabPath);
Debug.Log($"[GeoTilePrefabImporter] Created prefab: {prefabPath}");
// Cleanup temporary scene object
DestroyImmediate(root);
return true;
}
private void ApplyOrthoTexture(TerrainData terrainData, string tileId)
{
string orthoPath = Path.Combine(orthoDir, $"{tileId}.jpg").Replace("\\", "/");
if (!File.Exists(orthoPath))
{
Debug.LogWarning($"[GeoTilePrefabImporter] Ortho texture missing for {tileId}: {orthoPath}");
return;
}
EnsureOrthoImportSettings(orthoPath);
var orthoTex = AssetDatabase.LoadAssetAtPath<Texture2D>(orthoPath);
if (orthoTex == null)
{
Debug.LogWarning($"[GeoTilePrefabImporter] Could not load ortho texture: {orthoPath}");
return;
}
// Create and save TerrainLayer as asset (required for prefab serialization)
var layer = new TerrainLayer
{
diffuseTexture = orthoTex,
tileSize = new Vector2(tileSizeMeters, tileSizeMeters),
tileOffset = Vector2.zero
};
string layerPath = $"{prefabOutputDir}/TerrainLayers/{tileId}_Layer.asset";
if (File.Exists(layerPath))
AssetDatabase.DeleteAsset(layerPath);
AssetDatabase.CreateAsset(layer, layerPath);
terrainData.terrainLayers = new[] { layer };
// Setup alphamap (single layer, full coverage)
terrainData.alphamapResolution = 16;
var alpha = new float[16, 16, 1];
for (int y = 0; y < 16; y++)
for (int x = 0; x < 16; x++)
alpha[y, x, 0] = 1f;
terrainData.SetAlphamaps(0, 0, alpha);
}
private void AddBuildings(GameObject root, TileMetadata tile)
{
string glbPath = Path.Combine(buildingsDir, $"{tile.TileId}.glb").Replace("\\", "/");
if (!File.Exists(glbPath))
return;
var buildingPrefab = AssetDatabase.LoadAssetAtPath<GameObject>(glbPath);
if (buildingPrefab == null)
{
Debug.LogWarning($"[GeoTilePrefabImporter] Could not load building GLB: {glbPath}");
return;
}
// Building GLB vertices have absolute Z (elevation) from CityGML.
// Since prefab root will be at Y=gmin when placed, offset buildings by -gmin
// so building world Y = gmin + (-gmin) + GLB_Y = GLB_Y (correct absolute elevation)
var instance = PrefabUtility.InstantiatePrefab(buildingPrefab) as GameObject;
if (instance == null)
instance = Instantiate(buildingPrefab);
instance.name = "Buildings";
instance.transform.SetParent(root.transform, false);
instance.transform.localPosition = new Vector3(0f, -(float)tile.GlobalMin, 0f);
instance.transform.localRotation = Quaternion.Euler(0f, 180f, 0f);
instance.isStatic = true;
}
private void AddTrees(GameObject root, TileMetadata tile)
{
if (!Directory.Exists(treesDir))
return;
// Look for chunk files or single file
var chunkFiles = Directory.GetFiles(treesDir, $"{tile.TileId}_*.glb");
if (chunkFiles.Length == 0)
{
string singlePath = Path.Combine(treesDir, $"{tile.TileId}.glb").Replace("\\", "/");
if (File.Exists(singlePath))
chunkFiles = new[] { singlePath };
else
return;
}
// Tree GLB vertices use absolute elevation (z_ground from DGM).
// Since prefab root will be at Y=gmin when placed, offset trees by -gmin
// so tree world Y = gmin + (-gmin) + GLB_Y = GLB_Y (correct absolute elevation)
var treesContainer = new GameObject("Trees");
treesContainer.transform.SetParent(root.transform, false);
treesContainer.transform.localPosition = new Vector3(0f, -(float)tile.GlobalMin, 0f);
treesContainer.transform.localRotation = Quaternion.Euler(0f, 180f, 0f);
treesContainer.isStatic = true;
foreach (var chunkPath in chunkFiles)
{
string assetPath = chunkPath.Replace("\\", "/");
var chunkPrefab = AssetDatabase.LoadAssetAtPath<GameObject>(assetPath);
if (chunkPrefab == null)
{
Debug.LogWarning($"[GeoTilePrefabImporter] Could not load tree chunk: {assetPath}");
continue;
}
var instance = PrefabUtility.InstantiatePrefab(chunkPrefab) as GameObject;
if (instance == null)
instance = Instantiate(chunkPrefab);
instance.name = Path.GetFileNameWithoutExtension(chunkPath);
instance.transform.SetParent(treesContainer.transform, false);
instance.transform.localPosition = Vector3.zero;
instance.isStatic = true;
}
}
private void AddFurniture(GameObject root, TileMetadata tile)
{
string csvPath = Path.Combine(furnitureDir, $"{tile.TileId}.csv").Replace("\\", "/");
if (!File.Exists(csvPath))
return;
var lines = File.ReadAllLines(csvPath);
if (lines.Length < 2)
return;
var ci = CultureInfo.InvariantCulture;
var header = lines[0].Split(',');
int idxXLocal = -1, idxYLocal = -1, idxZGround = -1, idxHeight = -1, idxType = -1;
for (int i = 0; i < header.Length; i++)
{
var col = header[i].Trim().ToLowerInvariant();
if (col == "x_local") idxXLocal = i;
else if (col == "y_local") idxYLocal = i;
else if (col == "z_ground") idxZGround = i;
else if (col == "height") idxHeight = i;
else if (col == "type") idxType = i;
}
if (idxXLocal < 0 || idxYLocal < 0 || idxType < 0)
{
Debug.LogWarning($"[GeoTilePrefabImporter] Furniture CSV missing required columns: {csvPath}");
return;
}
var furnitureContainer = new GameObject("Furniture");
furnitureContainer.transform.SetParent(root.transform, false);
furnitureContainer.transform.localPosition = Vector3.zero;
furnitureContainer.isStatic = true;
float gmin = (float)tile.GlobalMin;
for (int i = 1; i < lines.Length; i++)
{
var parts = lines[i].Split(',');
if (parts.Length <= Math.Max(Math.Max(idxXLocal, idxYLocal), idxType))
continue;
try
{
float xLocal = float.Parse(parts[idxXLocal].Trim(), ci);
float yLocal = float.Parse(parts[idxYLocal].Trim(), ci);
float zGround = idxZGround >= 0 && idxZGround < parts.Length
? float.Parse(parts[idxZGround].Trim(), ci) : 0f;
float height = idxHeight >= 0 && idxHeight < parts.Length
? float.Parse(parts[idxHeight].Trim(), ci) : 1f;
string furnitureType = parts[idxType].Trim().ToLowerInvariant();
GameObject obj = CreateFurnitureObject(furnitureType, height, i);
obj.transform.SetParent(furnitureContainer.transform, false);
obj.transform.localPosition = new Vector3(xLocal, zGround - gmin, yLocal);
obj.isStatic = true;
}
catch (Exception e)
{
Debug.LogWarning($"[GeoTilePrefabImporter] Failed to parse furniture line {i}: {e.Message}");
}
}
}
private GameObject CreateFurnitureObject(string type, float height, int index)
{
GameObject prefabToUse = null;
Vector3 fallbackScale = Vector3.one;
switch (type)
{
case "lamp":
prefabToUse = lampPrefab;
fallbackScale = new Vector3(0.3f, height, 0.3f);
break;
case "bench":
prefabToUse = benchPrefab;
fallbackScale = new Vector3(1.5f, height, 0.5f);
break;
case "sign":
prefabToUse = signPrefab;
fallbackScale = new Vector3(0.1f, height, 0.5f);
break;
case "bollard":
prefabToUse = bollardPrefab;
fallbackScale = new Vector3(0.2f, height, 0.2f);
break;
default:
prefabToUse = defaultFurniturePrefab;
fallbackScale = new Vector3(0.5f, height, 0.5f);
break;
}
GameObject obj;
if (prefabToUse != null)
{
obj = PrefabUtility.InstantiatePrefab(prefabToUse) as GameObject ?? Instantiate(prefabToUse);
obj.name = $"{type}_{index}";
obj.transform.localScale = Vector3.one * height;
}
else
{
obj = GameObject.CreatePrimitive(PrimitiveType.Cube);
obj.name = $"{type}_{index}";
obj.transform.localScale = fallbackScale;
}
return obj;
}
private void AddEnhancedTrees(GameObject root, TileMetadata tile)
{
string csvPath = Path.Combine(enhancedTreesDir, $"{tile.TileId}.csv").Replace("\\", "/");
if (!File.Exists(csvPath))
return;
var lines = File.ReadAllLines(csvPath);
if (lines.Length < 2)
return;
var ci = CultureInfo.InvariantCulture;
var header = lines[0].Split(',');
int idxXLocal = -1, idxYLocal = -1, idxZGround = -1, idxHeight = -1, idxRadius = -1;
int idxCanopyR = -1, idxCanopyG = -1, idxCanopyB = -1;
for (int i = 0; i < header.Length; i++)
{
var col = header[i].Trim().ToLowerInvariant();
if (col == "x_local") idxXLocal = i;
else if (col == "y_local") idxYLocal = i;
else if (col == "z_ground") idxZGround = i;
else if (col == "height") idxHeight = i;
else if (col == "radius") idxRadius = i;
else if (col == "canopy_r") idxCanopyR = i;
else if (col == "canopy_g") idxCanopyG = i;
else if (col == "canopy_b") idxCanopyB = i;
}
if (idxXLocal < 0 || idxYLocal < 0 || idxHeight < 0)
{
Debug.LogWarning($"[GeoTilePrefabImporter] Enhanced trees CSV missing required columns: {csvPath}");
return;
}
var treesContainer = new GameObject("EnhancedTrees");
treesContainer.transform.SetParent(root.transform, false);
treesContainer.transform.localPosition = Vector3.zero;
treesContainer.isStatic = true;
float gmin = (float)tile.GlobalMin;
for (int i = 1; i < lines.Length; i++)
{
var parts = lines[i].Split(',');
if (parts.Length <= Math.Max(Math.Max(idxXLocal, idxYLocal), idxHeight))
continue;
try
{
float xLocal = float.Parse(parts[idxXLocal].Trim(), ci);
float yLocal = float.Parse(parts[idxYLocal].Trim(), ci);
float zGround = idxZGround >= 0 && idxZGround < parts.Length
? float.Parse(parts[idxZGround].Trim(), ci) : 0f;
float height = float.Parse(parts[idxHeight].Trim(), ci);
float radius = idxRadius >= 0 && idxRadius < parts.Length
? float.Parse(parts[idxRadius].Trim(), ci) : height * 0.25f;
int canopyR = 128, canopyG = 160, canopyB = 80;
if (idxCanopyR >= 0 && idxCanopyR < parts.Length)
canopyR = Mathf.Clamp(int.Parse(parts[idxCanopyR].Trim()), 0, 255);
if (idxCanopyG >= 0 && idxCanopyG < parts.Length)
canopyG = Mathf.Clamp(int.Parse(parts[idxCanopyG].Trim()), 0, 255);
if (idxCanopyB >= 0 && idxCanopyB < parts.Length)
canopyB = Mathf.Clamp(int.Parse(parts[idxCanopyB].Trim()), 0, 255);
Color canopyColor = new Color(canopyR / 255f, canopyG / 255f, canopyB / 255f);
GameObject treeObj = CreateEnhancedTree(height, radius, canopyColor, i);
treeObj.transform.SetParent(treesContainer.transform, false);
treeObj.transform.localPosition = new Vector3(xLocal, zGround - gmin, yLocal);
treeObj.isStatic = true;
}
catch (Exception e)
{
Debug.LogWarning($"[GeoTilePrefabImporter] Failed to parse enhanced tree line {i}: {e.Message}");
}
}
}
private GameObject CreateEnhancedTree(float height, float radius, Color canopyColor, int index)
{
if (treePrefab != null)
{
var treeObj = PrefabUtility.InstantiatePrefab(treePrefab) as GameObject ?? Instantiate(treePrefab);
treeObj.name = $"tree_{index}";
float scaleY = height / 10f;
float scaleXZ = radius * 2f / 5f;
treeObj.transform.localScale = new Vector3(scaleXZ, scaleY, scaleXZ);
var renderers = treeObj.GetComponentsInChildren<Renderer>();
foreach (var rend in renderers)
{
foreach (var mat in rend.sharedMaterials)
{
if (mat != null)
{
var matInst = new Material(mat);
matInst.color = canopyColor;
rend.material = matInst;
}
}
}
return treeObj;
}
// Create procedural placeholder
var tree = new GameObject($"tree_{index}");
var trunk = GameObject.CreatePrimitive(PrimitiveType.Cylinder);
trunk.name = "trunk";
trunk.transform.SetParent(tree.transform, false);
trunk.transform.localPosition = new Vector3(0, height * 0.25f, 0);
trunk.transform.localScale = new Vector3(0.3f, height * 0.5f, 0.3f);
var trunkRend = trunk.GetComponent<Renderer>();
if (trunkRend != null)
{
var trunkMat = new Material(Shader.Find("Universal Render Pipeline/Lit") ?? Shader.Find("Standard"));
trunkMat.color = new Color(0.4f, 0.25f, 0.15f);
trunkRend.material = trunkMat;
}
var canopy = GameObject.CreatePrimitive(PrimitiveType.Sphere);
canopy.name = "canopy";
canopy.transform.SetParent(tree.transform, false);
canopy.transform.localPosition = new Vector3(0, height * 0.7f, 0);
canopy.transform.localScale = new Vector3(radius * 2f, height * 0.5f, radius * 2f);
var canopyRend = canopy.GetComponent<Renderer>();
if (canopyRend != null)
{
var canopyMat = new Material(Shader.Find("Universal Render Pipeline/Lit") ?? Shader.Find("Standard"));
canopyMat.color = canopyColor;
canopyRend.material = canopyMat;
}
return tree;
}
#region Helper Methods
private static void EnsureDirectoryExists(string path)
{
if (!Directory.Exists(path))
{
Directory.CreateDirectory(path);
AssetDatabase.Refresh();
}
}
private static void EnsureHeightmapImportSettings(string assetPath)
{
var ti = (TextureImporter)AssetImporter.GetAtPath(assetPath);
if (ti == null) return;
bool changed = false;
if (!ti.isReadable) { ti.isReadable = true; changed = true; }
if (ti.sRGBTexture) { ti.sRGBTexture = false; changed = true; }
if (ti.textureCompression != TextureImporterCompression.Uncompressed)
{
ti.textureCompression = TextureImporterCompression.Uncompressed;
changed = true;
}
if (ti.npotScale != TextureImporterNPOTScale.None)
{
ti.npotScale = TextureImporterNPOTScale.None;
changed = true;
}
if (ti.textureType != TextureImporterType.SingleChannel)
{
ti.textureType = TextureImporterType.SingleChannel;
changed = true;
}
var ps = ti.GetDefaultPlatformTextureSettings();
if (ps.format != TextureImporterFormat.R16)
{
ps.format = TextureImporterFormat.R16;
ti.SetPlatformTextureSettings(ps);
changed = true;
}
changed |= EnsurePlatformR16(ti, "Standalone");
changed |= EnsurePlatformR16(ti, "Android");
changed |= EnsurePlatformR16(ti, "iPhone");
if (changed) ti.SaveAndReimport();
}
private static bool EnsurePlatformR16(TextureImporter ti, string platform)
{
var ps = ti.GetPlatformTextureSettings(platform);
bool changed = false;
if (ps.name != platform)
{
ps = new TextureImporterPlatformSettings
{
name = platform,
overridden = true,
format = TextureImporterFormat.R16,
textureCompression = TextureImporterCompression.Uncompressed,
};
ti.SetPlatformTextureSettings(ps);
return true;
}
if (!ps.overridden) { ps.overridden = true; changed = true; }
if (ps.format != TextureImporterFormat.R16) { ps.format = TextureImporterFormat.R16; changed = true; }
if (ps.textureCompression != TextureImporterCompression.Uncompressed)
{
ps.textureCompression = TextureImporterCompression.Uncompressed;
changed = true;
}
if (changed) ti.SetPlatformTextureSettings(ps);
return changed;
}
private static void EnsureOrthoImportSettings(string assetPath)
{
var ti = (TextureImporter)AssetImporter.GetAtPath(assetPath);
if (ti == null) return;
bool changed = false;
if (!ti.isReadable) { ti.isReadable = true; changed = true; }
if (!ti.sRGBTexture) { ti.sRGBTexture = true; changed = true; }
if (ti.npotScale != TextureImporterNPOTScale.None) { ti.npotScale = TextureImporterNPOTScale.None; changed = true; }
if (ti.wrapMode != TextureWrapMode.Clamp) { ti.wrapMode = TextureWrapMode.Clamp; changed = true; }
if (ti.textureCompression != TextureImporterCompression.Uncompressed)
{
ti.textureCompression = TextureImporterCompression.Uncompressed;
changed = true;
}
if (changed) ti.SaveAndReimport();
}
private static string NormalizeHeader(string s)
=> (s ?? "").Trim().ToLowerInvariant();
private static Dictionary<string, int> BuildHeaderMap(string headerLine)
{
var map = new Dictionary<string, int>();
var cols = headerLine.Split(',');
for (int i = 0; i < cols.Length; i++)
{
var key = NormalizeHeader(cols[i]);
if (string.IsNullOrEmpty(key)) continue;
if (!map.ContainsKey(key))
map[key] = i;
}
return map;
}
private static bool HasAll(Dictionary<string, int> map, params string[] required)
{
foreach (var r in required)
if (!map.ContainsKey(NormalizeHeader(r)))
return false;
return true;
}
#endregion
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3724238c12c2fccb284b3651c756ff19