From aec40967ed54b1a635efd2cd647dbf52d2f7de42 Mon Sep 17 00:00:00 2001 From: Bert Temme Date: Wed, 18 Feb 2026 21:32:29 +0100 Subject: [PATCH] fix keep_projection=true and initial rotation --- README.md | 10 +++++ docs/technical-transforms.md | 68 +++++++++++++++++++++++++++---- src/ImplicitTiling.cs | 10 ++--- src/TileHandler.cs | 79 +++++++++++++++++++++++++++++------- 4 files changed, 139 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 43a16af..5b77158 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/technical-transforms.md b/docs/technical-transforms.md index f3b94eb..36fc99d 100644 --- a/docs/technical-transforms.md +++ b/docs/technical-transforms.md @@ -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. @@ -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) @@ -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 ) @@ -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” @@ -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`) diff --git a/src/ImplicitTiling.cs b/src/ImplicitTiling.cs index d0789bb..1196fab 100644 --- a/src/ImplicitTiling.cs +++ b/src/ImplicitTiling.cs @@ -60,7 +60,7 @@ public static List 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); } } @@ -100,7 +100,7 @@ public static List 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); } @@ -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 instances, bool useGpuInstancing, bool useI3dm, string contentDirectory = null) + private static byte[] CreateTile(Options o, List instances, bool useGpuInstancing, bool useI3dm, string contentDirectory = null, bool keepProjection = false) { byte[] tile; @@ -143,12 +143,12 @@ private static byte[] CreateTile(Options o, List 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; diff --git a/src/TileHandler.cs b/src/TileHandler.cs index ee9c8e5..af46f57 100644 --- a/src/TileHandler.cs +++ b/src/TileHandler.cs @@ -13,7 +13,30 @@ namespace i3dm.export; public static class TileHandler { - public static byte[] GetCmptTile(List 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 instances, bool UseExternalModel = false, bool UseScaleNonUniform = false, string outputDirectory = null, bool keepProjection = false) { var uniqueModels = instances.Select(s => s.Model).Distinct(); @@ -21,7 +44,7 @@ public static byte[] GetCmptTile(List instances, bool UseExternalModel 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); } @@ -29,7 +52,7 @@ public static byte[] GetCmptTile(List instances, bool UseExternalModel return bytes; } - public static byte[] GetI3dmTile(List instances, bool UseExternalModel, bool UseScaleNonUniform, object model, string outputDirectory = null) + public static byte[] GetI3dmTile(List instances, bool UseExternalModel, bool UseScaleNonUniform, object model, string outputDirectory = null, bool keepProjection = false) { var positions = new List(); var scales = new List(); @@ -41,14 +64,14 @@ public static byte[] GetI3dmTile(List instances, bool UseExternalModel var tags = new List(); 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 instances, bool UseScaleNonUniform, List positions, List scales, List scalesNonUniform, List normalUps, List normalRights, List tags) + internal static void CalculateArrays(List instances, bool UseScaleNonUniform, List positions, List scales, List scalesNonUniform, List normalUps, List normalRights, List tags, bool keepProjection = false) { var firstPosition = (Point)instances[0].Position; @@ -68,18 +91,31 @@ internal static void CalculateArrays(List 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 positions, Point rtcCenter, List scales, List scalesNonUniform, List normalUps, List normalRights, List tags, bool UseExternalModel = false, bool UseScaleNonUniform = false, string outputDirectory = null) + internal static I3dm.Tile.I3dm GetI3dm(object model, List positions, Point rtcCenter, List scales, List scalesNonUniform, List normalUps, List normalRights, List tags, bool UseExternalModel = false, bool UseScaleNonUniform = false, string outputDirectory = null, bool keepProjection = false) { I3dm.Tile.I3dm i3dm = null; @@ -108,6 +144,13 @@ internal static I3dm.Tile.I3dm GetI3dm(object model, List 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 @@ -117,7 +160,15 @@ internal static I3dm.Tile.I3dm GetI3dm(object model, List 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)