3D Format Standards Comparison for Digital Twin Automation
Choosing a 3D format is not a file-extension preference — it decides whether your digital twin can carry coordinate reference metadata across a conversion, whether a browser can stream a city without exhausting memory, and whether the construction year baked into a building survives the trip from BIM authoring to a Cesium tileset. No single specification wins everywhere: CityGML and IFC are rich in semantics but blind to streaming; glTF and 3D Tiles stream beautifully but discard most of an IFC model’s metadata unless you map it deliberately; LAS/LAZ keep raw measurement fidelity but are not surfaces at all. This guide compares CityGML, IFC, OGC 3D Tiles, glTF 2.0, OBJ, GeoPackage, 3D GeoJSON, and LAS/LAZ on the axes that actually break pipelines — semantics, CRS handling, level of detail, streaming, schema validation, and compression — and gives you runnable conversions with trimesh, pygltflib, and GDAL/OGR so the comparison is something you can execute, not just read.
Prerequisites
The conversion snippets below assume a Python 3.11 environment with these packages and the GDAL/OGR command-line tools installed:
trimesh==4.4.*withpygltflib==1.16.*for glTF read/write and Draco/meshopt-aware export.numpy==1.26.*,shapely==2.0.*for geometry and attribute handling.pyproj==3.6.*(PROJ 9.x) for CRS validation and reprojection.- GDAL/OGR 3.8+ (
ogr2ogr,ogrinfo) for vector format conversion (GeoPackage, 3D GeoJSON). laspy==2.5.*with thelazrsbackend for.las/.lazinspection.IfcOpenShell0.7+ and the npm3d-tiles-validator(v0.4+) for IFC parsing and tileset validation.
Every input is assumed to declare an explicit CRS. Source meshes from photogrammetry typically arrive in a projected metric system such as UTM zone 18N (EPSG:32618); web outputs target EPSG:4326 for geographic positioning of the tileset, while local geometry inside a tile is kept in a metric east-north-up frame. State the EPSG in metadata at every stage — a format that “supports CRS” still records nothing if the writer was never told the code.
Concept
Each format optimizes for one thing and pays for it elsewhere. CityGML optimizes for semantic completeness: it models a building as a hierarchy of walls, roofs, and rooms with explicit LOD0–LOD4 and a validating XML schema, at the cost of being verbose and un-streamable. IFC optimizes for the building lifecycle — phases, materials, IfcPropertySets — but its native coordinates are a project-local frame with no CRS, so georeferencing is something you apply, not something you read. glTF 2.0 optimizes for GPU upload: an interleaved binary buffer the renderer maps almost directly to vertex attributes, with semantics demoted to a free-form extras/KHR_materials payload. OGC 3D Tiles wraps glTF (or pnts point tiles) in a spatial index with bounding volumes and geometricError so a client can page LOD by screen-space error. OBJ optimizes for nothing but portability — ASCII triangles, no CRS, no metadata. GeoPackage and 3D GeoJSON optimize for attribute queries: a SQLite-backed or JSON feature store you can filter with SQL or a web API.
The resolution to this tension is not a single winning format but a canonical internal format with derived outputs: keep semantics and CRS in an authoritative store, and generate each delivery format as a versioned, disposable derivative. The corollary is that conversion direction matters — you should only ever convert from a richer format to a leaner one (CityGML → 3D Tiles, IFC → glTF, LAS → derived mesh), never the reverse, because the lean format physically cannot reconstruct what it never stored. A glTF that has lost its IFC property sets cannot regrow them; an OBJ that dropped its CRS cannot infer one. Treat every arrow in the diagram below as one-way and lossy, and keep the upstream source so you can regenerate when a target spec changes or a Draco bug is found.
Comparison Table
The matrix below ranks each format on the axes that decide a pipeline. “Streaming” means native progressive paging, not just chunked reads; “LOD” means an explicit, addressable level-of-detail model rather than ad-hoc decimation. Read it as a capability map, not a ranking — the right cell to optimize for depends on whether your bottleneck is the browser (favour 3D Tiles + Draco glTF), the analyst (favour CityGML/GeoPackage semantics), or the surveyor (favour LAS/LAZ fidelity). Most production twins use three or four of these simultaneously: LAS/LAZ for the raw scan, a canonical GeoPackage for attributes, and 3D Tiles wrapping Draco glTF for delivery.
| Format | Semantics | CRS handling | LOD / streaming | Compression | Best use |
|---|---|---|---|---|---|
| CityGML | Rich — building parts, rooms, ADEs, validating XSD | Native (srsName with EPSG, e.g. EPSG:25832) |
LOD0–LOD4 explicit; no streaming, needs tiling | gzip only | Municipal/regulatory archival, semantic analysis |
| IFC | Extensive — phases, materials, property sets | None native; project-local frame, georeference on import | Implicit via spatial structure; no streaming | None (STEP text) or .ifczip |
BIM/infrastructure exchange, building lifecycle |
| OGC 3D Tiles | Feature tables / batch IDs / metadata | Native — root transform + bounding volumes (EPSG:4326 anchoring) | Hierarchical LOD by geometricError; full streaming |
Inherits glTF (Draco/meshopt) | Browser delivery of city/region twins |
| glTF 2.0 | Free-form extras, KHR_* extensions |
None native; CESIUM_RTC or external transform |
None native; LOD via separate nodes | KHR_draco_mesh_compression, EXT_meshopt_compression |
Web/real-time render payload inside tiles |
| OBJ | None | None | None | None (ASCII) | CAD interchange, throwaway intermediates |
| GeoPackage | Attribute tables (SQL-queryable) | Native (gpkg_spatial_ref_sys, EPSG) |
No (vector store) | SQLite + optional WKB | Attribute-rich query store, vector twin layers |
| 3D GeoJSON | Feature properties (JSON) | RFC 7946 mandates EPSG:4326 (CRS84) | No | gzip on the wire | Lightweight API delivery, web feature exchange |
| LAS / LAZ | Per-point class/intensity (ASPRS) | Native (VLR/EVLR WKT, EPSG) | Partial — chunked/COPC reads | LAZ (LASzip) ~7–10x | Point-cloud archival, raw measurement fidelity |
Step-by-Step Workflow
The workflow takes a photogrammetry-derived building mesh in UTM 18N (EPSG:32618), attaches semantics, and produces both a streaming glTF payload and a query-able vector layer — the two derivatives most twins need. Each step ends in a state you can assert on, so the pipeline can run unattended in CI and fail loudly the moment a conversion drops a CRS or an attribute rather than shipping a silently corrupted tile.
1. Load and inspect the source mesh
Confirm the geometry is sane and metric before converting. trimesh reports vertex/face counts and watertightness; the units must be metres in EPSG:32618, not the dimensionless coordinates OBJ often carries.
import trimesh
import numpy as np
mesh = trimesh.load("building_block.obj", file_type="obj", process=False)
print("vertices:", len(mesh.vertices), "faces:", len(mesh.faces))
print("watertight:", mesh.is_watertight, "winding ok:", mesh.is_winding_consistent)
# Source is in UTM 18N metres (EPSG:32618). Sanity-check the extent is metric.
extent = mesh.bounds[1] - mesh.bounds[0]
assert np.all(extent < 1000.0), "extent looks non-metric; check source CRS"
print(f"bbox extent (m): {extent.round(2)}")
2. Attach semantic attributes as glTF extras
glTF has no semantic schema, so map building metadata into metadata on the trimesh object, which the exporter serializes into the node extras. This is the explicit field mapping that prevents silent attribute loss.
mesh.metadata.update({
"asset_id": "BLDG-18N-00427",
"construction_year": 1962,
"source_crs": "EPSG:32618",
"lod": "LOD2",
})
3. Export Draco-compressed glTF with pygltflib
trimesh exports a .glb; for explicit Draco control and to verify the buffer, round-trip through pygltflib. Draco compression is what makes the payload streamable inside a 3D Tile.
import trimesh, pygltflib
# trimesh writes a binary glTF; Draco is applied when the optional encoder is present.
mesh.export("building_block.glb")
gltf = pygltflib.GLTF2().load("building_block.glb")
assert gltf.asset.version == "2.0"
# Confirm the semantic payload survived the export.
node_extras = gltf.nodes[0].extras or {}
print("embedded extras:", node_extras)
print("draco ext:", "KHR_draco_mesh_compression" in (gltf.extensionsUsed or []))
4. Derive a query-able vector layer with GDAL/OGR
The building footprint and attributes belong in a GeoPackage for SQL queries, and in 3D GeoJSON (EPSG:4326) for web delivery. ogr2ogr reprojects explicitly — never rely on its defaults.
import subprocess
# Footprint is authored in GeoPackage (EPSG:32618); derive WGS84 GeoJSON for the web.
subprocess.run([
"ogr2ogr", "-f", "GeoJSON",
"-s_srs", "EPSG:32618", "-t_srs", "EPSG:4326",
"-dim", "XYZ", "buildings_4326.geojson", "buildings.gpkg",
], check=True)
# Confirm the output CRS and dimensionality.
info = subprocess.run(
["ogrinfo", "-so", "-al", "buildings_4326.geojson"],
capture_output=True, text=True, check=True,
).stdout
assert "WGS 84" in info or "4326" in info, "GeoJSON CRS is not EPSG:4326"
5. Wrap glTF in a 3D Tiles tileset
The streaming derivative anchors the metric glTF at its EPSG:4326 origin via the tile transform, and declares a geometricError so clients can page LOD.
import json, numpy as np
from pyproj import Transformer
# Tile origin: place the metric mesh at its geographic position (EPSG:4326).
to_ecef = Transformer.from_crs("EPSG:4326", "EPSG:4978", always_xy=True)
lon, lat, h = -73.9857, 40.7484, 14.0 # tile anchor
x, y, z = to_ecef.transform(lon, lat, h)
tileset = {
"asset": {"version": "1.1"},
"geometricError": 70.0,
"root": {
"transform": [1,0,0,0, 0,1,0,0, 0,0,1,0, x, y, z, 1],
"boundingVolume": {"region": [
np.radians(-73.986), np.radians(40.748),
np.radians(-73.985), np.radians(40.749), 0, 80]},
"geometricError": 0.0,
"content": {"uri": "building_block.glb"},
},
}
with open("tileset.json", "w") as f:
json.dump(tileset, f, indent=2)
Validation & Verification
Every conversion crosses a format boundary where CRS metadata and semantics can be dropped. Assert parity rather than trust the writer.
Validate the tileset structure and CRS-anchored root with the official validator and a pyproj round-trip:
import subprocess, json
from pyproj import Transformer
# Schema validation via the OGC 3d-tiles-validator (npm).
subprocess.run(["3d-tiles-validator", "--tilesetFile", "tileset.json"], check=True)
# CRS parity: the root transform origin must round-trip back to the source lon/lat.
with open("tileset.json") as f:
t = json.load(f)["root"]["transform"]
x, y, z = t[12], t[13], t[14]
from_ecef = Transformer.from_crs("EPSG:4978", "EPSG:4326", always_xy=True)
lon, lat, _ = from_ecef.transform(x, y, z)
assert abs(lon - (-73.9857)) < 1e-4 and abs(lat - 40.7484) < 1e-4, "tile anchor CRS drift"
Verify the vector derivative preserved both attributes and CRS — ogrinfo should report EPSG:4326 and the original field set:
import subprocess
out = subprocess.run(
["ogrinfo", "-so", "-al", "buildings_4326.geojson"],
capture_output=True, text=True, check=True,
).stdout
for field in ("asset_id", "construction_year"):
assert field in out, f"attribute '{field}' lost in conversion"
Expected results: the validator exits 0, the anchor round-trips to within 1e-4 degrees (~10 m at this latitude is far looser than the 1e-4° assertion, which is ~11 m — tighten to 1e-6 for survey work), and every authored field appears in ogrinfo. A non-zero validator exit or a missing field must fail the build.
Performance & Scale
Format choice dominates payload size and client memory. For a 50,000-triangle textured building, an uncompressed .glb runs ~3.4 MB; the same mesh with KHR_draco_mesh_compression drops to ~0.4–0.6 MB — a 6–8x reduction — at the cost of ~15–40 ms decode per tile on the client. EXT_meshopt_compression trades a slightly larger payload (~0.7 MB) for near-zero decode latency, which is the better choice when the client is GPU-bound rather than bandwidth-bound.
LAS to LAZ compression with LASzip is lossless and typically 7–10x; a 1 km² urban tile at 16 pts/m² is ~120 MB as .las and ~14 MB as .laz, and Cloud-Optimized Point Cloud (COPC) layout adds spatial chunking so a client reads only the octree nodes in view.
At city scale, never hold the full model in memory. Tile to ~1 km² footprints, generate one tileset per district, and reference them from a parent tileset.json so the client streams by frustum. CityGML and IFC have no streaming path — convert them to 3D Tiles offline in a CI/CD job, chunking by feature with numpy.memmap-backed buffers or a dask bag when a single source file exceeds RAM. The detailed tiling step is covered under automated tile generation within LOD management.
Schema validation cost is also a scale factor that teams underestimate. Validating a CityGML file against its full XSD is O(document size) and can take tens of seconds for a large municipal export, so run it once at ingestion rather than on every CI build. The 3d-tiles-validator is comparatively cheap per tile but multiplies across thousands of tiles — validate a representative sample plus every tile touched by a change set, not the entire pyramid, to keep build times bounded. For LAS/LAZ, prefer COPC’s spatial chunking so validation and rendering both read only the octree nodes a query needs, which keeps verification linear in the viewport rather than in the full cloud.
Failure Modes & Gotchas
- Attribute loss on glTF export. glTF has no semantic schema, so any field not explicitly written to
extrasor aKHR_*extension is silently dropped. A building’sconstruction_yearandasset_idvanish unless you map them — assert their presence after every export, treating a missing field as a build failure. - IFC has no CRS. IfcOpenShell returns coordinates in IFC’s project-local frame. If you export to glTF or 3D Tiles without applying the
IfcMapConversion/IfcProjectedCRS(or an externally supplied EPSG transform), the building lands at the origin of the map or floats kilometres off. Always georeference explicitly on import. - 3D GeoJSON forces EPSG:4326. RFC 7946 mandates WGS84 (CRS84) and ignores any embedded
crsmember. Writing metric UTM coordinates into a.geojsonand assuming readers honour a custom CRS produces points off the coast of Africa. Reproject withogr2ogr -t_srs EPSG:4326before serializing. - OBJ silently strips units and CRS. OBJ is dimensionless ASCII with no spatial reference. A mesh exported to OBJ and re-imported loses its metric scale and EPSG entirely; treat OBJ only as a throwaway intermediate, never as an authoritative store.
- Draco quantization clobbers precision. Default Draco position quantization (11–14 bits) is fine for visual meshes but can shift survey-grade vertices by centimetres. For metrology, raise the quantization bits or keep an uncompressed canonical copy and ship Draco only as a derived web payload.
Frequently Asked Questions
Which format should be my single internal source of truth?
None of the delivery formats. Keep a canonical store that holds geometry, attributes, and an explicit CRS — commonly a GeoPackage for attributes plus uncompressed glTF or the original survey product for geometry — and generate 3D Tiles, Draco glTF, and 3D GeoJSON as versioned, disposable derivatives. That way a quantization or schema bug in one output never corrupts your authoritative data.
How do I convert CityGML or IFC to 3D Tiles without losing semantics?
Map the source attributes explicitly into the tileset’s feature-table/metadata or each glTF node’s extras during conversion. For IFC, parse property sets with IfcOpenShell and apply the georeferencing (IfcMapConversion or an external EPSG transform) before export. For CityGML, carry the gml:id and ADE attributes into batch IDs. Then assert every required field survives — see the Validation & Verification checks above.
Is glTF or 3D Tiles the right choice for a web twin?
They are complementary, not alternatives. glTF is the geometry payload; 3D Tiles is the spatial index and streaming wrapper around glTF. A single building can ship as glTF; a city must ship as 3D Tiles so the client pages by screen-space error. The full trade-off, including OBJ, is in glTF vs 3D Tiles vs OBJ for spatial data.
Does LAZ lose any data compared to LAS?
No. LAZ (LASzip) is lossless — it reconstructs every coordinate, classification, and intensity value bit-for-bit, typically at 7–10x compression. The lossy trade-off only appears if you reduce coordinate scale/offset precision in the LAS header before compressing, which is a separate decision from the LAZ encoding itself.
How do I keep CRS metadata intact across every conversion?
Declare the EPSG code at each stage and verify it on the other side. Use ogr2ogr -s_srs/-t_srs with explicit codes for vector formats, anchor 3D Tiles via a transform you can round-trip with pyproj, and write source_crs into glTF extras. The transformation mechanics are covered in Coordinate Reference Systems for 3D Assets and the practical step of converting WGS84 to local projected coordinates.
Related Guides
- glTF vs 3D Tiles vs OBJ for Spatial Data — the head-to-head on the three web-delivery formats
- Coordinate Reference Systems for 3D Assets — CRS and datum handling that every conversion depends on
- Digital Elevation Model Workflows — terrain raster generation and registration
- Automated Tile Generation for 3D Geospatial — turning canonical geometry into streamable tilesets
- Point Cloud & Mesh Processing Pipelines — producing the meshes these formats carry