Editor tool and helper classes for shaping and painting terrain to match road networks in the editor. RoadTerrainTool exposes UI actions (conform terrain, paint material, snap nodes) and draws gizmos; RoadTerrainConformer and RoadTerrainPainter perform the heightmap and control-map rasterisation and modify Terrain storage with undo support. A small ToolSidebarWidget implements the UI.
using System;
using System.Collections.Generic;
using System.Linq;
using Editor;
using Sandbox;
namespace RedSnail.RoadTool2.Editor;
/// <summary>
/// Terrain and roads, both ways: shape the terrain to the roads (the ground is cut and filled to meet the road
/// surface and its borders, blending back into the landscape over a falloff), or drop the road nodes onto the
/// ground. Both are explicit and undoable; nothing happens on its own while roads are being laid out.
/// </summary>
[Title("Terrain")]
[Icon("landscape")]
[Group("1")]
[Order(3)]
public class RoadTerrainTool : EditorTool
{
/// <summary>
/// How far beyond a road's border the terrain takes to blend back into the landscape.
/// </summary>
[Property, Title("Falloff"), Range(0.0f, 2000.0f)] public float Falloff { get; set { field = value.Clamp(0.0f, 100000.0f); } } = 300.0f;
/// <summary>
/// How far below the road surface the ground is kept, so it never shows through the asphalt.
/// </summary>
[Property, Title("Sink"), Range(0.0f, 50.0f)] public float Sink { get; set { field = value.Clamp(0.0f, 1000.0f); } } = 5.0f;
/// <summary>
/// The terrain material painted along the roads. Give a country road's verge the same texture and the two
/// merge, instead of the verge ending on a line against the landscape.
/// </summary>
[Property, Title("Paint Material")] public TerrainMaterial PaintMaterial { get; set; }
/// <summary>
/// How far past a road's border the material is painted at full strength.
/// </summary>
[Property, Title("Margin"), Range(0.0f, 1000.0f)] public float PaintMargin { get; set { field = value.Clamp(0.0f, 100000.0f); } } = 100.0f;
/// <summary>
/// How far it fades out beyond that, back into whatever the ground was painted with.
/// </summary>
[Property, Title("Fade"), Range(0.0f, 2000.0f)] public float PaintFade { get; set { field = value.Clamp(0.0f, 100000.0f); } } = 200.0f;
public RoadNetwork Network => Root?.Network;
private RoadNetworkTool Root => Manager?.CurrentTool as RoadNetworkTool;
/// <summary>
/// A short report of the last operation, for the sidebar.
/// </summary>
public string LastResult { get; private set; } = "";
public int ResultVersion { get; private set; }
public override Widget CreateToolSidebar()
{
return new RoadTerrainToolWidget(this);
}
public override void OnUpdate()
{
var network = Network;
if (!network.IsValid())
return;
// Show how far the terrain will be touched: the outer edge of the falloff round every road.
using (Gizmo.Scope("roadtool2_terrain"))
{
Gizmo.Draw.IgnoreDepth = true;
Gizmo.Draw.LineThickness = 1.0f;
Gizmo.Draw.Color = Color.Green.WithAlpha(0.35f);
foreach (var segment in network.Segments)
{
if (segment.Bridge is not null || !network.TryGetCurve(segment, out var curve))
continue;
float reach = network.WidthOf(segment).MaxHalf * 2.0f + 2.0f * (RoadTerrainConformer.BorderWidth(network.ProfileOf(segment)) + Falloff);
RoadGizmos.DrawRoadOutline(curve, reach, network.WorldTransform);
}
}
}
public void ConformTerrain()
{
var network = Network;
if (!network.IsValid())
return;
var terrains = Scene.GetAllComponents<Terrain>().Where(terrain => terrain.IsValid() && terrain.Storage is not null).ToList();
if (terrains.Count == 0)
{
Report("No terrain in the scene.");
return;
}
int texels = 0;
foreach (var terrain in terrains)
texels += RoadTerrainConformer.Conform(terrain, network, Falloff, Sink);
Report(texels > 0 ? $"Terrain shaped under the roads ({texels:N0} height samples)." : "No road lies over a terrain.");
}
public void PaintTerrain()
{
var network = Network;
if (!network.IsValid())
return;
if (PaintMaterial is null)
{
Report("Pick a terrain material to paint with first.");
return;
}
var terrains = Scene.GetAllComponents<Terrain>().Where(terrain => terrain.IsValid() && terrain.Storage is not null).ToList();
if (terrains.Count == 0)
{
Report("No terrain in the scene.");
return;
}
int texels = 0;
string problem = null;
foreach (var terrain in terrains)
{
texels += RoadTerrainPainter.Paint(terrain, network, PaintMaterial, PaintMargin, PaintFade, out string reason);
problem ??= reason;
}
Report(texels > 0 ? $"Terrain painted along the roads ({texels:N0} texels)." : problem ?? "No road lies over a terrain.");
}
/// <summary>
/// Drops every node onto whatever is under it (roads skipped), keeping the roads smooth between nodes.
/// </summary>
public void SnapNodesToGround()
{
var network = Network;
if (!network.IsValid())
return;
int moved = 0;
using (SceneEditorSession.Active.UndoScope("Snap Nodes To Ground").WithComponentChanges(network).Push())
{
foreach (var node in network.Nodes.ToList())
{
// A node with only bridges at it is up in the air on purpose; a bridge's end still lands.
var roads = network.GetSegmentsAt(node.Id).ToList();
if (roads.Count > 0 && roads.All(segment => segment.Bridge is not null))
continue;
Vector3 world = network.WorldTransform.PointToWorld(node.Position);
if (!TryGroundHeight(world, out float height) || MathF.Abs(height - world.z) < 0.01f)
continue;
network.MoveNode(node.Id, network.WorldTransform.PointToLocal(world.WithZ(height)));
moved++;
}
}
Report(moved > 0 ? $"{moved} node{(moved == 1 ? "" : "s")} dropped onto the ground." : "Every node already sits on the ground.");
}
private bool TryGroundHeight(Vector3 _World, out float _Height)
{
const float reach = 20000.0f;
var ray = new Ray(_World.WithZ(_World.z + reach * 0.5f), Vector3.Down);
var physics = Scene.Trace.Ray(ray, reach).WithoutTags(RoadNetwork.ChunkTag).Run();
if (physics.Hit)
{
_Height = physics.EndPosition.z;
return true;
}
var meshes = Scene.Trace.Ray(ray, reach).UseRenderMeshes(true, EditorPreferences.BackfaceSelection).WithoutTags("hidden", RoadNetwork.ChunkTag).UsePhysicsWorld(false).Run();
_Height = meshes.Hit ? meshes.EndPosition.z : 0.0f;
return meshes.Hit;
}
private void Report(string _Message)
{
LastResult = _Message;
ResultVersion++;
Log.Info($"Road Tool 2: {_Message}");
}
}
/// <summary>
/// Shapes a terrain's heightmap to a road network. Every height sample within reach of a road takes the height
/// of the nearest road there: below the surface under the road and its border, the border's own outer height at
/// its edge, and a smooth blend back to the untouched terrain across the falloff. Where roads meet, the nearest
/// one wins, which is what the junction geometry does too.
/// </summary>
internal static class RoadTerrainConformer
{
/// <summary>
/// What the roads within reach of one height sample ask of it: the height the nearest road wants the ground
/// at (under the road, or at its border's edge), and how much of the untouched ground is left once every
/// road's pull on the sample is taken together. A sample between two roads is pulled by both, so the ground
/// between them stays level with them instead of mounding up as soon as it leaves either edge.
/// </summary>
private readonly struct Entry
{
public readonly float Distance;
public readonly float Anchor;
public readonly float Remaining;
public Entry(float _Distance, float _Anchor, float _Remaining)
{
Distance = _Distance;
Anchor = _Anchor;
Remaining = _Remaining;
}
}
/// <summary>
/// How far out from the road edge the terrain is shaped to the road before the falloff starts: the pavement
/// of a sidewalk, the shoulder of a country road (its verge is meant to run under the ground, not sit on it).
/// </summary>
public static float BorderWidth(RoadProfile _Profile)
{
return _Profile.Border switch
{
RoadBorderStyle.Sidewalk => _Profile.BorderWidth,
RoadBorderStyle.CountrySide => _Profile.BorderWidth,
_ => 0.0f
};
}
/// <summary>
/// Shapes one terrain, records an undo step, and returns how many height samples changed.
/// </summary>
public static int Conform(Terrain _Terrain, RoadNetwork _Network, float _Falloff, float _Sink)
{
var storage = _Terrain.Storage;
int resolution = storage.Resolution;
if (resolution <= 0 || storage.HeightMap is null || storage.HeightMap.Length != resolution * resolution || storage.TerrainHeight <= 0.0f)
return 0;
float cell = storage.TerrainSize / resolution;
var terrainTransform = _Terrain.WorldTransform;
var networkTransform = _Network.WorldTransform;
var entries = new Dictionary<int, Entry>();
var caps = new Dictionary<int, float>();
var road = new Dictionary<int, Entry>();
foreach (var segment in _Network.Segments)
{
var table = _Network.GetCurveTable(segment);
if (table is null)
continue;
var profile = _Network.ProfileOf(segment);
var taper = _Network.GetBorderTaper(segment);
var width = new RoadWidth(segment, table.Length);
float border = BorderWidth(profile);
// A bridge spans the terrain: the ground under it is not shaped to it, only kept under its deck, which
// is what stops it showing through the deck's ends where the bridge meets the ground.
bool bridge = segment.Bridge is not null;
float deck = bridge ? profile.FlatBorderWidth + segment.Bridge.SideStripWidth + segment.Bridge.DeckOverhang : 0.0f;
float reach = bridge ? width.MaxHalf + deck : width.MaxHalf + border + _Falloff;
Vector3 previous = networkTransform.PointToWorld(table.SamplePosition(0));
road.Clear();
for (int i = 1; i < table.SampleCount; i++)
{
Vector3 current = networkTransform.PointToWorld(table.SamplePosition(i));
// Each side of the piece as it is at the piece's middle: how far out its edge sits, and its border
// as the segment's taper leaves it there.
float middle = (table.SampleDistance(i - 1) + table.SampleDistance(i)) * 0.5f;
if (bridge)
{
RasteriseDeck(caps, _Terrain, terrainTransform, resolution, cell, previous, current, width.LeftAt(middle) + deck, width.RightAt(middle) + deck, _Network.SurfaceOffset, _Sink);
previous = current;
continue;
}
var leftSide = new Side(width.LeftAt(middle),
border * taper.Left.WidthScaleAt(middle, table.Length, profile.FlatBorderWidth),
taper.Left.ReliefAt(middle, table.Length, out _, out _, out _));
var rightSide = new Side(width.RightAt(middle),
border * taper.Right.WidthScaleAt(middle, table.Length, profile.FlatBorderWidth),
taper.Right.ReliefAt(middle, table.Length, out _, out _, out _));
Rasterise(road, caps, terrainTransform, resolution, cell, previous, current, reach, leftSide, rightSide, profile, _Network.SurfaceOffset, _Falloff, _Sink);
previous = current;
}
// One road pulls on a sample once, from its nearest piece; roads pull together.
foreach (var (index, pull) in road)
{
if (entries.TryGetValue(index, out var existing))
entries[index] = new Entry(MathF.Min(existing.Distance, pull.Distance), existing.Distance <= pull.Distance ? existing.Anchor : pull.Anchor, existing.Remaining * pull.Remaining);
else
entries[index] = pull;
}
}
// The junction and parking lot surfaces and the pavements round them: the ground is held under them and
// brought up to meet their kerbs, the same way a road holds it.
foreach (var (centre, polygon, pavementReach, pavementTop) in _Network.GetJunctionPolygons())
{
road.Clear();
RasteriseArea(road, caps, _Terrain, terrainTransform, networkTransform, resolution, cell, centre, polygon, pavementReach, pavementTop, _Falloff, _Sink);
foreach (var (index, pull) in road)
{
if (entries.TryGetValue(index, out var existing))
entries[index] = new Entry(MathF.Min(existing.Distance, pull.Distance), existing.Distance <= pull.Distance ? existing.Anchor : pull.Anchor, existing.Remaining * pull.Remaining);
else
entries[index] = pull;
}
}
if (entries.Count == 0 && caps.Count == 0)
return 0;
// The touched rectangle, for the undo snapshot and the collider update.
int left = int.MaxValue, top = int.MaxValue, right = int.MinValue, bottom = int.MinValue;
foreach (int index in entries.Keys.Concat(caps.Keys))
{
int x = index % resolution;
int y = index / resolution;
left = Math.Min(left, x);
right = Math.Max(right, x);
top = Math.Min(top, y);
bottom = Math.Max(bottom, y);
}
var region = new RectInt(left, top, right - left + 1, bottom - top + 1);
ushort[] before = CopyRegion(storage.HeightMap, resolution, region);
float heightScale = ushort.MaxValue / storage.TerrainHeight;
float originalScale = storage.TerrainHeight / ushort.MaxValue;
int changed = 0;
foreach (int index in entries.Keys.Concat(caps.Keys).Distinct())
{
int x = index % resolution;
int y = index / resolution;
// What the roads round this sample pull it to, capped by every road surface lying over it: a sample
// under one road and in another's falloff is still under a road.
Vector3 worldCentre = terrainTransform.PointToWorld(new Vector3((x + 0.5f) * cell, (y + 0.5f) * cell, 0.0f));
float original = terrainTransform.PointToWorld(new Vector3(0.0f, 0.0f, storage.HeightMap[index] * originalScale)).z;
float target = entries.TryGetValue(index, out var entry) ? MathX.Lerp(entry.Anchor, original, entry.Remaining) : original;
if (caps.TryGetValue(index, out float cap))
target = MathF.Min(target, cap);
// The target is a world height; the map stores heights above the terrain's own origin.
float local = terrainTransform.PointToLocal(worldCentre.WithZ(target)).z;
storage.HeightMap[index] = (ushort)Math.Clamp(MathF.Round(local * heightScale), 0.0f, ushort.MaxValue);
changed++;
}
ushort[] after = CopyRegion(storage.HeightMap, resolution, region);
_Terrain.SyncGPUTexture();
_Terrain.UpdateCollision(Terrain.SyncFlags.Height, region);
SceneEditorSession.Active.UndoSystem.Insert("Conform Terrain To Roads",
() => RestoreRegion(_Terrain, before, region),
() => RestoreRegion(_Terrain, after, region));
return changed;
}
/// <summary>
/// One side of a piece of road: how far out from the centreline its edge sits, the width of its border there,
/// and how much of the border's relief is in force.
/// </summary>
private readonly record struct Side(float Edge, float Border, float Relief);
/// <summary>
/// Visits every height sample within reach of one straight piece of road and records, for the road it is a
/// piece of, what its nearest piece asks of the sample; and, for every sample under the road's surface, the
/// height it must at least stay under.
/// </summary>
private static void Rasterise(Dictionary<int, Entry> _Road, Dictionary<int, float> _Caps, in Transform _TerrainTransform, int _Resolution, float _Cell,
Vector3 _From, Vector3 _To, float _Reach, in Side _Left, in Side _Right, RoadProfile _Profile, float _SurfaceOffset, float _Falloff, float _Sink)
{
// The piece's footprint in terrain samples.
Vector3 fromLocal = _TerrainTransform.PointToLocal(_From);
Vector3 toLocal = _TerrainTransform.PointToLocal(_To);
float localReach = _Reach / _TerrainTransform.Scale.x;
int minX = Math.Clamp((int)MathF.Floor((MathF.Min(fromLocal.x, toLocal.x) - localReach) / _Cell), 0, _Resolution - 1);
int maxX = Math.Clamp((int)MathF.Ceiling((MathF.Max(fromLocal.x, toLocal.x) + localReach) / _Cell), 0, _Resolution - 1);
int minY = Math.Clamp((int)MathF.Floor((MathF.Min(fromLocal.y, toLocal.y) - localReach) / _Cell), 0, _Resolution - 1);
int maxY = Math.Clamp((int)MathF.Ceiling((MathF.Max(fromLocal.y, toLocal.y) + localReach) / _Cell), 0, _Resolution - 1);
if (minX > maxX || minY > maxY)
return;
var a = new Vector2(_From.x, _From.y);
var b = new Vector2(_To.x, _To.y);
Vector2 ab = b - a;
float lengthSquared = ab.LengthSquared;
for (int y = minY; y <= maxY; y++)
{
for (int x = minX; x <= maxX; x++)
{
Vector3 world = _TerrainTransform.PointToWorld(new Vector3((x + 0.5f) * _Cell, (y + 0.5f) * _Cell, 0.0f));
var point = new Vector2(world.x, world.y);
// Nearest point on the piece, the road height there, and which side of the road the sample is on
// (its edge may sit further out on one side than the other).
float t = lengthSquared > 0.0f ? Math.Clamp(Vector2.Dot(point - a, ab) / lengthSquared, 0.0f, 1.0f) : 0.0f;
Vector2 nearest = a + ab * t;
float distance = nearest.Distance(point);
if (distance > _Reach)
continue;
bool left = ab.x * (point.y - a.y) - ab.y * (point.x - a.x) >= 0.0f;
var side = left ? _Left : _Right;
int index = x + y * _Resolution;
float surface = MathX.Lerp(_From.z, _To.z, t) + _SurfaceOffset;
float anchor = AnchorHeight(distance, surface, side.Edge, side.Border, side.Relief, _Profile, _Sink);
// Under the road, and under a pavement, the ground can be no higher than this whatever another
// road nearby asks for.
bool covered = distance <= side.Edge + (_Profile.Border == RoadBorderStyle.Sidewalk ? side.Border : 0.0f);
if (covered && (!_Caps.TryGetValue(index, out float existingCap) || anchor < existingCap))
_Caps[index] = anchor;
if (_Road.TryGetValue(index, out var existing) && existing.Distance <= distance)
continue;
// This road's pull on the sample: all of it out to the border's edge, fading over the falloff. The
// roads' pulls are taken together afterwards, so between two roads the ground is held level.
float beyond = MathF.Max(0.0f, distance - side.Edge - side.Border);
float release = _Falloff > 0.0f ? Math.Clamp(beyond / _Falloff, 0.0f, 1.0f) : (beyond > 0.0f ? 1.0f : 0.0f);
release = release * release * (3.0f - 2.0f * release);
_Road[index] = new Entry(distance, anchor, release);
}
}
}
/// <summary>
/// Keeps every height sample under a bridge's deck below the road on it: the ground is not shaped to a bridge,
/// but it must not come up through its deck where the bridge runs into the ground at its ends.
/// </summary>
private static void RasteriseDeck(Dictionary<int, float> _Caps, Terrain _Terrain, in Transform _TerrainTransform, int _Resolution, float _Cell,
Vector3 _From, Vector3 _To, float _LeftReach, float _RightReach, float _SurfaceOffset, float _Sink)
{
float reach = MathF.Max(_LeftReach, _RightReach);
Vector3 fromLocal = _TerrainTransform.PointToLocal(_From);
Vector3 toLocal = _TerrainTransform.PointToLocal(_To);
float localReach = reach / _TerrainTransform.Scale.x;
int minX = Math.Clamp((int)MathF.Floor((MathF.Min(fromLocal.x, toLocal.x) - localReach) / _Cell), 0, _Resolution - 1);
int maxX = Math.Clamp((int)MathF.Ceiling((MathF.Max(fromLocal.x, toLocal.x) + localReach) / _Cell), 0, _Resolution - 1);
int minY = Math.Clamp((int)MathF.Floor((MathF.Min(fromLocal.y, toLocal.y) - localReach) / _Cell), 0, _Resolution - 1);
int maxY = Math.Clamp((int)MathF.Ceiling((MathF.Max(fromLocal.y, toLocal.y) + localReach) / _Cell), 0, _Resolution - 1);
if (minX > maxX || minY > maxY)
return;
var a = new Vector2(_From.x, _From.y);
var b = new Vector2(_To.x, _To.y);
Vector2 ab = b - a;
float lengthSquared = ab.LengthSquared;
for (int y = minY; y <= maxY; y++)
{
for (int x = minX; x <= maxX; x++)
{
Vector3 world = _TerrainTransform.PointToWorld(new Vector3((x + 0.5f) * _Cell, (y + 0.5f) * _Cell, 0.0f));
var point = new Vector2(world.x, world.y);
float t = lengthSquared > 0.0f ? Math.Clamp(Vector2.Dot(point - a, ab) / lengthSquared, 0.0f, 1.0f) : 0.0f;
float distance = (a + ab * t).Distance(point);
bool left = ab.x * (point.y - a.y) - ab.y * (point.x - a.x) >= 0.0f;
if (distance > (left ? _LeftReach : _RightReach))
continue;
int index = x + y * _Resolution;
float cap = MathX.Lerp(_From.z, _To.z, t) + _SurfaceOffset - _Sink;
if (!_Caps.TryGetValue(index, out float existingCap) || cap < existingCap)
_Caps[index] = cap;
}
}
}
/// <summary>
/// Keeps every height sample under a junction's surface (a fan from its centre to its outline) below it, and
/// every sample under the pavement round the outline just under the pavement's top.
/// </summary>
private static void RasteriseArea(Dictionary<int, Entry> _Area, Dictionary<int, float> _Caps, Terrain _Terrain, in Transform _TerrainTransform, in Transform _NetworkTransform, int _Resolution, float _Cell,
Vector3 _Centre, IReadOnlyList<Vector3> _Polygon, float _PavementReach, float _PavementTop, float _Falloff, float _Sink)
{
var centre = _NetworkTransform.PointToWorld(_Centre);
var vertices = new Vector3[_Polygon.Count];
float localReach = (_PavementReach + _Falloff) / _TerrainTransform.Scale.x;
float minX = float.MaxValue, minY = float.MaxValue, maxX = float.MinValue, maxY = float.MinValue;
for (int i = 0; i < _Polygon.Count; i++)
{
vertices[i] = _NetworkTransform.PointToWorld(_Polygon[i]);
Vector3 local = _TerrainTransform.PointToLocal(vertices[i]);
minX = MathF.Min(minX, local.x - localReach);
maxX = MathF.Max(maxX, local.x + localReach);
minY = MathF.Min(minY, local.y - localReach);
maxY = MathF.Max(maxY, local.y + localReach);
}
int fromX = Math.Clamp((int)MathF.Floor(minX / _Cell), 0, _Resolution - 1);
int toX = Math.Clamp((int)MathF.Ceiling(maxX / _Cell), 0, _Resolution - 1);
int fromY = Math.Clamp((int)MathF.Floor(minY / _Cell), 0, _Resolution - 1);
int toY = Math.Clamp((int)MathF.Ceiling(maxY / _Cell), 0, _Resolution - 1);
for (int y = fromY; y <= toY; y++)
{
for (int x = fromX; x <= toX; x++)
{
Vector3 world = _TerrainTransform.PointToWorld(new Vector3((x + 0.5f) * _Cell, (y + 0.5f) * _Cell, 0.0f));
var point = new Vector2(world.x, world.y);
float distance;
float edge;
if (FanHeight(centre, vertices, point) is { } height)
{
// Under the surface itself.
distance = 0.0f;
edge = height;
}
else
{
distance = OutlineDistance(vertices, point, out edge);
if (distance > _PavementReach + _Falloff)
continue;
}
// Under the surface the ground is kept below it; under the pavement it comes up to meet its outer
// edge; beyond that it is held there and let go over the falloff.
float anchor = _PavementReach > 0.0f
? MathX.Lerp(edge - _Sink, edge + _PavementTop - 0.5f, Math.Clamp(distance / _PavementReach, 0.0f, 1.0f))
: (distance <= 0.0f ? edge - _Sink : edge - 1.0f);
int index = x + y * _Resolution;
if (distance <= _PavementReach && (!_Caps.TryGetValue(index, out float existingCap) || anchor < existingCap))
_Caps[index] = anchor;
float beyond = MathF.Max(0.0f, distance - _PavementReach);
float release = _Falloff > 0.0f ? Math.Clamp(beyond / _Falloff, 0.0f, 1.0f) : (beyond > 0.0f ? 1.0f : 0.0f);
release = release * release * (3.0f - 2.0f * release);
if (_Area.TryGetValue(index, out var existing) && existing.Distance <= distance)
continue;
_Area[index] = new Entry(distance, anchor, release);
}
}
}
/// <summary>
/// How far a point in plan is from an outline, and the outline's height at the nearest point.
/// </summary>
internal static float OutlineDistance(Vector3[] _Vertices, Vector2 _Point, out float _Height)
{
float best = float.MaxValue;
_Height = 0.0f;
for (int i = 0; i < _Vertices.Length; i++)
{
Vector3 v0 = _Vertices[i];
Vector3 v1 = _Vertices[(i + 1) % _Vertices.Length];
var p0 = new Vector2(v0.x, v0.y);
Vector2 edge = new Vector2(v1.x, v1.y) - p0;
float lengthSquared = edge.LengthSquared;
float t = lengthSquared > 0.0f ? Math.Clamp(Vector2.Dot(_Point - p0, edge) / lengthSquared, 0.0f, 1.0f) : 0.0f;
float distance = (p0 + edge * t).Distance(_Point);
if (distance >= best)
continue;
best = distance;
_Height = MathX.Lerp(v0.z, v1.z, t);
}
return best;
}
/// <summary>
/// The height of the fan of triangles from a centre to an outline at a point in plan, or null outside it.
/// </summary>
internal static float? FanHeight(Vector3 _Centre, Vector3[] _Vertices, Vector2 _Point)
{
var c = new Vector2(_Centre.x, _Centre.y);
for (int i = 0; i < _Vertices.Length; i++)
{
Vector3 v0 = _Vertices[i];
Vector3 v1 = _Vertices[(i + 1) % _Vertices.Length];
var p0 = new Vector2(v0.x, v0.y);
var p1 = new Vector2(v1.x, v1.y);
float area = Cross(p0 - c, p1 - c);
if (MathF.Abs(area) < 0.0001f)
continue;
// Barycentric weights; a little slack so samples on the seams between triangles are not missed.
float w0 = Cross(p1 - _Point, c - _Point) / area;
float w1 = Cross(c - _Point, p0 - _Point) / area;
float w2 = 1.0f - w0 - w1;
if (w0 < -0.001f || w1 < -0.001f || w2 < -0.001f)
continue;
return w0 * v0.z + w1 * v1.z + w2 * _Centre.z;
}
return null;
}
private static float Cross(Vector2 _A, Vector2 _B)
{
return _A.x * _B.y - _A.y * _B.x;
}
/// <summary>
/// The height a road holds the ground at, at a distance from its centreline: under the surface across the
/// road, ramping under the border to meet the border's outer edge, and the edge's height from there on (how
/// far that hold reaches before the untouched terrain takes over is the falloff, applied by the caller).
/// </summary>
private static float AnchorHeight(float _Distance, float _Surface, float _HalfWidth, float _Border, float _Relief, RoadProfile _Profile, float _Sink)
{
float outer = _HalfWidth + _Border;
float edge;
// The border's relief is what it is here: sinking to nothing where the road type changes.
switch (_Profile.Border)
{
case RoadBorderStyle.Sidewalk:
// Flush with the pavement (a hair under, so its edge stays a clean line).
edge = _Surface + _Profile.CurbHeight * _Relief - 0.5f;
break;
case RoadBorderStyle.CountrySide:
// The ground meets the foot of the shoulder's drop; the verge slopes on down below it, so the
// terrain runs through the verge and the two merge.
edge = _Surface - _Profile.Drop * _Relief;
break;
default:
// Bare edge: the ground meets the slab just under its lip.
edge = _Surface - 1.0f;
break;
}
if (_Distance <= _HalfWidth)
return _Surface - _Sink;
if (_Distance <= outer)
{
float across = (_Distance - _HalfWidth) / MathF.Max(_Border, 0.001f);
switch (_Profile.Border)
{
case RoadBorderStyle.Sidewalk:
// Hidden under the pavement, rising to meet its outer edge.
return MathX.Lerp(_Surface - _Sink, edge, across);
case RoadBorderStyle.CountrySide:
// Hidden under the shoulder, coming up to the foot of the drop at its edge.
return MathX.Lerp(_Surface - _Sink, edge, across);
default:
return edge;
}
}
return edge;
}
private static ushort[] CopyRegion(ushort[] _Data, int _Stride, RectInt _Region)
{
var region = new ushort[_Region.Width * _Region.Height];
for (int y = 0; y < _Region.Height; y++)
{
for (int x = 0; x < _Region.Width; x++)
region[x + y * _Region.Width] = _Data[_Region.Left + x + (_Region.Top + y) * _Stride];
}
return region;
}
private static void RestoreRegion(Terrain _Terrain, ushort[] _Region, RectInt _Rect)
{
if (!_Terrain.IsValid() || _Terrain.Storage is null)
return;
int stride = _Terrain.Storage.Resolution;
for (int y = 0; y < _Rect.Height; y++)
{
for (int x = 0; x < _Rect.Width; x++)
_Terrain.Storage.HeightMap[_Rect.Left + x + (_Rect.Top + y) * stride] = _Region[x + y * _Rect.Width];
}
_Terrain.SyncGPUTexture();
_Terrain.UpdateCollision(Terrain.SyncFlags.Height, _Rect);
}
}
/// <summary>
/// Paints a terrain material along the roads. Every texel under a road and its border, and out by a margin from
/// there, takes the chosen material as its overlay layer — at full strength inside that band, fading to nothing
/// over the fade beyond it. Give a country road's verge the same texture and the two merge into each other; a
/// street gets a worn band round it.
/// </summary>
internal static class RoadTerrainPainter
{
/// <summary>
/// A control map texel names its two materials in five bits each, so a terrain can hold this many.
/// </summary>
private const int MaxMaterials = 32;
/// <summary>
/// Paints one terrain, records an undo step, and returns how many texels changed. <paramref name="_Problem"/>
/// is set when nothing could be painted for a reason the user needs telling about.
/// </summary>
public static int Paint(Terrain _Terrain, RoadNetwork _Network, TerrainMaterial _Material, float _Margin, float _Fade, out string _Problem)
{
_Problem = null;
var storage = _Terrain.Storage;
int resolution = storage.Resolution;
if (resolution <= 0 || storage.ControlMap is null || storage.ControlMap.Length != resolution * resolution)
return 0;
int id = storage.Materials.IndexOf(_Material);
if (id < 0)
{
if (storage.Materials.Count >= MaxMaterials)
{
_Problem = $"\"{_Terrain.GameObject.Name}\" already carries {MaxMaterials} terrain materials, which is all its control map can name.";
return 0;
}
// The control map names materials by their place in the terrain's own list, so one the terrain has
// never held joins the list first.
storage.Materials.Add(_Material);
_Terrain.UpdateMaterialsBuffer();
id = storage.Materials.Count - 1;
}
float cell = storage.TerrainSize / resolution;
var terrainTransform = _Terrain.WorldTransform;
var networkTransform = _Network.WorldTransform;
var coverage = new Dictionary<int, float>();
foreach (var segment in _Network.Segments)
{
var table = _Network.GetCurveTable(segment);
// A bridge carries its road over the ground rather than lying on it: what is under it is landscape.
if (table is null || segment.Bridge is not null)
continue;
var profile = _Network.ProfileOf(segment);
var taper = _Network.GetBorderTaper(segment);
var width = new RoadWidth(segment, table.Length);
float border = profile.FlatBorderWidth;
Vector3 previous = networkTransform.PointToWorld(table.SamplePosition(0));
for (int i = 1; i < table.SampleCount; i++)
{
Vector3 current = networkTransform.PointToWorld(table.SamplePosition(i));
// The band as the piece's middle has it: the road's edge on that side, its border as the segment's
// taper leaves it there, and the margin.
float middle = (table.SampleDistance(i - 1) + table.SampleDistance(i)) * 0.5f;
float left = width.LeftAt(middle) + border * taper.Left.WidthScaleAt(middle, table.Length, border) + _Margin;
float right = width.RightAt(middle) + border * taper.Right.WidthScaleAt(middle, table.Length, border) + _Margin;
RasterisePiece(coverage, terrainTransform, resolution, cell, previous, current, left, right, _Fade);
previous = current;
}
}
// A junction or a parking lot is a surface of its own: painted inside its outline and out from it.
foreach (var (centre, polygon, pavementReach, _) in _Network.GetJunctionPolygons())
RasteriseArea(coverage, terrainTransform, networkTransform, resolution, cell, centre, polygon, pavementReach + _Margin, _Fade);
if (coverage.Count == 0)
return 0;
// The touched rectangle, for the undo snapshot and the collider update.
int minX = int.MaxValue, minY = int.MaxValue, maxX = int.MinValue, maxY = int.MinValue;
foreach (int index in coverage.Keys)
{
int x = index % resolution;
int y = index / resolution;
minX = Math.Min(minX, x);
maxX = Math.Max(maxX, x);
minY = Math.Min(minY, y);
maxY = Math.Max(maxY, y);
}
var region = new RectInt(minX, minY, maxX - minX + 1, maxY - minY + 1);
uint[] before = CopyRegion(storage.ControlMap, resolution, region);
int changed = 0;
foreach (var (index, strength) in coverage)
{
// The material goes on as the OVERLAY, so whatever the ground was painted with stays underneath it as
// the base and the band's edge is a blend rather than a cut.
var texel = new CompactTerrainMaterial(storage.ControlMap[index])
{
OverlayTextureId = (byte)id,
BlendFactor = (byte)Math.Clamp(MathF.Round(strength * 255.0f), 0.0f, 255.0f)
};
if (storage.ControlMap[index] == texel.Packed)
continue;
storage.ControlMap[index] = texel.Packed;
changed++;
}
if (changed == 0)
return 0;
uint[] after = CopyRegion(storage.ControlMap, resolution, region);
_Terrain.SyncGPUTexture();
_Terrain.UpdateCollision(Terrain.SyncFlags.Control, region);
SceneEditorSession.Active.UndoSystem.Insert("Paint Terrain Along Roads",
() => RestoreRegion(_Terrain, before, region),
() => RestoreRegion(_Terrain, after, region));
return changed;
}
/// <summary>
/// Visits every control map texel within reach of one straight piece of road and records how much of the
/// material it takes. A texel near two roads takes the most either of them gives it.
/// </summary>
private static void RasterisePiece(Dictionary<int, float> _Coverage, in Transform _TerrainTransform, int _Resolution, float _Cell,
Vector3 _From, Vector3 _To, float _Left, float _Right, float _Fade)
{
Vector3 fromLocal = _TerrainTransform.PointToLocal(_From);
Vector3 toLocal = _TerrainTransform.PointToLocal(_To);
float localReach = (MathF.Max(_Left, _Right) + _Fade) / _TerrainTransform.Scale.x;
int minX = Math.Clamp((int)MathF.Floor((MathF.Min(fromLocal.x, toLocal.x) - localReach) / _Cell), 0, _Resolution - 1);
int maxX = Math.Clamp((int)MathF.Ceiling((MathF.Max(fromLocal.x, toLocal.x) + localReach) / _Cell), 0, _Resolution - 1);
int minY = Math.Clamp((int)MathF.Floor((MathF.Min(fromLocal.y, toLocal.y) - localReach) / _Cell), 0, _Resolution - 1);
int maxY = Math.Clamp((int)MathF.Ceiling((MathF.Max(fromLocal.y, toLocal.y) + localReach) / _Cell), 0, _Resolution - 1);
if (minX > maxX || minY > maxY)
return;
var a = new Vector2(_From.x, _From.y);
var b = new Vector2(_To.x, _To.y);
Vector2 ab = b - a;
float lengthSquared = ab.LengthSquared;
for (int y = minY; y <= maxY; y++)
{
for (int x = minX; x <= maxX; x++)
{
Vector3 world = _TerrainTransform.PointToWorld(new Vector3((x + 0.5f) * _Cell, (y + 0.5f) * _Cell, 0.0f));
var point = new Vector2(world.x, world.y);
float t = lengthSquared > 0.0f ? Math.Clamp(Vector2.Dot(point - a, ab) / lengthSquared, 0.0f, 1.0f) : 0.0f;
float distance = (a + ab * t).Distance(point);
// Which side the texel is on: a road's edge may sit further out on one side than the other.
bool left = ab.x * (point.y - a.y) - ab.y * (point.x - a.x) >= 0.0f;
Take(_Coverage, x + y * _Resolution, Strength(distance, left ? _Left : _Right, _Fade));
}
}
}
/// <summary>
/// The same for the surface of a junction or a parking lot: everything inside its outline, and out from it.
/// </summary>
private static void RasteriseArea(Dictionary<int, float> _Coverage, in Transform _TerrainTransform, in Transform _NetworkTransform, int _Resolution, float _Cell,
Vector3 _Centre, IReadOnlyList<Vector3> _Polygon, float _Band, float _Fade)
{
if (_Polygon.Count < 3)
return;
var centre = _NetworkTransform.PointToWorld(_Centre);
var vertices = new Vector3[_Polygon.Count];
float localReach = (_Band + _Fade) / _TerrainTransform.Scale.x;
float minX = float.MaxValue, minY = float.MaxValue, maxX = float.MinValue, maxY = float.MinValue;
for (int i = 0; i < _Polygon.Count; i++)
{
vertices[i] = _NetworkTransform.PointToWorld(_Polygon[i]);
Vector3 local = _TerrainTransform.PointToLocal(vertices[i]);
minX = MathF.Min(minX, local.x - localReach);
maxX = MathF.Max(maxX, local.x + localReach);
minY = MathF.Min(minY, local.y - localReach);
maxY = MathF.Max(maxY, local.y + localReach);
}
int fromX = Math.Clamp((int)MathF.Floor(minX / _Cell), 0, _Resolution - 1);
int toX = Math.Clamp((int)MathF.Ceiling(maxX / _Cell), 0, _Resolution - 1);
int fromY = Math.Clamp((int)MathF.Floor(minY / _Cell), 0, _Resolution - 1);
int toY = Math.Clamp((int)MathF.Ceiling(maxY / _Cell), 0, _Resolution - 1);
for (int y = fromY; y <= toY; y++)
{
for (int x = fromX; x <= toX; x++)
{
Vector3 world = _TerrainTransform.PointToWorld(new Vector3((x + 0.5f) * _Cell, (y + 0.5f) * _Cell, 0.0f));
var point = new Vector2(world.x, world.y);
float distance = RoadTerrainConformer.FanHeight(centre, vertices, point) is not null
? 0.0f
: RoadTerrainConformer.OutlineDistance(vertices, point, out _);
Take(_Coverage, x + y * _Resolution, Strength(distance, _Band, _Fade));
}
}
}
private static void Take(Dictionary<int, float> _Coverage, int _Index, float _Strength)
{
if (_Strength <= 0.0f)
return;
if (!_Coverage.TryGetValue(_Index, out float existing) || _Strength > existing)
_Coverage[_Index] = _Strength;
}
/// <summary>
/// How much of the material a texel takes at a distance from a road: all of it out to the band's edge, then
/// smoothly to nothing over the fade.
/// </summary>
private static float Strength(float _Distance, float _Band, float _Fade)
{
if (_Distance <= _Band)
return 1.0f;
if (_Fade <= 0.0f)
return 0.0f;
float beyond = Math.Clamp((_Distance - _Band) / _Fade, 0.0f, 1.0f);
return 1.0f - beyond * beyond * (3.0f - 2.0f * beyond);
}
private static uint[] CopyRegion(uint[] _Data, int _Stride, RectInt _Region)
{
var region = new uint[_Region.Width * _Region.Height];
for (int y = 0; y < _Region.Height; y++)
{
for (int x = 0; x < _Region.Width; x++)
region[x + y * _Region.Width] = _Data[_Region.Left + x + (_Region.Top + y) * _Stride];
}
return region;
}
private static void RestoreRegion(Terrain _Terrain, uint[] _Region, RectInt _Rect)
{
if (!_Terrain.IsValid() || _Terrain.Storage is null)
return;
int stride = _Terrain.Storage.Resolution;
for (int y = 0; y < _Rect.Height; y++)
{
for (int x = 0; x < _Rect.Width; x++)
_Terrain.Storage.ControlMap[_Rect.Left + x + (_Rect.Top + y) * stride] = _Region[x + y * _Rect.Width];
}
_Terrain.SyncGPUTexture();
_Terrain.UpdateCollision(Terrain.SyncFlags.Control, _Rect);
}
}
file class RoadTerrainToolWidget : ToolSidebarWidget
{
private readonly RoadTerrainTool m_Tool;
private Label m_Result;
private int m_ShownVersion = -1;
public RoadTerrainToolWidget(RoadTerrainTool _Tool)
{
m_Tool = _Tool;
AddTitle("Terrain", "landscape");
var serialized = _Tool.GetSerialized();
var settings = AddGroup("Shaping");
SidebarRows.Add(settings, serialized.GetProperty(nameof(RoadTerrainTool.Falloff)));
SidebarRows.Add(settings, serialized.GetProperty(nameof(RoadTerrainTool.Sink)));
settings.Add(new Label("The ground is cut and filled to meet every road and its border, then blends back over the falloff. Roads themselves do not move.") { WordWrap = true });
settings.Add(new Button("Conform Terrain To Roads", "landscape") { Clicked = m_Tool.ConformTerrain });
var painting = AddGroup("Painting");
SidebarRows.Add(painting, serialized.GetProperty(nameof(RoadTerrainTool.PaintMaterial)));
SidebarRows.Add(painting, serialized.GetProperty(nameof(RoadTerrainTool.PaintMargin)));
SidebarRows.Add(painting, serialized.GetProperty(nameof(RoadTerrainTool.PaintFade)));
painting.Add(new Label("Paints the material onto the terrain under every road and its border, out by the margin and fading from there. Give a country verge the same texture and the two merge.") { WordWrap = true });
painting.Add(new Button("Paint Terrain Along Roads", "brush") { Clicked = m_Tool.PaintTerrain });
var nodes = AddGroup("Nodes");
nodes.Add(new Label("Drops every node onto the ground under it; the roads stay smooth between nodes.") { WordWrap = true });
nodes.Add(new Button("Snap Nodes To Ground", "vertical_align_bottom") { Clicked = m_Tool.SnapNodesToGround });
var result = AddGroup("Result");
m_Result = result.Add(new Label("") { WordWrap = true });
Layout.AddStretchCell();
}
[EditorEvent.Frame]
public void Frame()
{
if (m_Tool.ResultVersion == m_ShownVersion)
return;
m_ShownVersion = m_Tool.ResultVersion;
m_Result.Text = m_Tool.LastResult;
}
}