diff --git a/README.md b/README.md index 17dbd03..4c08b2f 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ Input database table contains following columns: . pitch - double with pitch in degrees; . roll - double with roll in degrees; -Non-GPU (i3dm) mode supports yaw/pitch/roll. For backwards compatibility, if `yaw/pitch/roll` columns are missing the tool will fall back to legacy `rotation` (deprecated) and print a warning (rotation is treated as yaw; pitch/roll = 0). +For backwards compatibility, if `yaw/pitch/roll` columns are missing the tool will fall back to legacy `rotation` (deprecated) and print a warning (rotation is treated as yaw; pitch/roll = 0). . tags - json with instance attribute information; @@ -133,11 +133,9 @@ displaying the Instanced 3D Tiles, like 3DTilesRenderer/Giro3D/ITowns/QGIS Web C 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) +- **Rotation (yaw/pitch/roll)**: Only supported with `--use_gpu_instancing=true`. - **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 deleted file mode 100644 index c57e6e7..0000000 --- a/docs/technical-transforms.md +++ /dev/null @@ -1,98 +0,0 @@ -# Technical: Coordinate Systems & Transforms - -This document explains how `i3dm.export` computes translation, rotation, and scale for instances, covering coordinate transformations and matrix conventions. - -## Coordinate Systems - -### ECEF Mode (Default) -Standard mode converts positions to **ECEF (EPSG:4978)** - Earth-Centered, Earth-Fixed coordinates: -- Right-handed, meters -- +X: equator/prime meridian, +Y: 90° east, +Z: north pole -- Derives **ENU** (East/North/Up) tangent basis at each position: `E × N = U` -- Applies yaw/pitch/roll in local frame - -### Cartesian Mode (`--keep_projection=true`) -Uses local XYZ coordinates for viewers like Giro3D: -- Right-handed, meters (from source projection) -- +X: East, +Y: North, +Z: Up -- No ECEF/ENU transformations -- Model rotated: 90° around X (Z-up), 180° around Z (orientation) -- **Limitations**: i3dm only, no per-instance rotation, fixed NORMAL_RIGHT/UP - -### glTF Y-up Space -Output format uses right-handed: +X right, +Y up, +Z forward - -**ECEF → glTF swizzle**: `ToYUp(x, y, z) = (x, z, -y)` - -## Rotation Conventions - -Angles in **degrees**, **clockwise-positive**: -- **Yaw**: around Up axis (heading) -- **Pitch**: around East axis -- **Roll**: around North/Forward axis - -`Rotator.RotateVector` converts clockwise to right-hand-rule using `360 - angle`. - -## Matrix Conventions - -**glTF**: Column-major, column vectors → `world = M * local` -**System.Numerics**: Row-vector semantics → `world = local * M` - -This repo stores world basis vectors in matrix **rows** for row-vector semantics: -- `(1,0,0) * M = East`, `(0,1,0) * M = Up`, `(0,0,1) * M = Forward` - -## Transform Pipeline - -**Common workflow** (ECEF mode): -1. Compute ENU basis at ECEF position -2. Apply yaw/pitch/roll (clockwise-positive) -3. Build 3×3 orientation matrix - -**Encoding diverges**: -- **GPU mode**: Convert to glTF Y-up → quaternion → `EXT_mesh_gpu_instancing` TRS -- **i3dm mode**: Keep ECEF → extract `NORMAL_RIGHT`/`NORMAL_UP` vectors - -## GPU Instancing Mode (`--use_gpu_instancing=true`) - -Outputs `.glb` with `EXT_mesh_gpu_instancing`: `worldVertex = NodeWorld * InstanceTRS * vertex` - -**Node transforms preserved**: Uses `node.WorldMatrix` from input model (e.g., Blender axis corrections) - -**Instance TRS**: -1. **Translation**: ECEF → glTF Y-up via `ToYUp(Point)` -2. **Rotation**: ENU basis + yaw/pitch/roll → swizzle to Y-up → re-orthonormalize → quaternion -3. **Scale**: Uniform or non-uniform (`--use_scale_non_uniform`) - -**RTC optimization**: First instance position as tile anchor, improving precision - -## i3dm Mode (`--use_gpu_instancing=false`) - -Encodes orientation via `NORMAL_RIGHT` (local +X) and `NORMAL_UP` (local +Y) - -**ECEF mode**: -- Compute rotated ENU basis → `NORMAL_RIGHT` = East, `NORMAL_UP` = North (in ECEF) -- Legacy `rotation` field supported as yaw (with deprecation warning) - -**Cartesian mode**: -- Direct XYZ positions, model rotated 90° (X-axis) + 180° (Z-axis) -- Fixed: `NORMAL_RIGHT` = (1,0,0), `NORMAL_UP` = (0,1,0) -- No per-instance rotation yet - -## Common Issues - -1. **Handedness/sign confusion**: Clockwise-positive vs right-hand-rule -2. **Row vs column vectors**: Basis in wrong dimension for `Vector3.Transform` -3. **Dropped node transforms**: Missing axis corrections from input model -4. **Numerical drift**: Re-orthonormalization needed before quaternion conversion -5. **Wrong projection mode**: ECEF vs Cartesian mismatch with viewer expectations - -## Code References - -**ECEF mode**: -- `SpatialConverter.cs`: `EcefToEnu` - ENU basis computation -- `GPUTileHandler.cs`: `ToYUp`, `GetInstanceTransform`, `CollectNodesWithMeshes` -- `EnuCalculator.cs`: Yaw/pitch/roll application - -**Cartesian mode**: -- `TileHandler.cs`: `RotateModelForCartesian`, `CalculateArrays`, `GetI3dm` -- `ImplicitTiling.cs`: `CreateTile` diff --git a/i3dm.export.sln b/i3dm.export.sln index af3a9a3..627943d 100644 --- a/i3dm.export.sln +++ b/i3dm.export.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 18 -VisualStudioVersion = 18.4.11506.43 insiders +VisualStudioVersion = 18.4.11506.43 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "doc", "doc", "{68333F7F-D9B8-4215-AE46-6B3AFF14FFBB}" ProjectSection(SolutionItems) = preProject @@ -9,7 +9,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "doc", "doc", "{68333F7F-D9B docs\getting_started.md = docs\getting_started.md README.md = README.md docs\screenshot.png = docs\screenshot.png - docs\technical-transforms.md = docs\technical-transforms.md docs\trees.png = docs\trees.png EndProjectSection EndProject diff --git a/src/EnuCalculator.cs b/src/EnuCalculator.cs index 0d0bdf6..6c26d69 100644 --- a/src/EnuCalculator.cs +++ b/src/EnuCalculator.cs @@ -38,4 +38,41 @@ public static (Vector3 East, Vector3 North, Vector3 Up) GetLocalEnuCesium(Vector return (east, north, up); } + + public static (Vector3 East, Vector3 North, Vector3 Up) GetLocalCartesianBasis(double yaw, double pitch = 0, double roll = 0) + { + // Cartesian mode: start with identity basis (X=East, Y=North, Z=Up) + var east = new Vector3(1, 0, 0); + var north = new Vector3(0, 1, 0); + var up = new Vector3(0, 0, 1); + + // Apply rotations using same convention as ECEF mode (clockwise-positive). + // Yaw: rotation around Up axis (heading) + if (yaw != 0) + { + east = Vector3.Normalize(Rotator.RotateVector(east, up, yaw)); + north = Vector3.Normalize(Rotator.RotateVector(north, up, yaw)); + } + + // Pitch: rotation around East axis + if (pitch != 0) + { + north = Vector3.Normalize(Rotator.RotateVector(north, east, pitch)); + up = Vector3.Normalize(Rotator.RotateVector(up, east, pitch)); + } + + // Roll: rotation around North/Forward axis + if (roll != 0) + { + east = Vector3.Normalize(Rotator.RotateVector(east, north, roll)); + up = Vector3.Normalize(Rotator.RotateVector(up, north, roll)); + } + + // Re-orthonormalize to avoid drift. + east = Vector3.Normalize(east); + north = Vector3.Normalize(Vector3.Cross(up, east)); + up = Vector3.Normalize(Vector3.Cross(east, north)); + + return (east, north, up); + } } \ No newline at end of file diff --git a/src/GPUTileHandler.cs b/src/GPUTileHandler.cs index 96a5554..5d0c6ef 100644 --- a/src/GPUTileHandler.cs +++ b/src/GPUTileHandler.cs @@ -16,11 +16,11 @@ namespace i3dm.export; public static class GPUTileHandler { - public static void SaveGPUTile(string filePath, List instances, bool UseScaleNonUniform) + public static void SaveGPUTile(string filePath, List instances, bool UseScaleNonUniform, bool keepProjection = false) { var externalTextures = new Dictionary(StringComparer.OrdinalIgnoreCase); - var model = BuildGpuModel(instances, UseScaleNonUniform, externalTextures); + var model = BuildGpuModel(instances, UseScaleNonUniform, externalTextures, keepProjection); // If textures are embedded in the input model, keep them embedded in the output GLB. var hasEmbeddedImages = model.LogicalImages.Any(i => !i.Content.IsEmpty && string.IsNullOrWhiteSpace(i.Content.SourcePath)); @@ -35,19 +35,20 @@ public static void SaveGPUTile(string filePath, List instances, bool U model.SaveGLB(filePath, writeSettings); } - public static byte[] GetGPUTile(List instances, bool UseScaleNonUniform) + public static byte[] GetGPUTile(List instances, bool UseScaleNonUniform, bool keepProjection = false) { - var model = BuildGpuModel(instances, UseScaleNonUniform); + var model = BuildGpuModel(instances, UseScaleNonUniform, null, keepProjection); return model.WriteGLB().Array; } - private static ModelRoot BuildGpuModel(List instances, bool UseScaleNonUniform, Dictionary externalTextures = null) + private static ModelRoot BuildGpuModel(List instances, bool UseScaleNonUniform, Dictionary externalTextures = null, bool keepProjection = false) { var firstPosition = (Point)instances[0].Position; + // Always convert to Y-up for glTF, regardless of keepProjection var translation = ToYUp(firstPosition); var meshNodeCountsByModel = new Dictionary(StringComparer.OrdinalIgnoreCase); - var sceneBuilder = AddModels(instances, translation, UseScaleNonUniform, externalTextures, meshNodeCountsByModel); + var sceneBuilder = AddModels(instances, translation, UseScaleNonUniform, externalTextures, meshNodeCountsByModel, keepProjection); var settings = SceneBuilderSchema2Settings.WithGpuInstancing; settings.GpuMeshInstancingMinCount = 0; @@ -82,6 +83,7 @@ private static ModelRoot BuildGpuModel(List instances, bool UseScaleNo foreach (var node in model.LogicalNodes) { + // glTF always uses Y-up, so translation is already in Y-up format var tra = new Vector3((float)translation.X, (float)translation.Y, (float)translation.Z); node.LocalTransform *= Matrix4x4.CreateTranslation(tra); } @@ -108,7 +110,7 @@ private static StructuralMetadataClass AddMetadataSchema(ModelRoot gltf) return schemaClass; } - private static SceneBuilder AddModels(IEnumerable instances, Point translation, bool UseScaleNonUniform, Dictionary externalTextures = null, Dictionary meshNodeCountsByModel = null) + private static SceneBuilder AddModels(IEnumerable instances, Point translation, bool UseScaleNonUniform, Dictionary externalTextures = null, Dictionary meshNodeCountsByModel = null, bool keepProjection = false) { var sceneBuilder = new SceneBuilder(); @@ -121,14 +123,14 @@ private static SceneBuilder AddModels(IEnumerable instances, Point tra ExternalTextureHelper.CollectExternalTextures(externalTextures, modelPath, modelRoot); - var meshNodeCount = AddModelInstancesToScene(sceneBuilder, instances, UseScaleNonUniform, translation, modelPath, modelRoot); + var meshNodeCount = AddModelInstancesToScene(sceneBuilder, instances, UseScaleNonUniform, translation, modelPath, modelRoot, keepProjection); if (meshNodeCountsByModel != null) meshNodeCountsByModel[modelPath] = meshNodeCount; } return sceneBuilder; } - private static int AddModelInstancesToScene(SceneBuilder sceneBuilder, IEnumerable instances, bool UseScaleNonUniform, Point translation, string model, ModelRoot modelRoot) + private static int AddModelInstancesToScene(SceneBuilder sceneBuilder, IEnumerable instances, bool UseScaleNonUniform, Point translation, string model, ModelRoot modelRoot, bool keepProjection = false) { var modelInstances = instances.Where(s => s.Model.Equals(model)).ToList(); var pointId = 0; @@ -147,7 +149,7 @@ private static int AddModelInstancesToScene(SceneBuilder sceneBuilder, IEnumerab { foreach (var (meshBuilder, nodeWorldMatrix) in meshNodes) { - var sceneBuilderModel = GetSceneBuilder(meshBuilder, nodeWorldMatrix, instance, UseScaleNonUniform, translation, pointId); + var sceneBuilderModel = GetSceneBuilder(meshBuilder, nodeWorldMatrix, instance, UseScaleNonUniform, translation, pointId, keepProjection); sceneBuilder.AddScene(sceneBuilderModel, Matrix4x4.Identity); } @@ -170,9 +172,9 @@ private static void CollectNodesWithMeshes(Node node, List<(IMeshBuilder meshBuilder, Matrix4x4 nodeWorldMatrix, Instance instance, bool UseScaleNonUniform, Point translation, int pointId) + private static SceneBuilder GetSceneBuilder(IMeshBuilder meshBuilder, Matrix4x4 nodeWorldMatrix, Instance instance, bool UseScaleNonUniform, Point translation, int pointId, bool keepProjection = false) { - var instanceTransform = GetInstanceTransform(instance, UseScaleNonUniform, translation); + var instanceTransform = GetInstanceTransform(instance, UseScaleNonUniform, translation, keepProjection); var nodeTransform = new AffineTransform(nodeWorldMatrix); var combinedTransform = AffineTransform.Multiply(in nodeTransform, in instanceTransform); @@ -182,37 +184,78 @@ private static SceneBuilder GetSceneBuilder(IMeshBuilder meshBu return sceneBuilder; } - private static AffineTransform GetInstanceTransform(Instance instance, bool UseScaleNonUniform, Point translation) + private static AffineTransform GetInstanceTransform(Instance instance, bool UseScaleNonUniform, Point translation, bool keepProjection = false) { var point = (Point)instance.Position; - var position = ToYUp(point); + Vector3 position2; + Quaternion res; - // Use the same angle convention as non-GPU instancing (I3DM): degrees, clockwise-positive. - // yaw : rotation around local Up axis ("heading") - // pitch : rotation around local East/Right axis - // roll : rotation around local Forward axis - var positionVector3 = new Vector3((float)point.X, (float)point.Y, (float)point.Z); + if (keepProjection) + { + // Cartesian mode: positions are in local XYZ coordinates (X=East, Y=North, Z=Up) + // Transform to glTF Y-up space + + // Position: transform from Cartesian XYZ to Y-up, then compute offset from RTC + var cartesianPos = new Vector3( + (float)point.X, + (float)point.Y, + (float)point.Z.GetValueOrDefault()); + + var cartesianPosYUp = ToYUp(cartesianPos); + + // translation is already in Y-up format (ToYUp applied in BuildGpuModel) + position2 = new Vector3( + cartesianPosYUp.X - (float)translation.X, + cartesianPosYUp.Y - (float)translation.Y, + cartesianPosYUp.Z - (float)translation.Z); + + // Rotation: apply yaw/pitch/roll in local Cartesian frame + var (east, north, up) = EnuCalculator.GetLocalCartesianBasis(instance.Yaw, instance.Pitch, instance.Roll); + + // Convert Cartesian basis (Z-up) to glTF Y-up space + var eastYUp = Vector3.Normalize(ToYUp(east)); + var northYUp = Vector3.Normalize(ToYUp(north)); + var upYUp = Vector3.Normalize(ToYUp(up)); + + // Re-orthonormalize for numerical stability + var forwardYUp = Vector3.Normalize(Vector3.Cross(eastYUp, upYUp)); + upYUp = Vector3.Normalize(Vector3.Cross(forwardYUp, eastYUp)); + + var m4 = GetTransformationMatrix((eastYUp, new Vector3(0, 0, 0), upYUp), forwardYUp); + res = Quaternion.CreateFromRotationMatrix(m4); + } + else + { + // ECEF mode: convert to glTF Y-up space + var position = ToYUp(point); + + // Use the same angle convention as non-GPU instancing (I3DM): degrees, clockwise-positive. + // yaw : rotation around local Up axis ("heading") + // pitch : rotation around local East/Right axis + // roll : rotation around local Forward axis + var positionVector3 = new Vector3((float)point.X, (float)point.Y, (float)point.Z.GetValueOrDefault()); - var enu = EnuCalculator.GetLocalEnuCesium(positionVector3, instance.Yaw, instance.Pitch, instance.Roll); + var enu = EnuCalculator.GetLocalEnuCesium(positionVector3, instance.Yaw, instance.Pitch, instance.Roll); - var east = Vector3.Normalize(enu.East); - var up = Vector3.Normalize(enu.Up); - var forward = Vector3.Normalize(enu.North); + var east = Vector3.Normalize(enu.East); + var up = Vector3.Normalize(enu.Up); + var forward = Vector3.Normalize(enu.North); - // Convert basis from ECEF to glTF Y-up and build the quaternion in that coordinate system. - var eastYUp = Vector3.Normalize(ToYUp(east)); - var upYUp = Vector3.Normalize(ToYUp(up)); - var forwardYUp = Vector3.Normalize(ToYUp(forward)); + // Convert basis from ECEF to glTF Y-up and build the quaternion in that coordinate system. + var eastYUp = Vector3.Normalize(ToYUp(east)); + var upYUp = Vector3.Normalize(ToYUp(up)); + var forwardYUp = Vector3.Normalize(ToYUp(forward)); - // Orthonormalize (numerical stability). - forwardYUp = Vector3.Normalize(Vector3.Cross(eastYUp, upYUp)); - upYUp = Vector3.Normalize(Vector3.Cross(forwardYUp, eastYUp)); + // Orthonormalize (numerical stability). + forwardYUp = Vector3.Normalize(Vector3.Cross(eastYUp, upYUp)); + upYUp = Vector3.Normalize(Vector3.Cross(forwardYUp, eastYUp)); - var m4 = GetTransformationMatrix((eastYUp, new Vector3(0, 0, 0), upYUp), forwardYUp); - var res = Quaternion.CreateFromRotationMatrix(m4); + var m4 = GetTransformationMatrix((eastYUp, new Vector3(0, 0, 0), upYUp), forwardYUp); + res = Quaternion.CreateFromRotationMatrix(m4); - var position2 = new Vector3((float)(position.X - translation.X), (float)(position.Y - translation.Y), (float)(position.Z - translation.Z)); + position2 = new Vector3((float)(position.X - translation.X), (float)(position.Y - translation.Y), (float)(position.Z - translation.Z)); + } var scale = UseScaleNonUniform ? new Vector3((float)instance.ScaleNonUniform[0], (float)instance.ScaleNonUniform[1], (float)instance.ScaleNonUniform[2]) : diff --git a/src/ImplicitTiling.cs b/src/ImplicitTiling.cs index 1196fab..f5f6712 100644 --- a/src/ImplicitTiling.cs +++ b/src/ImplicitTiling.cs @@ -56,7 +56,7 @@ public static List GenerateTiles(Options o, NpgsqlConnection conn, Boundin instances = TileClustering.Cluster(instances, o.MaxFeaturesPerTile); if (useGpuInstancing) { - SaveGpuTile(contentDirectory, tile, instances, (bool)o.UseScaleNonUniform); + SaveGpuTile(contentDirectory, tile, instances, (bool)o.UseScaleNonUniform, keepProjection); } else { @@ -95,7 +95,7 @@ public static List GenerateTiles(Options o, NpgsqlConnection conn, Boundin if (useGpuInstancing) { var instances = InstancesRepository.GetInstances(conn, o.Table, o.GeometryColumn, bbox, source_epsg, where, (bool)o.UseScaleNonUniform, useGpuInstancing, keepProjection); - SaveGpuTile(contentDirectory, tile, instances, (bool)o.UseScaleNonUniform); + SaveGpuTile(contentDirectory, tile, instances, (bool)o.UseScaleNonUniform, keepProjection); } else { @@ -112,11 +112,11 @@ public static List GenerateTiles(Options o, NpgsqlConnection conn, Boundin return tiles; } - private static void SaveGpuTile(string contentDirectory, Tile tile, List instances, bool useScaleNonUniform) + private static void SaveGpuTile(string contentDirectory, Tile tile, List instances, bool useScaleNonUniform, bool keepProjection) { var file = $"{contentDirectory}{Path.AltDirectorySeparatorChar}{tile.Z}_{tile.X}_{tile.Y}.glb"; Console.Write($"\rCreating tile: {file} "); - GPUTileHandler.SaveGPUTile(file, instances, useScaleNonUniform); + GPUTileHandler.SaveGPUTile(file, instances, useScaleNonUniform, keepProjection); } private static void SaveTile(string contentDirectory, Tile tile, byte[] bytes, bool useGpuInstancing, bool useI3dm) @@ -138,7 +138,7 @@ private static byte[] CreateTile(Options o, List instances, bool useGp if (useGpuInstancing) { - tile = GPUTileHandler.GetGPUTile(instances, (bool)o.UseScaleNonUniform); + tile = GPUTileHandler.GetGPUTile(instances, (bool)o.UseScaleNonUniform, keepProjection); } else if(!useI3dm) { diff --git a/tests/CartesianRotationTests.cs b/tests/CartesianRotationTests.cs new file mode 100644 index 0000000..5426a16 --- /dev/null +++ b/tests/CartesianRotationTests.cs @@ -0,0 +1,215 @@ +using NUnit.Framework; +using SharpGLTF.Schema2; +using SharpGLTF.Schema2.Tiles3D; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using Wkx; + +namespace i3dm.export.tests; + +public class CartesianRotationTests +{ + [Test] + public void GetLocalCartesianBasis_NoRotation_ReturnsIdentityBasis() + { + // Arrange & Act + var (east, north, up) = EnuCalculator.GetLocalCartesianBasis(yaw: 0, pitch: 0, roll: 0); + + // Assert: identity basis (X=East, Y=North, Z=Up) + Assert.That(east.X, Is.EqualTo(1).Within(1e-6)); + Assert.That(east.Y, Is.EqualTo(0).Within(1e-6)); + Assert.That(east.Z, Is.EqualTo(0).Within(1e-6)); + + Assert.That(north.X, Is.EqualTo(0).Within(1e-6)); + Assert.That(north.Y, Is.EqualTo(1).Within(1e-6)); + Assert.That(north.Z, Is.EqualTo(0).Within(1e-6)); + + Assert.That(up.X, Is.EqualTo(0).Within(1e-6)); + Assert.That(up.Y, Is.EqualTo(0).Within(1e-6)); + Assert.That(up.Z, Is.EqualTo(1).Within(1e-6)); + } + + [Test] + public void GetLocalCartesianBasis_Yaw90_RotatesAroundUp() + { + // Arrange & Act: 90 degrees clockwise yaw (around Z-up) + var (east, north, up) = EnuCalculator.GetLocalCartesianBasis(yaw: 90, pitch: 0, roll: 0); + + // Assert: East should point to -Y (south), North should point to +X (east) + Assert.That(east.X, Is.EqualTo(0).Within(1e-6)); + Assert.That(east.Y, Is.EqualTo(-1).Within(1e-6)); + Assert.That(east.Z, Is.EqualTo(0).Within(1e-6)); + + Assert.That(north.X, Is.EqualTo(1).Within(1e-6)); + Assert.That(north.Y, Is.EqualTo(0).Within(1e-6)); + Assert.That(north.Z, Is.EqualTo(0).Within(1e-6)); + + Assert.That(up.Z, Is.EqualTo(1).Within(1e-6)); + } + + [Test] + public void GetLocalCartesianBasis_Pitch90_RotatesAroundEast() + { + // Arrange & Act: 90 degrees clockwise pitch (around X-east) + var (east, north, up) = EnuCalculator.GetLocalCartesianBasis(yaw: 0, pitch: 90, roll: 0); + + // Assert: East unchanged, North points down, Up points north + Assert.That(east.X, Is.EqualTo(1).Within(1e-6)); + Assert.That(north.Z, Is.EqualTo(-1).Within(1e-6)); + Assert.That(up.Y, Is.EqualTo(1).Within(1e-6)); + } + + [Test] + public void GetLocalCartesianBasis_Roll90_RotatesAroundNorth() + { + // Arrange & Act: 90 degrees clockwise roll (around Y-north) + var (east, north, up) = EnuCalculator.GetLocalCartesianBasis(yaw: 0, pitch: 0, roll: 90); + + // Assert: North unchanged, East points up, Up points west + Assert.That(east.Z, Is.EqualTo(1).Within(1e-6)); + Assert.That(north.Y, Is.EqualTo(1).Within(1e-6)); + Assert.That(up.X, Is.EqualTo(-1).Within(1e-6)); + } + + [Test] + public void GetLocalCartesianBasis_BasisIsOrthonormal() + { + // Arrange & Act: arbitrary rotation + var (east, north, up) = EnuCalculator.GetLocalCartesianBasis(yaw: 45, pitch: 30, roll: 15); + + // Assert: vectors are unit length + Assert.That(east.Length(), Is.EqualTo(1).Within(1e-6)); + Assert.That(north.Length(), Is.EqualTo(1).Within(1e-6)); + Assert.That(up.Length(), Is.EqualTo(1).Within(1e-6)); + + // Assert: vectors are orthogonal + Assert.That(Vector3.Dot(east, north), Is.EqualTo(0).Within(1e-6)); + Assert.That(Vector3.Dot(east, up), Is.EqualTo(0).Within(1e-6)); + Assert.That(Vector3.Dot(north, up), Is.EqualTo(0).Within(1e-6)); + } + + [Test] + public void GPUTileHandler_CartesianMode_CreatesValidTile() + { + // Arrange + var instances = new List + { + new Instance + { + Position = new Point(100, 200, 50), + Scale = 1.0, + Yaw = 45, + Pitch = 10, + Roll = 5, + Model = "./testfixtures/Box.glb" + } + }; + + // Act + var tile = GPUTileHandler.GetGPUTile(instances, UseScaleNonUniform: false, keepProjection: true); + + // Assert + Assert.That(tile, Is.Not.Null); + Assert.That(tile.Length, Is.GreaterThan(0)); + + var model = ModelRoot.ParseGLB(tile); + var instancingNode = model.LogicalNodes.FirstOrDefault(n => n.GetExtension() != null); + Assert.That(instancingNode, Is.Not.Null); + } + + [Test] + public void GPUTileHandler_CartesianMode_RotationAffectsQuaternion() + { + // Arrange: Two instances with different rotations + var instanceNoRot = new Instance { Position = new Point(0, 0, 0), Scale = 1.0, Yaw = 0, Pitch = 0, Roll = 0, Model = "./testfixtures/Box.glb" }; + var instanceWithRot = new Instance { Position = new Point(0, 0, 0), Scale = 1.0, Yaw = 45, Pitch = 0, Roll = 0, Model = "./testfixtures/Box.glb" }; + + // Act + var tile1 = GPUTileHandler.GetGPUTile(new List { instanceNoRot }, false, true); + var tile2 = GPUTileHandler.GetGPUTile(new List { instanceWithRot }, false, true); + + var quat1 = GetFirstInstanceRotation(tile1); + var quat2 = GetFirstInstanceRotation(tile2); + + // Assert: rotations should be different + var dotProduct = Quaternion.Dot(quat1, quat2); + Assert.That(Math.Abs(dotProduct), Is.LessThan(0.999), "Quaternions should differ when yaw is applied"); + } + + [Test] + public void GPUTileHandler_CartesianMode_NoRotation_OrientationTest() + { + // Arrange: OrientationTest.glb for visual verification in Giro3D + var instances = new List + { + new Instance + { + Position = new Point(0, 0, 0), + Scale = 1.0, + Yaw = 0, + Pitch = 0, + Roll = 0, + Model = "./testfixtures/OrientationTest.glb" + } + }; + + // Act + var tile = GPUTileHandler.GetGPUTile(instances, UseScaleNonUniform: false, keepProjection: true); + + // Assert: Tile created successfully + // Visual test: In Giro3D with yaw=0, pitch=0, roll=0: + // - Plane with green arrow should point Z-up + // - Arrow in plane should point east + Assert.That(tile, Is.Not.Null); + Assert.That(tile.Length, Is.GreaterThan(0)); + } + + [Test] + public void GPUTileHandler_CartesianMode_PositionTransformedToYUp() + { + // Arrange + var instances = new List + { + new Instance { Position = new Point(100, 200, 50), Scale = 1.0, Yaw = 0, Pitch = 0, Roll = 0, Model = "./testfixtures/Box.glb" } + }; + + // Act + var tile = GPUTileHandler.GetGPUTile(instances, UseScaleNonUniform: false, keepProjection: true); + var model = ModelRoot.ParseGLB(tile); + var instancingNode = model.LogicalNodes.First(n => n.GetExtension() != null); + var transform = instancingNode.GetExtension().GetLocalTransform(0); + + // Assert: Position relative to RTC center (first instance) should be (0,0,0) after Y-up transform + Assert.That(transform.Translation.X, Is.EqualTo(0).Within(1e-5)); + Assert.That(transform.Translation.Y, Is.EqualTo(0).Within(1e-5)); + Assert.That(transform.Translation.Z, Is.EqualTo(0).Within(1e-5)); + } + + [Test] + public void GPUTileHandler_ECEFMode_StillUsesYUpSwizzle() + { + // Arrange + var instances = new List + { + new Instance { Position = new Point(100, 200, 50), Scale = 1.0, Yaw = 0, Pitch = 0, Roll = 0, Model = "./testfixtures/OrientationTest.glb" } + }; + + // Act: ECEF mode (keepProjection=false) + var tile = GPUTileHandler.GetGPUTile(instances, UseScaleNonUniform: false, keepProjection: false); + + // Assert + Assert.That(tile, Is.Not.Null); + var model = ModelRoot.ParseGLB(tile); + var instancingNode = model.LogicalNodes.FirstOrDefault(n => n.GetExtension() != null); + Assert.That(instancingNode, Is.Not.Null, "ECEF mode should still work"); + } + + private static Quaternion GetFirstInstanceRotation(byte[] glbBytes) + { + var model = ModelRoot.ParseGLB(glbBytes); + var instancingNode = model.LogicalNodes.First(n => n.GetExtension() != null); + return instancingNode.GetExtension().GetLocalTransform(0).Rotation; + } +}