Script 03: Surface Attributes¶
File: sql/scripts/main/03_calc_child_feat_attr.sql
Reads from: {city2tabula_schema}.{lod_schema}_child_feature_geom_dump
Writes to: {city2tabula_schema}.{lod_schema}_surface_raw
Purpose¶
For each individual polygon face (from script 02), this script computes these physical attributes:
- Tilt: the surface's angle from vertical (0° = vertical wall, 90° = flat horizontal roof). This is the opposite of the usual from-horizontal slope convention; see the box below.
- Azimuth: the compass direction the surface faces (0°/360° = North, 90° = East, 180° = South, 270° = West).
- Surface area: the true 3D face area in square metres.
- Height span: the vertical range of the face (Z max − Z min) in metres.
- Horizontal and slope extent: the face's extent along the horizontal and along the line of steepest slope in its own plane, in metres.
It then marks the faces that lie between two solids of the same building as internal (see Internal faces).
Every attribute except height span is derived from the surface normal: a vector that points perpendicularly outward from the face. The bulk of this script's CTEs are dedicated to computing that normal correctly.
Tilt convention: 0° = wall, 90° = flat roof
tilt is DEGREES(ASIN(ABS(nz))), where nz is the vertical component of
the unit surface normal. It is the angle between the surface normal and the
horizontal plane, or equivalently the angle between the surface and the
vertical. A vertical wall is 0°, a flat roof or floor is 90°, a 45° pitched
roof is 45°.
Most building-energy calculations (including what ignis and BuEM expect)
use the opposite reference: the surface tilt measured from the horizontal,
where a flat roof is 0° and a wall is 90°. The two are complements, so
convert with 90 − tilt. They coincide only at 45°. Whoever feeds c2t
surface output to ignis or BuEM must apply the conversion. The same note is
on the Surface type in internal/onrequest/query.go and in docs/api.md.
Background: what is a surface normal?¶
The surface normal is a stick set perpendicular to a face and pointing away from the building. It encodes two things:
- Its vertical component (how much it points up or down) determines tilt.
- Its horizontal direction (which compass bearing it points toward) determines azimuth.
For the normal to be useful, it must:
- Be correctly computed from the polygon's vertices.
- Point outward (away from the building), not inward.
Both of these requirements require care when working with real-world CityGML data, which is why script 03 has eight CTEs.
Background: Newell's method¶
Why not a simple cross product to compute the normal vector?¶
The simplest way to compute a surface normal is to take three polygon vertices and compute their cross product. For a flat (planar) polygon this works, but CityGML surfaces are sometimes slightly non-planar due to data quality issues (vertices don't lie perfectly on a plane). Picking an arbitrary triplet on a warped surface gives an inconsistent result.
Newell's method¶
Newell's method solves this by accumulating a contribution from every consecutive edge pair around the polygon ring:
n_x = SUM over all edges: (y_i − y_{i+1}) × (z_i + z_{i+1})
n_y = SUM over all edges: (z_i − z_{i+1}) × (x_i + x_{i+1})
n_z = SUM over all edges: (x_i − x_{i+1}) × (y_i + y_{i+1})
For a perfectly flat polygon this gives the same answer as a simple cross product. For a warped polygon it gives the best-fit normal that minimises the error across all vertices: equivalent to fitting a plane by least squares. It runs in O(n) time (one pass over the edges) and handles both cases uniformly.
Background: vertex winding order¶
The sign of a cross-product normal depends on the order in which vertices are listed: tracing the polygon boundary clockwise points the normal one way, counter-clockwise the other. CityGML datasets from different providers may use either convention, and sometimes even mix them. Without correction, a north-facing wall might be computed as south-facing (180° error) purely because its vertices happen to be listed in a different order.
This script corrects for this by comparing the computed normal against a known reference point inside the solid the face bounds (the interior point, derived from that solid's GroundSurface). If the normal points toward the interior rather than away from it, it is flipped.
CTE walkthrough¶
Step 1: new_buildings¶
SELECT DISTINCT building_feature_id
FROM {city2tabula_schema}.{lod_schema}_child_feature_geom_dump
WHERE building_feature_id IN {building_ids}
AND building_feature_id NOT IN (
SELECT DISTINCT building_feature_id
FROM {city2tabula_schema}.{lod_schema}_surface_raw
)
Idempotency guard: selects only buildings not yet present in the output table.
Step 2: owner_interior_pts¶
SELECT
owner_feature_id,
ST_PointOnSurface(ST_Collect(ST_Force2D(geom))) AS interior_pt
FROM ...
WHERE classname = 'GroundSurface'
GROUP BY owner_feature_id
For each solid (the Building or one of its BuildingParts), computes a single 2D reference point guaranteed to lie inside its ground footprint. It is taken per solid because, in a building of several parts, a point inside one part would flip the outward test for walls of another.
ST_Force2Dstrips Z coordinates, since only the horizontal position is needed.ST_Collectmerges all GroundSurface polygons into one geometry without dissolving them.ST_PointOnSurfacereturns a point on the surface that is guaranteed to be inside, even for non-convex shapes like L-shaped footprints (where the centroid can fall outside).
This point is used in oriented_normals to check whether a wall's computed normal points outward or inward.
Step 3: raw_surfaces¶
SELECT
...,
gd.geom AS valid_geom,
ST_IsPlanar(gd.geom) AS is_planar
FROM {city2tabula_schema}.{lod_schema}_child_feature_geom_dump gd
INNER JOIN new_buildings nb ON gd.building_feature_id = nb.building_feature_id
One row per polygon face from the geometry dump. ST_IsPlanar checks whether all vertices lie on a single plane. This flag is stored in the output for diagnostic purposes but does not change how the normal is computed: Newell's method handles both planar and non-planar polygons identically.
Step 4: surface_points¶
SELECT
*,
(ST_DumpPoints(valid_geom)).geom AS point_geom,
(ST_DumpPoints(valid_geom)).path[2] AS pt_idx
FROM raw_surfaces
ST_DumpPoints extracts every individual vertex from the polygon ring as a separate row. path[2] gives the sequential index of each vertex within the ring, so they can be walked in order.
Example: a quadrilateral face with vertices v1, v2, v3, v4 (plus v1 again to close the ring) produces five rows with pt_idx values 1, 2, 3, 4, 5.
Step 5: surface_edges¶
SELECT
...,
point_geom,
LEAD(point_geom) OVER (PARTITION BY id ORDER BY pt_idx) AS next_pt
FROM surface_points
LEAD is a window function that pairs each vertex row with the next vertex in the sequence. This turns the list of vertices into a list of directed edges (current_vertex → next_vertex). The last vertex in the ring is paired with NULL (there is no next vertex), which is filtered out in the next step.
Result: each row now represents one edge, described by its start and end point, ready for Newell's formula.
Step 6: surface_normals¶
SELECT
...,
SUM((ST_Y(point_geom) - ST_Y(next_pt)) * (ST_Z(point_geom) + ST_Z(next_pt))) AS n_x,
SUM((ST_Z(point_geom) - ST_Z(next_pt)) * (ST_X(point_geom) + ST_X(next_pt))) AS n_y,
SUM((ST_X(point_geom) - ST_X(next_pt)) * (ST_Y(point_geom) + ST_Y(next_pt))) AS n_z
FROM surface_edges
WHERE next_pt IS NOT NULL
GROUP BY id, ...
HAVING COUNT(*) >= 2
Applies Newell's formula across all edges of each polygon face. The three sums give the raw (un-normalised) normal vector (n_x, n_y, n_z).
The outer query then computes the vector's magnitude (sqrt(n_x² + n_y² + n_z²)) and discards surfaces where the magnitude is near zero, which means all vertices are collinear (i.e. the polygon has no area and no well-defined normal).
Step 7: oriented_normals¶
COALESCE(
CASE
WHEN (centroid_x - interior_x) * (n_x / magnitude)
+ (centroid_y - interior_y) * (n_y / magnitude)
>= 0 THEN 1.0
ELSE -1.0
END,
1.0
) AS surface_flip
For each WallSurface and RoofSurface, computes a dot product between:
- The horizontal component of the surface normal (nx, ny), and
- The vector from the solid's interior point to the surface centroid.
If the dot product is ≥ 0, the normal already points away from the interior (outward): surface_flip = +1, keep as-is. If the dot product is negative, the normal points inward: surface_flip = −1, flip it.
If no interior point exists (the building has no GroundSurface), COALESCE falls back to +1 (no flip).
Step 8: normalized_normals¶
CASE
WHEN classname = 'RoofSurface' AND (n_z / magnitude) < 0
THEN -(n_x / magnitude)
WHEN classname = 'WallSurface'
THEN surface_flip * (n_x / magnitude)
ELSE n_x / magnitude
END AS nx
Applies per-class rules to produce the final unit normal (nx, ny, nz):
- RoofSurface: if the Z component is negative (normal points downward), flip all three components. A roof normal must always point upward. The Z sign check is used here rather than the dot-product flip because, for near-flat roofs, the horizontal components (nx, ny) are tiny and sign-unstable.
- WallSurface: multiply all three components by
surface_flipfrom the previous step. This corrects the CW/CCW winding issue. - Other types: divide by magnitude to get a unit vector, no flip.
Final SELECT: computing the attributes¶
With the unit normal (nx, ny, nz) available, the attributes are:
-- Tilt: angle of the normal above the horizontal plane
CASE
WHEN ABS(nz) > 0.985 THEN 90.0 -- near-horizontal; snapped to flat
ELSE DEGREES(ASIN(ABS(nz)))
END AS tilt
nz is the vertical component of the unit normal. For a vertical wall, nz = 0, giving ASIN(0) = 0°. For a flat horizontal surface, |nz| = 1, giving ASIN(1) = 90°. For |nz| > 0.985 (tilt > ~80°), tilt is snapped to exactly 90° rather than the raw computed value. This is the same near-horizontal band that azimuth treats as undefined, and for the same reason: the residual angle there is noise rather than signal.
-- Azimuth: compass bearing of the horizontal normal component
CASE
WHEN ABS(nz) > 0.985 THEN -1 -- surface is near-horizontal; azimuth is undefined
ELSE MOD((450.0 - degrees(atan2(ny, nx))) + 360.0, 360.0)
END AS azimuth
atan2(ny, nx) computes the mathematical angle (counter-clockwise from East). The formula (450 − angle) mod 360 converts this to a compass bearing (clockwise from North). For nearly-flat surfaces (tilt > ~80°), the horizontal components are near zero and atan2 becomes numerically unreliable, so azimuth is set to -1 (undefined).
-- Surface area: projected onto the surface plane before measurement
{city2tabula_schema}.surface_area_corrected_geom(valid_geom, nx, ny, nz)
ST_Area(geom) measures area on the XY plane, which underestimates a tilted surface (the same way a tilted roof looks smaller from directly above than its true size). The surface_area_corrected_geom function projects the polygon onto its own surface plane before measuring, giving the true 3D area.
The simplest attribute: the difference between the highest and lowest Z coordinate of the polygon's vertices.
-- Horizontal and slope extent: projections on two axes in the surface plane
LEFT JOIN LATERAL {city2tabula_schema}.surface_dimensions(valid_geom, nx, ny, nz) d
ON objectclass_id IN (709, 710, 712)
surface_dimensions fixes two axes in the face's plane from its unit normal n = (nx, ny, nz), with h = √(nx² + ny²):
| Axis | Direction | Column |
|---|---|---|
| u | (−ny, nx, 0) / h: horizontal, along the face | horizontal_extent |
| v | (−nz·nx / h, −nz·ny / h, h): the line of steepest slope, pointing up | slope_extent |
Each extent is the largest minus the smallest projection of the face's vertices on its axis, so the sign of the normal does not change it. That matters for walls, whose outward flip in oriented_normals points some walls of concave footprints inward. Faces with |nz| > 0.985, where azimuth is −1, have no horizontal axis of their own and get NULL in both columns.
| Face | horizontal_extent |
slope_extent |
height |
|---|---|---|---|
| Wall 10 m wide, 3 m tall | 10 | 3 | 3 |
| Wall 2 m wide, 8 m tall | 2 | 8 | 8 |
| Roof, 12 m eave, 30° pitch, 5 m deep in plan | 12 | 5.77 | 2.89 |
| Gable wall, 8 m base, 3 m to the apex | 8 | 3 | 3 |
| Flat roof or ground face | NULL | NULL | 0 |
On a planar face height = slope_extent × cos(tilt). For a wall, horizontal_extent is the run that adds up across coplanar wall segments. For triangles, L-shapes and other non-rectangular faces, horizontal_extent × slope_extent is larger than the face; surface_area / (horizontal_extent × slope_extent) gives the fraction of that rectangle the face fills.
Internal faces¶
Where two solids of one building touch, the face region between them is interior. A source that models each part as a closed solid (Vienna) has that face on both parts; a source that leaves the parts open on that side (Prague) has it on neither. A second statement after the INSERT finds these faces for the batch.
A face of one solid is internal where a face of another solid of the same building lies on it:
| Test | Rule |
|---|---|
| Classes | Wall against wall, or roof against ground |
| Orientation | Normals parallel within about 2.6° (\|cos\| ≥ 0.999); the sign of the normals is not used |
| Plane offset | ≤ 0.05 m |
| Overlap | 2D intersection of the two faces in the plane of the first |
face_to_plane (in sql/functions/02_face_plane.sql) rotates both faces so the first face's normal points along +Z. The plane offset is then a Z difference and the overlap a 2D intersection. face_from_plane rotates the exposed remainder back into world coordinates.
The sign of the normals is not used because the outward flip in oriented_normals points some walls of concave footprints inward. Two solids cannot both have exterior wall on the same patch, so the overlap is interior whichever way the stored normals point.
The exposed remainder keeps only pieces wider than 0.04 m and larger than 0.01 m², so the tolerance band leaves no slivers. For each face with an overlap of at least 0.01 m²:
area_internal=surface_areaminus the exposed area.geom_exposed= the exposed pieces, or an empty geometry when the whole face is internal.
Faces with no overlap keep both columns NULL. Faces between different buildings (party walls) are not tested here.
Output columns¶
| Column | Description |
|---|---|
owner_feature_id |
Feature id of the solid the face bounds: the Building or one of its BuildingParts |
surface_area |
True 3D area of the face (sqm) |
tilt |
Angle of the surface from vertical (degrees; 0° = wall, 90° = flat roof, snapped to 90° for \|nz\| > 0.985). Complement of the usual from-horizontal slope angle: 90 − tilt. |
azimuth |
Compass bearing of outward-facing normal (degrees; −1 = undefined) |
is_valid |
ST_IsValid result for the polygon |
is_planar |
Whether all vertices lie on a single plane |
height |
Vertical span: ZMax − ZMin (m) |
horizontal_extent |
Extent along the horizontal in the surface plane (m); NULL for faces with \|nz\| > 0.985 and for classes other than wall, roof and ground |
slope_extent |
Extent along the line of steepest slope (m); NULL as for horizontal_extent |
normal_x, normal_y, normal_z |
Unit normal after the orientation rules above |
area_internal |
Part of surface_area that lies against another solid of the same building (sqm); NULL when none |
geom_exposed |
Exterior remainder of an internal face; empty when fully internal; NULL when the face has no internal part |
geom |
Original polygon geometry (carried through) |
What comes next¶
Script 04 reads _surface_raw and aggregates the per-face attributes into one row per solid and one summary row per building.