Skip to content

Commit 92b5266

Browse files
mergify[bot]eringramhl662Copilot
authored
Fix reality data not being reprojected correctly when its CRS is different than iModel (backport #9059) [release/5.7.x] (#9075)
Co-authored-by: Erin Ingram <47707444+eringram@users.noreply.github.com> Co-authored-by: Nam Le <50554904+hl662@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 5c6e5dd commit 92b5266

File tree

5 files changed

+126
-15
lines changed

5 files changed

+126
-15
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"changes": [
3+
{
4+
"packageName": "@itwin/core-frontend",
5+
"comment": "Fix reality data not being reprojected correctly when its CRS is different than iModel",
6+
"type": "none"
7+
}
8+
],
9+
"packageName": "@itwin/core-frontend"
10+
}

core/frontend/src/internal/tile/RealityTileLoader.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -118,13 +118,22 @@ export abstract class RealityTileLoader {
118118
const geom = await reader?.readGltfAndCreateGeometry(transform);
119119

120120
// See RealityTileTree.reprojectAndResolveChildren for how reprojectionTransform is calculated
121+
// xForm is defined in root tile CRS, while geom is defined in iModel CRS
121122
const xForm = tile.reprojectionTransform;
122-
if (tile.tree.reprojectGeometry && geom?.polyfaces && xForm) {
123-
const polyfaces = geom.polyfaces.map((pf) => pf.cloneTransformed(xForm));
124-
return { geometry: { polyfaces } };
125-
} else {
126-
return { geometry: geom };
123+
124+
if (tile.tree.reprojectGeometry && geom?.polyfaces?.length && xForm) {
125+
// Transform from iModel/Db CRS -> root tile CRS
126+
const dbToRoot = tile.tree.iModelTransform.inverse();
127+
128+
if (dbToRoot) {
129+
// Conjugate xForm to apply it to polyfaces in iModel CRS:
130+
// dbToRoot converts to root tile CRS, xForm applies reprojection, iModelTransform converts back
131+
const polyfaceReprojectionTransform = tile.tree.iModelTransform.multiplyTransformTransform(xForm).multiplyTransformTransform(dbToRoot);
132+
const polyfaces = geom.polyfaces.map((pf) => pf.cloneTransformed(polyfaceReprojectionTransform));
133+
return { geometry: { polyfaces } };
134+
}
127135
}
136+
return { geometry: geom };
128137
}
129138

130139
private async loadGraphicsFromStream(tile: RealityTile, streamBuffer: ByteStream, system: RenderSystem, isCanceled?: () => boolean): Promise<TileContent> {

core/frontend/src/test/tile/RealityTile.test.ts

Lines changed: 98 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import { afterEach, beforeEach, describe, expect, it, MockInstance, vi } from "vitest";
77
import { ByteStream } from "@itwin/core-bentley";
88
import { GltfV2ChunkTypes, GltfVersions, TileFormat } from "@itwin/core-common";
9-
import { Point3d, PolyfaceBuilder, Range3d, StrokeOptions, Transform } from "@itwin/core-geometry";
9+
import { Angle, Matrix3d, Point3d, PolyfaceBuilder, Range3d, StrokeOptions, Transform } from "@itwin/core-geometry";
1010
import { IModelConnection } from "../../IModelConnection";
1111
import { IModelApp } from "../../IModelApp";
1212
import { MockRender } from "../../internal/render/MockRender";
@@ -98,7 +98,7 @@ class TestRealityTree extends RealityTileTree {
9898
public readonly contentSize: number;
9999
protected override readonly _rootTile: TestRealityTile;
100100

101-
public constructor(contentSize: number, iModel: IModelConnection, loader: TestRealityTileLoader, reprojectGeometry: boolean, reprojectTransform?: Transform) {
101+
public constructor(contentSize: number, iModel: IModelConnection, loader: TestRealityTileLoader, reprojectGeometry: boolean, reprojectTransform?: Transform, iModelTransform?: Transform) {
102102
super({
103103
loader,
104104
rootTile: {
@@ -108,7 +108,7 @@ class TestRealityTree extends RealityTileTree {
108108
},
109109
id: (++TestRealityTree._nextId).toString(),
110110
modelId: "0",
111-
location: Transform.createTranslationXYZ(2, 2, 2),
111+
location: iModelTransform ?? Transform.createIdentity(),
112112
priority: TileLoadPriority.Primary,
113113
iModel,
114114
gcsConverterAvailable: false,
@@ -119,8 +119,7 @@ class TestRealityTree extends RealityTileTree {
119119
this.treeId = TestRealityTree._nextId;
120120
this.contentSize = contentSize;
121121

122-
const transformToRoot = Transform.createTranslationXYZ(10, 10, 10);
123-
this._rootTile = new TestRealityTile(this, contentSize, reprojectTransform, transformToRoot);
122+
this._rootTile = new TestRealityTile(this, contentSize, reprojectTransform);
124123
}
125124

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

155154
class TestB3dmReader extends B3dmReader {
156-
public override async readGltfAndCreateGeometry(_transformToRoot?: Transform, _needNormals?: boolean, _needParams?: boolean): Promise<RealityTileGeometry> {
155+
public override async readGltfAndCreateGeometry(transformToRoot?: Transform, _needNormals?: boolean, _needParams?: boolean): Promise<RealityTileGeometry> {
157156
// Create mock geometry data with a simple polyface
158157
const options = StrokeOptions.createForFacets();
159158
const polyBuilder = PolyfaceBuilder.create(options);
@@ -162,7 +161,10 @@ class TestB3dmReader extends B3dmReader {
162161
Point3d.create(1, 0, 0),
163162
Point3d.create(1, 1, 0)
164163
]);
165-
const originalPolyface = polyBuilder.claimPolyface();
164+
let originalPolyface = polyBuilder.claimPolyface();
165+
if (transformToRoot) {
166+
originalPolyface = originalPolyface.cloneTransformed(transformToRoot);
167+
}
166168
const mockGeometry = { polyfaces: [originalPolyface] };
167169
return mockGeometry;
168170
}
@@ -316,8 +318,9 @@ describe("RealityTile", () => {
316318

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

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

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

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

348+
it("should apply reprojection transform correctly when tile tree's CRS differs from iModel CRS", async () => {
349+
// iModelTransform: 90 degree rotation around Z, scale by 2, translate by (10, 20, 30)
350+
const rotation = Matrix3d.createRotationAroundAxisIndex(2, Angle.createDegrees(90));
351+
const rotationAndScale = rotation.scale(2);
352+
const iModelTransform = Transform.createOriginAndMatrix(Point3d.create(10, 20, 30), rotationAndScale);
353+
354+
// Reprojection transform: translation of (1, 0, 0) in root tile CRS
355+
const reprojectTransform = Transform.createTranslationXYZ(1, 0, 0);
356+
357+
const tree = new TestRealityTree(0, imodel, reader, true, reprojectTransform, iModelTransform);
358+
const result = await reader.loadGeometryFromStream(tree.rootTile, streamBuffer, IModelApp.renderSystem);
359+
360+
expect(result.geometry).to.not.be.undefined;
361+
expect(result.geometry?.polyfaces).to.have.length(1);
362+
363+
if (result.geometry?.polyfaces) {
364+
const polyface = result.geometry.polyfaces[0];
365+
const points = polyface.data.point.getPoint3dArray();
366+
367+
// Step 1: readGltfAndCreateGeometry applies iModelTransform to original points
368+
// 90 degree rotation around Z + scale 2: (x,y,z) → (-2y, 2x, 2z) + origin (10,20,30)
369+
// (0,0,0) → (10,20,30), (1,0,0) → (10,22,30), (1,1,0) → (8,22,30)
370+
//
371+
// Step 2: Conjugated reprojection: iModelTransform * xForm * iModelTransform.inverse()
372+
// xForm Translation(1,0,0) in root tile CRS → (0,2,0) in iModel CRS after conjugation
373+
// Final: (10,22,30), (10,24,30), (8,24,30)
374+
expectPointToEqual(points[0], 10, 22, 30);
375+
expectPointToEqual(points[1], 10, 24, 30);
376+
expectPointToEqual(points[2], 8, 24, 30);
377+
}
378+
});
379+
380+
it("should apply reprojection transform correctly when tile tree's iModelTransform is identity", async () => {
381+
const iModelTransform = Transform.createIdentity();
382+
383+
// Reprojection transform is a translation
384+
const reprojectTransform = Transform.createTranslationXYZ(3, 4, 5);
385+
386+
const tree = new TestRealityTree(0, imodel, reader, true, reprojectTransform, iModelTransform);
387+
const result = await reader.loadGeometryFromStream(tree.rootTile, streamBuffer, IModelApp.renderSystem);
388+
389+
expect(result.geometry).to.not.be.undefined;
390+
expect(result.geometry?.polyfaces).to.have.length(1);
391+
392+
if (result.geometry?.polyfaces) {
393+
const polyface = result.geometry.polyfaces[0];
394+
const points = polyface.data.point.getPoint3dArray();
395+
396+
// With identity iModelTransform, conjugation has no effect - xForm is applied directly
397+
// Original points: (0,0,0), (1,0,0), (1,1,0)
398+
// After reprojection: shift by (3,4,5)
399+
expectPointToEqual(points[0], 3, 4, 5);
400+
expectPointToEqual(points[1], 4, 4, 5);
401+
expectPointToEqual(points[2], 4, 5, 5);
402+
}
403+
});
404+
405+
it("should skip reprojection when tile tree's iModelTransform is not invertible", async () => {
406+
// Create a singular (non-invertible) transform by scaling X to 0
407+
const singularMatrix = Matrix3d.createScale(0, 1, 1);
408+
const nonInvertibleTransform = Transform.createOriginAndMatrix(Point3d.createZero(), singularMatrix);
409+
410+
// Reprojection transform that would shift points if applied
411+
const reprojectTransform = Transform.createTranslationXYZ(100, 100, 100);
412+
413+
const tree = new TestRealityTree(0, imodel, reader, true, reprojectTransform, nonInvertibleTransform);
414+
const result = await reader.loadGeometryFromStream(tree.rootTile, streamBuffer, IModelApp.renderSystem);
415+
416+
expect(result.geometry).to.not.be.undefined;
417+
expect(result.geometry?.polyfaces).to.have.length(1);
418+
419+
if (result.geometry?.polyfaces) {
420+
const polyface = result.geometry.polyfaces[0];
421+
const points = polyface.data.point.getPoint3dArray();
422+
423+
// iModelTransform scales X to 0, so all X coordinates become 0
424+
// Since iModelTransform.inverse() returns undefined, reprojection is skipped
425+
// Original points: (0,0,0), (1,0,0), (1,1,0)
426+
// After non-invertible iModelTransform: (0,0,0), (0,0,0), (0,1,0)
427+
// Reprojection NOT applied (would have added 100,100,100)
428+
expectPointToEqual(points[0], 0, 0, 0);
429+
expectPointToEqual(points[1], 0, 0, 0);
430+
expectPointToEqual(points[2], 0, 1, 0);
431+
}
432+
});
433+
345434
it("should load geometry from tiles in glTF format", async () => {
346435
const gltfStreamBuffer = ByteStream.fromUint8Array(createMinimalGlb());
347436

docs/changehistory/5.7.0.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ The default behavior (`batchNotify = false`) is unchanged, preserving backward c
5454

5555
Additionally, [ObservableSet]($bentley) now provides `addAll` and `deleteAll` methods for batch mutations, along with corresponding `onBatchAdded` and `onBatchDeleted` events. [CategorySelectorState]($frontend) exposes these via `addCategoriesBatched` and `dropCategoriesBatched`.
5656

57+
### Fixes
58+
59+
- Fixed reality data geometry not being reprojected correctly when the reality data is in a different CRS than the iModel.
60+
5761
## Quantity Formatting
5862

5963
### Updated default engineering lengths in QuantityFormatter

docs/changehistory/NextVersion.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,3 @@
22
publish: false
33
---
44
# NextVersion
5-

0 commit comments

Comments
 (0)