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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,16 @@ When option keep_projection is set to true, the original projection of the input
this option, the client application must support the original projection of the input table for correctly
displaying the Instanced 3D Tiles, like 3DTilesRenderer/Giro3D/ITowns/QGIS Web Client (QWC).

### Known limitations keep_projection

When using `--keep_projection=true`, the following limitations apply:

- **Scale**: Works correctly in both GPU and non-GPU modes
- **With `--use_gpu_instancing=true`**: Incorrect rotation (ECEF rotation is still applied, which is wrong for Cartesian coordinates)
- **With `--use_gpu_instancing=false`**: Yaw, pitch, and roll are not supported (all set to 0). Only the base model rotation (90° X-axis + 180° Z-axis) is applied.

For Cartesian projection mode, it is recommended to use `--use_gpu_instancing=false` until full rotation support is implemented.

## Docker

See https://hub.docker.com/r/geodan/i3dm.export
Expand Down
68 changes: 59 additions & 9 deletions docs/technical-transforms.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,43 @@
# Technical notes: coordinate systems & transforms

This document explains how `i3dm.export` computes **translation**, **rotation**, and **scale** for instances in both output modes:
This document explains how `i3dm.export` computes **translation**, **rotation**, and **scale** for instances in multiple modes:

- `--use_gpu_instancing=true`: outputs a `.glb` that uses `EXT_mesh_gpu_instancing`.
- `--use_gpu_instancing=false`: outputs `i3dm` (or `cmpt` containing `i3dm`) using `NORMAL_UP` / `NORMAL_RIGHT`.
- `--keep_projection=true`: uses Cartesian projection (local XYZ coordinates) instead of ECEF.

It also documents the coordinate-system conversions between **ECEF (EPSG:4978)** and **glTF Y-up**, and clarifies the **matrix conventions** used by glTF vs `System.Numerics`.

## 1) Coordinate systems

### 1.0 Cartesian projection mode (`--keep_projection=true`)

When `--keep_projection=true` is enabled, the exporter uses **Cartesian coordinates** instead of ECEF. This mode is designed for use with viewers like **Giro3D** that expect local coordinate systems.

**Cartesian coordinate system:**
- Right-handed coordinate system
- Units: meters (from source projection)
- Axes:
- +X: East
- +Y: North
- +Z: Up

**Key differences from ECEF mode:**
- No transformation to EPSG:4978
- No ECEF or ENU calculations
- Positions are used directly from the source projection
- Model is rotated to align with Cartesian Z-up convention:
- 90° rotation around X-axis (to align Z-up)
- 180° rotation around Z-axis (yaw correction)

**Current limitations (Cartesian mode):**
- Only supported with `--use_gpu_instancing=false` (i3dm tiles)
- Yaw, pitch, and roll are set to 0 (no per-instance rotation)
- NORMAL_RIGHT is fixed to (1, 0, 0) - East
- NORMAL_UP is fixed to (0, 1, 0) - North

### 1.1 ECEF (EPSG:4978)
Internally, instance positions are converted to **ECEF** (Earth-Centered, Earth-Fixed) coordinates.
In standard mode (`--keep_projection=false`), instance positions are converted to **ECEF** (Earth-Centered, Earth-Fixed) coordinates.

- Right-handed coordinate system.
- Units: meters.
Expand All @@ -20,7 +47,7 @@ Internally, instance positions are converted to **ECEF** (Earth-Centered, Earth-
- +Z: north pole.

### 1.2 Local tangent frame (ENU)
For each instance position we derive a local tangent basis:
For each instance position in ECEF mode we derive a local tangent basis:

- **E**: East (tangent)
- **N**: North (tangent)
Expand All @@ -46,7 +73,7 @@ glTF uses a **right-handed** coordinate system with:
The exporter outputs **Y-up** glTF.

### 1.4 ECEF → glTF Y-up swizzle
The exporter maps vectors/points from ECEF to glTF Y-up using the same swizzle in both position and orientation code:
In ECEF mode, the exporter maps vectors/points from ECEF to glTF Y-up using the same swizzle in both position and orientation code:

```
ToYUp(x, y, z) = ( x, z, -y )
Expand Down Expand Up @@ -189,19 +216,32 @@ This improves numerical precision in clients.

## 6) Export mode: `--use_gpu_instancing=false` (i3dm)

In i3dm, per-instance orientation is encoded via two vectors derived from the same rotated ENU basis:
In i3dm, per-instance orientation is encoded via two vectors derived from the transform matrix:

- `NORMAL_RIGHT` (derived from the transform matrix; represents the instance local +X direction)
- `NORMAL_UP` (derived from the transform matrix; represents the instance local +Y direction)
- `NORMAL_RIGHT` (represents the instance local +X direction)
- `NORMAL_UP` (represents the instance local +Y direction)

Current behavior:
### 6.1 ECEF mode (`--keep_projection=false`)

In standard ECEF mode:
- The non-GPU path uses **yaw/pitch/roll** (degrees, clockwise-positive).
- Backwards compatibility: if the input table does not contain yaw/pitch/roll but does contain legacy `rotation`, the exporter will read `rotation` as yaw/heading and assumes pitch/roll = 0 (and prints a deprecation warning).
- We first compute the rotated ENU basis (conceptually a 3×3 orientation matrix), then derive i3dm vectors:
- `NORMAL_RIGHT` = East in **ECEF**
- `NORMAL_UP` = North in **ECEF**

(Non-GPU does not do the ECEF→Y-up swizzle; the vectors are stored in ECEF.)
### 6.2 Cartesian mode (`--keep_projection=true`)

In Cartesian projection mode:
- No ECEF or ENU transformations are applied
- Positions are used directly from the source projection (in XYZ meters)
- The input model is rotated to align with Cartesian Z-up convention:
- 90° rotation around X-axis (transforms Y-up to Z-up)
- 180° rotation around Z-axis (yaw correction for proper orientation)
- Per-instance orientation is fixed (yaw/pitch/roll are not yet supported):
- `NORMAL_RIGHT` = (1, 0, 0) - East direction
- `NORMAL_UP` = (0, 1, 0) - North direction
- This mode is designed for viewers like **Giro3D** that use local Cartesian coordinate systems

## 7) Common pitfalls / why models can look “tilted”

Expand All @@ -217,10 +257,20 @@ Current behavior:
4) **Non-orthonormal basis drift**
- Small floating point errors can accumulate; we re-orthonormalize the basis before creating quaternions.

5) **Using wrong projection mode**
- Using `--keep_projection=false` (ECEF mode) when the viewer expects Cartesian coordinates
- Using `--keep_projection=true` (Cartesian mode) with WGS84 data without proper setup

## 8) Code pointers

### ECEF mode
- ENU basis: `src\Cesium\SpatialConverter.cs` (`EcefToEnu`)
- Y-up swizzle: `src\GPUTileHandler.cs` (`ToYUp`)
- Instance TRS (GPU): `src\GPUTileHandler.cs` (`GetInstanceTransform`)
- Node transform preservation: `src\GPUTileHandler.cs` (`CollectNodesWithMeshes`)
- Yaw/pitch/roll application: `src\EnuCalculator.cs`

### Cartesian mode
- Model rotation: `src\TileHandler.cs` (`RotateModelForCartesian`)
- i3dm generation with Cartesian coordinates: `src\TileHandler.cs` (`CalculateArrays`, `GetI3dm`)
- Tile creation: `src\ImplicitTiling.cs` (`CreateTile`)
10 changes: 5 additions & 5 deletions src/ImplicitTiling.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ public static List<Tile> GenerateTiles(Options o, NpgsqlConnection conn, Boundin
}
else
{
var bytes = CreateTile(o, instances, useGpuInstancing, useI3dm, contentDirectory);
var bytes = CreateTile(o, instances, useGpuInstancing, useI3dm, contentDirectory, keepProjection);
SaveTile(contentDirectory, tile, bytes, useGpuInstancing, useI3dm);
}
}
Expand Down Expand Up @@ -100,7 +100,7 @@ public static List<Tile> GenerateTiles(Options o, NpgsqlConnection conn, Boundin
else
{
var instances = InstancesRepository.GetInstances(conn, o.Table, o.GeometryColumn, bbox, source_epsg, where, (bool)o.UseScaleNonUniform, useGpuInstancing, keepProjection);
var bytes = CreateTile(o, instances, useGpuInstancing, useI3dm, contentDirectory);
var bytes = CreateTile(o, instances, useGpuInstancing, useI3dm, contentDirectory, keepProjection);
SaveTile(contentDirectory, tile, bytes, useGpuInstancing, useI3dm);
}

Expand Down Expand Up @@ -132,7 +132,7 @@ private static void SaveTile(string contentDirectory, Tile tile, byte[] bytes, b
File.WriteAllBytes(file, bytes);
}

private static byte[] CreateTile(Options o, List<Instance> instances, bool useGpuInstancing, bool useI3dm, string contentDirectory = null)
private static byte[] CreateTile(Options o, List<Instance> instances, bool useGpuInstancing, bool useI3dm, string contentDirectory = null, bool keepProjection = false)
{
byte[] tile;

Expand All @@ -143,12 +143,12 @@ private static byte[] CreateTile(Options o, List<Instance> instances, bool useGp
else if(!useI3dm)
{
// create cmpt
tile = TileHandler.GetCmptTile(instances, (bool)o.UseExternalModel, (bool)o.UseScaleNonUniform, contentDirectory);
tile = TileHandler.GetCmptTile(instances, (bool)o.UseExternalModel, (bool)o.UseScaleNonUniform, contentDirectory, keepProjection);
}
else
{
// take the first model for i3dm
tile = TileHandler.GetI3dmTile(instances, (bool)o.UseExternalModel, (bool)o.UseScaleNonUniform, instances.First().Model, contentDirectory);
tile = TileHandler.GetI3dmTile(instances, (bool)o.UseExternalModel, (bool)o.UseScaleNonUniform, instances.First().Model, contentDirectory, keepProjection);
}

return tile;
Expand Down
79 changes: 65 additions & 14 deletions src/TileHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,23 +13,46 @@ namespace i3dm.export;

public static class TileHandler
{
public static byte[] GetCmptTile(List<Instance> instances, bool UseExternalModel = false, bool UseScaleNonUniform = false, string outputDirectory = null)
private static byte[] RotateModelForCartesian(byte[] glbBytes)
{
var model = ModelRoot.ParseGLB(glbBytes);

// Rotate 90° around X-axis (to align Z-up)
var rotX = Matrix4x4.CreateRotationX((float)(-Math.PI / 2.0));

// Rotate 180° around Z-axis (yaw)
var rotZ = Matrix4x4.CreateRotationZ((float)Math.PI);

// Combine rotations: first X, then Z
var combinedRotation = rotX * rotZ;

foreach (var scene in model.LogicalScenes)
{
foreach (var node in scene.VisualChildren)
{
node.LocalMatrix = node.LocalMatrix * combinedRotation;
}
}

return model.WriteGLB().Array;
}
public static byte[] GetCmptTile(List<Instance> instances, bool UseExternalModel = false, bool UseScaleNonUniform = false, string outputDirectory = null, bool keepProjection = false)
{
var uniqueModels = instances.Select(s => s.Model).Distinct();

var tiles = new List<byte[]>();

foreach (var model in uniqueModels)
{
var bytesI3dm = GetI3dmTile(instances, UseExternalModel, UseScaleNonUniform, model, outputDirectory);
var bytesI3dm = GetI3dmTile(instances, UseExternalModel, UseScaleNonUniform, model, outputDirectory, keepProjection);
tiles.Add(bytesI3dm);
}

var bytes = CmptWriter.Write(tiles);
return bytes;
}

public static byte[] GetI3dmTile(List<Instance> instances, bool UseExternalModel, bool UseScaleNonUniform, object model, string outputDirectory = null)
public static byte[] GetI3dmTile(List<Instance> instances, bool UseExternalModel, bool UseScaleNonUniform, object model, string outputDirectory = null, bool keepProjection = false)
{
var positions = new List<Vector3>();
var scales = new List<float>();
Expand All @@ -41,14 +64,14 @@ public static byte[] GetI3dmTile(List<Instance> instances, bool UseExternalModel
var tags = new List<JArray>();
var firstPosition = (Point)modelInstances[0].Position;

CalculateArrays(modelInstances, UseScaleNonUniform, positions, scales, scalesNonUniform, normalUps, normalRights, tags);
CalculateArrays(modelInstances, UseScaleNonUniform, positions, scales, scalesNonUniform, normalUps, normalRights, tags, keepProjection);

var i3dm = GetI3dm(model, positions, firstPosition, scales, scalesNonUniform, normalUps, normalRights, tags, UseExternalModel, UseScaleNonUniform, outputDirectory);
var i3dm = GetI3dm(model, positions, firstPosition, scales, scalesNonUniform, normalUps, normalRights, tags, UseExternalModel, UseScaleNonUniform, outputDirectory, keepProjection);
var bytesI3dm = I3dmWriter.Write(i3dm);
return bytesI3dm;
}

internal static void CalculateArrays(List<Instance> instances, bool UseScaleNonUniform, List<Vector3> positions, List<float> scales, List<Vector3> scalesNonUniform, List<Vector3> normalUps, List<Vector3> normalRights, List<JArray> tags)
internal static void CalculateArrays(List<Instance> instances, bool UseScaleNonUniform, List<Vector3> positions, List<float> scales, List<Vector3> scalesNonUniform, List<Vector3> normalUps, List<Vector3> normalRights, List<JArray> tags, bool keepProjection = false)
{
var firstPosition = (Point)instances[0].Position;

Expand All @@ -68,18 +91,31 @@ internal static void CalculateArrays(List<Instance> instances, bool UseScaleNonU
{
scalesNonUniform.Add(new Vector3((float)instance.ScaleNonUniform[0], (float)instance.ScaleNonUniform[1], (float)instance.ScaleNonUniform[2]));
}
var (east, north, _) = EnuCalculator.GetLocalEnuCesium(positionVector3, instance.Yaw, instance.Pitch, instance.Roll);

// i3dm uses NORMAL_RIGHT (local +X) and NORMAL_UP.
// In Cesium's i3dm pipeline, using ENU East for RIGHT and ENU North for UP yields an upright frame:
// East × North = Up.
normalRights.Add(Vector3.Normalize(east));
normalUps.Add(Vector3.Normalize(north));
if (keepProjection)
{
// Cartesian projection: X=East, Y=North, Z=Up
// For i3dm: NORMAL_RIGHT = local +X direction, NORMAL_UP = local +Y direction
// In Cartesian mode with no rotation (yaw/pitch/roll = 0):
normalRights.Add(new Vector3(1, 0, 0)); // X = East
normalUps.Add(new Vector3(0, 1, 0)); // Y = North
}
else
{
// ECEF mode: use ENU transformation
var (east, north, _) = EnuCalculator.GetLocalEnuCesium(positionVector3, instance.Yaw, instance.Pitch, instance.Roll);

// i3dm uses NORMAL_RIGHT (local +X) and NORMAL_UP.
// In Cesium's i3dm pipeline, using ENU East for RIGHT and ENU North for UP yields an upright frame:
// East × North = Up.
normalRights.Add(Vector3.Normalize(east));
normalUps.Add(Vector3.Normalize(north));
}
tags.Add(instance.Tags);
}
}

internal static I3dm.Tile.I3dm GetI3dm(object model, List<Vector3> positions, Point rtcCenter, List<float> scales, List<Vector3> scalesNonUniform, List<Vector3> normalUps, List<Vector3> normalRights, List<JArray> tags, bool UseExternalModel = false, bool UseScaleNonUniform = false, string outputDirectory = null)
internal static I3dm.Tile.I3dm GetI3dm(object model, List<Vector3> positions, Point rtcCenter, List<float> scales, List<Vector3> scalesNonUniform, List<Vector3> normalUps, List<Vector3> normalRights, List<JArray> tags, bool UseExternalModel = false, bool UseScaleNonUniform = false, string outputDirectory = null, bool keepProjection = false)
{
I3dm.Tile.I3dm i3dm = null;

Expand Down Expand Up @@ -108,6 +144,13 @@ internal static I3dm.Tile.I3dm GetI3dm(object model, List<Vector3> positions, Po
using var stream = ExternalTextureHelper.WriteGlbToStream(modelRoot, writeSettings);
glbBytes = stream.ToArray();
}

// Apply Cartesian rotation if needed
if (keepProjection)
{
glbBytes = RotateModelForCartesian(glbBytes);
}

i3dm = new I3dm.Tile.I3dm(positions, glbBytes);
}
else
Expand All @@ -117,7 +160,15 @@ internal static I3dm.Tile.I3dm GetI3dm(object model, List<Vector3> positions, Po
}
if (model is byte[])
{
i3dm = new I3dm.Tile.I3dm(positions, (byte[])model);
var glbBytes = (byte[])model;

// Apply Cartesian rotation if needed
if (keepProjection)
{
glbBytes = RotateModelForCartesian(glbBytes);
}

i3dm = new I3dm.Tile.I3dm(positions, glbBytes);
}

if (!UseScaleNonUniform)
Expand Down