From cc5eaad5062deac5471389cd9f98afdc8ddc2e99 Mon Sep 17 00:00:00 2001 From: Christian Braun Date: Fri, 7 Aug 2026 09:53:58 +0200 Subject: [PATCH 1/6] feat: add --theme option to filter v5 textures by appearance theme 3DCityDB v5+ can hold many appearance themes per shared geometry. The texture-enrichment query previously joined surface_data_mapping -> surface_data -> tex_image with no appearance filter, so a geometry with multiple mappings always rendered the lowest surface_data_id. Add an optional --theme option that, when set, joins appear_to_surface_data + appearance and filters on appearance.theme, letting one shared geometry serve N themes via N runs (no geometry duplication). Empty theme keeps the original behaviour. --- src/b3dm.tileset/GeometryRepository.cs | 25 +++++++++++++++++++------ src/b3dm.tileset/OctreeTiler.cs | 2 +- src/b3dm.tileset/QuadtreeTiler.cs | 2 +- src/b3dm.tileset/settings/InputTable.cs | 1 + src/pg2b3dm/Options.cs | 3 +++ src/pg2b3dm/Program.cs | 4 ++++ 6 files changed, 29 insertions(+), 8 deletions(-) diff --git a/src/b3dm.tileset/GeometryRepository.cs b/src/b3dm.tileset/GeometryRepository.cs index b21d4789..8f9c749b 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,17 @@ 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 via appear_to_surface_data + appearance. + var themeJoin = theme != string.Empty ? @" +JOIN citydb.appear_to_surface_data a2s + ON a2s.surface_data_id = sd.id +JOIN citydb.appearance ap + ON ap.id = a2s.appearance_id" : string.Empty; + var themeFilter = theme != string.Empty ? "\n 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, @@ -210,15 +220,18 @@ JOIN citydb.surface_data_mapping sdm JOIN citydb.surface_data sd ON sd.id = sdm.surface_data_id JOIN citydb.tex_image ti - ON ti.id = sd.tex_image_id + ON ti.id = sd.tex_image_id{themeJoin} 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 (theme != string.Empty) { + 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/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..17eaa3bd 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,9 @@ 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 (inputTable.Theme != String.Empty) { + Console.WriteLine($"Texture theme filter: {inputTable.Theme}"); + } var skipCreateTiles = (bool)o.SkipCreateTiles; Console.WriteLine("Skip create tiles: " + skipCreateTiles); From 7747e90392654cf633f4d9b1667374d90703cd40 Mon Sep 17 00:00:00 2001 From: Christian Braun Date: Fri, 7 Aug 2026 10:02:39 +0200 Subject: [PATCH 2/6] fix: use EXISTS semi-join for --theme to avoid duplicate textures A surface_data can be referenced by multiple appearance rows sharing the same theme string; the previous fan-out JOIN then emitted N identical rows per surface, and EnrichWithTextures adds each without dedup, embedding the same image N times. Replace the appearance JOIN with an EXISTS semi-join so each matching surface_data contributes exactly one row. Empty theme still reproduces the original query verbatim. --- src/b3dm.tileset/GeometryRepository.cs | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/b3dm.tileset/GeometryRepository.cs b/src/b3dm.tileset/GeometryRepository.cs index 8f9c749b..d4a937ff 100644 --- a/src/b3dm.tileset/GeometryRepository.cs +++ b/src/b3dm.tileset/GeometryRepository.cs @@ -200,13 +200,16 @@ private static void EnrichWithTextures(NpgsqlConnection conn, List Date: Mon, 10 Aug 2026 10:50:53 +0200 Subject: [PATCH 3/6] test: cover --theme selection and appearance de-duplication Adds a 3DCityDB v5 appearance fixture (citydb.appearance / citydb.appear_to_surface_data, column names taken from a real v5 database) and a fourth geometry whose single surface is textured twice - red under theme 'summer', blue under 'winter'. Selecting 'winter' must return the blue texture, which the unfiltered query would never pick (lower surface_data_id wins). 'summer' is carried by two appearances that both reference the same surface_data, so the second test pins the EXISTS semi-join: one texture, not one per appearance. The fixture is a separate script and sits outside the bounding boxes the existing texture tests query, so their expectations are untouched. --- src/pg2b3dm.database.tests/UnitTest1.cs | 55 +++++++++++++ .../pg2b3dm.database.tests.csproj | 3 + .../6_create_3dcitydb_appearance_tables.sql | 81 +++++++++++++++++++ 3 files changed, 139 insertions(+) create mode 100644 src/pg2b3dm.database.tests/postgres-db/6_create_3dcitydb_appearance_tables.sql diff --git a/src/pg2b3dm.database.tests/UnitTest1.cs b/src/pg2b3dm.database.tests/UnitTest1.cs index ff61d81c..c6d8a272 100644 --- a/src/pg2b3dm.database.tests/UnitTest1.cs +++ b/src/pg2b3dm.database.tests/UnitTest1.cs @@ -31,6 +31,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] @@ -157,6 +159,59 @@ 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'). Selecting 'winter' must yield the blue texture - + // without a theme the lower surface_data_id would win. + [Test] + public void ThemeFilterSelectsTexturesOfSelectedTheme() + { + var connectionString = _containerPostgres.GetConnectionString(); + var conn = new NpgsqlConnection(connectionString); + + var withoutTheme = GetThemedGeometry(conn, string.Empty); + var winter = GetThemedGeometry(conn, "winter"); + + Assert.That(withoutTheme.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)); + } + + 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]; + } + [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; From 97ff70f8ca197d00c8454c8da6773ec48a9e21f6 Mon Sep 17 00:00:00 2001 From: Christian Braun Date: Mon, 10 Aug 2026 11:23:50 +0200 Subject: [PATCH 4/6] docs: document --theme in README and the 3DCityDB v5 guide --- README.md | 5 +++++ dataprocessing/dataprocessing_citygml.md | 23 +++++++++++++++++++++++ 2 files changed, 28 insertions(+) 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 From 658668dca37d5fc5a76c4c406e44cf74d4f84b39 Mon Sep 17 00:00:00 2001 From: Christian Braun Date: Mon, 10 Aug 2026 11:26:00 +0200 Subject: [PATCH 5/6] fix: treat a blank --theme as no theme, tidy the test fixture helpers --theme " " fell through the string.Empty check and filtered on a theme no appearance carries, silently dropping every texture. IsNullOrWhiteSpace makes blank mean absent, which is what the option's default documents. Also moves the test helpers to the top of the class and corrects a comment that said the unfiltered query picks the lowest surface_data_id - it returns both mappings, and the renderer takes the first per objectId. --- src/b3dm.tileset/GeometryRepository.cs | 5 ++- src/pg2b3dm.database.tests/UnitTest1.cs | 52 +++++++++++++------------ src/pg2b3dm/Program.cs | 2 +- 3 files changed, 32 insertions(+), 27 deletions(-) diff --git a/src/b3dm.tileset/GeometryRepository.cs b/src/b3dm.tileset/GeometryRepository.cs index d4a937ff..e570355f 100644 --- a/src/b3dm.tileset/GeometryRepository.cs +++ b/src/b3dm.tileset/GeometryRepository.cs @@ -203,7 +203,8 @@ private static void EnrichWithTextures(NpgsqlConnection conn, List Date: Mon, 10 Aug 2026 12:03:36 +0200 Subject: [PATCH 6/6] feat: warn when --theme matches no appearance A theme nobody wrote is not an error to the tiler: it bakes a complete, valid tileset in which every tile is untextured, with the same exit code and tile count, so a typo only surfaces in the viewer. Probe citydb.appearance once, up front, and warn. Not per tile - with a theme filter most tiles are legitimately untextured. Gated on the texture pipeline, since citydb.appearance need not exist otherwise. --- src/b3dm.tileset/CityDbRepository.cs | 14 ++++++++++++++ src/pg2b3dm.database.tests/UnitTest1.cs | 12 ++++++++++++ src/pg2b3dm/Program.cs | 7 +++++++ 3 files changed, 33 insertions(+) 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/pg2b3dm.database.tests/UnitTest1.cs b/src/pg2b3dm.database.tests/UnitTest1.cs index d8740721..0670d44c 100644 --- a/src/pg2b3dm.database.tests/UnitTest1.cs +++ b/src/pg2b3dm.database.tests/UnitTest1.cs @@ -123,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() { diff --git a/src/pg2b3dm/Program.cs b/src/pg2b3dm/Program.cs index bdd9846c..b57c861e 100644 --- a/src/pg2b3dm/Program.cs +++ b/src/pg2b3dm/Program.cs @@ -124,6 +124,13 @@ static void Main(string[] args) 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;