浏览代码

Merge remote-tracking branch 'proto/master' into prototype-integration

Equbuxu 3 年之前
父节点
当前提交
c3cee159a5
共有 100 个文件被更改,包括 7396 次插入0 次删除
  1. 158 0
      src/.editorconfig
  2. 63 0
      src/.gitattributes
  3. 363 0
      src/.gitignore
  4. 13 0
      src/.idea/.idea.PixiEditorPrototype/.idea/.gitignore
  5. 262 0
      src/.idea/.idea.PixiEditorPrototype/.idea/deployment.xml
  6. 8 0
      src/.idea/.idea.PixiEditorPrototype/.idea/indexLayout.xml
  7. 69 0
      src/ChunkyImageLib/Chunk.cs
  8. 66 0
      src/ChunkyImageLib/ChunkPool.cs
  9. 1142 0
      src/ChunkyImageLib/ChunkyImage.cs
  10. 30 0
      src/ChunkyImageLib/ChunkyImageEx.cs
  11. 16 0
      src/ChunkyImageLib/ChunkyImageLib.csproj
  12. 51 0
      src/ChunkyImageLib/CommittedChunkStorage.cs
  13. 22 0
      src/ChunkyImageLib/DataHolders/ChunkResolution.cs
  14. 35 0
      src/ChunkyImageLib/DataHolders/ChunkResolutionEx.cs
  15. 4 0
      src/ChunkyImageLib/DataHolders/EmptyChunk.cs
  16. 4 0
      src/ChunkyImageLib/DataHolders/FilledChunk.cs
  17. 380 0
      src/ChunkyImageLib/DataHolders/RectD.cs
  18. 346 0
      src/ChunkyImageLib/DataHolders/RectI.cs
  19. 103 0
      src/ChunkyImageLib/DataHolders/ShapeCorners.cs
  20. 31 0
      src/ChunkyImageLib/DataHolders/ShapeData.cs
  21. 257 0
      src/ChunkyImageLib/DataHolders/VecD.cs
  22. 174 0
      src/ChunkyImageLib/DataHolders/VecI.cs
  23. 15 0
      src/ChunkyImageLib/IReadOnlyChunkyImage.cs
  24. 109 0
      src/ChunkyImageLib/Operations/BresenhamLineHelper.cs
  25. 65 0
      src/ChunkyImageLib/Operations/BresenhamLineOperation.cs
  26. 122 0
      src/ChunkyImageLib/Operations/ChunkyImageOperation.cs
  27. 6 0
      src/ChunkyImageLib/Operations/ClearOperation.cs
  28. 43 0
      src/ChunkyImageLib/Operations/ClearRegionOperation.cs
  29. 180 0
      src/ChunkyImageLib/Operations/EllipseHelper.cs
  30. 120 0
      src/ChunkyImageLib/Operations/EllipseOperation.cs
  31. 11 0
      src/ChunkyImageLib/Operations/IDrawOperation.cs
  32. 5 0
      src/ChunkyImageLib/Operations/IOperation.cs
  33. 96 0
      src/ChunkyImageLib/Operations/ImageOperation.cs
  34. 503 0
      src/ChunkyImageLib/Operations/OperationHelper.cs
  35. 58 0
      src/ChunkyImageLib/Operations/PathOperation.cs
  36. 54 0
      src/ChunkyImageLib/Operations/PixelOperation.cs
  37. 53 0
      src/ChunkyImageLib/Operations/PixelsOperation.cs
  38. 81 0
      src/ChunkyImageLib/Operations/RectangleOperation.cs
  39. 13 0
      src/ChunkyImageLib/Operations/ResizeOperation.cs
  40. 65 0
      src/ChunkyImageLib/Operations/SkiaLineOperation.cs
  41. 129 0
      src/ChunkyImageLib/Surface.cs
  42. 14 0
      src/ChunkyImageLibBenchmark/ChunkyImageLibBenchmark.csproj
  43. 52 0
      src/ChunkyImageLibBenchmark/Program.cs
  44. 27 0
      src/ChunkyImageLibTest/ChunkyImageLibTest.csproj
  45. 99 0
      src/ChunkyImageLibTest/ChunkyImageTests.cs
  46. 40 0
      src/ChunkyImageLibTest/ClearRegionOperationTests.cs
  47. 18 0
      src/ChunkyImageLibTest/ImageOperationTests.cs
  48. 102 0
      src/ChunkyImageLibTest/OperationHelperTests.cs
  49. 322 0
      src/ChunkyImageLibTest/RectITests.cs
  50. 121 0
      src/ChunkyImageLibTest/RectangleOperationTests.cs
  51. 9 0
      src/ChunkyImageLibVis/App.xaml
  52. 10 0
      src/ChunkyImageLibVis/App.xaml.cs
  53. 10 0
      src/ChunkyImageLibVis/AssemblyInfo.cs
  54. 14 0
      src/ChunkyImageLibVis/ChunkyImageLibVis.csproj
  55. 16 0
      src/ChunkyImageLibVis/MainWindow.xaml
  56. 205 0
      src/ChunkyImageLibVis/MainWindow.xaml.cs
  57. 49 0
      src/PixiEditor.ChangeableDocument.Gen/ChangeActionGenerator.cs
  58. 258 0
      src/PixiEditor.ChangeableDocument.Gen/Helpers.cs
  59. 4 0
      src/PixiEditor.ChangeableDocument.Gen/MethodInfo.cs
  60. 12 0
      src/PixiEditor.ChangeableDocument.Gen/NamedSourceCode.cs
  61. 13 0
      src/PixiEditor.ChangeableDocument.Gen/NamespacedType.cs
  62. 18 0
      src/PixiEditor.ChangeableDocument.Gen/PixiEditor.ChangeableDocument.Gen.csproj
  63. 28 0
      src/PixiEditor.ChangeableDocument.Gen/Result.cs
  64. 17 0
      src/PixiEditor.ChangeableDocument.Gen/TypeWithName.cs
  65. 124 0
      src/PixiEditor.ChangeableDocument.Gen/UpdateableChangeActionGenerator.cs
  66. 3 0
      src/PixiEditor.ChangeableDocument/Actions/Attributes/GenerateMakeChangeActionAttribute.cs
  67. 3 0
      src/PixiEditor.ChangeableDocument/Actions/Attributes/GenerateUpdateableChangeActionsAttribute.cs
  68. 3 0
      src/PixiEditor.ChangeableDocument/Actions/Attributes/UpdateChangeMethodAttribute.cs
  69. 5 0
      src/PixiEditor.ChangeableDocument/Actions/IAction.cs
  70. 8 0
      src/PixiEditor.ChangeableDocument/Actions/IEndChangeAction.cs
  71. 8 0
      src/PixiEditor.ChangeableDocument/Actions/IMakeChangeAction.cs
  72. 10 0
      src/PixiEditor.ChangeableDocument/Actions/IStartOrUpdateChangeAction.cs
  73. 3 0
      src/PixiEditor.ChangeableDocument/Actions/Undo/ChangeBoundary_Action.cs
  74. 3 0
      src/PixiEditor.ChangeableDocument/Actions/Undo/DeleteRecordedChanges_Action.cs
  75. 3 0
      src/PixiEditor.ChangeableDocument/Actions/Undo/Redo_Action.cs
  76. 3 0
      src/PixiEditor.ChangeableDocument/Actions/Undo/Undo_Action.cs
  77. 3 0
      src/PixiEditor.ChangeableDocument/ChangeInfos/Drawing/LayerImageChunks_ChangeInfo.cs
  78. 3 0
      src/PixiEditor.ChangeableDocument/ChangeInfos/Drawing/MaskChunks_ChangeInfo.cs
  79. 5 0
      src/PixiEditor.ChangeableDocument/ChangeInfos/Drawing/Selection_ChangeInfo.cs
  80. 5 0
      src/PixiEditor.ChangeableDocument/ChangeInfos/IChangeInfo.cs
  81. 2 0
      src/PixiEditor.ChangeableDocument/ChangeInfos/Properties/LayerLockTransparency_ChangeInfo.cs
  82. 4 0
      src/PixiEditor.ChangeableDocument/ChangeInfos/Properties/StructureMemberBlendMode_ChangeInfo.cs
  83. 2 0
      src/PixiEditor.ChangeableDocument/ChangeInfos/Properties/StructureMemberClipToMemberBelow_ChangeInfo.cs
  84. 3 0
      src/PixiEditor.ChangeableDocument/ChangeInfos/Properties/StructureMemberIsVisible_ChangeInfo.cs
  85. 2 0
      src/PixiEditor.ChangeableDocument/ChangeInfos/Properties/StructureMemberMaskIsVisible_ChangeInfo.cs
  86. 3 0
      src/PixiEditor.ChangeableDocument/ChangeInfos/Properties/StructureMemberMask_ChangeInfo.cs
  87. 3 0
      src/PixiEditor.ChangeableDocument/ChangeInfos/Properties/StructureMemberName_ChangeInfo.cs
  88. 3 0
      src/PixiEditor.ChangeableDocument/ChangeInfos/Properties/StructureMemberOpacity_ChangeInfo.cs
  89. 3 0
      src/PixiEditor.ChangeableDocument/ChangeInfos/Root/Size_ChangeInfo.cs
  90. 4 0
      src/PixiEditor.ChangeableDocument/ChangeInfos/Root/SymmetryAxisPosition_ChangeInfo.cs
  91. 4 0
      src/PixiEditor.ChangeableDocument/ChangeInfos/Root/SymmetryAxisState_ChangeInfo.cs
  92. 53 0
      src/PixiEditor.ChangeableDocument/ChangeInfos/Structure/CreateFolder_ChangeInfo.cs
  93. 40 0
      src/PixiEditor.ChangeableDocument/ChangeInfos/Structure/CreateLayer_ChangeInfo.cs
  94. 3 0
      src/PixiEditor.ChangeableDocument/ChangeInfos/Structure/CreateReferenceLayer_ChangeInfo.cs
  95. 16 0
      src/PixiEditor.ChangeableDocument/ChangeInfos/Structure/CreateStructureMember_ChangeInfo.cs
  96. 3 0
      src/PixiEditor.ChangeableDocument/ChangeInfos/Structure/DeleteStructureMember_ChangeInfo.cs
  97. 3 0
      src/PixiEditor.ChangeableDocument/ChangeInfos/Structure/MoveStructureMember_ChangeInfo.cs
  98. 3 0
      src/PixiEditor.ChangeableDocument/ChangeInfos/Structure/TransformReferenceLayer_ChangeInfo.cs
  99. 215 0
      src/PixiEditor.ChangeableDocument/Changeables/Document.cs
  100. 52 0
      src/PixiEditor.ChangeableDocument/Changeables/Folder.cs

+ 158 - 0
src/.editorconfig

@@ -0,0 +1,158 @@
+# To learn more about .editorconfig see https://aka.ms/editorconfigdocs
+###############################
+# Core EditorConfig Options   #
+###############################
+# All files
+[*]
+indent_style = space
+
+# XML project files
+[*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj}]
+indent_size = 2
+
+# XML config files
+[*.{props,targets,ruleset,config,nuspec,resx,vsixmanifest,vsct}]
+indent_size = 2
+
+# Code files
+[*.{cs,csx,vb,vbx}]
+indent_size = 4
+insert_final_newline = true
+charset = utf-8-bom
+###############################
+# .NET Coding Conventions     #
+###############################
+[*.{cs,vb}]
+# Organize usings
+dotnet_sort_system_directives_first = true
+# this. preferences
+dotnet_style_qualification_for_field = false:silent
+dotnet_style_qualification_for_property = false:silent
+dotnet_style_qualification_for_method = false:silent
+dotnet_style_qualification_for_event = false:silent
+# Language keywords vs BCL types preferences
+dotnet_style_predefined_type_for_locals_parameters_members = true:silent
+dotnet_style_predefined_type_for_member_access = true:silent
+# Parentheses preferences
+dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent
+dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent
+dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent
+dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent
+# Modifier preferences
+dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent
+dotnet_style_readonly_field = true:suggestion
+# Expression-level preferences
+dotnet_style_object_initializer = true:suggestion
+dotnet_style_collection_initializer = true:suggestion
+dotnet_style_explicit_tuple_names = true:suggestion
+dotnet_style_null_propagation = true:suggestion
+dotnet_style_coalesce_expression = true:suggestion
+dotnet_style_prefer_is_null_check_over_reference_equality_method = true:silent
+dotnet_style_prefer_inferred_tuple_names = true:suggestion
+dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion
+dotnet_style_prefer_auto_properties = true:silent
+dotnet_style_prefer_conditional_expression_over_assignment = true:silent
+dotnet_style_prefer_conditional_expression_over_return = true:silent
+###############################
+# Naming Conventions          #
+###############################
+# Style Definitions
+dotnet_naming_style.pascal_case_style.capitalization             = pascal_case
+# Use PascalCase for constant fields  
+dotnet_naming_rule.constant_fields_should_be_pascal_case.severity = suggestion
+dotnet_naming_rule.constant_fields_should_be_pascal_case.symbols  = constant_fields
+dotnet_naming_rule.constant_fields_should_be_pascal_case.style = pascal_case_style
+dotnet_naming_symbols.constant_fields.applicable_kinds            = field
+dotnet_naming_symbols.constant_fields.applicable_accessibilities  = *
+dotnet_naming_symbols.constant_fields.required_modifiers          = const
+dotnet_style_operator_placement_when_wrapping = beginning_of_line
+tab_width = 4
+end_of_line = crlf
+dotnet_style_prefer_simplified_boolean_expressions = true:suggestion
+dotnet_style_prefer_compound_assignment = true:suggestion
+dotnet_style_prefer_simplified_interpolation = true:suggestion
+dotnet_style_namespace_match_folder = true:suggestion
+dotnet_style_allow_multiple_blank_lines_experimental = true:silent
+dotnet_style_allow_statement_immediately_after_block_experimental = true:silent
+###############################
+# C# Coding Conventions       #
+###############################
+[*.cs]
+# var preferences
+csharp_style_var_for_built_in_types = true:silent
+csharp_style_var_when_type_is_apparent = true:silent
+csharp_style_var_elsewhere = true:silent
+# Expression-bodied members
+csharp_style_expression_bodied_methods = false:silent
+csharp_style_expression_bodied_constructors = false:silent
+csharp_style_expression_bodied_operators = false:silent
+csharp_style_expression_bodied_properties = true:silent
+csharp_style_expression_bodied_indexers = true:silent
+csharp_style_expression_bodied_accessors = true:silent
+# Pattern matching preferences
+csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
+csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
+# Null-checking preferences
+csharp_style_throw_expression = true:suggestion
+csharp_style_conditional_delegate_call = true:suggestion
+# Modifier preferences
+csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:suggestion
+# Expression-level preferences
+csharp_prefer_braces = true:silent
+csharp_style_deconstructed_variable_declaration = true:suggestion
+csharp_prefer_simple_default_expression = true:suggestion
+csharp_style_pattern_local_over_anonymous_function = true:suggestion
+csharp_style_inlined_variable_declaration = true:suggestion
+###############################
+# C# Formatting Rules         #
+###############################
+# New line preferences
+csharp_new_line_before_open_brace = all
+csharp_new_line_before_else = true
+csharp_new_line_before_catch = true
+csharp_new_line_before_finally = true
+csharp_new_line_before_members_in_object_initializers = true
+csharp_new_line_before_members_in_anonymous_types = true
+csharp_new_line_between_query_expression_clauses = true
+# Indentation preferences
+csharp_indent_case_contents = true
+csharp_indent_switch_labels = true
+csharp_indent_labels = flush_left
+# Space preferences
+csharp_space_after_cast = false
+csharp_space_after_keywords_in_control_flow_statements = true
+csharp_space_between_method_call_parameter_list_parentheses = false
+csharp_space_between_method_declaration_parameter_list_parentheses = false
+csharp_space_between_parentheses = false
+csharp_space_before_colon_in_inheritance_clause = true
+csharp_space_after_colon_in_inheritance_clause = true
+csharp_space_around_binary_operators = before_and_after
+csharp_space_between_method_declaration_empty_parameter_list_parentheses = false
+csharp_space_between_method_call_name_and_opening_parenthesis = false
+csharp_space_between_method_call_empty_parameter_list_parentheses = false
+# Wrapping preferences
+csharp_preserve_single_line_statements = true
+csharp_preserve_single_line_blocks = true
+csharp_style_namespace_declarations= file_scoped:silent
+csharp_using_directive_placement = outside_namespace:silent
+csharp_prefer_simple_using_statement = true:suggestion
+csharp_style_expression_bodied_lambdas = true:silent
+csharp_style_expression_bodied_local_functions = false:silent
+csharp_style_prefer_null_check_over_type_check = true:suggestion
+csharp_style_prefer_index_operator = true:suggestion
+csharp_style_prefer_local_over_anonymous_function = true:suggestion
+csharp_style_prefer_range_operator = true:suggestion
+csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion
+csharp_style_prefer_tuple_swap = true:suggestion
+csharp_style_unused_value_assignment_preference = discard_variable:suggestion
+csharp_style_unused_value_expression_statement_preference = discard_variable:silent
+csharp_prefer_static_local_function = true:suggestion
+csharp_style_allow_embedded_statements_on_same_line_experimental = true:silent
+csharp_style_allow_blank_lines_between_consecutive_braces_experimental = true:silent
+csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = true:silent
+###############################
+# VB Coding Conventions       #
+###############################
+[*.vb]
+# Modifier preferences
+visual_basic_preferred_modifier_order = Partial,Default,Private,Protected,Public,Friend,NotOverridable,Overridable,MustOverride,Overloads,Overrides,MustInherit,NotInheritable,Static,Shared,Shadows,ReadOnly,WriteOnly,Dim,Const,WithEvents,Widening,Narrowing,Custom,Async:suggestion

+ 63 - 0
src/.gitattributes

@@ -0,0 +1,63 @@
+###############################################################################
+# Set default behavior to automatically normalize line endings.
+###############################################################################
+* text=auto
+
+###############################################################################
+# Set default behavior for command prompt diff.
+#
+# This is need for earlier builds of msysgit that does not have it on by
+# default for csharp files.
+# Note: This is only used by command line
+###############################################################################
+#*.cs     diff=csharp
+
+###############################################################################
+# Set the merge driver for project and solution files
+#
+# Merging from the command prompt will add diff markers to the files if there
+# are conflicts (Merging from VS is not affected by the settings below, in VS
+# the diff markers are never inserted). Diff markers may cause the following 
+# file extensions to fail to load in VS. An alternative would be to treat
+# these files as binary and thus will always conflict and require user
+# intervention with every merge. To do so, just uncomment the entries below
+###############################################################################
+#*.sln       merge=binary
+#*.csproj    merge=binary
+#*.vbproj    merge=binary
+#*.vcxproj   merge=binary
+#*.vcproj    merge=binary
+#*.dbproj    merge=binary
+#*.fsproj    merge=binary
+#*.lsproj    merge=binary
+#*.wixproj   merge=binary
+#*.modelproj merge=binary
+#*.sqlproj   merge=binary
+#*.wwaproj   merge=binary
+
+###############################################################################
+# behavior for image files
+#
+# image files are treated as binary by default.
+###############################################################################
+#*.jpg   binary
+#*.png   binary
+#*.gif   binary
+
+###############################################################################
+# diff behavior for common document formats
+# 
+# Convert binary document formats to text before diffing them. This feature
+# is only available from the command line. Turn it on by uncommenting the 
+# entries below.
+###############################################################################
+#*.doc   diff=astextplain
+#*.DOC   diff=astextplain
+#*.docx  diff=astextplain
+#*.DOCX  diff=astextplain
+#*.dot   diff=astextplain
+#*.DOT   diff=astextplain
+#*.pdf   diff=astextplain
+#*.PDF   diff=astextplain
+#*.rtf   diff=astextplain
+#*.RTF   diff=astextplain

+ 363 - 0
src/.gitignore

@@ -0,0 +1,363 @@
+## Ignore Visual Studio temporary files, build results, and
+## files generated by popular Visual Studio add-ons.
+##
+## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
+
+# User-specific files
+*.rsuser
+*.suo
+*.user
+*.userosscache
+*.sln.docstates
+
+# User-specific files (MonoDevelop/Xamarin Studio)
+*.userprefs
+
+# Mono auto generated files
+mono_crash.*
+
+# Build results
+[Dd]ebug/
+[Dd]ebugPublic/
+[Rr]elease/
+[Rr]eleases/
+x64/
+x86/
+[Ww][Ii][Nn]32/
+[Aa][Rr][Mm]/
+[Aa][Rr][Mm]64/
+bld/
+[Bb]in/
+[Oo]bj/
+[Oo]ut/
+[Ll]og/
+[Ll]ogs/
+
+# Visual Studio 2015/2017 cache/options directory
+.vs/
+# Uncomment if you have tasks that create the project's static files in wwwroot
+#wwwroot/
+
+# Visual Studio 2017 auto generated files
+Generated\ Files/
+
+# MSTest test Results
+[Tt]est[Rr]esult*/
+[Bb]uild[Ll]og.*
+
+# NUnit
+*.VisualState.xml
+TestResult.xml
+nunit-*.xml
+
+# Build Results of an ATL Project
+[Dd]ebugPS/
+[Rr]eleasePS/
+dlldata.c
+
+# Benchmark Results
+BenchmarkDotNet.Artifacts/
+
+# .NET Core
+project.lock.json
+project.fragment.lock.json
+artifacts/
+
+# ASP.NET Scaffolding
+ScaffoldingReadMe.txt
+
+# StyleCop
+StyleCopReport.xml
+
+# Files built by Visual Studio
+*_i.c
+*_p.c
+*_h.h
+*.ilk
+*.meta
+*.obj
+*.iobj
+*.pch
+*.pdb
+*.ipdb
+*.pgc
+*.pgd
+*.rsp
+*.sbr
+*.tlb
+*.tli
+*.tlh
+*.tmp
+*.tmp_proj
+*_wpftmp.csproj
+*.log
+*.vspscc
+*.vssscc
+.builds
+*.pidb
+*.svclog
+*.scc
+
+# Chutzpah Test files
+_Chutzpah*
+
+# Visual C++ cache files
+ipch/
+*.aps
+*.ncb
+*.opendb
+*.opensdf
+*.sdf
+*.cachefile
+*.VC.db
+*.VC.VC.opendb
+
+# Visual Studio profiler
+*.psess
+*.vsp
+*.vspx
+*.sap
+
+# Visual Studio Trace Files
+*.e2e
+
+# TFS 2012 Local Workspace
+$tf/
+
+# Guidance Automation Toolkit
+*.gpState
+
+# ReSharper is a .NET coding add-in
+_ReSharper*/
+*.[Rr]e[Ss]harper
+*.DotSettings.user
+
+# TeamCity is a build add-in
+_TeamCity*
+
+# DotCover is a Code Coverage Tool
+*.dotCover
+
+# AxoCover is a Code Coverage Tool
+.axoCover/*
+!.axoCover/settings.json
+
+# Coverlet is a free, cross platform Code Coverage Tool
+coverage*.json
+coverage*.xml
+coverage*.info
+
+# Visual Studio code coverage results
+*.coverage
+*.coveragexml
+
+# NCrunch
+_NCrunch_*
+.*crunch*.local.xml
+nCrunchTemp_*
+
+# MightyMoose
+*.mm.*
+AutoTest.Net/
+
+# Web workbench (sass)
+.sass-cache/
+
+# Installshield output folder
+[Ee]xpress/
+
+# DocProject is a documentation generator add-in
+DocProject/buildhelp/
+DocProject/Help/*.HxT
+DocProject/Help/*.HxC
+DocProject/Help/*.hhc
+DocProject/Help/*.hhk
+DocProject/Help/*.hhp
+DocProject/Help/Html2
+DocProject/Help/html
+
+# Click-Once directory
+publish/
+
+# Publish Web Output
+*.[Pp]ublish.xml
+*.azurePubxml
+# Note: Comment the next line if you want to checkin your web deploy settings,
+# but database connection strings (with potential passwords) will be unencrypted
+*.pubxml
+*.publishproj
+
+# Microsoft Azure Web App publish settings. Comment the next line if you want to
+# checkin your Azure Web App publish settings, but sensitive information contained
+# in these scripts will be unencrypted
+PublishScripts/
+
+# NuGet Packages
+*.nupkg
+# NuGet Symbol Packages
+*.snupkg
+# The packages folder can be ignored because of Package Restore
+**/[Pp]ackages/*
+# except build/, which is used as an MSBuild target.
+!**/[Pp]ackages/build/
+# Uncomment if necessary however generally it will be regenerated when needed
+#!**/[Pp]ackages/repositories.config
+# NuGet v3's project.json files produces more ignorable files
+*.nuget.props
+*.nuget.targets
+
+# Microsoft Azure Build Output
+csx/
+*.build.csdef
+
+# Microsoft Azure Emulator
+ecf/
+rcf/
+
+# Windows Store app package directories and files
+AppPackages/
+BundleArtifacts/
+Package.StoreAssociation.xml
+_pkginfo.txt
+*.appx
+*.appxbundle
+*.appxupload
+
+# Visual Studio cache files
+# files ending in .cache can be ignored
+*.[Cc]ache
+# but keep track of directories ending in .cache
+!?*.[Cc]ache/
+
+# Others
+ClientBin/
+~$*
+*~
+*.dbmdl
+*.dbproj.schemaview
+*.jfm
+*.pfx
+*.publishsettings
+orleans.codegen.cs
+
+# Including strong name files can present a security risk
+# (https://github.com/github/gitignore/pull/2483#issue-259490424)
+#*.snk
+
+# Since there are multiple workflows, uncomment next line to ignore bower_components
+# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
+#bower_components/
+
+# RIA/Silverlight projects
+Generated_Code/
+
+# Backup & report files from converting an old project file
+# to a newer Visual Studio version. Backup files are not needed,
+# because we have git ;-)
+_UpgradeReport_Files/
+Backup*/
+UpgradeLog*.XML
+UpgradeLog*.htm
+ServiceFabricBackup/
+*.rptproj.bak
+
+# SQL Server files
+*.mdf
+*.ldf
+*.ndf
+
+# Business Intelligence projects
+*.rdl.data
+*.bim.layout
+*.bim_*.settings
+*.rptproj.rsuser
+*- [Bb]ackup.rdl
+*- [Bb]ackup ([0-9]).rdl
+*- [Bb]ackup ([0-9][0-9]).rdl
+
+# Microsoft Fakes
+FakesAssemblies/
+
+# GhostDoc plugin setting file
+*.GhostDoc.xml
+
+# Node.js Tools for Visual Studio
+.ntvs_analysis.dat
+node_modules/
+
+# Visual Studio 6 build log
+*.plg
+
+# Visual Studio 6 workspace options file
+*.opt
+
+# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
+*.vbw
+
+# Visual Studio LightSwitch build output
+**/*.HTMLClient/GeneratedArtifacts
+**/*.DesktopClient/GeneratedArtifacts
+**/*.DesktopClient/ModelManifest.xml
+**/*.Server/GeneratedArtifacts
+**/*.Server/ModelManifest.xml
+_Pvt_Extensions
+
+# Paket dependency manager
+.paket/paket.exe
+paket-files/
+
+# FAKE - F# Make
+.fake/
+
+# CodeRush personal settings
+.cr/personal
+
+# Python Tools for Visual Studio (PTVS)
+__pycache__/
+*.pyc
+
+# Cake - Uncomment if you are using it
+# tools/**
+# !tools/packages.config
+
+# Tabs Studio
+*.tss
+
+# Telerik's JustMock configuration file
+*.jmconfig
+
+# BizTalk build output
+*.btp.cs
+*.btm.cs
+*.odx.cs
+*.xsd.cs
+
+# OpenCover UI analysis results
+OpenCover/
+
+# Azure Stream Analytics local run output
+ASALocalRun/
+
+# MSBuild Binary and Structured Log
+*.binlog
+
+# NVidia Nsight GPU debugger configuration file
+*.nvuser
+
+# MFractors (Xamarin productivity tool) working folder
+.mfractor/
+
+# Local History for Visual Studio
+.localhistory/
+
+# BeatPulse healthcheck temp database
+healthchecksdb
+
+# Backup folder for Package Reference Convert tool in Visual Studio 2017
+MigrationBackup/
+
+# Ionide (cross platform F# VS Code tools) working folder
+.ionide/
+
+# Fody - auto-generated XML schema
+FodyWeavers.xsd

+ 13 - 0
src/.idea/.idea.PixiEditorPrototype/.idea/.gitignore

@@ -0,0 +1,13 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Rider ignored files
+/.idea.PixiEditorPrototype.iml
+/modules.xml
+/projectSettingsUpdater.xml
+/contentModel.xml
+# Editor-based HTTP Client requests
+/httpRequests/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml

+ 262 - 0
src/.idea/.idea.PixiEditorPrototype/.idea/deployment.xml

@@ -0,0 +1,262 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="PublishConfigData">
+    <serverData>
+      <paths name="Pi">
+        <serverdata>
+          <mappings>
+            <mapping local="$PROJECT_DIR$/../../../../../../Program Files/dotnet/sdk/6.0.300/Sdks/Microsoft.NET.Sdk/analyzers/build/config/analysislevel_6_default.editorconfig" web="/" />
+            <mapping local="$PROJECT_DIR$/../../../../../../Program Files/dotnet/sdk/6.0.300/Sdks/Microsoft.NET.Sdk/analyzers/build/config/analysislevel_6_default.editorconfig" web="/" />
+            <mapping local="$PROJECT_DIR$/../../../../../../Program Files/dotnet/sdk/6.0.300/Sdks/Microsoft.NET.Sdk/analyzers/build/config/analysislevel_6_default.editorconfig" web="/" />
+            <mapping local="$PROJECT_DIR$/../../../../../../Program Files/dotnet/sdk/6.0.300/Sdks/Microsoft.NET.Sdk/analyzers/build/config/analysislevel_6_default.editorconfig" web="/" />
+            <mapping local="$PROJECT_DIR$/../../../../../../Program Files/dotnet/sdk/6.0.300/Sdks/Microsoft.NET.Sdk/analyzers/build/config/analysislevel_6_default.editorconfig" web="/" />
+            <mapping local="$PROJECT_DIR$/../../../../../../Program Files/dotnet/sdk/6.0.300/Sdks/Microsoft.NET.Sdk/analyzers/build/config/analysislevel_6_default.editorconfig" web="/" />
+            <mapping local="$USER_HOME$/.nuget/packages/microsoft.net.test.sdk/16.11.0/build/netcoreapp2.1/Microsoft.NET.Test.Sdk.Program.cs" web="/" />
+            <mapping local="$USER_HOME$/.nuget/packages/microsoft.testplatform.testhost/16.11.0/build/netcoreapp2.1/x64/Microsoft.TestPlatform.PlatformAbstractions.dll" web="/" />
+            <mapping local="$USER_HOME$/.nuget/packages/microsoft.testplatform.testhost/16.11.0/build/netcoreapp2.1/x64/testhost.dll" web="/" />
+            <mapping local="$USER_HOME$/.nuget/packages/microsoft.testplatform.testhost/16.11.0/build/netcoreapp2.1/x64/testhost.exe" web="/" />
+            <mapping local="$USER_HOME$/.nuget/packages/xunit.runner.visualstudio/2.4.3/build/netcoreapp2.1/xunit.runner.reporters.netcoreapp10.dll" web="/" />
+            <mapping local="$USER_HOME$/.nuget/packages/xunit.runner.visualstudio/2.4.3/build/netcoreapp2.1/xunit.runner.utility.netcoreapp10.dll" web="/" />
+            <mapping local="$USER_HOME$/.nuget/packages/xunit.runner.visualstudio/2.4.3/build/netcoreapp2.1/xunit.runner.visualstudio.dotnetcore.testadapter.dll" web="/" />
+            <mapping local="$PROJECT_DIR$/.editorconfig" web="/" />
+            <mapping local="$PROJECT_DIR$/.editorconfig" web="/" />
+            <mapping local="$PROJECT_DIR$/.editorconfig" web="/" />
+            <mapping local="$PROJECT_DIR$/.editorconfig" web="/" />
+            <mapping local="$PROJECT_DIR$/.editorconfig" web="/" />
+            <mapping local="$PROJECT_DIR$/.editorconfig" web="/" />
+            <mapping local="$PROJECT_DIR$/.editorconfig" web="/" />
+            <mapping local="$PROJECT_DIR$/.editorconfig" web="/" />
+            <mapping local="$PROJECT_DIR$/ChunkyImageLib/Chunk.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/ChunkyImageLib/ChunkPool.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/ChunkyImageLib/ChunkyImage.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/ChunkyImageLib/ChunkyImageEx.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/ChunkyImageLib/ChunkyImageLib.csproj" web="/" />
+            <mapping local="$PROJECT_DIR$/ChunkyImageLib/CommittedChunkStorage.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/ChunkyImageLib/DataHolders/ChunkResolution.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/ChunkyImageLib/DataHolders/ChunkResolutionEx.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/ChunkyImageLib/DataHolders/EmptyChunk.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/ChunkyImageLib/DataHolders/FilledChunk.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/ChunkyImageLib/DataHolders/RectD.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/ChunkyImageLib/DataHolders/RectI.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/ChunkyImageLib/DataHolders/ShapeCorners.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/ChunkyImageLib/DataHolders/ShapeData.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/ChunkyImageLib/DataHolders/VecD.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/ChunkyImageLib/DataHolders/VecI.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/ChunkyImageLib/IReadOnlyChunkyImage.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/ChunkyImageLib/Operations/BresenhamLineHelper.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/ChunkyImageLib/Operations/BresenhamLineOperation.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/ChunkyImageLib/Operations/ChunkyImageOperation.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/ChunkyImageLib/Operations/ClearOperation.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/ChunkyImageLib/Operations/ClearRegionOperation.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/ChunkyImageLib/Operations/EllipseHelper.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/ChunkyImageLib/Operations/EllipseOperation.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/ChunkyImageLib/Operations/IDrawOperation.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/ChunkyImageLib/Operations/IOperation.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/ChunkyImageLib/Operations/ImageOperation.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/ChunkyImageLib/Operations/OperationHelper.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/ChunkyImageLib/Operations/PathOperation.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/ChunkyImageLib/Operations/RectangleOperation.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/ChunkyImageLib/Operations/ResizeOperation.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/ChunkyImageLib/Operations/SkiaLineOperation.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/ChunkyImageLib/Surface.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/ChunkyImageLib/obj/Debug/net6.0/ChunkyImageLib.AssemblyInfo.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/ChunkyImageLib/obj/Debug/net6.0/ChunkyImageLib.GeneratedMSBuildEditorConfig.editorconfig" web="/" />
+            <mapping local="$PROJECT_DIR$/ChunkyImageLib/obj/Debug/net6.0/ChunkyImageLib.GlobalUsings.g.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/ChunkyImageLibTest/ChunkyImageLibTest.csproj" web="/" />
+            <mapping local="$PROJECT_DIR$/ChunkyImageLibTest/ChunkyImageTests.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/ChunkyImageLibTest/ClearRegionOperationTests.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/ChunkyImageLibTest/ImageOperationTests.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/ChunkyImageLibTest/OperationHelperTests.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/ChunkyImageLibTest/RectITests.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/ChunkyImageLibTest/RectangleOperationTests.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/ChunkyImageLibTest/obj/Debug/net6.0/ChunkyImageLibTest.AssemblyInfo.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/ChunkyImageLibTest/obj/Debug/net6.0/ChunkyImageLibTest.GeneratedMSBuildEditorConfig.editorconfig" web="/" />
+            <mapping local="$PROJECT_DIR$/ChunkyImageLibVis/App.xaml" web="/" />
+            <mapping local="$PROJECT_DIR$/ChunkyImageLibVis/App.xaml.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/ChunkyImageLibVis/AssemblyInfo.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/ChunkyImageLibVis/ChunkyImageLibVis.csproj" web="/" />
+            <mapping local="$PROJECT_DIR$/ChunkyImageLibVis/MainWindow.xaml" web="/" />
+            <mapping local="$PROJECT_DIR$/ChunkyImageLibVis/MainWindow.xaml.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/ChunkyImageLibVis/obj/Debug/net6.0-windows/App.g.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/ChunkyImageLibVis/obj/Debug/net6.0-windows/ChunkyImageLibVis.AssemblyInfo.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/ChunkyImageLibVis/obj/Debug/net6.0-windows/ChunkyImageLibVis.GeneratedMSBuildEditorConfig.editorconfig" web="/" />
+            <mapping local="$PROJECT_DIR$/ChunkyImageLibVis/obj/Debug/net6.0-windows/MainWindow.g.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument.Gen/bin/Debug/netstandard2.0/PixiEditor.ChangeableDocument.Gen.dll" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument.Gen/ChangeActionGenerator.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument.Gen/Helpers.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument.Gen/MethodInfo.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument.Gen/NamedSourceCode.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument.Gen/NamespacedType.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument.Gen/PixiEditor.ChangeableDocument.Gen.csproj" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument.Gen/Result.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument.Gen/TypeWithName.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument.Gen/UpdateableChangeActionGenerator.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument.Gen/obj/Debug/netstandard2.0/PixiEditor.ChangeableDocument.Gen.AssemblyInfo.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument.Gen/obj/Debug/netstandard2.0/PixiEditor.ChangeableDocument.Gen.GeneratedMSBuildEditorConfig.editorconfig" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument.Gen/obj/Debug/netstandard2.0/PixiEditor.ChangeableDocument.Gen.GlobalUsings.g.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Actions/Attributes/GenerateMakeChangeActionAttribute.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Actions/Attributes/GenerateUpdateableChangeActionsAttribute.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Actions/Attributes/UpdateChangeMethodAttribute.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Actions/IAction.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Actions/IEndChangeAction.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Actions/IMakeChangeAction.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Actions/IStartOrUpdateChangeAction.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Actions/Undo/ChangeBoundary_Action.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Actions/Undo/DeleteRecordedChanges_Action.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Actions/Undo/Redo_Action.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Actions/Undo/Undo_Action.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/ChangeInfos/Drawing/LayerImageChunks_ChangeInfo.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/ChangeInfos/Drawing/MaskChunks_ChangeInfo.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/ChangeInfos/Drawing/Selection_ChangeInfo.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/ChangeInfos/IChangeInfo.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/ChangeInfos/Properties/LayerLockTransparency_ChangeInfo.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/ChangeInfos/Properties/StructureMemberBlendMode_ChangeInfo.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/ChangeInfos/Properties/StructureMemberClipToMemberBelow_ChangeInfo.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/ChangeInfos/Properties/StructureMemberIsVisible_ChangeInfo.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/ChangeInfos/Properties/StructureMemberMaskIsVisible_ChangeInfo.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/ChangeInfos/Properties/StructureMemberMask_ChangeInfo.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/ChangeInfos/Properties/StructureMemberName_ChangeInfo.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/ChangeInfos/Properties/StructureMemberOpacity_ChangeInfo.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/ChangeInfos/Root/Size_ChangeInfo.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/ChangeInfos/Root/SymmetryAxisPosition_ChangeInfo.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/ChangeInfos/Root/SymmetryAxisState_ChangeInfo.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/ChangeInfos/Structure/CreateFolder_ChangeInfo.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/ChangeInfos/Structure/CreateLayer_ChangeInfo.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/ChangeInfos/Structure/CreateStructureMember_ChangeInfo.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/ChangeInfos/Structure/DeleteStructureMember_ChangeInfo.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/ChangeInfos/Structure/MoveStructureMember_ChangeInfo.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Changeables/Document.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Changeables/Folder.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Changeables/IChangeable.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Changeables/Interfaces/IReadOnlyDocument.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Changeables/Interfaces/IReadOnlyFolder.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Changeables/Interfaces/IReadOnlyLayer.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Changeables/Interfaces/IReadOnlySelection.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Changeables/Interfaces/IReadOnlyStructureMember.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Changeables/Layer.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Changeables/Selection.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Changeables/StructureMember.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Changes/Change.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Changes/Drawing/ApplyLayerMask_Change.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Changes/Drawing/ClearSelectedArea_Change.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Changes/Drawing/ClearSelection_Change.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Changes/Drawing/CombineStructureMembersOnto_Change.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Changes/Drawing/DrawEllipse_UpdateableChange.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Changes/Drawing/DrawRectangle_UpdateableChange.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Changes/Drawing/DrawingChangeHelper.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Changes/Drawing/FloodFill/FloodFillChunkStorage.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Changes/Drawing/FloodFill/FloodFillColorBounds.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Changes/Drawing/FloodFill/FloodFillHelper.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Changes/Drawing/FloodFill/FloodFill_Change.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Changes/Drawing/LineBasedPen_UpdateableChange.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Changes/Drawing/PasteImage_UpdateableChange.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Changes/Drawing/PathBasedPen_UpdateableChange.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Changes/Drawing/ShiftLayer_UpdateableChange.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Changes/Properties/CreateStructureMemberMask_Change.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Changes/Properties/DeleteStructureMemberMask_Change.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Changes/Properties/LayerLockTransparency_Change.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Changes/Properties/StructureMemberBlendMode_Change.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Changes/Properties/StructureMemberClipToMemberBelow_Change.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Changes/Properties/StructureMemberIsVisible_Change.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Changes/Properties/StructureMemberMaskIsVisible_Change.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Changes/Properties/StructureMemberName_Change.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Changes/Properties/StructureMemberOpacity_UpdateableChange.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Changes/Root/ResizeCanvas_Change.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Changes/Root/SymmetryAxisPosition_UpdateableChange.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Changes/Root/SymmetryAxisState_Change.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Changes/Selection/SelectLasso_UpdateableChange.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Changes/Selection/SelectRectangle_UpdateableChange.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Changes/Selection/TransformSelectionPath_UpdateableChange.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Changes/Structure/CreateStructureMember_Change.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Changes/Structure/DeleteStructureMember_Change.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Changes/Structure/MoveStructureMember_Change.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Changes/UpdateableChange.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/DocumentChangeTracker.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Enums/BlendMode.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Enums/SelectionMode.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Enums/SelectionModeEx.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Enums/StructureMemberType.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Enums/SymmetryAxisDirection.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/GlobalUsings.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/PixiEditor.ChangeableDocument.csproj" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Rendering/ChunkRenderer.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/Rendering/RenderingContext.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/obj/Debug/net6.0/PixiEditor.ChangeableDocument.AssemblyInfo.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/obj/Debug/net6.0/PixiEditor.ChangeableDocument.GeneratedMSBuildEditorConfig.editorconfig" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.ChangeableDocument/obj/Debug/net6.0/PixiEditor.ChangeableDocument.GlobalUsings.g.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.Zoombox/AssemblyInfo.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.Zoombox/IDragOperation.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.Zoombox/MoveDragOperation.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.Zoombox/PixiEditor.Zoombox.csproj" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.Zoombox/RotateDragOperation.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.Zoombox/ViewportRoutedEventArgs.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.Zoombox/ZoomDragOperation.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.Zoombox/Zoombox.xaml" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.Zoombox/Zoombox.xaml.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.Zoombox/ZoomboxMode.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.Zoombox/obj/Debug/net6.0-windows/PixiEditor.Zoombox.AssemblyInfo.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.Zoombox/obj/Debug/net6.0-windows/PixiEditor.Zoombox.GeneratedMSBuildEditorConfig.editorconfig" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditor.Zoombox/obj/Debug/net6.0-windows/Zoombox.g.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditorPrototype.sln" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditorPrototype/App.xaml" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditorPrototype/App.xaml.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditorPrototype/AssemblyInfo.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditorPrototype/Behaviors/SliderUpdateBehavior.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditorPrototype/Converters/BlendModeToStringConverter.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditorPrototype/Converters/BoolToVisibilityConverter.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditorPrototype/Converters/IndexToChunkResolutionConverter.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditorPrototype/Converters/ScaleToBitmapScalingModeConverter.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditorPrototype/CustomControls/BlendModeComboBox.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditorPrototype/CustomControls/SelectionOverlay.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditorPrototype/CustomControls/SymmetryOverlay/SymmetryAxisDragInfo.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditorPrototype/CustomControls/SymmetryOverlay/SymmetryOverlay.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditorPrototype/CustomControls/TransformOverlay/Anchor.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditorPrototype/CustomControls/TransformOverlay/TransformCornerFreedom.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditorPrototype/CustomControls/TransformOverlay/TransformHelper.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditorPrototype/CustomControls/TransformOverlay/TransformOverlay.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditorPrototype/CustomControls/TransformOverlay/TransformSideFreedom.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditorPrototype/CustomControls/TransformOverlay/TransformState.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditorPrototype/CustomControls/TransformOverlay/TransformUpdateHelper.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditorPrototype/GlobalUsings.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditorPrototype/Models/ActionAccumulator.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditorPrototype/Models/BlendModeEx.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditorPrototype/Models/DocumentHelpers.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditorPrototype/Models/DocumentState.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditorPrototype/Models/DocumentStructureHelper.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditorPrototype/Models/DocumentUpdater.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditorPrototype/Models/IReadOnlyListEx.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditorPrototype/Models/RefreshViewport_PassthroughAction.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditorPrototype/Models/RemoveViewport_PassthroughAction.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditorPrototype/Models/Rendering/AffectedChunkGatherer.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditorPrototype/Models/Rendering/RenderInfos/DirtyRect_RenderInfo.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditorPrototype/Models/Rendering/RenderInfos/IRenderInfo.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditorPrototype/Models/Rendering/RenderInfos/MaskPreviewDirty_RenderInfo.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditorPrototype/Models/Rendering/RenderInfos/PreviewDirty_RenderInfo.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditorPrototype/Models/Rendering/WriteableBitmapUpdater.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditorPrototype/Models/Tool.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditorPrototype/Models/ViewportLocation.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditorPrototype/PixiEditorPrototype.csproj" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditorPrototype/RelayCommand.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditorPrototype/ReverseOrderStackPanel.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditorPrototype/UserControls/Viewport/Viewport.xaml" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditorPrototype/UserControls/Viewport/Viewport.xaml.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditorPrototype/ViewModels/DocumentTransformViewModel.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditorPrototype/ViewModels/DocumentViewModel.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditorPrototype/ViewModels/FolderViewModel.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditorPrototype/ViewModels/LayerViewModel.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditorPrototype/ViewModels/StructureMemberViewModel.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditorPrototype/ViewModels/ViewModelMain.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditorPrototype/Views/MainWindow.xaml" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditorPrototype/Views/MainWindow.xaml.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditorPrototype/obj/Debug/net6.0-windows/App.g.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditorPrototype/obj/Debug/net6.0-windows/GeneratedInternalTypeHelper.g.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditorPrototype/obj/Debug/net6.0-windows/PixiEditorPrototype.AssemblyInfo.cs" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditorPrototype/obj/Debug/net6.0-windows/PixiEditorPrototype.GeneratedMSBuildEditorConfig.editorconfig" web="/" />
+            <mapping local="$PROJECT_DIR$/PixiEditorPrototype/obj/Debug/net6.0-windows/Views/MainWindow.g.cs" web="/" />
+            <mapping local="$PROJECT_DIR$" web="/" />
+          </mappings>
+        </serverdata>
+      </paths>
+    </serverData>
+  </component>
+</project>

+ 8 - 0
src/.idea/.idea.PixiEditorPrototype/.idea/indexLayout.xml

@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="UserContentModel">
+    <attachedFolders />
+    <explicitIncludes />
+    <explicitExcludes />
+  </component>
+</project>

+ 69 - 0
src/ChunkyImageLib/Chunk.cs

@@ -0,0 +1,69 @@
+using ChunkyImageLib.DataHolders;
+using SkiaSharp;
+
+namespace ChunkyImageLib;
+
+public class Chunk : IDisposable
+{
+    private static volatile int chunkCounter = 0;
+    /// <summary>
+    /// The number of chunks that haven't yet been returned (includes garbage collected chunks).
+    /// Used in tests to make sure that all chunks are disposed.
+    /// </summary>
+    public static int ChunkCounter => chunkCounter;
+
+    private bool returned = false;
+    /// <summary>
+    /// The surface of the chunk
+    /// </summary>
+    public Surface Surface { get; }
+    /// <summary>
+    /// The size of the chunk
+    /// </summary>
+    public VecI PixelSize { get; }
+    /// <summary>
+    /// The resolution of the chunk
+    /// </summary>
+    public ChunkResolution Resolution { get; }
+    private Chunk(ChunkResolution resolution)
+    {
+        int size = resolution.PixelSize();
+
+        Resolution = resolution;
+        PixelSize = new(size, size);
+        Surface = new Surface(PixelSize);
+    }
+
+    /// <summary>
+    /// Tries to take a chunk with the <paramref name="resolution"/> from the pool, or creates a new one
+    /// </summary>
+    public static Chunk Create(ChunkResolution resolution = ChunkResolution.Full)
+    {
+        var chunk = ChunkPool.Instance.Get(resolution) ?? new Chunk(resolution);
+        chunk.returned = false;
+        Interlocked.Increment(ref chunkCounter);
+        return chunk;
+    }
+
+    /// <summary>
+    /// Draw's on the <see cref="Surface"/> of the chunk
+    /// </summary>
+    /// <param name="pos">The destination for the <paramref name="surface"/></param>
+    /// <param name="paint">The paint to use while drawing</param>
+    public void DrawOnSurface(SKSurface surface, VecI pos, SKPaint? paint = null)
+    {
+        surface.Canvas.DrawSurface(Surface.SkiaSurface, pos.X, pos.Y, paint);
+    }
+
+    /// <summary>
+    /// Returns the chunk back to the pool
+    /// </summary>
+    public void Dispose()
+    {
+        if (returned)
+            return;
+        returned = true;
+        Interlocked.Decrement(ref chunkCounter);
+        ChunkPool.Instance.Push(this);
+    }
+}

+ 66 - 0
src/ChunkyImageLib/ChunkPool.cs

@@ -0,0 +1,66 @@
+using ChunkyImageLib.DataHolders;
+using System.Collections.Concurrent;
+
+namespace ChunkyImageLib;
+
+internal class ChunkPool
+{
+    //must be divisible by 8
+    public const int FullChunkSize = 256;
+
+    private static object lockObj = new();
+    private static ChunkPool? instance;
+    /// <summary>
+    /// The instance of the <see cref="ChunkPool"/>
+    /// </summary>
+    public static ChunkPool Instance
+    {
+        get
+        {
+            if (instance is null)
+            {
+                lock (lockObj)
+                {
+                    instance ??= new ChunkPool();
+                }
+            }
+            return instance;
+        }
+    }
+
+    private readonly ConcurrentBag<Chunk> fullChunks = new();
+    private readonly ConcurrentBag<Chunk> halfChunks = new();
+    private readonly ConcurrentBag<Chunk> quarterChunks = new();
+    private readonly ConcurrentBag<Chunk> eighthChunks = new();
+    
+    /// <summary>
+    /// Tries to take a chunk from the pool, returns null if there's no Chunk available
+    /// </summary>
+    /// <param name="resolution">The resolution for the chunk</param>
+    internal Chunk? Get(ChunkResolution resolution) => GetBag(resolution).TryTake(out Chunk? item) ? item : null;
+
+    private ConcurrentBag<Chunk> GetBag(ChunkResolution resolution)
+    {
+        return resolution switch
+        {
+            ChunkResolution.Full => fullChunks,
+            ChunkResolution.Half => halfChunks,
+            ChunkResolution.Quarter => quarterChunks,
+            ChunkResolution.Eighth => eighthChunks,
+            _ => fullChunks
+        };
+    }
+
+    /// <summary>
+    /// Returns a chunk back to the pool
+    /// </summary>
+    internal void Push(Chunk chunk)
+    {
+        var chunks = GetBag(chunk.Resolution);
+        //a race condition can cause the count to go above 200, but likely not by much
+        if (chunks.Count < 200)
+            chunks.Add(chunk);
+        else
+            chunk.Surface.Dispose();
+    }
+}

+ 1142 - 0
src/ChunkyImageLib/ChunkyImage.cs

@@ -0,0 +1,1142 @@
+using System.Runtime.CompilerServices;
+using ChunkyImageLib.DataHolders;
+using ChunkyImageLib.Operations;
+using OneOf;
+using OneOf.Types;
+using SkiaSharp;
+
+[assembly: InternalsVisibleTo("ChunkyImageLibTest")]
+
+namespace ChunkyImageLib;
+
+/// <summary>
+/// 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:
+///     - SKBlendMode.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.
+/// </summary>
+public class ChunkyImage : IReadOnlyChunkyImage, IDisposable
+{
+    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;
+
+    public const int FullChunkSize = ChunkPool.FullChunkSize;
+    private static SKPaint ClippingPaint { get; } = new SKPaint() { BlendMode = SKBlendMode.DstIn };
+    private static SKPaint InverseClippingPaint { get; } = new SKPaint() { BlendMode = SKBlendMode.DstOut };
+    private static SKPaint ReplacingPaint { get; } = new SKPaint() { BlendMode = SKBlendMode.Src };
+    private static SKPaint SmoothReplacingPaint { get; } = new SKPaint() { BlendMode = SKBlendMode.Src, FilterQuality = SKFilterQuality.Medium };
+    private static SKPaint AddingPaint { get; } = new SKPaint() { BlendMode = SKBlendMode.Plus };
+    private readonly SKPaint blendModePaint = new SKPaint() { BlendMode = SKBlendMode.Src };
+
+    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, HashSet<VecI> affectedChunks)> queuedOperations = new();
+    private readonly List<ChunkyImage> activeClips = new();
+    private SKBlendMode blendMode = SKBlendMode.Src;
+    private bool lockTransparency = false;
+    private SKPath? clippingPath;
+    private int? horizontalSymmetryAxis = null;
+    private int? verticalSymmetryAxis = null;
+
+    private readonly Dictionary<ChunkResolution, Dictionary<VecI, Chunk>> committedChunks;
+    private readonly Dictionary<ChunkResolution, Dictionary<VecI, Chunk>> latestChunks;
+    private readonly Dictionary<ChunkResolution, Dictionary<VecI, LatestChunkData>> 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(),
+        };
+    }
+
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
+    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;
+        }
+    }
+
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
+    public SKColor GetCommittedPixel(VecI posOnImage)
+    {
+        lock (lockObject)
+        {
+            ThrowIfDisposed();
+            var chunkPos = OperationHelper.GetChunkPos(posOnImage, FullChunkSize);
+            var posInChunk = posOnImage - chunkPos * FullChunkSize;
+            return MaybeGetCommittedChunk(chunkPos, ChunkResolution.Full) switch
+            {
+                null => SKColors.Transparent,
+                var chunk => chunk.Surface.GetSRGBPixel(posInChunk)
+            };
+        }
+    }
+    
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
+    public SKColor 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 => SKColors.Transparent,
+                    _ => committedChunk.Surface.GetSRGBPixel(posInChunk)
+                };
+            }
+
+            // something is queued, blend mode is Src so no merging needed
+            if (blendMode == SKBlendMode.Src)
+            {
+                Chunk? latestChunk = GetLatestChunk(chunkPos, ChunkResolution.Full);
+                return latestChunk switch
+                {
+                    null => SKColors.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);
+                SKColor committedColor = committedChunk is null ? 
+                    SKColors.Transparent :  
+                    committedChunk.Surface.GetSRGBPixel(posInChunk);
+                SKColor latestColor = latestChunk is null ?
+                    SKColors.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 SKPaint committedPaint = new SKPaint() { Color = committedColor, BlendMode = SKBlendMode.Src };
+                using SKPaint latestPaint = new SKPaint() { Color = latestColor, BlendMode = this.blendMode };
+                tempChunk.Surface.SkiaSurface.Canvas.DrawPoint(VecI.Zero, committedPaint);
+                tempChunk.Surface.SkiaSurface.Canvas.DrawPoint(VecI.Zero, latestPaint);
+                return tempChunk.Surface.GetSRGBPixel(VecI.Zero);
+            }
+        }
+    }
+    
+    /// <returns>
+    /// True if the chunk existed and was drawn, otherwise false
+    /// </returns>
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
+    public bool DrawMostUpToDateChunkOn(VecI chunkPos, ChunkResolution resolution, SKSurface surface, VecI pos, SKPaint? paint = null)
+    {
+        lock (lockObject)
+        {
+            ThrowIfDisposed();
+            OneOf<None, EmptyChunk, Chunk> 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 != SKBlendMode.Src)
+            {
+                if (committedChunk is null)
+                    return false;
+                committedChunk.DrawOnSurface(surface, pos, paint);
+                return true;
+            }
+
+            // no need to combine with committed, draw directly
+            if (blendMode == SKBlendMode.Src || committedChunk is null)
+            {
+                if (latestChunk.IsT2)
+                {
+                    latestChunk.AsT2.DrawOnSurface(surface, pos, paint);
+                    return true;
+                }
+
+                return false;
+            }
+
+            // combine with committed and then draw
+            using var tempChunk = Chunk.Create(resolution);
+            tempChunk.Surface.SkiaSurface.Canvas.DrawSurface(committedChunk.Surface.SkiaSurface, 0, 0, ReplacingPaint);
+            blendModePaint.BlendMode = blendMode;
+            tempChunk.Surface.SkiaSurface.Canvas.DrawSurface(latestChunk.AsT2.Surface.SkiaSurface, 0, 0, blendModePaint);
+            if (lockTransparency)
+                OperationHelper.ClampAlpha(tempChunk.Surface.SkiaSurface, committedChunk.Surface.SkiaSurface);
+            tempChunk.DrawOnSurface(surface, pos, paint);
+
+            return true;
+        }
+    }
+
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
+    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.affectedChunks.Contains(chunkPos))
+                    return true;
+            }
+
+            return false;
+        }
+    }
+
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
+    internal bool DrawCommittedChunkOn(VecI chunkPos, ChunkResolution resolution, SKSurface surface, VecI pos, SKPaint? paint = null)
+    {
+        lock (lockObject)
+        {
+            ThrowIfDisposed();
+            var chunk = GetCommittedChunk(chunkPos, resolution);
+            if (chunk is null)
+                return false;
+            chunk.DrawOnSurface(surface, pos, paint);
+            return true;
+        }
+    }
+
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
+    internal bool CommittedChunkExists(VecI chunkPos)
+    {
+        lock (lockObject)
+        {
+            ThrowIfDisposed();
+            return MaybeGetCommittedChunk(chunkPos, ChunkResolution.Full) is not null;
+        }
+    }
+
+    /// <summary>
+    /// 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.
+    /// </summary>
+    private Chunk? GetLatestChunk(VecI pos, ChunkResolution resolution)
+    {
+        if (queuedOperations.Count == 0)
+            return null;
+
+        MaybeCreateAndProcessQueueForChunk(pos, resolution);
+        var maybeNewlyProcessedChunk = MaybeGetLatestChunk(pos, resolution);
+        return maybeNewlyProcessedChunk;
+    }
+
+    /// <summary>
+    /// 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.
+    /// </summary>
+    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;
+
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
+    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);
+        }
+    }
+
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
+    public void SetClippingPath(SKPath 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;
+        }
+    }
+
+    /// <summary>
+    /// Porter duff compositing operators (apart from SrcOver) likely won't have the intended effect.
+    /// </summary>
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
+    public void SetBlendMode(SKBlendMode 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;
+        }
+    }
+
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
+    public void SetHorizontalAxisOfSymmetry(int 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;
+        }
+    }
+
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
+    public void SetVerticalAxisOfSymmetry(int 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;
+        }
+    }
+
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
+    public void EnableLockTransparency()
+    {
+        lock (lockObject)
+        {
+            ThrowIfDisposed();
+            lockTransparency = true;
+        }
+    }
+
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
+    public void EnqueueDrawRectangle(ShapeData rect)
+    {
+        lock (lockObject)
+        {
+            ThrowIfDisposed();
+            RectangleOperation operation = new(rect);
+            EnqueueOperation(operation);
+        }
+    }
+
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
+    public void EnqueueDrawEllipse(RectI location, SKColor strokeColor, SKColor fillColor, int strokeWidth, SKPaint? paint = null)
+    {
+        lock (lockObject)
+        {
+            ThrowIfDisposed();
+            EllipseOperation operation = new(location, strokeColor, fillColor, strokeWidth, paint);
+            EnqueueOperation(operation);
+        }
+    }
+
+    /// <summary>
+    /// 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.
+    /// </summary>
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
+    public void EnqueueDrawImage(ShapeCorners corners, Surface image, SKPaint? paint = null, bool copyImage = true)
+    {
+        lock (lockObject)
+        {
+            ThrowIfDisposed();
+            ImageOperation operation = new(corners, image, paint, copyImage);
+            EnqueueOperation(operation);
+        }
+    }
+
+    /// <summary>
+    /// 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.
+    /// </summary>
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
+    public void EnqueueDrawImage(VecI pos, Surface image, SKPaint? paint = null, bool copyImage = true)
+    {
+        lock (lockObject)
+        {
+            ThrowIfDisposed();
+            ImageOperation operation = new(pos, image, paint, copyImage);
+            EnqueueOperation(operation);
+        }
+    }
+
+    /// <param name="customBounds">Bounds used for affected chunks, will be computed from path in O(n) if null is passed</param>
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
+    public void EnqueueDrawPath(SKPath path, SKColor color, float strokeWidth, SKStrokeCap strokeCap, SKBlendMode blendMode, RectI? customBounds = null)
+    {
+        lock (lockObject)
+        {
+            ThrowIfDisposed();
+            PathOperation operation = new(path, color, strokeWidth, strokeCap, blendMode, customBounds);
+            EnqueueOperation(operation);
+        }
+    }
+
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
+    public void EnqueueDrawBresenhamLine(VecI from, VecI to, SKColor color, SKBlendMode blendMode)
+    {
+        lock (lockObject)
+        {
+            ThrowIfDisposed();
+            BresenhamLineOperation operation = new(from, to, color, blendMode);
+            EnqueueOperation(operation);
+        }
+    }
+
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
+    public void EnqueueDrawSkiaLine(VecI from, VecI to, SKStrokeCap strokeCap, float strokeWidth, SKColor color, SKBlendMode blendMode)
+    {
+        lock (lockObject)
+        {
+            ThrowIfDisposed();
+            SkiaLineOperation operation = new(from, to, strokeCap, strokeWidth, color, blendMode);
+            EnqueueOperation(operation);
+        }
+    }
+
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
+    public void EnqueueDrawPixels(IEnumerable<VecI> pixels, SKColor color, SKBlendMode blendMode)
+    {
+        lock (lockObject)
+        {
+            ThrowIfDisposed();
+            PixelsOperation operation = new(pixels, color, blendMode);
+            EnqueueOperation(operation);
+        }
+    }
+
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
+    public void EnqueueDrawPixel(VecI pos, SKColor color, SKBlendMode blendMode)
+    {
+        lock (lockObject)
+        {
+            ThrowIfDisposed();
+            PixelOperation operation = new(pos, color, blendMode);
+            EnqueueOperation(operation);
+        }
+    }
+
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
+    public void EnqueueDrawChunkyImage(VecI pos, ChunkyImage image, bool flipHor = false, bool flipVer = false)
+    {
+        lock (lockObject)
+        {
+            ThrowIfDisposed();
+            ChunkyImageOperation operation = new(image, pos, flipHor, flipVer);
+            EnqueueOperation(operation);
+        }
+    }
+
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
+    public void EnqueueClearRegion(RectI region)
+    {
+        lock (lockObject)
+        {
+            ThrowIfDisposed();
+            ClearRegionOperation operation = new(region);
+            EnqueueOperation(operation);
+        }
+    }
+
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
+    public void EnqueueClear()
+    {
+        lock (lockObject)
+        {
+            ThrowIfDisposed();
+            ClearOperation operation = new();
+            EnqueueOperation(operation, FindAllChunks());
+        }
+    }
+
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
+    public void EnqueueResize(VecI newSize)
+    {
+        lock (lockObject)
+        {
+            ThrowIfDisposed();
+            ResizeOperation operation = new(newSize);
+            LatestSize = newSize;
+            EnqueueOperation(operation, FindAllChunksOutsideBounds(newSize));
+        }
+    }
+
+    private void EnqueueOperation(IDrawOperation operation)
+    {
+        List<IDrawOperation> operations = new(4) { operation };
+
+        if (horizontalSymmetryAxis is not null && verticalSymmetryAxis is not null)
+            operations.Add(operation.AsMirrored(verticalSymmetryAxis, horizontalSymmetryAxis));
+        if (horizontalSymmetryAxis is not null)
+            operations.Add(operation.AsMirrored(null, horizontalSymmetryAxis));
+        if (verticalSymmetryAxis is not null)
+            operations.Add(operation.AsMirrored(verticalSymmetryAxis, null));
+
+        foreach (var op in operations)
+        {
+            var chunks = op.FindAffectedChunks();
+            chunks.RemoveWhere(pos => IsOutsideBounds(pos, LatestSize));
+            if (operation.IgnoreEmptyChunks)
+                chunks.IntersectWith(FindAllChunks());
+            EnqueueOperation(op, chunks);
+        }
+    }
+
+    private void EnqueueOperation(IOperation operation, HashSet<VecI> chunks)
+    {
+        queuedOperations.Add((operation, chunks));
+    }
+
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
+    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 = SKBlendMode.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();
+            }
+        }
+    }
+
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
+    public void CommitChanges()
+    {
+        lock (lockObject)
+        {
+            ThrowIfDisposed();
+            var affectedChunks = FindAffectedChunks();
+            foreach (var chunk in affectedChunks)
+            {
+                MaybeCreateAndProcessQueueForChunk(chunk, ChunkResolution.Full);
+            }
+
+            foreach (var (operation, _) in queuedOperations)
+            {
+                operation.Dispose();
+            }
+
+            CommitLatestChunks();
+            CommittedSize = LatestSize;
+            queuedOperations.Clear();
+            activeClips.Clear();
+            blendMode = SKBlendMode.Src;
+            lockTransparency = false;
+            horizontalSymmetryAxis = null;
+            verticalSymmetryAxis = null;
+            clippingPath = null;
+
+            commitCounter++;
+            if (commitCounter % 30 == 0)
+                FindAndDeleteEmptyCommittedChunks();
+        }
+    }
+
+    /// <summary>
+    /// Does all necessary steps to convert latest chunks into committed ones. The latest chunk dictionary become empty after this function is called.
+    /// </summary>
+    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 == SKBlendMode.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.SkiaSurface.Canvas.DrawSurface(maybeCommitted.Surface.SkiaSurface, 0, 0, ReplacingPaint);
+                        maybeCommitted.Surface.SkiaSurface.Canvas.DrawSurface(chunk.Surface.SkiaSurface, 0, 0, blendModePaint);
+                        OperationHelper.ClampAlpha(maybeCommitted.Surface.SkiaSurface, tempChunk.Surface.SkiaSurface);
+                    }
+                    else
+                    {
+                        maybeCommitted.Surface.SkiaSurface.Canvas.DrawSurface(chunk.Surface.SkiaSurface, 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();
+        }
+    }
+
+    /// <returns>
+    /// All chunks that have something in them, including latest (uncommitted) ones
+    /// </returns>
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
+    public HashSet<VecI> FindAllChunks()
+    {
+        lock (lockObject)
+        {
+            ThrowIfDisposed();
+            var allChunks = committedChunks[ChunkResolution.Full].Select(chunk => chunk.Key).ToHashSet();
+            foreach (var (_, opChunks) in queuedOperations)
+            {
+                allChunks.UnionWith(opChunks);
+            }
+
+            return allChunks;
+        }
+    }
+
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
+    public HashSet<VecI> FindCommittedChunks()
+    {
+        lock (lockObject)
+        {
+            ThrowIfDisposed();
+            return committedChunks[ChunkResolution.Full].Select(chunk => chunk.Key).ToHashSet();
+        }
+    }
+
+    /// <returns>
+    /// Chunks affected by operations that haven't been committed yet
+    /// </returns>
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
+    public HashSet<VecI> FindAffectedChunks(int fromOperationIndex = 0)
+    {
+        lock (lockObject)
+        {
+            ThrowIfDisposed();
+            var chunks = new HashSet<VecI>();
+            for (int i = fromOperationIndex; i < queuedOperations.Count; i++)
+            {
+                var (_, opChunks) = queuedOperations[i];
+                chunks.UnionWith(opChunks);
+            }
+
+            return chunks;
+        }
+    }
+
+    /// <summary>
+    /// 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.
+    /// </summary>
+    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<FilledChunk, EmptyChunk, Chunk> combinedRasterClips = new FilledChunk();
+
+        bool initialized = false;
+
+        for (int i = 0; i < queuedOperations.Count; i++)
+        {
+            var (operation, operChunks) = queuedOperations[i];
+            if (!operChunks.Contains(chunkPos))
+                continue;
+
+            if (!initialized)
+            {
+                initialized = true;
+                targetChunk = GetOrCreateLatestChunk(chunkPos, resolution);
+                combinedRasterClips = CombineRasterClipsForChunk(chunkPos, resolution);
+            }
+
+            if (chunkData.QueueProgress <= i)
+                chunkData.IsDeleted = ApplyOperationToChunk(operation, 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.SkiaSurface, committed!.Surface.SkiaSurface);
+            }
+
+            chunkData.QueueProgress = queuedOperations.Count;
+            latestChunksData[resolution][chunkPos] = chunkData;
+        }
+
+        if (combinedRasterClips.TryPickT2(out Chunk value, out var _))
+            value.Dispose();
+    }
+
+    private OneOf<FilledChunk, EmptyChunk, Chunk> 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.SkiaSurface.Canvas.Clear(SKColors.White);
+
+        foreach (var mask in activeClips)
+        {
+            if (mask.CommittedChunkExists(chunkPos))
+            {
+                mask.DrawCommittedChunkOn(chunkPos, resolution, intersection.Surface.SkiaSurface, VecI.Zero, ClippingPaint);
+            }
+            else
+            {
+                intersection.Dispose();
+                return new EmptyChunk();
+            }
+        }
+
+        return intersection;
+    }
+
+    /// <returns>
+    /// True if the chunk was fully cleared (and should be deleted).
+    /// </returns>
+    private bool ApplyOperationToChunk(
+        IOperation operation,
+        OneOf<FilledChunk, EmptyChunk, Chunk> 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.SkiaSurface.Canvas.Clear();
+
+            // just regular drawing
+            if (combinedRasterClips.IsT0) //Everything is visible as far as raster clips are concerned
+            {
+                CallDrawWithClip(chunkOperation, targetChunk, resolution, chunkPos);
+                return false;
+            }
+
+            // drawing with raster clipping
+            var clip = combinedRasterClips.AsT2;
+
+            using var tempChunk = Chunk.Create(targetChunk.Resolution);
+            targetChunk.DrawOnSurface(tempChunk.Surface.SkiaSurface, VecI.Zero, ReplacingPaint);
+
+            CallDrawWithClip(chunkOperation, tempChunk, resolution, chunkPos);
+
+            clip.DrawOnSurface(tempChunk.Surface.SkiaSurface, VecI.Zero, ClippingPaint);
+            clip.DrawOnSurface(targetChunk.Surface.SkiaSurface, VecI.Zero, InverseClippingPaint);
+
+            tempChunk.DrawOnSurface(targetChunk.Surface.SkiaSurface, VecI.Zero, AddingPaint);
+            return false;
+        }
+
+        if (operation is ResizeOperation resizeOperation)
+        {
+            return IsOutsideBounds(chunkPos, resizeOperation.Size);
+        }
+
+        return chunkData.IsDeleted;
+    }
+
+    private void CallDrawWithClip(IDrawOperation operation, Chunk targetChunk, ChunkResolution resolution, VecI chunkPos)
+    {
+        if (clippingPath is not null && !clippingPath.IsEmpty)
+        {
+            int count = targetChunk.Surface.SkiaSurface.Canvas.Save();
+
+            using SKPath transformedPath = new(clippingPath);
+            float scale = (float)resolution.Multiplier();
+            VecD trans = -chunkPos * FullChunkSize * scale;
+            transformedPath.Transform(SKMatrix.CreateScaleTranslation(scale, scale, (float)trans.X, (float)trans.Y));
+            targetChunk.Surface.SkiaSurface.Canvas.ClipPath(transformedPath);
+            operation.DrawOnChunk(targetChunk, chunkPos);
+            targetChunk.Surface.SkiaSurface.Canvas.RestoreToCount(count);
+        }
+        else
+        {
+            operation.DrawOnChunk(targetChunk, chunkPos);
+        }
+    }
+
+    /// <summary>
+    /// 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).
+    /// </summary>
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
+    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<VecI> 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<VecI> 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);
+        }
+    }
+
+    /// <summary>
+    /// Gets existing committed chunk or creates a new one. Doesn't apply any operations to the chunk, returns it as it is.
+    /// </summary>
+    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.SkiaSurface.Canvas.Save();
+            newChunk.Surface.SkiaSurface.Canvas.Scale((float)resolution.Multiplier());
+
+            newChunk.Surface.SkiaSurface.Canvas.DrawSurface(existingFullResChunk.Surface.SkiaSurface, 0, 0, SmoothReplacingPaint);
+            newChunk.Surface.SkiaSurface.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;
+        }
+    }
+
+    /// <summary>
+    /// 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.
+    /// </summary>
+    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 == SKBlendMode.Src)
+                maybeCommittedAnyRes.Surface.CopyTo(newChunk.Surface);
+            else
+                newChunk.Surface.SkiaSurface.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.SkiaSurface.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;
+    }
+
+    ~ChunkyImage()
+    {
+        DisposeAll();
+    }
+}

+ 30 - 0
src/ChunkyImageLib/ChunkyImageEx.cs

@@ -0,0 +1,30 @@
+using ChunkyImageLib.DataHolders;
+using ChunkyImageLib.Operations;
+using SkiaSharp;
+
+namespace ChunkyImageLib;
+public static class IReadOnlyChunkyImageEx
+{
+    public static void DrawMostUpToDateRegionOn
+        (this IReadOnlyChunkyImage image, RectI fullResRegion, ChunkResolution resolution, SKSurface surface, VecI pos, SKPaint? paint = null)
+    {
+        surface.Canvas.Save();
+        surface.Canvas.ClipRect(SKRect.Create(pos, fullResRegion.Size));
+
+        VecI chunkTopLeft = OperationHelper.GetChunkPos(fullResRegion.TopLeft, ChunkyImage.FullChunkSize);
+        VecI chunkBotRigth = OperationHelper.GetChunkPos(fullResRegion.BottomRight, ChunkyImage.FullChunkSize);
+        VecI offsetFullRes = (chunkTopLeft * ChunkyImage.FullChunkSize) - fullResRegion.Pos;
+        VecI offsetTargetRes = (VecI)(offsetFullRes * resolution.Multiplier());
+
+        for (int j = chunkTopLeft.Y; j <= chunkBotRigth.Y; j++)
+        {
+            for (int i = chunkTopLeft.X; i <= chunkBotRigth.X; i++)
+            {
+                var chunkPos = new VecI(i, j);
+                image.DrawMostUpToDateChunkOn(chunkPos, resolution, surface, offsetTargetRes + (chunkPos - chunkTopLeft) * resolution.PixelSize() + pos, paint);
+            }
+        }
+
+        surface.Canvas.Restore();
+    }
+}

+ 16 - 0
src/ChunkyImageLib/ChunkyImageLib.csproj

@@ -0,0 +1,16 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <TargetFramework>net6.0</TargetFramework>
+    <ImplicitUsings>enable</ImplicitUsings>
+    <Nullable>enable</Nullable>
+    <WarningsAsErrors>Nullable</WarningsAsErrors>
+    <AllowUnsafeBlocks>True</AllowUnsafeBlocks>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <PackageReference Include="OneOf" Version="3.0.216" />
+    <PackageReference Include="SkiaSharp" Version="2.80.3" />
+  </ItemGroup>
+
+</Project>

+ 51 - 0
src/ChunkyImageLib/CommittedChunkStorage.cs

@@ -0,0 +1,51 @@
+using ChunkyImageLib.DataHolders;
+using SkiaSharp;
+
+namespace ChunkyImageLib;
+
+public class CommittedChunkStorage : IDisposable
+{
+    private bool disposed = false;
+    private List<(VecI, Chunk?)> savedChunks = new();
+    private static SKPaint ReplacingPaint { get; } = new SKPaint() { BlendMode = SKBlendMode.Src };
+
+    public CommittedChunkStorage(ChunkyImage image, HashSet<VecI> committedChunksToSave)
+    {
+        foreach (var chunkPos in committedChunksToSave)
+        {
+            Chunk copy = Chunk.Create();
+            if (!image.DrawCommittedChunkOn(chunkPos, ChunkResolution.Full, copy.Surface.SkiaSurface, VecI.Zero, ReplacingPaint))
+            {
+                copy.Dispose();
+                savedChunks.Add((chunkPos, null));
+                continue;
+            }
+            savedChunks.Add((chunkPos, copy));
+        }
+    }
+
+    public void ApplyChunksToImage(ChunkyImage image)
+    {
+        if (disposed)
+            throw new ObjectDisposedException(nameof(CommittedChunkStorage));
+        foreach (var (pos, chunk) in savedChunks)
+        {
+            if (chunk is null)
+                image.EnqueueClearRegion(new(pos * ChunkPool.FullChunkSize, new(ChunkPool.FullChunkSize, ChunkPool.FullChunkSize)));
+            else
+                image.EnqueueDrawImage(pos * ChunkPool.FullChunkSize, chunk.Surface, ReplacingPaint);
+        }
+    }
+
+    public void Dispose()
+    {
+        if (disposed)
+            return;
+        disposed = true;
+        foreach (var (_, chunk) in savedChunks)
+        {
+            if (chunk is not null)
+                chunk.Dispose();
+        }
+    }
+}

+ 22 - 0
src/ChunkyImageLib/DataHolders/ChunkResolution.cs

@@ -0,0 +1,22 @@
+namespace ChunkyImageLib.DataHolders;
+
+[Flags]
+public enum ChunkResolution
+{
+    /// <summary>
+    /// The full resolution of the chunk
+    /// </summary>
+    Full = 1,
+    /// <summary>
+    /// Half of the chunks resolution
+    /// </summary>
+    Half = 2,
+    /// <summary>
+    /// A quarter of the chunks resolution
+    /// </summary>
+    Quarter = 4,
+    /// <summary>
+    /// An eighth of the chunks resolution
+    /// </summary>
+    Eighth = 8
+}

+ 35 - 0
src/ChunkyImageLib/DataHolders/ChunkResolutionEx.cs

@@ -0,0 +1,35 @@
+namespace ChunkyImageLib.DataHolders;
+
+public static class ChunkResolutionEx
+{
+    /// <summary>
+    /// Returns the multiplier of the <paramref name="resolution"/>.
+    /// </summary>
+    public static double Multiplier(this ChunkResolution resolution)
+    {
+        return resolution switch
+        {
+            ChunkResolution.Full => 1.0,
+            ChunkResolution.Half => 1.0 / 2,
+            ChunkResolution.Quarter => 1.0 / 4,
+            ChunkResolution.Eighth => 1.0 / 8,
+            _ => 1,
+        };
+    }
+
+    /// <summary>
+    /// Returns the <see cref="ChunkPool.FullChunkSize"/> for the <paramref name="resolution"/>
+    /// </summary>
+    /// <seealso cref="ChunkPool"/>
+    public static int PixelSize(this ChunkResolution resolution)
+    {
+        return resolution switch
+        {
+            ChunkResolution.Full => ChunkPool.FullChunkSize,
+            ChunkResolution.Half => ChunkPool.FullChunkSize / 2,
+            ChunkResolution.Quarter => ChunkPool.FullChunkSize / 4,
+            ChunkResolution.Eighth => ChunkPool.FullChunkSize / 8,
+            _ => ChunkPool.FullChunkSize
+        };
+    }
+}

+ 4 - 0
src/ChunkyImageLib/DataHolders/EmptyChunk.cs

@@ -0,0 +1,4 @@
+namespace ChunkyImageLib.DataHolders;
+public struct EmptyChunk
+{
+}

+ 4 - 0
src/ChunkyImageLib/DataHolders/FilledChunk.cs

@@ -0,0 +1,4 @@
+namespace ChunkyImageLib.DataHolders;
+public struct FilledChunk
+{
+}

+ 380 - 0
src/ChunkyImageLib/DataHolders/RectD.cs

@@ -0,0 +1,380 @@
+using SkiaSharp;
+
+namespace ChunkyImageLib.DataHolders;
+public struct RectD : IEquatable<RectD>
+{
+    public static RectD Empty { get; } = new RectD();
+
+    private double left;
+    private double top;
+    private double right;
+    private double bottom;
+
+    public double Left { readonly get => left; set => left = value; }
+    public double Top { readonly get => top; set => top = value; }
+    public double Right { readonly get => right; set => right = value; }
+    public double Bottom { readonly get => bottom; set => bottom = value; }
+    public double X { readonly get => left; set => left = value; }
+    public double Y { readonly get => top; set => top = value; }
+    public bool HasNaNOrInfinity =>
+        double.IsNaN(left) || double.IsInfinity(left) ||
+        double.IsNaN(right) || double.IsInfinity(right) ||
+        double.IsNaN(top) || double.IsInfinity(top) ||
+        double.IsNaN(bottom) || double.IsInfinity(bottom);
+
+    public VecD Pos
+    {
+        readonly get => new VecD(left, top);
+        set
+        {
+            right = (right - left) + value.X;
+            bottom = (bottom - top) + value.Y;
+            left = value.X;
+            top = value.Y;
+        }
+    }
+    public VecD TopLeft
+    {
+        readonly get => new VecD(left, top);
+        set
+        {
+            left = value.X;
+            top = value.Y;
+        }
+    }
+    public VecD TopRight
+    {
+        readonly get => new VecD(right, top);
+        set
+        {
+            right = value.X;
+            top = value.Y;
+        }
+    }
+    public VecD BottomLeft
+    {
+        readonly get => new VecD(left, bottom);
+        set
+        {
+            left = value.X;
+            bottom = value.Y;
+        }
+    }
+    public VecD BottomRight
+    {
+        readonly get => new VecD(right, bottom);
+        set
+        {
+            right = value.X;
+            bottom = value.Y;
+        }
+    }
+    public VecD Size
+    {
+        readonly get => new VecD(right - left, bottom - top);
+        set
+        {
+            right = left + value.X;
+            bottom = top + value.Y;
+        }
+    }
+    public VecD Center { get => new VecD((left + right) / 2.0, (top + bottom) / 2.0); }
+    public double Width { readonly get => right - left; set => right = left + value; }
+    public double Height { readonly get => bottom - top; set => bottom = top + value; }
+    public readonly bool IsZeroArea => left == right || top == bottom;
+    public readonly bool IsZeroOrNegativeArea => left >= right || top >= bottom;
+    public RectD()
+    {
+        left = 0d;
+        top = 0d;
+        right = 0d;
+        bottom = 0d;
+    }
+
+    public RectD(VecD pos, VecD size)
+    {
+        left = pos.X;
+        top = pos.Y;
+        right = pos.X + size.X;
+        bottom = pos.Y + size.Y;
+    }
+
+    public RectD(double x, double y, double width, double height)
+    {
+        left = x;
+        top = y;
+        right = x + width;
+        bottom = y + height;
+    }
+    public static RectD FromSides(double left, double right, double top, double bottom)
+    {
+        return new RectD()
+        {
+            Left = left,
+            Right = right,
+            Top = top,
+            Bottom = bottom
+        };
+    }
+
+    public static RectD FromTwoPoints(VecD point, VecD opposite)
+    {
+        return new RectD()
+        {
+            Left = Math.Min(point.X, opposite.X),
+            Right = Math.Max(point.X, opposite.X),
+            Top = Math.Min(point.Y, opposite.Y),
+            Bottom = Math.Max(point.Y, opposite.Y)
+        };
+    }
+
+    public static RectD FromCenterAndSize(VecD center, VecD size)
+    {
+        return new RectD()
+        {
+            Left = center.X - size.X / 2,
+            Right = center.X + size.X / 2,
+            Top = center.Y - size.Y / 2,
+            Bottom = center.Y + size.Y / 2
+        };
+    }
+
+    /// <summary>
+    /// Converts rectangles with negative dimensions into a normal one
+    /// </summary>
+    public readonly RectD Standardize()
+    {
+        (double newLeft, double newRight) = left > right ? (right, left) : (left, right);
+        (double newTop, double newBottom) = top > bottom ? (bottom, top) : (top, bottom);
+        return new RectD()
+        {
+            Left = newLeft,
+            Right = newRight,
+            Top = newTop,
+            Bottom = newBottom
+        };
+    }
+
+    public readonly RectD ReflectX(double verLineX)
+    {
+        return RectD.FromTwoPoints(Pos.ReflectX(verLineX), (Pos + Size).ReflectX(verLineX));
+    }
+
+    public readonly RectD ReflectY(double horLineY)
+    {
+        return RectD.FromTwoPoints(Pos.ReflectY(horLineY), (Pos + Size).ReflectY(horLineY));
+    }
+
+    public readonly RectD Offset(VecD offset) => Offset(offset.X, offset.Y);
+    public readonly RectD Offset(double x, double y)
+    {
+        return new RectD()
+        {
+            Left = left + x,
+            Right = right + x,
+            Top = top + y,
+            Bottom = bottom + y
+        };
+    }
+
+    public readonly RectD Inflate(VecD amount) => Inflate(amount.Y, amount.Y);
+    public readonly RectD Inflate(double x, double y)
+    {
+        return new RectD()
+        {
+            Left = left - x,
+            Right = right + x,
+            Top = top - y,
+            Bottom = bottom + y,
+        };
+    }
+
+    public readonly RectD Inflate(double amount)
+    {
+        return new RectD()
+        {
+            Left = left - amount,
+            Right = right + amount,
+            Top = top - amount,
+            Bottom = bottom + amount,
+        };
+    }
+
+    /// <summary>
+    /// Fits passed rectangle into this rectangle while maintaining aspect ratio
+    /// </summary>
+    public readonly RectD AspectFit(RectD rect)
+    {
+        double widthRatio = Width / rect.Width;
+        double heightRatio = Height / rect.Height;
+        if (widthRatio > heightRatio)
+        {
+            double newWidth = Height * rect.Width / rect.Height;
+            double newLeft = left + (Width - newWidth) / 2;
+            return new RectD(new(newLeft, top), new(newWidth, Height));
+        }
+        else
+        {
+            double newHeight = Width * rect.Height / rect.Width;
+            double newTop = top + (Height - newHeight) / 2;
+            return new RectD(new(left, newTop), new(Width, newHeight));
+        }
+    }
+
+    public readonly RectD RoundOutwards()
+    {
+        return new RectD()
+        {
+            Left = Math.Floor(left),
+            Right = Math.Ceiling(right),
+            Top = Math.Floor(top),
+            Bottom = Math.Ceiling(bottom)
+        };
+    }
+
+    public readonly RectD RoundInwards()
+    {
+        return new RectD()
+        {
+            Left = Math.Ceiling(left),
+            Right = Math.Floor(right),
+            Top = Math.Ceiling(top),
+            Bottom = Math.Floor(bottom)
+        };
+    }
+
+    public readonly bool ContainsInclusive(VecD point) => ContainsInclusive(point.X, point.Y);
+    public readonly bool ContainsInclusive(double x, double y)
+    {
+        return x >= left && x <= right && y >= top && y <= bottom;
+    }
+
+    public readonly bool ContainsExclusive(VecD point) => ContainsExclusive(point.X, point.Y);
+    public readonly bool ContainsExclusive(double x, double y)
+    {
+        return x > left && x < right && y > top && y < bottom;
+    }
+
+    public readonly bool IntersectsWithInclusive(RectD rect)
+    {
+        return left <= rect.right && right >= rect.left && top <= rect.bottom && bottom >= rect.top;
+    }
+
+    public readonly bool IntersectsWithExclusive(RectD rect)
+    {
+        return left < rect.right && right > rect.left && top < rect.bottom && bottom > rect.top;
+    }
+
+    public readonly RectD Intersect(RectD other)
+    {
+        double left = Math.Max(this.left, other.left);
+        double top = Math.Max(this.top, other.top);
+
+        double right = Math.Min(this.right, other.right);
+        double bottom = Math.Min(this.bottom, other.bottom);
+
+        if (left >= right || top >= bottom)
+            return RectD.Empty;
+
+        return new RectD()
+        {
+            Left = left,
+            Right = right,
+            Top = top,
+            Bottom = bottom
+        };
+    }
+
+    public readonly RectD Union(RectD other)
+    {
+        double left = Math.Min(this.left, other.left);
+        double top = Math.Min(this.top, other.top);
+
+        double right = Math.Max(this.right, other.right);
+        double bottom = Math.Max(this.bottom, other.bottom);
+
+        if (left >= right || top >= bottom)
+            return RectD.Empty;
+
+        return new RectD()
+        {
+            Left = left,
+            Right = right,
+            Top = top,
+            Bottom = bottom
+        };
+    }
+
+    public static explicit operator RectI(RectD rect)
+    {
+        return new RectI()
+        {
+            Left = (int)rect.left,
+            Right = (int)rect.right,
+            Top = (int)rect.top,
+            Bottom = (int)rect.bottom
+        };
+    }
+
+    public static explicit operator SKRect(RectD rect)
+    {
+        return new SKRect((float)rect.left, (float)rect.top, (float)rect.right, (float)rect.bottom);
+    }
+
+    public static explicit operator SKRectI(RectD rect)
+    {
+        return new SKRectI((int)rect.left, (int)rect.top, (int)rect.right, (int)rect.bottom);
+    }
+
+    public static implicit operator RectD(SKRect rect)
+    {
+        return new RectD()
+        {
+            Left = rect.Left,
+            Right = rect.Right,
+            Top = rect.Top,
+            Bottom = rect.Bottom
+        };
+    }
+
+    public static implicit operator RectD(SKRectI rect)
+    {
+        return new RectD()
+        {
+            Left = rect.Left,
+            Right = rect.Right,
+            Top = rect.Top,
+            Bottom = rect.Bottom
+        };
+    }
+
+    public static bool operator ==(RectD left, RectD right)
+    {
+        return left.left == right.left && left.right == right.right && left.top == right.top && left.bottom == right.bottom;
+    }
+
+    public static bool operator !=(RectD left, RectD right)
+    {
+        return !(left.left == right.left && left.right == right.right && left.top == right.top && left.bottom == right.bottom);
+    }
+
+    public readonly override bool Equals(object? obj)
+    {
+        return obj is RectD rect && rect.left == left && rect.right == right && rect.top == top && rect.bottom == bottom;
+    }
+
+    public readonly bool Equals(RectD other)
+    {
+        return left == other.left && top == other.top && right == other.right && bottom == other.bottom;
+    }
+
+    public override int GetHashCode()
+    {
+        return HashCode.Combine(left, top, right, bottom);
+    }
+
+    public override string ToString()
+    {
+        return $"{{X: {X}, Y: {Y}, W: {Width}, H: {Height}}}";
+    }
+}

+ 346 - 0
src/ChunkyImageLib/DataHolders/RectI.cs

@@ -0,0 +1,346 @@
+using SkiaSharp;
+
+namespace ChunkyImageLib.DataHolders;
+public struct RectI : IEquatable<RectI>
+{
+    public static RectI Empty { get; } = new RectI();
+
+    private int left;
+    private int top;
+    private int right;
+    private int bottom;
+
+    public int Left { readonly get => left; set => left = value; }
+    public int Top { readonly get => top; set => top = value; }
+    public int Right { readonly get => right; set => right = value; }
+    public int Bottom { readonly get => bottom; set => bottom = value; }
+    public int X { readonly get => left; set => left = value; }
+    public int Y { readonly get => top; set => top = value; }
+    public VecI Pos
+    {
+        readonly get => new VecI(left, top);
+        set
+        {
+            right = (right - left) + value.X;
+            bottom = (bottom - top) + value.Y;
+            left = value.X;
+            top = value.Y;
+        }
+    }
+    public VecI TopLeft
+    {
+        readonly get => new VecI(left, top);
+        set
+        {
+            left = value.X;
+            top = value.Y;
+        }
+    }
+    public VecI TopRight
+    {
+        readonly get => new VecI(right, top);
+        set
+        {
+            right = value.X;
+            top = value.Y;
+        }
+    }
+    public VecI BottomLeft
+    {
+        readonly get => new VecI(left, bottom);
+        set
+        {
+            left = value.X;
+            bottom = value.Y;
+        }
+    }
+    public VecI BottomRight
+    {
+        readonly get => new VecI(right, bottom);
+        set
+        {
+            right = value.X;
+            bottom = value.Y;
+        }
+    }
+
+    public VecI Size
+    {
+        readonly get => new VecI(right - left, bottom - top);
+        set
+        {
+            right = left + value.X;
+            bottom = top + value.Y;
+        }
+    }
+    public VecD Center { get => new VecD((left + right) / 2.0, (top + bottom) / 2.0); }
+    public int Width { readonly get => right - left; set => right = left + value; }
+    public int Height { readonly get => bottom - top; set => bottom = top + value; }
+    public readonly bool IsZeroArea => left == right || top == bottom;
+    public readonly bool IsZeroOrNegativeArea => left >= right || top >= bottom;
+
+    public RectI()
+    {
+        left = 0;
+        top = 0;
+        right = 0;
+        bottom = 0;
+    }
+
+    public RectI(int x, int y, int width, int height)
+    {
+        left = x;
+        top = y;
+        right = x + width;
+        bottom = y + height;
+    }
+
+    public RectI(VecI pos, VecI size)
+    {
+        left = pos.X;
+        top = pos.Y;
+        right = pos.X + size.X;
+        bottom = pos.Y + size.Y;
+    }
+
+    public static RectI FromSides(int left, int right, int top, int bottom)
+    {
+        return new RectI()
+        {
+            Left = left,
+            Right = right,
+            Top = top,
+            Bottom = bottom
+        };
+    }
+
+    public static RectI FromTwoPoints(VecI point, VecI opposite)
+    {
+        return new RectI()
+        {
+            Left = Math.Min(point.X, opposite.X),
+            Right = Math.Max(point.X, opposite.X),
+            Top = Math.Min(point.Y, opposite.Y),
+            Bottom = Math.Max(point.Y, opposite.Y)
+        };
+    }
+
+    public static RectI FromTwoPixels(VecI pixel, VecI oppositePixel)
+    {
+        return new RectI(pixel, new(1, 1)).Union(new RectI(oppositePixel, new(1, 1)));
+    }
+
+    /// <summary>
+    /// Converts rectangle with negative dimensions into a normal one
+    /// </summary>
+    public readonly RectI Standardize()
+    {
+        (int newLeft, int newRight) = left > right ? (right, left) : (left, right);
+        (int newTop, int newBottom) = top > bottom ? (bottom, top) : (top, bottom);
+        return new RectI()
+        {
+            Left = newLeft,
+            Right = newRight,
+            Top = newTop,
+            Bottom = newBottom
+        };
+    }
+
+    public readonly RectI ReflectX(int verLineX)
+    {
+        return RectI.FromTwoPoints(Pos.ReflectX(verLineX), (Pos + Size).ReflectX(verLineX));
+    }
+
+    public readonly RectI ReflectY(int horLineY)
+    {
+        return RectI.FromTwoPoints(Pos.ReflectY(horLineY), (Pos + Size).ReflectY(horLineY));
+    }
+
+    public readonly RectI Offset(VecI offset) => Offset(offset.X, offset.Y);
+    public readonly RectI Offset(int x, int y)
+    {
+        return new RectI()
+        {
+            Left = left + x,
+            Right = right + x,
+            Top = top + y,
+            Bottom = bottom + y
+        };
+    }
+
+    public readonly RectI Inflate(VecI amount) => Inflate(amount.Y, amount.Y);
+    public readonly RectI Inflate(int x, int y)
+    {
+        return new RectI()
+        {
+            Left = left - x,
+            Right = right + x,
+            Top = top - y,
+            Bottom = bottom + y,
+        };
+    }
+
+    public readonly RectI Inflate(int amount)
+    {
+        return new RectI()
+        {
+            Left = left - amount,
+            Right = right + amount,
+            Top = top - amount,
+            Bottom = bottom + amount,
+        };
+    }
+
+    /// <summary>
+    /// Fits passed rectangle into this rectangle while maintaining aspect ratio
+    /// </summary>
+    public readonly RectI AspectFit(RectI rect)
+    {
+        return (RectI)((RectD)this).AspectFit(rect);
+    }
+
+    public readonly bool ContainsInclusive(VecI point) => ContainsInclusive(point.X, point.Y);
+    public readonly bool ContainsInclusive(int x, int y)
+    {
+        return x >= left && x <= right && y >= top && y <= bottom;
+    }
+
+    public readonly bool ContainsExclusive(VecI point) => ContainsExclusive(point.X, point.Y);
+    public readonly bool ContainsExclusive(int x, int y)
+    {
+        return x > left && x < right && y > top && y < bottom;
+    }
+
+    public readonly bool ContainsPixel(VecI pixelTopLeft) => ContainsPixel(pixelTopLeft.X, pixelTopLeft.Y);
+    public readonly bool ContainsPixel(int pixelTopLeftX, int pixelTopLeftY)
+    {
+        return
+            pixelTopLeftX >= left &&
+            pixelTopLeftX < right &&
+            pixelTopLeftY >= top &&
+            pixelTopLeftY < bottom;
+    }
+
+    public readonly bool IntersectsWithInclusive(RectI rect)
+    {
+        return left <= rect.right && right >= rect.left && top <= rect.bottom && bottom >= rect.top;
+    }
+
+    public readonly bool IntersectsWithExclusive(RectI rect)
+    {
+        return left < rect.right && right > rect.left && top < rect.bottom && bottom > rect.top;
+    }
+
+    public readonly RectI Intersect(RectI other)
+    {
+        int left = Math.Max(this.left, other.left);
+        int top = Math.Max(this.top, other.top);
+
+        int right = Math.Min(this.right, other.right);
+        int bottom = Math.Min(this.bottom, other.bottom);
+
+        if (left >= right || top >= bottom)
+            return RectI.Empty;
+
+        return new RectI()
+        {
+            Left = left,
+            Right = right,
+            Top = top,
+            Bottom = bottom
+        };
+    }
+
+    public readonly RectI Union(RectI other)
+    {
+        int left = Math.Min(this.left, other.left);
+        int top = Math.Min(this.top, other.top);
+
+        int right = Math.Max(this.right, other.right);
+        int bottom = Math.Max(this.bottom, other.bottom);
+
+        if (left >= right || top >= bottom)
+            return RectI.Empty;
+
+        return new RectI()
+        {
+            Left = left,
+            Right = right,
+            Top = top,
+            Bottom = bottom
+        };
+    }
+
+    public static implicit operator RectD(RectI rect)
+    {
+        return new RectD()
+        {
+            Left = rect.left,
+            Right = rect.right,
+            Top = rect.top,
+            Bottom = rect.bottom
+        };
+    }
+
+    public static implicit operator SKRect(RectI rect)
+    {
+        return new SKRect(rect.left, rect.top, rect.right, rect.bottom);
+    }
+
+    public static implicit operator SKRectI(RectI rect)
+    {
+        return new SKRectI(rect.left, rect.top, rect.right, rect.bottom);
+    }
+
+    public static explicit operator RectI(SKRect rect)
+    {
+        return new RectI()
+        {
+            Left = (int)rect.Left,
+            Right = (int)rect.Right,
+            Top = (int)rect.Top,
+            Bottom = (int)rect.Bottom
+        };
+    }
+
+    public static implicit operator RectI(SKRectI rect)
+    {
+        return new RectI()
+        {
+            Left = rect.Left,
+            Right = rect.Right,
+            Top = rect.Top,
+            Bottom = rect.Bottom
+        };
+    }
+
+    public static bool operator ==(RectI left, RectI right)
+    {
+        return left.left == right.left && left.right == right.right && left.top == right.top && left.bottom == right.bottom;
+    }
+
+    public static bool operator !=(RectI left, RectI right)
+    {
+        return !(left.left == right.left && left.right == right.right && left.top == right.top && left.bottom == right.bottom);
+    }
+
+    public readonly override bool Equals(object? obj)
+    {
+        return obj is RectI rect && rect.left == left && rect.right == right && rect.top == top && rect.bottom == bottom;
+    }
+
+    public readonly bool Equals(RectI other)
+    {
+        return left == other.left && top == other.top && right == other.right && bottom == other.bottom;
+    }
+
+    public override int GetHashCode()
+    {
+        return HashCode.Combine(left, top, right, bottom);
+    }
+
+    public override string ToString()
+    {
+        return $"{{X: {X}, Y: {Y}, W: {Width}, H: {Height}}}";
+    }
+}

+ 103 - 0
src/ChunkyImageLib/DataHolders/ShapeCorners.cs

@@ -0,0 +1,103 @@
+namespace ChunkyImageLib.DataHolders;
+public struct ShapeCorners
+{
+    public ShapeCorners(VecD center, VecD size)
+    {
+        TopLeft = center - size / 2;
+        TopRight = center + new VecD(size.X / 2, -size.Y / 2);
+        BottomRight = center + size / 2;
+        BottomLeft = center + new VecD(-size.X / 2, size.Y / 2);
+    }
+    public ShapeCorners(RectD rect)
+    {
+        TopLeft = rect.TopLeft;
+        TopRight = rect.TopRight;
+        BottomRight = rect.BottomRight;
+        BottomLeft = rect.BottomLeft;
+    }
+    public VecD TopLeft { get; set; }
+    public VecD TopRight { get; set; }
+    public VecD BottomLeft { get; set; }
+    public VecD BottomRight { get; set; }
+    public bool IsInverted
+    {
+        get
+        {
+            var top = TopLeft - TopRight;
+            var right = TopRight - BottomRight;
+            var bottom = BottomRight - BottomLeft;
+            var left = BottomLeft - TopLeft;
+            return Math.Sign(top.Cross(right)) + Math.Sign(right.Cross(bottom)) + Math.Sign(bottom.Cross(left)) + Math.Sign(left.Cross(top)) < 0;
+        }
+    }
+    public bool IsLegal
+    {
+        get
+        {
+            var top = TopLeft - TopRight;
+            var right = TopRight - BottomRight;
+            var bottom = BottomRight - BottomLeft;
+            var left = BottomLeft - TopLeft;
+            var topRight = Math.Sign(top.Cross(right));
+            return topRight == Math.Sign(right.Cross(bottom)) && topRight == Math.Sign(bottom.Cross(left)) && topRight == Math.Sign(left.Cross(top));
+        }
+    }
+    public bool HasNaNOrInfinity => TopLeft.IsNaNOrInfinity() || TopRight.IsNaNOrInfinity() || BottomLeft.IsNaNOrInfinity() || BottomRight.IsNaNOrInfinity();
+    public bool IsRect => Math.Abs((TopLeft - BottomRight).Length - (TopRight - BottomLeft).Length) < 0.001;
+    public VecD RectSize => new((TopLeft - TopRight).Length, (TopLeft - BottomLeft).Length);
+    public VecD RectCenter => (TopLeft - BottomRight) / 2 + BottomRight;
+    public double RectRotation =>
+        (TopLeft - TopRight).Cross(TopLeft - BottomLeft) > 0 ?
+        RectSize.CCWAngleTo(BottomRight - TopLeft) :
+        RectSize.CCWAngleTo(BottomLeft - TopRight);
+    public bool IsSnappedToPixels
+    {
+        get
+        {
+            double epsilon = 0.01;
+            return
+                (TopLeft - TopLeft.Round()).TaxicabLength < epsilon &&
+                (TopRight - TopRight.Round()).TaxicabLength < epsilon &&
+                (BottomLeft - BottomLeft.Round()).TaxicabLength < epsilon &&
+                (BottomRight - BottomRight.Round()).TaxicabLength < epsilon;
+        }
+    }
+    public bool IsPointInside(VecD point)
+    {
+        var top = TopLeft - TopRight;
+        var right = TopRight - BottomRight;
+        var bottom = BottomRight - BottomLeft;
+        var left = BottomLeft - TopLeft;
+
+        var deltaTopLeft = point - TopLeft;
+        var deltaTopRight = point - TopRight;
+        var deltaBottomRight = point - BottomRight;
+        var deltaBottomLeft = point - BottomLeft;
+
+        if (deltaTopRight.IsNaNOrInfinity() || deltaTopLeft.IsNaNOrInfinity() || deltaBottomRight.IsNaNOrInfinity() || deltaBottomRight.IsNaNOrInfinity())
+            return false;
+
+        var crossTop = Math.Sign(top.Cross(deltaTopLeft));
+        var crossRight = Math.Sign(right.Cross(deltaTopRight));
+        var crossBottom = Math.Sign(bottom.Cross(deltaBottomRight));
+        var crossLeft = Math.Sign(left.Cross(deltaBottomLeft));
+
+        return crossTop == crossRight && crossTop == crossLeft && crossTop == crossBottom;
+    }
+
+    public ShapeCorners AsMirroredAcrossHorAxis(int horAxisY) => new ShapeCorners
+    {
+        BottomLeft = BottomLeft.ReflectY(horAxisY),
+        BottomRight = BottomRight.ReflectY(horAxisY),
+        TopLeft = TopLeft.ReflectY(horAxisY),
+        TopRight = TopRight.ReflectY(horAxisY)
+    };
+    
+    public ShapeCorners AsMirroredAcrossVerAxis(int verAxisX) => new ShapeCorners
+    {
+        BottomLeft = BottomLeft.ReflectX(verAxisX),
+        BottomRight = BottomRight.ReflectX(verAxisX),
+        TopLeft = TopLeft.ReflectX(verAxisX),
+        TopRight = TopRight.ReflectX(verAxisX)
+    };
+}

+ 31 - 0
src/ChunkyImageLib/DataHolders/ShapeData.cs

@@ -0,0 +1,31 @@
+using SkiaSharp;
+
+namespace ChunkyImageLib.DataHolders;
+
+public record struct ShapeData
+{
+    public ShapeData(VecD center, VecD size, double rotation, int strokeWidth, SKColor strokeColor, SKColor fillColor, SKBlendMode blendMode = SKBlendMode.SrcOver)
+    {
+        StrokeColor = strokeColor;
+        FillColor = fillColor;
+        Center = center;
+        Size = size;
+        Angle = rotation;
+        StrokeWidth = strokeWidth;
+        BlendMode = blendMode;
+    }
+    public SKColor StrokeColor { get; }
+    public SKColor FillColor { get; }
+    public SKBlendMode BlendMode { get; }
+    public VecD Center { get; }
+    /// <summary>Can be negative to show flipping </summary>
+    public VecD Size { get; }
+    public double Angle { get; }
+    public int StrokeWidth { get; }
+
+    public ShapeData AsMirroredAcrossHorAxis(int horAxisY)
+        => new ShapeData(Center.ReflectY(horAxisY), new(Size.X, -Size.Y), -Angle, StrokeWidth, StrokeColor, FillColor, BlendMode);
+    public ShapeData AsMirroredAcrossVerAxis(int verAxisX)
+        => new ShapeData(Center.ReflectX(verAxisX), new(-Size.X, Size.Y), -Angle, StrokeWidth, StrokeColor, FillColor, BlendMode);
+
+}

+ 257 - 0
src/ChunkyImageLib/DataHolders/VecD.cs

@@ -0,0 +1,257 @@
+using SkiaSharp;
+
+namespace ChunkyImageLib.DataHolders;
+
+public struct VecD : IEquatable<VecD>
+{
+    public double X { set; get; }
+    public double Y { set; get; }
+
+    public double TaxicabLength => Math.Abs(X) + Math.Abs(Y);
+    public double Length => Math.Sqrt(LengthSquared);
+    public double LengthSquared => X * X + Y * Y;
+    public double Angle => Y < 0 ? -AngleTo(new VecD(1, 0)) : AngleTo(new VecD(1, 0));
+    public double LongestAxis => (Math.Abs(X) < Math.Abs(Y)) ? Y : X;
+    public double ShortestAxis => (Math.Abs(X) < Math.Abs(Y)) ? X : Y;
+
+    public static VecD Zero { get; } = new(0, 0);
+
+    public VecD(double x, double y)
+    {
+        X = x;
+        Y = y;
+    }
+    public VecD(double bothAxesValue)
+    {
+        X = bothAxesValue;
+        Y = bothAxesValue;
+    }
+    public static VecD FromAngleAndLength(double angle, double length)
+    {
+        return new VecD(Math.Cos(angle) * length, Math.Sin(angle) * length);
+    }
+    public VecD Round()
+    {
+        return new(Math.Round(X), Math.Round(Y));
+    }
+    public VecD Ceiling()
+    {
+        return new(Math.Ceiling(X), Math.Ceiling(Y));
+    }
+    public VecD Floor()
+    {
+        return new(Math.Floor(X), Math.Floor(Y));
+    }
+    public VecD Rotate(double angle)
+    {
+        VecD result = new VecD();
+        result.X = X * Math.Cos(angle) - Y * Math.Sin(angle);
+        result.Y = X * Math.Sin(angle) + Y * Math.Cos(angle);
+        return result;
+    }
+    public VecD Rotate(double angle, VecD around)
+    {
+        return (this - around).Rotate(angle) + around;
+    }
+    public double DistanceToLineSegment(VecD pos1, VecD pos2)
+    {
+        VecD segment = pos2 - pos1;
+        if ((this - pos1).AngleTo(segment) > Math.PI / 2)
+            return (this - pos1).Length;
+        if ((this - pos2).AngleTo(-segment) > Math.PI / 2)
+            return (this - pos2).Length;
+        return DistanceToLine(pos1, pos2);
+    }
+    public double DistanceToLine(VecD pos1, VecD pos2)
+    {
+        double a = (pos1 - pos2).Length;
+        double b = (this - pos1).Length;
+        double c = (this - pos2).Length;
+
+        double p = (a + b + c) / 2;
+        double triangleArea = Math.Sqrt(p * (p - a) * (p - b) * (p - c));
+
+        return triangleArea / a * 2;
+    }
+    public VecD ProjectOntoLine(VecD pos1, VecD pos2)
+    {
+        VecD line = (pos2 - pos1).Normalize();
+        VecD point = this - pos1;
+        return (line * point) * line + pos1;
+    }
+    /// <summary>
+    /// Reflects the vector across a vertical line with the specified position
+    /// </summary>
+    public VecD ReflectX(double lineX)
+    {
+        return new(2 * lineX - X, Y);
+    }
+    /// <summary>
+    /// Reflects the vector along a horizontal line with the specified position
+    /// </summary>
+    public VecD ReflectY(double lineY)
+    {
+        return new(X, 2 * lineY - Y);
+    }
+    public VecD ReflectAcrossLine(VecD pos1, VecD pos2)
+    {
+        var onLine = ProjectOntoLine(pos1, pos2);
+        return onLine - (this - onLine);
+    }
+    public double AngleTo(VecD other)
+    {
+        return Math.Acos((this * other) / Length / other.Length);
+    }
+
+    /// <summary>
+    /// Returns the angle between two vectors when travelling counterclockwise (assuming Y pointing up) from this vector to passed vector
+    /// </summary>
+    public double CCWAngleTo(VecD other)
+    {
+        var rot = other.Rotate(-Angle);
+        return rot.Angle;
+    }
+    public VecD Lerp(VecD other, double factor)
+    {
+        return (other - this) * factor + this;
+    }
+    public VecD Normalize()
+    {
+        return new VecD(X / Length, Y / Length);
+    }
+    public VecD Abs()
+    {
+        return new VecD(Math.Abs(X), Math.Abs(Y));
+    }
+    public VecD Signs()
+    {
+        return new VecD(X >= 0 ? 1 : -1, Y >= 0 ? 1 : -1);
+    }
+    /// <summary>
+    /// Returns the signed magnitude (Z coordinate) of the vector resulting from the cross product
+    /// </summary>
+    public double Cross(VecD other)
+    {
+        return (X * other.Y) - (Y * other.X);
+    }
+    public VecD Multiply(VecD other)
+    {
+        return new VecD(X * other.X, Y * other.Y);
+    }
+    public VecD Divide(VecD other)
+    {
+        return new VecD(X / other.X, Y / other.Y);
+    }
+    public static VecD operator +(VecD a, VecD b)
+    {
+        return new VecD(a.X + b.X, a.Y + b.Y);
+    }
+    public static VecD operator -(VecD a, VecD b)
+    {
+        return new VecD(a.X - b.X, a.Y - b.Y);
+    }
+    public static VecD operator -(VecD a)
+    {
+        return new VecD(-a.X, -a.Y);
+    }
+    public static VecD operator *(double b, VecD a)
+    {
+        return new VecD(a.X * b, a.Y * b);
+    }
+    public static double operator *(VecD a, VecD b)
+    {
+        return a.X * b.X + a.Y * b.Y;
+    }
+    public static VecD operator *(VecD a, double b)
+    {
+        return new VecD(a.X * b, a.Y * b);
+    }
+    public static VecD operator /(VecD a, double b)
+    {
+        return new VecD(a.X / b, a.Y / b);
+    }
+    public static VecD operator %(VecD a, double b)
+    {
+        return new(a.X % b, a.Y % b);
+    }
+    public static bool operator ==(VecD a, VecD b)
+    {
+        return a.X == b.X && a.Y == b.Y;
+    }
+    public static bool operator !=(VecD a, VecD b)
+    {
+        return !(a.X == b.X && a.Y == b.Y);
+    }
+    public static implicit operator VecD(SKPoint point)
+    {
+        return new VecD(point.X, point.Y);
+    }
+    public static implicit operator VecD(SKSize size)
+    {
+        return new VecD(size.Width, size.Height);
+    }
+    public static implicit operator VecD(SKPointI point)
+    {
+        return new VecD(point.X, point.Y);
+    }
+    public static implicit operator VecD(SKSizeI size)
+    {
+        return new VecD(size.Width, size.Height);
+    }
+    public static explicit operator VecI(VecD vec)
+    {
+        return new VecI((int)vec.X, (int)vec.Y);
+    }
+    public static explicit operator SKPointI(VecD vec)
+    {
+        return new SKPointI((int)vec.X, (int)vec.Y);
+    }
+    public static explicit operator SKPoint(VecD vec)
+    {
+        return new SKPoint((float)vec.X, (float)vec.Y);
+    }
+    public static explicit operator SKSizeI(VecD vec)
+    {
+        return new SKSizeI((int)vec.X, (int)vec.Y);
+    }
+    public static explicit operator SKSize(VecD vec)
+    {
+        return new SKSize((float)vec.X, (float)vec.Y);
+    }
+    public static implicit operator VecD((double, double) tuple)
+    {
+        return new VecD(tuple.Item1, tuple.Item2);
+    }
+    public void Deconstruct(out double x, out double y)
+    {
+        x = X;
+        y = Y;
+    }
+    public bool IsNaNOrInfinity()
+    {
+        return double.IsNaN(X) || double.IsNaN(Y) || double.IsInfinity(X) || double.IsInfinity(Y);
+    }
+
+    public override string ToString()
+    {
+        return $"({X}; {Y})";
+    }
+
+    public override bool Equals(object? obj)
+    {
+        var item = obj as VecD?;
+        if (item is null)
+            return false;
+        return this == item;
+    }
+
+    public override int GetHashCode()
+    {
+        return HashCode.Combine(X, Y);
+    }
+
+    public bool Equals(VecD other)
+    {
+        return other.X == X && other.Y == Y;
+    }
+}

+ 174 - 0
src/ChunkyImageLib/DataHolders/VecI.cs

@@ -0,0 +1,174 @@
+using SkiaSharp;
+
+namespace ChunkyImageLib.DataHolders;
+
+public struct VecI : IEquatable<VecI>
+{
+    public int X { set; get; }
+    public int Y { set; get; }
+
+    public int TaxicabLength => Math.Abs(X) + Math.Abs(Y);
+    public double Length => Math.Sqrt(LengthSquared);
+    public int LengthSquared => X * X + Y * Y;
+    public int LongestAxis => (Math.Abs(X) < Math.Abs(Y)) ? Y : X;
+    public int ShortestAxis => (Math.Abs(X) < Math.Abs(Y)) ? X : Y;
+
+    public static VecI Zero { get; } = new(0, 0);
+
+    public VecI(int x, int y)
+    {
+        X = x;
+        Y = y;
+    }
+    public VecI(int bothAxesValue)
+    {
+        X = bothAxesValue;
+        Y = bothAxesValue;
+    }
+    public VecI Signs()
+    {
+        return new VecI(X >= 0 ? 1 : -1, Y >= 0 ? 1 : -1);
+    }
+    public VecI Multiply(VecI other)
+    {
+        return new VecI(X * other.X, Y * other.Y);
+    }
+    public VecI Add(int value)
+    {
+        return new VecI(X + value, Y + value);
+    }
+    /// <summary>
+    /// Reflects the vector across a vertical line with the specified x position
+    /// </summary>
+    public VecI ReflectX(int lineX)
+    {
+        return new(2 * lineX - X, Y);
+    }
+    /// <summary>
+    /// Reflects the vector across a horizontal line with the specified y position
+    /// </summary>
+    public VecI ReflectY(int lineY)
+    {
+        return new(X, 2 * lineY - Y);
+    }
+    public static VecI operator +(VecI a, VecI b)
+    {
+        return new VecI(a.X + b.X, a.Y + b.Y);
+    }
+    public static VecI operator -(VecI a, VecI b)
+    {
+        return new VecI(a.X - b.X, a.Y - b.Y);
+    }
+    public static VecI operator -(VecI a)
+    {
+        return new VecI(-a.X, -a.Y);
+    }
+    public static VecI operator *(int b, VecI a)
+    {
+        return new VecI(a.X * b, a.Y * b);
+    }
+    public static int operator *(VecI a, VecI b)
+    {
+        return a.X * b.X + a.Y * b.Y;
+    }
+    public static VecI operator *(VecI a, int b)
+    {
+        return new VecI(a.X * b, a.Y * b);
+    }
+    public static VecD operator *(VecI a, double b)
+    {
+        return new VecD(a.X * b, a.Y * b);
+    }
+    public static VecI operator /(VecI a, int b)
+    {
+        return new VecI(a.X / b, a.Y / b);
+    }
+    public static VecD operator /(VecI a, double b)
+    {
+        return new VecD(a.X / b, a.Y / b);
+    }
+    public static VecI operator %(VecI a, int b)
+    {
+        return new(a.X % b, a.Y % b);
+    }
+    public static VecD operator %(VecI a, double b)
+    {
+        return new(a.X % b, a.Y % b);
+    }
+    public static bool operator ==(VecI a, VecI b)
+    {
+        return a.X == b.X && a.Y == b.Y;
+    }
+    public static bool operator !=(VecI a, VecI b)
+    {
+        return !(a.X == b.X && a.Y == b.Y);
+    }
+    public static explicit operator VecI(SKPoint point)
+    {
+        return new VecI((int)point.X, (int)point.Y);
+    }
+    public static explicit operator VecI(SKSize size)
+    {
+        return new VecI((int)size.Width, (int)size.Height);
+    }
+    public static implicit operator VecI(SKPointI point)
+    {
+        return new VecI(point.X, point.Y);
+    }
+    public static implicit operator VecI(SKSizeI size)
+    {
+        return new VecI(size.Width, size.Height);
+    }
+    public static implicit operator VecD(VecI vec)
+    {
+        return new VecD(vec.X, vec.Y);
+    }
+    public static implicit operator SKPointI(VecI vec)
+    {
+        return new SKPointI(vec.X, vec.Y);
+    }
+    public static implicit operator SKPoint(VecI vec)
+    {
+        return new SKPoint(vec.X, vec.Y);
+    }
+    public static implicit operator SKSizeI(VecI vec)
+    {
+        return new SKSizeI(vec.X, vec.Y);
+    }
+    public static implicit operator SKSize(VecI vec)
+    {
+        return new SKSize(vec.X, vec.Y);
+    }
+    public static implicit operator VecI((int, int) tuple)
+    {
+        return new VecI(tuple.Item1, tuple.Item2);
+    }
+    public void Deconstruct(out int x, out int y)
+    {
+        x = X;
+        y = Y;
+    }
+
+    public override string ToString()
+    {
+        return $"({X}; {Y})";
+    }
+
+    public override bool Equals(object? obj)
+    {
+        var item = obj as VecI?;
+        if (item is null)
+            return false;
+        return this == item;
+    }
+
+    public override int GetHashCode()
+    {
+        return HashCode.Combine(X, Y);
+    }
+
+    public bool Equals(VecI other)
+    {
+        return other.X == X && other.Y == Y;
+    }
+}

+ 15 - 0
src/ChunkyImageLib/IReadOnlyChunkyImage.cs

@@ -0,0 +1,15 @@
+using ChunkyImageLib.DataHolders;
+using SkiaSharp;
+
+namespace ChunkyImageLib;
+
+public interface IReadOnlyChunkyImage
+{
+    bool DrawMostUpToDateChunkOn(VecI chunkPos, ChunkResolution resolution, SKSurface surface, VecI pos, SKPaint? paint = null);
+    SKColor GetCommittedPixel(VecI posOnImage);
+    SKColor GetMostUpToDatePixel(VecI posOnImage);
+    bool LatestOrCommittedChunkExists(VecI chunkPos);
+    HashSet<VecI> FindAffectedChunks(int fromOperationIndex = 0);
+    HashSet<VecI> FindCommittedChunks();
+    HashSet<VecI> FindAllChunks();
+}

+ 109 - 0
src/ChunkyImageLib/Operations/BresenhamLineHelper.cs

@@ -0,0 +1,109 @@
+using ChunkyImageLib.DataHolders;
+using SkiaSharp;
+
+namespace ChunkyImageLib.Operations;
+public static class BresenhamLineHelper
+{
+    public static SKPoint[] GetBresenhamLine(VecI start, VecI end)
+    {
+        int count = Math.Abs((start - end).LongestAxis) + 1;
+        if (count > 100000)
+            return new SKPoint[0];
+        SKPoint[] output = new SKPoint[count];
+        CalculateBresenhamLine(start, end, output);
+        return output;
+    }
+
+    private static void CalculateBresenhamLine(VecI start, VecI end, SKPoint[] output)
+    {
+        int index = 0;
+
+        int x1 = start.X;
+        int x2 = end.X;
+        int y1 = start.Y;
+        int y2 = end.Y;
+
+        if (x1 == x2 && y1 == y2)
+        {
+            output[index] = start;
+            return;
+        }
+
+        int d, dx, dy, ai, bi, xi, yi;
+        int x = x1, y = y1;
+
+        if (x1 < x2)
+        {
+            xi = 1;
+            dx = x2 - x1;
+        }
+        else
+        {
+            xi = -1;
+            dx = x1 - x2;
+        }
+
+        if (y1 < y2)
+        {
+            yi = 1;
+            dy = y2 - y1;
+        }
+        else
+        {
+            yi = -1;
+            dy = y1 - y2;
+        }
+
+        output[index] = new SKPoint(x, y);
+        index++;
+
+        if (dx > dy)
+        {
+            ai = (dy - dx) * 2;
+            bi = dy * 2;
+            d = bi - dx;
+
+            while (x != x2)
+            {
+                if (d >= 0)
+                {
+                    x += xi;
+                    y += yi;
+                    d += ai;
+                }
+                else
+                {
+                    d += bi;
+                    x += xi;
+                }
+
+                output[index] = new SKPoint(x, y);
+                index++;
+            }
+        }
+        else
+        {
+            ai = (dx - dy) * 2;
+            bi = dx * 2;
+            d = bi - dy;
+
+            while (y != y2)
+            {
+                if (d >= 0)
+                {
+                    x += xi;
+                    y += yi;
+                    d += ai;
+                }
+                else
+                {
+                    d += bi;
+                    y += yi;
+                }
+
+                output[index] = new SKPoint(x, y);
+                index++;
+            }
+        }
+    }
+}

+ 65 - 0
src/ChunkyImageLib/Operations/BresenhamLineOperation.cs

@@ -0,0 +1,65 @@
+using ChunkyImageLib.DataHolders;
+using SkiaSharp;
+
+namespace ChunkyImageLib.Operations;
+internal class BresenhamLineOperation : IDrawOperation
+{
+    public bool IgnoreEmptyChunks => false;
+    private readonly VecI from;
+    private readonly VecI to;
+    private readonly SKColor color;
+    private readonly SKBlendMode blendMode;
+    private readonly SKPoint[] points;
+    private SKPaint paint;
+
+    public BresenhamLineOperation(VecI from, VecI to, SKColor color, SKBlendMode blendMode)
+    {
+        this.from = from;
+        this.to = to;
+        this.color = color;
+        this.blendMode = blendMode;
+        paint = new SKPaint() { BlendMode = blendMode };
+        points = BresenhamLineHelper.GetBresenhamLine(from, to);
+    }
+
+    public void DrawOnChunk(Chunk chunk, VecI chunkPos)
+    {
+        // a hacky way to make the lines look slightly better on non full res chunks
+        paint.Color = new SKColor(color.Red, color.Green, color.Blue, (byte)(color.Alpha * chunk.Resolution.Multiplier()));
+
+        var surf = chunk.Surface.SkiaSurface;
+        surf.Canvas.Save();
+        surf.Canvas.Scale((float)chunk.Resolution.Multiplier());
+        surf.Canvas.Translate(-chunkPos * ChunkyImage.FullChunkSize);
+        surf.Canvas.DrawPoints(SKPointMode.Points, points, paint);
+        surf.Canvas.Restore();
+    }
+
+    public HashSet<VecI> FindAffectedChunks()
+    {
+        RectI bounds = RectI.FromTwoPoints(from, to);
+        return OperationHelper.FindChunksTouchingRectangle(bounds, ChunkyImage.FullChunkSize);
+    }
+
+    public IDrawOperation AsMirrored(int? verAxisX, int? horAxisY)
+    {
+        VecI newFrom = from;
+        VecI newTo = to;
+        if (verAxisX is not null)
+        {
+            newFrom = newFrom.ReflectX((int)verAxisX);
+            newTo = newTo.ReflectX((int)verAxisX);
+        }
+        if (horAxisY is not null)
+        {
+            newFrom = newFrom.ReflectY((int)horAxisY);
+            newTo = newTo.ReflectY((int)horAxisY);
+        }
+        return new BresenhamLineOperation(newFrom, newTo, color, blendMode);
+    }
+
+    public void Dispose()
+    {
+        paint.Dispose();
+    }
+}

+ 122 - 0
src/ChunkyImageLib/Operations/ChunkyImageOperation.cs

@@ -0,0 +1,122 @@
+using ChunkyImageLib.DataHolders;
+using SkiaSharp;
+
+namespace ChunkyImageLib.Operations;
+internal class ChunkyImageOperation : IDrawOperation
+{
+    private readonly ChunkyImage imageToDraw;
+    private readonly VecI pos;
+    private readonly bool mirrorHorizontal;
+    private readonly bool mirrorVertical;
+
+    public bool IgnoreEmptyChunks => false;
+
+    public ChunkyImageOperation(ChunkyImage imageToDraw, VecI pos, bool mirrorHorizontal, bool mirrorVertical)
+    {
+        this.imageToDraw = imageToDraw;
+        this.pos = pos;
+        this.mirrorHorizontal = mirrorHorizontal;
+        this.mirrorVertical = mirrorVertical;
+    }
+
+    public void DrawOnChunk(Chunk chunk, VecI chunkPos)
+    {
+        chunk.Surface.SkiaSurface.Canvas.Save();
+
+        {
+            VecI pixelPos = chunkPos * ChunkyImage.FullChunkSize;
+            VecI topLeftImageCorner = GetTopLeft();
+            SKRect clippingRect = SKRect.Create(
+                OperationHelper.ConvertForResolution(topLeftImageCorner - pixelPos, chunk.Resolution),
+                OperationHelper.ConvertForResolution(imageToDraw.CommittedSize, chunk.Resolution));
+            chunk.Surface.SkiaSurface.Canvas.ClipRect(clippingRect);
+        }
+
+        VecI chunkPixelCenter = chunkPos * ChunkyImage.FullChunkSize;
+        chunkPixelCenter.X += ChunkyImage.FullChunkSize / 2;
+        chunkPixelCenter.Y += ChunkyImage.FullChunkSize / 2;
+
+        VecI chunkCenterOnImage = chunkPixelCenter - pos;
+        VecI chunkSize = chunk.PixelSize;
+        if (mirrorHorizontal)
+        {
+            chunk.Surface.SkiaSurface.Canvas.Scale(-1, 1, chunkSize.X / 2f, chunkSize.Y / 2f);
+            chunkCenterOnImage.X = -chunkCenterOnImage.X;
+        }
+        if (mirrorVertical)
+        {
+            chunk.Surface.SkiaSurface.Canvas.Scale(1, -1, chunkSize.X / 2f, chunkSize.Y / 2f);
+            chunkCenterOnImage.Y = -chunkCenterOnImage.Y;
+        }
+
+        VecI halfChunk = new(ChunkyImage.FullChunkSize / 2, ChunkyImage.FullChunkSize / 2);
+
+        VecI topLeft = OperationHelper.GetChunkPos(chunkCenterOnImage - halfChunk, ChunkyImage.FullChunkSize);
+        VecI topRight = OperationHelper.GetChunkPos(
+            new VecI(chunkCenterOnImage.X + halfChunk.X, chunkCenterOnImage.Y - halfChunk.Y), ChunkyImage.FullChunkSize);
+        VecI bottomRight = OperationHelper.GetChunkPos(chunkCenterOnImage + halfChunk, ChunkyImage.FullChunkSize);
+        VecI bottomLeft = OperationHelper.GetChunkPos(
+            new VecI(chunkCenterOnImage.X - halfChunk.X, chunkCenterOnImage.Y + halfChunk.Y), ChunkyImage.FullChunkSize);
+
+        imageToDraw.DrawCommittedChunkOn(
+            topLeft,
+            chunk.Resolution,
+            chunk.Surface.SkiaSurface,
+            (VecI)((topLeft * ChunkyImage.FullChunkSize - chunkCenterOnImage).Add(ChunkyImage.FullChunkSize / 2) * chunk.Resolution.Multiplier()));
+
+        VecI gridShift = pos % ChunkyImage.FullChunkSize;
+        if (gridShift.X != 0)
+        {
+            imageToDraw.DrawCommittedChunkOn(
+            topRight,
+            chunk.Resolution,
+            chunk.Surface.SkiaSurface,
+            (VecI)((topRight * ChunkyImage.FullChunkSize - chunkCenterOnImage).Add(ChunkyImage.FullChunkSize / 2) * chunk.Resolution.Multiplier()));
+        }
+        if (gridShift.Y != 0)
+        {
+            imageToDraw.DrawCommittedChunkOn(
+            bottomLeft,
+            chunk.Resolution,
+            chunk.Surface.SkiaSurface,
+            (VecI)((bottomLeft * ChunkyImage.FullChunkSize - chunkCenterOnImage).Add(ChunkyImage.FullChunkSize / 2) * chunk.Resolution.Multiplier()));
+        }
+        if (gridShift.X != 0 && gridShift.Y != 0)
+        {
+            imageToDraw.DrawCommittedChunkOn(
+            bottomRight,
+            chunk.Resolution,
+            chunk.Surface.SkiaSurface,
+            (VecI)((bottomRight * ChunkyImage.FullChunkSize - chunkCenterOnImage).Add(ChunkyImage.FullChunkSize / 2) * chunk.Resolution.Multiplier()));
+        }
+
+        chunk.Surface.SkiaSurface.Canvas.Restore();
+    }
+
+    public HashSet<VecI> FindAffectedChunks()
+    {
+        return OperationHelper.FindChunksTouchingRectangle(new(GetTopLeft(), imageToDraw.CommittedSize), ChunkyImage.FullChunkSize);
+    }
+
+    private VecI GetTopLeft()
+    {
+        VecI topLeft = pos;
+        if (mirrorHorizontal)
+            topLeft.X -= imageToDraw.CommittedSize.X;
+        if (mirrorVertical)
+            topLeft.Y -= imageToDraw.CommittedSize.Y;
+        return topLeft;
+    }
+
+    public IDrawOperation AsMirrored(int? verAxisX, int? horAxisY)
+    {
+        var newPos = pos;
+        if (verAxisX is not null)
+            newPos = newPos.ReflectX((int)verAxisX);
+        if (horAxisY is not null)
+            newPos = newPos.ReflectY((int)horAxisY);
+        return new ChunkyImageOperation(imageToDraw, newPos, mirrorHorizontal ^ (verAxisX is not null), mirrorVertical ^ (horAxisY is not null));
+    }
+
+    public void Dispose() { }
+}

+ 6 - 0
src/ChunkyImageLib/Operations/ClearOperation.cs

@@ -0,0 +1,6 @@
+namespace ChunkyImageLib.Operations;
+
+internal record class ClearOperation : IOperation
+{
+    public void Dispose() { }
+}

+ 43 - 0
src/ChunkyImageLib/Operations/ClearRegionOperation.cs

@@ -0,0 +1,43 @@
+using ChunkyImageLib.DataHolders;
+using SkiaSharp;
+
+namespace ChunkyImageLib.Operations;
+
+internal class ClearRegionOperation : IDrawOperation
+{
+    RectI rect;
+
+    public bool IgnoreEmptyChunks => true;
+
+    public ClearRegionOperation(RectI rect)
+    {
+        this.rect = rect;
+    }
+
+    public void DrawOnChunk(Chunk chunk, VecI chunkPos)
+    {
+        VecI convPos = OperationHelper.ConvertForResolution(rect.Pos, chunk.Resolution);
+        VecI convSize = OperationHelper.ConvertForResolution(rect.Size, chunk.Resolution);
+
+        chunk.Surface.SkiaSurface.Canvas.Save();
+        chunk.Surface.SkiaSurface.Canvas.ClipRect(SKRect.Create(convPos - chunkPos.Multiply(chunk.PixelSize), convSize));
+        chunk.Surface.SkiaSurface.Canvas.Clear();
+        chunk.Surface.SkiaSurface.Canvas.Restore();
+    }
+
+    public HashSet<VecI> FindAffectedChunks()
+    {
+        return OperationHelper.FindChunksTouchingRectangle(rect, ChunkPool.FullChunkSize);
+    }
+    public void Dispose() { }
+
+    public IDrawOperation AsMirrored(int? verAxisX, int? horAxisY)
+    {
+        var newRect = rect;
+        if (verAxisX is not null)
+            newRect = newRect.ReflectX((int)verAxisX);
+        if (horAxisY is not null)
+            newRect = newRect.ReflectY((int)horAxisY);
+        return new ClearRegionOperation(newRect);
+    }
+}

+ 180 - 0
src/ChunkyImageLib/Operations/EllipseHelper.cs

@@ -0,0 +1,180 @@
+using ChunkyImageLib.DataHolders;
+
+namespace ChunkyImageLib.Operations;
+public class EllipseHelper
+{
+    public static (List<VecI> lines, RectI rect) SplitEllipseIntoRegions(List<VecI> ellipse, RectI ellipseBounds)
+    {
+        if (ellipse.Count == 0)
+            return (new(), RectI.Empty);
+        List<VecI> lines = new();
+
+        VecD ellipseCenter = ellipseBounds.Center;
+        VecD inscribedRectSize = ellipseBounds.Size * Math.Sqrt(2) / 2;
+        inscribedRectSize.X -= 2;
+        inscribedRectSize.Y -= 2;
+        RectI inscribedRect = (RectI)RectD.FromCenterAndSize(ellipseCenter, inscribedRectSize).RoundInwards();
+        if (inscribedRect.IsZeroOrNegativeArea)
+            inscribedRect = RectI.Empty;
+
+        bool[] added = new bool[ellipseBounds.Height];
+        for (var i = 0; i < ellipse.Count; i++)
+        {
+            var point = ellipse[i];
+            if (!added[point.Y - ellipseBounds.Top] &&
+                i > 0 &&
+                ellipse[i - 1].Y == point.Y &&
+                point.X - ellipse[i - 1].X > 1 &&
+                point.Y > ellipseBounds.Top &&
+                point.Y < ellipseBounds.Bottom - 1)
+            {
+                int fromX = ellipse[i - 1].X + 1;
+                int toX = point.X;
+                int y = ellipse[i - 1].Y;
+                added[point.Y - ellipseBounds.Top] = true;
+                if (y >= inscribedRect.Top && y < inscribedRect.Bottom)
+                {
+                    lines.Add(new VecI(fromX, y));
+                    lines.Add(new VecI(inscribedRect.Left, y));
+                    lines.Add(new VecI(inscribedRect.Right, y));
+                    lines.Add(new VecI(toX, y));
+                }
+                else
+                {
+                    lines.Add(new VecI(fromX, y));
+                    lines.Add(new VecI(toX, y));
+                }
+            }
+        }
+        return (lines, inscribedRect);
+    }
+    public static List<VecI> GenerateEllipseFromRect(RectI rect)
+    {
+        if (rect.IsZeroOrNegativeArea)
+            return new();
+        float radiusX = (rect.Width - 1) / 2.0f;
+        float radiusY = (rect.Height - 1) / 2.0f;
+        return GenerateMidpointEllipse(radiusX, radiusY, rect.Center.X, rect.Center.Y);
+    }
+
+    /// <summary>
+    /// Draws an ellipse using it's center and radii
+    ///
+    /// Here is a usage example:
+    /// Let's say you want an ellipse that's 3 pixels wide and 3 pixels tall located in the top right corner of the canvas
+    /// It's center is at (1.5; 1.5). That's in the middle of a pixel
+    /// The radii are both equal to 1. Notice that it's 1 and not 1.5, since we want the ellipse to land in the middle of the pixel, not outside of it.
+    /// See desmos (note the inverted y axis): https://www.desmos.com/calculator/tq9uqg0hcq
+    ///
+    /// Another example:
+    /// 4x4 ellipse in the top right corner of the canvas
+    /// Center is at (2; 2). It's a place where 4 pixels meet
+    /// Both radii are 1.5. Making them 2 would make the ellipse touch the edges of pixels, whereas we want it to stay in the middle
+    /// </summary>
+    public static List<VecI> GenerateMidpointEllipse(
+        double halfWidth,
+        double halfHeight,
+        double centerX,
+        double centerY,
+        List<VecI>? listToFill = null)
+    {
+        listToFill ??= new List<VecI>();
+        if (halfWidth < 1 || halfHeight < 1)
+        {
+            AddFallbackRectangle(halfWidth, halfHeight, centerX, centerY, listToFill);
+            return listToFill;
+        }
+
+        // ellipse formula: halfHeight^2 * x^2 + halfWidth^2 * y^2 - halfHeight^2 * halfWidth^2 = 0
+
+        // Make sure we are always at the center of a pixel
+        double currentX = Math.Ceiling(centerX - 0.5) + 0.5;
+        double currentY = centerY + halfHeight;
+
+
+        double currentSlope;
+
+        // from PI/2 to PI/4
+        do
+        {
+            AddRegionPoints(listToFill, currentX, centerX, currentY, centerY);
+
+            // calculate next pixel coords
+            currentX++;
+
+            if ((Math.Pow(halfHeight, 2) * Math.Pow(currentX - centerX, 2)) +
+                (Math.Pow(halfWidth, 2) * Math.Pow(currentY - centerY - 0.5, 2)) -
+                (Math.Pow(halfWidth, 2) * Math.Pow(halfHeight, 2)) >= 0)
+            {
+                currentY--;
+            }
+
+            // calculate how far we've advanced
+            double derivativeX = 2 * Math.Pow(halfHeight, 2) * (currentX - centerX);
+            double derivativeY = 2 * Math.Pow(halfWidth, 2) * (currentY - centerY);
+            currentSlope = -(derivativeX / derivativeY);
+        }
+        while (currentSlope > -1 && currentY - centerY > 0.5);
+
+        // from PI/4 to 0
+        while (currentY - centerY >= 0)
+        {
+            AddRegionPoints(listToFill, currentX, centerX, currentY, centerY);
+
+            currentY--;
+            if ((Math.Pow(halfHeight, 2) * Math.Pow(currentX - centerX + 0.5, 2)) +
+                (Math.Pow(halfWidth, 2) * Math.Pow(currentY - centerY, 2)) -
+                (Math.Pow(halfWidth, 2) * Math.Pow(halfHeight, 2)) < 0)
+            {
+                currentX++;
+            }
+        }
+
+        return listToFill;
+    }
+
+    private static void AddFallbackRectangle(double halfWidth, double halfHeight, double centerX, double centerY, List<VecI> coordinates)
+    {
+        int left = (int)Math.Floor(centerX - halfWidth);
+        int top = (int)Math.Floor(centerY - halfHeight);
+        int right = (int)Math.Floor(centerX + halfWidth);
+        int bottom = (int)Math.Floor(centerY + halfHeight);
+
+        for (int x = left; x <= right; x++)
+        {
+            coordinates.Add(new VecI(x, top));
+            if (top != bottom)
+                coordinates.Add(new VecI(x, bottom));
+        }
+
+        for (int y = top + 1; y < bottom; y++)
+        {
+            coordinates.Add(new VecI(left, y));
+            if (left != right)
+                coordinates.Add(new VecI(right, y));
+        }
+    }
+
+    private static void AddRegionPoints(List<VecI> coordinates, double x, double xc, double y, double yc)
+    {
+        int xFloor = (int)Math.Floor(x);
+        int yFloor = (int)Math.Floor(y);
+        int xFloorInv = (int)Math.Floor(-x + 2 * xc);
+        int yFloorInv = (int)Math.Floor(-y + 2 * yc);
+
+        //top and bottom or left and right
+        if (xFloor == xFloorInv || yFloor == yFloorInv)
+        {
+            coordinates.Add(new VecI(xFloorInv, yFloorInv));
+            coordinates.Add(new VecI(xFloor, yFloor));
+        }
+        //part of the arc
+        else
+        {
+            coordinates.Add(new VecI(xFloorInv, yFloor));
+            coordinates.Add(new VecI(xFloor, yFloor));
+            coordinates.Add(new VecI(xFloorInv, yFloorInv));
+            coordinates.Add(new VecI(xFloor, yFloorInv));
+        }
+    }
+}

+ 120 - 0
src/ChunkyImageLib/Operations/EllipseOperation.cs

@@ -0,0 +1,120 @@
+using ChunkyImageLib.DataHolders;
+using SkiaSharp;
+
+namespace ChunkyImageLib.Operations;
+internal class EllipseOperation : IDrawOperation
+{
+    public bool IgnoreEmptyChunks => false;
+
+    private readonly RectI location;
+    private readonly SKColor strokeColor;
+    private readonly SKColor fillColor;
+    private readonly int strokeWidth;
+    private bool init = false;
+    private readonly SKPaint paint;
+    private SKPath? outerPath;
+    private SKPath? innerPath;
+    private SKPoint[]? ellipse;
+    private SKPoint[]? ellipseFill;
+    private RectI? ellipseFillRect;
+
+    public EllipseOperation(RectI location, SKColor strokeColor, SKColor fillColor, int strokeWidth, SKPaint? paint = null)
+    {
+        this.location = location;
+        this.strokeColor = strokeColor;
+        this.fillColor = fillColor;
+        this.strokeWidth = strokeWidth;
+        this.paint = paint?.Clone() ?? new SKPaint();
+    }
+
+    private void Init()
+    {
+        init = true;
+        if (strokeWidth == 1)
+        {
+            var ellipseList = EllipseHelper.GenerateEllipseFromRect(location);
+            ellipse = ellipseList.Select(a => (SKPoint)a).ToArray();
+            if (fillColor.Alpha > 0)
+            {
+                (var fill, ellipseFillRect) = EllipseHelper.SplitEllipseIntoRegions(ellipseList, location);
+                ellipseFill = fill.Select(a => (SKPoint)a).ToArray();
+            }
+        }
+        else
+        {
+            outerPath = new SKPath();
+            outerPath.ArcTo(location, 0, 359, true);
+            innerPath = new SKPath();
+            innerPath.ArcTo(location.Inflate(-strokeWidth), 0, 359, true);
+        }
+    }
+
+    public void DrawOnChunk(Chunk chunk, VecI chunkPos)
+    {
+        if (!init)
+            Init();
+        var surf = chunk.Surface.SkiaSurface;
+        surf.Canvas.Save();
+        surf.Canvas.Scale((float)chunk.Resolution.Multiplier());
+        surf.Canvas.Translate(-chunkPos * ChunkyImage.FullChunkSize);
+
+        paint.IsAntialias = chunk.Resolution != ChunkResolution.Full;
+
+        if (strokeWidth == 1)
+        {
+            if (fillColor.Alpha > 0)
+            {
+                paint.Color = fillColor;
+                surf.Canvas.DrawPoints(SKPointMode.Lines, ellipseFill, paint);
+                surf.Canvas.DrawRect((SKRect)ellipseFillRect!, paint);
+            }
+            paint.Color = strokeColor;
+            surf.Canvas.DrawPoints(SKPointMode.Points, ellipse, paint);
+        }
+        else
+        {
+            if (fillColor.Alpha > 0)
+            {
+                surf.Canvas.Save();
+                surf.Canvas.ClipPath(innerPath);
+                surf.Canvas.DrawColor(fillColor, paint.BlendMode);
+                surf.Canvas.Restore();
+            }
+            surf.Canvas.Save();
+            surf.Canvas.ClipPath(outerPath);
+            surf.Canvas.ClipPath(innerPath, SKClipOperation.Difference);
+            surf.Canvas.DrawColor(strokeColor, paint.BlendMode);
+            surf.Canvas.Restore();
+        }
+        surf.Canvas.Restore();
+    }
+
+    public HashSet<VecI> FindAffectedChunks()
+    {
+        var chunks = OperationHelper.FindChunksTouchingEllipse
+            (location.Center, location.Width / 2.0, location.Height / 2.0, ChunkyImage.FullChunkSize);
+        if (fillColor.Alpha == 0)
+        {
+            chunks.ExceptWith(OperationHelper.FindChunksFullyInsideEllipse
+                (location.Center, location.Width / 2.0 - strokeWidth * 2, location.Height / 2.0 - strokeWidth * 2, ChunkyImage.FullChunkSize));
+        }
+        return chunks;
+    }
+
+    public IDrawOperation AsMirrored(int? verAxisX, int? horAxisY)
+    {
+        RectI newLocation = location;
+        if (verAxisX is not null)
+            newLocation = newLocation.ReflectX((int)verAxisX);
+        if (horAxisY is not null)
+            newLocation = newLocation.ReflectY((int)horAxisY);
+        return new EllipseOperation(newLocation, strokeColor, fillColor, strokeWidth, paint);
+    }
+
+    public void Dispose()
+    {
+        paint.Dispose();
+        outerPath?.Dispose();
+        innerPath?.Dispose();
+    }
+}

+ 11 - 0
src/ChunkyImageLib/Operations/IDrawOperation.cs

@@ -0,0 +1,11 @@
+using ChunkyImageLib.DataHolders;
+
+namespace ChunkyImageLib.Operations;
+
+internal interface IDrawOperation : IOperation
+{
+    bool IgnoreEmptyChunks { get; }
+    void DrawOnChunk(Chunk chunk, VecI chunkPos);
+    HashSet<VecI> FindAffectedChunks();
+    IDrawOperation AsMirrored(int? verAxisX, int? horAxisY);
+}

+ 5 - 0
src/ChunkyImageLib/Operations/IOperation.cs

@@ -0,0 +1,5 @@
+namespace ChunkyImageLib.Operations;
+
+internal interface IOperation : IDisposable
+{
+}

+ 96 - 0
src/ChunkyImageLib/Operations/ImageOperation.cs

@@ -0,0 +1,96 @@
+using ChunkyImageLib.DataHolders;
+using SkiaSharp;
+
+namespace ChunkyImageLib.Operations;
+
+internal class ImageOperation : IDrawOperation
+{
+    private SKMatrix transformMatrix;
+    private ShapeCorners corners;
+    private Surface toPaint;
+    private bool imageWasCopied = false;
+    private readonly SKPaint? customPaint;
+
+    public bool IgnoreEmptyChunks => false;
+
+    public ImageOperation(VecI pos, Surface image, SKPaint? paint = null, bool copyImage = true)
+    {
+        if (paint is not null)
+            customPaint = paint.Clone();
+
+        corners = new()
+        {
+            TopLeft = pos,
+            TopRight = new(pos.X + image.Size.X, pos.Y),
+            BottomRight = pos + image.Size,
+            BottomLeft = new VecD(pos.X, pos.Y + image.Size.Y)
+        };
+        transformMatrix = SKMatrix.CreateIdentity();
+        transformMatrix.TransX = pos.X;
+        transformMatrix.TransY = pos.Y;
+
+        // copying is needed for thread safety
+        if (copyImage)
+            toPaint = new Surface(image);
+        else
+            toPaint = image;
+        imageWasCopied = copyImage;
+    }
+
+    public ImageOperation(ShapeCorners corners, Surface image, SKPaint? paint = null, bool copyImage = true)
+    {
+        if (paint is not null)
+            customPaint = paint.Clone();
+
+        this.corners = corners;
+        transformMatrix = OperationHelper.CreateMatrixFromPoints(corners, image.Size);
+
+        // copying is needed for thread safety
+        if (copyImage)
+            toPaint = new Surface(image);
+        else
+            toPaint = image;
+        imageWasCopied = copyImage;
+    }
+
+    public void DrawOnChunk(Chunk chunk, VecI chunkPos)
+    {
+        //customPaint.FilterQuality = chunk.Resolution != ChunkResolution.Full;
+        float scaleMult = (float)chunk.Resolution.Multiplier();
+        VecD trans = -chunkPos * ChunkPool.FullChunkSize;
+
+        var scaleTrans = SKMatrix.CreateScaleTranslation(scaleMult, scaleMult, (float)trans.X * scaleMult, (float)trans.Y * scaleMult);
+        var finalMatrix = SKMatrix.Concat(scaleTrans, transformMatrix);
+
+        chunk.Surface.SkiaSurface.Canvas.Save();
+        chunk.Surface.SkiaSurface.Canvas.SetMatrix(finalMatrix);
+        chunk.Surface.SkiaSurface.Canvas.DrawSurface(toPaint.SkiaSurface, 0, 0, customPaint);
+        chunk.Surface.SkiaSurface.Canvas.Restore();
+    }
+
+    public HashSet<VecI> FindAffectedChunks()
+    {
+        return OperationHelper.FindChunksTouchingQuadrilateral(corners, ChunkPool.FullChunkSize);
+    }
+
+    public void Dispose()
+    {
+        if (imageWasCopied)
+            toPaint.Dispose();
+        customPaint?.Dispose();
+    }
+
+    public IDrawOperation AsMirrored(int? verAxisX, int? horAxisY)
+    {
+        if (verAxisX is not null && horAxisY is not null)
+            return new ImageOperation
+                (corners.AsMirroredAcrossVerAxis((int)verAxisX).AsMirroredAcrossHorAxis((int)horAxisY), toPaint, customPaint, imageWasCopied);
+        if (verAxisX is not null)
+            return new ImageOperation
+                (corners.AsMirroredAcrossVerAxis((int)verAxisX), toPaint, customPaint, imageWasCopied);
+        if (horAxisY is not null)
+            return new ImageOperation
+                (corners.AsMirroredAcrossHorAxis((int)horAxisY), toPaint, customPaint, imageWasCopied);
+        return new ImageOperation(corners, toPaint, customPaint, imageWasCopied);
+    }
+}

+ 503 - 0
src/ChunkyImageLib/Operations/OperationHelper.cs

@@ -0,0 +1,503 @@
+using ChunkyImageLib.DataHolders;
+using SkiaSharp;
+
+namespace ChunkyImageLib.Operations;
+
+public static class OperationHelper
+{
+    public static VecI ConvertForResolution(VecI pixelPos, ChunkResolution resolution)
+    {
+        var mult = resolution.Multiplier();
+        return new((int)Math.Round(pixelPos.X * mult), (int)Math.Round(pixelPos.Y * mult));
+    }
+
+    public static VecD ConvertForResolution(VecD pixelPos, ChunkResolution resolution)
+    {
+        var mult = resolution.Multiplier();
+        return new(pixelPos.X * mult, pixelPos.Y * mult);
+    }
+
+    /// <summary>
+    /// toModify[x,y].Alpha = Math.Min(toModify[x,y].Alpha, toGetAlphaFrom[x,y].Alpha)
+    /// </summary>
+    public unsafe static void ClampAlpha(SKSurface toModify, SKSurface toGetAlphaFrom)
+    {
+        using (var map = toModify.PeekPixels())
+        {
+            using (var refMap = toGetAlphaFrom.PeekPixels())
+            {
+                long* pixels = (long*)map.GetPixels();
+                long* refPixels = (long*)refMap.GetPixels();
+                int size = map.Width * map.Height;
+                if (map.Width != refMap.Width || map.Height != refMap.Height)
+                    throw new ArgumentException("The surfaces must have the same size");
+
+                for (int i = 0; i < size; i++)
+                {
+                    long* offset = pixels + i;
+                    long* refOffset = refPixels + i;
+                    Half* alpha = (Half*)offset + 3;
+                    Half* refAlpha = (Half*)refOffset + 3;
+                    if (*refAlpha < *alpha)
+                    {
+                        float a = (float)(*alpha);
+                        float r = (float)(*((Half*)offset)) / a;
+                        float g = (float)(*((Half*)offset + 1)) / a;
+                        float b = (float)(*((Half*)offset + 2)) / a;
+                        float newA = (float)(*refAlpha);
+                        Half newR = (Half)(r * newA);
+                        Half newG = (Half)(g * newA);
+                        Half newB = (Half)(b * newA);
+                        *offset = (*(ushort*)(&newR)) | ((long)*(ushort*)(&newG)) << 16 | ((long)*(ushort*)(&newB)) << 32 | ((long)*(ushort*)(refAlpha)) << 48;
+                    }
+                }
+            }
+        }
+    }
+
+    public static ShapeCorners ConvertForResolution(ShapeCorners corners, ChunkResolution resolution)
+    {
+        return new ShapeCorners()
+        {
+            BottomLeft = ConvertForResolution(corners.BottomLeft, resolution),
+            BottomRight = ConvertForResolution(corners.BottomRight, resolution),
+            TopLeft = ConvertForResolution(corners.TopLeft, resolution),
+            TopRight = ConvertForResolution(corners.TopRight, resolution),
+        };
+    }
+
+    public static VecI GetChunkPos(VecI pixelPos, int chunkSize)
+    {
+        return new VecI()
+        {
+            X = (int)MathF.Floor(pixelPos.X / (float)chunkSize),
+            Y = (int)MathF.Floor(pixelPos.Y / (float)chunkSize)
+        };
+    }
+
+    public static SKMatrix CreateMatrixFromPoints(ShapeCorners corners, VecD size)
+        => CreateMatrixFromPoints((SKPoint)corners.TopLeft, (SKPoint)corners.TopRight, (SKPoint)corners.BottomRight, (SKPoint)corners.BottomLeft, (float)size.X, (float)size.Y);
+
+    // see https://stackoverflow.com/questions/48416118/perspective-transform-in-skia/72364829#72364829
+    public static SKMatrix CreateMatrixFromPoints(SKPoint topLeft, SKPoint topRight, SKPoint botRight, SKPoint botLeft, float width, float height)
+    {
+        (float x1, float y1) = (topLeft.X, topLeft.Y);
+        (float x2, float y2) = (topRight.X, topRight.Y);
+        (float x3, float y3) = (botRight.X, botRight.Y);
+        (float x4, float y4) = (botLeft.X, botLeft.Y);
+        (float w, float h) = (width, height);
+
+        float scaleX = (y1 * x2 * x4 - x1 * y2 * x4 + x1 * y3 * x4 - x2 * y3 * x4 - y1 * x2 * x3 + x1 * y2 * x3 - x1 * y4 * x3 + x2 * y4 * x3) / (x2 * y3 * w + y2 * x4 * w - y3 * x4 * w - x2 * y4 * w - y2 * w * x3 + y4 * w * x3);
+        float skewX = (-x1 * x2 * y3 - y1 * x2 * x4 + x2 * y3 * x4 + x1 * x2 * y4 + x1 * y2 * x3 + y1 * x4 * x3 - y2 * x4 * x3 - x1 * y4 * x3) / (x2 * y3 * h + y2 * x4 * h - y3 * x4 * h - x2 * y4 * h - y2 * h * x3 + y4 * h * x3);
+        float transX = x1;
+        float skewY = (-y1 * x2 * y3 + x1 * y2 * y3 + y1 * y3 * x4 - y2 * y3 * x4 + y1 * x2 * y4 - x1 * y2 * y4 - y1 * y4 * x3 + y2 * y4 * x3) / (x2 * y3 * w + y2 * x4 * w - y3 * x4 * w - x2 * y4 * w - y2 * w * x3 + y4 * w * x3);
+        float scaleY = (-y1 * x2 * y3 - y1 * y2 * x4 + y1 * y3 * x4 + x1 * y2 * y4 - x1 * y3 * y4 + x2 * y3 * y4 + y1 * y2 * x3 - y2 * y4 * x3) / (x2 * y3 * h + y2 * x4 * h - y3 * x4 * h - x2 * y4 * h - y2 * h * x3 + y4 * h * x3);
+        float transY = y1;
+        float persp0 = (x1 * y3 - x2 * y3 + y1 * x4 - y2 * x4 - x1 * y4 + x2 * y4 - y1 * x3 + y2 * x3) / (x2 * y3 * w + y2 * x4 * w - y3 * x4 * w - x2 * y4 * w - y2 * w * x3 + y4 * w * x3);
+        float persp1 = (-y1 * x2 + x1 * y2 - x1 * y3 - y2 * x4 + y3 * x4 + x2 * y4 + y1 * x3 - y4 * x3) / (x2 * y3 * h + y2 * x4 * h - y3 * x4 * h - x2 * y4 * h - y2 * h * x3 + y4 * h * x3);
+        float persp2 = 1;
+
+        return new SKMatrix(scaleX, skewX, transX, skewY, scaleY, transY, persp0, persp1, persp2);
+    }
+
+    public static (ShapeCorners, ShapeCorners) CreateStretchedHexagon(VecD centerPos, double hexagonSide, double stretchX)
+    {
+        ShapeCorners left = new ShapeCorners()
+        {
+            TopLeft = centerPos + VecD.FromAngleAndLength(Math.PI * 7 / 6, hexagonSide),
+            TopRight = new VecD(centerPos.X, centerPos.Y - hexagonSide),
+            BottomRight = new VecD(centerPos.X, centerPos.Y + hexagonSide),
+            BottomLeft = centerPos + VecD.FromAngleAndLength(Math.PI * 5 / 6, hexagonSide),
+        };
+        left.TopLeft = new VecD((left.TopLeft.X - centerPos.X) * stretchX + centerPos.X, left.TopLeft.Y);
+        left.BottomLeft = new VecD((left.BottomLeft.X - centerPos.X) * stretchX + centerPos.X, left.BottomLeft.Y);
+        ShapeCorners right = new ShapeCorners()
+        {
+            TopRight = centerPos + VecD.FromAngleAndLength(Math.PI * 11 / 6, hexagonSide),
+            TopLeft = new VecD(centerPos.X, centerPos.Y - hexagonSide),
+            BottomLeft = new VecD(centerPos.X, centerPos.Y + hexagonSide),
+            BottomRight = centerPos + VecD.FromAngleAndLength(Math.PI * 1 / 6, hexagonSide),
+        };
+        right.TopRight = new VecD((right.TopRight.X - centerPos.X) * stretchX + centerPos.X, right.TopRight.Y);
+        right.BottomRight = new VecD((right.BottomRight.X - centerPos.X) * stretchX + centerPos.X, right.BottomRight.Y);
+        return (left, right);
+    }
+
+    public static HashSet<VecI> FindChunksTouchingEllipse(VecD pos, double radiusX, double radiusY, int chunkSize)
+    {
+        const double sqrt3 = 1.73205080757;
+        double hexagonSide = 2.0 / sqrt3 * radiusY;
+        double stretchX = radiusX / radiusY;
+        var (left, right) = CreateStretchedHexagon(pos, hexagonSide, stretchX);
+        var chunks = FindChunksTouchingQuadrilateral(left, chunkSize);
+        chunks.UnionWith(FindChunksTouchingQuadrilateral(right, chunkSize));
+        return chunks;
+    }
+
+    public static HashSet<VecI> FindChunksFullyInsideEllipse(VecD pos, double radiusX, double radiusY, int chunkSize)
+    {
+        double stretchX = radiusX / radiusY;
+        var (left, right) = CreateStretchedHexagon(pos, radiusY, stretchX);
+        var chunks = FindChunksFullyInsideQuadrilateral(left, chunkSize);
+        chunks.UnionWith(FindChunksFullyInsideQuadrilateral(right, chunkSize));
+        return chunks;
+    }
+
+    public static HashSet<VecI> FindChunksTouchingQuadrilateral(ShapeCorners corners, int chunkSize)
+    {
+        if (corners.IsRect && Math.Abs(corners.RectRotation) < 0.0001)
+            return FindChunksTouchingRectangle((RectI)RectD.FromCenterAndSize(corners.RectCenter, corners.RectSize).RoundOutwards(), chunkSize);
+        if (corners.HasNaNOrInfinity ||
+            (corners.BottomLeft - corners.TopRight).Length > chunkSize * 40 * 20 ||
+            (corners.TopLeft - corners.BottomRight).Length > chunkSize * 40 * 20)
+            return new HashSet<VecI>();
+        if (corners.IsInverted)
+            corners = corners with { BottomLeft = corners.TopRight, TopRight = corners.BottomLeft };
+        List<VecI>[] lines = new List<VecI>[] {
+            FindChunksAlongLine(corners.TopRight, corners.TopLeft, chunkSize),
+            FindChunksAlongLine(corners.BottomRight, corners.TopRight, chunkSize),
+            FindChunksAlongLine(corners.BottomLeft, corners.BottomRight, chunkSize),
+            FindChunksAlongLine(corners.TopLeft, corners.BottomLeft, chunkSize)
+        };
+        return FillLines(lines);
+    }
+
+    public static HashSet<VecI> FindChunksFullyInsideQuadrilateral(ShapeCorners corners, int chunkSize)
+    {
+        if (corners.IsRect && Math.Abs(corners.RectRotation) < 0.0001)
+            return FindChunksFullyInsideRectangle((RectI)RectD.FromCenterAndSize(corners.RectCenter, corners.RectSize).RoundOutwards(), chunkSize);
+        if (corners.HasNaNOrInfinity ||
+            (corners.BottomLeft - corners.TopRight).Length > chunkSize * 40 * 20 ||
+            (corners.TopLeft - corners.BottomRight).Length > chunkSize * 40 * 20)
+            return new HashSet<VecI>();
+        if (corners.IsInverted)
+            corners = corners with { BottomLeft = corners.TopRight, TopRight = corners.BottomLeft };
+        List<VecI>[] lines = new List<VecI>[] {
+            FindChunksAlongLine(corners.TopLeft, corners.TopRight, chunkSize),
+            FindChunksAlongLine(corners.TopRight, corners.BottomRight, chunkSize),
+            FindChunksAlongLine(corners.BottomRight, corners.BottomLeft, chunkSize),
+            FindChunksAlongLine(corners.BottomLeft, corners.TopLeft, chunkSize)
+        };
+
+        var output = FillLines(lines);
+
+        //exclude lines
+        for (int i = 0; i < lines.Length; i++)
+        {
+            output.ExceptWith(lines[i]);
+        }
+
+        return output;
+    }
+
+    public static HashSet<VecI> FindChunksTouchingRectangle(RectI rect, int chunkSize)
+    {
+        if (rect.Width > chunkSize * 40 * 20 || rect.Height > chunkSize * 40 * 20)
+            return new HashSet<VecI>();
+
+        VecI min = GetChunkPos(rect.TopLeft, chunkSize);
+        VecI max = GetChunkPosBiased(rect.BottomRight, false, false, chunkSize);
+        HashSet<VecI> output = new();
+        for (int x = min.X; x <= max.X; x++)
+        {
+            for (int y = min.Y; y <= max.Y; y++)
+            {
+                output.Add(new(x, y));
+            }
+        }
+        return output;
+    }
+
+    /// <summary>
+    /// Finds chunks that at least partially lie inside of a rectangle
+    /// </summary>
+    public static HashSet<VecI> FindChunksTouchingRectangle(VecD center, VecD size, double angle, int chunkSize)
+    {
+        if (angle == 0)
+            return FindChunksTouchingRectangle((RectI)RectD.FromCenterAndSize(center, size).RoundOutwards(), chunkSize);
+        if (size.X == 0 || size.Y == 0 || center.IsNaNOrInfinity() || size.IsNaNOrInfinity() || double.IsNaN(angle) || double.IsInfinity(angle))
+            return new HashSet<VecI>();
+        if (size.X > chunkSize * 40 * 20 || size.Y > chunkSize * 40 * 20)
+            return new HashSet<VecI>();
+        // draw a line on the outside of each side
+        var corners = FindRectangleCorners(center, size, angle);
+        List<VecI>[] lines = new List<VecI>[] {
+            FindChunksAlongLine(corners.Item2, corners.Item1, chunkSize),
+            FindChunksAlongLine(corners.Item3, corners.Item2, chunkSize),
+            FindChunksAlongLine(corners.Item4, corners.Item3, chunkSize),
+            FindChunksAlongLine(corners.Item1, corners.Item4, chunkSize)
+        };
+        if (lines[0].Count == 0 || lines[1].Count == 0 || lines[2].Count == 0 || lines[3].Count == 0)
+            return new HashSet<VecI>();
+        return FillLines(lines);
+    }
+
+    public static HashSet<VecI> FillLines(List<VecI>[] lines)
+    {
+        if (lines.Length == 0)
+            return new HashSet<VecI>();
+
+        //find min and max X for each Y in lines
+        var ySel = (VecI vec) => vec.Y;
+        int minY = int.MaxValue;
+        int maxY = int.MinValue;
+        foreach (var line in lines)
+        {
+            minY = Math.Min(line.Min(ySel), minY);
+            maxY = Math.Max(line.Max(ySel), maxY);
+        }
+
+        int[] minXValues = new int[maxY - minY + 1];
+        int[] maxXValues = new int[maxY - minY + 1];
+        for (int i = 0; i < minXValues.Length; i++)
+        {
+            minXValues[i] = int.MaxValue;
+            maxXValues[i] = int.MinValue;
+        }
+
+        for (int i = 0; i < lines.Length; i++)
+        {
+            UpdateMinXValues(lines[i], minXValues, minY);
+            UpdateMaxXValues(lines[i], maxXValues, minY);
+        }
+
+        //draw a line from min X to max X for each Y
+        HashSet<VecI> output = new();
+        for (int i = 0; i < minXValues.Length; i++)
+        {
+            int minX = minXValues[i];
+            int maxX = maxXValues[i];
+            for (int x = minX; x <= maxX; x++)
+                output.Add(new(x, i + minY));
+        }
+
+        return output;
+    }
+
+    public static HashSet<VecI> FindChunksFullyInsideRectangle(RectI rect, int chunkSize)
+    {
+        if (rect.Width > chunkSize * 40 * 20 || rect.Height > chunkSize * 40 * 20)
+            return new HashSet<VecI>();
+        VecI startChunk = GetChunkPosBiased(rect.TopLeft, false, false, ChunkPool.FullChunkSize) + new VecI(1, 1);
+        VecI endChunk = GetChunkPosBiased(rect.BottomRight, true, true, chunkSize) - new VecI(1, 1);
+        HashSet<VecI> output = new();
+        for (int x = startChunk.X; x <= endChunk.X; x++)
+        {
+            for (int y = startChunk.Y; y <= endChunk.Y; y++)
+            {
+                output.Add(new VecI(x, y));
+            }
+        }
+        return output;
+    }
+
+    public static HashSet<VecI> FindChunksFullyInsideRectangle(VecD center, VecD size, double angle, int chunkSize)
+    {
+        if (angle == 0)
+            return FindChunksFullyInsideRectangle((RectI)RectD.FromCenterAndSize(center, size).RoundOutwards(), chunkSize);
+        if (size.X < chunkSize || size.Y < chunkSize || center.IsNaNOrInfinity() || size.IsNaNOrInfinity() || double.IsNaN(angle) || double.IsInfinity(angle))
+            return new HashSet<VecI>();
+        if (size.X > chunkSize * 40 * 20 || size.Y > chunkSize * 40 * 20)
+            return new HashSet<VecI>();
+        // draw a line on the inside of each side
+        var corners = FindRectangleCorners(center, size, angle);
+        List<VecI>[] lines = new List<VecI>[] {
+            FindChunksAlongLine(corners.Item1, corners.Item2, chunkSize),
+            FindChunksAlongLine(corners.Item2, corners.Item3, chunkSize),
+            FindChunksAlongLine(corners.Item3, corners.Item4, chunkSize),
+            FindChunksAlongLine(corners.Item4, corners.Item1, chunkSize)
+        };
+
+        var output = FillLines(lines);
+
+        //exclude lines
+        for (int i = 0; i < lines.Length; i++)
+        {
+            output.ExceptWith(lines[i]);
+        }
+
+        return output;
+    }
+
+    private static void UpdateMinXValues(List<VecI> line, int[] minXValues, int minY)
+    {
+        for (int i = 0; i < line.Count; i++)
+        {
+            if (line[i].X < minXValues[line[i].Y - minY])
+                minXValues[line[i].Y - minY] = line[i].X;
+        }
+    }
+
+    private static void UpdateMaxXValues(List<VecI> line, int[] maxXValues, int minY)
+    {
+        for (int i = 0; i < line.Count; i++)
+        {
+            if (line[i].X > maxXValues[line[i].Y - minY])
+                maxXValues[line[i].Y - minY] = line[i].X;
+        }
+    }
+
+    /// <summary>
+    /// Think of this function as a line drawing algorithm. 
+    /// The chosen chunks are guaranteed to be on the left side of the line (assuming y going upwards and looking from p1 towards p2).
+    /// This ensures that when you draw a filled shape all updated chunks will be covered (the filled part should go to the right of the line)
+    /// No parts of the line will stick out to the left and be left uncovered
+    /// </summary>
+    public static List<VecI> FindChunksAlongLine(VecD p1, VecD p2, int chunkSize)
+    {
+        if (p1 == p2 || p1.IsNaNOrInfinity() || p2.IsNaNOrInfinity())
+            return new List<VecI>();
+
+        //rotate the line into the first quadrant of the coordinate plane
+        int quadrant;
+        if (p2.X >= p1.X && p2.Y >= p1.Y)
+        {
+            quadrant = 1;
+        }
+        else if (p2.X <= p1.X && p2.Y <= p1.Y)
+        {
+            quadrant = 3;
+            p1 = -p1;
+            p2 = -p2;
+        }
+        else if (p2.X < p1.X)
+        {
+            quadrant = 2;
+            (p1.X, p1.Y) = (p1.Y, -p1.X);
+            (p2.X, p2.Y) = (p2.Y, -p2.X);
+        }
+        else
+        {
+            quadrant = 4;
+            (p1.X, p1.Y) = (-p1.Y, p1.X);
+            (p2.X, p2.Y) = (-p2.Y, p2.X);
+        }
+
+        List<VecI> output = new();
+        //vertical line
+        if (p1.X == p2.X)
+        {
+            //if exactly on a chunk boundary, pick the chunk on the top-left
+            VecI start = GetChunkPosBiased(p1, false, true, chunkSize);
+            //if exactly on chunk boundary, pick the chunk on the bottom-left
+            VecI end = GetChunkPosBiased(p2, false, false, chunkSize);
+            for (int y = start.Y; y <= end.Y; y++)
+                output.Add(new(start.X, y));
+        }
+        //horizontal line
+        else if (p1.Y == p2.Y)
+        {
+            //if exactly on a chunk boundary, pick the chunk on the top-right
+            VecI start = GetChunkPosBiased(p1, true, true, chunkSize);
+            //if exactly on chunk boundary, pick the chunk on the top-left
+            VecI end = GetChunkPosBiased(p2, false, true, chunkSize);
+            for (int x = start.X; x <= end.X; x++)
+                output.Add(new(x, start.Y));
+        }
+        //all other lines
+        else
+        {
+            //y = mx + b
+            double m = (p2.Y - p1.Y) / (p2.X - p1.X);
+            double b = p1.Y - (p1.X * m);
+            VecI cur = GetChunkPosBiased(p1, true, true, chunkSize);
+            output.Add(cur);
+            if (LineEq(m, cur.X * chunkSize + chunkSize, b) > cur.Y * chunkSize + chunkSize)
+                cur.X--;
+            VecI end = GetChunkPosBiased(p2, false, false, chunkSize);
+            if (m < 1)
+            {
+                while (true)
+                {
+                    if (LineEq(m, cur.X * chunkSize + chunkSize * 2, b) > cur.Y * chunkSize + chunkSize)
+                    {
+                        cur.X++;
+                        cur.Y++;
+                    }
+                    else
+                    {
+                        cur.X++;
+                    }
+                    if (cur.X >= end.X && cur.Y >= end.Y)
+                        break;
+                    output.Add(cur);
+                }
+                output.Add(end);
+            }
+            else
+            {
+                while (true)
+                {
+                    if (LineEq(m, cur.X * chunkSize + chunkSize, b) <= cur.Y * chunkSize + chunkSize)
+                    {
+                        cur.X++;
+                        cur.Y++;
+                    }
+                    else
+                    {
+                        cur.Y++;
+                    }
+                    if (cur.X >= end.X && cur.Y >= end.Y)
+                        break;
+                    output.Add(cur);
+                }
+                output.Add(end);
+            }
+        }
+
+        //rotate output back
+        if (quadrant == 1)
+            return output;
+        if (quadrant == 3)
+        {
+            for (int i = 0; i < output.Count; i++)
+                output[i] = new(-output[i].X - 1, -output[i].Y - 1);
+            return output;
+        }
+        if (quadrant == 2)
+        {
+            for (int i = 0; i < output.Count; i++)
+                output[i] = new(-output[i].Y - 1, output[i].X);
+            return output;
+        }
+        for (int i = 0; i < output.Count; i++)
+            output[i] = new(output[i].Y, -output[i].X - 1);
+        return output;
+    }
+
+    private static double LineEq(double m, double x, double b)
+    {
+        return m * x + b;
+    }
+
+    /// <summary>
+    /// "Bias" specifies how to handle whole values. This function behaves the same as GetChunkPos for fractional values.
+    /// Examples if you pass (0, 0):
+    /// If both positiveX and positiveY are true it behaves like GetChunkPos, you get chunk (0, 0)
+    /// If both are false you'll get (-1, -1), because the right and bottom boundaries are now considered to be part of the chunk, and top and left aren't.
+    /// </summary>
+    public static VecI GetChunkPosBiased(VecD pos, bool positiveX, bool positiveY, int chunkSize)
+    {
+        pos /= chunkSize;
+        return new VecI()
+        {
+            X = positiveX ? (int)Math.Floor(pos.X) : (int)Math.Ceiling(pos.X) - 1,
+            Y = positiveY ? (int)Math.Floor(pos.Y) : (int)Math.Ceiling(pos.Y) - 1,
+        };
+    }
+
+    /// <summary>
+    /// Returns corners in ccw direction (assuming y points up)
+    /// </summary>
+    private static (VecD, VecD, VecD, VecD) FindRectangleCorners(VecD center, VecD size, double angle)
+    {
+        VecD right = VecD.FromAngleAndLength(angle, size.X / 2);
+        VecD up = VecD.FromAngleAndLength(angle + Math.PI / 2, size.Y / 2);
+        return (
+            center + right + up,
+            center - right + up,
+            center - right - up,
+            center + right - up
+            );
+    }
+}

+ 58 - 0
src/ChunkyImageLib/Operations/PathOperation.cs

@@ -0,0 +1,58 @@
+using ChunkyImageLib.DataHolders;
+using SkiaSharp;
+
+namespace ChunkyImageLib.Operations;
+internal class PathOperation : IDrawOperation
+{
+    private readonly SKPath path;
+
+    private readonly SKPaint paint;
+    private readonly RectI bounds;
+
+    public bool IgnoreEmptyChunks => false;
+
+    public PathOperation(SKPath path, SKColor color, float strokeWidth, SKStrokeCap cap, SKBlendMode blendMode, RectI? customBounds = null)
+    {
+        this.path = new SKPath(path);
+        paint = new() { Color = color, Style = SKPaintStyle.Stroke, StrokeWidth = strokeWidth, StrokeCap = cap, BlendMode = blendMode };
+
+        RectI floatBounds = customBounds ?? (RectI)((RectD)path.TightBounds).RoundOutwards();
+        bounds = floatBounds.Inflate((int)Math.Ceiling(strokeWidth) + 1);
+    }
+
+    public void DrawOnChunk(Chunk chunk, VecI chunkPos)
+    {
+        paint.IsAntialias = chunk.Resolution != ChunkResolution.Full;
+        var surf = chunk.Surface.SkiaSurface;
+        surf.Canvas.Save();
+        surf.Canvas.Scale((float)chunk.Resolution.Multiplier());
+        surf.Canvas.Translate(-chunkPos * ChunkyImage.FullChunkSize);
+        surf.Canvas.DrawPath(path, paint);
+        surf.Canvas.Restore();
+    }
+
+    public HashSet<VecI> FindAffectedChunks()
+    {
+        return OperationHelper.FindChunksTouchingRectangle(bounds, ChunkyImage.FullChunkSize);
+    }
+
+    public IDrawOperation AsMirrored(int? verAxisX, int? horAxisY)
+    {
+        var matrix = SKMatrix.CreateScale(verAxisX is not null ? -1 : 1, horAxisY is not null ? -1 : 1, verAxisX ?? 0, horAxisY ?? 0);
+        using var copy = new SKPath(path);
+        copy.Transform(matrix);
+
+        RectI newBounds = bounds;
+        if (verAxisX is not null)
+            newBounds = newBounds.ReflectX((int)verAxisX);
+        if (horAxisY is not null)
+            newBounds = newBounds.ReflectY((int)horAxisY);
+        return new PathOperation(copy, paint.Color, paint.StrokeWidth, paint.StrokeCap, paint.BlendMode, newBounds);
+    }
+
+    public void Dispose()
+    {
+        path.Dispose();
+        paint.Dispose();
+    }
+}

+ 54 - 0
src/ChunkyImageLib/Operations/PixelOperation.cs

@@ -0,0 +1,54 @@
+using ChunkyImageLib.DataHolders;
+using SkiaSharp;
+
+namespace ChunkyImageLib.Operations;
+
+internal class PixelOperation : IDrawOperation
+{
+    public bool IgnoreEmptyChunks => false;
+    private readonly VecI pixel;
+    private readonly SKColor color;
+    private readonly SKBlendMode blendMode;
+    private readonly SKPaint paint;
+
+    public PixelOperation(VecI pixel, SKColor color, SKBlendMode blendMode)
+    {
+        this.pixel = pixel;
+        this.color = color;
+        this.blendMode = blendMode;
+        paint = new SKPaint() { BlendMode = blendMode };
+    }
+
+    public void DrawOnChunk(Chunk chunk, VecI chunkPos)
+    {
+        // a hacky way to make the lines look slightly better on non full res chunks
+        paint.Color = new SKColor(color.Red, color.Green, color.Blue, (byte)(color.Alpha * chunk.Resolution.Multiplier()));
+
+        SKSurface surf = chunk.Surface.SkiaSurface;
+        surf.Canvas.Save();
+        surf.Canvas.Scale((float)chunk.Resolution.Multiplier());
+        surf.Canvas.Translate(-chunkPos * ChunkyImage.FullChunkSize);
+        surf.Canvas.DrawPoint(pixel, paint);
+        surf.Canvas.Restore();
+    }
+
+    public HashSet<VecI> FindAffectedChunks()
+    {
+        return new HashSet<VecI>() { OperationHelper.GetChunkPos(pixel, ChunkyImage.FullChunkSize) };
+    }
+
+    public IDrawOperation AsMirrored(int? verAxisX, int? horAxisY)
+    {
+        RectI pixelRect = new RectI(pixel, new VecI(1, 1));
+        if (verAxisX is not null)
+            pixelRect = pixelRect.ReflectX((int)verAxisX);
+        if (horAxisY is not null)
+            pixelRect = pixelRect.ReflectY((int)horAxisY);
+        return new PixelOperation(pixelRect.Pos, color, blendMode);
+    }
+
+    public void Dispose()
+    {
+        paint.Dispose();
+    }
+}

+ 53 - 0
src/ChunkyImageLib/Operations/PixelsOperation.cs

@@ -0,0 +1,53 @@
+using ChunkyImageLib.DataHolders;
+using SkiaSharp;
+
+namespace ChunkyImageLib.Operations;
+
+internal class PixelsOperation : IDrawOperation
+{
+    public bool IgnoreEmptyChunks => false;
+    private readonly SKPoint[] pixels;
+    private readonly SKColor color;
+    private readonly SKBlendMode blendMode;
+    private readonly SKPaint paint;
+
+    public PixelsOperation(IEnumerable<VecI> pixels, SKColor color, SKBlendMode blendMode)
+    {
+        this.pixels = pixels.Select(pixel => (SKPoint)pixel).ToArray();
+        this.color = color;
+        this.blendMode = blendMode;
+        paint = new SKPaint() { BlendMode = blendMode };
+    }
+
+    public void DrawOnChunk(Chunk chunk, VecI chunkPos)
+    {
+        // a hacky way to make the lines look slightly better on non full res chunks
+        paint.Color = new SKColor(color.Red, color.Green, color.Blue, (byte)(color.Alpha * chunk.Resolution.Multiplier()));
+
+        SKSurface surf = chunk.Surface.SkiaSurface;
+        surf.Canvas.Save();
+        surf.Canvas.Scale((float)chunk.Resolution.Multiplier());
+        surf.Canvas.Translate(-chunkPos * ChunkyImage.FullChunkSize);
+        surf.Canvas.DrawPoints(SKPointMode.Points, pixels, paint);
+        surf.Canvas.Restore();
+    }
+
+    public HashSet<VecI> FindAffectedChunks()
+    {
+        return pixels.Select(static pixel => OperationHelper.GetChunkPos((VecI)pixel, ChunkyImage.FullChunkSize)).ToHashSet();
+    }
+
+    public IDrawOperation AsMirrored(int? verAxisX, int? horAxisY)
+    {
+        var arr = pixels.Select(pixel => new VecI(
+            verAxisX is not null ? 2 * (int)verAxisX - (int)pixel.X - 1 : (int)pixel.X,
+            horAxisY is not null ? 2 * (int)horAxisY - (int)pixel.Y - 1 : (int)pixel.Y
+        ));
+        return new PixelsOperation(arr, color, blendMode);
+    }
+
+    public void Dispose()
+    {
+        paint.Dispose();
+    }
+}

+ 81 - 0
src/ChunkyImageLib/Operations/RectangleOperation.cs

@@ -0,0 +1,81 @@
+using ChunkyImageLib.DataHolders;
+using SkiaSharp;
+
+namespace ChunkyImageLib.Operations;
+
+internal class RectangleOperation : IDrawOperation
+{
+    public RectangleOperation(ShapeData rect)
+    {
+        Data = rect;
+    }
+
+    public ShapeData Data { get; }
+
+    public bool IgnoreEmptyChunks => false;
+
+    public void DrawOnChunk(Chunk chunk, VecI chunkPos)
+    {
+        var skiaSurf = chunk.Surface.SkiaSurface;
+
+        var surf = chunk.Surface.SkiaSurface;
+
+        var rect = RectD.FromCenterAndSize(Data.Center, Data.Size.Abs());
+        var innerRect = rect.Inflate(-Data.StrokeWidth);
+        if (innerRect.IsZeroOrNegativeArea)
+            innerRect = RectD.Empty;
+
+        surf.Canvas.Save();
+        surf.Canvas.Scale((float)chunk.Resolution.Multiplier());
+        surf.Canvas.Translate(-chunkPos * ChunkyImage.FullChunkSize);
+        skiaSurf.Canvas.RotateRadians((float)Data.Angle, (float)rect.Center.X, (float)rect.Center.Y);
+
+        // draw fill
+        if (Data.FillColor.Alpha > 0)
+        {
+            skiaSurf.Canvas.Save();
+            skiaSurf.Canvas.ClipRect((SKRect)innerRect);
+            skiaSurf.Canvas.DrawColor(Data.FillColor, Data.BlendMode);
+            skiaSurf.Canvas.Restore();
+        }
+
+        // draw stroke
+        skiaSurf.Canvas.Save();
+        skiaSurf.Canvas.ClipRect((SKRect)rect);
+        skiaSurf.Canvas.ClipRect((SKRect)innerRect, SKClipOperation.Difference);
+        skiaSurf.Canvas.DrawColor(Data.StrokeColor, Data.BlendMode);
+        skiaSurf.Canvas.Restore();
+
+        surf.Canvas.Restore();
+    }
+
+    public HashSet<VecI> FindAffectedChunks()
+    {
+        if (Math.Abs(Data.Size.X) < 1 || Math.Abs(Data.Size.Y) < 1 || Data.StrokeColor.Alpha == 0 && Data.FillColor.Alpha == 0)
+            return new();
+        if (Data.FillColor.Alpha != 0 || Math.Abs(Data.Size.X) == 1 || Math.Abs(Data.Size.Y) == 1)
+            return OperationHelper.FindChunksTouchingRectangle(Data.Center, Data.Size.Abs(), Data.Angle, ChunkPool.FullChunkSize);
+
+        var chunks = OperationHelper.FindChunksTouchingRectangle(Data.Center, Data.Size.Abs(), Data.Angle, ChunkPool.FullChunkSize);
+        chunks.ExceptWith(
+            OperationHelper.FindChunksFullyInsideRectangle(
+                Data.Center,
+                Data.Size.Abs() - new VecD(Data.StrokeWidth * 2, Data.StrokeWidth * 2),
+                Data.Angle,
+                ChunkPool.FullChunkSize));
+        return chunks;
+    }
+
+    public void Dispose() { }
+
+    public IDrawOperation AsMirrored(int? verAxisX, int? horAxisY)
+    {
+        if (verAxisX is not null && horAxisY is not null)
+            return new RectangleOperation(Data.AsMirroredAcrossHorAxis((int)horAxisY).AsMirroredAcrossVerAxis((int)verAxisX));
+        else if (verAxisX is not null)
+            return new RectangleOperation(Data.AsMirroredAcrossVerAxis((int)verAxisX));
+        else if (horAxisY is not null)
+            return new RectangleOperation(Data.AsMirroredAcrossHorAxis((int)horAxisY));
+        return new RectangleOperation(Data);
+    }
+}

+ 13 - 0
src/ChunkyImageLib/Operations/ResizeOperation.cs

@@ -0,0 +1,13 @@
+using ChunkyImageLib.DataHolders;
+
+namespace ChunkyImageLib.Operations;
+
+internal record class ResizeOperation : IOperation
+{
+    public VecI Size { get; }
+    public ResizeOperation(VecI size)
+    {
+        Size = size;
+    }
+    public void Dispose() { }
+}

+ 65 - 0
src/ChunkyImageLib/Operations/SkiaLineOperation.cs

@@ -0,0 +1,65 @@
+using ChunkyImageLib.DataHolders;
+using SkiaSharp;
+
+namespace ChunkyImageLib.Operations;
+internal class SkiaLineOperation : IDrawOperation
+{
+    public bool IgnoreEmptyChunks => false;
+
+    private SKPaint paint;
+    private readonly VecI from;
+    private readonly VecI to;
+
+    public SkiaLineOperation(VecI from, VecI to, SKStrokeCap strokeCap, float strokeWidth, SKColor color, SKBlendMode blendMode)
+    {
+        paint = new()
+        {
+            StrokeCap = strokeCap,
+            StrokeWidth = strokeWidth,
+            Color = color,
+            Style = SKPaintStyle.Stroke,
+            BlendMode = blendMode,
+        };
+        this.from = from;
+        this.to = to;
+    }
+
+    public void DrawOnChunk(Chunk chunk, VecI chunkPos)
+    {
+        paint.IsAntialias = chunk.Resolution != ChunkResolution.Full;
+        var surf = chunk.Surface.SkiaSurface;
+        surf.Canvas.Save();
+        surf.Canvas.Scale((float)chunk.Resolution.Multiplier());
+        surf.Canvas.Translate(-chunkPos * ChunkyImage.FullChunkSize);
+        surf.Canvas.DrawLine(from, to, paint);
+        surf.Canvas.Restore();
+    }
+
+    public HashSet<VecI> FindAffectedChunks()
+    {
+        RectI bounds = RectI.FromTwoPoints(from, to).Inflate((int)Math.Ceiling(paint.StrokeWidth));
+        return OperationHelper.FindChunksTouchingRectangle(bounds, ChunkyImage.FullChunkSize);
+    }
+
+    public IDrawOperation AsMirrored(int? verAxisX, int? horAxisY)
+    {
+        VecI newFrom = from;
+        VecI newTo = to;
+        if (verAxisX is not null)
+        {
+            newFrom = newFrom.ReflectX((int)verAxisX);
+            newTo = newFrom.ReflectX((int)verAxisX);
+        }
+        if (horAxisY is not null)
+        {
+            newFrom = newFrom.ReflectY((int)horAxisY);
+            newTo = newFrom.ReflectY((int)horAxisY);
+        }
+        return new SkiaLineOperation(newFrom, newTo, paint.StrokeCap, paint.StrokeWidth, paint.Color, paint.BlendMode);
+    }
+
+    public void Dispose()
+    {
+        paint.Dispose();
+    }
+}

+ 129 - 0
src/ChunkyImageLib/Surface.cs

@@ -0,0 +1,129 @@
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+using ChunkyImageLib.DataHolders;
+using SkiaSharp;
+
+namespace ChunkyImageLib;
+
+public class Surface : IDisposable
+{
+    private bool disposed;
+    public IntPtr PixelBuffer { get; }
+    public SKSurface SkiaSurface { get; }
+    public int BytesPerPixel { get; }
+    public VecI Size { get; }
+
+    private SKPaint drawingPaint = new SKPaint() { BlendMode = SKBlendMode.Src };
+
+    public Surface(VecI size)
+    {
+        if (size.X < 1 || size.Y < 1)
+            throw new ArgumentException("Width and height must be >1");
+        if (size.X > 10000 || size.Y > 10000)
+            throw new ArgumentException("Width and height must be <=10000");
+
+        Size = size;
+
+        BytesPerPixel = 8;
+        PixelBuffer = CreateBuffer(size.X, size.Y, BytesPerPixel);
+        SkiaSurface = CreateSKSurface();
+    }
+
+    public Surface(Surface original) : this(original.Size)
+    {
+        SkiaSurface.Canvas.DrawSurface(original.SkiaSurface, 0, 0);
+    }
+
+    public static Surface Load(string path)
+    {
+        if (!File.Exists(path))
+            throw new FileNotFoundException(null, path);
+        using var bitmap = SKBitmap.Decode(path);
+        if (bitmap is null)
+            throw new ArgumentException($"The image with path {path} couldn't be loaded");
+        var surface = new Surface(new VecI(bitmap.Width, bitmap.Height));
+        surface.SkiaSurface.Canvas.DrawBitmap(bitmap, 0, 0);
+        return surface;
+    }
+
+    public unsafe void CopyTo(Surface other)
+    {
+        if (other.Size != Size)
+            throw new ArgumentException("Target Surface must have the same dimensions");
+        int bytesC = Size.X * Size.Y * BytesPerPixel;
+        using var pixmap = other.SkiaSurface.PeekPixels();
+        Buffer.MemoryCopy((void*)PixelBuffer, (void*)pixmap.GetPixels(), bytesC, bytesC);
+    }
+
+    /// <summary>
+    /// Consider getting a pixmap from SkiaSurface.PeekPixels().GetPixels() and writing into it's buffer for bulk pixel get/set. Don't forget to dispose the pixmap afterwards.
+    /// </summary>
+    public unsafe SKColor GetSRGBPixel(VecI pos)
+    {
+        Half* ptr = (Half*)(PixelBuffer + (pos.X + pos.Y * Size.X) * BytesPerPixel);
+        float a = (float)ptr[3];
+        return (SKColor)new SKColorF((float)ptr[0] / a, (float)ptr[1] / a, (float)ptr[2] / a, (float)ptr[3]);
+    }
+
+    public void SetSRGBPixel(VecI pos, SKColor color)
+    {
+        drawingPaint.Color = color;
+        SkiaSurface.Canvas.DrawPoint(pos.X, pos.Y, drawingPaint);
+    }
+
+    public unsafe bool IsFullyTransparent()
+    {
+        ulong* ptr = (ulong*)PixelBuffer;
+        for (int i = 0; i < Size.X * Size.Y; i++)
+        {
+            // ptr[i] actually contains 4 16-bit floats. We only care about the first one which is alpha.
+            // An empty pixel can have alpha of 0 or -0 (not sure if -0 actually ever comes up). 0 in hex is 0x0, -0 in hex is 0x8000
+            if ((ptr[i] & 0x1111_0000_0000_0000) != 0 && (ptr[i] & 0x1111_0000_0000_0000) != 0x8000_0000_0000_0000)
+                return false;
+        }
+        return true;
+    }
+
+    public void SaveToDesktop(string filename = "savedSurface.png")
+    {
+        using var final = SKSurface.Create(new SKImageInfo(Size.X, Size.Y, SKColorType.Rgba8888, SKAlphaType.Premul, SKColorSpace.CreateSrgb()));
+        final.Canvas.DrawSurface(SkiaSurface, 0, 0);
+        using (var snapshot = final.Snapshot())
+        {
+            using var stream = File.Create(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory), filename));
+            using var png = snapshot.Encode();
+            png.SaveTo(stream);
+        }
+    }
+
+    private SKSurface CreateSKSurface()
+    {
+        var surface = SKSurface.Create(new SKImageInfo(Size.X, Size.Y, SKColorType.RgbaF16, SKAlphaType.Premul, SKColorSpace.CreateSrgb()), PixelBuffer);
+        if (surface is null)
+            throw new InvalidOperationException($"Could not create surface (Size:{Size})");
+        return surface;
+    }
+
+    private unsafe static IntPtr CreateBuffer(int width, int height, int bytesPerPixel)
+    {
+        int byteC = width * height * bytesPerPixel;
+        var buffer = Marshal.AllocHGlobal(byteC);
+        Unsafe.InitBlockUnaligned((byte*)buffer, 0, (uint)byteC);
+        return buffer;
+    }
+
+    public void Dispose()
+    {
+        if (disposed)
+            return;
+        disposed = true;
+        drawingPaint.Dispose();
+        Marshal.FreeHGlobal(PixelBuffer);
+        GC.SuppressFinalize(this);
+    }
+
+    ~Surface()
+    {
+        Marshal.FreeHGlobal(PixelBuffer);
+    }
+}

+ 14 - 0
src/ChunkyImageLibBenchmark/ChunkyImageLibBenchmark.csproj

@@ -0,0 +1,14 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <OutputType>Exe</OutputType>
+    <TargetFramework>net6.0</TargetFramework>
+    <ImplicitUsings>enable</ImplicitUsings>
+    <Nullable>enable</Nullable>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <ProjectReference Include="..\ChunkyImageLib\ChunkyImageLib.csproj" />
+  </ItemGroup>
+
+</Project>

+ 52 - 0
src/ChunkyImageLibBenchmark/Program.cs

@@ -0,0 +1,52 @@
+using ChunkyImageLib;
+using SkiaSharp;
+using System.Diagnostics;
+
+
+for (int i = 0; i < 10; i++)
+{
+    Benchmark();
+}
+
+int count = 10000;
+double totalFirst = 0;
+double totalSecond = 0;
+for (int i = 0; i < count; i++)
+{
+    (double first, double second) = Benchmark();
+    totalFirst += first;
+    totalSecond += second;
+}
+
+Console.WriteLine($"took {totalFirst / count} ms first, then {totalSecond / count} ms");
+Console.ReadKey();
+
+(double first, double second) Benchmark()
+{
+    using ChunkyImage image = new(new(1024, 1024));
+    image.EnqueueDrawRectangle(new(new(0, 0), new(1024, 1024), 10, SKColors.Black, SKColors.Bisque));
+
+    Stopwatch sw = Stopwatch.StartNew();
+    for (int i = 0; i < 4; i++)
+    {
+        for (int j = 0; j < 4; j++)
+        {
+            //image.GetLatestChunk(new(i, j), ChunkyImageLib.DataHolders.ChunkResolution.Full);
+        }
+    }
+    sw.Stop();
+    double first = sw.ElapsedTicks / (double)Stopwatch.Frequency * 1000;
+
+    sw = Stopwatch.StartNew();
+    for (int i = 0; i < 4; i++)
+    {
+        for (int j = 0; j < 4; j++)
+        {
+            //image.GetLatestChunk(new(i, j), ChunkyImageLib.DataHolders.ChunkResolution.Full);
+        }
+    }
+    sw.Stop();
+    double second = sw.ElapsedTicks / (double)Stopwatch.Frequency * 1000;
+
+    return (first, second);
+}

+ 27 - 0
src/ChunkyImageLibTest/ChunkyImageLibTest.csproj

@@ -0,0 +1,27 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <TargetFramework>net6.0</TargetFramework>
+    <Nullable>enable</Nullable>
+
+    <IsPackable>false</IsPackable>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.11.0" />
+    <PackageReference Include="xunit" Version="2.4.1" />
+    <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3">
+      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+      <PrivateAssets>all</PrivateAssets>
+    </PackageReference>
+    <PackageReference Include="coverlet.collector" Version="3.1.0">
+      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+      <PrivateAssets>all</PrivateAssets>
+    </PackageReference>
+  </ItemGroup>
+
+  <ItemGroup>
+    <ProjectReference Include="..\ChunkyImageLib\ChunkyImageLib.csproj" />
+  </ItemGroup>
+
+</Project>

+ 99 - 0
src/ChunkyImageLibTest/ChunkyImageTests.cs

@@ -0,0 +1,99 @@
+using ChunkyImageLib;
+using ChunkyImageLib.DataHolders;
+using SkiaSharp;
+using Xunit;
+
+namespace ChunkyImageLibTest;
+public class ChunkyImageTests
+{
+    [Fact]
+    public void Dispose_ComplexImage_ReturnsAllChunks()
+    {
+        ChunkyImage image = new ChunkyImage(new(ChunkyImage.FullChunkSize, ChunkyImage.FullChunkSize));
+        image.EnqueueDrawRectangle(new(new(5, 5), new(80, 80), 0, 2, SKColors.AliceBlue, SKColors.Snow));
+        using (Chunk target = Chunk.Create())
+        {
+            image.DrawMostUpToDateChunkOn(new(0, 0), ChunkResolution.Full, target.Surface.SkiaSurface, VecI.Zero);
+            image.CancelChanges();
+            image.EnqueueResize(new(ChunkyImage.FullChunkSize * 4, ChunkyImage.FullChunkSize * 4));
+            image.EnqueueDrawRectangle(new(VecD.Zero, image.CommittedSize, 0, 2, SKColors.AliceBlue, SKColors.Snow, SKBlendMode.Multiply));
+            image.CommitChanges();
+            image.SetBlendMode(SKBlendMode.Overlay);
+            image.EnqueueDrawRectangle(new(VecD.Zero, image.CommittedSize, 0, 2, SKColors.AliceBlue, SKColors.Snow, SKBlendMode.Multiply));
+            image.EnqueueDrawRectangle(new(VecD.Zero, image.CommittedSize, 0, 2, SKColors.AliceBlue, SKColors.Snow));
+            image.CommitChanges();
+            image.SetBlendMode(SKBlendMode.Screen);
+            image.EnqueueDrawRectangle(new(VecD.Zero, image.CommittedSize, 0, 2, SKColors.AliceBlue, SKColors.Snow));
+            image.CancelChanges();
+            image.SetBlendMode(SKBlendMode.SrcOver);
+            image.EnqueueDrawRectangle(new(VecD.Zero, image.CommittedSize, 0, 2, SKColors.AliceBlue, SKColors.Snow));
+        }
+        image.Dispose();
+
+        Assert.Equal(0, Chunk.ChunkCounter);
+    }
+
+    [Fact]
+    public void GetCommittedPixel_RedImage_ReturnsRedPixel()
+    {
+        const int chunkSize = ChunkyImage.FullChunkSize;
+        ChunkyImage image = new ChunkyImage(new VecI(chunkSize * 2));
+        image.EnqueueDrawRectangle
+            (new ShapeData(new VecD(chunkSize), new VecD(chunkSize * 2), 0, 0, SKColors.Transparent, SKColors.Red));
+        image.CommitChanges();
+        Assert.Equal(SKColors.Red, image.GetCommittedPixel(new VecI(chunkSize + chunkSize / 2)));
+        image.Dispose();
+        Assert.Equal(0, Chunk.ChunkCounter);
+    }
+
+    [Fact]
+    public void GetMostUpToDatePixel_BlendModeSrc_ReturnsCorrectPixel()
+    {
+        const int chunkSize = ChunkyImage.FullChunkSize;
+        ChunkyImage image = new ChunkyImage(new VecI(chunkSize * 2));
+        image.EnqueueDrawRectangle
+            (new ShapeData(new VecD(chunkSize), new VecD(chunkSize * 2), 0, 0, SKColors.Transparent, SKColors.Red));
+        Assert.Equal(SKColors.Red, image.GetMostUpToDatePixel(new VecI(chunkSize + chunkSize / 2)));
+        image.Dispose();
+        Assert.Equal(0, Chunk.ChunkCounter);
+    }
+    
+    [Fact]
+    public void GetMostUpToDatePixel_BlendModeSrcOver_ReturnsCorrectPixel()
+    {
+        const int chunkSize = ChunkyImage.FullChunkSize;
+        ChunkyImage image = new ChunkyImage(new VecI(chunkSize * 2));
+        image.EnqueueDrawRectangle
+            (new ShapeData(new VecD(chunkSize), new VecD(chunkSize * 2), 0, 0, SKColors.Transparent, SKColors.Red));
+        image.CommitChanges();
+        image.SetBlendMode(SKBlendMode.SrcOver);
+        image.EnqueueDrawRectangle(new ShapeData(
+            new VecD(chunkSize),
+            new VecD(chunkSize * 2),
+            0, 
+            0,
+            SKColors.Transparent,
+            new SKColor(0, 255, 0, 128)));
+        Assert.Equal(new SKColor(127, 128, 0), image.GetMostUpToDatePixel(new VecI(chunkSize + chunkSize / 2)));
+        image.Dispose();
+        Assert.Equal(0, Chunk.ChunkCounter);
+    }
+
+    [Fact]
+    public void EnqueueDrawRectangle_OutsideOfImage_PartsAreNotDrawn()
+    {
+        const int chunkSize = ChunkyImage.FullChunkSize;
+        using ChunkyImage image = new(new VecI(chunkSize));
+        image.EnqueueDrawRectangle(new ShapeData(
+                VecD.Zero,
+                new VecD(chunkSize * 10),
+                0,
+                0,
+                SKColors.Transparent,
+                SKColors.Red));
+        image.CommitChanges();
+        Assert.Collection(
+            image.FindAllChunks(), 
+            elem => Assert.Equal(VecI.Zero, elem));
+    }
+}

+ 40 - 0
src/ChunkyImageLibTest/ClearRegionOperationTests.cs

@@ -0,0 +1,40 @@
+using System.Collections.Generic;
+using ChunkyImageLib;
+using ChunkyImageLib.DataHolders;
+using ChunkyImageLib.Operations;
+using Xunit;
+
+namespace ChunkyImageLibTest;
+
+public class ClearRegionOperationTests
+{
+    const int chunkSize = ChunkPool.FullChunkSize;
+    [Fact]
+    public void FindAffectedChunks_SingleChunk_ReturnsSingleChunk()
+    {
+        ClearRegionOperation operation = new(new(new(chunkSize, chunkSize), new(chunkSize, chunkSize)));
+        var expected = new HashSet<VecI>() { new(1, 1) };
+        var actual = operation.FindAffectedChunks();
+        Assert.Equal(expected, actual);
+    }
+
+// to keep expected aligned
+#pragma warning disable format
+    [Fact]
+    public void FindAffectedChunks_BigArea_ReturnsCorrectChunks()
+    {
+        int from = -chunkSize - chunkSize / 2;
+        int to = chunkSize + chunkSize / 2;
+        ClearRegionOperation operation = new(new(new(from, from), new(to - from, to - from)));
+        var expected = new HashSet<VecI>() 
+        { 
+            new(-2, -2), new(-1, -2), new(0, -2), new(1, -2),
+            new(-2, -1), new(-1, -1), new(0, -1), new(1, -1),
+            new(-2, -0), new(-1, -0), new(0, -0), new(1, -0),
+            new(-2,  1), new(-1,  1), new(0,  1), new(1,  1),
+        };
+        var actual = operation.FindAffectedChunks();
+        Assert.Equal(expected, actual);
+    }
+#pragma warning restore format
+}

+ 18 - 0
src/ChunkyImageLibTest/ImageOperationTests.cs

@@ -0,0 +1,18 @@
+using System.Collections.Generic;
+using ChunkyImageLib;
+using ChunkyImageLib.DataHolders;
+using ChunkyImageLib.Operations;
+using Xunit;
+
+namespace ChunkyImageLibTest;
+public class ImageOperationTests
+{
+    [Fact]
+    public void FindAffectedChunks_SingleChunk_ReturnsSingleChunk()
+    {
+        using Surface testImage = new Surface((ChunkyImage.FullChunkSize, ChunkyImage.FullChunkSize));
+        using ImageOperation operation = new((ChunkyImage.FullChunkSize, ChunkyImage.FullChunkSize), testImage);
+        var chunks = operation.FindAffectedChunks();
+        Assert.Equal(new HashSet<VecI>() { new(1, 1) }, chunks);
+    }
+}

+ 102 - 0
src/ChunkyImageLibTest/OperationHelperTests.cs

@@ -0,0 +1,102 @@
+using System;
+using System.Collections.Generic;
+using ChunkyImageLib;
+using ChunkyImageLib.DataHolders;
+using ChunkyImageLib.Operations;
+using Xunit;
+
+namespace ChunkyImageLibTest;
+
+public class OperationHelperTests
+{
+    [Theory]
+    [InlineData(0, 0, 0, 0)]
+    [InlineData(-1, -1, -1, -1)]
+    [InlineData(32, 32, 1, 1)]
+    [InlineData(-32, -32, -1, -1)]
+    [InlineData(-33, -33, -2, -2)]
+    public void GetChunkPos_32ChunkSize_ReturnsCorrectValues(int x, int y, int expX, int expY)
+    {
+        VecI act = OperationHelper.GetChunkPos(new(x, y), 32);
+        Assert.Equal(expX, act.X);
+        Assert.Equal(expY, act.Y);
+    }
+
+    [Theory]
+    [InlineData(0, 0, true, true, 0, 0)]
+    [InlineData(0, 0, false, true, -1, 0)]
+    [InlineData(0, 0, true, false, 0, -1)]
+    [InlineData(0, 0, false, false, -1, -1)]
+    [InlineData(48.5, 48.5, true, true, 1, 1)]
+    [InlineData(48.5, 48.5, false, true, 1, 1)]
+    [InlineData(48.5, 48.5, true, false, 1, 1)]
+    [InlineData(48.5, 48.5, false, false, 1, 1)]
+    public void GetChunkPosBiased_32ChunkSize_ReturnsCorrectValues(double x, double y, bool positiveX, bool positiveY, int expX, int expY)
+    {
+        VecI act = OperationHelper.GetChunkPosBiased(new(x, y), positiveX, positiveY, 32);
+        Assert.Equal(expX, act.X);
+        Assert.Equal(expY, act.Y);
+    }
+
+    [Fact]
+    public void CreateStretchedHexagon_NonStretched_ReturnsCorrectQuads()
+    {
+        var (left, right) = OperationHelper.CreateStretchedHexagon((-3, 5), 10 / Math.Sqrt(3), 1);
+        Assert.Equal(right.TopLeft.X, left.TopRight.X, 6);
+        Assert.Equal(right.BottomLeft.X, left.BottomRight.X, 6);
+
+        Assert.Equal(-3, right.BottomLeft.X, 2);
+        Assert.Equal(10.774, right.BottomLeft.Y, 2);
+
+        Assert.Equal(2, right.BottomRight.X, 2);
+        Assert.Equal(7.887, right.BottomRight.Y, 2);
+
+        Assert.Equal(2, right.TopRight.X, 2);
+        Assert.Equal(2.113, right.TopRight.Y, 2);
+
+        Assert.Equal(-3, right.TopLeft.X, 2);
+        Assert.Equal(-0.774, right.TopLeft.Y, 2);
+
+        Assert.Equal(-8, left.TopLeft.X, 2);
+        Assert.Equal(2.113, left.TopLeft.Y, 2);
+
+        Assert.Equal(-8, left.BottomLeft.X, 2);
+        Assert.Equal(7.887, left.BottomLeft.Y, 2);
+    }
+
+    [Fact]
+    public void CreateStretchedHexagon_Stretched_ReturnsCorrectQuads()
+    {
+        const double x = -7;
+        const double stretch = 4;
+        var (left, right) = OperationHelper.CreateStretchedHexagon((x, 1), 12 / Math.Sqrt(3), stretch);
+        Assert.Equal(right.TopLeft.X, left.TopRight.X, 6);
+        Assert.Equal(right.BottomLeft.X, left.BottomRight.X, 6);
+
+        Assert.Equal(-7, right.BottomLeft.X, 2);
+        Assert.Equal(7.928, right.BottomLeft.Y, 2);
+
+        Assert.Equal((-1 - x) * stretch + x, right.BottomRight.X, 2);
+        Assert.Equal(4.464, right.BottomRight.Y, 2);
+
+        Assert.Equal((-1 - x) * stretch + x, right.TopRight.X, 2);
+        Assert.Equal(-2.464, right.TopRight.Y, 2);
+
+        Assert.Equal(-7, right.TopLeft.X, 2);
+        Assert.Equal(-5.928, right.TopLeft.Y, 2);
+
+        Assert.Equal((-13 - x) * stretch + x, left.TopLeft.X, 2);
+        Assert.Equal(-2.464, left.TopLeft.Y, 2);
+
+        Assert.Equal((-13 - x) * stretch + x, left.BottomLeft.X, 2);
+        Assert.Equal(4.464, left.BottomLeft.Y, 2);
+    }
+
+    [Fact]
+    public void FindChunksTouchingEllipse_EllipseSpanningTwoChunks_FindsChunks()
+    {
+        int cS = ChunkyImage.FullChunkSize;
+        var chunks = OperationHelper.FindChunksTouchingEllipse((cS, cS / 2.0), cS / 2.0, cS / 4.0, cS);
+        Assert.Equal(new HashSet<VecI>() { (0, 0), (1, 0) }, chunks);
+    }
+}

+ 322 - 0
src/ChunkyImageLibTest/RectITests.cs

@@ -0,0 +1,322 @@
+using System;
+using ChunkyImageLib.DataHolders;
+using Xunit;
+
+namespace ChunkyImageLibTest;
+public class RectITests
+{
+    [Fact]
+    public void EmptyConstructor_Call_ResultsInZeroVec()
+    {
+        RectI rect = new RectI();
+        Assert.Equal(0, rect.Left);
+        Assert.Equal(0, rect.Right);
+        Assert.Equal(0, rect.Top);
+        Assert.Equal(0, rect.Bottom);
+    }
+
+    [Fact]
+    public void RegularConstructor_WithBasicArgs_Works()
+    {
+        RectI rect = new RectI(800, 600, 200, 300);
+        Assert.Equal(800, rect.Left);
+        Assert.Equal(600, rect.Top);
+        Assert.Equal(800 + 200, rect.Right);
+        Assert.Equal(600 + 300, rect.Bottom);
+    }
+
+    [Fact]
+    public void FromTwoPoints_DiagonalsCombinations_ReturnsStandardizedRects()
+    {
+        RectI refR = new RectI(3, 4, 8 - 3, 9 - 4);
+        Span<RectI> rects = stackalloc RectI[] {
+            RectI.FromTwoPoints(new VecI(3, 4), new VecI(8, 9)),
+            RectI.FromTwoPoints(new VecI(8, 9), new VecI(3, 4)),
+            RectI.FromTwoPoints(new VecI(8, 9), new VecI(3, 4)),
+            RectI.FromTwoPoints(new VecI(8, 9), new VecI(3, 4)),
+        };
+        foreach (var rect in rects)
+        {
+            Assert.Equal(
+                (refR.Left, refR.Top, refR.Right, refR.Bottom),
+                (rect.Left, rect.Top, rect.Right, rect.Bottom));
+        }
+    }
+
+    [Fact]
+    public void Properties_OfStandardRectangle_ReturnCorrectValues()
+    {
+        RectI r = new(new VecI(2, 3), new VecI(4, 5));
+        Assert.Equal(2, r.Left);
+        Assert.Equal(3, r.Top);
+        Assert.Equal(2 + 4, r.Right);
+        Assert.Equal(3 + 5, r.Bottom);
+
+        Assert.Equal(r.Left, r.X);
+        Assert.Equal(r.Top, r.Y);
+        Assert.Equal(new VecI(r.Left, r.Top), r.Pos);
+        Assert.Equal(new VecI(r.Right - r.Left, r.Bottom - r.Top), r.Size);
+
+        Assert.Equal(new VecI(r.Left, r.Bottom), r.BottomLeft);
+        Assert.Equal(new VecI(r.Right, r.Bottom), r.BottomRight);
+        Assert.Equal(new VecI(r.Left, r.Top), r.TopLeft);
+        Assert.Equal(new VecI(r.Right, r.Top), r.TopRight);
+
+        Assert.Equal(r.Size.X, r.Width);
+        Assert.Equal(r.Size.Y, r.Height);
+
+        Assert.False(r.IsZeroArea);
+    }
+
+    [Fact]
+    public void PropertySetters_SetPlainValues_UpdateSidesCorrectly()
+    {
+        RectI r = new();
+        // left, top, right bottom
+        (r.Left, r.Top, r.Right, r.Bottom) = (2, 3, 6, 8);
+        Assert.Equal((2, 3, 6, 8), (r.Left, r.Top, r.Right, r.Bottom));
+
+        // x, y
+        (r.X, r.Y) = (4, 5);
+        Assert.Equal((4, 5), (r.Left, r.Top));
+
+        // pos
+        var oldSize = new VecI(r.Right - r.Left, r.Bottom - r.Top);
+        r.Pos = new VecI(5, 6);
+        var newSize = new VecI(r.Right - r.Left, r.Bottom - r.Top);
+        Assert.Equal((5, 6), (r.Left, r.Top));
+        Assert.Equal(oldSize, newSize);
+
+        // size
+        var oldPos = r.Pos;
+        r.Size = new(18, 14);
+        var newPos = r.Pos;
+        Assert.Equal(oldPos, newPos);
+        Assert.Equal((18, 14), (r.Right - r.Left, r.Bottom - r.Top));
+
+        // corners
+        r.BottomLeft = new VecI(-13, -14);
+        Assert.Equal((-13, -14), (r.Left, r.Bottom));
+        r.BottomRight = new VecI(46, -12);
+        Assert.Equal((46, -12), (r.Right, r.Bottom));
+        r.TopLeft = new VecI(-46, 24);
+        Assert.Equal((-46, 24), (r.Left, r.Top));
+        r.TopRight = new VecI(100, 101);
+        Assert.Equal((100, 101), (r.Right, r.Top));
+
+        // width, height
+        var oldPos2 = r.Pos;
+        (r.Width, r.Height) = (1, 2);
+        var newPos2 = r.Pos;
+        Assert.Equal(oldPos2, newPos2);
+        Assert.Equal((1, 2), (r.Right - r.Left, r.Bottom - r.Top));
+    }
+
+    [Fact]
+    public void IsZeroArea_NormalRectangles_ReturnsFalse()
+    {
+        Assert.False(new RectI(new(5, 6), new VecI(1, 1)).IsZeroArea);
+        Assert.False(new RectI(new(-5, -6), new VecI(-1, -1)).IsZeroArea);
+    }
+
+    [Fact]
+    public void IsZeroArea_ZeroAreaRectangles_ReturnsFalse()
+    {
+        Assert.True(new RectI(new(5, 6), new VecI(0, 10)).IsZeroArea);
+        Assert.True(new RectI(new(-5, -6), new VecI(10, 0)).IsZeroArea);
+        Assert.True(new RectI(new(-5, -6), new VecI(0, 0)).IsZeroArea);
+    }
+
+    [Fact]
+    public void Standardize_StandardRects_RemainUnchanged()
+    {
+        var rect1 = new RectI(new(4, 5), new(1, 1));
+        Assert.Equal(rect1, rect1.Standardize());
+        var rect2 = new RectI(new(-4, -5), new(1, 1));
+        Assert.Equal(rect2, rect2.Standardize());
+    }
+
+    [Fact]
+    public void Standardize_NonStandardRects_BecomeStandard()
+    {
+        var rect1 = new RectI(4, 5, -1, -1);
+        Assert.Equal(new RectI(3, 4, 1, 1), rect1.Standardize());
+        var rect2 = new RectI(-4, -5, -1, 1);
+        Assert.Equal(new RectI(-5, -5, 1, 1), rect2.Standardize());
+        var rect3 = new RectI(-4, -5, 1, -1);
+        Assert.Equal(new RectI(-4, -6, 1, 1), rect3.Standardize());
+    }
+
+    [Fact]
+    public void ReflectX_BasicRect_ReturnsReflected()
+    {
+        var rect = new RectI(4, 5, 6, 7);
+        Assert.Equal(new RectI(-4, 5, 6, 7), rect.ReflectX(3));
+    }
+
+    [Fact]
+    public void ReflectY_BasicRect_ReturnsReflected()
+    {
+        var rect = new RectI(4, 5, 6, 7);
+        Assert.Equal(new RectI(4, -6, 6, 7), rect.ReflectY(3));
+    }
+
+    [Fact]
+    public void Inflate_BasicRect_ReturnsInflated()
+    {
+        var rect = new RectI(4, 5, 6, 7);
+        var infInt = rect.Inflate(2);
+        var infVec = rect.Inflate(2, 3);
+        Assert.Equal(new RectI(2, 3, 10, 11), infInt);
+        Assert.Equal(new RectI(2, 2, 10, 13), infVec);
+    }
+
+    [Fact]
+    public void AspectFit_FitPortraitIntoLandscape_FitsCorrectly()
+    {
+        RectI landscape = new(-1, 4, 5, 3);
+        RectI portrait = new(32, -41, 41, 41 * 3);
+        RectI fitted = landscape.AspectFit(portrait);
+        Assert.Equal(new RectI(1, 4, 1, 3), fitted);
+    }
+
+    [Fact]
+    public void AspectFit_FitLandscapeIntoPortrait_FitsCorrectly()
+    {
+        RectI portrait = new(1, -10, 7, 15);
+        RectI landscape = new(-314, 1592, 23 * 7, 23 * 3);
+        RectI fitted = portrait.AspectFit(landscape);
+        Assert.Equal(new RectI(1, -4, 7, 3), fitted);
+    }
+
+    [Fact]
+    public void ContainsInclusive_BasicRect_DeterminedCorrectly()
+    {
+        RectI rect = new(5, 4, 10, 11);
+        Assert.True(rect.ContainsInclusive(5, 4));
+        Assert.True(rect.ContainsInclusive(5 + 10, 4 + 11));
+        Assert.True(rect.ContainsInclusive(5, 4 + 2));
+        Assert.True(rect.ContainsInclusive(5 + 2, 4));
+        Assert.True(rect.ContainsInclusive(6, 5));
+
+        Assert.False(rect.ContainsInclusive(0, 0));
+        Assert.False(rect.ContainsInclusive(6, 80));
+        Assert.False(rect.ContainsInclusive(80, 6));
+        Assert.False(rect.ContainsInclusive(5 + 11, 4 + 10));
+    }
+
+    [Fact]
+    public void ContainsExclusive_BasicRect_DeterminedCorrectly()
+    {
+        RectI rect = new(5, 4, 10, 11);
+        Assert.False(rect.ContainsExclusive(5, 4));
+        Assert.False(rect.ContainsExclusive(5 + 10, 4 + 11));
+        Assert.False(rect.ContainsExclusive(5, 4 + 2));
+        Assert.False(rect.ContainsExclusive(5 + 2, 4));
+
+        Assert.True(rect.ContainsExclusive(6, 5));
+        Assert.True(rect.ContainsExclusive(5 + 9, 4 + 10));
+
+        Assert.False(rect.ContainsExclusive(0, 0));
+        Assert.False(rect.ContainsExclusive(6, 80));
+        Assert.False(rect.ContainsExclusive(80, 6));
+        Assert.False(rect.ContainsExclusive(5 + 11, 4 + 10));
+    }
+
+    [Fact]
+    public void ContainsPixel_BasicRect_DeterminedCorrectly()
+    {
+        RectI rect = new RectI(960, 540, 1920, 1080);
+        Assert.True(rect.ContainsPixel(960, 540));
+        Assert.True(rect.ContainsPixel(1920 - 1, 1080 - 1));
+        Assert.True(rect.ContainsPixel(960 + 960 / 2, 540 + 540 / 2));
+
+        Assert.False(rect.ContainsPixel(960 - 1, 540 - 1));
+        Assert.False(rect.ContainsPixel(960 + 1920, 540 + 1080));
+        Assert.False(rect.ContainsPixel(960 + 960, 1080 + 540));
+    }
+
+    [Fact]
+    public void IntersectsWithInclusive_BasicRects_ReturnsTrue()
+    {
+        RectI rect = new RectI(960, 540, 1920, 1080);
+        Span<RectI> rects = stackalloc RectI[] {
+            rect.Offset(1920, 1080),
+            rect.Offset(-1920, 0).Inflate(-1).Offset(1, 0),
+            rect.Offset(0, 1080).Inflate(-1).Offset(0, -1),
+            rect.Inflate(-1),
+            rect.Inflate(1),
+        };
+        foreach (var testRect in rects)
+            Assert.True(rect.IntersectsWithInclusive(testRect));
+    }
+
+    [Fact]
+    public void IntersectsWithInclusive_BasicRects_ReturnsFalse()
+    {
+        RectI rect = new RectI(960, 540, 1920, 1080);
+        Span<RectI> rects = stackalloc RectI[] {
+            rect.Offset(1921, 1080),
+            rect.Offset(-1921, 0).Inflate(-1).Offset(1, 0),
+            rect.Offset(0, 1081).Inflate(-1).Offset(0, -1)
+        };
+        foreach (var testRect in rects)
+            Assert.False(rect.IntersectsWithInclusive(testRect));
+    }
+
+    [Fact]
+    public void IntersectsWithExclusive_BasicRects_ReturnsTrue()
+    {
+        RectI rect = new RectI(960, 540, 1920, 1080);
+        Span<RectI> rects = stackalloc RectI[] {
+            rect.Offset(1920 - 1, 1080 - 1),
+            rect.Offset(-1920, 0).Inflate(-1).Offset(2, 0),
+            rect.Offset(0, 1080).Inflate(-1).Offset(0, -2),
+            rect.Inflate(-1),
+            rect.Inflate(1),
+        };
+        foreach (var testRect in rects)
+            Assert.True(rect.IntersectsWithExclusive(testRect));
+    }
+
+    [Fact]
+    public void IntersectsWithExclusive_BasicRects_ReturnsFalse()
+    {
+        RectI rect = new RectI(960, 540, 1920, 1080);
+        Span<RectI> rects = stackalloc RectI[] {
+            rect.Offset(1920, 1080),
+            rect.Offset(-1920, 0).Inflate(-1).Offset(1, 0),
+            rect.Offset(0, 1080).Inflate(-1).Offset(0, -1),
+            rect.Offset(1921, 1080),
+            rect.Offset(-1921, 0).Inflate(-1).Offset(1, 0),
+            rect.Offset(0, 1081).Inflate(-1).Offset(0, -1)
+        };
+        foreach (var testRect in rects)
+            Assert.False(rect.IntersectsWithExclusive(testRect));
+    }
+
+    [Fact]
+    public void Intersect_IntersectingRectangles_ReturnsIntersection()
+    {
+        Assert.Equal(
+            new RectI(400, 300, 400, 300),
+            new RectI(400, 300, 800, 600).Intersect(new RectI(0, 0, 800, 600)));
+    }
+
+    [Fact]
+    public void Intersect_NonIntersectingRectangles_ReturnsEmpty()
+    {
+        Assert.Equal(
+            RectI.Empty,
+            new RectI(-123, -456, 78, 10).Intersect(new RectI(123, 456, 789, 101)));
+    }
+
+    [Fact]
+    public void Union_BasicRectangles_ReturnsUnion()
+    {
+        var rect1 = new RectI(4, 5, 1, 1);
+        var rect2 = new RectI(-4, -5, 1, 1);
+        Assert.Equal(new RectI(-4, -5, 9, 11), rect1.Union(rect2));
+        Assert.Equal(new RectI(-4, -5, 9, 11), rect2.Union(rect1));
+    }
+}

+ 121 - 0
src/ChunkyImageLibTest/RectangleOperationTests.cs

@@ -0,0 +1,121 @@
+using System.Collections.Generic;
+using ChunkyImageLib;
+using ChunkyImageLib.DataHolders;
+using ChunkyImageLib.Operations;
+using SkiaSharp;
+using Xunit;
+
+namespace ChunkyImageLibTest;
+
+public class RectangleOperationTests
+{
+    const int chunkSize = ChunkPool.FullChunkSize;
+// to keep expected rectangles aligned
+#pragma warning disable format
+    [Fact]
+    public void FindAffectedChunks_SmallStrokeOnly_FindsCorrectChunks()
+    {
+        var (x, y, w, h) = (chunkSize / 2, chunkSize / 2, chunkSize, chunkSize);
+        RectangleOperation operation = new(new(new(x, y), new(w, h), 0, 1, SKColors.Black, SKColors.Transparent));
+
+        HashSet<VecI> expected = new() { new(0, 0) };
+        var actual = operation.FindAffectedChunks();
+
+        Assert.Equal(expected, actual);
+    }
+
+    [Fact]
+    public void FindAffectedChunks_2by2StrokeOnly_FindsCorrectChunks()
+    {
+        var (x, y, w, h) = (0, 0, chunkSize * 2, chunkSize * 2);
+        RectangleOperation operation = new(new(new(x, y), new(w, h), 0, 1, SKColors.Black, SKColors.Transparent));
+
+        HashSet<VecI> expected = new() { new(-1, -1), new(0, -1), new(-1, 0), new(0, 0) };
+        var actual = operation.FindAffectedChunks();
+
+        Assert.Equal(expected, actual);
+    }
+
+    [Fact]
+    public void FindAffectedChunks_3x3PositiveStrokeOnly_FindsCorrectChunks()
+    {
+        var (x, y, w, h) = (2 * chunkSize + chunkSize / 2, 2 * chunkSize + chunkSize / 2, chunkSize * 2, chunkSize * 2);
+        RectangleOperation operation = new(new(new(x, y), new(w, h), 0, 1, SKColors.Black, SKColors.Transparent));
+
+        HashSet<VecI> expected = new()
+        {
+            new(1, 1), new(2, 1), new(3, 1),
+            new(1, 2),            new(3, 2),
+            new(1, 3), new(2, 3), new(3, 3),
+        };
+        var actual = operation.FindAffectedChunks();
+
+        Assert.Equal(expected, actual);
+    }
+
+    [Fact]
+    public void FindAffectedChunks_3x3NegativeStrokeOnly_FindsCorrectChunks()
+    {
+        var (x, y, w, h) = (-chunkSize * 2 - chunkSize / 2, -chunkSize * 2 - chunkSize / 2, chunkSize * 2, chunkSize * 2);
+        RectangleOperation operation = new(new(new(x, y), new(w, h), 0, 1, SKColors.Black, SKColors.Transparent));
+
+        HashSet<VecI> expected = new()
+        {
+            new(-4, -4), new(-3, -4), new(-2, -4),
+            new(-4, -3),              new(-2, -3),
+            new(-4, -2), new(-3, -2), new(-2, -2),
+        };
+        var actual = operation.FindAffectedChunks();
+
+        Assert.Equal(expected, actual);
+    }
+
+    [Fact]
+    public void FindAffectedChunks_3x3PositiveFilled_FindsCorrectChunks()
+    {
+        var (x, y, w, h) = (2 * chunkSize + chunkSize / 2, 2 * chunkSize + chunkSize / 2, chunkSize * 2, chunkSize * 2);
+        RectangleOperation operation = new(new(new(x, y), new(w, h), 0, 1, SKColors.Black, SKColors.White));
+
+        HashSet<VecI> expected = new()
+        {
+            new(1, 1), new(2, 1), new(3, 1), 
+            new(1, 2), new(2, 2), new(3, 2),
+            new(1, 3), new(2, 3), new(3, 3),
+        };
+        var actual = operation.FindAffectedChunks();
+
+        Assert.Equal(expected, actual);
+    }
+
+    [Fact]
+    public void FindAffectedChunks_ThickPositiveStroke_FindsCorrectChunks()
+    {
+        var (x, y, w, h) = (2 * chunkSize + chunkSize / 2, 2 * chunkSize + chunkSize / 2, chunkSize * 4, chunkSize * 4);
+        RectangleOperation operation = new(new(new(x, y), new(w, h), 0, chunkSize, SKColors.Black, SKColors.Transparent));
+
+        HashSet<VecI> expected = new()
+        {
+            new(0, 0), new(1, 0), new(2, 0), new(3, 0), new(4, 0),
+            new(0, 1), new(1, 1), new(2, 1), new(3, 1), new(4, 1),
+            new(0, 2), new(1, 2),            new(3, 2), new(4, 2),
+            new(0, 3), new(1, 3), new(2, 3), new(3, 3), new(4, 3),
+            new(0, 4), new(1, 4), new(2, 4), new(3, 4), new(4, 4),
+        };
+        var actual = operation.FindAffectedChunks();
+
+        Assert.Equal(expected, actual);
+    }
+
+    [Fact]
+    public void FindAffectedChunks_SmallButThick_FindsCorrectChunks()
+    {
+        var (x, y, w, h) = (chunkSize / 2f - 0.5, chunkSize / 2f - 0.5, 1, 1);
+        RectangleOperation operation = new(new(new(x, y), new(w, h), 0, chunkSize, SKColors.Black, SKColors.White));
+
+        HashSet<VecI> expected = new() { new(0, 0) };
+        var actual = operation.FindAffectedChunks();
+
+        Assert.Equal(expected, actual);
+    }
+#pragma warning restore format
+}

+ 9 - 0
src/ChunkyImageLibVis/App.xaml

@@ -0,0 +1,9 @@
+<Application x:Class="ChunkyImageLibVis.App"
+             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
+             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+             xmlns:local="clr-namespace:ChunkyImageLibVis"
+             StartupUri="MainWindow.xaml">
+    <Application.Resources>
+         
+    </Application.Resources>
+</Application>

+ 10 - 0
src/ChunkyImageLibVis/App.xaml.cs

@@ -0,0 +1,10 @@
+using System.Windows;
+
+namespace ChunkyImageLibVis;
+
+/// <summary>
+/// Interaction logic for App.xaml
+/// </summary>
+public partial class App : Application
+{
+}

+ 10 - 0
src/ChunkyImageLibVis/AssemblyInfo.cs

@@ -0,0 +1,10 @@
+using System.Windows;
+
+[assembly: ThemeInfo(
+    ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
+                                     //(used if a resource is not found in the page,
+                                     // or application resource dictionaries)
+    ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
+                                              //(used if a resource is not found in the page,
+                                              // app, or any theme specific resource dictionaries)
+)]

+ 14 - 0
src/ChunkyImageLibVis/ChunkyImageLibVis.csproj

@@ -0,0 +1,14 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <OutputType>WinExe</OutputType>
+    <TargetFramework>net6.0-windows</TargetFramework>
+    <Nullable>enable</Nullable>
+    <UseWPF>true</UseWPF>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <ProjectReference Include="..\ChunkyImageLib\ChunkyImageLib.csproj" />
+  </ItemGroup>
+
+</Project>

+ 16 - 0
src/ChunkyImageLibVis/MainWindow.xaml

@@ -0,0 +1,16 @@
+<Window x:Class="ChunkyImageLibVis.MainWindow"
+        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
+        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+        xmlns:local="clr-namespace:ChunkyImageLibVis"
+        mc:Ignorable="d"
+        Title="MainWindow" Height="450" Width="800">
+    <Canvas PreviewMouseDown="Canvas_MouseDown" PreviewMouseMove="Canvas_MouseMove" PreviewMouseUp="Canvas_MouseUp" x:Name="canvas" Background="Transparent">
+        <Rectangle Canvas.Left="{Binding X1}" Canvas.Top="{Binding Y1}" Width="{Binding RectWidth}" Height="{Binding RectHeight}" Stroke="Black" StrokeThickness="1" Panel.ZIndex="999">
+            <Rectangle.RenderTransform>
+                <RotateTransform CenterX="{Binding HalfRectWidth}" CenterY="{Binding HalfRectHeight}" Angle="{Binding Angle}"/>
+            </Rectangle.RenderTransform>
+        </Rectangle>
+    </Canvas>
+</Window>

+ 205 - 0
src/ChunkyImageLibVis/MainWindow.xaml.cs

@@ -0,0 +1,205 @@
+using ChunkyImageLib.DataHolders;
+using ChunkyImageLib.Operations;
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using System.Windows.Media;
+using System.Windows.Shapes;
+
+namespace ChunkyImageLibVis;
+
+/// <summary>
+/// Interaction logic for MainWindow.xaml
+/// </summary>
+public partial class MainWindow : Window, INotifyPropertyChanged
+{
+    private double x1;
+    private double y1;
+    private double x2;
+    private double y2;
+
+    public double X1
+    {
+        get => x1;
+        set
+        {
+            x1 = value;
+            PropertyChanged?.Invoke(this, new(nameof(X1)));
+            PropertyChanged?.Invoke(this, new(nameof(RectWidth)));
+            PropertyChanged?.Invoke(this, new(nameof(HalfRectWidth)));
+        }
+    }
+    public double X2
+    {
+        get => x2;
+        set
+        {
+            x2 = value;
+            PropertyChanged?.Invoke(this, new(nameof(X2)));
+            PropertyChanged?.Invoke(this, new(nameof(RectWidth)));
+            PropertyChanged?.Invoke(this, new(nameof(HalfRectWidth)));
+        }
+    }
+    public double Y1
+    {
+        get => y1;
+        set
+        {
+            y1 = value;
+            PropertyChanged?.Invoke(this, new(nameof(Y1)));
+            PropertyChanged?.Invoke(this, new(nameof(RectHeight)));
+            PropertyChanged?.Invoke(this, new(nameof(HalfRectHeight)));
+        }
+    }
+    public double Y2
+    {
+        get => y2;
+        set
+        {
+            y2 = value;
+            PropertyChanged?.Invoke(this, new(nameof(Y2)));
+            PropertyChanged?.Invoke(this, new(nameof(RectHeight)));
+            PropertyChanged?.Invoke(this, new(nameof(HalfRectHeight)));
+        }
+    }
+
+    public double RectWidth { get => Math.Abs(X2 - X1); }
+    public double RectHeight { get => Math.Abs(Y2 - Y1); }
+
+    public double HalfRectWidth { get => Math.Abs(X2 - X1) / 2; }
+    public double HalfRectHeight { get => Math.Abs(Y2 - Y1) / 2; }
+
+
+    private double angle;
+    public double Angle
+    {
+        get => angle;
+        set
+        {
+            angle = value;
+            PropertyChanged?.Invoke(this, new(nameof(Angle)));
+        }
+    }
+
+    public MainWindow()
+    {
+        InitializeComponent();
+        DataContext = this;
+        CreateGrid();
+    }
+
+    public void CreateGrid()
+    {
+        for (int i = 0; i < 20; i++)
+        {
+            Line ver = new()
+            {
+                X1 = i * 32,
+                X2 = i * 32,
+                Y1 = 0,
+                Y2 = 1000,
+                Stroke = Brushes.Gray,
+                StrokeThickness = 1,
+            };
+            Line hor = new()
+            {
+                X1 = 0,
+                X2 = 1000,
+                Y1 = i * 32,
+                Y2 = i * 32,
+                Stroke = Brushes.Gray,
+                StrokeThickness = 1,
+            };
+            canvas.Children.Add(ver);
+            canvas.Children.Add(hor);
+        }
+    }
+
+    public List<Rectangle> rectangles = new();
+    private void UpdateChunks()
+    {
+        foreach (var rect in rectangles)
+        {
+            canvas.Children.Remove(rect);
+        }
+        rectangles.Clear();
+        var chunks = OperationHelper.FindChunksTouchingRectangle(new VecD(X1 + HalfRectWidth, Y1 + HalfRectHeight), new(X2 - X1, Y2 - Y1), Angle * Math.PI / 180, 32);
+        var innerChunks = OperationHelper.FindChunksFullyInsideRectangle(new VecD(X1 + HalfRectWidth, Y1 + HalfRectHeight), new(X2 - X1, Y2 - Y1), Angle * Math.PI / 180, 32);
+        chunks.ExceptWith(innerChunks);
+        foreach (var chunk in chunks)
+        {
+            Rectangle rectangle = new()
+            {
+                Fill = Brushes.Green,
+                Width = 32,
+                Height = 32,
+            };
+            Canvas.SetLeft(rectangle, chunk.X * 32);
+            Canvas.SetTop(rectangle, chunk.Y * 32);
+            canvas.Children.Add(rectangle);
+            rectangles.Add(rectangle);
+        }
+    }
+
+    public event PropertyChangedEventHandler? PropertyChanged;
+
+    private bool drawing = false;
+    private bool rotating = false;
+    private void Canvas_MouseDown(object sender, MouseButtonEventArgs e)
+    {
+        if (rotating)
+        {
+            rotating = false;
+            return;
+        }
+        drawing = true;
+        Angle = 0;
+        var pos = e.GetPosition(canvas);
+        if (e.LeftButton == MouseButtonState.Pressed)
+        {
+            X1 = pos.X;
+            Y1 = pos.Y;
+        }
+        else
+        {
+            X1 = Math.Floor(pos.X / 32) * 32;
+            Y1 = Math.Floor(pos.Y / 32) * 32;
+        }
+    }
+
+    private void Canvas_MouseMove(object sender, MouseEventArgs e)
+    {
+        var pos = e.GetPosition(canvas);
+        if (drawing)
+        {
+            if (e.LeftButton == MouseButtonState.Pressed)
+            {
+                X2 = pos.X;
+                Y2 = pos.Y;
+            }
+            else
+            {
+                X2 = Math.Floor(pos.X / 32) * 32;
+                Y2 = Math.Floor(pos.Y / 32) * 32;
+            }
+        }
+        else if (rotating)
+        {
+            VecD center = new VecD(X1 + HalfRectWidth, Y1 + HalfRectHeight);
+            Angle = new VecD(pos.X - center.X, pos.Y - center.Y).CCWAngleTo(new VecD(X2 - center.X, Y2 - center.Y)) * -180 / Math.PI;
+        }
+        UpdateChunks();
+    }
+
+    private void Canvas_MouseUp(object sender, MouseButtonEventArgs e)
+    {
+        if (drawing)
+        {
+            drawing = false;
+            rotating = true;
+        }
+    }
+}

+ 49 - 0
src/PixiEditor.ChangeableDocument.Gen/ChangeActionGenerator.cs

@@ -0,0 +1,49 @@
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace PixiEditor.ChangeableDocument.Gen
+{
+    [Generator]
+    public class ChangeActionGenerator : IIncrementalGenerator
+    {
+        public void Initialize(IncrementalGeneratorInitializationContext context)
+        {
+            // find constructors with the attribute
+            var constructorSymbolProvider = context.SyntaxProvider.CreateSyntaxProvider(
+                predicate: Helpers.IsConstructorWithAttribute,
+                transform: static (context, cancelToken) =>
+                {
+                    var constructor = (ConstructorDeclarationSyntax)context.Node;
+                    if (!Helpers.MethodHasAttribute(
+                        context,
+                        cancelToken,
+                        constructor,
+                        new NamespacedType("GenerateMakeChangeActionAttribute", "PixiEditor.ChangeableDocument.Actions.Attributes")
+                        ))
+                        return null;
+
+                    var constructorSymbol = context.SemanticModel.GetDeclaredSymbol(constructor, cancelToken);
+                    if (constructorSymbol is not IMethodSymbol methodConstructorSymbol ||
+                        methodConstructorSymbol.Kind != SymbolKind.Method)
+                        return null;
+                    return methodConstructorSymbol;
+                }
+            ).Where(a => a is not null);
+
+            // generate action source code
+            var actionSourceCodeProvider = constructorSymbolProvider.Select(
+                static (constructor, _) =>
+                {
+                    var info = Helpers.ExtractMethodInfo(constructor!);
+                    return new NamedSourceCode(info.ContainingClass.NameWithNamespace + "MakeChangeAction", Helpers.CreateMakeChangeAction(info));
+                }
+            );
+
+            // add the source code into compiler input
+            context.RegisterSourceOutput(actionSourceCodeProvider, static (context, namedCode) =>
+            {
+                context.AddSource(namedCode.Name, namedCode.Code);
+            });
+        }
+    }
+}

+ 258 - 0
src/PixiEditor.ChangeableDocument.Gen/Helpers.cs

@@ -0,0 +1,258 @@
+using System.Text;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace PixiEditor.ChangeableDocument.Gen;
+
+internal static class Helpers
+{
+    private static SymbolDisplayFormat TypeWithGenerics =
+        new SymbolDisplayFormat(genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters | SymbolDisplayGenericsOptions.IncludeTypeConstraints);
+    public static string CreateMakeChangeAction(MethodInfo changeConstructorInfo)
+    {
+        string actionName = changeConstructorInfo.ContainingClass.Name.Split('_')[0] + "_Action";
+        List<TypeWithName> constructorArgs = changeConstructorInfo.Arguments;
+        List<TypeWithName> properties = constructorArgs.Select(static typeWithName =>
+            {
+                return new TypeWithName(typeWithName.Type, typeWithName.FullNamespace, VariableNameIntoPropertyName(typeWithName.Name), typeWithName.Nullable);
+            }).ToList();
+
+        var propToVar = MatchMembers(properties, constructorArgs);
+
+        StringBuilder sb = new();
+         
+        sb.AppendLine("namespace PixiEditor.ChangeableDocument.Actions.Generated;\n");
+        sb.AppendLine("[System.Runtime.CompilerServices.CompilerGenerated]");
+        sb.AppendLine($"public record class {actionName} : PixiEditor.ChangeableDocument.Actions.IMakeChangeAction");
+        sb.AppendLine("{");
+        sb.Append($"public {actionName}");
+        AppendArgumentList(sb, constructorArgs);
+        AppendConstructorBody(sb, propToVar);
+        sb.AppendLine("// Properties");
+        AppendProperties(sb, properties);
+        sb.AppendLine("// Changes");
+        AppendCreateCorrespondingChange(sb, changeConstructorInfo.ContainingClass, properties);
+        sb.AppendLine("}");
+
+        return sb.ToString();
+    }
+
+    public static Result<string> CreateStartUpdateChangeAction
+        (MethodInfo changeConstructorInfo, MethodInfo updateMethodInfo, ClassDeclarationSyntax containingClass)
+    {
+        string actionName = changeConstructorInfo.ContainingClass.Name.Split('_')[0] + "_Action";
+        List<TypeWithName> constructorArgs = changeConstructorInfo.Arguments;
+        List<TypeWithName> properties = constructorArgs.Select(static typeWithName =>
+        {
+            return new TypeWithName(typeWithName.Type, typeWithName.FullNamespace, VariableNameIntoPropertyName(typeWithName.Name), typeWithName.Nullable);
+        }).ToList();
+
+        var constructorAssignments = MatchMembers(properties, constructorArgs);
+        var updatePropsToPass = MatchMembers(updateMethodInfo.Arguments, properties).Select(pair => pair.Item2).ToList();
+        if (updatePropsToPass.Count != updateMethodInfo.Arguments.Count)
+            return Result<string>.Error("Couldn't match update method arguments with constructor arguments", containingClass.SyntaxTree, containingClass.Span);
+
+        StringBuilder sb = new();
+
+        sb.AppendLine("namespace PixiEditor.ChangeableDocument.Actions.Generated;");
+        sb.AppendLine($"public record class {actionName} : PixiEditor.ChangeableDocument.Actions.IStartOrUpdateChangeAction");
+        sb.AppendLine("{");
+        sb.Append($"public {actionName}");
+        AppendArgumentList(sb, constructorArgs);
+        AppendConstructorBody(sb, constructorAssignments);
+        AppendProperties(sb, properties);
+        AppendCreateUpdateableCorrespondingChange(sb, changeConstructorInfo.ContainingClass, properties);
+        AppendUpdateCorrespondingChange(sb, updateMethodInfo.Name, changeConstructorInfo.ContainingClass, updatePropsToPass);
+        sb.AppendLine($@"
+bool PixiEditor.ChangeableDocument.Actions.IStartOrUpdateChangeAction.IsChangeTypeMatching(PixiEditor.ChangeableDocument.Changes.Change change)
+{{
+    return change is {changeConstructorInfo.ContainingClass.NameWithNamespace};
+}}
+");
+        sb.AppendLine("}");
+
+        return sb.ToString();
+    }
+
+    public static string CreateEndChangeAction(MethodInfo changeConstructorInfo)
+    {
+        string actionName = "End" + changeConstructorInfo.ContainingClass.Name.Split('_')[0] + "_Action";
+        return $@"
+namespace PixiEditor.ChangeableDocument.Actions.Generated;
+
+public record class {actionName} : PixiEditor.ChangeableDocument.Actions.IEndChangeAction
+{{
+    bool PixiEditor.ChangeableDocument.Actions.IEndChangeAction.IsChangeTypeMatching(PixiEditor.ChangeableDocument.Changes.Change change)
+    {{
+        return change is {changeConstructorInfo.ContainingClass.NameWithNamespace};
+    }}
+}}
+";
+    }
+
+    public static MethodInfo ExtractMethodInfo(IMethodSymbol method)
+    {
+        List<TypeWithName> variables = method.Parameters.Select(static parameter =>
+        {
+            return new TypeWithName(
+                parameter.Type.ToDisplayString(TypeWithGenerics),
+                parameter.Type.ContainingNamespace.ToDisplayString(),
+                parameter.Name,
+                parameter.NullableAnnotation is NullableAnnotation.Annotated
+                );
+        }).ToList();
+        string changeName = method.ContainingType.Name;
+
+        string changeFullNamespace = method.ContainingNamespace.ToDisplayString();
+        return new MethodInfo(method.Name, variables, new NamespacedType(changeName, changeFullNamespace));
+    }
+
+    private static void AppendConstructorBody(StringBuilder sb, List<(TypeWithName, TypeWithName)> assignments)
+    {
+        sb.AppendLine("{");
+        foreach (var (property, variable) in assignments)
+        {
+            sb.Append("this.").Append(property.Name).Append(" = ").Append(variable.Name).AppendLine(";");
+        }
+        sb.AppendLine("}");
+    }
+
+    private static List<(TypeWithName, TypeWithName)> MatchMembers(List<TypeWithName> list1, List<TypeWithName> list2)
+    {
+        List<(TypeWithName, TypeWithName)> paired = new();
+        for (int i = list1.Count - 1; i >= 0; i--)
+        {
+            for (int j = list2.Count - 1; j >= 0; j--)
+            {
+                if (list1[i].TypeWithNamespace == list2[j].TypeWithNamespace &&
+                    list1[i].Name.ToLower() == list2[j].Name.ToLower())
+                {
+                    paired.Add((list1[i], list2[j]));
+                }
+            }
+        }
+        paired.Reverse();
+        return paired;
+    }
+
+    private static void AppendArgumentList(StringBuilder sb, List<TypeWithName> variables)
+    {
+        sb.Append("(");
+        for (int i = 0; i < variables.Count; i++)
+        {
+            sb.Append(variables[i].TypeWithNamespace);
+
+            if (variables[i].Nullable)
+            {
+                sb.Append("?");
+            }
+            
+            sb.Append(" ").Append(variables[i].Name);
+            if (i != variables.Count - 1)
+                sb.Append(", ");
+        }
+        sb.AppendLine(")");
+    }
+
+    private static void AppendUpdateCorrespondingChange
+        (StringBuilder sb, string updateMethodName, NamespacedType corrChangeType, List<TypeWithName> propertiesToPass)
+    {
+        sb.AppendLine("void PixiEditor.ChangeableDocument.Actions.IStartOrUpdateChangeAction.UpdateCorrespodingChange(PixiEditor.ChangeableDocument.Changes.UpdateableChange change)");
+        sb.AppendLine("{");
+        sb.Append($"(({corrChangeType.NameWithNamespace})change).{updateMethodName}(");
+        for (int i = 0; i < propertiesToPass.Count; i++)
+        {
+            sb.Append(propertiesToPass[i].Name);
+            if (i != propertiesToPass.Count - 1)
+                sb.Append(", ");
+        }
+        sb.AppendLine(");");
+        sb.AppendLine("}");
+    }
+
+    private static void AppendCreateUpdateableCorrespondingChange
+        (StringBuilder sb, NamespacedType corrChangeType, List<TypeWithName> propertiesToPass)
+    {
+        sb.AppendLine("PixiEditor.ChangeableDocument.Changes.UpdateableChange PixiEditor.ChangeableDocument.Actions.IStartOrUpdateChangeAction.CreateCorrespondingChange()");
+        sb.AppendLine("{");
+        sb.Append($"return new {corrChangeType.NameWithNamespace}(");
+        for (int i = 0; i < propertiesToPass.Count; i++)
+        {
+            sb.Append(propertiesToPass[i].Name);
+            if (i != propertiesToPass.Count - 1)
+                sb.Append(", ");
+        }
+        sb.AppendLine(");");
+        sb.AppendLine("}");
+    }
+
+    private static void AppendCreateCorrespondingChange
+        (StringBuilder sb, NamespacedType corrChangeType, List<TypeWithName> propertiesToPass)
+    {
+        sb.AppendLine("PixiEditor.ChangeableDocument.Changes.Change PixiEditor.ChangeableDocument.Actions.IMakeChangeAction.CreateCorrespondingChange()");
+        sb.AppendLine("{");
+        sb.Append($"return new {corrChangeType.NameWithNamespace}(");
+        for (int i = 0; i < propertiesToPass.Count; i++)
+        {
+            sb.Append(propertiesToPass[i].Name);
+            if (i != propertiesToPass.Count - 1)
+                sb.Append(", ");
+        }
+        sb.AppendLine(");");
+        sb.AppendLine("}");
+    }
+
+    private static void AppendProperties(StringBuilder sb, List<TypeWithName> properties)
+    {
+        foreach (var typeWithName in properties)
+        {
+            sb.AppendLine($"public {typeWithName.TypeWithNamespace}{(typeWithName.Nullable ? "?" : "")} {typeWithName.Name} {{ get; init; }}");
+        }
+    }
+
+    private static string VariableNameIntoPropertyName(string varName)
+    {
+        string lowerCaseName = varName.Substring(0, 1).ToUpperInvariant() + varName.Substring(1);
+        return lowerCaseName;
+    }
+
+    public static bool IsConstructorWithAttribute(SyntaxNode node, CancellationToken token)
+    {
+        return node is ConstructorDeclarationSyntax constructor && constructor.AttributeLists.Count > 0;
+    }
+
+    public static bool IsMethodWithAttribute(SyntaxNode node, CancellationToken token)
+    {
+        return node is MethodDeclarationSyntax method && method.AttributeLists.Count > 0;
+    }
+
+    public static bool IsInheritedFrom(INamedTypeSymbol classSymbol, NamespacedType type)
+    {
+        while (classSymbol.BaseType is not null)
+        {
+            if (classSymbol.BaseType.ToDisplayString() == type.NameWithNamespace)
+                return true;
+            classSymbol = classSymbol.BaseType;
+        }
+        return false;
+    }
+
+    public static bool MethodHasAttribute
+        (GeneratorSyntaxContext context, CancellationToken cancelToken, BaseMethodDeclarationSyntax method, NamespacedType attributeType)
+    {
+        foreach (var attrList in method.AttributeLists)
+        {
+            foreach (var attribute in attrList.Attributes)
+            {
+                cancelToken.ThrowIfCancellationRequested();
+                var symbol = context.SemanticModel.GetSymbolInfo(attribute, cancelToken);
+                if (symbol.Symbol is not IMethodSymbol methodSymbol)
+                    continue;
+                if (methodSymbol.ContainingType.ToDisplayString() != attributeType.NameWithNamespace)
+                    continue;
+                return true;
+            }
+        }
+        return false;
+    }
+}

+ 4 - 0
src/PixiEditor.ChangeableDocument.Gen/MethodInfo.cs

@@ -0,0 +1,4 @@
+namespace PixiEditor.ChangeableDocument.Gen
+{
+    internal record struct MethodInfo(string Name, List<TypeWithName> Arguments, NamespacedType ContainingClass);
+}

+ 12 - 0
src/PixiEditor.ChangeableDocument.Gen/NamedSourceCode.cs

@@ -0,0 +1,12 @@
+namespace PixiEditor.ChangeableDocument.Gen;
+internal struct NamedSourceCode
+{
+    public NamedSourceCode(string name, string code)
+    {
+        Name = name;
+        Code = code;
+    }
+
+    public string Name { get; }
+    public string Code { get; }
+}

+ 13 - 0
src/PixiEditor.ChangeableDocument.Gen/NamespacedType.cs

@@ -0,0 +1,13 @@
+namespace PixiEditor.ChangeableDocument.Gen;
+internal struct NamespacedType
+{
+    public NamespacedType(string name, string fullNamespace)
+    {
+        Name = name;
+        FullNamespace = fullNamespace;
+    }
+
+    public string Name { get; }
+    public string FullNamespace { get; }
+    public string NameWithNamespace => FullNamespace + "." + Name;
+}

+ 18 - 0
src/PixiEditor.ChangeableDocument.Gen/PixiEditor.ChangeableDocument.Gen.csproj

@@ -0,0 +1,18 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <TargetFramework>netstandard2.0</TargetFramework>
+    <IncludeBuildOutput>false</IncludeBuildOutput>
+    <Nullable>enable</Nullable>
+    <ImplicitUsings>true</ImplicitUsings>
+    <LangVersion>Latest</LangVersion>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.2.0" />
+  </ItemGroup>
+  <ItemGroup>
+    <None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true"
+        PackagePath="analyzers/dotnet/cs" Visible="false" />
+  </ItemGroup>
+</Project>

+ 28 - 0
src/PixiEditor.ChangeableDocument.Gen/Result.cs

@@ -0,0 +1,28 @@
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Text;
+
+namespace PixiEditor.ChangeableDocument.Gen;
+internal struct Result<T>
+{
+    public string? ErrorText { get; }
+    public SyntaxTree? SyntaxTree { get; }
+    public TextSpan? Span { get; }
+    public T? Value { get; }
+
+    private Result(string? error, T? value, SyntaxTree? syntaxTree, TextSpan? span)
+    {
+        ErrorText = error;
+        Value = value;
+        SyntaxTree = syntaxTree;
+        Span = span;
+    }
+    public static Result<T> Error(string text, SyntaxTree tree, TextSpan span)
+    {
+        return new Result<T>(text, default(T), tree, span);
+    }
+
+    public static implicit operator Result<T>(T value)
+    {
+        return new Result<T>(null, value, null, null);
+    }
+}

+ 17 - 0
src/PixiEditor.ChangeableDocument.Gen/TypeWithName.cs

@@ -0,0 +1,17 @@
+namespace PixiEditor.ChangeableDocument.Gen;
+internal struct TypeWithName
+{
+    public TypeWithName(string type, string fullNamespace, string name, bool nullable)
+    {
+        Type = type;
+        FullNamespace = fullNamespace;
+        Name = name;
+        Nullable = nullable;
+    }
+
+    public string Type { get; }
+    public string FullNamespace { get; }
+    public string TypeWithNamespace => FullNamespace + "." + Type;
+    public string Name { get; }
+    public bool Nullable { get; }
+}

+ 124 - 0
src/PixiEditor.ChangeableDocument.Gen/UpdateableChangeActionGenerator.cs

@@ -0,0 +1,124 @@
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Microsoft.CodeAnalysis.Text;
+
+namespace PixiEditor.ChangeableDocument.Gen;
+[Generator]
+public class UpdateableChangeActionGenerator : IIncrementalGenerator
+{
+    private const string AttributesNamespace = "PixiEditor.ChangeableDocument.Actions.Attributes";
+    private const string ConstructorAttribute = "GenerateUpdateableChangeActionsAttribute";
+    private const string UpdateMethodAttribute = "UpdateChangeMethodAttribute";
+    private static NamespacedType ConstructorAttributeType = new NamespacedType(ConstructorAttribute, AttributesNamespace);
+    private static NamespacedType UpdateMethodAttributeType = new NamespacedType(UpdateMethodAttribute, AttributesNamespace);
+
+    private static Result<(IMethodSymbol, IMethodSymbol, ClassDeclarationSyntax)>? TransformSyntax
+        (GeneratorSyntaxContext context, CancellationToken cancelToken)
+    {
+        ClassDeclarationSyntax containingClass;
+        ConstructorDeclarationSyntax constructorSyntax;
+        // make sure we are actually working with a constructor
+        if (context.Node is ConstructorDeclarationSyntax constructor)
+        {
+            if (!Helpers.MethodHasAttribute(context, cancelToken, constructor, ConstructorAttributeType))
+                return null;
+            containingClass = (ClassDeclarationSyntax)constructor.Parent!;
+            constructorSyntax = constructor;
+        }
+        else
+        {
+            return null;
+        }
+
+        var classSymbol = (INamedTypeSymbol)context.SemanticModel.GetDeclaredSymbol(containingClass)!;
+        if (!Helpers.IsInheritedFrom(classSymbol, new("UpdateableChange", "PixiEditor.ChangeableDocument.Changes")))
+        {
+            return Result<(IMethodSymbol, IMethodSymbol, ClassDeclarationSyntax)>.Error
+                ("The GenerateUpdateableChangeActions and UpdateChangeMethodAttribute can only be used inside UpdateableChanges", containingClass.SyntaxTree, containingClass.Span);
+        }
+
+        // here we are sure we are inside an updateable change, time to find the update method
+        MethodDeclarationSyntax? methodSyntax = null;
+        var members = containingClass.Members.Where(node => node is MethodDeclarationSyntax).ToList();
+        const string errorMessage = $"Update method isn't marked with {UpdateMethodAttribute}";
+        if (!members.Any())
+            return Result<(IMethodSymbol, IMethodSymbol, ClassDeclarationSyntax)>.Error
+                (errorMessage, containingClass.SyntaxTree, containingClass.Span);
+        foreach (var member in members)
+        {
+            cancelToken.ThrowIfCancellationRequested();
+            var method = (MethodDeclarationSyntax)member;
+            bool hasAttr = Helpers.MethodHasAttribute(context, cancelToken, method, UpdateMethodAttributeType);
+            if (hasAttr)
+            {
+                methodSyntax = method;
+                break;
+            }
+        }
+        if (methodSyntax is null)
+        {
+            return Result<(IMethodSymbol, IMethodSymbol, ClassDeclarationSyntax)>.Error
+                (errorMessage, containingClass.SyntaxTree, containingClass.Span);
+        }
+
+        // finally, get symbols
+        var methodSymbol = context.SemanticModel.GetDeclaredSymbol(methodSyntax, cancelToken);
+        var constructorSymbol = context.SemanticModel.GetDeclaredSymbol(constructorSyntax, cancelToken);
+        if (constructorSymbol is not IMethodSymbol || methodSymbol is not IMethodSymbol)
+            return null;
+        return ((IMethodSymbol)constructorSymbol, (IMethodSymbol)methodSymbol, containingClass);
+    }
+
+    private static Result<(NamedSourceCode, NamedSourceCode)> GenerateActions
+        (Result<(IMethodSymbol, IMethodSymbol, ClassDeclarationSyntax)>? prevResult, CancellationToken cancelToken)
+    {
+        if (prevResult!.Value.ErrorText is not null)
+            return Result<(NamedSourceCode, NamedSourceCode)>.Error
+                (prevResult.Value.ErrorText, prevResult.Value.SyntaxTree!, (TextSpan)prevResult.Value.Span!);
+        var (constructor, update, containingClass) = prevResult.Value.Value;
+
+        var constructorInfo = Helpers.ExtractMethodInfo(constructor!);
+        var updateInfo = Helpers.ExtractMethodInfo(update!);
+
+        var maybeStartUpdateAction = Helpers.CreateStartUpdateChangeAction(constructorInfo, updateInfo, containingClass);
+        if (maybeStartUpdateAction.ErrorText is not null)
+            return Result<(NamedSourceCode, NamedSourceCode)>.Error
+                (maybeStartUpdateAction.ErrorText, maybeStartUpdateAction.SyntaxTree!, (TextSpan)maybeStartUpdateAction.Span!);
+
+        var endAction = Helpers.CreateEndChangeAction(constructorInfo);
+
+        return (
+            new NamedSourceCode(constructorInfo.ContainingClass.Name + "StartUpdate", maybeStartUpdateAction.Value!),
+            new NamedSourceCode(constructorInfo.ContainingClass.Name + "End", endAction)
+            );
+    }
+
+    public void Initialize(IncrementalGeneratorInitializationContext context)
+    {
+        // find the contrustor and the update method using the attributes
+        var constructorSymbolProvider = context.SyntaxProvider.CreateSyntaxProvider(
+            predicate: static (node, token) => Helpers.IsMethodWithAttribute(node, token) || Helpers.IsConstructorWithAttribute(node, token),
+            transform: TransformSyntax
+        ).Where(a => a is not null);
+
+        // generate the source code of actions
+        var actionSourceCodeProvider = constructorSymbolProvider.Select(GenerateActions);
+
+        // add the source code into compiler input
+        context.RegisterSourceOutput(actionSourceCodeProvider, static (context, namedActions) =>
+        {
+            if (namedActions.ErrorText is not null)
+            {
+                context.ReportDiagnostic(
+                    Diagnostic.Create(
+                        new DiagnosticDescriptor("AGErr", "", namedActions.ErrorText, "UpdateableActionGenerator", DiagnosticSeverity.Error, true),
+                        Location.Create(namedActions.SyntaxTree!, (TextSpan)namedActions.Span!)));
+                return;
+            }
+
+            var (act1, act2) = namedActions.Value;
+            context.AddSource(act1.Name, act1.Code);
+            context.AddSource(act2.Name, act2.Code);
+        });
+    }
+}

+ 3 - 0
src/PixiEditor.ChangeableDocument/Actions/Attributes/GenerateMakeChangeActionAttribute.cs

@@ -0,0 +1,3 @@
+namespace PixiEditor.ChangeableDocument.Actions.Attributes;
+[AttributeUsage(AttributeTargets.All, Inherited = false, AllowMultiple = false)]
+internal sealed class GenerateMakeChangeActionAttribute : Attribute { }

+ 3 - 0
src/PixiEditor.ChangeableDocument/Actions/Attributes/GenerateUpdateableChangeActionsAttribute.cs

@@ -0,0 +1,3 @@
+namespace PixiEditor.ChangeableDocument.Actions.Attributes;
+[AttributeUsage(AttributeTargets.All, Inherited = false, AllowMultiple = false)]
+internal sealed class GenerateUpdateableChangeActionsAttribute : Attribute { }

+ 3 - 0
src/PixiEditor.ChangeableDocument/Actions/Attributes/UpdateChangeMethodAttribute.cs

@@ -0,0 +1,3 @@
+namespace PixiEditor.ChangeableDocument.Actions.Attributes;
+[AttributeUsage(AttributeTargets.All, Inherited = false, AllowMultiple = false)]
+internal sealed class UpdateChangeMethodAttribute : Attribute { }

+ 5 - 0
src/PixiEditor.ChangeableDocument/Actions/IAction.cs

@@ -0,0 +1,5 @@
+namespace PixiEditor.ChangeableDocument.Actions;
+
+public interface IAction
+{
+}

+ 8 - 0
src/PixiEditor.ChangeableDocument/Actions/IEndChangeAction.cs

@@ -0,0 +1,8 @@
+using PixiEditor.ChangeableDocument.Changes;
+
+namespace PixiEditor.ChangeableDocument.Actions;
+
+internal interface IEndChangeAction : IAction
+{
+    bool IsChangeTypeMatching(Change change);
+}

+ 8 - 0
src/PixiEditor.ChangeableDocument/Actions/IMakeChangeAction.cs

@@ -0,0 +1,8 @@
+using PixiEditor.ChangeableDocument.Changes;
+
+namespace PixiEditor.ChangeableDocument.Actions;
+
+internal interface IMakeChangeAction : IAction
+{
+    Change CreateCorrespondingChange();
+}

+ 10 - 0
src/PixiEditor.ChangeableDocument/Actions/IStartOrUpdateChangeAction.cs

@@ -0,0 +1,10 @@
+using PixiEditor.ChangeableDocument.Changes;
+
+namespace PixiEditor.ChangeableDocument.Actions;
+
+internal interface IStartOrUpdateChangeAction : IAction
+{
+    bool IsChangeTypeMatching(Change change);
+    void UpdateCorrespodingChange(UpdateableChange change);
+    UpdateableChange CreateCorrespondingChange();
+}

+ 3 - 0
src/PixiEditor.ChangeableDocument/Actions/Undo/ChangeBoundary_Action.cs

@@ -0,0 +1,3 @@
+namespace PixiEditor.ChangeableDocument.Actions.Undo;
+
+public record class ChangeBoundary_Action : IAction;

+ 3 - 0
src/PixiEditor.ChangeableDocument/Actions/Undo/DeleteRecordedChanges_Action.cs

@@ -0,0 +1,3 @@
+namespace PixiEditor.ChangeableDocument.Actions.Undo;
+
+public record class DeleteRecordedChanges_Action : IAction;

+ 3 - 0
src/PixiEditor.ChangeableDocument/Actions/Undo/Redo_Action.cs

@@ -0,0 +1,3 @@
+namespace PixiEditor.ChangeableDocument.Actions.Undo;
+
+public record class Redo_Action : IAction;

+ 3 - 0
src/PixiEditor.ChangeableDocument/Actions/Undo/Undo_Action.cs

@@ -0,0 +1,3 @@
+namespace PixiEditor.ChangeableDocument.Actions.Undo;
+
+public record class Undo_Action : IAction;

+ 3 - 0
src/PixiEditor.ChangeableDocument/ChangeInfos/Drawing/LayerImageChunks_ChangeInfo.cs

@@ -0,0 +1,3 @@
+namespace PixiEditor.ChangeableDocument.ChangeInfos.Drawing;
+
+public record class LayerImageChunks_ChangeInfo(Guid GuidValue, HashSet<VecI> Chunks) : IChangeInfo;

+ 3 - 0
src/PixiEditor.ChangeableDocument/ChangeInfos/Drawing/MaskChunks_ChangeInfo.cs

@@ -0,0 +1,3 @@
+namespace PixiEditor.ChangeableDocument.ChangeInfos.Drawing;
+
+public record class MaskChunks_ChangeInfo(Guid GuidValue, HashSet<VecI> Chunks) : IChangeInfo;

+ 5 - 0
src/PixiEditor.ChangeableDocument/ChangeInfos/Drawing/Selection_ChangeInfo.cs

@@ -0,0 +1,5 @@
+using SkiaSharp;
+
+namespace PixiEditor.ChangeableDocument.ChangeInfos.Drawing;
+
+public record class Selection_ChangeInfo(SKPath NewPath) : IChangeInfo;

+ 5 - 0
src/PixiEditor.ChangeableDocument/ChangeInfos/IChangeInfo.cs

@@ -0,0 +1,5 @@
+namespace PixiEditor.ChangeableDocument.ChangeInfos;
+
+public interface IChangeInfo
+{
+}

+ 2 - 0
src/PixiEditor.ChangeableDocument/ChangeInfos/Properties/LayerLockTransparency_ChangeInfo.cs

@@ -0,0 +1,2 @@
+namespace PixiEditor.ChangeableDocument.ChangeInfos.Properties;
+public record class LayerLockTransparency_ChangeInfo(Guid GuidValue, bool LockTransparency) : IChangeInfo;

+ 4 - 0
src/PixiEditor.ChangeableDocument/ChangeInfos/Properties/StructureMemberBlendMode_ChangeInfo.cs

@@ -0,0 +1,4 @@
+using PixiEditor.ChangeableDocument.Enums;
+
+namespace PixiEditor.ChangeableDocument.ChangeInfos.Properties;
+public record class StructureMemberBlendMode_ChangeInfo(Guid GuidValue, BlendMode BlendMode) : IChangeInfo;

+ 2 - 0
src/PixiEditor.ChangeableDocument/ChangeInfos/Properties/StructureMemberClipToMemberBelow_ChangeInfo.cs

@@ -0,0 +1,2 @@
+namespace PixiEditor.ChangeableDocument.ChangeInfos.Properties;
+public record class StructureMemberClipToMemberBelow_ChangeInfo(Guid GuidValue, bool ClipToMemberBelow) : IChangeInfo;

+ 3 - 0
src/PixiEditor.ChangeableDocument/ChangeInfos/Properties/StructureMemberIsVisible_ChangeInfo.cs

@@ -0,0 +1,3 @@
+namespace PixiEditor.ChangeableDocument.ChangeInfos.Properties;
+
+public record class StructureMemberIsVisible_ChangeInfo(Guid GuidValue, bool IsVisible) : IChangeInfo;

+ 2 - 0
src/PixiEditor.ChangeableDocument/ChangeInfos/Properties/StructureMemberMaskIsVisible_ChangeInfo.cs

@@ -0,0 +1,2 @@
+namespace PixiEditor.ChangeableDocument.ChangeInfos.Properties;
+public record class StructureMemberMaskIsVisible_ChangeInfo(Guid GuidValue, bool IsVisible) : IChangeInfo;

+ 3 - 0
src/PixiEditor.ChangeableDocument/ChangeInfos/Properties/StructureMemberMask_ChangeInfo.cs

@@ -0,0 +1,3 @@
+namespace PixiEditor.ChangeableDocument.ChangeInfos.Properties;
+
+public record class StructureMemberMask_ChangeInfo(Guid GuidValue, bool HasMask) : IChangeInfo;

+ 3 - 0
src/PixiEditor.ChangeableDocument/ChangeInfos/Properties/StructureMemberName_ChangeInfo.cs

@@ -0,0 +1,3 @@
+namespace PixiEditor.ChangeableDocument.ChangeInfos.Properties;
+
+public record class StructureMemberName_ChangeInfo(Guid GuidValue, string Name) : IChangeInfo;

+ 3 - 0
src/PixiEditor.ChangeableDocument/ChangeInfos/Properties/StructureMemberOpacity_ChangeInfo.cs

@@ -0,0 +1,3 @@
+namespace PixiEditor.ChangeableDocument.ChangeInfos.Properties;
+
+public record class StructureMemberOpacity_ChangeInfo(Guid GuidValue, float Opacity) : IChangeInfo;

+ 3 - 0
src/PixiEditor.ChangeableDocument/ChangeInfos/Root/Size_ChangeInfo.cs

@@ -0,0 +1,3 @@
+namespace PixiEditor.ChangeableDocument.ChangeInfos.Root;
+
+public record class Size_ChangeInfo(VecI Size, int VerticalSymmetryAxisX, int HorizontalSymmetryAxisY) : IChangeInfo;

+ 4 - 0
src/PixiEditor.ChangeableDocument/ChangeInfos/Root/SymmetryAxisPosition_ChangeInfo.cs

@@ -0,0 +1,4 @@
+using PixiEditor.ChangeableDocument.Enums;
+
+namespace PixiEditor.ChangeableDocument.ChangeInfos.Root;
+public record class SymmetryAxisPosition_ChangeInfo(SymmetryAxisDirection Direction, int NewPosition) : IChangeInfo;

+ 4 - 0
src/PixiEditor.ChangeableDocument/ChangeInfos/Root/SymmetryAxisState_ChangeInfo.cs

@@ -0,0 +1,4 @@
+using PixiEditor.ChangeableDocument.Enums;
+
+namespace PixiEditor.ChangeableDocument.ChangeInfos.Root;
+public record class SymmetryAxisState_ChangeInfo(SymmetryAxisDirection Direction, bool State) : IChangeInfo;

+ 53 - 0
src/PixiEditor.ChangeableDocument/ChangeInfos/Structure/CreateFolder_ChangeInfo.cs

@@ -0,0 +1,53 @@
+using System.Collections.Immutable;
+using PixiEditor.ChangeableDocument.Enums;
+
+namespace PixiEditor.ChangeableDocument.ChangeInfos.Structure;
+public record class CreateFolder_ChangeInfo : CreateStructureMember_ChangeInfo
+{
+    public CreateFolder_ChangeInfo(
+        Guid parentGuid,
+        int index,
+        float opacity,
+        bool isVisible,
+        bool clipToMemberBelow,
+        string name,
+        BlendMode blendMode,
+        Guid guidValue,
+        bool hasMask,
+        bool maskIsVisible,
+        ImmutableList<CreateStructureMember_ChangeInfo> children) : base(parentGuid, index, opacity, isVisible, clipToMemberBelow, name, blendMode, guidValue, hasMask, maskIsVisible)
+    {
+        Children = children;
+    }
+
+    public ImmutableList<CreateStructureMember_ChangeInfo> Children { get; }
+
+    internal static CreateFolder_ChangeInfo FromFolder(Guid parentGuid, int index, Folder folder)
+    {
+        var builder = ImmutableList.CreateBuilder<CreateStructureMember_ChangeInfo>();
+        for (int i = 0; i < folder.Children.Count; i++)
+        {
+            var child = folder.Children[i];
+            CreateStructureMember_ChangeInfo info = child switch
+            {
+                Folder innerFolder => CreateFolder_ChangeInfo.FromFolder(folder.GuidValue, i, innerFolder),
+                Layer innerLayer => CreateLayer_ChangeInfo.FromLayer(folder.GuidValue, i, innerLayer),
+                _ => throw new NotSupportedException(),
+            };
+            builder.Add(info);
+        }
+        return new CreateFolder_ChangeInfo(
+            parentGuid,
+            index,
+            folder.Opacity,
+            folder.IsVisible,
+            folder.ClipToMemberBelow,
+            folder.Name,
+            folder.BlendMode,
+            folder.GuidValue,
+            folder.Mask is not null,
+            folder.MaskIsVisible,
+            builder.ToImmutable()
+            );
+    }
+}

+ 40 - 0
src/PixiEditor.ChangeableDocument/ChangeInfos/Structure/CreateLayer_ChangeInfo.cs

@@ -0,0 +1,40 @@
+using PixiEditor.ChangeableDocument.Enums;
+
+namespace PixiEditor.ChangeableDocument.ChangeInfos.Structure;
+public record class CreateLayer_ChangeInfo : CreateStructureMember_ChangeInfo
+{
+    public CreateLayer_ChangeInfo(
+        Guid parentGuid,
+        int index,
+        float opacity,
+        bool isVisible,
+        bool clipToMemberBelow,
+        string name,
+        BlendMode blendMode,
+        Guid guidValue,
+        bool hasMask,
+        bool maskIsVisible,
+        bool lockTransparency) : base(parentGuid, index, opacity, isVisible, clipToMemberBelow, name, blendMode, guidValue, hasMask, maskIsVisible)
+    {
+        LockTransparency = lockTransparency;
+    }
+
+    public bool LockTransparency { get; }
+
+    internal static CreateLayer_ChangeInfo FromLayer(Guid parentGuid, int index, Layer layer)
+    {
+        return new CreateLayer_ChangeInfo(
+            parentGuid,
+            index,
+            layer.Opacity,
+            layer.IsVisible,
+            layer.ClipToMemberBelow,
+            layer.Name,
+            layer.BlendMode,
+            layer.GuidValue,
+            layer.Mask is not null,
+            layer.MaskIsVisible,
+            layer.LockTransparency
+            );
+    }
+}

+ 3 - 0
src/PixiEditor.ChangeableDocument/ChangeInfos/Structure/CreateReferenceLayer_ChangeInfo.cs

@@ -0,0 +1,3 @@
+namespace PixiEditor.ChangeableDocument.ChangeInfos.Structure;
+
+public record class CreateReferenceLayer_ChangeInfo(bool ShapeOnly) : IChangeInfo;

+ 16 - 0
src/PixiEditor.ChangeableDocument/ChangeInfos/Structure/CreateStructureMember_ChangeInfo.cs

@@ -0,0 +1,16 @@
+using PixiEditor.ChangeableDocument.Enums;
+
+namespace PixiEditor.ChangeableDocument.ChangeInfos.Structure;
+
+public abstract record class CreateStructureMember_ChangeInfo(
+    Guid ParentGuid,
+    int Index,
+    float Opacity,
+    bool IsVisible,
+    bool ClipToMemberBelow,
+    string Name,
+    BlendMode BlendMode,
+    Guid GuidValue,
+    bool HasMask,
+    bool MaskIsVisible
+) : IChangeInfo;

+ 3 - 0
src/PixiEditor.ChangeableDocument/ChangeInfos/Structure/DeleteStructureMember_ChangeInfo.cs

@@ -0,0 +1,3 @@
+namespace PixiEditor.ChangeableDocument.ChangeInfos.Structure;
+
+public record class DeleteStructureMember_ChangeInfo(Guid GuidValue, Guid ParentGuid) : IChangeInfo;

+ 3 - 0
src/PixiEditor.ChangeableDocument/ChangeInfos/Structure/MoveStructureMember_ChangeInfo.cs

@@ -0,0 +1,3 @@
+namespace PixiEditor.ChangeableDocument.ChangeInfos.Structure;
+
+public record class MoveStructureMember_ChangeInfo(Guid GuidValue, Guid ParentFromGuid, Guid ParentToGuid, int NewIndex) : IChangeInfo;

+ 3 - 0
src/PixiEditor.ChangeableDocument/ChangeInfos/Structure/TransformReferenceLayer_ChangeInfo.cs

@@ -0,0 +1,3 @@
+namespace PixiEditor.ChangeableDocument.ChangeInfos.Structure;
+
+public record class TransformReferenceLayer_ChangeInfo(ShapeCorners Shape) : IChangeInfo;

+ 215 - 0
src/PixiEditor.ChangeableDocument/Changeables/Document.cs

@@ -0,0 +1,215 @@
+using System.Diagnostics.CodeAnalysis;
+using PixiEditor.ChangeableDocument.Changeables.Interfaces;
+
+namespace PixiEditor.ChangeableDocument.Changeables;
+
+internal class Document : IChangeable, IReadOnlyDocument, IDisposable
+{
+    IReadOnlyFolder IReadOnlyDocument.StructureRoot => StructureRoot;
+    IReadOnlySelection IReadOnlyDocument.Selection => Selection;
+    IReadOnlyStructureMember? IReadOnlyDocument.FindMember(Guid guid) => FindMember(guid);
+    bool IReadOnlyDocument.TryFindMember(Guid guid, [NotNullWhen(true)] out IReadOnlyStructureMember? member) => TryFindMember(guid, out member);
+    IReadOnlyList<IReadOnlyStructureMember> IReadOnlyDocument.FindMemberPath(Guid guid) => FindMemberPath(guid);
+    IReadOnlyStructureMember IReadOnlyDocument.FindMemberOrThrow(Guid guid) => FindMemberOrThrow(guid);
+    (IReadOnlyStructureMember, IReadOnlyFolder) IReadOnlyDocument.FindChildAndParentOrThrow(Guid guid) => FindChildAndParentOrThrow(guid);
+
+    IReadOnlyReferenceLayer? IReadOnlyDocument.ReferenceLayer => ReferenceLayer;
+    /// <summary>
+    /// The default size for a new document
+    /// </summary>
+
+    public static VecI DefaultSize { get; } = new VecI(64, 64);
+    internal Folder StructureRoot { get; } = new() { GuidValue = Guid.Empty };
+    internal Selection Selection { get; } = new();
+    internal ReferenceLayer? ReferenceLayer { get; set; }
+    public VecI Size { get; set; } = DefaultSize;
+    public bool HorizontalSymmetryAxisEnabled { get; set; }
+    public bool VerticalSymmetryAxisEnabled { get; set; }
+    public int HorizontalSymmetryAxisY { get; set; }
+    public int VerticalSymmetryAxisX { get; set; }
+
+    public void Dispose()
+    {
+        StructureRoot.Dispose();
+        Selection.Dispose();
+    }
+    
+    public void ForEveryReadonlyMember(Action<IReadOnlyStructureMember> action) => ForEveryReadonlyMember(StructureRoot, action);
+    /// <summary>
+    /// Performs the specified action on each member of the document
+    /// </summary>
+    public void ForEveryMember(Action<StructureMember> action) => ForEveryMember(StructureRoot, action);
+
+    private void ForEveryReadonlyMember(IReadOnlyFolder folder, Action<IReadOnlyStructureMember> action)
+    {
+        foreach (var child in folder.Children)
+        {
+            action(child);
+            if (child is IReadOnlyFolder innerFolder)
+                ForEveryReadonlyMember(innerFolder, action);
+        }
+    }
+
+    private void ForEveryMember(Folder folder, Action<StructureMember> action)
+    {
+        foreach (var child in folder.Children)
+        {
+            action(child);
+            if (child is Folder innerFolder)
+                ForEveryMember(innerFolder, action);
+        }
+    }
+
+    /// <summary>
+    /// Checks if a member with the <paramref name="guid"/> exists
+    /// </summary>
+    /// <param name="guid">The <see cref="StructureMember.GuidValue"/> of the member</param>
+    /// <returns>True if the member can be found, otherwise false</returns>
+    public bool HasMember(Guid guid)
+    {
+        var list = FindMemberPath(guid);
+        return list.Count > 0;
+    }
+
+    /// <summary>
+    /// Checks if a member with the <paramref name="guid"/> exists and is of type <typeparamref name="T"/>
+    /// </summary>
+    /// <param name="guid">The <see cref="StructureMember.GuidValue"/> of the member</param>
+    /// <returns>True if the member can be found and is of type <typeparamref name="T"/>, otherwise false</returns>
+    public bool HasMember<T>(Guid guid) where T : StructureMember
+    {
+        var list = FindMemberPath(guid);
+        return list.Count > 0 && list[0] is T;
+    }
+    
+    /// <summary>
+    /// Finds the member with the <paramref name="guid"/> or throws a ArgumentException if not found
+    /// </summary>
+    /// <param name="guid">The <see cref="StructureMember.GuidValue"/> of the member</param>
+    /// <exception cref="ArgumentException">Thrown if the member could not be found</exception>
+    public StructureMember FindMemberOrThrow(Guid guid) => FindMember(guid) ?? throw new ArgumentException($"Could not find member with guid '{guid}'");
+
+    /// <summary>
+    /// Finds the member of type <typeparamref name="T"/> with the <paramref name="guid"/> or throws an exception
+    /// </summary>
+    /// <param name="guid">The <see cref="StructureMember.GuidValue"/> of the member</param>
+    /// <exception cref="ArgumentException">Thrown if the member could not be found</exception>
+    /// <exception cref="InvalidCastException">Thrown if the member is not of type <typeparamref name="T"/></exception>
+    public T FindMemberOrThrow<T>(Guid guid) where T : StructureMember => (T)FindMember(guid)!;
+
+    /// <summary>
+    /// Finds the member with the <paramref name="guid"/> or returns null if not found
+    /// </summary>
+    /// <param name="guid">The <see cref="StructureMember.GuidValue"/> of the member</param>
+    public StructureMember? FindMember(Guid guid)
+    {
+        var list = FindMemberPath(guid);
+        return list.Count > 0 ? list[0] : null;
+    }
+
+    /// <summary>
+    /// Tries finding the member with the <paramref name="guid"/> and returns true if it was found
+    /// </summary>
+    /// <param name="guid">The <see cref="StructureMember.GuidValue"/> of the <paramref name="member"/></param>
+    /// <param name="member">The member</param>
+    /// <returns>True if the member could be found, otherwise false</returns>
+    public bool TryFindMember(Guid guid, [NotNullWhen(true)] out StructureMember? member)
+    {
+        var list = FindMemberPath(guid);
+        if (list.Count == 0)
+        {
+            member = null;
+            return false;
+        }
+
+        member = list[0];
+        return true;
+    }
+
+    /// <summary>
+    /// Tries finding the member with the <paramref name="guid"/> of type <typeparamref name="T"/> and returns true if it was found
+    /// </summary>
+    /// <param name="guid">The <see cref="StructureMember.GuidValue"/> of the <paramref name="member"/></param>
+    /// <param name="member">The member</param>
+    /// <typeparam name="T">The type of the <see cref="StructureMember"/></typeparam>
+    /// <returns>True if the member could be found and is of type <typeparamref name="T"/>, otherwise false</returns>
+    public bool TryFindMember<T>(Guid guid, [NotNullWhen(true)] out T? member) where T : IReadOnlyStructureMember
+    {
+        if (!TryFindMember(guid, out var structureMember) || structureMember is not T cast)
+        {
+            member = default;
+            return false;
+        }
+
+        member = cast;
+        return false;
+    }
+
+    /// <summary>
+    /// Finds a member with the <paramref name="childGuid"/>  and its parent, throws a ArgumentException if they can't be found
+    /// </summary>
+    /// <param name="childGuid">The <see cref="StructureMember.GuidValue"/> of the member</param>
+    /// <returns>A value tuple consisting of child (<see cref="ValueTuple{T, T}.Item1"/>) and parent (<see cref="ValueTuple{T, T}.Item2"/>)</returns>
+    /// <exception cref="ArgumentException">Thrown if the member and parent could not be found</exception>
+    public (StructureMember, Folder) FindChildAndParentOrThrow(Guid childGuid)
+    {
+        var path = FindMemberPath(childGuid);
+        if (path.Count < 2)
+            throw new ArgumentException("Couldn't find child and parent");
+        return (path[0], (Folder)path[1]);
+    }
+
+    /// <summary>
+    /// Finds a member with the <paramref name="childGuid"/> and its parent
+    /// </summary>
+    /// <param name="childGuid">The <see cref="StructureMember.GuidValue"/> of the member</param>
+    /// <returns>A value tuple consisting of child (<see cref="ValueTuple{T, T}.Item1"/>) and parent (<see cref="ValueTuple{T, T}.Item2"/>)<para>Child and parent can be null if not found!</para></returns>
+    public (StructureMember?, Folder?) FindChildAndParent(Guid childGuid)
+    {
+        var path = FindMemberPath(childGuid);
+        return path.Count switch
+        {
+            1 => (path[0], null),
+            > 1 => (path[0], (Folder)path[1]),
+            _ => (null, null),
+        };
+    }
+
+    /// <summary>
+    /// Finds the path to the member with <paramref name="guid"/>, the first element will be the member
+    /// </summary>
+    /// <param name="guid">The <see cref="StructureMember.GuidValue"/> of the member</param>
+    public List<StructureMember> FindMemberPath(Guid guid)
+    {
+        var list = new List<StructureMember>();
+        if (FillMemberPath(StructureRoot, guid, list))
+            list.Add(StructureRoot);
+        return list;
+    }
+
+    private bool FillMemberPath(Folder folder, Guid guid, List<StructureMember> toFill)
+    {
+        if (folder.GuidValue == guid)
+        {
+            return true;
+        }
+
+        foreach (var member in folder.Children)
+        {
+            if (member is Layer childLayer && childLayer.GuidValue == guid)
+            {
+                toFill.Add(member);
+                return true;
+            }
+            if (member is Folder childFolder)
+            {
+                if (FillMemberPath(childFolder, guid, toFill))
+                {
+                    toFill.Add(childFolder);
+                    return true;
+                }
+            }
+        }
+        return false;
+    }
+}

+ 52 - 0
src/PixiEditor.ChangeableDocument/Changeables/Folder.cs

@@ -0,0 +1,52 @@
+using System.Collections.Immutable;
+using PixiEditor.ChangeableDocument.Changeables.Interfaces;
+
+namespace PixiEditor.ChangeableDocument.Changeables;
+
+internal class Folder : StructureMember, IReadOnlyFolder
+{
+    // Don't forget to update CreateFolder_ChangeInfo, DocumentUpdater.ProcessCreateStructureMember, and Folder.Clone when adding new properties
+    /// <summary>
+    /// The children of the folder
+    /// </summary>
+    public ImmutableList<StructureMember> Children { get; set; } = ImmutableList<StructureMember>.Empty;
+    IReadOnlyList<IReadOnlyStructureMember> IReadOnlyFolder.Children => Children;
+
+    /// <summary>
+    /// Creates a clone of the folder, its mask and all of its children
+    /// </summary>
+    internal override Folder Clone()
+    {
+        var builder = ImmutableList<StructureMember>.Empty.ToBuilder();
+        for (var i = 0; i < Children.Count; i++)
+        {
+            var child = Children[i];
+            builder.Add(child.Clone());
+        }
+
+        return new Folder
+        {
+            GuidValue = GuidValue,
+            IsVisible = IsVisible,
+            Name = Name,
+            Opacity = Opacity,
+            Children = builder.ToImmutable(),
+            Mask = Mask?.CloneFromCommitted(),
+            BlendMode = BlendMode,
+            ClipToMemberBelow = ClipToMemberBelow,
+            MaskIsVisible = MaskIsVisible
+        };
+    }
+
+    /// <summary>
+    /// Disposes all children and the mask
+    /// </summary>
+    public override void Dispose()
+    {
+        foreach (var child in Children)
+        {
+            child.Dispose();
+        }
+        Mask?.Dispose();
+    }
+}

部分文件因为文件数量过多而无法显示