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);
}
});

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
64 changes: 64 additions & 0 deletions docs/changehistory/5.6.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@ version: '5.6.0'
- [analyze()](#analyze)
- [optimize()](#optimize)
- [TextAnnotation render priorities](#textannotation-render-priorities)
- [WithQueryReader API](#withqueryreader-api)
- [Display](#display)
- [BENTLEY_materials_planar_fill](#bentley_materials_planar_fill)
- [EXT_textureInfo_constant_lod](#ext_textureinfo_constant_lod)
- [Fixes](#fixes)

## Quantity Formatting

Expand Down Expand Up @@ -121,6 +123,64 @@ appendTextAnnotationGeometry({

The render priority values are added to [SubCategoryAppearance.priority]($common) to determine the final display priority. This allows text annotations to render correctly relative to other 2D graphics. Note that render priorities have no effect in 3D views.

### WithQueryReader API

A new [withQueryReader]($docs/learning/backend/WithQueryReaderCodeExamples.md) method has been added to both [ECDb]($backend) and [IModelDb]($backend), providing true row-by-row behavior for ECSQL queries with synchronous execution. This API introduces a new [ECSqlSyncReader]($backend) through the [ECSqlRowExecutor]($backend) and supports configuration via [SynchronousQueryOptions]($backend).

**Key Features:**

- **True row-by-row streaming**: Unlike the existing async reader APIs, `withQueryReader` provides synchronous row-by-row access to query results
- **Consistent API across databases**: The same interface is available on both `ECDb` and `IModelDb` instances
- **Configurable behavior**: Support for various query options through `SynchronousQueryOptions`

**Usage Examples:**

```typescript
// ECDb usage
db.withQueryReader(
"SELECT ECInstanceId, UserLabel FROM bis.Element LIMIT 100",
(reader) => {
while (reader.step()) {
const row = reader.current;
console.log(`ID: ${row.id}, Label: ${row.userLabel}`);
}
},
);

// IModelDb usage with options
iModelDb.withQueryReader(
"SELECT ECInstanceId, CodeValue FROM bis.Element",
(reader) => {
while (reader.step()) {
const row = reader.current;
processElement(row);
}
},
);
```

**Migration from deprecated APIs:**

This API serves as the recommended replacement for synchronous query scenarios previously handled by the deprecated `ECSqlStatement` for read-only operations:

```typescript
// Before - using deprecated ECSqlStatement
db.withPreparedStatement(query, (stmt) => {
while (stmt.step() === DbResult.BE_SQLITE_ROW) {
const row = stmt.getRow();
processRow(row);
}
});

// Now - using withQueryReader
db.withQueryReader(query, (reader) => {
while (reader.step()) {
const row = reader.current;
processRow(row);
}
});
```

## Display

### BENTLEY_materials_planar_fill
Expand Down Expand Up @@ -151,3 +211,7 @@ iTwin.js supports `EXT_textureInfo_constant_lod` on the `baseColorTexture` prope

The extension is not supported for `occlusionTexture` and `metallicRoughnessTexture`.

### Fixes

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

Loading