Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions dataprocessing/dataprocessing_citygml.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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:

<img width="986" height="948" alt="image" src="https://github.com/user-attachments/assets/1e434d3f-7918-4b9b-87e6-18f168f45b55" />
Expand Down
14 changes: 14 additions & 0 deletions src/b3dm.tileset/CityDbRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
27 changes: 22 additions & 5 deletions src/b3dm.tileset/GeometryRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public static double[] GetGeometriesBoundingBox(NpgsqlConnection conn, string ge
return result;
}

public static List<GeometryRecord> 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<GeometryRecord> 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;
Expand All @@ -51,7 +51,7 @@ public static List<GeometryRecord> GetGeometrySubset(NpgsqlConnection conn, stri

var geometries = GetGeometries(conn, shaderColumn, attributesColumns, sql, radiusColumn, idColumn);
if (includeTextures) {
EnrichWithTextures(conn, geometries);
EnrichWithTextures(conn, geometries, theme);
}
return geometries;
}
Expand Down Expand Up @@ -181,7 +181,7 @@ public static List<GeometryRecord> GetGeometries(NpgsqlConnection conn, string s
return geometries;
}

private static void EnrichWithTextures(NpgsqlConnection conn, List<GeometryRecord> geometries)
private static void EnrichWithTextures(NpgsqlConnection conn, List<GeometryRecord> geometries, string theme = "")
{
var sourceIds = geometries
.Where(g => g.SourceId.HasValue)
Expand All @@ -198,7 +198,21 @@ private static void EnrichWithTextures(NpgsqlConnection conn, List<GeometryRecor
.GroupBy(g => 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,
Expand All @@ -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()) {
Expand Down
2 changes: 1 addition & 1 deletion src/b3dm.tileset/OctreeTiler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ public List<Tile3D> 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) {

Expand Down
2 changes: 1 addition & 1 deletion src/b3dm.tileset/QuadtreeTiler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ public List<Tile> GenerateTiles(BoundingBox bbox, Tile tile, List<Tile> 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) {

Expand Down
1 change: 1 addition & 0 deletions src/b3dm.tileset/settings/InputTable.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
71 changes: 71 additions & 0 deletions src/pg2b3dm.database.tests/UnitTest1.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,33 @@
{
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()
{
_containerPostgres = new PostgreSqlBuilder()

Check warning on line 42 in src/pg2b3dm.database.tests/UnitTest1.cs

View workflow job for this annotation

GitHub Actions / build

'PostgreSqlBuilder.PostgreSqlBuilder()' is obsolete: 'This parameterless constructor is obsolete and will be removed. Use the constructor with the image parameter instead: https://github.com/testcontainers/testcontainers-dotnet/discussions/1470#discussioncomment-15185721.'

Check warning on line 42 in src/pg2b3dm.database.tests/UnitTest1.cs

View workflow job for this annotation

GitHub Actions / build

'PostgreSqlBuilder.PostgreSqlBuilder()' is obsolete: 'This parameterless constructor is obsolete and will be removed. Use the constructor with the image parameter instead: https://github.com/testcontainers/testcontainers-dotnet/discussions/1470#discussioncomment-15185721.'

Check warning on line 42 in src/pg2b3dm.database.tests/UnitTest1.cs

View workflow job for this annotation

GitHub Actions / build

'PostgreSqlBuilder.PostgreSqlBuilder()' is obsolete: 'This parameterless constructor is obsolete and will be removed. Use the constructor with the image parameter instead: https://github.com/testcontainers/testcontainers-dotnet/discussions/1470#discussioncomment-15185721.'
.WithImage("postgis/postgis:16-3.4-alpine")
.WithWaitStrategy(Wait.ForUnixContainer().UntilInternalTcpPortIsAvailable(5432))
.Build();
Expand All @@ -31,6 +54,8 @@
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]
Expand Down Expand Up @@ -98,6 +123,18 @@
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()
{
Expand Down Expand Up @@ -157,6 +194,40 @@
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()
{
Expand Down
3 changes: 3 additions & 0 deletions src/pg2b3dm.database.tests/pg2b3dm.database.tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@
<None Update="postgres-db\5_create_3dcitydb_texture_tables.sql">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="postgres-db\6_create_3dcitydb_appearance_tables.sql">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="testfixtures\delaware.sqlite">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
3 changes: 3 additions & 0 deletions src/pg2b3dm/Options.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }

Expand Down
11 changes: 11 additions & 0 deletions src/pg2b3dm/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
Loading