Editor/Effigy/PaintSession.cs
using System;
using System.Collections.Generic;
namespace Effigy;
/// <summary>
/// The paint tool, with no cursor in it.
///
/// WHY THIS IS IN THE KERNEL, the same reason SculptSession is: everything a paint tool does between
/// the pointer and the mesh is arithmetic — project a ray, decide whether the cursor has moved far
/// enough to earn a sample, drop a dab, record it as a stroke. All of it is testable with no engine
/// anywhere, and all of it is where the bugs are. What is left for the editor is genuinely thin: hand
/// this rays, upload <see cref="Canvas"/>, and draw a ring at <see cref="Hover"/>.
///
/// THE SESSION PAINTS TEXELS, NOT VERTICES. A dab stamps coverage into a per-stroke buffer rather
/// than blending straight into the canvas; the canvas the editor reads is recomposed from the
/// committed strokes plus the stroke in flight. That is what makes holding the brush still a no-op
/// instead of a darkening — the in-flight stroke's coverage is the maximum its dabs reached, so the
/// same dab re-stamped over and over recomposes to the same texels. A stroke ends as a
/// <see cref="PaintStroke"/> the caller appends to the feature; undo is the feature tree's undo, so
/// this session carries no undo stack.
///
/// ERASING IS THE SAME STROKE MACHINERY, not a second mode with its own path. <see cref="Erasing"/>
/// decides which kind the next stroke is, the dabs and the coverage buffer are identical either way,
/// and only the final application differs. So an erase costs one undo step like any stroke, saves
/// into the document like any stroke, and replays in its place in the log — which is what stops the
/// paint underneath it from coming back on the next rebuild.
/// </summary>
public sealed class PaintSession
{
readonly PolyMesh _mesh;
readonly MeshBVH _bvh;
readonly List<int> _found = new();
readonly int _resolution;
// Two canvases, because the live canvas must be recomposable from a base. _committed holds every
// finished stroke and never shows the one in flight; Canvas is _committed with the in-flight
// stroke composited over it, rebuilt over the dab's bounds each time a dab lands. The editor
// uploads Canvas, not _committed.
readonly PaintCanvas _committed;
// The in-flight stroke's per-texel coverage — the maximum any dab of this stroke reached. Cleared
// on BeginStroke, stamped by each dab, composited into _committed once on EndStroke.
readonly float[] _coverage;
// Live only between BeginStroke and EndStroke.
PaintStroke _current;
Vec3 _lastSample;
byte _strokeR, _strokeG, _strokeB;
// Brush settings. Floats in 0..1, the same units PaintStroke stores, so a stroke committed here
// and one read back from the document describe the same colour.
public float R = 1f;
public float G = 1f;
public float B = 1f;
public float A = 1f;
/// <summary>Brush radius in world units, not pixels — the kernel has no screen.</summary>
public float Radius = 0.1f;
/// <summary>How hard the stroke presses, 0..1. One is full opacity, half is a lighter dab.</summary>
public float Strength = 1f;
public BrushFalloff Falloff = BrushFalloff.Smooth;
/// <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; this is what keeps a slow
/// drag from biting far harder than a quick one for the same gesture.</summary>
public float Spacing = 0.5f;
/// <summary>Most samples one pointer move may be split into, so a drag across the whole model in
/// one frame under-samples rather than stalls.</summary>
public int MaxSamplesPerMove = 64;
/// <summary>
/// Whether the next stroke ERASES rather than paints.
///
/// A SESSION FLAG RATHER THAN SOMETHING READ PER DAB, the same shape and the same reason as
/// <see cref="SculptSession.Inverted"/>: the editor sets it from a held modifier at the moment a
/// stroke begins and leaves it alone for the rest of that stroke, so letting go of the key
/// halfway through a gesture cannot turn half of one mark into the other kind. The stroke carries
/// its own copy from here, so changing this afterwards does not rewrite what was already painted.
/// </summary>
public bool Erasing;
/// <summary>
/// Which origin plane every sample mirrors across, or none. Recorded INTO the stroke's path
/// (the mirrored point joins the real one), so a mirrored stroke survives replay and export the
/// way a live-only mirror would not. Shared enum — see <see cref="MirrorAxis"/>.
/// </summary>
public MirrorAxis Mirror;
/// <summary>
/// The strokes committed so far, in order. The caller mirrors each <see cref="EndStroke"/> result
/// into its feature; this list is the session's own copy, used to rebuild the canvas when a stroke
/// is cancelled mid-flight.
/// </summary>
public readonly List<PaintStroke> Strokes = new();
/// <summary>The live canvas — committed strokes with the stroke in flight composited over them.
/// The editor uploads this to a texture; its dirty rect is exactly what a dab touched.</summary>
public PaintCanvas Canvas { get; }
public PaintSession( PolyMesh mesh, int resolution, IReadOnlyList<PaintStroke> existing = null )
{
_mesh = mesh ?? throw new ArgumentNullException( nameof( mesh ) );
if ( resolution < 1 )
throw new ArgumentOutOfRangeException( nameof( resolution ) );
_resolution = resolution;
_coverage = new float[resolution * resolution];
_committed = new PaintCanvas( resolution, resolution );
Canvas = new PaintCanvas( resolution, resolution );
// Built once and never refitted: unlike a sculpt stroke, a paint stroke moves no geometry, so
// the tree stays valid for the life of the session. Paint is strictly cheaper than sculpt here.
_bvh = MeshBVH.Build( mesh );
if ( existing is { Count: > 0 } )
{
foreach ( var stroke in existing )
{
Strokes.Add( stroke );
CommitStroke( stroke );
}
}
RefreshCanvas();
}
public bool IsStroking => _current is not null;
/// <summary>
/// A starting radius that suits this model: a twelfth of the diagonal, the same argument
/// SculptSession makes — Effigy's units are dimensionless, so a fixed default is the whole model
/// on one part and invisible on the next. Unlike the vertex-colour brush there is no spacing floor
/// here: a texel dab needs no vertices to reach, so a bare box paints fine at any radius.
/// </summary>
public float SuggestedRadius
{
get
{
var diagonal = _mesh.BoundsDiagonal;
return diagonal > 1e-6f ? diagonal / 12f : 0.25f;
}
}
/// <summary>The mesh the strokes land on, exposed so the editor can build a preview from it —
/// the same surface the brush works on, which is the one the user is looking at.</summary>
public PolyMesh Mesh => _mesh;
/// <summary>The resolution the canvas is rasterised at, for an editor that must size a texture.</summary>
public int Resolution => _resolution;
/// <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 canvas.</summary>
public MeshHit? Hover( Vec3 origin, Vec3 direction )
{
var dir = direction.Normal;
if ( dir.LengthSquared < 0.5f )
return null;
return _bvh.Raycast( _mesh, origin, dir );
}
/// <summary>
/// Start a stroke. Returns false if the ray missed — clicking past the model deselects and must
/// not begin a stroke that lands somewhere surprising. The first dab 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 = _bvh.Raycast( _mesh, origin, dir );
if ( hit is null )
return false;
_current = new PaintStroke
{
R = R,
G = G,
B = B,
A = A,
Radius = Radius,
Strength = Strength,
Falloff = Falloff,
Spacing = Spacing,
// Copied at the press and never re-read, so a modifier released mid-gesture does not
// turn the back half of one mark into the other kind. See Erasing.
Erase = Erasing,
};
_strokeR = PaintReplay.ToByte( R );
_strokeG = PaintReplay.ToByte( G );
_strokeB = PaintReplay.ToByte( B );
_lastSample = hit.Value.Point;
Array.Clear( _coverage, 0, _coverage.Length );
AddSample( hit.Value.Point, hit.Value.Normal );
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, and several when it travelled far enough that one would
/// leave a gap. A ray that misses the model does NOT end the stroke — dragging off the silhouette
/// and back on is ordinary.
/// </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( _mesh, 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 — what the gesture looked like at this sampling rate. The normal
// is the current hit's for the whole segment, the same choice SculptSession makes.
var steps = Math.Min( (int)(travelled / spacing), MaxSamplesPerMove );
for ( var i = 1; i <= steps; i++ )
{
var t = (float)i / steps;
var point = _lastSample + (target - _lastSample) * t;
AddSample( point, hit.Value.Normal );
}
_lastSample = target;
return steps;
}
/// <summary>Finish the stroke and commit it to the session's list, returning it so the caller can
/// add it to the feature. Null if the stroke had no points.</summary>
public PaintStroke EndStroke()
{
if ( !IsStroking )
throw new InvalidOperationException( "No stroke is running." );
var stroke = _current;
_current = null;
if ( stroke.Path.Count == 0 )
{
Array.Clear( _coverage, 0, _coverage.Length );
return null;
}
Strokes.Add( stroke );
// Apply the stroke ONCE, as whichever kind it is. The live canvas already shows it (it was
// recomposed from this very coverage over the committed base), so applying the same coverage
// to _committed leaves the two canvases in agreement and nothing to re-upload.
PaintReplay.Apply( _committed, stroke, _coverage );
Array.Clear( _coverage, 0, _coverage.Length );
return stroke;
}
/// <summary>
/// Abandon the stroke in flight. The committed canvas never absorbed its dabs, so abandoning is a
/// rebuild of the visible canvas from the committed one — cheaper than tracking per-texel undo,
/// and the same answer the document itself would give.
/// </summary>
public void CancelStroke()
{
_current = null;
Array.Clear( _coverage, 0, _coverage.Length );
RefreshCanvas();
}
/// <summary>
/// Reset the session to a different stroke list — undo/redo's route in.
///
/// The document restore only rewrites the feature's stroke list; the session's canvas is its
/// own copy and does not change with it. Leaving it would make the next stroke resurrect paint
/// the undo just removed. So this drops any stroke in flight, adopts the new list, and replays it
/// from scratch — the same path <see cref="CancelStroke"/> walks.
/// </summary>
public void Reload( IReadOnlyList<PaintStroke> strokes )
{
_current = null;
Strokes.Clear();
if ( strokes is not null )
Strokes.AddRange( strokes );
_committed.Clear();
foreach ( var stroke in Strokes )
CommitStroke( stroke );
Array.Clear( _coverage, 0, _coverage.Length );
RefreshCanvas();
}
/// <summary>Replay one committed stroke into the committed canvas. Uses the live coverage buffer
/// as its scratch — BeginStroke clears it, and this only ever runs between strokes.</summary>
void CommitStroke( PaintStroke stroke )
{
Array.Clear( _coverage, 0, _coverage.Length );
PaintReplay.StampStroke( stroke, _mesh, _bvh, _coverage, _resolution, _found );
PaintReplay.Apply( _committed, stroke, _coverage );
}
/// <summary>Copy the committed canvas into the visible one and mark the whole thing dirty, so the
/// next upload sends everything. Used when the canvas is rebuilt wholesale — a cancel or a reload.</summary>
void RefreshCanvas()
{
Array.Copy( _committed.Rgba, Canvas.Rgba, _committed.Rgba.Length );
Canvas.Invalidate();
}
/// <summary>One sample onto the stroke and the coverage, mirrored across the chosen plane when
/// <see cref="Mirror"/> is set. The mirrored point is written into the path alongside the real one,
/// so the mirror is part of the stroke's own record rather than a live-only effect that a rebuild
/// would drop.</summary>
void AddSample( Vec3 point, Vec3 normal )
{
_current.Path.Add( new PaintStrokePoint( point, normal ) );
Recompose( Stamp( point, normal ) );
if ( Mirror == MirrorAxis.None )
return;
var mirroredPoint = Brush.Mirror( point, Mirror );
var mirroredNormal = Brush.Mirror( normal, Mirror );
_current.Path.Add( new PaintStrokePoint( mirroredPoint, mirroredNormal ) );
Recompose( Stamp( mirroredPoint, mirroredNormal ) );
}
/// <summary>Stamp one dab's coverage and return the bounds it touched, so the caller recomposes
/// the visible canvas over exactly that region.</summary>
TexelBounds Stamp( Vec3 point, Vec3 normal ) =>
PaintReplay.StampDab( _mesh, _bvh, _coverage, _resolution,
point, normal, Radius, Strength * A, Falloff, _found );
/// <summary>
/// Rebuild the visible canvas over a texel region: each texel becomes the committed colour with
/// the in-flight stroke applied over it at its running coverage. Reading the base from _committed
/// rather than from the canvas's current value is what stops overlapping dabs within one stroke
/// from stacking — and it is what lets an erase preview at all, since the texels it is taking
/// away have to come from somewhere once they are gone from the visible canvas.
/// </summary>
void Recompose( TexelBounds bounds )
{
if ( !bounds.Any )
return;
// Read once rather than per texel. Both branches end at the same arithmetic the replay uses,
// so the mark on screen and the mark after a rebuild are the same mark.
var erasing = _current is { Erase: true };
for ( var y = bounds.MinY; y <= bounds.MaxY; y++ )
{
for ( var x = bounds.MinX; x <= bounds.MaxX; x++ )
{
var i = (y * _resolution + x) * 4;
var coverage = _coverage[y * _resolution + x];
if ( erasing )
{
Canvas.Write( x, y,
_committed.Rgba[i], _committed.Rgba[i + 1], _committed.Rgba[i + 2],
PaintCanvas.DestinationOut( _committed.Rgba[i + 3], coverage ) );
continue;
}
var (r, g, b, a) = PaintCanvas.SourceOver(
_committed.Rgba[i], _committed.Rgba[i + 1], _committed.Rgba[i + 2], _committed.Rgba[i + 3],
_strokeR, _strokeG, _strokeB, coverage );
Canvas.Write( x, y, r, g, b, a );
}
}
}
}