66import { afterEach , beforeEach , describe , expect , it , MockInstance , vi } from "vitest" ;
77import { ByteStream } from "@itwin/core-bentley" ;
88import { 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" ;
1010import { IModelConnection } from "../../IModelConnection" ;
1111import { IModelApp } from "../../IModelApp" ;
1212import { 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
155154class 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
317319describe ( "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
0 commit comments