Loading Textured Meshes into PostGIS 3D
You have a cleaned photogrammetric mesh of a collapsing medieval undercroft — an OBJ with a texture atlas — and you need it in PostGIS so it can be spatially joined to its context record and measured with the 3D functions, not just admired in a viewer. The task is a controlled type conversion: read the triangle geometry, re-anchor it to the site datum, serialise it to a POLYHEDRALSURFACE Z well-known text, and insert it with the correct SRID so ST_IsValid and ST_3DArea return trustworthy answers. This guide, part of exporting 3D models to spatial databases, gives the full Python and SQL, including the closure and dimension pitfalls that make an otherwise-fine mesh fail validation.
Context & When to Use
Load a mesh into PostGIS 3D when you need to query or relate it, not merely display it — spatial joins to excavation contexts, 3D area and volume measurement, or intersection tests against a trench solid. If the only requirement is a web fly-through, keep the mesh as glTF on a file store and skip the database. The main trade-off is triangle count. A POLYHEDRALSURFACE Z stores every face as an explicit polygon ring, so a raw hundred-million-triangle master will bloat the table and slow SFCGAL to a crawl. Load a decimated LOD (typically well under a million faces) for query, and reference the full-density master by URI. The second trade-off is validity strictness: SFCGAL rejects unclosed rings and zero-area faces outright, so meshes with holes or degenerate triangles must be repaired before conversion, not after insertion.
Implementation
The converter reads the OBJ with trimesh, re-applies the datum offset removed at export, builds one closed ring per triangle, and inserts through psycopg with the SRID stamped in SQL. Every step comments the archaeological reasoning.
# requirements.txt
# trimesh==4.2.0
# numpy==1.26.4
# psycopg==3.1.18
# trimesh==4.2.0, numpy==1.26.4, psycopg==3.1.18
import numpy as np
import trimesh
import psycopg
SRID = 27700 # British National Grid; substitute your site's EPSG
def load_mesh_wkt(obj_path: str, offset: tuple[float, float, float]) -> str:
"""Read an OBJ and return POLYHEDRALSURFACE Z WKT anchored to the datum.
offset is the local shift the photogrammetry exporter subtracted for
float precision; re-applying it places the ruin on its true coordinates.
"""
mesh = trimesh.load(obj_path, process=False) # keep original vertex order
if mesh.faces.shape[1] != 3:
raise ValueError("expected a triangulated mesh; triangulate first")
verts = mesh.vertices + np.asarray(offset, dtype="f8")
face_wkt = []
for a, b, c in mesh.faces:
ring = [verts[a], verts[b], verts[c], verts[a]] # close: repeat first
coords = ",".join(f"{x:.4f} {y:.4f} {z:.4f}" for x, y, z in ring)
face_wkt.append(f"(({coords}))")
return "POLYHEDRALSURFACE Z(" + ",".join(face_wkt) + ")"
def insert_mesh(dsn: str, wkt: str, monument_ref: str, survey_date: str) -> int:
"""Insert the mesh with its SRID and metadata; return the new row id."""
sql = """
INSERT INTO heritage_mesh (monument_ref, survey_date, method, lod, geom)
VALUES (%s, %s, 'SfM photogrammetry', 'display',
ST_GeomFromText(%s, %s))
RETURNING id;
"""
with psycopg.connect(dsn) as conn, conn.cursor() as cur:
cur.execute(sql, (monument_ref, survey_date, wkt, SRID))
return cur.fetchone()[0]
if __name__ == "__main__":
wkt = load_mesh_wkt("undercroft_lod.obj", offset=(352000.0, 174000.0, 0.0))
new_id = insert_mesh("dbname=heritage", wkt, "MON-01423", "2026-06-18")
print(f"inserted mesh id={new_id}")
Two details do the load-bearing work. Repeating the first vertex (verts[a] at the end of each ring) is what makes the ring closed — omit it and every face is invalid. Building the geometry with ST_GeomFromText(%s, %s) stamps the SRID at insert time, so the row is anchored the moment it lands rather than relabelled later.
Verification
Immediately after loading, prove validity, dimensionality, SRID, and a plausible surface area:
-- PostgreSQL 16 / PostGIS 3.4 (SFCGAL)
SELECT id,
ST_IsValid(geom) AS valid,
ST_NDims(geom) AS ndims,
ST_SRID(geom) AS srid,
round(ST_3DArea(geom)::numeric, 2) AS area_m2
FROM heritage_mesh
WHERE id = :new_id;
Expected output for a well-formed undercroft record:
id | valid | ndims | srid | area_m2
----+-------+-------+-------+-----------
7 | t | 3 | 27700 | 248.61
Assert all four: valid = t, ndims = 3 (a 2 means the Z flag was dropped), srid = 27700, and an area_m2 in a sane range for the structure. A validity failure should block promotion — run ST_IsValidReason(geom) to see exactly which face failed. Confirm placement with SELECT ST_3DExtent(geom) FROM heritage_mesh WHERE id = :new_id; and check the box straddles the site’s known easting/northing, not the origin.
Common Errors & Fixes
Polyhedral surface is invalid : Ring 0 is not closed— the ring did not repeat its first vertex. Ensure the converter appends the opening coordinate to every face, as theringlist does above.Geometry has Z dimension but is not tagged as suchor a silently 2D result (ST_NDimsreturns 2) — the WKT string saidPOLYHEDRALSURFACE(...)without theZkeyword. EmitPOLYHEDRALSURFACE Z(...)so PostGIS keeps the elevations.lwgeom_sfcgal.c: ... SFCGAL is not enabledorfunction st_3darea(geometry) does not exist— the SFCGAL extension is not loaded. RunCREATE EXTENSION postgis_sfcgal;in the target database, then re-run the verification query.
Related
- Exporting 3D Models to Spatial Databases — the section overview on mesh and cloud handoff into PostGIS.
- Storing Point Clouds as PostGIS pgPointCloud Patches — the point-cloud counterpart to this mesh workflow.
- Mesh Generation & Optimization for Ruins — decimating the mesh before it reaches this loader.
- PostGIS Schema Design for Excavation Units — the excavation schema the mesh row joins against.