Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions .github/workflows/pr-preview.yml
Original file line number Diff line number Diff line change
@@ -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,
});
94 changes: 94 additions & 0 deletions Assets/Scripts/Core/MapSceneBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,17 @@ private void BuildRoad(
surfaceGo.AddComponent<MeshCollider>().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<MeshFilter>().sharedMesh = result.LaneMarkingMesh;
var laneRenderer = laneGo.AddComponent<MeshRenderer>();
Registry?.ApplyTo(laneRenderer, result.LaneMarkingTextureId);
laneGo.layer = LayerMask.NameToLayer("Road");
}

if (result.KerbMesh != null && result.KerbMesh.vertexCount > 0)
{
var kerbGo = new GameObject("Kerb");
Expand All @@ -277,6 +288,87 @@ private void BuildRoad(
var kerbRenderer = kerbGo.AddComponent<MeshRenderer>();
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<MeshFilter>().sharedMesh = result.DitchMesh;
var ditchRenderer = ditchGo.AddComponent<MeshRenderer>();
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<CapsuleCollider>();
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<CapsuleCollider>();
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<BoxCollider>();
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<MeshFilter>().sharedMesh = Resources.GetBuiltinResource<Mesh>("Capsule.fbx");
var mr = visual.AddComponent<MeshRenderer>();
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<MeshFilter>().sharedMesh = Resources.GetBuiltinResource<Mesh>("Cube.fbx");
var mr = visual.AddComponent<MeshRenderer>();
Registry?.ApplyTo(mr, textureId);
}

private static Vector3[] ClampRoadSplineToTerrain(
Expand Down Expand Up @@ -419,12 +511,14 @@ private void BuildBuilding(BuildingFootprint building, RegionType region)
wallGo.AddComponent<MeshFilter>().sharedMesh = result.WallMesh;
var wallRenderer = wallGo.AddComponent<MeshRenderer>();
Registry?.ApplyTo(wallRenderer, result.WallTextureId);
wallGo.AddComponent<MeshCollider>().sharedMesh = result.WallMesh;

var roofGo = new GameObject("Roof");
roofGo.transform.SetParent(parent.transform, false);
roofGo.AddComponent<MeshFilter>().sharedMesh = result.RoofMesh;
var roofRenderer = roofGo.AddComponent<MeshRenderer>();
Registry?.ApplyTo(roofRenderer, result.RoofTextureId);
roofGo.AddComponent<MeshCollider>().sharedMesh = result.RoofMesh;
}

private void BuildWater(WaterBody water, RegionType region)
Expand Down
6 changes: 5 additions & 1 deletion Assets/Scripts/DataInversion/OSMParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,9 @@
var nodePositions = new Dictionary<long, Vector3>();
foreach (XElement node in root.Elements("node"))
{
long id = (long)node.Attribute("id");

Check warning on line 99 in Assets/Scripts/DataInversion/OSMParser.cs

View workflow job for this annotation

GitHub Actions / Run unit tests (.NET)

Possible null reference argument for parameter 'attribute' in 'XAttribute.explicit operator long(XAttribute attribute)'.
double lat = (double)node.Attribute("lat");

Check warning on line 100 in Assets/Scripts/DataInversion/OSMParser.cs

View workflow job for this annotation

GitHub Actions / Run unit tests (.NET)

Possible null reference argument for parameter 'attribute' in 'XAttribute.explicit operator double(XAttribute attribute)'.
double lon = (double)node.Attribute("lon");

Check warning on line 101 in Assets/Scripts/DataInversion/OSMParser.cs

View workflow job for this annotation

GitHub Actions / Run unit tests (.NET)

Possible null reference argument for parameter 'attribute' in 'XAttribute.explicit operator double(XAttribute attribute)'.
nodePositions[id] = CoordinateConverter.LatLonToUnity(lat, lon, originLat, originLon);
}

Expand All @@ -109,11 +109,11 @@
// ── Process ways ───────────────────────────────────────────────────
foreach (XElement way in root.Elements("way"))
{
long wayId = (long)way.Attribute("id");

Check warning on line 112 in Assets/Scripts/DataInversion/OSMParser.cs

View workflow job for this annotation

GitHub Actions / Run unit tests (.NET)

Possible null reference argument for parameter 'attribute' in 'XAttribute.explicit operator long(XAttribute attribute)'.
var tags = ParseTags(way);
var nodeRefs = BuildNodeList(way, nodePositions);

if (tags.TryGetValue("highway", out string highwayType))

Check warning on line 116 in Assets/Scripts/DataInversion/OSMParser.cs

View workflow job for this annotation

GitHub Actions / Run unit tests (.NET)

Converting null literal or possible null value to non-nullable type.
{
roads.Add(new RoadSegment
{
Expand Down Expand Up @@ -194,9 +194,9 @@
var nodeLatLons = new Dictionary<long, (double lat, double lon)>();
foreach (XElement node in root.Elements("node"))
{
long id = (long)node.Attribute("id");

Check warning on line 197 in Assets/Scripts/DataInversion/OSMParser.cs

View workflow job for this annotation

GitHub Actions / Run unit tests (.NET)

Possible null reference argument for parameter 'attribute' in 'XAttribute.explicit operator long(XAttribute attribute)'.
double lat = (double)node.Attribute("lat");

Check warning on line 198 in Assets/Scripts/DataInversion/OSMParser.cs

View workflow job for this annotation

GitHub Actions / Run unit tests (.NET)

Possible null reference argument for parameter 'attribute' in 'XAttribute.explicit operator double(XAttribute attribute)'.
double lon = (double)node.Attribute("lon");

Check warning on line 199 in Assets/Scripts/DataInversion/OSMParser.cs

View workflow job for this annotation

GitHub Actions / Run unit tests (.NET)

Possible null reference argument for parameter 'attribute' in 'XAttribute.explicit operator double(XAttribute attribute)'.
nodeLatLons[id] = (lat, lon);
}

Expand Down Expand Up @@ -230,7 +230,7 @@
// ── Process ways ───────────────────────────────────────────────────────
foreach (XElement way in root.Elements("way"))
{
long wayId = (long)way.Attribute("id");

Check warning on line 233 in Assets/Scripts/DataInversion/OSMParser.cs

View workflow job for this annotation

GitHub Actions / Run unit tests (.NET)

Possible null reference argument for parameter 'attribute' in 'XAttribute.explicit operator long(XAttribute attribute)'.
var tags = ParseTags(way);
var nodeRefs = BuildNodeList(way, nodePositions);

Expand Down Expand Up @@ -333,10 +333,14 @@
"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
Expand Down
9 changes: 8 additions & 1 deletion Assets/Scripts/DataInversion/RegionType.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,17 @@ public enum RegionType

/// <summary>
/// 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.
/// </summary>
Temperate,

/// <summary>
/// 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.
/// </summary>
TemperateNorthAmerica,

/// <summary>
/// Hot desert or arid climate (very low rainfall, extreme heat).
/// Typical of the Middle East, North Africa, and the Australian interior.
Expand Down
15 changes: 15 additions & 0 deletions Assets/Scripts/Procedural/PlaceholderMaterialFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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
}
}
Expand Down
88 changes: 56 additions & 32 deletions Assets/Scripts/Procedural/RegionTextures.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
};
}

Expand All @@ -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",
};
}

Expand All @@ -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",
};
}

Expand All @@ -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",
};
}

Expand Down Expand Up @@ -153,6 +157,26 @@ public static string GetWaterTextureId(RegionType region)
};
}

// ── Roadside ditch ─────────────────────────────────────────────────────

/// <summary>
/// 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.
/// </summary>
/// <param name="region">Climate zone of the map area.</param>
/// <returns>A lowercase underscore-separated texture asset name.</returns>
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 ────────────────────────────────────────────────────

/// <summary>
Expand Down
Loading
Loading