Editor/TerrainEditorTool.cs
using System;
using Editor;
using System.Linq;
using Sandbox;
using RedSnail.RoadTool;
namespace RedSnail.RoadTool.Editor;
/// <summary>
/// Editor tool used to deform the terrain storage to align with a selected road spline.
/// Provides controls for falloff radius, sampling precision, and height offsets.
/// </summary>
[Title("Terrain")]
[Icon("landscape")]
[Group("1")]
[Order(0)]
public class TerrainEditorTool : EditorTool
{
public override Widget CreateToolSidebar()
{
ToolSidebarWidget sidebar = new ToolSidebarWidget();
sidebar.AddTitle("Terrain", "landscape");
var selection = SceneEditorSession.Active.Selection.FirstOrDefault();
Component targetComponent = null;
if (selection is RoadComponent r) targetComponent = r;
else if (selection is RoadIntersectionComponent i) targetComponent = i;
else if (selection is GameObject go)
{
targetComponent = go.Components.Get<RoadComponent>() ?? (Component)go.Components.Get<RoadIntersectionComponent>();
}
if (targetComponent.IsValid())
{
var serialized = targetComponent.GetSerialized();
Layout propertiesGroup = sidebar.AddGroup("Properties");
var varProperties = targetComponent is RoadComponent
? new[] { "TerrainFalloffRadius", "TerrainStepPrecision", "TerrainHeightOffset", "TerrainRoadInset" }
: new[] { "TerrainFalloffRadius", "TerrainHeightOffset" };
foreach (var propName in varProperties)
{
AddPropertyControl(propertiesGroup, serialized.GetProperty(propName));
}
propertiesGroup.Add(new Button("Apply to the Ground", "landscape") { Clicked = AlignTerrainToRoad });
Layout texGroup = sidebar.AddGroup("Texture");
var texProperties = new[] { "TerrainEdgeRadius", "TerrainTargetLayer", "TerrainTextureNoise", "TerrainEdgeMaterials", "TerrainEdgeBlendGradient" };
foreach (var propName in texProperties)
{
AddPropertyControl(texGroup, serialized.GetProperty(propName));
}
texGroup.Add(new Button("Apply Materials", "palette") { Clicked = PaintRoadMaterials });
}
else
{
Layout componente = sidebar.AddGroup("Componentes");
componente.Add(new Label("Select a route to edit"));
}
// Flexible space to push everything to the top
sidebar.Layout.AddStretchCell();
return sidebar;
}
private void AddPropertyControl(Layout layout, SerializedProperty prop)
{
if (prop == null) return;
var propLayout = layout.AddColumn();
propLayout.Spacing = 2;
propLayout.Margin = new Sandbox.UI.Margin(0, 4, 0, 4);
propLayout.Add(ControlSheet.CreateLabel(prop));
propLayout.Add(ControlWidget.Create(prop));
}
/// <summary>
/// This method applies to the selected RoadComponent.
/// </summary>
public static void AlignTerrainToRoad()
{
var selection = SceneEditorSession.Active.Selection.FirstOrDefault();
RoadComponent road = null;
RoadIntersectionComponent intersection = null;
if (selection is RoadComponent r) road = r;
else if (selection is RoadIntersectionComponent i) intersection = i;
else if (selection is GameObject go)
{
road = go.Components.Get<RoadComponent>();
intersection = go.Components.Get<RoadIntersectionComponent>();
}
if (road == null && intersection == null)
{
Log.Warning("RoadTool: Please select a Road or Intersection to use this tool.");
return;
}
var terrain = SceneEditorSession.Active.Scene.GetAllComponents<Terrain>().FirstOrDefault();
if (terrain == null) return;
// 1. Capture state BEFORE
var storage = terrain.Storage;
var oldHeightMap = new ushort[storage.HeightMap.Length];
Array.Copy(storage.HeightMap, oldHeightMap, oldHeightMap.Length);
// 2. Execute modification based on type
if (road.IsValid()) road.AdaptTerrainToRoad();
else if (intersection.IsValid()) intersection.AdaptTerrainToIntersection();
// 3. Capture state AFTER
var newHeightMap = new ushort[storage.HeightMap.Length];
Array.Copy(storage.HeightMap, newHeightMap, newHeightMap.Length);
// 4. Register in editor history
var targetTerrain = terrain;
var targetStorage = storage;
SceneEditorSession.Active.AddUndo("Align Terrain to Road",
undo: () =>
{
if (!targetTerrain.IsValid()) return;
targetStorage.HeightMap = oldHeightMap;
targetStorage.StateHasChanged();
targetTerrain.Create();
targetTerrain.SyncGPUTexture();
},
redo: () =>
{
if (!targetTerrain.IsValid()) return;
targetStorage.HeightMap = newHeightMap;
targetStorage.StateHasChanged();
targetTerrain.Create();
targetTerrain.SyncGPUTexture();
});
}
public static void PaintRoadMaterials()
{
var selection = SceneEditorSession.Active.Selection.FirstOrDefault();
RoadComponent road = null;
RoadIntersectionComponent intersection = null;
if (selection is RoadComponent r) road = r;
else if (selection is RoadIntersectionComponent i) intersection = i;
else if (selection is GameObject go)
{
road = go.Components.Get<RoadComponent>();
intersection = go.Components.Get<RoadIntersectionComponent>();
}
if (road == null && intersection == null) return;
var terrain = SceneEditorSession.Active.Scene.GetAllComponents<Terrain>().FirstOrDefault();
if (terrain == null) return;
var storage = terrain.Storage;
var oldControlMap = storage.ControlMap.ToArray();
if (road.IsValid()) road.PaintTerrainToRoad();
else if (intersection.IsValid()) intersection.PaintTerrainToIntersection();
var newControlMap = storage.ControlMap.ToArray();
var targetTerrain = terrain;
var targetStorage = storage;
SceneEditorSession.Active.AddUndo("Paint Terrain Materials",
undo: () =>
{
if (!targetTerrain.IsValid()) return;
// Re-copy to avoid reference issues
targetStorage.ControlMap = oldControlMap.ToArray();
targetStorage.StateHasChanged();
targetTerrain.SyncGPUTexture();
},
redo: () =>
{
if (!targetTerrain.IsValid()) return;
// Re-copy to avoid reference issues
targetStorage.ControlMap = newControlMap.ToArray();
targetStorage.StateHasChanged();
targetTerrain.SyncGPUTexture();
});
}
}