using System.ComponentModel.DataAnnotations; using System.Runtime.CompilerServices; using ChunkyImageLib.DataHolders; using ChunkyImageLib.Operations; using OneOf; using OneOf.Types; using PixiEditor.Common; using PixiEditor.DrawingApi.Core; using PixiEditor.DrawingApi.Core.ColorsImpl; using PixiEditor.DrawingApi.Core.Numerics; using PixiEditor.DrawingApi.Core.Surfaces; using PixiEditor.DrawingApi.Core.Surfaces.ImageData; using PixiEditor.DrawingApi.Core.Surfaces.PaintImpl; using PixiEditor.DrawingApi.Core.Surfaces.Vector; using PixiEditor.Numerics; [assembly: InternalsVisibleTo("ChunkyImageLibTest")] namespace ChunkyImageLib; /// /// This class is thread-safe only for reading! Only the functions from IReadOnlyChunkyImage can be called from any thread. /// ChunkyImage can be in two general states: /// 1. a state with all chunks committed and no queued operations /// - latestChunks and latestChunksData are empty /// - queuedOperations are empty /// - committedChunks[ChunkResolution.Full] contains the current versions of all stored chunks /// - committedChunks[*any other resolution*] may contain the current low res versions of some of the chunks (or all of them, or none) /// - LatestSize == CommittedSize == current image size (px) /// 2. and a state with some queued operations /// - queuedOperations contains all requested operations (drawing, raster clips, clear, etc.) /// - committedChunks[ChunkResolution.Full] contains the last versions before any operations of all stored chunks /// - committedChunks[*any other resolution*] may contain the last low res versions before any operations of some of the chunks (or all of them, or none) /// - latestChunks stores chunks with some (or none, or all) queued operations applied /// - latestChunksData stores the data for some or all of the latest chunks (not necessarily synced with latestChunks). /// The data includes how many operations from the queue have already been applied to the chunk, as well as chunk deleted state (the clear operation deletes chunks) /// - LatestSize contains the new size if any resize operations were requested, otherwise the committed size /// You can check the current state via queuedOperations.Count == 0 /// /// Depending on the chosen blend mode the latest chunks contain different things: /// - BlendMode.Src: default mode, the latest chunks are the same as committed ones but with some or all queued operations applied. /// This means that operations can work with the existing pixels. /// - Any other blend mode: the latest chunks contain only the things drawn by the queued operations. /// They need to be drawn over the committed chunks to obtain the final image. In this case, operations won't have access to the existing pixels. /// public class ChunkyImage : IReadOnlyChunkyImage, IDisposable, ICloneable, ICacheable { private struct LatestChunkData { public LatestChunkData() { QueueProgress = 0; IsDeleted = false; } public int QueueProgress { get; set; } public bool IsDeleted { get; set; } } private bool disposed = false; private readonly object lockObject = new(); private int commitCounter = 0; private RectI cachedPreciseBounds = RectI.Empty; private int lastBoundsCacheHash = -1; public const int FullChunkSize = ChunkPool.FullChunkSize; private static Paint ClippingPaint { get; } = new Paint() { BlendMode = BlendMode.DstIn }; private static Paint InverseClippingPaint { get; } = new Paint() { BlendMode = BlendMode.DstOut }; private static Paint ReplacingPaint { get; } = new Paint() { BlendMode = BlendMode.Src }; private static Paint SmoothReplacingPaint { get; } = new Paint() { BlendMode = BlendMode.Src, FilterQuality = FilterQuality.Medium }; private static Paint AddingPaint { get; } = new Paint() { BlendMode = BlendMode.Plus }; private readonly Paint blendModePaint = new Paint() { BlendMode = BlendMode.Src }; public int CommitCounter => commitCounter; public VecI CommittedSize { get; private set; } public VecI LatestSize { get; private set; } public int QueueLength { get { lock (lockObject) return queuedOperations.Count; } } private readonly List<(IOperation operation, AffectedArea affectedArea)> queuedOperations = new(); private readonly List activeClips = new(); private BlendMode blendMode = BlendMode.Src; private bool lockTransparency = false; private VectorPath? clippingPath; private double? horizontalSymmetryAxis = null; private double? verticalSymmetryAxis = null; private int operationCounter = 0; private readonly Dictionary> committedChunks; private readonly Dictionary> latestChunks; private readonly Dictionary> latestChunksData; public ChunkyImage(VecI size) { CommittedSize = size; LatestSize = size; committedChunks = new() { [ChunkResolution.Full] = new(), [ChunkResolution.Half] = new(), [ChunkResolution.Quarter] = new(), [ChunkResolution.Eighth] = new(), }; latestChunks = new() { [ChunkResolution.Full] = new(), [ChunkResolution.Half] = new(), [ChunkResolution.Quarter] = new(), [ChunkResolution.Eighth] = new(), }; latestChunksData = new() { [ChunkResolution.Full] = new(), [ChunkResolution.Half] = new(), [ChunkResolution.Quarter] = new(), [ChunkResolution.Eighth] = new(), }; } public ChunkyImage(Surface image) : this(image.Size) { EnqueueDrawImage(VecI.Zero, image); CommitChanges(); } /// This image is disposed public RectI? FindChunkAlignedMostUpToDateBounds() { lock (lockObject) { ThrowIfDisposed(); RectI? rect = null; foreach (var (pos, _) in committedChunks[ChunkResolution.Full]) { RectI chunkBounds = new RectI(pos * FullChunkSize, new VecI(FullChunkSize)); rect ??= chunkBounds; rect = rect.Value.Union(chunkBounds); } foreach (var operation in queuedOperations) { foreach (var pos in operation.affectedArea.Chunks) { RectI chunkBounds = new RectI(pos * FullChunkSize, new VecI(FullChunkSize)); rect ??= chunkBounds; rect = rect.Value.Union(chunkBounds); } } return rect; } } /// This image is disposed public RectI? FindChunkAlignedCommittedBounds() { lock (lockObject) { ThrowIfDisposed(); RectI? rect = null; foreach (var (pos, _) in committedChunks[ChunkResolution.Full]) { RectI chunkBounds = new RectI(pos * FullChunkSize, new VecI(FullChunkSize)); rect ??= chunkBounds; rect = rect.Value.Union(chunkBounds); } return rect; } } /// /// Finds the precise bounds in . If there are no chunks rendered for that resolution, full res chunks are used instead. /// /// This image is disposed public RectI? FindTightCommittedBounds(ChunkResolution suggestedResolution = ChunkResolution.Full) { lock (lockObject) { ThrowIfDisposed(); if (lastBoundsCacheHash == GetCacheHash()) { return cachedPreciseBounds; } var chunkSize = suggestedResolution.PixelSize(); var multiplier = suggestedResolution.Multiplier(); RectI scaledCommittedSize = (RectI)(new RectD(VecI.Zero, CommittedSize * multiplier)).RoundOutwards(); RectI? preciseBounds = null; foreach (var (chunkPos, fullResChunk) in committedChunks[ChunkResolution.Full]) { if (committedChunks[suggestedResolution].TryGetValue(chunkPos, out Chunk? requestedResChunk)) { RectI visibleArea = new RectI(chunkPos * chunkSize, new VecI(chunkSize)) .Intersect(scaledCommittedSize).Translate(-chunkPos * chunkSize); RectI? chunkPreciseBounds = requestedResChunk.FindPreciseBounds(visibleArea); if (chunkPreciseBounds is null) continue; RectI globalChunkBounds = chunkPreciseBounds.Value.Offset(chunkPos * chunkSize); preciseBounds ??= globalChunkBounds; preciseBounds = preciseBounds.Value.Union(globalChunkBounds); } else { RectI visibleArea = new RectI(chunkPos * FullChunkSize, new VecI(FullChunkSize)) .Intersect(new RectI(VecI.Zero, CommittedSize)).Translate(-chunkPos * FullChunkSize); RectI? chunkPreciseBounds = fullResChunk.FindPreciseBounds(visibleArea); if (chunkPreciseBounds is null) continue; RectI globalChunkBounds = (RectI)chunkPreciseBounds.Value.Scale(multiplier) .Offset(chunkPos * chunkSize).RoundOutwards(); preciseBounds ??= globalChunkBounds; preciseBounds = preciseBounds.Value.Union(globalChunkBounds); } } preciseBounds = (RectI?)preciseBounds?.Scale(suggestedResolution.InvertedMultiplier()).RoundOutwards(); preciseBounds = preciseBounds?.Intersect(new RectI(preciseBounds.Value.Pos, CommittedSize)); cachedPreciseBounds = preciseBounds.GetValueOrDefault(); lastBoundsCacheHash = GetCacheHash(); return preciseBounds; } } /// This image is disposed public ChunkyImage CloneFromCommitted() { lock (lockObject) { ThrowIfDisposed(); ChunkyImage output = new(LatestSize); var chunks = FindCommittedChunks(); foreach (var chunk in chunks) { var image = GetCommittedChunk(chunk, ChunkResolution.Full); if (image is null) continue; output.EnqueueDrawImage(chunk * FullChunkSize, image.Surface); } output.CommitChanges(); return output; } } /// This image is disposed public Color GetCommittedPixel(VecI posOnImage) { lock (lockObject) { ThrowIfDisposed(); var chunkPos = OperationHelper.GetChunkPos(posOnImage, FullChunkSize); var posInChunk = posOnImage - chunkPos * FullChunkSize; return MaybeGetCommittedChunk(chunkPos, ChunkResolution.Full) switch { null => Colors.Transparent, var chunk => chunk.Surface.GetSRGBPixel(posInChunk) }; } } /// This image is disposed public Color GetMostUpToDatePixel(VecI posOnImage) { lock (lockObject) { ThrowIfDisposed(); var chunkPos = OperationHelper.GetChunkPos(posOnImage, FullChunkSize); var posInChunk = posOnImage - chunkPos * FullChunkSize; // nothing queued, return committed if (queuedOperations.Count == 0) { Chunk? committedChunk = MaybeGetCommittedChunk(chunkPos, ChunkResolution.Full); return committedChunk switch { null => Colors.Transparent, _ => committedChunk.Surface.GetSRGBPixel(posInChunk) }; } // something is queued, blend mode is Src so no merging needed if (blendMode == BlendMode.Src) { Chunk? latestChunk = GetLatestChunk(chunkPos, ChunkResolution.Full); return latestChunk switch { null => Colors.Transparent, _ => latestChunk.Surface.GetSRGBPixel(posInChunk) }; } // something is queued, blend mode is not Src so we have to do merging { Chunk? committedChunk = MaybeGetCommittedChunk(chunkPos, ChunkResolution.Full); Chunk? latestChunk = GetLatestChunk(chunkPos, ChunkResolution.Full); Color committedColor = committedChunk is null ? Colors.Transparent : committedChunk.Surface.GetSRGBPixel(posInChunk); Color latestColor = latestChunk is null ? Colors.Transparent : latestChunk.Surface.GetSRGBPixel(posInChunk); // using a whole chunk just to draw 1 pixel is kinda dumb, // but this should be faster than any approach that requires allocations using Chunk tempChunk = Chunk.Create(ChunkResolution.Eighth); using Paint committedPaint = new Paint() { Color = committedColor, BlendMode = BlendMode.Src }; using Paint latestPaint = new Paint() { Color = latestColor, BlendMode = this.blendMode }; tempChunk.Surface.DrawingSurface.Canvas.DrawPixel(VecI.Zero, committedPaint); tempChunk.Surface.DrawingSurface.Canvas.DrawPixel(VecI.Zero, latestPaint); return tempChunk.Surface.GetSRGBPixel(VecI.Zero); } } } /// /// True if the chunk existed and was drawn, otherwise false /// /// This image is disposed public bool DrawMostUpToDateChunkOn(VecI chunkPos, ChunkResolution resolution, DrawingSurface surface, VecI pos, Paint? paint = null) { lock (lockObject) { ThrowIfDisposed(); OneOf latestChunk; { var chunk = GetLatestChunk(chunkPos, resolution); if (latestChunksData[resolution].TryGetValue(chunkPos, out var chunkData) && chunkData.IsDeleted) { latestChunk = new EmptyChunk(); } else { latestChunk = chunk is null ? new None() : chunk; } } var committedChunk = GetCommittedChunk(chunkPos, resolution); // draw committed directly if (latestChunk.IsT0 || latestChunk.IsT1 && committedChunk is not null && blendMode != BlendMode.Src) { if (committedChunk is null) return false; committedChunk.DrawChunkOn(surface, pos, paint); return true; } // no need to combine with committed, draw directly if (blendMode == BlendMode.Src || committedChunk is null) { if (latestChunk.IsT2) { latestChunk.AsT2.DrawChunkOn(surface, pos, paint); return true; } return false; } // combine with committed and then draw using var tempChunk = Chunk.Create(resolution); tempChunk.Surface.DrawingSurface.Canvas.DrawSurface(committedChunk.Surface.DrawingSurface, 0, 0, ReplacingPaint); blendModePaint.BlendMode = blendMode; tempChunk.Surface.DrawingSurface.Canvas.DrawSurface(latestChunk.AsT2.Surface.DrawingSurface, 0, 0, blendModePaint); if (lockTransparency) OperationHelper.ClampAlpha(tempChunk.Surface.DrawingSurface, committedChunk.Surface.DrawingSurface); tempChunk.DrawChunkOn(surface, pos, paint); return true; } } /// This image is disposed public bool LatestOrCommittedChunkExists(VecI chunkPos) { lock (lockObject) { ThrowIfDisposed(); if (MaybeGetLatestChunk(chunkPos, ChunkResolution.Full) is not null || MaybeGetCommittedChunk(chunkPos, ChunkResolution.Full) is not null) return true; foreach (var operation in queuedOperations) { if (operation.affectedArea.Chunks.Contains(chunkPos)) return true; } return false; } } public bool LatestOrCommittedChunkExists() { lock (lockObject) { ThrowIfDisposed(); var chunks = FindAllChunks(); foreach (var chunk in chunks) { if (LatestOrCommittedChunkExists(chunk)) return true; } } return false; } /// This image is disposed public bool DrawCommittedChunkOn(VecI chunkPos, ChunkResolution resolution, DrawingSurface surface, VecI pos, Paint? paint = null) { lock (lockObject) { ThrowIfDisposed(); var chunk = GetCommittedChunk(chunkPos, resolution); if (chunk is null) return false; chunk.DrawChunkOn(surface, pos, paint); return true; } } /// This image is disposed internal bool CommittedChunkExists(VecI chunkPos) { lock (lockObject) { ThrowIfDisposed(); return MaybeGetCommittedChunk(chunkPos, ChunkResolution.Full) is not null; } } /// /// Returns the latest version of the chunk if it exists or should exist based on queued operation. The returned chunk is fully up to date. /// private Chunk? GetLatestChunk(VecI pos, ChunkResolution resolution) { if (queuedOperations.Count == 0) return null; MaybeCreateAndProcessQueueForChunk(pos, resolution); var maybeNewlyProcessedChunk = MaybeGetLatestChunk(pos, resolution); return maybeNewlyProcessedChunk; } /// /// Tries it's best to return a committed chunk, either if it exists or if it can be created from it's high res version. Returns null if it can't. private Chunk? GetCommittedChunk(VecI pos, ChunkResolution resolution) { var maybeSameRes = MaybeGetCommittedChunk(pos, resolution); if (maybeSameRes is not null) return maybeSameRes; var maybeFullRes = MaybeGetCommittedChunk(pos, ChunkResolution.Full); if (maybeFullRes is not null) return GetOrCreateCommittedChunk(pos, resolution); return null; } private Chunk? MaybeGetLatestChunk(VecI pos, ChunkResolution resolution) => latestChunks[resolution].TryGetValue(pos, out Chunk? value) ? value : null; private Chunk? MaybeGetCommittedChunk(VecI pos, ChunkResolution resolution) => committedChunks[resolution].TryGetValue(pos, out Chunk? value) ? value : null; /// This image is disposed public void AddRasterClip(ChunkyImage clippingMask) { lock (lockObject) { ThrowIfDisposed(); if (queuedOperations.Count > 0) throw new InvalidOperationException( "This function can only be executed when there are no queued operations"); activeClips.Add(clippingMask); } } /// This image is disposed public void SetClippingPath(VectorPath clippingPath) { lock (lockObject) { ThrowIfDisposed(); if (queuedOperations.Count > 0) throw new InvalidOperationException( "This function can only be executed when there are no queued operations"); this.clippingPath = clippingPath; } } /// /// Porter duff compositing operators (apart from SrcOver) likely won't have the intended effect. /// /// This image is disposed public void SetBlendMode(BlendMode mode) { lock (lockObject) { ThrowIfDisposed(); if (queuedOperations.Count > 0) throw new InvalidOperationException( "This function can only be executed when there are no queued operations"); blendMode = mode; } } /// This image is disposed public void SetHorizontalAxisOfSymmetry(double position) { lock (lockObject) { ThrowIfDisposed(); if (queuedOperations.Count > 0) throw new InvalidOperationException( "This function can only be executed when there are no queued operations"); horizontalSymmetryAxis = position; } } /// This image is disposed public void SetVerticalAxisOfSymmetry(double position) { lock (lockObject) { ThrowIfDisposed(); if (queuedOperations.Count > 0) throw new InvalidOperationException( "This function can only be executed when there are no queued operations"); verticalSymmetryAxis = position; } } /// This image is disposed public void EnableLockTransparency() { lock (lockObject) { ThrowIfDisposed(); lockTransparency = true; } } /// This image is disposed public void EnqueueReplaceColor(Color oldColor, Color newColor) { lock (lockObject) { ThrowIfDisposed(); ReplaceColorOperation operation = new(oldColor, newColor); EnqueueOperation(operation); } } /// This image is disposed public void EnqueueDrawRectangle(ShapeData rect) { lock (lockObject) { ThrowIfDisposed(); RectangleOperation operation = new(rect); EnqueueOperation(operation); } } /// This image is disposed public void EnqueueDrawEllipse(RectI location, Color strokeColor, Color fillColor, int strokeWidth, double rotationRad = 0, Paint? paint = null) { lock (lockObject) { ThrowIfDisposed(); EllipseOperation operation = new(location, strokeColor, fillColor, strokeWidth, rotationRad, paint); EnqueueOperation(operation); } } /// /// Be careful about the copyImage argument. The default is true, and this is a thread safe version without any side effects. /// It will however copy the surface right away which can be slow (in updateable changes especially). /// If copyImage is set to false, the image won't be copied and instead a reference will be stored. /// Surface is NOT THREAD SAFE, so if you pass a Surface here with copyImage == false you must not do anything with that surface anywhere (not even read) until CommitChanges/CancelChanges is called. /// /// This image is disposed public void EnqueueDrawImage(Matrix3X3 transformMatrix, Surface image, Paint? paint = null, bool copyImage = true) { lock (lockObject) { ThrowIfDisposed(); ImageOperation operation = new(transformMatrix, image, paint, copyImage); EnqueueOperation(operation); } } /// /// Be careful about the copyImage argument, see other overload for details /// /// This image is disposed public void EnqueueDrawImage(ShapeCorners corners, Surface image, Paint? paint = null, bool copyImage = true) { lock (lockObject) { ThrowIfDisposed(); ImageOperation operation = new(corners, image, paint, copyImage); EnqueueOperation(operation); } } /// /// Be careful about the copyImage argument, see other overload for details /// /// This image is disposed public void EnqueueDrawImage(VecI pos, Surface image, Paint? paint = null, bool copyImage = true) { lock (lockObject) { ThrowIfDisposed(); ImageOperation operation = new(pos, image, paint, copyImage); EnqueueOperation(operation); } } /// /// Be careful about the copyImage argument. The default is true, and this is a thread safe version without any side effects. /// It will however copy the surface right away which can be slow (in updateable changes especially). /// If copyImage is set to false, the image won't be copied and instead a reference will be stored. /// Texture is NOT THREAD SAFE, so if you pass a Texture here with copyImage == false you must not do anything with that texture anywhere (not even read) until CommitChanges/CancelChanges is called. /// /// This image is disposed public void EnqueueDrawTexture(Matrix3X3 transformMatrix, Texture image, Paint? paint = null, bool copyImage = true) { lock (lockObject) { ThrowIfDisposed(); TextureOperation operation = new(transformMatrix, image, paint, copyImage); EnqueueOperation(operation); } } /// /// Be careful about the copyImage argument, see other overload for details /// /// This image is disposed public void EnqueueDrawTexture(ShapeCorners corners, Texture image, Paint? paint = null, bool copyImage = true) { lock (lockObject) { ThrowIfDisposed(); TextureOperation operation = new(corners, image, paint, copyImage); EnqueueOperation(operation); } } /// /// Be careful about the copyImage argument, see other overload for details /// /// This image is disposed public void EnqueueDrawTexture(VecI pos, Texture image, Paint? paint = null, bool copyImage = true) { lock (lockObject) { ThrowIfDisposed(); TextureOperation operation = new(pos, image, paint, copyImage); EnqueueOperation(operation); } } public void EnqueueApplyMask(ChunkyImage mask) { lock (lockObject) { ThrowIfDisposed(); ApplyMaskOperation operation = new(mask); EnqueueOperation(operation); } } /// Bounds used for affected chunks, will be computed from path in O(n) if null is passed /// This image is disposed public void EnqueueDrawPath(VectorPath path, Color color, float strokeWidth, StrokeCap strokeCap, BlendMode blendMode, RectI? customBounds = null) { lock (lockObject) { ThrowIfDisposed(); PathOperation operation = new(path, color, strokeWidth, strokeCap, blendMode, customBounds); EnqueueOperation(operation); } } /// This image is disposed public void EnqueueDrawBresenhamLine(VecI from, VecI to, Color color, BlendMode blendMode) { lock (lockObject) { ThrowIfDisposed(); BresenhamLineOperation operation = new(from, to, color, blendMode); EnqueueOperation(operation); } } /// This image is disposed public void EnqueueDrawSkiaLine(VecI from, VecI to, StrokeCap strokeCap, float strokeWidth, Color color, BlendMode blendMode) { lock (lockObject) { ThrowIfDisposed(); DrawingSurfaceLineOperation operation = new(from, to, strokeCap, strokeWidth, color, blendMode); EnqueueOperation(operation); } } /// This image is disposed public void EnqueueDrawPixels(IEnumerable pixels, Color color, BlendMode blendMode) { lock (lockObject) { ThrowIfDisposed(); PixelsOperation operation = new(pixels, color, blendMode); EnqueueOperation(operation); } } /// This image is disposed public void EnqueueDrawPixel(VecI pos, Color color, BlendMode blendMode) { lock (lockObject) { ThrowIfDisposed(); PixelOperation operation = new(pos, color, blendMode); EnqueueOperation(operation); } } /// This image is disposed public void EnqueueDrawPixel(VecI pos, PixelProcessor pixelProcessor, BlendMode blendMode) { lock (lockObject) { ThrowIfDisposed(); PixelOperation operation = new(pos, pixelProcessor, GetCommittedPixel, blendMode); EnqueueOperation(operation); } } /// This image is disposed public void EnqueueDrawCommitedChunkyImage(VecI pos, ChunkyImage image, bool flipHor = false, bool flipVer = false) { lock (lockObject) { ThrowIfDisposed(); ChunkyImageOperation operation = new(image, pos, flipHor, flipVer, false); EnqueueOperation(operation); } } public void EnqueueDrawUpToDateChunkyImage(VecI pos, ChunkyImage image, bool flipHor = false, bool flipVer = false) { ThrowIfDisposed(); ChunkyImageOperation operation = new(image, pos, flipHor, flipVer, true); EnqueueOperation(operation); } /// This image is disposed public void EnqueueClearRegion(RectI region) { lock (lockObject) { ThrowIfDisposed(); ClearRegionOperation operation = new(region); EnqueueOperation(operation); } } /// This image is disposed public void EnqueueClearPath(VectorPath path, RectI? pathTightBounds = null) { lock (lockObject) { ThrowIfDisposed(); ClearPathOperation operation = new(path, pathTightBounds); EnqueueOperation(operation); } } /// This image is disposed public void EnqueueClear() { lock (lockObject) { ThrowIfDisposed(); ClearOperation operation = new(); EnqueueOperation(operation, new(FindAllChunks())); } } /// This image is disposed public void EnqueueResize(VecI newSize) { lock (lockObject) { ThrowIfDisposed(); ResizeOperation operation = new(newSize); LatestSize = newSize; EnqueueOperation(operation, new(FindAllChunksOutsideBounds(newSize))); } } public void EnqueueDrawPaint(Paint paint) { lock (lockObject) { ThrowIfDisposed(); PaintOperation operation = new(paint); EnqueueOperation(operation); } } private void EnqueueOperation(IDrawOperation operation) { List operations = new(4) { operation }; if (operation is IMirroredDrawOperation mirroredOperation) { if (horizontalSymmetryAxis is not null && verticalSymmetryAxis is not null) operations.Add(mirroredOperation.AsMirrored(verticalSymmetryAxis, horizontalSymmetryAxis)); if (horizontalSymmetryAxis is not null) operations.Add(mirroredOperation.AsMirrored(null, horizontalSymmetryAxis)); if (verticalSymmetryAxis is not null) operations.Add(mirroredOperation.AsMirrored(verticalSymmetryAxis, null)); } foreach (var op in operations) { var area = op.FindAffectedArea(LatestSize); area.Chunks.RemoveWhere(pos => IsOutsideBounds(pos, LatestSize)); area.GlobalArea = area.GlobalArea?.Intersect(new RectI(VecI.Zero, LatestSize)); if (operation.IgnoreEmptyChunks) area.Chunks.IntersectWith(FindAllChunks()); EnqueueOperation(op, area); operationCounter++; } } private void EnqueueOperation(IOperation operation, AffectedArea area) { queuedOperations.Add((operation, area)); } /// This image is disposed public void CancelChanges() { lock (lockObject) { ThrowIfDisposed(); //clear queued operations foreach (var operation in queuedOperations) operation.operation.Dispose(); queuedOperations.Clear(); //clear additional state activeClips.Clear(); blendMode = BlendMode.Src; lockTransparency = false; horizontalSymmetryAxis = null; verticalSymmetryAxis = null; clippingPath = null; //clear latest chunks foreach (var (_, chunksOfRes) in latestChunks) { foreach (var (_, chunk) in chunksOfRes) { chunk.Dispose(); } } LatestSize = CommittedSize; foreach (var (res, chunks) in latestChunks) { chunks.Clear(); latestChunksData[res].Clear(); } } } /// This image is disposed public void CommitChanges() { lock (lockObject) { ThrowIfDisposed(); var affectedArea = FindAffectedArea(); foreach (var chunk in affectedArea.Chunks) { MaybeCreateAndProcessQueueForChunk(chunk, ChunkResolution.Full); } foreach (var (operation, _) in queuedOperations) { operation.Dispose(); } CommitLatestChunks(); CommittedSize = LatestSize; queuedOperations.Clear(); activeClips.Clear(); blendMode = BlendMode.Src; lockTransparency = false; horizontalSymmetryAxis = null; verticalSymmetryAxis = null; clippingPath = null; commitCounter++; if (commitCounter % 30 == 0) FindAndDeleteEmptyCommittedChunks(); } } /// /// Does all necessary steps to convert latest chunks into committed ones. The latest chunk dictionary become empty after this function is called. /// private void CommitLatestChunks() { // move/draw fully processed latest chunks to/on committed foreach (var (resolution, chunks) in latestChunks) { foreach (var (pos, chunk) in chunks) { // get chunk if exists LatestChunkData data = latestChunksData[resolution][pos]; if (data.QueueProgress != queuedOperations.Count) { if (resolution == ChunkResolution.Full) { throw new InvalidOperationException( "Trying to commit a full res chunk that wasn't fully processed"); } else { chunk.Dispose(); continue; } } // do a swap if (blendMode == BlendMode.Src) { // delete committed version if (committedChunks[resolution].ContainsKey(pos)) { var oldChunk = committedChunks[resolution][pos]; committedChunks[resolution].Remove(pos); oldChunk.Dispose(); } // put the latest version in place of the committed one if (!data.IsDeleted) committedChunks[resolution].Add(pos, chunk); else chunk.Dispose(); } // do blending else { // nothing to blend, continue if (data.IsDeleted) { chunk.Dispose(); continue; } // nothing to blend with, swap var maybeCommitted = MaybeGetCommittedChunk(pos, resolution); if (maybeCommitted is null) { committedChunks[resolution].Add(pos, chunk); continue; } //blend blendModePaint.BlendMode = blendMode; if (lockTransparency) { using Chunk tempChunk = Chunk.Create(resolution); tempChunk.Surface.DrawingSurface.Canvas.DrawSurface(maybeCommitted.Surface.DrawingSurface, 0, 0, ReplacingPaint); maybeCommitted.Surface.DrawingSurface.Canvas.DrawSurface(chunk.Surface.DrawingSurface, 0, 0, blendModePaint); OperationHelper.ClampAlpha(maybeCommitted.Surface.DrawingSurface, tempChunk.Surface.DrawingSurface); } else { maybeCommitted.Surface.DrawingSurface.Canvas.DrawSurface(chunk.Surface.DrawingSurface, 0, 0, blendModePaint); } chunk.Dispose(); } } } // delete committed low res chunks that weren't updated foreach (var (pos, _) in latestChunks[ChunkResolution.Full]) { foreach (var (resolution, _) in latestChunks) { if (resolution == ChunkResolution.Full) continue; if (!latestChunksData[resolution].TryGetValue(pos, out var halfChunk) || halfChunk.QueueProgress != queuedOperations.Count) { if (committedChunks[resolution].TryGetValue(pos, out var committedLowResChunk)) { committedChunks[resolution].Remove(pos); committedLowResChunk.Dispose(); } } } } // clear latest chunks foreach (var (resolution, chunks) in latestChunks) { chunks.Clear(); latestChunksData[resolution].Clear(); } } /// /// All chunks that have something in them, including latest (uncommitted) ones /// /// This image is disposed public HashSet FindAllChunks() { lock (lockObject) { ThrowIfDisposed(); var allChunks = committedChunks[ChunkResolution.Full].Select(chunk => chunk.Key).ToHashSet(); foreach (var (_, affArea) in queuedOperations) { allChunks.UnionWith(affArea.Chunks); } return allChunks; } } /// This image is disposed public HashSet FindCommittedChunks() { lock (lockObject) { ThrowIfDisposed(); return committedChunks[ChunkResolution.Full].Select(chunk => chunk.Key).ToHashSet(); } } /// /// Chunks affected by operations that haven't been committed yet /// /// This image is disposed public AffectedArea FindAffectedArea(int fromOperationIndex = 0) { lock (lockObject) { ThrowIfDisposed(); var chunks = new HashSet(); RectI? rect = null; for (int i = fromOperationIndex; i < queuedOperations.Count; i++) { var (_, area) = queuedOperations[i]; chunks.UnionWith(area.Chunks); rect ??= area.GlobalArea; if (area.GlobalArea is not null && rect is not null) rect = rect.Value.Union(area.GlobalArea.Value); } return new AffectedArea(chunks, rect); } } public void SetCommitedChunk(Chunk chunk, VecI pos, ChunkResolution resolution) { lock (lockObject) { ThrowIfDisposed(); committedChunks[resolution][pos] = chunk; } } /// /// Applies all operations queued for a specific (latest) chunk. If the latest chunk doesn't exist yet, creates it. If none of the existing operations affect the chunk does nothing. /// private void MaybeCreateAndProcessQueueForChunk(VecI chunkPos, ChunkResolution resolution) { if (!latestChunksData[resolution].TryGetValue(chunkPos, out LatestChunkData chunkData)) chunkData = new() { QueueProgress = 0, IsDeleted = !committedChunks[ChunkResolution.Full].ContainsKey(chunkPos) }; if (chunkData.QueueProgress == queuedOperations.Count) return; Chunk? targetChunk = null; OneOf combinedRasterClips = new FilledChunk(); bool initialized = false; for (int i = 0; i < queuedOperations.Count; i++) { var (operation, affArea) = queuedOperations[i]; if (!affArea.Chunks.Contains(chunkPos)) continue; if (!initialized) { initialized = true; targetChunk = GetOrCreateLatestChunk(chunkPos, resolution); combinedRasterClips = CombineRasterClipsForChunk(chunkPos, resolution); } if (chunkData.QueueProgress <= i) chunkData.IsDeleted = ApplyOperationToChunk(operation, affArea, combinedRasterClips, targetChunk!, chunkPos, resolution, chunkData); } if (initialized) { if (lockTransparency && !chunkData.IsDeleted && MaybeGetCommittedChunk(chunkPos, ChunkResolution.Full) is not null) { var committed = GetCommittedChunk(chunkPos, resolution); OperationHelper.ClampAlpha(targetChunk!.Surface.DrawingSurface, committed!.Surface.DrawingSurface); } chunkData.QueueProgress = queuedOperations.Count; latestChunksData[resolution][chunkPos] = chunkData; } if (combinedRasterClips.TryPickT2(out Chunk value, out var _)) value.Dispose(); } private OneOf CombineRasterClipsForChunk(VecI chunkPos, ChunkResolution resolution) { if (lockTransparency && MaybeGetCommittedChunk(chunkPos, ChunkResolution.Full) is null) { return new EmptyChunk(); } if (activeClips.Count == 0) { return new FilledChunk(); } var intersection = Chunk.Create(resolution); intersection.Surface.DrawingSurface.Canvas.Clear(Colors.White); foreach (var mask in activeClips) { if (mask.CommittedChunkExists(chunkPos)) { mask.DrawCommittedChunkOn(chunkPos, resolution, intersection.Surface.DrawingSurface, VecI.Zero, ClippingPaint); } else { intersection.Dispose(); return new EmptyChunk(); } } return intersection; } /// /// True if the chunk was fully cleared (and should be deleted). /// private bool ApplyOperationToChunk( IOperation operation, AffectedArea operationAffectedArea, OneOf combinedRasterClips, Chunk targetChunk, VecI chunkPos, ChunkResolution resolution, LatestChunkData chunkData) { if (operation is ClearOperation) return true; if (operation is IDrawOperation chunkOperation) { if (combinedRasterClips.IsT1) // Nothing is visible return chunkData.IsDeleted; if (chunkData.IsDeleted) targetChunk.Surface.DrawingSurface.Canvas.Clear(); // just regular drawing if (combinedRasterClips.IsT0) // Everything is visible as far as the raster clips are concerned { CallDrawWithClip(chunkOperation, operationAffectedArea.GlobalArea, targetChunk, resolution, chunkPos); return false; } // drawing with raster clipping var clip = combinedRasterClips.AsT2; using var tempChunk = Chunk.Create(targetChunk.Resolution); targetChunk.DrawChunkOn(tempChunk.Surface.DrawingSurface, VecI.Zero, ReplacingPaint); CallDrawWithClip(chunkOperation, operationAffectedArea.GlobalArea, tempChunk, resolution, chunkPos); clip.DrawChunkOn(tempChunk.Surface.DrawingSurface, VecI.Zero, ClippingPaint); clip.DrawChunkOn(targetChunk.Surface.DrawingSurface, VecI.Zero, InverseClippingPaint); tempChunk.DrawChunkOn(targetChunk.Surface.DrawingSurface, VecI.Zero, AddingPaint); return false; } if (operation is ResizeOperation resizeOperation) { return IsOutsideBounds(chunkPos, resizeOperation.Size); } return chunkData.IsDeleted; } private void CallDrawWithClip(IDrawOperation operation, RectI? operationAffectedArea, Chunk targetChunk, ChunkResolution resolution, VecI chunkPos) { if (operationAffectedArea is null) return; int count = targetChunk.Surface.DrawingSurface.Canvas.Save(); float scale = (float)resolution.Multiplier(); if (clippingPath is not null && !clippingPath.IsEmpty) { using VectorPath transformedPath = new(clippingPath); VecD trans = -chunkPos * FullChunkSize * scale; transformedPath.Transform(Matrix3X3.CreateScaleTranslation(scale, scale, (float)trans.X, (float)trans.Y)); targetChunk.Surface.DrawingSurface.Canvas.ClipPath(transformedPath); } VecD affectedAreaPos = operationAffectedArea.Value.TopLeft; VecD affectedAreaSize = operationAffectedArea.Value.Size; affectedAreaPos = (affectedAreaPos - chunkPos * FullChunkSize) * scale; affectedAreaSize = affectedAreaSize * scale; targetChunk.Surface.DrawingSurface.Canvas.ClipRect(new RectD(affectedAreaPos, affectedAreaSize)); operation.DrawOnChunk(targetChunk, chunkPos); targetChunk.Surface.DrawingSurface.Canvas.RestoreToCount(count); } /// /// Finds and deletes empty committed chunks. Returns true if all existing chunks were deleted. /// Note: this function modifies the internal state, it is not thread safe! Use it only in changes (same as all the other functions that change the image in some way). /// /// This image is disposed public bool CheckIfCommittedIsEmpty() { lock (lockObject) { ThrowIfDisposed(); if (queuedOperations.Count > 0) throw new InvalidOperationException( "This function can only be used when there are no queued operations"); FindAndDeleteEmptyCommittedChunks(); return committedChunks[ChunkResolution.Full].Count == 0; } } private HashSet FindAllChunksOutsideBounds(VecI size) { var chunks = FindAllChunks(); chunks.RemoveWhere(pos => !IsOutsideBounds(pos, size)); return chunks; } private static bool IsOutsideBounds(VecI chunkPos, VecI imageSize) { return chunkPos.X < 0 || chunkPos.Y < 0 || chunkPos.X * FullChunkSize >= imageSize.X || chunkPos.Y * FullChunkSize >= imageSize.Y; } private void FindAndDeleteEmptyCommittedChunks() { if (queuedOperations.Count != 0) throw new InvalidOperationException("This method cannot be used while any operations are queued"); HashSet toRemove = new(); foreach (var (pos, chunk) in committedChunks[ChunkResolution.Full]) { if (chunk.Surface.IsFullyTransparent()) { toRemove.Add(pos); chunk.Dispose(); } } foreach (var pos in toRemove) { committedChunks[ChunkResolution.Full].Remove(pos); committedChunks[ChunkResolution.Half].Remove(pos); committedChunks[ChunkResolution.Quarter].Remove(pos); committedChunks[ChunkResolution.Eighth].Remove(pos); } } /// /// Gets existing committed chunk or creates a new one. Doesn't apply any operations to the chunk, returns it as it is. /// private Chunk GetOrCreateCommittedChunk(VecI chunkPos, ChunkResolution resolution) { // committed chunk of the same resolution exists Chunk? targetChunk = MaybeGetCommittedChunk(chunkPos, resolution); if (targetChunk is not null) return targetChunk; // for full res chunks: nothing exists, create brand new chunk if (resolution == ChunkResolution.Full) { var newChunk = Chunk.Create(resolution); committedChunks[resolution][chunkPos] = newChunk; return newChunk; } // for low res chunks: full res version exists Chunk? existingFullResChunk = MaybeGetCommittedChunk(chunkPos, ChunkResolution.Full); if (existingFullResChunk is not null) { var newChunk = Chunk.Create(resolution); newChunk.Surface.DrawingSurface.Canvas.Save(); newChunk.Surface.DrawingSurface.Canvas.Scale((float)resolution.Multiplier()); newChunk.Surface.DrawingSurface.Canvas.DrawSurface(existingFullResChunk.Surface.DrawingSurface, 0, 0, SmoothReplacingPaint); newChunk.Surface.DrawingSurface.Canvas.Restore(); committedChunks[resolution][chunkPos] = newChunk; return newChunk; } // for low res chunks: full res version doesn't exist { GetOrCreateCommittedChunk(chunkPos, ChunkResolution.Full); var newChunk = Chunk.Create(resolution); committedChunks[resolution][chunkPos] = newChunk; return newChunk; } } /// /// Gets existing latest chunk or creates a new one, based on a committed one if it exists. Doesn't do any operations to the chunk. /// private Chunk GetOrCreateLatestChunk(VecI chunkPos, ChunkResolution resolution) { // latest chunk exists Chunk? targetChunk = MaybeGetLatestChunk(chunkPos, resolution); if (targetChunk is not null) return targetChunk; // committed chunk of the same resolution exists var maybeCommittedAnyRes = MaybeGetCommittedChunk(chunkPos, resolution); if (maybeCommittedAnyRes is not null) { Chunk newChunk = Chunk.Create(resolution); if (blendMode == BlendMode.Src) maybeCommittedAnyRes.Surface.CopyTo(newChunk.Surface); else newChunk.Surface.DrawingSurface.Canvas.Clear(); latestChunks[resolution][chunkPos] = newChunk; return newChunk; } // committed chunk of full resolution exists var maybeCommittedFullRes = MaybeGetCommittedChunk(chunkPos, ChunkResolution.Full); if (maybeCommittedFullRes is not null) { //create low res committed chunk var committedChunkLowRes = GetOrCreateCommittedChunk(chunkPos, resolution); //create latest based on it Chunk newChunk = Chunk.Create(resolution); committedChunkLowRes.Surface.CopyTo(newChunk.Surface); latestChunks[resolution][chunkPos] = newChunk; return newChunk; } // no previous chunks exist var newLatestChunk = Chunk.Create(resolution); newLatestChunk.Surface.DrawingSurface.Canvas.Clear(); latestChunks[resolution][chunkPos] = newLatestChunk; return newLatestChunk; } private void ThrowIfDisposed() { if (disposed) throw new ObjectDisposedException(nameof(ChunkyImage)); } public void Dispose() { lock (lockObject) { if (disposed) return; CancelChanges(); DisposeAll(); blendModePaint.Dispose(); GC.SuppressFinalize(this); } } private void DisposeAll() { foreach (var (_, chunks) in committedChunks) { foreach (var (_, chunk) in chunks) { chunk.Dispose(); } } foreach (var (_, chunks) in latestChunks) { foreach (var (_, chunk) in chunks) { chunk.Dispose(); } } disposed = true; } public object Clone() { lock (lockObject) { ThrowIfDisposed(); ChunkyImage clone = CloneFromCommitted(); return clone; } } public int GetCacheHash() { return commitCounter + queuedOperations.Count + operationCounter + activeClips.Count + (int)blendMode + (lockTransparency ? 1 : 0) + (horizontalSymmetryAxis is not null ? (int)(horizontalSymmetryAxis * 100) : 0) + (verticalSymmetryAxis is not null ? (int)(verticalSymmetryAxis * 100) : 0) + (clippingPath is not null ? 1 : 0); } }