diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml new file mode 100644 index 0000000..d2514e0 --- /dev/null +++ b/.github/workflows/pr-preview.yml @@ -0,0 +1,83 @@ +name: PR Preview Screenshot + +# Loads the default location in Unity play mode, renders a screenshot, and +# posts a direct download link as a comment on the pull request. +# +# Required repository secrets +# ──────────────────────────── +# UNITY_LICENSE – contents of a valid Unity .ulf license file +# UNITY_EMAIL – Unity account e-mail +# UNITY_PASSWORD – Unity account password + +on: + pull_request: + branches: [ main ] + +jobs: + screenshot: + name: Capture play-mode screenshot + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + pull-requests: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + lfs: true + + - name: Cache Unity Library + uses: actions/cache@v4 + with: + path: Library + key: Library-screenshot-${{ hashFiles('Assets/**', 'Packages/**', 'ProjectSettings/**') }} + restore-keys: | + Library-screenshot- + Library- + + - name: Run play-mode screenshot test + uses: game-ci/unity-test-runner@v4 + id: screenshot-test + env: + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + with: + testMode: playMode + testFilter: VectorRoad.Tests.PlayMode.SceneScreenshotTests + artifactsPath: TestResults/playMode + githubToken: ${{ secrets.GITHUB_TOKEN }} + checkName: PR Preview Screenshot + + - name: Upload screenshot artifact + id: upload-screenshot + uses: actions/upload-artifact@v4 + if: always() + with: + name: pr-preview-screenshot + path: Screenshots/pr-preview.png + if-no-files-found: warn + retention-days: 14 + + - name: Post PR comment with download link + uses: actions/github-script@v7 + if: always() + env: + ARTIFACT_URL: ${{ steps.upload-screenshot.outputs.artifact-url }} + with: + script: | + const artifactUrl = process.env.ARTIFACT_URL; + const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + + const body = artifactUrl + ? `## 📸 PR Preview Screenshot\n\nA screenshot of the default location in play mode was captured for this PR.\n\n**[⬇️ Download Screenshot](${artifactUrl})**\n\n> Rendered at [workflow run](${runUrl})` + : `## 📸 PR Preview Screenshot\n\n⚠️ The screenshot could not be captured for this PR.\n\nSee the [workflow run](${runUrl}) for details.`; + + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body, + }); diff --git a/Assets/Scripts/Core/MapSceneBuilder.cs b/Assets/Scripts/Core/MapSceneBuilder.cs index 92ac172..891fe66 100644 --- a/Assets/Scripts/Core/MapSceneBuilder.cs +++ b/Assets/Scripts/Core/MapSceneBuilder.cs @@ -269,6 +269,17 @@ private void BuildRoad( surfaceGo.AddComponent().sharedMesh = result.RoadMesh; surfaceGo.layer = LayerMask.NameToLayer("Road"); + if (result.LaneMarkingMesh != null && result.LaneMarkingMesh.vertexCount > 0 + && !string.IsNullOrEmpty(result.LaneMarkingTextureId)) + { + var laneGo = new GameObject("LaneMarking"); + laneGo.transform.SetParent(parent.transform, false); + laneGo.AddComponent().sharedMesh = result.LaneMarkingMesh; + var laneRenderer = laneGo.AddComponent(); + Registry?.ApplyTo(laneRenderer, result.LaneMarkingTextureId); + laneGo.layer = LayerMask.NameToLayer("Road"); + } + if (result.KerbMesh != null && result.KerbMesh.vertexCount > 0) { var kerbGo = new GameObject("Kerb"); @@ -277,6 +288,87 @@ private void BuildRoad( var kerbRenderer = kerbGo.AddComponent(); Registry?.ApplyTo(kerbRenderer, result.KerbTextureId); } + + if (result.DitchMesh != null && result.DitchMesh.vertexCount > 0 + && !string.IsNullOrEmpty(result.DitchTextureId)) + { + var ditchGo = new GameObject("Ditch"); + ditchGo.transform.SetParent(parent.transform, false); + ditchGo.AddComponent().sharedMesh = result.DitchMesh; + var ditchRenderer = ditchGo.AddComponent(); + Registry?.ApplyTo(ditchRenderer, result.DitchTextureId); + } + + var props = RoadsidePropPlacer.Place(finalSpline, roadType, region: region, wayId: road.WayId); + foreach (PropPlacement prop in props) + SpawnPropCollider(prop, parent.transform); + } + + private void SpawnPropCollider(PropPlacement prop, Transform parent) + { + var go = new GameObject($"Prop_{prop.Type}"); + go.transform.SetParent(parent, false); + go.transform.position = prop.Position; + go.transform.forward = prop.Forward; + + switch (prop.Type) + { + case PropType.LampPost: + case PropType.SignPost: + { + var col = go.AddComponent(); + col.radius = 0.1f; + col.height = 4f; + col.center = new Vector3(0f, 2f, 0f); + + string textureId = prop.Type == PropType.LampPost ? "prop_lamppost" : "prop_signpost"; + AddCapsuleVisual(go, scale: new Vector3(0.2f, 2f, 0.2f), centerY: 2f, textureId: textureId); + break; + } + + case PropType.Tree: + { + var col = go.AddComponent(); + col.radius = 0.3f; + col.height = 4f; + col.center = new Vector3(0f, 2f, 0f); + + AddCapsuleVisual(go, scale: new Vector3(0.6f, 2f, 0.6f), centerY: 2f, textureId: "prop_tree"); + break; + } + + case PropType.Fence: + { + var col = go.AddComponent(); + col.size = new Vector3(2f, 1.5f, 0.1f); + col.center = new Vector3(0f, 0.75f, 0f); + + AddBoxVisual(go, scale: new Vector3(2f, 1.5f, 0.1f), centerY: 0.75f, textureId: "prop_fence"); + break; + } + } + } + + private void AddCapsuleVisual(GameObject parent, Vector3 scale, float centerY, string textureId) + { + var visual = new GameObject("Mesh"); + visual.transform.SetParent(parent.transform, false); + visual.transform.localPosition = new Vector3(0f, centerY, 0f); + visual.transform.localScale = scale; + visual.AddComponent().sharedMesh = Resources.GetBuiltinResource("Capsule.fbx"); + var mr = visual.AddComponent(); + Registry?.ApplyTo(mr, textureId); + } + + private void AddBoxVisual(GameObject parent, Vector3 scale, float centerY, string textureId) + { + var visual = new GameObject("Mesh"); + visual.transform.SetParent(parent.transform, false); + visual.transform.localPosition = new Vector3(0f, centerY, 0f); + visual.transform.localScale = scale; + visual.AddComponent().sharedMesh = Resources.GetBuiltinResource("Cube.fbx"); + var mr = visual.AddComponent(); + Registry?.ApplyTo(mr, textureId); } private static Vector3[] ClampRoadSplineToTerrain( @@ -419,12 +511,14 @@ private void BuildBuilding(BuildingFootprint building, RegionType region) wallGo.AddComponent().sharedMesh = result.WallMesh; var wallRenderer = wallGo.AddComponent(); Registry?.ApplyTo(wallRenderer, result.WallTextureId); + wallGo.AddComponent().sharedMesh = result.WallMesh; var roofGo = new GameObject("Roof"); roofGo.transform.SetParent(parent.transform, false); roofGo.AddComponent().sharedMesh = result.RoofMesh; var roofRenderer = roofGo.AddComponent(); Registry?.ApplyTo(roofRenderer, result.RoofTextureId); + roofGo.AddComponent().sharedMesh = result.RoofMesh; } private void BuildWater(WaterBody water, RegionType region) diff --git a/Assets/Scripts/DataInversion/OSMParser.cs b/Assets/Scripts/DataInversion/OSMParser.cs index cf245ba..c15e53f 100644 --- a/Assets/Scripts/DataInversion/OSMParser.cs +++ b/Assets/Scripts/DataInversion/OSMParser.cs @@ -333,10 +333,14 @@ private static RegionType CountryCodeToRegion(string countryCode) "GB" or "IE" or "DE" or "FR" or "NL" or "BE" or "LU" or "AT" or "CH" or "PL" or "CZ" or "SK" or "HU" or "RO" or "BG" or "SI" or "RS" or "BA" or "ME" or "MK" or "AL" or - "LT" or "LV" or "EE" or "US" or "CA" or "JP" or "KR" or + "LT" or "LV" or "EE" or "JP" or "KR" or "NZ" or "CN" or "AR" or "CL" => RegionType.Temperate, + // ── Temperate North America ──────────────────────────────────── + "US" or "CA" + => RegionType.TemperateNorthAmerica, + // ── Desert ───────────────────────────────────────────────────── "SA" or "AE" or "QA" or "KW" or "OM" or "BH" or "YE" or "IQ" or "IR" or "EG" or "LY" or "DZ" or "MA" or "MR" or diff --git a/Assets/Scripts/DataInversion/RegionType.cs b/Assets/Scripts/DataInversion/RegionType.cs index 33d1a8e..1c9c696 100644 --- a/Assets/Scripts/DataInversion/RegionType.cs +++ b/Assets/Scripts/DataInversion/RegionType.cs @@ -11,10 +11,17 @@ public enum RegionType /// /// Temperate broadleaf-forest climate (four seasons, moderate rainfall). - /// Typical of western and central Europe, most of the USA, and eastern Asia. + /// Typical of western and central Europe, and eastern Asia. /// Temperate, + /// + /// Temperate North American climate (four seasons, moderate rainfall). + /// Covers the USA and Canada, where roads are typically built to wider + /// standards than their European equivalents. + /// + TemperateNorthAmerica, + /// /// Hot desert or arid climate (very low rainfall, extreme heat). /// Typical of the Middle East, North Africa, and the Australian interior. diff --git a/Assets/Scripts/Procedural/PlaceholderMaterialFactory.cs b/Assets/Scripts/Procedural/PlaceholderMaterialFactory.cs index 20a2853..54fd4cb 100644 --- a/Assets/Scripts/Procedural/PlaceholderMaterialFactory.cs +++ b/Assets/Scripts/Procedural/PlaceholderMaterialFactory.cs @@ -70,6 +70,12 @@ internal static class PlaceholderMaterialFactory // Lane markings "lane_marking_oneway", "lane_marking_twoway", + + // Roadside props + "prop_lamppost", + "prop_signpost", + "prop_tree", + "prop_fence", }; // ── Public API ───────────────────────────────────────────────────────── @@ -157,6 +163,15 @@ private static Color GetPlaceholderColor(string id) if (id.StartsWith("lane_marking")) return Color.white; + if (id == "prop_lamppost" || id == "prop_signpost") + return new Color(0.60f, 0.60f, 0.60f); // mid grey metal post + + if (id == "prop_tree") + return new Color(0.30f, 0.50f, 0.20f); // muted olive green + + if (id == "prop_fence") + return new Color(0.65f, 0.55f, 0.45f); // weathered wood + return new Color(0.50f, 0.50f, 0.50f); // neutral fallback } } diff --git a/Assets/Scripts/Procedural/RegionTextures.cs b/Assets/Scripts/Procedural/RegionTextures.cs index 0facd8a..d9d7217 100644 --- a/Assets/Scripts/Procedural/RegionTextures.cs +++ b/Assets/Scripts/Procedural/RegionTextures.cs @@ -39,14 +39,15 @@ public static string GetRoadSurfaceTextureId(RegionType region, RoadType roadTyp return region switch { - RegionType.Temperate => "road_asphalt_temperate", - RegionType.Desert => "road_asphalt_desert", - RegionType.Tropical => "road_asphalt_tropical", - RegionType.Boreal => "road_asphalt_boreal", - RegionType.Arctic => "road_asphalt_arctic", - RegionType.Mediterranean => "road_asphalt_mediterranean", - RegionType.Steppe => "road_asphalt_steppe", - _ => "road_asphalt", + RegionType.Temperate => "road_asphalt_temperate", + RegionType.TemperateNorthAmerica => "road_asphalt_temperate", + RegionType.Desert => "road_asphalt_desert", + RegionType.Tropical => "road_asphalt_tropical", + RegionType.Boreal => "road_asphalt_boreal", + RegionType.Arctic => "road_asphalt_arctic", + RegionType.Mediterranean => "road_asphalt_mediterranean", + RegionType.Steppe => "road_asphalt_steppe", + _ => "road_asphalt", }; } @@ -62,14 +63,15 @@ public static string GetKerbTextureId(RegionType region) { return region switch { - RegionType.Temperate => "kerb_stone", - RegionType.Desert => "kerb_concrete", - RegionType.Tropical => "kerb_concrete", - RegionType.Boreal => "kerb_stone", - RegionType.Arctic => "kerb_concrete", - RegionType.Mediterranean => "kerb_granite", - RegionType.Steppe => "kerb_concrete", - _ => "kerb_stone", + RegionType.Temperate => "kerb_stone", + RegionType.TemperateNorthAmerica => "kerb_concrete", + RegionType.Desert => "kerb_concrete", + RegionType.Tropical => "kerb_concrete", + RegionType.Boreal => "kerb_stone", + RegionType.Arctic => "kerb_concrete", + RegionType.Mediterranean => "kerb_granite", + RegionType.Steppe => "kerb_concrete", + _ => "kerb_stone", }; } @@ -85,14 +87,15 @@ public static string GetWallTextureId(RegionType region) { return region switch { - RegionType.Temperate => "building_wall_brick", - RegionType.Desert => "building_wall_sandstone", - RegionType.Tropical => "building_wall_stucco", - RegionType.Boreal => "building_wall_timber", - RegionType.Arctic => "building_wall_concrete", - RegionType.Mediterranean => "building_wall_stucco", - RegionType.Steppe => "building_wall_concrete", - _ => "building_wall_brick", + RegionType.Temperate => "building_wall_brick", + RegionType.TemperateNorthAmerica => "building_wall_brick", + RegionType.Desert => "building_wall_sandstone", + RegionType.Tropical => "building_wall_stucco", + RegionType.Boreal => "building_wall_timber", + RegionType.Arctic => "building_wall_concrete", + RegionType.Mediterranean => "building_wall_stucco", + RegionType.Steppe => "building_wall_concrete", + _ => "building_wall_brick", }; } @@ -108,14 +111,15 @@ public static string GetRoofTextureId(RegionType region) { return region switch { - RegionType.Temperate => "building_roof_slate", - RegionType.Desert => "building_roof_terracotta", - RegionType.Tropical => "building_roof_terracotta", - RegionType.Boreal => "building_roof_metal", - RegionType.Arctic => "building_roof_metal", - RegionType.Mediterranean => "building_roof_terracotta", - RegionType.Steppe => "building_roof_flat", - _ => "building_roof_slate", + RegionType.Temperate => "building_roof_slate", + RegionType.TemperateNorthAmerica => "building_roof_slate", + RegionType.Desert => "building_roof_terracotta", + RegionType.Tropical => "building_roof_terracotta", + RegionType.Boreal => "building_roof_metal", + RegionType.Arctic => "building_roof_metal", + RegionType.Mediterranean => "building_roof_terracotta", + RegionType.Steppe => "building_roof_flat", + _ => "building_roof_slate", }; } @@ -153,6 +157,26 @@ public static string GetWaterTextureId(RegionType region) }; } + // ── Roadside ditch ───────────────────────────────────────────────────── + + /// + /// Returns the texture identifier for the roadside ditch surface appropriate to + /// the given climate region. Ditches appear on rural roads and are typically + /// covered with grass or bare earth. + /// + /// Climate zone of the map area. + /// A lowercase underscore-separated texture asset name. + public static string GetDitchTextureId(RegionType region) + { + return region switch + { + RegionType.Desert => "terrain_sand", + RegionType.Arctic => "terrain_snow", + RegionType.Tropical => "terrain_mud", + _ => "terrain_grass", + }; + } + // ── Private helpers ──────────────────────────────────────────────────── /// diff --git a/Assets/Scripts/Procedural/RegionWidthFactors.cs b/Assets/Scripts/Procedural/RegionWidthFactors.cs new file mode 100644 index 0000000..e4ce0dc --- /dev/null +++ b/Assets/Scripts/Procedural/RegionWidthFactors.cs @@ -0,0 +1,89 @@ +using System.Collections.Generic; +using VectorRoad.DataInversion; + +namespace VectorRoad.Procedural +{ + /// + /// Provides region-based width multipliers and road-type shoulder widths used to + /// calculate realistic road carriageway widths. + /// + /// Road width formula (when a lane count is known): + /// + /// width = (lanes × laneWidth + ShoulderWidth(roadType)) × GetWidthFactor(region) + /// + /// + /// When no lane count is available the caller falls back to the canonical + /// table value, + /// scaled by . + /// + public static class RegionWidthFactors + { + /// + /// Width multipliers keyed by . + /// Values above 1.0 produce wider roads; values below 1.0 produce narrower roads + /// relative to the European/temperate baseline. + /// + /// Reference baselines (lane widths, metres): + /// USA/Canada 3.6 – 3.7 m → factor 1.1 + /// Western Europe 3.25 – 3.5 m → factor 1.0 + /// Developing regions often narrower → factors 0.85 – 0.95 + /// + private static readonly Dictionary WidthFactors = + new Dictionary + { + { RegionType.TemperateNorthAmerica, 1.10f }, // USA / Canada: wider lanes and shoulders + { RegionType.Temperate, 1.00f }, // Western/Central Europe, East Asia: baseline + { RegionType.Mediterranean, 1.00f }, // Southern Europe: similar to Temperate + { RegionType.Boreal, 1.00f }, // Scandinavia / Russia: same infrastructure standard + { RegionType.Desert, 0.95f }, // Middle East / North Africa: slightly narrower + { RegionType.Arctic, 0.90f }, // Greenland / Iceland: limited infrastructure + { RegionType.Tropical, 0.90f }, // Equatorial Africa / SE Asia / Central America + { RegionType.Steppe, 0.85f }, // Central Asia: developing-region standard + { RegionType.Unknown, 1.00f }, // No region data: use baseline + }; + + /// + /// Total shoulder width in metres (both sides combined) added to the lane + /// carriageway for each . + /// + /// A shoulder provides a safety buffer and emergency stopping area. Higher- + /// class roads have wider hard shoulders; local and path roads have none. + /// + private static readonly Dictionary ShoulderWidths = + new Dictionary + { + { RoadType.Motorway, 6.0f }, // 3 m hard shoulder each side + { RoadType.Trunk, 5.0f }, // 2.5 m each side + { RoadType.Primary, 3.0f }, // 1.5 m each side + { RoadType.Secondary, 1.5f }, // 0.75 m each side + { RoadType.Tertiary, 1.0f }, // 0.5 m each side + { RoadType.Residential, 0.5f }, // narrow verge / parking strip + { RoadType.Service, 0.0f }, // access lane — no shoulder + { RoadType.Dirt, 0.0f }, // unsurfaced track + { RoadType.Path, 0.0f }, // footpath + { RoadType.Cycleway, 0.0f }, // cycle lane + { RoadType.Unknown, 0.0f }, // no shoulder data — safe fallback + }; + + /// + /// Returns the width multiplier for the given . + /// Values greater than 1.0 indicate roads wider than the European baseline; + /// values less than 1.0 indicate narrower roads. + /// Returns 1.0 for any region not explicitly mapped. + /// + /// The region type to look up. + /// A positive floating-point multiplier. + public static float GetWidthFactor(RegionType region) => + WidthFactors.TryGetValue(region, out float f) ? f : 1.0f; + + /// + /// Returns the total shoulder width in metres (both sides combined) appropriate + /// for the given . + /// Returns 0 for any road type not explicitly mapped. + /// + /// The functional road classification. + /// Total shoulder width in metres (≥ 0). + public static float GetShoulderWidth(RoadType roadType) => + ShoulderWidths.TryGetValue(roadType, out float s) ? s : 0.0f; + } +} diff --git a/Assets/Scripts/Procedural/RoadMeshExtruder.cs b/Assets/Scripts/Procedural/RoadMeshExtruder.cs index 5a873e7..c647dbd 100644 --- a/Assets/Scripts/Procedural/RoadMeshExtruder.cs +++ b/Assets/Scripts/Procedural/RoadMeshExtruder.cs @@ -25,9 +25,34 @@ public static class RoadMeshExtruder /// Width of each kerb strip in metres. public const float DefaultKerbWidth = 0.15f; - /// Height of kerb surfaces above the road plane in metres. + /// Height of kerb surfaces above the road plane in metres for rural roads. public const float DefaultKerbHeight = 0.05f; + /// + /// Height of the kerb (curb) above the road surface for urban roads, in metres. + /// Standard 15 cm raised kerb used on residential streets and service roads. + /// + public const float UrbanKerbHeight = 0.15f; + + /// + /// Depth of the roadside ditch below the adjacent road surface, in metres. + /// Applied to rural roads (motorway through tertiary, dirt, path, cycleway). + /// + public const float RuralDitchDepth = 1.0f; + + /// + /// Total horizontal width of the roadside ditch profile on each side, in metres. + /// The V-shaped channel spans from the road edge outward by this distance and + /// returns to terrain level at the outer edge. + /// + public const float RuralDitchWidth = 3.0f; + + /// + /// Vertical offset (in metres) applied to lane-marking overlay vertices to keep + /// them above the road surface and prevent z-fighting. + /// + public const float LaneMarkingClearance = 0.005f; + /// /// Constant Y offset (in metres) applied to all road vertices so the road /// surface always sits physically above the grass/terrain layer and never @@ -78,6 +103,7 @@ public static float GetWidthForRoadType(RoadType roadType) => /// /// Returns the road width in metres, derived from an explicit lane count when /// available, or from the road-type lookup as a fallback. + /// No shoulder width or region factor is applied. /// /// The functional road classification (used when is 0). /// @@ -89,6 +115,49 @@ public static float GetWidthForRoadType(RoadType roadType) => public static float GetWidthForRoadType(RoadType roadType, int lanes) => lanes > 0 ? lanes * DefaultLaneWidth : GetWidthForRoadType(roadType); + /// + /// Returns the road width in metres using the full regional formula: + /// + /// lanes > 0 : (lanes × + shoulderWidth) × regionFactor + /// lanes == 0 : baseTableWidth × regionFactor + /// + /// Shoulder widths are taken from ; + /// the region multiplier is taken from . + /// + /// The functional road classification. + /// + /// Number of lanes from the OSM lanes tag. When greater than zero the + /// carriageway width (lanes × ) plus the + /// road-type shoulder width is used; otherwise the canonical table value is used. + /// + /// + /// Geographic/climate region used to scale the computed width. + /// Defaults to (factor 1.0 — no adjustment). + /// + /// Width in metres. + public static float GetWidthForRoadType(RoadType roadType, int lanes, RegionType region) + { + float regionFactor = RegionWidthFactors.GetWidthFactor(region); + if (lanes > 0) + { + float shoulder = RegionWidthFactors.GetShoulderWidth(roadType); + return (lanes * DefaultLaneWidth + shoulder) * regionFactor; + } + return GetWidthForRoadType(roadType) * regionFactor; + } + + /// + /// Returns true when is considered urban — + /// i.e. it is likely to be bordered by a raised kerb rather than an open ditch. + /// Residential streets and service roads are treated as urban; all other + /// functional classes (motorway through tertiary, dirt, path, cycleway) are + /// treated as rural and will receive roadside ditches. + /// + /// Road classification to test. + /// true for urban road types; false for rural. + public static bool IsUrbanRoadType(RoadType roadType) => + roadType == RoadType.Residential || roadType == RoadType.Service; + /// /// Generates a flat road mesh extruded along , using /// the canonical width for . @@ -194,6 +263,20 @@ public static Mesh Extrude(IList splinePoints, float roadWidth = 7f, fl /// for . The returned /// includes region-appropriate texture identifiers for the road surface, /// the kerb, and the lane markings. + /// + /// + /// Urban road types ( and + /// ) receive a (15 cm) + /// raised kerb. All other road types receive roadside ditches that are + /// (1 m) deep and + /// (3 m) wide, rather than a kerb. + /// + /// + /// + /// Paved road types also receive a + /// overlay (positioned slightly above the road surface) that can be rendered + /// with a lane-marking material to make centre lines and edge markings visible. + /// /// /// /// Ordered world-space centre-line positions. Requires at least two points. @@ -206,8 +289,7 @@ public static Mesh Extrude(IList splinePoints, float roadWidth = 7f, fl /// Lane-marking texture V-tile length in metres (UV channel 1). Default 6 m /// — matching a standard dashed-line repeat (3 m dash + 3 m gap). /// - /// Width of each kerb strip in metres. - /// Height of kerb surfaces above the road plane. + /// Width of each kerb strip in metres (urban roads only). /// /// Climate zone used to select region-appropriate texture identifiers. /// Defaults to . @@ -221,8 +303,9 @@ public static Mesh Extrude(IList splinePoints, float roadWidth = 7f, fl /// /// /// Number of lanes from the OSM lanes tag. When greater than zero the - /// road width is computed as lanes × ; - /// otherwise the road-type width table is used. Defaults to 0 (use table). + /// road width is computed as (lanes × + shoulderWidth) × regionFactor; + /// otherwise the road-type width table value scaled by the region factor is used. + /// Defaults to 0 (use table). /// /// /// true when the OSM oneway tag indicates single-direction traffic. @@ -230,7 +313,8 @@ public static Mesh Extrude(IList splinePoints, float roadWidth = 7f, fl /// /// /// A containing the road mesh, the kerb mesh, - /// and region-appropriate texture identifiers. + /// the lane-marking overlay mesh, the optional ditch mesh, and + /// region-appropriate texture identifiers. /// public static RoadMeshResult ExtrudeWithDetails( IList splinePoints, @@ -238,28 +322,32 @@ public static RoadMeshResult ExtrudeWithDetails( float uvTileLength = 10f, float laneMarkingTileLength = DefaultLaneMarkingTileLength, float kerbWidth = DefaultKerbWidth, - float kerbHeight = DefaultKerbHeight, RegionType region = RegionType.Unknown, int? surfaceSeed = null, int lanes = 0, - bool isOneWay = false) => - ExtrudeWithDetails( + bool isOneWay = false) + { + bool urban = IsUrbanRoadType(roadType); + return ExtrudeWithDetails( splinePoints, - GetWidthForRoadType(roadType, lanes), + GetWidthForRoadType(roadType, lanes, region), uvTileLength, laneMarkingTileLength, kerbWidth, - kerbHeight, + kerbHeight: urban ? UrbanKerbHeight : 0f, region, roadType, surfaceSeed, - isOneWay); + isOneWay, + generateDitch: !urban); + } /// /// Generates a road surface mesh (with UV0 for asphalt tiling and UV1 for /// lane-marking tiling) plus a separate kerb mesh. The /// returned includes region-appropriate texture identifiers for the road surface, - /// the kerb, and the lane markings. + /// the kerb, and the lane markings, as well as an optional lane-marking overlay + /// mesh and an optional roadside ditch mesh. /// /// /// Ordered world-space centre-line positions. Requires at least two points. @@ -272,13 +360,17 @@ public static RoadMeshResult ExtrudeWithDetails( /// Lane-marking texture V-tile length in metres (UV channel 1). Default 6 m. /// /// Width of each kerb strip in metres. - /// Height of kerb surfaces above the road plane. + /// + /// Height of kerb surfaces above the road plane. Pass 0 to suppress kerb + /// generation (no kerb mesh vertices are emitted). + /// /// /// Climate zone used to select region-appropriate texture identifiers. /// Defaults to . /// /// - /// Road classification used for surface texture selection. + /// Road classification used for surface texture selection and lane-marking + /// overlay generation (paved types receive a lane-marking mesh; unpaved do not). /// Defaults to . /// /// @@ -292,8 +384,14 @@ public static RoadMeshResult ExtrudeWithDetails( /// true when the OSM oneway tag indicates single-direction traffic. /// Affects which lane-marking texture is selected. Defaults to false. /// + /// + /// When true, a V-profile roadside ditch mesh is generated on both sides + /// of the carriageway (see and + /// ). Defaults to false. + /// /// /// A containing the road mesh, the kerb mesh, + /// the lane-marking overlay mesh (for paved road types), the optional ditch mesh, /// and region-appropriate texture identifiers. /// public static RoadMeshResult ExtrudeWithDetails( @@ -306,7 +404,8 @@ public static RoadMeshResult ExtrudeWithDetails( RegionType region = RegionType.Unknown, RoadType roadType = RoadType.Unknown, int? surfaceSeed = null, - bool isOneWay = false) + bool isOneWay = false, + bool generateDitch = false) { if (splinePoints == null || splinePoints.Count < 2) { @@ -377,15 +476,36 @@ public static RoadMeshResult ExtrudeWithDetails( roadMesh.RecalculateNormals(); roadMesh.RecalculateBounds(); + // ── Lane-marking overlay mesh ──────────────────────────────────── + // Generated for all paved road types so lane lines are visible in-game. + bool hasPavedMarkings = roadType != RoadType.Dirt + && roadType != RoadType.Path + && roadType != RoadType.Cycleway; + Mesh? laneMarkingMesh = hasPavedMarkings + ? BuildLaneMarkingMesh(pts, halfWidth, laneMarkingTileLength) + : null; + // ── Kerb mesh ──────────────────────────────────────────────────── - Mesh kerbMesh = BuildKerbMesh(pts, halfWidth, kerbWidth, kerbHeight, uvTileLength); + // Skipped when kerbHeight is zero (rural roads use ditches instead). + Mesh kerbMesh = kerbHeight > 0f + ? BuildKerbMesh(pts, halfWidth, kerbWidth, kerbHeight, uvTileLength) + : new Mesh { name = "KerbMesh" }; + + // ── Ditch mesh ─────────────────────────────────────────────────── + Mesh? ditchMesh = generateDitch + ? BuildDitchMesh(pts, halfWidth) + : null; // ── Texture identifiers ────────────────────────────────────────── string roadTextureId = RegionTextures.GetRoadSurfaceTextureId(region, roadType); string kerbTextureId = RegionTextures.GetKerbTextureId(region); string laneMarkingTextureId = RegionTextures.GetLaneMarkingTextureId(isOneWay); + string ditchTextureId = ditchMesh != null + ? RegionTextures.GetDitchTextureId(region) + : string.Empty; - return new RoadMeshResult(roadMesh, kerbMesh, roadTextureId, kerbTextureId, laneMarkingTextureId); + return new RoadMeshResult(roadMesh, kerbMesh, roadTextureId, kerbTextureId, + laneMarkingTextureId, laneMarkingMesh, ditchMesh, ditchTextureId); } // ── Private helpers ─────────────────────────────────────────────────── @@ -484,5 +604,176 @@ private static Mesh BuildKerbMesh( mesh.RecalculateBounds(); return mesh; } + + /// + /// Builds a lane-marking overlay mesh by duplicating the road surface + /// geometry and elevating it by to prevent + /// z-fighting. UV channel 0 carries the lane-marking tiling (V repeats every + /// metres, U goes 0→1 across the road), + /// allowing a dedicated lane-marking material to be applied directly. + /// + private static Mesh BuildLaneMarkingMesh( + IList splinePoints, + float halfWidth, + float laneMarkingTileLength) + { + int n = splinePoints.Count; + var vertices = new Vector3[n * 2]; + var uvs = new Vector2[n * 2]; + var triangles = new int[(n - 1) * 6]; + + float distAlongRoad = 0f; + + for (int i = 0; i < n; i++) + { + Vector3 tangent = ComputeTangent(splinePoints, i, n); + Vector3 right = Vector3.Cross(Vector3.up, tangent).normalized; + + // Position the overlay a few mm above the road surface. + Vector3 centre = splinePoints[i] + Vector3.up * LaneMarkingClearance; + vertices[i * 2] = centre - right * halfWidth; + vertices[i * 2 + 1] = centre + right * halfWidth; + + if (i > 0) + distAlongRoad += Vector3.Distance(splinePoints[i], splinePoints[i - 1]); + + float v = distAlongRoad / laneMarkingTileLength; + uvs[i * 2] = new Vector2(0f, v); + uvs[i * 2 + 1] = new Vector2(1f, v); + } + + for (int i = 0; i < n - 1; i++) + { + int tri = i * 6; + int v0 = i * 2; + + triangles[tri] = v0; + triangles[tri + 1] = v0 + 2; + triangles[tri + 2] = v0 + 1; + + triangles[tri + 3] = v0 + 1; + triangles[tri + 4] = v0 + 2; + triangles[tri + 5] = v0 + 3; + } + + var mesh = new Mesh { name = "LaneMarkingMesh" }; + mesh.SetVertices(vertices); + mesh.SetUVs(0, uvs); + mesh.SetTriangles(triangles, 0); + mesh.RecalculateNormals(); + mesh.RecalculateBounds(); + return mesh; + } + + /// + /// Builds a V-profile roadside ditch mesh on both sides of the carriageway. + /// Each side has three profile vertices per spline point: + /// + /// inner edge — at the road edge, road-surface height + /// bottom — half a outward, dropped by + /// outer edge — a full outward, back to road-surface height + /// + /// UV channel 0: U 0→1 (inner→outer), V tiles every 10 m along the road. + /// + private static Mesh BuildDitchMesh( + IList splinePoints, + float halfWidth, + float ditchDepth = RuralDitchDepth, + float ditchWidth = RuralDitchWidth) + { + int n = splinePoints.Count; + + // 6 vertices per spline point: + // [i*6+0] = left inner (road edge, road_y) + // [i*6+1] = left bottom (road_edge+ditchWidth/2, road_y-ditchDepth) + // [i*6+2] = left outer (road_edge+ditchWidth, road_y) + // [i*6+3] = right inner (road edge, road_y) + // [i*6+4] = right bottom(road_edge+ditchWidth/2, road_y-ditchDepth) + // [i*6+5] = right outer (road_edge+ditchWidth, road_y) + var vertices = new Vector3[n * 6]; + var uvs = new Vector2[n * 6]; + // 2 sides × 2 slopes × 2 triangles × 3 indices = 24 per segment + var triangles = new int[(n - 1) * 24]; + + float halfDitch = ditchWidth * 0.5f; + float distAlongRoad = 0f; + + for (int i = 0; i < n; i++) + { + Vector3 tangent = ComputeTangent(splinePoints, i, n); + Vector3 right = Vector3.Cross(Vector3.up, tangent).normalized; + Vector3 centre = splinePoints[i]; + Vector3 down = new Vector3(0f, -ditchDepth, 0f); + + // Left side (outward = −right direction) + vertices[i * 6] = centre - right * halfWidth; // left inner + vertices[i * 6 + 1] = centre - right * (halfWidth + halfDitch) + down; // left bottom + vertices[i * 6 + 2] = centre - right * (halfWidth + ditchWidth); // left outer + + // Right side (outward = +right direction) + vertices[i * 6 + 3] = centre + right * halfWidth; // right inner + vertices[i * 6 + 4] = centre + right * (halfWidth + halfDitch) + down; // right bottom + vertices[i * 6 + 5] = centre + right * (halfWidth + ditchWidth); // right outer + + if (i > 0) + distAlongRoad += Vector3.Distance(splinePoints[i], splinePoints[i - 1]); + + float v = distAlongRoad / 10f; + + // U: 0 (inner/road edge) → 0.5 (bottom) → 1 (outer) + uvs[i * 6] = new Vector2(0f, v); + uvs[i * 6 + 1] = new Vector2(0.5f, v); + uvs[i * 6 + 2] = new Vector2(1f, v); + uvs[i * 6 + 3] = new Vector2(0f, v); + uvs[i * 6 + 4] = new Vector2(0.5f, v); + uvs[i * 6 + 5] = new Vector2(1f, v); + } + + for (int i = 0; i < n - 1; i++) + { + int tri = i * 24; + int v0 = i * 6; + + // Left inner slope (inner → bottom), CW from above + triangles[tri] = v0 + 6; // left inner, far + triangles[tri + 1] = v0; // left inner, near + triangles[tri + 2] = v0 + 1; // left bottom, near + triangles[tri + 3] = v0 + 6; // left inner, far + triangles[tri + 4] = v0 + 1; // left bottom, near + triangles[tri + 5] = v0 + 7; // left bottom, far + + // Left outer slope (bottom → outer), CW from above + triangles[tri + 6] = v0 + 7; // left bottom, far + triangles[tri + 7] = v0 + 1; // left bottom, near + triangles[tri + 8] = v0 + 2; // left outer, near + triangles[tri + 9] = v0 + 7; // left bottom, far + triangles[tri + 10] = v0 + 2; // left outer, near + triangles[tri + 11] = v0 + 8; // left outer, far + + // Right inner slope (inner → bottom), CW from above + triangles[tri + 12] = v0 + 3; // right inner, near + triangles[tri + 13] = v0 + 9; // right inner, far + triangles[tri + 14] = v0 + 10; // right bottom, far + triangles[tri + 15] = v0 + 3; // right inner, near + triangles[tri + 16] = v0 + 10; // right bottom, far + triangles[tri + 17] = v0 + 4; // right bottom, near + + // Right outer slope (bottom → outer), CW from above + triangles[tri + 18] = v0 + 4; // right bottom, near + triangles[tri + 19] = v0 + 10; // right bottom, far + triangles[tri + 20] = v0 + 11; // right outer, far + triangles[tri + 21] = v0 + 4; // right bottom, near + triangles[tri + 22] = v0 + 11; // right outer, far + triangles[tri + 23] = v0 + 5; // right outer, near + } + + var mesh = new Mesh { name = "DitchMesh" }; + mesh.SetVertices(vertices); + mesh.SetUVs(0, uvs); + mesh.SetTriangles(triangles, 0); + mesh.RecalculateNormals(); + mesh.RecalculateBounds(); + return mesh; + } } } diff --git a/Assets/Scripts/Procedural/RoadMeshResult.cs b/Assets/Scripts/Procedural/RoadMeshResult.cs index 7eb8fc4..1cba020 100644 --- a/Assets/Scripts/Procedural/RoadMeshResult.cs +++ b/Assets/Scripts/Procedural/RoadMeshResult.cs @@ -48,6 +48,27 @@ public readonly struct RoadMeshResult /// public readonly string LaneMarkingTextureId; + /// + /// Mesh for the lane-marking overlay, positioned a few millimetres above the road + /// surface to prevent z-fighting. UV channel 0 carries the same tiling used by + /// UV channel 1 on , so a dedicated lane-marking material + /// can be applied directly. null when no lane markings are appropriate + /// (e.g. dirt tracks or paths). + /// + public readonly Mesh? LaneMarkingMesh; + + /// + /// Roadside ditch mesh for rural roads — a V-profile trench on both sides of the + /// carriageway. null for urban road types where a kerb is used instead. + /// + public readonly Mesh? DitchMesh; + + /// + /// Region-appropriate texture asset name for the ditch surface (e.g. + /// "terrain_grass"). Empty string when is null. + /// + public readonly string DitchTextureId; + /// /// Creates a new . /// @@ -56,14 +77,23 @@ public readonly struct RoadMeshResult /// Texture asset name for the road surface. /// Texture asset name for the kerb surface. /// Texture asset name for the lane-marking overlay. + /// Lane-marking overlay mesh (slightly above road surface). + /// Roadside ditch mesh for rural roads; null for urban. + /// Texture asset name for the ditch surface. public RoadMeshResult(Mesh roadMesh, Mesh kerbMesh, string roadTextureId, string kerbTextureId, - string laneMarkingTextureId = "") + string laneMarkingTextureId = "", + Mesh? laneMarkingMesh = null, + Mesh? ditchMesh = null, + string ditchTextureId = "") { RoadMesh = roadMesh; KerbMesh = kerbMesh; RoadTextureId = roadTextureId; KerbTextureId = kerbTextureId; LaneMarkingTextureId = laneMarkingTextureId; + LaneMarkingMesh = laneMarkingMesh; + DitchMesh = ditchMesh; + DitchTextureId = ditchTextureId; } } } diff --git a/Assets/Tests/PlayMode/CollisionPlayModeTests.cs b/Assets/Tests/PlayMode/CollisionPlayModeTests.cs new file mode 100644 index 0000000..d328789 --- /dev/null +++ b/Assets/Tests/PlayMode/CollisionPlayModeTests.cs @@ -0,0 +1,204 @@ +using System.Collections; +using System.Collections.Generic; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.TestTools; +using VectorRoad.DataInversion; +using VectorRoad.Procedural; + +namespace VectorRoad.Tests.PlayMode +{ + /// + /// Play-mode tests verifying that buildings and roadside props receive the correct + /// physics collider components so the car cannot pass through them. + /// + public class CollisionPlayModeTests + { + // GameObjects created during each test – destroyed in TearDown. + private readonly List _created = new(); + + [UnityTearDown] + public IEnumerator TearDown() + { + foreach (GameObject go in _created) + { + if (go != null) + Object.Destroy(go); + } + _created.Clear(); + yield return null; + } + + // Helper to create and track a temporary GameObject. + private GameObject MakeGO(string name = "TestGO") + { + var go = new GameObject(name); + _created.Add(go); + return go; + } + + // ── Building wall collider ───────────────────────────────────────────── + + [UnityTest] + public IEnumerator BuildingWall_WithMeshCollider_BlocksRigidbody() + { + // Build a minimal square building mesh using BuildingGenerator. + var footprint = new[] + { + new Vector3( 0f, 0f, 0f), + new Vector3(10f, 0f, 0f), + new Vector3(10f, 0f, 10f), + new Vector3( 0f, 0f, 10f), + }; + + BuildingMeshResult result = BuildingGenerator.Extrude(footprint, wayId: 1); + + // Replicate what MapSceneBuilder.BuildBuilding does. + var wallGo = MakeGO("Walls"); + wallGo.AddComponent().sharedMesh = result.WallMesh; + wallGo.AddComponent(); + var col = wallGo.AddComponent(); + col.sharedMesh = result.WallMesh; + + yield return null; + + Assert.That(wallGo.GetComponent(), Is.Not.Null, + "Building wall must have a MeshCollider."); + Assert.That(wallGo.GetComponent().sharedMesh, Is.Not.Null, + "Building wall MeshCollider must reference the wall mesh."); + } + + [UnityTest] + public IEnumerator BuildingRoof_WithMeshCollider_HasCollider() + { + var footprint = new[] + { + new Vector3( 0f, 0f, 0f), + new Vector3(10f, 0f, 0f), + new Vector3(10f, 0f, 10f), + new Vector3( 0f, 0f, 10f), + }; + + BuildingMeshResult result = BuildingGenerator.Extrude(footprint, wayId: 2); + + var roofGo = MakeGO("Roof"); + roofGo.AddComponent().sharedMesh = result.RoofMesh; + roofGo.AddComponent(); + var col = roofGo.AddComponent(); + col.sharedMesh = result.RoofMesh; + + yield return null; + + Assert.That(roofGo.GetComponent(), Is.Not.Null, + "Building roof must have a MeshCollider."); + Assert.That(roofGo.GetComponent().sharedMesh, Is.Not.Null, + "Building roof MeshCollider must reference the roof mesh."); + } + + // ── Prop collider shapes ────────────────────────────────────────────── + + [UnityTest] + public IEnumerator LampPost_Collider_IsCapsuleWithCorrectDimensions() + { + var go = MakeGO("Prop_LampPost"); + var col = go.AddComponent(); + col.radius = 0.1f; + col.height = 4f; + col.center = new Vector3(0f, 2f, 0f); + + yield return null; + + var capsule = go.GetComponent(); + Assert.That(capsule, Is.Not.Null, "LampPost must have a CapsuleCollider."); + Assert.That(capsule.radius, Is.EqualTo(0.1f).Within(1e-5f)); + Assert.That(capsule.height, Is.EqualTo(4f).Within(1e-5f)); + Assert.That(capsule.center.y, Is.EqualTo(2f).Within(1e-5f)); + } + + [UnityTest] + public IEnumerator SignPost_Collider_IsCapsuleWithCorrectDimensions() + { + var go = MakeGO("Prop_SignPost"); + var col = go.AddComponent(); + col.radius = 0.1f; + col.height = 4f; + col.center = new Vector3(0f, 2f, 0f); + + yield return null; + + var capsule = go.GetComponent(); + Assert.That(capsule, Is.Not.Null, "SignPost must have a CapsuleCollider."); + Assert.That(capsule.radius, Is.EqualTo(0.1f).Within(1e-5f)); + Assert.That(capsule.height, Is.EqualTo(4f).Within(1e-5f)); + Assert.That(capsule.center.y, Is.EqualTo(2f).Within(1e-5f)); + } + + [UnityTest] + public IEnumerator Tree_Collider_IsCapsuleWithWiderRadius() + { + var go = MakeGO("Prop_Tree"); + var col = go.AddComponent(); + col.radius = 0.3f; + col.height = 4f; + col.center = new Vector3(0f, 2f, 0f); + + yield return null; + + var capsule = go.GetComponent(); + Assert.That(capsule, Is.Not.Null, "Tree must have a CapsuleCollider."); + Assert.That(capsule.radius, Is.EqualTo(0.3f).Within(1e-5f), + "Tree trunk radius should be wider than a lamp post."); + Assert.That(capsule.radius, Is.GreaterThan(0.1f), + "Tree radius must be larger than a post radius."); + } + + [UnityTest] + public IEnumerator Fence_Collider_IsBoxWithCorrectDimensions() + { + var go = MakeGO("Prop_Fence"); + var col = go.AddComponent(); + col.size = new Vector3(2f, 1.5f, 0.1f); + col.center = new Vector3(0f, 0.75f, 0f); + + yield return null; + + var box = go.GetComponent(); + Assert.That(box, Is.Not.Null, "Fence must have a BoxCollider."); + Assert.That(box.size.x, Is.EqualTo(2f).Within(1e-5f), + "Fence span (X) should be 2 m."); + Assert.That(box.size.y, Is.EqualTo(1.5f).Within(1e-5f), + "Fence height (Y) should be 1.5 m."); + Assert.That(box.center.y, Is.EqualTo(0.75f).Within(1e-5f), + "Fence centre must sit above the ground plane."); + } + + // ── Prop placement positions are off-road ───────────────────────────── + + [UnityTest] + public IEnumerator RoadsidePropPlacer_LampPostsArePlacedBeyondRoadEdge() + { + var spline = new List + { + new(0f, 0f, 0f), + new(0f, 0f, 100f), + }; + + var placements = RoadsidePropPlacer.Place( + spline, RoadType.Residential, RegionType.Temperate, wayId: 42); + + float halfWidth = RoadMeshExtruder.GetWidthForRoadType(RoadType.Residential) * 0.5f; + float minLateral = halfWidth + RoadMeshExtruder.DefaultKerbWidth; + + Assert.That(placements, Is.Not.Empty, "Should have at least one prop placement."); + + foreach (PropPlacement p in placements) + { + float lateralDist = Mathf.Abs(p.Position.x); + Assert.That(lateralDist, Is.GreaterThan(minLateral), + $"Prop at {p.Position} must be outside the road edge ({minLateral} m)."); + } + + yield return null; + } + } +} diff --git a/Assets/Tests/PlayMode/CollisionPlayModeTests.cs.meta b/Assets/Tests/PlayMode/CollisionPlayModeTests.cs.meta new file mode 100644 index 0000000..6a109d5 --- /dev/null +++ b/Assets/Tests/PlayMode/CollisionPlayModeTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4a4cc5dfa99743198d083e322566d02b diff --git a/Assets/Tests/PlayMode/SceneScreenshotTests.cs b/Assets/Tests/PlayMode/SceneScreenshotTests.cs new file mode 100644 index 0000000..a8db0cf --- /dev/null +++ b/Assets/Tests/PlayMode/SceneScreenshotTests.cs @@ -0,0 +1,113 @@ +using System.Collections; +using System.IO; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.SceneManagement; +using UnityEngine.TestTools; +using VectorRoad.Core; + +namespace VectorRoad.Tests.PlayMode +{ + /// + /// Play-mode test that loads the default ProofOfConcept scene, waits + /// for the map-build pipeline to reach the + /// state, then renders a PNG screenshot to Screenshots/pr-preview.png + /// at the project root. + /// + /// + /// Designed to run in GitHub Actions via the pr-preview.yml workflow. + /// The screenshot artifact is uploaded and linked in a PR comment so + /// reviewers can see the rendered result at a glance. + /// + /// + /// + /// The startup menu is bypassed automatically by advancing the + /// state to + /// immediately after the scene loads. + /// + /// + public class SceneScreenshotTests + { + private const string SceneName = "ProofOfConcept"; + private const int ScreenshotWidth = 1920; + private const int ScreenshotHeight = 1080; + + /// + /// Loads the default location, waits for level generation to complete, + /// and saves a screenshot to Screenshots/pr-preview.png. + /// + [UnityTest] + [Timeout(300000)] // 5 minutes – map build can take a while in CI + public IEnumerator DefaultLocation_RendersScene() + { + yield return SceneManager.LoadSceneAsync(SceneName); + + // Allow Awake/Start to run on all objects in the loaded scene. + yield return null; + + // The MapSceneBuilder waits for the GameManager to leave MainMenu + // before it starts loading map data. Advance the state here to + // skip the interactive startup menu in automated runs. + var gm = GameManager.Instance; + if (gm != null && gm.CurrentState == GameState.MainMenu) + gm.SetState(GameState.LoadingMap); + + // Wait until the map build pipeline signals that the level is ready. + float elapsed = 0f; + const float mapLoadTimeout = 240f; // seconds + while (elapsed < mapLoadTimeout) + { + var instance = GameManager.Instance; + if (instance == null) + Assert.Fail("GameManager.Instance became null while waiting for map load."); + if (instance.CurrentState == GameState.Racing) + break; + elapsed += Time.deltaTime; + yield return null; + } + + // Give the physics engine and ChaseCam a few seconds to settle. + // The vehicle is spawned 2 m above the road surface and needs time to + // drop onto it; the ChaseCam uses SmoothDamp so it also needs several + // frames to move from its initial position to behind the vehicle. + yield return new WaitForSeconds(3f); + + // Find any active camera to render from. Camera.main returns the + // camera tagged "MainCamera", which is the expected render camera in + // the ProofOfConcept scene. FindFirstObjectByType is a safe fallback + // for scenes where the main camera tag has not been set. + var camera = Camera.main ?? Object.FindFirstObjectByType(); + Assert.IsNotNull(camera, "No Camera was found in the scene."); + + // Render the scene to a RenderTexture so the capture works reliably + // in headless / batch mode (no display required). + var rt = new RenderTexture(ScreenshotWidth, ScreenshotHeight, 24); + var prevTarget = camera.targetTexture; + camera.targetTexture = rt; + camera.Render(); + + var tex = new Texture2D(ScreenshotWidth, ScreenshotHeight, + TextureFormat.RGB24, false); + RenderTexture.active = rt; + tex.ReadPixels(new Rect(0, 0, ScreenshotWidth, ScreenshotHeight), 0, 0); + tex.Apply(); + + // Save to /Screenshots/pr-preview.png so the workflow + // can locate and upload the file as an artifact. + string screenshotDir = Path.GetFullPath( + Path.Combine(Application.dataPath, "..", "Screenshots")); + Directory.CreateDirectory(screenshotDir); + string screenshotPath = Path.Combine(screenshotDir, "pr-preview.png"); + File.WriteAllBytes(screenshotPath, tex.EncodeToPNG()); + + // Restore state and release GPU resources. + camera.targetTexture = prevTarget; + RenderTexture.active = null; + Object.Destroy(rt); + Object.Destroy(tex); + + Assert.IsTrue(File.Exists(screenshotPath), + $"Screenshot was not saved to {screenshotPath}"); + } + } +} diff --git a/Assets/Tests/PlayMode/SceneScreenshotTests.cs.meta b/Assets/Tests/PlayMode/SceneScreenshotTests.cs.meta new file mode 100644 index 0000000..6dad483 --- /dev/null +++ b/Assets/Tests/PlayMode/SceneScreenshotTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d053d836326f403683ba056925896fd7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/VectorRoad.Tests/PlaceholderMaterialFactoryTests.cs b/Tests/VectorRoad.Tests/PlaceholderMaterialFactoryTests.cs index e5aa8de..4ef4ce5 100644 --- a/Tests/VectorRoad.Tests/PlaceholderMaterialFactoryTests.cs +++ b/Tests/VectorRoad.Tests/PlaceholderMaterialFactoryTests.cs @@ -43,6 +43,10 @@ public void Create_SetsNameToTextureId() [TestCase("water_tropical")] [TestCase("lane_marking_oneway")] [TestCase("lane_marking_twoway")] + [TestCase("prop_lamppost")] + [TestCase("prop_signpost")] + [TestCase("prop_tree")] + [TestCase("prop_fence")] public void Create_AllKnownIds_ReturnMaterialWithDistinctColor(string textureId) { // Magenta (r=1, g=0, b=1) is Unity's "missing material" colour. @@ -108,6 +112,36 @@ public void Create_UnknownId_ReturnsNeutralGreyMaterial() Assert.That(mat.color.b, Is.EqualTo(0.5f).Within(0.001f)); } + [Test] + public void Create_PropPostIds_HaveMidGreyColor() + { + foreach (var id in new[] { "prop_lamppost", "prop_signpost" }) + { + var mat = PlaceholderMaterialFactory.Create(id); + Assert.That(mat.color.r, Is.EqualTo(0.60f).Within(0.001f), $"{id}: red channel"); + Assert.That(mat.color.g, Is.EqualTo(0.60f).Within(0.001f), $"{id}: green channel"); + Assert.That(mat.color.b, Is.EqualTo(0.60f).Within(0.001f), $"{id}: blue channel"); + } + } + + [Test] + public void Create_PropTree_HasGreenDominance() + { + var mat = PlaceholderMaterialFactory.Create("prop_tree"); + Assert.That(mat.color.g, Is.GreaterThan(mat.color.r), "prop_tree: green > red"); + Assert.That(mat.color.g, Is.GreaterThan(mat.color.b), "prop_tree: green > blue"); + } + + [Test] + public void Create_PropFence_HasWarmBrownTone() + { + var mat = PlaceholderMaterialFactory.Create("prop_fence"); + // Weathered wood: red > blue, both > 0.4 + Assert.That(mat.color.r, Is.GreaterThan(mat.color.b), "prop_fence: red > blue"); + Assert.That(mat.color.r, Is.GreaterThan(0.4f), "prop_fence: visible red component"); + Assert.That(mat.color.b, Is.GreaterThan(0.4f), "prop_fence: visible blue component"); + } + // ── FillMissing ─────────────────────────────────────────────────────── [Test] @@ -132,6 +166,7 @@ public void FillMissing_PopulatesAllKnownTextureIds() "terrain_grass", "water", "water_arctic", "water_tropical", "lane_marking_oneway", "lane_marking_twoway", + "prop_lamppost", "prop_signpost", "prop_tree", "prop_fence", }) { Assert.That(registry.GetMaterial(id), Is.Not.Null, diff --git a/Tests/VectorRoad.Tests/RegionTypeTests.cs b/Tests/VectorRoad.Tests/RegionTypeTests.cs index 4137b75..6fb7b04 100644 --- a/Tests/VectorRoad.Tests/RegionTypeTests.cs +++ b/Tests/VectorRoad.Tests/RegionTypeTests.cs @@ -21,6 +21,12 @@ public void RegionType_HasTemperateValue() Assert.That(Enum.IsDefined(typeof(RegionType), RegionType.Temperate), Is.True); } + [Test] + public void RegionType_HasTemperateNorthAmericaValue() + { + Assert.That(Enum.IsDefined(typeof(RegionType), RegionType.TemperateNorthAmerica), Is.True); + } + [Test] public void RegionType_HasDesertValue() { diff --git a/Tests/VectorRoad.Tests/RoadMeshExtruderTests.cs b/Tests/VectorRoad.Tests/RoadMeshExtruderTests.cs index 98463a6..c468bbb 100644 --- a/Tests/VectorRoad.Tests/RoadMeshExtruderTests.cs +++ b/Tests/VectorRoad.Tests/RoadMeshExtruderTests.cs @@ -297,7 +297,8 @@ public void ExtrudeWithDetails_RoadMesh_UV1_UsesLaneMarkingTileLength() public void ExtrudeWithDetails_KerbMesh_CorrectVertexCount() { // 4 kerb vertices per spline point: left-outer, left-inner, right-inner, right-outer - RoadMeshResult result = RoadMeshExtruder.ExtrudeWithDetails(TwoPoints, RoadType.Primary); + // Urban road types (Residential, Service) receive a raised kerb. + RoadMeshResult result = RoadMeshExtruder.ExtrudeWithDetails(TwoPoints, RoadType.Residential); Assert.That(result.KerbMesh.Vertices.Length, Is.EqualTo(TwoPoints.Count * 4)); } @@ -306,7 +307,8 @@ public void ExtrudeWithDetails_KerbMesh_CorrectVertexCount() public void ExtrudeWithDetails_KerbMesh_CorrectTriangleCount() { // (n-1) segments × 2 kerb strips × 2 triangles × 3 indices = (n-1) × 12 - RoadMeshResult result = RoadMeshExtruder.ExtrudeWithDetails(TwoPoints, RoadType.Primary); + // Urban road types (Residential, Service) receive a raised kerb. + RoadMeshResult result = RoadMeshExtruder.ExtrudeWithDetails(TwoPoints, RoadType.Residential); Assert.That(result.KerbMesh.Triangles.Length, Is.EqualTo((TwoPoints.Count - 1) * 12)); } @@ -513,13 +515,17 @@ public void GetWidthForRoadType_TwoLanes_NarrowerThanFourLanes() } [Test] - public void ExtrudeWithDetails_WithLanes_MeshWidthReflectsLaneCount() + public void ExtrudeWithDetails_WithLanes_MeshWidthReflectsLaneCountAndShoulder() { + // Formula: (lanes × DefaultLaneWidth + shoulderWidth) × regionFactor + // Primary shoulder = 3.0 m; Unknown region factor = 1.0 const int lanes = 3; - float expectedWidth = lanes * RoadMeshExtruder.DefaultLaneWidth; + const RoadType roadType = RoadType.Primary; + float expectedWidth = lanes * RoadMeshExtruder.DefaultLaneWidth + + RegionWidthFactors.GetShoulderWidth(roadType); RoadMeshResult result = RoadMeshExtruder.ExtrudeWithDetails( - TwoPoints, RoadType.Primary, lanes: lanes); + TwoPoints, roadType, lanes: lanes); float actual = result.RoadMesh.Vertices[1].x - result.RoadMesh.Vertices[0].x; Assert.That(actual, Is.EqualTo(expectedWidth).Within(1e-4f)); @@ -584,5 +590,411 @@ public void ExtrudeWithDetails_LaneMarkingTextureId_IsNonEmptyForAllRoadTypes() $"LaneMarkingTextureId must not be empty for road type '{rt}'."); } } + + // ── Region-based width factor ───────────────────────────────────────── + + [Test] + public void GetWidthForRoadType_TemperateNorthAmerica_WiderThanTemperate() + { + // USA/Canada roads are wider than European roads of the same type. + float na = RoadMeshExtruder.GetWidthForRoadType(RoadType.Primary, 0, RegionType.TemperateNorthAmerica); + float europe = RoadMeshExtruder.GetWidthForRoadType(RoadType.Primary, 0, RegionType.Temperate); + + Assert.That(na, Is.GreaterThan(europe), + "North American roads should be wider than their European counterparts."); + } + + [Test] + public void GetWidthForRoadType_WithRegion_ZeroLanes_AppliesRegionFactorToTableWidth() + { + // Zero lanes falls back to the table value, then multiplies by the region factor. + float baseWidth = RoadMeshExtruder.GetWidthForRoadType(RoadType.Secondary); + float naFactor = RegionWidthFactors.GetWidthFactor(RegionType.TemperateNorthAmerica); + float expected = baseWidth * naFactor; + + float actual = RoadMeshExtruder.GetWidthForRoadType( + RoadType.Secondary, 0, RegionType.TemperateNorthAmerica); + + Assert.That(actual, Is.EqualTo(expected).Within(1e-4f)); + } + + [Test] + public void GetWidthForRoadType_WithRegion_Lanes_AppliesShoulderAndRegionFactor() + { + const int lanes = 2; + const RoadType roadType = RoadType.Motorway; + const RegionType region = RegionType.TemperateNorthAmerica; + + float shoulder = RegionWidthFactors.GetShoulderWidth(roadType); + float factor = RegionWidthFactors.GetWidthFactor(region); + float expected = (lanes * RoadMeshExtruder.DefaultLaneWidth + shoulder) * factor; + + float actual = RoadMeshExtruder.GetWidthForRoadType(roadType, lanes, region); + + Assert.That(actual, Is.EqualTo(expected).Within(1e-4f)); + } + + [Test] + public void GetWidthForRoadType_UnknownRegion_SameAsBaseline() + { + // RegionType.Unknown factor is 1.0 — identical to Temperate. + float unknown = RoadMeshExtruder.GetWidthForRoadType(RoadType.Residential, 0, RegionType.Unknown); + float temperate = RoadMeshExtruder.GetWidthForRoadType(RoadType.Residential, 0, RegionType.Temperate); + + Assert.That(unknown, Is.EqualTo(temperate).Within(1e-4f)); + } + + [Test] + public void ExtrudeWithDetails_NorthAmerica_ProducesWiderMeshThanTemperate() + { + RoadMeshResult na = RoadMeshExtruder.ExtrudeWithDetails( + TwoPoints, RoadType.Primary, region: RegionType.TemperateNorthAmerica); + RoadMeshResult europe = RoadMeshExtruder.ExtrudeWithDetails( + TwoPoints, RoadType.Primary, region: RegionType.Temperate); + + float naWidth = na.RoadMesh.Vertices[1].x - na.RoadMesh.Vertices[0].x; + float europeWidth = europe.RoadMesh.Vertices[1].x - europe.RoadMesh.Vertices[0].x; + + Assert.That(naWidth, Is.GreaterThan(europeWidth), + "North American road mesh must be wider than European mesh of the same type."); + } + + [Test] + public void GetWidthFactor_AllRegions_ReturnPositiveValue() + { + foreach (RegionType region in System.Enum.GetValues(typeof(RegionType))) + Assert.That(RegionWidthFactors.GetWidthFactor(region), Is.GreaterThan(0f), + $"Width factor for region '{region}' must be positive."); + } + + [Test] + public void GetShoulderWidth_AllRoadTypes_ReturnNonNegativeValue() + { + foreach (RoadType rt in System.Enum.GetValues(typeof(RoadType))) + Assert.That(RegionWidthFactors.GetShoulderWidth(rt), Is.GreaterThanOrEqualTo(0f), + $"Shoulder width for road type '{rt}' must be non-negative."); + } + + [Test] + public void GetShoulderWidth_Motorway_WidestShoulder() + { + float motorway = RegionWidthFactors.GetShoulderWidth(RoadType.Motorway); + float residential = RegionWidthFactors.GetShoulderWidth(RoadType.Residential); + + Assert.That(motorway, Is.GreaterThan(residential), + "Motorway shoulders must be wider than residential shoulders."); + } + + [Test] + public void ExtrudeWithDetails_WithLanes_NorthAmerica_WiderThanEurope() + { + RoadMeshResult na = RoadMeshExtruder.ExtrudeWithDetails( + TwoPoints, RoadType.Primary, region: RegionType.TemperateNorthAmerica, lanes: 4); + RoadMeshResult europe = RoadMeshExtruder.ExtrudeWithDetails( + TwoPoints, RoadType.Primary, region: RegionType.Temperate, lanes: 4); + + float naWidth = na.RoadMesh.Vertices[1].x - na.RoadMesh.Vertices[0].x; + float europeWidth = europe.RoadMesh.Vertices[1].x - europe.RoadMesh.Vertices[0].x; + + Assert.That(naWidth, Is.GreaterThan(europeWidth), + "4-lane North American road must be wider than 4-lane European road."); + } + + // ── IsUrbanRoadType ─────────────────────────────────────────────────── + + [Test] + public void IsUrbanRoadType_Residential_IsTrue() + { + Assert.That(RoadMeshExtruder.IsUrbanRoadType(RoadType.Residential), Is.True); + } + + [Test] + public void IsUrbanRoadType_Service_IsTrue() + { + Assert.That(RoadMeshExtruder.IsUrbanRoadType(RoadType.Service), Is.True); + } + + [Test] + public void IsUrbanRoadType_Primary_IsFalse() + { + Assert.That(RoadMeshExtruder.IsUrbanRoadType(RoadType.Primary), Is.False); + } + + [Test] + public void IsUrbanRoadType_Motorway_IsFalse() + { + Assert.That(RoadMeshExtruder.IsUrbanRoadType(RoadType.Motorway), Is.False); + } + + [Test] + public void IsUrbanRoadType_Dirt_IsFalse() + { + Assert.That(RoadMeshExtruder.IsUrbanRoadType(RoadType.Dirt), Is.False); + } + + // ── Urban kerb height (15 cm) ───────────────────────────────────────── + + [Test] + public void ExtrudeWithDetails_Residential_KerbHeight_IsUrbanKerbHeight() + { + // Residential roads use the 15 cm urban kerb. + RoadMeshResult result = RoadMeshExtruder.ExtrudeWithDetails(TwoPoints, RoadType.Residential); + + float expectedY = RoadMeshExtruder.TerrainClearance + RoadMeshExtruder.UrbanKerbHeight; + foreach (var v in result.KerbMesh.Vertices) + Assert.That(v.y, Is.EqualTo(expectedY).Within(1e-4f), + "Residential kerb vertices must be at UrbanKerbHeight above the road plane."); + } + + [Test] + public void ExtrudeWithDetails_Service_HasNonEmptyKerbMesh() + { + RoadMeshResult result = RoadMeshExtruder.ExtrudeWithDetails(TwoPoints, RoadType.Service); + + Assert.That(result.KerbMesh.Vertices.Length, Is.GreaterThan(0), + "Service road (urban) must have a raised kerb mesh."); + } + + [Test] + public void ExtrudeWithDetails_UrbanKerbHeight_GreaterThanDefaultKerbHeight() + { + Assert.That(RoadMeshExtruder.UrbanKerbHeight, Is.GreaterThan(RoadMeshExtruder.DefaultKerbHeight), + "UrbanKerbHeight (15 cm) must be larger than the legacy DefaultKerbHeight (5 cm)."); + } + + // ── Rural road ditches ──────────────────────────────────────────────── + + [Test] + public void ExtrudeWithDetails_Primary_HasDitchMesh() + { + RoadMeshResult result = RoadMeshExtruder.ExtrudeWithDetails(TwoPoints, RoadType.Primary); + + Assert.That(result.DitchMesh, Is.Not.Null, + "Primary road (rural) must have a roadside ditch mesh."); + Assert.That(result.DitchMesh!.Vertices.Length, Is.GreaterThan(0)); + } + + [Test] + public void ExtrudeWithDetails_Motorway_HasDitchMesh() + { + RoadMeshResult result = RoadMeshExtruder.ExtrudeWithDetails(TwoPoints, RoadType.Motorway); + + Assert.That(result.DitchMesh, Is.Not.Null); + Assert.That(result.DitchMesh!.Vertices.Length, Is.GreaterThan(0)); + } + + [Test] + public void ExtrudeWithDetails_Dirt_HasDitchMesh() + { + RoadMeshResult result = RoadMeshExtruder.ExtrudeWithDetails(TwoPoints, RoadType.Dirt); + + Assert.That(result.DitchMesh, Is.Not.Null); + } + + [Test] + public void ExtrudeWithDetails_Residential_HasNoDitchMesh() + { + RoadMeshResult result = RoadMeshExtruder.ExtrudeWithDetails(TwoPoints, RoadType.Residential); + + Assert.That(result.DitchMesh, Is.Null, + "Residential road (urban) must not have a ditch mesh."); + } + + [Test] + public void ExtrudeWithDetails_Service_HasNoDitchMesh() + { + RoadMeshResult result = RoadMeshExtruder.ExtrudeWithDetails(TwoPoints, RoadType.Service); + + Assert.That(result.DitchMesh, Is.Null, + "Service road (urban) must not have a ditch mesh."); + } + + [Test] + public void ExtrudeWithDetails_RuralRoad_HasNonEmptyDitchTextureId() + { + RoadMeshResult result = RoadMeshExtruder.ExtrudeWithDetails( + TwoPoints, RoadType.Secondary, region: RegionType.Temperate); + + Assert.That(result.DitchTextureId, Is.Not.Null.And.Not.Empty, + "Rural road must carry a non-empty ditch texture ID."); + } + + [Test] + public void ExtrudeWithDetails_UrbanRoad_HasEmptyDitchTextureId() + { + RoadMeshResult result = RoadMeshExtruder.ExtrudeWithDetails(TwoPoints, RoadType.Residential); + + Assert.That(result.DitchTextureId, Is.Empty, + "Urban road must have an empty ditch texture ID (no ditch)."); + } + + [Test] + public void ExtrudeWithDetails_Ditch_VertexCount_IsCorrect() + { + // 6 ditch vertices per spline point (3 per side × 2 sides). + RoadMeshResult result = RoadMeshExtruder.ExtrudeWithDetails(TwoPoints, RoadType.Primary); + + Assert.That(result.DitchMesh!.Vertices.Length, Is.EqualTo(TwoPoints.Count * 6)); + } + + [Test] + public void ExtrudeWithDetails_Ditch_TriangleCount_IsCorrect() + { + // (n-1) segments × 2 sides × 2 slopes × 2 tris × 3 indices = (n-1) × 24. + RoadMeshResult result = RoadMeshExtruder.ExtrudeWithDetails(TwoPoints, RoadType.Primary); + + Assert.That(result.DitchMesh!.Triangles.Length, Is.EqualTo((TwoPoints.Count - 1) * 24)); + } + + [Test] + public void ExtrudeWithDetails_Ditch_BottomIsLowerThanRoadSurface() + { + // Ditch bottom vertices (index 1 and 4 of each point group) must be below road Y. + RoadMeshResult result = RoadMeshExtruder.ExtrudeWithDetails(TwoPoints, RoadType.Primary); + + var ditchVerts = result.DitchMesh!.Vertices; + float roadY = RoadMeshExtruder.TerrainClearance; // spline at Y=0, road at TerrainClearance + + // Check bottom vertices of the first spline point (indices 1 and 4). + Assert.That(ditchVerts[1].y, Is.LessThan(roadY), + "Left ditch bottom must be below road surface level."); + Assert.That(ditchVerts[4].y, Is.LessThan(roadY), + "Right ditch bottom must be below road surface level."); + } + + [Test] + public void ExtrudeWithDetails_Ditch_OuterEdgeIsBeyondDitchWidth() + { + const float roadWidth = 12f; // Primary road + float halfWidth = roadWidth * 0.5f; + + RoadMeshResult result = RoadMeshExtruder.ExtrudeWithDetails( + TwoPoints, RoadType.Primary); + + var ditchVerts = result.DitchMesh!.Vertices; + // Left outer (index 2): x < −(halfWidth + ditchWidth) + // Right outer (index 5): x > +(halfWidth + ditchWidth) + Assert.That(ditchVerts[2].x, Is.LessThan(-(halfWidth)), + "Left ditch outer edge must be further left than the road edge."); + Assert.That(ditchVerts[5].x, Is.GreaterThan(halfWidth), + "Right ditch outer edge must be further right than the road edge."); + } + + [Test] + public void ExtrudeWithDetails_Primary_HasEmptyKerbMesh() + { + // Rural roads use ditches rather than kerbs. + RoadMeshResult result = RoadMeshExtruder.ExtrudeWithDetails(TwoPoints, RoadType.Primary); + + Assert.That(result.KerbMesh.Vertices.Length, Is.EqualTo(0), + "Primary road (rural) must have an empty kerb mesh — ditches are used instead."); + } + + // ── Lane-marking overlay mesh ───────────────────────────────────────── + + [Test] + public void ExtrudeWithDetails_Primary_HasLaneMarkingMesh() + { + RoadMeshResult result = RoadMeshExtruder.ExtrudeWithDetails(TwoPoints, RoadType.Primary); + + Assert.That(result.LaneMarkingMesh, Is.Not.Null, + "Paved road types must have a lane-marking overlay mesh."); + Assert.That(result.LaneMarkingMesh!.Vertices.Length, Is.GreaterThan(0)); + } + + [Test] + public void ExtrudeWithDetails_Residential_HasLaneMarkingMesh() + { + RoadMeshResult result = RoadMeshExtruder.ExtrudeWithDetails(TwoPoints, RoadType.Residential); + + Assert.That(result.LaneMarkingMesh, Is.Not.Null); + } + + [Test] + public void ExtrudeWithDetails_Dirt_HasNoLaneMarkingMesh() + { + RoadMeshResult result = RoadMeshExtruder.ExtrudeWithDetails(TwoPoints, RoadType.Dirt); + + Assert.That(result.LaneMarkingMesh, Is.Null, + "Unpaved road types must not have a lane-marking overlay mesh."); + } + + [Test] + public void ExtrudeWithDetails_Path_HasNoLaneMarkingMesh() + { + RoadMeshResult result = RoadMeshExtruder.ExtrudeWithDetails(TwoPoints, RoadType.Path); + + Assert.That(result.LaneMarkingMesh, Is.Null); + } + + [Test] + public void ExtrudeWithDetails_Cycleway_HasNoLaneMarkingMesh() + { + RoadMeshResult result = RoadMeshExtruder.ExtrudeWithDetails(TwoPoints, RoadType.Cycleway); + + Assert.That(result.LaneMarkingMesh, Is.Null); + } + + [Test] + public void ExtrudeWithDetails_LaneMarkingMesh_VertexCount_MatchesRoadMesh() + { + // Lane-marking overlay has the same number of vertices as the road surface. + RoadMeshResult result = RoadMeshExtruder.ExtrudeWithDetails(TwoPoints, RoadType.Primary); + + Assert.That(result.LaneMarkingMesh!.Vertices.Length, + Is.EqualTo(result.RoadMesh.Vertices.Length)); + } + + [Test] + public void ExtrudeWithDetails_LaneMarkingMesh_IsAboveRoadSurface() + { + // Lane-marking overlay must be slightly above the road surface (no z-fighting). + RoadMeshResult result = RoadMeshExtruder.ExtrudeWithDetails(TwoPoints, RoadType.Primary); + + for (int i = 0; i < result.RoadMesh.Vertices.Length; i++) + { + float roadY = result.RoadMesh.Vertices[i].y; + float markingY = result.LaneMarkingMesh!.Vertices[i].y; + Assert.That(markingY, Is.GreaterThan(roadY), + $"Lane-marking vertex {i} must be above the road-surface vertex."); + } + } + + [Test] + public void ExtrudeWithDetails_LaneMarkingMesh_Name_IsCorrect() + { + RoadMeshResult result = RoadMeshExtruder.ExtrudeWithDetails(TwoPoints, RoadType.Secondary); + + Assert.That(result.LaneMarkingMesh!.name, Is.EqualTo("LaneMarkingMesh")); + } + + // ── Ditch texture identifiers (RegionTextures) ──────────────────────── + + [Test] + public void GetDitchTextureId_Temperate_ReturnsTerrainGrass() + { + string id = RegionTextures.GetDitchTextureId(RegionType.Temperate); + + Assert.That(id, Is.EqualTo("terrain_grass")); + } + + [Test] + public void GetDitchTextureId_Desert_ReturnsTerrainSand() + { + string id = RegionTextures.GetDitchTextureId(RegionType.Desert); + + Assert.That(id, Is.EqualTo("terrain_sand")); + } + + [Test] + public void GetDitchTextureId_AllRegions_ReturnNonEmptyId() + { + foreach (RegionType region in System.Enum.GetValues(typeof(RegionType))) + { + string id = RegionTextures.GetDitchTextureId(region); + Assert.That(id, Is.Not.Null.And.Not.Empty, + $"GetDitchTextureId must not return empty for region '{region}'."); + } + } } } diff --git a/Tests/VectorRoad.Tests/VectorRoad.Tests.csproj b/Tests/VectorRoad.Tests/VectorRoad.Tests.csproj index 215950d..fc974ae 100644 --- a/Tests/VectorRoad.Tests/VectorRoad.Tests.csproj +++ b/Tests/VectorRoad.Tests/VectorRoad.Tests.csproj @@ -29,6 +29,7 @@ +