diff --git a/README.md b/README.md index ad6ac9f9..8fee5dfb 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,11 @@ If `--connection` is not specified and none of the deprecated database parameter -q, --query (Default: '') Query parameter + --theme (Default: '') 3DCityDB v5+ appearance theme to + select textures from (filters the surface_data + via appearance). Empty = no theme filter + (lowest surface_data_id wins). + --copyright (Default: '') glTF asset copyright --default_color (Default: #FFFFFF) Default color, in RGB(A) order diff --git a/dataprocessing/dataprocessing_citygml.md b/dataprocessing/dataprocessing_citygml.md index 1a7cd083..dff86d41 100644 --- a/dataprocessing/dataprocessing_citygml.md +++ b/dataprocessing/dataprocessing_citygml.md @@ -211,6 +211,29 @@ Priority rule during export: - If textures and shaders are both available in the same tile, textures are used. - Tiles without texture data keep existing shader/default behavior. +### Selecting an appearance theme + +One geometry can carry textures from several appearance themes (for example an aerial photo theme next to +thematic analysis layers). Themes live in `citydb.appearance.theme` and are linked to the textures through +`citydb.appear_to_surface_data`. List the available themes with: + +```sql +SELECT DISTINCT theme FROM citydb.appearance; +``` + +Use the `--theme` option to export the textures of one theme: + +```bash +pg2b3dm --connection "Host=localhost;Port=5440;Username=postgres;Database=postgres;CommandTimeOut=0" -t citydb.geometry_data -c geometry --theme +``` + +Notes: + +- Without `--theme` the export is unchanged: no theme filter is applied and, for a geometry with multiple + mappings, the lowest `surface_data_id` wins. +- Run pg2b3dm once per theme (into separate output folders) to publish one tileset per theme from the same + geometry, instead of duplicating the geometry per theme. + Sample World Port Center Rotterdam: image diff --git a/src/b3dm.tileset/CityDbRepository.cs b/src/b3dm.tileset/CityDbRepository.cs index 0a40eb8d..5c8a6db6 100644 --- a/src/b3dm.tileset/CityDbRepository.cs +++ b/src/b3dm.tileset/CityDbRepository.cs @@ -46,6 +46,20 @@ LIMIT 1 return ExecuteBooleanScalar(conn, sql); } + public static bool HasTheme(NpgsqlConnection conn, string theme) + { + const string sql = @" +SELECT EXISTS ( + SELECT 1 + FROM citydb.appearance + WHERE theme = @theme +)"; + + return ExecuteBooleanScalar(conn, sql, cmd => { + cmd.Parameters.AddWithValue("theme", theme); + }); + } + public static bool HasColumn(NpgsqlConnection conn, string tableName, string columnName) { var schemaAndTable = GetSchemaAndTable(tableName); diff --git a/src/b3dm.tileset/GeometryRepository.cs b/src/b3dm.tileset/GeometryRepository.cs index b21d4789..e570355f 100644 --- a/src/b3dm.tileset/GeometryRepository.cs +++ b/src/b3dm.tileset/GeometryRepository.cs @@ -38,7 +38,7 @@ public static double[] GetGeometriesBoundingBox(NpgsqlConnection conn, string ge return result; } - public static List GetGeometrySubset(NpgsqlConnection conn, string geometry_table, string geometry_column, double[] bbox, int source_epsg, int target_srs, string shaderColumn = "", string attributesColumns = "", string query = "", string radiusColumn = "", bool keepProjection = false, string idColumn = "", bool includeTextures = false) + public static List GetGeometrySubset(NpgsqlConnection conn, string geometry_table, string geometry_column, double[] bbox, int source_epsg, int target_srs, string shaderColumn = "", string attributesColumns = "", string query = "", string radiusColumn = "", bool keepProjection = false, string idColumn = "", bool includeTextures = false, string theme = "") { var sqlselect = GetSqlSelect(geometry_column, shaderColumn, attributesColumns, radiusColumn, target_srs, idColumn); var sqlFrom = "FROM " + geometry_table; @@ -51,7 +51,7 @@ public static List GetGeometrySubset(NpgsqlConnection conn, stri var geometries = GetGeometries(conn, shaderColumn, attributesColumns, sql, radiusColumn, idColumn); if (includeTextures) { - EnrichWithTextures(conn, geometries); + EnrichWithTextures(conn, geometries, theme); } return geometries; } @@ -181,7 +181,7 @@ public static List GetGeometries(NpgsqlConnection conn, string s return geometries; } - private static void EnrichWithTextures(NpgsqlConnection conn, List geometries) + private static void EnrichWithTextures(NpgsqlConnection conn, List geometries, string theme = "") { var sourceIds = geometries .Where(g => g.SourceId.HasValue) @@ -198,7 +198,21 @@ private static void EnrichWithTextures(NpgsqlConnection conn, List g.SourceId!.Value) .ToDictionary(g => g.Key, g => g.First()); - const string sql = @" + // Optional appearance-theme filter: without it the query keeps its original shape + // (lowest surface_data_id wins per geometry); with a theme the surface_data is + // constrained to that appearance theme. Uses an EXISTS semi-join, not a JOIN, so a + // surface_data referenced by several appearances sharing the theme yields one row, + // not N duplicate textures. + var hasTheme = !string.IsNullOrWhiteSpace(theme); + var themeFilter = hasTheme ? @" + AND EXISTS ( + SELECT 1 + FROM citydb.appear_to_surface_data a2s + JOIN citydb.appearance ap ON ap.id = a2s.appearance_id + WHERE a2s.surface_data_id = sd.id + AND ap.theme = @theme)" : string.Empty; + + var sql = $@" SELECT g.id, g.geometry_properties::text AS geometry_properties, sdm.texture_mapping::text AS texture_mapping, @@ -213,12 +227,15 @@ JOIN citydb.tex_image ti ON ti.id = sd.tex_image_id WHERE g.id = ANY(@ids) AND sdm.texture_mapping IS NOT NULL - AND ti.image_data IS NOT NULL + AND ti.image_data IS NOT NULL{themeFilter} ORDER BY g.id, sdm.surface_data_id"; conn.Open(); var cmd = new NpgsqlCommand(sql, conn); cmd.Parameters.AddWithValue("ids", sourceIds); + if (hasTheme) { + cmd.Parameters.AddWithValue("theme", theme); + } var reader = cmd.ExecuteReader(); while (reader.Read()) { diff --git a/src/b3dm.tileset/OctreeTiler.cs b/src/b3dm.tileset/OctreeTiler.cs index 86cd19b4..114d1ac6 100644 --- a/src/b3dm.tileset/OctreeTiler.cs +++ b/src/b3dm.tileset/OctreeTiler.cs @@ -80,7 +80,7 @@ public List GenerateTiles3D(BoundingBox3D bbox, int level, Tile3D tile, } var bbox1 = new double[] { bbox.XMin, bbox.YMin, bbox.XMax, bbox.YMax, bbox.ZMin, bbox.ZMax }; - var geometries = GeometryRepository.GetGeometrySubset(conn, inputTable.TableName, inputTable.GeometryColumn, bbox1, inputTable.EPSGCode, target_srs, inputTable.ShadersColumn, inputTable.AttributeColumns, where, inputTable.RadiusColumn, tilingSettings.KeepProjection, inputTable.IdColumn, inputTable.UseTexturePipeline); + var geometries = GeometryRepository.GetGeometrySubset(conn, inputTable.TableName, inputTable.GeometryColumn, bbox1, inputTable.EPSGCode, target_srs, inputTable.ShadersColumn, inputTable.AttributeColumns, where, inputTable.RadiusColumn, tilingSettings.KeepProjection, inputTable.IdColumn, inputTable.UseTexturePipeline, inputTable.Theme); if (geometries.Count > 0) { diff --git a/src/b3dm.tileset/QuadtreeTiler.cs b/src/b3dm.tileset/QuadtreeTiler.cs index 4e734442..aed26b1b 100644 --- a/src/b3dm.tileset/QuadtreeTiler.cs +++ b/src/b3dm.tileset/QuadtreeTiler.cs @@ -98,7 +98,7 @@ public List GenerateTiles(BoundingBox bbox, Tile tile, List tiles, i byte[] bytes = null; - var geometries = GeometryRepository.GetGeometrySubset(conn, inputTable.TableName, inputTable.GeometryColumn, tile.BoundingBox, source_epsg, target_srs, inputTable.ShadersColumn, inputTable.AttributeColumns, where, inputTable.RadiusColumn, keepProjection, inputTable.IdColumn, inputTable.UseTexturePipeline); + var geometries = GeometryRepository.GetGeometrySubset(conn, inputTable.TableName, inputTable.GeometryColumn, tile.BoundingBox, source_epsg, target_srs, inputTable.ShadersColumn, inputTable.AttributeColumns, where, inputTable.RadiusColumn, keepProjection, inputTable.IdColumn, inputTable.UseTexturePipeline, inputTable.Theme); // var scale = new double[] { 1, 1, 1 }; if (geometries.Count > 0) { diff --git a/src/b3dm.tileset/settings/InputTable.cs b/src/b3dm.tileset/settings/InputTable.cs index 1cd3de52..e097927f 100644 --- a/src/b3dm.tileset/settings/InputTable.cs +++ b/src/b3dm.tileset/settings/InputTable.cs @@ -8,6 +8,7 @@ public class InputTable public string RadiusColumn { get; set; } = string.Empty; public string ShadersColumn { get; set; } = string.Empty; public string Query { get; set; } = string.Empty; + public string Theme { get; set; } = string.Empty; public string LodColumn { get; set; } = string.Empty; public string AttributeColumns { get; set; } = string.Empty; diff --git a/src/pg2b3dm.database.tests/UnitTest1.cs b/src/pg2b3dm.database.tests/UnitTest1.cs index ff61d81c..0670d44c 100644 --- a/src/pg2b3dm.database.tests/UnitTest1.cs +++ b/src/pg2b3dm.database.tests/UnitTest1.cs @@ -13,6 +13,29 @@ public class UnitTest1 { private PostgreSqlContainer _containerPostgres; + // The two 1x1 textures of the appearance fixture, so a theme test can name what it expects. + private static readonly byte[] RedPng = Convert.FromBase64String("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC"); + private static readonly byte[] BluePng = Convert.FromBase64String("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGNgYPgPAAEDAQAIicLsAAAAAElFTkSuQmCC"); + + private static GeometryRecord GetThemedGeometry(NpgsqlConnection conn, string theme) + { + var geometries = GeometryRepository.GetGeometrySubset( + conn, + "citydb.geometry_data", + "geometry", + new double[] { 29, 29, 32, 32 }, + 4326, + 4326, + keepProjection: true, + idColumn: "id", + includeTextures: true, + theme: theme + ); + + Assert.That(geometries.Count, Is.EqualTo(1)); + return geometries[0]; + } + [SetUp] public async Task Setup() { @@ -31,6 +54,8 @@ public async Task Setup() await _containerPostgres.ExecScriptAsync(initScript4); var initScript5 = File.ReadAllText("./postgres-db/5_create_3dcitydb_texture_tables.sql"); await _containerPostgres.ExecScriptAsync(initScript5); + var initScript6 = File.ReadAllText("./postgres-db/6_create_3dcitydb_appearance_tables.sql"); + await _containerPostgres.ExecScriptAsync(initScript6); } [TearDown] @@ -98,6 +123,18 @@ public void Detect3dCityDbV5AndTextures() Assert.That(hasIdColumn, Is.True); } + // A theme nobody wrote bakes an untextured tileset that otherwise looks fine, so the caller + // needs to hear about the typo before tiling, not after opening the viewer. + [Test] + public void DetectWhetherThemeExists() + { + var connectionString = _containerPostgres.GetConnectionString(); + var conn = new NpgsqlConnection(connectionString); + + Assert.That(CityDbRepository.HasTheme(conn, "winter"), Is.True); + Assert.That(CityDbRepository.HasTheme(conn, "wintr"), Is.False); + } + [Test] public void TextureCheckIsPerTile() { @@ -157,6 +194,40 @@ public void TextureEnrichmentCollectsMultipleMappingsPerGeometry() Assert.That(texturedGeometries[0].Textures.Count, Is.EqualTo(2)); } + // Geometry 4 carries the same surface twice: surface_data 4 (red, theme 'summer') and + // surface_data 5 (blue, theme 'winter'). Unfiltered, both mappings come back and the renderer + // takes the first per objectId, which is the lower surface_data_id - the red one. Selecting + // 'winter' must narrow that to the blue texture instead. + [Test] + public void ThemeFilterSelectsTexturesOfSelectedTheme() + { + var connectionString = _containerPostgres.GetConnectionString(); + var conn = new NpgsqlConnection(connectionString); + + var withoutTheme = GetThemedGeometry(conn, string.Empty); + var blankTheme = GetThemedGeometry(conn, " "); + var winter = GetThemedGeometry(conn, "winter"); + + Assert.That(withoutTheme.Textures.Count, Is.EqualTo(2)); + Assert.That(blankTheme.Textures.Count, Is.EqualTo(2)); + Assert.That(winter.Textures.Count, Is.EqualTo(1)); + Assert.That(winter.Textures[0].TextureImageData, Is.EqualTo(BluePng)); + } + + // surface_data 4 is referenced by two appearances that both carry theme 'summer'. The filter + // is a semi-join, so that must still be one texture - a JOIN would duplicate it. + [Test] + public void ThemeFilterDeduplicatesSurfaceDataSharedByAppearances() + { + var connectionString = _containerPostgres.GetConnectionString(); + var conn = new NpgsqlConnection(connectionString); + + var summer = GetThemedGeometry(conn, "summer"); + + Assert.That(summer.Textures.Count, Is.EqualTo(1)); + Assert.That(summer.Textures[0].TextureImageData, Is.EqualTo(RedPng)); + } + [Test] public void TestArvieuxBuildingsOctreeKeepProjection() { diff --git a/src/pg2b3dm.database.tests/pg2b3dm.database.tests.csproj b/src/pg2b3dm.database.tests/pg2b3dm.database.tests.csproj index b0b066a6..bba46454 100644 --- a/src/pg2b3dm.database.tests/pg2b3dm.database.tests.csproj +++ b/src/pg2b3dm.database.tests/pg2b3dm.database.tests.csproj @@ -50,6 +50,9 @@ PreserveNewest + + PreserveNewest + PreserveNewest diff --git a/src/pg2b3dm.database.tests/postgres-db/6_create_3dcitydb_appearance_tables.sql b/src/pg2b3dm.database.tests/postgres-db/6_create_3dcitydb_appearance_tables.sql new file mode 100644 index 00000000..d94b87d0 --- /dev/null +++ b/src/pg2b3dm.database.tests/postgres-db/6_create_3dcitydb_appearance_tables.sql @@ -0,0 +1,81 @@ +-- 3DCityDB v5 appearance graph, used by the --theme filter. +-- Additive on top of 5_create_3dcitydb_texture_tables.sql: a fourth geometry carrying two +-- surface_data (one per theme) so a theme selection can be told apart from "lowest +-- surface_data_id wins". Kept outside the bounding boxes the other texture tests query. + +CREATE TABLE IF NOT EXISTS citydb.appearance +( + id BIGINT PRIMARY KEY, + objectid TEXT, + theme TEXT +); + +CREATE TABLE IF NOT EXISTS citydb.appear_to_surface_data +( + id BIGSERIAL PRIMARY KEY, + appearance_id BIGINT NOT NULL, + surface_data_id BIGINT +); + +INSERT INTO citydb.geometry_data (id, geometry, geometry_properties) +VALUES + ( + 4, + 'SRID=4326;POLYGON Z ((30 30 0, 31 30 0, 30 31 0, 30 30 0))'::geometry, + '{"type": 6, "children": [{"type": 3, "objectId": "surface_4", "geometryIndex": 0}]}'::jsonb + ) +ON CONFLICT (id) DO NOTHING; + +-- Two 1x1 PNGs with distinct pixels (red / blue), so a test can tell which theme was baked. +INSERT INTO citydb.tex_image (id, image_uri, mime_type, image_data) +VALUES + ( + 3, + 'red.png', + 'image/png', + decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC', 'base64') + ), + ( + 4, + 'blue.png', + 'image/png', + decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGNgYPgPAAEDAQAIicLsAAAAAElFTkSuQmCC', 'base64') + ) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO citydb.surface_data (id, tex_image_id) +VALUES + (4, 3), + (5, 4) +ON CONFLICT (id) DO NOTHING; + +-- Same surface of the same geometry, textured twice - the themes are what tell them apart. +INSERT INTO citydb.surface_data_mapping (geometry_data_id, surface_data_id, texture_mapping) +VALUES + ( + 4, + 4, + '{"surface_4":[[[0.0,0.0],[1.0,0.0],[0.0,1.0],[0.0,0.0]]]}'::jsonb + ), + ( + 4, + 5, + '{"surface_4":[[[0.0,0.0],[1.0,0.0],[0.0,1.0],[0.0,0.0]]]}'::jsonb + ) +ON CONFLICT DO NOTHING; + +-- 'summer' is carried by two appearances that both reference surface_data 4: the semi-join must +-- still yield one texture row, not one per appearance. +INSERT INTO citydb.appearance (id, objectid, theme) +VALUES + (1, 'appearance_summer_a', 'summer'), + (2, 'appearance_winter', 'winter'), + (3, 'appearance_summer_b', 'summer') +ON CONFLICT (id) DO NOTHING; + +INSERT INTO citydb.appear_to_surface_data (appearance_id, surface_data_id) +VALUES + (1, 4), + (3, 4), + (2, 5) +ON CONFLICT DO NOTHING; diff --git a/src/pg2b3dm/Options.cs b/src/pg2b3dm/Options.cs index 162ab68c..dd97bd5c 100644 --- a/src/pg2b3dm/Options.cs +++ b/src/pg2b3dm/Options.cs @@ -28,6 +28,9 @@ public class Options [Option('q', "query", Required = false, Default = "", HelpText = "Query parameter")] public string Query { get; set; } + [Option("theme", Required = false, Default = "", HelpText = "3DCityDB v5+ appearance theme to select textures from (filters the surface_data via appearance). Empty = no theme filter (lowest surface_data_id wins).")] + public string Theme { get; set; } + [Option("copyright", Required = false, Default = "", HelpText = "glTF asset copyright")] public string Copyright { get; set; } diff --git a/src/pg2b3dm/Program.cs b/src/pg2b3dm/Program.cs index 445e3dbd..b57c861e 100644 --- a/src/pg2b3dm/Program.cs +++ b/src/pg2b3dm/Program.cs @@ -73,6 +73,7 @@ static void Main(string[] args) inputTable.TableName = o.GeometryTable; inputTable.GeometryColumn = o.GeometryColumn; inputTable.Query = o.Query; + inputTable.Theme = o.Theme; inputTable.RadiusColumn = o.RadiusColumn; inputTable.ShadersColumn = o.ShadersColumn; inputTable.AttributeColumns = o.AttributeColumns; @@ -121,6 +122,16 @@ static void Main(string[] args) Console.WriteLine($"Warning: column 'id' missing in {inputTable.TableName}, texture pipeline disabled."); } Console.WriteLine($"Texture pipeline enabled: {inputTable.UseTexturePipeline}"); + if (!String.IsNullOrWhiteSpace(inputTable.Theme)) { + Console.WriteLine($"Texture theme filter: {inputTable.Theme}"); + // A theme no appearance carries is not an error to the tiler: it bakes a complete + // tileset in which every tile is untextured, so a typo only shows up in the viewer. + // Say it once here, not per tile - with a theme filter most tiles are legitimately + // untextured. + if (inputTable.UseTexturePipeline && !CityDbRepository.HasTheme(conn, inputTable.Theme)) { + Console.WriteLine($"Warning: no appearance has theme '{inputTable.Theme}', all tiles will be untextured."); + } + } var skipCreateTiles = (bool)o.SkipCreateTiles; Console.WriteLine("Skip create tiles: " + skipCreateTiles);