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
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@itwin/core-frontend",
"comment": "Fix reality data not being reprojected correctly when its CRS is different than iModel",
"type": "none"
}
],
"packageName": "@itwin/core-frontend"
}
19 changes: 14 additions & 5 deletions core/frontend/src/internal/tile/RealityTileLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,13 +118,22 @@ export abstract class RealityTileLoader {
const geom = await reader?.readGltfAndCreateGeometry(transform);

// See RealityTileTree.reprojectAndResolveChildren for how reprojectionTransform is calculated
// xForm is defined in root tile CRS, while geom is defined in iModel CRS
const xForm = tile.reprojectionTransform;
if (tile.tree.reprojectGeometry && geom?.polyfaces && xForm) {
const polyfaces = geom.polyfaces.map((pf) => pf.cloneTransformed(xForm));
return { geometry: { polyfaces } };
} else {
return { geometry: geom };

if (tile.tree.reprojectGeometry && geom?.polyfaces?.length && xForm) {
// Transform from iModel/Db CRS -> root tile CRS
const dbToRoot = tile.tree.iModelTransform.inverse();

if (dbToRoot) {
// Conjugate xForm to apply it to polyfaces in iModel CRS:
// dbToRoot converts to root tile CRS, xForm applies reprojection, iModelTransform converts back
const polyfaceReprojectionTransform = tile.tree.iModelTransform.multiplyTransformTransform(xForm).multiplyTransformTransform(dbToRoot);
const polyfaces = geom.polyfaces.map((pf) => pf.cloneTransformed(polyfaceReprojectionTransform));
return { geometry: { polyfaces } };
}
}
return { geometry: geom };
}

private async loadGraphicsFromStream(tile: RealityTile, streamBuffer: ByteStream, system: RenderSystem, isCanceled?: () => boolean): Promise<TileContent> {
Expand Down
107 changes: 98 additions & 9 deletions core/frontend/src/test/tile/RealityTile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import { afterEach, beforeEach, describe, expect, it, MockInstance, vi } from "vitest";
import { ByteStream } from "@itwin/core-bentley";
import { GltfV2ChunkTypes, GltfVersions, TileFormat } from "@itwin/core-common";
import { Point3d, PolyfaceBuilder, Range3d, StrokeOptions, Transform } from "@itwin/core-geometry";
import { Angle, Matrix3d, Point3d, PolyfaceBuilder, Range3d, StrokeOptions, Transform } from "@itwin/core-geometry";
import { IModelConnection } from "../../IModelConnection";
import { IModelApp } from "../../IModelApp";
import { MockRender } from "../../internal/render/MockRender";
Expand Down Expand Up @@ -98,7 +98,7 @@ class TestRealityTree extends RealityTileTree {
public readonly contentSize: number;
protected override readonly _rootTile: TestRealityTile;

public constructor(contentSize: number, iModel: IModelConnection, loader: TestRealityTileLoader, reprojectGeometry: boolean, reprojectTransform?: Transform) {
public constructor(contentSize: number, iModel: IModelConnection, loader: TestRealityTileLoader, reprojectGeometry: boolean, reprojectTransform?: Transform, iModelTransform?: Transform) {
super({
loader,
rootTile: {
Expand All @@ -108,7 +108,7 @@ class TestRealityTree extends RealityTileTree {
},
id: (++TestRealityTree._nextId).toString(),
modelId: "0",
location: Transform.createTranslationXYZ(2, 2, 2),
location: iModelTransform ?? Transform.createIdentity(),
priority: TileLoadPriority.Primary,
iModel,
gcsConverterAvailable: false,
Expand All @@ -119,8 +119,7 @@ class TestRealityTree extends RealityTileTree {
this.treeId = TestRealityTree._nextId;
this.contentSize = contentSize;

const transformToRoot = Transform.createTranslationXYZ(10, 10, 10);
this._rootTile = new TestRealityTile(this, contentSize, reprojectTransform, transformToRoot);
this._rootTile = new TestRealityTile(this, contentSize, reprojectTransform);
}

public override get rootTile(): TestRealityTile { return this._rootTile; }
Expand Down Expand Up @@ -153,7 +152,7 @@ class TestRealityTree extends RealityTileTree {
}

class TestB3dmReader extends B3dmReader {
public override async readGltfAndCreateGeometry(_transformToRoot?: Transform, _needNormals?: boolean, _needParams?: boolean): Promise<RealityTileGeometry> {
public override async readGltfAndCreateGeometry(transformToRoot?: Transform, _needNormals?: boolean, _needParams?: boolean): Promise<RealityTileGeometry> {
// Create mock geometry data with a simple polyface
const options = StrokeOptions.createForFacets();
const polyBuilder = PolyfaceBuilder.create(options);
Expand All @@ -162,7 +161,10 @@ class TestB3dmReader extends B3dmReader {
Point3d.create(1, 0, 0),
Point3d.create(1, 1, 0)
]);
const originalPolyface = polyBuilder.claimPolyface();
let originalPolyface = polyBuilder.claimPolyface();
if (transformToRoot) {
originalPolyface = originalPolyface.cloneTransformed(transformToRoot);
}
const mockGeometry = { polyfaces: [originalPolyface] };
return mockGeometry;
}
Expand Down Expand Up @@ -316,8 +318,9 @@ describe("RealityTile", () => {

describe("RealityTileLoader", () => {
it("when loading geometry should apply both tile tree's iModelTransform and tile's transformToRoot", async () => {
const tree = new TestRealityTree(0, imodel, reader, false);
const tree = new TestRealityTree(0, imodel, reader, false, undefined, Transform.createTranslationXYZ(2, 2, 2));
const tile = tree.rootTile;
tile.transformToRoot = Transform.createTranslationXYZ(10, 10, 10);

const expectedTransform = Transform.createTranslationXYZ(2, 2, 2).multiplyTransformTransform(Transform.createTranslationXYZ(10, 10, 10));
await reader.loadGeometryFromStream(tile, streamBuffer, IModelApp.renderSystem);
Expand All @@ -329,7 +332,7 @@ describe("RealityTileLoader", () => {
});

it("when loading geometry should use only iModelTransform when transformToRoot is undefined", async () => {
const tree = new TestRealityTree(0, imodel, reader, false);
const tree = new TestRealityTree(0, imodel, reader, false, undefined, Transform.createTranslationXYZ(2, 2, 2));
const tile = tree.rootTile;
tile.transformToRoot = undefined;

Expand All @@ -342,6 +345,92 @@ describe("RealityTileLoader", () => {
expect(geometryTransform).toEqual(expectedTransform);
});

it("should apply reprojection transform correctly when tile tree's CRS differs from iModel CRS", async () => {
// iModelTransform: 90 degree rotation around Z, scale by 2, translate by (10, 20, 30)
const rotation = Matrix3d.createRotationAroundAxisIndex(2, Angle.createDegrees(90));
const rotationAndScale = rotation.scale(2);
const iModelTransform = Transform.createOriginAndMatrix(Point3d.create(10, 20, 30), rotationAndScale);

// Reprojection transform: translation of (1, 0, 0) in root tile CRS
const reprojectTransform = Transform.createTranslationXYZ(1, 0, 0);

const tree = new TestRealityTree(0, imodel, reader, true, reprojectTransform, iModelTransform);
const result = await reader.loadGeometryFromStream(tree.rootTile, streamBuffer, IModelApp.renderSystem);

expect(result.geometry).to.not.be.undefined;
expect(result.geometry?.polyfaces).to.have.length(1);

if (result.geometry?.polyfaces) {
const polyface = result.geometry.polyfaces[0];
const points = polyface.data.point.getPoint3dArray();

// Step 1: readGltfAndCreateGeometry applies iModelTransform to original points
// 90 degree rotation around Z + scale 2: (x,y,z) → (-2y, 2x, 2z) + origin (10,20,30)
// (0,0,0) → (10,20,30), (1,0,0) → (10,22,30), (1,1,0) → (8,22,30)
//
// Step 2: Conjugated reprojection: iModelTransform * xForm * iModelTransform.inverse()
// xForm Translation(1,0,0) in root tile CRS → (0,2,0) in iModel CRS after conjugation
// Final: (10,22,30), (10,24,30), (8,24,30)
expectPointToEqual(points[0], 10, 22, 30);
expectPointToEqual(points[1], 10, 24, 30);
expectPointToEqual(points[2], 8, 24, 30);
}
Comment thread
eringram marked this conversation as resolved.
});

it("should apply reprojection transform correctly when tile tree's iModelTransform is identity", async () => {
const iModelTransform = Transform.createIdentity();

// Reprojection transform is a translation
const reprojectTransform = Transform.createTranslationXYZ(3, 4, 5);

const tree = new TestRealityTree(0, imodel, reader, true, reprojectTransform, iModelTransform);
const result = await reader.loadGeometryFromStream(tree.rootTile, streamBuffer, IModelApp.renderSystem);

expect(result.geometry).to.not.be.undefined;
expect(result.geometry?.polyfaces).to.have.length(1);

if (result.geometry?.polyfaces) {
const polyface = result.geometry.polyfaces[0];
const points = polyface.data.point.getPoint3dArray();

// With identity iModelTransform, conjugation has no effect - xForm is applied directly
// Original points: (0,0,0), (1,0,0), (1,1,0)
// After reprojection: shift by (3,4,5)
expectPointToEqual(points[0], 3, 4, 5);
expectPointToEqual(points[1], 4, 4, 5);
expectPointToEqual(points[2], 4, 5, 5);
}
});

it("should skip reprojection when tile tree's iModelTransform is not invertible", async () => {
// Create a singular (non-invertible) transform by scaling X to 0
const singularMatrix = Matrix3d.createScale(0, 1, 1);
const nonInvertibleTransform = Transform.createOriginAndMatrix(Point3d.createZero(), singularMatrix);

// Reprojection transform that would shift points if applied
const reprojectTransform = Transform.createTranslationXYZ(100, 100, 100);

const tree = new TestRealityTree(0, imodel, reader, true, reprojectTransform, nonInvertibleTransform);
const result = await reader.loadGeometryFromStream(tree.rootTile, streamBuffer, IModelApp.renderSystem);

expect(result.geometry).to.not.be.undefined;
expect(result.geometry?.polyfaces).to.have.length(1);

if (result.geometry?.polyfaces) {
const polyface = result.geometry.polyfaces[0];
const points = polyface.data.point.getPoint3dArray();

// iModelTransform scales X to 0, so all X coordinates become 0
// Since iModelTransform.inverse() returns undefined, reprojection is skipped
// Original points: (0,0,0), (1,0,0), (1,1,0)
// After non-invertible iModelTransform: (0,0,0), (0,0,0), (0,1,0)
// Reprojection NOT applied (would have added 100,100,100)
expectPointToEqual(points[0], 0, 0, 0);
expectPointToEqual(points[1], 0, 0, 0);
expectPointToEqual(points[2], 0, 1, 0);
}
});

it("should load geometry from tiles in glTF format", async () => {
const gltfStreamBuffer = ByteStream.fromUint8Array(createMinimalGlb());

Expand Down
7 changes: 7 additions & 0 deletions docs/changehistory/NextVersion.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ publish: false
- [NextVersion](#nextversion)
- [@itwin/core-backend](#itwincore-backend)
- [WithQueryReader API](#withqueryreader-api)
- [Display](#display)
- [Fixes](#fixes)

## @itwin/core-backend

Expand Down Expand Up @@ -64,3 +66,8 @@ db.withQueryReader(query, (reader) => {
});
```

## Display

### Fixes

- Fixed reality data geometry not being reprojected correctly when the reality data is in a different CRS than the iModel.
Loading