Editor/Effigy/Features/PaintFeature.cs
using System.Collections.Generic;
namespace Effigy;
/// <summary>
/// A paint layer in the feature tree.
///
/// WHERE THE PAINT LIVES: strokes in object space, replayed onto whatever the mesh currently is.
/// The texture atlas is a derived artifact — the same bet the rest of the kernel already made by
/// keeping the mesh a function of the feature history. Nothing else in the document holds paint, undo
/// is the feature tree's undo, and a stroke is one entry in the list.
///
/// EXECUTE REPLAYS THE STROKES ONTO A TEXTURE ATLAS. The dab — faces in radius, reject the far side
/// by its normal, falloff-weighted coverage — lives in PaintReplay, shared with the live session so a
/// stroke painted by hand and the same stroke rebuilt later produce identical texels. A texture atlas
/// rather than vertex colours because paint resolution must not equal mesh density: a bare box paints
/// at the same texel resolution a sculpted part does, which is the whole reason the vertex-colour
/// path was replaced.
///
/// THE ATLAS IS KEYED TO THE UV LAYOUT, which is the trap the vertex-colour cache never had. A
/// re-unwrap keeps the topology and moves every island, so a canvas cached on topology alone would be
/// handed back against a rearranged atlas — paint scattered onto unrelated faces, silently. The cache
/// is keyed on both the topology id and <see cref="AtlasId"/>.
///
/// ITS STALENESS GUARD IS COPIED FROM SculptFeature FOR THE SAME REASON. A paint session appends
/// strokes nowhere near the studio, so nothing calls MarkDirty and the rebuild would happily reuse
/// the cached body from before the stroke — the paint would stop following the brush, which reads as
/// "the paint tool does nothing" rather than as a caching bug. A revision counter bumped when the
/// stroke list changes, compared here to what the last rebuild built from, is the guard.
/// </summary>
public sealed class PaintFeature : Feature
{
/// <summary>
/// How many texels across the replayed canvas is.
///
/// A PARAMETER RATHER THAN A CONSTANT because it belongs to the document, the same way a sculpt
/// cage's level does: a part with one small painted detail and one big painted wall wants two
/// answers, and 1024 texels across a matchbox is enormous while across a 4000-unit part it is
/// four texels per inch. Strokes are resolution-independent — points and radii, replayed — so a
/// change re-replays and loses nothing.
/// </summary>
public readonly IntParam Resolution = new( "Resolution", 1024, 64, 4096 );
public override string TypeName => "Paint";
public override GeometryKind Accepts => GeometryKind.Body;
public readonly BodySelectionParam Bodies = new( "Body" );
/// <summary>
/// Whether the paint tints what is underneath it or stands in for it.
///
/// CARRIED FOR THE DOCUMENT FORMAT, NOT READ BY THE ATLAS. The vertex-colour path used it to pick
/// which material an unbound slot compiled to (tint keeps default.vmat, replace binds white.vmat);
/// a texture atlas is the surface colour itself, and telling tint from replace through a texture
/// needs a shader that combines the base material with the atlas, which nothing shipped provides.
/// The atlas covers. The choice stays on the feature so documents saved before the switch still
/// load and round-trip unchanged.
/// </summary>
public readonly ChoiceParam Blend = new( "Blend", new[] { "Tint", "Replace" } );
/// <summary>
/// Blend is carried for old documents and is not a parameter the dialog or the bar offer.
/// A texture atlas is the surface colour; tint-versus-replace needed a shader nothing ships.
/// Showing a combo that changes nothing is a bug report waiting to be filed.
/// </summary>
public override IReadOnlyList<IParam> Parameters => new IParam[] { Bodies, Resolution };
/// <summary>
/// The strokes, in the order they were painted.
///
/// NULL UNTIL THE FIRST STROKE LANDS, the same "not yet populated" idiom SculptFeature uses for
/// its levels. A never-painted feature serialises to nothing at all: StudioDocument writes a null
/// field as absent, and the reflection sweep in DocumentTests round-trips a null list as null.
/// </summary>
public List<PaintStroke> Strokes;
/// <summary>Bumped each time the stroke list changes, so <see cref="IsStale"/> can notice
/// without anyone remembering to call MarkDirty.</summary>
public int Revision { get; private set; }
// The revision this feature last built from. See the class comment — a stroke lands nowhere near
// the studio, so nothing calls MarkDirty and this is what catches it.
int _builtRevision = -1;
// The replay cache: the canvas last produced, and the topology + atlas + revision + resolution it
// was produced from. Keyed on topology (vertex count and face indices, deliberately not
// positions), the atlas (every corner UV, so a re-unwrap invalidates it), the revision (so a new
// stroke does) and the resolution (so changing it re-replays rather than serving a stale canvas
// at the old size). A parametric edit that moves geometry without changing structure, UVs or
// resolution reuses the canvas rather than re-replaying.
PaintCanvas _cachedCanvas;
long _topologyId;
long _atlasId;
int _canvasRevision = -1;
int _canvasResolution = -1;
public override bool IsStale => Revision != _builtRevision;
/// <summary>The canvas last replayed by this feature, or null before a build. The editor reads it
/// to show paint after a rebuild — it is the same canvas <see cref="Execute"/> put on the body.</summary>
public PaintCanvas Canvas => _cachedCanvas;
/// <summary>Append a stroke and mark the feature stale, so the next rebuild replays it. The list
/// is lazily created here so a fresh feature never has to check for null before painting.</summary>
public void AddStroke( PaintStroke stroke )
{
(Strokes ??= new()).Add( stroke );
Revision++;
}
/// <summary>
/// Replace the whole stroke list — undo/redo's route in.
///
/// The revision is bumped, not merely the list swapped, because the replay cache is keyed on it: a
/// plain assignment would leave <see cref="Revision"/> unchanged, the cache would see no reason to
/// re-render, and the model would keep serving paint the restored strokes do not describe. The
/// strokes themselves are copied by reference — they are immutable once painted, so sharing them
/// across undo snapshots is the correct and cheapest read.
/// </summary>
public void ReplaceStrokes( IReadOnlyList<PaintStroke> strokes )
{
Strokes = strokes is null ? null : new List<PaintStroke>( strokes );
Revision++;
}
protected override void Execute( FeatureContext ctx )
{
var targets = RequireBodies( ctx, Bodies );
// ONE BODY, LOUDLY. An empty selection matches every body, so a two-body studio used to
// walk in here, skip the replay, and leave both canvases untouched with no error — paint
// that looks like it ran and did nothing. Fail at the door so the editor's "pick one"
// prompt and the kernel agree.
if ( targets.Count != 1 )
{
Fail(
"Paint paints one body at a time",
$"This feature matched {targets.Count} bodies and a paint layer is one stroke list, one atlas.",
"Pick one body in the Parts list",
"Add a Paint per body rather than one Paint over all of them" );
}
if ( Strokes is { Count: > 0 } )
{
var mesh = targets[0].Mesh;
var topology = MultiresSculpt.TopologyId( mesh );
var atlas = AtlasId.Of( mesh );
var resolution = Resolution.Clamped;
if ( _cachedCanvas is null || _topologyId != topology || _atlasId != atlas
|| _canvasRevision != Revision || _canvasResolution != resolution )
{
_cachedCanvas = PaintReplay.Replay( mesh, Strokes, resolution );
_topologyId = topology;
_atlasId = atlas;
_canvasRevision = Revision;
_canvasResolution = resolution;
}
mesh.Paint = _cachedCanvas;
}
// Last, so a failure above leaves the feature stale and the next rebuild tries again.
_builtRevision = Revision;
}
}