🔍 s&box Package Code Search
Search C# source code, UI razor templates, shaders, and configs across s&box packages.
🔗 Raw Facepunch API Link: https://public.facepunch.com/sbox/code/search/1/?ident=subzerostudios.subterrain-tool&take=20
Showing code results for query:
*
(33 total matches found)
Editor
library
// Copyright (c) 2026 SubZero Studios LLC. All rights reserved.
namespace Editor.SubTerrain;
/// <summary>
/// Sculpt modes for the SubTerrain compute shader.
/// Values 0-4 match the engine shader; 5-8 are SubTerrain extensions.
/// </summary>
public enum SubTerrainSculptMode
{
RaiseLower = 0,
Flatten = 1,
Smooth = 2,
Hole = 3,
Noise = 4,
Slope = 5,
Path = 6,
Plainer = 7,
Bridge = 8
}
Game
library
global using static Sandbox.Internal.GlobalGameNamespace;
global using Microsoft.AspNetCore.Components;
global using Microsoft.AspNetCore.Components.Rendering;
[assembly: global::System.Reflection.AssemblyMetadata( "AddonTitle", "SubTerrain Tool" )]
[assembly: global::System.Reflection.AssemblyMetadata( "AddonIdent", "subterrain-tool" )]
[assembly: global::System.Reflection.AssemblyMetadata( "OrgIdent", "subzerostudios" )]
[assembly: global::System.Reflection.AssemblyMetadata( "Ident", "subzerostudios.subterrain-tool" )]
[assembly: global::System.Reflection.AssemblyMetadata( "EngineVersion", "28" )]
[assembly: global::System.Reflection.AssemblyMetadata( "EngineMinorVersion", "1" )]
[assembly: System.Runtime.Versioning.TargetFramework( ".NETCoreApp,Version=v9.0", FrameworkDisplayName = ".NET 9.0" )]
[assembly: global::System.Reflection.AssemblyMetadata( "CompileTime", "2026-08-24T22:24:58.1339886Z" )]
[assembly: global::System.Reflection.AssemblyVersion("0.0.165.0")]
[assembly: global::System.Reflection.AssemblyFileVersion("0.0.165.0")]
Game
library
// Copyright (c) 2026 SubZero Studios LLC. All rights reserved.
using Sandbox;
namespace SubZero.SubTerrain;
public struct TerrainEditRequest
{
public TerrainEditKind Kind;
public Vector3 WorldPos;
public float Radius;
/// <summary>0 = kind auto-scale (radius × DepthScale, clamped).</summary>
public float Depth;
/// <summary>Unused this pass. Reserved for a later trench (capsule SDF).</summary>
public Vector3 EndPos;
public bool HasTargetZ;
public float TargetZ;
public int Seed;
}
public struct TerrainEditResult
{
public TerrainEditKind Kind;
public Vector3 Origin;
public float Radius;
public float Depth;
public RectInt Dirty;
public int Texels;
public float VolumeRemoved;
}
Editor
library
// Copyright (c) 2026 SubZero Studios LLC. All rights reserved.
using System;
using Sandbox;
namespace Editor.SubTerrain;
/// <summary>
/// Paint Texture sidebar: tmat list, Ground / Wear / Path, Steep / All, live mix.
/// </summary>
internal class SubTerrainBlendWidget : Widget
{
readonly SubTerrainTool _tool;
readonly SubTerrainPaintModeStrip _modes;
readonly SubTerrainPairPreview _pairPreview;
readonly Widget _listHost;
readonly Widget _optionsHost;
readonly Label _live;
Widget _limitRow;
Checkbox _keepPairBox;
Checkbox _sampledBox;
Checkbox _packCenterBox;
int _listedCount = -1;
SubTerrainPaintLayer _sheetLayer = (SubTerrainPaintLayer)(-1);
bool _sheetSteep;
const string IntentSuffix = " Alt sample | Ctrl fade | Shift smooth | ring yellow/green = Alt/Shift.";
public SubTerrainBlendWidget( SubTerrainTool tool, Widget parent ) : base( parent )
{
_tool = tool;
Layout = Layout.Column();
Layout.Spacing = 4;
_modes = Layout.Add( new SubTerrainPaintModeStrip( tool, this ) );
_pairPreview = Layout.Add( new SubTerrainPairPreview( tool, this ) );
SyncPairPreview();
_listHost = Layout.Add( new Widget( this ) );
_listHost.Layout = Layout.Column();
_listHost.Layout.Spacing = 0;
_optionsHost = Layout.Add( new Widget( this ) );
_optionsHost.Layout = Layout.Column();
_optionsHost.Layout.Spacing = 4;
_live = Layout.Add( new Label( "" ) );
RebuildList();
RebuildOptions();
RefreshCopy();
}
void OnGroundClicked() => SetLayer( SubTerrainPaintLayer.Base );
void OnWearClicked() => SetLayer( SubTerrainPaintLayer.Overlay );
void OnPathClicked()
{
var terrain = _tool.PeekTerrain();
int ground = SubTerrainSplatUtil.FindPathGround( terrain, _tool.PaintSettings );
int wear = _tool.PaintSettings.MaterialIndex;
if ( wear == ground )
{
wear = SubTerrainSplatUtil.FindPathWear( terrain, ground );
_tool.PaintSettings.MaterialIndex = wear;
SubTerrainPaintTool.TrySetSplatChannel( wear );
}
SetLayer( SubTerrainPaintLayer.Path );
}
void SetLayer( SubTerrainPaintLayer layer )
{
SubTerrainPaintStore.Capture( _tool.PaintSettings );
SubTerrainPaintStore.Apply( _tool.PaintSettings, layer );
SubTerrainPaintTool.ActiveLayer = layer;
RebuildOptions();
_tool.RefreshPaintChrome();
RefreshCopy();
_modes.Update();
SyncPairPreview();
}
void SyncPairPreview()
{
if ( _pairPreview is null )
return;
// Ground writes one tmat - a pair thumb is dead chrome.
bool show = _tool.PaintSettings.Layer != SubTerrainPaintLayer.Base;
_pairPreview.Visible = show;
_pairPreview.FixedHeight = show ? 36 : 0;
_pairPreview.MinimumHeight = show ? 36 : 0;
_pairPreview.MaximumHeight = show ? 36 : 0;
if ( show )
_pairPreview.Update();
}
internal void SelectGround() => OnGroundClicked();
internal void SelectWear() => OnWearClicked();
internal void SelectPath() => OnPathClicked();
internal void OnMaterialSelectionChanged()
{
RefreshCopy();
SyncPairPreview();
_listHost?.Update();
if ( _listHost?.Layout is null )
return;
foreach ( var child in _listHost.Children )
child?.Update();
}
internal void SelectSteep()
{
_tool.PaintSettings.OnlyOnSlope = true;
RebuildOptions();
RefreshCopy();
}
internal void SelectAll()
{
_tool.PaintSettings.OnlyOnSlope = false;
RebuildOptions();
RefreshCopy();
}
void OnKeepPairToggled()
{
if ( _keepPairBox is null )
return;
_tool.PaintSettings.MixOnly = _keepPairBox.Value;
RefreshCopy();
}
void OnSampledToggled()
{
if ( _sampledBox is null )
return;
_tool.PaintSettings.LimitToSampledGround = _sampledBox.Value;
RefreshCopy();
}
void OnPackCenterToggled()
{
if ( _packCenterBox is null )
return;
_tool.PaintSettings.PackCenter = _packCenterBox.Value;
RebuildOptions();
RefreshCopy();
SyncPairPreview();
}
void RebuildList()
{
if ( _listHost is null )
return;
_listHost.Layout ??= Layout.Column();
_listHost.Layout.Clear( true );
var terrain = _tool.PeekTerrain();
var materials = terrain?.Storage?.Materials;
int count = materials?.Count ?? 0;
_listedCount = count;
if ( count == 0 )
{
_listHost.Layout.Add( new Label( "This terrain has no materials to paint." ) );
return;
}
for ( int i = 0; i < count; i++ )
{
var name = SubTerrainSplatUtil.MaterialName( terrain, i );
_listHost.Layout.Add( new SubTerrainMaterialRow( _tool, i, name, _listHost ) );
}
}
void RebuildOptions()
{
if ( _optionsHost is null )
return;
_optionsHost.Layout ??= Layout.Column();
_optionsHost.Layout.Clear( true );
_limitRow = null;
_keepPairBox = null;
_sampledBox = null;
_packCenterBox = null;
_sheetLayer = _tool.PaintSettings.Layer;
_sheetSteep = _tool.PaintSettings.OnlyOnSlope;
var so = _tool.PaintSettings.GetSerialized();
if ( so is null )
return;
if ( _sheetLayer == SubTerrainPaintLayer.Overlay )
_optionsHost.Layout.Add( new SubTerrainWhereStrip( _tool, this ) );
var sheet = new ControlSheet { IncludePropertyNames = false };
if ( _sheetLayer == SubTerrainPaintLayer.Overlay )
AddSettingRow( sheet, so, nameof( SubTerrainPaintSettings.HowWorn ) );
else if ( _sheetLayer == SubTerrainPaintLayer.Path )
AddSettingRow( sheet, so, nameof( SubTerrainPaintSettings.CenterMix ) );
if ( _sheetLayer == SubTerrainPaintLayer.Overlay && _sheetSteep )
AddSettingRow( sheet, so, nameof( SubTerrainPaintSettings.SlopeDegrees ) );
_optionsHost.Layout.Add( sheet );
if ( _sheetLayer == SubTerrainPaintLayer.Overlay )
{
_keepPairBox = new Checkbox( "Keep pair" );
_keepPairBox.Value = _tool.PaintSettings.MixOnly;
_keepPairBox.ToolTip = "Only move mix. Do not change the base/overlay pair (avoids texel stairs next to a different pair).";
_keepPairBox.Toggled += OnKeepPairToggled;
_optionsHost.Layout.Add( _keepPairBox );
_sampledBox = new Checkbox( "Stay on sampled" );
_sampledBox.Value = _tool.PaintSettings.LimitToSampledGround;
_sampledBox.ToolTip = "Alt-click locks to that ground base. Wear will not paint other bases.";
_sampledBox.Toggled += OnSampledToggled;
_optionsHost.Layout.Add( _sampledBox );
_limitRow = _sampledBox;
}
else if ( _sheetLayer == SubTerrainPaintLayer.Path )
{
_packCenterBox = new Checkbox( "Pack center" );
_packCenterBox.Value = _tool.PaintSettings.PackCenter;
_packCenterBox.ToolTip = "On: solid path core along the stroke, soft apron outside. Off: soft ground + path apron only.";
_packCenterBox.Toggled += OnPackCenterToggled;
_optionsHost.Layout.Add( _packCenterBox );
if ( _tool.PaintSettings.PackCenter )
{
var coreSheet = new ControlSheet { IncludePropertyNames = false };
AddSettingRow( coreSheet, so, nameof( SubTerrainPaintSettings.PackCore ) );
_optionsHost.Layout.Add( coreSheet );
}
}
}
static void AddSettingRow( ControlSheet sheet, SerializedObject so, string name )
{
if ( so.TryGetProperty( name, out var prop ) && prop is not null )
sheet.AddRow( prop );
}
void RefreshCopy()
{
if ( _live is null )
return;
var line = WriteLine();
if ( _tool.PaintSettings.Layer == SubTerrainPaintLayer.Overlay && _tool.PaintSettings.Dithers )
line += " | dither";
_live.Text = line;
_live.ToolTip = IntentTip();
_live.Color = Theme.TextControl;
}
string WriteLine()
{
var settings = _tool.PaintSettings;
var layer = settings.Layer;
var terrain = _tool.PeekTerrain();
string mix = settings.MixCap.ToString( "0.00" );
if ( layer == SubTerrainPaintLayer.Path )
{
int ground = SubTerrainSplatUtil.FindPathGround( terrain, settings );
int wear = settings.MaterialIndex;
if ( ground == wear )
return "Click the packed tmat. Alt-click the ground.";
var gName = SubTerrainSplatUtil.MaterialName( terrain, ground );
var wName = SubTerrainSplatUtil.MaterialName( terrain, wear );
return settings.PackCenter
? $"{wName} center | {gName} | {wName} {mix} | core {settings.PackCore:0.00}"
: $"{gName} | {wName} {mix} | soft";
}
if ( layer == SubTerrainPaintLayer.Base )
return SubTerrainSplatUtil.MaterialName( terrain, settings.MaterialIndex );
var name = SubTerrainSplatUtil.MaterialName( terrain, settings.MaterialIndex );
string lockOn = "";
if ( settings.LimitToSampledGround && settings.HasSampledGround )
lockOn = $" on {SubTerrainSplatUtil.MaterialName( terrain, settings.SampledGroundIndex )}";
if ( settings.MixOnly )
return $"mix {name} {mix}{lockOn}";
return settings.OnlyOnSlope
? $"{name} {mix} steep{lockOn}"
: $"{name} {mix}{lockOn}";
}
string IntentTip()
{
var settings = _tool.PaintSettings;
string body = settings.Layer switch
{
SubTerrainPaintLayer.Path => settings.PackCenter
? "Pack center on. Raise Core for a wider solid middle. Center mix is the soft apron. Brush Size widens the whole blend."
: "Pack center off. Soft apron only: ground + path. Center mix is the apron mix.",
SubTerrainPaintLayer.Base => "Ground writes the selected tmat as base and overlay, mix 0.",
_ => "Wear writes overlay mix. Steep skips flats. Ctrl reduces. Alt-click locks Stay on sampled."
};
return body + IntentSuffix;
}
[EditorEvent.Frame]
public void Refresh()
{
if ( _live is null || _modes is null || _listHost is null || _optionsHost is null )
return;
_tool.RefreshPaintChrome();
if ( !_tool.PaintArmed )
return;
var terrain = _tool.PeekTerrain();
int count = terrain?.Storage?.Materials?.Count ?? 0;
if ( count != _listedCount )
RebuildList();
var settings = _tool.PaintSettings;
if ( _sheetLayer != settings.Layer || _sheetSteep != settings.OnlyOnSlope )
RebuildOptions();
if ( _keepPairBox is not null )
_keepPairBox.Value = settings.MixOnly;
if ( _sampledBox is not null )
{
_sampledBox.Value = settings.LimitToSampledGround;
_sampledBox.Enabled = settings.HasSampledGround;
if ( !settings.HasSampledGround )
_sampledBox.ToolTip = "Alt-click the ground to lock Stay on sampled.";
else
_sampledBox.ToolTip = "Locked to the Alt-clicked ground base. Uncheck to paint any base.";
}
if ( _packCenterBox is not null )
_packCenterBox.Value = settings.PackCenter;
if ( _limitRow is not null )
_limitRow.Enabled = settings.HasSampledGround;
RefreshCopy();
_modes.Update();
SyncPairPreview();
if ( !_tool.StatusValid || terrain is null || !terrain.IsValid() )
return;
if ( !SubTerrainSplatUtil.TryTexel( terrain, _tool.StatusHitLocal, out var x, out var y ) )
return;
var mat = SubTerrainSplatUtil.Read( terrain, x, y );
if ( mat.IsHole )
{
_live.Text = WriteLine() + " | hole";
return;
}
var baseName = SubTerrainSplatUtil.MaterialName( terrain, mat.BaseTextureId );
var overlayName = SubTerrainSplatUtil.MaterialName( terrain, mat.OverlayTextureId );
float mix = mat.BlendFactor / 255f;
string there = baseName == overlayName || mix < 0.02f
? baseName
: $"{baseName} + {overlayName} {mix:0.00}";
string extra = "";
if ( SubTerrainSplatUtil.TrySlopeDegrees( terrain, x, y, out var degrees ) )
{
int deg = (int)MathF.Round( degrees );
bool steepGate = settings.Layer == SubTerrainPaintLayer.Overlay && settings.OnlyOnSlope;
if ( steepGate )
extra = degrees >= settings.SlopeDegrees ? $" | steep {deg}" : $" | skip {deg}";
}
if ( SubTerrainSplatUtil.HasSeam( terrain, x, y ) )
extra += " | seam";
_live.Text = $"{WriteLine()} | {there}{extra}";
}
}
internal class SubTerrainPairPreview : Widget
{
readonly SubTerrainTool _tool;
const float ThumbSize = 24f;
public SubTerrainPairPreview( SubTerrainTool tool, Widget parent ) : base( parent )
{
_tool = tool;
FixedHeight = 36;
MinimumHeight = 36;
MaximumHeight = 36;
ToolTip = "Pair this stroke will write.";
}
protected override void OnPaint()
{
base.OnPaint();
if ( !Visible || _tool.PaintSettings.Layer == SubTerrainPaintLayer.Base )
return;
Paint.Antialiasing = true;
Paint.ClearPen();
Paint.SetBrush( Color.White.WithAlpha( 0.04f ) );
Paint.DrawRect( LocalRect );
ResolvePair( out int left, out int right );
float y = (Height - ThumbSize) * 0.5f;
float gap = 8f;
float total = ThumbSize * 2f + gap;
float x0 = (Width - total) * 0.5f;
DrawThumb( new Rect( x0, y, ThumbSize, ThumbSize ), left );
DrawThumb( new Rect( x0 + ThumbSize + gap, y, ThumbSize, ThumbSize ), right );
}
void ResolvePair( out int left, out int right )
{
var settings = _tool.PaintSettings;
var terrain = _tool.PeekTerrain();
int selected = settings.MaterialIndex;
if ( settings.Layer == SubTerrainPaintLayer.Path )
{
left = SubTerrainSplatUtil.FindPathGround( terrain, settings );
right = selected;
return;
}
if ( settings.Layer == SubTerrainPaintLayer.Overlay )
{
left = settings.HasSampledGround
? settings.SampledGroundIndex
: HoverBaseOr( terrain, selected );
right = selected;
return;
}
left = selected;
right = selected;
}
int HoverBaseOr( Terrain terrain, int fallback )
{
if ( terrain is null || !_tool.StatusValid )
return fallback;
if ( !SubTerrainSplatUtil.TryTexel( terrain, _tool.StatusHitLocal, out var x, out var y ) )
return fallback;
var mat = SubTerrainSplatUtil.Read( terrain, x, y );
if ( mat.IsHole )
return fallback;
return mat.BaseTextureId;
}
void DrawThumb( Rect rect, int index )
{
Paint.ClearPen();
Paint.SetBrush( Theme.SurfaceBackground.WithAlpha( 0.35f ) );
Paint.DrawRect( rect, 3 );
var path = MaterialPath( index );
var asset = string.IsNullOrWhiteSpace( path ) ? null : AssetSystem.FindByPath( path );
if ( asset is null || asset.IsDeleted )
return;
var thumb = asset.GetAssetThumb( true );
if ( thumb is not null )
Paint.Draw( rect, thumb );
}
string MaterialPath( int index )
{
var mats = _tool.PeekTerrain()?.Storage?.Materials;
if ( mats is null || index < 0 || index >= mats.Count )
return null;
return mats[index]?.ResourcePath;
}
[EditorEvent.Frame]
public void TickPreview()
{
if ( !Visible || _tool.PaintSettings.Layer == SubTerrainPaintLayer.Base )
return;
Update();
}
}
internal class SubTerrainPaintModeStrip : Widget
{
readonly SubTerrainTool _tool;
readonly SubTerrainBlendWidget _host;
const int SlotCount = 3;
int _hover = -1;
public SubTerrainPaintModeStrip( SubTerrainTool tool, SubTerrainBlendWidget host ) : base( host )
{
_tool = tool;
_host = host;
FixedHeight = 28;
MinimumHeight = 28;
MaximumHeight = 28;
Cursor = CursorShape.Finger;
MouseTracking = true;
ToolTip = "Ground is a solid tmat. Wear is overlay mix. Path packs a path tmat (optional Pack center) with a ground+path apron.";
}
protected override void OnMouseClick( MouseEvent e )
{
int slot = SlotAt( e.LocalPosition.x );
if ( slot == 0 )
_host.SelectGround();
else if ( slot == 1 )
_host.SelectWear();
else
_host.SelectPath();
Update();
}
protected override void OnMouseMove( MouseEvent e )
{
base.OnMouseMove( e );
int next = SlotAt( e.LocalPosition.x );
if ( next == _hover )
return;
_hover = next;
Update();
}
protected override void OnMouseLeave()
{
_hover = -1;
Update();
base.OnMouseLeave();
}
int SlotAt( float x )
{
float w = Math.Max( Width, 1f );
int slot = (int)(x / w * SlotCount);
return Math.Clamp( slot, 0, SlotCount - 1 );
}
protected override void OnPaint()
{
base.OnPaint();
Paint.Antialiasing = true;
Paint.ClearPen();
Paint.SetBrush( Color.White.WithAlpha( 0.04f ) );
Paint.DrawRect( LocalRect );
var layer = _tool.PaintSettings.Layer;
float slotW = Width / SlotCount;
for ( int i = 0; i < SlotCount; i++ )
{
var rect = new Rect( i * slotW, 0, slotW, Height );
bool selected = i switch
{
0 => layer == SubTerrainPaintLayer.Base,
1 => layer == SubTerrainPaintLayer.Overlay,
_ => layer == SubTerrainPaintLayer.Path
};
Paint.ClearPen();
if ( selected )
Paint.SetBrush( Theme.Primary.WithAlpha( 0.20f ) );
else if ( _hover == i )
Paint.SetBrush( Color.White.WithAlpha( 0.06f ) );
else
Paint.SetBrush( Color.Transparent );
Paint.DrawRect( rect );
if ( i > 0 )
{
Paint.ClearPen();
Paint.SetBrush( Color.White.WithAlpha( 0.10f ) );
Paint.DrawRect( new Rect( rect.Left, rect.Top + 5, 1, rect.Height - 10 ) );
}
string label = i switch
{
0 => "Ground",
1 => "Wear",
_ => "Path"
};
Paint.SetPen( selected ? Theme.Primary : Theme.TextControl );
Paint.DrawText( rect, label, TextFlag.Center );
}
}
}
internal class SubTerrainWhereStrip : Widget
{
readonly SubTerrainTool _tool;
readonly SubTerrainBlendWidget _host;
const int SlotCount = 2;
int _hover = -1;
public SubTerrainWhereStrip( SubTerrainTool tool, SubTerrainBlendWidget host ) : base( host )
{
_tool = tool;
_host = host;
FixedHeight = 28;
MinimumHeight = 28;
MaximumHeight = 28;
Cursor = CursorShape.Finger;
MouseTracking = true;
ToolTip = "Steep skips ground that is too flat. All paints the whole brush, including shelves.";
}
protected override void OnMouseClick( MouseEvent e )
{
if ( SlotAt( e.LocalPosition.x ) == 0 )
_host.SelectSteep();
else
_host.SelectAll();
Update();
}
protected override void OnMouseMove( MouseEvent e )
{
base.OnMouseMove( e );
int next = SlotAt( e.LocalPosition.x );
if ( next == _hover )
return;
_hover = next;
ToolTip = next == 0
? "Skip ground that is too flat. Hover a hill; the footer says steep or skip."
: "Paint the whole brush, including shelves.";
Update();
}
protected override void OnMouseLeave()
{
_hover = -1;
ToolTip = "Steep skips ground that is too flat. All paints the whole brush, including shelves.";
Update();
base.OnMouseLeave();
}
int SlotAt( float x )
{
float w = Math.Max( Width, 1f );
int slot = (int)(x / w * SlotCount);
return Math.Clamp( slot, 0, SlotCount - 1 );
}
protected override void OnPaint()
{
base.OnPaint();
Paint.Antialiasing = true;
Paint.ClearPen();
Paint.SetBrush( Color.White.WithAlpha( 0.04f ) );
Paint.DrawRect( LocalRect );
bool steep = _tool.PaintSettings.OnlyOnSlope;
float slotW = Width / SlotCount;
for ( int i = 0; i < SlotCount; i++ )
{
var rect = new Rect( i * slotW, 0, slotW, Height );
bool selected = i == 0 ? steep : !steep;
Paint.ClearPen();
if ( selected )
Paint.SetBrush( Theme.Primary.WithAlpha( 0.20f ) );
else if ( _hover == i )
Paint.SetBrush( Color.White.WithAlpha( 0.06f ) );
else
Paint.SetBrush( Color.Transparent );
Paint.DrawRect( rect );
if ( i > 0 )
{
Paint.ClearPen();
Paint.SetBrush( Color.White.WithAlpha( 0.10f ) );
Paint.DrawRect( new Rect( rect.Left, rect.Top + 5, 1, rect.Height - 10 ) );
}
Paint.SetPen( selected ? Theme.Primary : Theme.TextControl );
Paint.DrawText( rect, i == 0 ? "Steep" : "All", TextFlag.Center );
}
}
[EditorEvent.Frame]
public void TickSelect()
{
Update();
}
}
internal class SubTerrainMaterialRow : Widget
{
readonly SubTerrainTool _tool;
readonly int _index;
readonly string _name;
readonly string _resourcePath;
const float RowHeight = 42f;
const float ThumbSize = 28f;
public SubTerrainMaterialRow( SubTerrainTool tool, int index, string name, Widget parent ) : base( parent )
{
_tool = tool;
_index = index;
_name = name;
_resourcePath = tool.PeekTerrain()?.Storage?.Materials is { Count: > 0 } mats && index >= 0 && index < mats.Count
? mats[index]?.ResourcePath
: null;
FixedHeight = RowHeight;
MinimumHeight = RowHeight;
MaximumHeight = RowHeight;
Cursor = CursorShape.Finger;
ToolTip = name;
MouseTracking = true;
}
protected override void OnMouseClick( MouseEvent e )
{
var settings = _tool.PaintSettings;
bool path = settings.Layer == SubTerrainPaintLayer.Path;
bool alt = Application.KeyboardModifiers.HasFlag( KeyboardModifiers.Alt );
if ( path && alt )
{
if ( _index == settings.MaterialIndex )
return;
settings.PathGroundIndex = _index;
Update();
return;
}
if ( path && _index == SubTerrainSplatUtil.FindPathGround( _tool.PeekTerrain(), settings ) )
{
settings.PathGroundIndex = _index;
Update();
return;
}
settings.MaterialIndex = _index;
SubTerrainPaintTool.TrySetSplatChannel( _index );
Update();
}
protected override void OnDoubleClick( MouseEvent e )
{
if ( string.IsNullOrWhiteSpace( _resourcePath ) )
return;
var asset = AssetSystem.FindByPath( _resourcePath );
if ( asset is null || asset.IsDeleted )
return;
asset.OpenInEditor();
}
protected override void OnPaint()
{
base.OnPaint();
var settings = _tool.PaintSettings;
bool selected = settings.MaterialIndex == _index;
bool path = settings.Layer == SubTerrainPaintLayer.Path;
bool pathGround = path && SubTerrainSplatUtil.FindPathGround( _tool.PeekTerrain(), settings ) == _index;
Paint.Antialiasing = true;
Paint.ClearPen();
if ( selected )
Paint.SetBrush( Theme.Primary.WithAlpha( 0.18f ) );
else if ( IsUnderMouse )
Paint.SetBrush( Color.White.WithAlpha( 0.06f ) );
else
Paint.SetBrush( Color.Transparent );
Paint.DrawRect( LocalRect );
var thumbRect = new Rect( 8, (RowHeight - ThumbSize) * 0.5f, ThumbSize, ThumbSize );
Paint.ClearPen();
Paint.SetBrush( Theme.SurfaceBackground.WithAlpha( 0.35f ) );
Paint.DrawRect( thumbRect, 3 );
var asset = string.IsNullOrWhiteSpace( _resourcePath ) ? null : AssetSystem.FindByPath( _resourcePath );
if ( asset is not null && !asset.IsDeleted )
{
var thumb = asset.GetAssetThumb( true );
if ( thumb is not null )
Paint.Draw( thumbRect, thumb );
}
float textLeft = thumbRect.Right + 8f;
Paint.SetPen( selected ? Color.White : Theme.TextControl );
Paint.DrawText( new Rect( textLeft, 0, Math.Max( 40f, Width - textLeft - 56f ), Height ), _name, TextFlag.LeftCenter );
string role = null;
if ( pathGround )
role = "ground";
else if ( path && selected )
role = "path";
else if ( !path && selected && settings.Layer == SubTerrainPaintLayer.Overlay )
role = settings.MixCap.ToString( "0.00" );
if ( role is not null )
{
Paint.SetPen( Color.White.WithAlpha( selected ? 0.70f : 0.45f ) );
Paint.DrawText( new Rect( Width - 56, 0, 44, Height ), role, TextFlag.RightCenter );
}
}
[EditorEvent.Frame]
public void TickSelect()
{
bool path = _tool.PaintSettings.Layer == SubTerrainPaintLayer.Path;
ToolTip = path
? "Click the packed tmat. Alt-click the ground under it. Double-click opens the asset."
: _name + " | Double-click opens the asset.";
Update();
}
}
Game
library
// Copyright (c) 2026 SubZero Studios LLC. All rights reserved.
using System;
using Sandbox;
namespace SubZero.SubTerrain;
/// <summary>
/// Optional host-authoritative play-sculpt net. Attach to a NetworkMode.Object
/// GameObject. Host writes height; peers apply the same op locally.
/// </summary>
[Title( "Terrain Sculpt Net" )]
[Category( "SubTerrain" )]
[Icon( "share" )]
public sealed class TerrainSculptNet : Component
{
public bool Submit( TerrainEditRequest request )
{
if ( request.Seed == 0 )
request.Seed = Game.Random.Int( 1, 1_000_000_000 );
if ( !Networking.IsActive || Networking.IsHost )
{
ApplyLocal( request );
if ( Networking.IsActive )
BroadcastStamp( (int)request.Kind, request.WorldPos, request.Radius, request.Depth, request.Seed, request.HasTargetZ, request.TargetZ );
return true;
}
RequestHost( (int)request.Kind, request.WorldPos, request.Radius, request.Depth, request.Seed, request.HasTargetZ, request.TargetZ );
return true;
}
[Rpc.Host]
public void RequestHost( int kind, Vector3 pos, float radius, float depth, int seed, bool hasTargetZ, float targetZ )
{
var request = Make( kind, pos, radius, depth, seed, hasTargetZ, targetZ );
ApplyLocal( request );
BroadcastStamp( kind, pos, radius, depth, seed, hasTargetZ, targetZ );
}
[Rpc.Broadcast]
public void BroadcastStamp( int kind, Vector3 pos, float radius, float depth, int seed, bool hasTargetZ, float targetZ )
{
if ( Networking.IsHost )
return;
ApplyLocal( Make( kind, pos, radius, depth, seed, hasTargetZ, targetZ ) );
}
static TerrainEditRequest Make( int kind, Vector3 pos, float radius, float depth, int seed, bool hasTargetZ, float targetZ )
{
return new TerrainEditRequest
{
Kind = (TerrainEditKind)kind,
WorldPos = pos,
Radius = radius,
Depth = depth,
Seed = seed,
HasTargetZ = hasTargetZ,
TargetZ = targetZ
};
}
void ApplyLocal( TerrainEditRequest request )
{
TerrainSculpt.TryApply( Scene, request );
}
}
Game
library
// Copyright (c) 2026 SubZero Studios LLC. All rights reserved.
using Sandbox;
namespace SubZero.SubTerrain;
public sealed class CraterBrushSettings
{
[Property, Range( 16f, 200f )]
public float MaxDepth { get; set; } = 96f;
[Property, Title( "Depth Scale" ), Range( 0.1f, 1.5f )]
public float DepthScale { get; set; } = 0.5f;
[Property, Range( 0f, 32f )]
public float MaxRaise { get; set; } = 12f;
[Property, Range( 0.4f, 0.95f )]
public float BowlRadius { get; set; } = 0.75f;
[Property, Range( 0f, 0.7f )]
public float Irregularity { get; set; } = 0.35f;
[Property, Range( 0f, 0.5f )]
public float Stretch { get; set; } = 0.25f;
[Property, Range( 0f, 1f )]
public float Blend { get; set; } = 0.65f;
}
public sealed class DigBrushSettings
{
[Property, Range( 16f, 120f )]
public float MaxDepth { get; set; } = 48f;
[Property, Title( "Depth Scale" ), Range( 0.1f, 1.5f )]
public float DepthScale { get; set; } = 0.85f;
[Property, Range( 0f, 40f )]
public float MaxRaise { get; set; } = 18f;
[Property, Range( 0.7f, 0.98f )]
public float BowlRadius { get; set; } = 0.9f;
[Property, Range( 0f, 0.4f )]
public float Irregularity { get; set; } = 0.12f;
[Property, Range( 0f, 0.35f )]
public float Stretch { get; set; } = 0.08f;
[Property, Range( 0f, 1f )]
public float Blend { get; set; } = 0.35f;
}
public sealed class FlattenBrushSettings
{
[Property, Range( 0.05f, 1f )]
public float Strength { get; set; } = 0.65f;
[Property, Range( 4f, 64f )]
public float MaxDelta { get; set; } = 24f;
}
Editor
library
// Copyright (c) 2026 SubZero Studios LLC. All rights reserved.
using System;
using Sandbox;
using Editor.TerrainEditor;
namespace Editor.SubTerrain;
/// <summary>
/// Bridge tool. Press on the first point and release on the second to raise a land bridge
/// spanning the gap. The deck grades from the start ground height to the end ground height,
/// and the whole span is written once on release rather than painted stroke by stroke.
/// </summary>
[Title( "Bridge" )]
[Description( "Press start, release on end. Raises a land bridge between the two heights." )]
[Icon( "linear_scale" )]
[Alias( "subterrain_bridge" )]
[Group( "1" )]
[Order( 10 )]
public class SubBridgeTool : SubTerrainBaseBrushTool
{
Vector3 _startLocal;
Vector3 _endLocal;
Vector2 _endUV;
float _halfWidthTexels;
public SubBridgeTool( SubTerrainTool parent ) : base( parent )
{
Mode = SubTerrainSculptMode.Bridge;
}
protected override void OnStrokeStart( Terrain terrain, Vector3 hitPosition )
{
_startLocal = hitPosition;
_endLocal = hitPosition;
SlopeStartUV = new Vector2( hitPosition.x, hitPosition.y ) / terrain.Storage.TerrainSize;
SlopeStartHeight01 = hitPosition.z / terrain.Storage.TerrainHeight;
}
protected override void OnPaint( Terrain terrain, SubTerrainPaintParameters paint )
{
_endLocal = paint.HitPosition;
}
protected override void OnPaintEnded( Terrain terrain )
{
BuildBridge( terrain );
base.OnPaintEnded( terrain );
}
void BuildBridge( Terrain terrain )
{
var storage = terrain.Storage;
var settings = _parent.BrushSettings;
var startUV = new Vector2( _startLocal.x, _startLocal.y ) / storage.TerrainSize;
_endUV = new Vector2( _endLocal.x, _endLocal.y ) / storage.TerrainSize;
_halfWidthTexels = Math.Max( settings.BridgeWidth * 0.5f / storage.TerrainSize * storage.Resolution, 1.0f );
var spanTexels = (_endUV - startUV) * storage.Resolution;
if ( spanTexels.Length < 1.0f )
return;
int size = (int)Math.Ceiling( Math.Max( Math.Abs( spanTexels.x ), Math.Abs( spanTexels.y ) ) + _halfWidthTexels * 2.0f ) + 2;
size = Math.Clamp( size, 1, storage.Resolution );
SlopeStartUV = startUV;
SlopeStartHeight01 = _startLocal.z / storage.TerrainHeight;
var paint = new SubTerrainPaintParameters
{
HitPosition = _endLocal,
HitUV = (startUV + _endUV) * 0.5f,
FlattenHeight = _endLocal.z / storage.TerrainHeight,
BrushSettings = settings
};
var brushTexture = _parent.SelectedBrushTexture;
if ( brushTexture is null )
return;
DispatchSculpt( terrain, paint, brushTexture, size, 1.0f );
}
protected override void ApplyExtraAttributes( ComputeShader cs, SubTerrainPaintParameters paint )
{
var settings = paint.BrushSettings;
cs.Attributes.Set( "SlopeEndUV", _endUV );
cs.Attributes.Set( "SlopeEndHeight", paint.FlattenHeight );
cs.Attributes.Set( "BridgeHalfWidth", _halfWidthTexels );
cs.Attributes.Set( "BridgeDeckRatio", 1.0f - settings.BridgeEdgeFalloff );
cs.Attributes.Set( "BridgeRaiseOnly", settings.BridgeRaiseOnly ? 1 : 0 );
}
protected override void DrawToolOverlay( Terrain terrain, Vector3 worldCenter )
{
if ( !_dragging )
return;
var startWorld = terrain.WorldTransform.PointToWorld( _startLocal );
var span = worldCenter - startWorld;
var flat = new Vector3( span.x, span.y, 0.0f );
Gizmo.Draw.Color = Color.FromBytes( 250, 180, 120 );
Gizmo.Draw.Line( startWorld, worldCenter );
if ( flat.Length < 0.001f )
return;
var halfWidth = _parent.BrushSettings.BridgeWidth * 0.5f;
var side = new Vector3( -flat.y, flat.x, 0.0f ) / flat.Length * halfWidth;
Gizmo.Draw.Line( startWorld + side, worldCenter + side );
Gizmo.Draw.Line( startWorld - side, worldCenter - side );
Gizmo.Draw.Line( startWorld - side, startWorld + side );
Gizmo.Draw.Line( worldCenter - side, worldCenter + side );
}
}
Editor
library
// Copyright (c) 2026 SubZero Studios LLC. All rights reserved.
using Sandbox;
namespace Editor.SubTerrain;
/// <summary>
/// Flatten to Target Height. Alt-click the ground to load that Z.
/// </summary>
[Title( "Set Height" )]
[Icon( "straighten" )]
[Alias( "subterrain_set_height" )]
[Group( "1" )]
[Order( 7 )]
public class SubSetHeightTool : SubTerrainBaseBrushTool
{
public SubSetHeightTool( SubTerrainTool parent ) : base( parent )
{
Mode = SubTerrainSculptMode.Flatten;
AllowHeightSample = true;
}
protected override void OnPaint( Terrain terrain, SubTerrainPaintParameters paint )
{
paint.FlattenHeight = paint.BrushSettings.TargetHeight / terrain.Storage.TerrainHeight;
base.OnPaint( terrain, paint );
}
}
Game
library
// Copyright (c) 2026 SubZero Studios LLC. All rights reserved.
using System;
using System.Collections.Generic;
using Sandbox;
using Sandbox.Clutter;
namespace SubZero.SubTerrain;
/// <summary>
/// Runtime heightfield editor: queue CPU stamps, then SyncGPUTexture + region UpdateCollision.
/// Play-only. Add this next to a Terrain component. One sculpt per Terrain.
/// </summary>
[Title( "Terrain Sculpt" )]
[Category( "SubTerrain" )]
[Icon( "landslide" )]
public sealed class TerrainSculpt : Component
{
const int MinTexelsAcross = 8;
[Property, Group( "Limits" ), Range( 16f, 512f )]
public float MaxRadius { get; set; } = 240f;
[Property, Group( "Limits" ), Range( 0f, 64f )]
public float FloorMargin { get; set; } = 16f;
[Property, Group( "Limits" ), Range( 64f, 2048f )]
public float GroundDrop { get; set; } = 512f;
[Property, Group( "Crater" )]
public CraterBrushSettings Crater { get; set; } = new();
[Property, Group( "Dig" )]
public DigBrushSettings Dig { get; set; } = new();
[Property, Group( "Flatten" )]
public FlattenBrushSettings Flatten { get; set; } = new();
[Property, Group( "Paint" )]
public bool PaintDirt { get; set; }
[Property, Group( "Paint" ), Range( 0, 31 )]
public int PaintMaterialIndex { get; set; } = 1;
[Property, Group( "Paint" ), Range( 0f, 1f )]
public float PaintStrength { get; set; } = 0.85f;
[Property, Group( "Perf" ), Range( 1, 16 )]
public int StampsPerFrame { get; set; } = 4;
[Property, Group( "Debug" )]
public bool DebugLog { get; set; } = true;
public Action<TerrainEditResult> OnApplied { get; set; }
/// <summary>Fired after a successful play-mode reset (Clear Edits / ResetAll). Debris listens here.</summary>
public Action OnReset { get; set; }
readonly List<TerrainEditRequest> _queue = new();
readonly List<TerrainEditResult> _appliedThisFlush = new();
ushort[] _assetHeight;
uint[] _assetControl;
ushort[] _baseHeight;
uint[] _baseControl;
float _assetZ;
bool _floorRaised;
bool _warnedLowRes;
bool _paintedThisApply;
ushort[] _mergeScratch;
protected override void OnStart()
{
if ( !Game.IsPlaying )
return;
var ok = EnsureSnapshot();
Dbg( ok
? $"snapshot ready res={GetTerrain()?.Storage?.Resolution} size={GetTerrain()?.Storage?.TerrainSize} height={GetTerrain()?.Storage?.TerrainHeight} texels={_baseHeight?.Length}"
: "snapshot FAILED — no Terrain/Storage on this object" );
}
protected override void OnDisabled()
{
RestoreFromSnapshot();
}
protected override void OnDestroy()
{
RestoreFromSnapshot();
}
protected override void OnFixedUpdate()
{
if ( !Game.IsPlaying )
return;
ApplyQueued();
}
/// <summary>Queue an edit. Returns whether it was accepted (flushes next fixed update).</summary>
public static bool TryApply( Scene scene, TerrainEditRequest request )
{
if ( scene == null || !scene.IsValid() )
{
Log.Warning( "[Sculpt] TryApply — scene invalid" );
return false;
}
if ( !Game.IsPlaying )
{
Log.Info( "[Sculpt] TryApply ignored — not playing" );
return false;
}
if ( request.Radius <= 0f )
{
Log.Info( $"[Sculpt] TryApply ignored — radius={request.Radius}" );
return false;
}
TerrainSculpt best = null;
var bestDist = float.MaxValue;
var candidates = 0;
foreach ( var sculpt in scene.GetAllComponents<TerrainSculpt>() )
{
candidates++;
if ( !sculpt.Active )
continue;
var terrain = sculpt.GetTerrain();
if ( terrain == null || !terrain.IsValid() || terrain.Storage == null )
{
sculpt.Dbg( "TryApply skip — no Terrain/Storage" );
continue;
}
if ( !sculpt.TryProjectToTerrain( terrain, request.WorldPos, out var local ) )
{
sculpt.Dbg( $"TryApply miss project pos={request.WorldPos} drop={sculpt.GroundDrop}" );
continue;
}
var hitWorld = terrain.WorldTransform.PointToWorld( local );
var dist = request.WorldPos.DistanceSquared( hitWorld );
if ( dist >= bestDist )
continue;
bestDist = dist;
best = sculpt;
}
if ( best == null )
{
Log.Warning( $"[Sculpt] TryApply — no terrain under {request.WorldPos} (components={candidates})" );
return false;
}
if ( request.Seed == 0 )
request.Seed = Game.Random.Int( 1, 1_000_000_000 );
best.Dbg( $"TryApply hit kind={request.Kind} dist={MathF.Sqrt( bestDist ):0.#} pos={request.WorldPos}" );
best._queue.Add( request );
return true;
}
public static int ResetAll( Scene scene )
{
if ( scene == null || !scene.IsValid() )
{
Log.Warning( "[Sculpt] ResetAll — scene invalid" );
return 0;
}
var cleared = 0;
foreach ( var sculpt in scene.GetAllComponents<TerrainSculpt>() )
{
if ( sculpt.ResetEdits() )
cleared++;
}
Log.Info( $"[Sculpt] ResetAll — cleared {cleared} terrain(s)" );
return cleared;
}
[Button( "Clear Edits" )]
public void ClearEdits()
{
ResetEdits();
}
[Button( "Clear All Scene Edits" )]
public void ClearAllSceneEdits()
{
ResetAll( Scene );
}
public bool ResetEdits()
{
if ( _baseHeight == null )
{
Log.Warning( $"[Sculpt] {GameObject.Name}: Clear Edits — no snapshot (play first, or already restored)" );
return false;
}
var terrain = GetTerrain();
if ( terrain == null || !terrain.IsValid() || terrain.Storage == null )
{
Log.Warning( $"[Sculpt] {GameObject.Name}: Clear Edits — no Terrain/Storage" );
return false;
}
var storage = terrain.Storage;
if ( storage.HeightMap.Length != _baseHeight.Length )
{
Log.Warning( $"[Sculpt] {GameObject.Name}: Clear Edits — size mismatch snapshot={_baseHeight.Length} map={storage.HeightMap.Length}" );
return false;
}
_baseHeight.CopyTo( storage.HeightMap, 0 );
if ( _baseControl != null && storage.ControlMap.Length == _baseControl.Length )
_baseControl.CopyTo( storage.ControlMap, 0 );
PushToEngine( terrain, FullRect( storage.Resolution ), painted: _baseControl != null );
_queue.Clear();
OnReset?.Invoke();
Log.Info( $"[Sculpt] {GameObject.Name}: edits cleared" );
return true;
}
void ApplyQueued()
{
if ( _queue.Count == 0 )
return;
var terrain = GetTerrain();
if ( terrain == null || !terrain.IsValid() || terrain.Storage == null )
{
Dbg( $"ApplyQueued — dropped {_queue.Count} stamps (no Terrain/Storage)" );
_queue.Clear();
return;
}
if ( !EnsureSnapshot() )
{
Dbg( "ApplyQueued — snapshot failed" );
return;
}
var limit = Math.Clamp( StampsPerFrame, 1, 16 );
var take = Math.Min( limit, _queue.Count );
RectInt dirty = default;
var any = false;
_paintedThisApply = false;
_appliedThisFlush.Clear();
for ( var i = 0; i < take; i++ )
{
if ( !ApplyStamp( terrain, _queue[i], out var rect, out var result ) )
continue;
_appliedThisFlush.Add( result );
if ( !any )
{
dirty = rect;
any = true;
}
else
{
dirty.Add( rect );
}
}
_queue.RemoveRange( 0, take );
if ( !any )
{
Dbg( "ApplyQueued — all stamps missed terrain" );
return;
}
PushToEngine( terrain, dirty, _paintedThisApply );
Dbg( $"pushed dirty=({dirty.Left},{dirty.Top},{dirty.Width}x{dirty.Height}) painted={_paintedThisApply} remaining={_queue.Count}" );
var applied = OnApplied;
if ( applied == null )
return;
for ( var i = 0; i < _appliedThisFlush.Count; i++ )
applied( _appliedThisFlush[i] );
}
bool ApplyStamp( Terrain terrain, TerrainEditRequest request, out RectInt dirty, out TerrainEditResult result )
{
dirty = default;
result = default;
var storage = terrain.Storage;
if ( storage == null || _baseHeight == null )
return false;
var radius = Math.Clamp( request.Radius, 1f, MaxRadius );
var depth = ResolveDepth( request.Kind, radius, request.Depth );
if ( request.Kind != TerrainEditKind.Flatten && depth <= 0f )
{
Dbg( $"ApplyStamp — depth 0 kind={request.Kind}" );
return false;
}
if ( !TryProjectToTerrain( terrain, request.WorldPos, out var local ) )
{
Dbg( $"ApplyStamp — project miss world={request.WorldPos}" );
return false;
}
var size = storage.TerrainSize;
var height = storage.TerrainHeight;
var res = storage.Resolution;
if ( size <= 0f || height <= 0f || res <= 0 )
return false;
var uv = new Vector2( local.x, local.y ) / size;
if ( uv.x < 0f || uv.y < 0f || uv.x > 1f || uv.y > 1f )
return false;
var texelSize = size / res;
var across = radius * 2f / texelSize;
if ( across < MinTexelsAcross && !_warnedLowRes )
{
_warnedLowRes = true;
Log.Warning( $"[TerrainSculpt] Radius {radius:0} spans {across:0.0} texels (need >={MinTexelsAcross} to hide a character). texelSize={texelSize:0.0}." );
}
var cx = (int)Math.Floor( res * uv.x );
var cy = (int)Math.Floor( res * uv.y );
var irreg = request.Kind switch
{
TerrainEditKind.Dig => Math.Clamp( Dig.Irregularity, 0f, 0.4f ),
TerrainEditKind.Flatten => 0f,
_ => Math.Clamp( Crater.Irregularity, 0f, 0.7f )
};
var stretchAmt = request.Kind switch
{
TerrainEditKind.Dig => Math.Clamp( Dig.Stretch, 0f, 0.35f ),
TerrainEditKind.Flatten => 0f,
_ => Math.Clamp( Crater.Stretch, 0f, 0.5f )
};
var blend = request.Kind switch
{
TerrainEditKind.Dig => Math.Clamp( Dig.Blend, 0f, 1f ),
TerrainEditKind.Flatten => 0f,
_ => Math.Clamp( Crater.Blend, 0f, 1f )
};
var shapePad = 1f + irreg + stretchAmt + 0.25f + blend * 0.15f;
var radTexels = Math.Max( 1, (int)Math.Ceiling( radius / texelSize * shapePad ) + 3 );
dirty = new RectInt( cx - radTexels, cy - radTexels, radTexels * 2 + 1, radTexels * 2 + 1 );
var x0 = Math.Clamp( dirty.Left, 0, res - 1 );
var y0 = Math.Clamp( dirty.Top, 0, res - 1 );
var x1 = Math.Clamp( dirty.Right, 0, res );
var y1 = Math.Clamp( dirty.Bottom, 0, res );
if ( x1 <= x0 || y1 <= y0 )
return false;
dirty = new RectInt( x0, y0, x1 - x0, y1 - y0 );
var crater = Crater;
var dig = Dig;
var maxDepth = request.Kind == TerrainEditKind.Dig ? dig.MaxDepth : crater.MaxDepth;
var maxRaise = request.Kind == TerrainEditKind.Dig ? dig.MaxRaise : crater.MaxRaise;
var bowlFrac = request.Kind == TerrainEditKind.Dig
? Math.Clamp( dig.BowlRadius, 0.7f, 0.98f )
: Math.Clamp( crater.BowlRadius, 0.4f, 0.95f );
var depthU = WorldToUshort( depth, height );
var maxDepthU = WorldToUshort( maxDepth, height );
var raiseU = WorldToUshort( maxRaise, height );
var heights = storage.HeightMap;
var control = storage.ControlMap;
var paint = PaintDirt && PaintMaterialIndex >= 0
&& storage.Materials != null
&& PaintMaterialIndex < storage.Materials.Count;
if ( paint )
EnsureControlSnapshot();
var originX = uv.x * res;
var originY = uv.y * res;
var radiusTexels = radius / texelSize;
if ( radiusTexels < 0.5f )
return false;
var rng = new Random( request.Seed == 0 ? 1 : request.Seed );
var rot = rng.Float( 0f, MathF.PI * 2f );
var stretchX = Math.Clamp( 1f + rng.Float( -stretchAmt, stretchAmt ), 0.55f, 1.7f );
if ( stretchAmt <= 0.001f )
stretchX = 1f;
var sampleX = Math.Clamp( cx, x0, x1 - 1 );
var sampleY = Math.Clamp( cy, y0, y1 - 1 );
var sampleI = sampleX + sampleY * res;
var heightBefore = heights[sampleI];
var flattenTargetU = (int)heightBefore;
if ( request.Kind == TerrainEditKind.Flatten && request.HasTargetZ )
{
var targetLocal = terrain.WorldTransform.PointToLocal( new Vector3( request.WorldPos.x, request.WorldPos.y, request.TargetZ ) );
flattenTargetU = WorldToUshort( targetLocal.z, height );
}
var ctx = new TerrainBrushes.Context
{
Heights = heights,
Base = _baseHeight,
Control = control,
Res = res,
X0 = x0,
Y0 = y0,
X1 = x1,
Y1 = y1,
OriginX = originX,
OriginY = originY,
RadiusTexels = radiusTexels,
Ca = MathF.Cos( rot ),
Sa = MathF.Sin( rot ),
StretchX = stretchX,
StretchY = 1f / stretchX,
Ox = rng.Float( 0f, 64f ),
Oy = rng.Float( 0f, 64f ),
Oz = rng.Float( 0f, 64f ),
Freq = 2.1f + rng.Float( 0f, 2.4f ),
Irreg = irreg,
DepthU = depthU,
MaxDepthU = maxDepthU,
RaiseU = raiseU,
BowlFrac = bowlFrac,
SmoothK = blend * depthU * 0.55f,
DugU = Math.Max( 2, WorldToUshort( 8f, height ) ),
Paint = paint,
PaintIndex = PaintMaterialIndex,
PaintStrength = PaintStrength,
FlattenTargetU = flattenTargetU,
FlattenStrength = Math.Clamp( Flatten.Strength, 0.05f, 1f ),
FlattenMaxDeltaU = WorldToUshort( Flatten.MaxDelta, height ),
SpoilAngle = rng.Float( 0f, MathF.PI * 2f )
};
if ( request.Kind != TerrainEditKind.Flatten )
{
var eccA = rng.Float( 0f, MathF.PI * 2f );
var ecc = rng.Float( 0f, irreg * 0.22f ) * radiusTexels;
ctx.OriginX = originX + MathF.Cos( eccA ) * ecc;
ctx.OriginY = originY + MathF.Sin( eccA ) * ecc;
}
switch ( request.Kind )
{
case TerrainEditKind.Dig:
TerrainBrushes.ApplyDig( ref ctx );
break;
case TerrainEditKind.Flatten:
TerrainBrushes.ApplyFlatten( ref ctx );
break;
default:
TerrainBrushes.ApplyCrater( ref ctx );
break;
}
if ( ctx.Painted )
_paintedThisApply = true;
if ( blend > 0.01f && request.Kind != TerrainEditKind.Flatten )
BreakSaddles( storage, x0, y0, x1, y1, maxDepthU, ctx.DugU, blend, paint );
var heightAfter = heights[sampleI];
var dropWorld = (heightBefore - heightAfter) / (float)ushort.MaxValue * height;
var volume = ctx.VolumeU / (float)ushort.MaxValue * height * texelSize * texelSize;
Dbg( $"stamp kind={request.Kind} uv={uv.x:0.000},{uv.y:0.000} texel={cx},{cy} across={across:0.0} r={radius:0.#} d={depth:0.#} blend={blend:0.00} center {heightBefore}->{heightAfter} drop={dropWorld:0.0}u wrote={ctx.Written} rect=({dirty.Left},{dirty.Top},{dirty.Width}x{dirty.Height})" );
result = new TerrainEditResult
{
Kind = request.Kind,
Origin = request.WorldPos,
Radius = radius,
Depth = depth,
Dirty = dirty,
Texels = ctx.Written,
VolumeRemoved = volume
};
return true;
}
void BreakSaddles( TerrainStorage storage, int x0, int y0, int x1, int y1, int maxDepthU, int dugU, float blend, bool paint )
{
if ( _baseHeight == null )
return;
var res = storage.Resolution;
var w = x1 - x0;
var hgt = y1 - y0;
if ( w < 3 || hgt < 3 )
return;
var need = w * hgt;
if ( _mergeScratch == null || _mergeScratch.Length < need )
_mergeScratch = new ushort[need];
var heights = storage.HeightMap;
var control = storage.ControlMap;
var scratch = _mergeScratch;
for ( var y = 0; y < hgt; y++ )
{
var src = (y0 + y) * res + x0;
Array.Copy( heights, src, scratch, y * w, w );
}
for ( var y = 1; y < hgt - 1; y++ )
{
for ( var x = 1; x < w - 1; x++ )
{
var i = (y0 + y) * res + (x0 + x);
var row = y * w + x;
var left = scratch[row - 1];
var right = scratch[row + 1];
var down = scratch[row - w];
var up = scratch[row + w];
var leftD = (int)_baseHeight[i - 1] - left >= dugU;
var rightD = (int)_baseHeight[i + 1] - right >= dugU;
var downD = (int)_baseHeight[i - res] - down >= dugU;
var upD = (int)_baseHeight[i + res] - up >= dugU;
if ( !((leftD && rightD) || (downD && upD)) )
continue;
var minN = int.MaxValue;
if ( leftD ) minN = Math.Min( minN, left );
if ( rightD ) minN = Math.Min( minN, right );
if ( downD ) minN = Math.Min( minN, down );
if ( upD ) minN = Math.Min( minN, up );
var self = scratch[row];
if ( minN >= self )
continue;
var b = (int)_baseHeight[i];
var merged = (int)MathF.Round( self + (minN - self) * blend );
merged = Math.Max( merged, b - maxDepthU );
heights[i] = (ushort)Math.Clamp( merged, 0, ushort.MaxValue );
if ( !paint || control == null )
continue;
var mat = new CompactTerrainMaterial( control[i] );
if ( mat.IsHole )
continue;
mat.OverlayTextureId = (byte)PaintMaterialIndex;
mat.BlendFactor = (byte)Math.Clamp( mat.BlendFactor + (int)(blend * 80f), 0, 255 );
control[i] = mat.Packed;
_paintedThisApply = true;
}
}
}
bool TryProjectToTerrain( Terrain terrain, Vector3 worldPos, out Vector3 local )
{
local = default;
var origin = worldPos + Vector3.Up * 8f;
var ray = new Ray( origin, Vector3.Down );
if ( terrain.RayIntersects( ray, GroundDrop + 8f, out local ) )
return true;
local = terrain.WorldTransform.PointToLocal( worldPos );
var size = terrain.Storage?.TerrainSize ?? 0f;
return local.x >= 0f && local.y >= 0f && local.x <= size && local.y <= size;
}
void PushToEngine( Terrain terrain, RectInt region, bool painted )
{
if ( region.Width < 1 || region.Height < 1 )
return;
if ( terrain.HeightMap != null )
terrain.SyncGPUTexture();
var flags = Terrain.SyncFlags.Height;
if ( painted )
flags |= Terrain.SyncFlags.Control;
terrain.UpdateCollision( flags, region );
InvalidateClutter( terrain, region );
}
void InvalidateClutter( Terrain terrain, RectInt region )
{
if ( !Scene.IsValid() )
return;
var clutter = Scene.GetSystem<ClutterGridSystem>();
if ( clutter == null )
return;
var storage = terrain.Storage;
if ( storage == null )
return;
var res = storage.Resolution;
var size = storage.TerrainSize;
var minLocal = new Vector3( region.Left / (float)res * size, region.Top / (float)res * size, -1000f );
var maxLocal = new Vector3( region.Right / (float)res * size, region.Bottom / (float)res * size, 1000f );
var a = terrain.WorldTransform.PointToWorld( minLocal );
var b = terrain.WorldTransform.PointToWorld( maxLocal );
clutter.InvalidateTilesInBounds( new BBox( a, b ) );
}
bool EnsureSnapshot()
{
var terrain = GetTerrain();
if ( terrain == null || !terrain.IsValid() || terrain.Storage?.HeightMap == null )
return false;
var map = terrain.Storage.HeightMap;
if ( _baseHeight != null && _baseHeight.Length == map.Length )
return true;
_assetZ = WorldPosition.z;
_assetHeight = new ushort[map.Length];
map.CopyTo( _assetHeight, 0 );
_baseHeight = new ushort[map.Length];
map.CopyTo( _baseHeight, 0 );
SnapshotControl();
RaiseFloor( terrain );
return true;
}
void SnapshotControl()
{
var terrain = GetTerrain();
if ( terrain?.Storage?.ControlMap == null )
return;
var control = terrain.Storage.ControlMap;
_assetControl = new uint[control.Length];
control.CopyTo( _assetControl, 0 );
_baseControl = new uint[control.Length];
control.CopyTo( _baseControl, 0 );
}
void EnsureControlSnapshot()
{
if ( _baseControl != null )
return;
SnapshotControl();
}
void RaiseFloor( Terrain terrain )
{
var storage = terrain.Storage;
if ( storage == null || _baseHeight == null )
return;
var height = storage.TerrainHeight;
var needWorld = MathF.Max( Crater.MaxDepth, Dig.MaxDepth ) + FloorMargin;
var needU = WorldToUshort( needWorld, height );
var min = ushort.MaxValue;
for ( var i = 0; i < _baseHeight.Length; i++ )
{
if ( _baseHeight[i] < min )
min = _baseHeight[i];
}
if ( min >= needU )
{
Dbg( $"floor ok min={min} need={needU} ({needWorld:0.#}u of {height:0.#})" );
return;
}
var pad = needU - min;
for ( var i = 0; i < _baseHeight.Length; i++ )
{
var v = (int)_baseHeight[i] + pad;
_baseHeight[i] = (ushort)Math.Min( v, ushort.MaxValue );
}
_baseHeight.CopyTo( storage.HeightMap, 0 );
var padWorld = pad / (float)ushort.MaxValue * height;
WorldPosition = WorldPosition.WithZ( _assetZ - padWorld );
_floorRaised = true;
PushToEngine( terrain, FullRect( storage.Resolution ), painted: false );
Dbg( $"raised floor pad={pad} ({padWorld:0.#}u) min {min}->{min + pad} need={needU} z {_assetZ:0.#}->{WorldPosition.z:0.#}" );
}
void RestoreFromSnapshot()
{
if ( _assetHeight == null )
return;
var terrain = GetTerrain();
if ( terrain == null || !terrain.IsValid() || terrain.Storage == null )
return;
var storage = terrain.Storage;
if ( storage.HeightMap.Length != _assetHeight.Length )
return;
_assetHeight.CopyTo( storage.HeightMap, 0 );
if ( _assetControl != null && storage.ControlMap.Length == _assetControl.Length )
_assetControl.CopyTo( storage.ControlMap, 0 );
if ( _floorRaised )
{
WorldPosition = WorldPosition.WithZ( _assetZ );
_floorRaised = false;
}
PushToEngine( terrain, FullRect( storage.Resolution ), painted: _assetControl != null );
_queue.Clear();
_assetHeight = null;
_assetControl = null;
_baseHeight = null;
_baseControl = null;
Dbg( "RestoreFromSnapshot — disk heightmap restored" );
}
float ResolveDepth( TerrainEditKind kind, float radius, float depth )
{
if ( kind == TerrainEditKind.Flatten )
return 0f;
var scale = kind == TerrainEditKind.Dig ? Dig.DepthScale : Crater.DepthScale;
var max = kind == TerrainEditKind.Dig ? Dig.MaxDepth : Crater.MaxDepth;
var d = depth > 0f ? depth : radius * scale;
return Math.Clamp( d, 0f, max );
}
Terrain GetTerrain()
{
return Components.Get<Terrain>( FindMode.EverythingInSelfAndDescendants );
}
static RectInt FullRect( int resolution ) => new( 0, 0, resolution, resolution );
static int WorldToUshort( float world, float terrainHeight )
{
if ( terrainHeight <= 0f )
return 0;
return (int)Math.Clamp( MathF.Round( world / terrainHeight * ushort.MaxValue ), 0f, (float)ushort.MaxValue );
}
void Dbg( string message )
{
if ( !DebugLog )
return;
Log.Info( $"[Sculpt] {GameObject.Name}: {message}" );
}
}
Game
library
// Copyright (c) 2026 SubZero Studios LLC. All rights reserved.
using Sandbox;
namespace SubZero.SubTerrain;
public sealed class CraterBrushSettings
{
[Property, Range( 16f, 200f )]
public float MaxDepth { get; set; } = 96f;
[Property, Title( "Depth Scale" ), Range( 0.1f, 1.5f )]
public float DepthScale { get; set; } = 0.5f;
[Property, Range( 0f, 32f )]
public float MaxRaise { get; set; } = 12f;
[Property, Range( 0.4f, 0.95f )]
public float BowlRadius { get; set; } = 0.75f;
[Property, Range( 0f, 0.7f )]
public float Irregularity { get; set; } = 0.35f;
[Property, Range( 0f, 0.5f )]
public float Stretch { get; set; } = 0.25f;
[Property, Range( 0f, 1f )]
public float Blend { get; set; } = 0.65f;
}
public sealed class DigBrushSettings
{
[Property, Range( 16f, 120f )]
public float MaxDepth { get; set; } = 48f;
[Property, Title( "Depth Scale" ), Range( 0.1f, 1.5f )]
public float DepthScale { get; set; } = 0.85f;
[Property, Range( 0f, 40f )]
public float MaxRaise { get; set; } = 18f;
[Property, Range( 0.7f, 0.98f )]
public float BowlRadius { get; set; } = 0.9f;
[Property, Range( 0f, 0.4f )]
public float Irregularity { get; set; } = 0.12f;
[Property, Range( 0f, 0.35f )]
public float Stretch { get; set; } = 0.08f;
[Property, Range( 0f, 1f )]
public float Blend { get; set; } = 0.35f;
}
public sealed class FlattenBrushSettings
{
[Property, Range( 0.05f, 1f )]
public float Strength { get; set; } = 0.65f;
[Property, Range( 4f, 64f )]
public float MaxDelta { get; set; } = 24f;
}
Editor
library
// Copyright (c) 2026 SubZero Studios LLC. All rights reserved.
using System;
using System.Collections.Generic;
using Sandbox;
namespace Editor.SubTerrain;
/// <summary>
/// Paint Texture settings. Ground is the default mode. UI labels live on the [Title]
/// attributes; OnlyOnSlope has no inspector title (Steep | All owns it).
/// </summary>
public class SubTerrainPaintSettings
{
public const float WearMixMax = 0.40f;
public const float PathMixMax = 0.36f;
public const float DitherWarn = 0.36f;
public const float PackCoreMin = 0.15f;
public const float PackCoreMax = 0.95f;
public const string LibraryVersion = "1.4.15";
public SubTerrainPaintLayer Layer { get; set; } = SubTerrainPaintLayer.Base;
public int MaterialIndex { get; set; }
public float MixCap { get; set; } = 0.28f;
/// <summary>Wear only. Move mix on the pair that is already there. The selected tmat is not written.</summary>
[Property, Title( "Keep pair" ), WideMode]
public bool MixOnly { get; set; }
/// <summary>Alt-click the ground to stay on. Wear will not paint other bases.</summary>
[Property, Title( "Stay on sampled" ), WideMode]
public bool LimitToSampledGround { get; set; }
/// <summary>Path only. Hard brush center packs the path tmat as base.</summary>
[Property, Title( "Pack center" ), Description( "On: solid path core along the stroke, soft apron outside. Off: soft ground + path apron only." ), WideMode]
public bool PackCenter { get; set; } = true;
/// <summary>Path + Pack center. Higher = wider solid middle. Outer ring stays soft blend.</summary>
[Property, Title( "Core" ), Description( "How much of the brush packs solid. Raise for a wider solid middle. Outer ring stays Center mix apron." ), Range( PackCoreMin, PackCoreMax ), Step( 0.01f ), WideMode]
public float PackCore { get; set; } = 0.55f;
[Property, Title( "How worn" ), Description( "Overlay mix. Default 0.28. Soft chew to 0.36; hard stop 0.40. Above 0.36 can dither." ), Range( 0.05f, WearMixMax ), Step( 0.01f ), WideMode]
public float HowWorn
{
get => MixCap;
set => MixCap = value;
}
[Property, Title( "Center mix" ), Description( "Soft apron overlay mix outside the packed core. Raise with brush Size for a wider blend." ), Range( 0.05f, PathMixMax ), Step( 0.01f ), WideMode]
public float CenterMix
{
get => MixCap;
set => MixCap = value;
}
public bool OnlyOnSlope { get; set; } = true;
[Property, Title( "Steeper than" ), Description( "How steep the ground must be. 20 is a hill face. Lower it to catch gentler banks." ), Range( 1f, 60f ), Step( 1f ), WideMode]
public float SlopeDegrees { get; set; } = 20f;
public int SampledGroundIndex { get; set; } = -1;
public int PathGroundIndex { get; set; } = -1;
public bool HasSampledGround => SampledGroundIndex >= 0;
public bool HasPathGround => PathGroundIndex >= 0;
public bool Dithers => MixCap > DitherWarn;
/// <summary>Stamp value above which Pack center writes solid path. Higher Core = lower threshold.</summary>
public float PackThreshold
{
get
{
float core = Math.Clamp( PackCore, PackCoreMin, PackCoreMax );
return Math.Clamp( 0.82f - core * 0.64f, 0.15f, 0.85f );
}
}
public static float MixMaxFor( SubTerrainPaintLayer layer )
{
return layer == SubTerrainPaintLayer.Path ? PathMixMax : WearMixMax;
}
}
/// <summary>
/// Per Ground / Wear / Path mix and gate, for the editor session.
/// </summary>
internal static class SubTerrainPaintStore
{
struct LayerState
{
public float MixCap;
public bool OnlyOnSlope;
public float SlopeDegrees;
public bool MixOnly;
public bool LimitToSampledGround;
public bool PackCenter;
public float PackCore;
}
static readonly Dictionary<SubTerrainPaintLayer, LayerState> ByLayer = new();
public static void Capture( SubTerrainPaintSettings settings )
{
if ( settings is null )
return;
ByLayer[settings.Layer] = Read( settings );
}
public static void Apply( SubTerrainPaintSettings settings, SubTerrainPaintLayer layer )
{
if ( settings is null )
return;
if ( !ByLayer.TryGetValue( layer, out var state ) )
state = Defaults( layer );
settings.Layer = layer;
settings.MixCap = Math.Clamp( state.MixCap, 0.05f, SubTerrainPaintSettings.MixMaxFor( layer ) );
settings.OnlyOnSlope = state.OnlyOnSlope;
settings.SlopeDegrees = state.SlopeDegrees;
settings.MixOnly = state.MixOnly;
settings.LimitToSampledGround = state.LimitToSampledGround;
settings.PackCenter = state.PackCenter;
settings.PackCore = Math.Clamp( state.PackCore, SubTerrainPaintSettings.PackCoreMin, SubTerrainPaintSettings.PackCoreMax );
}
static LayerState Read( SubTerrainPaintSettings settings )
{
return new LayerState
{
MixCap = settings.MixCap,
OnlyOnSlope = settings.OnlyOnSlope,
SlopeDegrees = settings.SlopeDegrees,
MixOnly = settings.MixOnly,
LimitToSampledGround = settings.LimitToSampledGround,
PackCenter = settings.PackCenter,
PackCore = settings.PackCore
};
}
static LayerState Defaults( SubTerrainPaintLayer layer )
{
if ( layer == SubTerrainPaintLayer.Path )
{
return new LayerState
{
MixCap = 0.36f,
OnlyOnSlope = false,
SlopeDegrees = 20f,
PackCenter = true,
PackCore = 0.55f
};
}
if ( layer == SubTerrainPaintLayer.Overlay )
{
return new LayerState
{
MixCap = 0.28f,
OnlyOnSlope = true,
SlopeDegrees = 20f,
PackCenter = true,
PackCore = 0.55f
};
}
return new LayerState
{
MixCap = 0.28f,
OnlyOnSlope = false,
SlopeDegrees = 20f,
PackCenter = true,
PackCore = 0.55f
};
}
}
Game
library
// Copyright (c) 2026 SubZero Studios LLC. All rights reserved.
using System;
using Sandbox;
namespace SubZero.SubTerrain;
/// <summary>
/// Optional host-authoritative play-sculpt net. Attach to a NetworkMode.Object
/// GameObject. Host writes height; peers apply the same op locally.
/// </summary>
[Title( "Terrain Sculpt Net" )]
[Category( "SubTerrain" )]
[Icon( "share" )]
public sealed class TerrainSculptNet : Component
{
public bool Submit( TerrainEditRequest request )
{
if ( request.Seed == 0 )
request.Seed = Game.Random.Int( 1, 1_000_000_000 );
if ( !Networking.IsActive || Networking.IsHost )
{
ApplyLocal( request );
if ( Networking.IsActive )
BroadcastStamp( (int)request.Kind, request.WorldPos, request.Radius, request.Depth, request.Seed, request.HasTargetZ, request.TargetZ );
return true;
}
RequestHost( (int)request.Kind, request.WorldPos, request.Radius, request.Depth, request.Seed, request.HasTargetZ, request.TargetZ );
return true;
}
[Rpc.Host]
public void RequestHost( int kind, Vector3 pos, float radius, float depth, int seed, bool hasTargetZ, float targetZ )
{
var request = Make( kind, pos, radius, depth, seed, hasTargetZ, targetZ );
ApplyLocal( request );
BroadcastStamp( kind, pos, radius, depth, seed, hasTargetZ, targetZ );
}
[Rpc.Broadcast]
public void BroadcastStamp( int kind, Vector3 pos, float radius, float depth, int seed, bool hasTargetZ, float targetZ )
{
if ( Networking.IsHost )
return;
ApplyLocal( Make( kind, pos, radius, depth, seed, hasTargetZ, targetZ ) );
}
static TerrainEditRequest Make( int kind, Vector3 pos, float radius, float depth, int seed, bool hasTargetZ, float targetZ )
{
return new TerrainEditRequest
{
Kind = (TerrainEditKind)kind,
WorldPos = pos,
Radius = radius,
Depth = depth,
Seed = seed,
HasTargetZ = hasTargetZ,
TargetZ = targetZ
};
}
void ApplyLocal( TerrainEditRequest request )
{
TerrainSculpt.TryApply( Scene, request );
}
}
Game
library
// Copyright (c) 2026 SubZero Studios LLC. All rights reserved.
using System;
using System.Collections.Generic;
using Sandbox;
namespace SubZero.SubTerrain;
/// <summary>
/// Dirt spray and physics clods for crater / dig stamps. Listens to
/// TerrainSculpt.OnApplied after GPU and collision so debris lands in the bowl.
/// Prefabs use engine meshes (models/dev/box.vmdl).
/// </summary>
[Title( "Terrain Debris FX" )]
[Category( "SubTerrain" )]
[Icon( "grain" )]
public sealed class TerrainDebrisFx : Component
{
const string DefaultClodPath = "prefabs/subterrain/dirt_clod.prefab";
const string DefaultSprayPath = "prefabs/subterrain/dirt_spray.prefab";
const FindMode RigidbodyFind = FindMode.Enabled | FindMode.InSelf | FindMode.InDescendants;
[Property, Group( "Source" )]
public TerrainSculpt Sculpt { get; set; }
[Property, Group( "Prefabs" )]
public PrefabFile ClodPrefab { get; set; }
[Property, Group( "Prefabs" )]
public PrefabFile SprayPrefab { get; set; }
[Property, Group( "Kinds" )]
public bool Craters { get; set; } = true;
[Property, Group( "Kinds" )]
public bool Digs { get; set; } = true;
[Property, Group( "Clods" ), Range( 0, 48 )]
public int MinClods { get; set; } = 8;
[Property, Group( "Clods" ), Range( 1, 64 )]
public int MaxClods { get; set; } = 24;
[Property, Group( "Clods" ), Range( 0f, 0.5f )]
public float ClodsPerRadius { get; set; } = 0.12f;
[Property, Group( "Clods" ), Range( 0, 128 )]
public int MaxAlive { get; set; } = 64;
[Property, Group( "Launch" ), Range( 0f, 4000f )]
public float Impulse { get; set; } = 640f;
[Property, Group( "Launch" ), Range( 0f, 3f )]
public float UpBias { get; set; } = 0.45f;
[Property, Group( "Launch" ), Range( 0.5f, 4f )]
public float Outward { get; set; } = 2.2f;
[Property, Group( "Launch" ), Range( 0.4f, 1.4f )]
public float RimMin { get; set; } = 0.78f;
[Property, Group( "Launch" ), Range( 0.5f, 1.6f )]
public float RimMax { get; set; } = 1.12f;
[Property, Group( "Launch" ), Range( 0f, 40f )]
public float Spin { get; set; } = 14f;
[Property, Group( "Launch" ), Range( 0f, 48f )]
public float SpawnLift { get; set; } = 20f;
[Property, Group( "Launch" ), Range( 0.05f, 1.5f )]
public float ScaleMin { get; set; } = 0.7f;
[Property, Group( "Launch" ), Range( 0.05f, 2.5f )]
public float ScaleMax { get; set; } = 1.35f;
[Property, Group( "Dig" ), Range( 0.1f, 1f )]
public float DigImpulseScale { get; set; } = 0.5f;
[Property, Group( "Debug" )]
public bool DebugLog { get; set; }
readonly List<GameObject> _alive = new();
protected override void OnStart()
{
if ( !Game.IsPlaying )
return;
Sculpt ??= Components.Get<TerrainSculpt>( FindMode.EnabledInSelf );
ClodPrefab ??= ResourceLibrary.Get<PrefabFile>( DefaultClodPath );
SprayPrefab ??= ResourceLibrary.Get<PrefabFile>( DefaultSprayPath );
if ( Sculpt == null || !Sculpt.IsValid() )
{
Log.Warning( $"[Debris] {GameObject.Name}: no TerrainSculpt on this object" );
return;
}
Sculpt.OnApplied += OnTerrainApplied;
Sculpt.OnReset += ClearDebris;
}
protected override void OnDisabled()
{
Unhook();
ClearDebris();
}
protected override void OnDestroy()
{
Unhook();
}
void Unhook()
{
if ( Sculpt == null || !Sculpt.IsValid() )
return;
Sculpt.OnApplied -= OnTerrainApplied;
Sculpt.OnReset -= ClearDebris;
}
void OnTerrainApplied( TerrainEditResult result )
{
if ( result.Kind == TerrainEditKind.Flatten )
return;
if ( result.Kind == TerrainEditKind.Crater && !Craters )
return;
if ( result.Kind == TerrainEditKind.Dig && !Digs )
return;
SpawnSpray( result );
SpawnClods( result );
}
void SpawnSpray( TerrainEditResult result )
{
if ( SprayPrefab == null )
return;
var aimUp = Rotation.LookAt( Vector3.Up, Vector3.Forward );
var spray = GameObject.Clone( SprayPrefab, new Transform( result.Origin, aimUp ) );
if ( !spray.IsValid() )
return;
Track( spray );
}
void SpawnClods( TerrainEditResult result )
{
if ( ClodPrefab == null )
return;
var count = (int)Math.Clamp( result.Radius * ClodsPerRadius, MinClods, MaxClods );
if ( result.Kind == TerrainEditKind.Dig )
count = Math.Max( 3, count / 2 );
var impulse = Impulse;
var up = UpBias;
if ( result.Kind == TerrainEditKind.Dig )
{
impulse *= DigImpulseScale;
up *= 0.65f;
}
for ( var i = 0; i < count; i++ )
SpawnClod( result, impulse, up, i, count );
Dbg( $"spawned {count} clods kind={result.Kind} r={result.Radius:0.#}" );
}
void SpawnClod( TerrainEditResult result, float impulse, float up, int index, int count )
{
var yaw = (360f * index / Math.Max( count, 1 )) + Random.Shared.Float( -14f, 14f );
var outward = Rotation.FromYaw( yaw ).Forward;
var rimMin = result.Kind == TerrainEditKind.Dig
? Math.Clamp( RimMin * 0.7f, 0.4f, 1.2f )
: Math.Clamp( RimMin, 0.4f, 1.4f );
var rimMax = Math.Max( rimMin + 0.05f, RimMax );
var radial = result.Radius * Random.Shared.Float( rimMin, rimMax );
var pos = result.Origin + outward * radial + Vector3.Up * SpawnLift;
var scale = Random.Shared.Float( ScaleMin, ScaleMax );
var rot = Rotation.From( new Angles(
Random.Shared.Float( 0f, 360f ),
Random.Shared.Float( 0f, 360f ),
Random.Shared.Float( 0f, 360f )
) );
var clod = GameObject.Clone( ClodPrefab, new Transform( pos, rot ) );
if ( !clod.IsValid() )
return;
clod.WorldScale *= scale;
Track( clod );
var body = clod.Components.Get<Rigidbody>( RigidbodyFind );
if ( !body.IsValid() )
return;
var dir = (outward * Outward + Vector3.Up * up).Normal;
var speed = impulse * Random.Shared.Float( 0.85f, 1.25f );
body.Sleeping = false;
body.Velocity = dir * speed;
body.AngularVelocity = new Vector3(
Random.Shared.Float( -1f, 1f ),
Random.Shared.Float( -1f, 1f ),
Random.Shared.Float( -1f, 1f )
) * Spin;
}
void Track( GameObject go )
{
Prune();
_alive.Add( go );
while ( MaxAlive > 0 && _alive.Count > MaxAlive )
{
var oldest = _alive[0];
_alive.RemoveAt( 0 );
if ( oldest.IsValid() )
oldest.Destroy();
}
}
void ClearDebris()
{
for ( var i = _alive.Count - 1; i >= 0; i-- )
{
if ( _alive[i].IsValid() )
_alive[i].Destroy();
}
_alive.Clear();
}
void Prune()
{
for ( var i = _alive.Count - 1; i >= 0; i-- )
{
if ( !_alive[i].IsValid() )
_alive.RemoveAt( i );
}
}
void Dbg( string message )
{
if ( !DebugLog )
return;
Log.Info( $"[Debris] {message}" );
}
}
Editor
library
// Copyright (c) 2026 SubZero Studios LLC. All rights reserved.
using System;
using Sandbox;
using Editor.TerrainEditor;
namespace Editor.SubTerrain;
public struct SubTerrainPaintParameters
{
public Vector3 HitPosition { get; set; }
public Vector2 HitUV { get; set; }
public float FlattenHeight { get; set; }
public SubTerrainBrushSettings BrushSettings { get; set; }
}
/// <summary>
/// Shared stroke logic for SubTerrain sculpt tools.
/// </summary>
public abstract class SubTerrainBaseBrushTool : EditorTool
{
protected SubTerrainTool _parent;
protected bool _dragging;
protected RectInt _dirtyRegion;
protected SubTerrainSculptMode Mode { get; set; }
protected Vector2 SlopeStartUV;
protected float SlopeStartHeight01;
protected bool AllowBrushInvert { get; set; }
protected bool AllowHeightSample { get; set; }
protected Plane StrokePlane;
bool _samplingHeight;
GpuBuffer<BrushData> _engineBrushBuffer;
Vector3 _lastHitWorldPos;
Vector3 _lastPaintLocal;
Transform _lastHitTx;
Vector2? _cursorLockPosition;
float _raiseStampAccum;
public SubTerrainBaseBrushTool( SubTerrainTool parent )
{
_parent = parent;
}
public override void OnEnabled()
{
AllowGameObjectSelection = false;
_parent.SetBrushMode( GetType().Name );
}
public override void OnDisabled()
{
_parent.DetachSidebarForReuse();
}
public virtual bool GetHitPosition( Terrain terrain, out Vector3 position )
{
return terrain.RayIntersects( Gizmo.CurrentRay, Gizmo.RayDepth, out position );
}
public override void OnUpdate()
{
var terrain = GetSelectedComponent<Terrain>();
if ( !terrain.IsValid() )
{
_parent.ClearStatus();
return;
}
if ( !GetHitPosition( terrain, out var hitPosition ) )
{
_parent.ClearStatus();
return;
}
var tx = terrain.WorldTransform;
_parent.UpdateStatus( terrain, hitPosition );
if ( Application.MouseButtons.HasFlag( MouseButtons.Middle ) )
{
_cursorLockPosition ??= Application.UnscaledCursorPosition;
var d = Application.UnscaledCursorPosition - _cursorLockPosition.Value;
var brush = _parent.BrushSettings;
if ( Gizmo.IsShiftPressed )
{
brush.Size = SubTerrainBrushSize.Clamp( (int)(brush.Size + d.x * 0.25f) );
brush.Opacity = Math.Clamp( brush.Opacity - d.y * 0.002f, 0f, 1f );
_parent.SyncBrushSizeWidget();
}
else if ( Gizmo.IsCtrlPressed && !brush.RandomRotation )
{
brush.Rotation = ((brush.Rotation + d.x * 0.5f) % 360f + 360f) % 360f;
_parent.SyncBrushRotationRows();
}
Application.UnscaledCursorPosition = _cursorLockPosition.Value;
SceneOverlay.Parent.Cursor = CursorShape.Blank;
DrawBrushAdjustText();
_parent.DrawBrushPreview( new Transform( _lastHitWorldPos, _lastHitTx.Rotation ), terrain );
return;
}
if ( _cursorLockPosition.HasValue )
{
SceneOverlay.Parent.Cursor = CursorShape.None;
_cursorLockPosition = null;
}
_lastHitWorldPos = tx.PointToWorld( hitPosition );
_lastHitTx = tx;
if ( AllowHeightSample && Application.KeyboardModifiers.HasFlag( KeyboardModifiers.Alt ) )
{
if ( Gizmo.IsLeftMouseDown )
{
if ( !_samplingHeight )
{
_samplingHeight = true;
_parent.BrushSettings.TargetHeight = hitPosition.z;
}
}
else
{
_samplingHeight = false;
}
_parent.DrawBrushPreview( new Transform( _lastHitWorldPos, tx.Rotation ), terrain );
DrawToolOverlay( terrain, _lastHitWorldPos );
return;
}
_samplingHeight = false;
if ( Gizmo.IsLeftMouseDown )
{
bool moved = !Application.CursorDelta.IsNearZeroLength;
bool shouldSculpt = !_dragging || moved;
if ( !_dragging )
{
if ( _parent.BrushSettings.RandomRotation )
_parent.BrushSettings.Rotation = Random.Shared.NextSingle() * 360f;
StrokePlane = new Plane( _lastHitWorldPos, tx.Rotation.Up );
_dragging = true;
_raiseStampAccum = 0f;
var uv = new Vector2( hitPosition.x, hitPosition.y ) / terrain.Storage.TerrainSize;
var x = (int)Math.Floor( terrain.Storage.Resolution * uv.x );
var y = (int)Math.Floor( terrain.Storage.Resolution * uv.y );
_dirtyRegion = new( new Vector2Int( x, y ) );
_lastPaintLocal = hitPosition;
OnStrokeStart( terrain, hitPosition );
OnPaint( terrain, MakePaintParams( hitPosition, terrain ) );
}
else if ( Mode == SubTerrainSculptMode.RaiseLower )
{
if ( moved )
{
StrokeSculpt( terrain, hitPosition );
_raiseStampAccum = 0f;
}
else
{
// Hold in place: keep raising/lowering at Speed stamps per second.
float t = Math.Clamp( (_parent.BrushSettings.Speed - 1) / 99f, 0f, 1f );
float stampsPerSec = 2f + t * 58f;
_raiseStampAccum += Math.Clamp( RealTime.Delta, 0f, 0.1f ) * stampsPerSec;
while ( _raiseStampAccum >= 1f )
{
_raiseStampAccum -= 1f;
OnPaint( terrain, MakePaintParams( hitPosition, terrain ) );
_lastPaintLocal = hitPosition;
}
}
}
else if ( shouldSculpt )
{
StrokeSculpt( terrain, hitPosition );
}
}
else if ( _dragging )
{
_dragging = false;
_raiseStampAccum = 0f;
OnPaintEnded( terrain );
}
_parent.DrawBrushPreview( new Transform( _lastHitWorldPos, tx.Rotation ), terrain );
DrawToolOverlay( terrain, _lastHitWorldPos );
}
SubTerrainPaintParameters MakePaintParams( Vector3 hitPosition, Terrain terrain )
{
return new SubTerrainPaintParameters
{
HitPosition = hitPosition,
HitUV = new Vector2( hitPosition.x, hitPosition.y ) / terrain.Storage.TerrainSize,
FlattenHeight = hitPosition.z / terrain.Storage.TerrainHeight,
BrushSettings = _parent.BrushSettings
};
}
/// <summary>
/// World-space stamp fill so fast / zoomed-out drags do not leave dashed strokes.
/// </summary>
void StrokeSculpt( Terrain terrain, Vector3 hitLocal )
{
var from = _lastPaintLocal;
var to = hitLocal;
float dist = (to - from).Length;
float step = Math.Max( _parent.BrushSettings.Size * 0.2f, 8f );
if ( dist < 0.001f )
return;
if ( dist <= step )
{
OnPaint( terrain, MakePaintParams( to, terrain ) );
_lastPaintLocal = to;
return;
}
const int maxStamps = 64;
int stamps = (int)Math.Ceiling( dist / step );
if ( stamps > maxStamps )
{
stamps = maxStamps;
step = dist / stamps;
}
for ( int i = 1; i <= stamps; i++ )
{
float t = i / (float)stamps;
OnPaint( terrain, MakePaintParams( Vector3.Lerp( from, to, t ), terrain ) );
}
_lastPaintLocal = to;
}
void DrawBrushAdjustText()
{
var textScope = new TextRendering.Scope
{
TextColor = Color.White,
FontSize = 16 * Gizmo.Settings.GizmoScale * Application.DpiScale,
FontName = "Roboto Mono",
FontWeight = 600,
LineHeight = 1,
Outline = new TextRendering.Outline() { Color = Color.Black, Enabled = true, Size = 3 }
};
var offset = Vector2.Up * 24;
var brush = _parent.BrushSettings;
if ( Gizmo.IsShiftPressed )
{
textScope.Text = $"Size: {brush.Size}";
Gizmo.Draw.ScreenText( textScope, _lastHitWorldPos, offset );
textScope.Text = $"Opacity: {brush.Opacity:0.##}";
Gizmo.Draw.ScreenText( textScope, _lastHitWorldPos, offset * 2 );
if ( Mode == SubTerrainSculptMode.RaiseLower )
{
textScope.Text = $"Speed: {brush.Speed}";
Gizmo.Draw.ScreenText( textScope, _lastHitWorldPos, offset * 3 );
}
}
else if ( Gizmo.IsCtrlPressed && !brush.RandomRotation )
{
textScope.Text = $"Rotation: {brush.Rotation:0.#}";
Gizmo.Draw.ScreenText( textScope, _lastHitWorldPos, offset );
}
}
protected virtual void DrawToolOverlay( Terrain terrain, Vector3 worldCenter )
{
}
protected virtual void OnStrokeStart( Terrain terrain, Vector3 hitPosition )
{
}
/// <summary>
/// World Size is brush radius; dispatch covers the diameter (Size * 2 in world units).
/// </summary>
protected int BrushTexelSize( Terrain terrain, float worldSize )
{
int size = (int)Math.Floor( worldSize * 2.0f / terrain.Storage.TerrainSize * terrain.Storage.Resolution );
return Math.Max( size, 1 );
}
protected void GrowDirtyRegion( Terrain terrain, Vector2 hitUV, int size )
{
var x = (int)Math.Floor( terrain.Storage.Resolution * hitUV.x ) - size / 2;
var y = (int)Math.Floor( terrain.Storage.Resolution * hitUV.y ) - size / 2;
_dirtyRegion.Add( new RectInt( x, y, size + 1, size + 1 ) );
}
protected virtual void OnPaint( Terrain terrain, SubTerrainPaintParameters paint )
{
var brushTexture = _parent.SelectedBrushTexture;
if ( brushTexture is null )
return;
int size = BrushTexelSize( terrain, paint.BrushSettings.Size );
var amount = paint.BrushSettings.Opacity;
if ( Mode == SubTerrainSculptMode.RaiseLower )
{
// Speed 50 = historical 1x height per stamp; 100 ≈ 4x. Hold rate is separate.
amount *= Math.Clamp( paint.BrushSettings.Speed / 50f, 0.02f, 4f );
}
var strength = amount * (AllowBrushInvert && Gizmo.IsCtrlPressed ? -1.0f : 1.0f);
DispatchSculpt( terrain, paint, brushTexture, size, strength );
}
protected void DispatchSculpt( Terrain terrain, SubTerrainPaintParameters paint, Texture brushTexture, int size, float strength )
{
if ( (int)Mode <= (int)SubTerrainSculptMode.Noise )
{
DispatchEngineSculpt( terrain, paint, brushTexture, size, strength );
return;
}
var cs = new ComputeShader( "terrain/cs_subterrain_sculpt" );
cs.Attributes.SetComboEnum( "D_SCULPT_MODE", Mode );
cs.Attributes.Set( "Heightmap", terrain.HeightMap );
cs.Attributes.Set( "ControlMap", terrain.ControlMap );
cs.Attributes.Set( "HeightUV", paint.HitUV );
cs.Attributes.Set( "FlattenHeight", paint.FlattenHeight );
cs.Attributes.Set( "SlopeStartUV", SlopeStartUV );
cs.Attributes.Set( "SlopeEndUV", paint.HitUV );
cs.Attributes.Set( "SlopeStartHeight", SlopeStartHeight01 );
cs.Attributes.Set( "SlopeEndHeight", paint.FlattenHeight );
cs.Attributes.Set( "BrushStrength", strength );
cs.Attributes.Set( "BrushSize", size );
cs.Attributes.Set( "Brush", brushTexture );
ApplyExtraAttributes( cs, paint );
cs.Dispatch( size, size, 1 );
GrowDirtyRegion( terrain, paint.HitUV, size );
}
/// <summary>
/// Modes 0-4 match the engine sculpt shader, which already supports brush rotation.
/// Avoids recompiling cs_subterrain_sculpt (that compile has been crashing the editor).
/// </summary>
void DispatchEngineSculpt( Terrain terrain, SubTerrainPaintParameters paint, Texture brushTexture, int size, float strength )
{
var cs = new ComputeShader( "terrain/cs_terrain_sculpt" );
cs.Attributes.SetComboEnum( "D_SCULPT_MODE", (SculptMode)(int)Mode );
cs.Attributes.Set( "Heightmap", terrain.HeightMap );
cs.Attributes.Set( "ControlMap", terrain.ControlMap );
_engineBrushBuffer ??= new GpuBuffer<BrushData>( 1 );
_engineBrushBuffer.SetData( new[]
{
new BrushData
{
UV = paint.HitUV,
Strength = strength,
Size = size,
Rotation = paint.BrushSettings.Rotation * MathF.PI / 180f,
FlattenHeight = paint.FlattenHeight,
}
} );
cs.Attributes.Set( "BrushSettings", _engineBrushBuffer );
cs.Attributes.Set( "Brush", brushTexture );
cs.Dispatch( size, size, 1 );
GrowDirtyRegion( terrain, paint.HitUV, size );
}
/// <summary>
/// Set any attributes a sub-tool needs on top of the shared sculpt ones.
/// Runs after the shared attributes, so it can override them.
/// </summary>
protected virtual void ApplyExtraAttributes( ComputeShader cs, SubTerrainPaintParameters paint )
{
}
private static T[] CopyRegion<T>( T[] data, int stride, RectInt rect ) where T : unmanaged
{
T[] region = new T[rect.Width * rect.Height];
for ( int y = 0; y < rect.Height; y++ )
{
for ( int x = 0; x < rect.Width; x++ )
{
region[x + y * rect.Width] = data[rect.Left + x + (rect.Top + y) * stride];
}
}
return region;
}
private static Action CreateUndoAction<T>( Terrain terrain, T[] dest, T[] region, RectInt dirtyRegion, Terrain.SyncFlags flags ) => () =>
{
if ( !terrain.IsValid() )
return;
for ( int y = 0; y < dirtyRegion.Height; y++ )
{
for ( int x = 0; x < dirtyRegion.Width; x++ )
{
dest[dirtyRegion.Left + x + (dirtyRegion.Top + y) * terrain.Storage.Resolution] = region[x + y * dirtyRegion.Width];
}
}
terrain.SyncGPUTexture();
terrain.UpdateCollision( flags, dirtyRegion );
};
protected virtual void OnPaintEnded( Terrain terrain )
{
_dirtyRegion.Left = Math.Clamp( _dirtyRegion.Left, 0, terrain.Storage.Resolution - 1 );
_dirtyRegion.Right = Math.Clamp( _dirtyRegion.Right, 0, terrain.Storage.Resolution - 1 );
_dirtyRegion.Top = Math.Clamp( _dirtyRegion.Top, 0, terrain.Storage.Resolution - 1 );
_dirtyRegion.Bottom = Math.Clamp( _dirtyRegion.Bottom, 0, terrain.Storage.Resolution - 1 );
var name = $"Terrain {DisplayInfo.For( this ).Name}";
if ( Mode != SubTerrainSculptMode.Hole )
{
var regionBefore = CopyRegion( terrain.Storage.HeightMap, terrain.Storage.Resolution, _dirtyRegion );
terrain.SyncCPUTexture( Terrain.SyncFlags.Height, _dirtyRegion );
var regionAfter = CopyRegion( terrain.Storage.HeightMap, terrain.Storage.Resolution, _dirtyRegion );
SceneEditorSession.Active.UndoSystem.Insert( name,
CreateUndoAction( terrain, terrain.Storage.HeightMap, regionBefore, _dirtyRegion, Terrain.SyncFlags.Height ),
CreateUndoAction( terrain, terrain.Storage.HeightMap, regionAfter, _dirtyRegion, Terrain.SyncFlags.Height ) );
}
else
{
var regionBefore = CopyRegion( terrain.Storage.ControlMap, terrain.Storage.Resolution, _dirtyRegion );
terrain.SyncCPUTexture( Terrain.SyncFlags.Control, _dirtyRegion );
var regionAfter = CopyRegion( terrain.Storage.ControlMap, terrain.Storage.Resolution, _dirtyRegion );
SceneEditorSession.Active.UndoSystem.Insert( name,
CreateUndoAction( terrain, terrain.Storage.ControlMap, regionBefore, _dirtyRegion, Terrain.SyncFlags.Control ),
CreateUndoAction( terrain, terrain.Storage.ControlMap, regionAfter, _dirtyRegion, Terrain.SyncFlags.Control ) );
}
}
}
Game
library
// Copyright (c) 2026 SubZero Studios LLC. All rights reserved.
using System;
using Sandbox;
namespace SubZero.SubTerrain;
/// <summary>
/// Destroys this GameObject after a delay. Used by debris prefabs.
/// </summary>
[Title( "Timed Destroy" )]
[Category( "SubTerrain" )]
[Icon( "timer" )]
public sealed class TimedDestroy : Component
{
[Property] public float Seconds { get; set; } = 12f;
TimeSince _alive;
protected override void OnStart()
{
_alive = 0;
}
protected override void OnUpdate()
{
if ( _alive >= Seconds )
GameObject.Destroy();
}
}
Game
library
// Copyright (c) 2026 SubZero Studios LLC. All rights reserved.
using System;
using Sandbox;
namespace SubZero.SubTerrain;
/// <summary>
/// Destroys this GameObject after a delay. Used by debris prefabs.
/// </summary>
[Title( "Timed Destroy" )]
[Category( "SubTerrain" )]
[Icon( "timer" )]
public sealed class TimedDestroy : Component
{
[Property] public float Seconds { get; set; } = 12f;
TimeSince _alive;
protected override void OnStart()
{
_alive = 0;
}
protected override void OnUpdate()
{
if ( _alive >= Seconds )
GameObject.Destroy();
}
}
Editor
library
// Copyright (c) 2026 SubZero Studios LLC. All rights reserved.
using Sandbox;
namespace Editor.SubTerrain;
/// <summary>
/// Keeps the click height along the stroke. Paint Texture Path is the control-map path.
/// </summary>
[Title( "Path" )]
[Icon( "route" )]
[Alias( "subterrain_path" )]
[Group( "1" )]
[Order( 6 )]
public class SubPathTool : SubTerrainBaseBrushTool
{
Vector3 _startLocal;
public SubPathTool( SubTerrainTool parent ) : base( parent )
{
Mode = SubTerrainSculptMode.Path;
}
protected override void OnStrokeStart( Terrain terrain, Vector3 hitPosition )
{
_startLocal = hitPosition;
SlopeStartUV = new Vector2( hitPosition.x, hitPosition.y ) / terrain.Storage.TerrainSize;
SlopeStartHeight01 = hitPosition.z / terrain.Storage.TerrainHeight;
}
protected override void DrawToolOverlay( Terrain terrain, Vector3 worldCenter )
{
if ( !_dragging )
return;
var startWorld = terrain.WorldTransform.PointToWorld( _startLocal );
Gizmo.Draw.Color = Color.FromBytes( 120, 200, 250 );
Gizmo.Draw.Line( startWorld, worldCenter );
}
}
Editor
library
// Copyright (c) 2026 SubZero Studios LLC. All rights reserved.
using System;
namespace Editor.SubTerrain;
/// <summary>
/// Brush Size limits and piecewise track mapping.
/// Fine band stays precise; larger sizes take less track and bigger nudges.
/// </summary>
public static class SubTerrainBrushSize
{
public const int Min = 8;
public const int FineMax = 3500;
public const int MidMax = 18000;
public const int Max = 35000;
public const int FineStep = 50;
public const int MidStep = 500;
public const int CoarseStep = 1000;
/// <summary>Fraction of the slider track for the fine band (8..FineMax).</summary>
public const float FineTrack = 0.70f;
/// <summary>End of mid band on the track (FineMax..MidMax). Coarse is the rest to 1.</summary>
public const float MidTrack = 0.90f;
public static int Clamp( int size ) => Math.Clamp( size, Min, Max );
public static int StepFor( int size )
{
if ( size < FineMax )
return FineStep;
if ( size < MidMax )
return MidStep;
return CoarseStep;
}
public static int Nudge( int size, int direction )
{
if ( direction == 0 )
return Clamp( size );
size = Clamp( size );
int step = StepFor( size );
return Clamp( size + direction * step );
}
public static int SizeFromTrack( float t01 )
{
t01 = Math.Clamp( t01, 0f, 1f );
int raw;
if ( t01 <= FineTrack )
{
float u = FineTrack <= 0f ? 0f : t01 / FineTrack;
raw = (int)MathF.Round( Lerp( Min, FineMax, u ) );
}
else if ( t01 <= MidTrack )
{
float u = (t01 - FineTrack) / (MidTrack - FineTrack);
raw = (int)MathF.Round( Lerp( FineMax, MidMax, u ) );
}
else
{
float u = (t01 - MidTrack) / (1f - MidTrack);
raw = (int)MathF.Round( Lerp( MidMax, Max, u ) );
}
return Snap( raw );
}
public static float TrackFromSize( int size )
{
size = Clamp( size );
if ( size <= FineMax )
{
float u = (size - Min) / (float)(FineMax - Min);
return u * FineTrack;
}
if ( size <= MidMax )
{
float u = (size - FineMax) / (float)(MidMax - FineMax);
return FineTrack + u * (MidTrack - FineTrack);
}
{
float u = (size - MidMax) / (float)(Max - MidMax);
return MidTrack + u * (1f - MidTrack);
}
}
public static int Snap( int size )
{
size = Clamp( size );
int step = StepFor( size );
int snapped = (int)MathF.Round( size / (float)step ) * step;
return Clamp( snapped );
}
static float Lerp( float a, float b, float t ) => a + (b - a) * Math.Clamp( t, 0f, 1f );
}
Editor
library
// Copyright (c) 2026 SubZero Studios LLC. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
using Editor.TerrainEditor;
namespace Editor.SubTerrain;
/// <summary>
/// SubTerrain editor tool. Extends the built-in terrain workflow with larger brushes,
/// slope/path/set-height sculpt modes, and paint texture support.
/// </summary>
[EditorTool( "tools.subterrain" )]
[Title( "SubTerrain" )]
[Icon( "terrain" )]
[Alias( "subterrain" )]
[Group( "Scene" )]
public class SubTerrainTool : EditorTool
{
public static SubTerrainTool ActiveInstance { get; private set; }
public SubTerrainBrushSettings BrushSettings { get; private set; } = SubTerrainBrushStore.ForMode( nameof( SubRaiseLowerTool ) );
public SubTerrainPaintSettings PaintSettings { get; private set; } = new();
public bool PaintArmed { get; set; }
string _brushMode = nameof( SubRaiseLowerTool );
BrushPreviewSceneObject _previewObject;
SubTerrainStatusWidget _statusWidget;
Widget _opacityWidget;
Widget _speedWidget;
Widget _rotationWidget;
Widget _brushSheetHost;
SubTerrainBrushSizeWidget _sizeWidget;
Widget _sidebar;
Widget _textureSection;
Widget _statusSection;
SubTerrainBlendWidget _blendWidget;
Label _intentLabel;
public Terrain PeekTerrain()
{
if ( StatusTerrain.IsValid() )
return StatusTerrain;
return GetSelectedComponent<Terrain>();
}
public void SetBrushMode( string key )
{
if ( string.IsNullOrEmpty( key ) )
key = nameof( SubRaiseLowerTool );
var next = SubTerrainBrushStore.ForMode( key );
bool same = _brushMode == key && BrushSettings == next;
_brushMode = key;
BrushSettings = next;
if ( !same )
{
RebuildBrushSheet();
UpdateIntent();
}
UpdateOpacityState();
}
public void RefreshPaintChrome()
{
if ( _textureSection is not null )
_textureSection.Visible = PaintArmed;
if ( _blendWidget is not null )
_blendWidget.Visible = PaintArmed;
if ( _statusSection is not null )
_statusSection.Visible = !PaintArmed;
UpdateOpacityState();
UpdateIntent();
}
public void UpdateIntent()
{
if ( _intentLabel is null )
return;
if ( PaintArmed )
{
_intentLabel.Text = "Paint Texture.";
_intentLabel.ToolTip = "Alt sample | Ctrl fade | Shift smooth | ring yellow/green = Alt/Shift. Modes and size keys are in Shortcuts below.";
return;
}
_intentLabel.Text = IntentForSculpt();
_intentLabel.ToolTip = IntentTooltipForSculpt();
}
string IntentForSculpt()
{
return _brushMode switch
{
nameof( SubSmoothTool ) => "Smooth height.",
nameof( SubPlainerTool ) => "Plainer: broad weighted leveling.",
nameof( SubFlattenTool ) => "Flatten to click Z.",
nameof( SubSlopeTool ) => "Slope: click start, drag to end.",
nameof( SubPathTool ) => "Path: constant elevation strip.",
nameof( SubSetHeightTool ) => "Flatten to Target Height.",
nameof( SubNoiseTool ) => "Noise bump. Ctrl inverts.",
nameof( SubHoleTool ) => "Punch hole. Ctrl fills.",
nameof( SubBridgeTool ) => "Bridge: press start, release on end.",
_ => "Raise height. Ctrl lowers."
};
}
string IntentTooltipForSculpt()
{
var tip = _brushMode switch
{
nameof( SubPathTool ) => "Height-only strip. Control-map Path is Paint Texture (Ctrl+3).",
nameof( SubSetHeightTool ) => "Alt-click loads Z into Target Height.",
nameof( SubPlainerTool ) => "Like Hammer displacement smoothing across a wide brush.",
nameof( SubBridgeTool ) => "Deck sits on the line between the two picked heights. Shortcut B.",
_ => null
};
var keys = "Left / Right or Ctrl+[ / ] brush size. N activates SubTerrain. Keys 1-9, 0 Plainer, B Bridge.";
return tip is null ? keys : $"{tip} {keys}";
}
/// <summary>
/// Scene view destroys sidebar children on every subtool switch. Unparent first
/// from subtool OnDisabled so we can reuse one panel and avoid width/content flash.
/// </summary>
public void DetachSidebarForReuse()
{
if ( _sidebar is not null && _sidebar.IsValid() )
_sidebar.Parent = null;
}
public void NudgeBrushSize( int direction )
{
BrushSettings.Size = SubTerrainBrushSize.Nudge( BrushSettings.Size, direction );
SyncBrushSizeWidget();
}
/// <summary>
/// Sculpt modes only. Paint Texture keeps Up / Down for tmat selection.
/// </summary>
public bool NudgeBrushOpacity( int direction )
{
if ( PaintArmed || direction == 0 )
return false;
const float step = 0.05f;
BrushSettings.Opacity = Math.Clamp( BrushSettings.Opacity + direction * step, 0f, 1f );
RebuildBrushSheet();
return true;
}
public void SyncBrushSizeWidget()
{
_sizeWidget?.SyncFromSettings();
}
public void SyncBrushRotationRows()
{
if ( _rotationWidget is not null )
_rotationWidget.Enabled = !BrushSettings.RandomRotation;
}
public bool TrySetPaintLayer( SubTerrainPaintLayer layer )
{
if ( !PaintArmed || _blendWidget is null )
return false;
if ( layer == SubTerrainPaintLayer.Base )
_blendWidget.SelectGround();
else if ( layer == SubTerrainPaintLayer.Overlay )
_blendWidget.SelectWear();
else
_blendWidget.SelectPath();
return true;
}
/// <summary>
/// Cycle selected paint tmat. Path skips the locked ground row (that role is Alt-click).
/// </summary>
public bool NudgePaintMaterial( int delta )
{
if ( !PaintArmed || delta == 0 )
return false;
var terrain = PeekTerrain();
var materials = terrain?.Storage?.Materials;
int count = materials?.Count ?? 0;
if ( count <= 0 )
return false;
int skip = -1;
if ( PaintSettings.Layer == SubTerrainPaintLayer.Path )
skip = SubTerrainSplatUtil.FindPathGround( terrain, PaintSettings );
int current = PaintSettings.MaterialIndex;
if ( current < 0 || current >= count )
current = 0;
for ( int step = 0; step < count; step++ )
{
current = (current + delta) % count;
if ( current < 0 )
current += count;
if ( current == skip )
continue;
PaintSettings.MaterialIndex = current;
SubTerrainPaintTool.TrySetSplatChannel( current );
_blendWidget?.OnMaterialSelectionChanged();
return true;
}
return false;
}
public bool StatusValid { get; private set; }
public Terrain StatusTerrain { get; private set; }
public Vector3 StatusHitLocal { get; private set; }
public static BrushList BrushList => TerrainEditorTool.BrushList;
public Texture SelectedBrushTexture => BrushList?.Selected?.Texture;
public void UpdateStatus( Terrain terrain, Vector3 hitLocal )
{
StatusValid = true;
StatusTerrain = terrain;
StatusHitLocal = hitLocal;
}
public void ClearStatus()
{
StatusValid = false;
StatusTerrain = default;
}
public override IEnumerable<EditorTool> GetSubtools()
{
yield return new SubRaiseLowerTool( this );
yield return new SubSmoothTool( this );
yield return new SubPlainerTool( this );
yield return new SubFlattenTool( this );
yield return new SubTerrainPaintTool( this );
yield return new SubSlopeTool( this );
yield return new SubPathTool( this );
yield return new SubSetHeightTool( this );
yield return new SubNoiseTool( this );
yield return new SubHoleTool( this );
yield return new SubBridgeTool( this );
}
public override void OnEnabled()
{
ActiveInstance = this;
AllowGameObjectSelection = false;
RebuildSidebarOnSelectionChange = false;
var selectedTerrain = GetSelectedComponent<Terrain>();
if ( !selectedTerrain.IsValid() )
{
Selection.Clear();
var first = Scene.GetAllComponents<Terrain>().FirstOrDefault();
if ( first.IsValid() ) Selection.Add( first.GameObject );
}
}
public override void OnDisabled()
{
if ( ActiveInstance == this )
ActiveInstance = null;
if ( _sidebar is not null && _sidebar.IsValid() )
{
_sidebar.Parent = null;
_sidebar.Destroy();
}
_sidebar = null;
_blendWidget = null;
_intentLabel = null;
_brushSheetHost = null;
_sizeWidget = null;
_opacityWidget = null;
_rotationWidget = null;
_textureSection = null;
_statusSection = null;
_statusWidget = null;
_previewObject?.Delete();
_previewObject = null;
ClearStatus();
}
public override Widget CreateShortcutsWidget() => new SubTerrainToolShortcutsWidget();
[Shortcut( "tools.subterrain", "n", typeof( SceneViewWidget ) )]
public static void ActivateTool()
{
EditorToolManager.SetTool( nameof( SubTerrainTool ) );
}
public void DrawBrushPreview( Transform transform, Terrain terrain = null )
{
var brush = BrushList?.Selected?.Texture;
if ( brush is null )
return;
_previewObject ??= new BrushPreviewSceneObject( Gizmo.World );
var color = Color.FromBytes( 150, 150, 250 );
var mods = Application.KeyboardModifiers;
if ( mods.HasFlag( KeyboardModifiers.Alt ) )
color = Color.FromBytes( 220, 200, 80 );
else if ( mods.HasFlag( KeyboardModifiers.Shift ) )
color = Color.FromBytes( 120, 200, 140 );
else if ( mods.HasFlag( KeyboardModifiers.Ctrl ) )
color = color.AdjustHue( 90 );
color.a = BrushSettings.Opacity;
_previewObject.RenderLayer = SceneRenderLayer.OverlayWithDepth;
_previewObject.Bounds = BBox.FromPositionAndSize( 0, float.MaxValue );
_previewObject.Transform = transform;
_previewObject.Radius = BrushSettings.Size;
_previewObject.Texture = brush;
_previewObject.Color = color;
_previewObject.BrushRotation = BrushSettings.Rotation;
terrain ??= PeekTerrain();
if ( BrushSettings.ShowGridPreview && terrain?.Storage is not null )
{
var tx = terrain.WorldTransform;
_previewObject.CellSize = terrain.Storage.TerrainSize / terrain.Storage.Resolution;
_previewObject.TerrainOrigin = tx.Position;
_previewObject.TerrainRight = tx.Rotation.Right;
_previewObject.TerrainForward = tx.Rotation.Forward;
}
else
{
_previewObject.CellSize = 0f;
}
}
public override Widget CreateToolSidebar()
{
// Sidebar reuse: detach in OnDisabled before the scene view Clears the host.
if ( _sidebar is ToolSidebarWidget existing && existing.IsValid() )
{
existing.Parent = null;
RebuildBrushSheet();
RefreshPaintChrome();
return existing;
}
var sidebar = new ToolSidebarWidget();
_sidebar = sidebar;
sidebar.AddTitle( "SubTerrain", "terrain" );
sidebar.ToolTip = $"SubTerrain {SubTerrainPaintSettings.LibraryVersion}";
_intentLabel = sidebar.Layout.Add( new Label( "" ) );
_intentLabel.Color = Theme.TextControl;
sidebar.Layout.AddSpacingCell( 6 );
_textureSection = new Widget( sidebar );
_textureSection.Layout = Layout.Column();
_blendWidget = new SubTerrainBlendWidget( this, _textureSection );
_textureSection.Layout.Add( _blendWidget );
sidebar.Layout.Add( _textureSection );
_textureSection.Visible = false;
{
var group = sidebar.AddGroup( "Brush Type" );
group.Add( new SubTerrainBrushPreviewWidget( sidebar ) );
}
{
var group = sidebar.AddGroup( "Brush Properties" );
_brushSheetHost = new Widget( sidebar );
_brushSheetHost.Layout = Layout.Column();
group.Add( _brushSheetHost );
RebuildBrushSheet();
}
UpdateOpacityState();
if ( !GetSelectedComponent<Terrain>().IsValid() )
{
var group = sidebar.AddGroup( "No Terrain" );
group.Add( new Label( "Select a GameObject with a Terrain component." ) );
}
{
var cursorLayout = sidebar.AddGroup( "Cursor" );
_statusWidget = new SubTerrainStatusWidget( this, sidebar );
cursorLayout.Add( _statusWidget );
_statusSection = FindSidebarGroupHost( _statusWidget );
}
sidebar.AddShortcuts(
("Activate SubTerrain", "N"),
("Sculpt / paint tools", "1 - 9"),
("Plainer / Bridge", "0 / B"),
("Paint: Ground / Wear / Path", "Ctrl+1 / 2 / 3"),
("Paint tmat prev / next", "Up / Down"),
("Opacity (sculpt)", "Up / Down"),
("Brush size", "Left / Right | Ctrl+[ / ]"),
("Size / opacity", "MMB+Shift drag"),
("Brush rotation", "MMB+Ctrl drag"),
("Sample / fade / smooth", "Alt / Ctrl / Shift")
);
sidebar.Layout.AddStretchCell();
UpdateIntent();
return sidebar;
}
static Widget FindSidebarGroupHost( Widget child )
{
for ( var p = child?.Parent; p is not null; p = p.Parent )
{
if ( p.GetType().Name == "SidebarGroupWidget" )
return p;
}
return child?.Parent;
}
[EditorEvent.Frame]
public void TickSidebarChrome()
{
if ( ActiveInstance != this )
return;
if ( !PaintArmed )
UpdateIntent();
}
void RebuildBrushSheet()
{
if ( _brushSheetHost is null )
return;
_brushSheetHost.Layout.Clear( true );
_sizeWidget = null;
_opacityWidget = null;
_speedWidget = null;
_rotationWidget = null;
_sizeWidget = new SubTerrainBrushSizeWidget( this, _brushSheetHost );
_brushSheetHost.Layout.Add( _sizeWidget );
var so = BrushSettings.GetSerialized();
var sheet = new ControlSheet();
_opacityWidget = sheet.AddRow( so.GetProperty( nameof( SubTerrainBrushSettings.Opacity ) ) );
if ( _brushMode == nameof( SubRaiseLowerTool ) )
_speedWidget = sheet.AddRow( so.GetProperty( nameof( SubTerrainBrushSettings.Speed ) ) );
_rotationWidget = sheet.AddRow( so.GetProperty( nameof( SubTerrainBrushSettings.Rotation ) ) );
var rndRot = sheet.AddRow( so.GetProperty( nameof( SubTerrainBrushSettings.RandomRotation ) ) );
sheet.AddRow( so.GetProperty( nameof( SubTerrainBrushSettings.ShowGridPreview ) ) );
if ( _brushMode == nameof( SubSetHeightTool ) )
sheet.AddRow( so.GetProperty( nameof( SubTerrainBrushSettings.TargetHeight ) ) );
if ( _brushMode == nameof( SubBridgeTool ) )
{
sheet.AddRow( so.GetProperty( nameof( SubTerrainBrushSettings.BridgeWidth ) ) );
sheet.AddRow( so.GetProperty( nameof( SubTerrainBrushSettings.BridgeEdgeFalloff ) ) );
sheet.AddRow( so.GetProperty( nameof( SubTerrainBrushSettings.BridgeRaiseOnly ) ) );
}
so.OnPropertyChanged += OnBrushSheetPropertyChanged;
_brushSheetHost.Layout.Add( sheet );
SyncBrushRotationRows();
UpdateOpacityState();
}
void OnBrushSheetPropertyChanged( SerializedProperty prop )
{
if ( prop?.Name == nameof( SubTerrainBrushSettings.RandomRotation ) )
SyncBrushRotationRows();
}
void UpdateOpacityState()
{
if ( _speedWidget is not null )
_speedWidget.ToolTip = "How fast height accumulates. Hold still to keep raising. Higher = more stamps and more height per stamp.";
if ( _opacityWidget is null )
return;
if ( !PaintArmed )
{
bool bridge = _brushMode == nameof( SubBridgeTool );
_opacityWidget.Enabled = !bridge;
_opacityWidget.ToolTip = bridge
? "Bridge always builds at the exact start and end heights."
: OpacityTipForSculpt();
return;
}
var layer = PaintSettings.Layer;
if ( layer == SubTerrainPaintLayer.Overlay )
{
_opacityWidget.Enabled = true;
_opacityWidget.ToolTip = "How fast wear builds on this stroke. Ctrl reduces.";
return;
}
_opacityWidget.Enabled = false;
_opacityWidget.ToolTip = layer == SubTerrainPaintLayer.Path
? "Path uses Center mix and Core for the apron / solid ribbon. Pack center optional. Soft circle or stroke."
: "Ground paint lays a solid tmat. Mix is cleared.";
}
string OpacityTipForSculpt()
{
return _brushMode switch
{
nameof( SubRaiseLowerTool ) => "Brush soft strength. Ctrl lowers. Use Speed for how fast height builds.",
nameof( SubHoleTool ) => "How fast the punch cuts. Ctrl fills the hole back.",
nameof( SubNoiseTool ) => "How strong the bump is. Ctrl inverts.",
nameof( SubFlattenTool ) => "How fast it levels to the click height.",
nameof( SubSetHeightTool ) => "How fast it levels to Target Height.",
nameof( SubSlopeTool ) => "How fast it grades to the ramp.",
nameof( SubPathTool ) => "How fast it levels to the click height.",
nameof( SubSmoothTool ) => "How strong the smooth is.",
nameof( SubPlainerTool ) => "How strong the weighted leveling is.",
_ => "Stroke strength."
};
}
}
[Title( "Raise / Lower" )]
[Description( "Paint height up. Hold Ctrl to lower." )]
[Icon( "height" )]
[Alias( "subterrain_raise_lower" )]
[Group( "1" )]
[Order( 0 )]
public class SubRaiseLowerTool : SubTerrainBaseBrushTool
{
public SubRaiseLowerTool( SubTerrainTool parent ) : base( parent )
{
Mode = SubTerrainSculptMode.RaiseLower;
AllowBrushInvert = true;
}
}
[Title( "Flatten" )]
[Description( "Level to the height under the click. Stays on that plane while you drag." )]
[Icon( "trending_flat" )]
[Alias( "subterrain_flatten" )]
[Group( "1" )]
[Order( 3 )]
public class SubFlattenTool : SubTerrainBaseBrushTool
{
public SubFlattenTool( SubTerrainTool parent ) : base( parent )
{
Mode = SubTerrainSculptMode.Flatten;
}
public override bool GetHitPosition( Terrain terrain, out Vector3 position )
{
if ( _dragging )
{
var tx = terrain.WorldTransform;
var hit = StrokePlane.Trace( Gizmo.CurrentRay, true );
position = tx.PointToLocal( hit.Value );
return hit.HasValue;
}
return base.GetHitPosition( terrain, out position );
}
}
[Title( "Smooth" )]
[Description( "Blend neighbouring heights." )]
[Icon( "rounded_corner" )]
[Alias( "subterrain_smooth" )]
[Group( "1" )]
[Order( 1 )]
public class SubSmoothTool : SubTerrainBaseBrushTool
{
public SubSmoothTool( SubTerrainTool parent ) : base( parent )
{
Mode = SubTerrainSculptMode.Smooth;
}
public override bool GetHitPosition( Terrain terrain, out Vector3 position )
{
if ( _dragging )
{
var tx = terrain.WorldTransform;
var hit = StrokePlane.Trace( Gizmo.CurrentRay, true );
position = tx.PointToLocal( hit.Value );
return hit.HasValue;
}
return base.GetHitPosition( terrain, out position );
}
}
[Title( "Plainer" )]
[Description( "Gradually level a broad weighted area, like Hammer displacement smoothing." )]
[Icon( "blur_on" )]
[Alias( "subterrain_plainer" )]
[Group( "1" )]
[Order( 2 )]
public class SubPlainerTool : SubTerrainBaseBrushTool
{
public SubPlainerTool( SubTerrainTool parent ) : base( parent )
{
Mode = SubTerrainSculptMode.Plainer;
}
public override bool GetHitPosition( Terrain terrain, out Vector3 position )
{
if ( _dragging )
{
var tx = terrain.WorldTransform;
var hit = StrokePlane.Trace( Gizmo.CurrentRay, true );
position = tx.PointToLocal( hit.Value );
return hit.HasValue;
}
return base.GetHitPosition( terrain, out position );
}
}
[Title( "Noise" )]
[Description( "Add bump. Hold Ctrl to invert." )]
[Icon( "grain" )]
[Alias( "subterrain_noise" )]
[Group( "1" )]
[Order( 8 )]
public class SubNoiseTool : SubTerrainBaseBrushTool
{
public SubNoiseTool( SubTerrainTool parent ) : base( parent )
{
Mode = SubTerrainSculptMode.Noise;
AllowBrushInvert = true;
}
}
[Title( "Hole" )]
[Description( "Punch through the surface. Hold Ctrl to fill the hole back." )]
[Icon( "trip_origin" )]
[Alias( "subterrain_hole" )]
[Group( "1" )]
[Order( 9 )]
public class SubHoleTool : SubTerrainBaseBrushTool
{
public SubHoleTool( SubTerrainTool parent ) : base( parent )
{
Mode = SubTerrainSculptMode.Hole;
AllowBrushInvert = true;
}
}
internal class SubTerrainBrushPreviewWidget : Widget
{
public SubTerrainBrushPreviewWidget( Widget parent ) : base( parent )
{
MinimumSize = new( 32, 32 );
MaximumSize = new( 48, 48 );
Cursor = CursorShape.Finger;
}
protected override void OnPaint()
{
base.OnPaint();
Paint.Antialiasing = true;
Paint.ClearPen();
Paint.DrawRect( LocalRect );
var pixmap = SubTerrainTool.BrushList?.Selected?.Pixmap;
if ( pixmap != null )
{
Paint.Draw( LocalRect.Contain( pixmap.Size ), pixmap );
}
}
protected override void OnMouseClick( MouseEvent e )
{
var popup = new PopupWidget( null );
popup.Position = Application.CursorPosition;
popup.Visible = true;
popup.Layout = Layout.Column();
popup.Layout.Margin = 10;
popup.MaximumSize = new Vector2( 300, 200 );
var list = new SubTerrainBrushListWidget();
list.BrushSelected += () =>
{
popup.Close();
Update();
};
popup.Layout.Add( list );
}
[EditorEvent.Frame]
public void UpdatePreview()
{
Update();
}
}
internal class SubTerrainBrushListWidget : Widget
{
public Action BrushSelected { get; set; }
public SubTerrainBrushListWidget() : base( null )
{
ListView list = new();
list.SetItems( SubTerrainTool.BrushList.Brushes.Cast<object>() );
list.ItemSize = new Vector2( 40, 40 );
list.ItemAlign = Sandbox.UI.Align.SpaceBetween;
list.OnPaintOverride += () => PaintListBackground( list );
list.ItemPaint = PaintBrushItem;
list.ItemSelected = item =>
{
if ( item is Brush brush )
{
SubTerrainTool.BrushList.Selected = brush;
BrushSelected?.Invoke();
}
};
list.SelectItem( SubTerrainTool.BrushList.Selected );
Layout = Layout.Column();
var label = new Label( "Brushes" );
label.SetStyles( "font-weight: bold" );
Layout.Add( label );
Layout.AddSpacingCell( 8 );
Layout.Add( list );
}
private void PaintBrushItem( VirtualWidget widget )
{
var brush = (Brush)widget.Object;
Paint.Antialiasing = true;
Paint.TextAntialiasing = true;
if ( widget.Hovered || widget.Selected )
{
Paint.ClearPen();
Paint.SetBrush( widget.Selected ? Theme.Primary : Color.White.WithAlpha( 0.1f ) );
Paint.DrawRect( widget.Rect.Grow( 2 ), 3 );
}
Paint.Draw( widget.Rect, brush.Pixmap );
}
private bool PaintListBackground( Widget widget )
{
Paint.ClearPen();
Paint.SetBrush( Theme.ControlBackground );
Paint.DrawRect( widget.LocalRect );
return false;
}
}
internal class SubTerrainToolShortcutsWidget : Widget
{
[Shortcut( "tools.subterrain.raise-lower", "1", typeof( SceneViewWidget ) )]
public void ActivateRaiseLower() => EditorToolManager.SetSubTool( nameof( SubRaiseLowerTool ) );
[Shortcut( "tools.subterrain.smooth", "2", typeof( SceneViewWidget ) )]
public void ActivateSmooth() => EditorToolManager.SetSubTool( nameof( SubSmoothTool ) );
[Shortcut( "tools.subterrain.flatten", "3", typeof( SceneViewWidget ) )]
public void ActivateFlatten() => EditorToolManager.SetSubTool( nameof( SubFlattenTool ) );
[Shortcut( "tools.subterrain.paint", "4", typeof( SceneViewWidget ) )]
public void ActivatePaint() => EditorToolManager.SetSubTool( nameof( SubTerrainPaintTool ) );
[Shortcut( "tools.subterrain.slope", "5", typeof( SceneViewWidget ) )]
public void ActivateSlope() => EditorToolManager.SetSubTool( nameof( SubSlopeTool ) );
[Shortcut( "tools.subterrain.path", "6", typeof( SceneViewWidget ) )]
public void ActivatePath() => EditorToolManager.SetSubTool( nameof( SubPathTool ) );
[Shortcut( "tools.subterrain.set-height", "7", typeof( SceneViewWidget ) )]
public void ActivateSetHeight() => EditorToolManager.SetSubTool( nameof( SubSetHeightTool ) );
[Shortcut( "tools.subterrain.noise", "8", typeof( SceneViewWidget ) )]
public void ActivateNoise() => EditorToolManager.SetSubTool( nameof( SubNoiseTool ) );
[Shortcut( "tools.subterrain.hole", "9", typeof( SceneViewWidget ) )]
public void ActivateHole() => EditorToolManager.SetSubTool( nameof( SubHoleTool ) );
[Shortcut( "tools.subterrain.plainer", "0", typeof( SceneViewWidget ) )]
public void ActivatePlainer() => EditorToolManager.SetSubTool( nameof( SubPlainerTool ) );
[Shortcut( "tools.subterrain.bridge", "B", typeof( SceneViewWidget ) )]
public void ActivateBridge() => EditorToolManager.SetSubTool( nameof( SubBridgeTool ) );
[Shortcut( "tools.subterrain.paint-ground", "CTRL+1", typeof( SceneViewWidget ) )]
public void PaintGround() => SubTerrainTool.ActiveInstance?.TrySetPaintLayer( SubTerrainPaintLayer.Base );
[Shortcut( "tools.subterrain.paint-wear", "CTRL+2", typeof( SceneViewWidget ) )]
public void PaintWear() => SubTerrainTool.ActiveInstance?.TrySetPaintLayer( SubTerrainPaintLayer.Overlay );
[Shortcut( "tools.subterrain.paint-path", "CTRL+3", typeof( SceneViewWidget ) )]
public void PaintPath() => SubTerrainTool.ActiveInstance?.TrySetPaintLayer( SubTerrainPaintLayer.Path );
[Shortcut( "tools.subterrain.brush-smaller", "CTRL+[", typeof( SceneViewWidget ) )]
public void BrushSmaller() => SubTerrainTool.ActiveInstance?.NudgeBrushSize( -1 );
[Shortcut( "tools.subterrain.brush-larger", "CTRL+]", typeof( SceneViewWidget ) )]
public void BrushLarger() => SubTerrainTool.ActiveInstance?.NudgeBrushSize( 1 );
[Shortcut( "tools.subterrain.brush-smaller-arrow", "LEFT", typeof( SceneViewWidget ) )]
public void BrushSmallerArrow()
{
if ( SubTerrainTool.ActiveInstance is not null )
{
SubTerrainTool.ActiveInstance.NudgeBrushSize( -1 );
return;
}
EditorShortcuts.PassShortcut = true;
}
[Shortcut( "tools.subterrain.brush-larger-arrow", "RIGHT", typeof( SceneViewWidget ) )]
public void BrushLargerArrow()
{
if ( SubTerrainTool.ActiveInstance is not null )
{
SubTerrainTool.ActiveInstance.NudgeBrushSize( 1 );
return;
}
EditorShortcuts.PassShortcut = true;
}
[Shortcut( "tools.subterrain.paint-tmat-prev", "UP", typeof( SceneViewWidget ) )]
public void ArrowUp()
{
var tool = SubTerrainTool.ActiveInstance;
if ( tool is null )
{
EditorShortcuts.PassShortcut = true;
return;
}
if ( tool.PaintArmed )
{
if ( tool.NudgePaintMaterial( -1 ) )
return;
EditorShortcuts.PassShortcut = true;
return;
}
tool.NudgeBrushOpacity( 1 );
}
[Shortcut( "tools.subterrain.paint-tmat-next", "DOWN", typeof( SceneViewWidget ) )]
public void ArrowDown()
{
var tool = SubTerrainTool.ActiveInstance;
if ( tool is null )
{
EditorShortcuts.PassShortcut = true;
return;
}
if ( tool.PaintArmed )
{
if ( tool.NudgePaintMaterial( 1 ) )
return;
EditorShortcuts.PassShortcut = true;
return;
}
tool.NudgeBrushOpacity( -1 );
}
}
Editor
library
// Copyright (c) 2026 SubZero Studios LLC. All rights reserved.
using System;
using Sandbox;
namespace Editor.SubTerrain;
/// <summary>
/// Brush Size row: piecewise track (fine under 3500, mid to 18000, coarse to 35000) plus type-in.
/// </summary>
internal sealed class SubTerrainBrushSizeWidget : Widget
{
readonly SubTerrainTool _tool;
readonly SubTerrainBrushSizeTrack _track;
readonly LineEdit _field;
bool _syncing;
public SubTerrainBrushSizeWidget( SubTerrainTool tool, Widget parent ) : base( parent )
{
_tool = tool;
Layout = Layout.Row();
Layout.Spacing = 6;
Layout.Margin = new Sandbox.UI.Margin( 0, 2, 10, 2 );
var label = Layout.Add( new Label( "Size" ) );
label.MinimumWidth = 72;
label.Color = Theme.TextControl;
label.ToolTip = "World units. Fine under 3500 (step 50), mid to 18000 (step 500), then to 35000 (step 1000). Left / Right or Ctrl+[ / ] steps.";
_track = new SubTerrainBrushSizeTrack( this );
Layout.Add( _track, 1 );
_field = new LineEdit();
_field.MinimumWidth = 52;
_field.MaximumWidth = 56;
_field.ToolTip = "Type an exact size (8-35000).";
_field.EditingFinished += OnFieldFinished;
Layout.Add( _field );
FixedHeight = 28;
MinimumHeight = 28;
MaximumHeight = 28;
SyncFromSettings();
}
public void SyncFromSettings()
{
if ( _tool?.BrushSettings is null )
return;
_syncing = true;
int size = SubTerrainBrushSize.Clamp( _tool.BrushSettings.Size );
_tool.BrushSettings.Size = size;
_field.Text = size.ToString();
_track.Update();
_syncing = false;
}
public void SetSizeFromTrack( float t01 )
{
if ( _syncing || _tool?.BrushSettings is null )
return;
int size = SubTerrainBrushSize.SizeFromTrack( t01 );
if ( size == _tool.BrushSettings.Size )
{
_track.Update();
return;
}
_tool.BrushSettings.Size = size;
_syncing = true;
_field.Text = size.ToString();
_syncing = false;
_track.Update();
}
void OnFieldFinished()
{
if ( _syncing || _tool?.BrushSettings is null )
return;
if ( !int.TryParse( _field.Text.Trim(), out int parsed ) )
{
SyncFromSettings();
return;
}
_tool.BrushSettings.Size = SubTerrainBrushSize.Clamp( parsed );
SyncFromSettings();
}
}
internal sealed class SubTerrainBrushSizeTrack : Widget
{
readonly SubTerrainBrushSizeWidget _host;
bool _dragging;
public SubTerrainBrushSizeTrack( SubTerrainBrushSizeWidget host ) : base( host )
{
_host = host;
MouseTracking = true;
Cursor = CursorShape.Finger;
MinimumHeight = 22;
MaximumHeight = 22;
ToolTip = "Drag: fine under 3500, mid to 18000, coarse to 35000. Left / Right or Ctrl+[ / ] use the same steps.";
}
protected override void OnMousePress( MouseEvent e )
{
if ( e.Button == MouseButtons.Left )
{
_dragging = true;
ApplyAt( e.LocalPosition.x );
e.Accepted = true;
return;
}
base.OnMousePress( e );
}
protected override void OnMouseMove( MouseEvent e )
{
base.OnMouseMove( e );
if ( _dragging )
ApplyAt( e.LocalPosition.x );
}
protected override void OnMouseReleased( MouseEvent e )
{
if ( e.Button == MouseButtons.Left )
_dragging = false;
base.OnMouseReleased( e );
}
void ApplyAt( float x )
{
float w = Math.Max( Width - 8f, 1f );
float t = Math.Clamp( (x - 4f) / w, 0f, 1f );
_host.SetSizeFromTrack( t );
}
protected override void OnPaint()
{
base.OnPaint();
Paint.Antialiasing = true;
Paint.ClearPen();
var track = new Rect( 4, Height * 0.5f - 2f, Math.Max( Width - 8f, 1f ), 4f );
Paint.SetBrush( Color.White.WithAlpha( 0.08f ) );
Paint.DrawRect( track, 2 );
// Piecewise band ticks (fine / mid).
float fineX = track.Left + track.Width * SubTerrainBrushSize.FineTrack;
float midX = track.Left + track.Width * SubTerrainBrushSize.MidTrack;
Paint.SetBrush( Color.White.WithAlpha( 0.14f ) );
Paint.DrawRect( new Rect( fineX, track.Top - 2f, 1f, track.Height + 4f ) );
Paint.DrawRect( new Rect( midX, track.Top - 2f, 1f, track.Height + 4f ) );
var tool = SubTerrainTool.ActiveInstance;
int size = tool?.BrushSettings is not null
? SubTerrainBrushSize.Clamp( tool.BrushSettings.Size )
: SubTerrainBrushSize.Min;
float t = SubTerrainBrushSize.TrackFromSize( size );
float thumbX = track.Left + track.Width * t;
float thumbR = 6f;
Paint.SetBrush( Theme.Primary );
Paint.DrawCircle( new Vector2( thumbX, Height * 0.5f ), thumbR );
}
}
Debug: View Raw JSON Response
{
"TotalCount": 33,
"Files": [
{
"Ident": "subzerostudios.subterrain-tool",
"Path": "Editor/SubTerrainSculptMode.cs",
"FileName": "SubTerrainSculptMode.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 344638,
"Code": "// Copyright (c) 2026 SubZero Studios LLC. All rights reserved.\r\n\r\nnamespace Editor.SubTerrain;\r\n\r\n/// <summary>\r\n/// Sculpt modes for the SubTerrain compute shader.\r\n/// Values 0-4 match the engine shader; 5-8 are SubTerrain extensions.\r\n/// </summary>\r\npublic enum SubTerrainSculptMode\r\n{\r\n\tRaiseLower = 0,\r\n\tFlatten = 1,\r\n\tSmooth = 2,\r\n\tHole = 3,\r\n\tNoise = 4,\r\n\tSlope = 5,\r\n\tPath = 6,\r\n\tPlainer = 7,\r\n\tBridge = 8\r\n}\r\n"
},
{
"Ident": "subzerostudios.subterrain-tool",
"Path": ".obj/__compiler_extra.cs",
"FileName": "__compiler_extra.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 344638,
"Code": "global using static Sandbox.Internal.GlobalGameNamespace;\r\nglobal using Microsoft.AspNetCore.Components;\r\nglobal using Microsoft.AspNetCore.Components.Rendering;\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"AddonTitle\", \"SubTerrain Tool\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"AddonIdent\", \"subterrain-tool\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"OrgIdent\", \"subzerostudios\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"Ident\", \"subzerostudios.subterrain-tool\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"EngineVersion\", \"28\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"EngineMinorVersion\", \"1\" )]\r\n\r\n[assembly: System.Runtime.Versioning.TargetFramework( \".NETCoreApp,Version=v9.0\", FrameworkDisplayName = \".NET 9.0\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"CompileTime\", \"2026-08-24T22:24:58.1339886Z\" )]\r\n[assembly: global::System.Reflection.AssemblyVersion(\"0.0.165.0\")]\r\n[assembly: global::System.Reflection.AssemblyFileVersion(\"0.0.165.0\")]"
},
{
"Ident": "subzerostudios.subterrain-tool",
"Path": "Code/TerrainEditRequest.cs",
"FileName": "TerrainEditRequest.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 344638,
"Code": "// Copyright (c) 2026 SubZero Studios LLC. All rights reserved.\r\n\r\nusing Sandbox;\r\n\r\nnamespace SubZero.SubTerrain;\r\n\r\npublic struct TerrainEditRequest\r\n{\r\n\tpublic TerrainEditKind Kind;\r\n\tpublic Vector3 WorldPos;\r\n\tpublic float Radius;\r\n\r\n\t/// <summary>0 = kind auto-scale (radius \u00d7 DepthScale, clamped).</summary>\r\n\tpublic float Depth;\r\n\r\n\t/// <summary>Unused this pass. Reserved for a later trench (capsule SDF).</summary>\r\n\tpublic Vector3 EndPos;\r\n\r\n\tpublic bool HasTargetZ;\r\n\tpublic float TargetZ;\r\n\tpublic int Seed;\r\n}\r\n\r\npublic struct TerrainEditResult\r\n{\r\n\tpublic TerrainEditKind Kind;\r\n\tpublic Vector3 Origin;\r\n\tpublic float Radius;\r\n\tpublic float Depth;\r\n\tpublic RectInt Dirty;\r\n\tpublic int Texels;\r\n\tpublic float VolumeRemoved;\r\n}\r\n"
},
{
"Ident": "subzerostudios.subterrain-tool",
"Path": "Editor/SubTerrainBlendWidget.cs",
"FileName": "SubTerrainBlendWidget.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 344638,
"Code": "// Copyright (c) 2026 SubZero Studios LLC. All rights reserved.\r\n\r\nusing System;\r\nusing Sandbox;\r\n\r\nnamespace Editor.SubTerrain;\r\n\r\n/// <summary>\r\n/// Paint Texture sidebar: tmat list, Ground / Wear / Path, Steep / All, live mix.\r\n/// </summary>\r\ninternal class SubTerrainBlendWidget : Widget\r\n{\r\n\treadonly SubTerrainTool _tool;\r\n\treadonly SubTerrainPaintModeStrip _modes;\r\n\treadonly SubTerrainPairPreview _pairPreview;\r\n\treadonly Widget _listHost;\r\n\treadonly Widget _optionsHost;\r\n\treadonly Label _live;\r\n\tWidget _limitRow;\r\n\tCheckbox _keepPairBox;\r\n\tCheckbox _sampledBox;\r\n\tCheckbox _packCenterBox;\r\n\tint _listedCount = -1;\r\n\tSubTerrainPaintLayer _sheetLayer = (SubTerrainPaintLayer)(-1);\r\n\tbool _sheetSteep;\r\n\r\n\tconst string IntentSuffix = \" Alt sample | Ctrl fade | Shift smooth | ring yellow/green = Alt/Shift.\";\r\n\r\n\tpublic SubTerrainBlendWidget( SubTerrainTool tool, Widget parent ) : base( parent )\r\n\t{\r\n\t\t_tool = tool;\r\n\t\tLayout = Layout.Column();\r\n\t\tLayout.Spacing = 4;\r\n\r\n\t\t_modes = Layout.Add( new SubTerrainPaintModeStrip( tool, this ) );\r\n\t\t_pairPreview = Layout.Add( new SubTerrainPairPreview( tool, this ) );\r\n\t\tSyncPairPreview();\r\n\t\t_listHost = Layout.Add( new Widget( this ) );\r\n\t\t_listHost.Layout = Layout.Column();\r\n\t\t_listHost.Layout.Spacing = 0;\r\n\t\t_optionsHost = Layout.Add( new Widget( this ) );\r\n\t\t_optionsHost.Layout = Layout.Column();\r\n\t\t_optionsHost.Layout.Spacing = 4;\r\n\t\t_live = Layout.Add( new Label( \"\" ) );\r\n\r\n\t\tRebuildList();\r\n\t\tRebuildOptions();\r\n\t\tRefreshCopy();\r\n\t}\r\n\r\n\tvoid OnGroundClicked() => SetLayer( SubTerrainPaintLayer.Base );\r\n\r\n\tvoid OnWearClicked() => SetLayer( SubTerrainPaintLayer.Overlay );\r\n\r\n\tvoid OnPathClicked()\r\n\t{\r\n\t\tvar terrain = _tool.PeekTerrain();\r\n\t\tint ground = SubTerrainSplatUtil.FindPathGround( terrain, _tool.PaintSettings );\r\n\t\tint wear = _tool.PaintSettings.MaterialIndex;\r\n\t\tif ( wear == ground )\r\n\t\t{\r\n\t\t\twear = SubTerrainSplatUtil.FindPathWear( terrain, ground );\r\n\t\t\t_tool.PaintSettings.MaterialIndex = wear;\r\n\t\t\tSubTerrainPaintTool.TrySetSplatChannel( wear );\r\n\t\t}\r\n\r\n\t\tSetLayer( SubTerrainPaintLayer.Path );\r\n\t}\r\n\r\n\tvoid SetLayer( SubTerrainPaintLayer layer )\r\n\t{\r\n\t\tSubTerrainPaintStore.Capture( _tool.PaintSettings );\r\n\t\tSubTerrainPaintStore.Apply( _tool.PaintSettings, layer );\r\n\t\tSubTerrainPaintTool.ActiveLayer = layer;\r\n\t\tRebuildOptions();\r\n\t\t_tool.RefreshPaintChrome();\r\n\t\tRefreshCopy();\r\n\t\t_modes.Update();\r\n\t\tSyncPairPreview();\r\n\t}\r\n\r\n\tvoid SyncPairPreview()\r\n\t{\r\n\t\tif ( _pairPreview is null )\r\n\t\t\treturn;\r\n\r\n\t\t// Ground writes one tmat - a pair thumb is dead chrome.\r\n\t\tbool show = _tool.PaintSettings.Layer != SubTerrainPaintLayer.Base;\r\n\t\t_pairPreview.Visible = show;\r\n\t\t_pairPreview.FixedHeight = show ? 36 : 0;\r\n\t\t_pairPreview.MinimumHeight = show ? 36 : 0;\r\n\t\t_pairPreview.MaximumHeight = show ? 36 : 0;\r\n\t\tif ( show )\r\n\t\t\t_pairPreview.Update();\r\n\t}\r\n\r\n\tinternal void SelectGround() => OnGroundClicked();\r\n\tinternal void SelectWear() => OnWearClicked();\r\n\tinternal void SelectPath() => OnPathClicked();\r\n\r\n\tinternal void OnMaterialSelectionChanged()\r\n\t{\r\n\t\tRefreshCopy();\r\n\t\tSyncPairPreview();\r\n\t\t_listHost?.Update();\r\n\t\tif ( _listHost?.Layout is null )\r\n\t\t\treturn;\r\n\r\n\t\tforeach ( var child in _listHost.Children )\r\n\t\t\tchild?.Update();\r\n\t}\r\n\r\n\tinternal void SelectSteep()\r\n\t{\r\n\t\t_tool.PaintSettings.OnlyOnSlope = true;\r\n\t\tRebuildOptions();\r\n\t\tRefreshCopy();\r\n\t}\r\n\r\n\tinternal void SelectAll()\r\n\t{\r\n\t\t_tool.PaintSettings.OnlyOnSlope = false;\r\n\t\tRebuildOptions();\r\n\t\tRefreshCopy();\r\n\t}\r\n\r\n\tvoid OnKeepPairToggled()\r\n\t{\r\n\t\tif ( _keepPairBox is null )\r\n\t\t\treturn;\r\n\r\n\t\t_tool.PaintSettings.MixOnly = _keepPairBox.Value;\r\n\t\tRefreshCopy();\r\n\t}\r\n\r\n\tvoid OnSampledToggled()\r\n\t{\r\n\t\tif ( _sampledBox is null )\r\n\t\t\treturn;\r\n\r\n\t\t_tool.PaintSettings.LimitToSampledGround = _sampledBox.Value;\r\n\t\tRefreshCopy();\r\n\t}\r\n\r\n\tvoid OnPackCenterToggled()\r\n\t{\r\n\t\tif ( _packCenterBox is null )\r\n\t\t\treturn;\r\n\r\n\t\t_tool.PaintSettings.PackCenter = _packCenterBox.Value;\r\n\t\tRebuildOptions();\r\n\t\tRefreshCopy();\r\n\t\tSyncPairPreview();\r\n\t}\r\n\r\n\tvoid RebuildList()\r\n\t{\r\n\t\tif ( _listHost is null )\r\n\t\t\treturn;\r\n\r\n\t\t_listHost.Layout ??= Layout.Column();\r\n\t\t_listHost.Layout.Clear( true );\r\n\r\n\t\tvar terrain = _tool.PeekTerrain();\r\n\t\tvar materials = terrain?.Storage?.Materials;\r\n\t\tint count = materials?.Count ?? 0;\r\n\t\t_listedCount = count;\r\n\r\n\t\tif ( count == 0 )\r\n\t\t{\r\n\t\t\t_listHost.Layout.Add( new Label( \"This terrain has no materials to paint.\" ) );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tfor ( int i = 0; i < count; i++ )\r\n\t\t{\r\n\t\t\tvar name = SubTerrainSplatUtil.MaterialName( terrain, i );\r\n\t\t\t_listHost.Layout.Add( new SubTerrainMaterialRow( _tool, i, name, _listHost ) );\r\n\t\t}\r\n\t}\r\n\r\n\tvoid RebuildOptions()\r\n\t{\r\n\t\tif ( _optionsHost is null )\r\n\t\t\treturn;\r\n\r\n\t\t_optionsHost.Layout ??= Layout.Column();\r\n\t\t_optionsHost.Layout.Clear( true );\r\n\t\t_limitRow = null;\r\n\t\t_keepPairBox = null;\r\n\t\t_sampledBox = null;\r\n\t\t_packCenterBox = null;\r\n\t\t_sheetLayer = _tool.PaintSettings.Layer;\r\n\t\t_sheetSteep = _tool.PaintSettings.OnlyOnSlope;\r\n\r\n\t\tvar so = _tool.PaintSettings.GetSerialized();\r\n\t\tif ( so is null )\r\n\t\t\treturn;\r\n\r\n\t\tif ( _sheetLayer == SubTerrainPaintLayer.Overlay )\r\n\t\t\t_optionsHost.Layout.Add( new SubTerrainWhereStrip( _tool, this ) );\r\n\r\n\t\tvar sheet = new ControlSheet { IncludePropertyNames = false };\r\n\t\tif ( _sheetLayer == SubTerrainPaintLayer.Overlay )\r\n\t\t\tAddSettingRow( sheet, so, nameof( SubTerrainPaintSettings.HowWorn ) );\r\n\t\telse if ( _sheetLayer == SubTerrainPaintLayer.Path )\r\n\t\t\tAddSettingRow( sheet, so, nameof( SubTerrainPaintSettings.CenterMix ) );\r\n\r\n\t\tif ( _sheetLayer == SubTerrainPaintLayer.Overlay && _sheetSteep )\r\n\t\t\tAddSettingRow( sheet, so, nameof( SubTerrainPaintSettings.SlopeDegrees ) );\r\n\r\n\t\t_optionsHost.Layout.Add( sheet );\r\n\r\n\t\tif ( _sheetLayer == SubTerrainPaintLayer.Overlay )\r\n\t\t{\r\n\t\t\t_keepPairBox = new Checkbox( \"Keep pair\" );\r\n\t\t\t_keepPairBox.Value = _tool.PaintSettings.MixOnly;\r\n\t\t\t_keepPairBox.ToolTip = \"Only move mix. Do not change the base/overlay pair (avoids texel stairs next to a different pair).\";\r\n\t\t\t_keepPairBox.Toggled += OnKeepPairToggled;\r\n\t\t\t_optionsHost.Layout.Add( _keepPairBox );\r\n\r\n\t\t\t_sampledBox = new Checkbox( \"Stay on sampled\" );\r\n\t\t\t_sampledBox.Value = _tool.PaintSettings.LimitToSampledGround;\r\n\t\t\t_sampledBox.ToolTip = \"Alt-click locks to that ground base. Wear will not paint other bases.\";\r\n\t\t\t_sampledBox.Toggled += OnSampledToggled;\r\n\t\t\t_optionsHost.Layout.Add( _sampledBox );\r\n\t\t\t_limitRow = _sampledBox;\r\n\t\t}\r\n\t\telse if ( _sheetLayer == SubTerrainPaintLayer.Path )\r\n\t\t{\r\n\t\t\t_packCenterBox = new Checkbox( \"Pack center\" );\r\n\t\t\t_packCenterBox.Value = _tool.PaintSettings.PackCenter;\r\n\t\t\t_packCenterBox.ToolTip = \"On: solid path core along the stroke, soft apron outside. Off: soft ground + path apron only.\";\r\n\t\t\t_packCenterBox.Toggled += OnPackCenterToggled;\r\n\t\t\t_optionsHost.Layout.Add( _packCenterBox );\r\n\r\n\t\t\tif ( _tool.PaintSettings.PackCenter )\r\n\t\t\t{\r\n\t\t\t\tvar coreSheet = new ControlSheet { IncludePropertyNames = false };\r\n\t\t\t\tAddSettingRow( coreSheet, so, nameof( SubTerrainPaintSettings.PackCore ) );\r\n\t\t\t\t_optionsHost.Layout.Add( coreSheet );\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\r\n\tstatic void AddSettingRow( ControlSheet sheet, SerializedObject so, string name )\r\n\t{\r\n\t\tif ( so.TryGetProperty( name, out var prop ) && prop is not null )\r\n\t\t\tsheet.AddRow( prop );\r\n\t}\r\n\r\n\tvoid RefreshCopy()\r\n\t{\r\n\t\tif ( _live is null )\r\n\t\t\treturn;\r\n\r\n\t\tvar line = WriteLine();\r\n\t\tif ( _tool.PaintSettings.Layer == SubTerrainPaintLayer.Overlay && _tool.PaintSettings.Dithers )\r\n\t\t\tline += \" | dither\";\r\n\r\n\t\t_live.Text = line;\r\n\t\t_live.ToolTip = IntentTip();\r\n\t\t_live.Color = Theme.TextControl;\r\n\t}\r\n\r\n\tstring WriteLine()\r\n\t{\r\n\t\tvar settings = _tool.PaintSettings;\r\n\t\tvar layer = settings.Layer;\r\n\t\tvar terrain = _tool.PeekTerrain();\r\n\t\tstring mix = settings.MixCap.ToString( \"0.00\" );\r\n\r\n\t\tif ( layer == SubTerrainPaintLayer.Path )\r\n\t\t{\r\n\t\t\tint ground = SubTerrainSplatUtil.FindPathGround( terrain, settings );\r\n\t\t\tint wear = settings.MaterialIndex;\r\n\t\t\tif ( ground == wear )\r\n\t\t\t\treturn \"Click the packed tmat. Alt-click the ground.\";\r\n\r\n\t\t\tvar gName = SubTerrainSplatUtil.MaterialName( terrain, ground );\r\n\t\t\tvar wName = SubTerrainSplatUtil.MaterialName( terrain, wear );\r\n\t\t\treturn settings.PackCenter\r\n\t\t\t\t? $\"{wName} center | {gName} | {wName} {mix} | core {settings.PackCore:0.00}\"\r\n\t\t\t\t: $\"{gName} | {wName} {mix} | soft\";\r\n\t\t}\r\n\r\n\t\tif ( layer == SubTerrainPaintLayer.Base )\r\n\t\t\treturn SubTerrainSplatUtil.MaterialName( terrain, settings.MaterialIndex );\r\n\r\n\t\tvar name = SubTerrainSplatUtil.MaterialName( terrain, settings.MaterialIndex );\r\n\t\tstring lockOn = \"\";\r\n\t\tif ( settings.LimitToSampledGround && settings.HasSampledGround )\r\n\t\t\tlockOn = $\" on {SubTerrainSplatUtil.MaterialName( terrain, settings.SampledGroundIndex )}\";\r\n\r\n\t\tif ( settings.MixOnly )\r\n\t\t\treturn $\"mix {name} {mix}{lockOn}\";\r\n\r\n\t\treturn settings.OnlyOnSlope\r\n\t\t\t? $\"{name} {mix} steep{lockOn}\"\r\n\t\t\t: $\"{name} {mix}{lockOn}\";\r\n\t}\r\n\r\n\tstring IntentTip()\r\n\t{\r\n\t\tvar settings = _tool.PaintSettings;\r\n\t\tstring body = settings.Layer switch\r\n\t\t{\r\n\t\t\tSubTerrainPaintLayer.Path => settings.PackCenter\r\n\t\t\t\t? \"Pack center on. Raise Core for a wider solid middle. Center mix is the soft apron. Brush Size widens the whole blend.\"\r\n\t\t\t\t: \"Pack center off. Soft apron only: ground + path. Center mix is the apron mix.\",\r\n\t\t\tSubTerrainPaintLayer.Base => \"Ground writes the selected tmat as base and overlay, mix 0.\",\r\n\t\t\t_ => \"Wear writes overlay mix. Steep skips flats. Ctrl reduces. Alt-click locks Stay on sampled.\"\r\n\t\t};\r\n\r\n\t\treturn body + IntentSuffix;\r\n\t}\r\n\r\n\t[EditorEvent.Frame]\r\n\tpublic void Refresh()\r\n\t{\r\n\t\tif ( _live is null || _modes is null || _listHost is null || _optionsHost is null )\r\n\t\t\treturn;\r\n\r\n\t\t_tool.RefreshPaintChrome();\r\n\r\n\t\tif ( !_tool.PaintArmed )\r\n\t\t\treturn;\r\n\r\n\t\tvar terrain = _tool.PeekTerrain();\r\n\t\tint count = terrain?.Storage?.Materials?.Count ?? 0;\r\n\t\tif ( count != _listedCount )\r\n\t\t\tRebuildList();\r\n\r\n\t\tvar settings = _tool.PaintSettings;\r\n\t\tif ( _sheetLayer != settings.Layer || _sheetSteep != settings.OnlyOnSlope )\r\n\t\t\tRebuildOptions();\r\n\r\n\t\tif ( _keepPairBox is not null )\r\n\t\t\t_keepPairBox.Value = settings.MixOnly;\r\n\r\n\t\tif ( _sampledBox is not null )\r\n\t\t{\r\n\t\t\t_sampledBox.Value = settings.LimitToSampledGround;\r\n\t\t\t_sampledBox.Enabled = settings.HasSampledGround;\r\n\t\t\tif ( !settings.HasSampledGround )\r\n\t\t\t\t_sampledBox.ToolTip = \"Alt-click the ground to lock Stay on sampled.\";\r\n\t\t\telse\r\n\t\t\t\t_sampledBox.ToolTip = \"Locked to the Alt-clicked ground base. Uncheck to paint any base.\";\r\n\t\t}\r\n\r\n\t\tif ( _packCenterBox is not null )\r\n\t\t\t_packCenterBox.Value = settings.PackCenter;\r\n\r\n\t\tif ( _limitRow is not null )\r\n\t\t\t_limitRow.Enabled = settings.HasSampledGround;\r\n\r\n\t\tRefreshCopy();\r\n\t\t_modes.Update();\r\n\t\tSyncPairPreview();\r\n\r\n\t\tif ( !_tool.StatusValid || terrain is null || !terrain.IsValid() )\r\n\t\t\treturn;\r\n\r\n\t\tif ( !SubTerrainSplatUtil.TryTexel( terrain, _tool.StatusHitLocal, out var x, out var y ) )\r\n\t\t\treturn;\r\n\r\n\t\tvar mat = SubTerrainSplatUtil.Read( terrain, x, y );\r\n\t\tif ( mat.IsHole )\r\n\t\t{\r\n\t\t\t_live.Text = WriteLine() + \" | hole\";\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tvar baseName = SubTerrainSplatUtil.MaterialName( terrain, mat.BaseTextureId );\r\n\t\tvar overlayName = SubTerrainSplatUtil.MaterialName( terrain, mat.OverlayTextureId );\r\n\t\tfloat mix = mat.BlendFactor / 255f;\r\n\t\tstring there = baseName == overlayName || mix < 0.02f\r\n\t\t\t? baseName\r\n\t\t\t: $\"{baseName} + {overlayName} {mix:0.00}\";\r\n\r\n\t\tstring extra = \"\";\r\n\t\tif ( SubTerrainSplatUtil.TrySlopeDegrees( terrain, x, y, out var degrees ) )\r\n\t\t{\r\n\t\t\tint deg = (int)MathF.Round( degrees );\r\n\t\t\tbool steepGate = settings.Layer == SubTerrainPaintLayer.Overlay && settings.OnlyOnSlope;\r\n\t\t\tif ( steepGate )\r\n\t\t\t\textra = degrees >= settings.SlopeDegrees ? $\" | steep {deg}\" : $\" | skip {deg}\";\r\n\t\t}\r\n\r\n\t\tif ( SubTerrainSplatUtil.HasSeam( terrain, x, y ) )\r\n\t\t\textra += \" | seam\";\r\n\r\n\t\t_live.Text = $\"{WriteLine()} | {there}{extra}\";\r\n\t}\r\n}\r\n\r\ninternal class SubTerrainPairPreview : Widget\r\n{\r\n\treadonly SubTerrainTool _tool;\r\n\tconst float ThumbSize = 24f;\r\n\r\n\tpublic SubTerrainPairPreview( SubTerrainTool tool, Widget parent ) : base( parent )\r\n\t{\r\n\t\t_tool = tool;\r\n\t\tFixedHeight = 36;\r\n\t\tMinimumHeight = 36;\r\n\t\tMaximumHeight = 36;\r\n\t\tToolTip = \"Pair this stroke will write.\";\r\n\t}\r\n\r\n\tprotected override void OnPaint()\r\n\t{\r\n\t\tbase.OnPaint();\r\n\r\n\t\tif ( !Visible || _tool.PaintSettings.Layer == SubTerrainPaintLayer.Base )\r\n\t\t\treturn;\r\n\r\n\t\tPaint.Antialiasing = true;\r\n\t\tPaint.ClearPen();\r\n\t\tPaint.SetBrush( Color.White.WithAlpha( 0.04f ) );\r\n\t\tPaint.DrawRect( LocalRect );\r\n\r\n\t\tResolvePair( out int left, out int right );\r\n\t\tfloat y = (Height - ThumbSize) * 0.5f;\r\n\t\tfloat gap = 8f;\r\n\t\tfloat total = ThumbSize * 2f + gap;\r\n\t\tfloat x0 = (Width - total) * 0.5f;\r\n\r\n\t\tDrawThumb( new Rect( x0, y, ThumbSize, ThumbSize ), left );\r\n\t\tDrawThumb( new Rect( x0 + ThumbSize + gap, y, ThumbSize, ThumbSize ), right );\r\n\t}\r\n\r\n\tvoid ResolvePair( out int left, out int right )\r\n\t{\r\n\t\tvar settings = _tool.PaintSettings;\r\n\t\tvar terrain = _tool.PeekTerrain();\r\n\t\tint selected = settings.MaterialIndex;\r\n\r\n\t\tif ( settings.Layer == SubTerrainPaintLayer.Path )\r\n\t\t{\r\n\t\t\tleft = SubTerrainSplatUtil.FindPathGround( terrain, settings );\r\n\t\t\tright = selected;\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tif ( settings.Layer == SubTerrainPaintLayer.Overlay )\r\n\t\t{\r\n\t\t\tleft = settings.HasSampledGround\r\n\t\t\t\t? settings.SampledGroundIndex\r\n\t\t\t\t: HoverBaseOr( terrain, selected );\r\n\t\t\tright = selected;\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tleft = selected;\r\n\t\tright = selected;\r\n\t}\r\n\r\n\tint HoverBaseOr( Terrain terrain, int fallback )\r\n\t{\r\n\t\tif ( terrain is null || !_tool.StatusValid )\r\n\t\t\treturn fallback;\r\n\r\n\t\tif ( !SubTerrainSplatUtil.TryTexel( terrain, _tool.StatusHitLocal, out var x, out var y ) )\r\n\t\t\treturn fallback;\r\n\r\n\t\tvar mat = SubTerrainSplatUtil.Read( terrain, x, y );\r\n\t\tif ( mat.IsHole )\r\n\t\t\treturn fallback;\r\n\r\n\t\treturn mat.BaseTextureId;\r\n\t}\r\n\r\n\tvoid DrawThumb( Rect rect, int index )\r\n\t{\r\n\t\tPaint.ClearPen();\r\n\t\tPaint.SetBrush( Theme.SurfaceBackground.WithAlpha( 0.35f ) );\r\n\t\tPaint.DrawRect( rect, 3 );\r\n\r\n\t\tvar path = MaterialPath( index );\r\n\t\tvar asset = string.IsNullOrWhiteSpace( path ) ? null : AssetSystem.FindByPath( path );\r\n\t\tif ( asset is null || asset.IsDeleted )\r\n\t\t\treturn;\r\n\r\n\t\tvar thumb = asset.GetAssetThumb( true );\r\n\t\tif ( thumb is not null )\r\n\t\t\tPaint.Draw( rect, thumb );\r\n\t}\r\n\r\n\tstring MaterialPath( int index )\r\n\t{\r\n\t\tvar mats = _tool.PeekTerrain()?.Storage?.Materials;\r\n\t\tif ( mats is null || index < 0 || index >= mats.Count )\r\n\t\t\treturn null;\r\n\r\n\t\treturn mats[index]?.ResourcePath;\r\n\t}\r\n\r\n\t[EditorEvent.Frame]\r\n\tpublic void TickPreview()\r\n\t{\r\n\t\tif ( !Visible || _tool.PaintSettings.Layer == SubTerrainPaintLayer.Base )\r\n\t\t\treturn;\r\n\r\n\t\tUpdate();\r\n\t}\r\n}\r\n\r\ninternal class SubTerrainPaintModeStrip : Widget\r\n{\r\n\treadonly SubTerrainTool _tool;\r\n\treadonly SubTerrainBlendWidget _host;\r\n\tconst int SlotCount = 3;\r\n\tint _hover = -1;\r\n\r\n\tpublic SubTerrainPaintModeStrip( SubTerrainTool tool, SubTerrainBlendWidget host ) : base( host )\r\n\t{\r\n\t\t_tool = tool;\r\n\t\t_host = host;\r\n\t\tFixedHeight = 28;\r\n\t\tMinimumHeight = 28;\r\n\t\tMaximumHeight = 28;\r\n\t\tCursor = CursorShape.Finger;\r\n\t\tMouseTracking = true;\r\n\t\tToolTip = \"Ground is a solid tmat. Wear is overlay mix. Path packs a path tmat (optional Pack center) with a ground+path apron.\";\r\n\t}\r\n\r\n\tprotected override void OnMouseClick( MouseEvent e )\r\n\t{\r\n\t\tint slot = SlotAt( e.LocalPosition.x );\r\n\t\tif ( slot == 0 )\r\n\t\t\t_host.SelectGround();\r\n\t\telse if ( slot == 1 )\r\n\t\t\t_host.SelectWear();\r\n\t\telse\r\n\t\t\t_host.SelectPath();\r\n\r\n\t\tUpdate();\r\n\t}\r\n\r\n\tprotected override void OnMouseMove( MouseEvent e )\r\n\t{\r\n\t\tbase.OnMouseMove( e );\r\n\t\tint next = SlotAt( e.LocalPosition.x );\r\n\t\tif ( next == _hover )\r\n\t\t\treturn;\r\n\r\n\t\t_hover = next;\r\n\t\tUpdate();\r\n\t}\r\n\r\n\tprotected override void OnMouseLeave()\r\n\t{\r\n\t\t_hover = -1;\r\n\t\tUpdate();\r\n\t\tbase.OnMouseLeave();\r\n\t}\r\n\r\n\tint SlotAt( float x )\r\n\t{\r\n\t\tfloat w = Math.Max( Width, 1f );\r\n\t\tint slot = (int)(x / w * SlotCount);\r\n\t\treturn Math.Clamp( slot, 0, SlotCount - 1 );\r\n\t}\r\n\r\n\tprotected override void OnPaint()\r\n\t{\r\n\t\tbase.OnPaint();\r\n\r\n\t\tPaint.Antialiasing = true;\r\n\t\tPaint.ClearPen();\r\n\t\tPaint.SetBrush( Color.White.WithAlpha( 0.04f ) );\r\n\t\tPaint.DrawRect( LocalRect );\r\n\r\n\t\tvar layer = _tool.PaintSettings.Layer;\r\n\t\tfloat slotW = Width / SlotCount;\r\n\t\tfor ( int i = 0; i < SlotCount; i++ )\r\n\t\t{\r\n\t\t\tvar rect = new Rect( i * slotW, 0, slotW, Height );\r\n\t\t\tbool selected = i switch\r\n\t\t\t{\r\n\t\t\t\t0 => layer == SubTerrainPaintLayer.Base,\r\n\t\t\t\t1 => layer == SubTerrainPaintLayer.Overlay,\r\n\t\t\t\t_ => layer == SubTerrainPaintLayer.Path\r\n\t\t\t};\r\n\r\n\t\t\tPaint.ClearPen();\r\n\t\t\tif ( selected )\r\n\t\t\t\tPaint.SetBrush( Theme.Primary.WithAlpha( 0.20f ) );\r\n\t\t\telse if ( _hover == i )\r\n\t\t\t\tPaint.SetBrush( Color.White.WithAlpha( 0.06f ) );\r\n\t\t\telse\r\n\t\t\t\tPaint.SetBrush( Color.Transparent );\r\n\r\n\t\t\tPaint.DrawRect( rect );\r\n\r\n\t\t\tif ( i > 0 )\r\n\t\t\t{\r\n\t\t\t\tPaint.ClearPen();\r\n\t\t\t\tPaint.SetBrush( Color.White.WithAlpha( 0.10f ) );\r\n\t\t\t\tPaint.DrawRect( new Rect( rect.Left, rect.Top + 5, 1, rect.Height - 10 ) );\r\n\t\t\t}\r\n\r\n\t\t\tstring label = i switch\r\n\t\t\t{\r\n\t\t\t\t0 => \"Ground\",\r\n\t\t\t\t1 => \"Wear\",\r\n\t\t\t\t_ => \"Path\"\r\n\t\t\t};\r\n\r\n\t\t\tPaint.SetPen( selected ? Theme.Primary : Theme.TextControl );\r\n\t\t\tPaint.DrawText( rect, label, TextFlag.Center );\r\n\t\t}\r\n\t}\r\n}\r\n\r\ninternal class SubTerrainWhereStrip : Widget\r\n{\r\n\treadonly SubTerrainTool _tool;\r\n\treadonly SubTerrainBlendWidget _host;\r\n\tconst int SlotCount = 2;\r\n\tint _hover = -1;\r\n\r\n\tpublic SubTerrainWhereStrip( SubTerrainTool tool, SubTerrainBlendWidget host ) : base( host )\r\n\t{\r\n\t\t_tool = tool;\r\n\t\t_host = host;\r\n\t\tFixedHeight = 28;\r\n\t\tMinimumHeight = 28;\r\n\t\tMaximumHeight = 28;\r\n\t\tCursor = CursorShape.Finger;\r\n\t\tMouseTracking = true;\r\n\t\tToolTip = \"Steep skips ground that is too flat. All paints the whole brush, including shelves.\";\r\n\t}\r\n\r\n\tprotected override void OnMouseClick( MouseEvent e )\r\n\t{\r\n\t\tif ( SlotAt( e.LocalPosition.x ) == 0 )\r\n\t\t\t_host.SelectSteep();\r\n\t\telse\r\n\t\t\t_host.SelectAll();\r\n\r\n\t\tUpdate();\r\n\t}\r\n\r\n\tprotected override void OnMouseMove( MouseEvent e )\r\n\t{\r\n\t\tbase.OnMouseMove( e );\r\n\t\tint next = SlotAt( e.LocalPosition.x );\r\n\t\tif ( next == _hover )\r\n\t\t\treturn;\r\n\r\n\t\t_hover = next;\r\n\t\tToolTip = next == 0\r\n\t\t\t? \"Skip ground that is too flat. Hover a hill; the footer says steep or skip.\"\r\n\t\t\t: \"Paint the whole brush, including shelves.\";\r\n\t\tUpdate();\r\n\t}\r\n\r\n\tprotected override void OnMouseLeave()\r\n\t{\r\n\t\t_hover = -1;\r\n\t\tToolTip = \"Steep skips ground that is too flat. All paints the whole brush, including shelves.\";\r\n\t\tUpdate();\r\n\t\tbase.OnMouseLeave();\r\n\t}\r\n\r\n\tint SlotAt( float x )\r\n\t{\r\n\t\tfloat w = Math.Max( Width, 1f );\r\n\t\tint slot = (int)(x / w * SlotCount);\r\n\t\treturn Math.Clamp( slot, 0, SlotCount - 1 );\r\n\t}\r\n\r\n\tprotected override void OnPaint()\r\n\t{\r\n\t\tbase.OnPaint();\r\n\r\n\t\tPaint.Antialiasing = true;\r\n\t\tPaint.ClearPen();\r\n\t\tPaint.SetBrush( Color.White.WithAlpha( 0.04f ) );\r\n\t\tPaint.DrawRect( LocalRect );\r\n\r\n\t\tbool steep = _tool.PaintSettings.OnlyOnSlope;\r\n\t\tfloat slotW = Width / SlotCount;\r\n\t\tfor ( int i = 0; i < SlotCount; i++ )\r\n\t\t{\r\n\t\t\tvar rect = new Rect( i * slotW, 0, slotW, Height );\r\n\t\t\tbool selected = i == 0 ? steep : !steep;\r\n\r\n\t\t\tPaint.ClearPen();\r\n\t\t\tif ( selected )\r\n\t\t\t\tPaint.SetBrush( Theme.Primary.WithAlpha( 0.20f ) );\r\n\t\t\telse if ( _hover == i )\r\n\t\t\t\tPaint.SetBrush( Color.White.WithAlpha( 0.06f ) );\r\n\t\t\telse\r\n\t\t\t\tPaint.SetBrush( Color.Transparent );\r\n\r\n\t\t\tPaint.DrawRect( rect );\r\n\r\n\t\t\tif ( i > 0 )\r\n\t\t\t{\r\n\t\t\t\tPaint.ClearPen();\r\n\t\t\t\tPaint.SetBrush( Color.White.WithAlpha( 0.10f ) );\r\n\t\t\t\tPaint.DrawRect( new Rect( rect.Left, rect.Top + 5, 1, rect.Height - 10 ) );\r\n\t\t\t}\r\n\r\n\t\t\tPaint.SetPen( selected ? Theme.Primary : Theme.TextControl );\r\n\t\t\tPaint.DrawText( rect, i == 0 ? \"Steep\" : \"All\", TextFlag.Center );\r\n\t\t}\r\n\t}\r\n\r\n\t[EditorEvent.Frame]\r\n\tpublic void TickSelect()\r\n\t{\r\n\t\tUpdate();\r\n\t}\r\n}\r\n\r\ninternal class SubTerrainMaterialRow : Widget\r\n{\r\n\treadonly SubTerrainTool _tool;\r\n\treadonly int _index;\r\n\treadonly string _name;\r\n\treadonly string _resourcePath;\r\n\r\n\tconst float RowHeight = 42f;\r\n\tconst float ThumbSize = 28f;\r\n\r\n\tpublic SubTerrainMaterialRow( SubTerrainTool tool, int index, string name, Widget parent ) : base( parent )\r\n\t{\r\n\t\t_tool = tool;\r\n\t\t_index = index;\r\n\t\t_name = name;\r\n\t\t_resourcePath = tool.PeekTerrain()?.Storage?.Materials is { Count: > 0 } mats && index >= 0 && index < mats.Count\r\n\t\t\t? mats[index]?.ResourcePath\r\n\t\t\t: null;\r\n\t\tFixedHeight = RowHeight;\r\n\t\tMinimumHeight = RowHeight;\r\n\t\tMaximumHeight = RowHeight;\r\n\t\tCursor = CursorShape.Finger;\r\n\t\tToolTip = name;\r\n\t\tMouseTracking = true;\r\n\t}\r\n\r\n\tprotected override void OnMouseClick( MouseEvent e )\r\n\t{\r\n\t\tvar settings = _tool.PaintSettings;\r\n\t\tbool path = settings.Layer == SubTerrainPaintLayer.Path;\r\n\t\tbool alt = Application.KeyboardModifiers.HasFlag( KeyboardModifiers.Alt );\r\n\r\n\t\tif ( path && alt )\r\n\t\t{\r\n\t\t\tif ( _index == settings.MaterialIndex )\r\n\t\t\t\treturn;\r\n\r\n\t\t\tsettings.PathGroundIndex = _index;\r\n\t\t\tUpdate();\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tif ( path && _index == SubTerrainSplatUtil.FindPathGround( _tool.PeekTerrain(), settings ) )\r\n\t\t{\r\n\t\t\tsettings.PathGroundIndex = _index;\r\n\t\t\tUpdate();\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tsettings.MaterialIndex = _index;\r\n\t\tSubTerrainPaintTool.TrySetSplatChannel( _index );\r\n\t\tUpdate();\r\n\t}\r\n\r\n\tprotected override void OnDoubleClick( MouseEvent e )\r\n\t{\r\n\t\tif ( string.IsNullOrWhiteSpace( _resourcePath ) )\r\n\t\t\treturn;\r\n\r\n\t\tvar asset = AssetSystem.FindByPath( _resourcePath );\r\n\t\tif ( asset is null || asset.IsDeleted )\r\n\t\t\treturn;\r\n\r\n\t\tasset.OpenInEditor();\r\n\t}\r\n\r\n\tprotected override void OnPaint()\r\n\t{\r\n\t\tbase.OnPaint();\r\n\r\n\t\tvar settings = _tool.PaintSettings;\r\n\t\tbool selected = settings.MaterialIndex == _index;\r\n\t\tbool path = settings.Layer == SubTerrainPaintLayer.Path;\r\n\t\tbool pathGround = path && SubTerrainSplatUtil.FindPathGround( _tool.PeekTerrain(), settings ) == _index;\r\n\r\n\t\tPaint.Antialiasing = true;\r\n\t\tPaint.ClearPen();\r\n\t\tif ( selected )\r\n\t\t\tPaint.SetBrush( Theme.Primary.WithAlpha( 0.18f ) );\r\n\t\telse if ( IsUnderMouse )\r\n\t\t\tPaint.SetBrush( Color.White.WithAlpha( 0.06f ) );\r\n\t\telse\r\n\t\t\tPaint.SetBrush( Color.Transparent );\r\n\r\n\t\tPaint.DrawRect( LocalRect );\r\n\r\n\t\tvar thumbRect = new Rect( 8, (RowHeight - ThumbSize) * 0.5f, ThumbSize, ThumbSize );\r\n\t\tPaint.ClearPen();\r\n\t\tPaint.SetBrush( Theme.SurfaceBackground.WithAlpha( 0.35f ) );\r\n\t\tPaint.DrawRect( thumbRect, 3 );\r\n\r\n\t\tvar asset = string.IsNullOrWhiteSpace( _resourcePath ) ? null : AssetSystem.FindByPath( _resourcePath );\r\n\t\tif ( asset is not null && !asset.IsDeleted )\r\n\t\t{\r\n\t\t\tvar thumb = asset.GetAssetThumb( true );\r\n\t\t\tif ( thumb is not null )\r\n\t\t\t\tPaint.Draw( thumbRect, thumb );\r\n\t\t}\r\n\r\n\t\tfloat textLeft = thumbRect.Right + 8f;\r\n\t\tPaint.SetPen( selected ? Color.White : Theme.TextControl );\r\n\t\tPaint.DrawText( new Rect( textLeft, 0, Math.Max( 40f, Width - textLeft - 56f ), Height ), _name, TextFlag.LeftCenter );\r\n\r\n\t\tstring role = null;\r\n\t\tif ( pathGround )\r\n\t\t\trole = \"ground\";\r\n\t\telse if ( path && selected )\r\n\t\t\trole = \"path\";\r\n\t\telse if ( !path && selected && settings.Layer == SubTerrainPaintLayer.Overlay )\r\n\t\t\trole = settings.MixCap.ToString( \"0.00\" );\r\n\r\n\t\tif ( role is not null )\r\n\t\t{\r\n\t\t\tPaint.SetPen( Color.White.WithAlpha( selected ? 0.70f : 0.45f ) );\r\n\t\t\tPaint.DrawText( new Rect( Width - 56, 0, 44, Height ), role, TextFlag.RightCenter );\r\n\t\t}\r\n\t}\r\n\r\n\t[EditorEvent.Frame]\r\n\tpublic void TickSelect()\r\n\t{\r\n\t\tbool path = _tool.PaintSettings.Layer == SubTerrainPaintLayer.Path;\r\n\t\tToolTip = path\r\n\t\t\t? \"Click the packed tmat. Alt-click the ground under it. Double-click opens the asset.\"\r\n\t\t\t: _name + \" | Double-click opens the asset.\";\r\n\t\tUpdate();\r\n\t}\r\n}\r\n"
},
{
"Ident": "subzerostudios.subterrain-tool",
"Path": "Code/TerrainSculptNet.cs",
"FileName": "TerrainSculptNet.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 344638,
"Code": "// Copyright (c) 2026 SubZero Studios LLC. All rights reserved.\r\n\r\nusing System;\r\nusing Sandbox;\r\n\r\nnamespace SubZero.SubTerrain;\r\n\r\n/// <summary>\r\n/// Optional host-authoritative play-sculpt net. Attach to a NetworkMode.Object\r\n/// GameObject. Host writes height; peers apply the same op locally.\r\n/// </summary>\r\n[Title( \"Terrain Sculpt Net\" )]\r\n[Category( \"SubTerrain\" )]\r\n[Icon( \"share\" )]\r\npublic sealed class TerrainSculptNet : Component\r\n{\r\n\tpublic bool Submit( TerrainEditRequest request )\r\n\t{\r\n\t\tif ( request.Seed == 0 )\r\n\t\t\trequest.Seed = Game.Random.Int( 1, 1_000_000_000 );\r\n\r\n\t\tif ( !Networking.IsActive || Networking.IsHost )\r\n\t\t{\r\n\t\t\tApplyLocal( request );\r\n\t\t\tif ( Networking.IsActive )\r\n\t\t\t\tBroadcastStamp( (int)request.Kind, request.WorldPos, request.Radius, request.Depth, request.Seed, request.HasTargetZ, request.TargetZ );\r\n\t\t\treturn true;\r\n\t\t}\r\n\r\n\t\tRequestHost( (int)request.Kind, request.WorldPos, request.Radius, request.Depth, request.Seed, request.HasTargetZ, request.TargetZ );\r\n\t\treturn true;\r\n\t}\r\n\r\n\t[Rpc.Host]\r\n\tpublic void RequestHost( int kind, Vector3 pos, float radius, float depth, int seed, bool hasTargetZ, float targetZ )\r\n\t{\r\n\t\tvar request = Make( kind, pos, radius, depth, seed, hasTargetZ, targetZ );\r\n\t\tApplyLocal( request );\r\n\t\tBroadcastStamp( kind, pos, radius, depth, seed, hasTargetZ, targetZ );\r\n\t}\r\n\r\n\t[Rpc.Broadcast]\r\n\tpublic void BroadcastStamp( int kind, Vector3 pos, float radius, float depth, int seed, bool hasTargetZ, float targetZ )\r\n\t{\r\n\t\tif ( Networking.IsHost )\r\n\t\t\treturn;\r\n\r\n\t\tApplyLocal( Make( kind, pos, radius, depth, seed, hasTargetZ, targetZ ) );\r\n\t}\r\n\r\n\tstatic TerrainEditRequest Make( int kind, Vector3 pos, float radius, float depth, int seed, bool hasTargetZ, float targetZ )\r\n\t{\r\n\t\treturn new TerrainEditRequest\r\n\t\t{\r\n\t\t\tKind = (TerrainEditKind)kind,\r\n\t\t\tWorldPos = pos,\r\n\t\t\tRadius = radius,\r\n\t\t\tDepth = depth,\r\n\t\t\tSeed = seed,\r\n\t\t\tHasTargetZ = hasTargetZ,\r\n\t\t\tTargetZ = targetZ\r\n\t\t};\r\n\t}\r\n\r\n\tvoid ApplyLocal( TerrainEditRequest request )\r\n\t{\r\n\t\tTerrainSculpt.TryApply( Scene, request );\r\n\t}\r\n}\r\n"
},
{
"Ident": "subzerostudios.subterrain-tool",
"Path": "TerrainBrushSettings.cs",
"FileName": "TerrainBrushSettings.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 344638,
"Code": "// Copyright (c) 2026 SubZero Studios LLC. All rights reserved.\r\n\r\nusing Sandbox;\r\n\r\nnamespace SubZero.SubTerrain;\r\n\r\npublic sealed class CraterBrushSettings\r\n{\r\n\t[Property, Range( 16f, 200f )]\r\n\tpublic float MaxDepth { get; set; } = 96f;\r\n\r\n\t[Property, Title( \"Depth Scale\" ), Range( 0.1f, 1.5f )]\r\n\tpublic float DepthScale { get; set; } = 0.5f;\r\n\r\n\t[Property, Range( 0f, 32f )]\r\n\tpublic float MaxRaise { get; set; } = 12f;\r\n\r\n\t[Property, Range( 0.4f, 0.95f )]\r\n\tpublic float BowlRadius { get; set; } = 0.75f;\r\n\r\n\t[Property, Range( 0f, 0.7f )]\r\n\tpublic float Irregularity { get; set; } = 0.35f;\r\n\r\n\t[Property, Range( 0f, 0.5f )]\r\n\tpublic float Stretch { get; set; } = 0.25f;\r\n\r\n\t[Property, Range( 0f, 1f )]\r\n\tpublic float Blend { get; set; } = 0.65f;\r\n}\r\n\r\npublic sealed class DigBrushSettings\r\n{\r\n\t[Property, Range( 16f, 120f )]\r\n\tpublic float MaxDepth { get; set; } = 48f;\r\n\r\n\t[Property, Title( \"Depth Scale\" ), Range( 0.1f, 1.5f )]\r\n\tpublic float DepthScale { get; set; } = 0.85f;\r\n\r\n\t[Property, Range( 0f, 40f )]\r\n\tpublic float MaxRaise { get; set; } = 18f;\r\n\r\n\t[Property, Range( 0.7f, 0.98f )]\r\n\tpublic float BowlRadius { get; set; } = 0.9f;\r\n\r\n\t[Property, Range( 0f, 0.4f )]\r\n\tpublic float Irregularity { get; set; } = 0.12f;\r\n\r\n\t[Property, Range( 0f, 0.35f )]\r\n\tpublic float Stretch { get; set; } = 0.08f;\r\n\r\n\t[Property, Range( 0f, 1f )]\r\n\tpublic float Blend { get; set; } = 0.35f;\r\n}\r\n\r\npublic sealed class FlattenBrushSettings\r\n{\r\n\t[Property, Range( 0.05f, 1f )]\r\n\tpublic float Strength { get; set; } = 0.65f;\r\n\r\n\t[Property, Range( 4f, 64f )]\r\n\tpublic float MaxDelta { get; set; } = 24f;\r\n}\r\n"
},
{
"Ident": "subzerostudios.subterrain-tool",
"Path": "Editor/Tools/BridgeTool.cs",
"FileName": "BridgeTool.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 344638,
"Code": "// Copyright (c) 2026 SubZero Studios LLC. All rights reserved.\n\nusing System;\nusing Sandbox;\nusing Editor.TerrainEditor;\n\nnamespace Editor.SubTerrain;\n\n/// <summary>\n/// Bridge tool. Press on the first point and release on the second to raise a land bridge\n/// spanning the gap. The deck grades from the start ground height to the end ground height,\n/// and the whole span is written once on release rather than painted stroke by stroke.\n/// </summary>\n[Title( \"Bridge\" )]\n[Description( \"Press start, release on end. Raises a land bridge between the two heights.\" )]\n[Icon( \"linear_scale\" )]\n[Alias( \"subterrain_bridge\" )]\n[Group( \"1\" )]\n[Order( 10 )]\npublic class SubBridgeTool : SubTerrainBaseBrushTool\n{\n\tVector3 _startLocal;\n\tVector3 _endLocal;\n\tVector2 _endUV;\n\tfloat _halfWidthTexels;\n\n\tpublic SubBridgeTool( SubTerrainTool parent ) : base( parent )\n\t{\n\t\tMode = SubTerrainSculptMode.Bridge;\n\t}\n\n\tprotected override void OnStrokeStart( Terrain terrain, Vector3 hitPosition )\n\t{\n\t\t_startLocal = hitPosition;\n\t\t_endLocal = hitPosition;\n\n\t\tSlopeStartUV = new Vector2( hitPosition.x, hitPosition.y ) / terrain.Storage.TerrainSize;\n\t\tSlopeStartHeight01 = hitPosition.z / terrain.Storage.TerrainHeight;\n\t}\n\n\tprotected override void OnPaint( Terrain terrain, SubTerrainPaintParameters paint )\n\t{\n\t\t_endLocal = paint.HitPosition;\n\t}\n\n\tprotected override void OnPaintEnded( Terrain terrain )\n\t{\n\t\tBuildBridge( terrain );\n\t\tbase.OnPaintEnded( terrain );\n\t}\n\n\tvoid BuildBridge( Terrain terrain )\n\t{\n\t\tvar storage = terrain.Storage;\n\t\tvar settings = _parent.BrushSettings;\n\n\t\tvar startUV = new Vector2( _startLocal.x, _startLocal.y ) / storage.TerrainSize;\n\t\t_endUV = new Vector2( _endLocal.x, _endLocal.y ) / storage.TerrainSize;\n\n\t\t_halfWidthTexels = Math.Max( settings.BridgeWidth * 0.5f / storage.TerrainSize * storage.Resolution, 1.0f );\n\n\t\tvar spanTexels = (_endUV - startUV) * storage.Resolution;\n\t\tif ( spanTexels.Length < 1.0f )\n\t\t\treturn;\n\n\t\tint size = (int)Math.Ceiling( Math.Max( Math.Abs( spanTexels.x ), Math.Abs( spanTexels.y ) ) + _halfWidthTexels * 2.0f ) + 2;\n\t\tsize = Math.Clamp( size, 1, storage.Resolution );\n\n\t\tSlopeStartUV = startUV;\n\t\tSlopeStartHeight01 = _startLocal.z / storage.TerrainHeight;\n\n\t\tvar paint = new SubTerrainPaintParameters\n\t\t{\n\t\t\tHitPosition = _endLocal,\n\t\t\tHitUV = (startUV + _endUV) * 0.5f,\n\t\t\tFlattenHeight = _endLocal.z / storage.TerrainHeight,\n\t\t\tBrushSettings = settings\n\t\t};\n\n\t\tvar brushTexture = _parent.SelectedBrushTexture;\n\t\tif ( brushTexture is null )\n\t\t\treturn;\n\n\t\tDispatchSculpt( terrain, paint, brushTexture, size, 1.0f );\n\t}\n\n\tprotected override void ApplyExtraAttributes( ComputeShader cs, SubTerrainPaintParameters paint )\n\t{\n\t\tvar settings = paint.BrushSettings;\n\n\t\tcs.Attributes.Set( \"SlopeEndUV\", _endUV );\n\t\tcs.Attributes.Set( \"SlopeEndHeight\", paint.FlattenHeight );\n\t\tcs.Attributes.Set( \"BridgeHalfWidth\", _halfWidthTexels );\n\t\tcs.Attributes.Set( \"BridgeDeckRatio\", 1.0f - settings.BridgeEdgeFalloff );\n\t\tcs.Attributes.Set( \"BridgeRaiseOnly\", settings.BridgeRaiseOnly ? 1 : 0 );\n\t}\n\n\tprotected override void DrawToolOverlay( Terrain terrain, Vector3 worldCenter )\n\t{\n\t\tif ( !_dragging )\n\t\t\treturn;\n\n\t\tvar startWorld = terrain.WorldTransform.PointToWorld( _startLocal );\n\t\tvar span = worldCenter - startWorld;\n\t\tvar flat = new Vector3( span.x, span.y, 0.0f );\n\n\t\tGizmo.Draw.Color = Color.FromBytes( 250, 180, 120 );\n\t\tGizmo.Draw.Line( startWorld, worldCenter );\n\n\t\tif ( flat.Length < 0.001f )\n\t\t\treturn;\n\n\t\tvar halfWidth = _parent.BrushSettings.BridgeWidth * 0.5f;\n\t\tvar side = new Vector3( -flat.y, flat.x, 0.0f ) / flat.Length * halfWidth;\n\n\t\tGizmo.Draw.Line( startWorld + side, worldCenter + side );\n\t\tGizmo.Draw.Line( startWorld - side, worldCenter - side );\n\t\tGizmo.Draw.Line( startWorld - side, startWorld + side );\n\t\tGizmo.Draw.Line( worldCenter - side, worldCenter + side );\n\t}\n}"
},
{
"Ident": "subzerostudios.subterrain-tool",
"Path": "Editor/Tools/SetHeightTool.cs",
"FileName": "SetHeightTool.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 344638,
"Code": "// Copyright (c) 2026 SubZero Studios LLC. All rights reserved.\r\n\r\nusing Sandbox;\r\n\r\nnamespace Editor.SubTerrain;\r\n\r\n/// <summary>\r\n/// Flatten to Target Height. Alt-click the ground to load that Z.\r\n/// </summary>\r\n[Title( \"Set Height\" )]\r\n[Icon( \"straighten\" )]\r\n[Alias( \"subterrain_set_height\" )]\r\n[Group( \"1\" )]\r\n[Order( 7 )]\r\npublic class SubSetHeightTool : SubTerrainBaseBrushTool\r\n{\r\n\tpublic SubSetHeightTool( SubTerrainTool parent ) : base( parent )\r\n\t{\r\n\t\tMode = SubTerrainSculptMode.Flatten;\r\n\t\tAllowHeightSample = true;\r\n\t}\r\n\r\n\tprotected override void OnPaint( Terrain terrain, SubTerrainPaintParameters paint )\r\n\t{\r\n\t\tpaint.FlattenHeight = paint.BrushSettings.TargetHeight / terrain.Storage.TerrainHeight;\r\n\t\tbase.OnPaint( terrain, paint );\r\n\t}\r\n}\r\n"
},
{
"Ident": "subzerostudios.subterrain-tool",
"Path": "TerrainSculpt.cs",
"FileName": "TerrainSculpt.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 344638,
"Code": "// Copyright (c) 2026 SubZero Studios LLC. All rights reserved.\r\n\r\nusing System;\r\nusing System.Collections.Generic;\r\nusing Sandbox;\r\nusing Sandbox.Clutter;\r\n\r\nnamespace SubZero.SubTerrain;\r\n\r\n/// <summary>\r\n/// Runtime heightfield editor: queue CPU stamps, then SyncGPUTexture + region UpdateCollision.\r\n/// Play-only. Add this next to a Terrain component. One sculpt per Terrain.\r\n/// </summary>\r\n[Title( \"Terrain Sculpt\" )]\r\n[Category( \"SubTerrain\" )]\r\n[Icon( \"landslide\" )]\r\npublic sealed class TerrainSculpt : Component\r\n{\r\n\tconst int MinTexelsAcross = 8;\r\n\r\n\t[Property, Group( \"Limits\" ), Range( 16f, 512f )]\r\n\tpublic float MaxRadius { get; set; } = 240f;\r\n\r\n\t[Property, Group( \"Limits\" ), Range( 0f, 64f )]\r\n\tpublic float FloorMargin { get; set; } = 16f;\r\n\r\n\t[Property, Group( \"Limits\" ), Range( 64f, 2048f )]\r\n\tpublic float GroundDrop { get; set; } = 512f;\r\n\r\n\t[Property, Group( \"Crater\" )]\r\n\tpublic CraterBrushSettings Crater { get; set; } = new();\r\n\r\n\t[Property, Group( \"Dig\" )]\r\n\tpublic DigBrushSettings Dig { get; set; } = new();\r\n\r\n\t[Property, Group( \"Flatten\" )]\r\n\tpublic FlattenBrushSettings Flatten { get; set; } = new();\r\n\r\n\t[Property, Group( \"Paint\" )]\r\n\tpublic bool PaintDirt { get; set; }\r\n\r\n\t[Property, Group( \"Paint\" ), Range( 0, 31 )]\r\n\tpublic int PaintMaterialIndex { get; set; } = 1;\r\n\r\n\t[Property, Group( \"Paint\" ), Range( 0f, 1f )]\r\n\tpublic float PaintStrength { get; set; } = 0.85f;\r\n\r\n\t[Property, Group( \"Perf\" ), Range( 1, 16 )]\r\n\tpublic int StampsPerFrame { get; set; } = 4;\r\n\r\n\t[Property, Group( \"Debug\" )]\r\n\tpublic bool DebugLog { get; set; } = true;\r\n\r\n\tpublic Action<TerrainEditResult> OnApplied { get; set; }\r\n\r\n\t/// <summary>Fired after a successful play-mode reset (Clear Edits / ResetAll). Debris listens here.</summary>\r\n\tpublic Action OnReset { get; set; }\r\n\r\n\treadonly List<TerrainEditRequest> _queue = new();\r\n\treadonly List<TerrainEditResult> _appliedThisFlush = new();\r\n\tushort[] _assetHeight;\r\n\tuint[] _assetControl;\r\n\tushort[] _baseHeight;\r\n\tuint[] _baseControl;\r\n\tfloat _assetZ;\r\n\tbool _floorRaised;\r\n\tbool _warnedLowRes;\r\n\tbool _paintedThisApply;\r\n\tushort[] _mergeScratch;\r\n\r\n\tprotected override void OnStart()\r\n\t{\r\n\t\tif ( !Game.IsPlaying )\r\n\t\t\treturn;\r\n\r\n\t\tvar ok = EnsureSnapshot();\r\n\t\tDbg( ok\r\n\t\t\t? $\"snapshot ready res={GetTerrain()?.Storage?.Resolution} size={GetTerrain()?.Storage?.TerrainSize} height={GetTerrain()?.Storage?.TerrainHeight} texels={_baseHeight?.Length}\"\r\n\t\t\t: \"snapshot FAILED \u2014 no Terrain/Storage on this object\" );\r\n\t}\r\n\r\n\tprotected override void OnDisabled()\r\n\t{\r\n\t\tRestoreFromSnapshot();\r\n\t}\r\n\r\n\tprotected override void OnDestroy()\r\n\t{\r\n\t\tRestoreFromSnapshot();\r\n\t}\r\n\r\n\tprotected override void OnFixedUpdate()\r\n\t{\r\n\t\tif ( !Game.IsPlaying )\r\n\t\t\treturn;\r\n\r\n\t\tApplyQueued();\r\n\t}\r\n\r\n\t/// <summary>Queue an edit. Returns whether it was accepted (flushes next fixed update).</summary>\r\n\tpublic static bool TryApply( Scene scene, TerrainEditRequest request )\r\n\t{\r\n\t\tif ( scene == null || !scene.IsValid() )\r\n\t\t{\r\n\t\t\tLog.Warning( \"[Sculpt] TryApply \u2014 scene invalid\" );\r\n\t\t\treturn false;\r\n\t\t}\r\n\t\tif ( !Game.IsPlaying )\r\n\t\t{\r\n\t\t\tLog.Info( \"[Sculpt] TryApply ignored \u2014 not playing\" );\r\n\t\t\treturn false;\r\n\t\t}\r\n\t\tif ( request.Radius <= 0f )\r\n\t\t{\r\n\t\t\tLog.Info( $\"[Sculpt] TryApply ignored \u2014 radius={request.Radius}\" );\r\n\t\t\treturn false;\r\n\t\t}\r\n\r\n\t\tTerrainSculpt best = null;\r\n\t\tvar bestDist = float.MaxValue;\r\n\t\tvar candidates = 0;\r\n\r\n\t\tforeach ( var sculpt in scene.GetAllComponents<TerrainSculpt>() )\r\n\t\t{\r\n\t\t\tcandidates++;\r\n\t\t\tif ( !sculpt.Active )\r\n\t\t\t\tcontinue;\r\n\r\n\t\t\tvar terrain = sculpt.GetTerrain();\r\n\t\t\tif ( terrain == null || !terrain.IsValid() || terrain.Storage == null )\r\n\t\t\t{\r\n\t\t\t\tsculpt.Dbg( \"TryApply skip \u2014 no Terrain/Storage\" );\r\n\t\t\t\tcontinue;\r\n\t\t\t}\r\n\r\n\t\t\tif ( !sculpt.TryProjectToTerrain( terrain, request.WorldPos, out var local ) )\r\n\t\t\t{\r\n\t\t\t\tsculpt.Dbg( $\"TryApply miss project pos={request.WorldPos} drop={sculpt.GroundDrop}\" );\r\n\t\t\t\tcontinue;\r\n\t\t\t}\r\n\r\n\t\t\tvar hitWorld = terrain.WorldTransform.PointToWorld( local );\r\n\t\t\tvar dist = request.WorldPos.DistanceSquared( hitWorld );\r\n\t\t\tif ( dist >= bestDist )\r\n\t\t\t\tcontinue;\r\n\r\n\t\t\tbestDist = dist;\r\n\t\t\tbest = sculpt;\r\n\t\t}\r\n\r\n\t\tif ( best == null )\r\n\t\t{\r\n\t\t\tLog.Warning( $\"[Sculpt] TryApply \u2014 no terrain under {request.WorldPos} (components={candidates})\" );\r\n\t\t\treturn false;\r\n\t\t}\r\n\r\n\t\tif ( request.Seed == 0 )\r\n\t\t\trequest.Seed = Game.Random.Int( 1, 1_000_000_000 );\r\n\r\n\t\tbest.Dbg( $\"TryApply hit kind={request.Kind} dist={MathF.Sqrt( bestDist ):0.#} pos={request.WorldPos}\" );\r\n\t\tbest._queue.Add( request );\r\n\t\treturn true;\r\n\t}\r\n\r\n\tpublic static int ResetAll( Scene scene )\r\n\t{\r\n\t\tif ( scene == null || !scene.IsValid() )\r\n\t\t{\r\n\t\t\tLog.Warning( \"[Sculpt] ResetAll \u2014 scene invalid\" );\r\n\t\t\treturn 0;\r\n\t\t}\r\n\r\n\t\tvar cleared = 0;\r\n\t\tforeach ( var sculpt in scene.GetAllComponents<TerrainSculpt>() )\r\n\t\t{\r\n\t\t\tif ( sculpt.ResetEdits() )\r\n\t\t\t\tcleared++;\r\n\t\t}\r\n\r\n\t\tLog.Info( $\"[Sculpt] ResetAll \u2014 cleared {cleared} terrain(s)\" );\r\n\t\treturn cleared;\r\n\t}\r\n\r\n\t[Button( \"Clear Edits\" )]\r\n\tpublic void ClearEdits()\r\n\t{\r\n\t\tResetEdits();\r\n\t}\r\n\r\n\t[Button( \"Clear All Scene Edits\" )]\r\n\tpublic void ClearAllSceneEdits()\r\n\t{\r\n\t\tResetAll( Scene );\r\n\t}\r\n\r\n\tpublic bool ResetEdits()\r\n\t{\r\n\t\tif ( _baseHeight == null )\r\n\t\t{\r\n\t\t\tLog.Warning( $\"[Sculpt] {GameObject.Name}: Clear Edits \u2014 no snapshot (play first, or already restored)\" );\r\n\t\t\treturn false;\r\n\t\t}\r\n\r\n\t\tvar terrain = GetTerrain();\r\n\t\tif ( terrain == null || !terrain.IsValid() || terrain.Storage == null )\r\n\t\t{\r\n\t\t\tLog.Warning( $\"[Sculpt] {GameObject.Name}: Clear Edits \u2014 no Terrain/Storage\" );\r\n\t\t\treturn false;\r\n\t\t}\r\n\r\n\t\tvar storage = terrain.Storage;\r\n\t\tif ( storage.HeightMap.Length != _baseHeight.Length )\r\n\t\t{\r\n\t\t\tLog.Warning( $\"[Sculpt] {GameObject.Name}: Clear Edits \u2014 size mismatch snapshot={_baseHeight.Length} map={storage.HeightMap.Length}\" );\r\n\t\t\treturn false;\r\n\t\t}\r\n\r\n\t\t_baseHeight.CopyTo( storage.HeightMap, 0 );\r\n\t\tif ( _baseControl != null && storage.ControlMap.Length == _baseControl.Length )\r\n\t\t\t_baseControl.CopyTo( storage.ControlMap, 0 );\r\n\r\n\t\tPushToEngine( terrain, FullRect( storage.Resolution ), painted: _baseControl != null );\r\n\t\t_queue.Clear();\r\n\t\tOnReset?.Invoke();\r\n\t\tLog.Info( $\"[Sculpt] {GameObject.Name}: edits cleared\" );\r\n\t\treturn true;\r\n\t}\r\n\r\n\tvoid ApplyQueued()\r\n\t{\r\n\t\tif ( _queue.Count == 0 )\r\n\t\t\treturn;\r\n\r\n\t\tvar terrain = GetTerrain();\r\n\t\tif ( terrain == null || !terrain.IsValid() || terrain.Storage == null )\r\n\t\t{\r\n\t\t\tDbg( $\"ApplyQueued \u2014 dropped {_queue.Count} stamps (no Terrain/Storage)\" );\r\n\t\t\t_queue.Clear();\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tif ( !EnsureSnapshot() )\r\n\t\t{\r\n\t\t\tDbg( \"ApplyQueued \u2014 snapshot failed\" );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tvar limit = Math.Clamp( StampsPerFrame, 1, 16 );\r\n\t\tvar take = Math.Min( limit, _queue.Count );\r\n\r\n\t\tRectInt dirty = default;\r\n\t\tvar any = false;\r\n\t\t_paintedThisApply = false;\r\n\t\t_appliedThisFlush.Clear();\r\n\r\n\t\tfor ( var i = 0; i < take; i++ )\r\n\t\t{\r\n\t\t\tif ( !ApplyStamp( terrain, _queue[i], out var rect, out var result ) )\r\n\t\t\t\tcontinue;\r\n\r\n\t\t\t_appliedThisFlush.Add( result );\r\n\t\t\tif ( !any )\r\n\t\t\t{\r\n\t\t\t\tdirty = rect;\r\n\t\t\t\tany = true;\r\n\t\t\t}\r\n\t\t\telse\r\n\t\t\t{\r\n\t\t\t\tdirty.Add( rect );\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\t_queue.RemoveRange( 0, take );\r\n\r\n\t\tif ( !any )\r\n\t\t{\r\n\t\t\tDbg( \"ApplyQueued \u2014 all stamps missed terrain\" );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tPushToEngine( terrain, dirty, _paintedThisApply );\r\n\t\tDbg( $\"pushed dirty=({dirty.Left},{dirty.Top},{dirty.Width}x{dirty.Height}) painted={_paintedThisApply} remaining={_queue.Count}\" );\r\n\r\n\t\tvar applied = OnApplied;\r\n\t\tif ( applied == null )\r\n\t\t\treturn;\r\n\r\n\t\tfor ( var i = 0; i < _appliedThisFlush.Count; i++ )\r\n\t\t\tapplied( _appliedThisFlush[i] );\r\n\t}\r\n\r\n\tbool ApplyStamp( Terrain terrain, TerrainEditRequest request, out RectInt dirty, out TerrainEditResult result )\r\n\t{\r\n\t\tdirty = default;\r\n\t\tresult = default;\r\n\r\n\t\tvar storage = terrain.Storage;\r\n\t\tif ( storage == null || _baseHeight == null )\r\n\t\t\treturn false;\r\n\r\n\t\tvar radius = Math.Clamp( request.Radius, 1f, MaxRadius );\r\n\t\tvar depth = ResolveDepth( request.Kind, radius, request.Depth );\r\n\t\tif ( request.Kind != TerrainEditKind.Flatten && depth <= 0f )\r\n\t\t{\r\n\t\t\tDbg( $\"ApplyStamp \u2014 depth 0 kind={request.Kind}\" );\r\n\t\t\treturn false;\r\n\t\t}\r\n\r\n\t\tif ( !TryProjectToTerrain( terrain, request.WorldPos, out var local ) )\r\n\t\t{\r\n\t\t\tDbg( $\"ApplyStamp \u2014 project miss world={request.WorldPos}\" );\r\n\t\t\treturn false;\r\n\t\t}\r\n\r\n\t\tvar size = storage.TerrainSize;\r\n\t\tvar height = storage.TerrainHeight;\r\n\t\tvar res = storage.Resolution;\r\n\t\tif ( size <= 0f || height <= 0f || res <= 0 )\r\n\t\t\treturn false;\r\n\r\n\t\tvar uv = new Vector2( local.x, local.y ) / size;\r\n\t\tif ( uv.x < 0f || uv.y < 0f || uv.x > 1f || uv.y > 1f )\r\n\t\t\treturn false;\r\n\r\n\t\tvar texelSize = size / res;\r\n\t\tvar across = radius * 2f / texelSize;\r\n\t\tif ( across < MinTexelsAcross && !_warnedLowRes )\r\n\t\t{\r\n\t\t\t_warnedLowRes = true;\r\n\t\t\tLog.Warning( $\"[TerrainSculpt] Radius {radius:0} spans {across:0.0} texels (need >={MinTexelsAcross} to hide a character). texelSize={texelSize:0.0}.\" );\r\n\t\t}\r\n\r\n\t\tvar cx = (int)Math.Floor( res * uv.x );\r\n\t\tvar cy = (int)Math.Floor( res * uv.y );\r\n\t\tvar irreg = request.Kind switch\r\n\t\t{\r\n\t\t\tTerrainEditKind.Dig => Math.Clamp( Dig.Irregularity, 0f, 0.4f ),\r\n\t\t\tTerrainEditKind.Flatten => 0f,\r\n\t\t\t_ => Math.Clamp( Crater.Irregularity, 0f, 0.7f )\r\n\t\t};\r\n\t\tvar stretchAmt = request.Kind switch\r\n\t\t{\r\n\t\t\tTerrainEditKind.Dig => Math.Clamp( Dig.Stretch, 0f, 0.35f ),\r\n\t\t\tTerrainEditKind.Flatten => 0f,\r\n\t\t\t_ => Math.Clamp( Crater.Stretch, 0f, 0.5f )\r\n\t\t};\r\n\t\tvar blend = request.Kind switch\r\n\t\t{\r\n\t\t\tTerrainEditKind.Dig => Math.Clamp( Dig.Blend, 0f, 1f ),\r\n\t\t\tTerrainEditKind.Flatten => 0f,\r\n\t\t\t_ => Math.Clamp( Crater.Blend, 0f, 1f )\r\n\t\t};\r\n\t\tvar shapePad = 1f + irreg + stretchAmt + 0.25f + blend * 0.15f;\r\n\t\tvar radTexels = Math.Max( 1, (int)Math.Ceiling( radius / texelSize * shapePad ) + 3 );\r\n\t\tdirty = new RectInt( cx - radTexels, cy - radTexels, radTexels * 2 + 1, radTexels * 2 + 1 );\r\n\r\n\t\tvar x0 = Math.Clamp( dirty.Left, 0, res - 1 );\r\n\t\tvar y0 = Math.Clamp( dirty.Top, 0, res - 1 );\r\n\t\tvar x1 = Math.Clamp( dirty.Right, 0, res );\r\n\t\tvar y1 = Math.Clamp( dirty.Bottom, 0, res );\r\n\t\tif ( x1 <= x0 || y1 <= y0 )\r\n\t\t\treturn false;\r\n\r\n\t\tdirty = new RectInt( x0, y0, x1 - x0, y1 - y0 );\r\n\r\n\t\tvar crater = Crater;\r\n\t\tvar dig = Dig;\r\n\t\tvar maxDepth = request.Kind == TerrainEditKind.Dig ? dig.MaxDepth : crater.MaxDepth;\r\n\t\tvar maxRaise = request.Kind == TerrainEditKind.Dig ? dig.MaxRaise : crater.MaxRaise;\r\n\t\tvar bowlFrac = request.Kind == TerrainEditKind.Dig\r\n\t\t\t? Math.Clamp( dig.BowlRadius, 0.7f, 0.98f )\r\n\t\t\t: Math.Clamp( crater.BowlRadius, 0.4f, 0.95f );\r\n\r\n\t\tvar depthU = WorldToUshort( depth, height );\r\n\t\tvar maxDepthU = WorldToUshort( maxDepth, height );\r\n\t\tvar raiseU = WorldToUshort( maxRaise, height );\r\n\r\n\t\tvar heights = storage.HeightMap;\r\n\t\tvar control = storage.ControlMap;\r\n\t\tvar paint = PaintDirt && PaintMaterialIndex >= 0\r\n\t\t\t&& storage.Materials != null\r\n\t\t\t&& PaintMaterialIndex < storage.Materials.Count;\r\n\r\n\t\tif ( paint )\r\n\t\t\tEnsureControlSnapshot();\r\n\r\n\t\tvar originX = uv.x * res;\r\n\t\tvar originY = uv.y * res;\r\n\t\tvar radiusTexels = radius / texelSize;\r\n\t\tif ( radiusTexels < 0.5f )\r\n\t\t\treturn false;\r\n\r\n\t\tvar rng = new Random( request.Seed == 0 ? 1 : request.Seed );\r\n\t\tvar rot = rng.Float( 0f, MathF.PI * 2f );\r\n\t\tvar stretchX = Math.Clamp( 1f + rng.Float( -stretchAmt, stretchAmt ), 0.55f, 1.7f );\r\n\t\tif ( stretchAmt <= 0.001f )\r\n\t\t\tstretchX = 1f;\r\n\r\n\t\tvar sampleX = Math.Clamp( cx, x0, x1 - 1 );\r\n\t\tvar sampleY = Math.Clamp( cy, y0, y1 - 1 );\r\n\t\tvar sampleI = sampleX + sampleY * res;\r\n\t\tvar heightBefore = heights[sampleI];\r\n\r\n\t\tvar flattenTargetU = (int)heightBefore;\r\n\t\tif ( request.Kind == TerrainEditKind.Flatten && request.HasTargetZ )\r\n\t\t{\r\n\t\t\tvar targetLocal = terrain.WorldTransform.PointToLocal( new Vector3( request.WorldPos.x, request.WorldPos.y, request.TargetZ ) );\r\n\t\t\tflattenTargetU = WorldToUshort( targetLocal.z, height );\r\n\t\t}\r\n\r\n\t\tvar ctx = new TerrainBrushes.Context\r\n\t\t{\r\n\t\t\tHeights = heights,\r\n\t\t\tBase = _baseHeight,\r\n\t\t\tControl = control,\r\n\t\t\tRes = res,\r\n\t\t\tX0 = x0,\r\n\t\t\tY0 = y0,\r\n\t\t\tX1 = x1,\r\n\t\t\tY1 = y1,\r\n\t\t\tOriginX = originX,\r\n\t\t\tOriginY = originY,\r\n\t\t\tRadiusTexels = radiusTexels,\r\n\t\t\tCa = MathF.Cos( rot ),\r\n\t\t\tSa = MathF.Sin( rot ),\r\n\t\t\tStretchX = stretchX,\r\n\t\t\tStretchY = 1f / stretchX,\r\n\t\t\tOx = rng.Float( 0f, 64f ),\r\n\t\t\tOy = rng.Float( 0f, 64f ),\r\n\t\t\tOz = rng.Float( 0f, 64f ),\r\n\t\t\tFreq = 2.1f + rng.Float( 0f, 2.4f ),\r\n\t\t\tIrreg = irreg,\r\n\t\t\tDepthU = depthU,\r\n\t\t\tMaxDepthU = maxDepthU,\r\n\t\t\tRaiseU = raiseU,\r\n\t\t\tBowlFrac = bowlFrac,\r\n\t\t\tSmoothK = blend * depthU * 0.55f,\r\n\t\t\tDugU = Math.Max( 2, WorldToUshort( 8f, height ) ),\r\n\t\t\tPaint = paint,\r\n\t\t\tPaintIndex = PaintMaterialIndex,\r\n\t\t\tPaintStrength = PaintStrength,\r\n\t\t\tFlattenTargetU = flattenTargetU,\r\n\t\t\tFlattenStrength = Math.Clamp( Flatten.Strength, 0.05f, 1f ),\r\n\t\t\tFlattenMaxDeltaU = WorldToUshort( Flatten.MaxDelta, height ),\r\n\t\t\tSpoilAngle = rng.Float( 0f, MathF.PI * 2f )\r\n\t\t};\r\n\r\n\t\tif ( request.Kind != TerrainEditKind.Flatten )\r\n\t\t{\r\n\t\t\tvar eccA = rng.Float( 0f, MathF.PI * 2f );\r\n\t\t\tvar ecc = rng.Float( 0f, irreg * 0.22f ) * radiusTexels;\r\n\t\t\tctx.OriginX = originX + MathF.Cos( eccA ) * ecc;\r\n\t\t\tctx.OriginY = originY + MathF.Sin( eccA ) * ecc;\r\n\t\t}\r\n\r\n\t\tswitch ( request.Kind )\r\n\t\t{\r\n\t\t\tcase TerrainEditKind.Dig:\r\n\t\t\t\tTerrainBrushes.ApplyDig( ref ctx );\r\n\t\t\t\tbreak;\r\n\t\t\tcase TerrainEditKind.Flatten:\r\n\t\t\t\tTerrainBrushes.ApplyFlatten( ref ctx );\r\n\t\t\t\tbreak;\r\n\t\t\tdefault:\r\n\t\t\t\tTerrainBrushes.ApplyCrater( ref ctx );\r\n\t\t\t\tbreak;\r\n\t\t}\r\n\r\n\t\tif ( ctx.Painted )\r\n\t\t\t_paintedThisApply = true;\r\n\r\n\t\tif ( blend > 0.01f && request.Kind != TerrainEditKind.Flatten )\r\n\t\t\tBreakSaddles( storage, x0, y0, x1, y1, maxDepthU, ctx.DugU, blend, paint );\r\n\r\n\t\tvar heightAfter = heights[sampleI];\r\n\t\tvar dropWorld = (heightBefore - heightAfter) / (float)ushort.MaxValue * height;\r\n\t\tvar volume = ctx.VolumeU / (float)ushort.MaxValue * height * texelSize * texelSize;\r\n\t\tDbg( $\"stamp kind={request.Kind} uv={uv.x:0.000},{uv.y:0.000} texel={cx},{cy} across={across:0.0} r={radius:0.#} d={depth:0.#} blend={blend:0.00} center {heightBefore}->{heightAfter} drop={dropWorld:0.0}u wrote={ctx.Written} rect=({dirty.Left},{dirty.Top},{dirty.Width}x{dirty.Height})\" );\r\n\r\n\t\tresult = new TerrainEditResult\r\n\t\t{\r\n\t\t\tKind = request.Kind,\r\n\t\t\tOrigin = request.WorldPos,\r\n\t\t\tRadius = radius,\r\n\t\t\tDepth = depth,\r\n\t\t\tDirty = dirty,\r\n\t\t\tTexels = ctx.Written,\r\n\t\t\tVolumeRemoved = volume\r\n\t\t};\r\n\t\treturn true;\r\n\t}\r\n\r\n\tvoid BreakSaddles( TerrainStorage storage, int x0, int y0, int x1, int y1, int maxDepthU, int dugU, float blend, bool paint )\r\n\t{\r\n\t\tif ( _baseHeight == null )\r\n\t\t\treturn;\r\n\r\n\t\tvar res = storage.Resolution;\r\n\t\tvar w = x1 - x0;\r\n\t\tvar hgt = y1 - y0;\r\n\t\tif ( w < 3 || hgt < 3 )\r\n\t\t\treturn;\r\n\r\n\t\tvar need = w * hgt;\r\n\t\tif ( _mergeScratch == null || _mergeScratch.Length < need )\r\n\t\t\t_mergeScratch = new ushort[need];\r\n\r\n\t\tvar heights = storage.HeightMap;\r\n\t\tvar control = storage.ControlMap;\r\n\t\tvar scratch = _mergeScratch;\r\n\r\n\t\tfor ( var y = 0; y < hgt; y++ )\r\n\t\t{\r\n\t\t\tvar src = (y0 + y) * res + x0;\r\n\t\t\tArray.Copy( heights, src, scratch, y * w, w );\r\n\t\t}\r\n\r\n\t\tfor ( var y = 1; y < hgt - 1; y++ )\r\n\t\t{\r\n\t\t\tfor ( var x = 1; x < w - 1; x++ )\r\n\t\t\t{\r\n\t\t\t\tvar i = (y0 + y) * res + (x0 + x);\r\n\t\t\t\tvar row = y * w + x;\r\n\t\t\t\tvar left = scratch[row - 1];\r\n\t\t\t\tvar right = scratch[row + 1];\r\n\t\t\t\tvar down = scratch[row - w];\r\n\t\t\t\tvar up = scratch[row + w];\r\n\t\t\t\tvar leftD = (int)_baseHeight[i - 1] - left >= dugU;\r\n\t\t\t\tvar rightD = (int)_baseHeight[i + 1] - right >= dugU;\r\n\t\t\t\tvar downD = (int)_baseHeight[i - res] - down >= dugU;\r\n\t\t\t\tvar upD = (int)_baseHeight[i + res] - up >= dugU;\r\n\t\t\t\tif ( !((leftD && rightD) || (downD && upD)) )\r\n\t\t\t\t\tcontinue;\r\n\r\n\t\t\t\tvar minN = int.MaxValue;\r\n\t\t\t\tif ( leftD ) minN = Math.Min( minN, left );\r\n\t\t\t\tif ( rightD ) minN = Math.Min( minN, right );\r\n\t\t\t\tif ( downD ) minN = Math.Min( minN, down );\r\n\t\t\t\tif ( upD ) minN = Math.Min( minN, up );\r\n\r\n\t\t\t\tvar self = scratch[row];\r\n\t\t\t\tif ( minN >= self )\r\n\t\t\t\t\tcontinue;\r\n\r\n\t\t\t\tvar b = (int)_baseHeight[i];\r\n\t\t\t\tvar merged = (int)MathF.Round( self + (minN - self) * blend );\r\n\t\t\t\tmerged = Math.Max( merged, b - maxDepthU );\r\n\t\t\t\theights[i] = (ushort)Math.Clamp( merged, 0, ushort.MaxValue );\r\n\r\n\t\t\t\tif ( !paint || control == null )\r\n\t\t\t\t\tcontinue;\r\n\r\n\t\t\t\tvar mat = new CompactTerrainMaterial( control[i] );\r\n\t\t\t\tif ( mat.IsHole )\r\n\t\t\t\t\tcontinue;\r\n\r\n\t\t\t\tmat.OverlayTextureId = (byte)PaintMaterialIndex;\r\n\t\t\t\tmat.BlendFactor = (byte)Math.Clamp( mat.BlendFactor + (int)(blend * 80f), 0, 255 );\r\n\t\t\t\tcontrol[i] = mat.Packed;\r\n\t\t\t\t_paintedThisApply = true;\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\r\n\tbool TryProjectToTerrain( Terrain terrain, Vector3 worldPos, out Vector3 local )\r\n\t{\r\n\t\tlocal = default;\r\n\t\tvar origin = worldPos + Vector3.Up * 8f;\r\n\t\tvar ray = new Ray( origin, Vector3.Down );\r\n\t\tif ( terrain.RayIntersects( ray, GroundDrop + 8f, out local ) )\r\n\t\t\treturn true;\r\n\r\n\t\tlocal = terrain.WorldTransform.PointToLocal( worldPos );\r\n\t\tvar size = terrain.Storage?.TerrainSize ?? 0f;\r\n\t\treturn local.x >= 0f && local.y >= 0f && local.x <= size && local.y <= size;\r\n\t}\r\n\r\n\tvoid PushToEngine( Terrain terrain, RectInt region, bool painted )\r\n\t{\r\n\t\tif ( region.Width < 1 || region.Height < 1 )\r\n\t\t\treturn;\r\n\r\n\t\tif ( terrain.HeightMap != null )\r\n\t\t\tterrain.SyncGPUTexture();\r\n\r\n\t\tvar flags = Terrain.SyncFlags.Height;\r\n\t\tif ( painted )\r\n\t\t\tflags |= Terrain.SyncFlags.Control;\r\n\r\n\t\tterrain.UpdateCollision( flags, region );\r\n\t\tInvalidateClutter( terrain, region );\r\n\t}\r\n\r\n\tvoid InvalidateClutter( Terrain terrain, RectInt region )\r\n\t{\r\n\t\tif ( !Scene.IsValid() )\r\n\t\t\treturn;\r\n\r\n\t\tvar clutter = Scene.GetSystem<ClutterGridSystem>();\r\n\t\tif ( clutter == null )\r\n\t\t\treturn;\r\n\r\n\t\tvar storage = terrain.Storage;\r\n\t\tif ( storage == null )\r\n\t\t\treturn;\r\n\r\n\t\tvar res = storage.Resolution;\r\n\t\tvar size = storage.TerrainSize;\r\n\t\tvar minLocal = new Vector3( region.Left / (float)res * size, region.Top / (float)res * size, -1000f );\r\n\t\tvar maxLocal = new Vector3( region.Right / (float)res * size, region.Bottom / (float)res * size, 1000f );\r\n\t\tvar a = terrain.WorldTransform.PointToWorld( minLocal );\r\n\t\tvar b = terrain.WorldTransform.PointToWorld( maxLocal );\r\n\t\tclutter.InvalidateTilesInBounds( new BBox( a, b ) );\r\n\t}\r\n\r\n\tbool EnsureSnapshot()\r\n\t{\r\n\t\tvar terrain = GetTerrain();\r\n\t\tif ( terrain == null || !terrain.IsValid() || terrain.Storage?.HeightMap == null )\r\n\t\t\treturn false;\r\n\r\n\t\tvar map = terrain.Storage.HeightMap;\r\n\t\tif ( _baseHeight != null && _baseHeight.Length == map.Length )\r\n\t\t\treturn true;\r\n\r\n\t\t_assetZ = WorldPosition.z;\r\n\t\t_assetHeight = new ushort[map.Length];\r\n\t\tmap.CopyTo( _assetHeight, 0 );\r\n\t\t_baseHeight = new ushort[map.Length];\r\n\t\tmap.CopyTo( _baseHeight, 0 );\r\n\t\tSnapshotControl();\r\n\t\tRaiseFloor( terrain );\r\n\t\treturn true;\r\n\t}\r\n\r\n\tvoid SnapshotControl()\r\n\t{\r\n\t\tvar terrain = GetTerrain();\r\n\t\tif ( terrain?.Storage?.ControlMap == null )\r\n\t\t\treturn;\r\n\r\n\t\tvar control = terrain.Storage.ControlMap;\r\n\t\t_assetControl = new uint[control.Length];\r\n\t\tcontrol.CopyTo( _assetControl, 0 );\r\n\t\t_baseControl = new uint[control.Length];\r\n\t\tcontrol.CopyTo( _baseControl, 0 );\r\n\t}\r\n\r\n\tvoid EnsureControlSnapshot()\r\n\t{\r\n\t\tif ( _baseControl != null )\r\n\t\t\treturn;\r\n\r\n\t\tSnapshotControl();\r\n\t}\r\n\r\n\tvoid RaiseFloor( Terrain terrain )\r\n\t{\r\n\t\tvar storage = terrain.Storage;\r\n\t\tif ( storage == null || _baseHeight == null )\r\n\t\t\treturn;\r\n\r\n\t\tvar height = storage.TerrainHeight;\r\n\t\tvar needWorld = MathF.Max( Crater.MaxDepth, Dig.MaxDepth ) + FloorMargin;\r\n\t\tvar needU = WorldToUshort( needWorld, height );\r\n\r\n\t\tvar min = ushort.MaxValue;\r\n\t\tfor ( var i = 0; i < _baseHeight.Length; i++ )\r\n\t\t{\r\n\t\t\tif ( _baseHeight[i] < min )\r\n\t\t\t\tmin = _baseHeight[i];\r\n\t\t}\r\n\r\n\t\tif ( min >= needU )\r\n\t\t{\r\n\t\t\tDbg( $\"floor ok min={min} need={needU} ({needWorld:0.#}u of {height:0.#})\" );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tvar pad = needU - min;\r\n\t\tfor ( var i = 0; i < _baseHeight.Length; i++ )\r\n\t\t{\r\n\t\t\tvar v = (int)_baseHeight[i] + pad;\r\n\t\t\t_baseHeight[i] = (ushort)Math.Min( v, ushort.MaxValue );\r\n\t\t}\r\n\r\n\t\t_baseHeight.CopyTo( storage.HeightMap, 0 );\r\n\r\n\t\tvar padWorld = pad / (float)ushort.MaxValue * height;\r\n\t\tWorldPosition = WorldPosition.WithZ( _assetZ - padWorld );\r\n\t\t_floorRaised = true;\r\n\r\n\t\tPushToEngine( terrain, FullRect( storage.Resolution ), painted: false );\r\n\t\tDbg( $\"raised floor pad={pad} ({padWorld:0.#}u) min {min}->{min + pad} need={needU} z {_assetZ:0.#}->{WorldPosition.z:0.#}\" );\r\n\t}\r\n\r\n\tvoid RestoreFromSnapshot()\r\n\t{\r\n\t\tif ( _assetHeight == null )\r\n\t\t\treturn;\r\n\r\n\t\tvar terrain = GetTerrain();\r\n\t\tif ( terrain == null || !terrain.IsValid() || terrain.Storage == null )\r\n\t\t\treturn;\r\n\r\n\t\tvar storage = terrain.Storage;\r\n\t\tif ( storage.HeightMap.Length != _assetHeight.Length )\r\n\t\t\treturn;\r\n\r\n\t\t_assetHeight.CopyTo( storage.HeightMap, 0 );\r\n\t\tif ( _assetControl != null && storage.ControlMap.Length == _assetControl.Length )\r\n\t\t\t_assetControl.CopyTo( storage.ControlMap, 0 );\r\n\r\n\t\tif ( _floorRaised )\r\n\t\t{\r\n\t\t\tWorldPosition = WorldPosition.WithZ( _assetZ );\r\n\t\t\t_floorRaised = false;\r\n\t\t}\r\n\r\n\t\tPushToEngine( terrain, FullRect( storage.Resolution ), painted: _assetControl != null );\r\n\t\t_queue.Clear();\r\n\t\t_assetHeight = null;\r\n\t\t_assetControl = null;\r\n\t\t_baseHeight = null;\r\n\t\t_baseControl = null;\r\n\t\tDbg( \"RestoreFromSnapshot \u2014 disk heightmap restored\" );\r\n\t}\r\n\r\n\tfloat ResolveDepth( TerrainEditKind kind, float radius, float depth )\r\n\t{\r\n\t\tif ( kind == TerrainEditKind.Flatten )\r\n\t\t\treturn 0f;\r\n\r\n\t\tvar scale = kind == TerrainEditKind.Dig ? Dig.DepthScale : Crater.DepthScale;\r\n\t\tvar max = kind == TerrainEditKind.Dig ? Dig.MaxDepth : Crater.MaxDepth;\r\n\t\tvar d = depth > 0f ? depth : radius * scale;\r\n\t\treturn Math.Clamp( d, 0f, max );\r\n\t}\r\n\r\n\tTerrain GetTerrain()\r\n\t{\r\n\t\treturn Components.Get<Terrain>( FindMode.EverythingInSelfAndDescendants );\r\n\t}\r\n\r\n\tstatic RectInt FullRect( int resolution ) => new( 0, 0, resolution, resolution );\r\n\r\n\tstatic int WorldToUshort( float world, float terrainHeight )\r\n\t{\r\n\t\tif ( terrainHeight <= 0f )\r\n\t\t\treturn 0;\r\n\t\treturn (int)Math.Clamp( MathF.Round( world / terrainHeight * ushort.MaxValue ), 0f, (float)ushort.MaxValue );\r\n\t}\r\n\r\n\tvoid Dbg( string message )\r\n\t{\r\n\t\tif ( !DebugLog )\r\n\t\t\treturn;\r\n\r\n\t\tLog.Info( $\"[Sculpt] {GameObject.Name}: {message}\" );\r\n\t}\r\n}\r\n"
},
{
"Ident": "subzerostudios.subterrain-tool",
"Path": "Code/TerrainBrushSettings.cs",
"FileName": "TerrainBrushSettings.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 344638,
"Code": "// Copyright (c) 2026 SubZero Studios LLC. All rights reserved.\r\n\r\nusing Sandbox;\r\n\r\nnamespace SubZero.SubTerrain;\r\n\r\npublic sealed class CraterBrushSettings\r\n{\r\n\t[Property, Range( 16f, 200f )]\r\n\tpublic float MaxDepth { get; set; } = 96f;\r\n\r\n\t[Property, Title( \"Depth Scale\" ), Range( 0.1f, 1.5f )]\r\n\tpublic float DepthScale { get; set; } = 0.5f;\r\n\r\n\t[Property, Range( 0f, 32f )]\r\n\tpublic float MaxRaise { get; set; } = 12f;\r\n\r\n\t[Property, Range( 0.4f, 0.95f )]\r\n\tpublic float BowlRadius { get; set; } = 0.75f;\r\n\r\n\t[Property, Range( 0f, 0.7f )]\r\n\tpublic float Irregularity { get; set; } = 0.35f;\r\n\r\n\t[Property, Range( 0f, 0.5f )]\r\n\tpublic float Stretch { get; set; } = 0.25f;\r\n\r\n\t[Property, Range( 0f, 1f )]\r\n\tpublic float Blend { get; set; } = 0.65f;\r\n}\r\n\r\npublic sealed class DigBrushSettings\r\n{\r\n\t[Property, Range( 16f, 120f )]\r\n\tpublic float MaxDepth { get; set; } = 48f;\r\n\r\n\t[Property, Title( \"Depth Scale\" ), Range( 0.1f, 1.5f )]\r\n\tpublic float DepthScale { get; set; } = 0.85f;\r\n\r\n\t[Property, Range( 0f, 40f )]\r\n\tpublic float MaxRaise { get; set; } = 18f;\r\n\r\n\t[Property, Range( 0.7f, 0.98f )]\r\n\tpublic float BowlRadius { get; set; } = 0.9f;\r\n\r\n\t[Property, Range( 0f, 0.4f )]\r\n\tpublic float Irregularity { get; set; } = 0.12f;\r\n\r\n\t[Property, Range( 0f, 0.35f )]\r\n\tpublic float Stretch { get; set; } = 0.08f;\r\n\r\n\t[Property, Range( 0f, 1f )]\r\n\tpublic float Blend { get; set; } = 0.35f;\r\n}\r\n\r\npublic sealed class FlattenBrushSettings\r\n{\r\n\t[Property, Range( 0.05f, 1f )]\r\n\tpublic float Strength { get; set; } = 0.65f;\r\n\r\n\t[Property, Range( 4f, 64f )]\r\n\tpublic float MaxDelta { get; set; } = 24f;\r\n}\r\n"
},
{
"Ident": "subzerostudios.subterrain-tool",
"Path": "Editor/SubTerrainPaintSettings.cs",
"FileName": "SubTerrainPaintSettings.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 344638,
"Code": "// Copyright (c) 2026 SubZero Studios LLC. All rights reserved.\r\n\r\nusing System;\r\nusing System.Collections.Generic;\r\nusing Sandbox;\r\n\r\nnamespace Editor.SubTerrain;\r\n\r\n/// <summary>\r\n/// Paint Texture settings. Ground is the default mode. UI labels live on the [Title]\r\n/// attributes; OnlyOnSlope has no inspector title (Steep | All owns it).\r\n/// </summary>\r\npublic class SubTerrainPaintSettings\r\n{\r\n\tpublic const float WearMixMax = 0.40f;\r\n\tpublic const float PathMixMax = 0.36f;\r\n\tpublic const float DitherWarn = 0.36f;\r\n\tpublic const float PackCoreMin = 0.15f;\r\n\tpublic const float PackCoreMax = 0.95f;\r\n\tpublic const string LibraryVersion = \"1.4.15\";\r\n\r\n\tpublic SubTerrainPaintLayer Layer { get; set; } = SubTerrainPaintLayer.Base;\r\n\r\n\tpublic int MaterialIndex { get; set; }\r\n\r\n\tpublic float MixCap { get; set; } = 0.28f;\r\n\r\n\t/// <summary>Wear only. Move mix on the pair that is already there. The selected tmat is not written.</summary>\r\n\t[Property, Title( \"Keep pair\" ), WideMode]\r\n\tpublic bool MixOnly { get; set; }\r\n\r\n\t/// <summary>Alt-click the ground to stay on. Wear will not paint other bases.</summary>\r\n\t[Property, Title( \"Stay on sampled\" ), WideMode]\r\n\tpublic bool LimitToSampledGround { get; set; }\r\n\r\n\t/// <summary>Path only. Hard brush center packs the path tmat as base.</summary>\r\n\t[Property, Title( \"Pack center\" ), Description( \"On: solid path core along the stroke, soft apron outside. Off: soft ground + path apron only.\" ), WideMode]\r\n\tpublic bool PackCenter { get; set; } = true;\r\n\r\n\t/// <summary>Path + Pack center. Higher = wider solid middle. Outer ring stays soft blend.</summary>\r\n\t[Property, Title( \"Core\" ), Description( \"How much of the brush packs solid. Raise for a wider solid middle. Outer ring stays Center mix apron.\" ), Range( PackCoreMin, PackCoreMax ), Step( 0.01f ), WideMode]\r\n\tpublic float PackCore { get; set; } = 0.55f;\r\n\r\n\t[Property, Title( \"How worn\" ), Description( \"Overlay mix. Default 0.28. Soft chew to 0.36; hard stop 0.40. Above 0.36 can dither.\" ), Range( 0.05f, WearMixMax ), Step( 0.01f ), WideMode]\r\n\tpublic float HowWorn\r\n\t{\r\n\t\tget => MixCap;\r\n\t\tset => MixCap = value;\r\n\t}\r\n\r\n\t[Property, Title( \"Center mix\" ), Description( \"Soft apron overlay mix outside the packed core. Raise with brush Size for a wider blend.\" ), Range( 0.05f, PathMixMax ), Step( 0.01f ), WideMode]\r\n\tpublic float CenterMix\r\n\t{\r\n\t\tget => MixCap;\r\n\t\tset => MixCap = value;\r\n\t}\r\n\r\n\tpublic bool OnlyOnSlope { get; set; } = true;\r\n\r\n\t[Property, Title( \"Steeper than\" ), Description( \"How steep the ground must be. 20 is a hill face. Lower it to catch gentler banks.\" ), Range( 1f, 60f ), Step( 1f ), WideMode]\r\n\tpublic float SlopeDegrees { get; set; } = 20f;\r\n\r\n\tpublic int SampledGroundIndex { get; set; } = -1;\r\n\r\n\tpublic int PathGroundIndex { get; set; } = -1;\r\n\r\n\tpublic bool HasSampledGround => SampledGroundIndex >= 0;\r\n\r\n\tpublic bool HasPathGround => PathGroundIndex >= 0;\r\n\r\n\tpublic bool Dithers => MixCap > DitherWarn;\r\n\r\n\t/// <summary>Stamp value above which Pack center writes solid path. Higher Core = lower threshold.</summary>\r\n\tpublic float PackThreshold\r\n\t{\r\n\t\tget\r\n\t\t{\r\n\t\t\tfloat core = Math.Clamp( PackCore, PackCoreMin, PackCoreMax );\r\n\t\t\treturn Math.Clamp( 0.82f - core * 0.64f, 0.15f, 0.85f );\r\n\t\t}\r\n\t}\r\n\r\n\tpublic static float MixMaxFor( SubTerrainPaintLayer layer )\r\n\t{\r\n\t\treturn layer == SubTerrainPaintLayer.Path ? PathMixMax : WearMixMax;\r\n\t}\r\n}\r\n\r\n/// <summary>\r\n/// Per Ground / Wear / Path mix and gate, for the editor session.\r\n/// </summary>\r\ninternal static class SubTerrainPaintStore\r\n{\r\n\tstruct LayerState\r\n\t{\r\n\t\tpublic float MixCap;\r\n\t\tpublic bool OnlyOnSlope;\r\n\t\tpublic float SlopeDegrees;\r\n\t\tpublic bool MixOnly;\r\n\t\tpublic bool LimitToSampledGround;\r\n\t\tpublic bool PackCenter;\r\n\t\tpublic float PackCore;\r\n\t}\r\n\r\n\tstatic readonly Dictionary<SubTerrainPaintLayer, LayerState> ByLayer = new();\r\n\r\n\tpublic static void Capture( SubTerrainPaintSettings settings )\r\n\t{\r\n\t\tif ( settings is null )\r\n\t\t\treturn;\r\n\r\n\t\tByLayer[settings.Layer] = Read( settings );\r\n\t}\r\n\r\n\tpublic static void Apply( SubTerrainPaintSettings settings, SubTerrainPaintLayer layer )\r\n\t{\r\n\t\tif ( settings is null )\r\n\t\t\treturn;\r\n\r\n\t\tif ( !ByLayer.TryGetValue( layer, out var state ) )\r\n\t\t\tstate = Defaults( layer );\r\n\r\n\t\tsettings.Layer = layer;\r\n\t\tsettings.MixCap = Math.Clamp( state.MixCap, 0.05f, SubTerrainPaintSettings.MixMaxFor( layer ) );\r\n\t\tsettings.OnlyOnSlope = state.OnlyOnSlope;\r\n\t\tsettings.SlopeDegrees = state.SlopeDegrees;\r\n\t\tsettings.MixOnly = state.MixOnly;\r\n\t\tsettings.LimitToSampledGround = state.LimitToSampledGround;\r\n\t\tsettings.PackCenter = state.PackCenter;\r\n\t\tsettings.PackCore = Math.Clamp( state.PackCore, SubTerrainPaintSettings.PackCoreMin, SubTerrainPaintSettings.PackCoreMax );\r\n\t}\r\n\r\n\tstatic LayerState Read( SubTerrainPaintSettings settings )\r\n\t{\r\n\t\treturn new LayerState\r\n\t\t{\r\n\t\t\tMixCap = settings.MixCap,\r\n\t\t\tOnlyOnSlope = settings.OnlyOnSlope,\r\n\t\t\tSlopeDegrees = settings.SlopeDegrees,\r\n\t\t\tMixOnly = settings.MixOnly,\r\n\t\t\tLimitToSampledGround = settings.LimitToSampledGround,\r\n\t\t\tPackCenter = settings.PackCenter,\r\n\t\t\tPackCore = settings.PackCore\r\n\t\t};\r\n\t}\r\n\r\n\tstatic LayerState Defaults( SubTerrainPaintLayer layer )\r\n\t{\r\n\t\tif ( layer == SubTerrainPaintLayer.Path )\r\n\t\t{\r\n\t\t\treturn new LayerState\r\n\t\t\t{\r\n\t\t\t\tMixCap = 0.36f,\r\n\t\t\t\tOnlyOnSlope = false,\r\n\t\t\t\tSlopeDegrees = 20f,\r\n\t\t\t\tPackCenter = true,\r\n\t\t\t\tPackCore = 0.55f\r\n\t\t\t};\r\n\t\t}\r\n\r\n\t\tif ( layer == SubTerrainPaintLayer.Overlay )\r\n\t\t{\r\n\t\t\treturn new LayerState\r\n\t\t\t{\r\n\t\t\t\tMixCap = 0.28f,\r\n\t\t\t\tOnlyOnSlope = true,\r\n\t\t\t\tSlopeDegrees = 20f,\r\n\t\t\t\tPackCenter = true,\r\n\t\t\t\tPackCore = 0.55f\r\n\t\t\t};\r\n\t\t}\r\n\r\n\t\treturn new LayerState\r\n\t\t{\r\n\t\t\tMixCap = 0.28f,\r\n\t\t\tOnlyOnSlope = false,\r\n\t\t\tSlopeDegrees = 20f,\r\n\t\t\tPackCenter = true,\r\n\t\t\tPackCore = 0.55f\r\n\t\t};\r\n\t}\r\n}\r\n"
},
{
"Ident": "subzerostudios.subterrain-tool",
"Path": "TerrainSculptNet.cs",
"FileName": "TerrainSculptNet.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 344638,
"Code": "// Copyright (c) 2026 SubZero Studios LLC. All rights reserved.\r\n\r\nusing System;\r\nusing Sandbox;\r\n\r\nnamespace SubZero.SubTerrain;\r\n\r\n/// <summary>\r\n/// Optional host-authoritative play-sculpt net. Attach to a NetworkMode.Object\r\n/// GameObject. Host writes height; peers apply the same op locally.\r\n/// </summary>\r\n[Title( \"Terrain Sculpt Net\" )]\r\n[Category( \"SubTerrain\" )]\r\n[Icon( \"share\" )]\r\npublic sealed class TerrainSculptNet : Component\r\n{\r\n\tpublic bool Submit( TerrainEditRequest request )\r\n\t{\r\n\t\tif ( request.Seed == 0 )\r\n\t\t\trequest.Seed = Game.Random.Int( 1, 1_000_000_000 );\r\n\r\n\t\tif ( !Networking.IsActive || Networking.IsHost )\r\n\t\t{\r\n\t\t\tApplyLocal( request );\r\n\t\t\tif ( Networking.IsActive )\r\n\t\t\t\tBroadcastStamp( (int)request.Kind, request.WorldPos, request.Radius, request.Depth, request.Seed, request.HasTargetZ, request.TargetZ );\r\n\t\t\treturn true;\r\n\t\t}\r\n\r\n\t\tRequestHost( (int)request.Kind, request.WorldPos, request.Radius, request.Depth, request.Seed, request.HasTargetZ, request.TargetZ );\r\n\t\treturn true;\r\n\t}\r\n\r\n\t[Rpc.Host]\r\n\tpublic void RequestHost( int kind, Vector3 pos, float radius, float depth, int seed, bool hasTargetZ, float targetZ )\r\n\t{\r\n\t\tvar request = Make( kind, pos, radius, depth, seed, hasTargetZ, targetZ );\r\n\t\tApplyLocal( request );\r\n\t\tBroadcastStamp( kind, pos, radius, depth, seed, hasTargetZ, targetZ );\r\n\t}\r\n\r\n\t[Rpc.Broadcast]\r\n\tpublic void BroadcastStamp( int kind, Vector3 pos, float radius, float depth, int seed, bool hasTargetZ, float targetZ )\r\n\t{\r\n\t\tif ( Networking.IsHost )\r\n\t\t\treturn;\r\n\r\n\t\tApplyLocal( Make( kind, pos, radius, depth, seed, hasTargetZ, targetZ ) );\r\n\t}\r\n\r\n\tstatic TerrainEditRequest Make( int kind, Vector3 pos, float radius, float depth, int seed, bool hasTargetZ, float targetZ )\r\n\t{\r\n\t\treturn new TerrainEditRequest\r\n\t\t{\r\n\t\t\tKind = (TerrainEditKind)kind,\r\n\t\t\tWorldPos = pos,\r\n\t\t\tRadius = radius,\r\n\t\t\tDepth = depth,\r\n\t\t\tSeed = seed,\r\n\t\t\tHasTargetZ = hasTargetZ,\r\n\t\t\tTargetZ = targetZ\r\n\t\t};\r\n\t}\r\n\r\n\tvoid ApplyLocal( TerrainEditRequest request )\r\n\t{\r\n\t\tTerrainSculpt.TryApply( Scene, request );\r\n\t}\r\n}\r\n"
},
{
"Ident": "subzerostudios.subterrain-tool",
"Path": "TerrainDebrisFx.cs",
"FileName": "TerrainDebrisFx.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 344638,
"Code": "// Copyright (c) 2026 SubZero Studios LLC. All rights reserved.\r\n\r\nusing System;\r\nusing System.Collections.Generic;\r\nusing Sandbox;\r\n\r\nnamespace SubZero.SubTerrain;\r\n\r\n/// <summary>\r\n/// Dirt spray and physics clods for crater / dig stamps. Listens to\r\n/// TerrainSculpt.OnApplied after GPU and collision so debris lands in the bowl.\r\n/// Prefabs use engine meshes (models/dev/box.vmdl).\r\n/// </summary>\r\n[Title( \"Terrain Debris FX\" )]\r\n[Category( \"SubTerrain\" )]\r\n[Icon( \"grain\" )]\r\npublic sealed class TerrainDebrisFx : Component\r\n{\r\n\tconst string DefaultClodPath = \"prefabs/subterrain/dirt_clod.prefab\";\r\n\tconst string DefaultSprayPath = \"prefabs/subterrain/dirt_spray.prefab\";\r\n\tconst FindMode RigidbodyFind = FindMode.Enabled | FindMode.InSelf | FindMode.InDescendants;\r\n\r\n\t[Property, Group( \"Source\" )]\r\n\tpublic TerrainSculpt Sculpt { get; set; }\r\n\r\n\t[Property, Group( \"Prefabs\" )]\r\n\tpublic PrefabFile ClodPrefab { get; set; }\r\n\r\n\t[Property, Group( \"Prefabs\" )]\r\n\tpublic PrefabFile SprayPrefab { get; set; }\r\n\r\n\t[Property, Group( \"Kinds\" )]\r\n\tpublic bool Craters { get; set; } = true;\r\n\r\n\t[Property, Group( \"Kinds\" )]\r\n\tpublic bool Digs { get; set; } = true;\r\n\r\n\t[Property, Group( \"Clods\" ), Range( 0, 48 )]\r\n\tpublic int MinClods { get; set; } = 8;\r\n\r\n\t[Property, Group( \"Clods\" ), Range( 1, 64 )]\r\n\tpublic int MaxClods { get; set; } = 24;\r\n\r\n\t[Property, Group( \"Clods\" ), Range( 0f, 0.5f )]\r\n\tpublic float ClodsPerRadius { get; set; } = 0.12f;\r\n\r\n\t[Property, Group( \"Clods\" ), Range( 0, 128 )]\r\n\tpublic int MaxAlive { get; set; } = 64;\r\n\r\n\t[Property, Group( \"Launch\" ), Range( 0f, 4000f )]\r\n\tpublic float Impulse { get; set; } = 640f;\r\n\r\n\t[Property, Group( \"Launch\" ), Range( 0f, 3f )]\r\n\tpublic float UpBias { get; set; } = 0.45f;\r\n\r\n\t[Property, Group( \"Launch\" ), Range( 0.5f, 4f )]\r\n\tpublic float Outward { get; set; } = 2.2f;\r\n\r\n\t[Property, Group( \"Launch\" ), Range( 0.4f, 1.4f )]\r\n\tpublic float RimMin { get; set; } = 0.78f;\r\n\r\n\t[Property, Group( \"Launch\" ), Range( 0.5f, 1.6f )]\r\n\tpublic float RimMax { get; set; } = 1.12f;\r\n\r\n\t[Property, Group( \"Launch\" ), Range( 0f, 40f )]\r\n\tpublic float Spin { get; set; } = 14f;\r\n\r\n\t[Property, Group( \"Launch\" ), Range( 0f, 48f )]\r\n\tpublic float SpawnLift { get; set; } = 20f;\r\n\r\n\t[Property, Group( \"Launch\" ), Range( 0.05f, 1.5f )]\r\n\tpublic float ScaleMin { get; set; } = 0.7f;\r\n\r\n\t[Property, Group( \"Launch\" ), Range( 0.05f, 2.5f )]\r\n\tpublic float ScaleMax { get; set; } = 1.35f;\r\n\r\n\t[Property, Group( \"Dig\" ), Range( 0.1f, 1f )]\r\n\tpublic float DigImpulseScale { get; set; } = 0.5f;\r\n\r\n\t[Property, Group( \"Debug\" )]\r\n\tpublic bool DebugLog { get; set; }\r\n\r\n\treadonly List<GameObject> _alive = new();\r\n\r\n\tprotected override void OnStart()\r\n\t{\r\n\t\tif ( !Game.IsPlaying )\r\n\t\t\treturn;\r\n\r\n\t\tSculpt ??= Components.Get<TerrainSculpt>( FindMode.EnabledInSelf );\r\n\t\tClodPrefab ??= ResourceLibrary.Get<PrefabFile>( DefaultClodPath );\r\n\t\tSprayPrefab ??= ResourceLibrary.Get<PrefabFile>( DefaultSprayPath );\r\n\r\n\t\tif ( Sculpt == null || !Sculpt.IsValid() )\r\n\t\t{\r\n\t\t\tLog.Warning( $\"[Debris] {GameObject.Name}: no TerrainSculpt on this object\" );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tSculpt.OnApplied += OnTerrainApplied;\r\n\t\tSculpt.OnReset += ClearDebris;\r\n\t}\r\n\r\n\tprotected override void OnDisabled()\r\n\t{\r\n\t\tUnhook();\r\n\t\tClearDebris();\r\n\t}\r\n\r\n\tprotected override void OnDestroy()\r\n\t{\r\n\t\tUnhook();\r\n\t}\r\n\r\n\tvoid Unhook()\r\n\t{\r\n\t\tif ( Sculpt == null || !Sculpt.IsValid() )\r\n\t\t\treturn;\r\n\r\n\t\tSculpt.OnApplied -= OnTerrainApplied;\r\n\t\tSculpt.OnReset -= ClearDebris;\r\n\t}\r\n\r\n\tvoid OnTerrainApplied( TerrainEditResult result )\r\n\t{\r\n\t\tif ( result.Kind == TerrainEditKind.Flatten )\r\n\t\t\treturn;\r\n\r\n\t\tif ( result.Kind == TerrainEditKind.Crater && !Craters )\r\n\t\t\treturn;\r\n\r\n\t\tif ( result.Kind == TerrainEditKind.Dig && !Digs )\r\n\t\t\treturn;\r\n\r\n\t\tSpawnSpray( result );\r\n\t\tSpawnClods( result );\r\n\t}\r\n\r\n\tvoid SpawnSpray( TerrainEditResult result )\r\n\t{\r\n\t\tif ( SprayPrefab == null )\r\n\t\t\treturn;\r\n\r\n\t\tvar aimUp = Rotation.LookAt( Vector3.Up, Vector3.Forward );\r\n\t\tvar spray = GameObject.Clone( SprayPrefab, new Transform( result.Origin, aimUp ) );\r\n\t\tif ( !spray.IsValid() )\r\n\t\t\treturn;\r\n\r\n\t\tTrack( spray );\r\n\t}\r\n\r\n\tvoid SpawnClods( TerrainEditResult result )\r\n\t{\r\n\t\tif ( ClodPrefab == null )\r\n\t\t\treturn;\r\n\r\n\t\tvar count = (int)Math.Clamp( result.Radius * ClodsPerRadius, MinClods, MaxClods );\r\n\t\tif ( result.Kind == TerrainEditKind.Dig )\r\n\t\t\tcount = Math.Max( 3, count / 2 );\r\n\r\n\t\tvar impulse = Impulse;\r\n\t\tvar up = UpBias;\r\n\t\tif ( result.Kind == TerrainEditKind.Dig )\r\n\t\t{\r\n\t\t\timpulse *= DigImpulseScale;\r\n\t\t\tup *= 0.65f;\r\n\t\t}\r\n\r\n\t\tfor ( var i = 0; i < count; i++ )\r\n\t\t\tSpawnClod( result, impulse, up, i, count );\r\n\r\n\t\tDbg( $\"spawned {count} clods kind={result.Kind} r={result.Radius:0.#}\" );\r\n\t}\r\n\r\n\tvoid SpawnClod( TerrainEditResult result, float impulse, float up, int index, int count )\r\n\t{\r\n\t\tvar yaw = (360f * index / Math.Max( count, 1 )) + Random.Shared.Float( -14f, 14f );\r\n\t\tvar outward = Rotation.FromYaw( yaw ).Forward;\r\n\t\tvar rimMin = result.Kind == TerrainEditKind.Dig\r\n\t\t\t? Math.Clamp( RimMin * 0.7f, 0.4f, 1.2f )\r\n\t\t\t: Math.Clamp( RimMin, 0.4f, 1.4f );\r\n\t\tvar rimMax = Math.Max( rimMin + 0.05f, RimMax );\r\n\t\tvar radial = result.Radius * Random.Shared.Float( rimMin, rimMax );\r\n\t\tvar pos = result.Origin + outward * radial + Vector3.Up * SpawnLift;\r\n\t\tvar scale = Random.Shared.Float( ScaleMin, ScaleMax );\r\n\t\tvar rot = Rotation.From( new Angles(\r\n\t\t\tRandom.Shared.Float( 0f, 360f ),\r\n\t\t\tRandom.Shared.Float( 0f, 360f ),\r\n\t\t\tRandom.Shared.Float( 0f, 360f )\r\n\t\t) );\r\n\r\n\t\tvar clod = GameObject.Clone( ClodPrefab, new Transform( pos, rot ) );\r\n\t\tif ( !clod.IsValid() )\r\n\t\t\treturn;\r\n\r\n\t\tclod.WorldScale *= scale;\r\n\r\n\t\tTrack( clod );\r\n\r\n\t\tvar body = clod.Components.Get<Rigidbody>( RigidbodyFind );\r\n\t\tif ( !body.IsValid() )\r\n\t\t\treturn;\r\n\r\n\t\tvar dir = (outward * Outward + Vector3.Up * up).Normal;\r\n\t\tvar speed = impulse * Random.Shared.Float( 0.85f, 1.25f );\r\n\t\tbody.Sleeping = false;\r\n\t\tbody.Velocity = dir * speed;\r\n\t\tbody.AngularVelocity = new Vector3(\r\n\t\t\tRandom.Shared.Float( -1f, 1f ),\r\n\t\t\tRandom.Shared.Float( -1f, 1f ),\r\n\t\t\tRandom.Shared.Float( -1f, 1f )\r\n\t\t) * Spin;\r\n\t}\r\n\r\n\tvoid Track( GameObject go )\r\n\t{\r\n\t\tPrune();\r\n\t\t_alive.Add( go );\r\n\r\n\t\twhile ( MaxAlive > 0 && _alive.Count > MaxAlive )\r\n\t\t{\r\n\t\t\tvar oldest = _alive[0];\r\n\t\t\t_alive.RemoveAt( 0 );\r\n\t\t\tif ( oldest.IsValid() )\r\n\t\t\t\toldest.Destroy();\r\n\t\t}\r\n\t}\r\n\r\n\tvoid ClearDebris()\r\n\t{\r\n\t\tfor ( var i = _alive.Count - 1; i >= 0; i-- )\r\n\t\t{\r\n\t\t\tif ( _alive[i].IsValid() )\r\n\t\t\t\t_alive[i].Destroy();\r\n\t\t}\r\n\r\n\t\t_alive.Clear();\r\n\t}\r\n\r\n\tvoid Prune()\r\n\t{\r\n\t\tfor ( var i = _alive.Count - 1; i >= 0; i-- )\r\n\t\t{\r\n\t\t\tif ( !_alive[i].IsValid() )\r\n\t\t\t\t_alive.RemoveAt( i );\r\n\t\t}\r\n\t}\r\n\r\n\tvoid Dbg( string message )\r\n\t{\r\n\t\tif ( !DebugLog )\r\n\t\t\treturn;\r\n\r\n\t\tLog.Info( $\"[Debris] {message}\" );\r\n\t}\r\n}\r\n"
},
{
"Ident": "subzerostudios.subterrain-tool",
"Path": "Editor/SubTerrainBaseBrushTool.cs",
"FileName": "SubTerrainBaseBrushTool.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 344638,
"Code": "// Copyright (c) 2026 SubZero Studios LLC. All rights reserved.\r\n\r\nusing System;\r\nusing Sandbox;\r\nusing Editor.TerrainEditor;\r\n\r\nnamespace Editor.SubTerrain;\r\n\r\npublic struct SubTerrainPaintParameters\r\n{\r\n\tpublic Vector3 HitPosition { get; set; }\r\n\tpublic Vector2 HitUV { get; set; }\r\n\tpublic float FlattenHeight { get; set; }\r\n\tpublic SubTerrainBrushSettings BrushSettings { get; set; }\r\n}\r\n\r\n/// <summary>\r\n/// Shared stroke logic for SubTerrain sculpt tools.\r\n/// </summary>\r\npublic abstract class SubTerrainBaseBrushTool : EditorTool\r\n{\r\n\tprotected SubTerrainTool _parent;\r\n\tprotected bool _dragging;\r\n\tprotected RectInt _dirtyRegion;\r\n\r\n\tprotected SubTerrainSculptMode Mode { get; set; }\r\n\tprotected Vector2 SlopeStartUV;\r\n\tprotected float SlopeStartHeight01;\r\n\tprotected bool AllowBrushInvert { get; set; }\r\n\tprotected bool AllowHeightSample { get; set; }\r\n\tprotected Plane StrokePlane;\r\n\tbool _samplingHeight;\r\n\r\n\tGpuBuffer<BrushData> _engineBrushBuffer;\r\n\r\n\tVector3 _lastHitWorldPos;\r\n\tVector3 _lastPaintLocal;\r\n\tTransform _lastHitTx;\r\n\tVector2? _cursorLockPosition;\r\n\tfloat _raiseStampAccum;\r\n\r\n\tpublic SubTerrainBaseBrushTool( SubTerrainTool parent )\r\n\t{\r\n\t\t_parent = parent;\r\n\t}\r\n\r\n\tpublic override void OnEnabled()\r\n\t{\r\n\t\tAllowGameObjectSelection = false;\r\n\t\t_parent.SetBrushMode( GetType().Name );\r\n\t}\r\n\r\n\tpublic override void OnDisabled()\r\n\t{\r\n\t\t_parent.DetachSidebarForReuse();\r\n\t}\r\n\r\n\tpublic virtual bool GetHitPosition( Terrain terrain, out Vector3 position )\r\n\t{\r\n\t\treturn terrain.RayIntersects( Gizmo.CurrentRay, Gizmo.RayDepth, out position );\r\n\t}\r\n\r\n\tpublic override void OnUpdate()\r\n\t{\r\n\t\tvar terrain = GetSelectedComponent<Terrain>();\r\n\t\tif ( !terrain.IsValid() )\r\n\t\t{\r\n\t\t\t_parent.ClearStatus();\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tif ( !GetHitPosition( terrain, out var hitPosition ) )\r\n\t\t{\r\n\t\t\t_parent.ClearStatus();\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tvar tx = terrain.WorldTransform;\r\n\t\t_parent.UpdateStatus( terrain, hitPosition );\r\n\r\n\t\tif ( Application.MouseButtons.HasFlag( MouseButtons.Middle ) )\r\n\t\t{\r\n\t\t\t_cursorLockPosition ??= Application.UnscaledCursorPosition;\r\n\t\t\tvar d = Application.UnscaledCursorPosition - _cursorLockPosition.Value;\r\n\t\t\tvar brush = _parent.BrushSettings;\r\n\r\n\t\t\tif ( Gizmo.IsShiftPressed )\r\n\t\t\t{\r\n\t\t\t\tbrush.Size = SubTerrainBrushSize.Clamp( (int)(brush.Size + d.x * 0.25f) );\r\n\t\t\t\tbrush.Opacity = Math.Clamp( brush.Opacity - d.y * 0.002f, 0f, 1f );\r\n\t\t\t\t_parent.SyncBrushSizeWidget();\r\n\t\t\t}\r\n\t\t\telse if ( Gizmo.IsCtrlPressed && !brush.RandomRotation )\r\n\t\t\t{\r\n\t\t\t\tbrush.Rotation = ((brush.Rotation + d.x * 0.5f) % 360f + 360f) % 360f;\r\n\t\t\t\t_parent.SyncBrushRotationRows();\r\n\t\t\t}\r\n\r\n\t\t\tApplication.UnscaledCursorPosition = _cursorLockPosition.Value;\r\n\t\t\tSceneOverlay.Parent.Cursor = CursorShape.Blank;\r\n\r\n\t\t\tDrawBrushAdjustText();\r\n\t\t\t_parent.DrawBrushPreview( new Transform( _lastHitWorldPos, _lastHitTx.Rotation ), terrain );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tif ( _cursorLockPosition.HasValue )\r\n\t\t{\r\n\t\t\tSceneOverlay.Parent.Cursor = CursorShape.None;\r\n\t\t\t_cursorLockPosition = null;\r\n\t\t}\r\n\r\n\t\t_lastHitWorldPos = tx.PointToWorld( hitPosition );\r\n\t\t_lastHitTx = tx;\r\n\r\n\t\tif ( AllowHeightSample && Application.KeyboardModifiers.HasFlag( KeyboardModifiers.Alt ) )\r\n\t\t{\r\n\t\t\tif ( Gizmo.IsLeftMouseDown )\r\n\t\t\t{\r\n\t\t\t\tif ( !_samplingHeight )\r\n\t\t\t\t{\r\n\t\t\t\t\t_samplingHeight = true;\r\n\t\t\t\t\t_parent.BrushSettings.TargetHeight = hitPosition.z;\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t\telse\r\n\t\t\t{\r\n\t\t\t\t_samplingHeight = false;\r\n\t\t\t}\r\n\r\n\t\t\t_parent.DrawBrushPreview( new Transform( _lastHitWorldPos, tx.Rotation ), terrain );\r\n\t\t\tDrawToolOverlay( terrain, _lastHitWorldPos );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\t_samplingHeight = false;\r\n\r\n\t\tif ( Gizmo.IsLeftMouseDown )\r\n\t\t{\r\n\t\t\tbool moved = !Application.CursorDelta.IsNearZeroLength;\r\n\t\t\tbool shouldSculpt = !_dragging || moved;\r\n\r\n\t\t\tif ( !_dragging )\r\n\t\t\t{\r\n\t\t\t\tif ( _parent.BrushSettings.RandomRotation )\r\n\t\t\t\t\t_parent.BrushSettings.Rotation = Random.Shared.NextSingle() * 360f;\r\n\r\n\t\t\t\tStrokePlane = new Plane( _lastHitWorldPos, tx.Rotation.Up );\r\n\t\t\t\t_dragging = true;\r\n\t\t\t\t_raiseStampAccum = 0f;\r\n\r\n\t\t\t\tvar uv = new Vector2( hitPosition.x, hitPosition.y ) / terrain.Storage.TerrainSize;\r\n\t\t\t\tvar x = (int)Math.Floor( terrain.Storage.Resolution * uv.x );\r\n\t\t\t\tvar y = (int)Math.Floor( terrain.Storage.Resolution * uv.y );\r\n\t\t\t\t_dirtyRegion = new( new Vector2Int( x, y ) );\r\n\t\t\t\t_lastPaintLocal = hitPosition;\r\n\r\n\t\t\t\tOnStrokeStart( terrain, hitPosition );\r\n\t\t\t\tOnPaint( terrain, MakePaintParams( hitPosition, terrain ) );\r\n\t\t\t}\r\n\t\t\telse if ( Mode == SubTerrainSculptMode.RaiseLower )\r\n\t\t\t{\r\n\t\t\t\tif ( moved )\r\n\t\t\t\t{\r\n\t\t\t\t\tStrokeSculpt( terrain, hitPosition );\r\n\t\t\t\t\t_raiseStampAccum = 0f;\r\n\t\t\t\t}\r\n\t\t\t\telse\r\n\t\t\t\t{\r\n\t\t\t\t\t// Hold in place: keep raising/lowering at Speed stamps per second.\r\n\t\t\t\t\tfloat t = Math.Clamp( (_parent.BrushSettings.Speed - 1) / 99f, 0f, 1f );\r\n\t\t\t\t\tfloat stampsPerSec = 2f + t * 58f;\r\n\t\t\t\t\t_raiseStampAccum += Math.Clamp( RealTime.Delta, 0f, 0.1f ) * stampsPerSec;\r\n\t\t\t\t\twhile ( _raiseStampAccum >= 1f )\r\n\t\t\t\t\t{\r\n\t\t\t\t\t\t_raiseStampAccum -= 1f;\r\n\t\t\t\t\t\tOnPaint( terrain, MakePaintParams( hitPosition, terrain ) );\r\n\t\t\t\t\t\t_lastPaintLocal = hitPosition;\r\n\t\t\t\t\t}\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t\telse if ( shouldSculpt )\r\n\t\t\t{\r\n\t\t\t\tStrokeSculpt( terrain, hitPosition );\r\n\t\t\t}\r\n\t\t}\r\n\t\telse if ( _dragging )\r\n\t\t{\r\n\t\t\t_dragging = false;\r\n\t\t\t_raiseStampAccum = 0f;\r\n\t\t\tOnPaintEnded( terrain );\r\n\t\t}\r\n\r\n\t\t_parent.DrawBrushPreview( new Transform( _lastHitWorldPos, tx.Rotation ), terrain );\r\n\t\tDrawToolOverlay( terrain, _lastHitWorldPos );\r\n\t}\r\n\r\n\tSubTerrainPaintParameters MakePaintParams( Vector3 hitPosition, Terrain terrain )\r\n\t{\r\n\t\treturn new SubTerrainPaintParameters\r\n\t\t{\r\n\t\t\tHitPosition = hitPosition,\r\n\t\t\tHitUV = new Vector2( hitPosition.x, hitPosition.y ) / terrain.Storage.TerrainSize,\r\n\t\t\tFlattenHeight = hitPosition.z / terrain.Storage.TerrainHeight,\r\n\t\t\tBrushSettings = _parent.BrushSettings\r\n\t\t};\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// World-space stamp fill so fast / zoomed-out drags do not leave dashed strokes.\r\n\t/// </summary>\r\n\tvoid StrokeSculpt( Terrain terrain, Vector3 hitLocal )\r\n\t{\r\n\t\tvar from = _lastPaintLocal;\r\n\t\tvar to = hitLocal;\r\n\t\tfloat dist = (to - from).Length;\r\n\t\tfloat step = Math.Max( _parent.BrushSettings.Size * 0.2f, 8f );\r\n\r\n\t\tif ( dist < 0.001f )\r\n\t\t\treturn;\r\n\r\n\t\tif ( dist <= step )\r\n\t\t{\r\n\t\t\tOnPaint( terrain, MakePaintParams( to, terrain ) );\r\n\t\t\t_lastPaintLocal = to;\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tconst int maxStamps = 64;\r\n\t\tint stamps = (int)Math.Ceiling( dist / step );\r\n\t\tif ( stamps > maxStamps )\r\n\t\t{\r\n\t\t\tstamps = maxStamps;\r\n\t\t\tstep = dist / stamps;\r\n\t\t}\r\n\r\n\t\tfor ( int i = 1; i <= stamps; i++ )\r\n\t\t{\r\n\t\t\tfloat t = i / (float)stamps;\r\n\t\t\tOnPaint( terrain, MakePaintParams( Vector3.Lerp( from, to, t ), terrain ) );\r\n\t\t}\r\n\r\n\t\t_lastPaintLocal = to;\r\n\t}\r\n\r\n\tvoid DrawBrushAdjustText()\r\n\t{\r\n\t\tvar textScope = new TextRendering.Scope\r\n\t\t{\r\n\t\t\tTextColor = Color.White,\r\n\t\t\tFontSize = 16 * Gizmo.Settings.GizmoScale * Application.DpiScale,\r\n\t\t\tFontName = \"Roboto Mono\",\r\n\t\t\tFontWeight = 600,\r\n\t\t\tLineHeight = 1,\r\n\t\t\tOutline = new TextRendering.Outline() { Color = Color.Black, Enabled = true, Size = 3 }\r\n\t\t};\r\n\r\n\t\tvar offset = Vector2.Up * 24;\r\n\t\tvar brush = _parent.BrushSettings;\r\n\r\n\t\tif ( Gizmo.IsShiftPressed )\r\n\t\t{\r\n\t\t\ttextScope.Text = $\"Size: {brush.Size}\";\r\n\t\t\tGizmo.Draw.ScreenText( textScope, _lastHitWorldPos, offset );\r\n\r\n\t\t\ttextScope.Text = $\"Opacity: {brush.Opacity:0.##}\";\r\n\t\t\tGizmo.Draw.ScreenText( textScope, _lastHitWorldPos, offset * 2 );\r\n\r\n\t\t\tif ( Mode == SubTerrainSculptMode.RaiseLower )\r\n\t\t\t{\r\n\t\t\t\ttextScope.Text = $\"Speed: {brush.Speed}\";\r\n\t\t\t\tGizmo.Draw.ScreenText( textScope, _lastHitWorldPos, offset * 3 );\r\n\t\t\t}\r\n\t\t}\r\n\t\telse if ( Gizmo.IsCtrlPressed && !brush.RandomRotation )\r\n\t\t{\r\n\t\t\ttextScope.Text = $\"Rotation: {brush.Rotation:0.#}\";\r\n\t\t\tGizmo.Draw.ScreenText( textScope, _lastHitWorldPos, offset );\r\n\t\t}\r\n\t}\r\n\r\n\tprotected virtual void DrawToolOverlay( Terrain terrain, Vector3 worldCenter )\r\n\t{\r\n\t}\r\n\r\n\tprotected virtual void OnStrokeStart( Terrain terrain, Vector3 hitPosition )\r\n\t{\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// World Size is brush radius; dispatch covers the diameter (Size * 2 in world units).\r\n\t/// </summary>\r\n\tprotected int BrushTexelSize( Terrain terrain, float worldSize )\r\n\t{\r\n\t\tint size = (int)Math.Floor( worldSize * 2.0f / terrain.Storage.TerrainSize * terrain.Storage.Resolution );\r\n\t\treturn Math.Max( size, 1 );\r\n\t}\r\n\r\n\tprotected void GrowDirtyRegion( Terrain terrain, Vector2 hitUV, int size )\r\n\t{\r\n\t\tvar x = (int)Math.Floor( terrain.Storage.Resolution * hitUV.x ) - size / 2;\r\n\t\tvar y = (int)Math.Floor( terrain.Storage.Resolution * hitUV.y ) - size / 2;\r\n\t\t_dirtyRegion.Add( new RectInt( x, y, size + 1, size + 1 ) );\r\n\t}\r\n\r\n\tprotected virtual void OnPaint( Terrain terrain, SubTerrainPaintParameters paint )\r\n\t{\r\n\t\tvar brushTexture = _parent.SelectedBrushTexture;\r\n\t\tif ( brushTexture is null )\r\n\t\t\treturn;\r\n\r\n\t\tint size = BrushTexelSize( terrain, paint.BrushSettings.Size );\r\n\t\tvar amount = paint.BrushSettings.Opacity;\r\n\t\tif ( Mode == SubTerrainSculptMode.RaiseLower )\r\n\t\t{\r\n\t\t\t// Speed 50 = historical 1x height per stamp; 100 \u2248 4x. Hold rate is separate.\r\n\t\t\tamount *= Math.Clamp( paint.BrushSettings.Speed / 50f, 0.02f, 4f );\r\n\t\t}\r\n\r\n\t\tvar strength = amount * (AllowBrushInvert && Gizmo.IsCtrlPressed ? -1.0f : 1.0f);\r\n\r\n\t\tDispatchSculpt( terrain, paint, brushTexture, size, strength );\r\n\t}\r\n\r\n\tprotected void DispatchSculpt( Terrain terrain, SubTerrainPaintParameters paint, Texture brushTexture, int size, float strength )\r\n\t{\r\n\t\tif ( (int)Mode <= (int)SubTerrainSculptMode.Noise )\r\n\t\t{\r\n\t\t\tDispatchEngineSculpt( terrain, paint, brushTexture, size, strength );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tvar cs = new ComputeShader( \"terrain/cs_subterrain_sculpt\" );\r\n\r\n\t\tcs.Attributes.SetComboEnum( \"D_SCULPT_MODE\", Mode );\r\n\r\n\t\tcs.Attributes.Set( \"Heightmap\", terrain.HeightMap );\r\n\t\tcs.Attributes.Set( \"ControlMap\", terrain.ControlMap );\r\n\r\n\t\tcs.Attributes.Set( \"HeightUV\", paint.HitUV );\r\n\t\tcs.Attributes.Set( \"FlattenHeight\", paint.FlattenHeight );\r\n\t\tcs.Attributes.Set( \"SlopeStartUV\", SlopeStartUV );\r\n\t\tcs.Attributes.Set( \"SlopeEndUV\", paint.HitUV );\r\n\t\tcs.Attributes.Set( \"SlopeStartHeight\", SlopeStartHeight01 );\r\n\t\tcs.Attributes.Set( \"SlopeEndHeight\", paint.FlattenHeight );\r\n\t\tcs.Attributes.Set( \"BrushStrength\", strength );\r\n\t\tcs.Attributes.Set( \"BrushSize\", size );\r\n\t\tcs.Attributes.Set( \"Brush\", brushTexture );\r\n\r\n\t\tApplyExtraAttributes( cs, paint );\r\n\r\n\t\tcs.Dispatch( size, size, 1 );\r\n\r\n\t\tGrowDirtyRegion( terrain, paint.HitUV, size );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Modes 0-4 match the engine sculpt shader, which already supports brush rotation.\r\n\t/// Avoids recompiling cs_subterrain_sculpt (that compile has been crashing the editor).\r\n\t/// </summary>\r\n\tvoid DispatchEngineSculpt( Terrain terrain, SubTerrainPaintParameters paint, Texture brushTexture, int size, float strength )\r\n\t{\r\n\t\tvar cs = new ComputeShader( \"terrain/cs_terrain_sculpt\" );\r\n\r\n\t\tcs.Attributes.SetComboEnum( \"D_SCULPT_MODE\", (SculptMode)(int)Mode );\r\n\r\n\t\tcs.Attributes.Set( \"Heightmap\", terrain.HeightMap );\r\n\t\tcs.Attributes.Set( \"ControlMap\", terrain.ControlMap );\r\n\r\n\t\t_engineBrushBuffer ??= new GpuBuffer<BrushData>( 1 );\r\n\t\t_engineBrushBuffer.SetData( new[]\r\n\t\t{\r\n\t\t\tnew BrushData\r\n\t\t\t{\r\n\t\t\t\tUV = paint.HitUV,\r\n\t\t\t\tStrength = strength,\r\n\t\t\t\tSize = size,\r\n\t\t\t\tRotation = paint.BrushSettings.Rotation * MathF.PI / 180f,\r\n\t\t\t\tFlattenHeight = paint.FlattenHeight,\r\n\t\t\t}\r\n\t\t} );\r\n\t\tcs.Attributes.Set( \"BrushSettings\", _engineBrushBuffer );\r\n\t\tcs.Attributes.Set( \"Brush\", brushTexture );\r\n\r\n\t\tcs.Dispatch( size, size, 1 );\r\n\r\n\t\tGrowDirtyRegion( terrain, paint.HitUV, size );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Set any attributes a sub-tool needs on top of the shared sculpt ones.\r\n\t/// Runs after the shared attributes, so it can override them.\r\n\t/// </summary>\r\n\tprotected virtual void ApplyExtraAttributes( ComputeShader cs, SubTerrainPaintParameters paint )\r\n\t{\r\n\t}\r\n\r\n\tprivate static T[] CopyRegion<T>( T[] data, int stride, RectInt rect ) where T : unmanaged\r\n\t{\r\n\t\tT[] region = new T[rect.Width * rect.Height];\r\n\r\n\t\tfor ( int y = 0; y < rect.Height; y++ )\r\n\t\t{\r\n\t\t\tfor ( int x = 0; x < rect.Width; x++ )\r\n\t\t\t{\r\n\t\t\t\tregion[x + y * rect.Width] = data[rect.Left + x + (rect.Top + y) * stride];\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\treturn region;\r\n\t}\r\n\r\n\tprivate static Action CreateUndoAction<T>( Terrain terrain, T[] dest, T[] region, RectInt dirtyRegion, Terrain.SyncFlags flags ) => () =>\r\n\t{\r\n\t\tif ( !terrain.IsValid() )\r\n\t\t\treturn;\r\n\r\n\t\tfor ( int y = 0; y < dirtyRegion.Height; y++ )\r\n\t\t{\r\n\t\t\tfor ( int x = 0; x < dirtyRegion.Width; x++ )\r\n\t\t\t{\r\n\t\t\t\tdest[dirtyRegion.Left + x + (dirtyRegion.Top + y) * terrain.Storage.Resolution] = region[x + y * dirtyRegion.Width];\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tterrain.SyncGPUTexture();\r\n\t\tterrain.UpdateCollision( flags, dirtyRegion );\r\n\t};\r\n\r\n\tprotected virtual void OnPaintEnded( Terrain terrain )\r\n\t{\r\n\t\t_dirtyRegion.Left = Math.Clamp( _dirtyRegion.Left, 0, terrain.Storage.Resolution - 1 );\r\n\t\t_dirtyRegion.Right = Math.Clamp( _dirtyRegion.Right, 0, terrain.Storage.Resolution - 1 );\r\n\t\t_dirtyRegion.Top = Math.Clamp( _dirtyRegion.Top, 0, terrain.Storage.Resolution - 1 );\r\n\t\t_dirtyRegion.Bottom = Math.Clamp( _dirtyRegion.Bottom, 0, terrain.Storage.Resolution - 1 );\r\n\r\n\t\tvar name = $\"Terrain {DisplayInfo.For( this ).Name}\";\r\n\r\n\t\tif ( Mode != SubTerrainSculptMode.Hole )\r\n\t\t{\r\n\t\t\tvar regionBefore = CopyRegion( terrain.Storage.HeightMap, terrain.Storage.Resolution, _dirtyRegion );\r\n\t\t\tterrain.SyncCPUTexture( Terrain.SyncFlags.Height, _dirtyRegion );\r\n\t\t\tvar regionAfter = CopyRegion( terrain.Storage.HeightMap, terrain.Storage.Resolution, _dirtyRegion );\r\n\r\n\t\t\tSceneEditorSession.Active.UndoSystem.Insert( name,\r\n\t\t\t\tCreateUndoAction( terrain, terrain.Storage.HeightMap, regionBefore, _dirtyRegion, Terrain.SyncFlags.Height ),\r\n\t\t\t\tCreateUndoAction( terrain, terrain.Storage.HeightMap, regionAfter, _dirtyRegion, Terrain.SyncFlags.Height ) );\r\n\t\t}\r\n\t\telse\r\n\t\t{\r\n\t\t\tvar regionBefore = CopyRegion( terrain.Storage.ControlMap, terrain.Storage.Resolution, _dirtyRegion );\r\n\t\t\tterrain.SyncCPUTexture( Terrain.SyncFlags.Control, _dirtyRegion );\r\n\t\t\tvar regionAfter = CopyRegion( terrain.Storage.ControlMap, terrain.Storage.Resolution, _dirtyRegion );\r\n\r\n\t\t\tSceneEditorSession.Active.UndoSystem.Insert( name,\r\n\t\t\t\tCreateUndoAction( terrain, terrain.Storage.ControlMap, regionBefore, _dirtyRegion, Terrain.SyncFlags.Control ),\r\n\t\t\t\tCreateUndoAction( terrain, terrain.Storage.ControlMap, regionAfter, _dirtyRegion, Terrain.SyncFlags.Control ) );\r\n\t\t}\r\n\t}\r\n}\r\n"
},
{
"Ident": "subzerostudios.subterrain-tool",
"Path": "TimedDestroy.cs",
"FileName": "TimedDestroy.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 344638,
"Code": "// Copyright (c) 2026 SubZero Studios LLC. All rights reserved.\r\n\r\nusing System;\r\nusing Sandbox;\r\n\r\nnamespace SubZero.SubTerrain;\r\n\r\n/// <summary>\r\n/// Destroys this GameObject after a delay. Used by debris prefabs.\r\n/// </summary>\r\n[Title( \"Timed Destroy\" )]\r\n[Category( \"SubTerrain\" )]\r\n[Icon( \"timer\" )]\r\npublic sealed class TimedDestroy : Component\r\n{\r\n\t[Property] public float Seconds { get; set; } = 12f;\r\n\r\n\tTimeSince _alive;\r\n\r\n\tprotected override void OnStart()\r\n\t{\r\n\t\t_alive = 0;\r\n\t}\r\n\r\n\tprotected override void OnUpdate()\r\n\t{\r\n\t\tif ( _alive >= Seconds )\r\n\t\t\tGameObject.Destroy();\r\n\t}\r\n}\r\n"
},
{
"Ident": "subzerostudios.subterrain-tool",
"Path": "Code/TimedDestroy.cs",
"FileName": "TimedDestroy.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 344638,
"Code": "// Copyright (c) 2026 SubZero Studios LLC. All rights reserved.\r\n\r\nusing System;\r\nusing Sandbox;\r\n\r\nnamespace SubZero.SubTerrain;\r\n\r\n/// <summary>\r\n/// Destroys this GameObject after a delay. Used by debris prefabs.\r\n/// </summary>\r\n[Title( \"Timed Destroy\" )]\r\n[Category( \"SubTerrain\" )]\r\n[Icon( \"timer\" )]\r\npublic sealed class TimedDestroy : Component\r\n{\r\n\t[Property] public float Seconds { get; set; } = 12f;\r\n\r\n\tTimeSince _alive;\r\n\r\n\tprotected override void OnStart()\r\n\t{\r\n\t\t_alive = 0;\r\n\t}\r\n\r\n\tprotected override void OnUpdate()\r\n\t{\r\n\t\tif ( _alive >= Seconds )\r\n\t\t\tGameObject.Destroy();\r\n\t}\r\n}\r\n"
},
{
"Ident": "subzerostudios.subterrain-tool",
"Path": "Editor/Tools/PathTool.cs",
"FileName": "PathTool.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 344638,
"Code": "// Copyright (c) 2026 SubZero Studios LLC. All rights reserved.\r\n\r\nusing Sandbox;\r\n\r\nnamespace Editor.SubTerrain;\r\n\r\n/// <summary>\r\n/// Keeps the click height along the stroke. Paint Texture Path is the control-map path.\r\n/// </summary>\r\n[Title( \"Path\" )]\r\n[Icon( \"route\" )]\r\n[Alias( \"subterrain_path\" )]\r\n[Group( \"1\" )]\r\n[Order( 6 )]\r\npublic class SubPathTool : SubTerrainBaseBrushTool\r\n{\r\n\tVector3 _startLocal;\r\n\r\n\tpublic SubPathTool( SubTerrainTool parent ) : base( parent )\r\n\t{\r\n\t\tMode = SubTerrainSculptMode.Path;\r\n\t}\r\n\r\n\tprotected override void OnStrokeStart( Terrain terrain, Vector3 hitPosition )\r\n\t{\r\n\t\t_startLocal = hitPosition;\r\n\t\tSlopeStartUV = new Vector2( hitPosition.x, hitPosition.y ) / terrain.Storage.TerrainSize;\r\n\t\tSlopeStartHeight01 = hitPosition.z / terrain.Storage.TerrainHeight;\r\n\t}\r\n\r\n\tprotected override void DrawToolOverlay( Terrain terrain, Vector3 worldCenter )\r\n\t{\r\n\t\tif ( !_dragging )\r\n\t\t\treturn;\r\n\r\n\t\tvar startWorld = terrain.WorldTransform.PointToWorld( _startLocal );\r\n\t\tGizmo.Draw.Color = Color.FromBytes( 120, 200, 250 );\r\n\t\tGizmo.Draw.Line( startWorld, worldCenter );\r\n\t}\r\n}\r\n"
},
{
"Ident": "subzerostudios.subterrain-tool",
"Path": "Editor/SubTerrainBrushSize.cs",
"FileName": "SubTerrainBrushSize.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 344638,
"Code": "// Copyright (c) 2026 SubZero Studios LLC. All rights reserved.\r\n\r\nusing System;\r\n\r\nnamespace Editor.SubTerrain;\r\n\r\n/// <summary>\r\n/// Brush Size limits and piecewise track mapping.\r\n/// Fine band stays precise; larger sizes take less track and bigger nudges.\r\n/// </summary>\r\npublic static class SubTerrainBrushSize\r\n{\r\n\tpublic const int Min = 8;\r\n\tpublic const int FineMax = 3500;\r\n\tpublic const int MidMax = 18000;\r\n\tpublic const int Max = 35000;\r\n\r\n\tpublic const int FineStep = 50;\r\n\tpublic const int MidStep = 500;\r\n\tpublic const int CoarseStep = 1000;\r\n\r\n\t/// <summary>Fraction of the slider track for the fine band (8..FineMax).</summary>\r\n\tpublic const float FineTrack = 0.70f;\r\n\r\n\t/// <summary>End of mid band on the track (FineMax..MidMax). Coarse is the rest to 1.</summary>\r\n\tpublic const float MidTrack = 0.90f;\r\n\r\n\tpublic static int Clamp( int size ) => Math.Clamp( size, Min, Max );\r\n\r\n\tpublic static int StepFor( int size )\r\n\t{\r\n\t\tif ( size < FineMax )\r\n\t\t\treturn FineStep;\r\n\t\tif ( size < MidMax )\r\n\t\t\treturn MidStep;\r\n\t\treturn CoarseStep;\r\n\t}\r\n\r\n\tpublic static int Nudge( int size, int direction )\r\n\t{\r\n\t\tif ( direction == 0 )\r\n\t\t\treturn Clamp( size );\r\n\r\n\t\tsize = Clamp( size );\r\n\t\tint step = StepFor( size );\r\n\t\treturn Clamp( size + direction * step );\r\n\t}\r\n\r\n\tpublic static int SizeFromTrack( float t01 )\r\n\t{\r\n\t\tt01 = Math.Clamp( t01, 0f, 1f );\r\n\r\n\t\tint raw;\r\n\t\tif ( t01 <= FineTrack )\r\n\t\t{\r\n\t\t\tfloat u = FineTrack <= 0f ? 0f : t01 / FineTrack;\r\n\t\t\traw = (int)MathF.Round( Lerp( Min, FineMax, u ) );\r\n\t\t}\r\n\t\telse if ( t01 <= MidTrack )\r\n\t\t{\r\n\t\t\tfloat u = (t01 - FineTrack) / (MidTrack - FineTrack);\r\n\t\t\traw = (int)MathF.Round( Lerp( FineMax, MidMax, u ) );\r\n\t\t}\r\n\t\telse\r\n\t\t{\r\n\t\t\tfloat u = (t01 - MidTrack) / (1f - MidTrack);\r\n\t\t\traw = (int)MathF.Round( Lerp( MidMax, Max, u ) );\r\n\t\t}\r\n\r\n\t\treturn Snap( raw );\r\n\t}\r\n\r\n\tpublic static float TrackFromSize( int size )\r\n\t{\r\n\t\tsize = Clamp( size );\r\n\r\n\t\tif ( size <= FineMax )\r\n\t\t{\r\n\t\t\tfloat u = (size - Min) / (float)(FineMax - Min);\r\n\t\t\treturn u * FineTrack;\r\n\t\t}\r\n\r\n\t\tif ( size <= MidMax )\r\n\t\t{\r\n\t\t\tfloat u = (size - FineMax) / (float)(MidMax - FineMax);\r\n\t\t\treturn FineTrack + u * (MidTrack - FineTrack);\r\n\t\t}\r\n\r\n\t\t{\r\n\t\t\tfloat u = (size - MidMax) / (float)(Max - MidMax);\r\n\t\t\treturn MidTrack + u * (1f - MidTrack);\r\n\t\t}\r\n\t}\r\n\r\n\tpublic static int Snap( int size )\r\n\t{\r\n\t\tsize = Clamp( size );\r\n\t\tint step = StepFor( size );\r\n\t\tint snapped = (int)MathF.Round( size / (float)step ) * step;\r\n\t\treturn Clamp( snapped );\r\n\t}\r\n\r\n\tstatic float Lerp( float a, float b, float t ) => a + (b - a) * Math.Clamp( t, 0f, 1f );\r\n}\r\n"
},
{
"Ident": "subzerostudios.subterrain-tool",
"Path": "Editor/SubTerrainTool.cs",
"FileName": "SubTerrainTool.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 344638,
"Code": "// Copyright (c) 2026 SubZero Studios LLC. All rights reserved.\r\n\r\nusing System;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\nusing Sandbox;\r\nusing Editor.TerrainEditor;\r\n\r\nnamespace Editor.SubTerrain;\r\n\r\n/// <summary>\r\n/// SubTerrain editor tool. Extends the built-in terrain workflow with larger brushes,\r\n/// slope/path/set-height sculpt modes, and paint texture support.\r\n/// </summary>\r\n[EditorTool( \"tools.subterrain\" )]\r\n[Title( \"SubTerrain\" )]\r\n[Icon( \"terrain\" )]\r\n[Alias( \"subterrain\" )]\r\n[Group( \"Scene\" )]\r\npublic class SubTerrainTool : EditorTool\r\n{\r\n\tpublic static SubTerrainTool ActiveInstance { get; private set; }\r\n\r\n\tpublic SubTerrainBrushSettings BrushSettings { get; private set; } = SubTerrainBrushStore.ForMode( nameof( SubRaiseLowerTool ) );\r\n\tpublic SubTerrainPaintSettings PaintSettings { get; private set; } = new();\r\n\tpublic bool PaintArmed { get; set; }\r\n\tstring _brushMode = nameof( SubRaiseLowerTool );\r\n\r\n\tBrushPreviewSceneObject _previewObject;\r\n\tSubTerrainStatusWidget _statusWidget;\r\n\tWidget _opacityWidget;\r\n\tWidget _speedWidget;\r\n\tWidget _rotationWidget;\r\n\tWidget _brushSheetHost;\r\n\tSubTerrainBrushSizeWidget _sizeWidget;\r\n\tWidget _sidebar;\r\n\tWidget _textureSection;\r\n\tWidget _statusSection;\r\n\tSubTerrainBlendWidget _blendWidget;\r\n\tLabel _intentLabel;\r\n\r\n\tpublic Terrain PeekTerrain()\r\n\t{\r\n\t\tif ( StatusTerrain.IsValid() )\r\n\t\t\treturn StatusTerrain;\r\n\r\n\t\treturn GetSelectedComponent<Terrain>();\r\n\t}\r\n\r\n\tpublic void SetBrushMode( string key )\r\n\t{\r\n\t\tif ( string.IsNullOrEmpty( key ) )\r\n\t\t\tkey = nameof( SubRaiseLowerTool );\r\n\r\n\t\tvar next = SubTerrainBrushStore.ForMode( key );\r\n\t\tbool same = _brushMode == key && BrushSettings == next;\r\n\t\t_brushMode = key;\r\n\t\tBrushSettings = next;\r\n\r\n\t\tif ( !same )\r\n\t\t{\r\n\t\t\tRebuildBrushSheet();\r\n\t\t\tUpdateIntent();\r\n\t\t}\r\n\r\n\t\tUpdateOpacityState();\r\n\t}\r\n\r\n\tpublic void RefreshPaintChrome()\r\n\t{\r\n\t\tif ( _textureSection is not null )\r\n\t\t\t_textureSection.Visible = PaintArmed;\r\n\r\n\t\tif ( _blendWidget is not null )\r\n\t\t\t_blendWidget.Visible = PaintArmed;\r\n\r\n\t\tif ( _statusSection is not null )\r\n\t\t\t_statusSection.Visible = !PaintArmed;\r\n\r\n\t\tUpdateOpacityState();\r\n\t\tUpdateIntent();\r\n\t}\r\n\r\n\tpublic void UpdateIntent()\r\n\t{\r\n\t\tif ( _intentLabel is null )\r\n\t\t\treturn;\r\n\r\n\t\tif ( PaintArmed )\r\n\t\t{\r\n\t\t\t_intentLabel.Text = \"Paint Texture.\";\r\n\t\t\t_intentLabel.ToolTip = \"Alt sample | Ctrl fade | Shift smooth | ring yellow/green = Alt/Shift. Modes and size keys are in Shortcuts below.\";\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\t_intentLabel.Text = IntentForSculpt();\r\n\t\t_intentLabel.ToolTip = IntentTooltipForSculpt();\r\n\t}\r\n\r\n\tstring IntentForSculpt()\r\n\t{\r\n\t\treturn _brushMode switch\r\n\t\t{\r\n\t\t\tnameof( SubSmoothTool ) => \"Smooth height.\",\r\n\t\t\tnameof( SubPlainerTool ) => \"Plainer: broad weighted leveling.\",\r\n\t\t\tnameof( SubFlattenTool ) => \"Flatten to click Z.\",\r\n\t\t\tnameof( SubSlopeTool ) => \"Slope: click start, drag to end.\",\r\n\t\t\tnameof( SubPathTool ) => \"Path: constant elevation strip.\",\r\n\t\t\tnameof( SubSetHeightTool ) => \"Flatten to Target Height.\",\r\n\t\t\tnameof( SubNoiseTool ) => \"Noise bump. Ctrl inverts.\",\r\n\t\t\tnameof( SubHoleTool ) => \"Punch hole. Ctrl fills.\",\r\n\t\t\tnameof( SubBridgeTool ) => \"Bridge: press start, release on end.\",\r\n\t\t\t_ => \"Raise height. Ctrl lowers.\"\r\n\t\t};\r\n\t}\r\n\r\n\tstring IntentTooltipForSculpt()\r\n\t{\r\n\t\tvar tip = _brushMode switch\r\n\t\t{\r\n\t\t\tnameof( SubPathTool ) => \"Height-only strip. Control-map Path is Paint Texture (Ctrl+3).\",\r\n\t\t\tnameof( SubSetHeightTool ) => \"Alt-click loads Z into Target Height.\",\r\n\t\t\tnameof( SubPlainerTool ) => \"Like Hammer displacement smoothing across a wide brush.\",\r\n\t\t\tnameof( SubBridgeTool ) => \"Deck sits on the line between the two picked heights. Shortcut B.\",\r\n\t\t\t_ => null\r\n\t\t};\r\n\r\n\t\tvar keys = \"Left / Right or Ctrl+[ / ] brush size. N activates SubTerrain. Keys 1-9, 0 Plainer, B Bridge.\";\r\n\t\treturn tip is null ? keys : $\"{tip} {keys}\";\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Scene view destroys sidebar children on every subtool switch. Unparent first\r\n\t/// from subtool OnDisabled so we can reuse one panel and avoid width/content flash.\r\n\t/// </summary>\r\n\tpublic void DetachSidebarForReuse()\r\n\t{\r\n\t\tif ( _sidebar is not null && _sidebar.IsValid() )\r\n\t\t\t_sidebar.Parent = null;\r\n\t}\r\n\r\n\tpublic void NudgeBrushSize( int direction )\r\n\t{\r\n\t\tBrushSettings.Size = SubTerrainBrushSize.Nudge( BrushSettings.Size, direction );\r\n\t\tSyncBrushSizeWidget();\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Sculpt modes only. Paint Texture keeps Up / Down for tmat selection.\r\n\t/// </summary>\r\n\tpublic bool NudgeBrushOpacity( int direction )\r\n\t{\r\n\t\tif ( PaintArmed || direction == 0 )\r\n\t\t\treturn false;\r\n\r\n\t\tconst float step = 0.05f;\r\n\t\tBrushSettings.Opacity = Math.Clamp( BrushSettings.Opacity + direction * step, 0f, 1f );\r\n\t\tRebuildBrushSheet();\r\n\t\treturn true;\r\n\t}\r\n\r\n\tpublic void SyncBrushSizeWidget()\r\n\t{\r\n\t\t_sizeWidget?.SyncFromSettings();\r\n\t}\r\n\r\n\tpublic void SyncBrushRotationRows()\r\n\t{\r\n\t\tif ( _rotationWidget is not null )\r\n\t\t\t_rotationWidget.Enabled = !BrushSettings.RandomRotation;\r\n\t}\r\n\r\n\tpublic bool TrySetPaintLayer( SubTerrainPaintLayer layer )\r\n\t{\r\n\t\tif ( !PaintArmed || _blendWidget is null )\r\n\t\t\treturn false;\r\n\r\n\t\tif ( layer == SubTerrainPaintLayer.Base )\r\n\t\t\t_blendWidget.SelectGround();\r\n\t\telse if ( layer == SubTerrainPaintLayer.Overlay )\r\n\t\t\t_blendWidget.SelectWear();\r\n\t\telse\r\n\t\t\t_blendWidget.SelectPath();\r\n\r\n\t\treturn true;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Cycle selected paint tmat. Path skips the locked ground row (that role is Alt-click).\r\n\t/// </summary>\r\n\tpublic bool NudgePaintMaterial( int delta )\r\n\t{\r\n\t\tif ( !PaintArmed || delta == 0 )\r\n\t\t\treturn false;\r\n\r\n\t\tvar terrain = PeekTerrain();\r\n\t\tvar materials = terrain?.Storage?.Materials;\r\n\t\tint count = materials?.Count ?? 0;\r\n\t\tif ( count <= 0 )\r\n\t\t\treturn false;\r\n\r\n\t\tint skip = -1;\r\n\t\tif ( PaintSettings.Layer == SubTerrainPaintLayer.Path )\r\n\t\t\tskip = SubTerrainSplatUtil.FindPathGround( terrain, PaintSettings );\r\n\r\n\t\tint current = PaintSettings.MaterialIndex;\r\n\t\tif ( current < 0 || current >= count )\r\n\t\t\tcurrent = 0;\r\n\r\n\t\tfor ( int step = 0; step < count; step++ )\r\n\t\t{\r\n\t\t\tcurrent = (current + delta) % count;\r\n\t\t\tif ( current < 0 )\r\n\t\t\t\tcurrent += count;\r\n\r\n\t\t\tif ( current == skip )\r\n\t\t\t\tcontinue;\r\n\r\n\t\t\tPaintSettings.MaterialIndex = current;\r\n\t\t\tSubTerrainPaintTool.TrySetSplatChannel( current );\r\n\t\t\t_blendWidget?.OnMaterialSelectionChanged();\r\n\t\t\treturn true;\r\n\t\t}\r\n\r\n\t\treturn false;\r\n\t}\r\n\r\n\tpublic bool StatusValid { get; private set; }\r\n\tpublic Terrain StatusTerrain { get; private set; }\r\n\tpublic Vector3 StatusHitLocal { get; private set; }\r\n\r\n\tpublic static BrushList BrushList => TerrainEditorTool.BrushList;\r\n\r\n\tpublic Texture SelectedBrushTexture => BrushList?.Selected?.Texture;\r\n\r\n\tpublic void UpdateStatus( Terrain terrain, Vector3 hitLocal )\r\n\t{\r\n\t\tStatusValid = true;\r\n\t\tStatusTerrain = terrain;\r\n\t\tStatusHitLocal = hitLocal;\r\n\t}\r\n\r\n\tpublic void ClearStatus()\r\n\t{\r\n\t\tStatusValid = false;\r\n\t\tStatusTerrain = default;\r\n\t}\r\n\r\n\tpublic override IEnumerable<EditorTool> GetSubtools()\r\n\t{\r\n\t\tyield return new SubRaiseLowerTool( this );\r\n\t\tyield return new SubSmoothTool( this );\r\n\t\tyield return new SubPlainerTool( this );\r\n\t\tyield return new SubFlattenTool( this );\r\n\t\tyield return new SubTerrainPaintTool( this );\r\n\t\tyield return new SubSlopeTool( this );\r\n\t\tyield return new SubPathTool( this );\r\n\t\tyield return new SubSetHeightTool( this );\r\n\t\tyield return new SubNoiseTool( this );\r\n\t\tyield return new SubHoleTool( this );\r\n\t\tyield return new SubBridgeTool( this );\r\n\t}\r\n\r\n\tpublic override void OnEnabled()\r\n\t{\r\n\t\tActiveInstance = this;\r\n\t\tAllowGameObjectSelection = false;\r\n\t\tRebuildSidebarOnSelectionChange = false;\r\n\r\n\t\tvar selectedTerrain = GetSelectedComponent<Terrain>();\r\n\t\tif ( !selectedTerrain.IsValid() )\r\n\t\t{\r\n\t\t\tSelection.Clear();\r\n\t\t\tvar first = Scene.GetAllComponents<Terrain>().FirstOrDefault();\r\n\t\t\tif ( first.IsValid() ) Selection.Add( first.GameObject );\r\n\t\t}\r\n\t}\r\n\r\n\tpublic override void OnDisabled()\r\n\t{\r\n\t\tif ( ActiveInstance == this )\r\n\t\t\tActiveInstance = null;\r\n\r\n\t\tif ( _sidebar is not null && _sidebar.IsValid() )\r\n\t\t{\r\n\t\t\t_sidebar.Parent = null;\r\n\t\t\t_sidebar.Destroy();\r\n\t\t}\r\n\r\n\t\t_sidebar = null;\r\n\t\t_blendWidget = null;\r\n\t\t_intentLabel = null;\r\n\t\t_brushSheetHost = null;\r\n\t\t_sizeWidget = null;\r\n\t\t_opacityWidget = null;\r\n\t\t_rotationWidget = null;\r\n\t\t_textureSection = null;\r\n\t\t_statusSection = null;\r\n\t\t_statusWidget = null;\r\n\r\n\t\t_previewObject?.Delete();\r\n\t\t_previewObject = null;\r\n\t\tClearStatus();\r\n\t}\r\n\r\n\tpublic override Widget CreateShortcutsWidget() => new SubTerrainToolShortcutsWidget();\r\n\r\n\t[Shortcut( \"tools.subterrain\", \"n\", typeof( SceneViewWidget ) )]\r\n\tpublic static void ActivateTool()\r\n\t{\r\n\t\tEditorToolManager.SetTool( nameof( SubTerrainTool ) );\r\n\t}\r\n\r\n\tpublic void DrawBrushPreview( Transform transform, Terrain terrain = null )\r\n\t{\r\n\t\tvar brush = BrushList?.Selected?.Texture;\r\n\t\tif ( brush is null )\r\n\t\t\treturn;\r\n\r\n\t\t_previewObject ??= new BrushPreviewSceneObject( Gizmo.World );\r\n\r\n\t\tvar color = Color.FromBytes( 150, 150, 250 );\r\n\t\tvar mods = Application.KeyboardModifiers;\r\n\t\tif ( mods.HasFlag( KeyboardModifiers.Alt ) )\r\n\t\t\tcolor = Color.FromBytes( 220, 200, 80 );\r\n\t\telse if ( mods.HasFlag( KeyboardModifiers.Shift ) )\r\n\t\t\tcolor = Color.FromBytes( 120, 200, 140 );\r\n\t\telse if ( mods.HasFlag( KeyboardModifiers.Ctrl ) )\r\n\t\t\tcolor = color.AdjustHue( 90 );\r\n\r\n\t\tcolor.a = BrushSettings.Opacity;\r\n\r\n\t\t_previewObject.RenderLayer = SceneRenderLayer.OverlayWithDepth;\r\n\t\t_previewObject.Bounds = BBox.FromPositionAndSize( 0, float.MaxValue );\r\n\t\t_previewObject.Transform = transform;\r\n\t\t_previewObject.Radius = BrushSettings.Size;\r\n\t\t_previewObject.Texture = brush;\r\n\t\t_previewObject.Color = color;\r\n\t\t_previewObject.BrushRotation = BrushSettings.Rotation;\r\n\r\n\t\tterrain ??= PeekTerrain();\r\n\t\tif ( BrushSettings.ShowGridPreview && terrain?.Storage is not null )\r\n\t\t{\r\n\t\t\tvar tx = terrain.WorldTransform;\r\n\t\t\t_previewObject.CellSize = terrain.Storage.TerrainSize / terrain.Storage.Resolution;\r\n\t\t\t_previewObject.TerrainOrigin = tx.Position;\r\n\t\t\t_previewObject.TerrainRight = tx.Rotation.Right;\r\n\t\t\t_previewObject.TerrainForward = tx.Rotation.Forward;\r\n\t\t}\r\n\t\telse\r\n\t\t{\r\n\t\t\t_previewObject.CellSize = 0f;\r\n\t\t}\r\n\t}\r\n\r\n\tpublic override Widget CreateToolSidebar()\r\n\t{\r\n\t\t// Sidebar reuse: detach in OnDisabled before the scene view Clears the host.\r\n\t\tif ( _sidebar is ToolSidebarWidget existing && existing.IsValid() )\r\n\t\t{\r\n\t\t\texisting.Parent = null;\r\n\t\t\tRebuildBrushSheet();\r\n\t\t\tRefreshPaintChrome();\r\n\t\t\treturn existing;\r\n\t\t}\r\n\r\n\t\tvar sidebar = new ToolSidebarWidget();\r\n\t\t_sidebar = sidebar;\r\n\t\tsidebar.AddTitle( \"SubTerrain\", \"terrain\" );\r\n\t\tsidebar.ToolTip = $\"SubTerrain {SubTerrainPaintSettings.LibraryVersion}\";\r\n\r\n\t\t_intentLabel = sidebar.Layout.Add( new Label( \"\" ) );\r\n\t\t_intentLabel.Color = Theme.TextControl;\r\n\t\tsidebar.Layout.AddSpacingCell( 6 );\r\n\r\n\t\t_textureSection = new Widget( sidebar );\r\n\t\t_textureSection.Layout = Layout.Column();\r\n\t\t_blendWidget = new SubTerrainBlendWidget( this, _textureSection );\r\n\t\t_textureSection.Layout.Add( _blendWidget );\r\n\t\tsidebar.Layout.Add( _textureSection );\r\n\t\t_textureSection.Visible = false;\r\n\r\n\t\t{\r\n\t\t\tvar group = sidebar.AddGroup( \"Brush Type\" );\r\n\t\t\tgroup.Add( new SubTerrainBrushPreviewWidget( sidebar ) );\r\n\t\t}\r\n\r\n\t\t{\r\n\t\t\tvar group = sidebar.AddGroup( \"Brush Properties\" );\r\n\t\t\t_brushSheetHost = new Widget( sidebar );\r\n\t\t\t_brushSheetHost.Layout = Layout.Column();\r\n\t\t\tgroup.Add( _brushSheetHost );\r\n\t\t\tRebuildBrushSheet();\r\n\t\t}\r\n\r\n\t\tUpdateOpacityState();\r\n\r\n\t\tif ( !GetSelectedComponent<Terrain>().IsValid() )\r\n\t\t{\r\n\t\t\tvar group = sidebar.AddGroup( \"No Terrain\" );\r\n\t\t\tgroup.Add( new Label( \"Select a GameObject with a Terrain component.\" ) );\r\n\t\t}\r\n\r\n\t\t{\r\n\t\t\tvar cursorLayout = sidebar.AddGroup( \"Cursor\" );\r\n\t\t\t_statusWidget = new SubTerrainStatusWidget( this, sidebar );\r\n\t\t\tcursorLayout.Add( _statusWidget );\r\n\t\t\t_statusSection = FindSidebarGroupHost( _statusWidget );\r\n\t\t}\r\n\r\n\t\tsidebar.AddShortcuts(\r\n\t\t\t(\"Activate SubTerrain\", \"N\"),\r\n\t\t\t(\"Sculpt / paint tools\", \"1 - 9\"),\r\n\t\t\t(\"Plainer / Bridge\", \"0 / B\"),\r\n\t\t\t(\"Paint: Ground / Wear / Path\", \"Ctrl+1 / 2 / 3\"),\r\n\t\t\t(\"Paint tmat prev / next\", \"Up / Down\"),\r\n\t\t\t(\"Opacity (sculpt)\", \"Up / Down\"),\r\n\t\t\t(\"Brush size\", \"Left / Right | Ctrl+[ / ]\"),\r\n\t\t\t(\"Size / opacity\", \"MMB+Shift drag\"),\r\n\t\t\t(\"Brush rotation\", \"MMB+Ctrl drag\"),\r\n\t\t\t(\"Sample / fade / smooth\", \"Alt / Ctrl / Shift\")\r\n\t\t);\r\n\r\n\t\tsidebar.Layout.AddStretchCell();\r\n\t\tUpdateIntent();\r\n\t\treturn sidebar;\r\n\t}\r\n\r\n\tstatic Widget FindSidebarGroupHost( Widget child )\r\n\t{\r\n\t\tfor ( var p = child?.Parent; p is not null; p = p.Parent )\r\n\t\t{\r\n\t\t\tif ( p.GetType().Name == \"SidebarGroupWidget\" )\r\n\t\t\t\treturn p;\r\n\t\t}\r\n\r\n\t\treturn child?.Parent;\r\n\t}\r\n\r\n\t[EditorEvent.Frame]\r\n\tpublic void TickSidebarChrome()\r\n\t{\r\n\t\tif ( ActiveInstance != this )\r\n\t\t\treturn;\r\n\r\n\t\tif ( !PaintArmed )\r\n\t\t\tUpdateIntent();\r\n\t}\r\n\r\n\tvoid RebuildBrushSheet()\r\n\t{\r\n\t\tif ( _brushSheetHost is null )\r\n\t\t\treturn;\r\n\r\n\t\t_brushSheetHost.Layout.Clear( true );\r\n\t\t_sizeWidget = null;\r\n\t\t_opacityWidget = null;\r\n\t\t_speedWidget = null;\r\n\t\t_rotationWidget = null;\r\n\r\n\t\t_sizeWidget = new SubTerrainBrushSizeWidget( this, _brushSheetHost );\r\n\t\t_brushSheetHost.Layout.Add( _sizeWidget );\r\n\r\n\t\tvar so = BrushSettings.GetSerialized();\r\n\t\tvar sheet = new ControlSheet();\r\n\t\t_opacityWidget = sheet.AddRow( so.GetProperty( nameof( SubTerrainBrushSettings.Opacity ) ) );\r\n\t\tif ( _brushMode == nameof( SubRaiseLowerTool ) )\r\n\t\t\t_speedWidget = sheet.AddRow( so.GetProperty( nameof( SubTerrainBrushSettings.Speed ) ) );\r\n\t\t_rotationWidget = sheet.AddRow( so.GetProperty( nameof( SubTerrainBrushSettings.Rotation ) ) );\r\n\t\tvar rndRot = sheet.AddRow( so.GetProperty( nameof( SubTerrainBrushSettings.RandomRotation ) ) );\r\n\t\tsheet.AddRow( so.GetProperty( nameof( SubTerrainBrushSettings.ShowGridPreview ) ) );\r\n\t\tif ( _brushMode == nameof( SubSetHeightTool ) )\r\n\t\t\tsheet.AddRow( so.GetProperty( nameof( SubTerrainBrushSettings.TargetHeight ) ) );\r\n\t\tif ( _brushMode == nameof( SubBridgeTool ) )\r\n\t\t{\r\n\t\t\tsheet.AddRow( so.GetProperty( nameof( SubTerrainBrushSettings.BridgeWidth ) ) );\r\n\t\t\tsheet.AddRow( so.GetProperty( nameof( SubTerrainBrushSettings.BridgeEdgeFalloff ) ) );\r\n\t\t\tsheet.AddRow( so.GetProperty( nameof( SubTerrainBrushSettings.BridgeRaiseOnly ) ) );\r\n\t\t}\r\n\r\n\t\tso.OnPropertyChanged += OnBrushSheetPropertyChanged;\r\n\r\n\t\t_brushSheetHost.Layout.Add( sheet );\r\n\t\tSyncBrushRotationRows();\r\n\t\tUpdateOpacityState();\r\n\t}\r\n\r\n\tvoid OnBrushSheetPropertyChanged( SerializedProperty prop )\r\n\t{\r\n\t\tif ( prop?.Name == nameof( SubTerrainBrushSettings.RandomRotation ) )\r\n\t\t\tSyncBrushRotationRows();\r\n\t}\r\n\r\n\tvoid UpdateOpacityState()\r\n\t{\r\n\t\tif ( _speedWidget is not null )\r\n\t\t\t_speedWidget.ToolTip = \"How fast height accumulates. Hold still to keep raising. Higher = more stamps and more height per stamp.\";\r\n\r\n\t\tif ( _opacityWidget is null )\r\n\t\t\treturn;\r\n\r\n\t\tif ( !PaintArmed )\r\n\t\t{\r\n\t\t\tbool bridge = _brushMode == nameof( SubBridgeTool );\r\n\t\t\t_opacityWidget.Enabled = !bridge;\r\n\t\t\t_opacityWidget.ToolTip = bridge\r\n\t\t\t\t? \"Bridge always builds at the exact start and end heights.\"\r\n\t\t\t\t: OpacityTipForSculpt();\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tvar layer = PaintSettings.Layer;\r\n\t\tif ( layer == SubTerrainPaintLayer.Overlay )\r\n\t\t{\r\n\t\t\t_opacityWidget.Enabled = true;\r\n\t\t\t_opacityWidget.ToolTip = \"How fast wear builds on this stroke. Ctrl reduces.\";\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\t_opacityWidget.Enabled = false;\r\n\t\t_opacityWidget.ToolTip = layer == SubTerrainPaintLayer.Path\r\n\t\t\t? \"Path uses Center mix and Core for the apron / solid ribbon. Pack center optional. Soft circle or stroke.\"\r\n\t\t\t: \"Ground paint lays a solid tmat. Mix is cleared.\";\r\n\t}\r\n\r\n\tstring OpacityTipForSculpt()\r\n\t{\r\n\t\treturn _brushMode switch\r\n\t\t{\r\n\t\t\tnameof( SubRaiseLowerTool ) => \"Brush soft strength. Ctrl lowers. Use Speed for how fast height builds.\",\r\n\t\t\tnameof( SubHoleTool ) => \"How fast the punch cuts. Ctrl fills the hole back.\",\r\n\t\t\tnameof( SubNoiseTool ) => \"How strong the bump is. Ctrl inverts.\",\r\n\t\t\tnameof( SubFlattenTool ) => \"How fast it levels to the click height.\",\r\n\t\t\tnameof( SubSetHeightTool ) => \"How fast it levels to Target Height.\",\r\n\t\t\tnameof( SubSlopeTool ) => \"How fast it grades to the ramp.\",\r\n\t\t\tnameof( SubPathTool ) => \"How fast it levels to the click height.\",\r\n\t\t\tnameof( SubSmoothTool ) => \"How strong the smooth is.\",\r\n\t\t\tnameof( SubPlainerTool ) => \"How strong the weighted leveling is.\",\r\n\t\t\t_ => \"Stroke strength.\"\r\n\t\t};\r\n\t}\r\n}\r\n\r\n[Title( \"Raise / Lower\" )]\r\n[Description( \"Paint height up. Hold Ctrl to lower.\" )]\r\n[Icon( \"height\" )]\r\n[Alias( \"subterrain_raise_lower\" )]\r\n[Group( \"1\" )]\r\n[Order( 0 )]\r\npublic class SubRaiseLowerTool : SubTerrainBaseBrushTool\r\n{\r\n\tpublic SubRaiseLowerTool( SubTerrainTool parent ) : base( parent )\r\n\t{\r\n\t\tMode = SubTerrainSculptMode.RaiseLower;\r\n\t\tAllowBrushInvert = true;\r\n\t}\r\n}\r\n\r\n[Title( \"Flatten\" )]\r\n[Description( \"Level to the height under the click. Stays on that plane while you drag.\" )]\r\n[Icon( \"trending_flat\" )]\r\n[Alias( \"subterrain_flatten\" )]\r\n[Group( \"1\" )]\r\n[Order( 3 )]\r\npublic class SubFlattenTool : SubTerrainBaseBrushTool\r\n{\r\n\tpublic SubFlattenTool( SubTerrainTool parent ) : base( parent )\r\n\t{\r\n\t\tMode = SubTerrainSculptMode.Flatten;\r\n\t}\r\n\r\n\tpublic override bool GetHitPosition( Terrain terrain, out Vector3 position )\r\n\t{\r\n\t\tif ( _dragging )\r\n\t\t{\r\n\t\t\tvar tx = terrain.WorldTransform;\r\n\t\t\tvar hit = StrokePlane.Trace( Gizmo.CurrentRay, true );\r\n\t\t\tposition = tx.PointToLocal( hit.Value );\r\n\t\t\treturn hit.HasValue;\r\n\t\t}\r\n\r\n\t\treturn base.GetHitPosition( terrain, out position );\r\n\t}\r\n}\r\n\r\n[Title( \"Smooth\" )]\r\n[Description( \"Blend neighbouring heights.\" )]\r\n[Icon( \"rounded_corner\" )]\r\n[Alias( \"subterrain_smooth\" )]\r\n[Group( \"1\" )]\r\n[Order( 1 )]\r\npublic class SubSmoothTool : SubTerrainBaseBrushTool\r\n{\r\n\tpublic SubSmoothTool( SubTerrainTool parent ) : base( parent )\r\n\t{\r\n\t\tMode = SubTerrainSculptMode.Smooth;\r\n\t}\r\n\r\n\tpublic override bool GetHitPosition( Terrain terrain, out Vector3 position )\r\n\t{\r\n\t\tif ( _dragging )\r\n\t\t{\r\n\t\t\tvar tx = terrain.WorldTransform;\r\n\t\t\tvar hit = StrokePlane.Trace( Gizmo.CurrentRay, true );\r\n\t\t\tposition = tx.PointToLocal( hit.Value );\r\n\t\t\treturn hit.HasValue;\r\n\t\t}\r\n\r\n\t\treturn base.GetHitPosition( terrain, out position );\r\n\t}\r\n}\r\n\r\n[Title( \"Plainer\" )]\r\n[Description( \"Gradually level a broad weighted area, like Hammer displacement smoothing.\" )]\r\n[Icon( \"blur_on\" )]\r\n[Alias( \"subterrain_plainer\" )]\r\n[Group( \"1\" )]\r\n[Order( 2 )]\r\npublic class SubPlainerTool : SubTerrainBaseBrushTool\r\n{\r\n\tpublic SubPlainerTool( SubTerrainTool parent ) : base( parent )\r\n\t{\r\n\t\tMode = SubTerrainSculptMode.Plainer;\r\n\t}\r\n\r\n\tpublic override bool GetHitPosition( Terrain terrain, out Vector3 position )\r\n\t{\r\n\t\tif ( _dragging )\r\n\t\t{\r\n\t\t\tvar tx = terrain.WorldTransform;\r\n\t\t\tvar hit = StrokePlane.Trace( Gizmo.CurrentRay, true );\r\n\t\t\tposition = tx.PointToLocal( hit.Value );\r\n\t\t\treturn hit.HasValue;\r\n\t\t}\r\n\r\n\t\treturn base.GetHitPosition( terrain, out position );\r\n\t}\r\n}\r\n\r\n[Title( \"Noise\" )]\r\n[Description( \"Add bump. Hold Ctrl to invert.\" )]\r\n[Icon( \"grain\" )]\r\n[Alias( \"subterrain_noise\" )]\r\n[Group( \"1\" )]\r\n[Order( 8 )]\r\npublic class SubNoiseTool : SubTerrainBaseBrushTool\r\n{\r\n\tpublic SubNoiseTool( SubTerrainTool parent ) : base( parent )\r\n\t{\r\n\t\tMode = SubTerrainSculptMode.Noise;\r\n\t\tAllowBrushInvert = true;\r\n\t}\r\n}\r\n\r\n[Title( \"Hole\" )]\r\n[Description( \"Punch through the surface. Hold Ctrl to fill the hole back.\" )]\r\n[Icon( \"trip_origin\" )]\r\n[Alias( \"subterrain_hole\" )]\r\n[Group( \"1\" )]\r\n[Order( 9 )]\r\npublic class SubHoleTool : SubTerrainBaseBrushTool\r\n{\r\n\tpublic SubHoleTool( SubTerrainTool parent ) : base( parent )\r\n\t{\r\n\t\tMode = SubTerrainSculptMode.Hole;\r\n\t\tAllowBrushInvert = true;\r\n\t}\r\n}\r\n\r\ninternal class SubTerrainBrushPreviewWidget : Widget\r\n{\r\n\tpublic SubTerrainBrushPreviewWidget( Widget parent ) : base( parent )\r\n\t{\r\n\t\tMinimumSize = new( 32, 32 );\r\n\t\tMaximumSize = new( 48, 48 );\r\n\t\tCursor = CursorShape.Finger;\r\n\t}\r\n\r\n\tprotected override void OnPaint()\r\n\t{\r\n\t\tbase.OnPaint();\r\n\r\n\t\tPaint.Antialiasing = true;\r\n\t\tPaint.ClearPen();\r\n\t\tPaint.DrawRect( LocalRect );\r\n\r\n\t\tvar pixmap = SubTerrainTool.BrushList?.Selected?.Pixmap;\r\n\t\tif ( pixmap != null )\r\n\t\t{\r\n\t\t\tPaint.Draw( LocalRect.Contain( pixmap.Size ), pixmap );\r\n\t\t}\r\n\t}\r\n\r\n\tprotected override void OnMouseClick( MouseEvent e )\r\n\t{\r\n\t\tvar popup = new PopupWidget( null );\r\n\t\tpopup.Position = Application.CursorPosition;\r\n\t\tpopup.Visible = true;\r\n\t\tpopup.Layout = Layout.Column();\r\n\t\tpopup.Layout.Margin = 10;\r\n\t\tpopup.MaximumSize = new Vector2( 300, 200 );\r\n\r\n\t\tvar list = new SubTerrainBrushListWidget();\r\n\t\tlist.BrushSelected += () =>\r\n\t\t{\r\n\t\t\tpopup.Close();\r\n\t\t\tUpdate();\r\n\t\t};\r\n\t\tpopup.Layout.Add( list );\r\n\t}\r\n\r\n\t[EditorEvent.Frame]\r\n\tpublic void UpdatePreview()\r\n\t{\r\n\t\tUpdate();\r\n\t}\r\n}\r\n\r\ninternal class SubTerrainBrushListWidget : Widget\r\n{\r\n\tpublic Action BrushSelected { get; set; }\r\n\r\n\tpublic SubTerrainBrushListWidget() : base( null )\r\n\t{\r\n\t\tListView list = new();\r\n\t\tlist.SetItems( SubTerrainTool.BrushList.Brushes.Cast<object>() );\r\n\t\tlist.ItemSize = new Vector2( 40, 40 );\r\n\t\tlist.ItemAlign = Sandbox.UI.Align.SpaceBetween;\r\n\t\tlist.OnPaintOverride += () => PaintListBackground( list );\r\n\t\tlist.ItemPaint = PaintBrushItem;\r\n\t\tlist.ItemSelected = item =>\r\n\t\t{\r\n\t\t\tif ( item is Brush brush )\r\n\t\t\t{\r\n\t\t\t\tSubTerrainTool.BrushList.Selected = brush;\r\n\t\t\t\tBrushSelected?.Invoke();\r\n\t\t\t}\r\n\t\t};\r\n\t\tlist.SelectItem( SubTerrainTool.BrushList.Selected );\r\n\r\n\t\tLayout = Layout.Column();\r\n\r\n\t\tvar label = new Label( \"Brushes\" );\r\n\t\tlabel.SetStyles( \"font-weight: bold\" );\r\n\t\tLayout.Add( label );\r\n\t\tLayout.AddSpacingCell( 8 );\r\n\t\tLayout.Add( list );\r\n\t}\r\n\r\n\tprivate void PaintBrushItem( VirtualWidget widget )\r\n\t{\r\n\t\tvar brush = (Brush)widget.Object;\r\n\r\n\t\tPaint.Antialiasing = true;\r\n\t\tPaint.TextAntialiasing = true;\r\n\r\n\t\tif ( widget.Hovered || widget.Selected )\r\n\t\t{\r\n\t\t\tPaint.ClearPen();\r\n\t\t\tPaint.SetBrush( widget.Selected ? Theme.Primary : Color.White.WithAlpha( 0.1f ) );\r\n\t\t\tPaint.DrawRect( widget.Rect.Grow( 2 ), 3 );\r\n\t\t}\r\n\r\n\t\tPaint.Draw( widget.Rect, brush.Pixmap );\r\n\t}\r\n\r\n\tprivate bool PaintListBackground( Widget widget )\r\n\t{\r\n\t\tPaint.ClearPen();\r\n\t\tPaint.SetBrush( Theme.ControlBackground );\r\n\t\tPaint.DrawRect( widget.LocalRect );\r\n\t\treturn false;\r\n\t}\r\n}\r\n\r\ninternal class SubTerrainToolShortcutsWidget : Widget\r\n{\r\n\t[Shortcut( \"tools.subterrain.raise-lower\", \"1\", typeof( SceneViewWidget ) )]\r\n\tpublic void ActivateRaiseLower() => EditorToolManager.SetSubTool( nameof( SubRaiseLowerTool ) );\r\n\r\n\t[Shortcut( \"tools.subterrain.smooth\", \"2\", typeof( SceneViewWidget ) )]\r\n\tpublic void ActivateSmooth() => EditorToolManager.SetSubTool( nameof( SubSmoothTool ) );\r\n\r\n\t[Shortcut( \"tools.subterrain.flatten\", \"3\", typeof( SceneViewWidget ) )]\r\n\tpublic void ActivateFlatten() => EditorToolManager.SetSubTool( nameof( SubFlattenTool ) );\r\n\r\n\t[Shortcut( \"tools.subterrain.paint\", \"4\", typeof( SceneViewWidget ) )]\r\n\tpublic void ActivatePaint() => EditorToolManager.SetSubTool( nameof( SubTerrainPaintTool ) );\r\n\r\n\t[Shortcut( \"tools.subterrain.slope\", \"5\", typeof( SceneViewWidget ) )]\r\n\tpublic void ActivateSlope() => EditorToolManager.SetSubTool( nameof( SubSlopeTool ) );\r\n\r\n\t[Shortcut( \"tools.subterrain.path\", \"6\", typeof( SceneViewWidget ) )]\r\n\tpublic void ActivatePath() => EditorToolManager.SetSubTool( nameof( SubPathTool ) );\r\n\r\n\t[Shortcut( \"tools.subterrain.set-height\", \"7\", typeof( SceneViewWidget ) )]\r\n\tpublic void ActivateSetHeight() => EditorToolManager.SetSubTool( nameof( SubSetHeightTool ) );\r\n\r\n\t[Shortcut( \"tools.subterrain.noise\", \"8\", typeof( SceneViewWidget ) )]\r\n\tpublic void ActivateNoise() => EditorToolManager.SetSubTool( nameof( SubNoiseTool ) );\r\n\r\n\t[Shortcut( \"tools.subterrain.hole\", \"9\", typeof( SceneViewWidget ) )]\r\n\tpublic void ActivateHole() => EditorToolManager.SetSubTool( nameof( SubHoleTool ) );\r\n\r\n\t[Shortcut( \"tools.subterrain.plainer\", \"0\", typeof( SceneViewWidget ) )]\r\n\tpublic void ActivatePlainer() => EditorToolManager.SetSubTool( nameof( SubPlainerTool ) );\r\n\r\n\t[Shortcut( \"tools.subterrain.bridge\", \"B\", typeof( SceneViewWidget ) )]\r\n\tpublic void ActivateBridge() => EditorToolManager.SetSubTool( nameof( SubBridgeTool ) );\r\n\r\n\t[Shortcut( \"tools.subterrain.paint-ground\", \"CTRL+1\", typeof( SceneViewWidget ) )]\r\n\tpublic void PaintGround() => SubTerrainTool.ActiveInstance?.TrySetPaintLayer( SubTerrainPaintLayer.Base );\r\n\r\n\t[Shortcut( \"tools.subterrain.paint-wear\", \"CTRL+2\", typeof( SceneViewWidget ) )]\r\n\tpublic void PaintWear() => SubTerrainTool.ActiveInstance?.TrySetPaintLayer( SubTerrainPaintLayer.Overlay );\r\n\r\n\t[Shortcut( \"tools.subterrain.paint-path\", \"CTRL+3\", typeof( SceneViewWidget ) )]\r\n\tpublic void PaintPath() => SubTerrainTool.ActiveInstance?.TrySetPaintLayer( SubTerrainPaintLayer.Path );\r\n\r\n\t[Shortcut( \"tools.subterrain.brush-smaller\", \"CTRL+[\", typeof( SceneViewWidget ) )]\r\n\tpublic void BrushSmaller() => SubTerrainTool.ActiveInstance?.NudgeBrushSize( -1 );\r\n\r\n\t[Shortcut( \"tools.subterrain.brush-larger\", \"CTRL+]\", typeof( SceneViewWidget ) )]\r\n\tpublic void BrushLarger() => SubTerrainTool.ActiveInstance?.NudgeBrushSize( 1 );\r\n\r\n\t[Shortcut( \"tools.subterrain.brush-smaller-arrow\", \"LEFT\", typeof( SceneViewWidget ) )]\r\n\tpublic void BrushSmallerArrow()\r\n\t{\r\n\t\tif ( SubTerrainTool.ActiveInstance is not null )\r\n\t\t{\r\n\t\t\tSubTerrainTool.ActiveInstance.NudgeBrushSize( -1 );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tEditorShortcuts.PassShortcut = true;\r\n\t}\r\n\r\n\t[Shortcut( \"tools.subterrain.brush-larger-arrow\", \"RIGHT\", typeof( SceneViewWidget ) )]\r\n\tpublic void BrushLargerArrow()\r\n\t{\r\n\t\tif ( SubTerrainTool.ActiveInstance is not null )\r\n\t\t{\r\n\t\t\tSubTerrainTool.ActiveInstance.NudgeBrushSize( 1 );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tEditorShortcuts.PassShortcut = true;\r\n\t}\r\n\r\n\t[Shortcut( \"tools.subterrain.paint-tmat-prev\", \"UP\", typeof( SceneViewWidget ) )]\r\n\tpublic void ArrowUp()\r\n\t{\r\n\t\tvar tool = SubTerrainTool.ActiveInstance;\r\n\t\tif ( tool is null )\r\n\t\t{\r\n\t\t\tEditorShortcuts.PassShortcut = true;\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tif ( tool.PaintArmed )\r\n\t\t{\r\n\t\t\tif ( tool.NudgePaintMaterial( -1 ) )\r\n\t\t\t\treturn;\r\n\r\n\t\t\tEditorShortcuts.PassShortcut = true;\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\ttool.NudgeBrushOpacity( 1 );\r\n\t}\r\n\r\n\t[Shortcut( \"tools.subterrain.paint-tmat-next\", \"DOWN\", typeof( SceneViewWidget ) )]\r\n\tpublic void ArrowDown()\r\n\t{\r\n\t\tvar tool = SubTerrainTool.ActiveInstance;\r\n\t\tif ( tool is null )\r\n\t\t{\r\n\t\t\tEditorShortcuts.PassShortcut = true;\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tif ( tool.PaintArmed )\r\n\t\t{\r\n\t\t\tif ( tool.NudgePaintMaterial( 1 ) )\r\n\t\t\t\treturn;\r\n\r\n\t\t\tEditorShortcuts.PassShortcut = true;\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\ttool.NudgeBrushOpacity( -1 );\r\n\t}\r\n}\r\n"
},
{
"Ident": "subzerostudios.subterrain-tool",
"Path": "Editor/SubTerrainBrushSizeWidget.cs",
"FileName": "SubTerrainBrushSizeWidget.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 344638,
"Code": "// Copyright (c) 2026 SubZero Studios LLC. All rights reserved.\r\n\r\nusing System;\r\nusing Sandbox;\r\n\r\nnamespace Editor.SubTerrain;\r\n\r\n/// <summary>\r\n/// Brush Size row: piecewise track (fine under 3500, mid to 18000, coarse to 35000) plus type-in.\r\n/// </summary>\r\ninternal sealed class SubTerrainBrushSizeWidget : Widget\r\n{\r\n\treadonly SubTerrainTool _tool;\r\n\treadonly SubTerrainBrushSizeTrack _track;\r\n\treadonly LineEdit _field;\r\n\tbool _syncing;\r\n\r\n\tpublic SubTerrainBrushSizeWidget( SubTerrainTool tool, Widget parent ) : base( parent )\r\n\t{\r\n\t\t_tool = tool;\r\n\r\n\t\tLayout = Layout.Row();\r\n\t\tLayout.Spacing = 6;\r\n\t\tLayout.Margin = new Sandbox.UI.Margin( 0, 2, 10, 2 );\r\n\r\n\t\tvar label = Layout.Add( new Label( \"Size\" ) );\r\n\t\tlabel.MinimumWidth = 72;\r\n\t\tlabel.Color = Theme.TextControl;\r\n\t\tlabel.ToolTip = \"World units. Fine under 3500 (step 50), mid to 18000 (step 500), then to 35000 (step 1000). Left / Right or Ctrl+[ / ] steps.\";\r\n\r\n\t\t_track = new SubTerrainBrushSizeTrack( this );\r\n\t\tLayout.Add( _track, 1 );\r\n\r\n\t\t_field = new LineEdit();\r\n\t\t_field.MinimumWidth = 52;\r\n\t\t_field.MaximumWidth = 56;\r\n\t\t_field.ToolTip = \"Type an exact size (8-35000).\";\r\n\t\t_field.EditingFinished += OnFieldFinished;\r\n\t\tLayout.Add( _field );\r\n\r\n\t\tFixedHeight = 28;\r\n\t\tMinimumHeight = 28;\r\n\t\tMaximumHeight = 28;\r\n\r\n\t\tSyncFromSettings();\r\n\t}\r\n\r\n\tpublic void SyncFromSettings()\r\n\t{\r\n\t\tif ( _tool?.BrushSettings is null )\r\n\t\t\treturn;\r\n\r\n\t\t_syncing = true;\r\n\t\tint size = SubTerrainBrushSize.Clamp( _tool.BrushSettings.Size );\r\n\t\t_tool.BrushSettings.Size = size;\r\n\t\t_field.Text = size.ToString();\r\n\t\t_track.Update();\r\n\t\t_syncing = false;\r\n\t}\r\n\r\n\tpublic void SetSizeFromTrack( float t01 )\r\n\t{\r\n\t\tif ( _syncing || _tool?.BrushSettings is null )\r\n\t\t\treturn;\r\n\r\n\t\tint size = SubTerrainBrushSize.SizeFromTrack( t01 );\r\n\t\tif ( size == _tool.BrushSettings.Size )\r\n\t\t{\r\n\t\t\t_track.Update();\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\t_tool.BrushSettings.Size = size;\r\n\t\t_syncing = true;\r\n\t\t_field.Text = size.ToString();\r\n\t\t_syncing = false;\r\n\t\t_track.Update();\r\n\t}\r\n\r\n\tvoid OnFieldFinished()\r\n\t{\r\n\t\tif ( _syncing || _tool?.BrushSettings is null )\r\n\t\t\treturn;\r\n\r\n\t\tif ( !int.TryParse( _field.Text.Trim(), out int parsed ) )\r\n\t\t{\r\n\t\t\tSyncFromSettings();\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\t_tool.BrushSettings.Size = SubTerrainBrushSize.Clamp( parsed );\r\n\t\tSyncFromSettings();\r\n\t}\r\n}\r\n\r\ninternal sealed class SubTerrainBrushSizeTrack : Widget\r\n{\r\n\treadonly SubTerrainBrushSizeWidget _host;\r\n\tbool _dragging;\r\n\r\n\tpublic SubTerrainBrushSizeTrack( SubTerrainBrushSizeWidget host ) : base( host )\r\n\t{\r\n\t\t_host = host;\r\n\t\tMouseTracking = true;\r\n\t\tCursor = CursorShape.Finger;\r\n\t\tMinimumHeight = 22;\r\n\t\tMaximumHeight = 22;\r\n\t\tToolTip = \"Drag: fine under 3500, mid to 18000, coarse to 35000. Left / Right or Ctrl+[ / ] use the same steps.\";\r\n\t}\r\n\r\n\tprotected override void OnMousePress( MouseEvent e )\r\n\t{\r\n\t\tif ( e.Button == MouseButtons.Left )\r\n\t\t{\r\n\t\t\t_dragging = true;\r\n\t\t\tApplyAt( e.LocalPosition.x );\r\n\t\t\te.Accepted = true;\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tbase.OnMousePress( e );\r\n\t}\r\n\r\n\tprotected override void OnMouseMove( MouseEvent e )\r\n\t{\r\n\t\tbase.OnMouseMove( e );\r\n\t\tif ( _dragging )\r\n\t\t\tApplyAt( e.LocalPosition.x );\r\n\t}\r\n\r\n\tprotected override void OnMouseReleased( MouseEvent e )\r\n\t{\r\n\t\tif ( e.Button == MouseButtons.Left )\r\n\t\t\t_dragging = false;\r\n\r\n\t\tbase.OnMouseReleased( e );\r\n\t}\r\n\r\n\tvoid ApplyAt( float x )\r\n\t{\r\n\t\tfloat w = Math.Max( Width - 8f, 1f );\r\n\t\tfloat t = Math.Clamp( (x - 4f) / w, 0f, 1f );\r\n\t\t_host.SetSizeFromTrack( t );\r\n\t}\r\n\r\n\tprotected override void OnPaint()\r\n\t{\r\n\t\tbase.OnPaint();\r\n\r\n\t\tPaint.Antialiasing = true;\r\n\t\tPaint.ClearPen();\r\n\r\n\t\tvar track = new Rect( 4, Height * 0.5f - 2f, Math.Max( Width - 8f, 1f ), 4f );\r\n\t\tPaint.SetBrush( Color.White.WithAlpha( 0.08f ) );\r\n\t\tPaint.DrawRect( track, 2 );\r\n\r\n\t\t// Piecewise band ticks (fine / mid).\r\n\t\tfloat fineX = track.Left + track.Width * SubTerrainBrushSize.FineTrack;\r\n\t\tfloat midX = track.Left + track.Width * SubTerrainBrushSize.MidTrack;\r\n\t\tPaint.SetBrush( Color.White.WithAlpha( 0.14f ) );\r\n\t\tPaint.DrawRect( new Rect( fineX, track.Top - 2f, 1f, track.Height + 4f ) );\r\n\t\tPaint.DrawRect( new Rect( midX, track.Top - 2f, 1f, track.Height + 4f ) );\r\n\r\n\t\tvar tool = SubTerrainTool.ActiveInstance;\r\n\t\tint size = tool?.BrushSettings is not null\r\n\t\t\t? SubTerrainBrushSize.Clamp( tool.BrushSettings.Size )\r\n\t\t\t: SubTerrainBrushSize.Min;\r\n\r\n\t\tfloat t = SubTerrainBrushSize.TrackFromSize( size );\r\n\t\tfloat thumbX = track.Left + track.Width * t;\r\n\t\tfloat thumbR = 6f;\r\n\t\tPaint.SetBrush( Theme.Primary );\r\n\t\tPaint.DrawCircle( new Vector2( thumbX, Height * 0.5f ), thumbR );\r\n\t}\r\n}\r\n"
}
]
}