Skip to content
Merged
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: 3 additions & 2 deletions docs/CITYJSON_INTEROP.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,9 +202,10 @@ LIMIT 10;
make test_full
```

Four test files are gated on `require cityjson`
Five test files are gated on `require cityjson`
(`cityjson_interop`, `cityjson_hollow_solid`, `cityjson_multisolid`,
`cityjson_delft_remote`), and one on `require spatial` (`spatial_coexist`).
`cityjson_3dbag_attributes`, `cityjson_delft_remote`), and one on
`require spatial` (`spatial_coexist`).
Under `make test` / `make test_debug` they skip; `make test_full` does the setup
that makes them execute, and treats a skip as a failure. It needs network access
— the httpfs/spatial download on the first run, and the remote Delft fixture on
Expand Down
8 changes: 6 additions & 2 deletions docs/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -523,7 +523,11 @@ FROM good WHERE n_parts = 1 AND b3_volume_lod22 > 0 AND b3_opp_grond > 0;
```

The automated version of this check is
[`test/sql/cityjson_delft_remote.test`](../test/sql/cityjson_delft_remote.test).
[`test/sql/cityjson_delft_remote.test`](../test/sql/cityjson_delft_remote.test), which extends
it to volume at LoD1.2 and LoD1.3, surface area against the five `b3_opp_*` attributes summed,
`ST_3DZMin` against `b3_h_maaiveld` and the LoD1.2 extrusion height against `b3_h_dak_70p`.
[`test/sql/cityjson_3dbag_attributes.test`](../test/sql/cityjson_3dbag_attributes.test) runs
the same comparisons offline, over the frozen nine-building slice.

## 14 — Transforms: rigid motions and the cube law

Expand Down Expand Up @@ -1095,7 +1099,7 @@ The behaviours it demonstrates are covered automatically as follows:
| §2 | `test/sql/cityjson_hollow_solid.test`, `test/sql/st_3d_hollow_solid.test`, `test/cpp/test_inner_shell.cpp` |
| §3 | `test/sql/cityjson_multisolid.test`, `test/sql/st_3d_multisolid.test` |
| §5, §6, §17 | `test/sql/geom_3d_accessors.test`, `geom_3d_measurements.test`, `geom_3d_serialize.test` |
| §8–§13 | `test/sql/cityjson_delft_remote.test` (the 3DBAG oracle), `test/sql/st_3d_validation.test`, `test/sql/geom_3d_distance.test` |
| §8–§13 | `test/sql/cityjson_delft_remote.test` and `test/sql/cityjson_3dbag_attributes.test` (the 3DBAG oracle, remote and offline), `test/sql/st_3d_validation.test`, `test/sql/geom_3d_distance.test` |
| §14 | `test/sql/metamorphic_transforms.test` — **extended by this walkthrough** with the RotateX/RotateY cases from the quirks section |
| §14 (kernel) | `test/cpp/test_measurements.cpp` — **added by this walkthrough**: far-from-origin precision |
| §15 | `test/sql/st_transform.test`, `test/cpp/test_crs_transform.cpp` |
Expand Down
42 changes: 35 additions & 7 deletions docs/TEST_COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Suite layout and the TDD workflow are in [AGENTS.md](../AGENTS.md); this file re
| `test/cpp/` | `make test_cpp`, `make test_all`, `make test_full` | Kernel logic with no database: WKB parsing, model construction, payload round-trips, validation, triangulation, area/volume math |
| `test/sql/` | `make test`, `make test_debug`, `make test_full` (release, gated tests included) | SQL surface: binding, null propagation, `TRY` semantics, result contracts, interop |

Four tests gated on `require cityjson` skip unless a **locally built** `duckdb-cityjson` is
Five tests gated on `require cityjson` skip unless a **locally built** `duckdb-cityjson` is
staged for the sqllogic runner — the community-published extension emits an older column
shape these tests no longer target. `make test_full` stages it (and fails the run on any
skip); see [CITYJSON_INTEROP.md](./CITYJSON_INTEROP.md#running-the-gated-tests-under-sqllogic)
Expand All @@ -25,13 +25,40 @@ Three independent sources, each authoritative for a different class of claim.

### 3DBAG published attributes — real-geometry measurement

`test/sql/cityjson_delft_remote.test` streams the 3DBAG Delft tile and compares
`ST_3DVolume` against `b3_volume_lod22` and `ST_3DFootprintArea` against `b3_opp_grond`.
This is the oracle of record for measurement on **real reconstructed geometry**, because
SFCGAL rejects most real roofs for non-planarity while this extension measures them by
triangulation.
3DBAG publishes, per building, what its own reconstruction pipeline measured. Those figures
are an independent toolchain's answer for the very geometry it shipped, which makes them the
oracle of record for measurement on **real reconstructed geometry** — the case SFCGAL cannot
serve, because it rejects most real roofs for non-planarity while this extension measures
them by triangulation.

Requires network access and the `cityjson` extension.
| Attribute | Oracles |
| --- | --- |
| `b3_volume_lod12`, `b3_volume_lod13`, `b3_volume_lod22` | `ST_3DVolume`, at each LoD |
| `b3_opp_grond` + `b3_opp_dak_plat` + `b3_opp_dak_schuin` + `b3_opp_buitenmuur` + `b3_opp_scheidingsmuur` | `ST_3DSurfaceArea` / `ST_3DArea` at LoD2.2 — every face of that solid carries exactly one of the five semantic roles, so they sum to its whole boundary |
| `b3_opp_grond` | `ST_3DFootprintArea` — the ground surface is horizontal, so its 3D area and its XY projection are one number |
| `b3_h_maaiveld` | `ST_3DZMin`, at every LoD |
| `b3_h_dak_70p` | `ST_3DZMax` at LoD1.2 — the height the block was extruded to |
| `b3_val3dity_lod22` | `ST_3DValidationReport` — one-way: what val3dity passes, this extension must pass |

Two tests run those comparisons.

- **`test/sql/cityjson_3dbag_attributes.test`** — offline, over the frozen nine-building slice
in `test/data/3dbag.city.jsonl`, at the tolerances that slice actually meets (0.1 % on
volume, 1 % on surface area, 2 mm on ZMin) plus string-exact per-building pins. Needs the
`cityjson` extension but **no network**, so it is the one that runs anywhere.
- **`test/sql/cityjson_delft_remote.test`** — the same comparisons at tile scale, streamed
from the remote Delft tile. At that scale the thresholds are fractions ("≥95 % of buildings
within 2 %") rather than bounds, because a tile contains reconstructions that are simply
broken. Requires network access.

The val3dity cross-check is a fraction, not a bound, and deliberately: the two disagree on a
handful of tile solids that carry a single collapsed face. val3dity tolerates it; this
extension reports the duplicated edge it creates as non-manifold and refuses to measure the
solid, which is the documented no-silent-repair behaviour rather than a defect.

Numeric pins in both files are formatted with `printf` and compared as text. sqllogic compares
numeric results with a **1 % relative tolerance**, so a bare `ROUND(...)` pin admits drift an
order of magnitude larger than the decimals it displays.

### PostGIS + SFCGAL — analytic measurement math

Expand Down Expand Up @@ -111,6 +138,7 @@ over the whole frozen 3DBAG corpus.
| `ST_AsWKBMultiCube(separation)` | Volume conditioning when a collection's parts are spatially separated — the per-shell reference point of DESIGN_DOC §8.2. Total volume stays 16 at separations of 1e4–1e10, and the 1e6 case is oracled against SFCGAL as `fixture:multi_cube_far` |
| `test/data/multisolid.city.json`, `compositesolid.city.json` | `MultiSolid` / `CompositeSolid` import per solid, with shell grouping recovered from the sidecar (`test/sql/cityjson_multisolid.test`). Import does **not** raise on either class |
| `test/data/unit_cube.city.json` | End-to-end `cityjson` → `three_d` smoke test |
| `test/data/3dbag.city.jsonl` | Nine real 3DBAG buildings at LoD1.2 / 1.3 / 2.2. The WKB the PostGIS oracle freezes as its `NL.IMBAG.Pand.*` rows comes from here, and `test/sql/cityjson_3dbag_attributes.test` measures the same nine against 3DBAG's published attributes offline |

## Open work

Expand Down
196 changes: 196 additions & 0 deletions test/sql/cityjson_3dbag_attributes.test
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
# name: test/sql/cityjson_3dbag_attributes.test
# description: three_d measurements cross-checked against 3DBAG's own published attributes, on the frozen local tile slice
# group: [sql]

# ─────────────────────────────────────────────────────────────────────────────
# 3DBAG publishes, per building, the figures its own reconstruction pipeline
# measured: volume per LoD, the areas of ground / roof / wall surfaces, and the
# ground elevation. Those are an oracle computed by an independent toolchain on
# the same geometry, so they check `ST_3D*` measurement end to end.
#
# `test/data/3dbag.city.jsonl` is a frozen nine-building slice — the same nine
# solids the PostGIS oracle freezes as its `NL.IMBAG.Pand.*` rows
# (test/data/postgis_oracle/README.md). SFCGAL rejects eight of the nine for
# non-planarity and measures neither their area nor their volume, so for those
# this is the only oracle there is. It is also offline: unlike
# cityjson_delft_remote.test it needs no network, so the tolerances are pinned
# to what this frozen slice actually produces rather than left loose for drift.
#
# Which attribute checks which function:
#
# b3_volume_lod12/13/22 ST_3DVolume, per LoD
# b3_opp_grond + dak_plat + dak_schuin
# + buitenmuur + scheidingsmuur ST_3DSurfaceArea at LoD2.2
# b3_opp_grond ST_3DFootprintArea
# b3_h_maaiveld ST_3DZMin
# b3_h_dak_70p ST_3DZMax at LoD1.2
# b3_val3dity_lod22 ST_3DValidationReport
#
# The area identity holds at LoD2.2 only: the five `b3_opp_*` attributes
# describe the LoD2.2 model, and every face of that solid carries exactly one of
# those semantic roles, so they sum to its whole boundary. The LoD1.2/1.3 blocks
# extrude to a single roof height and their walls are a different surface.
#
# Requires the `cityjson` extension — a build of the sibling duckdb-cityjson
# checkout, which emits the per-LoD geometry_lod<X> columns read below. See
# docs/CITYJSON_INTEROP.md.
# ─────────────────────────────────────────────────────────────────────────────

require cityjson

require three_d

# One read yields every LoD present. Geometry lives on the `BuildingPart`
# features and the attributes on the parent `Building`, so each part is joined
# back to its parent.
statement ok
CREATE TABLE feats AS SELECT * FROM read_cityjsonseq('test/data/3dbag.city.jsonl');

statement ok
CREATE TABLE parts AS
SELECT p.id,
ST_3DFromWKB(p.geometry_lod1_2, p.geometry_properties_lod1_2) AS s12,
ST_3DFromWKB(p.geometry_lod1_3, p.geometry_properties_lod1_3) AS s13,
ST_3DFromWKB(p.geometry_lod2_2, p.geometry_properties_lod2_2) AS s22,
b.b3_volume_lod12, b.b3_volume_lod13, b.b3_volume_lod22,
b.b3_opp_grond,
b.b3_opp_grond + b.b3_opp_dak_plat + b.b3_opp_dak_schuin
+ b.b3_opp_buitenmuur + b.b3_opp_scheidingsmuur AS b3_opp_totaal,
b.b3_h_maaiveld, b.b3_h_dak_70p,
CAST(b.b3_val3dity_lod22 AS VARCHAR) AS b3_val3dity_lod22,
len(b.children) AS n_parts
FROM feats p
JOIN feats b ON b.id = p.parents[1]
WHERE p.geometry_lod2_2 IS NOT NULL;

# The slice is nine single-part buildings, each carrying all three LoDs, and
# every geometry imports. Attributes describe the whole Building, so a
# multi-part building would need its parts summed before any comparison below
# held — this slice has none, and the assertion keeps it that way.
query IIIII
SELECT count(*), count(s12), count(s13), count(s22), max(n_parts) FROM parts;
----
9 9 9 9 1

# ─────────────────────────────────────────────────────────────────────────────
# Volume, per LoD. Measured agreement on this slice is within 0.035 % at every
# LoD; the 0.1 % bound leaves room for the vertex quantisation 3DBAG applies on
# export (scale 0.001) without admitting a real regression.
#
# Each assertion lists the buildings that fail, so a failure names its rows.
# ─────────────────────────────────────────────────────────────────────────────
query I
SELECT id FROM parts
WHERE abs(ST_3DVolume(s12) - b3_volume_lod12) > 0.001 * b3_volume_lod12
ORDER BY id;
----

query I
SELECT id FROM parts
WHERE abs(ST_3DVolume(s13) - b3_volume_lod13) > 0.001 * b3_volume_lod13
ORDER BY id;
----

query I
SELECT id FROM parts
WHERE abs(ST_3DVolume(s22) - b3_volume_lod22) > 0.001 * b3_volume_lod22
ORDER BY id;
----

# ─────────────────────────────────────────────────────────────────────────────
# Surface area at LoD2.2 against the five semantic-surface areas summed. This is
# the only oracle `ST_3DSurfaceArea` has on real reconstructed geometry.
# Measured worst case on this slice is 0.55 %, on buildings where 3DBAG's
# published roof area disagrees with the roof it shipped; 1 % is the bound.
# ─────────────────────────────────────────────────────────────────────────────
query I
SELECT id FROM parts
WHERE abs(ST_3DSurfaceArea(s22) - b3_opp_totaal) > 0.01 * b3_opp_totaal
ORDER BY id;
----

# Footprint area against the published ground area. The ground surface is
# horizontal, so its 3D area and its XY projection are the same number, and
# agreement is near-exact — within 0.005 m² measured, 0.02 m² asserted.
query I
SELECT id FROM parts
WHERE abs(ST_3DFootprintArea(s22) - b3_opp_grond) > 0.02
ORDER BY id;
----

# The footprint does not move between LoDs: the same ground surface is carried
# at every level, remeshed but not reshaped.
query I
SELECT id FROM parts
WHERE abs(ST_3DFootprintArea(s22) - ST_3DFootprintArea(s12)) > 0.01
OR abs(ST_3DFootprintArea(s22) - ST_3DFootprintArea(s13)) > 0.01
ORDER BY id;
----

# ─────────────────────────────────────────────────────────────────────────────
# Heights. ZMin is the ground elevation 3DBAG publishes, to within the 1 mm
# quantisation step of the file's transform. ZMax at LoD1.2 is the 70th
# percentile roof elevation the block was extruded to — reconstruction snaps
# that height, so it is checked at 5 cm, not at the quantisation step.
# ─────────────────────────────────────────────────────────────────────────────
query I
SELECT id FROM parts
WHERE abs(ST_3DZMin(s22) - b3_h_maaiveld) > 0.002
OR abs(ST_3DZMin(s12) - b3_h_maaiveld) > 0.002
OR abs(ST_3DZMin(s13) - b3_h_maaiveld) > 0.002
ORDER BY id;
----

query I
SELECT id FROM parts
WHERE abs(ST_3DZMax(s12) - b3_h_dak_70p) > 0.05
ORDER BY id;
----

# ─────────────────────────────────────────────────────────────────────────────
# Validity. `b3_val3dity_lod22` is val3dity's error list for the LoD2.2 solid,
# and `[]` means it found none. val3dity checks strictly more than this
# extension does, so the implication runs one way only: what val3dity passes,
# ST_3DValidationReport must also pass. All nine are clean under both.
# ─────────────────────────────────────────────────────────────────────────────
query I
SELECT id FROM parts
WHERE b3_val3dity_lod22 = '[]' AND NOT ST_3DValidationReport(s22).is_valid
ORDER BY id;
----

query I
SELECT count(*) FROM parts WHERE b3_val3dity_lod22 = '[]';
----
9

# ─────────────────────────────────────────────────────────────────────────────
# Per-building pins. Face counts are structural — they come from the file, not
# from a measurement. The measured figures are formatted to a fixed number of
# decimals and compared as text: sqllogic compares numeric results with a 1 %
# relative tolerance, which would wave through drift far larger than anything
# the assertions above allow, so every pinned number here is a string.
# ─────────────────────────────────────────────────────────────────────────────
query TIITTTTTT
SELECT id,
ST_3DNumFaces(s12), ST_3DNumFaces(s22),
printf('%.2f', ST_3DVolume(s22)), printf('%.2f', b3_volume_lod22),
printf('%.2f', ST_3DSurfaceArea(s22)), printf('%.2f', ST_3DFootprintArea(s22)),
printf('%.3f', ST_3DZMin(s22)), printf('%.3f', ST_3DZMax(s22))
FROM parts ORDER BY id;
----
NL.IMBAG.Pand.0703100000027940-0 10 10 1399.54 1399.74 876.41 196.79 2.560 10.400
NL.IMBAG.Pand.0703100000028278-0 15 40 3474.99 3475.19 1805.90 581.67 3.722 11.074
NL.IMBAG.Pand.0703100000028817-0 6 6 1545.20 1545.00 1162.05 336.02 2.547 7.439
NL.IMBAG.Pand.0703100000030681-0 6 6 550.97 551.01 470.15 88.74 -1.666 4.622
NL.IMBAG.Pand.0703100000031557-0 6 6 46.82 46.82 79.10 15.21 2.835 5.921
NL.IMBAG.Pand.0703100000031696-0 6 6 60.34 60.36 94.10 15.46 2.412 6.349
NL.IMBAG.Pand.0703100000035296-0 8 8 37.25 37.26 70.32 14.86 4.329 6.861
NL.IMBAG.Pand.0703100000035935-0 6 6 375.06 375.19 420.82 143.92 3.296 5.902
NL.IMBAG.Pand.0703100000193621-0 8 12 1483.16 1483.55 1049.01 251.22 3.279 15.133

statement ok
DROP TABLE parts;

statement ok
DROP TABLE feats;
Loading
Loading