This is a hands-on walkthrough of the three_d extension against real 3D city
models: the 3DBAG reconstruction of Delft, streamed straight
from a remote server as CityJSONSeq and turned into queryable solids with the
cityjson extension.
For the formal contract of every function, see FUNCTIONS.md. For the extension-composition mechanics and troubleshooting, see CITYJSON_INTEROP.md.
three_d never parses CityJSON itself. The division of labour is:
cityjsonreads CityJSON / CityJSONSeq and, in per-LoD mode (lod => '...'), emits onegeometry_lod<X> BLOB(WKB) column per LoD present, each paired with a sidecargeometry_properties_lod<X> STRUCT.three_dconsumes that pair throughST_3DFromWKB(geometry_lod<X>, geometry_properties_lod<X>)and gives you aSOLID_3Dyou can validate, measure, and transform.
CityJSON ──cityjson──▶ (geometry_lod2_2 BLOB, geometry_properties_lod2_2 STRUCT) ──three_d──▶ SOLID_3D / GEOM_3D
The suffix is the normalised LoD, so lod => '2.2' gives _lod2_2 and lod => '2'
gives _lod2_0 — not _lod2.
Build both extensions from source. They pin the same DuckDB core, so the two load into one process without an ABI complaint:
GEN=ninja make # ./build/release/duckdb, three_d linked in
(cd ../duckdb-cityjson && GEN=ninja make release)cityjson is loaded by path — the published community build is a different, older
extension that still emits a flat geometry column, and none of the SQL below binds
against it:
LOAD '../duckdb-cityjson/build/release/extension/cityjson/cityjson.duckdb_extension';
LOAD three_d;On macOS debug builds, add
ASAN_OPTIONS=detect_container_overflow=0in front of the shell command when reading remote files — see the caveats in CITYJSON_INTEROP.md.
Before touching the network, confirm the pipeline on the bundled unit-cube fixture:
SELECT id,
ST_3DNumFaces(solid) AS faces,
ST_3DIsClosed(solid) AS closed,
ROUND(ST_3DSurfaceArea(solid), 3) AS area,
ROUND(ST_3DVolume(solid), 3) AS volume
FROM (
SELECT id, ST_3DFromWKB(geometry_lod2_2, geometry_properties_lod2_2) AS solid
FROM read_cityjson('test/data/unit_cube.city.json', lod => '2.2')
WHERE geometry_lod2_2 IS NOT NULL
);┌──────┬───────┬────────┬───────┬────────┐
│ id │ faces │ closed │ area │ volume │
├──────┼───────┼────────┼───────┼────────┤
│ cube │ 6 │ true │ 6.0 │ 1.0 │
└──────┴───────┴────────┴───────┴────────┘
The 3DBAG Delft tile is published as CityJSONSeq. Read it directly by URL, in LoD 2.2:
CREATE TABLE feats AS
SELECT id, parents, children,
geometry_lod2_2 AS geometry,
geometry_properties_lod2_2 AS geometry_properties,
b3_volume_lod22, b3_opp_grond, b3_h_maaiveld -- 3DBAG ground-truth attributes
FROM read_cityjsonseq(
'https://cityjson.open3d.city/cityjsonseq/delft.city.jsonl',
lod => '2.2');Important structural detail. In 3DBAG the geometry lives on BuildingPart
features, while the descriptive attributes (b3_volume_lod22, height percentiles,
areas) live on the parent Building. When cityjson flattens the sequence, the
geometry row and the attribute row are different rows linked by parents /
children. Join a part to its parent to line geometry up with ground truth:
CREATE TABLE parts AS
SELECT p.id,
ST_3DTryFromWKB(p.geometry, p.geometry_properties) AS solid,
b.b3_volume_lod22,
b.b3_opp_grond,
len(b.children) AS n_parts
FROM feats p
JOIN feats b ON b.id = p.parents[1]
WHERE p.geometry IS NOT NULL;Aliasing the two per-LoD columns to geometry / geometry_properties once, as above, is
worth doing when a query mentions them repeatedly.
Use ST_3DTryFromWKB (not ST_3DFromWKB) when exploring real data: it returns NULL
on an unsupported geometry instead of aborting the whole query.
SELECT id,
ROUND(ST_3DVolume(solid), 1) AS volume_m3,
ROUND(ST_3DFootprintArea(solid), 1) AS footprint_m2,
ROUND(ST_3DZMax(solid) - ST_3DZMin(solid), 2) AS height_m, -- roof − ground
ST_3DIsClosed(solid) AS closed
FROM parts
WHERE ST_3DValidationReport(solid).is_valid
LIMIT 5;Key expressions:
| Metric | Expression | Notes |
|---|---|---|
| Enclosed volume | ST_3DVolume(solid) |
Requires a valid (closed + manifold + oriented) solid. |
| Footprint area | ST_3DFootprintArea(solid) |
2D XY projection; the building's ground area. |
| Building height | ST_3DZMax(solid) - ST_3DZMin(solid) |
From the cached bounding box. |
| 3D surface area | ST_3DArea(solid) / ST_3DSurfaceArea(solid) |
Total area of all faces. |
ST_3DVolume raises on a non-manifold or open solid — real reconstructions always
contain a few. Guard measurements with the validation report:
-- How healthy is the tile?
SELECT count(*) AS parts,
count(*) FILTER (WHERE ST_3DValidationReport(solid).is_valid) AS valid,
count(*) FILTER (WHERE ST_3DIsClosed(solid)) AS closed
FROM parts;The report is a struct — inspect any building that fails:
SELECT id, ST_3DValidationReport(solid) AS report
FROM parts
WHERE NOT ST_3DValidationReport(solid).is_valid
LIMIT 3;
-- report = {is_valid, is_closed, is_manifold, is_oriented, solid_count, shell_count,
-- face_count, open_edge_count, non_manifold_edge_count, degenerate_face_count,
-- orientation_error_count, code, message}Compute total built volume over only the valid solids:
SELECT ROUND(SUM(ST_3DVolume(solid)), 0) AS total_volume_m3
FROM parts
WHERE ST_3DValidationReport(solid).is_valid;3DBAG ships each building's own computed volume (b3_volume_lod22) and ground-surface
area (b3_opp_grond). three_d's independent kernel agrees with them almost exactly —
for single-part, valid buildings the median volume error is 0.017 %:
SELECT ROUND(median(
abs(ST_3DVolume(solid) - b3_volume_lod22) / b3_volume_lod22
) * 100, 3) AS median_volume_error_pct
FROM parts
WHERE n_parts = 1
AND ST_3DValidationReport(solid).is_valid
AND b3_volume_lod22 > 0;
-- 0.017 %This double-checks both the data and the extension. The automated version of this check
lives in test/sql/cityjson_delft_remote.test.
Distance and relationship functions operate on the general geometry type GEOM_3D, which
you build from WKB with ST_Geom3DFromWKB. Minimum 3D distance between two named
buildings:
SELECT ROUND(ST_3DDistance(
ST_Geom3DFromWKB((SELECT geometry FROM feats WHERE id = 'NL.IMBAG.Pand.0503100000012869-0')),
ST_Geom3DFromWKB((SELECT geometry FROM feats WHERE id = 'NL.IMBAG.Pand.0503100000016459-0'))
), 1) AS gap_m; -- ≈ 1033.7Find every building within 5 m of a reference building (ST_3DDWithin short-circuits on
distance):
WITH ref AS (
SELECT ST_Geom3DFromWKB(geometry) AS g
FROM feats WHERE id = 'NL.IMBAG.Pand.0503100000012869-0'
)
SELECT p.id
FROM feats p, ref
WHERE p.geometry IS NOT NULL
AND ST_3DDWithin(ST_Geom3DFromWKB(p.geometry), ref.g, 5.0);Related functions on GEOM_3D: ST_3DMaxDistance, ST_3DDFullyWithin,
ST_3DIntersects, ST_3DClosestPoint, ST_3DShortestLine.
GEOM_3D carries accessors and serializers for debugging and interchange:
SELECT ST_3DGeometryType(g) AS gtype, -- ST_PolyhedralSurface
ST_NDims(g) AS dims, -- 3
ST_3DAsText(ST_3DCentroid(g)) AS centroid -- POINT Z (84595.382 446461.183 1.82866198)
FROM (SELECT ST_Geom3DFromWKB(geometry) AS g
FROM feats WHERE id = 'NL.IMBAG.Pand.0503100000012869-0');ST_3DAsText(geom)→ ISO WKT (with Z)ST_3DAsGeoJSON(geom)→ GeoJSON (aPolyhedralSurfaceis emitted as aMultiPolygon)ST_3DAsBinary(geom)→ OGC/ISO WKBST_3DAsWKB(solid)→ WKB from the canonicalSOLID_3Dmodel
Placement and geometric edits are per-vertex and preserve topology:
-- shift a building 100 m east, 50 m north
SELECT ST_3DTranslate(solid, 100, 50, 0) FROM parts LIMIT 1;Available transforms: ST_3DTranslate, ST_3DScale, ST_3DRotateX/Y/Z, ST_Force3D,
ST_3DConvexHull (2D XY hull), ST_IsPlanar.
Construction:
ST_3DExtrude(footprint_polygon, height)— extrude a 2D footprint (GEOM_3DPolygon Z) into a closed LoD1 prismSOLID_3D. Extruding a 4 m × 3 m footprint by 3 m yields a closed 6-face box of volume 36.ST_MakeSolid(surface)— promote a closed/manifold/orientedPolyhedralSurfaceto aSOLID_3D(no silent repair; raises if it is not solid-eligible).
- Always pass
lod => '...'toread_cityjson/read_cityjsonseq. Without it,cityjsonemits per-LoDgeom_lod*structs thatST_3DFromWKBcannot consume. - The LoD suffix is normalised.
lod => '2'yieldsgeometry_lod2_0, and the object may carry several LoDs at once —DESCRIBEthe reader to see which columns exist. - Filter
WHERE geometry_lod<X> IS NOT NULLupstream — objects lacking the requested LoD have a NULL geometry. - Load
cityjsonby path from the sibling build. The community-published extension is older and uses the flatgeometry/geometry_properties VARCHARshape; this document targets only the current one. - Attributes are on the parent
Building, geometry is on theBuildingPart; join viaparents/children(§2). - Validate before
ST_3DVolume/ST_3DSurfaceArea— they raise on invalid solids. UseST_3DTryFromWKBandST_3DValidationReportwhile exploring. SOLID_3D-only vsGEOM_3D-only: solid metrics (ST_3DVolume,ST_3DIsClosed,ST_3DBounds) take the solid payload; distance/serialization accessors takeGEOM_3D(build it withST_Geom3DFromWKB). Many accessors (ST_NDims,ST_3DZMin,ST_3DZMax,ST_3DTranslate, …) accept both.