Editor/Effigy/SculptSession.cs
using System;
using System.Collections.Generic;
namespace Effigy;
/// <summary>
/// One stroke's worth of change, sparse and symmetric, so undo and redo cost the vertices the
/// stroke touched rather than the whole level.
///
/// At L4 a level is ~128k vertices and a full snapshot per stroke is a megabyte and a half. A stroke
/// usually moves a few hundred, which is why <see cref="BrushUndo"/> records a working set rather
/// than a copy, and why this keeps that shape instead of flattening it into two meshes.
/// </summary>
public sealed class SculptEdit
{
public readonly int Level;
// Exactly one of these pairs is set. A stroke either moved the surface or painted the mask;
// nothing in the tool does both, and a single type keeps one undo stack rather than two that
// can be popped out of order.
readonly Dictionary<int, Vec3> _before;
readonly Dictionary<int, Vec3> _after;
readonly Dictionary<int, float> _maskBefore;
readonly Dictionary<int, float> _maskAfter;
internal SculptEdit( int level, Dictionary<int, Vec3> before, Dictionary<int, Vec3> after )
{
Level = level;
_before = before;
_after = after;
}
internal SculptEdit( int level, Dictionary<int, float> before, Dictionary<int, float> after )
{
Level = level;
_maskBefore = before;
_maskAfter = after;
}
/// <summary>A whole level that was removed, held so it can be put back.</summary>
internal SculptEdit( int level, SculptLayer removed )
{
Level = level;
_removed = removed;
}
readonly SculptLayer _removed;
/// <summary>Whether this edit removed a level rather than moving vertices or painting a mask.</summary>
public bool IsLevel => _removed is not null;
/// <summary>The layer this edit took off, for putting back.</summary>
internal SculptLayer Level_ => _removed;
/// <summary>Whether this stroke painted the mask rather than moving the surface.</summary>
public bool IsMask => _maskBefore is not null;
/// <summary>How many vertices this stroke actually changed.</summary>
public int Count => _before?.Count ?? _maskBefore?.Count ?? _removed.Count;
internal void Write( PolyMesh mesh, bool forward )
{
foreach ( var (vertex, position) in forward ? _after : _before )
mesh.Positions[vertex] = position;
}
internal void Write( SculptMask mask, bool forward )
{
foreach ( var (vertex, value) in forward ? _maskAfter : _maskBefore )
mask[vertex] = value;
}
}
/// <summary>
/// The sculpt tool, with no cursor in it.
///
/// WHY THIS IS IN THE KERNEL. The editor cannot be compiled outside s&box, so anything living there
/// is verified by reading it — and reading it is how a bug that made every parameter edit a silent
/// no-op survived long enough to look like three unrelated UI faults. Everything a sculpt tool does
/// between the pointer and the mesh is arithmetic: project a ray, decide whether the cursor has moved
/// far enough to deserve a sample, fill in the gap when it moved too far, apply, record once. All of
/// it is testable with no engine anywhere, and all of it is where the bugs are. What is left for the
/// s&box half is genuinely thin: hand this rays, draw <see cref="DisplayMesh"/>, and draw a ring at
/// <see cref="Hover"/>.
///
/// THE STROKE WORKS ON A MESH, NOT ON THE LEVEL STACK. Evaluating the stack and recording deltas costs
/// the whole level, and doing it per sample would make a stroke O(samples x vertices) and the tool
/// unusable at L3. So a stroke evaluates once at <see cref="BeginStroke"/>, brushes a working copy
/// that the viewport draws live, and calls <see cref="MultiresSculpt.Record"/> exactly once at
/// <see cref="EndStroke"/>. One stroke is therefore one revision and one undo entry, which is also
/// what a user means by "undo".
/// </summary>
public sealed class SculptSession
{
readonly MultiresSculpt _sculpt;
// Live only between BeginStroke and EndStroke.
PolyMesh _working;
SculptFrames _frames;
MeshBVH _bvh;
BrushUndo _undo;
Vec3 _lastSample;
// The mask as the stroke found it. Diffed against the mask afterwards, so what goes on the undo
// stack is only what the stroke actually touched — the same sparse promise the geometry side
// makes, and worth keeping: a full mask array at L4 is half a megabyte per stroke.
float[] _maskBefore;
// What the viewport draws when no stroke is running, rebuilt when the sculpt says it changed.
PolyMesh _display;
int _displayRevision = -1;
int _displayLevel = -1;
int _displayMaskRevision = -1;
bool _displayHidden;
readonly Stack<SculptEdit> _done = new();
readonly Stack<SculptEdit> _undone = new();
// One mask per level, made on demand. Kept per level rather than one shared array because a
// mask is per vertex and the levels have different vertex counts; dropping to L1 and back must
// not silently reinterpret L3's mask as L1's.
readonly Dictionary<int, SculptMask> _masks = new();
public SculptSession( MultiresSculpt sculpt )
{
_sculpt = sculpt ?? throw new ArgumentNullException( nameof( sculpt ) );
}
public MultiresSculpt Sculpt => _sculpt;
public BrushKind Brush = BrushKind.Draw;
public BrushFalloff Falloff = BrushFalloff.Smooth;
/// <summary>Brush radius in world units, not pixels — the kernel has no screen.</summary>
public float Radius = 0.25f;
/// <summary>
/// A starting radius that suits this model: a twelfth of the cage's diagonal, which is a brush
/// you can see and can still cross the part with in a few strokes.
///
/// Here rather than in the editor because a fixed default is wrong in both directions - Effigy's
/// units are dimensionless, so the same number is the whole model on one part and invisible on
/// the next - and because "what a sensible brush size is" is not a thing about widgets.
/// </summary>
public float SuggestedRadius
{
get
{
var diagonal = _sculpt.Cage.BoundsDiagonal;
return diagonal > 1e-6f ? diagonal / 12f : 0.25f;
}
}
public float Strength = 0.05f;
/// <summary>Mirror every sample across X. The cheap symmetry that covers most of what it is for.</summary>
public bool MirrorX;
/// <summary>
/// Strokes paint the mask instead of moving the surface.
///
/// A separate mode rather than a seventh brush, because it is not one: every BrushKind moves
/// vertices and this one does not, and putting it in that enum would mean every switch over
/// brushes in the kernel and the editor grew a case that does something categorically different.
/// </summary>
public bool Masking;
/// <summary>While masking, release protection instead of adding it. One control, both ways.</summary>
public bool Erasing;
/// <summary>
/// Draw the level with the fully masked parts dropped, so you can get at what is behind them.
///
/// A VIEW, like ViewLevel, and it reaches the model exactly as far as that one does: nowhere. The
/// feature still builds the whole surface. Hiding half a head to sculpt the inside of it must not
/// export a head with half of it missing.
///
/// Mid-stroke the working mesh is drawn whole regardless — the brush is acting on all of it, and
/// a mesh that changed shape the instant you pressed the mouse would read as the tool breaking.
/// </summary>
public bool HideMasked;
/// <summary>
/// How far the cursor must travel before it earns another sample, as a fraction of the radius.
///
/// A pointer produces events far faster than a brush needs them; without this, holding still
/// would pile hundreds of samples on one spot and a slow drag would bite far harder than a quick
/// one for the same gesture. A quarter of the radius is the usual figure and keeps overlapping
/// dabs reading as one continuous stroke.
/// </summary>
public float Spacing = 0.25f;
/// <summary>
/// Most samples one pointer move may be split into. A drag right across the model in one frame
/// would otherwise fill in thousands of dabs and stall — better to under-sample one flick than to
/// freeze the tool.
/// </summary>
public int MaxSamplesPerMove = 64;
/// <summary>Which level the brush works at. This IS the sculpt's view level; there is not a
/// second copy of it to disagree.</summary>
public int Level
{
get => _sculpt.ViewLevel;
set => _sculpt.ViewLevel = value;
}
public bool IsStroking => _working is not null;
public bool CanUndo => _done.Count > 0;
public bool CanRedo => _undone.Count > 0;
/// <summary>
/// The mask for a level, made unprotected on first ask.
///
/// Rebuilt from scratch if the level's vertex count has changed under it — which happens when a
/// level below is sculpted, since that changes nothing about the count, or when the cage is
/// rebased, which can. A mask of the wrong length would protect arbitrary vertices, so it is
/// dropped rather than resized.
/// </summary>
public SculptMask MaskFor( int level )
{
var count = _sculpt.Rest( level ).VertexCount;
if ( !_masks.TryGetValue( level, out var mask ) || mask.Count != count )
{
mask = new SculptMask( count );
_masks[level] = mask;
}
return mask;
}
/// <summary>The mask the brush is currently subject to, or null when nothing is protected.
/// Null rather than an all-ones array so the common case costs no multiply.</summary>
public SculptMask ActiveMask => _masks.TryGetValue( Level, out var mask ) && mask.Any ? mask : null;
public void ClearMask() => MaskFor( Level ).Clear();
public void InvertMask() => MaskFor( Level ).Invert();
/// <summary>The level's surface with the fully protected parts dropped — "hide by mask".</summary>
public PolyMesh HiddenByMask() => MaskFor( Level ).Hide( _sculpt.Evaluate( Level ) );
/// <summary>Protect everything, which is where "mask all but this bit" starts - invert it and
/// then paint free the part you want to work on.</summary>
public void ProtectAll() => MaskFor( Level ).Protect();
/// <summary>Vertex and face count at a level, for the slider that has to warn before the click.</summary>
public (int Vertices, int Faces) Cost( int level ) => _sculpt.Cost( level );
/// <summary>
/// The mesh to draw: the live working copy mid-stroke, otherwise the evaluated level.
///
/// Cached against the sculpt's revision, because a viewport asks every frame and evaluating the
/// level stack is not a per-frame cost. Treat it as read-only — it is the session's copy, not
/// yours.
/// </summary>
public PolyMesh DisplayMesh
{
get
{
if ( _working is not null )
return _working;
// The mask's own revision is part of the key: the sculpt does not change when a mask is
// painted, so a cache keyed on the sculpt alone would keep serving the mesh from before
// the mask moved and hide-by-mask would look like it did nothing.
var mask = HideMasked ? MaskFor( _sculpt.ViewLevel ) : null;
var maskRevision = mask?.Revision ?? -1;
if ( _display is null
|| _displayRevision != _sculpt.Revision
|| _displayLevel != _sculpt.ViewLevel
|| _displayHidden != HideMasked
|| _displayMaskRevision != maskRevision )
{
var evaluated = _sculpt.Display();
_display = mask is not null ? mask.Hide( evaluated ) : evaluated;
_displayRevision = _sculpt.Revision;
_displayLevel = _sculpt.ViewLevel;
_displayHidden = HideMasked;
_displayMaskRevision = maskRevision;
}
return _display;
}
}
/// <summary>
/// Where the cursor sits on the surface, or null if the ray missed. The editor draws its ring
/// here; nothing about it changes the model.
/// </summary>
public MeshHit? Hover( Vec3 origin, Vec3 direction )
{
var dir = direction.Normal;
if ( dir.LengthSquared < 0.5f )
return null;
if ( _working is not null )
return _bvh.Raycast( _working, origin, dir );
return MeshRaycast.Raycast( DisplayMesh, origin, dir );
}
/// <summary>
/// Start a stroke. Returns false if the ray missed, which is not a failure — clicking past the
/// model is how a user deselects, and it must not begin a stroke that later lands somewhere
/// surprising.
///
/// The first sample is applied here, so a single click leaves a mark rather than nothing.
/// </summary>
public bool BeginStroke( Vec3 origin, Vec3 direction )
{
if ( IsStroking )
throw new InvalidOperationException( "A stroke is already running; end it before starting another." );
if ( Radius <= 0f )
throw new InvalidOperationException( $"A brush needs a radius; this one is {Radius}." );
var dir = direction.Normal;
var hit = MeshRaycast.Raycast( DisplayMesh, origin, dir );
if ( hit is null )
return false;
_working = _sculpt.Evaluate( Level );
_bvh = MeshBVH.Build( _working );
_undo = new BrushUndo();
// Frames are built once, from the surface as the stroke found it, and not rebuilt per sample.
// Rebuilding them on 128k vertices per dab is the obvious way to make the tool unusable, and
// a brush that re-reads its own output also feeds back — an Inflate would run away as it
// followed the normals it had just moved. A stroke works against the surface it started on.
_frames = SculptFrames.Build( _working );
_lastSample = hit.Value.Point;
_maskBefore = Masking ? (float[])MaskFor( Level ).Values.Clone() : null;
Dab( hit.Value.Point, hit.Value.Normal, Vec3.Zero );
return true;
}
/// <summary>
/// Carry the stroke to a new pointer position. Returns how many samples it produced: zero when
/// the cursor has not travelled far enough to earn one, and several when it travelled far enough
/// that one would have left a gap.
///
/// A ray that misses the model does NOT end the stroke — dragging off the silhouette and back on
/// is ordinary, and ending there would make the tool feel like it drops the gesture.
/// </summary>
public int MoveTo( Vec3 origin, Vec3 direction )
{
if ( !IsStroking )
throw new InvalidOperationException( "No stroke is running." );
var dir = direction.Normal;
var hit = _bvh.Raycast( _working, origin, dir );
if ( hit is null )
return 0;
var target = hit.Value.Point;
var travelled = (target - _lastSample).Length;
var spacing = MathF.Max( Radius * Spacing, 1e-6f );
if ( travelled < spacing )
return 0;
// Fill the gap. The pointer's real path between two events is unknowable, so this walks the
// straight line between them — which is what the gesture looked like at this sampling rate.
var steps = Math.Min( (int)(travelled / spacing), MaxSamplesPerMove );
var from = _lastSample;
var direction3 = (target - from) / travelled;
for ( var i = 1; i <= steps; i++ )
{
var t = (float)i / steps;
var point = from + (target - from) * t;
Dab( point, hit.Value.Normal, direction3 * (travelled / steps) );
}
_lastSample = from + (target - from) * ((float)steps / steps);
return steps;
}
/// <summary>
/// Finish the stroke and commit it as one edit. Returns what was committed, or null if the stroke
/// moved nothing — a click that landed on the model but at zero strength is not worth an undo
/// entry.
/// </summary>
public SculptEdit EndStroke()
{
if ( !IsStroking )
throw new InvalidOperationException( "No stroke is running." );
var level = Level;
var working = _working;
var undo = _undo;
var maskBefore = _maskBefore;
_working = null;
_frames = null;
_bvh = null;
_undo = null;
_maskBefore = null;
var edit = maskBefore is not null
? CommitMask( level, maskBefore )
: CommitGeometry( level, working, undo );
if ( edit is null )
return null;
_done.Push( edit );
_undone.Clear();
return edit;
}
SculptEdit CommitGeometry( int level, PolyMesh working, BrushUndo undo )
{
if ( undo.Count == 0 )
return null;
var before = new Dictionary<int, Vec3>( undo.Previous );
var after = new Dictionary<int, Vec3>( before.Count );
foreach ( var vertex in before.Keys )
after[vertex] = working.Positions[vertex];
_sculpt.Record( level, working );
return new SculptEdit( level, before, after );
}
SculptEdit CommitMask( int level, float[] snapshot )
{
var mask = MaskFor( level );
if ( mask.Count != snapshot.Length )
return null;
var before = new Dictionary<int, float>();
var after = new Dictionary<int, float>();
for ( var i = 0; i < snapshot.Length; i++ )
{
if ( snapshot[i] == mask[i] )
continue;
before[i] = snapshot[i];
after[i] = mask[i];
}
return before.Count == 0 ? null : new SculptEdit( level, before, after );
}
/// <summary>Abandon the stroke, leaving the model as it was before it started.</summary>
public void CancelStroke()
{
// A mask stroke paints straight into the mask rather than into a working copy, so this is
// the one thing cancelling has to actively put back.
if ( _maskBefore is not null && MaskFor( Level ) is { } mask && mask.Count == _maskBefore.Length )
{
for ( var i = 0; i < _maskBefore.Length; i++ )
mask[i] = _maskBefore[i];
}
_working = null;
_frames = null;
_bvh = null;
_undo = null;
_maskBefore = null;
}
/// <summary>
/// Drop the finest level, and put it on the undo stack.
///
/// EXPOSED ONLY BECAUSE IT CAN BE UNDONE. Removing a level throws away every delta on it, and a
/// destructive button with no way back is one nobody should be given - which is why this sat in
/// the kernel unexposed until the session could hold the layer it dropped.
/// </summary>
public bool RemoveTopLevel()
{
if ( IsStroking || _sculpt.TopLevel == 0 )
return false;
var level = _sculpt.TopLevel;
var dropped = _sculpt.RemoveTopLevel();
_done.Push( new SculptEdit( level, dropped ) );
_undone.Clear();
return true;
}
public bool Undo() => Step( _done, _undone, forward: false );
public bool Redo() => Step( _undone, _done, forward: true );
bool Step( Stack<SculptEdit> from, Stack<SculptEdit> to, bool forward )
{
if ( IsStroking || from.Count == 0 )
return false;
var edit = from.Pop();
if ( edit.IsLevel )
{
// A level edit is its own inverse read backwards: undoing a removal puts the level back,
// and redoing it takes it away again.
if ( forward )
_sculpt.RemoveTopLevel();
else
_sculpt.RestoreTopLevel( edit.Level_ );
_masks.Remove( edit.Level );
to.Push( edit );
return true;
}
if ( edit.IsMask )
{
edit.Write( MaskFor( edit.Level ), forward );
}
else
{
var mesh = _sculpt.Evaluate( edit.Level );
edit.Write( mesh, forward );
_sculpt.Record( edit.Level, mesh );
}
to.Push( edit );
return true;
}
void Dab( Vec3 point, Vec3 normal, Vec3 direction )
{
if ( Masking )
{
var mask = MaskFor( Level );
mask.Paint( _working, _bvh, point, Radius, Erasing ? -Strength : Strength, Falloff );
if ( MirrorX )
mask.Paint( _working, _bvh, new Vec3( -point.x, point.y, point.z ), Radius,
Erasing ? -Strength : Strength, Falloff );
return;
}
var stroke = new BrushStroke { Kind = Brush, Falloff = Falloff, MirrorX = MirrorX };
stroke.Samples.Add( new BrushSample( point, normal, Radius, Strength, direction ) );
// The mask is passed EVERY stroke, not applied afterwards. Brush.Apply folds it into the
// per-vertex weight, so a half-masked vertex moves half as far; masking after the fact would
// mean snapping protected vertices back, which leaves a hard edge where the mask fades.
var undo = Effigy.Brush.Apply( _working, stroke, _frames, ActiveMask?.Values, _bvh );
_undo.Absorb( undo );
}
}