Skip to content

Commit 2cc9c3b

Browse files
committed
add experimental texture support
1 parent af8de7f commit 2cc9c3b

30 files changed

Lines changed: 1448 additions & 50 deletions

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ Features:
2020

2121
- Shading PbrMetallicRoughness and PbrSpecularGlossiness;
2222

23+
- Automatic 3DCityDB v5 texture support per tile/glTF (texture > shader > default);
24+
2325
- Query parameter support;
2426

2527
- Cesium: LOD support and Outlines support (using CESIUM_primitive_outline);
@@ -155,6 +157,19 @@ Sample command for running pg2b3dm:
155157
$ pg2b3dm -h localhost -U postgres -c geom_triangle --shaderscolumn shaders -t delaware_buildings -d postgres -g 100,0
156158
```
157159

160+
## 3DCityDB v5 textures
161+
162+
When the source database matches the 3DCityDB v5 schema, pg2b3dm automatically checks if texture data is present in
163+
`citydb.surface_data_mapping` + `citydb.surface_data` + `citydb.tex_image` and then resolves textures per tile/glTF.
164+
165+
Rules:
166+
167+
- Per-tile behavior: one tile can be textured while another tile from the same run is not textured.
168+
- Priority: if both textures and shaders are available in a tile, textures are used and shaders are ignored.
169+
- Fallback in textured tiles: geometries without valid texture coordinates/image use the default material.
170+
171+
No extra CLI option is required for this workflow.
172+
158173
Database password will be asked to create the database connection, unless:
159174

160175
- Trusted authentication is enabled;

dataprocessing/dataprocessing_citygml.md

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -173,11 +173,49 @@ Result: The exported 3D Tiles will now have different colors based on the CityGM
173173

174174
---
175175

176-
## 5. Conclusion
176+
## 6. Textures in 3DCityDB v5
177+
178+
For 3DCityDB v5 input (`citydb.geometry_data` or a view carrying `geometry_data.id`), pg2b3dm now automatically detects
179+
texture data and applies textures per tile.
180+
181+
Relevant texture tables and columns:
182+
183+
- `citydb.surface_data_mapping.texture_mapping` (texture coordinates)
184+
- `citydb.surface_data.tex_image_id`
185+
- `citydb.tex_image.image_data` (image bytes)
186+
187+
Example query:
188+
189+
```sql
190+
SELECT
191+
gmdt.id,
192+
gmdt.geometry,
193+
gmdt.geometry_properties,
194+
sdm.texture_mapping,
195+
ti.image_uri,
196+
ti.image_data
197+
FROM citydb.geometry_data gmdt
198+
JOIN citydb.surface_data_mapping sdm
199+
ON gmdt.id = sdm.geometry_data_id
200+
JOIN citydb.surface_data sd
201+
ON sd.id = sdm.surface_data_id
202+
JOIN citydb.tex_image ti
203+
ON ti.id = sd.tex_image_id
204+
WHERE sdm.texture_mapping IS NOT NULL
205+
AND ti.image_data IS NOT NULL;
206+
```
207+
208+
Priority rule during export:
209+
210+
- If textures and shaders are both available in the same tile, textures are used.
211+
- Tiles without texture data keep existing shader/default behavior.
212+
213+
---
214+
215+
## 7. Conclusion
177216

178217
3DCityDB v5 streamlines the process of:
179218
- Storing and querying CityGML 3.0 models in PostgreSQL/PostGIS
180219
- Converting them into web-ready 3D Tiles for efficient visualization
181-
- Supporting semantic 3D city models suitable for digital twin and urban planning applications
182-
183-
Future improvements may include the direct handling of texture data to enrich the visual quality of the exported 3D Tiles.
220+
- Supporting semantic 3D city models suitable for digital twin and urban planning applications
221+
- Exporting both shader-based and texture-based visualizations.
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
using System;
2+
using Npgsql;
3+
4+
namespace B3dm.Tileset;
5+
6+
public static class CityDbRepository
7+
{
8+
public static bool Is3dCityDbV5OrHigher(NpgsqlConnection conn)
9+
{
10+
const string sql = @"
11+
SELECT COUNT(*) = 10
12+
FROM (
13+
VALUES
14+
('geometry_data','id'),
15+
('geometry_data','geometry'),
16+
('surface_data_mapping','geometry_data_id'),
17+
('surface_data_mapping','surface_data_id'),
18+
('surface_data_mapping','texture_mapping'),
19+
('surface_data','id'),
20+
('surface_data','tex_image_id'),
21+
('tex_image','id'),
22+
('tex_image','image_data'),
23+
('tex_image','mime_type')
24+
) req(table_name, column_name)
25+
JOIN information_schema.columns c
26+
ON c.table_schema = 'citydb'
27+
AND c.table_name = req.table_name
28+
AND c.column_name = req.column_name";
29+
30+
return ExecuteBooleanScalar(conn, sql);
31+
}
32+
33+
public static bool HasTextureData(NpgsqlConnection conn)
34+
{
35+
const string sql = @"
36+
SELECT EXISTS (
37+
SELECT 1
38+
FROM citydb.surface_data_mapping sdm
39+
JOIN citydb.surface_data sd ON sd.id = sdm.surface_data_id
40+
JOIN citydb.tex_image ti ON ti.id = sd.tex_image_id
41+
WHERE sdm.texture_mapping IS NOT NULL
42+
AND ti.image_data IS NOT NULL
43+
LIMIT 1
44+
)";
45+
46+
return ExecuteBooleanScalar(conn, sql);
47+
}
48+
49+
public static bool HasColumn(NpgsqlConnection conn, string tableName, string columnName)
50+
{
51+
var schemaAndTable = GetSchemaAndTable(tableName);
52+
if (schemaAndTable == null) {
53+
return false;
54+
}
55+
56+
const string sql = @"
57+
SELECT EXISTS (
58+
SELECT 1
59+
FROM information_schema.columns
60+
WHERE table_schema = @schema
61+
AND table_name = @table
62+
AND column_name = @column
63+
)";
64+
65+
return ExecuteBooleanScalar(conn, sql, cmd => {
66+
cmd.Parameters.AddWithValue("schema", schemaAndTable.Value.Schema);
67+
cmd.Parameters.AddWithValue("table", schemaAndTable.Value.Table);
68+
cmd.Parameters.AddWithValue("column", columnName);
69+
});
70+
}
71+
72+
private static (string Schema, string Table)? GetSchemaAndTable(string tableName)
73+
{
74+
if (string.IsNullOrWhiteSpace(tableName)) {
75+
return null;
76+
}
77+
78+
var value = tableName.Trim();
79+
var parts = value.Split('.', 2, StringSplitOptions.RemoveEmptyEntries);
80+
if (parts.Length == 1) {
81+
return ("public", StripQuotes(parts[0]));
82+
}
83+
84+
return (StripQuotes(parts[0]), StripQuotes(parts[1]));
85+
}
86+
87+
private static string StripQuotes(string value)
88+
{
89+
return value.Trim().Trim('"');
90+
}
91+
92+
private static bool ExecuteBooleanScalar(NpgsqlConnection conn, string sql, Action<NpgsqlCommand> addParameters = null)
93+
{
94+
conn.Open();
95+
try {
96+
using var cmd = new NpgsqlCommand(sql, conn);
97+
addParameters?.Invoke(cmd);
98+
var value = cmd.ExecuteScalar();
99+
return value is bool result && result;
100+
}
101+
finally {
102+
conn.Close();
103+
}
104+
}
105+
}

src/b3dm.tileset/GeometryRepository.cs

Lines changed: 99 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,9 @@ public static double[] GetGeometriesBoundingBox(NpgsqlConnection conn, string ge
3838
return result;
3939
}
4040

41-
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)
41+
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)
4242
{
43-
var sqlselect = GetSqlSelect(geometry_column, shaderColumn, attributesColumns, radiusColumn, target_srs);
43+
var sqlselect = GetSqlSelect(geometry_column, shaderColumn, attributesColumns, radiusColumn, target_srs, idColumn);
4444
var sqlFrom = "FROM " + geometry_table;
4545

4646
// todo: fix unit test when there is no z
@@ -49,7 +49,10 @@ public static List<GeometryRecord> GetGeometrySubset(NpgsqlConnection conn, stri
4949
var sqlWhere = GetWhere(geometry_column, points.fromPoint, points.toPoint, query, source_epsg, keepProjection);
5050
var sql = sqlselect + sqlFrom + " where " + sqlWhere;
5151

52-
var geometries = GetGeometries(conn, shaderColumn, attributesColumns, sql, radiusColumn);
52+
var geometries = GetGeometries(conn, shaderColumn, attributesColumns, sql, radiusColumn, idColumn);
53+
if (includeTextures) {
54+
EnrichWithTextures(conn, geometries);
55+
}
5356
return geometries;
5457
}
5558

@@ -85,10 +88,13 @@ public static string GetWhere(string geometry_column, Point from, Point to, stri
8588
return where;
8689
}
8790

88-
public static string GetSqlSelect(string geometry_column, string shaderColumn, string attributesColumns, string radiusColumn, int target_srs)
91+
public static string GetSqlSelect(string geometry_column, string shaderColumn, string attributesColumns, string radiusColumn, int target_srs, string idColumn = "")
8992
{
9093
var g = GetGeometryColumn(geometry_column, target_srs);
9194
var sqlselect = $"SELECT ST_AsBinary({g})";
95+
if (idColumn != String.Empty) {
96+
sqlselect = $"{sqlselect}, {idColumn} ";
97+
}
9298
if (shaderColumn != String.Empty) {
9399
sqlselect = $"{sqlselect}, {shaderColumn} ";
94100
}
@@ -107,7 +113,7 @@ public static string GetGeometryColumn(string geometry_column, int target_srs)
107113
return $"st_transform({geometry_column}, {target_srs})";
108114
}
109115

110-
public static List<GeometryRecord> GetGeometries(NpgsqlConnection conn, string shaderColumn, string attributesColumns, string sql, string radiusColumn)
116+
public static List<GeometryRecord> GetGeometries(NpgsqlConnection conn, string shaderColumn, string attributesColumns, string sql, string radiusColumn, string idColumn = "")
111117
{
112118
var geometries = new List<GeometryRecord>();
113119
conn.Open();
@@ -116,11 +122,18 @@ public static List<GeometryRecord> GetGeometries(NpgsqlConnection conn, string s
116122
var attributesColumnIds = new Dictionary<string, int>();
117123
var shadersColumnId = int.MinValue;
118124
var radiusColumnId = int.MinValue;
125+
var idColumnId = int.MinValue;
119126

120127
if (attributesColumns != String.Empty) {
121128
var attributesColumnsList = attributesColumns.Split(',').ToList();
122129
attributesColumnIds = FindFields(reader, attributesColumnsList);
123130
}
131+
if (idColumn != String.Empty) {
132+
var fld = FindField(reader, idColumn);
133+
if (fld.HasValue) {
134+
idColumnId = fld.Value;
135+
}
136+
}
124137
if (shaderColumn != String.Empty) {
125138
var fld = FindField(reader, shaderColumn);
126139
if (fld.HasValue) {
@@ -140,6 +153,10 @@ public static List<GeometryRecord> GetGeometries(NpgsqlConnection conn, string s
140153

141154
var geom = Geometry.Deserialize<WkbSerializer>(stream);
142155
var geometryRecord = new GeometryRecord(batchId) { Geometry = geom };
156+
if (idColumn != string.Empty && idColumnId >= 0) {
157+
var id = reader.GetFieldValue<object>(idColumnId);
158+
geometryRecord.SourceId = Convert.ToInt64(id);
159+
}
143160

144161
if (shaderColumn != string.Empty) {
145162
var json = GetJson(reader, shadersColumnId);
@@ -164,6 +181,82 @@ public static List<GeometryRecord> GetGeometries(NpgsqlConnection conn, string s
164181
return geometries;
165182
}
166183

184+
private static void EnrichWithTextures(NpgsqlConnection conn, List<GeometryRecord> geometries)
185+
{
186+
var sourceIds = geometries
187+
.Where(g => g.SourceId.HasValue)
188+
.Select(g => g.SourceId!.Value)
189+
.Distinct()
190+
.ToArray();
191+
192+
if (sourceIds.Length == 0) {
193+
return;
194+
}
195+
196+
var geometriesById = geometries
197+
.Where(g => g.SourceId.HasValue)
198+
.GroupBy(g => g.SourceId!.Value)
199+
.ToDictionary(g => g.Key, g => g.First());
200+
201+
const string sql = @"
202+
SELECT g.id,
203+
g.geometry_properties::text AS geometry_properties,
204+
sdm.texture_mapping::text AS texture_mapping,
205+
ti.mime_type,
206+
ti.image_data
207+
FROM citydb.geometry_data g
208+
JOIN citydb.surface_data_mapping sdm
209+
ON sdm.geometry_data_id = g.id
210+
JOIN citydb.surface_data sd
211+
ON sd.id = sdm.surface_data_id
212+
JOIN citydb.tex_image ti
213+
ON ti.id = sd.tex_image_id
214+
WHERE g.id = ANY(@ids)
215+
AND sdm.texture_mapping IS NOT NULL
216+
AND ti.image_data IS NOT NULL
217+
ORDER BY g.id, sdm.surface_data_id";
218+
219+
conn.Open();
220+
var cmd = new NpgsqlCommand(sql, conn);
221+
cmd.Parameters.AddWithValue("ids", sourceIds);
222+
var reader = cmd.ExecuteReader();
223+
224+
while (reader.Read()) {
225+
var sourceId = Convert.ToInt64(reader.GetFieldValue<object>(0));
226+
if (!geometriesById.TryGetValue(sourceId, out var geometryRecord)) {
227+
continue;
228+
}
229+
230+
if (string.IsNullOrWhiteSpace(geometryRecord.GeometryProperties)) {
231+
geometryRecord.GeometryProperties = reader.IsDBNull(1) ? String.Empty : reader.GetString(1);
232+
}
233+
234+
var textureMapping = reader.IsDBNull(2) ? String.Empty : reader.GetString(2);
235+
var textureMimeType = reader.IsDBNull(3) ? String.Empty : reader.GetString(3);
236+
var textureImageData = reader.GetFieldValue<byte[]>(4);
237+
238+
var texture = new GeometryTexture() {
239+
TextureMapping = textureMapping,
240+
TextureMimeType = textureMimeType,
241+
TextureImageData = textureImageData
242+
};
243+
244+
if (texture.IsValid()) {
245+
geometryRecord.Textures.Add(texture);
246+
}
247+
248+
// keep legacy fields for backward compatibility with older call paths
249+
if (string.IsNullOrWhiteSpace(geometryRecord.TextureMapping) && geometryRecord.TextureImageData.Length == 0) {
250+
geometryRecord.TextureMapping = textureMapping;
251+
geometryRecord.TextureMimeType = textureMimeType;
252+
geometryRecord.TextureImageData = textureImageData;
253+
}
254+
}
255+
256+
reader.Close();
257+
conn.Close();
258+
}
259+
167260
private static int? FindField(NpgsqlDataReader reader, string fieldName)
168261
{
169262
var schema = reader.GetSchemaTable();
@@ -221,4 +314,4 @@ private static (Point fromPoint, Point toPoint) GetPoints(double[] bbox)
221314
return (fromPoint, toPoint);
222315
}
223316

224-
}
317+
}

src/b3dm.tileset/OctreeTiler.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ public List<Tile3D> GenerateTiles3D(BoundingBox3D bbox, int level, Tile3D tile,
8080
}
8181

8282
var bbox1 = new double[] { bbox.XMin, bbox.YMin, bbox.XMax, bbox.YMax, bbox.ZMin, bbox.ZMax };
83-
var geometries = GeometryRepository.GetGeometrySubset(conn, inputTable.TableName, inputTable.GeometryColumn, bbox1, inputTable.EPSGCode, target_srs, inputTable.ShadersColumn, inputTable.AttributeColumns, where, inputTable.RadiusColumn, tilingSettings.KeepProjection);
83+
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);
8484

8585
if (geometries.Count > 0) {
8686

src/b3dm.tileset/QuadtreeTiler.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ public List<Tile> GenerateTiles(BoundingBox bbox, Tile tile, List<Tile> tiles, i
9898

9999
byte[] bytes = null;
100100

101-
var geometries = GeometryRepository.GetGeometrySubset(conn, inputTable.TableName, inputTable.GeometryColumn, tile.BoundingBox, source_epsg, target_srs, inputTable.ShadersColumn, inputTable.AttributeColumns, where, inputTable.RadiusColumn, keepProjection);
101+
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);
102102
// var scale = new double[] { 1, 1, 1 };
103103
if (geometries.Count > 0) {
104104

src/b3dm.tileset/settings/InputTable.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,17 @@ public class InputTable
44
{
55
public string TableName { get; set; }
66
public string GeometryColumn { get; set; }
7+
public string IdColumn { get; set; } = string.Empty;
78
public string RadiusColumn { get; set; } = string.Empty;
89
public string ShadersColumn { get; set; } = string.Empty;
910
public string Query { get; set; } = string.Empty;
1011

1112
public string LodColumn { get; set; } = string.Empty;
1213
public string AttributeColumns { get; set; } = string.Empty;
1314
public int EPSGCode { get; set; }
15+
public bool Is3dCityDbV5OrHigher { get; set; }
16+
public bool HasTextureData { get; set; }
17+
public bool UseTexturePipeline { get; set; }
1418

1519
public string GetQueryClause()
1620
{

0 commit comments

Comments
 (0)