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
73 changes: 73 additions & 0 deletions Assets/Scripts/Core/MapSceneBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,77 @@ private void BuildRoad(
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 @@ -440,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
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 @@
// Lane markings
"lane_marking_oneway",
"lane_marking_twoway",

// Roadside props
"prop_lamppost",
"prop_signpost",
"prop_tree",
"prop_fence",
};

// ── Public API ─────────────────────────────────────────────────────────
Expand Down Expand Up @@ -105,7 +111,7 @@
Debug.LogWarning(
"[PlaceholderMaterialFactory] No suitable shader found. " +
"Placeholder colour will not be applied for: " + textureId);
return null;

Check warning on line 114 in Assets/Scripts/Procedural/PlaceholderMaterialFactory.cs

View workflow job for this annotation

GitHub Actions / Run unit tests (.NET)

Possible null reference return.
}

var mat = new Material(shader) { name = textureId };
Expand Down Expand Up @@ -157,6 +163,15 @@
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
204 changes: 204 additions & 0 deletions Assets/Tests/PlayMode/CollisionPlayModeTests.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Play-mode tests verifying that buildings and roadside props receive the correct
/// physics collider components so the car cannot pass through them.
/// </summary>
public class CollisionPlayModeTests
{
// GameObjects created during each test – destroyed in TearDown.
private readonly List<GameObject> _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<MeshFilter>().sharedMesh = result.WallMesh;
wallGo.AddComponent<MeshRenderer>();
var col = wallGo.AddComponent<MeshCollider>();
col.sharedMesh = result.WallMesh;

yield return null;

Assert.That(wallGo.GetComponent<MeshCollider>(), Is.Not.Null,
"Building wall must have a MeshCollider.");
Assert.That(wallGo.GetComponent<MeshCollider>().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<MeshFilter>().sharedMesh = result.RoofMesh;
roofGo.AddComponent<MeshRenderer>();
var col = roofGo.AddComponent<MeshCollider>();
col.sharedMesh = result.RoofMesh;

yield return null;

Assert.That(roofGo.GetComponent<MeshCollider>(), Is.Not.Null,
"Building roof must have a MeshCollider.");
Assert.That(roofGo.GetComponent<MeshCollider>().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<CapsuleCollider>();
col.radius = 0.1f;
col.height = 4f;
col.center = new Vector3(0f, 2f, 0f);

yield return null;

var capsule = go.GetComponent<CapsuleCollider>();
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<CapsuleCollider>();
col.radius = 0.1f;
col.height = 4f;
col.center = new Vector3(0f, 2f, 0f);

yield return null;

var capsule = go.GetComponent<CapsuleCollider>();
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<CapsuleCollider>();
col.radius = 0.3f;
col.height = 4f;
col.center = new Vector3(0f, 2f, 0f);

yield return null;

var capsule = go.GetComponent<CapsuleCollider>();
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<BoxCollider>();
col.size = new Vector3(2f, 1.5f, 0.1f);
col.center = new Vector3(0f, 0.75f, 0f);

yield return null;

var box = go.GetComponent<BoxCollider>();
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<Vector3>
{
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;
}
}
}
2 changes: 2 additions & 0 deletions Assets/Tests/PlayMode/CollisionPlayModeTests.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

35 changes: 35 additions & 0 deletions Tests/VectorRoad.Tests/PlaceholderMaterialFactoryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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]
Expand All @@ -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,
Expand Down
Loading