diff --git a/src/PlateauResoniteLink/Application/Importing/CityGmlDemTerrainGridCityObjectProjection.cs b/src/PlateauResoniteLink/Application/Importing/CityGmlDemTerrainGridCityObjectProjection.cs index 87d945b5..0c2e56ee 100644 --- a/src/PlateauResoniteLink/Application/Importing/CityGmlDemTerrainGridCityObjectProjection.cs +++ b/src/PlateauResoniteLink/Application/Importing/CityGmlDemTerrainGridCityObjectProjection.cs @@ -28,7 +28,7 @@ internal static bool TryProject( IDefaultMaterialResolver materialResolver, Action? progressReporter, CancellationToken cancellationToken, - out ImportedCityObject? heightMapCityObject) + out TerrainGridProjectedCityObject? heightMapCityObject) { cancellationToken.ThrowIfCancellationRequested(); heightMapCityObject = null; @@ -151,7 +151,17 @@ internal static bool TryProject( Z = slotPosition.Z + centerZ, }; - heightMapCityObject = new ImportedCityObject( + TerrainGridGeometry geometry = new( + Width: width, + Height: height, + Size: new Float2(extentX, extentZ), + MinHeight: minHeight, + MaxHeight: maxHeight, + HeightSamples: localHeights, + SampleCoverage: sampleCoverage, + UvScale: heightMapUvScale, + UvOffset: heightMapUvOffset); + ImportedCityObject projectedCityObject = new( ObjectKey: cityObject.SlotKey, DisplayName: cityObject.DisplayName, PackageName: cityObject.PackageName, @@ -160,18 +170,10 @@ internal static bool TryProject( Transform: new Transform3D( ToContractFloat3(adjustedSlotPosition), ToContractQuaternion(GridMeshTerrainRotation)), - Geometry: new TerrainGridGeometry( - Width: width, - Height: height, - Size: new Float2(extentX, extentZ), - MinHeight: minHeight, - MaxHeight: maxHeight, - HeightSamples: localHeights, - SampleCoverage: sampleCoverage, - UvScale: heightMapUvScale, - UvOffset: heightMapUvOffset), + Geometry: geometry, Materials: materials, SourceFileRelativePath: cityObject.SourceFileRelativePath); + heightMapCityObject = new TerrainGridProjectedCityObject(projectedCityObject, geometry); return true; } @@ -375,3 +377,7 @@ private sealed record DemTerrainGridBounds( double MinZ, double MaxZ); } + +internal sealed record TerrainGridProjectedCityObject( + ImportedCityObject CityObject, + TerrainGridGeometry Geometry); diff --git a/src/PlateauResoniteLink/Application/Importing/CityGmlParsedCityObjectProjection.cs b/src/PlateauResoniteLink/Application/Importing/CityGmlParsedCityObjectProjection.cs index 2f6889b3..0f8e9d40 100644 --- a/src/PlateauResoniteLink/Application/Importing/CityGmlParsedCityObjectProjection.cs +++ b/src/PlateauResoniteLink/Application/Importing/CityGmlParsedCityObjectProjection.cs @@ -251,7 +251,7 @@ internal static ImportedCityObject ProjectTerrainMeshModeCityObject( materialResolver, progressReporter, cancellationToken, - out ImportedCityObject? heightMapCityObject); + out TerrainGridProjectedCityObject? heightMapCityObject); if (!hasGrid) { return request.TerrainMeshMode == TerrainMeshMode.Dynamic @@ -261,24 +261,23 @@ internal static ImportedCityObject ProjectTerrainMeshModeCityObject( if (request.TerrainMeshMode == TerrainMeshMode.Grid) { - return heightMapCityObject!; + return heightMapCityObject!.CityObject; } - ImportedCityObject staticCityObject = CityGmlTriangleMeshCityObjectProjection.Project( + TriangleMeshProjectedCityObject staticCityObject = CityGmlTriangleMeshCityObjectProjection.ProjectTriangleMesh( cityObject, globalOriginPoint, globalCartesian, demTerrainTextureOverlay, materialResolver); - TriangleMeshGeometry staticMesh = AssertTriangleMeshGeometry(staticCityObject); TriangleMeshGeometry rebasedStaticMesh = TriangleMeshTransformRebaser.Rebase( - staticMesh, - staticCityObject.Transform, - heightMapCityObject!.Transform); - return heightMapCityObject with + staticCityObject.Geometry, + staticCityObject.CityObject.Transform, + heightMapCityObject!.CityObject.Transform); + return heightMapCityObject.CityObject with { - Geometry = new DynamicTerrainGeometry(rebasedStaticMesh, AssertTerrainGridGeometry(heightMapCityObject)), - Materials = staticCityObject.Materials, + Geometry = new DynamicTerrainGeometry(rebasedStaticMesh, heightMapCityObject.Geometry), + Materials = staticCityObject.CityObject.Materials, }; } @@ -452,18 +451,6 @@ private static Float3 CreateScenePosition( cartesian); } - private static TriangleMeshGeometry AssertTriangleMeshGeometry(ImportedCityObject cityObject) - { - return cityObject.Geometry as TriangleMeshGeometry - ?? throw new InvalidOperationException("Dynamic terrain static variant must be projected as a triangle mesh."); - } - - private static TerrainGridGeometry AssertTerrainGridGeometry(ImportedCityObject cityObject) - { - return cityObject.Geometry as TerrainGridGeometry - ?? throw new InvalidOperationException("Dynamic terrain grid variant must be projected as a terrain grid."); - } - private static bool HasRenderableGeometry(ImportedCityObject cityObject) { return cityObject.Geometry switch diff --git a/src/PlateauResoniteLink/Application/Importing/CityGmlSurfaceProjectionPolicy.cs b/src/PlateauResoniteLink/Application/Importing/CityGmlSurfaceProjectionPolicy.cs index d7893c1a..5c745aad 100644 --- a/src/PlateauResoniteLink/Application/Importing/CityGmlSurfaceProjectionPolicy.cs +++ b/src/PlateauResoniteLink/Application/Importing/CityGmlSurfaceProjectionPolicy.cs @@ -41,24 +41,24 @@ internal static HashSet GetCulledSurfaceIdsBeforeProjection( return []; } - SurfaceProjectionInfo[] candidates = surfaces - .Select(surface => CreateSurfaceProjectionInfo(surface, cityObjectOrigin, cityObjectCartesian)) - .Where(static info => info.MinimumY.HasValue && info.MaximumY.HasValue) - .ToArray(); + SurfaceProjectionInfo[] candidates = CreateSurfaceProjectionInfos( + surfaces, + cityObjectOrigin, + cityObjectCartesian); if (candidates.Length == 0) { return []; } - double objectMinimumY = candidates.Min(static info => info.MinimumY!.Value); - double objectMaximumY = candidates.Max(static info => info.MaximumY!.Value); + double objectMinimumY = candidates.Min(static info => info.MinimumY); + double objectMaximumY = candidates.Max(static info => info.MaximumY); return candidates .Where(static info => !info.IsGeneratedLod1RoofSurface) .Where(static info => info.IsNearHorizontal) - .Where(info => info.MaximumY!.Value <= objectMinimumY + BuildingBottomCullBandMeters) - .Where(info => objectMaximumY > info.MaximumY!.Value + BuildingBottomCullBandMeters) + .Where(info => info.MaximumY <= objectMinimumY + BuildingBottomCullBandMeters) + .Where(info => objectMaximumY > info.MaximumY + BuildingBottomCullBandMeters) .Select(static info => info.PolygonId) .ToHashSet(StringComparer.Ordinal); } @@ -74,10 +74,10 @@ internal static HashSet GetCulledSurfaceIdsBeforeProjection( return null; } - SurfaceProjectionInfo[] surfaceInfos = surfaces - .Select(surface => CreateSurfaceProjectionInfo(surface, cityObjectOrigin, cityObjectCartesian)) - .Where(static info => info.MinimumY.HasValue && info.MaximumY.HasValue) - .ToArray(); + SurfaceProjectionInfo[] surfaceInfos = CreateSurfaceProjectionInfos( + surfaces, + cityObjectOrigin, + cityObjectCartesian); if (surfaceInfos.Length == 0) { return null; @@ -118,43 +118,63 @@ internal static HashSet GetCulledSurfaceIdsBeforeProjection( return ComputePolygonNormal(positions); } - private static SurfaceProjectionInfo CreateSurfaceProjectionInfo( - ParsedSurface surface, + private static SurfaceProjectionInfo[] CreateSurfaceProjectionInfos( + IEnumerable surfaces, GeodeticPoint cityObjectOrigin, LocalCartesian? cityObjectCartesian) { + List infos = []; + foreach (ParsedSurface surface in surfaces) + { + if (TryCreateSurfaceProjectionInfo(surface, cityObjectOrigin, cityObjectCartesian, out SurfaceProjectionInfo info)) + { + infos.Add(info); + } + } + + return [.. infos]; + } + + private static bool TryCreateSurfaceProjectionInfo( + ParsedSurface surface, + GeodeticPoint cityObjectOrigin, + LocalCartesian? cityObjectCartesian, + out SurfaceProjectionInfo info) + { + info = default; Float3[] positions = surface.Vertices .Select(point => CreateScenePosition(point, cityObjectOrigin, cityObjectCartesian)) .ToArray(); if (positions.Length == 0) { - return new SurfaceProjectionInfo(surface.PolygonId, null, null, false, IsGeneratedLod1RoofSurface(surface.PolygonId)); + return false; } Float3? normal = ComputePolygonNormal(positions); bool isNearHorizontal = normal is not null && Math.Abs(normal.Y) >= 0.98; - return new SurfaceProjectionInfo( + info = new SurfaceProjectionInfo( surface.PolygonId, positions.Min(static position => position.Y), positions.Max(static position => position.Y), isNearHorizontal, IsGeneratedLod1RoofSurface(surface.PolygonId)); + return true; } private static (double MinimumY, double MaximumY) ResolveFacadeUvVerticalRange( IReadOnlyList contextSurfaceInfos, IReadOnlyList allSurfaceInfos) { - double minimumY = contextSurfaceInfos.Min(static info => info.MinimumY!.Value); - double maximumY = contextSurfaceInfos.Max(static info => info.MaximumY!.Value); + double minimumY = contextSurfaceInfos.Min(static info => info.MinimumY); + double maximumY = contextSurfaceInfos.Max(static info => info.MaximumY); if (maximumY - minimumY > 1e-6 || contextSurfaceInfos.Count == allSurfaceInfos.Count) { return (minimumY, maximumY); } - double fallbackMinimumY = allSurfaceInfos.Min(static info => info.MinimumY!.Value); - double fallbackMaximumY = allSurfaceInfos.Max(static info => info.MaximumY!.Value); + double fallbackMinimumY = allSurfaceInfos.Min(static info => info.MinimumY); + double fallbackMaximumY = allSurfaceInfos.Max(static info => info.MaximumY); return fallbackMaximumY - fallbackMinimumY > maximumY - minimumY ? (fallbackMinimumY, fallbackMaximumY) : (minimumY, maximumY); @@ -215,8 +235,8 @@ private static Float3 CreateScenePosition( private readonly record struct SurfaceProjectionInfo( string PolygonId, - double? MinimumY, - double? MaximumY, + double MinimumY, + double MaximumY, bool IsNearHorizontal, bool IsGeneratedLod1RoofSurface); } diff --git a/src/PlateauResoniteLink/Application/Importing/CityGmlTriangleMeshCityObjectProjection.cs b/src/PlateauResoniteLink/Application/Importing/CityGmlTriangleMeshCityObjectProjection.cs index db0efc23..a1298b7d 100644 --- a/src/PlateauResoniteLink/Application/Importing/CityGmlTriangleMeshCityObjectProjection.cs +++ b/src/PlateauResoniteLink/Application/Importing/CityGmlTriangleMeshCityObjectProjection.cs @@ -16,6 +16,21 @@ internal static ImportedCityObject Project( LocalCartesian? globalCartesian, TerrainTextureOverlay? demTerrainTextureOverlay, IDefaultMaterialResolver materialResolver) + { + return ProjectTriangleMesh( + cityObject, + globalOriginPoint, + globalCartesian, + demTerrainTextureOverlay, + materialResolver).CityObject; + } + + internal static TriangleMeshProjectedCityObject ProjectTriangleMesh( + ParsedCityObject cityObject, + GeodeticPoint globalOriginPoint, + LocalCartesian? globalCartesian, + TerrainTextureOverlay? demTerrainTextureOverlay, + IDefaultMaterialResolver materialResolver) { double? geometryHeightMeters = cityObject.GeometryHeightMeters ?? ResolveGeometryHeightMeters(cityObject.Surfaces); @@ -104,16 +119,18 @@ .. CityGmlSurfaceMaterialResolver.ResolveSurfaces( materials.Add(CityGmlSurfaceMaterialResolver.CreateMaterialBinding(cityObject.ActualMeshCode, representativeSurface, materialIndex)); } - return new ImportedCityObject( + TriangleMeshGeometry geometry = new(new ImportedMesh(vertices.ToArray(), submeshes.ToArray())); + ImportedCityObject projectedCityObject = new( ObjectKey: cityObject.SlotKey, DisplayName: cityObject.DisplayName, PackageName: cityObject.PackageName, ActualMeshCode: cityObject.ActualMeshCode, LodLevel: cityObject.LodLevel, Transform: new Transform3D(ToContractFloat3(slotPosition)), - Mesh: new ImportedMesh(vertices.ToArray(), submeshes.ToArray()), + Geometry: geometry, Materials: materials, SourceFileRelativePath: cityObject.SourceFileRelativePath); + return new TriangleMeshProjectedCityObject(projectedCityObject, geometry); } private static GeodeticPoint ResolveCityObjectOrigin(ParsedCityObject cityObject) @@ -177,3 +194,7 @@ private static Float3 CreateScenePosition( private static Float3 ToContractFloat3(Float3 value) => new(value.X, value.Y, value.Z); } + +internal sealed record TriangleMeshProjectedCityObject( + ImportedCityObject CityObject, + TriangleMeshGeometry Geometry); diff --git a/src/PlateauResoniteLink/Application/Importing/GeneratedLod1RoofCityObjectFactory.cs b/src/PlateauResoniteLink/Application/Importing/GeneratedLod1RoofCityObjectFactory.cs index c45871ba..20d8fb42 100644 --- a/src/PlateauResoniteLink/Application/Importing/GeneratedLod1RoofCityObjectFactory.cs +++ b/src/PlateauResoniteLink/Application/Importing/GeneratedLod1RoofCityObjectFactory.cs @@ -152,10 +152,10 @@ private static bool TryCreateNoWallRoofSlab( return false; } - SurfaceProjectionInfo[] surfaceInfos = cityObject.Surfaces - .Select(surface => CreateSurfaceProjectionInfo(surface, cityObjectOrigin, cityObjectCartesian)) - .Where(static info => info.MinimumY.HasValue && info.MaximumY.HasValue) - .ToArray(); + SurfaceProjectionInfo[] surfaceInfos = CreateSurfaceProjectionInfos( + cityObject.Surfaces, + cityObjectOrigin, + cityObjectCartesian); if (surfaceInfos.Length == 0) { return false; @@ -188,19 +188,19 @@ private static bool TryGetNoWallTopSurfaces( out ParsedSurface[]? topSurfaces) { topSurfaces = null; - SurfaceProjectionInfo[] surfaceInfos = cityObject.Surfaces - .Select(surface => CreateSurfaceProjectionInfo(surface, cityObjectOrigin, cityObjectCartesian)) - .Where(static info => info.MinimumY.HasValue && info.MaximumY.HasValue) - .ToArray(); + SurfaceProjectionInfo[] surfaceInfos = CreateSurfaceProjectionInfos( + cityObject.Surfaces, + cityObjectOrigin, + cityObjectCartesian); if (surfaceInfos.Length == 0) { return false; } - double objectMaximumY = surfaceInfos.Max(static info => info.MaximumY!.Value); + double objectMaximumY = surfaceInfos.Max(static info => info.MaximumY); SurfaceProjectionInfo[] topSurfaceInfos = surfaceInfos .Where(static info => info.IsNearHorizontal) - .Where(info => info.MaximumY!.Value >= objectMaximumY - 0.1) + .Where(info => info.MaximumY >= objectMaximumY - 0.1) .ToArray(); if (topSurfaceInfos.Length == 0 || topSurfaceInfos.Any(static info => info.Surface.InteriorRings.Length != 0)) @@ -217,10 +217,10 @@ private static bool TryGetNoWallTopSurfaces( .ToArray(); if (lowerSurfaceInfos.Length != 0) { - double objectMinimumY = lowerSurfaceInfos.Min(static info => info.MinimumY!.Value); + double objectMinimumY = lowerSurfaceInfos.Min(static info => info.MinimumY); topSurfaceInfos = topSurfaceInfos - .Where(info => info.MinimumY!.Value > objectMinimumY + BuildingBottomCullBandMeters) - .Where(info => info.MinimumY!.Value - NoWallRoofThicknessMeters > objectMinimumY + BuildingBottomCullBandMeters) + .Where(info => info.MinimumY > objectMinimumY + BuildingBottomCullBandMeters) + .Where(info => info.MinimumY - NoWallRoofThicknessMeters > objectMinimumY + BuildingBottomCullBandMeters) .ToArray(); if (topSurfaceInfos.Length == 0) { @@ -442,22 +442,22 @@ private static bool TryCreateFootprint( out Lod1RoofFootprint? footprint) { footprint = null; - SurfaceProjectionInfo[] surfaceInfos = cityObject.Surfaces - .Select(surface => CreateSurfaceProjectionInfo(surface, cityObjectOrigin, cityObjectCartesian)) - .Where(static info => info.MinimumY.HasValue && info.MaximumY.HasValue) - .ToArray(); + SurfaceProjectionInfo[] surfaceInfos = CreateSurfaceProjectionInfos( + cityObject.Surfaces, + cityObjectOrigin, + cityObjectCartesian); if (surfaceInfos.Length == 0) { return false; } - double objectMinimumY = surfaceInfos.Min(static info => info.MinimumY!.Value); - double objectMaximumY = surfaceInfos.Max(static info => info.MaximumY!.Value); + double objectMinimumY = surfaceInfos.Min(static info => info.MinimumY); + double objectMaximumY = surfaceInfos.Max(static info => info.MaximumY); double geometryHeight = objectMaximumY - objectMinimumY; SurfaceProjectionInfo[] topCandidates = surfaceInfos .Where(static info => info.IsNearHorizontal) - .Where(info => info.MaximumY!.Value >= objectMaximumY - 0.1) - .Where(info => info.MinimumY!.Value > objectMinimumY + BuildingBottomCullBandMeters) + .Where(info => info.MaximumY >= objectMaximumY - 0.1) + .Where(info => info.MinimumY > objectMinimumY + BuildingBottomCullBandMeters) .ToArray(); if (topCandidates.Length != 1) { @@ -495,27 +495,47 @@ private static bool TryCreateFootprint( return true; } - private static SurfaceProjectionInfo CreateSurfaceProjectionInfo( - ParsedSurface surface, + private static SurfaceProjectionInfo[] CreateSurfaceProjectionInfos( + IEnumerable surfaces, GeodeticPoint cityObjectOrigin, LocalCartesian cityObjectCartesian) { + List infos = []; + foreach (ParsedSurface surface in surfaces) + { + if (TryCreateSurfaceProjectionInfo(surface, cityObjectOrigin, cityObjectCartesian, out SurfaceProjectionInfo info)) + { + infos.Add(info); + } + } + + return [.. infos]; + } + + private static bool TryCreateSurfaceProjectionInfo( + ParsedSurface surface, + GeodeticPoint cityObjectOrigin, + LocalCartesian cityObjectCartesian, + out SurfaceProjectionInfo info) + { + info = default; Float3[] positions = surface.Vertices .Select(point => CreateScenePosition(point, cityObjectOrigin, cityObjectCartesian)) .ToArray(); if (positions.Length == 0) { - return new SurfaceProjectionInfo(surface, null, null, false); + return false; } Float3? normal = ComputePolygonNormal(positions); bool isNearHorizontal = normal is not null && Math.Abs(normal.Y) >= 0.98; - return new SurfaceProjectionInfo( + info = new SurfaceProjectionInfo( surface, positions.Min(static position => position.Y), positions.Max(static position => position.Y), isNearHorizontal); + return true; } private static GeodeticPoint[] RemoveClosingPoint(GeodeticPoint[] vertices) @@ -682,8 +702,8 @@ private static double Dot(Float3 left, Float3 right) private readonly record struct SurfaceProjectionInfo( ParsedSurface Surface, - double? MinimumY, - double? MaximumY, + double MinimumY, + double MaximumY, bool IsNearHorizontal); private readonly record struct NoWallRoofRing( diff --git a/tests/PlateauResoniteLink.Tests/Profiles/CityGmlSurfaceProjectionPolicyTests.cs b/tests/PlateauResoniteLink.Tests/Profiles/CityGmlSurfaceProjectionPolicyTests.cs index 687d5edf..c9335783 100644 --- a/tests/PlateauResoniteLink.Tests/Profiles/CityGmlSurfaceProjectionPolicyTests.cs +++ b/tests/PlateauResoniteLink.Tests/Profiles/CityGmlSurfaceProjectionPolicyTests.cs @@ -100,6 +100,28 @@ public void TryCreateFacadeUvProjectionContextIgnoresGeneratedNoWallRoofSurfaces Assert.InRange(context.Value.MaximumY, 6.0 - 1e-5, 6.0 + 1e-5); } + [Fact] + public void TryCreateFacadeUvProjectionContextSkipsEmptySurfacesBeforeResolvingHeightRange() + { + GeodeticPoint origin = new(35.0, 139.0, 0.0); + LocalCartesian cartesian = CreateCartesian(origin); + ParsedSurface empty = CreateSurface("empty", ParsedSurfaceSemantic.Wall, []); + ParsedSurface wall = CreateSurface( + "wall", + ParsedSurfaceSemantic.Wall, + CreateVerticalQuadVertices(origin, widthMeters: 8.0, heightMeters: 6.0)); + + FacadeUvProjectionContext? context = CityGmlSurfaceProjectionPolicy.TryCreateFacadeUvProjectionContext( + "bldg", + [empty, wall], + origin, + cartesian); + + Assert.NotNull(context); + Assert.InRange(context.Value.MinimumY, -1e-5, 1e-5); + Assert.InRange(context.Value.MaximumY, 6.0 - 1e-5, 6.0 + 1e-5); + } + [Fact] public void GetCulledSurfaceIdsBeforeProjectionKeepsGeneratedNoWallRoofBottomAtObjectMinimum() { diff --git a/tests/PlateauResoniteLink.Tests/Profiles/GeneratedLod1RoofCityObjectFactoryTests.cs b/tests/PlateauResoniteLink.Tests/Profiles/GeneratedLod1RoofCityObjectFactoryTests.cs index 4535893c..2caa5428 100644 --- a/tests/PlateauResoniteLink.Tests/Profiles/GeneratedLod1RoofCityObjectFactoryTests.cs +++ b/tests/PlateauResoniteLink.Tests/Profiles/GeneratedLod1RoofCityObjectFactoryTests.cs @@ -508,6 +508,33 @@ BuildingAttributeContext.Empty with static surface => Assert.Null(surface.TexturePayload)); } + [Fact] + public void CreateSkipsEmptySurfacesBeforeSelectingGeneratedRoofTopCandidate() + { + ParsedSurface empty = CreateSurface("empty", ParsedSurfaceSemantic.Roof, [], null, texturePayload: null); + ParsedSurface top = CreateSurface( + "lod1-top", + ParsedSurfaceSemantic.Roof, + altitude: 10.0); + ParsedSurface bottom = CreateSurface( + "lod1-bottom", + ParsedSurfaceSemantic.Ground, + altitude: 0.0); + ParsedCityObject cityObject = CreateCityObject( + [empty, top, bottom], + CoordinateReferenceSystem.Parse("EPSG:6697"), + BuildingAttributeContext.Empty with + { + RoofShape = new BuildingCodeValue(CityGmlRoofShape.Shed, "shed"), + }); + + ParsedCityObject generated = GeneratedLod1RoofCityObjectFactory.Create(cityObject); + + Assert.DoesNotContain(generated.Surfaces, static surface => surface.PolygonId == "lod1-top"); + Assert.Contains(generated.Surfaces, static surface => surface.PolygonId == "empty"); + Assert.Equal(4, generated.Surfaces.Count(static surface => surface.PolygonId.Contains("_generated_", System.StringComparison.Ordinal))); + } + [Fact] public void CreateSkipsObjectThatAlreadyHasGeneratedRoofSurface() {