Exporting OBJ and glTF for Web Viewers
The client opens the link and waits. Forty seconds later a grey model appears, sitting a few hundred metres from where the basemap says the site is, and it shimmers when they zoom in. Every one of those three symptoms is an export decision rather than a modelling one, and all three are avoidable in the same twenty lines of code.
This page covers the export stage of mesh and texture generation automation: choosing the container, handling coordinates that a 32-bit float cannot hold, keeping the texture attached to the geometry, and verifying the result without opening a browser.
Why the container matters more than the mesh
OBJ is a text format. Every vertex is written as a decimal string — around 40 bytes where the underlying data is 12 — and the parser must convert millions of strings back to numbers before anything renders. A five-million-triangle textured mesh lands at roughly 600 MB and takes tens of seconds to parse in a browser, during which the page is unresponsive.
glTF binary (.glb) stores the same arrays as packed binary buffers. The same mesh is about 90 MB, parses in roughly a second because there is nothing to parse, and carries the texture inside the same file rather than as a sidecar. It is also the format every web viewer actually wants; OBJ support in browsers is a conversion step wearing a loader’s clothing.
The one thing OBJ has is universality in desktop CAD. Where a client genuinely needs it, export both and deliver the GLB as the thing they click.
Figure 1 — The container is a bigger lever than any mesh optimisation available at this stage.
The float32 problem, concretely
glTF stores vertex positions as 32-bit floats. A float32 has about 24 bits of mantissa, so its resolution is roughly the magnitude of the value divided by 16.7 million. A UTM easting of 500,000 m therefore resolves to about 3 cm, and a northing of 5,600,000 m to about 33 cm.
That is the shimmer. The geometry is quantised to a lattice whose spacing depends on where in the coordinate system it sits, and as the camera moves, vertices snap between lattice positions. It also means the model cannot express anything finer than the quantisation, so a survey delivered at 1 cm accuracy arrives with a third of a metre of vertical noise.
The fix is to translate the mesh to a local origin and record the offset. Everything downstream — a viewer’s georeferencing, a measurement tool, a comparison against a later model — uses the offset to get back to the projected frame.
import json
from pathlib import Path
import numpy as np
import trimesh
def export_glb_local_origin(mesh: trimesh.Trimesh, out_path: str,
crs: str, *, draco: bool = False) -> dict:
"""Export to .glb in a local frame, with the offset recorded beside it.
The offset is rounded to whole metres so it stays exactly representable
and reads cleanly in the metadata. What matters is that it is recorded:
a model in a local frame with no offset is a model nobody can place.
"""
origin = np.round(mesh.bounds.mean(axis=0)).astype(float)
local = mesh.copy()
local.apply_translation(-origin)
residual = float(np.abs(local.vertices).max())
resolution = residual / 2 ** 23
if resolution > 0.005:
raise ValueError(
f"local extent {residual:.0f} m still quantises to {resolution*1000:.1f} mm "
"— tile the model before export")
local.export(out_path)
meta = {"crs": crs, "origin_offset": origin.tolist(),
"position_resolution_m": resolution,
"bytes": Path(out_path).stat().st_size}
Path(out_path).with_suffix(".json").write_text(json.dumps(meta, indent=2))
return meta
The residual check is the part worth keeping. Shifting to a local origin fixes the problem for a site a few hundred metres across; a corridor survey ten kilometres long still quantises to half a centimetre at its ends, and the answer there is tiling rather than a different offset.
Figure 3 — The destination decides, and shipping both is cheap.
Edge-case matrix
| Input variant | Naive export | Correct handling |
|---|---|---|
| Mesh in UTM coordinates | 3–33 cm quantisation, shimmer | Translate to a local origin, record it |
| Corridor model 10 km long | Local origin insufficient | Tile, one origin per tile |
| Texture as a separate PNG | Sidecar lost in transit | Export .glb, texture embedded |
| Vertex colours, no texture | Rendered grey by some viewers | Bake colours to a small texture, or set the material |
| Mesh with no normals | Flat, faceted shading | Compute smooth normals before export |
| Double-sided geometry needed | Back faces culled, holes appear | Set doubleSided in the material |
| Very large texture (16 k) | Rejected on mobile | Split, or downsample to 8 k |
| Z-up survey convention | Model appears lying on its side | Rotate to glTF’s Y-up convention |
The last row produces the most support tickets. Survey data is Z-up; glTF is Y-up. Viewers vary in whether they apply a correction, so exporting with an explicit rotation is the only way to be sure.
import numpy as np
import trimesh
def to_gltf_orientation(mesh: trimesh.Trimesh) -> trimesh.Trimesh:
"""Rotate a Z-up survey mesh into glTF's Y-up convention explicitly."""
m = mesh.copy()
m.apply_transform(trimesh.transformations.rotation_matrix(
angle=-np.pi / 2, direction=[1, 0, 0], point=[0, 0, 0]))
return m
Verification snippet
Everything that goes wrong in export is visible in the exported file, which means it can be checked in CI rather than by a client.
import json
from pathlib import Path
import numpy as np
import trimesh
def verify_export(glb_path: str, expect_max_extent_m: float = 2000.0) -> dict:
"""Post-export checks: placement, precision, material, orientation."""
m = trimesh.load(glb_path, process=False, force="mesh")
meta_path = Path(glb_path).with_suffix(".json")
meta = json.loads(meta_path.read_text()) if meta_path.exists() else {}
extent = float(np.abs(m.bounds).max())
problems = []
if "origin_offset" not in meta:
problems.append("no origin offset recorded — the model cannot be georeferenced")
if extent > expect_max_extent_m:
problems.append(f"vertices reach {extent:.0f} m from the origin — "
"positions are quantising")
if getattr(m.visual, "uv", None) is None:
problems.append("no UV coordinates — the model will render untextured")
if not m.is_winding_consistent:
problems.append("inconsistent winding — expect black faces")
if m.vertex_normals is None or len(m.vertex_normals) == 0:
problems.append("no normals — shading will be flat")
return {"bytes": Path(glb_path).stat().st_size,
"faces": int(len(m.faces)), "extent_from_origin_m": extent,
"position_resolution_mm": extent / 2 ** 23 * 1000,
"problems": problems}
Reporting position_resolution_mm rather than a pass/fail is deliberate: it is the number that tells a reviewer whether the model can support the measurement the client intends to take from it, and it belongs in the delivery note.
Figure 2 — The shimmer, quantified. Nothing about the mesh changes; only where it sits in the number line.
Delivering a model somebody can actually place
An exported GLB in a local frame is only half a deliverable. The other half is the information needed to put it back where it belongs, and three fields cover it: the CRS, the origin offset, and the orientation convention used. Shipping them as a small JSON sidecar next to the model — or, better, as glTF extras inside the file — means the model is self-describing.
def embed_georeference(gltf_dict: dict, crs: str, origin: list[float],
up_axis: str = "Y") -> dict:
"""Write placement metadata into the glTF asset extras, not a loose file."""
gltf_dict.setdefault("asset", {}).setdefault("extras", {}).update({
"crs": crs,
"originOffset": origin,
"upAxis": up_axis,
"note": "add originOffset to vertex positions to return to the CRS",
})
return gltf_dict
The note field looks redundant and is not. Whoever opens this file in two years will not be the person who exported it, and a single sentence saying which direction the offset applies removes the most likely error — applying it with the wrong sign, which puts the model exactly twice as far from the site as no correction at all.
Finally, decide the Draco question by the audience rather than by preference. Draco compression roughly quarters the file at the cost of a decode step, so it wins on any connection slower than about 50 Mbit and loses on a fast office link. Where the audience is mixed, ship both and let the viewer choose — the extra storage is trivial next to the imagery the model came from.
When to escalate
- The model still renders untextured in one specific viewer. Check the material’s alpha mode and double-sided flag before re-baking anything; several viewers render a correctly textured but back-facing mesh as flat grey.
- Positions are correct and the model is in the wrong place on the basemap. That is a CRS problem, not an export one — see managing coordinate reference systems in GDAL.
- The file is under the size budget and still loads slowly. The bottleneck is texture decode rather than geometry. Reduce the atlas resolution before reducing the triangle count.